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

3
.gitignore vendored
View File

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

99
app.go
View File

@@ -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() {

View File

@@ -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.

1
go.mod
View File

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

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/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=

View File

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

View File

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

View File

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

View File

@@ -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()

View File

@@ -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 {