python-mobius 0.1.4__tar.gz → 0.2.0__tar.gz

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.
Files changed (63) hide show
  1. {python_mobius-0.1.4 → python_mobius-0.2.0}/CHANGELOG.md +204 -0
  2. {python_mobius-0.1.4 → python_mobius-0.2.0}/PKG-INFO +1 -1
  3. {python_mobius-0.1.4 → python_mobius-0.2.0}/documentation/06-light-schedule.md +24 -0
  4. python_mobius-0.2.0/documentation/09-thread-coap-relay.md +337 -0
  5. {python_mobius-0.1.4 → python_mobius-0.2.0}/documentation/11-device-status-attributes.md +24 -0
  6. {python_mobius-0.1.4 → python_mobius-0.2.0}/pyproject.toml +1 -1
  7. {python_mobius-0.1.4 → python_mobius-0.2.0}/src/mobius/__init__.py +21 -5
  8. python_mobius-0.2.0/src/mobius/cli.py +393 -0
  9. python_mobius-0.2.0/src/mobius/coap.py +253 -0
  10. {python_mobius-0.1.4 → python_mobius-0.2.0}/src/mobius/constants.py +17 -0
  11. {python_mobius-0.1.4 → python_mobius-0.2.0}/src/mobius/device.py +265 -7
  12. {python_mobius-0.1.4 → python_mobius-0.2.0}/src/mobius/device_status.py +45 -0
  13. {python_mobius-0.1.4 → python_mobius-0.2.0}/src/mobius/discovery.py +68 -0
  14. python_mobius-0.2.0/src/mobius/mesh_address.py +116 -0
  15. python_mobius-0.2.0/src/mobius/relay.py +289 -0
  16. python_mobius-0.2.0/tests/test_coap.py +191 -0
  17. python_mobius-0.2.0/tests/test_coap_indication_handling.py +160 -0
  18. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_device_summary.py +12 -0
  19. python_mobius-0.2.0/tests/test_device_time.py +125 -0
  20. python_mobius-0.2.0/tests/test_frame_sniffer.py +87 -0
  21. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_light_intensity_branching.py +16 -0
  22. python_mobius-0.2.0/tests/test_mesh_address.py +126 -0
  23. python_mobius-0.2.0/tests/test_mesh_discovery.py +235 -0
  24. python_mobius-0.2.0/tests/test_mesh_discovery_direct_connect.py +180 -0
  25. python_mobius-0.2.0/tests/test_relay.py +313 -0
  26. python_mobius-0.1.4/documentation/09-thread-coap-relay.md +0 -78
  27. python_mobius-0.1.4/src/mobius/cli.py +0 -125
  28. {python_mobius-0.1.4 → python_mobius-0.2.0}/.gitignore +0 -0
  29. {python_mobius-0.1.4 → python_mobius-0.2.0}/LICENSE +0 -0
  30. {python_mobius-0.1.4 → python_mobius-0.2.0}/README.md +0 -0
  31. {python_mobius-0.1.4 → python_mobius-0.2.0}/documentation/00-overview.md +0 -0
  32. {python_mobius-0.1.4 → python_mobius-0.2.0}/documentation/01-ble-transport.md +0 -0
  33. {python_mobius-0.1.4 → python_mobius-0.2.0}/documentation/02-framing-and-crc.md +0 -0
  34. {python_mobius-0.1.4 → python_mobius-0.2.0}/documentation/03-attributes-and-opcodes.md +0 -0
  35. {python_mobius-0.1.4 → python_mobius-0.2.0}/documentation/04-device-identity.md +0 -0
  36. {python_mobius-0.1.4 → python_mobius-0.2.0}/documentation/05-scenes.md +0 -0
  37. {python_mobius-0.1.4 → python_mobius-0.2.0}/documentation/07-pump-schedule.md +0 -0
  38. {python_mobius-0.1.4 → python_mobius-0.2.0}/documentation/08-manufacturer-data.md +0 -0
  39. {python_mobius-0.1.4 → python_mobius-0.2.0}/documentation/10-known-gaps-and-open-questions.md +0 -0
  40. {python_mobius-0.1.4 → python_mobius-0.2.0}/documentation/12-device-identity-and-address-stability.md +0 -0
  41. {python_mobius-0.1.4 → python_mobius-0.2.0}/src/mobius/crc.py +0 -0
  42. {python_mobius-0.1.4 → python_mobius-0.2.0}/src/mobius/frame.py +0 -0
  43. {python_mobius-0.1.4 → python_mobius-0.2.0}/src/mobius/manufacturer.py +0 -0
  44. {python_mobius-0.1.4 → python_mobius-0.2.0}/src/mobius/modifiers.py +0 -0
  45. {python_mobius-0.1.4 → python_mobius-0.2.0}/src/mobius/power.py +0 -0
  46. {python_mobius-0.1.4 → python_mobius-0.2.0}/src/mobius/pump_status.py +0 -0
  47. {python_mobius-0.1.4 → python_mobius-0.2.0}/src/mobius/schedule.py +0 -0
  48. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_connection_cleanup.py +0 -0
  49. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_device_status.py +0 -0
  50. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_firmware_versions.py +0 -0
  51. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_frame.py +0 -0
  52. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_hyperdrive_device.py +0 -0
  53. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_insolation_detection.py +0 -0
  54. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_light_schedule.py +0 -0
  55. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_manufacturer.py +0 -0
  56. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_manufacturer_lookup.py +0 -0
  57. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_modifiers.py +0 -0
  58. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_power.py +0 -0
  59. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_pump_schedule.py +0 -0
  60. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_pump_status.py +0 -0
  61. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_schedule_intensity_scalar.py +0 -0
  62. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_serial_decoding.py +0 -0
  63. {python_mobius-0.1.4 → python_mobius-0.2.0}/tests/test_serial_identity.py +0 -0
@@ -1,5 +1,209 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ Thread/CoAP device relay -- see
6
+ [documentation/09-thread-coap-relay.md](documentation/09-thread-coap-relay.md)
7
+ for the full protocol trace and implementation history. The `coap-relay`
8
+ branch has been merged into main as part of this release.
9
+
10
+ - **Root cause of relayed reads returning empty data found and fixed,**
11
+ after the user provided the raw smali disassembly for
12
+ `BaseConnection.java` -- JADX's decompiler had given up specifically on
13
+ `handleIndication()`, the one method that held the answer, but the
14
+ underlying bytecode was still traceable. Confirmed precisely: the real
15
+ CoAP relay response arrives on a completely separate opcode (`0xDF`/
16
+ `26`, `COAP_INDICATION_OPCODE` -- not the request's `25`, whose confirm
17
+ is a bare, meaningless ack the app itself discards, confirmed
18
+ byte-for-byte via real hardware testing showing exactly 1 byte in every
19
+ relayed read's outer response), matched purely by CoAP token
20
+ (`lambda$getSentRequest$6`: `coapRequest.getToken() ==
21
+ coapResponse.getToken()`), never by the outer FSCI message ID. Added
22
+ `MobiusDevice._register_coap_token()`/`wait_for_coap_response()` -- a
23
+ token-keyed pending-response registry, entirely separate from the
24
+ existing message-ID-based `self._pending` mechanism -- wired into
25
+ `_on_rx_final()`. `RelayedMobiusDevice._relay()` was rewritten around
26
+ this: registers the token before sending (eliminating any race against
27
+ the response arriving first), sends without waiting for the
28
+ meaningless confirm, and awaits the token-matched indication instead.
29
+ `test_relay.py` rewritten entirely for the new architecture (14 tests);
30
+ new dedicated `test_coap_indication_handling.py` for the token-registry
31
+ mechanism itself (9 tests). Verified end-to-end with a full mocked
32
+ round-trip (both the discarded ack and the real indication arriving,
33
+ in that order) before asking for another real hardware run -- **this is
34
+ the next real test.**
35
+
36
+ - **Real hardware testing confirmed Thread IS active on regular
37
+ VorTech/Radion devices** (a genuine, positive result -- `MeshLocalAddresses`
38
+ reads back a valid, non-zero address), **but the three-array peer
39
+ discovery mechanism (`discover_mesh_peers()`) is Cowboy-hub-specific**
40
+ (`FsciStatus.UnsupportedAttribute` on hardware with no Cowboy hub,
41
+ even though that same device's own address reads back fine). Added
42
+ `MobiusDevice.get_own_mesh_address()` and `mobius.discovery.
43
+ discover_mesh_peers_via_direct_connect()` as the alternative --
44
+ confirmed via `PeripheralConnection.java` to be exactly how the app
45
+ itself always learns a device's address (automatic on every
46
+ connection, not Cowboy-specific). Added `mobius-scan --build-peer-map`
47
+ to exercise this from the CLI. `discover_mesh_peers()` remains in
48
+ place as a cheap, fail-soft first attempt in case a Cowboy hub IS
49
+ present, but is no longer the primary/recommended discovery path. 10
50
+ new tests.
51
+
52
+ - **Fixed a real bug found via real hardware testing: `--relay-target`
53
+ was never actually wired to the new Cowboy-hub-free discovery
54
+ mechanism.** After adding `discover_mesh_peers_via_direct_connect()`,
55
+ a real `--relay-target` attempt still failed with "not found among
56
+ discovered peers" -- not because relay itself failed, but because the
57
+ CLI's target lookup only ever checked the Cowboy-hub-specific peer
58
+ list. `--relay-target` now falls back to a brief direct connection to
59
+ the target itself (`find_device_by_serial()` +
60
+ `get_own_mesh_address()`) if the Cowboy-hub lookup doesn't find it.
61
+
62
+ - **Added `--debug-relay`** after the fixed `--relay-target` reached the
63
+ target but `get_device_summary()` came back entirely empty with no
64
+ visible error. Turned out every attribute fetch WAS raising an
65
+ exception internally (a malformed CoAP response) -- silently, since
66
+ `get_device_summary()`'s own per-field fail-soft handling was catching
67
+ and discarding them without trace. Prints raw bytes at every layer of
68
+ a relayed request/response so this stops being invisible. Verified
69
+ with a mocked round-trip that debug output itself is correct before
70
+ asking for another real hardware run.
71
+
72
+ - **Confirmed directly from source why the response comes back
73
+ malformed: the immediate confirm to a relay request is explicitly
74
+ discarded by the app itself, on purpose.** `BaseConnection.java`'s
75
+ `handleBytes()` matches `opGroup==0xDF && opCode==25` unconditionally
76
+ and logs it only -- before the dispatcher ever checks whether it
77
+ resolves a pending request. Real hardware testing independently
78
+ confirmed this byte-for-byte: every relayed read's outer response is
79
+ exactly 1 byte (`0x00`), not a truncated response but the genuine
80
+ complete confirm, thrown away by the app too. The real response, if it
81
+ arrives at all, comes through a separate "indication" handled by
82
+ `handleIndication()` -- a method JADX couldn't decompile, a genuine
83
+ limit of what source tracing alone can determine here. Added
84
+ `MobiusDevice.on_any_frame` (fires for every parsed incoming frame,
85
+ not just ones resolving a pending request) and extended
86
+ `--debug-relay` to watch for 2 seconds after the initial confirm, to
87
+ find out empirically whether a second frame actually arrives. 4 new
88
+ tests.
89
+
90
+ - **Added `mobius.coap`**: CoAP (RFC 7252) protocol primitives --
91
+ `CoapRequestType`/`CoapMethod`/`CoapResponseCode` enums,
92
+ `encode_coap_request()`/`decode_coap_response()`. Pure functions, no
93
+ hardware dependency. Confirmed wire format traced directly from the
94
+ decompiled app (`Coap.java`/`CoapRequest.java`/`CoapResponse.java`) --
95
+ see the module's docstring for exactly which enum values are
96
+ confirmed-literal, cross-referenced from elsewhere in this project, or
97
+ inferred-only (deliberately not guessing at unconfirmed ones -- an
98
+ unrecognized response code decodes to a plain int, matching the real
99
+ app's own graceful fallback behavior). 15 new tests, including
100
+ specific coverage of the asymmetric IPv6 byte-reversal between
101
+ requests and responses, which is easy to get backwards.
102
+
103
+ - **Added `mobius.mesh_address`**: Thread mesh-local RLOC IPv6 address
104
+ construction (`build_rloc_address()`/`extract_short_address()`/
105
+ `mesh_local_prefix_from_own_address()`/`is_valid_mesh_address()`/
106
+ `is_short_address_derived()`). Pure functions, no hardware dependency.
107
+ Includes a genuinely surprising, verified-not-assumed finding: the
108
+ short-address portion is little-endian (confirmed via
109
+ `ByteUtilities.getShort()`'s explicit `ByteOrder.LITTLE_ENDIAN`), the
110
+ opposite of IPv6's own conventional big-endian byte order -- easy to
111
+ get backwards by assuming the IPv6 standard's own convention applies
112
+ here too. 15 new tests.
113
+
114
+ - **Added `MobiusDevice.discover_mesh_peers()`**: fetches
115
+ `MeshLocalAddresses` (1005) + the three parallel `ShortAddressArray`/
116
+ `SerialNumberArray`/`DeviceModelArray` attributes (3700/3701/3702)
117
+ from a connected device, returning every other device it knows about
118
+ over the Thread mesh as a list of new `MeshPeer` dataclasses (serial,
119
+ model, short address, ready-to-use IPv6). Confirms `SerialNumberArray`'s
120
+ per-element format matches the existing 14-byte ASCII serial format
121
+ already used elsewhere in this library -- no new parsing logic needed.
122
+ Filters out cleared/empty slots (the literal sentinel string
123
+ `"00000000000000"`, confirmed via `CowboyNetworkProcess.java`'s own
124
+ cleanup logic). Fails soft to an empty list for unsupported devices or
125
+ ones not currently part of an active Thread network, matching this
126
+ library's usual convention. Also recorded (but not yet used) a fourth
127
+ parallel array, `BladeBleAddressArray` (3714, each peer's real BLE MAC
128
+ address) -- found alongside the other three but not needed for CoAP
129
+ relay itself. 7 new tests.
130
+
131
+ - **Added `RelayedMobiusDevice`** (`mobius.relay`): a `MobiusDevice`
132
+ subclass that overrides `get_attribute()`/`get_attribute_raw()`/
133
+ `set_attribute()` to route through a gateway device via CoAP instead of
134
+ owning its own BLE connection. Every other inherited method
135
+ (`get_device_summary()`, `get_current_light_intensities()`, etc.) works
136
+ transparently through relay, confirmed with a test that specifically
137
+ exercises an inherited high-level method rather than just the
138
+ overridden primitives. Confirmed `request.getData(true)` builds a
139
+ complete self-contained FSCI frame (byte-for-byte identical in
140
+ structure to this library's own `build_frame()`), resolving an
141
+ ambiguity flagged in an earlier commit -- relaying a request needed no
142
+ new encoding path, just wrapping the same frame a direct request would
143
+ use. Also confirmed, directly from the one real call site in the
144
+ decompile, a subtle and easy-to-get-wrong detail: the outer CoAP method
145
+ is always `GET`, even for a relayed Set -- the actual operation is
146
+ determined by the inner FSCI opcode, not the CoAP transport layer. 13
147
+ new tests.
148
+
149
+ - **Added `mobius-scan --dump-mesh-peers` / `--relay-target SERIAL`**
150
+ (both usable alongside `--by-serial`): prints every peer a connected
151
+ device knows about over the Thread mesh, and/or relays a full
152
+ `get_device_summary()` read to a specified target through it. This is
153
+ the last of the 5 planned pieces for this feature -- **implementation
154
+ is now complete, but not yet validated against real hardware.** Run a
155
+ separate direct `--by-serial <target>` command to confirm a relayed
156
+ read actually matches a direct one; this is also how the biggest open
157
+ question (whether these devices run Thread at all) finally gets
158
+ answered.
159
+
160
+ - **Added a verbose, step-by-step mesh discovery diagnostic
161
+ (`_debug_mesh_discovery()`)** after real hardware testing showed every
162
+ device returning "No peers found" with no further detail.
163
+ `discover_mesh_peers()`'s fail-soft-to-empty-list behavior is correct
164
+ for production/library use, but collapses several genuinely different
165
+ failure modes into the same result -- device doesn't support Thread at
166
+ all, vs. supports it but isn't on an active network right now, vs.
167
+ the peer arrays came back empty for some other reason. `--dump-mesh-peers`
168
+ now surfaces each step's raw result or exception individually instead
169
+ of just the final empty-or-not outcome.
170
+
171
+ - **Added `get_device_time_info()`** (`Epoch`/`RTCTime`, attributes
172
+ 201/219) -- device clock drift vs. local system time. Not speculative:
173
+ confirmed via the app's own "Time Difference" troubleshooting feature
174
+ (`TimeDifferenceFragment.java`), which checks these exact two attributes
175
+ the same way. Included automatically in `get_device_summary()`'s
176
+ `"device_time"` field. Important caveat documented: the app's own
177
+ schedule interpolation uses the phone/tank clock, not either of these,
178
+ so this doesn't directly explain a schedule-interpolation mismatch by
179
+ itself -- still worth checking as a general health signal. 5 new tests.
180
+
181
+ - **Added `mobius-scan --dump-schedule`**: prints each connected device's
182
+ full raw programmed schedule (every point's time, flags -- ACTIVE/NIGHT/
183
+ SUNRISE/SUNSET -- and per-channel/mode values), not just the
184
+ interpolated "current" value `get_device_summary()` already shows. For
185
+ lights, also prints `lunar_enabled`/`insolation_active`/
186
+ `schedule_intensity`/`acclimation` state. Combines with either the
187
+ normal scan or `--by-serial`. Added so a schedule's actual point
188
+ structure can be inspected directly -- needed to verify
189
+ `is_night_segment()`'s bracket-finding logic against real data, rather
190
+ than guessed at or transcribed by hand.
191
+
192
+ - **Added debugging diagnostics to `get_current_light_intensities()`**:
193
+ `.diagnostics` now also includes `is_night_segment`, `lunar_enabled`,
194
+ `scalar_source` (`"lunar"`/`"night_no_lunar"`/`"schedule_intensity"`),
195
+ and `scalar` (the actual final multiplier applied). Added after a real
196
+ discrepancy was reported between this library's computed value and the
197
+ app's own displayed value for a light at night (app showing lunar-
198
+ reduced moonlight intensities, this library showing the unreduced
199
+ schedule-intensity value instead) -- these fields exist specifically so
200
+ that class of mismatch can be diagnosed with real data (which branch was
201
+ taken, and why) rather than guessed at blindly. Also threaded into
202
+ `get_device_summary()` as `"light_diagnostics"`, so `mobius-scan`
203
+ surfaces this directly -- debugging this kind of mismatch belongs in the
204
+ library/CLI, not in a specific downstream integration. Existing tests
205
+ updated to assert on these fields directly.
206
+
3
207
  ## 0.1.4
4
208
 
5
209
  - Added `MobiusDevice.is_connected` property -- lets callers check
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mobius
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)
5
5
  Project-URL: Homepage, https://code.r3pek.org/r3pek/python-mobius
6
6
  Project-URL: Documentation, https://code.r3pek.org/r3pek/python-mobius/src/branch/main/documentation
@@ -165,6 +165,30 @@ wrong-but-present value is often more useful than nothing, and the
165
165
  severity depends on how different the real Insolation-derived schedule is
166
166
  from the manually-programmed one on a given day.
167
167
 
168
+ ## Debugging diagnostics: which branch/scalar was actually used
169
+
170
+ `.diagnostics` also includes `"is_night_segment"`, `"lunar_enabled"`,
171
+ `"scalar_source"` (one of `"lunar"`/`"night_no_lunar"`/
172
+ `"schedule_intensity"`), and `"scalar"` (the actual final multiplier
173
+ applied). Added specifically because a mismatch between this library's
174
+ computed value and the app's own displayed value could stem from either
175
+ `is_night_segment()` misjudging a specific schedule's point structure, or
176
+ `get_lunar_enabled()` reading incorrectly -- and those need to be told
177
+ apart with real data rather than guessed at. If you ever see a value that
178
+ doesn't match the app, check these fields first before assuming which
179
+ piece is wrong:
180
+
181
+ ```python
182
+ result = await device.get_current_light_intensities()
183
+ print(result.diagnostics)
184
+ # {'insolation_active': False, 'is_night_segment': True,
185
+ # 'lunar_enabled': True, 'scalar_source': 'lunar', 'scalar': 0.35}
186
+ ```
187
+
188
+ `lunar_enabled` is `None` (not `False`) when `is_night_segment` is
189
+ `False`, since it isn't checked/relevant outside the night segment --
190
+ don't read a `None` here as "lunar phases are off."
191
+
168
192
  ## Hyperdrive (boosted channels above 100%)
169
193
 
170
194
  Some light channels can be driven above their normally-rated power
@@ -0,0 +1,337 @@
1
+ # Thread/CoAP device relay
2
+
3
+ **Status: root cause found and fixed, confirmed directly from smali
4
+ bytecode (not just source) -- ready for a real hardware retest.** The
5
+ user provided the raw smali disassembly for `BaseConnection.java`
6
+ (specifically `handleIndication()`, the method JADX couldn't decompile).
7
+ This resolved the mystery completely: the real CoAP relay response
8
+ arrives as a genuinely separate message, on a different opcode (`0xDF`/
9
+ `26`, not the request's `25`), matched purely by CoAP token -- never by
10
+ the outer FSCI message ID, and never via the (confirmed meaningless,
11
+ app-discarded) confirm to opcode 25. Implemented: `MobiusDevice` now
12
+ tracks pending CoAP responses by token (`_register_coap_token()`/
13
+ `wait_for_coap_response()`), and `_on_rx_final()` resolves them when a
14
+ matching opcode-26 indication arrives, entirely independent of the
15
+ existing message-ID-based mechanism used for direct requests.
16
+ `RelayedMobiusDevice._relay()` was rewritten around this. See
17
+ "What's confirmed vs. not yet" below for the full, current picture.
18
+
19
+ ## Why this matters in practice
20
+
21
+ Every device this library talks to gets its own persistent, direct BLE
22
+ connection. The official app doesn't -- it connects to just **one or a
23
+ few** devices and reaches the rest through them. Concretely, this means:
24
+ if something else (this library, `ha-mobius`, or another instance of the
25
+ app) is already holding direct BLE connections to every device in a tank,
26
+ the official app can end up with nothing to connect to at all, and stops
27
+ working. Implementing relay properly is the fix -- it lets this library
28
+ behave the way the app does, freeing up connections rather than holding
29
+ one per device indefinitely.
30
+
31
+ ## How the app's relay actually works
32
+
33
+ Devices form a **Thread mesh network** (802.15.4 radio -- separate
34
+ hardware from BLE) identified by a `pan_id` (see
35
+ [08-manufacturer-data.md](./08-manufacturer-data.md)). `Comm.java`'s
36
+ `sendRequest()` routing logic (confirmed directly from source, not
37
+ inferred):
38
+
39
+ ```java
40
+ if (commDevice.equals(request.getTarget())) {
41
+ // direct: send over the existing BLE connection as-is
42
+ } else if (iPv6 != null) {
43
+ // relay: wrap in CoAP, address it to the target's mesh IPv6,
44
+ // send through commDevice (the connected gateway)
45
+ } else {
46
+ // no known address for the target -- fail
47
+ }
48
+ ```
49
+
50
+ The key insight: **the Thread border-router function lives on the
51
+ connected device itself**, not on the phone. A phone (or a laptop) never
52
+ needs its own 802.15.4 radio -- it only needs BLE to reach *one* mesh
53
+ member, and that device does the Thread-side forwarding using its own
54
+ onboard hardware.
55
+
56
+ ## CoAP wire format (confirmed)
57
+
58
+ Traced directly from `Coap.java` / `CoapRequest.java` / `CoapResponse.java`
59
+ / `CoapFsciRequest.java`. Reuses the same C2CI envelope described in
60
+ [02-framing-and-crc.md](./02-framing-and-crc.md), just a new opcode
61
+ (confirmed literal: **25**) alongside the existing Get(`0x17`)/Set(`0x18`)
62
+ attribute opcodes.
63
+
64
+ **Request** -- `CoapRequest`'s first constructor:
65
+ ```
66
+ IPv6(16 bytes, REVERSED) + token(4, LE) + method(2, LE)
67
+ + payload_length(2, LE) + request_type(1) + payload
68
+ ```
69
+
70
+ **Response** -- `CoapResponse`'s constructor:
71
+ ```
72
+ IPv6(16 bytes, NOT reversed) + token(4, LE) + response_code(2, LE)
73
+ + payload_length(2, LE) + request_type(1) + payload
74
+ ```
75
+
76
+ The asymmetric reversal (request reverses the address, response doesn't)
77
+ is confirmed directly from source and easy to get backwards -- see
78
+ `mobius.coap`'s test suite, which specifically checks this doesn't happen
79
+ by accident (a palindromic test address would pass even with the reversal
80
+ backwards or missing; the tests deliberately avoid that).
81
+
82
+ **The payload is a complete, self-contained FSCI frame.** `CoapFsciRequest`
83
+ wraps `request.getData(true)` -- and `Request.getData(true)`'s exact
84
+ implementation is now confirmed directly from source: it builds
85
+ `opGroup + opCode + messageId(2) + reserved(1) + dataLength(2) + data +
86
+ CRC16(2)`, then prepends the magic byte (`0x02`) to that -- i.e. the
87
+ **complete outer FSCI frame**, byte-for-byte identical in structure to
88
+ what this library's own `build_frame()` produces for a direct (non-
89
+ relayed) request. This means relaying a request is exactly as simple as
90
+ it sounds: build the request the normal way, wrap that complete frame
91
+ wholesale as the CoAP payload, and send it to the gateway on the CoAP
92
+ opcode instead of sending it directly. No separate encoding path needed
93
+ for relayed vs. direct requests -- only the outer envelope differs.
94
+
95
+ `Coap.ResponseCode` values are standard RFC 7252 codes (`2.01 Created`,
96
+ `2.05 Content`, `4.00 Bad Request`, `5.00 Internal Server Error`, etc.,
97
+ encoded as `class*100 + detail`). Several are JADX symbolic substitutions
98
+ rather than plain literals in the decompile; not all could be
99
+ independently confirmed (see `mobius.coap`'s module docstring for exactly
100
+ which values are literal, which are cross-referenced from elsewhere in
101
+ this project, and which are inferred-only). One confirmed value
102
+ (`ProxyingNotSupported = 512`) doesn't fit the otherwise-clean pattern --
103
+ a reminder that guessing at the unconfirmed ones isn't safe.
104
+
105
+ ## Address discovery (confirmed -- this is what the earlier version of
106
+ ## this document got wrong)
107
+
108
+ The earlier version of this document treated the target's Thread IPv6
109
+ address as unverified/only-readable-from-the-target-itself. A more
110
+ thorough trace found the actual mechanism, confirmed directly in
111
+ `CowboyNetworkProcess.java` and `Comm.java`:
112
+
113
+ 1. **`MeshLocalAddresses`** (attribute `1005`) -- read from the connected
114
+ gateway device. Confirmed via `PeripheralConnection.java`: returns the
115
+ gateway's OWN complete 16-byte mesh-local address (not "the prefix" as
116
+ its own standalone value), sanity-checked there as non-all-zero before
117
+ use. Since every device on the same Thread network shares the same
118
+ 8-byte prefix, extracting the first 8 bytes of the gateway's own
119
+ address gives the network's shared prefix.
120
+ 2. **`ShortAddressArray`/`SerialNumberArray`/`DeviceModelArray`**
121
+ (attributes `3700`/`3701`/`3702`) -- three parallel arrays, fetched via
122
+ the same "get all elements" pattern (`index=0, count=0xFFFF`) already
123
+ used elsewhere in this library, index-matched to give every mesh peer's
124
+ serial, model, and Thread short address (RLOC16) the gateway knows
125
+ about. **All readable from a single connected gateway device** --
126
+ nothing from the target itself is required. `SerialNumberArray`'s
127
+ per-element format is confirmed identical to the existing 14-byte
128
+ ASCII serial format already used elsewhere in this library (confirmed
129
+ via `CowboyNetworkProcess.java`'s own cleanup logic, which sets a
130
+ cleared slot to the literal string `"00000000000000"` -- fourteen
131
+ ASCII zero characters, the same length/format as a real serial, not
132
+ null bytes) -- no new serial-parsing logic needed.
133
+ 3. **Target IPv6 = `prefix(8) + 00:00 + 00:FF:FE:00 + short_address(2,
134
+ little-endian)`** -- the standard Thread RLOC IID format. Confirmed
135
+ via `IPv6.isEui64Address()`'s exact byte check (`bytes[10:14] ==
136
+ 00:FF:FE:00`), which matches the real Thread specification's own
137
+ convention for short-address-derived interface identifiers -- not
138
+ something EcoTech invented, a well-known, publicly documented mesh
139
+ networking format. The little-endian byte order for the short-address
140
+ portion is a genuinely surprising, verified-not-assumed detail --
141
+ confirmed via `ByteUtilities.getShort()`'s explicit
142
+ `ByteOrder.LITTLE_ENDIAN`, which is the opposite of IPv6's own
143
+ conventional big-endian (network byte order); easy to get backwards by
144
+ just assuming the IPv6 standard's convention applies here too.
145
+
146
+ ## What's confirmed vs. not yet
147
+
148
+ **Confirmed from source** (traced directly, not guessed): the CoAP
149
+ envelope wire format (both directions), the routing decision logic, the
150
+ RLOC-based address construction formula, that `request.getData(true)`
151
+ produces a complete self-contained FSCI frame (byte-for-byte identical in
152
+ structure to this library's own `build_frame()` output), and that the
153
+ outer CoAP method is always `GET` regardless of whether the relayed
154
+ operation is itself a Get or Set -- confirmed via the one real call site
155
+ (`Comm.java`), which hardcodes `Request.Method.GET` unconditionally.
156
+
157
+ **Confirmed from real hardware testing**:
158
+ - **Thread IS active on regular VorTech/Radion devices** -- a real,
159
+ positive result. `MeshLocalAddresses` reads back a genuine, valid,
160
+ non-zero mesh-local address on hardware with no Cowboy hub present.
161
+ This resolves what had been the single biggest open question.
162
+ - **The three-array peer discovery mechanism
163
+ (`ShortAddressArray`/`SerialNumberArray`/`DeviceModelArray`,
164
+ `MobiusDevice.discover_mesh_peers()`) is Cowboy-hub-specific.** On real
165
+ VorTech/Radion hardware with no Cowboy hub, all three return
166
+ `FsciStatus.UnsupportedAttribute` (an explicit "I don't support this,"
167
+ not a timeout or generic failure) -- even though `MeshLocalAddresses`
168
+ on that same device succeeds. This lines up with where these arrays
169
+ were originally found in the decompile: inside
170
+ `CowboyNetworkProcess.java`, the class specifically for setting up a
171
+ network via a dedicated Cowboy hub.
172
+ - **The alternative -- connecting directly to each device to read its
173
+ own `MeshLocalAddresses` -- doesn't depend on a Cowboy hub, and is
174
+ confirmed to be exactly how the app itself always learns a device's
175
+ address.** `PeripheralConnection.java`'s `updateConnectionState()` (the
176
+ standard GATT-setup-completion handler, checking the same
177
+ RxData/RxFinal/TxData/TxFinal characteristics this library's own
178
+ transport already uses) calls `getDeviceInfo()` -- which reads
179
+ `MeshLocalAddresses` -- automatically on every connection to any
180
+ non-legacy device. Not Cowboy-specific, not conditional: universal,
181
+ standard behavior. `MobiusDevice.get_own_mesh_address()` and
182
+ `mobius.discovery.discover_mesh_peers_via_direct_connect()` implement
183
+ this directly.
184
+ - **The real CoAP relay response arrives on a completely different
185
+ opcode than the request, matched purely by CoAP token.** Confirmed
186
+ directly from `BaseConnection.smali` (the user provided the raw
187
+ disassembly for the one method JADX couldn't decompile,
188
+ `handleIndication()`) -- traced from actual bytecode, not source-level
189
+ guessing:
190
+ - The confirm to a CoAP request (opGroup `0xDF`/opCode `25`,
191
+ `COAP_OPCODE`) is a **bare, meaningless ack**. `BaseConnection.java`'s
192
+ `handleBytes()` matches `opGroup==0xDF && opCode==25` unconditionally
193
+ and does nothing but log it, before the dispatcher ever checks
194
+ whether it resolves a pending request. Real hardware testing
195
+ independently confirmed this byte-for-byte: every relayed read's
196
+ outer response is exactly 1 byte (`0x00`) -- not a truncated
197
+ response, the genuine, complete confirm, thrown away by the app too.
198
+ - **The real response arrives as a separate message on opGroup
199
+ `0xDF`/opCode `26`** (`COAP_INDICATION_OPCODE`) -- confirmed via the
200
+ packed-switch inside `handleIndication()`.
201
+ - **Matched purely by CoAP token, never by the outer FSCI message ID.**
202
+ Confirmed via `lambda$getSentRequest$6`'s exact comparison:
203
+ `coapRequest.getToken() == coapResponse.getToken()`.
204
+ - Implemented in `MobiusDevice._register_coap_token()`/
205
+ `wait_for_coap_response()` and wired into `_on_rx_final()`, entirely
206
+ independent of the existing message-ID-based `self._pending`
207
+ mechanism used for direct (non-relayed) requests.
208
+ `RelayedMobiusDevice._relay()` was rewritten around this: registers
209
+ the token before sending (eliminating any race), sends without
210
+ waiting for the meaningless confirm, and awaits the token-matched
211
+ indication instead.
212
+
213
+ **Not yet confirmed**: whether this actually works end-to-end against
214
+ real hardware. Everything above is confirmed from source/bytecode and
215
+ covered by unit tests with mocked notifications, but hasn't been
216
+ exercised against a real device yet -- that's the next test.
217
+ hardware instead, which `--debug-relay`'s frame-sniffing window is for.
218
+
219
+ ## Implementation plan (this branch)
220
+
221
+ Split by functionality, each independently testable before the next
222
+ depends on it:
223
+
224
+ 1. **CoAP protocol primitives** (`mobius.coap`) -- envelope encode/decode,
225
+ pure functions, no hardware needed. *(done)*
226
+ 2. **Thread mesh IPv6 addressing** (`mobius.mesh_address`) -- RLOC
227
+ address construction from a mesh-local prefix + short address.
228
+ *(done -- includes a verified, non-obvious finding: the short-address
229
+ portion is little-endian, confirmed via `ByteUtilities.getShort()`'s
230
+ explicit `ByteOrder.LITTLE_ENDIAN`, not the IPv6 standard's own
231
+ big-endian convention one might otherwise assume)*
232
+ 3. **Peer discovery** (`MobiusDevice.discover_mesh_peers()`) -- fetching
233
+ and parsing the three parallel arrays into a serial→IPv6 map. *(done
234
+ -- also found and recorded a fourth parallel array,
235
+ `BladeBleAddressArray` (3714, each peer's real BLE MAC address), not
236
+ used by relay itself but included in constants.py for completeness.
237
+ **Confirmed via real hardware testing to be Cowboy-hub-specific** --
238
+ see item 6 below for the alternative this made necessary.)*
239
+ 4. **`RelayedMobiusDevice`** (`mobius.relay`) -- a `MobiusDevice` subclass
240
+ that overrides `get_attribute()`/`get_attribute_raw()`/`set_attribute()`
241
+ to route via CoAP through a gateway device instead of owning its own
242
+ connection. Every other inherited method (`get_device_summary()`,
243
+ `get_current_light_intensities()`, everything) works transparently
244
+ through relay, since they're all built in terms of those three
245
+ methods. *(done -- confirmed via a test that exercises an inherited
246
+ high-level method, not just the overridden primitives directly)*.
247
+ Also confirmed a subtle, easy-to-get-wrong detail directly from the
248
+ one real call site in the decompile: the outer CoAP method is always
249
+ `GET`, even for a relayed Set -- the actual operation is determined by
250
+ the *inner* FSCI opcode, not the CoAP transport layer.
251
+ 5. **CLI support** (`mobius-scan --dump-mesh-peers` /
252
+ `--relay-target SERIAL`) -- a way to actually validate this against
253
+ real hardware: connect to one device (the gateway), discover its mesh
254
+ peers, and relay a full `get_device_summary()` read to a specified
255
+ target through it. Run a separate direct `--by-serial <target>`
256
+ command to confirm the two match. *(done -- this is the step that
257
+ actually answers the biggest open question: whether these devices run
258
+ Thread at all. Everything above this point is implemented and unit-
259
+ tested, but genuinely unverified against real hardware until this is
260
+ run.)* Added a verbose, step-by-step diagnostic path
261
+ (`_debug_mesh_discovery()`) after real hardware testing showed every
262
+ device returning "No peers found" with no further detail --
263
+ `discover_mesh_peers()`'s production fail-soft behavior (the right
264
+ choice for library use) collapses several genuinely different failure
265
+ modes into the same empty result, which made the CLI output useless
266
+ for telling apart "this device doesn't support Thread at all" from
267
+ "it supports Thread but isn't on an active network right now" from
268
+ "the arrays came back empty." The CLI now surfaces each step's raw
269
+ result or exception individually instead.
270
+ 6. **Cowboy-hub-free peer discovery** (`MobiusDevice.
271
+ get_own_mesh_address()` / `mobius.discovery.
272
+ discover_mesh_peers_via_direct_connect()` / `mobius-scan
273
+ --build-peer-map`) -- added after real hardware testing confirmed
274
+ item 3's three-array approach requires a Cowboy hub, which this
275
+ project's test hardware doesn't have. Connects directly and briefly to
276
+ each device in turn to read its own `MeshLocalAddresses`, confirmed
277
+ via `PeripheralConnection.java` to be exactly how the app itself
278
+ always learns a device's mesh address (`getDeviceInfo()`, called
279
+ automatically on every connection as standard setup, not something
280
+ Cowboy-specific). *(done -- 10 new tests. This is the discovery
281
+ mechanism to actually use going forward; `discover_mesh_peers()`
282
+ remains in place as a cheap, fail-soft first attempt in case a Cowboy
283
+ hub IS present, but isn't the primary path anymore.)*
284
+
285
+ **Follow-up fix**: `--relay-target` was still only looking through the
286
+ Cowboy-specific peer list after this item landed -- it was never wired
287
+ to fall back to a direct connection, so real hardware testing hit
288
+ "not found among discovered peers" even with this new discovery
289
+ mechanism in place. Fixed: `--relay-target` now falls back to briefly
290
+ connecting directly to the target itself (via `find_device_by_serial()`
291
+ + `get_own_mesh_address()`) if the Cowboy-hub lookup doesn't find it.
292
+ 7. **`--debug-relay`** -- added after `--relay-target` reached the
293
+ target but `get_device_summary()` came back entirely empty
294
+ (model/serial/everything `None`), with no visible error. Turned out
295
+ every individual attribute fetch WAS raising an exception internally
296
+ (a too-short, malformed CoAP response) -- just silently, since
297
+ `get_device_summary()`'s own per-field fail-soft handling (correct
298
+ behavior for genuinely optional attributes) was catching and
299
+ discarding them without any visible trace. Prints raw bytes at every
300
+ layer of a relayed request/response (outer frame, CoAP envelope
301
+ fields, inner frame, decoded status/values) so this stops being
302
+ invisible. Verified with a mocked round-trip that the debug output
303
+ itself is correct and doesn't interfere with normal operation before
304
+ asking for another real hardware run.
305
+ 8. **Discovered the immediate confirm to a relay request is explicitly
306
+ discarded by the app itself** -- confirmed directly from
307
+ `BaseConnection.java`'s `handleBytes()` (see "What's confirmed" above)
308
+ after `--debug-relay`'s raw bytes showed every relayed read's outer
309
+ response was exactly 1 byte, too short to be a valid CoAP envelope at
310
+ all. The real response-correlation mechanism lives in
311
+ `handleIndication()`, a method JADX couldn't decompile -- a genuine
312
+ limit of what source tracing alone could determine at this point.
313
+ Added `MobiusDevice.on_any_frame` (a hook called for every parsed
314
+ incoming frame, not just ones resolving a pending request) and
315
+ extended `--debug-relay` to watch for 2 seconds after the initial
316
+ confirm. 4 new tests for the hook itself.
317
+ 9. **Root cause found and fixed**, after the user provided the raw smali
318
+ disassembly for `BaseConnection.java` -- JADX's decompiler had given
319
+ up on `handleIndication()` specifically, but the underlying bytecode
320
+ was still traceable. This resolved everything precisely: the real
321
+ response arrives on a completely separate opcode (`0xDF`/`26`,
322
+ `COAP_INDICATION_OPCODE` -- not the request's `25`), matched purely by
323
+ CoAP token (`lambda$getSentRequest$6`:
324
+ `coapRequest.getToken() == coapResponse.getToken()`), never by the
325
+ outer FSCI message ID. Implemented:
326
+ `MobiusDevice._register_coap_token()`/`wait_for_coap_response()` (a
327
+ token-keyed pending-response registry, separate from the existing
328
+ message-ID-based `self._pending`), wired into `_on_rx_final()`.
329
+ `RelayedMobiusDevice._relay()` was rewritten around this: registers
330
+ the token before sending (eliminating any race against the response
331
+ arriving first), sends without waiting for the meaningless confirm,
332
+ and awaits the token-matched indication instead. `test_relay.py`
333
+ rewritten entirely for the new architecture; new dedicated
334
+ `test_coap_indication_handling.py` for the token-registry mechanism
335
+ itself. Verified end-to-end with a full mocked round-trip (both the
336
+ discarded ack and the real indication arriving, in that order) before
337
+ asking for another real hardware run. **This is the next real test.**
@@ -164,3 +164,27 @@ Both are included automatically in `get_device_summary()`'s
164
164
  `get_device_summary()` already has the device's `Model` on hand).
165
165
  `get_hardware_info()` is not currently wired into the summary -- call it
166
166
  directly if you want it.
167
+
168
+ ## Device clock drift (`get_device_time_info()`)
169
+
170
+ `Epoch` (201) / `RTCTime` (219) -- two separate device-reported UNIX
171
+ timestamps, with drift computed against local system time. **This is not
172
+ a speculative diagnostic** -- it's confirmed via the app's own
173
+ `TimeDifferenceFragment.java` ("Time Difference" under Troubleshoot in
174
+ the app): it checks both of these attributes for drift against the
175
+ phone's clock, using this exact same computation
176
+ (`local_time_seconds - device_reported_seconds`), treating a large
177
+ discrepancy as a real, known failure mode.
178
+
179
+ **Important caveat**: the app's own schedule interpolation
180
+ (`WidgetViewCell.java`) uses the phone/tank's own clock
181
+ (`selectedTank.getDateTime()`), NOT either of these device-reported
182
+ values -- so this does not directly explain a mismatch between this
183
+ library's computed schedule value and the app's displayed one. It's
184
+ still worth checking as a general device-health signal, exactly as the
185
+ app's own troubleshooting tool treats it, but a clean result here doesn't
186
+ rule out a schedule-interpolation bug, and a drifted result here doesn't
187
+ necessarily explain one either.
188
+
189
+ Included automatically in `get_device_summary()`'s `"device_time"` field.
190
+
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-mobius"
7
- version = "0.1.4"
7
+ version = "0.2.0"
8
8
  description = "Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)"
9
9
  readme = "README.md"
10
10
  license = { text = "GPL-2.0-only" }