Metadata-Version: 2.5
Name: ditoo-claude-meter
Version: 0.1.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).

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

## 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 -- in steady state, with nothing to say, it sends nothing at all.

## 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.

## 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.
