Skip to content

Motion-Activated Lights

Turns a light on when a motion sensor detects movement, then turns it off automatically after a configurable delay once motion clears.

The Code

from hassette import App, AppConfig, D, states
from hassette.scheduler import ScheduledJob

MOTION_SENSOR = "binary_sensor.hallway_motion"
LIGHT = "light.hallway"
OFF_DELAY = 300  # seconds (5 minutes)
OFF_JOB_NAME = "motion_lights_off"


class MotionLightsConfig(AppConfig):
    motion_sensor: str = MOTION_SENSOR
    light: str = LIGHT
    off_delay: float = OFF_DELAY


class MotionLights(App[MotionLightsConfig]):
    _off_job: ScheduledJob | None = None

    async def on_initialize(self) -> None:
        await self.bus.on_state_change(
            self.app_config.motion_sensor,
            handler=self.on_motion,
            name="motion_sensor",
        )

    async def on_motion(self, new_state: D.StateNew[states.BinarySensorState]) -> None:
        if new_state.value is True:
            # Motion detected — cancel any pending off job and turn the light on.
            if self._off_job is not None:
                self._off_job.cancel()
                self._off_job = None
            await self.api.turn_on(self.app_config.light, domain="light")

        elif new_state.value is False:
            # Motion cleared — schedule the light to turn off after the delay.
            self._off_job = await self.scheduler.run_in(
                self.turn_off_light,
                delay=self.app_config.off_delay,
                name=OFF_JOB_NAME,
            )

    async def turn_off_light(self) -> None:
        self._off_job = None
        await self.api.turn_off(self.app_config.light, domain="light")

How It Works

  • on_state_change subscribes to every state transition on the motion sensor. The handler uses dependency injection (D.StateNew[states.BinarySensorState]) to receive the new state as a typed object — both "on" and "off" are handled in one place.
  • When state is "on", any pending off job is cancelled before turning the light on — this resets the timeout if motion is detected again while the timer is running.
  • When state is "off", run_in schedules turn_off_light to fire 5 minutes later. The job is stored on self._off_job so it can be cancelled on re-trigger.
  • Named job (OFF_JOB_NAME) keeps logs readable. Only one off job per app instance can exist with a given name — if you need multiple sensors driving the same light, give each instance a different name via config.
  • Config fields (motion_sensor, light, off_delay) let you run the same app class for multiple rooms with different values in hassette.toml.

Variations

Shorter or longer timeout — Change off_delay in hassette.toml without touching the code:

[apps.hallway_motion]
module = "motion_lights"
class = "MotionLights"
off_delay = 60  # 1 minute

Multiple sensors, one light — Deploy the app twice under different names, each pointing to its own sensor. The shared light entity is fine; whichever sensor detects motion last wins.

See Also