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 +13 -7
- mobius/checksums.py +119 -0
- mobius/cli.py +148 -19
- mobius/constants.py +19 -0
- mobius/device.py +311 -41
- mobius/device_status.py +5 -1
- mobius/discovery.py +19 -1
- mobius/manufacturer.py +6 -1
- mobius/relay.py +50 -38
- mobius/scenes.py +5 -1
- mobius/timezone.py +63 -0
- {python_mobius-0.9.0.dist-info → python_mobius-0.10.1.dist-info}/METADATA +1 -1
- python_mobius-0.10.1.dist-info/RECORD +26 -0
- python_mobius-0.9.0.dist-info/RECORD +0 -24
- {python_mobius-0.9.0.dist-info → python_mobius-0.10.1.dist-info}/WHEEL +0 -0
- {python_mobius-0.9.0.dist-info → python_mobius-0.10.1.dist-info}/entry_points.txt +0 -0
- {python_mobius-0.9.0.dist-info → python_mobius-0.10.1.dist-info}/licenses/LICENSE +0 -0
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.
|
|
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 .
|
|
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
|
|
240
|
-
|
|
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.
|
|
247
|
-
|
|
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}) --
|
|
257
|
-
|
|
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
|
|
438
|
-
|
|
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
|
|
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).
|