Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
Dosing: real peristaltic and top-off pump drivers with ml/s calibration, replacing the fake pumps #610
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
quill/arm:quill/607-dosing-real-peristaltic-and-top-off-pump → main 7 commits fork 3 threads · 3 unresolved
Software/so101/README.md +31
| @@ -265,3 +265,34 @@ Not drawn yet, because nothing records it: latency per hop (page → relay → P | ||
| 265 | 265 | ## Faster limits |
| 266 | 266 | |
| 267 | 267 | A person runs this at the arm while nobody drives: `bun bench/limits-search.ts http://<arm>:8101` (add `--joint 2` for one joint; with no URL it runs on the fake bus). From today's 90 °/s and 360 °/s² it steps each joint's speed and acceleration up within the safety ranges (the gripper is skipped), sweeps at each step, and keeps the last step that did not make p95 error or overshoot worse, writing them through `PUT /settings`. It prints its command line, then a table of limits and p95 error, overshoot and time to arrive, before → after. It refuses to start while anyone drives or a STOP or E-stop is in force, and if one appears mid-run it stops and puts the starting limits back. |
| 268 | ||
| 269 | ## Sensors & dosing (parts list, and the controller) | |
| 270 | ||
| 271 | `src/doser.ts` is the dose controller: three loops (nutrient A, nutrient B, pH down), each `off`, `suggest` (default) or `auto`, with per-dose and per-day ml limits, a ≥10 min mixing wait, no dose on a stale (>30 min), uncalibrated or already-used reading, and a halt until the owner resumes when a reading is out of physical bounds or a dose did not move it. Every dose goes in the log book with its cause (rule, crew, owner). `topOff` runs the fresh-water pump until the high float or a time cap. Sensor and pump drivers are separate pulls and plug in as `Reading` sources and `Pump`s. | |
| 272 | ||
| 273 | **Using it today.** The app builds the controller at boot over the Pi's real pumps (below); readings are the newest measurements in the log book. State (the day's doses, the stop, each loop's mode) is kept in `dosing.json` under `FORGE_APP_DATA` and survives a restart. | |
| 274 | - **Page:** the owner sees a Dosing card on `/care`: each loop, its mode, what it would add, a **Do it** button, a mode picker, and a red "Dosing stopped" banner with **Resume dosing**. Guests and non-owners see nothing. | |
| 275 | - **Terminal:** `/care dosing`, `/care dose <nutrientA|nutrientB|phDown> [ml]`, `/care mode <loop> <off|suggest|auto>`, `/care resume`. The server decides who may; all four are the owner's. | |
| 276 | - **Crew (MCP):** `POST /care/mcp` speaks JSON-RPC (`initialize`, `tools/list`, `tools/call`) with the tools `readings`, `log`, `suggest_dose` and `dose`. A crew dose is refused unless the owner put that loop in `auto`, and is bound by the same limits. The crew cannot change a mode or resume. | |
| 277 | **Who may call:** every call needs a verified identity (the forge-signed header), or it gets 401, `tools/list` included. `readings` and `log` are for anyone signed in; `suggest_dose`, `dose` and `pumps` are the owner's (403 otherwise), like the card on `/care`. A crew agent calls with the owner's identity, and its dose is still the crew's: refused unless the loop is in `auto`. At most 60 calls a minute per identity (429). | |
| 278 | - **Owner-only:** `setMode` and `resume` take the caller's cause and throw for anything but the owner. Modes are never raised by a rule or an agent. | |
| 279 | ||
| 280 | **The pumps (`src/dose-pumps.ts` on the Pi, `src/arm-pumps.ts` in the relay).** The arm server runs the four pumps (nutrient A, nutrient B, pH down, top-off), like the grow light: each pump's switch is a GPIO line to a relay or MOSFET channel, or a Shelly plug (the light's drivers, `src/light-switch.ts`; a USB port can't carry a 12 V pump, so uhubctl isn't offered). Each pump turns ml into seconds with its own measured ml/s. Settings and the day's runs are kept in `dose-pumps.json` beside the arm's other data. | |
| 281 | - **Set up, on `/care` → Dosing → Pumps (owner only):** *Wire* a pump (GPIO line, tick "closes on 0" for an active-low board, or a plug address). Tap **Run 10 s into a measuring cup** (also primes the tube), type the ml the cup caught, and **Save calibration**: 12 ml in 10 s is 1.2 ml/s. A new switch clears the calibration. For top-off, set the high float's GPIO line and the time cap (5–600 s, 120 by default), then **Top off now**: it runs until the float says full or the cap passes, and the log book gets *topoff*, or a note to check the jug when the cap stopped it. | |
| 282 | - **Terminal:** `/care pumps`, `/care wire <pump> gpio <line> [low]` · `shelly <host>` · `none`, `/care prime <pump> [seconds]`, `/care calibrate <pump> <ml>`, `/care float <line|none> [low] [cap seconds]`, `/care topoff`. A pump is `nutrientA`, `nutrientB`, `phDown` or `topOff`. | |
| 283 | - **Crew (MCP):** the `pumps` tool reads each pump's switch, ml/s, calibration date, today's ml and the float. The crew can't wire, prime, calibrate or top off. | |
| 284 | - **Refused, not failed:** a pump with no switch or no calibration, or a dose past the Pi's own backstop (60 s in one run, 100 ml a pump a day, whatever the controller asks) answers 409 before anything switches. The controller then refuses that dose without counting it or stopping automation. A switch that fails once it was asked to close is a failure: the pump is switched off anyway (three tries), and dosing stops until the owner resumes. | |
| 285 | - **Off, always:** every wired pump is switched off when the arm server starts, and after every run, including one that throws. | |
| 286 | - **Only the relay asks the Pi:** the relay answers `/grow/dosing` from a browser with 404, owner or not, so no dose skips the controller's limits. The Pi's `GET`/`POST /grow/dosing` aren't gated on the arm server itself, like `/grow/light`: whoever reaches the arm server's port reaches them, and the backstop above is what holds there. | |
| 287 | ||
| 288 | | Part | Module | Wiring | Price | | |
| 289 | |---|---|---|---| | |
| 290 | | Water temperature | DS18B20 waterproof, 4.7 kΩ pull-up | 1-Wire on GPIO4 | ~$4 | | |
| 291 | | Air temp/humidity | SHT31 or BME280 breakout | I²C SDA/SCL (GPIO2/3), 3.3 V | ~$8 | | |
| 292 | | ADC for probes | ADS1115 | I²C 0x48 | ~$7 | | |
| 293 | | pH probe + board | analog pH kit (BNC) | ADS1115 A0 | ~$30 | | |
| 294 | | EC probe + board | analog EC K=1 kit | ADS1115 A1 | ~$40 | | |
| 295 | | Reservoir level | float switch (low), second float (high, for top-off) | GPIO17, GPIO27 to GND | ~$3 each | | |
| 296 | | Dosing pumps ×3 | 12 V peristaltic, with a 4-channel MOSFET/relay board | GPIO22/23/24 → board, 12 V supply | ~$12 each + ~$6 | | |
| 297 | | Top-off pump | 12 V diaphragm or peristaltic, jug of fresh water | GPIO25 → fourth channel | ~$10 | | |
| 298 | | 12 V supply | 12 V 2 A | to the board, not the Pi | ~$10 | |
Software/so101/bench/fake-serve.ts +1 −1
| @@ -80,6 +80,6 @@ const facts = new Facts({ file: join(dir, "facts.sqlite") }); | ||
| 80 | 80 | const piSync = piSyncFromEnv(process.env, remote?.http ?? null, remote?.host ?? null, () => lobby.turns.snapshot().driver === null, dir); |
| 81 | 81 | const grow = { stills: new GrowStills(join(dir, "grow-stills"), { capBytes: Number(process.env.GROW_STILLS_CAP_MB) * 1e6 || undefined }), log: new GrowLog(join(dir, "grow-log.json")), everyMs: parseEvery(process.env.GROW_STILL_MINUTES) }; |
| 82 | 82 | const careProfile = loadProfiles(BUILT_IN_DIR, join(dir, "care-profiles")).find((p) => p.id === (process.env.CARE_PROFILE ?? "spinach")); |
| 83 | const relay = await startRelay({ probeEveryMs: 300_000, sensorsMs: 600_000, care: careProfile ? { log: new CareLog(join(dir, "care.json")), profile: careProfile } : undefined, grow, stations: new StationHub(join(dir, "stations.json")), facts, piSync: piSync ?? undefined, host, port, simulation: `http://127.0.0.1:${simulation.port}`, remote, problem, lobby, cameras, leaders, video, summaryEveryMs: 60_000, holdFile: join(dir, "hold.json"), baselineFile: join(dir, "servo-factory.json") }); | |
| 83 | const relay = await startRelay({ probeEveryMs: 300_000, sensorsMs: 600_000, care: careProfile ? { log: new CareLog(join(dir, "care.json")), profile: careProfile, dosingFile: join(dir, "dosing.json") } : undefined, grow, stations: new StationHub(join(dir, "stations.json")), facts, piSync: piSync ?? undefined, host, port, simulation: `http://127.0.0.1:${simulation.port}`, remote, problem, lobby, cameras, leaders, video, summaryEveryMs: 60_000, holdFile: join(dir, "hold.json"), baselineFile: join(dir, "servo-factory.json") }); | |
| 84 | 84 | const { mode, host: armHost } = relay.link(); |
| 85 | 85 | console.log(`arm on http://${host}:${relay.port} (${mode === "simulated" ? "simulated" : `arm ${armHost}: ${mode}`}; data in ${dir}${local ? "; local: every visitor drives as the owner" : ""})`); |
Software/so101/src/arm-pumps.ts added +136
| @@ -0,0 +1,136 @@ | ||
| 1 | /** | |
| 2 | * The relay's side of the dosing pumps, which live on the arm server (src/dose-pumps.ts on the Pi): | |
| 3 | * armPumps the controller's Pump for each loop: a dose is POST /grow/dosing {"action": "dose"}; a 409 (nothing ran) | |
| 4 | * becomes PumpRefused, anything else a failure that halts automation | |
| 5 | * readDosing GET /grow/dosing read field by field, so a wrong answer shows as less, never as markup or a crash | |
| 6 | * pumpRequest the owner's setup forms (wire, float, prime, calibrate, top off) as the Pi's JSON; never a dose, which | |
| 7 | * goes through the controller's limits alone | |
| 8 | */ | |
| 9 | import { LOOPS, PumpRefused, type LoopId, type Pump } from "./doser.ts"; | |
| 10 | import { PUMP_IDS, type DosingView, type FloatConfig, type PumpId, type PumpView } from "./dose-pumps.ts"; | |
| 11 | ||
| 12 | export type PumpAnswer = { ok: true; body: unknown } | { ok: false; status: number; error: string }; | |
| 13 | ||
| 14 | /** How the relay reaches the Pi's pumps; `status` 0 is no answer. */ | |
| 15 | export interface PumpHooks { | |
| 16 | read(): Promise<DosingView | null>; | |
| 17 | write(body: Record<string, unknown>, timeoutMs: number): Promise<PumpAnswer>; | |
| 18 | } | |
| 19 | ||
| 20 | /** Room for the Pi's longest dose (60 s) and its longest top-off (600 s), and the trip. */ | |
| 21 | export const DOSE_TIMEOUT_MS = 75_000; | |
| 22 | export const FILL_TIMEOUT_MS = 620_000; | |
| 23 | ||
| 24 | export const PUMP_LABEL: Record<PumpId, string> = { nutrientA: "Nutrient A", nutrientB: "Nutrient B", phDown: "pH down", topOff: "Top-off" }; | |
| 25 | ||
| 26 | export function armPumps(hooks: PumpHooks): Record<LoopId, Pump> { | |
| 27 | const pump = (id: LoopId): Pump => ({ | |
| 28 | async dose(ml) { | |
| 29 | const a = await hooks.write({ action: "dose", pump: id, ml }, DOSE_TIMEOUT_MS); | |
| 30 | if (a.ok) return; | |
| 31 | if (a.status === 409) throw new PumpRefused(a.error); | |
| 32 | throw new Error(a.error); | |
| 33 | }, | |
| 34 | }); | |
| 35 | return Object.fromEntries(LOOPS.map((l) => [l, pump(l)])) as Record<LoopId, Pump>; | |
| 36 | } | |
| 37 | ||
| 38 | const obj = (v: unknown): Record<string, unknown> => (typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {}); | |
| 39 | const text = (v: unknown, max: number): string => (typeof v === "string" ? v.replace(/[\p{Cc}\p{Cf}]/gu, "").slice(0, max) : ""); | |
| 40 | const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); | |
| 41 | const pumpId = (v: unknown): PumpId | null => (PUMP_IDS.includes(v as PumpId) ? (v as PumpId) : null); | |
| 42 | ||
| 43 | export function readDosing(body: unknown): DosingView | null { | |
| 44 | const b = obj(body); | |
| 45 | if (!Array.isArray(b.pumps)) return null; | |
| 46 | const pumps: PumpView[] = b.pumps.flatMap((x: unknown) => { | |
| 47 | const p = obj(x); | |
| 48 | const id = pumpId(p.id); | |
| 49 | const driver = text(p.driver, 20); | |
| 50 | return id ? [{ id, driver: driver === "gpio" || driver === "shelly" ? driver : null, mlPerSec: num(p.mlPerSec), calibratedAt: num(p.calibratedAt), dosedTodayMl: num(p.dosedTodayMl) ?? 0 }] : []; | |
| 51 | }); | |
| 52 | const f = obj(b.float); | |
| 53 | const float: FloatConfig | null = typeof f.chip === "string" && /^gpiochip\d{1,2}$/.test(f.chip) && Number.isInteger(f.line) ? { chip: f.chip, line: f.line as number, activeLow: f.activeLow === true } : null; | |
| 54 | const pr = obj(b.primed); | |
| 55 | const primedPump = pumpId(pr.pump); | |
| 56 | return { | |
| 57 | pumps, | |
| 58 | float, | |
| 59 | full: typeof b.full === "boolean" ? b.full : null, | |
| 60 | capSec: num(b.capSec) ?? 0, | |
| 61 | primed: primedPump && num(pr.ms) !== null && num(pr.at) !== null ? { pump: primedPump, ms: pr.ms as number, at: pr.at as number } : null, | |
| 62 | running: pumpId(b.running), | |
| 63 | maxRunMs: num(b.maxRunMs) ?? 0, | |
| 64 | maxDayMl: num(b.maxDayMl) ?? 0, | |
| 65 | error: text(b.error, 300) || null, | |
| 66 | }; | |
| 67 | } | |
| 68 | ||
| 69 | /** The setup actions the owner may ask of the Pi. `dose` is not one: a dose goes through the controller. */ | |
| 70 | export const SETUP_ACTIONS = ["switch", "float", "prime", "calibrate", "fill"] as const; | |
| 71 | ||
| 72 | /** | |
| 73 | * The Pi's JSON for one of the card's forms (strings) or the terminal's JSON (typed); null when it is not a setup | |
| 74 | * action. Only shape is decided here: the Pi checks every value. | |
| 75 | */ | |
| 76 | export function pumpRequest(input: Record<string, unknown>): Record<string, unknown> | null { | |
| 77 | const get = (k: string) => (typeof input[k] === "string" ? (input[k] as string).trim() : input[k]); | |
| 78 | const n = (k: string) => { | |
| 79 | const v = get(k); | |
| 80 | return typeof v === "number" ? v : typeof v === "string" && /^\d{1,6}(\.\d{1,3})?$/.test(v) ? Number(v) : v; | |
| 81 | }; | |
| 82 | const pump = get("pump"); | |
| 83 | switch (get("action")) { | |
| 84 | case "switch": { | |
| 85 | if (input.switch !== undefined) return { action: "switch", pump, switch: input.switch }; | |
| 86 | switch (get("driver")) { | |
| 87 | case "none": | |
| 88 | return { action: "switch", pump, switch: null }; | |
| 89 | case "gpio": | |
| 90 | return { action: "switch", pump, switch: { driver: "gpio", chip: get("chip") || "gpiochip0", line: n("line"), activeLow: input.activeLow === "on" || input.activeLow === true } }; | |
| 91 | case "shelly": | |
| 92 | return { action: "switch", pump, switch: { driver: "shelly", host: get("host"), gen: String(get("gen")) === "1" ? 1 : 2 } }; | |
| 93 | default: | |
| 94 | return { action: "switch", pump, switch: { driver: get("driver") } }; | |
| 95 | } | |
| 96 | } | |
| 97 | case "float": { | |
| 98 | const out: Record<string, unknown> = { action: "float" }; | |
| 99 | if (get("line") === "" || get("line") === "none") out.float = null; | |
| 100 | else if (get("line") !== undefined) out.float = { chip: get("chip") || "gpiochip0", line: n("line"), activeLow: input.activeLow === "on" || input.activeLow === true }; | |
| 101 | if (get("capSec") !== undefined && get("capSec") !== "") out.capSec = n("capSec"); | |
| 102 | return out; | |
| 103 | } | |
| 104 | case "prime": { | |
| 105 | const sec = n("sec"); | |
| 106 | return { action: "prime", pump, ms: typeof sec === "number" ? Math.round(sec * 1000) : sec }; | |
| 107 | } | |
| 108 | case "calibrate": | |
| 109 | return { action: "calibrate", pump, ml: n("ml") }; | |
| 110 | case "fill": | |
| 111 | return { action: "fill" }; | |
| 112 | default: | |
| 113 | return null; | |
| 114 | } | |
| 115 | } | |
| 116 | ||
| 117 | /** | |
| 118 | * The Pi's pumps as the page reads them: the last answer at once when it is under `freshMs` old, a fresh read otherwise | |
| 119 | * (one in flight at a time). The very first read waits at most `firstWaitMs`, so a Pi that is down never holds /care up. | |
| 120 | */ | |
| 121 | export function cachedRead(read: () => Promise<DosingView | null>, freshMs = 5000, now: () => number = Date.now, firstWaitMs = 300) { | |
| 122 | let last: { at: number; view: DosingView | null } | null = null; | |
| 123 | let inflight: Promise<DosingView | null> | null = null; | |
| 124 | const refresh = (): Promise<DosingView | null> => | |
| 125 | (inflight ??= read() | |
| 126 | .then((view) => ((last = { at: now(), view }), view)) | |
| 127 | .finally(() => (inflight = null))); | |
| 128 | return { | |
| 129 | read: async (): Promise<DosingView | null> => { | |
| 130 | if (last && now() - last.at < freshMs) return last.view; | |
| 131 | if (last) return void refresh().catch(() => {}), last.view; | |
| 132 | return Promise.race([refresh(), new Promise<null>((r) => setTimeout(() => r(null), firstWaitMs))]); | |
| 133 | }, | |
| 134 | refresh, | |
| 135 | }; | |
| 136 | } |
Software/so101/src/care-dosing.test.ts added +216
| @@ -0,0 +1,216 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 3 | import { createServer } from "node:http"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { CareLog } from "./care-log.ts"; | |
| 7 | import { fakeDoser, handleDosing, MCP_PER_MINUTE, rateLimit } from "./care-dosing.ts"; | |
| 8 | import { renderCare } from "./care-page.ts"; | |
| 9 | import { careCommand, dosingLines } from "./care-terminal.ts"; | |
| 10 | import { BUILT_IN_DIR, loadProfiles } from "./care-profile.ts"; | |
| 11 | import { Doser, LOOPS, topOff } from "./doser.ts"; | |
| 12 | ||
| 13 | const spinach = loadProfiles(BUILT_IN_DIR).find((p) => p.id === "spinach")!; | |
| 14 | ||
| 15 | async function serve(who: "owner" | "guest" | "member", opts: { doser?: Doser; log?: CareLog; origin?: boolean } = {}) { | |
| 16 | const log = opts.log ?? new CareLog(); | |
| 17 | const doser = opts.doser ?? fakeDoser(log); | |
| 18 | const server = createServer((req, res) => { | |
| 19 | const readBody = (r: typeof req, limit = 1 << 20) => new Promise<Buffer>((ok, no) => { const c: Buffer[] = []; let n = 0; r.on("data", (d: Buffer) => { n += d.length; if (n > limit) { no(new Error("body too large")); return; } c.push(d); }); r.on("end", () => ok(Buffer.concat(c))); }); | |
| 20 | handleDosing(req, res, new URL(req.url!, "http://x").pathname, { doser, log, readBody, sameOrigin: () => opts.origin !== false, who: () => ({ owner: who === "owner", known: who !== "guest" }) }).then((hit) => hit || (res.writeHead(404), res.end())); | |
| 21 | }); | |
| 22 | await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok)); | |
| 23 | const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`; | |
| 24 | const post = (path: string, body: unknown) => fetch(base + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); | |
| 25 | return { base, log, doser, post, close: () => server.close() }; | |
| 26 | } | |
| 27 | ||
| 28 | test("the owner sees dosing, sets a mode, taps Do it, and resumes; the log book says it was simulated", async () => { | |
| 29 | const s = await serve("owner"); | |
| 30 | try { | |
| 31 | s.log.add({ type: "measurement", kind: "ph", value: 7.2 }); | |
| 32 | const state = (await (await fetch(`${s.base}/care/dosing`)).json()) as { simulated: boolean; loops: { id: string; mode: string; suggestion: { ml: number } | null }[] }; | |
| 33 | expect(state.simulated).toBe(true); | |
| 34 | const ph = state.loops.find((l) => l.id === "phDown")!; | |
| 35 | expect(ph.mode).toBe("suggest"); | |
| 36 | expect(ph.suggestion!.ml).toBeGreaterThan(0); | |
| 37 | const did = await s.post("/care/dosing/dose", { loop: "phDown" }); | |
| 38 | expect(did.status).toBe(200); | |
| 39 | expect(s.log.entries().at(-1)).toMatchObject({ type: "observation" }); | |
| 40 | expect((await s.post("/care/dosing/mode", { loop: "phDown", mode: "auto" })).status).toBe(200); | |
| 41 | expect(s.doser.loops.phDown.mode).toBe("auto"); | |
| 42 | expect((await s.post("/care/dosing/mode", { loop: "phDown", mode: "wild" })).status).toBe(400); | |
| 43 | const second = await s.post("/care/dosing/dose", { loop: "phDown" }); | |
| 44 | expect(second.status).toBe(409); | |
| 45 | expect((await s.post("/care/dosing/resume", {})).status).toBe(200); | |
| 46 | const form = await fetch(`${s.base}/care/dosing/mode`, { method: "POST", redirect: "manual", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "loop=phDown&mode=off" }); | |
| 47 | expect(form.status).toBe(303); | |
| 48 | expect(s.doser.loops.phDown.mode).toBe("off"); | |
| 49 | } finally { | |
| 50 | s.close(); | |
| 51 | } | |
| 52 | }); | |
| 53 | ||
| 54 | test("a guest or a signed-in non-owner sees nothing and cannot dose, resume or set a mode", async () => { | |
| 55 | for (const who of ["guest", "member"] as const) { | |
| 56 | const s = await serve(who); | |
| 57 | try { | |
| 58 | expect((await fetch(`${s.base}/care/dosing`)).status).toBe(who === "guest" ? 401 : 403); | |
| 59 | for (const [path, body] of [["/care/dosing/dose", { loop: "phDown" }], ["/care/dosing/resume", {}], ["/care/dosing/mode", { loop: "phDown", mode: "auto" }]] as const) { | |
| 60 | expect((await s.post(path, body)).status).toBe(who === "guest" ? 401 : 403); | |
| 61 | } | |
| 62 | expect(s.doser.loops.phDown.mode).toBe("suggest"); | |
| 63 | } finally { | |
| 64 | s.close(); | |
| 65 | } | |
| 66 | } | |
| 67 | }); | |
| 68 | ||
| 69 | test("a write from another origin is refused, and a body over the cap is cut off", async () => { | |
| 70 | const other = await serve("owner", { origin: false }); | |
| 71 | try { | |
| 72 | expect((await other.post("/care/dosing/mode", { loop: "phDown", mode: "auto" })).status).toBe(403); | |
| 73 | expect(other.doser.loops.phDown.mode).toBe("suggest"); | |
| 74 | } finally { | |
| 75 | other.close(); | |
| 76 | } | |
| 77 | const s = await serve("owner"); | |
| 78 | try { | |
| 79 | const big = await fetch(`${s.base}/care/dosing/mode`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ loop: "phDown", mode: "auto", pad: "x".repeat(10_000) }) }); | |
| 80 | expect(big.status).toBe(413); | |
| 81 | } finally { | |
| 82 | s.close(); | |
| 83 | } | |
| 84 | }); | |
| 85 | ||
| 86 | test("crew tools over MCP, with the owner's verified identity: read and suggest, dose only in auto, never resume or set a mode", async () => { | |
| 87 | const s = await serve("owner"); | |
| 88 | const call = async (name: string, args: object = {}) => ((await (await s.post("/care/mcp", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } })).json()) as { result?: { isError: boolean; content: { text: string }[] }; error?: unknown }); | |
| 89 | try { | |
| 90 | s.log.add({ type: "measurement", kind: "ph", value: 7.2 }); | |
| 91 | const list = (await (await s.post("/care/mcp", { jsonrpc: "2.0", id: 1, method: "tools/list" })).json()) as { result: { tools: { name: string }[] } }; | |
| 92 | expect(list.result.tools.map((t) => t.name)).toEqual(["readings", "log", "suggest_dose", "dose", "pumps"]); | |
| 93 | expect(JSON.parse((await call("readings")).result!.content[0]!.text).ph.value).toBe(7.2); | |
| 94 | expect(JSON.parse((await call("suggest_dose", { loop: "phDown" })).result!.content[0]!.text).ml).toBeGreaterThan(0); | |
| 95 | const refused = await call("dose", { loop: "phDown" }); | |
| 96 | expect(refused.result!.isError).toBe(true); | |
| 97 | expect(refused.result!.content[0]!.text).toContain("only suggests"); | |
| 98 | expect(s.log.entries().filter((e) => e.type !== "measurement")).toHaveLength(0); | |
| 99 | expect((await call("resume")).error).toBeDefined(); | |
| 100 | expect((await call("setMode", { loop: "phDown", mode: "auto" })).error).toBeDefined(); | |
| 101 | s.doser.setMode("phDown", "auto", "owner"); | |
| 102 | const done = await call("dose", { loop: "phDown", ml: 2 }); | |
| 103 | expect(done.result!.isError).toBe(false); | |
| 104 | const over = await call("dose", { loop: "phDown", ml: 500 }); | |
| 105 | expect(over.result!.isError).toBe(true); | |
| 106 | } finally { | |
| 107 | s.close(); | |
| 108 | } | |
| 109 | }); | |
| 110 | ||
| 111 | test("MCP without a verified identity is refused 401 on every tool, dose in auto included; a signed-in non-owner gets the log book, never dosing", async () => { | |
| 112 | const guest = await serve("guest"); | |
| 113 | const rpc = (srv: typeof guest, name: string, args: object = {}) => srv.post("/care/mcp", { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }); | |
| 114 | try { | |
| 115 | guest.log.add({ type: "measurement", kind: "ph", value: 7.2 }); | |
| 116 | guest.doser.setMode("phDown", "auto", "owner"); | |
| 117 | for (const [name, args] of [["readings", {}], ["log", {}], ["pumps", {}], ["suggest_dose", { loop: "phDown" }], ["dose", { loop: "phDown", ml: 2 }]] as const) { | |
| 118 | const r = await rpc(guest, name, args); | |
| 119 | expect(r.status).toBe(401); | |
| 120 | } | |
| 121 | expect((await guest.post("/care/mcp", { jsonrpc: "2.0", id: 1, method: "tools/list" })).status).toBe(401); | |
| 122 | expect(guest.doser.dosedToday("phDown")).toBe(0); | |
| 123 | expect(guest.log.entries().filter((e) => e.type !== "measurement")).toHaveLength(0); | |
| 124 | } finally { | |
| 125 | guest.close(); | |
| 126 | } | |
| 127 | const member = await serve("member"); | |
| 128 | try { | |
| 129 | member.doser.setMode("phDown", "auto", "owner"); | |
| 130 | expect((await rpc(member, "log")).status).toBe(200); | |
| 131 | for (const [name, args] of [["pumps", {}], ["suggest_dose", { loop: "phDown" }], ["dose", { loop: "phDown", ml: 2 }]] as const) expect((await rpc(member, name, args)).status).toBe(403); | |
| 132 | expect(member.doser.dosedToday("phDown")).toBe(0); | |
| 133 | } finally { | |
| 134 | member.close(); | |
| 135 | } | |
| 136 | }); | |
| 137 | ||
| 138 | test("MCP is limited to 60 calls a minute per identity", () => { | |
| 139 | const l = rateLimit(MCP_PER_MINUTE); | |
| 140 | for (let i = 0; i < MCP_PER_MINUTE; i++) expect(l.take("a", 0)).toBe(true); | |
| 141 | expect(l.take("a", 1000)).toBe(false); | |
| 142 | expect(l.take("b", 1000)).toBe(true); | |
| 143 | expect(l.take("a", 60_000)).toBe(true); | |
| 144 | }); | |
| 145 | ||
| 146 | test("the care page shows the card to the owner only, with Do it and a halted banner with Resume", async () => { | |
| 147 | const log = new CareLog(); | |
| 148 | log.add({ type: "measurement", kind: "ph", value: 7.2 }); | |
| 149 | const doser = fakeDoser(log); | |
| 150 | const view = { profile: spinach, log, now: Date.now(), signedIn: null }; | |
| 151 | const html = renderCare({ ...view, owner: true, dosing: doser.status() }); | |
| 152 | expect(html).toContain("data-dosing"); | |
| 153 | expect(html).toContain("Do it"); | |
| 154 | expect(html).not.toContain("data-halted"); | |
| 155 | expect(renderCare({ ...view, owner: false })).not.toContain("data-dosing"); | |
| 156 | doser.loops.phDown.mode = "auto"; | |
| 157 | await doser.dose("phDown", "rule"); | |
| 158 | const later = new Doser({ pumps: { nutrientA: { dose: async () => {} }, nutrientB: { dose: async () => {} }, phDown: { dose: async () => { throw new Error("jammed"); } } }, latest: () => undefined, calibrated: () => true, log, loops: { phDown: { mode: "auto" } } }); | |
| 159 | later["s"].halted = "x <b>"; | |
| 160 | const stopped = renderCare({ ...view, owner: true, dosing: later.status() }); | |
| 161 | expect(stopped).toContain("data-halted"); | |
| 162 | expect(stopped).toContain("Resume dosing"); | |
| 163 | expect(stopped).toContain("x <b>"); | |
| 164 | }); | |
| 165 | ||
| 166 | test("the terminal words dosing as the same requests the page makes", () => { | |
| 167 | expect(careCommand(["dosing"], "")).toEqual({ method: "GET", path: "/care/dosing" }); | |
| 168 | expect(careCommand(["dose", "phDown", "2"], "phDown 2")).toEqual({ method: "POST", path: "/care/dosing/dose", body: { loop: "phDown", ml: 2 }, timeoutMs: 80_000 }); | |
| 169 | expect(careCommand(["mode", "phDown", "auto"], "")).toEqual({ method: "POST", path: "/care/dosing/mode", body: { loop: "phDown", mode: "auto" } }); | |
| 170 | expect(careCommand(["resume"], "")).toEqual({ method: "POST", path: "/care/dosing/resume", body: {} }); | |
| 171 | expect(careCommand(["dose", "water"], "")).toHaveProperty("print"); | |
| 172 | expect(dosingLines({ halted: "bad", loops: [{ label: "pH down", mode: "suggest", reason: "no ph reading", dosedTodayMl: 0, maxDayMl: 15 }] })[0]).toContain("STOPPED"); | |
| 173 | }); | |
| 174 | ||
| 175 | test("controller fixes: only the owner changes modes or resumes, modes persist, a bad file halts, a pump failure halts, a read changes nothing, top-off turns off when on() throws", async () => { | |
| 176 | const dir = mkdtempSync(join(tmpdir(), "dose-")); | |
| 177 | try { | |
| 178 | const file = join(dir, "dosing.json"); | |
| 179 | const log = new CareLog(); | |
| 180 | log.add({ type: "measurement", kind: "ph", value: 7.2 }); | |
| 181 | const a = fakeDoser(log, file); | |
| 182 | expect(() => a.setMode("phDown", "auto", "crew")).toThrow(/owner/); | |
| 183 | expect(() => a.resume("rule")).toThrow(/owner/); | |
| 184 | a.setMode("phDown", "auto", "owner"); | |
| 185 | expect(fakeDoser(log, file).loops.phDown.mode).toBe("auto"); | |
| 186 | ||
| 187 | writeFileSync(file, JSON.stringify({ halted: null, lastDoseAt: 0, usedReading: {}, pending: null, doses: null, modes: {} })); | |
| 188 | expect(fakeDoser(log, file).halted).toContain("unreadable"); | |
| 189 | ||
| 190 | const t = Date.now(); | |
| 191 | const jam = new Doser({ pumps: { nutrientA: { dose: async () => {} }, nutrientB: { dose: async () => {} }, phDown: { dose: async () => { throw new Error("jammed"); } } }, latest: () => ({ kind: "ph", value: 7.2, at: t, source: "sensor" }), calibrated: () => true, log, loops: { phDown: { mode: "auto" } }, now: () => t }); | |
| 192 | const out = await jam.dose("phDown", "rule", 2); | |
| 193 | expect(out).toMatchObject({ ok: false, reason: expect.stringContaining("jammed") }); | |
| 194 | expect(jam.halted).toContain("jammed"); | |
| 195 | expect(jam.dosedToday("phDown")).toBe(2); | |
| 196 | expect(log.entries().at(-1)).toMatchObject({ type: "observation", text: expect.stringContaining("jammed") }); | |
| 197 | ||
| 198 | // a read does not halt: an out-of-bounds reading is reported by suggest, and only a dose stops automation | |
| 199 | const wild = new Doser({ pumps: jam["d"].pumps, latest: () => ({ kind: "ph", value: 99, at: t, source: "sensor" }), calibrated: () => true, log, now: () => t }); | |
| 200 | expect(wild.suggest("phDown")).toHaveProperty("reason"); | |
| 201 | expect(wild.halted).toBeNull(); | |
| 202 | wild.loops.phDown.mode = "auto"; | |
| 203 | await wild.dose("phDown", "rule", 1); | |
| 204 | expect(wild.halted).toContain("outside"); | |
| 205 | ||
| 206 | const calls: string[] = []; | |
| 207 | await expect(topOff({ on: async () => { calls.push("on"); throw new Error("relay"); }, off: async () => void calls.push("off") }, async () => false, { capMs: 1000 })).rejects.toThrow("relay"); | |
| 208 | expect(calls).toEqual(["on", "off"]); | |
| 209 | } finally { | |
| 210 | rmSync(dir, { recursive: true, force: true }); | |
| 211 | } | |
| 212 | }); | |
| 213 | ||
| 214 | test("the loops are the three pumps", () => { | |
| 215 | expect(LOOPS).toEqual(["nutrientA", "nutrientB", "phDown"]); | |
| 216 | }); |
Software/so101/src/care-dosing.ts added +231
| @@ -0,0 +1,231 @@ | ||
| 1 | /** | |
| 2 | * The dosing doors on the care routes: /care/dosing (state), /care/dosing/dose, /care/dosing/resume, /care/dosing/mode | |
| 3 | * (the owner's writes) and /care/mcp (the crew's tools). The limits live in src/doser.ts; this only says who may ask. | |
| 4 | * The pumps are the Pi's (src/arm-pumps.ts → src/dose-pumps.ts): /care/dosing/pump is the owner's setup of them (wire, | |
| 5 | * float, prime, calibrate, top off), never a dose. Readings are the log book's newest measurements. `fakeDoser`, whose | |
| 6 | * pumps do nothing (the log book says "simulated"), is for a relay with no pumps given. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { IncomingMessage, ServerResponse } from "node:http"; | |
| 10 | import type { Cause, CareLog } from "./care-log.ts"; | |
| 11 | import { MEASURES, type Measure } from "./care-profile.ts"; | |
| 12 | import { Doser, LOOPS, type LoopId, type Mode, type Pump } from "./doser.ts"; | |
| 13 | import { FILL_TIMEOUT_MS, PUMP_LABEL, pumpRequest, type PumpHooks } from "./arm-pumps.ts"; | |
| 14 | import type { DosingView } from "./dose-pumps.ts"; | |
| 15 | ||
| 16 | export function fakeDoser(log: CareLog, file?: string, now?: () => number): Doser { | |
| 17 | const pump: Pump = { simulated: true, dose: async () => {} }; | |
| 18 | return makeDoser(log, { nutrientA: pump, nutrientB: pump, phDown: pump }, file, now); | |
| 19 | } | |
| 20 | ||
| 21 | /** The controller over these pumps, reading the log book's newest measurements. */ | |
| 22 | export function makeDoser(log: CareLog, pumps: Record<LoopId, Pump>, file?: string, now?: () => number): Doser { | |
| 23 | return new Doser({ | |
| 24 | pumps, | |
| 25 | latest: (kind: Measure) => { | |
| 26 | const m = log.entries().filter((e) => e.type === "measurement" && e.kind === kind).at(-1); | |
| 27 | return m && m.type === "measurement" ? { kind, value: m.value, at: m.at, source: m.source } : undefined; | |
| 28 | }, | |
| 29 | calibrated: () => true, | |
| 30 | log, | |
| 31 | file, | |
| 32 | now, | |
| 33 | }); | |
| 34 | } | |
| 35 | ||
| 36 | export interface DosingContext { | |
| 37 | doser: Doser; | |
| 38 | log: CareLog; | |
| 39 | /** Who asks, from the forge-signed identity; `id` keys the MCP's rate limit. */ | |
| 40 | who: (req: IncomingMessage) => { owner: boolean; known: boolean; id?: string }; | |
| 41 | readBody: (req: IncomingMessage, limit?: number) => Promise<Buffer>; | |
| 42 | /** A mutation is only taken from the page's own origin (relay.ts). */ | |
| 43 | sameOrigin: (req: IncomingMessage) => boolean; | |
| 44 | /** The Pi's pumps; without them there is nothing to set up. */ | |
| 45 | pumps?: PumpHooks; | |
| 46 | } | |
| 47 | ||
| 48 | const loopOf = (v: unknown): LoopId => { | |
| 49 | if (!LOOPS.includes(v as LoopId)) throw new Refused(`loop is one of ${LOOPS.join(", ")}`); | |
| 50 | return v as LoopId; | |
| 51 | }; | |
| 52 | class Refused extends Error {} | |
| 53 | ||
| 54 | /** Parse a JSON object body, capped by the bytes read. */ | |
| 55 | async function object(req: IncomingMessage, ctx: DosingContext): Promise<Record<string, unknown>> { | |
| 56 | const raw = await ctx.readBody(req, 4096); // capped by the bytes read, not by a header | |
| 57 | let v: unknown; | |
| 58 | if (!(req.headers["content-type"] ?? "").includes("json")) { | |
| 59 | // the page's buttons are forms | |
| 60 | v = Object.fromEntries([...new URLSearchParams(raw.toString("utf8"))].map(([k, x]) => [k, k === "ml" ? (x === "" ? undefined : Number(x)) : x])); | |
| 61 | } else | |
| 62 | try { | |
| 63 | v = JSON.parse(raw.toString("utf8") || "null"); | |
| 64 | } catch { | |
| 65 | throw new Refused("send JSON"); | |
| 66 | } | |
| 67 | if (!v || typeof v !== "object" || Array.isArray(v)) throw new Refused("send an object"); | |
| 68 | return v as Record<string, unknown>; | |
| 69 | } | |
| 70 | ||
| 71 | /** True when the path was this module's and has been answered. */ | |
| 72 | export async function handleDosing(req: IncomingMessage, res: ServerResponse, pathname: string, ctx: DosingContext): Promise<boolean> { | |
| 73 | if (pathname !== "/care/dosing" && !pathname.startsWith("/care/dosing/") && pathname !== "/care/mcp") return false; | |
| 74 | const json = (status: number, body: unknown) => { | |
| 75 | res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }); | |
| 76 | res.end(JSON.stringify(body)); | |
| 77 | }; | |
| 78 | if (pathname === "/care/mcp") { | |
| 79 | if (req.method !== "POST") return json(405, { error: "POST JSON-RPC here" }), true; | |
| 80 | if (!ctx.sameOrigin(req)) return json(403, { error: "not from this origin" }), true; | |
| 81 | // every call needs a verified identity: the log book and readings to read, the dosing tools only for the owner | |
| 82 | const caller = ctx.who(req); | |
| 83 | if (!caller.known) return json(401, { error: "sign in first: the care tools need a verified identity" }), true; | |
| 84 | if (!mcpLimit(ctx.doser).take(caller.id ?? "", Date.now())) return json(429, { error: `more than ${MCP_PER_MINUTE} calls a minute; wait a little` }), true; | |
| 85 | await mcp(req, res, ctx, caller.owner); | |
| 86 | return true; | |
| 87 | } | |
| 88 | const me = ctx.who(req); | |
| 89 | // dosing is the owner's to see and to change; a guest sees nothing | |
| 90 | if (!me.owner) return json(me.known ? 403 : 401, { error: "only the owner may use dosing" }), true; | |
| 91 | if (req.method === "GET" && pathname === "/care/dosing") return json(200, ctx.doser.status()), true; | |
| 92 | if (req.method === "GET" && pathname === "/care/dosing/pumps") { | |
| 93 | const v = ctx.pumps ? await ctx.pumps.read() : null; | |
| 94 | return v ? json(200, v) : json(502, { error: "the arm server did not answer" }), true; | |
| 95 | } | |
| 96 | if (req.method !== "POST") return json(405, { error: "POST here" }), true; | |
| 97 | if (!ctx.sameOrigin(req)) return json(403, { error: "only this page may change dosing" }), true; | |
| 98 | const form = !(req.headers["content-type"] ?? "").includes("json"); | |
| 99 | const back = (msg?: string) => { | |
| 100 | res.writeHead(303, { location: msg ? `/care?error=${encodeURIComponent(msg)}` : "/care", "cache-control": "no-store" }); | |
| 101 | res.end(); | |
| 102 | }; | |
| 103 | try { | |
| 104 | let result: unknown = { ok: true }; | |
|
||
| 105 | if (pathname === "/care/dosing/resume") { | |
| 106 | await object(req, ctx).catch(() => ({})); | |
| 107 | ctx.doser.resume("owner"); | |
| 108 | } else if (pathname === "/care/dosing/mode") { | |
| 109 | const b = await object(req, ctx); | |
| 110 | ctx.doser.setMode(loopOf(b.loop), b.mode as Mode, "owner"); | |
| 111 | } else if (pathname === "/care/dosing/dose") { | |
| 112 | const b = await object(req, ctx); | |
| 113 | const out = await ctx.doser.dose(loopOf(b.loop), "owner", b.ml === undefined ? undefined : (b.ml as number)); | |
| 114 | if (!out.ok) return form ? back(out.reason) : json(409, { error: out.reason }), true; | |
| 115 | result = out; | |
| 116 | } else if (pathname === "/care/dosing/pump") { | |
| 117 | result = await setup(await object(req, ctx), ctx); | |
| 118 | } else return json(404, { error: "no such route" }), true; | |
| 119 | if (form) back(); | |
| 120 | else json(200, result); | |
| 121 | } catch (err) { | |
| 122 | if (err instanceof Refused || (err instanceof Error && /^(mode|loop) is/.test(err.message))) form ? back((err as Error).message) : json(400, { error: (err as Error).message }); | |
| 123 | else if ((err as Error).message === "body too large") json(413, { error: "too long" }); | |
| 124 | else throw err; | |
| 125 | } | |
| 126 | return true; | |
| 127 | } | |
| 128 | ||
| 129 | /** One of the owner's setup actions, sent to the Pi; a prime or a top-off is written in the log book. */ | |
| 130 | async function setup(input: Record<string, unknown>, ctx: DosingContext): Promise<unknown> { | |
| 131 | if (!ctx.pumps) throw new Refused("no pumps are reachable from here"); | |
| 132 | const body = pumpRequest(input); | |
| 133 | if (!body) throw new Refused("action is switch, float, prime, calibrate or fill"); | |
| 134 | const a = await ctx.pumps.write(body, body.action === "fill" ? FILL_TIMEOUT_MS : 45_000); | |
| 135 | if (!a.ok) throw new Refused(a.error); | |
| 136 | const out = a.body as { stopped?: unknown; ms?: unknown; mlPerSec?: unknown } | null; | |
| 137 | const label = PUMP_LABEL[body.pump as keyof typeof PUMP_LABEL] ?? ""; | |
| 138 | if (body.action === "fill") { | |
| 139 | const secs = Math.round(Number(out?.ms ?? 0) / 1000); | |
| 140 | ctx.log.add(out?.stopped === "full" ? { type: "action", what: "topoff", cause: "owner" } : { type: "observation", text: `Top-off stopped at its ${secs} s cap before the float said full: check the jug and the float` }); | |
| 141 | } else if (body.action === "prime") ctx.log.add({ type: "observation", text: `Primed the ${label} pump for ${Number(body.ms) / 1000} s` }); | |
| 142 | else if (body.action === "calibrate") ctx.log.add({ type: "observation", text: `Calibrated the ${label} pump: ${String(out?.mlPerSec)} ml/s` }); | |
| 143 | return a.body; | |
| 144 | } | |
| 145 | ||
| 146 | /** Calls a minute per identity on /care/mcp: enough for an agent's loop, not for hammering the Pi. */ | |
| 147 | export const MCP_PER_MINUTE = 60; | |
| 148 | ||
| 149 | /** A fixed one-minute window per key; the oldest keys are dropped past 1000 so the map stays small. */ | |
| 150 | export function rateLimit(perMinute: number) { | |
| 151 | const seen = new Map<string, { start: number; n: number }>(); | |
| 152 | return { | |
| 153 | take(key: string, now: number): boolean { | |
| 154 | const w = seen.get(key); | |
The Pi's error text goes straight into |
||
| 155 | if (!w || now - w.start >= 60_000) { | |
| 156 | seen.delete(key); | |
| 157 | seen.set(key, { start: now, n: 1 }); | |
| 158 | if (seen.size > 1000) seen.delete(seen.keys().next().value!); | |
| 159 | return true; | |
| 160 | } | |
| 161 | return ++w.n <= perMinute; | |
| 162 | }, | |
| 163 | }; | |
| 164 | } | |
| 165 | /** One window per controller, so each app (and each test) counts its own calls. */ | |
| 166 | const mcpLimits = new WeakMap<Doser, ReturnType<typeof rateLimit>>(); | |
| 167 | const mcpLimit = (d: Doser) => mcpLimits.get(d) ?? (mcpLimits.set(d, rateLimit(MCP_PER_MINUTE)), mcpLimits.get(d)!); | |
| 168 | ||
| 169 | /** The tools that show or change dosing, which is the owner's like the card on /care. */ | |
| 170 | const OWNER_TOOLS = new Set(["suggest_dose", "dose", "pumps"]); | |
| 171 | ||
| 172 | const TOOLS = [ | |
| 173 | { name: "readings", description: "The newest reading of each measure (pH, EC, temperatures, level) with its age.", inputSchema: { type: "object", properties: {} } }, | |
| 174 | { name: "log", description: "The newest entries of the care log book.", inputSchema: { type: "object", properties: { limit: { type: "number" } } } }, | |
| 175 | { name: "suggest_dose", description: "What a dosing loop would add now, or why not. Never doses.", inputSchema: { type: "object", properties: { loop: { type: "string", enum: [...LOOPS] } }, required: ["loop"] } }, | |
| 176 | { name: "dose", description: "Dose a loop. Refused unless the owner put that loop in auto, and always bound by the per-dose and daily limits, the mixing wait and fresh readings.", inputSchema: { type: "object", properties: { loop: { type: "string", enum: [...LOOPS] }, ml: { type: "number" } }, required: ["loop"] } }, | |
| 177 | { name: "pumps", description: "The dosing pumps on the Pi: each one's switch, measured ml/s and when it was calibrated, ml today, and whether the reservoir's high float says full. Read-only.", inputSchema: { type: "object", properties: {} } }, | |
| 178 | ]; | |
| 179 | ||
| 180 | /** A minimal MCP over JSON-RPC: initialize, tools/list, tools/call. A dose here is the crew's; it cannot resume or change a mode. */ | |
| 181 | async function mcp(req: IncomingMessage, res: ServerResponse, ctx: DosingContext, owner: boolean) { | |
| 182 | const reply = (status: number, body: unknown) => { | |
| 183 | res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }); | |
| 184 | res.end(JSON.stringify(body)); | |
| 185 | }; | |
| 186 | let msg: { id?: unknown; method?: unknown; params?: Record<string, unknown> }; | |
| 187 | try { | |
| 188 | msg = await object(req, ctx); | |
| 189 | } catch (err) { | |
| 190 | return reply(400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: (err as Error).message } }); | |
| 191 | } | |
| 192 | const id = typeof msg.id === "number" || typeof msg.id === "string" ? msg.id : null; | |
| 193 | const ok = (result: unknown) => reply(200, { jsonrpc: "2.0", id, result }); | |
| 194 | const text = (v: unknown, isError = false) => ok({ content: [{ type: "text", text: JSON.stringify(v) }], isError }); | |
| 195 | switch (msg.method) { | |
| 196 | case "initialize": | |
| 197 | return ok({ protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "arm-care", version: "1" } }); | |
| 198 | case "tools/list": | |
| 199 | return ok({ tools: TOOLS }); | |
| 200 | case "tools/call": { | |
| 201 | const args = (msg.params?.arguments ?? {}) as Record<string, unknown>; | |
| 202 | const cause: Cause = "crew"; | |
| 203 | if (OWNER_TOOLS.has(String(msg.params?.name)) && !owner) return reply(403, { jsonrpc: "2.0", id, error: { code: -32001, message: "only the owner may use the dosing tools" } }); | |
| 204 | try { | |
| 205 | switch (msg.params?.name) { | |
| 206 | case "pumps": { | |
| 207 | const v: DosingView | null = ctx.pumps ? await ctx.pumps.read() : null; | |
| 208 | return text(v ?? { error: "the Pi did not answer" }, !v); | |
| 209 | } | |
| 210 | case "readings": { | |
| 211 | const now = Date.now(); | |
| 212 | return text(Object.fromEntries(MEASURES.map((k) => { const e = ctx.log.entries().filter((x) => x.type === "measurement" && x.kind === k).at(-1); return [k, e && e.type === "measurement" ? { value: e.value, at: e.at, ageMs: now - e.at, source: e.source } : null]; }))); | |
| 213 | } | |
| 214 | case "log": | |
| 215 | return text(ctx.log.entries().slice(-Math.min(Math.max(Math.floor(Number(args.limit ?? 20)) || 20, 1), 100))); | |
| 216 | case "suggest_dose": | |
| 217 | return text(ctx.doser.suggest(loopOf(args.loop))); | |
| 218 | case "dose": { | |
| 219 | const out = await ctx.doser.dose(loopOf(args.loop), cause, args.ml === undefined ? undefined : (args.ml as number)); | |
| 220 | return text(out, !out.ok); | |
| 221 | } | |
| 222 | } | |
| 223 | } catch (err) { | |
| 224 | return text({ error: (err as Error).message }, true); | |
| 225 | } | |
| 226 | return reply(200, { jsonrpc: "2.0", id, error: { code: -32602, message: "no such tool" } }); | |
| 227 | } | |
| 228 | default: | |
| 229 | return reply(200, { jsonrpc: "2.0", id, error: { code: -32601, message: "no such method" } }); | |
| 230 | } | |
| 231 | } |
Software/so101/src/care-log.ts +7 −3
| @@ -9,10 +9,13 @@ import { MEASURES, type Measure } from "./care-profile.ts"; | ||
| 9 | 9 | |
| 10 | 10 | export const MAX_ENTRIES = 5000; |
| 11 | 11 | export const SENSOR_EVERY_MS = 3_600_000; |
| 12 | export const ACTIONS = ["sow", "water", "topoff", "change", "nutrients", "thin", "harvest", "airstone", "pumpcheck", "transplant", "done"] as const; | |
| 12 | export const ACTIONS = ["sow", "water", "topoff", "change", "nutrients", "phdown", "thin", "harvest", "airstone", "pumpcheck", "transplant", "done"] as const; | |
| 13 | 13 | export const PROBLEMS = ["bolting", "tip burn", "root rot", "algae", "yellowing", "leggy"] as const; |
| 14 | 14 | export const SYSTEMS = ["vertical tower", "DWC", "NFT", "Kratky", "ebb-and-flow"] as const; |
| 15 | 15 | export type Action = (typeof ACTIONS)[number]; |
| 16 | /** Who or what made an action: the dose controller's rule, a crew agent, or the owner. */ | |
| 17 | export const CAUSES = ["rule", "crew", "owner"] as const; | |
| 18 | export type Cause = (typeof CAUSES)[number]; | |
| 16 | 19 | |
| 17 | 20 | /** What a sensor will also produce. */ |
| 18 | 21 | export interface Reading { |
| @@ -31,7 +34,7 @@ export interface Transplant { | ||
| 31 | 34 | |
| 32 | 35 | export type Entry = { id: number; at: number } & ( |
| 33 | 36 | | ({ type: "measurement" } & Omit<Reading, "at">) |
| 34 | | { type: "action"; what: Action; amount?: number; ref?: string; transplant?: Transplant } | |
| 37 | | { type: "action"; what: Action; amount?: number; ref?: string; cause?: Cause; transplant?: Transplant } | |
| 35 | 38 | | { type: "observation"; text: string } |
| 36 | 39 | | { type: "problem"; what: (typeof PROBLEMS)[number]; text?: string } |
| 37 | 40 | ); |
| @@ -87,7 +90,8 @@ export function parseEntry(input: unknown, now = Date.now()): EntryBody { | ||
| 87 | 90 | if (!/^[a-z0-9][a-z0-9-]*$/.test(ref!)) throw new LogError("ref is a reminder id"); |
| 88 | 91 | return { ...base, ref }; |
| 89 | 92 | } |
| 90 | return { ...base, ...(e.amount !== undefined ? { amount: positive(e.amount, "amount", 100000) } : {}) }; | |
| 93 | if (e.cause !== undefined && !CAUSES.includes(e.cause as Cause)) throw new LogError(`cause is one of ${CAUSES.join(", ")}`); | |
| 94 | return { ...base, ...(e.amount !== undefined ? { amount: positive(e.amount, "amount", 100000) } : {}), ...(e.cause !== undefined ? { cause: e.cause as Cause } : {}) }; | |
| 91 | 95 | } |
| 92 | 96 | case "observation": |
| 93 | 97 | return { at, type: "observation", text: text(e.text, "text", 1000)! }; |
Software/so101/src/care-page.ts +25 −2
| @@ -3,6 +3,10 @@ | ||
| 3 | 3 | import { boltingWatch, currentStage, health, LABELS, ago, readings, reminders, snapshot, stageAt, stageHistory, totals, transplantPrompt, type Snapshot } from "./care-rules.ts"; |
| 4 | 4 | import { ACTIONS, PROBLEMS, SYSTEMS, type Entry, type CareLog } from "./care-log.ts"; |
| 5 | 5 | import { MEASURES, type Band, type Measure, type Profile } from "./care-profile.ts"; |
| 6 | import type { Doser } from "./doser.ts"; | |
| 7 | import type { DosingView } from "./dose-pumps.ts"; | |
| 8 | import { renderPumps } from "./pumps-card.ts"; | |
| 9 | import { MODES } from "./doser.ts"; | |
| 6 | 10 | import { renderSignedIn, SIGNED_IN_STYLE, type SignedIn } from "./signed-in.ts"; |
| 7 | 11 | import { esc } from "./story-markdown.ts"; |
| 8 | 12 | |
| @@ -28,6 +32,7 @@ h1 { margin: .5rem 0; } h2.first { margin-top: 0; } h2 { margin: 1.2rem 0 .5rem; | ||
| 28 | 32 | .health p { margin: .1rem 0; } |
| 29 | 33 | .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: .6rem .8rem; margin: .6rem 0; } |
| 30 | 34 | ul.list { list-style: none; margin: 0; padding: 0; } ul.list li { display: flex; flex-wrap: wrap; gap: .4rem .8rem; align-items: center; justify-content: space-between; padding: .35rem 0; border-top: 1px solid var(--line); } ul.list li:first-child { border-top: 0; } |
| 35 | details { flex-basis: 100%; } details summary { cursor: pointer; color: var(--link); min-height: 44px; display: flex; align-items: center; } h3 { font-size: 1rem; margin: 1rem 0 .3rem; } | |
| 31 | 36 | .late { color: var(--bad); font-weight: 600; } .dim { color: var(--dim); font-size: .9rem; } |
| 32 | 37 | button, select, input { font: inherit; min-height: 44px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg); color: var(--fg); padding: 0 .7rem; box-sizing: border-box; } |
| 33 | 38 | button { background: var(--accent); color: var(--on-accent); border: 0; font-weight: 650; padding: 0 1rem; } |
| @@ -74,7 +79,7 @@ function describe(e: Entry): string { | ||
| 74 | 79 | case "action": |
| 75 | 80 | if (e.transplant) return `Transplant: ${e.transplant.system}, ${e.transplant.litres} L, ${e.transplant.sites} sites, pump ${e.transplant.pump ? "yes" : "no"}`; |
| 76 | 81 | if (e.what === "done") return `Done: ${e.ref}`; |
| 77 | return `${e.what}${e.amount ? ` ${e.amount}${e.what === "harvest" ? " g" : e.what === "nutrients" ? " ml" : ""}` : ""}`; | |
| 82 | return `${e.what}${e.amount ? ` ${e.amount}${e.what === "harvest" ? " g" : e.what === "nutrients" || e.what === "phdown" ? " ml" : ""}` : ""}`; | |
| 78 | 83 | } |
| 79 | 84 | } |
| 80 | 85 | |
| @@ -88,6 +93,10 @@ export interface CareView { | ||
| 88 | 93 | now: number; |
| 89 | 94 | /** May write: the owner (or a run on one's own machine). */ |
| 90 | 95 | owner: boolean; |
| 96 | /** The dosing card: the owner's only. */ | |
| 97 | dosing?: ReturnType<Doser["status"]>; | |
| 98 | /** The Pi's pumps under the dosing card; null when the Pi did not answer. */ | |
| 99 | pumps?: DosingView | null; | |
| 91 | 100 | signedIn: SignedIn | null; |
| 92 | 101 | /** A refused form's message, shown at the top. */ |
| 93 | 102 | error?: string | null; |
| @@ -95,7 +104,7 @@ export interface CareView { | ||
| 95 | 104 | probes?: string; |
| 96 | 105 | } |
| 97 | 106 | |
| 98 | export function renderCare({ profile, log, now, owner, signedIn, error, probes = "" }: CareView): string { | |
| 107 | export function renderCare({ profile, log, now, owner, signedIn, error, dosing, pumps, probes = "" }: CareView): string { | |
| 99 | 108 | const snap = snapshot(log); |
| 100 | 109 | const spans = stageHistory(profile, snap.entries, snap.startedAt); |
| 101 | 110 | const cur = currentStage(spans); |
| @@ -117,6 +126,19 @@ ${spans.length === 1 ? `<form method="post" action="/care/log"><input type="hidd | ||
| 117 | 126 | </section>` |
| 118 | 127 | : ""; |
| 119 | 128 | |
| 129 | const dosingCard = dosing | |
| 130 | ? `<section class="panel" aria-labelledby="dosing" data-dosing><h2 id="dosing" style="margin-top:0">Dosing</h2> | |
| 131 | ${dosing.halted ? `<p class="alert" role="alert" data-halted>Dosing stopped: ${esc(dosing.halted)}</p>${post("/care/dosing/resume", {}, "Resume dosing")}` : ""} | |
| 132 | ${dosing.simulated ? `<p class="dim">No pumps are wired yet: a dose is only noted in the log book.</p>` : ""} | |
| 133 | <ul class="list">${dosing.loops | |
| 134 | .map( | |
| 135 | (l) => `<li data-loop="${esc(l.id)}"><span><strong>${esc(l.label)}</strong> · ${esc(l.mode)}<br><span class="dim">${l.suggestion ? esc(l.suggestion.text) : esc(l.reason ?? "")} · today ${l.dosedTodayMl} of ${l.maxDayMl} ml, up to ${l.maxDoseMl} ml a dose</span></span> | |
| 136 | <span>${l.suggestion && !dosing.halted ? post("/care/dosing/dose", { loop: l.id, ml: String(l.suggestion.ml) }, "Do it", "", `Do it: ${l.suggestion.text}`) : ""} | |
| 137 | <form class="inline" method="post" action="/care/dosing/mode"><input type="hidden" name="loop" value="${esc(l.id)}"><select name="mode" aria-label="Mode for ${esc(l.label)}">${MODES.map((m) => `<option value="${m}"${m === l.mode ? " selected" : ""}>${m}</option>`).join("")}</select><button class="quiet">Set</button></form></span></li>`, | |
| 138 | ) | |
| 139 | .join("")}</ul>${pumps !== undefined ? renderPumps(pumps) : ""}</section>` | |
| 140 | : ""; | |
| 141 | ||
| 120 | 142 | const dueRows = due |
| 121 | 143 | .map((d) => { |
| 122 | 144 | const late = d.overdueMs > 0 && !d.snoozedUntil; |
| @@ -160,6 +182,7 @@ ${error ? `<p class="alert" role="alert">${esc(error)}</p>` : ""} | ||
| 160 | 182 | ${prompt ? `<p class="panel" data-prompt="transplant">${esc(prompt)}</p>` : ""}${bolt ? `<p class="panel" data-prompt="bolting">${esc(bolt)}</p>` : ""} |
| 161 | 183 | ${quick} |
| 162 | 184 | ${probes} |
| 185 | ${dosingCard} | |
| 163 | 186 | <h2>Reminders</h2><div class="panel"><ul class="list">${dueRows}</ul></div> |
| 164 | 187 | <h2>Metrics</h2> |
| 165 | 188 | <p class="dim">Typical ranges, not guaranteed. ${esc(profile.note)} Days since the last water change: ${sum.daysSinceChange ?? "none logged"}. Harvest so far: ${sum.harvestGrams} g. Light target ${profile.light.hours} h, warn above ${profile.light.warnAbove} h (bolting).</p> |
Software/so101/src/care-routes.ts +7 −1
| @@ -6,11 +6,17 @@ import { LogError, type CareLog } from "./care-log.ts"; | ||
| 6 | 6 | import { renderCare } from "./care-page.ts"; |
| 7 | 7 | import { calibrateProbe, renderProbes, type ProbeCache } from "./care-probes.ts"; |
| 8 | 8 | import type { Profile } from "./care-profile.ts"; |
| 9 | import type { Doser } from "./doser.ts"; | |
| 10 | import type { DosingView } from "./dose-pumps.ts"; | |
| 9 | 11 | import type { SignedIn } from "./signed-in.ts"; |
| 10 | 12 | |
| 11 | 13 | export interface CareContext { |
| 12 | 14 | profile: Profile; |
| 13 | 15 | log: CareLog; |
| 16 | /** The dosing controller; its card on the page is the owner's (the doors are src/care-dosing.ts). */ | |
| 17 | doser?: Doser; | |
| 18 | /** The Pi's pumps for the dosing card: wiring, calibration, the float. */ | |
| 19 | pumps?: () => Promise<DosingView | null>; | |
| 14 | 20 | /** null when the request may not write; the reader is told who they are for the page. */ |
| 15 | 21 | who: (req: IncomingMessage) => { owner: boolean; signedIn: SignedIn | null; known: boolean }; |
| 16 | 22 | readBody: (req: IncomingMessage, limit?: number) => Promise<Buffer>; |
| @@ -46,7 +52,7 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path | ||
| 46 | 52 | |
| 47 | 53 | if (req.method === "GET" && pathname === "/care") { |
| 48 | 54 | const probes = renderProbes(ctx.probes?.peek() ?? null, me.owner); |
| 49 | send(200, "text/html; charset=utf-8", renderCare({ probes, profile: ctx.profile, log: ctx.log, now, owner: me.owner, signedIn: me.signedIn, error: new URL(req.url ?? "/", "http://x").searchParams.get("error")?.slice(0, 200) })); | |
| 55 | send(200, "text/html; charset=utf-8", renderCare({ probes, profile: ctx.profile, log: ctx.log, now, owner: me.owner, dosing: me.owner ? ctx.doser?.status() : undefined, pumps: me.owner && ctx.doser ? ((await ctx.pumps?.()) ?? null) : undefined, signedIn: me.signedIn, error: new URL(req.url ?? "/", "http://x").searchParams.get("error")?.slice(0, 200) })); | |
| 50 | 56 | return true; |
| 51 | 57 | } |
| 52 | 58 | if (req.method === "GET" && pathname === "/care/events") { |
Software/so101/src/care-terminal.test.ts +20
| @@ -95,6 +95,26 @@ test("through the relay: a cross-site write is refused, a guest is 401, a plain | ||
| 95 | 95 | expect(careLines({ stage: "seedling", health: { ok: true, lines: [] }, reminders: [], entries: [] })[0]).toContain("healthy"); |
| 96 | 96 | }); |
| 97 | 97 | |
| 98 | test("through the terminal, a guest and a signed-in non-owner see nothing from /care dosing and /care pumps, and cannot set a pump up", async () => { | |
| 99 | const { port } = await start(); | |
| 100 | const run = async (env: Record<string, string>, line: string) => { | |
| 101 | const printed: string[] = []; | |
| 102 | new Door("ws://127.0.0.1:1/live", {}, (l) => printed.push(l), null, null, null, null, null, null, null, null, null, null, null, null, careReader(env)).input(line); | |
| 103 | for (let i = 0; i < 100 && !printed.length; i++) await Bun.sleep(20); | |
| 104 | return printed; | |
| 105 | }; | |
| 106 | for (const env of [terminal(port, null), terminal(port, "eve")]) { | |
| 107 | for (const line of ["/care dosing", "/care pumps", "/care prime phDown", "/care topoff", "/care dose phDown 2"]) { | |
| 108 | const out = await run(env, line); | |
| 109 | expect(out).toHaveLength(1); | |
| 110 | expect(out[0]).toMatch(/^error: only the owner may use dosing/); | |
| 111 | } | |
| 112 | } | |
| 113 | // the owner gets past the gate (the Pi here does not answer, and says so) | |
| 114 | const owner = await run(terminal(port, "trevato", "admin"), "/care dosing"); | |
| 115 | expect(owner[0]).toMatch(/^Dosing running|^STOPPED/); | |
| 116 | }); | |
| 117 | ||
| 98 | 118 | test("/probes through the relay: anyone reads, the owner calibrates, a web viewer hears it live, and a Pi that is down never delays /care", async () => { |
| 99 | 119 | const view = (next: string | null) => ({ present: true, error: null, ph: null, ec: null, tempC: 21, tempSource: "default", calibratedAt: { ph: null, ec: null }, next, pending: next === "ph4" }); |
| 100 | 120 | const pi = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (req) => (new URL(req.url).pathname === "/probes/calibrate" ? Response.json(view("ph4")) : Response.json(view("ph7"))) }); |
Software/so101/src/care-terminal.ts +74 −1
| @@ -5,14 +5,19 @@ | ||
| 5 | 5 | |
| 6 | 6 | import { ACTIONS, PROBLEMS } from "./care-log.ts"; |
| 7 | 7 | import { MEASURES } from "./care-profile.ts"; |
| 8 | import { LOOPS, MODES } from "./doser.ts"; | |
| 9 | import { DOSE_TIMEOUT_MS, FILL_TIMEOUT_MS, PUMP_LABEL } from "./arm-pumps.ts"; | |
| 10 | import { PUMP_IDS } from "./dose-pumps.ts"; | |
| 8 | 11 | |
| 9 | 12 | export interface CareAsk { |
| 10 | 13 | method: "GET" | "POST" | "DELETE"; |
| 11 | 14 | path: string; |
| 12 | 15 | body?: Record<string, unknown>; |
| 16 | /** How long to wait for the answer, when longer than a read: a dose or a top-off runs a pump first. */ | |
| 17 | timeoutMs?: number; | |
| 13 | 18 | } |
| 14 | 19 | |
| 15 | export const CARE_USAGE = "usage: /care · /care log <ph|ec|waterTemp|airTemp|level|lightHours> <value> · /care did <action> [amount] · /care note <text> · /care problem <bolting|tip burn|root rot|algae|yellowing|leggy> [detail] · /care done <reminder> · /care snooze <reminder> [hours] · /care delete <entry id>"; | |
| 20 | export const CARE_USAGE = "usage: /care · /care log <ph|ec|waterTemp|airTemp|level|lightHours> <value> · /care did <action> [amount] · /care note <text> · /care problem <bolting|tip burn|root rot|algae|yellowing|leggy> [detail] · /care done <reminder> · /care snooze <reminder> [hours] · /care delete <entry id> · /care dosing · /care dose <nutrientA|nutrientB|phDown> [ml] · /care mode <loop> <off|suggest|auto> · /care resume · /care pumps · /care wire <pump> gpio <line> [low] | shelly <host> | none · /care prime <pump> [seconds] · /care calibrate <pump> <ml> · /care float <line|none> [low] [cap seconds] · /care topoff (dosing is the owner's; a pump is nutrientA, nutrientB, phDown or topOff)"; | |
| 16 | 21 | |
| 17 | 22 | export const PROBES_USAGE = "usage: /probes · /probes calibrate <ph7|ph4|ec1413> [solution tempC] (owner)"; |
| 18 | 23 | |
| @@ -52,11 +57,79 @@ export function careCommand(words: string[], tail: string): CareAsk | { print: s | ||
| 52 | 57 | return /^[a-z0-9-]{1,32}$/.test(a) && words.length <= 3 && (!b || Number.isFinite(num(b))) ? post("/care/reminder", { id: a, action: "snooze", hours: b ? num(b) : 24 }) : usage; |
| 53 | 58 | case "delete": |
| 54 | 59 | return /^\d{1,9}$/.test(a) && words.length === 2 ? { method: "DELETE", path: `/care/log/${a}`, body: {} } : usage; |
| 60 | case "dosing": | |
| 61 | return words.length === 1 ? { method: "GET", path: "/care/dosing" } : usage; | |
| 62 | case "dose": | |
| 63 | return (LOOPS as readonly string[]).includes(a) && words.length <= 3 && (!b || Number.isFinite(num(b))) ? { ...post("/care/dosing/dose", { loop: a, ...(b ? { ml: num(b) } : {}) }), timeoutMs: DOSE_TIMEOUT_MS + 5000 } : usage; | |
| 64 | case "mode": | |
| 65 | return (LOOPS as readonly string[]).includes(a) && (MODES as readonly string[]).includes(b) && words.length === 3 ? post("/care/dosing/mode", { loop: a, mode: b }) : usage; | |
| 66 | case "resume": | |
| 67 | return words.length === 1 ? post("/care/dosing/resume", {}) : usage; | |
| 68 | case "pumps": | |
| 69 | return words.length === 1 ? { method: "GET", path: "/care/dosing/pumps" } : usage; | |
| 70 | case "wire": { | |
| 71 | const pump = (PUMP_IDS as readonly string[]).includes(a) ? a : null; | |
| 72 | const c = words[3] ?? ""; | |
| 73 | if (!pump) return usage; | |
| 74 | if (b === "none" && words.length === 3) return pumpPost({ action: "switch", pump, switch: null }); | |
| 75 | if (b === "gpio" && /^\d{1,2}$/.test(c) && words.length <= 5 && (words[4] === undefined || words[4] === "low")) return pumpPost({ action: "switch", pump, switch: { driver: "gpio", chip: "gpiochip0", line: Number(c), activeLow: words[4] === "low" } }); | |
| 76 | if (b === "shelly" && c && words.length === 4) return pumpPost({ action: "switch", pump, switch: { driver: "shelly", host: c, gen: 2 } }); | |
| 77 | return usage; | |
| 78 | } | |
| 79 | case "prime": | |
| 80 | return (PUMP_IDS as readonly string[]).includes(a) && words.length <= 3 && (!b || Number.isFinite(num(b))) ? pumpPost({ action: "prime", pump: a, sec: b ? num(b) : 10 }) : usage; | |
| 81 | case "calibrate": | |
| 82 | return (PUMP_IDS as readonly string[]).includes(a) && Number.isFinite(num(b)) && words.length === 3 ? pumpPost({ action: "calibrate", pump: a, ml: num(b) }) : usage; | |
| 83 | case "float": { | |
| 84 | if (a === "none" && words.length === 2) return pumpPost({ action: "float", line: "none" }); | |
| 85 | if (!/^\d{1,2}$/.test(a) || words.length > 4) return usage; | |
| 86 | const rest = words.slice(2); | |
| 87 | const low = rest.includes("low"); | |
| 88 | const cap = rest.find((w) => w !== "low"); | |
| 89 | if (cap !== undefined && !/^\d{1,3}$/.test(cap)) return usage; | |
| 90 | return pumpPost({ action: "float", line: Number(a), activeLow: low, ...(cap ? { capSec: Number(cap) } : {}) }); | |
| 91 | } | |
| 92 | case "topoff": | |
| 93 | return words.length === 1 ? pumpPost({ action: "fill" }) : usage; | |
| 55 | 94 | default: |
| 56 | 95 | return usage; |
| 57 | 96 | } |
| 58 | 97 | } |
| 59 | 98 | |
| 99 | const pumpPost = (body: Record<string, unknown>): CareAsk => ({ method: "POST", path: "/care/dosing/pump", body, timeoutMs: body.action === "fill" ? FILL_TIMEOUT_MS + 5000 : 50_000 }); | |
| 100 | ||
| 101 | /** GET /care/dosing/pumps as lines. */ | |
| 102 | export function pumpLines(body: unknown): string[] { | |
| 103 | const b = (body ?? {}) as { pumps?: { id?: unknown; driver?: unknown; mlPerSec?: unknown; calibratedAt?: unknown; dosedTodayMl?: unknown }[]; float?: { line?: unknown; activeLow?: unknown } | null; full?: unknown; capSec?: unknown; error?: unknown }; | |
| 104 | const lines = (b.pumps ?? []).map((p) => { | |
| 105 | const label = PUMP_LABEL[p.id as keyof typeof PUMP_LABEL] ?? String(p.id); | |
| 106 | const cal = typeof p.mlPerSec === "number" ? `${p.mlPerSec} ml/s${typeof p.calibratedAt === "number" ? ` (measured ${new Date(p.calibratedAt).toISOString().slice(0, 10)})` : ""}` : p.driver ? "not calibrated" : "not wired"; | |
| 107 | return ` ${label} · ${p.driver ? String(p.driver) : "no switch"} · ${cal}${p.id === "topOff" ? "" : ` · today ${String(p.dosedTodayMl)} ml`}`; | |
| 108 | }); | |
| 109 | lines.unshift("Pumps"); | |
| 110 | lines.push(b.float ? ` high float on GPIO ${String(b.float.line)}${b.float.activeLow ? " (low)" : ""}: ${b.full === true ? "full" : b.full === false ? "not full" : "no answer"} · top-off cap ${String(b.capSec)} s` : " no high float: top-off will not run"); | |
| 111 | if (b.error) lines.push(` the Pi says: ${String(b.error)}`); | |
| 112 | return lines; | |
| 113 | } | |
| 114 | ||
| 115 | /** What a setup action on the pumps answered, in a line. */ | |
| 116 | export function pumpSaid(body: unknown): string { | |
| 117 | const b = (body ?? {}) as { mlPerSec?: unknown; ms?: unknown; stopped?: unknown }; | |
| 118 | if (typeof b.mlPerSec === "number") return `calibrated: ${b.mlPerSec} ml/s`; | |
| 119 | if (b.stopped === "full") return `top-off: full after ${Math.round(Number(b.ms) / 1000)} s`; | |
| 120 | if (b.stopped === "cap") return `top-off: stopped at the cap after ${Math.round(Number(b.ms) / 1000)} s, not full; check the jug and the float`; | |
| 121 | if (typeof b.ms === "number") return `primed for ${b.ms / 1000} s: measure the cup, then /care calibrate <pump> <ml>`; | |
| 122 | return "pumps: done"; | |
| 123 | } | |
| 124 | ||
| 125 | /** GET /care/dosing as lines. */ | |
| 126 | export function dosingLines(body: unknown): string[] { | |
| 127 | const b = (body ?? {}) as { halted?: unknown; simulated?: unknown; loops?: { label?: unknown; mode?: unknown; suggestion?: { text?: unknown } | null; reason?: unknown; dosedTodayMl?: unknown; maxDayMl?: unknown }[] }; | |
| 128 | const lines = [b.halted ? `STOPPED: ${String(b.halted)} (/care resume)` : "Dosing running", ...(b.simulated ? [" no pumps wired: a dose is only noted in the log book"] : [])]; | |
| 129 | for (const l of b.loops ?? []) lines.push(` ${String(l.label)} · ${String(l.mode)} · ${l.suggestion ? String(l.suggestion.text) : String(l.reason ?? "")} · today ${String(l.dosedTodayMl)}/${String(l.maxDayMl)} ml`); | |
| 130 | return lines; | |
| 131 | } | |
| 132 | ||
| 60 | 133 | const day = (at: number) => new Date(at).toISOString().replace("T", " ").slice(0, 16); |
| 61 | 134 | |
| 62 | 135 | /** GET /care/api as lines: the stage, the health strip, what is due, and the latest entries with their ids. */ |
Software/so101/src/dose-pumps.test.ts added +296
| @@ -0,0 +1,296 @@ | ||
| 1 | import { afterEach, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { armPumps, cachedRead, pumpRequest, readDosing, type PumpHooks } from "./arm-pumps.ts"; | |
| 6 | import { CareLog } from "./care-log.ts"; | |
| 7 | import { BUILT_IN_DIR, loadProfiles } from "./care-profile.ts"; | |
| 8 | import { careCommand, pumpLines, pumpSaid } from "./care-terminal.ts"; | |
| 9 | import { DosePumps, MAX_DAY_ML, MAX_RUN_MS, PumpError } from "./dose-pumps.ts"; | |
| 10 | import { Doser, PumpRefused } from "./doser.ts"; | |
| 11 | import { FeetechBus } from "./bus.ts"; | |
| 12 | import { FakeBus } from "./fake-bus.ts"; | |
| 13 | import { sign } from "./identity.ts"; | |
| 14 | import type { DriverConfig, Switch } from "./light-switch.ts"; | |
| 15 | import { Lobby } from "./lobby.ts"; | |
| 16 | import { renderPumps } from "./pumps-card.ts"; | |
| 17 | import { startRelay, type RunningRelay } from "./relay.ts"; | |
| 18 | import { startServer, type RunningServer } from "./server.ts"; | |
| 19 | ||
| 20 | /** Fake hardware: every switch by its GPIO line (or host), what it was told, and a float the test sets. */ | |
| 21 | function rig(dir = mkdtempSync(join(tmpdir(), "so101-pumps-"))) { | |
| 22 | let t = 1_800_000_000_000; | |
| 23 | const calls: string[] = []; | |
| 24 | const state: Record<string, boolean> = {}; | |
| 25 | const fail: Record<string, "on" | "off"> = {}; | |
| 26 | let full = false; | |
| 27 | let fillAfterMs = Infinity; | |
| 28 | let onAt = 0; | |
| 29 | const key = (c: DriverConfig) => (c.driver === "gpio" ? `gpio${c.line}` : c.driver === "shelly" ? c.host : "usb"); | |
| 30 | const make = (c: DriverConfig): Switch => { | |
| 31 | const k = key(c); | |
| 32 | return { | |
| 33 | on: async () => { | |
| 34 | if (fail[k] === "on") throw new Error("relay stuck"); | |
| 35 | calls.push(`${k} on`); | |
| 36 | state[k] = true; | |
| 37 | onAt = t; | |
| 38 | }, | |
| 39 | off: async () => { | |
| 40 | if (fail[k] === "off") throw new Error("relay welded"); | |
| 41 | calls.push(`${k} off`); | |
| 42 | state[k] = false; | |
| 43 | }, | |
| 44 | // the float: wet once the top-off pump has run long enough | |
| 45 | state: async () => full || (state.gpio5 === true && t - onAt >= fillAfterMs), | |
| 46 | }; | |
| 47 | }; | |
| 48 | const sleep = async (ms: number) => void (t += ms); | |
| 49 | const pumps = new DosePumps({ file: join(dir, "dose-pumps.json"), now: () => t, sleep, make }); | |
| 50 | return { dir, pumps, calls, state, fail, make, sleep, now: () => t, advance: (ms: number) => (t += ms), setFull: (v: boolean) => (full = v), fillAfter: (ms: number) => (fillAfterMs = ms) }; | |
| 51 | } | |
| 52 | ||
| 53 | const refusal = async (p: Promise<unknown>) => { | |
| 54 | try { | |
| 55 | await p; | |
| 56 | } catch (err) { | |
| 57 | return err as PumpError; | |
| 58 | } | |
| 59 | throw new Error("expected a refusal"); | |
| 60 | }; | |
| 61 | ||
| 62 | describe("the Pi's pumps", () => { | |
| 63 | test("an unwired or uncalibrated pump refuses a dose before anything is switched", async () => { | |
| 64 | const r = rig(); | |
| 65 | const e = await refusal(r.pumps.act({ action: "dose", pump: "phDown", ml: 2 })); | |
| 66 | expect(e).toBeInstanceOf(PumpError); | |
| 67 | expect(e.refused).toBe(true); | |
| 68 | expect(e.message).toContain("no switch"); | |
| 69 | await r.pumps.act({ action: "switch", pump: "phDown", switch: { driver: "gpio", line: 17 } }); | |
| 70 | r.calls.length = 0; | |
| 71 | const u = await refusal(r.pumps.act({ action: "dose", pump: "phDown", ml: 2 })); | |
| 72 | expect(u.refused).toBe(true); | |
| 73 | expect(u.message).toContain("not calibrated"); | |
| 74 | expect(r.calls).toEqual([]); | |
| 75 | expect(r.pumps.dosedToday("phDown")).toBe(0); | |
| 76 | }); | |
| 77 | ||
| 78 | test("prime into a cup, type the ml, and a dose runs for ml ÷ ml/s", async () => { | |
| 79 | const r = rig(); | |
| 80 | await r.pumps.act({ action: "switch", pump: "nutrientA", switch: { driver: "gpio", line: 22, activeLow: true } }); | |
| 81 | expect((await refusal(r.pumps.act({ action: "calibrate", pump: "nutrientA", ml: 12 }))).message).toContain("prime"); | |
| 82 | const t0 = r.now(); | |
| 83 | expect(await r.pumps.act({ action: "prime", pump: "nutrientA", ms: 10_000 })).toEqual({ ok: true, ms: 10_000 }); | |
| 84 | expect(r.now() - t0).toBe(10_000); | |
| 85 | expect(r.calls.slice(-2)).toEqual(["gpio22 on", "gpio22 off"]); | |
| 86 | expect(await r.pumps.act({ action: "calibrate", pump: "nutrientA", ml: 12 })).toEqual({ ok: true, mlPerSec: 1.2 }); | |
| 87 | const t1 = r.now(); | |
| 88 | expect(await r.pumps.act({ action: "dose", pump: "nutrientA", ml: 6 })).toEqual({ ok: true, ml: 6, ms: 5000 }); | |
| 89 | expect(r.now() - t1).toBe(5000); | |
| 90 | expect(r.state.gpio22).toBe(false); | |
| 91 | const v = await r.pumps.view(); | |
| 92 | expect(v.pumps.find((p) => p.id === "nutrientA")).toMatchObject({ driver: "gpio", mlPerSec: 1.2, dosedTodayMl: 6 }); | |
| 93 | // a calibration that is not a dosing pump's flow, or an old prime, is refused | |
| 94 | await r.pumps.act({ action: "prime", pump: "nutrientA", ms: 1000 }); | |
| 95 | expect((await refusal(r.pumps.act({ action: "calibrate", pump: "nutrientA", ml: 900 }))).message).toContain("measure again"); | |
| 96 | r.advance(3_600_001); | |
| 97 | expect((await refusal(r.pumps.act({ action: "calibrate", pump: "nutrientA", ml: 1 }))).message).toContain("prime"); | |
| 98 | }); | |
| 99 | ||
| 100 | test("the Pi's backstop: one run past 60 s or a day past 100 ml is refused, whatever the controller asks", async () => { | |
| 101 | const r = rig(); | |
| 102 | await r.pumps.act({ action: "switch", pump: "phDown", switch: { driver: "gpio", line: 17 } }); | |
| 103 | await r.pumps.act({ action: "prime", pump: "phDown", ms: 10_000 }); | |
| 104 | await r.pumps.act({ action: "calibrate", pump: "phDown", ml: 10 }); // 1 ml/s | |
| 105 | const long = await refusal(r.pumps.act({ action: "dose", pump: "phDown", ml: MAX_RUN_MS / 1000 + 1 })); | |
| 106 | expect(long.refused).toBe(true); | |
| 107 | expect(long.message).toContain("60 s"); | |
| 108 | for (let i = 0; i < 2; i++) await r.pumps.act({ action: "dose", pump: "phDown", ml: 50 }); | |
| 109 | const day = await refusal(r.pumps.act({ action: "dose", pump: "phDown", ml: 1 })); | |
| 110 | expect(day.message).toContain(`${MAX_DAY_ML} ml`); | |
| 111 | r.advance(86_400_001); | |
| 112 | expect(await r.pumps.act({ action: "dose", pump: "phDown", ml: 1 })).toMatchObject({ ok: true }); | |
| 113 | }); | |
| 114 | ||
| 115 | test("a switch that fails once on is switched off anyway, and it is a failure, not a refusal", async () => { | |
| 116 | const r = rig(); | |
| 117 | await r.pumps.act({ action: "switch", pump: "phDown", switch: { driver: "gpio", line: 17 } }); | |
| 118 | r.fail.gpio17 = "on"; | |
| 119 | const e = await refusal(r.pumps.act({ action: "prime", pump: "phDown", ms: 2000 })); | |
| 120 | expect(e.refused).toBe(false); | |
| 121 | expect(r.calls.at(-1)).toBe("gpio17 off"); | |
| 122 | expect((await r.pumps.view()).running).toBeNull(); | |
| 123 | }); | |
| 124 | ||
| 125 | test("top-off runs to the high float, stops at the cap, and will not run without a float", async () => { | |
| 126 | const r = rig(); | |
| 127 | await r.pumps.act({ action: "switch", pump: "topOff", switch: { driver: "gpio", line: 5 } }); | |
| 128 | expect((await refusal(r.pumps.act({ action: "fill" }))).message).toContain("float"); | |
| 129 | await r.pumps.act({ action: "float", float: { line: 6 }, capSec: 30 }); | |
| 130 | r.fillAfter(4000); | |
| 131 | const filled = (await r.pumps.act({ action: "fill" })) as { stopped: string; ms: number }; | |
| 132 | expect(filled.stopped).toBe("full"); | |
| 133 | expect(filled.ms).toBeLessThan(6000); | |
| 134 | expect(r.state.gpio5).toBe(false); | |
| 135 | r.fillAfter(Infinity); | |
| 136 | expect(await r.pumps.act({ action: "fill" })).toMatchObject({ stopped: "cap", ms: 30_000 }); | |
| 137 | expect(r.state.gpio5).toBe(false); | |
| 138 | expect((await refusal(r.pumps.act({ action: "float", capSec: 9999 }))).message).toContain("cap"); | |
| 139 | expect((await refusal(r.pumps.act({ action: "dose", pump: "topOff", ml: 5 }))).message).toContain("float"); | |
| 140 | }); | |
| 141 | ||
| 142 | test("bad input is refused with a sentence; a USB port is not a pump's switch; a new switch clears the calibration", async () => { | |
| 143 | const r = rig(); | |
| 144 | for (const bad of [null, [], { action: "nope" }, { action: "dose", pump: "rum", ml: 1 }, { action: "prime", pump: "phDown", ms: 999_999 }, { action: "switch", pump: "phDown", switch: { driver: "uhubctl", hub: "1-1", port: 2 } }, { action: "dose", pump: "phDown", ml: 1, extra: 1 }]) { | |
| 145 | const e = await refusal(r.pumps.act(bad)); | |
| 146 | expect(e.refused).toBe(true); | |
| 147 | } | |
| 148 | await r.pumps.act({ action: "switch", pump: "phDown", switch: { driver: "gpio", line: 17 } }); | |
| 149 | await r.pumps.act({ action: "prime", pump: "phDown", ms: 10_000 }); | |
| 150 | await r.pumps.act({ action: "calibrate", pump: "phDown", ml: 10 }); | |
| 151 | await r.pumps.act({ action: "switch", pump: "phDown", switch: { driver: "shelly", host: "192.168.1.41" } }); | |
| 152 | expect((await r.pumps.view()).pumps.find((p) => p.id === "phDown")).toMatchObject({ driver: "shelly", mlPerSec: null }); | |
| 153 | }); | |
| 154 | ||
| 155 | test("kept across a restart, and every wired pump is switched off on boot", async () => { | |
| 156 | const r = rig(); | |
| 157 | try { | |
| 158 | await r.pumps.act({ action: "switch", pump: "nutrientB", switch: { driver: "gpio", line: 23 } }); | |
| 159 | await r.pumps.act({ action: "prime", pump: "nutrientB", ms: 5000 }); | |
| 160 | await r.pumps.act({ action: "calibrate", pump: "nutrientB", ml: 4 }); | |
| 161 | r.state.gpio23 = true; // as if the Pi went down mid-dose | |
| 162 | const again = new DosePumps({ file: join(r.dir, "dose-pumps.json"), now: r.now, sleep: r.sleep, make: r.make }); | |
| 163 | await again.boot(); | |
| 164 | expect(r.state.gpio23).toBe(false); | |
| 165 | expect((await again.view()).pumps.find((p) => p.id === "nutrientB")).toMatchObject({ driver: "gpio", mlPerSec: 0.8 }); | |
| 166 | } finally { | |
| 167 | rmSync(r.dir, { recursive: true, force: true }); | |
| 168 | } | |
| 169 | }); | |
| 170 | }); | |
| 171 | ||
| 172 | describe("the relay's side", () => { | |
| 173 | test("a 409 from the Pi is PumpRefused (nothing ran); anything else is a failure", async () => { | |
| 174 | const answers: Parameters<PumpHooks["write"]>[0][] = []; | |
| 175 | let next: Awaited<ReturnType<PumpHooks["write"]>> = { ok: false, status: 409, error: "not calibrated" }; | |
| 176 | const p = armPumps({ read: async () => null, write: async (b) => (answers.push(b), next) }); | |
| 177 | expect(p.phDown.dose(3)).rejects.toBeInstanceOf(PumpRefused); | |
| 178 | next = { ok: false, status: 500, error: "relay stuck" }; | |
| 179 | const err = await p.phDown.dose(3).catch((e: Error) => e); | |
| 180 | expect(err).not.toBeInstanceOf(PumpRefused); | |
| 181 | next = { ok: true, body: {} }; | |
| 182 | await p.nutrientA.dose(2); | |
| 183 | expect(answers.at(-1)).toEqual({ action: "dose", pump: "nutrientA", ml: 2 }); | |
| 184 | }); | |
| 185 | ||
| 186 | test("the owner's forms never become a dose; the Pi's answer is read field by field", () => { | |
| 187 | expect(pumpRequest({ action: "dose", pump: "phDown", ml: "50" })).toBeNull(); | |
| 188 | expect(pumpRequest({ action: "switch", pump: "phDown", driver: "gpio", line: "17", activeLow: "on" })).toEqual({ action: "switch", pump: "phDown", switch: { driver: "gpio", chip: "gpiochip0", line: 17, activeLow: true } }); | |
| 189 | expect(pumpRequest({ action: "prime", pump: "phDown", sec: "10" })).toEqual({ action: "prime", pump: "phDown", ms: 10_000 }); | |
| 190 | expect(pumpRequest({ action: "float", line: "", capSec: "60" })).toEqual({ action: "float", float: null, capSec: 60 }); | |
| 191 | const v = readDosing({ pumps: [{ id: "phDown", driver: "<script>", mlPerSec: 1 }, { id: "rum" }], error: "a\u0000<b>" })!; | |
| 192 | expect(v.pumps).toEqual([{ id: "phDown", driver: null, mlPerSec: 1, calibratedAt: null, dosedTodayMl: 0 }]); | |
| 193 | expect(renderPumps({ ...v, error: "<b>x</b>" })).not.toContain("<b>x</b>"); | |
| 194 | expect(readDosing("nope")).toBeNull(); | |
| 195 | expect(renderPumps(null)).toContain("did not answer"); | |
| 196 | }); | |
| 197 | ||
| 198 | test("the terminal's pump commands", () => { | |
| 199 | expect(careCommand(["wire", "phDown", "gpio", "17", "low"], "")).toMatchObject({ method: "POST", path: "/care/dosing/pump", body: { action: "switch", pump: "phDown", switch: { driver: "gpio", line: 17, activeLow: true } } }); | |
| 200 | expect(careCommand(["prime", "nutrientA"], "")).toMatchObject({ body: { action: "prime", pump: "nutrientA", sec: 10 } }); | |
| 201 | expect(careCommand(["calibrate", "nutrientA", "11.5"], "")).toMatchObject({ body: { action: "calibrate", pump: "nutrientA", ml: 11.5 } }); | |
| 202 | expect(careCommand(["float", "27", "low", "90"], "")).toMatchObject({ body: { action: "float", line: 27, activeLow: true, capSec: 90 } }); | |
| 203 | expect(careCommand(["topoff"], "")).toMatchObject({ body: { action: "fill" }, timeoutMs: expect.any(Number) }); | |
| 204 | expect(careCommand(["pumps"], "")).toEqual({ method: "GET", path: "/care/dosing/pumps" }); | |
| 205 | for (const bad of [["wire", "rum", "gpio", "1"], ["calibrate", "phDown"], ["float", "x"]]) expect(careCommand(bad, "")).toHaveProperty("print"); | |
| 206 | expect(pumpSaid({ ok: true, mlPerSec: 1.2 })).toBe("calibrated: 1.2 ml/s"); | |
| 207 | expect(pumpLines({ pumps: [{ id: "phDown", driver: "gpio", mlPerSec: 1, dosedTodayMl: 3 }], float: null })[1]).toContain("pH down · gpio · 1 ml/s · today 3 ml"); | |
| 208 | }); | |
| 209 | }); | |
| 210 | ||
| 211 | describe("end to end: the page's relay, the arm server and a pump", () => { | |
| 212 | const KEY = "k".repeat(64); | |
| 213 | const owner = () => { | |
| 214 | const identity = `id-trevato|trevato|admin|${Date.now() + 600_000}`; | |
| 215 | return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY), "content-type": "application/json" }; | |
| 216 | }; | |
| 217 | let relay: RunningRelay | undefined; | |
| 218 | let server: RunningServer | undefined; | |
| 219 | let lobby: Lobby | undefined; | |
| 220 | let dir = ""; | |
| 221 | afterEach(async () => { | |
| 222 | await relay?.close(); | |
| 223 | server?.close(); | |
| 224 | lobby?.close(); | |
| 225 | if (dir) rmSync(dir, { recursive: true, force: true }); | |
| 226 | relay = server = lobby = undefined; | |
| 227 | }); | |
| 228 | ||
| 229 | test("the owner wires and calibrates a pump from /care; a dose goes through the controller to the Pi; a browser cannot reach the Pi's pumps", async () => { | |
| 230 | const r = rig(); | |
| 231 | dir = r.dir; | |
| 232 | server = await startServer(new FeetechBus(new FakeBus([1, 2])), { host: "127.0.0.1", port: 0, ids: [1, 2], posesFile: join(dir, "poses.json"), dosePumps: r.pumps }); | |
| 233 | lobby = new Lobby({ identityKey: KEY, adminFile: join(dir, "admin.json"), countsFile: join(dir, "visitors.json") }); | |
| 234 | const log = new CareLog(); | |
| 235 | const profile = loadProfiles(BUILT_IN_DIR).find((p) => p.id === "spinach")!; | |
| 236 | relay = await startRelay({ port: 0, simulation: `http://127.0.0.1:${server.port}`, lobby, care: { log, profile } }); | |
| 237 | const base = `http://127.0.0.1:${relay.port}`; | |
| 238 | const setup = (body: unknown) => fetch(`${base}/care/dosing/pump`, { method: "POST", headers: owner(), body: JSON.stringify(body) }); | |
| 239 | ||
| 240 | // nobody but the relay may ask the Pi: a browser's /grow/dosing, owner or not, is no route | |
| 241 | for (const headers of [owner(), {}]) { | |
| 242 | expect((await fetch(`${base}/grow/dosing`, { headers })).status).toBe(404); | |
| 243 | expect((await fetch(`${base}/grow/dosing`, { method: "POST", headers, body: JSON.stringify({ action: "dose", pump: "phDown", ml: 50 }) })).status).toBe(404); | |
| 244 | } | |
| 245 | // and a dose is not a setup action | |
| 246 | expect((await setup({ action: "dose", pump: "phDown", ml: 50 })).status).toBe(400); | |
| 247 | // a guest cannot set up | |
| 248 | expect((await fetch(`${base}/care/dosing/pump`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "fill" }) })).status).toBe(401); | |
| 249 | ||
| 250 | expect((await setup({ action: "switch", pump: "phDown", driver: "gpio", line: 17 })).status).toBe(200); | |
| 251 | // before calibration a dose is refused by the pump, not counted, and dosing does not halt | |
| 252 | log.add({ type: "measurement", kind: "ph", value: 7.2 }); | |
| 253 | const early = await fetch(`${base}/care/dosing/dose`, { method: "POST", headers: owner(), body: JSON.stringify({ loop: "phDown" }) }); | |
| 254 | expect(early.status).toBe(409); | |
| 255 | expect(((await early.json()) as { error: string }).error).toContain("not calibrated"); | |
| 256 | expect(r.calls).not.toContain("gpio17 on"); | |
| 257 | ||
| 258 | expect((await setup({ action: "prime", pump: "phDown", sec: 10 })).status).toBe(200); | |
| 259 | const cal = await setup({ action: "calibrate", pump: "phDown", ml: 5 }); | |
| 260 | expect(await cal.json()).toEqual({ ok: true, mlPerSec: 0.5 }); | |
| 261 | r.calls.length = 0; | |
| 262 | const t0 = r.now(); | |
| 263 | const did = await fetch(`${base}/care/dosing/dose`, { method: "POST", headers: owner(), body: JSON.stringify({ loop: "phDown" }) }); | |
| 264 | expect(did.status).toBe(200); | |
| 265 | const ml = ((await did.json()) as { ml: number }).ml; | |
| 266 | expect(r.calls).toEqual(["gpio17 on", "gpio17 off"]); | |
| 267 | expect(r.now() - t0).toBe(Math.round((ml / 0.5) * 1000)); | |
| 268 | expect(log.entries().at(-1)).toMatchObject({ type: "action", what: "phdown", amount: ml, cause: "owner" }); | |
| 269 | expect(relay && (await (await fetch(`${base}/care/dosing`, { headers: owner() })).json())).toMatchObject({ halted: null, simulated: false }); | |
| 270 | ||
| 271 | // the card shows the pump, calibrated | |
| 272 | const page = await (await fetch(`${base}/care`, { headers: owner() })).text(); | |
| 273 | expect(page).toContain('data-pump="phDown"'); | |
| 274 | expect(page).toContain("0.5 ml/s"); | |
| 275 | // and the crew reads the pumps through the MCP | |
| 276 | expect((await fetch(`${base}/care/mcp`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "pumps", arguments: {} } }) })).status).toBe(401); | |
| 277 | const mcp = await fetch(`${base}/care/mcp`, { method: "POST", headers: owner(), body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "pumps", arguments: {} } }) }); | |
| 278 | const said = JSON.parse(((await mcp.json()) as { result: { content: { text: string }[] } }).result.content[0]!.text) as { pumps: { id: string; mlPerSec: number | null }[] }; | |
| 279 | expect(said.pumps.find((p) => p.id === "phDown")!.mlPerSec).toBe(0.5); | |
| 280 | }); | |
| 281 | }); | |
| 282 | ||
| 283 | test("Doser over armPumps keeps its own limits in front of the Pi", async () => { | |
| 284 | const asked: unknown[] = []; | |
| 285 | const log = new CareLog(undefined, () => 1_800_000_000_000); | |
| 286 | const doser = new Doser({ pumps: armPumps({ read: async () => null, write: async (b) => (asked.push(b), { ok: true, body: {} }) }), latest: () => ({ kind: "ph", value: 7.2, at: 1_800_000_000_000, source: "sensor" }), calibrated: () => true, log, loops: { phDown: { mode: "auto" } }, now: () => 1_800_000_000_000 }); | |
| 287 | expect(await doser.dose("phDown", "crew", 50)).toMatchObject({ ok: false, reason: expect.stringContaining("per-dose") }); | |
| 288 | expect(asked).toEqual([]); | |
| 289 | }); | |
| 290 | ||
| 291 | test("a Pi that is down holds the page's first read of the pumps for 300 ms at most", async () => { | |
| 292 | const c = cachedRead(() => new Promise(() => {})); | |
| 293 | const began = performance.now(); | |
| 294 | expect(await c.read()).toBeNull(); | |
| 295 | expect(performance.now() - began).toBeLessThan(450); | |
| 296 | }); |
Software/so101/src/dose-pumps.ts added +362
| @@ -0,0 +1,362 @@ | ||
| 1 | /** | |
| 2 | * The dosing pumps on the Pi: three peristaltic pumps (nutrient A, nutrient B, pH down) and the top-off pump, each a | |
| 3 | * `Switch` (a GPIO line to a relay or MOSFET board, or a Shelly plug; src/light-switch.ts), with a measured ml/s. | |
| 4 | * dose ml → seconds with the pump's calibration, then on, wait, off; refused before anything runs when the pump | |
| 5 | * has no switch, no calibration, or the ask is past the Pi's own backstop (per run and per day) | |
| 6 | * prime run a pump for a few seconds into a measuring cup (and fill its tube) | |
| 7 | * calibrate the ml the cup caught on the last prime becomes the pump's ml/s | |
| 8 | * fill top-off: the fresh-water pump runs until the high float says full or the time cap passes (doser.ts `topOff`) | |
| 9 | * The limits that decide whether to dose live in the controller (src/doser.ts); the backstop here is for a controller | |
| 10 | * that is wrong. A pump is switched off on boot and in every `finally`, so a crash or a throw never leaves one running. | |
| 11 | * Settings are kept beside the arm's other data. | |
| 12 | */ | |
| 13 | import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | |
| 14 | import { dirname } from "node:path"; | |
| 15 | import { topOff } from "./doser.ts"; | |
| 16 | import { makeSwitch, parseDriver, SwitchError, type Deps, type DriverConfig, type Switch } from "./light-switch.ts"; | |
| 17 | ||
| 18 | export const DOSE_PUMPS = ["nutrientA", "nutrientB", "phDown"] as const; | |
| 19 | export const PUMP_IDS = [...DOSE_PUMPS, "topOff"] as const; | |
| 20 | export type PumpId = (typeof PUMP_IDS)[number]; | |
| 21 | type DosePumpId = (typeof DOSE_PUMPS)[number]; | |
| 22 | ||
| 23 | /** The Pi's backstop, whatever the controller asks. */ | |
| 24 | export const MAX_RUN_MS = 60_000; | |
| 25 | export const MAX_DAY_ML = 100; | |
| 26 | export const PRIME_MS = { min: 1_000, max: 30_000 } as const; | |
| 27 | /** A calibration answers the prime just before it. */ | |
| 28 | export const PRIME_FRESH_MS = 3_600_000; | |
| 29 | export const ML_PER_SEC = { min: 0.01, max: 50 } as const; | |
| 30 | export const TOP_OFF_CAP = { min: 5, max: 600, default: 120 } as const; | |
| 31 | ||
| 32 | export class PumpError extends Error { | |
| 33 | /** True when nothing was switched on: the caller may treat it as a refusal, not a failure. */ | |
| 34 | readonly refused: boolean; | |
| 35 | constructor(message: string, refused: boolean) { | |
| 36 | super(message); | |
| 37 | this.refused = refused; | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | export interface FloatConfig { | |
| 42 | chip: string; | |
| 43 | line: number; | |
| 44 | activeLow: boolean; | |
| 45 | } | |
| 46 | interface PumpState { | |
| 47 | driver: DriverConfig | null; | |
| 48 | mlPerSec: number | null; | |
| 49 | calibratedAt: number | null; | |
| 50 | } | |
| 51 | interface Stored { | |
| 52 | pumps: Record<PumpId, PumpState>; | |
| 53 | /** The high float in the reservoir: top-off stops on it. */ | |
| 54 | float: FloatConfig | null; | |
| 55 | capSec: number; | |
| 56 | /** Runs in the last day, for the backstop. */ | |
| 57 | doses: { pump: DosePumpId; at: number; ml: number }[]; | |
| 58 | /** The last prime, which a calibration answers. */ | |
| 59 | primed: { pump: PumpId; ms: number; at: number } | null; | |
| 60 | } | |
| 61 | ||
| 62 | export interface PumpView { | |
| 63 | id: PumpId; | |
| 64 | driver: string | null; | |
| 65 | mlPerSec: number | null; | |
| 66 | calibratedAt: number | null; | |
| 67 | dosedTodayMl: number; | |
| 68 | } | |
| 69 | export interface DosingView { | |
| 70 | pumps: PumpView[]; | |
| 71 | float: FloatConfig | null; | |
| 72 | /** Wet (full), dry, or null when there is no float or it did not answer. */ | |
| 73 | full: boolean | null; | |
| 74 | capSec: number; | |
| 75 | primed: { pump: PumpId; ms: number; at: number } | null; | |
| 76 | running: PumpId | null; | |
| 77 | maxRunMs: number; | |
| 78 | maxDayMl: number; | |
| 79 | error: string | null; | |
| 80 | } | |
| 81 | ||
| 82 | export interface DosePumpsOptions { | |
| 83 | file: string; | |
| 84 | now?: () => number; | |
| 85 | sleep?: (ms: number) => Promise<void>; | |
| 86 | make?: (config: DriverConfig) => Switch; | |
| 87 | deps?: Deps; | |
| 88 | } | |
| 89 | ||
| 90 | const empty = (): PumpState => ({ driver: null, mlPerSec: null, calibratedAt: null }); | |
| 91 | const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v); | |
| 92 | const pumpOf = (v: unknown): PumpId => { | |
| 93 | if (!PUMP_IDS.includes(v as PumpId)) throw new PumpError(`pump is one of ${PUMP_IDS.join(", ")}`, true); | |
| 94 | return v as PumpId; | |
| 95 | }; | |
| 96 | ||
| 97 | /** A pump's switch: a GPIO line or a Shelly plug. A USB port cannot carry a 12 V pump, so uhubctl is not one. */ | |
| 98 | export function parsePumpDriver(input: unknown): DriverConfig { | |
| 99 | const d = parseDriver(input); | |
| 100 | if (d.driver === "uhubctl") throw new SwitchError("a pump's switch is a GPIO relay or MOSFET, or a Shelly plug"); | |
| 101 | return d; | |
| 102 | } | |
| 103 | ||
| 104 | export function parseFloatLine(input: unknown): FloatConfig { | |
| 105 | if (!isObj(input)) throw new PumpError("the float is an object", true); | |
| 106 | const d = parseDriver({ driver: "gpio", ...input }); | |
| 107 | if (d.driver !== "gpio") throw new PumpError("the float is a GPIO line", true); | |
| 108 | return { chip: d.chip, line: d.line, activeLow: d.activeLow }; | |
| 109 | } | |
| 110 | ||
| 111 | export class DosePumps { | |
| 112 | private s: Stored = { pumps: Object.fromEntries(PUMP_IDS.map((p) => [p, empty()])) as Record<PumpId, PumpState>, float: null, capSec: TOP_OFF_CAP.default, doses: [], primed: null }; | |
| 113 | private running: PumpId | null = null; | |
| 114 | private error: string | null = null; | |
| 115 | private lastFull: boolean | null = null; | |
| 116 | private readonly now: () => number; | |
| 117 | private readonly sleep: (ms: number) => Promise<void>; | |
| 118 | private readonly make: (config: DriverConfig) => Switch; | |
| 119 | private readonly o: DosePumpsOptions; | |
| 120 | ||
| 121 | constructor(o: DosePumpsOptions) { | |
| 122 | this.o = o; | |
| 123 | this.now = o.now ?? Date.now; | |
| 124 | this.sleep = o.sleep ?? ((ms) => new Promise<void>((r) => setTimeout(r, ms))); | |
| 125 | this.make = o.make ?? ((c) => makeSwitch(c, o.deps)); | |
| 126 | this.load(); | |
| 127 | } | |
| 128 | ||
| 129 | private sw(id: PumpId): Switch | null { | |
| 130 | const d = this.s.pumps[id].driver; | |
| 131 | return d ? this.make(d) : null; | |
| 132 | } | |
| 133 | private floatSwitch(): Switch | null { | |
| 134 | return this.s.float ? this.make({ driver: "gpio", ...this.s.float }) : null; | |
| 135 | } | |
| 136 | ||
| 137 | /** On start-up: every wired pump off, whatever it was doing when the Pi went down. */ | |
| 138 | async boot(): Promise<void> { | |
| 139 | for (const id of PUMP_IDS) { | |
| 140 | try { | |
| 141 | await this.sw(id)?.off(); | |
| 142 | } catch (err) { | |
| 143 | this.error = `${id} would not switch off: ${(err as Error).message}`; | |
| 144 | console.error(`dosing: ${this.error}`); | |
| 145 | } | |
| 146 | } | |
| 147 | } | |
| 148 | ||
| 149 | dosedToday(id: PumpId): number { | |
| 150 | const since = this.now() - 86_400_000; | |
| 151 | return this.s.doses.filter((d) => d.pump === id && d.at > since).reduce((n, d) => n + d.ml, 0); | |
| 152 | } | |
| 153 | ||
| 154 | async view(): Promise<DosingView> { | |
| 155 | const f = this.floatSwitch(); | |
| 156 | if (f && !this.running) { | |
| 157 | try { | |
| 158 | this.lastFull = await f.state(); | |
| 159 | } catch { | |
| 160 | this.lastFull = null; | |
| 161 | } | |
| 162 | } else if (!f) this.lastFull = null; | |
| 163 | return { | |
| 164 | pumps: PUMP_IDS.map((id) => ({ id, driver: this.s.pumps[id].driver?.driver ?? null, mlPerSec: this.s.pumps[id].mlPerSec, calibratedAt: this.s.pumps[id].calibratedAt, dosedTodayMl: this.dosedToday(id) })), | |
| 165 | float: this.s.float, | |
| 166 | full: this.lastFull, | |
| 167 | capSec: this.s.capSec, | |
| 168 | primed: this.s.primed, | |
| 169 | running: this.running, | |
| 170 | maxRunMs: MAX_RUN_MS, | |
| 171 | maxDayMl: MAX_DAY_ML, | |
| 172 | error: this.error, | |
| 173 | }; | |
| 174 | } | |
| 175 | ||
| 176 | /** One request, one action: `switch`, `float`, `dose`, `prime`, `calibrate` or `fill`. Throws PumpError naming what is wrong. */ | |
| 177 | async act(body: unknown): Promise<unknown> { | |
| 178 | if (!isObj(body)) throw new PumpError("send an object", true); | |
| 179 | const { action, pump, ...rest } = body; | |
| 180 | const only = (...keys: string[]) => { | |
| 181 | const extra = Object.keys(rest).find((k) => !keys.includes(k)); | |
| 182 | if (extra) throw new PumpError(`no field "${extra.slice(0, 40)}"`, true); | |
| 183 | }; | |
| 184 | try { | |
| 185 | switch (action) { | |
| 186 | case "switch": { | |
| 187 | only("switch"); | |
| 188 | const id = pumpOf(pump); | |
| 189 | const driver = rest.switch === null ? null : parsePumpDriver(rest.switch); | |
| 190 | if (this.running === id) throw new PumpError(`the ${id} pump is running`, true); | |
| 191 | // the new switch must answer (off) before it is kept; the old one is let go off | |
| 192 | if (driver) await this.make(driver).off(); | |
| 193 | await this.sw(id)?.off().catch(() => {}); | |
| 194 | // a new switch is a new pump as far as its flow goes: calibrate again | |
| 195 | this.s.pumps[id] = { driver, mlPerSec: null, calibratedAt: null }; | |
| 196 | this.save(); | |
| 197 | return { ok: true }; | |
| 198 | } | |
| 199 | case "float": { | |
| 200 | only("float", "capSec"); | |
| 201 | if (pump !== undefined) throw new PumpError("the float is not a pump's", true); | |
| 202 | if (rest.float !== undefined) this.s.float = rest.float === null ? null : parseFloatLine(rest.float); | |
| 203 | if (rest.capSec !== undefined) { | |
| 204 | const c = rest.capSec; | |
| 205 | if (typeof c !== "number" || !Number.isInteger(c) || c < TOP_OFF_CAP.min || c > TOP_OFF_CAP.max) throw new PumpError(`the top-off cap is a whole number of seconds from ${TOP_OFF_CAP.min} to ${TOP_OFF_CAP.max}`, true); | |
| 206 | this.s.capSec = c; | |
| 207 | } | |
| 208 | this.save(); | |
| 209 | return { ok: true }; | |
| 210 | } | |
| 211 | case "dose": { | |
| 212 | only("ml"); | |
| 213 | const id = pumpOf(pump); | |
| 214 | if (id === "topOff") throw new PumpError("top-off fills to the float; it does not dose", true); | |
| 215 | return await this.dose(id, rest.ml); | |
| 216 | } | |
| 217 | case "prime": { | |
| 218 | only("ms"); | |
| 219 | const id = pumpOf(pump); | |
| 220 | const ms = rest.ms; | |
| 221 | if (typeof ms !== "number" || !Number.isInteger(ms) || ms < PRIME_MS.min || ms > PRIME_MS.max) throw new PumpError(`prime for a whole number of ms from ${PRIME_MS.min} to ${PRIME_MS.max}`, true); | |
| 222 | const sw = this.ready(id); | |
| 223 | await this.runFor(id, sw, ms); | |
| 224 | this.s.primed = { pump: id, ms, at: this.now() }; | |
| 225 | this.save(); | |
| 226 | return { ok: true, ms }; | |
| 227 | } | |
| 228 | case "calibrate": { | |
| 229 | only("ml"); | |
| 230 | const id = pumpOf(pump); | |
| 231 | const ml = rest.ml; | |
| 232 | const p = this.s.primed; | |
| 233 | if (!p || p.pump !== id || this.now() - p.at > PRIME_FRESH_MS) throw new PumpError(`prime the ${id} pump into a measuring cup first, then say how many ml it caught`, true); | |
| 234 | if (typeof ml !== "number" || !Number.isFinite(ml) || ml <= 0 || ml > 1000) throw new PumpError("ml is a number above 0, up to 1000", true); | |
| 235 | const rate = Math.round((ml / (p.ms / 1000)) * 1000) / 1000; | |
| 236 | if (rate < ML_PER_SEC.min || rate > ML_PER_SEC.max) throw new PumpError(`${rate} ml/s is not a dosing pump's flow (${ML_PER_SEC.min}–${ML_PER_SEC.max}); measure again`, true); | |
| 237 | this.s.pumps[id] = { ...this.s.pumps[id], mlPerSec: rate, calibratedAt: this.now() }; | |
| 238 | this.s.primed = null; | |
| 239 | this.save(); | |
| 240 | return { ok: true, mlPerSec: rate }; | |
| 241 | } | |
| 242 | case "fill": { | |
| 243 | only(); | |
| 244 | if (pump !== undefined && pump !== "topOff") throw new PumpError("fill runs the top-off pump", true); | |
| 245 | return await this.fill(); | |
| 246 | } | |
| 247 | default: | |
| 248 | throw new PumpError("action is switch, float, dose, prime, calibrate or fill", true); | |
| 249 | } | |
| 250 | } catch (err) { | |
| 251 | if (err instanceof SwitchError) throw new PumpError(err.message, true); | |
| 252 | throw err; | |
| 253 | } | |
| 254 | } | |
| 255 | ||
| 256 | private ready(id: PumpId): Switch { | |
| 257 | if (this.running) throw new PumpError(`the ${this.running} pump is running`, true); | |
| 258 | const sw = this.sw(id); | |
| 259 | if (!sw) throw new PumpError(`the ${id} pump has no switch; wire it first`, true); | |
| 260 | return sw; | |
| 261 | } | |
| 262 | ||
| 263 | private async dose(id: DosePumpId, ml: unknown): Promise<{ ok: true; ml: number; ms: number }> { | |
| 264 | if (typeof ml !== "number" || !Number.isFinite(ml) || ml <= 0) throw new PumpError("ml is a number above 0", true); | |
| 265 | const sw = this.ready(id); | |
| 266 | const rate = this.s.pumps[id].mlPerSec; | |
| 267 | if (!rate) throw new PumpError(`the ${id} pump is not calibrated; prime it into a measuring cup and enter the ml`, true); | |
| 268 | const ms = Math.round((ml / rate) * 1000); | |
| 269 | if (ms > MAX_RUN_MS) throw new PumpError(`${ml} ml takes ${Math.ceil(ms / 1000)} s, past the Pi's ${MAX_RUN_MS / 1000} s limit for one run`, true); | |
| 270 | if (this.dosedToday(id) + ml > MAX_DAY_ML) throw new PumpError(`past the Pi's daily limit of ${MAX_DAY_ML} ml for the ${id} pump`, true); | |
| 271 | const at = this.now(); | |
| 272 | // counted before it runs: a crash mid-dose still counts | |
| 273 | this.s.doses = this.s.doses.filter((d) => d.at > at - 86_400_000).concat({ pump: id, at, ml }); | |
| 274 | this.save(); | |
| 275 | await this.runFor(id, sw, ms); | |
| 276 | return { ok: true, ml, ms }; | |
| 277 | } | |
| 278 | ||
| 279 | /** On, wait, off; off in every case. A failure after the switch was asked to close is not a refusal. */ | |
| 280 | private async runFor(id: PumpId, sw: Switch, ms: number): Promise<void> { | |
| 281 | this.running = id; | |
| 282 | try { | |
| 283 | await sw.on(); | |
| 284 | await this.sleep(ms); | |
| 285 | } catch (err) { | |
| 286 | this.error = `${id}: ${(err as Error).message}`; | |
| 287 | throw new PumpError(`the ${id} pump failed: ${(err as Error).message}`, false); | |
| 288 | } finally { | |
| 289 | await this.off(id, sw); | |
| 290 | this.running = null; | |
| 291 | } | |
| 292 | } | |
| 293 | ||
| 294 | private async off(id: PumpId, sw: Switch): Promise<void> { | |
| 295 | for (let i = 0; i < 3; i++) { | |
| 296 | try { | |
| 297 | await sw.off(); | |
| 298 | return; | |
| 299 | } catch (err) { | |
| 300 | this.error = `${id} would not switch off: ${(err as Error).message}`; | |
| 301 | console.error(`dosing: ${this.error}`); | |
| 302 | } | |
| 303 | } | |
| 304 | throw new PumpError(`the ${id} pump would not switch off; cut its power`, false); | |
| 305 | } | |
| 306 | ||
| 307 | private async fill(): Promise<{ ok: true; stopped: "full" | "cap"; ms: number }> { | |
| 308 | const sw = this.ready("topOff"); | |
| 309 | const f = this.floatSwitch(); | |
| 310 | if (!f) throw new PumpError("top-off needs a high float to stop on; set its GPIO line first", true); | |
| 311 | this.running = "topOff"; | |
| 312 | const start = this.now(); | |
| 313 | try { | |
| 314 | const stopped = await topOff({ on: () => sw.on(), off: () => this.off("topOff", sw) }, () => f.state(), { capMs: this.s.capSec * 1000, now: this.now, sleep: this.sleep }); | |
| 315 | this.lastFull = stopped === "full" ? true : this.lastFull; | |
| 316 | return { ok: true, stopped, ms: this.now() - start }; | |
| 317 | } catch (err) { | |
| 318 | if (err instanceof PumpError) throw err; | |
| 319 | this.error = `topOff: ${(err as Error).message}`; | |
| 320 | throw new PumpError(`top-off failed: ${(err as Error).message}`, false); | |
| 321 | } finally { | |
| 322 | this.running = null; | |
| 323 | } | |
| 324 | } | |
| 325 | ||
| 326 | private load(): void { | |
| 327 | let raw: unknown; | |
| 328 | try { | |
| 329 | raw = JSON.parse(readFileSync(this.o.file, "utf8")); | |
| 330 | } catch { | |
| 331 | return; // none yet, or unreadable: nothing wired | |
| 332 | } | |
| 333 | if (!isObj(raw)) return; | |
| 334 | // field by field: one bad value must not throw away the rest | |
| 335 | const pumps = isObj(raw.pumps) ? raw.pumps : {}; | |
| 336 | for (const id of PUMP_IDS) { | |
| 337 | const p = pumps[id]; | |
| 338 | if (!isObj(p)) continue; | |
| 339 | let driver: DriverConfig | null = null; | |
| 340 | try { | |
| 341 | driver = p.driver ? parsePumpDriver(p.driver) : null; | |
| 342 | } catch { | |
| 343 | continue; | |
| 344 | } | |
| 345 | const rate = typeof p.mlPerSec === "number" && p.mlPerSec >= ML_PER_SEC.min && p.mlPerSec <= ML_PER_SEC.max ? p.mlPerSec : null; | |
| 346 | this.s.pumps[id] = { driver, mlPerSec: rate, calibratedAt: rate && typeof p.calibratedAt === "number" ? p.calibratedAt : null }; | |
| 347 | } | |
| 348 | try { | |
| 349 | this.s.float = raw.float ? parseFloatLine(raw.float) : null; | |
| 350 | } catch { | |
| 351 | this.s.float = null; | |
| 352 | } | |
| 353 | if (typeof raw.capSec === "number" && Number.isInteger(raw.capSec) && raw.capSec >= TOP_OFF_CAP.min && raw.capSec <= TOP_OFF_CAP.max) this.s.capSec = raw.capSec; | |
| 354 | if (Array.isArray(raw.doses)) this.s.doses = raw.doses.filter((d): d is Stored["doses"][number] => isObj(d) && DOSE_PUMPS.includes(d.pump as DosePumpId) && typeof d.at === "number" && typeof d.ml === "number" && d.ml > 0); | |
| 355 | } | |
| 356 | ||
| 357 | private save(): void { | |
| 358 | mkdirSync(dirname(this.o.file), { recursive: true }); | |
| 359 | writeFileSync(`${this.o.file}.tmp`, JSON.stringify(this.s, null, 1)); | |
| 360 | renameSync(`${this.o.file}.tmp`, this.o.file); | |
| 361 | } | |
| 362 | } |
Software/so101/src/doser.test.ts added +173
| @@ -0,0 +1,173 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { CareLog, type Reading } from "./care-log.ts"; | |
| 3 | import { Doser, LOOPS, MIN_WAIT_MS, PumpRefused, topOff, type LoopId } from "./doser.ts"; | |
| 4 | ||
| 5 | const T0 = 1_800_000_000_000; | |
| 6 | function rig(over: Partial<Record<LoopId, object>> = {}, modes: "auto" | "suggest" = "auto") { | |
| 7 | let t = T0; | |
| 8 | const readings: Record<string, Reading | undefined> = { ph: { kind: "ph", value: 7.2, at: T0, source: "sensor" }, ec: { kind: "ec", value: 1.5, at: T0, source: "sensor" } }; | |
| 9 | const ran: Record<string, number[]> = {}; | |
| 10 | const pumps = Object.fromEntries(LOOPS.map((l) => [l, { dose: async (ml: number) => void (ran[l] ??= []).push(ml) }])) as never; | |
| 11 | const log = new CareLog(undefined, () => t); | |
| 12 | const loops = Object.fromEntries(LOOPS.map((l) => [l, { mode: modes, ...over[l] }])); | |
| 13 | let cal = true; | |
| 14 | const doser = new Doser({ pumps, latest: (k) => readings[k], calibrated: () => cal, log, loops, now: () => t }); | |
| 15 | return { | |
| 16 | doser, ran, log, readings, | |
| 17 | at: (ms: number) => (t = T0 + ms), | |
| 18 | read: (kind: "ph" | "ec", value: number) => (readings[kind] = { kind, value, at: t, source: "sensor" }), | |
| 19 | uncalibrate: () => (cal = false), | |
| 20 | }; | |
| 21 | } | |
| 22 | ||
| 23 | describe("dose controller", () => { | |
| 24 | test("doses pH down in auto, logs it with its cause", async () => { | |
| 25 | const r = rig(); | |
| 26 | const o = await r.doser.dose("phDown", "rule"); | |
| 27 | expect(o).toEqual({ ok: true, ml: 4 }); | |
| 28 | expect(r.ran.phDown).toEqual([4]); | |
| 29 | const e = r.log.entries()[0]!; | |
| 30 | expect(e).toMatchObject({ type: "action", what: "phdown", amount: 4, cause: "rule" }); | |
| 31 | }); | |
| 32 | ||
| 33 | test("refuses beyond the per-dose and the daily limit", async () => { | |
| 34 | const r = rig({ phDown: { maxDoseMl: 5, maxDayMl: 6 } }); | |
| 35 | expect(await r.doser.dose("phDown", "crew", 9)).toEqual({ ok: false, reason: "over the per-dose limit of 5 ml" }); | |
| 36 | expect(await r.doser.dose("phDown", "crew", 5)).toEqual({ ok: true, ml: 5 }); | |
| 37 | r.at(MIN_WAIT_MS + 1); | |
| 38 | r.read("ph", 7.0); | |
| 39 | const o = await r.doser.dose("phDown", "crew", 2); | |
| 40 | expect(o).toEqual({ ok: false, reason: "over the daily limit of 6 ml" }); | |
| 41 | expect(r.ran.phDown).toEqual([5]); | |
| 42 | }); | |
| 43 | ||
| 44 | test("no dose on a stale or uncalibrated reading", async () => { | |
| 45 | const r = rig(); | |
| 46 | r.at(31 * 60_000); | |
| 47 | expect(await r.doser.dose("phDown", "rule")).toMatchObject({ ok: false, reason: expect.stringContaining("30 minutes") }); | |
| 48 | r.read("ph", 7.2); | |
| 49 | r.uncalibrate(); | |
| 50 | expect(await r.doser.dose("phDown", "rule")).toMatchObject({ ok: false, reason: expect.stringContaining("not calibrated") }); | |
| 51 | expect(r.ran.phDown).toBeUndefined(); | |
| 52 | }); | |
| 53 | ||
| 54 | test("never twice on one reading, and not inside the mixing wait", async () => { | |
| 55 | const r = rig(); | |
| 56 | await r.doser.dose("phDown", "rule"); | |
| 57 | r.at(60_000); | |
| 58 | expect(await r.doser.dose("phDown", "rule")).toMatchObject({ ok: false, reason: expect.stringContaining("mix") }); | |
| 59 | r.at(MIN_WAIT_MS + 1); // waited, but no new reading | |
| 60 | expect(await r.doser.dose("phDown", "rule")).toMatchObject({ ok: false }); | |
| 61 | expect(r.ran.phDown).toHaveLength(1); | |
| 62 | }); | |
| 63 | ||
| 64 | test("pH down waits for a nutrient dose to mix", async () => { | |
| 65 | const r = rig(); | |
| 66 | await r.doser.dose("nutrientA", "rule", 2); | |
| 67 | r.at(120_000); | |
| 68 | expect(await r.doser.dose("phDown", "rule")).toMatchObject({ ok: false, reason: expect.stringContaining("mix") }); | |
| 69 | }); | |
| 70 | ||
| 71 | test("a dose that did not move the reading halts automation until the owner resumes", async () => { | |
| 72 | const r = rig(); | |
| 73 | await r.doser.dose("phDown", "rule"); | |
| 74 | r.at(MIN_WAIT_MS + 1); | |
| 75 | r.read("ph", 7.2); // unchanged | |
| 76 | const o = await r.doser.dose("phDown", "rule"); | |
| 77 | expect(o).toMatchObject({ ok: false, reason: expect.stringContaining("did not move ph") }); | |
| 78 | expect(r.doser.halted).toContain("did not move"); | |
| 79 | r.at(2 * MIN_WAIT_MS); | |
| 80 | r.read("ph", 7.1); | |
| 81 | expect(await r.doser.dose("phDown", "owner")).toMatchObject({ ok: false, reason: expect.stringContaining("stopped") }); | |
| 82 | r.doser.resume("owner"); | |
| 83 | expect(await r.doser.dose("phDown", "owner")).toMatchObject({ ok: true }); | |
| 84 | }); | |
| 85 | ||
| 86 | test("a dose that worked lets the next one through", async () => { | |
| 87 | const r = rig(); | |
| 88 | await r.doser.dose("phDown", "rule"); | |
| 89 | r.at(MIN_WAIT_MS + 1); | |
| 90 | r.read("ph", 6.9); | |
| 91 | expect(await r.doser.dose("phDown", "rule")).toMatchObject({ ok: true }); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a reading outside physical bounds stops automation", async () => { | |
| 95 | const r = rig(); | |
| 96 | r.read("ph", 15); | |
| 97 | expect(await r.doser.dose("phDown", "rule")).toMatchObject({ ok: false }); | |
| 98 | expect(r.doser.halted).toContain("outside"); | |
| 99 | expect(r.ran.phDown).toBeUndefined(); | |
| 100 | }); | |
| 101 | ||
| 102 | test("suggest mode never actuates for a rule or the crew; the owner's tap does", async () => { | |
| 103 | const r = rig({}, "suggest"); | |
| 104 | expect(r.doser.suggest("phDown")).toMatchObject({ ml: 4, text: "add 4 ml pH down (ph 7.2)" }); | |
| 105 | expect(await r.doser.dose("phDown", "rule")).toMatchObject({ ok: false }); | |
| 106 | expect(await r.doser.dose("phDown", "crew")).toMatchObject({ ok: false }); | |
| 107 | expect(await r.doser.tick()).toEqual({}); | |
| 108 | expect(r.ran.phDown).toBeUndefined(); | |
| 109 | expect(await r.doser.dose("phDown", "owner")).toEqual({ ok: true, ml: 4 }); | |
| 110 | }); | |
| 111 | ||
| 112 | test("off refuses everyone; a reading within the band suggests nothing", async () => { | |
| 113 | const r = rig({ phDown: { mode: "off" } }); | |
| 114 | expect(await r.doser.dose("phDown", "owner")).toMatchObject({ ok: false }); | |
| 115 | const q = rig(); | |
| 116 | q.read("ph", 6.0); | |
| 117 | expect(q.doser.suggest("phDown")).toMatchObject({ reason: expect.stringContaining("within") }); | |
| 118 | }); | |
| 119 | ||
| 120 | test("tick doses the loops in auto", async () => { | |
| 121 | const r = rig({ nutrientB: { mode: "off" }, phDown: { mode: "off" } }); | |
| 122 | r.read("ec", 1.0); | |
| 123 | const out = await r.doser.tick(); | |
| 124 | expect(Object.keys(out)).toEqual(["nutrientA"]); | |
| 125 | expect(out.nutrientA).toMatchObject({ ok: true }); | |
| 126 | }); | |
| 127 | ||
| 128 | test("a pump that refuses (nothing ran) is not counted and does not halt", async () => { | |
| 129 | const log = new CareLog(undefined, () => T0); | |
| 130 | let refuse = true; | |
| 131 | const ran: number[] = []; | |
| 132 | const pump = { dose: async (ml: number) => { if (refuse) throw new PumpRefused("the phDown pump is not calibrated"); ran.push(ml); } }; | |
| 133 | const ok = { dose: async () => {} }; | |
| 134 | const doser = new Doser({ pumps: { nutrientA: ok, nutrientB: ok, phDown: pump }, latest: (k) => (k === "ph" ? { kind: "ph", value: 7.2, at: T0, source: "sensor" } : undefined), calibrated: () => true, log, loops: { phDown: { mode: "auto" } }, now: () => T0 }); | |
| 135 | expect(await doser.dose("phDown", "rule")).toEqual({ ok: false, reason: "the phDown pump is not calibrated" }); | |
| 136 | expect(doser.halted).toBeNull(); | |
| 137 | expect(doser.dosedToday("phDown")).toBe(0); | |
| 138 | expect(log.entries()).toHaveLength(0); | |
| 139 | refuse = false; | |
| 140 | // no mixing wait and the same reading still usable: nothing was dosed | |
| 141 | expect(await doser.dose("phDown", "rule")).toEqual({ ok: true, ml: 4 }); | |
| 142 | expect(ran).toEqual([4]); | |
| 143 | }); | |
| 144 | ||
| 145 | test("a config with the per-dose limit above the daily one is refused", () => { | |
| 146 | expect(() => rig({ phDown: { maxDoseMl: 50, maxDayMl: 10 } })).toThrow("daily limit"); | |
| 147 | }); | |
| 148 | }); | |
| 149 | ||
| 150 | describe("top-off", () => { | |
| 151 | const fake = () => { | |
| 152 | let t = 0; | |
| 153 | const calls: string[] = []; | |
| 154 | return { calls, now: () => t, sleep: async (ms: number) => void (t += ms), pump: { on: async () => void calls.push("on"), off: async () => void calls.push("off") } }; | |
| 155 | }; | |
| 156 | test("stops at the high float", async () => { | |
| 157 | const f = fake(); | |
| 158 | let polls = 0; | |
| 159 | expect(await topOff(f.pump, async () => ++polls > 3, { capMs: 60_000, now: f.now, sleep: f.sleep })).toBe("full"); | |
| 160 | expect(f.calls).toEqual(["on", "off"]); | |
| 161 | expect(f.now()).toBeLessThan(5000); | |
| 162 | }); | |
| 163 | test("stops at the time cap when the float never trips", async () => { | |
| 164 | const f = fake(); | |
| 165 | expect(await topOff(f.pump, async () => false, { capMs: 10_000, now: f.now, sleep: f.sleep })).toBe("cap"); | |
| 166 | expect(f.calls).toEqual(["on", "off"]); | |
| 167 | }); | |
| 168 | test("does not start when already full", async () => { | |
| 169 | const f = fake(); | |
| 170 | expect(await topOff(f.pump, async () => true, { capMs: 10_000, now: f.now, sleep: f.sleep })).toBe("full"); | |
| 171 | expect(f.calls).toEqual([]); | |
| 172 | }); | |
| 173 | }); |
Software/so101/src/doser.ts added +337
| @@ -0,0 +1,337 @@ | ||
| 1 | /** | |
| 2 | * The dose controller: sensors measure, pumps act, and this module decides inside limits that live here, on the Pi, | |
| 3 | * never in the agent that asks. One loop per pump (nutrient A, nutrient B, pH down), each `off`, `suggest` or `auto`. | |
| 4 | * - a dose is refused over its per-dose or per-day ml, on a stale (>30 min) or uncalibrated reading, on a reading it | |
| 5 | * already dosed on, and inside the mixing wait (≥10 min) after any dose; | |
| 6 | * - after the wait the next reading must have moved the way the dose should move it; if not, or if a reading is | |
| 7 | * outside physical bounds, automation halts until the owner resumes it; | |
| 8 | * - `suggest` never actuates for a rule or a crew agent: only the owner's tap does; | |
| 9 | * - every dose is written to the log book with its cause. | |
| 10 | * Pumps, readings and the clock are injected, so the tests use fakes. Top-off is `topOff` below; the Pi runs it. | |
| 11 | */ | |
| 12 | import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | |
| 13 | import { dirname } from "node:path"; | |
| 14 | import type { Cause, CareLog, Reading } from "./care-log.ts"; | |
| 15 | import type { Measure } from "./care-profile.ts"; | |
| 16 | ||
| 17 | export const LOOPS = ["nutrientA", "nutrientB", "phDown"] as const; | |
| 18 | export type LoopId = (typeof LOOPS)[number]; | |
| 19 | export const MODES = ["off", "suggest", "auto"] as const; | |
| 20 | export type Mode = (typeof MODES)[number]; | |
| 21 | ||
| 22 | export const STALE_MS = 30 * 60_000; | |
| 23 | export const MIN_WAIT_MS = 10 * 60_000; | |
| 24 | ||
| 25 | /** | |
| 26 | * A dosing pump: pumps ml, turning them into run time with its own measured ml/s (src/dose-pumps.ts on the Pi). | |
| 27 | * It throws PumpRefused when it ran nothing (no switch, no calibration, its own backstop); anything else it throws | |
| 28 | * means it may have run. | |
| 29 | */ | |
| 30 | export interface Pump { | |
| 31 | /** A stand-in with nothing wired: the log book says so instead of claiming water was dosed. */ | |
| 32 | simulated?: boolean; | |
| 33 | dose(ml: number): Promise<void>; | |
| 34 | } | |
| 35 | ||
| 36 | /** The pump ran nothing: the dose is not counted and automation carries on. */ | |
| 37 | export class PumpRefused extends Error {} | |
| 38 | ||
| 39 | export interface LoopConfig { | |
| 40 | mode: Mode; | |
| 41 | maxDoseMl: number; | |
| 42 | maxDayMl: number; | |
| 43 | /** The reading it answers, the band's edge that triggers it, and how many ml move the reading by one unit (conservative). */ | |
| 44 | kind: Measure; | |
| 45 | trigger: "above" | "below"; | |
| 46 | target: number; | |
| 47 | mlPerUnit: number; | |
| 48 | /** The smallest move after a dose that counts as the pump having worked. */ | |
| 49 | minMove: number; | |
| 50 | } | |
| 51 | ||
| 52 | export const DEFAULTS: Record<LoopId, LoopConfig> = { | |
| 53 | nutrientA: { mode: "suggest", maxDoseMl: 10, maxDayMl: 40, kind: "ec", trigger: "below", target: 1.2, mlPerUnit: 20, minMove: 0.02 }, | |
| 54 | nutrientB: { mode: "suggest", maxDoseMl: 10, maxDayMl: 40, kind: "ec", trigger: "below", target: 1.2, mlPerUnit: 20, minMove: 0.02 }, | |
| 55 | phDown: { mode: "suggest", maxDoseMl: 5, maxDayMl: 15, kind: "ph", trigger: "above", target: 6.2, mlPerUnit: 4, minMove: 0.05 }, | |
| 56 | }; | |
| 57 | ||
| 58 | /** Physical bounds: a reading outside them is a broken probe, not water. */ | |
| 59 | export const BOUNDS: Partial<Record<Measure, [number, number]>> = { ph: [2, 12], ec: [0, 6], waterTemp: [2, 40] }; | |
| 60 | ||
| 61 | const LABEL: Record<LoopId, string> = { nutrientA: "nutrient A", nutrientB: "nutrient B", phDown: "pH down" }; | |
| 62 | const ACTION: Record<LoopId, "nutrients" | "phdown"> = { nutrientA: "nutrients", nutrientB: "nutrients", phDown: "phdown" }; | |
| 63 | ||
| 64 | export type Outcome = { ok: true; ml: number } | { ok: false; reason: string }; | |
| 65 | export type Suggestion = { ml: number; text: string } | { reason: string }; | |
| 66 | ||
| 67 | interface Saved { | |
| 68 | halted: string | null; | |
| 69 | lastDoseAt: number; | |
| 70 | /** Per loop: the reading time last dosed on. */ | |
| 71 | usedReading: Partial<Record<LoopId, number>>; | |
| 72 | /** What to check once the wait is over. */ | |
| 73 | pending: { loop: LoopId; kind: Measure; before: number; at: number; expect: "down" | "up"; minMove: number } | null; | |
| 74 | /** Doses in the last day, for the daily limit. */ | |
| 75 | doses: { loop: LoopId; at: number; ml: number }[]; | |
| 76 | /** The owner's choice per loop; a loop not here is at its configured mode. */ | |
| 77 | modes: Partial<Record<LoopId, Mode>>; | |
| 78 | } | |
| 79 | ||
| 80 | const num = (v: unknown) => typeof v === "number" && Number.isFinite(v); | |
| 81 | const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v); | |
| 82 | ||
| 83 | /** Whether a parsed file has the shape Saved promises; anything else is not trusted. */ | |
| 84 | function savedOk(v: unknown): v is Saved { | |
| 85 | if (!isObj(v)) return false; | |
| 86 | const p = v.pending; | |
| 87 | return ( | |
| 88 | (v.halted === null || typeof v.halted === "string") && | |
| 89 | num(v.lastDoseAt) && | |
| 90 | isObj(v.usedReading) && Object.values(v.usedReading).every(num) && | |
| 91 | (p === null || (isObj(p) && LOOPS.includes(p.loop as LoopId) && num(p.before) && num(p.at) && num(p.minMove) && (p.expect === "down" || p.expect === "up") && typeof p.kind === "string")) && | |
| 92 | Array.isArray(v.doses) && v.doses.every((x) => isObj(x) && LOOPS.includes(x.loop as LoopId) && num(x.at) && num(x.ml)) && | |
| 93 | isObj(v.modes) && Object.entries(v.modes).every(([k, m]) => LOOPS.includes(k as LoopId) && MODES.includes(m as Mode)) | |
| 94 | ); | |
| 95 | } | |
| 96 | ||
| 97 | export interface DoserDeps { | |
| 98 | pumps: Record<LoopId, Pump>; | |
| 99 | /** The newest reading of a kind, or undefined. */ | |
| 100 | latest: (kind: Measure) => Reading | undefined; | |
| 101 | /** Whether the probe behind a kind is calibrated (manual entries count as calibrated). */ | |
| 102 | calibrated: (kind: Measure) => boolean; | |
| 103 | log: CareLog; | |
| 104 | loops?: Partial<Record<LoopId, Partial<LoopConfig>>>; | |
| 105 | file?: string; | |
| 106 | now?: () => number; | |
| 107 | } | |
| 108 | ||
| 109 | export class Doser { | |
| 110 | readonly loops: Record<LoopId, LoopConfig>; | |
| 111 | private s: Saved = { halted: null, lastDoseAt: 0, usedReading: {}, pending: null, doses: [], modes: {} }; | |
| 112 | private busy = false; | |
| 113 | ||
| 114 | private readonly d: DoserDeps; | |
| 115 | ||
| 116 | constructor(deps: DoserDeps) { | |
| 117 | this.d = deps; | |
| 118 | this.loops = Object.fromEntries(LOOPS.map((l) => [l, { ...DEFAULTS[l], ...deps.loops?.[l] }])) as Record<LoopId, LoopConfig>; | |
| 119 | for (const l of LOOPS) validate(l, this.loops[l]); | |
| 120 | if (deps.file && existsSync(deps.file)) { | |
| 121 | try { | |
| 122 | const saved: unknown = JSON.parse(readFileSync(deps.file, "utf8")); | |
| 123 | if (!savedOk(saved)) throw new Error("unexpected shape"); | |
| 124 | this.s = saved; | |
| 125 | for (const l of LOOPS) if (saved.modes[l]) this.loops[l].mode = saved.modes[l]!; | |
| 126 | } catch { | |
| 127 | // an unreadable file means we don't know what was dosed: stay stopped | |
| 128 | this.s.halted = "the dosing record was unreadable; check the reservoir, then resume"; | |
| 129 | } | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | private now() { | |
| 134 | return this.d.now?.() ?? Date.now(); | |
| 135 | } | |
| 136 | private save() { | |
| 137 | if (!this.d.file) return; | |
| 138 | mkdirSync(dirname(this.d.file), { recursive: true }); | |
| 139 | writeFileSync(`${this.d.file}.tmp`, JSON.stringify(this.s)); | |
| 140 | renameSync(`${this.d.file}.tmp`, this.d.file); | |
| 141 | } | |
| 142 | ||
| 143 | get halted(): string | null { | |
| 144 | return this.s.halted; | |
| 145 | } | |
| 146 | /** The owner's say-so that the reservoir is as it should be. Only the owner: a rule or an agent cannot lift its own stop. */ | |
| 147 | resume(cause: Cause) { | |
| 148 | if (cause !== "owner") throw new Error("only the owner may resume dosing"); | |
| 149 | this.s.halted = null; | |
| 150 | this.s.pending = null; | |
| 151 | this.save(); | |
| 152 | } | |
| 153 | /** Only the owner changes a mode, and it is kept across a restart. */ | |
| 154 | setMode(loop: LoopId, mode: Mode, cause: Cause) { | |
| 155 | if (cause !== "owner") throw new Error("only the owner may change a mode"); | |
| 156 | if (!LOOPS.includes(loop)) throw new Error(`loop is one of ${LOOPS.join(", ")}`); | |
| 157 | if (!MODES.includes(mode)) throw new Error(`mode is one of ${MODES.join(", ")}`); | |
| 158 | this.loops[loop].mode = mode; | |
| 159 | this.s.modes[loop] = mode; | |
| 160 | this.save(); | |
| 161 | } | |
| 162 | /** What a page or the terminal shows: per loop its mode, what it would add and what it has used today. */ | |
| 163 | status() { | |
| 164 | return { | |
| 165 | halted: this.s.halted, | |
| 166 | simulated: LOOPS.some((l) => this.d.pumps[l].simulated), | |
| 167 | loops: LOOPS.map((l) => { | |
| 168 | const c = this.loops[l]; | |
| 169 | const sg = this.suggest(l); | |
| 170 | return { id: l, label: LABEL[l], mode: c.mode, suggestion: "ml" in sg ? sg : null, reason: "reason" in sg ? sg.reason : null, dosedTodayMl: this.dosedToday(l), maxDoseMl: c.maxDoseMl, maxDayMl: c.maxDayMl }; | |
| 171 | }), | |
| 172 | }; | |
| 173 | } | |
| 174 | /** ml dosed by a loop in the last 24 h. */ | |
| 175 | dosedToday(loop: LoopId): number { | |
| 176 | const since = this.now() - 86_400_000; | |
| 177 | return this.s.doses.filter((x) => x.loop === loop && x.at > since).reduce((n, x) => n + x.ml, 0); | |
| 178 | } | |
| 179 | ||
| 180 | private halt(reason: string): never[] { | |
| 181 | this.s.halted = reason; | |
| 182 | this.s.pending = null; | |
| 183 | this.save(); | |
| 184 | return []; | |
| 185 | } | |
| 186 | ||
| 187 | /** | |
| 188 | * The reading a dose would act on, or why there is none. A reading outside physical bounds, or a dose that did nothing, | |
| 189 | * stops automation; that and clearing a passed check happen only with `act` (a dose), never on a read. | |
| 190 | */ | |
| 191 | private usable(loop: LoopId, act: boolean): Reading | string { | |
| 192 | const c = this.loops[loop]; | |
| 193 | const r = this.d.latest(c.kind); | |
| 194 | if (!r) return `no ${c.kind} reading`; | |
| 195 | const bounds = BOUNDS[c.kind]; | |
| 196 | if (bounds && (r.value < bounds[0] || r.value > bounds[1])) { | |
| 197 | const why = `${c.kind} ${r.value} is outside what water can read; check the probe`; | |
| 198 | if (act) this.halt(why); | |
| 199 | return `stopped: ${why}`; | |
| 200 | } | |
| 201 | const now = this.now(); | |
| 202 | if (now - r.at > STALE_MS) return `the ${c.kind} reading is more than 30 minutes old`; | |
| 203 | if (!this.d.calibrated(c.kind)) return `the ${c.kind} probe is not calibrated`; | |
| 204 | const p = this.s.pending; | |
| 205 | if (p) { | |
| 206 | if (now - p.at < MIN_WAIT_MS || r.at < p.at + MIN_WAIT_MS) return "waiting for the dose to mix, then a fresh reading"; | |
| 207 | const moved = p.expect === "down" ? p.before - r.value : r.value - p.before; | |
| 208 | if (moved < p.minMove) { | |
| 209 | const why = `the last ${p.loop} dose did not move ${p.kind} as expected (${p.before} → ${r.value}); check the pump and the probe`; | |
| 210 | if (act) this.halt(why); | |
| 211 | return `stopped: ${why}`; | |
| 212 | } | |
| 213 | if (act) { | |
| 214 | this.s.pending = null; | |
| 215 | this.save(); | |
| 216 | } | |
| 217 | } | |
| 218 | if (r.at <= (this.s.usedReading[loop] ?? 0)) return "already dosed on this reading; waiting for a new one"; | |
| 219 | return r; | |
| 220 | } | |
| 221 | ||
| 222 | /** What the loop would add now, or why not. Never actuates. */ | |
| 223 | suggest(loop: LoopId): Suggestion { | |
| 224 | const c = this.loops[loop]; | |
| 225 | if (this.s.halted) return { reason: `stopped: ${this.s.halted}` }; | |
| 226 | if (c.mode === "off") return { reason: "this loop is off" }; | |
| 227 | const r = this.usable(loop, false); | |
| 228 | if (typeof r === "string") return { reason: r }; | |
| 229 | const gap = c.trigger === "above" ? r.value - c.target : c.target - r.value; | |
| 230 | if (gap <= 0) return { reason: `${c.kind} ${r.value} is within its band` }; | |
| 231 | const ml = Math.min(round(gap * c.mlPerUnit), c.maxDoseMl, Math.max(0, c.maxDayMl - this.dosedToday(loop))); | |
| 232 | if (ml < 0.5) return { reason: "the daily limit is reached" }; | |
| 233 | return { ml, text: `add ${ml} ml ${LABEL[loop]} (${c.kind} ${r.value})` }; | |
| 234 | } | |
| 235 | ||
| 236 | /** | |
| 237 | * Run a dose. `rule` and `crew` need the loop in auto; the owner may also act in suggest. Every limit applies to all three. | |
| 238 | * With no `ml`, the suggested amount. | |
| 239 | */ | |
| 240 | async dose(loop: LoopId, cause: Cause, ml?: number): Promise<Outcome> { | |
| 241 | const c = this.loops[loop]; | |
| 242 | if (this.busy) return refuse("a dose is running"); | |
| 243 | if (this.s.halted) return refuse(`stopped: ${this.s.halted}`); | |
| 244 | if (c.mode === "off") return refuse("this loop is off"); | |
| 245 | if (c.mode === "suggest" && cause !== "owner") return refuse("this loop only suggests; the owner taps Do it"); | |
| 246 | const wait = this.now() - this.s.lastDoseAt; | |
| 247 | if (wait < MIN_WAIT_MS) return refuse(`wait ${Math.ceil((MIN_WAIT_MS - wait) / 60_000)} more minutes for the last dose to mix`); | |
| 248 | const r = this.usable(loop, true); | |
| 249 | if (typeof r === "string") return refuse(r); | |
| 250 | if (ml === undefined) { | |
| 251 | const s = this.suggest(loop); | |
| 252 | if ("reason" in s) return refuse(s.reason); | |
| 253 | ml = s.ml; | |
| 254 | } | |
| 255 | if (!(typeof ml === "number" && Number.isFinite(ml) && ml > 0)) return refuse("ml is a number above 0"); | |
| 256 | if (ml > c.maxDoseMl) return refuse(`over the per-dose limit of ${c.maxDoseMl} ml`); | |
| 257 | if (this.dosedToday(loop) + ml > c.maxDayMl) return refuse(`over the daily limit of ${c.maxDayMl} ml`); | |
| 258 | ||
| 259 | const at = this.now(); | |
| 260 | const before = { lastDoseAt: this.s.lastDoseAt, used: this.s.usedReading[loop], doses: this.s.doses, pending: this.s.pending }; | |
| 261 | this.busy = true; | |
| 262 | try { | |
| 263 | this.s.lastDoseAt = at; | |
| 264 | this.s.usedReading[loop] = r.at; | |
| 265 | this.s.doses = this.s.doses.filter((x) => x.at > at - 86_400_000).concat({ loop, at, ml }); | |
| 266 | this.s.pending = { loop, kind: c.kind, before: r.value, at, expect: c.trigger === "above" ? "down" : "up", minMove: c.minMove }; | |
| 267 | this.save(); // before the pump runs: a crash mid-dose still counts against the limits | |
| 268 | try { | |
| 269 | await this.d.pumps[loop].dose(ml); | |
| 270 | } catch (err) { | |
| 271 | if (err instanceof PumpRefused) { | |
| 272 | // nothing ran: put the record back as it was | |
| 273 | this.s.lastDoseAt = before.lastDoseAt; | |
| 274 | if (before.used === undefined) delete this.s.usedReading[loop]; | |
| 275 | else this.s.usedReading[loop] = before.used; | |
| 276 | this.s.doses = before.doses; | |
| 277 | this.s.pending = before.pending; | |
| 278 | this.save(); | |
| 279 | return refuse(err.message); | |
| 280 | } | |
| 281 | // the ml stay counted (we cannot know how much ran); say so and stop | |
| 282 | const why = `the ${loop} pump failed (${(err as Error).message}); check the reservoir, then resume`; | |
| 283 | this.halt(why); | |
| 284 | this.d.log.add({ type: "observation", text: `Dosing stopped: ${why}` }); | |
| 285 | return refuse(`stopped: ${why}`); | |
| 286 | } | |
| 287 | if (this.d.pumps[loop].simulated) this.d.log.add({ type: "observation", text: `Simulated dose, nothing was pumped: ${ml} ml ${LABEL[loop]} (${cause})` }); | |
| 288 | else this.d.log.add({ type: "action", what: ACTION[loop], amount: ml, cause }); | |
| 289 | } finally { | |
| 290 | this.busy = false; | |
| 291 | } | |
| 292 | return { ok: true, ml }; | |
| 293 | } | |
| 294 | ||
| 295 | /** One pass of automation: dose each loop that is in auto and has something to add. Returns what it did. */ | |
| 296 | async tick(): Promise<Record<string, Outcome>> { | |
| 297 | const out: Record<string, Outcome> = {}; | |
| 298 | for (const l of LOOPS) { | |
| 299 | if (this.loops[l].mode !== "auto") continue; | |
| 300 | const s = this.suggest(l); | |
| 301 | if ("ml" in s) out[l] = await this.dose(l, "rule", s.ml); | |
| 302 | } | |
| 303 | return out; | |
| 304 | } | |
| 305 | } | |
| 306 | ||
| 307 | const refuse = (reason: string): Outcome => ({ ok: false, reason }); | |
| 308 | const round = (n: number) => Math.round(n * 10) / 10; | |
| 309 | ||
| 310 | function validate(id: LoopId, c: LoopConfig) { | |
| 311 | if (!MODES.includes(c.mode)) throw new Error(`${id}: mode is one of ${MODES.join(", ")}`); | |
| 312 | for (const k of ["maxDoseMl", "maxDayMl", "mlPerUnit", "minMove"] as const) { | |
| 313 | if (!(typeof c[k] === "number" && Number.isFinite(c[k]) && c[k] > 0)) throw new Error(`${id}: ${k} is a number above 0`); | |
| 314 | } | |
| 315 | if (c.maxDoseMl > c.maxDayMl) throw new Error(`${id}: the per-dose limit is above the daily limit`); | |
| 316 | } | |
| 317 | ||
| 318 | /** | |
| 319 | * Top-off: run the fresh-water pump until the high float says full or the time cap passes, whichever comes first. | |
| 320 | * `full` is polled every `pollMs`; the pump is stopped in all cases, including a throw. | |
| 321 | */ | |
| 322 | export async function topOff(pump: { on(): Promise<void>; off(): Promise<void> }, full: () => Promise<boolean>, opts: { capMs: number; pollMs?: number; now?: () => number; sleep?: (ms: number) => Promise<void> }): Promise<"full" | "cap"> { | |
| 323 | const now = opts.now ?? Date.now; | |
| 324 | const sleep = opts.sleep ?? ((ms) => new Promise<void>((r) => setTimeout(r, ms))); | |
| 325 | if (await full()) return "full"; | |
| 326 | const end = now() + opts.capMs; | |
| 327 | try { | |
| 328 | await pump.on(); | |
| 329 | while (now() < end) { | |
| 330 | await sleep(opts.pollMs ?? 500); | |
| 331 | if (await full()) return "full"; | |
| 332 | } | |
| 333 | return "cap"; | |
| 334 | } finally { | |
| 335 | await pump.off(); | |
| 336 | } | |
| 337 | } |
Software/so101/src/pumps-card.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** | |
| 2 | * The pumps on the /care dosing card, the owner's only: each pump's switch, its measured ml/s and today's ml, and the | |
| 3 | * steps to set one up — wire it (a GPIO relay or MOSFET line, or a Shelly plug), prime it for 10 s into a measuring | |
| 4 | * cup, type the ml the cup caught — then the top-off pump with its high float and time cap. Plain forms to | |
| 5 | * /care/dosing/pump, no script; the Pi checks every field (src/dose-pumps.ts). Everything shown is escaped. | |
| 6 | */ | |
| 7 | import { PUMP_LABEL } from "./arm-pumps.ts"; | |
| 8 | import { PUMP_IDS, type DosingView, type PumpId, type PumpView } from "./dose-pumps.ts"; | |
| 9 | import { esc } from "./story-markdown.ts"; | |
| 10 | ||
| 11 | const ACTION = "/care/dosing/pump"; | |
| 12 | const PRIME_SEC = 10; | |
| 13 | ||
| 14 | const hidden = (fields: Record<string, string>) => Object.entries(fields).map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join(""); | |
| 15 | const form = (fields: Record<string, string>, inner: string, label: string, cls = "") => `<form method="post" action="${ACTION}">${hidden(fields)}${inner}<button${cls ? ` class="${cls}"` : ""}>${esc(label)}</button></form>`; | |
| 16 | const day = (at: number) => new Date(at).toISOString().slice(0, 10); | |
| 17 | ||
| 18 | function wiring(p: PumpView): string { | |
| 19 | const fields = { action: "switch", pump: p.id }; | |
| 20 | return `<details${p.driver ? "" : " open"}><summary>Wire ${esc(PUMP_LABEL[p.id])}${p.driver ? ` · ${esc(p.driver)}` : ""}</summary> | |
| 21 | ${form({ ...fields, driver: "gpio" }, `<label>GPIO line <input name="line" type="number" required min="0" max="63" step="1" inputmode="numeric" placeholder="17"></label><label><input name="activeLow" type="checkbox" style="min-height:auto"> closes on 0</label>`, "Use relay / MOSFET", "quiet")} | |
| 22 | ${form({ ...fields, driver: "shelly" }, `<label>Shelly plug <input name="host" required maxlength="80" class="wide" autocapitalize="none" autocomplete="off" spellcheck="false" placeholder="192.168.1.40"></label>`, "Use plug", "quiet")} | |
| 23 | ${p.driver ? form({ ...fields, driver: "none" }, "", "Unwire", "quiet") : ""} | |
| 24 | <p class="dim">A new switch clears the calibration: prime and measure again.</p></details>`; | |
| 25 | } | |
| 26 | ||
| 27 | function calibration(p: PumpView, primed: DosingView["primed"]): string { | |
| 28 | if (!p.driver) return ""; | |
| 29 | const prime = form({ action: "prime", pump: p.id, sec: String(PRIME_SEC) }, "", p.mlPerSec ? `Prime ${PRIME_SEC} s` : `1. Run ${PRIME_SEC} s into a measuring cup`, p.mlPerSec ? "quiet" : ""); | |
| 30 | const caught = primed?.pump === p.id | |
| 31 | ? form({ action: "calibrate", pump: p.id }, `<label>2. The cup caught <input name="ml" type="number" required min="0.1" max="1000" step="0.1" inputmode="decimal"> ml in ${primed.ms / 1000} s</label>`, "Save calibration") | |
| 32 | : ""; | |
| 33 | return `${prime}${caught}`; | |
| 34 | } | |
| 35 | ||
| 36 | function row(p: PumpView, v: DosingView): string { | |
| 37 | const cal = p.mlPerSec ? `${p.mlPerSec} ml/s, measured ${p.calibratedAt ? day(p.calibratedAt) : "earlier"}` : p.driver ? "not calibrated: it will not dose" : "not wired"; | |
| 38 | const today = p.id === "topOff" ? "" : ` · today ${p.dosedTodayMl} of the Pi's ${v.maxDayMl} ml`; | |
| 39 | return `<li data-pump="${esc(p.id)}"><span><strong>${esc(PUMP_LABEL[p.id])}</strong>${v.running === p.id ? " · running" : ""}<br><span class="dim">${esc(cal)}${today}</span></span> | |
| 40 | <span>${calibration(p, v.primed)}</span>${wiring(p)}</li>`; | |
| 41 | } | |
| 42 | ||
| 43 | function topOff(v: DosingView): string { | |
| 44 | const p = v.pumps.find((x) => x.id === "topOff"); | |
| 45 | const float = v.float ? `high float on GPIO ${v.float.line}${v.float.activeLow ? " (closes on 0)" : ""}: ${v.full === null ? "no answer" : v.full ? "full" : "not full"}` : "no high float set: top-off will not run"; | |
| 46 | const go = p?.driver && v.float ? form({ action: "fill" }, "", "Top off now") : ""; | |
| 47 | return `<li data-pump="topOff-fill"><span><strong>Fill</strong><br><span class="dim">${esc(float)} · stops at ${v.capSec} s at most</span></span><span>${go}</span> | |
| 48 | <details${v.float ? "" : " open"}><summary>Float and time cap</summary> | |
| 49 | ${form({ action: "float" }, `<label>Float GPIO line <input name="line" type="number" min="0" max="63" step="1" inputmode="numeric" value="${v.float ? v.float.line : ""}" placeholder="27"></label><label><input name="activeLow" type="checkbox" style="min-height:auto"${v.float?.activeLow ? " checked" : ""}> closes on 0</label><label>Cap <input name="capSec" type="number" min="5" max="600" step="1" inputmode="numeric" value="${v.capSec}"> s</label>`, "Save", "quiet")} | |
| 50 | <p class="dim">Leave the line empty to remove the float.</p></details></li>`; | |
| 51 | } | |
| 52 | ||
| 53 | /** The pumps section; null is the Pi not answering. */ | |
| 54 | export function renderPumps(v: DosingView | null): string { | |
| 55 | if (!v) return `<h3>Pumps</h3><p class="dim">The arm server did not answer, so the pumps cannot be read or set up. Nothing doses until it does.</p>`; | |
| 56 | const pumps = PUMP_IDS.map((id: PumpId) => v.pumps.find((p) => p.id === id)).filter((p): p is PumpView => !!p); | |
| 57 | return `<h3>Pumps</h3> | |
| 58 | ${v.error ? `<p class="alert" role="alert">The Pi says: ${esc(v.error)}</p>` : ""} | |
| 59 | <p class="dim">Each pump stops after ${v.maxRunMs / 1000} s and ${v.maxDayMl} ml a day whatever is asked: that backstop is on the Pi.</p> | |
| 60 | <ul class="list">${pumps.map((p) => row(p, v)).join("")}${topOff(v)}</ul>`; | |
| 61 | } |
Software/so101/src/relay.ts +33 −3
| @@ -110,6 +110,9 @@ import { CareLog } from "./care-log.ts"; | ||
| 110 | 110 | import { BUILT_IN_DIR, loadProfiles, type Profile } from "./care-profile.ts"; |
| 111 | 111 | import { ProbeCache, readProbeView, sampleProbes, waterTemp, type ProbeHooks } from "./care-probes.ts"; |
| 112 | 112 | import { handleCare } from "./care-routes.ts"; |
| 113 | import { handleDosing, makeDoser } from "./care-dosing.ts"; | |
| 114 | import type { Doser } from "./doser.ts"; | |
| 115 | import { armPumps, cachedRead, readDosing, type PumpHooks } from "./arm-pumps.ts"; | |
| 113 | 116 | import { renderWorkshop, type StationView } from "./workshop-page.ts"; |
| 114 | 117 | import { DAY_MS, GrowStills, KEEP_DAYS } from "./grow-stills.ts"; |
| 115 | 118 | import { finishStillApart } from "./grow-frame.ts"; |
| @@ -164,7 +167,7 @@ export interface RelayOptions { | ||
| 164 | 167 | /** The workshop's stations; by default in memory, the arm alone. */ |
| 165 | 168 | stations?: StationHub; |
| 166 | 169 | /** The plant care app's log book and plant profile (src/care-routes.ts); by default in memory, with the built-in spinach profile. */ |
| 167 | care?: { log: CareLog; profile: Profile }; | |
| 170 | care?: { log: CareLog; profile: Profile; doser?: Doser; dosingFile?: string }; | |
| 168 | 171 | /** How often the Pi's pH/EC probes are read into the care log, ms; unset, they are not sampled (the page still shows them and walks the calibration). */ |
| 169 | 172 | probeEveryMs?: number; |
| 170 | 173 | /** The grow box's stills, log and how often a still is taken (src/grow-stills.ts); without it nothing is taken and the card shows no timelapse. */ |
| @@ -330,6 +333,9 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 330 | 333 | const metrics = new LiveMetrics(); |
| 331 | 334 | const stations = options.stations ?? new StationHub(undefined, ARM_ONLY); |
| 332 | 335 | const care = options.care ?? { log: new CareLog(), profile: loadProfiles(BUILT_IN_DIR).find((p) => p.id === "spinach")! }; |
| 336 | /** The Pi's dosing pumps (src/dose-pumps.ts), reached through the arm server like the grow light; set up below with armGrow. */ | |
| 337 | const pumpHooks: PumpHooks = { read: () => pumpRead.read(), write: (body, timeoutMs) => pumpWrite(body, timeoutMs) }; | |
| 338 | const doser = care.doser ?? makeDoser(care.log, armPumps(pumpHooks), care.dosingFile); | |
| 333 | 339 | const cameras = options.cameras ?? new CameraHub({ feed: { ...options.camera, metrics }, seed: remote ? [{ name: "Camera 1", source: remote.cameras[0]! }] : [] }); |
| 334 | 340 | const leaders = options.leaders ?? new LeaderCalibrations(); |
| 335 | 341 | const videos = new Map<number, VideoFeed>(Object.entries(options.video ?? {}).map(([id, sources]) => [Number(id), new VideoFeed(sources, {}, options.env ?? process.env)])); |
| @@ -658,6 +664,27 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 658 | 664 | } |
| 659 | 665 | }, |
| 660 | 666 | }; |
| 667 | const pumpRead = cachedRead(async () => { | |
| 668 | try { | |
| 669 | const answer = await fetch(armGrow("/dosing"), { cache: "no-store", signal: AbortSignal.timeout(Math.min(readTimeoutMs, 1000)) }); | |
| 670 | return answer.ok ? readDosing(await answer.json().catch(() => null)) : null; | |
| 671 | } catch { | |
| 672 | return null; | |
| 673 | } | |
| 674 | }); | |
| 675 | /** One request to the Pi's pumps; the relay's own, never a browser's (a browser's /grow/dosing is refused below). */ | |
| 676 | const pumpWrite = async (body: Record<string, unknown>, timeoutMs: number): Promise<import("./arm-pumps.ts").PumpAnswer> => { | |
| 677 | try { | |
| 678 | const answer = await fetch(armGrow("/dosing"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) }); | |
| 679 | const parsed = (await answer.json().catch(() => null)) as { error?: unknown } | null; | |
| 680 | if (answer.ok) return { ok: true, body: parsed }; | |
| 681 | return { ok: false, status: answer.status, error: typeof parsed?.error === "string" ? parsed.error.replace(/[\p{Cc}\p{Cf}]/gu, "").slice(0, 300) : `the arm server answered ${answer.status}` }; | |
| 682 | } catch (err) { | |
| 683 | return { ok: false, status: 0, error: `the arm server did not answer (${reason(err)})` }; | |
| 684 | } finally { | |
| 685 | void pumpRead.refresh().catch(() => {}); | |
| 686 | } | |
| 687 | }; | |
| 661 | 688 | /** The arm's servo registers for the owner's admin page; null when the arm does not answer in time. */ |
| 662 | 689 | const askServos = async () => { |
| 663 | 690 | try { |
| @@ -1122,9 +1149,10 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1122 | 1149 | if (/^\/care(?:\/|$)/.test(url.pathname)) { |
| 1123 | 1150 | const who = (r: IncomingMessage) => { |
| 1124 | 1151 | const person = lobby ? lobby.who(r) : LOCAL_PERSON; |
| 1125 | return { owner: Boolean(person?.owner), known: Boolean(person), signedIn: lobby && !lobby.local ? lobby.signedIn(r) : null }; | |
| 1152 | return { owner: Boolean(person?.owner), known: Boolean(person), id: person?.id, signedIn: lobby && !lobby.local ? lobby.signedIn(r) : null }; | |
| 1126 | 1153 | }; |
| 1127 | if (await handleCare(req, res, url.pathname, { ...care, probes: probeCache, who, readBody })) return; | |
| 1154 | if (await handleDosing(req, res, url.pathname, { doser, log: care.log, who, readBody, sameOrigin, pumps: pumpHooks })) return; | |
| 1155 | if (await handleCare(req, res, url.pathname, { profile: care.profile, log: care.log, probes: probeCache, doser, pumps: () => pumpRead.read(), who, readBody })) return; | |
| 1128 | 1156 | } |
| 1129 | 1157 | if (reading && url.pathname === "/stations") return json(res, 200, stationViews().map(({ station, present, resting, cameras: c }) => ({ id: station.id, name: station.name, kind: station.kind, present, ...(resting ? { resting } : {}), cameras: c }))); |
| 1130 | 1158 | const cameraAdmin = ADMIN_CAMERA.exec(url.pathname); |
| @@ -1157,6 +1185,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1157 | 1185 | res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); |
| 1158 | 1186 | return res.end(text); |
| 1159 | 1187 | } |
| 1188 | // The Pi's pumps answer the relay's controller alone: a browser that reached them would skip every dosing limit. | |
| 1189 | if (url.pathname === "/grow/dosing" || url.pathname.startsWith("/grow/dosing/")) return json(res, 404, { error: "not found" }); | |
| 1160 | 1190 | if (url.pathname.startsWith("/grow/") && options.grow) { |
| 1161 | 1191 | const handled = await growRoutes(req, res, url); |
| 1162 | 1192 | if (handled) return; |
Software/so101/src/server.ts +17
| @@ -54,6 +54,9 @@ | ||
| 54 | 54 | * GET /probes the pH and EC probes on the ADS1115 (src/probes.ts): values (null until calibrated), ?temp= compensates, and the next calibration step |
| 55 | 55 | * POST /probes/calibrate {"step": "ph7"|"ph4"|"ec1413", "tempC"?: number}; 400 says what to do |
| 56 | 56 | * POST /grow/pump the same, with an interval schedule {"onMinutes": 5, "offMinutes": 25} |
| 57 | * GET /grow/dosing the dosing pumps (src/dose-pumps.ts): each one's switch, ml/s and when it was calibrated, the high float, today's ml | |
| 58 | * POST /grow/dosing {"action": "switch"|"float"|"dose"|"prime"|"calibrate"|"fill", "pump"?, ...}; 409 is refused before anything ran, | |
| 59 | * 500 a pump that failed once switched on. The relay alone asks: its controller (src/doser.ts) holds the limits | |
| 57 | 60 | * GET /tuning the run's phase while one is under way, and the last outcome |
| 58 | 61 | * GET /precision/trace?session=<id> a kept session's 5 Hz intent and read-back per joint, ticks: {session, t0, hz, joints} (400 bad id, 404 unknown or evicted; the last 20 sessions) |
| 59 | 62 | * POST /precision/snap-back {"cause": one of CAUSES, "sizeDeg"}: a snap-back only the page saw, counted beside the server's own (400 for any other cause) |
| @@ -108,6 +111,7 @@ import { CalError } from "./probe-math.ts"; | ||
| 108 | 111 | import { Probes } from "./probes.ts"; |
| 109 | 112 | import { loadSensors, Sensors } from "./sensors.ts"; |
| 110 | 113 | import { DEVICES, Light, LightError, type DeviceName } from "./light.ts"; |
| 114 | import { DosePumps, PumpError } from "./dose-pumps.ts"; | |
| 111 | 115 | import { ARRIVE_TICKS, LINK_HOPS, parseSnapBack, READ_BACK_HOP, RELAY_HOP, type Cause, type Tracker } from "./precision.ts"; |
| 112 | 116 | import { deployedCommit, PrecisionHistory, TRACE_HZ } from "./precision-history.ts"; |
| 113 | 117 | import { Tuning, TuningRefused } from "./tuning.ts"; |
| @@ -146,6 +150,8 @@ export interface ServerOptions { | ||
| 146 | 150 | lightMs?: number; |
| 147 | 151 | /** The grow light and the pump, for tests that give them a fake switch. */ |
| 148 | 152 | lights?: Record<DeviceName, Light>; |
| 153 | /** The dosing pumps, for tests that give them fake switches. */ | |
| 154 | dosePumps?: DosePumps; | |
| 149 | 155 | /** The pH/EC probes on the ADS1115, for tests that give them a fake ADC. */ |
| 150 | 156 | probes?: Probes; |
| 151 | 157 | /** How often the sensors (src/sensors.ts, listed in sensors.json) are read, ms; unset, they are not run. */ |
| @@ -254,6 +260,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 254 | 260 | } |
| 255 | 261 | const metrics = new Metrics(join(dataDir, "metrics.jsonl")); |
| 256 | 262 | const lights = options.lights ?? { light: new Light({ name: "light", file: join(dataDir, "light.json") }), pump: new Light({ name: "pump", file: join(dataDir, "pump.json") }) }; |
| 263 | const dosePumps = options.dosePumps ?? new DosePumps({ file: join(dataDir, "dose-pumps.json") }); | |
| 257 | 264 | const probes = options.probes ?? new Probes(ads1115(), join(dataDir, "probes.json")); |
| 258 | 265 | const sensors = options.sensors ?? loadSensors(join(dataDir, "sensors.json")); |
| 259 | 266 | const precision = new PrecisionHistory({ file: join(dataDir, "precision.sqlite"), commit: options.commit }); |
| @@ -996,6 +1003,15 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 996 | 1003 | } |
| 997 | 1004 | return json(res, 200, device.view()); |
| 998 | 1005 | } |
| 1006 | case "GET /grow/dosing": | |
| 1007 | return json(res, 200, await dosePumps.view()); | |
| 1008 | case "POST /grow/dosing": | |
| 1009 | try { | |
| 1010 | return json(res, 200, await dosePumps.act(await readBody(req))); | |
| 1011 | } catch (err) { | |
| 1012 | if (err instanceof PumpError) throw new HttpError(err.refused ? 409 : 500, err.message); | |
| 1013 | throw err; | |
| 1014 | } | |
| 999 | 1015 | case "GET /probes": { |
| 1000 | 1016 | const t = url.searchParams.get("temp"); |
| 1001 | 1017 | return json(res, 200, await probes.view(t !== null && /^\d{1,2}(\.\d{1,3})?$/.test(t) ? Number(t) : undefined)); |
| @@ -1812,6 +1828,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 1812 | 1828 | if (gainTimer) void gainSync.tick(); |
| 1813 | 1829 | const lightTimer = options.lightMs ? setInterval(() => DEVICES.forEach((d) => void lights[d].tick()), options.lightMs) : undefined; |
| 1814 | 1830 | if (options.lightMs) DEVICES.forEach((d) => void lights[d].boot()); |
| 1831 | void dosePumps.boot(); | |
| 1815 | 1832 | const sensorTimer = options.sensorsMs ? setInterval(() => void sensors.tick(), options.sensorsMs) : undefined; |
| 1816 | 1833 | if (options.sensorsMs) void sensors.tick(); |
| 1817 | 1834 | const address = http.address(); |
Software/so101/src/terminal.ts +5 −3
| @@ -74,7 +74,7 @@ import { readTuning, tuningSentence, type TuningView } from "./tuning-panel.ts"; | ||
| 74 | 74 | import { JOINTS } from "./sts3215.ts"; |
| 75 | 75 | import { headlineValue, hopLines, NO_HOPS, P95_RULE, readPrecision, sampleSize, sparkline, type PrecisionPanel } from "./precision-panel.ts"; |
| 76 | 76 | import { probeLines } from "./care-probes.ts"; |
| 77 | import { careCommand, careLines, probesCommand, type CareAsk } from "./care-terminal.ts"; | |
| 77 | import { careCommand, careLines, dosingLines, pumpLines, pumpSaid, probesCommand, type CareAsk } from "./care-terminal.ts"; | |
| 78 | 78 | import { presenceText } from "./ui/lobby-panel.js"; |
| 79 | 79 | import { entryLine, jointRows, linkRows, stateRows } from "./ui/machine.js"; |
| 80 | 80 | |
| @@ -192,10 +192,12 @@ export function careReader(env: Env, fetcher: typeof fetch = fetch): ReadCare { | ||
| 192 | 192 | return async (ask) => { |
| 193 | 193 | try { |
| 194 | 194 | const write = ask.method !== "GET"; |
| 195 | const r = await fetcher(`${appUrl(env)}${ask.path}`, { method: ask.method, headers: { ...terminalHeaders(env), ...(write ? { "content-type": "application/json" } : {}) }, ...(write ? { body: JSON.stringify(ask.body ?? {}) } : {}), signal: AbortSignal.timeout(5000) }); | |
| 195 | const r = await fetcher(`${appUrl(env)}${ask.path}`, { method: ask.method, headers: { ...terminalHeaders(env), ...(write ? { "content-type": "application/json" } : {}) }, ...(write ? { body: JSON.stringify(ask.body ?? {}) } : {}), signal: AbortSignal.timeout(ask.timeoutMs ?? 5000) }); | |
| 196 | 196 | const body = (await r.json().catch(() => null)) as unknown; |
| 197 | 197 | if (!r.ok) return { error: plain((body as { error?: unknown } | null)?.error ?? `status ${r.status}`), lines: [] }; |
| 198 | return { error: null, lines: ask.path === "/care/probes/calibrate" ? ["probe captured", ...probeLines({ view: body }).map(plain)] : write ? ["care saved"] : ask.path === "/care/probes" ? probeLines(body).map(plain) : careLines(body).map(plain) }; | |
| 198 | if (ask.path === "/care/probes/calibrate") return { error: null, lines: ["probe captured", ...probeLines({ view: body }).map(plain)] }; | |
| 199 | if (write) return { error: null, lines: [plain(ask.path === "/care/dosing/pump" ? pumpSaid(body) : ask.path.startsWith("/care/dosing") ? "dosing: done" : "care saved")] }; | |
| 200 | return { error: null, lines: (ask.path === "/care/dosing" ? dosingLines(body) : ask.path === "/care/probes" ? probeLines(body) : ask.path === "/care/dosing/pumps" ? pumpLines(body) : careLines(body)).map(plain) }; | |
| 199 | 201 | } catch (err) { |
| 200 | 202 | return { error: plain((err as Error).message), lines: [] }; |
| 201 | 203 | } |
later["s"]andjam["d"]reach into private fields, so the test breaks on a rename that changes no behaviour. Expose a small test seam, or build the halted state through a real failing dose.