package rgb import ( "fmt" "strings" "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" // Effect is one named effect on a channel: the ID the keyboard uses and the name // a definition gives it. The pair is explicit because a VIA definition may // attach a number to an option that is not its position in the list, so a // board's effect ID is not an index into its names. type Effect struct { ID uint8 Name string } // Catalog is one board's effect names, per channel. The keyboard holds numbers, // not names, so `effect ` needs a catalog and a board without one is // driven through raw IDs. type Catalog struct { board string names map[via.Channel][]Effect aliases map[via.Channel]map[string]string defaults map[via.Channel]uint8 } // dense turns a name list whose index is the effect ID into effect entries. It // is how a hand-written list becomes a catalog; a definition carries its IDs // itself and does not go through here. func dense(names []string) []Effect { effects := make([]Effect, 0, len(names)) for id, name := range names { effects = append(effects, Effect{ID: uint8(id), Name: name}) } return effects } // NewCatalog returns a catalog for a board described by effect entries, as a VIA // definition file provides them. The board name is what `effect --list` reports; // the entries may leave gaps, because a definition names only the effects a // board's firmware implements. // // The tool's aliases come with it, because they are a spelling convenience and // not board knowledge: a definition writes the manufacturer's spelling, and the // user should not have to know which one it chose. func NewCatalog(board string, effects map[via.Channel][]Effect) *Catalog { return &Catalog{board: board, names: effects, aliases: defaultAliases} } // defaultAliases are the spellings every catalog accepts, whatever the source of // its names. They resolve per channel, so `rainbow` is the vendor's `spectrum` // where that is the name and the catalog's own on another channel. var defaultAliases = 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", }, } // 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 the QMK rgb_matrix_effects.inc of the VIA era, // in order, transcribed from this board's vendor VIA definition. They are not // current QMK master, which has 45 entries under other names and other IDs, // so the list must not be repaired against upstream. Each of the 46 IDs was // taken by the keyboard when written; the board also takes ID 46, which no // name in this list covers. // - 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 keyboard takes these IDs only from 1 to 6: ID 0 means "lighting off" // and leaves the mode register untouched. // - The 47th backlight name, "freeze" at ID 46, is the tool's own. The // vendor's JSON stops at 45 and names no such behaviour; the name and the // behaviour behind it were measured on an Impact 80 and are recorded in // impact80.go. It is a tool name, not a name the board uses. // // Both lists come from the VIA JSON the vendor's Driver & Firmware page links at // https://wiki.wobkey.com/en/Products/PMOKEY-Impact-80/Driver-Firmware // (the file itself: https://drive.wobkey.com/f/d/6BtO/Impact_80.JSON). That page // is also where the firmware variants are: the proprietary one offers the // advanced lighting but disables VIA, so a board running it has no Raw HID // interface and is invisible here rather than unsupported. // - 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][]Effect{ via.ChannelRgblight: dense(impact80LogoEffects[:]), via.ChannelRgbMatrix: dense(impact80BacklightEffects[:]), via.ChannelAudio: dense(impact80SideEffects[:]), }, aliases: defaultAliases, 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 } // Effects returns a channel's named effects, or nil when the catalog says // nothing about it. func (c *Catalog) Effects(ch via.Channel) []Effect { if c == nil { return nil } return c.names[ch] } // Names returns the effect names of a channel in effect-ID order, or nil when // the catalog says nothing about it. func (c *Catalog) Names(ch via.Channel) []string { effects := c.Effects(ch) if effects == nil { return nil } names := make([]string, 0, len(effects)) for _, e := range effects { names = append(names, e.Name) } return names } // 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 } for _, e := range c.names[ch] { if e.ID == id { return e.Name } } return unknownEffectName } // 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 } if id, found := c.byName(ch, name); found { return id, true } if canonical, ok := c.aliases[ch][name]; ok { if id, found := c.EffectID(ch, canonical); found { return id, true } } // A definition may carry the other spelling of the same pair: the tool's // alias table says "breathe" for "breathing", and a manufacturer's file // may use either. So a name that is the target of an alias resolves to // that alias where the channel has it. for alias, canonical := range c.aliases[ch] { if canonical != name { continue } if id, found := c.byName(ch, alias); found { return id, true } } return 0, false } // byName finds an effect by its exact name, without following an alias, then by // the same name with spaces written as underscores. A definition carries display // spellings where the tool carries identifiers, and the difference is // whitespace, not a different effect: "fixed wave" and "fixed_wave" name the // same thing, and the tool's documented spelling has to keep working while a // definition file is present. func (c *Catalog) byName(ch via.Channel, name string) (uint8, bool) { for _, e := range c.names[ch] { if e.Name == name { return e.ID, true } } spaced := strings.ReplaceAll(name, "_", " ") if spaced != name { for _, e := range c.names[ch] { if e.Name == spaced { return e.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 the caller asked for one channel explicitly, because a // command that silently did nothing looks like a command that worked. That is // the caller's knowledge to pass: a board with a single lighting channel is not // an explicit request for it. func ResolveEffect(catalog *Catalog, name string, channels []via.Channel, explicit bool) ([]EffectTarget, []string, error) { if catalog == nil { return nil, nil, fmt.Errorf("no effect catalog for this keyboard; set an effect by number with `mode `") } // The compatibility spelling every catalog shares, so a caller cannot // resolve "static" differently from another. if name == "static" { name = "solid" } // Whether the board has the name at all is a different question from // whether the channels asked for can do it: naming a real effect on the // wrong channel is a different mistake, with a different fix, and gets its // own message. knownAnywhere := false for ch := range catalog.names { if _, ok := catalog.EffectID(ch, name); ok { knownAnywhere = true break } } if !knownAnywhere { return nil, nil, fmt.Errorf("unknown effect: %s", name) } 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 }