package main import ( "fmt" "os" "path/filepath" "strings" "sync" "netdome.biz/paul/qmk-rgb/definitions" intdevice "netdome.biz/paul/qmk-rgb/internal/device" intrgb "netdome.biz/paul/qmk-rgb/internal/rgb" "netdome.biz/paul/qmk-rgb/internal/via" ) // definitionFlag names a definition file to use instead of looking one up. var definitionFlag string // builtInDefinitions are the definition files compiled into the binary, parsed // once. They are the last resort in a lookup and not a catalog of their own: the // files are the vendor's own, kept as served, and one a user has placed in a // definitions directory takes precedence over the copy here. Parsing them per // lookup would be wasted work on every command that names an effect. var builtInDefinitions = sync.OnceValue(func() []*intrgb.Definition { defs, err := intrgb.LoadDefinitionsFS(definitions.Vendored, "definitions") if err != nil { // A vendored file that does not parse is a build defect, not something // a user can fix by putting a file somewhere. Carry on without them: // the board is then driven through raw effect IDs, which is worse but // still works, and a lookup must not fail because of it. return nil } return defs }) // candidateDefinitions returns every definition a lookup may match, in the order // the tool trusts: the file in the per-user directory first, so one placed there // or fetched for the board overrides the copy built into the binary, and the // built-in files last so that an installed tool has the boards whose definitions // cannot be fetched. func candidateDefinitions() []*intrgb.Definition { var defs []*intrgb.Definition if fromDir, err := intrgb.LoadDefinitionsDir(definitionsPath()); err == nil { defs = append(defs, fromDir...) } return append(defs, builtInDefinitions()...) } // resolveCatalog returns the effect catalog for a board, in the order the tool // trusts: the file named by --definition, then the file in the per-user // definitions directory that matches the board, then the one built into the // binary. The second return value says which of them it was, because a board // that has no names at all is a different situation from one whose names came // from somewhere the user can see. // // This is the one place a catalog is looked up. A command that reached for a // catalog itself would silently ignore a definition file the user had placed, // which is the whole point of having one. func resolveCatalog(target targetDeviceData) (*intrgb.Catalog, string, error) { if definitionFlag != "" { def, err := intrgb.LoadDefinition(definitionFlag) if err != nil { return nil, "", err } if !def.Matches(target.Device.VendorID, target.Device.ProductID) { return nil, "", fmt.Errorf("%s is a definition for %s (0x%04X/0x%04X), not for this keyboard (0x%04X/0x%04X)", definitionFlag, def.Name, def.VendorID, def.ProductID, target.Device.VendorID, target.Device.ProductID) } return withNames(def.Catalog, def.VendorID, def.ProductID), def.Path, nil } catalog, source, err := resolveCatalogFor(target.Device.VendorID, target.Device.ProductID) return catalog, source, err } // resolveCatalogFor is the lookup without a target, for the commands that report // on a board rather than open it. func resolveCatalogFor(vendorID, productID uint16) (*intrgb.Catalog, string, error) { if definitionFlag != "" { def, err := intrgb.LoadDefinition(definitionFlag) if err != nil { return nil, "", err } if !def.Matches(vendorID, productID) { return nil, "", nil } return def.Catalog, def.Path, nil } if def := intrgb.FindDefinition(candidateDefinitions(), vendorID, productID); def != nil { return withNames(def.Catalog, def.VendorID, def.ProductID), def.Path, nil } // No definition for this board, not in a directory and not built in. A names // file may still hold names for it, and that is a board somebody wrote them // for; a board with neither holds numbers rather than names and is driven // through raw effect IDs. return withNames(nil, vendorID, productID), "", nil } // loadedDefinition returns the definition file for a board, from the file // --definition names, from the per-user directory or from the binary, and nil // when there is none. func loadedDefinition(vendorID, productID uint16) *intrgb.Definition { if definitionFlag != "" { if def, err := intrgb.LoadDefinition(definitionFlag); err == nil && def.Matches(vendorID, productID) { return def } return nil } return intrgb.FindDefinition(candidateDefinitions(), vendorID, productID) } // definitionLabels returns the channel names a definition gives the board, which // are the names VIA shows. They are the only channel names there are: a board // without a definition is addressed by its QMK subsystem name. func definitionLabels(vendorID, productID uint16) map[uint16]string { def := loadedDefinition(vendorID, productID) if def == nil { return nil } return def.Labels } // applyDefinitionLabels returns the display names a board answers to and, beside // them, the alternatives each channel keeps. The definition's label is the name // the board is called in VIA; the QMK subsystem name stays an accepted // alternative, because it follows from the channel number and is the one a // document can promise without knowing a board. // // Only a channel the definition names gets a display name, and that is // deliberate. Adding an entry for every QMK lighting channel would put // "backlight" on channel 1 of a board that has none, where the same word is also // the board's name for channel 3 — and a name that reaches two channels is // refused. A channel the definition does not name is addressed by its subsystem // name, which is what the channel number alone tells us. func applyDefinitionLabels(vendorID, productID uint16) (map[uint16]string, map[uint16][]string) { labels := definitionLabels(vendorID, productID) display := make(map[uint16]string, len(labels)) alternatives := make(map[uint16][]string, len(labels)) for number, label := range labels { display[number] = label if subsystem := via.Channel(number).Subsystem(); subsystem != label { alternatives[number] = []string{subsystem} } } return display, alternatives } // boardName is what a board is called: the name its definition gives it, else the // USB product string the keyboard itself reports, else an honest placeholder. func boardName(dev intdevice.Device, _ map[uint16]string) string { if def := loadedDefinition(dev.VendorID, dev.ProductID); def != nil && def.Name != "" { return def.Name } if dev.Name != "" { return dev.Name } return "unknown model" } // ensureDefinitionsDir returns the data directory, creating it if it is not // there yet, so a fetch has somewhere to write to. func ensureDefinitionsDir() (string, error) { dir, err := ensureDataDir(definitionsPath()) if err != nil { return "", fmt.Errorf("create %s: %w", definitionsPath(), err) } return dir, nil } // NamesDir is the name of the directory a user's own effect names are kept in. It // is not the definitions directory, because the two hold different things and only // one of them is the manufacturer's: a definition file is what a board's vendor // published, and a names file is what a person wrote. Merging them into one file // would make the tool author a document in someone else's name, and it would make // every name the user supplies indistinguishable from one the vendor published. const namesDir = "names" // namesPath is where a user's names for effect IDs live, and where a command that // writes one puts it. func namesPath() string { if namesDirOverride != "" { return namesDirOverride } return filepath.Join(userDataDir(), namesDir) } // ensureNamesDir returns the names directory, creating it if it is not there. func ensureNamesDir() (string, error) { dir, err := ensureDataDir(namesPath()) if err != nil { return "", fmt.Errorf("create %s: %w", namesPath(), err) } return dir, nil } // candidateNames returns the names files in the per-user directory. A directory // that is not there yet is not an error: a user who has written none has none. func candidateNames() []*intrgb.Names { entries, err := os.ReadDir(namesPath()) if err != nil { return nil } var out []*intrgb.Names for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { continue } names, err := intrgb.LoadNamesFile(filepath.Join(namesPath(), entry.Name())) if err != nil { // A file the user wrote and that does not load is worth saying, but // not from here: a lookup is not the place to report it, and a broken // file for one board must not stop a name resolving for another. The // `names` command lists what is there and reports the broken one. continue } out = append(out, names) } return out } // withNames returns the catalog for a board with the user's names over the top of // whatever the definition said, and nil when there is neither. A names file is // consulted for every board, whether or not a definition exists, because the two // are independent: a user may override one name of a board whose definition the // tool has, and name a board whose definition nobody published. func withNames(catalog *intrgb.Catalog, vendorID, productID uint16) *intrgb.Catalog { for _, names := range candidateNames() { if !names.Matches(vendorID, productID) { continue } return names.Apply(catalog) } return catalog }