Explorar o código

read effect names from a VIA definition file, and print text by default

The keyboard holds effect numbers, not names, and the names live in a VIA
definition file — the same file VIA reads when you open a board in its web
app. So the tool takes them from there, in the order: the file named by
--definition, then the file in definitions/ whose vendor and product ID
match the board, then the catalog compiled in for a known board.
resolveCatalog is the only place that lookup happens, so a command cannot
ignore a file the user placed.

Two things about reading those files are measured, not assumed. An option's
number is not its position: 194 options in VIA's own collection of 2029
definitions carry a number that differs from where they stand, so the ID is
read from the number and the position is only a fallback. And a fetched file
has to be parsed and matched before it is believed, because VIA answers an
unknown board with its own web page and HTTP 200. definition fetch tries v3
then v2 and stores only a document that parses and names the board asked
about; the Impact 80 is one of the boards VIA does not carry, and it says so
and names the two ways to supply one.

A definition also names its channels, in the sub-menu it puts each under, and
those are the names VIA shows, so they win over keyboards.json. Both that name
and the keyboards.json one stay accepted and matching ignores case, because
users type lowercase and `--zone backlight` must keep working on a board whose
definition writes `Backlight`.

The tool's aliases now belong to every catalog rather than to the compiled-in
one, and a name resolves to itself, to the name it aliases, and to an alias of
it, with a space and an underscore the same character. A definition writes the
manufacturer's spelling — this board's file writes "breathe" and "fixed wave"
where the compiled-in catalog writes "breathing" and "fixed_wave" — and without
that the Impact 80's profile stopped loading the moment a file was placed, one
warning per zone, and `effect fixed_wave` failed outright.

The five commands that report state print text now, because that is what a
person reads, and JSON behind a persistent --json, with the field names
unchanged. An agent passes the flag; a person does not learn a second shape.
`effect --list` prints one effect per line with its ID, and the last line says
which file the names came from, because a list without it does not say whether
it came from a definition or from the tool.

A profile records the keyboard it was saved from, since the names in it are
that board's. Loading one onto another keyboard still works and now says which
profile belongs to which keyboard. The five profiles in this repository are
Impact 80 profiles: only that board has channel display names in keyboards.json
at all, so a profile written with logo, backlight and side keys can only have
come from it, and the address tuple is derived rather than guessed.

Two defects fixed on the way. load indexed the result of ResolveEffect without
checking its length, and that function can answer with nothing to do and no
error, so loading a profile whose name a channel does not have crashed instead
of skipping the zone. And keyboard info listed channels it cannot know: it does
not open the board, so it was printing the channels a definition names rather
than the ones the board has.
Paul Klumpp hai 1 semana
pai
achega
4391d58f20

+ 37 - 9
.claude/skills/qmk-rgb/SKILL.md

@@ -23,9 +23,11 @@ The tool is the single source of truth. Always start by fetching its output:
 
 ```bash
 qmk-rgb-tool --help                           # Command structure
-qmk-rgb-tool effect --list                    # All available effects (JSON)
+qmk-rgb-tool effect --list                    # Effects per zone, one per line
+qmk-rgb-tool --json effect --list             # The same, as JSON for parsing
 qmk-rgb-tool keyboard info                    # Connected keyboards
 qmk-rgb-tool info                             # Current RGB state
+qmk-rgb-tool definition list                  # Which definition files are present
 ```
 
 Present the raw output to the user. Never duplicate documentation — the tool output is authoritative.
@@ -45,8 +47,8 @@ If no keyboard is connected:
 ### 3. When a user wants to change something
 
 - Identify the channel: `backlight`, `rgblight`, `rgb_matrix`, `audio` or
-  `led_matrix`, or a name from `keyboards.json` — on the Impact 80 that is
-  `logo`, `backlight` or `side`
+  `led_matrix`, or the name this keyboard's definition gives it — on the Impact
+  80 that is `logo`, `Backlight` or `side`, and the name matches ignoring case
 - Ask for confirmation if the action will affect every channel
 - Run the command: `qmk-rgb-tool effect breathing --zone rgb_matrix`
 - Return the output
@@ -62,6 +64,28 @@ qmk-rgb-tool load name  # Apply a profile
 qmk-rgb-tool delete name # Remove a profile
 ```
 
+## Setting up a keyboard whose effect names are missing
+
+Effect names come from a VIA definition file, the same one VIA reads. If a
+keyboard has no names, say so and offer the two ways to supply them, in this
+order:
+
+```bash
+qmk-rgb-tool definition fetch   # download the definition for the connected board
+qmk-rgb-tool definition list    # what is in definitions/
+```
+
+`fetch` needs the board to be in VIA's collection. Many are not, including the
+Impact 80. Then the vendor's own definition is the source: find the board's
+support or driver page, download the VIA JSON it links, and put it in
+`definitions/` or pass it with `--definition <path>`. A file is used for its own
+board only; one for another board is refused by name.
+
+Never write an effect name into this repo that no source states. Either the
+vendor's file names it, or the user has looked at the board and named what they
+see — in which case it is a tool name, it goes in the board's file in
+`internal/rgb`, and the comment says it was measured.
+
 ## Zone behavior
 
 `--zone` names a VIA lighting channel, and the tool asks the keyboard which
@@ -70,9 +94,10 @@ channel order. With `--zone`, commands target exactly that channel. `--zone` is
 not remembered between runs, so repeat it on every invocation.
 
 On the Impact 80 the `rgblight` and `audio` channels carry fewer effects (0–6)
-than `rgb_matrix` (0–45). Always check `qmk-rgb-tool effect --list` to see what is
+than `rgb_matrix`. Always check `qmk-rgb-tool effect --list` to see what is
 available per channel, and note that a keyboard with no catalog has no effect
-names at all — `mode <index>` is the way to set one there.
+names at all — `mode <index>` is the way to set one there. The list prints the
+file the names came from as its last line.
 
 ## Values the keyboard changes
 
@@ -82,7 +107,7 @@ speeds — 0 freezes the animation, 1 is the slowest movement, anything above 1
 becomes 4 — while `rgb_matrix` scales brightness up to 255 and applies speed as
 given.
 
-Both commands read the value back. When a zone differs, the output names what
+These commands read the value back. When a zone differs, the output names what
 was actually applied:
 
 ```
@@ -101,7 +126,10 @@ user that the tool's own output did not confirm.
 - `solid` → varies by zone (resolves automatically)
 
 Aliases are channel-aware. `qmk-rgb-tool effect rainbow` means different effects
-on the `rgblight` channel than on `rgb_matrix`.
+on the `rgblight` channel than on `rgb_matrix`. They also bridge a definition
+file's spelling: where a vendor writes `breathe` or `fixed wave`, the tool's
+`breathing` and `fixed_wave` still resolve to the same effect, and the output
+shows the spelling of the source in use.
 
 ## Known limitations
 
@@ -110,8 +138,8 @@ on the `rgblight` channel than on `rgb_matrix`.
   firmware's own default effect; run `qmk-rgb-tool load <name>` to reapply a
   profile.
 - Some effects are Backlight-only, and the set is larger than it looks: the
-  `rgblight` and `audio` channels have only seven effects, so Backlight IDs 1, 2,
-  3, 4, 6 and 7–45 are all Backlight-only. Trying one on another channel is
+  `rgblight` and `audio` channels have only seven effects, so every Backlight ID
+  above 6 is Backlight-only. Trying one on another channel is
   refused with `effect <name> is not supported on <channel>`, and nothing is
   written. The check happens after the keyboard has been opened, because which
   channels a board has is only knowable by asking it.

+ 87 - 7
AGENTS.md

@@ -10,7 +10,9 @@ This file deliberately does not repeat any of it. A second copy is a second
 thing that can go stale, and that has happened: a command prefix that had not
 existed for many commits survived in eleven lines of prose, and a
 `keyboards.json` field was documented here as load-bearing while the code never
-read it. So the split is:
+read it. The file itself is now gone, replaced by the definition file and the
+keyboard's own product string; a second place for a name is a second place for it
+to go stale. So the split is:
 
 - `README.md` — what the software does. Reference.
 - `AGENTS.md` — how to work on it. Discipline.
@@ -30,9 +32,15 @@ Cross-platform Go CLI library for programmatic/agent-friendly control of QMK key
 
 Discovery matches every connected keyboard exposing the QMK Raw HID signature
 (Usage Page `0xFF60`, Usage `0x61`) — the same check `qmk/qmk_udev` performs.
-`keyboards.json` is only consulted to attach a name to a recognized model and to
-name its channels; an absent or malformed file costs names, not the ability to
-drive the keyboard.
+`keyboards.json` supplies the model name and the channel display names, and its
+absence costs names only, not the ability to drive the keyboard. A definition
+file for the board takes precedence over it for the channel names, because those
+are the names VIA shows.
+
+`keyboard info` does not open the board, so it must not report the board's
+channels: it does not know them, and printing the ones a definition names would
+claim more than it knows. `info` and `effect --list` open the board and report
+them.
 
 No command validates `--zone` or `--device` before the command that needs it
 does. A root-level pre-run would make `keyboard info`, `list`, `delete` and
@@ -48,8 +56,8 @@ otherwise they fail and list the selectable numbers, so a command never targets
 an unintended keyboard. Device numbers are stable for the current session only —
 HID paths are reassigned on reboot and most keyboards report no serial number.
 
-`--zone` takes a VIA lighting channel, named by its QMK subsystem, and a board
-in `keyboards.json` may give a channel a display name. README.md carries the
+`--zone` takes a VIA lighting channel, named by its QMK subsystem, and a board's
+definition file or keyboards.json entry may name a channel differently. README.md carries the
 vocabulary. It is a persistent flag in cobra's sense only — accepted on the
 root and inherited by subcommands — and nothing is remembered between runs, so
 it must be repeated on every invocation. Without `--zone`, commands target every
@@ -79,7 +87,7 @@ behavior counts too. Before any edit:
 1. **Binary name** — the `go build -o` target is `qmk-rgb-tool`, which is not the module's last path element. It must match every example in README, AGENTS.md, comments, and docs.
 2. **CLI command** — the root command name (e.g. `qmk-rgb-tool`) is the binary name; every usage example, CLI reference table, and doc must use the same string.
 3. **Module path** — the `go.mod` module path is the source of truth for `go install` targets.
-4. **Zone names** — the vocabulary is QMK's subsystem names, so they must be identical in code, docs, and examples: `backlight`, `rgblight`, `rgb_matrix`, `audio`, `led_matrix`. A board's own names (`logo`, `backlight`, `side` on the Impact 80) are **data** in `keyboards.json`, not code and not part of the vocabulary: do not hardcode one into Go, and do not put one in a `--zone` help string.
+4. **Zone names** — the vocabulary is QMK's subsystem names, so they must be identical in code, docs, and examples: `backlight`, `rgblight`, `rgb_matrix`, `audio`, `led_matrix`. A board's own names (`logo`, `Backlight`, `side` on the Impact 80) are **data** in its definition file, not code and not part of the vocabulary: do not hardcode one into Go, and do not put one in a `--zone` help string. A board's *model* name is the definition's `name`, the keyboards.json entry, or nothing.
 
 Documented behavior must also match the code:
 
@@ -164,6 +172,78 @@ govern another, so "the QMK source says" has to name the channel it was read
 for. A formula from the wrong subsystem is how this repo ended up claiming that
 speed 0 and 1 behaved identically.
 
+## Effect Names Come From Definition Files
+
+A board's effect names live in a VIA definition file, the same one VIA reads. The
+tool therefore depends on those files, exactly as VIA does, and takes them in a
+fixed order: `resolveCatalog` in `cmd/qmk-rgb-tool/catalog.go` is the **only**
+place a catalog is looked up, and the order is `--definition`, then a file in
+`definitions/` whose `vendorId`/`productId` match the board, then the compiled-in
+`CatalogFor`. A command that called `CatalogFor` itself would silently ignore a
+file the user had placed, so do not add one.
+
+Two things about reading those files are easy to get wrong, and both were
+measured on VIA's own collection of 2029 definitions rather than assumed:
+
+- An option's number is not its position. 194 options carry a number that differs
+  from where they stand, so a board can name its first effect as ID 1 and have
+  no ID 0 at all. `internal/rgb/definition.go` reads the number and falls back to
+  the position only when there is none. This is why `Catalog` holds
+  `[]Effect{ID, Name}` and not a `[]string` indexed by ID.
+- A definition names its channels too, in the sub-menu it puts each one under,
+  and those names are what VIA shows, so they win over keyboards.json. The
+  keyboards.json name and the QMK subsystem name both stay accepted, and matching
+  ignores case, so `--zone backlight` keeps working on a board whose definition
+  writes `Backlight`. The QMK subsystem name always works, which is why the subsystem is
+  what a document should tell a user to type.
+- A fetched file has to be parsed and matched before it is believed. VIA answers
+  an unknown board with its own web page and HTTP 200, so status alone proves
+  nothing. `definition fetch` tries v3 then v2, and only stores a document that
+  parses and names the board that was asked about.
+
+The files are VIA's format, but their addressing is QMK's, and that is not a
+choice this tool made. The HID interface, the channel numbers, the lighting
+value IDs and the whole channel probe are QMK's; only the names come from the
+file, because the VIA protocol cannot carry names and VIA reads them from a
+file for the same reason. Measured over VIA's collection, all 190 effect
+dropdowns there use a QMK value key, so keying the parser on `id_qmk_*_effect`
+loses nothing today. `effectValueKeys` in `internal/rgb/definition.go` is the
+only place to register a vendor's own key, and a board that invents one gets no
+names until it is added there.
+
+A definition writes the manufacturer's spelling, which need not be the tool's,
+so the alias table is a package default every catalog gets rather than part of
+one board's list, and `EffectID` resolves a name to itself, to the name it
+aliases, and to an alias of it, and `byName` treats a space and an underscore as
+the same character, because that is the whole difference between a
+manufacturer's display spelling and the tool's identifier — `fixed wave` and
+`fixed_wave` are one effect, and a documented name may not stop working because a
+definition file is present. Without the last of those, the Impact 80's
+profile stops loading the moment a definition file is placed: the file writes
+`breathe`, the compiled-in catalog writes `breathing`, and only one of them is
+present at a time.
+
+A definition names the effects a board's firmware implements, so it may cover
+fewer IDs than the board takes. A file replaces the compiled-in catalog for its
+board, which is worth knowing before adding one for the Impact 80: the vendor's
+file stops at ID 45, so the tool's own name for ID 46 is not reachable while that
+file is present. README.md carries the user-facing half of this.
+
+There is no shared database of effect names to be had, and do not plan around
+one. The closest thing is VIA's collection, `the-via/keyboards`: 2029
+definitions, one per unique VID/PID, of which 179 name VIA's built-in
+`qmk_rgb_matrix` menu and 163 ship their own `id_qmk_rgb_matrix_effect`
+dropdown, which is where a board's effect names actually live — not under a
+`lighting` or `effects` key, so a grep for those finds nothing and looks like
+proof that no bulk source exists. The 153 that deviate are not merely shorter
+lists: they reorder IDs and spell names as QMK enum identifiers or as numbered
+display labels, and only 10 are an exact prefix of the built-in 45. So a
+subsystem default is wrong for most boards, and a board's names come from the
+definition its own vendor publishes. OpenRGB drives the same raw HID interface
+but keeps its device data in C++ controllers, so it settles VID/PID, not effects.
+README.md carries the details; the conclusion for code here is that a catalog is
+transcribed per board and cannot be generated from a common source.
+
 ## Code vs Documentation
 
 All documentation (README.md, comments, AGENTS.md) must stay in sync with the code.

+ 175 - 72
README.md

@@ -89,13 +89,12 @@ re-run the command whenever commands or flags change.
 ```
 
 `--zone` accepts a VIA lighting channel: `backlight`, `rgblight`, `rgb_matrix`,
-`audio` or `led_matrix`, and may be given on any subcommand. The name follows
-from the channel number, so it works on every QMK keyboard. A board listed in
-`keyboards.json` may give a channel a display name, and that name is accepted
-too — the Impact 80 calls channels 2, 3 and 4 `logo`, `backlight` and `side`.
-Where a display name would be the subsystem name of a different channel the
-keyboard has, the board is refused rather than a command being sent to the wrong
-channel.
+`audio` or `led_matrix`, and may be given on any subcommand. That name follows
+from the channel number, so it works on every QMK keyboard. A board's definition
+file may name a channel differently, and that name is accepted too, ignoring
+case — the Impact 80 calls channels 2, 3 and 4 `logo`, `Backlight` and `side`.
+Where a name would be the subsystem name of a different channel the keyboard has,
+the board is refused rather than a command being sent to the wrong channel.
 
 `--zone` is not remembered between runs: repeat it on every invocation. Without
 `--zone`, commands target every channel the keyboard reports, in channel order.
@@ -193,14 +192,14 @@ a hex triple.
 ## Effect Names Are Per Board
 
 The keyboard holds effect numbers, not names, so `effect <name>` needs a catalog
-and the tool has one for the Impact 80. Its 46 backlight names are the QMK
+and the tool has one for the Impact 80. Its first 46 backlight names are the QMK
 `rgb_matrix_effects.inc` of the VIA era, transcribed from that board's vendor VIA
 definition, and the 7 `logo` and 7 `side` names are that same definition, whose
-dropdowns read `fixed wave` and `breathe`; the compatibility aliases are the
-tool's own.
+dropdowns read `fixed wave` and `breathe`. The 47th backlight name, `freeze` at
+ID 46, is the tool's own, and so are the compatibility aliases.
 
 `effect` writes the ID and reads the register back, because a board can refuse
-one. All 46 backlight IDs are taken, and so are the `logo` and `side` IDs from 1
+one. All 47 backlight IDs are taken, and so are the `logo` and `side` IDs from 1
 to 6. ID 0 is not: on those two channels the firmware reads it as "lighting
 off" and leaves the mode register where it was, so `effect none --zone logo`
 leaves the previous effect running and says so.
@@ -211,9 +210,33 @@ Effect logo "wave" (1) (requested "none" (0))
 ```
 
 The read-back proves an ID is one the keyboard takes, not that the name labels
-it correctly; the names come from the vendor definition. The board takes 47 IDs
-on the backlight channel, 0 to 46, of which this catalog names 46 — ID 46 exists,
-is reported as `unknown`, and is set with `mode 46`.
+it correctly. The board takes 47 IDs on the backlight channel, 0 to 46, and
+anything above 46 is clamped down to it. 46 of those names come from the vendor
+definition, which ends at 45; the 47th is the tool's own.
+
+### `freeze` Is the Tool's Name, Not Wobkey's
+
+Set after a moving effect, ID 46 leaves the LEDs on the pattern they are
+currently showing and stops the animation. It is not another effect with a
+pattern of its own — `cycle_left_right` frozen still looks like a standing
+rainbow, because that is what it froze:
+
+```
+$ qmk-rgb-tool effect gradient_left_right --zone backlight
+Effect set to "gradient_left_right"
+$ qmk-rgb-tool effect freeze --zone backlight
+Effect set to "freeze"
+$ qmk-rgb-tool info | jq '.zones[] | select(.zone == "backlight")'
+  "effect": "freeze", "effectId": 46
+```
+
+Wobkey publishes no name for it. The vendor's
+[VIA definition JSON](https://drive.wobkey.com/f/d/6BtO/Impact_80.JSON) ends its
+backlight dropdown at `riverflow` (ID 45) and contains no word for pause, stop,
+freeze or hold, which means VIA itself cannot set this ID from its dropdown —
+a raw `mode 46` is the only way to reach it. `freeze` is the tool's word for the
+measured behaviour, and it is the only name in the catalog that Wobkey did not
+write down.
 
 A keyboard without a catalog is still driven: `brightness`, `speed`, `color`,
 `mode <index>` and `info` all work, because none of them needs a name. Four
@@ -235,6 +258,7 @@ Warning: this keyboard has no effect catalog, so the profile records effect "unk
 
 `enable` needs it because it has to choose an effect to turn a channel on.
 `load` applies nothing without it, because the profile stores effect names.
+
 `save` still writes the profile, but every effect is recorded as `unknown`, so
 that file cannot restore an effect later.
 
@@ -248,6 +272,66 @@ $ qmk-rgb-tool effect nonsense --zone rgb_matrix
 Error: unknown effect: nonsense
 ```
 
+### Where Effect Names Can Come From
+
+VIA's own keyboard collection, [`the-via/keyboards`](https://github.com/the-via/keyboards),
+is the only bulk source, and it is more useful than it looks. It holds 2029
+definitions under `v3/`, one per unique `vendorId`/`productId` pair, and not one
+of them has a `lighting` or an `effects` key — a keyboard's effect names are not
+where you would look for them.
+
+They are in `menus`. A definition either names one of VIA's built-in lighting
+menus, or spells out its own UI as dropdowns, and a dropdown for
+`id_qmk_rgb_matrix_effect` is a board's effect list, in ID order. Measured over
+the collection:
+
+| | Boards |
+|---|---|
+| name VIA's built-in `qmk_rgb_matrix` menu | 179 |
+| ship their own `id_qmk_rgb_matrix_effect` dropdown | 163 |
+| of those, an exact prefix of the built-in list | 10 |
+
+The built-in list lives in
+[`the-via/reader/src/common-menus/qmk_rgb_matrix.ts`](https://github.com/the-via/reader/blob/master/src/common-menus/qmk_rgb_matrix.ts)
+and has 45 names, `All Off` through `Solid Multi Splash`, at IDs 0 to 44. A board
+that uses it needs no work at all: the names and the IDs are the built-in ones.
+
+The other 153 deviate, and not only in how many effects they compile in. MonsGeek's
+M1 uses QMK's enum identifiers and its own 19: `SOLID_COLOR`, `BREATHING`,
+`CYCLE_ALL`, `TYPING_HEATMAP`, `MATRIX_MULTISPLASH`. Redragon's K667 ships 14 in
+yet another order, with `Pixel Fractal` already at ID 13. Only 11 of the 163
+write names as enum identifiers; 152 use display labels, so the same effect arrives
+as `All Off`, `NONE` or `00.NONE` depending on the vendor.
+
+That is why a catalog cannot be defaulted per subsystem, and it is the same
+reason the Impact 80 needs its own. Its backlight channel reorders IDs 15 to 17 —
+`cycle_out_in`, `cycle_out_in_dual`, then `rainbow_moving_chevron`, where the
+built-in list has `rainbow_moving_chevron` first — and its list runs to 45 because
+it adds `flower_blooming`, `starlight`, `starlight_dual_hue`, `starlight_dual_sat`
+and `riverflow`, none of which the built-in list has. A board can also be at an
+ID the built-in list does not name at all, and the source for that name is the
+board, not VIA:
+
+| What | Where |
+|------|-------|
+| VIA definition JSON, the origin of all three Impact 80 effect lists | [`Impact_80.JSON`](https://drive.wobkey.com/f/d/6BtO/Impact_80.JSON) |
+| VIA firmware image | [`impact_80.bin`](https://drive.wobkey.com/f/d/9yH8/impact_80.bin) |
+| Update instructions, vendor's warning against updating a working board | [Driver & Firmware page](https://wiki.wobkey.com/en/Products/PMOKEY-Impact-80/Driver-Firmware) |
+
+The Impact 80 is not in VIA's collection — there is no Wobkey entry under `v3/`
+— so its names are reachable only through that vendor page.
+
+[OpenRGB](https://codeberg.org/OpenRGB/OpenRGB) also drives QMK keyboards over
+the very same raw HID interface, but as `Controllers/QMKController/` including a
+Vial and a Keychron variant: C++ device code, no JSON, no effect tables. It is a
+good cross-check for which VID/PID belongs to which model, and no help for effect
+names. The project has left GitHub; the mirror is
+[`CalcProgrammer1/OpenRGB`](https://github.com/CalcProgrammer1/OpenRGB).
+
+So a catalog is transcribed per board, and the collection above says where to
+look for each board's list, but which IDs a board takes is still confirmed
+against its register. That part cannot be automated from any database.
+
 ## Brightness and Speed Are Not Applied Verbatim
 
 The keyboard's firmware transforms these values per channel, so the accepted
@@ -295,7 +379,7 @@ failure, but the summary line always states the value that was actually applied.
 - **Cross-platform** — Linux, macOS, Windows via [hidapi](https://github.com/libusb/hidapi)
 - **Channel-aware effects** — per-channel effect control with per-board effect catalogs, so a board without one is still driven
 - **Profile system** — save, load, list, and delete RGB presets as JSON files in `profiles/`
-- **46 backlight effect names** — the Impact 80's VIA-era QMK family, mapped to zone-aware names; the board takes 47 IDs, the highest has no name
+- **47 backlight effect names** — the Impact 80's VIA-era QMK family plus `freeze` for ID 46, which the vendor does not name
 - **Compatibility aliases** — `off`, `breathe`, `rainbow`, `rainbow_wave`, `solid`, `static` resolve to correct effect IDs per channel
 - **Reactive & splash effects** — honor `color` and `speed` for key-press illumination
 - **Machine-parseable output** — JSON for `keyboard info`, `info`, `list`, and `effect --list`
@@ -310,9 +394,9 @@ failure, but the summary line always states the value that was actually applied.
 | Backlight | `backlight` | 3 | 0–45 |
 | Side | `side` | 4 | 0–6 |
 
-Those three display names live in `keyboards.json`; the QMK subsystem names for
-the same channels are `rgblight`, `rgb_matrix` and `audio`, and both spellings
-work on this board.
+Those three display names come from the board's definition file, in the sub-menu
+it puts each channel under. The QMK subsystem names for the same channels are
+`rgblight`, `rgb_matrix` and `audio`, and both spellings work on this board.
 
 Logo and Side share this complete effect family:
 
@@ -353,6 +437,7 @@ Backlight uses the complete Impact 80 catalog:
 | 20 | `dual_beacon` | 43 | `starlight_dual_hue` |
 | 21 | `rainbow_beacon` | 44 | `starlight_dual_sat` |
 | 22 | `rainbow_pinwheels` | 45 | `riverflow` |
+| | | 46 | `freeze` (tool's name) |
 
 ## Effect Behavior
 
@@ -381,6 +466,23 @@ for Logo/Side and `rainbow_moving_chevron` for Backlight, `rainbow_wave` →
 for Backlight. The legacy `static` alias remains accepted as a compatibility
 alias for `solid`.
 
+The aliases belong to the tool, not to a board, so they work for every catalog —
+including one read from a definition file. A name resolves to itself, to the name
+it is an alias of, or to an alias of it, and spaces and underscores are the same
+character as far as lookup is concerned. So on this board all four of these select
+the same effect:
+
+```
+qmk-rgb-tool effect breathing --zone logo     # the compiled-in catalog's spelling
+qmk-rgb-tool effect breathe   --zone logo     # the vendor definition's spelling
+qmk-rgb-tool effect fixed_wave --zone logo     # the tool's spelling
+qmk-rgb-tool effect "fixed wave" --zone logo   # the vendor definition's spelling
+```
+
+`info` and `effect --list` report the spelling of whichever source is in use,
+which is why the list may show `fixed wave` where an earlier version of this
+document wrote `fixed_wave`. The ID beside the name is the same in both.
+
 The effect catalog follows the QMK RGB Matrix firmware of the VIA era, not
 current QMK master: the two lists differ in length, in naming and in numbering
 (`alpha_mods` against `alphas_mods`, `colorband_sat` against `band_sat`, and
@@ -463,59 +565,35 @@ Any keyboard running QMK is detected automatically via the QMK Raw HID
 signature (Usage Page 0xFF60, Usage 0x61). No manual configuration required.
 
 That signature says nothing about lighting, so a keyboard with no VIA lighting
-channel at all is listed here and then fails every command that opens it with
+channel at all is listed and then fails every command that opens it with
 `this keyboard exposes no VIA lighting channels`.
 
-`keyboards.json` is looked for next to the executable first, then in the current
-working directory. `go install` puts the binary in `$GOPATH/bin` while the file
-stays in the repository, so run the tool from the repository, put a copy of the
-file next to the binary, or name the zones by their QMK subsystem. Without the
-file the tool still works: model names, the `logo`/`backlight`/`side` display
-names and the effect catalog are what it supplies.
-
-`keyboards.json` is optional. When present it supplies a name for the model and,
-per entry, a display name per channel: `keyboard info` reports `"known": true`
-for models listed there and `"known": false` for every other QMK keyboard. Both
-are fully controllable — `known` describes the name lookup, not compatibility.
-Deleting or corrupting the file costs names only: the channel vocabulary
-survives, because the QMK subsystem name follows from the channel number.
-
-Two models are listed in `keyboards.json`:
-
-| Keyboard        | VID    | PID    |
-|-----------------|--------|--------|
-| Wobkey Rainy 75 | 0x6666 | 0x0001 |
-| Wobkey Impact 80 | 0x36B0 | 0x309F |
-
-### Impact 80 Sources
-
-The vendor's
-[Driver & Firmware page](https://wiki.wobkey.com/en/Products/PMOKEY-Impact-80/Driver-Firmware)
-is where the effect catalog in `internal/rgb/impact80.go` comes from. It is the
-authority on the wire format, and it settles two things this tool cannot:
-
-- The board must be in **wired mode** for VIA to see it. On 2.4G or Bluetooth
-  the keyboard is not detected at all, so a tool that finds no keyboard is
-  consistent with the hardware being on wireless. Changes made in wired mode are
-  saved to onboard memory and still apply in wireless mode.
-- The vendor ships **two firmware variants**. The VIA variant is what this tool
-  drives. The proprietary variant offers the advanced lighting (music rhythm,
-  SyncLight) but **disables VIA**, so a board flashed with it exposes no QMK Raw
-  HID interface and is invisible to the tool, not unsupported by it. The audio
-  channel this board reports as `side` is where that lighting lives.
-
-The page links the sources used here directly:
+A definition also names its channels, and those names take precedence over the
+ones in `keyboards.json`, because they are the names VIA shows — so the tool and
+VIA call a channel the same thing, and a name is matched ignoring case. On a board whose
+definition writes `Backlight`, all of `--zone backlight`, `--zone Backlight` and
+`--zone rgb_matrix` reach the same channel: the label is the name, the subsystem
+stays accepted because it follows from the channel number. `qmk-rgb-tool
+definition list` prints the label beside the subsystem it stands for:
+
+```
+Impact 80 (0x36B0/0x309F) definitions/impact80.json
+  logo       rgblight   7 effects
+  Backlight  rgb_matrix 46 effects
+  side       audio      7 effects
+```
+
+`keyboards.json` still supplies the model name and can name a channel as well.
+It is looked for next to the executable first, then in the current working
+directory; without the file the tool still works, and a board is shown as
+`unknown model` when nothing else names it.
+
+```console
+$ qmk-rgb-tool keyboard info
+1  Wobkey Impact 80  0x36B0/0x309F (known model)
+```
 
-| What | Where |
-|------|-------|
-| VIA definition JSON, the origin of all three effect lists | [`Impact_80.JSON`](https://drive.wobkey.com/f/d/6BtO/Impact_80.JSON) |
-| VIA firmware image | [`impact_80.bin`](https://drive.wobkey.com/f/d/9yH8/impact_80.bin) |
-| Update instructions, vendor's warning against updating a working board | [Driver & Firmware page](https://wiki.wobkey.com/en/Products/PMOKEY-Impact-80/Driver-Firmware) |
 
-Stock VIA exposes no way to ask a keyboard which effect IDs it implements, so
-the names come from that JSON and not from the board. The counts the board
-actually takes were measured here: 47 on the backlight channel, 6 on `logo` and
-`side`.
 
 ## CLI Reference
 
@@ -524,17 +602,19 @@ actually takes were measured here: 47 on the backlight channel, 6 on `logo` and
 | `qmk-rgb-tool keyboard info`          | Discover connected QMK keyboards          |
 | `qmk-rgb-tool enable`                 | Enable selected lighting zones            |
 | `qmk-rgb-tool disable`                | Disable selected lighting zones           |
-| `qmk-rgb-tool info`                   | Show per-zone RGB state (JSON)            |
+| `qmk-rgb-tool info`                   | Show per-zone RGB state                   |
 | `qmk-rgb-tool effect <name>`          | Set a zone-aware effect by name, verified by read-back |
-| `qmk-rgb-tool effect`                 | With no argument, list every effect per channel (JSON) |
+| `qmk-rgb-tool effect`                 | With no argument, list every effect per channel |
 | `qmk-rgb-tool effect --list`          | The same list, as a flag                   |
+| `qmk-rgb-tool definition fetch`       | Download the VIA definition for the connected keyboard |
+| `qmk-rgb-tool definition list`        | List the definition files in the data directory |
 | `qmk-rgb-tool brightness <val>`       | Set brightness (0–255) on selected zones, verified by read-back |
 | `qmk-rgb-tool speed <val>`            | Set effect speed (0–255) on selected zones, verified by read-back; on `logo` and `side` only 0, 1 and 4 are reachable |
 | `qmk-rgb-tool color <hex>`            | Set color (e.g. `ff0000`) on selected zones |
 | `qmk-rgb-tool color rgb:<hex>`        | The same hex color, written out |
 | `qmk-rgb-tool color hsv:<h>,<s>,<v>`  | Set hue and saturation (0–255) and write `v` to the brightness of the same zones |
 | `qmk-rgb-tool mode <index>`           | Set a raw zone-specific effect ID, verified by read-back; a board that does not implement the index clamps it to the highest it does |
-| `qmk-rgb-tool --zone <channel> ...`   | Target one channel: `backlight`, `rgblight`, `rgb_matrix`, `audio` or `led_matrix`, or a name from `keyboards.json` |
+| `qmk-rgb-tool --zone <channel> ...`   | Target one channel: `backlight`, `rgblight`, `rgb_matrix`, `audio` or `led_matrix`, or the name the board's definition gives it |
 | `qmk-rgb-tool --device <n> ...`      | Target keyboard by number (see `keyboard info`) |
 | `qmk-rgb-tool -v`, `--version`       | Print the version                          |
 | `qmk-rgb-tool save [name]`            | Save current RGB state to `profiles/<name>.json` (name lowercased, non-`[a-z0-9-_]` mapped to `-`, a leading `-` prefixed with `unnamed-`); without a name it writes `default` |
@@ -547,11 +627,34 @@ actually takes were measured here: 47 on the backlight channel, 6 on `logo` and
 token. `effect`, `load`, `save` and `delete` accept an optional name.
 `brightness`, `speed`, `color` and `mode` require exactly one argument.
 
+`keyboard info`, `info`, `list`, `definition list` and `effect --list` print
+text, because a person reads them. Pass `--json` for the machine shape, which is
+the same data with the same field names as before:
+
+```bash
+qmk-rgb-tool info
+qmk-rgb-tool --json info
+qmk-rgb-tool info --json      # the flag is persistent, both spellings work
+```
+
+`--definition <path>` names the definition file to read instead of the one in
+the data directory, and applies to every command that resolves effect names.
+
 ## Profiles
 
 Profiles store RGB state (effect, brightness, speed, color) per channel as JSON
 files in the `profiles/` directory, which is resolved against the working
-directory the tool runs in. The keys are the names the file was written with, so
+directory the tool runs in. A profile also records the keyboard it was saved
+from, because the effect names in it are that board's:
+
+```json
+"board": { "vendorId": "0x36B0", "productId": "0x309F" }
+```
+
+Loading a profile onto another keyboard still works — names that do not exist
+there are reported per key and skipped — but it says which profile belongs to
+which keyboard. A profile written before this field existed has no board and is
+loaded without complaint. The keys are the names the file was written with, so
 a profile written before a board was renamed reports the key it cannot place and
 skips it. They are the only way to persist RGB
 settings across reboots. The keyboard's VIA protocol does not expose a
@@ -562,7 +665,7 @@ files instead.
 
 ```
 cmd/qmk-rgb-tool/  # Cobra-based CLI
-internal/device/  # HID discovery + keyboards.json loader
+internal/device/  # HID discovery
 internal/hid/     # Cross-platform HID access (hidapi)
 internal/rgb/     # Effects, colors, state
 internal/via/     # VIA protocol implementation

+ 1 - 1
cmd/qmk-rgb-tool/brightness_verify_test.go

@@ -20,7 +20,7 @@ type verifyingProtocol struct {
 	getCalls int
 }
 
-// impact80Display is the channel naming the Impact 80's keyboards.json entry
+// impact80Display is the channel naming the Impact 80's definition file
 // supplies, so summary lines name logo, backlight and side.
 func impact80Display() map[uint16]string {
 	return map[uint16]string{2: "logo", 3: "backlight", 4: "side"}

+ 170 - 0
cmd/qmk-rgb-tool/catalog.go

@@ -0,0 +1,170 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+
+	intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
+	"netdome.biz/paul/qmk-rgb/internal/via"
+)
+
+// definitionFlag names a definition file to use instead of looking one up.
+var definitionFlag string
+
+// resolveCatalog returns the effect catalog for a board, in the order the tool
+// trusts: the file named by --definition, then the definition in the data
+// directory that matches the board, then the catalog compiled in for a known
+// board. The second return value says which of the three it was, because a
+// board that has no names at all is a different situation from one whose names
+// came from somewhere the user can see.
+//
+// This is the one place a catalog is looked up. A command that reached for a
+// catalog itself would silently ignore a definition file the user had placed,
+// which is the whole point of having one.
+func resolveCatalog(target targetDeviceData) (*intrgb.Catalog, string, error) {
+	if definitionFlag != "" {
+		def, err := intrgb.LoadDefinition(definitionFlag)
+		if err != nil {
+			return nil, "", err
+		}
+		if !def.Matches(target.Device.VendorID, target.Device.ProductID) {
+			return nil, "", fmt.Errorf("%s is a definition for %s (0x%04X/0x%04X), not for this keyboard (0x%04X/0x%04X)",
+				definitionFlag, def.Name, def.VendorID, def.ProductID,
+				target.Device.VendorID, target.Device.ProductID)
+		}
+		return def.Catalog, def.Path, nil
+	}
+
+	catalog, source, err := resolveCatalogFor(target.Device.VendorID, target.Device.ProductID)
+	return catalog, source, err
+}
+
+// resolveCatalogFor is the lookup without a target, for the commands that report
+// on a board rather than open it.
+func resolveCatalogFor(vendorID, productID uint16) (*intrgb.Catalog, string, error) {
+	if definitionFlag != "" {
+		def, err := intrgb.LoadDefinition(definitionFlag)
+		if err != nil {
+			return nil, "", err
+		}
+		if !def.Matches(vendorID, productID) {
+			return nil, "", nil
+		}
+		return def.Catalog, def.Path, nil
+	}
+
+	if dir, err := definitionsDir(); err == nil {
+		if defs, loadErr := intrgb.LoadDefinitionsDir(dir); loadErr == nil {
+			if def := intrgb.FindDefinition(defs, vendorID, productID); def != nil {
+				return def.Catalog, def.Path, nil
+			}
+		}
+	}
+
+	catalog, ok := intrgb.CatalogFor(vendorID, productID)
+	if !ok {
+		return nil, "", nil
+	}
+	return catalog, fmt.Sprintf("built in (%s)", catalog.Name()), nil
+}
+
+// lookupPaths returns the directories a data file is looked for in: the one
+// holding the executable, then the working directory, which is the order the
+// profile directory uses too.
+func lookupPaths() []string {
+	var paths []string
+	if exe, err := os.Executable(); err == nil {
+		paths = append(paths, filepath.Dir(exe))
+	}
+	if cwd, err := os.Getwd(); err == nil {
+		paths = append(paths, cwd)
+	}
+	return paths
+}
+
+// definitionLabels returns the channel names a definition gives the board, which
+// are the names VIA shows. They are the only channel names there are: a board
+// without a definition is addressed by its QMK subsystem name.
+func definitionLabels(vendorID, productID uint16) map[uint16]string {
+	def := loadedDefinition(vendorID, productID)
+	if def == nil {
+		return nil
+	}
+	return def.Labels
+}
+
+// loadedDefinition returns the definition file for a board, from the data
+// directory or from the file --definition names, and nil when there is none.
+func loadedDefinition(vendorID, productID uint16) *intrgb.Definition {
+	if definitionFlag != "" {
+		if def, err := intrgb.LoadDefinition(definitionFlag); err == nil && def.Matches(vendorID, productID) {
+			return def
+		}
+		return nil
+	}
+	dir, err := definitionsDir()
+	if err != nil {
+		return nil
+	}
+	defs, err := intrgb.LoadDefinitionsDir(dir)
+	if err != nil {
+		return nil
+	}
+	return intrgb.FindDefinition(defs, vendorID, productID)
+}
+
+// applyDefinitionLabels returns the display names with the definition's names
+// laid over them, and the names each channel keeps as alternatives. A definition
+// names a channel as VIA does, and the keyboards.json name and the QMK subsystem
+// name both stay accepted, so a command written before the definition arrived
+// keeps working.
+func applyDefinitionLabels(display map[uint16]string, vendorID, productID uint16) (map[uint16]string, map[uint16][]string) {
+	labels := definitionLabels(vendorID, productID)
+	merged := make(map[uint16]string, len(display)+len(labels))
+	for number, name := range display {
+		merged[number] = name
+	}
+	alternatives := make(map[uint16][]string, len(display))
+	for number, label := range labels {
+		if existing, ok := merged[number]; ok && existing != label {
+			alternatives[number] = append(alternatives[number], existing)
+		}
+		merged[number] = label
+	}
+	for number, name := range merged {
+		if subsystem := via.Channel(number).Subsystem(); subsystem != "" && subsystem != name {
+			alternatives[number] = append(alternatives[number], subsystem)
+		}
+	}
+	return merged, alternatives
+}
+
+// definitionsDir returns the data directory, looked for next to the executable
+// first and then in the working directory.
+var definitionsDir = func() (string, error) {
+	paths := lookupPaths()
+	for _, p := range paths {
+		dir := filepath.Join(p, intrgb.DefinitionsDir)
+		if info, err := os.Stat(dir); err == nil && info.IsDir() {
+			return dir, nil
+		}
+	}
+	if len(paths) == 0 {
+		return intrgb.DefinitionsDir, nil
+	}
+	return filepath.Join(paths[0], intrgb.DefinitionsDir), nil
+}
+
+// ensureDefinitionsDir returns the data directory, creating it if it is not
+// there yet, so a fetch has somewhere to write to.
+func ensureDefinitionsDir() (string, error) {
+	dir, err := definitionsDir()
+	if err != nil {
+		return "", err
+	}
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		return "", fmt.Errorf("create %s: %w", dir, err)
+	}
+	return dir, nil
+}

+ 53 - 21
cmd/qmk-rgb-tool/channels_test.go

@@ -10,7 +10,7 @@ import (
 )
 
 func TestResolveZoneNameAcceptsASubsystemName(t *testing.T) {
-	got, err := resolveZoneName("rgb_matrix", map[uint16]string{2: "logo"})
+	got, err := resolveZoneName("rgb_matrix", map[uint16]string{2: "logo"}, nil)
 	if err != nil {
 		t.Fatalf("resolveZoneName() error = %v", err)
 	}
@@ -20,7 +20,7 @@ func TestResolveZoneNameAcceptsASubsystemName(t *testing.T) {
 }
 
 func TestResolveZoneNameAcceptsADisplayName(t *testing.T) {
-	got, err := resolveZoneName("logo", map[uint16]string{2: "logo", 3: "backlight", 4: "side"})
+	got, err := resolveZoneName("logo", map[uint16]string{2: "logo", 3: "backlight", 4: "side"}, nil)
 	if err != nil {
 		t.Fatalf("resolveZoneName() error = %v", err)
 	}
@@ -30,7 +30,7 @@ func TestResolveZoneNameAcceptsADisplayName(t *testing.T) {
 }
 
 func TestResolveZoneNameRejectsAnUnknownName(t *testing.T) {
-	_, err := resolveZoneName("nope", map[uint16]string{2: "logo"})
+	_, err := resolveZoneName("nope", map[uint16]string{2: "logo"}, nil)
 	if err == nil {
 		t.Fatal("resolveZoneName() expected an error, got nil")
 	}
@@ -42,8 +42,8 @@ func TestResolveZoneNameRejectsAnUnknownName(t *testing.T) {
 // A board that supplies no display names keeps the subsystem vocabulary, so the
 // physical names that work on the Impact 80 do not work elsewhere.
 func TestResolveZoneNameIgnoresDisplayNamesForAnotherBoard(t *testing.T) {
-	if _, err := resolveZoneName("logo", nil); err == nil {
-		t.Fatal("resolveZoneName(\"logo\", nil) expected an error, got nil")
+	if _, err := resolveZoneName("logo", nil, nil); err == nil {
+		t.Fatal("resolveZoneName(\"logo\", nil, nil) expected an error, got nil")
 	}
 }
 
@@ -52,7 +52,7 @@ func TestResolveZoneNameIgnoresDisplayNamesForAnotherBoard(t *testing.T) {
 // channel's subsystem name cannot be decided here, because presence is only
 // known after the probe; displayNameConflicts decides that.
 func TestResolveZoneNameReportsAnAmbiguousName(t *testing.T) {
-	_, err := resolveZoneName("backlight", map[uint16]string{2: "logo", 3: "backlight", 4: "backlight"})
+	_, err := resolveZoneName("backlight", map[uint16]string{2: "logo", 3: "backlight", 4: "backlight"}, nil)
 	if err == nil {
 		t.Fatal("resolveZoneName() expected an error for a name two channels answer to")
 	}
@@ -99,34 +99,34 @@ func TestChannelNamePrefersTheDisplayName(t *testing.T) {
 }
 
 // The display names must come from the keyboard the command targets, not from
-// whichever one enumeration returned first.
+// whichever one enumeration returned first. They come from that board's
+// definition file, which is where a channel's name lives now.
 func TestPrepareTargetUsesTheSelectedKeyboard(t *testing.T) {
 	devices := []intdevice.Device{
 		{VendorID: 0x6666, ProductID: 0x0001},
-		{VendorID: 0x36B0, ProductID: 0x309F, Name: "Wobkey Impact 80"},
+		{VendorID: 0x36B0, ProductID: 0x309F, Name: "Impact 80"},
 	}
+	dir := t.TempDir()
+	writeDefinition(t, dir, "rainy75.json", `{
+		"name": "Rainy 75", "vendorId": "0x6666", "productId": "0x0001",
+		"menus": [{"label":"Lighting","content":[{"label":"deck","content":[
+			{"label":"Effect","type":"dropdown","content":["id_qmk_rgblight_effect",2,2],"options":["none"]}]}]}]}`)
+	writeDefinition(t, dir, "impact80.json", `{
+		"name": "Impact 80", "vendorId": "0x36B0", "productId": "0x309F",
+		"menus": [{"label":"Lighting","content":[{"label":"logo","content":[
+			{"label":"Effect","type":"dropdown","content":["id_qmk_rgblight_effect",2,2],"options":["none","wave"]}]}]}]}`)
+	t.Cleanup(forceDefinitionsDir(t, dir))
 
 	originalDiscover := discoverAll
-	originalKeyboardFor := keyboardFor
 	originalTarget := targetDevice
 	originalZone := targetZone
 	t.Cleanup(func() {
 		discoverAll = originalDiscover
-		keyboardFor = originalKeyboardFor
 		targetDevice = originalTarget
 		targetZone = originalZone
 	})
 
 	discoverAll = func() ([]intdevice.Device, error) { return devices, nil }
-	keyboardFor = func(vendorID, productID uint16) (intdevice.Keyboard, bool, error) {
-		switch {
-		case vendorID == 0x6666:
-			return intdevice.Keyboard{Name: "Wobkey Rainy 75", Channels: map[uint16]string{2: "deck"}}, true, nil
-		case vendorID == 0x36B0:
-			return intdevice.Keyboard{Name: "Wobkey Impact 80", Channels: map[uint16]string{2: "logo"}}, true, nil
-		}
-		return intdevice.Keyboard{}, false, nil
-	}
 	targetDevice = "2"
 	targetZone = "logo"
 
@@ -137,6 +137,9 @@ func TestPrepareTargetUsesTheSelectedKeyboard(t *testing.T) {
 	if got.Display[2] != "logo" {
 		t.Errorf("display names = %v, want the Impact 80's", got.Display)
 	}
+	if got.Device.Name != "Impact 80" {
+		t.Errorf("name = %q, want the name the definition gives the board", got.Device.Name)
+	}
 }
 
 // A board renamed after a profile was written leaves keys that resolve to
@@ -196,7 +199,7 @@ func stubTargetForProfileTest(t *testing.T, proto rgbProtocol, zoneFlag string,
 			Device:  intdevice.Device{VendorID: 0x36B0, ProductID: 0x309F},
 			Display: display,
 		}
-		requested, err := resolveZoneName(zoneFlag, display)
+		requested, err := resolveZoneName(zoneFlag, display, nil)
 		if err != nil {
 			return nil, target, nil, err
 		}
@@ -305,7 +308,7 @@ func TestLoadSaysSoWhenTheBoardHasNoCatalog(t *testing.T) {
 }
 
 // stubTargetForUnknownBoard points the commands at a board that has channels but
-// no keyboards.json entry, so it has no catalog.
+// no definition and no compiled-in catalog, so it has no effect names.
 func stubTargetForUnknownBoard(t *testing.T, proto rgbProtocol, dir string) func() {
 	t.Helper()
 
@@ -358,3 +361,32 @@ func TestSaveWarnsThatEffectNamesCannotBeRecorded(t *testing.T) {
 		t.Errorf("stderr = %q, want the missing catalog named", stderr.String())
 	}
 }
+
+// A channel's own definition label and the subsystem name it replaces can both
+// match one name, and that is one channel rather than a conflict: the
+// documented `--zone backlight` has to keep working on a board whose definition
+// calls the channel "Backlight".
+func TestResolveZoneNameAcceptsTheNameADefinitionReplaced(t *testing.T) {
+	display := map[uint16]string{2: "logo", 3: "Backlight", 4: "side"}
+	alternatives := map[uint16][]string{3: {"backlight"}}
+
+	for _, name := range []string{"backlight", "Backlight", "BACKLIGHT"} {
+		got, err := resolveZoneName(name, display, alternatives)
+		if err != nil {
+			t.Errorf("resolveZoneName(%q) error = %v", name, err)
+			continue
+		}
+		if len(got) != 1 || got[0] != 3 {
+			t.Errorf("resolveZoneName(%q) = %v, want [3]", name, got)
+		}
+	}
+}
+
+// Two different channels answering to one name is still a conflict, whichever
+// source the names come from.
+func TestResolveZoneNameStillReportsARealConflict(t *testing.T) {
+	display := map[uint16]string{2: "Backlight", 3: "backlight"}
+	if _, err := resolveZoneName("backlight", display, map[uint16][]string{4: {"backlight"}}); err == nil {
+		t.Error("resolveZoneName() = nil error, want a conflict for one name on two channels")
+	}
+}

+ 1 - 1
cmd/qmk-rgb-tool/color_verify_test.go

@@ -294,7 +294,7 @@ func stubColorTarget(t *testing.T, proto *colorProtocol, zoneFlag string) func()
 
 	openTarget = func() (rgbProtocol, targetDeviceData, []via.Channel, error) {
 		target := targetDeviceData{Display: impact80Display()}
-		requested, err := resolveZoneName(zoneFlag, impact80Display())
+		requested, err := resolveZoneName(zoneFlag, impact80Display(), nil)
 		if err != nil {
 			return nil, target, nil, err
 		}

+ 251 - 0
cmd/qmk-rgb-tool/definition.go

@@ -0,0 +1,251 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/spf13/cobra"
+	intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
+	"netdome.biz/paul/qmk-rgb/internal/via"
+)
+
+// definitionHost serves the definitions VIA ships, one file per board, named by
+// vendor and product ID. The path segment is the definition generation, not the
+// protocol version: a board gets v3 and falls back to v2.
+const definitionHost = "https://www.usevia.app"
+
+// httpGet is a seam so a fetch can be tested without a network. It returns the
+// body and the status code.
+var httpGet = func(url string) ([]byte, int, error) {
+	resp, err := http.Get(url)
+	if err != nil {
+		return nil, 0, err
+	}
+	defer resp.Body.Close()
+	body, err := io.ReadAll(resp.Body)
+	return body, resp.StatusCode, err
+}
+
+func NewDefinitionCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "definition",
+		Short: "Manage keyboard definition files",
+		Long: "A keyboard's effect names come from a VIA definition file, the same one VIA\n" +
+			"itself uses. `fetch` downloads the file for the connected keyboard into the\n" +
+			"data directory; to use a file you already have, put it in that directory or\n" +
+			"pass it with --definition.",
+	}
+	cmd.AddCommand(newDefinitionFetchCmd(), newDefinitionListCmd())
+	return cmd
+}
+
+func newDefinitionFetchCmd() *cobra.Command {
+	return &cobra.Command{
+		Use:   "fetch",
+		Short: "Download the definition file for the connected keyboard",
+		Long: "Identify the connected keyboard, then download its VIA definition into the\n" +
+			"data directory. VIA does not carry a definition for every board, and answers\n" +
+			"an unknown one with its own web page, so a downloaded file is parsed and\n" +
+			"matched against the keyboard before it is stored.",
+		Args: cobra.NoArgs,
+		RunE: runDefinitionFetch,
+	}
+}
+
+func newDefinitionListCmd() *cobra.Command {
+	return &cobra.Command{
+		Use:   "list",
+		Short: "List the definition files in the data directory",
+		Args:  cobra.NoArgs,
+		RunE:  runDefinitionList,
+	}
+}
+
+func runDefinitionFetch(cmd *cobra.Command, args []string) error {
+	target, err := prepareTarget()
+	if err != nil {
+		return err
+	}
+
+	def, err := fetchDefinition(target.Device.VendorID, target.Device.ProductID)
+	if err != nil {
+		return err
+	}
+
+	dir, err := ensureDefinitionsDir()
+	if err != nil {
+		return err
+	}
+	path := filepath.Join(dir, definitionFileName(def))
+	if err := os.WriteFile(path, []byte(def.raw), 0o644); err != nil {
+		return fmt.Errorf("write %s: %w", path, err)
+	}
+
+	fmt.Fprintf(cmd.OutOrStdout(), "Saved definition for %s (0x%04X/0x%04X) to %s\n",
+		def.Definition.Name, def.Definition.VendorID, def.Definition.ProductID, path)
+	for _, ch := range def.channels {
+		fmt.Fprintf(cmd.OutOrStdout(), "  %-10s %d effects\n", ch.Subsystem(), len(def.Definition.Catalog.Effects(ch)))
+	}
+	return nil
+}
+
+func runDefinitionList(cmd *cobra.Command, args []string) error {
+	dir, err := definitionsDir()
+	if err != nil {
+		return err
+	}
+	if _, statErr := os.Stat(dir); os.IsNotExist(statErr) {
+		if jsonOutput {
+			return encodeJSON(cmd.OutOrStdout(), struct {
+				Directory   string           `json:"directory"`
+				Definitions []definitionLine `json:"definitions"`
+			}{Directory: dir, Definitions: []definitionLine{}})
+		}
+		fmt.Fprintf(cmd.OutOrStdout(), "No definition directory yet at %s; run `definition fetch` "+
+			"or put a manufacturer file there\n", dir)
+		return nil
+	}
+	defs, err := intrgb.LoadDefinitionsDir(dir)
+	if err != nil {
+		return err
+	}
+
+	lines := make([]definitionLine, 0, len(defs))
+	for _, def := range defs {
+		line := definitionLine{
+			Name:      def.Name,
+			VendorID:  fmt.Sprintf("0x%04X", def.VendorID),
+			ProductID: fmt.Sprintf("0x%04X", def.ProductID),
+			Path:      def.Path,
+		}
+		for _, ch := range via.LightingChannels {
+			effects := def.Catalog.Effects(ch)
+			if len(effects) == 0 {
+				continue
+			}
+			// The label is what VIA calls the channel, so the name here is the
+			// name the user sees there. The subsystem stays as the fallback and
+			// as the spelling that works everywhere.
+			name := def.Labels[uint16(ch)]
+			if name == "" {
+				name = ch.Subsystem()
+			}
+			line.Channels = append(line.Channels, definitionChannel{
+				Name:      name,
+				Subsystem: ch.Subsystem(),
+				Channel:   uint8(ch),
+				Effects:   len(effects),
+			})
+		}
+		lines = append(lines, line)
+	}
+
+	if jsonOutput {
+		return encodeJSON(cmd.OutOrStdout(), struct {
+			Directory   string           `json:"directory"`
+			Definitions []definitionLine `json:"definitions"`
+		}{Directory: dir, Definitions: lines})
+	}
+
+	if len(lines) == 0 {
+		fmt.Fprintf(cmd.OutOrStdout(), "No definition files in %s\n", dir)
+		return nil
+	}
+	for _, line := range lines {
+		fmt.Fprintf(cmd.OutOrStdout(), "%s (%s/%s) %s\n", line.Name, line.VendorID, line.ProductID, line.Path)
+		for _, ch := range line.Channels {
+			fmt.Fprintf(cmd.OutOrStdout(), "  %-10s %-10s %d effects\n", ch.Name, ch.Subsystem, ch.Effects)
+		}
+	}
+	return nil
+}
+
+// definitionLine is one definition file as the list command reports it. The
+// identifiers are hex strings, the way the files themselves spell them, so the
+// two can be compared by eye.
+type definitionLine struct {
+	Name      string              `json:"name"`
+	VendorID  string              `json:"vendorId"`
+	ProductID string              `json:"productId"`
+	Path      string              `json:"path"`
+	Channels  []definitionChannel `json:"channels"`
+}
+
+// definitionChannel is one lighting channel a definition names effects for.
+type definitionChannel struct {
+	Name      string `json:"name"`
+	Subsystem string `json:"subsystem"`
+	Channel   uint8  `json:"channel"`
+	Effects   int    `json:"effects"`
+}
+
+// fetchedDefinition is a definition together with the bytes it came from, so it
+// can be stored exactly as it was served.
+type fetchedDefinition struct {
+	Definition *intrgb.Definition
+	raw        string
+	channels   []via.Channel
+}
+
+// fetchDefinition downloads the definition for a board. It is separate from the
+// command so the download and the checks can be tested on their own.
+func fetchDefinition(vendorID, productID uint16) (*fetchedDefinition, error) {
+	vpid := intrgb.VendorProductID(vendorID, productID)
+
+	var lastReason string
+	for _, version := range []string{"v3", "v2"} {
+		url := fmt.Sprintf("%s/definitions/%s/%d.json", definitionHost, version, vpid)
+		body, status, err := httpGet(url)
+		if err != nil {
+			lastReason = err.Error()
+			continue
+		}
+		if status != http.StatusOK {
+			lastReason = fmt.Sprintf("HTTP %d", status)
+			continue
+		}
+
+		def, err := intrgb.ParseDefinition(url, body)
+		if err != nil {
+			lastReason = "the server did not return a definition"
+			continue
+		}
+		if !def.Matches(vendorID, productID) {
+			lastReason = fmt.Sprintf("it is a definition for 0x%04X/0x%04X", def.VendorID, def.ProductID)
+			continue
+		}
+		return &fetchedDefinition{
+			Definition: def,
+			raw:        string(body),
+			channels:   via.LightingChannels,
+		}, nil
+	}
+
+	return nil, fmt.Errorf("no definition for this keyboard (0x%04X/0x%04X) at %s: %s; "+
+		"if the manufacturer publishes one, put it in %s or pass --definition",
+		vendorID, productID, definitionHost, lastReason, intrgb.DefinitionsDir)
+}
+
+// definitionFileName names a stored definition after the board, so a directory
+// of them is readable.
+func definitionFileName(def *fetchedDefinition) string {
+	name := def.Definition.Name
+	var b strings.Builder
+	for _, r := range strings.ToLower(name) {
+		switch {
+		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
+			b.WriteRune(r)
+		default:
+			b.WriteRune('_')
+		}
+	}
+	slug := strings.Trim(b.String(), "_")
+	if slug == "" {
+		slug = "keyboard"
+	}
+	return fmt.Sprintf("%s_0x%04X_0x%04X.json", slug, def.Definition.VendorID, def.Definition.ProductID)
+}

+ 283 - 0
cmd/qmk-rgb-tool/definition_test.go

@@ -0,0 +1,283 @@
+package main
+
+import (
+	"encoding/json"
+	"errors"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	intdevice "netdome.biz/paul/qmk-rgb/internal/device"
+)
+
+func TestFetchDefinitionStoresTheFileForTheBoard(t *testing.T) {
+	dir := t.TempDir()
+	t.Cleanup(definitionFlagRestore(t))
+	t.Cleanup(forceDefinitionsDir(t, dir))
+	stubPrepareTarget(t, 0x1234, 0x5678)
+
+	served := `{"name":"Test Board","vendorProductId":305419896,"menus":[]}`
+
+	var requested []string
+	stubHTTPGet(t, func(url string) ([]byte, int, error) {
+		requested = append(requested, url)
+		if strings.Contains(url, "/v3/") {
+			return []byte(served), http.StatusOK, nil
+		}
+		return nil, http.StatusNotFound, errors.New("404")
+	})
+
+	cmd := NewDefinitionCmd()
+	var out, errOut strings.Builder
+	cmd.SetOut(&out)
+	cmd.SetErr(&errOut)
+	cmd.SetArgs([]string{"fetch"})
+
+	if err := cmd.Execute(); err != nil {
+		t.Fatalf("definition fetch error = %v (stderr %q)", err, errOut.String())
+	}
+
+	if len(requested) != 1 || !strings.Contains(requested[0], "/v3/305419896.json") {
+		t.Errorf("requested = %v, want one v3 URL for vendorProductId 305419896", requested)
+	}
+	if !strings.Contains(out.String(), "Test Board") {
+		t.Errorf("stdout = %q, want the board name", out.String())
+	}
+
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 1 || !strings.HasSuffix(entries[0].Name(), ".json") {
+		t.Fatalf("definitions dir = %v, want one JSON file", entries)
+	}
+}
+
+// An unknown board is answered with a web page and a success status, so the
+// fetch must not store it and must say what happened instead.
+func TestFetchDefinitionRejectsAPageThatIsNotADefinition(t *testing.T) {
+	dir := t.TempDir()
+	t.Cleanup(definitionFlagRestore(t))
+	t.Cleanup(forceDefinitionsDir(t, dir))
+	stubPrepareTarget(t, 0x1234, 0x5678)
+
+	stubHTTPGet(t, func(string) ([]byte, int, error) {
+		return []byte("<!doctype html><html><head><title>VIA</title></head></html>"), http.StatusOK, nil
+	})
+
+	cmd := NewDefinitionCmd()
+	var out, errOut strings.Builder
+	cmd.SetOut(&out)
+	cmd.SetErr(&errOut)
+	cmd.SetArgs([]string{"fetch"})
+
+	err := cmd.Execute()
+	if err == nil {
+		t.Fatal("definition fetch = nil error, want a failure for a non-definition")
+	}
+	if !strings.Contains(err.Error(), "no definition") {
+		t.Errorf("error = %q, want it to say there is no definition", err)
+	}
+
+	entries, _ := os.ReadDir(dir)
+	if len(entries) != 0 {
+		t.Errorf("definitions dir = %v, want nothing stored", entries)
+	}
+}
+
+// A definition for another board must not be used, and saying so is the whole
+// point of carrying the identifiers.
+func TestDefinitionFlagRejectsTheWrongBoard(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "other.json")
+	if err := os.WriteFile(path, []byte(`{"name":"Other","vendorId":"0x1111","productId":"0x2222"}`), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(definitionFlagRestore(t))
+	definitionFlag = path
+
+	_, _, err := resolveCatalog(stubTargetData(0x1234, 0x5678))
+	if err == nil {
+		t.Fatal("resolveCatalog() = nil error, want a rejection for another board")
+	}
+	if !strings.Contains(err.Error(), "0x1111") {
+		t.Errorf("error = %q, want it to name the board the file is for", err)
+	}
+}
+
+// A definition in the data directory is used for its board without a flag.
+func TestResolveCatalogPrefersTheFileForTheBoard(t *testing.T) {
+	dir := t.TempDir()
+	if err := os.WriteFile(filepath.Join(dir, "b.json"), []byte(`{
+		"name":"From File","vendorId":"0x1234","productId":"0x5678",
+		"menus":[{"label":"Lighting","content":[{"label":"Backlight","content":[
+			{"label":"Effect","type":"dropdown","content":["id_qmk_rgb_matrix_effect",3,2],
+			 "options":[["Only One",4]]}]}]}]}`), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(definitionFlagRestore(t))
+	t.Cleanup(forceDefinitionsDir(t, dir))
+
+	catalog, source, err := resolveCatalog(stubTargetData(0x1234, 0x5678))
+	if err != nil {
+		t.Fatalf("resolveCatalog() error = %v", err)
+	}
+	if id, ok := catalog.EffectID(3, "Only One"); !ok || id != 4 {
+		t.Errorf("EffectID(rgb_matrix, \"Only One\") = %d, %t, want 4, true", id, ok)
+	}
+	if !strings.Contains(source, "b.json") {
+		t.Errorf("source = %q, want the file it came from", source)
+	}
+}
+
+// The built-in catalog is the last resort, so a board the tool knows about
+// keeps working with no definition file anywhere.
+func TestResolveCatalogFallsBackToTheBuiltInCatalog(t *testing.T) {
+	dir := t.TempDir()
+	t.Cleanup(definitionFlagRestore(t))
+	t.Cleanup(forceDefinitionsDir(t, dir))
+
+	catalog, source, err := resolveCatalog(stubTargetData(0x36B0, 0x309F))
+	if err != nil {
+		t.Fatalf("resolveCatalog() error = %v", err)
+	}
+	if catalog == nil {
+		t.Fatal("resolveCatalog() = nil catalog, want the built-in Impact 80 one")
+	}
+	if !strings.Contains(source, "built in") {
+		t.Errorf("source = %q, want it to say the catalog is built in", source)
+	}
+}
+
+// An unknown board with no file has no catalog at all, which the commands
+// already report rather than guessing.
+func TestResolveCatalogHasNothingForAnUnknownBoard(t *testing.T) {
+	dir := t.TempDir()
+	t.Cleanup(definitionFlagRestore(t))
+	t.Cleanup(forceDefinitionsDir(t, dir))
+
+	catalog, source, err := resolveCatalog(stubTargetData(0x1111, 0x2222))
+	if err != nil {
+		t.Fatalf("resolveCatalog() error = %v", err)
+	}
+	if catalog != nil || source != "" {
+		t.Errorf("resolveCatalog() = %v, %q, want nil, \"\"", catalog, source)
+	}
+}
+
+// definitionFlagRestore resets --definition and returns a cleanup that restores
+// it, so a test can use the flag without leaking it into the next one.
+func definitionFlagRestore(t *testing.T) func() {
+	t.Helper()
+	original := definitionFlag
+	return func() { definitionFlag = original }
+}
+
+// forceDefinitionsDir points the data directory at a test directory. The tool
+// looks for it next to the executable and then in the working directory, so a
+// test that wants its own has to make that lookup find it.
+func forceDefinitionsDir(t *testing.T, dir string) func() {
+	t.Helper()
+	original := definitionsDir
+	definitionsDir = func() (string, error) { return dir, nil }
+	return func() { definitionsDir = original }
+}
+
+// stubHTTPGet answers every request from a function instead of a network.
+func stubHTTPGet(t *testing.T, fn func(url string) ([]byte, int, error)) {
+	t.Helper()
+	original := httpGet
+	httpGet = fn
+	t.Cleanup(func() { httpGet = original })
+}
+
+// stubPrepareTarget makes the connected keyboard a fixed one, so a test does
+// not need hardware to describe a board.
+func stubPrepareTarget(t *testing.T, vendorID, productID uint16) {
+	t.Helper()
+	original := prepareTarget
+	prepareTarget = func() (targetDeviceData, error) { return stubTargetData(vendorID, productID), nil }
+	t.Cleanup(func() { prepareTarget = original })
+}
+
+// stubTargetData is a target for a board, for the catalog lookup tests.
+func stubTargetData(vendorID, productID uint16) targetDeviceData {
+	return targetDeviceData{
+		Device:  intdevice.Device{VendorID: vendorID, ProductID: productID},
+		Display: map[uint16]string{},
+	}
+}
+
+// definition list is structured data, so --json has to reach it like every other
+// listing command; text by default is no excuse for a missing machine shape.
+func TestDefinitionListHonoursTheJSONFlag(t *testing.T) {
+	dir := t.TempDir()
+	if err := os.WriteFile(filepath.Join(dir, "b.json"), []byte(`{"name":"Test Board","vendorId":"0x1234","productId":"0x5678"}`), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(forceDefinitionsDir(t, dir))
+
+	var out, errOut strings.Builder
+	cmd := NewDefinitionCmd()
+	cmd.SetOut(&out)
+	cmd.SetErr(&errOut)
+	cmd.SetArgs([]string{"list"})
+	withJSON(t)
+	if err := cmd.Execute(); err != nil {
+		t.Fatalf("definition list --json error = %v", err)
+	}
+
+	var payload struct {
+		Definitions []struct {
+			Name      string `json:"name"`
+			VendorID  string `json:"vendorId"`
+			ProductID string `json:"productId"`
+			Path      string `json:"path"`
+		} `json:"definitions"`
+	}
+	if err := json.Unmarshal([]byte(out.String()), &payload); err != nil {
+		t.Fatalf("output is not JSON: %v (%q)", err, out.String())
+	}
+	if len(payload.Definitions) != 1 || payload.Definitions[0].Name != "Test Board" {
+		t.Errorf("payload = %+v, want the one definition", payload.Definitions)
+	}
+	if payload.Definitions[0].VendorID != "0x1234" || payload.Definitions[0].ProductID != "0x5678" {
+		t.Errorf("identifiers = %s/%s, want 0x1234/0x5678",
+			payload.Definitions[0].VendorID, payload.Definitions[0].ProductID)
+	}
+}
+
+func TestDefinitionListPrintsTextByDefault(t *testing.T) {
+	dir := t.TempDir()
+	if err := os.WriteFile(filepath.Join(dir, "b.json"), []byte(`{"name":"Test Board","vendorId":"0x1234","productId":"0x5678"}`), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(forceDefinitionsDir(t, dir))
+
+	var out, errOut strings.Builder
+	cmd := NewDefinitionCmd()
+	cmd.SetOut(&out)
+	cmd.SetErr(&errOut)
+	cmd.SetArgs([]string{"list"})
+	if err := cmd.Execute(); err != nil {
+		t.Fatalf("definition list error = %v", err)
+	}
+	var probe any
+	if err := json.Unmarshal([]byte(out.String()), &probe); err == nil {
+		t.Errorf("stdout = %q, want text, not JSON", out.String())
+	}
+	if !strings.Contains(out.String(), "Test Board") {
+		t.Errorf("stdout = %q, want the board name", out.String())
+	}
+}
+
+// writeDefinition puts a definition file into a directory, for the tests that
+// need a board described by one.
+func writeDefinition(t *testing.T, dir, name, content string) {
+	t.Helper()
+	if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
+		t.Fatal(err)
+	}
+}

+ 24 - 7
cmd/qmk-rgb-tool/effect.go

@@ -44,11 +44,25 @@ func listAllEffects(cmd *cobra.Command) error {
 	}
 	defer proto.Close()
 
-	catalog, _ := intrgb.CatalogFor(target.Device.VendorID, target.Device.ProductID)
-	return printEffectList(cmd, catalog, channels, target.Display)
+	catalog, source, err := resolveCatalog(target)
+	if err != nil {
+		return err
+	}
+	return printEffectList(cmd, catalog, channels, target.Display, source)
 }
 
-func printEffectList(cmd *cobra.Command, catalog *intrgb.Catalog, channels []via.Channel, display map[uint16]string) error {
+// printEffectList writes the catalog per channel, as text unless --json asks
+// for the machine shape. The source of the names travels along, because a list
+// without it does not say whether it came from a definition file or from the
+// tool itself.
+func printEffectList(cmd *cobra.Command, catalog *intrgb.Catalog, channels []via.Channel, display map[uint16]string, source string) error {
+	if !jsonOutput {
+		return printEffectListText(cmd.OutOrStdout(), catalog, channels, display, source)
+	}
+	return printEffectListJSON(cmd, catalog, channels, display)
+}
+
+func printEffectListJSON(cmd *cobra.Command, catalog *intrgb.Catalog, channels []via.Channel, display map[uint16]string) error {
 	type ZoneEffectList struct {
 		Zone      string `json:"zone"`
 		Channel   uint8  `json:"channel"`
@@ -63,13 +77,13 @@ func printEffectList(cmd *cobra.Command, catalog *intrgb.Catalog, channels []via
 
 	list := EffectList{Catalog: catalog.Name(), Zones: []ZoneEffectList{}}
 	for _, ch := range channels {
-		for id, name := range catalog.Names(ch) {
+		for _, e := range catalog.Effects(ch) {
 			list.Zones = append(list.Zones, ZoneEffectList{
 				Zone:      channelName(ch, display),
 				Channel:   uint8(ch),
 				Subsystem: ch.Subsystem(),
-				Effect:    name,
-				ID:        uint8(id),
+				Effect:    e.Name,
+				ID:        e.ID,
 			})
 		}
 	}
@@ -84,7 +98,10 @@ func runEffectSet(cmd *cobra.Command, args []string) error {
 	}
 	defer proto.Close()
 
-	catalog, _ := intrgb.CatalogFor(target.Device.VendorID, target.Device.ProductID)
+	catalog, _, err := resolveCatalog(target)
+	if err != nil {
+		return err
+	}
 	targets, skipped, err := resolveEffectTargets(catalog, args[0], channels)
 	if err != nil {
 		return err

+ 1 - 0
cmd/qmk-rgb-tool/effect_list_test.go

@@ -10,6 +10,7 @@ import (
 // later without breaking. `effect --list` returned a bare array because the
 // EffectList wrapper it built was bypassed in favour of its slice.
 func TestEffectListEmitsAnObjectNotABareArray(t *testing.T) {
+	withJSON(t)
 	var out, errOut bytes.Buffer
 	cmd := NewEffectCmd()
 	cmd.SetOut(&out)

+ 4 - 2
cmd/qmk-rgb-tool/effect_verify_test.go

@@ -73,10 +73,12 @@ func TestSetEffectVerifiedMatchesWhenApplied(t *testing.T) {
 
 // An effect the catalog does not name must be reported as unknown rather than
 // dropped, so a set to a raw index the catalog does not cover stays visible.
+// 46 is the board's highest ID and is named, so an index above it is what is
+// left uncatalogued.
 func TestSetEffectVerifiedNamesUncataloguedEffect(t *testing.T) {
-	proto := &verifyingProtocol{applied: map[via.Channel]uint8{via.ChannelRgbMatrix: 46}}
+	proto := &verifyingProtocol{applied: map[via.Channel]uint8{via.ChannelRgbMatrix: 99}}
 
-	targets := []intrgb.EffectTarget{{Channel: via.ChannelRgbMatrix, ID: 46}}
+	targets := []intrgb.EffectTarget{{Channel: via.ChannelRgbMatrix, ID: 99}}
 	results, err := setEffectVerified(proto, targets, impact80Display(), impact80Catalog(t))
 	if err != nil {
 		t.Fatalf("setEffectVerified() error = %v", err)

+ 4 - 2
cmd/qmk-rgb-tool/enable.go

@@ -4,7 +4,6 @@ import (
 	"fmt"
 
 	"github.com/spf13/cobra"
-	intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
 )
 
 func NewEnableCmd() *cobra.Command {
@@ -19,7 +18,10 @@ func NewEnableCmd() *cobra.Command {
 			}
 			defer proto.Close()
 
-			catalog, _ := intrgb.CatalogFor(target.Device.VendorID, target.Device.ProductID)
+			catalog, _, err := resolveCatalog(target)
+			if err != nil {
+				return err
+			}
 			for _, ch := range channels {
 				if _, ok := catalog.DefaultEffect(ch); !ok {
 					return fmt.Errorf("this keyboard has no effect catalog, so `enable` cannot choose an effect; set one with `mode <index>`")

+ 1 - 1
cmd/qmk-rgb-tool/flags_test.go

@@ -70,7 +70,7 @@ func TestDocsDoNotOfferAPathValueForDevice(t *testing.T) {
 }
 
 // The docs must not teach a zone name the resolver rejects. The subsystem
-// names are the vocabulary; a board's display names come from keyboards.json.
+// names are the vocabulary; a board's own channel names come from its definition file.
 func TestDocsDoNotTeachAZoneNameTheResolverRejects(t *testing.T) {
 	for _, path := range []string{
 		"../../README.md",

+ 9 - 3
cmd/qmk-rgb-tool/info.go

@@ -117,9 +117,15 @@ func NewInfoCmd() *cobra.Command {
 			}
 			defer proto.Close()
 
-			catalog, _ := intrgb.CatalogFor(target.Device.VendorID, target.Device.ProductID)
-			out, queryErr := readInfo(proto, channels, target.Display, catalog)
-			if err := encodeJSON(cmd.OutOrStdout(), out); err != nil {
+			catalog, _, err := resolveCatalog(target)
+			if err != nil {
+				return err
+			}
+			state, queryErr := readInfo(proto, channels, target.Display, catalog)
+			if !jsonOutput {
+				return printZoneInfoText(cmd.OutOrStdout(), state)
+			}
+			if err := encodeJSON(cmd.OutOrStdout(), state); err != nil {
 				return err
 			}
 			return queryErr

+ 1 - 0
cmd/qmk-rgb-tool/info_test.go

@@ -188,6 +188,7 @@ func TestInfoErrorRecord(t *testing.T) {
 }
 
 func TestInfoCommandPrintsJSONBeforeReturningError(t *testing.T) {
+	withJSON(t)
 	protocol := &fakeInfoProtocol{
 		values: map[infoKey][]byte{
 			{channel: 2, param: 1}: {120},

+ 7 - 4
cmd/qmk-rgb-tool/keyboard_info_test.go

@@ -11,8 +11,8 @@ import (
 )
 
 type deviceInfoOutput struct {
-	Devices []intdevice.Device `json:"devices"`
-	Total   int                `json:"total"`
+	Devices []keyboardLine `json:"devices"`
+	Total   int            `json:"total"`
 }
 
 func stubDiscovery(t *testing.T, devices []intdevice.Device) {
@@ -41,8 +41,9 @@ func runRealKeyboardInfo(t *testing.T) (stdout, stderr string, err error) {
 }
 
 func TestKeyboardInfoWritesOnlyJSON(t *testing.T) {
+	withJSON(t)
 	stubDiscovery(t, []intdevice.Device{
-		{Index: 1, Path: "/dev/hidraw7", VendorID: 0x36b0, ProductID: 0x309f, Name: "Wobkey Impact 80", Known: true},
+		{Index: 1, Path: "/dev/hidraw7", VendorID: 0x36b0, ProductID: 0x309f, Name: "Impact 80"},
 	})
 
 	stdout, stderr, err := runRealKeyboardInfo(t)
@@ -73,8 +74,9 @@ func TestKeyboardInfoWritesOnlyJSON(t *testing.T) {
 }
 
 func TestKeyboardInfoEmitsIndentedJSON(t *testing.T) {
+	withJSON(t)
 	stubDiscovery(t, []intdevice.Device{
-		{Index: 1, Path: "/dev/hidraw7", Name: "Wobkey Impact 80", Known: true},
+		{Index: 1, Path: "/dev/hidraw7", Name: "Impact 80"},
 	})
 
 	stdout, _, err := runRealKeyboardInfo(t)
@@ -91,6 +93,7 @@ func TestKeyboardInfoEmitsIndentedJSON(t *testing.T) {
 }
 
 func TestKeyboardInfoEmitsEmptyArrayNotNull(t *testing.T) {
+	withJSON(t)
 	stubDiscovery(t, nil)
 
 	stdout, _, err := runRealKeyboardInfo(t)

+ 17 - 7
cmd/qmk-rgb-tool/main.go

@@ -25,7 +25,9 @@ func newRootCommand() *cobra.Command {
 		SilenceUsage:  true,
 	}
 	cmd.PersistentFlags().StringVar(&targetDevice, "device", "", "Keyboard number as printed by 'keyboard info', starting at 1")
-	cmd.PersistentFlags().StringVar(&targetZone, "zone", "", "RGB lighting channel: backlight, rgblight, rgb_matrix, audio or led_matrix, or a name from keyboards.json")
+	cmd.PersistentFlags().StringVar(&targetZone, "zone", "", "RGB lighting channel: backlight, rgblight, rgb_matrix, audio or led_matrix, or the name this keyboard's definition gives it")
+	cmd.PersistentFlags().StringVar(&definitionFlag, "definition", "", "VIA definition file to read effect names from, instead of the one in the data directory")
+	cmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Print JSON instead of text")
 	return cmd
 }
 
@@ -50,9 +52,11 @@ var keyboardCmd = &cobra.Command{
 // discoverAll is a seam for tests.
 var discoverAll = device.DiscoverAll
 
-// runKeyboardInfo writes the connected QMK keyboards as JSON. The 1-based
-// index that --device accepts travels in the "index" field, so the command
-// emits no human-readable banner alongside it.
+// runKeyboardInfo writes the connected QMK keyboards, as text unless --json
+// asks otherwise. The 1-based index that --device accepts is in the "index"
+// field, so the machine shape emits no banner alongside it. Whether a keyboard
+// has effect names is reported per keyboard, because it differs: one board may
+// have a definition and the next may not.
 func runKeyboardInfo(out io.Writer) error {
 	devices, err := discoverAll()
 	if err != nil {
@@ -63,11 +67,16 @@ func runKeyboardInfo(out io.Writer) error {
 		devices = []device.Device{}
 	}
 
+	lines := describeDevices(devices)
+	if !jsonOutput {
+		return printKeyboardInfoText(out, lines)
+	}
+
 	type info struct {
-		Devices []device.Device `json:"devices"`
-		Total   int             `json:"total"`
+		Devices []keyboardLine `json:"devices"`
+		Total   int            `json:"total"`
 	}
-	return encodeJSON(out, info{Devices: devices, Total: len(devices)})
+	return encodeJSON(out, info{Devices: lines, Total: len(lines)})
 }
 
 var keyboardInfoCmd = &cobra.Command{
@@ -97,6 +106,7 @@ func init() {
 	rootCmd.AddCommand(NewProfileLoadCmd())
 	rootCmd.AddCommand(NewProfileListCmd())
 	rootCmd.AddCommand(NewProfileDeleteCmd())
+	rootCmd.AddCommand(NewDefinitionCmd())
 
 	rootCmd.Version = version
 	rootCmd.SetVersionTemplate("	qmk-rgb-tool {{.Version}}\n")

+ 115 - 0
cmd/qmk-rgb-tool/output.go

@@ -0,0 +1,115 @@
+package main
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/spf13/cobra"
+	"netdome.biz/paul/qmk-rgb/internal/device"
+	intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
+	"netdome.biz/paul/qmk-rgb/internal/via"
+)
+
+// The commands below print text, because that is what a person reads, and JSON
+// only when it is asked for. The flag is persistent, so it reads the same
+// everywhere: `qmk-rgb-tool --json info` and `qmk-rgb-tool info --json` are the
+// same command. An agent that parses the output passes it; a person does not
+// have to learn a second shape for the same information.
+var jsonOutput bool
+
+func addJSONFlag(cmd *cobra.Command) {
+	cmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Print JSON instead of text")
+}
+
+// printEffectListText writes one effect per line, its ID in brackets, under a
+// heading per channel. The ID is in the line because it is what a user needs to
+// check a name against the register, and what `mode <index>` takes.
+func printEffectListText(out io.Writer, catalog *intrgb.Catalog, channels []via.Channel, display map[uint16]string, source string) error {
+	for _, ch := range channels {
+		effects := catalog.Effects(ch)
+		if len(effects) == 0 {
+			continue
+		}
+		fmt.Fprintf(out, "%s (%s)\n", channelName(ch, display), ch.Subsystem())
+		for _, e := range effects {
+			fmt.Fprintf(out, "  %s (%d)\n", e.Name, e.ID)
+		}
+	}
+	if source != "" {
+		fmt.Fprintf(out, "\nNames from %s\n", source)
+	}
+	return nil
+}
+
+// printZoneInfoText writes one line per zone, which is what a person compares.
+func printZoneInfoText(out io.Writer, state infoOutput) error {
+	fmt.Fprintf(out, "%s\n", state.Mode)
+	for _, z := range state.Zones {
+		enabled := "off"
+		if z.Enabled {
+			enabled = "on"
+		}
+		fmt.Fprintf(out, "  %-12s %-4s %-28s brightness %3d  speed %3d  color %s\n",
+			z.Zone, enabled, z.Effect, z.Brightness, z.Speed, formatInfoColor(z.Color))
+	}
+	return nil
+}
+
+// formatInfoColor renders a hue and saturation the way the color command takes
+// them, so a value read from the keyboard can be written back unchanged.
+func formatInfoColor(c infoColor) string {
+	return fmt.Sprintf("hsv:%d,%d", c.Hue, c.Saturation)
+}
+
+// printKeyboardInfoText writes one line per keyboard, with the index --device
+// takes, because the index is the only part a user has to act on.
+func printKeyboardInfoText(out io.Writer, devices []keyboardLine) error {
+	if len(devices) == 0 {
+		fmt.Fprintln(out, "No QMK keyboard found.")
+		return nil
+	}
+	for _, d := range devices {
+		known := ""
+		if d.Known {
+			known = " (known model)"
+		}
+		fmt.Fprintf(out, "%d  %s  0x%04X/0x%04X%s\n", d.Index, lineName(d), d.VendorID, d.ProductID, known)
+	}
+	return nil
+}
+
+// keyboardLine is one keyboard as the info command reports it. Its channels are
+// deliberately absent: this command does not open the board, so it cannot know
+// which ones it has, and reporting the ones a definition names would claim more
+// than it knows.
+type keyboardLine struct {
+	Index     int    `json:"index"`
+	Path      string `json:"path"`
+	VendorID  uint16 `json:"vendorId"`
+	ProductID uint16 `json:"productId"`
+	Name      string `json:"name"`
+	Known     bool   `json:"known"`
+}
+
+func lineName(d keyboardLine) string {
+	if d.Name != "" {
+		return d.Name
+	}
+	return "unknown model"
+}
+
+// describeDevices turns discovered devices into what the info command reports.
+func describeDevices(devices []device.Device) []keyboardLine {
+	lines := make([]keyboardLine, 0, len(devices))
+	for _, d := range devices {
+		lines = append(lines, keyboardLine{
+			Index:     d.Index,
+			Path:      d.Path,
+			VendorID:  d.VendorID,
+			ProductID: d.ProductID,
+			Name:      lineName(keyboardLine{Name: d.Name}),
+			Known:     d.Known,
+		})
+	}
+	return lines
+}

+ 105 - 6
cmd/qmk-rgb-tool/profile.go

@@ -20,9 +20,67 @@ var profilesDir = "profiles"
 type Profile struct {
 	Name    string                   `json:"name"`
 	Version int                      `json:"version"`
+	Board   *Board                   `json:"board,omitempty"`
 	Zones   map[string]*ZoneSettings `json:"zones"`
 }
 
+// Board identifies the keyboard a profile was saved from. A profile stores
+// effect names, and names belong to a board, so without this a profile written
+// for one keyboard would be applied to another without a word. The pair is
+// spelled as hex strings, the way the VIA definition files
+// spell it, so the three can be read side by side.
+type Board struct {
+	VendorID  string `json:"vendorId"`
+	ProductID string `json:"productId"`
+}
+
+// boardMatch is what comparing a profile's board to the connected one tells us.
+type boardMatch int
+
+const (
+	// boardUnknown is a profile written before profiles carried a board.
+	boardUnknown boardMatch = iota
+	boardSame
+	boardOther
+)
+
+func boardFor(vendorID, productID uint16) *Board {
+	return &Board{
+		VendorID:  fmt.Sprintf("0x%04X", vendorID),
+		ProductID: fmt.Sprintf("0x%04X", productID),
+	}
+}
+
+// MatchesBoard reports whether a profile was saved from the connected keyboard,
+// was saved from another one, or says nothing about it.
+func (p *Profile) MatchesBoard(vendorID, productID uint16) boardMatch {
+	if p == nil || p.Board == nil {
+		return boardUnknown
+	}
+	if strings.EqualFold(p.Board.VendorID, fmt.Sprintf("0x%04X", vendorID)) &&
+		strings.EqualFold(p.Board.ProductID, fmt.Sprintf("0x%04X", productID)) {
+		return boardSame
+	}
+	return boardOther
+}
+
+// boardMismatchWarning says which profile belongs to which keyboard, because
+// the user has to be able to tell which of their profiles is the wrong one.
+func (p *Profile) boardMismatchWarning(vendorID, productID uint16) string {
+	return fmt.Sprintf("Warning: profile %q was saved for keyboard %s/%s, and this keyboard is %s/%s; "+
+		"its effect names may not exist here\n",
+		p.Name, p.Board.VendorID, p.Board.ProductID,
+		fmt.Sprintf("0x%04X", vendorID), fmt.Sprintf("0x%04X", productID))
+}
+
+// listLine is one profile as the list command prints it.
+func (p *Profile) listLine() string {
+	if p.Board == nil {
+		return p.Name
+	}
+	return fmt.Sprintf("%s  %s/%s", p.Name, p.Board.VendorID, p.Board.ProductID)
+}
+
 type ZoneSettings struct {
 	Enabled    bool   `json:"enabled"`
 	Effect     string `json:"effect"`
@@ -150,7 +208,10 @@ func loadProfileFromDevice(name string, warn io.Writer) error {
 	}
 	defer proto.Close()
 
-	catalog, _ := intrgb.CatalogFor(target.Device.VendorID, target.Device.ProductID)
+	catalog, _, err := resolveCatalog(target)
+	if err != nil {
+		return err
+	}
 	if catalog == nil {
 		// The profile stores effect names and a board without a catalog has none
 		// to store, so every channel is written as "unknown" and cannot be
@@ -162,6 +223,7 @@ func loadProfileFromDevice(name string, warn io.Writer) error {
 	p := &Profile{
 		Name:    name,
 		Version: 1,
+		Board:   boardFor(target.Device.VendorID, target.Device.ProductID),
 	}
 	if err := applyProfileToProfile(proto, channels, target.Display, catalog, p); err != nil {
 		return err
@@ -213,7 +275,18 @@ func NewProfileLoadCmd() *cobra.Command {
 				return err
 			}
 
-			catalog, _ := intrgb.CatalogFor(target.Device.VendorID, target.Device.ProductID)
+			// A profile belongs to the keyboard it was saved from: its effect
+			// names are that board's. Applying it elsewhere is allowed, because
+			// the names that do not exist are reported per key below, but it is
+			// said out loud, since a mismatch is the likeliest reason.
+			if p.MatchesBoard(target.Device.VendorID, target.Device.ProductID) == boardOther {
+				fmt.Fprintln(cmd.ErrOrStderr(), p.boardMismatchWarning(target.Device.VendorID, target.Device.ProductID))
+			}
+
+			catalog, _, err := resolveCatalog(target)
+			if err != nil {
+				return err
+			}
 
 			keys := make([]string, 0, len(p.Zones))
 			for key := range p.Zones {
@@ -254,7 +327,7 @@ func NewProfileLoadCmd() *cobra.Command {
 			applied := 0
 			for _, key := range keys {
 				settings := p.Zones[key]
-				keyChannels, err := resolveZoneName(key, target.Display)
+				keyChannels, err := resolveZoneName(key, target.Display, target.Alternatives)
 				if err != nil {
 					fmt.Fprintf(cmd.ErrOrStderr(),
 						"Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", name, key)
@@ -287,6 +360,16 @@ func NewProfileLoadCmd() *cobra.Command {
 						continue
 					}
 
+					// ResolveEffect can answer with nothing to do and no
+					// error: the name exists on the board but not on this
+					// channel, and no zone was named, so it is skipped. Indexing
+					// its result would crash here instead of saying so.
+					if len(targets) == 0 {
+						fmt.Fprintf(cmd.ErrOrStderr(),
+							"Warning: effect %q not found on %s, skipping\n", settings.Effect, channelName(ch, target.Display))
+						continue
+					}
+
 					if err := proto.SetValue(ch, uint8(intrgb.EffectID), targets[0].ID); err != nil {
 						return err
 					}
@@ -333,10 +416,26 @@ func NewProfileListCmd() *cobra.Command {
 			if names == nil {
 				names = []string{}
 			}
-			type list struct {
-				Profiles []string `json:"profiles"`
+			lines := make([]string, 0, len(names))
+			for _, name := range names {
+				p, err := LoadProfile(name)
+				if err != nil {
+					// A file that cannot be read is still a name in the
+					// directory; the name is what the user can act on.
+					lines = append(lines, name)
+					continue
+				}
+				lines = append(lines, p.listLine())
+			}
+			if jsonOutput {
+				return encodeJSON(cmd.OutOrStdout(), struct {
+					Profiles []string `json:"profiles"`
+				}{Profiles: names})
 			}
-			return encodeJSON(cmd.OutOrStdout(), list{Profiles: names})
+			for _, line := range lines {
+				fmt.Fprintln(cmd.OutOrStdout(), line)
+			}
+			return nil
 		},
 	}
 }

+ 105 - 0
cmd/qmk-rgb-tool/profile_board_test.go

@@ -0,0 +1,105 @@
+package main
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+)
+
+// A profile stores effect names, and names belong to a board, so a profile has
+// to say which board it came from. Otherwise a profile written for one keyboard
+// is applied to another without a word.
+func TestProfileCarriesTheBoardIdentity(t *testing.T) {
+	p := &Profile{Name: "x", Board: &Board{VendorID: "0x36B0", ProductID: "0x309F"}}
+
+	data, err := json.MarshalIndent(p, "", "  ")
+	if err != nil {
+		t.Fatalf("marshal: %v", err)
+	}
+	if !strings.Contains(string(data), `"vendorId": "0x36B0"`) {
+		t.Errorf("profile JSON = %s, want the vendor ID", data)
+	}
+	if !strings.Contains(string(data), `"productId": "0x309F"`) {
+		t.Errorf("profile JSON = %s, want the product ID", data)
+	}
+
+	var back Profile
+	if err := json.Unmarshal(data, &back); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+	if back.Board == nil || back.Board.ProductID != "0x309F" {
+		t.Errorf("round trip = %+v, want the board back", back.Board)
+	}
+}
+
+// A profile written before the field existed must still load, and must not be
+// reported as a mismatch.
+func TestProfileWithoutABoardStillLoads(t *testing.T) {
+	var p Profile
+	if err := json.Unmarshal([]byte(`{"name":"old","version":1,"zones":{}}`), &p); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+	if p.Board != nil {
+		t.Errorf("Board = %+v, want nil for a profile that has none", p.Board)
+	}
+	if got := p.MatchesBoard(0x36B0, 0x309F); got != boardUnknown {
+		t.Errorf("MatchesBoard() = %v, want %v for a profile with no board", got, boardUnknown)
+	}
+}
+
+func TestProfileBoardComparison(t *testing.T) {
+	same := &Profile{Board: &Board{VendorID: "0x36B0", ProductID: "0x309F"}}
+	other := &Profile{Board: &Board{VendorID: "0x1111", ProductID: "0x2222"}}
+
+	tests := []struct {
+		name             string
+		profile          *Profile
+		vendorID, prodID uint16
+		want             boardMatch
+	}{
+		{"same board", same, 0x36B0, 0x309F, boardSame},
+		{"other board", other, 0x36B0, 0x309F, boardOther},
+		{"no board", &Profile{}, 0x36B0, 0x309F, boardUnknown},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := tt.profile.MatchesBoard(tt.vendorID, tt.prodID); got != tt.want {
+				t.Errorf("MatchesBoard() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+// The warning has to name both keyboards, or the user cannot tell which
+// profile is the wrong one.
+func TestBoardMismatchWarningNamesBothKeyboards(t *testing.T) {
+	p := &Profile{
+		Name:  "paul",
+		Board: &Board{VendorID: "0x1111", ProductID: "0x2222"},
+	}
+	msg := p.boardMismatchWarning(0x36B0, 0x309F)
+	for _, want := range []string{"paul", "0x1111/0x2222", "0x36B0/0x309F"} {
+		if !strings.Contains(msg, want) {
+			t.Errorf("warning = %q, want it to name %q", msg, want)
+		}
+	}
+}
+
+func TestBoardIdentityIsShownNextToTheName(t *testing.T) {
+	p := &Profile{Name: "paul", Board: &Board{VendorID: "0x36B0", ProductID: "0x309F"}}
+	line := p.listLine()
+	if !strings.HasPrefix(line, "paul") {
+		t.Errorf("listLine() = %q, want it to start with the name", line)
+	}
+	if !strings.Contains(line, "0x36B0/0x309F") {
+		t.Errorf("listLine() = %q, want the board", line)
+	}
+}
+
+// A profile with no board must not claim to belong to one.
+func TestListLineWithoutABoardSaysNothingAboutIt(t *testing.T) {
+	line := (&Profile{Name: "old"}).listLine()
+	if strings.Contains(line, "0x") {
+		t.Errorf("listLine() = %q, want no board for a profile that has none", line)
+	}
+}

+ 3 - 0
cmd/qmk-rgb-tool/profile_list_test.go

@@ -42,6 +42,7 @@ func runProfileList(t *testing.T) string {
 // README.md advertises JSON for `list`. The shape must not change with the
 // number of profiles on disk.
 func TestProfileListEmitsJSONWithProfiles(t *testing.T) {
+	withJSON(t)
 	withProfilesDir(t, "desk", "movie")
 
 	got := runProfileList(t)
@@ -56,6 +57,7 @@ func TestProfileListEmitsJSONWithProfiles(t *testing.T) {
 }
 
 func TestProfileListEmitsJSONWithoutProfiles(t *testing.T) {
+	withJSON(t)
 	withProfilesDir(t)
 
 	got := runProfileList(t)
@@ -70,6 +72,7 @@ func TestProfileListEmitsJSONWithoutProfiles(t *testing.T) {
 }
 
 func TestProfileListUsesTheSharedJSONShape(t *testing.T) {
+	withJSON(t)
 	withProfilesDir(t, "desk")
 
 	got := runProfileList(t)

+ 17 - 3
cmd/qmk-rgb-tool/rgb.go

@@ -29,6 +29,9 @@ var keyboardFor = intdevice.KeyboardFor
 type targetDeviceData struct {
 	Device  intdevice.Device
 	Display map[uint16]string
+	// Alternatives are the names a channel also answers to besides the one its
+	// definition gives it, so a name the user already knows keeps working.
+	Alternatives map[uint16][]string
 	// Requested is nil when no --zone was given, which means every channel
 	// the keyboard has.
 	Requested []via.Channel
@@ -37,7 +40,7 @@ type targetDeviceData struct {
 // prepareTarget resolves the keyboard, its display names and the requested
 // channels. Enumeration does not open a HID handle, so an unusable --zone value
 // is still rejected before the device is opened.
-func prepareTarget() (targetDeviceData, error) {
+var prepareTarget = func() (targetDeviceData, error) {
 	devices, err := discoverAll()
 	if err != nil {
 		return targetDeviceData{}, fmt.Errorf("discover: %w", err)
@@ -53,12 +56,23 @@ func prepareTarget() (targetDeviceData, error) {
 		return targetDeviceData{}, err
 	}
 
-	requested, err := resolveZoneName(targetZone, keyboard.Channels)
+	// The definition's own names for the channels win, because they are the names
+	// VIA shows, and keyboards.json keeps its names as alternatives so a command
+	// written before the definition arrived still resolves.
+	display, alternatives := applyDefinitionLabels(keyboard.Channels, dev.VendorID, dev.ProductID)
+	keyboard.Channels = display
+
+	requested, err := resolveZoneName(targetZone, display, alternatives)
 	if err != nil {
 		return targetDeviceData{}, err
 	}
 
-	return targetDeviceData{Device: dev, Display: keyboard.Channels, Requested: requested}, nil
+	return targetDeviceData{
+		Device:       dev,
+		Display:      keyboard.Channels,
+		Alternatives: alternatives,
+		Requested:    requested,
+	}, nil
 }
 
 func forEachChannel(channels []via.Channel, fn func(via.Channel) error) error {

+ 0 - 5
cmd/qmk-rgb-tool/rgb_test.go

@@ -39,11 +39,6 @@ func stubOneImpact80(t *testing.T) {
 
 	stubDiscovery(t, []intdevice.Device{{Index: 1, Path: "hid-device-0", VendorID: 0x36B0, ProductID: 0x309F}})
 
-	originalKeyboard := keyboardFor
-	t.Cleanup(func() { keyboardFor = originalKeyboard })
-	keyboardFor = func(uint16, uint16) (intdevice.Keyboard, bool, error) {
-		return intdevice.Keyboard{Channels: impact80Display()}, true, nil
-	}
 }
 
 func TestDeviceFlagIsInheritedByCommands(t *testing.T) {

+ 2 - 2
cmd/qmk-rgb-tool/select_test.go

@@ -9,8 +9,8 @@ import (
 
 func testDevices() []intdevice.Device {
 	return []intdevice.Device{
-		{Index: 1, Path: "/dev/hidraw7", VendorID: 0x36b0, ProductID: 0x309f, Name: "Wobkey Impact 80", Known: true},
-		{Index: 2, Path: "/dev/hidraw9", VendorID: 0x1234, ProductID: 0x5678, Name: "Unknown QMK keyboard", Known: false},
+		{Index: 1, Path: "/dev/hidraw7", VendorID: 0x36b0, ProductID: 0x309f, Name: "Wobkey Impact 80"},
+		{Index: 2, Path: "/dev/hidraw9", VendorID: 0x1234, ProductID: 0x5678, Name: "Unknown QMK keyboard"},
 	}
 }
 

+ 10 - 1
cmd/qmk-rgb-tool/target_seam_test.go

@@ -18,7 +18,7 @@ func stubOpenTarget(t *testing.T, proto rgbProtocol, display map[uint16]string,
 			Device:  intdevice.Device{VendorID: vendorID, ProductID: productID},
 			Display: display,
 		}
-		requested, err := resolveZoneName(targetZone, display)
+		requested, err := resolveZoneName(targetZone, display, nil)
 		if err != nil {
 			return nil, target, nil, err
 		}
@@ -38,3 +38,12 @@ func impact80Target(t *testing.T, proto rgbProtocol) func() {
 	t.Helper()
 	return stubOpenTarget(t, proto, impact80Display(), impact80Channels(), 0x36B0, 0x309F)
 }
+
+// withJSON turns on the machine shape for a test that asserts the JSON
+// commands, which print text unless it is asked for.
+func withJSON(t *testing.T) {
+	t.Helper()
+	original := jsonOutput
+	jsonOutput = true
+	t.Cleanup(func() { jsonOutput = original })
+}

+ 43 - 7
cmd/qmk-rgb-tool/zones.go

@@ -3,6 +3,7 @@ package main
 import (
 	"fmt"
 	"sort"
+	"strings"
 
 	"netdome.biz/paul/qmk-rgb/internal/via"
 )
@@ -13,13 +14,13 @@ import (
 // than resolved to one of them.
 //
 // An empty name means every channel and is reported as a nil slice.
-func resolveZoneName(name string, display map[uint16]string) ([]via.Channel, error) {
+func resolveZoneName(name string, display map[uint16]string, alternatives map[uint16][]string) ([]via.Channel, error) {
 	if name == "" {
 		return nil, nil
 	}
 
-	if channels := displayNameChannels(display, name); len(channels) > 1 {
-		return nil, fmt.Errorf("zone %q matches several channels of this keyboard; keyboards.json gives the same name to more than one", name)
+	if channels := dedupeChannels(append(displayNameChannels(display, name), alternateNameChannels(alternatives, name)...)); len(channels) > 1 {
+		return nil, fmt.Errorf("zone %q matches several channels of this keyboard; its definition gives the same name to more than one", name)
 	} else if len(channels) == 1 {
 		return channels, nil
 	}
@@ -30,14 +31,16 @@ func resolveZoneName(name string, display map[uint16]string) ([]via.Channel, err
 		}
 	}
 
-	return nil, fmt.Errorf("unknown zone %q: use a channel name such as rgb_matrix, or a name from keyboards.json", name)
+	return nil, fmt.Errorf("unknown zone %q: use a channel name such as rgb_matrix, or the name this keyboard's definition gives it", name)
 }
 
-// displayNameChannels returns every channel the board names displayName.
+// displayNameChannels returns every channel the board names displayName. The
+// comparison ignores case, because a definition writes "Backlight" where a user
+// types "backlight", and the two mean the same channel.
 func displayNameChannels(display map[uint16]string, displayName string) []via.Channel {
 	var channels []via.Channel
 	for number, name := range display {
-		if name == displayName {
+		if strings.EqualFold(name, displayName) {
 			channels = append(channels, via.Channel(number))
 		}
 	}
@@ -45,6 +48,23 @@ func displayNameChannels(display map[uint16]string, displayName string) []via.Ch
 	return channels
 }
 
+// alternateNameChannels returns the channels a name reaches through the
+// alternatives a channel carries, which is how a QMK subsystem name keeps
+// working for a board whose definition calls that channel something else.
+func alternateNameChannels(alternatives map[uint16][]string, displayName string) []via.Channel {
+	var channels []via.Channel
+	for number, names := range alternatives {
+		for _, name := range names {
+			if strings.EqualFold(name, displayName) {
+				channels = append(channels, via.Channel(number))
+				break
+			}
+		}
+	}
+	sort.Slice(channels, func(i, j int) bool { return channels[i] < channels[j] })
+	return channels
+}
+
 // displayNameConflicts rejects a board whose display name is also the subsystem
 // name of a different channel the keyboard actually has. A name that means two
 // channels is a silent retarget, so the tool refuses it.
@@ -67,7 +87,7 @@ func displayNameConflicts(display map[uint16]string, present []via.Channel) erro
 		}
 		for _, other := range present {
 			if other != named && other.Subsystem() == name {
-				return fmt.Errorf("keyboards.json: channel %d is named %q, which is also the subsystem name of channel %d", named, name, other)
+				return fmt.Errorf("channel %d is named %q, which is also the subsystem name of channel %d", named, name, other)
 			}
 		}
 	}
@@ -82,3 +102,19 @@ func channelName(c via.Channel, display map[uint16]string) string {
 	}
 	return c.Subsystem()
 }
+
+// dedupeChannels drops repeats, keeping the order. A name can reach the same
+// channel twice — once as the definition's label and once as the subsystem name
+// it replaces — and that is one channel, not a conflict.
+func dedupeChannels(channels []via.Channel) []via.Channel {
+	seen := make(map[via.Channel]bool, len(channels))
+	out := make([]via.Channel, 0, len(channels))
+	for _, ch := range channels {
+		if seen[ch] {
+			continue
+		}
+		seen[ch] = true
+		out = append(out, ch)
+	}
+	return out
+}

+ 12 - 197
internal/device/device_test.go

@@ -2,8 +2,6 @@ package device
 
 import (
 	"encoding/json"
-	"os"
-	"path/filepath"
 	"strings"
 	"testing"
 )
@@ -84,8 +82,7 @@ func TestDeviceJSONFieldNames(t *testing.T) {
 		Path:      "/dev/hidraw7",
 		VendorID:  0x36b0,
 		ProductID: 0x309f,
-		Name:      "Wobkey Impact 80",
-		Known:     true,
+		Name:      "Impact 80",
 	}
 
 	data, err := json.Marshal(dev)
@@ -98,7 +95,7 @@ func TestDeviceJSONFieldNames(t *testing.T) {
 		t.Fatalf("Unmarshal() returned error: %v", err)
 	}
 
-	for _, field := range []string{"index", "path", "vendorId", "productId", "name", "known"} {
+	for _, field := range []string{"index", "path", "vendorId", "productId", "name"} {
 		if _, ok := got[field]; !ok {
 			t.Errorf("Device JSON = %s, missing field %q", data, field)
 		}
@@ -109,206 +106,24 @@ func TestDeviceJSONFieldNames(t *testing.T) {
 	}
 }
 
+// A keyboard that reports no USB product string has no name, and the field is
+// omitted rather than sent empty: a consumer can tell "no name" from a name.
 func TestDeviceJSONOmitsUnnamedName(t *testing.T) {
-	data, err := json.Marshal(Device{Index: 2, Path: "/dev/hidraw9", Known: false})
+	data, err := json.Marshal(Device{Index: 2, Path: "/dev/hidraw9"})
 	if err != nil {
 		t.Fatalf("Marshal() returned error: %v", err)
 	}
 
 	if strings.Contains(string(data), `"name"`) {
-		t.Errorf("Device JSON = %s, want the name omitted for an unknown keyboard", data)
+		t.Errorf("Device JSON = %s, want the name omitted for a keyboard that reports none", data)
 	}
 }
 
-func TestKeyboardIgnoresUnknownFields(t *testing.T) {
-	tmpDir := t.TempDir()
-	tmpFile := filepath.Join(tmpDir, "keyboards.json")
-
-	// Entries may still carry fields the tool does not use; loading must
-	// succeed and yield only the fields the code actually reads.
-	testData := `[
-		{"name":"Impact 80","vendorId":14000,"productId":12447,
-		 "protocol":"via","viaVersion":3,"ledLayout":"rgblight","futureField":"ignored"}
-	]`
-
-	if err := os.WriteFile(tmpFile, []byte(testData), 0644); err != nil {
-		t.Fatalf("failed to write test file: %v", err)
-	}
-
-	origFind := findKeyboardsJSON
-	findKeyboardsJSON = func() (string, error) { return tmpFile, nil }
-	defer func() { findKeyboardsJSON = origFind }()
-
-	keyboards, err := LoadKeyboards()
-	if err != nil {
-		t.Fatalf("LoadKeyboards() returned error: %v", err)
-	}
-	if len(keyboards) != 1 {
-		t.Fatalf("LoadKeyboards() returned %d keyboards, want 1", len(keyboards))
-	}
-	if keyboards[0].Name != "Impact 80" {
-		t.Errorf("Name = %q, want %q", keyboards[0].Name, "Impact 80")
-	}
-}
-
-func TestKeyboardJSONOmitsRemovedFields(t *testing.T) {
-	data, err := json.Marshal(Keyboard{Name: "Impact 80", VendorID: 0x36b0, ProductID: 0x309f})
-	if err != nil {
-		t.Fatalf("Marshal() returned error: %v", err)
-	}
-
-	for _, dead := range []string{"protocol", "viaVersion", "ledLayout"} {
-		if strings.Contains(string(data), dead) {
-			t.Errorf("Keyboard JSON = %s, want no %q field", data, dead)
-		}
-	}
-}
-
-func TestLoadKeyboards(t *testing.T) {
-	tmpDir := t.TempDir()
-	tmpFile := filepath.Join(tmpDir, "keyboards.json")
-
-	testData := `[
-		{"name": "Test Keyboard 1", "vendorId": 22222, "productId": 1, "protocol": "via"},
-		{"name": "Test Keyboard 2", "vendorId": 33333, "productId": 2, "protocol": "viapro"}
-	]`
-
-	if err := os.WriteFile(tmpFile, []byte(testData), 0644); err != nil {
-		t.Fatalf("failed to write test file: %v", err)
-	}
-
-	origFind := findKeyboardsJSON
-	findKeyboardsJSON = func() (string, error) {
-		return tmpFile, nil
-	}
-	defer func() { findKeyboardsJSON = origFind }()
-
-	keyboards, err := LoadKeyboards()
-	if err != nil {
-		t.Fatalf("LoadKeyboards() returned error: %v", err)
-	}
-
-	if len(keyboards) != 2 {
-		t.Errorf("LoadKeyboards() returned %d keyboards, want 2", len(keyboards))
-	}
-
-	if keyboards[0].Name != "Test Keyboard 1" {
-		t.Errorf("First keyboard name = %q, want %q", keyboards[0].Name, "Test Keyboard 1")
-	}
-
-	if keyboards[0].VendorID != 22222 {
-		t.Errorf("First keyboard VID = %d, want %d", keyboards[0].VendorID, 22222)
-	}
-
-	if keyboards[1].ProductID != 2 {
-		t.Errorf("Second keyboard PID = %d, want %d", keyboards[1].ProductID, 2)
-	}
-}
-
-func TestLoadKeyboardsInvalid(t *testing.T) {
-	tmpDir := t.TempDir()
-	tmpFile := filepath.Join(tmpDir, "keyboards.json")
-
-	invalidData := `[this is not valid json`
-
-	if err := os.WriteFile(tmpFile, []byte(invalidData), 0644); err != nil {
-		t.Fatalf("failed to write test file: %v", err)
-	}
-
-	origFind := findKeyboardsJSON
-	findKeyboardsJSON = func() (string, error) {
-		return tmpFile, nil
-	}
-	defer func() { findKeyboardsJSON = origFind }()
-
-	_, err := LoadKeyboards()
-	if err == nil {
-		t.Error("LoadKeyboards() expected error for invalid JSON, got nil")
-	}
-}
-
-func TestLoadKeyboardsEmptyFile(t *testing.T) {
-	tmpDir := t.TempDir()
-	tmpFile := filepath.Join(tmpDir, "keyboards.json")
-
-	if err := os.WriteFile(tmpFile, []byte("[]"), 0644); err != nil {
-		t.Fatalf("failed to write test file: %v", err)
-	}
-
-	origFind := findKeyboardsJSON
-	findKeyboardsJSON = func() (string, error) {
-		return tmpFile, nil
-	}
-	defer func() { findKeyboardsJSON = origFind }()
-
-	keyboards, err := LoadKeyboards()
-	if err != nil {
-		t.Fatalf("LoadKeyboards() returned error: %v", err)
-	}
-
-	if len(keyboards) != 0 {
-		t.Errorf("LoadKeyboards() returned %d keyboards, want 0", len(keyboards))
-	}
-}
-
-func writeTempKeyboards(t *testing.T, content string) string {
-	t.Helper()
-	path := filepath.Join(t.TempDir(), "keyboards.json")
-	if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
-		t.Fatalf("write keyboards.json: %v", err)
-	}
-	return path
-}
-
-func TestLoadKeyboardsReadsChannelDisplayNames(t *testing.T) {
-	original := findKeyboardsJSON
-	t.Cleanup(func() { findKeyboardsJSON = original })
-	findKeyboardsJSON = func() (string, error) {
-		return writeTempKeyboards(t, `[
-			{"name": "Wobkey Impact 80", "vendorId": 14000, "productId": 12447,
-			 "channels": {"2": "logo", "3": "backlight", "4": "side"}}
-		]`), nil
-	}
-
-	keyboards, err := LoadKeyboards()
-	if err != nil {
-		t.Fatalf("LoadKeyboards() error = %v", err)
-	}
-	if len(keyboards) != 1 {
-		t.Fatalf("keyboards = %d, want 1", len(keyboards))
-	}
-	got := keyboards[0].Channels
-	if got[2] != "logo" || got[3] != "backlight" || got[4] != "side" {
-		t.Errorf("Channels = %v, want 2:logo 3:backlight 4:side", got)
-	}
-}
-
-func TestLoadKeyboardsAcceptsAnEntryWithoutChannels(t *testing.T) {
-	original := findKeyboardsJSON
-	t.Cleanup(func() { findKeyboardsJSON = original })
-	findKeyboardsJSON = func() (string, error) {
-		return writeTempKeyboards(t, `[{"name": "Wobkey Rainy 75", "vendorId": 26214, "productId": 1}]`), nil
-	}
-
-	keyboards, err := LoadKeyboards()
-	if err != nil {
-		t.Fatalf("LoadKeyboards() error = %v", err)
-	}
-	if len(keyboards[0].Channels) != 0 {
-		t.Errorf("Channels = %v, want empty", keyboards[0].Channels)
-	}
-}
-
-// README.md promises that a missing or broken keyboards.json costs names, not
-// the ability to drive the keyboard.
-func TestKeyboardForReportsAbsentRatherThanFailing(t *testing.T) {
-	original := findKeyboardsJSON
-	t.Cleanup(func() { findKeyboardsJSON = original })
-	findKeyboardsJSON = func() (string, error) { return "does-not-exist.json", nil }
-
-	if _, known, err := KeyboardFor(0x6666, 0x0001); err != nil {
-		t.Errorf("KeyboardFor() error = %v, want nil for a missing file", err)
-	} else if known {
-		t.Error("KeyboardFor() = known, want unknown for a missing file")
+// The name is the keyboard's own, from the USB product string, so a data file
+// cannot go stale and disagree with the hardware.
+func TestDeviceTakesTheNameFromTheProductString(t *testing.T) {
+	d := Device{Index: 1, Path: "p", VendorID: 0x36B0, ProductID: 0x309F, Name: "Impact 80"}
+	if d.Name != "Impact 80" {
+		t.Errorf("Name = %q, want the product string the keyboard reports", d.Name)
 	}
 }

+ 131 - 43
internal/rgb/catalog.go

@@ -2,6 +2,7 @@ package rgb
 
 import (
 	"fmt"
+	"strings"
 
 	"netdome.biz/paul/qmk-rgb/internal/via"
 )
@@ -14,16 +15,77 @@ type EffectTarget struct {
 
 const unknownEffectName = "unknown"
 
+// Effect is one named effect on a channel: the ID the keyboard uses and the name
+// a definition gives it. The pair is explicit because a VIA definition may
+// attach a number to an option that is not its position in the list, so a
+// board's effect ID is not an index into its names.
+type Effect struct {
+	ID   uint8
+	Name string
+}
+
 // Catalog is one board's effect names, per channel. The keyboard holds numbers,
 // not names, so `effect <name>` needs a catalog and a board without one is
 // driven through raw IDs.
 type Catalog struct {
 	board    string
-	names    map[via.Channel][]string
+	names    map[via.Channel][]Effect
 	aliases  map[via.Channel]map[string]string
 	defaults map[via.Channel]uint8
 }
 
+// dense turns a name list whose index is the effect ID into effect entries. It
+// is how a hand-written list becomes a catalog; a definition carries its IDs
+// itself and does not go through here.
+func dense(names []string) []Effect {
+	effects := make([]Effect, 0, len(names))
+	for id, name := range names {
+		effects = append(effects, Effect{ID: uint8(id), Name: name})
+	}
+	return effects
+}
+
+// NewCatalog returns a catalog for a board described by effect entries, as a VIA
+// definition file provides them. The board name is what `effect --list` reports;
+// the entries may leave gaps, because a definition names only the effects a
+// board's firmware implements.
+//
+// The tool's aliases come with it, because they are a spelling convenience and
+// not board knowledge: a definition writes the manufacturer's spelling, and the
+// user should not have to know which one it chose.
+func NewCatalog(board string, effects map[via.Channel][]Effect) *Catalog {
+	return &Catalog{board: board, names: effects, aliases: defaultAliases}
+}
+
+// defaultAliases are the spellings every catalog accepts, whatever the source of
+// its names. They resolve per channel, so `rainbow` is the vendor's `spectrum`
+// where that is the name and the catalog's own on another channel.
+var defaultAliases = map[via.Channel]map[string]string{
+	via.ChannelRgblight: {
+		"off":          "none",
+		"breathe":      "breathing",
+		"rainbow":      "spectrum",
+		"rainbow_wave": "wave",
+		"solid":        "light",
+		"static":       "solid",
+	},
+	via.ChannelRgbMatrix: {
+		"off":     "none",
+		"breathe": "breathing",
+		"rainbow": "rainbow_moving_chevron",
+		"solid":   "solid_color",
+		"static":  "solid",
+	},
+	via.ChannelAudio: {
+		"off":          "none",
+		"breathe":      "breathing",
+		"rainbow":      "spectrum",
+		"rainbow_wave": "wave",
+		"solid":        "light",
+		"static":       "solid",
+	},
+}
+
 // CatalogFor returns the effect catalog of a board, and whether one exists.
 //
 // Provenance, because these lists look invented and are not:
@@ -39,6 +101,10 @@ type Catalog struct {
 //     fixed_wave and breathing and accepts the vendor's spellings as aliases.
 //     The keyboard takes these IDs only from 1 to 6: ID 0 means "lighting off"
 //     and leaves the mode register untouched.
+//   - The 47th backlight name, "freeze" at ID 46, is the tool's own. The
+//     vendor's JSON stops at 45 and names no such behaviour; the name and the
+//     behaviour behind it were measured on an Impact 80 and are recorded in
+//     impact80.go. It is a tool name, not a name the board uses.
 //
 // Both lists come from the VIA JSON the vendor's Driver & Firmware page links at
 // https://wiki.wobkey.com/en/Products/PMOKEY-Impact-80/Driver-Firmware
@@ -54,36 +120,12 @@ func CatalogFor(vendorID, productID uint16) (*Catalog, bool) {
 	}
 	return &Catalog{
 		board: "impact80",
-		names: map[via.Channel][]string{
-			via.ChannelRgblight:  impact80LogoEffects[:],
-			via.ChannelRgbMatrix: impact80BacklightEffects[:],
-			via.ChannelAudio:     impact80SideEffects[:],
-		},
-		aliases: map[via.Channel]map[string]string{
-			via.ChannelRgblight: {
-				"off":          "none",
-				"breathe":      "breathing",
-				"rainbow":      "spectrum",
-				"rainbow_wave": "wave",
-				"solid":        "light",
-				"static":       "solid",
-			},
-			via.ChannelRgbMatrix: {
-				"off":     "none",
-				"breathe": "breathing",
-				"rainbow": "rainbow_moving_chevron",
-				"solid":   "solid_color",
-				"static":  "solid",
-			},
-			via.ChannelAudio: {
-				"off":          "none",
-				"breathe":      "breathing",
-				"rainbow":      "spectrum",
-				"rainbow_wave": "wave",
-				"solid":        "light",
-				"static":       "solid",
-			},
+		names: map[via.Channel][]Effect{
+			via.ChannelRgblight:  dense(impact80LogoEffects[:]),
+			via.ChannelRgbMatrix: dense(impact80BacklightEffects[:]),
+			via.ChannelAudio:     dense(impact80SideEffects[:]),
 		},
+		aliases: defaultAliases,
 		defaults: map[via.Channel]uint8{
 			via.ChannelRgblight:  4,
 			via.ChannelRgbMatrix: 5,
@@ -100,26 +142,41 @@ func (c *Catalog) Name() string {
 	return c.board
 }
 
-// Names returns the effect names of a channel, or nil when the catalog says
+// Effects returns a channel's named effects, or nil when the catalog says
 // nothing about it.
-func (c *Catalog) Names(ch via.Channel) []string {
+func (c *Catalog) Effects(ch via.Channel) []Effect {
 	if c == nil {
 		return nil
 	}
 	return c.names[ch]
 }
 
+// Names returns the effect names of a channel in effect-ID order, or nil when
+// the catalog says nothing about it.
+func (c *Catalog) Names(ch via.Channel) []string {
+	effects := c.Effects(ch)
+	if effects == nil {
+		return nil
+	}
+	names := make([]string, 0, len(effects))
+	for _, e := range effects {
+		names = append(names, e.Name)
+	}
+	return names
+}
+
 // EffectName returns the name of an effect ID, or "unknown" when the catalog has
 // no entry for it.
 func (c *Catalog) EffectName(ch via.Channel, id uint8) string {
 	if c == nil {
 		return unknownEffectName
 	}
-	names := c.names[ch]
-	if int(id) >= len(names) {
-		return unknownEffectName
+	for _, e := range c.names[ch] {
+		if e.ID == id {
+			return e.Name
+		}
 	}
-	return names[id]
+	return unknownEffectName
 }
 
 // EffectID resolves an effect name on a channel, following one level of alias.
@@ -127,15 +184,46 @@ func (c *Catalog) EffectID(ch via.Channel, name string) (uint8, bool) {
 	if c == nil {
 		return 0, false
 	}
-	for id, candidate := range c.names[ch] {
-		if candidate == name {
-			return uint8(id), true
-		}
+	if id, found := c.byName(ch, name); found {
+		return id, true
 	}
 	if canonical, ok := c.aliases[ch][name]; ok {
-		for id, candidate := range c.names[ch] {
-			if candidate == canonical {
-				return uint8(id), true
+		if id, found := c.EffectID(ch, canonical); found {
+			return id, true
+		}
+	}
+	// A definition may carry the other spelling of the same pair: the tool's
+	// alias table says "breathe" for "breathing", and a manufacturer's file
+	// may use either. So a name that is the target of an alias resolves to
+	// that alias where the channel has it.
+	for alias, canonical := range c.aliases[ch] {
+		if canonical != name {
+			continue
+		}
+		if id, found := c.byName(ch, alias); found {
+			return id, true
+		}
+	}
+	return 0, false
+}
+
+// byName finds an effect by its exact name, without following an alias, then by
+// the same name with spaces written as underscores. A definition carries display
+// spellings where the tool carries identifiers, and the difference is
+// whitespace, not a different effect: "fixed wave" and "fixed_wave" name the
+// same thing, and the tool's documented spelling has to keep working while a
+// definition file is present.
+func (c *Catalog) byName(ch via.Channel, name string) (uint8, bool) {
+	for _, e := range c.names[ch] {
+		if e.Name == name {
+			return e.ID, true
+		}
+	}
+	spaced := strings.ReplaceAll(name, "_", " ")
+	if spaced != name {
+		for _, e := range c.names[ch] {
+			if e.Name == spaced {
+				return e.ID, true
 			}
 		}
 	}

+ 41 - 2
internal/rgb/catalog_test.go

@@ -19,8 +19,10 @@ func impact80(t *testing.T) *Catalog {
 func TestCatalogHasTheVendorEffectFamilies(t *testing.T) {
 	catalog := impact80(t)
 
-	if got := len(catalog.Names(via.ChannelRgbMatrix)); got != 46 {
-		t.Errorf("backlight effects = %d, want 46", got)
+	// 47, not 46: the vendor's VIA definition stops at 45, and the board takes
+	// one ID more, which the tool names itself.
+	if got := len(catalog.Names(via.ChannelRgbMatrix)); got != 47 {
+		t.Errorf("backlight effects = %d, want 47", got)
 	}
 	if got := len(catalog.Names(via.ChannelRgblight)); got != 7 {
 		t.Errorf("logo effects = %d, want 7", got)
@@ -30,6 +32,43 @@ func TestCatalogHasTheVendorEffectFamilies(t *testing.T) {
 	}
 }
 
+// The last name in the vendor's list and the one the tool added on top of it,
+// pinned at the boundary, because a list that silently grows or shrinks would
+// shift every ID above the change.
+func TestCatalogEndsWithTheVendorListThenTheToolName(t *testing.T) {
+	catalog := impact80(t)
+
+	tests := []struct {
+		id   uint8
+		want string
+	}{
+		{45, "riverflow"},
+		{46, "freeze"},
+		{47, unknownEffectName},
+	}
+	for _, tt := range tests {
+		if got := catalog.EffectName(via.ChannelRgbMatrix, tt.id); got != tt.want {
+			t.Errorf("EffectName(rgb_matrix, %d) = %q, want %q", tt.id, got, tt.want)
+		}
+	}
+
+	if id, ok := catalog.EffectID(via.ChannelRgbMatrix, "freeze"); !ok || id != 46 {
+		t.Errorf("EffectID(rgb_matrix, \"freeze\") = %d, %t, want 46, true", id, ok)
+	}
+}
+
+// The freeze is a tool name, so it must not leak into the channels the vendor
+// did not put it on. The board's other two channels have no such ID.
+func TestFreezeIsBacklightOnly(t *testing.T) {
+	catalog := impact80(t)
+
+	for _, ch := range []via.Channel{via.ChannelRgblight, via.ChannelAudio} {
+		if id, ok := catalog.EffectID(ch, "freeze"); ok {
+			t.Errorf("EffectID(%v, \"freeze\") = %d, true, want not found", ch, id)
+		}
+	}
+}
+
 func TestCatalogIsEmptyForAnUnknownBoard(t *testing.T) {
 	if _, ok := CatalogFor(0x6666, 0x0001); ok {
 		t.Error("CatalogFor(0x6666, 0x0001) = found, want none")

+ 362 - 0
internal/rgb/definition.go

@@ -0,0 +1,362 @@
+package rgb
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+	"strconv"
+	"strings"
+
+	"netdome.biz/paul/qmk-rgb/internal/via"
+)
+
+// DefinitionsDir is where definition files are looked for: the one to fetch
+// into, and the one a user places a manufacturer's file in by hand. The
+// keyboard is identified first, so a file is only ever read for a board whose
+// vendor and product ID match.
+const DefinitionsDir = "definitions"
+
+// VendorProductID is the key a definition is filed under, and the key the
+// keyboard is looked up with: vendorID * 65536 + productID. The multiplication
+// rather than a shift is what VIA uses, and the two must stay equal.
+func VendorProductID(vendorID, productID uint16) int64 {
+	return int64(vendorID)*65536 + int64(productID)
+}
+
+// effectValueKeys are the value keys whose control is a list of effects. A
+// definition that invents its own key for effects does not name them, because
+// nothing distinguishes such a control from any other dropdown.
+var effectValueKeys = map[string]via.Channel{
+	"id_qmk_backlight_effect":  via.ChannelBacklight,
+	"id_qmk_rgblight_effect":   via.ChannelRgblight,
+	"id_qmk_rgb_matrix_effect": via.ChannelRgbMatrix,
+	"id_qmk_audio_effect":      via.ChannelAudio,
+	"id_qmk_led_matrix_effect": via.ChannelLedMatrix,
+}
+
+// Definition is one keyboard definition file: the board it describes, and the
+// effect names it holds per lighting channel.
+type Definition struct {
+	Path      string
+	Name      string
+	VendorID  uint16
+	ProductID uint16
+	Catalog   *Catalog
+	// Labels is the name each lighting channel has in VIA's own interface, taken
+	// from the sub-menu the definition puts it under. It is board data the file
+	// already carries, and using it means the tool and VIA call the same channel
+	// the same thing instead of the tool adding a third name of its own.
+	Labels map[uint16]string
+}
+
+// definitionFile is the part of a VIA definition this tool reads. VIA serves
+// built definitions that carry vendorProductId as a number and no vendorId and
+// productId pair, so both spellings are optional and at least one is required.
+type definitionFile struct {
+	Name            string          `json:"name"`
+	VendorID        string          `json:"vendorId"`
+	ProductID       string          `json:"productId"`
+	VendorProductID int64           `json:"vendorProductId"`
+	Menus           json.RawMessage `json:"menus"`
+}
+
+// Matches reports whether a definition is the one for a board. A definition
+// for another board must not be used for this one, however it was found.
+func (d *Definition) Matches(vendorID, productID uint16) bool {
+	return d.VendorID == vendorID && d.ProductID == productID
+}
+
+// LoadDefinition reads one definition file.
+func LoadDefinition(path string) (*Definition, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, fmt.Errorf("read %s: %w", path, err)
+	}
+	def, err := ParseDefinition(path, data)
+	if err != nil {
+		return nil, err
+	}
+	return def, nil
+}
+
+// ParseDefinition reads a definition from bytes, and is what LoadDefinition
+// hands them to. A file fetched from a server is not a file on disk, and this
+// is also the check that rejects a page which is not a definition: VIA answers
+// an unknown board with its own web page and a success status, so a fetched
+// document has to be parsed and matched before it is believed.
+func ParseDefinition(source string, data []byte) (*Definition, error) {
+	var file definitionFile
+	if err := json.Unmarshal(data, &file); err != nil {
+		return nil, fmt.Errorf("parse %s: not a keyboard definition: %w", source, err)
+	}
+
+	vendorID, productID, err := file.identifiers()
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", source, err)
+	}
+
+	board := file.Name
+	if board == "" {
+		board = filepath.Base(source)
+	}
+
+	return &Definition{
+		Path:      source,
+		Name:      board,
+		VendorID:  vendorID,
+		ProductID: productID,
+		Catalog:   NewCatalog(board, parseEffects(file.Menus)),
+		Labels:    parseLabels(file.Menus),
+	}, nil
+}
+
+// lightingValueKeys are the value keys a lighting channel binds. A control on one
+// of them says which channel the sub-menu around it is about.
+var lightingValueKeys = map[string]bool{
+	"id_qmk_backlight_brightness":    true,
+	"id_qmk_backlight_effect":        true,
+	"id_qmk_rgblight_brightness":     true,
+	"id_qmk_rgblight_effect":         true,
+	"id_qmk_rgblight_effect_speed":   true,
+	"id_qmk_rgblight_color":          true,
+	"id_qmk_rgb_matrix_brightness":   true,
+	"id_qmk_rgb_matrix_effect":       true,
+	"id_qmk_rgb_matrix_effect_speed": true,
+	"id_qmk_rgb_matrix_color":        true,
+	"id_qmk_audio_brightness":        true,
+	"id_qmk_audio_effect":            true,
+	"id_qmk_audio_effect_speed":      true,
+	"id_qmk_audio_color":             true,
+	"id_qmk_led_matrix_brightness":   true,
+	"id_qmk_led_matrix_effect":       true,
+	"id_qmk_led_matrix_color":        true,
+}
+
+// parseLabels returns the sub-menu label per lighting channel: the name VIA
+// shows for that channel. A definition that names no channel yields nothing, so
+// the name a keyboard has elsewhere stays in charge.
+func parseLabels(menus json.RawMessage) map[uint16]string {
+	if len(menus) == 0 {
+		return nil
+	}
+	var decoded any
+	if err := json.Unmarshal(menus, &decoded); err != nil {
+		return nil
+	}
+
+	labels := make(map[uint16]string)
+	var walk func(node any, submenu string)
+	walk = func(node any, submenu string) {
+		switch v := node.(type) {
+		case []any:
+			for _, child := range v {
+				walk(child, submenu)
+			}
+		case map[string]any:
+			label, _ := v["label"].(string)
+			// A sub-menu is an object holding controls; a control is an object
+			// holding a value binding. Which one this is tells us whether the
+			// label around a control is the channel's name.
+			if controls, ok := v["content"].([]any); ok && len(controls) > 0 {
+				if _, isControl := controls[0].(map[string]any); isControl && label != "" {
+					submenu = label
+				}
+			}
+			if c, ok := v["content"].([]any); ok && len(c) >= 2 {
+				if key, isString := c[0].(string); isString && lightingValueKeys[key] {
+					if number, isNumber := c[1].(float64); isNumber && submenu != "" {
+						labels[uint16(number)] = submenu
+					}
+				}
+			}
+			for _, child := range v {
+				walk(child, submenu)
+			}
+		}
+	}
+	walk(decoded, "")
+	if len(labels) == 0 {
+		return nil
+	}
+	return labels
+}
+
+// identifiers returns the board a definition is for, from whichever of the two
+// spellings the file carries.
+func (f definitionFile) identifiers() (uint16, uint16, error) {
+	if f.VendorProductID != 0 {
+		return uint16(f.VendorProductID / 65536), uint16(f.VendorProductID % 65536), nil
+	}
+	if f.VendorID == "" || f.ProductID == "" {
+		return 0, 0, fmt.Errorf("neither vendorProductId nor vendorId/productId; not a keyboard definition")
+	}
+	vendorID, err := parseHexID(f.VendorID)
+	if err != nil {
+		return 0, 0, fmt.Errorf("vendorId %q: %w", f.VendorID, err)
+	}
+	productID, err := parseHexID(f.ProductID)
+	if err != nil {
+		return 0, 0, fmt.Errorf("productId %q: %w", f.ProductID, err)
+	}
+	return vendorID, productID, nil
+}
+
+func parseHexID(s string) (uint16, error) {
+	v, err := strconv.ParseUint(strings.TrimPrefix(strings.TrimSpace(s), "0x"), 16, 16)
+	if err != nil {
+		return 0, fmt.Errorf("not a hexadecimal USB ID: %w", err)
+	}
+	return uint16(v), nil
+}
+
+// parseEffects walks the menus of a definition and returns the effect list of
+// every lighting channel it names. The walk is generic because a definition
+// nests its controls as it likes; what identifies an effect list is the value
+// key and an options array beside it.
+func parseEffects(menus json.RawMessage) map[via.Channel][]Effect {
+	if len(menus) == 0 {
+		return nil
+	}
+	var found map[via.Channel][]Effect
+	var walk func(node any)
+	walk = func(node any) {
+		switch v := node.(type) {
+		case map[string]any:
+			if effects, ok := effectList(v); ok {
+				if found == nil {
+					found = make(map[via.Channel][]Effect)
+				}
+				found[effects.channel] = append(found[effects.channel], effects.list...)
+			}
+			for _, child := range v {
+				walk(child)
+			}
+		case []any:
+			for _, child := range v {
+				walk(child)
+			}
+		}
+	}
+
+	var decoded any
+	if err := json.Unmarshal(menus, &decoded); err != nil {
+		return nil
+	}
+	walk(decoded)
+	return found
+}
+
+type channelEffects struct {
+	channel via.Channel
+	list    []Effect
+}
+
+// effectList reads one UI control and reports the effects it offers, if it is an
+// effect list at all.
+func effectList(control map[string]any) (channelEffects, bool) {
+	content, ok := control["content"].([]any)
+	if !ok || len(content) < 2 {
+		return channelEffects{}, false
+	}
+	valueKey, ok := content[0].(string)
+	if !ok {
+		return channelEffects{}, false
+	}
+	if _, known := effectValueKeys[valueKey]; !known {
+		return channelEffects{}, false
+	}
+	channelNumber, ok := content[1].(float64)
+	if !ok {
+		return channelEffects{}, false
+	}
+
+	options, ok := control["options"].([]any)
+	if !ok {
+		return channelEffects{}, false
+	}
+
+	out := channelEffects{channel: via.Channel(int(channelNumber))}
+	for position, option := range options {
+		name, id, hasID, ok := optionName(option)
+		if !ok {
+			// An option that is neither a string nor a name/number pair has
+			// no name to report; skipping it keeps the rest of the list.
+			continue
+		}
+		if !hasID {
+			id = uint8(position)
+		}
+		out.list = append(out.list, Effect{ID: id, Name: name})
+	}
+	if len(out.list) == 0 {
+		return channelEffects{}, false
+	}
+	sort.Slice(out.list, func(i, j int) bool { return out.list[i].ID < out.list[j].ID })
+	return out, true
+}
+
+// optionName reads one dropdown option. VIA allows a bare string, which takes
+// its number from the position, or a name and number pair, whose number is the
+// value and need not be the position. The second is why an index into a name
+// list is not an effect ID.
+func optionName(option any) (name string, id uint8, hasID bool, ok bool) {
+	switch v := option.(type) {
+	case string:
+		return v, 0, false, true
+	case []any:
+		if len(v) == 0 {
+			return "", 0, false, false
+		}
+		label, isString := v[0].(string)
+		if !isString {
+			return "", 0, false, false
+		}
+		if len(v) < 2 {
+			return label, 0, false, true
+		}
+		number, isNumber := v[1].(float64)
+		if !isNumber {
+			return label, 0, false, true
+		}
+		return label, uint8(number), true, true
+	default:
+		return "", 0, false, false
+	}
+}
+
+// LoadDefinitionsDir reads every definition file in a directory. A file it
+// cannot use is an error rather than a silent skip: a definition the user
+// placed there and that does not work is worth saying out loud.
+func LoadDefinitionsDir(dir string) ([]*Definition, error) {
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return nil, fmt.Errorf("read %s: %w", dir, err)
+	}
+
+	var defs []*Definition
+	for _, entry := range entries {
+		if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
+			continue
+		}
+		def, err := LoadDefinition(filepath.Join(dir, entry.Name()))
+		if err != nil {
+			return nil, err
+		}
+		defs = append(defs, def)
+	}
+	sort.Slice(defs, func(i, j int) bool { return defs[i].Path < defs[j].Path })
+	return defs, nil
+}
+
+// FindDefinition returns the definition for a board, or nil when the directory
+// holds none for it.
+func FindDefinition(defs []*Definition, vendorID, productID uint16) *Definition {
+	for _, def := range defs {
+		if def.VendorID == vendorID && def.ProductID == productID {
+			return def
+		}
+	}
+	return nil
+}

+ 291 - 0
internal/rgb/definition_test.go

@@ -0,0 +1,291 @@
+package rgb
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"netdome.biz/paul/qmk-rgb/internal/via"
+)
+
+// A definition may attach a number to an option that is not its position in the
+// list, so the ID has to come from the number and not from the index. This
+// fixture names three effects at IDs 1, 2 and 5 and leaves 0, 3 and 4 unnamed.
+func TestLoadDefinitionReadsEffectIDsFromTheOptions(t *testing.T) {
+	def := loadDefinitionFixture(t, "tuple_options.json")
+
+	effects := def.Catalog.Effects(via.ChannelRgbMatrix)
+	if len(effects) != 3 {
+		t.Fatalf("rgb_matrix effects = %d, want 3", len(effects))
+	}
+	want := []Effect{{ID: 1, Name: "Solid Color"}, {ID: 2, Name: "Breathing"}, {ID: 5, Name: "Rainbow"}}
+	for i, w := range want {
+		if effects[i] != w {
+			t.Errorf("effects[%d] = %+v, want %+v", i, effects[i], w)
+		}
+	}
+
+	// A gap must report unknown rather than the next name along.
+	for _, id := range []uint8{0, 3, 4, 6} {
+		if got := def.Catalog.EffectName(via.ChannelRgbMatrix, id); got != unknownEffectName {
+			t.Errorf("EffectName(rgb_matrix, %d) = %q, want %q", id, got, unknownEffectName)
+		}
+	}
+}
+
+// The same effect is reachable by name, which is what `effect <name>` needs.
+func TestDefinitionEffectsResolveByName(t *testing.T) {
+	def := loadDefinitionFixture(t, "tuple_options.json")
+
+	id, ok := def.Catalog.EffectID(via.ChannelRgbMatrix, "Rainbow")
+	if !ok || id != 5 {
+		t.Errorf("EffectID(rgb_matrix, \"Rainbow\") = %d, %t, want 5, true", id, ok)
+	}
+	if _, ok := def.Catalog.EffectID(via.ChannelRgbMatrix, "Solid"); ok {
+		t.Error("EffectID(rgb_matrix, \"Solid\") = found, want not found")
+	}
+}
+
+// Plain string options carry no number, and the position is the ID.
+func TestLoadDefinitionNumbersStringOptionsByPosition(t *testing.T) {
+	def := loadDefinitionFixture(t, "string_options.json")
+
+	effects := def.Catalog.Effects(via.ChannelRgblight)
+	want := []Effect{{ID: 0, Name: "All Off"}, {ID: 1, Name: "Solid Color"}, {ID: 2, Name: "Breathing 1"}, {ID: 3, Name: "Breathing 2"}}
+	if len(effects) != len(want) {
+		t.Fatalf("rgblight effects = %d, want %d", len(effects), len(want))
+	}
+	for i, w := range want {
+		if effects[i] != w {
+			t.Errorf("effects[%d] = %+v, want %+v", i, effects[i], w)
+		}
+	}
+}
+
+// VIA serves built definitions that carry vendorProductId as a number and no
+// vendorId/productId pair, so both spellings have to be read.
+func TestDefinitionIdentifiesTheBoardFromEitherSpelling(t *testing.T) {
+	tests := []struct {
+		file       string
+		wantVendor uint16
+		wantProd   uint16
+	}{
+		{"tuple_options.json", 0x1234, 0x5678},
+		{"string_options.json", 0x1234, 0x5678},
+	}
+	for _, tt := range tests {
+		def := loadDefinitionFixture(t, tt.file)
+		if def.VendorID != tt.wantVendor || def.ProductID != tt.wantProd {
+			t.Errorf("%s: vendor/product = 0x%04x/0x%04x, want 0x%04x/0x%04x",
+				tt.file, def.VendorID, def.ProductID, tt.wantVendor, tt.wantProd)
+		}
+	}
+	if got := loadDefinitionFixture(t, "string_options.json").Name; got != "Served Board" {
+		t.Errorf("name = %q, want %q", got, "Served Board")
+	}
+}
+
+// Brightness, color and speed controls are not effect lists and must not become
+// one.
+func TestLoadDefinitionIgnoresOtherControls(t *testing.T) {
+	def := loadDefinitionFixture(t, "tuple_options.json")
+
+	for _, ch := range []via.Channel{via.ChannelBacklight, via.ChannelRgbMatrix} {
+		for _, e := range def.Catalog.Effects(ch) {
+			if e.Name == "" {
+				t.Errorf("channel %v has an effect without a name", ch)
+			}
+		}
+	}
+	if got := def.Catalog.Effects(via.ChannelRgbMatrix); len(got) != 3 {
+		t.Errorf("rgb_matrix effects = %d, want 3 (brightness and color excluded)", len(got))
+	}
+}
+
+// A definition is matched to a board by its VID/PID, so a directory can hold
+// many of them and the wrong one must not be used.
+func TestFindDefinitionMatchesTheBoard(t *testing.T) {
+	dir := t.TempDir()
+	copyFixture(t, dir, "tuple_options.json")
+
+	defs, err := LoadDefinitionsDir(dir)
+	if err != nil {
+		t.Fatalf("LoadDefinitionsDir() error = %v", err)
+	}
+	if len(defs) != 1 {
+		t.Fatalf("definitions = %d, want 1", len(defs))
+	}
+
+	if got := FindDefinition(defs, 0x1234, 0x5678); got == nil {
+		t.Error("FindDefinition(matching) = nil, want the definition")
+	}
+	if got := FindDefinition(defs, 0x36B0, 0x309F); got != nil {
+		t.Error("FindDefinition(other board) = a definition, want nil")
+	}
+}
+
+// A file that is not a definition, or not one for any board, is reported rather
+// than skipped in silence.
+func TestLoadDefinitionsDirRejectsUnusableFiles(t *testing.T) {
+	dir := t.TempDir()
+	if err := os.WriteFile(filepath.Join(dir, "broken.json"), []byte("{not json"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+
+	if _, err := LoadDefinitionsDir(dir); err == nil {
+		t.Error("LoadDefinitionsDir(broken json) = nil error, want an error")
+	}
+}
+
+func TestLoadDefinitionRejectsAMissingFile(t *testing.T) {
+	if _, err := LoadDefinition(filepath.Join(t.TempDir(), "nope.json")); err == nil {
+		t.Error("LoadDefinition(missing) = nil error, want an error")
+	}
+}
+
+func loadDefinitionFixture(t *testing.T, name string) *Definition {
+	t.Helper()
+	def, err := LoadDefinition(filepath.Join("testdata", name))
+	if err != nil {
+		t.Fatalf("LoadDefinition(%s) error = %v", name, err)
+	}
+	return def
+}
+
+func copyFixture(t *testing.T, dir, name string) {
+	t.Helper()
+	data, err := os.ReadFile(filepath.Join("testdata", name))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil {
+		t.Fatal(err)
+	}
+}
+
+// A definition carries the manufacturer's own spelling, and the tool's aliases
+// are a spelling convenience rather than board knowledge, so they have to work
+// whichever spelling a definition happens to use. The vendor of this board
+// writes "breathe" where the compiled-in catalog writes "breathing".
+func TestDefinitionCatalogResolvesTheToolsAliasesBothWays(t *testing.T) {
+	names := []Effect{{ID: 0, Name: "none"}, {ID: 3, Name: "spectrum"}, {ID: 4, Name: "breathe"}, {ID: 5, Name: "light"}}
+	catalog := NewCatalog("vendor", map[via.Channel][]Effect{via.ChannelRgblight: names})
+
+	tests := []struct {
+		name   string
+		effect string
+		want   uint8
+	}{
+		{"the vendor's own spelling", "breathe", 4},
+		{"the tool's canonical name", "breathing", 4},
+		{"an alias that the channel has", "off", 0},
+		{"an alias that resolves per channel", "rainbow", 3},
+		{"a name no alias or entry reaches", "nonsense", 0},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			id, ok := catalog.EffectID(via.ChannelRgblight, tt.effect)
+			if tt.effect == "nonsense" {
+				if ok {
+					t.Errorf("EffectID(%q) = %d, true, want not found", tt.effect, id)
+				}
+				return
+			}
+			if !ok || id != tt.want {
+				t.Errorf("EffectID(%q) = %d, %t, want %d, true", tt.effect, id, ok, tt.want)
+			}
+		})
+	}
+}
+
+// The compiled-in catalog keeps working exactly as before.
+func TestBuiltInCatalogStillResolvesItsAliases(t *testing.T) {
+	catalog := impact80(t)
+
+	tests := []struct {
+		channel via.Channel
+		effect  string
+		want    uint8
+	}{
+		{via.ChannelRgblight, "breathing", 4},
+		{via.ChannelRgblight, "breathe", 4},
+		{via.ChannelRgblight, "off", 0},
+		{via.ChannelRgbMatrix, "breathing", 5},
+		{via.ChannelRgbMatrix, "rainbow", 17},
+		{via.ChannelAudio, "rainbow", 3},
+	}
+	for _, tt := range tests {
+		id, ok := catalog.EffectID(tt.channel, tt.effect)
+		if !ok || id != tt.want {
+			t.Errorf("EffectID(%v, %q) = %d, %t, want %d, true", tt.channel, tt.effect, id, ok, tt.want)
+		}
+	}
+}
+
+// A definition writes display spellings ("fixed wave") where the tool writes
+// identifiers ("fixed_wave"). The difference is whitespace, not a different
+// effect, so both must resolve — the tool's documented name may not stop working
+// because a definition file happens to be present.
+func TestDefinitionCatalogResolvesSpacesAsUnderscores(t *testing.T) {
+	names := []Effect{{ID: 0, Name: "none"}, {ID: 1, Name: "wave"}, {ID: 2, Name: "fixed wave"}}
+	catalog := NewCatalog("vendor", map[via.Channel][]Effect{via.ChannelRgblight: names})
+
+	tests := []struct {
+		effect string
+		want   uint8
+	}{
+		{"fixed wave", 2},
+		{"fixed_wave", 2},
+		{"wave", 1},
+	}
+	for _, tt := range tests {
+		id, ok := catalog.EffectID(via.ChannelRgblight, tt.effect)
+		if !ok || id != tt.want {
+			t.Errorf("EffectID(%q) = %d, %t, want %d, true", tt.effect, id, ok, tt.want)
+		}
+	}
+}
+
+// The compiled-in catalog has no spaced names, so the rule changes nothing there.
+func TestBuiltInCatalogIsUnchangedByTheSpacingRule(t *testing.T) {
+	catalog := impact80(t)
+
+	if id, ok := catalog.EffectID(via.ChannelRgblight, "fixed_wave"); !ok || id != 2 {
+		t.Errorf("EffectID(rgblight, \"fixed_wave\") = %d, %t, want 2, true", id, ok)
+	}
+	if _, ok := catalog.EffectID(via.ChannelRgbMatrix, "fixed_wave"); ok {
+		t.Error("EffectID(rgb_matrix, \"fixed_wave\") = found, want not found: only the rgblight channel has it")
+	}
+}
+
+// The sub-menu a definition puts a lighting channel under is that channel's name
+// in VIA's own interface, and it is board data the file already carries. Using
+// it means the tool and VIA call the same channel the same thing, instead of
+// the tool inventing a third name.
+func TestDefinitionNamesItsChannelsBySubMenu(t *testing.T) {
+	def := loadDefinitionFixture(t, "tuple_options.json")
+
+	want := map[uint16]string{2: "logo", 3: "Backlight", 4: "side"}
+	for channel, label := range want {
+		if got := def.Labels[channel]; got != label {
+			t.Errorf("Labels[%d] = %q, want %q", channel, got, label)
+		}
+	}
+	if len(def.Labels) != len(want) {
+		t.Errorf("Labels = %v, want only the lighting channels", def.Labels)
+	}
+}
+
+// The label is whatever the definition calls the channel, not a name this tool
+// knows: a different definition calls the same channel Underglow, and that name
+// is the one VIA shows.
+func TestDefinitionTakesWhateverTheChannelIsCalled(t *testing.T) {
+	def := loadDefinitionFixture(t, "string_options.json")
+
+	if got := def.Labels[2]; got != "Underglow" {
+		t.Errorf("Labels[2] = %q, want %q", got, "Underglow")
+	}
+	if len(def.Labels) != 1 {
+		t.Errorf("Labels = %v, want only the channel the definition names", def.Labels)
+	}
+}

+ 15 - 0
internal/rgb/impact80.go

@@ -2,6 +2,10 @@ package rgb
 
 // The effect names of this board, in ID order. Their provenance is recorded on
 // CatalogFor, which is the only entry point that hands them out.
+//
+// Everything below is a fact about one Wobkey Impact 80 and not about QMK,
+// rgblight or VIA. Where a board disagrees with a common source, the board wins
+// and the list is not edited to agree with it.
 
 var impact80BacklightEffects = [...]string{
 	"none",
@@ -50,6 +54,17 @@ var impact80BacklightEffects = [...]string{
 	"starlight_dual_hue",
 	"starlight_dual_sat",
 	"riverflow",
+	// ID 46 is this board's highest effect ID and the only name in this file
+	// that Wobkey did not publish. The vendor's VIA definition ends at 45 and
+	// the file contains no word for pause, stop, freeze or hold, so the name is
+	// the tool's own.
+	//
+	// The behaviour was measured on an Impact 80: writing it leaves whatever
+	// the LEDs currently show in place and stops the animation, so a channel
+	// that was mid-effect freezes on that effect's pattern rather than showing
+	// a pattern of its own. It is the one ID here whose name describes a
+	// property of the firmware and not a name from a catalog.
+	"freeze",
 }
 
 var impact80LogoEffects = [...]string{

+ 23 - 0
internal/rgb/testdata/string_options.json

@@ -0,0 +1,23 @@
+{
+  "name": "Served Board",
+  "vendorProductId": 305419896,
+  "firmwareVersion": 0,
+  "menus": [
+    {
+      "label": "Lighting",
+      "content": [
+        {
+          "label": "Underglow",
+          "content": [
+            {
+              "label": "Effect",
+              "type": "dropdown",
+              "content": ["id_qmk_rgblight_effect", 2, 2],
+              "options": ["All Off", "Solid Color", "Breathing 1", "Breathing 2"]
+            }
+          ]
+        }
+      ]
+    }
+  ]
+}

+ 122 - 0
internal/rgb/testdata/tuple_options.json

@@ -0,0 +1,122 @@
+{
+  "name": "Test Board 65",
+  "vendorId": "0x1234",
+  "productId": "0x5678",
+  "matrix": {
+    "rows": 5,
+    "cols": 15
+  },
+  "keycodes": [
+    "qmk_rgb_matrix_keycodes"
+  ],
+  "menus": [
+    {
+      "label": "Lighting",
+      "content": [
+        {
+          "label": "logo",
+          "content": [
+            {
+              "label": "Brightness",
+              "type": "range",
+              "options": [
+                0,
+                255
+              ],
+              "content": [
+                "id_qmk_rgblight_brightness",
+                2,
+                1
+              ]
+            },
+            {
+              "label": "Effect",
+              "type": "dropdown",
+              "content": [
+                "id_qmk_rgblight_effect",
+                2,
+                2
+              ],
+              "options": [
+                "none",
+                "breathe"
+              ]
+            }
+          ]
+        },
+        {
+          "label": "Backlight",
+          "content": [
+            {
+              "label": "Brightness",
+              "type": "range",
+              "options": [
+                0,
+                255
+              ],
+              "content": [
+                "id_qmk_rgb_matrix_brightness",
+                3,
+                1
+              ]
+            },
+            {
+              "label": "Effect",
+              "type": "dropdown",
+              "content": [
+                "id_qmk_rgb_matrix_effect",
+                3,
+                2
+              ],
+              "options": [
+                [
+                  "Solid Color",
+                  1
+                ],
+                [
+                  "Breathing",
+                  2
+                ],
+                [
+                  "Rainbow",
+                  5
+                ]
+              ]
+            }
+          ]
+        },
+        {
+          "label": "side",
+          "content": [
+            {
+              "label": "Brightness",
+              "type": "range",
+              "options": [
+                0,
+                255
+              ],
+              "content": [
+                "id_qmk_audio_brightness",
+                4,
+                1
+              ]
+            },
+            {
+              "label": "Effect",
+              "type": "dropdown",
+              "content": [
+                "id_qmk_audio_effect",
+                4,
+                2
+              ],
+              "options": [
+                "none",
+                "breathe"
+              ]
+            }
+          ]
+        }
+      ]
+    }
+  ]
+}

+ 4 - 0
internal/via/channel.go

@@ -33,6 +33,10 @@ const probeValueID = 0x01
 // Subsystem returns the QMK name of the channel's lighting subsystem. The name
 // follows from the channel number, so no board file stores it and it is always
 // available for a channel the keyboard has.
+// LightingChannels lists the QMK lighting channels in channel order, which is
+// the order the tool reports them in.
+var LightingChannels = []Channel{ChannelBacklight, ChannelRgblight, ChannelRgbMatrix, ChannelAudio, ChannelLedMatrix}
+
 func (c Channel) Subsystem() string {
 	switch c {
 	case ChannelBacklight: