definition.go 9.6 KB

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