|
@@ -0,0 +1,265 @@
|
|
|
|
|
+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
|
|
|
|
|
+}
|