package rgb import ( "encoding/json" "fmt" "os" "sort" "strconv" "strings" "netdome.biz/paul/qmk-rgb/internal/via" ) // NameSource says where an effect name came from. It is on the effect rather // than on the file because one board's names can come from both: a definition // the manufacturer wrote and a name the user wrote over the top of one of its // entries. The tool's rule is that it never reports a value it has not verified, // and a name is the one value it cannot read back off a keyboard, so which of // the two a name is has to be visible wherever the name is. type NameSource string const ( // SourceVendor is a name from the board's own definition file. It is what the // manufacturer wrote, so it is a fact about the board. SourceVendor NameSource = "" // SourceUser is a name from the per-user names file. It is what the user // called the effect, which is a fact about them and not about the board. SourceUser NameSource = "you" ) // namesDocument is the file as it is stored. The channel is keyed by its QMK // subsystem name and the effect by its number, both as strings because that is // what a JSON object key is, and both are read back through the one vocabulary in // internal/via rather than a second table here. type namesDocument struct { Name string `json:"name,omitempty"` VendorID string `json:"vendorId"` ProductID string `json:"productId"` Channels map[string]map[string]string `json:"channels"` Aliases map[string]map[string]string `json:"aliases,omitempty"` } // Names is one board's effect names a user wrote, or overrode. // // It exists because a VIA definition file belongs to the manufacturer. The tool // used to write one of its own, which meant authoring a menu description for // someone else's keyboard and handing the user a nested JSON array to type a // name into. The names are the part that is missing and the part that is // anyone's to supply, so they are stored on their own and merged over whatever // the definition says. type Names struct { Path string Name string VendorID uint16 ProductID uint16 // effects and aliases are keyed by channel, then by effect ID or by spelling. effects map[via.Channel]map[uint8]string aliases map[via.Channel]map[string]string } // LoadNamesFile reads one names file. A file that is not a names file is an // error rather than nothing: the user put it there, and a silent "no names" would // read as the keyboard having none. func LoadNamesFile(path string) (*Names, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } var doc namesDocument if err := json.Unmarshal(raw, &doc); err != nil { return nil, fmt.Errorf("parse %s: %w", path, err) } vendorID, productID, err := namesIdentifiers(doc.VendorID, doc.ProductID) if err != nil { return nil, fmt.Errorf("parse %s: %w", path, err) } names := &Names{ Path: path, Name: doc.Name, VendorID: vendorID, ProductID: productID, effects: map[via.Channel]map[uint8]string{}, aliases: map[via.Channel]map[string]string{}, } for key, byID := range doc.Channels { ch, ok := via.ChannelFromSubsystem(key) if !ok { return nil, fmt.Errorf("parse %s: %q is not a QMK lighting channel", path, key) } for rawID, name := range byID { id, err := parseEffectID(rawID) if err != nil { return nil, fmt.Errorf("parse %s: %s channel %q: %w", path, key, rawID, err) } if names.effects[ch] == nil { names.effects[ch] = map[uint8]string{} } names.effects[ch][id] = name } } for key, byName := range doc.Aliases { ch, ok := via.ChannelFromSubsystem(key) if !ok { return nil, fmt.Errorf("parse %s: %q is not a QMK lighting channel", path, key) } if names.aliases[ch] == nil { names.aliases[ch] = map[string]string{} } for spelling, name := range byName { names.aliases[ch][spelling] = name } } return names, nil } // Matches reports whether these names are the ones for a board. func (n *Names) Matches(vendorID, productID uint16) bool { return n.VendorID == vendorID && n.ProductID == productID } // Apply returns a catalog with these names over the top of the ones it holds. // // It works on a nil catalog, because a board with no definition file is exactly // the board a user writes names for: the file the manufacturer would have // published is the one that is missing, not the names. A name here replaces the // vendor's for that one effect ID and leaves every other entry alone, so // overriding one name does not mean restating the ones that were right. func (n *Names) Apply(base *Catalog) *Catalog { merged := make(map[via.Channel][]Effect) var aliases = map[via.Channel]map[string]string{} board := "" // A nil catalog is the normal case here, not a degenerate one: it is what a // board with no definition file gives, and that board is the one a user writes // names for. if base != nil { board = base.board for ch, effects := range base.names { merged[ch] = append([]Effect(nil), effects...) } for ch, byName := range base.aliases { copied := make(map[string]string, len(byName)) for k, v := range byName { copied[k] = v } aliases[ch] = copied } } // An effect the definition does not have is added, because the definition may // stop short of the board's highest ID and a user naming that one is telling // us something the file did not. for ch, byID := range n.effects { for id, name := range byID { merged[ch] = upsertEffect(merged[ch], Effect{ID: id, Name: name, Source: SourceUser}) } } for ch, byName := range n.aliases { if aliases[ch] == nil { aliases[ch] = map[string]string{} } for spelling, name := range byName { // Stored as written. An alias is looked up by the exact string the // catalog already uses, so normalising it here would make a spelling // resolve that the definition's own aliases do not. aliases[ch][spelling] = name } } if n.Name != "" { board = n.Name } out := &Catalog{board: board, names: merged, aliases: aliases} for ch := range merged { sort.Slice(out.names[ch], func(i, j int) bool { return out.names[ch][i].ID < out.names[ch][j].ID }) } return out } // upsertEffect replaces the entry for an ID or adds one, keeping the list sorted. func upsertEffect(effects []Effect, want Effect) []Effect { for i := range effects { if effects[i].ID == want.ID { effects[i] = want return effects } } effects = append(effects, want) sort.Slice(effects, func(i, j int) bool { return effects[i].ID < effects[j].ID }) return effects } // Channels returns the lighting channels this file names, in channel order, so a // command that reports on the file can walk it the way it walks a board. func (n *Names) Channels() []via.Channel { var out []via.Channel for _, ch := range via.LightingChannels { if len(n.effects[ch]) > 0 || len(n.aliases[ch]) > 0 { out = append(out, ch) } } return out } // Effects returns the names on one channel, in ID order. func (n *Names) Effects(ch via.Channel) []Effect { ids := make([]int, 0, len(n.effects[ch])) for id := range n.effects[ch] { ids = append(ids, int(id)) } sort.Ints(ids) out := make([]Effect, 0, len(ids)) for _, id := range ids { out = append(out, Effect{ID: uint8(id), Name: n.effects[ch][uint8(id)], Source: SourceUser}) } return out } // parseEffectID reads an effect ID from a names file, where it is a JSON object // key and therefore a string. A plain number is what a person writes, and 0x is // accepted because both spellings appear in the tool's own output. func parseEffectID(raw string) (uint8, error) { var n uint64 var err error if base, value, found := strings.Cut(raw, "0x"); found { _ = base n, err = strconv.ParseUint(value, 16, 16) } else { n, err = strconv.ParseUint(raw, 10, 16) } if err != nil { return 0, fmt.Errorf("%q is not an effect ID", raw) } return uint8(n), nil } // namesIdentifiers returns the board a names file is for. It is the same pair of // spellings a definition file carries, read through the same hex parser, so a // names file and a definition file for one board are recognisably the same board. func namesIdentifiers(vendorID, productID string) (uint16, uint16, error) { if vendorID == "" || productID == "" { return 0, 0, fmt.Errorf("neither vendorId nor productId; not a names file") } v, err := parseHexID(vendorID) if err != nil { return 0, 0, fmt.Errorf("vendorId %q: %w", vendorID, err) } p, err := parseHexID(productID) if err != nil { return 0, 0, fmt.Errorf("productId %q: %w", productID, err) } return v, p, nil } // EffectSource says whether the name for an effect ID on a channel is the // manufacturer's or the user's, which is what lets a command print where a name // came from instead of presenting both as the same kind of fact. func (c *Catalog) EffectSource(ch via.Channel, id uint8) NameSource { if c == nil { return SourceVendor } for _, e := range c.names[ch] { if e.ID == id { return e.Source } } return SourceVendor }