/** * farm-ingest — the cloud half of the "push, don't expose" backhaul. * * The base-station phone sits behind carrier CGNAT, so nothing on the internet can * dial *in* to it. This service is what it dials *out* to: a small bun + SQLite * receiver that accepts the exact JSON the ESP nodes already POST to Node-RED, * stores it, and serves a read-only page you can open from anywhere. * * Design notes worth keeping: * * - **Failed reads are first-class.** `{"ok":false,"error":"..."}` rows are stored, * not rejected. A silent node is indistinguishable from a dead battery; an * explicit error says "node alive, probe isn't". Same invariant as the firmware. * * - **`client_id` makes delivery idempotent.** Store-and-forward on the phone will * re-send a batch whose response was lost in a 4G dropout. Without a dedupe key * that silently double-counts every reading taken during bad signal — which is * exactly when you care about the data. `INSERT OR IGNORE` on a UNIQUE column. * * - **The server clock is authoritative for time.** The ESP has no RTC and the * phone's clock can be anything. We store `received_at` ourselves and keep the * node's own `uptime_s` alongside it as a liveness signal, not a timestamp. * * - **Nothing is thrown away.** Known fields get typed columns for querying; the * complete original JSON goes into `payload` so a field we haven't thought of * yet is still there when we do. * * - **The valve downlink rides on the ingest response.** The phone cannot be dialled * into, so a command has to be something it collects. It already POSTs batches, so * every batch is answered with the desired valve state — zero extra requests, zero * extra data on a metered SIM, and still nothing exposed on the farm. * * - **Commands carry a `seq`, and the phone applies one only when it CHANGES.** The * local Node-RED page has its own Open/Close buttons and must keep working with no * internet at all. If the cloud re-asserted its state on every push, a button * pressed at the tank would be silently undone a minute later. Acting on the edge * means the cloud overrides only when someone actually presses a cloud button. * * Binds to 127.0.0.1 only — nginx terminates TLS in front of it. Never expose the * raw port; the bearer token is the only thing between this and the open internet. */ import { Database } from "bun:sqlite"; import { mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; // ── config ─────────────────────────────────────────────────────────────────── const PORT = Number(process.env.FARM_PORT ?? 8790); const HOST = process.env.FARM_HOST ?? "127.0.0.1"; const DB_PATH = process.env.FARM_DB ?? join(import.meta.dir, "data", "readings.db"); const TOKEN = process.env.FARM_TOKEN ?? ""; const RETENTION_DAYS = Number(process.env.FARM_RETENTION_DAYS ?? 400); // Water tank geometry, so the dashboard can show % full without reflashing the node. // FARM_TANK_FULL_MM is the water depth ABOVE THE PROBE when the tank is at its overflow // — not the tank's outside height: the probe sits above the floor and the overflow sits // below the lid, and both errors inflate the percentage. 0 / unset = unknown, and the // page shows depth only rather than inventing a number. A node that sends its own // `percent` (TANK_FULL_MM in its config.h) still wins, because it holds the probe. // FARM_TANK_LITRES adds a litres figure at that full level. // // Percent is by VOLUME, not depth, when the tank's shape is given. A stackable tank is // a cone frustum — narrower at the base so they nest — so its bottom half holds less // than half the water, and a depth ratio overstates the tank by up to ~4 points right // where it matters, near empty. Leave the three shape values unset for a straight-sided // tank and it falls back to plain depth. // // Maani's tank, from its label and the maker's brochure (Aqua Tanks 2021, ST06000 row): // AFS6000, 5,680 L, base Ø1877, top Ø2278, 2000 mm overall. That shape holds 5,681 L at // ~1718 mm of water — and the highest level the probe has ever logged is 1719-1721 mm // (2026-08-02). Brochure and data agree to a couple of mm, so FARM_TANK_FULL_MM=1720. const TANK_FULL_MM = Number(process.env.FARM_TANK_FULL_MM ?? 0); const TANK_LITRES = Number(process.env.FARM_TANK_LITRES ?? 0); const TANK_BASE_DIAM_MM = Number(process.env.FARM_TANK_BASE_DIAM_MM ?? 0); const TANK_TOP_DIAM_MM = Number(process.env.FARM_TANK_TOP_DIAM_MM ?? 0); const TANK_HEIGHT_MM = Number(process.env.FARM_TANK_HEIGHT_MM ?? 0); /** * Water volume below `depthMm`, in arbitrary units — only ever used as a ratio against * the full level, so pi, wall thickness and the probe's small height off the floor all * cancel or nearly so. Frustum: integral of pi*(r0 + k*z)^2 dz from 0 to h, pi dropped. */ function tankVolume(depthMm: number): number { const h = Math.max(0, depthMm) / 1000; if (!(TANK_BASE_DIAM_MM > 0 && TANK_TOP_DIAM_MM > 0 && TANK_HEIGHT_MM > 0)) return h; const r0 = TANK_BASE_DIAM_MM / 2000; const k = (TANK_TOP_DIAM_MM - TANK_BASE_DIAM_MM) / 2000 / (TANK_HEIGHT_MM / 1000); return r0 * r0 * h + r0 * k * h * h + (k * k * h * h * h) / 3; } const MAX_BODY = 1_000_000; // 1 MB — a 50-reading batch is ~8 KB const MAX_BATCH = 500; if (!TOKEN || TOKEN.length < 24) { console.error( "FATAL: FARM_TOKEN is unset or too short. Refusing to start unauthenticated.\n" + " Generate one with: openssl rand -hex 32", ); process.exit(1); } // ── storage ────────────────────────────────────────────────────────────────── mkdirSync(dirname(DB_PATH), { recursive: true }); const db = new Database(DB_PATH, { create: true }); db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA synchronous = NORMAL"); db.exec(` CREATE TABLE IF NOT EXISTS readings ( id INTEGER PRIMARY KEY AUTOINCREMENT, received_at TEXT NOT NULL, client_id TEXT UNIQUE, kind TEXT NOT NULL, node TEXT, ok INTEGER NOT NULL, error TEXT, depth_mm REAL, raw REAL, percent REAL, moisture_pct REAL, temp_c REAL, ec REAL, valve INTEGER, rssi INTEGER, uptime_s INTEGER, vbat REAL, pack_mv INTEGER, charger TEXT, batt_pct REAL, payload TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_kind_time ON readings (kind, received_at DESC); CREATE INDEX IF NOT EXISTS idx_time ON readings (received_at DESC); CREATE INDEX IF NOT EXISTS idx_node_time ON readings (node, received_at DESC); -- One row per controllable thing. 'seq' increments on every change and is what -- the phone edge-detects on; 'expires_at' is the fail-closed backstop. CREATE TABLE IF NOT EXISTS commands ( name TEXT PRIMARY KEY, state INTEGER NOT NULL, seq INTEGER NOT NULL, updated_at TEXT NOT NULL, expires_at TEXT, source TEXT ); `); db.run( `INSERT OR IGNORE INTO commands (name, state, seq, updated_at, expires_at, source) VALUES ('valve', 0, 0, ?, NULL, 'default')`, [new Date().toISOString()], ); // Migration: add columns to a database created before the field existed. // CREATE TABLE IF NOT EXISTS silently does nothing to an existing table, so a new // column has to be added explicitly — and this database already holds months of // readings that must survive. // // Historic rows are backfilled out of `payload`, where the value was being stored // all along: the publisher has always sent these fields when they existed, and the // full JSON has always been kept. The typed column is for querying, not for // capture — nothing was ever lost, it was just awkward to reach. // // Table-driven so the next field is one line here rather than another copy of this // block. Adding a row is safe to re-run: the column check makes it idempotent. { const ADDED: Array<{ name: string; type: string }> = [ { name: "vbat", type: "REAL" }, // 1S cell volts, soil nodes { name: "pack_mv", type: "INTEGER" }, // 3S solar pack millivolts, farm-node { name: "charger", type: "TEXT" }, // phone charger relay state, "on"/"off" { name: "batt_pct", type: "REAL" }, // base-station phone battery percent ]; const cols = db.query("PRAGMA table_info(readings)").all() as Array<{ name: string }>; const have = new Set(cols.map((c) => c.name)); for (const col of ADDED) { if (have.has(col.name)) continue; db.exec(`ALTER TABLE readings ADD COLUMN ${col.name} ${col.type}`); const filled = db.run( `UPDATE readings SET ${col.name} = json_extract(payload, '$.${col.name}') ` + `WHERE ${col.name} IS NULL AND json_extract(payload, '$.${col.name}') IS NOT NULL` ); console.log(`[migrate] added readings.${col.name}; backfilled ${filled.changes} row(s) from payload`); } } const insert = db.query(` INSERT OR IGNORE INTO readings (received_at, client_id, kind, node, ok, error, depth_mm, raw, percent, moisture_pct, temp_c, ec, valve, rssi, uptime_s, vbat, pack_mv, charger, batt_pct, payload) VALUES ($received_at, $client_id, $kind, $node, $ok, $error, $depth_mm, $raw, $percent, $moisture_pct, $temp_c, $ec, $valve, $rssi, $uptime_s, $vbat, $pack_mv, $charger, $batt_pct, $payload) `); const num = (v: unknown): number | null => typeof v === "number" && Number.isFinite(v) ? v : null; /** * Valve position, normalised to 1 / 0 / null. * * `farm-node.ino` reports the pin as the STRING "open" or "closed"; bench payloads * and curl tests send 1/0 or true/false. Accepting only the numeric form silently * dropped every real field reading's valve state to null — and any code that * truthiness-tests the raw value reads "closed" as OPEN, which is worse than * dropping it. Normalise once, here, and let everything downstream trust it. */ function valveState(v: unknown): number | null { if (v === true || v === 1) return 1; if (v === false || v === 0) return 0; if (typeof v === "string") { const s = v.trim().toLowerCase(); if (s === "open" || s === "1" || s === "true") return 1; if (s === "closed" || s === "shut" || s === "0" || s === "false") return 0; } return null; } type Stored = { accepted: number; duplicates: number }; function store(items: unknown[]): Stored { let accepted = 0; let duplicates = 0; const now = new Date().toISOString(); const tx = db.transaction((rows: unknown[]) => { for (const row of rows) { if (!row || typeof row !== "object") continue; const r = row as Record; // `kind` may be sent explicitly; otherwise infer it from which fields are present. const kind = typeof r.kind === "string" && r.kind ? r.kind : "depth_mm" in r || "raw" in r ? "water" : "moisture_pct" in r ? "soil" : "batt_pct" in r ? "phone" : "unknown"; const res = insert.run({ $received_at: now, $client_id: typeof r.client_id === "string" ? r.client_id : null, $kind: kind, $node: typeof r.node === "string" ? r.node : null, // Anything that isn't explicitly ok:true is treated as not-ok. A missing // flag is a broken publisher, and optimism here would hide real faults. $ok: r.ok === true ? 1 : 0, $error: typeof r.error === "string" ? r.error : null, $depth_mm: num(r.depth_mm), $raw: num(r.raw), $percent: num(r.percent), $moisture_pct: num(r.moisture_pct), $temp_c: num(r.temp_c), $ec: num(r.ec), $valve: valveState(r.valve), $rssi: num(r.rssi), $uptime_s: num(r.uptime_s), $vbat: num(r.vbat), // The pack and the charger ride on the WATER payload, because they belong to // the node rather than to a probe — so they keep arriving even when the probe // is faulted, which is exactly when you want to know what the battery is doing. $pack_mv: num(r.pack_mv), $charger: typeof r.charger === "string" ? r.charger : null, $batt_pct: num(r.batt_pct), $payload: JSON.stringify(r), }); if (res.changes > 0) accepted++; else duplicates++; } }); tx(items); return { accepted, duplicates }; } function prune() { const cutoff = new Date(Date.now() - RETENTION_DAYS * 86_400_000).toISOString(); const res = db.run("DELETE FROM readings WHERE received_at < ?", [cutoff]); if (res.changes > 0) console.log(`[prune] removed ${res.changes} rows older than ${RETENTION_DAYS}d`); } prune(); setInterval(prune, 24 * 3600 * 1000); // ── commands (the downlink) ────────────────────────────────────────────────── const DEFAULT_OPEN_TTL_S = Number(process.env.FARM_VALVE_TTL_S ?? 1800); // 30 min type Command = { name: string; state: number; seq: number; updated_at: string; expires_at: string | null; source: string | null; expired?: boolean; }; const qCommand = db.query("SELECT * FROM commands WHERE name = ?"); /** * Reads a command, applying its expiry. * * An OPEN that has run past `expires_at` is reported as CLOSED **with a bumped seq**, * so the phone sees a genuine edge and shuts the valve. Without the bump it would * treat the expiry as "no change" and irrigate until someone noticed. * * This is a backstop, not a guarantee: if the phone cannot reach us at all, no * expiry we compute here will arrive. Only VALVE_MAX_OPEN_S in the firmware closes * a valve when the link itself is what failed. */ function readCommand(name: string): Command { const c = qCommand.get(name) as Command | null; if (!c) return { name, state: 0, seq: 0, updated_at: new Date().toISOString(), expires_at: null, source: null }; if (c.state === 1 && c.expires_at && Date.parse(c.expires_at) <= Date.now()) { const now = new Date().toISOString(); db.run("UPDATE commands SET state = 0, seq = seq + 1, updated_at = ?, expires_at = NULL, source = 'expiry' WHERE name = ?", [now, name]); console.log(`[command] ${name} auto-closed — open window expired`); return { ...(qCommand.get(name) as Command), expired: true }; } return c; } function setCommand(name: string, state: number, ttlS: number | null, source: string): Command { const now = new Date().toISOString(); const expires = state === 1 ? new Date(Date.now() + (ttlS ?? DEFAULT_OPEN_TTL_S) * 1000).toISOString() : null; db.run( "UPDATE commands SET state = ?, seq = seq + 1, updated_at = ?, expires_at = ?, source = ? WHERE name = ?", [state, now, expires, source, name], ); console.log(`[command] ${name} := ${state ? "OPEN" : "CLOSED"} (${source})`); return qCommand.get(name) as Command; } // ── valve: commanded vs confirmed ──────────────────────────────────────────── // // The dashboard must never call the valve OPEN or CLOSED on the strength of a command. // A command is a request, and it takes about two minutes to reach the relay: server → // phone on its next push, phone → node on the node's 1 s poll, node → back up on its // next report (measured 2026-09-10: 1m49s to confirm an open, 1m56s a close). Until a // report that arrived AFTER the command shows the new state, the honest word is // "changing". // // Past the grace window a mismatch stops being "changing" and becomes a fault worth // saying out loud: the node is silent, or it is reporting but not obeying (someone // used the button at the tank, or the phone has stopped collecting commands). const VALVE_CONFIRM_GRACE_MS = 5 * 60_000; type ValvePhase = | "confirmed" // a report since the command shows the commanded state | "refreshing" // already in that state, waiting on the first report since the command | "changing" // commanded one way, node still reports the other, inside the grace window | "unconfirmed" // same, past the grace window | "unknown"; // no node has ever reported a valve position type ValveView = { phase: ValvePhase; commanded: 0 | 1; /** The state the node last REPORTED — the only state the page may claim. */ reported: 0 | 1 | null; heard_since_command: boolean; since_command_ms: number; report_age_ms: number | null; }; function valveView(cmd: Command, report: { valve: number | null; received_at: string } | null, now = Date.now()): ValveView { const commanded = cmd.state === 1 ? 1 : 0; const reported = report?.valve === 1 ? 1 : report?.valve === 0 ? 0 : null; const sinceCmd = Math.max(0, now - Date.parse(cmd.updated_at)); const reportAge = report ? Math.max(0, now - Date.parse(report.received_at)) : null; const heardSince = report != null && Date.parse(report.received_at) >= Date.parse(cmd.updated_at); let phase: ValvePhase; if (reported === null) phase = "unknown"; else if (reported === commanded) phase = heardSince || sinceCmd >= VALVE_CONFIRM_GRACE_MS ? "confirmed" : "refreshing"; else phase = sinceCmd < VALVE_CONFIRM_GRACE_MS ? "changing" : "unconfirmed"; return { phase, commanded, reported, heard_since_command: heardSince, since_command_ms: sinceCmd, report_age_ms: reportAge, }; } // ── auth ───────────────────────────────────────────────────────────────────── /** Constant-time-ish compare. Lengths differ → mismatch, which is fine to leak. */ function tokenOk(candidate: string | null | undefined): boolean { if (!candidate || candidate.length !== TOKEN.length) return false; let diff = 0; for (let i = 0; i < TOKEN.length; i++) diff |= TOKEN.charCodeAt(i) ^ candidate.charCodeAt(i); return diff === 0; } function bearer(req: Request): string | null { const h = req.headers.get("authorization"); return h?.startsWith("Bearer ") ? h.slice(7).trim() : null; } function cookieToken(req: Request): string | null { const raw = req.headers.get("cookie"); if (!raw) return null; for (const part of raw.split(";")) { const [k, ...v] = part.trim().split("="); if (k === "farm_token") return decodeURIComponent(v.join("=")); } return null; } // `no-store` on every API response. All of these are live state — the latest // reading, the valve's commanded position — and a cached copy of live state is // not stale data, it is wrong data presented as current. Phones and mobile // proxies both cache far more eagerly than a desktop, and this service is read // almost exclusively from a phone. const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store, max-age=0", }, }); const UNAUTHORIZED = () => json({ error: "unauthorized" }, 401); // ── queries for the read side ──────────────────────────────────────────────── const qRecent = db.query( "SELECT * FROM readings WHERE ($kind IS NULL OR kind = $kind) ORDER BY id DESC LIMIT $limit", ); const qLatestPerKind = db.query(` SELECT r.* FROM readings r JOIN (SELECT kind, MAX(id) AS id FROM readings GROUP BY kind) m ON m.id = r.id `); /** * Newest reading for every (kind, node) pair — this is what the dashboard draws. * * Keyed on node rather than kind so a new bed appears the moment it first reports. * soil-bed-3 needs no code change here; flash it, and it shows up. */ const qLatestPerNode = db.query(` SELECT r.* FROM readings r JOIN (SELECT kind, node, MAX(id) AS id FROM readings GROUP BY kind, node) m ON m.id = r.id ORDER BY r.kind, r.node `); /** Recent arrival times for one node, newest first — used to learn its cadence. */ const qCadence = db.query( "SELECT received_at FROM readings WHERE node = $node ORDER BY id DESC LIMIT 12", ); /** Last reading that actually reported a valve position, whoever reported it. */ const qLastValveReport = db.query( "SELECT node, valve, received_at FROM readings WHERE valve IS NOT NULL ORDER BY id DESC LIMIT 1", ); /** Newest reading carrying a pack voltage, whoever sent it — the pack has its own card. */ const qLastPack = db.query( "SELECT node, pack_mv, charger, received_at, payload FROM readings WHERE pack_mv IS NOT NULL ORDER BY id DESC LIMIT 1", ); /** Last GOOD water reading for a node — shown, clearly dated, while its probe is faulted. */ const qLastGoodWater = db.query( "SELECT depth_mm, percent, received_at FROM readings WHERE kind = 'water' AND node = $node AND ok = 1 ORDER BY id DESC LIMIT 1", ); /** * How long this node may be quiet before we call it stale. * * Learned from its own history rather than fixed, because the nodes do not agree on * a cadence: the mains-ish farm-node reports every 60 s while the deep-sleep * battery-swap node wakes far more rarely. A single threshold would either cry wolf * over the sleeper or stay green for hours after a live node died. */ function staleAfterMs(node: string): number { const rows = qCadence.all({ $node: node }) as { received_at: string }[]; if (rows.length < 3) return 20 * 60_000; // not enough history — be forgiving const gaps: number[] = []; for (let i = 0; i < rows.length - 1; i++) { gaps.push(Date.parse(rows[i].received_at) - Date.parse(rows[i + 1].received_at)); } gaps.sort((a, b) => a - b); const median = gaps[Math.floor(gaps.length / 2)] || 60_000; return Math.max(3 * median, 5 * 60_000); } const qStats = db.query(` SELECT kind, COUNT(*) AS total, SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END) AS failures, MIN(received_at) AS first_seen, MAX(received_at) AS last_seen FROM readings GROUP BY kind `); // ── the page ───────────────────────────────────────────────────────────────── function escapeHtml(s: unknown): string { return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!, ); } function ago(iso: string): string { const s = Math.max(0, Math.floor((Date.now() - Date.parse(iso)) / 1000)); if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.floor(s / 60)}m ago`; if (s < 86400) return `${Math.floor(s / 3600)}h ago`; return `${Math.floor(s / 86400)}d ago`; } /** The unauthenticated door. Kept deliberately plain and self-explanatory. */ function renderLogin(error?: string): string { return ` Maani Plantation — farm telemetry

Maani Plantation

${error ? escapeHtml(error) : "Paste your access key to view the farm."}

`; } function renderPage(): string { const latest = qLatestPerNode.all() as any[]; const recent = qRecent.all({ $kind: null, $limit: 60 }) as any[]; const stats = qStats.all() as any[]; // One card per reporting node, water first, then beds in name order. Nothing here // is hardcoded to a node name — a new bed appears by reporting, not by an edit. const order = (r: any) => (r.kind === "water" ? 0 : r.kind === "soil" ? 1 : 2); const nodes = [...latest].sort( (a, b) => order(a) - order(b) || String(a.node ?? "").localeCompare(String(b.node ?? "")), ); // --- battery gauges --------------------------------------------------------- // Piecewise, not linear: a Li-ion sits between 3.7 and 3.8 V for most of its life, // so a straight line would read ~50% for a fortnight and then fall off a cliff, // which is worse than no gauge at all. // // The 3S solar pack uses the SAME curve multiplied by three, so both gauges read // the same way and there is only one shape to reason about — 100% at 12.60 V, // 0% at 9.90 V. // // WARNING on both: volts under load read LOW, and a pack under MPPT constant- // voltage reads HIGH regardless of true state of charge. The raw value is always // shown beside the percentage for that reason. Indicator, not fuel gauge. const CURVE_1S: Array<[number, number]> = [ [4.2, 100], [4.1, 90], [4.0, 80], [3.95, 70], [3.87, 60], [3.82, 50], [3.79, 40], [3.77, 30], [3.74, 20], [3.68, 15], [3.6, 10], [3.45, 5], [3.3, 0], ]; const CURVE_3S: Array<[number, number]> = CURVE_1S.map(([v, pc]) => [v * 3, pc]); const pctFrom = (v: number, curve: Array<[number, number]>): number | null => { if (!isFinite(v)) return null; if (v >= curve[0][0]) return 100; if (v <= curve[curve.length - 1][0]) return 0; for (let i = 0; i < curve.length - 1; i++) { const [vHi, pHi] = curve[i]; const [vLo, pLo] = curve[i + 1]; if (v <= vHi && v >= vLo) return Math.round(pLo + ((v - vLo) * (pHi - pLo)) / (vHi - vLo)); } return null; }; // Out of range = claim nothing, rather than clamping a 12 V pack to 100% forever // and hiding a dying battery behind a full-looking bar. const battPct = (v: unknown): number | null => typeof v === "number" && v <= 4.6 && v >= 2.0 ? pctFrom(v, CURVE_1S) : null; const packPct = (mv: unknown): number | null => typeof mv === "number" && mv >= 6000 && mv <= 15000 ? pctFrom(mv / 1000, CURVE_3S) : null; // Fields with no typed column of their own still live in `payload`, which is kept // in full precisely so a value never has to be re-collected to be read later. const payloadStr = (row: Record, key: string): string => { try { const j = JSON.parse(String(row.payload ?? "{}")) as Record; return typeof j[key] === "string" ? (j[key] as string) : ""; } catch { return ""; } }; // A bar you can read at a glance in sunlight, which a bare number is not. The // digits always ride alongside: the picture is for glancing, the number for deciding. const bar = (pct: number): string => { const n = Math.max(0, Math.min(100, pct)); const c = n > 50 ? "bh" : n > 20 ? "bm" : "bl"; return ``; }; // ── water tank ────────────────────────────────────────────────────────────── // Percent from the node if it sent one, otherwise from FARM_TANK_FULL_MM. Overfull // reads 100, not 103: the overflow is the top, and past it the water leaves. const tankFrac = (r: { percent?: number | null; depth_mm?: number | null }): number | null => r.percent != null ? Math.max(0, Math.min(1, r.percent / 100)) : TANK_FULL_MM > 0 && r.depth_mm != null ? tankVolume(Math.min(r.depth_mm, TANK_FULL_MM)) / tankVolume(TANK_FULL_MM) : null; const tankPct = (r: { percent?: number | null; depth_mm?: number | null }): number | null => { const f = tankFrac(r); return f == null ? null : Math.round(100 * f); }; // Litres to the nearest 50: the probe is ±25 mm (0.5 % FS), which is ~±100 L in this // tank, so a figure to the litre would claim precision nobody measured. const tankDetail = (r: { percent?: number | null; depth_mm?: number | null }): string => { const f = tankFrac(r); return [ r.depth_mm != null ? `${r.depth_mm} mm deep` : null, f != null && TANK_LITRES > 0 ? `≈ ${(Math.round((TANK_LITRES * f) / 50) * 50).toLocaleString("en-NZ")} L` : null, ] .filter(Boolean) .join(" · "); }; // A faulted probe still leaves the last thing it knew. Worth showing — dated, in the // muted line, never in the big number, so nobody reads yesterday's level as today's. const lastGoodWater = (node: string): string => { const g = qLastGoodWater.get({ $node: node }) as any; if (!g) return ""; const pct = tankPct(g); return `

last good reading ${ago(g.received_at)}: ${pct != null ? `${pct}% full · ` : ""}${g.depth_mm} mm

`; }; // ── solar pack ────────────────────────────────────────────────────────────── // Its own card rather than a line on the water card: the pack powers everything, and // it was buried inside a red FAULT card whenever the water probe was down. The reading // still rides on the water node's payload — that is the board with the divider on A0. // Percent leads because it is what you act on; the volts stay because they are what // you diagnose with, and because the percentage reads high while the MPPT holds CV. const pack = qLastPack.get() as any; const packCard = (() => { if (!pack) return ""; const pp = packPct(pack.pack_mv); const volts = (pack.pack_mv / 1000).toFixed(2); let uncal = false; try { uncal = JSON.parse(String(pack.payload ?? "{}")).pack_cal === false; } catch {} const quiet = Date.now() - Date.parse(pack.received_at) > staleAfterMs(pack.node); const cls = pp == null ? "bad" : quiet ? "stale" : "good"; return `

Solar pack 12 V · via ${escapeHtml(pack.node)}

${pp != null ? `${pp} %` : `${volts} V`}

${pp != null ? `${bar(pp)}${volts} V` : `outside 3S range — check the divider`}${ uncal ? ` uncal` : "" }

${ pack.charger == null ? "" : `

phone charger ${pack.charger === "on" ? "ON" : "off"}${ pack.charger === "on" ? " — surplus" : " — no surplus" }

` }

${ago(pack.received_at)}${quiet ? " · quiet" : ""}

`; })(); const renderCard = (l: any): string => { const quiet = Date.now() - Date.parse(l.received_at); const cls = !l.ok ? "bad" : quiet > staleAfterMs(l.node) ? "stale" : "good"; const title = l.kind === "water" ? "Water tank" : l.kind === "soil" ? "Soil" : l.kind === "phone" ? "Base station phone" : l.kind; const pct = l.kind === "water" ? tankPct(l) : null; const body = !l.ok ? `

FAULT — ${escapeHtml(l.error ?? "no error given")}

` + (l.kind === "water" ? lastGoodWater(l.node) : "") : l.kind === "phone" ? `

${l.batt_pct != null ? `${Math.round(l.batt_pct)} %` : "—"}

` + (l.batt_pct != null ? `

${bar(l.batt_pct)}${escapeHtml(payloadStr(l, "status"))}

` : "") : l.kind === "water" ? `

${ pct != null ? `${pct} % full` : l.depth_mm != null ? `${l.depth_mm} mm` : "—" }

` + (pct != null ? `

${bar(pct)}${tankDetail(l)}

` : `

tank size not set — depth only

`) : `

${l.moisture_pct != null ? `${l.moisture_pct} %` : "—"}

` + `

${[ l.temp_c != null ? `${l.temp_c} °C` : null, l.ec != null ? `EC ${l.ec}` : null, l.valve != null ? `valve ${l.valve ? "OPEN" : "closed"}` : null, ] .filter(Boolean) .join(" · ")}

` + (battPct(l.vbat) != null ? `

${bar(battPct(l.vbat)!)}batt ${battPct(l.vbat)}% (${l.vbat} V)

` : l.vbat != null ? `

batt ${l.vbat} V

` : ""); return `

${title} ${escapeHtml(l.node ?? "?")}

${body}

${ago(l.received_at)}${cls === "stale" ? " · quiet" : ""}${ l.rssi != null ? ` · ${l.rssi} dBm` : "" }${l.uptime_s != null ? ` · up ${Math.floor(l.uptime_s / 3600)}h` : ""}

`; }; // Water first, then the pack it reports, then the beds. const cards = nodes.length ? nodes.filter((l) => l.kind === "water").map(renderCard).join("") + packCard + nodes.filter((l) => l.kind !== "water").map(renderCard).join("") : `

No nodes yet

nothing has reported

` + packCard; // ── valve panel ─────────────────────────────────────────────────────────── // The big label only ever shows a state the NODE reported. A command still in // flight reads "Changing to …" with a spinner, and one the node never applied says // so, rather than quietly displaying the request as if it were the valve. const cmd = readCommand("valve"); const report = qLastValveReport.get() as any; const vv = valveView(cmd, report ?? null); const commanded = vv.commanded === 1; const stateWord = (s: number) => (s ? "OPEN" : "CLOSED"); const ttlLeft = cmd.expires_at ? Math.max(0, Math.round((Date.parse(cmd.expires_at) - Date.now()) / 60000)) : null; const nodeQuiet = vv.report_age_ms != null && vv.report_age_ms > VALVE_CONFIRM_GRACE_MS; const bigLabel = vv.phase === "unknown" ? "—" : vv.phase === "changing" ? `Changing to ${commanded ? "open" : "closed"}…` : stateWord(vv.reported!); const statusLine = vv.phase === "unknown" ? `

node has not reported a valve position yet

` : vv.phase === "changing" ? `

sent ${ago(cmd.updated_at)} · the node usually confirms within about 2 min

` : vv.phase === "refreshing" ? `

waiting for the node's next report

` : vv.phase === "unconfirmed" ? `

⚠ ${stateWord(vv.commanded)} was requested ${ago(cmd.updated_at)}, but ${ vv.heard_since_command ? `${escapeHtml(report.node)} still reports ${stateWord(vv.reported!)} — changed at the tank, or the phone isn't collecting commands` : "nothing has reported since — the node has not confirmed it" }

` : `

confirmed by ${escapeHtml(report.node)} · ${ago(report.received_at)}${ nodeQuiet ? " — node quiet, this may be out of date" : "" }

`; // Close stays usable whenever the node reports OPEN, even if the last request was // already "close" — re-sending bumps seq, which is how a valve opened at the tank // gets shut from here. Same for re-sending an open the node never applied. const canOpen = !commanded || (vv.phase === "unconfirmed" && vv.reported === 0); const canClose = commanded || vv.reported === 1; const valvePanel = `

Master valve

${bigLabel}

${statusLine} ${ // The seeded row is not a request anyone made — "requested 0s ago by default" would be a small lie. cmd.source === "default" ? "" : `

requested ${ago(cmd.updated_at)}${cmd.source ? ` · by ${escapeHtml(cmd.source)}` : ""}${ commanded && ttlLeft != null ? ` · closes automatically in ${ttlLeft} min` : "" }

` }

A request takes about 2 minutes to reach the valve. OPEN and CLOSED only show once the node confirms it.

`; const rows = recent .map( (r) => ` ${escapeHtml(r.received_at.replace("T", " ").slice(0, 19))} ${escapeHtml(r.kind)} ${escapeHtml(r.node ?? "")} ${ r.ok ? r.kind === "water" ? `${r.depth_mm ?? "—"} mm` : `${r.moisture_pct ?? "—"}% · ${r.temp_c ?? "—"}°C` : `${escapeHtml(r.error ?? "fault")}` } ${r.rssi ?? ""} `, ) .join(""); const statRows = stats .map( (s) => `${escapeHtml(s.kind)}${s.total}${ s.failures }${escapeHtml(s.last_seen.replace("T", " ").slice(0, 19))}`, ) .join(""); return ` Maani Plantation — farm telemetry

Maani Plantation · farm telemetry · auto-refresh 60 s

${cards}${valvePanel}

Per-sensor totals

${statRows || ''}
SensorReadingsFaultsLast seen
nothing yet

Last 60 readings

${rows || ''}
Received (UTC)KindNodeValueRSSI
nothing yet
Times are UTC (server clock — the ESP has no RTC). JSON at /api/readings.
`; } // ── routes ─────────────────────────────────────────────────────────────────── const server = Bun.serve({ port: PORT, hostname: HOST, maxRequestBodySize: MAX_BODY, async fetch(req) { const url = new URL(req.url); const path = url.pathname.replace(/\/+$/, "") || "/"; // Unauthenticated liveness probe. Deliberately says nothing about the data. if (path === "/health") return json({ ok: true, service: "farm-ingest" }); // ── write side ────────────────────────────────────────────────────────── if (req.method === "POST" && (path === "/ingest" || path === "/ingest/batch")) { if (!tokenOk(bearer(req))) return UNAUTHORIZED(); let body: unknown; try { body = await req.json(); } catch { return json({ error: "invalid json" }, 400); } // Accept a bare reading, an array, or {readings:[...]} — Node-RED users will // send all three eventually, and rejecting two of them is a support burden. const items = Array.isArray(body) ? body : body && typeof body === "object" && Array.isArray((body as any).readings) ? (body as any).readings : [body]; if (items.length > MAX_BATCH) return json({ error: `batch too large (max ${MAX_BATCH})` }, 413); const { accepted, duplicates } = store(items); console.log(`[ingest] ${accepted} stored, ${duplicates} dup, ${items.length} offered`); // The downlink rides home on the response. The phone edge-detects `seq`, so a // command is applied once when it changes rather than re-asserted every push — // which is what lets the local page's buttons keep working offline. const valve = readCommand("valve"); return json({ ok: true, accepted, duplicates, received: items.length, valve: { state: valve.state, seq: valve.seq, updated_at: valve.updated_at }, }); } if (req.method === "POST" && path === "/valve/set") { // Accept a bearer token OR the dashboard's cookie — the page's buttons are the // main caller, and SameSite=Lax keeps a cross-site form from driving the valve. if (!tokenOk(bearer(req)) && !tokenOk(cookieToken(req))) return UNAUTHORIZED(); let body: any; try { body = await req.json(); } catch { return json({ error: "invalid json" }, 400); } const state = body?.state === 1 || body?.state === true || body?.state === "1" ? 1 : 0; const ttl = Number.isFinite(Number(body?.ttl_s)) ? Number(body.ttl_s) : null; if (ttl != null && (ttl <= 0 || ttl > 86400)) return json({ error: "ttl_s must be 1..86400" }, 400); const c = setCommand("valve", state, ttl, typeof body?.source === "string" ? body.source : "dashboard"); return json({ ok: true, valve: { state: c.state, seq: c.seq, updated_at: c.updated_at, expires_at: c.expires_at } }); } // ── read side ─────────────────────────────────────────────────────────── const authed = tokenOk(bearer(req)) || tokenOk(cookieToken(req)) || tokenOk(url.searchParams.get("k")); if (path === "/api/readings") { if (!authed) return UNAUTHORIZED(); const limit = Math.min(Number(url.searchParams.get("limit") ?? 200) || 200, 5000); const kind = url.searchParams.get("kind"); return json(qRecent.all({ $kind: kind, $limit: limit })); } if (path === "/api/latest") { if (!authed) return UNAUTHORIZED(); // Per node, not per kind — three beds are three entries, not one overwriting two. return json(qLatestPerNode.all()); } if (path === "/api/nodes") { if (!authed) return UNAUTHORIZED(); const rows = qLatestPerNode.all() as any[]; return json( rows.map((r) => ({ node: r.node, kind: r.kind, last_seen: r.received_at, ok: r.ok === 1, error: r.error, stale: Date.now() - Date.parse(r.received_at) > staleAfterMs(r.node), })), ); } if (path === "/api/valve") { if (!authed) return UNAUTHORIZED(); const c = readCommand("valve"); const report = qLastValveReport.get() as any; return json({ commanded: c, reported: report ?? null, view: valveView(c, report ?? null) }); } // Token entry. POST rather than a query string so the secret does not end up // in nginx's access log, which `?k=` unavoidably does. if (req.method === "POST" && path === "/login") { const form = await req.formData().catch(() => null); const k = form?.get("k"); if (typeof k !== "string" || !tokenOk(k.trim())) { return new Response(renderLogin("That key was not accepted."), { status: 401, headers: { "content-type": "text/html; charset=utf-8" }, }); } return new Response(null, { status: 302, headers: { location: "/", "set-cookie": `farm_token=${encodeURIComponent(TOKEN)}; Path=/; Max-Age=31536000; HttpOnly; Secure; SameSite=Lax`, }, }); } if (path === "/") { if (!authed) { // A bare 401 of plain text is indistinguishable from "the site is down" on a // phone — which is exactly how this looked from the field. Give it a door. return new Response(renderLogin(), { status: 401, headers: { "content-type": "text/html; charset=utf-8" }, }); } const headers: Record = { "content-type": "text/html; charset=utf-8", // Same reason as the API responses, and the same symptom that sent us // looking: a dashboard served from cache shows a tank level from hours // ago and looks perfectly healthy doing it. "cache-control": "no-store, no-cache, must-revalidate, max-age=0", }; // First visit carries the token in the URL; park it in a cookie so the // secret stops appearing in the address bar, browser history, and referrers. if (url.searchParams.get("k")) { headers["set-cookie"] = `farm_token=${encodeURIComponent(TOKEN)}; Path=/; Max-Age=31536000; HttpOnly; Secure; SameSite=Lax`; headers["location"] = "/"; return new Response(null, { status: 302, headers }); } return new Response(renderPage(), { headers }); } return json({ error: "not found" }, 404); }, }); console.log(`farm-ingest listening on http://${server.hostname}:${server.port} · db ${DB_PATH}`);