Leo
Leo
Docs/home server/home assistant/presence simulation

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: true

Structure 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")