Pārlūkot izejas kodu

address detected channels instead of assumed zones

Commands no longer assume three zones. They resolve --zone against the
channels the keyboard reports, and print the board's display name where
keyboards.json supplies one.

keyboards.json gains the Impact 80's channel names; without them the
board would have lost logo and side, because the QMK subsystem names are
rgblight, rgb_matrix and audio.

Verified against the unit: info is byte-identical to the pre-change
binary on an Impact 80, and effect --list keeps all 60 zone/effect/id
entries, gaining catalog, channel and subsystem.
Paul Klumpp 1 nedēļu atpakaļ
vecāks
revīzija
08ebaaeae4

+ 2 - 7
cmd/qmk-rgb-tool/brightness.go

@@ -19,18 +19,13 @@ func NewBrightnessCmd() *cobra.Command {
 				return err
 			}
 
-			zones, err := selectedZones()
-			if err != nil {
-				return err
-			}
-
-			proto, err := openRGBProtocol()
+			proto, target, channels, err := openTarget()
 			if err != nil {
 				return err
 			}
 			defer proto.Close()
 
-			results, err := setBrightnessVerified(proto, zones, val)
+			results, err := setBrightnessVerified(proto, channels, target.Display, val)
 			if err != nil {
 				return fmt.Errorf("set brightness: %w", err)
 			}

+ 4 - 14
cmd/qmk-rgb-tool/brightness_summary_test.go

@@ -12,15 +12,10 @@ func runBrightness(t *testing.T, applied map[via.Channel]uint8, zoneFlag string,
 	t.Helper()
 
 	proto := &verifyingProtocol{applied: applied}
-
-	originalOpen := openRGBProtocol
 	originalZone := targetZone
-	t.Cleanup(func() {
-		openRGBProtocol = originalOpen
-		targetZone = originalZone
-	})
-	openRGBProtocol = func() (rgbProtocol, error) { return proto, nil }
+	t.Cleanup(func() { targetZone = originalZone })
 	targetZone = zoneFlag
+	t.Cleanup(impact80Target(t, proto))
 
 	var out, errOut bytes.Buffer
 	cmd := NewBrightnessCmd()
@@ -84,15 +79,10 @@ func TestBrightnessSummarisesASingleMismatchingZone(t *testing.T) {
 // The summary must not invent zones the user did not select.
 func TestBrightnessSummaryNamesOnlySelectedZones(t *testing.T) {
 	proto := &verifyingProtocol{applied: map[via.Channel]uint8{via.ChannelAudio: 160}}
-
-	originalOpen := openRGBProtocol
 	originalZone := targetZone
-	t.Cleanup(func() {
-		openRGBProtocol = originalOpen
-		targetZone = originalZone
-	})
-	openRGBProtocol = func() (rgbProtocol, error) { return proto, nil }
+	t.Cleanup(func() { targetZone = originalZone })
 	targetZone = "side"
+	t.Cleanup(stubOpenTarget(t, proto, impact80Display(), impact80Channels(), 0x36B0, 0x309F))
 
 	var out bytes.Buffer
 	cmd := NewBrightnessCmd()

+ 34 - 22
cmd/qmk-rgb-tool/brightness_verify_test.go

@@ -6,7 +6,6 @@ import (
 	"strings"
 	"testing"
 
-	intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
 	"netdome.biz/paul/qmk-rgb/internal/via"
 )
 
@@ -15,10 +14,30 @@ import (
 type verifyingProtocol struct {
 	reports  []commandReport
 	applied  map[via.Channel]uint8
+	channels []via.Channel
 	getErr   error
 	getCalls int
 }
 
+// impact80Display is the channel naming the Impact 80's keyboards.json entry
+// supplies, so summary lines name logo, backlight and side.
+func impact80Display() map[uint16]string {
+	return map[uint16]string{2: "logo", 3: "backlight", 4: "side"}
+}
+
+func impact80Channels() []via.Channel {
+	return []via.Channel{via.ChannelRgblight, via.ChannelRgbMatrix, via.ChannelAudio}
+}
+
+// DetectChannels reports the three channels of the board under test unless a
+// test asks for a different set.
+func (f *verifyingProtocol) DetectChannels() ([]via.Channel, error) {
+	if f.channels == nil {
+		return []via.Channel{via.ChannelRgblight, via.ChannelRgbMatrix, via.ChannelAudio}, nil
+	}
+	return f.channels, nil
+}
+
 func (f *verifyingProtocol) SetValue(channel via.Channel, param, value uint8) error {
 	f.reports = append(f.reports, commandReport{channel: channel, param: param, value: value})
 	return nil
@@ -61,8 +80,8 @@ func TestSetBrightnessVerifiedReportsAppliedValue(t *testing.T) {
 				},
 			}
 
-			zones := []intrgb.Zone{intrgb.ZoneLogo}
-			results, err := setBrightnessVerified(proto, zones, tt.requested)
+			zones := []via.Channel{via.ChannelRgblight}
+			results, err := setBrightnessVerified(proto, zones, impact80Display(), tt.requested)
 			if err != nil {
 				t.Fatalf("setBrightnessVerified() error = %v", err)
 			}
@@ -71,8 +90,8 @@ func TestSetBrightnessVerifiedReportsAppliedValue(t *testing.T) {
 			}
 
 			got := results[0]
-			if got.Zone != intrgb.ZoneLogo {
-				t.Errorf("results[0].Zone = %q, want %q", got.Zone, intrgb.ZoneLogo)
+			if got.Name != "logo" {
+				t.Errorf("results[0].Name = %q, want %q", got.Name, "logo")
 			}
 			if got.Requested != tt.requested {
 				t.Errorf("results[0].Requested = %d, want %d", got.Requested, tt.requested)
@@ -97,7 +116,7 @@ func TestSetBrightnessVerifiedReadsBackEveryZone(t *testing.T) {
 		},
 	}
 
-	results, err := setBrightnessVerified(proto, intrgb.AllZones(), 200)
+	results, err := setBrightnessVerified(proto, impact80Channels(), impact80Display(), 200)
 	if err != nil {
 		t.Fatalf("setBrightnessVerified() error = %v", err)
 	}
@@ -113,17 +132,17 @@ func TestSetBrightnessVerifiedReadsBackEveryZone(t *testing.T) {
 		t.Errorf("unexpected applied value %d", got)
 	}
 
-	want := map[intrgb.Zone]uint8{
-		intrgb.ZoneLogo:      160,
-		intrgb.ZoneBacklight: 255,
-		intrgb.ZoneSide:      160,
+	want := map[string]uint8{
+		"logo":      160,
+		"backlight": 255,
+		"side":      160,
 	}
 	for _, r := range results {
-		if r.Applied != want[r.Zone] {
-			t.Errorf("%s applied = %d, want %d", r.Zone, r.Applied, want[r.Zone])
+		if r.Applied != want[r.Name] {
+			t.Errorf("%s applied = %d, want %d", r.Name, r.Applied, want[r.Name])
 		}
 		if !r.Mismatch() {
-			t.Errorf("%s: Mismatch() = false, want true (requested 200)", r.Zone)
+			t.Errorf("%s: Mismatch() = false, want true (requested 200)", r.Name)
 		}
 	}
 }
@@ -132,7 +151,7 @@ func TestSetBrightnessVerifiedReadsBackEveryZone(t *testing.T) {
 func TestSetBrightnessVerifiedPropagatesReadError(t *testing.T) {
 	proto := &verifyingProtocol{getErr: errors.New("read timeout")}
 
-	if _, err := setBrightnessVerified(proto, []intrgb.Zone{intrgb.ZoneLogo}, 100); err == nil {
+	if _, err := setBrightnessVerified(proto, []via.Channel{via.ChannelRgblight}, impact80Display(), 100); err == nil {
 		t.Fatal("setBrightnessVerified() expected read error, got nil")
 	}
 }
@@ -140,14 +159,7 @@ func TestSetBrightnessVerifiedPropagatesReadError(t *testing.T) {
 func TestBrightnessCommandStaysQuietWhenApplied(t *testing.T) {
 	proto := &verifyingProtocol{applied: map[via.Channel]uint8{via.ChannelRgblight: 200}}
 
-	originalOpen := openRGBProtocol
-	originalZone := targetZone
-	t.Cleanup(func() {
-		openRGBProtocol = originalOpen
-		targetZone = originalZone
-	})
-	openRGBProtocol = func() (rgbProtocol, error) { return proto, nil }
-	targetZone = "logo"
+	t.Cleanup(impact80Target(t, proto))
 
 	var out, errOut bytes.Buffer
 	cmd := NewBrightnessCmd()

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

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"bytes"
 	"strings"
 	"testing"
 
@@ -137,3 +138,80 @@ func TestPrepareTargetUsesTheSelectedKeyboard(t *testing.T) {
 		t.Errorf("display names = %v, want the Impact 80's", got.Display)
 	}
 }
+
+// A board renamed after a profile was written leaves keys that resolve to
+// nothing. Each one must be reported by name, and nothing may be written.
+func TestLoadWarnsForEveryUnresolvableZoneKey(t *testing.T) {
+	dir := t.TempDir()
+	profile := Profile{
+		Name:    "renamed",
+		Version: 1,
+		Zones: map[string]*ZoneSettings{
+			"old-logo":      {Enabled: true, Effect: "light", Brightness: 100, Speed: 1, Color: "00ff"},
+			"old-backlight": {Enabled: true, Effect: "wave", Brightness: 100, Speed: 1, Color: "00ff"},
+		},
+	}
+	originalDir := profilesDir
+	t.Cleanup(func() { profilesDir = originalDir })
+	profilesDir = dir
+
+	if err := profile.Save(); err != nil {
+		t.Fatalf("save profile: %v", err)
+	}
+	proto := &verifyingProtocol{}
+	t.Cleanup(stubTargetForProfileTest(t, proto, "", impact80Display()))
+
+	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)
+	}
+
+	for _, want := range []string{"old-logo", "old-backlight"} {
+		if !strings.Contains(stderr.String(), want) {
+			t.Errorf("stderr = %q, want it to name the unresolved key %q", stderr.String(), want)
+		}
+	}
+	if len(proto.reports) != 0 {
+		t.Errorf("reports = %v, want nothing written for unresolvable keys", proto.reports)
+	}
+}
+
+// stubTargetForProfileTest points the profile commands at one protocol, one
+// profile directory and one --zone value, and returns the restore function.
+func stubTargetForProfileTest(t *testing.T, proto rgbProtocol, zoneFlag string, display map[uint16]string) func() {
+	t.Helper()
+
+	originalTarget := openTarget
+	originalZone := targetZone
+
+	openTarget = func() (rgbProtocol, targetDeviceData, []via.Channel, error) {
+		// The board identity matters: the catalog is selected by VID/PID, and a
+		// profile that names an effect needs one to resolve it against.
+		target := targetDeviceData{
+			Device:  intdevice.Device{VendorID: 0x36B0, ProductID: 0x309F},
+			Display: display,
+		}
+		requested, err := resolveZoneName(zoneFlag, display)
+		if err != nil {
+			return nil, target, nil, err
+		}
+		target.Requested = requested
+
+		resolved, err := resolveChannels(proto, target)
+		if err != nil {
+			return nil, target, nil, err
+		}
+		return proto, target, resolved, nil
+	}
+	targetZone = zoneFlag
+
+	return func() {
+		openTarget = originalTarget
+		targetZone = originalZone
+	}
+}

+ 4 - 9
cmd/qmk-rgb-tool/color.go

@@ -31,18 +31,13 @@ func NewColorCmd() *cobra.Command {
 				return err
 			}
 
-			zones, err := selectedZones()
-			if err != nil {
-				return err
-			}
-
-			proto, err := openRGBProtocol()
+			proto, target, channels, err := openTarget()
 			if err != nil {
 				return err
 			}
 			defer proto.Close()
 
-			colors, err := setColorVerified(proto, zones, spec.H, spec.S)
+			colors, err := setColorVerified(proto, channels, target.Display, spec.H, spec.S)
 			if err != nil {
 				return fmt.Errorf("set color: %w", err)
 			}
@@ -52,7 +47,7 @@ func NewColorCmd() *cobra.Command {
 				return nil
 			}
 
-			brightness, err := setBrightnessVerified(proto, zones, spec.V)
+			brightness, err := setBrightnessVerified(proto, channels, target.Display, spec.V)
 			if err != nil {
 				return fmt.Errorf("set brightness from %s: %w", args[0], err)
 			}
@@ -83,7 +78,7 @@ func printColorResult(cmd *cobra.Command, colors []colorResult, brightness []zon
 	var b strings.Builder
 	b.WriteString("Color")
 	for i, c := range colors {
-		fmt.Fprintf(&b, " %s hue %d sat %d", c.Zone, c.Hue, c.Saturation)
+		fmt.Fprintf(&b, " %s hue %d sat %d", c.Name, c.Hue, c.Saturation)
 		if i < len(brightness) {
 			fmt.Fprintf(&b, " brightness %d", brightness[i].Applied)
 		}

+ 48 - 18
cmd/qmk-rgb-tool/color_verify_test.go

@@ -19,6 +19,7 @@ type colorProtocol struct {
 	appliedColor     map[via.Channel][2]uint8
 	appliedBright    map[via.Channel]uint8
 	shortColorRead   bool
+	channels         []via.Channel
 	setColorErr      error
 	getErr           error
 }
@@ -60,6 +61,13 @@ func (p *colorProtocol) GetValue(channel via.Channel, param uint8) ([]byte, erro
 
 func (p *colorProtocol) Close() error { return nil }
 
+func (p *colorProtocol) DetectChannels() ([]via.Channel, error) {
+	if p.channels == nil {
+		return []via.Channel{via.ChannelRgblight, via.ChannelRgbMatrix, via.ChannelAudio}, nil
+	}
+	return p.channels, nil
+}
+
 // A color is two bytes on the wire, so the read-back has to look at both. A
 // one byte answer would leave the saturation unverified.
 func TestSetColorVerifiedReadsBackHueAndSaturation(t *testing.T) {
@@ -67,7 +75,7 @@ func TestSetColorVerifiedReadsBackHueAndSaturation(t *testing.T) {
 		appliedColor: map[via.Channel][2]uint8{via.ChannelRgblight: {99, 255}},
 	}
 
-	results, err := setColorVerified(proto, []intrgb.Zone{intrgb.ZoneLogo}, 85, 255)
+	results, err := setColorVerified(proto, []via.Channel{via.ChannelRgblight}, impact80Display(), 85, 255)
 	if err != nil {
 		t.Fatalf("setColorVerified() error = %v", err)
 	}
@@ -76,8 +84,8 @@ func TestSetColorVerifiedReadsBackHueAndSaturation(t *testing.T) {
 	}
 
 	got := results[0]
-	if got.Zone != intrgb.ZoneLogo {
-		t.Errorf("results[0].Zone = %q, want %q", got.Zone, intrgb.ZoneLogo)
+	if got.Name != "logo" {
+		t.Errorf("results[0].Name = %q, want %q", got.Name, "logo")
 	}
 	if got.RequestedHue != 85 || got.RequestedSaturation != 255 {
 		t.Errorf("requested = hue %d sat %d, want hue 85 sat 255", got.RequestedHue, got.RequestedSaturation)
@@ -95,7 +103,7 @@ func TestSetColorVerifiedReportsNoMismatchWhenKeyboardAppliesTheRequest(t *testi
 		appliedColor: map[via.Channel][2]uint8{via.ChannelRgblight: {0, 255}},
 	}
 
-	results, err := setColorVerified(proto, []intrgb.Zone{intrgb.ZoneLogo}, 0, 255)
+	results, err := setColorVerified(proto, []via.Channel{via.ChannelRgblight}, impact80Display(), 0, 255)
 	if err != nil {
 		t.Fatalf("setColorVerified() error = %v", err)
 	}
@@ -109,7 +117,7 @@ func TestSetColorVerifiedReportsNoMismatchWhenKeyboardAppliesTheRequest(t *testi
 func TestSetColorVerifiedRejectsATruncatedColorRead(t *testing.T) {
 	proto := &colorProtocol{shortColorRead: true}
 
-	_, err := setColorVerified(proto, []intrgb.Zone{intrgb.ZoneLogo}, 0, 255)
+	_, err := setColorVerified(proto, []via.Channel{via.ChannelRgblight}, impact80Display(), 0, 255)
 	if err == nil {
 		t.Fatal("setColorVerified() expected an error, got nil")
 	}
@@ -121,7 +129,7 @@ func TestSetColorVerifiedRejectsATruncatedColorRead(t *testing.T) {
 func TestSetColorVerifiedPropagatesWriteErrors(t *testing.T) {
 	proto := &colorProtocol{setColorErr: errors.New("write refused")}
 
-	_, err := setColorVerified(proto, []intrgb.Zone{intrgb.ZoneLogo}, 0, 255)
+	_, err := setColorVerified(proto, []via.Channel{via.ChannelRgblight}, impact80Display(), 0, 255)
 	if err == nil {
 		t.Fatal("setColorVerified() expected an error, got nil")
 	}
@@ -133,14 +141,7 @@ func TestSetColorVerifiedPropagatesWriteErrors(t *testing.T) {
 func runColor(t *testing.T, proto *colorProtocol, zoneFlag string, args ...string) (stdout, stderr string, err error) {
 	t.Helper()
 
-	originalOpen := openRGBProtocol
-	originalZone := targetZone
-	t.Cleanup(func() {
-		openRGBProtocol = originalOpen
-		targetZone = originalZone
-	})
-	openRGBProtocol = func() (rgbProtocol, error) { return proto, nil }
-	targetZone = zoneFlag
+	t.Cleanup(stubColorTarget(t, proto, zoneFlag))
 
 	var out, errOut bytes.Buffer
 	cmd := NewColorCmd()
@@ -254,15 +255,15 @@ func TestColorRejectsAnUnknownNotationWithoutOpeningTheDevice(t *testing.T) {
 	proto := &colorProtocol{}
 
 	opened := false
-	originalOpen := openRGBProtocol
+	originalTarget := openTarget
 	originalZone := targetZone
 	t.Cleanup(func() {
-		openRGBProtocol = originalOpen
+		openTarget = originalTarget
 		targetZone = originalZone
 	})
-	openRGBProtocol = func() (rgbProtocol, error) {
+	openTarget = func() (rgbProtocol, targetDeviceData, []via.Channel, error) {
 		opened = true
-		return proto, nil
+		return proto, targetDeviceData{}, nil, nil
 	}
 	targetZone = ""
 
@@ -283,3 +284,32 @@ func TestColorRejectsAnUnknownNotationWithoutOpeningTheDevice(t *testing.T) {
 		t.Errorf("color writes = %v, want none", proto.colorWrites)
 	}
 }
+
+// stubColorTarget points the color command at one protocol and one --zone value.
+func stubColorTarget(t *testing.T, proto *colorProtocol, zoneFlag string) func() {
+	t.Helper()
+
+	originalTarget := openTarget
+	originalZone := targetZone
+
+	openTarget = func() (rgbProtocol, targetDeviceData, []via.Channel, error) {
+		target := targetDeviceData{Display: impact80Display()}
+		requested, err := resolveZoneName(zoneFlag, impact80Display())
+		if err != nil {
+			return nil, target, nil, err
+		}
+		target.Requested = requested
+
+		resolved, err := resolveChannels(proto, target)
+		if err != nil {
+			return nil, target, nil, err
+		}
+		return proto, target, resolved, nil
+	}
+	targetZone = zoneFlag
+
+	return func() {
+		openTarget = originalTarget
+		targetZone = originalZone
+	}
+}

+ 32 - 19
cmd/qmk-rgb-tool/commands_test.go

@@ -18,8 +18,18 @@ type commandReport struct {
 }
 
 type fakeZoneProtocol struct {
-	reports []commandReport
-	failAt  int
+	reports  []commandReport
+	failAt   int
+	channels []via.Channel
+}
+
+// DetectChannels reports the board's three channels unless a test asks for a
+// different set.
+func (f *fakeZoneProtocol) DetectChannels() ([]via.Channel, error) {
+	if f.channels == nil {
+		return impact80Channels(), nil
+	}
+	return f.channels, nil
 }
 
 func (f *fakeZoneProtocol) SetValue(channel via.Channel, param, value uint8) error {
@@ -56,14 +66,14 @@ func (f *fakeZoneProtocol) Close() error {
 }
 
 func TestSelectedChannelOperations(t *testing.T) {
-	defaultZones := []intrgb.Zone{intrgb.ZoneLogo, intrgb.ZoneBacklight, intrgb.ZoneSide}
-	sideZones := []intrgb.Zone{intrgb.ZoneSide}
+	defaultZones := []via.Channel{via.ChannelRgblight, via.ChannelRgbMatrix, via.ChannelAudio}
+	sideZones := []via.Channel{via.ChannelAudio}
 
 	operations := []struct {
 		name      string
-		zones     []intrgb.Zone
-		sideZones []intrgb.Zone
-		run       func(zoneProtocol, []intrgb.Zone) error
+		zones     []via.Channel
+		sideZones []via.Channel
+		run       func(zoneProtocol, []via.Channel) error
 		want      []commandReport
 		wantSide  []commandReport
 	}{
@@ -71,8 +81,8 @@ func TestSelectedChannelOperations(t *testing.T) {
 			name:      "brightness",
 			zones:     defaultZones,
 			sideZones: sideZones,
-			run: func(protocol zoneProtocol, zones []intrgb.Zone) error {
-				return setBrightnessOnZones(protocol, zones, 160)
+			run: func(protocol zoneProtocol, zones []via.Channel) error {
+				return setBrightnessOnChannels(protocol, zones, 160)
 			},
 			want: []commandReport{
 				{channel: 2, param: 1, value: 160},
@@ -85,8 +95,8 @@ func TestSelectedChannelOperations(t *testing.T) {
 			name:      "speed",
 			zones:     defaultZones,
 			sideZones: sideZones,
-			run: func(protocol zoneProtocol, zones []intrgb.Zone) error {
-				return setSpeedOnZones(protocol, zones, 2)
+			run: func(protocol zoneProtocol, zones []via.Channel) error {
+				return setSpeedChannels(protocol, zones, 2)
 			},
 			want: []commandReport{
 				{channel: 2, param: 3, value: 2},
@@ -99,8 +109,8 @@ func TestSelectedChannelOperations(t *testing.T) {
 			name:      "color",
 			zones:     defaultZones,
 			sideZones: sideZones,
-			run: func(protocol zoneProtocol, zones []intrgb.Zone) error {
-				return setColorOnZones(protocol, zones, 85, 255)
+			run: func(protocol zoneProtocol, zones []via.Channel) error {
+				return setColorOnChannels(protocol, zones, 85, 255)
 			},
 			want: []commandReport{
 				{channel: 2, param: 4, hue: 85, saturation: 255},
@@ -113,7 +123,7 @@ func TestSelectedChannelOperations(t *testing.T) {
 			name:      "disable",
 			zones:     defaultZones,
 			sideZones: sideZones,
-			run:       disableLightingOnZones,
+			run:       disableLightingOnChannels,
 			want: []commandReport{
 				{channel: 2, param: 2, value: 0},
 				{channel: 2, param: 1, value: 0},
@@ -131,7 +141,10 @@ func TestSelectedChannelOperations(t *testing.T) {
 			name:      "enable",
 			zones:     defaultZones,
 			sideZones: sideZones,
-			run:       enableLightingOnZones,
+			run: func(protocol zoneProtocol, channels []via.Channel) error {
+				catalog, _ := intrgb.CatalogFor(0x36B0, 0x309F)
+				return enableLightingOnChannels(protocol, channels, catalog)
+			},
 			want: []commandReport{
 				{channel: 2, param: 2, value: 4},
 				{channel: 2, param: 1, value: 160},
@@ -149,8 +162,8 @@ func TestSelectedChannelOperations(t *testing.T) {
 			name:      "mode",
 			zones:     defaultZones,
 			sideZones: sideZones,
-			run: func(protocol zoneProtocol, zones []intrgb.Zone) error {
-				return setModeOnZones(protocol, zones, 17)
+			run: func(protocol zoneProtocol, zones []via.Channel) error {
+				return setModeOnChannels(protocol, zones, 17)
 			},
 			want: []commandReport{
 				{channel: 2, param: 2, value: 17},
@@ -185,9 +198,9 @@ func TestSelectedChannelOperations(t *testing.T) {
 
 func TestSelectedChannelStopsOnFirstError(t *testing.T) {
 	protocol := &fakeZoneProtocol{failAt: 1}
-	err := setBrightnessOnZones(protocol, intrgb.AllZones(), 160)
+	err := setBrightnessOnChannels(protocol, impact80Channels(), 160)
 	if err == nil {
-		t.Fatal("setBrightnessOnZones() expected error, got nil")
+		t.Fatal("setBrightnessOnChannels() expected error, got nil")
 	}
 	if len(protocol.reports) != 1 {
 		t.Errorf("reports = %v, want one report before failure", protocol.reports)

+ 7 - 15
cmd/qmk-rgb-tool/disable.go

@@ -2,7 +2,6 @@ package main
 
 import (
 	"fmt"
-	"os"
 
 	"github.com/spf13/cobra"
 )
@@ -12,26 +11,19 @@ func NewDisableCmd() *cobra.Command {
 		Use:   "disable",
 		Short: "Disable RGB lighting",
 		Args:  cobra.NoArgs,
-		Run: func(cmd *cobra.Command, args []string) {
-			zones, err := selectedZones()
+		RunE: func(cmd *cobra.Command, args []string) error {
+			proto, _, channels, err := openTarget()
 			if err != nil {
-				fmt.Fprintf(os.Stderr, "Error: %v\n", err)
-				os.Exit(1)
-			}
-
-			proto, err := OpenDevice()
-			if err != nil {
-				fmt.Fprintf(os.Stderr, "Error: %v\n", err)
-				os.Exit(1)
+				return err
 			}
 			defer proto.Close()
 
-			if err := disableLightingOnZones(proto, zones); err != nil {
-				fmt.Fprintf(os.Stderr, "Error disabling RGB: %v\n", err)
-				os.Exit(1)
+			if err := disableLightingOnChannels(proto, channels); err != nil {
+				return fmt.Errorf("disable RGB: %w", err)
 			}
 
-			fmt.Println("RGB disabled")
+			fmt.Fprintln(cmd.OutOrStdout(), "RGB disabled")
+			return nil
 		},
 	}
 }

+ 43 - 31
cmd/qmk-rgb-tool/effect.go

@@ -11,19 +11,23 @@ import (
 
 var listEffects bool
 
-func resolveEffectTargets(name string, zones []intrgb.Zone) ([]intrgb.EffectTarget, []intrgb.Zone, error) {
+// resolveEffectTargets turns a name into one target per channel, rewriting the
+// legacy spelling the Impact 80's own keycodes use.
+func resolveEffectTargets(catalog *intrgb.Catalog, name string, channels []via.Channel) ([]intrgb.EffectTarget, []string, error) {
 	if name == "static" {
 		name = "solid"
 	}
-	return intrgb.ResolveEffect(name, zones)
+	return intrgb.ResolveEffect(catalog, name, channels)
 }
 
 func NewEffectCmd() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "effect [name]",
 		Short: "Set or list RGB effects",
-		Long:  "Set the RGB lighting effect on the connected keyboard. Without an argument, lists all effects.",
-		Args:  cobra.MaximumNArgs(1),
+		Long: "Set the RGB lighting effect on the connected keyboard. Without an argument, lists all effects.\n" +
+			"Effect names come from a per-board catalog; a keyboard without one has no names\n" +
+			"and is driven with `mode <index>` instead.",
+		Args: cobra.MaximumNArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
 			if listEffects || len(args) == 0 {
 				return listAllEffects(cmd)
@@ -36,23 +40,38 @@ func NewEffectCmd() *cobra.Command {
 }
 
 func listAllEffects(cmd *cobra.Command) error {
+	proto, target, channels, err := openTarget()
+	if err != nil {
+		return err
+	}
+	defer proto.Close()
+
+	catalog, _ := intrgb.CatalogFor(target.Device.VendorID, target.Device.ProductID)
+	return printEffectList(cmd, catalog, channels, target.Display)
+}
+
+func printEffectList(cmd *cobra.Command, catalog *intrgb.Catalog, channels []via.Channel, display map[uint16]string) error {
 	type ZoneEffectList struct {
-		Zone   string `json:"zone"`
-		Effect string `json:"effect"`
-		ID     uint8  `json:"id"`
+		Zone      string `json:"zone"`
+		Channel   uint8  `json:"channel"`
+		Subsystem string `json:"subsystem"`
+		Effect    string `json:"effect"`
+		ID        uint8  `json:"id"`
 	}
 	type EffectList struct {
-		Zones []ZoneEffectList `json:"zones"`
+		Catalog string           `json:"catalog"`
+		Zones   []ZoneEffectList `json:"zones"`
 	}
 
-	var list EffectList
-	for _, zone := range intrgb.AllZones() {
-		names := intrgb.ZoneEffects(zone)
-		for id, name := range names {
+	list := EffectList{Catalog: catalog.Name(), Zones: []ZoneEffectList{}}
+	for _, ch := range channels {
+		for id, name := range catalog.Names(ch) {
 			list.Zones = append(list.Zones, ZoneEffectList{
-				Zone:   string(zone),
-				Effect: name,
-				ID:     uint8(id),
+				Zone:      channelName(ch, display),
+				Channel:   uint8(ch),
+				Subsystem: ch.Subsystem(),
+				Effect:    name,
+				ID:        uint8(id),
 			})
 		}
 	}
@@ -61,31 +80,24 @@ func listAllEffects(cmd *cobra.Command) error {
 }
 
 func runEffectSet(cmd *cobra.Command, args []string) error {
-	zones, err := selectedZones()
+	proto, target, channels, err := openTarget()
 	if err != nil {
 		return err
 	}
-	targets, skipped, err := resolveEffectTargets(args[0], zones)
+	defer proto.Close()
+
+	catalog, _ := intrgb.CatalogFor(target.Device.VendorID, target.Device.ProductID)
+	targets, skipped, err := resolveEffectTargets(catalog, args[0], channels)
 	if err != nil {
 		return err
 	}
 	if len(skipped) > 0 {
-		skippedNames := make([]string, len(skipped))
-		for i, zone := range skipped {
-			skippedNames[i] = string(zone)
-		}
-		fmt.Fprintf(cmd.ErrOrStderr(), "Warning: effect not supported on zone(s): %s\n", strings.Join(skippedNames, ", "))
+		fmt.Fprintf(cmd.ErrOrStderr(), "Warning: effect not supported on channel(s): %s\n", strings.Join(skipped, ", "))
 	}
 
-	proto, err := openRGBProtocol()
-	if err != nil {
-		return err
-	}
-	defer proto.Close()
-
-	for _, target := range targets {
-		if err := proto.SetValue(via.Channel(target.Zone.Channel()), uint8(intrgb.EffectID), target.ID); err != nil {
-			return fmt.Errorf("set effect on %s: %w", target.Zone, err)
+	for _, t := range targets {
+		if err := proto.SetValue(t.Channel, uint8(intrgb.EffectID), t.ID); err != nil {
+			return fmt.Errorf("set effect on %s: %w", channelName(t.Channel, target.Display), err)
 		}
 	}
 

+ 20 - 8
cmd/qmk-rgb-tool/effect_list_test.go

@@ -20,27 +20,39 @@ func TestEffectListEmitsAnObjectNotABareArray(t *testing.T) {
 		t.Fatalf("effect --list returned error: %v", err)
 	}
 
-	var parsed map[string][]struct {
-		Zone   string `json:"zone"`
-		Effect string `json:"effect"`
-		ID     int    `json:"id"`
+	var parsed struct {
+		Catalog string `json:"catalog"`
+		Zones   []struct {
+			Zone      string `json:"zone"`
+			Channel   int    `json:"channel"`
+			Subsystem string `json:"subsystem"`
+			Effect    string `json:"effect"`
+			ID        int    `json:"id"`
+		} `json:"zones"`
 	}
 	if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
 		t.Fatalf("effect --list output is not a JSON object: %v (output %q)", err, out.String()[:min(80, out.Len())])
 	}
+	if parsed.Catalog != "impact80" {
+		t.Errorf("catalog = %q, want %q", parsed.Catalog, "impact80")
+	}
 
-	zones, ok := parsed["zones"]
-	if !ok {
-		t.Fatalf("output = %q, want a top-level \"zones\" key", out.String()[:min(80, out.Len())])
+	zones := parsed.Zones
+	if len(zones) == 0 {
+		t.Fatal("zones is empty, want the full catalog")
 	}
 	if len(zones) == 0 {
 		t.Fatal("zones is empty, want the full catalog")
 	}
 
-	// The catalog itself must not have changed shape.
+	// The catalog itself must not have lost a field, and gained the channel
+	// identity a consumer needs to address the entry it finds.
 	if zones[0].Zone == "" || zones[0].Effect == "" {
 		t.Errorf("zones[0] = %+v, want zone, effect and id populated", zones[0])
 	}
+	if zones[0].Channel == 0 || zones[0].Subsystem == "" {
+		t.Errorf("zones[0] = %+v, want channel and subsystem populated", zones[0])
+	}
 }
 
 func min(a, b int) int {

+ 31 - 26
cmd/qmk-rgb-tool/effect_test.go

@@ -7,32 +7,43 @@ import (
 	"testing"
 
 	intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
+	"netdome.biz/paul/qmk-rgb/internal/via"
 )
 
+// impact80Catalog is the catalog of the board these cases describe.
+func impact80Catalog(t *testing.T) *intrgb.Catalog {
+	t.Helper()
+	catalog, ok := intrgb.CatalogFor(0x36B0, 0x309F)
+	if !ok {
+		t.Fatal("CatalogFor(0x36b0, 0x309f) = not found")
+	}
+	return catalog
+}
+
 func TestResolveEffectTargetsBacklightOnly(t *testing.T) {
-	targets, skipped, err := resolveEffectTargets("rainbow_moving_chevron", intrgb.AllZones())
+	targets, skipped, err := resolveEffectTargets(impact80Catalog(t), "rainbow_moving_chevron", impact80Channels())
 	if err != nil {
 		t.Fatalf("resolveEffectTargets() unexpected error: %v", err)
 	}
-	wantTargets := []intrgb.EffectTarget{{Zone: intrgb.ZoneBacklight, ID: 17}}
+	wantTargets := []intrgb.EffectTarget{{Channel: via.ChannelRgbMatrix, ID: 17}}
 	if !reflect.DeepEqual(targets, wantTargets) {
 		t.Errorf("resolveEffectTargets() targets = %v, want %v", targets, wantTargets)
 	}
-	wantSkipped := []intrgb.Zone{intrgb.ZoneLogo, intrgb.ZoneSide}
+	wantSkipped := []string{"rgblight", "audio"}
 	if !reflect.DeepEqual(skipped, wantSkipped) {
 		t.Errorf("resolveEffectTargets() skipped = %v, want %v", skipped, wantSkipped)
 	}
 }
 
 func TestResolveEffectTargetsBreathing(t *testing.T) {
-	targets, skipped, err := resolveEffectTargets("breathing", intrgb.AllZones())
+	targets, skipped, err := resolveEffectTargets(impact80Catalog(t), "breathing", impact80Channels())
 	if err != nil {
 		t.Fatalf("resolveEffectTargets() unexpected error: %v", err)
 	}
 	wantTargets := []intrgb.EffectTarget{
-		{Zone: intrgb.ZoneLogo, ID: 4},
-		{Zone: intrgb.ZoneBacklight, ID: 5},
-		{Zone: intrgb.ZoneSide, ID: 4},
+		{Channel: via.ChannelRgblight, ID: 4},
+		{Channel: via.ChannelRgbMatrix, ID: 5},
+		{Channel: via.ChannelAudio, ID: 4},
 	}
 	if !reflect.DeepEqual(targets, wantTargets) {
 		t.Errorf("resolveEffectTargets() targets = %v, want %v", targets, wantTargets)
@@ -45,15 +56,15 @@ func TestResolveEffectTargetsBreathing(t *testing.T) {
 func TestResolveEffectTargetsExplicitUnsupported(t *testing.T) {
 	cases := []struct {
 		name  string
-		zones []intrgb.Zone
+		zones []via.Channel
 	}{
-		{"rainbow_moving_chevron", []intrgb.Zone{intrgb.ZoneLogo}},
-		{"rainbow_wave", []intrgb.Zone{intrgb.ZoneBacklight}},
-		{"splash", []intrgb.Zone{intrgb.ZoneSide}},
+		{"rainbow_moving_chevron", []via.Channel{via.ChannelRgblight}},
+		{"rainbow_wave", []via.Channel{via.ChannelRgbMatrix}},
+		{"splash", []via.Channel{via.ChannelAudio}},
 	}
 	for _, tc := range cases {
 		t.Run(tc.name+string(tc.zones[0]), func(t *testing.T) {
-			targets, skipped, err := resolveEffectTargets(tc.name, tc.zones)
+			targets, skipped, err := resolveEffectTargets(impact80Catalog(t), tc.name, tc.zones)
 			if err == nil {
 				t.Fatal("resolveEffectTargets() expected error, got nil")
 			}
@@ -68,7 +79,7 @@ func TestResolveEffectTargetsExplicitUnsupported(t *testing.T) {
 }
 
 func TestResolveEffectTargetsUnknownName(t *testing.T) {
-	targets, skipped, err := resolveEffectTargets("not_an_effect", intrgb.AllZones())
+	targets, skipped, err := resolveEffectTargets(impact80Catalog(t), "not_an_effect", impact80Channels())
 	if err == nil {
 		t.Fatal("resolveEffectTargets() expected error, got nil")
 	}
@@ -81,14 +92,14 @@ func TestResolveEffectTargetsUnknownName(t *testing.T) {
 }
 
 func TestResolveEffectTargetsStaticCompatibility(t *testing.T) {
-	targets, skipped, err := resolveEffectTargets("static", intrgb.AllZones())
+	targets, skipped, err := resolveEffectTargets(impact80Catalog(t), "static", impact80Channels())
 	if err != nil {
 		t.Fatalf("resolveEffectTargets() unexpected error: %v", err)
 	}
 	wantTargets := []intrgb.EffectTarget{
-		{Zone: intrgb.ZoneLogo, ID: 5},
-		{Zone: intrgb.ZoneBacklight, ID: 1},
-		{Zone: intrgb.ZoneSide, ID: 5},
+		{Channel: via.ChannelRgblight, ID: 5},
+		{Channel: via.ChannelRgbMatrix, ID: 1},
+		{Channel: via.ChannelAudio, ID: 5},
 	}
 	if !reflect.DeepEqual(targets, wantTargets) {
 		t.Errorf("resolveEffectTargets() targets = %v, want %v", targets, wantTargets)
@@ -100,16 +111,10 @@ func TestResolveEffectTargetsStaticCompatibility(t *testing.T) {
 
 func executeEffectCommand(t *testing.T, protocol *fakeZoneProtocol, target string, args ...string) (string, string, error) {
 	t.Helper()
-	originalOpen := openRGBProtocol
 	originalZone := targetZone
-	openRGBProtocol = func() (rgbProtocol, error) {
-		return protocol, nil
-	}
+	t.Cleanup(func() { targetZone = originalZone })
 	targetZone = target
-	t.Cleanup(func() {
-		openRGBProtocol = originalOpen
-		targetZone = originalZone
-	})
+	t.Cleanup(stubOpenTarget(t, protocol, impact80Display(), impact80Channels(), 0x36B0, 0x309F))
 
 	var stdout bytes.Buffer
 	var stderr bytes.Buffer
@@ -129,7 +134,7 @@ func TestEffectCommandWarnsForSkippedZones(t *testing.T) {
 	if err != nil {
 		t.Fatalf("Execute() unexpected error: %v", err)
 	}
-	if !strings.Contains(stderr, "effect not supported on zone(s): logo, side") {
+	if !strings.Contains(stderr, "effect not supported on channel(s): rgblight, audio") {
 		t.Errorf("stderr = %q, want skipped-zone warning", stderr)
 	}
 	if !strings.Contains(stdout, `Effect set to "rainbow_moving_chevron"`) {

+ 14 - 14
cmd/qmk-rgb-tool/enable.go

@@ -2,9 +2,9 @@ package main
 
 import (
 	"fmt"
-	"os"
 
 	"github.com/spf13/cobra"
+	intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
 )
 
 func NewEnableCmd() *cobra.Command {
@@ -12,26 +12,26 @@ func NewEnableCmd() *cobra.Command {
 		Use:   "enable",
 		Short: "Enable RGB lighting",
 		Args:  cobra.NoArgs,
-		Run: func(cmd *cobra.Command, args []string) {
-			zones, err := selectedZones()
+		RunE: func(cmd *cobra.Command, args []string) error {
+			proto, target, channels, err := openTarget()
 			if err != nil {
-				fmt.Fprintf(os.Stderr, "Error: %v\n", err)
-				os.Exit(1)
+				return err
 			}
+			defer proto.Close()
 
-			proto, err := OpenDevice()
-			if err != nil {
-				fmt.Fprintf(os.Stderr, "Error: %v\n", err)
-				os.Exit(1)
+			catalog, _ := intrgb.CatalogFor(target.Device.VendorID, target.Device.ProductID)
+			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>`")
+				}
 			}
-			defer proto.Close()
 
-			if err := enableLightingOnZones(proto, zones); err != nil {
-				fmt.Fprintf(os.Stderr, "Error enabling RGB: %v\n", err)
-				os.Exit(1)
+			if err := enableLightingOnChannels(proto, channels, catalog); err != nil {
+				return fmt.Errorf("enable RGB: %w", err)
 			}
 
-			fmt.Println("RGB enabled")
+			fmt.Fprintln(cmd.OutOrStdout(), "RGB enabled")
+			return nil
 		},
 	}
 }

+ 20 - 23
cmd/qmk-rgb-tool/info.go

@@ -19,15 +19,15 @@ type infoColor struct {
 }
 
 type zoneInfo struct {
-	Zone       intrgb.Zone `json:"zone"`
-	Channel    uint8       `json:"channel"`
-	Enabled    bool        `json:"enabled"`
-	Effect     string      `json:"effect"`
-	EffectID   uint8       `json:"effectId"`
-	Brightness uint8       `json:"brightness"`
-	Speed      uint8       `json:"speed"`
-	Color      infoColor   `json:"color"`
-	Error      string      `json:"error,omitempty"`
+	Zone       string    `json:"zone"`
+	Channel    uint8     `json:"channel"`
+	Enabled    bool      `json:"enabled"`
+	Effect     string    `json:"effect"`
+	EffectID   uint8     `json:"effectId"`
+	Brightness uint8     `json:"brightness"`
+	Speed      uint8     `json:"speed"`
+	Color      infoColor `json:"color"`
+	Error      string    `json:"error,omitempty"`
 }
 
 type infoOutput struct {
@@ -38,11 +38,11 @@ type infoOutput struct {
 	Zones      []zoneInfo `json:"zones"`
 }
 
-func readInfo(proto infoGetter, zones []intrgb.Zone) (infoOutput, error) {
-	out := infoOutput{Zones: make([]zoneInfo, 0, len(zones))}
+func readInfo(proto infoGetter, channels []via.Channel, display map[uint16]string, catalog *intrgb.Catalog) (infoOutput, error) {
+	out := infoOutput{Zones: make([]zoneInfo, 0, len(channels))}
 	var queryErrors []error
-	for _, zone := range zones {
-		record, err := readZoneInfo(proto, zone)
+	for _, ch := range channels {
+		record, err := readZoneInfo(proto, ch, display, catalog)
 		out.Zones = append(out.Zones, record)
 		if err != nil {
 			queryErrors = append(queryErrors, err)
@@ -58,9 +58,9 @@ func readInfo(proto infoGetter, zones []intrgb.Zone) (infoOutput, error) {
 	return out, errors.Join(queryErrors...)
 }
 
-func readZoneInfo(proto infoGetter, zone intrgb.Zone) (zoneInfo, error) {
-	record := zoneInfo{Zone: zone, Channel: zone.Channel()}
-	channel := via.Channel(zone.Channel())
+func readZoneInfo(proto infoGetter, ch via.Channel, display map[uint16]string, catalog *intrgb.Catalog) (zoneInfo, error) {
+	record := zoneInfo{Zone: channelName(ch, display), Channel: uint8(ch)}
+	channel := ch
 
 	brightness, err := getInfoValue(proto, channel, uint8(intrgb.Brightness), 1)
 	if err != nil {
@@ -75,7 +75,7 @@ func readZoneInfo(proto infoGetter, zone intrgb.Zone) (zoneInfo, error) {
 		return record, err
 	}
 	record.EffectID = effect[0]
-	record.Effect = intrgb.EffectName(zone, record.EffectID)
+	record.Effect = catalog.EffectName(ch, record.EffectID)
 	record.Enabled = lightingEnabled(record.EffectID, record.Brightness)
 
 	speed, err := getInfoValue(proto, channel, uint8(intrgb.Speed), 1)
@@ -111,17 +111,14 @@ func NewInfoCmd() *cobra.Command {
 		Short: "Show current RGB state",
 		Args:  cobra.NoArgs,
 		RunE: func(cmd *cobra.Command, args []string) error {
-			zones, err := selectedZones()
-			if err != nil {
-				return err
-			}
-			proto, err := openRGBProtocol()
+			proto, target, channels, err := openTarget()
 			if err != nil {
 				return err
 			}
 			defer proto.Close()
 
-			out, queryErr := readInfo(proto, zones)
+			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 {
 				return err
 			}

+ 15 - 20
cmd/qmk-rgb-tool/info_test.go

@@ -8,7 +8,6 @@ import (
 	"strings"
 	"testing"
 
-	intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
 	"netdome.biz/paul/qmk-rgb/internal/via"
 )
 
@@ -17,6 +16,11 @@ type infoKey struct {
 	param   uint8
 }
 
+// DetectChannels lets the info command's target seam reuse this fake.
+func (f *fakeInfoProtocol) DetectChannels() ([]via.Channel, error) {
+	return impact80Channels(), nil
+}
+
 type fakeInfoProtocol struct {
 	values map[infoKey][]byte
 	errors map[infoKey]error
@@ -58,9 +62,9 @@ func TestInfoSuccessfulRecord(t *testing.T) {
 		},
 		errors: make(map[infoKey]error),
 	}
-	zones := []intrgb.Zone{intrgb.ZoneBacklight, intrgb.ZoneLogo}
+	zones := []via.Channel{via.ChannelRgbMatrix, via.ChannelRgblight}
 
-	got, err := readInfo(protocol, zones)
+	got, err := readInfo(protocol, zones, impact80Display(), impact80Catalog(t))
 	if err != nil {
 		t.Fatalf("readInfo() unexpected error: %v", err)
 	}
@@ -71,7 +75,7 @@ func TestInfoSuccessfulRecord(t *testing.T) {
 		Speed:      2,
 		Zones: []zoneInfo{
 			{
-				Zone:       intrgb.ZoneBacklight,
+				Zone:       "backlight",
 				Channel:    3,
 				Enabled:    true,
 				Effect:     "rainbow_moving_chevron",
@@ -81,7 +85,7 @@ func TestInfoSuccessfulRecord(t *testing.T) {
 				Color:      infoColor{Hue: 85, Saturation: 255},
 			},
 			{
-				Zone:       intrgb.ZoneLogo,
+				Zone:       "logo",
 				Channel:    2,
 				Enabled:    true,
 				Effect:     "breathing",
@@ -122,9 +126,9 @@ func TestInfoErrorRecord(t *testing.T) {
 			{channel: 4, param: 1}: errors.New("side unavailable"),
 		},
 	}
-	zones := []intrgb.Zone{intrgb.ZoneLogo, intrgb.ZoneSide, intrgb.ZoneBacklight}
+	zones := []via.Channel{via.ChannelRgblight, via.ChannelAudio, via.ChannelRgbMatrix}
 
-	got, err := readInfo(protocol, zones)
+	got, err := readInfo(protocol, zones, impact80Display(), impact80Catalog(t))
 	if err == nil {
 		t.Fatal("readInfo() expected error, got nil")
 	}
@@ -135,7 +139,7 @@ func TestInfoErrorRecord(t *testing.T) {
 		Speed:      3,
 		Zones: []zoneInfo{
 			{
-				Zone:       intrgb.ZoneLogo,
+				Zone:       "logo",
 				Channel:    2,
 				Enabled:    true,
 				Effect:     "breathing",
@@ -145,12 +149,12 @@ func TestInfoErrorRecord(t *testing.T) {
 				Color:      infoColor{Hue: 10, Saturation: 20},
 			},
 			{
-				Zone:    intrgb.ZoneSide,
+				Zone:    "side",
 				Channel: 4,
 				Error:   "side unavailable",
 			},
 			{
-				Zone:       intrgb.ZoneBacklight,
+				Zone:       "backlight",
 				Channel:    3,
 				Enabled:    true,
 				Effect:     "breathing",
@@ -199,16 +203,7 @@ func TestInfoCommandPrintsJSONBeforeReturningError(t *testing.T) {
 			{channel: 4, param: 1}: errors.New("side unavailable"),
 		},
 	}
-	originalOpen := openRGBProtocol
-	originalZone := targetZone
-	openRGBProtocol = func() (rgbProtocol, error) {
-		return protocol, nil
-	}
-	targetZone = ""
-	t.Cleanup(func() {
-		openRGBProtocol = originalOpen
-		targetZone = originalZone
-	})
+	t.Cleanup(stubOpenTarget(t, protocol, impact80Display(), impact80Channels(), 0x36B0, 0x309F))
 
 	var stdout bytes.Buffer
 	var stderr bytes.Buffer

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

@@ -26,8 +26,11 @@ func newRootCommand() *cobra.Command {
 	}
 	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 := selectedZones()
+		_, err := prepareTarget()
 		return err
 	}
 	return cmd

+ 12 - 19
cmd/qmk-rgb-tool/mode.go

@@ -2,7 +2,6 @@ package main
 
 import (
 	"fmt"
-	"os"
 
 	"github.com/spf13/cobra"
 )
@@ -11,34 +10,28 @@ func NewModeCmd() *cobra.Command {
 	return &cobra.Command{
 		Use:   "mode <index>",
 		Short: "Set effect mode by index",
-		Long:  "Set the RGB effect mode by numeric index (0-255).",
-		Args:  cobra.ExactArgs(1),
-		Run: func(cmd *cobra.Command, args []string) {
+		Long: "Set the effect mode by numeric index (0-255). The index is the\n" +
+			"channel's own: 5 is light on rgblight and rainbow_beacon on rgb_matrix.\n" +
+			"Use `effect <name>` where a catalog exists, which is unambiguous.",
+		Args: cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
 			val, err := ParseUint8(args[0])
 			if err != nil {
-				fmt.Fprintf(os.Stderr, "Error: %v\n", err)
-				os.Exit(1)
+				return err
 			}
 
-			zones, err := selectedZones()
+			proto, _, channels, err := openTarget()
 			if err != nil {
-				fmt.Fprintf(os.Stderr, "Error: %v\n", err)
-				os.Exit(1)
-			}
-
-			proto, err := OpenDevice()
-			if err != nil {
-				fmt.Fprintf(os.Stderr, "Error: %v\n", err)
-				os.Exit(1)
+				return err
 			}
 			defer proto.Close()
 
-			if err := setModeOnZones(proto, zones, val); err != nil {
-				fmt.Fprintf(os.Stderr, "Error setting mode: %v\n", err)
-				os.Exit(1)
+			if err := setModeOnChannels(proto, channels, val); err != nil {
+				return fmt.Errorf("set mode: %w", err)
 			}
 
-			fmt.Printf("Mode set to index %d\n", val)
+			fmt.Fprintf(cmd.OutOrStdout(), "Mode set to index %d\n", val)
+			return nil
 		},
 	}
 }

+ 56 - 53
cmd/qmk-rgb-tool/profile.go

@@ -17,9 +17,9 @@ import (
 var profilesDir = "profiles"
 
 type Profile struct {
-	Name    string                        `json:"name"`
-	Version int                           `json:"version"`
-	Zones   map[intrgb.Zone]*ZoneSettings `json:"zones"`
+	Name    string                   `json:"name"`
+	Version int                      `json:"version"`
+	Zones   map[string]*ZoneSettings `json:"zones"`
 }
 
 type ZoneSettings struct {
@@ -118,19 +118,13 @@ func sanitizeFilename(name string) string {
 }
 
 // applyProfile reads RGB state from the device and stores it in a Profile.
-func applyProfileToProfile(proto rgbProtocol, p *Profile) error {
-	zones, err := selectedZones()
-	if err != nil {
-		// Fall back to all zones for save
-		zones = intrgb.AllZones()
-	}
-
-	out, err := readInfo(proto, zones)
+func applyProfileToProfile(proto rgbProtocol, channels []via.Channel, display map[uint16]string, catalog *intrgb.Catalog, p *Profile) error {
+	out, err := readInfo(proto, channels, display, catalog)
 	if err != nil {
 		return fmt.Errorf("read device state: %w", err)
 	}
 
-	p.Zones = make(map[intrgb.Zone]*ZoneSettings)
+	p.Zones = make(map[string]*ZoneSettings)
 	for _, zi := range out.Zones {
 		if zi.Error != "" {
 			continue
@@ -148,17 +142,18 @@ func applyProfileToProfile(proto rgbProtocol, p *Profile) error {
 
 // loadProfileFromDevice reads RGB state from device and saves it.
 func loadProfileFromDevice(name string) error {
-	proto, err := openRGBProtocol()
+	proto, target, channels, err := openTarget()
 	if err != nil {
 		return err
 	}
 	defer proto.Close()
 
+	catalog, _ := intrgb.CatalogFor(target.Device.VendorID, target.Device.ProductID)
 	p := &Profile{
 		Name:    name,
 		Version: 1,
 	}
-	if err := applyProfileToProfile(proto, p); err != nil {
+	if err := applyProfileToProfile(proto, channels, target.Display, catalog, p); err != nil {
 		return err
 	}
 	return p.Save()
@@ -197,7 +192,7 @@ func NewProfileLoadCmd() *cobra.Command {
 				name = args[0]
 			}
 
-			proto, err := openRGBProtocol()
+			proto, target, selected, err := openTarget()
 			if err != nil {
 				return err
 			}
@@ -208,58 +203,66 @@ func NewProfileLoadCmd() *cobra.Command {
 				return err
 			}
 
-			selected, err := selectedZones()
-			if err != nil {
-				return err
-			}
+			catalog, _ := intrgb.CatalogFor(target.Device.VendorID, target.Device.ProductID)
 
-			// Iterate the profile in a deterministic order and honour --zone,
-			// so `--zone logo load <name>` restores the logo and leaves the
-			// other zones untouched.
-			profileZones := make([]intrgb.Zone, 0, len(p.Zones))
-			for zone := range p.Zones {
-				profileZones = append(profileZones, zone)
+			keys := make([]string, 0, len(p.Zones))
+			for key := range p.Zones {
+				keys = append(keys, key)
+			}
+			sort.Strings(keys)
+
+			// A key is a channel name as the file wrote it. Resolving it here
+			// means a profile written before a board was renamed reports the
+			// key it cannot place instead of silently applying nothing.
+			selectedSet := make(map[via.Channel]bool, len(selected))
+			for _, ch := range selected {
+				selectedSet[ch] = true
 			}
-			sort.Slice(profileZones, func(i, j int) bool {
-				return profileZones[i] < profileZones[j]
-			})
 
 			applied := 0
-			for _, zone := range filterZones(profileZones, selected) {
-				settings := p.Zones[zone]
-				channel := via.Channel(zone.Channel())
-
-				// Resolve effect name to ID
-				targets, _, err := intrgb.ResolveEffect(settings.Effect, []intrgb.Zone{zone})
+			for _, key := range keys {
+				settings := p.Zones[key]
+				keyChannels, err := resolveZoneName(key, target.Display)
 				if err != nil {
-					fmt.Fprintf(cmd.ErrOrStderr(), "Warning: effect %q not found on zone %s, skipping\n", settings.Effect, zone)
+					fmt.Fprintf(cmd.ErrOrStderr(),
+						"Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", name, key)
 					continue
 				}
 
-				if err := proto.SetValue(channel, uint8(intrgb.EffectID), targets[0].ID); err != nil {
-					return err
-				}
-				if err := proto.SetValue(channel, uint8(intrgb.Brightness), settings.Brightness); err != nil {
-					return err
-				}
-				if err := proto.SetValue(channel, uint8(intrgb.Speed), settings.Speed); err != nil {
-					return err
-				}
+				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})
+					if err != nil {
+						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
+					}
+					if err := proto.SetValue(ch, uint8(intrgb.Brightness), settings.Brightness); err != nil {
+						return err
+					}
+					if err := proto.SetValue(ch, uint8(intrgb.Speed), settings.Speed); err != nil {
+						return err
+					}
 
-				if settings.Enabled {
-					// Parse hex color (Hue+Sat as 2-byte hex)
-					if settings.Color != "" {
+					if settings.Enabled && settings.Color != "" {
 						hue, sat, err := hexToHSV(settings.Color)
 						if err != nil {
-							fmt.Fprintf(cmd.ErrOrStderr(), "Warning: invalid color %q on zone %s, skipping\n", settings.Color, zone)
-						} else {
-							if err := proto.SetColor(channel, hue, sat); err != nil {
-								return err
-							}
+							fmt.Fprintf(cmd.ErrOrStderr(),
+								"Warning: invalid color %q on zone %s, skipping\n", settings.Color, channelName(ch, target.Display))
+						} else if err := proto.SetColor(ch, hue, sat); err != nil {
+							return err
 						}
 					}
+					applied++
 				}
-				applied++
 			}
 
 			if applied == 0 {

+ 50 - 67
cmd/qmk-rgb-tool/profile_load_test.go

@@ -1,93 +1,76 @@
 package main
 
 import (
+	"bytes"
 	"testing"
 
 	intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
 )
 
-func sameZones(a, b []intrgb.Zone) bool {
-	if len(a) != len(b) {
-		return false
+// README.md: "With `--zone`, commands target exactly the selected channel."
+// A profile key names a channel, so a load applies only the keys that resolve to
+// a selected channel. The keys themselves are resolved by resolveZoneName, which
+// channels_test.go covers; what matters here is that a key is never applied to a
+// channel it does not name.
+
+func TestLoadAppliesOnlyTheKeysTheSelectionNames(t *testing.T) {
+	dir := t.TempDir()
+	profile := Profile{
+		Name:    "three",
+		Version: 1,
+		Zones: map[string]*ZoneSettings{
+			"logo":      {Enabled: true, Effect: "light", Brightness: 100, Speed: 1, Color: "00ff"},
+			"backlight": {Enabled: true, Effect: "solid_color", Brightness: 100, Speed: 1, Color: "00ff"},
+			"side":      {Enabled: true, Effect: "wave", Brightness: 100, Speed: 1, Color: "00ff"},
+		},
 	}
-	for i := range a {
-		if a[i] != b[i] {
-			return false
-		}
-	}
-	return true
-}
+	originalDir := profilesDir
+	t.Cleanup(func() { profilesDir = originalDir })
+	profilesDir = dir
 
-// README.md: "With `--zone`, commands target exactly the selected zone."
-// `save` already honours it, so `load` must not rewrite zones the user did
-// not select — otherwise a single-zone restore silently touches all three.
-func TestFilterZonesKeepsOnlyTheSelection(t *testing.T) {
-	allZones := []intrgb.Zone{intrgb.ZoneLogo, intrgb.ZoneBacklight, intrgb.ZoneSide}
+	if err := profile.Save(); err != nil {
+		t.Fatalf("save profile: %v", err)
+	}
 
 	tests := []struct {
-		name     string
-		zoneFlag string
-		want     []intrgb.Zone
+		name        string
+		zoneFlag    string
+		wantApplied int
 	}{
-		{"no flag keeps every zone", "", allZones},
-		{"logo keeps logo", "logo", []intrgb.Zone{intrgb.ZoneLogo}},
-		{"backlight keeps backlight", "backlight", []intrgb.Zone{intrgb.ZoneBacklight}},
-		{"side keeps side", "side", []intrgb.Zone{intrgb.ZoneSide}},
+		{"no flag applies all three", "", 3},
+		{"logo applies one", "logo", 1},
+		{"backlight applies one", "backlight", 1},
+		{"side applies one", "side", 1},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			original := targetZone
-			t.Cleanup(func() { targetZone = original })
-			targetZone = tt.zoneFlag
+			proto := &verifyingProtocol{}
+			restore := stubTargetForProfileTest(t, proto, tt.zoneFlag, impact80Display())
+			defer restore()
 
-			selected, err := selectedZones()
-			if err != nil {
-				t.Fatalf("selectedZones() returned error: %v", err)
-			}
+			cmd := NewProfileLoadCmd()
+			cmd.SetOut(&bytes.Buffer{})
+			cmd.SetErr(&bytes.Buffer{})
+			cmd.SetArgs([]string{"three"})
 
-			got := filterZones(allZones, selected)
-			if !sameZones(got, tt.want) {
-				t.Errorf("filterZones(all, %v) = %v, want %v", selected, got, tt.want)
+			if err := cmd.Execute(); err != nil {
+				t.Fatalf("load returned error: %v", err)
+			}
+			if got := effectWrites(proto); got != tt.wantApplied {
+				t.Errorf("effect writes = %d, want %d", got, tt.wantApplied)
 			}
 		})
 	}
 }
 
-// A profile may omit zones the selection names. Those must not be invented.
-func TestFilterZonesDoesNotAddMissingZones(t *testing.T) {
-	original := targetZone
-	t.Cleanup(func() { targetZone = original })
-	targetZone = "backlight"
-
-	profileZones := []intrgb.Zone{intrgb.ZoneSide}
-
-	selected, err := selectedZones()
-	if err != nil {
-		t.Fatalf("selectedZones() returned error: %v", err)
-	}
-
-	got := filterZones(profileZones, selected)
-	if len(got) != 0 {
-		t.Errorf("filterZones(side-only profile, backlight) = %v, want empty", got)
-	}
-}
-
-// filterZones must not reorder or mutate its input.
-func TestFilterZonesDoesNotMutateInput(t *testing.T) {
-	original := targetZone
-	t.Cleanup(func() { targetZone = original })
-	targetZone = "logo"
-
-	input := []intrgb.Zone{intrgb.ZoneSide, intrgb.ZoneLogo}
-	selected, err := selectedZones()
-	if err != nil {
-		t.Fatalf("selectedZones() returned error: %v", err)
-	}
-
-	filterZones(input, selected)
-
-	if !sameZones(input, []intrgb.Zone{intrgb.ZoneSide, intrgb.ZoneLogo}) {
-		t.Errorf("filterZones() mutated its input: %v", input)
+// effectWrites counts the effect writes, one per applied channel.
+func effectWrites(proto *verifyingProtocol) int {
+	count := 0
+	for _, r := range proto.reports {
+		if r.param == uint8(intrgb.EffectID) {
+			count++
+		}
 	}
+	return count
 }

+ 105 - 72
cmd/qmk-rgb-tool/rgb.go

@@ -18,13 +18,10 @@ type zoneProtocol interface {
 type rgbProtocol interface {
 	zoneProtocol
 	GetValue(via.Channel, uint8) ([]byte, error)
+	DetectChannels() ([]via.Channel, error)
 	Close() error
 }
 
-var openRGBProtocol = func() (rgbProtocol, error) {
-	return OpenDevice()
-}
-
 // keyboardFor is a seam for tests; it reads the optional keyboards.json.
 var keyboardFor = intdevice.KeyboardFor
 
@@ -64,32 +61,27 @@ func prepareTarget() (targetDeviceData, error) {
 	return targetDeviceData{Device: dev, Display: keyboard.Channels, Requested: requested}, nil
 }
 
-func forEachSelectedZone(zones []intrgb.Zone, fn func(intrgb.Zone, via.Channel) error) error {
-	channels := zoneChannels(zones)
-	for i, zone := range zones {
-		if err := fn(zone, channels[i]); err != nil {
+func forEachChannel(channels []via.Channel, fn func(via.Channel) error) error {
+	for _, ch := range channels {
+		if err := fn(ch); err != nil {
 			return err
 		}
 	}
 	return nil
 }
 
-func setValueOnZones(proto zoneProtocol, zones []intrgb.Zone, param, value uint8) error {
-	return forEachSelectedZone(zones, func(_ intrgb.Zone, channel via.Channel) error {
-		return proto.SetValue(channel, param, value)
+func setValueOnChannels(proto zoneProtocol, channels []via.Channel, param, value uint8) error {
+	return forEachChannel(channels, func(ch via.Channel) error {
+		return proto.SetValue(ch, param, value)
 	})
 }
 
-func setBrightnessOnZones(proto zoneProtocol, zones []intrgb.Zone, value uint8) error {
-	return setValueOnZones(proto, zones, uint8(intrgb.Brightness), value)
-}
-
 // zoneResult records what was requested for a zone and what the keyboard
 // reported afterwards. The two differ on real hardware: the Impact 80 clamps
 // brightness at 160 on the logo and side channels and scales it up to 255 on
 // the backlight channel.
 type zoneResult struct {
-	Zone      intrgb.Zone
+	Name      string
 	Requested uint8
 	Applied   uint8
 }
@@ -115,7 +107,7 @@ func formatResults(label string, results []zoneResult) string {
 	var b strings.Builder
 	b.WriteString(label)
 	for _, r := range results {
-		fmt.Fprintf(&b, " %s %d", r.Zone, r.Applied)
+		fmt.Fprintf(&b, " %s %d", r.Name, r.Applied)
 	}
 	if len(results) > 0 {
 		fmt.Fprintf(&b, " (requested %d)", results[0].Requested)
@@ -127,41 +119,41 @@ func formatResults(label string, results []zoneResult) string {
 // clamped or rescaled value is reported instead of silently claimed as set.
 // The firmware transform differs per channel and per value ID, so the read-back
 // is not optional for any parameter the keyboard rescales.
-func setValueVerified(proto rgbProtocol, zones []intrgb.Zone, param, value uint8) ([]zoneResult, error) {
-	if err := setValueOnZones(proto, zones, param, value); err != nil {
+func setValueVerified(proto rgbProtocol, channels []via.Channel, display map[uint16]string, param, value uint8) ([]zoneResult, error) {
+	if err := setValueOnChannels(proto, channels, param, value); err != nil {
 		return nil, err
 	}
 
-	results := make([]zoneResult, 0, len(zones))
-	for _, zone := range zones {
-		raw, err := proto.GetValue(via.Channel(zone.Channel()), param)
+	results := make([]zoneResult, 0, len(channels))
+	for _, ch := range channels {
+		raw, err := proto.GetValue(ch, param)
 		if err != nil {
-			return nil, fmt.Errorf("read back value 0x%02x for %s: %w", param, zone, err)
+			return nil, fmt.Errorf("read back value 0x%02x for %s: %w", param, channelName(ch, display), err)
 		}
 		if len(raw) == 0 {
-			return nil, fmt.Errorf("read back value 0x%02x for %s: empty response", param, zone)
+			return nil, fmt.Errorf("read back value 0x%02x for %s: empty response", param, channelName(ch, display))
 		}
-		results = append(results, zoneResult{Zone: zone, Requested: value, Applied: raw[0]})
+		results = append(results, zoneResult{Name: channelName(ch, display), Requested: value, Applied: raw[0]})
 	}
 	return results, nil
 }
 
-// setBrightnessVerified writes the brightness and reads every zone back.
-func setBrightnessVerified(proto rgbProtocol, zones []intrgb.Zone, value uint8) ([]zoneResult, error) {
-	return setValueVerified(proto, zones, uint8(intrgb.Brightness), value)
+// setBrightnessVerified writes the brightness and reads every channel back.
+func setBrightnessVerified(proto rgbProtocol, channels []via.Channel, display map[uint16]string, value uint8) ([]zoneResult, error) {
+	return setValueVerified(proto, channels, display, uint8(intrgb.Brightness), value)
 }
 
-// setSpeedVerified writes the speed and reads every zone back.
-func setSpeedVerified(proto rgbProtocol, zones []intrgb.Zone, value uint8) ([]zoneResult, error) {
-	return setValueVerified(proto, zones, uint8(intrgb.Speed), value)
+// setSpeedVerified writes the speed and reads every channel back.
+func setSpeedVerified(proto rgbProtocol, channels []via.Channel, display map[uint16]string, value uint8) ([]zoneResult, error) {
+	return setValueVerified(proto, channels, display, uint8(intrgb.Speed), value)
 }
 
-// colorResult records the hue and saturation one zone holds after a color was
+// colorResult records the hue and saturation one channel holds after a color was
 // written to it. The color value ID carries two bytes, so both components are
 // read back: a keyboard that stored another saturation must not be reported as
 // having taken the requested color.
 type colorResult struct {
-	Zone                intrgb.Zone
+	Name                string
 	RequestedHue        uint8
 	RequestedSaturation uint8
 	Hue                 uint8
@@ -174,25 +166,25 @@ func (r colorResult) Mismatch() bool {
 	return r.Hue != r.RequestedHue || r.Saturation != r.RequestedSaturation
 }
 
-// setColorVerified writes one hue and saturation to every zone and reads each
+// setColorVerified writes one hue and saturation to every channel and reads each
 // back, for the same reason setValueVerified exists: the command must report
 // what the keyboard holds, not what it was asked for.
-func setColorVerified(proto rgbProtocol, zones []intrgb.Zone, hue, saturation uint8) ([]colorResult, error) {
-	if err := setColorOnZones(proto, zones, hue, saturation); err != nil {
+func setColorVerified(proto rgbProtocol, channels []via.Channel, display map[uint16]string, hue, saturation uint8) ([]colorResult, error) {
+	if err := setColorOnChannels(proto, channels, hue, saturation); err != nil {
 		return nil, err
 	}
 
-	results := make([]colorResult, 0, len(zones))
-	for _, zone := range zones {
-		raw, err := proto.GetValue(via.Channel(zone.Channel()), uint8(intrgb.ColorValue))
+	results := make([]colorResult, 0, len(channels))
+	for _, ch := range channels {
+		raw, err := proto.GetValue(ch, uint8(intrgb.ColorValue))
 		if err != nil {
-			return nil, fmt.Errorf("read back color for %s: %w", zone, err)
+			return nil, fmt.Errorf("read back color for %s: %w", channelName(ch, display), err)
 		}
 		if len(raw) < 2 {
-			return nil, fmt.Errorf("read back color for %s: got %d bytes, want hue and saturation", zone, len(raw))
+			return nil, fmt.Errorf("read back color for %s: got %d bytes, want hue and saturation", channelName(ch, display), len(raw))
 		}
 		results = append(results, colorResult{
-			Zone:                zone,
+			Name:                channelName(ch, display),
 			RequestedHue:        hue,
 			RequestedSaturation: saturation,
 			Hue:                 raw[0],
@@ -202,68 +194,109 @@ func setColorVerified(proto rgbProtocol, zones []intrgb.Zone, hue, saturation ui
 	return results, nil
 }
 
-func setSpeedOnZones(proto zoneProtocol, zones []intrgb.Zone, value uint8) error {
-	return setValueOnZones(proto, zones, uint8(intrgb.Speed), value)
+func setColorOnChannels(proto zoneProtocol, channels []via.Channel, hue, saturation uint8) error {
+	return forEachChannel(channels, func(ch via.Channel) error {
+		return proto.SetColor(ch, hue, saturation)
+	})
 }
 
-func setColorOnZones(proto zoneProtocol, zones []intrgb.Zone, hue, saturation uint8) error {
-	return forEachSelectedZone(zones, func(_ intrgb.Zone, channel via.Channel) error {
-		return proto.SetColor(channel, hue, saturation)
-	})
+func setBrightnessOnChannels(proto zoneProtocol, channels []via.Channel, value uint8) error {
+	return setValueOnChannels(proto, channels, uint8(intrgb.Brightness), value)
+}
+
+func setSpeedChannels(proto zoneProtocol, channels []via.Channel, value uint8) error {
+	return setValueOnChannels(proto, channels, uint8(intrgb.Speed), value)
 }
 
-func disableLightingOnZones(proto zoneProtocol, zones []intrgb.Zone) error {
-	return forEachSelectedZone(zones, func(_ intrgb.Zone, channel via.Channel) error {
-		if err := proto.SetValue(channel, uint8(intrgb.EffectID), 0); err != nil {
+func disableLightingOnChannels(proto zoneProtocol, channels []via.Channel) error {
+	return forEachChannel(channels, func(ch via.Channel) error {
+		if err := proto.SetValue(ch, uint8(intrgb.EffectID), 0); err != nil {
 			return err
 		}
-		return proto.SetValue(channel, uint8(intrgb.Brightness), 0)
+		return proto.SetValue(ch, uint8(intrgb.Brightness), 0)
 	})
 }
 
-func enableLightingOnZones(proto zoneProtocol, zones []intrgb.Zone) error {
-	return forEachSelectedZone(zones, func(zone intrgb.Zone, channel via.Channel) error {
-		if err := proto.SetValue(channel, uint8(intrgb.EffectID), intrgb.DefaultEffect(zone)); err != nil {
+func enableLightingOnChannels(proto zoneProtocol, channels []via.Channel, catalog *intrgb.Catalog) error {
+	return forEachChannel(channels, func(ch via.Channel) error {
+		effect, ok := catalog.DefaultEffect(ch)
+		if !ok {
+			return fmt.Errorf("no default effect for %s", channelName(ch, nil))
+		}
+		if err := proto.SetValue(ch, uint8(intrgb.EffectID), effect); err != nil {
 			return err
 		}
-		return proto.SetValue(channel, uint8(intrgb.Brightness), 160)
+		return proto.SetValue(ch, uint8(intrgb.Brightness), 160)
 	})
 }
 
-func setModeOnZones(proto zoneProtocol, zones []intrgb.Zone, value uint8) error {
-	return setValueOnZones(proto, zones, uint8(intrgb.EffectID), value)
+func setModeOnChannels(proto zoneProtocol, channels []via.Channel, value uint8) error {
+	return setValueOnChannels(proto, channels, uint8(intrgb.EffectID), value)
 }
 
-// OpenDevice discovers the connected QMK keyboards and opens the one selected
-// by --device. Without --device it only proceeds when exactly one keyboard is
-// connected, so a command can never hit an unintended keyboard.
-func OpenDevice() (*via.Protocol, error) {
-	if _, err := selectedZones(); err != nil {
-		return nil, err
+// openTarget opens the keyboard and resolves the requested channels against the
+// ones it actually has. It is a seam because a command needs all three: the
+// handle it writes to, the names it reports with, and the channels it may touch.
+var openTarget = func() (rgbProtocol, targetDeviceData, []via.Channel, error) {
+	target, err := prepareTarget()
+	if err != nil {
+		return nil, targetDeviceData{}, nil, err
 	}
 
-	devices, err := intdevice.DiscoverAll()
+	proto, err := via.New(target.Device)
 	if err != nil {
-		return nil, fmt.Errorf("discover: %w", err)
+		return nil, targetDeviceData{}, nil, fmt.Errorf("open protocol: %w", err)
 	}
 
-	dev, err := selectDevice(devices, targetDevice)
+	channels, err := resolveChannels(proto, target)
 	if err != nil {
-		return nil, err
+		proto.Close()
+		return nil, targetDeviceData{}, nil, err
 	}
+	return proto, target, channels, nil
+}
 
-	proto, err := via.New(dev)
+// resolveChannels intersects the requested channels with the detected ones, and
+// refuses a name that resolves to a channel this keyboard does not have.
+func resolveChannels(proto rgbProtocol, target targetDeviceData) ([]via.Channel, error) {
+	present, err := proto.DetectChannels()
 	if err != nil {
-		return nil, fmt.Errorf("open protocol: %w", err)
+		return nil, err
+	}
+
+	if err := displayNameConflicts(target.Display, present); err != nil {
+		return nil, err
+	}
+
+	if target.Requested == nil {
+		if len(present) == 0 {
+			return nil, fmt.Errorf("this keyboard exposes no VIA lighting channels")
+		}
+		return present, nil
 	}
 
-	return proto, nil
+	presentSet := make(map[via.Channel]bool, len(present))
+	for _, ch := range present {
+		presentSet[ch] = true
+	}
+
+	var found []via.Channel
+	for _, ch := range target.Requested {
+		if presentSet[ch] {
+			found = append(found, ch)
+		}
+	}
+	if len(found) == 0 {
+		return nil, fmt.Errorf("channel %s is not present on this keyboard", target.Requested[0].Subsystem())
+	}
+	return found, nil
 }
 
+// lightingEnabled reports whether a channel is lit: a mode other than none and a
+// brightness above zero.
 func lightingEnabled(mode, brightness uint8) bool {
 	return mode != 0 && brightness > 0
 }
-
 func ParseUint8(s string) (uint8, error) {
 	v, err := strconv.ParseUint(s, 10, 8)
 	if err != nil {

+ 47 - 71
cmd/qmk-rgb-tool/rgb_test.go

@@ -6,7 +6,7 @@ import (
 	"testing"
 
 	"github.com/spf13/cobra"
-	intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
+	intdevice "netdome.biz/paul/qmk-rgb/internal/device"
 	"netdome.biz/paul/qmk-rgb/internal/via"
 )
 
@@ -32,11 +32,22 @@ func TestLightingEnabledRequiresActiveEffectAndBrightness(t *testing.T) {
 	}
 }
 
-func TestDeviceFlagIsInheritedByCommands(t *testing.T) {
-	originalTargetDevice := targetDevice
-	t.Cleanup(func() { targetDevice = originalTargetDevice })
+// stubOneImpact80 puts one keyboard in front of the root command's validation,
+// which now resolves the target before a subcommand runs.
+func stubOneImpact80(t *testing.T) {
+	t.Helper()
+
+	stubDiscovery(t, []intdevice.Device{{Index: 1, Path: "hid-device-0", VendorID: 0x36B0, ProductID: 0x309F}})
 
-	const wantPath = "hid-device-0"
+	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) {
+	stubOneImpact80(t)
 	ran := false
 
 	cmd := newRootCommand()
@@ -44,7 +55,7 @@ func TestDeviceFlagIsInheritedByCommands(t *testing.T) {
 		Use: "probe",
 		Run: func(*cobra.Command, []string) { ran = true },
 	})
-	cmd.SetArgs([]string{"--device", wantPath, "probe"})
+	cmd.SetArgs([]string{"--device", "1", "probe"})
 
 	if err := cmd.Execute(); err != nil {
 		t.Fatalf("Execute() error = %v", err)
@@ -52,91 +63,59 @@ func TestDeviceFlagIsInheritedByCommands(t *testing.T) {
 	if !ran {
 		t.Fatal("child command did not run")
 	}
-	if targetDevice != wantPath {
-		t.Errorf("targetDevice = %q, want %q", targetDevice, wantPath)
+	if targetDevice != "1" {
+		t.Errorf("targetDevice = %q, want %q", targetDevice, "1")
 	}
 }
 
-func TestSelectedZonesDefaultsToAll(t *testing.T) {
-	originalTargetZone := targetZone
-	t.Cleanup(func() { targetZone = originalTargetZone })
-	targetZone = ""
+func TestResolveChannelsDefaultsToEveryDetectedChannel(t *testing.T) {
+	proto := &verifyingProtocol{}
 
-	got, err := selectedZones()
+	got, err := resolveChannels(proto, targetDeviceData{})
 	if err != nil {
-		t.Fatalf("selectedZones() unexpected error: %v", err)
+		t.Fatalf("resolveChannels() unexpected error: %v", err)
 	}
-	want := []intrgb.Zone{intrgb.ZoneLogo, intrgb.ZoneBacklight, intrgb.ZoneSide}
+	want := []via.Channel{via.ChannelRgblight, via.ChannelRgbMatrix, via.ChannelAudio}
 	if !reflect.DeepEqual(got, want) {
-		t.Errorf("selectedZones() = %v, want %v", got, want)
+		t.Errorf("resolveChannels() = %v, want %v", got, want)
 	}
 }
 
-func TestSelectedZonesRejectsUnknownZone(t *testing.T) {
-	originalTargetZone := targetZone
-	t.Cleanup(func() { targetZone = originalTargetZone })
-	targetZone = "matrix"
+func TestResolveChannelsRejectsAChannelTheKeyboardDoesNotHave(t *testing.T) {
+	proto := &verifyingProtocol{channels: []via.Channel{via.ChannelRgblight}}
+	target := targetDeviceData{Requested: []via.Channel{via.ChannelAudio}}
 
-	got, err := selectedZones()
-	if err == nil {
-		t.Fatal("selectedZones() expected error, got nil")
-	}
-	if got != nil {
-		t.Errorf("selectedZones() = %v, want nil", got)
+	if _, err := resolveChannels(proto, target); err == nil {
+		t.Fatal("resolveChannels() expected an error, want the missing channel reported")
 	}
 }
 
-func TestSelectedZonesExplicitZones(t *testing.T) {
-	originalTargetZone := targetZone
-	t.Cleanup(func() { targetZone = originalTargetZone })
+func TestResolveChannelsRejectsAShadowedDisplayName(t *testing.T) {
+	proto := &verifyingProtocol{channels: []via.Channel{via.ChannelBacklight, via.ChannelRgbMatrix}}
+	target := targetDeviceData{Display: map[uint16]string{3: "backlight"}}
 
-	cases := []struct {
-		name string
-		want intrgb.Zone
-	}{
-		{"logo", intrgb.ZoneLogo},
-		{"backlight", intrgb.ZoneBacklight},
-		{"side", intrgb.ZoneSide},
-	}
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			targetZone = tc.name
-			got, err := selectedZones()
-			if err != nil {
-				t.Fatalf("selectedZones() unexpected error: %v", err)
-			}
-			want := []intrgb.Zone{tc.want}
-			if !reflect.DeepEqual(got, want) {
-				t.Errorf("selectedZones() = %v, want %v", got, want)
-			}
-		})
+	if _, err := resolveChannels(proto, target); err == nil {
+		t.Fatal("resolveChannels() expected an error, want the shadowed name reported")
 	}
 }
 
-func TestZoneChannels(t *testing.T) {
-	got := zoneChannels(intrgb.AllZones())
-	want := []via.Channel{2, 3, 4}
-	if !reflect.DeepEqual(got, want) {
-		t.Errorf("zoneChannels() = %v, want %v", got, want)
+func TestResolveChannelsReportsWhenNoChannelIsPresent(t *testing.T) {
+	proto := &verifyingProtocol{channels: []via.Channel{}}
+
+	if _, err := resolveChannels(proto, targetDeviceData{}); err == nil {
+		t.Fatal("resolveChannels() expected an error when the keyboard has no channels")
 	}
 }
 
 func TestZoneFlagIsInheritedByCommands(t *testing.T) {
-	originalTargetDevice := targetDevice
-	originalTargetZone := targetZone
-	t.Cleanup(func() {
-		targetDevice = originalTargetDevice
-		targetZone = originalTargetZone
-	})
-
-	const wantPath = "hid-device-0"
+	stubOneImpact80(t)
 	ran := false
 	cmd := newRootCommand()
 	cmd.AddCommand(&cobra.Command{
 		Use: "probe",
 		Run: func(*cobra.Command, []string) { ran = true },
 	})
-	cmd.SetArgs([]string{"--device", wantPath, "--zone", "backlight", "probe"})
+	cmd.SetArgs([]string{"--zone", "backlight", "probe"})
 
 	if err := cmd.Execute(); err != nil {
 		t.Fatalf("Execute() error = %v", err)
@@ -144,9 +123,6 @@ func TestZoneFlagIsInheritedByCommands(t *testing.T) {
 	if !ran {
 		t.Fatal("child command did not run")
 	}
-	if targetDevice != wantPath {
-		t.Errorf("targetDevice = %q, want %q", targetDevice, wantPath)
-	}
 	if targetZone != "backlight" {
 		t.Errorf("targetZone = %q, want %q", targetZone, "backlight")
 	}
@@ -157,12 +133,12 @@ func TestZoneValidationPrecedesDeviceOpening(t *testing.T) {
 	t.Cleanup(func() { targetZone = originalTargetZone })
 	targetZone = "matrix"
 
-	_, err := OpenDevice()
+	_, _, _, err := openTarget()
 	if err == nil {
-		t.Fatal("OpenDevice() expected zone error, got nil")
+		t.Fatal("openTarget() expected zone error, got nil")
 	}
 	if !strings.Contains(err.Error(), "unknown zone") {
-		t.Errorf("OpenDevice() error = %v, want unknown zone error", err)
+		t.Errorf("openTarget() error = %v, want unknown zone error", err)
 	}
 }
 
@@ -180,7 +156,7 @@ func TestZoneValidationRunsBeforeCommandOpener(t *testing.T) {
 		Use: "probe",
 		RunE: func(*cobra.Command, []string) error {
 			openerCalled = true
-			_, err := OpenDevice()
+			_, _, _, err := openTarget()
 			return err
 		},
 	})
@@ -194,6 +170,6 @@ func TestZoneValidationRunsBeforeCommandOpener(t *testing.T) {
 		t.Errorf("Execute() error = %v, want unknown zone error", err)
 	}
 	if openerCalled {
-		t.Fatal("child command invoked OpenDevice before zone validation")
+		t.Fatal("child command invoked openTarget before zone validation")
 	}
 }

+ 2 - 7
cmd/qmk-rgb-tool/speed.go

@@ -19,18 +19,13 @@ func NewSpeedCmd() *cobra.Command {
 				return err
 			}
 
-			zones, err := selectedZones()
-			if err != nil {
-				return err
-			}
-
-			proto, err := openRGBProtocol()
+			proto, target, channels, err := openTarget()
 			if err != nil {
 				return err
 			}
 			defer proto.Close()
 
-			results, err := setSpeedVerified(proto, zones, val)
+			results, err := setSpeedVerified(proto, channels, target.Display, val)
 			if err != nil {
 				return fmt.Errorf("set speed: %w", err)
 			}

+ 3 - 7
cmd/qmk-rgb-tool/speed_verify_test.go

@@ -14,14 +14,10 @@ func runSpeed(t *testing.T, applied map[via.Channel]uint8, zoneFlag string, arg
 
 	proto := &verifyingProtocol{applied: applied}
 
-	originalOpen := openRGBProtocol
 	originalZone := targetZone
-	t.Cleanup(func() {
-		openRGBProtocol = originalOpen
-		targetZone = originalZone
-	})
-	openRGBProtocol = func() (rgbProtocol, error) { return proto, nil }
+	t.Cleanup(func() { targetZone = originalZone })
 	targetZone = zoneFlag
+	t.Cleanup(stubOpenTarget(t, proto, impact80Display(), impact80Channels(), 0x36B0, 0x309F))
 
 	var out, errOut bytes.Buffer
 	cmd := NewSpeedCmd()
@@ -105,7 +101,7 @@ func TestSetValueVerifiedReadsBackTheRequestedParam(t *testing.T) {
 		applied: map[via.Channel]uint8{via.ChannelRgbMatrix: 159},
 	}
 
-	results, err := setValueVerified(proto, []intrgb.Zone{intrgb.ZoneBacklight},
+	results, err := setValueVerified(proto, []via.Channel{via.ChannelRgbMatrix}, impact80Display(),
 		uint8(intrgb.Speed), 100)
 	if err != nil {
 		t.Fatalf("setValueVerified() error = %v", err)

+ 40 - 0
cmd/qmk-rgb-tool/target_seam_test.go

@@ -0,0 +1,40 @@
+package main
+
+import (
+	"testing"
+
+	intdevice "netdome.biz/paul/qmk-rgb/internal/device"
+	"netdome.biz/paul/qmk-rgb/internal/via"
+)
+
+// stubOpenTarget points the commands at one protocol, the display names a board
+// would supply and the channels that board has. It returns the restore function.
+func stubOpenTarget(t *testing.T, proto rgbProtocol, display map[uint16]string, channels []via.Channel, vendorID, productID uint16) func() {
+	t.Helper()
+
+	original := openTarget
+	openTarget = func() (rgbProtocol, targetDeviceData, []via.Channel, error) {
+		target := targetDeviceData{
+			Device:  intdevice.Device{VendorID: vendorID, ProductID: productID},
+			Display: display,
+		}
+		requested, err := resolveZoneName(targetZone, display)
+		if err != nil {
+			return nil, target, nil, err
+		}
+		target.Requested = requested
+
+		resolved, err := resolveChannels(proto, target)
+		if err != nil {
+			return nil, target, nil, err
+		}
+		return proto, target, resolved, nil
+	}
+	return func() { openTarget = original }
+}
+
+// impact80Target is the board most command tests describe.
+func impact80Target(t *testing.T, proto rgbProtocol) func() {
+	t.Helper()
+	return stubOpenTarget(t, proto, impact80Display(), impact80Channels(), 0x36B0, 0x309F)
+}

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

@@ -4,7 +4,6 @@ import (
 	"fmt"
 	"sort"
 
-	intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
 	"netdome.biz/paul/qmk-rgb/internal/via"
 )
 
@@ -83,45 +82,3 @@ func channelName(c via.Channel, display map[uint16]string) string {
 	}
 	return c.Subsystem()
 }
-
-// The zone helpers below survive until the commands speak channels. They are
-// deleted with their call sites, not before, so every commit in between
-// compiles and its tests pass.
-
-func selectedZones() ([]intrgb.Zone, error) {
-	if targetZone == "" {
-		return intrgb.AllZones(), nil
-	}
-	zone, err := intrgb.ParseZone(targetZone)
-	if err != nil {
-		return nil, err
-	}
-	return []intrgb.Zone{zone}, nil
-}
-
-func zoneChannels(zones []intrgb.Zone) []via.Channel {
-	var channels []via.Channel
-	for _, zone := range zones {
-		channels = append(channels, via.Channel(zone.Channel()))
-	}
-	return channels
-}
-
-// filterZones keeps the zones that appear in selected, preserving the order
-// of candidates. It neither adds nor removes zones on its own: a zone the
-// caller does not offer stays absent, so a profile that omits a zone is not
-// padded with defaults.
-func filterZones(candidates, selected []intrgb.Zone) []intrgb.Zone {
-	keep := make(map[intrgb.Zone]bool, len(selected))
-	for _, zone := range selected {
-		keep[zone] = true
-	}
-
-	filtered := make([]intrgb.Zone, 0, len(candidates))
-	for _, zone := range candidates {
-		if keep[zone] {
-			filtered = append(filtered, zone)
-		}
-	}
-	return filtered
-}

+ 2 - 1
keyboards.json

@@ -7,6 +7,7 @@
   {
     "name": "Wobkey Impact 80",
     "vendorId": 14000,
-    "productId": 12447
+    "productId": 12447,
+    "channels": {"2": "logo", "3": "backlight", "4": "side"}
   }
 ]