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 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, is_valid_mesh_address,
54
- build_rloc_address, extract_short_address, is_short_address_derived,
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.3.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
- Scans for Mobius devices, groups them by pan_id (no connection required),
5
- then connects to each and prints a full device summary.
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 discover_mesh_peers() for CLI
83
- debugging. discover_mesh_peers() deliberately fails soft to an empty
84
- list on ANY error -- the right behavior for production use (e.g.
85
- ha-mobius shouldn't crash because a device doesn't support this), but
86
- that makes it useless for diagnosing WHY no peers were found. This
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
- discover_mesh_peers()'s return value) so callers needing the actual
90
- peer list (e.g. --relay-target) don't need a second, separate fetch.
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 is_valid_mesh_address, mesh_local_prefix_from_own_address, build_rloc_address
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} address={peer.address.hex()}")
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 (expected "
201
- f"without a Cowboy hub) -- trying a brief direct connection to "
202
- f"{args.relay_target!r} instead to learn its address...")
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
- print("=== Pan ID groups (from advertisements, no connection needed) ===")
258
- for pan_id, members in group_by_pan_id(found).items():
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="Only scan/group by pan_id; don't connect to any device.")
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
- "discover_mesh_peers() on it and prints every other device it "
352
- "knows about over the Thread mesh (serial/model/short address/"
353
- "IPv6) -- an empty result here is itself informative (this device "
354
- "may not support Thread mesh at all, or isn't currently part of "
355
- "an active one). See documentation/09-thread-coap-relay.md.")
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 (requires a Cowboy hub); "
360
- "if not found there, falls back to a brief separate direct "
361
- "connection to the target itself to learn its address (no "
362
- "Cowboy hub needed for this path -- confirmed via real hardware "
363
- "testing to be necessary, since the peer-tracking attributes "
364
- "are Cowboy-hub-specific). Either way, once an address is known, "
365
- "relays a full get_device_summary() read to it via CoAP through "
366
- "the gateway and prints the result -- run a separate direct "
367
- "`--by-serial <this serial>` command to confirm the two match. "
368
- "This is the actual validation this whole relay implementation "
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 alternative to "
382
- "--dump-mesh-peers when there's no dedicated Cowboy hub device "
383
- "(confirmed via real hardware testing to be required for that "
384
- "attribute-array-based approach). This is the practical way to "
385
- "learn every device's mesh address once, so you can later keep "
386
- "only one connection open and relay to the rest. See "
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, ported from com.c2.comm.Coap and
3
- com.c2.comm.requests.coap.{CoapRequest,CoapFsciRequest} /
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 in
13
- the decompiled source (`Comm.java`), not inferred:
14
-
15
- if (commDevice.equals(request.getTarget())) {
16
- // direct: send over the existing BLE connection as-is
17
- } else if (iPv6 != null) {
18
- // relay: wrap in CoAP, address it to the target's mesh IPv6,
19
- // send through commDevice (the connected gateway)
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 in the decompile are JADX symbolic
58
- substitutions (obfuscated numeric constants resolved back to unrelated
59
- Android/Google library field names, e.g. `TypedValues.PositionType.
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 Java constant), that
63
- confirmed value is used here too -- `InternalServerError` (500, via
64
- `ServiceStarter.ERROR_UNKNOWN`) and `BadGateway`/`ProxyingNotSupported`
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 from BaseConnection.smali's handleIndication() (a method JADX
100
- # couldn't decompile to Java -- traced from raw bytecode instead): the
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 (lambda$getSentRequest$6:
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 Coap.currentToken's confirmed starting value in the decompile
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 ServiceStarter.ERROR_UNKNOWN)
169
- BadGateway = 502 # CONFIRMED (cross-referenced via TypedValues.PositionType.TYPE_DRAWPATH)
170
- ProxyingNotSupported = 512 # CONFIRMED (cross-referenced via TypedValues.PositionType.TYPE_SIZE_PERCENT) -- numerically surprising, see module docstring
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 from M.Model
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
- # Ported from M.Model.getManufacturer(). The decompiled version branches on
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 JADX symbolic-constant substitution back to the real
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 Firebase's
216
- ServiceStarter.ERROR_UNKNOWN=500 to resolve (also fully confirmed, just
217
- via an extra hop)."""
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
- # PeripheralConnection.java; the three parallel arrays confirmed via
279
- # CowboyNetworkProcess.java (index-matched: serialArray[i]/
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 (CowboyNetworkProcess.java references it
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 from M.VisualID."""
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: androidx.recyclerview.widget.ItemTouchHelper.Callback.DEFAULT_SWIPE_ANIMATION_DURATION
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
- Ported from the decompiled com.c2.comm.utilities.Crc class. The table below
5
- is Java's original signed-short constants converted to unsigned 16-bit for
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`, matching com.c2.comm.utilities.Crc.crc16()."""
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