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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {python_mobius-0.3.1 → python_mobius-0.4.1}/.forgejo/workflows/release.yml +25 -10
  2. {python_mobius-0.3.1 → python_mobius-0.4.1}/CHANGELOG.md +170 -34
  3. {python_mobius-0.3.1 → python_mobius-0.4.1}/PKG-INFO +2 -2
  4. {python_mobius-0.3.1 → python_mobius-0.4.1}/README.md +1 -1
  5. {python_mobius-0.3.1 → python_mobius-0.4.1}/documentation/00-overview.md +2 -2
  6. {python_mobius-0.3.1 → python_mobius-0.4.1}/documentation/01-ble-transport.md +1 -1
  7. {python_mobius-0.3.1 → python_mobius-0.4.1}/documentation/02-framing-and-crc.md +4 -4
  8. {python_mobius-0.3.1 → python_mobius-0.4.1}/documentation/03-attributes-and-opcodes.md +10 -11
  9. {python_mobius-0.3.1 → python_mobius-0.4.1}/documentation/04-device-identity.md +2 -2
  10. {python_mobius-0.3.1 → python_mobius-0.4.1}/documentation/06-light-schedule.md +13 -14
  11. {python_mobius-0.3.1 → python_mobius-0.4.1}/documentation/07-pump-schedule.md +3 -4
  12. {python_mobius-0.3.1 → python_mobius-0.4.1}/documentation/09-thread-coap-relay.md +118 -60
  13. {python_mobius-0.3.1 → python_mobius-0.4.1}/documentation/11-device-status-attributes.md +15 -21
  14. {python_mobius-0.3.1 → python_mobius-0.4.1}/documentation/12-device-identity-and-address-stability.md +7 -23
  15. {python_mobius-0.3.1 → python_mobius-0.4.1}/pyproject.toml +1 -1
  16. {python_mobius-0.3.1 → python_mobius-0.4.1}/src/mobius/__init__.py +9 -6
  17. {python_mobius-0.3.1 → python_mobius-0.4.1}/src/mobius/cli.py +208 -40
  18. {python_mobius-0.3.1 → python_mobius-0.4.1}/src/mobius/coap.py +24 -30
  19. {python_mobius-0.3.1 → python_mobius-0.4.1}/src/mobius/constants.py +30 -11
  20. {python_mobius-0.3.1 → python_mobius-0.4.1}/src/mobius/crc.py +3 -3
  21. {python_mobius-0.3.1 → python_mobius-0.4.1}/src/mobius/device.py +199 -41
  22. {python_mobius-0.3.1 → python_mobius-0.4.1}/src/mobius/device_status.py +56 -16
  23. {python_mobius-0.3.1 → python_mobius-0.4.1}/src/mobius/discovery.py +85 -2
  24. {python_mobius-0.3.1 → python_mobius-0.4.1}/src/mobius/frame.py +1 -1
  25. {python_mobius-0.3.1 → python_mobius-0.4.1}/src/mobius/mesh_address.py +32 -12
  26. {python_mobius-0.3.1 → python_mobius-0.4.1}/src/mobius/modifiers.py +11 -15
  27. {python_mobius-0.3.1 → python_mobius-0.4.1}/src/mobius/power.py +2 -2
  28. {python_mobius-0.3.1 → python_mobius-0.4.1}/src/mobius/pump_status.py +4 -4
  29. {python_mobius-0.3.1 → python_mobius-0.4.1}/src/mobius/relay.py +16 -14
  30. {python_mobius-0.3.1 → python_mobius-0.4.1}/src/mobius/schedule.py +17 -17
  31. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_coap.py +2 -2
  32. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_coap_indication_handling.py +4 -3
  33. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_connection_cleanup.py +2 -2
  34. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_device_time.py +4 -4
  35. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_frame_sniffer.py +2 -1
  36. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_light_intensity_branching.py +1 -1
  37. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_light_schedule.py +3 -4
  38. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_mesh_discovery.py +181 -3
  39. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_mesh_discovery_direct_connect.py +3 -3
  40. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_modifiers.py +5 -5
  41. python_mobius-0.4.1/tests/test_networked_thread_devices.py +302 -0
  42. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_power.py +1 -1
  43. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_pump_status.py +2 -2
  44. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_relay.py +3 -2
  45. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_schedule_intensity_scalar.py +2 -2
  46. {python_mobius-0.3.1 → python_mobius-0.4.1}/.forgejo/scripts/extract_changelog_section.py +0 -0
  47. {python_mobius-0.3.1 → python_mobius-0.4.1}/.gitignore +0 -0
  48. {python_mobius-0.3.1 → python_mobius-0.4.1}/LICENSE +0 -0
  49. {python_mobius-0.3.1 → python_mobius-0.4.1}/documentation/05-scenes.md +0 -0
  50. {python_mobius-0.3.1 → python_mobius-0.4.1}/documentation/08-manufacturer-data.md +0 -0
  51. {python_mobius-0.3.1 → python_mobius-0.4.1}/documentation/10-known-gaps-and-open-questions.md +0 -0
  52. {python_mobius-0.3.1 → python_mobius-0.4.1}/src/mobius/manufacturer.py +0 -0
  53. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_device_status.py +0 -0
  54. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_device_summary.py +0 -0
  55. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_firmware_versions.py +0 -0
  56. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_frame.py +0 -0
  57. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_hyperdrive_device.py +0 -0
  58. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_insolation_detection.py +0 -0
  59. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_manufacturer.py +0 -0
  60. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_manufacturer_lookup.py +0 -0
  61. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_mesh_address.py +0 -0
  62. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_pump_schedule.py +0 -0
  63. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_serial_decoding.py +0 -0
  64. {python_mobius-0.3.1 → python_mobius-0.4.1}/tests/test_serial_identity.py +0 -0
@@ -88,16 +88,30 @@ 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.
99
- # Same event-gating reason as the two steps above -- nothing to
100
- # release against on a manual, non-tag run.
91
+ # Confirmed via direct comparison against ha-mobius's own release
92
+ # workflow (which has never hit this failure) exactly why an
93
+ # earlier version of this step kept failing on a real run ("tag
94
+ # vX.Y.Z exists" / "curl: (26) Failed to open/read local data from
95
+ # file/application") even after release-dir was set explicitly:
96
+ # the YAML here and in ha-mobius's version are structurally
97
+ # identical (same action, same inputs), but ha-mobius's
98
+ # release-dir always has at least one real file in it (a zip it
99
+ # builds beforehand for manual installers), while this repo's own
100
+ # release-dir was a directory that existed but was ALWAYS
101
+ # completely empty (deliberately, since PyPI is meant to be the
102
+ # source of truth here, not a Forgejo release asset). That's
103
+ # consistent with the action's own upload logic looping over
104
+ # release-dir's contents with an unquoted/unguarded shell glob:
105
+ # with zero matching files and no nullglob set, such a glob
106
+ # doesn't expand to nothing, it stays as the literal, unexpanded
107
+ # string -- and curl trying to read a file literally named
108
+ # "dist/release/*" produces exactly this error. Rather than an
109
+ # artificially empty directory, this points release-dir at dist/
110
+ # itself, which already has the actual built wheel/sdist in it
111
+ # from the Build package step above and is therefore never empty
112
+ # -- also means those get attached to the Forgejo release as a
113
+ # side effect, which is a reasonable thing to have anyway, not
114
+ # just a workaround.
101
115
  - name: Create Forgejo release
102
116
  if: github.event_name == 'push'
103
117
  uses: https://code.forgejo.org/actions/forgejo-release@v2
@@ -107,4 +121,5 @@ jobs:
107
121
  repo: r3pek/python-mobius
108
122
  token: ${{ secrets.GITHUB_TOKEN }}
109
123
  tag: ${{ github.ref_name }}
124
+ release-dir: dist
110
125
  release-notes-file: release_notes.md
@@ -1,15 +1,150 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1
4
+
5
+ - Fixed `discover_tank()` sometimes reporting the connected device
6
+ twice in a tank's peer list.
7
+
8
+ ## 0.4.0
9
+
10
+ - **`mobius-scan`'s default now also connects to ONE device and dumps
11
+ real mesh addresses for its whole network**, not just the pan_id
12
+ listing. Reuses the exact same verbose discovery `--dump-mesh-peers`
13
+ already used (`_debug_mesh_discovery()` -- `MLPrefix`/
14
+ `NetworkedThreadDevices` first, falling back to the older Cowboy-hub
15
+ mechanism), so the output is identical in shape whether it happens via
16
+ the default run or that flag explicitly. `--scan-only` now has real
17
+ meaning again (it had briefly become a no-op after the previous
18
+ change made the pan_id listing itself the whole default): it skips
19
+ this connection step, for a genuinely connection-free run.
20
+ `--dump-tanks` still exists separately for connecting to EVERY pan_id
21
+ group rather than just one, useful when multiple genuinely separate
22
+ tanks/networks are in range at once. Verified end-to-end with the
23
+ scan/connect layer mocked out: confirmed exactly one `MobiusDevice`
24
+ connection gets created by the plain default (to the correct,
25
+ first-found serial), and confirmed `--scan-only` still makes zero.
26
+
27
+ - **Added `Tank` and `discover_tank()`: the connection-based building
28
+ block for full tank discovery** (device + address + age + shared
29
+ prefix, all in one call) -- the "basic block" a downstream integration
30
+ (e.g. ha-mobius) can call to trigger an on-demand scan and show
31
+ address/age/prefix for its devices. `discover_tank(mdevice)` takes an
32
+ already-connected `MobiusDevice`, calls `discover_mesh_peers_auto()`
33
+ plus `get_own_mesh_address()`/`get_device_info()` to include the
34
+ connected device itself in the result (the peer-discovery calls only
35
+ ever report OTHER devices, never themselves) -- so a single-device
36
+ tank correctly comes back with that one device as its sole peer, not
37
+ an empty list that would be indistinguishable from "discovery failed
38
+ entirely." `tank.prefix` is `None` (and `peers` always empty) if the
39
+ connected device didn't report a valid mesh address at all. 3 new
40
+ tests. Also exported `mesh_local_prefix_from_mlprefix()` from the
41
+ top-level `mobius` package -- a real gap from when it was first added,
42
+ caught while adding these new exports alongside it.
43
+
44
+ - **`mobius-scan`'s default behavior changed: now genuinely just scans
45
+ and lists devices grouped by tank, no connections at all** -- it used
46
+ to print that same pan_id-grouped listing (already existed, already
47
+ connection-free) and then STILL go on to connect to every single
48
+ scanned device for a full summary regardless. That's now opt-in via a
49
+ new `--full-summaries` flag instead of being the implicit default.
50
+ Added `--dump-tanks`: connects to just ONE device per pan_id group
51
+ (not every device) and calls the new `discover_tank()` on it, printing
52
+ the full mesh-address/age for every device sharing that tank --
53
+ richer than the default listing, which only has whatever's in the BLE
54
+ advertisement (model/serial), not address or age. `--scan-only` kept
55
+ accepted as a no-op (it's redundant now that its old behavior IS the
56
+ default) rather than removed, so no existing script using it
57
+ explicitly breaks. Renamed the printed header from "Pan ID groups" to
58
+ "Tanks" to match. Verified end-to-end with the scan/connect layer
59
+ mocked out, for both the new default (confirmed zero MobiusDevice
60
+ connections created) and `--dump-tanks` (confirmed it correctly
61
+ prints prefix/address/age for a 2-device tank), not just via
62
+ `--help`'s rendered text.
63
+
64
+ - **`discover_networked_thread_devices()` confirmed against real
65
+ hardware** (a live 4-device tank: 2 VorTech pumps, 2 Radion lights) --
66
+ the byte layout, little-endian field order, and all 4 real peers'
67
+ model/serial/age/derived-address all matched exactly. One real
68
+ correction from this: empty slots on real hardware are encoded as 28
69
+ bytes of raw zero (an all-null-byte serial), **not** the ASCII
70
+ `"00000000000000"` sentinel string `discover_mesh_peers()`'s older
71
+ mechanism uses -- the existing `if not serial` filter already handled
72
+ this correctly (an all-null-byte serial decodes to an empty string
73
+ after stripping), but the docstring incorrectly claimed this newer
74
+ mechanism used the same ASCII convention as the older one. Docstring
75
+ corrected; added a dedicated test for the actual null-byte case, plus
76
+ a new test replaying the exact real capture end-to-end (all 4 peers,
77
+ interleaved with the 26 real empty slots).
78
+
79
+ - **Added `NetworkedThreadDevices` (1001) + `MLPrefix` (2128) support: a
80
+ second, better Thread mesh peer-discovery mechanism.** Confirmed via
81
+ reverse engineering the app's own tank-setup logic to be checked via a
82
+ generic support check on the connected device, **not gated to a
83
+ Cowboy hub** the way the existing three-parallel-arrays mechanism
84
+ (`discover_mesh_peers()`) is -- found while investigating whether the
85
+ app caches device mesh addresses at all (it does, persisting them to
86
+ a local file and syncing them to a cloud backend as part of each
87
+ device's saved state; this attribute is what actually populates that
88
+ cache in the first place). `MobiusDevice.discover_networked_thread_devices()`
89
+ fetches both attributes and returns every peer the connected device
90
+ knows about in a single round-trip, including a per-peer "age" value
91
+ (present in the confirmed wire format, but its exact meaning isn't
92
+ independently confirmed against real hardware -- see `MeshPeer`'s own
93
+ docstring). `MeshPeer` gained an `age: Optional[int] = None` field
94
+ (only ever set by this new method; `None` from every other source, a
95
+ non-breaking addition verified against all 4 existing `MeshPeer(...)`
96
+ construction sites, all of which use keyword arguments). Added
97
+ `mesh_local_prefix_from_mlprefix()` to `mobius.mesh_address`
98
+ (MLPrefix's own response is 24 bytes; only the first 8 are the real
99
+ prefix, confirmed via reverse engineering).
100
+
101
+ - **Added `MobiusDevice.discover_mesh_peers_auto()`: the new
102
+ recommended default entry point for mesh peer discovery**, replacing
103
+ the previous recommendation to call `discover_mesh_peers()` or
104
+ `discover_mesh_peers_via_direct_connect()` directly depending on
105
+ hardware. Tries `discover_networked_thread_devices()` first, falling
106
+ back to `discover_mesh_peers()` only if that finds nothing --
107
+ `discover_mesh_peers_via_direct_connect()` isn't included in this
108
+ chain, since it needs a list of scanned devices as input rather than
109
+ an already-connected `MobiusDevice`; it remains the right fallback to
110
+ reach for explicitly if both of the above come back empty.
111
+
112
+ - **On "discovering Tanks"**: investigated and confirmed not locally
113
+ possible -- the app's own "Tank" grouping is account-level and
114
+ cloud-synced, with user-edited metadata (name, photo) that has no
115
+ local/BLE equivalent to read. The closest local approximation this
116
+ library can offer: every peer `discover_networked_thread_devices()`
117
+ returns shares the connected device's own `MLPrefix`, which in
118
+ practice is "every device on the same Thread mesh" -- for a typical
119
+ setup, the same thing as "every device in the same aquarium."
120
+ Documented as an approximation, not presented as equivalent to the
121
+ app's own Tank object.
122
+
123
+ - **`mobius-scan` CLI updated to match**: `--dump-mesh-peers` and
124
+ `--relay-target` now run the new `discover_mesh_peers_auto()`
125
+ priority order (step 0: `MLPrefix`/`NetworkedThreadDevices`, falling
126
+ through to the existing Cowboy-hub-specific steps only if that finds
127
+ nothing) instead of only ever exercising the older mechanism;
128
+ `--dump-mesh-peers`' output now also prints each peer's `age` when
129
+ set. `--build-peer-map` unchanged in behavior, now documented as the
130
+ last-resort fallback when both attribute-based mechanisms come back
131
+ empty. 10 new tests covering the new discovery method, the prefix
132
+ helper, and both branches of `discover_mesh_peers_auto()`'s fallback
133
+ logic -- plus two direct end-to-end simulations of the updated CLI
134
+ debug path (both the new-mechanism-succeeds case and the
135
+ falls-through-to-the-old-mechanism case) run manually before
136
+ delivery, not just unit-level mocks.
137
+
3
138
  ## 0.3.1
4
139
 
5
140
  - **Fixed a real, high-impact bug: `Brightness` (the master dimmer
6
141
  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
142
+ Confirmed via reverse engineering the app's own "flatten" logic, called
143
+ on both bracketing schedule points before
144
+ interpolating between them -- an earlier decompilation attempt for this logic
145
+ was unreliable here, the same underlying issue that led to
11
146
  finding the `is_night_segment()` bug, so this was re-verified against
12
- bytecode rather than trusted from the decompile a second time). Every
147
+ the raw bytecode rather than trusted from the decompile a second time). Every
13
148
  channel -- moonlight included -- was being interpolated and returned
14
149
  without ever being scaled by `Brightness`, found via real hardware
15
150
  testing: a light showing ~1% in the app's own display came back as
@@ -33,9 +168,9 @@
33
168
 
34
169
  - **Fixed a real, high-impact bug in `is_night_segment()`: the condition
35
170
  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
171
+ inverted**, confirmed directly against the raw bytecode after an
172
+ earlier decompilation attempt on this exact
173
+ method turned out to be unreliable -- a real signal, not noise: the decompiler had
39
174
  produced `!point.has(NIGHT)` where the actual bytecode requires
40
175
  `point.has(NIGHT)`. In practice this meant lunar reduction was applied
41
176
  only during the single dusk-transition segment (and only if the query
@@ -116,16 +251,16 @@ for the full protocol trace and implementation history. The `coap-relay`
116
251
  branch has been merged into main as part of this release.
117
252
 
118
253
  - **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
254
+ after the user provided the raw disassembly for
255
+ the app's own connection logic -- the decompiler had given up specifically on
256
+ the indication-handling logic, the one thing that held the answer, but the
122
257
  underlying bytecode was still traceable. Confirmed precisely: the real
123
258
  CoAP relay response arrives on a completely separate opcode (`0xDF`/
124
259
  `26`, `COAP_INDICATION_OPCODE` -- not the request's `25`, whose confirm
125
260
  is a bare, meaningless ack the app itself discards, confirmed
126
261
  byte-for-byte via real hardware testing showing exactly 1 byte in every
127
262
  relayed read's outer response), matched purely by CoAP token
128
- (`lambda$getSentRequest$6`: `coapRequest.getToken() ==
263
+ (confirmed via reverse engineering: `coapRequest.getToken() ==
129
264
  coapResponse.getToken()`), never by the outer FSCI message ID. Added
130
265
  `MobiusDevice._register_coap_token()`/`wait_for_coap_response()` -- a
131
266
  token-keyed pending-response registry, entirely separate from the
@@ -149,7 +284,8 @@ branch has been merged into main as part of this release.
149
284
  even though that same device's own address reads back fine). Added
150
285
  `MobiusDevice.get_own_mesh_address()` and `mobius.discovery.
151
286
  discover_mesh_peers_via_direct_connect()` as the alternative --
152
- confirmed via `PeripheralConnection.java` to be exactly how the app
287
+ confirmed via reverse engineering the app's own connection-setup logic
288
+ to be exactly how the app
153
289
  itself always learns a device's address (automatic on every
154
290
  connection, not Cowboy-specific). Added `mobius-scan --build-peer-map`
155
291
  to exercise this from the CLI. `discover_mesh_peers()` remains in
@@ -179,15 +315,15 @@ branch has been merged into main as part of this release.
179
315
 
180
316
  - **Confirmed directly from source why the response comes back
181
317
  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
318
+ discarded by the app itself, on purpose.** The app's own byte-dispatch
319
+ logic matches `opGroup==0xDF && opCode==25` unconditionally
184
320
  and logs it only -- before the dispatcher ever checks whether it
185
321
  resolves a pending request. Real hardware testing independently
186
322
  confirmed this byte-for-byte: every relayed read's outer response is
187
323
  exactly 1 byte (`0x00`), not a truncated response but the genuine
188
324
  complete confirm, thrown away by the app too. The real response, if it
189
325
  arrives at all, comes through a separate "indication" handled by
190
- `handleIndication()` -- a method JADX couldn't decompile, a genuine
326
+ the app's own indication-handling logic -- a method the decompiler couldn't resolve to readable code, a genuine
191
327
  limit of what source tracing alone can determine here. Added
192
328
  `MobiusDevice.on_any_frame` (fires for every parsed incoming frame,
193
329
  not just ones resolving a pending request) and extended
@@ -198,8 +334,8 @@ branch has been merged into main as part of this release.
198
334
  - **Added `mobius.coap`**: CoAP (RFC 7252) protocol primitives --
199
335
  `CoapRequestType`/`CoapMethod`/`CoapResponseCode` enums,
200
336
  `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`) --
337
+ hardware dependency. Confirmed wire format traced directly from
338
+ reverse engineering the app's own CoAP model --
203
339
  see the module's docstring for exactly which enum values are
204
340
  confirmed-literal, cross-referenced from elsewhere in this project, or
205
341
  inferred-only (deliberately not guessing at unconfirmed ones -- an
@@ -213,8 +349,8 @@ branch has been merged into main as part of this release.
213
349
  `mesh_local_prefix_from_own_address()`/`is_valid_mesh_address()`/
214
350
  `is_short_address_derived()`). Pure functions, no hardware dependency.
215
351
  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
352
+ short-address portion is little-endian (confirmed via reverse
353
+ engineering the app's own byte-extraction logic), the
218
354
  opposite of IPv6's own conventional big-endian byte order -- easy to
219
355
  get backwards by assuming the IPv6 standard's own convention applies
220
356
  here too. 15 new tests.
@@ -228,8 +364,8 @@ branch has been merged into main as part of this release.
228
364
  per-element format matches the existing 14-byte ASCII serial format
229
365
  already used elsewhere in this library -- no new parsing logic needed.
230
366
  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
367
+ `"00000000000000"`, confirmed via reverse engineering the app's own
368
+ network-setup cleanup logic). Fails soft to an empty list for unsupported devices or
233
369
  ones not currently part of an active Thread network, matching this
234
370
  library's usual convention. Also recorded (but not yet used) a fourth
235
371
  parallel array, `BladeBleAddressArray` (3714, each peer's real BLE MAC
@@ -278,8 +414,8 @@ branch has been merged into main as part of this release.
278
414
 
279
415
  - **Added `get_device_time_info()`** (`Epoch`/`RTCTime`, attributes
280
416
  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
417
+ confirmed via the app's own "Time Difference" troubleshooting feature,
418
+ which checks these exact two attributes
283
419
  the same way. Included automatically in `get_device_summary()`'s
284
420
  `"device_time"` field. Important caveat documented: the app's own
285
421
  schedule interpolation uses the phone/tank clock, not either of these,
@@ -400,8 +536,7 @@ branch has been merged into main as part of this release.
400
536
  (`IsCalibrated`/`LastCalibrationTime`/`MinCalibratedSpeed`/
401
537
  `MaxCalibratedSpeed`, 1300/1304/1308/1309) -- **important correction**:
402
538
  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
539
+ (its own device category check) that this is a light feature, not a pump
405
540
  one. A separate, unrelated pump-side calibration flow exists in the app
406
541
  (Nero-specific zero-flow/max-flow calibration) but isn't implemented and
407
542
  doesn't apply to VorTech anyway (no physical flow sensor to calibrate).
@@ -430,7 +565,7 @@ branch has been merged into main as part of this release.
430
565
  - **Added full hyperdrive support** (correct percentage conversion for
431
566
  light channels boosted above their normal 100% rating). New
432
567
  `mobius.power` module (`ChannelPowerInfo`, `channel_percent_value()`,
433
- ported from `LightPowerInfo.java`) and `get_channel_power_info()`
568
+ confirmed via reverse engineering the app's own power model) and `get_channel_power_info()`
434
569
  (`MaxPower`/`NormalPower`, attributes 1504/1513). New
435
570
  `get_current_light_percentages()` convenience method returns corrected
436
571
  0-100+ percentages instead of raw permille, flagging boosted channels via
@@ -463,20 +598,21 @@ branch has been merged into main as part of this release.
463
598
  a schedule-level master intensity scalar (`Schedule1Intensity`, attribute
464
599
  511) that the real app applies on top. Caught by direct comparison
465
600
  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.
601
+ library computed for the same light at the same time. Traced through
602
+ reverse engineering the app's own dashboard-rendering logic.
468
603
  Added `get_schedule_intensity()`; `get_current_light_intensities()` now
469
604
  applies it automatically.
470
605
  - **Added lunar-phase and acclimation-ramp support**, the two remaining
471
606
  modifiers flagged as gaps in the previous entry. New `mobius.modifiers`
472
- module, ported from `LunarInfo.java`/`AcclimationInfo.java`:
607
+ module, confirmed via reverse engineering the app's own lunar and
608
+ acclimation models:
473
609
  - `lunar_percent_reduction(date)` -- verified against real,
474
610
  independently-confirmed 2026 moon phase dates (full moon Jan 3, new
475
611
  moon Jan 18); the ported algorithm matches exactly.
476
612
  - `AcclimationInfo` -- linear intensity ramp from a starting percentage
477
613
  up to full over a configured period.
478
- - `is_night_segment()` -- ported from the boolean half of
479
- `PointSchedule.getIntensitiesAtTime()`; confirmed this is specifically
614
+ - `is_night_segment()` -- confirmed via reverse engineering the boolean half of
615
+ the app's own schedule-interpolation logic; confirmed this is specifically
480
616
  "the dusk-to-night transition segment," not simply "is it night."
481
617
  - `get_lunar_enabled()`, `get_acclimation_info()`,
482
618
  `get_current_light_intensities()` now applies the full branching the
@@ -491,8 +627,8 @@ branch has been merged into main as part of this release.
491
627
 
492
628
  ## 0.1.2
493
629
 
494
- - Added `manufacturer_for_model()` / `MODEL_MANUFACTURER` (ported from
495
- `M.Model.getManufacturer()`) — surfaces "EcoTech Marine" /
630
+ - Added `manufacturer_for_model()` / `MODEL_MANUFACTURER` (confirmed via
631
+ reverse engineering the app's own manufacturer-lookup logic) — surfaces "EcoTech Marine" /
496
632
  "AquaIllumination" / "Neptune Systems" / "NYOS" per device model. Now
497
633
  included automatically in `get_device_info()`'s `"manufacturer"` field.
498
634
  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.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
@@ -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