Motion-Activated Lights
A motion sensor in the hallway fires every time someone walks past. The light should turn on immediately and stay on until motion has been clear for a set period. If someone walks past again while the timer is running, the timeout should restart instead of firing at the original time.
The Code
from hassette import App, AppConfig, D, states
from hassette.scheduler import ScheduledJob
OFF_JOB_NAME = "motion_lights_off"
class MotionLightsConfig(AppConfig):
motion_sensor: str = "binary_sensor.hallway_motion"
light: str = "light.hallway"
off_delay_seconds: float = 300
class MotionLights(App[MotionLightsConfig]):
off_job: ScheduledJob | None
async def on_initialize(self) -> None:
self.off_job = 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]):
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_seconds,
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")
Run It
Save the code as motion_lights.py in your apps directory and register it in hassette.toml:
[hassette.apps.motion_lights]
filename = "motion_lights.py"
class_name = "MotionLights"
The section name (motion_lights) is the app key the hassette CLI commands below take via --app. App Configuration covers registration in full.
How It Works
self.bus.on_state_change subscribes to every state transition on the motion sensor. The name= parameter is required on all bus registrations — it identifies the listener in the database and in CLI output.
D.StateNew[states.BinarySensorState] is a dependency injection annotation — Hassette inspects the handler's parameter types at registration and passes the extracted value in automatically. D.StateNew delivers the new state, already converted to a BinarySensorState object. BinarySensorState.value is bool | None: True when the sensor is on (motion detected), False when off (motion cleared), None when the state is unknown or unavailable — not the raw HA strings "on" and "off". The handler covers both transitions in one place rather than two separate subscriptions.
When motion turns on, any pending off job is cancelled before the light turns on. This resets the timer. If motion fires again while the delay is running, the timeout starts over instead of firing at the original time.
When motion clears, self.scheduler.run_in schedules turn_off_light for off_delay_seconds seconds later. The returned ScheduledJob handle exposes a .cancel() method — storing it on self.off_job lets the on-handler cancel the pending job on re-trigger.
OFF_JOB_NAME gives the scheduled job a stable name for log readability.
motion_sensor, light, and off_delay_seconds all come from config via hassette.toml. Nothing in the app is hardcoded to a specific room.
Verify It's Working
Trigger your motion sensor (or toggle it manually in Home Assistant) and check the handler fired:
hassette log --app motion_lights --since 5m
Look for on_motion entries showing the state transition:
INFO [motion_lights] on_motion triggered — new state: True
To verify the off timer, wait for the delay to elapse and confirm the light turns off:
hassette listener --app motion_lights
The motion_sensor listener should show an increasing invocation count each time motion fires.
Variations
Shorter or longer timeout. Change off_delay_seconds in hassette.toml without touching the code:
[hassette.apps.hallway_motion]
filename = "motion_lights.py"
class_name = "MotionLights"
[hassette.apps.hallway_motion.config]
off_delay_seconds = 60
Split handlers with changed_to. changed_to is a filter on on_state_change — the handler fires only when the state becomes the specified value. When using changed_to, the handler does not need a state parameter since the filter already ensures which transition occurred:
await self.bus.on_state_change(
self.app_config.motion_sensor,
handler=self.on_motion_detected,
changed_to="on",
name="motion_on",
)
await self.bus.on_state_change(
self.app_config.motion_sensor,
handler=self.on_motion_cleared,
changed_to="off",
name="motion_off",
)
The trade-off: two subscriptions are easier to read individually, but the single-handler version keeps the on/off logic in one place where the relationship between them is visible.
See Also
Bus: event subscriptions and rate controlScheduler:run_inand job management- Application Configuration: per-instance config in
hassette.toml