diff --git a/vban_cmd/error.py b/vban_cmd/error.py index 578a911..fca8ed2 100644 --- a/vban_cmd/error.py +++ b/vban_cmd/error.py @@ -1,6 +1,18 @@ +from .packet.enums import ServiceTypes, SubProtocols + + class VBANCMDError(Exception): """Base VBANCMD Exception class.""" class VBANCMDConnectionError(VBANCMDError): """Exception raised when connection/timeout errors occur""" + + +class VBANCMDPacketError(VBANCMDError): + """Exception raised when packet parsing errors occur""" + + def __init__(self, message: str, protocol: SubProtocols, type_: ServiceTypes): + super().__init__(message) + self.protocol = protocol + self.type = type_ diff --git a/vban_cmd/packet/headers.py b/vban_cmd/packet/headers.py index 692edfb..139971b 100644 --- a/vban_cmd/packet/headers.py +++ b/vban_cmd/packet/headers.py @@ -2,6 +2,7 @@ import struct from dataclasses import dataclass from vban_cmd.enums import NBS +from vban_cmd.error import VBANCMDPacketError from vban_cmd.kinds import KindMapClass from .enums import ServiceTypes, StreamTypes, SubProtocols @@ -10,6 +11,15 @@ PINGPONG_PACKET_SIZE = 704 # Size of the PING/PONG header + payload in bytes MAX_PACKET_SIZE = 1436 HEADER_SIZE = 4 + 1 + 1 + 1 + 1 + 16 +STREAMNAME_MAX_LENGTH = 16 +# fmt: off +BPS_OPTS = [ + 0, 110, 150, 300, 600, 1200, 2400, 4800, 9600, 14400, 19200, 31250, + 38400, 57600, 115200, 128000, 230400, 250000, 256000, 460800, 921600, + 1000000, 1500000, 2000000, 3000000, +] +# fmt: on + @dataclass class VbanPingHeader: @@ -28,7 +38,9 @@ class VbanPingHeader: @property def streamname(self) -> bytes: - return self.name.encode('ascii')[:16].ljust(16, b'\x00') + return self.name.encode('ascii')[:STREAMNAME_MAX_LENGTH].ljust( + STREAMNAME_MAX_LENGTH, b'\x00' + ) @classmethod def to_bytes(cls, framecounter: int = 0) -> bytes: @@ -64,7 +76,9 @@ class VbanPongHeader: @property def streamname(self) -> bytes: - return self.name.encode('ascii')[:16].ljust(16, b'\x00') + return self.name.encode('ascii')[:STREAMNAME_MAX_LENGTH].ljust( + STREAMNAME_MAX_LENGTH, b'\x00' + ) @classmethod def from_bytes(cls, data: bytes): @@ -74,7 +88,11 @@ class VbanPongHeader: # PONG responses use the same service type as PING (0x00) # and are identified by having payload data if parsed['format_nbc'] != ServiceTypes.PONG.value: - raise ValueError(f'Not a PONG response packet: {parsed["format_nbc"]:02x}') + raise VBANCMDPacketError( + f'Not a PONG response packet: {parsed["format_nbc"]:02x}', + protocol=SubProtocols(parsed['format_sr'] & SubProtocols.MASK.value), + type_=ServiceTypes(parsed['format_nbc']), + ) return cls(**parsed) @@ -133,7 +151,7 @@ class VbanRTPacket: class VbanRTSubscribeHeader: """Represents the header of an RT subscription packet""" - nbs: NBS = NBS.zero + _nbs: NBS = NBS.zero name: str = 'Register-RTP' timeout: int = 15 @@ -142,36 +160,38 @@ class VbanRTSubscribeHeader: return b'VBAN' @property - def format_sr(self) -> bytes: - return SubProtocols.SERVICE.value.to_bytes(1, 'little') + def sr(self) -> int: + return SubProtocols.SERVICE.value @property - def format_nbs(self) -> bytes: - return (self.nbs.value & 0xFF).to_bytes(1, 'little') + def nbs(self) -> int: + return self._nbs.value & 0xFF @property - def format_nbc(self) -> bytes: - return ServiceTypes.RTPACKETREGISTER.value.to_bytes(1, 'little') + def nbc(self) -> int: + return ServiceTypes.RTPACKETREGISTER.value @property - def format_bit(self) -> bytes: - return (self.timeout & 0xFF).to_bytes(1, 'little') + def bit(self) -> int: + return self.timeout & 0xFF @property def streamname(self) -> bytes: - return self.name.encode('ascii') + bytes(16 - len(self.name)) + return self.name.encode()[:STREAMNAME_MAX_LENGTH].ljust( + STREAMNAME_MAX_LENGTH, b'\x00' + ) @classmethod def to_bytes(cls, nbs: NBS, framecounter: int) -> bytes: - header = cls(nbs=nbs) + header = cls(_nbs=nbs) return struct.pack( '<4s4B16sI', header.vban, - header.format_sr[0], - header.format_nbs[0], - header.format_nbc[0], - header.format_bit[0], + header.sr, + header.nbs, + header.nbc, + header.bit, header.streamname, framecounter, ) @@ -182,59 +202,64 @@ class VbanRTRequestHeader: """Represents the header of an RT request packet""" name: str - bps_index: int + bps: int channel: int framecounter: int = 0 + def __post_init__(self): + if self.bps not in BPS_OPTS: + raise ValueError( + f'Invalid bps value: {self.bps}. Must be one of {BPS_OPTS}' + ) + self.bps_index = BPS_OPTS.index(self.bps) + @property def vban(self) -> bytes: return b'VBAN' @property - def sr(self) -> bytes: - return (self.bps_index | SubProtocols.TEXT.value).to_bytes(1, 'little') + def sr(self) -> int: + return self.bps_index | SubProtocols.TEXT.value @property - def nbs(self) -> bytes: - return (0).to_bytes(1, 'little') + def nbs(self) -> int: + return 0 @property - def nbc(self) -> bytes: - return (self.channel).to_bytes(1, 'little') + def nbc(self) -> int: + return self.channel @property - def bit(self) -> bytes: - return (StreamTypes.UTF8.value).to_bytes(1, 'little') + def bit(self) -> int: + return StreamTypes.UTF8.value @property def streamname(self) -> bytes: - return self.name.encode()[:16].ljust(16, b'\x00') + return self.name.encode()[:STREAMNAME_MAX_LENGTH].ljust( + STREAMNAME_MAX_LENGTH, b'\x00' + ) @classmethod - def to_bytes( - cls, name: str, bps_index: int, channel: int, framecounter: int - ) -> bytes: - header = cls( - name=name, bps_index=bps_index, channel=channel, framecounter=framecounter - ) + def to_bytes(cls, name: str, bps: int, channel: int, framecounter: int) -> bytes: + header = cls(name=name, bps=bps, channel=channel, framecounter=framecounter) return struct.pack( '<4s4B16sI', header.vban, - header.sr[0], - header.nbs[0], - header.nbc[0], - header.bit[0], + header.sr, + header.nbs, + header.nbc, + header.bit, header.streamname, header.framecounter, ) @classmethod def encode_with_payload( - cls, name: str, bps_index: int, channel: int, framecounter: int, payload: str + cls, name: str, bps: int, channel: int, framecounter: int, payload: str ) -> bytes: """Creates the complete packet with header and payload.""" - return cls.to_bytes(name, bps_index, channel, framecounter) + payload.encode() + return cls.to_bytes(name, bps, channel, framecounter) + payload.encode() def _parse_vban_service_header(data: bytes) -> dict: @@ -253,7 +278,10 @@ def _parse_vban_service_header(data: bytes) -> dict: # Verify this is a service protocol packet protocol = format_sr & SubProtocols.MASK.value if protocol != SubProtocols.SERVICE.value: - raise ValueError(f'Not a service protocol packet: {protocol:02x}') + raise VBANCMDPacketError( + f'Invalid protocol in service header: {protocol:02x}', + protocol=SubProtocols(protocol), + ) # Extract stream name and frame counter name = data[8:24].rstrip(b'\x00').decode('utf-8', errors='ignore') @@ -286,7 +314,9 @@ class VbanRTResponseHeader: @property def streamname(self) -> bytes: - return self.name.encode('ascii') + bytes(16 - len(self.name)) + return self.name.encode()[:STREAMNAME_MAX_LENGTH].ljust( + STREAMNAME_MAX_LENGTH, b'\x00' + ) @classmethod def from_bytes(cls, data: bytes): @@ -295,8 +325,10 @@ class VbanRTResponseHeader: # Validate this is an RTPacket response if parsed['format_nbc'] != ServiceTypes.RTPACKET.value: - raise ValueError( - f'Not an RTPacket response packet: {parsed["format_nbc"]:02x}' + raise VBANCMDPacketError( + f'Not an RTPacket response packet: {parsed["format_nbc"]:02x}', + protocol=SubProtocols(parsed['format_sr'] & SubProtocols.MASK.value), + type_=ServiceTypes(parsed['format_nbc']), ) return cls(**parsed) @@ -319,16 +351,29 @@ class VbanMatrixResponseHeader: @property def streamname(self) -> bytes: - return self.name.encode('ascii')[:16].ljust(16, b'\x00') + return self.name.encode()[:STREAMNAME_MAX_LENGTH].ljust( + STREAMNAME_MAX_LENGTH, b'\x00' + ) @classmethod def from_bytes(cls, data: bytes): """Parse a matrix response packet from bytes.""" parsed = _parse_vban_service_header(data) - # Validate this is a service reply packet + # Validate this is a service reply packet (dual encoding scheme) if parsed['format_nbs'] != ServiceTypes.FNCT_REPLY.value: - raise ValueError(f'Not a service reply packet: {parsed["format_nbs"]:02x}') + raise VBANCMDPacketError( + f'Not a service reply packet: {parsed["format_nbs"]:02x}', + protocol=SubProtocols(parsed['format_sr'] & SubProtocols.MASK.value), + type_=ServiceTypes(parsed['format_nbs']), + ) + + if parsed['format_nbc'] != ServiceTypes.REQUESTREPLY.value: + raise VBANCMDPacketError( + f'Not a request reply packet: {parsed["format_nbc"]:02x}', + protocol=SubProtocols(parsed['format_sr'] & SubProtocols.MASK.value), + type_=ServiceTypes(parsed['format_nbc']), + ) return cls(**parsed) diff --git a/vban_cmd/vbancmd.py b/vban_cmd/vbancmd.py index acd9191..d63220e 100644 --- a/vban_cmd/vbancmd.py +++ b/vban_cmd/vbancmd.py @@ -17,7 +17,7 @@ from .packet.headers import ( ) from .packet.ping0 import VbanPing0Payload, VbanServerType from .subject import Subject -from .util import bump_framecounter, deep_merge, pong_timeout, ratelimit +from .util import bump_framecounter, deep_merge, pong_timeout, script_ratelimit from .worker import Producer, Subscriber, Updater logger = logging.getLogger(__name__) @@ -27,13 +27,6 @@ class VbanCmd(abc.ABC): """Abstract Base Class for Voicemeeter VBAN Command Interfaces""" DELAY = 0.001 - # fmt: off - BPS_OPTS = [ - 0, 110, 150, 300, 600, 1200, 2400, 4800, 9600, 14400, 19200, 31250, - 38400, 57600, 115200, 128000, 230400, 250000, 256000, 460800, 921600, - 1000000, 1500000, 2000000, 3000000, - ] - # fmt: on def __init__(self, **kwargs): self.logger = logger.getChild(self.__class__.__name__) @@ -203,7 +196,7 @@ class VbanCmd(abc.ABC): self.sock.sendto( VbanRTRequestHeader.encode_with_payload( name=self.streamname, - bps_index=self.BPS_OPTS.index(self.bps), + bps=self.bps, channel=self.channel, framecounter=self._get_next_framecounter(), payload=payload, @@ -216,7 +209,7 @@ class VbanCmd(abc.ABC): self._send_request(f'{cmd}={val};') self.cache[cmd] = val - @ratelimit + @script_ratelimit def sendtext(self, script) -> str | None: """Sends a multiple parameter string over a network.""" self._send_request(script) diff --git a/vban_cmd/worker.py b/vban_cmd/worker.py index 6836e8e..1edbc3f 100644 --- a/vban_cmd/worker.py +++ b/vban_cmd/worker.py @@ -3,7 +3,8 @@ import threading import time from .enums import NBS -from .error import VBANCMDConnectionError +from .error import VBANCMDConnectionError, VBANCMDPacketError +from .packet.enums import SubProtocols from .packet.headers import ( HEADER_SIZE, VbanRTPacket, @@ -81,8 +82,13 @@ class Producer(threading.Thread): try: header = VbanRTResponseHeader.from_bytes(data[:HEADER_SIZE]) - except ValueError as e: - self.logger.debug(f'Error parsing response packet: {e}') + except VBANCMDPacketError as e: + match e.protocol: + case SubProtocols.SERVICE: + # Silently ignore periodic SERVICE packets unrelated to vban-cmd + pass + case _: + self.logger.debug(f'Error parsing response packet: {e}') continue match header.format_nbs: