├── pythonosc ├── py.typed ├── __init__.py ├── test │ ├── __init__.py │ ├── parsing │ │ ├── __init__.py │ │ ├── test_ntp.py │ │ └── test_osc_types.py │ ├── test_osc_bundle_builder.py │ ├── test_udp_client.py │ ├── test_osc_packet.py │ ├── test_tcp_client.py │ ├── test_osc_message_builder.py │ ├── test_osc_bundle.py │ ├── test_osc_server.py │ ├── test_dispatcher.py │ ├── test_osc_message.py │ └── test_osc_tcp_server.py ├── parsing │ ├── __init__.py │ ├── ntp.py │ └── osc_types.py ├── osc_bundle_builder.py ├── osc_packet.py ├── slip.py ├── osc_bundle.py ├── udp_client.py ├── osc_message.py ├── osc_server.py ├── osc_message_builder.py ├── tcp_client.py ├── osc_tcp_server.py └── dispatcher.py ├── MANIFEST.in ├── CONTRIBUTING.md ├── docs ├── Makefile ├── index.rst ├── make.bat ├── client.rst ├── dispatcher.rst ├── server.rst └── conf.py ├── examples ├── simple_client.py ├── simple_echo_client.py ├── simple_echo_server.py ├── async_server.py ├── dispatcher.py ├── simple_server.py ├── simple_tcp_client.py ├── simple_tcp_server.py ├── async_tcp_server.py ├── async_simple_tcp_client.py └── simple_2way.py ├── scripts └── print_datagrams_main.py ├── .gitignore ├── .github └── workflows │ ├── publish-pypi.yml │ └── python-test.yml ├── .readthedocs.yaml ├── LICENSE.txt ├── CHANGELOG.md ├── pyproject.toml └── README.rst /pythonosc/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonosc/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonosc/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonosc/parsing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonosc/test/parsing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE.txt 3 | include pythonosc/py.typed 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Thanks for contributing to this package! 2 | 3 | Before sending a PR, please make sure you checked the [python test workflow](.github/workflows/python-test.yml) and ran it locally, either using [act](https://nektosact.com) or by executing the workflow actions yourself. 4 | 5 | TL;DR: 6 | - Format all code with Black 7 | - Provide type annotations with mypy 8 | - Write and run tests with pytest 9 | - If you're adding a new feature, mention it in the [CHANGELOG.md](CHANGELOG.md) file under the _Unreleased_ section 10 | 11 | Please only send the PR once all of the above is done, thanks! -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /pythonosc/test/parsing/test_ntp.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import time 3 | 4 | from pythonosc.parsing import ntp 5 | 6 | 7 | class TestNTP(unittest.TestCase): 8 | """TODO: Write real tests for this when I get time...""" 9 | 10 | def test_nto_to_system_time(self): 11 | unix_time = time.time() 12 | timestamp = ntp.system_time_to_ntp(unix_time) 13 | unix_time2 = ntp.ntp_to_system_time(timestamp) 14 | self.assertTrue(type(unix_time) is float) 15 | self.assertTrue(type(timestamp) is bytes) 16 | self.assertTrue(type(unix_time2) is float) 17 | self.assertAlmostEqual(unix_time, unix_time2, places=5) 18 | 19 | 20 | if __name__ == "__main__": 21 | unittest.main() 22 | -------------------------------------------------------------------------------- /examples/simple_client.py: -------------------------------------------------------------------------------- 1 | """Small example OSC client 2 | 3 | This program sends 10 random values between 0.0 and 1.0 to the /filter address, 4 | waiting for 1 seconds between each value. 5 | """ 6 | 7 | import argparse 8 | import random 9 | import time 10 | 11 | from pythonosc import udp_client 12 | 13 | if __name__ == "__main__": 14 | parser = argparse.ArgumentParser() 15 | parser.add_argument("--ip", default="127.0.0.1", help="The ip of the OSC server") 16 | parser.add_argument( 17 | "--port", type=int, default=5005, help="The port the OSC server is listening on" 18 | ) 19 | args = parser.parse_args() 20 | 21 | client = udp_client.SimpleUDPClient(args.ip, args.port) 22 | 23 | for x in range(10): 24 | client.send_message("/filter", random.random()) 25 | time.sleep(1) 26 | -------------------------------------------------------------------------------- /scripts/print_datagrams_main.py: -------------------------------------------------------------------------------- 1 | """A simple program that displays datagrams received on a port.""" 2 | 3 | import argparse 4 | import socket 5 | 6 | 7 | def main(): 8 | parser = argparse.ArgumentParser() 9 | parser.add_argument("--ip", default="127.0.0.1", help="The ip to listen on") 10 | parser.add_argument("--port", type=int, default=5005, help="The port to listen on") 11 | 12 | args = parser.parse_args() 13 | _PrintOscMessages(args.ip, args.port) 14 | 15 | 16 | def _PrintOscMessages(ip, port): 17 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 18 | sock.bind((ip, port)) 19 | print(f"Listening for UDP packets on {ip}:{port} ...") 20 | while True: 21 | data, _ = sock.recvfrom(1024) 22 | print(f"{data}") 23 | 24 | 25 | if __name__ == "__main__": 26 | main() 27 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. python-osc documentation master file, created by 2 | sphinx-quickstart on Tue Jan 8 15:29:10 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Python-osc - OSC server and client in pure python 7 | ==================================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | dispatcher 14 | client 15 | server 16 | 17 | 18 | Python-osc implements a server and client for Open Sound Control. 19 | It allows a python access to a versatile protocol used in many musical instruments, controller hardware and control applications. 20 | 21 | 22 | 23 | 24 | 25 | 26 | Indices and tables 27 | ================== 28 | 29 | * :ref:`genindex` 30 | * :ref:`modindex` 31 | * :ref:`search` 32 | -------------------------------------------------------------------------------- /examples/simple_echo_client.py: -------------------------------------------------------------------------------- 1 | """Small example OSC client 2 | 3 | This program sends 10 random values between 0.0 and 1.0 to the /filter address, 4 | waiting for 1 seconds between each value. 5 | """ 6 | 7 | import argparse 8 | import random 9 | import time 10 | 11 | from pythonosc import udp_client 12 | 13 | if __name__ == "__main__": 14 | parser = argparse.ArgumentParser() 15 | parser.add_argument("--ip", default="127.0.0.1", help="The ip of the OSC server") 16 | parser.add_argument( 17 | "--port", type=int, default=5005, help="The port the OSC server is listening on" 18 | ) 19 | args = parser.parse_args() 20 | 21 | client = udp_client.SimpleUDPClient(args.ip, args.port) 22 | 23 | for x in range(10): 24 | client.send_message("/filter", random.random()) 25 | reply = next(client.get_messages(2)) 26 | print(str(reply)) 27 | time.sleep(1) 28 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /examples/simple_echo_server.py: -------------------------------------------------------------------------------- 1 | """Small example OSC server 2 | 3 | This program listens to several addresses, and prints some information about 4 | received packets. 5 | """ 6 | 7 | import argparse 8 | import math 9 | 10 | from pythonosc.dispatcher import Dispatcher 11 | from pythonosc import osc_server 12 | 13 | 14 | def echo_handler(client_addr, unused_addr, args): 15 | print(unused_addr, args) 16 | return (unused_addr, args) 17 | 18 | 19 | if __name__ == "__main__": 20 | parser = argparse.ArgumentParser() 21 | parser.add_argument("--ip", default="127.0.0.1", help="The ip to listen on") 22 | parser.add_argument("--port", type=int, default=5005, help="The port to listen on") 23 | args = parser.parse_args() 24 | 25 | dispatcher = Dispatcher() 26 | dispatcher.set_default_handler(echo_handler, True) 27 | 28 | server = osc_server.ThreadingOSCUDPServer((args.ip, args.port), dispatcher) 29 | print(f"Serving on {server.server_address}") 30 | server.serve_forever() 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # act 57 | bin/ 58 | 59 | # PyBuilder 60 | target/ 61 | 62 | # PyCharm 63 | .idea/ 64 | -------------------------------------------------------------------------------- /.github/workflows/publish-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | on: push 3 | jobs: 4 | build-n-publish: 5 | name: Build and publish to PyPI 6 | runs-on: ubuntu-latest 7 | permissions: 8 | # IMPORTANT: this permission is mandatory for trusted publishing 9 | id-token: write 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: "3.x" 16 | - name: Install pypa/build 17 | run: >- 18 | python3 -m 19 | pip install 20 | build 21 | --user 22 | - name: Build a binary wheel and a source tarball 23 | run: >- 24 | python3 -m 25 | build 26 | --sdist 27 | --wheel 28 | --outdir dist/ 29 | . 30 | - name: Publish distribution 📦 to PyPI 31 | if: startsWith(github.ref, 'refs/tags') 32 | uses: pypa/gh-action-pypi-publish@release/v1 -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.12" 12 | # You can also specify other tool versions: 13 | # nodejs: "20" 14 | # rust: "1.70" 15 | # golang: "1.20" 16 | 17 | # Build documentation in the "docs/" directory with Sphinx 18 | sphinx: 19 | configuration: docs/conf.py 20 | # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs 21 | # builder: "dirhtml" 22 | # Fail on all warnings to avoid broken references 23 | # fail_on_warning: true 24 | 25 | # Optionally build your docs in additional formats such as PDF and ePub 26 | # formats: 27 | # - pdf 28 | # - epub 29 | 30 | # Optional but recommended, declare the Python requirements required 31 | # to build your documentation 32 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 33 | # python: 34 | # install: 35 | # - requirements: docs/requirements.txt 36 | -------------------------------------------------------------------------------- /examples/async_server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pythonosc.osc_server import AsyncIOOSCUDPServer 3 | from pythonosc.dispatcher import Dispatcher 4 | import asyncio 5 | 6 | 7 | def filter_handler(address, *args): 8 | print(f"{address}: {args}") 9 | 10 | 11 | dispatcher = Dispatcher() 12 | dispatcher.map("/filter", filter_handler) 13 | 14 | ip = "127.0.0.1" 15 | port = 1337 16 | 17 | 18 | async def loop(): 19 | """Example main loop that only runs for 10 iterations before finishing""" 20 | for i in range(10): 21 | print(f"Loop {i}") 22 | await asyncio.sleep(1) 23 | 24 | 25 | async def init_main(): 26 | server = AsyncIOOSCUDPServer((ip, port), dispatcher, asyncio.get_event_loop()) 27 | transport, protocol = ( 28 | await server.create_serve_endpoint() 29 | ) # Create datagram endpoint and start serving 30 | 31 | await loop() # Enter main loop of program 32 | 33 | transport.close() # Clean up serve endpoint 34 | 35 | 36 | if sys.version_info >= (3, 7): 37 | asyncio.run(init_main()) 38 | else: 39 | # TODO(python-upgrade): drop this once 3.6 is no longer supported 40 | event_loop = asyncio.get_event_loop() 41 | event_loop.run_until_complete(init_main()) 42 | event_loop.close() 43 | -------------------------------------------------------------------------------- /examples/dispatcher.py: -------------------------------------------------------------------------------- 1 | from pythonosc.dispatcher import Dispatcher 2 | from typing import List, Any 3 | from pythonosc.osc_server import BlockingOSCUDPServer 4 | from pythonosc.udp_client import SimpleUDPClient 5 | 6 | dispatcher = Dispatcher() 7 | 8 | 9 | def set_filter(address: str, *args: List[Any]) -> None: 10 | # We expect two float arguments 11 | if not len(args) == 2 or type(args[0]) is not float or type(args[1]) is not float: 12 | return 13 | 14 | # Check that address starts with filter 15 | if not address[:-1] == "/filter": # Cut off the last character 16 | return 17 | 18 | value1 = args[0] 19 | value2 = args[1] 20 | filterno = address[-1] 21 | print(f"Setting filter {filterno} values: {value1}, {value2}") 22 | 23 | 24 | dispatcher.map("/filter*", set_filter) # Map wildcard address to set_filter function 25 | 26 | # Set up server and client for testing 27 | 28 | server = BlockingOSCUDPServer(("127.0.0.1", 1337), dispatcher) 29 | client = SimpleUDPClient("127.0.0.1", 1337) 30 | 31 | # Send message and receive exactly one message (blocking) 32 | client.send_message("/filter1", [1.0, 2.0]) 33 | server.handle_request() 34 | 35 | client.send_message("/filter8", [6.0, -2.0]) 36 | server.handle_request() 37 | -------------------------------------------------------------------------------- /examples/simple_server.py: -------------------------------------------------------------------------------- 1 | """Small example OSC server 2 | 3 | This program listens to several addresses, and prints some information about 4 | received packets. 5 | """ 6 | 7 | import argparse 8 | import math 9 | 10 | from pythonosc.dispatcher import Dispatcher 11 | from pythonosc import osc_server 12 | 13 | 14 | def print_volume_handler(unused_addr, args, volume): 15 | print(f"[{args[0]}] ~ {volume}") 16 | 17 | 18 | def print_compute_handler(unused_addr, args, volume): 19 | try: 20 | print(f"[{args[0]}] ~ {args[1](volume)}") 21 | except ValueError: 22 | pass 23 | 24 | 25 | if __name__ == "__main__": 26 | parser = argparse.ArgumentParser() 27 | parser.add_argument("--ip", default="127.0.0.1", help="The ip to listen on") 28 | parser.add_argument("--port", type=int, default=5005, help="The port to listen on") 29 | args = parser.parse_args() 30 | 31 | dispatcher = Dispatcher() 32 | dispatcher.map("/filter", print) 33 | dispatcher.map("/volume", print_volume_handler, "Volume") 34 | dispatcher.map("/logvolume", print_compute_handler, "Log volume", math.log) 35 | 36 | server = osc_server.ThreadingOSCUDPServer((args.ip, args.port), dispatcher) 37 | print(f"Serving on {server.server_address}") 38 | server.serve_forever() 39 | -------------------------------------------------------------------------------- /examples/simple_tcp_client.py: -------------------------------------------------------------------------------- 1 | """Small example OSC client 2 | 3 | This program sends 10 random values between 0.0 and 1.0 to the /filter address, 4 | and listens for incoming messages for 1 second between each value. 5 | """ 6 | 7 | import argparse 8 | import random 9 | 10 | from pythonosc import tcp_client 11 | 12 | if __name__ == "__main__": 13 | parser = argparse.ArgumentParser() 14 | parser.add_argument("--ip", default="127.0.0.1", help="The ip of the OSC server") 15 | parser.add_argument( 16 | "--port", type=int, default=5005, help="The port the OSC server is listening on" 17 | ) 18 | parser.add_argument( 19 | "--mode", 20 | default="1.1", 21 | help="The OSC protocol version of the server (default is 1.1)", 22 | ) 23 | args = parser.parse_args() 24 | 25 | with tcp_client.SimpleTCPClient(args.ip, args.port, mode=args.mode) as client: 26 | for x in range(10): 27 | n = random.random() 28 | print(f"Sending /filter {n}") 29 | client.send_message("/filter", n) 30 | resp = client.get_messages(1) 31 | for r in resp: 32 | try: 33 | print(r) 34 | except Exception as e: 35 | print(f"oops {str(e)}: {r}") 36 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /examples/simple_tcp_server.py: -------------------------------------------------------------------------------- 1 | """Small example OSC server 2 | 3 | This program listens to the specified address and port, and prints some information about 4 | received packets. 5 | """ 6 | 7 | import argparse 8 | import math 9 | 10 | from pythonosc import osc_tcp_server 11 | from pythonosc.dispatcher import Dispatcher 12 | 13 | 14 | def print_volume_handler(unused_addr, args, volume): 15 | print(f"[{args[0]}] ~ {volume}") 16 | 17 | 18 | def print_compute_handler(unused_addr, args, volume): 19 | try: 20 | print(f"[{args[0]}] ~ {args[1](volume)}") 21 | except ValueError: 22 | pass 23 | 24 | 25 | if __name__ == "__main__": 26 | parser = argparse.ArgumentParser() 27 | parser.add_argument("--ip", default="127.0.0.1", help="The ip to listen on") 28 | parser.add_argument("--port", type=int, default=5005, help="The port to listen on") 29 | parser.add_argument( 30 | "--mode", 31 | default="1.1", 32 | help="The OSC protocol version of the server (default is 1.1)", 33 | ) 34 | 35 | args = parser.parse_args() 36 | 37 | dispatcher = Dispatcher() 38 | dispatcher.map("/filter", print) 39 | dispatcher.map("/volume", print_volume_handler, "Volume") 40 | dispatcher.map("/logvolume", print_compute_handler, "Log volume", math.log) 41 | 42 | server = osc_tcp_server.ThreadingOSCTCPServer( 43 | (args.ip, args.port), dispatcher, mode=args.mode 44 | ) 45 | print(f"Serving on {server.server_address}") 46 | server.serve_forever() 47 | -------------------------------------------------------------------------------- /docs/client.rst: -------------------------------------------------------------------------------- 1 | Client 2 | ======== 3 | 4 | The client allows you to connect and exchange messages with an OSC server. 5 | Client classes are available for UDP and TCP protocols. 6 | The base client class ``send`` method expects an :class:`OSCMessage` object, which is then sent out over TCP or UDP. 7 | Additionally, a simple client class exists that constructs the :class:`OSCMessage` object for you. 8 | 9 | See the examples folder for more use cases. 10 | 11 | Examples 12 | --------- 13 | 14 | .. code-block:: python 15 | 16 | from pythonosc.udp_client import SimpleUDPClient 17 | 18 | ip = "127.0.0.1" 19 | port = 1337 20 | 21 | client = SimpleUDPClient(ip, port) # Create client 22 | 23 | client.send_message("/some/address", 123) # Send float message 24 | client.send_message("/some/address", [1, 2., "hello"]) # Send message with int, float and string 25 | 26 | 27 | .. code-block:: python 28 | 29 | from pythonosc.tcp_client import SimpleTCPClient 30 | 31 | ip = "127.0.0.1" 32 | port = 1337 33 | 34 | client = SimpleTCPClient(ip, port) # Create client 35 | 36 | client.send_message("/some/address", 123) # Send float message 37 | client.send_message("/some/address", [1, 2., "hello"]) # Send message with int, float and string 38 | 39 | Client Module Documentation 40 | --------------------------------- 41 | 42 | .. automodule:: pythonosc.udp_client 43 | :special-members: 44 | :members: 45 | :exclude-members: __weakref__ 46 | 47 | .. automodule:: pythonosc.tcp_client 48 | :special-members: 49 | :members: 50 | :exclude-members: __weakref__ 51 | -------------------------------------------------------------------------------- /examples/async_tcp_server.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | import sys 4 | 5 | from pythonosc.dispatcher import Dispatcher 6 | from pythonosc.osc_tcp_server import AsyncOSCTCPServer 7 | 8 | 9 | def filter_handler(address, *args): 10 | print(f"{address}: {args}") 11 | 12 | 13 | dispatcher = Dispatcher() 14 | dispatcher.map("/filter", filter_handler) 15 | 16 | 17 | async def loop(): 18 | """Example main loop that only runs for 10 iterations before finishing""" 19 | for i in range(10): 20 | print(f"Loop {i}") 21 | await asyncio.sleep(10) 22 | 23 | 24 | async def init_main(): 25 | parser = argparse.ArgumentParser() 26 | parser.add_argument("--ip", default="127.0.0.1", help="The ip of the OSC server") 27 | parser.add_argument( 28 | "--port", type=int, default=5005, help="The port the OSC server is listening on" 29 | ) 30 | parser.add_argument( 31 | "--mode", 32 | default="1.1", 33 | help="The OSC protocol version of the server (default is 1.1)", 34 | ) 35 | args = parser.parse_args() 36 | 37 | async with AsyncOSCTCPServer( 38 | args.ip, args.port, dispatcher, mode=args.mode 39 | ) as server: 40 | async with asyncio.TaskGroup() as tg: 41 | tg.create_task(server.start()) 42 | tg.create_task(loop()) 43 | 44 | 45 | if sys.version_info >= (3, 7): 46 | asyncio.run(init_main()) 47 | else: 48 | # TODO(python-upgrade): drop this once 3.6 is no longer supported 49 | event_loop = asyncio.get_event_loop() 50 | event_loop.run_until_complete(init_main()) 51 | event_loop.close() 52 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). 5 | 6 | ## [Unreleased] 7 | 8 | - Added TCPDispatchClient to tcp_client 9 | - Fixed TPC dispatcher type annotations 10 | 11 | ## [1.9.3] 12 | 13 | - Reinstate mistakenly deleted package type annotations on main branch (again) 14 | 15 | ## [1.9.2] 16 | 17 | - Reinstate mistakenly deleted package type annotations on main branch 18 | 19 | ## [1.9.1] 20 | 21 | - Reinstate mistakenly deleted package type annotations 22 | 23 | ## [1.9.0] 24 | 25 | - Added TCP Client and Server support for OSC 1.0 and OSC 1.1 formats, with support for sending responses to the client 26 | - Added response support to the existing UDP Client and Server code 27 | 28 | ## [1.8.3] 29 | 30 | - Using trusted publisher setup to publish to pypi 31 | 32 | ## [1.8.2] 33 | 34 | - Changed packaging method to pypa/build 35 | - Removed pygame example to simplify dependencies 36 | 37 | ## [1.8.1] 38 | 39 | - Add options to UdpClient to force the use of IPv4 when IPv6 is available and vice versa 40 | 41 | - Add support for arguments with Nil type 42 | 43 | ### [1.8.0] 44 | 45 | - Support for sending and receiving Int64 datatype (`h`). 46 | 47 | ## [1.7.7] 48 | 49 | ### Fixed 50 | 51 | Flaky NTP test 52 | 53 | ## [1.7.6] 54 | 55 | ### Added 56 | 57 | - Support for python 3.7 and 3.8. 58 | 59 | - Releasing wheel on top of source package. 60 | 61 | ## [1.7.4] 62 | 63 | ### Added 64 | 65 | - Support for sending nil values. 66 | 67 | - IPV6 support to UDPClient. 68 | 69 | ### Fixed 70 | 71 | Timestamp parsing 72 | -------------------------------------------------------------------------------- /pythonosc/test/test_osc_bundle_builder.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from pythonosc import osc_bundle_builder 4 | from pythonosc import osc_message_builder 5 | 6 | 7 | class TestOscBundleBuilder(unittest.TestCase): 8 | def test_empty_bundle(self): 9 | bundle = osc_bundle_builder.OscBundleBuilder( 10 | osc_bundle_builder.IMMEDIATELY 11 | ).build() 12 | self.assertEqual(0, bundle.num_contents) 13 | 14 | def test_raises_on_build(self): 15 | bundle = osc_bundle_builder.OscBundleBuilder(0.0) 16 | bundle.add_content(None) 17 | self.assertRaises(osc_bundle_builder.BuildError, bundle.build) 18 | 19 | def test_raises_on_invalid_timestamp(self): 20 | bundle = osc_bundle_builder.OscBundleBuilder("I am not a timestamp") 21 | self.assertRaises(osc_bundle_builder.BuildError, bundle.build) 22 | 23 | def test_build_complex_bundle(self): 24 | bundle = osc_bundle_builder.OscBundleBuilder(osc_bundle_builder.IMMEDIATELY) 25 | msg = osc_message_builder.OscMessageBuilder(address="/SYNC") 26 | msg.add_arg(4.0) 27 | # Add 4 messages in the bundle, each with more arguments. 28 | bundle.add_content(msg.build()) 29 | msg.add_arg(2) 30 | bundle.add_content(msg.build()) 31 | msg.add_arg("value") 32 | bundle.add_content(msg.build()) 33 | msg.add_arg(b"\x01\x02\x03") 34 | bundle.add_content(msg.build()) 35 | 36 | sub_bundle = bundle.build() 37 | # Now add the same bundle inside itself. 38 | bundle.add_content(sub_bundle) 39 | 40 | bundle = bundle.build() 41 | self.assertEqual(5, bundle.num_contents) 42 | 43 | 44 | if __name__ == "__main__": 45 | unittest.main() 46 | -------------------------------------------------------------------------------- /examples/async_simple_tcp_client.py: -------------------------------------------------------------------------------- 1 | """Small example Asynchronous OSC TCP client 2 | 3 | This program listens for incoming messages in one task, and 4 | sends 10 random values between 0.0 and 1.0 to the /filter address, 5 | waiting for 1 seconds between each value in a second task. 6 | """ 7 | 8 | import argparse 9 | import asyncio 10 | import random 11 | import sys 12 | 13 | from pythonosc import tcp_client 14 | 15 | 16 | async def get_messages(client): 17 | async for msg in client.get_messages(60): 18 | print(msg) 19 | 20 | 21 | async def send_messages(client): 22 | for x in range(10): 23 | r = random.random() 24 | print(f"Sending /filter {r}") 25 | await client.send_message("/filter", r) 26 | await asyncio.sleep(1) 27 | 28 | 29 | async def init_main(): 30 | parser = argparse.ArgumentParser() 31 | parser.add_argument("--ip", default="127.0.0.1", help="The ip of the OSC server") 32 | parser.add_argument( 33 | "--port", type=int, default=5005, help="The port the OSC server is listening on" 34 | ) 35 | parser.add_argument( 36 | "--mode", 37 | default="1.1", 38 | help="The OSC protocol version of the server (default is 1.1)", 39 | ) 40 | args = parser.parse_args() 41 | 42 | async with tcp_client.AsyncSimpleTCPClient( 43 | args.ip, args.port, mode=args.mode 44 | ) as client: 45 | async with asyncio.TaskGroup() as tg: 46 | tg.create_task(get_messages(client)) 47 | tg.create_task(send_messages(client)) 48 | 49 | 50 | if sys.version_info >= (3, 7): 51 | asyncio.run(init_main()) 52 | else: 53 | # TODO(python-upgrade): drop this once 3.6 is no longer supported 54 | event_loop = asyncio.get_event_loop() 55 | event_loop.run_until_complete(init_main()) 56 | event_loop.close() 57 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "python-osc" 7 | version = "1.9.3" 8 | description = "Open Sound Control server and client implementations in pure Python" 9 | readme = "README.rst" 10 | requires-python = ">=3.10" 11 | license = { file = "LICENSE.txt" } 12 | authors = [ 13 | { name = "attwad", email = "tmusoft@gmail.com" }, 14 | ] 15 | keywords = ["osc", "sound", "midi", "music"] 16 | classifiers = [ 17 | "Development Status :: 5 - Production/Stable", 18 | "Intended Audience :: Developers", 19 | "License :: Freely Distributable", 20 | "Programming Language :: Python :: 3", 21 | "Topic :: Multimedia :: Sound/Audio", 22 | "Topic :: System :: Networking", 23 | ] 24 | 25 | [project.urls] 26 | Repository = "https://github.com/attwad/python-osc" 27 | 28 | [tool.mypy] 29 | # Would be great to turn this on, however there's too many cases it would break 30 | # right now. 31 | # disallow_any_generics = true 32 | 33 | disallow_subclassing_any = true 34 | 35 | # Allow functions _without_ type annotations, but require that annotations be 36 | # complete (possibly including the `Any` type) where they are present. 37 | disallow_incomplete_defs = true 38 | # check_untyped_defs = true 39 | disallow_untyped_decorators = true 40 | 41 | # # Would be great to turn these on eventually 42 | # no_implicit_optional = true 43 | # strict_optional = true 44 | 45 | warn_redundant_casts = true 46 | warn_unused_ignores = true 47 | show_error_codes = true 48 | # # Would be great to turn this on eventually 49 | # # warn_return_any = true 50 | # warn_unreachable = true 51 | 52 | # implicit_reexport = False 53 | # strict_equality = true 54 | 55 | scripts_are_modules = true 56 | warn_unused_configs = true 57 | 58 | enable_error_code = "ignore-without-code" 59 | -------------------------------------------------------------------------------- /.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Test 5 | 6 | permissions: read-all 7 | 8 | on: [push, pull_request] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: ['3.10', '3.11', '3.12'] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | python -m pip install flake8 pytest mypy 28 | - name: Lint with flake8 29 | run: | 30 | # stop the build if there are Python syntax errors or undefined names 31 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 32 | - name: Check with mypy 33 | run: mypy pythonosc examples 34 | - name: Test with pytest 35 | run: pytest 36 | lint: 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v3 40 | - uses: psf/black@stable 41 | 42 | check-types-published: 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v3 46 | - uses: actions/setup-python@v5 47 | with: 48 | python-version: '3.12' 49 | - run: | 50 | pip install build 51 | python -m build --sdist --wheel 52 | 53 | temp=$(mktemp -d) 54 | 55 | python -m venv $temp/venv 56 | source $temp/venv/bin/activate 57 | 58 | pip install mypy ./dist/*whl 59 | 60 | cd $temp 61 | 62 | echo 'import pythonosc' > demo.py 63 | mypy demo.py 64 | -------------------------------------------------------------------------------- /pythonosc/test/test_udp_client.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import mock 3 | 4 | from pythonosc import osc_message_builder 5 | from pythonosc import udp_client 6 | 7 | 8 | class TestUdpClient(unittest.TestCase): 9 | @mock.patch("socket.socket") 10 | def test_send(self, mock_socket_ctor): 11 | mock_socket = mock_socket_ctor.return_value 12 | client = udp_client.UDPClient("::1", 31337) 13 | 14 | msg = osc_message_builder.OscMessageBuilder("/").build() 15 | client.send(msg) 16 | 17 | self.assertTrue(mock_socket.sendto.called) 18 | mock_socket.sendto.assert_called_once_with(msg.dgram, ("::1", 31337)) 19 | 20 | 21 | class TestSimpleUdpClient(unittest.TestCase): 22 | def setUp(self): 23 | self.patcher = mock.patch("pythonosc.udp_client.OscMessageBuilder") 24 | self.patcher.start() 25 | self.builder = udp_client.OscMessageBuilder.return_value 26 | self.msg = self.builder.build.return_value 27 | self.client = mock.Mock() 28 | 29 | def tearDown(self): 30 | self.patcher.stop() 31 | 32 | def test_send_message_calls_send_with_msg(self): 33 | udp_client.SimpleUDPClient.send_message(self.client, "/address", 1) 34 | self.client.send.assert_called_once_with(self.msg) 35 | 36 | def test_send_message_calls_add_arg_with_value(self): 37 | udp_client.SimpleUDPClient.send_message(self.client, "/address", 1) 38 | self.builder.add_arg.assert_called_once_with(1) 39 | 40 | def test_send_message_calls_add_arg_once_with_string(self): 41 | udp_client.SimpleUDPClient.send_message(self.client, "/address", "hello") 42 | self.builder.add_arg.assert_called_once_with("hello") 43 | 44 | def test_send_message_calls_add_arg_multiple_times_with_list(self): 45 | udp_client.SimpleUDPClient.send_message( 46 | self.client, "/address", [1, "john", True] 47 | ) 48 | self.assertEqual(self.builder.add_arg.call_count, 3) 49 | 50 | 51 | if __name__ == "__main__": 52 | unittest.main() 53 | -------------------------------------------------------------------------------- /pythonosc/parsing/ntp.py: -------------------------------------------------------------------------------- 1 | """Parsing and conversion of NTP dates contained in datagrams.""" 2 | 3 | import datetime 4 | import struct 5 | import time 6 | 7 | from typing import NamedTuple 8 | 9 | # 63 zero bits followed by a one in the least signifigant bit is a special 10 | # case meaning "immediately." 11 | IMMEDIATELY = struct.pack(">Q", 1) 12 | 13 | # timetag * (1 / 2 ** 32) == l32bits + (r32bits / 1 ** 32) 14 | _NTP_TIMESTAMP_TO_SECONDS = 1.0 / 2.0**32.0 15 | _SECONDS_TO_NTP_TIMESTAMP = 2.0**32.0 16 | 17 | # From NTP lib. 18 | _SYSTEM_EPOCH = datetime.date(*time.gmtime(0)[0:3]) 19 | _NTP_EPOCH = datetime.date(1900, 1, 1) 20 | # _NTP_DELTA is 2208988800 21 | _NTP_DELTA = (_SYSTEM_EPOCH - _NTP_EPOCH).days * 24 * 3600 22 | 23 | 24 | Timestamp = NamedTuple( 25 | "Timestamp", 26 | [ 27 | ("seconds", int), 28 | ("fraction", int), 29 | ], 30 | ) 31 | 32 | 33 | class NtpError(Exception): 34 | """Base class for ntp module errors.""" 35 | 36 | 37 | def parse_timestamp(timestamp: int) -> Timestamp: 38 | """Parse NTP timestamp as Timetag.""" 39 | seconds = timestamp >> 32 40 | fraction = timestamp & 0xFFFFFFFF 41 | return Timestamp(seconds, fraction) 42 | 43 | 44 | def ntp_to_system_time(timestamp: bytes) -> float: 45 | """Convert a NTP timestamp to system time in seconds.""" 46 | try: 47 | ts = struct.unpack(">Q", timestamp)[0] 48 | except Exception as e: 49 | raise NtpError(e) 50 | return ts * _NTP_TIMESTAMP_TO_SECONDS - _NTP_DELTA 51 | 52 | 53 | def system_time_to_ntp(seconds: float) -> bytes: 54 | """Convert a system time in seconds to NTP timestamp.""" 55 | try: 56 | seconds = seconds + _NTP_DELTA 57 | except TypeError as e: 58 | raise NtpError(e) 59 | return struct.pack(">Q", int(seconds * _SECONDS_TO_NTP_TIMESTAMP)) 60 | 61 | 62 | def ntp_time_to_system_epoch(seconds: float) -> float: 63 | """Convert a NTP time in seconds to system time in seconds.""" 64 | return seconds - _NTP_DELTA 65 | 66 | 67 | def system_time_to_ntp_epoch(seconds: float) -> float: 68 | """Convert a system time in seconds to NTP time in seconds.""" 69 | return seconds + _NTP_DELTA 70 | -------------------------------------------------------------------------------- /pythonosc/osc_bundle_builder.py: -------------------------------------------------------------------------------- 1 | """Build OSC bundles for client applications.""" 2 | 3 | from typing import List 4 | 5 | from pythonosc import osc_bundle 6 | from pythonosc import osc_message 7 | from pythonosc.parsing import osc_types 8 | 9 | # Shortcut to specify an immediate execution of messages in the bundle. 10 | IMMEDIATELY = osc_types.IMMEDIATELY 11 | 12 | 13 | class BuildError(Exception): 14 | """Error raised when an error occurs building the bundle.""" 15 | 16 | 17 | class OscBundleBuilder(object): 18 | """Builds arbitrary OscBundle instances.""" 19 | 20 | def __init__(self, timestamp: int) -> None: 21 | """Build a new bundle with the associated timestamp. 22 | 23 | Args: 24 | - timestamp: system time represented as a floating point number of 25 | seconds since the epoch in UTC or IMMEDIATELY. 26 | """ 27 | self._timestamp = timestamp 28 | self._contents: List[osc_bundle.OscBundle | osc_message.OscMessage] = [] 29 | 30 | def add_content( 31 | self, content: osc_bundle.OscBundle | osc_message.OscMessage 32 | ) -> None: 33 | """Add a new content to this bundle. 34 | 35 | Args: 36 | - content: Either an OscBundle or an OscMessage 37 | """ 38 | self._contents.append(content) 39 | 40 | def build(self) -> osc_bundle.OscBundle: 41 | """Build an OscBundle with the current state of this builder. 42 | 43 | Raises: 44 | - BuildError: if we could not build the bundle. 45 | """ 46 | dgram = b"#bundle\x00" 47 | try: 48 | dgram += osc_types.write_date(self._timestamp) 49 | for content in self._contents: 50 | if isinstance(content, osc_message.OscMessage) or isinstance( 51 | content, osc_bundle.OscBundle 52 | ): 53 | size = content.size 54 | dgram += osc_types.write_int(size) 55 | dgram += content.dgram 56 | else: 57 | raise BuildError( 58 | f"Content must be either OscBundle or OscMessage, found {type(content)}" 59 | ) 60 | return osc_bundle.OscBundle(dgram) 61 | except osc_types.BuildError as be: 62 | raise BuildError(f"Could not build the bundle {be}") 63 | -------------------------------------------------------------------------------- /pythonosc/test/test_osc_packet.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from pythonosc import osc_packet 4 | 5 | _DGRAM_TWO_MESSAGES_IN_BUNDLE = ( 6 | b"#bundle\x00" 7 | b"\x00\x00\x00\x00\x00\x00\x00\x01" 8 | # First message. 9 | b"\x00\x00\x00\x10" 10 | b"/SYNC\x00\x00\x00" 11 | b",f\x00\x00" 12 | b"?\x00\x00\x00" 13 | # Second message, same. 14 | b"\x00\x00\x00\x10" 15 | b"/SYNC\x00\x00\x00" 16 | b",f\x00\x00" 17 | b"?\x00\x00\x00" 18 | ) 19 | 20 | _DGRAM_EMPTY_BUNDLE = b"#bundle\x00" b"\x00\x00\x00\x00\x00\x00\x00\x01" 21 | 22 | _DGRAM_NESTED_MESS = ( 23 | b"#bundle\x00" 24 | b"\x10\x00\x00\x00\x00\x00\x00\x00" 25 | # First message. 26 | b"\x00\x00\x00\x10" # 16 bytes 27 | b"/1111\x00\x00\x00" 28 | b",f\x00\x00" 29 | b"?\x00\x00\x00" 30 | # Second message, same. 31 | b"\x00\x00\x00\x10" # 16 bytes 32 | b"/2222\x00\x00\x00" 33 | b",f\x00\x00" 34 | b"?\x00\x00\x00" 35 | # Now another bundle within it, oh my... 36 | b"\x00\x00\x00$" # 36 bytes. 37 | b"#bundle\x00" 38 | b"\x20\x00\x00\x00\x00\x00\x00\x00" 39 | # First message. 40 | b"\x00\x00\x00\x10" 41 | b"/3333\x00\x00\x00" 42 | b",f\x00\x00" 43 | b"?\x00\x00\x00" 44 | # And another final bundle. 45 | b"\x00\x00\x00$" # 36 bytes. 46 | b"#bundle\x00" 47 | b"\x15\x00\x00\x00\x00\x00\x00\x01" # Immediately this one. 48 | # First message. 49 | b"\x00\x00\x00\x10" 50 | b"/4444\x00\x00\x00" 51 | b",f\x00\x00" 52 | b"?\x00\x00\x00" 53 | ) 54 | 55 | 56 | class TestOscPacket(unittest.TestCase): 57 | def test_two_messages_in_a_bundle(self): 58 | packet = osc_packet.OscPacket(_DGRAM_TWO_MESSAGES_IN_BUNDLE) 59 | self.assertEqual(2, len(packet.messages)) 60 | 61 | def test_empty_dgram_raises_exception(self): 62 | self.assertRaises(osc_packet.ParseError, osc_packet.OscPacket, b"") 63 | 64 | def test_empty_bundle(self): 65 | packet = osc_packet.OscPacket(_DGRAM_EMPTY_BUNDLE) 66 | self.assertEqual(0, len(packet.messages)) 67 | 68 | def test_nested_mess_bundle(self): 69 | packet = osc_packet.OscPacket(_DGRAM_NESTED_MESS) 70 | self.assertEqual(4, len(packet.messages)) 71 | self.assertTrue(packet.messages[0][0], packet.messages[1][0]) 72 | self.assertTrue(packet.messages[1][0], packet.messages[2][0]) 73 | self.assertTrue(packet.messages[2][0], packet.messages[3][0]) 74 | 75 | 76 | if __name__ == "__main__": 77 | unittest.main() 78 | -------------------------------------------------------------------------------- /pythonosc/test/test_tcp_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import unittest 3 | from unittest import mock 4 | 5 | from pythonosc import osc_message_builder, slip, tcp_client 6 | 7 | 8 | class TestTcpClient(unittest.TestCase): 9 | @mock.patch("socket.socket") 10 | def test_client(self, mock_socket_ctor): 11 | mock_socket = mock_socket_ctor.return_value 12 | mock_send = mock.Mock() 13 | mock_recv = mock.Mock() 14 | mock_send.return_value = None 15 | mock_recv.return_value = "" 16 | 17 | mock_socket.sendall = mock_send 18 | mock_socket.recv = mock_recv 19 | msg = osc_message_builder.OscMessageBuilder("/").build() 20 | with tcp_client.TCPClient("::1", 31337) as client: 21 | client.send(msg) 22 | mock_socket.sendall.assert_called_once_with(slip.encode(msg.dgram)) 23 | 24 | @mock.patch("socket.socket") 25 | def test_simple_client(self, mock_socket_ctor): 26 | mock_socket = mock_socket_ctor.return_value 27 | mock_send = mock.Mock() 28 | mock_recv = mock.Mock() 29 | mock_send.return_value = None 30 | mock_recv.return_value = "" 31 | 32 | mock_socket.sendall = mock_send 33 | mock_socket.recv = mock_recv 34 | with tcp_client.SimpleTCPClient("::1", 31337) as client: 35 | client.send_message("/", []) 36 | mock_socket.sendall.assert_called_once() 37 | 38 | 39 | class TestAsyncTcpClient(unittest.IsolatedAsyncioTestCase): 40 | @mock.patch("asyncio.open_connection") 41 | async def test_send(self, mock_socket_ctor): 42 | mock_reader = mock.Mock() 43 | mock_writer = mock.Mock() 44 | mock_writer.drain = mock.AsyncMock() 45 | mock_writer.wait_closed = mock.AsyncMock() 46 | mock_socket_ctor.return_value = (mock_reader, mock_writer) 47 | loop = asyncio.get_running_loop() 48 | loop.set_debug(False) 49 | msg = osc_message_builder.OscMessageBuilder("/").build() 50 | async with tcp_client.AsyncTCPClient("::1", 31337) as client: 51 | await client.send(msg) 52 | 53 | self.assertTrue(mock_writer.write.called) 54 | mock_writer.write.assert_called_once_with(slip.encode(msg.dgram)) 55 | 56 | @mock.patch("asyncio.open_connection") 57 | async def test_send_message_calls_send_with_msg(self, mock_socket_ctor): 58 | mock_reader = mock.Mock() 59 | mock_writer = mock.Mock() 60 | mock_writer.drain = mock.AsyncMock() 61 | mock_writer.wait_closed = mock.AsyncMock() 62 | mock_socket_ctor.return_value = (mock_reader, mock_writer) 63 | async with tcp_client.AsyncSimpleTCPClient("::1", 31337) as client: 64 | await client.send_message("/address", 1) 65 | self.assertTrue(mock_writer.write.called) 66 | 67 | 68 | if __name__ == "__main__": 69 | unittest.main() 70 | -------------------------------------------------------------------------------- /examples/simple_2way.py: -------------------------------------------------------------------------------- 1 | """Small example OSC server anbd client combined 2 | This program listens to serveral addresses and print if there is an input. 3 | It also transmits on a different port at the same time random values to different addresses. 4 | This can be used to demonstrate concurrent send and recieve over OSC 5 | """ 6 | 7 | import argparse 8 | import random 9 | import time 10 | import threading 11 | 12 | from pythonosc import udp_client 13 | from pythonosc.dispatcher import Dispatcher 14 | from pythonosc import osc_server 15 | 16 | 17 | def print_fader_handler(unused_addr, args, value): 18 | print(f"[{args[0]}] ~ {value:0.2f}") 19 | 20 | 21 | def print_xy_fader_handler(unused_addr, args, value1, value2): 22 | print(f"[{args[0]}] ~ {value2:0.2f} ~ {value1:0.2f}") 23 | 24 | 25 | if __name__ == "__main__": 26 | parser = argparse.ArgumentParser() 27 | parser.add_argument("--serverip", default="127.0.0.1", help="The ip to listen on") 28 | parser.add_argument( 29 | "--serverport", 30 | type=int, 31 | default=5005, 32 | help="The port the OSC Server is listening on", 33 | ) 34 | parser.add_argument( 35 | "--clientip", default="127.0.0.1", help="The ip of the OSC server" 36 | ) 37 | parser.add_argument( 38 | "--clientport", 39 | type=int, 40 | default=5006, 41 | help="The port the OSC Client is listening on", 42 | ) 43 | args = parser.parse_args() 44 | 45 | # listen to addresses and print changes in values 46 | dispatcher = Dispatcher() 47 | dispatcher.map("/1/push2", print) 48 | dispatcher.map("/1/fader1", print_fader_handler, "Focus") 49 | dispatcher.map("/1/fader2", print_fader_handler, "Zoom") 50 | dispatcher.map("/1/xy1", print_xy_fader_handler, "Pan-Tilt") 51 | dispatcher.map("/ping", print) 52 | 53 | 54 | def start_server(ip, port): 55 | print("Starting Server") 56 | server = osc_server.ThreadingOSCUDPServer((ip, port), dispatcher) 57 | print(f"Serving on {server.server_address}") 58 | thread = threading.Thread(target=server.serve_forever) 59 | thread.start() 60 | 61 | 62 | def start_client(ip, port): 63 | print("Starting Client") 64 | client = udp_client.SimpleUDPClient(ip, port) 65 | # print("Sending on {}".format(client.)) 66 | thread = threading.Thread(target=random_values(client)) 67 | thread.start() 68 | 69 | 70 | # send random values between 0-1 to the three addresses 71 | def random_values(client): 72 | while True: 73 | for x in range(10): 74 | client.send_message("/1/fader2", random.random()) 75 | client.send_message("/1/fader1", random.random()) 76 | client.send_message("/1/xy1", [random.random(), random.random()]) 77 | time.sleep(0.5) 78 | 79 | 80 | start_server(args.serverip, args.serverport) 81 | start_client(args.clientip, args.clientport) 82 | -------------------------------------------------------------------------------- /pythonosc/osc_packet.py: -------------------------------------------------------------------------------- 1 | """Use OSC packets to parse incoming UDP packets into messages or bundles. 2 | 3 | It lets you access easily to OscMessage and OscBundle instances in the packet. 4 | """ 5 | 6 | import time 7 | 8 | from pythonosc.parsing import osc_types 9 | from pythonosc import osc_bundle 10 | from pythonosc import osc_message 11 | 12 | from typing import List, NamedTuple 13 | 14 | # A namedtuple as returned my the _timed_msg_of_bundle function. 15 | # 1) the system time at which the message should be executed 16 | # in seconds since the epoch. 17 | # 2) the actual message. 18 | TimedMessage = NamedTuple( 19 | "TimedMessage", 20 | [ 21 | ("time", float), 22 | ("message", osc_message.OscMessage), 23 | ], 24 | ) 25 | 26 | 27 | def _timed_msg_of_bundle( 28 | bundle: osc_bundle.OscBundle, now: float 29 | ) -> List[TimedMessage]: 30 | """Returns messages contained in nested bundles as a list of TimedMessage.""" 31 | msgs = [] 32 | for content in bundle: 33 | if type(content) is osc_message.OscMessage: 34 | if bundle.timestamp == osc_types.IMMEDIATELY or bundle.timestamp < now: 35 | msgs.append(TimedMessage(now, content)) 36 | else: 37 | msgs.append(TimedMessage(bundle.timestamp, content)) 38 | else: 39 | msgs.extend(_timed_msg_of_bundle(content, now)) 40 | return msgs 41 | 42 | 43 | class ParseError(Exception): 44 | """Base error thrown when a packet could not be parsed.""" 45 | 46 | 47 | class OscPacket(object): 48 | """Unit of transmission of the OSC protocol. 49 | 50 | Any application that sends OSC Packets is an OSC Client. 51 | Any application that receives OSC Packets is an OSC Server. 52 | """ 53 | 54 | def __init__(self, dgram: bytes) -> None: 55 | """Initialize an OdpPacket with the given UDP datagram. 56 | 57 | Args: 58 | - dgram: the raw UDP datagram holding the OSC packet. 59 | 60 | Raises: 61 | - ParseError if the datagram could not be parsed. 62 | """ 63 | now = time.time() 64 | try: 65 | if osc_bundle.OscBundle.dgram_is_bundle(dgram): 66 | self._messages = sorted( 67 | _timed_msg_of_bundle(osc_bundle.OscBundle(dgram), now), 68 | key=lambda x: x.time, 69 | ) 70 | elif osc_message.OscMessage.dgram_is_message(dgram): 71 | self._messages = [TimedMessage(now, osc_message.OscMessage(dgram))] 72 | else: 73 | # Empty packet, should not happen as per the spec but heh, UDP... 74 | raise ParseError( 75 | "OSC Packet should at least contain an OscMessage or an " 76 | "OscBundle." 77 | ) 78 | except (osc_bundle.ParseError, osc_message.ParseError) as pe: 79 | raise ParseError(f"Could not parse packet {pe}") 80 | 81 | @property 82 | def messages(self) -> List[TimedMessage]: 83 | """Returns asc-time-sorted TimedMessages of the messages in this packet.""" 84 | return self._messages 85 | -------------------------------------------------------------------------------- /pythonosc/slip.py: -------------------------------------------------------------------------------- 1 | # This file is part of the SlipLib project which is released under the MIT license. 2 | # See https://github.com/rhjdjong/SlipLib for details. 3 | # 4 | # The MIT License (MIT) 5 | # 6 | # Copyright (c) 2015 Ruud de Jong 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in all 16 | # copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | # SOFTWARE. 25 | 26 | import re 27 | 28 | END = b"\xc0" 29 | ESC = b"\xdb" 30 | ESC_END = b"\xdc" 31 | ESC_ESC = b"\xdd" 32 | END_END = b"\xc0\xc0" 33 | """These constants represent the special SLIP bytes""" 34 | 35 | 36 | class ProtocolError(ValueError): 37 | """Exception to indicate that a SLIP protocol error has occurred. 38 | 39 | This exception is raised when an attempt is made to decode 40 | a packet with an invalid byte sequence. 41 | An invalid byte sequence is either an :const:`ESC` byte followed 42 | by any byte that is not an :const:`ESC_ESC` or :const:`ESC_END` byte, 43 | or a trailing :const:`ESC` byte as last byte of the packet. 44 | 45 | The :exc:`ProtocolError` carries the invalid packet 46 | as the first (and only) element in in its :attr:`args` tuple. 47 | """ 48 | 49 | 50 | def encode(msg: bytes) -> bytes: 51 | """Encodes a message (a byte sequence) into a SLIP-encoded packet. 52 | 53 | Args: 54 | msg: The message that must be encoded 55 | 56 | Returns: 57 | The SLIP-encoded message 58 | """ 59 | if msg: 60 | msg = bytes(msg) 61 | else: 62 | msg = b"" 63 | return END + msg.replace(ESC, ESC + ESC_ESC).replace(END, ESC + ESC_END) + END 64 | 65 | 66 | def decode(packet: bytes) -> bytes: 67 | """Retrieves the message from the SLIP-encoded packet. 68 | 69 | Args: 70 | packet: The SLIP-encoded message. 71 | Note that this must be exactly one complete packet. 72 | The :func:`decode` function does not provide any buffering 73 | for incomplete packages, nor does it provide support 74 | for decoding data with multiple packets. 75 | Returns: 76 | The decoded message 77 | 78 | Raises: 79 | ProtocolError: if the packet contains an invalid byte sequence. 80 | """ 81 | if not is_valid(packet): 82 | raise ProtocolError(packet) 83 | return packet.strip(END).replace(ESC + ESC_END, END).replace(ESC + ESC_ESC, ESC) 84 | 85 | 86 | def is_valid(packet: bytes) -> bool: 87 | """Indicates if the packet's contents conform to the SLIP specification. 88 | 89 | A packet is valid if: 90 | 91 | * It contains no :const:`END` bytes other than leading and/or trailing :const:`END` bytes, and 92 | * Each :const:`ESC` byte is followed by either an :const:`ESC_END` or an :const:`ESC_ESC` byte. 93 | 94 | Args: 95 | packet: The packet to inspect. 96 | 97 | Returns: 98 | :const:`True` if the packet is valid, :const:`False` otherwise 99 | """ 100 | packet = packet.strip(END) 101 | return not ( 102 | END in packet 103 | or packet.endswith(ESC) 104 | or re.search(ESC + b"[^" + ESC_END + ESC_ESC + b"]", packet) 105 | ) 106 | -------------------------------------------------------------------------------- /pythonosc/osc_bundle.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pythonosc import osc_message 4 | from pythonosc.parsing import osc_types 5 | 6 | from typing import Any, Iterator, List, Union 7 | 8 | _BUNDLE_PREFIX = b"#bundle\x00" 9 | 10 | 11 | class ParseError(Exception): 12 | """Base exception raised when a datagram parsing error occurs.""" 13 | 14 | 15 | class OscBundle(object): 16 | """Bundles elements that should be triggered at the same time. 17 | 18 | An element can be another OscBundle or an OscMessage. 19 | """ 20 | 21 | def __init__(self, dgram: bytes) -> None: 22 | """Initializes the OscBundle with the given datagram. 23 | 24 | Args: 25 | dgram: a UDP datagram representing an OscBundle. 26 | 27 | Raises: 28 | ParseError: if the datagram could not be parsed into an OscBundle. 29 | """ 30 | # Interesting stuff starts after the initial b"#bundle\x00". 31 | self._dgram = dgram 32 | index = len(_BUNDLE_PREFIX) 33 | try: 34 | self._timestamp, index = osc_types.get_date(self._dgram, index) 35 | except osc_types.ParseError as pe: 36 | raise ParseError(f"Could not get the date from the datagram: {pe}") 37 | # Get the contents as a list of OscBundle and OscMessage. 38 | self._contents = self._parse_contents(index) 39 | 40 | def _parse_contents( 41 | self, index: int 42 | ) -> List[Union["OscBundle", osc_message.OscMessage]]: 43 | contents = [] # type: List[Union[OscBundle, osc_message.OscMessage]] 44 | 45 | try: 46 | # An OSC Bundle Element consists of its size and its contents. 47 | # The size is an int32 representing the number of 8-bit bytes in the 48 | # contents, and will always be a multiple of 4. The contents are either 49 | # an OSC Message or an OSC Bundle. 50 | while self._dgram[index:]: 51 | # Get the sub content size. 52 | content_size, index = osc_types.get_int(self._dgram, index) 53 | # Get the datagram for the sub content. 54 | content_dgram = self._dgram[index : index + content_size] 55 | # Increment our position index up to the next possible content. 56 | index += content_size 57 | # Parse the content into an OSC message or bundle. 58 | if OscBundle.dgram_is_bundle(content_dgram): 59 | contents.append(OscBundle(content_dgram)) 60 | elif osc_message.OscMessage.dgram_is_message(content_dgram): 61 | contents.append(osc_message.OscMessage(content_dgram)) 62 | else: 63 | logging.warning( 64 | f"Could not identify content type of dgram {content_dgram!r}" 65 | ) 66 | except (osc_types.ParseError, osc_message.ParseError, IndexError) as e: 67 | raise ParseError(f"Could not parse a content datagram: {e}") 68 | 69 | return contents 70 | 71 | @staticmethod 72 | def dgram_is_bundle(dgram: bytes) -> bool: 73 | """Returns whether this datagram starts like an OSC bundle.""" 74 | return dgram.startswith(_BUNDLE_PREFIX) 75 | 76 | @property 77 | def timestamp(self) -> float: 78 | """Returns the timestamp associated with this bundle.""" 79 | return self._timestamp 80 | 81 | @property 82 | def num_contents(self) -> int: 83 | """Shortcut for len(*bundle) returning the number of elements.""" 84 | return len(self._contents) 85 | 86 | @property 87 | def size(self) -> int: 88 | """Returns the length of the datagram for this bundle.""" 89 | return len(self._dgram) 90 | 91 | @property 92 | def dgram(self) -> bytes: 93 | """Returns the datagram from which this bundle was built.""" 94 | return self._dgram 95 | 96 | def content(self, index: int) -> Any: 97 | """Returns the bundle's content 0-indexed.""" 98 | return self._contents[index] 99 | 100 | def __iter__(self) -> Iterator[Any]: 101 | """Returns an iterator over the bundle's content.""" 102 | return iter(self._contents) 103 | -------------------------------------------------------------------------------- /pythonosc/test/test_osc_message_builder.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from pythonosc import osc_message_builder 4 | 5 | 6 | class TestOscMessageBuilder(unittest.TestCase): 7 | def test_just_address(self): 8 | msg = osc_message_builder.OscMessageBuilder("/a/b/c").build() 9 | self.assertEqual("/a/b/c", msg.address) 10 | self.assertEqual([], msg.params) 11 | # Messages with just an address should still contain the ",". 12 | self.assertEqual(b"/a/b/c\x00\x00,\x00\x00\x00", msg.dgram) 13 | 14 | def test_no_address_raises(self): 15 | builder = osc_message_builder.OscMessageBuilder("") 16 | self.assertRaises(osc_message_builder.BuildError, builder.build) 17 | 18 | def test_wrong_param_raise(self): 19 | builder = osc_message_builder.OscMessageBuilder("") 20 | self.assertRaises(ValueError, builder.add_arg, "what?", 1) 21 | 22 | def test_add_arg_invalid_inferred_type(self): 23 | builder = osc_message_builder.OscMessageBuilder("") 24 | self.assertRaises(ValueError, builder.add_arg, {"name": "John"}) 25 | 26 | def test_all_param_types(self): 27 | builder = osc_message_builder.OscMessageBuilder(address="/SYNC") 28 | builder.add_arg(4.0) 29 | builder.add_arg(2) 30 | builder.add_arg(1099511627776) 31 | builder.add_arg("value") 32 | builder.add_arg(True) 33 | builder.add_arg(False) 34 | builder.add_arg(b"\x01\x02\x03") 35 | builder.add_arg([1, ["abc"]]) 36 | builder.add_arg(None) 37 | # The same args but with explicit types. 38 | builder.add_arg(4.0, builder.ARG_TYPE_FLOAT) 39 | builder.add_arg(2, builder.ARG_TYPE_INT) 40 | builder.add_arg(1099511627776, builder.ARG_TYPE_INT64) 41 | builder.add_arg("value", builder.ARG_TYPE_STRING) 42 | builder.add_arg(True) 43 | builder.add_arg(False) 44 | builder.add_arg(b"\x01\x02\x03", builder.ARG_TYPE_BLOB) 45 | builder.add_arg([1, ["abc"]], [builder.ARG_TYPE_INT, [builder.ARG_TYPE_STRING]]) 46 | builder.add_arg(None, builder.ARG_TYPE_NIL) 47 | builder.add_arg(4278255360, builder.ARG_TYPE_RGBA) 48 | builder.add_arg((1, 145, 36, 125), builder.ARG_TYPE_MIDI) 49 | builder.add_arg(1e-9, builder.ARG_TYPE_DOUBLE) 50 | self.assertEqual(len("fihsTFb[i[s]]N") * 2 + 3, len(builder.args)) 51 | self.assertEqual("/SYNC", builder.address) 52 | builder.address = "/SEEK" 53 | msg = builder.build() 54 | self.assertEqual("/SEEK", msg.address) 55 | self.assertSequenceEqual( 56 | [ 57 | 4.0, 58 | 2, 59 | 1099511627776, 60 | "value", 61 | True, 62 | False, 63 | b"\x01\x02\x03", 64 | [1, ["abc"]], 65 | None, 66 | ] 67 | * 2 68 | + [4278255360, (1, 145, 36, 125), 1e-9], 69 | msg.params, 70 | ) 71 | 72 | def test_long_list(self): 73 | huge_list = list(range(512)) 74 | builder = osc_message_builder.OscMessageBuilder(address="/SYNC") 75 | builder.add_arg(huge_list) 76 | msg = builder.build() 77 | print(msg._dgram) 78 | self.assertSequenceEqual([huge_list], msg.params) 79 | 80 | def test_build_wrong_type_raises(self): 81 | builder = osc_message_builder.OscMessageBuilder(address="/SYNC") 82 | builder.add_arg("this is not a float", builder.ARG_TYPE_FLOAT) 83 | self.assertRaises(osc_message_builder.BuildError, builder.build) 84 | 85 | def test_build_noarg_message(self): 86 | msg = osc_message_builder.OscMessageBuilder(address="/SYNC").build() 87 | # This reference message was generated with Cycling 74's Max software 88 | # and then was intercepted with Wireshark 89 | reference = bytearray.fromhex("2f53594e430000002c000000") 90 | self.assertSequenceEqual(msg._dgram, reference) 91 | 92 | def test_bool_encoding(self): 93 | builder = osc_message_builder.OscMessageBuilder("") 94 | builder.add_arg(0) 95 | builder.add_arg(1) 96 | builder.add_arg(False) 97 | builder.add_arg(True) 98 | self.assertEqual(builder.args, [("i", 0), ("i", 1), ("F", False), ("T", True)]) 99 | 100 | 101 | if __name__ == "__main__": 102 | unittest.main() 103 | -------------------------------------------------------------------------------- /pythonosc/test/test_osc_bundle.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from pythonosc import osc_message 4 | from pythonosc import osc_bundle 5 | from pythonosc.parsing import osc_types 6 | 7 | _DGRAM_KNOB_ROTATES_BUNDLE = ( 8 | b"#bundle\x00" 9 | b"\x00\x00\x00\x00\x00\x00\x00\x01" 10 | b"\x00\x00\x00\x14" 11 | b"/LFO_Rate\x00\x00\x00" 12 | b",f\x00\x00" 13 | b">\x8c\xcc\xcd" 14 | ) 15 | 16 | _DGRAM_SWITCH_GOES_OFF = ( 17 | b"#bundle\x00" 18 | b"\x00\x00\x00\x00\x00\x00\x00\x01" 19 | b"\x00\x00\x00\x10" 20 | b"/SYNC\x00\x00\x00" 21 | b",f\x00\x00" 22 | b"\x00\x00\x00\x00" 23 | ) 24 | 25 | _DGRAM_SWITCH_GOES_ON = ( 26 | b"#bundle\x00" 27 | b"\x00\x00\x00\x00\x00\x00\x00\x01" 28 | b"\x00\x00\x00\x10" 29 | b"/SYNC\x00\x00\x00" 30 | b",f\x00\x00" 31 | b"?\x00\x00\x00" 32 | ) 33 | 34 | _DGRAM_TWO_MESSAGES_IN_BUNDLE = ( 35 | b"#bundle\x00" 36 | b"\x00\x00\x00\x00\x00\x00\x00\x01" 37 | # First message. 38 | b"\x00\x00\x00\x10" 39 | b"/SYNC\x00\x00\x00" 40 | b",f\x00\x00" 41 | b"?\x00\x00\x00" 42 | # Second message, same. 43 | b"\x00\x00\x00\x10" 44 | b"/SYNC\x00\x00\x00" 45 | b",f\x00\x00" 46 | b"?\x00\x00\x00" 47 | ) 48 | 49 | _DGRAM_EMPTY_BUNDLE = b"#bundle\x00" b"\x00\x00\x00\x00\x00\x00\x00\x01" 50 | 51 | _DGRAM_BUNDLE_IN_BUNDLE = ( 52 | b"#bundle\x00" 53 | b"\x00\x00\x00\x00\x00\x00\x00\x01" 54 | b"\x00\x00\x00(" # length of sub bundle: 40 bytes. 55 | b"#bundle\x00" 56 | b"\x00\x00\x00\x00\x00\x00\x00\x01" 57 | b"\x00\x00\x00\x10" 58 | b"/SYNC\x00\x00\x00" 59 | b",f\x00\x00" 60 | b"?\x00\x00\x00" 61 | ) 62 | 63 | _DGRAM_INVALID = b"#bundle\x00" b"\x00\x00\x00" 64 | 65 | _DGRAM_INVALID_INDEX = ( 66 | b"#bundle\x00" 67 | b"\x00\x00\x00\x00\x00\x00\x00\x01" 68 | b"\x00\x00\x00\x20" 69 | b"/SYNC\x00\x00\x00\x00" 70 | ) 71 | 72 | _DGRAM_UNKNOWN_TYPE = ( 73 | b"#bundle\x00" 74 | b"\x00\x00\x00\x00\x00\x00\x00\x01" 75 | b"\x00\x00\x00\x10" 76 | b"iamnotaslash" 77 | ) 78 | 79 | 80 | class TestOscBundle(unittest.TestCase): 81 | def test_switch_goes_off(self): 82 | bundle = osc_bundle.OscBundle(_DGRAM_SWITCH_GOES_OFF) 83 | self.assertEqual(1, bundle.num_contents) 84 | self.assertEqual(len(_DGRAM_SWITCH_GOES_OFF), bundle.size) 85 | self.assertEqual(osc_types.IMMEDIATELY, bundle.timestamp) 86 | 87 | def test_switch_goes_on(self): 88 | bundle = osc_bundle.OscBundle(_DGRAM_SWITCH_GOES_ON) 89 | self.assertEqual(1, bundle.num_contents) 90 | self.assertEqual(len(_DGRAM_SWITCH_GOES_ON), bundle.size) 91 | self.assertEqual(osc_types.IMMEDIATELY, bundle.timestamp) 92 | 93 | def test_datagram_length(self): 94 | bundle = osc_bundle.OscBundle(_DGRAM_KNOB_ROTATES_BUNDLE) 95 | self.assertEqual(1, bundle.num_contents) 96 | self.assertEqual(len(_DGRAM_KNOB_ROTATES_BUNDLE), bundle.size) 97 | self.assertEqual(osc_types.IMMEDIATELY, bundle.timestamp) 98 | 99 | def test_two_messages_in_bundle(self): 100 | bundle = osc_bundle.OscBundle(_DGRAM_TWO_MESSAGES_IN_BUNDLE) 101 | self.assertEqual(2, bundle.num_contents) 102 | self.assertEqual(osc_types.IMMEDIATELY, bundle.timestamp) 103 | for content in bundle: 104 | self.assertEqual(osc_message.OscMessage, type(content)) 105 | 106 | def test_empty_bundle(self): 107 | bundle = osc_bundle.OscBundle(_DGRAM_EMPTY_BUNDLE) 108 | self.assertEqual(0, bundle.num_contents) 109 | self.assertEqual(osc_types.IMMEDIATELY, bundle.timestamp) 110 | 111 | def test_bundle_in_bundle_we_must_go_deeper(self): 112 | bundle = osc_bundle.OscBundle(_DGRAM_BUNDLE_IN_BUNDLE) 113 | self.assertEqual(1, bundle.num_contents) 114 | self.assertEqual(osc_types.IMMEDIATELY, bundle.timestamp) 115 | self.assertEqual(osc_bundle.OscBundle, type(bundle.content(0))) 116 | 117 | def test_dgram_is_bundle(self): 118 | self.assertTrue(osc_bundle.OscBundle.dgram_is_bundle(_DGRAM_SWITCH_GOES_ON)) 119 | self.assertFalse(osc_bundle.OscBundle.dgram_is_bundle(b"junk")) 120 | 121 | def test_raises_on_invalid_datagram(self): 122 | self.assertRaises(osc_bundle.ParseError, osc_bundle.OscBundle, _DGRAM_INVALID) 123 | self.assertRaises( 124 | osc_bundle.ParseError, osc_bundle.OscBundle, _DGRAM_INVALID_INDEX 125 | ) 126 | 127 | def test_unknown_type(self): 128 | osc_bundle.OscBundle(_DGRAM_UNKNOWN_TYPE) 129 | 130 | 131 | if __name__ == "__main__": 132 | unittest.main() 133 | -------------------------------------------------------------------------------- /pythonosc/test/test_osc_server.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import unittest.mock 3 | 4 | from pythonosc import dispatcher, osc_server 5 | 6 | _SIMPLE_PARAM_INT_MSG = b"/SYNC\x00\x00\x00" b",i\x00\x00" b"\x00\x00\x00\x04" 7 | 8 | # Regression test for a datagram that should NOT be stripped, ever... 9 | _SIMPLE_PARAM_INT_9 = b"/debug\x00\x00,i\x00\x00\x00\x00\x00\t" 10 | 11 | _SIMPLE_MSG_NO_PARAMS = b"/SYNC\x00\x00\x00" 12 | 13 | 14 | class TestOscServer(unittest.TestCase): 15 | def test_is_valid_request(self): 16 | self.assertTrue(osc_server._is_valid_request((b"#bundle\x00foobar",))) 17 | self.assertTrue(osc_server._is_valid_request((b"/address/1/2/3,foobar",))) 18 | self.assertFalse(osc_server._is_valid_request((b"",))) 19 | 20 | 21 | class TestUDPHandler(unittest.TestCase): 22 | def setUp(self): 23 | super().setUp() 24 | self.dispatcher = dispatcher.Dispatcher() 25 | # We do not want to create real UDP connections during unit tests. 26 | self.server = unittest.mock.Mock(spec=osc_server.BlockingOSCUDPServer) 27 | # Need to attach property mocks to types, not objects... weird. 28 | type(self.server).dispatcher = unittest.mock.PropertyMock( 29 | return_value=self.dispatcher 30 | ) 31 | self.client_address = ("127.0.0.1", 8080) 32 | 33 | def test_no_match(self): 34 | mock_meth = unittest.mock.MagicMock() 35 | mock_meth.return_value = None 36 | self.dispatcher.map("/foobar", mock_meth) 37 | osc_server._UDPHandler( 38 | [_SIMPLE_PARAM_INT_MSG, None], self.client_address, self.server 39 | ) 40 | self.assertFalse(mock_meth.called) 41 | 42 | def test_match_with_args(self): 43 | mock_meth = unittest.mock.MagicMock() 44 | mock_meth.return_value = None 45 | self.dispatcher.map("/SYNC", mock_meth, 1, 2, 3) 46 | osc_server._UDPHandler( 47 | [_SIMPLE_PARAM_INT_MSG, None], self.client_address, self.server 48 | ) 49 | mock_meth.assert_called_with("/SYNC", [1, 2, 3], 4) 50 | 51 | def test_match_int9(self): 52 | mock_meth = unittest.mock.MagicMock() 53 | mock_meth.return_value = None 54 | self.dispatcher.map("/debug", mock_meth) 55 | osc_server._UDPHandler( 56 | [_SIMPLE_PARAM_INT_9, None], self.client_address, self.server 57 | ) 58 | self.assertTrue(mock_meth.called) 59 | mock_meth.assert_called_with("/debug", 9) 60 | 61 | def test_match_without_args(self): 62 | mock_meth = unittest.mock.MagicMock() 63 | mock_meth.return_value = None 64 | self.dispatcher.map("/SYNC", mock_meth) 65 | osc_server._UDPHandler( 66 | [_SIMPLE_MSG_NO_PARAMS, None], self.client_address, self.server 67 | ) 68 | mock_meth.assert_called_with("/SYNC") 69 | 70 | def test_match_default_handler(self): 71 | mock_meth = unittest.mock.MagicMock() 72 | mock_meth.return_value = None 73 | self.dispatcher.set_default_handler(mock_meth) 74 | osc_server._UDPHandler( 75 | [_SIMPLE_MSG_NO_PARAMS, None], self.client_address, self.server 76 | ) 77 | mock_meth.assert_called_with("/SYNC") 78 | 79 | def test_response_no_args(self): 80 | def respond(*args, **kwargs): 81 | return "/SYNC" 82 | 83 | mock_sock = unittest.mock.Mock() 84 | mock_sock.sendto = unittest.mock.Mock() 85 | self.dispatcher.map("/SYNC", respond) 86 | osc_server._UDPHandler( 87 | (_SIMPLE_PARAM_INT_MSG, mock_sock), self.client_address, self.server 88 | ) 89 | mock_sock.sendto.assert_called_with( 90 | b"/SYNC\00\00\00,\00\00\00", ("127.0.0.1", 8080) 91 | ) 92 | 93 | def test_response_with_args(self): 94 | def respond(*args, **kwargs): 95 | return ( 96 | "/SYNC", 97 | 1, 98 | "2", 99 | 3.0, 100 | ) 101 | 102 | self.dispatcher.map("/SYNC", respond) 103 | mock_sock = unittest.mock.Mock() 104 | mock_sock.sendto = unittest.mock.Mock() 105 | self.dispatcher.map("/SYNC", respond) 106 | osc_server._UDPHandler( 107 | (_SIMPLE_PARAM_INT_MSG, mock_sock), self.client_address, self.server 108 | ) 109 | mock_sock.sendto.assert_called_with( 110 | b"/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00", 111 | ("127.0.0.1", 8080), 112 | ) 113 | 114 | 115 | if __name__ == "__main__": 116 | unittest.main() 117 | -------------------------------------------------------------------------------- /pythonosc/udp_client.py: -------------------------------------------------------------------------------- 1 | """UDP Clients for sending OSC messages to an OSC server""" 2 | 3 | import sys 4 | 5 | if sys.version_info > (3, 5): 6 | from collections.abc import Iterable 7 | else: 8 | from collections import Iterable 9 | 10 | import socket 11 | from typing import Generator, Union 12 | 13 | from pythonosc.dispatcher import Dispatcher 14 | from pythonosc.osc_bundle import OscBundle 15 | from pythonosc.osc_message import OscMessage 16 | from pythonosc.osc_message_builder import ArgValue, OscMessageBuilder 17 | 18 | 19 | class UDPClient(object): 20 | """OSC client to send :class:`OscMessage` or :class:`OscBundle` via UDP""" 21 | 22 | def __init__( 23 | self, 24 | address: str, 25 | port: int, 26 | allow_broadcast: bool = False, 27 | family: socket.AddressFamily = socket.AF_UNSPEC, 28 | ) -> None: 29 | """Initialize client 30 | 31 | As this is UDP it will not actually make any attempt to connect to the 32 | given server at ip:port until the send() method is called. 33 | 34 | Args: 35 | address: IP address of server 36 | port: Port of server 37 | allow_broadcast: Allow for broadcast transmissions 38 | family: address family parameter (passed to socket.getaddrinfo) 39 | """ 40 | 41 | for addr in socket.getaddrinfo( 42 | address, port, type=socket.SOCK_DGRAM, family=family 43 | ): 44 | af, socktype, protocol, canonname, sa = addr 45 | 46 | try: 47 | self._sock = socket.socket(af, socktype) 48 | except OSError: 49 | continue 50 | break 51 | 52 | self._sock.setblocking(False) 53 | if allow_broadcast: 54 | self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 55 | self._address = address 56 | self._port = port 57 | 58 | def send(self, content: Union[OscMessage, OscBundle]) -> None: 59 | """Sends an :class:`OscMessage` or :class:`OscBundle` via UDP 60 | 61 | Args: 62 | content: Message or bundle to be sent 63 | """ 64 | self._sock.sendto(content.dgram, (self._address, self._port)) 65 | 66 | def receive(self, timeout: int = 30) -> bytes: 67 | """Wait :int:`timeout` seconds for a message an return the raw bytes 68 | 69 | Args: 70 | timeout: Number of seconds to wait for a message 71 | """ 72 | self._sock.settimeout(timeout) 73 | try: 74 | return self._sock.recv(4096) 75 | except TimeoutError: 76 | return b"" 77 | 78 | 79 | class SimpleUDPClient(UDPClient): 80 | """Simple OSC client that automatically builds :class:`OscMessage` from arguments""" 81 | 82 | def send_message(self, address: str, value: ArgValue) -> None: 83 | """Build :class:`OscMessage` from arguments and send to server 84 | 85 | Args: 86 | address: OSC address the message shall go to 87 | value: One or more arguments to be added to the message 88 | """ 89 | builder = OscMessageBuilder(address=address) 90 | values: ArgValue 91 | if value is None: 92 | pass 93 | elif not isinstance(value, Iterable) or isinstance(value, (str, bytes)): 94 | builder.add_arg(value) 95 | else: 96 | for val in value: 97 | builder.add_arg(val) 98 | msg = builder.build() 99 | self.send(msg) 100 | 101 | def get_messages(self, timeout: int = 30) -> Generator: 102 | """Wait :int:`timeout` seconds for a message from the server and convert it to a :class:`OscMessage` 103 | 104 | Args: 105 | timeout: Time in seconds to wait for a message 106 | """ 107 | msg = self.receive(timeout) 108 | while msg: 109 | yield OscMessage(msg) 110 | msg = self.receive(timeout) 111 | 112 | 113 | class DispatchClient(SimpleUDPClient): 114 | """OSC Client that includes a :class:`Dispatcher` for handling responses and other messages from the server""" 115 | 116 | dispatcher = Dispatcher() 117 | 118 | def handle_messages(self, timeout: int = 30) -> None: 119 | """Wait :int:`timeout` seconds for a message from the server and process each message with the registered 120 | handlers. Continue until a timeout occurs. 121 | 122 | Args: 123 | timeout: Time in seconds to wait for a message 124 | """ 125 | msg = self.receive(timeout) 126 | while msg: 127 | self.dispatcher.call_handlers_for_packet(msg, (self._address, self._port)) 128 | msg = self.receive(timeout) 129 | -------------------------------------------------------------------------------- /docs/dispatcher.rst: -------------------------------------------------------------------------------- 1 | Dispatcher 2 | ============ 3 | 4 | The dispatcher maps OSC addresses to functions and calls the functions with the messages' arguments. 5 | Function can also be mapped to wildcard addresses. 6 | 7 | 8 | Example 9 | --------- 10 | 11 | .. code-block:: python 12 | 13 | from pythonosc.dispatcher import Dispatcher 14 | from typing import List, Any 15 | 16 | dispatcher = Dispatcher() 17 | 18 | 19 | def set_filter(address: str, *args: List[Any]) -> None: 20 | # We expect two float arguments 21 | if not len(args) == 2 or type(args[0]) is not float or type(args[1]) is not float: 22 | return 23 | 24 | # Check that address starts with filter 25 | if not address[:-1] == "/filter": # Cut off the last character 26 | return 27 | 28 | value1 = args[0] 29 | value2 = args[1] 30 | filterno = address[-1] 31 | print(f"Setting filter {filterno} values: {value1}, {value2}") 32 | 33 | 34 | dispatcher.map("/filter*", set_filter) # Map wildcard address to set_filter function 35 | 36 | # Set up server and client for testing 37 | from pythonosc.osc_server import BlockingOSCUDPServer 38 | from pythonosc.udp_client import SimpleUDPClient 39 | 40 | server = BlockingOSCUDPServer(("127.0.0.1", 1337), dispatcher) 41 | client = SimpleUDPClient("127.0.0.1", 1337) 42 | 43 | # Send message and receive exactly one message (blocking) 44 | client.send_message("/filter1", [1., 2.]) 45 | server.handle_request() 46 | 47 | client.send_message("/filter8", [6., -2.]) 48 | server.handle_request() 49 | 50 | 51 | Mapping 52 | --------- 53 | 54 | The dispatcher associates addresses with functions by storing them in a mapping. 55 | An address can contains wildcards as defined in the OSC specifications. 56 | Call the ``Dispatcher.map`` method with an address pattern and a handler callback function: 57 | 58 | .. code-block:: python 59 | 60 | from pythonosc.dispatcher import Dispatcher 61 | disp = Dispatcher() 62 | disp.map("/some/address*", some_printing_func) 63 | 64 | This will for example print any OSC messages starting with ``/some/address``. 65 | 66 | Additionally you can provide any amount of extra fixed argument that will always be passed before the OSC message arguments: 67 | 68 | .. code-block:: python 69 | 70 | handler = disp.map("/some/other/address", some_printing_func, "This is a fixed arg", "and this is another fixed arg") 71 | 72 | The handler callback signature must look like this: 73 | 74 | .. code-block:: python 75 | 76 | def some_callback(address: str, *osc_arguments: List[Any]) -> None: 77 | def some_callback(address: str, fixed_argument: List[Any], *osc_arguments: List[Any]) -> None: 78 | 79 | Instead of a list you can of course also use a fixed amount of arguments for ``osc_arguments`` 80 | 81 | The ``Dispatcher.map`` method returns a ``Handler`` object, which can be used to remove the mapping from the dispatcher. 82 | 83 | 84 | Unmapping 85 | ----------- 86 | 87 | A mapping can be undone with the ``Dispatcher.unmap`` method, which takes an address and ``Handler`` object as arguments. 88 | For example, to unmap the mapping from the `Mapping`_ section: 89 | 90 | .. code-block:: python 91 | 92 | disp.unmap("some/other/address", handler) 93 | 94 | Alternatively the handler can be reconstructed from a function and optional fixed argument: 95 | 96 | .. code-block:: python 97 | 98 | disp.unmap("some/other/address", some_printing_func, *some_fixed_args) 99 | 100 | If the provided mapping doesn't exist, a ``ValueError`` is raised. 101 | 102 | 103 | Default Handler 104 | ----------------- 105 | 106 | It is possible to specify a handler callback function that is called for every unmatched address: 107 | 108 | .. code-block:: python 109 | 110 | disp.set_default_handler(some_handler_function) 111 | 112 | This is extremely useful if you quickly need to find out what addresses an undocumented device is transmitting on or for building a learning function for some controls. 113 | The handler must have the same signature as map callbacks: 114 | 115 | .. code-block:: python 116 | 117 | def some_callback(address: str, *osc_arguments: List[Any]) -> None: 118 | 119 | 120 | Handler Responses 121 | ----------------- 122 | 123 | Handler functions can return responses back to the client, when running on a server, or to the 124 | server when running as a client. Handler functions should return one of: 125 | 126 | * None 127 | * An OSC address in string format 128 | * A tuple containing a string OSC address and the associated arguments 129 | 130 | If the handler function response is not None it will be encoded in an OSCMessage and sent to the 131 | remote client or server. 132 | 133 | Dispatcher Module Documentation 134 | --------------------------------- 135 | 136 | .. automodule:: pythonosc.dispatcher 137 | :special-members: 138 | :members: 139 | :exclude-members: __weakref__ 140 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | python-osc 3 | ========== 4 | 5 | Open Sound Control server and client implementations in **pure python**. 6 | 7 | .. image:: https://github.com/attwad/python-osc/actions/workflows/python-test.yml/badge.svg 8 | :target: https://github.com/attwad/python-osc/actions/workflows/python-test.yml 9 | 10 | 11 | Current status 12 | ============== 13 | 14 | This library was developed following the 15 | `OpenSoundControl Specification 1.0 `_ 16 | and is currently in a stable state. 17 | 18 | Features 19 | ======== 20 | 21 | * UDP and TCP blocking/threading/forking/asyncio server implementations 22 | * UDP and TCP clients, including asyncio support 23 | * TCP support for 1.0 and 1.1 protocol formats 24 | * int, int64, float, string, double, MIDI, timestamps, blob, nil OSC arguments 25 | * simple OSC address<->callback matching system 26 | * support for sending responses from callback handlers in client and server 27 | * extensive unit test coverage 28 | * basic client and server examples 29 | 30 | Documentation 31 | ============= 32 | 33 | Available at https://python-osc.readthedocs.io/. 34 | 35 | Installation 36 | ============ 37 | 38 | python-osc is a pure python library that has no external dependencies, 39 | to install it just use pip (prefered): 40 | 41 | .. image:: https://img.shields.io/pypi/v/python-osc.svg 42 | :target: https://pypi.python.org/pypi/python-osc 43 | 44 | .. code-block:: bash 45 | 46 | $ pip install python-osc 47 | 48 | Examples 49 | ======== 50 | 51 | Simple client 52 | ------------- 53 | 54 | .. code-block:: python 55 | 56 | """Small example OSC client 57 | 58 | This program sends 10 random values between 0.0 and 1.0 to the /filter address, 59 | waiting for 1 seconds between each value. 60 | """ 61 | import argparse 62 | import random 63 | import time 64 | 65 | from pythonosc import udp_client 66 | 67 | 68 | if __name__ == "__main__": 69 | parser = argparse.ArgumentParser() 70 | parser.add_argument("--ip", default="127.0.0.1", 71 | help="The ip of the OSC server") 72 | parser.add_argument("--port", type=int, default=5005, 73 | help="The port the OSC server is listening on") 74 | args = parser.parse_args() 75 | 76 | client = udp_client.SimpleUDPClient(args.ip, args.port) 77 | 78 | for x in range(10): 79 | client.send_message("/filter", random.random()) 80 | time.sleep(1) 81 | 82 | Simple server 83 | ------------- 84 | 85 | .. code-block:: python 86 | 87 | """Small example OSC server 88 | 89 | This program listens to several addresses, and prints some information about 90 | received packets. 91 | """ 92 | import argparse 93 | import math 94 | 95 | from pythonosc.dispatcher import Dispatcher 96 | from pythonosc import osc_server 97 | 98 | def print_volume_handler(unused_addr, args, volume): 99 | print("[{0}] ~ {1}".format(args[0], volume)) 100 | 101 | def print_compute_handler(unused_addr, args, volume): 102 | try: 103 | print("[{0}] ~ {1}".format(args[0], args[1](volume))) 104 | except ValueError: pass 105 | 106 | if __name__ == "__main__": 107 | parser = argparse.ArgumentParser() 108 | parser.add_argument("--ip", 109 | default="127.0.0.1", help="The ip to listen on") 110 | parser.add_argument("--port", 111 | type=int, default=5005, help="The port to listen on") 112 | args = parser.parse_args() 113 | 114 | dispatcher = Dispatcher() 115 | dispatcher.map("/filter", print) 116 | dispatcher.map("/volume", print_volume_handler, "Volume") 117 | dispatcher.map("/logvolume", print_compute_handler, "Log volume", math.log) 118 | 119 | server = osc_server.ThreadingOSCUDPServer( 120 | (args.ip, args.port), dispatcher) 121 | print("Serving on {}".format(server.server_address)) 122 | server.serve_forever() 123 | 124 | Building bundles 125 | ---------------- 126 | 127 | .. code-block:: python 128 | 129 | from pythonosc import osc_bundle_builder 130 | from pythonosc import osc_message_builder 131 | 132 | bundle = osc_bundle_builder.OscBundleBuilder( 133 | osc_bundle_builder.IMMEDIATELY) 134 | msg = osc_message_builder.OscMessageBuilder(address="/SYNC") 135 | msg.add_arg(4.0) 136 | # Add 4 messages in the bundle, each with more arguments. 137 | bundle.add_content(msg.build()) 138 | msg.add_arg(2) 139 | bundle.add_content(msg.build()) 140 | msg.add_arg("value") 141 | bundle.add_content(msg.build()) 142 | msg.add_arg(b"\x01\x02\x03") 143 | bundle.add_content(msg.build()) 144 | 145 | sub_bundle = bundle.build() 146 | # Now add the same bundle inside itself. 147 | bundle.add_content(sub_bundle) 148 | # The bundle has 5 elements in total now. 149 | 150 | bundle = bundle.build() 151 | # You can now send it via a client with the `.send()` method: 152 | client.send(bundle) 153 | 154 | License? 155 | ======== 156 | Unlicensed, do what you want with it. (http://unlicense.org) 157 | -------------------------------------------------------------------------------- /pythonosc/osc_message.py: -------------------------------------------------------------------------------- 1 | """Representation of an OSC message in a pythonesque way.""" 2 | 3 | import logging 4 | 5 | from pythonosc.parsing import osc_types 6 | from typing import List, Iterator, Any 7 | 8 | 9 | class ParseError(Exception): 10 | """Base exception raised when a datagram parsing error occurs.""" 11 | 12 | 13 | class OscMessage(object): 14 | """Representation of a parsed datagram representing an OSC message. 15 | 16 | An OSC message consists of an OSC Address Pattern followed by an OSC 17 | Type Tag String followed by zero or more OSC Arguments. 18 | """ 19 | 20 | def __init__(self, dgram: bytes) -> None: 21 | self._dgram = dgram 22 | self._parameters = [] # type: List[Any] 23 | self._parse_datagram() 24 | 25 | def __str__(self): 26 | return f"{self.address} {' '.join(str(p) for p in self.params)}" 27 | 28 | def _parse_datagram(self) -> None: 29 | try: 30 | self._address_regexp, index = osc_types.get_string(self._dgram, 0) 31 | if not self._dgram[index:]: 32 | # No params is legit, just return now. 33 | return 34 | 35 | # Get the parameters types. 36 | type_tag, index = osc_types.get_string(self._dgram, index) 37 | if type_tag.startswith(","): 38 | type_tag = type_tag[1:] 39 | 40 | params = [] # type: List[Any] 41 | param_stack = [params] 42 | # Parse each parameter given its type. 43 | for param in type_tag: 44 | val = NotImplemented # type: Any 45 | if param == "i": # Integer. 46 | val, index = osc_types.get_int(self._dgram, index) 47 | elif param == "h": # Int64. 48 | val, index = osc_types.get_int64(self._dgram, index) 49 | elif param == "f": # Float. 50 | val, index = osc_types.get_float(self._dgram, index) 51 | elif param == "d": # Double. 52 | val, index = osc_types.get_double(self._dgram, index) 53 | elif param == "s": # String. 54 | val, index = osc_types.get_string(self._dgram, index) 55 | elif param == "b": # Blob. 56 | val, index = osc_types.get_blob(self._dgram, index) 57 | elif param == "r": # RGBA. 58 | val, index = osc_types.get_rgba(self._dgram, index) 59 | elif param == "m": # MIDI. 60 | val, index = osc_types.get_midi(self._dgram, index) 61 | elif param == "t": # osc time tag: 62 | val, index = osc_types.get_timetag(self._dgram, index) 63 | elif param == "T": # True. 64 | val = True 65 | elif param == "F": # False. 66 | val = False 67 | elif param == "N": # Nil. 68 | val = None 69 | elif param == "[": # Array start. 70 | array = [] # type: List[Any] 71 | param_stack[-1].append(array) 72 | param_stack.append(array) 73 | elif param == "]": # Array stop. 74 | if len(param_stack) < 2: 75 | raise ParseError( 76 | f"Unexpected closing bracket in type tag: {type_tag}" 77 | ) 78 | param_stack.pop() 79 | # TODO: Support more exotic types as described in the specification. 80 | else: 81 | logging.warning(f"Unhandled parameter type: {param}") 82 | continue 83 | if param not in "[]": 84 | param_stack[-1].append(val) 85 | if len(param_stack) != 1: 86 | raise ParseError(f"Missing closing bracket in type tag: {type_tag}") 87 | self._parameters = params 88 | except osc_types.ParseError as pe: 89 | raise ParseError("Found incorrect datagram, ignoring it", pe) 90 | 91 | @property 92 | def address(self) -> str: 93 | """Returns the OSC address regular expression.""" 94 | return self._address_regexp 95 | 96 | @staticmethod 97 | def dgram_is_message(dgram: bytes) -> bool: 98 | """Returns whether this datagram starts as an OSC message.""" 99 | return dgram.startswith(b"/") 100 | 101 | @property 102 | def size(self) -> int: 103 | """Returns the length of the datagram for this message.""" 104 | return len(self._dgram) 105 | 106 | @property 107 | def dgram(self) -> bytes: 108 | """Returns the datagram from which this message was built.""" 109 | return self._dgram 110 | 111 | @property 112 | def params(self) -> List[Any]: 113 | """Convenience method for list(self) to get the list of parameters.""" 114 | return list(self) 115 | 116 | def __iter__(self) -> Iterator[Any]: 117 | """Returns an iterator over the parameters of this message.""" 118 | return iter(self._parameters) 119 | -------------------------------------------------------------------------------- /docs/server.rst: -------------------------------------------------------------------------------- 1 | Server 2 | ========= 3 | 4 | The server receives OSC Messages from connected clients and invoked the appropriate callback functions with the dispatcher. There are several server types available. 5 | Server implementations are available for both UDP and TCP protocols. 6 | 7 | Blocking Server 8 | ----------------- 9 | 10 | The blocking server type is the simplest of them all. Once it starts to serve, it blocks the program execution forever and remains idle inbetween handling requests. This type is good enough if your application is very simple and only has to react to OSC messages coming in and nothing else. 11 | 12 | .. code-block:: python 13 | 14 | from pythonosc.dispatcher import Dispatcher 15 | from pythonosc.osc_server import BlockingOSCUDPServer 16 | 17 | 18 | def print_handler(address, *args): 19 | print(f"{address}: {args}") 20 | 21 | 22 | def default_handler(address, *args): 23 | print(f"DEFAULT {address}: {args}") 24 | 25 | 26 | dispatcher = Dispatcher() 27 | dispatcher.map("/something/*", print_handler) 28 | dispatcher.set_default_handler(default_handler) 29 | 30 | ip = "127.0.0.1" 31 | port = 1337 32 | 33 | server = BlockingOSCUDPServer((ip, port), dispatcher) 34 | server.serve_forever() # Blocks forever 35 | 36 | 37 | Threading Server 38 | ------------------ 39 | 40 | Each incoming packet will be handled in it's own thread. This also blocks further program execution, but allows concurrent handling of multiple incoming messages. Otherwise usage is identical to blocking type. Use for lightweight message handlers. 41 | 42 | 43 | Forking Server 44 | ----------------- 45 | 46 | The process is forked every time a packet is coming in. Also blocks program execution forever. Use for heavyweight message handlers. 47 | 48 | 49 | Async Server 50 | ------------- 51 | 52 | This server type takes advantage of the asyncio functionality of python, and allows truly non-blocking parallel execution of both your main loop and the server loop. You can use it in two ways, exclusively and concurrently. In the concurrent mode other tasks (like a main loop) can run in parallel to the server, meaning that the server doesn't block further program execution. In exclusive mode the server task is the only task that is started. 53 | 54 | Concurrent Mode 55 | ^^^^^^^^^^^^^^^^^ 56 | 57 | Use this mode if you have a main program loop that needs to run without being blocked by the server. The below example runs ``init_main()`` once, which creates the serve endpoint and adds it to the asyncio event loop. The transport object is returned, which is required later to clean up the endpoint and release the socket. Afterwards we start the main loop with ``await loop()``. The example loop runs 10 times and sleeps for a second on every iteration. During the sleep the program execution is handed back to the event loop which gives the serve endpoint a chance to handle incoming OSC messages. Your loop needs to at least do an ``await asyncio.sleep(0)`` every iteration, otherwise your main loop will never release program control back to the event loop. 58 | 59 | .. code-block:: python 60 | 61 | from pythonosc.osc_server import AsyncIOOSCUDPServer 62 | from pythonosc.dispatcher import Dispatcher 63 | import asyncio 64 | 65 | 66 | def filter_handler(address, *args): 67 | print(f"{address}: {args}") 68 | 69 | 70 | dispatcher = Dispatcher() 71 | dispatcher.map("/filter", filter_handler) 72 | 73 | ip = "127.0.0.1" 74 | port = 1337 75 | 76 | 77 | async def loop(): 78 | """Example main loop that only runs for 10 iterations before finishing""" 79 | for i in range(10): 80 | print(f"Loop {i}") 81 | await asyncio.sleep(1) 82 | 83 | 84 | async def init_main(): 85 | server = AsyncIOOSCUDPServer((ip, port), dispatcher, asyncio.get_event_loop()) 86 | transport, protocol = await server.create_serve_endpoint() # Create datagram endpoint and start serving 87 | 88 | await loop() # Enter main loop of program 89 | 90 | transport.close() # Clean up serve endpoint 91 | 92 | 93 | asyncio.run(init_main()) 94 | 95 | 96 | Exclusive Mode 97 | ^^^^^^^^^^^^^^^^^ 98 | 99 | This mode comes without a main loop. You only have the OSC server running in the event loop initially. You could of course use an OSC message to start a main loop from within a handler. 100 | 101 | .. code-block:: python 102 | 103 | from pythonosc.osc_server import AsyncIOOSCUDPServer 104 | from pythonosc.dispatcher import Dispatcher 105 | import asyncio 106 | 107 | 108 | def filter_handler(address, *args): 109 | print(f"{address}: {args}") 110 | 111 | 112 | dispatcher = Dispatcher() 113 | dispatcher.map("/filter", filter_handler) 114 | 115 | ip = "127.0.0.1" 116 | port = 1337 117 | 118 | server = AsyncIOOSCUDPServer((ip, port), dispatcher, asyncio.get_event_loop()) 119 | server.serve() 120 | 121 | 122 | Server Module Documentation 123 | ------------------------------ 124 | 125 | .. automodule:: pythonosc.osc_server 126 | :special-members: 127 | :members: 128 | :exclude-members: __weakref__ 129 | 130 | .. automodule:: pythonosc.osc_tcp_server 131 | :special-members: 132 | :members: 133 | :exclude-members: __weakref__ -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | # import os 16 | # import sys 17 | # sys.path.insert(0, os.path.abspath('.')) 18 | import os 19 | import sys 20 | 21 | sys.path.insert(0, os.path.abspath("..")) 22 | 23 | # -- Project information ----------------------------------------------------- 24 | 25 | project = "python-osc" 26 | copyright = "2019, attwad" 27 | author = "attwad" 28 | 29 | # The short X.Y version 30 | version = "" 31 | # The full version, including alpha/beta/rc tags 32 | release = "1.7.1" 33 | 34 | 35 | # -- General configuration --------------------------------------------------- 36 | 37 | # If your documentation needs a minimal Sphinx version, state it here. 38 | # 39 | # needs_sphinx = '1.0' 40 | 41 | # Add any Sphinx extension module names here, as strings. They can be 42 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 43 | # ones. 44 | extensions = [ 45 | "sphinx.ext.autodoc", 46 | "sphinx.ext.doctest", 47 | "sphinx.ext.coverage", 48 | "sphinx.ext.viewcode", 49 | "sphinx.ext.napoleon", 50 | ] 51 | 52 | # Add any paths that contain templates here, relative to this directory. 53 | templates_path = ["_templates"] 54 | 55 | # The suffix(es) of source filenames. 56 | # You can specify multiple suffix as a list of string: 57 | # 58 | # source_suffix = ['.rst', '.md'] 59 | source_suffix = ".rst" 60 | 61 | # The master toctree document. 62 | master_doc = "index" 63 | 64 | # The language for content autogenerated by Sphinx. Refer to documentation 65 | # for a list of supported languages. 66 | # 67 | # This is also used if you do content translation via gettext catalogs. 68 | # Usually you set "language" from the command line for these cases. 69 | language = None 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | # This pattern also affects html_static_path and html_extra_path. 74 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 75 | 76 | # The name of the Pygments (syntax highlighting) style to use. 77 | pygments_style = None 78 | 79 | 80 | # -- Options for HTML output ------------------------------------------------- 81 | 82 | # The theme to use for HTML and HTML Help pages. See the documentation for 83 | # a list of builtin themes. 84 | # 85 | # html_theme = "sphinx_rtd_theme" 86 | 87 | # Theme options are theme-specific and customize the look and feel of a theme 88 | # further. For a list of options available for each theme, see the 89 | # documentation. 90 | # 91 | # html_theme_options = {} 92 | 93 | # Add any paths that contain custom static files (such as style sheets) here, 94 | # relative to this directory. They are copied after the builtin static files, 95 | # so a file named "default.css" will overwrite the builtin "default.css". 96 | html_static_path = ["_static"] 97 | 98 | # Custom sidebar templates, must be a dictionary that maps document names 99 | # to template names. 100 | # 101 | # The default sidebars (for documents that don't match any pattern) are 102 | # defined by theme itself. Builtin themes are using these templates by 103 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 104 | # 'searchbox.html']``. 105 | # 106 | # html_sidebars = {} 107 | 108 | 109 | # -- Options for HTMLHelp output --------------------------------------------- 110 | 111 | # Output file base name for HTML help builder. 112 | htmlhelp_basename = "python-oscdoc" 113 | 114 | 115 | # -- Options for LaTeX output ------------------------------------------------ 116 | 117 | latex_elements = { 118 | # The paper size ('letterpaper' or 'a4paper'). 119 | # 120 | # 'papersize': 'letterpaper', 121 | # The font size ('10pt', '11pt' or '12pt'). 122 | # 123 | # 'pointsize': '10pt', 124 | # Additional stuff for the LaTeX preamble. 125 | # 126 | # 'preamble': '', 127 | # Latex figure (float) alignment 128 | # 129 | # 'figure_align': 'htbp', 130 | } 131 | 132 | # Grouping the document tree into LaTeX files. List of tuples 133 | # (source start file, target name, title, 134 | # author, documentclass [howto, manual, or own class]). 135 | latex_documents = [ 136 | (master_doc, "python-osc.tex", "python-osc Documentation", "attwad", "manual"), 137 | ] 138 | 139 | 140 | # -- Options for manual page output ------------------------------------------ 141 | 142 | # One entry per manual page. List of tuples 143 | # (source start file, name, description, authors, manual section). 144 | man_pages = [(master_doc, "python-osc", "python-osc Documentation", [author], 1)] 145 | 146 | 147 | # -- Options for Texinfo output ---------------------------------------------- 148 | 149 | # Grouping the document tree into Texinfo files. List of tuples 150 | # (source start file, target name, title, author, 151 | # dir menu entry, description, category) 152 | texinfo_documents = [ 153 | ( 154 | master_doc, 155 | "python-osc", 156 | "python-osc Documentation", 157 | author, 158 | "python-osc", 159 | "One line description of project.", 160 | "Miscellaneous", 161 | ), 162 | ] 163 | 164 | 165 | # -- Options for Epub output ------------------------------------------------- 166 | 167 | # Bibliographic Dublin Core info. 168 | epub_title = project 169 | 170 | # The unique identifier of the text. This can be a ISBN number 171 | # or the project homepage. 172 | # 173 | # epub_identifier = '' 174 | 175 | # A unique identification for the text. 176 | # 177 | # epub_uid = '' 178 | 179 | # A list of files that should not be packed into the epub file. 180 | epub_exclude_files = ["search.html"] 181 | 182 | 183 | # -- Extension configuration ------------------------------------------------- 184 | -------------------------------------------------------------------------------- /pythonosc/test/test_dispatcher.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from pythonosc.dispatcher import Dispatcher, Handler 4 | 5 | 6 | class TestDispatcher(unittest.TestCase): 7 | def setUp(self): 8 | super().setUp() 9 | self.dispatcher = Dispatcher() 10 | 11 | def sortAndAssertSequenceEqual(self, expected, result): 12 | def sort(lst): 13 | return sorted(lst, key=lambda x: x.callback) 14 | 15 | return self.assertSequenceEqual(sort(expected), sort(result)) 16 | 17 | def test_empty_by_default(self): 18 | self.sortAndAssertSequenceEqual( 19 | [], self.dispatcher.handlers_for_address("/test") 20 | ) 21 | 22 | def test_use_default_handler_when_set_and_no_match(self): 23 | handler = object() 24 | self.dispatcher.set_default_handler(handler) 25 | 26 | self.sortAndAssertSequenceEqual( 27 | [Handler(handler, [])], self.dispatcher.handlers_for_address("/test") 28 | ) 29 | 30 | def test_simple_map_and_match(self): 31 | handler = object() 32 | self.dispatcher.map("/test", handler, 1, 2, 3) 33 | self.dispatcher.map("/test2", handler) 34 | self.sortAndAssertSequenceEqual( 35 | [Handler(handler, [1, 2, 3])], self.dispatcher.handlers_for_address("/test") 36 | ) 37 | self.sortAndAssertSequenceEqual( 38 | [Handler(handler, [])], self.dispatcher.handlers_for_address("/test2") 39 | ) 40 | 41 | def test_example_from_spec(self): 42 | addresses = [ 43 | "/first/this/one", 44 | "/second/1", 45 | "/second/2", 46 | "/third/a", 47 | "/third/b", 48 | "/third/c", 49 | ] 50 | for index, address in enumerate(addresses): 51 | self.dispatcher.map(address, index) 52 | 53 | for index, address in enumerate(addresses): 54 | self.sortAndAssertSequenceEqual( 55 | [Handler(index, [])], self.dispatcher.handlers_for_address(address) 56 | ) 57 | 58 | self.sortAndAssertSequenceEqual( 59 | [Handler(1, []), Handler(2, [])], 60 | self.dispatcher.handlers_for_address("/second/?"), 61 | ) 62 | 63 | self.sortAndAssertSequenceEqual( 64 | [Handler(3, []), Handler(4, []), Handler(5, [])], 65 | self.dispatcher.handlers_for_address("/third/*"), 66 | ) 67 | 68 | def test_do_not_match_over_slash(self): 69 | self.dispatcher.map("/foo/bar/1", 1) 70 | self.dispatcher.map("/foo/bar/2", 2) 71 | 72 | self.sortAndAssertSequenceEqual([], self.dispatcher.handlers_for_address("/*")) 73 | 74 | def test_match_middle_star(self): 75 | self.dispatcher.map("/foo/bar/1", 1) 76 | self.dispatcher.map("/foo/bar/2", 2) 77 | 78 | self.sortAndAssertSequenceEqual( 79 | [Handler(2, [])], self.dispatcher.handlers_for_address("/foo/*/2") 80 | ) 81 | 82 | def test_match_multiple_stars(self): 83 | self.dispatcher.map("/foo/bar/1", 1) 84 | self.dispatcher.map("/foo/bar/2", 2) 85 | 86 | self.sortAndAssertSequenceEqual( 87 | [Handler(1, []), Handler(2, [])], 88 | self.dispatcher.handlers_for_address("/*/*/*"), 89 | ) 90 | 91 | def test_match_address_contains_plus_as_character(self): 92 | self.dispatcher.map("/footest/bar+tender/1", 1) 93 | 94 | self.sortAndAssertSequenceEqual( 95 | [Handler(1, [])], self.dispatcher.handlers_for_address("/foo*/bar+*/*") 96 | ) 97 | self.sortAndAssertSequenceEqual( 98 | [Handler(1, [])], self.dispatcher.handlers_for_address("/foo*/bar*/*") 99 | ) 100 | 101 | def test_call_correct_dispatcher_on_star(self): 102 | self.dispatcher.map("/a+b", 1) 103 | self.dispatcher.map("/aaab", 2) 104 | self.sortAndAssertSequenceEqual( 105 | [Handler(2, [])], self.dispatcher.handlers_for_address("/aaab") 106 | ) 107 | self.sortAndAssertSequenceEqual( 108 | [Handler(1, [])], self.dispatcher.handlers_for_address("/a+b") 109 | ) 110 | 111 | def test_map_star(self): 112 | self.dispatcher.map("/starbase/*", 1) 113 | self.sortAndAssertSequenceEqual( 114 | [Handler(1, [])], self.dispatcher.handlers_for_address("/starbase/bar") 115 | ) 116 | 117 | def test_map_root_star(self): 118 | self.dispatcher.map("/*", 1) 119 | self.sortAndAssertSequenceEqual( 120 | [Handler(1, [])], self.dispatcher.handlers_for_address("/anything/matches") 121 | ) 122 | 123 | def test_map_double_stars(self): 124 | self.dispatcher.map("/foo/*/bar/*", 1) 125 | self.sortAndAssertSequenceEqual( 126 | [Handler(1, [])], self.dispatcher.handlers_for_address("/foo/wild/bar/wild") 127 | ) 128 | self.sortAndAssertSequenceEqual( 129 | [], self.dispatcher.handlers_for_address("/foo/wild/nomatch/wild") 130 | ) 131 | 132 | def test_multiple_handlers(self): 133 | self.dispatcher.map("/foo/bar", 1) 134 | self.dispatcher.map("/foo/bar", 2) 135 | self.sortAndAssertSequenceEqual( 136 | [Handler(1, []), Handler(2, [])], 137 | self.dispatcher.handlers_for_address("/foo/bar"), 138 | ) 139 | 140 | def test_multiple_handlers_with_wildcard_map(self): 141 | self.dispatcher.map("/foo/bar", 1) 142 | self.dispatcher.map("/*", 2) 143 | self.sortAndAssertSequenceEqual( 144 | [Handler(1, []), Handler(2, [])], 145 | self.dispatcher.handlers_for_address("/foo/bar"), 146 | ) 147 | 148 | def test_unmap(self): 149 | def dummyhandler(): 150 | pass 151 | 152 | # Test with handler returned by map 153 | returnedhandler = self.dispatcher.map("/map/me", dummyhandler) 154 | self.sortAndAssertSequenceEqual( 155 | [Handler(dummyhandler, [])], self.dispatcher.handlers_for_address("/map/me") 156 | ) 157 | self.dispatcher.unmap("/map/me", returnedhandler) 158 | self.sortAndAssertSequenceEqual( 159 | [], self.dispatcher.handlers_for_address("/map/me") 160 | ) 161 | 162 | # Test with reconstructing handler 163 | self.dispatcher.map("/map/me/too", dummyhandler) 164 | self.sortAndAssertSequenceEqual( 165 | [Handler(dummyhandler, [])], 166 | self.dispatcher.handlers_for_address("/map/me/too"), 167 | ) 168 | self.dispatcher.unmap("/map/me/too", dummyhandler) 169 | self.sortAndAssertSequenceEqual( 170 | [], self.dispatcher.handlers_for_address("/map/me/too") 171 | ) 172 | 173 | def test_unmap_exception(self): 174 | def dummyhandler(): 175 | pass 176 | 177 | with self.assertRaises(ValueError): 178 | self.dispatcher.unmap("/unmap/exception", dummyhandler) 179 | 180 | handlerobj = self.dispatcher.map("/unmap/somethingelse", dummyhandler()) 181 | with self.assertRaises(ValueError): 182 | self.dispatcher.unmap("/unmap/exception", handlerobj) 183 | 184 | 185 | if __name__ == "__main__": 186 | unittest.main() 187 | -------------------------------------------------------------------------------- /pythonosc/osc_server.py: -------------------------------------------------------------------------------- 1 | """OSC Servers that receive UDP packets and invoke handlers accordingly.""" 2 | 3 | import asyncio 4 | import os 5 | import socketserver 6 | from socket import socket as _socket 7 | from typing import Any, Coroutine, Tuple, Union, cast 8 | 9 | from pythonosc import osc_bundle, osc_message 10 | from pythonosc.dispatcher import Dispatcher 11 | from pythonosc.osc_message_builder import build_msg 12 | 13 | _RequestType = Union[_socket, Tuple[bytes, _socket]] 14 | _AddressType = Union[Tuple[str, int], str] 15 | 16 | 17 | class _UDPHandler(socketserver.BaseRequestHandler): 18 | """Handles correct UDP messages for all types of server.""" 19 | 20 | def __init__(self, request, client_address, server): 21 | self.socket = request[1] 22 | super().__init__(request, client_address, server) 23 | 24 | def handle(self) -> None: 25 | """Calls the handlers via dispatcher 26 | 27 | This method is called after a basic sanity check was done on the datagram, 28 | whether this datagram looks like an osc message or bundle. 29 | If not the server won't call it and so no new 30 | threads/processes will be spawned. 31 | """ 32 | server = cast(OSCUDPServer, self.server) 33 | resp = server.dispatcher.call_handlers_for_packet( 34 | self.request[0], self.client_address 35 | ) 36 | for r in resp: 37 | if not isinstance(r, tuple): 38 | r = [r] 39 | msg = build_msg(r[0], r[1:]) 40 | self.socket.sendto(msg.dgram, self.client_address) 41 | 42 | 43 | def _is_valid_request(request: _RequestType) -> bool: 44 | """Returns true if the request's data looks like an osc bundle or message. 45 | 46 | Returns: 47 | True if request is OSC bundle or OSC message 48 | """ 49 | assert isinstance( 50 | request, tuple 51 | ) # TODO: handle requests which are passed just as a socket? 52 | data = request[0] 53 | return osc_bundle.OscBundle.dgram_is_bundle( 54 | data 55 | ) or osc_message.OscMessage.dgram_is_message(data) 56 | 57 | 58 | class OSCUDPServer(socketserver.UDPServer): 59 | """Superclass for different flavors of OSC UDP servers""" 60 | 61 | def __init__( 62 | self, 63 | server_address: Tuple[str, int], 64 | dispatcher: Dispatcher, 65 | bind_and_activate: bool = True, 66 | ) -> None: 67 | """Initialize 68 | 69 | Args: 70 | server_address: IP and port of server 71 | dispatcher: Dispatcher this server will use 72 | (optional) bind_and_activate: default=True defines if the server has to start on call of constructor 73 | """ 74 | super().__init__(server_address, _UDPHandler, bind_and_activate) 75 | self._dispatcher = dispatcher 76 | 77 | def verify_request( 78 | self, request: _RequestType, client_address: _AddressType 79 | ) -> bool: 80 | """Returns true if the data looks like a valid OSC UDP datagram 81 | 82 | Args: 83 | request: Incoming data 84 | client_address: IP and port of client this message came from 85 | 86 | Returns: 87 | True if request is OSC bundle or OSC message 88 | """ 89 | return _is_valid_request(request) 90 | 91 | @property 92 | def dispatcher(self) -> Dispatcher: 93 | return self._dispatcher 94 | 95 | 96 | class BlockingOSCUDPServer(OSCUDPServer): 97 | """Blocking version of the UDP server. 98 | 99 | Each message will be handled sequentially on the same thread. 100 | Use this is you don't care about latency in your message handling or don't 101 | have a multiprocess/multithread environment. 102 | """ 103 | 104 | 105 | class ThreadingOSCUDPServer(socketserver.ThreadingMixIn, OSCUDPServer): 106 | """Threading version of the OSC UDP server. 107 | 108 | Each message will be handled in its own new thread. 109 | Use this when lightweight operations are done by each message handlers. 110 | """ 111 | 112 | 113 | if hasattr(os, "fork"): 114 | 115 | class ForkingOSCUDPServer(socketserver.ForkingMixIn, OSCUDPServer): 116 | """Forking version of the OSC UDP server. 117 | 118 | Each message will be handled in its own new process. 119 | Use this when heavyweight operations are done by each message handlers 120 | and forking a whole new process for each of them is worth it. 121 | """ 122 | 123 | 124 | class AsyncIOOSCUDPServer: 125 | """Asynchronous OSC Server 126 | 127 | An asynchronous OSC Server using UDP. It creates a datagram endpoint that runs in an event loop. 128 | """ 129 | 130 | def __init__( 131 | self, 132 | server_address: Tuple[str, int], 133 | dispatcher: Dispatcher, 134 | loop: asyncio.BaseEventLoop, 135 | ) -> None: 136 | """Initialize 137 | 138 | Args: 139 | server_address: IP and port of server 140 | dispatcher: Dispatcher this server shall use 141 | loop: Event loop to add the server task to. Use ``asyncio.get_event_loop()`` unless you know what you're 142 | doing. 143 | """ 144 | 145 | self._server_address = server_address 146 | self._dispatcher = dispatcher 147 | self._loop = loop 148 | 149 | class _OSCProtocolFactory(asyncio.DatagramProtocol): 150 | """OSC protocol factory which passes datagrams to dispatcher""" 151 | 152 | def __init__(self, dispatcher: Dispatcher) -> None: 153 | self.dispatcher = dispatcher 154 | 155 | def connection_made(self, transport): 156 | self.transport = transport 157 | 158 | def datagram_received( 159 | self, data: bytes, client_address: Tuple[str, int] 160 | ) -> None: 161 | resp = self.dispatcher.call_handlers_for_packet(data, client_address) 162 | for r in resp: 163 | if not isinstance(r, tuple): 164 | r = [r] 165 | msg = build_msg(r[0], r[1:]) 166 | self.transport.sendto(msg.dgram, client_address) 167 | 168 | def serve(self) -> None: 169 | """Creates a datagram endpoint and registers it with event loop. 170 | 171 | Use this only in synchronous code (i.e. not from within a coroutine). This will start the server and run it 172 | forever or until a ``stop()`` is called on the event loop. 173 | """ 174 | self._loop.run_until_complete(self.create_serve_endpoint()) 175 | 176 | def create_serve_endpoint( 177 | self, 178 | ) -> Coroutine[ 179 | Any, Any, Tuple[asyncio.transports.BaseTransport, asyncio.DatagramProtocol] 180 | ]: 181 | """Creates a datagram endpoint and registers it with event loop as coroutine. 182 | 183 | Returns: 184 | Awaitable coroutine that returns transport and protocol objects 185 | """ 186 | return self._loop.create_datagram_endpoint( 187 | lambda: self._OSCProtocolFactory(self.dispatcher), 188 | local_addr=self._server_address, 189 | ) 190 | 191 | @property 192 | def dispatcher(self) -> Dispatcher: 193 | return self._dispatcher 194 | -------------------------------------------------------------------------------- /pythonosc/osc_message_builder.py: -------------------------------------------------------------------------------- 1 | """Build OSC messages for client applications.""" 2 | 3 | from typing import Any, Iterable, List, Optional, Tuple, Union 4 | 5 | from pythonosc import osc_message 6 | from pythonosc.parsing import osc_types 7 | 8 | ArgValue = Union[str, bytes, bool, int, float, osc_types.MidiPacket, list, None] 9 | 10 | 11 | class BuildError(Exception): 12 | """Error raised when an incomplete message is trying to be built.""" 13 | 14 | 15 | class OscMessageBuilder(object): 16 | """Builds arbitrary OscMessage instances.""" 17 | 18 | ARG_TYPE_FLOAT = "f" 19 | ARG_TYPE_DOUBLE = "d" 20 | ARG_TYPE_INT = "i" 21 | ARG_TYPE_INT64 = "h" 22 | ARG_TYPE_STRING = "s" 23 | ARG_TYPE_BLOB = "b" 24 | ARG_TYPE_RGBA = "r" 25 | ARG_TYPE_MIDI = "m" 26 | ARG_TYPE_TRUE = "T" 27 | ARG_TYPE_FALSE = "F" 28 | ARG_TYPE_NIL = "N" 29 | 30 | ARG_TYPE_ARRAY_START = "[" 31 | ARG_TYPE_ARRAY_STOP = "]" 32 | 33 | _SUPPORTED_ARG_TYPES = ( 34 | ARG_TYPE_FLOAT, 35 | ARG_TYPE_DOUBLE, 36 | ARG_TYPE_INT, 37 | ARG_TYPE_INT64, 38 | ARG_TYPE_BLOB, 39 | ARG_TYPE_STRING, 40 | ARG_TYPE_RGBA, 41 | ARG_TYPE_MIDI, 42 | ARG_TYPE_TRUE, 43 | ARG_TYPE_FALSE, 44 | ARG_TYPE_NIL, 45 | ) 46 | 47 | def __init__(self, address: Optional[str] = None) -> None: 48 | """Initialize a new builder for a message. 49 | 50 | Args: 51 | - address: The osc address to send this message to. 52 | """ 53 | self._address = address 54 | self._args = [] # type: List[Tuple[str, Union[ArgValue, None]]] 55 | 56 | @property 57 | def address(self) -> Optional[str]: 58 | """Returns the OSC address this message will be sent to.""" 59 | return self._address 60 | 61 | @address.setter 62 | def address(self, value: str) -> None: 63 | """Sets the OSC address this message will be sent to.""" 64 | self._address = value 65 | 66 | @property 67 | def args(self) -> List[Tuple[str, Union[ArgValue, None]]]: 68 | """Returns the (type, value) arguments list of this message.""" 69 | return self._args 70 | 71 | def _valid_type(self, arg_type: str) -> bool: 72 | if arg_type in self._SUPPORTED_ARG_TYPES: 73 | return True 74 | elif isinstance(arg_type, list): 75 | for sub_type in arg_type: 76 | if not self._valid_type(sub_type): 77 | return False 78 | return True 79 | return False 80 | 81 | def add_arg(self, arg_value: ArgValue, arg_type: Optional[str] = None) -> None: 82 | """Add a typed argument to this message. 83 | 84 | Args: 85 | - arg_value: The corresponding value for the argument. 86 | - arg_type: A value in ARG_TYPE_* defined in this class, 87 | if none then the type will be guessed. 88 | Raises: 89 | - ValueError: if the type is not supported. 90 | """ 91 | if arg_type and not self._valid_type(arg_type): 92 | raise ValueError( 93 | f"arg_type must be one of {self._SUPPORTED_ARG_TYPES}, or an array of valid types" 94 | ) 95 | if not arg_type: 96 | arg_type = self._get_arg_type(arg_value) 97 | if isinstance(arg_type, list): 98 | self._args.append((self.ARG_TYPE_ARRAY_START, None)) 99 | for v, t in zip(arg_value, arg_type): # type: ignore[arg-type] 100 | self.add_arg(v, t) 101 | self._args.append((self.ARG_TYPE_ARRAY_STOP, None)) 102 | else: 103 | self._args.append((arg_type, arg_value)) 104 | 105 | # The return type here is actually Union[str, List[]], however there 106 | # is no annotation for a recursive type like this. 107 | def _get_arg_type(self, arg_value: ArgValue) -> Union[str, Any]: 108 | """Guess the type of a value. 109 | 110 | Args: 111 | - arg_value: The value to guess the type of. 112 | Raises: 113 | - ValueError: if the type is not supported. 114 | """ 115 | if isinstance(arg_value, str): 116 | arg_type = self.ARG_TYPE_STRING # type: Union[str, Any] 117 | elif isinstance(arg_value, bytes): 118 | arg_type = self.ARG_TYPE_BLOB 119 | elif arg_value is True: 120 | arg_type = self.ARG_TYPE_TRUE 121 | elif arg_value is False: 122 | arg_type = self.ARG_TYPE_FALSE 123 | elif isinstance(arg_value, int): 124 | if arg_value.bit_length() > 32: 125 | arg_type = self.ARG_TYPE_INT64 126 | else: 127 | arg_type = self.ARG_TYPE_INT 128 | elif isinstance(arg_value, float): 129 | arg_type = self.ARG_TYPE_FLOAT 130 | elif isinstance(arg_value, tuple) and len(arg_value) == 4: 131 | arg_type = self.ARG_TYPE_MIDI 132 | elif isinstance(arg_value, list): 133 | arg_type = [self._get_arg_type(v) for v in arg_value] 134 | elif arg_value is None: 135 | arg_type = self.ARG_TYPE_NIL 136 | else: 137 | raise ValueError("Inferred arg_value type is not supported") 138 | return arg_type 139 | 140 | def build(self) -> osc_message.OscMessage: 141 | """Builds an OscMessage from the current state of this builder. 142 | 143 | Raises: 144 | - BuildError: if the message could not be build or if the address 145 | was empty. 146 | 147 | Returns: 148 | - an osc_message.OscMessage instance. 149 | """ 150 | if not self._address: 151 | raise BuildError("OSC addresses cannot be empty") 152 | dgram = b"" 153 | try: 154 | # Write the address. 155 | dgram += osc_types.write_string(self._address) 156 | if not self._args: 157 | dgram += osc_types.write_string(",") 158 | return osc_message.OscMessage(dgram) 159 | 160 | # Write the parameters. 161 | arg_types = "".join([arg[0] for arg in self._args]) 162 | dgram += osc_types.write_string(f",{arg_types}") 163 | for arg_type, value in self._args: 164 | if arg_type == self.ARG_TYPE_STRING: 165 | dgram += osc_types.write_string(value) # type: ignore[arg-type] 166 | elif arg_type == self.ARG_TYPE_INT: 167 | dgram += osc_types.write_int(value) # type: ignore[arg-type] 168 | elif arg_type == self.ARG_TYPE_INT64: 169 | dgram += osc_types.write_int64(value) # type: ignore[arg-type] 170 | elif arg_type == self.ARG_TYPE_FLOAT: 171 | dgram += osc_types.write_float(value) # type: ignore[arg-type] 172 | elif arg_type == self.ARG_TYPE_DOUBLE: 173 | dgram += osc_types.write_double(value) # type: ignore[arg-type] 174 | elif arg_type == self.ARG_TYPE_BLOB: 175 | dgram += osc_types.write_blob(value) # type: ignore[arg-type] 176 | elif arg_type == self.ARG_TYPE_RGBA: 177 | dgram += osc_types.write_rgba(value) # type: ignore[arg-type] 178 | elif arg_type == self.ARG_TYPE_MIDI: 179 | dgram += osc_types.write_midi(value) # type: ignore[arg-type] 180 | elif arg_type in ( 181 | self.ARG_TYPE_TRUE, 182 | self.ARG_TYPE_FALSE, 183 | self.ARG_TYPE_ARRAY_START, 184 | self.ARG_TYPE_ARRAY_STOP, 185 | self.ARG_TYPE_NIL, 186 | ): 187 | continue 188 | else: 189 | raise BuildError(f"Incorrect parameter type found {arg_type}") 190 | 191 | return osc_message.OscMessage(dgram) 192 | except osc_types.BuildError as be: 193 | raise BuildError(f"Could not build the message: {be}") 194 | 195 | 196 | def build_msg(address: str, value: ArgValue = "") -> osc_message.OscMessage: 197 | builder = OscMessageBuilder(address=address) 198 | values: ArgValue 199 | if value == "": 200 | values = [] 201 | elif not isinstance(value, Iterable) or isinstance(value, (str, bytes)): 202 | values = [value] 203 | else: 204 | values = value 205 | for val in values: 206 | builder.add_arg(val) 207 | return builder.build() 208 | -------------------------------------------------------------------------------- /pythonosc/tcp_client.py: -------------------------------------------------------------------------------- 1 | """TCP Clients for sending OSC messages to an OSC server""" 2 | 3 | import asyncio 4 | import socket 5 | import struct 6 | from typing import AsyncGenerator, Generator, List, Union 7 | 8 | from pythonosc import slip 9 | from pythonosc.dispatcher import Dispatcher 10 | from pythonosc.osc_bundle import OscBundle 11 | from pythonosc.osc_message import OscMessage 12 | from pythonosc.osc_message_builder import ArgValue, build_msg 13 | from pythonosc.osc_tcp_server import MODE_1_1 14 | 15 | 16 | class TCPClient(object): 17 | """Async OSC client to send :class:`OscMessage` or :class:`OscBundle` via TCP""" 18 | 19 | def __init__( 20 | self, 21 | address: str, 22 | port: int, 23 | family: socket.AddressFamily = socket.AF_INET, 24 | mode: str = MODE_1_1, 25 | ) -> None: 26 | """Initialize client 27 | 28 | Args: 29 | address: IP address of server 30 | port: Port of server 31 | family: address family parameter (passed to socket.getaddrinfo) 32 | """ 33 | self.address = address 34 | self.port = port 35 | self.family = family 36 | self.mode = mode 37 | self.socket = socket.socket(self.family, socket.SOCK_STREAM) 38 | self.socket.settimeout(30) 39 | self.socket.connect((address, port)) 40 | 41 | def __enter__(self): 42 | return self 43 | 44 | def __exit__(self, exc_type, exc_val, exc_tb): 45 | self.close() 46 | 47 | def send(self, content: Union[OscMessage, OscBundle]) -> None: 48 | """Sends an :class:`OscMessage` or :class:`OscBundle` via TCP 49 | 50 | Args: 51 | content: Message or bundle to be sent 52 | """ 53 | if self.mode == MODE_1_1: 54 | self.socket.sendall(slip.encode(content.dgram)) 55 | else: 56 | b = struct.pack("!I", len(content.dgram)) 57 | self.socket.sendall(b + content.dgram) 58 | 59 | def receive(self, timeout: int = 30) -> List[bytes]: 60 | self.socket.settimeout(timeout) 61 | if self.mode == MODE_1_1: 62 | try: 63 | buf = self.socket.recv(4096) 64 | except TimeoutError: 65 | return [] 66 | if not buf: 67 | return [] 68 | # If the last byte is not an END marker there could be more data coming 69 | while buf[-1] != 192: 70 | try: 71 | newbuf = self.socket.recv(4096) 72 | except TimeoutError: 73 | break 74 | if not newbuf: 75 | # Maybe should raise an exception here? 76 | break 77 | buf += newbuf 78 | return [slip.decode(p) for p in buf.split(slip.END_END)] 79 | else: 80 | buf = b"" 81 | try: 82 | lengthbuf = self.socket.recv(4) 83 | except TimeoutError: 84 | return [] 85 | (length,) = struct.unpack("!I", lengthbuf) 86 | while length > 0: 87 | try: 88 | newbuf = self.socket.recv(length) 89 | except TimeoutError: 90 | return [] 91 | if not newbuf: 92 | return [] 93 | buf += newbuf 94 | length -= len(newbuf) 95 | return [buf] 96 | 97 | def close(self): 98 | self.socket.close() 99 | 100 | 101 | class SimpleTCPClient(TCPClient): 102 | """Simple OSC client that automatically builds :class:`OscMessage` from arguments""" 103 | 104 | def __init__(self, *args, **kwargs): 105 | super().__init__(*args, **kwargs) 106 | 107 | def send_message(self, address: str, value: ArgValue = "") -> None: 108 | """Build :class:`OscMessage` from arguments and send to server 109 | 110 | Args: 111 | address: OSC address the message shall go to 112 | value: One or more arguments to be added to the message 113 | """ 114 | msg = build_msg(address, value) 115 | return self.send(msg) 116 | 117 | def get_messages(self, timeout: int = 30) -> Generator: 118 | r = self.receive(timeout) 119 | while r: 120 | for m in r: 121 | yield OscMessage(m) 122 | r = self.receive(timeout) 123 | 124 | 125 | class TCPDispatchClient(SimpleTCPClient): 126 | """OSC TCP Client that includes a :class:`Dispatcher` for handling responses and other messages from the server""" 127 | 128 | dispatcher = Dispatcher() 129 | 130 | def handle_messages(self, timeout_sec: int = 30) -> None: 131 | """Wait :int:`timeout` seconds for a message from the server and process each message with the registered 132 | handlers. Continue until a timeout occurs. 133 | 134 | Args: 135 | timeout: Time in seconds to wait for a message 136 | """ 137 | r = self.receive(timeout_sec) 138 | while r: 139 | for m in r: 140 | self.dispatcher.call_handlers_for_packet(m, (self.address, self.port)) 141 | r = self.receive(timeout_sec) 142 | 143 | 144 | class AsyncTCPClient: 145 | """Async OSC client to send :class:`OscMessage` or :class:`OscBundle` via TCP""" 146 | 147 | def __init__( 148 | self, 149 | address: str, 150 | port: int, 151 | family: socket.AddressFamily = socket.AF_INET, 152 | mode: str = MODE_1_1, 153 | ) -> None: 154 | """Initialize client 155 | 156 | Args: 157 | address: IP address of server 158 | port: Port of server 159 | family: address family parameter (passed to socket.getaddrinfo) 160 | """ 161 | self.address: str = address 162 | self.port: int = port 163 | self.mode: str = mode 164 | self.family: socket.AddressFamily = family 165 | 166 | def __await__(self): 167 | async def closure(): 168 | await self.__open__() 169 | return self 170 | 171 | return closure().__await__() 172 | 173 | async def __aenter__(self): 174 | await self.__open__() 175 | return self 176 | 177 | async def __open__(self): 178 | self.reader, self.writer = await asyncio.open_connection( 179 | self.address, self.port 180 | ) 181 | 182 | async def __aexit__(self, exc_type, exc_val, exc_tb): 183 | await self.close() 184 | 185 | async def send(self, content: Union[OscMessage, OscBundle]) -> None: 186 | """Sends an :class:`OscMessage` or :class:`OscBundle` via TCP 187 | 188 | Args: 189 | content: Message or bundle to be sent 190 | """ 191 | if self.mode == MODE_1_1: 192 | self.writer.write(slip.encode(content.dgram)) 193 | else: 194 | b = struct.pack("!I", len(content.dgram)) 195 | self.writer.write(b + content.dgram) 196 | await self.writer.drain() 197 | 198 | async def receive(self, timeout: int = 30) -> List[bytes]: 199 | if self.mode == MODE_1_1: 200 | try: 201 | buf = await asyncio.wait_for(self.reader.read(4096), timeout) 202 | except TimeoutError: 203 | return [] 204 | if not buf: 205 | return [] 206 | # If the last byte is not an END marker there could be more data coming 207 | while buf[-1] != 192: 208 | try: 209 | newbuf = await asyncio.wait_for(self.reader.read(4096), timeout) 210 | except TimeoutError: 211 | break 212 | if not newbuf: 213 | # Maybe should raise an exception here? 214 | break 215 | buf += newbuf 216 | return [slip.decode(p) for p in buf.split(slip.END_END)] 217 | else: 218 | buf = b"" 219 | try: 220 | lengthbuf = await asyncio.wait_for(self.reader.read(4), timeout) 221 | except TimeoutError: 222 | return [] 223 | 224 | (length,) = struct.unpack("!I", lengthbuf) 225 | while length > 0: 226 | try: 227 | newbuf = await asyncio.wait_for(self.reader.read(length), timeout) 228 | except TimeoutError: 229 | return [] 230 | if not newbuf: 231 | return [] 232 | buf += newbuf 233 | length -= len(newbuf) 234 | return [buf] 235 | 236 | async def close(self): 237 | self.writer.write_eof() 238 | self.writer.close() 239 | await self.writer.wait_closed() 240 | 241 | 242 | class AsyncSimpleTCPClient(AsyncTCPClient): 243 | """Simple OSC client that automatically builds :class:`OscMessage` from arguments""" 244 | 245 | def __init__( 246 | self, 247 | address: str, 248 | port: int, 249 | family: socket.AddressFamily = socket.AF_INET, 250 | mode: str = MODE_1_1, 251 | ): 252 | super().__init__(address, port, family, mode) 253 | 254 | async def send_message(self, address: str, value: ArgValue = "") -> None: 255 | """Build :class:`OscMessage` from arguments and send to server 256 | 257 | Args: 258 | address: OSC address the message shall go to 259 | value: One or more arguments to be added to the message 260 | """ 261 | msg = build_msg(address, value) 262 | return await self.send(msg) 263 | 264 | async def get_messages(self, timeout: int = 30) -> AsyncGenerator: 265 | r = await self.receive(timeout) 266 | while r: 267 | for m in r: 268 | yield OscMessage(m) 269 | r = await self.receive(timeout) 270 | 271 | 272 | class AsyncDispatchTCPClient(AsyncTCPClient): 273 | """OSC Client that includes a :class:`Dispatcher` for handling responses and other messages from the server""" 274 | 275 | dispatcher = Dispatcher() 276 | 277 | async def handle_messages(self, timeout: int = 30) -> None: 278 | """Wait :int:`timeout` seconds for a message from the server and process each message with the registered 279 | handlers. Continue until a timeout occurs. 280 | 281 | Args: 282 | timeout: Time in seconds to wait for a message 283 | """ 284 | msgs = await self.receive(timeout) 285 | while msgs: 286 | for m in msgs: 287 | await self.dispatcher.async_call_handlers_for_packet( 288 | m, (self.address, self.port) 289 | ) 290 | msgs = await self.receive(timeout) 291 | -------------------------------------------------------------------------------- /pythonosc/osc_tcp_server.py: -------------------------------------------------------------------------------- 1 | """OSC Servers that receive TCP packets and invoke handlers accordingly. 2 | 3 | Use like this: 4 | 5 | dispatcher = dispatcher.Dispatcher() 6 | # This will print all parameters to stdout. 7 | dispatcher.map("/bpm", print) 8 | server = ForkingOSCTCPServer((ip, port), dispatcher) 9 | server.serve_forever() 10 | 11 | or run the server on its own thread: 12 | server = ForkingOSCTCPServer((ip, port), dispatcher) 13 | server_thread = threading.Thread(target=server.serve_forever) 14 | server_thread.start() 15 | ... 16 | server.shutdown() 17 | 18 | 19 | Those servers are using the standard socketserver from the standard library: 20 | http://docs.python.org/library/socketserver.html 21 | 22 | 23 | Alternatively, the AsyncIOOSCTCPServer server can be integrated with an 24 | asyncio event loop: 25 | 26 | loop = asyncio.get_event_loop() 27 | server = AsyncIOOSCTCPServer(server_address, dispatcher) 28 | server.serve() 29 | loop.run_forever() 30 | 31 | """ 32 | 33 | # mypy: disable-error-code="attr-defined" 34 | 35 | import asyncio 36 | import logging 37 | import os 38 | import socketserver 39 | import struct 40 | from typing import List, Tuple 41 | 42 | from pythonosc import osc_message_builder, slip 43 | from pythonosc.dispatcher import Dispatcher 44 | 45 | LOG = logging.getLogger() 46 | MODE_1_0 = "1.0" 47 | MODE_1_1 = "1.1" 48 | 49 | 50 | class _TCPHandler1_0(socketserver.BaseRequestHandler): 51 | """Handles correct OSC1.0 messages. 52 | 53 | Whether this will be run on its own thread, the server's or a whole new 54 | process depends on the server you instantiated, look at their documentation. 55 | 56 | This method is called after a basic sanity check was done on the datagram, 57 | basically whether this datagram looks like an osc message or bundle, 58 | if not the server won't even bother to call it and so no new 59 | threads/processes will be spawned. 60 | """ 61 | 62 | def handle(self) -> None: 63 | LOG.debug("handle OSC 1.0 protocol") 64 | while True: 65 | lengthbuf = self.recvall(4) 66 | if lengthbuf == b"": 67 | break 68 | (length,) = struct.unpack("!I", lengthbuf) 69 | data = self.recvall(length) 70 | if data == b"": 71 | break 72 | 73 | resp = self.server.dispatcher.call_handlers_for_packet( 74 | data, self.client_address 75 | ) 76 | # resp = _call_handlers_for_packet(data, self.server.dispatcher) 77 | for r in resp: 78 | if not isinstance(r, tuple): 79 | r = [r] 80 | msg = osc_message_builder.build_msg(r[0], r[1:]) 81 | b = struct.pack("!I", len(msg.dgram)) 82 | self.request.sendall(b + msg.dgram) 83 | 84 | def recvall(self, count: int) -> bytes: 85 | buf = b"" 86 | while count > 0: 87 | newbuf = self.request.recv(count) 88 | if not newbuf: 89 | return b"" 90 | buf += newbuf 91 | count -= len(newbuf) 92 | return buf 93 | 94 | 95 | class _TCPHandler1_1(socketserver.BaseRequestHandler): 96 | """Handles correct OSC1.1 messages. 97 | 98 | Whether this will be run on its own thread, the server's or a whole new 99 | process depends on the server you instantiated, look at their documentation. 100 | 101 | This method is called after a basic sanity check was done on the datagram, 102 | basically whether this datagram looks like an osc message or bundle, 103 | if not the server won't even bother to call it and so no new 104 | threads/processes will be spawned. 105 | """ 106 | 107 | def handle(self) -> None: 108 | LOG.debug("handle OSC 1.1 protocol") 109 | while True: 110 | packets = self.recvall() 111 | if not packets: 112 | break 113 | 114 | for p in packets: 115 | # resp = _call_handlers_for_packet(p, self.server.dispatcher) 116 | resp = self.server.dispatcher.call_handlers_for_packet( 117 | p, self.client_address 118 | ) 119 | for r in resp: 120 | if not isinstance(r, tuple): 121 | r = [r] 122 | msg = osc_message_builder.build_msg(r[0], r[1:]) 123 | self.request.sendall(slip.encode(msg.dgram)) 124 | 125 | def recvall(self) -> List[bytes]: 126 | buf = self.request.recv(4096) 127 | if not buf: 128 | return [] 129 | # If the last byte is not an END marker there could be more data coming 130 | while buf[-1] != 192: 131 | newbuf = self.request.recv(4096) 132 | if not newbuf: 133 | # Maybe should raise an exception here? 134 | break 135 | buf += newbuf 136 | 137 | packets = [slip.decode(p) for p in buf.split(slip.END_END)] 138 | return packets 139 | 140 | 141 | class OSCTCPServer(socketserver.TCPServer): 142 | """Superclass for different flavors of OSCTCPServer""" 143 | 144 | def __init__( 145 | self, 146 | server_address: Tuple[str | bytes | bytearray, int], 147 | dispatcher: Dispatcher, 148 | mode: str = MODE_1_1, 149 | ): 150 | self.request_queue_size = 300 151 | self.mode = mode 152 | if mode not in [MODE_1_0, MODE_1_1]: 153 | raise ValueError("OSC Mode must be '1.0' or '1.1'") 154 | if self.mode == MODE_1_0: 155 | super().__init__(server_address, _TCPHandler1_0) 156 | else: 157 | super().__init__(server_address, _TCPHandler1_1) 158 | self._dispatcher = dispatcher 159 | 160 | @property 161 | def dispatcher(self): 162 | """Dispatcher accessor for handlers to dispatch osc messages.""" 163 | return self._dispatcher 164 | 165 | 166 | class BlockingOSCTCPServer(OSCTCPServer): 167 | """Blocking version of the TCP server. 168 | 169 | Each message will be handled sequentially on the same thread. 170 | Use this is you don't care about latency in your message handling or don't 171 | have a multiprocess/multithread environment (really?). 172 | """ 173 | 174 | 175 | class ThreadingOSCTCPServer(socketserver.ThreadingMixIn, OSCTCPServer): 176 | """Threading version of the OSC TCP server. 177 | 178 | Each message will be handled in its own new thread. 179 | Use this when lightweight operations are done by each message handlers. 180 | """ 181 | 182 | 183 | if hasattr(os, "fork"): 184 | 185 | class ForkingOSCTCPServer(socketserver.ForkingMixIn, OSCTCPServer): 186 | """Forking version of the OSC TCP server. 187 | 188 | Each message will be handled in its own new process. 189 | Use this when heavyweight operations are done by each message handlers 190 | and forking a whole new process for each of them is worth it. 191 | """ 192 | 193 | 194 | class AsyncOSCTCPServer: 195 | """Asyncio version of the OSC TCP Server. 196 | Each TCP message is handled by _call_handlers_for_packet, the same method as in the 197 | OSCTCPServer family of blocking, threading, and forking servers 198 | """ 199 | 200 | def __init__( 201 | self, 202 | server_address: str, 203 | port: int, 204 | dispatcher: Dispatcher, 205 | mode: str = MODE_1_1, 206 | ): 207 | """ 208 | :param server_address: tuple of (IP address to bind to, port) 209 | :param dispatcher: a pythonosc.dispatcher.Dispatcher 210 | """ 211 | self._port = port 212 | self._server_address = server_address 213 | self._dispatcher = dispatcher 214 | self._mode = mode 215 | 216 | async def __aenter__(self): 217 | return self 218 | 219 | async def __aexit__(self, exc_type, exc_val, exc_tb): 220 | await self.stop() 221 | 222 | async def start(self) -> None: 223 | """creates a socket endpoint and registers it with our event loop""" 224 | self._server = await asyncio.start_server( 225 | self.handle, self._server_address, self._port 226 | ) 227 | 228 | addrs = ", ".join(str(sock.getsockname()) for sock in self._server.sockets) 229 | LOG.debug(f"Serving on {addrs}") 230 | 231 | async with self._server: 232 | await self._server.serve_forever() 233 | 234 | async def stop(self) -> None: 235 | self._server.close() 236 | await self._server.wait_closed() 237 | 238 | @property 239 | def dispatcher(self): 240 | return self._dispatcher 241 | 242 | async def handle( 243 | self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter 244 | ) -> None: 245 | client_address = ("", 0) 246 | sock = writer.transport.get_extra_info("socket") 247 | if sock is not None: 248 | client_address = sock.getpeername() 249 | 250 | if self._mode == MODE_1_1: 251 | await self.handle_1_1(reader, writer, client_address) 252 | else: 253 | await self.handle1_0(reader, writer, client_address) 254 | writer.write_eof() 255 | LOG.debug("Close the connection") 256 | writer.close() 257 | await writer.wait_closed() 258 | 259 | async def handle1_0( 260 | self, 261 | reader: asyncio.StreamReader, 262 | writer: asyncio.StreamWriter, 263 | client_address: Tuple[str, int], 264 | ) -> None: 265 | LOG.debug("Incoming socket open 1.0") 266 | while True: 267 | try: 268 | buf = await reader.read(4) 269 | except Exception as e: 270 | LOG.exception("Read error", e) 271 | return 272 | if buf == b"": 273 | break 274 | (length,) = struct.unpack("!I", buf) 275 | buf = b"" 276 | while length > 0: 277 | newbuf = await reader.read(length) 278 | if not newbuf: 279 | break 280 | buf += newbuf 281 | length -= len(newbuf) 282 | 283 | result = await self.dispatcher.async_call_handlers_for_packet( 284 | buf, client_address 285 | ) 286 | for r in result: 287 | if not isinstance(r, tuple): 288 | r = [r] 289 | msg = osc_message_builder.build_msg(r[0], r[1:]) 290 | b = struct.pack("!I", len(msg.dgram)) 291 | writer.write(b + msg.dgram) 292 | await writer.drain() 293 | 294 | async def handle_1_1( 295 | self, 296 | reader: asyncio.StreamReader, 297 | writer: asyncio.StreamWriter, 298 | client_address: Tuple[str, int], 299 | ) -> None: 300 | LOG.debug("Incoming socket open 1.1") 301 | while True: 302 | try: 303 | buf = await reader.read(4096) 304 | except Exception as e: 305 | LOG.exception("Read error", e) 306 | return 307 | if buf == b"": 308 | break 309 | while len(buf) > 0 and buf[-1] != 192: 310 | newbuf = await reader.read(4096) 311 | if not newbuf: 312 | # Maybe should raise an exception here? 313 | break 314 | buf += newbuf 315 | 316 | packets = [slip.decode(p) for p in buf.split(slip.END_END)] 317 | for p in packets: 318 | result = await self.dispatcher.async_call_handlers_for_packet( 319 | p, client_address 320 | ) 321 | for r in result: 322 | if not isinstance(r, tuple): 323 | r = [r] 324 | msg = osc_message_builder.build_msg(r[0], r[1:]) 325 | writer.write(slip.encode(msg.dgram)) 326 | await writer.drain() 327 | -------------------------------------------------------------------------------- /pythonosc/test/test_osc_message.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from pythonosc import osc_message 4 | 5 | from datetime import datetime 6 | 7 | # Datagrams sent by Reaktor 5.8 by Native Instruments (c). 8 | _DGRAM_KNOB_ROTATES = b"/FB\x00" b",f\x00\x00" b">xca=q" 9 | 10 | _DGRAM_SWITCH_GOES_OFF = b"/SYNC\x00\x00\x00" b",f\x00\x00" b"\x00\x00\x00\x00" 11 | 12 | _DGRAM_SWITCH_GOES_ON = b"/SYNC\x00\x00\x00" b",f\x00\x00" b"?\x00\x00\x00" 13 | 14 | _DGRAM_NO_PARAMS = b"/SYNC\x00\x00\x00" 15 | 16 | _DGRAM_ALL_STANDARD_TYPES_OF_PARAMS = ( 17 | b"/SYNC\x00\x00\x00" 18 | b",ifsb\x00\x00\x00" 19 | b"\x00\x00\x00\x03" # 3 20 | b"@\x00\x00\x00" # 2.0 21 | b"ABC\x00" # "ABC" 22 | b"\x00\x00\x00\x08stuff\x00\x00\x00" 23 | ) # b"stuff\x00\x00\x00" 24 | 25 | _DGRAM_ALL_NON_STANDARD_TYPES_OF_PARAMS = ( 26 | b"/SYNC\x00\x00\x00" 27 | b"T" # True 28 | b"F" # False 29 | b"N" # Nil 30 | b"[]th\x00" # Empty array 31 | b"\x00\x00\x00\x00\x00\x00\x00\x00" 32 | b"\x00\x00\x00\xe8\xd4\xa5\x10\x00" # 1000000000000 33 | ) 34 | 35 | _DGRAM_COMPLEX_ARRAY_PARAMS = ( 36 | b"/SYNC\x00\x00\x00" 37 | b",[i][[ss]][[i][i[s]]]\x00\x00\x00" 38 | b"\x00\x00\x00\x01" # 1 39 | b"ABC\x00" # "ABC" 40 | b"DEF\x00" # "DEF" 41 | b"\x00\x00\x00\x02" # 2 42 | b"\x00\x00\x00\x03" # 3 43 | b"GHI\x00" 44 | ) # "GHI" 45 | 46 | _DGRAM_UNKNOWN_PARAM_TYPE = ( 47 | b"/SYNC\x00\x00\x00" 48 | b",fx\x00" # x is an unknown param type. 49 | b"?\x00\x00\x00" 50 | ) 51 | 52 | # range(512) param list. 53 | _DGRAM_LONG_LIST = b"/SYNC\x00\x00\x00,[iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii]\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00\x03\x00\x00\x00\x04\x00\x00\x00\x05\x00\x00\x00\x06\x00\x00\x00\x07\x00\x00\x00\x08\x00\x00\x00\t\x00\x00\x00\n\x00\x00\x00\x0b\x00\x00\x00\x0c\x00\x00\x00\r\x00\x00\x00\x0e\x00\x00\x00\x0f\x00\x00\x00\x10\x00\x00\x00\x11\x00\x00\x00\x12\x00\x00\x00\x13\x00\x00\x00\x14\x00\x00\x00\x15\x00\x00\x00\x16\x00\x00\x00\x17\x00\x00\x00\x18\x00\x00\x00\x19\x00\x00\x00\x1a\x00\x00\x00\x1b\x00\x00\x00\x1c\x00\x00\x00\x1d\x00\x00\x00\x1e\x00\x00\x00\x1f\x00\x00\x00 \x00\x00\x00!\x00\x00\x00\"\x00\x00\x00#\x00\x00\x00$\x00\x00\x00%\x00\x00\x00&\x00\x00\x00'\x00\x00\x00(\x00\x00\x00)\x00\x00\x00*\x00\x00\x00+\x00\x00\x00,\x00\x00\x00-\x00\x00\x00.\x00\x00\x00/\x00\x00\x000\x00\x00\x001\x00\x00\x002\x00\x00\x003\x00\x00\x004\x00\x00\x005\x00\x00\x006\x00\x00\x007\x00\x00\x008\x00\x00\x009\x00\x00\x00:\x00\x00\x00;\x00\x00\x00<\x00\x00\x00=\x00\x00\x00>\x00\x00\x00?\x00\x00\x00@\x00\x00\x00A\x00\x00\x00B\x00\x00\x00C\x00\x00\x00D\x00\x00\x00E\x00\x00\x00F\x00\x00\x00G\x00\x00\x00H\x00\x00\x00I\x00\x00\x00J\x00\x00\x00K\x00\x00\x00L\x00\x00\x00M\x00\x00\x00N\x00\x00\x00O\x00\x00\x00P\x00\x00\x00Q\x00\x00\x00R\x00\x00\x00S\x00\x00\x00T\x00\x00\x00U\x00\x00\x00V\x00\x00\x00W\x00\x00\x00X\x00\x00\x00Y\x00\x00\x00Z\x00\x00\x00[\x00\x00\x00\\\x00\x00\x00]\x00\x00\x00^\x00\x00\x00_\x00\x00\x00`\x00\x00\x00a\x00\x00\x00b\x00\x00\x00c\x00\x00\x00d\x00\x00\x00e\x00\x00\x00f\x00\x00\x00g\x00\x00\x00h\x00\x00\x00i\x00\x00\x00j\x00\x00\x00k\x00\x00\x00l\x00\x00\x00m\x00\x00\x00n\x00\x00\x00o\x00\x00\x00p\x00\x00\x00q\x00\x00\x00r\x00\x00\x00s\x00\x00\x00t\x00\x00\x00u\x00\x00\x00v\x00\x00\x00w\x00\x00\x00x\x00\x00\x00y\x00\x00\x00z\x00\x00\x00{\x00\x00\x00|\x00\x00\x00}\x00\x00\x00~\x00\x00\x00\x7f\x00\x00\x00\x80\x00\x00\x00\x81\x00\x00\x00\x82\x00\x00\x00\x83\x00\x00\x00\x84\x00\x00\x00\x85\x00\x00\x00\x86\x00\x00\x00\x87\x00\x00\x00\x88\x00\x00\x00\x89\x00\x00\x00\x8a\x00\x00\x00\x8b\x00\x00\x00\x8c\x00\x00\x00\x8d\x00\x00\x00\x8e\x00\x00\x00\x8f\x00\x00\x00\x90\x00\x00\x00\x91\x00\x00\x00\x92\x00\x00\x00\x93\x00\x00\x00\x94\x00\x00\x00\x95\x00\x00\x00\x96\x00\x00\x00\x97\x00\x00\x00\x98\x00\x00\x00\x99\x00\x00\x00\x9a\x00\x00\x00\x9b\x00\x00\x00\x9c\x00\x00\x00\x9d\x00\x00\x00\x9e\x00\x00\x00\x9f\x00\x00\x00\xa0\x00\x00\x00\xa1\x00\x00\x00\xa2\x00\x00\x00\xa3\x00\x00\x00\xa4\x00\x00\x00\xa5\x00\x00\x00\xa6\x00\x00\x00\xa7\x00\x00\x00\xa8\x00\x00\x00\xa9\x00\x00\x00\xaa\x00\x00\x00\xab\x00\x00\x00\xac\x00\x00\x00\xad\x00\x00\x00\xae\x00\x00\x00\xaf\x00\x00\x00\xb0\x00\x00\x00\xb1\x00\x00\x00\xb2\x00\x00\x00\xb3\x00\x00\x00\xb4\x00\x00\x00\xb5\x00\x00\x00\xb6\x00\x00\x00\xb7\x00\x00\x00\xb8\x00\x00\x00\xb9\x00\x00\x00\xba\x00\x00\x00\xbb\x00\x00\x00\xbc\x00\x00\x00\xbd\x00\x00\x00\xbe\x00\x00\x00\xbf\x00\x00\x00\xc0\x00\x00\x00\xc1\x00\x00\x00\xc2\x00\x00\x00\xc3\x00\x00\x00\xc4\x00\x00\x00\xc5\x00\x00\x00\xc6\x00\x00\x00\xc7\x00\x00\x00\xc8\x00\x00\x00\xc9\x00\x00\x00\xca\x00\x00\x00\xcb\x00\x00\x00\xcc\x00\x00\x00\xcd\x00\x00\x00\xce\x00\x00\x00\xcf\x00\x00\x00\xd0\x00\x00\x00\xd1\x00\x00\x00\xd2\x00\x00\x00\xd3\x00\x00\x00\xd4\x00\x00\x00\xd5\x00\x00\x00\xd6\x00\x00\x00\xd7\x00\x00\x00\xd8\x00\x00\x00\xd9\x00\x00\x00\xda\x00\x00\x00\xdb\x00\x00\x00\xdc\x00\x00\x00\xdd\x00\x00\x00\xde\x00\x00\x00\xdf\x00\x00\x00\xe0\x00\x00\x00\xe1\x00\x00\x00\xe2\x00\x00\x00\xe3\x00\x00\x00\xe4\x00\x00\x00\xe5\x00\x00\x00\xe6\x00\x00\x00\xe7\x00\x00\x00\xe8\x00\x00\x00\xe9\x00\x00\x00\xea\x00\x00\x00\xeb\x00\x00\x00\xec\x00\x00\x00\xed\x00\x00\x00\xee\x00\x00\x00\xef\x00\x00\x00\xf0\x00\x00\x00\xf1\x00\x00\x00\xf2\x00\x00\x00\xf3\x00\x00\x00\xf4\x00\x00\x00\xf5\x00\x00\x00\xf6\x00\x00\x00\xf7\x00\x00\x00\xf8\x00\x00\x00\xf9\x00\x00\x00\xfa\x00\x00\x00\xfb\x00\x00\x00\xfc\x00\x00\x00\xfd\x00\x00\x00\xfe\x00\x00\x00\xff\x00\x00\x01\x00\x00\x00\x01\x01\x00\x00\x01\x02\x00\x00\x01\x03\x00\x00\x01\x04\x00\x00\x01\x05\x00\x00\x01\x06\x00\x00\x01\x07\x00\x00\x01\x08\x00\x00\x01\t\x00\x00\x01\n\x00\x00\x01\x0b\x00\x00\x01\x0c\x00\x00\x01\r\x00\x00\x01\x0e\x00\x00\x01\x0f\x00\x00\x01\x10\x00\x00\x01\x11\x00\x00\x01\x12\x00\x00\x01\x13\x00\x00\x01\x14\x00\x00\x01\x15\x00\x00\x01\x16\x00\x00\x01\x17\x00\x00\x01\x18\x00\x00\x01\x19\x00\x00\x01\x1a\x00\x00\x01\x1b\x00\x00\x01\x1c\x00\x00\x01\x1d\x00\x00\x01\x1e\x00\x00\x01\x1f\x00\x00\x01 \x00\x00\x01!\x00\x00\x01\"\x00\x00\x01#\x00\x00\x01$\x00\x00\x01%\x00\x00\x01&\x00\x00\x01'\x00\x00\x01(\x00\x00\x01)\x00\x00\x01*\x00\x00\x01+\x00\x00\x01,\x00\x00\x01-\x00\x00\x01.\x00\x00\x01/\x00\x00\x010\x00\x00\x011\x00\x00\x012\x00\x00\x013\x00\x00\x014\x00\x00\x015\x00\x00\x016\x00\x00\x017\x00\x00\x018\x00\x00\x019\x00\x00\x01:\x00\x00\x01;\x00\x00\x01<\x00\x00\x01=\x00\x00\x01>\x00\x00\x01?\x00\x00\x01@\x00\x00\x01A\x00\x00\x01B\x00\x00\x01C\x00\x00\x01D\x00\x00\x01E\x00\x00\x01F\x00\x00\x01G\x00\x00\x01H\x00\x00\x01I\x00\x00\x01J\x00\x00\x01K\x00\x00\x01L\x00\x00\x01M\x00\x00\x01N\x00\x00\x01O\x00\x00\x01P\x00\x00\x01Q\x00\x00\x01R\x00\x00\x01S\x00\x00\x01T\x00\x00\x01U\x00\x00\x01V\x00\x00\x01W\x00\x00\x01X\x00\x00\x01Y\x00\x00\x01Z\x00\x00\x01[\x00\x00\x01\\\x00\x00\x01]\x00\x00\x01^\x00\x00\x01_\x00\x00\x01`\x00\x00\x01a\x00\x00\x01b\x00\x00\x01c\x00\x00\x01d\x00\x00\x01e\x00\x00\x01f\x00\x00\x01g\x00\x00\x01h\x00\x00\x01i\x00\x00\x01j\x00\x00\x01k\x00\x00\x01l\x00\x00\x01m\x00\x00\x01n\x00\x00\x01o\x00\x00\x01p\x00\x00\x01q\x00\x00\x01r\x00\x00\x01s\x00\x00\x01t\x00\x00\x01u\x00\x00\x01v\x00\x00\x01w\x00\x00\x01x\x00\x00\x01y\x00\x00\x01z\x00\x00\x01{\x00\x00\x01|\x00\x00\x01}\x00\x00\x01~\x00\x00\x01\x7f\x00\x00\x01\x80\x00\x00\x01\x81\x00\x00\x01\x82\x00\x00\x01\x83\x00\x00\x01\x84\x00\x00\x01\x85\x00\x00\x01\x86\x00\x00\x01\x87\x00\x00\x01\x88\x00\x00\x01\x89\x00\x00\x01\x8a\x00\x00\x01\x8b\x00\x00\x01\x8c\x00\x00\x01\x8d\x00\x00\x01\x8e\x00\x00\x01\x8f\x00\x00\x01\x90\x00\x00\x01\x91\x00\x00\x01\x92\x00\x00\x01\x93\x00\x00\x01\x94\x00\x00\x01\x95\x00\x00\x01\x96\x00\x00\x01\x97\x00\x00\x01\x98\x00\x00\x01\x99\x00\x00\x01\x9a\x00\x00\x01\x9b\x00\x00\x01\x9c\x00\x00\x01\x9d\x00\x00\x01\x9e\x00\x00\x01\x9f\x00\x00\x01\xa0\x00\x00\x01\xa1\x00\x00\x01\xa2\x00\x00\x01\xa3\x00\x00\x01\xa4\x00\x00\x01\xa5\x00\x00\x01\xa6\x00\x00\x01\xa7\x00\x00\x01\xa8\x00\x00\x01\xa9\x00\x00\x01\xaa\x00\x00\x01\xab\x00\x00\x01\xac\x00\x00\x01\xad\x00\x00\x01\xae\x00\x00\x01\xaf\x00\x00\x01\xb0\x00\x00\x01\xb1\x00\x00\x01\xb2\x00\x00\x01\xb3\x00\x00\x01\xb4\x00\x00\x01\xb5\x00\x00\x01\xb6\x00\x00\x01\xb7\x00\x00\x01\xb8\x00\x00\x01\xb9\x00\x00\x01\xba\x00\x00\x01\xbb\x00\x00\x01\xbc\x00\x00\x01\xbd\x00\x00\x01\xbe\x00\x00\x01\xbf\x00\x00\x01\xc0\x00\x00\x01\xc1\x00\x00\x01\xc2\x00\x00\x01\xc3\x00\x00\x01\xc4\x00\x00\x01\xc5\x00\x00\x01\xc6\x00\x00\x01\xc7\x00\x00\x01\xc8\x00\x00\x01\xc9\x00\x00\x01\xca\x00\x00\x01\xcb\x00\x00\x01\xcc\x00\x00\x01\xcd\x00\x00\x01\xce\x00\x00\x01\xcf\x00\x00\x01\xd0\x00\x00\x01\xd1\x00\x00\x01\xd2\x00\x00\x01\xd3\x00\x00\x01\xd4\x00\x00\x01\xd5\x00\x00\x01\xd6\x00\x00\x01\xd7\x00\x00\x01\xd8\x00\x00\x01\xd9\x00\x00\x01\xda\x00\x00\x01\xdb\x00\x00\x01\xdc\x00\x00\x01\xdd\x00\x00\x01\xde\x00\x00\x01\xdf\x00\x00\x01\xe0\x00\x00\x01\xe1\x00\x00\x01\xe2\x00\x00\x01\xe3\x00\x00\x01\xe4\x00\x00\x01\xe5\x00\x00\x01\xe6\x00\x00\x01\xe7\x00\x00\x01\xe8\x00\x00\x01\xe9\x00\x00\x01\xea\x00\x00\x01\xeb\x00\x00\x01\xec\x00\x00\x01\xed\x00\x00\x01\xee\x00\x00\x01\xef\x00\x00\x01\xf0\x00\x00\x01\xf1\x00\x00\x01\xf2\x00\x00\x01\xf3\x00\x00\x01\xf4\x00\x00\x01\xf5\x00\x00\x01\xf6\x00\x00\x01\xf7\x00\x00\x01\xf8\x00\x00\x01\xf9\x00\x00\x01\xfa\x00\x00\x01\xfb\x00\x00\x01\xfc\x00\x00\x01\xfd\x00\x00\x01\xfe\x00\x00\x01\xff" 54 | 55 | 56 | class TestOscMessage(unittest.TestCase): 57 | def test_switch_goes_off(self): 58 | msg = osc_message.OscMessage(_DGRAM_SWITCH_GOES_OFF) 59 | self.assertEqual("/SYNC", msg.address) 60 | self.assertEqual(1, len(msg.params)) 61 | self.assertTrue(isinstance(msg.params[0], float)) 62 | self.assertAlmostEqual(0.0, msg.params[0]) 63 | 64 | def test_switch_goes_on(self): 65 | msg = osc_message.OscMessage(_DGRAM_SWITCH_GOES_ON) 66 | self.assertEqual("/SYNC", msg.address) 67 | self.assertEqual(1, len(msg.params)) 68 | self.assertTrue(isinstance(msg.params[0], float)) 69 | self.assertAlmostEqual(0.5, msg.params[0]) 70 | 71 | def test_knob_rotates(self): 72 | msg = osc_message.OscMessage(_DGRAM_KNOB_ROTATES) 73 | self.assertEqual("/FB", msg.address) 74 | self.assertEqual(1, len(msg.params)) 75 | self.assertTrue(isinstance(msg.params[0], float)) 76 | 77 | def test_no_params(self): 78 | msg = osc_message.OscMessage(_DGRAM_NO_PARAMS) 79 | self.assertEqual("/SYNC", msg.address) 80 | self.assertEqual(0, len(msg.params)) 81 | 82 | def test_all_standard_types_off_params(self): 83 | msg = osc_message.OscMessage(_DGRAM_ALL_STANDARD_TYPES_OF_PARAMS) 84 | self.assertEqual("/SYNC", msg.address) 85 | self.assertEqual(4, len(msg.params)) 86 | self.assertEqual(3, msg.params[0]) 87 | self.assertAlmostEqual(2.0, msg.params[1]) 88 | self.assertEqual("ABC", msg.params[2]) 89 | self.assertEqual(b"stuff\x00\x00\x00", msg.params[3]) 90 | self.assertEqual(4, len(list(msg))) 91 | 92 | def test_all_non_standard_params(self): 93 | msg = osc_message.OscMessage(_DGRAM_ALL_NON_STANDARD_TYPES_OF_PARAMS) 94 | 95 | self.assertEqual("/SYNC", msg.address) 96 | self.assertEqual(6, len(msg.params)) 97 | self.assertEqual(True, msg.params[0]) 98 | self.assertEqual(False, msg.params[1]) 99 | self.assertEqual(None, msg.params[2]) 100 | self.assertEqual([], msg.params[3]) 101 | self.assertEqual((datetime(1900, 1, 1, 0, 0, 0), 0), msg.params[4]) 102 | self.assertEqual(1000000000000, msg.params[5]) 103 | self.assertEqual(6, len(list(msg))) 104 | 105 | def test_complex_array_params(self): 106 | msg = osc_message.OscMessage(_DGRAM_COMPLEX_ARRAY_PARAMS) 107 | self.assertEqual("/SYNC", msg.address) 108 | self.assertEqual(3, len(msg.params)) 109 | self.assertEqual([1], msg.params[0]) 110 | self.assertEqual([["ABC", "DEF"]], msg.params[1]) 111 | self.assertEqual([[2], [3, ["GHI"]]], msg.params[2]) 112 | self.assertEqual(3, len(list(msg))) 113 | 114 | def test_raises_on_empty_datargram(self): 115 | self.assertRaises(osc_message.ParseError, osc_message.OscMessage, b"") 116 | 117 | def test_ignores_unknown_param(self): 118 | msg = osc_message.OscMessage(_DGRAM_UNKNOWN_PARAM_TYPE) 119 | self.assertEqual("/SYNC", msg.address) 120 | self.assertEqual(1, len(msg.params)) 121 | self.assertTrue(isinstance(msg.params[0], float)) 122 | self.assertAlmostEqual(0.5, msg.params[0]) 123 | 124 | def test_raises_on_invalid_array(self): 125 | self.assertRaises( 126 | osc_message.ParseError, osc_message.OscMessage, b"/SYNC\x00\x00\x00[]]\x00" 127 | ) 128 | self.assertRaises( 129 | osc_message.ParseError, osc_message.OscMessage, b"/SYNC\x00\x00\x00[[]\x00" 130 | ) 131 | 132 | def test_raises_on_incorrect_datargram(self): 133 | self.assertRaises(osc_message.ParseError, osc_message.OscMessage, b"foobar") 134 | 135 | def test_parse_long_params_list(self): 136 | msg = osc_message.OscMessage(_DGRAM_LONG_LIST) 137 | self.assertEqual("/SYNC", msg.address) 138 | self.assertEqual(1, len(msg.params)) 139 | self.assertEqual(512, len(msg.params[0])) 140 | 141 | 142 | if __name__ == "__main__": 143 | unittest.main() 144 | -------------------------------------------------------------------------------- /pythonosc/test/parsing/test_osc_types.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the osc_types module.""" 2 | 3 | import unittest 4 | 5 | from pythonosc.parsing import ntp 6 | from pythonosc.parsing import osc_types 7 | 8 | from datetime import datetime 9 | 10 | 11 | class TestString(unittest.TestCase): 12 | def test_get_string(self): 13 | cases = { 14 | b"A\x00\x00\x00": ("A", 4), 15 | b"AB\x00\x00": ("AB", 4), 16 | b"ABC\x00": ("ABC", 4), 17 | b"ABCD\x00\x00\x00\x00": ("ABCD", 8), 18 | b"ABCD\x00\x00\x00\x00GARBAGE": ("ABCD", 8), 19 | b"\x00\x00\x00\x00": ("", 4), 20 | } 21 | 22 | for dgram, expected in cases.items(): 23 | self.assertEqual(expected, osc_types.get_string(dgram, 0)) 24 | 25 | def test_get_string_raises_on_wrong_dgram(self): 26 | cases = [ 27 | b"blablaba", 28 | b"", 29 | b"\x00", 30 | b"\x00\x00", 31 | True, 32 | ] 33 | 34 | for case in cases: 35 | self.assertRaises(osc_types.ParseError, osc_types.get_string, case, 0) 36 | 37 | def test_get_string_raises_when_datagram_too_short(self): 38 | self.assertRaises(osc_types.ParseError, osc_types.get_string, b"abc\x00", 1) 39 | 40 | def test_get_string_raises_on_wrong_start_index_negative(self): 41 | self.assertRaises(osc_types.ParseError, osc_types.get_string, b"abc\x00", -1) 42 | 43 | 44 | class TestInteger(unittest.TestCase): 45 | def test_get_integer(self): 46 | cases = { 47 | b"\x00\x00\x00\x00": (0, 4), 48 | b"\x00\x00\x00\x01": (1, 4), 49 | b"\x00\x00\x00\x02": (2, 4), 50 | b"\x00\x00\x00\x03": (3, 4), 51 | b"\x00\x00\x01\x00": (256, 4), 52 | b"\x00\x01\x00\x00": (65536, 4), 53 | b"\x01\x00\x00\x00": (16777216, 4), 54 | b"\x00\x00\x00\x01GARBAGE": (1, 4), 55 | } 56 | 57 | for dgram, expected in cases.items(): 58 | self.assertEqual(expected, osc_types.get_int(dgram, 0)) 59 | 60 | def test_get_integer_raises_on_type_error(self): 61 | cases = [b"", True] 62 | 63 | for case in cases: 64 | self.assertRaises(osc_types.ParseError, osc_types.get_int, case, 0) 65 | 66 | def test_get_integer_raises_on_wrong_start_index(self): 67 | self.assertRaises( 68 | osc_types.ParseError, osc_types.get_int, b"\x00\x00\x00\x11", 1 69 | ) 70 | 71 | def test_get_integer_raises_on_wrong_start_index_negative(self): 72 | self.assertRaises( 73 | osc_types.ParseError, osc_types.get_int, b"\x00\x00\x00\x00", -1 74 | ) 75 | 76 | def test_datagram_too_short(self): 77 | dgram = b"\x00" * 3 78 | self.assertRaises(osc_types.ParseError, osc_types.get_int, dgram, 2) 79 | 80 | 81 | class TestRGBA(unittest.TestCase): 82 | def test_get_rgba(self): 83 | cases = { 84 | b"\x00\x00\x00\x00": (0, 4), 85 | b"\x00\x00\x00\x01": (1, 4), 86 | b"\x00\x00\x00\x02": (2, 4), 87 | b"\x00\x00\x00\x03": (3, 4), 88 | b"\xff\x00\x00\x00": (4278190080, 4), 89 | b"\x00\xff\x00\x00": (16711680, 4), 90 | b"\x00\x00\xff\x00": (65280, 4), 91 | b"\x00\x00\x00\xff": (255, 4), 92 | b"\x00\x00\x00\x01GARBAGE": (1, 4), 93 | } 94 | 95 | for dgram, expected in cases.items(): 96 | self.assertEqual(expected, osc_types.get_rgba(dgram, 0)) 97 | 98 | def test_get_rgba_raises_on_type_error(self): 99 | cases = [b"", True] 100 | 101 | for case in cases: 102 | self.assertRaises(osc_types.ParseError, osc_types.get_rgba, case, 0) 103 | 104 | def test_get_rgba_raises_on_wrong_start_index(self): 105 | self.assertRaises( 106 | osc_types.ParseError, osc_types.get_rgba, b"\x00\x00\x00\x11", 1 107 | ) 108 | 109 | def test_get_rgba_raises_on_wrong_start_index_negative(self): 110 | self.assertRaises( 111 | osc_types.ParseError, osc_types.get_rgba, b"\x00\x00\x00\x00", -1 112 | ) 113 | 114 | def test_datagram_too_short(self): 115 | dgram = b"\x00" * 3 116 | self.assertRaises(osc_types.ParseError, osc_types.get_rgba, dgram, 2) 117 | 118 | 119 | class TestMidi(unittest.TestCase): 120 | def test_get_midi(self): 121 | cases = { 122 | b"\x00\x00\x00\x00": ((0, 0, 0, 0), 4), 123 | b"\x00\x00\x00\x01": ((0, 0, 0, 1), 4), 124 | b"\x00\x00\x00\x02": ((0, 0, 0, 2), 4), 125 | b"\x00\x00\x00\x03": ((0, 0, 0, 3), 4), 126 | b"\x00\x00\x01\x00": ((0, 0, 1, 0), 4), 127 | b"\x00\x01\x00\x00": ((0, 1, 0, 0), 4), 128 | b"\x01\x00\x00\x00": ((1, 0, 0, 0), 4), 129 | b"\x00\x00\x00\x01GARBAGE": ((0, 0, 0, 1), 4), 130 | } 131 | 132 | for dgram, expected in cases.items(): 133 | self.assertEqual(expected, osc_types.get_midi(dgram, 0)) 134 | 135 | def test_get_midi_raises_on_type_error(self): 136 | cases = [b"", True] 137 | 138 | for case in cases: 139 | self.assertRaises(osc_types.ParseError, osc_types.get_midi, case, 0) 140 | 141 | def test_get_midi_raises_on_wrong_start_index(self): 142 | self.assertRaises( 143 | osc_types.ParseError, osc_types.get_midi, b"\x00\x00\x00\x11", 1 144 | ) 145 | 146 | def test_get_midi_raises_on_wrong_start_index_negative(self): 147 | self.assertRaises( 148 | osc_types.ParseError, osc_types.get_midi, b"\x00\x00\x00\x00", -1 149 | ) 150 | 151 | def test_datagram_too_short(self): 152 | dgram = b"\x00" * 3 153 | self.assertRaises(osc_types.ParseError, osc_types.get_midi, dgram, 2) 154 | 155 | 156 | class TestDate(unittest.TestCase): 157 | def test_get_timetag(self): 158 | cases = { 159 | b"\xde\x9c\x91\xbf\x00\x01\x00\x00": ( 160 | (datetime(2018, 5, 8, 21, 14, 39), 65536), 161 | 8, 162 | ), # NOTE: fraction is expresed as 32bit OSC. 163 | b"\x00\x00\x00\x00\x00\x00\x00\x00": ( 164 | (datetime(1900, 1, 1, 0, 0, 0), 0), 165 | 8, 166 | ), 167 | b"\x83\xaa\x7e\x80\x0a\x00\xb0\x0c": ( 168 | (datetime(1970, 1, 1, 0, 0, 0), 167817228), 169 | 8, 170 | ), 171 | } 172 | 173 | for dgram, expected in cases.items(): 174 | self.assertEqual(expected, osc_types.get_timetag(dgram, 0)) 175 | 176 | def test_get_timetag_raises_on_wrong_start_index_negative(self): 177 | self.assertRaises( 178 | osc_types.ParseError, 179 | osc_types.get_timetag, 180 | b"\x00\x00\x00\x00\x00\x00\x00\x00", 181 | -1, 182 | ) 183 | 184 | def test_get_timetag_raises_on_type_error(self): 185 | cases = [b"", True] 186 | 187 | for case in cases: 188 | self.assertRaises(osc_types.ParseError, osc_types.get_timetag, case, 0) 189 | 190 | def test_get_timetag_raises_on_wrong_start_index(self): 191 | self.assertRaises( 192 | osc_types.ParseError, 193 | osc_types.get_date, 194 | b"\x00\x00\x00\x11\x00\x00\x00\x11", 195 | 1, 196 | ) 197 | 198 | def test_ttag_datagram_too_short(self): 199 | dgram = b"\x00" * 7 200 | self.assertRaises(osc_types.ParseError, osc_types.get_timetag, dgram, 6) 201 | 202 | dgram = b"\x00" * 2 203 | self.assertRaises(osc_types.ParseError, osc_types.get_timetag, dgram, 1) 204 | 205 | dgram = b"\x00" * 5 206 | self.assertRaises(osc_types.ParseError, osc_types.get_timetag, dgram, 4) 207 | 208 | dgram = b"\x00" * 1 209 | self.assertRaises(osc_types.ParseError, osc_types.get_timetag, dgram, 0) 210 | 211 | 212 | class TestFloat(unittest.TestCase): 213 | def test_get_float(self): 214 | cases = { 215 | b"\x00\x00\x00\x00": (0.0, 4), 216 | b"?\x80\x00\x00'": (1.0, 4), 217 | b"@\x00\x00\x00": (2.0, 4), 218 | b"\x00\x00\x00\x00GARBAGE": (0.0, 4), 219 | } 220 | 221 | for dgram, expected in cases.items(): 222 | self.assertAlmostEqual(expected, osc_types.get_float(dgram, 0)) 223 | 224 | def test_get_float_raises_on_wrong_dgram(self): 225 | cases = [True] 226 | 227 | for case in cases: 228 | self.assertRaises(osc_types.ParseError, osc_types.get_float, case, 0) 229 | 230 | def test_get_float_raises_on_type_error(self): 231 | cases = [None] 232 | 233 | for case in cases: 234 | self.assertRaises(osc_types.ParseError, osc_types.get_float, case, 0) 235 | 236 | def test_datagram_too_short_pads(self): 237 | dgram = b"\x00" * 2 238 | self.assertEqual((0, 4), osc_types.get_float(dgram, 0)) 239 | 240 | 241 | class TestDouble(unittest.TestCase): 242 | def test_get_double(self): 243 | cases = { 244 | b"\x00\x00\x00\x00\x00\x00\x00\x00": (0.0, 8), 245 | b"?\xf0\x00\x00\x00\x00\x00\x00": (1.0, 8), 246 | b"@\x00\x00\x00\x00\x00\x00\x00": (2.0, 8), 247 | b"\xbf\xf0\x00\x00\x00\x00\x00\x00": (-1.0, 8), 248 | b"\xc0\x00\x00\x00\x00\x00\x00\x00": (-2.0, 8), 249 | b"\x00\x00\x00\x00\x00\x00\x00\x00GARBAGE": (0.0, 8), 250 | } 251 | 252 | for dgram, expected in cases.items(): 253 | self.assertAlmostEqual(expected, osc_types.get_double(dgram, 0)) 254 | 255 | def test_get_double_raises_on_wrong_dgram(self): 256 | cases = [True] 257 | 258 | for case in cases: 259 | self.assertRaises(osc_types.ParseError, osc_types.get_double, case, 0) 260 | 261 | def test_get_double_raises_on_type_error(self): 262 | cases = [None] 263 | 264 | for case in cases: 265 | self.assertRaises(osc_types.ParseError, osc_types.get_double, case, 0) 266 | 267 | def test_datagram_too_short_pads(self): 268 | dgram = b"\x00" * 2 269 | self.assertRaises(osc_types.ParseError, osc_types.get_double, dgram, 0) 270 | 271 | 272 | class TestBlob(unittest.TestCase): 273 | def test_get_blob(self): 274 | cases = { 275 | b"\x00\x00\x00\x00": (b"", 4), 276 | b"\x00\x00\x00\x08stuff\x00\x00\x00": (b"stuff\x00\x00\x00", 12), 277 | b"\x00\x00\x00\x04\x00\x00\x00\x00": (b"\x00\x00\x00\x00", 8), 278 | b"\x00\x00\x00\x02\x00\x00\x00\x00": (b"\x00\x00", 8), 279 | b"\x00\x00\x00\x08stuff\x00\x00\x00datagramcontinues": ( 280 | b"stuff\x00\x00\x00", 281 | 12, 282 | ), 283 | } 284 | 285 | for dgram, expected in cases.items(): 286 | self.assertEqual(expected, osc_types.get_blob(dgram, 0)) 287 | 288 | def test_get_blob_raises_on_wrong_dgram(self): 289 | cases = [b"", True, b"\x00\x00\x00\x08"] 290 | 291 | for case in cases: 292 | self.assertRaises(osc_types.ParseError, osc_types.get_blob, case, 0) 293 | 294 | def test_get_blob_raises_on_wrong_start_index(self): 295 | self.assertRaises( 296 | osc_types.ParseError, osc_types.get_blob, b"\x00\x00\x00\x11", 1 297 | ) 298 | 299 | def test_get_blob_raises_too_short_buffer(self): 300 | self.assertRaises( 301 | osc_types.ParseError, osc_types.get_blob, b"\x00\x00\x00\x11\x00\x00", 1 302 | ) 303 | 304 | def test_get_blog_raises_on_wrong_start_index_negative(self): 305 | self.assertRaises( 306 | osc_types.ParseError, osc_types.get_blob, b"\x00\x00\x00\x00", -1 307 | ) 308 | 309 | 310 | class TestNTPTimestamp(unittest.TestCase): 311 | def test_immediately_dgram(self): 312 | dgram = ntp.IMMEDIATELY 313 | self.assertEqual(osc_types.IMMEDIATELY, osc_types.get_date(dgram, 0)[0]) 314 | 315 | def test_origin_of_time(self): 316 | dgram = b"\x00" * 8 317 | self.assertGreater(0, osc_types.get_date(dgram, 0)[0]) 318 | 319 | def test_datagram_too_short(self): 320 | dgram = b"\x00" * 8 321 | self.assertRaises(osc_types.ParseError, osc_types.get_date, dgram, 2) 322 | 323 | def test_write_date(self): 324 | time = 1569899476.167749 # known round(time.time(), 6) 325 | timetag = b"\xe1=BT*\xf1\x98\x00" 326 | self.assertEqual(timetag, osc_types.write_date(time)) 327 | 328 | 329 | class TestBuildMethods(unittest.TestCase): 330 | def test_string(self): 331 | self.assertEqual(b"\x00\x00\x00\x00", osc_types.write_string("")) 332 | self.assertEqual(b"A\x00\x00\x00", osc_types.write_string("A")) 333 | self.assertEqual(b"AB\x00\x00", osc_types.write_string("AB")) 334 | self.assertEqual(b"ABC\x00", osc_types.write_string("ABC")) 335 | self.assertEqual(b"ABCD\x00\x00\x00\x00", osc_types.write_string("ABCD")) 336 | 337 | def test_string_raises(self): 338 | self.assertRaises(osc_types.BuildError, osc_types.write_string, 123) 339 | 340 | def test_int(self): 341 | self.assertEqual(b"\x00\x00\x00\x00", osc_types.write_int(0)) 342 | self.assertEqual(b"\x00\x00\x00\x01", osc_types.write_int(1)) 343 | 344 | def test_int_raises(self): 345 | self.assertRaises(osc_types.BuildError, osc_types.write_int, "no int") 346 | 347 | def test_float(self): 348 | self.assertEqual(b"\x00\x00\x00\x00", osc_types.write_float(0.0)) 349 | self.assertEqual(b"?\x00\x00\x00", osc_types.write_float(0.5)) 350 | self.assertEqual(b"?\x80\x00\x00", osc_types.write_float(1.0)) 351 | self.assertEqual(b"?\x80\x00\x00", osc_types.write_float(1)) 352 | 353 | def test_float_raises(self): 354 | self.assertRaises(osc_types.BuildError, osc_types.write_float, "no float") 355 | 356 | def test_blob(self): 357 | self.assertEqual( 358 | b"\x00\x00\x00\x02\x00\x01\x00\x00", osc_types.write_blob(b"\x00\x01") 359 | ) 360 | self.assertEqual( 361 | b"\x00\x00\x00\x04\x00\x01\x02\x03", 362 | osc_types.write_blob(b"\x00\x01\x02\x03"), 363 | ) 364 | 365 | def test_blob_raises(self): 366 | self.assertRaises(osc_types.BuildError, osc_types.write_blob, b"") 367 | 368 | 369 | if __name__ == "__main__": 370 | unittest.main() 371 | -------------------------------------------------------------------------------- /pythonosc/dispatcher.py: -------------------------------------------------------------------------------- 1 | """Maps OSC addresses to handler functions""" 2 | 3 | import collections 4 | import inspect 5 | import logging 6 | import re 7 | import time 8 | from pythonosc import osc_packet 9 | from typing import ( 10 | overload, 11 | List, 12 | Union, 13 | Any, 14 | AnyStr, 15 | Generator, 16 | Tuple, 17 | Callable, 18 | Optional, 19 | DefaultDict, 20 | ) 21 | from pythonosc.osc_message import OscMessage 22 | from pythonosc.osc_message_builder import ArgValue 23 | 24 | 25 | class Handler(object): 26 | """Wrapper for a callback function that will be called when an OSC message is sent to the right address. 27 | 28 | Represents a handler callback function that will be called whenever an OSC message is sent to the address this 29 | handler is mapped to. It passes the address, the fixed arguments (if any) as well as all osc arguments from the 30 | message if any were passed. 31 | """ 32 | 33 | def __init__( 34 | self, 35 | _callback: Callable, 36 | _args: Union[Any, List[Any]], 37 | _needs_reply_address: bool = False, 38 | ) -> None: 39 | """ 40 | Args: 41 | _callback Function that is called when handler is invoked 42 | _args: Message causing invocation 43 | _needs_reply_address Whether the client's ip address shall be passed as an argument or not 44 | """ 45 | self.callback = _callback 46 | self.args = _args 47 | self.needs_reply_address = _needs_reply_address 48 | 49 | # needed for test module 50 | def __eq__(self, other: Any) -> bool: 51 | return ( 52 | isinstance(self, type(other)) 53 | and self.callback == other.callback 54 | and self.args == other.args 55 | and self.needs_reply_address == other.needs_reply_address 56 | ) 57 | 58 | def invoke( 59 | self, client_address: Tuple[str, int], message: OscMessage 60 | ) -> Union[None, AnyStr, Tuple[AnyStr, ArgValue]]: 61 | """Invokes the associated callback function 62 | 63 | Args: 64 | client_address: Address match that causes the invocation 65 | message: Message causing invocation 66 | Returns: 67 | The result of the handler function can be None, a string OSC address, or a tuple of the OSC address 68 | and arguments. 69 | """ 70 | if self.needs_reply_address: 71 | if self.args: 72 | return self.callback( 73 | client_address, message.address, self.args, *message 74 | ) 75 | else: 76 | return self.callback(client_address, message.address, *message) 77 | else: 78 | if self.args: 79 | return self.callback(message.address, self.args, *message) 80 | else: 81 | return self.callback(message.address, *message) 82 | 83 | 84 | class Dispatcher(object): 85 | """Maps Handlers to OSC addresses and dispatches messages to the handler on matched addresses 86 | 87 | Maps OSC addresses to handler functions and invokes the correct handler when a message comes in. 88 | """ 89 | 90 | def __init__(self) -> None: 91 | self._map: DefaultDict[str, List[Handler]] = collections.defaultdict(list) 92 | self._default_handler: Optional[Handler] = None 93 | 94 | def map( 95 | self, 96 | address: str, 97 | handler: Callable, 98 | *args: Union[Any, List[Any]], 99 | needs_reply_address: bool = False, 100 | ) -> Handler: 101 | """Map an address to a handler 102 | 103 | The callback function must have one of the following signatures: 104 | 105 | ``def some_cb(address: str, *osc_args: List[Any]) -> Union[None, AnyStr, Tuple(str, ArgValue)]:`` 106 | ``def some_cb(address: str, fixed_args: List[Any], *osc_args: List[Any]) -> Union[None, AnyStr, 107 | Tuple(str, ArgValue)]:`` 108 | 109 | ``def some_cb(client_address: Tuple[str, int], address: str, *osc_args: List[Any]) -> Union[None, AnyStr, 110 | Tuple(str, ArgValue)]:`` 111 | ``def some_cb(client_address: Tuple[str, int], address: str, fixed_args: List[Any], *osc_args: List[Any]) -> Union[None, AnyStr, Tuple(str, ArgValue)]:`` 112 | 113 | The callback function can return None, or a string representing an OSC address to be returned to the client, 114 | or a tuple that includes the address and ArgValue which will be converted to an OSC message and returned to 115 | the client. 116 | 117 | Args: 118 | address: Address to be mapped 119 | handler: Callback function that will be called as the handler for the given address 120 | *args: Fixed arguements that will be passed to the callback function 121 | needs_reply_address: Whether the IP address from which the message originated from shall be passed as 122 | an argument to the handler callback 123 | 124 | Returns: 125 | The handler object that will be invoked should the given address match 126 | 127 | """ 128 | # TODO: Check the spec: 129 | # http://opensoundcontrol.org/spec-1_0 130 | # regarding multiple mappings 131 | handlerobj = Handler(handler, list(args), needs_reply_address) 132 | self._map[address].append(handlerobj) 133 | return handlerobj 134 | 135 | @overload 136 | def unmap(self, address: str, handler: Handler) -> None: 137 | """Remove an already mapped handler from an address 138 | 139 | Args: 140 | address (str): Address to be unmapped 141 | handler (Handler): A Handler object as returned from map(). 142 | """ 143 | pass 144 | 145 | @overload 146 | def unmap( 147 | self, 148 | address: str, 149 | handler: Callable, 150 | *args: Union[Any, List[Any]], 151 | needs_reply_address: bool = False, 152 | ) -> None: 153 | """Remove an already mapped handler from an address 154 | 155 | Args: 156 | address: Address to be unmapped 157 | handler: A function that will be run when the address matches with 158 | the OscMessage passed as parameter. 159 | args: Any additional arguments that will be always passed to the 160 | handlers after the osc messages arguments if any. 161 | needs_reply_address: True if the handler function needs the 162 | originating client address passed (as the first argument). 163 | """ 164 | pass 165 | 166 | def unmap(self, address, handler, *args, needs_reply_address=False): 167 | try: 168 | if isinstance(handler, Handler): 169 | self._map[address].remove(handler) 170 | else: 171 | self._map[address].remove( 172 | Handler(handler, list(args), needs_reply_address) 173 | ) 174 | except ValueError as e: 175 | if str(e) == "list.remove(x): x not in list": 176 | raise ValueError( 177 | f"Address '{address}' doesn't have handler '{handler}' mapped to it" 178 | ) from e 179 | 180 | def handlers_for_address( 181 | self, address_pattern: str 182 | ) -> Generator[Handler, None, None]: 183 | """Yields handlers matching an address 184 | 185 | 186 | Args: 187 | address_pattern: Address to match 188 | 189 | Returns: 190 | Generator yielding Handlers matching address_pattern 191 | """ 192 | # First convert the address_pattern into a matchable regexp. 193 | # '?' in the OSC Address Pattern matches any single character. 194 | # Let's consider numbers and _ "characters" too here, it's not said 195 | # explicitly in the specification but it sounds good. 196 | escaped_address_pattern = re.escape(address_pattern) 197 | pattern = escaped_address_pattern.replace("\\?", "\\w?") 198 | # '*' in the OSC Address Pattern matches any sequence of zero or more 199 | # characters. 200 | pattern = pattern.replace("\\*", "[\\w|\\+]*") 201 | # The rest of the syntax in the specification is like the re module so 202 | # we're fine. 203 | pattern = f"{pattern}$" 204 | patterncompiled = re.compile(pattern) 205 | matched = False 206 | 207 | for addr, handlers in self._map.items(): 208 | if patterncompiled.match(addr) or ( 209 | ("*" in addr) 210 | and re.match(addr.replace("*", "[^/]*?/*"), address_pattern) 211 | ): 212 | yield from handlers 213 | matched = True 214 | 215 | if not matched and self._default_handler: 216 | logging.debug("No handler matched but default handler present, added it.") 217 | yield self._default_handler 218 | 219 | def call_handlers_for_packet( 220 | self, data: bytes, client_address: Tuple[str, int] 221 | ) -> List: 222 | """Invoke handlers for all messages in OSC packet 223 | 224 | The incoming OSC Packet is decoded and the handlers for each included message is found and invoked. 225 | 226 | Args: 227 | data: Data of packet 228 | client_address: Address of client this packet originated from 229 | Returns: A list of strings or tuples to be converted to OSC messages and returned to the client 230 | """ 231 | results = list() 232 | # Get OSC messages from all bundles or standalone message. 233 | try: 234 | packet = osc_packet.OscPacket(data) 235 | for timed_msg in packet.messages: 236 | now = time.time() 237 | handlers = self.handlers_for_address(timed_msg.message.address) 238 | if not handlers: 239 | continue 240 | # If the message is to be handled later, then so be it. 241 | if timed_msg.time > now: 242 | time.sleep(timed_msg.time - now) 243 | for handler in handlers: 244 | result = handler.invoke(client_address, timed_msg.message) 245 | if result is not None: 246 | results.append(result) 247 | except osc_packet.ParseError: 248 | pass 249 | return results 250 | 251 | async def async_call_handlers_for_packet( 252 | self, data: bytes, client_address: Tuple[str, int] 253 | ) -> List: 254 | """ 255 | This function calls the handlers registered to the dispatcher for 256 | every message it found in the packet. 257 | The process/thread granularity is thus the OSC packet, not the handler. 258 | 259 | If parameters were registered with the dispatcher, then the handlers are 260 | called this way: 261 | handler('/address that triggered the message', 262 | registered_param_list, osc_msg_arg1, osc_msg_arg2, ...) 263 | if no parameters were registered, then it is just called like this: 264 | handler('/address that triggered the message', 265 | osc_msg_arg1, osc_msg_arg2, osc_msg_param3, ...) 266 | """ 267 | 268 | # Get OSC messages from all bundles or standalone message. 269 | results = [] 270 | try: 271 | packet = osc_packet.OscPacket(data) 272 | for timed_msg in packet.messages: 273 | now = time.time() 274 | handlers = self.handlers_for_address(timed_msg.message.address) 275 | if not handlers: 276 | continue 277 | # If the message is to be handled later, then so be it. 278 | if timed_msg.time > now: 279 | time.sleep(timed_msg.time - now) 280 | for handler in handlers: 281 | if inspect.iscoroutinefunction(handler.callback): 282 | if handler.needs_reply_address: 283 | result = await handler.callback( 284 | client_address, 285 | timed_msg.message.address, 286 | handler.args, 287 | *timed_msg.message, 288 | ) 289 | elif handler.args: 290 | result = await handler.callback( 291 | timed_msg.message.address, 292 | handler.args, 293 | *timed_msg.message, 294 | ) 295 | else: 296 | result = await handler.callback( 297 | timed_msg.message.address, *timed_msg.message 298 | ) 299 | else: 300 | if handler.needs_reply_address: 301 | result = handler.callback( 302 | client_address, 303 | timed_msg.message.address, 304 | handler.args, 305 | *timed_msg.message, 306 | ) 307 | elif handler.args: 308 | result = handler.callback( 309 | timed_msg.message.address, 310 | handler.args, 311 | *timed_msg.message, 312 | ) 313 | else: 314 | result = handler.callback( 315 | timed_msg.message.address, *timed_msg.message 316 | ) 317 | if result: 318 | results.append(result) 319 | except osc_packet.ParseError as e: 320 | pass 321 | return results 322 | 323 | def set_default_handler( 324 | self, handler: Callable, needs_reply_address: bool = False 325 | ) -> None: 326 | """Sets the default handler 327 | 328 | The default handler is invoked every time no other handler is mapped to an address. 329 | 330 | Args: 331 | handler: Callback function to handle unmapped requests 332 | needs_reply_address: Whether the callback shall be passed the client address 333 | """ 334 | self._default_handler = ( 335 | None if (handler is None) else Handler(handler, [], needs_reply_address) 336 | ) 337 | -------------------------------------------------------------------------------- /pythonosc/test/test_osc_tcp_server.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import unittest 3 | import unittest.mock as mock 4 | 5 | from pythonosc import dispatcher, osc_tcp_server 6 | from pythonosc.slip import END 7 | 8 | _SIMPLE_PARAM_INT_MSG = b"/SYNC\x00\x00\x00" b",i\x00\x00" b"\x00\x00\x00\x04" 9 | 10 | LEN_SIMPLE_PARAM_INT_MSG = struct.pack("!I", len(_SIMPLE_PARAM_INT_MSG)) 11 | _SIMPLE_PARAM_INT_MSG_1_1 = END + _SIMPLE_PARAM_INT_MSG + END 12 | 13 | # Regression test for a datagram that should NOT be stripped, ever... 14 | _SIMPLE_PARAM_INT_9 = b"/debug\x00\x00,i\x00\x00\x00\x00\x00\t" 15 | LEN_SIMPLE_PARAM_INT_9 = struct.pack("!I", len(_SIMPLE_PARAM_INT_9)) 16 | 17 | _SIMPLE_PARAM_INT_9_1_1 = END + _SIMPLE_PARAM_INT_9 + END 18 | 19 | _SIMPLE_MSG_NO_PARAMS = b"/SYNC\x00\x00\x00" 20 | LEN_SIMPLE_MSG_NO_PARAMS = struct.pack("!I", len(_SIMPLE_MSG_NO_PARAMS)) 21 | _SIMPLE_MSG_NO_PARAMS_1_1 = END + _SIMPLE_MSG_NO_PARAMS + END 22 | 23 | 24 | class TestTCP_1_1_Handler(unittest.TestCase): 25 | def setUp(self): 26 | super().setUp() 27 | self.dispatcher = dispatcher.Dispatcher() 28 | # We do not want to create real UDP connections during unit tests. 29 | self.server = unittest.mock.Mock(spec=osc_tcp_server.BlockingOSCTCPServer) 30 | # Need to attach property mocks to types, not objects... weird. 31 | type(self.server).dispatcher = unittest.mock.PropertyMock( 32 | return_value=self.dispatcher 33 | ) 34 | self.client_address = ("127.0.0.1", 8080) 35 | self.mock_meth = unittest.mock.MagicMock() 36 | self.mock_meth.return_value = None 37 | 38 | def test_no_match(self): 39 | self.dispatcher.map("/foobar", self.mock_meth) 40 | mock_sock = mock.Mock() 41 | mock_sock.recv = mock.Mock() 42 | mock_sock.recv.side_effect = [ 43 | _SIMPLE_MSG_NO_PARAMS_1_1, 44 | _SIMPLE_PARAM_INT_MSG_1_1, 45 | b"", 46 | ] 47 | osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) 48 | self.assertFalse(self.mock_meth.called) 49 | 50 | def test_match_with_args(self): 51 | self.dispatcher.map("/SYNC", self.mock_meth, 1, 2, 3) 52 | mock_sock = mock.Mock() 53 | mock_sock.recv = mock.Mock() 54 | mock_sock.recv.side_effect = [_SIMPLE_PARAM_INT_MSG_1_1, b""] 55 | osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) 56 | self.mock_meth.assert_called_with("/SYNC", [1, 2, 3], 4) 57 | 58 | def test_match_int9(self): 59 | self.dispatcher.map("/debug", self.mock_meth) 60 | mock_sock = mock.Mock() 61 | mock_sock.recv = mock.Mock() 62 | mock_sock.recv.side_effect = [_SIMPLE_PARAM_INT_9_1_1, b""] 63 | osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) 64 | self.assertTrue(self.mock_meth.called) 65 | self.mock_meth.assert_called_with("/debug", 9) 66 | 67 | def test_match_without_args(self): 68 | self.dispatcher.map("/SYNC", self.mock_meth) 69 | mock_sock = mock.Mock() 70 | mock_sock.recv = mock.Mock() 71 | mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] 72 | osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) 73 | self.mock_meth.assert_called_with("/SYNC") 74 | 75 | def test_match_default_handler(self): 76 | self.dispatcher.set_default_handler(self.mock_meth) 77 | mock_sock = mock.Mock() 78 | mock_sock.recv = mock.Mock() 79 | mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] 80 | osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) 81 | self.mock_meth.assert_called_with("/SYNC") 82 | 83 | def test_response_no_args(self): 84 | def respond(*args, **kwargs): 85 | return "/SYNC" 86 | 87 | self.dispatcher.map("/SYNC", respond) 88 | mock_sock = mock.Mock() 89 | mock_sock.recv = mock.Mock() 90 | mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] 91 | mock_sock.sendall = mock.Mock() 92 | mock_sock.sendall.return_value = None 93 | osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) 94 | mock_sock.sendall.assert_called_with(b"\xc0/SYNC\00\00\00,\00\00\00\xc0") 95 | 96 | def test_response_with_args(self): 97 | def respond(*args, **kwargs): 98 | return ( 99 | "/SYNC", 100 | 1, 101 | "2", 102 | 3.0, 103 | ) 104 | 105 | self.dispatcher.map("/SYNC", respond) 106 | mock_sock = mock.Mock() 107 | mock_sock.recv = mock.Mock() 108 | mock_sock.recv.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] 109 | mock_sock.sendall = mock.Mock() 110 | mock_sock.sendall.return_value = None 111 | osc_tcp_server._TCPHandler1_1(mock_sock, self.client_address, self.server) 112 | mock_sock.sendall.assert_called_with( 113 | b"\xc0/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00\xc0" 114 | ) 115 | 116 | 117 | class TestTCP_1_0_Handler(unittest.TestCase): 118 | def setUp(self): 119 | super().setUp() 120 | self.dispatcher = dispatcher.Dispatcher() 121 | # We do not want to create real UDP connections during unit tests. 122 | self.server = unittest.mock.Mock(spec=osc_tcp_server.BlockingOSCTCPServer) 123 | # Need to attach property mocks to types, not objects... weird. 124 | type(self.server).dispatcher = unittest.mock.PropertyMock( 125 | return_value=self.dispatcher 126 | ) 127 | self.client_address = ("127.0.0.1", 8080) 128 | self.mock_meth = unittest.mock.MagicMock() 129 | self.mock_meth.return_value = None 130 | 131 | def test_no_match(self): 132 | self.dispatcher.map("/foobar", self.mock_meth) 133 | mock_sock = mock.Mock() 134 | mock_sock.recv = mock.Mock() 135 | mock_sock.recv.side_effect = [ 136 | LEN_SIMPLE_MSG_NO_PARAMS, 137 | _SIMPLE_MSG_NO_PARAMS, 138 | LEN_SIMPLE_PARAM_INT_MSG, 139 | _SIMPLE_PARAM_INT_MSG, 140 | b"", 141 | ] 142 | osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) 143 | self.assertFalse(self.mock_meth.called) 144 | 145 | def test_match_with_args(self): 146 | self.dispatcher.map("/SYNC", self.mock_meth, 1, 2, 3) 147 | mock_sock = mock.Mock() 148 | mock_sock.recv = mock.Mock() 149 | mock_sock.recv.side_effect = [ 150 | LEN_SIMPLE_PARAM_INT_MSG, 151 | _SIMPLE_PARAM_INT_MSG, 152 | b"", 153 | ] 154 | osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) 155 | self.mock_meth.assert_called_with("/SYNC", [1, 2, 3], 4) 156 | 157 | def test_match_int9(self): 158 | self.dispatcher.map("/debug", self.mock_meth) 159 | mock_sock = mock.Mock() 160 | mock_sock.recv = mock.Mock() 161 | mock_sock.recv.side_effect = [LEN_SIMPLE_PARAM_INT_9, _SIMPLE_PARAM_INT_9, b""] 162 | osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) 163 | self.assertTrue(self.mock_meth.called) 164 | self.mock_meth.assert_called_with("/debug", 9) 165 | 166 | def test_match_without_args(self): 167 | self.dispatcher.map("/SYNC", self.mock_meth) 168 | mock_sock = mock.Mock() 169 | mock_sock.recv = mock.Mock() 170 | mock_sock.recv.side_effect = [ 171 | LEN_SIMPLE_MSG_NO_PARAMS, 172 | _SIMPLE_MSG_NO_PARAMS, 173 | b"", 174 | ] 175 | osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) 176 | self.mock_meth.assert_called_with("/SYNC") 177 | 178 | def test_match_default_handler(self): 179 | self.dispatcher.set_default_handler(self.mock_meth) 180 | mock_sock = mock.Mock() 181 | mock_sock.recv = mock.Mock() 182 | mock_sock.recv.side_effect = [ 183 | LEN_SIMPLE_MSG_NO_PARAMS, 184 | _SIMPLE_MSG_NO_PARAMS, 185 | b"", 186 | ] 187 | osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) 188 | self.mock_meth.assert_called_with("/SYNC") 189 | 190 | def test_response_no_args(self): 191 | def respond(*args, **kwargs): 192 | return "/SYNC" 193 | 194 | self.dispatcher.map("/SYNC", respond) 195 | mock_sock = mock.Mock() 196 | mock_sock.recv = mock.Mock() 197 | mock_sock.recv.side_effect = [ 198 | LEN_SIMPLE_MSG_NO_PARAMS, 199 | _SIMPLE_MSG_NO_PARAMS, 200 | b"", 201 | ] 202 | mock_sock.sendall = mock.Mock() 203 | mock_sock.sendall.return_value = None 204 | osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) 205 | mock_sock.sendall.assert_called_with( 206 | b"\x00\x00\x00\x0c/SYNC\00\00\00,\00\00\00" 207 | ) 208 | 209 | def test_response_with_args(self): 210 | def respond(*args, **kwargs): 211 | return ( 212 | "/SYNC", 213 | 1, 214 | "2", 215 | 3.0, 216 | ) 217 | 218 | self.dispatcher.map("/SYNC", respond) 219 | mock_sock = mock.Mock() 220 | mock_sock.recv = mock.Mock() 221 | mock_sock.recv.side_effect = [ 222 | LEN_SIMPLE_MSG_NO_PARAMS, 223 | _SIMPLE_MSG_NO_PARAMS, 224 | b"", 225 | ] 226 | mock_sock.sendall = mock.Mock() 227 | mock_sock.sendall.return_value = None 228 | osc_tcp_server._TCPHandler1_0(mock_sock, self.client_address, self.server) 229 | mock_sock.sendall.assert_called_with( 230 | b"\x00\x00\x00\x1c/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00" 231 | ) 232 | 233 | 234 | class TestAsync1_1Handler(unittest.IsolatedAsyncioTestCase): 235 | def setUp(self): 236 | super().setUp() 237 | self.dispatcher = dispatcher.Dispatcher() 238 | # We do not want to create real UDP connections during unit tests. 239 | self.server = unittest.mock.Mock(spec=osc_tcp_server.BlockingOSCTCPServer) 240 | # Need to attach property mocks to types, not objects... weird. 241 | type(self.server).dispatcher = unittest.mock.PropertyMock( 242 | return_value=self.dispatcher 243 | ) 244 | self.client_address = ("127.0.0.1", 8080) 245 | self.mock_writer = mock.Mock() 246 | self.mock_writer.close = mock.Mock() 247 | self.mock_writer.write = mock.Mock() 248 | self.mock_writer.write_eof = mock.Mock() 249 | self.mock_writer.drain = mock.AsyncMock() 250 | self.mock_writer.wait_closed = mock.AsyncMock() 251 | self.mock_reader = mock.Mock() 252 | self.mock_reader.read = mock.AsyncMock() 253 | self.server = osc_tcp_server.AsyncOSCTCPServer( 254 | "127.0.0.1", 8008, self.dispatcher 255 | ) 256 | self.mock_meth = unittest.mock.MagicMock() 257 | self.mock_meth.return_value = None 258 | 259 | async def test_no_match(self): 260 | self.dispatcher.map("/foobar", self.mock_meth) 261 | self.mock_reader.read.side_effect = [ 262 | _SIMPLE_MSG_NO_PARAMS_1_1, 263 | _SIMPLE_PARAM_INT_MSG_1_1, 264 | b"", 265 | ] 266 | await osc_tcp_server.AsyncOSCTCPServer.handle( 267 | self.server, self.mock_reader, self.mock_writer 268 | ) 269 | self.assertFalse(self.mock_meth.called) 270 | 271 | async def test_match_with_args(self): 272 | self.dispatcher.map("/SYNC", self.mock_meth, 1, 2, 3) 273 | self.mock_reader.read.side_effect = [_SIMPLE_PARAM_INT_MSG_1_1, b""] 274 | await osc_tcp_server.AsyncOSCTCPServer.handle( 275 | self.server, self.mock_reader, self.mock_writer 276 | ) 277 | self.mock_meth.assert_called_with("/SYNC", [1, 2, 3], 4) 278 | 279 | async def test_match_int9(self): 280 | self.dispatcher.map("/debug", self.mock_meth) 281 | self.mock_reader.read.side_effect = [_SIMPLE_PARAM_INT_9_1_1, b""] 282 | await osc_tcp_server.AsyncOSCTCPServer.handle( 283 | self.server, self.mock_reader, self.mock_writer 284 | ) 285 | self.assertTrue(self.mock_meth.called) 286 | self.mock_meth.assert_called_with("/debug", 9) 287 | 288 | async def test_match_without_args(self): 289 | self.dispatcher.map("/SYNC", self.mock_meth) 290 | self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] 291 | await osc_tcp_server.AsyncOSCTCPServer.handle( 292 | self.server, self.mock_reader, self.mock_writer 293 | ) 294 | self.mock_meth.assert_called_with("/SYNC") 295 | 296 | async def test_match_default_handler(self): 297 | self.dispatcher.set_default_handler(self.mock_meth) 298 | self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] 299 | await osc_tcp_server.AsyncOSCTCPServer.handle( 300 | self.server, self.mock_reader, self.mock_writer 301 | ) 302 | self.mock_meth.assert_called_with("/SYNC") 303 | 304 | async def test_response_no_args(self): 305 | def respond(*args, **kwargs): 306 | return "/SYNC" 307 | 308 | self.dispatcher.map("/SYNC", respond) 309 | self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] 310 | await osc_tcp_server.AsyncOSCTCPServer.handle( 311 | self.server, self.mock_reader, self.mock_writer 312 | ) 313 | self.mock_writer.write.assert_called_with(b"\xc0/SYNC\00\00\00,\00\00\00\xc0") 314 | 315 | async def test_response_with_args(self): 316 | def respond(*args, **kwargs): 317 | return ( 318 | "/SYNC", 319 | 1, 320 | "2", 321 | 3.0, 322 | ) 323 | 324 | self.dispatcher.map("/SYNC", respond) 325 | self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] 326 | await osc_tcp_server.AsyncOSCTCPServer.handle( 327 | self.server, self.mock_reader, self.mock_writer 328 | ) 329 | self.mock_writer.write.assert_called_with( 330 | b"\xc0/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00\xc0" 331 | ) 332 | 333 | async def test_async_response_with_args(self): 334 | async def respond(*args, **kwargs): 335 | return ( 336 | "/SYNC", 337 | 1, 338 | "2", 339 | 3.0, 340 | ) 341 | 342 | self.dispatcher.map("/SYNC", respond) 343 | self.mock_reader.read.side_effect = [_SIMPLE_MSG_NO_PARAMS_1_1, b""] 344 | await osc_tcp_server.AsyncOSCTCPServer.handle( 345 | self.server, self.mock_reader, self.mock_writer 346 | ) 347 | self.mock_writer.write.assert_called_with( 348 | b"\xc0/SYNC\00\00\00,isf\x00\x00\x00\x00\x00\x00\x00\x012\x00\x00\x00@@\x00\x00\xc0" 349 | ) 350 | 351 | 352 | if __name__ == "__main__": 353 | unittest.main() 354 | -------------------------------------------------------------------------------- /pythonosc/parsing/osc_types.py: -------------------------------------------------------------------------------- 1 | """Functions to get OSC types from datagrams and vice versa""" 2 | 3 | import struct 4 | 5 | from pythonosc.parsing import ntp 6 | from datetime import datetime, timedelta 7 | 8 | from typing import Union, Tuple, cast 9 | 10 | MidiPacket = Tuple[int, int, int, int] 11 | 12 | 13 | class ParseError(Exception): 14 | """Base exception for when a datagram parsing error occurs.""" 15 | 16 | 17 | class BuildError(Exception): 18 | """Base exception for when a datagram building error occurs.""" 19 | 20 | 21 | # Constant for special ntp datagram sequences that represent an immediate time. 22 | IMMEDIATELY = 0 23 | 24 | # Datagram length in bytes for types that have a fixed size. 25 | _INT_DGRAM_LEN = 4 26 | _INT64_DGRAM_LEN = 8 27 | _UINT64_DGRAM_LEN = 8 28 | _FLOAT_DGRAM_LEN = 4 29 | _DOUBLE_DGRAM_LEN = 8 30 | _TIMETAG_DGRAM_LEN = 8 31 | # Strings and blob dgram length is always a multiple of 4 bytes. 32 | _STRING_DGRAM_PAD = 4 33 | _BLOB_DGRAM_PAD = 4 34 | _EMPTY_STR_DGRAM = b"\x00\x00\x00\x00" 35 | 36 | 37 | def write_string(val: str) -> bytes: 38 | """Returns the OSC string equivalent of the given python string. 39 | 40 | Raises: 41 | - BuildError if the string could not be encoded. 42 | """ 43 | try: 44 | dgram = val.encode("utf-8") # Default, but better be explicit. 45 | except (UnicodeEncodeError, AttributeError) as e: 46 | raise BuildError(f"Incorrect string, could not encode {e}") 47 | diff = _STRING_DGRAM_PAD - (len(dgram) % _STRING_DGRAM_PAD) 48 | dgram += b"\x00" * diff 49 | return dgram 50 | 51 | 52 | def get_string(dgram: bytes, start_index: int) -> Tuple[str, int]: 53 | """Get a python string from the datagram, starting at pos start_index. 54 | 55 | According to the specifications, a string is: 56 | "A sequence of non-null ASCII characters followed by a null, 57 | followed by 0-3 additional null characters to make the total number 58 | of bits a multiple of 32". 59 | 60 | Args: 61 | dgram: A datagram packet. 62 | start_index: An index where the string starts in the datagram. 63 | 64 | Returns: 65 | A tuple containing the string and the new end index. 66 | 67 | Raises: 68 | ParseError if the datagram could not be parsed. 69 | """ 70 | if start_index < 0: 71 | raise ParseError("start_index < 0") 72 | offset = 0 73 | try: 74 | if ( 75 | len(dgram) > start_index + _STRING_DGRAM_PAD 76 | and dgram[start_index + _STRING_DGRAM_PAD] == _EMPTY_STR_DGRAM 77 | ): 78 | return "", start_index + _STRING_DGRAM_PAD 79 | while dgram[start_index + offset] != 0: 80 | offset += 1 81 | # Align to a byte word. 82 | if (offset) % _STRING_DGRAM_PAD == 0: 83 | offset += _STRING_DGRAM_PAD 84 | else: 85 | offset += -offset % _STRING_DGRAM_PAD 86 | # Python slices do not raise an IndexError past the last index, 87 | # do it ourselves. 88 | if offset > len(dgram[start_index:]): 89 | raise ParseError("Datagram is too short") 90 | data_str = dgram[start_index : start_index + offset] 91 | return data_str.replace(b"\x00", b"").decode("utf-8"), start_index + offset 92 | except IndexError as ie: 93 | raise ParseError(f"Could not parse datagram {ie}") 94 | except TypeError as te: 95 | raise ParseError(f"Could not parse datagram {te}") 96 | 97 | 98 | def write_int(val: int) -> bytes: 99 | """Returns the datagram for the given integer parameter value 100 | 101 | Raises: 102 | - BuildError if the int could not be converted. 103 | """ 104 | try: 105 | return struct.pack(">i", val) 106 | except struct.error as e: 107 | raise BuildError(f"Wrong argument value passed: {e}") 108 | 109 | 110 | def get_int(dgram: bytes, start_index: int) -> Tuple[int, int]: 111 | """Get a 32-bit big-endian two's complement integer from the datagram. 112 | 113 | Args: 114 | dgram: A datagram packet. 115 | start_index: An index where the integer starts in the datagram. 116 | 117 | Returns: 118 | A tuple containing the integer and the new end index. 119 | 120 | Raises: 121 | ParseError if the datagram could not be parsed. 122 | """ 123 | try: 124 | if len(dgram[start_index:]) < _INT_DGRAM_LEN: 125 | raise ParseError("Datagram is too short") 126 | return ( 127 | struct.unpack(">i", dgram[start_index : start_index + _INT_DGRAM_LEN])[0], 128 | start_index + _INT_DGRAM_LEN, 129 | ) 130 | except (struct.error, TypeError) as e: 131 | raise ParseError(f"Could not parse datagram {e}") 132 | 133 | 134 | def write_int64(val: int) -> bytes: 135 | """Returns the datagram for the given 64-bit big-endian signed parameter value 136 | 137 | Raises: 138 | - BuildError if the int64 could not be converted. 139 | """ 140 | try: 141 | return struct.pack(">q", val) 142 | except struct.error as e: 143 | raise BuildError(f"Wrong argument value passed: {e}") 144 | 145 | 146 | def get_int64(dgram: bytes, start_index: int) -> Tuple[int, int]: 147 | """Get a 64-bit big-endian signed integer from the datagram. 148 | 149 | Args: 150 | dgram: A datagram packet. 151 | start_index: An index where the 64-bit integer starts in the datagram. 152 | 153 | Returns: 154 | A tuple containing the 64-bit integer and the new end index. 155 | 156 | Raises: 157 | ParseError if the datagram could not be parsed. 158 | """ 159 | try: 160 | if len(dgram[start_index:]) < _INT64_DGRAM_LEN: 161 | raise ParseError("Datagram is too short") 162 | return ( 163 | struct.unpack(">q", dgram[start_index : start_index + _INT64_DGRAM_LEN])[0], 164 | start_index + _INT64_DGRAM_LEN, 165 | ) 166 | except (struct.error, TypeError) as e: 167 | raise ParseError(f"Could not parse datagram {e}") 168 | 169 | 170 | def get_uint64(dgram: bytes, start_index: int) -> Tuple[int, int]: 171 | """Get a 64-bit big-endian unsigned integer from the datagram. 172 | 173 | Args: 174 | dgram: A datagram packet. 175 | start_index: An index where the integer starts in the datagram. 176 | 177 | Returns: 178 | A tuple containing the integer and the new end index. 179 | 180 | Raises: 181 | ParseError if the datagram could not be parsed. 182 | """ 183 | try: 184 | if len(dgram[start_index:]) < _UINT64_DGRAM_LEN: 185 | raise ParseError("Datagram is too short") 186 | return ( 187 | struct.unpack(">Q", dgram[start_index : start_index + _UINT64_DGRAM_LEN])[ 188 | 0 189 | ], 190 | start_index + _UINT64_DGRAM_LEN, 191 | ) 192 | except (struct.error, TypeError) as e: 193 | raise ParseError(f"Could not parse datagram {e}") 194 | 195 | 196 | def get_timetag(dgram: bytes, start_index: int) -> Tuple[Tuple[datetime, int], int]: 197 | """Get a 64-bit OSC time tag from the datagram. 198 | 199 | Args: 200 | dgram: A datagram packet. 201 | start_index: An index where the osc time tag starts in the datagram. 202 | 203 | Returns: 204 | A tuple containing the tuple of time of sending in utc as datetime and the 205 | fraction of the current second and the new end index. 206 | 207 | Raises: 208 | ParseError if the datagram could not be parsed. 209 | """ 210 | try: 211 | if len(dgram[start_index:]) < _TIMETAG_DGRAM_LEN: 212 | raise ParseError("Datagram is too short") 213 | 214 | timetag, _ = get_uint64(dgram, start_index) 215 | seconds, fraction = ntp.parse_timestamp(timetag) 216 | 217 | hours, seconds = seconds // 3600, seconds % 3600 218 | minutes, seconds = seconds // 60, seconds % 60 219 | 220 | utc = datetime.combine(ntp._NTP_EPOCH, datetime.min.time()) + timedelta( 221 | hours=hours, minutes=minutes, seconds=seconds 222 | ) 223 | 224 | return (utc, fraction), start_index + _TIMETAG_DGRAM_LEN 225 | except (struct.error, TypeError) as e: 226 | raise ParseError(f"Could not parse datagram {e}") 227 | 228 | 229 | def write_float(val: float) -> bytes: 230 | """Returns the datagram for the given float parameter value 231 | 232 | Raises: 233 | - BuildError if the float could not be converted. 234 | """ 235 | try: 236 | return struct.pack(">f", val) 237 | except struct.error as e: 238 | raise BuildError(f"Wrong argument value passed: {e}") 239 | 240 | 241 | def get_float(dgram: bytes, start_index: int) -> Tuple[float, int]: 242 | """Get a 32-bit big-endian IEEE 754 floating point number from the datagram. 243 | 244 | Args: 245 | dgram: A datagram packet. 246 | start_index: An index where the float starts in the datagram. 247 | 248 | Returns: 249 | A tuple containing the float and the new end index. 250 | 251 | Raises: 252 | ParseError if the datagram could not be parsed. 253 | """ 254 | try: 255 | if len(dgram[start_index:]) < _FLOAT_DGRAM_LEN: 256 | # Noticed that Reaktor doesn't send the last bunch of \x00 needed to make 257 | # the float representation complete in some cases, thus we pad here to 258 | # account for that. 259 | dgram = dgram + b"\x00" * (_FLOAT_DGRAM_LEN - len(dgram[start_index:])) 260 | return ( 261 | struct.unpack(">f", dgram[start_index : start_index + _FLOAT_DGRAM_LEN])[0], 262 | start_index + _FLOAT_DGRAM_LEN, 263 | ) 264 | except (struct.error, TypeError) as e: 265 | raise ParseError(f"Could not parse datagram {e}") 266 | 267 | 268 | def write_double(val: float) -> bytes: 269 | """Returns the datagram for the given double parameter value 270 | 271 | Raises: 272 | - BuildError if the double could not be converted. 273 | """ 274 | try: 275 | return struct.pack(">d", val) 276 | except struct.error as e: 277 | raise BuildError(f"Wrong argument value passed: {e}") 278 | 279 | 280 | def get_double(dgram: bytes, start_index: int) -> Tuple[float, int]: 281 | """Get a 64-bit big-endian IEEE 754 floating point number from the datagram. 282 | 283 | Args: 284 | dgram: A datagram packet. 285 | start_index: An index where the double starts in the datagram. 286 | 287 | Returns: 288 | A tuple containing the double and the new end index. 289 | 290 | Raises: 291 | ParseError if the datagram could not be parsed. 292 | """ 293 | try: 294 | if len(dgram[start_index:]) < _DOUBLE_DGRAM_LEN: 295 | raise ParseError("Datagram is too short") 296 | return ( 297 | struct.unpack(">d", dgram[start_index : start_index + _DOUBLE_DGRAM_LEN])[ 298 | 0 299 | ], 300 | start_index + _DOUBLE_DGRAM_LEN, 301 | ) 302 | except (struct.error, TypeError) as e: 303 | raise ParseError(f"Could not parse datagram {e}") 304 | 305 | 306 | def get_blob(dgram: bytes, start_index: int) -> Tuple[bytes, int]: 307 | """Get a blob from the datagram. 308 | 309 | According to the specifications, a blob is made of 310 | "an int32 size count, followed by that many 8-bit bytes of arbitrary 311 | binary data, followed by 0-3 additional zero bytes to make the total 312 | number of bits a multiple of 32". 313 | 314 | Args: 315 | dgram: A datagram packet. 316 | start_index: An index where the float starts in the datagram. 317 | 318 | Returns: 319 | A tuple containing the blob and the new end index. 320 | 321 | Raises: 322 | ParseError if the datagram could not be parsed. 323 | """ 324 | size, int_offset = get_int(dgram, start_index) 325 | # Make the size a multiple of 32 bits. 326 | total_size = size + (-size % _BLOB_DGRAM_PAD) 327 | end_index = int_offset + size 328 | if end_index - start_index > len(dgram[start_index:]): 329 | raise ParseError("Datagram is too short.") 330 | return dgram[int_offset : int_offset + size], int_offset + total_size 331 | 332 | 333 | def write_blob(val: bytes) -> bytes: 334 | """Returns the datagram for the given blob parameter value. 335 | 336 | Raises: 337 | - BuildError if the value was empty or if its size didn't fit an OSC int. 338 | """ 339 | if not val: 340 | raise BuildError("Blob value cannot be empty") 341 | dgram = write_int(len(val)) 342 | dgram += val 343 | while len(dgram) % _BLOB_DGRAM_PAD != 0: 344 | dgram += b"\x00" 345 | return dgram 346 | 347 | 348 | def get_date(dgram: bytes, start_index: int) -> Tuple[float, int]: 349 | """Get a 64-bit big-endian fixed-point time tag as a date from the datagram. 350 | 351 | According to the specifications, a date is represented as is: 352 | "the first 32 bits specify the number of seconds since midnight on 353 | January 1, 1900, and the last 32 bits specify fractional parts of a second 354 | to a precision of about 200 picoseconds". 355 | 356 | Args: 357 | dgram: A datagram packet. 358 | start_index: An index where the date starts in the datagram. 359 | 360 | Returns: 361 | A tuple containing the system date and the new end index. 362 | returns osc_immediately (0) if the corresponding OSC sequence was found. 363 | 364 | Raises: 365 | ParseError if the datagram could not be parsed. 366 | """ 367 | # Check for the special case first. 368 | if dgram[start_index : start_index + _TIMETAG_DGRAM_LEN] == ntp.IMMEDIATELY: 369 | return IMMEDIATELY, start_index + _TIMETAG_DGRAM_LEN 370 | if len(dgram[start_index:]) < _TIMETAG_DGRAM_LEN: 371 | raise ParseError("Datagram is too short") 372 | timetag, start_index = get_uint64(dgram, start_index) 373 | seconds = timetag * ntp._NTP_TIMESTAMP_TO_SECONDS 374 | return ntp.ntp_time_to_system_epoch(seconds), start_index 375 | 376 | 377 | def write_date(system_time: Union[int, float]) -> bytes: 378 | if system_time == IMMEDIATELY: 379 | return ntp.IMMEDIATELY 380 | 381 | try: 382 | return ntp.system_time_to_ntp(system_time) 383 | except ntp.NtpError as ntpe: 384 | raise BuildError(ntpe) 385 | 386 | 387 | def write_rgba(val: bytes) -> bytes: 388 | """Returns the datagram for the given rgba32 parameter value 389 | 390 | Raises: 391 | - BuildError if the int could not be converted. 392 | """ 393 | try: 394 | return struct.pack(">I", val) 395 | except struct.error as e: 396 | raise BuildError(f"Wrong argument value passed: {e}") 397 | 398 | 399 | def get_rgba(dgram: bytes, start_index: int) -> Tuple[bytes, int]: 400 | """Get an rgba32 integer from the datagram. 401 | 402 | Args: 403 | dgram: A datagram packet. 404 | start_index: An index where the integer starts in the datagram. 405 | 406 | Returns: 407 | A tuple containing the integer and the new end index. 408 | 409 | Raises: 410 | ParseError if the datagram could not be parsed. 411 | """ 412 | try: 413 | if len(dgram[start_index:]) < _INT_DGRAM_LEN: 414 | raise ParseError("Datagram is too short") 415 | return ( 416 | struct.unpack(">I", dgram[start_index : start_index + _INT_DGRAM_LEN])[0], 417 | start_index + _INT_DGRAM_LEN, 418 | ) 419 | except (struct.error, TypeError) as e: 420 | raise ParseError(f"Could not parse datagram {e}") 421 | 422 | 423 | def write_midi(val: MidiPacket) -> bytes: 424 | """Returns the datagram for the given MIDI message parameter value 425 | 426 | A valid MIDI message: (port id, status byte, data1, data2). 427 | 428 | Raises: 429 | - BuildError if the MIDI message could not be converted. 430 | 431 | """ 432 | if len(val) != 4: 433 | raise BuildError("MIDI message length is invalid") 434 | try: 435 | value = sum((value & 0xFF) << 8 * (3 - pos) for pos, value in enumerate(val)) 436 | return struct.pack(">I", value) 437 | except struct.error as e: 438 | raise BuildError(f"Wrong argument value passed: {e}") 439 | 440 | 441 | def get_midi(dgram: bytes, start_index: int) -> Tuple[MidiPacket, int]: 442 | """Get a MIDI message (port id, status byte, data1, data2) from the datagram. 443 | 444 | Args: 445 | dgram: A datagram packet. 446 | start_index: An index where the MIDI message starts in the datagram. 447 | 448 | Returns: 449 | A tuple containing the MIDI message and the new end index. 450 | 451 | Raises: 452 | ParseError if the datagram could not be parsed. 453 | """ 454 | try: 455 | if len(dgram[start_index:]) < _INT_DGRAM_LEN: 456 | raise ParseError("Datagram is too short") 457 | val = struct.unpack(">I", dgram[start_index : start_index + _INT_DGRAM_LEN])[0] 458 | midi_msg = cast( 459 | MidiPacket, tuple((val & 0xFF << 8 * i) >> 8 * i for i in range(3, -1, -1)) 460 | ) 461 | return (midi_msg, start_index + _INT_DGRAM_LEN) 462 | except (struct.error, TypeError) as e: 463 | raise ParseError(f"Could not parse datagram {e}") 464 | --------------------------------------------------------------------------------