Public: anyone can read this; the crew can change it.

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

Dosing: real peristaltic and top-off pump drivers with ml/s calibration, replacing the fake pumps #610

Merged opened this

Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.

Merged

quill/arm:quill/607-dosing-real-peristaltic-and-top-off-pump → main 7 commits fork 3 threads · 3 unresolved

  • Merged. quill/607-dosing-real-peristaltic-and-top-off-pump was merged into main as fa0051d.

Review

Sign in to review this change.
18 files changed +2032 −14
Software/so101/README.md +31
@@ -265,3 +265,34 @@ Not drawn yet, because nothing records it: latency per hop (page → relay → P
265265 ## Faster limits
266266
267267 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") });
8080 const piSync = piSyncFromEnv(process.env, remote?.http ?? null, remote?.host ?? null, () => lobby.turns.snapshot().driver === null, dir);
8181 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) };
8282 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") });
8484 const { mode, host: armHost } = relay.link();
8585 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 &lt;b&gt;");
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

later["s"] and jam["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.

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 };

await object(req, ctx).catch(() => ({})) swallows every body error, including body too large, so an oversized resume still resumes. Resume needs no body, so don't read one. Or catch only the parse errors and let the 413 through.

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 /care?error=. It is escaped, but it is the tool's wording (gpioset failed). Translate it for the person here.

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";
99
1010 export const MAX_ENTRIES = 5000;
1111 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;
1313 export const PROBLEMS = ["bolting", "tip burn", "root rot", "algae", "yellowing", "leggy"] as const;
1414 export const SYSTEMS = ["vertical tower", "DWC", "NFT", "Kratky", "ebb-and-flow"] as const;
1515 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];
1619
1720 /** What a sensor will also produce. */
1821 export interface Reading {
@@ -31,7 +34,7 @@ export interface Transplant {
3134
3235 export type Entry = { id: number; at: number } & (
3336 | ({ 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 }
3538 | { type: "observation"; text: string }
3639 | { type: "problem"; what: (typeof PROBLEMS)[number]; text?: string }
3740 );
@@ -87,7 +90,8 @@ export function parseEntry(input: unknown, now = Date.now()): EntryBody {
8790 if (!/^[a-z0-9][a-z0-9-]*$/.test(ref!)) throw new LogError("ref is a reminder id");
8891 return { ...base, ref };
8992 }
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 } : {}) };
9195 }
9296 case "observation":
9397 return { at, type: "observation", text: text(e.text, "text", 1000)! };
Software/so101/src/care-page.ts +25 −2
@@ -3,6 +3,10 @@
33 import { boltingWatch, currentStage, health, LABELS, ago, readings, reminders, snapshot, stageAt, stageHistory, totals, transplantPrompt, type Snapshot } from "./care-rules.ts";
44 import { ACTIONS, PROBLEMS, SYSTEMS, type Entry, type CareLog } from "./care-log.ts";
55 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";
610 import { renderSignedIn, SIGNED_IN_STYLE, type SignedIn } from "./signed-in.ts";
711 import { esc } from "./story-markdown.ts";
812
@@ -28,6 +32,7 @@ h1 { margin: .5rem 0; } h2.first { margin-top: 0; } h2 { margin: 1.2rem 0 .5rem;
2832 .health p { margin: .1rem 0; }
2933 .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: .6rem .8rem; margin: .6rem 0; }
3034 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; }
3136 .late { color: var(--bad); font-weight: 600; } .dim { color: var(--dim); font-size: .9rem; }
3237 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; }
3338 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 {
7479 case "action":
7580 if (e.transplant) return `Transplant: ${e.transplant.system}, ${e.transplant.litres} L, ${e.transplant.sites} sites, pump ${e.transplant.pump ? "yes" : "no"}`;
7681 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" : ""}` : ""}`;
7883 }
7984 }
8085
@@ -88,6 +93,10 @@ export interface CareView {
8893 now: number;
8994 /** May write: the owner (or a run on one's own machine). */
9095 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;
91100 signedIn: SignedIn | null;
92101 /** A refused form's message, shown at the top. */
93102 error?: string | null;
@@ -95,7 +104,7 @@ export interface CareView {
95104 probes?: string;
96105 }
97106
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 {
99108 const snap = snapshot(log);
100109 const spans = stageHistory(profile, snap.entries, snap.startedAt);
101110 const cur = currentStage(spans);
@@ -117,6 +126,19 @@ ${spans.length === 1 ? `<form method="post" action="/care/log"><input type="hidd
117126 </section>`
118127 : "";
119128
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
120142 const dueRows = due
121143 .map((d) => {
122144 const late = d.overdueMs > 0 && !d.snoozedUntil;
@@ -160,6 +182,7 @@ ${error ? `<p class="alert" role="alert">${esc(error)}</p>` : ""}
160182 ${prompt ? `<p class="panel" data-prompt="transplant">${esc(prompt)}</p>` : ""}${bolt ? `<p class="panel" data-prompt="bolting">${esc(bolt)}</p>` : ""}
161183 ${quick}
162184 ${probes}
185 ${dosingCard}
163186 <h2>Reminders</h2><div class="panel"><ul class="list">${dueRows}</ul></div>
164187 <h2>Metrics</h2>
165188 <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";
66 import { renderCare } from "./care-page.ts";
77 import { calibrateProbe, renderProbes, type ProbeCache } from "./care-probes.ts";
88 import type { Profile } from "./care-profile.ts";
9 import type { Doser } from "./doser.ts";
10 import type { DosingView } from "./dose-pumps.ts";
911 import type { SignedIn } from "./signed-in.ts";
1012
1113 export interface CareContext {
1214 profile: Profile;
1315 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>;
1420 /** null when the request may not write; the reader is told who they are for the page. */
1521 who: (req: IncomingMessage) => { owner: boolean; signedIn: SignedIn | null; known: boolean };
1622 readBody: (req: IncomingMessage, limit?: number) => Promise<Buffer>;
@@ -46,7 +52,7 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path
4652
4753 if (req.method === "GET" && pathname === "/care") {
4854 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) }));
5056 return true;
5157 }
5258 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
9595 expect(careLines({ stage: "seedling", health: { ok: true, lines: [] }, reminders: [], entries: [] })[0]).toContain("healthy");
9696 });
9797
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
98118 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 () => {
99119 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" });
100120 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 @@
55
66 import { ACTIONS, PROBLEMS } from "./care-log.ts";
77 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";
811
912 export interface CareAsk {
1013 method: "GET" | "POST" | "DELETE";
1114 path: string;
1215 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;
1318 }
1419
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)";
1621
1722 export const PROBES_USAGE = "usage: /probes · /probes calibrate <ph7|ph4|ec1413> [solution tempC] (owner)";
1823
@@ -52,11 +57,79 @@ export function careCommand(words: string[], tail: string): CareAsk | { print: s
5257 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;
5358 case "delete":
5459 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;
5594 default:
5695 return usage;
5796 }
5897 }
5998
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
60133 const day = (at: number) => new Date(at).toISOString().replace("T", " ").slice(0, 16);
61134
62135 /** 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";
110110 import { BUILT_IN_DIR, loadProfiles, type Profile } from "./care-profile.ts";
111111 import { ProbeCache, readProbeView, sampleProbes, waterTemp, type ProbeHooks } from "./care-probes.ts";
112112 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";
113116 import { renderWorkshop, type StationView } from "./workshop-page.ts";
114117 import { DAY_MS, GrowStills, KEEP_DAYS } from "./grow-stills.ts";
115118 import { finishStillApart } from "./grow-frame.ts";
@@ -164,7 +167,7 @@ export interface RelayOptions {
164167 /** The workshop's stations; by default in memory, the arm alone. */
165168 stations?: StationHub;
166169 /** 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 };
168171 /** 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). */
169172 probeEveryMs?: number;
170173 /** 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> {
330333 const metrics = new LiveMetrics();
331334 const stations = options.stations ?? new StationHub(undefined, ARM_ONLY);
332335 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);
333339 const cameras = options.cameras ?? new CameraHub({ feed: { ...options.camera, metrics }, seed: remote ? [{ name: "Camera 1", source: remote.cameras[0]! }] : [] });
334340 const leaders = options.leaders ?? new LeaderCalibrations();
335341 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> {
658664 }
659665 },
660666 };
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 };
661688 /** The arm's servo registers for the owner's admin page; null when the arm does not answer in time. */
662689 const askServos = async () => {
663690 try {
@@ -1122,9 +1149,10 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
11221149 if (/^\/care(?:\/|$)/.test(url.pathname)) {
11231150 const who = (r: IncomingMessage) => {
11241151 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 };
11261153 };
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;
11281156 }
11291157 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 })));
11301158 const cameraAdmin = ADMIN_CAMERA.exec(url.pathname);
@@ -1157,6 +1185,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
11571185 res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
11581186 return res.end(text);
11591187 }
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" });
11601190 if (url.pathname.startsWith("/grow/") && options.grow) {
11611191 const handled = await growRoutes(req, res, url);
11621192 if (handled) return;
Software/so101/src/server.ts +17
@@ -54,6 +54,9 @@
5454 * GET /probes the pH and EC probes on the ADS1115 (src/probes.ts): values (null until calibrated), ?temp= compensates, and the next calibration step
5555 * POST /probes/calibrate {"step": "ph7"|"ph4"|"ec1413", "tempC"?: number}; 400 says what to do
5656 * 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
5760 * GET /tuning the run's phase while one is under way, and the last outcome
5861 * 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)
5962 * 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";
108111 import { Probes } from "./probes.ts";
109112 import { loadSensors, Sensors } from "./sensors.ts";
110113 import { DEVICES, Light, LightError, type DeviceName } from "./light.ts";
114 import { DosePumps, PumpError } from "./dose-pumps.ts";
111115 import { ARRIVE_TICKS, LINK_HOPS, parseSnapBack, READ_BACK_HOP, RELAY_HOP, type Cause, type Tracker } from "./precision.ts";
112116 import { deployedCommit, PrecisionHistory, TRACE_HZ } from "./precision-history.ts";
113117 import { Tuning, TuningRefused } from "./tuning.ts";
@@ -146,6 +150,8 @@ export interface ServerOptions {
146150 lightMs?: number;
147151 /** The grow light and the pump, for tests that give them a fake switch. */
148152 lights?: Record<DeviceName, Light>;
153 /** The dosing pumps, for tests that give them fake switches. */
154 dosePumps?: DosePumps;
149155 /** The pH/EC probes on the ADS1115, for tests that give them a fake ADC. */
150156 probes?: Probes;
151157 /** 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 = {}):
254260 }
255261 const metrics = new Metrics(join(dataDir, "metrics.jsonl"));
256262 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") });
257264 const probes = options.probes ?? new Probes(ads1115(), join(dataDir, "probes.json"));
258265 const sensors = options.sensors ?? loadSensors(join(dataDir, "sensors.json"));
259266 const precision = new PrecisionHistory({ file: join(dataDir, "precision.sqlite"), commit: options.commit });
@@ -996,6 +1003,15 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
9961003 }
9971004 return json(res, 200, device.view());
9981005 }
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 }
9991015 case "GET /probes": {
10001016 const t = url.searchParams.get("temp");
10011017 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 = {}):
18121828 if (gainTimer) void gainSync.tick();
18131829 const lightTimer = options.lightMs ? setInterval(() => DEVICES.forEach((d) => void lights[d].tick()), options.lightMs) : undefined;
18141830 if (options.lightMs) DEVICES.forEach((d) => void lights[d].boot());
1831 void dosePumps.boot();
18151832 const sensorTimer = options.sensorsMs ? setInterval(() => void sensors.tick(), options.sensorsMs) : undefined;
18161833 if (options.sensorsMs) void sensors.tick();
18171834 const address = http.address();
Software/so101/src/terminal.ts +5 −3
@@ -74,7 +74,7 @@ import { readTuning, tuningSentence, type TuningView } from "./tuning-panel.ts";
7474 import { JOINTS } from "./sts3215.ts";
7575 import { headlineValue, hopLines, NO_HOPS, P95_RULE, readPrecision, sampleSize, sparkline, type PrecisionPanel } from "./precision-panel.ts";
7676 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";
7878 import { presenceText } from "./ui/lobby-panel.js";
7979 import { entryLine, jointRows, linkRows, stateRows } from "./ui/machine.js";
8080
@@ -192,10 +192,12 @@ export function careReader(env: Env, fetcher: typeof fetch = fetch): ReadCare {
192192 return async (ask) => {
193193 try {
194194 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) });
196196 const body = (await r.json().catch(() => null)) as unknown;
197197 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) };
199201 } catch (err) {
200202 return { error: plain((err as Error).message), lines: [] };
201203 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs