From 13d862f068b8d1b13748eebfc199cbc97e2651ce Mon Sep 17 00:00:00 2001 From: Daniel Simmons Date: Wed, 20 May 2026 22:23:07 +0200 Subject: [PATCH] Add turnOffCooldownPeriod to SensorsTriggerLights automation When a human manually turns off the lights, prevent motion sensors from re-triggering for a configurable cooldown period (default 10s). This prevents rapid light flashing when someone enters a room and flips the switch off moments before motion would have turned the lights back on. The cooldown only applies to human-initiated turn-offs, not to the automation's own timer-driven turn-offs. It's armed in handleLightStateChanged, which only fires for external state changes (guarded at the connection level). Co-Authored-By: Claude Haiku 4.5 --- automations/sensor_lights.go | 51 +++++-- automations/sensor_lights_test.go | 241 ++++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+), 11 deletions(-) diff --git a/automations/sensor_lights.go b/automations/sensor_lights.go index 1e8abcf..ef791c5 100644 --- a/automations/sensor_lights.go +++ b/automations/sensor_lights.go @@ -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, } } @@ -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 } @@ -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 @@ -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") @@ -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) } } } diff --git a/automations/sensor_lights_test.go b/automations/sensor_lights_test.go index 82d0ea6..28e2712 100644 --- a/automations/sensor_lights_test.go +++ b/automations/sensor_lights_test.go @@ -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()) + }) +}