2026-09-27-channel-discovery-design.md 13 KB

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:

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:<h>,<s>,<v> 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 <name> fails and names mode <index> 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:

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:

{
  "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:

// 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 <name> returns an error naming the board and pointing at mode <index>
  • effect --list prints an empty zones array
  • mode <index>, 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:

{
  "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.