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/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
+ ]