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.
Files changed (53) hide show
  1. {s2_python-0.0.1.dist-info → s2_python-0.2.0.dist-info}/METADATA +22 -4
  2. s2_python-0.2.0.dist-info/RECORD +54 -0
  3. {s2_python-0.0.1.dist-info → s2_python-0.2.0.dist-info}/WHEEL +1 -1
  4. s2python/__init__.py +1 -1
  5. s2python/common/__init__.py +1 -1
  6. s2python/common/duration.py +5 -5
  7. s2python/common/handshake.py +5 -5
  8. s2python/common/handshake_response.py +5 -5
  9. s2python/common/instruction_status_update.py +6 -10
  10. s2python/common/number_range.py +17 -21
  11. s2python/common/power_forecast.py +7 -9
  12. s2python/common/power_forecast_element.py +9 -10
  13. s2python/common/power_forecast_value.py +4 -6
  14. s2python/common/power_measurement.py +7 -7
  15. s2python/common/power_range.py +11 -13
  16. s2python/common/power_value.py +4 -4
  17. s2python/common/reception_status.py +5 -7
  18. s2python/common/resource_manager_details.py +11 -12
  19. s2python/common/revoke_object.py +6 -6
  20. s2python/common/role.py +4 -4
  21. s2python/common/select_control_type.py +5 -5
  22. s2python/common/session_request.py +5 -5
  23. s2python/common/support.py +6 -4
  24. s2python/common/timer.py +6 -6
  25. s2python/common/transition.py +12 -14
  26. s2python/frbc/frbc_actuator_description.py +53 -74
  27. s2python/frbc/frbc_actuator_status.py +10 -12
  28. s2python/frbc/frbc_fill_level_target_profile.py +10 -14
  29. s2python/frbc/frbc_fill_level_target_profile_element.py +7 -9
  30. s2python/frbc/frbc_instruction.py +8 -8
  31. s2python/frbc/frbc_leakage_behaviour.py +8 -10
  32. s2python/frbc/frbc_leakage_behaviour_element.py +6 -8
  33. s2python/frbc/frbc_operation_mode.py +19 -29
  34. s2python/frbc/frbc_operation_mode_element.py +11 -13
  35. s2python/frbc/frbc_storage_description.py +6 -8
  36. s2python/frbc/frbc_storage_status.py +5 -5
  37. s2python/frbc/frbc_system_description.py +10 -13
  38. s2python/frbc/frbc_timer_status.py +7 -7
  39. s2python/frbc/frbc_usage_forecast.py +7 -9
  40. s2python/frbc/frbc_usage_forecast_element.py +5 -7
  41. s2python/frbc/rm.py +0 -0
  42. s2python/generated/gen_s2.py +1150 -1130
  43. s2python/reception_status_awaiter.py +60 -0
  44. s2python/s2_connection.py +470 -0
  45. s2python/s2_control_type.py +56 -0
  46. s2python/s2_parser.py +113 -0
  47. s2python/s2_validation_error.py +7 -5
  48. s2python/utils.py +7 -2
  49. s2python/validate_values_mixin.py +32 -89
  50. s2python/version.py +2 -0
  51. s2_python-0.0.1.dist-info/RECORD +0 -49
  52. {s2_python-0.0.1.dist-info → s2_python-0.2.0.dist-info}/entry_points.txt +0 -0
  53. {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")