Docs
Examples

Motion light by time of day

Motion turns on different lights at different brightness depending on the time of day, from a list of windows the installer defines.

A richer motion light. Instead of one set of lights, the installer defines windows: at 06:00 the hall at 30 %, at 17:00 hall and kitchen at 100 %, at 22:00 just the hall at 10 %. On motion the app works out which window is active right now and turns on that window's lights.

What it shows

  • An array input whose rows hold a time, a multi-entity field and a gated number. See Array inputs.
  • Entity fields inside rows arrive as ids, so the code builds EntityWrappers itself.
  • Evaluating "which window is active" fresh on every event instead of scheduling anything, which keeps it reboot-proof.
  • Several motion sensors with an off-timer that only starts once all of them are clear.
motion-light-by-time-of-day.js
const definition = {
  name: 'Motion Light by Time of Day',
  namespace: 'com.example',
  description: 'Motion turns on different lights at different brightness depending on the time of day.',
  category: 'lighting',
};

const preferences = {
  pages: [
    {
      id: 'sensors',
      title: 'Motion',
      inputs: [
        { key: 'motionSensors', label: 'Motion sensors', type: 'entity',
          domainFilter: 'binary_sensor', deviceClassFilter: 'motion', multiple: true, required: true },
        { key: 'offAfterMinutes', label: 'Turn off after no motion for (minutes)', type: 'number', default: 5, minValue: 1 },
      ],
    },
    {
      id: 'windows',
      title: 'Time windows',
      description: 'Each window starts at its time and lasts until the next one starts. Before the first window of the day, motion does nothing.',
      inputs: [
        {
          key: 'windows',
          label: 'Windows',
          type: 'array',
          minItems: 1,
          maxItems: 6,
          itemSummaryKeys: ['startTime', 'brightness'],
          itemSchema: {
            inputs: [
              { key: 'startTime', label: 'Starts at', type: 'time', required: true },
              { key: 'lights', label: 'Lights', type: 'entity', domainFilter: 'light', multiple: true, required: true },
              { key: 'brightness', label: 'Brightness (1–100)', type: 'number', minValue: 1, maxValue: 100,
                requiresEntityFeature: { siblingKey: 'lights', domain: 'light', colorMode: 'brightness' } },
            ],
          },
        },
      ],
    },
  ],
};

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

function initialize() {
  subscribe(motionSensors, { to: 'Detected' }, onMotion);
  subscribe(motionSensors, { to: 'Clear' }, onMotionCleared);
}

function onMotion() {
  unschedule(lightsOff);                                  // motion resumed: cancel a pending off
  const win = activeWindow();
  if (!win) return;                                       // before the first window of the day
  const params = typeof win.brightness === 'number' ? { brightnessPercent: win.brightness } : {};
  const ids = idList(win.lights);
  for (const id of ids) new EntityWrapper(id).turnOn(params);
  state.activeLights = ids;                               // remember the set, so we turn off exactly these
}

function onMotionCleared() {
  if (anyMotion()) return;                                // another sensor still sees someone
  const minutes = typeof offAfterMinutes === 'number' && offAfterMinutes > 0 ? offAfterMinutes : 5;
  runIn(minutes * 60, lightsOff);
}

function lightsOff() {
  for (const id of state.activeLights ?? []) new EntityWrapper(id).turnOff();
  state.activeLights = [];
}

function anyMotion() {
  const sensors = Array.isArray(motionSensors) ? motionSensors : motionSensors ? [motionSensors] : [];
  return sensors.some((s) => s.is('Detected'));
}

// The window whose start time most recently passed, in the home's clock.
function activeWindow() {
  const now = formatDateParts(new Date(), getHomeTimeZone());
  const nowMinutes = Number(now.hour) * 60 + Number(now.minute);
  let best = null;
  for (const row of windows ?? []) {
    const start = toMinutes(row.startTime);
    if (start === null || start > nowMinutes) continue;
    if (!best || start > toMinutes(best.startTime)) best = row;
  }
  return best;
}

function toMinutes(value) {
  if (typeof value !== 'string' || !/^\d{1,2}:\d{2}$/.test(value)) return null;
  const [h, m] = value.split(':');
  return Number(h) * 60 + Number(m);
}

// A `multiple` entity field inside a row is a list of ids; a single one is a string.
function idList(value) {
  return Array.isArray(value) ? value : typeof value === 'string' ? [value] : [];
}

How it works

  • Rows are plain JSON. windows hydrates to an array of { startTime, lights, brightness } objects. lights is a list of entity ids, not wrappers, hence new EntityWrapper(id).
  • No schedules. The active window is computed when motion happens, so there is nothing to re-arm after a reboot and no cron to tear down. Changing the windows only needs updated.
  • Turn off what you turned on. The window may have changed by the time the off-timer fires, so the ids that were switched on are kept in state.activeLights and those are what go off.
  • All clear means all sensors. A Clear from one sensor only starts the timer if no other sensor still reports Detected; a later Detected cancels it again.

Try changing

  • Anchor a window to sunset instead of a clock time: add an anchor enum per row and, for sunset, derive the start from getSunTimes().sunsetIso with formatDateParts. See Time zones.
  • Add a days enum per row (multiple: true) and skip rows whose days do not include formatDateParts(...).weekday.

On this page