From e58a75388ec6da5d7caa181c223cdfe0358e12e6 Mon Sep 17 00:00:00 2001 From: Sam Lewis Date: Thu, 12 Jan 2023 22:33:38 -0500 Subject: [PATCH] fix bug when registering multiple listeners and change sunrise/sunset to non-ha impl --- .gitignore | 3 +- app.go | 99 ++++++++++++++++++++++----------- example/example.go | 7 ++- go.mod | 1 + go.sum | 2 + internal/http/http.go | 4 +- internal/internal.go | 4 +- internal/websocket/websocket.go | 16 +++--- schedule.go | 11 +--- state.go | 27 +++++++-- 10 files changed, 112 insertions(+), 62 deletions(-) diff --git a/.gitignore b/.gitignore index 5b7147b..2a775a7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -testing/ \ No newline at end of file +testing/ +.vscode/launch.json \ No newline at end of file diff --git a/app.go b/app.go index ef507ad..6aef05d 100644 --- a/app.go +++ b/app.go @@ -4,11 +4,11 @@ import ( "context" "fmt" "log" - "os" "time" "github.com/golang-module/carbon" "github.com/gorilla/websocket" + sunriseLib "github.com/nathan-osman/go-sunrise" "saml.dev/gome-assistant/internal" "saml.dev/gome-assistant/internal/http" pq "saml.dev/gome-assistant/internal/priorityqueue" @@ -26,9 +26,9 @@ type App struct { schedules pq.PriorityQueue intervals pq.PriorityQueue - entityListeners map[string][]*EntityListener + entityListeners map[string][]EntityListener entityListenersId int64 - eventListeners map[string][]*EventListener + eventListeners map[string][]EventListener } /* @@ -47,18 +47,46 @@ type timeRange struct { 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 you can use to register schedules and listeners. */ -func NewApp(connString string) *App { - token := os.Getenv("HA_AUTH_TOKEN") - conn, ctx, ctxCancel := ws.SetupConnection(connString, token) +func NewApp(request NewAppRequest) *App { + if request.IpAddress == "" || request.HAAuthToken == "" || request.HomeZoneEntityId == "" { + 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) - state := newState(httpClient) + state := newState(httpClient, request.HomeZoneEntityId) return &App{ conn: conn, @@ -69,8 +97,8 @@ func NewApp(connString string) *App { state: state, schedules: pq.New(), intervals: pq.New(), - entityListeners: map[string][]*EntityListener{}, - eventListeners: map[string][]*EventListener{}, + entityListeners: map[string][]EntityListener{}, + eventListeners: map[string][]EventListener{}, } } @@ -84,7 +112,7 @@ func (a *App) RegisterSchedules(schedules ...DailySchedule) { for _, s := range schedules { // realStartTime already set for sunset/sunrise 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())) continue } @@ -105,7 +133,7 @@ func (a *App) RegisterSchedules(schedules ...DailySchedule) { func (a *App) RegisterIntervals(intervals ...Interval) { for _, i := range intervals { 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() @@ -113,7 +141,6 @@ func (a *App) RegisterIntervals(intervals ...Interval) { for i.nextRunTime.Before(now) { i.nextRunTime = i.nextRunTime.Add(i.frequency) } - fmt.Println(i) a.intervals.Insert(i, float64(i.nextRunTime.Unix())) } } @@ -121,14 +148,14 @@ func (a *App) RegisterIntervals(intervals ...Interval) { func (a *App) RegisterEntityListeners(etls ...EntityListener) { for _, etl := range etls { 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 { if elList, ok := a.entityListeners[entity]; ok { - a.entityListeners[entity] = append(elList, &etl) + a.entityListeners[entity] = append(elList, etl) } 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 _, eventType := range evl.eventTypes { if elList, ok := a.eventListeners[eventType]; ok { - a.eventListeners[eventType] = append(elList, &evl) + a.eventListeners[eventType] = append(elList, evl) } else { 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" - attrKey := "next_setting" if sunrise { + val = rise printString = "Sunrise" - attrKey = "next_rising" } + setOrRiseToday := carbon.Parse(val.String()) + var t time.Duration var err error if len(offset) == 1 { t, err = time.ParseDuration(string(offset[0])) 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 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 { - return getSunriseSunsetFromState(a.state, sunrise, offset...) +func getNextSunRiseOrSet(a *App, sunrise bool, offset ...DurationString) carbon.Carbon { + 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() { diff --git a/example/example.go b/example/example.go index 506a2c8..206a366 100644 --- a/example/example.go +++ b/example/example.go @@ -3,13 +3,18 @@ package main import ( "encoding/json" "log" + "os" "time" ga "saml.dev/gome-assistant" ) 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() pantryDoor := ga. diff --git a/go.mod b/go.mod index 3c71e89..1ca098b 100644 --- a/go.mod +++ b/go.mod @@ -12,5 +12,6 @@ require ( github.com/gobuffalo/packd v1.0.2 // indirect github.com/gobuffalo/packr v1.30.1 // 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 ) diff --git a/go.sum b/go.sum index ee8c362..8f25b0c 100644 --- a/go.sum +++ b/go.sum @@ -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/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/http/http.go b/internal/http/http.go index 214e190..15938a5 100644 --- a/internal/http/http.go +++ b/internal/http/http.go @@ -15,8 +15,8 @@ type HttpClient struct { token string } -func NewHttpClient(url, token string) *HttpClient { - url = fmt.Sprintf("http://%s/api", url) +func NewHttpClient(ip, port, token string) *HttpClient { + url := fmt.Sprintf("http://%s:%s/api", ip, port) return &HttpClient{url, token} } diff --git a/internal/internal.go b/internal/internal.go index 238992d..9742c58 100644 --- a/internal/internal.go +++ b/internal/internal.go @@ -21,7 +21,7 @@ func GetId() int64 { func ParseTime(s string) carbon.Carbon { t, err := time.Parse("15:04", s) 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) } @@ -29,7 +29,7 @@ func ParseTime(s string) carbon.Carbon { func ParseDuration(s string) time.Duration { d, err := time.ParseDuration(s) 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 } diff --git a/internal/websocket/websocket.go b/internal/websocket/websocket.go index bf323d1..ce1fcee 100644 --- a/internal/websocket/websocket.go +++ b/internal/websocket/websocket.go @@ -21,6 +21,8 @@ type AuthMessage struct { 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 { msgJson, err := json.Marshal(msg) // fmt.Println(string(msgJson)) @@ -44,36 +46,36 @@ func ReadMessage(conn *websocket.Conn, ctx context.Context) ([]byte, error) { 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) // Init websocket connection 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 { 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 _, err = ReadMessage(conn, ctx) if err != nil { ctxCancel() - panic("Unknown error creating websocket client") + log.Fatalf("Unknown error creating websocket client\n") } // Send auth message err = SendAuthMessage(conn, ctx, authToken) if err != nil { ctxCancel() - panic("Unknown error creating websocket client") + log.Fatalf("Unknown error creating websocket client\n") } // Verify auth message was successful err = VerifyAuthResponse(conn, ctx) if err != nil { 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 @@ -132,7 +134,7 @@ func SubscribeToEventType(eventType string, conn *websocket.Conn, ctx context.Co } err := WriteMessage(e, conn, ctx) 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) // log.Default().Println(string(m)) diff --git a/schedule.go b/schedule.go index ba861c8..1024f60 100644 --- a/schedule.go +++ b/schedule.go @@ -157,16 +157,9 @@ func requeueSchedule(a *App, s DailySchedule) { var nextSunTime carbon.Carbon // "0s" is default value if s.sunOffset != "0s" { - nextSunTime = getSunriseSunsetFromApp(a, s.isSunrise, s.sunOffset) + nextSunTime = getNextSunRiseOrSet(a, s.isSunrise, s.sunOffset) } else { - nextSunTime = getSunriseSunsetFromApp(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() + nextSunTime = getNextSunRiseOrSet(a, s.isSunrise) } s.nextRunTime = nextSunTime.Carbon2Time() diff --git a/state.go b/state.go index c881346..e07c4b7 100644 --- a/state.go +++ b/state.go @@ -2,14 +2,18 @@ package gomeassistant import ( "encoding/json" + "log" "time" + "github.com/golang-module/carbon" "saml.dev/gome-assistant/internal/http" ) // State is used to retrieve state from Home Assistant. type State struct { httpClient *http.HttpClient + latitude float64 + longitude float64 } type EntityState struct { @@ -19,8 +23,19 @@ type EntityState struct { LastChanged time.Time `json:"last_changed"` } -func newState(c *http.HttpClient) *State { - return &State{httpClient: c} +func newState(c *http.HttpClient, homeZoneEntityId string) *State { + 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) { @@ -42,8 +57,8 @@ func (s *State) Equals(entityId string, expectedState string) (bool, error) { } func (s *State) BeforeSunrise(offset ...DurationString) bool { - sunrise := getSunriseSunsetFromState(s, true, offset...) - return sunrise.IsToday() + sunrise := getSunriseSunset(s /* sunrise = */, true, carbon.Now(), offset...) + return carbon.Now().Lt(sunrise) } 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 { - sunset := getSunriseSunsetFromState(s, false, offset...) - return sunset.IsToday() + sunset := getSunriseSunset(s /* sunrise = */, false, carbon.Now(), offset...) + return carbon.Now().Lt(sunset) } func (s *State) AfterSunset(offset ...DurationString) bool {