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 ADDED
@@ -0,0 +1,6 @@
1
+ """Python library for Saeco-family coffee machines."""
2
+
3
+ from pysaeco.avanti import MachineStatus
4
+ from pysaeco.client import SaecoClient, scan
5
+
6
+ __all__ = ["MachineStatus", "SaecoClient", "scan"]
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=(",", ":"))