ESP32-S3 desk display + iOS app/widget for Codex/OpenAI usage tracking. Adapted from Clawdmeter.
| Layer | Tech | Entrypoint |
|---|---|---|
| Firmware | C++ (Arduino), PlatformIO | firmware/atom_main.cpp |
| Daemon | Python 3.10+, bleak/pyserial/zeroconf | daemon/codex-usage-daemon.py |
| iOS App | Swift, XcodeGen | ios/CodexMeterApp/project.yml |
Firmware:
pio run -d firmware -e m5stack_atoms3 # build full firmware
pio run -d firmware -e m5stack_atoms3_smoke # build minimal smoke test (RGB cycle + device name)
./flash.sh [port] [env] # build + upload (default: /dev/cu.usbmodem101, m5stack_atoms3)Daemon:
pip install -r requirements.txt
./daemon/codex-usage-daemon.py --print # smoke test
./daemon/codex-usage-daemon.py --transport serial --once # serial to AtomS3
./daemon/codex-usage-daemon.py --transport ble # BLE to AtomS3
./daemon/codex-usage-daemon.py --http-port 9595 # HTTP server for iOS app
CODEXMETER_PROVIDER=claude ./daemon/codex-usage-daemon.py --print # Claude modeTesting:
ruff check . # Python lint
pytest -q # Python tests (tests/test_ble_device_selection.py, tests/test_claude_local_usage.py)
# iOS: open in Xcode and run CodexMeterApp scheme, or `xcodegen generate && xcodebuild test`iOS:
cd ios/CodexMeterApp
xcodegen generate # regenerate .xcodeproj from project.yml
# then open in Xcode or use xcodebuildInstall as service:
./install.sh # macOS LaunchAgent or Linux systemd user serviceHealth check:
./doctor.sh # Python version, venv, deps, PlatformIO, USB port, Codex auth, API key, service status~/.config/codexmeter/env — full env var reference in admin.md. Key vars:
OPENAI_ADMIN_KEYorOPENAI_API_KEY(for OpenAI costs API)CODEXMETER_PROVIDER=claude(switch to Claude Code activity tracking)CODEXMETER_TRANSPORT=serial|ble|noneCODEXMETER_HTTP_PORT(default 9595 for iOS app)
Priority: Codex OAuth (~/.codex/auth.json → chatgpt.com/backend-api/wham/usage) > OpenAI costs API > local activity (~/.codex/sessions/*.jsonl or ~/.claude/projects/**/*.jsonl).
- Two PlatformIO environments:
m5stack_atoms3(full, src: atom_main.cpp + ble.cpp) andm5stack_atoms3_smoke(minimal, src: smoke.cpp) - Key libs: M5Unified, ArduinoJson, NimBLE-Arduino
- 5 screens: Usage, Connection, Status Pet, Pet Selector, Now Working
- Button: short-press cycles screens, hold (2s) selects pet or clears BLE bonds
- 512-byte device buffer, prefers serial over BLE
- Serial: 115200 baud,
\nterminated - Upload mode: hold button while plugging in until green LED
- XcodeGen project (
project.yml), generate withxcodegen generate - Targets:
CodexMeterApp(main),CodexMeterAppWidget(WidgetKit),CodexMeterAppTests - Bonjour/mDNS discovery of daemon via
MDNSBrowser.swift - BLE connection to ESP32 via
BLEManager.swift(CoreBluetooth) - App Groups:
group.com.codexmeter(shared UserDefaults with widget) - Personal Team free accounts: app group container not provisioned →
UserDefaults.sharedGroupfalls back to.standard, widget won't sync
- Device name:
Codex Controller - Service:
434f4445-584d-4554-4552-000000000001- RX (write):
...0002 - TX (read/notify):
...0003 - Refresh (notify):
...0004
- RX (write):
- Host writes compact JSON to RX:
{"s":45,"sr":120,"w":28,"wr":7200,"st":"$2.31 today","ok":true}- Daemon caches BLE addresses; can stale if device re-pairs. Fallback: delete
~/.config/codexmeter/ble_address.txt
python-ci.yml: ruff + pytest on ubuntuios-ci.yml: xcodegen generate → simulator tests → widget build (macos-15)- Both trigger on push/PR to
main
extension BLEManager: @MainActor CBCentralManagerDelegate { } // ERRORFix: Remove @MainActor from extension line. Class is already @MainActor, conformances inherit implicitly.
UserDefaults(suiteName: "group.com.codexmeter") returns nil. Fix:
extension UserDefaults {
static let sharedGroup = UserDefaults(suiteName: "group.com.codexmeter") ?? .standard
}admin.md— operator runbook (setup, flash, daemon, config, troubleshooting)systm.md— internal architecture (device state, screens, payload, BLE/serial protocol)docs/E2E_TESTING.md— daemon HTTP endpoint testing, mDNS verificationTODO.md— pending improvements