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.
- cremalink/__init__.py +33 -0
- cremalink/clients/__init__.py +10 -0
- cremalink/clients/cloud.py +130 -0
- cremalink/core/__init__.py +6 -0
- cremalink/core/binary.py +102 -0
- cremalink/crypto/__init__.py +142 -0
- cremalink/devices/AY008ESP1.json +114 -0
- cremalink/devices/__init__.py +116 -0
- cremalink/domain/__init__.py +11 -0
- cremalink/domain/device.py +245 -0
- cremalink/domain/factory.py +98 -0
- cremalink/local_server.py +76 -0
- cremalink/local_server_app/__init__.py +20 -0
- cremalink/local_server_app/api.py +272 -0
- cremalink/local_server_app/config.py +64 -0
- cremalink/local_server_app/device_adapter.py +96 -0
- cremalink/local_server_app/jobs.py +104 -0
- cremalink/local_server_app/logging.py +116 -0
- cremalink/local_server_app/models.py +76 -0
- cremalink/local_server_app/protocol.py +135 -0
- cremalink/local_server_app/state.py +358 -0
- cremalink/parsing/__init__.py +7 -0
- cremalink/parsing/monitor/__init__.py +22 -0
- cremalink/parsing/monitor/decode.py +79 -0
- cremalink/parsing/monitor/extractors.py +69 -0
- cremalink/parsing/monitor/frame.py +132 -0
- cremalink/parsing/monitor/model.py +42 -0
- cremalink/parsing/monitor/profile.py +144 -0
- cremalink/parsing/monitor/view.py +196 -0
- cremalink/parsing/properties/__init__.py +9 -0
- cremalink/parsing/properties/decode.py +53 -0
- cremalink/resources/__init__.py +10 -0
- cremalink/resources/api_config.json +14 -0
- cremalink/resources/api_config.py +30 -0
- cremalink/resources/lang.json +223 -0
- cremalink/transports/__init__.py +7 -0
- cremalink/transports/base.py +94 -0
- cremalink/transports/cloud/__init__.py +9 -0
- cremalink/transports/cloud/transport.py +166 -0
- cremalink/transports/local/__init__.py +9 -0
- cremalink/transports/local/transport.py +164 -0
- cremalink-0.1.0b5.dist-info/METADATA +138 -0
- cremalink-0.1.0b5.dist-info/RECORD +47 -0
- cremalink-0.1.0b5.dist-info/WHEEL +5 -0
- cremalink-0.1.0b5.dist-info/entry_points.txt +2 -0
- cremalink-0.1.0b5.dist-info/licenses/LICENSE +661 -0
- 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,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
|
+
)
|