definition.go 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. package main
  2. import (
  3. "fmt"
  4. "io"
  5. "net/http"
  6. "os"
  7. "path/filepath"
  8. "strings"
  9. "github.com/spf13/cobra"
  10. intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
  11. "netdome.biz/paul/qmk-rgb/internal/via"
  12. )
  13. // definitionHost serves the definitions VIA ships, one file per board, named by
  14. // vendor and product ID. The path segment is the definition generation, not the
  15. // protocol version: a board gets v3 and falls back to v2.
  16. const definitionHost = "https://www.usevia.app"
  17. // httpGet is a seam so a fetch can be tested without a network. It returns the
  18. // body and the status code.
  19. var httpGet = func(url string) ([]byte, int, error) {
  20. resp, err := http.Get(url)
  21. if err != nil {
  22. return nil, 0, err
  23. }
  24. defer resp.Body.Close()
  25. body, err := io.ReadAll(resp.Body)
  26. return body, resp.StatusCode, err
  27. }
  28. func NewDefinitionCmd() *cobra.Command {
  29. cmd := &cobra.Command{
  30. Use: "definition",
  31. Short: "Manage keyboard definition files",
  32. Long: "A keyboard's effect names come from a VIA definition file, the same one VIA\n" +
  33. "itself uses. `fetch` downloads the file for the connected keyboard into the\n" +
  34. "data directory; to use a file you already have, put it in that directory or\n" +
  35. "pass it with --definition.",
  36. }
  37. cmd.AddCommand(newDefinitionFetchCmd(), newDefinitionListCmd())
  38. return cmd
  39. }
  40. func newDefinitionFetchCmd() *cobra.Command {
  41. return &cobra.Command{
  42. Use: "fetch",
  43. Short: "Download the definition file for the connected keyboard",
  44. Long: "Identify the connected keyboard, then download its VIA definition into the\n" +
  45. "data directory. VIA does not carry a definition for every board, and answers\n" +
  46. "an unknown one with its own web page, so a downloaded file is parsed and\n" +
  47. "matched against the keyboard before it is stored.",
  48. Args: cobra.NoArgs,
  49. RunE: runDefinitionFetch,
  50. }
  51. }
  52. func newDefinitionListCmd() *cobra.Command {
  53. return &cobra.Command{
  54. Use: "list",
  55. Short: "List the definition files in the data directory",
  56. Args: cobra.NoArgs,
  57. RunE: runDefinitionList,
  58. }
  59. }
  60. func runDefinitionFetch(cmd *cobra.Command, args []string) error {
  61. target, err := prepareTarget()
  62. if err != nil {
  63. return err
  64. }
  65. def, err := fetchDefinition(target.Device.VendorID, target.Device.ProductID)
  66. if err != nil {
  67. return err
  68. }
  69. dir, err := ensureDefinitionsDir()
  70. if err != nil {
  71. return err
  72. }
  73. path := filepath.Join(dir, definitionFileName(def))
  74. if err := os.WriteFile(path, []byte(def.raw), 0o644); err != nil {
  75. return fmt.Errorf("write %s: %w", path, err)
  76. }
  77. fmt.Fprintf(cmd.OutOrStdout(), "Saved definition for %s (0x%04X/0x%04X) to %s\n",
  78. def.Definition.Name, def.Definition.VendorID, def.Definition.ProductID, describeDataDir(path))
  79. for _, ch := range def.channels {
  80. fmt.Fprintf(cmd.OutOrStdout(), " %-10s %d effects\n", ch.Subsystem(), len(def.Definition.Catalog.Effects(ch)))
  81. }
  82. return nil
  83. }
  84. func runDefinitionList(cmd *cobra.Command, args []string) error {
  85. dir := definitionsPath()
  86. if _, statErr := os.Stat(dir); os.IsNotExist(statErr) {
  87. if jsonOutput {
  88. return encodeJSON(cmd.OutOrStdout(), struct {
  89. Directory string `json:"directory"`
  90. Definitions []definitionLine `json:"definitions"`
  91. }{Directory: dir, Definitions: []definitionLine{}})
  92. }
  93. fmt.Fprintf(cmd.OutOrStdout(), "No definition directory yet at %s; run `definition fetch` "+
  94. "or put a manufacturer file there\n", dir)
  95. return nil
  96. }
  97. defs, err := intrgb.LoadDefinitionsDir(dir)
  98. if err != nil {
  99. return err
  100. }
  101. lines := make([]definitionLine, 0, len(defs))
  102. for _, def := range defs {
  103. line := definitionLine{
  104. Name: def.Name,
  105. VendorID: fmt.Sprintf("0x%04X", def.VendorID),
  106. ProductID: fmt.Sprintf("0x%04X", def.ProductID),
  107. Path: def.Path,
  108. }
  109. for _, ch := range via.LightingChannels {
  110. effects := def.Catalog.Effects(ch)
  111. if len(effects) == 0 {
  112. continue
  113. }
  114. // The label is what VIA calls the channel, so the name here is the
  115. // name the user sees there. The subsystem stays as the fallback and
  116. // as the spelling that works everywhere.
  117. name := def.Labels[uint16(ch)]
  118. if name == "" {
  119. name = ch.Subsystem()
  120. }
  121. line.Channels = append(line.Channels, definitionChannel{
  122. Name: name,
  123. Subsystem: ch.Subsystem(),
  124. Channel: uint8(ch),
  125. Effects: len(effects),
  126. })
  127. }
  128. lines = append(lines, line)
  129. }
  130. if jsonOutput {
  131. return encodeJSON(cmd.OutOrStdout(), struct {
  132. Directory string `json:"directory"`
  133. Definitions []definitionLine `json:"definitions"`
  134. }{Directory: dir, Definitions: lines})
  135. }
  136. if len(lines) == 0 {
  137. fmt.Fprintf(cmd.OutOrStdout(), "No definition files in %s\n", dir)
  138. return nil
  139. }
  140. fmt.Fprintf(cmd.OutOrStdout(), "Definitions in %s\n\n", describeDataDir(dir))
  141. for _, line := range lines {
  142. fmt.Fprintf(cmd.OutOrStdout(), "%s (%s/%s) %s\n", line.Name, line.VendorID, line.ProductID, line.Path)
  143. for _, ch := range line.Channels {
  144. fmt.Fprintf(cmd.OutOrStdout(), " %-10s %-10s %d effects\n", ch.Name, ch.Subsystem, ch.Effects)
  145. }
  146. }
  147. return nil
  148. }
  149. // definitionLine is one definition file as the list command reports it. The
  150. // identifiers are hex strings, the way the files themselves spell them, so the
  151. // two can be compared by eye.
  152. type definitionLine struct {
  153. Name string `json:"name"`
  154. VendorID string `json:"vendorId"`
  155. ProductID string `json:"productId"`
  156. Path string `json:"path"`
  157. Channels []definitionChannel `json:"channels"`
  158. }
  159. // definitionChannel is one lighting channel a definition names effects for.
  160. type definitionChannel struct {
  161. Name string `json:"name"`
  162. Subsystem string `json:"subsystem"`
  163. Channel uint8 `json:"channel"`
  164. Effects int `json:"effects"`
  165. }
  166. // fetchedDefinition is a definition together with the bytes it came from, so it
  167. // can be stored exactly as it was served.
  168. type fetchedDefinition struct {
  169. Definition *intrgb.Definition
  170. raw string
  171. channels []via.Channel
  172. }
  173. // fetchDefinition downloads the definition for a board. It is separate from the
  174. // command so the download and the checks can be tested on their own.
  175. func fetchDefinition(vendorID, productID uint16) (*fetchedDefinition, error) {
  176. vpid := intrgb.VendorProductID(vendorID, productID)
  177. var lastReason string
  178. for _, version := range []string{"v3", "v2"} {
  179. url := fmt.Sprintf("%s/definitions/%s/%d.json", definitionHost, version, vpid)
  180. body, status, err := httpGet(url)
  181. if err != nil {
  182. lastReason = err.Error()
  183. continue
  184. }
  185. if status != http.StatusOK {
  186. lastReason = fmt.Sprintf("HTTP %d", status)
  187. continue
  188. }
  189. def, err := intrgb.ParseDefinition(url, body)
  190. if err != nil {
  191. lastReason = "the server did not return a definition"
  192. continue
  193. }
  194. if !def.Matches(vendorID, productID) {
  195. lastReason = fmt.Sprintf("it is a definition for 0x%04X/0x%04X", def.VendorID, def.ProductID)
  196. continue
  197. }
  198. return &fetchedDefinition{
  199. Definition: def,
  200. raw: string(body),
  201. channels: via.LightingChannels,
  202. }, nil
  203. }
  204. return nil, fmt.Errorf("no definition for this keyboard (0x%04X/0x%04X) at %s: %s; "+
  205. "if the manufacturer publishes one, put it in %s or pass --definition",
  206. vendorID, productID, definitionHost, lastReason, ensureDefinitionsDirHint())
  207. }
  208. // ensureDefinitionsDirHint is the directory a message can name, with the user
  209. // directory named outright so the advice to put a file somewhere is actionable.
  210. // A directory that cannot be created is not this message's problem, so the
  211. // lookup is not created here.
  212. func ensureDefinitionsDirHint() string {
  213. return describeDataDir(definitionsPath())
  214. }
  215. // definitionFileName names a stored definition after the board, so a directory
  216. // of them is readable.
  217. func definitionFileName(def *fetchedDefinition) string {
  218. name := def.Definition.Name
  219. var b strings.Builder
  220. for _, r := range strings.ToLower(name) {
  221. switch {
  222. case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
  223. b.WriteRune(r)
  224. default:
  225. b.WriteRune('_')
  226. }
  227. }
  228. slug := strings.Trim(b.String(), "_")
  229. if slug == "" {
  230. slug = "keyboard"
  231. }
  232. return fmt.Sprintf("%s_0x%04X_0x%04X.json", slug, def.Definition.VendorID, def.Definition.ProductID)
  233. }