From a04988ab83df89276cde6e7945523c6284ff7e2c Mon Sep 17 00:00:00 2001 From: nikita WSL Date: Thu, 6 Nov 2025 18:10:31 +0300 Subject: [PATCH] Initial commit --- .gitignore | 2 + client.py | 114 +++++++++++++++++++++++++++++++++++++++++++++++++++++ parser.py | 98 +++++++++++++++++++++++++++++++++++++++++++++ server.py | 83 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 297 insertions(+) create mode 100644 .gitignore create mode 100644 client.py create mode 100644 parser.py create mode 100644 server.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..182ed0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +*.swp \ No newline at end of file diff --git a/client.py b/client.py new file mode 100644 index 0000000..766f5a5 --- /dev/null +++ b/client.py @@ -0,0 +1,114 @@ +''' Module that implements OSC ''' + +import asyncio +import struct + +_TASK = None +_Q = asyncio.Queue() + +def __string_to_osc(s: str) -> bytes: + ''' Convert python string to OSC-string ''' + data = s.encode('ascii') + to_append = 4 - (len(data) % 4) + data += b'\0' * to_append + return data + +async def __serve() -> None: + ''' Task that manages OSC sending ''' + # log + print('[I] OSC: task is running') + # task loop + while True: + # get value from queue + val = await _Q.get() + # stop + if val == 0: + break + # host, port, OSC address and data + host = val['host'] + port = val['port'] + addr = val['addr'] + data = val['data'] + # OSC type tag string + type_tag = ',' + # OSC data to append to the final packet + osc_data = bytes() + # check if data is correct + for d in data: + d_type = type(d) + # process the value + if d_type is int: + type_tag += 'i' + osc_data += struct.pack('>i', d) + elif d_type is str: + type_tag += 's' + osc_data += __string_to_osc(d) + # unsupported + else: + print('[!] OSC: unsupported data type was provided! The packet is discarded.') + print(' * host: %s' % host) + print(' * port: %s' % port) + print(' * address: %s' % addr) + print(' * data: %s' % data) + print(' (bad type is %s)' % d_type) + type_tag = None + break + # no type tag + if type_tag is None: + continue + # convert type tag to OSC-string + type_tag = __string_to_osc(type_tag) + # create OSC packet + packet = __string_to_osc(addr) + type_tag + osc_data + + # send the packet + trans, prot = await asyncio.get_running_loop().create_datagram_endpoint( + asyncio.DatagramProtocol, + remote_addr=(host, port) + ) + try: + trans.sendto(packet) + print('[I] OSC: sent data to %s:%s' % (host, port)) + except: + print('[!] OSC: failed to send to %s:%s!' % (host, port)) + print(' * address: %s' % addr) + print(' * data: %s' % data) + finally: + trans.close() + + # log + print('[I] OSC: task is stopped') + +async def start() -> None: + ''' Starts OSC task ''' + global _TASK + # already started + if _TASK is not None: + return + _TASK = asyncio.create_task(__serve()) + +async def stop() -> None: + ''' Stops OSC task ''' + global _TASK + # not started + if _TASK is None: + return + # stop + await _Q.put(0) + await _TASK + # reset + _TASK = None + +async def send(host: str, port: str, addr: str, values: list) -> None: + ''' Schedule packet transmission ''' + # not started + if _TASK is None: + return + # create + d = { + 'host': host, + 'port': port, + 'addr': addr, + 'data': values + } + await _Q.put(d) diff --git a/parser.py b/parser.py new file mode 100644 index 0000000..0369a37 --- /dev/null +++ b/parser.py @@ -0,0 +1,98 @@ +''' Parse OSC packet into dict ''' + +# set True to enable log messages +DEBUG = True + +import struct +import traceback + +def _parse_str(body: bytearray) -> (str, int): + ''' Parse OSC string ''' + try: + result = '' + for b in body: + if b == 0: + break + result += chr(b) + else: + return None + l = ((len(result) // 4) + 1) * 4 + if l > len(body): + return None + return (result, l) + except: + if DEBUG: traceback.print_exc() + return None + +def _parse_int(body: bytearray) -> (int, int): + ''' Parse OSC int ''' + try: + return (struct.unpack('>i', body[0:4])[0], 4) + except: + if DEBUG: traceback.print_exc() + return None + +def _parse_float(body: bytearray) -> (float, int): + ''' Parse OSC float ''' + try: + return (struct.unpack('>f', body[0:4])[0], 4) + except: + if DEBUG: traceback.print_exc() + return None + +def parse(body: bytearray) -> dict | None: + ''' Parse OSC datagram into dict. ''' + # too short + if len(body) < 8: + if DEBUG: print('[OSC PARSER] Body is shorter than 8 bytes') + return None + # does not start with slash + if body[0] != ord('/'): + if DEBUG: print('[OSC PARSER] First byte of packet is not forward slash') + return None + # get the address + addr = _parse_str(body) + if not addr: + if DEBUG: print('[OSC PARSER] Failed to parse address (OSC-string)') + return None + # edit body + body = body[addr[1]:] + # get the type tag + type_tag = _parse_str(body) + if not type_tag: + if DEBUG: print('[OSC PARSER] Failed to parse type tag (OSC-string)') + return None + # edit body + body = body[type_tag[1]:] + # type is invalid + if type_tag[0][0] != ',': + if DEBUG: print('[OSC PARSER] The first symbol of type tag must be comma') + return None + # arguments + args = [] + # type parsers + type_parsers = { + 'i': _parse_int, + 'f': _parse_float, + 's': _parse_str + } + # parse the arguments + for arg_type in type_tag[0][1:]: + # parse body if parser exists + if arg_type not in type_parsers: + if DEBUG: print('[OSC PARSER] Argument of type \'%s\' can\'t be parsed by this library' % arg_type) + return None + parse_result = type_parsers[arg_type](body) + # failed to parse + if parse_result is None: + if DEBUG: print('[OSC PARSER] Failed to parse an argument of type \'%s\'' % arg_type) + return None + # success + args.append(parse_result[0]) + body = body[parse_result[1]:] + # result + return { + 'addr': addr[0], + 'type_tag': type_tag[0], + 'args': args + } \ No newline at end of file diff --git a/server.py b/server.py new file mode 100644 index 0000000..661b641 --- /dev/null +++ b/server.py @@ -0,0 +1,83 @@ +import asyncio +import traceback +import parser +import json + +class __OSCServer(asyncio.DatagramProtocol): + ''' OSC server ''' + def __init__(self, callback, event_loop): + super().__init__() + self.callback = callback + self.loop = event_loop + self.is_cb_async = asyncio.iscoroutinefunction(self.callback) + + def datagram_received(self, data, addr): + try: + # try to parse + osc_data = parser.parse(data) + # failed to parse + if osc_data is None: + return + # no callback - debug + if self.callback is None: + print(osc_data) + return + # callback + if self.is_cb_async: + if self.loop is not None: + self.loop.create_task(self.callback(osc_data, addr)) + else: + self.callback(osc_data, addr) + except: + traceback.print_exc() + pass + +__transport, __protocol = None, None + +async def start(host: str, port: int, callback) -> bool: + global __transport, __protocol + try: + # exists + if __transport is not None: + return False + # start + loop = asyncio.get_running_loop() + __transport, __protocol = await loop.create_datagram_endpoint( + lambda: __OSCServer(callback, loop), + local_addr=(host, port) + ) + # success + return True + except: + traceback.print_exc() + # serious failure + return False + +async def stop() -> None: + global __transport, __protocol + # does not exist + if __transport is None: + return + # close + __transport.close() + __transport, __protocol = None, None + +# test +async def __test_cb(data, addr): + print('Received packet from %s:%s:\n%s' % (addr[0], addr[1], json.dumps(data, indent=4))) + +async def __main__() -> None: + print('Starting OSC server on 0.0.0.0:23654') + if not await start('0.0.0.0', '23654', __test_cb): + print('Failed') + return + print('Started') + print('The server will print packet content on reception.') + print('GOING TO WORK FOREVER') + await asyncio.Future() + await stop() + print('Stopped') + + +if __name__ == '__main__': + asyncio.run(__main__()) \ No newline at end of file