[ { "id": "e7a50000000000a1", "type": "tab", "label": "Shelly Fleet Dashboard", "disabled": false, "info": "Shelly fleet dashboard for FlowFuse Dashboard 2.0 (@flowfuse/node-red-dashboard), built on the outputs of the shelly-admin nodes.\n\nSETUP\n1. Select the seven link-out nodes on the left (\"Shelly → Dashboard …\"), cut them (Ctrl+X), paste them (Ctrl+V) into the tab that contains your shelly-admin nodes and wire:\n Discovery output 1 → Inventory, output 2 → Events, output 3 → Errors\n Monitor output 1 → Health, output 2 → Alerts, output 3 → Errors\n Maintenance output 1 → Maintenance result, output 2 → Maintenance progress, output 3 → Errors\n2. Move the three link-in nodes on the right (\"Dashboard command → …\") into the same tab and connect each to the INPUT of the matching node.\n Link connections are kept when cutting and pasting.\n3. Deploy and open /dashboard/shellies, then click \"Check inventory\".\n\nEXISTING DASHBOARD 2.0\nOnly one ui-base is supported per Node-RED instance. If you already use Dashboard 2.0, delete the imported ui-base \"Shelly Admin\" from the configuration nodes and assign the page \"Shellies\" to your own dashboard.\n\nSWITCHING\nRelays and plugs (up to 4 channels) are read and switched directly via the local Shelly API:\n Gen2+: /rpc/Shelly.GetStatus, /rpc/Shelly.GetConfig, /rpc/Switch.Set\n Gen1: /status, /settings, /relay/N?turn=on|off\nDevices of class \"critical\" need a second click. Password-protected devices: set authentication in the \"Shelly HTTP\" node (Digest for Gen2+, Basic for Gen1, user admin).\n\nSAFETY\nFirmware updates and reboots only while maintenance is unlocked in the dashboard (10 min), for selected devices only, max. 5 per command, two-step confirmation; the maintenance node receives confirm:true. Firmware check and dry run are read-only.\n\nPERSISTENCE\nModel and 24 h history use the \"file\" context store when configured (fallback: default store)." }, { "id": "e7a5000000000001", "type": "comment", "z": "e7a50000000000a1", "name": "① Move the link-out nodes to your shelly-admin nodes and wire outputs 1/2/3 (see tab info)", "info": "", "x": 380, "y": 40, "wires": [] }, { "id": "e7a5000000000010", "type": "link out", "z": "e7a50000000000a1", "name": "Shelly → Dashboard Inventory", "mode": "link", "links": [ "e7a5000000000020" ], "x": 180, "y": 100, "wires": [], "l": true, "info": "Connect to: Discovery output 1" }, { "id": "e7a5000000000020", "type": "link in", "z": "e7a50000000000a1", "name": "Dashboard Inventory", "links": [ "e7a5000000000010" ], "x": 480, "y": 100, "wires": [ [ "e7a5000000000030" ] ], "l": true }, { "id": "e7a5000000000030", "type": "change", "z": "e7a50000000000a1", "name": "source: inventory", "rules": [ { "t": "set", "p": "source", "pt": "msg", "to": "inventory", "tot": "str" } ], "action": "", "property": "", "from": "", "to": "", "reg": false, "x": 690, "y": 100, "wires": [ [ "e7a5000000000050" ] ] }, { "id": "e7a5000000000011", "type": "link out", "z": "e7a50000000000a1", "name": "Shelly → Dashboard Events", "mode": "link", "links": [ "e7a5000000000021" ], "x": 180, "y": 150, "wires": [], "l": true, "info": "Connect to: Discovery output 2" }, { "id": "e7a5000000000021", "type": "link in", "z": "e7a50000000000a1", "name": "Dashboard Events", "links": [ "e7a5000000000011" ], "x": 480, "y": 150, "wires": [ [ "e7a5000000000031" ] ], "l": true }, { "id": "e7a5000000000031", "type": "change", "z": "e7a50000000000a1", "name": "source: events", "rules": [ { "t": "set", "p": "source", "pt": "msg", "to": "events", "tot": "str" } ], "action": "", "property": "", "from": "", "to": "", "reg": false, "x": 690, "y": 150, "wires": [ [ "e7a5000000000050" ] ] }, { "id": "e7a5000000000012", "type": "link out", "z": "e7a50000000000a1", "name": "Shelly → Dashboard Health", "mode": "link", "links": [ "e7a5000000000022" ], "x": 180, "y": 200, "wires": [], "l": true, "info": "Connect to: Monitor output 1" }, { "id": "e7a5000000000022", "type": "link in", "z": "e7a50000000000a1", "name": "Dashboard Health", "links": [ "e7a5000000000012" ], "x": 480, "y": 200, "wires": [ [ "e7a5000000000032" ] ], "l": true }, { "id": "e7a5000000000032", "type": "change", "z": "e7a50000000000a1", "name": "source: health", "rules": [ { "t": "set", "p": "source", "pt": "msg", "to": "health", "tot": "str" } ], "action": "", "property": "", "from": "", "to": "", "reg": false, "x": 690, "y": 200, "wires": [ [ "e7a5000000000051" ] ] }, { "id": "e7a5000000000013", "type": "link out", "z": "e7a50000000000a1", "name": "Shelly → Dashboard Alerts", "mode": "link", "links": [ "e7a5000000000023" ], "x": 180, "y": 250, "wires": [], "l": true, "info": "Connect to: Monitor output 2" }, { "id": "e7a5000000000023", "type": "link in", "z": "e7a50000000000a1", "name": "Dashboard Alerts", "links": [ "e7a5000000000013" ], "x": 480, "y": 250, "wires": [ [ "e7a5000000000033" ] ], "l": true }, { "id": "e7a5000000000033", "type": "change", "z": "e7a50000000000a1", "name": "source: alerts", "rules": [ { "t": "set", "p": "source", "pt": "msg", "to": "alerts", "tot": "str" } ], "action": "", "property": "", "from": "", "to": "", "reg": false, "x": 690, "y": 250, "wires": [ [ "e7a5000000000050" ] ] }, { "id": "e7a5000000000014", "type": "link out", "z": "e7a50000000000a1", "name": "Shelly → Dashboard Maintenance result", "mode": "link", "links": [ "e7a5000000000024" ], "x": 180, "y": 300, "wires": [], "l": true, "info": "Connect to: Maintenance output 1" }, { "id": "e7a5000000000024", "type": "link in", "z": "e7a50000000000a1", "name": "Dashboard Maintenance result", "links": [ "e7a5000000000014" ], "x": 480, "y": 300, "wires": [ [ "e7a5000000000034" ] ], "l": true }, { "id": "e7a5000000000034", "type": "change", "z": "e7a50000000000a1", "name": "source: maint", "rules": [ { "t": "set", "p": "source", "pt": "msg", "to": "maint", "tot": "str" } ], "action": "", "property": "", "from": "", "to": "", "reg": false, "x": 690, "y": 300, "wires": [ [ "e7a5000000000050" ] ] }, { "id": "e7a5000000000015", "type": "link out", "z": "e7a50000000000a1", "name": "Shelly → Dashboard Maintenance progress", "mode": "link", "links": [ "e7a5000000000025" ], "x": 180, "y": 350, "wires": [], "l": true, "info": "Connect to: Maintenance output 2" }, { "id": "e7a5000000000025", "type": "link in", "z": "e7a50000000000a1", "name": "Dashboard Maintenance progress", "links": [ "e7a5000000000015" ], "x": 480, "y": 350, "wires": [ [ "e7a5000000000035" ] ], "l": true }, { "id": "e7a5000000000035", "type": "change", "z": "e7a50000000000a1", "name": "source: progress", "rules": [ { "t": "set", "p": "source", "pt": "msg", "to": "progress", "tot": "str" } ], "action": "", "property": "", "from": "", "to": "", "reg": false, "x": 690, "y": 350, "wires": [ [ "e7a5000000000050" ] ] }, { "id": "e7a5000000000016", "type": "link out", "z": "e7a50000000000a1", "name": "Shelly → Dashboard Errors", "mode": "link", "links": [ "e7a5000000000026" ], "x": 180, "y": 400, "wires": [], "l": true, "info": "Connect to: Output 3 of all nodes" }, { "id": "e7a5000000000026", "type": "link in", "z": "e7a50000000000a1", "name": "Dashboard Errors", "links": [ "e7a5000000000016" ], "x": 480, "y": 400, "wires": [ [ "e7a5000000000036" ] ], "l": true }, { "id": "e7a5000000000036", "type": "change", "z": "e7a50000000000a1", "name": "source: errors", "rules": [ { "t": "set", "p": "source", "pt": "msg", "to": "errors", "tot": "str" } ], "action": "", "property": "", "from": "", "to": "", "reg": false, "x": 690, "y": 400, "wires": [ [ "e7a5000000000050" ] ] }, { "id": "e7a5000000000051", "type": "function", "z": "e7a50000000000a1", "name": "Enrich power from health", "func": "/**\n * Enrich power values from health data\n *\n * Depending on device and version, power is reported per channel\n * (Gen2+: \"switch:0\".apower, \"pm1:0\".apower, \"em:0\".total_act_power,\n * \"em1:0\".act_power) or in lists (Gen1: meters[].power, emeters[].power).\n *\n * This function searches each device recursively and writes powerW\n * (sum of all channels), plus voltage, current, energy and output state\n * when available. Baselines, history and observations are skipped.\n *\n * If nothing is found, powerW stays untouched (never an artificial 0).\n *\n * Output 1: -> Update Shelly model\n * Output 2: diagnostics – first device without a power value\n */\n\n// Per-channel instantaneous power\nconst POWER_KEYS = [\"apower\", \"act_power\", \"power\", \"powerW\", \"activePowerW\", \"activePower\"];\n// Total power fields (take precedence to avoid double counting)\nconst TOTAL_KEYS = [\"total_act_power\", \"totalActivePower\", \"totalPowerW\"];\n// Sections that do not contain live values\nconst SKIP = /^(baseline|baselines|history|observations|findings|peer|peers|stats|statistics|median|mad|threshold|thresholds|limits|settings|config|policy|policies|cards|summary|previous|aggregate|buckets)$/i;\n\nfunction num(v) {\n if (v && typeof v === \"object\" && \"value\" in v) v = v.value;\n if (v === null || v === undefined || v === \"\" || typeof v === \"boolean\") return null;\n const n = Number(v);\n return Number.isFinite(n) ? n : null;\n}\n\nfunction scan(obj, depth, found) {\n if (!obj || typeof obj !== \"object\" || depth > 6) return;\n\n for (const [key, val] of Object.entries(obj)) {\n\n if (SKIP.test(key)) continue;\n\n if (TOTAL_KEYS.includes(key) && num(val) !== null) found.total.push(num(val));\n else if (POWER_KEYS.includes(key) && num(val) !== null) found.power.push(num(val));\n else if ((key === \"voltage\" || key === \"voltageV\") && num(val) !== null) found.voltage.push(num(val));\n else if ((key === \"current\" || key === \"currentA\") && num(val) !== null) found.current.push(num(val));\n else if (key === \"aenergy\" && val && num(val.total) !== null) found.energy.push(num(val.total));\n else if ((key === \"output\" || key === \"ison\") && typeof val === \"boolean\") found.output.push(val);\n else if (val && typeof val === \"object\") scan(val, depth + 1, found);\n }\n}\n\nfunction enrich(dev) {\n if (!dev || typeof dev !== \"object\") return false;\n\n const found = { power: [], total: [], voltage: [], current: [], energy: [], output: [] };\n scan(dev, 0, found);\n\n let power = null;\n if (found.total.length) power = found.total[0];\n else if (found.power.length) power = found.power.reduce((a, b) => a + b, 0);\n\n if (power === null) return false;\n\n dev.powerW = Math.round(power * 10) / 10;\n if (dev.voltage == null && found.voltage.length) dev.voltage = found.voltage[0];\n if (dev.current == null && found.current.length) dev.current = found.current.reduce((a, b) => a + b, 0);\n if (dev.energyWh == null && found.energy.length) dev.energyWh = found.energy.reduce((a, b) => a + b, 0);\n if (dev.output == null && found.output.length) dev.output = found.output.some(Boolean);\n return true;\n}\n\nconst p = msg.payload || {};\nconst container = p.devices || (p.health && p.health.devices) || p.results || null;\nconst list = Array.isArray(container) ? container\n : container && typeof container === \"object\" ? Object.values(container) : [];\n\nlet withPower = 0;\nfor (const dev of list) if (enrich(dev)) withPower++;\n\nconst sample = list.find(d => d && d.powerW == null);\n\nnode.status({\n fill: withPower ? \"green\" : \"yellow\",\n shape: \"dot\",\n text: `${withPower}/${list.length} devices with power`\n});\n\nreturn [msg, sample ? { payload: sample, topic: \"Device without power value\" } : null];", "outputs": 2, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 920, "y": 150, "wires": [ [ "e7a5000000000050" ], [ "e7a5000000000068" ] ], "outputLabels": [ "→ Update Shelly model", "Diagnostics: device without power" ] }, { "id": "e7a5000000000068", "type": "debug", "z": "e7a50000000000a1", "name": "Diagnostics: device without power", "active": false, "tosidebar": true, "console": false, "tostatus": false, "complete": "payload", "targetType": "msg", "statusVal": "", "statusType": "auto", "x": 1200, "y": 100, "wires": [] }, { "id": "e7a5000000000050", "type": "function", "z": "e7a50000000000a1", "name": "Update Shelly model", "func": "/**\n * Update Shelly model\n *\n * Receives the outputs of the shelly-admin nodes and maintains a single\n * device model for the dashboard.\n *\n * msg.source (set by the change nodes):\n * \"inventory\" Discovery output 1\n * \"events\" Discovery output 2\n * \"health\" Monitor output 1 (incl. observations)\n * \"alerts\" Monitor output 2\n * \"maint\" Maintenance output 1 (result)\n * \"progress\" Maintenance output 2 (per-device progress)\n * \"errors\" Output 3 of all nodes\n *\n * Fields are read defensively from several possible paths.\n * Missing metrics stay null and are never turned into 0.\n */\n\nconst STORE = \"file\";\nconst HISTORY_STEP_MS = 15 * 60 * 1000; // one history point per 15 min\nconst HISTORY_MAX = 96; // 24 h\nconst MAX_ALERT_LOG = 200;\n\n// --------------------------------------------------\n// Context with fallback to the default store\n// --------------------------------------------------\n\nfunction load(key, fallback) {\n try {\n const v = flow.get(key, STORE);\n return v === undefined ? fallback : v;\n } catch (e) {\n const v = flow.get(key);\n return v === undefined ? fallback : v;\n }\n}\n\nfunction save(key, value) {\n try {\n flow.set(key, value, STORE);\n } catch (e) {\n flow.set(key, value);\n }\n}\n\n// --------------------------------------------------\n// Helpers\n// --------------------------------------------------\n\n// Read the first non-empty value from several paths (\"a.b.c\")\nfunction pick(obj, paths) {\n for (const path of paths) {\n let cur = obj;\n for (const part of path.split(\".\")) {\n if (cur === null || cur === undefined) {\n break;\n }\n cur = cur[part];\n }\n if (cur !== undefined && cur !== null && cur !== \"\") {\n return cur;\n }\n }\n return null;\n}\n\nfunction num(value) {\n if (value === null || value === undefined || value === \"\" || typeof value === \"boolean\") {\n return null;\n }\n if (typeof value === \"object\") {\n return num(value.value ?? value.last ?? value.current ?? null);\n }\n const n = Number(value);\n return Number.isFinite(n) ? n : null;\n}\n\nfunction bool(value) {\n if (value === true || value === false) {\n return value;\n }\n if ([\"online\", \"ok\", \"reachable\", \"found\"].includes(value)) {\n return true;\n }\n if ([\"offline\", \"unreachable\", \"error\", \"not-found\"].includes(value)) {\n return false;\n }\n return null;\n}\n\nfunction str(value) {\n if (value === null || value === undefined) {\n return null;\n }\n if (typeof value === \"object\") {\n return str(value.version ?? value.name ?? value.id ?? null);\n }\n const s = String(value).trim();\n return s ? s : null;\n}\n\n// Extract a device list from any payload shape\nfunction extractDevices(p) {\n\n if (!p || typeof p !== \"object\") {\n return [];\n }\n\n const candidates = [\n p.devices, p.inventory && p.inventory.devices, p.inventory,\n p.health && p.health.devices, p.health, p.results, p.deviceResults,\n p.items, p.device ? [p.device] : null\n ];\n\n for (let i = 0; i < candidates.length; i++) {\n\n const c = candidates[i];\n\n if (Array.isArray(c) && c.length && typeof c[0] === \"object\") {\n return c;\n }\n\n if (c && typeof c === \"object\" && !Array.isArray(c)) {\n\n // p.devices as object: key = device ID, take all entries.\n // Other candidates only if the entries look like devices.\n const strict = i !== 0;\n\n const values = Object.entries(c)\n .filter(([, v]) => v && typeof v === \"object\" && !Array.isArray(v)\n && (!strict || v.ip || v.address || v.model || v.id || v.deviceId || v.mac))\n .map(([k, v]) => ({ id: k, ...v }));\n\n if (values.length) {\n return values;\n }\n }\n }\n\n if (Array.isArray(p) && p.length && typeof p[0] === \"object\") {\n return p;\n }\n\n return [];\n}\n\nfunction extractObservations(p) {\n if (!p) {\n return [];\n }\n if (Array.isArray(p)) {\n return p.filter(o => o && typeof o === \"object\");\n }\n const list = p.observations || p.alerts || p.findings || p.items;\n if (Array.isArray(list)) {\n return list;\n }\n if (p.kind || p.severity || p.lifecycle) {\n return [p];\n }\n return [];\n}\n\n// --------------------------------------------------\n// Device type\n// --------------------------------------------------\n//\n// Order:\n// 1. Profile (\"cover\" on Plus/Pro 2PM in cover mode)\n// 2. Shelly model code – unambiguous\n// Gen2+: SN.. (Plus), SP.. (Pro), S3.. (Gen3), S4.. (Gen4);\n// the next two letters define the type:\n// SW switch · PM power meter · EM energy meter · PL plug\n// DM dimmer · DC RGBW · SN sensor · BL/BU light · SH/CV cover · IX/BT input\n// Gen1: SH* prefixes\n// 3. App name (e.g. \"MiniPMG3\", \"MiniEMG4\", \"Mini1PMG3\")\n// 4. Free-text fallback\n//\n// \"1PM\"/\"2PM\" = switch WITH metering (relay),\n// \"PM\"/\"EM\" without a preceding digit = pure meter.\n\nconst TYPE_BY_CODE = {\n SW: \"relay\", PM: \"energy\", EM: \"energy\", PL: \"plug\",\n DM: \"dimmer\", DC: \"light\", SN: \"sensor\",\n BL: \"light\", BU: \"light\", RG: \"light\", LD: \"light\",\n SH: \"cover\", CV: \"cover\", IX: \"input\", BT: \"input\"\n};\n\nconst GEN1_CODES = [\n [/^SHSW/, \"relay\"],\n [/^SHPLG|^SHPL/, \"plug\"],\n [/^SHEM/, \"energy\"],\n [/^SHDM|^SHDIMT/, \"dimmer\"],\n [/^SHRGBW|^SHBLB|^SHVIN|^SHBDUO|^SHCB|^SHCL/, \"light\"],\n [/^SHHT|^SHDW|^SHWT|^SHMOS|^SHGS|^SHSM|^SHSEN/, \"sensor\"],\n [/^SHIX|^SHBTN/, \"input\"],\n [/^SHUNI/, \"relay\"]\n];\n\nfunction classify(d) {\n\n const profile = String(d.profile || \"\").toLowerCase();\n\n if (profile.includes(\"cover\") || profile.includes(\"roller\")) {\n return \"cover\";\n }\n\n for (const raw of [d.modelCode, d.model]) {\n\n const code = String(raw || \"\").toUpperCase().trim();\n\n if (!code) {\n continue;\n }\n\n // i4 / i4DC report as SNSN-0024X / SNSN-0D24X -> inputs\n if (/^S[NP34]SN-(00|0D)24/.test(code)) {\n return \"input\";\n }\n\n const gen2 = code.match(/^S(?:N|P|3|4)([A-Z]{2})-/);\n\n if (gen2 && TYPE_BY_CODE[gen2[1]]) {\n return TYPE_BY_CODE[gen2[1]];\n }\n\n for (const [re, cat] of GEN1_CODES) {\n if (re.test(code)) {\n return cat;\n }\n }\n }\n\n const app = String(d.app || \"\").toLowerCase().replace(/[\\s_-]/g, \"\");\n\n if (app) {\n if (/cover/.test(app)) return \"cover\";\n if (/^(mini)?pm(mini)?(g\\d)?$|pmmini|minipm/.test(app)) return \"energy\";\n if (/em(g\\d)?$|^(pro)?3?em|miniem/.test(app)) return \"energy\";\n if (/plug/.test(app)) return \"plug\";\n if (/dimmer/.test(app)) return \"dimmer\";\n if (/rgbw|bulb|duo|light/.test(app)) return \"light\";\n if (/^i4|^i3/.test(app)) return \"input\";\n if (/ht$|^ht|flood|smoke|motion|door|window|blu/.test(app)) return \"sensor\";\n if (/\\dpm|plus1|pro\\d|mini1|1l|2l|uni|switch|relay/.test(app)) return \"relay\";\n }\n\n const text = [d.model, d.name].filter(Boolean).join(\" \").toLowerCase();\n\n if (/(cover|roller|shutter)/.test(text)) return \"cover\";\n if (/\\bplug\\b/.test(text)) return \"plug\";\n if (/dimmer/.test(text)) return \"dimmer\";\n if (/(rgbw|bulb|duo|vintage)/.test(text)) return \"light\";\n if (/(\\bpm ?mini\\b|\\bmini ?pm\\b|\\b3em\\b|\\bpro ?em\\b|\\bem\\b|energy meter)/.test(text)) return \"energy\";\n if (/(h&t|\\bht\\b|door|window|flood|smoke|motion|gas|sensor)/.test(text)) return \"sensor\";\n if (/(\\bi3\\b|\\bi4\\b|button)/.test(text)) return \"input\";\n if (/(\\d ?pm\\b|\\bplus 1\\b|\\bpro \\d|\\bmini\\b|\\b1l\\b|\\b2l\\b|\\buni\\b|switch|relay)/.test(text)) return \"relay\";\n\n return \"other\";\n}\n\n// --------------------------------------------------\n// Normalise one device\n// --------------------------------------------------\n\nfunction normalizeDevice(raw) {\n\n const d = {};\n\n d.id = str(pick(raw, [\"id\", \"deviceId\", \"device.id\", \"key\", \"mac\", \"device.mac\"]));\n d.name = str(pick(raw, [\"name\", \"device.name\", \"deviceName\", \"settings.name\", \"info.name\"]));\n d.ip = str(pick(raw, [\"ip\", \"address\", \"host\", \"device.ip\", \"lastKnownIp\", \"network.ip\", \"wifi.sta_ip\"]));\n d.hostname = str(pick(raw, [\"hostname\", \"device.hostname\", \"info.id\"]));\n d.mac = str(pick(raw, [\"mac\", \"device.mac\", \"info.mac\"]));\n d.model = str(pick(raw, [\"modelName\", \"model\", \"device.model\", \"info.model\", \"type\"]));\n d.modelCode = str(pick(raw, [\"modelCode\", \"model_code\", \"device.modelCode\", \"info.model\", \"info.type\", \"type\", \"model\"]));\n d.app = str(pick(raw, [\"app\", \"device.app\", \"info.app\"]));\n d.profile = str(pick(raw, [\"profile\", \"device.profile\", \"info.profile\", \"application\"]));\n d.gen = num(pick(raw, [\"generation\", \"gen\", \"device.generation\", \"device.gen\", \"info.gen\"]));\n d.deviceClass = str(pick(raw, [\"deviceClass\", \"class\", \"settings.deviceClass\", \"effective.deviceClass.value\"]));\n\n // Reachability\n d.online = bool(pick(raw, [\"online\", \"reachable\", \"isOnline\", \"status.online\", \"health.reachable\", \"lastScan.status\", \"status\"]));\n d.confirmedOffline = pick(raw, [\"confirmedOffline\", \"offline.confirmed\"]) === true;\n d.lastSeen = str(pick(raw, [\"lastSeen\", \"lastSeenAt\", \"lastSuccessAt\", \"lastContactAt\", \"lastOkAt\", \"updatedAt\", \"checkedAt\", \"timestamp\"]));\n d.lastError = str(pick(raw, [\"lastError.message\", \"lastError.code\", \"error.message\", \"error.code\"]));\n\n // Firmware\n d.fw = str(pick(raw, [\"firmware.version\", \"firmware.current\", \"firmwareVersion\", \"fw\", \"fw_id\", \"version\", \"ver\", \"info.ver\"]));\n d.fwStable = str(pick(raw, [\"firmware.available.stable\", \"firmware.stable.version\", \"firmware.stable\", \"firmware.update.stable.version\", \"firmware.latest\", \"firmware.available\", \"availableFirmware\"]));\n d.fwBeta = str(pick(raw, [\"firmware.available.beta\", \"firmware.beta.version\", \"firmware.beta\", \"firmware.update.beta.version\"]));\n\n // Only set when it can be determined; null never overwrites\n const updFlag = pick(raw, [\"firmware.updateAvailable\", \"firmware.policyEligible\", \"updateAvailable\", \"firmware.eligible\"]);\n\n if (updFlag === true || updFlag === false) {\n d.updateAvailable = updFlag;\n } else if (d.fw && d.fwStable) {\n d.updateAvailable = d.fwStable !== d.fw;\n } else {\n d.updateAvailable = null;\n }\n\n // Metrics (only when reported)\n d.temperature = num(pick(raw, [\"temperatureC\", \"temperature\", \"temp\", \"metrics.temperatureC\", \"metrics.temperature\", \"measurements.temperature\", \"sys.temperature\", \"latest.temperatureC\"]));\n d.rssi = num(pick(raw, [\"rssi\", \"wifiRssi\", \"wifi.rssi\", \"metrics.rssi\", \"measurements.rssi\", \"latest.rssi\"]));\n d.uptimeSec = num(pick(raw, [\"uptimeSec\", \"uptime\", \"metrics.uptimeSec\", \"metrics.uptime\", \"sys.uptime\", \"latest.uptimeSec\"]));\n d.uptimeBeforeReboot = num(pick(raw, [\"uptimeBeforeRebootSec\", \"previousUptimeSec\", \"reboot.uptimeBeforeSec\"]));\n d.lastReboot = str(pick(raw, [\"lastRebootAt\", \"reboot.lastAt\", \"lastReboot.at\"]));\n d.latencyMs = num(pick(raw, [\"latencyMs\", \"responseTimeMs\", \"latency\", \"metrics.latencyMs\", \"metrics.responseTimeMs\", \"latest.latencyMs\"]));\n d.ramFreePct = num(pick(raw, [\"ramFreePercent\", \"freeRamPercent\", \"metrics.ramFreePercent\", \"metrics.ramFreePct\", \"resources.ramFreePercent\"]));\n d.fsFreePct = num(pick(raw, [\"fsFreePercent\", \"freeFsPercent\", \"metrics.fsFreePercent\", \"metrics.fsFreePct\", \"resources.fsFreePercent\"]));\n d.powerW = num(pick(raw, [\"powerW\", \"apower\", \"power\", \"metrics.powerW\", \"metrics.power\", \"electrical.powerW\", \"latest.powerW\"]));\n d.voltage = num(pick(raw, [\"voltage\", \"voltageV\", \"metrics.voltage\", \"electrical.voltage\", \"latest.voltage\"]));\n d.current = num(pick(raw, [\"current\", \"currentA\", \"metrics.current\", \"electrical.current\", \"latest.current\"]));\n d.energyWh = num(pick(raw, [\"energyWh\", \"energy\", \"aenergy.total\", \"metrics.energyWh\", \"electrical.energyWh\", \"latest.energyWh\"]));\n\n const output = pick(raw, [\"output\", \"relay.ison\", \"ison\", \"switch.output\", \"state.output\"]);\n d.output = output === true || output === false ? output : null;\n\n return d;\n}\n\n// Merge new values (null never overwrites)\nfunction merge(target, src) {\n for (const [k, v] of Object.entries(src)) {\n if (v !== null && v !== undefined) {\n target[k] = v;\n }\n }\n return target;\n}\n\n// --------------------------------------------------\n// Load state\n// --------------------------------------------------\n\nconst model = load(\"shellyDash\", null) || { devices: {}, alerts: {}, alertLog: [], runs: {}, errors: [] };\nconst history = load(\"shellyDashHistory\", {});\nconst now = Date.now();\nconst nowIso = new Date(now).toISOString();\nconst p = msg.payload;\n\n// Find device by ID or IP, or create it\nfunction upsert(d) {\n\n let key = d.id;\n\n if (!key && d.ip) {\n key = Object.keys(model.devices).find(k => model.devices[k].ip === d.ip) || null;\n }\n\n if (!key) {\n return null;\n }\n\n const existing = model.devices[key] || { id: key, firstSeen: nowIso };\n merge(existing, d);\n existing.id = key;\n existing.category = classify(existing);\n model.devices[key] = existing;\n return existing;\n}\n\n// Re-classify stored devices on every run\nfor (const dev of Object.values(model.devices)) {\n dev.category = classify(dev);\n}\n\n// --------------------------------------------------\n// Per source\n// --------------------------------------------------\n\nconst source = msg.source || \"health\";\n\nif (source === \"inventory\" || source === \"health\" || source === \"events\") {\n\n for (const raw of extractDevices(p)) {\n\n const d = upsert(normalizeDevice(raw));\n\n if (!d) {\n continue;\n }\n\n if (source !== \"events\") {\n d.lastUpdate = nowIso;\n }\n\n // History (temperature, power, RSSI) on a 15-min grid\n if (source === \"health\" && (d.temperature !== null || d.powerW !== null || d.rssi !== null)) {\n\n const h = history[d.id] || [];\n const last = h[h.length - 1];\n const point = { t: now, temp: d.temperature ?? null, p: d.powerW ?? null, rssi: d.rssi ?? null };\n\n if (last && now - last.t < HISTORY_STEP_MS) {\n h[h.length - 1] = { ...point, t: last.t };\n } else {\n h.push(point);\n }\n\n history[d.id] = h.slice(-HISTORY_MAX);\n }\n }\n}\n\n// Observations (from health or alerts)\nif (source === \"health\" || source === \"alerts\") {\n\n for (const o of extractObservations(source === \"alerts\" ? p : (p && p.observations))) {\n\n const devId = str(pick(o, [\"device.id\", \"deviceId\", \"device\"])) || \"global\";\n const kind = str(pick(o, [\"kind\", \"category\", \"type\", \"code\"])) || \"notice\";\n const key = str(pick(o, [\"id\", \"observationId\", \"key\"])) || `${devId}:${kind}`;\n const lifecycle = str(pick(o, [\"lifecycle\", \"state\", \"transition\"])) || \"present\";\n\n const entry = {\n key,\n deviceId: devId,\n deviceName: str(pick(o, [\"device.name\", \"deviceName\"])),\n ip: str(pick(o, [\"device.ip\", \"ip\"])),\n kind,\n severity: (str(pick(o, [\"severity\", \"level\"])) || \"info\").toLowerCase(),\n lifecycle,\n text: str(pick(o, [\"summaryTextDe\", \"summaryText\", \"humanSummary\", \"title\", \"reasons.0\", \"message\"])) || kind,\n textEn: str(pick(o, [\"summaryText\", \"humanSummary\", \"title\", \"reasons.0\", \"message\", \"summaryTextDe\"])) || kind,\n notify: o.notify === true,\n at: str(pick(o, [\"at\", \"timestamp\", \"time\", \"updatedAt\", \"openedAt\"])) || nowIso\n };\n\n if (lifecycle === \"cleared\" || lifecycle === \"suppressed\") {\n delete model.alerts[key];\n } else {\n model.alerts[key] = entry;\n }\n\n if (lifecycle !== \"present\") {\n model.alertLog.unshift(entry);\n }\n }\n\n model.alertLog = model.alertLog.slice(0, MAX_ALERT_LOG);\n}\n\n// Run status (lifecycle) of all nodes\nconst lifecycle = p && (p.lifecycle || (p.runId || p.state ? p : null));\n\nif (lifecycle && typeof lifecycle === \"object\") {\n\n const nodeKey = {\n inventory: \"discovery\", events: \"discovery\",\n health: \"monitor\", alerts: \"monitor\",\n maint: \"maintenance\", progress: \"maintenance\"\n }[source];\n\n if (nodeKey) {\n model.runs[nodeKey] = {\n state: str(lifecycle.state) || model.runs[nodeKey]?.state || null,\n phase: str(lifecycle.phase),\n processed: num(lifecycle.processed),\n total: num(lifecycle.total),\n action: str(lifecycle.action || p.action),\n runId: str(lifecycle.runId),\n at: nowIso\n };\n }\n}\n\n// Maintenance: per-device result and progress\nif (source === \"maint\" || source === \"progress\") {\n\n for (const r of extractDevices(p)) {\n\n const id = str(pick(r, [\"id\", \"deviceId\", \"device.id\"]));\n\n if (!id || !model.devices[id]) {\n continue;\n }\n\n const dev = model.devices[id];\n\n dev.maintenance = {\n status: str(pick(r, [\"status\", \"result\", \"state\"])),\n reason: str(pick(r, [\"reason\", \"error.code\", \"error.message\"])),\n at: nowIso\n };\n\n merge(dev, {\n fw: str(pick(r, [\"firmware.version\", \"current\", \"currentVersion\", \"firmware.current\"])),\n fwStable: str(pick(r, [\"firmware.available.stable\", \"available.stable\", \"stable\", \"targetVersion\", \"firmware.stable\"])),\n fwBeta: str(pick(r, [\"firmware.available.beta\", \"available.beta\", \"beta\", \"firmware.beta\"]))\n });\n\n const elig = pick(r, [\"eligible\", \"policyEligible\", \"updateAvailable\"]);\n\n if (elig === true || elig === false) {\n dev.updateAvailable = elig;\n }\n }\n}\n\n// Error outputs\nif (source === \"errors\") {\n\n model.errors.unshift({\n at: nowIso,\n text: str(pick(p, [\"messageDe\", \"message\", \"error.message\", \"code\"])) || \"Unbekannter Fehler\",\n textEn: str(pick(p, [\"message\", \"error.message\", \"code\", \"messageDe\"])) || \"Unknown error\",\n deviceId: str(pick(p, [\"device.id\", \"deviceId\"]))\n });\n\n model.errors = model.errors.slice(0, 50);\n}\n\nmodel.updated = nowIso;\n\nsave(\"shellyDash\", model);\nsave(\"shellyDashHistory\", history);\n\nnode.status({\n fill: \"green\",\n shape: \"dot\",\n text: `${source} · ${Object.keys(model.devices).length} devices · ${Object.keys(model.alerts).length} alerts`\n});\n\nreturn { trigger: true };", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 940, "y": 260, "wires": [ [ "e7a5000000000052" ] ] }, { "id": "e7a5000000000067", "type": "inject", "z": "e7a50000000000a1", "name": "Start / every minute", "props": [], "repeat": "60", "crontab": "", "once": true, "onceDelay": "3", "topic": "", "x": 950, "y": 340, "wires": [ [ "e7a5000000000052" ] ] }, { "id": "e7a5000000000052", "type": "function", "z": "e7a50000000000a1", "name": "Build dashboard model", "func": "/**\n * Build dashboard model (debounced)\n *\n * Combines devices, alerts, runs and history into ONE message for the\n * dashboard. Several changes within 500 ms result in a single message.\n */\n\nconst DEBOUNCE_MS = 500;\nconst STORE = \"file\";\n\nfunction load(key, fallback) {\n try {\n const v = flow.get(key, STORE);\n return v === undefined ? fallback : v;\n } catch (e) {\n const v = flow.get(key);\n return v === undefined ? fallback : v;\n }\n}\n\nfunction build() {\n\n const model = load(\"shellyDash\", null) || { devices: {}, alerts: {}, alertLog: [], runs: {}, errors: [] };\n const history = load(\"shellyDashHistory\", {});\n const armedUntil = flow.get(\"shellyArmedUntil\") || 0;\n const now = Date.now();\n\n // Count active alerts per device\n const alertsByDevice = {};\n\n for (const a of Object.values(model.alerts)) {\n const k = a.deviceId;\n alertsByDevice[k] = alertsByDevice[k] || { warning: 0, critical: 0, info: 0 };\n const sev = a.severity === \"critical\" ? \"critical\" : a.severity === \"warning\" ? \"warning\" : \"info\";\n alertsByDevice[k][sev] += 1;\n }\n\n const devices = Object.values(model.devices).map(d => {\n\n const a = alertsByDevice[d.id] || { warning: 0, critical: 0, info: 0 };\n\n let status = \"unknown\";\n\n if (d.confirmedOffline || d.online === false) {\n status = \"offline\";\n } else if (d.online === true) {\n status = a.critical ? \"critical\" : a.warning ? \"warning\" : \"online\";\n }\n\n // Data older than 15 min is considered stale\n const age = d.lastUpdate ? now - Date.parse(d.lastUpdate) : null;\n\n return {\n ...d,\n status,\n stale: age === null || age > 15 * 60 * 1000,\n alerts: a,\n history: history[d.id] || []\n };\n });\n\n const rank = { critical: 0, warning: 1, info: 2 };\n\n const alerts = Object.values(model.alerts)\n .sort((x, y) => (rank[x.severity] ?? 3) - (rank[y.severity] ?? 3) || String(y.at).localeCompare(String(x.at)));\n\n const online = devices.filter(d => d.status !== \"offline\" && d.status !== \"unknown\").length;\n\n node.status({\n fill: devices.some(d => d.status === \"offline\") ? \"yellow\" : \"green\",\n shape: \"dot\",\n text: `${online}/${devices.length} online · ${alerts.length} alerts`\n });\n\n return {\n payload: {\n updated: model.updated || new Date().toISOString(),\n devices,\n alerts,\n alertLog: (model.alertLog || []).slice(0, 50),\n runs: model.runs || {},\n errors: (model.errors || []).slice(0, 20),\n armed: armedUntil > now,\n armedUntil,\n lastCommand: flow.get(\"shellyLastCommand\") || null\n }\n };\n}\n\nconst timer = context.get(\"timer\");\n\nif (timer) {\n clearTimeout(timer);\n}\n\ncontext.set(\"timer\", setTimeout(() => {\n context.set(\"timer\", null);\n node.send(build());\n}, DEBOUNCE_MS));\n\nreturn null;", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 1180, "y": 300, "wires": [ [ "e7a5000000000053", "e7a5000000000055" ] ] }, { "id": "e7a5000000000053", "type": "ui-template", "z": "e7a50000000000a1", "group": "e7a50000000000b4", "page": "", "ui": "", "name": "Shelly fleet dashboard", "order": 1, "width": "12", "height": "0", "head": "", "format": "\n\n\n\n", "storeOutMessages": true, "passthru": false, "resendOnRefresh": true, "templateScope": "local", "className": "", "x": 1440, "y": 260, "wires": [ [ "e7a5000000000054" ] ] }, { "id": "e7a5000000000055", "type": "debug", "z": "e7a50000000000a1", "name": "Dashboard model", "active": false, "tosidebar": true, "console": false, "tostatus": false, "complete": "payload", "targetType": "msg", "statusVal": "", "statusType": "auto", "x": 1420, "y": 340, "wires": [] }, { "id": "e7a5000000000054", "type": "function", "z": "e7a50000000000a1", "name": "Validate & route command", "func": "/**\n * Validate and route dashboard commands\n *\n * Input from the dashboard: msg.payload = { target, action, ... }\n *\n * target \"discovery\" action \"scan\" mode \"incremental\" | \"full\"\n * target \"monitor\" action \"poll\" devices (optional)\n * target \"maintenance\" action \"check\" devices (optional) – read-only\n * target \"maintenance\" action \"dryrun\" devices – dry run\n * target \"maintenance\" action \"update\" devices – REAL, only while unlocked\n * target \"maintenance\" action \"reboot\" devices – REAL, only while unlocked\n * target \"device\" action \"switch\" id, channel, on, confirm\n * target \"device\" action \"status\" id (optional)\n * target \"ui\" action \"arm\" value true|false\n * target \"ui\" action \"forget\" id\n *\n * Output 1: discovery Output 2: monitor Output 3: maintenance\n * Output 4: trigger for the dashboard model\n * Output 5: switch command (HTTP to the device)\n * Output 6: read switch state\n *\n * Switching: relays and plugs only, known channels only, online only.\n * Devices of class \"critical\"/\"kritisch\" require confirm:true\n * (second click in the dashboard).\n */\n\nconst ARM_MS = 10 * 60 * 1000;\nconst MAX_REAL_DEVICES = 5;\nconst SWITCHABLE = [\"relay\", \"plug\"];\n\nconst cmd = msg.payload || {};\nconst now = Date.now();\nconst out = [null, null, null, { trigger: true }, null, null];\n\nfunction feedback(ok, de, en) {\n flow.set(\"shellyLastCommand\", { ts: now, ok, de, en, text: de });\n node.status({ fill: ok ? \"green\" : \"red\", shape: \"dot\", text: en });\n}\n\nfunction deviceList(value) {\n if (!Array.isArray(value)) {\n return [];\n }\n return value.map(String).filter(Boolean).slice(0, 200);\n}\n\nfunction loadModel() {\n try {\n return flow.get(\"shellyDash\", \"file\");\n } catch (e) {\n return flow.get(\"shellyDash\");\n }\n}\n\nfunction genOf(d) {\n if (Number(d.gen) >= 1) {\n return Number(d.gen);\n }\n return /^SH/i.test(String(d.modelCode || d.model || \"\")) ? 1 : 2;\n}\n\nconst devices = deviceList(cmd.devices);\nconst n = devices.length;\n\nswitch (`${cmd.target}:${cmd.action}`) {\n\n case \"discovery:scan\": {\n const mode = cmd.mode === \"full\" ? \"full\" : \"incremental\";\n out[0] = { action: \"scan\", mode };\n feedback(true,\n mode === \"full\" ? \"Vollscan gestartet\" : \"Inventarprüfung gestartet\",\n mode === \"full\" ? \"Full scan started\" : \"Inventory check started\");\n break;\n }\n\n case \"monitor:poll\": {\n out[1] = n ? { action: \"poll\", devices } : { action: \"poll\" };\n // Refresh switch states as well\n out[5] = n === 1 ? { deviceId: devices[0] } : {};\n feedback(true,\n n ? `Abfrage für ${n} Gerät(e)` : \"Abfrage aller Geräte gestartet\",\n n ? `Polling ${n} device(s)` : \"Polling all devices\");\n break;\n }\n\n case \"maintenance:check\": {\n out[2] = n ? { action: \"check\", devices } : { action: \"check\" };\n feedback(true, \"Firmwareprüfung gestartet (nur lesend)\", \"Firmware check started (read-only)\");\n break;\n }\n\n case \"maintenance:dryrun\": {\n if (!n) {\n feedback(false, \"Probelauf: keine Geräte ausgewählt\", \"Dry run: no devices selected\");\n break;\n }\n out[2] = { action: \"update\", devices, dryRun: true };\n feedback(true, `Update-Probelauf für ${n} Gerät(e)`, `Update dry run for ${n} device(s)`);\n break;\n }\n\n case \"maintenance:update\":\n case \"maintenance:reboot\": {\n\n const armed = (flow.get(\"shellyArmedUntil\") || 0) > now;\n const de = cmd.action === \"update\" ? \"Firmwareupdate\" : \"Neustart\";\n const en = cmd.action === \"update\" ? \"Firmware update\" : \"Reboot\";\n\n if (!armed) {\n feedback(false, `${de} blockiert: Wartung ist nicht freigeschaltet`, `${en} blocked: maintenance is locked`);\n break;\n }\n if (!n) {\n feedback(false, `${de} blockiert: kein Gerät ausgewählt`, `${en} blocked: no device selected`);\n break;\n }\n if (n > MAX_REAL_DEVICES) {\n feedback(false, `${de} blockiert: max. ${MAX_REAL_DEVICES} Geräte pro Befehl`, `${en} blocked: max. ${MAX_REAL_DEVICES} devices per command`);\n break;\n }\n if (cmd.confirm !== true) {\n feedback(false, `${de} blockiert: keine Bestätigung`, `${en} blocked: not confirmed`);\n break;\n }\n\n out[2] = { action: cmd.action, devices, dryRun: false, confirm: true };\n feedback(true, `${de} gesendet: ${n} Gerät(e)`, `${en} sent: ${n} device(s)`);\n break;\n }\n\n // ----------------------------------------------\n // Switch a channel\n // ----------------------------------------------\n case \"device:switch\": {\n\n const model = loadModel();\n const dev = model && model.devices ? model.devices[String(cmd.id)] : null;\n\n if (!dev) {\n feedback(false, \"Schalten: Gerät unbekannt\", \"Switch: unknown device\");\n break;\n }\n\n const name = dev.name || dev.id;\n\n if (!SWITCHABLE.includes(dev.category)) {\n feedback(false, `${name}: Gerät ist nicht schaltbar`, `${name}: device is not switchable`);\n break;\n }\n if (!dev.ip) {\n feedback(false, `${name}: keine IP-Adresse bekannt`, `${name}: no IP address known`);\n break;\n }\n if (dev.confirmedOffline || dev.online === false) {\n feedback(false, `${name}: Gerät ist offline`, `${name}: device is offline`);\n break;\n }\n\n const channel = Math.round(Number(cmd.channel));\n const channels = Array.isArray(dev.channels) ? dev.channels : [];\n\n if (!channels.some(c => c.id === channel)) {\n feedback(false,\n `${name}: Kanal ${channel + 1} unbekannt (Schaltzustand noch nicht gelesen)`,\n `${name}: channel ${channel + 1} unknown (state not read yet)`);\n break;\n }\n\n const critical = /krit|crit/i.test(String(dev.deviceClass || \"\"));\n\n if (critical && cmd.confirm !== true) {\n feedback(false,\n `${name}: kritisches Gerät, zweite Bestätigung erforderlich`,\n `${name}: critical device, second confirmation required`);\n break;\n }\n\n const on = cmd.on === true;\n const gen = genOf(dev);\n\n out[4] = {\n kind: \"switch\",\n deviceId: dev.id,\n channel,\n on,\n gen,\n requestTimeout: 5000,\n url: gen >= 2\n ? `http://${dev.ip}/rpc/Switch.Set?id=${channel}&on=${on}`\n : `http://${dev.ip}/relay/${channel}?turn=${on ? \"on\" : \"off\"}`\n };\n\n // Feedback follows the device response\n node.status({ fill: \"blue\", shape: \"dot\", text: `${name} · channel ${channel + 1} → ${on ? \"ON\" : \"OFF\"}` });\n out[3] = null;\n break;\n }\n\n case \"device:status\": {\n out[5] = cmd.id ? { deviceId: String(cmd.id) } : {};\n out[3] = null;\n break;\n }\n\n case \"ui:arm\": {\n const on = cmd.value === true;\n flow.set(\"shellyArmedUntil\", on ? now + ARM_MS : 0);\n feedback(true,\n on ? \"Wartung für 10 Minuten freigeschaltet\" : \"Wartung gesperrt\",\n on ? \"Maintenance unlocked for 10 minutes\" : \"Maintenance locked\");\n break;\n }\n\n case \"ui:forget\": {\n const model = loadModel();\n if (model && model.devices && model.devices[cmd.id]) {\n delete model.devices[cmd.id];\n try { flow.set(\"shellyDash\", model, \"file\"); } catch (e) { flow.set(\"shellyDash\", model); }\n feedback(true, \"Gerät aus dem Dashboard entfernt (Inventar unverändert)\", \"Device removed from dashboard (inventory unchanged)\");\n }\n break;\n }\n\n default:\n feedback(false, `Unbekannter Befehl ${cmd.target}:${cmd.action}`, `Unknown command ${cmd.target}:${cmd.action}`);\n}\n\nreturn out;", "outputs": 6, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 930, "y": 520, "wires": [ [ "e7a5000000000040" ], [ "e7a5000000000041" ], [ "e7a5000000000042" ], [ "e7a5000000000052" ], [ "e7a5000000000062" ], [ "e7a5000000000060" ] ], "outputLabels": [ "→ Discovery", "→ Monitor", "→ Maintenance", "Refresh dashboard", "→ Switch (HTTP)", "→ Read switch state" ] }, { "id": "e7a5000000000002", "type": "comment", "z": "e7a50000000000a1", "name": "② Move the link-in nodes to your shelly-admin nodes and connect them to their inputs", "info": "", "x": 1400, "y": 460, "wires": [] }, { "id": "e7a5000000000040", "type": "link out", "z": "e7a50000000000a1", "name": "Command to Discovery", "mode": "link", "links": [ "e7a5000000000045" ], "x": 1170, "y": 500, "wires": [], "l": true }, { "id": "e7a5000000000045", "type": "link in", "z": "e7a50000000000a1", "name": "Dashboard command → Discovery", "links": [ "e7a5000000000040" ], "x": 1460, "y": 500, "wires": [ [] ], "l": true, "info": "Move into the tab of your shelly-admin nodes and connect to the input of the Discovery node." }, { "id": "e7a5000000000041", "type": "link out", "z": "e7a50000000000a1", "name": "Command to Monitor", "mode": "link", "links": [ "e7a5000000000046" ], "x": 1170, "y": 550, "wires": [], "l": true }, { "id": "e7a5000000000046", "type": "link in", "z": "e7a50000000000a1", "name": "Dashboard command → Monitor", "links": [ "e7a5000000000041" ], "x": 1460, "y": 550, "wires": [ [] ], "l": true, "info": "Move into the tab of your shelly-admin nodes and connect to the input of the Monitor node." }, { "id": "e7a5000000000042", "type": "link out", "z": "e7a50000000000a1", "name": "Command to Maintenance", "mode": "link", "links": [ "e7a5000000000047" ], "x": 1170, "y": 600, "wires": [], "l": true }, { "id": "e7a5000000000047", "type": "link in", "z": "e7a50000000000a1", "name": "Dashboard command → Maintenance", "links": [ "e7a5000000000042" ], "x": 1460, "y": 600, "wires": [ [] ], "l": true, "info": "Move into the tab of your shelly-admin nodes and connect to the input of the Maintenance node." }, { "id": "e7a5000000000003", "type": "comment", "z": "e7a50000000000a1", "name": "Switching: relays & plugs via the local Shelly API (up to 4 channels)", "info": "Gen2+: /rpc/Shelly.GetStatus, /rpc/Shelly.GetConfig, /rpc/Switch.Set\nGen1: /status, /settings, /relay/N?turn=on|off\n\nPassword-protected devices: set authentication in the \"Shelly HTTP\" node (Digest for Gen2+, Basic for Gen1, user admin).", "x": 520, "y": 680, "wires": [] }, { "id": "e7a5000000000066", "type": "inject", "z": "e7a50000000000a1", "name": "Switch state every 30 s", "props": [], "repeat": "30", "crontab": "", "once": true, "onceDelay": "8", "topic": "", "x": 190, "y": 740, "wires": [ [ "e7a5000000000060" ] ] }, { "id": "e7a5000000000060", "type": "function", "z": "e7a50000000000a1", "name": "Read switch state", "func": "/**\n * Read switch state\n *\n * Reads the state of every channel of all relays and plugs directly\n * via the local Shelly API:\n *\n * Gen2+ GET http://IP/rpc/Shelly.GetStatus (switch:0 … switch:3)\n * GET http://IP/rpc/Shelly.GetConfig (channel names, every 6 h)\n * Gen1 GET http://IP/status (relays[], meters[])\n * GET http://IP/settings (channel names, every 6 h)\n *\n * Input:\n * - inject (every 30 s) -> all switchable devices\n * - msg.deviceId -> this device only (after switching)\n *\n * Output: one HTTP request per device and kind (through a rate limit).\n */\n\nconst STORE = \"file\";\nconst SWITCHABLE = [\"relay\", \"plug\"];\nconst CONFIG_MAX_AGE_MS = 6 * 3600 * 1000;\nconst TIMEOUT_MS = 4000;\n\nfunction load(key, fallback) {\n try {\n const v = flow.get(key, STORE);\n return v === undefined ? fallback : v;\n } catch (e) {\n const v = flow.get(key);\n return v === undefined ? fallback : v;\n }\n}\n\n// Determine generation (Gen1 model codes start with \"SH\")\nfunction genOf(d) {\n if (Number(d.gen) >= 1) {\n return Number(d.gen);\n }\n return /^SH/i.test(String(d.modelCode || d.model || \"\")) ? 1 : 2;\n}\n\nconst model = load(\"shellyDash\", null) || { devices: {} };\nconst only = msg.deviceId ? String(msg.deviceId) : null;\nconst now = Date.now();\nconst out = [];\n\nfor (const d of Object.values(model.devices || {})) {\n\n if (only && d.id !== only) {\n continue;\n }\n\n if (!SWITCHABLE.includes(d.category) || !d.ip) {\n continue;\n }\n\n // Offline devices only on targeted requests\n if (!only && (d.confirmedOffline || d.online === false)) {\n continue;\n }\n\n const gen = genOf(d);\n const base = { deviceId: d.id, gen, requestTimeout: TIMEOUT_MS };\n\n out.push({\n ...base,\n kind: \"status\",\n url: gen >= 2 ? `http://${d.ip}/rpc/Shelly.GetStatus` : `http://${d.ip}/status`\n });\n\n const namesAt = Date.parse(d.channelNamesAt || \"\");\n\n if (!Number.isFinite(namesAt) || now - namesAt > CONFIG_MAX_AGE_MS) {\n out.push({\n ...base,\n kind: \"config\",\n url: gen >= 2 ? `http://${d.ip}/rpc/Shelly.GetConfig` : `http://${d.ip}/settings`\n });\n }\n}\n\nnode.status({\n fill: out.length ? \"blue\" : \"grey\",\n shape: \"dot\",\n text: `${out.length} requests${only ? \" · \" + only : \"\"}`\n});\n\nreturn [out];", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 420, "y": 760, "wires": [ [ "e7a5000000000061" ] ] }, { "id": "e7a5000000000061", "type": "delay", "z": "e7a50000000000a1", "name": "max. 5 requests/s", "pauseType": "rate", "timeout": "5", "timeoutUnits": "seconds", "rate": "5", "nbRateUnits": "1", "rateUnits": "second", "randomFirst": "1", "randomLast": "5", "randomUnits": "seconds", "drop": false, "allowrate": false, "outputs": 1, "x": 630, "y": 760, "wires": [ [ "e7a5000000000062" ] ] }, { "id": "e7a5000000000062", "type": "http request", "z": "e7a50000000000a1", "name": "Shelly HTTP", "method": "GET", "ret": "obj", "paytoqs": "ignore", "url": "", "tls": "", "persist": false, "proxy": "", "insecureHTTPParser": false, "authType": "", "senderr": false, "headers": [], "x": 830, "y": 760, "wires": [ [ "e7a5000000000063" ] ] }, { "id": "e7a5000000000064", "type": "catch", "z": "e7a50000000000a1", "name": "HTTP errors", "scope": [ "e7a5000000000062" ], "uncaught": false, "x": 830, "y": 820, "wires": [ [ "e7a5000000000063" ] ] }, { "id": "e7a5000000000063", "type": "function", "z": "e7a50000000000a1", "name": "Apply switch state", "func": "e7a5000000000063", "outputs": 2, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 1050, "y": 780, "wires": [ [ "e7a5000000000052" ], [ "e7a5000000000065" ] ], "outputLabels": [ "Refresh dashboard", "Verification read" ] }, { "id": "e7a5000000000065", "type": "delay", "z": "e7a50000000000a1", "name": "wait 1.5 s", "pauseType": "delay", "timeout": "1500", "timeoutUnits": "milliseconds", "rate": "1", "nbRateUnits": "1", "rateUnits": "second", "randomFirst": "1", "randomLast": "5", "randomUnits": "seconds", "drop": false, "allowrate": false, "outputs": 1, "x": 420, "y": 840, "wires": [ [ "e7a5000000000060" ] ] }, { "id": "e7a50000000000b1", "type": "ui-base", "name": "Shelly Admin", "path": "/dashboard", "appIcon": "", "includeClientData": true, "acceptsClientConfig": [ "ui-notification", "ui-control" ], "showPathInSidebar": false, "headerContent": "page", "navigationStyle": "default", "titleBarStyle": "default", "showReconnectNotification": true, "notificationDisplayTime": 5, "showDisconnectNotification": true, "allowInstall": false }, { "id": "e7a50000000000b2", "type": "ui-theme", "name": "Shelly Admin Theme", "colors": { "surface": "#ffffff", "primary": "#1565c0", "bgPage": "#f3f6f9", "groupBg": "#ffffff", "groupOutline": "#d7dee5" }, "sizes": { "density": "default", "pagePadding": "12px", "groupGap": "12px", "groupBorderRadius": "12px", "widgetGap": "12px" } }, { "id": "e7a50000000000b3", "type": "ui-page", "name": "Shellies", "ui": "e7a50000000000b1", "path": "/shellies", "icon": "access-point-network", "layout": "grid", "theme": "e7a50000000000b2", "breakpoints": [ { "name": "Default", "px": "0", "cols": "4" }, { "name": "Tablet", "px": "576", "cols": "8" }, { "name": "Desktop", "px": "1024", "cols": "12" } ], "order": 1, "className": "", "visible": "true", "disabled": "false" }, { "id": "e7a50000000000b4", "type": "ui-group", "name": "Shelly fleet", "page": "e7a50000000000b3", "width": "12", "height": "1", "order": 1, "showTitle": false, "className": "", "visible": "true", "disabled": "false", "groupType": "default" } ]