Skip to content

Commit

Permalink
feat: add homeassistant gdo (#7)
Browse files Browse the repository at this point in the history
Adds support for controlling gdo's that are controlled by home
assistant, by proxying the command through home assistant.
  • Loading branch information
brchri authored Dec 2, 2023
1 parent b2304b6 commit 9fca2fd
Show file tree
Hide file tree
Showing 8 changed files with 286 additions and 12 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ A lightweight app that will operate your smart Garage Door Openers (GDOs) based

## Supported Smart Garage Door Openers
* Current
* Any [Home Assistant](https://www.home-assistant.io/) Controlled Garage Door Opener
* Controlled by proxying commands through Home Assistant
* [ratgdo](https://paulwieland.github.io/ratgdo/) (MQTT Configuration)
* Generic MQTT Controlled Smart Garage Door Openers
* Generic HTTP Controlled Smart Garage Door Openers
Expand Down
41 changes: 41 additions & 0 deletions examples/config.circular.homeassistant.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# This is an example config file with all available options and explanations for circular geofence and homeassistant opener types.

## NOTE ##
# Spacing is very important in this file, particularly the leading spacing (indentations). Failure to properly indent may cause config parsing to fail silently

global:
teslamate_mqtt_settings: # settings for teslamate's mqtt broker
connection:
host: localhost # dns, container name, or IP of teslamate's mqtt host
port: 1883
client_id: tesla-geogdo # optional, arbitrary client name for MQTT connection; must not be the same as any other MQTT client name, will use random uuid if omitted
user: mqtt_user # optional, only define if your mqtt broker requires authentication, can also be passed as env var MQTT_USER
pass: mqtt_pass # optional, only define if your mqtt broker requires authentication, can also be passed as env var MQTT_PASS
use_tls: false # optional, instructs app to connect to mqtt broker using tls (defaults to false)
skip_tls_verify: false # optional, if use_tls = true, this option indicates whether the client should skip certificate validation on the mqtt broker
cooldown: 5 # minutes to wait after operating garage before allowing another garage operation (set to 0 or omit to disable)

garage_doors:
- # main garage example
geofence: # circular geofence with a center point, open and close distances (radii)
type: circular
settings:
center:
lat: 46.19290425661381
lng: -123.79965087116439
close_distance: .013 # distance in kilometers car must travel away from garage location to close garage door
open_distance: .04 # distance in kilometers car must be in range of garage location while traveling closer to it to open garage door
opener:
type: homeassistant # type of garage door opener to use
settings:
connection: # connection settings for home assistant
host: homeassistant.local # dns, container name, or IP of home assistant
port: 8123
api_key: long_api_key # api key for home assistant; generate in user profile
use_tls: false # optional, instructs app to connect to home assistant using tls (defaults to false)
skip_tls_verify: false # optional, if use_tls = true, this option indicates whether the client should skip certificate validation on home assistant
entity_id: cover.main_door # id for the garage door entity in home assistant, can be found by adding '/config/entities' to the base url in home assistant
enable_status_checks: true # set to true if gdo supports garage states (e.g. garage is closed)
cars: # list of cars that use this garage door
- teslamate_car_id: 1 # id used for the first vehicle in TeslaMate's MQTT broker
- teslamate_car_id: 2 # id used for the second vehicle in TeslaMate's MQTT broker
9 changes: 9 additions & 0 deletions examples/config.polygon.http.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ garage_doors:
pass: pass # optional if basic auth is required
status:
endpoint: /status # optional, GET endpoint to retrieve current door status; expects simple return values like `open` or `closed`
headers: # optional, list of headers, each must be surrounded by single quotes
- 'Authorization: Bearer long_api_key' # example header
- 'Content-Type: application/json' # example header
commands:
# /command endpoint with a body to indicate the command type
- name: open # name of command
Expand All @@ -72,6 +75,9 @@ garage_doors:
required_start_state: closed # optional; if status endpoint is available, require this starting state to execute this command
required_finish_state: open # optional; if status endpoint is available, require this stop state to confirm successful command execution
timeout: 25 # optional, seconds to wait for garage door operation to complete if watching the status (default 30)
headers: # optional, list of headers, each must be surrounded by single quotes
- 'Authorization: Bearer long_api_key' # example header
- 'Content-Type: application/json' # example header
# /close endpoint with no body required, as the endpoint /close defines the type
- name: close
endpoint: /close
Expand All @@ -80,5 +86,8 @@ garage_doors:
required_start_state: open
required_finish_state: closed
timeout: 25
headers: # optional, list of headers, each must be surrounded by single quotes
- 'Authorization: Bearer long_api_key' # example header
- 'Content-Type: application/json' # example header
cars:
- teslamate_car_id: 1 # id used for the first vehicle in TeslaMate's MQTT broker
3 changes: 3 additions & 0 deletions internal/gdo/gdo.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"

"github.com/brchri/tesla-geogdo/internal/gdo/homeassistant"
"github.com/brchri/tesla-geogdo/internal/gdo/http"
"github.com/brchri/tesla-geogdo/internal/gdo/mqtt"
"github.com/brchri/tesla-geogdo/internal/gdo/ratgdo"
Expand All @@ -29,6 +30,8 @@ func Initialize(config map[string]interface{}) (GDO, error) {
return mqtt.Initialize(config)
case "http":
return http.Initialize(config)
case "homeassistant":
return homeassistant.Initialize(config)
default:
return nil, fmt.Errorf("gdo type %s not recognized", typeValue)
}
Expand Down
120 changes: 120 additions & 0 deletions internal/gdo/homeassistant/homeassistant.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package homeassistant

import (
"encoding/json"
"os"
"strings"

httpGdo "github.com/brchri/tesla-geogdo/internal/gdo/http"
"github.com/brchri/tesla-geogdo/internal/util"
logger "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)

// stubbed struct to extract api key and entity id from the yaml to pass into what is expected by httpGdo
type HomeAssistant struct {
Settings struct {
Connection struct {
ApiKey string `yaml:"api_key"`
} `yaml:"connection"`
EntityId string `yaml:"entity_id"`
EnableStatusChecks bool `yaml:"enable_status_checks"`
} `yaml:"settings"`
}

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)
}
}

// this is just a wrapper for the http package with some predefined settings for homeassistant
func Initialize(config map[string]interface{}) (httpGdo.HttpGdo, error) {
h, err := NewHomeAssistantGdo(config)
if err != nil {
return nil, err
}
return h, nil
}

func NewHomeAssistantGdo(config map[string]interface{}) (httpGdo.HttpGdo, error) {
var hassGdo *HomeAssistant
// marshall map[string]interface into yaml, then unmarshal to object based on yaml def in struct
yamlData, err := yaml.Marshal(config)
if err != nil {
logger.Fatal("Failed to marhsal garage doors yaml object")
}
err = yaml.Unmarshal(yamlData, &hassGdo)
if err != nil {
logger.Fatal("Failed to unmarshal garage doors yaml object")
}

// add homeassistant-specific http settings to the config object
if httpSettings, ok := config["settings"].(map[string]interface{}); ok {
httpSettings["commands"] = []map[string]interface{}{
{
"name": "open",
"endpoint": "/api/services/cover/open_cover",
"http_method": "post",
"body": `{"entity_id": "` + hassGdo.Settings.EntityId + `"}`,
"required_start_state": "closed",
"required_finish_state": "open",
"headers": []string{
"Authorization: Bearer " + hassGdo.Settings.Connection.ApiKey,
"Content-Type: application/json",
},
},
{
"name": "close",
"endpoint": "/api/services/cover/close_cover",
"http_method": "post",
"body": `{"entity_id": "` + hassGdo.Settings.EntityId + `"}`,
"required_start_state": "open",
"required_finish_state": "closed",
"headers": []string{
"Authorization: Bearer " + hassGdo.Settings.Connection.ApiKey,
"Content-Type: application/json",
},
},
}

if hassGdo.Settings.EnableStatusChecks {
httpSettings["status"] = map[string]interface{}{
"endpoint": "/api/states/" + hassGdo.Settings.EntityId,
"headers": []string{
"Authorization: Bearer " + hassGdo.Settings.Connection.ApiKey,
"Content-Type: application/json",
},
}
}
}

// create new httpGdo object with updated config
h, err := httpGdo.NewHttpGdo(config)
if err != nil {
return nil, err
}

// set callback function for httpGdo object to parse returned garage status
h.SetExtractStatusCallbackFunction(ExtractStatusCallback)

return h, nil
}

// define a callback function for the httpGdo package to extract the garage status from the returned json
// all that's needed is the json value for the `state` key
func ExtractStatusCallback(status string) (string, error) {
type statusResponse struct {
State string `json:"state"`
}

var s statusResponse
err := json.Unmarshal([]byte(status), &s)
if err != nil {
logger.Debugf("Unable to parse")
}

return s.State, nil
}
53 changes: 53 additions & 0 deletions internal/gdo/homeassistant/homeassistant_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package homeassistant

import (
"path/filepath"
"testing"

"github.com/brchri/tesla-geogdo/internal/util"
"github.com/stretchr/testify/assert"
)

var sampleYaml = map[string]interface{}{
"settings": map[string]interface{}{
"connection": map[string]interface{}{
"host": "localhost",
"port": 80,
"api_key": "somelongtoken",
"use_tls": false,
"skip_tls_verify": false,
},
"entity_id": "cover.main_cover",
"enable_status_checks": true,
},
}

// Since homeassistant is just a wrapper for httpGdo with some predefined configs,
// just need to ensure NewRatgdo doesn't throw any errors when returning
// an httpGdo object
func Test_NewHomeAssistantGdo(t *testing.T) {
// test with sample config defined above
_, err := NewHomeAssistantGdo(sampleYaml)
assert.Equal(t, nil, err)

// test with sample config extracted from example config.yml file
util.LoadConfig(filepath.Join("..", "..", "..", "examples", "config.circular.homeassistant.yml"))
door := *util.Config.GarageDoors[0]
var openerConfig interface{}
for k, v := range door {
if k == "opener" {
openerConfig = v
}
}
if openerConfig == nil {
t.Error("unable to parse config from garage door")
return
}
config, ok := openerConfig.(map[string]interface{})
if !ok {
t.Error("unable to parse config from garage door")
return
}
_, err = NewHomeAssistantGdo(config)
assert.Equal(t, nil, err)
}
Loading

0 comments on commit 9fca2fd

Please sign in to comment.