From 235a655ac1832e292f3b9de625e99b8be0440225 Mon Sep 17 00:00:00 2001 From: Thibault Pensec <39826516+thibauult@users.noreply.github.com> Date: Sat, 13 Jan 2024 17:54:56 +0100 Subject: [PATCH] New setup command (#34) The setup command automatically discovers the Hue Bridge and ask user to push the pairing button. ![openhue_setup](https://github.com/openhue/openhue-cli/assets/39826516/e2a6d1de-6296-437b-a85b-5785f4583652) --- cmd/root.go | 2 +- cmd/setup/auth.go | 69 ----------- cmd/setup/{configure.go => config.go} | 6 +- .../{configure_test.go => config_test.go} | 2 +- cmd/setup/discover.go | 32 ++--- cmd/setup/setup.go | 113 ++++++++++++++++++ cmd/setup/{auth_test.go => setup_test.go} | 7 +- openhue/config.go | 44 +++---- util/logger/logger.go | 5 +- util/mdns/bridge.go | 41 +++++++ 10 files changed, 195 insertions(+), 126 deletions(-) delete mode 100644 cmd/setup/auth.go rename cmd/setup/{configure.go => config.go} (90%) rename cmd/setup/{configure_test.go => config_test.go} (81%) create mode 100644 cmd/setup/setup.go rename cmd/setup/{auth_test.go => setup_test.go} (51%) create mode 100644 util/mdns/bridge.go diff --git a/cmd/root.go b/cmd/root.go index 2060167..7852c8b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -78,7 +78,7 @@ func Execute(buildInfo *openhue.BuildInfo) { // add sub commands root.AddCommand(version.NewCmdVersion(ctx)) - root.AddCommand(setup.NewCmdAuth(ctx.Io)) + root.AddCommand(setup.NewCmdSetup(ctx.Io)) root.AddCommand(setup.NewCmdDiscover(ctx.Io)) root.AddCommand(setup.NewCmdConfigure()) diff --git a/cmd/setup/auth.go b/cmd/setup/auth.go deleted file mode 100644 index 30e4f24..0000000 --- a/cmd/setup/auth.go +++ /dev/null @@ -1,69 +0,0 @@ -package setup - -import ( - "context" - "fmt" - "github.com/spf13/cobra" - "openhue-cli/openhue" - "openhue-cli/openhue/gen" - "os" -) - -type CmdAuthOptions struct { - bridge string - deviceType string - generateClientKey bool -} - -// NewCmdAuth creates the auth command -func NewCmdAuth(streams openhue.IOStreams) *cobra.Command { - - o := CmdAuthOptions{} - - cmd := &cobra.Command{ - Use: "auth", - GroupID: "config", - Short: "Retrieve the Hue Application Key", - Long: `Authenticate to retrieve the Hue Application Key. - - Requires to go and press the button on the bridge. - -You can use the 'openhue discover' command to lookup the IP of your bridge. -`, - Run: func(cmd *cobra.Command, args []string) { - RunCmdAuth(streams, o.bridge, o.deviceType, o.generateClientKey) - }, - } - - cmd.Flags().StringVarP(&o.bridge, "bridge", "b", "", "Bridge IP (example '192.168.1.23')") - cmd.MarkFlagRequired("bridge") - - cmd.Flags().StringVarP(&o.deviceType, "devicetype", "d", getHostName(), "Device identifier") - - cmd.Flags().BoolVarP(&o.generateClientKey, "generateclientkey", "k", true, "Generate the client key") - - return cmd -} - -func RunCmdAuth(streams openhue.IOStreams, bridge string, deviceType string, generateClientKey bool) { - client := openhue.NewOpenHueClientNoAuth(bridge) - - body := gen.AuthenticateJSONRequestBody{} - body.Devicetype = &deviceType - body.Generateclientkey = &generateClientKey - resp, err := client.AuthenticateWithResponse(context.Background(), body) - cobra.CheckErr(err) - - auth := (*resp.JSON200)[0] - if auth.Error != nil { - fmt.Fprintln(streams.Out, "\n", *auth.Error.Description) - } else { - fmt.Fprintln(streams.Out, "\nYour hue-application-key ->", *auth.Success.Username) - } -} - -func getHostName() string { - hostname, err := os.Hostname() - cobra.CheckErr(err) - return hostname -} diff --git a/cmd/setup/configure.go b/cmd/setup/config.go similarity index 90% rename from cmd/setup/configure.go rename to cmd/setup/config.go index 85ef293..b4638a8 100644 --- a/cmd/setup/configure.go +++ b/cmd/setup/config.go @@ -6,7 +6,7 @@ import ( ) const ( - docShortConfigure = `Configure your local Philips Hue environment` + docShortConfigure = `Manual openhue CLI setup` docLongConfigure = ` The setup command must be run as a prerequisite for all resource related commands (controlling lights, rooms, scenes, etc.) @@ -24,7 +24,7 @@ func NewCmdConfigure() *cobra.Command { o := Options{} cmd := &cobra.Command{ - Use: "configure", + Use: "config", GroupID: "config", Short: docShortConfigure, Long: docLongConfigure, @@ -35,7 +35,7 @@ func NewCmdConfigure() *cobra.Command { Key: o.key, } - err := c.Save() + _, err := c.Save() cobra.CheckErr(err) }, } diff --git a/cmd/setup/configure_test.go b/cmd/setup/config_test.go similarity index 81% rename from cmd/setup/configure_test.go rename to cmd/setup/config_test.go index ad3ac32..bfc2daa 100644 --- a/cmd/setup/configure_test.go +++ b/cmd/setup/config_test.go @@ -9,7 +9,7 @@ func TestNewCmdConfigure(t *testing.T) { cmd := NewCmdConfigure() - assert.ThatCmdUseIs(t, cmd, "configure") + assert.ThatCmdUseIs(t, cmd, "config") assert.ThatCmdGroupIs(t, cmd, "config") } diff --git a/cmd/setup/discover.go b/cmd/setup/discover.go index 8567f06..3cac375 100644 --- a/cmd/setup/discover.go +++ b/cmd/setup/discover.go @@ -2,17 +2,13 @@ package setup import ( "fmt" - "github.com/brutella/dnssd" "github.com/spf13/cobra" - "golang.org/x/net/context" + "net" "openhue-cli/openhue" - "os" - "strings" + "openhue-cli/util/mdns" + "time" ) -const serviceName = "_hue._tcp" -const domain = ".local" - // NewCmdDiscover represents the discover command func NewCmdDiscover(io openhue.IOStreams) *cobra.Command { @@ -22,26 +18,12 @@ func NewCmdDiscover(io openhue.IOStreams) *cobra.Command { Short: "Hue Bridge discovery", Long: `Discover your Hue Bridge on your local network using the mDNS Service Discovery`, Run: func(cmd *cobra.Command, args []string) { - DiscoverBridge(io) + + ip := make(chan *net.IP) + go mdns.DiscoverBridge(ip, 5*time.Second) + fmt.Fprintf(io.Out, "%s\n", <-ip) }, } return cmd } - -func DiscoverBridge(io openhue.IOStreams) { - service := fmt.Sprintf("%s.%s.", strings.Trim(serviceName, "."), strings.Trim(domain, ".")) - - foundFn := func(e dnssd.BrowseEntry) { - - for _, ip := range e.IPs { - if ip.To4() != nil { // we want to display IPv4 address only - fmt.Fprintf(io.Out, "\nFound '%s' with IP '%s'\n", strings.Replace(e.Name, "\\", "", 3), ip) - os.Exit(0) - } - } - } - - err := dnssd.LookupType(context.Background(), service, foundFn, nil) - cobra.CheckErr(err) -} diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go new file mode 100644 index 0000000..555f70d --- /dev/null +++ b/cmd/setup/setup.go @@ -0,0 +1,113 @@ +package setup + +import ( + "context" + "errors" + "fmt" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "net" + "openhue-cli/openhue" + "openhue-cli/openhue/gen" + "openhue-cli/util/mdns" + "os" + "time" +) + +type CmdSetupOptions struct { + deviceType string + generateClientKey bool +} + +func NewCmdSetup(io openhue.IOStreams) *cobra.Command { + + o := CmdSetupOptions{} + + cmd := &cobra.Command{ + Use: "setup", + GroupID: "config", + Short: "Automatic openhue CLI setup", + Long: ` +The setup command will automatically discover the Hue Bridge connected to your local network and ask you to push +the bridge button to perform initial pairing. +`, + Run: func(cmd *cobra.Command, args []string) { + startSetup(io, &o) + }, + } + + cmd.Flags().StringVarP(&o.deviceType, "devicetype", "d", getHostName(), "Device identifier") + cmd.Flags().BoolVarP(&o.generateClientKey, "generateclientkey", "k", true, "Generate the client key") + + return cmd +} + +func startSetup(io openhue.IOStreams, o *CmdSetupOptions) { + ipChan := make(chan *net.IP) + go mdns.DiscoverBridge(ipChan, 5*time.Second) + ip := <-ipChan + + if ip == nil { + fmt.Fprintf(io.ErrOut, "❌ Unable to discover your Hue Bridge on your local network\n") + return + } + + fmt.Fprintf(io.Out, "[OK] Found Hue Bridge with IP '%s'\n", ip) + + client := openhue.NewOpenHueClientNoAuth(ip.String()) + + fmt.Fprintln(io.Out, "[..] Please push the button on your Hue Bridge") + done := false + for done == false { + fmt.Fprintf(io.Out, ".") + key, err := tryAuth(client, o.toAuthenticateBody()) + if err != nil { + time.Sleep(1 * time.Second) + continue + } + done = true + fmt.Fprintf(io.Out, "\n") + log.Info("Hue Application Key is ", key) + fmt.Fprintln(io.Out, "[OK] Successfully paired openhue with your Hue Bridge!") + path, err := saveConfig(ip.String(), key) + if err != nil { + fmt.Fprintf(io.ErrOut, "[KO] Unable to save config") + } + fmt.Fprintln(io.Out, "[OK] Configuration saved in file", path) + } +} + +func (o *CmdSetupOptions) toAuthenticateBody() gen.AuthenticateJSONRequestBody { + body := gen.AuthenticateJSONRequestBody{} + body.Devicetype = &o.deviceType + body.Generateclientkey = &o.generateClientKey + return body +} + +func tryAuth(client *gen.ClientWithResponses, body gen.AuthenticateJSONRequestBody) (string, error) { + + resp, err := client.AuthenticateWithResponse(context.Background(), body) + cobra.CheckErr(err) + + auth := (*resp.JSON200)[0] + if auth.Error != nil { + return "", errors.New(*auth.Error.Description) + } + + return *auth.Success.Username, nil +} + +func saveConfig(bridge string, key string) (string, error) { + c := openhue.Config{ + Bridge: bridge, + Key: key, + } + + return c.Save() +} + +func getHostName() string { + hostname, err := os.Hostname() + cobra.CheckErr(err) + return hostname +} diff --git a/cmd/setup/auth_test.go b/cmd/setup/setup_test.go similarity index 51% rename from cmd/setup/auth_test.go rename to cmd/setup/setup_test.go index 90acac9..4d30385 100644 --- a/cmd/setup/auth_test.go +++ b/cmd/setup/setup_test.go @@ -6,10 +6,11 @@ import ( "testing" ) -func TestNewCmdAuth(t *testing.T) { +func TestNewCmdSetup(t *testing.T) { - cmd := NewCmdAuth(openhue.NewTestIOStreamsDiscard()) + cmd := NewCmdSetup(openhue.NewTestIOStreamsDiscard()) - assert.ThatCmdUseIs(t, cmd, "auth") + assert.ThatCmdUseIs(t, cmd, "setup") assert.ThatCmdGroupIs(t, cmd, "config") + } diff --git a/openhue/config.go b/openhue/config.go index f957ab8..6223a68 100644 --- a/openhue/config.go +++ b/openhue/config.go @@ -16,7 +16,17 @@ import ( ) // CommandsWithNoConfig contains the list of commands that don't require the configuration to exist -var CommandsWithNoConfig = []string{"configure", "help", "discover", "auth", "version", "completion"} +var CommandsWithNoConfig = []string{"setup", "config", "help", "discover", "auth", "version", "completion"} +var configPath string + +func init() { + // Find home directory. + home, err := os.UserHomeDir() + cobra.CheckErr(err) + + configPath = filepath.Join(home, "/.openhue") + _ = os.MkdirAll(configPath, os.ModePerm) +} type Config struct { // The IP of the Philips HUE Bridge @@ -25,26 +35,18 @@ type Config struct { Key string } -func (c *Config) Load() { - - // Find home directory. - home, err := os.UserHomeDir() - cobra.CheckErr(err) - - var configPath = filepath.Join(home, "/.openhue") - _ = os.MkdirAll(configPath, os.ModePerm) - - logger.Init(configPath) +func (c *Config) GetConfigFile() string { + return filepath.Join(configPath, "config.yaml") +} - // Search config in home directory with name ".openhue" (without an extension). - viper.AddConfigPath(configPath) - viper.SetConfigName("config") - viper.SetConfigType("yaml") +func (c *Config) Load() { + logger.Init(filepath.Join(configPath, "openhue.log")) + viper.SetConfigFile(c.GetConfigFile()) // When trying to run CLI without configuration configDoesNotExist := viper.ReadInConfig() != nil if configDoesNotExist && len(os.Args) > 1 && !slices.Contains(CommandsWithNoConfig, os.Args[1]) { - fmt.Println("\nopenhue-cli not configured yet, please run the 'configure' command") + fmt.Println("\nopenhue-cli not configured yet, please run the 'setup' command") os.Exit(0) } @@ -52,14 +54,14 @@ func (c *Config) Load() { c.Key = viper.GetString("Key") } -func (c *Config) Save() error { +func (c *Config) Save() (string, error) { if len(c.Bridge) == 0 { - return errors.New("'bridge' value not set in config") + return "", errors.New("'bridge' value not set in config") } if len(c.Key) == 0 { - return errors.New("'key' value not set in config") + return "", errors.New("'key' value not set in config") } viper.Set("Bridge", c.Bridge) @@ -67,10 +69,10 @@ func (c *Config) Save() error { err := viper.SafeWriteConfig() if err != nil { - return viper.WriteConfig() + return c.GetConfigFile(), viper.WriteConfig() } - return nil + return c.GetConfigFile(), nil } // NewOpenHueClient Creates a new NewClientWithResponses for a given server and hueApplicationKey. diff --git a/util/logger/logger.go b/util/logger/logger.go index 2feeb42..5930950 100644 --- a/util/logger/logger.go +++ b/util/logger/logger.go @@ -3,14 +3,13 @@ package logger import ( log "github.com/sirupsen/logrus" "os" - "path/filepath" ) -func Init(configPath string) { +func Init(path string) { if isProd() { // If the file doesn't exist, create it or append to the file - file, err := os.OpenFile(filepath.Join(configPath, "openhue.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666) + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666) if err != nil { log.Fatal(err) } diff --git a/util/mdns/bridge.go b/util/mdns/bridge.go new file mode 100644 index 0000000..4455425 --- /dev/null +++ b/util/mdns/bridge.go @@ -0,0 +1,41 @@ +package mdns + +import ( + "context" + "fmt" + "github.com/brutella/dnssd" + log "github.com/sirupsen/logrus" + "net" + "strings" + "time" +) + +const serviceName = "_hue._tcp" +const domain = ".local" + +func DiscoverBridge(result chan *net.IP, timeout time.Duration) { + // build the service identifier + service := fmt.Sprintf("%s.%s.", strings.Trim(serviceName, "."), strings.Trim(domain, ".")) + log.Infof("DNS-SD service '%s'", service) + found := false + foundFn := func(e dnssd.BrowseEntry) { + + for _, ip := range e.IPs { + if ip.To4() != nil { // we want to display IPv4 address only + log.Info("Found bridge with IP ", ip) + result <- &ip + found = true + return + } + } + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + err := dnssd.LookupType(ctx, service, foundFn, nil) + if err != nil && !found { + log.Errorf("Unable to lookup bridge with service '%s'. Check error details below:", service) + log.Error(err) + } + <-ctx.Done() +}