From 58d6e749422ddb2d41e47b80f35ba21d55306b34 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Thu, 18 Sep 2025 11:27:41 -0400 Subject: [PATCH 01/32] refactor: remove dummy lines --- src/scadable/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/scadable/__init__.py b/src/scadable/__init__.py index ad35e5a..e69de29 100644 --- a/src/scadable/__init__.py +++ b/src/scadable/__init__.py @@ -1 +0,0 @@ -print("Hello World") From eaf601fcbea7bde9a98d9433ba297bbc2e6155e5 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Thu, 18 Sep 2025 11:50:08 -0400 Subject: [PATCH 02/32] chore: add websockets as a dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 03bcd51..7844d84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ license = "Apache-2.0" license-files = [ "LICENSE" ] dependencies = [ + "websockets" ] [project.optional-dependencies] From 46cfec2a811334a513a57db8742ab6666c8f2ba8 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Thu, 18 Sep 2025 12:23:26 -0400 Subject: [PATCH 03/32] feat: add basic device framework --- src/scadable/__init__.py | 1 + src/scadable/live_query/__init__.py | 1 + src/scadable/live_query/live_telemetry.py | 73 +++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 src/scadable/live_query/__init__.py create mode 100644 src/scadable/live_query/live_telemetry.py diff --git a/src/scadable/__init__.py b/src/scadable/__init__.py index e69de29..0c0a863 100644 --- a/src/scadable/__init__.py +++ b/src/scadable/__init__.py @@ -0,0 +1 @@ +from .live_query import * # noqa: F403 diff --git a/src/scadable/live_query/__init__.py b/src/scadable/live_query/__init__.py new file mode 100644 index 0000000..6e5fe14 --- /dev/null +++ b/src/scadable/live_query/__init__.py @@ -0,0 +1 @@ +from live_telemetry import DeviceFactory # noqa: F401 diff --git a/src/scadable/live_query/live_telemetry.py b/src/scadable/live_query/live_telemetry.py new file mode 100644 index 0000000..6659394 --- /dev/null +++ b/src/scadable/live_query/live_telemetry.py @@ -0,0 +1,73 @@ +import asyncio +from typing import Callable, Awaitable, Any +from websockets.asyncio import client + + +class DeviceFactory: + """ + A class to create Devices + + Instance attributes: + api_key: API Key to authenticate devices + dest_url: URL of the websocket + connection_type: WSS or WS depending on websocket type + """ + + def __init__(self, api_key: str, dest_url: str = "", connection_type="wss"): + self.api_key = api_key + self.connection_type = connection_type + self.dest_url = dest_url + + def create_device(self, device_id: str): + """ + Creates a device that subscribes to a websocket + + :param device_id: ID of the device we connect to + :return: Created device + """ + return Device( + f"{self.connection_type}://{self.dest_url}?token={self.api_key}&deviceid={device_id}" + ) + + +class Device: + """ + A class that represents a single device + + Instance attributes: + ws_url: fully formed websocket url that we can connect to + raw_bus: set of subscribed handlers that will be called when receiving a response (raw) + """ + + def __init__(self, wss_url: str): + self.ws_url = wss_url + + # Bus that handles all the raw data + self.raw_bus: set[Callable[[str], Awaitable[Any]]] = set() + self.raw_live_telemetry(self._handle_raw) + + def raw_live_telemetry(self, subscriber: Callable[[str], Awaitable]): + """ + Decorator that adds a function to the bus + :param subscriber: Function that subscribes to raw data + :return: subscriber + """ + self.raw_bus.add(subscriber) + return subscriber + + async def _handle_raw(self, data: str): + """ + Internal method to prase raw data and send it to a different bus + :param data: raw data that was received by the websocket + :return: None + """ + print(data) + + async def start(self): + """ + Starts the websocket connection to the server to receive live telemetry + :return: None + """ + async with client.connect(self.ws_url) as ws: + async for message in ws: + await asyncio.gather(*[s(message) for s in self.raw_bus]) From ab8cb4a79c7d6f3bf052c67a8a6c8f8e50693642 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Thu, 18 Sep 2025 12:40:57 -0400 Subject: [PATCH 04/32] feat: add graceful stops, update import statement --- src/scadable/__init__.py | 2 +- src/scadable/live_query/live_telemetry.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/scadable/__init__.py b/src/scadable/__init__.py index 0c0a863..972393b 100644 --- a/src/scadable/__init__.py +++ b/src/scadable/__init__.py @@ -1 +1 @@ -from .live_query import * # noqa: F403 +import live_query # noqa: F401 diff --git a/src/scadable/live_query/live_telemetry.py b/src/scadable/live_query/live_telemetry.py index 6659394..a9aeeb4 100644 --- a/src/scadable/live_query/live_telemetry.py +++ b/src/scadable/live_query/live_telemetry.py @@ -46,6 +46,8 @@ def __init__(self, wss_url: str): self.raw_bus: set[Callable[[str], Awaitable[Any]]] = set() self.raw_live_telemetry(self._handle_raw) + self._stop_event = asyncio.Event() + def raw_live_telemetry(self, subscriber: Callable[[str], Awaitable]): """ Decorator that adds a function to the bus @@ -70,4 +72,13 @@ async def start(self): """ async with client.connect(self.ws_url) as ws: async for message in ws: + if self._stop_event.is_set(): + break await asyncio.gather(*[s(message) for s in self.raw_bus]) + + async def stop(self): + """ + Ends the websocket connection to the server gracefully + :return: None + """ + self._stop_event.set() From 83e907ead5d9708a08f9bd94c70a45869c1132c8 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Thu, 18 Sep 2025 12:41:59 -0400 Subject: [PATCH 05/32] refactor: dest_url -> dest_uri --- src/scadable/live_query/live_telemetry.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/scadable/live_query/live_telemetry.py b/src/scadable/live_query/live_telemetry.py index a9aeeb4..6545e05 100644 --- a/src/scadable/live_query/live_telemetry.py +++ b/src/scadable/live_query/live_telemetry.py @@ -13,10 +13,10 @@ class DeviceFactory: connection_type: WSS or WS depending on websocket type """ - def __init__(self, api_key: str, dest_url: str = "", connection_type="wss"): + def __init__(self, api_key: str, dest_uri: str = "", connection_type="wss"): self.api_key = api_key self.connection_type = connection_type - self.dest_url = dest_url + self.dest_uri = dest_uri def create_device(self, device_id: str): """ @@ -26,7 +26,7 @@ def create_device(self, device_id: str): :return: Created device """ return Device( - f"{self.connection_type}://{self.dest_url}?token={self.api_key}&deviceid={device_id}" + f"{self.connection_type}://{self.dest_uri}?token={self.api_key}&deviceid={device_id}" ) From a989bc130a8bc7461b8114f7c606b4285ad4da89 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Thu, 18 Sep 2025 12:46:42 -0400 Subject: [PATCH 06/32] bug: fix import paths --- src/scadable/__init__.py | 4 +++- src/scadable/live_query/__init__.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/scadable/__init__.py b/src/scadable/__init__.py index 972393b..8486ffa 100644 --- a/src/scadable/__init__.py +++ b/src/scadable/__init__.py @@ -1 +1,3 @@ -import live_query # noqa: F401 +from . import live_query as live_query + +__all__ = ["live_query"] diff --git a/src/scadable/live_query/__init__.py b/src/scadable/live_query/__init__.py index 6e5fe14..9fe111e 100644 --- a/src/scadable/live_query/__init__.py +++ b/src/scadable/live_query/__init__.py @@ -1 +1,3 @@ -from live_telemetry import DeviceFactory # noqa: F401 +from .live_telemetry import DeviceFactory + +__all__ = ["DeviceFactory"] From e8cace03f31943907ffe7fe749bba31bb0e630f4 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Thu, 18 Sep 2025 17:48:19 -0400 Subject: [PATCH 07/32] feat: abstract connections out for easier testing --- src/scadable/live_query/__init__.py | 3 +- src/scadable/live_query/connection_type.py | 96 ++++++++++++++++++++++ src/scadable/live_query/live_telemetry.py | 30 ++----- 3 files changed, 106 insertions(+), 23 deletions(-) create mode 100644 src/scadable/live_query/connection_type.py diff --git a/src/scadable/live_query/__init__.py b/src/scadable/live_query/__init__.py index 9fe111e..328439d 100644 --- a/src/scadable/live_query/__init__.py +++ b/src/scadable/live_query/__init__.py @@ -1,3 +1,4 @@ from .live_telemetry import DeviceFactory +from .connection_type import WebsocketConnectionFactory -__all__ = ["DeviceFactory"] +__all__ = ["DeviceFactory", "WebsocketConnectionFactory"] diff --git a/src/scadable/live_query/connection_type.py b/src/scadable/live_query/connection_type.py new file mode 100644 index 0000000..d782a3d --- /dev/null +++ b/src/scadable/live_query/connection_type.py @@ -0,0 +1,96 @@ +import asyncio +from websockets.asyncio import client +from websockets.asyncio.client import ClientConnection +from websockets.exceptions import ConnectionClosed +from typing import Callable, Awaitable + + +class ConnectionFactory: + def create_connection(self, device_id: str) -> "Connection": + raise NotImplementedError + + +class WebsocketConnectionFactory(ConnectionFactory): + def __init__(self, dest_uri: str, api_key: str, connection_type="wss"): + self.connection_type = connection_type + self.dest_uri = dest_uri + self.api_key = api_key + + def create_connection(self, device_id: str) -> "Connection": + return WebsocketConnection( + f"{self.connection_type}://{self.dest_uri}?token={self.api_key}&deviceid={device_id}" + ) + + +class Connection: + def __init__(self): + pass + + async def connect(self, handler: Callable[[str], Awaitable]): + """ + Connects to a server + :param handler: Function that handles messages + :return: None + """ + raise NotImplementedError + + async def send_message(self, message: str): + """ + Sends a message through the connection + :param message: Message to be sent + :return: None + """ + raise NotImplementedError + + async def stop(self): + """ + Ends the connection + :return: None + """ + raise NotImplementedError + + +class WebsocketConnection(Connection): + def __init__(self, dest_uri: str): + super().__init__() + self.dest_uri = dest_uri + self.ws: ClientConnection | None = None + self._stop_event = asyncio.Event() + + async def connect(self, handler: Callable[[str], Awaitable]): + """ + Starts the websocket connection to the server to receive data + :return: None + """ + stop_flag = False + async for ws in client.connect(self.dest_uri): + try: + self.ws = ws + async for message in ws: + if self._stop_event.is_set(): + stop_flag = True + break + await handler(message) + + if stop_flag: + break + except ConnectionClosed: + continue + self.ws = None + + async def send_message(self, message: str): + """ + Sends a message to the websocket connection + :param message: Message to send + :return: + """ + if self.ws: + await self.ws.send(message) + + async def stop(self): + """ + Ends the websocket connection to the server gracefully + :return: None + """ + self._stop_event.set() + self.ws = None diff --git a/src/scadable/live_query/live_telemetry.py b/src/scadable/live_query/live_telemetry.py index 6545e05..9456f00 100644 --- a/src/scadable/live_query/live_telemetry.py +++ b/src/scadable/live_query/live_telemetry.py @@ -1,6 +1,6 @@ import asyncio from typing import Callable, Awaitable, Any -from websockets.asyncio import client +from .connection_type import ConnectionFactory, Connection class DeviceFactory: @@ -13,10 +13,9 @@ class DeviceFactory: connection_type: WSS or WS depending on websocket type """ - def __init__(self, api_key: str, dest_uri: str = "", connection_type="wss"): + def __init__(self, api_key: str, connection_factory: ConnectionFactory): self.api_key = api_key - self.connection_type = connection_type - self.dest_uri = dest_uri + self.connection_factory = connection_factory def create_device(self, device_id: str): """ @@ -25,9 +24,7 @@ def create_device(self, device_id: str): :param device_id: ID of the device we connect to :return: Created device """ - return Device( - f"{self.connection_type}://{self.dest_uri}?token={self.api_key}&deviceid={device_id}" - ) + return Device(self.connection_factory.create_connection(device_id)) class Device: @@ -39,8 +36,8 @@ class Device: raw_bus: set of subscribed handlers that will be called when receiving a response (raw) """ - def __init__(self, wss_url: str): - self.ws_url = wss_url + def __init__(self, connection: Connection): + self.connection = connection # Bus that handles all the raw data self.raw_bus: set[Callable[[str], Awaitable[Any]]] = set() @@ -63,22 +60,11 @@ async def _handle_raw(self, data: str): :param data: raw data that was received by the websocket :return: None """ - print(data) + await asyncio.gather(*[s(data) for s in self.raw_bus]) async def start(self): """ Starts the websocket connection to the server to receive live telemetry :return: None """ - async with client.connect(self.ws_url) as ws: - async for message in ws: - if self._stop_event.is_set(): - break - await asyncio.gather(*[s(message) for s in self.raw_bus]) - - async def stop(self): - """ - Ends the websocket connection to the server gracefully - :return: None - """ - self._stop_event.set() + await self.connection.connect(self._handle_raw) From 2a40dd3c76b057369823e0869ff8c2f0d2de2173 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Thu, 18 Sep 2025 17:52:08 -0400 Subject: [PATCH 08/32] refactor: ignore abstract functions for coverage --- src/scadable/live_query/connection_type.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/scadable/live_query/connection_type.py b/src/scadable/live_query/connection_type.py index d782a3d..fe2c67f 100644 --- a/src/scadable/live_query/connection_type.py +++ b/src/scadable/live_query/connection_type.py @@ -6,7 +6,7 @@ class ConnectionFactory: - def create_connection(self, device_id: str) -> "Connection": + def create_connection(self, device_id: str) -> "Connection": # pragma: no cover raise NotImplementedError @@ -26,7 +26,7 @@ class Connection: def __init__(self): pass - async def connect(self, handler: Callable[[str], Awaitable]): + async def connect(self, handler: Callable[[str], Awaitable]): # pragma: no cover """ Connects to a server :param handler: Function that handles messages @@ -34,7 +34,7 @@ async def connect(self, handler: Callable[[str], Awaitable]): """ raise NotImplementedError - async def send_message(self, message: str): + async def send_message(self, message: str): # pragma: no cover """ Sends a message through the connection :param message: Message to be sent @@ -42,7 +42,7 @@ async def send_message(self, message: str): """ raise NotImplementedError - async def stop(self): + async def stop(self): # pragma: no cover """ Ends the connection :return: None From 998e7c6597271e87d9fb6977732d7a9d4936b97a Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Thu, 18 Sep 2025 19:03:59 -0400 Subject: [PATCH 09/32] fix: make ws just close on error --- src/scadable/live_query/connection_type.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/scadable/live_query/connection_type.py b/src/scadable/live_query/connection_type.py index fe2c67f..1e879ad 100644 --- a/src/scadable/live_query/connection_type.py +++ b/src/scadable/live_query/connection_type.py @@ -1,4 +1,3 @@ -import asyncio from websockets.asyncio import client from websockets.asyncio.client import ClientConnection from websockets.exceptions import ConnectionClosed @@ -55,27 +54,20 @@ def __init__(self, dest_uri: str): super().__init__() self.dest_uri = dest_uri self.ws: ClientConnection | None = None - self._stop_event = asyncio.Event() async def connect(self, handler: Callable[[str], Awaitable]): """ Starts the websocket connection to the server to receive data :return: None """ - stop_flag = False - async for ws in client.connect(self.dest_uri): + async with client.connect(self.dest_uri) as ws: + self.ws = ws try: - self.ws = ws async for message in ws: - if self._stop_event.is_set(): - stop_flag = True - break await handler(message) - - if stop_flag: - break except ConnectionClosed: - continue + pass + self.ws = None async def send_message(self, message: str): @@ -92,5 +84,5 @@ async def stop(self): Ends the websocket connection to the server gracefully :return: None """ - self._stop_event.set() - self.ws = None + if self.ws: + await self.ws.close() From 08664805805c05b7a27359cd7d49fbed0020665e Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Thu, 18 Sep 2025 19:19:11 -0400 Subject: [PATCH 10/32] fix: remove catch since we aren't actually going to be restarting the service --- src/scadable/live_query/connection_type.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/scadable/live_query/connection_type.py b/src/scadable/live_query/connection_type.py index 1e879ad..02dd1a8 100644 --- a/src/scadable/live_query/connection_type.py +++ b/src/scadable/live_query/connection_type.py @@ -1,6 +1,5 @@ from websockets.asyncio import client from websockets.asyncio.client import ClientConnection -from websockets.exceptions import ConnectionClosed from typing import Callable, Awaitable @@ -62,11 +61,8 @@ async def connect(self, handler: Callable[[str], Awaitable]): """ async with client.connect(self.dest_uri) as ws: self.ws = ws - try: - async for message in ws: - await handler(message) - except ConnectionClosed: - pass + async for message in ws: + await handler(message) self.ws = None From 1b140cf2d9c5ae477f2c066f4a6015fae689663a Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Thu, 18 Sep 2025 19:19:29 -0400 Subject: [PATCH 11/32] tests: test ws connection --- pyproject.toml | 1 + tests/test_connection_type.py | 88 +++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tests/test_connection_type.py diff --git a/pyproject.toml b/pyproject.toml index 7844d84..5401d6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dev = [ "pytest", "coverage", "pytest-cov", + "pytest-asyncio", "pre-commit" ] diff --git a/tests/test_connection_type.py b/tests/test_connection_type.py new file mode 100644 index 0000000..d3192a0 --- /dev/null +++ b/tests/test_connection_type.py @@ -0,0 +1,88 @@ +import asyncio +from websockets.asyncio import ws_server +from src.scadable.live_query import WebsocketConnectionFactory +from src.scadable.live_query.connection_type import WebsocketConnection +import pytest +import pytest_asyncio + + +async def echo_server(connection: ws_server.ServerConnection): + async for message in connection: + await connection.send(message) + + +class BasicWebsocketServer: + def __init__(self, uri="localhost", port=8765): + self.uri = uri + self.port = port + + self.handler = echo_server + self.server: ws_server.Server | None = None + + def full_uri(self): + return f"{self.uri}:{self.port}" + + async def _handle(self, connection): + if self.handler: + await self.handler(connection) + + def set_handler(self, handler): + self.handler = handler + + async def start(self): + if self.server: + await self.stop() + + self.server = await ws_server.serve(self._handle, self.uri, self.port) + + async def stop(self): + if self.server: + self.server.close() + await self.server.wait_closed() + self.server = None + + +@pytest_asyncio.fixture(scope="session") +async def websocket_server(): + server = BasicWebsocketServer() + yield server + + +@pytest.mark.asyncio +async def test_ws_connection_factory(websocket_server): + factory = WebsocketConnectionFactory( + dest_uri=websocket_server.full_uri(), api_key="apikey", connection_type="ws" + ) + assert factory.dest_uri == websocket_server.full_uri() + assert factory.api_key == "apikey" + assert factory.connection_type == "ws" + + conn = factory.create_connection("deviceid") + assert isinstance(conn, WebsocketConnection) + assert ( + conn.dest_uri + == f"ws://{websocket_server.full_uri()}?token=apikey&deviceid=deviceid" + ) + + +@pytest.mark.asyncio +async def test_connection(websocket_server): + await websocket_server.start() + factory = WebsocketConnectionFactory( + dest_uri=websocket_server.full_uri(), api_key="apikey", connection_type="ws" + ) + conn = factory.create_connection("deviceid") + + echo_messages = [] + + async def handler(message): + echo_messages.append(message) + await conn.stop() + + async def run(): + await asyncio.sleep(1) + await conn.send_message("test") + + await asyncio.gather(conn.connect(handler), run()) + + assert echo_messages == ["test"] From e267cab0c192087f9036b72385e6c3fba1aa96aa Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Thu, 18 Sep 2025 19:32:01 -0400 Subject: [PATCH 12/32] docs: docstring changes --- pyproject.toml | 6 +++++- src/scadable/live_query/live_telemetry.py | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5401d6a..5f7b9f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ "Operating System :: OS Independent", ] license = "Apache-2.0" -license-files = [ "LICENSE" ] +license-files = ["LICENSE"] dependencies = [ "websockets" ] @@ -41,3 +41,7 @@ dev = [ [project.urls] "Homepage" = "https://github.com/scadable/library-python" "Bug Tracker" = "https://github.com/scadable/library-python/issues" + +[tool.ruff] +# Ignore all tests +extend-exclude = ["tests"] diff --git a/src/scadable/live_query/live_telemetry.py b/src/scadable/live_query/live_telemetry.py index 9456f00..ac65c31 100644 --- a/src/scadable/live_query/live_telemetry.py +++ b/src/scadable/live_query/live_telemetry.py @@ -57,14 +57,14 @@ def raw_live_telemetry(self, subscriber: Callable[[str], Awaitable]): async def _handle_raw(self, data: str): """ Internal method to prase raw data and send it to a different bus - :param data: raw data that was received by the websocket + :param data: raw data that was received by the connection :return: None """ await asyncio.gather(*[s(data) for s in self.raw_bus]) async def start(self): """ - Starts the websocket connection to the server to receive live telemetry + Starts the connection to the server to receive live telemetry :return: None """ await self.connection.connect(self._handle_raw) From fea802c1ba59ffa88b6fa63522721749cfe78c87 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Thu, 18 Sep 2025 19:38:48 -0400 Subject: [PATCH 13/32] bug: infinite recursion on raw message --- src/scadable/live_query/live_telemetry.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/scadable/live_query/live_telemetry.py b/src/scadable/live_query/live_telemetry.py index ac65c31..dda3b7e 100644 --- a/src/scadable/live_query/live_telemetry.py +++ b/src/scadable/live_query/live_telemetry.py @@ -39,9 +39,8 @@ class Device: def __init__(self, connection: Connection): self.connection = connection - # Bus that handles all the raw data + # Bus that contains all functions that handle raw data self.raw_bus: set[Callable[[str], Awaitable[Any]]] = set() - self.raw_live_telemetry(self._handle_raw) self._stop_event = asyncio.Event() From 8aa03d0c3f79b1c86868b3ad813684a52d28d4de Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Thu, 18 Sep 2025 19:39:13 -0400 Subject: [PATCH 14/32] test: add test for live_telemetry --- tests/test_live_query.py | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/test_live_query.py diff --git a/tests/test_live_query.py b/tests/test_live_query.py new file mode 100644 index 0000000..d855fc7 --- /dev/null +++ b/tests/test_live_query.py @@ -0,0 +1,50 @@ +import pytest +from src.scadable.live_query.connection_type import ConnectionFactory, Connection +from src.scadable.live_query import DeviceFactory + + +class TestConnectionFactory(ConnectionFactory): + def create_connection(self, device_id: str): + return TestConnection() + + +class TestConnection(Connection): + def __init__(self): + super().__init__() + self.handler = None + + async def connect(self, handler): + self.handler = handler + + async def send_message(self, message): + if self.handler: + await self.handler(message) + + async def stop(self): + pass + + +@pytest.mark.asyncio +async def test_device_factory(): + device_factory = DeviceFactory("apikey", TestConnectionFactory()) + assert device_factory.api_key == "apikey" + device = device_factory.create_device("deviceid") + assert isinstance(device.connection, TestConnection) + + +@pytest.mark.asyncio +async def test_device_connection(): + device_factory = DeviceFactory("apikey", TestConnectionFactory()) + device = device_factory.create_device("deviceid") + + messages = [] + + # Basic Decorator Usage + @device.raw_live_telemetry + async def handle(m): + messages.append(m) + + await device.start() + await device.connection.send_message("test") + + assert messages == ["test"] From 497eb2d19187535d0db36cc67f999d03b64e0d9b Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Thu, 18 Sep 2025 19:46:09 -0400 Subject: [PATCH 15/32] docs: add docstrings --- src/scadable/live_query/connection_type.py | 74 +++++++++++++++++----- 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/src/scadable/live_query/connection_type.py b/src/scadable/live_query/connection_type.py index 02dd1a8..e233ba9 100644 --- a/src/scadable/live_query/connection_type.py +++ b/src/scadable/live_query/connection_type.py @@ -3,28 +3,65 @@ from typing import Callable, Awaitable -class ConnectionFactory: - def create_connection(self, device_id: str) -> "Connection": # pragma: no cover +class ConnectionFactory: # pragma: no cover + """ + Abstract Connection Factory + + These factories create 'Connection' types which live_telemetry will use. This is passed into the DeviceFactory + which creates devices that we can subscribe to. + + We expose create_connection(device_id) so we can pass in an instance of any type of factory + """ + + def create_connection(self, device_id: str) -> "Connection": + """ + Creates a connection to a device + :param device_id: Device Id of the device + :return: Connection + """ raise NotImplementedError class WebsocketConnectionFactory(ConnectionFactory): + """ + A factory that creates websocket connections. + + Instance Attributes: + connection_type: ws or wss depending on the connection type + dest_uri: destination uri of the server, e.g. localhost:8765 + api_key: api key of the factory + """ + def __init__(self, dest_uri: str, api_key: str, connection_type="wss"): self.connection_type = connection_type self.dest_uri = dest_uri self.api_key = api_key def create_connection(self, device_id: str) -> "Connection": + """ + Creates a connection to a device + :param device_id: Device Id of the device + :return: WebsocketConnection + """ return WebsocketConnection( f"{self.connection_type}://{self.dest_uri}?token={self.api_key}&deviceid={device_id}" ) -class Connection: - def __init__(self): - pass +class Connection: # pragma: no cover + """ + Abstract Connection + + A generic connection that the device uses to send and receive messages. - async def connect(self, handler: Callable[[str], Awaitable]): # pragma: no cover + We expose: + - connect(func) + - send_message(str) + - stop() + to interact with the connection. + """ + + async def connect(self, handler: Callable[[str], Awaitable]): """ Connects to a server :param handler: Function that handles messages @@ -32,7 +69,7 @@ async def connect(self, handler: Callable[[str], Awaitable]): # pragma: no cove """ raise NotImplementedError - async def send_message(self, message: str): # pragma: no cover + async def send_message(self, message: str): """ Sends a message through the connection :param message: Message to be sent @@ -40,7 +77,7 @@ async def send_message(self, message: str): # pragma: no cover """ raise NotImplementedError - async def stop(self): # pragma: no cover + async def stop(self): """ Ends the connection :return: None @@ -49,10 +86,17 @@ async def stop(self): # pragma: no cover class WebsocketConnection(Connection): + """ + A class representing a Websocket Connection + + Instance Attributes: + dest_uri: full uri of the destination, e.g. wss://localhost:8765&apikey=a&deviceid=b + """ + def __init__(self, dest_uri: str): super().__init__() self.dest_uri = dest_uri - self.ws: ClientConnection | None = None + self._ws: ClientConnection | None = None async def connect(self, handler: Callable[[str], Awaitable]): """ @@ -60,11 +104,11 @@ async def connect(self, handler: Callable[[str], Awaitable]): :return: None """ async with client.connect(self.dest_uri) as ws: - self.ws = ws + self._ws = ws async for message in ws: await handler(message) - self.ws = None + self._ws = None async def send_message(self, message: str): """ @@ -72,13 +116,13 @@ async def send_message(self, message: str): :param message: Message to send :return: """ - if self.ws: - await self.ws.send(message) + if self._ws: + await self._ws.send(message) async def stop(self): """ Ends the websocket connection to the server gracefully :return: None """ - if self.ws: - await self.ws.close() + if self._ws: + await self._ws.close() From f66e2e6db3a9bc5d325f14c6d6108b2f39d3862c Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Thu, 18 Sep 2025 19:48:21 -0400 Subject: [PATCH 16/32] chore: update websockets dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5f7b9f8..9368d4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ license = "Apache-2.0" license-files = ["LICENSE"] dependencies = [ - "websockets" + "websockets >= 13.0" ] [project.optional-dependencies] From 70a9a30d30204227564448602bb89c5329e880d3 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Thu, 18 Sep 2025 19:56:18 -0400 Subject: [PATCH 17/32] bug: fix weird refactoring causing file diff --- tests/test_connection_type.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_connection_type.py b/tests/test_connection_type.py index d3192a0..cd48c34 100644 --- a/tests/test_connection_type.py +++ b/tests/test_connection_type.py @@ -1,12 +1,12 @@ import asyncio -from websockets.asyncio import ws_server +from websockets.asyncio import server from src.scadable.live_query import WebsocketConnectionFactory from src.scadable.live_query.connection_type import WebsocketConnection import pytest import pytest_asyncio -async def echo_server(connection: ws_server.ServerConnection): +async def echo_server(connection: server.ServerConnection): async for message in connection: await connection.send(message) @@ -17,7 +17,7 @@ def __init__(self, uri="localhost", port=8765): self.port = port self.handler = echo_server - self.server: ws_server.Server | None = None + self.server: server.Server | None = None def full_uri(self): return f"{self.uri}:{self.port}" @@ -33,7 +33,7 @@ async def start(self): if self.server: await self.stop() - self.server = await ws_server.serve(self._handle, self.uri, self.port) + self.server = await server.serve(self._handle, self.uri, self.port) async def stop(self): if self.server: From ea8f3552e962f7ba89b28a1c3b08abd0e3b6b518 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Sat, 20 Sep 2025 17:24:34 -0400 Subject: [PATCH 18/32] refactor: update connection factory to not store the api key --- src/scadable/live_query/connection_type.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/scadable/live_query/connection_type.py b/src/scadable/live_query/connection_type.py index e233ba9..1b03ba9 100644 --- a/src/scadable/live_query/connection_type.py +++ b/src/scadable/live_query/connection_type.py @@ -13,9 +13,10 @@ class ConnectionFactory: # pragma: no cover We expose create_connection(device_id) so we can pass in an instance of any type of factory """ - def create_connection(self, device_id: str) -> "Connection": + def create_connection(self, api_key: str, device_id: str) -> "Connection": """ Creates a connection to a device + :param api_key: API key of the connection you want to create :param device_id: Device Id of the device :return: Connection """ @@ -32,19 +33,19 @@ class WebsocketConnectionFactory(ConnectionFactory): api_key: api key of the factory """ - def __init__(self, dest_uri: str, api_key: str, connection_type="wss"): + def __init__(self, dest_uri: str, connection_type="wss"): self.connection_type = connection_type self.dest_uri = dest_uri - self.api_key = api_key - def create_connection(self, device_id: str) -> "Connection": + def create_connection(self, api_key: str, device_id: str) -> "Connection": """ Creates a connection to a device + :param api_key: API key of the connection you want to create :param device_id: Device Id of the device :return: WebsocketConnection """ return WebsocketConnection( - f"{self.connection_type}://{self.dest_uri}?token={self.api_key}&deviceid={device_id}" + f"{self.connection_type}://{self.dest_uri}?token={api_key}&deviceid={device_id}" ) From 4900b2154c120c93100e088af07a72a0f9d6f8be Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Sat, 20 Sep 2025 17:29:56 -0400 Subject: [PATCH 19/32] refactor: move connection out --- .../{live_query/connection_type.py => connection.py} | 0 src/scadable/live_query/__init__.py | 3 +-- src/scadable/live_query/live_telemetry.py | 5 ++--- tests/test_connection_type.py | 3 +-- tests/test_live_query.py | 2 +- 5 files changed, 5 insertions(+), 8 deletions(-) rename src/scadable/{live_query/connection_type.py => connection.py} (100%) diff --git a/src/scadable/live_query/connection_type.py b/src/scadable/connection.py similarity index 100% rename from src/scadable/live_query/connection_type.py rename to src/scadable/connection.py diff --git a/src/scadable/live_query/__init__.py b/src/scadable/live_query/__init__.py index 328439d..9fe111e 100644 --- a/src/scadable/live_query/__init__.py +++ b/src/scadable/live_query/__init__.py @@ -1,4 +1,3 @@ from .live_telemetry import DeviceFactory -from .connection_type import WebsocketConnectionFactory -__all__ = ["DeviceFactory", "WebsocketConnectionFactory"] +__all__ = ["DeviceFactory"] diff --git a/src/scadable/live_query/live_telemetry.py b/src/scadable/live_query/live_telemetry.py index dda3b7e..b8da4a1 100644 --- a/src/scadable/live_query/live_telemetry.py +++ b/src/scadable/live_query/live_telemetry.py @@ -1,6 +1,6 @@ import asyncio from typing import Callable, Awaitable, Any -from .connection_type import ConnectionFactory, Connection +from ..connection import ConnectionFactory, Connection class DeviceFactory: @@ -13,8 +13,7 @@ class DeviceFactory: connection_type: WSS or WS depending on websocket type """ - def __init__(self, api_key: str, connection_factory: ConnectionFactory): - self.api_key = api_key + def __init__(self, connection_factory: ConnectionFactory): self.connection_factory = connection_factory def create_device(self, device_id: str): diff --git a/tests/test_connection_type.py b/tests/test_connection_type.py index cd48c34..01a661f 100644 --- a/tests/test_connection_type.py +++ b/tests/test_connection_type.py @@ -1,7 +1,6 @@ import asyncio from websockets.asyncio import server -from src.scadable.live_query import WebsocketConnectionFactory -from src.scadable.live_query.connection_type import WebsocketConnection +from src.scadable.connection import WebsocketConnection, WebsocketConnectionFactory import pytest import pytest_asyncio diff --git a/tests/test_live_query.py b/tests/test_live_query.py index d855fc7..ec93eaf 100644 --- a/tests/test_live_query.py +++ b/tests/test_live_query.py @@ -1,5 +1,5 @@ import pytest -from src.scadable.live_query.connection_type import ConnectionFactory, Connection +from src.scadable.connection import ConnectionFactory, Connection from src.scadable.live_query import DeviceFactory From dceb71f4786ddb3d05a12f4adedf2e5ec625c8b4 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Sat, 20 Sep 2025 17:39:56 -0400 Subject: [PATCH 20/32] refactor: move files around --- {src/scadable => scadable}/__init__.py | 0 {src/scadable => scadable}/connection.py | 11 +++++++---- .../live_telemetry.py => scadable/device.py | 4 ++-- scadable/facility.py | 3 +++ src/scadable/live_query/__init__.py | 3 --- tests/test_connection_type.py | 4 ++-- tests/test_import.py | 2 +- tests/test_live_query.py | 8 ++++---- 8 files changed, 19 insertions(+), 16 deletions(-) rename {src/scadable => scadable}/__init__.py (100%) rename {src/scadable => scadable}/connection.py (91%) rename src/scadable/live_query/live_telemetry.py => scadable/device.py (96%) create mode 100644 scadable/facility.py delete mode 100644 src/scadable/live_query/__init__.py diff --git a/src/scadable/__init__.py b/scadable/__init__.py similarity index 100% rename from src/scadable/__init__.py rename to scadable/__init__.py diff --git a/src/scadable/connection.py b/scadable/connection.py similarity index 91% rename from src/scadable/connection.py rename to scadable/connection.py index 1b03ba9..e3d1229 100644 --- a/src/scadable/connection.py +++ b/scadable/connection.py @@ -29,13 +29,16 @@ class WebsocketConnectionFactory(ConnectionFactory): Instance Attributes: connection_type: ws or wss depending on the connection type - dest_uri: destination uri of the server, e.g. localhost:8765 - api_key: api key of the factory """ def __init__(self, dest_uri: str, connection_type="wss"): + """ + Init for a Websocket Factory + :param dest_uri: Destination URI of the websocket + :param connection_type: Connection type (wss or ws) + """ self.connection_type = connection_type - self.dest_uri = dest_uri + self._dest_uri = dest_uri def create_connection(self, api_key: str, device_id: str) -> "Connection": """ @@ -45,7 +48,7 @@ def create_connection(self, api_key: str, device_id: str) -> "Connection": :return: WebsocketConnection """ return WebsocketConnection( - f"{self.connection_type}://{self.dest_uri}?token={api_key}&deviceid={device_id}" + f"{self.connection_type}://{self._dest_uri}?token={api_key}&deviceid={device_id}" ) diff --git a/src/scadable/live_query/live_telemetry.py b/scadable/device.py similarity index 96% rename from src/scadable/live_query/live_telemetry.py rename to scadable/device.py index b8da4a1..3ab02af 100644 --- a/src/scadable/live_query/live_telemetry.py +++ b/scadable/device.py @@ -1,9 +1,9 @@ import asyncio from typing import Callable, Awaitable, Any -from ..connection import ConnectionFactory, Connection +from scadable.connection import ConnectionFactory, Connection -class DeviceFactory: +class DeviceManager: """ A class to create Devices diff --git a/scadable/facility.py b/scadable/facility.py new file mode 100644 index 0000000..80cc5da --- /dev/null +++ b/scadable/facility.py @@ -0,0 +1,3 @@ +class Facility: + def __init__(self, api_key: str): + self._api_key = api_key diff --git a/src/scadable/live_query/__init__.py b/src/scadable/live_query/__init__.py deleted file mode 100644 index 9fe111e..0000000 --- a/src/scadable/live_query/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .live_telemetry import DeviceFactory - -__all__ = ["DeviceFactory"] diff --git a/tests/test_connection_type.py b/tests/test_connection_type.py index 01a661f..023b8bf 100644 --- a/tests/test_connection_type.py +++ b/tests/test_connection_type.py @@ -1,6 +1,6 @@ import asyncio from websockets.asyncio import server -from src.scadable.connection import WebsocketConnection, WebsocketConnectionFactory +from scadable import WebsocketConnection, WebsocketConnectionFactory import pytest import pytest_asyncio @@ -52,7 +52,7 @@ async def test_ws_connection_factory(websocket_server): factory = WebsocketConnectionFactory( dest_uri=websocket_server.full_uri(), api_key="apikey", connection_type="ws" ) - assert factory.dest_uri == websocket_server.full_uri() + assert factory._dest_uri == websocket_server.full_uri() assert factory.api_key == "apikey" assert factory.connection_type == "ws" diff --git a/tests/test_import.py b/tests/test_import.py index 3c50747..65cf54a 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -1,2 +1,2 @@ def test_module_import(): - import src.scadable # noqa + import scadable # noqa diff --git a/tests/test_live_query.py b/tests/test_live_query.py index ec93eaf..cc282bd 100644 --- a/tests/test_live_query.py +++ b/tests/test_live_query.py @@ -1,6 +1,6 @@ import pytest -from src.scadable.connection import ConnectionFactory, Connection -from src.scadable.live_query import DeviceFactory +from scadable import ConnectionFactory, Connection +from scadable import DeviceManager class TestConnectionFactory(ConnectionFactory): @@ -26,7 +26,7 @@ async def stop(self): @pytest.mark.asyncio async def test_device_factory(): - device_factory = DeviceFactory("apikey", TestConnectionFactory()) + device_factory = DeviceManager("apikey", TestConnectionFactory()) assert device_factory.api_key == "apikey" device = device_factory.create_device("deviceid") assert isinstance(device.connection, TestConnection) @@ -34,7 +34,7 @@ async def test_device_factory(): @pytest.mark.asyncio async def test_device_connection(): - device_factory = DeviceFactory("apikey", TestConnectionFactory()) + device_factory = DeviceManager("apikey", TestConnectionFactory()) device = device_factory.create_device("deviceid") messages = [] From ed357da7620aa2cc6227c1e370d6a606dce958eb Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Sat, 20 Sep 2025 17:56:52 -0400 Subject: [PATCH 21/32] refactor: update docstrings --- scadable/device.py | 60 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/scadable/device.py b/scadable/device.py index 3ab02af..9d1097c 100644 --- a/scadable/device.py +++ b/scadable/device.py @@ -5,25 +5,54 @@ class DeviceManager: """ - A class to create Devices + A class to manage created Devices Instance attributes: - api_key: API Key to authenticate devices - dest_url: URL of the websocket - connection_type: WSS or WS depending on websocket type + connection_factory: Connection Factory + devices: dict that maps deviceid->Device """ def __init__(self, connection_factory: ConnectionFactory): self.connection_factory = connection_factory + self.devices: dict[str, Device] = {} - def create_device(self, device_id: str): + def create_device( + self, api_key: str, device_id: str, create_connection: bool = False + ): """ - Creates a device that subscribes to a websocket + Creates a device if not already created, otherwise return the already created one + :param api_key: API key to be used to create the device :param device_id: ID of the device we connect to + :param create_connection: Whether or not to create a connection (to be used with live telemetry), defaults to False :return: Created device """ - return Device(self.connection_factory.create_connection(device_id)) + if device_id in self.devices: + device = self.devices[device_id] + else: + conn = ( + self.connection_factory.create_connection( + api_key=api_key, device_id=device_id + ) + if create_connection + else None + ) + device = Device(device_id=device_id, connection=conn) + self.devices[device_id] = device + + return device + + def remove_device(self, device_id: str): + """ + Removes a device from our manager + + :param device_id: Device Id to remove + :return: + """ + if device_id in self.devices: + device = self.devices[device_id] # noqa + # TODO: figure out what to do here + del self.devices[device_id] class Device: @@ -31,21 +60,21 @@ class Device: A class that represents a single device Instance attributes: - ws_url: fully formed websocket url that we can connect to - raw_bus: set of subscribed handlers that will be called when receiving a response (raw) + connection: connection that the device will read messages from + device_id: device id of the device + raw_bus: set of subscribed handlers that will be called when receiving a raw response """ - def __init__(self, connection: Connection): + def __init__(self, device_id: str, connection: Connection): self.connection = connection + self.device_id = device_id # Bus that contains all functions that handle raw data self.raw_bus: set[Callable[[str], Awaitable[Any]]] = set() - self._stop_event = asyncio.Event() - def raw_live_telemetry(self, subscriber: Callable[[str], Awaitable]): """ - Decorator that adds a function to the bus + Decorator that adds a function to our bus :param subscriber: Function that subscribes to raw data :return: subscriber """ @@ -60,9 +89,10 @@ async def _handle_raw(self, data: str): """ await asyncio.gather(*[s(data) for s in self.raw_bus]) - async def start(self): + async def start_live_telemetry(self): """ - Starts the connection to the server to receive live telemetry + Starts the connection to the server to receive live telemetry for this particular device + This function is called when we want to initialize a connection to a single device :return: None """ await self.connection.connect(self._handle_raw) From 8e2a76e82892931dafdbea81dd99f83a29ce42ee Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Sat, 20 Sep 2025 18:00:22 -0400 Subject: [PATCH 22/32] bug: throw error when no connection was specified during init --- scadable/device.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scadable/device.py b/scadable/device.py index 9d1097c..01f94ae 100644 --- a/scadable/device.py +++ b/scadable/device.py @@ -65,7 +65,7 @@ class Device: raw_bus: set of subscribed handlers that will be called when receiving a raw response """ - def __init__(self, device_id: str, connection: Connection): + def __init__(self, device_id: str, connection: Connection | None): self.connection = connection self.device_id = device_id @@ -95,4 +95,9 @@ async def start_live_telemetry(self): This function is called when we want to initialize a connection to a single device :return: None """ - await self.connection.connect(self._handle_raw) + if self.connection: + await self.connection.connect(self._handle_raw) + else: + raise RuntimeError( + f"No connection was specified for device {self.device_id}" + ) From 42e664b90d9ec49be40de988ea14c72c132ab8f0 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Sat, 20 Sep 2025 18:06:48 -0400 Subject: [PATCH 23/32] feat: add generic live_telemetry function --- scadable/device.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scadable/device.py b/scadable/device.py index 01f94ae..2183914 100644 --- a/scadable/device.py +++ b/scadable/device.py @@ -71,6 +71,8 @@ def __init__(self, device_id: str, connection: Connection | None): # Bus that contains all functions that handle raw data self.raw_bus: set[Callable[[str], Awaitable[Any]]] = set() + # Bus that contains all functions that handle parsed data + self.parsed_bus: set[Callable[[str], Awaitable[Any]]] = set() def raw_live_telemetry(self, subscriber: Callable[[str], Awaitable]): """ @@ -81,6 +83,15 @@ def raw_live_telemetry(self, subscriber: Callable[[str], Awaitable]): self.raw_bus.add(subscriber) return subscriber + def live_telemetry(self, subscriber: Callable[[str], Awaitable]): + """ + Decorator that adds a function to our bus + :param subscriber: Function that subscribes to raw data + :return: subscriber + """ + self.parsed_bus.add(subscriber) + return subscriber + async def _handle_raw(self, data: str): """ Internal method to prase raw data and send it to a different bus @@ -88,6 +99,8 @@ async def _handle_raw(self, data: str): :return: None """ await asyncio.gather(*[s(data) for s in self.raw_bus]) + # TODO: parse data + await asyncio.gather(*[s(data) for s in self.parsed_bus]) async def start_live_telemetry(self): """ From aeeec7c14613b7e7f08634979cce3bb4ec0c5afd Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Sat, 20 Sep 2025 18:30:39 -0400 Subject: [PATCH 24/32] feat: update facility and add docs --- scadable/__init__.py | 4 +- scadable/device.py | 47 ++++++++++++++--------- scadable/facility.py | 90 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 121 insertions(+), 20 deletions(-) diff --git a/scadable/__init__.py b/scadable/__init__.py index 8486ffa..5d85e48 100644 --- a/scadable/__init__.py +++ b/scadable/__init__.py @@ -1,3 +1,3 @@ -from . import live_query as live_query +from facility import Facility -__all__ = ["live_query"] +__all__ = ["Facility"] diff --git a/scadable/device.py b/scadable/device.py index 2183914..4fba12c 100644 --- a/scadable/device.py +++ b/scadable/device.py @@ -1,6 +1,6 @@ import asyncio from typing import Callable, Awaitable, Any -from scadable.connection import ConnectionFactory, Connection +from .connection import Connection class DeviceManager: @@ -8,36 +8,33 @@ class DeviceManager: A class to manage created Devices Instance attributes: - connection_factory: Connection Factory devices: dict that maps deviceid->Device """ - def __init__(self, connection_factory: ConnectionFactory): - self.connection_factory = connection_factory + def __init__(self): self.devices: dict[str, Device] = {} - def create_device( - self, api_key: str, device_id: str, create_connection: bool = False - ): + def __getitem__(self, device_id: str) -> "Device": + """ + Returns a device from a device id + + :param device_id: device id + :return: Device with associated device id + """ + return self.devices[device_id] + + def create_device(self, device_id: str, connection: Connection | None): """ Creates a device if not already created, otherwise return the already created one - :param api_key: API key to be used to create the device :param device_id: ID of the device we connect to - :param create_connection: Whether or not to create a connection (to be used with live telemetry), defaults to False + :param connection: The connection that should be used for live_telemetry :return: Created device """ if device_id in self.devices: device = self.devices[device_id] else: - conn = ( - self.connection_factory.create_connection( - api_key=api_key, device_id=device_id - ) - if create_connection - else None - ) - device = Device(device_id=device_id, connection=conn) + device = Device(device_id=device_id, connection=connection) self.devices[device_id] = device return device @@ -63,6 +60,7 @@ class Device: connection: connection that the device will read messages from device_id: device id of the device raw_bus: set of subscribed handlers that will be called when receiving a raw response + parsed_bus: set of subscribed handlers that will be called after parsing a response """ def __init__(self, device_id: str, connection: Connection | None): @@ -77,18 +75,32 @@ def __init__(self, device_id: str, connection: Connection | None): def raw_live_telemetry(self, subscriber: Callable[[str], Awaitable]): """ Decorator that adds a function to our bus + Throws an error if no connection was specified + :param subscriber: Function that subscribes to raw data :return: subscriber """ + if not self.connection: + raise RuntimeError( + f"No connection was specified for device {self.device_id}" + ) + self.raw_bus.add(subscriber) return subscriber def live_telemetry(self, subscriber: Callable[[str], Awaitable]): """ Decorator that adds a function to our bus + Throws an error if no connection was specified + :param subscriber: Function that subscribes to raw data :return: subscriber """ + if not self.connection: + raise RuntimeError( + f"No connection was specified for device {self.device_id}" + ) + self.parsed_bus.add(subscriber) return subscriber @@ -106,6 +118,7 @@ async def start_live_telemetry(self): """ Starts the connection to the server to receive live telemetry for this particular device This function is called when we want to initialize a connection to a single device + :return: None """ if self.connection: diff --git a/scadable/facility.py b/scadable/facility.py index 80cc5da..712ef8f 100644 --- a/scadable/facility.py +++ b/scadable/facility.py @@ -1,3 +1,91 @@ +from .device import DeviceManager, Device +from .connection import ConnectionFactory + + class Facility: - def __init__(self, api_key: str): + """ + A class to manage everything related to a Facility + + Instance attributes: + device_manager: DeviceManager, a default one is created if not specified + connection_factory: ConnectionFactory that is used to init long lived connections (e.g. websockets) for live telemetry + """ + + def __init__( + self, + api_key: str, + device_manager: DeviceManager | None = None, + connection_factory: ConnectionFactory | None = None, + ): + """ + Initializes the Facility Class + + :param api_key: API Key associated with the facility + :param device_manager: Optional if you want to specify your own device manager, default is created + :param connection_factory: Optional if you want to specify your own connection factory, otherwise none is specified + """ self._api_key = api_key + + if device_manager: + self.device_manager = device_manager + else: + self.device_manager = DeviceManager() + + if connection_factory: + self.connection_factory = connection_factory + else: + self.connection_factory = None + + def create_device(self, device_id: str, create_connection=False) -> Device: + """ + Creates a device associated with the factory + + :param device_id: Device id to be created + :param create_connection: Whether to create a connection for each device to be used in live telemetry, default is False + :return: Created device + """ + if create_connection: + if self.connection_factory: + conn = self.connection_factory.create_connection( + api_key=self._api_key, device_id=device_id + ) + else: + raise RuntimeError( + "Facility was never initialized with a connection factory" + ) + else: + conn = None + + return self.device_manager.create_device(device_id, conn) + + def create_many_devices( + self, device_ids: list[str], create_connection=False + ) -> list[Device]: + """ + Creates many devices associated with the factory + + :param device_ids: List of device ids to be created + :param create_connection: Whether to create a connection for each device to be used in live telemetry, default is False + :return: List of created devices + """ + return [ + self.create_device(device_id=i, create_connection=create_connection) + for i in device_ids + ] + + def live_telemetry(self, device_ids: list[str] | str): + """ + Decorator to register a function with device id(s) + + :param device_ids: Single device (str) or multiple devices list(str) + :return: Function + """ + if isinstance(device_ids, str): + device_ids = [device_ids] + + def decorator(subscriber): + for d_id in device_ids: + self.device_manager[d_id].live_telemetry(subscriber=subscriber) + return subscriber + + return decorator From b54d1335f3d94d471b137a340e50ac0d8d4b03b9 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Sat, 20 Sep 2025 18:34:11 -0400 Subject: [PATCH 25/32] test: update test cases --- scadable/__init__.py | 2 +- tests/test_connection_type.py | 23 +++++++++++------------ 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/scadable/__init__.py b/scadable/__init__.py index 5d85e48..4867b6e 100644 --- a/scadable/__init__.py +++ b/scadable/__init__.py @@ -1,3 +1,3 @@ -from facility import Facility +from .facility import Facility __all__ = ["Facility"] diff --git a/tests/test_connection_type.py b/tests/test_connection_type.py index 023b8bf..873a2aa 100644 --- a/tests/test_connection_type.py +++ b/tests/test_connection_type.py @@ -1,11 +1,11 @@ import asyncio -from websockets.asyncio import server -from scadable import WebsocketConnection, WebsocketConnectionFactory +from websockets.asyncio import server as WeboscketServer +from scadable.connection import WebsocketConnection, WebsocketConnectionFactory import pytest import pytest_asyncio -async def echo_server(connection: server.ServerConnection): +async def echo_server(connection: WeboscketServer.ServerConnection): async for message in connection: await connection.send(message) @@ -16,7 +16,7 @@ def __init__(self, uri="localhost", port=8765): self.port = port self.handler = echo_server - self.server: server.Server | None = None + self.server: WeboscketServer.Server | None = None def full_uri(self): return f"{self.uri}:{self.port}" @@ -32,7 +32,7 @@ async def start(self): if self.server: await self.stop() - self.server = await server.serve(self._handle, self.uri, self.port) + self.server = await WeboscketServer.serve(self._handle, self.uri, self.port) async def stop(self): if self.server: @@ -50,17 +50,16 @@ async def websocket_server(): @pytest.mark.asyncio async def test_ws_connection_factory(websocket_server): factory = WebsocketConnectionFactory( - dest_uri=websocket_server.full_uri(), api_key="apikey", connection_type="ws" + dest_uri=websocket_server.full_uri(), connection_type="ws" ) assert factory._dest_uri == websocket_server.full_uri() - assert factory.api_key == "apikey" assert factory.connection_type == "ws" - conn = factory.create_connection("deviceid") + conn = factory.create_connection("apikey", "deviceid") assert isinstance(conn, WebsocketConnection) assert ( - conn.dest_uri - == f"ws://{websocket_server.full_uri()}?token=apikey&deviceid=deviceid" + conn.dest_uri + == f"ws://{websocket_server.full_uri()}?token=apikey&deviceid=deviceid" ) @@ -68,9 +67,9 @@ async def test_ws_connection_factory(websocket_server): async def test_connection(websocket_server): await websocket_server.start() factory = WebsocketConnectionFactory( - dest_uri=websocket_server.full_uri(), api_key="apikey", connection_type="ws" + dest_uri=websocket_server.full_uri(), connection_type="ws" ) - conn = factory.create_connection("deviceid") + conn = factory.create_connection("apikey", "deviceid") echo_messages = [] From a86e9d4e9bf7dd926f11d525b97f25709b3c2875 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Sat, 20 Sep 2025 18:47:08 -0400 Subject: [PATCH 26/32] refactor: update typings --- scadable/device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scadable/device.py b/scadable/device.py index 4fba12c..0b69ba8 100644 --- a/scadable/device.py +++ b/scadable/device.py @@ -23,7 +23,7 @@ def __getitem__(self, device_id: str) -> "Device": """ return self.devices[device_id] - def create_device(self, device_id: str, connection: Connection | None): + def create_device(self, device_id: str, connection: Connection | None) -> "Device": """ Creates a device if not already created, otherwise return the already created one From 4cab32e56922f57c0a90a0f0514594f06a03a12b Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Sat, 20 Sep 2025 19:09:01 -0400 Subject: [PATCH 27/32] fix: facility live_telemetry used to throw key error which left some handlers being added and some not, now it just throws runtime error and no handlers are added --- scadable/device.py | 9 +++++++++ scadable/facility.py | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/scadable/device.py b/scadable/device.py index 0b69ba8..d622331 100644 --- a/scadable/device.py +++ b/scadable/device.py @@ -23,6 +23,15 @@ def __getitem__(self, device_id: str) -> "Device": """ return self.devices[device_id] + def __contains__(self, device_id): + """ + Returns whether device id is created + + :param device_id: device id + :return: If device id is created + """ + return device_id in self.devices + def create_device(self, device_id: str, connection: Connection | None) -> "Device": """ Creates a device if not already created, otherwise return the already created one diff --git a/scadable/facility.py b/scadable/facility.py index 712ef8f..c711901 100644 --- a/scadable/facility.py +++ b/scadable/facility.py @@ -84,8 +84,18 @@ def live_telemetry(self, device_ids: list[str] | str): device_ids = [device_ids] def decorator(subscriber): - for d_id in device_ids: - self.device_manager[d_id].live_telemetry(subscriber=subscriber) + if not ( + error_device_ids := [ + i for i in device_ids if i not in self.device_manager + ] + ): + for d_id in device_ids: + self.device_manager[d_id].live_telemetry(subscriber=subscriber) + else: + raise RuntimeError( + f"Device ids: {error_device_ids} were not found in device manager" + ) + return subscriber return decorator From 6858f4595624fd0c24f6d3afddb00baee66f06fe Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Sat, 20 Sep 2025 19:09:16 -0400 Subject: [PATCH 28/32] test: update tests for 100% cv --- tests/mock_connection.py | 22 ++++++++ tests/test_device.py | 98 +++++++++++++++++++++++++++++++++++ tests/test_facility.py | 107 +++++++++++++++++++++++++++++++++++++++ tests/test_live_query.py | 50 ------------------ 4 files changed, 227 insertions(+), 50 deletions(-) create mode 100644 tests/mock_connection.py create mode 100644 tests/test_device.py create mode 100644 tests/test_facility.py delete mode 100644 tests/test_live_query.py diff --git a/tests/mock_connection.py b/tests/mock_connection.py new file mode 100644 index 0000000..1ec56bd --- /dev/null +++ b/tests/mock_connection.py @@ -0,0 +1,22 @@ +from scadable.connection import ConnectionFactory, Connection + + +class TestConnectionFactory(ConnectionFactory): + def create_connection(self, api_key, device_id): + return TestConnection() + + +class TestConnection(Connection): + def __init__(self): + super().__init__() + self.handler = None + + async def connect(self, handler): + self.handler = handler + + async def send_message(self, message): + if self.handler: + await self.handler(message) + + async def stop(self): + pass diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 0000000..653e6cc --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,98 @@ +import pytest +from scadable.device import DeviceManager, Device +from .mock_connection import * + + +@pytest.fixture(scope="function") +def connection_factory(): + factory = TestConnectionFactory() + return factory + + +@pytest.fixture(scope="function") +def device_manager(): + manager = DeviceManager() + return manager + + +def test_device_manager_create_device(device_manager): + # Creates a new device + d1 = device_manager.create_device("abc", None) + assert isinstance(d1, Device) + assert len(device_manager.devices) == 1 + + # Test to see if same device id returns the same + d2 = device_manager.create_device("abc", None) + assert d1 == d2 + assert len(device_manager.devices) == 1 + + +def test_device_manager_get_device(device_manager): + d1 = device_manager.create_device("abc", None) + d2 = device_manager["abc"] + + assert d1 == d2 + + +def test_device_manager_remove_device(device_manager): + # Creates a new device + d1 = device_manager.create_device("abc", None) + + device_manager.remove_device(d1.device_id) + assert len(device_manager.devices) == 0 + + +@pytest.mark.asyncio +async def test_device_raw_telemetry(connection_factory, device_manager): + conn = connection_factory.create_connection("apikey", "abc") + device = device_manager.create_device("abc", conn) + + messages = [] + + # Basic Decorator Usage + @device.raw_live_telemetry + async def handle(m): + messages.append(m) + + await device.start_live_telemetry() + await device.connection.send_message("test") + + assert messages == ["test"] + + +@pytest.mark.asyncio +async def test_device_parsed_telemetry(connection_factory, device_manager): + conn = connection_factory.create_connection("apikey", "abc") + device = device_manager.create_device("abc", conn) + + messages = [] + + # Basic Decorator Usage + @device.live_telemetry + async def handle(m): + messages.append(m) + + await device.start_live_telemetry() + await device.connection.send_message("test") + + assert messages == ["test"] + + +@pytest.mark.asyncio +async def test_device_no_conn(device_manager): + device = device_manager.create_device("abc", None) + + messages = [] + + with pytest.raises(RuntimeError): + @device.raw_live_telemetry + async def handle(m): + messages.append(m) + + with pytest.raises(RuntimeError): + @device.live_telemetry + async def handle(m): + messages.append(m) + + with pytest.raises(RuntimeError): + await device.start_live_telemetry() diff --git a/tests/test_facility.py b/tests/test_facility.py new file mode 100644 index 0000000..65e33ad --- /dev/null +++ b/tests/test_facility.py @@ -0,0 +1,107 @@ +import pytest +import scadable +from scadable.device import DeviceManager +from .mock_connection import * + + +def test_init_default(): + facility = scadable.Facility("apikey") + + assert facility._api_key == "apikey" + assert isinstance(facility.device_manager, DeviceManager) + assert facility.connection_factory is None + + +def test_init_custom(): + dm = DeviceManager() + cf = TestConnectionFactory() + + facility = scadable.Facility("apikey1", dm, cf) + assert facility._api_key == "apikey1" + assert facility.device_manager == dm + assert facility.connection_factory == cf + + +def test_create_device_no_conn(): + facility = scadable.Facility("apikey1") + + dev = facility.create_device("abc") + + assert dev.device_id == "abc" + assert len(facility.device_manager.devices) == 1 + assert facility.device_manager["abc"] == dev + + +def test_create_device_conn(): + dm = DeviceManager() + cf = TestConnectionFactory() + + facility = scadable.Facility("apikey1", dm, cf) + dev = facility.create_device("abc", create_connection=True) + + assert dev.connection is not None + + +def test_create_device_conn_no_conn(): + facility = scadable.Facility("apikey1") + + with pytest.raises(RuntimeError): + facility.create_device("abc", create_connection=True) + + +def test_create_many_devices_no_conn(): + facility = scadable.Facility("apikey1") + + dev = facility.create_many_devices(["abc", "def"]) + + assert len(dev) == 2 + assert dev[0].device_id == "abc" + assert dev[1].device_id == "def" + assert len(facility.device_manager.devices) == 2 + + +def test_create_many_devices_conn(): + dm = DeviceManager() + cf = TestConnectionFactory() + + facility = scadable.Facility("apikey1", dm, cf) + dev = facility.create_many_devices(["mambo", "hachimi"], create_connection=True) + + assert len(dev) == 2 + assert dev[0].connection is not None + assert dev[1].connection is not None + + +def test_create_many_devices_conn_no_conn(): + facility = scadable.Facility("teio") + + with pytest.raises(RuntimeError): + facility.create_many_devices(["mambo", "hachimi"], create_connection=True) + + +def test_live_telemetry_decorator(): + dm = DeviceManager() + cf = TestConnectionFactory() + + facility = scadable.Facility(":3", dm, cf) + + facility.create_many_devices(["mambo", "hachimi"], create_connection=True) + + @facility.live_telemetry("mambo") + def handler(data): + pass + + assert len(facility.device_manager["mambo"].parsed_bus) == 1 + assert len(facility.device_manager["hachimi"].parsed_bus) == 0 + + @facility.live_telemetry(["mambo", "hachimi"]) + def handler1(data): + pass + + assert len(facility.device_manager["mambo"].parsed_bus) == 2 + assert len(facility.device_manager["hachimi"].parsed_bus) == 1 + + with pytest.raises(RuntimeError): + @facility.live_telemetry(["mambo", "hachimi", "eieimun"]) + def handler2(data): + pass diff --git a/tests/test_live_query.py b/tests/test_live_query.py deleted file mode 100644 index cc282bd..0000000 --- a/tests/test_live_query.py +++ /dev/null @@ -1,50 +0,0 @@ -import pytest -from scadable import ConnectionFactory, Connection -from scadable import DeviceManager - - -class TestConnectionFactory(ConnectionFactory): - def create_connection(self, device_id: str): - return TestConnection() - - -class TestConnection(Connection): - def __init__(self): - super().__init__() - self.handler = None - - async def connect(self, handler): - self.handler = handler - - async def send_message(self, message): - if self.handler: - await self.handler(message) - - async def stop(self): - pass - - -@pytest.mark.asyncio -async def test_device_factory(): - device_factory = DeviceManager("apikey", TestConnectionFactory()) - assert device_factory.api_key == "apikey" - device = device_factory.create_device("deviceid") - assert isinstance(device.connection, TestConnection) - - -@pytest.mark.asyncio -async def test_device_connection(): - device_factory = DeviceManager("apikey", TestConnectionFactory()) - device = device_factory.create_device("deviceid") - - messages = [] - - # Basic Decorator Usage - @device.raw_live_telemetry - async def handle(m): - messages.append(m) - - await device.start() - await device.connection.send_message("test") - - assert messages == ["test"] From be0eb5afe27cb0d6e02e7fac28cf956b61cf04b5 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Sat, 20 Sep 2025 19:10:08 -0400 Subject: [PATCH 29/32] refactor: fix typo --- scadable/device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scadable/device.py b/scadable/device.py index d622331..8b9fd2d 100644 --- a/scadable/device.py +++ b/scadable/device.py @@ -115,7 +115,7 @@ def live_telemetry(self, subscriber: Callable[[str], Awaitable]): async def _handle_raw(self, data: str): """ - Internal method to prase raw data and send it to a different bus + Internal method to parse raw data and send it to a different bus :param data: raw data that was received by the connection :return: None """ From 2e9a55c602952c04dda72f4cf0c72eb6573287ed Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Sat, 20 Sep 2025 19:11:18 -0400 Subject: [PATCH 30/32] bug: fix workflow to run on correct files --- .github/workflows/test-project.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-project.yml b/.github/workflows/test-project.yml index 3f07c32..77f1209 100644 --- a/.github/workflows/test-project.yml +++ b/.github/workflows/test-project.yml @@ -51,7 +51,7 @@ jobs: pip install -e .[dev] - name: Run tests run: | - pytest --cov src/scadable --cov-config=.coveragerc --cov-report lcov + pytest --cov scadable --cov-config=.coveragerc --cov-report lcov # - name: Upload test coverage to coveralls.io # uses: coverallsapp/github-action@v2 # with: From 2ad4546c1422dbfcecb84f28553ed9d95ed88b71 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Sat, 20 Sep 2025 19:12:27 -0400 Subject: [PATCH 31/32] bug: fix pytest accidentally testing mock connections --- tests/mock_connection.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/mock_connection.py b/tests/mock_connection.py index 1ec56bd..71232e6 100644 --- a/tests/mock_connection.py +++ b/tests/mock_connection.py @@ -2,11 +2,15 @@ class TestConnectionFactory(ConnectionFactory): + __test__ = False + def create_connection(self, api_key, device_id): return TestConnection() class TestConnection(Connection): + __test__ = False + def __init__(self): super().__init__() self.handler = None From 932c66c10945765e62428d566a89464c8edc9e16 Mon Sep 17 00:00:00 2001 From: Christopher Li <.@.> Date: Sat, 20 Sep 2025 19:14:50 -0400 Subject: [PATCH 32/32] chore: deprecate support for python 3.9 --- .github/workflows/test-project.yml | 2 -- pyproject.toml | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/test-project.yml b/.github/workflows/test-project.yml index 77f1209..f2a48a1 100644 --- a/.github/workflows/test-project.yml +++ b/.github/workflows/test-project.yml @@ -14,8 +14,6 @@ jobs: strategy: matrix: include: - - os: "ubuntu-latest" - python-version: "3.9" - os: "ubuntu-latest" python-version: "3.10" - os: "ubuntu-latest" diff --git a/pyproject.toml b/pyproject.toml index 9368d4c..2de35e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,12 +10,11 @@ authors = [ ] description = "A Python library for scalable and modular software development." readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12',