diff --git a/app.go b/app.go index ae7b308..3e4d028 100644 --- a/app.go +++ b/app.go @@ -27,6 +27,7 @@ type app struct { schedules pq.PriorityQueue entityListeners map[string][]*entityListener entityListenersId int64 + eventListeners map[string][]*eventListener } type TimeString string @@ -53,6 +54,7 @@ func NewApp(connString string) app { state: state, schedules: pq.New(), entityListeners: map[string][]*entityListener{}, + eventListeners: map[string][]*eventListener{}, } } @@ -83,12 +85,23 @@ func (a *app) RegisterSchedule(s schedule) { a.schedules.Insert(s, float64(startTime.Unix())) } -func (a *app) RegisterEntityListener(el entityListener) { - for _, entity := range el.entityIds { +func (a *app) RegisterEntityListener(etl entityListener) { + for _, entity := range etl.entityIds { if elList, ok := a.entityListeners[entity]; ok { - a.entityListeners[entity] = append(elList, &el) + a.entityListeners[entity] = append(elList, &etl) } else { - a.entityListeners[entity] = []*entityListener{&el} + a.entityListeners[entity] = []*entityListener{&etl} + } + } +} + +func (a *app) RegisterEventListener(evl eventListener) { + for _, eventType := range evl.eventTypes { + if elList, ok := a.eventListeners[eventType]; ok { + a.eventListeners[eventType] = append(elList, &evl) + } else { + ws.SubscribeToEventType(eventType, a.conn, a.ctx) + a.eventListeners[eventType] = []*eventListener{&evl} } } } @@ -144,24 +157,13 @@ func carbon2TimeString(c carbon.Carbon) string { return fmt.Sprintf("%02d:%02d", c.Hour(), c.Minute()) } -type subEvent struct { - Id int64 `json:"id"` - Type string `json:"type"` - EventType string `json:"event_type"` -} - func (a *app) Start() { // schedules go RunSchedules(a) // subscribe to state_changed events id := internal.GetId() - e := subEvent{ - Id: id, - Type: "subscribe_events", - EventType: "state_changed", - } - ws.WriteMessage(e, a.conn, a.ctx) + ws.SubscribeToStateChangedEvents(id, a.conn, a.ctx) a.entityListenersId = id // entity listeners @@ -173,6 +175,8 @@ func (a *app) Start() { msg = <-elChan if a.entityListenersId == msg.Id { go callEntityListeners(a, msg.Raw) + } else { + go callEventListeners(a, msg) } } } diff --git a/entitylistener.go b/entitylistener.go index 1c8b7e9..f7a4db4 100644 --- a/entitylistener.go +++ b/entitylistener.go @@ -2,12 +2,10 @@ package gomeassistant import ( "encoding/json" - "errors" "log" "time" "github.com/golang-module/carbon" - i "github.com/saml-dev/gome-assistant/internal" ) type entityListener struct { @@ -19,7 +17,6 @@ type entityListener struct { betweenEnd string throttle time.Duration lastRan carbon.Carbon - err error } type entityListenerCallback func(*Service, EntityData) @@ -68,7 +65,7 @@ type elBuilder1 struct { func (b elBuilder1) EntityIds(entityIds ...string) elBuilder2 { if len(entityIds) == 0 { - b.err = errors.New("must pass at least one entityId to EntityIds()") + log.Fatalln("must pass at least one entityId to EntityIds()") } else { b.entityListener.entityIds = entityIds } @@ -80,9 +77,7 @@ type elBuilder2 struct { } func (b elBuilder2) Call(callback entityListenerCallback) elBuilder3 { - if b.err == nil { - b.entityListener.callback = callback - } + b.entityListener.callback = callback return elBuilder3(b) } @@ -142,44 +137,17 @@ func callEntityListeners(app *app, msgBytes []byte) { } for _, l := range listeners { - // if betweenStart and betweenEnd both set, first account for midnight - // overlap, then only run if between those times. - if l.betweenStart != "" && l.betweenEnd != "" { - start := i.ParseTime(l.betweenStart) - end := i.ParseTime(l.betweenEnd) - - // check for midnight overlap - if end.Lt(start) { // example turn on night lights when motion from 23:00 to 07:00 - if end.IsPast() { // such as at 15:00, 22:00 - end = end.AddDay() - } else { - start = start.SubDay() // such as at 03:00, 05:00 - } - } - - // skip callback if not inside the range - if !carbon.Now().BetweenIncludedStart(start, end) { - return - } - - // otherwise just check individual before/after - } else if l.betweenStart != "" && i.ParseTime(l.betweenStart).IsFuture() { - return - } else if l.betweenEnd != "" && i.ParseTime(l.betweenEnd).IsPast() { + // Check conditions + if c := CheckWithinTimeRange(l.betweenStart, l.betweenEnd); c.fail { return } - - // don't run callback if fromState or toState are set and don't match - if l.fromState != "" && l.fromState != data.OldState.State { + if c := CheckStatesMatch(l.fromState, data.OldState.State); c.fail { return } - if l.toState != "" && l.toState != data.NewState.State { + if c := CheckStatesMatch(l.toState, data.NewState.State); c.fail { return } - - // don't run callback if Throttle is set and that duration hasn't passed since lastRan - if l.throttle.Seconds() > 0 && - l.lastRan.DiffAbsInSeconds(carbon.Now()) < int64(l.throttle.Seconds()) { + if c := CheckThrottle(l.throttle, l.lastRan); c.fail { return } diff --git a/eventListener.go b/eventListener.go index 18cf355..005b660 100644 --- a/eventListener.go +++ b/eventListener.go @@ -1,4 +1,122 @@ package gomeassistant // TODO: impl eventListener. could probably create generic listener struct for -// code reuse between eventListener and entityListener +// code reuse between eventListener and eventListener + +import ( + "encoding/json" + "log" + "time" + + "github.com/golang-module/carbon" + ws "github.com/saml-dev/gome-assistant/internal/websocket" +) + +type eventListener struct { + eventTypes []string + callback eventListenerCallback + betweenStart string + betweenEnd string + throttle time.Duration + lastRan carbon.Carbon +} + +type eventListenerCallback func(*Service, EventData) + +type EventData struct { + Type string + RawEventJSON []byte +} + +/* Methods */ + +func EventListenerBuilder() eventListenerBuilder1 { + return eventListenerBuilder1{eventListener{ + lastRan: carbon.Now().StartOfCentury(), + }} +} + +type eventListenerBuilder1 struct { + eventListener +} + +func (b eventListenerBuilder1) EventType(ets ...string) eventListenerBuilder2 { + b.eventTypes = ets + return eventListenerBuilder2(b) +} + +type eventListenerBuilder2 struct { + eventListener +} + +func (b eventListenerBuilder2) Call(callback eventListenerCallback) eventListenerBuilder3 { + b.eventListener.callback = callback + return eventListenerBuilder3(b) +} + +type eventListenerBuilder3 struct { + eventListener +} + +func (b eventListenerBuilder3) OnlyBetween(start string, end string) eventListenerBuilder3 { + b.eventListener.betweenStart = start + b.eventListener.betweenEnd = end + return b +} + +func (b eventListenerBuilder3) OnlyAfter(start string) eventListenerBuilder3 { + b.eventListener.betweenStart = start + return b +} + +func (b eventListenerBuilder3) OnlyBefore(end string) eventListenerBuilder3 { + b.eventListener.betweenEnd = end + return b +} + +func (b eventListenerBuilder3) Throttle(s TimeString) eventListenerBuilder3 { + d, err := time.ParseDuration(string(s)) + if err != nil { + log.Fatalf("Couldn't parse string duration passed to Throttle(): \"%s\" see https://pkg.go.dev/time#ParseDuration for valid time units", s) + } + b.eventListener.throttle = d + return b +} + +func (b eventListenerBuilder3) Build() eventListener { + return b.eventListener +} + +type BaseEventMsg struct { + Event struct { + EventType string `json:"event_type"` + } `json:"event"` +} + +/* Functions */ +func callEventListeners(app *app, msg ws.ChanMsg) { + baseEventMsg := BaseEventMsg{} + json.Unmarshal(msg.Raw, &baseEventMsg) + listeners, ok := app.eventListeners[baseEventMsg.Event.EventType] + if !ok { + // no listeners registered for this event type + return + } + + for _, l := range listeners { + // Check conditions + if c := CheckWithinTimeRange(l.betweenStart, l.betweenEnd); c.fail { + return + } + if c := CheckThrottle(l.throttle, l.lastRan); c.fail { + return + } + + eventData := EventData{ + Type: baseEventMsg.Event.EventType, + RawEventJSON: msg.Raw, + } + go l.callback(app.service, eventData) + l.lastRan = carbon.Now() + } +} diff --git a/example/main/testing.go b/example/main/testing.go index fa071aa..0e70f4a 100644 --- a/example/main/testing.go +++ b/example/main/testing.go @@ -14,23 +14,34 @@ func main() { EntityIds("binary_sensor.pantry_door"). Call(pantryLights). Build() + zwaveEventListener := ga. + EventListenerBuilder(). + EventType("zwave_js_value_notification"). + Call(onEvent). + Build() app.RegisterEntityListener(pantryDoor) app.RegisterSchedule(ga.ScheduleBuilder().Call(cool).Every("5s").Build()) + app.RegisterEventListener(zwaveEventListener) app.Start() } func pantryLights(service *ga.Service, data ga.EntityData) { + l := "group.kitchen_ceiling_lights" // service.HomeAssistant.Toggle("group.living_room_lamps", map[string]any{"brightness_pct": 100}) // service.Light.Toggle("light.entryway_lamp", map[string]any{"brightness_pct": 100}) if data.ToState == "on" { - service.HomeAssistant.TurnOn("switch.pantry_light_2") + service.HomeAssistant.TurnOn(l) } else { - service.HomeAssistant.TurnOff("switch.pantry_light_2") + service.HomeAssistant.TurnOff(l) } } +func onEvent(service *ga.Service, data ga.EventData) { + service.HomeAssistant.Toggle("light.el_gato_key_lights") +} + func cool(service *ga.Service, state *ga.State) { // service.InputDatetime.Set("input_datetime.garage_last_triggered_ts", time.Now()) // service.Light.TurnOn("light.entryway_lamp") @@ -42,5 +53,5 @@ func c(service *ga.Service, state *ga.State) { } func listenerCB(service *ga.Service, data ga.EntityData) { - log.Default().Println("hi katie") + log.Default().Println("hi") } diff --git a/internal/websocket/reader.go b/internal/websocket/reader.go index cc16d92..ad0bdf0 100644 --- a/internal/websocket/reader.go +++ b/internal/websocket/reader.go @@ -23,7 +23,6 @@ func ListenWebsocket(conn *websocket.Conn, ctx context.Context, c chan ChanMsg) bytes, _ := ReadMessage(conn, ctx) base := BaseMessage{} json.Unmarshal(bytes, &base) - chanMsg := ChanMsg{ Type: base.Type, Id: base.Id, diff --git a/internal/websocket/websocket.go b/internal/websocket/websocket.go index ba0fdb7..9312602 100644 --- a/internal/websocket/websocket.go +++ b/internal/websocket/websocket.go @@ -13,6 +13,7 @@ import ( "time" "github.com/gorilla/websocket" + i "github.com/saml-dev/gome-assistant/internal" ) type AuthMessage struct { @@ -106,3 +107,33 @@ func VerifyAuthResponse(conn *websocket.Conn, ctx context.Context) error { return nil } + +type SubEvent struct { + Id int64 `json:"id"` + Type string `json:"type"` + EventType string `json:"event_type"` +} + +func SubscribeToStateChangedEvents(id int64, conn *websocket.Conn, ctx context.Context) { + SubscribeToEventType("state_changed", conn, ctx, id) +} + +func SubscribeToEventType(eventType string, conn *websocket.Conn, ctx context.Context, id ...int64) { + var finalId int64 + if len(id) == 0 { + finalId = i.GetId() + } else { + finalId = id[0] + } + e := SubEvent{ + Id: finalId, + Type: "subscribe_events", + EventType: eventType, + } + err := WriteMessage(e, conn, ctx) + if err != nil { + log.Fatalln("Error writing to websocket: ", err) + } + // m, _ := ReadMessage(conn, ctx) + // log.Default().Println(string(m)) +} diff --git a/listeners.go b/listeners.go new file mode 100644 index 0000000..1e21f1f --- /dev/null +++ b/listeners.go @@ -0,0 +1,62 @@ +package gomeassistant + +import ( + "time" + + "github.com/golang-module/carbon" + i "github.com/saml-dev/gome-assistant/internal" +) + +type conditionCheck struct { + fail bool +} + +func CheckWithinTimeRange(startTime, endTime string) conditionCheck { + cc := conditionCheck{fail: false} + // if betweenStart and betweenEnd both set, first account for midnight + // overlap, then check if between those times. + if startTime != "" && endTime != "" { + parsedStart := i.ParseTime(startTime) + parsedEnd := i.ParseTime(endTime) + + // check for midnight overlap + if parsedEnd.Lt(parsedStart) { // example turn on night lights when motion from 23:00 to 07:00 + if parsedEnd.IsPast() { // such as at 15:00, 22:00 + parsedEnd = parsedEnd.AddDay() + } else { + parsedStart = parsedStart.SubDay() // such as at 03:00, 05:00 + } + } + + // skip callback if not inside the range + if !carbon.Now().BetweenIncludedStart(parsedStart, parsedEnd) { + cc.fail = true + } + + // otherwise just check individual before/after + } else if startTime != "" && i.ParseTime(startTime).IsFuture() { + cc.fail = true + } else if endTime != "" && i.ParseTime(endTime).IsPast() { + cc.fail = true + } + return cc +} + +func CheckStatesMatch(listenerState, s string) conditionCheck { + cc := conditionCheck{fail: false} + // check if fromState or toState are set and don't match + if listenerState != "" && listenerState != s { + cc.fail = true + } + return cc +} + +func CheckThrottle(throttle time.Duration, lastRan carbon.Carbon) conditionCheck { + cc := conditionCheck{fail: false} + // check if Throttle is set and that duration hasn't passed since lastRan + if throttle.Seconds() > 0 && + lastRan.DiffAbsInSeconds(carbon.Now()) < int64(throttle.Seconds()) { + cc.fail = true + } + return cc +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..99af5c3 --- /dev/null +++ b/types.go @@ -0,0 +1,25 @@ +package gomeassistant + +import "time" + +type EventZWaveJSValueNotification struct { + EventType string `yaml:"event_type"` + Data struct { + Domain string `yaml:"domain"` + NodeID int `yaml:"node_id"` + HomeID int64 `yaml:"home_id"` + Endpoint int `yaml:"endpoint"` + DeviceID string `yaml:"device_id"` + CommandClass int `yaml:"command_class"` + CommandClassName string `yaml:"command_class_name"` + Label string `yaml:"label"` + Property string `yaml:"property"` + PropertyName string `yaml:"property_name"` + PropertyKey string `yaml:"property_key"` + PropertyKeyName string `yaml:"property_key_name"` + Value string `yaml:"value"` + ValueRaw int `yaml:"value_raw"` + } `yaml:"data"` + Origin string `yaml:"origin"` + TimeFired time.Time `yaml:"time_fired"` +}