Metadata-Version: 2.5
Name: ditoo-claude-meter
Version: 0.3.0
Summary: Push Claude Code 5h/7d usage quota to a Divoom Ditoo Mic's 16x16 display over Bluetooth
Project-URL: Homepage, https://github.com/nowheremanx/ditoo-claude-meter
Project-URL: Repository, https://github.com/nowheremanx/ditoo-claude-meter
Project-URL: Issues, https://github.com/nowheremanx/ditoo-claude-meter/issues
License-Expression: MIT
License-File: LICENSE
Classifier: Environment :: MacOS X
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Topic :: System :: Monitoring
Requires-Python: >=3.11
Requires-Dist: pyobjc-framework-iobluetooth; sys_platform == 'darwin'
Description-Content-Type: text/markdown

# ditoo-claude-meter

Pushes Claude Code's 5h/7d usage quota to a [Divoom Ditoo Mic](https://divoom.com/)'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.](docs/anim.gif)

```
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`](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).

```mermaid
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.](docs/variants.gif)

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
bootout`s 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.
