Last updated Jul 1, 2026
Home Presence Simulation#
When the house is empty for an extended period, lights that never turn on are one of the more obvious tells from the street. Home Assistant's built-in "Presence Simulation" integration replays your actual light history, which is a good default but ties the simulation to whatever you happened to do on previous days. I wanted a version that runs on its own schedule, defined once, with enough randomness that it doesn't look mechanical.
Why PyScript instead of an automation#
The stock automation editor can handle a handful of time-triggered actions, but a full evening schedule with per-step randomization and a single on/off toggle turns into a large number of automations that are awkward to review as a group. PyScript lets the whole schedule live in one file as a Python script, still running inside Home Assistant with full access to light.turn_on, scene.turn_on, and the rest of the service calls.
This uses PyScript's custom integration (pyscript), not the sandboxed python_script integration that ships with core Home Assistant. python_script blocks import statements entirely, which rules out random. PyScript needs one extra line in configuration.yaml:
pyscript:
allow_all_imports: trueStructure of the script#
A dict of controllable lights. Only lights listed here are within reach of the script, including its all_off step. Entity IDs and room names below are placeholders, not what's actually in my config.
LIGHTS = {
"living_room": "light.living_room",
"bedroom_1": "light.bedroom_1",
"bedroom_2": "light.bedroom_2",
"toilet": "light.toilet",
"office": "light.office",
"bathroom": "light.bathroom",
"bedroom_3": "light.bedroom_3",
"bedroom_4": "light.bedroom_4",
"kitchen": "light.kitchen",
"bedroom_1b": "light.bedroom_1_accent",
}A dict of scenes. Scenes in Home Assistant are one-shot snapshots: activating one applies a fixed state to a set of lights, but the scene itself has no on/off state and can't be "turned off" later. Only the lights it touched can be turned off.
SCENES = {
"relax": "scene.chill",
"work": "scene.work",
}A guard toggle. An input_boolean helper gates whether the scheduler does anything on a given night, created through Settings → Helpers → Add → Toggle. An empty guard string is treated as "always run" so the script doesn't fail if the helper is ever removed.
GUARD = "input_boolean.presence_simulation"An ordered day plan. A list of steps, each a dict with a time, an action, a target, extra service-call data, and a randomization offset. Steps have to be written in chronological order; the scheduler walks the list top to bottom and sleeps until each step's time, it does not sort them itself.
PLAN = [
# --- Morning: someone wakes up ---
{"time": "07:15", "action": "light_on", "target": "living_room", "offset": 10},
{"time": "07:50", "action": "light_on", "target": "bathroom", "offset": 5},
{"time": "07:58", "action": "light_off", "target": "bathroom", "offset": 3},
{"time": "08:10", "action": "light_off", "target": "living_room", "offset": 5},
{"time": "08:30", "action": "light_on", "target": "office", "offset": 10},
{"time": "08:50", "action": "scene_on", "target": "work", "offset": 10},
# --- Evening: family home, cooking, activity ---
{"time": "18:30", "action": "light_on", "target": "living_room", "offset": 15},
{"time": "18:35", "action": "light_on", "target": "kitchen", "offset": 10},
{"time": "19:10", "action": "light_off", "target": "kitchen", "offset": 10},
{"time": "19:15", "action": "scene_on", "target": "relax", "offset": 12},
{"time": "20:00", "action": "light_on", "target": "toilet", "offset": 5},
{"time": "20:08", "action": "light_off", "target": "toilet", "offset": 3},
{"time": "21:00", "action": "light_on", "target": "bedroom_2", "offset": 15},
{"time": "21:15", "action": "light_on", "target": "bedroom_3", "offset": 12},
{"time": "21:45", "action": "light_on", "target": "bedroom_4", "offset": 10},
{"time": "22:00", "action": "light_on", "target": "toilet", "offset": 5},
{"time": "22:07", "action": "light_off", "target": "toilet", "offset": 3},
{"time": "22:15", "action": "light_off", "target": "bedroom_2", "offset": 8},
# --- Winding down ---
{"time": "22:30", "action": "scene_on", "target": "relax", "offset": 5},
{"time": "22:50", "action": "light_off", "target": "living_room", "offset": 5},
{"time": "22:55", "action": "light_off", "target": "office", "offset": 5},
{"time": "23:00", "action": "light_off", "target": "bedroom_3", "offset": 8},
{"time": "23:10", "action": "light_off", "target": "bedroom_4", "offset": 8},
# --- Lights out ---
{"time": "23:30", "action": "all_off", "target": None, "offset": 10},
{"time": "23:45", "action": "light_off", "target": "bedroom_1b", "offset": 5},
]action is one of scene_on, light_on, light_off, or all_off. offset is a maximum number of minutes the step's time can shift in either direction, picked with random.randint, so the same plan doesn't fire at identical timestamps two nights in a row. Brightness and color temperature are deliberately left out of every step; letting lights and scenes use their own defaults was simpler to reason about than tuning per-step values that would rarely get looked at again.
Handling lights that only exist inside scenes#
Some scenes touch lights that were never added to LIGHTS, since they were only ever meant to be set, not turned off individually. scene.chill and scene.work, for example, also affect a light that lives outside LIGHTS. Because all_off only iterates over LIGHTS.values(), it can't reach that light.
Rather than making all_off scene-aware, the fix is a convention: any light that needs a guaranteed-off at the end of the night gets added to LIGHTS and gets its own explicit light_off step near the end of the plan, in addition to the general all_off step. That's bedroom_1b in the plan above: it exists specifically because that light is scene-controlled but still needs a hard off at 23:45. It's a manual rule to keep in mind when adding a new scene, not something the script enforces automatically.
The scheduler#
Two stacked @time_trigger decorators mean the function runs once when Home Assistant starts and again every midnight, so a restart mid-evening doesn't leave the house without a schedule for the rest of the night. task.unique(..., kill_me=True) stops a new run from overlapping with one still in progress, which matters if a config reload or a second midnight trigger fires before the previous run has finished sleeping through its steps.
@time_trigger("startup")
@time_trigger("cron(0 0 * * *)")
def presence_simulation():
task.unique("presence_sim_task", kill_me=True)
if not _guard_active():
log.info("[presence_sim] guard is off, skipping today")
return
now = datetime.now()
log.info(f"[presence_sim] starting day plan ({len(PLAN)} steps)")
for step in PLAN:
run_at = _resolve_run_time(step, now)
if run_at <= datetime.now():
log.debug(f"[presence_sim] skipping past step: {step['time']}")
continue
wait_sec = (run_at - datetime.now()).total_seconds()
task.sleep(wait_sec)
if not _guard_active():
log.info("[presence_sim] guard turned off, stopping")
return
_execute(step)
log.info("[presence_sim] day plan complete")Full script#
import random
from datetime import datetime, timedelta
# ---------------------------------------------------------------------------
# Lights (add or remove entries to match what you want the sim to control)
# ---------------------------------------------------------------------------
LIGHTS = {
"living_room": "light.living_room",
"bedroom_1": "light.bedroom_1",
"bedroom_2": "light.bedroom_2",
"toilet": "light.toilet",
"office": "light.office",
"bathroom": "light.bathroom",
"bedroom_3": "light.bedroom_3",
"bedroom_4": "light.bedroom_4",
"kitchen": "light.kitchen",
"bedroom_1b": "light.bedroom_1_accent",
}
# ---------------------------------------------------------------------------
# Scenes already created in HA
# ---------------------------------------------------------------------------
SCENES = {
"relax": "scene.chill",
"work": "scene.work",
}
# ---------------------------------------------------------------------------
# Guard: set to an input_boolean entity_id to gate the simulation.
# Leave empty ("") to always run.
# Create one in HA: Settings -> Helpers -> Add -> Toggle, then paste the id here.
# ---------------------------------------------------------------------------
GUARD = "input_boolean.presence_simulation"
# ---------------------------------------------------------------------------
# Day plan - steps MUST be in chronological order.
#
# Each entry:
# time - "HH:MM" (24h local time)
# action - "scene_on" | "light_on" | "light_off" | "all_off"
# target - key from LIGHTS or SCENES above (None for all_off)
# data - extra kwargs for the service call:
# light_on -> brightness_pct (0-100), color_temp_kelvin, transition (sec)
# scene_on -> transition (sec)
# light_off -> transition (sec)
# omit or leave {} to use defaults
# offset - max random shift in minutes (+-). offset: 10 means the step
# fires anywhere between -10 min and +10 min from the listed time.
# Use this so the house never looks robotic.
# ---------------------------------------------------------------------------
PLAN = [
# --- Morning: someone wakes up ---
{"time": "07:15", "action": "light_on", "target": "living_room", "offset": 10},
{"time": "07:50", "action": "light_on", "target": "bathroom", "offset": 5},
{"time": "07:58", "action": "light_off", "target": "bathroom", "offset": 3},
{"time": "08:10", "action": "light_off", "target": "living_room", "offset": 5},
{"time": "08:30", "action": "light_on", "target": "office", "offset": 10},
{"time": "08:50", "action": "scene_on", "target": "work", "offset": 10},
# --- Daytime: house is quiet ---
# --- Evening: family home, cooking, activity ---
{"time": "18:30", "action": "light_on", "target": "living_room", "offset": 15},
{"time": "18:35", "action": "light_on", "target": "kitchen", "offset": 10},
{"time": "19:10", "action": "light_off", "target": "kitchen", "offset": 10},
{"time": "19:15", "action": "scene_on", "target": "relax", "offset": 12},
{"time": "20:00", "action": "light_on", "target": "toilet", "offset": 5},
{"time": "20:08", "action": "light_off", "target": "toilet", "offset": 3},
{"time": "21:00", "action": "light_on", "target": "bedroom_2", "offset": 15},
{"time": "21:15", "action": "light_on", "target": "bedroom_3", "offset": 12},
{"time": "21:45", "action": "light_on", "target": "bedroom_4", "offset": 10},
{"time": "22:00", "action": "light_on", "target": "toilet", "offset": 5},
{"time": "22:07", "action": "light_off", "target": "toilet", "offset": 3},
{"time": "22:15", "action": "light_off", "target": "bedroom_2", "offset": 8},
# --- Winding down ---
{"time": "22:30", "action": "scene_on", "target": "relax", "offset": 5},
{"time": "22:50", "action": "light_off", "target": "living_room", "offset": 5},
{"time": "22:55", "action": "light_off", "target": "office", "offset": 5},
{"time": "23:00", "action": "light_off", "target": "bedroom_3", "offset": 8},
{"time": "23:10", "action": "light_off", "target": "bedroom_4", "offset": 8},
# --- Lights out ---
{"time": "23:30", "action": "all_off", "target": None, "offset": 10},
{"time": "23:45", "action": "light_off", "target": "bedroom_1b", "offset": 5},
]
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _resolve_run_time(step, base_date):
h, m = map(int, step["time"].split(":"))
max_shift = step.get("offset", 0) * 60
shift = random.randint(-max_shift, max_shift) if max_shift else 0
return base_date.replace(hour=h, minute=m, second=0, microsecond=0) + timedelta(seconds=shift)
def _guard_active():
if not GUARD:
return True
return state.get(GUARD) == "on"
def _execute(step):
action = step["action"]
target = step.get("target")
data = step.get("data", {})
if action == "scene_on":
entity_id = SCENES[target]
scene.turn_on(entity_id=entity_id, **data)
log.info(f"[presence_sim] scene on: {entity_id}")
elif action == "light_on":
entity_id = LIGHTS[target]
light.turn_on(entity_id=entity_id, **data)
log.info(f"[presence_sim] light on: {entity_id}")
elif action == "light_off":
entity_id = LIGHTS[target]
light.turn_off(entity_id=entity_id)
log.info(f"[presence_sim] light off: {entity_id}")
elif action == "all_off":
for entity_id in LIGHTS.values():
light.turn_off(entity_id=entity_id)
log.info("[presence_sim] all lights off")
# ---------------------------------------------------------------------------
# Main scheduler - runs at startup and restarts every midnight
# ---------------------------------------------------------------------------
@time_trigger("startup")
@time_trigger("cron(0 0 * * *)")
def presence_simulation():
task.unique("presence_sim_task", kill_me=True)
if not _guard_active():
log.info("[presence_sim] guard is off, skipping today")
return
now = datetime.now()
log.info(f"[presence_sim] starting day plan ({len(PLAN)} steps)")
for step in PLAN:
run_at = _resolve_run_time(step, now)
if run_at <= datetime.now():
log.debug(f"[presence_sim] skipping past step: {step['time']}")
continue
wait_sec = (run_at - datetime.now()).total_seconds()
task.sleep(wait_sec)
if not _guard_active():
log.info("[presence_sim] guard turned off, stopping")
return
_execute(step)
log.info("[presence_sim] day plan complete")