python-mobius 0.2.0__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 (64) hide show
  1. {python_mobius-0.2.0 → python_mobius-0.2.1}/CHANGELOG.md +23 -0
  2. {python_mobius-0.2.0 → python_mobius-0.2.1}/PKG-INFO +1 -1
  3. {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/00-overview.md +1 -1
  4. {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/06-light-schedule.md +42 -63
  5. python_mobius-0.2.1/documentation/09-thread-coap-relay.md +214 -0
  6. {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/10-known-gaps-and-open-questions.md +0 -11
  7. {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/11-device-status-attributes.md +33 -16
  8. python_mobius-0.2.1/documentation/12-device-identity-and-address-stability.md +105 -0
  9. {python_mobius-0.2.0 → python_mobius-0.2.1}/pyproject.toml +1 -1
  10. {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/__init__.py +1 -1
  11. {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/device.py +112 -59
  12. {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/relay.py +38 -8
  13. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_device_summary.py +9 -7
  14. python_mobius-0.2.1/tests/test_firmware_versions.py +228 -0
  15. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_hyperdrive_device.py +20 -20
  16. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_relay.py +75 -0
  17. python_mobius-0.2.0/documentation/09-thread-coap-relay.md +0 -337
  18. python_mobius-0.2.0/documentation/12-device-identity-and-address-stability.md +0 -114
  19. python_mobius-0.2.0/tests/test_firmware_versions.py +0 -141
  20. {python_mobius-0.2.0 → python_mobius-0.2.1}/.gitignore +0 -0
  21. {python_mobius-0.2.0 → python_mobius-0.2.1}/LICENSE +0 -0
  22. {python_mobius-0.2.0 → python_mobius-0.2.1}/README.md +0 -0
  23. {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/01-ble-transport.md +0 -0
  24. {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/02-framing-and-crc.md +0 -0
  25. {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/03-attributes-and-opcodes.md +0 -0
  26. {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/04-device-identity.md +0 -0
  27. {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/05-scenes.md +0 -0
  28. {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/07-pump-schedule.md +0 -0
  29. {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/08-manufacturer-data.md +0 -0
  30. {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/cli.py +0 -0
  31. {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/coap.py +0 -0
  32. {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/constants.py +0 -0
  33. {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/crc.py +0 -0
  34. {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/device_status.py +0 -0
  35. {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/discovery.py +0 -0
  36. {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/frame.py +0 -0
  37. {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/manufacturer.py +0 -0
  38. {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/mesh_address.py +0 -0
  39. {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/modifiers.py +0 -0
  40. {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/power.py +0 -0
  41. {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/pump_status.py +0 -0
  42. {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/schedule.py +0 -0
  43. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_coap.py +0 -0
  44. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_coap_indication_handling.py +0 -0
  45. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_connection_cleanup.py +0 -0
  46. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_device_status.py +0 -0
  47. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_device_time.py +0 -0
  48. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_frame.py +0 -0
  49. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_frame_sniffer.py +0 -0
  50. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_insolation_detection.py +0 -0
  51. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_light_intensity_branching.py +0 -0
  52. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_light_schedule.py +0 -0
  53. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_manufacturer.py +0 -0
  54. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_manufacturer_lookup.py +0 -0
  55. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_mesh_address.py +0 -0
  56. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_mesh_discovery.py +0 -0
  57. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_mesh_discovery_direct_connect.py +0 -0
  58. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_modifiers.py +0 -0
  59. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_power.py +0 -0
  60. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_pump_schedule.py +0 -0
  61. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_pump_status.py +0 -0
  62. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_schedule_intensity_scalar.py +0 -0
  63. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_serial_decoding.py +0 -0
  64. {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_serial_identity.py +0 -0
@@ -1,5 +1,28 @@
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
+
3
26
  ## 0.2.0
4
27
 
5
28
  Thread/CoAP device relay -- see
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mobius
3
- Version: 0.2.0
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,18 +154,15 @@ 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
 
168
- ## Debugging diagnostics: which branch/scalar was actually used
157
+ ## Diagnostics: which branch/scalar was actually used
169
158
 
170
159
  `.diagnostics` also includes `"is_night_segment"`, `"lunar_enabled"`,
171
160
  `"scalar_source"` (one of `"lunar"`/`"night_no_lunar"`/
172
161
  `"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:
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:
180
166
 
181
167
  ```python
182
168
  result = await device.get_current_light_intensities()
@@ -198,26 +184,19 @@ intensity values above `1000` permille (100%) on the wire, up to `2000`
198
184
  percentage is wrong once it's above 1000 -- the true percentage depends on
199
185
  how much headroom that specific channel actually has.
200
186
 
201
- `get_current_light_intensities()` (and `interpolate_light_schedule()`
202
- underneath it) already handled boosted raw values correctly all along --
203
- nothing was capped or clamped, so the interpolation itself was never
204
- wrong. What was missing was converting a boosted raw value to an accurate
205
- *percentage* for display.
206
-
207
- `get_current_light_percentages()` is the corrected alternative: it fetches
187
+ `get_current_light_percentages()` accounts for this: it fetches
208
188
  `MaxPower`/`NormalPower` (`get_channel_power_info()`, attributes `1504`/
209
189
  `1513`) and applies `channel_percent_value()` (`mobius.power`, ported from
210
- `LightPowerInfo.getPercentValue()`) per channel. For the overwhelming
211
- common case -- non-hyperdrive channels/devices, i.e. any raw value at or
212
- below 1000 -- this gives byte-identical results to the old `raw/10`
213
- shortcut; the extra machinery only changes anything for genuinely boosted
214
- channels. It also flags which channels are currently boosted via
215
- `.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"]`.
216
195
 
217
196
  This costs extra BLE round trips beyond `get_current_light_intensities()`
218
197
  alone (two more `Get`s for `MaxPower`/`NormalPower`). Use
219
- `get_current_light_intensities()` directly if you don't need hyperdrive
220
- 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.
221
200
 
222
201
  **Not replicated**: `LightPowerInfo`'s `gm8b4()` lookup table -- a
223
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.
@@ -51,17 +51,6 @@ included as a starting point.
51
51
  The write path would need the same `Set`-attribute mechanism already
52
52
  implemented for scenes, applied to `Schedule1`/`Schedule2`, but hasn't
53
53
  been built or tested.
54
- - **Thread/CoAP device relay** — see
55
- [09-thread-coap-relay.md](./09-thread-coap-relay.md) for the full
56
- writeup of why and what it would take.
57
- - **Acclimation ramp / lunar cycle modifiers** for light interpolation —
58
- the app layers these on top of the raw schedule interpolation; this
59
- library returns the raw interpolated value only.
60
- - **`FirmwareVersion`/`HardwareRevision`** — these are sub-indexed per
61
- `FirmwareType`/`HardwareInfo` (the same "attribute + index" pattern as
62
- `PhysicalValues`), adding real complexity that wasn't worth it for the
63
- currently-covered use cases. Straightforward to add following the same
64
- pattern as `PhysicalValues`/`GallonsPerHour`.
65
54
  - **Group/master pump relationships** (`Sync`/`EcoSmartBack` modes'
66
55
  `Master` parameter) — the raw 8-byte BLE-address-suffix is returned, but
67
56
  matching it against known devices in a scan result is left to the
@@ -55,14 +55,11 @@ situation as `MotorRPM`. `group_master` is returned as raw bytes rather
55
55
  than interpreted. **Confirmed against real hardware to be 28 bytes when
56
56
  populated** (a real Radion light returned a 28-byte all-zero value; real
57
57
  VorTech pumps tested returned nothing for either attribute, i.e.
58
- unsupported) -- this corrects an earlier guess here that it was probably
59
- 8 bytes by loose analogy to the `Master` field in `Sync`/`EcoSmartBack`
60
- pump modes (see [07-pump-schedule.md](./07-pump-schedule.md)); that
61
- analogy was wrong. The all-zero value observed is consistent with "no
62
- group master set," but the field's actual semantics remain unconfirmed --
63
- still dead in the app itself. `is_group_master` uses the
64
- single-byte-boolean convention confirmed consistently across every other
65
- boolean attribute in this protocol.
58
+ unsupported). The all-zero value observed is consistent with "no group
59
+ master set," but the field's actual semantics remain unconfirmed -- still
60
+ dead in the app itself. `is_group_master` uses the single-byte-boolean
61
+ convention confirmed consistently across every other boolean attribute in
62
+ this protocol.
66
63
 
67
64
  ## Calibration (`get_calibration_info()`) -- a LIGHT feature, not a pump one
68
65
 
@@ -119,6 +116,25 @@ element per component the device has, the same "get all elements" pattern
119
116
  as `MaxPower`/`NormalPower`/`SupportedColorChannels`. The sub-index is
120
117
  `FirmwareType`/`HardwareInfo` respectively (see `mobius.constants`).
121
118
 
119
+ **A single such response can split across more than one block.**
120
+ Confirmed via real hardware testing: a Radion light showing 8 firmware
121
+ components in the official app initially came back with only 5 via this
122
+ library, because the underlying transport method returned just the
123
+ first `(attrId, index, count, values)` block in the response and
124
+ silently discarded the rest -- this device's `FirmwareVersion` response
125
+ genuinely arrives as two separate blocks (one covering
126
+ `MainMicroOS`/`MainMicroBootloader`, another covering the
127
+ `QCA4020`-prefixed types), not one block covering every element. Fixed
128
+ via `get_attribute_raw_all()`/`get_attribute_all()` (`MobiusDevice`, and
129
+ `RelayedMobiusDevice` with a matching override for the same reason),
130
+ which return every matching block rather than just the first;
131
+ `get_firmware_versions()`, `get_hardware_info()`,
132
+ `get_channel_power_info()`, and `get_supported_channels()` are all built
133
+ on these rather than the single-block `get_attribute_raw()`/
134
+ `get_attribute()`, and merge every block's values together, each
135
+ interpreted using its own block's starting index (never assuming one
136
+ single starting index covers every returned value).
137
+
122
138
  **Firmware version display labels are fully confirmed against real
123
139
  hardware.** `FirmwareType.name(Model)` in the decompiled app maps each
124
140
  `FirmwareType` value to the exact label shown in the UI, gated by
@@ -138,14 +154,15 @@ branch), confirmed to match real output exactly:
138
154
  | `QCA4020WLAN` | 9 | WLAN |
139
155
 
140
156
  A real VorTech pump showed exactly Radio/Radio Bootloader/Product
141
- OS/Product Bootloader (values 1-4); a real Radion light showed exactly
142
- Product OS/Product Bootloader/Radio Firmware/Filesystem/Radio OS/Radio/
143
- WLAN (values 3-9, confirming lights use a QCA4020-based radio chip with
144
- WiFi+BLE, unlike the simpler radio in pumps). `get_firmware_versions()`
145
- takes an optional `model` parameter to apply these confirmed labels;
146
- without it (or for non-EcoTech models, whose label mapping in the
147
- decompile is a different, unverified branch), falls back to the raw
148
- `FirmwareType` enum name.
157
+ OS/Product Bootloader (values 1-4, in one block). A real Radion light
158
+ showed Product OS/Product Bootloader/Radio Firmware/Filesystem/Radio
159
+ OS/Radio/WLAN (values 3-9, confirming lights use a QCA4020-based radio
160
+ chip with WiFi+BLE, unlike the simpler radio in pumps) -- split across
161
+ two blocks as described above, not one. `get_firmware_versions()` takes
162
+ an optional `model` parameter to apply these confirmed labels; without
163
+ it (or for non-EcoTech models, whose label mapping in the decompile is a
164
+ different, unverified branch), falls back to the raw `FirmwareType` enum
165
+ name.
149
166
 
150
167
  Each version is a **dot-joined string built directly from the raw
151
168
  response bytes** (e.g. `[4, 0, 21]` -> `"4.0.21"`) -- confirmed via
@@ -0,0 +1,105 @@
1
+ # Device identity and BLE address stability
2
+
3
+ ## Device identity is based on serial number, not BLE address
4
+
5
+ A device's BLE MAC address is not a stable identifier -- it can change
6
+ between connections (confirmed via `Peripheral.java` in the decompiled
7
+ app, and independently via real-world testing showing a device's
8
+ advertised address change between runs while its serial number stayed
9
+ the same). The official app relies entirely on serial number for device
10
+ identity, never on address stability:
11
+
12
+ ```java
13
+ // Peripheral.java
14
+ public int hashCode() {
15
+ return getDevice().serialNumber.hashCode();
16
+ }
17
+
18
+ public boolean equals(Object obj) {
19
+ return obj != null && (obj instanceof Peripheral)
20
+ && ((Peripheral) obj).getDevice().serialNumber.equals(getDevice().serialNumber);
21
+ }
22
+
23
+ public void update(Peripheral peripheral) {
24
+ setBluetoothDevice(peripheral.getBluetoothDevice()); // <-- replaces the address
25
+ setRssi(peripheral.getRssi());
26
+ setScanRecord(peripheral.getScanRecord());
27
+ setDevice(peripheral.getDevice());
28
+ this.lastSeen = new Date();
29
+ }
30
+ ```
31
+
32
+ `equals()`/`hashCode()` never reference the BLE address at all -- only
33
+ `serialNumber`. `update()`, called whenever a new scan result matches an
34
+ *existing* `Peripheral` by serial number, explicitly **overwrites** the
35
+ stored `BluetoothDevice` (which encapsulates the address) with whatever
36
+ came from the new scan result. This is the app's designed architecture
37
+ for handling devices whose BLE address may change over time (plausibly
38
+ BLE resolvable-private-address rotation, though this library doesn't
39
+ need to know the mechanism, only that the address isn't a safe long-term
40
+ identifier).
41
+
42
+ ## API
43
+
44
+ - **`find_device_by_serial(serial, timeout=10.0, adapter=None)`**
45
+ (`mobius.discovery`): scans and returns whichever device is *currently*
46
+ advertising the given serial number, regardless of its current address.
47
+ - **`dedupe_by_serial(devices)`** (`mobius.discovery`): if the same serial
48
+ appears twice in one scan result set (plausible if caught mid-rotation),
49
+ keeps only the first entry. Applied automatically in the CLI.
50
+ - **`MobiusDevice(device=None, serial=None, ...)`**: `serial` is an
51
+ optional parameter; at least one of `device` or `serial` must be
52
+ provided.
53
+ - If a `BLEDevice` is given, it's used directly for the *first* connect
54
+ (no extra scan needed). If `serial` is also set, that `BLEDevice` is
55
+ discarded after first use: any *subsequent* `connect()` on the same
56
+ instance (e.g. a coordinator reusing one `MobiusDevice` object across
57
+ multiple poll cycles) re-resolves the current address via
58
+ `find_device_by_serial()` instead of assuming the original address is
59
+ still valid.
60
+ - If only `serial` is given (no `device`), every `connect()` resolves
61
+ the current address via serial from scratch.
62
+ - If only a bare address string is given (no `serial`), reconnection
63
+ re-scans by that *fixed* address. This path is only as reliable as
64
+ the assumption that the address doesn't change; prefer passing
65
+ `serial` if that isn't certain.
66
+
67
+ Passing both `device` and `serial` into one `MobiusDevice` and expecting
68
+ that to give serial-based robustness on every connect is a common
69
+ mistake: a directly-provided `BLEDevice` is always used on the *first*
70
+ connect regardless of whether `serial` is also set. If something only
71
+ ever calls `connect()` once per `MobiusDevice` instance (e.g. a
72
+ scan-then-connect-once loop over several devices), passing `serial=`
73
+ *alone* (omitting `device`) forces a fresh resolution immediately before
74
+ each connect, rather than trusting a scan-time `BLEDevice` reference that
75
+ could be stale by the time it's actually used.
76
+
77
+ ## In practice
78
+
79
+ **Prefer passing `serial` whenever it's available** (which is almost
80
+ always -- it's right there in the BLE advertisement's manufacturer data,
81
+ no connection needed to read it; see
82
+ [08-manufacturer-data.md](./08-manufacturer-data.md)), especially for any
83
+ `MobiusDevice` instance reused across multiple connect cycles (like a
84
+ polling coordinator) or held onto for more than a single short-lived
85
+ scan-then-connect sequence.
86
+
87
+ The CLI's `--by-serial SERIAL` flag resolves the current address for a
88
+ given serial from scratch and connects, independent of whatever address
89
+ may have been seen previously.
90
+
91
+ `ha-mobius` uses serial-based identity throughout: config entries store
92
+ `CONF_SERIAL` alongside `CONF_ADDRESS`, `MobiusConnectionManager` resolves
93
+ the current address via Home Assistant's own Bluetooth cache (matching
94
+ by serial) rather than a fixed address, and setup fails cleanly (asking
95
+ for re-configuration) for any entry that predates this and lacks a stored
96
+ serial.
97
+
98
+ ## Still open
99
+
100
+ Not confirmed whether the address changes seen are BLE address-privacy
101
+ rotation specifically (vs. some other cause -- a firmware update, a
102
+ factory reset, etc.). This library doesn't need to know the mechanism to
103
+ handle it correctly, but it would be useful to know for certain --
104
+ watching a device's address over time with a tool like
105
+ `bluetoothctl scan on`, independent of this library, would settle it.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-mobius"
7
- version = "0.2.0"
7
+ version = "0.2.1"
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" }