Skip to content

Commit ec18084

Browse files
authored
Merge pull request #15 from scadable/9/live-service-auth
Live Service
2 parents 52a4798 + 932c66c commit ec18084

12 files changed

Lines changed: 701 additions & 8 deletions

File tree

.github/workflows/test-project.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ jobs:
1414
strategy:
1515
matrix:
1616
include:
17-
- os: "ubuntu-latest"
18-
python-version: "3.9"
1917
- os: "ubuntu-latest"
2018
python-version: "3.10"
2119
- os: "ubuntu-latest"
@@ -51,7 +49,7 @@ jobs:
5149
pip install -e .[dev]
5250
- name: Run tests
5351
run: |
54-
pytest --cov src/scadable --cov-config=.coveragerc --cov-report lcov
52+
pytest --cov scadable --cov-config=.coveragerc --cov-report lcov
5553
# - name: Upload test coverage to coveralls.io
5654
# uses: coverallsapp/github-action@v2
5755
# with:

pyproject.toml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,32 +10,37 @@ authors = [
1010
]
1111
description = "A Python library for scalable and modular software development."
1212
readme = "README.md"
13-
requires-python = ">=3.9"
13+
requires-python = ">=3.10"
1414
classifiers = [
1515
'Programming Language :: Python',
1616
'Programming Language :: Python :: 3',
1717
'Programming Language :: Python :: 3 :: Only',
18-
'Programming Language :: Python :: 3.9',
1918
'Programming Language :: Python :: 3.10',
2019
'Programming Language :: Python :: 3.11',
2120
'Programming Language :: Python :: 3.12',
2221
'Programming Language :: Python :: 3.13',
2322
"Operating System :: OS Independent",
2423
]
2524
license = "Apache-2.0"
26-
license-files = [ "LICENSE" ]
25+
license-files = ["LICENSE"]
2726
dependencies = [
27+
"websockets >= 13.0"
2828
]
2929

3030
[project.optional-dependencies]
3131
dev = [
3232
"pytest",
3333
"coverage",
3434
"pytest-cov",
35+
"pytest-asyncio",
3536
"pre-commit"
3637
]
3738

3839

3940
[project.urls]
4041
"Homepage" = "https://github.com/scadable/library-python"
4142
"Bug Tracker" = "https://github.com/scadable/library-python/issues"
43+
44+
[tool.ruff]
45+
# Ignore all tests
46+
extend-exclude = ["tests"]

scadable/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .facility import Facility
2+
3+
__all__ = ["Facility"]

scadable/connection.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
from websockets.asyncio import client
2+
from websockets.asyncio.client import ClientConnection
3+
from typing import Callable, Awaitable
4+
5+
6+
class ConnectionFactory: # pragma: no cover
7+
"""
8+
Abstract Connection Factory
9+
10+
These factories create 'Connection' types which live_telemetry will use. This is passed into the DeviceFactory
11+
which creates devices that we can subscribe to.
12+
13+
We expose create_connection(device_id) so we can pass in an instance of any type of factory
14+
"""
15+
16+
def create_connection(self, api_key: str, device_id: str) -> "Connection":
17+
"""
18+
Creates a connection to a device
19+
:param api_key: API key of the connection you want to create
20+
:param device_id: Device Id of the device
21+
:return: Connection
22+
"""
23+
raise NotImplementedError
24+
25+
26+
class WebsocketConnectionFactory(ConnectionFactory):
27+
"""
28+
A factory that creates websocket connections.
29+
30+
Instance Attributes:
31+
connection_type: ws or wss depending on the connection type
32+
"""
33+
34+
def __init__(self, dest_uri: str, connection_type="wss"):
35+
"""
36+
Init for a Websocket Factory
37+
:param dest_uri: Destination URI of the websocket
38+
:param connection_type: Connection type (wss or ws)
39+
"""
40+
self.connection_type = connection_type
41+
self._dest_uri = dest_uri
42+
43+
def create_connection(self, api_key: str, device_id: str) -> "Connection":
44+
"""
45+
Creates a connection to a device
46+
:param api_key: API key of the connection you want to create
47+
:param device_id: Device Id of the device
48+
:return: WebsocketConnection
49+
"""
50+
return WebsocketConnection(
51+
f"{self.connection_type}://{self._dest_uri}?token={api_key}&deviceid={device_id}"
52+
)
53+
54+
55+
class Connection: # pragma: no cover
56+
"""
57+
Abstract Connection
58+
59+
A generic connection that the device uses to send and receive messages.
60+
61+
We expose:
62+
- connect(func)
63+
- send_message(str)
64+
- stop()
65+
to interact with the connection.
66+
"""
67+
68+
async def connect(self, handler: Callable[[str], Awaitable]):
69+
"""
70+
Connects to a server
71+
:param handler: Function that handles messages
72+
:return: None
73+
"""
74+
raise NotImplementedError
75+
76+
async def send_message(self, message: str):
77+
"""
78+
Sends a message through the connection
79+
:param message: Message to be sent
80+
:return: None
81+
"""
82+
raise NotImplementedError
83+
84+
async def stop(self):
85+
"""
86+
Ends the connection
87+
:return: None
88+
"""
89+
raise NotImplementedError
90+
91+
92+
class WebsocketConnection(Connection):
93+
"""
94+
A class representing a Websocket Connection
95+
96+
Instance Attributes:
97+
dest_uri: full uri of the destination, e.g. wss://localhost:8765&apikey=a&deviceid=b
98+
"""
99+
100+
def __init__(self, dest_uri: str):
101+
super().__init__()
102+
self.dest_uri = dest_uri
103+
self._ws: ClientConnection | None = None
104+
105+
async def connect(self, handler: Callable[[str], Awaitable]):
106+
"""
107+
Starts the websocket connection to the server to receive data
108+
:return: None
109+
"""
110+
async with client.connect(self.dest_uri) as ws:
111+
self._ws = ws
112+
async for message in ws:
113+
await handler(message)
114+
115+
self._ws = None
116+
117+
async def send_message(self, message: str):
118+
"""
119+
Sends a message to the websocket connection
120+
:param message: Message to send
121+
:return:
122+
"""
123+
if self._ws:
124+
await self._ws.send(message)
125+
126+
async def stop(self):
127+
"""
128+
Ends the websocket connection to the server gracefully
129+
:return: None
130+
"""
131+
if self._ws:
132+
await self._ws.close()

scadable/device.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import asyncio
2+
from typing import Callable, Awaitable, Any
3+
from .connection import Connection
4+
5+
6+
class DeviceManager:
7+
"""
8+
A class to manage created Devices
9+
10+
Instance attributes:
11+
devices: dict that maps deviceid->Device
12+
"""
13+
14+
def __init__(self):
15+
self.devices: dict[str, Device] = {}
16+
17+
def __getitem__(self, device_id: str) -> "Device":
18+
"""
19+
Returns a device from a device id
20+
21+
:param device_id: device id
22+
:return: Device with associated device id
23+
"""
24+
return self.devices[device_id]
25+
26+
def __contains__(self, device_id):
27+
"""
28+
Returns whether device id is created
29+
30+
:param device_id: device id
31+
:return: If device id is created
32+
"""
33+
return device_id in self.devices
34+
35+
def create_device(self, device_id: str, connection: Connection | None) -> "Device":
36+
"""
37+
Creates a device if not already created, otherwise return the already created one
38+
39+
:param device_id: ID of the device we connect to
40+
:param connection: The connection that should be used for live_telemetry
41+
:return: Created device
42+
"""
43+
if device_id in self.devices:
44+
device = self.devices[device_id]
45+
else:
46+
device = Device(device_id=device_id, connection=connection)
47+
self.devices[device_id] = device
48+
49+
return device
50+
51+
def remove_device(self, device_id: str):
52+
"""
53+
Removes a device from our manager
54+
55+
:param device_id: Device Id to remove
56+
:return:
57+
"""
58+
if device_id in self.devices:
59+
device = self.devices[device_id] # noqa
60+
# TODO: figure out what to do here
61+
del self.devices[device_id]
62+
63+
64+
class Device:
65+
"""
66+
A class that represents a single device
67+
68+
Instance attributes:
69+
connection: connection that the device will read messages from
70+
device_id: device id of the device
71+
raw_bus: set of subscribed handlers that will be called when receiving a raw response
72+
parsed_bus: set of subscribed handlers that will be called after parsing a response
73+
"""
74+
75+
def __init__(self, device_id: str, connection: Connection | None):
76+
self.connection = connection
77+
self.device_id = device_id
78+
79+
# Bus that contains all functions that handle raw data
80+
self.raw_bus: set[Callable[[str], Awaitable[Any]]] = set()
81+
# Bus that contains all functions that handle parsed data
82+
self.parsed_bus: set[Callable[[str], Awaitable[Any]]] = set()
83+
84+
def raw_live_telemetry(self, subscriber: Callable[[str], Awaitable]):
85+
"""
86+
Decorator that adds a function to our bus
87+
Throws an error if no connection was specified
88+
89+
:param subscriber: Function that subscribes to raw data
90+
:return: subscriber
91+
"""
92+
if not self.connection:
93+
raise RuntimeError(
94+
f"No connection was specified for device {self.device_id}"
95+
)
96+
97+
self.raw_bus.add(subscriber)
98+
return subscriber
99+
100+
def live_telemetry(self, subscriber: Callable[[str], Awaitable]):
101+
"""
102+
Decorator that adds a function to our bus
103+
Throws an error if no connection was specified
104+
105+
:param subscriber: Function that subscribes to raw data
106+
:return: subscriber
107+
"""
108+
if not self.connection:
109+
raise RuntimeError(
110+
f"No connection was specified for device {self.device_id}"
111+
)
112+
113+
self.parsed_bus.add(subscriber)
114+
return subscriber
115+
116+
async def _handle_raw(self, data: str):
117+
"""
118+
Internal method to parse raw data and send it to a different bus
119+
:param data: raw data that was received by the connection
120+
:return: None
121+
"""
122+
await asyncio.gather(*[s(data) for s in self.raw_bus])
123+
# TODO: parse data
124+
await asyncio.gather(*[s(data) for s in self.parsed_bus])
125+
126+
async def start_live_telemetry(self):
127+
"""
128+
Starts the connection to the server to receive live telemetry for this particular device
129+
This function is called when we want to initialize a connection to a single device
130+
131+
:return: None
132+
"""
133+
if self.connection:
134+
await self.connection.connect(self._handle_raw)
135+
else:
136+
raise RuntimeError(
137+
f"No connection was specified for device {self.device_id}"
138+
)

0 commit comments

Comments
 (0)