Selaa lähdekoodia

make the effect catalog a property of the board, not the zone

Paul Klumpp 1 viikko sitten
vanhempi
commit
eee5e8c408

+ 177 - 0
internal/rgb/catalog.go

@@ -0,0 +1,177 @@
+package rgb
+
+import (
+	"fmt"
+
+	"netdome.biz/paul/qmk-rgb/internal/via"
+)
+
+// EffectTarget is one effect ID to write to one channel.
+type EffectTarget struct {
+	Channel via.Channel
+	ID      uint8
+}
+
+const unknownEffectName = "unknown"
+
+// Catalog is one board's effect names, per channel. The keyboard holds numbers,
+// not names, so `effect <name>` needs a catalog and a board without one is
+// driven through raw IDs.
+type Catalog struct {
+	board    string
+	names    map[via.Channel][]string
+	aliases  map[via.Channel]map[string]string
+	defaults map[via.Channel]uint8
+}
+
+// CatalogFor returns the effect catalog of a board, and whether one exists.
+//
+// Provenance, because these lists look invented and are not:
+//
+//   - The 46 backlight names are QMK's rgb_matrix_effects.inc in order, each ID
+//     verified against the live register of an Impact 80.
+//   - The 7 logo and 7 side names are this board's vendor VIA definition, whose
+//     dropdowns read "fixed wave" and "breathe". The tool spells them
+//     fixed_wave and breathing and accepts the vendor's spellings as aliases.
+//   - The brightness and speed transforms documented in README.md were measured
+//     on the unit, not read from anywhere.
+func CatalogFor(vendorID, productID uint16) (*Catalog, bool) {
+	if vendorID != 0x36B0 || productID != 0x309F {
+		return nil, false
+	}
+	return &Catalog{
+		board: "impact80",
+		names: map[via.Channel][]string{
+			via.ChannelRgblight:  impact80LogoEffects[:],
+			via.ChannelRgbMatrix: impact80BacklightEffects[:],
+			via.ChannelAudio:     impact80SideEffects[:],
+		},
+		aliases: map[via.Channel]map[string]string{
+			via.ChannelRgblight: {
+				"off":          "none",
+				"breathe":      "breathing",
+				"rainbow":      "spectrum",
+				"rainbow_wave": "wave",
+				"solid":        "light",
+				"static":       "solid",
+			},
+			via.ChannelRgbMatrix: {
+				"off":     "none",
+				"breathe": "breathing",
+				"rainbow": "rainbow_moving_chevron",
+				"solid":   "solid_color",
+				"static":  "solid",
+			},
+			via.ChannelAudio: {
+				"off":          "none",
+				"breathe":      "breathing",
+				"rainbow":      "spectrum",
+				"rainbow_wave": "wave",
+				"solid":        "light",
+				"static":       "solid",
+			},
+		},
+		defaults: map[via.Channel]uint8{
+			via.ChannelRgblight:  4,
+			via.ChannelRgbMatrix: 5,
+			via.ChannelAudio:     4,
+		},
+	}, true
+}
+
+// Name returns the catalog's board name, which `effect --list` reports.
+func (c *Catalog) Name() string {
+	if c == nil {
+		return ""
+	}
+	return c.board
+}
+
+// Names returns the effect names of a channel, or nil when the catalog says
+// nothing about it.
+func (c *Catalog) Names(ch via.Channel) []string {
+	if c == nil {
+		return nil
+	}
+	return c.names[ch]
+}
+
+// EffectName returns the name of an effect ID, or "unknown" when the catalog has
+// no entry for it.
+func (c *Catalog) EffectName(ch via.Channel, id uint8) string {
+	if c == nil {
+		return unknownEffectName
+	}
+	names := c.names[ch]
+	if int(id) >= len(names) {
+		return unknownEffectName
+	}
+	return names[id]
+}
+
+// EffectID resolves an effect name on a channel, following one level of alias.
+func (c *Catalog) EffectID(ch via.Channel, name string) (uint8, bool) {
+	if c == nil {
+		return 0, false
+	}
+	for id, candidate := range c.names[ch] {
+		if candidate == name {
+			return uint8(id), true
+		}
+	}
+	if canonical, ok := c.aliases[ch][name]; ok {
+		for id, candidate := range c.names[ch] {
+			if candidate == canonical {
+				return uint8(id), true
+			}
+		}
+	}
+	return 0, false
+}
+
+// DefaultEffect returns the effect ID enable writes when turning a channel on.
+func (c *Catalog) DefaultEffect(ch via.Channel) (uint8, bool) {
+	if c == nil {
+		return 0, false
+	}
+	id, ok := c.defaults[ch]
+	return id, ok
+}
+
+// ResolveEffect turns an effect name into one target per channel that supports
+// it, plus the subsystem names of those that do not. A skip is an error rather
+// than a warning when a single channel was asked for, because a command that
+// silently did nothing looks like a command that worked.
+func ResolveEffect(catalog *Catalog, name string, channels []via.Channel) ([]EffectTarget, []string, error) {
+	if catalog == nil {
+		return nil, nil, fmt.Errorf("no effect catalog for this keyboard; set an effect by number with `mode <index>`")
+	}
+
+	known := false
+	for _, ch := range channels {
+		if _, ok := catalog.EffectID(ch, name); ok {
+			known = true
+			break
+		}
+	}
+	if !known {
+		return nil, nil, fmt.Errorf("unknown effect: %s", name)
+	}
+
+	explicit := len(channels) == 1
+	var targets []EffectTarget
+	var skipped []string
+
+	for _, ch := range channels {
+		id, ok := catalog.EffectID(ch, name)
+		if !ok {
+			if explicit {
+				return nil, nil, fmt.Errorf("effect %s is not supported on %s", name, ch.Subsystem())
+			}
+			skipped = append(skipped, ch.Subsystem())
+			continue
+		}
+		targets = append(targets, EffectTarget{Channel: ch, ID: id})
+	}
+	return targets, skipped, nil
+}

+ 128 - 0
internal/rgb/catalog_test.go

@@ -0,0 +1,128 @@
+package rgb
+
+import (
+	"strings"
+	"testing"
+
+	"netdome.biz/paul/qmk-rgb/internal/via"
+)
+
+func impact80(t *testing.T) *Catalog {
+	t.Helper()
+	catalog, ok := CatalogFor(0x36B0, 0x309F)
+	if !ok {
+		t.Fatal("CatalogFor(0x36b0, 0x309f) = not found, want the Impact 80 catalog")
+	}
+	return catalog
+}
+
+func TestCatalogHasTheVendorEffectFamilies(t *testing.T) {
+	catalog := impact80(t)
+
+	if got := len(catalog.Names(via.ChannelRgbMatrix)); got != 46 {
+		t.Errorf("backlight effects = %d, want 46", got)
+	}
+	if got := len(catalog.Names(via.ChannelRgblight)); got != 7 {
+		t.Errorf("logo effects = %d, want 7", got)
+	}
+	if got := len(catalog.Names(via.ChannelAudio)); got != 7 {
+		t.Errorf("side effects = %d, want 7", got)
+	}
+}
+
+func TestCatalogIsEmptyForAnUnknownBoard(t *testing.T) {
+	if _, ok := CatalogFor(0x6666, 0x0001); ok {
+		t.Error("CatalogFor(0x6666, 0x0001) = found, want none")
+	}
+}
+
+func TestCatalogIDResolvesNamesAndAliases(t *testing.T) {
+	catalog := impact80(t)
+
+	tests := []struct {
+		channel via.Channel
+		name    string
+		want    uint8
+	}{
+		{via.ChannelRgblight, "light", 5},
+		{via.ChannelRgblight, "solid", 5},
+		{via.ChannelRgblight, "breathe", 4},
+		{via.ChannelRgbMatrix, "solid_color", 1},
+		{via.ChannelRgbMatrix, "rainbow_moving_chevron", 17},
+	}
+
+	for _, tt := range tests {
+		got, ok := catalog.EffectID(tt.channel, tt.name)
+		if !ok {
+			t.Errorf("EffectID(%d, %q) not found", tt.channel, tt.name)
+			continue
+		}
+		if got != tt.want {
+			t.Errorf("EffectID(%d, %q) = %d, want %d", tt.channel, tt.name, got, tt.want)
+		}
+	}
+}
+
+func TestCatalogEffectNameReportsUnknownForAnUnknownID(t *testing.T) {
+	catalog := impact80(t)
+	if got := catalog.EffectName(via.ChannelRgbMatrix, 200); got != "unknown" {
+		t.Errorf("EffectName(3, 200) = %q, want %q", got, "unknown")
+	}
+}
+
+func TestCatalogDefaultEffectPerChannel(t *testing.T) {
+	catalog := impact80(t)
+
+	if got, ok := catalog.DefaultEffect(via.ChannelRgblight); !ok || got != 4 {
+		t.Errorf("DefaultEffect(2) = %d, %t, want 4, true", got, ok)
+	}
+	if got, ok := catalog.DefaultEffect(via.ChannelRgbMatrix); !ok || got != 5 {
+		t.Errorf("DefaultEffect(3) = %d, %t, want 5, true", got, ok)
+	}
+}
+
+func TestResolveEffectWithoutACatalogRefuses(t *testing.T) {
+	_, _, err := ResolveEffect(nil, "wave", []via.Channel{via.ChannelRgblight})
+	if err == nil {
+		t.Fatal("ResolveEffect(nil, ...) expected an error, got nil")
+	}
+	if !strings.Contains(err.Error(), "mode") {
+		t.Errorf("error = %q, want it to point at mode", err)
+	}
+}
+
+// ID 5 is light on channel 2 and rainbow_beacon on channel 3, so the same name
+// cannot be applied to both.
+func TestResolveEffectSkipsAChannelThatDoesNotSupportTheName(t *testing.T) {
+	catalog := impact80(t)
+
+	targets, skipped, err := ResolveEffect(catalog, "light", []via.Channel{via.ChannelRgblight, via.ChannelRgbMatrix})
+	if err != nil {
+		t.Fatalf("ResolveEffect() error = %v", err)
+	}
+	if len(targets) != 1 || targets[0].Channel != via.ChannelRgblight || targets[0].ID != 5 {
+		t.Errorf("targets = %+v, want one target on channel 2 with id 5", targets)
+	}
+	if len(skipped) != 1 || skipped[0] != "rgb_matrix" {
+		t.Errorf("skipped = %v, want [rgb_matrix]", skipped)
+	}
+}
+
+// Asking for the unsupported channel by name is an error, not a skip: a command
+// that quietly did nothing would look like a command that worked.
+func TestResolveEffectRejectsTheOnlyChannelWhenItDoesNotSupportTheName(t *testing.T) {
+	catalog := impact80(t)
+
+	_, _, err := ResolveEffect(catalog, "light", []via.Channel{via.ChannelRgbMatrix})
+	if err == nil {
+		t.Fatal("ResolveEffect() expected an error, want light rejected on the backlight")
+	}
+}
+
+func TestResolveEffectRejectsANameNoChannelKnows(t *testing.T) {
+	catalog := impact80(t)
+
+	if _, _, err := ResolveEffect(catalog, "nope", []via.Channel{via.ChannelRgblight}); err == nil {
+		t.Fatal("ResolveEffect() expected an error for an unknown name")
+	}
+}

+ 0 - 113
internal/rgb/effects.go

@@ -6,110 +6,6 @@ import (
 	"strconv"
 )
 
-// Effect defines an RGB lighting effect.
-type Effect uint8
-
-const (
-	Static       Effect = 0
-	Breathing    Effect = 1
-	EffectAlmond Effect = 2
-	BandSat      Effect = 3
-	BandVal      Effect = 4
-	BandHue      Effect = 5
-	Chroma       Effect = 6
-	Rainbow      Effect = 7
-	RainbowWave  Effect = 8
-	Dance        Effect = 9
-	RainbowDance Effect = 10
-	ChromaDance  Effect = 11
-	Snake        Effect = 12
-	Tail         Effect = 13
-	Split        Effect = 14
-	Heartbeat    Effect = 15
-	Blink        Effect = 16
-	BlinkStatic  Effect = 17
-	Gradient     Effect = 18
-	Testing      Effect = 19
-)
-
-// EffectName returns the human-readable name of an effect.
-func (e Effect) String() string {
-	names := map[Effect]string{
-		Static:       "static",
-		Breathing:    "breathing",
-		EffectAlmond: "almond",
-		BandSat:      "band_sat",
-		BandVal:      "band_val",
-		BandHue:      "band_hue",
-		Chroma:       "chroma",
-		Rainbow:      "rainbow",
-		RainbowWave:  "rainbow_wave",
-		Dance:        "dance",
-		RainbowDance: "rainbow_dance",
-		ChromaDance:  "chroma_dance",
-		Snake:        "snake",
-		Tail:         "tail",
-		Split:        "split",
-		Heartbeat:    "heartbeat",
-		Blink:        "blink",
-		BlinkStatic:  "blink_static",
-		Gradient:     "gradient",
-		Testing:      "testing",
-	}
-	if name, ok := names[e]; ok {
-		return name
-	}
-	return "unknown"
-}
-
-// ParseEffect converts a string name to an Effect.
-func ParseEffect(name string) (Effect, error) {
-	switch name {
-	case "static":
-		return Static, nil
-	case "breathing":
-		return Breathing, nil
-	case "almond":
-		return EffectAlmond, nil
-	case "band_sat":
-		return BandSat, nil
-	case "band_val":
-		return BandVal, nil
-	case "band_hue":
-		return BandHue, nil
-	case "chroma":
-		return Chroma, nil
-	case "rainbow":
-		return Rainbow, nil
-	case "rainbow_wave":
-		return RainbowWave, nil
-	case "dance":
-		return Dance, nil
-	case "rainbow_dance":
-		return RainbowDance, nil
-	case "chroma_dance":
-		return ChromaDance, nil
-	case "snake":
-		return Snake, nil
-	case "tail":
-		return Tail, nil
-	case "split":
-		return Split, nil
-	case "heartbeat":
-		return Heartbeat, nil
-	case "blink":
-		return Blink, nil
-	case "blink_static":
-		return BlinkStatic, nil
-	case "gradient":
-		return Gradient, nil
-	case "testing":
-		return Testing, nil
-	default:
-		return 0, fmt.Errorf("unknown effect: %s", name)
-	}
-}
-
 // LEDParam defines QMK rgblight parameter indices.
 type LEDParam uint8
 
@@ -151,15 +47,6 @@ func ParseHexColor(s string) (Color, error) {
 	return Color{R: uint8(r), G: uint8(g), B: uint8(b)}, nil
 }
 
-// State represents the current RGB state from the keyboard.
-type State struct {
-	Enabled    bool
-	Mode       Effect
-	Brightness uint8
-	Speed      uint8
-	Color      Color
-}
-
 // HSV converts the color to HSV (QMK rgblight uses 0-255 ranges for H, S, V).
 func (c Color) HSV() (h, s, v uint8) {
 	r, g, b := float64(c.R), float64(c.G), float64(c.B)

+ 0 - 60
internal/rgb/effects_test.go

@@ -19,66 +19,6 @@ func TestQMKValueIDs(t *testing.T) {
 	}
 }
 
-func TestParseEffect(t *testing.T) {
-	cases := []struct {
-		name  string
-		input string
-		want  Effect
-		err   bool
-	}{
-		{"static", "static", Static, false},
-		{"breathing", "breathing", Breathing, false},
-		{"rainbow", "rainbow", Rainbow, false},
-		{"rainbow_wave", "rainbow_wave", RainbowWave, false},
-		{"snake", "snake", Snake, false},
-		{"gradient", "gradient", Gradient, false},
-		{"tail", "tail", Tail, false},
-		{"unknown", "flubber", 0, true},
-		{"empty", "", 0, true},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			got, err := ParseEffect(tc.input)
-			if tc.err {
-				if err == nil {
-					t.Errorf("ParseEffect(%q) expected error, got nil", tc.input)
-				}
-				return
-			}
-			if err != nil {
-				t.Errorf("ParseEffect(%q) unexpected error: %v", tc.input, err)
-				return
-			}
-			if got != tc.want {
-				t.Errorf("ParseEffect(%q) = %d, want %d", tc.input, got, tc.want)
-			}
-		})
-	}
-}
-
-func TestEffectString(t *testing.T) {
-	cases := []struct {
-		effect Effect
-		want   string
-	}{
-		{Static, "static"},
-		{Breathing, "breathing"},
-		{Rainbow, "rainbow"},
-		{RainbowWave, "rainbow_wave"},
-		{Effect(99), "unknown"},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.want, func(t *testing.T) {
-			got := tc.effect.String()
-			if got != tc.want {
-				t.Errorf("Effect(%d).String() = %q, want %q", tc.effect, got, tc.want)
-			}
-		})
-	}
-}
-
 func TestParseHexColor(t *testing.T) {
 	cases := []struct {
 		name  string

+ 2 - 178
internal/rgb/impact80.go

@@ -1,19 +1,7 @@
 package rgb
 
-import "fmt"
-
-type Zone string
-
-const (
-	ZoneLogo      Zone = "logo"
-	ZoneBacklight Zone = "backlight"
-	ZoneSide      Zone = "side"
-)
-
-type EffectTarget struct {
-	Zone Zone
-	ID   uint8
-}
+// The effect names of this board, in ID order. Their provenance is recorded on
+// CatalogFor, which is the only entry point that hands them out.
 
 var impact80BacklightEffects = [...]string{
 	"none",
@@ -83,167 +71,3 @@ var impact80SideEffects = [...]string{
 	"light",
 	"shutdown",
 }
-
-var impact80EffectAliases = map[Zone]map[string]string{
-	ZoneLogo: {
-		"off":          "none",
-		"breathe":      "breathing",
-		"rainbow":      "spectrum",
-		"rainbow_wave": "wave",
-		"solid":        "light",
-	},
-	ZoneBacklight: {
-		"off":     "none",
-		"breathe": "breathing",
-		"rainbow": "rainbow_moving_chevron",
-		"solid":   "solid_color",
-	},
-	ZoneSide: {
-		"off":          "none",
-		"breathe":      "breathing",
-		"rainbow":      "spectrum",
-		"rainbow_wave": "wave",
-		"solid":        "light",
-	},
-}
-
-func AllZones() []Zone {
-	return []Zone{ZoneLogo, ZoneBacklight, ZoneSide}
-}
-
-func ParseZone(value string) (Zone, error) {
-	zone := Zone(value)
-	switch zone {
-	case ZoneLogo, ZoneBacklight, ZoneSide:
-		return zone, nil
-	default:
-		return "", fmt.Errorf("unknown zone: %s", value)
-	}
-}
-
-func (z Zone) Channel() uint8 {
-	switch z {
-	case ZoneLogo:
-		return 2
-	case ZoneBacklight:
-		return 3
-	case ZoneSide:
-		return 4
-	default:
-		return 0
-	}
-}
-
-func EffectName(zone Zone, id uint8) string {
-	names := impact80EffectNames(zone)
-	if int(id) >= len(names) {
-		return "unknown"
-	}
-	return names[id]
-}
-
-func DefaultEffect(zone Zone) uint8 {
-	switch zone {
-	case ZoneLogo, ZoneSide:
-		return 4
-	case ZoneBacklight:
-		return 5
-	default:
-		return 0
-	}
-}
-
-func ResolveEffect(name string, zones []Zone) ([]EffectTarget, []Zone, error) {
-	for _, zone := range zones {
-		if impact80EffectNames(zone) == nil {
-			return nil, nil, fmt.Errorf("unknown zone: %s", zone)
-		}
-	}
-
-	known := false
-	for _, zone := range AllZones() {
-		if _, ok := resolveEffectID(zone, name); ok {
-			known = true
-			break
-		}
-	}
-	if !known {
-		return nil, nil, fmt.Errorf("unknown effect: %s", name)
-	}
-
-	explicit := len(zones) == 1
-	var targets []EffectTarget
-	var skipped []Zone
-	for _, zone := range zones {
-		id, ok := resolveEffectID(zone, name)
-		if !ok {
-			if explicit {
-				return nil, nil, fmt.Errorf("effect %s is not supported on zone %s", name, zone)
-			}
-			skipped = append(skipped, zone)
-			continue
-		}
-		targets = append(targets, EffectTarget{Zone: zone, ID: id})
-	}
-	return targets, skipped, nil
-}
-
-func impact80EffectNames(zone Zone) []string {
-	switch zone {
-	case ZoneLogo:
-		return impact80LogoEffects[:]
-	case ZoneBacklight:
-		return impact80BacklightEffects[:]
-	case ZoneSide:
-		return impact80SideEffects[:]
-	default:
-		return nil
-	}
-}
-
-func resolveEffectID(zone Zone, name string) (uint8, bool) {
-	for id, effectName := range impact80EffectNames(zone) {
-		if effectName == name {
-			return uint8(id), true
-		}
-	}
-	if canonical, ok := impact80EffectAliases[zone][name]; ok {
-		return resolveEffectID(zone, canonical)
-	}
-	return 0, false
-}
-
-func Impact80EffectName(value uint8) string {
-	return EffectName(ZoneLogo, value)
-}
-
-func ParseImpact80Effect(name string) (uint8, error) {
-	if name == "static" {
-		return 5, nil
-	}
-	targets, _, err := ResolveEffect(name, []Zone{ZoneLogo})
-	if err != nil {
-		return 0, err
-	}
-	return targets[0].ID, nil
-}
-
-// AllLogoEffects returns the effect names for the logo zone.
-func AllLogoEffects() []string {
-	return impact80LogoEffects[:]
-}
-
-// AllBacklightEffects returns the effect names for the backlight zone.
-func AllBacklightEffects() []string {
-	return impact80BacklightEffects[:]
-}
-
-// AllSideEffects returns the effect names for the side zone.
-func AllSideEffects() []string {
-	return impact80SideEffects[:]
-}
-
-// ZoneEffects returns the effect names for a zone.
-func ZoneEffects(zone Zone) []string {
-	return impact80EffectNames(zone)
-}

+ 25 - 294
internal/rgb/impact80_test.go

@@ -1,315 +1,46 @@
 package rgb
 
 import (
-	"reflect"
 	"testing"
+
+	"netdome.biz/paul/qmk-rgb/internal/via"
 )
 
-func TestAllImpact80Zones(t *testing.T) {
-	want := []Zone{ZoneLogo, ZoneBacklight, ZoneSide}
-	got := AllZones()
-	if !reflect.DeepEqual(got, want) {
-		t.Fatalf("AllZones() = %v, want %v", got, want)
+func TestImpact80CatalogHasTheVendorEffectFamilies(t *testing.T) {
+	catalog, ok := CatalogFor(0x36B0, 0x309F)
+	if !ok {
+		t.Fatal("CatalogFor() = not found")
 	}
-}
 
-func TestImpact80ZoneChannels(t *testing.T) {
-	cases := []struct {
-		zone Zone
-		want uint8
-	}{
-		{ZoneLogo, 2},
-		{ZoneBacklight, 3},
-		{ZoneSide, 4},
-	}
-	for _, tc := range cases {
-		if got := tc.zone.Channel(); got != tc.want {
-			t.Errorf("%s.Channel() = %d, want %d", tc.zone, got, tc.want)
+	logoWant := []string{"none", "wave", "fixed_wave", "spectrum", "breathing", "light", "shutdown"}
+	for id, want := range logoWant {
+		if got := catalog.EffectName(via.ChannelRgblight, uint8(id)); got != want {
+			t.Errorf("logo effect %d = %q, want %q", id, got, want)
 		}
 	}
-}
 
-func TestImpact80ParseZone(t *testing.T) {
-	cases := []struct {
-		input string
-		want  Zone
-		valid bool
-	}{
-		{"logo", ZoneLogo, true},
-		{"backlight", ZoneBacklight, true},
-		{"side", ZoneSide, true},
-		{"matrix", "", false},
-		{"", "", false},
+	backlightWant := map[int]string{
+		0: "none", 1: "solid_color", 5: "breathing", 17: "rainbow_moving_chevron",
+		29: "pixel_flow", 37: "solid_reactive_multinexus", 45: "riverflow",
 	}
-	for _, tc := range cases {
-		got, err := ParseZone(tc.input)
-		if tc.valid {
-			if err != nil {
-				t.Errorf("ParseZone(%q) unexpected error: %v", tc.input, err)
-				continue
-			}
-			if got != tc.want {
-				t.Errorf("ParseZone(%q) = %q, want %q", tc.input, got, tc.want)
-			}
-			continue
-		}
-		if err == nil {
-			t.Errorf("ParseZone(%q) expected error, got nil", tc.input)
+	for id, want := range backlightWant {
+		if got := catalog.EffectName(via.ChannelRgbMatrix, uint8(id)); got != want {
+			t.Errorf("backlight effect %d = %q, want %q", id, got, want)
 		}
 	}
 }
 
-func TestImpact80BacklightCatalog(t *testing.T) {
-	want := []string{
-		"none",
-		"solid_color",
-		"alphas_mods",
-		"gradient_up_down",
-		"gradient_left_right",
-		"breathing",
-		"band_sat",
-		"band_val",
-		"band_pinwheel_sat",
-		"band_pinwheel_val",
-		"band_spiral_sat",
-		"band_spiral_val",
-		"cycle_all",
-		"cycle_left_right",
-		"cycle_up_down",
-		"cycle_out_in",
-		"cycle_out_in_dual",
-		"rainbow_moving_chevron",
-		"cycle_pinwheel",
-		"cycle_spiral",
-		"dual_beacon",
-		"rainbow_beacon",
-		"rainbow_pinwheels",
-		"flower_blooming",
-		"raindrops",
-		"jellybean_raindrops",
-		"hue_breathing",
-		"hue_pendulum",
-		"hue_wave",
-		"pixel_flow",
-		"digital_rain",
-		"solid_reactive",
-		"solid_reactive_wide",
-		"solid_reactive_multiwide",
-		"solid_reactive_cross",
-		"solid_reactive_multicross",
-		"solid_reactive_nexus",
-		"solid_reactive_multinexus",
-		"splash",
-		"multisplash",
-		"solid_splash",
-		"solid_multisplash",
-		"starlight",
-		"starlight_dual_hue",
-		"starlight_dual_sat",
-		"riverflow",
-	}
-	if len(want) != 46 {
-		t.Fatalf("Backlight catalog fixture has %d entries, want 46", len(want))
-	}
-	for id, name := range want {
-		if got := EffectName(ZoneBacklight, uint8(id)); got != name {
-			t.Errorf("EffectName(%q, %d) = %q, want %q", ZoneBacklight, id, got, name)
-		}
-	}
-	if got := EffectName(ZoneBacklight, 46); got != "unknown" {
-		t.Errorf("EffectName(%q, 46) = %q, want %q", ZoneBacklight, got, "unknown")
-	}
-}
+func TestImpact80LogoAndSideShareTheirEffectFamily(t *testing.T) {
+	catalog, _ := CatalogFor(0x36B0, 0x309F)
 
-func TestImpact80LogoAndSideCatalog(t *testing.T) {
-	want := []string{
-		"none",
-		"wave",
-		"fixed_wave",
-		"spectrum",
-		"breathing",
-		"light",
-		"shutdown",
+	logo := catalog.Names(via.ChannelRgblight)
+	side := catalog.Names(via.ChannelAudio)
+	if len(logo) != len(side) {
+		t.Fatalf("logo has %d effects, side has %d", len(logo), len(side))
 	}
-	for _, zone := range []Zone{ZoneLogo, ZoneSide} {
-		for id, name := range want {
-			if got := EffectName(zone, uint8(id)); got != name {
-				t.Errorf("EffectName(%q, %d) = %q, want %q", zone, id, got, name)
-			}
-		}
-		if got := EffectName(zone, 7); got != "unknown" {
-			t.Errorf("EffectName(%q, 7) = %q, want %q", zone, got, "unknown")
+	for i := range logo {
+		if logo[i] != side[i] {
+			t.Errorf("effect %d: logo %q, side %q", i, logo[i], side[i])
 		}
 	}
-	if got := EffectName(Zone("matrix"), 0); got != "unknown" {
-		t.Errorf("EffectName(%q, 0) = %q, want %q", "matrix", got, "unknown")
-	}
-}
-
-func TestImpact80DefaultEffects(t *testing.T) {
-	cases := []struct {
-		zone Zone
-		want uint8
-	}{
-		{ZoneLogo, 4},
-		{ZoneBacklight, 5},
-		{ZoneSide, 4},
-		{Zone("matrix"), 0},
-	}
-	for _, tc := range cases {
-		if got := DefaultEffect(tc.zone); got != tc.want {
-			t.Errorf("DefaultEffect(%q) = %d, want %d", tc.zone, got, tc.want)
-		}
-	}
-}
-
-func TestImpact80ResolveAliases(t *testing.T) {
-	cases := []struct {
-		name    string
-		targets []EffectTarget
-		skipped []Zone
-	}{
-		{
-			name: "off",
-			targets: []EffectTarget{
-				{ZoneLogo, 0},
-				{ZoneBacklight, 0},
-				{ZoneSide, 0},
-			},
-		},
-		{
-			name: "breathe",
-			targets: []EffectTarget{
-				{ZoneLogo, 4},
-				{ZoneBacklight, 5},
-				{ZoneSide, 4},
-			},
-		},
-		{
-			name: "rainbow",
-			targets: []EffectTarget{
-				{ZoneLogo, 3},
-				{ZoneBacklight, 17},
-				{ZoneSide, 3},
-			},
-		},
-		{
-			name: "rainbow_wave",
-			targets: []EffectTarget{
-				{ZoneLogo, 1},
-				{ZoneSide, 1},
-			},
-			skipped: []Zone{ZoneBacklight},
-		},
-		{
-			name: "solid",
-			targets: []EffectTarget{
-				{ZoneLogo, 5},
-				{ZoneBacklight, 1},
-				{ZoneSide, 5},
-			},
-		},
-	}
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			targets, skipped, err := ResolveEffect(tc.name, AllZones())
-			if err != nil {
-				t.Fatalf("ResolveEffect(%q) unexpected error: %v", tc.name, err)
-			}
-			if !reflect.DeepEqual(targets, tc.targets) {
-				t.Errorf("ResolveEffect(%q) targets = %v, want %v", tc.name, targets, tc.targets)
-			}
-			if !reflect.DeepEqual(skipped, tc.skipped) {
-				t.Errorf("ResolveEffect(%q) skipped = %v, want %v", tc.name, skipped, tc.skipped)
-			}
-		})
-	}
-}
-
-func TestResolveBacklightOnlyEffect(t *testing.T) {
-	targets, skipped, err := ResolveEffect("rainbow_moving_chevron", AllZones())
-	if err != nil {
-		t.Fatal(err)
-	}
-	wantTargets := []EffectTarget{{ZoneBacklight, 17}}
-	if !reflect.DeepEqual(targets, wantTargets) {
-		t.Fatalf("ResolveEffect() targets = %v, want %v", targets, wantTargets)
-	}
-	wantSkipped := []Zone{ZoneLogo, ZoneSide}
-	if !reflect.DeepEqual(skipped, wantSkipped) {
-		t.Fatalf("ResolveEffect() skipped = %v, want %v", skipped, wantSkipped)
-	}
-}
-
-func TestResolveEffectPreservesZoneOrder(t *testing.T) {
-	zones := []Zone{ZoneSide, ZoneBacklight, ZoneLogo}
-	want := []EffectTarget{
-		{ZoneSide, 4},
-		{ZoneBacklight, 5},
-		{ZoneLogo, 4},
-	}
-	targets, skipped, err := ResolveEffect("breathing", zones)
-	if err != nil {
-		t.Fatal(err)
-	}
-	if !reflect.DeepEqual(targets, want) {
-		t.Fatalf("ResolveEffect() targets = %v, want %v", targets, want)
-	}
-	if len(skipped) != 0 {
-		t.Fatalf("ResolveEffect() skipped = %v, want none", skipped)
-	}
-}
-
-func TestResolveEffectExplicitUnsupported(t *testing.T) {
-	cases := []struct {
-		name  string
-		zones []Zone
-	}{
-		{"rainbow_moving_chevron", []Zone{ZoneLogo}},
-		{"rainbow_wave", []Zone{ZoneBacklight}},
-		{"splash", []Zone{ZoneSide}},
-	}
-	for _, tc := range cases {
-		t.Run(tc.name+string(tc.zones[0]), func(t *testing.T) {
-			targets, skipped, err := ResolveEffect(tc.name, tc.zones)
-			if err == nil {
-				t.Fatal("ResolveEffect() expected error, got nil")
-			}
-			if len(targets) != 0 {
-				t.Errorf("ResolveEffect() targets = %v, want none", targets)
-			}
-			if len(skipped) != 0 {
-				t.Errorf("ResolveEffect() skipped = %v, want none", skipped)
-			}
-		})
-	}
-}
-
-func TestResolveEffectUnknownName(t *testing.T) {
-	targets, skipped, err := ResolveEffect("not_an_effect", AllZones())
-	if err == nil {
-		t.Fatal("ResolveEffect() expected error, got nil")
-	}
-	if len(targets) != 0 {
-		t.Errorf("ResolveEffect() targets = %v, want none", targets)
-	}
-	if len(skipped) != 0 {
-		t.Errorf("ResolveEffect() skipped = %v, want none", skipped)
-	}
-}
-
-func TestResolveEffectInvalidZone(t *testing.T) {
-	if _, _, err := ResolveEffect("breathing", []Zone{Zone("matrix")}); err == nil {
-		t.Fatal("ResolveEffect() expected error, got nil")
-	}
-}
-
-func TestParseImpact80EffectStaticAlias(t *testing.T) {
-	got, err := ParseImpact80Effect("static")
-	if err != nil {
-		t.Fatalf("ParseImpact80Effect(%q) unexpected error: %v", "static", err)
-	}
-	if got != 5 {
-		t.Errorf("ParseImpact80Effect(%q) = %d, want %d", "static", got, 5)
-	}
 }