python-mobius 0.3.1__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {python_mobius-0.3.1 → python_mobius-0.4.0}/.forgejo/workflows/release.yml +18 -8
  2. {python_mobius-0.3.1 → python_mobius-0.4.0}/CHANGELOG.md +165 -34
  3. {python_mobius-0.3.1 → python_mobius-0.4.0}/PKG-INFO +2 -2
  4. {python_mobius-0.3.1 → python_mobius-0.4.0}/README.md +1 -1
  5. {python_mobius-0.3.1 → python_mobius-0.4.0}/documentation/00-overview.md +2 -2
  6. {python_mobius-0.3.1 → python_mobius-0.4.0}/documentation/01-ble-transport.md +1 -1
  7. {python_mobius-0.3.1 → python_mobius-0.4.0}/documentation/02-framing-and-crc.md +4 -4
  8. {python_mobius-0.3.1 → python_mobius-0.4.0}/documentation/03-attributes-and-opcodes.md +10 -11
  9. {python_mobius-0.3.1 → python_mobius-0.4.0}/documentation/04-device-identity.md +2 -2
  10. {python_mobius-0.3.1 → python_mobius-0.4.0}/documentation/06-light-schedule.md +13 -14
  11. {python_mobius-0.3.1 → python_mobius-0.4.0}/documentation/07-pump-schedule.md +3 -4
  12. {python_mobius-0.3.1 → python_mobius-0.4.0}/documentation/09-thread-coap-relay.md +118 -60
  13. {python_mobius-0.3.1 → python_mobius-0.4.0}/documentation/11-device-status-attributes.md +15 -21
  14. {python_mobius-0.3.1 → python_mobius-0.4.0}/documentation/12-device-identity-and-address-stability.md +7 -23
  15. {python_mobius-0.3.1 → python_mobius-0.4.0}/pyproject.toml +1 -1
  16. {python_mobius-0.3.1 → python_mobius-0.4.0}/src/mobius/__init__.py +9 -6
  17. {python_mobius-0.3.1 → python_mobius-0.4.0}/src/mobius/cli.py +208 -40
  18. {python_mobius-0.3.1 → python_mobius-0.4.0}/src/mobius/coap.py +24 -30
  19. {python_mobius-0.3.1 → python_mobius-0.4.0}/src/mobius/constants.py +30 -11
  20. {python_mobius-0.3.1 → python_mobius-0.4.0}/src/mobius/crc.py +3 -3
  21. {python_mobius-0.3.1 → python_mobius-0.4.0}/src/mobius/device.py +176 -38
  22. {python_mobius-0.3.1 → python_mobius-0.4.0}/src/mobius/device_status.py +56 -16
  23. {python_mobius-0.3.1 → python_mobius-0.4.0}/src/mobius/discovery.py +61 -2
  24. {python_mobius-0.3.1 → python_mobius-0.4.0}/src/mobius/frame.py +1 -1
  25. {python_mobius-0.3.1 → python_mobius-0.4.0}/src/mobius/mesh_address.py +32 -12
  26. {python_mobius-0.3.1 → python_mobius-0.4.0}/src/mobius/modifiers.py +11 -15
  27. {python_mobius-0.3.1 → python_mobius-0.4.0}/src/mobius/power.py +2 -2
  28. {python_mobius-0.3.1 → python_mobius-0.4.0}/src/mobius/pump_status.py +4 -4
  29. {python_mobius-0.3.1 → python_mobius-0.4.0}/src/mobius/relay.py +16 -14
  30. {python_mobius-0.3.1 → python_mobius-0.4.0}/src/mobius/schedule.py +17 -17
  31. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_coap.py +2 -2
  32. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_coap_indication_handling.py +4 -3
  33. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_connection_cleanup.py +2 -2
  34. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_device_time.py +4 -4
  35. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_frame_sniffer.py +2 -1
  36. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_light_intensity_branching.py +1 -1
  37. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_light_schedule.py +3 -4
  38. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_mesh_discovery.py +109 -2
  39. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_mesh_discovery_direct_connect.py +3 -3
  40. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_modifiers.py +5 -5
  41. python_mobius-0.4.0/tests/test_networked_thread_devices.py +302 -0
  42. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_power.py +1 -1
  43. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_pump_status.py +2 -2
  44. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_relay.py +3 -2
  45. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_schedule_intensity_scalar.py +2 -2
  46. {python_mobius-0.3.1 → python_mobius-0.4.0}/.forgejo/scripts/extract_changelog_section.py +0 -0
  47. {python_mobius-0.3.1 → python_mobius-0.4.0}/.gitignore +0 -0
  48. {python_mobius-0.3.1 → python_mobius-0.4.0}/LICENSE +0 -0
  49. {python_mobius-0.3.1 → python_mobius-0.4.0}/documentation/05-scenes.md +0 -0
  50. {python_mobius-0.3.1 → python_mobius-0.4.0}/documentation/08-manufacturer-data.md +0 -0
  51. {python_mobius-0.3.1 → python_mobius-0.4.0}/documentation/10-known-gaps-and-open-questions.md +0 -0
  52. {python_mobius-0.3.1 → python_mobius-0.4.0}/src/mobius/manufacturer.py +0 -0
  53. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_device_status.py +0 -0
  54. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_device_summary.py +0 -0
  55. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_firmware_versions.py +0 -0
  56. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_frame.py +0 -0
  57. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_hyperdrive_device.py +0 -0
  58. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_insolation_detection.py +0 -0
  59. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_manufacturer.py +0 -0
  60. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_manufacturer_lookup.py +0 -0
  61. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_mesh_address.py +0 -0
  62. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_pump_schedule.py +0 -0
  63. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_serial_decoding.py +0 -0
  64. {python_mobius-0.3.1 → python_mobius-0.4.0}/tests/test_serial_identity.py +0 -0
@@ -88,14 +88,23 @@ jobs:
88
88
  if: github.event_name == 'push'
89
89
  run: python3 .forgejo/scripts/extract_changelog_section.py "${{ github.ref_name }}"
90
90
 
91
- # direction: upload with no release-dir set -- no wheel/sdist attached
92
- # to the Forgejo release itself (PyPI is the source of truth for the
93
- # built artifacts); this step only creates the release entry with the
94
- # extracted changelog section as its notes. Worth confirming on the
95
- # first real run that omitting release-dir doesn't pick up dist/ by
96
- # some other default -- the action's own docs only show examples with
97
- # release-dir explicitly set, so this specific "notes only" case
98
- # isn't directly demonstrated there.
91
+ # Both of the action's own README examples always set release-dir
92
+ # explicitly, even though it's documented as optional with a
93
+ # default -- confirmed via a real failure on this exact step
94
+ # ("tag v0.3.1 exists" / "curl: (26) Failed to open/read local
95
+ # data from file/application") that omitting it entirely doesn't
96
+ # work, the same bug already found and fixed the same way for
97
+ # ha-mobius's own release workflow. dist/ has the actual built
98
+ # wheel/sdist in it (from the Build package step above), but
99
+ # those are deliberately NOT what goes here -- PyPI stays the
100
+ # source of truth for the built artifacts, this step only creates
101
+ # the release entry with the extracted changelog section as its
102
+ # notes -- hence a separate, empty dist/release/ rather than
103
+ # pointing release-dir at dist/ itself.
104
+ - name: Ensure release-dir exists (empty -- no files attached)
105
+ if: github.event_name == 'push'
106
+ run: mkdir -p dist/release
107
+
99
108
  # Same event-gating reason as the two steps above -- nothing to
100
109
  # release against on a manual, non-tag run.
101
110
  - name: Create Forgejo release
@@ -107,4 +116,5 @@ jobs:
107
116
  repo: r3pek/python-mobius
108
117
  token: ${{ secrets.GITHUB_TOKEN }}
109
118
  tag: ${{ github.ref_name }}
119
+ release-dir: dist/release
110
120
  release-notes-file: release_notes.md
@@ -1,15 +1,145 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - **`mobius-scan`'s default now also connects to ONE device and dumps
6
+ real mesh addresses for its whole network**, not just the pan_id
7
+ listing. Reuses the exact same verbose discovery `--dump-mesh-peers`
8
+ already used (`_debug_mesh_discovery()` -- `MLPrefix`/
9
+ `NetworkedThreadDevices` first, falling back to the older Cowboy-hub
10
+ mechanism), so the output is identical in shape whether it happens via
11
+ the default run or that flag explicitly. `--scan-only` now has real
12
+ meaning again (it had briefly become a no-op after the previous
13
+ change made the pan_id listing itself the whole default): it skips
14
+ this connection step, for a genuinely connection-free run.
15
+ `--dump-tanks` still exists separately for connecting to EVERY pan_id
16
+ group rather than just one, useful when multiple genuinely separate
17
+ tanks/networks are in range at once. Verified end-to-end with the
18
+ scan/connect layer mocked out: confirmed exactly one `MobiusDevice`
19
+ connection gets created by the plain default (to the correct,
20
+ first-found serial), and confirmed `--scan-only` still makes zero.
21
+
22
+ - **Added `Tank` and `discover_tank()`: the connection-based building
23
+ block for full tank discovery** (device + address + age + shared
24
+ prefix, all in one call) -- the "basic block" a downstream integration
25
+ (e.g. ha-mobius) can call to trigger an on-demand scan and show
26
+ address/age/prefix for its devices. `discover_tank(mdevice)` takes an
27
+ already-connected `MobiusDevice`, calls `discover_mesh_peers_auto()`
28
+ plus `get_own_mesh_address()`/`get_device_info()` to include the
29
+ connected device itself in the result (the peer-discovery calls only
30
+ ever report OTHER devices, never themselves) -- so a single-device
31
+ tank correctly comes back with that one device as its sole peer, not
32
+ an empty list that would be indistinguishable from "discovery failed
33
+ entirely." `tank.prefix` is `None` (and `peers` always empty) if the
34
+ connected device didn't report a valid mesh address at all. 3 new
35
+ tests. Also exported `mesh_local_prefix_from_mlprefix()` from the
36
+ top-level `mobius` package -- a real gap from when it was first added,
37
+ caught while adding these new exports alongside it.
38
+
39
+ - **`mobius-scan`'s default behavior changed: now genuinely just scans
40
+ and lists devices grouped by tank, no connections at all** -- it used
41
+ to print that same pan_id-grouped listing (already existed, already
42
+ connection-free) and then STILL go on to connect to every single
43
+ scanned device for a full summary regardless. That's now opt-in via a
44
+ new `--full-summaries` flag instead of being the implicit default.
45
+ Added `--dump-tanks`: connects to just ONE device per pan_id group
46
+ (not every device) and calls the new `discover_tank()` on it, printing
47
+ the full mesh-address/age for every device sharing that tank --
48
+ richer than the default listing, which only has whatever's in the BLE
49
+ advertisement (model/serial), not address or age. `--scan-only` kept
50
+ accepted as a no-op (it's redundant now that its old behavior IS the
51
+ default) rather than removed, so no existing script using it
52
+ explicitly breaks. Renamed the printed header from "Pan ID groups" to
53
+ "Tanks" to match. Verified end-to-end with the scan/connect layer
54
+ mocked out, for both the new default (confirmed zero MobiusDevice
55
+ connections created) and `--dump-tanks` (confirmed it correctly
56
+ prints prefix/address/age for a 2-device tank), not just via
57
+ `--help`'s rendered text.
58
+
59
+ - **`discover_networked_thread_devices()` confirmed against real
60
+ hardware** (a live 4-device tank: 2 VorTech pumps, 2 Radion lights) --
61
+ the byte layout, little-endian field order, and all 4 real peers'
62
+ model/serial/age/derived-address all matched exactly. One real
63
+ correction from this: empty slots on real hardware are encoded as 28
64
+ bytes of raw zero (an all-null-byte serial), **not** the ASCII
65
+ `"00000000000000"` sentinel string `discover_mesh_peers()`'s older
66
+ mechanism uses -- the existing `if not serial` filter already handled
67
+ this correctly (an all-null-byte serial decodes to an empty string
68
+ after stripping), but the docstring incorrectly claimed this newer
69
+ mechanism used the same ASCII convention as the older one. Docstring
70
+ corrected; added a dedicated test for the actual null-byte case, plus
71
+ a new test replaying the exact real capture end-to-end (all 4 peers,
72
+ interleaved with the 26 real empty slots).
73
+
74
+ - **Added `NetworkedThreadDevices` (1001) + `MLPrefix` (2128) support: a
75
+ second, better Thread mesh peer-discovery mechanism.** Confirmed via
76
+ reverse engineering the app's own tank-setup logic to be checked via a
77
+ generic support check on the connected device, **not gated to a
78
+ Cowboy hub** the way the existing three-parallel-arrays mechanism
79
+ (`discover_mesh_peers()`) is -- found while investigating whether the
80
+ app caches device mesh addresses at all (it does, persisting them to
81
+ a local file and syncing them to a cloud backend as part of each
82
+ device's saved state; this attribute is what actually populates that
83
+ cache in the first place). `MobiusDevice.discover_networked_thread_devices()`
84
+ fetches both attributes and returns every peer the connected device
85
+ knows about in a single round-trip, including a per-peer "age" value
86
+ (present in the confirmed wire format, but its exact meaning isn't
87
+ independently confirmed against real hardware -- see `MeshPeer`'s own
88
+ docstring). `MeshPeer` gained an `age: Optional[int] = None` field
89
+ (only ever set by this new method; `None` from every other source, a
90
+ non-breaking addition verified against all 4 existing `MeshPeer(...)`
91
+ construction sites, all of which use keyword arguments). Added
92
+ `mesh_local_prefix_from_mlprefix()` to `mobius.mesh_address`
93
+ (MLPrefix's own response is 24 bytes; only the first 8 are the real
94
+ prefix, confirmed via reverse engineering).
95
+
96
+ - **Added `MobiusDevice.discover_mesh_peers_auto()`: the new
97
+ recommended default entry point for mesh peer discovery**, replacing
98
+ the previous recommendation to call `discover_mesh_peers()` or
99
+ `discover_mesh_peers_via_direct_connect()` directly depending on
100
+ hardware. Tries `discover_networked_thread_devices()` first, falling
101
+ back to `discover_mesh_peers()` only if that finds nothing --
102
+ `discover_mesh_peers_via_direct_connect()` isn't included in this
103
+ chain, since it needs a list of scanned devices as input rather than
104
+ an already-connected `MobiusDevice`; it remains the right fallback to
105
+ reach for explicitly if both of the above come back empty.
106
+
107
+ - **On "discovering Tanks"**: investigated and confirmed not locally
108
+ possible -- the app's own "Tank" grouping is account-level and
109
+ cloud-synced, with user-edited metadata (name, photo) that has no
110
+ local/BLE equivalent to read. The closest local approximation this
111
+ library can offer: every peer `discover_networked_thread_devices()`
112
+ returns shares the connected device's own `MLPrefix`, which in
113
+ practice is "every device on the same Thread mesh" -- for a typical
114
+ setup, the same thing as "every device in the same aquarium."
115
+ Documented as an approximation, not presented as equivalent to the
116
+ app's own Tank object.
117
+
118
+ - **`mobius-scan` CLI updated to match**: `--dump-mesh-peers` and
119
+ `--relay-target` now run the new `discover_mesh_peers_auto()`
120
+ priority order (step 0: `MLPrefix`/`NetworkedThreadDevices`, falling
121
+ through to the existing Cowboy-hub-specific steps only if that finds
122
+ nothing) instead of only ever exercising the older mechanism;
123
+ `--dump-mesh-peers`' output now also prints each peer's `age` when
124
+ set. `--build-peer-map` unchanged in behavior, now documented as the
125
+ last-resort fallback when both attribute-based mechanisms come back
126
+ empty. 10 new tests covering the new discovery method, the prefix
127
+ helper, and both branches of `discover_mesh_peers_auto()`'s fallback
128
+ logic -- plus two direct end-to-end simulations of the updated CLI
129
+ debug path (both the new-mechanism-succeeds case and the
130
+ falls-through-to-the-old-mechanism case) run manually before
131
+ delivery, not just unit-level mocks.
132
+
3
133
  ## 0.3.1
4
134
 
5
135
  - **Fixed a real, high-impact bug: `Brightness` (the master dimmer
6
136
  channel) was never being applied to any other channel at all.**
7
- Confirmed directly from raw smali (`LightPrimitive.flatten()`, called
8
- on both bracketing schedule points in `getIntensitiesAtTime()` before
9
- interpolating between them -- JADX's decompiled Java for this class
10
- carried its own "Code duplicated" warning, the same signal that led to
137
+ Confirmed via reverse engineering the app's own "flatten" logic, called
138
+ on both bracketing schedule points before
139
+ interpolating between them -- an earlier decompilation attempt for this logic
140
+ was unreliable here, the same underlying issue that led to
11
141
  finding the `is_night_segment()` bug, so this was re-verified against
12
- bytecode rather than trusted from the decompile a second time). Every
142
+ the raw bytecode rather than trusted from the decompile a second time). Every
13
143
  channel -- moonlight included -- was being interpolated and returned
14
144
  without ever being scaled by `Brightness`, found via real hardware
15
145
  testing: a light showing ~1% in the app's own display came back as
@@ -33,9 +163,9 @@
33
163
 
34
164
  - **Fixed a real, high-impact bug in `is_night_segment()`: the condition
35
165
  deciding whether to apply lunar-phase reduction had its first check
36
- inverted**, confirmed directly from raw smali bytecode after JADX's
37
- own decompiled Java carried a "Code duplicated" warning on this exact
38
- method that turned out to be a real signal -- the decompiler had
166
+ inverted**, confirmed directly against the raw bytecode after an
167
+ earlier decompilation attempt on this exact
168
+ method turned out to be unreliable -- a real signal, not noise: the decompiler had
39
169
  produced `!point.has(NIGHT)` where the actual bytecode requires
40
170
  `point.has(NIGHT)`. In practice this meant lunar reduction was applied
41
171
  only during the single dusk-transition segment (and only if the query
@@ -116,16 +246,16 @@ for the full protocol trace and implementation history. The `coap-relay`
116
246
  branch has been merged into main as part of this release.
117
247
 
118
248
  - **Root cause of relayed reads returning empty data found and fixed,**
119
- after the user provided the raw smali disassembly for
120
- `BaseConnection.java` -- JADX's decompiler had given up specifically on
121
- `handleIndication()`, the one method that held the answer, but the
249
+ after the user provided the raw disassembly for
250
+ the app's own connection logic -- the decompiler had given up specifically on
251
+ the indication-handling logic, the one thing that held the answer, but the
122
252
  underlying bytecode was still traceable. Confirmed precisely: the real
123
253
  CoAP relay response arrives on a completely separate opcode (`0xDF`/
124
254
  `26`, `COAP_INDICATION_OPCODE` -- not the request's `25`, whose confirm
125
255
  is a bare, meaningless ack the app itself discards, confirmed
126
256
  byte-for-byte via real hardware testing showing exactly 1 byte in every
127
257
  relayed read's outer response), matched purely by CoAP token
128
- (`lambda$getSentRequest$6`: `coapRequest.getToken() ==
258
+ (confirmed via reverse engineering: `coapRequest.getToken() ==
129
259
  coapResponse.getToken()`), never by the outer FSCI message ID. Added
130
260
  `MobiusDevice._register_coap_token()`/`wait_for_coap_response()` -- a
131
261
  token-keyed pending-response registry, entirely separate from the
@@ -149,7 +279,8 @@ branch has been merged into main as part of this release.
149
279
  even though that same device's own address reads back fine). Added
150
280
  `MobiusDevice.get_own_mesh_address()` and `mobius.discovery.
151
281
  discover_mesh_peers_via_direct_connect()` as the alternative --
152
- confirmed via `PeripheralConnection.java` to be exactly how the app
282
+ confirmed via reverse engineering the app's own connection-setup logic
283
+ to be exactly how the app
153
284
  itself always learns a device's address (automatic on every
154
285
  connection, not Cowboy-specific). Added `mobius-scan --build-peer-map`
155
286
  to exercise this from the CLI. `discover_mesh_peers()` remains in
@@ -179,15 +310,15 @@ branch has been merged into main as part of this release.
179
310
 
180
311
  - **Confirmed directly from source why the response comes back
181
312
  malformed: the immediate confirm to a relay request is explicitly
182
- discarded by the app itself, on purpose.** `BaseConnection.java`'s
183
- `handleBytes()` matches `opGroup==0xDF && opCode==25` unconditionally
313
+ discarded by the app itself, on purpose.** The app's own byte-dispatch
314
+ logic matches `opGroup==0xDF && opCode==25` unconditionally
184
315
  and logs it only -- before the dispatcher ever checks whether it
185
316
  resolves a pending request. Real hardware testing independently
186
317
  confirmed this byte-for-byte: every relayed read's outer response is
187
318
  exactly 1 byte (`0x00`), not a truncated response but the genuine
188
319
  complete confirm, thrown away by the app too. The real response, if it
189
320
  arrives at all, comes through a separate "indication" handled by
190
- `handleIndication()` -- a method JADX couldn't decompile, a genuine
321
+ the app's own indication-handling logic -- a method the decompiler couldn't resolve to readable code, a genuine
191
322
  limit of what source tracing alone can determine here. Added
192
323
  `MobiusDevice.on_any_frame` (fires for every parsed incoming frame,
193
324
  not just ones resolving a pending request) and extended
@@ -198,8 +329,8 @@ branch has been merged into main as part of this release.
198
329
  - **Added `mobius.coap`**: CoAP (RFC 7252) protocol primitives --
199
330
  `CoapRequestType`/`CoapMethod`/`CoapResponseCode` enums,
200
331
  `encode_coap_request()`/`decode_coap_response()`. Pure functions, no
201
- hardware dependency. Confirmed wire format traced directly from the
202
- decompiled app (`Coap.java`/`CoapRequest.java`/`CoapResponse.java`) --
332
+ hardware dependency. Confirmed wire format traced directly from
333
+ reverse engineering the app's own CoAP model --
203
334
  see the module's docstring for exactly which enum values are
204
335
  confirmed-literal, cross-referenced from elsewhere in this project, or
205
336
  inferred-only (deliberately not guessing at unconfirmed ones -- an
@@ -213,8 +344,8 @@ branch has been merged into main as part of this release.
213
344
  `mesh_local_prefix_from_own_address()`/`is_valid_mesh_address()`/
214
345
  `is_short_address_derived()`). Pure functions, no hardware dependency.
215
346
  Includes a genuinely surprising, verified-not-assumed finding: the
216
- short-address portion is little-endian (confirmed via
217
- `ByteUtilities.getShort()`'s explicit `ByteOrder.LITTLE_ENDIAN`), the
347
+ short-address portion is little-endian (confirmed via reverse
348
+ engineering the app's own byte-extraction logic), the
218
349
  opposite of IPv6's own conventional big-endian byte order -- easy to
219
350
  get backwards by assuming the IPv6 standard's own convention applies
220
351
  here too. 15 new tests.
@@ -228,8 +359,8 @@ branch has been merged into main as part of this release.
228
359
  per-element format matches the existing 14-byte ASCII serial format
229
360
  already used elsewhere in this library -- no new parsing logic needed.
230
361
  Filters out cleared/empty slots (the literal sentinel string
231
- `"00000000000000"`, confirmed via `CowboyNetworkProcess.java`'s own
232
- cleanup logic). Fails soft to an empty list for unsupported devices or
362
+ `"00000000000000"`, confirmed via reverse engineering the app's own
363
+ network-setup cleanup logic). Fails soft to an empty list for unsupported devices or
233
364
  ones not currently part of an active Thread network, matching this
234
365
  library's usual convention. Also recorded (but not yet used) a fourth
235
366
  parallel array, `BladeBleAddressArray` (3714, each peer's real BLE MAC
@@ -278,8 +409,8 @@ branch has been merged into main as part of this release.
278
409
 
279
410
  - **Added `get_device_time_info()`** (`Epoch`/`RTCTime`, attributes
280
411
  201/219) -- device clock drift vs. local system time. Not speculative:
281
- confirmed via the app's own "Time Difference" troubleshooting feature
282
- (`TimeDifferenceFragment.java`), which checks these exact two attributes
412
+ confirmed via the app's own "Time Difference" troubleshooting feature,
413
+ which checks these exact two attributes
283
414
  the same way. Included automatically in `get_device_summary()`'s
284
415
  `"device_time"` field. Important caveat documented: the app's own
285
416
  schedule interpolation uses the phone/tank clock, not either of these,
@@ -400,8 +531,7 @@ branch has been merged into main as part of this release.
400
531
  (`IsCalibrated`/`LastCalibrationTime`/`MinCalibratedSpeed`/
401
532
  `MaxCalibratedSpeed`, 1300/1304/1308/1309) -- **important correction**:
402
533
  despite "Speed"-sounding names, confirmed via the app's own UI gating
403
- (`DeviceSettingsFragment.java`: `device.primitive.category() ==
404
- M.DeviceCategory.Lighting`) that this is a light feature, not a pump
534
+ (its own device category check) that this is a light feature, not a pump
405
535
  one. A separate, unrelated pump-side calibration flow exists in the app
406
536
  (Nero-specific zero-flow/max-flow calibration) but isn't implemented and
407
537
  doesn't apply to VorTech anyway (no physical flow sensor to calibrate).
@@ -430,7 +560,7 @@ branch has been merged into main as part of this release.
430
560
  - **Added full hyperdrive support** (correct percentage conversion for
431
561
  light channels boosted above their normal 100% rating). New
432
562
  `mobius.power` module (`ChannelPowerInfo`, `channel_percent_value()`,
433
- ported from `LightPowerInfo.java`) and `get_channel_power_info()`
563
+ confirmed via reverse engineering the app's own power model) and `get_channel_power_info()`
434
564
  (`MaxPower`/`NormalPower`, attributes 1504/1513). New
435
565
  `get_current_light_percentages()` convenience method returns corrected
436
566
  0-100+ percentages instead of raw permille, flagging boosted channels via
@@ -463,20 +593,21 @@ branch has been merged into main as part of this release.
463
593
  a schedule-level master intensity scalar (`Schedule1Intensity`, attribute
464
594
  511) that the real app applies on top. Caught by direct comparison
465
595
  against real app screenshots showing different numbers than this
466
- library computed for the same light at the same time. Traced to
467
- `WidgetViewCell.java`/`IntensityView.java` in the decompiled source.
596
+ library computed for the same light at the same time. Traced through
597
+ reverse engineering the app's own dashboard-rendering logic.
468
598
  Added `get_schedule_intensity()`; `get_current_light_intensities()` now
469
599
  applies it automatically.
470
600
  - **Added lunar-phase and acclimation-ramp support**, the two remaining
471
601
  modifiers flagged as gaps in the previous entry. New `mobius.modifiers`
472
- module, ported from `LunarInfo.java`/`AcclimationInfo.java`:
602
+ module, confirmed via reverse engineering the app's own lunar and
603
+ acclimation models:
473
604
  - `lunar_percent_reduction(date)` -- verified against real,
474
605
  independently-confirmed 2026 moon phase dates (full moon Jan 3, new
475
606
  moon Jan 18); the ported algorithm matches exactly.
476
607
  - `AcclimationInfo` -- linear intensity ramp from a starting percentage
477
608
  up to full over a configured period.
478
- - `is_night_segment()` -- ported from the boolean half of
479
- `PointSchedule.getIntensitiesAtTime()`; confirmed this is specifically
609
+ - `is_night_segment()` -- confirmed via reverse engineering the boolean half of
610
+ the app's own schedule-interpolation logic; confirmed this is specifically
480
611
  "the dusk-to-night transition segment," not simply "is it night."
481
612
  - `get_lunar_enabled()`, `get_acclimation_info()`,
482
613
  `get_current_light_intensities()` now applies the full branching the
@@ -491,8 +622,8 @@ branch has been merged into main as part of this release.
491
622
 
492
623
  ## 0.1.2
493
624
 
494
- - Added `manufacturer_for_model()` / `MODEL_MANUFACTURER` (ported from
495
- `M.Model.getManufacturer()`) — surfaces "EcoTech Marine" /
625
+ - Added `manufacturer_for_model()` / `MODEL_MANUFACTURER` (confirmed via
626
+ reverse engineering the app's own manufacturer-lookup logic) — surfaces "EcoTech Marine" /
496
627
  "AquaIllumination" / "Neptune Systems" / "NYOS" per device model. Now
497
628
  included automatically in `get_device_info()`'s `"manufacturer"` field.
498
629
  Added while building a Home Assistant integration on top of this library,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mobius
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)
5
5
  Project-URL: Homepage, https://code.r3pek.org/r3pek/python-mobius
6
6
  Project-URL: Documentation, https://code.r3pek.org/r3pek/python-mobius/src/branch/main/documentation
@@ -140,7 +140,7 @@ GPLv2 — see [`LICENSE`](./LICENSE).
140
140
 
141
141
  The protocol reverse-engineering and implementation in this library were
142
142
  carried out with substantial assistance from Claude (Anthropic), used to
143
- analyze a decompiled copy of the official Mobius Android app (JADX) and
143
+ analyze a decompiled copy of the official Mobius Android app and
144
144
  cross-reference it against prior public community research (notably the
145
145
  Reef2Reef "Controlling Mobius enabled VorTech pump using 0-10V and BLE"
146
146
  thread and the `danmrossi/MobiusControl` project), then to design, write,
@@ -108,7 +108,7 @@ GPLv2 — see [`LICENSE`](./LICENSE).
108
108
 
109
109
  The protocol reverse-engineering and implementation in this library were
110
110
  carried out with substantial assistance from Claude (Anthropic), used to
111
- analyze a decompiled copy of the official Mobius Android app (JADX) and
111
+ analyze a decompiled copy of the official Mobius Android app and
112
112
  cross-reference it against prior public community research (notably the
113
113
  Reef2Reef "Controlling Mobius enabled VorTech pump using 0-10V and BLE"
114
114
  thread and the `danmrossi/MobiusControl` project), then to design, write,
@@ -15,7 +15,7 @@ Interface conventions, extended by EcoTech).
15
15
  sniffing and partial decompilation. The `danmrossi/MobiusControl` GitHub
16
16
  repo has a working Arduino/ESP32 implementation built on that research.
17
17
 
18
- 2. **Direct decompilation of the Mobius Android app** (JADX), which
18
+ 2. **Direct reverse engineering of the Mobius Android app**, which
19
19
  confirmed everything from (1) and filled in every gap it left:
20
20
  - The exact reserved-byte bit layout.
21
21
  - Both the "old" (1-byte field) and "new" (2-byte field) opcode variants
@@ -60,7 +60,7 @@ The decompiled-source analysis, protocol derivation, and Python
60
60
  implementation in this project were done with substantial assistance from
61
61
  Claude (Anthropic) — reading the decompiled Java sources, cross-referencing
62
62
  them against the public community research above, resolving several
63
- JADX cosmetic-decompilation artifacts (see
63
+ decompilation artifacts specific to the reverse-engineering process (see
64
64
  [03-attributes-and-opcodes.md](./03-attributes-and-opcodes.md) for a
65
65
  specific example), and writing/testing the resulting Python code. Every
66
66
  claim in these docs is still tagged by how it was actually confirmed (see
@@ -16,7 +16,7 @@ This UUID pattern is a generic Nordic-style "wireless UART" shape; the
16
16
  `01ff01xx` prefix is what actually identifies it as Mobius-specific, not the
17
17
  UUID base itself.
18
18
 
19
- **Confirmed** via the decompiled `PeripheralConnection.java`: the app writes
19
+ **Confirmed** via reverse engineering the app's own connection logic: the app writes
20
20
  to `TX_DATA` for every chunk except the last, and to `TX_FINAL` for the
21
21
  last (or only) chunk — writing to `TX_FINAL` is what triggers the device to
22
22
  actually process the accumulated message. Symmetrically, on receive, it
@@ -44,7 +44,7 @@ explored.
44
44
 
45
45
  ## `reserved` field bit layout
46
46
 
47
- Confirmed from `Request.java`:
47
+ Confirmed via reverse engineering:
48
48
 
49
49
  ```
50
50
  byte0: bits 0-1 = FSCI instance
@@ -66,10 +66,10 @@ def crc16(data: bytes, seed: int = 0xFFFF) -> int:
66
66
  return crc
67
67
  ```
68
68
 
69
- The 256-entry table is ported directly from the decompiled
70
- `com.c2.comm.utilities.Crc` class (Java's signed-short constants, converted
69
+ The 256-entry table is confirmed via reverse engineering the app's own
70
+ CRC implementation (the original signed-short constants, converted
71
71
  to unsigned 16-bit for Python — see `src/mobius/crc.py`). There's also an
72
- unused CRC8 table in the same Java class, not used by anything observed and
72
+ unused CRC8 table alongside it, not used by anything observed and
73
73
  not ported here.
74
74
 
75
75
  ## Message ID / request-response correlation
@@ -46,7 +46,7 @@ from a multi-element Get, this assumption is the first thing to suspect.
46
46
 
47
47
  ## The `C2Attribute` table
48
48
 
49
- The decompiled `M.C2Attribute` enum has on the order of 250 entries,
49
+ The app's own attribute-identifier enum has on the order of 250 entries,
50
50
  covering the entire equipment line (lighting, pumps, dosing, Thread
51
51
  networking, OTA firmware, engineering/debug attributes, etc.). This library
52
52
  implements a curated subset — see `src/mobius/constants.py::C2Attribute` —
@@ -54,23 +54,22 @@ covering device identity, scenes, schedules, pump telemetry, and light
54
54
  channels.
55
55
 
56
56
  Every value is either a literal read directly from the decompile, or
57
- resolved through a JADX cosmetic-substitution artifact (explained below)
57
+ resolved through a symbolic-substitution artifact (explained below)
58
58
  back to a real integer — confirmed, not guessed, in both cases.
59
59
 
60
- ### The JADX substitution artifact
60
+ ### The symbolic-substitution artifact
61
61
 
62
- R8/ProGuard inlines small integer constants at compile time. JADX, when
63
- decompiling, sometimes picks a symbolic name for one of these literals from
62
+ R8/ProGuard inlines small integer constants at compile time. The
63
+ decompiler, in turn, sometimes picks a symbolic name for one of these literals from
64
64
  an *unrelated* imported class whose constant happens to share the same
65
65
  numeric value — purely cosmetic, but it means the decompiled source shows
66
- e.g. `TypedValues.CycleType.TYPE_CURVE_FIT` instead of the literal `401`.
66
+ a symbolic name from an unrelated library instead of the literal `401`.
67
67
 
68
68
  Most of these were resolved by obtaining the real source of the referenced
69
- library class (e.g. `androidx.constraintlayout.core.motion.utils.TypedValues`)
70
- and reading its actual constant values. One, `Schedule1`, required an extra
71
- hop: it resolved to `com.google.firebase.messaging.ServiceStarter.ERROR_UNKNOWN`,
72
- a Firebase Cloud Messaging internal class obtaining *that* class's source
73
- confirmed `ERROR_UNKNOWN = 500`, and therefore `Schedule1 = 500`. This was
69
+ library class and reading its actual constant values. One, `Schedule1`, required an extra
70
+ hop: it resolved to an unrelated library's internal
71
+ constant obtaining *that* class's source
72
+ confirmed the real value was `500`, and therefore `Schedule1 = 500`. This was
74
73
  subsequently confirmed live: fetching schedules with attribute ID 500
75
74
  against real pumps and lights returned correctly-structured, sane data.
76
75
 
@@ -58,7 +58,7 @@ a fourth "Hera" product line (codenamed after Taco Bell menu items in the
58
58
  source — `CrunchyTaco`, `SoftTaco`, `BajaBlast`, etc.). This library
59
59
  implements a curated subset relevant to lights/pumps/skimmers — see
60
60
  `src/mobius/constants.py::Model`; add entries as needed by copying values
61
- from the decompiled `M.Model` enum.
61
+ from the app's own model-identifier enum.
62
62
 
63
63
  Some newer model names in the decompile are Base64-encoded string literals
64
64
  rather than plain strings (likely an unrelated obfuscation pass) — e.g.
@@ -66,7 +66,7 @@ model `179` decodes to `"Radion XR15w G6 Pro"` from
66
66
  `base64.b64decode("UmFkaW9uIFhSMTV3IEc2IFBybw==")`. `RadionXR15wG6Blue = 180`
67
67
  is a sequential-numbering *inference*, not a directly-read literal (it sits
68
68
  in a gap between two confirmed values, but the exact literal wasn't
69
- resolvable through the JADX-substitution technique the way others were).
69
+ resolvable through the symbolic-substitution technique the way others were).
70
70
 
71
71
  ## `ErrorState`
72
72
 
@@ -17,8 +17,7 @@ lunar/acclimation modifiers.
17
17
 
18
18
  ## Schedule-level master intensity (`Schedule1Intensity`, attribute 511)
19
19
 
20
- Confirmed via `WidgetViewCell.java`/`IntensityView.java` in the decompiled
21
- app: on top of the per-channel interpolated curve, the app applies a
20
+ Confirmed via reverse engineering the app's own dashboard-rendering logic: on top of the per-channel interpolated curve, the app applies a
22
21
  **separate schedule-level master dimmer** -- `dCurrentIntensity =
23
22
  schedule.intensity / 1000.0`, multiplied onto every channel's interpolated
24
23
  value. This is genuinely separate from any individual channel's own value,
@@ -75,7 +74,7 @@ lights.
75
74
 
76
75
  ## Interpolation algorithm
77
76
 
78
- Ported from `PointSchedule.getIntensitiesAtTime()`:
77
+ Confirmed via reverse engineering the app's own schedule-interpolation logic:
79
78
 
80
79
  1. Sort points by time.
81
80
  2. Find the two points that bracket the target minute-of-day, wrapping
@@ -83,12 +82,12 @@ Ported from `PointSchedule.getIntensitiesAtTime()`:
83
82
  "the next day," i.e. its time + 1440).
84
83
  3. **`Brightness` (`VisualID.Brightness`) is a master dimmer, applied to
85
84
  EACH bracketing point separately, before interpolating between them**
86
- -- confirmed from raw smali (`LightPrimitive.flatten()`, called on
87
- both point copies in `getIntensitiesAtTime()` before the
88
- interpolation loop -- JADX's decompiled Java for this class carried
89
- its own "Code duplicated" warning, the same signal that led to
85
+ -- confirmed via reverse engineering the app's own "flatten" logic, called on
86
+ both point copies before the
87
+ interpolation loop -- an earlier decompilation attempt for this logic
88
+ was unreliable here, the same underlying issue that led to
90
89
  finding the `is_night_segment()` bug, so this was verified against
91
- bytecode directly rather than trusted from the decompile). For each
90
+ the raw bytecode directly rather than trusted from the decompile). For each
92
91
  point independently: every channel except `Brightness` itself is
93
92
  multiplied by that same point's own `Brightness` value / 1000. This
94
93
  was a real, confirmed-missing step for a while -- every channel
@@ -139,7 +138,7 @@ A static list of `VisualID` values (channel identities) a given light has
139
138
  ## Lunar phase and acclimation ramp
140
139
 
141
140
  Both implemented, on top of the schedule-level master intensity above.
142
- Ported from `LunarInfo.java`/`AcclimationInfo.java` in the decompiled app.
141
+ Confirmed via reverse engineering the app's own lunar and acclimation models.
143
142
 
144
143
  **Lunar phase** (`lunar_percent_reduction(date)`): a simplified moon-phase
145
144
  model -- a Julian-Day-based calculation (`julian_day_from_date()`, the
@@ -155,8 +154,8 @@ where `is_night_segment()` is true (see below), and only when
155
154
  that segment just uses a flat `1.0` (full/unreduced) scalar, matching
156
155
  the app's own fallback.
157
156
 
158
- **`is_night_segment()`**: ported from the boolean half of
159
- `PointSchedule.getIntensitiesAtTime()`'s return value. This is *not*
157
+ **`is_night_segment()`**: confirmed via reverse engineering the boolean half of
158
+ the app's own schedule-interpolation return value. This is *not*
160
159
  simply "is it night" as a general concept -- it's specifically true for
161
160
  the segment whose **start** point IS flagged `NIGHT` (but not also
162
161
  `SUNRISE`) and whose end point is ALSO flagged `NIGHT` -- i.e. once
@@ -166,9 +165,9 @@ point. In practice this covers essentially the whole night, *except*
166
165
  the two transition segments themselves: dusk (start point not yet
167
166
  night-flagged) and the sunrise segment (start point flagged `SUNRISE`).
168
167
 
169
- **Confirmed directly from raw smali bytecode, not the decompiled Java.**
170
- JADX's decompiler carried its own "Code duplicated" warning on this
171
- exact method -- a real signal, not noise: its decompiled Java had this
168
+ **Confirmed directly against the raw bytecode, not the decompiled Java.**
169
+ An earlier decompilation attempt for this
170
+ exact method was unreliable -- a real signal, not noise: the decompiled Java had this
172
171
  condition's first check inverted (`!point.has(NIGHT)` instead of the
173
172
  actual `point.has(NIGHT)`), which an earlier version of this port
174
173
  carried forward uncorrected. Found via real hardware testing: a light
@@ -81,11 +81,10 @@ byte):
81
81
  `Time`, `StartTime`, and `EndTime` (**only these three** — not `OnTime`/
82
82
  `OffTime`/`BigTime`, which are stored raw) are stored as
83
83
  `actual_value - 250` on the wire, and the app adds `250` back when reading.
84
- `250` is a real Android constant:
85
- `androidx.recyclerview.widget.ItemTouchHelper.Callback.DEFAULT_SWIPE_ANIMATION_DURATION`
86
- — another instance of the JADX cosmetic-substitution artifact described in
84
+ `250` is a real Android library constant
85
+ — another instance of the symbolic-substitution artifact described in
87
86
  [03-attributes-and-opcodes.md](./03-attributes-and-opcodes.md), resolved by
88
- looking up that Android library constant's real value. Verified against
87
+ looking up that library constant's real value. Verified against
89
88
  synthetic round-trip data (`tests/test_pump_schedule.py`).
90
89
 
91
90
  ### `MaxSpeed`/`MinSpeed` sign