python-mobius 0.9.0__py3-none-any.whl → 0.10.1__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.
mobius/__init__.py CHANGED
@@ -9,6 +9,10 @@ documentation/ for the protocol description.
9
9
  """
10
10
 
11
11
  from .crc import crc16, CRC16_TABLE
12
+ from .checksums import (
13
+ ConfigurationCache, ConfigurationChecksums, configured_scenes_checksum, schedule_checksum,
14
+ supported_attributes_crc,
15
+ )
12
16
  from .frame import (
13
17
  build_frame, parse_frame, ParsedFrame,
14
18
  encode_get_attribute, encode_get_attributes_batch, encode_set_attribute, decode_attribute_response, AttributeValue,
@@ -22,7 +26,7 @@ from .constants import (
22
26
  PrimitiveType, PRIMITIVE_SIZE, LIGHT_PRIMITIVES,
23
27
  PUMP_PRIMITIVES_VERIFIED, PUMP_PRIMITIVES_EXPERIMENTAL, PUMP_PRIMITIVES, enum_or_none,
24
28
  primitive_type_from_name, support_tier, PumpParamRange, pump_param_range, pump_param_errors,
25
- Model, MODEL_MANUFACTURER, manufacturer_for_model,
29
+ Model, MODEL_MANUFACTURER, manufacturer_for_model, BLUETOOTH_ONLY_MODELS, is_bluetooth_only,
26
30
  ErrorState, C2Attribute, OperationMode, PhysicalValueID, VisualID,
27
31
  SceneID, OperationState, FsciStatus,
28
32
  PumpMode, PumpOverrideMode, RampType, PumpParam, PUMP_PARAM_SIZE, PUMP_MODE_PARAMS,
@@ -68,9 +72,10 @@ from .device import (
68
72
  from .discovery import (
69
73
  scan_for_mobius_devices, scan_for_mobius_devices_with_info, group_by_pan_id,
70
74
  dedupe_by_serial, find_device_by_serial, discover_mesh_peers_via_direct_connect,
71
- discover_tank,
75
+ discover_tank, find_bluetooth_only_tank_members,
72
76
  )
73
77
  from .frame import SupportedAttribute
78
+ from .timezone import posix_tz_string, TIME_ZONE_FIELD_SIZE
74
79
  from .dump import (
75
80
  dump_attributes, format_attribute_dump_json, parse_attribute_dump_json,
76
81
  format_attribute_dump_text, parse_attribute_dump_text, parse_attribute_dump,
@@ -82,12 +87,13 @@ from .mob import (
82
87
  MAX_PUMP_PRIMITIVE_SIZE,
83
88
  )
84
89
 
85
- __version__ = "0.9.0"
90
+ __version__ = "0.10.1"
86
91
 
87
92
  __all__ = [
88
93
  "__version__",
89
94
  # crc
90
- "crc16", "CRC16_TABLE",
95
+ "crc16", "CRC16_TABLE", "supported_attributes_crc", "configured_scenes_checksum",
96
+ "schedule_checksum", "ConfigurationChecksums", "ConfigurationCache",
91
97
  # frame
92
98
  "build_frame", "parse_frame", "ParsedFrame",
93
99
  "encode_get_attribute", "encode_get_attributes_batch", "encode_set_attribute", "decode_attribute_response", "AttributeValue",
@@ -100,7 +106,7 @@ __all__ = [
100
106
  "PrimitiveType", "PRIMITIVE_SIZE", "LIGHT_PRIMITIVES",
101
107
  "PUMP_PRIMITIVES_VERIFIED", "PUMP_PRIMITIVES_EXPERIMENTAL", "PUMP_PRIMITIVES", "enum_or_none",
102
108
  "primitive_type_from_name", "support_tier", "PumpParamRange", "pump_param_range", "pump_param_errors",
103
- "Model", "MODEL_MANUFACTURER", "manufacturer_for_model",
109
+ "Model", "MODEL_MANUFACTURER", "BLUETOOTH_ONLY_MODELS", "is_bluetooth_only", "manufacturer_for_model",
104
110
  "ErrorState", "C2Attribute", "OperationMode", "PhysicalValueID", "VisualID",
105
111
  "SceneID", "OperationState", "FsciStatus",
106
112
  "PumpMode", "PumpOverrideMode", "RampType", "PumpParam", "PUMP_PARAM_SIZE", "PUMP_MODE_PARAMS",
@@ -134,7 +140,7 @@ __all__ = [
134
140
  "next_coap_token",
135
141
  "mesh_local_prefix_from_own_address", "mesh_local_prefix_from_mlprefix", "is_valid_mesh_address",
136
142
  "build_rloc_address", "extract_short_address", "is_short_address_derived",
137
- "format_mesh_address",
143
+ "format_mesh_address", "posix_tz_string", "TIME_ZONE_FIELD_SIZE",
138
144
  "RelayedMobiusDevice",
139
145
  # device
140
146
  "MobiusDevice", "MobiusPump", "LightIntensityResult", "LightPollResult", "FullPollResult",
@@ -143,7 +149,7 @@ __all__ = [
143
149
  # discovery
144
150
  "scan_for_mobius_devices", "scan_for_mobius_devices_with_info", "group_by_pan_id",
145
151
  "dedupe_by_serial", "find_device_by_serial", "discover_mesh_peers_via_direct_connect",
146
- "discover_tank",
152
+ "find_bluetooth_only_tank_members", "discover_tank",
147
153
  "SupportedAttribute", "dump_attributes", "enrich_attribute_dump", "generic_interpretations",
148
154
  "format_attribute_dump_json", "parse_attribute_dump_json",
149
155
  "format_attribute_dump_text", "parse_attribute_dump_text", "parse_attribute_dump",
mobius/checksums.py ADDED
@@ -0,0 +1,119 @@
1
+ """Checksums a device reports about its own configuration, and how to
2
+ compute them. Reading a 2-byte checksum instead of the data it covers lets
3
+ a client skip re-reading data that hasn't changed.
4
+
5
+ | Attribute | ID | Covers |
6
+ |---|---|---|
7
+ | `SupportedAttributesCRC` | 14 | The supported attribute table (see supported_attributes_crc()) |
8
+ | `ConfiguredScenesChecksum` | 408 | ConfiguredScenes (400) |
9
+ | `Schedule1Checksum` | 501 | Schedule1 (500) |
10
+ | `Schedule2Checksum` | 504 | Schedule2 (503) |
11
+
12
+ All four are CRC16 with seed 0xFFFF (see the functions below for what each
13
+ covers), confirmed on a VorTech MP40 and a Radion XR15 G6. The app
14
+ computes SupportedAttributesCRC itself and compares it with the device's
15
+ value to decide whether to read the attribute table again; the other
16
+ three it doesn't read.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import struct
22
+ from dataclasses import dataclass, field
23
+ from typing import Iterable, Optional
24
+
25
+ from .crc import crc16
26
+ from .frame import AttributeValue, SupportedAttribute
27
+
28
+
29
+ def supported_attributes_crc(supported: Iterable[SupportedAttribute]) -> int:
30
+ """
31
+ The app's checksum of a supported attribute table, for comparison with
32
+ SupportedAttributesCRC (14): crc16 over, for each attribute in the
33
+ order the device lists them, the id (u16 LE), the number of indexes
34
+ (u8) and each index (u8). Counts and indexes above 255 are truncated
35
+ to their low byte, as the app does.
36
+ """
37
+ data = bytearray()
38
+ for attribute in supported:
39
+ data += struct.pack("<H", attribute.attr_id)
40
+ data.append(len(attribute.indexes) & 0xFF)
41
+ data.extend(index & 0xFF for index in attribute.indexes)
42
+ return crc16(bytes(data))
43
+
44
+
45
+ def configured_scenes_checksum(slots: Iterable[bytes]) -> int:
46
+ """ConfiguredScenesChecksum (408) of ConfiguredScenes (400) slots as
47
+ read: crc16 over the used slots (scene id != 0) in slot order."""
48
+ return crc16(b"".join(slot for slot in slots if len(slot) >= 2 and struct.unpack_from("<H", slot)[0] != 0))
49
+
50
+
51
+ def schedule_checksum(points: Iterable[bytes]) -> int:
52
+ """Schedule1Checksum (501) / Schedule2Checksum (504) of Schedule1 (500) /
53
+ Schedule2 (503) points as read: crc16 over the active points (flags
54
+ bit 0 set) in order. Schedule1Intensity/Schedule2Intensity are not
55
+ included."""
56
+ return crc16(b"".join(point for point in points if len(point) >= 3 and point[2] & 0x01))
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ConfigurationChecksums:
61
+ """The checksums a device reports; None when not supported or not
62
+ readable."""
63
+ supported_attributes: Optional[int] = None
64
+ configured_scenes: Optional[int] = None
65
+ schedule1: Optional[int] = None
66
+ schedule2: Optional[int] = None
67
+
68
+
69
+ @dataclass
70
+ class ConfigurationCache:
71
+ """
72
+ Data of one device kept between get_full_poll_batch(cache=...) calls
73
+ so a poll reads the 2-byte checksums instead of the data they cover.
74
+
75
+ entries: {data attribute id: (checksum, response blocks)} for
76
+ ConfiguredScenes and the polled schedule, each with the checksum the
77
+ device reported in the same request. A poll whose checksum differs
78
+ (or can't be read) reads the data again. The device's own value is
79
+ the key, so no local computation is involved, and a change by any
80
+ client, this one included, shows up as a changed checksum.
81
+ supported_attributes_crc: SupportedAttributesCRC from the last poll.
82
+ When it changes (a firmware update), the poll result says so
83
+ (FullPollResult.attribute_table_changed) and the static attributes
84
+ are read again on the next poll. `entries` stay valid: each is still
85
+ checked against its own checksum.
86
+ static / static_read_at / static_refresh_interval: attributes that
87
+ rarely change (identity, firmware and hardware, color channels,
88
+ calibration, advanced settings, pump flow range and battery settings;
89
+ STATIC_POLL_ATTRIBUTES in device.py), read at most every
90
+ `static_refresh_interval` seconds and kept here in between. None (the
91
+ default) reads them on every poll. A client that writes one of them
92
+ calls invalidate_static() so the next poll reads them again.
93
+
94
+ One per device.
95
+ """
96
+ entries: dict[int, tuple[int, list[AttributeValue]]] = field(default_factory=dict)
97
+ supported_attributes_crc: Optional[int] = None
98
+ static: dict[int, list[AttributeValue]] = field(default_factory=dict)
99
+ static_read_at: Optional[float] = None
100
+ static_refresh_interval: Optional[float] = None
101
+
102
+ def static_due(self, now: float) -> bool:
103
+ """Whether the static attributes are to be read by a poll at
104
+ `now` (time.monotonic())."""
105
+ return (
106
+ self.static_refresh_interval is None or self.static_read_at is None
107
+ or now - self.static_read_at >= self.static_refresh_interval
108
+ )
109
+
110
+ def invalidate_static(self) -> None:
111
+ """Makes the next poll read the static attributes."""
112
+ self.static.clear()
113
+ self.static_read_at = None
114
+
115
+ def clear(self) -> None:
116
+ """Forgets everything: the next poll reads all data."""
117
+ self.entries.clear()
118
+ self.supported_attributes_crc = None
119
+ self.invalidate_static()
mobius/cli.py CHANGED
@@ -11,11 +11,13 @@ from __future__ import annotations
11
11
 
12
12
  import argparse
13
13
  import asyncio
14
+ import contextlib
14
15
  import json
15
16
  import struct
16
17
  from typing import Optional
17
18
 
18
- from .device import MobiusDevice, min_schedule_capacity
19
+ from .checksums import configured_scenes_checksum, schedule_checksum, supported_attributes_crc
20
+ from .device import ALL_ELEMENTS, MobiusDevice, min_schedule_capacity
19
21
  from .relay import RelayedMobiusDevice
20
22
  from .device_status import MeshPeer
21
23
  from .mesh_address import (
@@ -29,6 +31,7 @@ from .constants import (
29
31
  from .discovery import (
30
32
  scan_for_mobius_devices_with_info, group_by_pan_id, dedupe_by_serial,
31
33
  find_device_by_serial, discover_mesh_peers_via_direct_connect, discover_tank,
34
+ find_bluetooth_only_tank_members,
32
35
  )
33
36
  from .schedule import (
34
37
  SchedulePoint, light_schedule_to_dict, light_schedule_from_dict,
@@ -232,19 +235,24 @@ async def _load_schedule_from_mob_file(device: MobiusDevice, support: str, path:
232
235
 
233
236
  async def _resolve_group_targets(
234
237
  gateway: MobiusDevice, target_serial: str, target_device, target_group_mask,
235
- support: str, debug_relay: bool,
238
+ support: str, debug_relay: bool, pan_id: Optional[int] = None,
239
+ stack: Optional[contextlib.AsyncExitStack] = None, args=None,
236
240
  ) -> list[tuple[str, object]]:
237
241
  """
238
242
  Every device a schedule write to `target_device` should reach: the
239
- target itself and, for a light with a group_mask, every other light on
240
- `gateway`'s mesh with exactly the same group_mask (reached through
241
- RelayedMobiusDevice(gateway, peer)). See
243
+ target itself and, for a light with a group_mask, every other light of
244
+ the tank with exactly the same group_mask. See
242
245
  documentation/06-light-schedule.md, "Schedule groups". A light without
243
246
  a group_mask, or a pump, only returns the target.
244
247
 
248
+ Lights on `gateway`'s mesh are reached through
249
+ RelayedMobiusDevice(gateway, peer); Bluetooth-only lights advertising
250
+ `pan_id` (find_bluetooth_only_tank_members()) through their own
251
+ connection, kept open on `stack` (both needed for them to be included).
252
+
245
253
  `gateway` is the directly connected device, also when `target_device`
246
- is itself relayed. Peers that can't be read through the relay are
247
- skipped with a warning.
254
+ is itself relayed. Devices that can't be read are skipped with a
255
+ warning.
248
256
  """
249
257
  targets = [(target_serial, target_device)]
250
258
  if support != "light" or target_group_mask is None:
@@ -253,10 +261,10 @@ async def _resolve_group_targets(
253
261
  try:
254
262
  tank = await discover_tank(gateway)
255
263
  except Exception as e:
256
- print(f" Could not discover tank mesh peers ({e}) -- writing only to {target_serial!r}.")
257
- return targets
264
+ print(f" Could not discover tank mesh peers ({e}) -- only Bluetooth-only lights will be added.")
265
+ tank = None
258
266
 
259
- for peer in tank.peers:
267
+ for peer in (tank.peers if tank is not None else []):
260
268
  if peer.serial == target_serial:
261
269
  continue
262
270
  relayed = RelayedMobiusDevice(gateway, peer, debug=debug_relay)
@@ -270,9 +278,51 @@ async def _resolve_group_targets(
270
278
  if peer_info.get("group_mask") == target_group_mask:
271
279
  targets.append((peer.serial, relayed))
272
280
 
281
+ if pan_id is not None and stack is not None:
282
+ targets += await _bluetooth_only_group_targets(
283
+ pan_id, target_group_mask, {serial for serial, _device in targets}, stack, args,
284
+ )
285
+ return targets
286
+
287
+
288
+ async def _bluetooth_only_group_targets(
289
+ pan_id: int, group_mask: int, exclude: set[str], stack: contextlib.AsyncExitStack, args,
290
+ ) -> list[tuple[str, MobiusDevice]]:
291
+ """Bluetooth-only lights of the tank with `group_mask`, each connected
292
+ directly and kept open on `stack`."""
293
+ targets = []
294
+ adapter = getattr(args, "adapter", None)
295
+ for ble_device, info in await find_bluetooth_only_tank_members(
296
+ pan_id, timeout=getattr(args, "timeout", 10.0), adapter=adapter,
297
+ ):
298
+ if info.serial in exclude:
299
+ continue
300
+ member = MobiusDevice(
301
+ ble_device, serial=info.serial, adapter=adapter,
302
+ connect_timeout=getattr(args, "connect_timeout", 30.0),
303
+ )
304
+ try:
305
+ await member.connect()
306
+ member_info = await member.get_device_info()
307
+ except Exception as e:
308
+ print(f" {info.serial!r} (Bluetooth-only): could not connect directly ({e}) -- skipped.")
309
+ await _disconnect_quietly(member)
310
+ continue
311
+ if member_info.get("primitive_type") == "VisualV1" and member_info.get("group_mask") == group_mask:
312
+ stack.push_async_callback(_disconnect_quietly, member)
313
+ targets.append((info.serial, member))
314
+ else:
315
+ await _disconnect_quietly(member)
273
316
  return targets
274
317
 
275
318
 
319
+ async def _disconnect_quietly(device: MobiusDevice) -> None:
320
+ try:
321
+ await device.disconnect()
322
+ except Exception:
323
+ pass
324
+
325
+
276
326
  async def _min_group_capacity(group_targets: list[tuple[str, MobiusDevice]], which: int) -> Optional[int]:
277
327
  """min_schedule_capacity() of the devices in `group_targets`."""
278
328
  return await min_schedule_capacity([device for _serial, device in group_targets], which)
@@ -300,6 +350,46 @@ async def _dump_scenes(device: MobiusDevice, primitive: PrimitiveType | None) ->
300
350
  f"timeout={scene.timeout}s: {payload}")
301
351
 
302
352
 
353
+ def _print_checksum(label: str, reported: Optional[int], computed: int, covering: str) -> None:
354
+ reported_text = f"0x{reported:04X}" if reported is not None else "not readable"
355
+ verdict = "match" if reported == computed else "MISMATCH"
356
+ print(f" {label}: reported {reported_text}, computed 0x{computed:04X} from {covering} -- {verdict}")
357
+
358
+
359
+ async def _check_checksums(target) -> None:
360
+ """
361
+ Reads the configuration checksums (see checksums.py) and the data each
362
+ covers, and prints whether the device's value matches the computation.
363
+ """
364
+ supported = await target.get_supported_attributes()
365
+ supported_ids = {a.attr_id for a in supported}
366
+ checksums = await target.get_configuration_checksums()
367
+
368
+ _print_checksum(
369
+ "SupportedAttributesCRC (14)", checksums.supported_attributes,
370
+ supported_attributes_crc(supported), f"the {len(supported)} supported attributes",
371
+ )
372
+ sections = (
373
+ ("ConfiguredScenesChecksum (408)", checksums.configured_scenes, C2Attribute.ConfiguredScenes),
374
+ ("Schedule1Checksum (501)", checksums.schedule1, C2Attribute.Schedule1),
375
+ ("Schedule2Checksum (504)", checksums.schedule2, C2Attribute.Schedule2),
376
+ )
377
+ for label, reported, data_attr in sections:
378
+ if data_attr not in supported_ids:
379
+ print(f" {label}: not supported by this device")
380
+ continue
381
+ values = await target.get_attribute_all(data_attr, 0, ALL_ELEMENTS)
382
+ if data_attr == C2Attribute.ConfiguredScenes:
383
+ computed = configured_scenes_checksum(values)
384
+ used = sum(1 for v in values if len(v) >= 2 and struct.unpack_from("<H", v)[0] != 0)
385
+ covering = f"the {used} used of {len(values)} slots"
386
+ else:
387
+ computed = schedule_checksum(values)
388
+ active = sum(1 for v in values if len(v) >= 3 and v[2] & 0x01)
389
+ covering = f"the {active} active of {len(values)} points"
390
+ _print_checksum(label, reported, computed, covering)
391
+
392
+
303
393
  async def _debug_mesh_discovery(device) -> list:
304
394
  """
305
395
  Step-by-step version of discover_mesh_peers_auto() that prints every
@@ -433,12 +523,9 @@ async def _print_summary(mdevice: MobiusDevice, args) -> dict:
433
523
  return summary
434
524
 
435
525
 
436
- async def _learn_peer_directly(args) -> Optional[MeshPeer]:
437
- """Connects briefly to --relay-target to read its own mesh address."""
438
- found_target = await find_device_by_serial(args.relay_target, timeout=args.timeout, adapter=args.adapter)
439
- if found_target is None:
440
- print(f" No device currently advertising serial {args.relay_target!r} found.")
441
- return None
526
+ async def _learn_peer_directly(args, found_target) -> Optional[MeshPeer]:
527
+ """Connects briefly to --relay-target (`found_target`, from
528
+ find_device_by_serial()) to read its own mesh address."""
442
529
  target_device, target_info = found_target
443
530
  target_address = None
444
531
  try:
@@ -460,7 +547,8 @@ async def _learn_peer_directly(args) -> Optional[MeshPeer]:
460
547
 
461
548
 
462
549
  async def _write_to_group(mdevice, args, target, label: str, serial: str, summary: dict,
463
- point_counts: dict, title: str, write) -> None:
550
+ point_counts: dict, title: str, write, pan_id: Optional[int] = None,
551
+ stack: Optional[contextlib.AsyncExitStack] = None) -> None:
464
552
  """
465
553
  Writes to `target` and every light sharing its schedule group
466
554
  (_resolve_group_targets()). `point_counts` maps a schedule slot to the
@@ -482,6 +570,7 @@ async def _write_to_group(mdevice, args, target, label: str, serial: str, summar
482
570
  print(f"\n=== resolving schedule-group targets for {label} ===")
483
571
  group_targets = await _resolve_group_targets(
484
572
  mdevice, serial, target, target_group_mask, target_support, args.debug_relay,
573
+ pan_id=pan_id, stack=stack, args=args,
485
574
  )
486
575
  if len(group_targets) > 1:
487
576
  print(f" Writing to {len(group_targets)} device(s) sharing this light's "
@@ -523,7 +612,8 @@ async def _run_by_serial(args) -> None:
523
612
  try:
524
613
  # Passing the BLEDevice just found avoids a second scan in connect().
525
614
  async with MobiusDevice(device=device, serial=args.by_serial, adapter=args.adapter,
526
- connect_timeout=args.connect_timeout) as mdevice:
615
+ connect_timeout=args.connect_timeout) as mdevice, \
616
+ contextlib.AsyncExitStack() as stack:
527
617
  summary = await _print_summary(mdevice, args)
528
618
 
529
619
  peers = []
@@ -533,13 +623,35 @@ async def _run_by_serial(args) -> None:
533
623
  peers = await _debug_mesh_discovery(mdevice)
534
624
  _print_mesh_peers_or_hint(peers)
535
625
 
626
+ relay_is_direct = False
536
627
  if args.relay_target:
537
628
  print(f"\n=== Relaying to {args.relay_target!r} through {args.by_serial!r} ===")
538
629
  target_peer = next((p for p in peers if p.serial == args.relay_target), None)
630
+ found_target = None
539
631
  if target_peer is None:
632
+ found_target = await find_device_by_serial(
633
+ args.relay_target, timeout=args.timeout, adapter=args.adapter,
634
+ )
635
+ if found_target is None:
636
+ print(f" No device currently advertising serial {args.relay_target!r} found.")
637
+ if found_target is not None and found_target[1] is not None and found_target[1].bluetooth_only:
638
+ print(f" {args.relay_target!r} is a Bluetooth-only device (not on the Thread "
639
+ f"mesh) -- connecting to it directly instead.")
640
+ try:
641
+ relayed = await stack.enter_async_context(MobiusDevice(
642
+ found_target[0], serial=args.relay_target, adapter=args.adapter,
643
+ connect_timeout=args.connect_timeout,
644
+ ))
645
+ relay_is_direct = True
646
+ for k, v in (await relayed.get_device_summary()).items():
647
+ print(f" {k}: {v}")
648
+ except Exception as e:
649
+ relayed = None
650
+ print(f" direct connection failed: {e}")
651
+ elif target_peer is None and found_target is not None:
540
652
  print(f" Not reported by {args.by_serial!r}'s mesh peer list -- trying a "
541
653
  f"brief direct connection to {args.relay_target!r} to learn its address...")
542
- target_peer = await _learn_peer_directly(args)
654
+ target_peer = await _learn_peer_directly(args, found_target)
543
655
  if target_peer is not None:
544
656
  relayed = RelayedMobiusDevice(mdevice, target_peer, debug=args.debug_relay)
545
657
  try:
@@ -561,6 +673,8 @@ async def _run_by_serial(args) -> None:
561
673
  print(f"\n=== {action} on {args.relay_target!r} (via relay) -- "
562
674
  f"SKIPPED: relay target could not be resolved above ===")
563
675
  return None
676
+ if relay_is_direct:
677
+ return f"{args.relay_target!r} (direct, Bluetooth-only)", relayed, args.relay_target
564
678
  return f"{args.relay_target!r} (via relay through {args.by_serial!r})", relayed, args.relay_target
565
679
 
566
680
  async def target_support(target, serial: str) -> str:
@@ -590,6 +704,14 @@ async def _run_by_serial(args) -> None:
590
704
  except Exception as e:
591
705
  print(f" set_time_to_now() FAILED: {e}")
592
706
 
707
+ if args.check_checksums and (picked := pick_target("checksum check")):
708
+ label, target, _serial = picked
709
+ print(f"\n=== Configuration checksums on {label} ===")
710
+ try:
711
+ await _check_checksums(target)
712
+ except Exception as e:
713
+ print(f" checksum check FAILED: {e}")
714
+
593
715
  if args.dump_attributes and (picked := pick_target("dump_attributes()")):
594
716
  label, target, _serial = picked
595
717
  print(f"\n=== dump_attributes() on {label} -- writing to {args.dump_attributes!r} ===")
@@ -624,6 +746,7 @@ async def _run_by_serial(args) -> None:
624
746
  lambda dev, support, member_serial: _load_schedule_from_file(
625
747
  dev, support, member_serial, args.load_schedule_file,
626
748
  ),
749
+ pan_id=info.pan_id if info else None, stack=stack,
627
750
  )
628
751
 
629
752
  if args.dump_mob_file and (picked := pick_target("mob dump")):
@@ -657,6 +780,7 @@ async def _run_by_serial(args) -> None:
657
780
  lambda dev, support, _member_serial: _load_schedule_from_mob_file(
658
781
  dev, support, args.load_mob_file, which=args.mob_which,
659
782
  ),
783
+ pan_id=info.pan_id if info else None, stack=stack,
660
784
  )
661
785
 
662
786
  requested_features = {}
@@ -885,6 +1009,11 @@ def main() -> None:
885
1009
  "(serial/model/short address/IPv6). Useful when neither "
886
1010
  "--dump-mesh-peers mechanism is supported. See "
887
1011
  "documentation/09-thread-coap-relay.md.")
1012
+ parser.add_argument("--check-checksums", action="store_true",
1013
+ help="With --by-serial: read SupportedAttributesCRC, "
1014
+ "ConfiguredScenesChecksum and Schedule1/2Checksum and the data "
1015
+ "each covers, and print whether each matches its computation "
1016
+ "(on --relay-target if given). Read-only.")
888
1017
  parser.add_argument("--dump-attributes", default=None, metavar="FILE",
889
1018
  help="Only usable with --by-serial. Discovers every attribute the "
890
1019
  "target device reports supporting -- the relay target if "
mobius/constants.py CHANGED
@@ -304,6 +304,25 @@ def manufacturer_for_model(model: Optional[Model]) -> Optional[str]:
304
304
  return MODEL_MANUFACTURER.get(model)
305
305
 
306
306
 
307
+ # Models that are not on the tank's Thread mesh: they share the tank's
308
+ # pan_id in their advertisement but have no mesh address, so they can't be
309
+ # reached through a gateway (see documentation/09-thread-coap-relay.md,
310
+ # "Bluetooth-only devices"). The app keeps the same list.
311
+ BLUETOOTH_ONLY_MODELS = frozenset({
312
+ Model.Blade, Model.BladeD, Model.BladeCoralGlow, Model.BladeCoralGrow, Model.BladeFreshwater,
313
+ Model.BladeRefugium, Model.BladeGrowPlus, Model.BladeGloFish, Model.MXM,
314
+ })
315
+
316
+
317
+ def is_bluetooth_only(model: "Model | int | None") -> bool:
318
+ """Whether `model` (a Model or its raw value) is reached only over its
319
+ own Bluetooth connection, never through a gateway. False for None or
320
+ an unknown value."""
321
+ if model is None:
322
+ return False
323
+ return enum_or_none(Model, int(model)) in BLUETOOTH_ONLY_MODELS
324
+
325
+
307
326
  class ErrorState(IntEnum):
308
327
  """Device error states (pump and light fault codes; dosing-pump-specific
309
328
  codes are not included).