python-mobius 0.3.0__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 (65) hide show
  1. {python_mobius-0.3.0 → python_mobius-0.4.0}/.forgejo/workflows/release.yml +18 -8
  2. {python_mobius-0.3.0 → python_mobius-0.4.0}/CHANGELOG.md +189 -29
  3. {python_mobius-0.3.0 → python_mobius-0.4.0}/PKG-INFO +2 -2
  4. {python_mobius-0.3.0 → python_mobius-0.4.0}/README.md +1 -1
  5. {python_mobius-0.3.0 → python_mobius-0.4.0}/documentation/00-overview.md +2 -2
  6. {python_mobius-0.3.0 → python_mobius-0.4.0}/documentation/01-ble-transport.md +1 -1
  7. {python_mobius-0.3.0 → python_mobius-0.4.0}/documentation/02-framing-and-crc.md +4 -4
  8. {python_mobius-0.3.0 → python_mobius-0.4.0}/documentation/03-attributes-and-opcodes.md +10 -11
  9. {python_mobius-0.3.0 → python_mobius-0.4.0}/documentation/04-device-identity.md +2 -2
  10. {python_mobius-0.3.0 → python_mobius-0.4.0}/documentation/06-light-schedule.md +51 -13
  11. {python_mobius-0.3.0 → python_mobius-0.4.0}/documentation/07-pump-schedule.md +3 -4
  12. {python_mobius-0.3.0 → python_mobius-0.4.0}/documentation/09-thread-coap-relay.md +118 -60
  13. {python_mobius-0.3.0 → python_mobius-0.4.0}/documentation/11-device-status-attributes.md +15 -21
  14. {python_mobius-0.3.0 → python_mobius-0.4.0}/documentation/12-device-identity-and-address-stability.md +7 -23
  15. {python_mobius-0.3.0 → python_mobius-0.4.0}/pyproject.toml +1 -1
  16. {python_mobius-0.3.0 → python_mobius-0.4.0}/src/mobius/__init__.py +9 -6
  17. {python_mobius-0.3.0 → python_mobius-0.4.0}/src/mobius/cli.py +208 -40
  18. {python_mobius-0.3.0 → python_mobius-0.4.0}/src/mobius/coap.py +24 -30
  19. {python_mobius-0.3.0 → python_mobius-0.4.0}/src/mobius/constants.py +30 -11
  20. {python_mobius-0.3.0 → python_mobius-0.4.0}/src/mobius/crc.py +3 -3
  21. {python_mobius-0.3.0 → python_mobius-0.4.0}/src/mobius/device.py +176 -38
  22. {python_mobius-0.3.0 → python_mobius-0.4.0}/src/mobius/device_status.py +56 -16
  23. {python_mobius-0.3.0 → python_mobius-0.4.0}/src/mobius/discovery.py +61 -2
  24. {python_mobius-0.3.0 → python_mobius-0.4.0}/src/mobius/frame.py +1 -1
  25. {python_mobius-0.3.0 → python_mobius-0.4.0}/src/mobius/mesh_address.py +32 -12
  26. {python_mobius-0.3.0 → python_mobius-0.4.0}/src/mobius/modifiers.py +11 -15
  27. {python_mobius-0.3.0 → python_mobius-0.4.0}/src/mobius/power.py +2 -2
  28. {python_mobius-0.3.0 → python_mobius-0.4.0}/src/mobius/pump_status.py +4 -4
  29. {python_mobius-0.3.0 → python_mobius-0.4.0}/src/mobius/relay.py +16 -14
  30. {python_mobius-0.3.0 → python_mobius-0.4.0}/src/mobius/schedule.py +51 -2
  31. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_coap.py +2 -2
  32. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_coap_indication_handling.py +4 -3
  33. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_connection_cleanup.py +2 -2
  34. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_device_time.py +4 -4
  35. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_frame_sniffer.py +2 -1
  36. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_light_intensity_branching.py +1 -1
  37. python_mobius-0.4.0/tests/test_light_schedule.py +134 -0
  38. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_mesh_discovery.py +109 -2
  39. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_mesh_discovery_direct_connect.py +3 -3
  40. {python_mobius-0.3.0 → 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.0 → python_mobius-0.4.0}/tests/test_power.py +1 -1
  43. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_pump_status.py +2 -2
  44. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_relay.py +3 -2
  45. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_schedule_intensity_scalar.py +2 -2
  46. python_mobius-0.3.0/tests/test_light_schedule.py +0 -58
  47. {python_mobius-0.3.0 → python_mobius-0.4.0}/.forgejo/scripts/extract_changelog_section.py +0 -0
  48. {python_mobius-0.3.0 → python_mobius-0.4.0}/.gitignore +0 -0
  49. {python_mobius-0.3.0 → python_mobius-0.4.0}/LICENSE +0 -0
  50. {python_mobius-0.3.0 → python_mobius-0.4.0}/documentation/05-scenes.md +0 -0
  51. {python_mobius-0.3.0 → python_mobius-0.4.0}/documentation/08-manufacturer-data.md +0 -0
  52. {python_mobius-0.3.0 → python_mobius-0.4.0}/documentation/10-known-gaps-and-open-questions.md +0 -0
  53. {python_mobius-0.3.0 → python_mobius-0.4.0}/src/mobius/manufacturer.py +0 -0
  54. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_device_status.py +0 -0
  55. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_device_summary.py +0 -0
  56. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_firmware_versions.py +0 -0
  57. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_frame.py +0 -0
  58. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_hyperdrive_device.py +0 -0
  59. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_insolation_detection.py +0 -0
  60. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_manufacturer.py +0 -0
  61. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_manufacturer_lookup.py +0 -0
  62. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_mesh_address.py +0 -0
  63. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_pump_schedule.py +0 -0
  64. {python_mobius-0.3.0 → python_mobius-0.4.0}/tests/test_serial_decoding.py +0 -0
  65. {python_mobius-0.3.0 → 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,12 +1,171 @@
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
+
133
+ ## 0.3.1
134
+
135
+ - **Fixed a real, high-impact bug: `Brightness` (the master dimmer
136
+ channel) was never being applied to any other channel at all.**
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
141
+ finding the `is_night_segment()` bug, so this was re-verified against
142
+ the raw bytecode rather than trusted from the decompile a second time). Every
143
+ channel -- moonlight included -- was being interpolated and returned
144
+ without ever being scaled by `Brightness`, found via real hardware
145
+ testing: a light showing ~1% in the app's own display came back as
146
+ ~25% from this library at the same moment, with `Brightness` itself
147
+ reading ~3.3% at that point in its own ramp -- fully consistent with
148
+ the missing multiplication, not a lunar or scheduling bug.
149
+ `interpolate_light_schedule()` now flattens each bracketing point
150
+ SEPARATELY (by that point's own `Brightness` value) BEFORE
151
+ interpolating between the two already-flattened points -- confirmed
152
+ via a dedicated test that this is NOT mathematically equivalent to
153
+ interpolating raw values first and multiplying by a single
154
+ interpolated `Brightness` at the end, whenever `Brightness` itself
155
+ changes across the segment (which it usually does, e.g. ramping
156
+ during dusk/dawn). `Brightness` itself is still returned as the plain
157
+ raw interpolated value, not flattened to a constant 100% the way the
158
+ real `flatten()` does to the primitive it's applied to -- callers
159
+ (e.g. a dedicated `Brightness` sensor) want the actual current
160
+ master-dimmer level. 4 new tests.
161
+
3
162
  ## 0.3.0
4
163
 
5
164
  - **Fixed a real, high-impact bug in `is_night_segment()`: the condition
6
165
  deciding whether to apply lunar-phase reduction had its first check
7
- inverted**, confirmed directly from raw smali bytecode after JADX's
8
- own decompiled Java carried a "Code duplicated" warning on this exact
9
- 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
10
169
  produced `!point.has(NIGHT)` where the actual bytecode requires
11
170
  `point.has(NIGHT)`. In practice this meant lunar reduction was applied
12
171
  only during the single dusk-transition segment (and only if the query
@@ -87,16 +246,16 @@ for the full protocol trace and implementation history. The `coap-relay`
87
246
  branch has been merged into main as part of this release.
88
247
 
89
248
  - **Root cause of relayed reads returning empty data found and fixed,**
90
- after the user provided the raw smali disassembly for
91
- `BaseConnection.java` -- JADX's decompiler had given up specifically on
92
- `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
93
252
  underlying bytecode was still traceable. Confirmed precisely: the real
94
253
  CoAP relay response arrives on a completely separate opcode (`0xDF`/
95
254
  `26`, `COAP_INDICATION_OPCODE` -- not the request's `25`, whose confirm
96
255
  is a bare, meaningless ack the app itself discards, confirmed
97
256
  byte-for-byte via real hardware testing showing exactly 1 byte in every
98
257
  relayed read's outer response), matched purely by CoAP token
99
- (`lambda$getSentRequest$6`: `coapRequest.getToken() ==
258
+ (confirmed via reverse engineering: `coapRequest.getToken() ==
100
259
  coapResponse.getToken()`), never by the outer FSCI message ID. Added
101
260
  `MobiusDevice._register_coap_token()`/`wait_for_coap_response()` -- a
102
261
  token-keyed pending-response registry, entirely separate from the
@@ -120,7 +279,8 @@ branch has been merged into main as part of this release.
120
279
  even though that same device's own address reads back fine). Added
121
280
  `MobiusDevice.get_own_mesh_address()` and `mobius.discovery.
122
281
  discover_mesh_peers_via_direct_connect()` as the alternative --
123
- 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
124
284
  itself always learns a device's address (automatic on every
125
285
  connection, not Cowboy-specific). Added `mobius-scan --build-peer-map`
126
286
  to exercise this from the CLI. `discover_mesh_peers()` remains in
@@ -150,15 +310,15 @@ branch has been merged into main as part of this release.
150
310
 
151
311
  - **Confirmed directly from source why the response comes back
152
312
  malformed: the immediate confirm to a relay request is explicitly
153
- discarded by the app itself, on purpose.** `BaseConnection.java`'s
154
- `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
155
315
  and logs it only -- before the dispatcher ever checks whether it
156
316
  resolves a pending request. Real hardware testing independently
157
317
  confirmed this byte-for-byte: every relayed read's outer response is
158
318
  exactly 1 byte (`0x00`), not a truncated response but the genuine
159
319
  complete confirm, thrown away by the app too. The real response, if it
160
320
  arrives at all, comes through a separate "indication" handled by
161
- `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
162
322
  limit of what source tracing alone can determine here. Added
163
323
  `MobiusDevice.on_any_frame` (fires for every parsed incoming frame,
164
324
  not just ones resolving a pending request) and extended
@@ -169,8 +329,8 @@ branch has been merged into main as part of this release.
169
329
  - **Added `mobius.coap`**: CoAP (RFC 7252) protocol primitives --
170
330
  `CoapRequestType`/`CoapMethod`/`CoapResponseCode` enums,
171
331
  `encode_coap_request()`/`decode_coap_response()`. Pure functions, no
172
- hardware dependency. Confirmed wire format traced directly from the
173
- 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 --
174
334
  see the module's docstring for exactly which enum values are
175
335
  confirmed-literal, cross-referenced from elsewhere in this project, or
176
336
  inferred-only (deliberately not guessing at unconfirmed ones -- an
@@ -184,8 +344,8 @@ branch has been merged into main as part of this release.
184
344
  `mesh_local_prefix_from_own_address()`/`is_valid_mesh_address()`/
185
345
  `is_short_address_derived()`). Pure functions, no hardware dependency.
186
346
  Includes a genuinely surprising, verified-not-assumed finding: the
187
- short-address portion is little-endian (confirmed via
188
- `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
189
349
  opposite of IPv6's own conventional big-endian byte order -- easy to
190
350
  get backwards by assuming the IPv6 standard's own convention applies
191
351
  here too. 15 new tests.
@@ -199,8 +359,8 @@ branch has been merged into main as part of this release.
199
359
  per-element format matches the existing 14-byte ASCII serial format
200
360
  already used elsewhere in this library -- no new parsing logic needed.
201
361
  Filters out cleared/empty slots (the literal sentinel string
202
- `"00000000000000"`, confirmed via `CowboyNetworkProcess.java`'s own
203
- 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
204
364
  ones not currently part of an active Thread network, matching this
205
365
  library's usual convention. Also recorded (but not yet used) a fourth
206
366
  parallel array, `BladeBleAddressArray` (3714, each peer's real BLE MAC
@@ -249,8 +409,8 @@ branch has been merged into main as part of this release.
249
409
 
250
410
  - **Added `get_device_time_info()`** (`Epoch`/`RTCTime`, attributes
251
411
  201/219) -- device clock drift vs. local system time. Not speculative:
252
- confirmed via the app's own "Time Difference" troubleshooting feature
253
- (`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
254
414
  the same way. Included automatically in `get_device_summary()`'s
255
415
  `"device_time"` field. Important caveat documented: the app's own
256
416
  schedule interpolation uses the phone/tank clock, not either of these,
@@ -371,8 +531,7 @@ branch has been merged into main as part of this release.
371
531
  (`IsCalibrated`/`LastCalibrationTime`/`MinCalibratedSpeed`/
372
532
  `MaxCalibratedSpeed`, 1300/1304/1308/1309) -- **important correction**:
373
533
  despite "Speed"-sounding names, confirmed via the app's own UI gating
374
- (`DeviceSettingsFragment.java`: `device.primitive.category() ==
375
- 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
376
535
  one. A separate, unrelated pump-side calibration flow exists in the app
377
536
  (Nero-specific zero-flow/max-flow calibration) but isn't implemented and
378
537
  doesn't apply to VorTech anyway (no physical flow sensor to calibrate).
@@ -401,7 +560,7 @@ branch has been merged into main as part of this release.
401
560
  - **Added full hyperdrive support** (correct percentage conversion for
402
561
  light channels boosted above their normal 100% rating). New
403
562
  `mobius.power` module (`ChannelPowerInfo`, `channel_percent_value()`,
404
- 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()`
405
564
  (`MaxPower`/`NormalPower`, attributes 1504/1513). New
406
565
  `get_current_light_percentages()` convenience method returns corrected
407
566
  0-100+ percentages instead of raw permille, flagging boosted channels via
@@ -434,20 +593,21 @@ branch has been merged into main as part of this release.
434
593
  a schedule-level master intensity scalar (`Schedule1Intensity`, attribute
435
594
  511) that the real app applies on top. Caught by direct comparison
436
595
  against real app screenshots showing different numbers than this
437
- library computed for the same light at the same time. Traced to
438
- `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.
439
598
  Added `get_schedule_intensity()`; `get_current_light_intensities()` now
440
599
  applies it automatically.
441
600
  - **Added lunar-phase and acclimation-ramp support**, the two remaining
442
601
  modifiers flagged as gaps in the previous entry. New `mobius.modifiers`
443
- module, ported from `LunarInfo.java`/`AcclimationInfo.java`:
602
+ module, confirmed via reverse engineering the app's own lunar and
603
+ acclimation models:
444
604
  - `lunar_percent_reduction(date)` -- verified against real,
445
605
  independently-confirmed 2026 moon phase dates (full moon Jan 3, new
446
606
  moon Jan 18); the ported algorithm matches exactly.
447
607
  - `AcclimationInfo` -- linear intensity ramp from a starting percentage
448
608
  up to full over a configured period.
449
- - `is_night_segment()` -- ported from the boolean half of
450
- `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
451
611
  "the dusk-to-night transition segment," not simply "is it night."
452
612
  - `get_lunar_enabled()`, `get_acclimation_info()`,
453
613
  `get_current_light_intensities()` now applies the full branching the
@@ -462,8 +622,8 @@ branch has been merged into main as part of this release.
462
622
 
463
623
  ## 0.1.2
464
624
 
465
- - Added `manufacturer_for_model()` / `MODEL_MANUFACTURER` (ported from
466
- `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" /
467
627
  "AquaIllumination" / "Neptune Systems" / "NYOS" per device model. Now
468
628
  included automatically in `get_device_info()`'s `"manufacturer"` field.
469
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.0
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,18 +74,57 @@ 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
82
81
  around midnight (the point after the last one is treated as being on
83
82
  "the next day," i.e. its time + 1440).
84
- 3. For each channel present in either bracketing point, linearly
85
- interpolate: `value(t) = v1 + (v2 - v1) * (t - t1) / (t2 - t1)`.
86
- 4. Channels absent from a point default to `0` (off).
83
+ 3. **`Brightness` (`VisualID.Brightness`) is a master dimmer, applied to
84
+ EACH bracketing point separately, before interpolating between them**
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
89
+ finding the `is_night_segment()` bug, so this was verified against
90
+ the raw bytecode directly rather than trusted from the decompile). For each
91
+ point independently: every channel except `Brightness` itself is
92
+ multiplied by that same point's own `Brightness` value / 1000. This
93
+ was a real, confirmed-missing step for a while -- every channel
94
+ (moonlight included) was being interpolated and returned without
95
+ ever being scaled by `Brightness` at all, found via real hardware
96
+ testing (a light showing ~1% in the app's own display came back as
97
+ ~25% from this library at the same moment, with `Brightness` itself
98
+ reading ~3.3% at that point in its own ramp).
99
+ 4. For each channel present in either bracketing point, linearly
100
+ interpolate between the (already `Brightness`-scaled, for
101
+ non-`Brightness` channels) endpoint values:
102
+ `value(t) = v1 + (v2 - v1) * (t - t1) / (t2 - t1)`.
103
+ **Flattening happens per-endpoint before this step, not
104
+ interpolate-raw-then-multiply-by-a-single-`Brightness`-value after
105
+ it** -- these are NOT mathematically equivalent whenever `Brightness`
106
+ itself changes across the segment (which it usually does, e.g.
107
+ ramping during a dusk/dawn transition).
108
+ 5. Channels absent from a point default to `0` (off), except
109
+ `Brightness` itself: a point missing that channel entirely is treated
110
+ as a no-op 1000 (100%) multiplier for that point, matching
111
+ `flatten()`'s own `visuals().contains(Brightness)` guard (skips
112
+ scaling entirely rather than treating an absent value as 0, which
113
+ would zero out every other channel).
114
+ 6. `Brightness` itself is returned as the plain raw interpolated value
115
+ between the two points' own `Brightness` levels -- NOT flattened to
116
+ 1000 the way the real `flatten()` call does internally to the
117
+ primitive it's applied to. Unlike the app's own dashboard (which
118
+ doesn't display `Brightness` as its own value at all, only as the
119
+ multiplier baked into every other channel), this library's callers
120
+ (e.g. a dedicated `Brightness` sensor) want the actual current
121
+ master-dimmer level itself.
87
122
 
88
123
  Verified against a synthetic two-point schedule (exact endpoint values,
89
- midpoint blending, and midnight-wraparound blending -- see
124
+ midpoint blending, midnight-wraparound blending, and the `Brightness`
125
+ master-dimmer behavior above, specifically including a case designed to
126
+ mathematically distinguish flatten-then-interpolate from the wrong
127
+ interpolate-then-multiply alternative -- see
90
128
  `tests/test_light_schedule.py`) and against a real 9-point schedule
91
129
  fetched live, which produces a coherent daily light-intensity curve (dim
92
130
  moonlight overnight → ramp to full blues/UV midday → wind back down).
@@ -100,7 +138,7 @@ A static list of `VisualID` values (channel identities) a given light has
100
138
  ## Lunar phase and acclimation ramp
101
139
 
102
140
  Both implemented, on top of the schedule-level master intensity above.
103
- Ported from `LunarInfo.java`/`AcclimationInfo.java` in the decompiled app.
141
+ Confirmed via reverse engineering the app's own lunar and acclimation models.
104
142
 
105
143
  **Lunar phase** (`lunar_percent_reduction(date)`): a simplified moon-phase
106
144
  model -- a Julian-Day-based calculation (`julian_day_from_date()`, the
@@ -116,8 +154,8 @@ where `is_night_segment()` is true (see below), and only when
116
154
  that segment just uses a flat `1.0` (full/unreduced) scalar, matching
117
155
  the app's own fallback.
118
156
 
119
- **`is_night_segment()`**: ported from the boolean half of
120
- `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*
121
159
  simply "is it night" as a general concept -- it's specifically true for
122
160
  the segment whose **start** point IS flagged `NIGHT` (but not also
123
161
  `SUNRISE`) and whose end point is ALSO flagged `NIGHT` -- i.e. once
@@ -127,9 +165,9 @@ point. In practice this covers essentially the whole night, *except*
127
165
  the two transition segments themselves: dusk (start point not yet
128
166
  night-flagged) and the sunrise segment (start point flagged `SUNRISE`).
129
167
 
130
- **Confirmed directly from raw smali bytecode, not the decompiled Java.**
131
- JADX's decompiler carried its own "Code duplicated" warning on this
132
- 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
133
171
  condition's first check inverted (`!point.has(NIGHT)` instead of the
134
172
  actual `point.has(NIGHT)`), which an earlier version of this port
135
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