Skip to main content

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). The creature has a pool of animation clips per state, its mood shifts as the 5h quota fills, it reacts when usage crosses a milestone, and the panel dims while it's asleep -- all without adding any steady-state Bluetooth traffic (see section 4a).

A scripted loop on the 16x16 display: the creature working (with a jump), the 5h gauge climbing green to red, a hand-off to waiting then idle, a milestone reaction, sleep, and a reset. The border ring is the 5h usage gauge, green through red as it fills; the 14x14 interior is the activity-state creature.

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.

How it fits together

Nothing talks to the daemon directly. Claude Code just runs two short-lived commands -- tap on every statusLine refresh and hook on session events -- and they leave JSON in ~/.local/state/. launchd keeps the daemon running in the background; it polls that state twice a second, and only touches Bluetooth when the rendered image actually changes. The daemon commits a frame as "displayed" only once the helper subprocess has confirmed every line of it (see sections 4 and 5).

flowchart TD
    settings[/"~/.claude/settings.json"/]

    subgraph trig [Triggered by Claude Code]
        CC[Claude Code]
        tap["ditoo-meter tap"]
        hook["ditoo-meter hook"]
        CC -->|"statusLine JSON on stdin, every refresh"| tap
        CC -->|"session events: Stop, SessionStart, ..."| hook
    end

    settings -.->|"statusLine + hooks, installed by setup"| CC
    tap -->|"writes 5h / 7d %"| usage[("usage.json")]
    hook -->|"writes session state"| activity[("activity.json")]

    plist[/"~/Library/LaunchAgents/....plist"/]
    plist -.->|"read at login"| launchd(["launchd"])

    subgraph res [Resident daemon]
        launchd -->|"exec; restart on exit (KeepAlive)"| daemon["ditoo-meter daemon<br/>reduce &rarr; render &rarr; hash &rarr; push"]
        daemon -->|"frames, only when the rendered image changes"| link["link.py"]
        link -->|"seq-tagged hex, one line per chunk"| helper["link_helper.py<br/>subprocess &middot; pyobjc IOBluetooth"]
        helper -->|"OK per line confirms the push &middot; RX = 0x46 reply"| link
    end

    usage -->|"poll 0.5s"| daemon
    activity -->|"poll 0.5s"| daemon
    daemon -->|"heartbeat, at least every 90s"| dstatus[("daemon-status.json")]
    dstatus -->|"read by"| statuscmd["ditoo-meter status"]

    helper -->|"RFCOMM / SPP write (Bluetooth Classic)"| device[["Divoom Ditoo &middot; 16&times;16<br/>loops the animation locally"]]
    device -.->|"replies to the 0x46 liveness probe only"| helper

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.

  • setup snapshots your prior statusLine.command (if any) into ~/.config/ditoo-claude-meter/config.json as passthrough, then installs its own command in its place. It has to happen at install time, once -- by the time tap is running, statusLine already 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 a passthrough command 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, tap falls back to printing its own compact usage line -- a stuck passthrough must not be able to take Claude Code's statusline down with it.
  • undo restores the saved passthrough command back into statusLine and clears the saved copy -- but only when statusLine still actually points at us at the time undo runs. If you've hand-edited statusLine since setup (pointing it at something else), undo leaves it alone and does not discard the saved passthrough value, 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.

4a. Clip pools, mood, and reactions -- all event-driven

A grid of six looping clips: working with a jump, panic, waiting glancing around, idle with happy squinted eyes, a milestone reaction, and sleep.

The creature is not one loop per state any more. Each activity state has a pool of hand-drawn clips under src/ditoo_meter/art/<state>/*.pix, plus optional per-band sub-pools (art/<state>/<band>/) for the sweat and panic ends of the 5h gauge, and a set of one-shot milestone reactions in art/reactions/. render() stays a pure function: which clip it picks is a deterministic function of (state, band, turn), where turn is a counter the daemon bumps only on real events -- a state transition, a usage-band change, a milestone crossing. There is no rotation timer. A long uninterrupted working session shows one clip start to finish, so the zero-traffic property from section 4 is untouched; you just get a different clip next session, and a brief reaction clip (then one push back to normal) when usage crosses 50 / 75 / 90 / 100%.

mood.py owns the band cutoffs (the same 50 / 75 / 90 as the ring colour) and the milestone-crossing logic, and is the one place both render() and the daemon read "what band are we in" from.

Props. Fragments in art/props/*.pix are drawn over the composed creature. One ships: a key while a permission prompt is waiting on you. It follows the same rule as everything else here -- it appears or clears only on a real event, never on a timer. A dedicated Notification / permission_prompt hook drops a TTL'd marker in props.json; the daemon surfaces key only while the state is waiting, so it clears on the next tool call (you answered), with a 5-minute TTL as a backstop. An unknown prop name, or one whose colours would bust the 16-colour frame budget, is dropped rather than allowed to break the render -- so adding a new prop is just art plus one name in props.PROPS.

To see every clip without a device: uv run python tools/preview_variants.py writes a self-contained HTML page (dist/variants.html) that loops each one with its real timing. uv run python tools/make_gifs.py regenerates the two README GIFs (docs/anim.gif, docs/variants.gif) from the same renderer.

Sleep dims the panel. While the state is sleep, the daemon targets sleep_brightness (config, default 12) instead of brightness; entering and leaving sleep already repaints the creature, so the brightness command rides that push and costs nothing extra. The hysteresis is free: reduce_activity only returns sleep after sleep_after_seconds, and any activity flips the state -- and the brightness -- straight back.

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.py waits CHANNEL_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".

Uninstalling

ditoo-meter setup --undo

That reverses everything setup did to your system: it restores the original statusLine command (or removes ours if there wasn't one), strips the hook entries out of ~/.claude/settings.json -- touching only entries whose command contains ditoo_meter, never yours -- launchctl bootouts the background job, and deletes its plist. It's idempotent; running it twice is a no-op. Confirm the job is gone with:

launchctl print gui/$(id -u)/com.nowheremanx.ditoo-claude-meter

which should now report Could not find service.

Then remove the package and the files setup --undo deliberately leaves behind (your config, and the runtime state the daemon wrote):

uv tool uninstall ditoo-claude-meter
rm -rf ~/.config/ditoo-claude-meter ~/.local/state/ditoo-claude-meter
rm -f  ~/Library/Logs/ditoo-claude-meter.log

~/.config/ditoo-claude-meter/ also holds any sprite overrides you made -- art/<state>/*.pix, art/<state>/<band>/*.pix, art/reactions/*.pix, or art/props/*.pix, mirroring the packaged layout; a folder of clips replaces the packaged pool for that state, and a flat art/<state>.pix still works as a one-clip pool. Skip that line if you want to keep them for a later reinstall.

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 uv tool 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.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ditoo-claude-meter 0.3.0
File Size Uploaded
ditoo_claude_meter-0.3.0.tar.gz 297.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for ditoo-claude-meter 0.3.0
File Interpreter ABI Platform
ditoo_claude_meter-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 366.1 kB

Release files / ditoo_claude_meter-0.3.0.tar.gz

Download URL ditoo_claude_meter-0.3.0.tar.gz
Size 297.5 kB
Tags Source
SHA-256 checksum
How to use checksums
4b6d1ed0c9c4af3674ac96a52b56535f3a8e89d946eb2faf60f6a32d0371740e
BLAKE2b-256 checksum
How to use checksums
5f5f1a65624cb6f44143c81fda501c461dd717c19fea668516144c7e440eda2e
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.3.0-py3-none-any.whl

Download URL ditoo_claude_meter-0.3.0-py3-none-any.whl
Size 68.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
21c99f42de43f15b3b883a31ce2f6b2ad9decd04406f2c3c9cec7fffda99ccbc
BLAKE2b-256 checksum
How to use checksums
9723ec042e38f2cd6528239f9d03fe3a0090b02dd42393b7492c2ada36695065
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 history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page