package main import ( "errors" "fmt" "io" "net/http" "os" "path/filepath" "strings" "github.com/spf13/cobra" intrgb "netdome.biz/paul/qmk-rgb/internal/rgb" "netdome.biz/paul/qmk-rgb/internal/via" ) // definitionHost serves the definitions VIA ships, one file per board, named by // vendor and product ID. The path segment is the definition generation, not the // protocol version: a board gets v3 and falls back to v2. const definitionHost = "https://www.usevia.app" // httpGet is a seam so a fetch can be tested without a network. It returns the // body and the status code. var httpGet = func(url string) ([]byte, int, error) { resp, err := http.Get(url) if err != nil { return nil, 0, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) return body, resp.StatusCode, err } // forceFetch replaces a definition that is already stored. It is the only way to // do so, because a file in the user directory may be one the user has edited and // nothing on disk says which it is. var forceFetch bool // NewKeyboardFetchCmd is `keyboard fetch`. It belongs to the keyboard command // because what it fetches belongs to a board: the file is looked up by the // board's vendor and product ID, so there is nothing to fetch without one. func NewKeyboardFetchCmd() *cobra.Command { cmd := &cobra.Command{ Use: "fetch", Short: "Download the definition file for the connected keyboard", Long: "Identify the connected keyboard, then download its VIA definition into the\n" + "data directory. VIA does not carry a definition for every board, and answers\n" + "an unknown one with its own web page, so a downloaded file is parsed and\n" + "matched against the keyboard before it is stored.\n\n" + "A definition that is already stored for this keyboard is not replaced, because\n" + "it may be one you have edited. Edit that file, or pass --force to overwrite it.", Args: cobra.NoArgs, RunE: runKeyboardFetch, } cmd.Flags().BoolVar(&forceFetch, "force", false, "Replace a definition that is already stored, discarding whatever that file holds") return cmd } // NewKeyboardDefinitionsCmd is `keyboard definitions`, and the name is the noun // rather than a verb on purpose. `keyboard list` reads as listing the // keyboards, which is what `keyboard info` does, and the root already has a // `list` that means the profile store; one verb must not mean two stores. func NewKeyboardDefinitionsCmd() *cobra.Command { return &cobra.Command{ Use: "definitions", Short: "List the definition files in the data directory, and those built in", Args: cobra.NoArgs, RunE: runKeyboardDefinitions, } } func runKeyboardFetch(cmd *cobra.Command, args []string) error { // A definition is a property of a board, not of a lighting channel, so this // asks for the board alone and names no zone. target, err := prepareTarget("") if err != nil { return err } dir, err := ensureDefinitionsDir() if err != nil { return err } // The check comes before the download, so a user whose definition is already // there is told so without waiting on the network and without the answer // depending on VIA still carrying the board. It matches by content rather // than by file name, because what identifies a definition is the vendor and // product ID inside it and the name of the file is only a label. stored := storedDefinitionsFor(dir, target.Device.VendorID, target.Device.ProductID) if len(stored) > 0 && !forceFetch { return fmt.Errorf("a definition for %s (0x%04X/0x%04X) is already at %s; it may hold your "+ "edits, so fetch does not replace it, use --force to overwrite it or edit that file", stored[0].Name, target.Device.VendorID, target.Device.ProductID, describeDataDir(stored[0].Path)) } def, err := fetchDefinition(target.Device.VendorID, target.Device.ProductID) if err != nil { return err } path := filepath.Join(dir, definitionFileName(def)) if err := os.WriteFile(path, []byte(def.raw), 0o644); err != nil { return fmt.Errorf("write %s: %w", path, err) } // A definition that was replaced under a different file name is removed // rather than left standing beside the new one: two files describing one // board leave which of them is read to the order the directory happens to // come back in. The removal follows the write, so a write that failed keeps // what was there. for _, old := range stored { if old.Path == path { continue } if err := os.Remove(old.Path); err != nil { return fmt.Errorf("remove replaced %s: %w", old.Path, err) } } verb := "Saved" if len(stored) > 0 { verb = "Replaced" } fmt.Fprintf(cmd.OutOrStdout(), "%s definition for %s (0x%04X/0x%04X) to %s\n", verb, def.Definition.Name, def.Definition.VendorID, def.Definition.ProductID, describeDataDir(path)) for _, ch := range def.channels { fmt.Fprintf(cmd.OutOrStdout(), " %-10s %d effects\n", ch.Subsystem(), len(def.Definition.Catalog.Effects(ch))) } return nil } // storedDefinitionsFor returns the definitions the user directory already holds // for a board. It is a slice because two files can describe one board: the // vendor issues the same vendor and product ID to two models, which // definitions/README.md records, and only one of the two files is read. // // A directory that cannot be read is not an answer. A file in it may be // unreadable, and "nothing stored" is what a fetch acts on, so an unreadable // directory leaves the fetch to write rather than refuse. func storedDefinitionsFor(dir string, vendorID, productID uint16) []*intrgb.Definition { defs, err := intrgb.LoadDefinitionsDir(dir) if err != nil { return nil } var out []*intrgb.Definition for _, def := range defs { if def.Matches(vendorID, productID) { out = append(out, def) } } return out } func runKeyboardDefinitions(cmd *cobra.Command, args []string) error { dir := definitionsPath() // Both sources are listed, not just the directory: the file built into the // binary is where the names for a board that cannot be fetched come from, and // a listing that did not show it would say the tool has no definition for a // board it has one for. userDefs, dirErr := intrgb.LoadDefinitionsDir(dir) switch { case dirErr == nil, errors.Is(dirErr, os.ErrNotExist): // A directory that is not there yet is not an error: a user who has never // fetched one has none. A file inside it that cannot be read is, because // a definition the user placed and that does not work is worth saying. default: return dirErr } builtIn := builtInDefinitions() lines := make([]definitionLine, 0, len(userDefs)+len(builtIn)) for _, def := range userDefs { lines = append(lines, definitionLineFor(def, "user")) } for _, def := range builtIn { lines = append(lines, definitionLineFor(def, "built-in")) } if jsonOutput { return encodeJSON(cmd.OutOrStdout(), struct { Directory string `json:"directory"` Definitions []definitionLine `json:"definitions"` }{Directory: dir, Definitions: lines}) } if len(lines) == 0 { fmt.Fprintf(cmd.OutOrStdout(), "No definition files, in %s and none built into this binary\n", describeDataDir(dir)) return nil } fmt.Fprintf(cmd.OutOrStdout(), "Definitions\n\n user %s\n built into this binary\n", describeDataDir(dir)) for _, line := range lines { shadowed := "" if line.Source == "user" && isShadowed(line, builtIn) { shadowed = " (shadows the built-in copy)" } fmt.Fprintf(cmd.OutOrStdout(), "\n%s (%s/%s) %s %s%s\n", line.Name, line.VendorID, line.ProductID, line.Source, line.Path, shadowed) for _, ch := range line.Channels { fmt.Fprintf(cmd.OutOrStdout(), " %-10s %-10s %d effects\n", ch.Name, ch.Subsystem, ch.Effects) } } return nil } // definitionLineFor is one definition file in the shape the list command reports. // The source is carried on the line because the path alone does not say where it // is: a file built into the binary has a path relative to the build, not one a // user can open. func definitionLineFor(def *intrgb.Definition, source string) definitionLine { line := definitionLine{ Name: def.Name, VendorID: fmt.Sprintf("0x%04X", def.VendorID), ProductID: fmt.Sprintf("0x%04X", def.ProductID), Path: def.Path, Source: source, } for _, ch := range via.LightingChannels { effects := def.Catalog.Effects(ch) if len(effects) == 0 { continue } // The label is what VIA calls the channel, so the name here is the // name the user sees there. The subsystem stays as the fallback and // as the spelling that works everywhere. name := def.Labels[uint16(ch)] if name == "" { name = ch.Subsystem() } line.Channels = append(line.Channels, definitionChannel{ Name: name, Subsystem: ch.Subsystem(), Channel: uint8(ch), Effects: len(effects), }) } return line } // isShadowed reports whether the built-in set holds a definition for the same // board, which the file in the user directory overrides. Saying so is the point: // the two files disagree, and only one of them is read. func isShadowed(line definitionLine, builtIn []*intrgb.Definition) bool { for _, def := range builtIn { if fmt.Sprintf("0x%04X", def.VendorID) == line.VendorID && fmt.Sprintf("0x%04X", def.ProductID) == line.ProductID { return true } } return false } // definitionLine is one definition file as the list command reports it. The // identifiers are hex strings, the way the files themselves spell them, so the // two can be compared by eye. Source is "user" for a file in the user directory // and "built-in" for one compiled into the binary, and it is what says which // file is read when both describe the same board. type definitionLine struct { Name string `json:"name"` VendorID string `json:"vendorId"` ProductID string `json:"productId"` Path string `json:"path"` Source string `json:"source"` Channels []definitionChannel `json:"channels"` } // definitionChannel is one lighting channel a definition names effects for. type definitionChannel struct { Name string `json:"name"` Subsystem string `json:"subsystem"` Channel uint8 `json:"channel"` Effects int `json:"effects"` } // fetchedDefinition is a definition together with the bytes it came from, so it // can be stored exactly as it was served. type fetchedDefinition struct { Definition *intrgb.Definition raw string channels []via.Channel } // fetchDefinition downloads the definition for a board. It is separate from the // command so the download and the checks can be tested on their own. func fetchDefinition(vendorID, productID uint16) (*fetchedDefinition, error) { vpid := intrgb.VendorProductID(vendorID, productID) var lastReason string for _, version := range []string{"v3", "v2"} { url := fmt.Sprintf("%s/definitions/%s/%d.json", definitionHost, version, vpid) body, status, err := httpGet(url) if err != nil { lastReason = err.Error() continue } if status != http.StatusOK { lastReason = fmt.Sprintf("HTTP %d", status) continue } def, err := intrgb.ParseDefinition(url, body) if err != nil { lastReason = "the server did not return a definition" continue } if !def.Matches(vendorID, productID) { lastReason = fmt.Sprintf("it is a definition for 0x%04X/0x%04X", def.VendorID, def.ProductID) continue } return &fetchedDefinition{ Definition: def, raw: string(body), channels: via.LightingChannels, }, nil } return nil, fmt.Errorf("no definition for this keyboard (0x%04X/0x%04X) at %s: %s; "+ "if the manufacturer publishes one, put it in %s or pass --definition", vendorID, productID, definitionHost, lastReason, ensureDefinitionsDirHint()) } // ensureDefinitionsDirHint is the directory a message can name, with the user // directory named outright so the advice to put a file somewhere is actionable. // A directory that cannot be created is not this message's problem, so the // lookup is not created here. func ensureDefinitionsDirHint() string { return describeDataDir(definitionsPath()) } // definitionFileName names a stored definition after the board, so a directory // of them is readable. func definitionFileName(def *fetchedDefinition) string { name := def.Definition.Name var b strings.Builder for _, r := range strings.ToLower(name) { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) default: b.WriteRune('_') } } slug := strings.Trim(b.String(), "_") if slug == "" { slug = "keyboard" } return fmt.Sprintf("%s_0x%04X_0x%04X.json", slug, def.Definition.VendorID, def.Definition.ProductID) }