A Powerful Auto-Off Timer for Home Assistant
Time Off is a lightweight, local-push integration that solves the "I forgot to turn it off" problem once and for all. It adds a set of timer controls and sensors directly into the control panel of your selected devices, so you can manage countdowns without writing a single line of YAML.
In standard Home Assistant setups, creating a simple auto-off timer usually requires:
- Creating a Timer Helper
- Writing an Automation to start the timer when the device turns on
- Writing a Second Automation to turn the device off when the timer finishes
This leads to a cluttered Helpers list and disconnected logic - a timer named timer.fan_1 living in a separate menu from the fan itself, with no obvious link between them.
Time Off changes the workflow:
- Tethered Logic: The timer, controls, and sensors all live inside the device itself.
- No More Ghost Helpers: Everything is created and named automatically based on the device it's controlling.
- One-Step Setup: No YAML or multi-step automations required. Just add the integration, pick your device, and set a duration.
Time Off is device-aware. It automatically detects the type of device you are controlling and sends the correct command when the timer expires:
| Device Type | Action on Expiry |
|---|---|
| Lights / Fans / Switches / Media Players / Climate / Humidifiers / Sirens / Input Booleans | homeassistant.turn_off |
| Covers (Gates / Blinds) | cover.close_cover |
| Valves | valve.close_valve |
| Vacuums | vacuum.return_to_base |
Template entities and Helpers are also fully supported, including helper groups that appear as a light, switch, fan, or other supported domain.
- Download the
time_offzip archive from this repository and unpack it. - Copy the
time_offfolder into yourcustom_components/directory. - Restart Home Assistant.
- Go to Settings -> Devices & Services -> Add Integration and search for Time Off.
- Open HACS.
- Click the three dots (top right) -> Custom repositories.
- Paste
https://github.com/sfox38/time_offand select Integration as the category. - Click Download, then restart Home Assistant.
Once installed, Time Off appears in your Integrations panel.
- Click Add Integration and search for Time Off (or click Add entry on the Time Off card) to add a new device.
- Select your device from the dropdown and click Submit.
- You will be taken to the device's control page where the Time Off entities will appear.
Templates, Groups, and Helpers: Since these device types usually lack a dedicated Device page, Time Off automatically creates one containing only the Time Off entities.
Note
By default, adding Time Off to a device will not affect its behaviour. You must set Time Off to a value greater than 0 to activate the timer.
Time Off adds the following entities to your device:
number.[device]_time_off
The default timer duration. This is the value that Off After is set to each time the device turns on.
- Unit: Minutes (supports 0.1 increments for 6-second precision).
- Set to
0to disable the timer entirely. - The configured value survives restarts.
Running countdowns also survive restarts - see Advanced Notes for how recovery works.
switch.[device]_trigger_only
Controls how Time Off behaves when the timer expires.
| State | Behaviour |
|---|---|
| Off (default) | Time Off turns the device off automatically. |
| On | Time Off fires the event only - the device is left alone. |
In both cases the time_off_timer_expired event is fired. In Trigger Only mode the timer loops continuously, firing the event on each cycle, until the device is turned off or time_off.stop_timer is called.
binary_sensor.[device]_timer_active
Indicates whether a countdown is currently in progress.
- Visible in the Sensor section of the device card.
- State:
onwhile counting down,offwhen idle. - Attributes:
expiry- the exact timestamp when the timer will finish.remaining- a human-readable countdown string (e.g.2m 30s). Refreshed every 30 seconds while the timer runs, so it can lag up to 30 seconds behind the clock - for a live countdown, render fromexpiryinstead. Not recorded in history.
sensor.[device]_off_after
A sensor showing the active countdown duration currently in use. This value is taken from the Time Off entity each time your device is turned on.
- Visible in the Diagnostic section of the device card.
- Can be changed via the
time_off.set_off_afterservice (see Services) without affecting theTime Offdefault.- Your device must already be
onin order for any change to take effect. - The timer will immediately restart with the new duration when this value is changed.
- Setting this value to
0will immediately stop the timer. Thetime_off_timer_expiredevent is not fired - this is a clean cancellation, not an expiry.
- Your device must already be
- When your device is turned
offthis value will reset to0.
Manually starts or restarts the timer for a managed device. The duration comes from the active Off After value if an automation pre-set one via set_off_after; otherwise from the Time Off default.
Important
The entity_id must be the managed device (e.g. fan.bathroom_fan, light.porch_light) - not the Time Off entity.
action: time_off.start_timer
data:
entity_id: fan.bathroom_fanStops the active timer immediately. Does not turn the device off and does not fire the time_off_timer_expired event.
Important
The entity_id must be the managed device (e.g. fan.bathroom_fan, light.porch_light) - not the Time Off entity.
action: time_off.stop_timer
data:
entity_id: fan.bathroom_fanImmediately restarts the timer with a specific duration without changing the Time Off default.
Use this to start a countdown with a duration that differs from the Time Off default. Automations cannot change the value of Off After directly - set_off_after is the sole mechanism. A subsequent start_timer call re-arms from the value set_off_after injected: the custom duration stays in effect until the countdown exits and Off After resets to 0.
This distinction is what makes the Trigger Only looping pattern so powerful: Time Off holds the initial, default, duration (e.g. 20 minutes), while set_off_after injects a shorter recheck duration (e.g. 5 minutes) for subsequent cycles - without permanently altering the 20-minute default. Thus, when the device is turned on again, the timer duration is taken from Time Off - 20 minutes.
If minutes is 0, the timer stops immediately rather than restarting.
Important
The entity_id must be the managed device (e.g. fan.bathroom_fan, light.porch_light) - not the Off After sensor or other Time Off entity.
action: time_off.set_off_after
data:
entity_id: fan.bathroom_fan
minutes: 30Fired when the timer reaches zero. In Trigger Only mode this fires on every loop cycle.
Payload:
| Field | Description |
|---|---|
entity_id |
The managed device entity ID (e.g. fan.bathroom_fan) |
device_id |
The Home Assistant device registry ID of the managed device (used internally by the UI device trigger to filter per device) |
Note
You only need entity_id to filter or act on an event. It is unlikely you will need to use device_id directly. In general, automations written against entity IDs are more readable, portable, and easier to debug than those written against device IDs.
trigger: event
event_type: time_off_timer_expired
event_data:
entity_id: fan.bathroom_fanSet Time Off to 4 minutes. The light turns off automatically after 4 minutes every time it's switched on. No coding required.
If you want to also trigger a notification when the light turns off, create a new automation and select the Time Off device trigger from the UI (Device -> [Your Device] -> Time Off: When timer expires), or use an event trigger in YAML:
alias: Porch Light - Turned Off Notification
description: "Notify when the porch light timer expires"
mode: single
triggers:
- trigger: event
event_type: time_off_timer_expired
event_data:
entity_id: light.porch_light
actions:
- action: notify.mobile_app
data:
message: "Porch light has been turned off automatically."Set Time Off to 2 minutes. As soon as the gate opens, the timer starts. After 2 minutes it closes automatically. No coding required.
If you also want a warning notification before the gate closes:
alias: Front Gate - Pre-Close Warning
description: "Notify 30 seconds before the gate closes"
mode: single
triggers:
- trigger: state
entity_id: binary_sensor.gate_timer_active
to: "on"
actions:
- delay: "00:01:30"
- action: notify.mobile_app
data:
message: "Front gate closing in 30 seconds."Set Time Off to 20 minutes and enable Time Off Trigger Only. Use the time_off_timer_expired event to trigger a humidity check - if humidity is still high, let the fan keep running; otherwise turn it off.
The porch light turns on for 20 minutes (Time Off = 20, Trigger Only = On). When the first timer expires, switch to 5-minute rechecks scanning two mmWave sensors. The light stays on as long as presence is detected, then turns off cleanly once the area is clear.
alias: Porch Light - Presence-Aware Shutoff
description: >
After the initial 20-minute period, recheck every 5 minutes for presence.
Turn off the light only when both mmWave sensors report clear.
mode: single
triggers:
- trigger: event
event_type: time_off_timer_expired
event_data:
entity_id: light.porch_light
actions:
- if:
- condition: or
conditions:
- condition: state
entity_id: binary_sensor.porch_mmwave_1
state: "on"
- condition: state
entity_id: binary_sensor.porch_mmwave_2
state: "on"
then:
# Presence detected - set Off After to 5 minutes for the next cycle.
# Trigger Only mode restarts the timer automatically, so no explicit timer restart is needed.
- action: time_off.set_off_after
data:
entity_id: light.porch_light
minutes: 5
else:
# No presence - turn off the light. The timer stops itself automatically.
- action: light.turn_off
target:
entity_id: light.porch_lightNote
Set Time Off to 20 and Time Off Trigger Only to On on the device card. On the first expiry the automation sets Off After to 5 minutes - Trigger Only mode immediately restarts the timer and picks up the new value for the next cycle. This continues every 5 minutes until no presence is detected and the light is turned off.
-
Trigger Only loop: In
Trigger Onlymode the timer fires thetime_off_timer_expiredevent and immediately restarts. This continues until the device is manually turned off ortime_off.stop_timeris called. Each cycle reads the currentOff Aftervalue, so you can change the duration mid-loop viatime_off.set_off_after. -
Restart recovery: Timer state is persisted to
.storage/time_off.timers. On restart, if any timers were active at shutdown, Time Off waits for the managed device to report a real state (slow-loading integrations like Zigbee and Z-Wave can take a moment), capped at 30 seconds. As soon as the state arrives - or the cap expires: timers still in the future resume their countdowns; timers that expired during the shutdown window act immediately (turn off or fire event). InTrigger Onlymode, a timer that expired during shutdown fires the event and resumes looping if the device is still on. If the device was turned off while HA was down, the stale expiry is cleared soTimer Activedoes not remain on with no countdown running. -
Connectivity blips: If the managed device drops to
unavailable/unknownmid-countdown (a Zigbee or Wi-Fi blip, or its integration reloading), the timer keeps running and resumes untouched when the device comes backon. If it comes backoff- it was really turned off during the outage - the countdown is cancelled cleanly. A device that reappearsonwith no countdown running (e.g. power restored at a wall switch) starts a fresh one as usual. -
Manual stop is clean: Calling
time_off.stop_timercancels the countdown, clears the persisted expiry, and refreshes theTimer Activesensor - but does not turn the device off and does not firetime_off_timer_expired. -
Multiple devices: Each device gets its own independent instance. Timers run in parallel with no shared state between devices.
-
Template entities: Fully supported. Template fans, lights, and switches work identically to physical devices.
- [Fix] Connectivity blips no longer destroy running timers - previously, a device dropping to
unavailablemid-countdown (a Zigbee/Wi-Fi blip, or its integration reloading) cancelled the countdown, and the device coming backonstarted a fresh timer from theTime Offdefault - silently discarding a customset_off_afterduration and extending the total on-time. Blips are now ignored: the countdown keeps running and resumes untouched when the device returns. A device that comes backoffcancels the countdown cleanly, and one that reappearsonwith no countdown running (e.g. power restored at a wall switch) still auto-starts as before. - [Fix] Race between arming and the device turning off - if the device was turned off in the brief window while a new countdown's expiry was being written to storage, the countdown armed anyway and later fired a spurious turn-off plus
time_off_timer_expiredevent. The device state is now re-checked after the write. - [Improvement] Startup recovery is event-driven - instead of always sleeping a fixed 30 seconds before acting on a recovered timer, Time Off now acts as soon as the managed device reports a real state; the 30-second cap remains as a fallback for devices that never do. Recovery after a reload is effectively instant, and device turn-ons during the old fixed window are no longer silently ignored.
- [Improvement]
remainingattribute excluded from recorder history - the Timer Active sensor'sremainingattribute refreshes every 30 seconds while a timer runs; it is now excluded from long-term history so it no longer stores a new attribute blob per tick.expiryis still recorded. - [Doc] Corrected
start_timerdocumentation - the README claimedstart_timeralways restarts from theTime Offdefault; it actually honours a custom duration pre-set viaset_off_after(and always has - a regression test now locks the intended behaviour in). - [Internal] Cleanup pass - removed dead test helpers left over from the pre-2.0.7 architecture, the duplicate
VERSIONconstant (manifest.jsonis now the single source), a private re-export kept only for the test suite, a redundant dispatcher notification per timer arm, and stale comments; modernisedconfig_flow.pyanddevice_trigger.py(type hints, import order) to match the rest of the codebase; added arufflint job to CI and import sorting across the repo; new regression tests for the blip semantics, the arming race, thestart_timer/set_off_afterinteraction, the recovery grace window, and mid-loop duration changes in Trigger Only mode.
- [Fix] "When timer expires" automation trigger didn't work - if you built an automation using the "Time Off: When timer expires" trigger, the automation could fail to load and then stay disabled, so it never ran. It now works as expected.
- [Refactor] Coordinator architecture - the timer logic, persistence, startup recovery, and device-state handling moved out of the nested closures in
__init__.pyinto a dedicatedTimeOffCoordinatorclass incoordinator.py. Each piece is now an ordinary method (independently testable) instead of a closure whose reference was passed back throughhass.data. No change to entities, services, events, or behavior. - [Performance] Event-driven countdown - the timer no longer wakes once per second to check the clock. It schedules a single
async_track_point_in_timecallback that fires exactly at expiry, and reuses the managed device's existing state listener to stop cleanly when it is turned off. This removes up to ~86,400 wake-ups per long-running timer and reduces the countdown to a single storage write per arm. - [Refactor] Single source of truth for
Off After- the active duration now lives on the coordinator and theOff Aftersensor renders from it, replacing the previous dual store (a private field plus a parallelhass.datadict) that existed only to dodge stale state writes. - [Improvement] Internal updates moved to the dispatcher - the internal
time_off_cache_updatedbus event was replaced with Home Assistant's per-device dispatcher signal, so "timer changed, refresh" notifications no longer touch the global event bus or recorder. The publictime_off_timer_expiredevent is unchanged. - [Improvement]
hass.datanamespacing - per-entry coordinators now live underhass.data["time_off"]["coordinators"], separated from the shared globals, removing the type-sniffing the old flat layout required to locate an entry. - [Improvement] Type hints and
async_naming - added throughout the integration and entity platforms for consistency with the Home Assistant style guide. - [Internal]
manifest.jsonkeys sorted into hassfest's required order (values unchanged). - [Doc] - Added mesa_profile.json
- [Improvement] Entity rename auto-follow - if the managed device entity is renamed in the entity registry, Time Off now migrates the persisted expiry to the new entity ID and schedules a reload, keeping state consistent without manual intervention.
- [Improvement]
Trigger Onlystate read from entity object reference - previously read from the HA state machine via string lookup; now reads directly from the registered switch entity object, eliminating a class of subtle timing failures during startup recovery. - [Improvement]
Off Afterdefault read from entity object reference - same improvement as above for theTime Offnumber entity. - [Improvement] Ghost entity cleanup on setup - stale entities from a previous load that are no longer in the current target list are removed from the entity registry automatically on
async_setup_entry. - [Improvement]
CONFIG_SCHEMAadded - registerscv.config_entry_only_config_schemaso HA correctly reports that this integration does not support YAML configuration. - [Improvement] Device trigger strings added -
strings.jsonnow includes thedevice_automation.trigger_typesection required for the UI device trigger label ("Time Off: When timer expires") to render correctly. - [Improvement] Service descriptions added to
strings.json- all three services now have name, description, and field metadata, enabling documentation in the HA Services UI. - [Fix] Device triggers never fired - the
time_off_timer_expireddevice trigger automation option was silently broken in rare cases. The event config was passed to HA's event trigger engine as a plain string rather than a validated schema object, causing the event type to be iterated character by character. Fixed by running the trigger config throughevent_trigger.TRIGGER_SCHEMAbefore passing it toasync_attach_trigger. - [Fix] Reload after unload orphaned the device - cancelling timers during
async_unload_entryincorrectly triggered the loop's cleanup guard (which clears the persisted expiry), so after a reload the device had no saved state and appeared as if it had never had a timer. Fixed by popping tasks from the active-timers dict before cancelling, matching the behaviour of the normal stop-timer path. - [Fix] Stale "Timer Active" after device turned off during shutdown - if a device was turned off while HA was down, the saved expiry was still present on next boot, leaving
Timer Activestuck on with no countdown running. Fixed: recovery now explicitly clears stale expiries for devices that are off at startup. - [Fix] Recovery task not cancelled on unload - the startup recovery background task was not stored or cancelled when an entry was unloaded, leaving a dangling task that could act on a stale entry. Fixed by storing the task handle in entry data and cancelling it in
async_unload_entry. - [Fix]
Off Afterrestore incorrectly set a non-zero value on a fresh boot - theOff Aftersensor restored its previous value even when no timer was actually running (no matching entry in the persisted expiry store). It now only restores a non-zero value if a saved expiry exists for that device. - [Fix]
Trigger Onlyswitch restore used a hardcoded string - the on/off restore comparison used a literal"on"string instead of theSTATE_ONconstant, making it fragile against HA internals. Fixed. - [Fix] Binary sensor interval handle was not initialised before
async_added_to_hass- a missingself._unsub_interval = Nonein__init__meant anAttributeErrorcould occur ifasync_will_remove_from_hassran before the entity was fully added. Fixed. - [Fix] Binary sensor
_updatescheduled a coroutine per tick - the interval callback was anasync def, causing HA to allocate a task on every polling tick. Converted to a synchronous@callbacksince the method only calls synchronous HA helpers. - [Fix] Binary sensor did not recover its polling interval on restart - if a timer was already active when the binary sensor entity loaded (e.g. after a reload), the
remainingattribute stayed stale. Fixed by starting the update interval at the end ofasync_added_to_hasswhen the timer is already active. - [Fix]
Time Offnumber entity used a redundant state backing field -number.pymaintained a private_statefield alongside_attr_native_value, causing a double-write on every value change. Simplified to use_attr_native_valuedirectly. - [Fix] Services registered per config entry instead of once at integration load - services were re-registered on every
async_setup_entrycall and only conditionally removed, leading to duplicate registration warnings and broken removal logic. All three services are now registered once inasync_setup. - [Fix] Config entry title was overwritten on every reload -
async_setup_entryunconditionally calledasync_update_entrywith a freshly computed title, overwriting any name the user had set. Removed; the title is now set once at creation and never touched again. - [Fix]
already_configuredabort used wrongstrings.jsonkey - the abort reason key was nested under"error"instead of"abort", so the duplicate-device message was never shown. Fixed. - [Doc] Universal Functionality table - added Climate and Input Boolean to the supported device list.
- [Doc] Restart recovery description - clarified that the 30-second wait is a fixed sleep (not adaptive), that it only runs when timers were active at shutdown, and documented the
Trigger Onlyrecovery path (fires event + resumes loop if device is still on). - [Doc] Various grammar and wording fixes.


