Переглянути джерело

fix what the branch review found

The root command's pre-run made every subcommand enumerate keyboards, so
keyboard info, list, delete and completion demanded a keyboard and failed
with two connected — including the command you run to choose one. The
pre-run had no value: openTarget already resolves the target before it
opens a handle, so a bad --zone is still refused before anything is
opened. Its absence is now pinned by a test, and the error precedence is
documented: the board wins over a zone name.

A profile key naming a channel the keyboard lacks is now reported, not
skipped in silence. That case is reachable precisely when a board is
renamed, because the old name may still resolve as a subsystem name.

A load on a board without a catalog said the effect name was not found,
which is a different failure and hid the real one. It now says the board
has no catalog and applies nothing.

Whether an unsupported effect is an error or a skip comes from the caller
now, not from the length of the channel list, so a keyboard with a single
lighting channel does not turn a default command into a hard error. The
static alias moved into the catalog, where every caller meets it; profile
load bypassed the command-level rewrite and so did not have it.

An unhandled answer whose channel disagrees with the request is a stale
response rather than an absent channel, and now fails the probe instead
of quietly shortening the channel list.

The --zone usage text taught three names the resolver no longer accepts,
and a test pinned them. Documentation finished: the usage block, the
features list, the keyboards.json wording, the qmk-rgb skill, and the JSON
shapes info and effect --list emit.
Paul Klumpp 1 тиждень тому
батько
коміт
291d866dac

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

@@ -37,9 +37,11 @@ Explain what the command would do, then offer to build and run it:
 
 ### 3. When a user wants to change something
 
-- Identify the zone: `logo`, `backlight`, or `side`
-- Ask for confirmation if the action will affect all zones
-- Run the command: `qmk-rgb-tool effect breathing --zone backlight`
+- 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`
+- Ask for confirmation if the action will affect every channel
+- Run the command: `qmk-rgb-tool effect breathing --zone rgb_matrix`
 - Return the output
 
 ### 4. Profiles
@@ -55,17 +57,23 @@ qmk-rgb-tool delete name # Remove a profile
 
 ## Zone behavior
 
-Without `--zone`, commands target Logo, Backlight, and Side in that order.
-With `--zone`, commands target exactly one zone. `--zone` is not remembered
-between runs, so repeat it on every invocation.
+`--zone` names a VIA lighting channel, and the tool asks the keyboard which
+channels it has. Without `--zone`, commands target every channel it reports, in
+channel order. With `--zone`, commands target exactly that channel. `--zone` is
+not remembered between runs, so repeat it on every invocation.
 
-Logo and Side support fewer effects (0–6). Backlight supports the full catalog (0–45). Always check `qmk-rgb-tool effect --list` to see what's available per zone.
+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
+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.
 
 ## Values the keyboard changes
 
 `brightness` and `speed` accept 0–255, but the firmware rescales per channel:
-`logo` and `side` cap brightness at 160 and collapse any speed above 0 to 4,
-while `backlight` scales brightness up to 255 and applies speed as given.
+`rgblight` and `audio` 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 `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
 was actually applied:

+ 7 - 0
AGENTS.md

@@ -43,6 +43,13 @@ Discovery matches every connected keyboard exposing the QMK Raw HID signature
 name its channels; an absent or malformed file costs names, not the ability to
 drive the keyboard.
 
+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
+`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;

+ 31 - 9
README.md

@@ -61,7 +61,7 @@ re-run the command whenever commands or flags change.
 # Discover connected keyboards
 ./qmk-rgb-tool keyboard info
 
-# RGB commands target Logo, Backlight, and Side by default
+# RGB commands target every channel the keyboard reports, in channel order
 ./qmk-rgb-tool effect breathing
 ./qmk-rgb-tool effect rainbow_moving_chevron
 ./qmk-rgb-tool effect rainbow_moving_chevron --zone backlight
@@ -108,7 +108,24 @@ The keyboard is asked which channels it has: one read per channel, and a channel
 its firmware does not implement answers as unhandled. `keyboard info` does not
 report them, because it never opens the keyboard.
 
-All output is machine-parseable JSON when applicable.
+All output is machine-parseable JSON when applicable. The shapes an agent parses:
+
+```jsonc
+// info
+{"enabled": true, "mode": "fixed_wave", "brightness": 10, "speed": 0,
+ "zones": [{"zone": "logo", "channel": 2, "enabled": true, "effect": "fixed_wave",
+            "effectId": 2, "brightness": 10, "speed": 0,
+            "color": {"hue": 0, "saturation": 255}, "error": "only on a channel that failed"}]}
+
+// effect --list
+{"catalog": "impact80",
+ "zones": [{"zone": "logo", "channel": 2, "subsystem": "rgblight",
+            "effect": "none", "id": 0}]}
+```
+
+`effect --list` reports `"catalog": ""` and an empty `zones` array for a board
+that has no catalog, and it reads the keyboard to learn which channels to list.
+`info` prints the JSON and then exits non-zero if a channel could not be read.
 
 ## Selecting a Keyboard
 `keyboard info` numbers every connected QMK keyboard starting at 1, and
@@ -225,7 +242,7 @@ failure, but the summary line always states the value that was actually applied.
 ## Features
 
 - **Cross-platform** — Linux, macOS, Windows via [hidapi](https://github.com/libusb/hidapi)
-- **Zone-aware effects** — per-zone effect control (Logo, Backlight, Side) with zone-specific effect catalogs
+- **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 effects** — complete Impact 80 effect family mapped to zone-aware effect names
 - **Compatibility aliases** — `off`, `breathe`, `rainbow`, `solid`, `static` resolve to correct effect IDs per zone
@@ -236,12 +253,16 @@ failure, but the summary line always states the value that was actually applied.
 
 ## Impact 80 Zones and Effects
 
-| Zone | CLI name | VIA channel | Effect IDs |
+| Zone on the Impact 80 | CLI name | VIA channel | Effect IDs |
 |---|---|---:|---|
 | Logo | `logo` | 2 | 0–6 |
 | 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.
+
 Logo and Side share this complete effect family:
 
 | ID | Name |
@@ -383,11 +404,12 @@ Any keyboard running QMK with the RGB Matrix subsystem and VIA support is
 detected automatically via the QMK Raw HID signature (Usage Page 0xFF60,
 Usage 0x61). No manual configuration required.
 
-`keyboards.json` is optional. When present it only supplies friendly names:
-`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 you names only.
+`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`:
 

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

@@ -215,3 +215,108 @@ func stubTargetForProfileTest(t *testing.T, proto rgbProtocol, zoneFlag string,
 		targetZone = originalZone
 	}
 }
+
+// A key that names a channel this keyboard does not have must be reported like
+// any other key it cannot place. "backlight" is the case that matters: it is a
+// display name on the Impact 80 and QMK's subsystem name for channel 1, so it
+// still resolves after a board renames channel 3 — to a channel that is absent.
+func TestLoadWarnsWhenAKeyNamesAnAbsentChannel(t *testing.T) {
+	dir := t.TempDir()
+	originalDir := profilesDir
+	t.Cleanup(func() { profilesDir = originalDir })
+	profilesDir = dir
+
+	profile := Profile{
+		Name:    "renamed",
+		Version: 1,
+		Zones: map[string]*ZoneSettings{
+			"backlight": {Enabled: true, Effect: "light", Brightness: 100, Speed: 1, Color: "00ff"},
+		},
+	}
+	if err := profile.Save(); err != nil {
+		t.Fatalf("save profile: %v", err)
+	}
+
+	proto := &verifyingProtocol{}
+	t.Cleanup(stubTargetForProfileTest(t, proto, "", nil))
+
+	var stderr bytes.Buffer
+	cmd := NewProfileLoadCmd()
+	cmd.SetOut(&bytes.Buffer{})
+	cmd.SetErr(&stderr)
+	cmd.SetArgs([]string{"renamed"})
+
+	if err := cmd.Execute(); err != nil {
+		t.Fatalf("load returned error: %v", err)
+	}
+	if !strings.Contains(stderr.String(), "backlight") {
+		t.Errorf("stderr = %q, want the key reported by name", stderr.String())
+	}
+	if len(proto.reports) != 0 {
+		t.Errorf("reports = %v, want nothing written for an absent channel", proto.reports)
+	}
+}
+
+// Without a catalog there are no effect names to look the profile's value up
+// in. The load must say that, not claim the name was not found.
+func TestLoadSaysSoWhenTheBoardHasNoCatalog(t *testing.T) {
+	dir := t.TempDir()
+	originalDir := profilesDir
+	t.Cleanup(func() { profilesDir = originalDir })
+	profilesDir = dir
+
+	profile := Profile{
+		Name:    "p",
+		Version: 1,
+		Zones: map[string]*ZoneSettings{
+			"rgb_matrix": {Enabled: true, Effect: "breathing", Brightness: 100, Speed: 1, Color: "00ff"},
+		},
+	}
+	if err := profile.Save(); err != nil {
+		t.Fatalf("save profile: %v", err)
+	}
+
+	proto := &verifyingProtocol{}
+	restore := stubTargetForUnknownBoard(t, proto, dir)
+	t.Cleanup(restore)
+
+	var stderr bytes.Buffer
+	cmd := NewProfileLoadCmd()
+	cmd.SetOut(&bytes.Buffer{})
+	cmd.SetErr(&stderr)
+	cmd.SetArgs([]string{"p"})
+
+	if err := cmd.Execute(); err != nil {
+		t.Fatalf("load returned error: %v", err)
+	}
+	if !strings.Contains(stderr.String(), "catalog") {
+		t.Errorf("stderr = %q, want the missing catalog named", stderr.String())
+	}
+	if strings.Contains(stderr.String(), `effect "breathing" not found`) {
+		t.Errorf("stderr = %q, must not blame the effect name for a missing catalog", stderr.String())
+	}
+	if len(proto.reports) != 0 {
+		t.Errorf("reports = %v, want nothing written without a catalog", proto.reports)
+	}
+}
+
+// stubTargetForUnknownBoard points the commands at a board that has channels but
+// no keyboards.json entry, so it has no catalog.
+func stubTargetForUnknownBoard(t *testing.T, proto rgbProtocol, dir string) func() {
+	t.Helper()
+
+	originalTarget := openTarget
+	originalDir := profilesDir
+
+	openTarget = func() (rgbProtocol, targetDeviceData, []via.Channel, error) {
+		return proto, targetDeviceData{
+			Device: intdevice.Device{VendorID: 0x6666, ProductID: 0x0001},
+		}, impact80Channels(), nil
+	}
+	profilesDir = dir
+
+	return func() {
+		openTarget = originalTarget
+		profilesDir = originalDir
+	}
+}

+ 4 - 6
cmd/qmk-rgb-tool/effect.go

@@ -11,13 +11,11 @@ import (
 
 var listEffects bool
 
-// resolveEffectTargets turns a name into one target per channel, rewriting the
-// legacy spelling the Impact 80's own keycodes use.
+// resolveEffectTargets turns a name into one target per channel. Whether the
+// user named a channel is passed in, because a board with a single channel is
+// not an explicit request for it.
 func resolveEffectTargets(catalog *intrgb.Catalog, name string, channels []via.Channel) ([]intrgb.EffectTarget, []string, error) {
-	if name == "static" {
-		name = "solid"
-	}
-	return intrgb.ResolveEffect(catalog, name, channels)
+	return intrgb.ResolveEffect(catalog, name, channels, targetZone != "")
 }
 
 func NewEffectCmd() *cobra.Command {

+ 11 - 4
cmd/qmk-rgb-tool/flags_test.go

@@ -69,8 +69,9 @@ func TestDocsDoNotOfferAPathValueForDevice(t *testing.T) {
 	}
 }
 
-// The zone names are a three-way contract between the resolver and the docs.
-func TestDocsUseTheCanonicalZoneNames(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.
+func TestDocsDoNotTeachAZoneNameTheResolverRejects(t *testing.T) {
 	for _, path := range []string{
 		"../../README.md",
 		"../../AGENTS.md",
@@ -90,15 +91,21 @@ func TestDocsUseTheCanonicalZoneNames(t *testing.T) {
 	}
 }
 
-func TestZoneFlagUsageListsEveryZone(t *testing.T) {
+func TestZoneFlagUsageNamesTheChannelVocabulary(t *testing.T) {
 	flag := newRootCommand().PersistentFlags().Lookup("zone")
 	if flag == nil {
 		t.Fatal("--zone flag not found")
 	}
 
-	for _, zone := range []string{"logo", "backlight", "side"} {
+	// Every subsystem name must be discoverable from the help text.
+	for _, zone := range []string{"backlight", "rgblight", "rgb_matrix", "audio", "led_matrix"} {
 		if !strings.Contains(flag.Usage, zone) {
 			t.Errorf("--zone usage = %q, want it to list %q", flag.Usage, zone)
 		}
 	}
+	// The board-supplied names are not a fixed vocabulary, so the help must not
+	// claim they are.
+	if strings.Contains(flag.Usage, "logo") || strings.Contains(flag.Usage, "side") {
+		t.Errorf("--zone usage = %q, must not name channels a board supplies", flag.Usage)
+	}
 }

+ 1 - 8
cmd/qmk-rgb-tool/main.go

@@ -25,14 +25,7 @@ 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 zone (logo, backlight, side)")
-	// The zone name is resolved against the connected board, which needs
-	// enumeration but no HID handle, so an unknown name still fails before
-	// anything is opened. openTarget does the resolving.
-	cmd.PersistentPreRunE = func(*cobra.Command, []string) error {
-		_, err := prepareTarget()
-		return err
-	}
+	cmd.PersistentFlags().StringVar(&targetZone, "zone", "", "RGB lighting channel: backlight, rgblight, rgb_matrix, audio or led_matrix, or a name from keyboards.json")
 	return cmd
 }
 

+ 26 - 1
cmd/qmk-rgb-tool/profile.go

@@ -219,6 +219,15 @@ func NewProfileLoadCmd() *cobra.Command {
 				selectedSet[ch] = true
 			}
 
+			// Without a catalog there are no effect names to look the
+			// profile's value up in, so the whole load is impossible. Say that
+			// once instead of reporting every key's name as not found.
+			if catalog == nil {
+				fmt.Fprintf(cmd.ErrOrStderr(),
+					"Warning: profile %q has no effect catalog for this keyboard; nothing applied\n", name)
+				return nil
+			}
+
 			applied := 0
 			for _, key := range keys {
 				settings := p.Zones[key]
@@ -229,13 +238,29 @@ func NewProfileLoadCmd() *cobra.Command {
 					continue
 				}
 
+				// A key that resolves only to channels the keyboard lacks has
+				// nowhere to go. That is a different situation from a key the
+				// selection excludes, which is the user's own choice and quiet.
+				present := false
+				for _, ch := range keyChannels {
+					if selectedSet[ch] {
+						present = true
+						break
+					}
+				}
+				if !present {
+					fmt.Fprintf(cmd.ErrOrStderr(),
+						"Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", name, key)
+					continue
+				}
+
 				for _, ch := range keyChannels {
 					// A key is applied only where the selection allows it, so
 					// `--zone logo load <name>` leaves the other channels alone.
 					if !selectedSet[ch] {
 						continue
 					}
-					targets, _, err := intrgb.ResolveEffect(catalog, settings.Effect, []via.Channel{ch})
+					targets, _, err := intrgb.ResolveEffect(catalog, settings.Effect, []via.Channel{ch}, targetZone != "")
 					if err != nil {
 						fmt.Fprintf(cmd.ErrOrStderr(),
 							"Warning: effect %q not found on %s, skipping\n", settings.Effect, channelName(ch, target.Display))

+ 11 - 39
cmd/qmk-rgb-tool/rgb_test.go

@@ -128,48 +128,20 @@ func TestZoneFlagIsInheritedByCommands(t *testing.T) {
 	}
 }
 
-func TestZoneValidationPrecedesDeviceOpening(t *testing.T) {
-	originalTargetZone := targetZone
-	t.Cleanup(func() { targetZone = originalTargetZone })
-	targetZone = "matrix"
+// Precedence, documented because two errors can apply at once: a board that
+// cannot be selected is reported first, since a zone name is resolved against
+// the board's own names.
+func TestNoKeyboardIsReportedBeforeAnUnknownZone(t *testing.T) {
+	stubDiscovery(t, []intdevice.Device{})
+	originalZone := targetZone
+	t.Cleanup(func() { targetZone = originalZone })
+	targetZone = "matx"
 
 	_, _, _, err := openTarget()
 	if err == nil {
-		t.Fatal("openTarget() expected zone error, got nil")
+		t.Fatal("openTarget() expected an error, got nil")
 	}
-	if !strings.Contains(err.Error(), "unknown zone") {
-		t.Errorf("openTarget() error = %v, want unknown zone error", err)
-	}
-}
-
-func TestZoneValidationRunsBeforeCommandOpener(t *testing.T) {
-	originalTargetDevice := targetDevice
-	originalTargetZone := targetZone
-	t.Cleanup(func() {
-		targetDevice = originalTargetDevice
-		targetZone = originalTargetZone
-	})
-
-	cmd := newRootCommand()
-	openerCalled := false
-	cmd.AddCommand(&cobra.Command{
-		Use: "probe",
-		RunE: func(*cobra.Command, []string) error {
-			openerCalled = true
-			_, _, _, err := openTarget()
-			return err
-		},
-	})
-	cmd.SetArgs([]string{"--zone", "matrix", "probe"})
-
-	err := cmd.Execute()
-	if err == nil {
-		t.Fatal("Execute() expected zone error, got nil")
-	}
-	if !strings.Contains(err.Error(), "unknown zone") {
-		t.Errorf("Execute() error = %v, want unknown zone error", err)
-	}
-	if openerCalled {
-		t.Fatal("child command invoked openTarget before zone validation")
+	if !strings.Contains(err.Error(), "no QMK keyboard found") {
+		t.Errorf("openTarget() error = %v, want the missing keyboard reported", err)
 	}
 }

+ 11 - 4
internal/rgb/catalog.go

@@ -140,13 +140,21 @@ func (c *Catalog) DefaultEffect(ch via.Channel) (uint8, bool) {
 
 // ResolveEffect turns an effect name into one target per channel that supports
 // it, plus the subsystem names of those that do not. A skip is an error rather
-// than a warning when a single channel was asked for, because a command that
-// silently did nothing looks like a command that worked.
-func ResolveEffect(catalog *Catalog, name string, channels []via.Channel) ([]EffectTarget, []string, error) {
+// than a warning when the caller asked for one channel explicitly, because a
+// command that silently did nothing looks like a command that worked. That is
+// the caller's knowledge to pass: a board with a single lighting channel is not
+// an explicit request for it.
+func ResolveEffect(catalog *Catalog, name string, channels []via.Channel, explicit bool) ([]EffectTarget, []string, error) {
 	if catalog == nil {
 		return nil, nil, fmt.Errorf("no effect catalog for this keyboard; set an effect by number with `mode <index>`")
 	}
 
+	// The compatibility spelling every catalog shares, so a caller cannot
+	// resolve "static" differently from another.
+	if name == "static" {
+		name = "solid"
+	}
+
 	known := false
 	for _, ch := range channels {
 		if _, ok := catalog.EffectID(ch, name); ok {
@@ -158,7 +166,6 @@ func ResolveEffect(catalog *Catalog, name string, channels []via.Channel) ([]Eff
 		return nil, nil, fmt.Errorf("unknown effect: %s", name)
 	}
 
-	explicit := len(channels) == 1
 	var targets []EffectTarget
 	var skipped []string
 

+ 52 - 4
internal/rgb/catalog_test.go

@@ -82,7 +82,7 @@ func TestCatalogDefaultEffectPerChannel(t *testing.T) {
 }
 
 func TestResolveEffectWithoutACatalogRefuses(t *testing.T) {
-	_, _, err := ResolveEffect(nil, "wave", []via.Channel{via.ChannelRgblight})
+	_, _, err := ResolveEffect(nil, "wave", []via.Channel{via.ChannelRgblight}, false)
 	if err == nil {
 		t.Fatal("ResolveEffect(nil, ...) expected an error, got nil")
 	}
@@ -96,7 +96,7 @@ func TestResolveEffectWithoutACatalogRefuses(t *testing.T) {
 func TestResolveEffectSkipsAChannelThatDoesNotSupportTheName(t *testing.T) {
 	catalog := impact80(t)
 
-	targets, skipped, err := ResolveEffect(catalog, "light", []via.Channel{via.ChannelRgblight, via.ChannelRgbMatrix})
+	targets, skipped, err := ResolveEffect(catalog, "light", []via.Channel{via.ChannelRgblight, via.ChannelRgbMatrix}, false)
 	if err != nil {
 		t.Fatalf("ResolveEffect() error = %v", err)
 	}
@@ -113,7 +113,7 @@ func TestResolveEffectSkipsAChannelThatDoesNotSupportTheName(t *testing.T) {
 func TestResolveEffectRejectsTheOnlyChannelWhenItDoesNotSupportTheName(t *testing.T) {
 	catalog := impact80(t)
 
-	_, _, err := ResolveEffect(catalog, "light", []via.Channel{via.ChannelRgbMatrix})
+	_, _, err := ResolveEffect(catalog, "light", []via.Channel{via.ChannelRgbMatrix}, true)
 	if err == nil {
 		t.Fatal("ResolveEffect() expected an error, want light rejected on the backlight")
 	}
@@ -122,7 +122,55 @@ func TestResolveEffectRejectsTheOnlyChannelWhenItDoesNotSupportTheName(t *testin
 func TestResolveEffectRejectsANameNoChannelKnows(t *testing.T) {
 	catalog := impact80(t)
 
-	if _, _, err := ResolveEffect(catalog, "nope", []via.Channel{via.ChannelRgblight}); err == nil {
+	if _, _, err := ResolveEffect(catalog, "nope", []via.Channel{via.ChannelRgblight}, true); err == nil {
 		t.Fatal("ResolveEffect() expected an error for an unknown name")
 	}
 }
+
+// "static" is a documented alias. It must resolve through every caller, not
+// only the one that rewrites the string before calling: the backlight catalog
+// spells its solid effect solid_color, so the alias chain needs both steps.
+func TestResolveEffectAcceptsTheStaticAliasOnEveryPath(t *testing.T) {
+	catalog := impact80(t)
+
+	for _, ch := range []via.Channel{via.ChannelRgblight, via.ChannelRgbMatrix, via.ChannelAudio} {
+		targets, _, err := ResolveEffect(catalog, "static", []via.Channel{ch}, true)
+		if err != nil {
+			t.Errorf("ResolveEffect(static on channel %d) error = %v", ch, err)
+			continue
+		}
+		if len(targets) != 1 {
+			t.Errorf("ResolveEffect(static on channel %d) = %d targets, want 1", ch, len(targets))
+		}
+	}
+}
+
+// Whether a command may skip an unsupported effect is the caller's knowledge,
+// not something the channel count can tell it. A board with a single lighting
+// channel and no --zone must still behave like a default command.
+func TestResolveEffectDistinguishesExplicitFromDefault(t *testing.T) {
+	catalog := impact80(t)
+	both := []via.Channel{via.ChannelRgblight, via.ChannelRgbMatrix}
+
+	// Default command, one channel cannot do it: skipped, not refused.
+	targets, skipped, err := ResolveEffect(catalog, "rainbow_moving_chevron", both, false)
+	if err != nil {
+		t.Errorf("default command error = %v, want the unsupported channel skipped", err)
+	}
+	if len(skipped) != 1 || skipped[0] != "rgblight" {
+		t.Errorf("skipped = %v, want [rgblight]", skipped)
+	}
+	if len(targets) != 1 || targets[0].Channel != via.ChannelRgbMatrix {
+		t.Errorf("targets = %+v, want the backlight only", targets)
+	}
+
+	// Same name, named channel only: refused.
+	if _, _, err := ResolveEffect(catalog, "rainbow_moving_chevron", []via.Channel{via.ChannelRgblight}, true); err == nil {
+		t.Error("explicit request error = nil, want it refused")
+	}
+
+	// A name no channel of this board has is unknown either way.
+	if _, _, err := ResolveEffect(catalog, "rainbow_moving_chevron", []via.Channel{via.ChannelRgblight}, false); err == nil {
+		t.Error("single-channel default command error = nil, want an unknown name reported")
+	}
+}

+ 40 - 0
internal/via/channel_test.go

@@ -98,6 +98,21 @@ func TestDetectChannelsFailsOnATransportErrorMidProbe(t *testing.T) {
 	}
 }
 
+// A 0xFF that matches nothing is a valid "no such channel". Only a 0xFF whose
+// echoed channel disagrees is a desynchronised stream.
+func TestDetectChannelsAcceptsAWellFormedUnhandledAnswer(t *testing.T) {
+	transport := &fakeTransport{queue: probeQueue(ChannelRgblight)}
+	protocol := Protocol{handle: transport}
+
+	got, err := protocol.DetectChannels()
+	if err != nil {
+		t.Fatalf("DetectChannels() error = %v", err)
+	}
+	if len(got) != 1 || got[0] != ChannelRgblight {
+		t.Errorf("DetectChannels() = %v, want [2]", got)
+	}
+}
+
 func TestDetectChannelsReportsNoChannelsWhenNoneArePresent(t *testing.T) {
 	transport := &fakeTransport{queue: probeQueue()}
 	protocol := Protocol{handle: transport}
@@ -130,3 +145,28 @@ func TestChannelSubsystemNames(t *testing.T) {
 		}
 	}
 }
+
+// A stale 0xFF — the answer to an earlier request, still in the read buffer —
+// looks exactly like absence. Reading the channel out of it is what tells the
+// two apart, and a mismatch means the request/response stream is out of step.
+func TestDetectChannelsRejectsAnUnhandledAnswerForAnotherChannel(t *testing.T) {
+	queue := probeQueue(ChannelRgblight, ChannelRgbMatrix, ChannelAudio)
+	// Channel 2 answers "unhandled", but for a different channel: the stream
+	// is answering a request that is not this one.
+	stale := make([]byte, 32)
+	stale[0] = byte(Unhandled)
+	stale[1] = 0x07
+	stale[2] = probeValueID
+	queue[1] = stale
+
+	transport := &fakeTransport{queue: queue}
+	protocol := Protocol{handle: transport}
+
+	_, err := protocol.DetectChannels()
+	if err == nil {
+		t.Fatal("DetectChannels() expected an error for an answer belonging to another channel")
+	}
+	if !strings.Contains(err.Error(), "channel 2") {
+		t.Errorf("error = %q, want it to name the channel whose answer did not match", err)
+	}
+}

+ 6 - 0
internal/via/protocol.go

@@ -115,6 +115,12 @@ func (p *Protocol) readResponse(command Message, ch, param byte) ([]byte, error)
 		return nil, fmt.Errorf("short response: got %d bytes, want 32", n)
 	}
 	if buf[0] == byte(Unhandled) {
+		// A 0xFF for a different channel is not absence, it is a stale answer
+		// still sitting in the read buffer. QMK echoes the channel it answers,
+		// so the mismatch is what tells the two apart.
+		if buf[1] != ch {
+			return nil, fmt.Errorf("stale response for channel 0x%02x while reading channel 0x%02x", buf[1], ch)
+		}
 		return nil, errUnhandled
 	}
 	if buf[0] != byte(command) {