ditoo-claude-meter 0.1.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 (43) hide show
  1. ditoo_claude_meter-0.1.0/.gitignore +7 -0
  2. ditoo_claude_meter-0.1.0/CHANGELOG.md +11 -0
  3. ditoo_claude_meter-0.1.0/LICENSE +21 -0
  4. ditoo_claude_meter-0.1.0/PKG-INFO +149 -0
  5. ditoo_claude_meter-0.1.0/README.md +133 -0
  6. ditoo_claude_meter-0.1.0/docs/DECISIONS.md +92 -0
  7. ditoo_claude_meter-0.1.0/docs/PROTOCOL.md +310 -0
  8. ditoo_claude_meter-0.1.0/docs/ROADMAP.md +263 -0
  9. ditoo_claude_meter-0.1.0/pyproject.toml +35 -0
  10. ditoo_claude_meter-0.1.0/src/ditoo_meter/__init__.py +0 -0
  11. ditoo_claude_meter-0.1.0/src/ditoo_meter/art/idle.pix +15 -0
  12. ditoo_claude_meter-0.1.0/src/ditoo_meter/art/sleep.pix +15 -0
  13. ditoo_claude_meter-0.1.0/src/ditoo_meter/art/waiting.pix +15 -0
  14. ditoo_claude_meter-0.1.0/src/ditoo_meter/art/working.pix +15 -0
  15. ditoo_claude_meter-0.1.0/src/ditoo_meter/cli.py +320 -0
  16. ditoo_claude_meter-0.1.0/src/ditoo_meter/config.py +67 -0
  17. ditoo_claude_meter-0.1.0/src/ditoo_meter/daemon.py +103 -0
  18. ditoo_claude_meter-0.1.0/src/ditoo_meter/discovery.py +36 -0
  19. ditoo_claude_meter-0.1.0/src/ditoo_meter/doctor.py +90 -0
  20. ditoo_claude_meter-0.1.0/src/ditoo_meter/frame.py +71 -0
  21. ditoo_claude_meter-0.1.0/src/ditoo_meter/integrate.py +243 -0
  22. ditoo_claude_meter-0.1.0/src/ditoo_meter/link.py +275 -0
  23. ditoo_claude_meter-0.1.0/src/ditoo_meter/link_helper.py +195 -0
  24. ditoo_claude_meter-0.1.0/src/ditoo_meter/png.py +33 -0
  25. ditoo_claude_meter-0.1.0/src/ditoo_meter/protocol.py +156 -0
  26. ditoo_claude_meter-0.1.0/src/ditoo_meter/render.py +90 -0
  27. ditoo_claude_meter-0.1.0/src/ditoo_meter/sprites.py +67 -0
  28. ditoo_claude_meter-0.1.0/src/ditoo_meter/state.py +117 -0
  29. ditoo_claude_meter-0.1.0/src/ditoo_meter/usage.py +101 -0
  30. ditoo_claude_meter-0.1.0/tests/fake_helper.py +46 -0
  31. ditoo_claude_meter-0.1.0/tests/test_cli_entrypoint.py +22 -0
  32. ditoo_claude_meter-0.1.0/tests/test_daemon.py +91 -0
  33. ditoo_claude_meter-0.1.0/tests/test_discovery.py +17 -0
  34. ditoo_claude_meter-0.1.0/tests/test_integrate.py +202 -0
  35. ditoo_claude_meter-0.1.0/tests/test_link.py +156 -0
  36. ditoo_claude_meter-0.1.0/tests/test_platform_gate.py +68 -0
  37. ditoo_claude_meter-0.1.0/tests/test_protocol.py +197 -0
  38. ditoo_claude_meter-0.1.0/tests/test_render.py +102 -0
  39. ditoo_claude_meter-0.1.0/tests/test_sprites.py +70 -0
  40. ditoo_claude_meter-0.1.0/tests/test_state.py +122 -0
  41. ditoo_claude_meter-0.1.0/tests/test_tap.py +120 -0
  42. ditoo_claude_meter-0.1.0/tests/test_usage.py +120 -0
  43. ditoo_claude_meter-0.1.0/uv.lock +138 -0
@@ -0,0 +1,7 @@
1
+ _legacy/
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ .pytest_cache/
6
+ *.egg-info/
7
+ dist/
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release. Pushes Claude Code's 5h/7d usage quota to a Divoom Ditoo
6
+ Mic's 16x16 Bluetooth display as a ring gauge with an activity-state
7
+ creature sprite. `ditoo-meter setup` wires up a `statusLine` tap, activity
8
+ hooks, and a launchd-managed background daemon; `ditoo-meter list-devices`
9
+ finds the paired device. macOS only. See `docs/PROTOCOL.md` for the wire
10
+ protocol and `docs/DECISIONS.md` for the reasoning behind the current
11
+ architecture.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nowhereman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.5
2
+ Name: ditoo-claude-meter
3
+ Version: 0.1.0
4
+ Summary: Push Claude Code 5h/7d usage quota to a Divoom Ditoo Mic's 16x16 display over Bluetooth
5
+ Project-URL: Homepage, https://github.com/nowheremanx/ditoo-claude-meter
6
+ Project-URL: Repository, https://github.com/nowheremanx/ditoo-claude-meter
7
+ Project-URL: Issues, https://github.com/nowheremanx/ditoo-claude-meter/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Environment :: MacOS X
11
+ Classifier: Operating System :: MacOS :: MacOS X
12
+ Classifier: Topic :: System :: Monitoring
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: pyobjc-framework-iobluetooth; sys_platform == 'darwin'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # ditoo-claude-meter
18
+
19
+ Pushes Claude Code's 5h/7d usage quota to a [Divoom Ditoo Mic](https://divoom.com/)'s
20
+ 16x16 Bluetooth display, as a ring gauge around a small creature that reflects
21
+ whatever Claude Code is currently doing (working / waiting / idle / asleep).
22
+
23
+ ```
24
+ uv tool install ditoo-claude-meter
25
+ ditoo-meter list-devices # find your paired device
26
+ ditoo-meter setup # wire up statusLine + hooks + the background daemon
27
+ ```
28
+
29
+ Full wire-protocol detail (frame format, commands, real-hardware findings)
30
+ lives in [`docs/PROTOCOL.md`](docs/PROTOCOL.md) and is not repeated here.
31
+ This README exists to explain the handful of design decisions that aren't
32
+ obvious from reading the code, so that extending this project doesn't mean
33
+ re-deriving them from scratch.
34
+
35
+ ## 1. Two names, one device -- the first mistake everyone makes
36
+
37
+ The device answers to *two different Bluetooth names for the same physical
38
+ unit and address*: the Classic radio calls itself `<name>-Audio`, the BLE
39
+ radio advertises as `<name>-Light`. The display protocol lives on the
40
+ **Classic (`-Audio`) side**, despite what the name suggests -- BLE only
41
+ exposes a transparent-UART service that nothing in this project (or any
42
+ known open-source Divoom project) has gotten to accept image data. If you're
43
+ poking around with a Bluetooth scanner and see `-Light`, that's the wrong
44
+ radio for this project's purposes; look for pairing/SDP activity on the
45
+ `-Audio` name instead. See PROTOCOL.md's "Transport" and "Why not BLE"
46
+ sections for the full story, including what was tried against BLE and why it
47
+ didn't pan out.
48
+
49
+ ## 2. Why the Bluetooth helper is a separate subprocess
50
+
51
+ `link_helper.py` runs as its own subprocess (`python -m ditoo_meter.link_helper
52
+ <MAC>`), spoken to over stdin/stdout by `link.py`, rather than being called
53
+ directly from the daemon. This isn't incidental complexity: `pyobjc-framework-
54
+ IOBluetooth`'s async APIs only deliver their callbacks (RFCOMM channel open,
55
+ incoming data, channel closed) by pumping an `NSRunLoop`. The daemon's own
56
+ loop is a plain synchronous `while` with a `time.sleep(0.5)` -- there's no
57
+ run loop for IOBluetooth's callbacks to arrive on. Reconciling the two in one
58
+ process means either running IOBluetooth on a dedicated thread with its own
59
+ run loop and shuttling data across a queue, or making the daemon's main loop
60
+ itself run loop-driven and fighting IOBluetooth for control of it. A separate
61
+ process sidesteps both: the helper owns an event loop it can pump freely
62
+ (`link_helper.py`'s `_pump()`), and the daemon side (`link.py`) stays fully
63
+ synchronous -- `ensure_connected()`, `send_burst()`, and friends are plain
64
+ function calls with no async machinery of their own. The cost is a pipe
65
+ protocol and a subprocess lifecycle to manage (see `link.py`'s lock-guarded
66
+ state machine), which is a smaller problem than making two different
67
+ concurrency models share one process.
68
+
69
+ ## 3. The statusLine tap passthrough contract
70
+
71
+ `ditoo-meter setup` takes over `~/.claude/settings.json`'s `statusLine`
72
+ slot, pointing it at `ditoo-meter tap`. If you already had a `statusLine`
73
+ command configured, whatever it printed must keep printing -- this tool
74
+ occupies the slot, it doesn't get to break your existing statusline.
75
+
76
+ - **`setup`** snapshots your prior `statusLine.command` (if any) into
77
+ `~/.config/ditoo-claude-meter/config.json` as `passthrough`, then installs
78
+ its own command in its place. It has to happen at install time, once --
79
+ by the time `tap` is running, `statusLine` already points at us, so the
80
+ original command has nowhere else to be read from later.
81
+ - **`tap`** (called by Claude Code on every statusline tick) always reads
82
+ the usage payload off stdin first and records it, then -- if a
83
+ `passthrough` command was saved -- re-invokes *that* command with the same
84
+ stdin and relays its stdout/stderr/exit code byte-for-byte, unmodified.
85
+ Whatever your original statusline displayed (formatting, colors, other
86
+ integrations) is not ours to reinterpret. If there's no saved passthrough,
87
+ or the passthrough command times out or errors, `tap` falls back to
88
+ printing its own compact usage line -- a stuck passthrough must not be
89
+ able to take Claude Code's statusline down with it.
90
+ - **`undo`** restores the saved `passthrough` command back into
91
+ `statusLine` and clears the saved copy -- but only when `statusLine` still
92
+ actually points at us at the time `undo` runs. If you've hand-edited
93
+ `statusLine` since `setup` (pointing it at something else), `undo` leaves
94
+ it alone and *does not* discard the saved `passthrough` value, since that
95
+ might be the only copy of your original command; it prints a note telling
96
+ you where to find it in config.json instead.
97
+
98
+ ## 4. Why animations make the daemon simpler
99
+
100
+ Real hardware finding (see PROTOCOL.md's "Multi-frame animation"): **the
101
+ device loops a pushed animation locally and indefinitely.** Push once, it
102
+ keeps playing until replaced -- there's no need to re-send frames on a
103
+ timer. Every type in this codebase reflects that: `render()` always returns
104
+ an `Animation` (a tuple of `(frame, duration_ms)` pairs), even a static
105
+ scene is just a one-frame animation, and `protocol.commands_for()` decides
106
+ whether that goes out as a single `0x44` image or a chunked `0x49`
107
+ animation. The consequence that matters most for the daemon: **alert
108
+ blinking (usage over 100%) is a real 2-frame animation, not a 500ms
109
+ re-render loop.** The daemon hashes the animation it would push and only
110
+ talks to Bluetooth when that hash changes from what's already on the
111
+ device -- in steady state, with nothing to say, it sends nothing at all.
112
+
113
+ ## 5. Silent failure -- why pacing is insurance, not decoration
114
+
115
+ `0x44` and `0x49` (the image/animation push commands) never get an ACK
116
+ from the device. A dropped write and a successful one look identical at the
117
+ RFCOMM layer -- silence either way. Two consequences follow directly from
118
+ this, both load-bearing:
119
+
120
+ - `link.py` waits `CHANNEL_SETTLE_S` (~1.5s, real-hardware-verified) after
121
+ sending the channel-switch command before it will attempt to push an
122
+ image. Skipping this produces a write that "succeeds" and a screen that
123
+ never updates.
124
+ - `Link.send_burst()` puts a small gap between chunks of a multi-part push
125
+ instead of firing them back-to-back. It's the only line of defense
126
+ against a burst the device can't keep up with, given that there's no way
127
+ to ask it afterward whether the push landed.
128
+
129
+ If you need positive confirmation the link is alive, `0x46` (get view) is
130
+ the one command in this protocol that *does* reply -- see PROTOCOL.md.
131
+
132
+ ## A note on naming
133
+
134
+ The repo/package is `ditoo-claude-meter`, the importable module is
135
+ `ditoo_meter`, and the installed command is `ditoo-meter`. That's three
136
+ different spellings for one project. It's intentional (PyPI package names
137
+ can't contain underscores the way Python module names require), not an
138
+ inconsistency to "fix" -- if you see `ditoo_meter` in an import and
139
+ `ditoo-claude-meter` in a `pip install`, that's expected.
140
+
141
+ ## Linux
142
+
143
+ Not implemented. `link.py`'s `Link._popen()` has an explicit branch on
144
+ `sys.platform` where a Linux transport would plug in -- Classic SPP over
145
+ `socket.AF_BLUETOOTH` is roughly a dozen lines (connect, then plain
146
+ `socket.send`/`recv` instead of the IOBluetooth async dance macOS needs).
147
+ No IOBluetooth-style run-loop problem exists on Linux, so a Linux helper
148
+ likely wouldn't even need the separate-subprocess split described above.
149
+ Contributions welcome.
@@ -0,0 +1,133 @@
1
+ # ditoo-claude-meter
2
+
3
+ Pushes Claude Code's 5h/7d usage quota to a [Divoom Ditoo Mic](https://divoom.com/)'s
4
+ 16x16 Bluetooth display, as a ring gauge around a small creature that reflects
5
+ whatever Claude Code is currently doing (working / waiting / idle / asleep).
6
+
7
+ ```
8
+ uv tool install ditoo-claude-meter
9
+ ditoo-meter list-devices # find your paired device
10
+ ditoo-meter setup # wire up statusLine + hooks + the background daemon
11
+ ```
12
+
13
+ Full wire-protocol detail (frame format, commands, real-hardware findings)
14
+ lives in [`docs/PROTOCOL.md`](docs/PROTOCOL.md) and is not repeated here.
15
+ This README exists to explain the handful of design decisions that aren't
16
+ obvious from reading the code, so that extending this project doesn't mean
17
+ re-deriving them from scratch.
18
+
19
+ ## 1. Two names, one device -- the first mistake everyone makes
20
+
21
+ The device answers to *two different Bluetooth names for the same physical
22
+ unit and address*: the Classic radio calls itself `<name>-Audio`, the BLE
23
+ radio advertises as `<name>-Light`. The display protocol lives on the
24
+ **Classic (`-Audio`) side**, despite what the name suggests -- BLE only
25
+ exposes a transparent-UART service that nothing in this project (or any
26
+ known open-source Divoom project) has gotten to accept image data. If you're
27
+ poking around with a Bluetooth scanner and see `-Light`, that's the wrong
28
+ radio for this project's purposes; look for pairing/SDP activity on the
29
+ `-Audio` name instead. See PROTOCOL.md's "Transport" and "Why not BLE"
30
+ sections for the full story, including what was tried against BLE and why it
31
+ didn't pan out.
32
+
33
+ ## 2. Why the Bluetooth helper is a separate subprocess
34
+
35
+ `link_helper.py` runs as its own subprocess (`python -m ditoo_meter.link_helper
36
+ <MAC>`), spoken to over stdin/stdout by `link.py`, rather than being called
37
+ directly from the daemon. This isn't incidental complexity: `pyobjc-framework-
38
+ IOBluetooth`'s async APIs only deliver their callbacks (RFCOMM channel open,
39
+ incoming data, channel closed) by pumping an `NSRunLoop`. The daemon's own
40
+ loop is a plain synchronous `while` with a `time.sleep(0.5)` -- there's no
41
+ run loop for IOBluetooth's callbacks to arrive on. Reconciling the two in one
42
+ process means either running IOBluetooth on a dedicated thread with its own
43
+ run loop and shuttling data across a queue, or making the daemon's main loop
44
+ itself run loop-driven and fighting IOBluetooth for control of it. A separate
45
+ process sidesteps both: the helper owns an event loop it can pump freely
46
+ (`link_helper.py`'s `_pump()`), and the daemon side (`link.py`) stays fully
47
+ synchronous -- `ensure_connected()`, `send_burst()`, and friends are plain
48
+ function calls with no async machinery of their own. The cost is a pipe
49
+ protocol and a subprocess lifecycle to manage (see `link.py`'s lock-guarded
50
+ state machine), which is a smaller problem than making two different
51
+ concurrency models share one process.
52
+
53
+ ## 3. The statusLine tap passthrough contract
54
+
55
+ `ditoo-meter setup` takes over `~/.claude/settings.json`'s `statusLine`
56
+ slot, pointing it at `ditoo-meter tap`. If you already had a `statusLine`
57
+ command configured, whatever it printed must keep printing -- this tool
58
+ occupies the slot, it doesn't get to break your existing statusline.
59
+
60
+ - **`setup`** snapshots your prior `statusLine.command` (if any) into
61
+ `~/.config/ditoo-claude-meter/config.json` as `passthrough`, then installs
62
+ its own command in its place. It has to happen at install time, once --
63
+ by the time `tap` is running, `statusLine` already points at us, so the
64
+ original command has nowhere else to be read from later.
65
+ - **`tap`** (called by Claude Code on every statusline tick) always reads
66
+ the usage payload off stdin first and records it, then -- if a
67
+ `passthrough` command was saved -- re-invokes *that* command with the same
68
+ stdin and relays its stdout/stderr/exit code byte-for-byte, unmodified.
69
+ Whatever your original statusline displayed (formatting, colors, other
70
+ integrations) is not ours to reinterpret. If there's no saved passthrough,
71
+ or the passthrough command times out or errors, `tap` falls back to
72
+ printing its own compact usage line -- a stuck passthrough must not be
73
+ able to take Claude Code's statusline down with it.
74
+ - **`undo`** restores the saved `passthrough` command back into
75
+ `statusLine` and clears the saved copy -- but only when `statusLine` still
76
+ actually points at us at the time `undo` runs. If you've hand-edited
77
+ `statusLine` since `setup` (pointing it at something else), `undo` leaves
78
+ it alone and *does not* discard the saved `passthrough` value, since that
79
+ might be the only copy of your original command; it prints a note telling
80
+ you where to find it in config.json instead.
81
+
82
+ ## 4. Why animations make the daemon simpler
83
+
84
+ Real hardware finding (see PROTOCOL.md's "Multi-frame animation"): **the
85
+ device loops a pushed animation locally and indefinitely.** Push once, it
86
+ keeps playing until replaced -- there's no need to re-send frames on a
87
+ timer. Every type in this codebase reflects that: `render()` always returns
88
+ an `Animation` (a tuple of `(frame, duration_ms)` pairs), even a static
89
+ scene is just a one-frame animation, and `protocol.commands_for()` decides
90
+ whether that goes out as a single `0x44` image or a chunked `0x49`
91
+ animation. The consequence that matters most for the daemon: **alert
92
+ blinking (usage over 100%) is a real 2-frame animation, not a 500ms
93
+ re-render loop.** The daemon hashes the animation it would push and only
94
+ talks to Bluetooth when that hash changes from what's already on the
95
+ device -- in steady state, with nothing to say, it sends nothing at all.
96
+
97
+ ## 5. Silent failure -- why pacing is insurance, not decoration
98
+
99
+ `0x44` and `0x49` (the image/animation push commands) never get an ACK
100
+ from the device. A dropped write and a successful one look identical at the
101
+ RFCOMM layer -- silence either way. Two consequences follow directly from
102
+ this, both load-bearing:
103
+
104
+ - `link.py` waits `CHANNEL_SETTLE_S` (~1.5s, real-hardware-verified) after
105
+ sending the channel-switch command before it will attempt to push an
106
+ image. Skipping this produces a write that "succeeds" and a screen that
107
+ never updates.
108
+ - `Link.send_burst()` puts a small gap between chunks of a multi-part push
109
+ instead of firing them back-to-back. It's the only line of defense
110
+ against a burst the device can't keep up with, given that there's no way
111
+ to ask it afterward whether the push landed.
112
+
113
+ If you need positive confirmation the link is alive, `0x46` (get view) is
114
+ the one command in this protocol that *does* reply -- see PROTOCOL.md.
115
+
116
+ ## A note on naming
117
+
118
+ The repo/package is `ditoo-claude-meter`, the importable module is
119
+ `ditoo_meter`, and the installed command is `ditoo-meter`. That's three
120
+ different spellings for one project. It's intentional (PyPI package names
121
+ can't contain underscores the way Python module names require), not an
122
+ inconsistency to "fix" -- if you see `ditoo_meter` in an import and
123
+ `ditoo-claude-meter` in a `pip install`, that's expected.
124
+
125
+ ## Linux
126
+
127
+ Not implemented. `link.py`'s `Link._popen()` has an explicit branch on
128
+ `sys.platform` where a Linux transport would plug in -- Classic SPP over
129
+ `socket.AF_BLUETOOTH` is roughly a dozen lines (connect, then plain
130
+ `socket.send`/`recv` instead of the IOBluetooth async dance macOS needs).
131
+ No IOBluetooth-style run-loop problem exists on Linux, so a Linux helper
132
+ likely wouldn't even need the separate-subprocess split described above.
133
+ Contributions welcome.
@@ -0,0 +1,92 @@
1
+ # Decisions
2
+
3
+ ## 2026-08-24: replaced the Swift `ditoo_send` helper with pyobjc-framework-IOBluetooth
4
+
5
+ **Motivation:** shipping to PyPI as a pure-Python wheel means not depending on a
6
+ swiftc-compiled binary. If `pyobjc-framework-IOBluetooth` can open the RFCOMM
7
+ channel and talk to the device, a compiled Swift helper and the whole
8
+ swiftc-in-CI/Xcode-Command-Line-Tools path can go away.
9
+
10
+ **Result: confirmed working, real hardware, all 5 success criteria met.**
11
+ The exact sequence, implemented via PyObjC selectors:
12
+
13
+ 1. `IOBluetoothDevice.deviceWithAddressString_(mac)`
14
+ 2. `device.openConnection()` -- baseband link, done *before* opening RFCOMM
15
+ 3. `device.performSDPQuery_(None)`, wait ~4s
16
+ 4. `device.getServiceRecordForUUID_(uuid16=0x1101)` -> `record.getRFCOMMChannelID_(None)`
17
+ 5. `device.openRFCOMMChannelAsync_withChannelID_delegate_(None, cid, delegate)` -- async, not sync
18
+ 6. Pump `NSRunLoop.currentRunLoop()` in `NSDefaultRunLoopMode` until the delegate's
19
+ `rfcommChannelOpenComplete_status_` fires
20
+ 7. `channel.writeSync_length_(bytes, len(bytes))`
21
+
22
+ Final run: SDP resolved channel 1, async open completed with status 0 and
23
+ MTU 666, a brightness command got a real device reply (`0x46` echo), a test
24
+ image visibly changed the screen, and liveness probes over a 60s hold got
25
+ replies.
26
+
27
+ **Two real gotchas found beyond what was known going in -- both fixed, both
28
+ now baked into `src/ditoo_meter/link_helper.py`:**
29
+
30
+ 1. **The RFCOMM delegate must be a real `NSObject` subclass.** A plain
31
+ Python object (not subclassing `NSObject`) crashes the process with an
32
+ uncaught `NSException` inside `-[OC_PythonObject forwardInvocation:]` the
33
+ moment `IOBluetoothRFCOMMChannel` calls back into it -- even though
34
+ PyObjC's "plain Python object as delegate" pattern is normally fine for
35
+ many AppKit/Cocoa callback APIs. Fix: subclass `Foundation.NSObject` and
36
+ call `objc.super(Delegate, self).init()` in a proper `init()` override.
37
+ 2. **`rfcommChannelData_data_length_`'s `data_pointer` argument arrives
38
+ already bridged to a Python `bytes` object**, not a raw C pointer. The
39
+ fix is just `bytes(data_pointer)[:length]`, not `ctypes.string_at(...)`.
40
+
41
+ **A debugging detour worth recording:** early runs showed zero `RX` replies
42
+ and no visible image change, on both pyobjc *and* a reference Swift binary
43
+ checked directly -- so the first-pass conclusion was "device/session state
44
+ issue, not a pyobjc gap." On the next attempt, with a freshly-idle device
45
+ and no back-to-back rapid connect/disconnect churn from testing, both
46
+ implementations replied cleanly and identically. The root cause of the
47
+ earlier flakiness was never conclusively identified (worth keeping in mind:
48
+ heavy churn from repeated manual connect/open/close cycles during
49
+ interactive testing may leave the device slow to reply for a while -- give
50
+ it a few seconds of quiet between manual test runs). Point for next time:
51
+ when a real-hardware test shows a total communication blackout, check a
52
+ reference implementation under identical conditions before concluding new
53
+ code is at fault -- and double check any new marshalling code
54
+ (pointer/bytes decoding) before trusting a "no data received" result.
55
+
56
+ ## 2026-08-24: rewrote the project from scratch for a PyPI-publishable package
57
+
58
+ **Motivation:** the prior implementation (kept as `_legacy/` during the
59
+ rewrite, not shipped) proved the protocol and the overall architecture on
60
+ real hardware, but its structure only worked from an editable checkout:
61
+ `Path(__file__).parents[2]` located both the `.pix` sprite assets and the
62
+ launchd plist template relative to a source tree that doesn't exist once
63
+ installed as a wheel, and the device MAC address was a hardcoded module
64
+ constant rather than something `list-devices`/`setup` could discover. It
65
+ also carried a handful of structural bugs discovered while reading the code
66
+ closely for this rewrite -- notably a subprocess-lifecycle race in the old
67
+ `link.py` (the reader thread's exit handling and a concurrent
68
+ `ensure_connected()` call could each independently decide a new helper
69
+ process was needed, orphaning one of the two), a data-loss path in
70
+ `integrate.undo()` (it unconditionally cleared the saved passthrough
71
+ command even when there was nothing to restore it into), and a usage-source
72
+ resolution that let a stale `usage.json` permanently shadow a fresher
73
+ `plan-usage-history.json` sample.
74
+
75
+ **What changed, architecturally:** the biggest simplification came from a
76
+ real-hardware finding that was already true of the protocol but not yet
77
+ reflected in the code: the device loops a pushed animation locally and
78
+ indefinitely. Making `Animation` (not a single `Frame`) the type `render()`
79
+ returns everywhere let a lot of daemon-side complexity disappear outright --
80
+ alert blinking became a genuine 2-frame animation instead of a 500ms
81
+ re-render timer, and the daemon's steady-state Bluetooth traffic dropped to
82
+ zero. `link.py`'s subprocess state machine was rewritten so every mutable
83
+ field is read and written under one lock, closing the spawn race described
84
+ above. Device discovery (`discovery.py`, backed by
85
+ `IOBluetoothDevice.pairedDevices()`) replaced the hardcoded MAC. Packaging
86
+ now uses `importlib.resources` for the `.pix` sprites (with a
87
+ `~/.config/ditoo-claude-meter/art/` override directory for users who want to
88
+ edit them without touching the installed package) and generates the launchd
89
+ plist in-process with `plistlib.dump()` instead of templating a checked-in
90
+ file. See `docs/PROTOCOL.md` for the protocol facts themselves, which did
91
+ not change -- this rewrite is a structural one, not a re-verification of the
92
+ wire format.