Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 40 additions & 11 deletions automations/sensor_lights.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,18 @@ type SensorsTriggerLights struct {
turnsOnLights []hal.LightInterface
turnsOffLights []hal.LightInterface
turnsOffAfter *time.Duration // optional: duration after which lights will turn off after being turned on
turnOffCooldownPeriod time.Duration

dimLightsTimer hal.Timer
humanOverrideTimer hal.Timer
turnOffTimer hal.Timer
dimLightsTimer hal.Timer
humanOverrideTimer hal.Timer
turnOffTimer hal.Timer
turnOffCooldownTimer hal.Timer
}

func NewSensorsTriggerLights() *SensorsTriggerLights {
return &SensorsTriggerLights{
dimLightsBeforeTurnOff: time.Second * 10,
turnOffCooldownPeriod: time.Second * 10,
brightness: 255,
}
}
Expand All @@ -62,6 +65,7 @@ func (a *SensorsTriggerLights) WithClock(c clock.Clock) *SensorsTriggerLights {
a.dimLightsTimer = *hal.NewTimer(c)
a.humanOverrideTimer = *hal.NewTimer(c)
a.turnOffTimer = *hal.NewTimer(c)
a.turnOffCooldownTimer = *hal.NewTimer(c)

return a
}
Expand Down Expand Up @@ -148,6 +152,16 @@ func (a *SensorsTriggerLights) TurnsOffAfter(turnsOffAfter time.Duration) *Senso
return a
}

// TurnOffCooldownPeriod sets a duration after the lights turn off during which
// sensor state changes are ignored. Prevents the lights from flashing back on
// immediately if motion is still present when the turn-off timer fires. Set to
// 0 to disable.
func (a *SensorsTriggerLights) TurnOffCooldownPeriod(duration time.Duration) *SensorsTriggerLights {
a.turnOffCooldownPeriod = duration

return a
}

func (a *SensorsTriggerLights) SetScene(scene map[string]any) *SensorsTriggerLights {
a.scene = scene

Expand Down Expand Up @@ -311,6 +325,12 @@ func (a *SensorsTriggerLights) handleSensorStateChange(ctx context.Context) {
return
}

if a.turnOffCooldownTimer.IsRunning() {
logger.InfoContext(ctx, "Turn off cooldown active, skipping")

return
}

if a.condition != nil && !a.condition() {
logger.InfoContext(ctx, "Condition not met, skipping")

Expand Down Expand Up @@ -344,19 +364,28 @@ func (a *SensorsTriggerLights) handleSensorStateChange(ctx context.Context) {
func (a *SensorsTriggerLights) handleLightStateChanged(ctx context.Context) {
logger.InfoContext(ctx, "Light state change")

// Light was either turned on or off, or brightness changed or whatever,
// in which case we want to stop any further automations since the user has
// overridden it and we want to respect that.
// Connection-level dispatch (connection.go) drops state_changed events
// whose Context.UserID matches hal's configured user, so reaching this
// handler implies an external party (human or other integration) changed
// the light. Stop any pending timers so we respect their intent.
a.stopDimLightsTimer(ctx)
a.stopTurnOffTimer(ctx)

if a.humanOverrideFor != nil {
if a.lightsOn() {
if a.lightsOn() {
a.turnOffCooldownTimer.Cancel()

if a.humanOverrideFor != nil {
logger.InfoContext(ctx, "Light turned on, setting human override", "duration", a.humanOverrideFor.String())
a.humanOverrideTimer.Start(nil, *a.humanOverrideFor)
} else {
logger.InfoContext(ctx, "Light turned off, cancelling human override")
a.humanOverrideTimer.Cancel()
}
} else {
a.humanOverrideTimer.Cancel()

if a.turnOffCooldownPeriod > 0 {
logger.InfoContext(ctx, "Light turned off, starting turn off cooldown", "duration", a.turnOffCooldownPeriod.String())
a.turnOffCooldownTimer.StartContext(ctx, func(ctx context.Context) {
logger.InfoContext(ctx, "Turn off cooldown elapsed")
}, a.turnOffCooldownPeriod)
}
}
}
Expand Down
241 changes: 241 additions & 0 deletions automations/sensor_lights_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -513,3 +513,244 @@ func TestHumanOverride(t *testing.T) {
spew.Dump(testLight.GetID(), testLight.GetState())
})
}

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

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

mockClock := clock.NewMock()

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

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

// Default cooldown of 10s applies.
automation := halautomations.NewSensorsTriggerLights().
WithName("test automation").
WithClock(mockClock).
WithSensors(testSensor).
WithLights(testLight).
TurnsOffAfter(time.Hour)

conn.RegisterAutomations(automation)

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

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

// Simulate a human turning the light off (no Context.UserID set, so the
// connection treats it as external and runs handleLightStateChanged).
slog.Info("Test: Human turns light off")
server.SendEvent(homeassistant.Event{
EventType: "state_changed",
EventData: homeassistant.EventData{
EntityID: testLight.GetID(),
NewState: &homeassistant.State{
EntityID: testLight.GetID(),
State: "off",
},
},
})

testutil.WaitFor(t, "verify light is off", func() bool {
return testLight.GetState().State == "off"
}, func() {
spew.Dump(testLight.GetID(), testLight.GetState())
})

// Clear and re-trigger sensor during cooldown — light must stay off.
slog.Info("Test: Clearing motion sensor")
server.SendEvent(homeassistant.Event{
EventType: "state_changed",
EventData: homeassistant.EventData{
EntityID: testSensor.GetID(),
NewState: &homeassistant.State{
EntityID: testSensor.GetID(),
State: "off",
},
},
})

testutil.WaitFor(t, "motion sensor is cleared", func() bool {
return testSensor.GetState().State == "off"
}, func() {
spew.Dump(testSensor.GetID(), testSensor.GetState())
})

slog.Info("Test: Re-triggering motion sensor during cooldown")
server.SendEvent(homeassistant.Event{
EventType: "state_changed",
EventData: homeassistant.EventData{
EntityID: testSensor.GetID(),
NewState: &homeassistant.State{
EntityID: testSensor.GetID(),
State: "on",
},
},
})

testutil.WaitFor(t, "motion sensor is on", func() bool {
return testSensor.GetState().State == "on"
}, func() {
spew.Dump(testSensor.GetID(), testSensor.GetState())
})

slog.Info("Test: Asserting light stays off during cooldown")
testutil.WaitFor(t, "verify light is still off during cooldown", func() bool {
return testLight.GetState().State == "off"
}, func() {
spew.Dump(testLight.GetID(), testLight.GetState())
})

// Clear sensor, advance past cooldown, re-trigger — light should turn on.
server.SendEvent(homeassistant.Event{
EventType: "state_changed",
EventData: homeassistant.EventData{
EntityID: testSensor.GetID(),
NewState: &homeassistant.State{
EntityID: testSensor.GetID(),
State: "off",
},
},
})

testutil.WaitFor(t, "motion sensor is cleared again", func() bool {
return testSensor.GetState().State == "off"
}, func() {
spew.Dump(testSensor.GetID(), testSensor.GetState())
})

slog.Info("Test: Advancing past cooldown")
mockClock.Add(11 * time.Second)

server.SendEvent(homeassistant.Event{
EventType: "state_changed",
EventData: homeassistant.EventData{
EntityID: testSensor.GetID(),
NewState: &homeassistant.State{
EntityID: testSensor.GetID(),
State: "on",
},
},
})

testutil.WaitFor(t, "verify light is on after cooldown elapsed", func() bool {
return testLight.GetState().State == "on"
}, func() {
spew.Dump(testLight.GetID(), testLight.GetState())
})
}

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

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

mockClock := clock.NewMock()

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

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

automation := halautomations.NewSensorsTriggerLights().
WithName("test automation").
WithClock(mockClock).
WithSensors(testSensor).
WithLights(testLight).
TurnsOffAfter(time.Hour).
TurnOffCooldownPeriod(0)

conn.RegisterAutomations(automation)

server.SendEvent(homeassistant.Event{
EventType: "state_changed",
EventData: homeassistant.EventData{
EntityID: testSensor.GetID(),
NewState: &homeassistant.State{
EntityID: testSensor.GetID(),
State: "on",
},
},
})

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

// Human turns light off.
server.SendEvent(homeassistant.Event{
EventType: "state_changed",
EventData: homeassistant.EventData{
EntityID: testLight.GetID(),
NewState: &homeassistant.State{
EntityID: testLight.GetID(),
State: "off",
},
},
})

testutil.WaitFor(t, "verify light is off", func() bool {
return testLight.GetState().State == "off"
}, func() {
spew.Dump(testLight.GetID(), testLight.GetState())
})

// Clear and re-trigger sensor — with cooldown disabled, the light should
// turn back on immediately.
server.SendEvent(homeassistant.Event{
EventType: "state_changed",
EventData: homeassistant.EventData{
EntityID: testSensor.GetID(),
NewState: &homeassistant.State{
EntityID: testSensor.GetID(),
State: "off",
},
},
})

testutil.WaitFor(t, "motion sensor is cleared", func() bool {
return testSensor.GetState().State == "off"
}, func() {
spew.Dump(testSensor.GetID(), testSensor.GetState())
})

server.SendEvent(homeassistant.Event{
EventType: "state_changed",
EventData: homeassistant.EventData{
EntityID: testSensor.GetID(),
NewState: &homeassistant.State{
EntityID: testSensor.GetID(),
State: "on",
},
},
})

testutil.WaitFor(t, "verify light is on again with no cooldown", func() bool {
return testLight.GetState().State == "on"
}, func() {
spew.Dump(testLight.GetID(), testLight.GetState())
})
}
Loading