definition_generate.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. package main
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "os"
  6. "path/filepath"
  7. "strings"
  8. "github.com/spf13/cobra"
  9. intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
  10. "netdome.biz/paul/qmk-rgb/internal/spotted"
  11. "netdome.biz/paul/qmk-rgb/internal/via"
  12. )
  13. // effectRangeProbe is what `keyboard definitions generate` needs from a
  14. // keyboard: the highest effect ID each channel takes, and a way to close it.
  15. // It is deliberately not a method on rgbProtocol, because that interface is what
  16. // every other command's test stub implements and a probe is not something
  17. // brightness and colour have to know about.
  18. type effectRangeProbe interface {
  19. EffectTop(via.Channel) (int, error)
  20. Close() error
  21. }
  22. // generateForce replaces a definition that is already stored. It is the same
  23. // rule `keyboard fetch` follows, for the same reason: the file may be one the
  24. // user has written themselves, and this command is the one they would have run
  25. // to write it.
  26. var generateForce bool
  27. // The value IDs a VIA lighting menu addresses, from quantum/via.h. They are the
  28. // same bytes on every lighting subsystem, which is why one scaffold covers every
  29. // channel.
  30. const (
  31. viaValueBrightness = 0x01
  32. viaValueEffect = 0x02
  33. viaValueSpeed = 0x03
  34. )
  35. // NewKeyboardDefinitionsGenerateCmd is `keyboard definitions generate`. It writes
  36. // a definition for a board that has none, with the effect slots the keyboard
  37. // reports and no names in them, plus a note beside it saying which spellings
  38. // other keyboards' definitions use for the same numbers.
  39. //
  40. // The names are the user's to write, and the command does not write them into
  41. // the definition: a name it put there would read as the manufacturer's own, and
  42. // nothing in a file can be read back off a keyboard to check it. An option with
  43. // an empty name is a slot without a name, so the channel reports no effects
  44. // until one is filled in — which is the honest state, and the same one a board
  45. // with no definition reports.
  46. func NewKeyboardDefinitionsGenerateCmd() *cobra.Command {
  47. cmd := &cobra.Command{
  48. Use: "generate",
  49. Short: "Write a definition for the connected keyboard, with the effect names left to you",
  50. Long: "Ask the keyboard how many effect IDs each of its lighting channels takes, then\n" +
  51. "write a definition file with one unnamed option per ID, and a note beside it\n" +
  52. "saying which spellings other keyboards' definitions use for the same numbers.\n\n" +
  53. "The names are yours to write: open the file and put one in each options entry,\n" +
  54. "and the channel starts resolving them. Nothing in a file can be read back off a\n" +
  55. "keyboard, so a name the tool wrote would be indistinguishable from the\n" +
  56. "manufacturer's, and a wrong name is worse than none: the tool reports an effect\n" +
  57. "as unknown and set by number until you fill it in.\n\n" +
  58. "A definition already stored for this keyboard is not replaced, because it is\n" +
  59. "probably the one you wrote. Edit it, or pass --force.",
  60. Args: cobra.NoArgs,
  61. RunE: runKeyboardDefinitionsGenerate,
  62. }
  63. cmd.Flags().BoolVar(&generateForce, "force", false,
  64. "Replace a definition that is already stored, discarding whatever that file holds")
  65. return cmd
  66. }
  67. func runKeyboardDefinitionsGenerate(cmd *cobra.Command, args []string) error {
  68. proto, target, channels, err := openTarget("")
  69. if err != nil {
  70. return err
  71. }
  72. defer proto.Close()
  73. probe, ok := proto.(effectRangeProbe)
  74. if !ok {
  75. return fmt.Errorf("this build cannot read a keyboard's effect range")
  76. }
  77. stored := storedDefinitionsFor(definitionsPath(), target.Device.VendorID, target.Device.ProductID)
  78. if len(stored) > 0 && !generateForce {
  79. return fmt.Errorf("a definition for %s (0x%04X/0x%04X) is already at %s; it is probably the one "+
  80. "you wrote, so generate does not replace it, use --force to overwrite it or edit that file",
  81. stored[0].Name, target.Device.VendorID, target.Device.ProductID, describeDataDir(stored[0].Path))
  82. }
  83. // The built-in set is checked as well, and for a different reason than the
  84. // directory above. A definition the binary carries is the vendor's own file and
  85. // the user directory shadows it, so a scaffold written for such a board would
  86. // take it from the names it has to none — a loss the generated file could not
  87. // report, because the file is the one that is read. `fetch` has the same
  88. // shadowing and is allowed it, because a fetched file is the vendor's file too
  89. // and carries more than a scaffold does.
  90. if !generateForce {
  91. for _, def := range builtInDefinitions() {
  92. if !def.Matches(target.Device.VendorID, target.Device.ProductID) {
  93. continue
  94. }
  95. return fmt.Errorf("this binary carries a definition for %s (0x%04X/0x%04X) that names %d "+
  96. "effects, and a file in the definitions directory would shadow it; a generated one names "+
  97. "none, so the board would go from %d names to no effect names at all, use --force to do "+
  98. "that anyway, or edit the built-in file's copy in the definitions directory",
  99. def.Name, def.VendorID, def.ProductID, namedEffectCount(def), namedEffectCount(def))
  100. }
  101. }
  102. if len(channels) == 0 {
  103. return fmt.Errorf("this keyboard exposes no VIA lighting channels, so there is nothing to " +
  104. "generate a definition for")
  105. }
  106. name := scaffoldName(target.Device.Name)
  107. doc := scaffoldDefinition{
  108. Name: name,
  109. VendorID: fmt.Sprintf("0x%04X", target.Device.VendorID),
  110. ProductID: fmt.Sprintf("0x%04X", target.Device.ProductID),
  111. }
  112. notes := &strings.Builder{}
  113. tops := map[via.Channel]int{}
  114. for _, ch := range channels {
  115. top, err := probe.EffectTop(ch)
  116. if err != nil {
  117. return err
  118. }
  119. if menu := scaffoldMenu(ch, top); len(menu.Content) > 0 {
  120. doc.Menus = append(doc.Menus, menu)
  121. }
  122. tops[ch] = top
  123. writeCandidates(notes, ch, top)
  124. }
  125. dir, err := ensureDefinitionsDir()
  126. if err != nil {
  127. return err
  128. }
  129. base := definitionFileName(&fetchedDefinition{
  130. Definition: &intrgb.Definition{
  131. Name: name,
  132. VendorID: target.Device.VendorID,
  133. ProductID: target.Device.ProductID,
  134. },
  135. })
  136. encoded, err := json.MarshalIndent(doc, "", " ")
  137. if err != nil {
  138. return err
  139. }
  140. path := filepath.Join(dir, base)
  141. if err := os.WriteFile(path, append(encoded, '\n'), 0o644); err != nil {
  142. return fmt.Errorf("write %s: %w", path, err)
  143. }
  144. notePath := strings.TrimSuffix(path, ".json") + spottedNoteSuffix
  145. if err := os.WriteFile(notePath, []byte(notes.String()), 0o644); err != nil {
  146. return fmt.Errorf("write %s: %w", notePath, err)
  147. }
  148. verb := "Wrote"
  149. if len(stored) > 0 {
  150. verb = "Replaced"
  151. }
  152. fmt.Fprintf(cmd.OutOrStdout(), "%s a definition for %s (%s/%s) to %s\n",
  153. verb, doc.Name, doc.VendorID, doc.ProductID, describeDataDir(path))
  154. fmt.Fprintf(cmd.OutOrStdout(), "Every effect is an unnamed option. Open the file and write a name into each one:\n")
  155. for _, ch := range channels {
  156. if _, ok := intrgb.EffectValueKey(ch); !ok {
  157. continue
  158. }
  159. fmt.Fprintf(cmd.OutOrStdout(), " %-10s %d slots, IDs 0 to %d\n", ch.Subsystem(), tops[ch]+1, tops[ch])
  160. }
  161. fmt.Fprintf(cmd.OutOrStdout(), "\nWhat other keyboards call the same numbers is in %s\n", describeDataDir(notePath))
  162. return nil
  163. }
  164. // spottedNoteSuffix names the note beside a generated definition. It is not a
  165. // .json file, so the definitions directory does not read it as one: it carries
  166. // no definition, only what was seen.
  167. const spottedNoteSuffix = ".spotted.txt"
  168. // scaffoldName is the board's name in a generated definition. A keyboard that
  169. // reports no product string gets a name that says so rather than an empty one,
  170. // which would match nothing and read as a broken file.
  171. func scaffoldName(productString string) string {
  172. if strings.TrimSpace(productString) == "" {
  173. return "Unnamed keyboard"
  174. }
  175. return productString
  176. }
  177. // scaffoldDefinition is the shape of a VIA definition with nothing in it yet.
  178. // The identifiers are the board's, so the file is matched to it the way any
  179. // other definition is.
  180. type scaffoldDefinition struct {
  181. Name string `json:"name"`
  182. // The identifiers are hex strings, which is how a definition file spells them
  183. // and how the parser reads them back. VIA's own served files carry the
  184. // packed number instead; both are read and this is the one a human edits.
  185. VendorID string `json:"vendorId"`
  186. ProductID string `json:"productId"`
  187. Menus []scaffoldMenuT `json:"menus"`
  188. }
  189. type scaffoldMenuT struct {
  190. Label string `json:"label"`
  191. Content []scaffoldEntry `json:"content"`
  192. }
  193. type scaffoldEntry struct {
  194. Label string `json:"label"`
  195. Type string `json:"type"`
  196. Content []any `json:"content"`
  197. Options any `json:"options,omitempty"`
  198. }
  199. // scaffoldMenu is one lighting channel as a VIA menu: the three controls the
  200. // value IDs name, with the effect list holding one unnamed option per slot. The
  201. // name is the channel's own, so the file names it the way the tool does rather
  202. // than inventing a label — a board the user fills in can rename it.
  203. func scaffoldMenu(ch via.Channel, top int) scaffoldMenuT {
  204. valueKey, ok := intrgb.EffectValueKey(ch)
  205. if !ok {
  206. // A channel with no registered value key has no list this tool reads, so
  207. // it is left out rather than written as something that would not load.
  208. return scaffoldMenuT{}
  209. }
  210. channel := int(ch)
  211. // The three controls address different value IDs under keys that share the
  212. // effect key's prefix, and the prefix ends where "_effect" starts. Trimming
  213. // the suffix and putting the underscore back is what keeps a key spelled
  214. // `id_qmk_rgb_matrix_brightness` rather than one the parser will not find.
  215. prefix := strings.TrimSuffix(valueKey, "_effect")
  216. options := make([][]any, 0, top+1)
  217. for id := 0; id <= top; id++ {
  218. // The empty name is the point: it states a slot without naming it, and the
  219. // parser skips it, so the channel reports no effects until a name is
  220. // written here.
  221. options = append(options, []any{"", id})
  222. }
  223. return scaffoldMenuT{
  224. Label: ch.Subsystem(),
  225. Content: []scaffoldEntry{
  226. {
  227. Label: "Brightness",
  228. Type: "range",
  229. Content: []any{prefix + "_brightness", channel, viaValueBrightness},
  230. Options: []any{0, 255},
  231. },
  232. {
  233. Label: "Effect",
  234. Type: "dropdown",
  235. Content: []any{valueKey, channel, viaValueEffect},
  236. Options: options,
  237. },
  238. {
  239. Label: "Effect Speed",
  240. Type: "range",
  241. Content: []any{prefix + "_effect_speed", channel, viaValueSpeed},
  242. Options: []any{0, 255},
  243. },
  244. },
  245. }
  246. }
  247. // writeCandidates records what other keyboards' definitions were seen to call
  248. // each ID, with the counts and the manufacturer, because a name without those is
  249. // a guess wearing a number.
  250. func writeCandidates(out *strings.Builder, ch via.Channel, top int) {
  251. valueKey, ok := intrgb.EffectValueKey(ch)
  252. if !ok {
  253. return
  254. }
  255. fmt.Fprintf(out, "\n%s (channel %d), IDs 0 to %d\n", ch.Subsystem(), ch, top)
  256. seen := 0
  257. for id := 0; id <= top; id++ {
  258. obs, ok := spotted.Candidates(valueKey, id)
  259. if !ok {
  260. continue
  261. }
  262. seen++
  263. names := make([]string, 0, len(obs.Candidates))
  264. for _, c := range obs.Candidates {
  265. names = append(names, fmt.Sprintf("%s (%d)", c.Name, c.Count))
  266. }
  267. line := fmt.Sprintf(" ID %-3d seen on %3d boards: %s", id, obs.Boards, strings.Join(names, ", "))
  268. if obs.TopVendor != "" && obs.TopVendorCount > 0 {
  269. line += fmt.Sprintf(" most of them %s (%d)", obs.TopVendor, obs.TopVendorCount)
  270. }
  271. fmt.Fprintln(out, line)
  272. }
  273. if seen == 0 {
  274. fmt.Fprintf(out, " no definition anywhere names an effect on this channel\n")
  275. }
  276. }
  277. // namedEffectCount is how many effects a definition names across the channels it
  278. // covers. A file is a board's whole catalog, so the number the user loses is the
  279. // total and not the largest channel.
  280. func namedEffectCount(def *intrgb.Definition) int {
  281. total := 0
  282. for _, ch := range via.LightingChannels {
  283. total += len(def.Catalog.Effects(ch))
  284. }
  285. return total
  286. }