definition.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  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. // forceFetch replaces a definition that is already stored. It is the only way to
  30. // do so, because a file in the user directory may be one the user has edited and
  31. // nothing on disk says which it is.
  32. var forceFetch bool
  33. // NewKeyboardFetchCmd is `keyboard fetch`. It belongs to the keyboard command
  34. // because what it fetches belongs to a board: the file is looked up by the
  35. // board's vendor and product ID, so there is nothing to fetch without one.
  36. func NewKeyboardFetchCmd() *cobra.Command {
  37. cmd := &cobra.Command{
  38. Use: "fetch",
  39. Short: "Download the definition file for the connected keyboard",
  40. Long: "Identify the connected keyboard, then download its VIA definition into the\n" +
  41. "data directory. VIA does not carry a definition for every board, and answers\n" +
  42. "an unknown one with its own web page, so a downloaded file is parsed and\n" +
  43. "matched against the keyboard before it is stored.\n\n" +
  44. "A definition that is already stored for this keyboard is not replaced, because\n" +
  45. "it may be one you have edited. Edit that file, or pass --force to overwrite it.",
  46. Args: cobra.NoArgs,
  47. RunE: runKeyboardFetch,
  48. }
  49. cmd.Flags().BoolVar(&forceFetch, "force", false,
  50. "Replace a definition that is already stored, discarding whatever that file holds")
  51. return cmd
  52. }
  53. // NewKeyboardDefinitionsCmd is `keyboard definitions`, and the name is the noun
  54. // rather than a verb on purpose. `keyboard list` reads as listing the
  55. // keyboards, which is what `keyboard info` does, and the root already has a
  56. // `list` that means the profile store; one verb must not mean two stores.
  57. //
  58. // The Short names what the command can do beyond listing, because `generate` is
  59. // two levels down and cobra does not list a subcommand under a parent that runs
  60. // itself: from `keyboard --help` this line is all there is, and a reader who
  61. // finds out by running `generate` and getting an arity error has not been told
  62. // anything.
  63. func NewKeyboardDefinitionsCmd() *cobra.Command {
  64. return &cobra.Command{
  65. Use: "definitions",
  66. Short: "List the definition files in the data directory, and those built in; also writes one, with `generate`",
  67. Args: cobra.NoArgs,
  68. RunE: runKeyboardDefinitions,
  69. }
  70. }
  71. func runKeyboardFetch(cmd *cobra.Command, args []string) error {
  72. // A definition is a property of a board, not of a lighting channel, so this
  73. // asks for the board alone and names no zone.
  74. target, err := prepareTarget("")
  75. if err != nil {
  76. return err
  77. }
  78. dir, err := ensureDefinitionsDir()
  79. if err != nil {
  80. return err
  81. }
  82. // The check comes before the download, so a user whose definition is already
  83. // there is told so without waiting on the network and without the answer
  84. // depending on VIA still carrying the board. It matches by content rather
  85. // than by file name, because what identifies a definition is the vendor and
  86. // product ID inside it and the name of the file is only a label.
  87. stored := storedDefinitionsFor(dir, target.Device.VendorID, target.Device.ProductID)
  88. if len(stored) > 0 && !forceFetch {
  89. return fmt.Errorf("a definition for %s (0x%04X/0x%04X) is already at %s; it may hold your "+
  90. "edits, so fetch does not replace it, use --force to overwrite it or edit that file",
  91. stored[0].Name, target.Device.VendorID, target.Device.ProductID, describeDataDir(stored[0].Path))
  92. }
  93. def, err := fetchDefinition(target.Device.VendorID, target.Device.ProductID)
  94. if err != nil {
  95. return err
  96. }
  97. path := filepath.Join(dir, definitionFileName(def))
  98. if err := os.WriteFile(path, []byte(def.raw), 0o644); err != nil {
  99. return fmt.Errorf("write %s: %w", path, err)
  100. }
  101. // A definition that was replaced under a different file name is removed
  102. // rather than left standing beside the new one: two files describing one
  103. // board leave which of them is read to the order the directory happens to
  104. // come back in. The removal follows the write, so a write that failed keeps
  105. // what was there.
  106. for _, old := range stored {
  107. if old.Path == path {
  108. continue
  109. }
  110. if err := os.Remove(old.Path); err != nil {
  111. return fmt.Errorf("remove replaced %s: %w", old.Path, err)
  112. }
  113. }
  114. verb := "Saved"
  115. if len(stored) > 0 {
  116. verb = "Replaced"
  117. }
  118. fmt.Fprintf(cmd.OutOrStdout(), "%s definition for %s (0x%04X/0x%04X) to %s\n",
  119. verb, def.Definition.Name, def.Definition.VendorID, def.Definition.ProductID, describeDataDir(path))
  120. for _, ch := range def.channels {
  121. fmt.Fprintf(cmd.OutOrStdout(), " %-10s %d effects\n", ch.Subsystem(), len(def.Definition.Catalog.Effects(ch)))
  122. }
  123. return nil
  124. }
  125. // storedDefinitionsFor returns the definitions the user directory already holds
  126. // for a board. It is a slice because two files can describe one board: the
  127. // vendor issues the same vendor and product ID to two models, which
  128. // definitions/README.md records, and only one of the two files is read.
  129. //
  130. // A directory that cannot be read is not an answer. A file in it may be
  131. // unreadable, and "nothing stored" is what a fetch acts on, so an unreadable
  132. // directory leaves the fetch to write rather than refuse.
  133. func storedDefinitionsFor(dir string, vendorID, productID uint16) []*intrgb.Definition {
  134. defs, err := intrgb.LoadDefinitionsDir(dir)
  135. if err != nil {
  136. return nil
  137. }
  138. var out []*intrgb.Definition
  139. for _, def := range defs {
  140. if def.Matches(vendorID, productID) {
  141. out = append(out, def)
  142. }
  143. }
  144. return out
  145. }
  146. func runKeyboardDefinitions(cmd *cobra.Command, args []string) error {
  147. dir := definitionsPath()
  148. // Both sources are listed, not just the directory: the file built into the
  149. // binary is where the names for a board that cannot be fetched come from, and
  150. // a listing that did not show it would say the tool has no definition for a
  151. // board it has one for.
  152. userDefs, dirErr := intrgb.LoadDefinitionsDir(dir)
  153. switch {
  154. case dirErr == nil, errors.Is(dirErr, os.ErrNotExist):
  155. // A directory that is not there yet is not an error: a user who has never
  156. // fetched one has none. A file inside it that cannot be read is, because
  157. // a definition the user placed and that does not work is worth saying.
  158. default:
  159. return dirErr
  160. }
  161. builtIn := builtInDefinitions()
  162. lines := make([]definitionLine, 0, len(userDefs)+len(builtIn))
  163. for _, def := range userDefs {
  164. lines = append(lines, definitionLineFor(def, "user"))
  165. }
  166. for _, def := range builtIn {
  167. lines = append(lines, definitionLineFor(def, "built-in"))
  168. }
  169. if jsonOutput {
  170. return encodeJSON(cmd.OutOrStdout(), struct {
  171. Directory string `json:"directory"`
  172. Definitions []definitionLine `json:"definitions"`
  173. }{Directory: dir, Definitions: lines})
  174. }
  175. if len(lines) == 0 {
  176. fmt.Fprintf(cmd.OutOrStdout(),
  177. "No definition files, in %s and none built into this binary\n", describeDataDir(dir))
  178. return nil
  179. }
  180. fmt.Fprintf(cmd.OutOrStdout(), "Definitions\n\n user %s\n built into this binary\n", describeDataDir(dir))
  181. for _, line := range lines {
  182. shadowed := ""
  183. if line.Source == "user" && isShadowed(line, builtIn) {
  184. shadowed = " (shadows the built-in copy)"
  185. }
  186. fmt.Fprintf(cmd.OutOrStdout(), "\n%s (%s/%s) %s %s%s\n",
  187. line.Name, line.VendorID, line.ProductID, line.Source, line.Path, shadowed)
  188. for _, ch := range line.Channels {
  189. fmt.Fprintf(cmd.OutOrStdout(), " %-10s %-10s %d effects\n", ch.Name, ch.Subsystem, ch.Effects)
  190. }
  191. }
  192. return nil
  193. }
  194. // definitionLineFor is one definition file in the shape the list command reports.
  195. // The source is carried on the line because the path alone does not say where it
  196. // is: a file built into the binary has a path relative to the build, not one a
  197. // user can open.
  198. func definitionLineFor(def *intrgb.Definition, source string) definitionLine {
  199. line := definitionLine{
  200. Name: def.Name,
  201. VendorID: fmt.Sprintf("0x%04X", def.VendorID),
  202. ProductID: fmt.Sprintf("0x%04X", def.ProductID),
  203. Path: def.Path,
  204. Source: source,
  205. }
  206. for _, ch := range via.LightingChannels {
  207. effects := def.Catalog.Effects(ch)
  208. if len(effects) == 0 {
  209. continue
  210. }
  211. // The label is what VIA calls the channel, so the name here is the
  212. // name the user sees there. The subsystem stays as the fallback and
  213. // as the spelling that works everywhere.
  214. name := def.Labels[uint16(ch)]
  215. if name == "" {
  216. name = ch.Subsystem()
  217. }
  218. line.Channels = append(line.Channels, definitionChannel{
  219. Name: name,
  220. Subsystem: ch.Subsystem(),
  221. Channel: uint8(ch),
  222. Effects: len(effects),
  223. })
  224. }
  225. return line
  226. }
  227. // isShadowed reports whether the built-in set holds a definition for the same
  228. // board, which the file in the user directory overrides. Saying so is the point:
  229. // the two files disagree, and only one of them is read.
  230. func isShadowed(line definitionLine, builtIn []*intrgb.Definition) bool {
  231. for _, def := range builtIn {
  232. if fmt.Sprintf("0x%04X", def.VendorID) == line.VendorID &&
  233. fmt.Sprintf("0x%04X", def.ProductID) == line.ProductID {
  234. return true
  235. }
  236. }
  237. return false
  238. }
  239. // definitionLine is one definition file as the list command reports it. The
  240. // identifiers are hex strings, the way the files themselves spell them, so the
  241. // two can be compared by eye. Source is "user" for a file in the user directory
  242. // and "built-in" for one compiled into the binary, and it is what says which
  243. // file is read when both describe the same board.
  244. type definitionLine struct {
  245. Name string `json:"name"`
  246. VendorID string `json:"vendorId"`
  247. ProductID string `json:"productId"`
  248. Path string `json:"path"`
  249. Source string `json:"source"`
  250. Channels []definitionChannel `json:"channels"`
  251. }
  252. // definitionChannel is one lighting channel a definition names effects for.
  253. type definitionChannel struct {
  254. Name string `json:"name"`
  255. Subsystem string `json:"subsystem"`
  256. Channel uint8 `json:"channel"`
  257. Effects int `json:"effects"`
  258. }
  259. // fetchedDefinition is a definition together with the bytes it came from, so it
  260. // can be stored exactly as it was served.
  261. type fetchedDefinition struct {
  262. Definition *intrgb.Definition
  263. raw string
  264. channels []via.Channel
  265. }
  266. // fetchDefinition downloads the definition for a board. It is separate from the
  267. // command so the download and the checks can be tested on their own.
  268. func fetchDefinition(vendorID, productID uint16) (*fetchedDefinition, error) {
  269. vpid := intrgb.VendorProductID(vendorID, productID)
  270. var lastReason string
  271. for _, version := range []string{"v3", "v2"} {
  272. url := fmt.Sprintf("%s/definitions/%s/%d.json", definitionHost, version, vpid)
  273. body, status, err := httpGet(url)
  274. if err != nil {
  275. lastReason = err.Error()
  276. continue
  277. }
  278. if status != http.StatusOK {
  279. lastReason = fmt.Sprintf("HTTP %d", status)
  280. continue
  281. }
  282. def, err := intrgb.ParseDefinition(url, body)
  283. if err != nil {
  284. lastReason = "the server did not return a definition"
  285. continue
  286. }
  287. if !def.Matches(vendorID, productID) {
  288. lastReason = fmt.Sprintf("it is a definition for 0x%04X/0x%04X", def.VendorID, def.ProductID)
  289. continue
  290. }
  291. return &fetchedDefinition{
  292. Definition: def,
  293. raw: string(body),
  294. channels: via.LightingChannels,
  295. }, nil
  296. }
  297. return nil, fmt.Errorf("no definition for this keyboard (0x%04X/0x%04X) at %s: %s; "+
  298. "if the manufacturer publishes one, put it in %s or pass --definition",
  299. vendorID, productID, definitionHost, lastReason, ensureDefinitionsDirHint())
  300. }
  301. // ensureDefinitionsDirHint is the directory a message can name, with the user
  302. // directory named outright so the advice to put a file somewhere is actionable.
  303. // A directory that cannot be created is not this message's problem, so the
  304. // lookup is not created here.
  305. func ensureDefinitionsDirHint() string {
  306. return describeDataDir(definitionsPath())
  307. }
  308. // definitionFileName names a stored definition after the board, so a directory
  309. // of them is readable.
  310. func definitionFileName(def *fetchedDefinition) string {
  311. name := def.Definition.Name
  312. var b strings.Builder
  313. for _, r := range strings.ToLower(name) {
  314. switch {
  315. case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
  316. b.WriteRune(r)
  317. default:
  318. b.WriteRune('_')
  319. }
  320. }
  321. slug := strings.Trim(b.String(), "_")
  322. if slug == "" {
  323. slug = "keyboard"
  324. }
  325. return fmt.Sprintf("%s_0x%04X_0x%04X.json", slug, def.Definition.VendorID, def.Definition.ProductID)
  326. }