Docs
Examples

Fan auto-off

Turn a fan off a set number of minutes after it turns on, optionally only when a person switched it on.

An exhaust fan that someone switches on by hand and forgets. The app watches the fan, and whenever it turns on, arms a timer to turn it off again. Switching it off early cancels the timer; switching it off and on again restarts the countdown.

What it shows

  • runIn replaces its own pending timer, so there is no bookkeeping. See Scheduling.
  • Gating on evt.newState.context.actorType === 'physical' to react only to the wall switch, not to other automations. See The event.
  • A logging preference read at call time, so turning it on takes effect on the next event.
fan-auto-off.js
const definition = {
  name: 'Fan Auto-off',
  namespace: 'com.example',
  description: 'Turns a fan off a set number of minutes after it turns on.',
  category: 'climate',
};

const preferences = {
  pages: [{
    id: 'main',
    title: 'Fan Auto-off',
    inputs: [
      { key: 'fan', label: 'Fan', type: 'entity', domainFilter: 'fan', required: true },
      { key: 'offAfterMinutes', label: 'Turn off after (minutes)', type: 'number', default: 15, minValue: 1, maxValue: 240 },
      { key: 'manualOnly', label: 'Only when switched on by hand',
        description: 'Ignore the fan being turned on by another automation or from the app.',
        type: 'boolean', default: false },
      { key: 'logging', label: 'Enable logging', type: 'boolean', default: false },
    ],
  }],
};

function installed() { initialize(); }
function updated() { unsubscribe(); unschedule(); initialize(); }

function initialize() {
  if (!fan) { console.warn('No fan configured'); return; }
  subscribe(fan, { to: 'on' }, onFanOn);
  subscribe(fan, { to: 'off' }, onFanOff);
}

function onFanOn(evt) {
  const actor = evt.newState.context?.actorType ?? 'system';
  if (manualOnly === true && actor !== 'physical') {
    logInfo(`Fan turned on by ${actor}; leaving it alone`);
    return;
  }
  const minutes = typeof offAfterMinutes === 'number' && offAfterMinutes > 0 ? offAfterMinutes : 15;
  state.lastOnAt = new Date().toISOString();
  logInfo(`Fan on, off again in ${minutes} min`);
  runIn(minutes * 60, turnFanOff);          // re-arming replaces any pending timer
}

function onFanOff() {
  unschedule(turnFanOff);                   // switched off early, by hand or by us: nothing left to do
}

function turnFanOff() {
  logInfo('Auto-off elapsed');
  fan.turnOff();
}

function logInfo(...args) {
  if (logging === true) console.log(...args);
}

How it works

  • Two subscriptions, one timer. { to: 'on' } arms turnFanOff; { to: 'off' } cancels it. When the timer itself turns the fan off, the off event runs onFanOff once more, which cancels a timer that no longer exists. That is harmless, and simpler than tracking who turned it off.
  • Re-arming restarts the countdown. A second on event while the timer is pending calls runIn again, and the hub replaces the pending timer with a fresh full-length one.
  • actorType is 'physical' when the device reported a change the hub did not command. Anything else ('user' from the app, 'smart-app', 'system') is skipped when manual only is on.
  • The timer survives a reboot. If the hub restarts while the timer is pending it fires once on boot, so a fan is never left running because of a power blip.

Try changing

  • Make it work for a switch as well: add 'switch' to a second entity input, or drop domainFilter and let the installer pick anything with turnOff.
  • Replace the fixed delay with a humidity sensor: subscribe to the sensor with {} and turn the fan off when the reading drops below a threshold, as in Temperature alert.

On this page