s2-python 0.0.1__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {s2_python-0.0.1.dist-info → s2_python-0.2.0.dist-info}/METADATA +22 -4
- s2_python-0.2.0.dist-info/RECORD +54 -0
- {s2_python-0.0.1.dist-info → s2_python-0.2.0.dist-info}/WHEEL +1 -1
- s2python/__init__.py +1 -1
- s2python/common/__init__.py +1 -1
- s2python/common/duration.py +5 -5
- s2python/common/handshake.py +5 -5
- s2python/common/handshake_response.py +5 -5
- s2python/common/instruction_status_update.py +6 -10
- s2python/common/number_range.py +17 -21
- s2python/common/power_forecast.py +7 -9
- s2python/common/power_forecast_element.py +9 -10
- s2python/common/power_forecast_value.py +4 -6
- s2python/common/power_measurement.py +7 -7
- s2python/common/power_range.py +11 -13
- s2python/common/power_value.py +4 -4
- s2python/common/reception_status.py +5 -7
- s2python/common/resource_manager_details.py +11 -12
- s2python/common/revoke_object.py +6 -6
- s2python/common/role.py +4 -4
- s2python/common/select_control_type.py +5 -5
- s2python/common/session_request.py +5 -5
- s2python/common/support.py +6 -4
- s2python/common/timer.py +6 -6
- s2python/common/transition.py +12 -14
- s2python/frbc/frbc_actuator_description.py +53 -74
- s2python/frbc/frbc_actuator_status.py +10 -12
- s2python/frbc/frbc_fill_level_target_profile.py +10 -14
- s2python/frbc/frbc_fill_level_target_profile_element.py +7 -9
- s2python/frbc/frbc_instruction.py +8 -8
- s2python/frbc/frbc_leakage_behaviour.py +8 -10
- s2python/frbc/frbc_leakage_behaviour_element.py +6 -8
- s2python/frbc/frbc_operation_mode.py +19 -29
- s2python/frbc/frbc_operation_mode_element.py +11 -13
- s2python/frbc/frbc_storage_description.py +6 -8
- s2python/frbc/frbc_storage_status.py +5 -5
- s2python/frbc/frbc_system_description.py +10 -13
- s2python/frbc/frbc_timer_status.py +7 -7
- s2python/frbc/frbc_usage_forecast.py +7 -9
- s2python/frbc/frbc_usage_forecast_element.py +5 -7
- s2python/frbc/rm.py +0 -0
- s2python/generated/gen_s2.py +1150 -1130
- s2python/reception_status_awaiter.py +60 -0
- s2python/s2_connection.py +470 -0
- s2python/s2_control_type.py +56 -0
- s2python/s2_parser.py +113 -0
- s2python/s2_validation_error.py +7 -5
- s2python/utils.py +7 -2
- s2python/validate_values_mixin.py +32 -89
- s2python/version.py +2 -0
- s2_python-0.0.1.dist-info/RECORD +0 -49
- {s2_python-0.0.1.dist-info → s2_python-0.2.0.dist-info}/entry_points.txt +0 -0
- {s2_python-0.0.1.dist-info → s2_python-0.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,60 @@
|
|
1
|
+
"""ReceptationStatusAwaiter class which notifies any coroutine waiting for a certain reception status message.
|
2
|
+
|
3
|
+
Copied from
|
4
|
+
https://github.com/flexiblepower/s2-analyzer/blob/main/backend/s2_analyzer_backend/reception_status_awaiter.py under
|
5
|
+
Apache2 license on 31-08-2024.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import asyncio
|
9
|
+
import uuid
|
10
|
+
from typing import Dict
|
11
|
+
|
12
|
+
from s2python.common import ReceptionStatus
|
13
|
+
|
14
|
+
|
15
|
+
class ReceptionStatusAwaiter:
|
16
|
+
received: Dict[uuid.UUID, ReceptionStatus]
|
17
|
+
awaiting: Dict[uuid.UUID, asyncio.Event]
|
18
|
+
|
19
|
+
def __init__(self) -> None:
|
20
|
+
self.received = {}
|
21
|
+
self.awaiting = {}
|
22
|
+
|
23
|
+
async def wait_for_reception_status(
|
24
|
+
self, message_id: uuid.UUID, timeout_reception_status: float
|
25
|
+
) -> ReceptionStatus:
|
26
|
+
if message_id in self.received:
|
27
|
+
reception_status = self.received[message_id]
|
28
|
+
else:
|
29
|
+
if message_id in self.awaiting:
|
30
|
+
received_event = self.awaiting[message_id]
|
31
|
+
else:
|
32
|
+
received_event = asyncio.Event()
|
33
|
+
self.awaiting[message_id] = received_event
|
34
|
+
|
35
|
+
await asyncio.wait_for(received_event.wait(), timeout_reception_status)
|
36
|
+
reception_status = self.received[message_id]
|
37
|
+
|
38
|
+
if message_id in self.awaiting:
|
39
|
+
del self.awaiting[message_id]
|
40
|
+
|
41
|
+
return reception_status
|
42
|
+
|
43
|
+
async def receive_reception_status(self, reception_status: ReceptionStatus) -> None:
|
44
|
+
if not isinstance(reception_status, ReceptionStatus):
|
45
|
+
raise RuntimeError(
|
46
|
+
f"Expected a ReceptionStatus but received message {reception_status}"
|
47
|
+
)
|
48
|
+
|
49
|
+
if reception_status.subject_message_id in self.received:
|
50
|
+
raise RuntimeError(
|
51
|
+
f"ReceptationStatus for message_subject_id {reception_status.subject_message_id} has already "
|
52
|
+
f"been received!"
|
53
|
+
)
|
54
|
+
|
55
|
+
self.received[reception_status.subject_message_id] = reception_status
|
56
|
+
awaiting = self.awaiting.get(reception_status.subject_message_id)
|
57
|
+
|
58
|
+
if awaiting:
|
59
|
+
awaiting.set()
|
60
|
+
del self.awaiting[reception_status.subject_message_id]
|
@@ -0,0 +1,470 @@
|
|
1
|
+
import asyncio
|
2
|
+
import json
|
3
|
+
import logging
|
4
|
+
import threading
|
5
|
+
import uuid
|
6
|
+
from dataclasses import dataclass
|
7
|
+
from typing import Optional, List, Type, Dict, Callable, Awaitable, Union
|
8
|
+
|
9
|
+
from websockets.asyncio.client import ClientConnection as WSConnection, connect as ws_connect
|
10
|
+
|
11
|
+
from s2python.common import (
|
12
|
+
ReceptionStatusValues,
|
13
|
+
ReceptionStatus,
|
14
|
+
Handshake,
|
15
|
+
EnergyManagementRole,
|
16
|
+
Role,
|
17
|
+
HandshakeResponse,
|
18
|
+
ResourceManagerDetails,
|
19
|
+
Duration,
|
20
|
+
Currency,
|
21
|
+
SelectControlType,
|
22
|
+
)
|
23
|
+
from s2python.generated.gen_s2 import CommodityQuantity
|
24
|
+
from s2python.reception_status_awaiter import ReceptionStatusAwaiter
|
25
|
+
from s2python.s2_control_type import S2ControlType
|
26
|
+
from s2python.s2_parser import S2Parser
|
27
|
+
from s2python.s2_validation_error import S2ValidationError
|
28
|
+
from s2python.validate_values_mixin import S2Message
|
29
|
+
from s2python.version import S2_VERSION
|
30
|
+
|
31
|
+
logger = logging.getLogger("s2python")
|
32
|
+
|
33
|
+
|
34
|
+
@dataclass
|
35
|
+
class AssetDetails: # pylint: disable=too-many-instance-attributes
|
36
|
+
resource_id: str
|
37
|
+
|
38
|
+
provides_forecast: bool
|
39
|
+
provides_power_measurements: List[CommodityQuantity]
|
40
|
+
|
41
|
+
instruction_processing_delay: Duration
|
42
|
+
roles: List[Role]
|
43
|
+
currency: Optional[Currency] = None
|
44
|
+
|
45
|
+
name: Optional[str] = None
|
46
|
+
manufacturer: Optional[str] = None
|
47
|
+
model: Optional[str] = None
|
48
|
+
firmware_version: Optional[str] = None
|
49
|
+
serial_number: Optional[str] = None
|
50
|
+
|
51
|
+
def to_resource_manager_details(
|
52
|
+
self, control_types: List[S2ControlType]
|
53
|
+
) -> ResourceManagerDetails:
|
54
|
+
return ResourceManagerDetails(
|
55
|
+
available_control_types=[
|
56
|
+
control_type.get_protocol_control_type() for control_type in control_types
|
57
|
+
],
|
58
|
+
currency=self.currency,
|
59
|
+
firmware_version=self.firmware_version,
|
60
|
+
instruction_processing_delay=self.instruction_processing_delay,
|
61
|
+
manufacturer=self.manufacturer,
|
62
|
+
message_id=uuid.uuid4(),
|
63
|
+
model=self.model,
|
64
|
+
name=self.name,
|
65
|
+
provides_forecast=self.provides_forecast,
|
66
|
+
provides_power_measurement_types=self.provides_power_measurements,
|
67
|
+
resource_id=self.resource_id,
|
68
|
+
roles=self.roles,
|
69
|
+
serial_number=self.serial_number,
|
70
|
+
)
|
71
|
+
|
72
|
+
|
73
|
+
S2MessageHandler = Union[
|
74
|
+
Callable[["S2Connection", S2Message, Callable[[], None]], None],
|
75
|
+
Callable[["S2Connection", S2Message, Awaitable[None]], Awaitable[None]],
|
76
|
+
]
|
77
|
+
|
78
|
+
|
79
|
+
class SendOkay:
|
80
|
+
status_is_send: threading.Event
|
81
|
+
connection: "S2Connection"
|
82
|
+
subject_message_id: uuid.UUID
|
83
|
+
|
84
|
+
def __init__(self, connection: "S2Connection", subject_message_id: uuid.UUID):
|
85
|
+
self.status_is_send = threading.Event()
|
86
|
+
self.connection = connection
|
87
|
+
self.subject_message_id = subject_message_id
|
88
|
+
|
89
|
+
async def run_async(self) -> None:
|
90
|
+
self.status_is_send.set()
|
91
|
+
|
92
|
+
await self.connection.respond_with_reception_status(
|
93
|
+
subject_message_id=str(self.subject_message_id),
|
94
|
+
status=ReceptionStatusValues.OK,
|
95
|
+
diagnostic_label="Processed okay.",
|
96
|
+
)
|
97
|
+
|
98
|
+
def run_sync(self) -> None:
|
99
|
+
self.status_is_send.set()
|
100
|
+
|
101
|
+
self.connection.respond_with_reception_status_sync(
|
102
|
+
subject_message_id=str(self.subject_message_id),
|
103
|
+
status=ReceptionStatusValues.OK,
|
104
|
+
diagnostic_label="Processed okay.",
|
105
|
+
)
|
106
|
+
|
107
|
+
async def ensure_send_async(self, type_msg: Type[S2Message]) -> None:
|
108
|
+
if not self.status_is_send.is_set():
|
109
|
+
logger.warning(
|
110
|
+
"Handler for message %s %s did not call send_okay / function to send the ReceptionStatus. "
|
111
|
+
"Sending it now.",
|
112
|
+
type_msg,
|
113
|
+
self.subject_message_id,
|
114
|
+
)
|
115
|
+
await self.run_async()
|
116
|
+
|
117
|
+
def ensure_send_sync(self, type_msg: Type[S2Message]) -> None:
|
118
|
+
if not self.status_is_send.is_set():
|
119
|
+
logger.warning(
|
120
|
+
"Handler for message %s %s did not call send_okay / function to send the ReceptionStatus. "
|
121
|
+
"Sending it now.",
|
122
|
+
type_msg,
|
123
|
+
self.subject_message_id,
|
124
|
+
)
|
125
|
+
self.run_sync()
|
126
|
+
|
127
|
+
|
128
|
+
class MessageHandlers:
|
129
|
+
handlers: Dict[Type[S2Message], S2MessageHandler]
|
130
|
+
|
131
|
+
def __init__(self) -> None:
|
132
|
+
self.handlers = {}
|
133
|
+
|
134
|
+
async def handle_message(self, connection: "S2Connection", msg: S2Message) -> None:
|
135
|
+
"""Handle the S2 message using the registered handler.
|
136
|
+
|
137
|
+
:param connection: The S2 conncetion the `msg` is received from.
|
138
|
+
:param msg: The S2 message
|
139
|
+
"""
|
140
|
+
handler = self.handlers.get(type(msg))
|
141
|
+
if handler is not None:
|
142
|
+
send_okay = SendOkay(connection, msg.message_id) # type: ignore[attr-defined]
|
143
|
+
|
144
|
+
try:
|
145
|
+
if asyncio.iscoroutinefunction(handler):
|
146
|
+
await handler(connection, msg, send_okay.run_async()) # type: ignore[arg-type]
|
147
|
+
await send_okay.ensure_send_async(type(msg))
|
148
|
+
else:
|
149
|
+
|
150
|
+
def do_message() -> None:
|
151
|
+
handler(connection, msg, send_okay.run_sync) # type: ignore[arg-type]
|
152
|
+
send_okay.ensure_send_sync(type(msg))
|
153
|
+
|
154
|
+
eventloop = asyncio.get_event_loop()
|
155
|
+
await eventloop.run_in_executor(executor=None, func=do_message)
|
156
|
+
except Exception:
|
157
|
+
if not send_okay.status_is_send.is_set():
|
158
|
+
await connection.respond_with_reception_status(
|
159
|
+
subject_message_id=str(msg.message_id), # type: ignore[attr-defined]
|
160
|
+
status=ReceptionStatusValues.PERMANENT_ERROR,
|
161
|
+
diagnostic_label=f"While processing message {msg.message_id} " # type: ignore[attr-defined]
|
162
|
+
f"an unrecoverable error occurred.",
|
163
|
+
)
|
164
|
+
raise
|
165
|
+
else:
|
166
|
+
logger.warning(
|
167
|
+
"Received a message of type %s but no handler is registered. Ignoring the message.",
|
168
|
+
type(msg),
|
169
|
+
)
|
170
|
+
|
171
|
+
def register_handler(self, msg_type: Type[S2Message], handler: S2MessageHandler) -> None:
|
172
|
+
"""Register a coroutine function or a normal function as the handler for a specific S2 message type.
|
173
|
+
|
174
|
+
:param msg_type: The S2 message type to attach the handler to.
|
175
|
+
:param handler: The function (asynchronuous or normal) which should handle the S2 message.
|
176
|
+
"""
|
177
|
+
self.handlers[msg_type] = handler
|
178
|
+
|
179
|
+
|
180
|
+
class S2Connection: # pylint: disable=too-many-instance-attributes
|
181
|
+
url: str
|
182
|
+
reception_status_awaiter: ReceptionStatusAwaiter
|
183
|
+
ws: Optional[WSConnection]
|
184
|
+
s2_parser: S2Parser
|
185
|
+
control_types: List[S2ControlType]
|
186
|
+
role: EnergyManagementRole
|
187
|
+
asset_details: AssetDetails
|
188
|
+
|
189
|
+
_thread: threading.Thread
|
190
|
+
|
191
|
+
_handlers: MessageHandlers
|
192
|
+
_current_control_type: Optional[S2ControlType]
|
193
|
+
_received_messages: asyncio.Queue
|
194
|
+
|
195
|
+
_eventloop: asyncio.AbstractEventLoop
|
196
|
+
_background_tasks: Optional[asyncio.Task]
|
197
|
+
_stop_event: asyncio.Event
|
198
|
+
|
199
|
+
def __init__(
|
200
|
+
self,
|
201
|
+
url: str,
|
202
|
+
role: EnergyManagementRole,
|
203
|
+
control_types: List[S2ControlType],
|
204
|
+
asset_details: AssetDetails,
|
205
|
+
) -> None:
|
206
|
+
self.url = url
|
207
|
+
self.reception_status_awaiter = ReceptionStatusAwaiter()
|
208
|
+
self.s2_parser = S2Parser()
|
209
|
+
|
210
|
+
self._handlers = MessageHandlers()
|
211
|
+
self._current_control_type = None
|
212
|
+
|
213
|
+
self._eventloop = asyncio.new_event_loop()
|
214
|
+
self._background_tasks = None
|
215
|
+
|
216
|
+
self.control_types = control_types
|
217
|
+
self.role = role
|
218
|
+
self.asset_details = asset_details
|
219
|
+
|
220
|
+
self._handlers.register_handler(SelectControlType, self.handle_select_control_type_as_rm)
|
221
|
+
self._handlers.register_handler(Handshake, self.handle_handshake)
|
222
|
+
self._handlers.register_handler(HandshakeResponse, self.handle_handshake_response_as_rm)
|
223
|
+
|
224
|
+
def start_as_rm(self) -> None:
|
225
|
+
self._thread = threading.Thread(target=self._run_eventloop)
|
226
|
+
self._thread.start()
|
227
|
+
logger.debug("Started eventloop thread!")
|
228
|
+
|
229
|
+
def _run_eventloop(self) -> None:
|
230
|
+
logger.debug("Starting eventloop")
|
231
|
+
self._eventloop.run_until_complete(self._run_as_rm())
|
232
|
+
|
233
|
+
def stop(self) -> None:
|
234
|
+
"""Stops the S2 connection.
|
235
|
+
|
236
|
+
Note: Ensure this method is called from a different thread than the thread running the S2 connection.
|
237
|
+
Otherwise it will block waiting on the coroutine _do_stop to terminate successfully but it can't run
|
238
|
+
the coroutine. A `RuntimeError` will be raised to prevent the indefinite block.
|
239
|
+
"""
|
240
|
+
if threading.current_thread() == self._thread:
|
241
|
+
raise RuntimeError(
|
242
|
+
"Do not call stop from the thread running the S2 connection. This results in an "
|
243
|
+
"infinite block!"
|
244
|
+
)
|
245
|
+
|
246
|
+
asyncio.run_coroutine_threadsafe(self._do_stop(), self._eventloop).result()
|
247
|
+
|
248
|
+
async def _do_stop(self) -> None:
|
249
|
+
logger.info("Will stop the S2 connection.")
|
250
|
+
if self._background_tasks:
|
251
|
+
self._background_tasks.cancel()
|
252
|
+
self._background_tasks = None
|
253
|
+
|
254
|
+
if self.ws:
|
255
|
+
await self.ws.close()
|
256
|
+
await self.ws.wait_closed()
|
257
|
+
|
258
|
+
async def _run_as_rm(self) -> None:
|
259
|
+
logger.debug("Connecting as S2 resource manager.")
|
260
|
+
self._received_messages = asyncio.Queue()
|
261
|
+
await self.connect_ws()
|
262
|
+
|
263
|
+
self._background_tasks = self._eventloop.create_task(
|
264
|
+
asyncio.wait(
|
265
|
+
(self._receive_messages(), self._handle_received_messages()),
|
266
|
+
return_when=asyncio.FIRST_EXCEPTION,
|
267
|
+
)
|
268
|
+
)
|
269
|
+
|
270
|
+
await self.connect_as_rm()
|
271
|
+
done: List[asyncio.Task]
|
272
|
+
pending: List[asyncio.Task]
|
273
|
+
(done, pending) = await self._background_tasks
|
274
|
+
|
275
|
+
for task in done:
|
276
|
+
task.result()
|
277
|
+
|
278
|
+
for task in pending:
|
279
|
+
task.cancel()
|
280
|
+
|
281
|
+
async def connect_ws(self) -> None:
|
282
|
+
self.ws = await ws_connect(uri=self.url)
|
283
|
+
|
284
|
+
async def connect_as_rm(self) -> None:
|
285
|
+
await self.send_msg_and_await_reception_status_async(
|
286
|
+
Handshake(
|
287
|
+
message_id=uuid.uuid4(), role=self.role, supported_protocol_versions=[S2_VERSION]
|
288
|
+
)
|
289
|
+
)
|
290
|
+
logger.debug("Send handshake to CEM. Expecting Handshake and HandshakeResponse from CEM.")
|
291
|
+
|
292
|
+
async def handle_handshake(
|
293
|
+
self, _: "S2Connection", message: S2Message, send_okay: Awaitable[None]
|
294
|
+
) -> None:
|
295
|
+
if not isinstance(message, Handshake):
|
296
|
+
logger.error(
|
297
|
+
"Handler for Handshake received a message of the wrong type: %s", type(message)
|
298
|
+
)
|
299
|
+
return
|
300
|
+
|
301
|
+
logger.debug(
|
302
|
+
"%s supports S2 protocol versions: %s",
|
303
|
+
message.role,
|
304
|
+
message.supported_protocol_versions,
|
305
|
+
)
|
306
|
+
await send_okay
|
307
|
+
|
308
|
+
async def handle_handshake_response_as_rm(
|
309
|
+
self, _: "S2Connection", message: S2Message, send_okay: Awaitable[None]
|
310
|
+
) -> None:
|
311
|
+
if not isinstance(message, HandshakeResponse):
|
312
|
+
logger.error(
|
313
|
+
"Handler for HandshakeResponse received a message of the wrong type: %s",
|
314
|
+
type(message),
|
315
|
+
)
|
316
|
+
return
|
317
|
+
|
318
|
+
logger.debug("Received HandshakeResponse %s", message.to_json())
|
319
|
+
|
320
|
+
logger.debug("CEM selected to use version %s", message.selected_protocol_version)
|
321
|
+
await send_okay
|
322
|
+
logger.debug("Handshake complete. Sending first ResourceManagerDetails.")
|
323
|
+
|
324
|
+
await self.send_msg_and_await_reception_status_async(
|
325
|
+
self.asset_details.to_resource_manager_details(self.control_types)
|
326
|
+
)
|
327
|
+
|
328
|
+
async def handle_select_control_type_as_rm(
|
329
|
+
self, _: "S2Connection", message: S2Message, send_okay: Awaitable[None]
|
330
|
+
) -> None:
|
331
|
+
if not isinstance(message, SelectControlType):
|
332
|
+
logger.error(
|
333
|
+
"Handler for SelectControlType received a message of the wrong type: %s",
|
334
|
+
type(message),
|
335
|
+
)
|
336
|
+
return
|
337
|
+
|
338
|
+
await send_okay
|
339
|
+
|
340
|
+
logger.debug("CEM selected control type %s. Activating control type.", message.control_type)
|
341
|
+
|
342
|
+
control_types_by_protocol_name = {
|
343
|
+
c.get_protocol_control_type(): c for c in self.control_types
|
344
|
+
}
|
345
|
+
selected_control_type: Optional[S2ControlType] = control_types_by_protocol_name.get(
|
346
|
+
message.control_type
|
347
|
+
)
|
348
|
+
|
349
|
+
if self._current_control_type is not None:
|
350
|
+
await self._eventloop.run_in_executor(None, self._current_control_type.deactivate, self)
|
351
|
+
|
352
|
+
self._current_control_type = selected_control_type
|
353
|
+
|
354
|
+
if self._current_control_type is not None:
|
355
|
+
await self._eventloop.run_in_executor(None, self._current_control_type.activate, self)
|
356
|
+
self._current_control_type.register_handlers(self._handlers)
|
357
|
+
|
358
|
+
async def _receive_messages(self) -> None:
|
359
|
+
"""Receives all incoming messages in the form of a generator.
|
360
|
+
|
361
|
+
Will also receive the ReceptionStatus messages but instead of yielding these messages, they are routed
|
362
|
+
to any calls of `send_msg_and_await_reception_status`.
|
363
|
+
"""
|
364
|
+
if self.ws is None:
|
365
|
+
raise RuntimeError(
|
366
|
+
"Cannot receive messages if websocket connection is not yet established."
|
367
|
+
)
|
368
|
+
|
369
|
+
logger.info("S2 connection has started to receive messages.")
|
370
|
+
|
371
|
+
async for message in self.ws:
|
372
|
+
try:
|
373
|
+
s2_msg: S2Message = self.s2_parser.parse_as_any_message(message)
|
374
|
+
except json.JSONDecodeError:
|
375
|
+
await self._send_and_forget(
|
376
|
+
ReceptionStatus(
|
377
|
+
subject_message_id="00000000-0000-0000-0000-000000000000",
|
378
|
+
status=ReceptionStatusValues.INVALID_DATA,
|
379
|
+
diagnostic_label="Not valid json.",
|
380
|
+
)
|
381
|
+
)
|
382
|
+
except S2ValidationError as e:
|
383
|
+
json_msg = json.loads(message)
|
384
|
+
message_id = json_msg.get("message_id")
|
385
|
+
if message_id:
|
386
|
+
await self.respond_with_reception_status(
|
387
|
+
subject_message_id=message_id,
|
388
|
+
status=ReceptionStatusValues.INVALID_MESSAGE,
|
389
|
+
diagnostic_label=str(e),
|
390
|
+
)
|
391
|
+
else:
|
392
|
+
await self.respond_with_reception_status(
|
393
|
+
subject_message_id="00000000-0000-0000-0000-000000000000",
|
394
|
+
status=ReceptionStatusValues.INVALID_DATA,
|
395
|
+
diagnostic_label="Message appears valid json but could not find a message_id field.",
|
396
|
+
)
|
397
|
+
else:
|
398
|
+
logger.debug("Received message %s", s2_msg.to_json())
|
399
|
+
|
400
|
+
if isinstance(s2_msg, ReceptionStatus):
|
401
|
+
logger.debug(
|
402
|
+
"Message is a reception status for %s so registering in cache.",
|
403
|
+
s2_msg.subject_message_id,
|
404
|
+
)
|
405
|
+
await self.reception_status_awaiter.receive_reception_status(s2_msg)
|
406
|
+
else:
|
407
|
+
await self._received_messages.put(s2_msg)
|
408
|
+
|
409
|
+
async def _send_and_forget(self, s2_msg: S2Message) -> None:
|
410
|
+
if self.ws is None:
|
411
|
+
raise RuntimeError(
|
412
|
+
"Cannot send messages if websocket connection is not yet established."
|
413
|
+
)
|
414
|
+
|
415
|
+
json_msg = s2_msg.to_json()
|
416
|
+
logger.debug("Sending message %s", json_msg)
|
417
|
+
await self.ws.send(json_msg)
|
418
|
+
|
419
|
+
async def respond_with_reception_status(
|
420
|
+
self, subject_message_id: str, status: ReceptionStatusValues, diagnostic_label: str
|
421
|
+
) -> None:
|
422
|
+
logger.debug("Responding to message %s with status %s", subject_message_id, status)
|
423
|
+
await self._send_and_forget(
|
424
|
+
ReceptionStatus(
|
425
|
+
subject_message_id=subject_message_id,
|
426
|
+
status=status,
|
427
|
+
diagnostic_label=diagnostic_label,
|
428
|
+
)
|
429
|
+
)
|
430
|
+
|
431
|
+
def respond_with_reception_status_sync(
|
432
|
+
self, subject_message_id: str, status: ReceptionStatusValues, diagnostic_label: str
|
433
|
+
) -> None:
|
434
|
+
asyncio.run_coroutine_threadsafe(
|
435
|
+
self.respond_with_reception_status(subject_message_id, status, diagnostic_label),
|
436
|
+
self._eventloop,
|
437
|
+
).result()
|
438
|
+
|
439
|
+
async def send_msg_and_await_reception_status_async(
|
440
|
+
self, s2_msg: S2Message, timeout_reception_status: float = 5.0, raise_on_error: bool = True
|
441
|
+
) -> ReceptionStatus:
|
442
|
+
await self._send_and_forget(s2_msg)
|
443
|
+
logger.debug(
|
444
|
+
"Waiting for ReceptionStatus for %s %s seconds",
|
445
|
+
s2_msg.message_id, # type: ignore[attr-defined]
|
446
|
+
timeout_reception_status,
|
447
|
+
)
|
448
|
+
reception_status = await self.reception_status_awaiter.wait_for_reception_status(
|
449
|
+
s2_msg.message_id, timeout_reception_status # type: ignore[attr-defined]
|
450
|
+
)
|
451
|
+
|
452
|
+
if reception_status.status != ReceptionStatusValues.OK and raise_on_error:
|
453
|
+
raise RuntimeError(f"ReceptionStatus was not OK but rather {reception_status.status}")
|
454
|
+
|
455
|
+
return reception_status
|
456
|
+
|
457
|
+
def send_msg_and_await_reception_status_sync(
|
458
|
+
self, s2_msg: S2Message, timeout_reception_status: float = 5.0, raise_on_error: bool = True
|
459
|
+
) -> ReceptionStatus:
|
460
|
+
return asyncio.run_coroutine_threadsafe(
|
461
|
+
self.send_msg_and_await_reception_status_async(
|
462
|
+
s2_msg, timeout_reception_status, raise_on_error
|
463
|
+
),
|
464
|
+
self._eventloop,
|
465
|
+
).result()
|
466
|
+
|
467
|
+
async def _handle_received_messages(self) -> None:
|
468
|
+
while True:
|
469
|
+
msg = await self._received_messages.get()
|
470
|
+
await self._handlers.handle_message(self, msg)
|
@@ -0,0 +1,56 @@
|
|
1
|
+
import abc
|
2
|
+
import typing
|
3
|
+
|
4
|
+
from s2python.common import ControlType as ProtocolControlType
|
5
|
+
from s2python.frbc import FRBCInstruction
|
6
|
+
from s2python.validate_values_mixin import S2Message
|
7
|
+
|
8
|
+
if typing.TYPE_CHECKING:
|
9
|
+
from s2python.s2_connection import S2Connection, MessageHandlers
|
10
|
+
|
11
|
+
|
12
|
+
class S2ControlType(abc.ABC):
|
13
|
+
@abc.abstractmethod
|
14
|
+
def get_protocol_control_type(self) -> ProtocolControlType: ...
|
15
|
+
|
16
|
+
@abc.abstractmethod
|
17
|
+
def register_handlers(self, handlers: "MessageHandlers") -> None: ...
|
18
|
+
|
19
|
+
@abc.abstractmethod
|
20
|
+
def activate(self, conn: "S2Connection") -> None: ...
|
21
|
+
|
22
|
+
@abc.abstractmethod
|
23
|
+
def deactivate(self, conn: "S2Connection") -> None: ...
|
24
|
+
|
25
|
+
|
26
|
+
class FRBCControlType(S2ControlType):
|
27
|
+
def get_protocol_control_type(self) -> ProtocolControlType:
|
28
|
+
return ProtocolControlType.FILL_RATE_BASED_CONTROL
|
29
|
+
|
30
|
+
def register_handlers(self, handlers: "MessageHandlers") -> None:
|
31
|
+
handlers.register_handler(FRBCInstruction, self.handle_instruction)
|
32
|
+
|
33
|
+
@abc.abstractmethod
|
34
|
+
def handle_instruction(
|
35
|
+
self, conn: "S2Connection", msg: S2Message, send_okay: typing.Callable[[], None]
|
36
|
+
) -> None: ...
|
37
|
+
|
38
|
+
@abc.abstractmethod
|
39
|
+
def activate(self, conn: "S2Connection") -> None: ...
|
40
|
+
|
41
|
+
@abc.abstractmethod
|
42
|
+
def deactivate(self, conn: "S2Connection") -> None: ...
|
43
|
+
|
44
|
+
|
45
|
+
class NoControlControlType(S2ControlType):
|
46
|
+
def get_protocol_control_type(self) -> ProtocolControlType:
|
47
|
+
return ProtocolControlType.NOT_CONTROLABLE
|
48
|
+
|
49
|
+
def register_handlers(self, handlers: "MessageHandlers") -> None:
|
50
|
+
pass
|
51
|
+
|
52
|
+
@abc.abstractmethod
|
53
|
+
def activate(self, conn: "S2Connection") -> None: ...
|
54
|
+
|
55
|
+
@abc.abstractmethod
|
56
|
+
def deactivate(self, conn: "S2Connection") -> None: ...
|
s2python/s2_parser.py
ADDED
@@ -0,0 +1,113 @@
|
|
1
|
+
import json
|
2
|
+
import logging
|
3
|
+
from typing import Optional, TypeVar, Union, Type, Dict
|
4
|
+
|
5
|
+
from s2python.common import (
|
6
|
+
Handshake,
|
7
|
+
HandshakeResponse,
|
8
|
+
InstructionStatusUpdate,
|
9
|
+
PowerForecast,
|
10
|
+
PowerMeasurement,
|
11
|
+
ReceptionStatus,
|
12
|
+
ResourceManagerDetails,
|
13
|
+
RevokeObject,
|
14
|
+
SelectControlType,
|
15
|
+
SessionRequest,
|
16
|
+
)
|
17
|
+
from s2python.frbc import (
|
18
|
+
FRBCActuatorStatus,
|
19
|
+
FRBCFillLevelTargetProfile,
|
20
|
+
FRBCInstruction,
|
21
|
+
FRBCLeakageBehaviour,
|
22
|
+
FRBCStorageStatus,
|
23
|
+
FRBCSystemDescription,
|
24
|
+
FRBCTimerStatus,
|
25
|
+
FRBCUsageForecast,
|
26
|
+
)
|
27
|
+
from s2python.validate_values_mixin import S2Message
|
28
|
+
from s2python.s2_validation_error import S2ValidationError
|
29
|
+
|
30
|
+
|
31
|
+
LOGGER = logging.getLogger(__name__)
|
32
|
+
S2MessageType = str
|
33
|
+
|
34
|
+
M = TypeVar("M", bound=S2Message)
|
35
|
+
|
36
|
+
|
37
|
+
# May be generated with development_utilities/generate_s2_message_type_to_class.py
|
38
|
+
TYPE_TO_MESSAGE_CLASS: Dict[str, Type[S2Message]] = {
|
39
|
+
"FRBC.ActuatorStatus": FRBCActuatorStatus,
|
40
|
+
"FRBC.FillLevelTargetProfile": FRBCFillLevelTargetProfile,
|
41
|
+
"FRBC.Instruction": FRBCInstruction,
|
42
|
+
"FRBC.LeakageBehaviour": FRBCLeakageBehaviour,
|
43
|
+
"FRBC.StorageStatus": FRBCStorageStatus,
|
44
|
+
"FRBC.SystemDescription": FRBCSystemDescription,
|
45
|
+
"FRBC.TimerStatus": FRBCTimerStatus,
|
46
|
+
"FRBC.UsageForecast": FRBCUsageForecast,
|
47
|
+
"Handshake": Handshake,
|
48
|
+
"HandshakeResponse": HandshakeResponse,
|
49
|
+
"InstructionStatusUpdate": InstructionStatusUpdate,
|
50
|
+
"PowerForecast": PowerForecast,
|
51
|
+
"PowerMeasurement": PowerMeasurement,
|
52
|
+
"ReceptionStatus": ReceptionStatus,
|
53
|
+
"ResourceManagerDetails": ResourceManagerDetails,
|
54
|
+
"RevokeObject": RevokeObject,
|
55
|
+
"SelectControlType": SelectControlType,
|
56
|
+
"SessionRequest": SessionRequest,
|
57
|
+
}
|
58
|
+
|
59
|
+
|
60
|
+
class S2Parser:
|
61
|
+
@staticmethod
|
62
|
+
def _parse_json_if_required(unparsed_message: Union[dict, str, bytes]) -> dict:
|
63
|
+
if isinstance(unparsed_message, (str, bytes)):
|
64
|
+
return json.loads(unparsed_message)
|
65
|
+
return unparsed_message
|
66
|
+
|
67
|
+
@staticmethod
|
68
|
+
def parse_as_any_message(unparsed_message: Union[dict, str, bytes]) -> S2Message:
|
69
|
+
"""Parse the message as any S2 python message regardless of message type.
|
70
|
+
|
71
|
+
:param unparsed_message: The message as a JSON-formatted string or as a json-parsed dictionary.
|
72
|
+
:raises: S2ValidationError, json.JSONDecodeError
|
73
|
+
:return: The parsed S2 message if no errors were found.
|
74
|
+
"""
|
75
|
+
message_json = S2Parser._parse_json_if_required(unparsed_message)
|
76
|
+
message_type = S2Parser.parse_message_type(message_json)
|
77
|
+
|
78
|
+
if message_type not in TYPE_TO_MESSAGE_CLASS:
|
79
|
+
raise S2ValidationError(
|
80
|
+
None,
|
81
|
+
message_json,
|
82
|
+
f"Unable to parse {message_type} as an S2 message. Type unknown.",
|
83
|
+
None,
|
84
|
+
)
|
85
|
+
|
86
|
+
return TYPE_TO_MESSAGE_CLASS[message_type].model_validate(message_json)
|
87
|
+
|
88
|
+
@staticmethod
|
89
|
+
def parse_as_message(unparsed_message: Union[dict, str, bytes], as_message: Type[M]) -> M:
|
90
|
+
"""Parse the message to a specific S2 python message.
|
91
|
+
|
92
|
+
:param unparsed_message: The message as a JSON-formatted string or as a JSON-parsed dictionary.
|
93
|
+
:param as_message: The type of message that is expected within the `message`
|
94
|
+
:raises: S2ValidationError, json.JSONDecodeError
|
95
|
+
:return: The parsed S2 message if no errors were found.
|
96
|
+
"""
|
97
|
+
message_json = S2Parser._parse_json_if_required(unparsed_message)
|
98
|
+
return as_message.from_dict(message_json)
|
99
|
+
|
100
|
+
@staticmethod
|
101
|
+
def parse_message_type(unparsed_message: Union[dict, str, bytes]) -> Optional[S2MessageType]:
|
102
|
+
"""Parse only the message type from the unparsed message.
|
103
|
+
|
104
|
+
This is useful to call before `parse_as_message` to retrieve the message type and allows for strictly-typed
|
105
|
+
parsing.
|
106
|
+
|
107
|
+
:param unparsed_message: The message as a JSON-formatted string or as a JSON-parsed dictionary.
|
108
|
+
:raises: json.JSONDecodeError
|
109
|
+
:return: The parsed S2 message type if no errors were found.
|
110
|
+
"""
|
111
|
+
message_json = S2Parser._parse_json_if_required(unparsed_message)
|
112
|
+
|
113
|
+
return message_json.get("message_type")
|