[ { "id": "fcafa14fdc883280", "type": "tab", "label": "Farmers IoT — both sensors", "disabled": false, "info": "Receives readings from the water tank probe (Module 1) and the soil\nprobe (Module 2), and serves a live page at GET /page — one card per reporting node, so extra\nsoil beds appear on their own.\n\nWorks whether the two sensors are on one ESP8266 or two — each has its\nown endpoint, so nothing cares where the POST came from.\n\nSUPERSEDES flows/water-level-flow.json. Do not import both: two `http in`\nnodes on /water will conflict.\n\nCore nodes only — no node-red-dashboard install. Every extra install step\nis a place a build dies on someone else's phone.\n\nTest with no hardware at all:\n\n curl -X POST http://localhost:1880/water -H 'Content-Type: application/json' \\\n -d '{\"node\":\"water-tank-1\",\"ok\":true,\"raw\":139,\"depth_mm\":113}'\n\n curl -X POST http://localhost:1880/soil -H 'Content-Type: application/json' \\\n -d '{\"node\":\"soil-bed-1\",\"ok\":true,\"moisture_pct\":22.4,\"temp_c\":20.2,\"ec\":0}'\n\nThen open http://:1880/page in a browser.\n" }, { "id": "f8c6abf3c86ea1f5", "type": "ui_tab", "name": "Maani Plantation", "icon": "dashboard", "disabled": false, "hidden": false }, { "id": "09631a93c01a2b6c", "type": "ui_base", "theme": { "name": "theme-light", "lightTheme": { "default": "#0094CE", "baseColor": "#0094CE", "baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif", "edited": true, "reset": false }, "darkTheme": { "default": "#097479", "baseColor": "#097479", "baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif", "edited": false }, "customTheme": { "name": "Untitled Theme 1", "default": "#4B7930", "baseColor": "#4B7930", "baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif" }, "themeState": { "base-color": { "default": "#0094CE", "value": "#0094CE", "edited": false }, "page-titlebar-backgroundColor": { "value": "#0094CE", "edited": false }, "page-backgroundColor": { "value": "#fafafa", "edited": false }, "page-sidebar-backgroundColor": { "value": "#ffffff", "edited": false }, "group-textColor": { "value": "#1bbfff", "edited": false }, "group-borderColor": { "value": "#ffffff", "edited": false }, "group-backgroundColor": { "value": "#ffffff", "edited": false }, "widget-textColor": { "value": "#111111", "edited": false }, "widget-backgroundColor": { "value": "#0094ce", "edited": false }, "widget-borderColor": { "value": "#ffffff", "edited": false }, "base-font": { "value": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif" } }, "angularTheme": { "primary": "indigo", "accents": "blue", "warn": "red", "background": "grey", "palette": "light" } }, "site": { "name": "Node-RED Dashboard", "hideToolbar": "false", "allowSwipe": "false", "lockMenu": "false", "allowTempTheme": "true", "dateFormat": "DD/MM/YYYY", "sizes": { "sx": 48, "sy": 48, "gx": 6, "gy": 6, "cx": 6, "cy": 6, "px": 0, "py": 0 } } }, { "id": "766fb58593dbe099", "type": "ui_group", "name": "Water Tank", "tab": "f8c6abf3c86ea1f5", "order": 1, "disp": true, "width": 6, "collapse": false, "className": "" }, { "id": "dc3b371660d82dcf", "type": "comment", "z": "fcafa14fdc883280", "name": "READ ME — endpoints, payloads, and the one thing people forget", "info": "ENDPOINTS\n POST /water Module 1, the QDY30A tank probe\n POST /soil Module 2, the THC-S soil probe\n GET /valve returns 1/0 — the node POLLS this for the valve\n POST /valve/set?v=1|0[&ttl=s] the page buttons set the desired state\n GET /page live page: tank, solar pack, valve, a card per node\n\nVALVE IS MANUAL FOR NOW: no moisture thresholds. The buttons set\nflow.valveCmd; the node polls GET /valve every ~1s and drives the relay.\nThe node echoes its ACTUAL pin state back in the /water payload as `valve`\n(the valve belongs to the node, not to a probe), and while it is open it also\nsends `valve_open_s`. Commanded and confirmed are not the same thing, so the\npage shows both.\n\nTIMED OPENS: \"Open 30 min\" and \"Open 1 hour\" POST ttl=1800 / 3600, which sets\nflow.valveUntil. The close happens inside GET /valve, on the node's own poll —\nnot in an inject, because Android dozes Termux and a Node-RED timer can stop\nfiring, while every poll from the node wakes the process. Plain \"Open\" has no\ntimer. The node's VALVE_MAX_OPEN_S still runs underneath as the dead-man guard\nfor a lost link.\n\nTANK % needs the tank's size: the EDIT HERE block at the top of `build page`.\n\nTHE PAGE IS SERVED no-store AND RELOADS TO /page?t=. Both are load-bearing.\nExpress (the http nodes) puts an ETag on every response and sets no freshness\npolicy at all, which leaves the phone free to re-serve the page on a reload —\nthe symptom is a live dashboard that needs browsing data cleared before it\nshows today's reading.\n\nTHE ONE THING PEOPLE FORGET: every `http in` needs an `http response`.\nWithout it the request never completes and the ESP blocks until timeout.\n", "x": 250, "y": 60, "wires": [] }, { "id": "6042937f8610fc79", "type": "http in", "z": "fcafa14fdc883280", "name": "POST /water", "url": "/water", "method": "post", "upload": false, "swaggerDoc": "", "x": 140, "y": 140, "wires": [ [ "8fa2948ed8c0879e", "59fccee597a5ab69", "4717fcf54ca79116", "fa11c0de00000002", "phbat_exec01" ] ] }, { "id": "59fccee597a5ab69", "type": "http response", "z": "fcafa14fdc883280", "name": "200 OK", "statusCode": "200", "headers": {}, "x": 420, "y": 200, "wires": [] }, { "id": "4717fcf54ca79116", "type": "debug", "z": "fcafa14fdc883280", "name": "water raw", "active": true, "tosidebar": true, "console": false, "tostatus": false, "complete": "payload", "targetType": "msg", "statusVal": "", "statusType": "auto", "x": 420, "y": 80, "wires": [] }, { "id": "8fa2948ed8c0879e", "type": "function", "z": "fcafa14fdc883280", "name": "store water", "func": "// Keep the latest reading PER NODE, not one slot per sensor kind.\n//\n// The old single 'soil' slot meant soil-bed-2 overwrote soil-bed-1 and the page\n// showed whichever reported last — with no hint the others existed. Keyed on node,\n// a new bed appears by reporting rather than by an edit here.\n//\n// 'gaps' is a short rolling history of arrival intervals. The page uses its median\n// to decide when a node has gone quiet, because a deep-sleep node at 10 minutes and\n// farm-node at 60 seconds cannot share one fixed threshold.\nconst p = msg.payload;\nconst now = Date.now();\nconst key = \"water:\" + (p.node || \"unknown\");\nconst nodes = flow.get(\"nodes\") || {};\nconst prev = nodes[key];\n\nconst gaps = (prev && prev.gaps) ? prev.gaps.slice() : [];\nif (prev && prev.at) {\n gaps.push(now - prev.at);\n if (gaps.length > 8) gaps.shift();\n}\n\n// A faulted probe replaces this slot with a reading that has no depth in it, so the\n// last GOOD level is carried forward on its own. The page shows it dated and muted,\n// never in the big number, so nobody reads yesterday's level as today's.\nlet lastGood = (prev && prev.last_good) || null;\nif (p.ok !== false && typeof p.depth_mm === \"number\") {\n lastGood = { depth_mm: p.depth_mm, percent: p.percent, at: now };\n}\n\nnodes[key] = Object.assign({}, p, {\n kind: \"water\",\n node: p.node || \"unknown\",\n at: now,\n gaps: gaps,\n last_good: lastGood\n});\nflow.set(\"nodes\", nodes);\n\n// The old single-slot keys stay written so nothing that still reads them breaks.\nflow.set(\"water\", Object.assign({}, p, { at: now }));\n\n// ok:false means the ESP is alive and the probe is not. Show it as a fault\n// rather than dropping it — that's a different repair job from silence.\nif (p.ok === false) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"probe fault: \" + p.error });\n return null;\n}\n\nlet text = p.depth_mm + \" mm\";\nif (p.percent !== undefined) text += \" (\" + p.percent + \"%)\";\nnode.status({ fill: \"green\", shape: \"dot\", text: text });\n\nreturn { payload: p.depth_mm, topic: p.node };\n", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 420, "y": 140, "wires": [ [] ] }, { "id": "7da67b6b4492951a", "type": "http in", "z": "fcafa14fdc883280", "name": "POST /soil", "url": "/soil", "method": "post", "upload": false, "swaggerDoc": "", "x": 140, "y": 300, "wires": [ [ "623736eba3a16904", "1490f7e3e6274188", "b277b2fc6723b198", "fa11c0de00000002", "phbat_exec01" ] ] }, { "id": "1490f7e3e6274188", "type": "http response", "z": "fcafa14fdc883280", "name": "200 OK", "statusCode": "200", "headers": {}, "x": 420, "y": 360, "wires": [] }, { "id": "b277b2fc6723b198", "type": "debug", "z": "fcafa14fdc883280", "name": "soil raw", "active": true, "tosidebar": true, "console": false, "tostatus": false, "complete": "payload", "targetType": "msg", "statusVal": "", "statusType": "auto", "x": 420, "y": 240, "wires": [] }, { "id": "623736eba3a16904", "type": "function", "z": "fcafa14fdc883280", "name": "store soil", "func": "// Keep the latest reading PER NODE, not one slot per sensor kind.\n//\n// The old single 'soil' slot meant soil-bed-2 overwrote soil-bed-1 and the page\n// showed whichever reported last — with no hint the others existed. Keyed on node,\n// a new bed appears by reporting rather than by an edit here.\n//\n// 'gaps' is a short rolling history of arrival intervals. The page uses its median\n// to decide when a node has gone quiet, because a deep-sleep node at 10 minutes and\n// farm-node at 60 seconds cannot share one fixed threshold.\nconst p = msg.payload;\nconst now = Date.now();\nconst key = \"soil:\" + (p.node || \"unknown\");\nconst nodes = flow.get(\"nodes\") || {};\nconst prev = nodes[key];\n\nconst gaps = (prev && prev.gaps) ? prev.gaps.slice() : [];\nif (prev && prev.at) {\n gaps.push(now - prev.at);\n if (gaps.length > 8) gaps.shift();\n}\n\nnodes[key] = Object.assign({}, p, {\n kind: \"soil\",\n node: p.node || \"unknown\",\n at: now,\n gaps: gaps\n});\nflow.set(\"nodes\", nodes);\n\n// The old single-slot keys stay written so nothing that still reads them breaks.\nflow.set(\"soil\", Object.assign({}, p, { at: now }));\n\nif (p.ok === false) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"probe fault: \" + p.error });\n return null;\n}\n\nnode.status({\n fill: \"green\", shape: \"dot\",\n text: (p.node || \"soil\") + \": \" + p.moisture_pct + \" % | \" + p.temp_c + \" C\"\n});\n\nreturn { payload: p.moisture_pct, topic: p.node };", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 420, "y": 300, "wires": [ [] ] }, { "id": "e3306f9782ac5a27", "type": "http in", "z": "fcafa14fdc883280", "name": "GET /page", "url": "/page", "method": "get", "upload": false, "skipBodyParsing": false, "swaggerDoc": "", "x": 140, "y": 440, "wires": [ [ "29e3211fd487e017" ] ] }, { "id": "29e3211fd487e017", "type": "function", "z": "fcafa14fdc883280", "name": "build page", "func": "// A live page from core nodes only — no node-red-dashboard install. Every extra\n// install step is a place a build dies on someone else's phone.\n//\n// Deliberately mirrors the cloud dashboard (farm.sunriselabs.io): one card per\n// reporting node, the same colour semantics, the same valve panel. Same system,\n// two windows — and this one keeps working with no internet at all, which is the\n// whole reason it exists.\nconst now = Date.now();\nconst nodes = flow.get(\"nodes\") || {};\n\n// ── EDIT HERE: your tank ────────────────────────────────────────────────────\n// TANK_FULL_MM is the water depth ABOVE THE PROBE when the tank is at its overflow —\n// not the tank's outside height: the probe sits above the floor and the overflow sits\n// below the lid, and both errors inflate the percentage. 0 = unknown, and the page\n// shows depth only rather than inventing a number.\n//\n// Here and not in firmware, so re-siting a probe needs no reflash of a node that may be\n// up a ladder. A node that sends its own `percent` (TANK_FULL_MM in config.h) still wins.\n//\n// A stackable tank is a cone frustum, narrower at the base so they nest, so its bottom\n// half holds less than half the water and a depth ratio overstates it by up to ~4 points\n// right where it matters, near empty. Give the shape and percent is by VOLUME; leave the\n// three shape values 0 for a straight-sided tank.\n//\n// Example, Maani's Aqua Tanks AFS6000: 1720 mm full, 5680 L, base 1877, top 2278, 2000 tall.\nconst TANK_FULL_MM = 0; // mm of water above the probe when full\nconst TANK_LITRES = 0; // litres at that level; 0 = no litres figure\nconst TANK_BASE_DIAM_MM = 0; // shape: base diameter, mm\nconst TANK_TOP_DIAM_MM = 0; // shape: top diameter, mm\nconst TANK_HEIGHT_MM = 0; // shape: overall height, mm\n// ────────────────────────────────────────────────────────────────────────────\n\n// A timed open that has run out closes here too, not only on the node's poll — so the\n// page never shows OPEN for a run whose time is up just because the node went quiet.\nif (flow.get(\"valveCmd\") && flow.get(\"valveUntil\") && now >= flow.get(\"valveUntil\")) {\n flow.set(\"valveCmd\", 0);\n flow.set(\"valveSince\", null);\n flow.set(\"valveUntil\", null);\n}\n\nfunction esc(s) {\n return String(s === undefined || s === null ? \"\" : s).replace(/[&<>\"']/g, function (c) {\n return { \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" }[c];\n });\n}\n\nfunction ago(ms) {\n const s = Math.max(0, Math.round((now - ms) / 1000));\n if (s < 60) return s + \"s ago\";\n if (s < 3600) return Math.floor(s / 60) + \"m ago\";\n if (s < 86400) return Math.floor(s / 3600) + \"h ago\";\n return Math.floor(s / 86400) + \"d ago\";\n}\n\n// farm-node.ino reports the pin as the STRING \"open\"/\"closed\"; bench payloads send\n// 1/0. A bare truthiness test reads \"closed\" as OPEN — the page would have told you\n// the valve was open while it sat shut. Normalise before believing it.\nfunction valveState(v) {\n if (v === true || v === 1) return 1;\n if (v === false || v === 0) return 0;\n if (typeof v === \"string\") {\n const s = v.trim().toLowerCase();\n if (s === \"open\" || s === \"1\" || s === \"true\") return 1;\n if (s === \"closed\" || s === \"shut\" || s === \"0\" || s === \"false\") return 0;\n }\n return null;\n}\n\n// --- battery percentage -------------------------------------------------------\n// Cell volts -> % of USABLE charge, for the 1S Li-ion soil nodes.\n//\n// Anchored at the FIRMWARE'S OWN CUTOFF (VBAT_CUTOFF_V, 3.30 V) rather than the\n// cell's absolute floor: below that the node sleeps indefinitely and stops\n// reporting, so 0% here means \"about to go dark\", which is the thing worth acting\n// on. 100% is 4.20 V, which is also the ADC's full-scale point.\n//\n// Piecewise, not linear, because a Li-ion sits between 3.7 and 3.8 V for most of\n// its life: a straight line would read ~50% for a fortnight and then fall off a\n// cliff, which is worse than no gauge at all.\n//\n// WARNING - voltage under load reads LOW. A node mid-transmit can dip a couple of\n// tenths and recover, so a single low sample is not a flat battery. This is an\n// indicator, not a fuel gauge.\nfunction battPct(v) {\n var n = Number(v);\n if (!isFinite(n)) return null;\n // Only the 1S soil nodes use this curve. The solar pack (3S, 12.6 V full) has a\n // completely different range - running it through this would clamp it to 100%\n // forever and hide a dying pack. Out of 1S range = show volts, claim nothing.\n if (n > 4.6 || n < 2.0) return null;\n var curve = [\n [4.20, 100], [4.10, 90], [4.00, 80], [3.95, 70], [3.87, 60],\n [3.82, 50], [3.79, 40], [3.77, 30], [3.74, 20], [3.68, 15],\n [3.60, 10], [3.45, 5], [3.30, 0]\n ];\n if (n >= curve[0][0]) return 100;\n if (n <= curve[curve.length - 1][0]) return 0;\n for (var i = 0; i < curve.length - 1; i++) {\n var vHi = curve[i][0], pHi = curve[i][1];\n var vLo = curve[i + 1][0], pLo = curve[i + 1][1];\n if (n <= vHi && n >= vLo) {\n return Math.round(pLo + (n - vLo) * (pHi - pLo) / (vHi - vLo));\n }\n }\n return null;\n}\n\n// --- solar pack percentage (3S) -----------------------------------------------\n// The curve above is for the soil nodes' SINGLE cell and would clamp a 12 V pack to\n// 100% forever. This is that same curve multiplied by three so both gauges read the\n// same way: 100% at 12.60 V (3 x 4.20), 0% at 9.90 V (3 x 3.30), which is where you\n// are into the deep discharge the BMS eventually cuts.\n//\n// WARNING, and it bites harder here than on the soil nodes: while the MPPT is in CV\n// the terminal sits near 12.6 V REGARDLESS of true state of charge, so this reads\n// high through the middle of a sunny day and sags under load. A gauge to glance at,\n// not a fuel gauge to trust. The volts stay on screen next to it for that reason.\nfunction packPct(mv) {\n var v = Number(mv) / 1000;\n if (!isFinite(v) || v < 6 || v > 15) return null; // not a 3S pack, claim nothing\n var curve = [\n [12.60, 100], [12.30, 90], [12.00, 80], [11.85, 70], [11.61, 60],\n [11.46, 50], [11.37, 40], [11.31, 30], [11.22, 20], [11.04, 15],\n [10.80, 10], [10.35, 5], [9.90, 0]\n ];\n if (v >= curve[0][0]) return 100;\n if (v <= curve[curve.length - 1][0]) return 0;\n for (var i = 0; i < curve.length - 1; i++) {\n var vHi = curve[i][0], pHi = curve[i][1];\n var vLo = curve[i + 1][0], pLo = curve[i + 1][1];\n if (v <= vHi && v >= vLo) return Math.round(pLo + (v - vLo) * (pHi - pLo) / (vHi - vLo));\n }\n return null;\n}\n\n// --- tank percentage ----------------------------------------------------------\n// Water volume below depthMm, in arbitrary units — only ever a ratio against the full\n// level, so pi and the probe's small height off the floor cancel. Frustum: integral of\n// pi*(r0 + k*z)^2 dz from 0 to h, pi dropped. The same maths as farm-ingest, so the two\n// dashboards cannot disagree about the same tank.\nfunction tankVolume(depthMm) {\n var h = Math.max(0, depthMm) / 1000;\n if (!(TANK_BASE_DIAM_MM > 0 && TANK_TOP_DIAM_MM > 0 && TANK_HEIGHT_MM > 0)) return h;\n var r0 = TANK_BASE_DIAM_MM / 2000;\n var k = (TANK_TOP_DIAM_MM - TANK_BASE_DIAM_MM) / 2000 / (TANK_HEIGHT_MM / 1000);\n return r0 * r0 * h + r0 * k * h * h + (k * k * h * h * h) / 3;\n}\n\n// Fraction full, 0..1. Overfull reads full, not 103%: the overflow is the top, and\n// past it the water leaves.\nfunction tankFrac(r) {\n if (typeof r.percent === \"number\") return Math.max(0, Math.min(1, r.percent / 100));\n if (!(TANK_FULL_MM > 0) || typeof r.depth_mm !== \"number\") return null;\n return tankVolume(Math.min(r.depth_mm, TANK_FULL_MM)) / tankVolume(TANK_FULL_MM);\n}\n\n// Litres to the nearest 50: the probe is ±25 mm (0.5 % FS), ~±100 L in a 5,680 L tank,\n// so a figure to the litre would claim precision nobody measured.\nfunction litres(f) {\n var n = Math.round((TANK_LITRES * f) / 50) * 50;\n return String(n).replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\") + \" L\";\n}\n\n// --- the visual ---------------------------------------------------------------\n// A bar you can read at arm's length in sunlight, which a bare number is not. The\n// number always rides alongside it: the picture is for glancing, the digits are for\n// deciding.\nfunction bar(pct) {\n var n = Math.max(0, Math.min(100, Number(pct)));\n var cls = n > 50 ? \"bh\" : n > 20 ? \"bm\" : \"bl\";\n return '';\n}\n\n// How long this node may be quiet before we call it stale — learned from its own\n// arrival history, so the 10-minute sleep node and the 60-second farm-node can\n// share a page without one of them being permanently wrong.\nfunction staleAfter(r) {\n if (!r.gaps || r.gaps.length < 3) return 20 * 60000;\n const g = r.gaps.slice().sort(function (a, b) { return a - b; });\n const median = g[Math.floor(g.length / 2)] || 60000;\n return Math.max(3 * median, 5 * 60000);\n}\n\nconst list = Object.keys(nodes).map(function (k) { return nodes[k]; });\nlist.sort(function (a, b) {\n const rank = function (r) { return r.kind === \"water\" ? 0 : r.kind === \"soil\" ? 1 : 2; };\n return rank(a) - rank(b) || String(a.node).localeCompare(String(b.node));\n});\n\nfunction renderCard(r) {\n const title = r.kind === \"water\" ? \"Water tank\" : r.kind === \"soil\" ? \"Soil\" : esc(r.kind);\n const quiet = now - r.at;\n const cls = r.ok === false ? \"bad\" : quiet > staleAfter(r) ? \"stale\" : \"good\";\n\n let body;\n if (r.ok === false) {\n body = '

FAULT — ' + esc(r.error || \"no error given\") + \"

\";\n // A faulted probe still leaves the last thing it knew. Dated, in the muted\n // line, never the big number.\n if (r.kind === \"water\" && r.last_good) {\n var gf = tankFrac(r.last_good);\n body += '

last good reading ' + ago(r.last_good.at) + \": \"\n + (gf !== null ? Math.round(100 * gf) + \"% full · \" : \"\")\n + esc(r.last_good.depth_mm) + \" mm

\";\n }\n } else if (r.kind === \"water\") {\n // Percent leads because it is what you act on; mm and raw stay because they\n // are what you diagnose with. Never show one without the other.\n var f = tankFrac(r);\n var detail = [esc(r.depth_mm) + \" mm deep\"];\n if (f !== null && TANK_LITRES > 0) detail.push(\"≈ \" + litres(f));\n if (r.raw !== undefined) detail.push(\"raw \" + esc(r.raw));\n if (f !== null) {\n var tp = Math.round(100 * f);\n body = '

' + tp + '% full

'\n + '

' + bar(tp) + detail.join(\" · \") + \"

\";\n } else {\n body = '

' + esc(r.depth_mm) + \"mm

\"\n + '

tank size not set — depth only'\n + (r.raw !== undefined ? \" · raw \" + esc(r.raw) : \"\") + \"

\";\n }\n } else {\n body = '

' + esc(r.moisture_pct) + \"%

\";\n const bits = [];\n if (r.temp_c !== undefined) bits.push(esc(r.temp_c) + \" °C\");\n if (r.ec !== undefined) bits.push(\"EC \" + esc(r.ec));\n if (r.vbat !== undefined) {\n // Percent leads because it is what you act on; the volts stay because\n // they are what you diagnose with. Never show one without the other.\n var bp = battPct(r.vbat);\n var batTxt = (bp === null ? \"\" : bp + \"% \") + \"(\" + esc(r.vbat) + \" V)\";\n if (r.low_battery) batTxt = \"⚠ \" + batTxt + \" LOW\";\n bits.push(\"batt \" + batTxt);\n }\n const vs = valveState(r.valve);\n if (vs !== null) bits.push(\"valve \" + (vs ? \"OPEN\" : \"closed\"));\n body += '

' + bits.join(\" · \") + \"

\";\n }\n\n const meta = [ago(r.at)];\n if (cls === \"stale\") meta.push(\"quiet\");\n if (r.rssi !== undefined) meta.push(esc(r.rssi) + \" dBm\");\n if (r.sleep_min !== undefined) meta.push(\"sleeps \" + esc(r.sleep_min) + \"m\");\n\n return '

' + title\n + ' ' + esc(r.node) + \"

\"\n + body\n + '

' + meta.join(\" · \") + \"

\";\n}\n\nconst waterCards = list.filter(function (r) { return r.kind === \"water\"; }).map(renderCard).join(\"\");\nconst otherCards = list.filter(function (r) { return r.kind !== \"water\"; }).map(renderCard).join(\"\");\n\n// --- the solar pack -----------------------------------------------------------\n// Its own card, not a line on the water card. The pack powers everything, and as a\n// line it vanished whenever the water probe faulted: the fault branch replaces the\n// card body, pack included, exactly when the battery is most worth watching. The\n// reading still rides on the water node's payload — that board has the divider on A0.\nlet packNode = null;\nlist.forEach(function (r) {\n if (r.pack_mv !== undefined && (!packNode || r.at > packNode.at)) packNode = r;\n});\nlet packCard = \"\";\nif (packNode) {\n const pp = packPct(packNode.pack_mv);\n const pv = (Number(packNode.pack_mv) / 1000).toFixed(2);\n const pquiet = now - packNode.at > staleAfter(packNode);\n const pbad = pp === null || packNode.pack_ok === false;\n let pbody;\n if (packNode.pack_ok === false) {\n pbody = '

' + esc(pv) + \"V

\"\n + '

⚠ pack reading INVALID — raw ' + esc(packNode.pack_raw)\n + \", check the divider

\";\n } else if (pp === null) {\n pbody = '

' + esc(pv) + \"V

\"\n + '

outside 3S range — check the divider

';\n } else {\n pbody = '

' + pp + \"%

\"\n + '

' + bar(pp) + esc(pv) + \" V\"\n + (packNode.pack_cal === false ? \" uncal\" : \"\") + \"

\";\n }\n if (packNode.charger !== undefined) {\n var on = String(packNode.charger) === \"on\";\n pbody += '

phone charger '\n + (on ? \"ON\" : \"off\") + \"\"\n + (on ? \" — there is surplus\" : \" — no surplus, phone on its own battery\")\n + \"

\";\n }\n packCard = '
'\n + '

Solar pack 12 V · via ' + esc(packNode.node) + \"

\"\n + pbody\n + '

' + ago(packNode.at) + (pquiet ? \" · quiet\" : \"\") + \"

\";\n}\n\n// --- the phone itself ---------------------------------------------------------\n// Node-RED runs ON this phone (Termux), so its battery needs no network call —\n// termux-battery-status reads it locally. Worth a card of its own because it is\n// the ONE thing that closes the loop on the charger duty cycle: the node decides\n// there is surplus and switches the relay, and this says whether the phone is\n// actually charging as a result. Commanded and confirmed are not the same thing.\nconst ph = flow.get(\"phone\") || null;\nlet phoneCard = \"\";\nif (ph && ph.error) {\n phoneCard = '

Base station phone

'\n + '

battery unavailable — ' + esc(ph.error) + \"

\"\n + '

needs the Termux:API app plus pkg install termux-api

';\n} else if (ph && typeof ph.pct === \"number\") {\n // Plugged in counts: a full phone on the charger reports DISCHARGING, not CHARGING.\n const chg = ph.status === \"CHARGING\" || ph.status === \"FULL\" || (!!ph.plugged && ph.plugged !== \"UNPLUGGED\");\n phoneCard = '
30 ? \"good\" : ph.pct > 15 ? \"stale\" : \"bad\") + '\">'\n + \"

Base station phone

\"\n + '

' + ph.pct + \"%\"\n + (chg ? ' ' : \"\") + \"

\"\n + '

' + bar(ph.pct) + esc(ph.status || \"\")\n + (ph.plugged ? \" · \" + (ph.plugged === \"UNPLUGGED\" ? \"unplugged\" : \"plugged in\") : \"\")\n + (ph.temp !== undefined ? \" · \" + esc(ph.temp) + \" °C\" : \"\") + \"

\"\n + '

' + ago(ph.at) + \"

\";\n}\n\n// Valve — MANUAL. flow.valveCmd is the DESIRED state (set by these buttons, or by\n// the cloud dashboard arriving on a push response). The node echoes its ACTUAL pin\n// state back in /water as `valve`; commanded and confirmed are not the same thing.\n// flow.valveUntil is when a timed open ends (phone clock, ms), null for no timer.\nconst vcmd = flow.get(\"valveCmd\") ? 1 : 0;\nconst vuntil = vcmd ? (flow.get(\"valveUntil\") || 0) : 0;\nlet confirmedBy = null, confirmedVal = null, confirmedAt = 0, nodeOpenS = -1;\nlist.forEach(function (r) {\n const vs = valveState(r.valve);\n if (vs !== null) {\n confirmedBy = r.node;\n confirmedVal = vs;\n confirmedAt = r.at;\n // The node's OWN run timer, when it publishes one: measured at the pin.\n nodeOpenS = typeof r.valve_open_s === \"number\" ? r.valve_open_s : -1;\n }\n});\nconst agrees = confirmedVal !== null && confirmedVal === vcmd;\n\n// When the OPEN was commanded. Set on the 0->1 edge in `set desired valve state`\n// and on the cloud downlink; the fallback keeps a timer on screen for a valve that\n// was already open before this change shipped, rather than showing nothing.\nconst vsince = vcmd ? (flow.get(\"valveSince\") || now) : 0;\n\n// What the browser gets is AGES, never phone timestamps. The page is read on whatever\n// device is on the hotspot and its clock is not the phone's: a few minutes of skew and\n// a countdown built from the phone's epoch reads nonsense.\n//\n// Run time is the LONGER of the node's pin timer and the time since the open was\n// commanded. The node's resets whenever its VALVE_MAX_OPEN_S guard blips the relay at\n// 30 min and the next poll re-opens it, so on a 1 hour run it alone would read 0 again\n// halfway through; the commanded time is what the person pressing the button means.\nconst runS = !vcmd ? -1 : Math.max((now - vsince) / 1000,\n nodeOpenS >= 0 && confirmedVal === 1 ? nodeOpenS + (now - confirmedAt) / 1000 : 0);\nconst leftS = vuntil ? Math.max(0, (vuntil - now) / 1000) : -1;\n\n// Closed: three ways to open. Open: the same buttons re-plan the close from now, so\n// they say that rather than \"Open\" on a valve that already is.\nconst valveCard =\n '

Master valve

'\n + '

' + (vcmd ? \"OPEN\" : \"CLOSED\")\n + (vcmd ? ' ' : \"\") + \"

\"\n + (vcmd ? '

' + (vuntil ? \"\" : \"no timer — stays open until you press Close\") + \"

\" : \"\")\n + '
'\n + '\"\n + '\"\n + '
'\n + '\"\n + '\"\n + \"
\"\n + '

'\n + (confirmedVal === null\n ? \"no node has reported a valve position yet\"\n : agrees\n ? \"confirmed \" + (confirmedVal ? \"open\" : \"closed\") + \" by \" + esc(confirmedBy)\n : \"⚠ \" + esc(confirmedBy) + \" still reports \" + (confirmedVal ? \"OPEN\" : \"CLOSED\") + \" — not applied yet\")\n + \"

\";\n\n// Backhaul health. If the cloud push is broken this is where you find out, rather\n// than by noticing farm.sunriselabs.io has quietly stopped moving.\nconst ps = flow.get(\"pushState\") || {};\nconst queued = (flow.get(\"outbox\") || []).length;\nconst pushLine = ps.at\n ? (ps.ok ? \"cloud sync OK · \" + ago(ps.at) : \"⚠ cloud sync failing · \" + esc(ps.error) + \" · \" + ago(ps.at))\n : \"cloud sync: nothing sent yet\";\n\n// NEVER let this page be cached. It is a live instrument read on a phone, and a\n// cached copy of it is worse than a blank screen: it shows a plausible depth from\n// hours ago with no hint that it is stale. Express (Node-RED's http nodes) puts an\n// ETag on every response and sets no freshness policy at all, which leaves Android\n// free to re-serve the page on a reload — the symptom was having to clear browsing\n// data to see the current reading. `no-store` settles it for the browser; the\n// cache-busted reload URL below settles it for everything in between.\nmsg.headers = {\n \"content-type\": \"text/html; charset=utf-8\",\n \"cache-control\": \"no-store, no-cache, must-revalidate, max-age=0\",\n \"pragma\": \"no-cache\",\n \"expires\": \"0\"\n};\nmsg.payload =\n''\n+ ''\n+ 'Farmers IoT — base station\"\n+ \"

Farmers IoT Toolkit · base station

\"\n+ '

' + pushLine + (queued ? \" · \" + queued + \" queued\" : \"\") + \"

\"\n+ '
'\n+ (waterCards || otherCards || packCard ? waterCards + packCard + valveCard + otherCards\n : '

No nodes yet

waiting for a first reading

' + valveCard)\n+ phoneCard + \"
\"\n+ \"
This page works with no internet. Same data at farm.sunriselabs.io when the phone has signal.
\"\n+ \"\"\n+ \"\";\n\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 320, "y": 440, "wires": [ [ "7872e612fe744f37" ] ] }, { "id": "7872e612fe744f37", "type": "http response", "z": "fcafa14fdc883280", "name": "serve page", "statusCode": "200", "headers": {}, "x": 510, "y": 440, "wires": [] }, { "id": "af1df82f3513adcf", "type": "http in", "z": "fcafa14fdc883280", "name": "GET /valve", "url": "/valve", "method": "get", "upload": false, "swaggerDoc": "", "x": 140, "y": 540, "wires": [ [ "a4d4b43e12d844c7" ] ] }, { "id": "a4d4b43e12d844c7", "type": "function", "z": "fcafa14fdc883280", "name": "valve cmd -> 1/0", "func": "// The node polls this to learn the desired valve state. Plain \"1\"/\"0\" so the\n// firmware needs no JSON parser. flow.valveCmd is set by the page buttons.\n//\n// A TIMED open (the 30 min / 1 hour buttons) ends HERE, on the node's own poll,\n// and not in an inject timer: Android dozes Termux and a Node-RED timer can simply\n// stop firing, while every poll from the node wakes the process. So the close lands\n// within a second of the deadline for as long as the node can reach the phone — and\n// when it can't, the node's own VALVE_MAX_OPEN_S is the guard.\nconst until = flow.get('valveUntil') || 0;\nif (flow.get('valveCmd') && until && Date.now() >= until) {\n flow.set('valveCmd', 0);\n flow.set('valveSince', null);\n flow.set('valveUntil', null);\n node.status({ fill: 'grey', shape: 'dot', text: 'timer ended, CLOSED at ' + new Date().toLocaleTimeString() });\n}\n\n// no-store because this is a command, not a document: anything that ever caches\n// it hands the node a valve state from the past, and does so most eagerly on the\n// bad link where that is most dangerous.\nmsg.payload = flow.get('valveCmd') ? '1' : '0';\nmsg.headers = { 'content-type': 'text/plain', 'cache-control': 'no-store' };\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 340, "y": 540, "wires": [ [ "be546a4878ed3a8f" ] ] }, { "id": "be546a4878ed3a8f", "type": "http response", "z": "fcafa14fdc883280", "name": "200", "statusCode": "200", "headers": {}, "x": 540, "y": 540, "wires": [] }, { "id": "f1a73ae531fdfba1", "type": "http in", "z": "fcafa14fdc883280", "name": "POST /valve/set", "url": "/valve/set", "method": "post", "upload": false, "swaggerDoc": "", "x": 150, "y": 620, "wires": [ [ "744d15d58e216b88" ] ] }, { "id": "744d15d58e216b88", "type": "function", "z": "fcafa14fdc883280", "name": "set desired valve state", "func": "// The page's buttons POST here: ?v=1 opens, ?v=0 closes, and &ttl= makes\n// the open a TIMED one (the 30 min / 1 hour buttons). No ttl = open with no timer.\n// This only records the DESIRED state; the node applies it on its next poll and\n// reports back what the pin actually did. The timed close happens in `valve cmd -> 1/0`.\nconst q = (msg.req && msg.req.query) || {};\nconst v = q.v !== undefined ? q.v : msg.payload;\nconst on = String(v).indexOf('1') >= 0;\nconst was = flow.get('valveCmd') ? 1 : 0;\nmsg.headers = { 'content-type': 'text/plain', 'cache-control': 'no-store' };\n\n// Same bounds as farm-ingest's /api/valve. A typo'd ttl must not become a week of\n// irrigation, so anything outside 1 s .. 24 h is refused rather than guessed at.\nconst hasTtl = q.ttl !== undefined && q.ttl !== '';\nconst ttl = hasTtl ? Number(q.ttl) : 0;\nif (on && hasTtl && !(ttl >= 1 && ttl <= 86400)) {\n msg.statusCode = 400;\n msg.payload = 'ttl must be 1..86400 seconds';\n return msg;\n}\n\nflow.set('valveCmd', on ? 1 : 0);\n\n// Stamp the 0->1 EDGE only, so pressing Open on an already-open valve does not\n// silently restart the run timer on the page. Cleared on close: a stale start\n// time is what makes a timer lie.\nif (on && !was) flow.set('valveSince', Date.now());\nif (!on) flow.set('valveSince', null);\n\n// The close-by time, though, is set on EVERY press. A timed button on a valve that is\n// already open means \"close it this long from now\" — a change of plan, not a new run.\nflow.set('valveUntil', on && hasTtl ? Date.now() + ttl * 1000 : null);\n\nnode.status({ fill: on ? 'green' : 'grey', shape: 'dot',\n text: !on ? 'commanded CLOSED'\n : hasTtl ? 'commanded OPEN for ' + Math.round(ttl / 60) + ' min'\n : 'commanded OPEN, no timer' });\nmsg.payload = on ? '1' : '0';\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 360, "y": 620, "wires": [ [ "ff10b1b0f22123bc" ] ] }, { "id": "ff10b1b0f22123bc", "type": "http response", "z": "fcafa14fdc883280", "name": "200", "statusCode": "200", "headers": {}, "x": 560, "y": 620, "wires": [] }, { "id": "fa11c0de00000001", "type": "comment", "z": "fcafa14fdc883280", "name": "PUSH TO CLOUD — store-and-forward to farm-ingest", "info": "The phone is behind carrier CGNAT, so nothing can dial IN to it. This group\ndials OUT instead: readings are buffered in flow context and POSTed in batches\nto the VPS. Survives 4G dropouts — nothing is deleted from the buffer until\nthe server has answered 200.\n\n**Change the URL or token in the `drain outbox` function** — both live at the\ntop of that one node, nowhere else.\n\nBoth `http in` nodes feed `queue for cloud` directly rather than going through\nthe `store …` functions, because those return null on `ok:false` and the fault\nrows are the ones you most want off the phone.\n\nThe push is EVENT-DRIVEN: every arriving reading triggers a drain. Android\nfreezes Termux's JS timers when the phone dozes, so a purely scheduled push\nsilently stops while everything still looks healthy. An ESP POST has already\nwoken the process, so draining on that event needs no wake-lock. The 30 s\ninject is only a backstop for retrying while no new readings are arriving.\n\n⚠️ The buffer lives in memory. A Node-RED restart drops whatever is queued.\nTo survive restarts, add a filesystem context store in settings.js.", "x": 250, "y": 620, "wires": [] }, { "id": "fa11c0de00000002", "type": "function", "z": "fcafa14fdc883280", "name": "queue for cloud", "func": "// Buffer every reading — good AND faulted — for the cloud push.\n//\n// Wired straight off the 'http in' nodes ON PURPOSE. The 'store water'/'store soil'\n// functions return null when ok:false, so tapping them would drop exactly the rows\n// that tell you a probe died. A silent node and a broken probe are different repairs.\n\nconst p = msg.payload || {};\nconst url = (msg.req && msg.req.url) || \"\";\nconst kind = url.indexOf(\"soil\") !== -1 ? \"soil\"\n : url.indexOf(\"water\") !== -1 ? \"water\"\n : (p.moisture_pct !== undefined ? \"soil\" : \"water\");\n\n// client_id makes redelivery idempotent. A batch whose response is lost in a 4G\n// dropout gets re-sent; without this key the server would count it twice, and it\n// would do so precisely during bad signal — when the data matters most.\nconst seq = (flow.get(\"pushSeq\") || 0) + 1;\nflow.set(\"pushSeq\", seq);\n\nconst row = Object.assign({}, p, {\n kind: kind,\n client_id: kind + \":\" + (p.node || \"unknown\") + \":\" + Date.now() + \":\" + seq\n});\n\nconst outbox = flow.get(\"outbox\") || [];\noutbox.push(row);\n\n// ~17 hours of backlog for two sensors at 60 s. Past that, drop the OLDEST —\n// during a long outage the newest readings are the ones worth having.\nconst CAP = 2000;\nif (outbox.length > CAP) outbox.splice(0, outbox.length - CAP);\nflow.set(\"outbox\", outbox);\n\nnode.status({ fill: outbox.length > 50 ? \"yellow\" : \"grey\", shape: \"ring\", text: \"queued \" + outbox.length });\n\n// Drain right here, on the arriving reading, rather than waiting for the timer.\n//\n// Android freezes Termux's JS timers whenever the phone dozes — a 30 s setInterval\n// simply stops running, and the backlog grows forever while everything LOOKS fine.\n// An incoming POST from the ESP has already woken the process, so draining on that\n// event needs no wake-lock and no scheduler. The inject stays as a backstop.\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 480, "y": 680, "wires": [ [ "fa11c0de00000004" ] ] }, { "id": "fa11c0de00000003", "type": "inject", "z": "fcafa14fdc883280", "name": "every 30 s (backstop)", "props": [ { "p": "payload" } ], "repeat": "30", "crontab": "", "once": true, "onceDelay": "15", "topic": "", "payload": "", "payloadType": "date", "x": 160, "y": 760, "wires": [ [ "fa11c0de00000004" ] ] }, { "id": "fa11c0de00000004", "type": "function", "z": "fcafa14fdc883280", "name": "drain outbox ← URL + TOKEN HERE", "func": "// ── EDIT HERE: where the readings go ────────────────────────────────────────\nconst INGEST_URL = \"https://YOUR-SERVER.example.com/ingest/batch\";\nconst INGEST_TOKEN = \"PASTE-YOUR-FARM_TOKEN-HERE\";\n// ────────────────────────────────────────────────────────────────────────────\n\nconst BATCH = 50; // ~8 KB per POST — cheap on a metered SIM\nconst LOCK_MS = 90000; // if a POST neither succeeds nor errors, unstick after this\n\nconst outbox = flow.get(\"outbox\") || [];\nif (outbox.length === 0) {\n node.status({ fill: \"green\", shape: \"dot\", text: \"up to date\" });\n return null;\n}\n\n// Don't stack POSTs on top of each other on slow 4G. The server dedupes anyway,\n// but piling requests onto a marginal link only makes the link worse.\nconst lock = flow.get(\"pushLock\") || 0;\nif (Date.now() - lock < LOCK_MS) {\n node.status({ fill: \"blue\", shape: \"ring\", text: \"sending… (\" + outbox.length + \" queued)\" });\n return null;\n}\nflow.set(\"pushLock\", Date.now());\n\nconst batch = outbox.slice(0, BATCH);\n\n// NOTHING is removed from the outbox here. Items are only dropped once the server\n// has confirmed 200. Delete-then-send loses data on every failed request.\nmsg.batchIds = batch.map(function (r) { return r.client_id; });\nmsg.url = INGEST_URL;\nmsg.headers = {\n \"Content-Type\": \"application/json\",\n \"Authorization\": \"Bearer \" + INGEST_TOKEN\n};\nmsg.payload = { readings: batch };\n\nnode.status({ fill: \"blue\", shape: \"dot\", text: \"sending \" + batch.length + \" of \" + outbox.length });\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 400, "y": 760, "wires": [ [ "fa11c0de00000005" ] ] }, { "id": "fa11c0de00000005", "type": "http request", "z": "fcafa14fdc883280", "name": "POST → farm-ingest", "method": "POST", "ret": "obj", "paytoqs": "ignore", "url": "", "tls": "", "persist": false, "proxy": "", "insecureHTTPParser": false, "authType": "", "senderr": false, "headers": [], "timeout": "30000", "x": 690, "y": 760, "wires": [ [ "fa11c0de00000006" ] ] }, { "id": "fa11c0de00000006", "type": "function", "z": "fcafa14fdc883280", "name": "confirm / keep", "func": "// Only a 200 that CAME FROM farm-ingest clears the buffer.\n//\n// Checking the status code alone is not enough: a misconfigured vhost, a captive\n// portal, or a proxy error page all return 200 with a body that isn't ours. That\n// would silently delete readings we never delivered — the worst possible failure\n// for a store-and-forward queue. Demand the service's own {ok:true} envelope.\nflow.set(\"pushLock\", 0);\n\nconst body = msg.payload || {};\nconst delivered = msg.statusCode === 200 && body.ok === true && typeof body.accepted === \"number\";\n\nif (!delivered) {\n // Surfaced on the local page too — a broken backhaul should be visible there,\n // not discovered by noticing the cloud dashboard stopped moving.\n flow.set(\"pushState\", { ok: false, at: Date.now(), error: \"HTTP \" + msg.statusCode });\n node.status({ fill: \"red\", shape: \"ring\", text: \"HTTP \" + msg.statusCode + \" — kept, will retry\" });\n node.warn(\"farm-ingest did not confirm the batch (HTTP \" + msg.statusCode + \"): \" +\n JSON.stringify(body).slice(0, 200));\n return null;\n}\nflow.set(\"pushState\", { ok: true, at: Date.now(), error: null });\n\nconst sent = msg.batchIds || [];\nconst done = {};\nsent.forEach(function (id) { done[id] = true; });\n\nconst outbox = (flow.get(\"outbox\") || []).filter(function (r) { return !done[r.client_id]; });\nflow.set(\"outbox\", outbox);\n\n// ── downlink: the valve command rides home on this response ─────────────────\n//\n// Applied on the EDGE of seq, never re-asserted. The local page's Open/Close\n// buttons must keep working with no internet; if every push re-applied the cloud's\n// state, a button pressed at the tank would be silently undone a minute later.\n//\n// On first contact we adopt the seq WITHOUT acting. Node-RED's context is in\n// memory, so a restart forgets valveCmd and the valve falls shut — and it should\n// STAY shut until a human asks again, rather than a stale cloud \"open\" re-opening\n// it unattended. Fail closed is the only safe default for irrigation.\nconst v = body.valve;\nif (v && typeof v.seq === \"number\") {\n const lastSeq = flow.get(\"valveSeq\");\n if (lastSeq === undefined) {\n flow.set(\"valveSeq\", v.seq);\n } else if (v.seq !== lastSeq) {\n flow.set(\"valveSeq\", v.seq);\n flow.set(\"valveCmd\", v.state ? 1 : 0);\n flow.set(\"valveSince\", v.state ? Date.now() : null);\n // A cloud open that carries its expiry gets a LOCAL timer too, so the run still\n // ends on time if the 4G drops mid-run: the server's own expiry only reaches the\n // phone over the link that just failed. No expiry sent = no local timer, and the\n // server's seq bump does the closing, as before.\n const exp = v.state && v.expires_at ? Date.parse(v.expires_at) : NaN;\n flow.set(\"valveUntil\", isFinite(exp) ? exp : null);\n node.warn(\"valve \" + (v.state ? \"OPEN\" : \"SHUT\") + \" commanded from the cloud (seq \" + v.seq + \")\");\n }\n}\n\nnode.status({\n fill: outbox.length ? \"yellow\" : \"green\",\n shape: \"dot\",\n text: \"sent \" + sent.length + \" (\" + (body.accepted || 0) + \" new, \" + (body.duplicates || 0) + \" dup) · \" + outbox.length + \" queued\"\n});\n\n// Anything still queued? Go straight round again instead of waiting to be poked.\n//\n// Without this, the tail of a burst sits undelivered until the NEXT reading happens\n// to arrive — and on a dozing phone the 30 s inject that was supposed to cover that\n// case never fires. Each pass strictly shrinks the outbox, so this terminates.\nif (outbox.length > 0) return { payload: \"continue\" };\nreturn null;\n", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 930, "y": 760, "wires": [ [ "fa11c0de00000004" ] ] }, { "id": "fa11c0de00000007", "type": "catch", "z": "fcafa14fdc883280", "name": "network failure", "scope": [ "fa11c0de00000005" ], "uncaught": false, "x": 690, "y": 830, "wires": [ [ "fa11c0de00000008" ] ] }, { "id": "fa11c0de00000008", "type": "function", "z": "fcafa14fdc883280", "name": "offline — keep queued", "func": "// Network-level failure: no signal, DNS down, TLS refused, timeout. The batch is\n// still in the outbox untouched — clear the lock and let the next tick retry.\nflow.set(\"pushLock\", 0);\nconst queued = (flow.get(\"outbox\") || []).length;\nflow.set(\"pushState\", { ok: false, at: Date.now(), error: \"offline\" });\nnode.status({ fill: \"red\", shape: \"ring\", text: \"offline — \" + queued + \" queued\" });\nreturn null;", "outputs": 0, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 930, "y": 830, "wires": [] }, { "id": "phbat_inject01", "type": "inject", "z": "fcafa14fdc883280", "name": "every 60 s", "props": [ { "p": "payload" } ], "repeat": "60", "crontab": "", "once": true, "onceDelay": "8", "topic": "", "payload": "", "payloadType": "date", "x": 170, "y": 820, "wires": [ [ "phbat_exec01" ] ] }, { "id": "phbat_exec01", "type": "exec", "z": "fcafa14fdc883280", "command": "PATH=$PATH:/data/data/com.termux/files/usr/bin termux-battery-status", "addpay": false, "append": "", "useSpawn": "false", "timer": "10", "winHide": false, "oldrc": false, "name": "termux-battery-status", "x": 390, "y": 820, "wires": [ [ "phbat_store01" ], [ "phbat_store01" ], [ "phbat_store01" ] ] }, { "id": "phbat_store01", "type": "function", "z": "fcafa14fdc883280", "name": "store phone battery", "func": "// termux-battery-status prints JSON on stdout:\n// {\"health\":\"GOOD\",\"percentage\":85,\"status\":\"CHARGING\",\"temperature\":28.9,...}\n//\n// Node-RED runs ON the phone (Termux), so this is a local read — no network call, no\n// IP to go stale, and it keeps working on the FarmIoT hotspot with no internet.\n//\n// stderr is wired into this same input on purpose. Anything that does not parse is\n// recorded as the failure it is, because a battery gauge that has quietly stopped\n// updating is worse than no gauge: the number still looks plausible and you will act\n// on it. Same argument as never publishing WiFi.RSSI() while disconnected.\n// Every exec output lands here — stdout, stderr, and the return code — because a\n// silent failure is the one outcome that must not be possible. A battery gauge that\n// has quietly stopped updating is worse than no gauge: the number still looks\n// plausible and you will act on it.\nvar raw = msg.payload;\nif (raw && typeof raw === \"object\" && typeof raw.code === \"number\") {\n // return-code output: only interesting when it is non-zero, since a success also\n // arrives here right behind the stdout we already handled.\n if (raw.code === 0) return null;\n raw = \"exit \" + raw.code + (msg.payload.message ? \": \" + msg.payload.message : \"\");\n}\nif (typeof raw === \"string\" && raw.trim() === \"\") return null; // empty stderr on success\nvar out = { at: Date.now() };\n\ntry {\n var j = typeof raw === \"string\" ? JSON.parse(raw) : raw;\n if (j && typeof j.percentage === \"number\") {\n out.pct = Math.round(j.percentage);\n out.status = j.status || \"\";\n // At 100% Android reports DISCHARGING or NOT_CHARGING while plugged in, so status\n // alone cannot confirm the node's charger relay. `plugged` can.\n out.plugged = j.plugged || \"\";\n if (typeof j.temperature === \"number\") out.temp = Math.round(j.temperature * 10) / 10;\n flow.set(\"phone\", out);\n node.status({ fill: out.pct > 30 ? \"green\" : \"yellow\", shape: \"dot\",\n text: out.pct + \"% \" + out.status });\n\n // Queue for the cloud, but NOT at the 60 s polling rate. The page wants a\n // fresh number every minute; farm.sunriselabs.io does not, and the link is a\n // metered SIM. Send every 5 minutes — or immediately when the charging state\n // CHANGES, because that edge is the whole point: it is the confirmation that\n // the node's relay decision actually reached the phone.\n var last = flow.get(\"phonePushed\") || { at: 0, status: null };\n var edge = out.status !== last.status || out.plugged !== last.plugged;\n if (edge || Date.now() - last.at >= 300000) {\n var seq = (flow.get(\"pushSeq\") || 0) + 1;\n flow.set(\"pushSeq\", seq);\n var outbox = flow.get(\"outbox\") || [];\n outbox.push({\n kind: \"phone\",\n node: \"base-station\",\n ok: true, // ingest treats anything not ok:true as a fault\n batt_pct: out.pct,\n charging: out.status === \"CHARGING\" || out.status === \"FULL\" || (!!out.plugged && out.plugged !== \"UNPLUGGED\"),\n plugged: out.plugged,\n status: out.status,\n temp_c: out.temp,\n client_id: \"phone:base-station:\" + Date.now() + \":\" + seq\n });\n var CAP = 2000;\n if (outbox.length > CAP) outbox.splice(0, outbox.length - CAP);\n flow.set(\"outbox\", outbox);\n flow.set(\"phonePushed\", { at: Date.now(), status: out.status, plugged: out.plugged });\n }\n return null;\n }\n} catch (e) { /* fall through */ }\n\nout.error = String(raw || \"no output\").slice(0, 120);\nflow.set(\"phone\", out);\nnode.status({ fill: \"red\", shape: \"ring\", text: \"battery read failed\" });\nreturn null;\n", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 650, "y": 820, "wires": [ [] ] } ]