diff --git a/src/flapi/__comms.py b/flapi/__comms.py similarity index 100% rename from src/flapi/__comms.py rename to flapi/__comms.py diff --git a/src/flapi/__context.py b/flapi/__context.py similarity index 100% rename from src/flapi/__context.py rename to flapi/__context.py diff --git a/src/flapi/__decorate.py b/flapi/__decorate.py similarity index 100% rename from src/flapi/__decorate.py rename to flapi/__decorate.py diff --git a/src/flapi/__enable.py b/flapi/__enable.py similarity index 100% rename from src/flapi/__enable.py rename to flapi/__enable.py diff --git a/src/flapi/__init__.py b/flapi/__init__.py similarity index 100% rename from src/flapi/__init__.py rename to flapi/__init__.py diff --git a/src/flapi/__main__.py b/flapi/__main__.py similarity index 100% rename from src/flapi/__main__.py rename to flapi/__main__.py diff --git a/src/flapi/__util.py b/flapi/__util.py similarity index 100% rename from src/flapi/__util.py rename to flapi/__util.py diff --git a/src/flapi/_consts.py b/flapi/_consts.py similarity index 100% rename from src/flapi/_consts.py rename to flapi/_consts.py diff --git a/src/flapi/cli/__init__.py b/flapi/cli/__init__.py similarity index 100% rename from src/flapi/cli/__init__.py rename to flapi/cli/__init__.py diff --git a/src/flapi/cli/consts.py b/flapi/cli/consts.py similarity index 100% rename from src/flapi/cli/consts.py rename to flapi/cli/consts.py diff --git a/src/flapi/cli/install.py b/flapi/cli/install.py similarity index 100% rename from src/flapi/cli/install.py rename to flapi/cli/install.py diff --git a/src/flapi/cli/repl.py b/flapi/cli/repl.py similarity index 100% rename from src/flapi/cli/repl.py rename to flapi/cli/repl.py diff --git a/src/flapi/cli/uninstall.py b/flapi/cli/uninstall.py similarity index 100% rename from src/flapi/cli/uninstall.py rename to flapi/cli/uninstall.py diff --git a/src/flapi/cli/util.py b/flapi/cli/util.py similarity index 100% rename from src/flapi/cli/util.py rename to flapi/cli/util.py diff --git a/src/flapi/client/__init__.py b/flapi/client/__init__.py similarity index 100% rename from src/flapi/client/__init__.py rename to flapi/client/__init__.py diff --git a/src/flapi/client/base_client.py b/flapi/client/base_client.py similarity index 100% rename from src/flapi/client/base_client.py rename to flapi/client/base_client.py diff --git a/src/flapi/client/client.py b/flapi/client/client.py similarity index 100% rename from src/flapi/client/client.py rename to flapi/client/client.py diff --git a/src/flapi/client/comms.py b/flapi/client/comms.py similarity index 100% rename from src/flapi/client/comms.py rename to flapi/client/comms.py diff --git a/src/flapi/client/ports.py b/flapi/client/ports.py similarity index 100% rename from src/flapi/client/ports.py rename to flapi/client/ports.py diff --git a/src/server/device_flapi_receive.py b/flapi/device_flapi_receive.py similarity index 51% rename from src/server/device_flapi_receive.py rename to flapi/device_flapi_receive.py index 438b987..417914f 100644 --- a/src/server/device_flapi_receive.py +++ b/flapi/device_flapi_receive.py @@ -8,13 +8,29 @@ It attaches to the "Flapi Request" device and handles messages before sending a response via the "Flapi Respond" script. """ +import sys import logging import device +from pathlib import Path +from typing import Optional from base64 import b64decode, b64encode -from flapi import _consts as consts -from flapi._consts import MessageStatus, MessageOrigin, MessageType -from capout import Capout -from flapi.flapi_msg import FlapiMsg + +# Add the dir containing flapi to the PATH, so that imports work +sys.path.append(str(Path(__file__).parent.parent)) + +# These imports need lint ignores, since they depend on the path modification +# above + +from flapi import _consts as consts # noqa: E402 +from flapi._consts import ( # noqa: E402 + MessageStatus, + MessageOrigin, + MessageType, +) +from flapi.server.capout import Capout # noqa: E402 +from flapi.server.client_context import ClientContext # noqa: E402 +from flapi.flapi_msg import FlapiMsg # noqa: E402 +from flapi.types import ScopeType try: from fl_classes import FlMidiMsg @@ -44,6 +60,37 @@ def send_stdout(text: str): capout = Capout(send_stdout) +############################################################################### + + +clients: dict[int, ClientContext] = {} + + +def version_query( + client_id: int, + status_code: int, + msg_data: Optional[bytes], + context: ClientContext, +) -> tuple[int, bytes]: + """ + Request the server version + """ + return MessageStatus.OK, bytes(consts.VERSION) + + +def register_message_type( + client_id: int, + status_code: int, + msg_data: Optional[bytes], + context: ClientContext, +) -> tuple[int, bytes]: + """ + Register a new message type + """ + assert msg_data + # TODO + + def OnInit(): print("\n".join([ "Flapi request server", diff --git a/src/server/device_flapi_respond.py b/flapi/device_flapi_respond.py similarity index 100% rename from src/server/device_flapi_respond.py rename to flapi/device_flapi_respond.py diff --git a/src/flapi/errors.py b/flapi/errors.py similarity index 100% rename from src/flapi/errors.py rename to flapi/errors.py diff --git a/src/flapi/flapi_msg.py b/flapi/flapi_msg.py similarity index 100% rename from src/flapi/flapi_msg.py rename to flapi/flapi_msg.py diff --git a/src/flapi/py.typed b/flapi/py.typed similarity index 100% rename from src/flapi/py.typed rename to flapi/py.typed diff --git a/src/server/flapi/__init__.py b/flapi/server/__init__.py similarity index 100% rename from src/server/flapi/__init__.py rename to flapi/server/__init__.py diff --git a/src/server/capout.py b/flapi/server/capout.py similarity index 100% rename from src/server/capout.py rename to flapi/server/capout.py diff --git a/src/server/client_context.py b/flapi/server/client_context.py similarity index 78% rename from src/server/client_context.py rename to flapi/server/client_context.py index 8a5c8fa..5c18ca9 100644 --- a/src/server/client_context.py +++ b/flapi/server/client_context.py @@ -1,11 +1,7 @@ """ # Flapi Server / Client Context """ -from typing import Any -from flapi.types import ServerMessageHandler - - -ScopeType = dict[str, Any] +from flapi.types import ServerMessageHandler, ScopeType class ClientContext: diff --git a/src/flapi/types/__init__.py b/flapi/types/__init__.py similarity index 82% rename from src/flapi/types/__init__.py rename to flapi/types/__init__.py index 5cf15db..a9bbb29 100644 --- a/src/flapi/types/__init__.py +++ b/flapi/types/__init__.py @@ -4,6 +4,7 @@ Type definitions used by Flapi. """ from .mido_types import MidoPort, MidoMsg +from .scope import ScopeType from .message_handler import ServerMessageHandler @@ -11,4 +12,5 @@ 'MidoPort', 'MidoMsg', 'ServerMessageHandler', + 'ScopeType', ] diff --git a/src/server/flapi/types/message_handler.py b/flapi/types/message_handler.py similarity index 74% rename from src/server/flapi/types/message_handler.py rename to flapi/types/message_handler.py index 09fb032..9d24eef 100644 --- a/src/server/flapi/types/message_handler.py +++ b/flapi/types/message_handler.py @@ -1,7 +1,8 @@ """ # Flapi / Types / Message Handler """ -from typing import Any, Optional, Protocol +from typing import Optional, Protocol +from flapi.server.client_context import ClientContext class ServerMessageHandler(Protocol): @@ -15,12 +16,17 @@ class ServerMessageHandler(Protocol): * `status_code`: status code sent by client. * `msg_data`: optional additional bytes. * `scope`: local scope to use when executing arbitrary code. + + ## Returns of handler function + + * `int` status code + * `bytes` additional data """ def __call__( self, client_id: int, status_code: int, msg_data: Optional[bytes], - scope: dict[str, Any], + context: ClientContext, ) -> int | tuple[int, bytes]: ... diff --git a/src/flapi/types/mido_types.py b/flapi/types/mido_types.py similarity index 100% rename from src/flapi/types/mido_types.py rename to flapi/types/mido_types.py diff --git a/flapi/types/scope.py b/flapi/types/scope.py new file mode 100644 index 0000000..329040e --- /dev/null +++ b/flapi/types/scope.py @@ -0,0 +1,10 @@ +""" +# Flapi / Types / Scope +""" +from typing import Any + + +ScopeType = dict[str, Any] +""" +Represents a variable scope in Python +""" diff --git a/src/flapi/types/message_handler.py b/src/flapi/types/message_handler.py deleted file mode 100644 index 09fb032..0000000 --- a/src/flapi/types/message_handler.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -# Flapi / Types / Message Handler -""" -from typing import Any, Optional, Protocol - - -class ServerMessageHandler(Protocol): - """ - Function to be executed on the Flapi server. This function will be called - whenever a message of this type is sent to the server. - - ## Args of handler function - - * `client_id`: ID of client. - * `status_code`: status code sent by client. - * `msg_data`: optional additional bytes. - * `scope`: local scope to use when executing arbitrary code. - """ - def __call__( - self, - client_id: int, - status_code: int, - msg_data: Optional[bytes], - scope: dict[str, Any], - ) -> int | tuple[int, bytes]: - ... diff --git a/src/server/flapi/__util.py b/src/server/flapi/__util.py deleted file mode 100644 index 14d3944..0000000 --- a/src/server/flapi/__util.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -# 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/src/server/flapi/_consts.py b/src/server/flapi/_consts.py deleted file mode 100644 index b647d7c..0000000 --- a/src/server/flapi/_consts.py +++ /dev/null @@ -1,185 +0,0 @@ -""" -# Flapi > Consts - -Constants used by Flapi -""" -from enum import IntEnum - -VERSION = (1, 0, 1) -""" -The version of Flapi in the format (major, minor, revision) -""" - -TIMEOUT_DURATION = 0.1 -""" -The amount of time to wait for a response before giving an error -""" - - -SYSEX_HEADER = bytes([ - # 0xF0, # Begin sysex (added by Mido) - 0x7D, # Non-commercial use byte number (see - # https://midi.org/specifications/midi1-specifications/midi-1-0-core-specifications) - 0x46, # 'F' - 0x6C, # 'l' - 0x61, # 'a' - 0x70, # 'p' - 0x69, # 'i' -]) -""" -Header for Sysex messages sent by Flapi, excluding the `0xF0` status byte -""" - - -MAX_DATA_LEN = 1000 -""" -Maximum number of bytes to use for additional message data. -""" - - -class MessageOrigin(IntEnum): - """ - Origin of a Flapi message - """ - CLIENT = 0x00 - """ - Message originates from the Flapi client (library) - """ - - INTERNAL = 0x02 - """ - Message internal to Flapi server (communication between ports in FL Studio) - """ - - SERVER = 0x01 - """ - Message originates from Flapi server (FL Studio) - """ - - -class MessageType(IntEnum): - """ - Type of a Flapi message - """ - - CLIENT_HELLO = 0x00 - """ - Hello message, used to connect to the client - """ - - CLIENT_GOODBYE = 0x01 - """ - Message from server instructing client to exit. Used so that we can have a - working `exit` function when using the server-side REPL, and to cleanly - disconnect from the server. - """ - - SERVER_GOODBYE = 0x02 - """ - Message from server notifying client that it is shutting down. - """ - - VERSION_QUERY = 0x03 - """ - Query the server version - this is used to ensure that the server is - running a matching version of Flapi, so that there aren't any bugs with - communication. - """ - - REGISTER_MESSAGE_TYPE = 0x04 - """ - Register a new message type for the client. - """ - - EXEC = 0x05 - """ - 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 - """ - Message contains text to write into stdout. - """ - - -class MessageStatus(IntEnum): - """ - Status of a Flapi message - """ - OK = 0x00 - """ - Message was processed correctly. - """ - - ERR = 0x01 - """ - Processing of message raised an exception. - """ - - FAIL = 0x02 - """ - The message could not be processed - - The error message is attached in the remaining bytes. - """ - - -DEVICE_ENQUIRY_MESSAGE = bytes([ - # 0xF0 - begin sysex (omitted by Mido) - 0x7E, # Universal sysex message - 0x00, # Device ID (assume zero?) - 0x06, # General information - 0x01, # Identity request - # 0xF7 - end sysex (omitted by Mido) -]) -""" -A universal device enquiry message, sent by FL Studio to attempt to identify -the type of the connected device. -""" - -DEVICE_ENQUIRY_RESPONSE = bytes([ - # 0xF0 - begin sysex (omitted by Mido) - 0x7E, # Universal sysex message - 0x00, # Device ID (assume zero) - 0x06, # General information - 0x02, # Identity reply - 0x7D, # Non-commercial use byte number - 0x46, # 'F' - 0x6c, # 'l' - 0x61, # 'a' - 0x70, # 'p' - 0x69, # 'i' - VERSION[0], # Major version - VERSION[1], # Minor version - VERSION[2], # Revision version - # 0xF7 - end sysex (omitted by Mido) -]) - - -DEFAULT_REQ_PORT = "Flapi Request" -""" -MIDI port to use/create for sending requests to FL Studio -""" - - -DEFAULT_RES_PORT = "Flapi Response" -""" -MIDI port to use/create for receiving responses from FL Studio -""" - - -FL_MODULES = [ - "playlist", - "channels", - "mixer", - "patterns", - "arrangement", - "ui", - "transport", - "plugins", - "general", -] -""" -Modules we need to decorate within FL Studio -""" diff --git a/src/server/flapi/errors.py b/src/server/flapi/errors.py deleted file mode 100644 index 161d902..0000000 --- a/src/server/flapi/errors.py +++ /dev/null @@ -1,146 +0,0 @@ -""" -# 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/flapi_msg.py b/src/server/flapi/flapi_msg.py deleted file mode 100644 index 32ac11e..0000000 --- a/src/server/flapi/flapi_msg.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -# 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/src/server/flapi/types/__init__.py b/src/server/flapi/types/__init__.py deleted file mode 100644 index 5cf15db..0000000 --- a/src/server/flapi/types/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -# Flapi / Types - -Type definitions used by Flapi. -""" -from .mido_types import MidoPort, MidoMsg -from .message_handler import ServerMessageHandler - - -__all__ = [ - 'MidoPort', - 'MidoMsg', - 'ServerMessageHandler', -] diff --git a/src/server/flapi/types/mido_types.py b/src/server/flapi/types/mido_types.py deleted file mode 100644 index 3714f71..0000000 --- a/src/server/flapi/types/mido_types.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -# Flapi / Types / Mido - -Type definitions for classes and interfaces used by Mido. -""" -import mido # type: ignore -from typing import Protocol, overload, Literal, TYPE_CHECKING - - -MessageType = Literal["sysex", "note_on", "note_off"] - - -if TYPE_CHECKING: - class MidoMsg: - def __init__(self, type: str, *, data: bytes | None = None) -> None: - super().__init__() - - def bytes(self) -> bytes: - ... - - class MidoPort(Protocol): - def send(self, msg: MidoMsg): - ... - - @overload - def receive(self, block: Literal[True] = True) -> MidoMsg: - ... - - @overload - def receive(self, block: Literal[False]) -> MidoMsg | None: - ... - - def receive(self, block: bool = True) -> MidoMsg | None: - ... -else: - MidoMsg = mido.Message - MidoPort = mido.ports.BaseIOPort