Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
58d6e74
refactor: remove dummy lines
Sep 18, 2025
eaf601f
chore: add websockets as a dependency
Sep 18, 2025
46cfec2
feat: add basic device framework
Sep 18, 2025
ab8cb4a
feat: add graceful stops, update import statement
Sep 18, 2025
83e907e
refactor: dest_url -> dest_uri
Sep 18, 2025
a989bc1
bug: fix import paths
Sep 18, 2025
e8cace0
feat: abstract connections out for easier testing
Sep 18, 2025
2a40dd3
refactor: ignore abstract functions for coverage
Sep 18, 2025
998e7c6
fix: make ws just close on error
Sep 18, 2025
0866480
fix: remove catch since we aren't actually going to be restarting the…
Sep 18, 2025
1b140cf
tests: test ws connection
Sep 18, 2025
e267cab
docs: docstring changes
Sep 18, 2025
fea802c
bug: infinite recursion on raw message
Sep 18, 2025
8aa03d0
test: add test for live_telemetry
Sep 18, 2025
497eb2d
docs: add docstrings
Sep 18, 2025
f66e2e6
chore: update websockets dependency
Sep 18, 2025
70a9a30
bug: fix weird refactoring causing file diff
Sep 18, 2025
ea8f355
refactor: update connection factory to not store the api key
Sep 20, 2025
4900b21
refactor: move connection out
Sep 20, 2025
dceb71f
refactor: move files around
Sep 20, 2025
ed357da
refactor: update docstrings
Sep 20, 2025
8e2a76e
bug: throw error when no connection was specified during init
Sep 20, 2025
42e664b
feat: add generic live_telemetry function
Sep 20, 2025
aeeec7c
feat: update facility and add docs
Sep 20, 2025
b54d133
test: update test cases
Sep 20, 2025
a86e9d4
refactor: update typings
Sep 20, 2025
4cab32e
fix: facility live_telemetry used to throw key error which left some …
Sep 20, 2025
6858f45
test: update tests for 100% cv
Sep 20, 2025
be0eb5a
refactor: fix typo
Sep 20, 2025
2e9a55c
bug: fix workflow to run on correct files
Sep 20, 2025
2ad4546
bug: fix pytest accidentally testing mock connections
Sep 20, 2025
932c66c
chore: deprecate support for python 3.9
Sep 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .github/workflows/test-project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -51,7 +49,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:
Expand Down
11 changes: 8 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,37 @@ 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',
'Programming Language :: Python :: 3.13',
"Operating System :: OS Independent",
]
license = "Apache-2.0"
license-files = [ "LICENSE" ]
license-files = ["LICENSE"]
dependencies = [
"websockets >= 13.0"
]

[project.optional-dependencies]
dev = [
"pytest",
"coverage",
"pytest-cov",
"pytest-asyncio",
"pre-commit"
]


[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"]
3 changes: 3 additions & 0 deletions scadable/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .facility import Facility

__all__ = ["Facility"]
132 changes: 132 additions & 0 deletions scadable/connection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
from websockets.asyncio import client
from websockets.asyncio.client import ClientConnection
from typing import Callable, Awaitable


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, 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
"""
raise NotImplementedError


class WebsocketConnectionFactory(ConnectionFactory):
"""
A factory that creates websocket connections.

Instance Attributes:
connection_type: ws or wss depending on the connection type
"""

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

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={api_key}&deviceid={device_id}"
)


class Connection: # pragma: no cover
"""
Abstract Connection

A generic connection that the device uses to send and receive messages.

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
: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):
"""
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

async def connect(self, handler: Callable[[str], Awaitable]):
"""
Starts the websocket connection to the server to receive data
:return: None
"""
async with client.connect(self.dest_uri) as ws:
self._ws = ws
async for message in ws:
await handler(message)

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
"""
if self._ws:
await self._ws.close()
138 changes: 138 additions & 0 deletions scadable/device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import asyncio
from typing import Callable, Awaitable, Any
from .connection import Connection


class DeviceManager:
"""
A class to manage created Devices

Instance attributes:
devices: dict that maps deviceid->Device
"""

def __init__(self):
self.devices: dict[str, Device] = {}

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 __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

:param device_id: ID of the device we connect to
: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:
device = Device(device_id=device_id, connection=connection)
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:
"""
A class that represents a single device

Instance attributes:
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):
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()
# 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]):
"""
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

async def _handle_raw(self, data: str):
"""
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
"""
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):
"""
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:
await self.connection.connect(self._handle_raw)
else:
raise RuntimeError(
f"No connection was specified for device {self.device_id}"
)
Loading
Loading