Skip to content

Commit

Permalink
add http opener type
Browse files Browse the repository at this point in the history
  • Loading branch information
brchri committed Nov 1, 2023
1 parent d80226e commit 0cf3763
Show file tree
Hide file tree
Showing 9 changed files with 619 additions and 46 deletions.
8 changes: 8 additions & 0 deletions cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand Down
37 changes: 37 additions & 0 deletions config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions internal/gdo/gdo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
Expand Down
235 changes: 235 additions & 0 deletions internal/gdo/http/http.go
Original file line number Diff line number Diff line change
@@ -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() {

}
Loading

0 comments on commit 0cf3763

Please sign in to comment.