Skip to content

sfox38/Time_Off

Repository files navigation

Time Off

A Powerful Auto-Off Timer for Home Assistant

Tests Validate HACS Custom

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.


Eliminates Helper Fatigue

In standard Home Assistant setups, creating a simple auto-off timer usually requires:

  1. Creating a Timer Helper
  2. Writing an Automation to start the timer when the device turns on
  3. 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.

Universal Functionality

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.


Installation

Manual

  1. Download the time_off zip archive from this repository and unpack it.
  2. Copy the time_off folder into your custom_components/ directory.
  3. Restart Home Assistant.
  4. Go to Settings -> Devices & Services -> Add Integration and search for Time Off.

HACS (Recommended)

  1. Open HACS.
  2. Click the three dots (top right) -> Custom repositories.
  3. Paste https://github.com/sfox38/time_off and select Integration as the category.
  4. Click Download, then restart Home Assistant.

Adding Devices

Once installed, Time Off appears in your Integrations panel.

  1. Click Add Integration and search for Time Off (or click Add entry on the Time Off card) to add a new device.
  2. Select your device from the dropdown and click Submit.
  3. 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.


Entities

Time Off adds the following entities to your device:

Time Off

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 0 to disable the timer entirely.
  • The configured value survives restarts.

Running countdowns also survive restarts - see Advanced Notes for how recovery works.

Time Off Trigger Only

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.

Timer Active

binary_sensor.[device]_timer_active

Indicates whether a countdown is currently in progress.

  • Visible in the Sensor section of the device card.
  • State: on while counting down, off when 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 from expiry instead. Not recorded in history.

Off After

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_after service (see Services) without affecting the Time Off default.
    • Your device must already be on in 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 0 will immediately stop the timer. The time_off_timer_expired event is not fired - this is a clean cancellation, not an expiry.
  • When your device is turned off this value will reset to 0.

Services

time_off.start_timer

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_fan

time_off.stop_timer

Stops 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_fan

time_off.set_off_after

Immediately 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: 30

Events

time_off_timer_expired

Fired 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_fan

Sample Use Cases

Porch Light - Simple Auto-Off

Set 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."

Front Gate - Auto-Close

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."

Bathroom Fan - Humidity-Aware Shutoff

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.

alias: Shower Fan Off (Humidity Check)
description: "Only turns off the fan if humidity is below 30%"
mode: single
triggers:
  - trigger: event
    event_type: time_off_timer_expired
    event_data:
      entity_id: fan.bathroom_fan
actions:
  - if:
      - condition: numeric_state
        entity_id: sensor.bathroom_humidity
        below: 30
    then:
      - action: time_off.stop_timer
        data:
          entity_id: fan.bathroom_fan
      - action: fan.turn_off
        target:
          entity_id: fan.bathroom_fan
    # No else needed: in Trigger Only mode the timer restarts itself
    # automatically, so the fan is rechecked on the next cycle.

Porch Light - Presence-Aware Shutoff

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_light

Note

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.


Advanced Notes

  • Trigger Only loop: In Trigger Only mode the timer fires the time_off_timer_expired event and immediately restarts. This continues until the device is manually turned off or time_off.stop_timer is called. Each cycle reads the current Off After value, so you can change the duration mid-loop via time_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). In Trigger Only mode, 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 so Timer Active does not remain on with no countdown running.

  • Connectivity blips: If the managed device drops to unavailable/unknown mid-countdown (a Zigbee or Wi-Fi blip, or its integration reloading), the timer keeps running and resumes untouched when the device comes back on. If it comes back off - it was really turned off during the outage - the countdown is cancelled cleanly. A device that reappears on with 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_timer cancels the countdown, clears the persisted expiry, and refreshes the Timer Active sensor - but does not turn the device off and does not fire time_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.


Changelog

v2.0.8

  • [Fix] Connectivity blips no longer destroy running timers - previously, a device dropping to unavailable mid-countdown (a Zigbee/Wi-Fi blip, or its integration reloading) cancelled the countdown, and the device coming back on started a fresh timer from the Time Off default - silently discarding a custom set_off_after duration 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 back off cancels the countdown cleanly, and one that reappears on with 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_expired event. 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] remaining attribute excluded from recorder history - the Timer Active sensor's remaining attribute 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. expiry is still recorded.
  • [Doc] Corrected start_timer documentation - the README claimed start_timer always restarts from the Time Off default; it actually honours a custom duration pre-set via set_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 VERSION constant (manifest.json is now the single source), a private re-export kept only for the test suite, a redundant dispatcher notification per timer arm, and stale comments; modernised config_flow.py and device_trigger.py (type hints, import order) to match the rest of the codebase; added a ruff lint job to CI and import sorting across the repo; new regression tests for the blip semantics, the arming race, the start_timer/set_off_after interaction, the recovery grace window, and mid-loop duration changes in Trigger Only mode.

v2.0.7

  • [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__.py into a dedicated TimeOffCoordinator class in coordinator.py. Each piece is now an ordinary method (independently testable) instead of a closure whose reference was passed back through hass.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_time callback 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 the Off After sensor renders from it, replacing the previous dual store (a private field plus a parallel hass.data dict) that existed only to dodge stale state writes.
  • [Improvement] Internal updates moved to the dispatcher - the internal time_off_cache_updated bus 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 public time_off_timer_expired event is unchanged.
  • [Improvement] hass.data namespacing - per-entry coordinators now live under hass.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.json keys sorted into hassfest's required order (values unchanged).
  • [Doc] - Added mesa_profile.json

v2.0.6

  • [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 Only state 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 After default read from entity object reference - same improvement as above for the Time Off number 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_SCHEMA added - registers cv.config_entry_only_config_schema so HA correctly reports that this integration does not support YAML configuration.
  • [Improvement] Device trigger strings added - strings.json now includes the device_automation.trigger_type section 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_expired device 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 through event_trigger.TRIGGER_SCHEMA before passing it to async_attach_trigger.
  • [Fix] Reload after unload orphaned the device - cancelling timers during async_unload_entry incorrectly 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 Active stuck 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 After restore incorrectly set a non-zero value on a fresh boot - the Off After sensor 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 Only switch restore used a hardcoded string - the on/off restore comparison used a literal "on" string instead of the STATE_ON constant, making it fragile against HA internals. Fixed.
  • [Fix] Binary sensor interval handle was not initialised before async_added_to_hass - a missing self._unsub_interval = None in __init__ meant an AttributeError could occur if async_will_remove_from_hass ran before the entity was fully added. Fixed.
  • [Fix] Binary sensor _update scheduled a coroutine per tick - the interval callback was an async def, causing HA to allocate a task on every polling tick. Converted to a synchronous @callback since 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 remaining attribute stayed stale. Fixed by starting the update interval at the end of async_added_to_hass when the timer is already active.
  • [Fix] Time Off number entity used a redundant state backing field - number.py maintained a private _state field alongside _attr_native_value, causing a double-write on every value change. Simplified to use _attr_native_value directly.
  • [Fix] Services registered per config entry instead of once at integration load - services were re-registered on every async_setup_entry call and only conditionally removed, leading to duplicate registration warnings and broken removal logic. All three services are now registered once in async_setup.
  • [Fix] Config entry title was overwritten on every reload - async_setup_entry unconditionally called async_update_entry with 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_configured abort used wrong strings.json key - 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 Only recovery path (fires event + resumes loop if device is still on).
  • [Doc] Various grammar and wording fixes.

About

Time Off : A powerful "Auto-Off" timer for Home Assistant devices

Topics

Resources

License

Stars

41 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages