A script to check sensor last update

Hm, let me see.

You can find Zigbee only version from now on : Homey_Scripts/checkLastUpdate_Zigbee.js at main · shaarkys/Homey_Scripts · GitHub

Any device version : Homey_Scripts/checkLastUpdate_any.js at main · shaarkys/Homey_Scripts · GitHub

Original posts above updated as well.

@Bert_Wissink , check the latest 0.7 version of Any device version.

--- v0.7 additions ---
Adds battery-level monitoring for ANY devices (not just Zigbee):
  • Flags devices whose battery level is at/below BATTERY_THRESHOLD_PERCENT
  • Treats alarm_battery=true as low battery
  • Prints a "Batt" column alongside existing columns
  • Exposes LowBatteryDevices & lowBatteryCount as Flow tags
  • Extends returned text to include low-battery section
2 Likes

Thanks!
Just tested this one. Works perfect!

How can I exclude devices in the script?

Can you show me with an example how I write this in the script?

I only get errors when I try.

Thanks!

Hi Tumtum,

line 23 gives you the possibility to enter your own (partly) device name(s) you’d like to be excluded;
The pre-entered ones are just examples:

const EXCLUDED_DEVICE_NAME_PATTERN = /Flood|Netatmo Rain|Motion|Flora|Rear gate Vibration Sensor|Vibration Sensor Attic Doors/i;  // Device names (case-insensitive) to be excluded
  • don’t touch the / and /i
  • use a | as (partly) device name divider.
2 Likes

Please note, there is a bug in firmware version Homey Pro v12.6.1, which currently influence Zigbee variant script from being reliable. It has been already fixed in next version, so in case you are facing any issue, update to the next release candidate version, when available (v12.7.x), on your own risk naturally.

1 Like

Thanks I extended your Zigbee script with my Xiaomi devices, I also have a separate list of Zigbee devices via Xiaomi app (maybe I am not the only one, therefore her a variant which includes also Zigbee devices via Xiaomi app). Excuse for the formatting

// Zigbee + Xiaomi Last-Seen & Battery Monitor — v1.0
//   • Keeps original Zigbee behaviour & formatting 1:1
//   • Adds Xiaomi (ownerUri: com.xiaomi-miio) using latest capability lastUpdated
//   • Same thresholds, filters, counters & flow tags
//   • Adds an extra column "Origin" (Zigbee / Xiaomi)
//
// ------------------------------- configurable constants -----------

// Duration (in hours) after which a device is considered “not reporting”
const NotReportingThreshold  = 2;            // hours

// Battery-level percentage at/below which a device is considered “low”
const BatteryThreshold       = 30;           // percent

// Zones to exclude (case-insensitive full-string match)
const EXCLUDED_ZONES         = ['Bathroom', 'Main Entry', 'Living Room'];
// const EXCLUDED_ZONES      = [];            // Uncomment to exclude none

// Device-class / name filters
const INCLUDED_DEVICE_CLASSES_REGEX = /sensor|button|remote|socket|light|other|switch/i;
const EXCLUDED_DEVICE_NAME_PATTERN  = /smoke|flood|bulb|spot/i;
const INCLUDED_DEVICE_NAME_PATTERN  = /.*/i; // e.g. /temperature/i to narrow

// Whether to include these Zigbee node types
const includeEndDevices = true;
const includeRouters    = true;

// Optional time zone override (e.g., "Europe/Prague"). Leave null/'' to use system TZ.
const TIME_ZONE = null;

// Label used when there have been no updates (invalid/absent timestamp)
const NO_UPDATES_LABEL = 'No updates';

// ------------------------------- derived constants ----------------

const thresholdInMillis = NotReportingThreshold * 3600000; // h → ms

// ------------------------------- globals (counters & lists) --------

let notReportingCount   = 0;
let lowBatteryCount     = 0;

let DevicesNotReporting = [];
let DevicesLowBattery   = [];

// ------------------------------- helpers ---------------------------

// Format Date → "dd-mm-yyyy, hh:mm:ss"
function formatDate(date) {
  if (!date || isNaN(date.getTime())) return NO_UPDATES_LABEL;
  const d = date;
  // Use system time zone unless TIME_ZONE is set
  const opts = {
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', second: '2-digit',
    hour12: false
  };
  if (TIME_ZONE) opts.timeZone = TIME_ZONE;

  const parts = new Intl.DateTimeFormat('en-GB', opts).formatToParts(d);
  const get = (t) => parts.find(p => p.type === t)?.value ?? '';
  const DD = get('day').padStart(2, '0');
  const MM = get('month').padStart(2, '0');
  const YYYY = get('year');
  const HH = get('hour').padStart(2, '0');
  const mm = get('minute').padStart(2, '0');
  const ss = get('second').padStart(2, '0');
  return `${DD}-${MM}-${YYYY}, ${HH}:${mm}:${ss}`;
}

// Simple pad-right for console table layout
function padRight(str, width) {
  str = String(str);
  return str.length >= width ? str.slice(0, width) : str + ' '.repeat(width - str.length);
}

// ------------------------------- main routine ----------------------

async function checkZigbeeLastSeen() {
  try {
    /* ---------- fetch zones/devices/zigbee state ---------------- */
    const [zonesObj, devicesObj, zigbeeState] = await Promise.all([
      Homey.zones.getZones(),
      Homey.devices.getDevices(),
      Homey.zigbee.getState()
    ]);

    const allDevices = Object.values(devicesObj);

    // map zoneId → zoneName
    const zoneMap = {};
    Object.values(zonesObj).forEach(z => { zoneMap[z.id] = z.name; });

    /* ---------- stats holders ----------------------------------- */
    const okRows  = [];
    const nokRows = [];
    let routerCount = 0;
    let endDevCount = 0;
    let totalCount  = 0;

    /* ---------- iterate Zigbee nodes ---------------------------- */
    for (const node of Object.values(zigbeeState.nodes)) {
      const typeLower = node.type?.toLowerCase() || '';
      if ((!includeRouters && typeLower === 'router') ||
          (!includeEndDevices && typeLower === 'enddevice')) continue;

      totalCount++;

      // IMPORTANT: match by name (original script behaviour), NOT by id
      const homeyDevice = allDevices.find(d => d.name === node.name);
      const zoneName    = homeyDevice?.zone ? zoneMap[homeyDevice.zone] : null;

      // Skip excluded zones
      if (zoneName && EXCLUDED_ZONES.some(z => z.toLowerCase() === zoneName.toLowerCase())) {
        continue;
      }

      // Apply class & name filters
      if (homeyDevice) {
        if (!INCLUDED_DEVICE_CLASSES_REGEX.test(homeyDevice.class || '')) continue;
        if (!INCLUDED_DEVICE_NAME_PATTERN.test(homeyDevice.name || ''))    continue;
        if (EXCLUDED_DEVICE_NAME_PATTERN.test(homeyDevice.name || ''))     continue;
      }

      /* ---------- last-seen check ------------------------------- */
      const lastSeenDate = node.lastSeen ? new Date(node.lastSeen) : null;
      const lastSeenTs   = lastSeenDate ? lastSeenDate.getTime() : NaN;
      const dateFormatted = Number.isNaN(lastSeenTs) ? NO_UPDATES_LABEL : formatDate(lastSeenDate);

      let statusMark = '(OK)';
      let reason = '';

      if (Number.isNaN(lastSeenTs)) {
        statusMark = '(NOK)';
        reason = 'no updates';
        notReportingCount++;
        DevicesNotReporting.push(`${node.name || '(unknown)'} ${dateFormatted} (${typeLower}) - NOK: ${reason}`);
      } else {
        const age = Date.now() - lastSeenTs;
        if (age >= thresholdInMillis) {
          statusMark = '(NOK)';
          reason = `threshold ${NotReportingThreshold}h`;
          notReportingCount++;
          DevicesNotReporting.push(`${node.name || '(unknown)'} ${dateFormatted} (${typeLower}) - NOK: ${reason}`);
        }
      }

      /* ---------- battery check -------------------------------- */
      let batteryMsg = 'N/A';   // appended to console table if available
      if (homeyDevice?.capabilities?.includes('measure_battery')) {
        const battVal = homeyDevice.capabilitiesObj?.measure_battery?.value;
        if (typeof battVal === 'number') {
          batteryMsg = `${battVal}%`;
          if (battVal <= BatteryThreshold) {
            lowBatteryCount++;
            DevicesLowBattery.push(`${node.name || '(unknown)'} ${battVal}%`);
          }
        }
      }
      if (homeyDevice?.capabilities?.includes('alarm_battery')) {
        const alarmVal = !!(homeyDevice.capabilitiesObj?.alarm_battery?.value === true);
        if (alarmVal) {
          // treat as low battery (in addition to % check)
          if (!DevicesLowBattery.some(x => x.startsWith(`${node.name || '(unknown)'} `))) {
            lowBatteryCount++;
            DevicesLowBattery.push(`${node.name || '(unknown)'} ALARM`);
          }
          if (batteryMsg === 'N/A') batteryMsg = 'ALARM';
        } else if (batteryMsg === 'N/A') {
          batteryMsg = 'OK';
        }
      }

      /* ---------- update type counters -------------------------- */
      if (typeLower === 'router')     routerCount++;
      if (typeLower === 'enddevice')  endDevCount++;

      /* ---------- keep row for later printing ------------------- */
      const rowObj = {
        name   : node.name || '(unknown)',
        date   : dateFormatted,
        type   : typeLower || 'unknown',
        origin : 'Zigbee',
        batt   : batteryMsg,
        status : statusMark
      };
      (statusMark === '(OK)' ? okRows : nokRows).push(rowObj);
    }

    /* ---------- iterate Xiaomi devices (ownerUri: com.xiaomi-miio) ------ */
    for (const dev of allDevices) {
      if (dev.ownerUri !== 'homey:app:com.xiaomi-miio') continue;

      totalCount++;

      const zoneName = dev?.zone ? zoneMap[dev.zone] : null;

      // Skip excluded zones
      if (zoneName && EXCLUDED_ZONES.some(z => z.toLowerCase() === zoneName.toLowerCase())) {
        continue;
      }

      // Apply class & name filters (same as Zigbee)
      if (!INCLUDED_DEVICE_CLASSES_REGEX.test(dev.class || '')) continue;
      if (!INCLUDED_DEVICE_NAME_PATTERN.test(dev.name || ''))    continue;
      if (EXCLUDED_DEVICE_NAME_PATTERN.test(dev.name || ''))     continue;

      // Determine latest capability lastUpdated as "last seen"
      let latestUpdate = null;
      if (dev.capabilitiesObj) {
        for (const cap of Object.values(dev.capabilitiesObj)) {
          if (cap && cap.lastUpdated) {
            const t = new Date(cap.lastUpdated);
            if (!latestUpdate || t > latestUpdate) latestUpdate = t;
          }
        }
      }
      const latestTs = latestUpdate ? latestUpdate.getTime() : NaN;
      const dateFormatted = Number.isNaN(latestTs) ? NO_UPDATES_LABEL : formatDate(latestUpdate);

      let statusMark = '(OK)';
      let reason = '';

      if (Number.isNaN(latestTs)) {
        statusMark = '(NOK)';
        reason = 'no updates';
        notReportingCount++;
        // type label from class (sensor/light/socket/…)
        const typeLbl = (dev.class || 'unknown').toLowerCase();
        DevicesNotReporting.push(`${dev.name || '(unknown)'} ${dateFormatted} (${typeLbl}) - NOK: ${reason}`);
      } else {
        const age = Date.now() - latestTs;
        if (age >= thresholdInMillis) {
          statusMark = '(NOK)';
          reason = `threshold ${NotReportingThreshold}h`;
          const typeLbl = (dev.class || 'unknown').toLowerCase();
          notReportingCount++;
          DevicesNotReporting.push(`${dev.name || '(unknown)'} ${dateFormatted} (${typeLbl}) - NOK: ${reason}`);
        }
      }

      // Battery check (same logic)
      let batteryMsg = 'N/A';
      if (dev?.capabilities?.includes('measure_battery')) {
        const battVal = dev.capabilitiesObj?.measure_battery?.value;
        if (typeof battVal === 'number') {
          batteryMsg = `${battVal}%`;
          if (battVal <= BatteryThreshold) {
            lowBatteryCount++;
            DevicesLowBattery.push(`${dev.name || '(unknown)'} ${battVal}%`);
          }
        }
      }
      if (dev?.capabilities?.includes('alarm_battery')) {
        const alarmVal = !!(dev.capabilitiesObj?.alarm_battery?.value === true);
        if (alarmVal) {
          if (!DevicesLowBattery.some(x => x.startsWith(`${dev.name || '(unknown)'} `))) {
            lowBatteryCount++;
            DevicesLowBattery.push(`${dev.name || '(unknown)'} ALARM`);
          }
          if (batteryMsg === 'N/A') batteryMsg = 'ALARM';
        } else if (batteryMsg === 'N/A') {
          batteryMsg = 'OK';
        }
      }

      const rowObj = {
        name   : dev.name || '(unknown)',
        date   : dateFormatted,
        type   : (dev.class || 'unknown').toLowerCase(),
        origin : 'Xiaomi',
        batt   : batteryMsg,
        status : statusMark
      };
      (statusMark === '(OK)' ? okRows : nokRows).push(rowObj);
    }

    /* ---------- console output ---------------------------------- */
    console.log(`${totalCount} Zigbee device(s) scanned (incl. Xiaomi).`);
    console.log(`OK :  ${okRows.length}`);
    console.log(`NOK:  ${nokRows.length}`);
    console.log(`Router: ${routerCount}, EndDevice: ${endDevCount}`);
    console.log('---------------------------------------------');

    const header = [
      padRight('#', 3),
      padRight('Device Name', 35),
      padRight('Last Seen', 20),
      padRight('Type', 10),
      padRight('Origin', 8),
      padRight('Batt', 5),
      padRight('Status', 6)
    ].join(' ');

    function printRows(arr) {
      arr.forEach((r, i) => {
        console.log([
          padRight(i + 1, 3),
          padRight(r.name, 35),
          padRight(r.date, 20),
          padRight(r.type, 10),
          padRight(r.origin, 8),
          padRight(r.batt, 5),
          padRight(r.status, 6)
        ].join(' '));
      });
    }

    if (okRows.length) {
      console.log(`\nOK Zigbee device(s): ${okRows.length}`);
      console.log(header);
      console.log('-'.repeat(header.length));
      printRows(okRows);
    }

    if (nokRows.length) {
      console.log(`\nNOK Zigbee device(s): ${nokRows.length}`);
      console.log(header);
      console.log('-'.repeat(header.length));
      printRows(nokRows);
    }

    if (lowBatteryCount) {
      console.log(`\nLow-battery device(s) (≤${BatteryThreshold}% or alarm): ${lowBatteryCount}`);
      DevicesLowBattery.forEach((d, i) => console.log(`${i + 1}. ${d}`));
    }

    console.log('---------------------------------------------\n');

    /* ---------- Flow tags --------------------------------------- */
    await tag('InvalidatedDevices', DevicesNotReporting.join('\n'));
    await tag('notReportingCount', notReportingCount);
    await tag('LowBatteryDevices',  DevicesLowBattery.join('\n'));
    await tag('lowBatteryCount',    lowBatteryCount);

    /* ---------- return value for script runner ------------------ */
    const result =
      `Not Reporting Count: ${notReportingCount}\n` +
      `Low Battery Count:   ${lowBatteryCount}\n` +
      `Devices Not Reporting:\n${DevicesNotReporting.join('\n')}` +
      (DevicesLowBattery.length ? `\nDevices Low Battery:\n${DevicesLowBattery.join('\n')}` : '');
    return result;

  } catch (err) {
    console.error('Failed: getting Zigbee/Xiaomi state', err);
    await tag('InvalidatedDevices', '');
    await tag('notReportingCount', -1);
    await tag('LowBatteryDevices', '');
    await tag('lowBatteryCount', -1);
    return 'Error while retrieving Zigbee/Xiaomi state';
  }
}

/* ------------------------------- run ----------------------------- */

const myTag = await checkZigbeeLastSeen();
return myTag;
1 Like

I have some Nous devices with an quite old (thus invalid) last-updated timestamp. I checked then all capabilities with timestamp and found they have much more recent ones then the last seen timestamp suggests.

FYI, on top of A script to check sensor last update - #25 by Sharkys

and A script to check sensor last update - #39 by Sharkys ,

I created new wake-up scripts for Zigbee lights - seems to be working fine for me without side effects, so feel free to test.

Use case : ensure light is alive (by turning “switched off” light off, “switched on” light again on etc), if failed - report

LOG example :

CONFIG: {
  includeTransports: [ 'zigbee' ],
  includeClasses: [ 'light' ],
  excludeClasses: [
    'sensor',     'button',
    'remote',     'socket',
    'other',      'curtain',
    'blind',      'valve',
    'thermostat', 'fan',
    'lock'
  ],
  includeNamePatterns: [],
  excludeNamePatterns: [ 'Unif', 'Christma', 'group' ],
  verboseSkipLogs: false
}

NOK: Spot Ambiance 3 - light [69.38m] (Last: 24-09-2025, 16:00:02; Reason: threshold 60m)
WAKE try: Spot Ambiance 3 → set onoff=false (noop poke)
NOK: Spot Ambiance 1 - light [69.38m] (Last: 24-09-2025, 16:00:02; Reason: threshold 60m)
WAKE try: Spot Ambiance 1 → set onoff=false (noop poke)
NOK: Lucca Outdoor Pedestal left - light [66.96m] (Last: 24-09-2025, 16:02:27; Reason: threshold 60m)
WAKE try: Lucca Outdoor Pedestal left → set onoff=false (noop poke)
OK:  Ensis Pendant - Lower light - light [0.88m] (Last: 24-09-2025, 17:08:32)
NOK: Spot Ambiance 2 - light [69.38m] (Last: 24-09-2025, 16:00:02; Reason: threshold 60m)
WAKE try: Spot Ambiance 2 → set onoff=false (noop poke)
NOK: Kitchen Light - light [69.38m] (Last: 24-09-2025, 16:00:02; Reason: threshold 60m)
WAKE try: Kitchen Light → set onoff=false (noop poke)
NOK: Welcome Outdoor Floodlight - light [69.38m] (Last: 24-09-2025, 16:00:02; Reason: threshold 60m)
WAKE try: Welcome Outdoor Floodlight → set onoff=false (noop poke)
WAKE ok: Bulb 1600 Lumen White
WAKE ok: Spot Ambiance 1
WAKE ok: Spot Ambiance 3
WAKE ok: Welcome Outdoor Floodlight
WAKE ok: Spot Ambiance 2
WAKE ok: Lucca Outdoor Pedestal left
WAKE ok: Kitchen Light
WAKE ok: Lucca Outdoor Pedestal right

Validation: waiting 5s for capability updates…
Validation: timer API not available in this environment → skipping wait.

RECOVERED: Spot Ambiance 3 (after WAKE) [0.01m] (Last: 24-09-2025, 17:09:26)
RECOVERED: Spot Ambiance 1 (after WAKE) [0.01m] (Last: 24-09-2025, 17:09:26)
RECOVERED: Lucca Outdoor Pedestal left (after WAKE) [0.01m] (Last: 24-09-2025, 17:09:26)
RECOVERED: Spot Ambiance 2 (after WAKE) [0.01m] (Last: 24-09-2025, 17:09:26)
RECOVERED: Kitchen Light (after WAKE) [0.01m] (Last: 24-09-2025, 17:09:26)
RECOVERED: Bulb 1600 Lumen White (after WAKE) [0.01m] (Last: 24-09-2025, 17:09:26)
RECOVERED: Lucca Outdoor Pedestal right (after WAKE) [0.01m] (Last: 24-09-2025, 17:09:26)
RECOVERED: Welcome Outdoor Floodlight (after WAKE) [0.01m] (Last: 24-09-2025, 17:09:26)

---------------------------------------------

Summary (threshold 60 minutes; validation 5 seconds):
OK devices:         2
NOK devices:        0
WAKE attempted:     8
WAKE succeeded:     8

Failed devices list: (none)

Flow example :

2 Likes

Nice script thank you!

1 Like