definition_generate.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. package main
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "os"
  6. "path/filepath"
  7. "strconv"
  8. "strings"
  9. "github.com/spf13/cobra"
  10. intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
  11. "netdome.biz/paul/qmk-rgb/internal/spotted"
  12. "netdome.biz/paul/qmk-rgb/internal/via"
  13. )
  14. // effectRangeProbe is what `keyboard definitions generate` needs from a keyboard:
  15. // the effect IDs each channel takes, a way to close it, and the two Vial calls
  16. // that may answer the question without writing to it.
  17. //
  18. // It is deliberately not a method on rgbProtocol, because that interface is what
  19. // every other command's test stub implements and a probe is not something
  20. // brightness and colour have to know about.
  21. type effectRangeProbe interface {
  22. EffectTop(via.Channel) (int, error)
  23. VialVersion() (uint32, bool, error)
  24. VialEffectIDs() ([]uint16, error)
  25. Close() error
  26. }
  27. // slotsFrom is where a channel's effect slots came from. It is reported per
  28. // channel because the two answers are not the same kind of thing: one is the
  29. // firmware listing what it compiled in, the other is the firmware's answer to a
  30. // value written above the top.
  31. type slotsFrom string
  32. const (
  33. // slotsMeasured means the range was found by writing above the top and
  34. // reading back what the firmware clamped to.
  35. slotsMeasured slotsFrom = "measured by clamping"
  36. // slotsReported means the firmware listed them itself, which is Vial's
  37. // vialrgb_get_supported and nothing else.
  38. slotsReported slotsFrom = "reported by the firmware"
  39. )
  40. // experimentalVial marks the Vial path. It is written from Vial's source and
  41. // tested against a scripted keyboard, and nobody has run it against a Vial
  42. // keyboard, so every command that uses it says so rather than presenting an
  43. // unverified answer as a measurement. Remove the word when a real one has.
  44. const experimentalVial = "experimental"
  45. // generateForce replaces a definition that is already stored. It is the same
  46. // rule `keyboard fetch` follows, for the same reason: the file may be one the
  47. // user has written themselves, and this command is the one they would have run
  48. // to write it.
  49. var generateForce bool
  50. // The value IDs a VIA lighting menu addresses, from quantum/via.h. They are the
  51. // same bytes on every lighting subsystem, which is why one scaffold covers every
  52. // channel.
  53. const (
  54. viaValueBrightness = 0x01
  55. viaValueEffect = 0x02
  56. viaValueSpeed = 0x03
  57. )
  58. // NewKeyboardDefinitionsGenerateCmd is `keyboard definitions generate`. It writes
  59. // a definition for a board that has none, with the effect slots the keyboard
  60. // reports and no names in them, plus a note beside it saying which spellings
  61. // other keyboards' definitions use for the same numbers.
  62. //
  63. // The names are the user's to write, and the command does not write them into
  64. // the definition: a name it put there would read as the manufacturer's own, and
  65. // nothing in a file can be read back off a keyboard to check it. An option with
  66. // an empty name is a slot without a name, so the channel reports no effects
  67. // until one is filled in — which is the honest state, and the same one a board
  68. // with no definition reports.
  69. func NewKeyboardDefinitionsGenerateCmd() *cobra.Command {
  70. cmd := &cobra.Command{
  71. Use: "generate",
  72. Short: "Write a definition for the connected keyboard, with the effect names left to you",
  73. Long: experimentalVial + ": the Vial path below has not been run against a Vial keyboard.\n\n" +
  74. "Ask the keyboard how many effect IDs each of its lighting channels takes, then\n" +
  75. "write a definition file with one unnamed option per ID, and a note beside it\n" +
  76. "saying which spellings other keyboards' definitions use for the same numbers.\n\n" +
  77. "The names are yours to write: open the file and put one in each options entry,\n" +
  78. "and the channel starts resolving them. Nothing in a file can be read back off a\n" +
  79. "keyboard, so a name the tool wrote would be indistinguishable from the\n" +
  80. "manufacturer's, and a wrong name is worse than none: the tool reports an effect\n" +
  81. "as unknown and set by number until you fill it in.\n\n" +
  82. "A definition already stored for this keyboard is not replaced, because it is\n" +
  83. "probably the one you wrote. Edit it, or pass --force.\n\n" +
  84. "A keyboard running Vial firmware is asked for its rgb_matrix effect IDs instead of\n" +
  85. "being written to, which is the better answer, and it numbers them in its own\n" +
  86. "namespace rather than QMK's. Every channel says where its slots came from.",
  87. Args: cobra.NoArgs,
  88. RunE: runKeyboardDefinitionsGenerate,
  89. }
  90. cmd.Flags().BoolVar(&generateForce, "force", false,
  91. "Replace a definition that is already stored, discarding whatever that file holds")
  92. return cmd
  93. }
  94. func runKeyboardDefinitionsGenerate(cmd *cobra.Command, args []string) error {
  95. proto, target, channels, err := openTarget("")
  96. if err != nil {
  97. return err
  98. }
  99. defer proto.Close()
  100. probe, ok := proto.(effectRangeProbe)
  101. if !ok {
  102. return fmt.Errorf("this build cannot read a keyboard's effect range")
  103. }
  104. stored := storedDefinitionsFor(definitionsPath(), target.Device.VendorID, target.Device.ProductID)
  105. if len(stored) > 0 && !generateForce {
  106. return fmt.Errorf("a definition for %s (0x%04X/0x%04X) is already at %s; it is probably the one "+
  107. "you wrote, so generate does not replace it, use --force to overwrite it or edit that file",
  108. stored[0].Name, target.Device.VendorID, target.Device.ProductID, describeDataDir(stored[0].Path))
  109. }
  110. // The built-in set is checked as well, and for a different reason than the
  111. // directory above. A definition the binary carries is the vendor's own file and
  112. // the user directory shadows it, so a scaffold written for such a board would
  113. // take it from the names it has to none — a loss the generated file could not
  114. // report, because the file is the one that is read. `fetch` has the same
  115. // shadowing and is allowed it, because a fetched file is the vendor's file too
  116. // and carries more than a scaffold does.
  117. if !generateForce {
  118. for _, def := range builtInDefinitions() {
  119. if !def.Matches(target.Device.VendorID, target.Device.ProductID) {
  120. continue
  121. }
  122. return fmt.Errorf("this binary carries a definition for %s (0x%04X/0x%04X) that names %d "+
  123. "effects, and a file in the definitions directory would shadow it; a generated one names "+
  124. "none, so the board would go from %d names to no effect names at all, use --force to do "+
  125. "that anyway, or edit the built-in file's copy in the definitions directory",
  126. def.Name, def.VendorID, def.ProductID, namedEffectCount(def), namedEffectCount(def))
  127. }
  128. }
  129. if len(channels) == 0 {
  130. return fmt.Errorf("this keyboard exposes no VIA lighting channels, so there is nothing to " +
  131. "generate a definition for")
  132. }
  133. name := scaffoldName(target.Device.Name)
  134. doc := scaffoldDefinition{
  135. Name: name,
  136. VendorID: fmt.Sprintf("0x%04X", target.Device.VendorID),
  137. ProductID: fmt.Sprintf("0x%04X", target.Device.ProductID),
  138. }
  139. notes := &strings.Builder{}
  140. // Vial can list the rgb_matrix effect IDs without the keyboard being written
  141. // to. It is asked first because its answer is the better one, and only where
  142. // the firmware serves it: VIALRGB_ENABLE is a build option, and a Vial
  143. // keyboard without it answers nothing.
  144. version, isVial, err := probe.VialVersion()
  145. if err != nil {
  146. return err
  147. }
  148. if isVial {
  149. fmt.Fprintf(cmd.OutOrStdout(), "This keyboard answers as Vial firmware, protocol version %d (%s: not run against real hardware)\n",
  150. version, experimentalVial)
  151. }
  152. sources := map[via.Channel]slotsFrom{}
  153. slots := map[via.Channel][]uint16{}
  154. for _, ch := range channels {
  155. ids, from, err := slotsFor(probe, ch, isVial)
  156. if err != nil {
  157. return err
  158. }
  159. if menu := scaffoldMenu(ch, ids); len(menu.Content) > 0 {
  160. doc.Menus = append(doc.Menus, menu)
  161. }
  162. sources[ch] = from
  163. slots[ch] = ids
  164. writeCandidates(notes, ch, ids, from)
  165. }
  166. dir, err := ensureDefinitionsDir()
  167. if err != nil {
  168. return err
  169. }
  170. base := definitionFileName(&fetchedDefinition{
  171. Definition: &intrgb.Definition{
  172. Name: name,
  173. VendorID: target.Device.VendorID,
  174. ProductID: target.Device.ProductID,
  175. },
  176. })
  177. encoded, err := json.MarshalIndent(doc, "", " ")
  178. if err != nil {
  179. return err
  180. }
  181. path := filepath.Join(dir, base)
  182. if err := os.WriteFile(path, append(encoded, '\n'), 0o644); err != nil {
  183. return fmt.Errorf("write %s: %w", path, err)
  184. }
  185. notePath := strings.TrimSuffix(path, ".json") + spottedNoteSuffix
  186. if err := os.WriteFile(notePath, []byte(notes.String()), 0o644); err != nil {
  187. return fmt.Errorf("write %s: %w", notePath, err)
  188. }
  189. verb := "Wrote"
  190. if len(stored) > 0 {
  191. verb = "Replaced"
  192. }
  193. fmt.Fprintf(cmd.OutOrStdout(), "%s a definition for %s (%s/%s) to %s\n",
  194. verb, doc.Name, doc.VendorID, doc.ProductID, describeDataDir(path))
  195. fmt.Fprintf(cmd.OutOrStdout(), "Every effect is an unnamed option. Open the file and write a name into each one:\n")
  196. for _, ch := range channels {
  197. ids, ok := slots[ch]
  198. if !ok {
  199. continue
  200. }
  201. fmt.Fprintf(cmd.OutOrStdout(), " %-10s %d slots, IDs %s (%s)\n",
  202. ch.Subsystem(), len(ids), describeSlotRange(ids), sources[ch])
  203. }
  204. fmt.Fprintf(cmd.OutOrStdout(), "\nWhat other keyboards call the same numbers is in %s\n", describeDataDir(notePath))
  205. return nil
  206. }
  207. // spottedNoteSuffix names the note beside a generated definition. It is not a
  208. // .json file, so the definitions directory does not read it as one: it carries
  209. // no definition, only what was seen.
  210. const spottedNoteSuffix = ".spotted.txt"
  211. // scaffoldName is the board's name in a generated definition. A keyboard that
  212. // reports no product string gets a name that says so rather than an empty one,
  213. // which would match nothing and read as a broken file.
  214. func scaffoldName(productString string) string {
  215. if strings.TrimSpace(productString) == "" {
  216. return "Unnamed keyboard"
  217. }
  218. return productString
  219. }
  220. // scaffoldDefinition is the shape of a VIA definition with nothing in it yet.
  221. // The identifiers are the board's, so the file is matched to it the way any
  222. // other definition is.
  223. type scaffoldDefinition struct {
  224. Name string `json:"name"`
  225. // The identifiers are hex strings, which is how a definition file spells them
  226. // and how the parser reads them back. VIA's own served files carry the
  227. // packed number instead; both are read and this is the one a human edits.
  228. VendorID string `json:"vendorId"`
  229. ProductID string `json:"productId"`
  230. Menus []scaffoldMenuT `json:"menus"`
  231. }
  232. type scaffoldMenuT struct {
  233. Label string `json:"label"`
  234. Content []scaffoldEntry `json:"content"`
  235. }
  236. type scaffoldEntry struct {
  237. Label string `json:"label"`
  238. Type string `json:"type"`
  239. Content []any `json:"content"`
  240. Options any `json:"options,omitempty"`
  241. }
  242. // slotsFor returns the effect IDs a channel has and where that came from.
  243. //
  244. // Vial is asked only for rgb_matrix, because VialRGB is an rgb_matrix extension:
  245. // there is no Vial list for backlight, rgblight, audio or led_matrix, and those
  246. // keep the clamping answer. A Vial keyboard without VIALRGB_ENABLE answers
  247. // nothing, which is an error rather than an empty list, and it falls back to the
  248. // measurement because a board that cannot list its effects can still be asked.
  249. func slotsFor(probe effectRangeProbe, ch via.Channel, isVial bool) ([]uint16, slotsFrom, error) {
  250. if isVial && ch == via.ChannelRgbMatrix {
  251. ids, err := probe.VialEffectIDs()
  252. if err == nil && len(ids) > 0 {
  253. return ids, slotsReported, nil
  254. }
  255. if err != nil {
  256. fmt.Fprintf(os.Stderr, "vialrgb effect list on %s unavailable (%v), falling back to the clamp\n", ch.Subsystem(), err)
  257. }
  258. }
  259. top, err := probe.EffectTop(ch)
  260. if err != nil {
  261. return nil, "", err
  262. }
  263. ids := make([]uint16, 0, top+1)
  264. for id := 0; id <= top; id++ {
  265. ids = append(ids, uint16(id))
  266. }
  267. return ids, slotsMeasured, nil
  268. }
  269. // describeSlotRange writes a list of IDs as a range when it is one, because
  270. // "0 to 45" says in six characters what a list of forty-six does not.
  271. func describeSlotRange(ids []uint16) string {
  272. if len(ids) == 0 {
  273. return "none"
  274. }
  275. runs := true
  276. for i := 1; i < len(ids); i++ {
  277. if ids[i] != ids[i-1]+1 {
  278. runs = false
  279. break
  280. }
  281. }
  282. if runs {
  283. return fmt.Sprintf("%d to %d", ids[0], ids[len(ids)-1])
  284. }
  285. shown := ids
  286. if len(shown) > 8 {
  287. shown = append(append([]uint16{}, ids[:8]...), 0xFFFF)
  288. }
  289. parts := make([]string, 0, len(shown))
  290. for _, id := range shown {
  291. if id == 0xFFFF {
  292. parts = append(parts, "...")
  293. continue
  294. }
  295. parts = append(parts, strconv.Itoa(int(id)))
  296. }
  297. return strings.Join(parts, ", ")
  298. }
  299. // scaffoldMenu is one lighting channel as a VIA menu: the three controls the
  300. // value IDs name, with the effect list holding one unnamed option per slot. The
  301. // name is the channel's own, so the file names it the way the tool does rather
  302. // than inventing a label — a board the user fills in can rename it.
  303. func scaffoldMenu(ch via.Channel, ids []uint16) scaffoldMenuT {
  304. valueKey, ok := intrgb.EffectValueKey(ch)
  305. if !ok {
  306. // A channel with no registered value key has no list this tool reads, so
  307. // it is left out rather than written as something that would not load.
  308. return scaffoldMenuT{}
  309. }
  310. channel := int(ch)
  311. // The three controls address different value IDs under keys that share the
  312. // effect key's prefix, and the prefix ends where "_effect" starts. Trimming
  313. // the suffix and putting the underscore back is what keeps a key spelled
  314. // `id_qmk_rgb_matrix_brightness` rather than one the parser will not find.
  315. prefix := strings.TrimSuffix(valueKey, "_effect")
  316. options := make([][]any, 0, len(ids))
  317. for _, id := range ids {
  318. // The empty name is the point: it states a slot without naming it, and the
  319. // parser skips it, so the channel reports no effects until a name is
  320. // written here.
  321. options = append(options, []any{"", id})
  322. }
  323. return scaffoldMenuT{
  324. Label: ch.Subsystem(),
  325. Content: []scaffoldEntry{
  326. {
  327. Label: "Brightness",
  328. Type: "range",
  329. Content: []any{prefix + "_brightness", channel, viaValueBrightness},
  330. Options: []any{0, 255},
  331. },
  332. {
  333. Label: "Effect",
  334. Type: "dropdown",
  335. Content: []any{valueKey, channel, viaValueEffect},
  336. Options: options,
  337. },
  338. {
  339. Label: "Effect Speed",
  340. Type: "range",
  341. Content: []any{prefix + "_effect_speed", channel, viaValueSpeed},
  342. Options: []any{0, 255},
  343. },
  344. },
  345. }
  346. }
  347. // writeCandidates records what other keyboards' definitions were seen to call
  348. // each ID, with the counts and the manufacturer, because a name without those is
  349. // a guess wearing a number.
  350. func writeCandidates(out *strings.Builder, ch via.Channel, ids []uint16, from slotsFrom) {
  351. valueKey, ok := intrgb.EffectValueKey(ch)
  352. if !ok {
  353. return
  354. }
  355. fmt.Fprintf(out, "\n%s (channel %d), %d slots, IDs %s (%s)\n",
  356. ch.Subsystem(), ch, len(ids), describeSlotRange(ids), from)
  357. if from == slotsReported {
  358. // The firmware that listed these numbers numbers them in its own
  359. // namespace, and the measurement below is QMK's. Saying "here is what
  360. // other boards call ID 7" next to a slot that is not QMK's ID 7 is the one
  361. // thing this note must not do.
  362. fmt.Fprintf(out, " These IDs are the firmware's own numbering, not QMK's: the list the firmware sends\n")
  363. fmt.Fprintf(out, " is VIALRGB_EFFECT_* and the translation to QMK's stays inside the firmware. The\n")
  364. fmt.Fprintf(out, " spellings other keyboards use are indexed by QMK's numbers, so they are not listed here.\n")
  365. return
  366. }
  367. seen := 0
  368. for _, id := range ids {
  369. obs, ok := spotted.Candidates(valueKey, int(id))
  370. if !ok {
  371. continue
  372. }
  373. seen++
  374. names := make([]string, 0, len(obs.Candidates))
  375. for _, c := range obs.Candidates {
  376. names = append(names, fmt.Sprintf("%s (%d)", c.Name, c.Count))
  377. }
  378. line := fmt.Sprintf(" ID %-3d seen on %3d boards: %s", id, obs.Boards, strings.Join(names, ", "))
  379. if obs.TopVendor != "" && obs.TopVendorCount > 0 {
  380. line += fmt.Sprintf(" most of them %s (%d)", obs.TopVendor, obs.TopVendorCount)
  381. }
  382. fmt.Fprintln(out, line)
  383. }
  384. if seen == 0 {
  385. fmt.Fprintf(out, " no definition anywhere names an effect on this channel\n")
  386. }
  387. }
  388. // namedEffectCount is how many effects a definition names across the channels it
  389. // covers. A file is a board's whole catalog, so the number the user loses is the
  390. // total and not the largest channel.
  391. func namedEffectCount(def *intrgb.Definition) int {
  392. total := 0
  393. for _, ch := range via.LightingChannels {
  394. total += len(def.Catalog.Effects(ch))
  395. }
  396. return total
  397. }