good progress yay:

- impl http client
- create http client in App()
- generic builder for Service.*
- set Service on app to pass to callbacks later
- impl State
- set State on app to pass to callbacks later
- change panic to log.Fatalln
This commit is contained in:
Sam Lewis
2022-10-11 01:22:23 -04:00
parent 689a6ce4d3
commit 7bcca889f9
13 changed files with 296 additions and 93 deletions

59
app.go
View File

@@ -2,42 +2,51 @@ package gomeassistant
import (
"context"
"log"
"os"
"time"
"github.com/saml-dev/gome-assistant/internal/http"
"github.com/saml-dev/gome-assistant/internal/setup"
"nhooyr.io/websocket"
)
type app struct {
ctx context.Context
ctxCancel context.CancelFunc
conn *websocket.Conn
ctx context.Context
ctxCancel context.CancelFunc
conn *websocket.Conn
httpClient *http.HttpClient
service *Service
state *State
schedules []schedule
entityListeners []entityListener
}
var (
Sunrise hourMinute = hourMinute{1000, 0}
Sunset hourMinute = hourMinute{1001, 0}
)
/*
App establishes the websocket connection and returns an object
you can use to register schedules and listeners.
*/
func App(connString string) (app, error) {
conn, ctx, ctxCancel, err := setup.SetupConnection(connString)
if err != nil {
return app{}, err
}
func App(connString string) app {
token := os.Getenv("AUTH_TOKEN")
conn, ctx, ctxCancel := setup.SetupConnection(connString)
httpClient := http.NewHttpClient(connString, token)
service := NewService(conn, ctx, httpClient)
state := NewState(httpClient)
return app{
conn: conn,
ctx: ctx,
ctxCancel: ctxCancel,
httpClient: httpClient,
service: service,
state: state,
schedules: []schedule{},
entityListeners: []entityListener{},
}, nil
}
}
func (a *app) Cleanup() {
@@ -48,27 +57,20 @@ func (a *app) Cleanup() {
func (a *app) RegisterSchedule(s schedule) {
if s.err != nil {
panic(s.err) // something wasn't configured properly when the schedule was built
log.Fatalln(s.err) // something wasn't configured properly when the schedule was built
}
if s.frequency == 0 {
panic("A schedule must call either Daily() or Every() when built.")
log.Fatalln("A schedule must call either Daily() or Every() when built.")
}
now := time.Now()
startTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) // start at midnight today
// apply offset if set
if s.offset.int() != 0 {
if s.offset.int() == Sunrise.int() {
// TODO: same as sunset w/ sunrise
} else if s.offset.int() == Sunset.int() {
// TODO: add an http client (w/ token) to *app, use it to get state of sun.sun
// to get next sunset time
} else {
startTime.Add(time.Hour * time.Duration(s.offset.Hour))
startTime.Add(time.Minute * time.Duration(s.offset.Minute))
}
if s.offset.minutes() != 0 {
startTime.Add(time.Hour * time.Duration(s.offset.hour))
startTime.Add(time.Minute * time.Duration(s.offset.minute))
}
// advance first scheduled time by frequency until it is in the future
@@ -81,6 +83,11 @@ func (a *app) RegisterSchedule(s schedule) {
}
func (a *app) Start() {
// NOTE:should the prio queue and websocket listener both write to a channel or something?
// then select from that and spawn new goroutine to call callback?
// TODO: loop through schedules and create heap priority queue
// TODO: figure out looping listening to messages for
// listeners
}

View File

@@ -8,28 +8,24 @@ import (
)
func main() {
app, err := ga.App("192.168.86.67:8123")
app := ga.App("192.168.86.67:8123")
defer app.Cleanup()
if err != nil {
fmt.Println(err)
return
}
s := ga.ScheduleBuilder().Call(lightsOut).Daily().At(ga.HourMinute(22, 00)).Build()
s2 := ga.ScheduleBuilder().Call(lightsOut).Every(time.Hour*4 + time.Minute*30).Offset(ga.HourMinute(1, 0)).Build()
s := ga.ScheduleBuilder().Call(lightsOut).Daily().At(ga.Sunset.Subtract(ga.TimeOfDay(0, 30))).Build()
s2 := ga.ScheduleBuilder().Call(lightsOut).Every(time.Hour*4 + time.Minute*30).Offset(ga.TimeOfDay(1, 0)).Build()
app.RegisterSchedule(s2)
// err = app.Start()
simpleListener := ga.EntityListenerBuilder().
EntityId("light.lights").
Call(cool).
OnlyBetween(ga.HourMinute(22, 00), ga.HourMinute(07, 00))
OnlyBetween(ga.TimeOfDay(22, 00), ga.TimeOfDay(07, 00))
fmt.Println(simpleListener)
fmt.Println(s, "\n", s2)
}
func lightsOut(service ga.Service) {
func lightsOut(service ga.Service, state ga.State) {
// ga.TurnOff("light.all_lights")
}

View File

@@ -5,15 +5,15 @@ type entityListener struct {
callback entityListenerCallback
fromState string
toState string
betweenStart hourMinute
betweenEnd hourMinute
betweenStart timeOfDay
betweenEnd timeOfDay
}
type entityListenerCallback func(Service, Data)
type Data struct{}
func (b elBuilder3) OnlyBetween(start hourMinute, end hourMinute) elBuilder3 {
func (b elBuilder3) OnlyBetween(start timeOfDay, end timeOfDay) elBuilder3 {
b.entityListener.betweenStart = start
b.entityListener.betweenEnd = end
return b

1
go.mod
View File

@@ -5,5 +5,6 @@ go 1.19
require nhooyr.io/websocket v1.8.7
require (
github.com/golang-module/carbon/v2 v2.1.9 // indirect
github.com/klauspost/compress v1.10.3 // indirect
)

4
go.sum
View File

@@ -17,6 +17,8 @@ github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/golang-module/carbon/v2 v2.1.9 h1:OWkhYzTTPe+jPOUEL2JkvGwf6bKNQJoh4LVT1LUay80=
github.com/golang-module/carbon/v2 v2.1.9/go.mod h1:NF5unWf838+pyRY0o+qZdIwBMkFf7w0hmLIguLiEpzU=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
@@ -47,6 +49,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
@@ -68,5 +71,6 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=

View File

@@ -1,6 +1,83 @@
// http is used to interact with the home assistant
// REST API, currently only for retrieving state for
// REST API. Currently only used to retrieve state for
// a single entity_id
package http
// TODO: impl http struct, should be initialized as part of App()
import (
"errors"
"fmt"
"io"
"net/http"
)
type HttpClient struct {
url string
token string
}
func NewHttpClient(url, token string) *HttpClient {
url = fmt.Sprintf("http://%s/api", url)
return &HttpClient{url, token}
}
func (c *HttpClient) GetState(entityId string) ([]byte, error) {
resp, err := get(c.url+"/states/"+entityId, c.token)
if err != nil {
return nil, err
}
return resp, nil
}
func get(url, token string) ([]byte, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, errors.New("Error creating HTTP request: " + err.Error())
}
req.Header.Add("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, errors.New("Error on response.\n[ERROR] -" + err.Error())
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.New("Error while reading the response bytes:" + err.Error())
}
return body, nil
}
// func post(url string, token string, data any) ([]byte, error) {
// postBody, err := json.Marshal(data)
// if err != nil {
// return nil, err
// }
// req, err := http.NewRequest("GET", url, bytes.NewBuffer(postBody))
// if err != nil {
// return nil, errors.New("Error building post request: " + err.Error())
// }
// req.Header.Add("Authorization", "Bearer "+token)
// client := &http.Client{}
// resp, err := client.Do(req)
// if err != nil {
// return nil, errors.New("Error in post response: " + err.Error())
// }
// defer resp.Body.Close()
// if resp.StatusCode == 401 {
// log.Fatalln("ERROR: Auth token is invalid. Please double check it or create a new token in your Home Assistant profile")
// }
// body, err := io.ReadAll(resp.Body)
// if err != nil {
// log.Fatalln(err)
// }
// return body, nil
// }

View File

@@ -0,0 +1,11 @@
package services
import (
"context"
"nhooyr.io/websocket"
)
func BuildService[T Light | HomeAssistant](conn *websocket.Conn, ctx context.Context) *T {
return &T{conn: conn, ctx: ctx}
}

View File

@@ -0,0 +1,18 @@
package services
import (
"context"
"github.com/saml-dev/gome-assistant/internal/http"
"nhooyr.io/websocket"
)
type HomeAssistant struct {
conn *websocket.Conn
ctx context.Context
httpClient *http.HttpClient
}
// TODO: design how much reuse I can get between request types. E.g.
// only difference between light.turnon and homeassistant.turnon is
// domain and extra data

View File

@@ -3,13 +3,15 @@ package services
import (
"context"
"github.com/saml-dev/gome-assistant/internal/http"
"github.com/saml-dev/gome-assistant/internal/setup"
"nhooyr.io/websocket"
)
type Light struct {
conn *websocket.Conn
ctx context.Context
conn *websocket.Conn
ctx context.Context
httpClient *http.HttpClient
}
type LightRequest struct {
@@ -22,7 +24,21 @@ type LightRequest struct {
} `json:"target"`
}
func LightOnRequest(entityId string) LightRequest {
/* Public API */
func (l Light) TurnOn(entityId string) {
req := newLightOnRequest(entityId)
setup.WriteMessage(req, l.conn, l.ctx)
}
func (l Light) TurnOff(entityId string) {
req := newLightOffRequest(entityId)
setup.WriteMessage(req, l.conn, l.ctx)
}
/* Internal */
func newLightOnRequest(entityId string) LightRequest {
req := LightRequest{
Id: 5,
Type: "call_service",
@@ -33,18 +49,8 @@ func LightOnRequest(entityId string) LightRequest {
return req
}
func LightOffRequest(entityId string) LightRequest {
req := LightOnRequest(entityId)
func newLightOffRequest(entityId string) LightRequest {
req := newLightOnRequest(entityId)
req.Service = "turn_off"
return req
}
func (l Light) TurnOn(entityId string) {
req := LightOnRequest(entityId)
setup.WriteMessage(req, l.conn, l.ctx)
}
func (l Light) TurnOff(entityId string) {
req := LightOffRequest(entityId)
setup.WriteMessage(req, l.conn, l.ctx)
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"time"
@@ -39,40 +40,38 @@ func ReadMessage(conn *websocket.Conn, ctx context.Context) (string, error) {
return string(msg), nil
}
func SetupConnection(connString string) (*websocket.Conn, context.Context, context.CancelFunc, error) {
ctx, ctxCancel := context.WithTimeout(context.Background(), time.Second*5)
func SetupConnection(connString string) (*websocket.Conn, context.Context, context.CancelFunc) {
ctx, ctxCancel := context.WithTimeout(context.Background(), time.Second*3)
// Init websocket connection
conn, _, err := websocket.Dial(ctx, fmt.Sprintf("ws://%s/api/websocket", connString), nil)
if err != nil {
fmt.Printf("ERROR: Failed to connect to websocket at ws://%s/api/websocket. Check IP address and port\n", connString)
ctxCancel()
return nil, nil, nil, err
log.Fatalf("ERROR: Failed to connect to websocket at ws://%s/api/websocket. Check IP address and port\n", connString)
}
// Read auth_required message
_, err = ReadMessage(conn, ctx)
if err != nil {
ctxCancel()
return nil, nil, nil, err
log.Fatalln("Unknown error creating websocket client")
}
// Send auth message
err = SendAuthMessage(conn, ctx)
if err != nil {
ctxCancel()
return nil, nil, nil, err
log.Fatalln("Unknown error creating websocket client")
}
// Verify auth message
err = VerifyAuthResponse(conn, ctx)
if err != nil {
fmt.Println("ERROR: Auth token is invalid. Please double check it or create a new token in your Home Assistant profile")
ctxCancel()
return nil, nil, nil, err
log.Fatalln("ERROR: Auth token is invalid. Please double check it or create a new token in your Home Assistant profile")
}
return conn, ctx, ctxCancel, err
return conn, ctx, ctxCancel
}
func SendAuthMessage(conn *websocket.Conn, ctx context.Context) error {

View File

@@ -2,29 +2,76 @@ package gomeassistant
import (
"fmt"
"log"
"reflect"
"runtime"
"time"
)
type hourMinute struct {
Hour int
Minute int
type sunriseSunset struct {
base timeOfDay
addition timeOfDay
subtraction timeOfDay
}
func (hm hourMinute) int() int {
return hm.Hour + hm.Minute
var Sunrise *sunriseSunset = &sunriseSunset{
base: TimeOfDay(0, 10000),
addition: TimeOfDay(0, 0),
subtraction: TimeOfDay(0, 0),
}
func HourMinute(Hour, Minute int) hourMinute {
return hourMinute{Hour, Minute}
var Sunset *sunriseSunset = &sunriseSunset{
base: TimeOfDay(0, 20000),
addition: TimeOfDay(0, 0),
subtraction: TimeOfDay(0, 0),
}
func (hm hourMinute) String() string {
return fmt.Sprintf("%02d:%02d", hm.Hour, hm.Minute)
func (ss *sunriseSunset) Add(hm timeOfDay) *sunriseSunset {
ss.addition = hm
return ss
}
type scheduleCallback func(Service)
func (ss *sunriseSunset) Subtract(hm timeOfDay) *sunriseSunset {
ss.subtraction = hm
return ss
}
func (ss *sunriseSunset) minutes() int {
return ss.base.minute +
(ss.addition.hour*60 + ss.addition.minute) -
(ss.subtraction.hour*60 + ss.subtraction.minute)
}
// HourMinute is used to express a time of day
// but it shouldn't be used directly. Use
// HourMinute(), Sunset(), or Sunrise() to
// create one. Add() and Subtract() can be
// called on Sunset and Sunrise to offset
// from that time.
type timeOfDay struct {
hour int
minute int
}
type timeOfDayInterface interface {
// Time represented as number of minutes
// after midnight. E.g. 02:00 would be 120.
minutes() int
}
func (hm timeOfDay) minutes() int {
return hm.hour*60 + hm.minute
}
func TimeOfDay(Hour, Minute int) timeOfDay {
return timeOfDay{Hour, Minute}
}
func (hm timeOfDay) String() string {
return fmt.Sprintf("%02d:%02d", hm.hour, hm.minute)
}
type scheduleCallback func(Service, State)
type schedule struct {
/*
@@ -50,10 +97,10 @@ type schedule struct {
offset: "0003"
}
*/
offset hourMinute
offset timeOfDay
/*
This will be set rather than returning an error to avoid checking err for nil on every schedule :)
RegisterSchedule will panic if the error is set.
RegisterSchedule will exit if the error is set.
*/
err error
realStartTime time.Time
@@ -83,7 +130,7 @@ func ScheduleBuilder() scheduleBuilder {
return scheduleBuilder{
schedule{
frequency: 0,
offset: hourMinute{0, 0},
offset: timeOfDay{0, 0},
},
}
}
@@ -113,18 +160,18 @@ func (sb scheduleBuilderCall) Daily() scheduleBuilderDaily {
return scheduleBuilderDaily(sb)
}
func (sb scheduleBuilderDaily) At(t hourMinute) scheduleBuilderEnd {
sb.schedule.offset = t
func (sb scheduleBuilderDaily) At(t timeOfDayInterface) scheduleBuilderEnd {
sb.schedule.offset = convertTimeOfDayToActualOffset(t)
return scheduleBuilderEnd(sb)
}
func (sb scheduleBuilderCall) Every(d time.Duration) scheduleBuilderCustom {
sb.schedule.frequency = d
func (sb scheduleBuilderCall) Every(duration time.Duration) scheduleBuilderCustom {
sb.schedule.frequency = duration
return scheduleBuilderCustom(sb)
}
func (sb scheduleBuilderCustom) Offset(o hourMinute) scheduleBuilderEnd {
sb.schedule.offset = o
func (sb scheduleBuilderCustom) Offset(t timeOfDay) scheduleBuilderEnd {
sb.schedule.offset = t
return scheduleBuilderEnd(sb)
}
@@ -139,3 +186,22 @@ func (sb scheduleBuilderEnd) Build() schedule {
func getFunctionName(i interface{}) string {
return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name()
}
func convertTimeOfDayToActualOffset(t timeOfDayInterface) timeOfDay {
if t.minutes() > 15000 {
// TODO: same as below but w/ sunset
return TimeOfDay(0, 0)
} else if t.minutes() > 5000 {
// TODO: use httpClient to get state of sun.sun
// to get next sunrise time
// retrieve next sunrise time
// use carbon.Parse() to create time.Time of that time
// return Time() of that many hours and minutes to set offset from midnight
} else if t.minutes() >= 1440 {
log.Fatalln("Offset (set via At() or Offset()) cannot be more than 1 day (23h59m)")
}
return TimeOfDay(0, t.minutes())
}

View File

@@ -3,21 +3,19 @@ package gomeassistant
import (
"context"
"github.com/saml-dev/gome-assistant/internal/http"
"github.com/saml-dev/gome-assistant/internal/services"
"nhooyr.io/websocket"
)
type Service struct {
HomeAssistant homeAssistant
Light services.Light
HomeAssistant *services.HomeAssistant
Light *services.Light
}
type homeAssistant struct {
conn websocket.Conn
ctx context.Context
func NewService(conn *websocket.Conn, ctx context.Context, httpClient *http.HttpClient) *Service {
return &Service{
Light: services.BuildService[services.Light](conn, ctx),
HomeAssistant: services.BuildService[services.HomeAssistant](conn, ctx),
}
}
// type light struct {
// conn websocket.Conn
// ctx context.Context
// }

20
state.go Normal file
View File

@@ -0,0 +1,20 @@
package gomeassistant
import "github.com/saml-dev/gome-assistant/internal/http"
// State is used to retrieve state from Home Assistant.
type State struct {
httpClient *http.HttpClient
}
func NewState(c *http.HttpClient) *State {
return &State{httpClient: c}
}
func (s *State) Get(entityId string) (string, error) {
resp, err := s.httpClient.GetState(entityId)
if err != nil {
return "", err
}
return string(resp), nil
}