From ddf56ec6aa15409ad31189f6560327415d822317 Mon Sep 17 00:00:00 2001 From: Jordan Hotmann Date: Wed, 18 Oct 2023 12:35:46 -0600 Subject: [PATCH] Use home assistant timer helpers --- go.mod | 2 - go.sum | 8 +- internal/api/api.go | 26 +++-- internal/homeassistant/subscriber.go | 13 ++- internal/nats/timers.go | 164 ++++++++++----------------- main.go | 7 -- pkg/client/client.go | 8 +- pkg/homeassistant/rest.go | 4 +- pkg/homeassistant/structs.go | 20 ++++ pkg/homeassistant/util.go | 20 ++++ 10 files changed, 135 insertions(+), 137 deletions(-) diff --git a/go.mod b/go.mod index e56a94b..f864346 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,6 @@ require ( github.com/google/uuid v1.3.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect go.uber.org/atomic v1.9.0 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/net v0.15.0 // indirect golang.org/x/time v0.3.0 // indirect ) @@ -28,7 +27,6 @@ require ( github.com/nats-io/nats-server/v2 v2.10.2 // indirect github.com/nats-io/nkeys v0.4.5 // indirect github.com/nats-io/nuid v1.0.1 // indirect - github.com/samber/lo v1.38.1 golang.org/x/crypto v0.13.0 // indirect golang.org/x/sys v0.12.0 // indirect golang.org/x/text v0.13.0 // indirect diff --git a/go.sum b/go.sum index 127a0ab..a585ce6 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,7 @@ github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= @@ -42,19 +43,19 @@ github.com/nats-io/nkeys v0.4.5/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5s github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= -github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= -github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= @@ -63,8 +64,6 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -116,4 +115,5 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/api.go b/internal/api/api.go index be04e1f..1a468c5 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -41,7 +41,7 @@ func Listen(parentLogger *slog.Logger) { router.Post("/api/state/{entityId}/{service}", setEntityStateHandler) router.Get("/api/timer/{timerName}", getTimerHandler) - router.Post("/api/timer/{timerName}", createTimerHandler) + router.Post("/api/timer/{timerName}", startTimerHandler) router.Delete("/api/timer/{timerName}", deleteTimerHandler) router.Get("/api/schedule/{scheduleName}", getScheduleHandler) @@ -100,7 +100,7 @@ func setEntityStateHandler(w http.ResponseWriter, r *http.Request) { entityId := chi.URLParam(r, "entityId") service := chi.URLParam(r, "service") - var extras map[string]string + var extras map[string]any err := render.DecodeJSON(r.Body, &extras) var haErr error if err == nil && len(extras) > 0 { @@ -131,34 +131,38 @@ func getTimerHandler(w http.ResponseWriter, r *http.Request) { return } - render.PlainText(w, r, string(timer.Marshall())) + render.PlainText(w, r, timer.ToString()) } -type CreateTimerData struct { +type StartTimerData struct { Duration string `json:"duration"` Force bool `json:"force"` } -func createTimerHandler(w http.ResponseWriter, r *http.Request) { +func startTimerHandler(w http.ResponseWriter, r *http.Request) { timerName := chi.URLParam(r, "timerName") logRequest(w, r) - data := &CreateTimerData{} + data := &StartTimerData{} if err := render.DecodeJSON(r.Body, data); err != nil { http.Error(w, "Unable to parse timer data", http.StatusNotAcceptable) return } - if data.Duration == "" { - http.Error(w, "duration required", http.StatusNotAcceptable) + timer, err := nats.GetTimer(timerName) + if err != nil { + http.Error(w, "Unable to get timer: "+err.Error(), http.StatusInternalServerError) return } - timer := nats.NewTimerWithDuration(timerName, data.Duration).CalculateNext() + if data.Duration == "" { + data.Duration = timer.Duration.String() + } + if data.Force { - timer.Activate() + timer.Activate(data.Duration) } else { - timer.ActivateIfNotAlready() + timer.ActivateIfNotAlready(data.Duration) } getTimerHandler(w, r) diff --git a/internal/homeassistant/subscriber.go b/internal/homeassistant/subscriber.go index 98c0a6e..735acb7 100644 --- a/internal/homeassistant/subscriber.go +++ b/internal/homeassistant/subscriber.go @@ -22,9 +22,10 @@ var ( ) const ( - stateChangeEventId = 1001 - zhaEventId = 1002 - qrEventId = 1003 + stateChangeEventId = 1001 + zhaEventId = 1002 + qrEventId = 1003 + timerFinishedEventId = 1005 ) func CloseSubscription() error { @@ -115,6 +116,10 @@ func handleMessages() { Type: ha.MessageType.SubscribeEvents, EventType: ha.MessageType.TagScanned, Id: qrEventId}) + haWebsocketConn.WriteJSON(ha.SubscribeEventsMessage{ + Type: ha.MessageType.SubscribeEvents, + EventType: ha.MessageType.TimerFinished, + Id: timerFinishedEventId}) case ha.MessageType.Result: if !message.Success { logger.Error("Non-Success Result:", "message", message) @@ -137,6 +142,8 @@ func handleMessages() { case qrEventId: data, _ := json.Marshal(message.Event.Data) nats.Publish(fmt.Sprintf("homeassistant.qr.%s", message.Event.Data.TagId), data) + case timerFinishedEventId: + nats.PublishString(fmt.Sprintf("homeassistant.%s.finished", message.Event.Data.EntityId), "finished") } } } diff --git a/internal/nats/timers.go b/internal/nats/timers.go index 1d554cc..824d446 100644 --- a/internal/nats/timers.go +++ b/internal/nats/timers.go @@ -1,138 +1,94 @@ package nats import ( - "errors" + "fmt" "time" - "github.com/nats-io/nats.go/jetstream" + "code.jhot.me/jhot/hats/pkg/config" + "code.jhot.me/jhot/hats/pkg/homeassistant" ) var ( - timerStore jetstream.KeyValue - ticker *time.Ticker + haClient *homeassistant.RestClient ) -func TimerStoreConnect() error { - if client.JS == nil { - return errors.New("jetstream must be connected first") +func initHomeAssistantClient() { + if haClient == nil { + cfg = config.FromEnvironment() + haClient = homeassistant.NewRestClient(cfg.GetHomeAssistantBaseUrl(), cfg.HomeAssistantToken) } - - logger.Debug("Looking for KV store") - listener := client.JS.KeyValueStoreNames(ctx) - found := false - for name := range listener.Name() { - if name == "KV_hats_timers" { - found = true - } - } - - var err error - if found { - logger.Debug("Connecting to Timers KV store") - timerStore, err = client.JS.KeyValue(ctx, "hats_timers") - } else { - logger.Debug("Creating Timers KV store") - timerStore, err = client.JS.CreateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: "hats_timers", - }) - } - - return err } type HatsTimer struct { - Name string - Duration time.Duration - NextActivation time.Time + Name string + State string + Duration time.Duration + FinishTime time.Time } -func NewTimerWithDuration(name, duration string) *HatsTimer { - t := &HatsTimer{ - Name: name, - } - - d, err := time.ParseDuration(duration) - if err != nil { - d = 5 * time.Minute - } - t.Duration = d - - return t.CalculateNext() -} - -func NewTimerWithActivation(name string, activation []byte) (*HatsTimer, error) { - t := &HatsTimer{ - Name: name, - Duration: 5 * time.Minute, - } - - a, err := time.Parse(time.RFC3339, string(activation)) - if err != nil { - return t.CalculateNext(), err - } - t.NextActivation = a - - return t, nil +func (t *HatsTimer) getEntityId() string { + return fmt.Sprintf("timer.%s", t.Name) } func GetTimer(name string) (*HatsTimer, error) { - value, err := timerStore.Get(ctx, name) + initHomeAssistantClient() + state, err := haClient.GetState(fmt.Sprintf("timer.%s", name)) if err != nil { return nil, err } - return NewTimerWithActivation(name, value.Value()) + d, err := homeassistant.ParseDuration(state.Attributes["duration"].(string)) + if err != nil { + return nil, err + } + + var finishTime time.Time + if finishesAt, ok := state.Attributes["finishes_at"]; ok { + finishTime, _ = time.Parse(time.RFC3339, finishesAt.(string)) + } + + return &HatsTimer{ + Name: name, + State: state.State, + Duration: d, + FinishTime: finishTime, + }, nil } -func (t *HatsTimer) CalculateNext() *HatsTimer { - t.NextActivation = time.Now().Add(t.Duration) - return t +func (t *HatsTimer) Activate(durationOverride string) { + d := t.Duration + if durationOverride != "" { + var err error + d, err = time.ParseDuration(durationOverride) + if err != nil { + logger.Error("Error parsing duration", "error", err) + d = t.Duration + } + } + logger.Error("Starting timer", "duration", int(d.Seconds())) + err := haClient.CallService(t.getEntityId(), homeassistant.Services.Start, map[string]any{ + homeassistant.ExtraProps.Duration: int(d.Seconds()), + }) + if err != nil { + logger.Error("Error starting timer", "error", err) + } } -func (t *HatsTimer) Marshall() []byte { - timestamp, _ := t.NextActivation.MarshalText() - return timestamp -} - -func (t *HatsTimer) Activate() { - timerStore.Put(ctx, t.Name, t.Marshall()) -} - -func (t *HatsTimer) ActivateIfNotAlready() { - timerStore.Create(ctx, t.Name, t.Marshall()) +func (t *HatsTimer) ActivateIfNotAlready(durationOverride string) { + state, err := haClient.GetState(t.getEntityId()) + if err != nil || state.State != "active" { + t.Activate(durationOverride) + } } func (t *HatsTimer) Cancel() { - timerStore.Purge(ctx, t.Name) + haClient.CallService(t.getEntityId(), homeassistant.Services.Cancel) } -func (t *HatsTimer) End() { - t.Cancel() - PublishString("timers."+t.Name, "done") -} - -func WatchTimers() { - ticker = time.NewTicker(time.Second) - for { - t := <-ticker.C - timers, _ := timerStore.Keys(ctx, jetstream.IgnoreDeletes()) - for _, timerName := range timers { - timer, err := GetTimer(timerName) - if err != nil { - logger.Error("Error retrieving timer", "timer", timerName, "error", err) - timer.Cancel() - continue - } - - if t.After(timer.NextActivation) { - timer.End() - } - } - } -} - -func StopTimers() { - if ticker != nil { - ticker.Stop() +func (t *HatsTimer) ToString() string { + if t.State == "active" { + time, _ := t.FinishTime.MarshalText() + return string(time) } + return t.State } diff --git a/main.go b/main.go index a4ba581..e22d9af 100644 --- a/main.go +++ b/main.go @@ -40,13 +40,6 @@ func main() { panic(err) } - err = nats.TimerStoreConnect() - if err != nil { - panic(err) - } - go nats.WatchTimers() - defer nats.StopTimers() - err = nats.ScheduleStoreConnect() if err != nil { panic(err) diff --git a/pkg/client/client.go b/pkg/client/client.go index de79257..f959010 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -101,13 +101,13 @@ func (c *HatsClient) GetTimer(name string) (string, error) { return resp.String(), nil } -// Set a timer +// Start a timer // // name: the name of the timer (should be unique) // duration: time.Duration string for how long the timer should last // force: if true, will start the timer over even if it is already running -func (c *HatsClient) SetTimer(name string, duration string, force bool) (string, error) { - data := api.CreateTimerData{ +func (c *HatsClient) StartTimer(name string, duration string, force bool) (string, error) { + data := api.StartTimerData{ Duration: duration, Force: force, } @@ -124,7 +124,7 @@ func (c *HatsClient) SetTimer(name string, duration string, force bool) (string, return resp.String(), nil } -func (c *HatsClient) DeleteTimer(name string) error { +func (c *HatsClient) CancelTimer(name string) error { resp, err := c.client.R().Delete(fmt.Sprintf("api/timer/%s", name)) if err == nil && !resp.IsSuccess() { err = fmt.Errorf("%d status code received: %s", resp.StatusCode(), resp.String()) diff --git a/pkg/homeassistant/rest.go b/pkg/homeassistant/rest.go index 4a8604d..3978937 100644 --- a/pkg/homeassistant/rest.go +++ b/pkg/homeassistant/rest.go @@ -31,9 +31,9 @@ func (c *RestClient) GetState(entityId string) (StateData, error) { return data, err } -func (c *RestClient) CallService(entityId string, service string, extras ...map[string]string) error { +func (c *RestClient) CallService(entityId string, service string, extras ...map[string]any) error { domain := strings.Split(entityId, ".")[0] - data := map[string]interface{}{ + data := map[string]any{ "entity_id": entityId, } for _, extra := range extras { diff --git a/pkg/homeassistant/structs.go b/pkg/homeassistant/structs.go index 3fd18ef..67da543 100644 --- a/pkg/homeassistant/structs.go +++ b/pkg/homeassistant/structs.go @@ -11,6 +11,8 @@ var MessageType = struct { SubscribeEvents string StateChanged string TagScanned string + TimerStarted string + TimerFinished string }{ AuthRequired: "auth_required", AuthOk: "auth_ok", @@ -21,6 +23,8 @@ var MessageType = struct { SubscribeEvents: "subscribe_events", StateChanged: "state_changed", TagScanned: "tag_scanned", + TimerStarted: "timer.started", + TimerFinished: "timer.finished", } // Home Assistant device domains @@ -50,6 +54,9 @@ var Services = struct { SetHvacMode string SetFanMode string SetTemperature string + Start string + Change string + Cancel string }{ TurnOn: "turn_on", TurnOff: "turn_off", @@ -63,6 +70,9 @@ var Services = struct { SetHvacMode: "set_hvac_mode", SetFanMode: "set_fan_mode", SetTemperature: "set_temperature", + Start: "start", + Change: "change", + Cancel: "cancel", } // Extra props that can be sent when calling a Home Assistant service @@ -70,10 +80,20 @@ var ExtraProps = struct { Transition string Brightness string BrightnessPercent string + HvacMode string + Temperature string + TargetTempHigh string + TargetTempLow string + Duration string }{ Transition: "transition", Brightness: "brightness", BrightnessPercent: "brightness_pct", + HvacMode: "hvac_mode", + Temperature: "temperature", + TargetTempHigh: "target_temp_high", + TargetTempLow: "target_temp_low", + Duration: "duration", } type ResultContext struct { diff --git a/pkg/homeassistant/util.go b/pkg/homeassistant/util.go index b793b6b..fd39031 100644 --- a/pkg/homeassistant/util.go +++ b/pkg/homeassistant/util.go @@ -1,8 +1,10 @@ package homeassistant import ( + "fmt" "regexp" "strings" + "time" ) // StateToBool converts a state string into a boolean @@ -43,3 +45,21 @@ func BoolToService(entityId string, desiredState bool) string { } } } + +// Parse a Home Assistant duration in HH:MM:SS format +func ParseDuration(d string) (time.Duration, error) { + var hour, min, sec int + _, err := fmt.Sscanf(d, "%d:%d:%d", &hour, &min, &sec) + if err != nil { + return 5 * time.Minute, err + } + + return time.Duration(hour)*time.Hour + time.Duration(min)*time.Minute + time.Duration(sec)*time.Second, nil +} + +// func FormatDuration(d time.Duration) string { +// hours := math.Floor(d.Hours()) +// minutes := math.Floor(d.Minutes() - (hours * 60)) +// seconds := math.Floor(d.Seconds() - (hours * 60 * 60) - (minutes * 60)) +// return fmt.Sprintf("%02.f:%02.f:%02.f", hours, minutes, seconds) +// }