package main import ( "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 } func NewDefinitionCmd() *cobra.Command { cmd := &cobra.Command{ Use: "definition", Short: "Manage keyboard definition files", Long: "A keyboard's effect names come from a VIA definition file, the same one VIA\n" + "itself uses. `fetch` downloads the file for the connected keyboard into the\n" + "data directory; to use a file you already have, put it in that directory or\n" + "pass it with --definition.", } cmd.AddCommand(newDefinitionFetchCmd(), newDefinitionListCmd()) return cmd } func newDefinitionFetchCmd() *cobra.Command { return &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.", Args: cobra.NoArgs, RunE: runDefinitionFetch, } } func newDefinitionListCmd() *cobra.Command { return &cobra.Command{ Use: "list", Short: "List the definition files in the data directory", Args: cobra.NoArgs, RunE: runDefinitionList, } } func runDefinitionFetch(cmd *cobra.Command, args []string) error { target, err := prepareTarget() if err != nil { return err } def, err := fetchDefinition(target.Device.VendorID, target.Device.ProductID) if err != nil { return err } dir, err := ensureDefinitionsDir() 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) } fmt.Fprintf(cmd.OutOrStdout(), "Saved definition for %s (0x%04X/0x%04X) to %s\n", 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 } func runDefinitionList(cmd *cobra.Command, args []string) error { dir := definitionsPath() if _, statErr := os.Stat(dir); os.IsNotExist(statErr) { if jsonOutput { return encodeJSON(cmd.OutOrStdout(), struct { Directory string `json:"directory"` Definitions []definitionLine `json:"definitions"` }{Directory: dir, Definitions: []definitionLine{}}) } fmt.Fprintf(cmd.OutOrStdout(), "No definition directory yet at %s; run `definition fetch` "+ "or put a manufacturer file there\n", dir) return nil } defs, err := intrgb.LoadDefinitionsDir(dir) if err != nil { return err } lines := make([]definitionLine, 0, len(defs)) for _, def := range defs { line := definitionLine{ Name: def.Name, VendorID: fmt.Sprintf("0x%04X", def.VendorID), ProductID: fmt.Sprintf("0x%04X", def.ProductID), Path: def.Path, } 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), }) } lines = append(lines, line) } 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\n", dir) return nil } fmt.Fprintf(cmd.OutOrStdout(), "Definitions in %s\n\n", describeDataDir(dir)) for _, line := range lines { fmt.Fprintf(cmd.OutOrStdout(), "%s (%s/%s) %s\n", line.Name, line.VendorID, line.ProductID, line.Path) for _, ch := range line.Channels { fmt.Fprintf(cmd.OutOrStdout(), " %-10s %-10s %d effects\n", ch.Name, ch.Subsystem, ch.Effects) } } return nil } // 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. type definitionLine struct { Name string `json:"name"` VendorID string `json:"vendorId"` ProductID string `json:"productId"` Path string `json:"path"` 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) }