ditoo-claude-meter
Pushes Claude Code's 5h/7d usage quota to a Divoom Ditoo Mic's 16x16 Bluetooth display, as a ring gauge around a small creature that reflects whatever Claude Code is currently doing (working / waiting / idle / asleep).
uv tool install ditoo-claude-meter
ditoo-meter list-devices # find your paired device
ditoo-meter setup # wire up statusLine + hooks + the background daemon
Full wire-protocol detail (frame format, commands, real-hardware findings)
lives in docs/PROTOCOL.md and is not repeated here.
This README exists to explain the handful of design decisions that aren't
obvious from reading the code, so that extending this project doesn't mean
re-deriving them from scratch.
1. Two names, one device -- the first mistake everyone makes
The device answers to two different Bluetooth names for the same physical
unit and address: the Classic radio calls itself <name>-Audio, the BLE
radio advertises as <name>-Light. The display protocol lives on the
Classic (-Audio) side, despite what the name suggests -- BLE only
exposes a transparent-UART service that nothing in this project (or any
known open-source Divoom project) has gotten to accept image data. If you're
poking around with a Bluetooth scanner and see -Light, that's the wrong
radio for this project's purposes; look for pairing/SDP activity on the
-Audio name instead. See PROTOCOL.md's "Transport" and "Why not BLE"
sections for the full story, including what was tried against BLE and why it
didn't pan out.
2. Why the Bluetooth helper is a separate subprocess
link_helper.py runs as its own subprocess (python -m ditoo_meter.link_helper <MAC>), spoken to over stdin/stdout by link.py, rather than being called
directly from the daemon. This isn't incidental complexity: pyobjc-framework- IOBluetooth's async APIs only deliver their callbacks (RFCOMM channel open,
incoming data, channel closed) by pumping an NSRunLoop. The daemon's own
loop is a plain synchronous while with a time.sleep(0.5) -- there's no
run loop for IOBluetooth's callbacks to arrive on. Reconciling the two in one
process means either running IOBluetooth on a dedicated thread with its own
run loop and shuttling data across a queue, or making the daemon's main loop
itself run loop-driven and fighting IOBluetooth for control of it. A separate
process sidesteps both: the helper owns an event loop it can pump freely
(link_helper.py's _pump()), and the daemon side (link.py) stays fully
synchronous -- ensure_connected(), send_burst(), and friends are plain
function calls with no async machinery of their own. The cost is a pipe
protocol and a subprocess lifecycle to manage (see link.py's lock-guarded
state machine), which is a smaller problem than making two different
concurrency models share one process.
3. The statusLine tap passthrough contract
ditoo-meter setup takes over ~/.claude/settings.json's statusLine
slot, pointing it at ditoo-meter tap. If you already had a statusLine
command configured, whatever it printed must keep printing -- this tool
occupies the slot, it doesn't get to break your existing statusline.
setupsnapshots your priorstatusLine.command(if any) into~/.config/ditoo-claude-meter/config.jsonaspassthrough, then installs its own command in its place. It has to happen at install time, once -- by the timetapis running,statusLinealready points at us, so the original command has nowhere else to be read from later.tap(called by Claude Code on every statusline tick) always reads the usage payload off stdin first and records it, then -- if apassthroughcommand was saved -- re-invokes that command with the same stdin and relays its stdout/stderr/exit code byte-for-byte, unmodified. Whatever your original statusline displayed (formatting, colors, other integrations) is not ours to reinterpret. If there's no saved passthrough, or the passthrough command times out or errors,tapfalls back to printing its own compact usage line -- a stuck passthrough must not be able to take Claude Code's statusline down with it.undorestores the savedpassthroughcommand back intostatusLineand clears the saved copy -- but only whenstatusLinestill actually points at us at the timeundoruns. If you've hand-editedstatusLinesincesetup(pointing it at something else),undoleaves it alone and does not discard the savedpassthroughvalue, since that might be the only copy of your original command; it prints a note telling you where to find it in config.json instead.
4. Why animations make the daemon simpler
Real hardware finding (see PROTOCOL.md's "Multi-frame animation"): the
device loops a pushed animation locally and indefinitely. Push once, it
keeps playing until replaced -- there's no need to re-send frames on a
timer. Every type in this codebase reflects that: render() always returns
an Animation (a tuple of (frame, duration_ms) pairs), even a static
scene is just a one-frame animation, and protocol.commands_for() decides
whether that goes out as a single 0x44 image or a chunked 0x49
animation. The consequence that matters most for the daemon: alert
blinking (usage over 100%) is a real 2-frame animation, not a 500ms
re-render loop. The daemon hashes the animation it would push and only
talks to Bluetooth when that hash changes from what's already on the
device -- no Bluetooth writes in steady state except one heartbeat every
10 minutes. That heartbeat (a single re-push of the current frame when
nothing has been confirmed pushed for 10 minutes) is a deliberate dent in
the zero-traffic ideal: a few hundred bytes six times an hour, in exchange
for automatic recovery from a device that has silently gone deaf. See
docs/DECISIONS.md.
5. Silent failure -- why pacing is insurance, not decoration
0x44 and 0x49 (the image/animation push commands) never get an ACK
from the device. A dropped write and a successful one look identical at the
RFCOMM layer -- silence either way. Two consequences follow directly from
this, both load-bearing:
link.pywaitsCHANNEL_SETTLE_S(~1.5s, real-hardware-verified) after sending the channel-switch command before it will attempt to push an image. Skipping this produces a write that "succeeds" and a screen that never updates.Link.send_burst()puts a small gap between chunks of a multi-part push instead of firing them back-to-back. It's the only line of defense against a burst the device can't keep up with, given that there's no way to ask it afterward whether the push landed.
If you need positive confirmation the link is alive, 0x46 (get view) is
the one command in this protocol that does reply -- see PROTOCOL.md.
6. If the screen won't update: check your phone
The device's application protocol serves one client at a time. If the
Divoom app on a phone is connected to the device (it connects over BLE), this
tool's Bluetooth writes all succeed at the link layer but the device ignores
them -- the screen just freezes on the last frame, and ditoo-meter status
still reports link ready: True. Turn off the phone's Bluetooth, or
disconnect the device in the app, and pushes resume immediately. This is a
hardware constraint, not a bug; the full story (and how the daemon should
learn to detect it) is in PROTOCOL.md's "The application protocol is
single-host".
A note on naming
The repo/package is ditoo-claude-meter, the importable module is
ditoo_meter, and the installed command is ditoo-meter. That's three
different spellings for one project. It's intentional (PyPI package names
can't contain underscores the way Python module names require), not an
inconsistency to "fix" -- if you see ditoo_meter in an import and
ditoo-claude-meter in a pip install, that's expected.
Linux
Not implemented. link.py's Link._popen() has an explicit branch on
sys.platform where a Linux transport would plug in -- Classic SPP over
socket.AF_BLUETOOTH is roughly a dozen lines (connect, then plain
socket.send/recv instead of the IOBluetooth async dance macOS needs).
No IOBluetooth-style run-loop problem exists on Linux, so a Linux helper
likely wouldn't even need the separate-subprocess split described above.
Contributions welcome.
Metadata
Release files for ditoo-claude-meter 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| ditoo_claude_meter-0.2.0.tar.gz | 104.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| ditoo_claude_meter-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 154.5 kB
Release files / ditoo_claude_meter-0.2.0.tar.gz
| Download URL | ditoo_claude_meter-0.2.0.tar.gz |
|---|---|
| Size | 104.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8c9c1b43f92c03030eb505d348669ceb1cec798043c4566933798e6b70e038a1
|
|
BLAKE2b-256 checksum How to use checksums |
e7e7d5c5db7927098272f2f78d55835a68d22e12897642b58c61d417cf88b9bb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / ditoo_claude_meter-0.2.0-py3-none-any.whl
| Download URL | ditoo_claude_meter-0.2.0-py3-none-any.whl |
|---|---|
| Size | 50.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2441e5871d228c8f65538436c3dc22296de071ef7b6bc0855d4f042632df5cec
|
|
BLAKE2b-256 checksum How to use checksums |
6326809427efedaa6d40210ba762496ac45842b9bf87ac0068597d559db064c5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|