python-mobius 0.1.4__tar.gz → 0.2.1__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 (65) hide show
  1. {python_mobius-0.1.4 → python_mobius-0.2.1}/CHANGELOG.md +227 -0
  2. {python_mobius-0.1.4 → python_mobius-0.2.1}/PKG-INFO +1 -1
  3. {python_mobius-0.1.4 → python_mobius-0.2.1}/documentation/00-overview.md +1 -1
  4. {python_mobius-0.1.4 → python_mobius-0.2.1}/documentation/06-light-schedule.md +58 -55
  5. python_mobius-0.2.1/documentation/09-thread-coap-relay.md +214 -0
  6. {python_mobius-0.1.4 → python_mobius-0.2.1}/documentation/10-known-gaps-and-open-questions.md +0 -11
  7. {python_mobius-0.1.4 → python_mobius-0.2.1}/documentation/11-device-status-attributes.md +57 -16
  8. python_mobius-0.2.1/documentation/12-device-identity-and-address-stability.md +105 -0
  9. {python_mobius-0.1.4 → python_mobius-0.2.1}/pyproject.toml +1 -1
  10. {python_mobius-0.1.4 → python_mobius-0.2.1}/src/mobius/__init__.py +21 -5
  11. python_mobius-0.2.1/src/mobius/cli.py +393 -0
  12. python_mobius-0.2.1/src/mobius/coap.py +253 -0
  13. {python_mobius-0.1.4 → python_mobius-0.2.1}/src/mobius/constants.py +17 -0
  14. {python_mobius-0.1.4 → python_mobius-0.2.1}/src/mobius/device.py +377 -66
  15. {python_mobius-0.1.4 → python_mobius-0.2.1}/src/mobius/device_status.py +45 -0
  16. {python_mobius-0.1.4 → python_mobius-0.2.1}/src/mobius/discovery.py +68 -0
  17. python_mobius-0.2.1/src/mobius/mesh_address.py +116 -0
  18. python_mobius-0.2.1/src/mobius/relay.py +319 -0
  19. python_mobius-0.2.1/tests/test_coap.py +191 -0
  20. python_mobius-0.2.1/tests/test_coap_indication_handling.py +160 -0
  21. {python_mobius-0.1.4 → python_mobius-0.2.1}/tests/test_device_summary.py +21 -7
  22. python_mobius-0.2.1/tests/test_device_time.py +125 -0
  23. python_mobius-0.2.1/tests/test_firmware_versions.py +228 -0
  24. python_mobius-0.2.1/tests/test_frame_sniffer.py +87 -0
  25. {python_mobius-0.1.4 → python_mobius-0.2.1}/tests/test_hyperdrive_device.py +20 -20
  26. {python_mobius-0.1.4 → python_mobius-0.2.1}/tests/test_light_intensity_branching.py +16 -0
  27. python_mobius-0.2.1/tests/test_mesh_address.py +126 -0
  28. python_mobius-0.2.1/tests/test_mesh_discovery.py +235 -0
  29. python_mobius-0.2.1/tests/test_mesh_discovery_direct_connect.py +180 -0
  30. python_mobius-0.2.1/tests/test_relay.py +388 -0
  31. python_mobius-0.1.4/documentation/09-thread-coap-relay.md +0 -78
  32. python_mobius-0.1.4/documentation/12-device-identity-and-address-stability.md +0 -114
  33. python_mobius-0.1.4/src/mobius/cli.py +0 -125
  34. python_mobius-0.1.4/tests/test_firmware_versions.py +0 -141
  35. {python_mobius-0.1.4 → python_mobius-0.2.1}/.gitignore +0 -0
  36. {python_mobius-0.1.4 → python_mobius-0.2.1}/LICENSE +0 -0
  37. {python_mobius-0.1.4 → python_mobius-0.2.1}/README.md +0 -0
  38. {python_mobius-0.1.4 → python_mobius-0.2.1}/documentation/01-ble-transport.md +0 -0
  39. {python_mobius-0.1.4 → python_mobius-0.2.1}/documentation/02-framing-and-crc.md +0 -0
  40. {python_mobius-0.1.4 → python_mobius-0.2.1}/documentation/03-attributes-and-opcodes.md +0 -0
  41. {python_mobius-0.1.4 → python_mobius-0.2.1}/documentation/04-device-identity.md +0 -0
  42. {python_mobius-0.1.4 → python_mobius-0.2.1}/documentation/05-scenes.md +0 -0
  43. {python_mobius-0.1.4 → python_mobius-0.2.1}/documentation/07-pump-schedule.md +0 -0
  44. {python_mobius-0.1.4 → python_mobius-0.2.1}/documentation/08-manufacturer-data.md +0 -0
  45. {python_mobius-0.1.4 → python_mobius-0.2.1}/src/mobius/crc.py +0 -0
  46. {python_mobius-0.1.4 → python_mobius-0.2.1}/src/mobius/frame.py +0 -0
  47. {python_mobius-0.1.4 → python_mobius-0.2.1}/src/mobius/manufacturer.py +0 -0
  48. {python_mobius-0.1.4 → python_mobius-0.2.1}/src/mobius/modifiers.py +0 -0
  49. {python_mobius-0.1.4 → python_mobius-0.2.1}/src/mobius/power.py +0 -0
  50. {python_mobius-0.1.4 → python_mobius-0.2.1}/src/mobius/pump_status.py +0 -0
  51. {python_mobius-0.1.4 → python_mobius-0.2.1}/src/mobius/schedule.py +0 -0
  52. {python_mobius-0.1.4 → python_mobius-0.2.1}/tests/test_connection_cleanup.py +0 -0
  53. {python_mobius-0.1.4 → python_mobius-0.2.1}/tests/test_device_status.py +0 -0
  54. {python_mobius-0.1.4 → python_mobius-0.2.1}/tests/test_frame.py +0 -0
  55. {python_mobius-0.1.4 → python_mobius-0.2.1}/tests/test_insolation_detection.py +0 -0
  56. {python_mobius-0.1.4 → python_mobius-0.2.1}/tests/test_light_schedule.py +0 -0
  57. {python_mobius-0.1.4 → python_mobius-0.2.1}/tests/test_manufacturer.py +0 -0
  58. {python_mobius-0.1.4 → python_mobius-0.2.1}/tests/test_manufacturer_lookup.py +0 -0
  59. {python_mobius-0.1.4 → python_mobius-0.2.1}/tests/test_modifiers.py +0 -0
  60. {python_mobius-0.1.4 → python_mobius-0.2.1}/tests/test_power.py +0 -0
  61. {python_mobius-0.1.4 → python_mobius-0.2.1}/tests/test_pump_schedule.py +0 -0
  62. {python_mobius-0.1.4 → python_mobius-0.2.1}/tests/test_pump_status.py +0 -0
  63. {python_mobius-0.1.4 → python_mobius-0.2.1}/tests/test_schedule_intensity_scalar.py +0 -0
  64. {python_mobius-0.1.4 → python_mobius-0.2.1}/tests/test_serial_decoding.py +0 -0
  65. {python_mobius-0.1.4 → python_mobius-0.2.1}/tests/test_serial_identity.py +0 -0
@@ -1,5 +1,232 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+
5
+ - **Fixed a real bug found via real hardware testing: some "get all
6
+ elements" responses were silently truncated to their first block.**
7
+ `get_attribute_raw()` returned only the first matching
8
+ `(attrId, index, count, values)` block in a response and discarded any
9
+ others -- a Radion light showing 8 firmware components in the official
10
+ app came back as only 5 via `get_firmware_versions()`, because that
11
+ light's `FirmwareVersion` response genuinely splits across two
12
+ separate blocks (one covering `MainMicroOS`/`MainMicroBootloader`,
13
+ another covering the `QCA4020`-prefixed types), not one block covering
14
+ every element. Added `get_attribute_raw_all()`/`get_attribute_all()`
15
+ (`MobiusDevice`, with a matching `RelayedMobiusDevice` override --
16
+ without one, callers built on these would have silently fallen through
17
+ to `MobiusDevice`'s own non-relay implementation when called on a
18
+ relayed device) -- return every matching block instead of just the
19
+ first. `get_firmware_versions()`, `get_hardware_info()`,
20
+ `get_channel_power_info()`, and `get_supported_channels()` are now
21
+ built on these rather than the single-block versions, merging every
22
+ block's values, each interpreted using its own block's starting index.
23
+ 5 new tests, including ones that specifically reproduce the two-block
24
+ scenario found on real hardware, both directly and through relay.
25
+
26
+ ## 0.2.0
27
+
28
+ Thread/CoAP device relay -- see
29
+ [documentation/09-thread-coap-relay.md](documentation/09-thread-coap-relay.md)
30
+ for the full protocol trace and implementation history. The `coap-relay`
31
+ branch has been merged into main as part of this release.
32
+
33
+ - **Root cause of relayed reads returning empty data found and fixed,**
34
+ after the user provided the raw smali disassembly for
35
+ `BaseConnection.java` -- JADX's decompiler had given up specifically on
36
+ `handleIndication()`, the one method that held the answer, but the
37
+ underlying bytecode was still traceable. Confirmed precisely: the real
38
+ CoAP relay response arrives on a completely separate opcode (`0xDF`/
39
+ `26`, `COAP_INDICATION_OPCODE` -- not the request's `25`, whose confirm
40
+ is a bare, meaningless ack the app itself discards, confirmed
41
+ byte-for-byte via real hardware testing showing exactly 1 byte in every
42
+ relayed read's outer response), matched purely by CoAP token
43
+ (`lambda$getSentRequest$6`: `coapRequest.getToken() ==
44
+ coapResponse.getToken()`), never by the outer FSCI message ID. Added
45
+ `MobiusDevice._register_coap_token()`/`wait_for_coap_response()` -- a
46
+ token-keyed pending-response registry, entirely separate from the
47
+ existing message-ID-based `self._pending` mechanism -- wired into
48
+ `_on_rx_final()`. `RelayedMobiusDevice._relay()` was rewritten around
49
+ this: registers the token before sending (eliminating any race against
50
+ the response arriving first), sends without waiting for the
51
+ meaningless confirm, and awaits the token-matched indication instead.
52
+ `test_relay.py` rewritten entirely for the new architecture (14 tests);
53
+ new dedicated `test_coap_indication_handling.py` for the token-registry
54
+ mechanism itself (9 tests). Verified end-to-end with a full mocked
55
+ round-trip (both the discarded ack and the real indication arriving,
56
+ in that order) before asking for another real hardware run -- **this is
57
+ the next real test.**
58
+
59
+ - **Real hardware testing confirmed Thread IS active on regular
60
+ VorTech/Radion devices** (a genuine, positive result -- `MeshLocalAddresses`
61
+ reads back a valid, non-zero address), **but the three-array peer
62
+ discovery mechanism (`discover_mesh_peers()`) is Cowboy-hub-specific**
63
+ (`FsciStatus.UnsupportedAttribute` on hardware with no Cowboy hub,
64
+ even though that same device's own address reads back fine). Added
65
+ `MobiusDevice.get_own_mesh_address()` and `mobius.discovery.
66
+ discover_mesh_peers_via_direct_connect()` as the alternative --
67
+ confirmed via `PeripheralConnection.java` to be exactly how the app
68
+ itself always learns a device's address (automatic on every
69
+ connection, not Cowboy-specific). Added `mobius-scan --build-peer-map`
70
+ to exercise this from the CLI. `discover_mesh_peers()` remains in
71
+ place as a cheap, fail-soft first attempt in case a Cowboy hub IS
72
+ present, but is no longer the primary/recommended discovery path. 10
73
+ new tests.
74
+
75
+ - **Fixed a real bug found via real hardware testing: `--relay-target`
76
+ was never actually wired to the new Cowboy-hub-free discovery
77
+ mechanism.** After adding `discover_mesh_peers_via_direct_connect()`,
78
+ a real `--relay-target` attempt still failed with "not found among
79
+ discovered peers" -- not because relay itself failed, but because the
80
+ CLI's target lookup only ever checked the Cowboy-hub-specific peer
81
+ list. `--relay-target` now falls back to a brief direct connection to
82
+ the target itself (`find_device_by_serial()` +
83
+ `get_own_mesh_address()`) if the Cowboy-hub lookup doesn't find it.
84
+
85
+ - **Added `--debug-relay`** after the fixed `--relay-target` reached the
86
+ target but `get_device_summary()` came back entirely empty with no
87
+ visible error. Turned out every attribute fetch WAS raising an
88
+ exception internally (a malformed CoAP response) -- silently, since
89
+ `get_device_summary()`'s own per-field fail-soft handling was catching
90
+ and discarding them without trace. Prints raw bytes at every layer of
91
+ a relayed request/response so this stops being invisible. Verified
92
+ with a mocked round-trip that debug output itself is correct before
93
+ asking for another real hardware run.
94
+
95
+ - **Confirmed directly from source why the response comes back
96
+ malformed: the immediate confirm to a relay request is explicitly
97
+ discarded by the app itself, on purpose.** `BaseConnection.java`'s
98
+ `handleBytes()` matches `opGroup==0xDF && opCode==25` unconditionally
99
+ and logs it only -- before the dispatcher ever checks whether it
100
+ resolves a pending request. Real hardware testing independently
101
+ confirmed this byte-for-byte: every relayed read's outer response is
102
+ exactly 1 byte (`0x00`), not a truncated response but the genuine
103
+ complete confirm, thrown away by the app too. The real response, if it
104
+ arrives at all, comes through a separate "indication" handled by
105
+ `handleIndication()` -- a method JADX couldn't decompile, a genuine
106
+ limit of what source tracing alone can determine here. Added
107
+ `MobiusDevice.on_any_frame` (fires for every parsed incoming frame,
108
+ not just ones resolving a pending request) and extended
109
+ `--debug-relay` to watch for 2 seconds after the initial confirm, to
110
+ find out empirically whether a second frame actually arrives. 4 new
111
+ tests.
112
+
113
+ - **Added `mobius.coap`**: CoAP (RFC 7252) protocol primitives --
114
+ `CoapRequestType`/`CoapMethod`/`CoapResponseCode` enums,
115
+ `encode_coap_request()`/`decode_coap_response()`. Pure functions, no
116
+ hardware dependency. Confirmed wire format traced directly from the
117
+ decompiled app (`Coap.java`/`CoapRequest.java`/`CoapResponse.java`) --
118
+ see the module's docstring for exactly which enum values are
119
+ confirmed-literal, cross-referenced from elsewhere in this project, or
120
+ inferred-only (deliberately not guessing at unconfirmed ones -- an
121
+ unrecognized response code decodes to a plain int, matching the real
122
+ app's own graceful fallback behavior). 15 new tests, including
123
+ specific coverage of the asymmetric IPv6 byte-reversal between
124
+ requests and responses, which is easy to get backwards.
125
+
126
+ - **Added `mobius.mesh_address`**: Thread mesh-local RLOC IPv6 address
127
+ construction (`build_rloc_address()`/`extract_short_address()`/
128
+ `mesh_local_prefix_from_own_address()`/`is_valid_mesh_address()`/
129
+ `is_short_address_derived()`). Pure functions, no hardware dependency.
130
+ Includes a genuinely surprising, verified-not-assumed finding: the
131
+ short-address portion is little-endian (confirmed via
132
+ `ByteUtilities.getShort()`'s explicit `ByteOrder.LITTLE_ENDIAN`), the
133
+ opposite of IPv6's own conventional big-endian byte order -- easy to
134
+ get backwards by assuming the IPv6 standard's own convention applies
135
+ here too. 15 new tests.
136
+
137
+ - **Added `MobiusDevice.discover_mesh_peers()`**: fetches
138
+ `MeshLocalAddresses` (1005) + the three parallel `ShortAddressArray`/
139
+ `SerialNumberArray`/`DeviceModelArray` attributes (3700/3701/3702)
140
+ from a connected device, returning every other device it knows about
141
+ over the Thread mesh as a list of new `MeshPeer` dataclasses (serial,
142
+ model, short address, ready-to-use IPv6). Confirms `SerialNumberArray`'s
143
+ per-element format matches the existing 14-byte ASCII serial format
144
+ already used elsewhere in this library -- no new parsing logic needed.
145
+ Filters out cleared/empty slots (the literal sentinel string
146
+ `"00000000000000"`, confirmed via `CowboyNetworkProcess.java`'s own
147
+ cleanup logic). Fails soft to an empty list for unsupported devices or
148
+ ones not currently part of an active Thread network, matching this
149
+ library's usual convention. Also recorded (but not yet used) a fourth
150
+ parallel array, `BladeBleAddressArray` (3714, each peer's real BLE MAC
151
+ address) -- found alongside the other three but not needed for CoAP
152
+ relay itself. 7 new tests.
153
+
154
+ - **Added `RelayedMobiusDevice`** (`mobius.relay`): a `MobiusDevice`
155
+ subclass that overrides `get_attribute()`/`get_attribute_raw()`/
156
+ `set_attribute()` to route through a gateway device via CoAP instead of
157
+ owning its own BLE connection. Every other inherited method
158
+ (`get_device_summary()`, `get_current_light_intensities()`, etc.) works
159
+ transparently through relay, confirmed with a test that specifically
160
+ exercises an inherited high-level method rather than just the
161
+ overridden primitives. Confirmed `request.getData(true)` builds a
162
+ complete self-contained FSCI frame (byte-for-byte identical in
163
+ structure to this library's own `build_frame()`), resolving an
164
+ ambiguity flagged in an earlier commit -- relaying a request needed no
165
+ new encoding path, just wrapping the same frame a direct request would
166
+ use. Also confirmed, directly from the one real call site in the
167
+ decompile, a subtle and easy-to-get-wrong detail: the outer CoAP method
168
+ is always `GET`, even for a relayed Set -- the actual operation is
169
+ determined by the inner FSCI opcode, not the CoAP transport layer. 13
170
+ new tests.
171
+
172
+ - **Added `mobius-scan --dump-mesh-peers` / `--relay-target SERIAL`**
173
+ (both usable alongside `--by-serial`): prints every peer a connected
174
+ device knows about over the Thread mesh, and/or relays a full
175
+ `get_device_summary()` read to a specified target through it. This is
176
+ the last of the 5 planned pieces for this feature -- **implementation
177
+ is now complete, but not yet validated against real hardware.** Run a
178
+ separate direct `--by-serial <target>` command to confirm a relayed
179
+ read actually matches a direct one; this is also how the biggest open
180
+ question (whether these devices run Thread at all) finally gets
181
+ answered.
182
+
183
+ - **Added a verbose, step-by-step mesh discovery diagnostic
184
+ (`_debug_mesh_discovery()`)** after real hardware testing showed every
185
+ device returning "No peers found" with no further detail.
186
+ `discover_mesh_peers()`'s fail-soft-to-empty-list behavior is correct
187
+ for production/library use, but collapses several genuinely different
188
+ failure modes into the same result -- device doesn't support Thread at
189
+ all, vs. supports it but isn't on an active network right now, vs.
190
+ the peer arrays came back empty for some other reason. `--dump-mesh-peers`
191
+ now surfaces each step's raw result or exception individually instead
192
+ of just the final empty-or-not outcome.
193
+
194
+ - **Added `get_device_time_info()`** (`Epoch`/`RTCTime`, attributes
195
+ 201/219) -- device clock drift vs. local system time. Not speculative:
196
+ confirmed via the app's own "Time Difference" troubleshooting feature
197
+ (`TimeDifferenceFragment.java`), which checks these exact two attributes
198
+ the same way. Included automatically in `get_device_summary()`'s
199
+ `"device_time"` field. Important caveat documented: the app's own
200
+ schedule interpolation uses the phone/tank clock, not either of these,
201
+ so this doesn't directly explain a schedule-interpolation mismatch by
202
+ itself -- still worth checking as a general health signal. 5 new tests.
203
+
204
+ - **Added `mobius-scan --dump-schedule`**: prints each connected device's
205
+ full raw programmed schedule (every point's time, flags -- ACTIVE/NIGHT/
206
+ SUNRISE/SUNSET -- and per-channel/mode values), not just the
207
+ interpolated "current" value `get_device_summary()` already shows. For
208
+ lights, also prints `lunar_enabled`/`insolation_active`/
209
+ `schedule_intensity`/`acclimation` state. Combines with either the
210
+ normal scan or `--by-serial`. Added so a schedule's actual point
211
+ structure can be inspected directly -- needed to verify
212
+ `is_night_segment()`'s bracket-finding logic against real data, rather
213
+ than guessed at or transcribed by hand.
214
+
215
+ - **Added debugging diagnostics to `get_current_light_intensities()`**:
216
+ `.diagnostics` now also includes `is_night_segment`, `lunar_enabled`,
217
+ `scalar_source` (`"lunar"`/`"night_no_lunar"`/`"schedule_intensity"`),
218
+ and `scalar` (the actual final multiplier applied). Added after a real
219
+ discrepancy was reported between this library's computed value and the
220
+ app's own displayed value for a light at night (app showing lunar-
221
+ reduced moonlight intensities, this library showing the unreduced
222
+ schedule-intensity value instead) -- these fields exist specifically so
223
+ that class of mismatch can be diagnosed with real data (which branch was
224
+ taken, and why) rather than guessed at blindly. Also threaded into
225
+ `get_device_summary()` as `"light_diagnostics"`, so `mobius-scan`
226
+ surfaces this directly -- debugging this kind of mismatch belongs in the
227
+ library/CLI, not in a specific downstream integration. Existing tests
228
+ updated to assert on these fields directly.
229
+
3
230
  ## 0.1.4
4
231
 
5
232
  - 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.1
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
@@ -49,7 +49,7 @@ Interface conventions, extended by EcoTech).
49
49
  | [06-light-schedule.md](./06-light-schedule.md) | Light point-schedule format + client-side interpolation |
50
50
  | [07-pump-schedule.md](./07-pump-schedule.md) | Pump primitive format, mode/parameter tables, block lookup |
51
51
  | [08-manufacturer-data.md](./08-manufacturer-data.md) | BLE advertisement format (no-connection discovery) |
52
- | [09-thread-coap-relay.md](./09-thread-coap-relay.md) | How the official app relays to devices without direct BLE (not implemented here) |
52
+ | [09-thread-coap-relay.md](./09-thread-coap-relay.md) | Relaying requests to devices without a direct BLE connection, through another connected device |
53
53
  | [10-known-gaps-and-open-questions.md](./10-known-gaps-and-open-questions.md) | Everything not yet confirmed or implemented |
54
54
  | [11-device-status-attributes.md](./11-device-status-attributes.md) | Flow range, override mode, battery backup, group/sync, calibration (light-only), maintenance |
55
55
  | [12-device-identity-and-address-stability.md](./12-device-identity-and-address-stability.md) | Why device identity is serial-number-based, not address-based |
@@ -8,18 +8,12 @@ There's no attribute you can `Get` that tells you what a light is currently
8
8
  displaying. Instead, the app fetches the entire programmed point-schedule
9
9
  and computes the current intensity **client-side**, by linearly
10
10
  interpolating between the two points that bracket the current time, then
11
- applying a schedule-level master intensity scalar on top (see below), plus
12
- acclimation-ramp and lunar-cycle modifiers this library still doesn't
13
- implement.
11
+ applying a schedule-level master intensity scalar (see below), plus
12
+ lunar-cycle and acclimation-ramp modifiers under specific conditions.
14
13
 
15
14
  This library's `get_current_light_intensities()` replicates that
16
- client-side computation. **Correction to an earlier version of this doc**:
17
- this was previously described as "confirmed correct by validating against
18
- real fetched schedules" -- that validation only checked the per-channel
19
- interpolation curve, not the schedule-level scalar below, which was
20
- missing entirely at the time and silently returned values that didn't
21
- match the app's real displayed numbers. Caught by direct comparison
22
- against real app screenshots. Fixed; see below.
15
+ client-side computation, including the master intensity scalar and the
16
+ lunar/acclimation modifiers.
23
17
 
24
18
  ## Schedule-level master intensity (`Schedule1Intensity`, attribute 511)
25
19
 
@@ -34,13 +28,13 @@ regular per-channel value some models expose, not this).
34
28
  `get_schedule_intensity()` fetches this (a plain `Get` on
35
29
  `Schedule1Intensity`/`Schedule2Intensity`, no special indexing -- a short,
36
30
  0-1000, default 500/50% per the app's own initialization code).
37
- `get_current_light_intensities()` now applies it automatically.
31
+ `get_current_light_intensities()` applies it automatically.
38
32
 
39
- **Update**: lunar-phase and acclimation-ramp reductions, which are layered
40
- on top of this same scalar under specific conditions (night + lunar phases
41
- enabled; an active, incomplete acclimation period), are now also
42
- implemented -- see "Lunar phase and acclimation ramp" below. "Insolation"
43
- (a separate, rarer modifier) is still not.
33
+ Lunar-phase and acclimation-ramp reductions layer on top of this same
34
+ scalar under specific conditions (night + lunar phases enabled; an
35
+ active, incomplete acclimation period) -- see "Lunar phase and
36
+ acclimation ramp" below. "Insolation" (a separate, rarer modifier) is
37
+ not implemented.
44
38
 
45
39
  ## Wire format
46
40
 
@@ -76,10 +70,8 @@ Repeating triplets, one per channel the point defines:
76
70
  VisualID (u8) + intensity (u16 LE, 0-1000 = 0.0-100.0%)
77
71
  ```
78
72
 
79
- Confirmed via a full 9-point schedule fetched live from two real XR15
80
- lights (identical schedules on both — a deliberately symmetric setup, not a
81
- parsing artifact, confirmed by the two lights having different serial
82
- numbers).
73
+ Confirmed against a full 9-point schedule fetched live from real XR15
74
+ lights.
83
75
 
84
76
  ## Interpolation algorithm
85
77
 
@@ -94,32 +86,29 @@ Ported from `PointSchedule.getIntensitiesAtTime()`:
94
86
  4. Channels absent from a point default to `0` (off).
95
87
 
96
88
  Verified against a synthetic two-point schedule (exact endpoint values,
97
- midpoint blending, and midnight-wraparound blending all behave correctly —
98
- see `tests/test_light_schedule.py`) and against a real 9-point schedule
99
- fetched live, which produced a coherent daily light-intensity curve
100
- (dim moonlight overnight → ramp to full blues/UV midday → wind back down).
89
+ midpoint blending, and midnight-wraparound blending -- see
90
+ `tests/test_light_schedule.py`) and against a real 9-point schedule
91
+ fetched live, which produces a coherent daily light-intensity curve (dim
92
+ moonlight overnight → ramp to full blues/UV midday → wind back down).
101
93
 
102
94
  ## `SupportedColorChannels` (attribute 901)
103
95
 
104
- A static list of `VisualID` values (channel identities) a given light has
105
- this is capability metadata, not live state. Confirmed via `Get` with
106
- `index=0, count=0xFFFF` against real hardware; both test lights reported
107
- the same 13-channel set (`Violet, CoolWhite, WarmWhite, Blue, UV, RoyalBlue,
108
- Green, Red, MoonlightBlue, MoonlightWhite, Brightness, StormProbability,
109
- CloudProbability`).
96
+ A static list of `VisualID` values (channel identities) a given light has
97
+ -- this is capability metadata, not live state. Confirmed via `Get` with
98
+ `index=0, count=0xFFFF` against real hardware.
110
99
 
111
100
  ## Lunar phase and acclimation ramp
112
101
 
113
- Both now implemented, on top of the schedule-level master intensity above.
102
+ Both implemented, on top of the schedule-level master intensity above.
114
103
  Ported from `LunarInfo.java`/`AcclimationInfo.java` in the decompiled app.
115
104
 
116
105
  **Lunar phase** (`lunar_percent_reduction(date)`): a simplified moon-phase
117
106
  model -- a Julian-Day-based calculation (`julian_day_from_date()`, the
118
107
  standard Fliegel-Van Flandern conversion) feeding a 16-entry lookup table
119
108
  (`LUNAR_CYCLE_SCALERS`), returning `0.0` at new moon ramping to `1.0` at
120
- full moon. Verified against real, independently-confirmed 2026 moon phase
121
- dates (full moon Jan 3 2026, new moon Jan 18 2026) -- the ported algorithm
122
- matches exactly.
109
+ full moon. Verified against independently-confirmed real 2026 moon phase
110
+ dates (full moon Jan 3 2026, new moon Jan 18 2026) -- the ported
111
+ algorithm matches exactly.
123
112
 
124
113
  This replaces the normal schedule-intensity scalar specifically during the
125
114
  **dusk-to-night segment** of the schedule (see `is_night_segment()` below),
@@ -129,10 +118,10 @@ matching the app's own fallback.
129
118
 
130
119
  **`is_night_segment()`**: ported from the boolean half of
131
120
  `PointSchedule.getIntensitiesAtTime()`'s return value. This is *not*
132
- simply "is it night" -- confirmed by reading the Java source directly, it's
133
- specifically true for the segment whose start point is NOT flagged `NIGHT`
134
- or `SUNRISE` and whose end point IS flagged `NIGHT` (the dusk-transition
135
- segment), or when the query time exactly matches a `NIGHT`-flagged point.
121
+ simply "is it night" -- it's specifically true for the segment whose
122
+ start point is NOT flagged `NIGHT` or `SUNRISE` and whose end point IS
123
+ flagged `NIGHT` (the dusk-transition segment), or when the query time
124
+ exactly matches a `NIGHT`-flagged point.
136
125
 
137
126
  **Acclimation ramp** (`AcclimationInfo`): a linear intensity ramp from
138
127
  `start_intensity` (0-1000 permille) up to full (1000) over `period_days`,
@@ -147,8 +136,8 @@ automatically -- no separate calls needed for the common case.
147
136
  ## Insolation detection (flagged, not implemented)
148
137
 
149
138
  Insolation itself is not implemented (see below), but
150
- `get_current_light_intensities()` does cheaply detect whether it's active
151
- and flag it, via `LightIntensityResult` -- the return value is a real
139
+ `get_current_light_intensities()` cheaply detects whether it's active
140
+ and flags it, via `LightIntensityResult` -- the return value is a real
152
141
  dict (fully backward compatible with code that indexes/iterates it like a
153
142
  plain dict), with an added `.diagnostics` attribute:
154
143
 
@@ -165,6 +154,27 @@ wrong-but-present value is often more useful than nothing, and the
165
154
  severity depends on how different the real Insolation-derived schedule is
166
155
  from the manually-programmed one on a given day.
167
156
 
157
+ ## Diagnostics: which branch/scalar was actually used
158
+
159
+ `.diagnostics` also includes `"is_night_segment"`, `"lunar_enabled"`,
160
+ `"scalar_source"` (one of `"lunar"`/`"night_no_lunar"`/
161
+ `"schedule_intensity"`), and `"scalar"` (the actual final multiplier
162
+ applied) -- useful for telling apart which branch/scalar produced a given
163
+ value, e.g. when a computed value doesn't match the app's own displayed
164
+ one and it isn't obvious whether `is_night_segment()` misjudged a
165
+ schedule's point structure or `get_lunar_enabled()` read incorrectly:
166
+
167
+ ```python
168
+ result = await device.get_current_light_intensities()
169
+ print(result.diagnostics)
170
+ # {'insolation_active': False, 'is_night_segment': True,
171
+ # 'lunar_enabled': True, 'scalar_source': 'lunar', 'scalar': 0.35}
172
+ ```
173
+
174
+ `lunar_enabled` is `None` (not `False`) when `is_night_segment` is
175
+ `False`, since it isn't checked/relevant outside the night segment --
176
+ don't read a `None` here as "lunar phases are off."
177
+
168
178
  ## Hyperdrive (boosted channels above 100%)
169
179
 
170
180
  Some light channels can be driven above their normally-rated power
@@ -174,26 +184,19 @@ intensity values above `1000` permille (100%) on the wire, up to `2000`
174
184
  percentage is wrong once it's above 1000 -- the true percentage depends on
175
185
  how much headroom that specific channel actually has.
176
186
 
177
- `get_current_light_intensities()` (and `interpolate_light_schedule()`
178
- underneath it) already handled boosted raw values correctly all along --
179
- nothing was capped or clamped, so the interpolation itself was never
180
- wrong. What was missing was converting a boosted raw value to an accurate
181
- *percentage* for display.
182
-
183
- `get_current_light_percentages()` is the corrected alternative: it fetches
187
+ `get_current_light_percentages()` accounts for this: it fetches
184
188
  `MaxPower`/`NormalPower` (`get_channel_power_info()`, attributes `1504`/
185
189
  `1513`) and applies `channel_percent_value()` (`mobius.power`, ported from
186
- `LightPowerInfo.getPercentValue()`) per channel. For the overwhelming
187
- common case -- non-hyperdrive channels/devices, i.e. any raw value at or
188
- below 1000 -- this gives byte-identical results to the old `raw/10`
189
- shortcut; the extra machinery only changes anything for genuinely boosted
190
- channels. It also flags which channels are currently boosted via
191
- `.diagnostics["hyperdrive_channels"]`.
190
+ `LightPowerInfo.getPercentValue()`) per channel. For the common case --
191
+ non-hyperdrive channels/devices, i.e. any raw value at or below 1000 --
192
+ this gives the same results as a simple `raw/10`; the extra machinery
193
+ only changes anything for genuinely boosted channels. It also flags which
194
+ channels are currently boosted via `.diagnostics["hyperdrive_channels"]`.
192
195
 
193
196
  This costs extra BLE round trips beyond `get_current_light_intensities()`
194
197
  alone (two more `Get`s for `MaxPower`/`NormalPower`). Use
195
- `get_current_light_intensities()` directly if you don't need hyperdrive
196
- accuracy or want to minimize round trips.
198
+ `get_current_light_intensities()` directly if hyperdrive accuracy isn't
199
+ needed, or to minimize round trips.
197
200
 
198
201
  **Not replicated**: `LightPowerInfo`'s `gm8b4()` lookup table -- a
199
202
  hardcoded wattage override for a handful of specific AquaIllumination
@@ -0,0 +1,214 @@
1
+ # Thread/CoAP device relay
2
+
3
+ Every device this library talks to normally needs its own direct,
4
+ persistent BLE connection. The official app doesn't work this way -- it
5
+ connects to just one or a few devices and reaches the rest through them.
6
+ If something else (this library, a downstream integration, or another
7
+ instance of the app) is already holding direct BLE connections to every
8
+ device, the app can end up with nothing to connect to at all. Relay lets
9
+ this library behave the same way the app does: connect to one device,
10
+ reach the others through it.
11
+
12
+ `mobius.relay.RelayedMobiusDevice` implements this. Every method on
13
+ `MobiusDevice` (`get_device_summary()`, `get_current_light_intensities()`,
14
+ everything) works transparently through it, since all of them are built
15
+ in terms of `get_attribute()`/`get_attribute_raw()`/`set_attribute()` --
16
+ the three methods `RelayedMobiusDevice` overrides.
17
+
18
+ ## How it works
19
+
20
+ Devices form a Thread mesh network (802.15.4 radio, separate hardware
21
+ from BLE) identified by a `pan_id` (see
22
+ [08-manufacturer-data.md](./08-manufacturer-data.md)). The Thread
23
+ border-router function lives on the connected device itself, not on the
24
+ phone -- a phone (or a laptop) never needs its own 802.15.4 radio, only
25
+ BLE to reach one mesh member, which does the Thread-side forwarding
26
+ using its own onboard hardware.
27
+
28
+ Routing decision, from `Comm.java`'s `sendRequest()`:
29
+
30
+ ```java
31
+ if (commDevice.equals(request.getTarget())) {
32
+ // direct: send over the existing BLE connection as-is
33
+ } else if (iPv6 != null) {
34
+ // relay: wrap in CoAP, address it to the target's mesh IPv6,
35
+ // send through commDevice (the connected gateway)
36
+ } else {
37
+ // no known address for the target -- fail
38
+ }
39
+ ```
40
+
41
+ ## CoAP wire format
42
+
43
+ Reuses the same C2CI envelope described in
44
+ [02-framing-and-crc.md](./02-framing-and-crc.md), with two new opcodes
45
+ alongside the existing Get(`0x17`)/Set(`0x18`) attribute opcodes:
46
+
47
+ - **`25`** (`mobius.coap.COAP_OPCODE`) -- the request opcode. Its
48
+ confirm is a bare, meaningless 1-byte ack (`Coap.RequestType`/etc. are
49
+ set, but the payload carries no real data) -- the app itself
50
+ unconditionally discards it in `BaseConnection.java`'s `handleBytes()`
51
+ before ever checking whether it resolves a pending request. This
52
+ library sends on this opcode without waiting for its confirm
53
+ (`wait_response=False`).
54
+ - **`26`** (`mobius.coap.COAP_INDICATION_OPCODE`) -- where the real
55
+ response actually arrives, as a separate, asynchronous message
56
+ ("indication"). Matched to its originating request purely by CoAP
57
+ token, from `BaseConnection.java`'s `lambda$getSentRequest$6`:
58
+ `coapRequest.getToken() == coapResponse.getToken()`. The outer FSCI
59
+ message ID is never used for this correlation -- it only has meaning
60
+ for the single BLE hop to the gateway, not across a full Thread mesh
61
+ round-trip.
62
+
63
+ **Request** (`encode_coap_request()`) -- `CoapRequest`'s constructor:
64
+ ```
65
+ IPv6(16 bytes, REVERSED) + token(4, LE) + method(2, LE)
66
+ + payload_length(2, LE) + request_type(1) + payload
67
+ ```
68
+
69
+ **Response** (`decode_coap_response()`) -- `CoapResponse`'s constructor:
70
+ ```
71
+ IPv6(16 bytes, NOT reversed) + token(4, LE) + response_code(2, LE)
72
+ + payload_length(2, LE) + request_type(1) + payload
73
+ ```
74
+
75
+ The IPv6 reversal is asymmetric: requests reverse the address, responses
76
+ don't. Easy to get backwards -- `mobius.coap`'s test suite specifically
77
+ checks this with a deliberately non-palindromic test address, since a
78
+ palindromic one would still pass even with the reversal backwards or
79
+ missing.
80
+
81
+ **The payload is a complete, self-contained FSCI frame** -- byte-for-byte
82
+ identical in structure to what this library's own `build_frame()`
83
+ produces for a direct request (`opGroup + opCode + messageId(2) +
84
+ reserved(1) + dataLength(2) + data + CRC16(2)`, prefixed with the magic
85
+ byte `0x02`). Relaying a request is exactly as simple as that sounds:
86
+ build the request the normal way, wrap that complete frame wholesale as
87
+ the CoAP payload, and send it to the gateway on the CoAP opcode instead
88
+ of sending it directly. No separate encoding path for relayed vs. direct
89
+ requests -- only the outer envelope differs.
90
+
91
+ The CoAP method field is always `GET`, regardless of whether the
92
+ relayed operation is itself a Get or Set attribute request -- the CoAP
93
+ method is a generic transport-layer wrapper; the actual semantic
94
+ operation is determined by the *inner* FSCI opcode.
95
+
96
+ `Coap.ResponseCode` values are standard RFC 7252 codes (`2.01 Created`,
97
+ `2.05 Content`, `4.00 Bad Request`, `5.00 Internal Server Error`, etc.,
98
+ encoded as `class*100 + detail`) -- but `mobius.coap.CoapResponseCode`
99
+ only defines the subset that could be independently confirmed as
100
+ literal or reliably cross-referenced values (see that module's
101
+ docstring for exactly which). An unrecognized response code decodes to
102
+ a plain `int` rather than a guessed enum member, matching the real app's
103
+ own graceful fallback behavior. In practice, the response code isn't
104
+ load-bearing for this library -- the payload's own embedded FSCI frame
105
+ carries its own CRC, which is what's actually checked.
106
+
107
+ ## Response correlation implementation
108
+
109
+ `MobiusDevice` maintains a token-keyed pending-response registry
110
+ (`_pending_coap_tokens`), separate from the existing message-ID-based
111
+ `_pending` used for direct requests:
112
+
113
+ - `_register_coap_token(token)` -- synchronous, registers and returns a
114
+ future immediately. Call this *before* sending the triggering request,
115
+ eliminating any race against the response arriving first.
116
+ - `wait_for_coap_response(token, timeout)` -- convenience wrapper that
117
+ registers and awaits in one call; only race-free if nothing could
118
+ plausibly respond before it starts running.
119
+ - `_on_rx_final()` resolves a pending token's future when a frame
120
+ matching `opGroup==0xDF && opCode==26` arrives and decodes to a
121
+ `CoapResponse` with a matching token -- entirely independent of the
122
+ normal message-ID-based resolution used for direct requests.
123
+
124
+ `RelayedMobiusDevice._relay()` registers the token, sends the outer
125
+ frame without waiting for its confirm, and awaits the token-matched
126
+ indication with a timeout.
127
+
128
+ ## Address discovery
129
+
130
+ A device's Thread mesh-local RLOC (Routing Locator) IPv6 address:
131
+
132
+ ```
133
+ mesh_local_prefix(8 bytes) + 00:00(2 bytes) + 00:FF:FE:00(4 bytes)
134
+ + short_address(2 bytes, LITTLE-ENDIAN)
135
+ ```
136
+
137
+ The `00:FF:FE:00` marker is the standard Thread/6LoWPAN convention for a
138
+ short-address-derived interface identifier (confirmed via
139
+ `IPv6.isEui64Address()`'s exact byte check) -- a real, publicly
140
+ documented Thread networking convention, not proprietary to EcoTech. The
141
+ little-endian byte order for the short-address portion is the opposite
142
+ of IPv6's own conventional big-endian (network byte order) -- confirmed
143
+ via `ByteUtilities.getShort()`'s explicit `ByteOrder.LITTLE_ENDIAN`, not
144
+ assumed.
145
+
146
+ `mobius.mesh_address` implements construction (`build_rloc_address()`)
147
+ and the reverse (`extract_short_address()`), plus
148
+ `mesh_local_prefix_from_own_address()`/`is_valid_mesh_address()` for
149
+ deriving a network's shared prefix from any one device's own address.
150
+
151
+ ### Two discovery mechanisms
152
+
153
+ **`MobiusDevice.get_own_mesh_address()`** -- fetches `MeshLocalAddresses`
154
+ (attribute `1005`) from the connected device itself. Returns that
155
+ device's own complete 16-byte mesh-local address (not "the prefix" on
156
+ its own -- extract the first 8 bytes yourself for the shared network
157
+ prefix), sanity-checked as non-all-zero. Confirmed via
158
+ `PeripheralConnection.java`'s `updateConnectionState()` ->
159
+ `getDeviceInfo()` to be exactly what the official app does automatically
160
+ on every connection to any non-legacy device -- universal, standard
161
+ behavior, not tied to any specific hardware.
162
+
163
+ **`mobius.discovery.discover_mesh_peers_via_direct_connect()`** -- the
164
+ practical way to build a serial→IPv6 map across multiple devices:
165
+ connects directly and briefly to each one in turn (sequentially, not
166
+ concurrently), calling `get_own_mesh_address()` on each. Meant to be run
167
+ once (or occasionally) to build the map, not on every poll cycle -- keep
168
+ only one connection open afterward (the "gateway") and reach the rest via
169
+ `RelayedMobiusDevice`.
170
+
171
+ **`MobiusDevice.discover_mesh_peers()`** -- an alternative that asks a
172
+ *connected* device to report on other devices it already knows about,
173
+ via three parallel attributes:
174
+ `ShortAddressArray`/`SerialNumberArray`/`DeviceModelArray` (`3700`/
175
+ `3701`/`3702`, fetched via the same "get all elements" pattern used
176
+ elsewhere in this library, index-matched). `SerialNumberArray`'s
177
+ per-element format is the same 14-byte ASCII serial format used
178
+ elsewhere in this library; a cleared/empty slot is the literal sentinel
179
+ string `"00000000000000"` (fourteen ASCII zero characters, not null
180
+ bytes), filtered out automatically. A fourth parallel array,
181
+ `BladeBleAddressArray` (`3714`, each peer's real BLE MAC address), is
182
+ recorded in `constants.py` but not used by this method.
183
+
184
+ **This mechanism requires a dedicated Cowboy hub device.** These three
185
+ arrays were originally found in `CowboyNetworkProcess.java` -- the class
186
+ specifically for setting up a network via a Cowboy hub -- and on
187
+ hardware without one, all three return `FsciStatus.UnsupportedAttribute`
188
+ even though `MeshLocalAddresses` on that same device succeeds. Cheap and
189
+ fails soft, so it's a reasonable first attempt, but
190
+ `discover_mesh_peers_via_direct_connect()` is the mechanism to actually
191
+ rely on unless a Cowboy hub is confirmed present.
192
+
193
+ ## CLI support
194
+
195
+ `mobius-scan` flags for working with relay against real hardware:
196
+
197
+ - `--dump-mesh-peers` -- runs `discover_mesh_peers()` (the Cowboy-hub
198
+ path) with verbose, step-by-step output at each stage (own address,
199
+ each of the three arrays), rather than collapsing every possible
200
+ failure into a single empty result.
201
+ - `--build-peer-map` -- runs `discover_mesh_peers_via_direct_connect()`
202
+ across every device found in a scan.
203
+ - `--relay-target SERIAL` (used with `--by-serial <gateway>`) -- looks
204
+ for the target among the gateway's Cowboy-tracked peers first; if not
205
+ found there, falls back to a brief direct connection to the target
206
+ itself to learn its address. Either way, relays a full
207
+ `get_device_summary()` read to it and prints the result.
208
+ - `--debug-relay` (with `--relay-target`) -- prints raw bytes at every
209
+ layer of a relayed request/response: the inner request frame, the
210
+ outer request frame, every frame the gateway receives while waiting
211
+ (via `MobiusDevice.on_any_frame`, a hook called for every parsed
212
+ incoming frame regardless of whether it resolves anything), the
213
+ decoded CoAP envelope, the inner response frame, and the decoded
214
+ attribute status/values.