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.
- sofapython/__init__.py +136 -0
- sofapython/ack.py +79 -0
- sofapython/aio.py +552 -0
- sofapython/backup_export.py +507 -0
- sofapython/blob_decoders.py +806 -0
- sofapython/cli.py +447 -0
- sofapython/commands.py +1273 -0
- sofapython/deframer.py +73 -0
- sofapython/device_create.py +1174 -0
- sofapython/devices.py +534 -0
- sofapython/discovery.py +315 -0
- sofapython/frame_handlers.py +131 -0
- sofapython/hub_listener.py +242 -0
- sofapython/hub_logging.py +152 -0
- sofapython/hub_versions.py +112 -0
- sofapython/inputs.py +501 -0
- sofapython/macros.py +669 -0
- sofapython/notify_demuxer.py +434 -0
- sofapython/opcode_handlers.py +1655 -0
- sofapython/protocol_const.py +633 -0
- sofapython/proxy_ack_waiters.py +660 -0
- sofapython/proxy_activity_ops.py +943 -0
- sofapython/proxy_backup.py +504 -0
- sofapython/proxy_backup_export.py +486 -0
- sofapython/proxy_catalog.py +915 -0
- sofapython/proxy_frame_decode.py +227 -0
- sofapython/proxy_ir_blob.py +676 -0
- sofapython/proxy_restore.py +2004 -0
- sofapython/proxy_wifi_device.py +1101 -0
- sofapython/state_helpers.py +713 -0
- sofapython/transport_bridge.py +876 -0
- sofapython/version.py +4 -0
- sofapython/wire_schema.py +164 -0
- sofapython/x1_proxy.py +1833 -0
- sofapython-0.0.1rc1.dist-info/METADATA +162 -0
- sofapython-0.0.1rc1.dist-info/RECORD +39 -0
- sofapython-0.0.1rc1.dist-info/WHEEL +4 -0
- sofapython-0.0.1rc1.dist-info/entry_points.txt +2 -0
- sofapython-0.0.1rc1.dist-info/licenses/LICENSE +21 -0
|
@@ -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"]
|