python-mobius 0.3.0__py3-none-any.whl → 0.4.0__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 +9 -6
- mobius/cli.py +208 -40
- mobius/coap.py +24 -30
- mobius/constants.py +30 -11
- mobius/crc.py +3 -3
- mobius/device.py +176 -38
- mobius/device_status.py +56 -16
- mobius/discovery.py +61 -2
- mobius/frame.py +1 -1
- mobius/mesh_address.py +32 -12
- mobius/modifiers.py +11 -15
- mobius/power.py +2 -2
- mobius/pump_status.py +4 -4
- mobius/relay.py +16 -14
- mobius/schedule.py +51 -2
- {python_mobius-0.3.0.dist-info → python_mobius-0.4.0.dist-info}/METADATA +2 -2
- python_mobius-0.4.0.dist-info/RECORD +21 -0
- python_mobius-0.3.0.dist-info/RECORD +0 -21
- {python_mobius-0.3.0.dist-info → python_mobius-0.4.0.dist-info}/WHEEL +0 -0
- {python_mobius-0.3.0.dist-info → python_mobius-0.4.0.dist-info}/entry_points.txt +0 -0
- {python_mobius-0.3.0.dist-info → python_mobius-0.4.0.dist-info}/licenses/LICENSE +0 -0
mobius/__init__.py
CHANGED
|
@@ -43,15 +43,16 @@ from .modifiers import (
|
|
|
43
43
|
)
|
|
44
44
|
from .power import ChannelPowerInfo, channel_percent_value
|
|
45
45
|
from .pump_status import PumpFlowRange, BatteryBackupInfo, BoostedBatteryInfo
|
|
46
|
-
from .device_status import GroupInfo, CalibrationInfo, MaintenanceInfo, DeviceTimeInfo, MeshPeer
|
|
46
|
+
from .device_status import GroupInfo, CalibrationInfo, MaintenanceInfo, DeviceTimeInfo, MeshPeer, Tank
|
|
47
47
|
from .coap import (
|
|
48
48
|
CoapRequestType, CoapMethod, CoapResponseCode, CoapResponse,
|
|
49
49
|
encode_coap_request, decode_coap_response, COAP_OPCODE, COAP_INDICATION_OPCODE,
|
|
50
50
|
next_coap_token,
|
|
51
51
|
)
|
|
52
52
|
from .mesh_address import (
|
|
53
|
-
mesh_local_prefix_from_own_address,
|
|
54
|
-
build_rloc_address, extract_short_address,
|
|
53
|
+
mesh_local_prefix_from_own_address, mesh_local_prefix_from_mlprefix,
|
|
54
|
+
is_valid_mesh_address, build_rloc_address, extract_short_address,
|
|
55
|
+
is_short_address_derived,
|
|
55
56
|
)
|
|
56
57
|
from .relay import RelayedMobiusDevice
|
|
57
58
|
from .device import (
|
|
@@ -61,9 +62,10 @@ from .device import (
|
|
|
61
62
|
from .discovery import (
|
|
62
63
|
scan_for_mobius_devices, scan_for_mobius_devices_with_info, group_by_pan_id,
|
|
63
64
|
dedupe_by_serial, find_device_by_serial, discover_mesh_peers_via_direct_connect,
|
|
65
|
+
discover_tank,
|
|
64
66
|
)
|
|
65
67
|
|
|
66
|
-
__version__ = "0.
|
|
68
|
+
__version__ = "0.4.0"
|
|
67
69
|
|
|
68
70
|
__all__ = [
|
|
69
71
|
"__version__",
|
|
@@ -101,11 +103,11 @@ __all__ = [
|
|
|
101
103
|
# pump_status
|
|
102
104
|
"PumpFlowRange", "BatteryBackupInfo", "BoostedBatteryInfo",
|
|
103
105
|
# device_status
|
|
104
|
-
"GroupInfo", "CalibrationInfo", "MaintenanceInfo", "DeviceTimeInfo", "MeshPeer",
|
|
106
|
+
"GroupInfo", "CalibrationInfo", "MaintenanceInfo", "DeviceTimeInfo", "MeshPeer", "Tank",
|
|
105
107
|
"CoapRequestType", "CoapMethod", "CoapResponseCode", "CoapResponse",
|
|
106
108
|
"encode_coap_request", "decode_coap_response", "COAP_OPCODE", "COAP_INDICATION_OPCODE",
|
|
107
109
|
"next_coap_token",
|
|
108
|
-
"mesh_local_prefix_from_own_address", "is_valid_mesh_address",
|
|
110
|
+
"mesh_local_prefix_from_own_address", "mesh_local_prefix_from_mlprefix", "is_valid_mesh_address",
|
|
109
111
|
"build_rloc_address", "extract_short_address", "is_short_address_derived",
|
|
110
112
|
"RelayedMobiusDevice",
|
|
111
113
|
# device
|
|
@@ -114,4 +116,5 @@ __all__ = [
|
|
|
114
116
|
# discovery
|
|
115
117
|
"scan_for_mobius_devices", "scan_for_mobius_devices_with_info", "group_by_pan_id",
|
|
116
118
|
"dedupe_by_serial", "find_device_by_serial", "discover_mesh_peers_via_direct_connect",
|
|
119
|
+
"discover_tank",
|
|
117
120
|
]
|
mobius/cli.py
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Command-line entrypoint: `mobius-scan`.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
By default, scans for Mobius devices, groups them into tanks by pan_id
|
|
5
|
+
(read from BLE advertisements alone -- no connection needed), then
|
|
6
|
+
connects to ONE of them to dump real mesh addresses for its whole
|
|
7
|
+
network. `--scan-only` skips that connection step for a genuinely
|
|
8
|
+
connection-free run. Everything past that (full per-device summaries,
|
|
9
|
+
relay testing) is opt-in via flags -- see `mobius-scan --help`.
|
|
6
10
|
"""
|
|
7
11
|
|
|
8
12
|
from __future__ import annotations
|
|
@@ -16,7 +20,7 @@ from .device_status import MeshPeer
|
|
|
16
20
|
from .mesh_address import extract_short_address
|
|
17
21
|
from .discovery import (
|
|
18
22
|
scan_for_mobius_devices_with_info, group_by_pan_id, dedupe_by_serial,
|
|
19
|
-
find_device_by_serial, discover_mesh_peers_via_direct_connect,
|
|
23
|
+
find_device_by_serial, discover_mesh_peers_via_direct_connect, discover_tank,
|
|
20
24
|
)
|
|
21
25
|
from .schedule import SchedulePoint
|
|
22
26
|
|
|
@@ -79,20 +83,86 @@ async def _dump_schedule(device: MobiusDevice, support: str) -> None:
|
|
|
79
83
|
|
|
80
84
|
async def _debug_mesh_discovery(device) -> list:
|
|
81
85
|
"""
|
|
82
|
-
Verbose, step-by-step version of
|
|
83
|
-
debugging.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
Verbose, step-by-step version of discover_mesh_peers_auto() for CLI
|
|
87
|
+
debugging. Both discover_networked_thread_devices() and
|
|
88
|
+
discover_mesh_peers() deliberately fail soft to an empty list on ANY
|
|
89
|
+
error -- the right behavior for production use (e.g. ha-mobius
|
|
90
|
+
shouldn't crash because a device doesn't support this), but that
|
|
91
|
+
makes it useless for diagnosing WHY no peers were found. This
|
|
87
92
|
surfaces each step's raw result or exception instead, and returns
|
|
88
93
|
whatever peers it manages to construct (same shape as
|
|
89
|
-
|
|
90
|
-
peer list (e.g. --relay-target) don't need a second, separate
|
|
94
|
+
discover_mesh_peers_auto()'s return value) so callers needing the
|
|
95
|
+
actual peer list (e.g. --relay-target) don't need a second, separate
|
|
96
|
+
fetch.
|
|
97
|
+
|
|
98
|
+
Tries NetworkedThreadDevices/MLPrefix first (Step 0) -- the newer,
|
|
99
|
+
more generally-available mechanism, not gated to a Cowboy hub -- and
|
|
100
|
+
only falls through to the older, Cowboy-hub-specific
|
|
101
|
+
MeshLocalAddresses/three-parallel-arrays steps if that finds nothing,
|
|
102
|
+
matching discover_mesh_peers_auto()'s own priority order.
|
|
91
103
|
"""
|
|
92
104
|
from .constants import C2Attribute, Model
|
|
93
|
-
from .mesh_address import
|
|
105
|
+
from .mesh_address import (
|
|
106
|
+
is_valid_mesh_address, mesh_local_prefix_from_own_address,
|
|
107
|
+
mesh_local_prefix_from_mlprefix, build_rloc_address, extract_short_address,
|
|
108
|
+
)
|
|
94
109
|
import struct
|
|
95
110
|
|
|
111
|
+
print(" Step 0: MLPrefix (2128) + NetworkedThreadDevices (1001)...")
|
|
112
|
+
try:
|
|
113
|
+
mlprefix_raw = await device.get_attribute(C2Attribute.MLPrefix)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
print(f" MLPrefix FAILED: {e!r}")
|
|
116
|
+
mlprefix_raw = None
|
|
117
|
+
if mlprefix_raw and len(mlprefix_raw[0]) == 24:
|
|
118
|
+
prefix = mesh_local_prefix_from_mlprefix(mlprefix_raw[0])
|
|
119
|
+
print(f" MLPrefix OK. Derived mesh-local prefix: {prefix.hex()}")
|
|
120
|
+
try:
|
|
121
|
+
entries_raw = await device.get_attribute(
|
|
122
|
+
C2Attribute.NetworkedThreadDevices, index=0, count=0xFFFF,
|
|
123
|
+
)
|
|
124
|
+
except Exception as e:
|
|
125
|
+
print(f" NetworkedThreadDevices FAILED: {e!r}")
|
|
126
|
+
entries_raw = []
|
|
127
|
+
print(f" NetworkedThreadDevices returned {len(entries_raw)} element(s): "
|
|
128
|
+
f"{[e.hex() for e in entries_raw]}")
|
|
129
|
+
|
|
130
|
+
peers = []
|
|
131
|
+
for entry in entries_raw:
|
|
132
|
+
if len(entry) != 28:
|
|
133
|
+
print(f" Skipping malformed entry (expected 28 bytes, got "
|
|
134
|
+
f"{len(entry)}): {entry.hex()}")
|
|
135
|
+
continue
|
|
136
|
+
address = prefix + entry[0:8]
|
|
137
|
+
age = struct.unpack("<i", entry[8:12])[0]
|
|
138
|
+
model_raw = struct.unpack("<h", entry[12:14])[0]
|
|
139
|
+
try:
|
|
140
|
+
serial = entry[14:28].decode("ascii").rstrip("\x00")
|
|
141
|
+
except UnicodeDecodeError:
|
|
142
|
+
continue
|
|
143
|
+
if not serial or serial == "00000000000000":
|
|
144
|
+
continue
|
|
145
|
+
try:
|
|
146
|
+
model = Model(model_raw)
|
|
147
|
+
except ValueError:
|
|
148
|
+
model = None
|
|
149
|
+
peers.append(MeshPeer(
|
|
150
|
+
serial=serial, model_raw=model_raw, model=model,
|
|
151
|
+
short_address=extract_short_address(address), address=address, age=age,
|
|
152
|
+
))
|
|
153
|
+
if peers:
|
|
154
|
+
print(f" Parsed {len(peers)} peer(s) via NetworkedThreadDevices -- "
|
|
155
|
+
f"skipping the older Cowboy-hub-specific steps below.")
|
|
156
|
+
return peers
|
|
157
|
+
print(" No usable peers from NetworkedThreadDevices -- falling back to "
|
|
158
|
+
"the older, Cowboy-hub-specific mechanism below.")
|
|
159
|
+
elif mlprefix_raw is not None:
|
|
160
|
+
print(f" MLPrefix returned an unexpected length "
|
|
161
|
+
f"({len(mlprefix_raw[0])} bytes, expected 24) -- falling back to "
|
|
162
|
+
f"the older, Cowboy-hub-specific mechanism below.")
|
|
163
|
+
else:
|
|
164
|
+
print(" Falling back to the older, Cowboy-hub-specific mechanism below.")
|
|
165
|
+
|
|
96
166
|
print(" Step 1: MeshLocalAddresses (1005)...")
|
|
97
167
|
try:
|
|
98
168
|
own_address_raw = await device.get_attribute(C2Attribute.MeshLocalAddresses)
|
|
@@ -190,16 +260,20 @@ async def _run(args) -> None:
|
|
|
190
260
|
print()
|
|
191
261
|
for peer in peers:
|
|
192
262
|
model_name = peer.model.name if peer.model else f"unknown({peer.model_raw})"
|
|
263
|
+
age_suffix = f" age={peer.age}" if peer.age is not None else ""
|
|
193
264
|
print(f" {peer.serial} model={model_name} "
|
|
194
|
-
f"short_address={peer.short_address:#06x}
|
|
265
|
+
f"short_address={peer.short_address:#06x} "
|
|
266
|
+
f"address={peer.address.hex()}{age_suffix}")
|
|
195
267
|
|
|
196
268
|
if args.relay_target:
|
|
197
269
|
print(f"\n=== Relaying to {args.relay_target!r} through {args.by_serial!r} ===")
|
|
198
270
|
target_peer = next((p for p in peers if p.serial == args.relay_target), None)
|
|
199
271
|
if target_peer is None:
|
|
200
|
-
print(f" Not found via {args.by_serial!r}'s own peer-tracking
|
|
201
|
-
f"
|
|
202
|
-
f"
|
|
272
|
+
print(f" Not found via {args.by_serial!r}'s own peer-tracking "
|
|
273
|
+
f"(neither NetworkedThreadDevices nor the older Cowboy-hub-"
|
|
274
|
+
f"specific mechanism reported it) -- trying a brief direct "
|
|
275
|
+
f"connection to {args.relay_target!r} instead to learn its "
|
|
276
|
+
f"address...")
|
|
203
277
|
found_target = await find_device_by_serial(
|
|
204
278
|
args.relay_target, timeout=args.timeout, adapter=args.adapter,
|
|
205
279
|
)
|
|
@@ -254,8 +328,16 @@ async def _run(args) -> None:
|
|
|
254
328
|
# documentation/12-device-identity-and-address-stability.md.
|
|
255
329
|
found = dedupe_by_serial(found)
|
|
256
330
|
|
|
257
|
-
|
|
258
|
-
|
|
331
|
+
# pan_id is read straight from each device's advertisement -- no
|
|
332
|
+
# connection needed at all -- and is the fast, local approximation of
|
|
333
|
+
# "which tank is this device part of" (the same approximation
|
|
334
|
+
# discover_tank()'s own docstring documents for its own,
|
|
335
|
+
# connection-based, richer mechanism: the app's own Tank grouping is
|
|
336
|
+
# account-level and cloud-synced, with no local/BLE equivalent to
|
|
337
|
+
# read directly).
|
|
338
|
+
groups = group_by_pan_id(found)
|
|
339
|
+
print("=== Tanks (grouped by pan_id from advertisements, no connection needed) ===")
|
|
340
|
+
for pan_id, members in groups.items():
|
|
259
341
|
label = f"{pan_id:#06x}" if pan_id is not None else "unknown"
|
|
260
342
|
print(f" pan_id {label}:")
|
|
261
343
|
for device, info in members:
|
|
@@ -268,6 +350,72 @@ async def _run(args) -> None:
|
|
|
268
350
|
if args.scan_only:
|
|
269
351
|
return
|
|
270
352
|
|
|
353
|
+
# Part of the actual default now, not behind a flag -- connects to
|
|
354
|
+
# ONE device (any one with a parsed serial; which one doesn't
|
|
355
|
+
# matter, since the point is just "ask whatever's reachable about
|
|
356
|
+
# its own network") and runs the same verbose discovery
|
|
357
|
+
# --dump-mesh-peers uses, so a plain `mobius-scan` with zero flags
|
|
358
|
+
# still shows real mesh addresses, not just the pan_id/serial/model
|
|
359
|
+
# the advertisement alone carries. --scan-only skips this (and
|
|
360
|
+
# everything after) if a genuinely connection-free run is wanted.
|
|
361
|
+
connectable = [(d, i) for d, i in found if i and i.serial]
|
|
362
|
+
if not connectable:
|
|
363
|
+
print("\n=== Network addresses ===\n No device with a parsed serial to connect through.")
|
|
364
|
+
else:
|
|
365
|
+
device, info = connectable[0]
|
|
366
|
+
print(f"\n=== Network addresses (connecting to {info.serial} to ask) ===")
|
|
367
|
+
try:
|
|
368
|
+
async with MobiusDevice(serial=info.serial, adapter=args.adapter,
|
|
369
|
+
connect_timeout=args.connect_timeout) as mdevice:
|
|
370
|
+
peers = await _debug_mesh_discovery(mdevice)
|
|
371
|
+
except Exception as e:
|
|
372
|
+
print(f" failed to connect: {e}")
|
|
373
|
+
peers = []
|
|
374
|
+
if not peers:
|
|
375
|
+
print(" No peers found -- see the step-by-step output above for exactly "
|
|
376
|
+
"which stage this failed at, rather than just that it failed. See "
|
|
377
|
+
"documentation/09-thread-coap-relay.md.")
|
|
378
|
+
else:
|
|
379
|
+
print()
|
|
380
|
+
for peer in peers:
|
|
381
|
+
model_name = peer.model.name if peer.model else f"unknown({peer.model_raw})"
|
|
382
|
+
age_suffix = f" age={peer.age}" if peer.age is not None else ""
|
|
383
|
+
print(f" {peer.serial} model={model_name} "
|
|
384
|
+
f"short_address={peer.short_address:#06x} "
|
|
385
|
+
f"address={peer.address.hex()}{age_suffix}")
|
|
386
|
+
|
|
387
|
+
if not (args.dump_tanks or args.full_summaries or args.build_peer_map):
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
if args.dump_tanks:
|
|
391
|
+
print("\n=== Tank details (connects to one device per pan_id group) ===")
|
|
392
|
+
for pan_id, members in groups.items():
|
|
393
|
+
label = f"{pan_id:#06x}" if pan_id is not None else "unknown"
|
|
394
|
+
connectable = [(d, i) for d, i in members if i and i.serial]
|
|
395
|
+
if not connectable:
|
|
396
|
+
print(f" pan_id {label}: no device with a parsed serial to connect through -- skipped")
|
|
397
|
+
continue
|
|
398
|
+
device, info = connectable[0]
|
|
399
|
+
print(f" pan_id {label} (connecting through {info.serial})...")
|
|
400
|
+
try:
|
|
401
|
+
async with MobiusDevice(serial=info.serial, adapter=args.adapter,
|
|
402
|
+
connect_timeout=args.connect_timeout) as mdevice:
|
|
403
|
+
tank = await discover_tank(mdevice)
|
|
404
|
+
except Exception as e:
|
|
405
|
+
print(f" failed to connect/discover: {e}")
|
|
406
|
+
continue
|
|
407
|
+
if tank.prefix is None:
|
|
408
|
+
print(f" {info.serial} didn't report a valid mesh address -- "
|
|
409
|
+
f"can't map this tank's devices/addresses this way.")
|
|
410
|
+
continue
|
|
411
|
+
print(f" prefix={tank.prefix.hex()}")
|
|
412
|
+
for peer in tank.peers:
|
|
413
|
+
model_name = peer.model.name if peer.model else f"unknown({peer.model_raw})"
|
|
414
|
+
age_suffix = f" age={peer.age}" if peer.age is not None else ""
|
|
415
|
+
print(f" {peer.serial} model={model_name} "
|
|
416
|
+
f"short_address={peer.short_address:#06x} "
|
|
417
|
+
f"address={peer.address.hex()}{age_suffix}")
|
|
418
|
+
|
|
271
419
|
if args.build_peer_map:
|
|
272
420
|
print("\n=== Building mesh peer map via direct connect (no Cowboy hub needed) ===")
|
|
273
421
|
peers = await discover_mesh_peers_via_direct_connect(
|
|
@@ -282,6 +430,8 @@ async def _run(args) -> None:
|
|
|
282
430
|
model_name = peer.model.name if peer.model else f"unknown({peer.model_raw})"
|
|
283
431
|
print(f" {peer.serial} model={model_name} "
|
|
284
432
|
f"short_address={peer.short_address:#06x} address={peer.address.hex()}")
|
|
433
|
+
|
|
434
|
+
if not args.full_summaries:
|
|
285
435
|
return
|
|
286
436
|
|
|
287
437
|
print("\n=== Full device summaries (connects to each) ===")
|
|
@@ -333,7 +483,21 @@ def main() -> None:
|
|
|
333
483
|
parser.add_argument("--connect-timeout", type=float, default=30.0,
|
|
334
484
|
help="Per-device connect timeout in seconds (default: 30.0)")
|
|
335
485
|
parser.add_argument("--scan-only", action="store_true",
|
|
336
|
-
help="
|
|
486
|
+
help="Stops after the pan_id listing -- skips the network-address dump "
|
|
487
|
+
"(which connects to one device) that otherwise runs by default, "
|
|
488
|
+
"and everything else. The only genuinely connection-free mode.")
|
|
489
|
+
parser.add_argument("--dump-tanks", action="store_true",
|
|
490
|
+
help="Like the network-address dump that already runs by default, but "
|
|
491
|
+
"for EVERY pan_id group found, not just one -- useful if multiple "
|
|
492
|
+
"genuinely separate tanks/networks are in range at once. Connects "
|
|
493
|
+
"to one device per group and calls discover_tank() on each, "
|
|
494
|
+
"printing the full mesh-address/age for every device in that tank. "
|
|
495
|
+
"Skips any pan_id group with no device carrying a parsed serial to "
|
|
496
|
+
"connect through.")
|
|
497
|
+
parser.add_argument("--full-summaries", action="store_true",
|
|
498
|
+
help="Connects to EVERY scanned device in turn and prints a full "
|
|
499
|
+
"get_device_summary() for each -- the most thorough option, and "
|
|
500
|
+
"the slowest for anything beyond a couple of devices.")
|
|
337
501
|
parser.add_argument("--by-serial", default=None, metavar="SERIAL",
|
|
338
502
|
help="Skip the normal scan/group flow and instead resolve + connect "
|
|
339
503
|
"to whichever device is CURRENTLY advertising this serial number, "
|
|
@@ -348,25 +512,28 @@ def main() -> None:
|
|
|
348
512
|
"app's own displayed value.")
|
|
349
513
|
parser.add_argument("--dump-mesh-peers", action="store_true",
|
|
350
514
|
help="Only usable with --by-serial. After connecting, calls "
|
|
351
|
-
"
|
|
352
|
-
"knows about over the Thread mesh (serial/model/short
|
|
353
|
-
"IPv6) --
|
|
354
|
-
"
|
|
355
|
-
"
|
|
515
|
+
"discover_mesh_peers_auto() on it and prints every other device "
|
|
516
|
+
"it knows about over the Thread mesh (serial/model/short "
|
|
517
|
+
"address/IPv6/age) -- tries the newer NetworkedThreadDevices "
|
|
518
|
+
"mechanism first (not gated to a Cowboy hub), falling back to "
|
|
519
|
+
"the older MeshLocalAddresses/three-arrays mechanism (Cowboy-hub-"
|
|
520
|
+
"specific) only if that finds nothing. An empty result from both "
|
|
521
|
+
"is itself informative (this device may not support Thread mesh "
|
|
522
|
+
"at all, or isn't currently part of an active one). See "
|
|
523
|
+
"documentation/09-thread-coap-relay.md.")
|
|
356
524
|
parser.add_argument("--relay-target", default=None, metavar="SERIAL",
|
|
357
525
|
help="Only usable with --by-serial (the device you connect to becomes "
|
|
358
526
|
"the relay gateway). Tries to find this serial among the "
|
|
359
|
-
"gateway's own reported mesh peers first (
|
|
360
|
-
"
|
|
361
|
-
"
|
|
362
|
-
"
|
|
363
|
-
"
|
|
364
|
-
"
|
|
365
|
-
"
|
|
366
|
-
"
|
|
367
|
-
"
|
|
368
|
-
"
|
|
369
|
-
"needs against real hardware.")
|
|
527
|
+
"gateway's own reported mesh peers first (via "
|
|
528
|
+
"discover_mesh_peers_auto() -- NetworkedThreadDevices first, then "
|
|
529
|
+
"the older Cowboy-hub-specific mechanism); if not found either "
|
|
530
|
+
"way, falls back to a brief separate direct connection to the "
|
|
531
|
+
"target itself to learn its address. Either way, once an address "
|
|
532
|
+
"is known, relays a full get_device_summary() read to it via CoAP "
|
|
533
|
+
"through the gateway and prints the result -- run a separate "
|
|
534
|
+
"direct `--by-serial <this serial>` command to confirm the two "
|
|
535
|
+
"match. This is the actual validation this whole relay "
|
|
536
|
+
"implementation needs against real hardware.")
|
|
370
537
|
parser.add_argument("--debug-relay", action="store_true",
|
|
371
538
|
help="Only usable with --relay-target. Prints raw bytes at every "
|
|
372
539
|
"layer of each relayed request/response (outer frame, CoAP "
|
|
@@ -378,12 +545,13 @@ def main() -> None:
|
|
|
378
545
|
parser.add_argument("--build-peer-map", action="store_true",
|
|
379
546
|
help="Instead of full device summaries, scan for devices then "
|
|
380
547
|
"briefly connect to EACH one in turn to build a mesh peer map "
|
|
381
|
-
"(serial/model/short address/IPv6) -- the
|
|
382
|
-
"--dump-mesh-peers
|
|
383
|
-
"
|
|
384
|
-
"
|
|
385
|
-
"learn every device's mesh address
|
|
386
|
-
"
|
|
548
|
+
"(serial/model/short address/IPv6) -- the last-resort fallback "
|
|
549
|
+
"when --dump-mesh-peers' two attribute-based mechanisms both come "
|
|
550
|
+
"back empty (neither NetworkedThreadDevices nor the older "
|
|
551
|
+
"three-array mechanism supported/populated on this hardware). "
|
|
552
|
+
"This is the practical way to learn every device's mesh address "
|
|
553
|
+
"once in that case, so you can later keep only one connection "
|
|
554
|
+
"open and relay to the rest. See "
|
|
387
555
|
"documentation/09-thread-coap-relay.md.")
|
|
388
556
|
args = parser.parse_args()
|
|
389
557
|
asyncio.run(_run(args))
|
mobius/coap.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"""
|
|
2
|
-
CoAP (RFC 7252) protocol primitives,
|
|
3
|
-
|
|
4
|
-
com.c2.comm.responses.coap.CoapResponse in the decompiled app.
|
|
2
|
+
CoAP (RFC 7252) protocol primitives, confirmed via reverse engineering the
|
|
3
|
+
app's own CoAP model.
|
|
5
4
|
|
|
6
5
|
## Why this exists
|
|
7
6
|
|
|
@@ -9,17 +8,14 @@ The official app doesn't maintain a direct BLE connection to every device
|
|
|
9
8
|
in a tank. When it needs to talk to a device it isn't directly connected
|
|
10
9
|
to, it wraps the request in CoAP and sends it through whichever device it
|
|
11
10
|
*is* connected to (the "gateway"), which forwards it over the underlying
|
|
12
|
-
Thread mesh network and relays the response back. Confirmed directly
|
|
13
|
-
the
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
} else {
|
|
21
|
-
// no known address for the target -- fail
|
|
22
|
-
}
|
|
11
|
+
Thread mesh network and relays the response back. Confirmed directly via
|
|
12
|
+
reverse engineering the app's own dispatch logic, not inferred: if the
|
|
13
|
+
target device is the one already directly connected over BLE, the app
|
|
14
|
+
sends the request as-is over that existing connection; otherwise, if a
|
|
15
|
+
mesh IPv6 address is known for the target, it wraps the request in CoAP,
|
|
16
|
+
addresses it to the target's mesh IPv6, and sends it through the
|
|
17
|
+
connected gateway device; if neither applies (no known address for the
|
|
18
|
+
target), the request fails.
|
|
23
19
|
|
|
24
20
|
## Wire format
|
|
25
21
|
|
|
@@ -54,16 +50,13 @@ encode_set_attribute() / decode_attribute_response(), same as a direct
|
|
|
54
50
|
|
|
55
51
|
## Confidence notes on enum values
|
|
56
52
|
|
|
57
|
-
Some `Coap.ResponseCode` values
|
|
58
|
-
|
|
59
|
-
Android/Google library field names
|
|
60
|
-
TYPE_SIZE_PERCENT`) rather than plain literals. Where a value was already
|
|
53
|
+
Some `Coap.ResponseCode` values are symbolic substitutions surfaced during
|
|
54
|
+
reverse engineering (obfuscated numeric constants resolved back to
|
|
55
|
+
unrelated Android/Google library field names) rather than plain literals. Where a value was already
|
|
61
56
|
independently confirmed elsewhere in this project (for a completely
|
|
62
|
-
different attribute, but the same underlying
|
|
63
|
-
confirmed value is used here too -- `InternalServerError` (500
|
|
64
|
-
|
|
65
|
-
(502/512, via `TypedValues.PositionType.TYPE_DRAWPATH`/`TYPE_SIZE_PERCENT`).
|
|
66
|
-
Note `ProxyingNotSupported=512` does NOT fit the otherwise-clean RFC 7252
|
|
57
|
+
different attribute, but the same underlying constant), that
|
|
58
|
+
confirmed value is used here too -- `InternalServerError` (500) and `BadGateway`/`ProxyingNotSupported`
|
|
59
|
+
(502/512, via the same symbolic-substitution technique). Note `ProxyingNotSupported=512` does NOT fit the otherwise-clean RFC 7252
|
|
67
60
|
`class*100+detail` pattern every other value in this enum follows (5.05
|
|
68
61
|
would predict 505) -- confirmed via cross-reference, not just pattern
|
|
69
62
|
completion, but flagged as numerically surprising and worth treating with
|
|
@@ -96,20 +89,21 @@ from typing import Optional
|
|
|
96
89
|
# alongside the existing Get(0x17)/Set(0x18) attribute opcodes.
|
|
97
90
|
COAP_OPCODE = 25
|
|
98
91
|
|
|
99
|
-
# Confirmed
|
|
100
|
-
# couldn't
|
|
92
|
+
# Confirmed via reverse engineering the app's own indication-handling
|
|
93
|
+
# logic (a method the decompiler couldn't resolve to readable Java -- traced
|
|
94
|
+
# from the raw bytecode instead): the
|
|
101
95
|
# REAL CoAP relay response arrives as a completely separate message on
|
|
102
96
|
# THIS opcode, not COAP_OPCODE (25). Opcode 25's confirm is a bare 1-byte
|
|
103
97
|
# ack the app itself explicitly discards (see mobius.relay's module
|
|
104
98
|
# docstring for the full trace) -- opcode 26 is a genuinely distinct
|
|
105
99
|
# "indication," and matching it back to the request that triggered it is
|
|
106
|
-
# done purely by CoAP token (
|
|
100
|
+
# done purely by CoAP token (confirmed via reverse engineering:
|
|
107
101
|
# coapRequest.getToken() == coapResponse.getToken()), never by the outer
|
|
108
102
|
# FSCI message ID.
|
|
109
103
|
COAP_INDICATION_OPCODE = 26
|
|
110
104
|
|
|
111
105
|
|
|
112
|
-
# Matches
|
|
106
|
+
# Matches the app's own confirmed starting value in the decompile
|
|
113
107
|
# (a simple static int starting at 100) -- the exact starting value
|
|
114
108
|
# doesn't matter functionally (any value unique per outstanding request
|
|
115
109
|
# works), matched here purely for closest fidelity to the real app.
|
|
@@ -165,9 +159,9 @@ class CoapResponseCode(IntEnum):
|
|
|
165
159
|
PreconditionFailed = 412 # literal
|
|
166
160
|
RequestEntityTooLarge = 413 # literal
|
|
167
161
|
UnsupportedContentFormat = 415 # literal
|
|
168
|
-
InternalServerError = 500 # CONFIRMED (cross-referenced via
|
|
169
|
-
BadGateway = 502 # CONFIRMED (cross-referenced via
|
|
170
|
-
ProxyingNotSupported = 512 # CONFIRMED (cross-referenced via
|
|
162
|
+
InternalServerError = 500 # CONFIRMED (cross-referenced via symbolic-substitution technique)
|
|
163
|
+
BadGateway = 502 # CONFIRMED (cross-referenced via symbolic-substitution technique)
|
|
164
|
+
ProxyingNotSupported = 512 # CONFIRMED (cross-referenced via symbolic-substitution technique) -- numerically surprising, see module docstring
|
|
171
165
|
|
|
172
166
|
|
|
173
167
|
@dataclass
|
mobius/constants.py
CHANGED
|
@@ -61,7 +61,8 @@ PUMP_PRIMITIVES_EXPERIMENTAL = {PrimitiveType.CoffeeV1}
|
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
class Model(IntEnum):
|
|
64
|
-
"""Device model identifiers. Confirmed literal values
|
|
64
|
+
"""Device model identifiers. Confirmed literal values via reverse
|
|
65
|
+
engineering the app's own model-identifier enum
|
|
65
66
|
(a curated subset -- the full enum covers ~100 models across EcoTech
|
|
66
67
|
Marine, AquaIllumination, Neptune Systems, NYOS, and Hera product lines;
|
|
67
68
|
add entries here as needed)."""
|
|
@@ -108,7 +109,8 @@ class Model(IntEnum):
|
|
|
108
109
|
Coffee2 = 397 # "Quantum 220/300" (NYOS)
|
|
109
110
|
|
|
110
111
|
|
|
111
|
-
#
|
|
112
|
+
# Confirmed via reverse engineering the app's own manufacturer-lookup logic.
|
|
113
|
+
# The decompiled version branches on
|
|
112
114
|
# enum *ordinal* against a hardcoded position list (the full ~100-entry
|
|
113
115
|
# Model enum's index 1-37 range, plus a few extras, all map to "EcoTech
|
|
114
116
|
# Marine"); since this library only implements a curated subset of Model,
|
|
@@ -210,11 +212,12 @@ class ErrorState(IntEnum):
|
|
|
210
212
|
class C2Attribute(IntEnum):
|
|
211
213
|
"""Attribute IDs used with Get/Set requests. Every value here is a
|
|
212
214
|
directly-confirmed literal (either a plain literal in the decompile, or
|
|
213
|
-
resolved through a
|
|
215
|
+
resolved through a symbolic-constant substitution surfaced during
|
|
216
|
+
reverse engineering back to the real
|
|
214
217
|
integer -- see documentation/03-attributes-and-opcodes.md), EXCEPT
|
|
215
|
-
Schedule1 which required tracing through
|
|
216
|
-
|
|
217
|
-
via
|
|
218
|
+
Schedule1 which required tracing through an extra hop via an unrelated
|
|
219
|
+
library constant to resolve (also fully confirmed, just
|
|
220
|
+
via that extra hop)."""
|
|
218
221
|
AttributeTableVersion = 0
|
|
219
222
|
FirmwareVersion = 1
|
|
220
223
|
HardwareRevision = 2
|
|
@@ -275,19 +278,31 @@ class C2Attribute(IntEnum):
|
|
|
275
278
|
RTCTime = 219
|
|
276
279
|
# Thread mesh peer discovery -- see documentation/09-thread-coap-relay.md
|
|
277
280
|
# for the full protocol trace. MeshLocalAddresses confirmed via
|
|
278
|
-
#
|
|
279
|
-
#
|
|
281
|
+
# reverse engineering the app's own connection-setup logic; the three
|
|
282
|
+
# parallel arrays confirmed via reverse engineering the app's own
|
|
283
|
+
# Cowboy-hub network-setup logic (index-matched: serialArray[i]/
|
|
280
284
|
# modelArray[i]/shortArray[i] all refer to the same peer).
|
|
281
285
|
MeshLocalAddresses = 1005
|
|
282
286
|
ShortAddressArray = 3700
|
|
283
287
|
SerialNumberArray = 3701
|
|
284
288
|
DeviceModelArray = 3702
|
|
285
289
|
# A fourth parallel array giving each peer's real BLE MAC address --
|
|
286
|
-
# confirmed present (
|
|
290
|
+
# confirmed present (the app's own Cowboy-hub network-setup logic references it
|
|
287
291
|
# alongside the three above) but not used by discover_mesh_peers()
|
|
288
292
|
# (not needed for CoAP relay, which addresses peers by Thread IPv6,
|
|
289
293
|
# not BLE address). Included for completeness/future use.
|
|
290
294
|
BladeBleAddressArray = 3714
|
|
295
|
+
# A second, better mesh-peer-discovery mechanism -- confirmed via
|
|
296
|
+
# reverse engineering the app's own tank-setup logic, NOT gated to a
|
|
297
|
+
# Cowboy hub (checked via a generic isSupported() call, unlike the
|
|
298
|
+
# three arrays above). NetworkedThreadDevices is a "get all elements"
|
|
299
|
+
# list of peer entries, each one containing that peer's model, serial,
|
|
300
|
+
# mesh address, and an "age" value in a single fetch -- see
|
|
301
|
+
# discover_networked_thread_devices()'s docstring for the confirmed
|
|
302
|
+
# byte layout of each entry, and mesh_local_prefix_from_mlprefix()
|
|
303
|
+
# for why MLPrefix's own 24-byte response only uses its first 8 bytes.
|
|
304
|
+
NetworkedThreadDevices = 1001
|
|
305
|
+
MLPrefix = 2128
|
|
291
306
|
InsolationEnabled = 912
|
|
292
307
|
MaxPower = 1504
|
|
293
308
|
Ramp = 1505
|
|
@@ -318,7 +333,8 @@ class PhysicalValueID(IntEnum):
|
|
|
318
333
|
|
|
319
334
|
|
|
320
335
|
class VisualID(IntEnum):
|
|
321
|
-
"""Light channel identifiers. Confirmed literal values
|
|
336
|
+
"""Light channel identifiers. Confirmed literal values via reverse
|
|
337
|
+
engineering the app's own channel-identifier enum."""
|
|
322
338
|
Unknown = 0
|
|
323
339
|
Brightness = 1
|
|
324
340
|
CoolWhite = 16
|
|
@@ -736,5 +752,8 @@ PUMP_MODE_PARAMS: dict[PumpMode, list[PumpParam]] = {
|
|
|
736
752
|
}
|
|
737
753
|
|
|
738
754
|
PUMP_TIME_OFFSET_PARAMS = {PumpParam.Time, PumpParam.StartTime, PumpParam.EndTime}
|
|
739
|
-
# Confirmed:
|
|
755
|
+
# Confirmed via reverse engineering: resolved through the same
|
|
756
|
+
# symbolic-substitution artifact documented in
|
|
757
|
+
# documentation/03-attributes-and-opcodes.md, back to a real, unrelated
|
|
758
|
+
# library constant equal to 250.
|
|
740
759
|
PUMP_TIME_OFFSET = 250
|
mobius/crc.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""
|
|
2
2
|
CRC16 (table-driven, seed 0xFFFF) used on every Mobius wire frame.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
Confirmed via reverse engineering the app's own CRC implementation. The table below
|
|
5
|
+
uses the original signed-short constants converted to unsigned 16-bit for
|
|
6
6
|
Python arithmetic. Verified byte-for-byte against real captured packets --
|
|
7
7
|
see documentation/02-framing-and-crc.md.
|
|
8
8
|
"""
|
|
@@ -41,7 +41,7 @@ CRC16_TABLE = [v & 0xFFFF for v in _CRC16_TABLE_SIGNED]
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def crc16(data: bytes, seed: int = 0xFFFF) -> int:
|
|
44
|
-
"""CRC16 over `data`,
|
|
44
|
+
"""CRC16 over `data`, confirmed via reverse engineering the app's own CRC implementation."""
|
|
45
45
|
crc = seed & 0xFFFF
|
|
46
46
|
for b in data:
|
|
47
47
|
idx = (b ^ (crc >> 8)) & 0xFF
|