main.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. package main
  2. import (
  3. "fmt"
  4. "io"
  5. "os"
  6. "strings"
  7. "github.com/spf13/cobra"
  8. "netdome.biz/paul/qmk-rgb/internal/device"
  9. )
  10. // version is set at build time via -ldflags.
  11. var version = "dev"
  12. // Group IDs for the root help. They name a group in the help output and say
  13. // nothing else: no behaviour hangs off them.
  14. const (
  15. groupLighting = "lighting"
  16. groupProfile = "profile"
  17. groupKeyboard = "keyboard"
  18. )
  19. var targetDevice string
  20. // zoneSelection is the rule every command that writes lighting carries in its own
  21. // help. It is repeated per command rather than left to the root help because a
  22. // subcommand's --help does not print the root's, and this is the one thing a user
  23. // has to know before running one of them.
  24. const zoneSelection = "The zone is a VIA lighting channel: backlight, rgblight, rgb_matrix, audio or\n" +
  25. "led_matrix, or the name this keyboard's definition gives it. Several are written\n" +
  26. "comma separated and the word all means every channel the keyboard reports."
  27. // writesLighting is the help of a command that writes to the keyboard: what the
  28. // command does, then the one rule they share.
  29. func writesLighting(long string) string {
  30. return long + "\n\n" + zoneSelection
  31. }
  32. func newRootCommand() *cobra.Command {
  33. cmd := &cobra.Command{
  34. Use: "qmk-rgb-tool",
  35. Short: "QMK RGB CLI — control QMK keyboard lighting",
  36. SilenceErrors: true,
  37. SilenceUsage: true,
  38. }
  39. // The commands are grouped because fifteen of them in one flat list is where
  40. // a user starts reading the help to find a command and gives up. A group says
  41. // what a command is for, and a command in none of them lands under
  42. // "Additional Commands" where the grouping says nothing.
  43. cmd.AddGroup(
  44. &cobra.Group{ID: groupLighting, Title: "Lighting Commands:"},
  45. &cobra.Group{ID: groupProfile, Title: "Profile Commands:"},
  46. &cobra.Group{ID: groupKeyboard, Title: "Keyboard Commands:"},
  47. )
  48. cmd.PersistentFlags().StringVar(&targetDevice, "device", "", "Keyboard number as printed by 'keyboard info', starting at 1")
  49. cmd.PersistentFlags().StringVar(&definitionFlag, "definition", "", "VIA definition file to read effect names from, instead of the one in the data directory")
  50. cmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Print JSON instead of text")
  51. return cmd
  52. }
  53. var rootCmd = newRootCommand()
  54. func Execute() {
  55. if err := rootCmd.Execute(); err != nil {
  56. fmt.Fprintf(os.Stderr, "Error: %v\n", err)
  57. os.Exit(1)
  58. }
  59. }
  60. func main() {
  61. Execute()
  62. }
  63. // keyboardCmd is the namespace for everything that is about one board rather
  64. // than about its lighting: which boards are connected, and the definition file
  65. // whose effect names belong to a board. The definition prose lives here because
  66. // there is no `definition` command of its own any more.
  67. var keyboardCmd = &cobra.Command{
  68. Use: "keyboard",
  69. Short: "Keyboard management commands",
  70. GroupID: groupKeyboard,
  71. Long: "A keyboard's effect names come from a VIA definition file, the same one VIA\n" +
  72. "itself uses. `fetch` downloads the file for the connected keyboard into the\n" +
  73. "data directory; to use a file you already have, put it in that directory or\n" +
  74. "pass it with --definition.",
  75. }
  76. // inGroup places a command in one of the root help's groups.
  77. func inGroup(cmd *cobra.Command, group string) *cobra.Command {
  78. cmd.GroupID = group
  79. return cmd
  80. }
  81. // discoverAll is a seam for tests.
  82. var discoverAll = device.DiscoverAll
  83. // discoverOther is a seam for tests, and the reason an empty result explains
  84. // itself: a keyboard that is connected but not reachable is only distinguishable
  85. // from a keyboard that is not there by the HID devices that were seen and passed
  86. // over.
  87. var discoverOther = device.DiscoverOther
  88. // runKeyboardInfo writes the connected QMK keyboards, as text unless --json
  89. // asks otherwise. The 1-based index that --device accepts is in the "index"
  90. // field, so the machine shape emits no banner alongside it. Whether a keyboard
  91. // has effect names is reported per keyboard, because it differs: one board may
  92. // have a definition and the next may not.
  93. //
  94. // The HID devices that were passed over travel along in the JSON shape whatever
  95. // it finds, because a consumer asking "which keyboards" and a consumer asking
  96. // "why not this one" want the same answer. The text form only prints them when
  97. // there is no keyboard, which is when they explain something; a Mac has dozens of
  98. // HID devices that are never a keyboard, and a successful listing next to thirty
  99. // of them buries the line the user came for.
  100. func runKeyboardInfo(out io.Writer) error {
  101. devices, err := discoverAll()
  102. if err != nil {
  103. return err
  104. }
  105. if devices == nil {
  106. devices = []device.Device{}
  107. }
  108. others, err := discoverOther()
  109. if err != nil {
  110. return err
  111. }
  112. if others == nil {
  113. others = []device.HIDDevice{}
  114. }
  115. lines := describeDevices(devices)
  116. if !jsonOutput {
  117. return printKeyboardInfoText(out, lines, others)
  118. }
  119. type info struct {
  120. Devices []keyboardLine `json:"devices"`
  121. Total int `json:"total"`
  122. Others []device.HIDDevice `json:"otherHidDevices"`
  123. }
  124. return encodeJSON(out, info{Devices: lines, Total: len(lines), Others: others})
  125. }
  126. var keyboardInfoCmd = &cobra.Command{
  127. Use: "info",
  128. Short: "Discover connected QMK keyboards",
  129. Long: "Scan for connected QMK keyboards and print device info as text, or as JSON\n" +
  130. "with --json.\n" +
  131. "Each device carries the 1-based index that --device accepts.\n" +
  132. "\n" +
  133. "A keyboard is one whose HID collection is the QMK Raw HID signature,\n" +
  134. "0xFF60/0x61. Nothing else is addressed, and the HID devices that were\n" +
  135. "passed over are reported, so a keyboard that is connected but unreachable\n" +
  136. "does not look like a keyboard that is not connected.",
  137. Args: cobra.NoArgs,
  138. RunE: func(cmd *cobra.Command, args []string) error {
  139. return runKeyboardInfo(cmd.OutOrStdout())
  140. },
  141. }
  142. func init() {
  143. registerCommands(rootCmd)
  144. registerFlagCompletions(rootCmd)
  145. rootCmd.Version = version
  146. rootCmd.SetVersionTemplate(" qmk-rgb-tool {{.Version}}\n")
  147. }
  148. // registerCommands adds every command to the root, each in the group the help
  149. // output lists it under. It is a function of its own so that a test can build the
  150. // same tree the binary runs and check the grouping is real, rather than repeating
  151. // a list that could fall behind the one that ships.
  152. func registerCommands(root *cobra.Command) {
  153. definitionsCmd := NewKeyboardDefinitionsCmd()
  154. // `generate` hangs off `definitions` because it writes one of the files that
  155. // command lists. A parent with a RunE of its own still runs when no
  156. // subcommand is named, so `keyboard definitions` keeps listing.
  157. definitionsCmd.AddCommand(NewKeyboardDefinitionsGenerateCmd())
  158. keyboardCmd.AddCommand(
  159. keyboardInfoCmd,
  160. NewKeyboardFetchCmd(),
  161. definitionsCmd,
  162. )
  163. // The zone is a positional argument, not a flag. A flag every help lists told
  164. // `keyboard info` and `list` about a channel they have none of, and it was
  165. // ignored there rather than refused. A positional argument is spelled only
  166. // where it is read, so there is nothing to ignore.
  167. lighting := []*cobra.Command{
  168. inGroup(NewEnableCmd(), groupLighting),
  169. inGroup(NewDisableCmd(), groupLighting),
  170. inGroup(NewInfoCmd(), groupLighting),
  171. inGroup(NewEffectCmd(), groupLighting),
  172. inGroup(NewBrightnessCmd(), groupLighting),
  173. inGroup(NewSpeedCmd(), groupLighting),
  174. inGroup(NewColorCmd(), groupLighting),
  175. }
  176. profiles := []*cobra.Command{
  177. inGroup(NewProfileSaveCmd(), groupProfile),
  178. inGroup(NewProfileLoadCmd(), groupProfile),
  179. }
  180. rest := []*cobra.Command{
  181. inGroup(NewProfileListCmd(), groupProfile),
  182. inGroup(NewProfileDeleteCmd(), groupProfile),
  183. keyboardCmd,
  184. }
  185. commands := make([]*cobra.Command, 0, len(lighting)+len(profiles)+len(rest))
  186. commands = append(commands, lighting...)
  187. commands = append(commands, profiles...)
  188. commands = append(commands, rest...)
  189. root.AddCommand(commands...)
  190. }
  191. // zoneArgs is the argument count of a command that reads a zone, together with
  192. // the message a user gets when the count is wrong. Cobra's own "accepts 2 arg(s),
  193. // received 1" does not say what the argument should have been, and a command that
  194. // writes lighting now fails at its arity rather than at a choice of channel, so
  195. // the usage line is the only help there is.
  196. func zoneArgs(min, max int, what string) cobra.PositionalArgs {
  197. return func(cmd *cobra.Command, args []string) error {
  198. if len(args) < min || len(args) > max {
  199. // cmd.Use already starts with the command's own name, so the path is
  200. // the name and what follows it, not the name twice.
  201. return fmt.Errorf("%s takes %s\nusage: %s%s", cmd.Name(), what,
  202. cmd.CommandPath(), strings.TrimPrefix(cmd.Use, cmd.Name()))
  203. }
  204. return nil
  205. }
  206. }
  207. // withZoneArgs declares a command's zone argument count, its usage line and the
  208. // completion for it, in one call. A command that names a channel and does not
  209. // call this is a command the shell knows nothing about, so the three belong
  210. // together: the count is what makes the zone required, the usage line is where
  211. // the vocabulary is documented, and the completion is how a user finds the names
  212. // a board supplies.
  213. //
  214. // A command that takes a zone and something else first — `load` takes a profile
  215. // name — cannot use this, because the zone is not what its first argument is. It
  216. // declares the count and its own completion instead.
  217. func withZoneArgs(cmd *cobra.Command, min, max int, what string) *cobra.Command {
  218. cmd.Args = zoneArgs(min, max, what)
  219. cmd.ValidArgsFunction = completeZoneArgs
  220. return cmd
  221. }