pysaeco 0.1.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.
- pysaeco/__init__.py +6 -0
- pysaeco/avanti.py +616 -0
- pysaeco/cli.py +110 -0
- pysaeco/client.py +279 -0
- pysaeco/homeassistant/__init__.py +4 -0
- pysaeco/homeassistant/discovery.py +297 -0
- pysaeco/server.py +531 -0
- pysaeco-0.1.0.dist-info/METADATA +63 -0
- pysaeco-0.1.0.dist-info/RECORD +11 -0
- pysaeco-0.1.0.dist-info/WHEEL +4 -0
- pysaeco-0.1.0.dist-info/entry_points.txt +2 -0
pysaeco/__init__.py
ADDED
pysaeco/avanti.py
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import binascii
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import IntEnum, StrEnum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
SERVICE_UUID = "b8e06067-62ad-41ba-9231-206ae80ab550"
|
|
13
|
+
RESPONSE_CHAR_UUID = "f897177b-aee8-4767-8ecc-cc694fd5fcee"
|
|
14
|
+
WAKEUP_CHAR_UUID = "2fbc0f31-726a-4014-b9fe-c8be0652e982"
|
|
15
|
+
COMMAND_CHAR_UUID = "bf45e40a-de2a-4bc8-bba0-e5d6065f1b4b"
|
|
16
|
+
WAKEUP_VALUE = b"\xaa"
|
|
17
|
+
DEFAULT_COMMAND_HANDLE = 0x1234
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AvantiCommand(IntEnum):
|
|
21
|
+
ENTER_STANDBY = 0x10
|
|
22
|
+
SHOW_PIN = 0x18
|
|
23
|
+
READ_STATUS = 0x52
|
|
24
|
+
BREW_PRODUCT = 0xC0
|
|
25
|
+
STOP_BREWING = 0x50
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AvantiResponse(IntEnum):
|
|
29
|
+
ENTER_STANDBY = 0x11
|
|
30
|
+
SHOW_PIN = 0x19
|
|
31
|
+
READ_STATUS = 0x53
|
|
32
|
+
STOP_BREWING = 0x51
|
|
33
|
+
BREW_PRODUCT = 0xC1
|
|
34
|
+
CRC_ERROR = 0xE0
|
|
35
|
+
LEN_ERROR = 0xE1
|
|
36
|
+
CMD_ERROR = 0xE2
|
|
37
|
+
PIN_ERROR = 0xE3
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
ERROR_RESPONSES = frozenset(
|
|
41
|
+
{
|
|
42
|
+
AvantiResponse.CRC_ERROR,
|
|
43
|
+
AvantiResponse.LEN_ERROR,
|
|
44
|
+
AvantiResponse.CMD_ERROR,
|
|
45
|
+
AvantiResponse.PIN_ERROR,
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AvantiError(Exception):
|
|
51
|
+
"""Base exception for Avanti protocol failures."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AvantiResponseCrcError(AvantiError):
|
|
55
|
+
def __init__(self, frame: AvantiFrame) -> None:
|
|
56
|
+
self.frame = frame
|
|
57
|
+
super().__init__(
|
|
58
|
+
f"machine response {frame.response_name} failed CRC validation "
|
|
59
|
+
f"(payload={frame.payload.hex()})"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AvantiProtocolError(AvantiError):
|
|
64
|
+
def __init__(self, frame: AvantiFrame | None = None, message: str | None = None) -> None:
|
|
65
|
+
self.frame = frame
|
|
66
|
+
if message is None and frame is not None:
|
|
67
|
+
message = f"machine returned {frame.response_name} (payload={frame.payload.hex()})"
|
|
68
|
+
super().__init__(message or "Avanti protocol error")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AvantiCommandCrcError(AvantiProtocolError):
|
|
72
|
+
"""The machine rejected a command because its CRC was invalid."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AvantiCommandLengthError(AvantiProtocolError):
|
|
76
|
+
"""The machine rejected a command because its length was invalid."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AvantiCommandError(AvantiProtocolError):
|
|
80
|
+
"""The machine rejected an unsupported or invalid command."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AvantiPinError(AvantiProtocolError):
|
|
84
|
+
"""The machine rejected the command PIN/handle."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AvantiNoResponseError(AvantiProtocolError):
|
|
88
|
+
"""The machine did not return a response frame."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AvantiUnexpectedResponseError(AvantiProtocolError):
|
|
92
|
+
"""The machine returned valid frames, but not the response we expected."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, expected: AvantiResponse, frames: list[AvantiFrame]) -> None:
|
|
95
|
+
self.expected = expected
|
|
96
|
+
self.frames = frames
|
|
97
|
+
names = ", ".join(frame.response_name for frame in frames) or "none"
|
|
98
|
+
super().__init__(message=f"expected {expected.name.lower()} response, got {names}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
PROTOCOL_ERROR_TYPES: dict[AvantiResponse, type[AvantiProtocolError]] = {
|
|
102
|
+
AvantiResponse.CRC_ERROR: AvantiCommandCrcError,
|
|
103
|
+
AvantiResponse.LEN_ERROR: AvantiCommandLengthError,
|
|
104
|
+
AvantiResponse.CMD_ERROR: AvantiCommandError,
|
|
105
|
+
AvantiResponse.PIN_ERROR: AvantiPinError,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class PowerState(StrEnum):
|
|
110
|
+
UNKNOWN = "unknown"
|
|
111
|
+
OFF = "off"
|
|
112
|
+
ON = "on"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class BrewState(StrEnum):
|
|
116
|
+
IDLE = "idle"
|
|
117
|
+
BREWING = "brewing"
|
|
118
|
+
RINSING = "rinsing"
|
|
119
|
+
CLEANING = "cleaning"
|
|
120
|
+
DESCALING = "descaling"
|
|
121
|
+
BUSY = "busy"
|
|
122
|
+
ERROR = "error"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class MachinePhase(IntEnum):
|
|
126
|
+
READY = 0
|
|
127
|
+
STOPPING_AFTER_ABORT = 16
|
|
128
|
+
DESCALING_PHASE_1 = 41
|
|
129
|
+
DESCALING_PHASE_2 = 42
|
|
130
|
+
CARAFE_WASHING_PHASE_1 = 50
|
|
131
|
+
CARAFE_WASHING_PHASE_2 = 51
|
|
132
|
+
BREWGROUP_CLEANING = 60
|
|
133
|
+
WATER_FILTER_ACTIVATION = 71
|
|
134
|
+
BREWING = 80
|
|
135
|
+
WATER_CIRCUIT_PRIMING = 81
|
|
136
|
+
PRESSURE_RECOVERY = 82
|
|
137
|
+
CARAFE_AUTOCLEAN = 85
|
|
138
|
+
BUSY = 90
|
|
139
|
+
SHUTTING_DOWN = 170
|
|
140
|
+
SHUTTING_DOWN_RINSING = 171
|
|
141
|
+
STARTING_UP = 186
|
|
142
|
+
STARTING_UP_HEATING_UP = 187
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class MaintenanceStatus(IntEnum):
|
|
146
|
+
NONE = 0
|
|
147
|
+
REPLACE_WATER_FILTER = 1
|
|
148
|
+
DESCALING_NEEDED = 2
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
WARNING_NAMES: dict[int, str] = {
|
|
152
|
+
1: "refill_water_tank",
|
|
153
|
+
2: "refill_coffee_beans",
|
|
154
|
+
4: "empty_drip_tray",
|
|
155
|
+
8: "close_front_door",
|
|
156
|
+
16: "insert_brew_group",
|
|
157
|
+
32: "insert_dreg_drawer",
|
|
158
|
+
64: "close_bean_hopper",
|
|
159
|
+
128: "empty_dreg_drawer",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
FEATURE_NAMES: dict[int, str] = {
|
|
164
|
+
1: "bean_hopper_door_present",
|
|
165
|
+
2: "water_filter_present",
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class AvantiRecipe:
|
|
171
|
+
product_number: int
|
|
172
|
+
coffee_ml: int = 0
|
|
173
|
+
milk_ml: int = 0
|
|
174
|
+
aroma: int = 3
|
|
175
|
+
prebrew: int = 1
|
|
176
|
+
temperature: int = 1
|
|
177
|
+
coffee_min_ml: int = 0
|
|
178
|
+
coffee_max_ml: int = 0
|
|
179
|
+
milk_min_ml: int = 0
|
|
180
|
+
milk_max_ml: int = 0
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def id(self) -> int:
|
|
184
|
+
return self.product_number
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def name(self) -> str:
|
|
188
|
+
words = []
|
|
189
|
+
for char in type(self).__name__:
|
|
190
|
+
if char.isupper() and words:
|
|
191
|
+
words.append(" ")
|
|
192
|
+
words.append(char)
|
|
193
|
+
return "".join(words)
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def slug(self) -> str:
|
|
197
|
+
return slugify(f"{self.id}_{self.name}")
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def expression(self) -> str:
|
|
201
|
+
args: dict[str, int] = {
|
|
202
|
+
"coffee_ml": self.coffee_ml,
|
|
203
|
+
"aroma": self.aroma,
|
|
204
|
+
"prebrew": self.prebrew,
|
|
205
|
+
"temperature": self.temperature,
|
|
206
|
+
}
|
|
207
|
+
if self.milk_ml:
|
|
208
|
+
args["milk_ml"] = self.milk_ml
|
|
209
|
+
rendered = ", ".join(f"{name}={value}" for name, value in args.items())
|
|
210
|
+
return f"{type(self).__name__}({rendered})"
|
|
211
|
+
|
|
212
|
+
def payload(self) -> bytes:
|
|
213
|
+
coffee_quantity = 0
|
|
214
|
+
if self.coffee_ml != 0:
|
|
215
|
+
coffee_quantity = amount_ml_to_scalar(
|
|
216
|
+
self.coffee_ml,
|
|
217
|
+
self.coffee_min_ml,
|
|
218
|
+
self.coffee_max_ml,
|
|
219
|
+
"coffee",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
milk_quantity = 0
|
|
223
|
+
if self.milk_ml != 0:
|
|
224
|
+
milk_quantity = amount_ml_to_scalar(
|
|
225
|
+
self.milk_ml,
|
|
226
|
+
self.milk_min_ml,
|
|
227
|
+
self.milk_max_ml,
|
|
228
|
+
"milk",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return bytes(
|
|
232
|
+
[
|
|
233
|
+
self.product_number & 0xFF,
|
|
234
|
+
self.aroma & 0xFF,
|
|
235
|
+
self.prebrew & 0xFF,
|
|
236
|
+
coffee_quantity & 0xFF,
|
|
237
|
+
self.temperature & 0xFF,
|
|
238
|
+
milk_quantity & 0xFF,
|
|
239
|
+
0x7B,
|
|
240
|
+
0x00,
|
|
241
|
+
]
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@dataclass(frozen=True)
|
|
246
|
+
class Espresso(AvantiRecipe):
|
|
247
|
+
def __init__(
|
|
248
|
+
self,
|
|
249
|
+
*,
|
|
250
|
+
coffee_ml: int = 50,
|
|
251
|
+
aroma: int = 5,
|
|
252
|
+
prebrew: int = 1,
|
|
253
|
+
temperature: int = 1,
|
|
254
|
+
) -> None:
|
|
255
|
+
super().__init__(
|
|
256
|
+
product_number=1,
|
|
257
|
+
coffee_ml=coffee_ml,
|
|
258
|
+
aroma=aroma,
|
|
259
|
+
prebrew=prebrew,
|
|
260
|
+
temperature=temperature,
|
|
261
|
+
coffee_min_ml=30,
|
|
262
|
+
coffee_max_ml=70,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@dataclass(frozen=True)
|
|
267
|
+
class Coffee(AvantiRecipe):
|
|
268
|
+
def __init__(
|
|
269
|
+
self,
|
|
270
|
+
*,
|
|
271
|
+
coffee_ml: int = 110,
|
|
272
|
+
aroma: int = 3,
|
|
273
|
+
prebrew: int = 1,
|
|
274
|
+
temperature: int = 1,
|
|
275
|
+
) -> None:
|
|
276
|
+
super().__init__(
|
|
277
|
+
product_number=3,
|
|
278
|
+
coffee_ml=coffee_ml,
|
|
279
|
+
aroma=aroma,
|
|
280
|
+
prebrew=prebrew,
|
|
281
|
+
temperature=temperature,
|
|
282
|
+
coffee_min_ml=70,
|
|
283
|
+
coffee_max_ml=140,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass(frozen=True)
|
|
288
|
+
class AmericanCoffee(AvantiRecipe):
|
|
289
|
+
def __init__(
|
|
290
|
+
self,
|
|
291
|
+
*,
|
|
292
|
+
coffee_ml: int = 170,
|
|
293
|
+
aroma: int = 3,
|
|
294
|
+
prebrew: int = 1,
|
|
295
|
+
temperature: int = 1,
|
|
296
|
+
) -> None:
|
|
297
|
+
super().__init__(
|
|
298
|
+
product_number=4,
|
|
299
|
+
coffee_ml=coffee_ml,
|
|
300
|
+
aroma=aroma,
|
|
301
|
+
prebrew=prebrew,
|
|
302
|
+
temperature=temperature,
|
|
303
|
+
coffee_min_ml=110,
|
|
304
|
+
coffee_max_ml=320,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@dataclass(frozen=True)
|
|
309
|
+
class Cappuccino(AvantiRecipe):
|
|
310
|
+
def __init__(
|
|
311
|
+
self,
|
|
312
|
+
*,
|
|
313
|
+
coffee_ml: int = 70,
|
|
314
|
+
milk_ml: int = 70,
|
|
315
|
+
aroma: int = 5,
|
|
316
|
+
prebrew: int = 1,
|
|
317
|
+
temperature: int = 1,
|
|
318
|
+
) -> None:
|
|
319
|
+
super().__init__(
|
|
320
|
+
product_number=10,
|
|
321
|
+
coffee_ml=coffee_ml,
|
|
322
|
+
milk_ml=milk_ml,
|
|
323
|
+
aroma=aroma,
|
|
324
|
+
prebrew=prebrew,
|
|
325
|
+
temperature=temperature,
|
|
326
|
+
coffee_min_ml=30,
|
|
327
|
+
coffee_max_ml=170,
|
|
328
|
+
milk_min_ml=40,
|
|
329
|
+
milk_max_ml=200,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass(frozen=True)
|
|
334
|
+
class AvantiFrame:
|
|
335
|
+
response: int
|
|
336
|
+
payload: bytes
|
|
337
|
+
crc_ok: bool
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def response_name(self) -> str:
|
|
341
|
+
try:
|
|
342
|
+
return AvantiResponse(self.response).name.lower()
|
|
343
|
+
except ValueError:
|
|
344
|
+
return "unknown"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def raise_for_response_errors(frames: list[AvantiFrame]) -> None:
|
|
348
|
+
for frame in frames:
|
|
349
|
+
if not frame.crc_ok:
|
|
350
|
+
raise AvantiResponseCrcError(frame)
|
|
351
|
+
try:
|
|
352
|
+
response = AvantiResponse(frame.response)
|
|
353
|
+
except ValueError:
|
|
354
|
+
continue
|
|
355
|
+
if response in ERROR_RESPONSES:
|
|
356
|
+
raise PROTOCOL_ERROR_TYPES[response](frame)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def require_response(frames: list[AvantiFrame], expected: AvantiResponse) -> None:
|
|
360
|
+
if not frames:
|
|
361
|
+
raise AvantiNoResponseError(message=f"expected {expected.name.lower()} response")
|
|
362
|
+
if not any(frame.response == expected for frame in frames):
|
|
363
|
+
raise AvantiUnexpectedResponseError(expected, frames)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class MachineStatus(BaseModel):
|
|
367
|
+
"""State document published to MQTT."""
|
|
368
|
+
|
|
369
|
+
model_config = ConfigDict(frozen=True)
|
|
370
|
+
|
|
371
|
+
power: PowerState = PowerState.UNKNOWN
|
|
372
|
+
running: bool = False
|
|
373
|
+
brew: BrewState = BrewState.IDLE
|
|
374
|
+
error: int = 0
|
|
375
|
+
warning: tuple[str, ...] = ()
|
|
376
|
+
maintenance: str | None = None
|
|
377
|
+
phase: int | None = None
|
|
378
|
+
features: tuple[str, ...] = ()
|
|
379
|
+
phase_name: str | None = None
|
|
380
|
+
descaling_needed: bool = False
|
|
381
|
+
raw: dict[str, object] = Field(default_factory=dict)
|
|
382
|
+
|
|
383
|
+
@classmethod
|
|
384
|
+
def from_avanti_frames(
|
|
385
|
+
cls,
|
|
386
|
+
frames: list[AvantiFrame],
|
|
387
|
+
) -> MachineStatus:
|
|
388
|
+
frame_list = list(frames)
|
|
389
|
+
raw_frames = [_frame_to_raw(frame) for frame in frame_list]
|
|
390
|
+
status_frame = next(
|
|
391
|
+
(
|
|
392
|
+
frame
|
|
393
|
+
for frame in reversed(frame_list)
|
|
394
|
+
if frame.crc_ok
|
|
395
|
+
and frame.response == AvantiResponse.READ_STATUS
|
|
396
|
+
and len(frame.payload) >= 5
|
|
397
|
+
),
|
|
398
|
+
None,
|
|
399
|
+
)
|
|
400
|
+
if status_frame is None:
|
|
401
|
+
return cls(raw={"transport": "ble", "frames": raw_frames})
|
|
402
|
+
|
|
403
|
+
error, warning, maintenance, phase, features = status_frame.payload[:5]
|
|
404
|
+
brew = _brew_for_status(error, phase)
|
|
405
|
+
return cls(
|
|
406
|
+
power=_power_for_phase(phase),
|
|
407
|
+
running=brew != BrewState.IDLE,
|
|
408
|
+
brew=brew,
|
|
409
|
+
error=error,
|
|
410
|
+
warning=_active_names(warning, WARNING_NAMES),
|
|
411
|
+
maintenance=_maintenance_name(maintenance),
|
|
412
|
+
phase=phase,
|
|
413
|
+
features=_active_names(features, FEATURE_NAMES),
|
|
414
|
+
phase_name=_enum_name(MachinePhase, phase),
|
|
415
|
+
descaling_needed=maintenance == MaintenanceStatus.DESCALING_NEEDED,
|
|
416
|
+
raw={"transport": "ble", "frames": raw_frames},
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def crc16_ccitt(data: bytes) -> int:
|
|
421
|
+
return binascii.crc_hqx(data, 0xFFFF)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def build_command_packet(
|
|
425
|
+
command: AvantiCommand | int,
|
|
426
|
+
payload: bytes = b"",
|
|
427
|
+
handle: int = DEFAULT_COMMAND_HANDLE,
|
|
428
|
+
) -> bytes:
|
|
429
|
+
if not 0 <= handle <= 0xFFFF:
|
|
430
|
+
raise ValueError("command handle must fit in 16 bits")
|
|
431
|
+
body = bytes([(handle >> 8) & 0xFF, handle & 0xFF, int(command) & 0xFF]) + payload
|
|
432
|
+
crc = crc16_ccitt(body)
|
|
433
|
+
return (
|
|
434
|
+
bytes([0xFE, 0xFF, 0x0F, (len(body) >> 8) & 0xFF, len(body) & 0xFF])
|
|
435
|
+
+ body
|
|
436
|
+
+ bytes([(crc >> 8) & 0xFF, crc & 0xFF])
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def parse_response_frame(data: bytes) -> AvantiFrame | None:
|
|
441
|
+
if len(data) < 8 or data[:3] != b"\xfe\xff\x0f":
|
|
442
|
+
return None
|
|
443
|
+
length = (data[3] << 8) | data[4]
|
|
444
|
+
body = data[5 : 5 + length]
|
|
445
|
+
if not body or len(data) < 7 + length:
|
|
446
|
+
return None
|
|
447
|
+
expected_crc = int.from_bytes(data[5 + length : 7 + length], "big")
|
|
448
|
+
return AvantiFrame(body[0], body[1:], expected_crc == crc16_ccitt(body))
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _frame_to_raw(frame: object) -> dict[str, object]:
|
|
452
|
+
return {
|
|
453
|
+
"response": frame.response,
|
|
454
|
+
"response_name": frame.response_name,
|
|
455
|
+
"payload": frame.payload.hex(),
|
|
456
|
+
"crc_ok": frame.crc_ok,
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _enum_name(enum: type[IntEnum], value: int) -> str | None:
|
|
461
|
+
try:
|
|
462
|
+
return enum(value).name.lower()
|
|
463
|
+
except ValueError:
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _maintenance_name(value: int) -> str | None:
|
|
468
|
+
if value == MaintenanceStatus.NONE:
|
|
469
|
+
return None
|
|
470
|
+
return _enum_name(MaintenanceStatus, value) or "unknown"
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _active_names(value: int, names: dict[int, str]) -> tuple[str, ...]:
|
|
474
|
+
return tuple(name for bit, name in names.items() if value & bit)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _power_for_phase(phase: int) -> PowerState:
|
|
478
|
+
del phase
|
|
479
|
+
return PowerState.ON
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _brew_for_status(error: int, phase: int) -> BrewState:
|
|
483
|
+
if error:
|
|
484
|
+
return BrewState.ERROR
|
|
485
|
+
if phase in {MachinePhase.BREWING, MachinePhase.CARAFE_AUTOCLEAN}:
|
|
486
|
+
return BrewState.BREWING
|
|
487
|
+
if phase in {
|
|
488
|
+
MachinePhase.CARAFE_WASHING_PHASE_1,
|
|
489
|
+
MachinePhase.CARAFE_WASHING_PHASE_2,
|
|
490
|
+
MachinePhase.SHUTTING_DOWN_RINSING,
|
|
491
|
+
}:
|
|
492
|
+
return BrewState.RINSING
|
|
493
|
+
if phase == MachinePhase.BREWGROUP_CLEANING:
|
|
494
|
+
return BrewState.CLEANING
|
|
495
|
+
if phase in {MachinePhase.DESCALING_PHASE_1, MachinePhase.DESCALING_PHASE_2}:
|
|
496
|
+
return BrewState.DESCALING
|
|
497
|
+
if phase in {
|
|
498
|
+
MachinePhase.BUSY,
|
|
499
|
+
MachinePhase.STARTING_UP,
|
|
500
|
+
MachinePhase.STARTING_UP_HEATING_UP,
|
|
501
|
+
MachinePhase.WATER_CIRCUIT_PRIMING,
|
|
502
|
+
MachinePhase.WATER_FILTER_ACTIVATION,
|
|
503
|
+
MachinePhase.PRESSURE_RECOVERY,
|
|
504
|
+
}:
|
|
505
|
+
return BrewState.BUSY
|
|
506
|
+
return BrewState.IDLE
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
class ResponseAssembler:
|
|
510
|
+
def __init__(self) -> None:
|
|
511
|
+
self.buffer = bytearray()
|
|
512
|
+
|
|
513
|
+
def feed(self, data: bytes) -> list[bytes]:
|
|
514
|
+
if not data:
|
|
515
|
+
return []
|
|
516
|
+
if self.buffer or data.startswith(b"\xfe"):
|
|
517
|
+
self.buffer.extend(data)
|
|
518
|
+
return self._drain()
|
|
519
|
+
return [data]
|
|
520
|
+
|
|
521
|
+
def _drain(self) -> list[bytes]:
|
|
522
|
+
responses: list[bytes] = []
|
|
523
|
+
while len(self.buffer) >= 5:
|
|
524
|
+
if not self.buffer.startswith(b"\xfe\xff\x0f"):
|
|
525
|
+
responses.append(bytes(self.buffer))
|
|
526
|
+
self.buffer.clear()
|
|
527
|
+
break
|
|
528
|
+
length = (self.buffer[3] << 8) | self.buffer[4]
|
|
529
|
+
total = 7 + length
|
|
530
|
+
if len(self.buffer) < total:
|
|
531
|
+
break
|
|
532
|
+
frame = bytes(self.buffer[:total])
|
|
533
|
+
del self.buffer[:total]
|
|
534
|
+
responses.append(frame)
|
|
535
|
+
return responses
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def standby_packet(handle: int = DEFAULT_COMMAND_HANDLE) -> bytes:
|
|
539
|
+
return build_command_packet(AvantiCommand.ENTER_STANDBY, handle=handle)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def show_pin_packet() -> bytes:
|
|
543
|
+
return build_command_packet(AvantiCommand.SHOW_PIN)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def read_status_packet(handle: int = DEFAULT_COMMAND_HANDLE) -> bytes:
|
|
547
|
+
return build_command_packet(AvantiCommand.READ_STATUS, handle=handle)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def stop_brewing_packet(handle: int = DEFAULT_COMMAND_HANDLE) -> bytes:
|
|
551
|
+
return build_command_packet(AvantiCommand.STOP_BREWING, handle=handle)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def brew_packet(recipe: AvantiRecipe, handle: int = DEFAULT_COMMAND_HANDLE) -> bytes:
|
|
555
|
+
return build_command_packet(AvantiCommand.BREW_PRODUCT, recipe.payload(), handle=handle)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
AVANTI_RECIPES: tuple[AvantiRecipe, ...] = (
|
|
559
|
+
Espresso(),
|
|
560
|
+
Coffee(),
|
|
561
|
+
AmericanCoffee(),
|
|
562
|
+
Cappuccino(),
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
_RECIPE_TYPES: dict[str, type[AvantiRecipe]] = {
|
|
566
|
+
recipe_type.__name__: recipe_type
|
|
567
|
+
for recipe_type in (Espresso, Coffee, AmericanCoffee, Cappuccino)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def parse_recipe_expression(expression: str) -> AvantiRecipe:
|
|
572
|
+
node = ast.parse(expression, mode="eval").body
|
|
573
|
+
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name):
|
|
574
|
+
raise ValueError("recipe must be a call like Espresso(coffee_ml=50)")
|
|
575
|
+
recipe_type = _RECIPE_TYPES.get(node.func.id)
|
|
576
|
+
if recipe_type is None:
|
|
577
|
+
choices = ", ".join(sorted(_RECIPE_TYPES))
|
|
578
|
+
raise ValueError(f"unknown recipe {node.func.id!r}; expected one of: {choices}")
|
|
579
|
+
if node.args:
|
|
580
|
+
raise ValueError(f"recipe expressions only support keyword arguments: {expression}")
|
|
581
|
+
|
|
582
|
+
kwargs: dict[str, Any] = {}
|
|
583
|
+
for keyword in node.keywords:
|
|
584
|
+
if keyword.arg is None:
|
|
585
|
+
raise ValueError(f"recipe expressions do not support **kwargs: {expression}")
|
|
586
|
+
value = ast.literal_eval(keyword.value)
|
|
587
|
+
if not isinstance(value, int):
|
|
588
|
+
raise ValueError(f"{keyword.arg} must be an integer")
|
|
589
|
+
kwargs[keyword.arg] = value
|
|
590
|
+
|
|
591
|
+
return recipe_type(**kwargs)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def amount_ml_to_scalar(value: int, minimum: int, maximum: int, label: str = "amount") -> int:
|
|
595
|
+
if minimum == 0 and maximum == 0:
|
|
596
|
+
raise ValueError(f"{label} amount is not supported for this recipe")
|
|
597
|
+
if value < minimum or value > maximum:
|
|
598
|
+
raise ValueError(f"{label} amount must be between {minimum}ml and {maximum}ml")
|
|
599
|
+
|
|
600
|
+
span = maximum - minimum
|
|
601
|
+
if span <= 0:
|
|
602
|
+
return 0
|
|
603
|
+
value = round_to_step(value, 5)
|
|
604
|
+
return round(((value - minimum) / span) * 100)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def round_to_step(value: int, step: int) -> int:
|
|
608
|
+
return ((value + step // 2) // step) * step
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def slugify(value: str) -> str:
|
|
612
|
+
return "".join(char.lower() if char.isalnum() else "_" for char in value).strip("_")
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def command_message(action: str, **data: object) -> str:
|
|
616
|
+
return json.dumps({"action": action, **data}, separators=(",", ":"))
|