mirror of
https://github.com/Xevion/go-ha.git
synced 2025-12-06 01:15:10 -06:00
fix bug when registering multiple listeners and change sunrise/sunset to non-ha impl
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
testing/
|
||||
.vscode/launch.json
|
||||
99
app.go
99
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() {
|
||||
|
||||
@@ -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
1
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
|
||||
)
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
11
schedule.go
11
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()
|
||||
|
||||
27
state.go
27
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 {
|
||||
|
||||
Reference in New Issue
Block a user