1
0
Fork 0

Use home assistant timer helpers

main v0.6.0
Jordan Hotmann 2023-10-18 12:35:46 -06:00
parent dbf76af86a
commit ddf56ec6aa
No known key found for this signature in database
GPG Key ID: 01B504170C2A2EA3
10 changed files with 135 additions and 137 deletions

2
go.mod
View File

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

8
go.sum
View File

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

View File

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

View File

@ -25,6 +25,7 @@ const (
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")
}
}
}

View File

@ -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
State string
Duration time.Duration
NextActivation time.Time
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
}

View File

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

View File

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

View File

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

View File

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

View File

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