README.md is the single source of truth for this project's behaviour: the
command surface, the effect catalog, zone semantics, the protocol, the keyboard
list and platform setup. Read it in full before you work on this repository.
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. 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..claude/skills/qmk-rgb/SKILL.md — the live-query workflow for the tool.For the effect catalog specifically, read it from no document at all. Run
qmk-rgb-tool effect all and use the output. Logo and Side accept fewer
effects than Backlight, and only the tool knows what the current firmware
supports.
origin/main moves while you work, and this repository is worked on from more
than one machine. Check it before the first edit of a session, not before the
push: a working tree that starts a rebase half-finished has already lost work.
git fetch origin && git log --oneline HEAD..origin/main && git diff --stat HEAD...origin/main
Read what the incoming commits touch, and rebase onto them before editing:
git rebase origin/main
git status -sb reporting ahead N, behind M means both are true, and M > 0
is the one that ends in a rejected push. A non-fast-forward rejection is that
same finding one step too late — rebase, never force-push, because the commits
behind are other people's work and this branch is not the only writer.
Three times now the local checkout has been behind at the moment of the push,
and once the incoming commits touched AGENTS.md and README.md as well, which
is where a stale copy of this file is written. A commit built against a tree that
no longer exists is not wrong, it is orphaned, and the checks that would have
caught it — go test ./... and the doc tests in cmd/qmk-rgb-tool — run against
whatever is on disk, not against origin/main.
Cross-platform Go CLI library for programmatic/agent-friendly control of QMK keyboard RGB lighting.
Discovery matches every connected keyboard exposing the QMK Raw HID signature
(Usage Page 0xFF60, Usage 0x61) — the same check qmk/qmk_udev performs.
Nothing has to be registered for a board to work. A keyboard states its own name
over USB, and the definition file that describes it names its channels; a board
with neither is still driven, because the channel numbers follow from the QMK
subsystem they belong to. keyboards.json used to supply both and is gone: two
places to update is how the channel names and the catalog drifted apart.
An empty discovery has to explain itself, and that is the whole of what
DiscoverEvery is for. "No keyboard" is what a board that is not connected
looks like, and it is also what a board that is connected and not reachable looks
like — a firmware that puts Raw HID on another usage page, a wireless receiver, a
firmware without Raw HID at all. Nothing in the tool can tell those apart from
outside, so keyboard info prints the HID devices it passed over, one line per
device with the usage pages it exposes, and the JSON shape carries them in
otherHidDevices on every run.
The two lines above that list say what was looked for and why a board has it, and
what they may not say is that VIA was checked. They did not: this command
never opens a device, and the filter asks about a usage page, not about VIA. A
QMK firmware has the collection when Raw HID is enabled and VIA's build cannot be
compiled without it (quantum/via.c errors out), which is a property of the
firmware and belongs in the sentence as the reason — not as a claim about what
this command observed. The same goes for "No QMK keyboard found", which is
overstated anyway: most QMK builds do not enable Raw HID at all, so a plain QMK
keyboard is a QMK keyboard this tool does not see.
Two decisions there are load-bearing and were measured, not chosen. It is one line
per device, not per collection: a keyboard over USB reports five collections
(measured on the Impact 80: 0x0001/0x02, 0x0001/0x01, 0x0001/0x80,
0x000C/0x01, 0x0001/0x06), so per collection buries the one line that says
something. And in text the list appears only when nothing was found: a Mac has
43 HID collections across 10 devices, none of which is ever a keyboard, and
printing them beside a successful listing buries the line the user came for. The
JSON always has them, because a consumer asking "why not this one" needs them
whether or not the tool found something.
The signature is an exact pair, and it is checked per collection rather than per
device because macOS reports one HID device per usage pair and Linux one interface
at a time; the seen map is keyed on the path after the filter, so a keyboard's
own keyboard collection cannot consume the raw HID entry. A vendor page near
0xFF60 is not a raw HID interface and no guessing is done about intent — the
firmware picked the page, and saying which page it picked is the answer.
Where a board's own files live is definitions/ and profiles/, both under the
platform's per-user configuration directory, which os.UserConfigDir answers — a
hardcoded ~/.config would be wrong on macOS and Windows. os.UserConfigDir is
what names it, and userDataDir in cmd/qmk-rgb-tool/datadir.go is the only
place that asks.
Each of the two is one directory, and it is both what is written and what is
read: definitionsPath() and profilesPath(), and nothing else. Both were
searches until they were not — next to the executable, then the working
directory, then the user's — and the search was a defect in both cases, for the
same reason in reverse. A definition is written by keyboard fetch and read by
every command, so a directory beside the binary hides the file the user just
fetched, for as long as the binary is run from a checkout, which is where it is
run from. A profile is written to the user's directory and read from a
checkout's, so load <name> finds a different file with the same name depending
on where the command is run, and save followed by load does not return what
was saved. A test that a definitions or profiles directory beside the binary is
ignored, in TestDataDirectoriesAreNotSearched, is what stops the search coming
back.
One argument reaches a file outside those directories, and it is deliberately not
a search: a profile argument ending in .json is a path, handed to the file
system as written, so load profiles/lava.json reads that file. resolveProfileTarget
in cmd/qmk-rgb-tool/profile.go is the whole rule — a .json suffix is a path,
anything else is a name in profilesPath() — and it has no fallback between the
two, because a fallback is the search this file rules out. delete and list stay
name-only, so there is no way to remove or list a file by path and the per-user
directory remains the one place a name resolves to.
A board's definition has one further source: the file built into the binary, which
candidateDefinitions in cmd/qmk-rgb-tool/catalog.go consults after the
per-user directory.
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 the effect list open the board and report
them.
No command validates the zone or --device before the command that needs it
does. A root-level pre-run would make keyboard info, list, delete and
completion demand a keyboard, and the one you run to choose a keyboard cannot
require that you have chosen one. Where two errors apply, the board wins: a
keyboard that cannot be selected is reported before a zone name, because the
vocabulary of a board cannot be known without the board.
keyboard info numbers the connected keyboards from 1 in a stable order
(vendor ID, product ID, path). --device accepts that number, never a HID path.
Without --device, commands proceed only when exactly one keyboard is connected;
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.
The zone is a positional argument, not a flag, and that is not a style choice.
It was a flag, on the root, and a flag on the root is a flag every help lists:
keyboard info and list were told about a channel neither can address, and
ignored it. An argument is spelled only where it is read, so there is nothing to
ignore. zoneArgs in cmd/qmk-rgb-tool/main.go is the one place a zone
argument count is declared — withZoneArgs is every command's route into it, and
load calls it directly because its zone is the second argument. Commands that
take no zone use cobra's own NoArgs or MaximumNArgs, which is correct: a
stray token there is a mistyped command, not a missing channel. zoneArgs prints
the usage line on a wrong count, because cobra's own message does not say what
the missing argument should have been.
The zone takes a VIA lighting channel, named by its QMK subsystem, and a board's
definition file may name a channel differently. README.md carries the vocabulary.
Several channels are written comma separated and all means every channel the
keyboard reports; all next to another name is all, since the union is the
whole keyboard. Both live in resolveZoneName in cmd/qmk-rgb-tool/zones.go, and
so does the fact that a list comes back in channel order however it was written —
one order for every spelling of one selection, which is what a JSON consumer
parses. The channels themselves are discovered by asking the keyboard, not
assumed, and a name that resolves to a channel the board does not have is refused
by name, every missing one of a list, so nothing is written on the way to a
failure.
A command that writes lighting cannot be called without a zone, and the argument
count is what keeps it that way: brightness given one argument fails before the
keyboard is opened. A command that only reads — info, effect without a name,
save — takes no zone and reports every channel, which is what they are for.
load takes a profile name and at most a zone, and applies the profile to the
channels the zone names. Write the arity into a new command that writes, or the
new command will be the one command that writes every channel by default.
The zone is a parameter of prepareTarget and openTarget rather than a
package-level variable, which is what makes the rule above enforceable: there is
no global a test or a command could set by accident, and a command that forgets to
pass one passes "", which means every channel. Only load skips a channel that
does not support an effect, warning on stderr, because there a whole profile is
being applied. effect refuses instead, all included: effect all asks for
every channel, so a name only one of them has is a request the tool cannot carry
out rather than a reason to write the others and report success.
github.com/sstallion/go-hid for HID accessgithub.com/spf13/cobra for CLI frameworkDocumentation describes real, verifiable behavior. Names are only the start — behavior counts too. Before any edit:
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.qmk-rgb-tool) is the binary name; every usage example, CLI reference table, and doc must use the same string.go.mod module path is the source of truth for go install targets.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's help text. A board's model name is the definition's name, or the USB product string, or nothing — there is no lookup file left to put one in.Documented behavior must also match the code:
--device 1 must not accept a path.null. Agents and scripts parse this.The Impact 80 firmware rescales or clamps brightness and speed per channel, so
an accepted 0–255 request is not the value the keyboard holds. logo and side
cap brightness at 160 and have only three reachable speeds — 0 freezes the
animation, 1 is the slowest movement, anything above 1 becomes 4 — while
backlight scales brightness up to 255 and applies speed as given. README.md
tabulates the per-channel behaviour; the rule that follows from it is the part
that matters when you write code here:
brightness, speed, color and effect read every selected zone
back and
print what was actually applied; where all zones match they print the plain
success line, and where any zone differs they print one summary line naming each
zone's real value and the request. Never let a command report success for a
value the keyboard did not accept. All of them read back through the shared
readBackValues, so a future parameter needs no new read-back path — but the
helper above it is not as shared as it looks. setValueVerified writes one
value to every channel, which fits brightness and speed and fits nothing
else: effect carries a different ID per channel, because one effect name is a
different index on each subsystem, and so has its own setEffectVerified.
Assume the next parameter needs its own wrapper, and share only the read.
A board can refuse an ID that looks valid. The Impact 80's logo and side
channels read effect ID 0 as "lighting off" and leave the mode register alone,
so effect logo none there does nothing to the effect and is reported as the
effect still running. disable is the command that turns a channel off, because
it also writes brightness 0. Above the top the behaviour is the opposite: the
backlight channel does not refuse ID 47 or ID 99 but clamps it to the 46 it
holds, so an effect index there is reported as the index the keyboard ended
up with. Read
the register back; never report the number that was asked for.
There is no hand-written effect catalog, and that is deliberate. A board's names
come from its VIA definition file, read at runtime, and the Impact 80's file is
vendored in definitions/. A hand-written list beside the vendor's file is two
places to update one name, which is exactly how the two drifted apart: the
compiled-in list spelled breathing and fixed_wave where the file writes
breathe and fixed wave, and the file spells the same effect two ways on two
channels of one board. Do not add such a list back, and do not "correct" a
definition file either.
Embedding a vendor's file is not adding a list. definitions/embed.go builds
the JSON files in that directory into the binary with go:embed, and
candidateDefinitions in cmd/qmk-rgb-tool/catalog.go consults them last, after
--definition and after a file in a definitions directory. The distinction is
the one that matters: the bytes are the vendor's own file, there is exactly one
copy of it in the repository, and nothing transcribes it. A file the user places
still wins, so the built-in copy is a fallback and not a second source a name
could drift from. go install copies a binary to $GOPATH/bin and creates no
data directory, so without the embed an installed tool has no definition for the
Impact 80 at all — and that board is not in VIA's collection, so there is nothing
to fetch either.
The file names only the effects a board's firmware implements, so it may stop
short of the board's highest ID: this one's backlight channel ends at 45 while
the board takes 46, which is left unknown on purpose. A name no source publishes
is a name the tool would be inventing. The live register proves which IDs a board
takes, never which name belongs to one.
When you see a name or a behavior in one file, grep for it across the whole repo before deciding if a change is consistent.
Unclear firmware behaviour is answered by reading source, not by inferring it from names, from a general QMK assumption, or from a doc line that nobody has checked. Two sources settle nearly everything, and both are cheap to reach.
Upstream QMK is public: quantum/rgb_matrix/rgb_matrix.h for the value API,
quantum/rgb_matrix/animations/ for what an effect actually does to hue,
saturation and value. The vendor's VIA definition JSON for the board settles
what the interface exposes at all — which custom value IDs exist, which ranges
they take, and for which effects a color control is offered. It is the authority
on the wire format, more than upstream is, because the ID mapping is the
vendor's. For the Impact 80 that JSON is
https://drive.wobkey.com/f/d/6BtO/Impact_80.JSON, reached from the vendor's
Driver & Firmware page;
the page is the place to re-read, because it also carries what a wrong flash does
to this tool: the vendor ships a proprietary firmware alongside the VIA one, it
disables VIA completely, and a board on it is invisible rather than unsupported.
It also documents that VIA only sees the board in wired mode. A keyboard the
tool cannot find is therefore not yet evidence of a bug here.
The board's own firmware is a vendor binary with no public source, so both sources describe the family, not the unit on the desk. Confirm against the hardware where that is cheap, then put the observation in README.md.
One trap this session paid for: the vendor channels are not all the same QMK
subsystem. One is rgb_matrix, one is the lightweight rgblight, and one is
the audio effect subsystem. An upstream file that governs one channel does not
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.
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. There is no third
step: CatalogFor is gone, and a command that reached for a catalog 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:
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.brightness backlight 100 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.keyboard 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 built-in copy for its board, which is worth knowing before adding one for the Impact 80: the vendor's file stops at ID 45, so no name for ID 46 is reachable while that file is present, built in or placed. 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.
The names for VIA's built-in menu are in no definition file at all. A
definition that writes "menus": ["qmk_rgb_matrix"] — a string, not a menu object —
refers to the menu VIA's own app carries, and those names live in the-via/reader
(the @the-via/reader package), in src/common-menus/qmk_rgb_matrix.ts: the
options array of the id_qmk_rgb_matrix_effect dropdown, where position equals
effect ID 0 to 44. It is TypeScript with nested content, showIf and range
constraints, so nothing here can read it at runtime, and the spellings are VIA
display labels rather than QMK enum identifiers — Solid Color and Breathing
reach solid_color and breathing through byName's space/underscore rule,
while All Off and Band Sat. reach nothing the tool spells today. The GMMK Pro
(0x320F/0x5044) is one of these boards: keyboard fetch succeeds, stores the file,
and the catalog comes out empty, so no effect name resolves. Whether to fall back
to those 45 names, and where such a fallback's list would come from, is still open
— a list typed into this repository is the drift these rules forbid, so do not add
one without asking.
Nobody ships names over the wire, which is the reason this tool exists. These projects drive the same raw HID interface from a terminal, and each of them takes an effect ID:
python/; BSD-3-Clause. qmk_hid via --rgb-effect 38, plus --rgb-brightness,
--rgb-hue, --rgb-saturation, --rgb-color, --rgb-effect-speed,
--backlight, --backlight-breathing, --save, --device-indication,
--eeprom-reset, --bootloader, and -l/--vid/--pid. Its README states
the reason out loud: "the effect numbers can be different per keyboard", and it
says the tool "will soon be superceded by QMK XAP".qmk-light.cpp;
no licence file, so nothing may be taken from it. --list, --get-brightness,
--set-brightness (absolute or +5/-10), --list-effects, --get-effect,
--set-effect, --get-effect-speed, --set-effect-speed, --get-color,
--set-color h,s, --device <index|path>, --first-device, --quiet.RAW_ENABLE = yes
and the board's VID/PID written into the source.None of them reads a definition file, so none can name an effect. That is the gap
this tool's catalog and keyboard fetch sit in.
Read them for that gap, and for anything this tool does not do yet: they are the
three live implementations of the same wire protocol, and their flag surfaces are
where a gap in ours shows up first — persistence (--save, --eeprom-reset),
device indication, effect speed as its own parameter, colour by name, jumping to
the bootloader, and explicit device selection where more than one board is
attached (--first-device). Any of those is a candidate feature here, not a
duplicate to reimplement badly. Check a project's licence before taking code from
it, and check whether it has moved on before treating its behaviour as current.
The qmk CLI has never had a lighting command. Do not explain a missing
feature by saying QMK removed one. Measured on qmk_firmware: lib/python/qmk/cli/
carries no led, rgblight or hid module at tags 0.6, 0.9, 0.10, 0.15 through
0.21, 0.24 or on master, the commits API returns zero commits for those paths, and
docs/cli_commands.md documents none at any of those tags. The path filter does
report deletions — lib/python/qmk/cli/cformat.py and multibuild.py both end at
4723f308a, "Remove CLI commands: multibuild, cformat, fileformat,
pyformat", 2023-01-18 — so an empty result means the file was never there, not
that it was removed.
keyboard info, info, list, the effect list and keyboard definitions print text,
because that is what a person reads, and JSON only behind the persistent --json.
The field names are the ones the JSON always had, so a consumer that passes the
flag is unaffected — where a field had to be added, it is added rather than
renamed or reshaped. keyboard definitions grew a source field per entry for exactly
this reason: it now lists the definitions built into the binary as well as the
ones in the user directory, and a path alone does not say which of the two a line
is, because a built-in one has a path relative to the build. A command that emits
structured data and neither honours --json nor says why is the bug this rule
exists for. Four shapes are deliberately
not in that list: effect with no effect name routes to the effect list,
keyboard fetch writes a file and prints a line about it, and save and
delete say which file they wrote or removed — the latter two only since save
could name a file by path, where a bare "saved" would leave the one thing worth
reporting unsaid, and where a delete that printed nothing could not be told apart
from one that removed something else. The two lines come in one shape,
savedProfileLine and deletedProfileLine next to each other in profile.go, so a
file the user asked for is always named back to them and annotated by
describeDataDir.
keyboard info does not open the board, so it must not report the board's
channels or an effect list from one: it reports the name and whether this tool has
effect names for that board, and the channels come from info and the effect list,
which open it.
All documentation (README.md, comments, AGENTS.md) must stay in sync with the code. When code and documentation conflict, ask the user before deciding which one to change. Do not silently pick a winner — explicitly state the conflict and get direction.
gopls — Go Language Server, installed separately with go install golang.org/x/tools/gopls@latest (LSP, diagnostics, go-to-def, rename, references)gofmt / goimports — formatting. goimports adds/removes imports automatically. Always run before committing.go test — built-in test framework. Tests live in *_test.go files alongside source.go vet — static analysis. Run before committing.go build — compiles without installing. Fast, cached.go mod tidy — adds missing deps, removes unused ones. Run after every import change.gofmt or goimports. Tabs for indentation, no line-length limit but avoid uncomfortably long lines.MixedCaps / mixedCaps, no underscores. Short local variables (i, c, r). Descriptive for globals.URL, ID, HTTP → appID, urlPony, ServeHTTP.// Package rgb provides RGB effect handling.panic for normal control flow.go
if err != nil {
return err
}
// normal code
- Error strings: lowercase, no period — fmt.Errorf("device not found").
- Do not discard errors with _.
### Interfaces & Types
- Define interfaces in the consumer package, not the producer.
- Use pointer receivers when in doubt (mutation, large structs, sync fields).
- Return concrete types from constructors — let consumers mock if needed.
- Empty slices: var t []string (nil), not t := []string{} (non-nil), unless JSON encoding requires [].
### Concurrency
- Pass context.Context as first parameter. Never store in structs.
- Clear goroutine lifetimes — document when/why they exit. Avoid leaks via channels.
- Prefer synchronous functions — callers can add concurrency, not remove it.
### Testing
- Table-driven tests preferred for multiple cases.
- Failure messages: t.Errorf("Effect(%q) = %d, want %d", input, got, want).
- Use *_test.go files. Test package should match source package (internal tests).
- Example functions (func Example...) double as docs and tests.
### Modules & Packages
- Module path: github.com/<user>/<project> (when published).
- Package names: short, single-word, lowercase. No util, common, api, types.
- Zero values should be useful (e.g. bytes.Buffer, sync.Mutex).
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
When the user types /graphify, use the installed graphify skill or instructions before doing anything else.
Rules:
- For codebase questions, first run graphify query "<question>" when graphify-out/graph.json exists. Use graphify path "<A>" "<B>" for relationships and graphify explain "<concept>" for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run graphify update . to keep the graph current (AST-only, no API cost).