mirror of
https://github.com/Xevion/go-ha.git
synced 2025-12-06 01:15:10 -06:00
Additionally, removed the context that gets passed into the Services but isn't used in one of them. The websockets APIs also don't have any use for context.
329 lines
8.5 KiB
Go
329 lines
8.5 KiB
Go
package gomeassistant
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"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"
|
|
ws "saml.dev/gome-assistant/internal/websocket"
|
|
)
|
|
|
|
// Returned by NewApp() if authentication fails
|
|
var ErrInvalidToken = ws.ErrInvalidToken
|
|
|
|
var ErrInvalidArgs = errors.New("invalid arguments provided")
|
|
|
|
type App struct {
|
|
ctx context.Context
|
|
ctxCancel context.CancelFunc
|
|
conn *websocket.Conn
|
|
|
|
// Wraps the ws connection with added mutex locking
|
|
wsWriter *ws.WebsocketWriter
|
|
|
|
httpClient *http.HttpClient
|
|
|
|
service *Service
|
|
state *StateImpl
|
|
|
|
schedules pq.PriorityQueue
|
|
intervals pq.PriorityQueue
|
|
entityListeners map[string][]*EntityListener
|
|
entityListenersId int64
|
|
eventListeners map[string][]*EventListener
|
|
}
|
|
|
|
/*
|
|
DurationString represents a duration, such as "2s" or "24h".
|
|
See https://pkg.go.dev/time#ParseDuration for all valid time units.
|
|
*/
|
|
type DurationString string
|
|
|
|
/*
|
|
TimeString is a 24-hr format time "HH:MM" such as "07:30".
|
|
*/
|
|
type TimeString string
|
|
|
|
type timeRange struct {
|
|
start 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
|
|
|
|
// Optional
|
|
// Whether to use secure connections for http and websockets.
|
|
// Setting this to `true` will use `https://` instead of `https://`
|
|
// and `wss://` instead of `ws://`.
|
|
Secure bool
|
|
}
|
|
|
|
/*
|
|
NewApp establishes the websocket connection and returns an object
|
|
you can use to register schedules and listeners.
|
|
*/
|
|
func NewApp(request NewAppRequest) (*App, error) {
|
|
if request.IpAddress == "" || request.HAAuthToken == "" || request.HomeZoneEntityId == "" {
|
|
slog.Error("IpAddress, HAAuthToken, and HomeZoneEntityId are all required arguments in NewAppRequest")
|
|
return nil, ErrInvalidArgs
|
|
}
|
|
port := request.Port
|
|
if port == "" {
|
|
port = "8123"
|
|
}
|
|
|
|
var (
|
|
conn *websocket.Conn
|
|
ctx context.Context
|
|
ctxCancel context.CancelFunc
|
|
err error
|
|
)
|
|
|
|
if request.Secure {
|
|
conn, ctx, ctxCancel, err = ws.SetupSecureConnection(request.IpAddress, port, request.HAAuthToken)
|
|
} else {
|
|
conn, ctx, ctxCancel, err = ws.SetupConnection(request.IpAddress, port, request.HAAuthToken)
|
|
}
|
|
|
|
if conn == nil {
|
|
return nil, err
|
|
}
|
|
|
|
var httpClient *http.HttpClient
|
|
|
|
if request.Secure {
|
|
httpClient = http.NewHttpsClient(request.IpAddress, port, request.HAAuthToken)
|
|
} else {
|
|
httpClient = http.NewHttpClient(request.IpAddress, port, request.HAAuthToken)
|
|
}
|
|
|
|
wsWriter := &ws.WebsocketWriter{Conn: conn}
|
|
service := newService(wsWriter)
|
|
state, err := newState(httpClient, request.HomeZoneEntityId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &App{
|
|
conn: conn,
|
|
wsWriter: wsWriter,
|
|
ctx: ctx,
|
|
ctxCancel: ctxCancel,
|
|
httpClient: httpClient,
|
|
service: service,
|
|
state: state,
|
|
schedules: pq.New(),
|
|
intervals: pq.New(),
|
|
entityListeners: map[string][]*EntityListener{},
|
|
eventListeners: map[string][]*EventListener{},
|
|
}, nil
|
|
}
|
|
|
|
func (a *App) Cleanup() {
|
|
if a.ctxCancel != nil {
|
|
a.ctxCancel()
|
|
}
|
|
}
|
|
|
|
func (a *App) RegisterSchedules(schedules ...DailySchedule) {
|
|
for _, s := range schedules {
|
|
// realStartTime already set for sunset/sunrise
|
|
if s.isSunrise || s.isSunset {
|
|
s.nextRunTime = getNextSunRiseOrSet(a, s.isSunrise, s.sunOffset).Carbon2Time()
|
|
a.schedules.Insert(s, float64(s.nextRunTime.Unix()))
|
|
continue
|
|
}
|
|
|
|
now := carbon.Now()
|
|
startTime := carbon.Now().SetTimeMilli(s.hour, s.minute, 0, 0)
|
|
|
|
// advance first scheduled time by frequency until it is in the future
|
|
if startTime.Lt(now) {
|
|
startTime = startTime.AddDay()
|
|
}
|
|
|
|
s.nextRunTime = startTime.Carbon2Time()
|
|
a.schedules.Insert(s, float64(startTime.Carbon2Time().Unix()))
|
|
}
|
|
}
|
|
|
|
func (a *App) RegisterIntervals(intervals ...Interval) {
|
|
for _, i := range intervals {
|
|
if i.frequency == 0 {
|
|
slog.Error("A schedule must use either set frequency via Every()")
|
|
panic(ErrInvalidArgs)
|
|
}
|
|
|
|
i.nextRunTime = internal.ParseTime(string(i.startTime)).Carbon2Time()
|
|
now := time.Now()
|
|
for i.nextRunTime.Before(now) {
|
|
i.nextRunTime = i.nextRunTime.Add(i.frequency)
|
|
}
|
|
a.intervals.Insert(i, float64(i.nextRunTime.Unix()))
|
|
}
|
|
}
|
|
|
|
func (a *App) RegisterEntityListeners(etls ...EntityListener) {
|
|
for _, etl := range etls {
|
|
etl := etl
|
|
if etl.delay != 0 && etl.toState == "" {
|
|
slog.Error("EntityListener error: you have to use ToState() when using Duration()")
|
|
panic(ErrInvalidArgs)
|
|
}
|
|
|
|
for _, entity := range etl.entityIds {
|
|
if elList, ok := a.entityListeners[entity]; ok {
|
|
a.entityListeners[entity] = append(elList, &etl)
|
|
} else {
|
|
a.entityListeners[entity] = []*EntityListener{&etl}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *App) RegisterEventListeners(evls ...EventListener) {
|
|
for _, evl := range evls {
|
|
evl := evl
|
|
for _, eventType := range evl.eventTypes {
|
|
if elList, ok := a.eventListeners[eventType]; ok {
|
|
a.eventListeners[eventType] = append(elList, &evl)
|
|
} else {
|
|
ws.SubscribeToEventType(eventType, a.wsWriter, a.ctx)
|
|
a.eventListeners[eventType] = []*EventListener{&evl}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func getSunriseSunset(s *StateImpl, 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"
|
|
if sunrise {
|
|
val = rise
|
|
printString = "Sunrise"
|
|
}
|
|
|
|
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 {
|
|
parsingErr := fmt.Errorf("could not parse offset passed to %s: \"%s\": %w", printString, offset[0], err)
|
|
slog.Error(parsingErr.Error())
|
|
panic(parsingErr)
|
|
}
|
|
}
|
|
|
|
// add offset if set, this code works for negative values too
|
|
if t.Microseconds() != 0 {
|
|
setOrRiseToday = setOrRiseToday.AddMinutes(int(t.Minutes()))
|
|
}
|
|
|
|
return setOrRiseToday
|
|
}
|
|
|
|
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() {
|
|
slog.Info("Starting", "schedules", a.schedules.Len())
|
|
slog.Info("Starting", "entity listeners", len(a.entityListeners))
|
|
slog.Info("Starting", "event listeners", len(a.eventListeners))
|
|
|
|
go runSchedules(a)
|
|
go runIntervals(a)
|
|
|
|
// subscribe to state_changed events
|
|
id := internal.GetId()
|
|
ws.SubscribeToStateChangedEvents(id, a.wsWriter, a.ctx)
|
|
a.entityListenersId = id
|
|
|
|
// entity listeners runOnStartup
|
|
for eid, etls := range a.entityListeners {
|
|
for _, etl := range etls {
|
|
// ensure each ETL only runs once, even if
|
|
// it listens to multiple entities
|
|
if etl.runOnStartup && !etl.runOnStartupCompleted {
|
|
entityState, err := a.state.Get(eid)
|
|
if err != nil {
|
|
slog.Warn("Failed to get entity state \"", eid, "\" during startup, skipping RunOnStartup")
|
|
}
|
|
|
|
etl.runOnStartupCompleted = true
|
|
go etl.callback(a.service, a.state, EntityData{
|
|
TriggerEntityId: eid,
|
|
FromState: entityState.State,
|
|
FromAttributes: entityState.Attributes,
|
|
ToState: entityState.State,
|
|
ToAttributes: entityState.Attributes,
|
|
LastChanged: entityState.LastChanged,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// entity listeners and event listeners
|
|
elChan := make(chan ws.ChanMsg)
|
|
go ws.ListenWebsocket(a.conn, a.ctx, elChan)
|
|
|
|
for {
|
|
msg, ok := <-elChan
|
|
if !ok {
|
|
break
|
|
}
|
|
if a.entityListenersId == msg.Id {
|
|
go callEntityListeners(a, msg.Raw)
|
|
} else {
|
|
go callEventListeners(a, msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *App) GetService() *Service {
|
|
return a.service
|
|
}
|
|
|
|
func (a *App) GetState() State {
|
|
return a.state
|
|
}
|