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.
- {python_mobius-0.2.0 → python_mobius-0.2.1}/CHANGELOG.md +23 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/PKG-INFO +1 -1
- {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/00-overview.md +1 -1
- {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/06-light-schedule.md +42 -63
- python_mobius-0.2.1/documentation/09-thread-coap-relay.md +214 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/10-known-gaps-and-open-questions.md +0 -11
- {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/11-device-status-attributes.md +33 -16
- python_mobius-0.2.1/documentation/12-device-identity-and-address-stability.md +105 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/pyproject.toml +1 -1
- {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/__init__.py +1 -1
- {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/device.py +112 -59
- {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/relay.py +38 -8
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_device_summary.py +9 -7
- python_mobius-0.2.1/tests/test_firmware_versions.py +228 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_hyperdrive_device.py +20 -20
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_relay.py +75 -0
- python_mobius-0.2.0/documentation/09-thread-coap-relay.md +0 -337
- python_mobius-0.2.0/documentation/12-device-identity-and-address-stability.md +0 -114
- python_mobius-0.2.0/tests/test_firmware_versions.py +0 -141
- {python_mobius-0.2.0 → python_mobius-0.2.1}/.gitignore +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/LICENSE +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/README.md +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/01-ble-transport.md +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/02-framing-and-crc.md +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/03-attributes-and-opcodes.md +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/04-device-identity.md +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/05-scenes.md +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/07-pump-schedule.md +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/08-manufacturer-data.md +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/cli.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/coap.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/constants.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/crc.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/device_status.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/discovery.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/frame.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/manufacturer.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/mesh_address.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/modifiers.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/power.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/pump_status.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/src/mobius/schedule.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_coap.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_coap_indication_handling.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_connection_cleanup.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_device_status.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_device_time.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_frame.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_frame_sniffer.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_insolation_detection.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_light_intensity_branching.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_light_schedule.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_manufacturer.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_manufacturer_lookup.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_mesh_address.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_mesh_discovery.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_mesh_discovery_direct_connect.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_modifiers.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_power.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_pump_schedule.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_pump_status.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_schedule_intensity_scalar.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.2.1}/tests/test_serial_decoding.py +0 -0
- {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.
|
|
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) |
|
|
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
|
|
12
|
-
|
|
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
|
|
17
|
-
|
|
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()`
|
|
31
|
+
`get_current_light_intensities()` applies it automatically.
|
|
38
32
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
80
|
-
lights
|
|
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
|
|
98
|
-
|
|
99
|
-
fetched live, which
|
|
100
|
-
|
|
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
|
|
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
|
|
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
|
|
121
|
-
dates (full moon Jan 3 2026, new moon Jan 18 2026) -- the ported
|
|
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" --
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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()`
|
|
151
|
-
and
|
|
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
|
-
##
|
|
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)
|
|
174
|
-
computed value
|
|
175
|
-
`is_night_segment()`
|
|
176
|
-
`get_lunar_enabled()`
|
|
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
|
-
`
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
channels
|
|
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
|
|
220
|
-
|
|
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.
|
{python_mobius-0.2.0 → python_mobius-0.2.1}/documentation/10-known-gaps-and-open-questions.md
RENAMED
|
@@ -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)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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)
|
|
142
|
-
Product OS/Product Bootloader/Radio Firmware/Filesystem/Radio
|
|
143
|
-
WLAN (values 3-9, confirming lights use a QCA4020-based radio
|
|
144
|
-
WiFi+BLE, unlike the simpler radio in pumps)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
`FirmwareType` enum
|
|
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.
|
|
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" }
|