From 0cf3763518b97537b0c166930e22e5e0e2dda03d Mon Sep 17 00:00:00 2001 From: brchri <126272303+brchri@users.noreply.github.com> Date: Tue, 31 Oct 2023 23:21:35 -0600 Subject: [PATCH] add http opener type --- cmd/app/main.go | 8 ++ config.example.yml | 37 ++++++ internal/gdo/gdo.go | 6 + internal/gdo/http/http.go | 235 +++++++++++++++++++++++++++++++++ internal/gdo/http/http_test.go | 218 ++++++++++++++++++++++++++++++ internal/gdo/mqtt/mqtt.go | 82 ++++++------ internal/gdo/mqtt/mqtt_test.go | 44 +++++- internal/gdo/ratgdo/ratgdo.go | 3 +- internal/mocks/GDO.go | 32 +++++ 9 files changed, 619 insertions(+), 46 deletions(-) create mode 100644 internal/gdo/http/http.go create mode 100644 internal/gdo/http/http_test.go diff --git a/cmd/app/main.go b/cmd/app/main.go index 2a10e06..3dbdef1 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -39,9 +39,14 @@ func init() { logger.SetLevel(logger.DebugLevel) } log.SetOutput(os.Stdout) + parseArgs() util.LoadConfig(configFile) mqttSettings = &util.Config.Global.MqttSettings.Connection + if util.Config.Testing { + logger.Warn("TESTING=true, will not execute garage door actions") + } + geo.ParseGarageDoorConfig() checkEnvVars() for _, garageDoor := range geo.GarageDoors { @@ -198,6 +203,9 @@ func main() { case <-signalChannel: logger.Info("Received interrupt signal, shutting down...") client.Disconnect(250) + for _, g := range geo.GarageDoors { + g.Opener.ProcessShutdown() + } time.Sleep(250 * time.Millisecond) return diff --git a/config.example.yml b/config.example.yml index 3cdde2b..e9b3a47 100644 --- a/config.example.yml +++ b/config.example.yml @@ -123,3 +123,40 @@ garage_doors: required_stop_state: closed cars: - teslamate_car_id: 4 # id used for the third vehicle in TeslaMate's MQTT broker + + - # example with http opener type + circular_geofence: + center: + lat: 46.19290425661381 + lng: -123.79965087116439 + close_distance: .013 + open_distance: .04 + opener: + type: http + settings: + connection: + host: localhost + port: 80 + use_tls: false + skip_tls_verify: false + username: user + password: pass + status: + endpoint: /status # optional, GET endpoint to retrieve current door status; expects simple return values like `open` or `closed` + commands: + # /command endpoint with a body to indicate the command type + - name: open + endpoint: /command + http_method: post + body: { "command": "open" } + required_start_state: closed + required_stop_state: open + # /close endpoint with no body required, as the endpoint /close defines the type + - name: close + endpoint: /close + http_method: post + body: + required_start_state: open + required_stop_state: closed + cars: + - teslamate_car_id: 5 diff --git a/internal/gdo/gdo.go b/internal/gdo/gdo.go index 6f96ef2..7f00504 100644 --- a/internal/gdo/gdo.go +++ b/internal/gdo/gdo.go @@ -4,12 +4,16 @@ import ( "errors" "fmt" + "github.com/brchri/tesla-youq/internal/gdo/http" "github.com/brchri/tesla-youq/internal/gdo/mqtt" "github.com/brchri/tesla-youq/internal/gdo/ratgdo" ) type GDO interface { + // set garage door action, e.g. `open` or `close` SetGarageDoor(action string) (err error) + // process any required shutdown events, such as service disconnects + ProcessShutdown() } func Initialize(config map[string]interface{}) (GDO, error) { @@ -23,6 +27,8 @@ func Initialize(config map[string]interface{}) (GDO, error) { return ratgdo.Initialize(config) case "mqtt": return mqtt.Initialize(config) + case "http": + return http.Initialize(config) default: return nil, fmt.Errorf("gdo type %s not recognized", typeValue) } diff --git a/internal/gdo/http/http.go b/internal/gdo/http/http.go new file mode 100644 index 0000000..4755a3e --- /dev/null +++ b/internal/gdo/http/http.go @@ -0,0 +1,235 @@ +package http + +import ( + "bytes" + "crypto/tls" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/brchri/tesla-youq/internal/util" + logger "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" +) + +type ( + HttpGdo interface { + SetGarageDoor(string) error + ProcessShutdown() + } + + httpGdo struct { + Settings struct { + Connection struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + User string `yaml:"user"` + Pass string `yaml:"pass"` + UseTls bool `yaml:"use_tls"` + SkipTlsVerify bool `yaml:"skip_tls_verify"` + } `yaml:"connection"` + Status struct { + Endpoint string `yaml:"endpoint"` + } `yaml:"status"` + Commands []Command `yaml:"commands"` + } `yaml:"settings"` + OpenerType string `yaml:"type"` // name used by this module can be overridden by consuming modules, such as ratgdo, which is a wrapper for this package + State string // state of the garage door + Availability string // if the garage door controller publishes an availability status (e.g. online), it will be stored here + Obstruction string // if the garage door controller publishes obstruction information, it will be stored here + } + + Command struct { + Name string `yaml:"name"` // e.g. `open` or `close` + Endpoint string `yaml:"endpoint"` + HttpMethod string `yaml:"http_method"` + Body string `yaml:"body"` + RequiredStartState string `yaml:"required_start_state"` // if set, garage door will not operate if current state does not equal this + RequiredStopState string `yaml:"required_stop_state"` // if set, garage door will monitor the door state compared to this value to determine success + Timeout int `yaml:"timeout"` // time to wait for garage door to operate if monitored + } +) + +const ( + defaultHttpPort = 80 + defaultHttpsPort = 443 +) + +func init() { + logger.SetFormatter(&util.CustomFormatter{}) + logger.SetOutput(os.Stdout) + if val, ok := os.LookupEnv("DEBUG"); ok && strings.ToLower(val) == "true" { + logger.SetLevel(logger.DebugLevel) + } +} + +func Initialize(config map[string]interface{}) (HttpGdo, error) { + return NewHttpGdo(config) +} + +func NewHttpGdo(config map[string]interface{}) (HttpGdo, error) { + var httpGdo *httpGdo + + yamlData, err := yaml.Marshal(config) + if err != nil { + logger.Fatal("Failed to marhsal garage doors yaml object") + } + err = yaml.Unmarshal(yamlData, &httpGdo) + if err != nil { + logger.Fatal("Failed to unmarhsal garage doors yaml object") + } + + // set port if not set explicitly int he config + if httpGdo.Settings.Connection.Port == 0 { + if httpGdo.Settings.Connection.UseTls { + httpGdo.Settings.Connection.Port = defaultHttpsPort + } else { + httpGdo.Settings.Connection.Port = defaultHttpPort + } + } + + // set command timeouts if not defined + for k, c := range httpGdo.Settings.Commands { + if c.Timeout == 0 { + httpGdo.Settings.Commands[k].Timeout = 30 + } + } + + return httpGdo, nil +} + +func (h *httpGdo) SetGarageDoor(action string) error { + // identify command based on action + var command Command + for _, v := range h.Settings.Commands { + if action == v.Name { + command = v + } + } + if command.Name == "" { + return fmt.Errorf("no command defined for action %s", action) + } + + // validate required door state + if command.RequiredStartState != "" && h.Settings.Status.Endpoint != "" { + var err error + h.State, err = h.getDoorStatus() + if err != nil { + return fmt.Errorf("unable to get door state, received err: %v", err) + } + if h.State != "" && h.State != command.RequiredStartState { + logger.Warnf("Action and state mismatch: garage state is not valid for executing requested action; current state %s; requrested action: %s", h.State, action) + return nil + } + } + + // start building url and http client + url := "http" + if h.Settings.Connection.UseTls { + url += "s" + } + url += fmt.Sprintf("://%s:%d%s", h.Settings.Connection.Host, h.Settings.Connection.Port, command.Endpoint) + req, err := http.NewRequest(strings.ToUpper(command.HttpMethod), url, bytes.NewBuffer([]byte(command.Body))) + if err != nil { + return fmt.Errorf("unable to create http request, received err: %v", err) + } + + // set basic auth credentials if rqeuired + if h.Settings.Connection.User != "" || h.Settings.Connection.Pass != "" { + req.SetBasicAuth(h.Settings.Connection.User, h.Settings.Connection.Pass) + } + + // initialize http client and configure tls settings if relevant + client := &http.Client{} + if h.Settings.Connection.UseTls && h.Settings.Connection.SkipTlsVerify { + client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } + + // execute request + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("unable to send command to http endpoint, received err: %v", err) + } + defer resp.Body.Close() + + // check for 2xx response code + if resp.StatusCode > 300 { + return fmt.Errorf("received unexpected http status code: %s", resp.Status) + } + + // if no required_stop_state or status.endpoint was defined, then just return that we successfully posted to the endpoint + if command.RequiredStopState == "" || h.Settings.Status.Endpoint == "" { + logger.Infof("Garage door command `%s` has been sent to the http endpoint", action) + return nil + } + + // wait for timeout + start := time.Now() + for time.Since(start) < time.Duration(command.Timeout)*time.Second { + h.State, err = h.getDoorStatus() + if err != nil { + logger.Debugf("Unable to get door state, received err: %v", err) + logger.Debugf("Will keep trying until timeout expires") + } else if h.State == command.RequiredStopState { + logger.Infof("Garage door state has been set successfully: %s", action) + return nil + } else { + logger.Debugf("Current opener state: %s", h.State) + } + time.Sleep(1 * time.Second) + } + + // if we've hit this point, then we've timed out waiting for the garage to reach the requiredStopState + return fmt.Errorf("command sent to http endpoint, but timed out waiting for door to reach required_stop_state %s; current door state: %s", command.RequiredStopState, h.State) +} + +func (h *httpGdo) getDoorStatus() (string, error) { + if h.Settings.Status.Endpoint == "" { + // status endpoint not set, so just return empty string + return "", nil + } + + // start building url + url := "http" + if h.Settings.Connection.UseTls { + url += "s" + } + url += fmt.Sprintf("://%s:%d%s", h.Settings.Connection.Host, h.Settings.Connection.Port, h.Settings.Status.Endpoint) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", fmt.Errorf("unable to create http request, received err: %v", err) + } + + if h.Settings.Connection.User != "" || h.Settings.Connection.Pass != "" { + req.SetBasicAuth(h.Settings.Connection.User, h.Settings.Connection.Pass) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("unable to request status from http endpoint, received err: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode > 300 { + return "", fmt.Errorf("received unexpected http status code: %s", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("unable to parse response body, received err: %v", err) + } + + return string(body), nil + +} + +// stubbed function for rquired interface, no shutdown routines required for this package +func (h *httpGdo) ProcessShutdown() { + +} diff --git a/internal/gdo/http/http_test.go b/internal/gdo/http/http_test.go new file mode 100644 index 0000000..da63861 --- /dev/null +++ b/internal/gdo/http/http_test.go @@ -0,0 +1,218 @@ +package http + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "regexp" + "strconv" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +type httpRequestData struct { + method string + path string + body string +} + +var sampleYaml = map[string]interface{}{ + "settings": map[string]interface{}{ + "connection": map[string]interface{}{ + "host": "localhost", + "port": 80, + "user": "test-user", + "pass": "test-pass", + "use_tls": false, + "skip_tls_verify": false, + }, + "status": map[string]interface{}{ + "endpoint": "/status", + }, + "commands": []map[string]interface{}{ + { + "name": "open", + "endpoint": "/command", + "http_method": "post", + "body": "{ \"command\": \"open\" }", + "required_start_state": "closed", + "required_stop_state": "open", + "timeout": 5, + }, { + "name": "close", + "endpoint": "/command", + "http_method": "post", + "body": "{ \"command\": \"close\" }", + "required_start_state": "open", + "required_stop_state": "closed", + "timeout": 5, + }, + }, + }, +} + +var ( + httpRequests = []httpRequestData{} + + doorStateToReturn = "closed" +) + +func Test_NewClient(t *testing.T) { + httpgdo, err := NewHttpGdo(sampleYaml) + assert.Equal(t, nil, err) + if err != nil { + return + } + + if h, ok := httpgdo.(*httpGdo); ok { + assert.Equal(t, h.Settings.Connection.Host, "localhost") + assert.Equal(t, h.Settings.Connection.Port, 80) + assert.Equal(t, h.Settings.Status.Endpoint, "/status") + assert.Equal(t, h.Settings.Commands[0].Name, "open") + assert.Equal(t, h.Settings.Commands[1].Timeout, 5) + } else { + t.Error("returned type is not *mqttGdo") + } +} + +func mockServerHandler(w http.ResponseWriter, r *http.Request) { + bodyBytes, _ := io.ReadAll(r.Body) + body := string(bodyBytes) + + httpRequests = append(httpRequests, httpRequestData{ + method: r.Method, + path: r.URL.Path, + body: body, + }) + + if r.Method == "GET" && r.URL.Path == "/status" { + fmt.Fprint(w, doorStateToReturn) + return + } + + if r.Method == "POST" && r.URL.Path == "/command" { + fmt.Fprint(w, "") + } +} + +func Test_getDoorStatus(t *testing.T) { + h, err := NewHttpGdo(sampleYaml) + assert.Equal(t, nil, err) + if err != nil { + return + } + httpGdo, ok := h.(*httpGdo) + if !ok { + t.Error("returned type is not *httpGdo") + } + + mockServer := httptest.NewServer(http.HandlerFunc(mockServerHandler)) + defer mockServer.Close() + re := regexp.MustCompile(`http[s]?:\/\/(.+):(.*)`) + matches := re.FindStringSubmatch(mockServer.URL) + httpGdo.Settings.Connection.Host = matches[1] + serverPort, _ := strconv.ParseInt(matches[2], 10, 32) + httpGdo.Settings.Connection.Port = int(serverPort) + + doorStateToReturn = "open" + + state, err := httpGdo.getDoorStatus() + assert.Equal(t, nil, err) + if err != nil { + return + } + assert.Equal(t, "open", state) + + doorStateToReturn = "closed" + + state, err = httpGdo.getDoorStatus() + assert.Equal(t, nil, err) + if err != nil { + return + } + assert.Equal(t, "closed", state) +} + +// check SetGarageDoor with no status checks +func Test_SetGarageDoor_NoStatus(t *testing.T) { + h, err := NewHttpGdo(sampleYaml) + assert.Equal(t, nil, err) + if err != nil { + return + } + httpGdo, ok := h.(*httpGdo) + if !ok { + t.Error("returned type is not *httpGdo") + } + + // create mock http server and extract host and port info + mockServer := httptest.NewServer(http.HandlerFunc(mockServerHandler)) + defer mockServer.Close() + re := regexp.MustCompile(`http[s]?:\/\/(.+):(.*)`) + matches := re.FindStringSubmatch(mockServer.URL) + httpGdo.Settings.Connection.Host = matches[1] + serverPort, _ := strconv.ParseInt(matches[2], 10, 32) + httpGdo.Settings.Connection.Port = int(serverPort) + + // clear status endpoint, as we're testing without it here + httpGdo.Settings.Status.Endpoint = "" + httpGdo.SetGarageDoor("open") + // check that we received some requests + assert.LessOrEqual(t, 1, len(httpRequests)) + // check that final request was expected open params + assert.Equal(t, `{ "command": "open" }`, httpRequests[len(httpRequests)-1].body) + assert.Equal(t, "POST", httpRequests[len(httpRequests)-1].method) + assert.Equal(t, "/command", httpRequests[len(httpRequests)-1].path) +} + +// check SetGarageDoor with status checks +func Test_SetGarageDoor_WithStatus(t *testing.T) { + h, err := NewHttpGdo(sampleYaml) + assert.Equal(t, nil, err) + if err != nil { + return + } + httpGdo, ok := h.(*httpGdo) + if !ok { + t.Error("returned type is not *httpGdo") + } + + // create mock http server and extract host and port info + mockServer := httptest.NewServer(http.HandlerFunc(mockServerHandler)) + defer mockServer.Close() + re := regexp.MustCompile(`http[s]?:\/\/(.+):(.*)`) + matches := re.FindStringSubmatch(mockServer.URL) + httpGdo.Settings.Connection.Host = matches[1] + serverPort, _ := strconv.ParseInt(matches[2], 10, 32) + httpGdo.Settings.Connection.Port = int(serverPort) + + // clear tracking vars + httpRequests = []httpRequestData{} + doorStateToReturn = "closed" + + // execute SetGarageDoor in a goroutine so we can update the mocked door status + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + assert.Equal(t, nil, httpGdo.SetGarageDoor("open")) + }() + + // watch for "door open" request with a timeout, and set the door state to "open" + start := time.Now() + for doorStateToReturn == "closed" && time.Since(start) < 5*time.Second { + for _, v := range httpRequests { + if v.path == "/command" && v.method == "POST" && v.body == `{ "command": "open" }` { + doorStateToReturn = "open" + break + } + } + } + + // wait for goroutine to finish + wg.Wait() +} diff --git a/internal/gdo/mqtt/mqtt.go b/internal/gdo/mqtt/mqtt.go index 8fbd1d4..74b6dc3 100644 --- a/internal/gdo/mqtt/mqtt.go +++ b/internal/gdo/mqtt/mqtt.go @@ -28,11 +28,13 @@ type ( processMqttMessage(mqtt.Client, mqtt.Message) // SetGarageDoor operates the garage door by publishing to the configured mqtt topic with the configured payload SetGarageDoor(string) error + // process any required shutdown events, such as service disconnects + ProcessShutdown() } // mqttGdo is the struct that implements the MqttGdo interface mqttGdo struct { - MqttSettings struct { + Settings struct { Connection struct { Host string `yaml:"host"` Port int `yaml:"port"` @@ -49,7 +51,7 @@ type ( Availability string `yaml:"availability"` } `yaml:"topics"` Commands []Command `yaml:"commands"` - } `yaml:"mqtt_settings"` + } `yaml:"settings"` OpenerType string `yaml:"type"` // name used by this module can be overridden by consuming modules, such as ratgdo, which is a wrapper for this package MqttClient mqtt.Client // client that manages the connections and subscriptions to the mqtt broker State string // state of the garage door @@ -69,7 +71,7 @@ type ( const ( defaultModuleName = "Generic MQTT Opener" - defaultMattPort = 1883 + defaultMqttPort = 1883 ) var mqttNewClientFunc = mqtt.NewClient // abstract NewClient function call to allow mocking @@ -110,7 +112,7 @@ func NewMqttGdo(config map[string]interface{}) (MqttGdo, error) { } // check if garage door opener is connecting to the same mqtt broker as the global for teslamate, and if so, that they have unique clientIDs - localMqtt := &mqttGdo.MqttSettings.Connection + localMqtt := &mqttGdo.Settings.Connection globalMqtt := util.Config.Global.MqttSettings.Connection if localMqtt.ClientID != "" && localMqtt.ClientID == globalMqtt.ClientID && localMqtt.Host == globalMqtt.Host && localMqtt.Port == globalMqtt.Port { localMqtt.ClientID = localMqtt.ClientID + "-" + mqttGdo.OpenerType + "-" + uuid.NewString() @@ -118,13 +120,13 @@ func NewMqttGdo(config map[string]interface{}) (MqttGdo, error) { } // set command timeouts if not defined - for k, v := range mqttGdo.MqttSettings.Commands { + for k, v := range mqttGdo.Settings.Commands { if v.Timeout == 0 { - mqttGdo.MqttSettings.Commands[k].Timeout = 30 + mqttGdo.Settings.Commands[k].Timeout = 30 } } - mqttGdo.MqttSettings.Topics.Prefix = strings.TrimRight(mqttGdo.MqttSettings.Topics.Prefix, "/") // trim any trailing `/` on the prefix topic + mqttGdo.Settings.Topics.Prefix = strings.TrimRight(mqttGdo.Settings.Topics.Prefix, "/") // trim any trailing `/` on the prefix topic return mqttGdo, mqttGdo.ValidateMinimumMqttSettings() } @@ -138,13 +140,13 @@ func NewMqttGdo(config map[string]interface{}) (MqttGdo, error) { // host, commands[*].{Name,Payload,TopicSuffix} func (m *mqttGdo) ValidateMinimumMqttSettings() error { var errors []string - if m.MqttSettings.Connection.Host == "" { + if m.Settings.Connection.Host == "" { errors = append(errors, "missing mqtt host setting") } - if len(m.MqttSettings.Commands) == 0 { + if len(m.Settings.Commands) == 0 { errors = append(errors, "at least 1 command required to operate garage") } - for i, c := range m.MqttSettings.Commands { + for i, c := range m.Settings.Commands { commandErrorFormat := "missing %s for command %d" if c.Name == "" { errors = append(errors, fmt.Sprintf(commandErrorFormat, "command name", i)) @@ -158,9 +160,9 @@ func (m *mqttGdo) ValidateMinimumMqttSettings() error { } // set defaults if omitted from config - if m.MqttSettings.Connection.Port == 0 { - logger.Debugf("Port is undefined for %s, using default port %d", m.OpenerType, defaultMattPort) - m.MqttSettings.Connection.Port = defaultMattPort + if m.Settings.Connection.Port == 0 { + logger.Debugf("Port is undefined for %s, using default port %d", m.OpenerType, defaultMqttPort) + m.Settings.Connection.Port = defaultMqttPort } if len(errors) > 0 { @@ -183,24 +185,24 @@ func (m *mqttGdo) InitializeMqttClient() { opts.SetPingTimeout(10 * time.Second) logger.Debug(" AutoReconnect: true") opts.SetAutoReconnect(true) - if m.MqttSettings.Connection.User != "" { + if m.Settings.Connection.User != "" { logger.Debug(" Username: true ") } else { logger.Debug(" Username: false (not set)") } - opts.SetUsername(m.MqttSettings.Connection.User) // if not defined, will just set empty strings and won't be used by pkg - if m.MqttSettings.Connection.Pass != "" { + opts.SetUsername(m.Settings.Connection.User) // if not defined, will just set empty strings and won't be used by pkg + if m.Settings.Connection.Pass != "" { logger.Debug(" Password: true ") } else { logger.Debug(" Password: false (not set)") } - opts.SetPassword(m.MqttSettings.Connection.Pass) // if not defined, will just set empty strings and won't be used by pkg + opts.SetPassword(m.Settings.Connection.Pass) // if not defined, will just set empty strings and won't be used by pkg opts.OnConnect = m.onMqttConnect // set conditional MQTT client opts - if m.MqttSettings.Connection.ClientID != "" { - logger.Debugf(" ClientID: %s", m.MqttSettings.Connection.ClientID) - opts.SetClientID(m.MqttSettings.Connection.ClientID) + if m.Settings.Connection.ClientID != "" { + logger.Debugf(" ClientID: %s", m.Settings.Connection.ClientID) + opts.SetClientID(m.Settings.Connection.ClientID) } else { // generate UUID for mqtt client connection if not specified in config file id := uuid.New().String() @@ -209,17 +211,17 @@ func (m *mqttGdo) InitializeMqttClient() { } logger.Debug(" Protocol: TCP") mqttProtocol := "tcp" - if m.MqttSettings.Connection.UseTls { + if m.Settings.Connection.UseTls { logger.Debug(" UseTLS: true") - logger.Debugf(" SkipTLSVerify: %t", m.MqttSettings.Connection.SkipTlsVerify) + logger.Debugf(" SkipTLSVerify: %t", m.Settings.Connection.SkipTlsVerify) opts.SetTLSConfig(&tls.Config{ - InsecureSkipVerify: m.MqttSettings.Connection.SkipTlsVerify, + InsecureSkipVerify: m.Settings.Connection.SkipTlsVerify, }) mqttProtocol = "ssl" } else { logger.Debug(" UseTLS: false") } - broker := fmt.Sprintf("%s://%s:%d", mqttProtocol, m.MqttSettings.Connection.Host, m.MqttSettings.Connection.Port) + broker := fmt.Sprintf("%s://%s:%d", mqttProtocol, m.Settings.Connection.Host, m.Settings.Connection.Port) logger.Debugf(" Broker: %s", broker) opts.AddBroker(broker) @@ -240,9 +242,9 @@ func (m *mqttGdo) InitializeMqttClient() { // subscribes to topics if relevant func (m *mqttGdo) onMqttConnect(client mqtt.Client) { topicSuffixes := []string{ - m.MqttSettings.Topics.Obstruction, - m.MqttSettings.Topics.Availability, - m.MqttSettings.Topics.DoorStatus, + m.Settings.Topics.Obstruction, + m.Settings.Topics.Availability, + m.Settings.Topics.DoorStatus, } for _, t := range topicSuffixes { @@ -251,7 +253,7 @@ func (m *mqttGdo) onMqttConnect(client mqtt.Client) { continue } - fullTopic := m.MqttSettings.Topics.Prefix + "/" + t + fullTopic := m.Settings.Topics.Prefix + "/" + t logger.Debugf("Subscribing to MqttGdo MQTT topic %s", fullTopic) topicSubscribed := false // retry topic subscription attempts with 1 sec delay between attempts @@ -280,12 +282,12 @@ func (m *mqttGdo) onMqttConnect(client mqtt.Client) { // sets mqttGdo properties based on payloads func (m *mqttGdo) processMqttMessage(client mqtt.Client, message mqtt.Message) { // update MqttGdo property based on topic suffix (strip shared prefix on the switch) - switch strings.TrimPrefix(message.Topic(), m.MqttSettings.Topics.Prefix+"/") { - case m.MqttSettings.Topics.DoorStatus: + switch strings.TrimPrefix(message.Topic(), m.Settings.Topics.Prefix+"/") { + case m.Settings.Topics.DoorStatus: m.State = string(message.Payload()) - case m.MqttSettings.Topics.Availability: + case m.Settings.Topics.Availability: m.Availability = string(message.Payload()) - case m.MqttSettings.Topics.Obstruction: + case m.Settings.Topics.Obstruction: m.Obstruction = string(message.Payload()) default: logger.Debugf("invalid message topic: %s", message.Topic()) @@ -297,7 +299,7 @@ func (m *mqttGdo) processMqttMessage(client mqtt.Client, message mqtt.Message) { // if configured, will monitor door status to confirm successful operation func (m *mqttGdo) SetGarageDoor(action string) (err error) { var command Command - for _, v := range m.MqttSettings.Commands { + for _, v := range m.Settings.Commands { if action == v.Name { command = v break @@ -309,7 +311,7 @@ func (m *mqttGdo) SetGarageDoor(action string) (err error) { } // if status topic and required state are defined, check that the required state is satisfied - if m.MqttSettings.Topics.DoorStatus != "" && command.RequiredStartState != "" && m.State != command.RequiredStartState { + if m.Settings.Topics.DoorStatus != "" && command.RequiredStartState != "" && m.State != command.RequiredStartState { logger.Warnf("Action and state mismatch: garage state is not valid for executing requested action; current state: %s; requested action: %s", m.State, action) return } @@ -322,11 +324,11 @@ func (m *mqttGdo) SetGarageDoor(action string) (err error) { logger.Infof("setting garage door %s", action) logger.Debugf("Reported MqttGdo availability: %s", m.Availability) - token := m.MqttClient.Publish(m.MqttSettings.Topics.Prefix+"/"+command.TopicSuffix, 0, false, command.Payload) + token := m.MqttClient.Publish(m.Settings.Topics.Prefix+"/"+command.TopicSuffix, 0, false, command.Payload) token.Wait() // if a required stop state and status topic are defined, wait for it to be satisfied - if command.RequiredStopState != "" && m.MqttSettings.Topics.DoorStatus != "" { + if command.RequiredStopState != "" && m.Settings.Topics.DoorStatus != "" { // wait for timeout start := time.Now() for time.Since(start) < time.Duration(command.Timeout)*time.Second { @@ -339,9 +341,9 @@ func (m *mqttGdo) SetGarageDoor(action string) (err error) { } // these are based on ratgdo implementation, should probably make them configurable as other implementations may not use the same statuses - if m.MqttSettings.Topics.Availability != "" && m.Availability == "offline" { + if m.Settings.Topics.Availability != "" && m.Availability == "offline" { err = fmt.Errorf("unable to %s garage door, possible reason: mqttGdo availability reporting offline", action) - } else if m.MqttSettings.Topics.Obstruction != "" && m.Obstruction == "obstructed" { + } else if m.Settings.Topics.Obstruction != "" && m.Obstruction == "obstructed" { err = fmt.Errorf("unable to %s garage door, possible reason: mqttGdo obstruction reported", action) } else { err = fmt.Errorf("unable to %s garage door, possible reason: unknown; current state: %s", action, m.State) @@ -352,3 +354,7 @@ func (m *mqttGdo) SetGarageDoor(action string) (err error) { return } + +func (m *mqttGdo) ProcessShutdown() { + m.MqttClient.Disconnect(250) +} diff --git a/internal/gdo/mqtt/mqtt_test.go b/internal/gdo/mqtt/mqtt_test.go index 72e72f4..53b0602 100644 --- a/internal/gdo/mqtt/mqtt_test.go +++ b/internal/gdo/mqtt/mqtt_test.go @@ -12,7 +12,7 @@ import ( ) var sampleYaml = map[string]interface{}{ - "mqtt_settings": map[string]interface{}{ + "settings": map[string]interface{}{ "connection": map[string]interface{}{ "host": "localhost", "port": 1883, @@ -56,11 +56,11 @@ func Test_NewClient(t *testing.T) { } if m, ok := mqttgdo.(*mqttGdo); ok { - assert.Equal(t, m.MqttSettings.Connection.Host, "localhost") - assert.Equal(t, m.MqttSettings.Connection.Port, 1883) - assert.Equal(t, m.MqttSettings.Topics.DoorStatus, "status/door") - assert.Equal(t, m.MqttSettings.Commands[0].Name, "open") - assert.Equal(t, m.MqttSettings.Commands[1].Timeout, 5) + assert.Equal(t, m.Settings.Connection.Host, "localhost") + assert.Equal(t, m.Settings.Connection.Port, 1883) + assert.Equal(t, m.Settings.Topics.DoorStatus, "status/door") + assert.Equal(t, m.Settings.Commands[0].Name, "open") + assert.Equal(t, m.Settings.Commands[1].Timeout, 5) } else { t.Error("returned type is not *mqttGdo") } @@ -90,7 +90,7 @@ func Test_InitializeClient(t *testing.T) { mqttGdo.InitializeMqttClient() } -func Test_SetGarageDoor(t *testing.T) { +func Test_SetGarageDoor_WithStatus(t *testing.T) { // initialize mock objects mockMqttClient := &mocks.Client{} mockMqttClient.Test(t) @@ -136,3 +136,33 @@ func Test_SetGarageDoor(t *testing.T) { wg.Wait() } + +func Test_SetGarageDoor_NoStatus(t *testing.T) { + // initialize mock objects + mockMqttClient := &mocks.Client{} + mockMqttClient.Test(t) + mockMqttToken := &mocks.Token{} + mockMqttToken.Test(t) + defer mockMqttClient.AssertExpectations(t) + defer mockMqttToken.AssertExpectations(t) + + // set expectations for assertion + mockMqttToken.EXPECT().Wait().Once().Return(true) + mockMqttClient.EXPECT().Publish("home/garage/Main/command/door", mock.Anything, false, "open").Once().Return(mockMqttToken) + + // initialize test object + m, err := NewMqttGdo(sampleYaml) + assert.Equal(t, nil, err) + if err != nil { + return + } + mqttGdo, ok := m.(*mqttGdo) // check type so we can access structs + if !ok { + t.Error("returned type is not *mqttGdo") + } + mqttGdo.State = "closed" + mqttGdo.MqttClient = mockMqttClient + mqttGdo.Settings.Topics.DoorStatus = "" + + assert.Equal(t, nil, mqttGdo.SetGarageDoor("open")) +} diff --git a/internal/gdo/ratgdo/ratgdo.go b/internal/gdo/ratgdo/ratgdo.go index cf580d2..103a915 100644 --- a/internal/gdo/ratgdo/ratgdo.go +++ b/internal/gdo/ratgdo/ratgdo.go @@ -48,7 +48,8 @@ func NewRatgdo(config map[string]interface{}) (mqttGdo.MqttGdo, error) { } // add ratgdo-specific mqtt settings to the config object - if mqttSettings, ok := config["mqtt_settings"].(map[string]interface{}); ok { + config["settings"] = config["mqtt_settings"] // mqtt expects just `settings` key + if mqttSettings, ok := config["settings"].(map[string]interface{}); ok { mqttSettings["topics"] = map[string]string{ "prefix": ratgdo.MqttSettings.TopicPrefix, "door_status": "status/door", diff --git a/internal/mocks/GDO.go b/internal/mocks/GDO.go index b4e4710..f491713 100644 --- a/internal/mocks/GDO.go +++ b/internal/mocks/GDO.go @@ -17,6 +17,38 @@ func (_m *GDO) EXPECT() *GDO_Expecter { return &GDO_Expecter{mock: &_m.Mock} } +// ProcessShutdown provides a mock function with given fields: +func (_m *GDO) ProcessShutdown() { + _m.Called() +} + +// GDO_ProcessShutdown_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ProcessShutdown' +type GDO_ProcessShutdown_Call struct { + *mock.Call +} + +// ProcessShutdown is a helper method to define mock.On call +func (_e *GDO_Expecter) ProcessShutdown() *GDO_ProcessShutdown_Call { + return &GDO_ProcessShutdown_Call{Call: _e.mock.On("ProcessShutdown")} +} + +func (_c *GDO_ProcessShutdown_Call) Run(run func()) *GDO_ProcessShutdown_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *GDO_ProcessShutdown_Call) Return() *GDO_ProcessShutdown_Call { + _c.Call.Return() + return _c +} + +func (_c *GDO_ProcessShutdown_Call) RunAndReturn(run func()) *GDO_ProcessShutdown_Call { + _c.Call.Return(run) + return _c +} + // SetGarageDoor provides a mock function with given fields: action func (_m *GDO) SetGarageDoor(action string) error { ret := _m.Called(action)