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,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides extractor functions that pull data from a raw monitor frame.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
from typing import Any, Tuple
|
|
8
|
+
|
|
9
|
+
from cremalink.parsing.monitor.frame import MonitorFrame
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def extract_fields_from_b64(
|
|
13
|
+
raw_b64: str,
|
|
14
|
+
) -> Tuple[dict[str, Any], list[str], list[str], MonitorFrame | None]:
|
|
15
|
+
"""
|
|
16
|
+
Parses a base64-encoded monitor string into a low-level MonitorFrame
|
|
17
|
+
and extracts its fields into a dictionary.
|
|
18
|
+
|
|
19
|
+
This function serves as the first step in the decoding pipeline. It handles
|
|
20
|
+
the initial, structural parsing of the byte frame and populates a dictionary
|
|
21
|
+
with the raw integer and byte values.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
raw_b64: The base64-encoded monitor data string.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
A tuple containing:
|
|
28
|
+
- A dictionary of the extracted raw fields.
|
|
29
|
+
- A list of any warnings generated during parsing.
|
|
30
|
+
- A list of any errors encountered during parsing.
|
|
31
|
+
- The parsed `MonitorFrame` object, or None if parsing failed.
|
|
32
|
+
"""
|
|
33
|
+
parsed: dict[str, Any] = {}
|
|
34
|
+
warnings: list[str] = []
|
|
35
|
+
errors: list[str] = []
|
|
36
|
+
frame: MonitorFrame | None = None
|
|
37
|
+
|
|
38
|
+
# --- Step 1: Decode the raw bytes into a MonitorFrame ---
|
|
39
|
+
try:
|
|
40
|
+
frame = MonitorFrame.from_b64(raw_b64)
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
# If frame parsing fails, record the error and exit.
|
|
43
|
+
errors.append(f"parse_failed: {exc}")
|
|
44
|
+
try:
|
|
45
|
+
# As a fallback, try to at least record the length of the raw data.
|
|
46
|
+
raw = base64.b64decode(raw_b64)
|
|
47
|
+
parsed["raw_length"] = len(raw)
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
return parsed, warnings, errors, frame
|
|
51
|
+
|
|
52
|
+
# --- Step 2: Populate the 'parsed' dictionary with the frame's fields ---
|
|
53
|
+
parsed.update(
|
|
54
|
+
{
|
|
55
|
+
"accessory": frame.accessory,
|
|
56
|
+
"switches": list(frame.switches),
|
|
57
|
+
"alarms": list(frame.alarms),
|
|
58
|
+
"status": frame.status,
|
|
59
|
+
"action": frame.action,
|
|
60
|
+
"progress": frame.progress,
|
|
61
|
+
"direction": frame.direction,
|
|
62
|
+
"request_id": frame.request_id,
|
|
63
|
+
"answer_required": frame.answer_required,
|
|
64
|
+
"timestamp": frame.timestamp.hex() if frame.timestamp else "",
|
|
65
|
+
"extra": frame.extra.hex() if frame.extra else "",
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return parsed, warnings, errors, frame
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module defines the low-level structure of a "monitor" data frame.
|
|
3
|
+
It handles the byte-level decoding of the raw payload from the device.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from cremalink.core.binary import crc16_ccitt
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class MonitorFrame:
|
|
15
|
+
"""
|
|
16
|
+
Represents the decoded, low-level structure of a monitor data frame.
|
|
17
|
+
|
|
18
|
+
This class takes the raw bytes from a monitor update and parses them into
|
|
19
|
+
their fundamental components according to the device's binary protocol.
|
|
20
|
+
This includes separating headers, payload, checksums, and timestamps.
|
|
21
|
+
"""
|
|
22
|
+
# --- Frame Header/Metadata ---
|
|
23
|
+
direction: int
|
|
24
|
+
request_id: int
|
|
25
|
+
answer_required: int
|
|
26
|
+
# --- Core Payload Fields ---
|
|
27
|
+
accessory: int
|
|
28
|
+
switches: bytes
|
|
29
|
+
alarms: bytes
|
|
30
|
+
status: int
|
|
31
|
+
action: int
|
|
32
|
+
progress: int
|
|
33
|
+
# --- Frame Footer/Extra Data ---
|
|
34
|
+
timestamp: bytes
|
|
35
|
+
extra: bytes
|
|
36
|
+
# --- Raw Data ---
|
|
37
|
+
raw: bytes
|
|
38
|
+
raw_b64: str
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_b64(cls, raw_b64: str) -> "MonitorFrame":
|
|
42
|
+
"""
|
|
43
|
+
Decodes a base64 string into a structured MonitorFrame.
|
|
44
|
+
|
|
45
|
+
This factory method performs the primary byte-level parsing, including:
|
|
46
|
+
- Base64 decoding.
|
|
47
|
+
- Length validation.
|
|
48
|
+
- CRC-16 checksum verification.
|
|
49
|
+
- Splitting the raw bytes into their respective fields (header, payload, etc.).
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
raw_b64: The base64-encoded monitor data string.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
A populated MonitorFrame instance.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If the data is malformed, too short, or fails the CRC check.
|
|
59
|
+
"""
|
|
60
|
+
raw = base64.b64decode(raw_b64)
|
|
61
|
+
if len(raw) < 4:
|
|
62
|
+
raise ValueError("Raw data is too short to contain a monitor frame")
|
|
63
|
+
|
|
64
|
+
# --- Unpack the outer frame ---
|
|
65
|
+
direction = raw[0]
|
|
66
|
+
length = raw[1]
|
|
67
|
+
if length < 4 or len(raw) < length + 1:
|
|
68
|
+
raise ValueError("Length byte inconsistent with payload")
|
|
69
|
+
|
|
70
|
+
data = raw[2: length - 1]
|
|
71
|
+
crc = raw[length - 1: length + 1]
|
|
72
|
+
|
|
73
|
+
# --- Verify CRC ---
|
|
74
|
+
if crc != crc16_ccitt(raw[: length - 1]):
|
|
75
|
+
raise ValueError("CRC check failed")
|
|
76
|
+
|
|
77
|
+
timestamp = raw[length + 1: length + 5]
|
|
78
|
+
extra = raw[length + 5:]
|
|
79
|
+
|
|
80
|
+
# --- Unpack the inner monitor data payload ---
|
|
81
|
+
if len(data) < 2:
|
|
82
|
+
raise ValueError("Monitor payload too short")
|
|
83
|
+
request_id = data[0]
|
|
84
|
+
answer_required = data[1]
|
|
85
|
+
contents = data[2:]
|
|
86
|
+
|
|
87
|
+
# This implementation assumes a "V2" frame structure with 13 bytes of contents.
|
|
88
|
+
if len(contents) != 13:
|
|
89
|
+
raise ValueError("Monitor contents expected to be 13 bytes for V2 frames")
|
|
90
|
+
|
|
91
|
+
accessory = contents[0]
|
|
92
|
+
switches = contents[1:3]
|
|
93
|
+
# Alarms are non-contiguous in the payload, so they are concatenated here.
|
|
94
|
+
alarms = contents[3:5] + contents[8:10]
|
|
95
|
+
status = contents[5]
|
|
96
|
+
action = contents[6]
|
|
97
|
+
progress = contents[7]
|
|
98
|
+
|
|
99
|
+
return cls(
|
|
100
|
+
direction=direction,
|
|
101
|
+
request_id=request_id,
|
|
102
|
+
answer_required=answer_required,
|
|
103
|
+
accessory=accessory,
|
|
104
|
+
switches=switches,
|
|
105
|
+
alarms=alarms,
|
|
106
|
+
status=status,
|
|
107
|
+
action=action,
|
|
108
|
+
progress=progress,
|
|
109
|
+
timestamp=timestamp,
|
|
110
|
+
extra=extra,
|
|
111
|
+
raw=raw,
|
|
112
|
+
raw_b64=raw_b64,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def as_dict(self) -> dict:
|
|
116
|
+
"""
|
|
117
|
+
Returns a dictionary representation of the frame's contents.
|
|
118
|
+
This is useful for serialization or debugging.
|
|
119
|
+
"""
|
|
120
|
+
return {
|
|
121
|
+
"direction": self.direction,
|
|
122
|
+
"request_id": self.request_id,
|
|
123
|
+
"answer_required": self.answer_required,
|
|
124
|
+
"accessory": self.accessory,
|
|
125
|
+
"switches": list(self.switches),
|
|
126
|
+
"alarms": list(self.alarms),
|
|
127
|
+
"status": self.status,
|
|
128
|
+
"action": self.action,
|
|
129
|
+
"progress": self.progress,
|
|
130
|
+
"timestamp": self.timestamp.hex() if self.timestamp else "",
|
|
131
|
+
"extra": self.extra.hex() if self.extra else "",
|
|
132
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module defines the high-level data model for a "monitor" data snapshot.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from cremalink.parsing.monitor.frame import MonitorFrame
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class MonitorSnapshot:
|
|
15
|
+
"""
|
|
16
|
+
Represents a single snapshot of the device's monitoring status.
|
|
17
|
+
|
|
18
|
+
This dataclass acts as a container for all information related to a single
|
|
19
|
+
monitor update from the device. It holds the raw data, timestamps, any
|
|
20
|
+
parsed values, and metadata about the decoding process.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
raw: The raw bytes of the monitor data payload.
|
|
24
|
+
raw_b64: The base64-encoded string representation of the raw data.
|
|
25
|
+
received_at: The timestamp when this snapshot was received.
|
|
26
|
+
parsed: A dictionary to hold the successfully parsed key-value data.
|
|
27
|
+
warnings: A list of any warnings generated during parsing.
|
|
28
|
+
errors: A list of any errors encountered during parsing.
|
|
29
|
+
source: The origin of the data (e.g., 'local' or 'cloud').
|
|
30
|
+
device_id: The identifier of the device that sent the data.
|
|
31
|
+
frame: A `MonitorFrame` instance if the raw bytes were successfully
|
|
32
|
+
decoded into a low-level frame structure.
|
|
33
|
+
"""
|
|
34
|
+
raw: bytes
|
|
35
|
+
raw_b64: str
|
|
36
|
+
received_at: datetime
|
|
37
|
+
parsed: dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
warnings: list[str] = field(default_factory=list)
|
|
39
|
+
errors: list[str] = field(default_factory=list)
|
|
40
|
+
source: str = "local"
|
|
41
|
+
device_id: Optional[str] = None
|
|
42
|
+
frame: Optional[MonitorFrame] = None
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module defines the data structures for a "Monitor Profile". A profile is a
|
|
3
|
+
declarative configuration that describes how to interpret the raw bytes of a
|
|
4
|
+
monitor frame for a specific device model.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Dict, Iterable, Optional
|
|
10
|
+
|
|
11
|
+
# A set of valid source fields within the MonitorFrame.
|
|
12
|
+
VALID_SOURCES = {"alarms", "switches", "status", "action", "progress", "accessory"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class FlagDefinition:
|
|
17
|
+
"""
|
|
18
|
+
Defines how to extract a boolean flag from a specific bit in a byte array.
|
|
19
|
+
This is used for parsing the 'alarms' and 'switches' byte fields.
|
|
20
|
+
"""
|
|
21
|
+
source: str
|
|
22
|
+
byte: int
|
|
23
|
+
bit: int
|
|
24
|
+
invert: bool = False
|
|
25
|
+
description: Optional[str] = None
|
|
26
|
+
|
|
27
|
+
def validate(self) -> None:
|
|
28
|
+
"""Checks if the definition is valid."""
|
|
29
|
+
if self.source not in {"alarms", "switches"}:
|
|
30
|
+
raise ValueError("flag source must be 'alarms' or 'switches'")
|
|
31
|
+
if self.byte < 0:
|
|
32
|
+
raise ValueError("byte must be non-negative")
|
|
33
|
+
if self.bit < 0 or self.bit > 7:
|
|
34
|
+
raise ValueError("bit must be between 0 and 7")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class PredicateDefinition:
|
|
39
|
+
"""
|
|
40
|
+
Defines a logical condition to be evaluated against the monitor frame data.
|
|
41
|
+
Predicates are used to create higher-level boolean states, like "is_on"
|
|
42
|
+
or "is_making_coffee".
|
|
43
|
+
"""
|
|
44
|
+
kind: str
|
|
45
|
+
source: Optional[str] = None
|
|
46
|
+
value: Any = None
|
|
47
|
+
values: Iterable[Any] | None = None
|
|
48
|
+
flag: str | None = None
|
|
49
|
+
byte: int | None = None
|
|
50
|
+
bit: int | None = None
|
|
51
|
+
|
|
52
|
+
def validate(self) -> None:
|
|
53
|
+
"""Checks if the predicate definition is valid."""
|
|
54
|
+
if self.kind not in {
|
|
55
|
+
"equals", "not_equals", "in_set", "not_in_set",
|
|
56
|
+
"flag_true", "flag_false", "bit_set", "bit_clear",
|
|
57
|
+
}:
|
|
58
|
+
raise ValueError(f"Unsupported predicate kind: {self.kind}")
|
|
59
|
+
if self.source and self.source not in VALID_SOURCES:
|
|
60
|
+
raise ValueError(f"source must be one of {sorted(VALID_SOURCES)}")
|
|
61
|
+
if self.bit is not None and (self.bit < 0 or self.bit > 7):
|
|
62
|
+
raise ValueError("bit must be between 0 and 7")
|
|
63
|
+
|
|
64
|
+
def uses_flag(self) -> bool:
|
|
65
|
+
"""Returns True if the predicate depends on a named flag."""
|
|
66
|
+
return self.kind in {"flag_true", "flag_false"}
|
|
67
|
+
|
|
68
|
+
def uses_bit_address(self) -> bool:
|
|
69
|
+
"""Returns True if the predicate directly addresses a bit."""
|
|
70
|
+
return self.kind in {"bit_set", "bit_clear"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class MonitorProfile:
|
|
75
|
+
"""
|
|
76
|
+
A complete profile for parsing a device's monitor frame.
|
|
77
|
+
|
|
78
|
+
This class holds all the definitions needed to translate the raw bytes of a
|
|
79
|
+
monitor frame into meaningful, human-readable data. It is typically loaded
|
|
80
|
+
from a device-specific JSON file.
|
|
81
|
+
"""
|
|
82
|
+
flags: Dict[str, FlagDefinition] = field(default_factory=dict)
|
|
83
|
+
enums: Dict[str, Dict[int, str]] = field(default_factory=dict)
|
|
84
|
+
predicates: Dict[str, PredicateDefinition] = field(default_factory=dict)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_dict(cls, data: dict | None) -> "MonitorProfile":
|
|
88
|
+
"""
|
|
89
|
+
Constructs a MonitorProfile from a dictionary (e.g., from a JSON file).
|
|
90
|
+
"""
|
|
91
|
+
if not data:
|
|
92
|
+
return cls()
|
|
93
|
+
|
|
94
|
+
# --- Load Flag Definitions ---
|
|
95
|
+
flags = {}
|
|
96
|
+
for name, flag_data in (data.get("flags") or {}).items():
|
|
97
|
+
flag = FlagDefinition(
|
|
98
|
+
source=flag_data.get("source"),
|
|
99
|
+
byte=int(flag_data.get("byte", 0)),
|
|
100
|
+
bit=int(flag_data.get("bit", 0)),
|
|
101
|
+
invert=bool(flag_data.get("invert", False)),
|
|
102
|
+
description=flag_data.get("description"),
|
|
103
|
+
)
|
|
104
|
+
flag.validate()
|
|
105
|
+
flags[name] = flag
|
|
106
|
+
|
|
107
|
+
# --- Load Predicate Definitions ---
|
|
108
|
+
predicates = {}
|
|
109
|
+
for name, pred_data in (data.get("predicates") or {}).items():
|
|
110
|
+
pred = PredicateDefinition(
|
|
111
|
+
kind=pred_data.get("kind"),
|
|
112
|
+
source=pred_data.get("source"),
|
|
113
|
+
value=pred_data.get("value"),
|
|
114
|
+
values=pred_data.get("values") or pred_data.get("set") or pred_data.get("in"),
|
|
115
|
+
flag=pred_data.get("flag"),
|
|
116
|
+
byte=pred_data.get("byte"),
|
|
117
|
+
bit=pred_data.get("bit"),
|
|
118
|
+
)
|
|
119
|
+
pred.validate()
|
|
120
|
+
predicates[name] = pred
|
|
121
|
+
|
|
122
|
+
# --- Load Enum Mappings ---
|
|
123
|
+
enums = {
|
|
124
|
+
name: {int(k): v for k, v in (mapping or {}).items()}
|
|
125
|
+
for name, mapping in (data.get("enums", {}) or {}).items()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return cls(flags=flags, enums=enums, predicates=predicates)
|
|
129
|
+
|
|
130
|
+
def available_fields(self) -> list[str]:
|
|
131
|
+
"""Returns a sorted list of all defined flag and predicate names."""
|
|
132
|
+
dynamic = list(self.flags.keys()) + list(self.predicates.keys())
|
|
133
|
+
return sorted(set(dynamic))
|
|
134
|
+
|
|
135
|
+
def summary(self) -> dict[str, Any]:
|
|
136
|
+
"""Provides a brief summary of the profile's contents."""
|
|
137
|
+
return {
|
|
138
|
+
"flags": list(self.flags.keys()),
|
|
139
|
+
"enums": {name: list(mapping.keys()) for name, mapping in self.enums.items()},
|
|
140
|
+
"predicates": list(self.predicates.keys()),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
__all__ = ["FlagDefinition", "PredicateDefinition", "MonitorProfile"]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides the `MonitorView` class, which offers a high-level,
|
|
3
|
+
user-friendly interface for accessing data from a `MonitorSnapshot`.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from cremalink.core.binary import get_bit
|
|
10
|
+
from cremalink.parsing.monitor.frame import MonitorFrame
|
|
11
|
+
from cremalink.parsing.monitor.model import MonitorSnapshot
|
|
12
|
+
from cremalink.parsing.monitor.profile import MonitorProfile, PredicateDefinition
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MonitorView:
|
|
16
|
+
"""
|
|
17
|
+
A user-friendly view of a `MonitorSnapshot`, powered by a `MonitorProfile`.
|
|
18
|
+
|
|
19
|
+
This class acts as a wrapper around a `MonitorSnapshot`. It uses a given
|
|
20
|
+
`MonitorProfile` to translate raw, low-level data (like integer codes and
|
|
21
|
+
bit flags) into human-readable values (like enum names and boolean predicates).
|
|
22
|
+
|
|
23
|
+
It provides dynamic attribute access (`__getattr__`) to resolve flags and
|
|
24
|
+
predicates from the profile on the fly. For example, `view.is_on` or
|
|
25
|
+
`view.has_descaling_alarm`.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, snapshot: MonitorSnapshot, profile: MonitorProfile | dict[str, Any] | None = None) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Initializes the MonitorView.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
snapshot: The `MonitorSnapshot` containing the data to be viewed.
|
|
34
|
+
profile: A `MonitorProfile` or a dictionary to build one from. This
|
|
35
|
+
profile dictates how the raw data is interpreted.
|
|
36
|
+
"""
|
|
37
|
+
self.snapshot = snapshot
|
|
38
|
+
# Ensure profile is always a MonitorProfile instance.
|
|
39
|
+
self.profile = profile if isinstance(profile, MonitorProfile) else MonitorProfile.from_dict(profile or {})
|
|
40
|
+
|
|
41
|
+
# Attempt to get or parse the low-level MonitorFrame.
|
|
42
|
+
self._frame: Optional[MonitorFrame] = snapshot.frame
|
|
43
|
+
if self._frame is None and snapshot.raw_b64:
|
|
44
|
+
try:
|
|
45
|
+
self._frame = MonitorFrame.from_b64(snapshot.raw_b64)
|
|
46
|
+
except Exception:
|
|
47
|
+
self._frame = None
|
|
48
|
+
|
|
49
|
+
# --- raw accessors ---
|
|
50
|
+
@property
|
|
51
|
+
def raw(self) -> bytes:
|
|
52
|
+
"""The raw bytes of the monitor payload."""
|
|
53
|
+
return self.snapshot.raw
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def raw_b64(self) -> str:
|
|
57
|
+
"""The base64-encoded string of the monitor payload."""
|
|
58
|
+
return self.snapshot.raw_b64
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def parsed(self) -> dict[str, Any]:
|
|
62
|
+
"""The dictionary of initially parsed, low-level fields."""
|
|
63
|
+
return self.snapshot.parsed
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def received_at(self):
|
|
67
|
+
"""The timestamp when the snapshot was received."""
|
|
68
|
+
return self.snapshot.received_at
|
|
69
|
+
|
|
70
|
+
# --- standard fields ---
|
|
71
|
+
@property
|
|
72
|
+
def status_code(self) -> Optional[int]:
|
|
73
|
+
"""The raw integer code for the device's main status."""
|
|
74
|
+
return self._frame.status if self._frame else None
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def action_code(self) -> Optional[int]:
|
|
78
|
+
"""The raw integer code for the device's current action."""
|
|
79
|
+
return self._frame.action if self._frame else None
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def progress_percent(self) -> Optional[int]:
|
|
83
|
+
"""The progress percentage (0-100) of the current action."""
|
|
84
|
+
return self._frame.progress if self._frame else None
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def accessory_code(self) -> Optional[int]:
|
|
88
|
+
"""The raw integer code for the currently detected accessory."""
|
|
89
|
+
return self._frame.accessory if self._frame else None
|
|
90
|
+
|
|
91
|
+
# --- enum mapping ---
|
|
92
|
+
def _enum_lookup(self, enum_name: str, code: Optional[int]) -> Optional[str]:
|
|
93
|
+
"""Looks up an enum name from a code using the profile."""
|
|
94
|
+
if code is None:
|
|
95
|
+
return None
|
|
96
|
+
mapping = self.profile.enums.get(enum_name, {})
|
|
97
|
+
# Return the string name, or the original code as a string if not found.
|
|
98
|
+
return mapping.get(int(code), str(code))
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def status_name(self) -> Optional[str]:
|
|
102
|
+
"""The human-readable name of the device's status (e.g., 'Standby')."""
|
|
103
|
+
return self._enum_lookup("status", self.status_code)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def action_name(self) -> Optional[str]:
|
|
107
|
+
"""The human-readable name of the device's action (e.g., 'Brewing')."""
|
|
108
|
+
return self._enum_lookup("action", self.action_code)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def accessory_name(self) -> Optional[str]:
|
|
112
|
+
"""The human-readable name of the accessory (e.g., 'Milk Carafe')."""
|
|
113
|
+
return self._enum_lookup("accessory", self.accessory_code)
|
|
114
|
+
|
|
115
|
+
# --- flag/predicate helpers ---
|
|
116
|
+
def _resolve_flag(self, flag_name: str) -> Optional[bool]:
|
|
117
|
+
"""Resolves a named boolean flag using its definition in the profile."""
|
|
118
|
+
if not self._frame:
|
|
119
|
+
return None
|
|
120
|
+
flag_def = self.profile.flags.get(flag_name)
|
|
121
|
+
if not flag_def:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
data_bytes = self._frame.alarms if flag_def.source == "alarms" else self._frame.switches
|
|
125
|
+
if flag_def.byte >= len(data_bytes):
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
byte_val = data_bytes[flag_def.byte]
|
|
129
|
+
value = get_bit(byte_val, flag_def.bit)
|
|
130
|
+
return not value if flag_def.invert else value
|
|
131
|
+
|
|
132
|
+
def _source_value(self, source: str) -> Any:
|
|
133
|
+
"""Gets a raw value from the frame by its source name."""
|
|
134
|
+
if not self._frame:
|
|
135
|
+
return None
|
|
136
|
+
return {
|
|
137
|
+
"alarms": self._frame.alarms,
|
|
138
|
+
"switches": self._frame.switches,
|
|
139
|
+
"status": self._frame.status,
|
|
140
|
+
"action": self._frame.action,
|
|
141
|
+
"progress": self._frame.progress,
|
|
142
|
+
"accessory": self._frame.accessory,
|
|
143
|
+
}.get(source)
|
|
144
|
+
|
|
145
|
+
def _evaluate_predicate(self, definition: PredicateDefinition) -> Optional[bool]:
|
|
146
|
+
"""Evaluates a named predicate using its definition in the profile."""
|
|
147
|
+
try:
|
|
148
|
+
if definition.uses_flag():
|
|
149
|
+
flag_value = self._resolve_flag(definition.flag or "")
|
|
150
|
+
if flag_value is None:
|
|
151
|
+
return None
|
|
152
|
+
return flag_value if definition.kind == "flag_true" else not flag_value
|
|
153
|
+
|
|
154
|
+
if definition.uses_bit_address():
|
|
155
|
+
if not self._frame or not definition.source:
|
|
156
|
+
return None
|
|
157
|
+
source_bytes = self._frame.alarms if definition.source == "alarms" else self._frame.switches
|
|
158
|
+
if definition.byte is None or definition.byte >= len(source_bytes) or definition.bit is None:
|
|
159
|
+
return None
|
|
160
|
+
bit_value = get_bit(source_bytes[definition.byte], definition.bit)
|
|
161
|
+
return bit_value if definition.kind == "bit_set" else not bit_value
|
|
162
|
+
|
|
163
|
+
source_val = self._source_value(definition.source) if definition.source else None
|
|
164
|
+
if definition.kind == "equals":
|
|
165
|
+
return source_val == definition.value
|
|
166
|
+
if definition.kind == "not_equals":
|
|
167
|
+
return source_val != definition.value
|
|
168
|
+
if definition.kind == "in_set":
|
|
169
|
+
return source_val in set(definition.values or [])
|
|
170
|
+
if definition.kind == "not_in_set":
|
|
171
|
+
return source_val not in set(definition.values or [])
|
|
172
|
+
except Exception:
|
|
173
|
+
return None
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
# --- dynamic access ---
|
|
177
|
+
@property
|
|
178
|
+
def available_fields(self) -> list[str]:
|
|
179
|
+
"""A list of all available dynamic fields (flags and predicates)."""
|
|
180
|
+
return self.profile.available_fields()
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def profile_summary(self) -> dict[str, Any]:
|
|
184
|
+
"""A summary of the loaded profile."""
|
|
185
|
+
return self.profile.summary()
|
|
186
|
+
|
|
187
|
+
def __getattr__(self, item: str) -> Any:
|
|
188
|
+
"""
|
|
189
|
+
Dynamically resolves flags and predicates from the profile.
|
|
190
|
+
This allows for accessing profile-defined fields like `view.is_on`.
|
|
191
|
+
"""
|
|
192
|
+
if item in self.profile.flags:
|
|
193
|
+
return self._resolve_flag(item)
|
|
194
|
+
if item in self.profile.predicates:
|
|
195
|
+
return self._evaluate_predicate(self.profile.predicates[item])
|
|
196
|
+
raise AttributeError(f"{self.__class__.__name__} has no attribute '{item}'")
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This package handles the parsing and decoding of device 'properties'.
|
|
3
|
+
|
|
4
|
+
Properties are key-value pairs that represent the static or semi-static
|
|
5
|
+
attributes of the coffee machine, such as configuration settings or counters.
|
|
6
|
+
"""
|
|
7
|
+
from cremalink.parsing.properties.decode import PropertiesSnapshot
|
|
8
|
+
|
|
9
|
+
__all__ = ["PropertiesSnapshot"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides classes for handling and decoding device properties.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class PropertiesSnapshot:
|
|
13
|
+
"""
|
|
14
|
+
A container for a snapshot of device properties at a specific time.
|
|
15
|
+
|
|
16
|
+
This class holds the raw property data as received from the device,
|
|
17
|
+
the timestamp of when it was received, and a dictionary for any parsed
|
|
18
|
+
or processed values. It provides a helper method to easily access
|
|
19
|
+
property values from the potentially nested raw data structure.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
raw: The raw dictionary of properties from the device.
|
|
23
|
+
received_at: The timestamp when the snapshot was taken.
|
|
24
|
+
parsed: A dictionary to hold processed or parsed property values.
|
|
25
|
+
"""
|
|
26
|
+
raw: dict[str, Any]
|
|
27
|
+
received_at: Optional[datetime]
|
|
28
|
+
parsed: dict[str, Any] = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
def get(self, name: str) -> Any:
|
|
31
|
+
"""
|
|
32
|
+
Safely retrieves a property by its name from the raw data.
|
|
33
|
+
|
|
34
|
+
The properties data can come in different formats. This method checks
|
|
35
|
+
for the property name as a direct key and also searches within the
|
|
36
|
+
nested 'property' objects that are common in the API response.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
name: The name of the property to retrieve.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
The property dictionary if found, otherwise None.
|
|
43
|
+
"""
|
|
44
|
+
# First, check if the name is a top-level key in the raw dictionary.
|
|
45
|
+
if name in self.raw:
|
|
46
|
+
return self.raw[name]
|
|
47
|
+
|
|
48
|
+
# If not, iterate through the values to find a nested property object.
|
|
49
|
+
# This handles the common format: `{'some_id': {'property': {'name': name, ...}}}`
|
|
50
|
+
for entry in self.raw.values():
|
|
51
|
+
if isinstance(entry, dict) and entry.get("property", {}).get("name") == name:
|
|
52
|
+
return entry
|
|
53
|
+
return None
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This package contains static resources and configuration files for the cremalink
|
|
3
|
+
library, such as API keys and language mappings.
|
|
4
|
+
|
|
5
|
+
It provides helper functions to access these resources in a way that is
|
|
6
|
+
compatible with standard Python packaging.
|
|
7
|
+
"""
|
|
8
|
+
from cremalink.resources.api_config import load_api_config
|
|
9
|
+
|
|
10
|
+
__all__ = ["load_api_config"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"GIGYA": {
|
|
3
|
+
"API_KEY": "3_e5qn7USZK-QtsIso1wCelqUKAK_IVEsYshRIssQ-X-k55haiZXmKWDHDRul2e5Y2",
|
|
4
|
+
"CLIENT_ID": "1S8q1WJEs-emOB43Z0-66WnL",
|
|
5
|
+
"CLIENT_SECRET": "lmnceiD0B-4KPNN5ZS6WuWU70j9V5BCuSlz2OPsvHkyLryhMkJkPvKsivfTq3RfNYj8GpCELtOBvhaDIzKcBtg",
|
|
6
|
+
"SDK_BUILD": "16650"
|
|
7
|
+
},
|
|
8
|
+
"AYLA": {
|
|
9
|
+
"APP_ID": "DeLonghiComfort2-mw-id",
|
|
10
|
+
"APP_SECRET": "DeLonghiComfort2-Yg4miiqiNcf0Or-EhJwRh7ACfBY",
|
|
11
|
+
"API_URL": "https://ads-eu.aylanetworks.com/apiv1",
|
|
12
|
+
"OAUTH_URL": "https://user-field-eu.aylanetworks.com"
|
|
13
|
+
}
|
|
14
|
+
}
|