diff --git a/automations.go b/automations.go index bb27877..9c21d46 100644 --- a/automations.go +++ b/automations.go @@ -12,3 +12,47 @@ type Automation interface { // Action is called when the automation is triggered. Action() } + +type AutomationConfig struct { + action func() + entities Entities + name string +} + +func NewAutomation() *AutomationConfig { + return &AutomationConfig{} +} + +func (c *AutomationConfig) Entities() Entities { + return c.entities +} + +func (c *AutomationConfig) Action() { + c.action() +} + +func (c *AutomationConfig) Name() string { + return c.name +} + +func (c *AutomationConfig) WithAction(action func()) *AutomationConfig { + c.action = action + + if c.name == "" { + c.name = getShortFunctionName(action) + } + + return c +} + +func (c *AutomationConfig) WithEntities(entities ...EntityInterface) *AutomationConfig { + c.entities = entities + + return c +} + +func (c *AutomationConfig) WithName(name string) *AutomationConfig { + c.name = name + + return c +} diff --git a/automations/print_debug.go b/automations/print_debug.go new file mode 100644 index 0000000..897b8de --- /dev/null +++ b/automations/print_debug.go @@ -0,0 +1,31 @@ +package halautomations + +import ( + "log" + + "github.com/dansimau/hal" +) + +// PrintDebug prints state changes for the specified entities. +type PrintDebug struct { + name string + entities hal.Entities +} + +func NewPrintDebug(name string, entities ...hal.EntityInterface) *PrintDebug { + return &PrintDebug{name: name, entities: entities} +} + +func (p *PrintDebug) Name() string { + return p.name +} + +func (p *PrintDebug) Entities() hal.Entities { + return p.entities +} + +func (p *PrintDebug) Action() { + for _, entity := range p.entities { + log.Printf("[%s] Entity %s state: %+v", p.name, entity.GetID(), entity.GetState()) + } +} diff --git a/automations/sensor_lights.go b/automations/sensor_lights.go index bfd4be4..bb9f7f1 100644 --- a/automations/sensor_lights.go +++ b/automations/sensor_lights.go @@ -12,31 +12,35 @@ import ( // any of the sensors are triggered and turned off after a given duration. type SensorsTriggerLights struct { name string + log *slog.Logger - sensors []hal.EntityLike - lights []*hal.Light + sensors []hal.EntityInterface + lights []hal.LightInterface turnsOffAfter *time.Duration turnOffTimer *time.Timer } func NewSensorsTriggersLights() *SensorsTriggerLights { - return &SensorsTriggerLights{} + return &SensorsTriggerLights{ + log: slog.Default(), + } } func (a *SensorsTriggerLights) WithName(name string) *SensorsTriggerLights { a.name = name + a.log = slog.With("automation", a.name) return a } -func (a *SensorsTriggerLights) WithSensors(sensors ...hal.EntityLike) *SensorsTriggerLights { +func (a *SensorsTriggerLights) WithSensors(sensors ...hal.EntityInterface) *SensorsTriggerLights { a.sensors = sensors return a } -func (a *SensorsTriggerLights) WithLights(lights ...*hal.Light) *SensorsTriggerLights { +func (a *SensorsTriggerLights) WithLights(lights ...hal.LightInterface) *SensorsTriggerLights { a.lights = lights return a @@ -86,6 +90,8 @@ func (a *SensorsTriggerLights) turnOnLights() { } func (a *SensorsTriggerLights) turnOffLights() { + a.log.Info("Turning off lights") + for _, light := range a.lights { if err := light.TurnOff(); err != nil { slog.Error("Error turning off light", "error", err) @@ -95,9 +101,11 @@ func (a *SensorsTriggerLights) turnOffLights() { func (a *SensorsTriggerLights) Action() { if a.triggered() { + a.log.Info("Sensor triggered, turning on lights") a.stopTurnOffTimer() a.turnOnLights() } else { + a.log.Info("Sensor cleared, starting turn off countdown") a.startTurnOffTimer() } } diff --git a/connection.go b/connection.go index 865322f..f050675 100644 --- a/connection.go +++ b/connection.go @@ -17,7 +17,7 @@ type Connection struct { config Config automations map[string][]Automation - entities map[string]EntityLike + entities map[string]EntityInterface // Lock to serialize state updates and ensure automations fire in order. mutex sync.RWMutex @@ -44,7 +44,7 @@ func NewConnection(cfg Config) *Connection { homeAssistant: api, automations: make(map[string][]Automation), - entities: make(map[string]EntityLike), + entities: make(map[string]EntityInterface), SunTimes: NewSunTimes(cfg.Location), } @@ -63,6 +63,7 @@ func (h *Connection) FindEntities(v any) { // RegisterAutomations registers automations and binds them to the relevant entities. func (h *Connection) RegisterAutomations(automations ...Automation) { for _, automation := range automations { + slog.Info("Registering automation", "Name", automation.Name()) for _, entity := range automation.Entities() { h.automations[entity.GetID()] = append(h.automations[entity.GetID()], automation) } @@ -70,7 +71,7 @@ func (h *Connection) RegisterAutomations(automations ...Automation) { } // RegisterEntities registers entities and binds them to the connection. -func (h *Connection) RegisterEntities(entities ...EntityLike) { +func (h *Connection) RegisterEntities(entities ...EntityInterface) { for _, entity := range entities { slog.Info("Registering entity", "EntityID", entity.GetID()) entity.BindConnection(h) @@ -115,6 +116,7 @@ func (h *Connection) StateChangeEvent(event hassws.EventMessage) { // Dispatch automations for _, automation := range h.automations[event.Event.EventData.EntityID] { + slog.Info("Running automation", "name", automation.Name()) automation.Action() } } diff --git a/connection_test.go b/connection_test.go index 355d497..0f8d5b2 100644 --- a/connection_test.go +++ b/connection_test.go @@ -28,6 +28,6 @@ func TestConnection(t *testing.T) { // Verify entity state was updated testutil.WaitFor(t, func() bool { - return entity.State.State == "on" + return entity.GetState().State == "on" }) } diff --git a/entities.go b/entities.go index 118ed8d..388d171 100644 --- a/entities.go +++ b/entities.go @@ -6,9 +6,9 @@ import ( "github.com/dansimau/hal/homeassistant" ) -// EntityLike is the interface that we accept which allows us to create +// EntityInterface is the interface that we accept which allows us to create // custom components that embed an Entity. -type EntityLike interface { +type EntityInterface interface { ConnectionBinder GetID() string @@ -16,17 +16,16 @@ type EntityLike interface { SetState(event homeassistant.State) } -type Entities []EntityLike +type Entities []EntityInterface // Entity is a base type for all entities that can be embedded into other types. type Entity struct { connection *Connection - - homeassistant.State + state homeassistant.State } func NewEntity(id string) *Entity { - return &Entity{State: homeassistant.State{EntityID: id}} + return &Entity{state: homeassistant.State{EntityID: id}} } // BindConnection binds the entity to the connection. This allows entities to @@ -36,20 +35,20 @@ func (e *Entity) BindConnection(connection *Connection) { } func (e *Entity) GetID() string { - return e.State.EntityID + return e.state.EntityID } func (e *Entity) SetState(state homeassistant.State) { - e.State = state + e.state = state } func (e *Entity) GetState() homeassistant.State { - return e.State + return e.state } // findEntities recursively finds all entities in a struct, map, or slice. -func findEntities(v any) []EntityLike { - var entities []EntityLike +func findEntities(v any) []EntityInterface { + var entities []EntityInterface value := reflect.ValueOf(v) if value.Kind() == reflect.Ptr { @@ -73,7 +72,7 @@ func findEntities(v any) []EntityLike { // Check if field implements EntityLike interface if field.Kind() == reflect.Ptr && !field.IsNil() { - if entity, ok := field.Interface().(EntityLike); ok { + if entity, ok := field.Interface().(EntityInterface); ok { entities = append(entities, entity) continue diff --git a/entity_binary_sensor.go b/entity_binary_sensor.go new file mode 100644 index 0000000..02b872a --- /dev/null +++ b/entity_binary_sensor.go @@ -0,0 +1,18 @@ +package hal + +// BinarySensor is any sensor with a state of "on" or "off". +type BinarySensor struct { + *Entity +} + +func NewBinarySensor(id string) *BinarySensor { + return &BinarySensor{Entity: NewEntity(id)} +} + +func (s *BinarySensor) IsOff() bool { + return s.GetState().State == "off" +} + +func (s *BinarySensor) IsOn() bool { + return s.GetState().State == "on" +} diff --git a/entity_light.go b/entity_light.go index fd57b5f..9389e0b 100644 --- a/entity_light.go +++ b/entity_light.go @@ -1,9 +1,18 @@ package hal import ( + "errors" + "log/slog" + "github.com/dansimau/hal/hassws" ) +type LightInterface interface { + IsOn() bool + TurnOn() error + TurnOff() error +} + type Light struct { *Entity } @@ -18,9 +27,12 @@ func (l *Light) IsOn() bool { func (l *Light) TurnOn() error { if l.connection == nil { + slog.Error("Light not registered", "entity", l.GetID()) return ErrEntityNotRegistered } + slog.Debug("Turning on light", "entity", l.GetID()) + _, err := l.connection.HomeAssistant().CallService(hassws.CallServiceRequest{ Type: hassws.MessageTypeCallService, Domain: "light", @@ -29,15 +41,21 @@ func (l *Light) TurnOn() error { "entity_id": []string{l.GetID()}, }, }) + if err != nil { + slog.Error("Error turning on light", "entity", l.GetID(), "error", err) + } return err } func (l *Light) TurnOff() error { if l.connection == nil { + slog.Error("Light not registered", "entity", l.GetID()) return ErrEntityNotRegistered } + slog.Info("Turning off light", "entity", l.GetID()) + _, err := l.connection.HomeAssistant().CallService(hassws.CallServiceRequest{ Type: hassws.MessageTypeCallService, Domain: "light", @@ -46,6 +64,53 @@ func (l *Light) TurnOff() error { "entity_id": []string{l.GetID()}, }, }) + if err != nil { + slog.Error("Error turning off light", "entity", l.GetID(), "error", err) + } return err } + +type LightGroup []LightInterface + +func (lg LightGroup) IsOn() bool { + for _, l := range lg { + if !l.IsOn() { + return false + } + } + + return true +} + +func (lg LightGroup) TurnOn() error { + var errs []error + + for _, l := range lg { + if err := l.TurnOn(); err != nil { + errs = append(errs, err) + } + } + + if len(errs) > 1 { + return errors.Join(errs...) + } + + return nil +} + +func (lg LightGroup) TurnOff() error { + var errs []error + + for _, l := range lg { + if err := l.TurnOff(); err != nil { + errs = append(errs, err) + } + } + + if len(errs) > 1 { + return errors.Join(errs...) + } + + return nil +} diff --git a/entity_light_sensor.go b/entity_light_sensor.go new file mode 100644 index 0000000..e0a8289 --- /dev/null +++ b/entity_light_sensor.go @@ -0,0 +1,20 @@ +package hal + +import "strconv" + +type LightSensor struct { + *Entity +} + +func NewLightSensor(id string) *LightSensor { + return &LightSensor{Entity: NewEntity(id)} +} + +func (s *LightSensor) Level() int { + v, err := strconv.Atoi(s.GetState().State) + if err != nil { + return 0 + } + + return v +} diff --git a/hassws/client.go b/hassws/client.go index a1e2507..bbbc467 100644 --- a/hassws/client.go +++ b/hassws/client.go @@ -53,7 +53,7 @@ func (c *Client) authenticate() error { return err } - log.Println("Authenticating") + slog.Debug("Authenticating") // Send auth message with access token if err := c.send(AuthRequest{ @@ -79,7 +79,7 @@ func (c *Client) authenticate() error { return fmt.Errorf("%w: %s", ErrUnexpectedResponse, authResponse.Message) } - log.Println("Authenticated") + slog.Debug("Authenticated") return nil } @@ -93,14 +93,14 @@ func (c *Client) shutdown() error { } func (c *Client) Connect() error { - log.Println("Connecting to", c.cfg.Host) + slog.Info("Connecting", "host", c.cfg.Host) conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://%s/api/websocket", c.cfg.Host), nil) if err != nil { return err } - log.Println("Connected") + slog.Debug("Connection established") c.conn = conn @@ -116,6 +116,8 @@ func (c *Client) Connect() error { // Listen for messages from the websocket and dispatch to listener channels. func (c *Client) listen() { + slog.Info("Connection established, listening for state changes") + for { _, msgBytes, err := c.conn.ReadMessage() if err != nil { diff --git a/homeassistant/state.go b/homeassistant/state.go index 9db4776..b33b205 100644 --- a/homeassistant/state.go +++ b/homeassistant/state.go @@ -1,7 +1,6 @@ package homeassistant import ( - "encoding/json" "time" ) @@ -12,13 +11,8 @@ const ( type State struct { EntityID string `json:"entity_id"` - State string `json:"state"` - Attributes struct { - DeviceClass string `json:"device_class"` - FriendlyName string `json:"friendly_name"` - - json.RawMessage - } `json:"attributes"` + State string `json:"state"` + Attributes map[string]any `json:"attributes"` LastChanged time.Time `json:"last_changed"` LastReported time.Time `json:"last_reported"` diff --git a/timer.go b/timer.go new file mode 100644 index 0000000..467115f --- /dev/null +++ b/timer.go @@ -0,0 +1,18 @@ +package hal + +import "time" + +type Timer struct { + timer *time.Timer +} + +func (t *Timer) Cancel() { + if t.timer != nil { + t.timer.Stop() + } +} + +func (t *Timer) Start(callback func(), d time.Duration) { + t.Cancel() + t.timer = time.AfterFunc(d, callback) +} diff --git a/timer_test.go b/timer_test.go new file mode 100644 index 0000000..e33356a --- /dev/null +++ b/timer_test.go @@ -0,0 +1,28 @@ +package hal_test + +import ( + "testing" + "time" + + "github.com/dansimau/hal" +) + +func TestTimer(t *testing.T) { + t.Parallel() + + var testStruct struct { + timer hal.Timer + } + + timerRan := false + + testStruct.timer.Start(func() { + timerRan = true + }, 100*time.Millisecond) + + time.Sleep(200 * time.Millisecond) + + if !timerRan { + t.Error("Timer did not run") + } +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..c2a2261 --- /dev/null +++ b/util.go @@ -0,0 +1,15 @@ +package hal + +import ( + "reflect" + "runtime" + "strings" +) + +func getShortFunctionName(i interface{}) string { + fullName := runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name() + // Split by dots and get the last element + parts := strings.Split(fullName, ".") + + return parts[len(parts)-1] +}