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,633 @@
1
+ """Common protocol constants shared by the Sofabaton proxy helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+ # Frame markers used by the hub protocol
8
+ SYNC0, SYNC1 = 0xA5, 0x5A
9
+
10
+ # Frame invariant: opcode_hi == payload_length
11
+ # ---------------------------------------------
12
+ # The byte at offset [2] (which is also the high byte of the 16-bit BE opcode)
13
+ # equals the number of payload bytes between the opcode and the checksum.
14
+ # Total frame length is therefore always `5 + (opcode >> 8)`.
15
+ #
16
+ # Companion-client framing relies on this directly instead of
17
+ # sync-scanning. Every opcode in this file obeys the invariant; if you
18
+ # add a new opcode constant whose high byte does not match the payload size in
19
+ # the comment, suspect the comment first.
20
+ #
21
+ # Example: OP_REQ_BUTTONS = 0x023C carries a 2-byte payload [act_lo, 0xFF];
22
+ # the frame is 7 bytes total: A5 5A 02 3C XX XX SUM.
23
+
24
+
25
+ class ButtonName:
26
+ """Enumeration of known Sofabaton button codes."""
27
+
28
+ # X2-only / extended keys (below 0xAE)
29
+ C = 0x97
30
+ B = 0x98
31
+ A = 0x99
32
+ EXIT = 0x9A
33
+ DVR = 0x9B
34
+ PLAY = 0x9C
35
+ GUIDE = 0x9D
36
+
37
+ # Shared X1/X1S/X2 keys (existing)
38
+ UP = 0xAE
39
+ DOWN = 0xB2
40
+ LEFT = 0xAF
41
+ RIGHT = 0xB1
42
+ OK = 0xB0
43
+ HOME = 0xB4
44
+ BACK = 0xB3
45
+ MENU = 0xB5
46
+ VOL_UP = 0xB6
47
+ VOL_DOWN = 0xB9
48
+ MUTE = 0xB8
49
+ CH_UP = 0xB7
50
+ CH_DOWN = 0xBA
51
+ REW = 0xBB
52
+ PAUSE = 0xBC
53
+ FWD = 0xBD
54
+ RED = 0xBE
55
+ GREEN = 0xBF
56
+ YELLOW = 0xC0
57
+ BLUE = 0xC1
58
+ POWER_ON = 0xC6
59
+ POWER_OFF = 0xC7
60
+
61
+
62
+ BUTTONNAME_BY_CODE = {
63
+ v: k
64
+ for k, v in ButtonName.__dict__.items()
65
+ if isinstance(v, int) and k.isupper() and not k.startswith("_")
66
+ }
67
+
68
+
69
+ # A→H requests (from client to hub)
70
+ OP_REQ_BANNER = 0x0001 # yields family-0x02 banner reply with model/batch/hub-fw
71
+ OP_REQ_DEVICES = 0x000A # yields CATALOG_ROW_DEVICE rows (0xD50B)
72
+ OP_REQ_ACTIVITIES = 0x003A # yields CATALOG_ROW_ACTIVITY rows (0xD53B)
73
+ OP_REQ_BUTTONS = 0x023C # payload: [act_lo, 0xFF]
74
+ OP_REQ_COMMANDS = 0x025C # payload: [dev_lo, cmd] (1-byte) or [dev_lo, 0xFF] for full list
75
+ OP_REQ_BLOB = 0x020C # payload: [dev_lo, item_lo] or [dev_lo, 0xFF] for full blob/object dump
76
+ OP_REQ_ACTIVATE = 0x023F # payload: [id_lo, key_code] (activity or device ID)
77
+ OP_REQ_ACTIVITY_MAP = 0x016C # payload: [act_lo] request activity favorites mapping (X1)
78
+ OP_DELETE_DEVICE = 0x0109 # payload: [dev_lo] delete an existing device (observed X1)
79
+ OP_SET_HUB_NAME = 0x0030 # payload: GB2312-encoded hub name bytes
80
+ FAMILY_HUB_NAME_REPLY = 0x31 # H→A variable-length hub-name reply family
81
+ OP_FIND_REMOTE = 0x0023 # payload: [0x01] to trigger remote buzzer
82
+ OP_ERASE_CONFIGURATION = 0x001D # payload: empty; wipes all devices/activities/macros/favorites/inputs.
83
+ # Identical across X1, X1S, X2. The hub commonly disconnects after the ack; clients
84
+ # must reconnect and re-fetch the catalogs from scratch. See docs/protocol/erase.md.
85
+ # NOTE: opcode_hi=0x00 contradicts the documented 1-byte payload (frame
86
+ # invariant says payload length == opcode_hi). Two possibilities:
87
+ # - The actual opcode is 0x0123 (1-byte payload) and was mis-recorded, or
88
+ # - The payload is empty and the "[0x01]" in this comment is wrong.
89
+ # The X2 variant OP_FIND_REMOTE_X2 = 0x0323 correctly has a 3-byte payload.
90
+ # Worth verifying against a captured X1/X1S buzzer frame.
91
+ OP_FIND_REMOTE_X2 = 0x0323 # payload: [0x00, 0x00, 0x08] observed on X2 hubs
92
+ OP_REMOTE_SYNC = 0x0064 # payload: empty; force remote<->hub sync on X1/X1S
93
+ OP_X2_REMOTE_LIST = 0x012E # payload: [0x00]; request connected remotes on X2
94
+ OP_X2_REMOTE_LIST_ROW = 0x332F # payload includes 3-byte remote id and remote metadata
95
+ OP_X2_REMOTE_SYNC = 0x0464 # payload: [remote_id:3][0x01]; force sync for selected remote
96
+ OP_CREATE_DEVICE_HEAD = 0x07D5 # payload includes UTF-16LE device name
97
+ OP_DEFINE_IP_CMD = 0x0ED3 # payload includes HTTP method/URL/headers
98
+ OP_DEFINE_IP_CMD_EXISTING = 0x0EAE # payload defines IP command for an existing device
99
+ OP_PREPARE_SAVE = 0x4102 # payload triggers save transaction start
100
+ OP_FINALIZE_DEVICE = 0x4677
101
+ OP_DEVICE_SAVE_HEAD = 0x8D5D # hub assigns device id
102
+ OP_SAVE_COMMIT = 0x6501
103
+ # ACK family — dispatch by opcode-lo only
104
+ # ----------------------------------------
105
+ # ACK dispatch reads
106
+ # opcode-lo and ignores opcode-hi entirely when classifying ACKs.
107
+ # Because of the opcode-hi == payload-length invariant, `0x0103`, `0x0203`,
108
+ # `0x0303`, etc. are all the **same** "status ack" family with progressively
109
+ # larger payloads — only the payload-0 byte is the verdict.
110
+ #
111
+ # Success/failure rule (binary):
112
+ # payload[0] == 0x00 → success
113
+ # payload[0] != 0x00 → failure
114
+ #
115
+ # Other ACK / event opcode-lo families observed in the dispatcher:
116
+ # 0x15 batch-resume cursor: `cursor = BE(payload[1..4]) - 1`, re-emit
117
+ # 0x3E bare progression ack (no status byte; just "advance")
118
+ # 0x42 device power-state / idle-behavior reply family (status byte at payload offset 1)
119
+ # 0x45 one-shot status return (status byte at payload offset 1)
120
+ # 0x57 one-shot status return (status byte at payload offset 2)
121
+ # 0x60 ACK_READY family — posts "activity_update" event globally
122
+ # 0x67 OTA-update push event from hub
123
+ #
124
+ # `ACK_SUCCESS = 0x0301` and `OP_STATUS_ACK = 0x0103` are both opcode-lo 0x03;
125
+ # they are the same family with different payload widths. The naming is
126
+ # retained for backward compatibility but the dispatcher logic should treat
127
+ # any opcode with low byte 0x03 as a status frame.
128
+ ACK_SUCCESS = 0x0301
129
+ OP_STATUS_ACK = 0x0103 # H→A generic status/ack frame; payload[0] carries the status byte
130
+ # Status-ack family identifier (low byte). Use `opcode & 0xFF == FAMILY_STATUS_ACK`.
131
+ FAMILY_STATUS_ACK = 0x03
132
+
133
+ # IP command synchronization (existing devices)
134
+ OP_REQ_IPCMD_SYNC = 0x0C02
135
+ OP_IPCMD_ROW_A = 0x0DD3
136
+ OP_IPCMD_ROW_B = 0x0DAC
137
+ OP_IPCMD_ROW_C = 0x0D9B
138
+ OP_IPCMD_ROW_D = 0x0DAE
139
+
140
+ # H→A responses (from hub to app/client)
141
+ OP_ACK_READY = 0x0160
142
+ # H→A push emitted when the hub begins a firmware OTA update. The hub goes
143
+ # silent for a few minutes while the update runs and does not actively
144
+ # manage the connection during that window, so receivers are expected to
145
+ # tear down the session and stay disconnected until the hub comes back.
146
+ OP_OTA_UPDATE = 0x0167
147
+ OP_MARKER = 0x0C3D # segment marker before continuation
148
+
149
+ OP_CATALOG_ROW_DEVICE = 0xD50B # Row from list of devices
150
+ OP_CATALOG_ROW_ACTIVITY = 0xD53B # Row from list of activities
151
+ OP_DEVBTN_HEADER = 0xD95D # H→A: REQ_COMMANDS header page on X1S/X2
152
+ OP_DEVBTN_PAGE = 0xD55D # H→A: REQ_COMMANDS body page on X1S/X2
153
+ OP_DEVBTN_SINGLE = 0x4D5D # H→A: single-command metadata page (response to targeted REQ_COMMANDS)
154
+ OP_DEVBTN_TAIL = 0x495D # H→A: REQ_COMMANDS final page variant on X1S/X2
155
+ OP_KEYMAP_EXTRA = 0x303D # H→A: small follow-up page sometimes present (REQ_BUTTONS family)
156
+ OP_DEVBTN_MORE = 0x8F5D # H→A: REQ_COMMANDS final page variant on X1S/X2
157
+ OP_DEVBTN_PAGE_ALT1 = 0xF75D # H→A: REQ_COMMANDS header page on X1
158
+ OP_DEVBTN_PAGE_ALT2 = 0xA35D # H→A: REQ_COMMANDS final page variant on X1
159
+ OP_DEVBTN_PAGE_ALT3 = 0x2F5D # H→A: REQ_COMMANDS page variant on X1
160
+ OP_DEVBTN_PAGE_ALT4 = 0xF35D # H→A: REQ_COMMANDS body page on X1
161
+ OP_DEVBTN_PAGE_ALT5 = 0x7B5D # H→A: REQ_COMMANDS final/page variant on X1
162
+ OP_DEVBTN_PAGE_ALT6 = 0xCB5D # H→A: REQ_COMMANDS final page variant on X1
163
+ OP_DEVBTN_PAGE_ALT7 = 0x535D # H→A: REQ_COMMANDS final page variant on X1
164
+
165
+ # X1 hub responses
166
+ OP_X1_DEVICE = 0x7B0B # Row from list of devices (X1 firmware)
167
+ OP_X1_ACTIVITY = 0x7B3B # Row from list of activities (X1 firmware)
168
+
169
+ OP_KEYMAP_TBL_A = 0xF13D
170
+ OP_KEYMAP_TBL_B = 0xFA3D
171
+ OP_KEYMAP_TBL_C = 0x3D3D # Returned when Hue buttons requested
172
+ OP_KEYMAP_TBL_D = 0x1E3D # Observed keymap table variant
173
+ OP_KEYMAP_TBL_F = 0x783D # Observed keymap table variant
174
+ OP_KEYMAP_TBL_E = 0xBB3D # Observed keymap table variant
175
+ OP_KEYMAP_TBL_G = 0xCD3D # Observed keymap table variant
176
+ OP_KEYMAP_CONT = 0x543D # Observed continuation page after MARKER
177
+ OP_KEYMAP_FINAL_X1S = 0x233D # H→A: short final REQ_BUTTONS page on X1S/X2
178
+ OP_KEYMAP_PAGE_X2_C03D = 0xC03D # H→A: REQ_BUTTONS continuation/data page on X2
179
+ OP_KEYMAP_PAGE_X1_663D = 0x663D # H→A: REQ_BUTTONS page variant on X1
180
+ OP_KEYMAP_OVERLAY_X1 = 0x733D # H→A: single-page REQ_BUTTONS overlay burst on X1
181
+ OP_KEYMAP_PAGE_X1_AE3D = 0xAE3D # H→A: REQ_BUTTONS page variant on X1
182
+ OP_KEYMAP_PAGE_X1_E43D = 0xE43D # H→A: REQ_BUTTONS page variant on X1
183
+
184
+ # UDP CALL_ME (same frame used both directions over UDP)
185
+ OP_CALL_ME = 0x0CC3
186
+
187
+ # X1 activity mapping pages (favorites)
188
+ OP_ACTIVITY_MAP_PAGE = 0x7B6D
189
+
190
+ # noise we're not using (kept for reference)
191
+ OP_REQ_VERSION = 0x0058 # yields WIFI_FW (0x0359) then INFO_BANNER (0x112F)
192
+ OP_REQ_IDLE_BEHAVIOR = 0x0140 # payload: [dev_lo]; query device idle/power behavior
193
+ OP_REQ_ACTIVITY_INPUTS = 0x0148 # payload: [0x01] request activity input candidates
194
+ OP_SET_IDLE_BEHAVIOR = 0x0241 # payload: [dev_lo, mode]; update device idle/power behavior
195
+ OP_IDLE_BEHAVIOR = 0x0242 # H→A payload: [dev_lo, mode] current device idle/power behavior
196
+ OP_ACTIVITY_DEVICE_CONFIRM = 0x024F # payload: [dev_lo, include_flag]
197
+ OP_REQ_MACRO_LABELS = 0x024D # payload: [act_lo, 0xFF]
198
+ OP_REQ_MACROS = OP_REQ_MACRO_LABELS # backward-compat alias
199
+ OP_MACROS_A1 = 0x6E13
200
+ OP_MACROS_B1 = 0x5A13
201
+ OP_MACROS_A2 = 0x8213
202
+ OP_MACROS_B2 = 0x6413
203
+ OP_ACTIVITY_INPUTS_PAGE_A = 0xFA47 # H→A activity-input candidates page (X1S/X2)
204
+ OP_ACTIVITY_INPUTS_PAGE_B = 0xC947 # H→A final activity-input candidates page (X1S/X2)
205
+ OP_ACTIVITY_ASSIGN_FINALIZE = 0xD538 # A→H post-macro activity assignment save (X1S/X2)
206
+ OP_ACTIVITY_ASSIGN_COMMIT = 0x0265 # A→H post-save commit marker (observed on X2)
207
+ OP_ACTIVITY_CONFIRM = 0x7B38 # A→H activity confirmation row write (observed X1)
208
+ OP_ACTIVITY_CREATE_ACK = 0x0137 # H→A assigned activity id after family-0x37 create write (X1)
209
+ OP_ACTIVITY_MAP_PAGE_X1S = 0xD56D # H→A activity mapping page variant (X1S/X2)
210
+ OP_BANNER = 0x1D02 # representative family-0x02 banner reply with hub ident/name/batch/fw
211
+ OP_WIFI_FW = 0x0359 # WiFi firmware ver (Vx.y.z)
212
+ OP_INFO_BANNER = 0x112F # vendor tag, batch date, remote fw byte, etc.
213
+
214
+
215
+ # Backward-compatible aliases retained for older call sites and docs.
216
+ OP_PING2 = OP_REQ_IDLE_BEHAVIOR
217
+ OP_PING2_ACK = OP_IDLE_BEHAVIOR
218
+
219
+ OPNAMES: Dict[int, str] = {
220
+ OP_CALL_ME: "CALL_ME",
221
+ OP_REQ_BANNER: "REQ_BANNER",
222
+ OP_REQ_ACTIVITIES: "REQ_ACTIVITIES",
223
+ OP_REQ_DEVICES: "REQ_DEVICES",
224
+ OP_REQ_BUTTONS: "REQ_BUTTONS",
225
+ OP_REQ_COMMANDS: "REQ_COMMANDS",
226
+ OP_REQ_BLOB: "REQ_BLOB",
227
+ OP_REQ_ACTIVATE: "REQ_ACTIVATE",
228
+ OP_REQ_ACTIVITY_MAP: "REQ_ACTIVITY_MAP",
229
+ OP_DELETE_DEVICE: "DELETE_DEVICE",
230
+ OP_SET_HUB_NAME: "SET_HUB_NAME",
231
+ OP_FIND_REMOTE: "FIND_REMOTE",
232
+ OP_ERASE_CONFIGURATION: "ERASE_CONFIGURATION",
233
+ OP_FIND_REMOTE_X2: "FIND_REMOTE_X2",
234
+ OP_REMOTE_SYNC: "REMOTE_SYNC",
235
+ OP_X2_REMOTE_LIST: "X2_REMOTE_LIST",
236
+ OP_X2_REMOTE_LIST_ROW: "X2_REMOTE_LIST_ROW",
237
+ OP_X2_REMOTE_SYNC: "X2_REMOTE_SYNC",
238
+ OP_CREATE_DEVICE_HEAD: "CREATE_DEVICE_HEAD",
239
+ OP_DEFINE_IP_CMD: "DEFINE_IP_CMD",
240
+ OP_DEFINE_IP_CMD_EXISTING: "DEFINE_IP_CMD_EXISTING",
241
+ OP_PREPARE_SAVE: "PREPARE_SAVE",
242
+ OP_FINALIZE_DEVICE: "FINALIZE_DEVICE",
243
+ OP_DEVICE_SAVE_HEAD: "DEVICE_SAVE_HEAD",
244
+ OP_SAVE_COMMIT: "SAVE_COMMIT",
245
+ OP_REQ_IPCMD_SYNC: "REQ_IPCMD_SYNC",
246
+ OP_IPCMD_ROW_A: "IPCMD_ROW_A",
247
+ OP_IPCMD_ROW_B: "IPCMD_ROW_B",
248
+ OP_IPCMD_ROW_C: "IPCMD_ROW_C",
249
+ OP_IPCMD_ROW_D: "IPCMD_ROW_D",
250
+ ACK_SUCCESS: "ACK_SUCCESS",
251
+ OP_STATUS_ACK: "STATUS_ACK",
252
+ OP_ACTIVITY_CREATE_ACK: "ACTIVITY_CREATE_ACK",
253
+ OP_ACK_READY: "ACK_READY",
254
+ OP_OTA_UPDATE: "OTA_UPDATE",
255
+ OP_MARKER: "REQ_BUTTONS_MARKER_X1S_X2",
256
+ OP_CATALOG_ROW_DEVICE: "CATALOG_ROW_DEVICE",
257
+ OP_CATALOG_ROW_ACTIVITY: "CATALOG_ROW_ACTIVITY",
258
+ OP_KEYMAP_TBL_A: "REQ_BUTTONS_PAGE_A",
259
+ OP_KEYMAP_TBL_B: "REQ_BUTTONS_HEADER_OR_PAGE",
260
+ OP_KEYMAP_TBL_C: "REQ_BUTTONS_PAGE_C",
261
+ OP_KEYMAP_TBL_D: "REQ_BUTTONS_PAGE_D",
262
+ OP_KEYMAP_TBL_F: "REQ_BUTTONS_PAGE_F",
263
+ OP_KEYMAP_TBL_E: "REQ_BUTTONS_PAGE_E",
264
+ OP_KEYMAP_TBL_G: "REQ_BUTTONS_PAGE_G",
265
+ OP_KEYMAP_CONT: "REQ_BUTTONS_PAGE_X1S_X2",
266
+ OP_KEYMAP_FINAL_X1S: "REQ_BUTTONS_FINAL_X1S_X2_233D",
267
+ OP_KEYMAP_PAGE_X2_C03D: "REQ_BUTTONS_PAGE_X1S_X2_C03D",
268
+ OP_KEYMAP_PAGE_X1_663D: "REQ_BUTTONS_PAGE_X1_663D",
269
+ OP_KEYMAP_OVERLAY_X1: "REQ_BUTTONS_OVERLAY_X1",
270
+ OP_KEYMAP_PAGE_X1_AE3D: "REQ_BUTTONS_PAGE_X1_AE3D",
271
+ OP_KEYMAP_PAGE_X1_E43D: "REQ_BUTTONS_PAGE_X1_E43D",
272
+ OP_ACTIVITY_MAP_PAGE: "ACTIVITY_MAP_PAGE",
273
+ OP_DEVBTN_HEADER: "REQ_COMMANDS_HEADER_X1S_X2",
274
+ OP_DEVBTN_PAGE: "REQ_COMMANDS_PAGE_X1S_X2",
275
+ OP_DEVBTN_SINGLE: "REQ_COMMANDS_SINGLE",
276
+ OP_DEVBTN_TAIL: "REQ_COMMANDS_FINAL_X1S_X2_495D",
277
+ OP_KEYMAP_EXTRA: "REQ_BUTTONS_PAGE_EXTRA",
278
+ OP_DEVBTN_MORE: "REQ_COMMANDS_FINAL_X1S_X2_8F5D",
279
+ OP_DEVBTN_PAGE_ALT1: "REQ_COMMANDS_HEADER_X1",
280
+ OP_DEVBTN_PAGE_ALT2: "REQ_COMMANDS_FINAL_X1_A35D",
281
+ OP_DEVBTN_PAGE_ALT3: "REQ_COMMANDS_PAGE_X1_2F5D",
282
+ OP_DEVBTN_PAGE_ALT4: "REQ_COMMANDS_PAGE_X1",
283
+ OP_DEVBTN_PAGE_ALT5: "REQ_COMMANDS_PAGE_OR_FINAL_X1_7B5D",
284
+ OP_DEVBTN_PAGE_ALT6: "REQ_COMMANDS_FINAL_X1_CB5D",
285
+ OP_DEVBTN_PAGE_ALT7: "REQ_COMMANDS_FINAL_X1_535D",
286
+ OP_X1_DEVICE: "X1_DEVICE",
287
+ OP_X1_ACTIVITY: "X1_ACTIVITY",
288
+ # The rest are unused but kept for completeness
289
+ OP_BANNER: "BANNER",
290
+ OP_WIFI_FW: "WIFI_FW",
291
+ OP_INFO_BANNER: "INFO_BANNER",
292
+ OP_MACROS_A1: "MACROS_A1",
293
+ OP_MACROS_B1: "MACROS_B1",
294
+ OP_MACROS_A2: "MACROS_A2",
295
+ OP_MACROS_B2: "MACROS_B2",
296
+ OP_ACTIVITY_DEVICE_CONFIRM: "ACTIVITY_DEVICE_CONFIRM",
297
+ OP_REQ_ACTIVITY_INPUTS: "REQ_ACTIVITY_INPUTS",
298
+ OP_IDLE_BEHAVIOR: "IDLE_BEHAVIOR",
299
+ OP_REQ_MACRO_LABELS: "REQ_MACRO_LABELS",
300
+ OP_ACTIVITY_INPUTS_PAGE_A: "ACTIVITY_INPUTS_PAGE_A",
301
+ OP_ACTIVITY_INPUTS_PAGE_B: "ACTIVITY_INPUTS_PAGE_B",
302
+ OP_ACTIVITY_ASSIGN_FINALIZE: "ACTIVITY_ASSIGN_FINALIZE",
303
+ OP_ACTIVITY_ASSIGN_COMMIT: "ACTIVITY_ASSIGN_COMMIT",
304
+ OP_ACTIVITY_CONFIRM: "ACTIVITY_CONFIRM",
305
+ OP_ACTIVITY_MAP_PAGE_X1S: "ACTIVITY_MAP_PAGE_X1S",
306
+ OP_REQ_VERSION: "REQ_VERSION",
307
+ OP_REQ_IDLE_BEHAVIOR: "REQ_IDLE_BEHAVIOR",
308
+ OP_SET_IDLE_BEHAVIOR: "SET_IDLE_BEHAVIOR",
309
+ }
310
+
311
+
312
+ def opcode_hi(opcode: int) -> int:
313
+ """Return the high byte of an opcode."""
314
+
315
+ return (opcode >> 8) & 0xFF
316
+
317
+
318
+ def opcode_lo(opcode: int) -> int:
319
+ """Return the low byte of an opcode."""
320
+
321
+ return opcode & 0xFF
322
+
323
+
324
+ def opcode_family(opcode: int) -> int:
325
+ """Return the low-byte "family" for list/table opcodes."""
326
+
327
+ return opcode_lo(opcode)
328
+
329
+
330
+ # Known opcode families (low byte) grouped by semantic row/page type
331
+ FAMILY_STATUS_ACK = 0x03 # generic status / ack responses
332
+ FAMILY_DEV_ROW = 0x0B # device catalog rows (OP_CATALOG_ROW_DEVICE, OP_X1_DEVICE)
333
+ FAMILY_ACT_ROW = 0x3B # activity catalog rows (OP_CATALOG_ROW_ACTIVITY, OP_X1_ACTIVITY)
334
+ FAMILY_MACROS = 0x13 # macro pages (OP_MACROS_A1/B1/A2/B2)
335
+ FAMILY_KEYMAP = 0x3D # keymap / continuation / devbtn-extra pages
336
+ FAMILY_DEVBTNS = 0x5D # device button pages (header, body, tail, variants)
337
+
338
+ # Favorites reorder / delete families (observed in app traffic)
339
+ FAMILY_FAV_DELETE = 0x10 # A→H: delete a single favorite from an activity (opcode 0x0210)
340
+ FAMILY_FAV_ORDER_REQ = 0x62 # A→H: request current favorites ordering (opcode 0x0162)
341
+ FAMILY_FAV_ORDER_RESP = 0x63 # H→A: hub returns current favorites ordering (opcode variable)
342
+
343
+ FAMILY_KEY_SORT_REQ = 0x62
344
+ FAMILY_KEY_SORT_RESP = 0x63
345
+
346
+ # IR blob playback.
347
+ # Each frame carries a 247-byte slice of a single contiguous body buffer.
348
+ # The body buffer is laid out as:
349
+ # body[0] = 0x01
350
+ # body[1..2] = total_pages BE
351
+ # body[3..5] = 0x00 0x00 0x00
352
+ # body[6..11] = 0x00 * 6
353
+ # body[12..] = library_data
354
+ # body[-1] = sum8 over the preceding bytes
355
+ # Every wire frame prepends a 3-byte page header [0x01, 0x00, page_no_lo] to
356
+ # its slice. Frame opcode_hi is the resulting payload length (full chunks are
357
+ # OP_FA0F = 250B payload).
358
+ FAMILY_PLAY_BLOB = 0x0F
359
+ PLAY_BLOB_MAX_PAYLOAD = 0xFA # 250B — full-chunk payload size
360
+ PLAY_BLOB_PAGE_HEADER_LEN = 3 # [0x01, 0x00, page_no_lo] preface per frame
361
+ PLAY_BLOB_BODY_HEADER_LEN = 12 # body bytes preceding library_data
362
+ PLAY_BLOB_CHUNK_SIZE = PLAY_BLOB_MAX_PAYLOAD - PLAY_BLOB_PAGE_HEADER_LEN # 247B body slice per frame
363
+
364
+ FAMILY_BLOB_ROW = 0x0D # IP-command sync rows, input-config refresh, REQ_BLOB dump pages
365
+
366
+ FAMILY_NAMES: Dict[int, str] = {
367
+ FAMILY_STATUS_ACK: "STATUS_ACK",
368
+ FAMILY_HUB_NAME_REPLY: "HUB_NAME_REPLY",
369
+ FAMILY_DEV_ROW: "DEVICE_ROW",
370
+ FAMILY_BLOB_ROW: "BLOB_ROW",
371
+ FAMILY_PLAY_BLOB: "PLAY_BLOB",
372
+ FAMILY_FAV_DELETE: "FAV_DELETE",
373
+ FAMILY_MACROS: "MACROS",
374
+ FAMILY_KEYMAP: "KEYMAP",
375
+ FAMILY_ACT_ROW: "ACTIVITY_ROW",
376
+ FAMILY_DEVBTNS: "COMMANDS",
377
+ FAMILY_FAV_ORDER_REQ: "FAV_ORDER_REQ",
378
+ FAMILY_FAV_ORDER_RESP: "FAV_ORDER_RESP",
379
+ }
380
+
381
+
382
+ DEVICE_CLASS_IR = "ir"
383
+ DEVICE_CLASS_BLUETOOTH = "bluetooth"
384
+ DEVICE_CLASS_WIFI_HUE = "wifi_hue"
385
+ DEVICE_CLASS_WIFI_ROKU = "wifi_roku"
386
+ DEVICE_CLASS_WIFI_IP = "wifi_ip"
387
+ DEVICE_CLASS_WIFI_MQTT = "wifi_mqtt"
388
+ DEVICE_CLASS_WIFI_SONOS = "wifi_sonos"
389
+ DEVICE_CLASS_RF_315 = "rf_315mhz"
390
+ DEVICE_CLASS_RF_433 = "rf_433mhz"
391
+
392
+ DEVICE_CLASS_CODE_BLUETOOTH = 0x03
393
+ DEVICE_CLASS_CODE_WIFI_ROKU = 0x0A
394
+ DEVICE_CLASS_CODE_IR = 0x0D
395
+ DEVICE_CLASS_CODE_WIFI_HUE = 0x1A
396
+ DEVICE_CLASS_CODE_WIFI_SONOS = 0x1B
397
+ DEVICE_CLASS_CODE_WIFI_IP = 0x1C
398
+ DEVICE_CLASS_CODE_WIFI_MQTT = 0x20
399
+
400
+ DEVICE_CLASS_BY_CODE: Dict[int, str] = {
401
+ DEVICE_CLASS_CODE_BLUETOOTH: DEVICE_CLASS_BLUETOOTH,
402
+ DEVICE_CLASS_CODE_WIFI_ROKU: DEVICE_CLASS_WIFI_ROKU,
403
+ DEVICE_CLASS_CODE_IR: DEVICE_CLASS_IR,
404
+ DEVICE_CLASS_CODE_WIFI_HUE: DEVICE_CLASS_WIFI_HUE,
405
+ DEVICE_CLASS_CODE_WIFI_SONOS: DEVICE_CLASS_WIFI_SONOS,
406
+ DEVICE_CLASS_CODE_WIFI_IP: DEVICE_CLASS_WIFI_IP,
407
+ DEVICE_CLASS_CODE_WIFI_MQTT: DEVICE_CLASS_WIFI_MQTT,
408
+ }
409
+
410
+ DEVICE_CLASS_ALIASES: Dict[str, str] = {
411
+ "bt": DEVICE_CLASS_BLUETOOTH,
412
+ "bluetooth": DEVICE_CLASS_BLUETOOTH,
413
+ "device_type_bluetooth": DEVICE_CLASS_BLUETOOTH,
414
+ "hue": DEVICE_CLASS_WIFI_HUE,
415
+ "ip": DEVICE_CLASS_WIFI_IP,
416
+ "ir": DEVICE_CLASS_IR,
417
+ "rf": DEVICE_CLASS_RF_433,
418
+ "rf/315": DEVICE_CLASS_RF_315,
419
+ "rf/433": DEVICE_CLASS_RF_433,
420
+ "rf315": DEVICE_CLASS_RF_315,
421
+ "rf315mhz": DEVICE_CLASS_RF_315,
422
+ "rf_315": DEVICE_CLASS_RF_315,
423
+ "rf_315mhz": DEVICE_CLASS_RF_315,
424
+ "rf433": DEVICE_CLASS_RF_433,
425
+ "rf433mhz": DEVICE_CLASS_RF_433,
426
+ "rf_433": DEVICE_CLASS_RF_433,
427
+ "rf_433mhz": DEVICE_CLASS_RF_433,
428
+ "roku": DEVICE_CLASS_WIFI_ROKU,
429
+ "sonos": DEVICE_CLASS_WIFI_SONOS,
430
+ "mqtt": DEVICE_CLASS_WIFI_MQTT,
431
+ "315": DEVICE_CLASS_RF_315,
432
+ "315mhz": DEVICE_CLASS_RF_315,
433
+ "433": DEVICE_CLASS_RF_433,
434
+ "433mhz": DEVICE_CLASS_RF_433,
435
+ "virtual_http": DEVICE_CLASS_WIFI_IP,
436
+ "wifi/hue": DEVICE_CLASS_WIFI_HUE,
437
+ "wifi/ip": DEVICE_CLASS_WIFI_IP,
438
+ "wifi/mqtt": DEVICE_CLASS_WIFI_MQTT,
439
+ "wifi/roku": DEVICE_CLASS_WIFI_ROKU,
440
+ "wifi/sonos": DEVICE_CLASS_WIFI_SONOS,
441
+ "wifi_hue": DEVICE_CLASS_WIFI_HUE,
442
+ "wifi_ip": DEVICE_CLASS_WIFI_IP,
443
+ "wifi_mqtt": DEVICE_CLASS_WIFI_MQTT,
444
+ "wifi_roku": DEVICE_CLASS_WIFI_ROKU,
445
+ "wifi_sonos": DEVICE_CLASS_WIFI_SONOS,
446
+ }
447
+
448
+ PUBLIC_DEVICE_CLASSES: tuple[str, ...] = (
449
+ DEVICE_CLASS_IR,
450
+ DEVICE_CLASS_BLUETOOTH,
451
+ DEVICE_CLASS_WIFI_HUE,
452
+ DEVICE_CLASS_WIFI_ROKU,
453
+ DEVICE_CLASS_WIFI_IP,
454
+ DEVICE_CLASS_WIFI_MQTT,
455
+ DEVICE_CLASS_WIFI_SONOS,
456
+ DEVICE_CLASS_RF_315,
457
+ DEVICE_CLASS_RF_433,
458
+ )
459
+
460
+
461
+ def classify_device_class_code(device_class_code: Any) -> str | None:
462
+ """Map an observed device-class code to a stable normalized string."""
463
+
464
+ try:
465
+ code = int(device_class_code) & 0xFF
466
+ except (TypeError, ValueError):
467
+ return None
468
+ return DEVICE_CLASS_BY_CODE.get(code)
469
+
470
+
471
+ def normalize_device_class(value: Any) -> str | None:
472
+ text = str(value or "").strip().lower()
473
+ if not text:
474
+ return None
475
+ return DEVICE_CLASS_ALIASES.get(text, text)
476
+
477
+
478
+ def known_public_device_classes() -> tuple[str, ...]:
479
+ """Return the normalized public device classes we intentionally model."""
480
+
481
+ return PUBLIC_DEVICE_CLASSES
482
+
483
+
484
+ def opcode_family_name(opcode: int) -> str | None:
485
+ """Return a human-friendly name for an opcode family, if known."""
486
+
487
+ return FAMILY_NAMES.get(opcode_family(opcode))
488
+
489
+
490
+ def group_known_opcodes_by_family() -> dict[int, list[str]]:
491
+ """Return a mapping of low-byte opcode families to names defined here."""
492
+
493
+ family_map: dict[int, list[str]] = {}
494
+ for name, value in globals().items():
495
+ if not name.startswith("OP_"):
496
+ continue
497
+ if not isinstance(value, int):
498
+ continue
499
+ low = opcode_lo(value)
500
+ family_map.setdefault(low, []).append(name)
501
+ return family_map
502
+
503
+
504
+ __all__ = [
505
+ "SYNC0",
506
+ "SYNC1",
507
+ "ButtonName",
508
+ "BUTTONNAME_BY_CODE",
509
+ "DEVICE_CLASS_IR",
510
+ "DEVICE_CLASS_BLUETOOTH",
511
+ "DEVICE_CLASS_WIFI_HUE",
512
+ "DEVICE_CLASS_WIFI_ROKU",
513
+ "DEVICE_CLASS_WIFI_IP",
514
+ "DEVICE_CLASS_WIFI_MQTT",
515
+ "DEVICE_CLASS_WIFI_SONOS",
516
+ "DEVICE_CLASS_RF_315",
517
+ "DEVICE_CLASS_RF_433",
518
+ "DEVICE_CLASS_CODE_BLUETOOTH",
519
+ "DEVICE_CLASS_CODE_WIFI_ROKU",
520
+ "DEVICE_CLASS_CODE_IR",
521
+ "DEVICE_CLASS_CODE_WIFI_HUE",
522
+ "DEVICE_CLASS_CODE_WIFI_SONOS",
523
+ "DEVICE_CLASS_CODE_WIFI_IP",
524
+ "DEVICE_CLASS_CODE_WIFI_MQTT",
525
+ "DEVICE_CLASS_BY_CODE",
526
+ "DEVICE_CLASS_ALIASES",
527
+ "PUBLIC_DEVICE_CLASSES",
528
+ "classify_device_class_code",
529
+ "known_public_device_classes",
530
+ "normalize_device_class",
531
+ "OP_REQ_BANNER",
532
+ "OP_REQ_DEVICES",
533
+ "OP_REQ_ACTIVITIES",
534
+ "OP_REQ_BUTTONS",
535
+ "OP_REQ_COMMANDS",
536
+ "OP_REQ_BLOB",
537
+ "OP_REQ_ACTIVATE",
538
+ "OP_REQ_ACTIVITY_MAP",
539
+ "OP_DELETE_DEVICE",
540
+ "OP_SET_HUB_NAME",
541
+ "OP_FIND_REMOTE",
542
+ "OP_FIND_REMOTE_X2",
543
+ "OP_REMOTE_SYNC",
544
+ "OP_X2_REMOTE_LIST",
545
+ "OP_X2_REMOTE_LIST_ROW",
546
+ "OP_X2_REMOTE_SYNC",
547
+ "OP_CREATE_DEVICE_HEAD",
548
+ "OP_DEFINE_IP_CMD",
549
+ "OP_DEFINE_IP_CMD_EXISTING",
550
+ "OP_PREPARE_SAVE",
551
+ "OP_FINALIZE_DEVICE",
552
+ "OP_DEVICE_SAVE_HEAD",
553
+ "OP_SAVE_COMMIT",
554
+ "ACK_SUCCESS",
555
+ "OP_STATUS_ACK",
556
+ "OP_REQ_IPCMD_SYNC",
557
+ "OP_IPCMD_ROW_A",
558
+ "OP_IPCMD_ROW_B",
559
+ "OP_IPCMD_ROW_C",
560
+ "OP_IPCMD_ROW_D",
561
+ "OP_ACK_READY",
562
+ "OP_OTA_UPDATE",
563
+ "OP_MARKER",
564
+ "OP_CATALOG_ROW_DEVICE",
565
+ "OP_CATALOG_ROW_ACTIVITY",
566
+ "OP_DEVBTN_HEADER",
567
+ "OP_DEVBTN_PAGE",
568
+ "OP_DEVBTN_SINGLE",
569
+ "OP_DEVBTN_TAIL",
570
+ "OP_KEYMAP_EXTRA",
571
+ "OP_DEVBTN_MORE",
572
+ "OP_DEVBTN_PAGE_ALT1",
573
+ "OP_DEVBTN_PAGE_ALT2",
574
+ "OP_DEVBTN_PAGE_ALT3",
575
+ "OP_DEVBTN_PAGE_ALT4",
576
+ "OP_DEVBTN_PAGE_ALT5",
577
+ "OP_DEVBTN_PAGE_ALT6",
578
+ "OP_X1_DEVICE",
579
+ "OP_X1_ACTIVITY",
580
+ "OP_KEYMAP_TBL_A",
581
+ "OP_KEYMAP_TBL_B",
582
+ "OP_KEYMAP_TBL_C",
583
+ "OP_KEYMAP_TBL_D",
584
+ "OP_KEYMAP_TBL_F",
585
+ "OP_KEYMAP_TBL_E",
586
+ "OP_KEYMAP_TBL_G",
587
+ "OP_KEYMAP_CONT",
588
+ "OP_KEYMAP_FINAL_X1S",
589
+ "OP_KEYMAP_PAGE_X2_C03D",
590
+ "OP_KEYMAP_PAGE_X1_663D",
591
+ "OP_KEYMAP_OVERLAY_X1",
592
+ "OP_KEYMAP_PAGE_X1_AE3D",
593
+ "OP_KEYMAP_PAGE_X1_E43D",
594
+ "OP_CALL_ME",
595
+ "OP_ACTIVITY_MAP_PAGE",
596
+ "OP_REQ_VERSION",
597
+ "OP_REQ_IDLE_BEHAVIOR",
598
+ "OP_SET_IDLE_BEHAVIOR",
599
+ "OP_IDLE_BEHAVIOR",
600
+ "OP_PING2",
601
+ "OP_PING2_ACK",
602
+ "OP_ACTIVITY_DEVICE_CONFIRM",
603
+ "OP_REQ_ACTIVITY_INPUTS",
604
+ "OP_REQ_MACRO_LABELS",
605
+ "OP_MACROS_A1",
606
+ "OP_MACROS_B1",
607
+ "OP_MACROS_A2",
608
+ "OP_MACROS_B2",
609
+ "OP_ACTIVITY_INPUTS_PAGE_A",
610
+ "OP_ACTIVITY_INPUTS_PAGE_B",
611
+ "OP_ACTIVITY_ASSIGN_FINALIZE",
612
+ "OP_ACTIVITY_CONFIRM",
613
+ "OP_ACTIVITY_MAP_PAGE_X1S",
614
+ "OP_BANNER",
615
+ "OP_WIFI_FW",
616
+ "OP_INFO_BANNER",
617
+ "OPNAMES",
618
+ "opcode_hi",
619
+ "opcode_lo",
620
+ "opcode_family",
621
+ "opcode_family_name",
622
+ "FAMILY_NAMES",
623
+ "FAMILY_STATUS_ACK",
624
+ "FAMILY_HUB_NAME_REPLY",
625
+ "FAMILY_DEV_ROW",
626
+ "FAMILY_ACT_ROW",
627
+ "FAMILY_MACROS",
628
+ "FAMILY_KEYMAP",
629
+ "FAMILY_DEVBTNS",
630
+ "FAMILY_FAV_DELETE",
631
+ "FAMILY_FAV_ORDER_REQ",
632
+ "FAMILY_FAV_ORDER_RESP",
633
+ ]