| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249 |
- package main
- import (
- "fmt"
- "io"
- "os"
- "strings"
- "github.com/spf13/cobra"
- "netdome.biz/paul/qmk-rgb/internal/device"
- )
- // version is set at build time via -ldflags.
- var version = "dev"
- // Group IDs for the root help. They name a group in the help output and say
- // nothing else: no behaviour hangs off them.
- const (
- groupLighting = "lighting"
- groupProfile = "profile"
- groupKeyboard = "keyboard"
- )
- var targetDevice string
- // zoneSelection is the rule every command that writes lighting carries in its own
- // help. It is repeated per command rather than left to the root help because a
- // subcommand's --help does not print the root's, and this is the one thing a user
- // has to know before running one of them.
- const zoneSelection = "The zone is a VIA lighting channel: backlight, rgblight, rgb_matrix, audio or\n" +
- "led_matrix, or the name this keyboard's definition gives it. Several are written\n" +
- "comma separated and the word all means every channel the keyboard reports."
- // writesLighting is the help of a command that writes to the keyboard: what the
- // command does, then the one rule they share.
- func writesLighting(long string) string {
- return long + "\n\n" + zoneSelection
- }
- func newRootCommand() *cobra.Command {
- cmd := &cobra.Command{
- Use: "qmk-rgb-tool",
- Short: "QMK RGB CLI — control QMK keyboard lighting",
- SilenceErrors: true,
- SilenceUsage: true,
- }
- // The commands are grouped because fifteen of them in one flat list is where
- // a user starts reading the help to find a command and gives up. A group says
- // what a command is for, and a command in none of them lands under
- // "Additional Commands" where the grouping says nothing.
- cmd.AddGroup(
- &cobra.Group{ID: groupLighting, Title: "Lighting Commands:"},
- &cobra.Group{ID: groupProfile, Title: "Profile Commands:"},
- &cobra.Group{ID: groupKeyboard, Title: "Keyboard Commands:"},
- )
- cmd.PersistentFlags().StringVar(&targetDevice, "device", "", "Keyboard number as printed by 'keyboard info', starting at 1")
- cmd.PersistentFlags().StringVar(&definitionFlag, "definition", "", "VIA definition file to read effect names from, instead of the one in the data directory")
- cmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Print JSON instead of text")
- return cmd
- }
- var rootCmd = newRootCommand()
- func Execute() {
- if err := rootCmd.Execute(); err != nil {
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
- os.Exit(1)
- }
- }
- func main() {
- Execute()
- }
- // keyboardCmd is the namespace for everything that is about one board rather
- // than about its lighting: which boards are connected, and the definition file
- // whose effect names belong to a board. The definition prose lives here because
- // there is no `definition` command of its own any more.
- var keyboardCmd = &cobra.Command{
- Use: "keyboard",
- Short: "Keyboard management commands",
- GroupID: groupKeyboard,
- Long: "A keyboard's effect names come from a VIA definition file, the same one VIA\n" +
- "itself uses. `fetch` downloads the file for the connected keyboard into the\n" +
- "data directory; to use a file you already have, put it in that directory or\n" +
- "pass it with --definition.",
- }
- // inGroup places a command in one of the root help's groups.
- func inGroup(cmd *cobra.Command, group string) *cobra.Command {
- cmd.GroupID = group
- return cmd
- }
- // discoverAll is a seam for tests.
- var discoverAll = device.DiscoverAll
- // discoverOther is a seam for tests, and the reason an empty result explains
- // itself: a keyboard that is connected but not reachable is only distinguishable
- // from a keyboard that is not there by the HID devices that were seen and passed
- // over.
- var discoverOther = device.DiscoverOther
- // runKeyboardInfo writes the connected QMK keyboards, as text unless --json
- // asks otherwise. The 1-based index that --device accepts is in the "index"
- // field, so the machine shape emits no banner alongside it. Whether a keyboard
- // has effect names is reported per keyboard, because it differs: one board may
- // have a definition and the next may not.
- //
- // The HID devices that were passed over travel along in the JSON shape whatever
- // it finds, because a consumer asking "which keyboards" and a consumer asking
- // "why not this one" want the same answer. The text form only prints them when
- // there is no keyboard, which is when they explain something; a Mac has dozens of
- // HID devices that are never a keyboard, and a successful listing next to thirty
- // of them buries the line the user came for.
- func runKeyboardInfo(out io.Writer) error {
- devices, err := discoverAll()
- if err != nil {
- return err
- }
- if devices == nil {
- devices = []device.Device{}
- }
- others, err := discoverOther()
- if err != nil {
- return err
- }
- if others == nil {
- others = []device.HIDDevice{}
- }
- lines := describeDevices(devices)
- if !jsonOutput {
- return printKeyboardInfoText(out, lines, others)
- }
- type info struct {
- Devices []keyboardLine `json:"devices"`
- Total int `json:"total"`
- Others []device.HIDDevice `json:"otherHidDevices"`
- }
- return encodeJSON(out, info{Devices: lines, Total: len(lines), Others: others})
- }
- var keyboardInfoCmd = &cobra.Command{
- Use: "info",
- Short: "Discover connected QMK keyboards",
- Long: "Scan for connected QMK keyboards and print device info as text, or as JSON\n" +
- "with --json.\n" +
- "Each device carries the 1-based index that --device accepts.\n" +
- "\n" +
- "A keyboard is one whose HID collection is the QMK Raw HID signature,\n" +
- "0xFF60/0x61. Nothing else is addressed, and the HID devices that were\n" +
- "passed over are reported, so a keyboard that is connected but unreachable\n" +
- "does not look like a keyboard that is not connected.",
- Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, args []string) error {
- return runKeyboardInfo(cmd.OutOrStdout())
- },
- }
- func init() {
- registerCommands(rootCmd)
- registerFlagCompletions(rootCmd)
- rootCmd.Version = version
- rootCmd.SetVersionTemplate(" qmk-rgb-tool {{.Version}}\n")
- }
- // registerCommands adds every command to the root, each in the group the help
- // output lists it under. It is a function of its own so that a test can build the
- // same tree the binary runs and check the grouping is real, rather than repeating
- // a list that could fall behind the one that ships.
- func registerCommands(root *cobra.Command) {
- definitionsCmd := NewKeyboardDefinitionsCmd()
- // `generate` hangs off `definitions` because it writes one of the files that
- // command lists. A parent with a RunE of its own still runs when no
- // subcommand is named, so `keyboard definitions` keeps listing.
- definitionsCmd.AddCommand(NewKeyboardDefinitionsGenerateCmd())
- keyboardCmd.AddCommand(
- keyboardInfoCmd,
- NewKeyboardFetchCmd(),
- definitionsCmd,
- )
- // The zone is a positional argument, not a flag. A flag every help lists told
- // `keyboard info` and `list` about a channel they have none of, and it was
- // ignored there rather than refused. A positional argument is spelled only
- // where it is read, so there is nothing to ignore.
- lighting := []*cobra.Command{
- inGroup(NewEnableCmd(), groupLighting),
- inGroup(NewDisableCmd(), groupLighting),
- inGroup(NewInfoCmd(), groupLighting),
- inGroup(NewEffectCmd(), groupLighting),
- inGroup(NewBrightnessCmd(), groupLighting),
- inGroup(NewSpeedCmd(), groupLighting),
- inGroup(NewColorCmd(), groupLighting),
- }
- profiles := []*cobra.Command{
- inGroup(NewProfileSaveCmd(), groupProfile),
- inGroup(NewProfileLoadCmd(), groupProfile),
- }
- rest := []*cobra.Command{
- inGroup(NewProfileListCmd(), groupProfile),
- inGroup(NewProfileDeleteCmd(), groupProfile),
- keyboardCmd,
- }
- commands := make([]*cobra.Command, 0, len(lighting)+len(profiles)+len(rest))
- commands = append(commands, lighting...)
- commands = append(commands, profiles...)
- commands = append(commands, rest...)
- root.AddCommand(commands...)
- }
- // zoneArgs is the argument count of a command that reads a zone, together with
- // the message a user gets when the count is wrong. Cobra's own "accepts 2 arg(s),
- // received 1" does not say what the argument should have been, and a command that
- // writes lighting now fails at its arity rather than at a choice of channel, so
- // the usage line is the only help there is.
- func zoneArgs(min, max int, what string) cobra.PositionalArgs {
- return func(cmd *cobra.Command, args []string) error {
- if len(args) < min || len(args) > max {
- // cmd.Use already starts with the command's own name, so the path is
- // the name and what follows it, not the name twice.
- return fmt.Errorf("%s takes %s\nusage: %s%s", cmd.Name(), what,
- cmd.CommandPath(), strings.TrimPrefix(cmd.Use, cmd.Name()))
- }
- return nil
- }
- }
- // withZoneArgs declares a command's zone argument count, its usage line and the
- // completion for it, in one call. A command that names a channel and does not
- // call this is a command the shell knows nothing about, so the three belong
- // together: the count is what makes the zone required, the usage line is where
- // the vocabulary is documented, and the completion is how a user finds the names
- // a board supplies.
- //
- // A command that takes a zone and something else first — `load` takes a profile
- // name — cannot use this, because the zone is not what its first argument is. It
- // declares the count and its own completion instead.
- func withZoneArgs(cmd *cobra.Command, min, max int, what string) *cobra.Command {
- cmd.Args = zoneArgs(min, max, what)
- cmd.ValidArgsFunction = completeZoneArgs
- return cmd
- }
|