Running log of what was done, newest first. Companion to ROADMAP.md (plan) and
AUDIT-2026-07-19.md (findings).
- New standalone public repo: https://github.com/the3dcoder/KeydialCommander — created via
gh(Node/gh installed user-local; no system-package changes). Pushed as a single fresh initial commit (full build history kept locally on thefeat/*/docs/*branches +full-history-backuptag). MIT LICENSE retained. - Auto-start service:
~/.config/systemd/user/keydial-commander.service(points at the venv), enabled — starts on login, serves the GUI, grabs the K20. - Sensible default bindings seeded into the Default profile so the K20 is useful out of the box: buttons 1–12 = Undo/Redo/Copy/Paste/Cut/Save/Select-All/Find/New/Open/Close/Print; 13–15 = sticky Ctrl/Alt/Shift; 16–18 = Enter/Space/Esc; dial = Volume ±/Mute.
- Renamed the working folder to
KeydialCommander(recreated the venv at the new path since venvs hard-code paths; re-pointed the systemd unit; 128 tests still pass; app still served). - Fixed a shutdown hang exposed by
systemctl restart: with a browser/GUI WebSocket open, aiohttp waited on it for up to 90 s. The API now tracks open/api/eventssockets and closes them on shutdown (+ bounded cleanup). A graceful restart with 8 clients connected now takes ~0.2 s. - Independence pass: made every doc/file present the project as its own standalone thing —
README (title → Keydial Commander, install/clone → KeydialCommander), a rewritten CONTRIBUTING
for the current evdev architecture,
pyprojectURLs/description/keywords/author, NIXOS/flake/ example Nix URLs, BRANCHING, ROADMAP,.claude/agents/*, and author metadata (__init__, pyproject). Renamed the uinput virtual-device name tokeydial-commander-uinput.
Set up Node via nvm (git-clone, no curl|bash; v24 at ~/.nvm — system node v18 shadows it, so
PATH-prepend every command; see memory node-toolchain). Built the full "Deck" GUI in 9 slices
on feat/commander-frontend, verifying each in the Browser pane against the live daemon:
- Vite + React + TS SPA in
web/, building to the package'sweb/dist(served by the daemon's ApiServer; SPA + assets route added). - Typed API client + TanStack Query + live WebSocket hook (pulse/identify/invalidation) + zustand.
- App layout + teal dark-first/light/system theme; ProfileBar (switch/create), StatusStrip.
- DeviceStage: K20 render (18 keys + dial zones), live key-highlight from WS, click-to-select, identify mode.
- Inspector: per-type editors — keystroke (ShortcutCapture via KeyboardEvent.code→KEY_* + manual picker + sticky), macro (step list), command (argv), profile_switch; save/clear/test-fire.
- ActionLibrary + dnd-kit drag-and-drop onto keys; Settings (dial sensitivity, theme, profile rename/duplicate/export/import/delete); edge states (daemon-down, disconnected, drop-conflict confirm).
- GTK3/WebKit2 desktop shell (
packaging/shell/commander_shell.py, runs under system python3),.desktop+ icon +make install-shell(user-local). Launched a real window on the session. - Verified in-browser end-to-end: clicked BUTTON_1 → picked KEY_F9 → Save → key labeled "F9"
and live in the daemon map; dragged "Mute" onto BUTTON_4 (created binding); Settings modal;
rebound BUTTON_1→KEY_1 via API and confirmed in
keydialctl list-bindings. - Fixed a real CLI bug:
keydialctl list-bindingsshowed "None" for keystroke bindings (still checked the oldkeyboardtype name). Addedmake build-web. Python suite: 128 passing.
Executed the 8-task backend plan inline, TDD, one commit per task on
feat/commander-backend.
- Typed action model:
KeybindActiongainskeystroke|macro|command|profile_switchwith per-type fields;validate_actionenforces schemas + macro caps (≤32 steps, ≤10s);ProfileStorepersists/loads all types;keyboard→keystrokealias. - ActionEngine: executes each type (keystroke via uinput; macro with fake-clock-testable
delays; command via
create_subprocess_exec, no shell, detached; profile_switch incl."next"cycling). Routed intodevice._dispatch. - Embedded aiohttp API (
127.0.0.1:8137):/api/status,/api/keys, full profiles + bindings CRUD (reusing ProfileStore/KeybindManager — active-profile edits reload the live map), settings, YAML export/import,test-fire, and/api/eventsWebSocket relaying the EventBus with clean unsubscribe. Serves the SPA placeholder; port written to XDG_RUNTIME_DIR. - Tests: 99 → 127 passing (action model, action engine, api basic/profiles/events, daemon
API). Live smoke on the running daemon:
GET /api/status→device.connected: true(K20 grabbed), created a profile + macro binding over REST, read it back — all working. - Added
aiohttpdep; registeredweb/as package data. Phase 2B (React SPA + GTK shell) remains — needs Node/npm (still half-configured from the kernel dpkg issue).
Executed the 7-task evdev-grab plan inline, TDD, one commit per task on
feat/evdev-device-layer. New modules: input_events, input_map,
input_translator (combo/sticky parity), evdev_source (grab + async read +
hotplug rescan). Rewrote device.py to drive EvdevSource → translator → uinput
- EventBus. Retired
hid_parser,bluetooth_watcher, the unbind script/rule, and thebleak+dbus-nextdependencies. Rewroteevent_loggeras a live evdev monitor. Caught + fixed a real bug (the daemon would grab its own uinput output device, whose name also contains "keydial" — now excluded by name).
- Tests: 99 passing (parser-format tests replaced by
input_translator/input_map/evdev_sourcesuites incl. a live synthetic-uinput grab test). - LIVE HARDWARE E2E (the proof): ran the real daemon → it grabbed
event26+event27; bound BUTTON_1/2/3 → KEY_1/2/3 and dial CW/CCW/click →
KEY_9/8/0; Earl typed into a text field and got
1239998880(1,2,3 · 999 · 888 · 0) with no k/g/l — every layer works: EVIOCGRAB suppression, key mapping, dial rotation + click, uinput re-emit, and binding persistence across daemon restarts. Clean start/stop; device released to normal keyboard on exit. - Confirmed the wheel sign convention (CW = REL_WHEEL −1 = DIAL_CW); INVERT_WHEEL stays False for this unit.
- udev uaccess fix merged to main: split rules into
70-(uaccess for event nodes +/dev/uinput, must sort before systemd's73-seat-late.rules) and99-(unbind only). This is what finally gave the user session access to/dev/uinput. - Ran the real daemon end-to-end on the live K20. It connected, subscribed to vendor
FFE1, and received zero input on keypress. Diagnosed via three independent methods (all-notify D-Bus capture, real daemon, kernel evdev read). - CRITICAL FINDING (C1): the K20 is a standard BLE HID keyboard; input flows through the
standard HID service
0x1812→ BlueZ HoG → kernel evdev, invisible to bleak/D-Bus.FFE1is a firmware/status channel (its descriptor literally readsHUION_T21h_230628; sibling char =OTA). The original driver's bleak→FFE1→hid_parser design never worked on this hardware. - Proved the fix: read the kernel evdev nodes directly (no sudo, via the
70-uaccess rule) — all 18 buttons arrive as cleanKEY_*onevent26, dial rotation asREL_WHEELonevent27, dial-click asKEY_PLAYPAUSE. Full live-verified map inDEVICE-K20.md§G. - Decided (with user): pivot the device layer to evdev-grab + uinput (keyd/xremap pattern),
fold USB in (same evdev path). All Phase 1 control-plane work stays valid; only
device.py+hid_parser.pyget replaced. CorrectedDEVICE-K20.md§E/F (my earlier "high confidence" FFE1 conclusion was wrong), added audit addendum C1, inserted Phase 1.5 in the roadmap, updated the design-spec architecture. Wrote the Phase 1.5 plan.
Executed the full 12-task plan (docs/superpowers/plans/2026-07-19-phase1-driver-hardening.md)
inline, TDD per task, one commit per task on feat/phase1-driver-hardening.
- Audit findings closed: H1 H2 H3 H4 H5 H6 · M1 M2 M3 M4 M5 M6 M8 M9 M10 · L1 L2 L3 L4
L6 L7 L9 (+ most of L5/L8: event-logger test data fixed, poll loop → Event,
--userflag removed). Remaining open: M7 (deb/rpm pipelines — Phase 4), L5's logger--testfull rework, L8's monkey-patch pattern (works, noted). - New capabilities: on-disk profiles with auto-persist + migration;
keydialctl profile list/switch/create/delete; framed IPC v2 with timeouts at$XDG_RUNTIME_DIR; event streaming (subscribe_events) with battery/device state; Huion-only attach filter with D-Bus name lookup; FFE1-targeted subscription; async closeable uinput; real dial sensitivity; working systemd unit + udev rules. - Tests: 59 → 122 passing. New suites: validation, keymap, config IO, profile store, IPC, manager persistence, event bus, device identity, uinput lifecycle, parser fixes, CLI, packaging meta.
- Execution notes: Tasks 8+9 landed in one commit (identity tests require the lazy
uinput constructor — plan sequencing flaw found at runtime). Two test-design bugs caught
and fixed during TDD (Python 3.12
Server.wait_closedsemantics; event-stream needed client-EOF detection — the latter was a REAL production deadlock the test exposed).KEY_CTRL→KEY_LEFTCTRLcorrected across legacy tests (the audit's landmine). - Kernel packages on Earl's machine remain half-configured (
linux-*-7.0.0-28), nodejs/npm iniUstate —sudo dpkg --configure -aneeded before Phase 2 frontend work.
- Spec approved by Earl (both design rounds + written document).
- Branching model adopted (
docs/BRANCHING.md): GitHub-flow; today's work ondocs/audit-and-design. - Device probe completed inline (two background agent attempts were lost to process
restarts) →
docs/DEVICE-K20.md. Headline findings:- BlueZ does not export the HID service — the driver's true event source is vendor characteristic FFE1, previously undocumented. Explains the udev unbind design.
- Kernel HID descriptors decoded: keyboard(1)/consumer(2)/radial(3)/mouse(5) report IDs; the radial interface is Surface-Dial-class.
- Device connects via hci1 here — audit M9 (hardcoded hci0) confirmed as a real, machine-breaking bug, not theoretical.
- Vendor command channel candidates for Phase 3 LED/sleep:
…2b12, FFE2. No writes made.
- Phase 1 implementation plan started per writing-plans skill (Phase 2 plans follow at their phase boundary).
- Full audit completed (lead + senior code-audit agent): entire repo read, 25 defects
confirmed (6 high), dead-code/duplication/doc-drift catalogued, test suite statically
verified as implementation-matching but with major coverage gaps. →
AUDIT-2026-07-19.md - Live device inspected: K20 connected via BT as "Keydial mini-504" (90% battery), hid-generic currently handling it (driver not installed on this machine). USB confirmed charge-only with present cable; manual confirms USB-C data mode exists.
- Official K20 manual retrieved — exact physical layout (dial top-left; 4×5 grid, one double-wide + one double-tall key), LED/sleep vendor-settable, official driver's press-dial-to-cycle-3-modes behavior documented as parity target.
- BLE protocol probe agent dispatched (GATT table, report map decode) — report pending.
- Product interview held. Decisions locked (see ROADMAP Decisions log): web UI + Python backend; v1 = editor + profiles + macros + live status; Direction A "Deck" mockup; drag+click; dark-first + light; teal accent; webview window + tray; name Keydial Commander; BT now / USB later; user runs apt line for dev tooling.
- Three UI mockups produced →
docs/mockups/mockup-{a-deck,b-studio,c-commandgrid}.html(A chosen). - Project agent team created →
.claude/agents/(gui-architect, ux-designer, driver-engineer, qa-verifier). - Docs created:
AUDIT-2026-07-19.md,ROADMAP.md, this worklog. - Next: backend API approach proposal → design spec → implementation plan.