Skip to content

Commit

Permalink
fix: Dimmed lights can be re-triggered by sensor
Browse files Browse the repository at this point in the history
  • Loading branch information
dansimau committed Jan 12, 2025
1 parent 6f26310 commit 699e33b
Show file tree
Hide file tree
Showing 10 changed files with 222 additions and 27 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
cover.out
sqlite.db
53 changes: 36 additions & 17 deletions automations/sensor_lights.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,25 @@ type SensorsTriggerLights struct {
brightness float64
scene map[string]any

condition func() bool // optional: func that must return true for the automation to run
conditionScene []ConditionScene
humanOverrideFor *time.Duration // optional: duration after which lights will turn off after being turned on from outside this system
sensors []hal.EntityInterface
turnsOnLights []hal.LightInterface
turnsOffLights []hal.LightInterface
turnsOffAfter *time.Duration // optional: duration after which lights will turn off after being turned on

dimLightsTimer *time.Timer
condition func() bool // optional: func that must return true for the automation to run
conditionScene []ConditionScene
dimLightsBeforeTurnOff time.Duration
humanOverrideFor *time.Duration // optional: duration after which lights will turn off after being turned on from outside this system
sensors []hal.EntityInterface
turnsOnLights []hal.LightInterface
turnsOffLights []hal.LightInterface
turnsOffAfter *time.Duration // optional: duration after which lights will turn off after being turned on

dimLightsTimer *timerutil.Timer
humanOverrideTimer *timerutil.Timer
turnOffTimer *time.Timer
turnOffTimer *timerutil.Timer
}

func NewSensorsTriggerLights() *SensorsTriggerLights {
return &SensorsTriggerLights{
brightness: 255,
log: slog.Default(),
dimLightsBeforeTurnOff: time.Second * 10,
brightness: 255,
log: slog.Default(),
}
}

Expand Down Expand Up @@ -73,6 +75,14 @@ func (a *SensorsTriggerLights) WithConditionScene(condition func() bool, scene m
return a
}

// DimLightsBeforeTurnOff sets the duration before lights will turn off after
// being turned on.
func (a *SensorsTriggerLights) DimLightsBeforeTurnOff(duration time.Duration) *SensorsTriggerLights {
a.dimLightsBeforeTurnOff = duration

return a
}

// WithHumanOverrideFor sets a secondary timer that will kick in if the light
// was turned on from outside this system.
func (a *SensorsTriggerLights) WithHumanOverrideFor(duration time.Duration) *SensorsTriggerLights {
Expand Down Expand Up @@ -163,17 +173,22 @@ func (a *SensorsTriggerLights) startDimLightsTimer() {
return
}

// TODO: Make this configurable
dimLightsAfter := *a.turnsOffAfter - 10*time.Second
if a.dimLightsBeforeTurnOff < 0 {
return
}

dimLightsAfter := *a.turnsOffAfter - a.dimLightsBeforeTurnOff
if dimLightsAfter < 1*time.Second {
return
}

if a.dimLightsTimer == nil {
a.dimLightsTimer = time.AfterFunc(dimLightsAfter, a.dimLights)
a.dimLightsTimer = timerutil.NewTimer(dimLightsAfter, a.dimLights)
} else {
a.dimLightsTimer.Reset(dimLightsAfter)
}

a.log.Debug("Dim lights timer set for", "time", time.Now().Add(dimLightsAfter))
}

func (a *SensorsTriggerLights) startTurnOffTimer() {
Expand All @@ -182,11 +197,13 @@ func (a *SensorsTriggerLights) startTurnOffTimer() {
}

if a.turnOffTimer == nil {
a.turnOffTimer = time.AfterFunc(*a.turnsOffAfter, a.turnOffLights)
a.turnOffTimer = timerutil.NewTimer(*a.turnsOffAfter, a.turnOffLights)
} else {
a.turnOffTimer.Reset(*a.turnsOffAfter)
}

a.log.Debug("Turn off timer set for", "time", time.Now().Add(*a.turnsOffAfter))

a.startDimLightsTimer()
}

Expand Down Expand Up @@ -294,12 +311,14 @@ func (a *SensorsTriggerLights) handleSensorTriggered() {
}

if a.triggered() {
lightsWereDimmed := a.dimLightsTimer != nil && a.turnOffTimer.IsRunning()

a.stopTurnOffTimer()
a.stopDimLightsTimer()

// This avoids a situation where the user has changed the lights state
// but it gets overridden by a sensor being triggered again.
if a.lightsOn() {
if a.lightsOn() && !lightsWereDimmed {
a.log.Info("Sensor triggered, but lights are already on, ignoring")

return
Expand Down
101 changes: 101 additions & 0 deletions automations/sensor_lights_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package halautomations_test

import (
"log/slog"
"testing"
"time"

"github.com/dansimau/hal"
halautomations "github.com/dansimau/hal/automations"
"github.com/dansimau/hal/homeassistant"
"github.com/dansimau/hal/testutil"
"github.com/davecgh/go-spew/spew"
)

func TestSensorLightsTurnOnAfterDimming(t *testing.T) {
t.Parallel()

conn, server, cleanup := testutil.NewClientServer(t)
defer cleanup()

// Create test light
testLight := hal.NewLight("test.light")
conn.RegisterEntities(testLight)

// Create test sensor
testSensor := hal.NewBinarySensor("test.sensor")
conn.RegisterEntities(testSensor)

// Create automation
automation := halautomations.NewSensorsTriggerLights().
WithName("test automation").
WithSensors(testSensor).
WithLights(testLight).
WithBrightness(100).
TurnsOffAfter(time.Second * 3).
DimLightsBeforeTurnOff(time.Second)

conn.RegisterAutomations(automation)

// Trigger motion sensor
slog.Info("Test: Triggering motion sensor")
server.SendStateChangeEvent(homeassistant.Event{
EventData: homeassistant.EventData{
EntityID: testSensor.GetID(),
NewState: &homeassistant.State{
EntityID: testSensor.GetID(),
State: "on",
},
},
})

slog.Info("Test: Asserting light was turned on")
testutil.WaitFor(t, "verify light was turned on", func() bool {
return testLight.GetState().State == "on"
}, func() {
spew.Dump(testLight.GetID(), testLight.GetState())
})

// Clear motion sensor
slog.Info("Test: Clearing motion sensor")
server.SendStateChangeEvent(homeassistant.Event{
EventData: homeassistant.EventData{
EntityID: testSensor.GetID(),
NewState: &homeassistant.State{
EntityID: testSensor.GetID(),
State: "off",
},
},
})

// TODO: Replace this with mocked time
slog.Info("Test: Sleeping")
time.Sleep(time.Second)

slog.Info("Test: Asserting light was dimmed")
testutil.WaitFor(t, "verify light was dimmed", func() bool {
return testLight.GetState().Attributes["brightness"] == 50.0
}, func() {
spew.Dump(testLight.GetID(), testLight.GetState(), testLight.GetState().Attributes["brightness"])
})

// Trigger motion sensor again
slog.Info("Test: Triggering motion sensor again")
server.SendStateChangeEvent(homeassistant.Event{
EventData: homeassistant.EventData{
EntityID: testSensor.GetID(),
NewState: &homeassistant.State{
EntityID: testSensor.GetID(),
State: "on",
},
},
})

// Verify light is bright again
slog.Info("Test: Asserting light is bright again")
testutil.WaitFor(t, "verify light is bright again", func() bool {
return testLight.GetState().Attributes["brightness"] == 100.0
}, func() {
spew.Dump(testLight.GetID(), testLight.GetState())
})
}
6 changes: 4 additions & 2 deletions connection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/dansimau/hal"
"github.com/dansimau/hal/homeassistant"
"github.com/dansimau/hal/testutil"
"github.com/davecgh/go-spew/spew"
)

func TestConnection(t *testing.T) {
Expand All @@ -26,8 +27,9 @@ func TestConnection(t *testing.T) {
},
})

// Verify entity state was updated
testutil.WaitFor(t, func() bool {
testutil.WaitFor(t, "verify entity state was updated", func() bool {
return entity.GetState().State == "on"
}, func() {
spew.Dump(entity.GetID(), entity.GetState())
})
}
30 changes: 25 additions & 5 deletions entity_light.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,18 @@ func (l *Light) TurnOn(attributes ...map[string]any) error {
})
if err != nil {
slog.Error("Error turning on light", "entity", l.GetID(), "error", err)

return err
}

return err
state := l.GetState()
state.Update(homeassistant.State{
State: "on",
Attributes: data,
})
l.SetState(state)

return nil
}

func (l *Light) TurnOff() error {
Expand All @@ -79,19 +88,30 @@ func (l *Light) TurnOff() error {

slog.Info("Turning off light", "entity", l.GetID())

data := map[string]any{
"entity_id": []string{l.GetID()},
}

_, err := l.connection.HomeAssistant().CallService(hassws.CallServiceRequest{
Type: hassws.MessageTypeCallService,
Domain: "light",
Service: "turn_off",
Data: map[string]any{
"entity_id": []string{l.GetID()},
},
Data: data,
})
if err != nil {
slog.Error("Error turning off light", "entity", l.GetID(), "error", err)

return err
}

return err
state := l.GetState()
state.Update(homeassistant.State{
State: "off",
Attributes: data,
})
l.SetState(state)

return nil
}

type LightGroup []LightInterface
Expand Down
2 changes: 2 additions & 0 deletions hassws/message_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type CommandMessage struct {
}

type CommandResponse struct {
ID int `json:"id"`
Type MessageType `json:"type"`
Success bool `json:"success"`
Result json.RawMessage `json:"result,omitempty"`
Expand Down Expand Up @@ -84,6 +85,7 @@ type CallServiceRequest struct {
}

type CallServiceResponse struct {
ID int `json:"id"`
Type MessageType `json:"type"`
Success bool `json:"success"`
Result struct {
Expand Down
18 changes: 18 additions & 0 deletions hassws/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ func (s *Server) listen() {
}

switch cmd.Type {
case MessageTypeCallService:
s.SendMessage(CallServiceResponse{
ID: cmd.ID,
Type: MessageTypeResult,
Success: true,
})

case MessageTypeSubscribeEvents:
s.lock.Lock()
s.subscribers = append(s.subscribers, cmd.ID)
Expand All @@ -118,6 +125,17 @@ func (s *Server) listen() {
Type: MessageTypeResult,
Success: true,
})

case MessageTypeGetStates:
s.SendMessage(CommandResponse{
ID: cmd.ID,
Type: MessageTypeResult,
Success: true,
// TODO: Either keep state on the server site, or allow testers
// to set it. For now we just leave it empty so tests don't
// crash.
Result: json.RawMessage("[]"),
})
default:
panic("[Server] Unknown message type: " + cmd.Type)
}
Expand Down
26 changes: 26 additions & 0 deletions homeassistant/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,29 @@ type State struct {
LastReported time.Time `json:"last_reported"`
LastUpdated time.Time `json:"last_updated"`
}

func (s *State) Update(newState State) {
if newState.State != "" {
s.State = newState.State
}

for k, v := range newState.Attributes {
if s.Attributes == nil {
s.Attributes = make(map[string]any)
}

s.Attributes[k] = v
}

if newState.LastChanged != (time.Time{}) {
s.LastChanged = newState.LastChanged
}

if newState.LastReported != (time.Time{}) {
s.LastReported = newState.LastReported
}

if newState.LastUpdated != (time.Time{}) {
s.LastUpdated = newState.LastUpdated
}
}
5 changes: 5 additions & 0 deletions testutil/testutil.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package testutil

import (
"log/slog"
"testing"

"github.com/dansimau/hal"
"github.com/dansimau/hal/hassws"
"gotest.tools/v3/assert"
)

func init() {
slog.SetLogLoggerLevel(slog.LevelDebug)
}

func NewClientServer(t *testing.T) (*hal.Connection, *hassws.Server, func()) {
t.Helper()

Expand Down
Loading

0 comments on commit 699e33b

Please sign in to comment.