Skip to content

Added Xiaomi App related Zigbee devices #1

@wdool

Description

@wdool

// Zigbee + Xiaomi Last-Seen & Battery Monitor — v0.1
// • Keeps original Zigbee behaviour & formatting 1:1 from shaarkys 0.9 version
// • 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;

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions