| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285 |
- 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
- }
- // 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 {
- 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: runKeyboardFetch,
- }
- }
- // 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 {
- 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 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)
- }
|