diff --git a/flapi/client/client.py b/flapi/client/client.py deleted file mode 100644 index f54ebad..0000000 --- a/flapi/client/client.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -# Flapi / Client / Client - -Class using the Flapi base client to implement a Pythonic system for -communicating with the Flapi server. -""" -from typing import Any, Optional -from base64 import b64decode, b64encode -import pickle - -from flapi import _consts as consts -from flapi._consts import MessageStatus -from flapi.client.base_client import FlapiBaseClient -from flapi.errors import FlapiServerError - - -class FlapiClient: - """ - Implementation that wraps around the base Flapi client to implement - additional features. - """ - def __init__( - self, - req_port: str = consts.DEFAULT_REQ_PORT, - res_port: str = consts.DEFAULT_RES_PORT, - ) -> None: - self.__client = FlapiBaseClient().open(req_port, res_port).hello() - - def pickle_eval( - client_id: int, - status_code: int, - msg_data: Optional[bytes], - scope: dict[str, Any], - ) -> tuple[int, bytes]: - """ - Implementation of an eval message type using `pickle` to encode - response data. - """ - import pickle # noqa: F811 - from base64 import b64decode, b64encode # noqa: F811 - - assert msg_data is not None - source = b64decode(msg_data).decode() - - try: - result = eval(source, globals(), scope) - except Exception as e: - return (1, b64encode(pickle.dumps(e))) - - return (0, b64encode(pickle.dumps(result))) - - self.__pickle_eval = self.__client.register_message_type(pickle_eval) - - def exec(self, code: str) -> None: - """ - Execute the given code on the Flapi server - - Args: - code (str): code to execute - """ - self.__client.exec(code) - - def eval(self, code: str) -> Any: - """ - Evaluate the given code on the Flapi server - - Args: - code (str): code to execute - """ - result = self.__pickle_eval(b64encode(code.encode())) - if result.status_code == MessageStatus.ERR: - # An error occurred while executing the code, raise it as an - # exception after decoding it. - raise pickle.loads(b64decode(result.additional_data)) - elif result.status_code == MessageStatus.OK: - return pickle.loads(b64decode(result.additional_data)) - else: - raise FlapiServerError(b64decode(result.additional_data).decode()) diff --git a/flapi/server/flapi_response.py b/flapi/server/flapi_response.py deleted file mode 100644 index 9e7a8b0..0000000 --- a/flapi/server/flapi_response.py +++ /dev/null @@ -1,212 +0,0 @@ -""" -# Flapi / Server / Flapi Response - -Class representing a MIDI message response in the format used by Flapi. -""" -import device -import pickle -import sys -from typing import Any, Literal, overload, Self -from base64 import b64encode, b64decode -from consts import SYSEX_HEADER, MessageOrigin, MessageType, MessageStatus - - -def send_sysex(msg: bytes): - """ - Helper for sending sysex, with some debugging print statements, since this - seems to cause FL Studio to crash a lot of the time, and I want to find out - why. - """ - # capout.fl_print(f"MSG OUT -- {bytes_to_str(msg)}") - if device.dispatchReceiverCount() == 0: - print("ERROR: No response device found", file=sys.stderr) - for i in range(device.dispatchReceiverCount()): - device.dispatch(i, 0xF0, msg) - # capout.fl_print("MSG OUT SUCCESS") - - -def decode_python_object(data: bytes) -> Any: - """ - Encode Python object to send to the client - """ - return pickle.loads(b64decode(data)) - - -def encode_python_object(object: Any) -> bytes: - """ - Encode Python object to send to the client - """ - return b64encode(pickle.dumps(object)) - - -class FlapiResponse: - """ - Represents a MIDI message sent by Flapi. This class is used to build - responses to requests. - """ - - def __init__(self, client_id: int) -> None: - """ - Create a FlapiResponse - """ - self.client_id = client_id - self.__messages: list[bytes] = [] - - def send(self) -> None: - """ - Send the required messages - """ - for msg in self.__messages: - send_sysex(msg) - self.__messages.clear() - - def fail(self, type: MessageType, info: str) -> Self: - self.__messages.append( - bytes([0xF0]) - + SYSEX_HEADER - + bytes([MessageOrigin.INTERNAL]) - + bytes([self.client_id]) - + bytes([type]) - + bytes([MessageStatus.FAIL]) - + b64encode(info.encode()) - + bytes([0xF7]) - ) - return self - - def client_hello(self) -> Self: - self.__messages.append( - bytes([0xF0]) - + SYSEX_HEADER - + bytes([MessageOrigin.INTERNAL]) - + bytes([self.client_id]) - + bytes([MessageType.CLIENT_HELLO]) - + bytes([MessageStatus.OK]) - + bytes([0xF7]) - ) - return self - - def client_goodbye(self, exit_code: int) -> Self: - self.__messages.append( - bytes([0xF0]) - + SYSEX_HEADER - + bytes([MessageOrigin.INTERNAL]) - + bytes([self.client_id]) - + bytes([MessageType.CLIENT_GOODBYE]) - + bytes([MessageStatus.OK]) - + b64encode(str(exit_code).encode()) - + bytes([0xF7]) - ) - return self - - # Server goodbye is handled externally in `device_flapi_respond.py` - - def version_query(self, version_info: tuple[int, int, int]) -> Self: - self.__messages.append( - bytes([0xF0]) - + SYSEX_HEADER - + bytes([MessageOrigin.INTERNAL]) - + bytes([self.client_id]) - + bytes([MessageType.VERSION_QUERY]) - + bytes([MessageStatus.OK]) - + bytes(version_info) - + bytes([0xF7]) - ) - return self - - @overload - def exec(self, status: Literal[MessageStatus.OK]) -> Self: - ... - - @overload - def exec( - self, - status: Literal[MessageStatus.ERR], - exc_info: Exception, - ) -> Self: - ... - ... - - @overload - def exec( - self, - status: Literal[MessageStatus.FAIL], - exc_info: str, - ) -> Self: - ... - - def exec( - self, - status: MessageStatus, - exc_info: Exception | str | None = None, - ) -> Self: - if status != MessageStatus.OK: - response_data = encode_python_object(exc_info) - else: - response_data = bytes() - - self.__messages.append( - bytes([0xF0]) - + SYSEX_HEADER - + bytes([MessageOrigin.INTERNAL]) - + bytes([self.client_id]) - + bytes([MessageType.EXEC]) - + bytes([status]) - + response_data - + bytes([0xF7]) - ) - return self - - @overload - def eval( - self, - status: Literal[MessageStatus.OK], - data: Any, - ) -> Self: - ... - - @overload - def eval( - self, - status: Literal[MessageStatus.ERR], - data: Exception, - ) -> Self: - ... - ... - - @overload - def eval( - self, - status: Literal[MessageStatus.FAIL], - data: str, - ) -> Self: - ... - - def eval( - self, - status: MessageStatus, - data: Exception | str | Any, - ) -> Self: - self.__messages.append( - bytes([0xF0]) - + SYSEX_HEADER - + bytes([MessageOrigin.INTERNAL]) - + bytes([self.client_id]) - + bytes([MessageType.EVAL]) - + bytes([status]) - + encode_python_object(data) - + bytes([0xF7]) - ) - return self - - def stdout(self, content: str) -> Self: - self.__messages.append( - bytes([0xF0]) - + SYSEX_HEADER - + bytes([MessageOrigin.INTERNAL]) - + bytes([self.client_id]) - + bytes([MessageType.STDOUT]) - + bytes([MessageStatus.OK]) - + b64encode(content.encode()) - + bytes([0xF7]) - ) - return self diff --git a/flapi/__comms.py b/src/client/__comms.py similarity index 100% rename from flapi/__comms.py rename to src/client/__comms.py diff --git a/flapi/__context.py b/src/client/__context.py similarity index 100% rename from flapi/__context.py rename to src/client/__context.py diff --git a/flapi/__decorate.py b/src/client/__decorate.py similarity index 100% rename from flapi/__decorate.py rename to src/client/__decorate.py diff --git a/flapi/__enable.py b/src/client/__enable.py similarity index 100% rename from flapi/__enable.py rename to src/client/__enable.py diff --git a/flapi/__init__.py b/src/client/__init__.py similarity index 100% rename from flapi/__init__.py rename to src/client/__init__.py diff --git a/flapi/__main__.py b/src/client/__main__.py similarity index 100% rename from flapi/__main__.py rename to src/client/__main__.py diff --git a/flapi/__util.py b/src/client/__util.py similarity index 100% rename from flapi/__util.py rename to src/client/__util.py diff --git a/flapi/_consts.py b/src/client/_consts.py similarity index 100% rename from flapi/_consts.py rename to src/client/_consts.py diff --git a/flapi/cli/__init__.py b/src/client/cli/__init__.py similarity index 100% rename from flapi/cli/__init__.py rename to src/client/cli/__init__.py diff --git a/flapi/cli/consts.py b/src/client/cli/consts.py similarity index 100% rename from flapi/cli/consts.py rename to src/client/cli/consts.py diff --git a/flapi/cli/install.py b/src/client/cli/install.py similarity index 100% rename from flapi/cli/install.py rename to src/client/cli/install.py diff --git a/flapi/cli/repl.py b/src/client/cli/repl.py similarity index 100% rename from flapi/cli/repl.py rename to src/client/cli/repl.py diff --git a/flapi/cli/uninstall.py b/src/client/cli/uninstall.py similarity index 100% rename from flapi/cli/uninstall.py rename to src/client/cli/uninstall.py diff --git a/flapi/cli/util.py b/src/client/cli/util.py similarity index 100% rename from flapi/cli/util.py rename to src/client/cli/util.py diff --git a/flapi/client/__init__.py b/src/client/client/__init__.py similarity index 100% rename from flapi/client/__init__.py rename to src/client/client/__init__.py diff --git a/flapi/client/base_client.py b/src/client/client/base_client.py similarity index 100% rename from flapi/client/base_client.py rename to src/client/client/base_client.py diff --git a/src/client/client/client.py b/src/client/client/client.py new file mode 100644 index 0000000..32ac11e --- /dev/null +++ b/src/client/client/client.py @@ -0,0 +1,135 @@ +""" +# Flapi / Client / Flapi Msg + +Wrapper class for MIDI messages sent/received by Flapi. +""" +from flapi import _consts as consts +from flapi._consts import MessageType, MessageOrigin, MessageStatus +from flapi.errors import FlapiInvalidMsgError +from typing import overload +import itertools as iter + + +class FlapiMsg: + """ + Wrapper for Flapi messages, allowing for convenient access to their + properties. + """ + @overload + def __init__( + self, + data: bytes, + /, + ) -> None: + ... + + @overload + def __init__( + self, + origin: MessageOrigin, + client_id: int, + msg_type: MessageType | int, + status_code: MessageStatus, + additional_data: bytes | None = None, + /, + ) -> None: + ... + + def __init__( + self, + origin_data: MessageOrigin | bytes, + client_id: int | None = None, + msg_type: MessageType | int | None = None, + status: MessageStatus | None = None, + additional_data: bytes | None = None, + /, + ) -> None: + if isinstance(origin_data, (MessageOrigin, int)): + self.origin: MessageOrigin = origin_data + self.client_id: int = client_id # type: ignore + self.continuation = False + self.msg_type: MessageType | int = msg_type # type: ignore + self.status_code: MessageStatus = status # type: ignore + self.additional_data: bytes = ( + additional_data + if additional_data is not None + else bytes() + ) + else: + # Check header validity + header = origin_data[1:7] + if header != consts.SYSEX_HEADER: + raise FlapiInvalidMsgError(origin_data) + + # Extract data + self.origin = MessageOrigin(origin_data[7]) + self.client_id = bytes(origin_data)[8] + # Continuation byte is used to control whether additional messages + # can be appended + self.continuation = bool(origin_data[9]) + self.msg_type = bytes(origin_data)[10] + self.status_code = MessageStatus(origin_data[11]) + self.additional_data = origin_data[12:-1] + # Trim off the 0xF7 from the end ^^ + + def append(self, other: 'FlapiMsg') -> None: + """ + Append another Flapi message to this message. + + This works by merging the data bytes. + + ## Args + + * `other` (`FlapiMsg`): other message to append. + """ + if not self.continuation: + raise FlapiInvalidMsgError( + b''.join(other.to_bytes()), + "Cannot append to FlapiMsg if continuation byte is not set", + ) + + # Check other properties are the same + assert self.origin == other.origin + assert self.client_id == other.client_id + assert self.msg_type == other.msg_type + assert self.status_code == other.status_code + + self.continuation = other.continuation + self.additional_data += other.additional_data + + def to_bytes(self) -> list[bytes]: + """ + Convert the message into bytes, in preparation for being sent. + + This automatically handles the splitting of MIDI messages. + + Note that each message does not contain the leading 0xF0, or trailing + 0xF7 required by sysex messages. This is because Mido adds these + automatically. + + ## Returns + + * `list[bytes]`: MIDI message(s) to send. + """ + msgs: list[bytes] = [] + + # Append in reverse, so we can easily detect the last element (which + # shouldn't have its "continuation" byte set) + first = True + for data in reversed(list( + iter.batched(self.additional_data, consts.MAX_DATA_LEN) + )): + msgs.insert(0, bytes( + consts.SYSEX_HEADER + + bytes([ + self.origin, + self.client_id, + first, + self.msg_type, + self.status_code, + ]) + + bytes(data) + )) + first = False + + return msgs diff --git a/flapi/client/comms.py b/src/client/client/comms.py similarity index 100% rename from flapi/client/comms.py rename to src/client/client/comms.py diff --git a/flapi/client/flapi_msg.py b/src/client/client/flapi_msg.py similarity index 100% rename from flapi/client/flapi_msg.py rename to src/client/client/flapi_msg.py diff --git a/flapi/client/ports.py b/src/client/client/ports.py similarity index 100% rename from flapi/client/ports.py rename to src/client/client/ports.py diff --git a/flapi/errors.py b/src/client/errors.py similarity index 100% rename from flapi/errors.py rename to src/client/errors.py diff --git a/flapi/py.typed b/src/client/py.typed similarity index 100% rename from flapi/py.typed rename to src/client/py.typed diff --git a/flapi/types/__init__.py b/src/client/types/__init__.py similarity index 100% rename from flapi/types/__init__.py rename to src/client/types/__init__.py diff --git a/flapi/types/mido_types.py b/src/client/types/mido_types.py similarity index 100% rename from flapi/types/mido_types.py rename to src/client/types/mido_types.py diff --git a/flapi/server/capout.py b/src/server/capout.py similarity index 100% rename from flapi/server/capout.py rename to src/server/capout.py diff --git a/flapi/server/device_flapi_receive.py b/src/server/device_flapi_receive.py similarity index 88% rename from flapi/server/device_flapi_receive.py rename to src/server/device_flapi_receive.py index c2b087b..385c9b0 100644 --- a/flapi/server/device_flapi_receive.py +++ b/src/server/device_flapi_receive.py @@ -11,11 +11,11 @@ import logging import device from base64 import b64decode -import consts +import _consts from typing import Any -from consts import MessageStatus, MessageOrigin, MessageType +from _consts import MessageStatus, MessageOrigin, MessageType from capout import Capout -from flapi_response import FlapiResponse +from flapi_msg import FlapiMsg try: from fl_classes import FlMidiMsg @@ -34,7 +34,14 @@ def send_stdout(text: str): Callback for Capout, sending stdout to the client console """ # Target all devices - FlapiResponse(capout.target).stdout(text).send() + FlapiMsg( + MessageOrigin.SERVER, + capout.target, + MessageType.STDOUT, + MessageStatus.OK, + + ) + FlapiMsg(capout.target).stdout(text).send() capout = Capout(send_stdout) @@ -43,7 +50,7 @@ def send_stdout(text: str): def OnInit(): print("\n".join([ "Flapi request server", - f"Server version: {'.'.join(str(n) for n in consts.VERSION)}", + f"Server version: {'.'.join(str(n) for n in _consts.VERSION)}", f"Device name: {device.getName()}", f"Device assigned: {bool(device.isAssigned())}", f"FL Studio port number: {device.getPortNumber()}", @@ -90,7 +97,7 @@ def client_goodbye(res: FlapiResponse, data: bytes): def version_query(res: FlapiResponse, data: bytes): - res.version_query(consts.VERSION) + res.version_query(_consts.VERSION) def fl_exec(res: FlapiResponse, data: bytes): @@ -134,12 +141,12 @@ def receive_stdout(res: FlapiResponse, data: bytes): def OnSysEx(event: 'FlMidiMsg'): - header = event.sysex[1:len(consts.SYSEX_HEADER)+1] # Sysex header + header = event.sysex[1:len(_consts.SYSEX_HEADER)+1] # Sysex header # Remaining sysex data - sysex_data = event.sysex[len(consts.SYSEX_HEADER)+1:-1] + sysex_data = event.sysex[len(_consts.SYSEX_HEADER)+1:-1] # Ignore events that aren't Flapi messages - if header != consts.SYSEX_HEADER: + if header != _consts.SYSEX_HEADER: return message_origin = sysex_data[0] diff --git a/flapi/server/device_flapi_respond.py b/src/server/device_flapi_respond.py similarity index 96% rename from flapi/server/device_flapi_respond.py rename to src/server/device_flapi_respond.py index d78659b..37a796c 100644 --- a/flapi/server/device_flapi_respond.py +++ b/src/server/device_flapi_respond.py @@ -11,7 +11,7 @@ Flapi client. """ import device -from consts import MessageOrigin, MessageType, SYSEX_HEADER, VERSION +from _consts import MessageOrigin, MessageType, SYSEX_HEADER, VERSION try: from fl_classes import FlMidiMsg diff --git a/src/server/flapi/__util.py b/src/server/flapi/__util.py new file mode 100644 index 0000000..14d3944 --- /dev/null +++ b/src/server/flapi/__util.py @@ -0,0 +1,33 @@ +""" +# Flapi / Util + +Helper functions +""" +import pickle +from base64 import b64decode +from typing import Any + + +def bytes_to_str(msg: bytes) -> str: + """ + Helper to give a nicer representation of bytes + """ + return f"{repr([hex(i) for i in msg])} ({repr(msg)})" + + +def decode_python_object(data: bytes) -> Any: + """ + Encode Python object to send to the client + """ + return pickle.loads(b64decode(data)) + + +def format_fn_params(args, kwargs): + args_str = ", ".join(repr(a) for a in args) + kwargs_str = ", ".join(f"{k}={repr(v)}" for k, v in kwargs.items()) + + # Overall parameters string (avoid invalid syntax by removing extra + # commas) + return f"{args_str}, {kwargs_str}"\ + .removeprefix(", ")\ + .removesuffix(", ") diff --git a/flapi/server/consts.py b/src/server/flapi/_consts.py similarity index 95% rename from flapi/server/consts.py rename to src/server/flapi/_consts.py index df4afb5..b647d7c 100644 --- a/flapi/server/consts.py +++ b/src/server/flapi/_consts.py @@ -31,6 +31,12 @@ """ +MAX_DATA_LEN = 1000 +""" +Maximum number of bytes to use for additional message data. +""" + + class MessageOrigin(IntEnum): """ Origin of a Flapi message @@ -80,16 +86,15 @@ class MessageType(IntEnum): communication. """ - EXEC = 0x04 + REGISTER_MESSAGE_TYPE = 0x04 """ - Exec message - this is used to run an `exec` command in FL Studio, with no - return type (just a success, or an exception raised). + Register a new message type for the client. """ - EVAL = 0x05 + EXEC = 0x05 """ - Eval message - this is used to run an `eval` command in FL Studio, where - the value that it produces is returned. + Exec message - this is used to run an `exec` command in FL Studio, with no + return type (just a success, or an exception raised). """ STDOUT = 0x06 diff --git a/src/server/flapi/errors.py b/src/server/flapi/errors.py new file mode 100644 index 0000000..161d902 --- /dev/null +++ b/src/server/flapi/errors.py @@ -0,0 +1,146 @@ +""" +# Flapi > Errors + +Error classes used within FlApi +""" +from .__util import bytes_to_str + + +class FlapiPortError(IOError): + """ + Unable to open a MIDI port + + On Windows, this happens when trying to create a virtual MIDI port, since + it is currently impossible to do so without a kernel-mode driver for some + reason. + """ + + def __init__(self, port_names: tuple[str, str]) -> None: + super().__init__( + f"Could not create ports {port_names}. On Windows, you need to " + f"use software such as Loop MIDI " + f"(https://www.tobias-erichsen.de/software/loopmidi.html) to " + f"create the required ports yourself, as doing so requires a " + f"kernel-mode driver, which cannot be bundled in a Python library." + ) + + +class FlapiConnectionError(ConnectionError): + """ + Flapi was able to connect to the MIDI port, but didn't receive a response + from the server. + """ + + +class FlapiContextError(Exception): + """ + Flapi wasn't initialised, so its context could not be loaded + """ + + def __init__(self) -> None: + """ + Flapi wasn't initialised, so its context could not be loaded + """ + super().__init__( + "Could not find Flapi context. Perhaps you haven't initialised " + "Flapi by calling `flapi.enable()`." + ) + + +class FlapiVersionError(Exception): + """ + The version of the Flapi server doesn't match that of the Flapi client + """ + + +class FlapiInvalidMsgError(ValueError): + """ + Flapi unexpectedly received a MIDI message that it could not process + """ + + def __init__(self, msg: bytes, context: str = "") -> None: + """ + Flapi unexpectedly received a MIDI message that it could not process + """ + super().__init__( + f"Flapi received a message that it didn't understand. Perhaps " + f"another device is communicating on Flapi's MIDI port. Message " + f"received: {bytes_to_str(msg)}\n" + + f"Context: {context}" if context else "" + ) + + +class FlapiServerError(Exception): + """ + An unexpected error occurred on the server side. + + Ensure that the Flapi server and client have matching versions. + """ + + def __init__(self, msg: str) -> None: + """ + An unexpected error occurred on the server side. + + Ensure that the Flapi server and client have matching versions. + """ + super().__init__( + f"An unexpected server error occurred due to a miscommunication. " + f"Please ensure the Flapi server version matches that of the " + f"Flapi client by running the `flapi install` command. " + f"If they do match, please open a bug report. " + f"Failure message: {msg}" + ) + + +class FlapiServerExit(Exception): + """ + The Flapi server exited. + """ + + def __init__(self) -> None: + """ + The Flapi server exited. + """ + super().__init__( + "The Flapi server exited, likely because FL Studio was closed." + ) + + +class FlapiClientExit(SystemExit): + """ + The flapi client requested to exit + """ + + def __init__(self, code: int) -> None: + """ + The flapi client requested to exit + """ + super().__init__(code, "The flapi client requested to exit") + + +class FlapiClientError(Exception): + """ + An unexpected error occurred on the client side. + + Ensure that the Flapi server and client have matching versions. + """ + + def __init__(self, msg: str) -> None: + """ + An unexpected error occurred on the client side. + + Ensure that the Flapi server and client have matching versions. + """ + super().__init__( + f"An unexpected client error occurred due to a miscommunication. " + f"Please ensure the Flapi server version matches that of the " + f"Flapi client by running the `flapi install` command. " + f"If they do match, please open a bug report. " + f"Failure message: {msg}" + ) + + +class FlapiTimeoutError(TimeoutError): + """ + Flapi didn't receive a MIDI message within the timeout window + """ diff --git a/src/server/flapi_msg.py b/src/server/flapi_msg.py new file mode 100644 index 0000000..32ac11e --- /dev/null +++ b/src/server/flapi_msg.py @@ -0,0 +1,135 @@ +""" +# Flapi / Client / Flapi Msg + +Wrapper class for MIDI messages sent/received by Flapi. +""" +from flapi import _consts as consts +from flapi._consts import MessageType, MessageOrigin, MessageStatus +from flapi.errors import FlapiInvalidMsgError +from typing import overload +import itertools as iter + + +class FlapiMsg: + """ + Wrapper for Flapi messages, allowing for convenient access to their + properties. + """ + @overload + def __init__( + self, + data: bytes, + /, + ) -> None: + ... + + @overload + def __init__( + self, + origin: MessageOrigin, + client_id: int, + msg_type: MessageType | int, + status_code: MessageStatus, + additional_data: bytes | None = None, + /, + ) -> None: + ... + + def __init__( + self, + origin_data: MessageOrigin | bytes, + client_id: int | None = None, + msg_type: MessageType | int | None = None, + status: MessageStatus | None = None, + additional_data: bytes | None = None, + /, + ) -> None: + if isinstance(origin_data, (MessageOrigin, int)): + self.origin: MessageOrigin = origin_data + self.client_id: int = client_id # type: ignore + self.continuation = False + self.msg_type: MessageType | int = msg_type # type: ignore + self.status_code: MessageStatus = status # type: ignore + self.additional_data: bytes = ( + additional_data + if additional_data is not None + else bytes() + ) + else: + # Check header validity + header = origin_data[1:7] + if header != consts.SYSEX_HEADER: + raise FlapiInvalidMsgError(origin_data) + + # Extract data + self.origin = MessageOrigin(origin_data[7]) + self.client_id = bytes(origin_data)[8] + # Continuation byte is used to control whether additional messages + # can be appended + self.continuation = bool(origin_data[9]) + self.msg_type = bytes(origin_data)[10] + self.status_code = MessageStatus(origin_data[11]) + self.additional_data = origin_data[12:-1] + # Trim off the 0xF7 from the end ^^ + + def append(self, other: 'FlapiMsg') -> None: + """ + Append another Flapi message to this message. + + This works by merging the data bytes. + + ## Args + + * `other` (`FlapiMsg`): other message to append. + """ + if not self.continuation: + raise FlapiInvalidMsgError( + b''.join(other.to_bytes()), + "Cannot append to FlapiMsg if continuation byte is not set", + ) + + # Check other properties are the same + assert self.origin == other.origin + assert self.client_id == other.client_id + assert self.msg_type == other.msg_type + assert self.status_code == other.status_code + + self.continuation = other.continuation + self.additional_data += other.additional_data + + def to_bytes(self) -> list[bytes]: + """ + Convert the message into bytes, in preparation for being sent. + + This automatically handles the splitting of MIDI messages. + + Note that each message does not contain the leading 0xF0, or trailing + 0xF7 required by sysex messages. This is because Mido adds these + automatically. + + ## Returns + + * `list[bytes]`: MIDI message(s) to send. + """ + msgs: list[bytes] = [] + + # Append in reverse, so we can easily detect the last element (which + # shouldn't have its "continuation" byte set) + first = True + for data in reversed(list( + iter.batched(self.additional_data, consts.MAX_DATA_LEN) + )): + msgs.insert(0, bytes( + consts.SYSEX_HEADER + + bytes([ + self.origin, + self.client_id, + first, + self.msg_type, + self.status_code, + ]) + + bytes(data) + )) + first = False + + return msgs