Ok, I’ll share this one, it has not been cleaned up and there may be dragons…:
[{"id":"cb4eadb4.7c938","type":"mqtt in","z":"1ae1a884.3eb117","name":"","topic":"zigbee2mqtt/#","qos":"2","datatype":"auto","broker":"756cfdc4.e690b4","nl":false,"rap":true,"rh":0,"inputs":0,"x":130,"y":140,"wires":[["a9c0e5d5.3640a8","bc07260b.c393e8"]]},{"id":"a9c0e5d5.3640a8","type":"function","z":"1ae1a884.3eb117","name":"Check Device Health & Save Global State","func":"// -- DO NOT EDIT BELOW THIS LINE --\n// Actual code\nconst defaultTimeout = context.get(\"defaultTimeout\");\nconst deviceOverrides = context.get(\"deviceOverrides\");\n\nif(msg.topic.startsWith(\"zigbee2mqtt/bridge/\")) {\n // These messages we don't need, in general, but renames are important\n if(msg.topic == \"zigbee2mqtt/bridge/response/device/rename\") {\n node.log(msg);\n const payloadObj = JSON.parse(msg.payload);\n flow.set(\"dh*_\" + encodeURI(payloadObj.data.to.replace(/\\s/g, '_')), flow.get(\"dh*_\" + encodeURI(payloadObj.data.from.replace(/\\s/g, '_'))));\n flow.set(\"dh*_\" + encodeURI(payloadObj.data.from.replace(/\\s/g, '_')), undefined);\n flow.set(\"dh*_\" + encodeURI(payloadObj.data.from), undefined);\n }\n return null;\n} else {\n if(msg.topic == \"PING\" || msg.topic == \"STATUS\") {\n // Cleanup code, only used temporarily\n // for(const savedDeviceNameEncoded of global.keys()) {\n // if(savedDeviceNameEncoded.includes('%20')) {\n // global.set(savedDeviceNameEncoded, undefined);\n // }\n // }\n let deviceStatus = {};\n let deviceOffline = {};\n let allOnline = true;\n const now = new Date().getTime();\n // const now = new Date().getTime();\n msg.now = now;\n const lastSeenTime = new Date(null);\n for(const deviceNameEncoded of flow.keys()) {\n if(deviceNameEncoded.startsWith(\"dh*_\")) {\n if(deviceNameEncoded.includes(\"%20\")) {\n // This is an old and incorrect property\n flow.set(deviceNameEncoded, undefined);\n } else {\n const lastSeen = flow.get(deviceNameEncoded);\n const deviceName = decodeURI(deviceNameEncoded.substring(4).replace(/_/g, ' '));\n if(deviceName.startsWith(\"0x\")) {\n // This is an unnamed device, ignore it\n flow.set(deviceNameEncoded, undefined);\n \n } else if(lastSeen !== null) {\n let timeout = defaultTimeout;\n if(deviceOverrides.hasOwnProperty(deviceName)) timeout = deviceOverrides[deviceName];\n const timeLapsedNum = Math.round((now - lastSeen) / 1000)\n msg.timeLapsedNum = timeLapsedNum;\n lastSeenTime.setSeconds(timeLapsedNum);\n const timeLapsedStr = lastSeenTime.toISOString().substr(11, 8);\n if(timeout == -1) {\n deviceStatus[deviceName] = \"SKIPPED: \" + timeLapsedNum;\n } else if(timeLapsedNum < timeout) {\n deviceStatus[deviceName] = \"ONLINE: \" + timeLapsedNum + \", \" + lastSeen;\n } else {\n deviceStatus[deviceName] = \"OFFLINE: \" + timeLapsedNum + \", \" + lastSeen;\n // Clean up offline devices\n global.set(\"zigbee_motion_\" + encodeURI(deviceName.replace(/\\s/g, '_')), undefined);\n global.set(\"zigbee_contact_\" + encodeURI(deviceName.replace(/\\s/g, '_')), undefined);\n global.set(\"zigbee_\" + encodeURI(deviceName.replace(/\\s/g, '_')), undefined);\n allOnline = false;\n deviceOffline[deviceName] = timeLapsedNum;\n }\n }\n }\n }\n }\n if(allOnline) {\n node.status({fill:\"green\",shape:\"dot\",text:\"online\"});\n } else {\n node.status({fill:\"red\",shape:\"ring\",text:\"offline\"});\n }\n msg.payload = deviceStatus;\n if(Object.keys(deviceOffline).length > 0) {\n msg.deviceOffline = deviceOffline;\n } else {\n msg.deviceOffline = null;\n }\n if(msg.topic == \"PING\") {\n if(msg.deviceOffline === null) {\n return null;\n } else {\n msg.topic = \"DeviceOffline\";\n }\n }\n return msg;\n } else {\n const deviceNameRegExp = /zigbee2mqtt\\/([^\\/]*)/;\n const deviceNameExtendedRegExp = /zigbee2mqtt\\/([^\\/]*)\\/([^\\/]*)/;\n let deviceName = deviceNameRegExp.exec(msg.topic);\n let deviceNameExtended = deviceNameExtendedRegExp.exec(msg.topic);\n if(deviceName !== null && deviceName.length == 2 && (deviceNameExtended === null || deviceNameExtended.length < 3)) {\n const deviceNameEncoded = encodeURI(deviceName[1].replace(/\\s/g, '_'));\n if(msg.payload != '') {\n let payload = JSON.parse(msg.payload);\n if(payload.hasOwnProperty(\"last_seen\")) {\n flow.set(\"dh*_\" + deviceNameEncoded, payload.last_seen);\n }\n // Save the device state to global variables\n if(payload.hasOwnProperty(\"occupancy\")) {\n global.set(\"zigbee_motion_\" + deviceNameEncoded, payload.occupancy);\n // Also make a combined state available\n if(payload.occupancy == true) {\n global.set(\"zigbee_any_motion\", true);\n } else {\n let motion = false;\n for(const savedDeviceNameEncoded of global.keys()) {\n if(savedDeviceNameEncoded.startsWith(\"zigbee_motion_\") && global.get(savedDeviceNameEncoded) == true) {\n motion = true;\n break;\n }\n }\n global.set(\"zigbee_any_motion\", motion);\n }\n }\n if(payload.hasOwnProperty(\"contact\")) {\n global.set(\"zigbee_contact_\" + deviceNameEncoded, payload.contact);\n }\n global.set(\"zigbee_\" + deviceNameEncoded, payload);\n }\n }\n }\n}\nreturn null;","outputs":1,"noerr":0,"initialize":"// Code added here will be run once\n// whenever the node is started.\n// Settings\n// IMPORTANT: Device NAMES can't contain a forward slash \"/\"!!!\nconst defaultTimeout = 2 * 3600; // 4 hours = 4 * 3600 seconds\nconst deviceOverrides = {\n \"EXAMPLE Repeater - Balcony (IKEA)\": -1, // -1 = Do not check for Device Health\n \"EXAMPLE SillySensor\": 8 * 60 * 60, // Checks in once every 8 hours\n \"Entrance - Doorbell Button OLD\": -1,\n \"Bathroom - Motion Shower\": 21 * 3600,\n \"Undefined - Unused Sonoff Contact\": -1,\n \"Kitchen - Fridge Temperature Top\": 8 * 3600,\n \"Bathroom - Motion Basin\": 8 * 3600,\n \"Kitchen - Motion (Ceiling)\": 8 * 3600,\n // \"Curtain - Livingroom\": 24 * 3600, \n // \"Curtain - Bedroom\": 24 * 3600,\n // \"Button - Study Alice (Aqara)\": 4 * 3600,\n // \"Balcony - Contact Door\": 4 * 3600,\n // \"Study - GU10 Gledopto\": 24 * 3600,\n // \"Livingroom - Yellow Lamp\": 24*3600,\n \"Study - Bulb (IKEA 1 color)\": -1,\n // \"Study - Alice Lamp (IKEA)\": -1,\n \"Study - TuYa Motion\": -1,\n \"AqaraCubeUnassigned2\": -1,\n \"T&H - Study (Aqara T1)\": -1,\n // \"Livingroom - Coffee Machine Water Leak\": -1,\n \"Kitchen - Main Gas Valve\": 12*3600,\n // \"Kitchen - Sink Water Sensor\": 4*3600,\n \"Kitchen - Contact Door\": 4*3600,\n \"Corridor - Temp & Humidity\": 7*3600,\n // \"Entrance - Motion\": -1\n \"Study - iHorn Motion\": -1,\n \"StudyAqaraCube\": -1,\n}\n// -- DO NOT EDIT BELOW THIS LINE --\n// Actual code\ncontext.set(\"defaultTimeout\", defaultTimeout);\ncontext.set(\"deviceOverrides\", deviceOverrides);\nlet deviceStatus = {};\nlet deviceOffline = {};\nlet allOnline = true;\n// const now = new Date().getTime();\nconst now = new Date().getTime();\nfor(const deviceNameEncoded of flow.keys()) {\n if(deviceNameEncoded.startsWith(\"dh*_\")) {\n if(deviceNameEncoded.includes(\"%20\")) {\n // This is an old and incorrect property\n flow.set(deviceNameEncoded, undefined);\n } else {\n const lastSeen = flow.get(deviceNameEncoded);\n if(lastSeen !== null) {\n const deviceName = decodeURI(deviceNameEncoded.substring(4).replace(/_/g, ' '));\n let timeout = defaultTimeout;\n if(deviceOverrides.hasOwnProperty(deviceName)) timeout = deviceOverrides[deviceName];\n if(timeout == -1) {\n deviceStatus[deviceName] = \"SKIPPED\";\n } else if((now - lastSeen) < (timeout * 1000)) {\n deviceStatus[deviceName] = \"ONLINE\";\n } else {\n deviceStatus[deviceName] = \"OFFLINE\";\n allOnline = false;\n deviceOffline[deviceName] = Math.round((now - lastSeen) / 1000);\n }\n }\n }\n }\n}\nif(allOnline) {\n node.status({fill:\"green\",shape:\"dot\",text:\"online\"});\n} else {\n node.status({fill:\"red\",shape:\"ring\",text:\"offline\"});\n}","finalize":"","libs":[],"x":460,"y":140,"wires":[["bd829a9c.bf1978"]]},{"id":"37778eb9.f7dc12","type":"debug","z":"1ae1a884.3eb117","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":950,"y":200,"wires":[]},{"id":"5a25e056.2c10d","type":"inject","z":"1ae1a884.3eb117","name":"Check Health Ping","props":[{"p":"topic","vt":"str"},{"p":"payload"}],"repeat":"300","crontab":"","once":true,"onceDelay":"10","topic":"PING","payload":"PING","payloadType":"str","x":160,"y":260,"wires":[["a9c0e5d5.3640a8"]]},{"id":"2e3ae0b7.cf33c","type":"inject","z":"1ae1a884.3eb117","name":"Get Health Status","props":[{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":"10","topic":"STATUS","payloadType":"str","x":140,"y":220,"wires":[["a9c0e5d5.3640a8"]]},{"id":"bd829a9c.bf1978","type":"switch","z":"1ae1a884.3eb117","name":"","property":"topic","propertyType":"msg","rules":[{"t":"eq","v":"DeviceOffline","vt":"str"},{"t":"neq","v":"DeviceOffline","vt":"str"}],"checkall":"true","repair":false,"outputs":2,"x":730,"y":140,"wires":[["c1ade6d8.4bf888"],["37778eb9.f7dc12","5356c370.f1d45c"]]},{"id":"814eaffd.df059","type":"function","z":"1ae1a884.3eb117","name":"Format Device Offline Message","func":"msg.topic = \"Device Offline!\";\nlet payload = \"Zigbee Device Health\\n\";\npayload += \"--------------------\\n\";\nconst lastSeenTime = new Date(null);\nfor(const [offlineDevice, lastSeen] of Object.entries(msg.deviceOffline)) {\n lastSeenTime.setSeconds(lastSeen);\n payload += offlineDevice + \": \" + lastSeenTime.toISOString().substr(11, 8) + '\\n';\n}\nmsg.payload = payload;\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1270,"y":140,"wires":[["37778eb9.f7dc12","bdb48a5f.4eed18","5356c370.f1d45c"]]},{"id":"bc07260b.c393e8","type":"debug","z":"1ae1a884.3eb117","name":"MQTT debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":390,"y":100,"wires":[]},{"id":"5356c370.f1d45c","type":"debug","z":"1ae1a884.3eb117","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":970,"y":240,"wires":[]},{"id":"c1ade6d8.4bf888","type":"throttle","z":"1ae1a884.3eb117","name":"Limit Offline Messages","throttleType":"time","timeLimit":"30","timeLimitType":"minutes","countLimit":0,"blockSize":0,"locked":false,"x":960,"y":140,"wires":[["37778eb9.f7dc12","814eaffd.df059","5356c370.f1d45c"]]},{"id":"756cfdc4.e690b4","type":"mqtt-broker","name":"CORE MQTT NON-CLEAN","broker":"10.10.2.1","port":"1883","clientid":"CORE-MQTT-NON-CLEAN1","autoConnect":true,"usetls":false,"protocolVersion":"4","keepalive":"60","cleansession":false,"birthTopic":"","birthQos":"0","birthPayload":"","birthMsg":{},"closeTopic":"","closeQos":"0","closePayload":"","closeMsg":{},"willTopic":"","willQos":"0","willPayload":"","willMsg":{},"sessionExpiry":""}]
Do check the “On Start” tab in the function node. This is a function-based flow which has not been adapted to be shared, but maybe it’ll be useful.