# Channel Discovery Design Date: 2026-09-27 Status: approved in conversation, awaiting written review ## Problem The tool drives three hardcoded zones. `internal/rgb/impact80.go` names them `logo`, `backlight` and `side`, and `Zone.Channel()` maps them to VIA channels 2, 3 and 4. Every command therefore assumes that any connected keyboard exposes exactly those three channels, with the Impact 80's effect catalog and the Impact 80's per-channel firmware transforms. That assumption is wrong for any other board. A keyboard with only `rgb_matrix` would receive writes to channels it does not have, and a keyboard with a different firmware build would be offered effect names that mean something else there. `keyboards.json` does not help: it supplies names only, and `README.md` says so. The question this design answers: how much of the zone model can be derived from the keyboard instead of assumed? ## What the hardware and protocol actually offer Measured on the Impact 80 by reading the HID reports directly, and read from QMK upstream. **Channel presence is queryable.** QMK's `quantum/via.h` defines the channel space: ```c enum via_channel_id { id_custom_channel = 0, id_qmk_backlight_channel = 1, id_qmk_rgblight_channel = 2, id_qmk_rgb_matrix_channel = 3, id_qmk_audio_channel = 4, id_qmk_led_matrix_channel = 5, }; ``` A `CustomGetValue` for a channel the firmware does not compile in is answered with `id_unhandled` (`0xFF`); a channel it does have answers with `0x08`. Probe results on the Impact 80, asking for brightness (value ID `0x01`): | Channel | Answer | Verdict | |---:|---|---| | `0x00`, `0x01` | `0xFF` | not present | | `0x02` | `0x08`, value 160 | present, `rgblight` | | `0x03` | `0x08`, value 255 | present, `rgb_matrix` | | `0x04` | `0x08`, value 160 | present, `audio` | | `0x05`–`0x08` | `0xFF` | not present | The `0xFF` response is the discriminator. An unknown *value ID* on a known channel behaves differently: the firmware mirrors the request and answers with a zero payload. That asymmetry is what makes the probe unambiguous. **Value IDs are uniform across subsystems.** QMK defines brightness `1`, effect `2`, effect speed `3` and color `4` identically for `rgblight` and `rgb_matrix`. There is no per-subsystem value handling to write, which is the main reason this design is small. **Effect catalogs are not queryable.** Stock VIA has no enumeration command; `GetValue(channel, 0x02)` returns only the running effect. The catalog is a compile-time constant in the firmware. Vial would answer it through its own RGB commands (`GetInfo` `0x40`, `GetSupportedOrDirectFastSet` `0x42`), but the Impact 80 runs stock VIA. Whether brute-force probing would recover the valid ID range is unknown and untested; the design does not depend on the answer. **Color is two bytes.** Value ID `4` carries hue and saturation only. The keyboard has no value register, and a VIA handler preserves the current value when the color is set. `color` therefore reads back two bytes, and its `hsv:,,` notation writes `v` to the brightness register of the same channels, which is documented in README.md. ## Decisions Decided in conversation, with the rejected alternatives recorded. 1. **Probe channel presence; do not guess.** Rejected: keep the hardcoded three zones. It is the bug being fixed. 2. **Name channels with the QMK subsystem names.** `--zone rgblight`, `--zone rgb_matrix`, `--zone audio`. Rejected: physical names from the board, which are not in the protocol; and numbers only, which lose readability. 3. **The canonical vocabulary is the QMK subsystem name.** A board may add display names in `keyboards.json`, and those are accepted too. The physical names are not baked into the code: `logo` and `side` are names this board supplies through the file, which is why they keep working here and would not work on a board with no entry. Rejected: keeping the old names as aliases in code, which would make them work everywhere and put a board's layout in the binary. `--zone backlight` on the Impact 80 resolves to channel 3 because the file says so; on a board that has a channel 1, the same name is the QMK meaning and the file entry is rejected at load rather than retargeting the command. 4. **Store only a display name per channel in `keyboards.json`.** The subsystem name follows from the channel number, so it is not stored. 5. **Keep the effect catalogs in code, keyed by VID/PID.** Rejected: catalogs in `keyboards.json` (a transcription of an external document would then live in the file the README describes as a name lookup), and a catalog per subsystem (provably wrong for `rgblight` and `audio`, whose names on this board are the vendor's 7-entry scheme, not QMK's). 6. **A board without a catalog gets raw IDs only.** `effect ` fails and names `mode ` as the way. `effect --list` reports an empty catalog. 7. **Do not open the device in `keyboard info`.** Channel detection therefore runs in the commands that already open a keyboard, and `info` reports them. ## Design ### Detection A probe over channels 1 to 15, one read each, asking for brightness: ``` for channel := 1; channel <= 15; channel++ { response := GetValue(channel, 0x01) 0xFF -> absent 0x08 -> present } ``` Brightness is the probe value because it is a single byte and valid on every lighting subsystem, so one request finds every kind of channel. The range goes to 15 rather than stopping at 5 so a future QMK that claims channel 6 is found without a code change; unassigned channels answer `0xFF`, which is measured. New API, in `internal/via` because the channel space is a protocol concept: ```go type Channel uint8 const ( ChannelBacklight Channel = 1 ChannelRgblight Channel = 2 ChannelRgbMatrix Channel = 3 ChannelAudio Channel = 4 ChannelLedMatrix Channel = 5 ) func (c Channel) Subsystem() string // "backlight", "rgblight", "rgb_matrix", "audio", "led_matrix" func (p *Protocol) DetectChannels() ([]Channel, error) ``` `internal/rgb.Zone` and `Zone.Channel()` are removed; commands address `via.Channel` directly. ### Naming `keyboards.json` gains an optional per-board display name per channel: ```json { "name": "Wobkey Impact 80", "vendorId": 14000, "productId": 12447, "channels": { "2": "logo", "3": "backlight", "4": "side" } } ``` `--zone` accepts, in resolution order: 1. a display name, when the board is known, the channel is present, and the name does not shadow the subsystem name of a *different* present channel 2. a subsystem name, which always works for a present channel A display name that shadows another present channel's subsystem name is rejected when the file is loaded, with an error naming the conflict. This is the case that would otherwise retarget a command silently. The Impact 80's display names are `logo`, `backlight` and `side`, and it has no channel 1, so `backlight` is unambiguous there. That is a property of this board to be verified, not assumed: a board with both channel 1 and a channel 3 called `backlight` must fail to load. Without an entry in `keyboards.json`, channels are named by subsystem only. Resolution lives in `cmd/qmk-rgb-tool/zones.go`, which already owns `--zone` parsing; the display names come from `internal/device`, which already loads the file. ### Effect catalogs `internal/rgb` keeps the Impact 80 tables and gains a lookup: ```go // The 46 backlight names are QMK's rgb_matrix_effects.inc, verified against // the live register. The 7 logo and side names are the vendor's VIA // definition for this board, whose dropdowns read "fixed wave" and "breathe"; // the tool spells them fixed_wave and breathing and adds the compatibility // aliases. The brightness and speed transforms were measured on the unit. // The generic Effect enum in effects.go is dead and is removed. func CatalogFor(vendorID, productID uint16) (*Catalog, bool) // Catalog is one board's effect names, keyed by the channel they apply to. type Catalog struct { effects map[via.Channel][]string } ``` `internal/rgb` imports `internal/via` for the `Channel` type. That direction is acyclic already: `internal/via` imports `internal/device` and `internal/hid`, never `internal/rgb`. Unknown board, no catalog: - `effect ` returns an error naming the board and pointing at `mode ` - `effect --list` prints an empty `zones` array - `mode `, `brightness`, `speed`, `color` and `info` all work, because they need no catalog ### Command surface and JSON `effect --list` is extended additively so existing readers keep working. For a known board, `zone`, `effect` and `id` are unchanged and three fields appear: ```json { "catalog": "impact80", "zones": [ {"zone": "logo", "channel": 2, "subsystem": "rgblight", "effect": "none", "id": 0} ] } ``` For a board without a catalog, `"catalog": ""` and `"zones": []`. `info` keeps its shape. Its `zone` field carries the resolved name, and its existing `channel` field becomes the authoritative identity. On the Impact 80 both are byte-identical to today's output. Default targeting without `--zone` is every detected channel in ascending channel order, which for the Impact 80 is 2, 3, 4 — the current order. ### Profiles Profiles are keyed by zone name, so every key must resolve to a channel. On load, each key resolves through the same order as `--zone`: display name first, subsystem name second. A key that resolves to nothing is skipped with a warning, as today. The Impact 80's display names are the strings its existing profiles already use, so those profiles keep loading. Renaming a channel in `keyboards.json` invalidates the profiles that used the old name, and README.md will say so. ## Constraints - `keyboard info` must not open a device, so it cannot report channels. - Read-back reporting stays as it is for the parameters that report values today, `brightness`, `speed` and `color`. `effect` and `mode` still write without reading back; closing that gap is a separate change, deliberately not folded into this one. - A silent retarget is a defect. Where a name could mean two channels, the tool refuses. - No Vial support, and no editing of effect catalogs outside code. ## Acceptance criteria 1. `effect --list` and `info` produce byte-identical output on the Impact 80 before and after the change. The board we have must not change behaviour. 2. `qmk-rgb-tool info` names the Impact 80's channels `logo`, `backlight` and `side`, and `qmk-rgb-tool --zone rgb_matrix brightness 160` reaches the same zone as the old `--zone backlight`. 3. `--zone logo` reaches channel 2 on the Impact 80, because `keyboards.json` names it. On a board with no entry, `--zone logo` is rejected with an error naming the accepted forms, and the exit code is non-zero. 4. `keyboards.json` with a display name that shadows a present channel's subsystem name is rejected at load, naming the conflict. 5. On a board with no catalog entry, `effect --list` prints an empty `zones` array and `effect anything` fails with a message that names `mode`. ## Testing Unit tests against the existing protocol fakes, no device: - `DetectChannels` returns exactly the channels that answer, treats `0xFF` as absence, and propagates a read error instead of reporting a short list - no channel present produces a clear error, not an empty zone list - name resolution for a known board, an unknown board, and a display name that shadows a subsystem name - catalog gating: known board resolves names, unknown board does not - `mode` still works on a board without a catalog - profile load with display-name keys and with subsystem-name keys, and the warning for a key that resolves to nothing - removal of the dead `Effect` enum and `State` type, together with their tests Live verification on the Impact 80, by hand, comparing before and after: `effect --list`, `info`, one `brightness`, one `speed`, one `color`, and a profile save and load round trip. ## Documentation README.md: - `--zone` vocabulary is the QMK subsystem names, with display names per board - the protocol section names the channel enum and its QMK source - the effect catalog is per board; an unknown board has raw IDs only - renaming a channel invalidates the profiles that used the old name AGENTS.md: - Device Selection no longer claims the three zones are fixed, and no longer claims `--zone` takes `logo`, `backlight` or `side` - the provenance note from the catalog section, so nobody re-derives the names or edits them on a hunch ## Risks - **A board that answers on a channel it should not.** If a firmware responds to a channel it does not implement, the probe reports a phantom zone. Nothing in the measurement shows that, and a read-back after every write would catch it, but the detection itself would still be wrong. - **The transient protocol desync.** A `save` during this session failed once with `Interrupted system call` and two value mismatches, which is a request/response stream falling out of step. A probe is 15 sequential round trips and is therefore more exposed to it than a single read. If it happens, the probe must fail loudly rather than report a short channel list as complete. This is unfixed and tracked separately. - **Profile names become board metadata.** A board whose display names change silently stops loading its old profiles. Documented, and a warning is printed.