Docs
Examples

Door left open

Notify chosen people when a door or window stays open too long, keep reminding, and say when it closes.

Several contact sensors, one app. Each sensor that opens gets its own timer; if it is still open when the timer fires, the people the installer picked get a push notification.

What it shows

  • One timer per entity with runIn(..., data, { overwrite: false }), so sensors do not cancel each other. See Inactivity timers.
  • Letting the installer choose recipients with an accountUserId input read from settings. See Choosing recipients.
  • Per-entity bookkeeping in state keyed by entity id. See State › Patterns.
  • Friendly labels ('Open', 'Closed') that mean the right raw token for door and window sensors.
door-left-open.js
const definition = {
  name: 'Door Left Open',
  namespace: 'com.example',
  description: 'Notifies chosen people when a door or window stays open too long, and again when it closes.',
  category: 'security',
};

const preferences = {
  pages: [{
    id: 'main',
    title: 'Door Left Open',
    inputs: [
      { key: 'openings', label: 'Doors and windows', type: 'entity', domainFilter: 'binary_sensor',
        multiple: true, required: true, description: 'Contact sensors (device class door or window).' },
      { key: 'afterMinutes', label: 'Notify after open for (minutes)', type: 'number', default: 10, minValue: 1 },
      { key: 'recipients', label: 'Notify', type: 'accountUserId', multiple: true,
        description: 'Leave empty to notify everyone on the account.' },
      { key: 'critical', label: 'Time-sensitive push', type: 'boolean', default: false },
      { key: 'repeat', label: 'Keep reminding while it stays open', type: 'boolean', default: false },
    ],
  }],
};

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

function initialize() {
  subscribe(openings, { to: 'Open' }, onOpened);        // 'Open' is `on` for door and window sensors
  subscribe(openings, { to: 'Closed' }, onClosed);
}

function onOpened(evt) {
  state.openSince ??= {};
  state.openSince[evt.entityId] = Date.now();
  // One timer per sensor: `data` says which, overwrite:false keeps the other sensors' timers alive.
  runIn(minutes() * 60, remind, { entityId: evt.entityId }, { overwrite: false });
}

function onClosed(evt) {
  const since = state.openSince?.[evt.entityId];
  if (state.openSince) delete state.openSince[evt.entityId];
  if (since && Date.now() - since >= minutes() * 60 * 1000) {
    notify(`${nameOf(evt.entityId)} is closed again.`);    // only after we had alerted
  }
}

function remind(data) {
  const since = state.openSince?.[data.entityId];
  if (!since) return;                                       // closed before the timer fired: stale timer, ignore
  if (new EntityWrapper(data.entityId).isNot('Open')) return;   // belt and braces: check the live state
  const openFor = Math.round((Date.now() - since) / 60000);
  notify(`${nameOf(data.entityId)} has been open for ${openFor} minutes.`);
  if (repeat === true) runIn(minutes() * 60, remind, { entityId: data.entityId }, { overwrite: false });
}

function notify(message) {
  sendPushNotification(message, 'Door left open', critical === true, settings.recipients);
}

function nameOf(entityId) {
  try { return String(new EntityWrapper(entityId).getAttribute('friendlyName')); } catch { return entityId; }
}

function minutes() {
  return typeof afterMinutes === 'number' && afterMinutes > 0 ? afterMinutes : 10;
}

How it works

  • Stacked timers. By default runIn replaces the pending timer for the same handler, which would make a second door cancel the first door's reminder. { overwrite: false } stacks them, and the data argument tells remind which door it is about.
  • Stale timers are expected. A closed door does not cancel its timer (unschedule(remind) would cancel every door's). Instead remind checks state.openSince and returns if the door closed in the meantime. Cheap, and it also makes the handler safe to run twice after a reboot.
  • settings.recipients. accountUserId inputs are not hydrated as globals; settings has the raw array (or undefined, which sendPushNotification treats as everyone).
  • friendlyName is read inside try/catch because getAttribute throws for an entity that no longer exists.

Try changing

  • Only alert when the house is armed: check panel.is('Armed Away') in remind with an intrusion_alarm_panel input. See Alarm webhook for the panel states.
  • Email instead of push: add { key: 'emailTo', type: 'accountUserEmail' } and call sendEmail(settings.emailTo, 'Door left open', message).

On this page