sofapython 0.0.1rc1__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.
@@ -0,0 +1,943 @@
1
+ """Activity-side write/edit-flow mixin for :class:`X1Proxy`.
2
+
3
+ Houses the operations the user invokes against an existing activity --
4
+ removing devices, confirming downstream activity-row updates after a
5
+ device delete, adding new commands to physical buttons, reordering /
6
+ deleting / appending favourites. Each operation walks the same three-
7
+ phase shape (map → stage → commit) implemented as a small handful of
8
+ ``_send_step`` calls plus a final ``request_activity_mapping`` refresh.
9
+
10
+ The macro save and ack-wait helpers continue to live on the proxy
11
+ itself; this mixin only owns the orchestration of activity-level edits.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import time
18
+ from typing import Any
19
+
20
+ from .hub_versions import HUB_VERSION_X1, HUB_VERSION_X1S, HUB_VERSION_X2
21
+ from .hub_logging import LogTag
22
+ from .device_create import build_button_binding_step, synthesize_command_code
23
+ from .protocol_const import (
24
+ BUTTONNAME_BY_CODE,
25
+ ButtonName,
26
+ FAMILY_FAV_DELETE,
27
+ FAMILY_FAV_ORDER_REQ,
28
+ OP_ACTIVITY_ASSIGN_FINALIZE,
29
+ OP_ACTIVITY_CONFIRM,
30
+ OP_ACTIVITY_DEVICE_CONFIRM,
31
+ OP_REQ_MACRO_LABELS,
32
+ )
33
+
34
+ log = logging.getLogger("x1proxy")
35
+
36
+
37
+ # Position of the tail token block inside a CATALOG_ROW_ACTIVITY payload.
38
+ # See the activity-row schema comment in ``opcode_handlers`` for details.
39
+ _ACTIVITY_ROW_TAIL_OFFSET_IN_PAYLOAD = 152
40
+ _ACTIVITY_ROW_TAIL_LEN = 60
41
+
42
+
43
+ class ActivityOpsMixin:
44
+ """Mixin providing activity-edit orchestration."""
45
+
46
+ def _wait_for_activity_map_burst(self, act_id: int, *, timeout: float = 5.0) -> bool:
47
+ deadline = time.monotonic() + timeout
48
+ act_lo = act_id & 0xFF
49
+ while time.monotonic() < deadline:
50
+ if act_lo in self._activity_map_complete:
51
+ return True
52
+ time.sleep(0.05)
53
+ self._log.warning("[ACTMAP] timeout waiting for activity map burst act=0x%02X", act_lo)
54
+ return False
55
+
56
+ def _activities_requiring_confirmation(self) -> list[int]:
57
+ targets: list[int] = []
58
+ for act_lo, details in sorted(self.state.entities("activity").items()):
59
+ if not isinstance(details, dict):
60
+ continue
61
+ if bool(details.get("needs_confirm", False)):
62
+ targets.append(act_lo & 0xFF)
63
+ return targets
64
+
65
+ def _clear_x1s_confirm_flag(self, payload: bytes) -> bytes:
66
+ """Return ``payload`` with the activity-row needs-confirm flag cleared.
67
+
68
+ The flag lives at the value byte of the final ``fc XX fc YY`` sub-token
69
+ pair inside the row's tail token block (see the activity-row schema
70
+ comment in ``opcode_handlers``). Setting it to ``0x00`` tells the hub
71
+ the user has acknowledged the impact of a device delete.
72
+ """
73
+
74
+ mutable = bytearray(payload)
75
+ tail_start = _ACTIVITY_ROW_TAIL_OFFSET_IN_PAYLOAD
76
+ tail_end = min(len(mutable), tail_start + _ACTIVITY_ROW_TAIL_LEN)
77
+ if tail_end - tail_start < 4:
78
+ return bytes(mutable)
79
+
80
+ marker_indexes = [
81
+ idx
82
+ for idx in range(tail_start, tail_end - 3)
83
+ if mutable[idx] == 0xFC and mutable[idx + 2] == 0xFC
84
+ ]
85
+ if marker_indexes:
86
+ mutable[marker_indexes[-1] + 3] = 0x00
87
+ return bytes(mutable)
88
+
89
+ def _build_activity_confirm_payload(self, activity_id: int) -> bytes | None:
90
+ act_lo = activity_id & 0xFF
91
+ activity = self.state.entities("activity").get(act_lo)
92
+ if not isinstance(activity, dict):
93
+ return None
94
+
95
+ if self.hub_version in (HUB_VERSION_X1S, HUB_VERSION_X2):
96
+ row_payload = self._activity_row_payloads.get(act_lo)
97
+ if isinstance(row_payload, (bytes, bytearray)) and len(row_payload) >= 120:
98
+ return self._clear_x1s_confirm_flag(bytes(row_payload))
99
+
100
+ name = str(activity.get("name", ""))
101
+ encoded_name = name.encode("ascii", errors="ignore")[:60].ljust(60, b"\x00")
102
+ active_flag = 0x01 if bool(activity.get("active", False)) else 0x02
103
+
104
+ return (
105
+ bytes([
106
+ 0x01,
107
+ 0x00,
108
+ 0x01,
109
+ 0x01,
110
+ 0x00,
111
+ 0x01,
112
+ 0x00,
113
+ act_lo,
114
+ 0x01,
115
+ active_flag,
116
+ ])
117
+ + (b"\x00" * 22)
118
+ + encoded_name
119
+ + bytes([0xFC, 0x00, 0xFC, 0x00])
120
+ + (b"\x00" * 27)
121
+ )
122
+
123
+ def delete_device(self, device_id: int) -> dict[str, Any] | None:
124
+ if not self.can_issue_commands():
125
+ self._log.info("[DELETE] delete_device ignored: proxy client is connected")
126
+ return None
127
+
128
+ dev_lo = device_id & 0xFF
129
+ self.reset_ack_queues()
130
+
131
+ _step = self._send_step(
132
+ step_name=f"delete-device[dev=0x{dev_lo:02X}]",
133
+ family=0x09,
134
+ payload=bytes([dev_lo]),
135
+ ack_opcode=0x0103,
136
+ timeout=120.0,
137
+ )
138
+ if not _step.ok:
139
+ return None
140
+
141
+ if not self.request_activities():
142
+ self._log.warning("[DELETE] failed to refresh activities after deleting dev=0x%02X", dev_lo)
143
+ return None
144
+
145
+ deadline = time.monotonic() + 15.0
146
+ while time.monotonic() < deadline:
147
+ if self._burst.active and self._burst.kind == "activities":
148
+ break
149
+ time.sleep(0.01)
150
+ while time.monotonic() < deadline:
151
+ if not self._burst.active:
152
+ break
153
+ time.sleep(0.01)
154
+ if self._burst.active:
155
+ self._log.warning("[DELETE] timeout waiting for activities burst after deleting dev=0x%02X", dev_lo)
156
+ return None
157
+
158
+ confirmed_activities: list[int] = []
159
+ for act_lo in self._activities_requiring_confirmation():
160
+ confirm_payload = self._build_activity_confirm_payload(act_lo)
161
+ if confirm_payload is None:
162
+ self._log.warning("[DELETE] missing cached activity row for confirm act=0x%02X", act_lo)
163
+ return None
164
+
165
+ confirm_opcode = (
166
+ OP_ACTIVITY_ASSIGN_FINALIZE
167
+ if self.hub_version in (HUB_VERSION_X1S, HUB_VERSION_X2)
168
+ else OP_ACTIVITY_CONFIRM
169
+ )
170
+ self._log.info("[DELETE] confirming updated activity act=0x%02X", act_lo)
171
+ send_ts = time.monotonic()
172
+ self._send_cmd_frame(confirm_opcode, confirm_payload)
173
+ if self.wait_for_ack_any([(0x0103, None)], timeout=5.0, not_before=send_ts) is None:
174
+ self._log.warning("[DELETE] missing ACK after activity confirm act=0x%02X", act_lo)
175
+ return None
176
+
177
+ activity = self.state.entities("activity").get(act_lo)
178
+ if isinstance(activity, dict):
179
+ activity["needs_confirm"] = False
180
+ self.clear_entity_cache(act_lo, clear_buttons=True, clear_favorites=True, clear_macros=True)
181
+ confirmed_activities.append(act_lo)
182
+
183
+ self.state.devices.pop(dev_lo, None)
184
+ self.state.buttons.pop(dev_lo, None)
185
+ self.state.ip_devices.pop(dev_lo, None)
186
+ self.state.ip_buttons.pop(dev_lo, None)
187
+ self.clear_entity_cache(dev_lo)
188
+
189
+ return {
190
+ "device_id": dev_lo,
191
+ "confirmed_activities": confirmed_activities,
192
+ "status": "success",
193
+ }
194
+
195
+ def add_device_to_activity(
196
+ self,
197
+ activity_id: int,
198
+ device_id: int,
199
+ *,
200
+ input_cmd_id: int | None = None,
201
+ ) -> dict[str, Any] | None:
202
+ """Add ``device_id`` to ``activity_id`` and replay POWER_ON/OFF macro updates."""
203
+
204
+ if not self.can_issue_commands():
205
+ self._log.info("[ACTIVITY_ASSIGN] add_device_to_activity ignored: proxy client is connected")
206
+ return None
207
+
208
+ act_lo = activity_id & 0xFF
209
+ dev_lo = device_id & 0xFF
210
+
211
+ self._log.info("[ACTIVITY_ASSIGN] start act=0x%02X (%d) add dev=0x%02X (%d)", act_lo, act_lo, dev_lo, dev_lo)
212
+
213
+ self._activity_map_complete.discard(act_lo)
214
+ # Refresh mapping-derived members/slots to avoid carrying stale entries
215
+ # from prior bursts into a new assignment transaction.
216
+ self.state.activity_members.pop(act_lo, None)
217
+ self.state.activity_command_refs.pop(act_lo, None)
218
+ self.state.activity_favorite_slots.pop(act_lo, None)
219
+ self.state.activity_keybinding_slots.pop(act_lo, None)
220
+ self.state.activity_favorite_labels.pop(act_lo, None)
221
+ self.state.activity_keybinding_labels.pop(act_lo, None)
222
+ self._clear_favorite_label_requests_for_activity(act_lo)
223
+
224
+ if not self.request_activity_mapping(act_lo):
225
+ self._log.warning("[ACTIVITY_ASSIGN] failed to request activity map for act=0x%02X", act_lo)
226
+ return None
227
+ if not self._wait_for_activity_map_burst(act_lo, timeout=5.0):
228
+ return None
229
+
230
+ current_members = self.state.get_activity_members(act_lo)
231
+ if not current_members:
232
+ # Fallback for older cached data paths where only favorites were parsed.
233
+ current_members = sorted(
234
+ {
235
+ int(slot.get("device_id", 0)) & 0xFF
236
+ for slot in self.state.get_activity_favorite_slots(act_lo)
237
+ if int(slot.get("device_id", 0)) & 0xFF
238
+ }
239
+ )
240
+ if not current_members:
241
+ self._log.warning("[ACTIVITY_ASSIGN] no existing members discovered for act=0x%02X", act_lo)
242
+
243
+ ordered_members: list[int] = []
244
+ for member in current_members + [dev_lo]:
245
+ if member not in ordered_members:
246
+ ordered_members.append(member)
247
+
248
+ self._log.info("[ACTIVITY_ASSIGN] members before=%s target=%s", current_members, ordered_members)
249
+
250
+ self.reset_ack_queues()
251
+
252
+ for member in ordered_members:
253
+ # Always send 0x00: the hub interprets this second byte as the
254
+ # device's current power state (0x01 = on, 0x00 = off). The earlier
255
+ # pattern of 0x01 for the first two rows was copied from a capture
256
+ # where those devices happened to be on, causing the hub to mark them
257
+ # as powered-on after every device-to-activity operation. Displacement
258
+ # prevention comes from replaying the full ordered member list, not
259
+ # from this flag value.
260
+ include_flag = 0x00
261
+ payload = bytes([member & 0xFF, include_flag])
262
+ self._log.info(
263
+ "[ACTIVITY_ASSIGN] confirm member dev=0x%02X include=0x%02X",
264
+ member & 0xFF,
265
+ include_flag,
266
+ )
267
+ send_ts = time.monotonic()
268
+ self._send_cmd_frame(OP_ACTIVITY_DEVICE_CONFIRM, payload)
269
+ if self.wait_for_ack_any([(0x0103, None)], timeout=5.0, not_before=send_ts) is None:
270
+ self._log.warning(
271
+ "[ACTIVITY_ASSIGN] missing ACK after 0x024F dev=0x%02X include=0x%02X",
272
+ member & 0xFF,
273
+ include_flag,
274
+ )
275
+ return None
276
+
277
+ input_index = 0
278
+ if input_cmd_id is not None:
279
+ resolved = self.query_device_input_index(dev_lo, input_cmd_id & 0xFF)
280
+ if resolved is not None:
281
+ input_index = resolved
282
+ else:
283
+ self._log.warning(
284
+ "[ACTIVITY_ASSIGN] input_cmd_id=0x%02X not found for dev=0x%02X; proceeding without input",
285
+ input_cmd_id & 0xFF,
286
+ dev_lo,
287
+ )
288
+
289
+ macro_updates: list[int] = []
290
+ for macro_button in (ButtonName.POWER_ON, ButtonName.POWER_OFF):
291
+ macro_name = BUTTONNAME_BY_CODE.get(macro_button, f"0x{macro_button:02X}")
292
+ self._log.info("[ACTIVITY_ASSIGN] fetch macro act=0x%02X button=%s", act_lo, macro_name)
293
+ self._send_cmd_frame(OP_REQ_MACRO_LABELS, bytes([act_lo, macro_button]))
294
+
295
+ source_record = self.wait_for_macro_record(act_lo, macro_button, timeout=5.0)
296
+ if source_record is None:
297
+ self._log.warning(
298
+ "[ACTIVITY_ASSIGN] missing macro record act=0x%02X button=0x%02X",
299
+ act_lo,
300
+ macro_button,
301
+ )
302
+ return None
303
+
304
+ updated_payload = self._build_macro_save_payload(
305
+ source_record,
306
+ device_id=dev_lo,
307
+ button_id=macro_button,
308
+ allowed_device_ids=set(ordered_members),
309
+ input_index=input_index,
310
+ )
311
+
312
+ row_count = updated_payload[8] if len(updated_payload) >= 9 else 0
313
+ page_payloads = self._build_paged_macro_save_payloads(updated_payload)
314
+ self._log.info(
315
+ "[ACTIVITY_ASSIGN] save macro act=0x%02X button=0x%02X payload=%dB rows=%d pages=%d",
316
+ act_lo,
317
+ macro_button,
318
+ len(updated_payload),
319
+ row_count,
320
+ len(page_payloads),
321
+ )
322
+ if self.diag_dump:
323
+ self._log.info("[ACTIVITY_ASSIGN] save macro payload (%dB)", len(updated_payload))
324
+
325
+ macro_ack = self._send_paged_macro_save(
326
+ payload=updated_payload,
327
+ macro_button=macro_button,
328
+ ack_timeout=5.0,
329
+ )
330
+
331
+ if macro_ack is None:
332
+ return None
333
+
334
+ ack_opcode, ack_payload = macro_ack
335
+ if ack_opcode == 0x0112 and ack_payload and ack_payload[0] != (macro_button & 0xFF):
336
+ self._log.info(
337
+ "[ACTIVITY_ASSIGN] macro save ack fallback act=0x%02X button=0x%02X ack=0x%02X",
338
+ act_lo,
339
+ macro_button,
340
+ ack_payload[0],
341
+ )
342
+ macro_updates.append(macro_button)
343
+
344
+ self.clear_entity_cache(
345
+ act_lo,
346
+ clear_buttons=False,
347
+ clear_favorites=False,
348
+ clear_macros=True,
349
+ )
350
+
351
+ self._log.info("[ACTIVITY_ASSIGN] completed act=0x%02X add dev=0x%02X with macro updates", act_lo, dev_lo)
352
+ return {
353
+ "activity_id": act_lo,
354
+ "device_id": dev_lo,
355
+ "members_before": current_members,
356
+ "members_confirmed": ordered_members,
357
+ "macros_updated": macro_updates,
358
+ "status": "success",
359
+ }
360
+
361
+ def _build_favorite_map_payload(
362
+ self,
363
+ *,
364
+ activity_id: int,
365
+ device_id: int,
366
+ command_id: int,
367
+ slot_id: int,
368
+ ) -> bytes:
369
+ payload = bytearray(
370
+ [
371
+ 0x01,
372
+ 0x00,
373
+ 0x01,
374
+ 0x01,
375
+ 0x00,
376
+ 0x01,
377
+ activity_id & 0xFF,
378
+ slot_id & 0xFF,
379
+ device_id & 0xFF,
380
+ 0x00,
381
+ 0x00,
382
+ 0x00,
383
+ 0x00,
384
+ ]
385
+ )
386
+ cmd_lo = command_id & 0xFF
387
+ if self.hub_version in (HUB_VERSION_X1S, HUB_VERSION_X2):
388
+ payload.extend([0x4E, 0x20 + cmd_lo])
389
+ else:
390
+ payload.extend((0x4E24).to_bytes(2, "big"))
391
+ payload.extend(
392
+ [
393
+ cmd_lo,
394
+ 0x00,
395
+ 0x00,
396
+ 0x00,
397
+ 0x00,
398
+ 0x00,
399
+ 0x00,
400
+ 0x00,
401
+ 0x00,
402
+ ]
403
+ )
404
+ payload.append((sum(payload) - 2) & 0xFF)
405
+ return bytes(payload)
406
+
407
+ def _build_favorite_stage_payload(self, activity_id: int, fav_count: int = 4) -> bytes:
408
+ """Build the 0x61 stage payload for favorite writes.
409
+
410
+ *fav_count* is the total number of favorites on the activity **after**
411
+ the new entry has been registered by the hub (i.e. the fav_id returned
412
+ in the 0x013E map ACK). The payload encodes one ``(fav_id, slot)``
413
+ pair per favorite in sequential order:
414
+
415
+ 01 01 02 02 03 03 … [fav_count] [fav_count]
416
+
417
+ The official app builds this list dynamically — it grows by one entry
418
+ each time a favorite is added. The previous hard-coded version stopped
419
+ at exactly 4 pairs, which left any 5th-or-later favorite without a
420
+ display slot, making it invisible on the physical remote's touch screen.
421
+ """
422
+
423
+ act_lo = activity_id & 0xFF
424
+ if self.hub_version in (HUB_VERSION_X1S, HUB_VERSION_X2):
425
+ payload = bytearray([0x01, 0x00, 0x01, 0x01, 0x00, 0x01, act_lo])
426
+ for i in range(1, max(1, fav_count) + 1):
427
+ payload.append(i & 0xFF) # fav_id
428
+ payload.append(i & 0xFF) # slot = fav_id (sequential 1-based)
429
+ payload.append((sum(payload) - 2) & 0xFF)
430
+ return bytes(payload)
431
+
432
+ return bytes([0x00, 0x01, 0x01, 0x00, 0x01, act_lo, 0x01, 0x01, 0x6A])
433
+
434
+ def _build_favorites_reorder_payload(
435
+ self,
436
+ act_lo: int,
437
+ ordered_fav_ids: list[int],
438
+ ) -> bytes:
439
+ """Build the family-0x61 SET_FAVORITES_ORDER payload.
440
+
441
+ Frame structure (app→hub):
442
+ [01 00 01 01 00 01] [act_lo] [fav_id slot] × N [token]
443
+
444
+ The token is computed with the same formula used by
445
+ _build_favorite_stage_payload: ``(sum(payload_so_far) - 2) & 0xFF``.
446
+
447
+ Verified against 5 captured reorder frames (N=5..9).
448
+ """
449
+ act_lo = act_lo & 0xFF
450
+ payload = bytearray([0x01, 0x00, 0x01, 0x01, 0x00, 0x01, act_lo])
451
+ for slot_index, fav_id in enumerate(ordered_fav_ids, start=1):
452
+ payload.append(fav_id & 0xFF)
453
+ payload.append(slot_index & 0xFF)
454
+ payload.append((sum(payload) - 2) & 0xFF)
455
+ return bytes(payload)
456
+
457
+ def request_favorites_order(self, activity_id: int) -> list[tuple[int, int]] | None:
458
+ """Request the current favorites ordering from the hub for *activity_id*.
459
+
460
+ Sends OP_FAV_ORDER_REQ (opcode 0x0162) and blocks until the hub replies
461
+ with a family-0x63 response (parsed by FavoritesOrderHandler).
462
+
463
+ Returns a list of ``(fav_id, slot)`` tuples sorted by slot (ascending),
464
+ or ``None`` on timeout.
465
+ """
466
+ act_lo = activity_id & 0xFF
467
+ self.reset_ack_queues()
468
+ send_ts = time.monotonic()
469
+ self._send_family_frame(FAMILY_FAV_ORDER_REQ, bytes([act_lo]))
470
+ # FavoritesOrderHandler fires synthetic ack 0xFF63 with first byte = act_lo
471
+ result = self.wait_for_ack_any([(0xFF63, act_lo)], timeout=5.0, not_before=send_ts)
472
+ if result is None:
473
+ self._log.warning("[FAV_ORDER] timeout waiting for hub response act=0x%02X", act_lo)
474
+ return None
475
+ return self.state.activity_favorites_order.get(act_lo)
476
+
477
+ def _validate_favorite_fav_id(
478
+ self,
479
+ act_lo: int,
480
+ fav_id: int,
481
+ current_order: list[tuple[int, int]],
482
+ ) -> int | None:
483
+ """Validate that *fav_id* is a known quick-access identifier.
484
+
485
+ X1S hubs may return a partial 0x63 favorites-order response even when
486
+ the app's Macro & Favorite Keys UI exposes additional quick-access
487
+ entries discovered through the activity keymap and macro caches. Treat
488
+ the latest hub order as authoritative for ordering when present, but
489
+ also accept cached quick-access ``button_id`` / macro ids as writable
490
+ identifiers when they are visible for the activity.
491
+ """
492
+
493
+ fav_lo = fav_id & 0xFF
494
+ if any((known_fav_id & 0xFF) == fav_lo for known_fav_id, _slot in current_order):
495
+ return fav_lo
496
+ if any(
497
+ (int(slot.get("button_id", 0)) & 0xFF) == fav_lo
498
+ for slot in self.state.get_activity_favorite_slots(act_lo)
499
+ ):
500
+ return fav_lo
501
+ if any(
502
+ (int(macro.get("command_id", 0)) & 0xFF) == fav_lo
503
+ for macro in self.state.get_activity_macros(act_lo)
504
+ ):
505
+ return fav_lo
506
+ return None
507
+
508
+ def reorder_favorites(
509
+ self,
510
+ activity_id: int,
511
+ ordered_fav_ids: list[int],
512
+ *,
513
+ refresh_after_write: bool = True,
514
+ ) -> dict[str, Any] | None:
515
+ """Re-order favorites for *activity_id* to match *ordered_fav_ids*.
516
+
517
+ *ordered_fav_ids* is the list of quick-access ``fav_id`` values in the
518
+ desired display order (first element = position 1). On X1S, the latest
519
+ hub-order response may be partial; cached activity keymap/macros can
520
+ still expose additional valid ids that the official app reorders.
521
+
522
+ Protocol sequence (mirrors the Sofabaton app):
523
+ 1. family 0x61 SET_FAVORITES_ORDER → ACK 0x0103
524
+ 2. family 0x65 COMMIT → ACK 0x0103
525
+ """
526
+ if not self.can_issue_commands():
527
+ self._log.info("[FAV_REORDER] ignored: proxy client is connected")
528
+ return None
529
+
530
+ act_lo = activity_id & 0xFF
531
+
532
+ # Fetch current ordering to validate the supplied fav_ids
533
+ current_order = self.request_favorites_order(act_lo)
534
+ if current_order is None:
535
+ self._log.warning("[FAV_REORDER] could not fetch current order act=0x%02X", act_lo)
536
+ return None
537
+
538
+ ordered_fav_ids_checked: list[int] = []
539
+ for fav_id in ordered_fav_ids:
540
+ validated_fav_id = self._validate_favorite_fav_id(
541
+ act_lo, fav_id, current_order
542
+ )
543
+ if validated_fav_id is None:
544
+ self._log.warning(
545
+ "[FAV_REORDER] fav_id=0x%02X not present in hub order/cache for act=0x%02X, skipping",
546
+ fav_id & 0xFF,
547
+ act_lo,
548
+ )
549
+ continue
550
+ ordered_fav_ids_checked.append(validated_fav_id)
551
+
552
+ if not ordered_fav_ids_checked:
553
+ self._log.warning("[FAV_REORDER] no valid fav_ids for act=0x%02X", act_lo)
554
+ return None
555
+
556
+ self.reset_ack_queues()
557
+
558
+ _step = self._send_step(
559
+ step_name=f"fav-reorder-61[act=0x{act_lo:02X}]",
560
+ family=0x61,
561
+ payload=self._build_favorites_reorder_payload(act_lo, ordered_fav_ids_checked),
562
+ ack_opcode=0x0103,
563
+ )
564
+ if not _step.ok:
565
+ return None
566
+
567
+ _step = self._send_step(
568
+ step_name=f"fav-reorder-commit-65[act=0x{act_lo:02X}]",
569
+ family=0x65,
570
+ payload=bytes([act_lo]),
571
+ ack_opcode=0x0103,
572
+ )
573
+ if not _step.ok:
574
+ return None
575
+
576
+ self.clear_entity_cache(act_lo, clear_buttons=False, clear_favorites=True, clear_macros=False)
577
+ self.state.activity_favorites_order.pop(act_lo, None)
578
+ self._activity_map_complete.discard(act_lo)
579
+ if refresh_after_write:
580
+ self.request_activity_mapping(act_lo)
581
+
582
+ return {
583
+ "activity_id": act_lo,
584
+ "fav_ids": [f & 0xFF for f in ordered_fav_ids_checked],
585
+ "status": "success",
586
+ }
587
+
588
+ def delete_favorite(
589
+ self,
590
+ activity_id: int,
591
+ fav_id: int,
592
+ *,
593
+ refresh_after_write: bool = True,
594
+ ) -> dict[str, Any] | None:
595
+ """Delete the favorite identified by *fav_id* from *activity_id*.
596
+
597
+ *fav_id* must be a hub-order favorite identifier returned by
598
+ ``request_favorites_order`` / the Home Assistant ``get_favorites``
599
+ service.
600
+
601
+ The hub requires the remaining ordered list to be re-sent after the
602
+ deletion. This method first fetches the current ordering from the hub,
603
+ removes the specified entry, then executes:
604
+
605
+ 1. family 0x10 DELETE_FAV (act_lo, fav_id) → ACK 0x0103 (~5 s hub delay)
606
+ 2. family 0x61 SET_FAVORITES_ORDER (remaining) → ACK 0x0103
607
+ 3. family 0x65 COMMIT → ACK 0x0103
608
+ """
609
+ if not self.can_issue_commands():
610
+ self._log.info("[FAV_DELETE] ignored: proxy client is connected")
611
+ return None
612
+
613
+ act_lo = activity_id & 0xFF
614
+
615
+ # Fetch current ordering so we can build the post-delete list
616
+ current_order = self.request_favorites_order(act_lo)
617
+ if current_order is None:
618
+ self._log.warning("[FAV_DELETE] could not fetch current order act=0x%02X", act_lo)
619
+ return None
620
+
621
+ validated_fav_id = self._validate_favorite_fav_id(
622
+ act_lo, fav_id, current_order
623
+ )
624
+ if validated_fav_id is None:
625
+ self._log.warning(
626
+ "[FAV_DELETE] fav_id=0x%02X not present in current order for act=0x%02X",
627
+ fav_id & 0xFF,
628
+ act_lo,
629
+ )
630
+ return None
631
+
632
+ remaining_fav_ids = [fid for fid, _slot in current_order if fid != validated_fav_id]
633
+ self._log.info(
634
+ "[FAV_DELETE] act=0x%02X deleting fav_id=0x%02X; %d remaining",
635
+ act_lo,
636
+ validated_fav_id,
637
+ len(remaining_fav_ids),
638
+ )
639
+
640
+ self.reset_ack_queues()
641
+
642
+ # Step 1: signal deletion to hub (hub takes ~5 s to process)
643
+ _step = self._send_step(
644
+ step_name=f"fav-delete-10[act=0x{act_lo:02X} fav=0x{validated_fav_id:02X}]",
645
+ family=FAMILY_FAV_DELETE,
646
+ payload=bytes([act_lo, validated_fav_id]),
647
+ ack_opcode=0x0103,
648
+ timeout=7.5,
649
+ )
650
+ if not _step.ok:
651
+ return None
652
+
653
+ # Step 2: send the new (shorter) ordering
654
+ _step = self._send_step(
655
+ step_name=f"fav-delete-reorder-61[act=0x{act_lo:02X}]",
656
+ family=0x61,
657
+ payload=self._build_favorites_reorder_payload(act_lo, remaining_fav_ids),
658
+ ack_opcode=0x0103,
659
+ )
660
+ if not _step.ok:
661
+ return None
662
+
663
+ # Step 3: commit
664
+ _step = self._send_step(
665
+ step_name=f"fav-delete-commit-65[act=0x{act_lo:02X}]",
666
+ family=0x65,
667
+ payload=bytes([act_lo]),
668
+ ack_opcode=0x0103,
669
+ )
670
+ if not _step.ok:
671
+ return None
672
+
673
+ self.clear_entity_cache(act_lo, clear_buttons=False, clear_favorites=True, clear_macros=False)
674
+ self.state.activity_favorites_order.pop(act_lo, None)
675
+ self._activity_map_complete.discard(act_lo)
676
+ if refresh_after_write:
677
+ self.request_activity_mapping(act_lo)
678
+
679
+ return {
680
+ "activity_id": act_lo,
681
+ "deleted_fav_id": validated_fav_id,
682
+ "remaining": len(remaining_fav_ids),
683
+ "status": "success",
684
+ }
685
+
686
+ def command_to_favorite(
687
+ self,
688
+ activity_id: int,
689
+ device_id: int,
690
+ command_id: int,
691
+ *,
692
+ slot_id: int | None = None,
693
+ refresh_after_write: bool = True,
694
+ query_existing_order: bool = True,
695
+ ) -> dict[str, Any] | None:
696
+ """Add a command favorite to an arbitrary activity."""
697
+
698
+ if not self.can_issue_commands():
699
+ self._log.info("[FAVORITE] command_to_favorite ignored: proxy client is connected")
700
+ return None
701
+
702
+ act_lo = activity_id & 0xFF
703
+ dev_lo = device_id & 0xFF
704
+ cmd_lo = command_id & 0xFF
705
+ slot_lo = (0 if slot_id is None else slot_id) & 0xFF
706
+
707
+ self.reset_ack_queues()
708
+
709
+ # X1 only: query the current favorites order BEFORE the map step so we
710
+ # know which (fav_id, slot) pairs to preserve in the stage payload.
711
+ # On X1, macros share the same fav_id/slot namespace as command favorites
712
+ # and must be included in the stage payload with their actual slot numbers.
713
+ x1_existing_fav_ids: list[int] = []
714
+ if self.hub_version == HUB_VERSION_X1 and query_existing_order:
715
+ existing_order = self.request_favorites_order(act_lo) or []
716
+ x1_existing_fav_ids = [
717
+ fav_id
718
+ for fav_id, _slot in sorted(existing_order, key=lambda x: x[1])
719
+ ]
720
+ self._log.info(
721
+ "%s[STEP] favorite-map[act=0x%02X] x1 pre-existing order: %s",
722
+ LogTag.ACTIVITY,
723
+ act_lo,
724
+ x1_existing_fav_ids,
725
+ )
726
+
727
+ # Step 1: Map — inlined so we can read the assigned fav_id from the
728
+ # 0x013E ACK payload. That fav_id is used to build the stage payload.
729
+ map_step = f"favorite-map[act=0x{act_lo:02X} slot=0x{slot_lo:02X}]"
730
+ map_payload = self._build_favorite_map_payload(
731
+ activity_id=act_lo,
732
+ device_id=dev_lo,
733
+ command_id=cmd_lo,
734
+ slot_id=slot_lo,
735
+ )
736
+ map_ack: tuple[int, bytes] | None = None
737
+ for attempt in range(1, 3): # retries=1 → 2 attempts total
738
+ self._log.info(
739
+ "%s[STEP] %s tx family=0x3E expect_ack=0x013E attempt=%d/2",
740
+ LogTag.ACTIVITY,
741
+ map_step,
742
+ attempt,
743
+ )
744
+ send_ts = time.monotonic()
745
+ self._send_family_frame(0x3E, map_payload)
746
+ map_ack = self.wait_for_ack_any(
747
+ [(0x013E, None), (0x0103, None)],
748
+ timeout=7.5,
749
+ not_before=send_ts,
750
+ )
751
+ if map_ack is not None:
752
+ self._log.info("%s[STEP] %s acked via 0x%04X", LogTag.ACTIVITY, map_step, map_ack[0])
753
+ break
754
+ if attempt < 2:
755
+ self._log.warning("%s[STEP] %s retrying after ack timeout", LogTag.ACTIVITY, map_step)
756
+ time.sleep(0.15)
757
+ if map_ack is None:
758
+ self._log.warning("%s[STEP] %s failed waiting ack=0x013E", LogTag.ACTIVITY, map_step)
759
+ return None
760
+
761
+ # The 0x013E ACK payload's first byte is the hub-assigned fav_id.
762
+ map_ack_opcode, map_ack_payload = map_ack
763
+ new_fav_id: int | None = None
764
+ if map_ack_opcode == 0x013E and map_ack_payload:
765
+ new_fav_id = map_ack_payload[0] or None
766
+
767
+ # Build the stage payload (Step 2) differently per hub version.
768
+ #
769
+ # X1: the stage payload must list ALL current entries (macros AND regular
770
+ # favorites) in their current slot order, followed by the new fav_id
771
+ # at the next slot. The official app builds this by reading the
772
+ # current order before the map step and appending the new entry.
773
+ # Verified against captured traffic (log 04_x1_add_6_favorites).
774
+ #
775
+ # X1S/X2: sequential (i, i) pairs for i in 1..new_fav_id. On these hubs
776
+ # macros are in a separate namespace and are not included here.
777
+ # Verified against captured traffic (log 02_x1s_add_6_favorites).
778
+ if self.hub_version == HUB_VERSION_X1:
779
+ ordered_ids = x1_existing_fav_ids + ([new_fav_id] if new_fav_id is not None else [])
780
+ if not ordered_ids:
781
+ ordered_ids = [1] # last-resort fallback
782
+ stage_payload = self._build_favorites_reorder_payload(act_lo, ordered_ids)
783
+ stage_n = len(ordered_ids)
784
+ else:
785
+ fav_count: int = 4 # safe fallback matching previous hardcoded behaviour
786
+ if new_fav_id is not None:
787
+ fav_count = new_fav_id
788
+ stage_payload = self._build_favorite_stage_payload(act_lo, fav_count)
789
+ stage_n = fav_count
790
+
791
+ _step = self._send_step(
792
+ step_name=f"favorite-stage-61[act=0x{act_lo:02X} n={stage_n}]",
793
+ family=0x61,
794
+ payload=stage_payload,
795
+ ack_opcode=0x0103,
796
+ )
797
+ if not _step.ok:
798
+ return None
799
+
800
+ _step = self._send_step(
801
+ step_name=f"favorite-commit-65[act=0x{act_lo:02X}]",
802
+ family=0x65,
803
+ payload=bytes([act_lo]),
804
+ ack_opcode=0x0103,
805
+ )
806
+ if not _step.ok:
807
+ return None
808
+
809
+ self.clear_entity_cache(act_lo, clear_buttons=False, clear_favorites=True, clear_macros=False)
810
+ self._activity_map_complete.discard(act_lo)
811
+ if refresh_after_write:
812
+ self.request_activity_mapping(act_lo)
813
+
814
+ return {
815
+ "activity_id": act_lo,
816
+ "device_id": dev_lo,
817
+ "command_id": cmd_lo,
818
+ "slot_id": slot_lo,
819
+ "fav_id": new_fav_id,
820
+ "status": "success",
821
+ }
822
+
823
+ def command_to_button(
824
+ self,
825
+ activity_id: int,
826
+ button_id: int,
827
+ device_id: int,
828
+ command_id: int,
829
+ *,
830
+ long_press_device_id: int | None = None,
831
+ long_press_command_id: int | None = None,
832
+ refresh_after_write: bool = True,
833
+ ) -> dict[str, Any] | None:
834
+ """Map a device command to a physical activity button using 0x193E.
835
+
836
+ When *long_press_device_id* and *long_press_command_id* are both
837
+ provided the hub will also fire that command on a long-press of the
838
+ same physical button.
839
+ """
840
+
841
+ if not self.can_issue_commands():
842
+ self._log.info("[KEYMAP_WRITE] command_to_button ignored: proxy client is connected")
843
+ return None
844
+
845
+ act_lo = activity_id & 0xFF
846
+ btn_lo = button_id & 0xFF
847
+ dev_lo = device_id & 0xFF
848
+ cmd_lo = command_id & 0xFF
849
+
850
+ # Route through the canonical family-0x3E builder. The 0x193E
851
+ # mapping a command-to-physical-button is the same wire shape
852
+ # as the device-create button-binding step: the per-press
853
+ # ``button_code`` is the X1 synthetic ``0x4E20 + cmd_lo``
854
+ # carried on a 6-byte BE slot, and the per-press
855
+ # ``button_id`` equals the bound ``cmd_lo``. The canonical
856
+ # builder's "device_id" is the keymap entity id, which on
857
+ # this path is the activity id.
858
+ if long_press_device_id is not None and long_press_command_id is not None:
859
+ long_press_kwargs = dict(
860
+ long_press_device_id=long_press_device_id & 0xFF,
861
+ long_press_button_code=synthesize_command_code(
862
+ long_press_command_id & 0xFF
863
+ ),
864
+ long_press_button_id=long_press_command_id & 0xFF,
865
+ )
866
+ else:
867
+ long_press_kwargs = {}
868
+ binding_step = build_button_binding_step(
869
+ device_id=act_lo,
870
+ button_id=btn_lo,
871
+ short_press_device_id=dev_lo,
872
+ short_press_button_code=synthesize_command_code(cmd_lo),
873
+ short_press_button_id=cmd_lo,
874
+ **long_press_kwargs,
875
+ )
876
+ payload = binding_step.payload
877
+ if long_press_device_id is not None and long_press_command_id is not None:
878
+ self._log.info(
879
+ "[KEYMAP_WRITE] map act=0x%02X button=0x%02X dev=0x%02X cmd=0x%02X"
880
+ " long_dev=0x%02X long_cmd=0x%02X",
881
+ act_lo,
882
+ btn_lo,
883
+ dev_lo,
884
+ cmd_lo,
885
+ long_press_device_id & 0xFF,
886
+ long_press_command_id & 0xFF,
887
+ )
888
+ else:
889
+ self._log.info(
890
+ "[KEYMAP_WRITE] map act=0x%02X button=0x%02X dev=0x%02X cmd=0x%02X",
891
+ act_lo,
892
+ btn_lo,
893
+ dev_lo,
894
+ cmd_lo,
895
+ )
896
+ if self.diag_dump:
897
+ self._log.info("[KEYMAP_WRITE] 193E payload (%dB)", len(payload))
898
+
899
+ self.reset_ack_queues()
900
+
901
+ _step = self._send_step(
902
+ step_name=f"keymap-write[act=0x{act_lo:02X} btn=0x{btn_lo:02X}]",
903
+ family=0x3E,
904
+ payload=payload,
905
+ ack_opcode=0x013E,
906
+ ack_first_byte=btn_lo,
907
+ ack_fallback_opcodes=(0x0103,),
908
+ timeout=7.5,
909
+ retries=1,
910
+ retry_delay=0.15,
911
+ )
912
+ if not _step.ok:
913
+ return None
914
+
915
+ _step = self._send_step(
916
+ step_name=f"keymap-commit-65[act=0x{act_lo:02X}]",
917
+ family=0x65,
918
+ payload=bytes([act_lo]),
919
+ ack_opcode=0x0103,
920
+ )
921
+ if not _step.ok:
922
+ return None
923
+
924
+ self.clear_entity_cache(act_lo, clear_buttons=True, clear_favorites=False, clear_macros=False)
925
+ self._activity_map_complete.discard(act_lo)
926
+ if refresh_after_write:
927
+ self.request_activity_mapping(act_lo)
928
+ self.get_buttons_for_entity(act_lo, fetch_if_missing=True)
929
+
930
+ result: dict[str, Any] = {
931
+ "activity_id": act_lo,
932
+ "button_id": btn_lo,
933
+ "device_id": dev_lo,
934
+ "command_id": cmd_lo,
935
+ "status": "success",
936
+ }
937
+ if long_press_device_id is not None and long_press_command_id is not None:
938
+ result["long_press_device_id"] = long_press_device_id & 0xFF
939
+ result["long_press_command_id"] = long_press_command_id & 0xFF
940
+ return result
941
+
942
+
943
+ __all__ = ["ActivityOpsMixin"]