cremalink 0.1.0b5__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 (47) hide show
  1. cremalink/__init__.py +33 -0
  2. cremalink/clients/__init__.py +10 -0
  3. cremalink/clients/cloud.py +130 -0
  4. cremalink/core/__init__.py +6 -0
  5. cremalink/core/binary.py +102 -0
  6. cremalink/crypto/__init__.py +142 -0
  7. cremalink/devices/AY008ESP1.json +114 -0
  8. cremalink/devices/__init__.py +116 -0
  9. cremalink/domain/__init__.py +11 -0
  10. cremalink/domain/device.py +245 -0
  11. cremalink/domain/factory.py +98 -0
  12. cremalink/local_server.py +76 -0
  13. cremalink/local_server_app/__init__.py +20 -0
  14. cremalink/local_server_app/api.py +272 -0
  15. cremalink/local_server_app/config.py +64 -0
  16. cremalink/local_server_app/device_adapter.py +96 -0
  17. cremalink/local_server_app/jobs.py +104 -0
  18. cremalink/local_server_app/logging.py +116 -0
  19. cremalink/local_server_app/models.py +76 -0
  20. cremalink/local_server_app/protocol.py +135 -0
  21. cremalink/local_server_app/state.py +358 -0
  22. cremalink/parsing/__init__.py +7 -0
  23. cremalink/parsing/monitor/__init__.py +22 -0
  24. cremalink/parsing/monitor/decode.py +79 -0
  25. cremalink/parsing/monitor/extractors.py +69 -0
  26. cremalink/parsing/monitor/frame.py +132 -0
  27. cremalink/parsing/monitor/model.py +42 -0
  28. cremalink/parsing/monitor/profile.py +144 -0
  29. cremalink/parsing/monitor/view.py +196 -0
  30. cremalink/parsing/properties/__init__.py +9 -0
  31. cremalink/parsing/properties/decode.py +53 -0
  32. cremalink/resources/__init__.py +10 -0
  33. cremalink/resources/api_config.json +14 -0
  34. cremalink/resources/api_config.py +30 -0
  35. cremalink/resources/lang.json +223 -0
  36. cremalink/transports/__init__.py +7 -0
  37. cremalink/transports/base.py +94 -0
  38. cremalink/transports/cloud/__init__.py +9 -0
  39. cremalink/transports/cloud/transport.py +166 -0
  40. cremalink/transports/local/__init__.py +9 -0
  41. cremalink/transports/local/transport.py +164 -0
  42. cremalink-0.1.0b5.dist-info/METADATA +138 -0
  43. cremalink-0.1.0b5.dist-info/RECORD +47 -0
  44. cremalink-0.1.0b5.dist-info/WHEEL +5 -0
  45. cremalink-0.1.0b5.dist-info/entry_points.txt +2 -0
  46. cremalink-0.1.0b5.dist-info/licenses/LICENSE +661 -0
  47. cremalink-0.1.0b5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,76 @@
1
+ """
2
+ This module defines the Pydantic data models used for API request and response
3
+ validation in the local server application. These models ensure type safety
4
+ and clear contracts for the API endpoints.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Dict, Optional
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+
13
+ class ConfigureRequest(BaseModel):
14
+ """
15
+ Model for the `/configure` endpoint request body.
16
+ It contains all the necessary information to establish a connection
17
+ with a local device.
18
+ """
19
+ dsn: str
20
+ device_ip: str
21
+ lan_key: str
22
+ device_scheme: str = Field("https", description="The protocol scheme, e.g., 'http' or 'https'.")
23
+ monitor_property_name: str | None = None
24
+
25
+
26
+ class CommandRequest(BaseModel):
27
+ """Model for the `/command` endpoint request body."""
28
+ command: str
29
+
30
+
31
+ class KeyExchange(BaseModel):
32
+ """
33
+ Represents the data payload for the key exchange process, which is
34
+ part of the authentication handshake with the device.
35
+ """
36
+ random_1: str
37
+ time_1: str | int
38
+
39
+
40
+ class KeyExchangeRequest(BaseModel):
41
+ """Model for a key exchange request, wrapping the KeyExchange payload."""
42
+ key_exchange: KeyExchange
43
+
44
+
45
+ class EncPayload(BaseModel):
46
+ """A generic model for a payload that contains encrypted data (`enc`)."""
47
+ enc: str
48
+
49
+
50
+ class CommandPollResponse(BaseModel):
51
+ """
52
+ Model for the response when polling for a command result from the device.
53
+ It includes the encrypted response, a signature, and a sequence number.
54
+ """
55
+ enc: str
56
+ sign: str
57
+ seq: int
58
+
59
+
60
+ class MonitorResponse(BaseModel):
61
+ """
62
+ Model for the response from the `/get_monitor` endpoint.
63
+ Includes the parsed monitor data, the raw base64 string, and a timestamp.
64
+ """
65
+ monitor: Dict[str, Any] | Any | None = None
66
+ monitor_b64: Optional[str] = None
67
+ received_at: Optional[float] = None
68
+
69
+
70
+ class PropertiesResponse(BaseModel):
71
+ """
72
+ Model for the response from the `/get_properties` endpoint.
73
+ Includes the dictionary of properties and a timestamp.
74
+ """
75
+ properties: Dict[str, Any] = Field(default_factory=dict)
76
+ received_at: Optional[float] = None
@@ -0,0 +1,135 @@
1
+ """
2
+ This module implements the low-level cryptographic protocol for communicating
3
+ with the De'Longhi device over the local network. It handles session key
4
+ derivation, payload encryption/decryption, and message signing.
5
+ """
6
+ import base64
7
+ import json
8
+ from typing import Tuple
9
+
10
+ from cremalink.crypto import (
11
+ aes_decrypt, aes_encrypt, extract_bits, hmac_for_key_and_data,
12
+ rotate_iv_from_ciphertext
13
+ )
14
+
15
+
16
+ def pad_seq(seq: int) -> str:
17
+ """Pads the sequence number as a string (currently a no-op)."""
18
+ return str(seq)
19
+
20
+
21
+ def derive_keys(
22
+ lan_key: str, random_1: str, random_2: str, time_1: str, time_2: str
23
+ ) -> Tuple[bytes, bytes, bytes, bytes, bytes]:
24
+ """
25
+ Derives all necessary session keys from the initial key exchange parameters.
26
+
27
+ The key derivation process is a specific, non-standard protocol that uses
28
+ a series of HMAC-SHA256 operations on concatenated inputs (random values,
29
+ timestamps, and a final byte that varies for each key type). This creates
30
+ unique keys for signing, client-side encryption, and server-side encryption.
31
+
32
+ Args:
33
+ lan_key: The main secret key for the device on the LAN.
34
+ random_1: The random value from the device (client).
35
+ random_2: The random value from this server (host).
36
+ time_1: The timestamp from the device (client).
37
+ time_2: The timestamp from this server (host).
38
+
39
+ Returns:
40
+ A tuple containing the five derived keys:
41
+ (app_sign_key, app_crypto_key, app_iv_seed, dev_crypto_key, dev_iv_seed)
42
+ """
43
+ rnd_1s = random_1.encode("utf-8")
44
+ rnd_2s = random_2.encode("utf-8")
45
+ time_1s = str(time_1).encode("utf-8")
46
+ time_2s = str(time_2).encode("utf-8")
47
+ lan_key_bytes = lan_key.encode("utf-8")
48
+
49
+ # --- Application (Client-Side) Keys ---
50
+
51
+ # 1. Application Signing Key
52
+ lastbyte = b"\x30"
53
+ concat = rnd_1s + rnd_2s + time_1s + time_2s + lastbyte
54
+ app_sign_key = hmac_for_key_and_data(
55
+ lan_key_bytes, hmac_for_key_and_data(lan_key_bytes, concat) + concat
56
+ )
57
+
58
+ # 2. Application Encryption Key
59
+ lastbyte = b"\x31"
60
+ concat = rnd_1s + rnd_2s + time_1s + time_2s + lastbyte
61
+ app_crypto_key = hmac_for_key_and_data(
62
+ lan_key_bytes, hmac_for_key_and_data(lan_key_bytes, concat) + concat
63
+ )
64
+
65
+ # 3. Application IV Seed (for AES-CBC)
66
+ lastbyte = b"\x32"
67
+ concat = rnd_1s + rnd_2s + time_1s + time_2s + lastbyte
68
+ app_iv_seed = extract_bits(
69
+ hmac_for_key_and_data(lan_key_bytes, hmac_for_key_and_data(lan_key_bytes, concat) + concat),
70
+ 0,
71
+ 16 * 2, # Extract 16 bytes (32 hex chars)
72
+ )
73
+
74
+ # --- Device (Server-Side) Keys ---
75
+ # Note the reversed order of randoms and timestamps.
76
+
77
+ # 4. Device Encryption Key
78
+ lastbyte = b"\x31"
79
+ concat = rnd_2s + rnd_1s + time_2s + time_1s + lastbyte
80
+ dev_crypto_key = hmac_for_key_and_data(
81
+ lan_key_bytes, hmac_for_key_and_data(lan_key_bytes, concat) + concat
82
+ )
83
+
84
+ # 5. Device IV Seed (for AES-CBC)
85
+ lastbyte = b"\x32"
86
+ concat = rnd_2s + rnd_1s + time_2s + time_1s + lastbyte
87
+ dev_iv_seed = extract_bits(
88
+ hmac_for_key_and_data(lan_key_bytes, hmac_for_key_and_data(lan_key_bytes, concat) + concat),
89
+ 0,
90
+ 16 * 2, # Extract 16 bytes (32 hex chars)
91
+ )
92
+
93
+ return app_sign_key, app_crypto_key, app_iv_seed, dev_crypto_key, dev_iv_seed
94
+
95
+
96
+ def encrypt_payload(payload: str, crypto_key: bytes, iv_seed: bytes) -> Tuple[str, bytes]:
97
+ """
98
+ Encrypts a payload string using AES-CBC and returns the new IV.
99
+
100
+ The IV for the next encryption is derived from the ciphertext of the current one.
101
+
102
+ Returns:
103
+ A tuple containing the base64-encoded ciphertext and the next IV.
104
+ """
105
+ enc = aes_encrypt(payload, crypto_key, iv_seed)
106
+ new_iv = rotate_iv_from_ciphertext(enc)
107
+ return enc, new_iv
108
+
109
+
110
+ def decrypt_payload(enc: str, crypto_key: bytes, iv_seed: bytes) -> Tuple[bytes, bytes]:
111
+ """
112
+ Decrypts a base64-encoded ciphertext and returns the new IV.
113
+
114
+ The IV for the next decryption is derived from the ciphertext of the current one.
115
+
116
+ Returns:
117
+ A tuple containing the decrypted plaintext (bytes) and the next IV.
118
+ """
119
+ decrypted = aes_decrypt(enc, crypto_key, iv_seed)
120
+ new_iv = rotate_iv_from_ciphertext(enc)
121
+ return decrypted, new_iv
122
+
123
+
124
+ def sign_payload(payload: str, sign_key: bytes) -> str:
125
+ """
126
+ Signs a payload string using HMAC-SHA256 and returns the base64-encoded signature.
127
+ """
128
+ return base64.b64encode(hmac_for_key_and_data(sign_key, payload.encode("utf-8"))).decode("utf-8")
129
+
130
+
131
+ def build_empty_payload(seq: int) -> str:
132
+ """
133
+ Creates a JSON string for an empty command payload, used as a heartbeat.
134
+ """
135
+ return json.dumps({"seq_no": pad_seq(seq), "data": {}}, separators=(",", ":"))
@@ -0,0 +1,358 @@
1
+ """
2
+ This module defines the state management for the local server application.
3
+ It centralizes all runtime data, including device configuration, cryptographic
4
+ keys, command queues, and the latest received device data.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import base64
10
+ import json
11
+ import os
12
+ import time
13
+ from collections import deque
14
+ from typing import TYPE_CHECKING, Any, Deque, Dict, Optional
15
+
16
+ from cremalink.local_server_app import protocol
17
+ from cremalink.local_server_app.logging import redact
18
+
19
+ if TYPE_CHECKING:
20
+ from cremalink.local_server_app.config import ServerSettings
21
+
22
+
23
+ class LocalServerState:
24
+ """
25
+ Manages the runtime state of the local server in a thread-safe manner.
26
+
27
+ This class acts as a state machine, holding all information related to the
28
+ device connection, including credentials, cryptographic session keys,
29
+ pending commands, and the most recent data snapshots (monitor and properties).
30
+ An asyncio.Lock is used to prevent race conditions when accessing state
31
+ from different asynchronous tasks.
32
+ """
33
+ def __init__(self, settings: ServerSettings, logger):
34
+ """
35
+ Initializes the state with default values.
36
+
37
+ Args:
38
+ settings: The application's configuration settings.
39
+ logger: The application's logger instance.
40
+ """
41
+ self.settings = settings
42
+ self.logger = logger
43
+ # --- Device Configuration ---
44
+ self.dsn: Optional[str] = None
45
+ self.device_ip: Optional[str] = None
46
+ self.device_scheme: str = "https"
47
+ self.lan_key: Optional[str] = None
48
+ # --- Session & Command State ---
49
+ self.seq: int = 0
50
+ self.command_queue: Deque[str] = deque()
51
+ self.command_payload: str = protocol.build_empty_payload(self.seq)
52
+ self.last_command: Optional[str] = None
53
+ self.registered: bool = False
54
+
55
+ # --- Cryptographic Keys & IVs ---
56
+ self.app_sign_key: Optional[bytes] = None
57
+ self.app_crypto_key: Optional[bytes] = None
58
+ self.app_iv_seed: Optional[bytes] = None
59
+ self.dev_crypto_key: Optional[bytes] = None
60
+ self.dev_iv_seed: Optional[bytes] = None
61
+
62
+ # --- Key Exchange Parameters ---
63
+ self.random_2: str = self._generate_random_2()
64
+ self.time_2: str = self._generate_time_2()
65
+
66
+ # --- Data Snapshots ---
67
+ self.last_monitor: Dict[str, Any] | dict = {}
68
+ self.last_monitor_raw: Dict[str, Any] = {}
69
+ self.last_monitor_b64: Optional[str] = None
70
+ self.last_monitor_received_at: Optional[float] = None
71
+ self.last_properties: Dict[str, Any] = {}
72
+ self.last_properties_received_at: Optional[float] = None
73
+ self._monitor_request_pending = False
74
+ self._properties_request_pending = False
75
+ self.monitor_property_name: str = None
76
+
77
+ # --- Concurrency Control ---
78
+ self.lock = asyncio.Lock()
79
+
80
+ # --- helpers ---
81
+ def _generate_random_2(self) -> str:
82
+ """Generates the server-side random value for key exchange."""
83
+ if self.settings.fixed_random_2:
84
+ return self.settings.fixed_random_2
85
+ return base64.b64encode(os.urandom(12)).decode("utf-8")
86
+
87
+ def _generate_time_2(self) -> str:
88
+ """Generates the server-side timestamp for key exchange."""
89
+ if self.settings.fixed_time_2:
90
+ return self.settings.fixed_time_2
91
+ return str(int(time.time() * 1000))
92
+
93
+ # --- lifecycle ---
94
+ async def configure(
95
+ self,
96
+ dsn: str,
97
+ device_ip: str,
98
+ lan_key: str,
99
+ device_scheme: str = "https",
100
+ monitor_property_name: Optional[str] = None,
101
+ ) -> None:
102
+ """
103
+ Configures the state with new device details and resets the session.
104
+ If the device details are unchanged, this is a no-op.
105
+ """
106
+ resolved_monitor_property = monitor_property_name
107
+ async with self.lock:
108
+ # Check if configuration is for the same device and keys are already set up.
109
+ same_device = (
110
+ self.dsn == dsn
111
+ and self.device_ip == device_ip
112
+ and self.lan_key == lan_key
113
+ and self.monitor_property_name == resolved_monitor_property
114
+ and self.app_crypto_key
115
+ and self.dev_crypto_key
116
+ )
117
+ if same_device:
118
+ self.logger.info("configure noop", extra={"details": {"dsn": dsn, "device_ip": device_ip}})
119
+ return
120
+
121
+ # Reset the entire state for the new device configuration.
122
+ self.dsn = dsn
123
+ self.device_ip = device_ip
124
+ self.device_scheme = device_scheme or "https"
125
+ self.lan_key = lan_key
126
+ self.monitor_property_name = resolved_monitor_property
127
+ self.seq = 0
128
+ self.command_queue = deque()
129
+ self.command_payload = protocol.build_empty_payload(self.seq)
130
+ self.app_sign_key = None
131
+ self.app_crypto_key = None
132
+ self.app_iv_seed = None
133
+ self.dev_crypto_key = None
134
+ self.dev_iv_seed = None
135
+ self.random_2 = self._generate_random_2()
136
+ self.time_2 = self._generate_time_2()
137
+ self.registered = False
138
+ self.last_monitor = {}
139
+ self.last_monitor_raw = {}
140
+ self.last_monitor_b64 = None
141
+ self.last_monitor_received_at = None
142
+ self._monitor_request_pending = False
143
+ self.last_properties = {}
144
+ self.last_properties_received_at = None
145
+ self._properties_request_pending = False
146
+ self.logger.info("configured", extra={"details": {"dsn": dsn, "device_ip": device_ip, "scheme": device_scheme}})
147
+
148
+ async def rekey(self) -> None:
149
+ """
150
+ Resets cryptographic keys and session state to force a new key exchange.
151
+ """
152
+ async with self.lock:
153
+ self.app_sign_key = None
154
+ self.app_crypto_key = None
155
+ self.app_iv_seed = None
156
+ self.dev_crypto_key = None
157
+ self.dev_iv_seed = None
158
+ self.random_2 = self._generate_random_2()
159
+ self.time_2 = self._generate_time_2()
160
+ self.seq = 0
161
+ self.command_queue = deque()
162
+ self.command_payload = protocol.build_empty_payload(self.seq)
163
+ self._monitor_request_pending = False
164
+ self._properties_request_pending = False
165
+ self.registered = False
166
+ self.logger.info("rekey_reset")
167
+
168
+ async def init_crypto(self, random_1: str, time_1: str | int) -> None:
169
+ """
170
+ Derives and initializes all session keys using values from the key exchange.
171
+ """
172
+ if not self.lan_key:
173
+ raise ValueError("LAN key not set; configure server first")
174
+
175
+ (
176
+ app_sign_key,
177
+ app_crypto_key,
178
+ app_iv_seed,
179
+ dev_crypto_key,
180
+ dev_iv_seed,
181
+ ) = protocol.derive_keys(self.lan_key, random_1, self.random_2, str(time_1), str(self.time_2))
182
+
183
+ async with self.lock:
184
+ self.app_sign_key = app_sign_key
185
+ self.app_crypto_key = app_crypto_key
186
+ self.app_iv_seed = app_iv_seed
187
+ self.dev_crypto_key = dev_crypto_key
188
+ self.dev_iv_seed = dev_iv_seed
189
+ self.seq = 0
190
+ self.command_payload = protocol.build_empty_payload(self.seq)
191
+ self.logger.info("crypto_init", extra={"details": redact({"app_crypto_key": True, "dev_crypto_key": True})})
192
+
193
+ # --- state queries ---
194
+ def is_configured(self) -> bool:
195
+ """Returns True if the server has been configured with device details."""
196
+ return bool(self.dsn and self.device_ip and self.lan_key)
197
+
198
+ def keys_ready(self) -> bool:
199
+ """Returns True if the cryptographic session keys have been derived."""
200
+ return bool(self.app_crypto_key and self.app_iv_seed and self.app_sign_key)
201
+
202
+ # --- command queue ---
203
+ async def queue_command(self, command: str) -> None:
204
+ """Adds a high-level device command to the outgoing queue."""
205
+ if not self.is_configured():
206
+ raise ValueError("Server not configured")
207
+ payload = {
208
+ "seq_no": protocol.pad_seq(self.seq),
209
+ "data": {
210
+ "properties": [
211
+ {
212
+ "property": {
213
+ "base_type": "string",
214
+ "dsn": self.dsn,
215
+ "name": "data_request",
216
+ "value": f"{command}\n",
217
+ }
218
+ }
219
+ ]
220
+ },
221
+ }
222
+ async with self.lock:
223
+ if len(self.command_queue) >= self.settings.queue_max_size:
224
+ raise OverflowError("Command queue is full")
225
+ payload_str = json.dumps(payload, separators=(",", ":"))
226
+ self.command_queue.append(payload_str)
227
+ self.last_command = command
228
+ self.logger.info("queue_command", extra={"details": {"command": command}})
229
+
230
+ async def queue_monitor(self) -> None:
231
+ """Adds a request for the device's monitoring status to the queue."""
232
+ if not self.is_configured():
233
+ return
234
+ monitor_cmd = {
235
+ "cmds": [
236
+ {
237
+ "cmd": {
238
+ "cmd_id": 1,
239
+ "data": "",
240
+ "method": "GET",
241
+ "resource": f"property.json?name={self.monitor_property_name}",
242
+ "uri": "/local_lan/property/datapoint.json",
243
+ }
244
+ }
245
+ ]
246
+ }
247
+ async with self.lock:
248
+ if self._monitor_request_pending:
249
+ return
250
+ self.command_queue.append(json.dumps({"seq_no": protocol.pad_seq(self.seq), "data": monitor_cmd}, separators=(",", ":")))
251
+ self._monitor_request_pending = True
252
+ self.logger.info("queue_monitor")
253
+
254
+ async def queue_properties(self) -> None:
255
+ """Adds a request for all device properties to the queue."""
256
+ if not self.is_configured():
257
+ return
258
+ properties_cmd = {
259
+ "cmds": [
260
+ {
261
+ "cmd": {
262
+ "cmd_id": 1,
263
+ "data": "",
264
+ "method": "GET",
265
+ "resource": "property.json?name=''",
266
+ "uri": "/local_lan/property/datapoint.json",
267
+ }
268
+ }
269
+ ]
270
+ }
271
+ async with self.lock:
272
+ if self._properties_request_pending:
273
+ return
274
+ self.command_queue.append(
275
+ json.dumps({"seq_no": protocol.pad_seq(self.seq), "data": properties_cmd}, separators=(",", ":"))
276
+ )
277
+ self._properties_request_pending = True
278
+ self.logger.info("queue_properties")
279
+
280
+ async def next_command_payload(self) -> Dict[str, Any]:
281
+ """
282
+ Retrieves the next command from the queue for sending to the device.
283
+ If the queue is empty, it returns an empty "heartbeat" payload.
284
+ """
285
+ async with self.lock:
286
+ if self.command_queue:
287
+ payload = self.command_queue.popleft()
288
+ else:
289
+ payload = protocol.build_empty_payload(self.seq)
290
+ current_seq = self.seq
291
+ self.seq += 1
292
+ return {"payload": payload, "seq": current_seq}
293
+
294
+ async def set_registered(self, value: bool) -> None:
295
+ async with self.lock:
296
+ self.registered = value
297
+
298
+ # --- datapoints ---
299
+ async def handle_datapoint(self, decrypted_json: dict) -> None:
300
+ """
301
+ Processes a decrypted data payload from the device, updating the
302
+ appropriate data snapshot (properties or monitor).
303
+ """
304
+ data_block = decrypted_json.get("data", {})
305
+ async with self.lock:
306
+ if "properties" in data_block:
307
+ self.last_properties = data_block["properties"]
308
+ self.last_properties_received_at = time.time()
309
+ self._properties_request_pending = False
310
+ self.logger.info("properties_datapoint", extra={"details": {"count": len(data_block['properties'])}})
311
+ return
312
+
313
+ monitor_value = data_block.get("value")
314
+ if monitor_value:
315
+ self.last_monitor = {"raw_value_len": len(monitor_value)}
316
+ self.last_monitor_b64 = monitor_value
317
+ self.last_monitor_raw = decrypted_json
318
+ self.last_monitor_received_at = time.time()
319
+ self._monitor_request_pending = False
320
+ self.logger.info("monitor_datapoint", extra={"details": {"raw_value_len": len(monitor_value)}})
321
+ else:
322
+ self.last_monitor = decrypted_json
323
+ self.last_monitor_raw = decrypted_json
324
+ self.last_monitor_b64 = None
325
+ self.last_monitor_received_at = time.time()
326
+ self._monitor_request_pending = False
327
+ self.logger.info("monitor_datapoint", extra={"details": {"monitor_keys": list(data_block.keys())}})
328
+
329
+ # --- snapshots ---
330
+ async def snapshot_monitor(self) -> Dict[str, Any]:
331
+ """Returns the latest monitoring data snapshot."""
332
+ async with self.lock:
333
+ monitor_payload = self.last_monitor_raw or self.last_monitor or {}
334
+ return {
335
+ "monitor": monitor_payload,
336
+ "monitor_b64": self.last_monitor_b64,
337
+ "received_at": self.last_monitor_received_at,
338
+ }
339
+
340
+ async def snapshot_properties(self) -> Dict[str, Any]:
341
+ """Returns the latest properties data snapshot."""
342
+ async with self.lock:
343
+ return {"properties": self.last_properties, "received_at": self.last_properties_received_at}
344
+
345
+ async def get_property_value(self, property_name: str) -> Optional[Any]:
346
+ """Retrieves a single property value from the last known snapshot."""
347
+ async with self.lock:
348
+ if property_name in self.last_properties:
349
+ return self.last_properties[property_name]
350
+ for entry in self.last_properties.values():
351
+ if isinstance(entry, dict) and entry.get("property", {}).get("name") == property_name:
352
+ return entry
353
+ return None
354
+
355
+ # --- logging helper ---
356
+ def log(self, event: str, details: Optional[dict] = None) -> None:
357
+ """Convenience method for logging with redacted details."""
358
+ self.logger.info(event, extra={"details": redact(details)})
@@ -0,0 +1,7 @@
1
+ """
2
+ This package contains all modules related to parsing and decoding data
3
+ received from the coffee machine.
4
+
5
+ Sub-packages like `monitor` and `properties` handle the specific formats
6
+ for different types of device data.
7
+ """
@@ -0,0 +1,22 @@
1
+ """
2
+ This package handles the parsing and decoding of the device's 'monitor' data.
3
+
4
+ The monitor data is a compact binary payload that represents the real-time
5
+ status of the coffee machine, including its current state, any active alarms,
6
+ and the progress of ongoing actions. This package provides the tools to decode
7
+ this binary data into a structured and human-readable format.
8
+ """
9
+ from cremalink.parsing.monitor.decode import build_monitor_snapshot, decode_monitor_b64
10
+ from cremalink.parsing.monitor.frame import MonitorFrame
11
+ from cremalink.parsing.monitor.model import MonitorSnapshot
12
+ from cremalink.parsing.monitor.profile import MonitorProfile
13
+ from cremalink.parsing.monitor.view import MonitorView
14
+
15
+ __all__ = [
16
+ "build_monitor_snapshot",
17
+ "decode_monitor_b64",
18
+ "MonitorSnapshot",
19
+ "MonitorView",
20
+ "MonitorProfile",
21
+ "MonitorFrame"
22
+ ]
@@ -0,0 +1,79 @@
1
+ """
2
+ This module contains the primary functions for decoding a raw monitor payload
3
+ into a structured `MonitorSnapshot`.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import datetime as dt
9
+ from typing import Any
10
+
11
+ from cremalink.parsing.monitor.extractors import extract_fields_from_b64
12
+ from cremalink.parsing.monitor.model import MonitorSnapshot
13
+
14
+
15
+ def decode_monitor_b64(raw_b64: str) -> bytes:
16
+ """
17
+ A simple wrapper for base64 decoding that provides a more specific error message.
18
+ """
19
+ try:
20
+ return base64.b64decode(raw_b64)
21
+ except Exception as exc:
22
+ raise ValueError(f"Failed to decode monitor base64: {exc}") from exc
23
+
24
+
25
+ def build_monitor_snapshot(
26
+ payload: dict[str, Any],
27
+ source: str = "local",
28
+ device_id: str | None = None,
29
+ ) -> MonitorSnapshot:
30
+ """
31
+ Constructs a `MonitorSnapshot` from a raw payload dictionary.
32
+
33
+ This function orchestrates the decoding process:
34
+ 1. It extracts the base64-encoded monitor string from the input payload.
35
+ 2. It decodes the base64 string into raw bytes.
36
+ 3. It calls `extract_fields_from_b64` to parse the raw bytes into a
37
+ low-level dictionary and a `MonitorFrame`.
38
+ 4. It bundles all this information into a `MonitorSnapshot` object.
39
+
40
+ Args:
41
+ payload: The raw dictionary payload, typically from the local server or cloud API.
42
+ source: The origin of the data (e.g., 'local', 'cloud').
43
+ device_id: The identifier of the device.
44
+
45
+ Returns:
46
+ A populated `MonitorSnapshot` instance.
47
+ """
48
+ # The base64 data can be in a few different places depending on the source.
49
+ raw_b64 = payload.get("monitor_b64") or payload.get("monitor", {}).get("data", {}).get("value")
50
+
51
+ # If no base64 data is found, return an empty snapshot with a warning.
52
+ if not raw_b64:
53
+ return MonitorSnapshot(
54
+ raw=b"",
55
+ raw_b64="",
56
+ received_at=dt.datetime.fromtimestamp(payload.get("received_at") or dt.datetime.now(dt.UTC).timestamp()),
57
+ parsed={},
58
+ warnings=["no monitor_b64 in payload"],
59
+ errors=[],
60
+ source=source,
61
+ device_id=device_id,
62
+ )
63
+
64
+ # Decode the base64 string and then extract the low-level fields.
65
+ raw = decode_monitor_b64(raw_b64)
66
+ parsed, warnings, errors, frame = extract_fields_from_b64(raw_b64)
67
+
68
+ # Assemble the final snapshot object.
69
+ return MonitorSnapshot(
70
+ raw=raw,
71
+ raw_b64=raw_b64,
72
+ received_at=dt.datetime.fromtimestamp(payload.get("received_at") or dt.datetime.now(dt.UTC).timestamp()),
73
+ parsed=parsed,
74
+ warnings=warnings,
75
+ errors=errors,
76
+ source=source,
77
+ device_id=device_id,
78
+ frame=frame,
79
+ )