Bladeren bron

resolve --zone to channels before the device is opened

Paul Klumpp 1 week geleden
bovenliggende
commit
67821f5d2f

+ 8 - 8
cmd/qmk-rgb-tool/brightness_summary_test.go

@@ -8,7 +8,7 @@ import (
 	"netdome.biz/paul/qmk-rgb/internal/via"
 )
 
-func runBrightness(t *testing.T, applied map[via.LEDType]uint8, zoneFlag string, arg string) (stdout, stderr string) {
+func runBrightness(t *testing.T, applied map[via.Channel]uint8, zoneFlag string, arg string) (stdout, stderr string) {
 	t.Helper()
 
 	proto := &verifyingProtocol{applied: applied}
@@ -38,7 +38,7 @@ func runBrightness(t *testing.T, applied map[via.LEDType]uint8, zoneFlag string,
 // contradiction. Where every zone applied the request, the plain message is
 // accurate and stays.
 func TestBrightnessReportsPlainMessageWhenAllZonesMatch(t *testing.T) {
-	stdout, stderr := runBrightness(t, map[via.LEDType]uint8{via.RGBLight: 200}, "logo", "200")
+	stdout, stderr := runBrightness(t, map[via.Channel]uint8{via.ChannelRgblight: 200}, "logo", "200")
 
 	if strings.TrimSpace(stdout) != "Brightness set to 200" {
 		t.Errorf("stdout = %q, want the exact success message", stdout)
@@ -50,10 +50,10 @@ func TestBrightnessReportsPlainMessageWhenAllZonesMatch(t *testing.T) {
 
 // Where a zone differs, one line must state what each zone actually holds.
 func TestBrightnessSummarisesAppliedValuesOnMismatch(t *testing.T) {
-	stdout, _ := runBrightness(t, map[via.LEDType]uint8{
-		via.RGBLight:  160,
-		via.RGBMatrix: 255,
-		via.SideLight: 160,
+	stdout, _ := runBrightness(t, map[via.Channel]uint8{
+		via.ChannelRgblight:  160,
+		via.ChannelRgbMatrix: 255,
+		via.ChannelAudio:     160,
 	}, "", "200")
 
 	for _, want := range []string{"logo 160", "backlight 255", "side 160", "requested 200"} {
@@ -71,7 +71,7 @@ func TestBrightnessSummarisesAppliedValuesOnMismatch(t *testing.T) {
 
 func TestBrightnessSummarisesASingleMismatchingZone(t *testing.T) {
 	// backlight lives on the RGBMatrix channel, not RGBLight.
-	stdout, _ := runBrightness(t, map[via.LEDType]uint8{via.RGBMatrix: 159}, "backlight", "100")
+	stdout, _ := runBrightness(t, map[via.Channel]uint8{via.ChannelRgbMatrix: 159}, "backlight", "100")
 
 	if !strings.Contains(stdout, "backlight 159") || !strings.Contains(stdout, "requested 100") {
 		t.Errorf("stdout = %q, want the single zone's applied value and the request", stdout)
@@ -83,7 +83,7 @@ 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.LEDType]uint8{via.SideLight: 160}}
+	proto := &verifyingProtocol{applied: map[via.Channel]uint8{via.ChannelAudio: 160}}
 
 	originalOpen := openRGBProtocol
 	originalZone := targetZone

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

@@ -14,19 +14,19 @@ import (
 // applied, which on real hardware is not always the requested value.
 type verifyingProtocol struct {
 	reports  []commandReport
-	applied  map[via.LEDType]uint8
+	applied  map[via.Channel]uint8
 	getErr   error
 	getCalls int
 }
 
-func (f *verifyingProtocol) SetValue(channel via.LEDType, param, value uint8) error {
+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
 }
 
-func (f *verifyingProtocol) SetColor(via.LEDType, uint8, uint8) error { return nil }
+func (f *verifyingProtocol) SetColor(via.Channel, uint8, uint8) error { return nil }
 
-func (f *verifyingProtocol) GetValue(channel via.LEDType, _ uint8) ([]byte, error) {
+func (f *verifyingProtocol) GetValue(channel via.Channel, _ uint8) ([]byte, error) {
 	f.getCalls++
 	if f.getErr != nil {
 		return nil, f.getErr
@@ -36,7 +36,7 @@ func (f *verifyingProtocol) GetValue(channel via.LEDType, _ uint8) ([]byte, erro
 
 func (f *verifyingProtocol) Close() error { return nil }
 
-func appliedFor(p *verifyingProtocol, channel via.LEDType) uint8 { return p.applied[channel] }
+func appliedFor(p *verifyingProtocol, channel via.Channel) uint8 { return p.applied[channel] }
 
 func TestSetBrightnessVerifiedReportsAppliedValue(t *testing.T) {
 	tests := []struct {
@@ -54,10 +54,10 @@ func TestSetBrightnessVerifiedReportsAppliedValue(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			proto := &verifyingProtocol{
-				applied: map[via.LEDType]uint8{
-					via.RGBLight:  tt.applied,
-					via.RGBMatrix: tt.applied,
-					via.SideLight: tt.applied,
+				applied: map[via.Channel]uint8{
+					via.ChannelRgblight:  tt.applied,
+					via.ChannelRgbMatrix: tt.applied,
+					via.ChannelAudio:     tt.applied,
 				},
 			}
 
@@ -90,10 +90,10 @@ func TestSetBrightnessVerifiedReportsAppliedValue(t *testing.T) {
 // The read-back must happen once per written zone, on the right channel.
 func TestSetBrightnessVerifiedReadsBackEveryZone(t *testing.T) {
 	proto := &verifyingProtocol{
-		applied: map[via.LEDType]uint8{
-			via.RGBLight:  160,
-			via.RGBMatrix: 255,
-			via.SideLight: 160,
+		applied: map[via.Channel]uint8{
+			via.ChannelRgblight:  160,
+			via.ChannelRgbMatrix: 255,
+			via.ChannelAudio:     160,
 		},
 	}
 
@@ -109,7 +109,7 @@ func TestSetBrightnessVerifiedReadsBackEveryZone(t *testing.T) {
 	}
 
 	// A set that fails must not be reported as applied.
-	if got := appliedFor(proto, via.RGBLight); got != 160 {
+	if got := appliedFor(proto, via.ChannelRgblight); got != 160 {
 		t.Errorf("unexpected applied value %d", got)
 	}
 
@@ -138,7 +138,7 @@ func TestSetBrightnessVerifiedPropagatesReadError(t *testing.T) {
 }
 
 func TestBrightnessCommandStaysQuietWhenApplied(t *testing.T) {
-	proto := &verifyingProtocol{applied: map[via.LEDType]uint8{via.RGBLight: 200}}
+	proto := &verifyingProtocol{applied: map[via.Channel]uint8{via.ChannelRgblight: 200}}
 
 	originalOpen := openRGBProtocol
 	originalZone := targetZone

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

@@ -0,0 +1,139 @@
+package main
+
+import (
+	"strings"
+	"testing"
+
+	intdevice "netdome.biz/paul/qmk-rgb/internal/device"
+	"netdome.biz/paul/qmk-rgb/internal/via"
+)
+
+func TestResolveZoneNameAcceptsASubsystemName(t *testing.T) {
+	got, err := resolveZoneName("rgb_matrix", map[uint16]string{2: "logo"})
+	if err != nil {
+		t.Fatalf("resolveZoneName() error = %v", err)
+	}
+	if len(got) != 1 || got[0] != via.ChannelRgbMatrix {
+		t.Errorf("resolveZoneName() = %v, want [3]", got)
+	}
+}
+
+func TestResolveZoneNameAcceptsADisplayName(t *testing.T) {
+	got, err := resolveZoneName("logo", map[uint16]string{2: "logo", 3: "backlight", 4: "side"})
+	if err != nil {
+		t.Fatalf("resolveZoneName() error = %v", err)
+	}
+	if len(got) != 1 || got[0] != via.ChannelRgblight {
+		t.Errorf("resolveZoneName() = %v, want [2]", got)
+	}
+}
+
+func TestResolveZoneNameRejectsAnUnknownName(t *testing.T) {
+	_, err := resolveZoneName("nope", map[uint16]string{2: "logo"})
+	if err == nil {
+		t.Fatal("resolveZoneName() expected an error, got nil")
+	}
+	if !strings.Contains(err.Error(), "rgb_matrix") {
+		t.Errorf("error = %q, want it to name an accepted form", err)
+	}
+}
+
+// A board that supplies no display names keeps the subsystem vocabulary, so the
+// physical names that work on the Impact 80 do not work elsewhere.
+func TestResolveZoneNameIgnoresDisplayNamesForAnotherBoard(t *testing.T) {
+	if _, err := resolveZoneName("logo", nil); err == nil {
+		t.Fatal("resolveZoneName(\"logo\", nil) expected an error, got nil")
+	}
+}
+
+// Two channels carrying the same display name are ambiguous, and the tool
+// reports that rather than picking one. Whether a display name shadows another
+// channel's subsystem name cannot be decided here, because presence is only
+// known after the probe; displayNameConflicts decides that.
+func TestResolveZoneNameReportsAnAmbiguousName(t *testing.T) {
+	_, err := resolveZoneName("backlight", map[uint16]string{2: "logo", 3: "backlight", 4: "backlight"})
+	if err == nil {
+		t.Fatal("resolveZoneName() expected an error for a name two channels answer to")
+	}
+	if !strings.Contains(err.Error(), "backlight") {
+		t.Errorf("error = %q, want it to name the conflicting name", err)
+	}
+}
+
+func TestDisplayNameConflictsRejectsAShadowedSubsystemName(t *testing.T) {
+	// Channel 1 is present and its subsystem is "backlight", while the file
+	// also calls channel 3 "backlight": two channels, one name.
+	err := displayNameConflicts(
+		map[uint16]string{3: "backlight"},
+		[]via.Channel{via.ChannelBacklight, via.ChannelRgbMatrix},
+	)
+	if err == nil {
+		t.Fatal("displayNameConflicts() expected an error, got nil")
+	}
+	if !strings.Contains(err.Error(), "backlight") {
+		t.Errorf("error = %q, want it to name the conflicting name", err)
+	}
+}
+
+func TestDisplayNameConflictsAllowsTheImpact80Naming(t *testing.T) {
+	// The Impact 80 calls channel 3 "backlight" and has no channel 1, so
+	// nothing shadows anything.
+	err := displayNameConflicts(
+		map[uint16]string{2: "logo", 3: "backlight", 4: "side"},
+		[]via.Channel{via.ChannelRgblight, via.ChannelRgbMatrix, via.ChannelAudio},
+	)
+	if err != nil {
+		t.Fatalf("displayNameConflicts() error = %v, want nil", err)
+	}
+}
+
+func TestChannelNamePrefersTheDisplayName(t *testing.T) {
+	display := map[uint16]string{2: "logo"}
+	if got := channelName(via.ChannelRgblight, display); got != "logo" {
+		t.Errorf("channelName(2) = %q, want %q", got, "logo")
+	}
+	if got := channelName(via.ChannelRgbMatrix, display); got != "rgb_matrix" {
+		t.Errorf("channelName(3) = %q, want %q", got, "rgb_matrix")
+	}
+}
+
+// The display names must come from the keyboard the command targets, not from
+// whichever one enumeration returned first.
+func TestPrepareTargetUsesTheSelectedKeyboard(t *testing.T) {
+	devices := []intdevice.Device{
+		{VendorID: 0x6666, ProductID: 0x0001},
+		{VendorID: 0x36B0, ProductID: 0x309F, Name: "Wobkey Impact 80"},
+	}
+
+	originalDiscover := discoverAll
+	originalKeyboardFor := keyboardFor
+	originalTarget := targetDevice
+	originalZone := targetZone
+	t.Cleanup(func() {
+		discoverAll = originalDiscover
+		keyboardFor = originalKeyboardFor
+		targetDevice = originalTarget
+		targetZone = originalZone
+	})
+
+	discoverAll = func() ([]intdevice.Device, error) { return devices, nil }
+	keyboardFor = func(vendorID, productID uint16) (intdevice.Keyboard, bool, error) {
+		switch {
+		case vendorID == 0x6666:
+			return intdevice.Keyboard{Name: "Wobkey Rainy 75", Channels: map[uint16]string{2: "deck"}}, true, nil
+		case vendorID == 0x36B0:
+			return intdevice.Keyboard{Name: "Wobkey Impact 80", Channels: map[uint16]string{2: "logo"}}, true, nil
+		}
+		return intdevice.Keyboard{}, false, nil
+	}
+	targetDevice = "2"
+	targetZone = "logo"
+
+	got, err := prepareTarget()
+	if err != nil {
+		t.Fatalf("prepareTarget() error = %v", err)
+	}
+	if got.Display[2] != "logo" {
+		t.Errorf("display names = %v, want the Impact 80's", got.Display)
+	}
+}

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

@@ -16,27 +16,27 @@ import (
 type colorProtocol struct {
 	colorWrites      []colorWrite
 	brightnessWrites []uint8
-	appliedColor     map[via.LEDType][2]uint8
-	appliedBright    map[via.LEDType]uint8
+	appliedColor     map[via.Channel][2]uint8
+	appliedBright    map[via.Channel]uint8
 	shortColorRead   bool
 	setColorErr      error
 	getErr           error
 }
 
 type colorWrite struct {
-	channel via.LEDType
+	channel via.Channel
 	hue     uint8
 	sat     uint8
 }
 
-func (p *colorProtocol) SetValue(_ via.LEDType, param, value uint8) error {
+func (p *colorProtocol) SetValue(_ via.Channel, param, value uint8) error {
 	if param == uint8(intrgb.Brightness) {
 		p.brightnessWrites = append(p.brightnessWrites, value)
 	}
 	return nil
 }
 
-func (p *colorProtocol) SetColor(channel via.LEDType, hue, sat uint8) error {
+func (p *colorProtocol) SetColor(channel via.Channel, hue, sat uint8) error {
 	if p.setColorErr != nil {
 		return p.setColorErr
 	}
@@ -44,7 +44,7 @@ func (p *colorProtocol) SetColor(channel via.LEDType, hue, sat uint8) error {
 	return nil
 }
 
-func (p *colorProtocol) GetValue(channel via.LEDType, param uint8) ([]byte, error) {
+func (p *colorProtocol) GetValue(channel via.Channel, param uint8) ([]byte, error) {
 	if p.getErr != nil {
 		return nil, p.getErr
 	}
@@ -64,7 +64,7 @@ func (p *colorProtocol) Close() error { return nil }
 // one byte answer would leave the saturation unverified.
 func TestSetColorVerifiedReadsBackHueAndSaturation(t *testing.T) {
 	proto := &colorProtocol{
-		appliedColor: map[via.LEDType][2]uint8{via.RGBLight: {99, 255}},
+		appliedColor: map[via.Channel][2]uint8{via.ChannelRgblight: {99, 255}},
 	}
 
 	results, err := setColorVerified(proto, []intrgb.Zone{intrgb.ZoneLogo}, 85, 255)
@@ -92,7 +92,7 @@ func TestSetColorVerifiedReadsBackHueAndSaturation(t *testing.T) {
 
 func TestSetColorVerifiedReportsNoMismatchWhenKeyboardAppliesTheRequest(t *testing.T) {
 	proto := &colorProtocol{
-		appliedColor: map[via.LEDType][2]uint8{via.RGBLight: {0, 255}},
+		appliedColor: map[via.Channel][2]uint8{via.ChannelRgblight: {0, 255}},
 	}
 
 	results, err := setColorVerified(proto, []intrgb.Zone{intrgb.ZoneLogo}, 0, 255)
@@ -152,11 +152,11 @@ func runColor(t *testing.T, proto *colorProtocol, zoneFlag string, args ...strin
 	return out.String(), errOut.String(), err
 }
 
-func allZonesAppliedColor(hue, sat uint8) map[via.LEDType][2]uint8 {
-	return map[via.LEDType][2]uint8{
-		via.RGBLight:  {hue, sat},
-		via.RGBMatrix: {hue, sat},
-		via.SideLight: {hue, sat},
+func allZonesAppliedColor(hue, sat uint8) map[via.Channel][2]uint8 {
+	return map[via.Channel][2]uint8{
+		via.ChannelRgblight:  {hue, sat},
+		via.ChannelRgbMatrix: {hue, sat},
+		via.ChannelAudio:     {hue, sat},
 	}
 }
 
@@ -198,7 +198,7 @@ func TestColorHexNotationLeavesBrightnessAlone(t *testing.T) {
 func TestColorHSVNotationWritesTheValueAsBrightness(t *testing.T) {
 	proto := &colorProtocol{
 		appliedColor:  allZonesAppliedColor(85, 255),
-		appliedBright: map[via.LEDType]uint8{via.RGBLight: 200, via.RGBMatrix: 200, via.SideLight: 200},
+		appliedBright: map[via.Channel]uint8{via.ChannelRgblight: 200, via.ChannelRgbMatrix: 200, via.ChannelAudio: 200},
 	}
 
 	stdout, _, err := runColor(t, proto, "", "hsv:85,255,200")
@@ -223,7 +223,7 @@ func TestColorHSVNotationWritesTheValueAsBrightness(t *testing.T) {
 func TestColorHSVSummarisesTheBrightnessTheKeyboardApplied(t *testing.T) {
 	proto := &colorProtocol{
 		appliedColor:  allZonesAppliedColor(0, 255),
-		appliedBright: map[via.LEDType]uint8{via.RGBLight: 160, via.RGBMatrix: 255, via.SideLight: 160},
+		appliedBright: map[via.Channel]uint8{via.ChannelRgblight: 160, via.ChannelRgbMatrix: 255, via.ChannelAudio: 160},
 	}
 
 	stdout, _, err := runColor(t, proto, "", "hsv:0,255,200")

+ 4 - 4
cmd/qmk-rgb-tool/commands_test.go

@@ -10,7 +10,7 @@ import (
 )
 
 type commandReport struct {
-	channel    via.LEDType
+	channel    via.Channel
 	param      uint8
 	value      uint8
 	hue        uint8
@@ -22,7 +22,7 @@ type fakeZoneProtocol struct {
 	failAt  int
 }
 
-func (f *fakeZoneProtocol) SetValue(channel via.LEDType, param, value uint8) error {
+func (f *fakeZoneProtocol) SetValue(channel via.Channel, param, value uint8) error {
 	if f.failAt >= 0 && f.failAt == len(f.reports) {
 		return errors.New("write failed")
 	}
@@ -34,7 +34,7 @@ func (f *fakeZoneProtocol) SetValue(channel via.LEDType, param, value uint8) err
 	return nil
 }
 
-func (f *fakeZoneProtocol) SetColor(channel via.LEDType, hue, saturation uint8) error {
+func (f *fakeZoneProtocol) SetColor(channel via.Channel, hue, saturation uint8) error {
 	if f.failAt >= 0 && f.failAt == len(f.reports) {
 		return errors.New("write failed")
 	}
@@ -47,7 +47,7 @@ func (f *fakeZoneProtocol) SetColor(channel via.LEDType, hue, saturation uint8)
 	return nil
 }
 
-func (f *fakeZoneProtocol) GetValue(via.LEDType, uint8) ([]byte, error) {
+func (f *fakeZoneProtocol) GetValue(via.Channel, uint8) ([]byte, error) {
 	return nil, errors.New("unexpected GetValue call")
 }
 

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

@@ -84,7 +84,7 @@ func runEffectSet(cmd *cobra.Command, args []string) error {
 	defer proto.Close()
 
 	for _, target := range targets {
-		if err := proto.SetValue(via.LEDType(target.Zone.Channel()), uint8(intrgb.EffectID), target.ID); err != nil {
+		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)
 		}
 	}

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

@@ -10,7 +10,7 @@ import (
 )
 
 type infoGetter interface {
-	GetValue(via.LEDType, uint8) ([]byte, error)
+	GetValue(via.Channel, uint8) ([]byte, error)
 }
 
 type infoColor struct {
@@ -60,7 +60,7 @@ func readInfo(proto infoGetter, zones []intrgb.Zone) (infoOutput, error) {
 
 func readZoneInfo(proto infoGetter, zone intrgb.Zone) (zoneInfo, error) {
 	record := zoneInfo{Zone: zone, Channel: zone.Channel()}
-	channel := via.LEDType(zone.Channel())
+	channel := via.Channel(zone.Channel())
 
 	brightness, err := getInfoValue(proto, channel, uint8(intrgb.Brightness), 1)
 	if err != nil {
@@ -94,7 +94,7 @@ func readZoneInfo(proto infoGetter, zone intrgb.Zone) (zoneInfo, error) {
 	return record, nil
 }
 
-func getInfoValue(proto infoGetter, channel via.LEDType, param uint8, size int) ([]byte, error) {
+func getInfoValue(proto infoGetter, channel via.Channel, param uint8, size int) ([]byte, error) {
 	value, err := proto.GetValue(channel, param)
 	if err != nil {
 		return nil, err

+ 4 - 4
cmd/qmk-rgb-tool/info_test.go

@@ -13,7 +13,7 @@ import (
 )
 
 type infoKey struct {
-	channel via.LEDType
+	channel via.Channel
 	param   uint8
 }
 
@@ -23,7 +23,7 @@ type fakeInfoProtocol struct {
 	calls  []infoKey
 }
 
-func (f *fakeInfoProtocol) GetValue(channel via.LEDType, param uint8) ([]byte, error) {
+func (f *fakeInfoProtocol) GetValue(channel via.Channel, param uint8) ([]byte, error) {
 	key := infoKey{channel: channel, param: param}
 	f.calls = append(f.calls, key)
 	if err := f.errors[key]; err != nil {
@@ -32,11 +32,11 @@ func (f *fakeInfoProtocol) GetValue(channel via.LEDType, param uint8) ([]byte, e
 	return append([]byte(nil), f.values[key]...), nil
 }
 
-func (f *fakeInfoProtocol) SetValue(via.LEDType, uint8, uint8) error {
+func (f *fakeInfoProtocol) SetValue(via.Channel, uint8, uint8) error {
 	return errors.New("unexpected SetValue call")
 }
 
-func (f *fakeInfoProtocol) SetColor(via.LEDType, uint8, uint8) error {
+func (f *fakeInfoProtocol) SetColor(via.Channel, uint8, uint8) error {
 	return errors.New("unexpected SetColor call")
 }
 

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

@@ -227,7 +227,7 @@ func NewProfileLoadCmd() *cobra.Command {
 			applied := 0
 			for _, zone := range filterZones(profileZones, selected) {
 				settings := p.Zones[zone]
-				channel := via.LEDType(zone.Channel())
+				channel := via.Channel(zone.Channel())
 
 				// Resolve effect name to ID
 				targets, _, err := intrgb.ResolveEffect(settings.Effect, []intrgb.Zone{zone})

+ 49 - 10
cmd/qmk-rgb-tool/rgb.go

@@ -11,13 +11,13 @@ import (
 )
 
 type zoneProtocol interface {
-	SetValue(via.LEDType, uint8, uint8) error
-	SetColor(via.LEDType, uint8, uint8) error
+	SetValue(via.Channel, uint8, uint8) error
+	SetColor(via.Channel, uint8, uint8) error
 }
 
 type rgbProtocol interface {
 	zoneProtocol
-	GetValue(via.LEDType, uint8) ([]byte, error)
+	GetValue(via.Channel, uint8) ([]byte, error)
 	Close() error
 }
 
@@ -25,7 +25,46 @@ var openRGBProtocol = func() (rgbProtocol, error) {
 	return OpenDevice()
 }
 
-func forEachSelectedZone(zones []intrgb.Zone, fn func(intrgb.Zone, via.LEDType) error) error {
+// keyboardFor is a seam for tests; it reads the optional keyboards.json.
+var keyboardFor = intdevice.KeyboardFor
+
+// targetDeviceData is everything a command needs before it opens the keyboard.
+type targetDeviceData struct {
+	Device  intdevice.Device
+	Display map[uint16]string
+	// Requested is nil when no --zone was given, which means every channel
+	// the keyboard has.
+	Requested []via.Channel
+}
+
+// prepareTarget resolves the keyboard, its display names and the requested
+// channels. Enumeration does not open a HID handle, so an unusable --zone value
+// is still rejected before the device is opened.
+func prepareTarget() (targetDeviceData, error) {
+	devices, err := discoverAll()
+	if err != nil {
+		return targetDeviceData{}, fmt.Errorf("discover: %w", err)
+	}
+
+	dev, err := selectDevice(devices, targetDevice)
+	if err != nil {
+		return targetDeviceData{}, err
+	}
+
+	keyboard, _, err := keyboardFor(dev.VendorID, dev.ProductID)
+	if err != nil {
+		return targetDeviceData{}, err
+	}
+
+	requested, err := resolveZoneName(targetZone, keyboard.Channels)
+	if err != nil {
+		return targetDeviceData{}, err
+	}
+
+	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 {
@@ -36,7 +75,7 @@ func forEachSelectedZone(zones []intrgb.Zone, fn func(intrgb.Zone, via.LEDType)
 }
 
 func setValueOnZones(proto zoneProtocol, zones []intrgb.Zone, param, value uint8) error {
-	return forEachSelectedZone(zones, func(_ intrgb.Zone, channel via.LEDType) error {
+	return forEachSelectedZone(zones, func(_ intrgb.Zone, channel via.Channel) error {
 		return proto.SetValue(channel, param, value)
 	})
 }
@@ -95,7 +134,7 @@ func setValueVerified(proto rgbProtocol, zones []intrgb.Zone, param, value uint8
 
 	results := make([]zoneResult, 0, len(zones))
 	for _, zone := range zones {
-		raw, err := proto.GetValue(via.LEDType(zone.Channel()), param)
+		raw, err := proto.GetValue(via.Channel(zone.Channel()), param)
 		if err != nil {
 			return nil, fmt.Errorf("read back value 0x%02x for %s: %w", param, zone, err)
 		}
@@ -145,7 +184,7 @@ func setColorVerified(proto rgbProtocol, zones []intrgb.Zone, hue, saturation ui
 
 	results := make([]colorResult, 0, len(zones))
 	for _, zone := range zones {
-		raw, err := proto.GetValue(via.LEDType(zone.Channel()), uint8(intrgb.ColorValue))
+		raw, err := proto.GetValue(via.Channel(zone.Channel()), uint8(intrgb.ColorValue))
 		if err != nil {
 			return nil, fmt.Errorf("read back color for %s: %w", zone, err)
 		}
@@ -168,13 +207,13 @@ func setSpeedOnZones(proto zoneProtocol, zones []intrgb.Zone, value uint8) error
 }
 
 func setColorOnZones(proto zoneProtocol, zones []intrgb.Zone, hue, saturation uint8) error {
-	return forEachSelectedZone(zones, func(_ intrgb.Zone, channel via.LEDType) error {
+	return forEachSelectedZone(zones, func(_ intrgb.Zone, channel via.Channel) error {
 		return proto.SetColor(channel, hue, saturation)
 	})
 }
 
 func disableLightingOnZones(proto zoneProtocol, zones []intrgb.Zone) error {
-	return forEachSelectedZone(zones, func(_ intrgb.Zone, channel via.LEDType) error {
+	return forEachSelectedZone(zones, func(_ intrgb.Zone, channel via.Channel) error {
 		if err := proto.SetValue(channel, uint8(intrgb.EffectID), 0); err != nil {
 			return err
 		}
@@ -183,7 +222,7 @@ func disableLightingOnZones(proto zoneProtocol, zones []intrgb.Zone) error {
 }
 
 func enableLightingOnZones(proto zoneProtocol, zones []intrgb.Zone) error {
-	return forEachSelectedZone(zones, func(zone intrgb.Zone, channel via.LEDType) 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 {
 			return err
 		}

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

@@ -115,7 +115,7 @@ func TestSelectedZonesExplicitZones(t *testing.T) {
 
 func TestZoneChannels(t *testing.T) {
 	got := zoneChannels(intrgb.AllZones())
-	want := []via.LEDType{2, 3, 4}
+	want := []via.Channel{2, 3, 4}
 	if !reflect.DeepEqual(got, want) {
 		t.Errorf("zoneChannels() = %v, want %v", got, want)
 	}

+ 15 - 15
cmd/qmk-rgb-tool/speed_verify_test.go

@@ -9,7 +9,7 @@ import (
 	"netdome.biz/paul/qmk-rgb/internal/via"
 )
 
-func runSpeed(t *testing.T, applied map[via.LEDType]uint8, zoneFlag string, arg string) (stdout, stderr string) {
+func runSpeed(t *testing.T, applied map[via.Channel]uint8, zoneFlag string, arg string) (stdout, stderr string) {
 	t.Helper()
 
 	proto := &verifyingProtocol{applied: applied}
@@ -39,10 +39,10 @@ func runSpeed(t *testing.T, applied map[via.LEDType]uint8, zoneFlag string, arg
 // channels, so `speed 60` claiming success there is the same lie brightness
 // had.
 func TestSpeedReportsPlainMessageWhenAllZonesMatch(t *testing.T) {
-	stdout, stderr := runSpeed(t, map[via.LEDType]uint8{
-		via.RGBLight:  60,
-		via.RGBMatrix: 60,
-		via.SideLight: 60,
+	stdout, stderr := runSpeed(t, map[via.Channel]uint8{
+		via.ChannelRgblight:  60,
+		via.ChannelRgbMatrix: 60,
+		via.ChannelAudio:     60,
 	}, "", "60")
 
 	if strings.TrimSpace(stdout) != "Speed set to 60" {
@@ -54,10 +54,10 @@ func TestSpeedReportsPlainMessageWhenAllZonesMatch(t *testing.T) {
 }
 
 func TestSpeedSummarisesAppliedValuesOnMismatch(t *testing.T) {
-	stdout, _ := runSpeed(t, map[via.LEDType]uint8{
-		via.RGBLight:  4,
-		via.RGBMatrix: 60,
-		via.SideLight: 4,
+	stdout, _ := runSpeed(t, map[via.Channel]uint8{
+		via.ChannelRgblight:  4,
+		via.ChannelRgbMatrix: 60,
+		via.ChannelAudio:     4,
 	}, "", "60")
 
 	for _, want := range []string{"logo 4", "backlight 60", "side 4", "requested 60"} {
@@ -74,7 +74,7 @@ func TestSpeedSummarisesAppliedValuesOnMismatch(t *testing.T) {
 }
 
 func TestSpeedNamesOnlySelectedZones(t *testing.T) {
-	stdout, _ := runSpeed(t, map[via.LEDType]uint8{via.SideLight: 4}, "side", "60")
+	stdout, _ := runSpeed(t, map[via.Channel]uint8{via.ChannelAudio: 4}, "side", "60")
 
 	if !strings.Contains(stdout, "side 4") {
 		t.Errorf("stdout = %q, want side reported", stdout)
@@ -88,10 +88,10 @@ func TestSpeedNamesOnlySelectedZones(t *testing.T) {
 
 // speed 0 is a genuine value on every channel, not a collapsed one.
 func TestSpeedZeroIsReportedAsApplied(t *testing.T) {
-	stdout, _ := runSpeed(t, map[via.LEDType]uint8{
-		via.RGBLight:  0,
-		via.RGBMatrix: 0,
-		via.SideLight: 0,
+	stdout, _ := runSpeed(t, map[via.Channel]uint8{
+		via.ChannelRgblight:  0,
+		via.ChannelRgbMatrix: 0,
+		via.ChannelAudio:     0,
 	}, "", "0")
 
 	if strings.TrimSpace(stdout) != "Speed set to 0" {
@@ -102,7 +102,7 @@ func TestSpeedZeroIsReportedAsApplied(t *testing.T) {
 // setValueVerified must be parameter-agnostic so both commands share it.
 func TestSetValueVerifiedReadsBackTheRequestedParam(t *testing.T) {
 	proto := &verifyingProtocol{
-		applied: map[via.LEDType]uint8{via.RGBMatrix: 159},
+		applied: map[via.Channel]uint8{via.ChannelRgbMatrix: 159},
 	}
 
 	results, err := setValueVerified(proto, []intrgb.Zone{intrgb.ZoneBacklight},

+ 86 - 3
cmd/qmk-rgb-tool/zones.go

@@ -1,10 +1,93 @@
 package main
 
 import (
+	"fmt"
+	"sort"
+
 	intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
 	"netdome.biz/paul/qmk-rgb/internal/via"
 )
 
+// resolveZoneName maps a --zone value to the channels it may mean, without
+// opening the keyboard. A display name of the connected board wins over a
+// subsystem name, and a value that would mean two channels is refused rather
+// than resolved to one of them.
+//
+// An empty name means every channel and is reported as a nil slice.
+func resolveZoneName(name string, display map[uint16]string) ([]via.Channel, error) {
+	if name == "" {
+		return nil, nil
+	}
+
+	if channels := displayNameChannels(display, name); len(channels) > 1 {
+		return nil, fmt.Errorf("zone %q matches several channels of this keyboard; keyboards.json gives the same name to more than one", name)
+	} else if len(channels) == 1 {
+		return channels, nil
+	}
+
+	for c := via.Channel(1); c <= via.AssignedChannelMax; c++ {
+		if c.Subsystem() == name {
+			return []via.Channel{c}, nil
+		}
+	}
+
+	return nil, fmt.Errorf("unknown zone %q: use a channel name such as rgb_matrix, or a name from keyboards.json", name)
+}
+
+// displayNameChannels returns every channel the board names displayName.
+func displayNameChannels(display map[uint16]string, displayName string) []via.Channel {
+	var channels []via.Channel
+	for number, name := range display {
+		if name == displayName {
+			channels = append(channels, via.Channel(number))
+		}
+	}
+	sort.Slice(channels, func(i, j int) bool { return channels[i] < channels[j] })
+	return channels
+}
+
+// displayNameConflicts rejects a board whose display name is also the subsystem
+// name of a different channel the keyboard actually has. A name that means two
+// channels is a silent retarget, so the tool refuses it.
+//
+// Presence is only known after the probe, which is why this is checked here
+// rather than when the file is read: a static check would reject the Impact 80,
+// which calls channel 3 "backlight" while channel 1 is absent.
+func displayNameConflicts(display map[uint16]string, present []via.Channel) error {
+	names := make([]string, 0, len(display))
+	for _, name := range display {
+		names = append(names, name)
+	}
+	sort.Strings(names)
+
+	for _, name := range names {
+		named := via.Channel(0)
+		for _, ch := range displayNameChannels(display, name) {
+			named = ch
+			break
+		}
+		for _, other := range present {
+			if other != named && other.Subsystem() == name {
+				return fmt.Errorf("keyboards.json: channel %d is named %q, which is also the subsystem name of channel %d", named, name, other)
+			}
+		}
+	}
+	return nil
+}
+
+// channelName is how a channel is printed: the board's display name when it has
+// one, otherwise the QMK subsystem name.
+func channelName(c via.Channel, display map[uint16]string) string {
+	if name, ok := display[uint16(c)]; ok {
+		return name
+	}
+	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
@@ -16,10 +99,10 @@ func selectedZones() ([]intrgb.Zone, error) {
 	return []intrgb.Zone{zone}, nil
 }
 
-func zoneChannels(zones []intrgb.Zone) []via.LEDType {
-	var channels []via.LEDType
+func zoneChannels(zones []intrgb.Zone) []via.Channel {
+	var channels []via.Channel
 	for _, zone := range zones {
-		channels = append(channels, via.LEDType(zone.Channel()))
+		channels = append(channels, via.Channel(zone.Channel()))
 	}
 	return channels
 }