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.
- ditoo_claude_meter-0.1.0/.gitignore +7 -0
- ditoo_claude_meter-0.1.0/CHANGELOG.md +11 -0
- ditoo_claude_meter-0.1.0/LICENSE +21 -0
- ditoo_claude_meter-0.1.0/PKG-INFO +149 -0
- ditoo_claude_meter-0.1.0/README.md +133 -0
- ditoo_claude_meter-0.1.0/docs/DECISIONS.md +92 -0
- ditoo_claude_meter-0.1.0/docs/PROTOCOL.md +310 -0
- ditoo_claude_meter-0.1.0/docs/ROADMAP.md +263 -0
- ditoo_claude_meter-0.1.0/pyproject.toml +35 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/__init__.py +0 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/art/idle.pix +15 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/art/sleep.pix +15 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/art/waiting.pix +15 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/art/working.pix +15 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/cli.py +320 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/config.py +67 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/daemon.py +103 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/discovery.py +36 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/doctor.py +90 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/frame.py +71 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/integrate.py +243 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/link.py +275 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/link_helper.py +195 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/png.py +33 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/protocol.py +156 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/render.py +90 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/sprites.py +67 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/state.py +117 -0
- ditoo_claude_meter-0.1.0/src/ditoo_meter/usage.py +101 -0
- ditoo_claude_meter-0.1.0/tests/fake_helper.py +46 -0
- ditoo_claude_meter-0.1.0/tests/test_cli_entrypoint.py +22 -0
- ditoo_claude_meter-0.1.0/tests/test_daemon.py +91 -0
- ditoo_claude_meter-0.1.0/tests/test_discovery.py +17 -0
- ditoo_claude_meter-0.1.0/tests/test_integrate.py +202 -0
- ditoo_claude_meter-0.1.0/tests/test_link.py +156 -0
- ditoo_claude_meter-0.1.0/tests/test_platform_gate.py +68 -0
- ditoo_claude_meter-0.1.0/tests/test_protocol.py +197 -0
- ditoo_claude_meter-0.1.0/tests/test_render.py +102 -0
- ditoo_claude_meter-0.1.0/tests/test_sprites.py +70 -0
- ditoo_claude_meter-0.1.0/tests/test_state.py +122 -0
- ditoo_claude_meter-0.1.0/tests/test_tap.py +120 -0
- ditoo_claude_meter-0.1.0/tests/test_usage.py +120 -0
- ditoo_claude_meter-0.1.0/uv.lock +138 -0
|
@@ -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.
|