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
sofapython/devices.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""Structured builders for device-record payloads sent via family 0x07.
|
|
2
|
+
|
|
3
|
+
The hub exchanges device records (TV/AVR/WiFi/IP/etc.) using a single
|
|
4
|
+
fixed-shape body whose width depends on hub model: the X1 firmware uses
|
|
5
|
+
30-byte name/brand/tail slots (120-byte body), and the X1S/X2 firmware
|
|
6
|
+
widens those slots to 60 bytes (210-byte body). Both share the same
|
|
7
|
+
field order in the header, and both terminate with a 1-byte checksum
|
|
8
|
+
that sums every preceding byte modulo 256.
|
|
9
|
+
|
|
10
|
+
The integration historically built this payload by patching a frozen
|
|
11
|
+
hex template via ``payload.find(...)``-style lookups, which made the
|
|
12
|
+
write path device-class-specific and impossible to round-trip with a
|
|
13
|
+
fetched device row. This module exposes the canonical shape as a
|
|
14
|
+
plain dataclass so backup/restore flows can serialise a hub-stored
|
|
15
|
+
device, mutate any field, and reconstruct a faithful write payload.
|
|
16
|
+
|
|
17
|
+
The encoding is the same on both directions of the wire: name and
|
|
18
|
+
brand slots are ASCII on X1 and UTF-16BE on X1S/X2 (matching the
|
|
19
|
+
existing CatalogDeviceHandler decode path).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Final, Mapping
|
|
26
|
+
|
|
27
|
+
from .hub_versions import HUB_VERSION_X1, HUB_VERSION_X1S, HUB_VERSION_X2
|
|
28
|
+
from .wire_schema import schema_for
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
#: Fixed length of the binary code identifier baked into the header.
|
|
32
|
+
DEVICE_CODE_ID_LEN: Final[int] = 16
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
#: Total body length on X1 firmware (excluding the outer ``[01][seq_be]``
|
|
36
|
+
#: wrapper but including the trailing checksum byte). Mirrored from the
|
|
37
|
+
#: shared :mod:`wire_schema` so legacy imports keep working; the schema
|
|
38
|
+
#: module is the source of truth.
|
|
39
|
+
DEVICE_BODY_LEN_X1: Final[int] = schema_for(HUB_VERSION_X1).device_body_len
|
|
40
|
+
|
|
41
|
+
#: Total body length on X1S/X2 firmware.
|
|
42
|
+
DEVICE_BODY_LEN_X1S_X2: Final[int] = schema_for(HUB_VERSION_X1S).device_body_len
|
|
43
|
+
|
|
44
|
+
# Module-load asserts: catch any future drift between the historical
|
|
45
|
+
# literals and the per-variant schema. The literals exist only for
|
|
46
|
+
# external test fixtures that already imported them.
|
|
47
|
+
assert DEVICE_BODY_LEN_X1 == 120
|
|
48
|
+
assert DEVICE_BODY_LEN_X1S_X2 == 210
|
|
49
|
+
assert schema_for(HUB_VERSION_X1).device_slot_width == 30
|
|
50
|
+
assert schema_for(HUB_VERSION_X1S).device_slot_width == 60
|
|
51
|
+
assert schema_for(HUB_VERSION_X2).device_slot_width == 60
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True, frozen=True)
|
|
55
|
+
class DeviceConfig:
|
|
56
|
+
"""All fields the hub stores per device record.
|
|
57
|
+
|
|
58
|
+
Construct one of these to send a create or update via
|
|
59
|
+
:func:`build_device_create_payload`. The ``device_id`` field is
|
|
60
|
+
``0xFF`` for create-new (the hub assigns the real id and echoes it
|
|
61
|
+
back in the post-create ACK); set it to the existing id to update.
|
|
62
|
+
|
|
63
|
+
The string fields ``name`` and ``brand`` are encoded into the body
|
|
64
|
+
using the hub's slot width and codec. On X1 the slots are 30-byte
|
|
65
|
+
ASCII; on X1S/X2 they are 60-byte UTF-16BE. Strings longer than
|
|
66
|
+
the slot are truncated; shorter strings are zero-padded.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
name: str = ""
|
|
70
|
+
brand: str = ""
|
|
71
|
+
|
|
72
|
+
#: Set to ``0xFF`` to create a new record; the real id to update.
|
|
73
|
+
device_id: int = 0xFF
|
|
74
|
+
|
|
75
|
+
#: Record kind marker. ``0`` for create, the existing record's
|
|
76
|
+
#: marker for update. Maps to body byte 3 of the header.
|
|
77
|
+
record_kind: int = 0
|
|
78
|
+
|
|
79
|
+
#: Icon glyph id rendered on the remote's screen.
|
|
80
|
+
icon: int = 1
|
|
81
|
+
|
|
82
|
+
#: Sort order within the activity-device list. Hubs sort by this
|
|
83
|
+
#: when rendering the device picker.
|
|
84
|
+
sort: int = 0
|
|
85
|
+
|
|
86
|
+
#: Code-source type: which IRDB / WiFi / RF backend the device's
|
|
87
|
+
#: commands come from. Common values seen on real devices:
|
|
88
|
+
#: ``0x0A`` for WiFi-IP virtual devices, ``0x0D`` for IR-DB,
|
|
89
|
+
#: ``0x10`` for raw-IR learned, plus several RF variants.
|
|
90
|
+
code_type: int = 0x0A
|
|
91
|
+
|
|
92
|
+
#: Device category (TV, AVR, set-top box, smart light, etc.).
|
|
93
|
+
device_type: int = 0x10
|
|
94
|
+
|
|
95
|
+
#: 16-byte opaque code identifier. Zero-filled for WiFi-IP devices;
|
|
96
|
+
#: an IRDB lookup key for IR/RF devices.
|
|
97
|
+
code_id: bytes = b"\x00" * DEVICE_CODE_ID_LEN
|
|
98
|
+
|
|
99
|
+
#: Visibility flag. ``1`` hides the device from the main remote
|
|
100
|
+
#: picker (still usable in activities).
|
|
101
|
+
hide: int = 0
|
|
102
|
+
|
|
103
|
+
#: Input-list visibility flag. ``1`` exposes the device's input
|
|
104
|
+
#: list under the activity-inputs view.
|
|
105
|
+
input_flag: int = 0
|
|
106
|
+
|
|
107
|
+
#: Hub-defined channel index. Meaning is device-class-specific
|
|
108
|
+
#: (e.g. IR carrier-group selector on RF devices).
|
|
109
|
+
channel: int = 0
|
|
110
|
+
|
|
111
|
+
#: Initial power state to display: ``0`` off, ``1`` on.
|
|
112
|
+
power_state: int = 0
|
|
113
|
+
|
|
114
|
+
#: IPv4 address as a dotted-decimal string. Encoded into the tail
|
|
115
|
+
#: slot's IP marker. ``None`` leaves the marker empty (used for
|
|
116
|
+
#: non-network devices).
|
|
117
|
+
ip_address: str | None = None
|
|
118
|
+
|
|
119
|
+
#: Poll interval (hub-defined, seen in seconds in real captures).
|
|
120
|
+
#: Set to ``-1`` to omit the marker; ``0`` or positive values
|
|
121
|
+
#: emit the marker with a 2-byte big-endian value.
|
|
122
|
+
poll_time: int = -1
|
|
123
|
+
|
|
124
|
+
#: Input-switching configuration. The value identifies which of the
|
|
125
|
+
#: input styles the device uses:
|
|
126
|
+
#:
|
|
127
|
+
#: - ``0`` **needs configuration** -- the user has not picked an
|
|
128
|
+
#: input style yet. The remote shows "Not configured" in its
|
|
129
|
+
#: device list and the hub rejects REQ_ACTIVITY_INPUTS with a
|
|
130
|
+
#: non-success STATUS_ACK.
|
|
131
|
+
#: - ``1`` direct / discrete inputs (each input is its own command).
|
|
132
|
+
#: - ``2`` source-list style -- the device exposes a configured list
|
|
133
|
+
#: of named sources (typical for WiFi-Roku and similar devices
|
|
134
|
+
#: where the activity picker cycles through entries). The 0x46
|
|
135
|
+
#: page carries real entries in this mode.
|
|
136
|
+
#: - ``3`` not yet observed; the remaining configuration choice in
|
|
137
|
+
#: the app is between menu-based and next-input styles, one of
|
|
138
|
+
#: which is this value.
|
|
139
|
+
#:
|
|
140
|
+
#: Devices can arrive from the IRDB with a non-zero ``input_mode``
|
|
141
|
+
#: baked into the create payload, so this is **not** purely user-set;
|
|
142
|
+
#: it reflects what the IRDB entry says about the device's input
|
|
143
|
+
#: behaviour by default. See :attr:`is_input_configured`.
|
|
144
|
+
input_mode: int = 0
|
|
145
|
+
|
|
146
|
+
#: Power configuration value. ``0`` means **power has not been
|
|
147
|
+
#: configured** on this device (the user has not picked a power
|
|
148
|
+
#: style). A non-zero value indicates power is configured; the
|
|
149
|
+
#: specific value distinguishes between toggle / discrete /
|
|
150
|
+
#: separate-on-off styles, but the encoding here is not fully
|
|
151
|
+
#: decoded yet (real captures show ``0`` unconfigured, ``1``
|
|
152
|
+
#: configured). See :attr:`is_power_configured`.
|
|
153
|
+
power_mode: int = 0
|
|
154
|
+
|
|
155
|
+
#: Companion byte to :attr:`power_mode`. Observed values vary per
|
|
156
|
+
#: device (IRDB metadata, not a fixed sentinel): real captures show
|
|
157
|
+
#: ``1`` and ``2`` on freshly-created devices before any user
|
|
158
|
+
#: configuration, and ``3`` once power is configured on the device
|
|
159
|
+
#: that started at ``2``. The exact meaning is not fully decoded.
|
|
160
|
+
power_style: int = 2
|
|
161
|
+
|
|
162
|
+
#: Shared-state flag controlling whether the device's commands are
|
|
163
|
+
#: visible to other apps/integrations.
|
|
164
|
+
share_mode: int = 0
|
|
165
|
+
|
|
166
|
+
#: Tail-slot marker byte at offset 17 of the tail region. Empirical
|
|
167
|
+
#: captures show this is ``1`` on WiFi-IP devices and ``0`` on
|
|
168
|
+
#: other classes; the value is round-tripped by this builder.
|
|
169
|
+
tail_marker: int = 0
|
|
170
|
+
|
|
171
|
+
#: Optional vendor-extension triple. When ``extras_present`` is
|
|
172
|
+
#: true, the tail region carries an additional ``0xFC`` marker
|
|
173
|
+
#: followed by these three bytes (seen on some X1S build variants).
|
|
174
|
+
extra_a: int = 0
|
|
175
|
+
extra_b: int = 0
|
|
176
|
+
extra_c: int = 0
|
|
177
|
+
extras_present: bool = False
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def is_input_configured(self) -> bool:
|
|
181
|
+
"""``True`` when the device has been configured for inputs.
|
|
182
|
+
|
|
183
|
+
Empirically: tail byte 10 (``input_mode``) is ``0`` on devices
|
|
184
|
+
the user has not yet configured for inputs, and non-zero (with
|
|
185
|
+
the value indicating which input style was chosen) once they
|
|
186
|
+
have. When ``False``, REQ_ACTIVITY_INPUTS is rejected by the
|
|
187
|
+
hub with a non-success STATUS_ACK.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
return self.input_mode != 0
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def is_power_configured(self) -> bool:
|
|
194
|
+
"""``True`` when the device has been configured for power.
|
|
195
|
+
|
|
196
|
+
Empirically: tail byte 11 (``power_mode``) is ``0`` on devices
|
|
197
|
+
the user has not yet configured for power, and non-zero once
|
|
198
|
+
they have (the specific value encodes the chosen power style).
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
return self.power_mode != 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Builder
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _slot_widths_for(hub_version: str) -> tuple[int, int, str]:
|
|
210
|
+
"""Return (slot_width, body_len, encoding) for the hub variant.
|
|
211
|
+
|
|
212
|
+
Thin wrapper over :func:`schema_for` kept for call-site stability;
|
|
213
|
+
raises ``ValueError`` on unknown variants via the shared schema.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
schema = schema_for(hub_version)
|
|
217
|
+
return schema.device_slot_width, schema.device_body_len, schema.device_label_encoding
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _encode_text_slot(value: str, *, width: int, encoding: str) -> bytes:
|
|
221
|
+
"""Encode a text field into a fixed-width zero-padded slot."""
|
|
222
|
+
|
|
223
|
+
encoded = value.encode(encoding, errors="ignore")
|
|
224
|
+
if len(encoded) > width:
|
|
225
|
+
encoded = encoded[:width]
|
|
226
|
+
return encoded + b"\x00" * (width - len(encoded))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _encode_ip(ip_address: str | None) -> bytes | None:
|
|
230
|
+
"""Encode a dotted IPv4 string into 4 packed bytes, or ``None``."""
|
|
231
|
+
|
|
232
|
+
if not ip_address:
|
|
233
|
+
return None
|
|
234
|
+
parts = ip_address.split(".")
|
|
235
|
+
if len(parts) != 4:
|
|
236
|
+
return None
|
|
237
|
+
try:
|
|
238
|
+
return bytes(int(part) & 0xFF for part in parts)
|
|
239
|
+
except ValueError:
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _build_tail_slot(config: DeviceConfig, *, width: int) -> bytes:
|
|
244
|
+
"""Build the tail-slot bytes (width 30 on X1, 60 on X1S/X2).
|
|
245
|
+
|
|
246
|
+
The first 18 bytes carry the structured device-state markers; the
|
|
247
|
+
remaining bytes are zero (or, when ``extras_present`` is true,
|
|
248
|
+
carry an extra 4-byte marker block in the X1S/X2 variant).
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
tail = bytearray(width)
|
|
252
|
+
|
|
253
|
+
ip_bytes = _encode_ip(config.ip_address)
|
|
254
|
+
if ip_bytes is not None:
|
|
255
|
+
tail[0] = 0xFC
|
|
256
|
+
tail[1] = 0x55
|
|
257
|
+
tail[2:6] = ip_bytes
|
|
258
|
+
|
|
259
|
+
if config.poll_time >= 0:
|
|
260
|
+
tail[6] = 0xFC
|
|
261
|
+
tail[7:9] = (config.poll_time & 0xFFFF).to_bytes(2, "big")
|
|
262
|
+
|
|
263
|
+
tail[9] = 0xFC
|
|
264
|
+
tail[10] = config.input_mode & 0xFF
|
|
265
|
+
tail[11] = config.power_mode & 0xFF
|
|
266
|
+
tail[12] = config.power_style & 0xFF
|
|
267
|
+
tail[13] = config.share_mode & 0xFF
|
|
268
|
+
tail[14] = 0xFC
|
|
269
|
+
tail[15] = 0
|
|
270
|
+
tail[16] = 0xFC
|
|
271
|
+
tail[17] = config.tail_marker & 0xFF
|
|
272
|
+
|
|
273
|
+
if config.extras_present and width >= 22:
|
|
274
|
+
tail[18] = 0xFC
|
|
275
|
+
tail[19] = config.extra_a & 0xFF
|
|
276
|
+
tail[20] = config.extra_b & 0xFF
|
|
277
|
+
tail[21] = config.extra_c & 0xFF
|
|
278
|
+
|
|
279
|
+
return bytes(tail)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def build_device_create_payload(config: DeviceConfig, *, hub_version: str) -> bytes:
|
|
283
|
+
"""Serialise a :class:`DeviceConfig` into a family-0x07 payload.
|
|
284
|
+
|
|
285
|
+
The returned bytes are the outer-wrapped ``[0x01][seq_be][body]``
|
|
286
|
+
form ready to hand to the family-0x07 sender. The body itself is
|
|
287
|
+
120 bytes on X1 and 210 bytes on X1S/X2, terminated by a single
|
|
288
|
+
checksum byte computed over the preceding body content.
|
|
289
|
+
|
|
290
|
+
The payload is always a single page (the body fits well inside the
|
|
291
|
+
247-byte chunk limit), so no paging is required.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
slot_width, body_len, encoding = _slot_widths_for(hub_version)
|
|
295
|
+
|
|
296
|
+
code_id = bytes(config.code_id)
|
|
297
|
+
if len(code_id) < DEVICE_CODE_ID_LEN:
|
|
298
|
+
code_id = code_id + b"\x00" * (DEVICE_CODE_ID_LEN - len(code_id))
|
|
299
|
+
elif len(code_id) > DEVICE_CODE_ID_LEN:
|
|
300
|
+
code_id = code_id[:DEVICE_CODE_ID_LEN]
|
|
301
|
+
|
|
302
|
+
body = bytearray(body_len)
|
|
303
|
+
body[0] = 0x01
|
|
304
|
+
body[1:3] = (1).to_bytes(2, "big") # total_pages = 1
|
|
305
|
+
body[3] = config.record_kind & 0xFF
|
|
306
|
+
body[4] = config.device_id & 0xFF
|
|
307
|
+
body[5] = config.icon & 0xFF
|
|
308
|
+
body[6] = config.sort & 0xFF
|
|
309
|
+
body[7] = config.code_type & 0xFF
|
|
310
|
+
body[8] = config.device_type & 0xFF
|
|
311
|
+
body[9:25] = code_id
|
|
312
|
+
body[25] = config.hide & 0xFF
|
|
313
|
+
body[26] = config.input_flag & 0xFF
|
|
314
|
+
body[27] = config.channel & 0xFF
|
|
315
|
+
body[28] = config.power_state & 0xFF
|
|
316
|
+
|
|
317
|
+
name_start = 29
|
|
318
|
+
brand_start = name_start + slot_width
|
|
319
|
+
tail_start = brand_start + slot_width
|
|
320
|
+
checksum_index = body_len - 1
|
|
321
|
+
|
|
322
|
+
body[name_start : name_start + slot_width] = _encode_text_slot(
|
|
323
|
+
config.name, width=slot_width, encoding=encoding
|
|
324
|
+
)
|
|
325
|
+
body[brand_start : brand_start + slot_width] = _encode_text_slot(
|
|
326
|
+
config.brand, width=slot_width, encoding=encoding
|
|
327
|
+
)
|
|
328
|
+
body[tail_start : tail_start + slot_width] = _build_tail_slot(config, width=slot_width)
|
|
329
|
+
|
|
330
|
+
body[checksum_index] = sum(body[:checksum_index]) & 0xFF
|
|
331
|
+
|
|
332
|
+
return bytes([0x01, 0x00, 0x01]) + bytes(body)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
# Parser (inverse of the builder)
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _decode_text_slot(slot: bytes, *, encoding: str) -> str:
|
|
341
|
+
"""Decode a fixed-width slot, stripping trailing nulls and whitespace."""
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
if encoding == "utf-16-be":
|
|
345
|
+
raw = slot if len(slot) % 2 == 0 else slot[:-1]
|
|
346
|
+
decoded = raw.decode(encoding, errors="ignore")
|
|
347
|
+
else:
|
|
348
|
+
decoded = slot.decode(encoding, errors="ignore")
|
|
349
|
+
except Exception:
|
|
350
|
+
decoded = ""
|
|
351
|
+
return decoded.rstrip("\x00").strip()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _decode_ip(tail_prefix: bytes) -> str | None:
|
|
355
|
+
"""Extract the IPv4 address from the leading IP-marker bytes of a tail.
|
|
356
|
+
|
|
357
|
+
Returns ``None`` when the marker is absent (first byte is not 0xFC,
|
|
358
|
+
or the marker bytes are zero, indicating the device has no IP).
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
if len(tail_prefix) < 6 or tail_prefix[0] != 0xFC or tail_prefix[1] != 0x55:
|
|
362
|
+
return None
|
|
363
|
+
if tail_prefix[2:6] == b"\x00\x00\x00\x00":
|
|
364
|
+
return None
|
|
365
|
+
return ".".join(str(b) for b in tail_prefix[2:6])
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def parse_device_record(
|
|
369
|
+
body: bytes,
|
|
370
|
+
*,
|
|
371
|
+
hub_version: str,
|
|
372
|
+
entity_kind: str = "device",
|
|
373
|
+
) -> DeviceConfig:
|
|
374
|
+
"""Decode a hub-stored device record body into a :class:`DeviceConfig`.
|
|
375
|
+
|
|
376
|
+
``body`` is the inner body (with outer ``[01][seq_be]`` wrapper
|
|
377
|
+
already stripped) as received from a CATALOG_ROW_DEVICE frame
|
|
378
|
+
(family ``0x07``), a CATALOG_ROW_ACTIVITY frame (family ``0x37``),
|
|
379
|
+
an OP_REQ_BLOB device response, or any other path that exposes
|
|
380
|
+
the full 120/210-byte record. The returned config is the inverse
|
|
381
|
+
of :func:`build_device_create_payload` and can be passed straight
|
|
382
|
+
back to it to reconstruct the same wire bytes.
|
|
383
|
+
|
|
384
|
+
``entity_kind`` defaults to ``"device"`` and accepts ``"activity"``
|
|
385
|
+
for callers that want to make the activity (family-0x37)
|
|
386
|
+
interpretation explicit at the call site. The body layout is
|
|
387
|
+
identical between the two kinds -- the field is metadata, not a
|
|
388
|
+
parse-time discriminant.
|
|
389
|
+
|
|
390
|
+
Raises ``ValueError`` if the body length does not match the hub
|
|
391
|
+
variant, or if ``entity_kind`` is unrecognised.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
if entity_kind not in ("device", "activity"):
|
|
395
|
+
raise ValueError(
|
|
396
|
+
"parse_device_record: entity_kind must be 'device' or 'activity'; "
|
|
397
|
+
f"got {entity_kind!r}"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
slot_width, expected_len, encoding = _slot_widths_for(hub_version)
|
|
401
|
+
if len(body) != expected_len:
|
|
402
|
+
raise ValueError(
|
|
403
|
+
f"parse_device_record: body length {len(body)} does not match "
|
|
404
|
+
f"expected {expected_len} for hub_version={hub_version!r}"
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
name_start = 29
|
|
408
|
+
brand_start = name_start + slot_width
|
|
409
|
+
tail_start = brand_start + slot_width
|
|
410
|
+
|
|
411
|
+
code_id = bytes(body[9:25])
|
|
412
|
+
name = _decode_text_slot(body[name_start : name_start + slot_width], encoding=encoding)
|
|
413
|
+
brand = _decode_text_slot(body[brand_start : brand_start + slot_width], encoding=encoding)
|
|
414
|
+
|
|
415
|
+
tail = body[tail_start : tail_start + slot_width]
|
|
416
|
+
ip_address = _decode_ip(tail[:6])
|
|
417
|
+
|
|
418
|
+
poll_time = -1
|
|
419
|
+
if len(tail) > 8 and tail[6] == 0xFC:
|
|
420
|
+
poll_time = int.from_bytes(tail[7:9], "big")
|
|
421
|
+
|
|
422
|
+
input_mode = tail[10] if len(tail) > 10 else 0
|
|
423
|
+
power_mode = tail[11] if len(tail) > 11 else 0
|
|
424
|
+
power_style = tail[12] if len(tail) > 12 else 0
|
|
425
|
+
share_mode = tail[13] if len(tail) > 13 else 0
|
|
426
|
+
tail_marker = tail[17] if len(tail) > 17 else 0
|
|
427
|
+
|
|
428
|
+
extras_present = False
|
|
429
|
+
extra_a = extra_b = extra_c = 0
|
|
430
|
+
if len(tail) > 21 and tail[18] == 0xFC:
|
|
431
|
+
extras_present = True
|
|
432
|
+
extra_a = tail[19]
|
|
433
|
+
extra_b = tail[20]
|
|
434
|
+
extra_c = tail[21]
|
|
435
|
+
|
|
436
|
+
return DeviceConfig(
|
|
437
|
+
name=name,
|
|
438
|
+
brand=brand,
|
|
439
|
+
device_id=body[4],
|
|
440
|
+
record_kind=body[3],
|
|
441
|
+
icon=body[5],
|
|
442
|
+
sort=body[6],
|
|
443
|
+
code_type=body[7],
|
|
444
|
+
device_type=body[8],
|
|
445
|
+
code_id=code_id,
|
|
446
|
+
hide=body[25],
|
|
447
|
+
input_flag=body[26],
|
|
448
|
+
channel=body[27],
|
|
449
|
+
power_state=body[28],
|
|
450
|
+
ip_address=ip_address,
|
|
451
|
+
poll_time=poll_time,
|
|
452
|
+
input_mode=input_mode,
|
|
453
|
+
power_mode=power_mode,
|
|
454
|
+
power_style=power_style,
|
|
455
|
+
share_mode=share_mode,
|
|
456
|
+
tail_marker=tail_marker,
|
|
457
|
+
extra_a=extra_a,
|
|
458
|
+
extra_b=extra_b,
|
|
459
|
+
extra_c=extra_c,
|
|
460
|
+
extras_present=extras_present,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def device_config_from_backup(
|
|
465
|
+
device: Mapping[str, object],
|
|
466
|
+
*,
|
|
467
|
+
for_create: bool = False,
|
|
468
|
+
) -> DeviceConfig:
|
|
469
|
+
"""Build a :class:`DeviceConfig` from a ``backup_device`` payload block.
|
|
470
|
+
|
|
471
|
+
``device`` is the ``payload["device"]`` dictionary returned by
|
|
472
|
+
:meth:`SofabatonHub.async_backup_device`. When ``for_create`` is true, the
|
|
473
|
+
returned config is shaped for a fresh create transaction: ``device_id`` is
|
|
474
|
+
set to ``0xFF``, ``record_kind`` to ``0``, and ``tail_marker`` to ``0`` so
|
|
475
|
+
the subsequent update/commit step can finalize the record with the
|
|
476
|
+
hub-assigned device id.
|
|
477
|
+
"""
|
|
478
|
+
|
|
479
|
+
def _as_int(key: str, default: int = 0) -> int:
|
|
480
|
+
value = device.get(key, default)
|
|
481
|
+
try:
|
|
482
|
+
return int(value)
|
|
483
|
+
except (TypeError, ValueError):
|
|
484
|
+
return default
|
|
485
|
+
|
|
486
|
+
code_id_raw = str(device.get("code_id_hex") or "")
|
|
487
|
+
code_id_hex = "".join(ch for ch in code_id_raw if ch not in " \t\r\n")
|
|
488
|
+
if len(code_id_hex) % 2:
|
|
489
|
+
code_id_hex = code_id_hex[:-1]
|
|
490
|
+
try:
|
|
491
|
+
code_id = bytes.fromhex(code_id_hex) if code_id_hex else b""
|
|
492
|
+
except ValueError:
|
|
493
|
+
code_id = b""
|
|
494
|
+
|
|
495
|
+
extras = device.get("extras")
|
|
496
|
+
extras_present = isinstance(extras, Mapping)
|
|
497
|
+
|
|
498
|
+
return DeviceConfig(
|
|
499
|
+
name=str(device.get("name") or ""),
|
|
500
|
+
brand=str(device.get("brand") or ""),
|
|
501
|
+
device_id=0xFF if for_create else (_as_int("device_id", 0xFF) & 0xFF),
|
|
502
|
+
record_kind=0 if for_create else (_as_int("record_kind", 0) & 0xFF),
|
|
503
|
+
icon=_as_int("icon", 1) & 0xFF,
|
|
504
|
+
sort=_as_int("sort", 0) & 0xFF,
|
|
505
|
+
code_type=_as_int("code_type", 0x0A) & 0xFF,
|
|
506
|
+
device_type=_as_int("device_type", 0x10) & 0xFF,
|
|
507
|
+
code_id=code_id,
|
|
508
|
+
hide=_as_int("hide", 0) & 0xFF,
|
|
509
|
+
input_flag=_as_int("input_flag", 0) & 0xFF,
|
|
510
|
+
channel=_as_int("channel", 0) & 0xFF,
|
|
511
|
+
power_state=_as_int("power_state", 0) & 0xFF,
|
|
512
|
+
ip_address=str(device.get("ip_address")) if device.get("ip_address") else None,
|
|
513
|
+
poll_time=_as_int("poll_time", -1),
|
|
514
|
+
input_mode=_as_int("input_mode", 0) & 0xFF,
|
|
515
|
+
power_mode=_as_int("power_mode", 0) & 0xFF,
|
|
516
|
+
power_style=_as_int("power_style", 2) & 0xFF,
|
|
517
|
+
share_mode=_as_int("share_mode", 0) & 0xFF,
|
|
518
|
+
tail_marker=0 if for_create else (_as_int("tail_marker", 1) & 0xFF),
|
|
519
|
+
extra_a=_as_int("a", 0) & 0xFF if isinstance(extras, Mapping) else 0,
|
|
520
|
+
extra_b=_as_int("b", 0) & 0xFF if isinstance(extras, Mapping) else 0,
|
|
521
|
+
extra_c=_as_int("c", 0) & 0xFF if isinstance(extras, Mapping) else 0,
|
|
522
|
+
extras_present=extras_present,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
__all__ = [
|
|
527
|
+
"DEVICE_BODY_LEN_X1",
|
|
528
|
+
"DEVICE_BODY_LEN_X1S_X2",
|
|
529
|
+
"DEVICE_CODE_ID_LEN",
|
|
530
|
+
"DeviceConfig",
|
|
531
|
+
"build_device_create_payload",
|
|
532
|
+
"device_config_from_backup",
|
|
533
|
+
"parse_device_record",
|
|
534
|
+
]
|