Преглед изворни кода

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
 ### 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
 - Return the output
 
 
 ### 4. Profiles
 ### 4. Profiles
@@ -55,17 +57,23 @@ qmk-rgb-tool delete name # Remove a profile
 
 
 ## Zone behavior
 ## 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
 ## Values the keyboard changes
 
 
 `brightness` and `speed` accept 0–255, but the firmware rescales per channel:
 `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
 Both commands read the value back. When a zone differs, the output names what
 was actually applied:
 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
 name its channels; an absent or malformed file costs names, not the ability to
 drive the keyboard.
 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
 `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.
 (vendor ID, product ID, path). `--device` accepts that number, never a HID path.
 Without `--device`, commands proceed only when exactly one keyboard is connected;
 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
 # Discover connected keyboards
 ./qmk-rgb-tool keyboard info
 ./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 breathing
 ./qmk-rgb-tool effect rainbow_moving_chevron
 ./qmk-rgb-tool effect rainbow_moving_chevron
 ./qmk-rgb-tool effect rainbow_moving_chevron --zone backlight
 ./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
 its firmware does not implement answers as unhandled. `keyboard info` does not
 report them, because it never opens the keyboard.
 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
 ## Selecting a Keyboard
 `keyboard info` numbers every connected QMK keyboard starting at 1, and
 `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
 ## Features
 
 
 - **Cross-platform** — Linux, macOS, Windows via [hidapi](https://github.com/libusb/hidapi)
 - **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/`
 - **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
 - **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
 - **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
 ## 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 |
 | Logo | `logo` | 2 | 0–6 |
 | Backlight | `backlight` | 3 | 0–45 |
 | Backlight | `backlight` | 3 | 0–45 |
 | Side | `side` | 4 | 0–6 |
 | 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:
 Logo and Side share this complete effect family:
 
 
 | ID | Name |
 | 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,
 detected automatically via the QMK Raw HID signature (Usage Page 0xFF60,
 Usage 0x61). No manual configuration required.
 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`:
 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
 		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
 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) {
 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 {
 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{
 	for _, path := range []string{
 		"../../README.md",
 		"../../README.md",
 		"../../AGENTS.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")
 	flag := newRootCommand().PersistentFlags().Lookup("zone")
 	if flag == nil {
 	if flag == nil {
 		t.Fatal("--zone flag not found")
 		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) {
 		if !strings.Contains(flag.Usage, zone) {
 			t.Errorf("--zone usage = %q, want it to list %q", 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,
 		SilenceUsage:  true,
 	}
 	}
 	cmd.PersistentFlags().StringVar(&targetDevice, "device", "", "Keyboard number as printed by 'keyboard info', starting at 1")
 	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
 	return cmd
 }
 }
 
 

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

@@ -219,6 +219,15 @@ func NewProfileLoadCmd() *cobra.Command {
 				selectedSet[ch] = true
 				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
 			applied := 0
 			for _, key := range keys {
 			for _, key := range keys {
 				settings := p.Zones[key]
 				settings := p.Zones[key]
@@ -229,13 +238,29 @@ func NewProfileLoadCmd() *cobra.Command {
 					continue
 					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 {
 				for _, ch := range keyChannels {
 					// A key is applied only where the selection allows it, so
 					// A key is applied only where the selection allows it, so
 					// `--zone logo load <name>` leaves the other channels alone.
 					// `--zone logo load <name>` leaves the other channels alone.
 					if !selectedSet[ch] {
 					if !selectedSet[ch] {
 						continue
 						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 {
 					if err != nil {
 						fmt.Fprintf(cmd.ErrOrStderr(),
 						fmt.Fprintf(cmd.ErrOrStderr(),
 							"Warning: effect %q not found on %s, skipping\n", settings.Effect, channelName(ch, target.Display))
 							"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()
 	_, _, _, err := openTarget()
 	if err == nil {
 	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
 // 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
 // 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 {
 	if catalog == nil {
 		return nil, nil, fmt.Errorf("no effect catalog for this keyboard; set an effect by number with `mode <index>`")
 		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
 	known := false
 	for _, ch := range channels {
 	for _, ch := range channels {
 		if _, ok := catalog.EffectID(ch, name); ok {
 		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)
 		return nil, nil, fmt.Errorf("unknown effect: %s", name)
 	}
 	}
 
 
-	explicit := len(channels) == 1
 	var targets []EffectTarget
 	var targets []EffectTarget
 	var skipped []string
 	var skipped []string
 
 

+ 52 - 4
internal/rgb/catalog_test.go

@@ -82,7 +82,7 @@ func TestCatalogDefaultEffectPerChannel(t *testing.T) {
 }
 }
 
 
 func TestResolveEffectWithoutACatalogRefuses(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 {
 	if err == nil {
 		t.Fatal("ResolveEffect(nil, ...) expected an error, got nil")
 		t.Fatal("ResolveEffect(nil, ...) expected an error, got nil")
 	}
 	}
@@ -96,7 +96,7 @@ func TestResolveEffectWithoutACatalogRefuses(t *testing.T) {
 func TestResolveEffectSkipsAChannelThatDoesNotSupportTheName(t *testing.T) {
 func TestResolveEffectSkipsAChannelThatDoesNotSupportTheName(t *testing.T) {
 	catalog := impact80(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 {
 	if err != nil {
 		t.Fatalf("ResolveEffect() error = %v", err)
 		t.Fatalf("ResolveEffect() error = %v", err)
 	}
 	}
@@ -113,7 +113,7 @@ func TestResolveEffectSkipsAChannelThatDoesNotSupportTheName(t *testing.T) {
 func TestResolveEffectRejectsTheOnlyChannelWhenItDoesNotSupportTheName(t *testing.T) {
 func TestResolveEffectRejectsTheOnlyChannelWhenItDoesNotSupportTheName(t *testing.T) {
 	catalog := impact80(t)
 	catalog := impact80(t)
 
 
-	_, _, err := ResolveEffect(catalog, "light", []via.Channel{via.ChannelRgbMatrix})
+	_, _, err := ResolveEffect(catalog, "light", []via.Channel{via.ChannelRgbMatrix}, true)
 	if err == nil {
 	if err == nil {
 		t.Fatal("ResolveEffect() expected an error, want light rejected on the backlight")
 		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) {
 func TestResolveEffectRejectsANameNoChannelKnows(t *testing.T) {
 	catalog := impact80(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")
 		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) {
 func TestDetectChannelsReportsNoChannelsWhenNoneArePresent(t *testing.T) {
 	transport := &fakeTransport{queue: probeQueue()}
 	transport := &fakeTransport{queue: probeQueue()}
 	protocol := Protocol{handle: transport}
 	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)
 		return nil, fmt.Errorf("short response: got %d bytes, want 32", n)
 	}
 	}
 	if buf[0] == byte(Unhandled) {
 	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
 		return nil, errUnhandled
 	}
 	}
 	if buf[0] != byte(command) {
 	if buf[0] != byte(command) {