fix bug when registering multiple listeners and change sunrise/sunset to non-ha impl

This commit is contained in:
Sam Lewis
2023-01-12 22:33:38 -05:00
parent a868c08d40
commit e58a75388e
10 changed files with 112 additions and 62 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
testing/ testing/
.vscode/launch.json

99
app.go
View File

@@ -4,11 +4,11 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"os"
"time" "time"
"github.com/golang-module/carbon" "github.com/golang-module/carbon"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
sunriseLib "github.com/nathan-osman/go-sunrise"
"saml.dev/gome-assistant/internal" "saml.dev/gome-assistant/internal"
"saml.dev/gome-assistant/internal/http" "saml.dev/gome-assistant/internal/http"
pq "saml.dev/gome-assistant/internal/priorityqueue" pq "saml.dev/gome-assistant/internal/priorityqueue"
@@ -26,9 +26,9 @@ type App struct {
schedules pq.PriorityQueue schedules pq.PriorityQueue
intervals pq.PriorityQueue intervals pq.PriorityQueue
entityListeners map[string][]*EntityListener entityListeners map[string][]EntityListener
entityListenersId int64 entityListenersId int64
eventListeners map[string][]*EventListener eventListeners map[string][]EventListener
} }
/* /*
@@ -47,18 +47,46 @@ type timeRange struct {
end time.Time end time.Time
} }
type NewAppRequest struct {
// Required
// IpAddress of your Home Assistant instance i.e. "localhost"
// or "192.168.86.59" etc.
IpAddress string
// Optional
// Port number Home Assistant is running on. Defaults to 8123.
Port string
// Required
// Auth token generated in Home Assistant. Used
// to connect to the Websocket API.
HAAuthToken string
// Required
// EntityId of the zone representing your home e.g. "zone.home".
// Used to pull latitude/longitude from Home Assistant
// to calculate sunset/sunrise times.
HomeZoneEntityId string
}
/* /*
NewApp establishes the websocket connection and returns an object NewApp establishes the websocket connection and returns an object
you can use to register schedules and listeners. you can use to register schedules and listeners.
*/ */
func NewApp(connString string) *App { func NewApp(request NewAppRequest) *App {
token := os.Getenv("HA_AUTH_TOKEN") if request.IpAddress == "" || request.HAAuthToken == "" || request.HomeZoneEntityId == "" {
conn, ctx, ctxCancel := ws.SetupConnection(connString, token) log.Fatalln("IpAddress, HAAuthToken, and HomeZoneEntityId are all required arguments in NewAppRequest.")
}
port := request.Port
if port == "" {
port = "8123"
}
conn, ctx, ctxCancel := ws.SetupConnection(request.IpAddress, port, request.HAAuthToken)
httpClient := http.NewHttpClient(connString, token) httpClient := http.NewHttpClient(request.IpAddress, port, request.HAAuthToken)
service := newService(conn, ctx, httpClient) service := newService(conn, ctx, httpClient)
state := newState(httpClient) state := newState(httpClient, request.HomeZoneEntityId)
return &App{ return &App{
conn: conn, conn: conn,
@@ -69,8 +97,8 @@ func NewApp(connString string) *App {
state: state, state: state,
schedules: pq.New(), schedules: pq.New(),
intervals: pq.New(), intervals: pq.New(),
entityListeners: map[string][]*EntityListener{}, entityListeners: map[string][]EntityListener{},
eventListeners: map[string][]*EventListener{}, eventListeners: map[string][]EventListener{},
} }
} }
@@ -84,7 +112,7 @@ func (a *App) RegisterSchedules(schedules ...DailySchedule) {
for _, s := range schedules { for _, s := range schedules {
// realStartTime already set for sunset/sunrise // realStartTime already set for sunset/sunrise
if s.isSunrise || s.isSunset { if s.isSunrise || s.isSunset {
s.nextRunTime = getSunriseSunsetFromApp(a, s.isSunrise, s.sunOffset).Carbon2Time() s.nextRunTime = getNextSunRiseOrSet(a, s.isSunrise, s.sunOffset).Carbon2Time()
a.schedules.Insert(s, float64(s.nextRunTime.Unix())) a.schedules.Insert(s, float64(s.nextRunTime.Unix()))
continue continue
} }
@@ -105,7 +133,7 @@ func (a *App) RegisterSchedules(schedules ...DailySchedule) {
func (a *App) RegisterIntervals(intervals ...Interval) { func (a *App) RegisterIntervals(intervals ...Interval) {
for _, i := range intervals { for _, i := range intervals {
if i.frequency == 0 { if i.frequency == 0 {
panic("A schedule must use either set frequency via Every().") log.Fatalf("A schedule must use either set frequency via Every().\n")
} }
i.nextRunTime = internal.ParseTime(string(i.startTime)).Carbon2Time() i.nextRunTime = internal.ParseTime(string(i.startTime)).Carbon2Time()
@@ -113,7 +141,6 @@ func (a *App) RegisterIntervals(intervals ...Interval) {
for i.nextRunTime.Before(now) { for i.nextRunTime.Before(now) {
i.nextRunTime = i.nextRunTime.Add(i.frequency) i.nextRunTime = i.nextRunTime.Add(i.frequency)
} }
fmt.Println(i)
a.intervals.Insert(i, float64(i.nextRunTime.Unix())) a.intervals.Insert(i, float64(i.nextRunTime.Unix()))
} }
} }
@@ -121,14 +148,14 @@ func (a *App) RegisterIntervals(intervals ...Interval) {
func (a *App) RegisterEntityListeners(etls ...EntityListener) { func (a *App) RegisterEntityListeners(etls ...EntityListener) {
for _, etl := range etls { for _, etl := range etls {
if etl.delay != 0 && etl.toState == "" { if etl.delay != 0 && etl.toState == "" {
panic("EntityListener error: you have to use ToState() when using Duration()") log.Fatalln("EntityListener error: you have to use ToState() when using Duration()")
} }
for _, entity := range etl.entityIds { for _, entity := range etl.entityIds {
if elList, ok := a.entityListeners[entity]; ok { if elList, ok := a.entityListeners[entity]; ok {
a.entityListeners[entity] = append(elList, &etl) a.entityListeners[entity] = append(elList, etl)
} else { } else {
a.entityListeners[entity] = []*EntityListener{&etl} a.entityListeners[entity] = []EntityListener{etl}
} }
} }
} }
@@ -138,50 +165,54 @@ func (a *App) RegisterEventListeners(evls ...EventListener) {
for _, evl := range evls { for _, evl := range evls {
for _, eventType := range evl.eventTypes { for _, eventType := range evl.eventTypes {
if elList, ok := a.eventListeners[eventType]; ok { if elList, ok := a.eventListeners[eventType]; ok {
a.eventListeners[eventType] = append(elList, &evl) a.eventListeners[eventType] = append(elList, evl)
} else { } else {
ws.SubscribeToEventType(eventType, a.conn, a.ctx) ws.SubscribeToEventType(eventType, a.conn, a.ctx)
a.eventListeners[eventType] = []*EventListener{&evl} a.eventListeners[eventType] = []EventListener{evl}
} }
} }
} }
} }
func getSunriseSunsetFromState(s *State, sunrise bool, offset ...DurationString) carbon.Carbon { func getSunriseSunset(s *State, sunrise bool, dateToUse carbon.Carbon, offset ...DurationString) carbon.Carbon {
date := dateToUse.Carbon2Time()
rise, set := sunriseLib.SunriseSunset(s.latitude, s.longitude, date.Year(), date.Month(), date.Day())
rise, set = rise.Local(), set.Local()
val := set
printString := "Sunset" printString := "Sunset"
attrKey := "next_setting"
if sunrise { if sunrise {
val = rise
printString = "Sunrise" printString = "Sunrise"
attrKey = "next_rising"
} }
setOrRiseToday := carbon.Parse(val.String())
var t time.Duration var t time.Duration
var err error var err error
if len(offset) == 1 { if len(offset) == 1 {
t, err = time.ParseDuration(string(offset[0])) t, err = time.ParseDuration(string(offset[0]))
if err != nil { if err != nil {
panic(fmt.Sprintf("Could not parse offset passed to %s: \"%s\"", printString, offset[0])) log.Fatalf(fmt.Sprintf("Could not parse offset passed to %s: \"%s\"\n", printString, offset[0]))
} }
} }
// get next sunrise/sunset time from HA
state, err := s.Get("sun.sun")
if err != nil {
panic(fmt.Sprintf("Couldn't get sun.sun state from HA to calculate %s", printString))
}
nextSetOrRise := carbon.Parse(state.Attributes[attrKey].(string))
// add offset if set, this code works for negative values too // add offset if set, this code works for negative values too
if t.Microseconds() != 0 { if t.Microseconds() != 0 {
nextSetOrRise = nextSetOrRise.AddMinutes(int(t.Minutes())) setOrRiseToday = setOrRiseToday.AddMinutes(int(t.Minutes()))
} }
return nextSetOrRise return setOrRiseToday
} }
func getSunriseSunsetFromApp(a *App, sunrise bool, offset ...DurationString) carbon.Carbon { func getNextSunRiseOrSet(a *App, sunrise bool, offset ...DurationString) carbon.Carbon {
return getSunriseSunsetFromState(a.state, sunrise, offset...) sunriseOrSunset := getSunriseSunset(a.state, sunrise, carbon.Now(), offset...)
if sunriseOrSunset.Lt(carbon.Now()) {
// if we're past today's sunset or sunrise (accounting for offset) then get tomorrows
// as that's the next time the schedule will run
sunriseOrSunset = getSunriseSunset(a.state, sunrise, carbon.Tomorrow(), offset...)
}
return sunriseOrSunset
} }
func (a *App) Start() { func (a *App) Start() {

View File

@@ -3,13 +3,18 @@ package main
import ( import (
"encoding/json" "encoding/json"
"log" "log"
"os"
"time" "time"
ga "saml.dev/gome-assistant" ga "saml.dev/gome-assistant"
) )
func main() { func main() {
app := ga.NewApp("0.0.0.0:8123") // Replace with your Home Assistant IP Address app := ga.NewApp(ga.NewAppRequest{
IpAddress: "192.168.86.67", // Replace with your Home Assistant IP Address
HAAuthToken: os.Getenv("HA_AUTH_TOKEN"),
HomeZoneEntityId: "zone.home",
})
defer app.Cleanup() defer app.Cleanup()
pantryDoor := ga. pantryDoor := ga.

1
go.mod
View File

@@ -12,5 +12,6 @@ require (
github.com/gobuffalo/packd v1.0.2 // indirect github.com/gobuffalo/packd v1.0.2 // indirect
github.com/gobuffalo/packr v1.30.1 // indirect github.com/gobuffalo/packr v1.30.1 // indirect
github.com/joho/godotenv v1.4.0 // indirect github.com/joho/godotenv v1.4.0 // indirect
github.com/nathan-osman/go-sunrise v1.1.0 // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect
) )

2
go.sum
View File

@@ -36,6 +36,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/nathan-osman/go-sunrise v1.1.0 h1:ZqZmtmtzs8Os/DGQYi0YMHpuUqR/iRoJK+wDO0wTCw8=
github.com/nathan-osman/go-sunrise v1.1.0/go.mod h1:RcWqhT+5ShCZDev79GuWLayetpJp78RSjSWxiDowmlM=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

View File

@@ -15,8 +15,8 @@ type HttpClient struct {
token string token string
} }
func NewHttpClient(url, token string) *HttpClient { func NewHttpClient(ip, port, token string) *HttpClient {
url = fmt.Sprintf("http://%s/api", url) url := fmt.Sprintf("http://%s:%s/api", ip, port)
return &HttpClient{url, token} return &HttpClient{url, token}
} }

View File

@@ -21,7 +21,7 @@ func GetId() int64 {
func ParseTime(s string) carbon.Carbon { func ParseTime(s string) carbon.Carbon {
t, err := time.Parse("15:04", s) t, err := time.Parse("15:04", s)
if err != nil { if err != nil {
log.Fatalf("Failed to parse time string \"%s\"; format must be HH:MM.", s) log.Fatalf("Failed to parse time string \"%s\"; format must be HH:MM.\n", s)
} }
return carbon.Now().SetTimeMilli(t.Hour(), t.Minute(), 0, 0) return carbon.Now().SetTimeMilli(t.Hour(), t.Minute(), 0, 0)
} }
@@ -29,7 +29,7 @@ func ParseTime(s string) carbon.Carbon {
func ParseDuration(s string) time.Duration { func ParseDuration(s string) time.Duration {
d, err := time.ParseDuration(s) d, err := time.ParseDuration(s)
if err != nil { if err != nil {
panic(fmt.Sprintf("Couldn't parse string duration: \"%s\" see https://pkg.go.dev/time#ParseDuration for valid time units", s)) log.Fatalf(fmt.Sprintf("Couldn't parse string duration: \"%s\" see https://pkg.go.dev/time#ParseDuration for valid time units\n", s))
} }
return d return d
} }

View File

@@ -21,6 +21,8 @@ type AuthMessage struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
} }
// TODO: use a mutex to prevent concurrent writes panic here
// https://github.com/gorilla/websocket/issues/119
func WriteMessage[T any](msg T, conn *websocket.Conn, ctx context.Context) error { func WriteMessage[T any](msg T, conn *websocket.Conn, ctx context.Context) error {
msgJson, err := json.Marshal(msg) msgJson, err := json.Marshal(msg)
// fmt.Println(string(msgJson)) // fmt.Println(string(msgJson))
@@ -44,36 +46,36 @@ func ReadMessage(conn *websocket.Conn, ctx context.Context) ([]byte, error) {
return msg, nil return msg, nil
} }
func SetupConnection(connString string, authToken string) (*websocket.Conn, context.Context, context.CancelFunc) { func SetupConnection(ip, port, authToken string) (*websocket.Conn, context.Context, context.CancelFunc) {
ctx, ctxCancel := context.WithTimeout(context.Background(), time.Second*3) ctx, ctxCancel := context.WithTimeout(context.Background(), time.Second*3)
// Init websocket connection // Init websocket connection
dialer := websocket.DefaultDialer dialer := websocket.DefaultDialer
conn, _, err := dialer.DialContext(ctx, fmt.Sprintf("ws://%s/api/websocket", connString), nil) conn, _, err := dialer.DialContext(ctx, fmt.Sprintf("ws://%s:%s/api/websocket", ip, port), nil)
if err != nil { if err != nil {
ctxCancel() ctxCancel()
log.Fatalf("ERROR: Failed to connect to websocket at ws://%s/api/websocket. Check IP address and port\n", connString) log.Fatalf("ERROR: Failed to connect to websocket at ws://%s:%s/api/websocket. Check IP address and port\n", ip, port)
} }
// Read auth_required message // Read auth_required message
_, err = ReadMessage(conn, ctx) _, err = ReadMessage(conn, ctx)
if err != nil { if err != nil {
ctxCancel() ctxCancel()
panic("Unknown error creating websocket client") log.Fatalf("Unknown error creating websocket client\n")
} }
// Send auth message // Send auth message
err = SendAuthMessage(conn, ctx, authToken) err = SendAuthMessage(conn, ctx, authToken)
if err != nil { if err != nil {
ctxCancel() ctxCancel()
panic("Unknown error creating websocket client") log.Fatalf("Unknown error creating websocket client\n")
} }
// Verify auth message was successful // Verify auth message was successful
err = VerifyAuthResponse(conn, ctx) err = VerifyAuthResponse(conn, ctx)
if err != nil { if err != nil {
ctxCancel() ctxCancel()
panic("ERROR: Auth token is invalid. Please double check it or create a new token in your Home Assistant profile") log.Fatalf("ERROR: Auth token is invalid. Please double check it or create a new token in your Home Assistant profile\n")
} }
return conn, ctx, ctxCancel return conn, ctx, ctxCancel
@@ -132,7 +134,7 @@ func SubscribeToEventType(eventType string, conn *websocket.Conn, ctx context.Co
} }
err := WriteMessage(e, conn, ctx) err := WriteMessage(e, conn, ctx)
if err != nil { if err != nil {
panic(fmt.Sprintf("Error writing to websocket: %s", err)) log.Fatalf("Error writing to websocket: %s\n", err)
} }
// m, _ := ReadMessage(conn, ctx) // m, _ := ReadMessage(conn, ctx)
// log.Default().Println(string(m)) // log.Default().Println(string(m))

View File

@@ -157,16 +157,9 @@ func requeueSchedule(a *App, s DailySchedule) {
var nextSunTime carbon.Carbon var nextSunTime carbon.Carbon
// "0s" is default value // "0s" is default value
if s.sunOffset != "0s" { if s.sunOffset != "0s" {
nextSunTime = getSunriseSunsetFromApp(a, s.isSunrise, s.sunOffset) nextSunTime = getNextSunRiseOrSet(a, s.isSunrise, s.sunOffset)
} else { } else {
nextSunTime = getSunriseSunsetFromApp(a, s.isSunrise) nextSunTime = getNextSunRiseOrSet(a, s.isSunrise)
}
// this is true when there is a negative offset, so schedule runs before sunset/sunrise and
// HA still shows today's sunset as next sunset. Just add 1 day as a default handler
// since we can't get tomorrow's sunset from HA at this point.
if nextSunTime.IsToday() {
nextSunTime = nextSunTime.AddDay()
} }
s.nextRunTime = nextSunTime.Carbon2Time() s.nextRunTime = nextSunTime.Carbon2Time()

View File

@@ -2,14 +2,18 @@ package gomeassistant
import ( import (
"encoding/json" "encoding/json"
"log"
"time" "time"
"github.com/golang-module/carbon"
"saml.dev/gome-assistant/internal/http" "saml.dev/gome-assistant/internal/http"
) )
// State is used to retrieve state from Home Assistant. // State is used to retrieve state from Home Assistant.
type State struct { type State struct {
httpClient *http.HttpClient httpClient *http.HttpClient
latitude float64
longitude float64
} }
type EntityState struct { type EntityState struct {
@@ -19,8 +23,19 @@ type EntityState struct {
LastChanged time.Time `json:"last_changed"` LastChanged time.Time `json:"last_changed"`
} }
func newState(c *http.HttpClient) *State { func newState(c *http.HttpClient, homeZoneEntityId string) *State {
return &State{httpClient: c} state := &State{httpClient: c}
state.getLatLong(c, homeZoneEntityId)
return state
}
func (s *State) getLatLong(c *http.HttpClient, homeZoneEntityId string) {
resp, err := s.Get(homeZoneEntityId)
if err != nil {
log.Fatalf("Couldn't get latitude/longitude from home assistant entity '%s'. Did you type it correctly? It should be a zone like 'zone.home'.\n", homeZoneEntityId)
}
s.latitude = resp.Attributes["latitude"].(float64)
s.longitude = resp.Attributes["longitude"].(float64)
} }
func (s *State) Get(entityId string) (EntityState, error) { func (s *State) Get(entityId string) (EntityState, error) {
@@ -42,8 +57,8 @@ func (s *State) Equals(entityId string, expectedState string) (bool, error) {
} }
func (s *State) BeforeSunrise(offset ...DurationString) bool { func (s *State) BeforeSunrise(offset ...DurationString) bool {
sunrise := getSunriseSunsetFromState(s, true, offset...) sunrise := getSunriseSunset(s /* sunrise = */, true, carbon.Now(), offset...)
return sunrise.IsToday() return carbon.Now().Lt(sunrise)
} }
func (s *State) AfterSunrise(offset ...DurationString) bool { func (s *State) AfterSunrise(offset ...DurationString) bool {
@@ -51,8 +66,8 @@ func (s *State) AfterSunrise(offset ...DurationString) bool {
} }
func (s *State) BeforeSunset(offset ...DurationString) bool { func (s *State) BeforeSunset(offset ...DurationString) bool {
sunset := getSunriseSunsetFromState(s, false, offset...) sunset := getSunriseSunset(s /* sunrise = */, false, carbon.Now(), offset...)
return sunset.IsToday() return carbon.Now().Lt(sunset)
} }
func (s *State) AfterSunset(offset ...DurationString) bool { func (s *State) AfterSunset(offset ...DurationString) bool {