Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
workbenchthis machine
Dosing: pH/EC probes via ADS1115 with guided calibration and monthly reminder #609
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
smith/arm:smith/606-dosing-ph-ec-probes-via-ads1115-with-gui → main 4 commits fork 3 threads · 3 unresolved
17 files
changed +756
−19
Software/so101/README.md +6
| @@ -145,6 +145,12 @@ The grow box's care app: a plant profile, reminders worked out from it and the l | ||
| 145 | 145 | - **Live.** `GET /care/events` is a server-sent stream that says `care` after every change to the book, from any door; the page listens with a few lines of script and refreshes itself (not while a field has focus). Without the script the page works the same, on reload. A refused form returns to `/care?error=…` with the message in an alert line. |
| 146 | 146 | - **Three doors.** The page; JSON: `GET /care/api` (anyone), `POST /care/log`, `PUT` or `DELETE /care/log/<id>` and `POST /care/reminder` (owner, same-origin only, 8 KB at most); and in the terminal `/care` (stage, health, what is due, the last entries with their ids) for anyone, with `/care log ph 6.1`, `/care did topoff`, `/care did harvest 40`, `/care note <text>`, `/care problem algae [detail]`, `/care done <reminder>`, `/care snooze <reminder> [hours]` and `/care delete <id>` for the owner. All three act on the same log, so a write in one shows in the others at once. |
| 147 | 147 | |
| 148 | ### pH and EC probes (on /care) | |
| 149 | ||
| 150 | An ADS1115 breakout on the Pi's I²C bus (address 0x48; `sudo apt install i2c-tools`, I²C enabled) reads the pH probe board on input A0 and the EC probe board on A1. The arm server serves `GET /probes` and `POST /probes/calibrate`; the care page shows the readings and walks the owner through the calibration: pH 7.0 buffer, then pH 4.0, then the 1413 µS/cm solution, each captured only once the probe has settled. A probe gives no value until it is calibrated. Calibration lives in `probes.json` under the data dir. Values are compensated for the water temperature (the latest `waterTemp` in the log book from the last two hours, else 25 °C), and every `probeEveryMs` (5 min on the forge) a calibrated reading is written into the log book as a sensor reading. Finishing a calibration marks the monthly "calibrate" reminder done. | |
| 151 | ||
| 152 | The page paints from the last answer (a few seconds old, shown with its age) and refreshes it in the background, so a Pi that is down or slow never delays `/care`. The terminal has the same two doors: `/probes` prints the readings, calibration dates and the next step (anyone), and `/probes calibrate <ph7|ph4|ec1413> [solution tempC]` captures a step (owner). Both go through the same code as the page's buttons, and a calibration made in either shows live for everyone watching `/care`. | |
| 153 | ||
| 148 | 154 | ## The story page: /story |
| 149 | 155 | |
| 150 | 156 | `/story` on the hosted app is the arm explained for anyone: the hook, how it works (with a diagram of browser → forge → Pi → servos, and what latency, the queue, STOP and the safety zone mean), how to build your own (the [](https://huggingface.co/docs/lerobot/so101), this repository, `bun so101 setup`) and what the crew built. Anyone may read it; it is linked from the Chat tab's footer (hidden while you drive) and the Queue tab. |
Software/so101/bench/fake-serve.ts +1 −1
| @@ -80,6 +80,6 @@ const facts = new Facts({ file: join(dir, "facts.sqlite") }); | ||
| 80 | 80 | const piSync = piSyncFromEnv(process.env, remote?.http ?? null, remote?.host ?? null, () => lobby.turns.snapshot().driver === null, dir); |
| 81 | 81 | const grow = { stills: new GrowStills(join(dir, "grow-stills"), { capBytes: Number(process.env.GROW_STILLS_CAP_MB) * 1e6 || undefined }), log: new GrowLog(join(dir, "grow-log.json")), everyMs: parseEvery(process.env.GROW_STILL_MINUTES) }; |
| 82 | 82 | const careProfile = loadProfiles(BUILT_IN_DIR, join(dir, "care-profiles")).find((p) => p.id === (process.env.CARE_PROFILE ?? "spinach")); |
| 83 | const relay = await startRelay({ 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 } : undefined, grow, stations: new StationHub(join(dir, "stations.json")), facts, piSync: piSync ?? undefined, host, port, simulation: `http://127.0.0.1:${simulation.port}`, remote, problem, lobby, cameras, leaders, video, summaryEveryMs: 60_000, holdFile: join(dir, "hold.json"), baselineFile: join(dir, "servo-factory.json") }); | |
| 84 | 84 | const { mode, host: armHost } = relay.link(); |
| 85 | 85 | console.log(`arm on http://${host}:${relay.port} (${mode === "simulated" ? "simulated" : `arm ${armHost}: ${mode}`}; data in ${dir}${local ? "; local: every visitor drives as the owner" : ""})`); |
Software/so101/src/ads1115.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | /** | |
| 2 | * The ADS1115 16-bit ADC on the Pi's I²C bus, read through i2c-tools (`i2cset`, `i2cget`) the way the light's GPIO relay | |
| 3 | * goes through gpioset: no native module. Single-shot, ±4.096 V (0.125 mV a step), 128 samples a second. | |
| 4 | * Everything that reaches the machine is the injected `Run`, so the tests use a fake chip. | |
| 5 | */ | |
| 6 | import { run as defaultRun, type Run } from "./light-switch.ts"; | |
| 7 | ||
| 8 | export class AdcError extends Error {} | |
| 9 | ||
| 10 | /** Volts on one input (0 to 3, single-ended). */ | |
| 11 | export type Adc = (channel: number) => Promise<number>; | |
| 12 | ||
| 13 | const LSB = 0.000125; | |
| 14 | const pause = (ms: number) => new Promise((r) => setTimeout(r, ms)); | |
| 15 | const swap = (w: number) => ((w & 0xff) << 8) | (w >> 8); | |
| 16 | ||
| 17 | /** The config word for one single-shot conversion on AINx: start, mux, ±4.096 V, single-shot, 128 SPS, comparator off. */ | |
| 18 | export const configWord = (channel: number): number => 0x8000 | ((4 + channel) << 12) | (1 << 9) | (1 << 8) | (4 << 5) | 3; | |
| 19 | ||
| 20 | export function ads1115(opts: { bus?: number; address?: number; run?: Run; wait?: (ms: number) => Promise<void> } = {}): Adc { | |
| 21 | const { bus = 1, address = 0x48, run = defaultRun, wait = pause } = opts; | |
| 22 | const a = `0x${address.toString(16)}`; | |
| 23 | return async (channel) => { | |
| 24 | if (!Number.isInteger(channel) || channel < 0 || channel > 3) throw new AdcError("the ADS1115 has inputs 0 to 3"); | |
| 25 | // SMBus sends a word's low byte first and the chip wants the high byte first, so both ways swap. | |
| 26 | const wrote = await run("i2cset", ["-y", String(bus), a, "0x01", `0x${swap(configWord(channel)).toString(16).padStart(4, "0")}`, "w"]); | |
| 27 | if (wrote.code !== 0) throw new AdcError(`no ADS1115 at ${a} on i2c-${bus} (${wrote.out.trim().slice(0, 120) || "i2cset failed"})`); | |
| 28 | await wait(12); | |
| 29 | const read = await run("i2cget", ["-y", String(bus), a, "0x00", "w"]); | |
| 30 | const word = /^0x([0-9a-f]{1,4})\s*$/i.exec(read.out.trim()); | |
| 31 | if (read.code !== 0 || !word) throw new AdcError("the ADS1115 did not answer a read"); | |
| 32 | const raw = swap(parseInt(word[1]!, 16)); | |
| 33 | return (raw > 0x7fff ? raw - 0x10000 : raw) * LSB; | |
| 34 | }; | |
| 35 | } |
Software/so101/src/care-log.ts +10 −1
| @@ -142,6 +142,11 @@ export class CareLog { | ||
| 142 | 142 | return () => this.listeners.delete(fn); |
| 143 | 143 | } |
| 144 | 144 | |
| 145 | /** Tell the listeners something changed that is not in the book (a probe was calibrated). */ | |
| 146 | notify() { | |
| 147 | for (const fn of this.listeners) fn(); | |
| 148 | } | |
| 149 | ||
| 145 | 150 | private save() { |
| 146 | 151 | for (const fn of this.listeners) fn(); |
| 147 | 152 | if (!this.file) return; |
| @@ -165,8 +170,12 @@ export class CareLog { | ||
| 165 | 170 | } |
| 166 | 171 | |
| 167 | 172 | add(input: unknown): Entry { |
| 173 | return this.push(parseEntry(input, this.clock())); | |
| 174 | } | |
| 175 | ||
| 176 | private push(body: EntryBody): Entry { | |
| 168 | 177 | if (this.data.entries.length >= MAX_ENTRIES) throw new LogError("the log book is full"); |
| 169 | const entry = { ...parseEntry(input, this.clock()), id: this.data.nextId++ } as Entry; | |
| 178 | const entry = { ...body, id: this.data.nextId++ } as Entry; | |
| 170 | 179 | this.data.entries.push(entry); |
| 171 | 180 | this.data.entries.sort((a, b) => a.at - b.at || a.id - b.id); |
| 172 | 181 | this.save(); |
Software/so101/src/care-page.ts +5 −2
| @@ -21,7 +21,7 @@ const STYLE = ` | ||
| 21 | 21 | @media (prefers-color-scheme: light) { :root { --bg: #fbfaf7; --panel: #fff; --fg: #1f2328; --dim: #59636e; --line: #d8dee4; --accent: #bc4c00; --on-accent: #fff; --link: #0969da; --ok: #1a7f37; --bad: #cf222e; --band: #1a7f3733; } } |
| 22 | 22 | body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.5 ui-sans-serif, system-ui, sans-serif; } |
| 23 | 23 | main { max-width: 60rem; margin: 0 auto; padding: 1rem; } |
| 24 | h1 { margin: .5rem 0; } h2 { margin: 1.2rem 0 .5rem; font-size: 1.1rem; } | |
| 24 | h1 { margin: .5rem 0; } h2.first { margin-top: 0; } h2 { margin: 1.2rem 0 .5rem; font-size: 1.1rem; } | |
| 25 | 25 | .top { max-width: 60rem; margin: 0 auto; padding: .5rem 1rem 0; display: flex; justify-content: space-between; gap: 1rem; font-size: .9rem; color: var(--dim); } |
| 26 | 26 | .health { border-left: 6px solid var(--ok); background: var(--panel); padding: .6rem .9rem; border-radius: 6px; } |
| 27 | 27 | .health[data-ok="false"] { border-color: var(--bad); } |
| @@ -91,9 +91,11 @@ export interface CareView { | ||
| 91 | 91 | signedIn: SignedIn | null; |
| 92 | 92 | /** A refused form's message, shown at the top. */ |
| 93 | 93 | error?: string | null; |
| 94 | /** The probe panel, already rendered (src/care-probes.ts); empty without probes. */ | |
| 95 | probes?: string; | |
| 94 | 96 | } |
| 95 | 97 | |
| 96 | export function renderCare({ profile, log, now, owner, signedIn, error }: CareView): string { | |
| 98 | export function renderCare({ profile, log, now, owner, signedIn, error, probes = "" }: CareView): string { | |
| 97 | 99 | const snap = snapshot(log); |
| 98 | 100 | const spans = stageHistory(profile, snap.entries, snap.startedAt); |
| 99 | 101 | const cur = currentStage(spans); |
| @@ -157,6 +159,7 @@ ${error ? `<p class="alert" role="alert">${esc(error)}</p>` : ""} | ||
| 157 | 159 | <section class="health" data-ok="${h.ok}" aria-label="Health">${h.lines.map((l) => `<p>${esc(l)}</p>`).join("")}</section> |
| 158 | 160 | ${prompt ? `<p class="panel" data-prompt="transplant">${esc(prompt)}</p>` : ""}${bolt ? `<p class="panel" data-prompt="bolting">${esc(bolt)}</p>` : ""} |
| 159 | 161 | ${quick} |
| 162 | ${probes} | |
| 160 | 163 | <h2>Reminders</h2><div class="panel"><ul class="list">${dueRows}</ul></div> |
| 161 | 164 | <h2>Metrics</h2> |
| 162 | 165 | <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-probes.ts added +172
| @@ -0,0 +1,172 @@ | ||
| 1 | /** | |
| 2 | * The pH/EC probes as the care app sees them: what the Pi's GET /probes answered (read field by field), the sampler that | |
| 3 | * writes them into the log book as sensor readings, and the guided calibration panel. The probes themselves, the | |
| 4 | * calibration and the ADC are on the Pi (src/probes.ts); nothing here touches hardware. | |
| 5 | */ | |
| 6 | import { LogError, type CareLog } from "./care-log.ts"; | |
| 7 | import { esc } from "./story-markdown.ts"; | |
| 8 | import type { ProbeView, Step } from "./probes.ts"; | |
| 9 | ||
| 10 | export type { ProbeView } from "./probes.ts"; | |
| 11 | ||
| 12 | export interface ProbeHooks { | |
| 13 | /** The probes' answer, compensated for this water temperature; null when the Pi does not answer. */ | |
| 14 | read: (tempC?: number) => Promise<ProbeView | null>; | |
| 15 | /** One calibration step; the error says what to do. */ | |
| 16 | calibrate: (step: string, tempC: number) => Promise<{ view: ProbeView } | { error: string }>; | |
| 17 | } | |
| 18 | ||
| 19 | const STEPS: readonly string[] = ["ph7", "ph4", "ec1413"]; | |
| 20 | const obj = (v: unknown): Record<string, unknown> => (typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {}); | |
| 21 | const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); | |
| 22 | ||
| 23 | /** Any JSON → a view, or null when it is not one. */ | |
| 24 | export function readProbeView(body: unknown): ProbeView | null { | |
| 25 | const b = obj(body); | |
| 26 | const cal = obj(b.calibratedAt); | |
| 27 | if (typeof b.present !== "boolean" || typeof b.tempC !== "number") return null; | |
| 28 | return { | |
| 29 | present: b.present, | |
| 30 | error: typeof b.error === "string" ? b.error.replace(/[\p{Cc}\p{Cf}]/gu, "").slice(0, 200) : null, | |
| 31 | ph: num(b.ph), | |
| 32 | ec: num(b.ec), | |
| 33 | tempC: b.tempC, | |
| 34 | tempSource: b.tempSource === "given" ? "given" : "default", | |
| 35 | calibratedAt: { ph: num(cal.ph), ec: num(cal.ec) }, | |
| 36 | next: STEPS.includes(b.next as string) ? (b.next as Step) : null, | |
| 37 | pending: b.pending === true, | |
| 38 | }; | |
| 39 | } | |
| 40 | ||
| 41 | const HOUR = 3_600_000; | |
| 42 | ||
| 43 | /** The newest water temperature in the book from the last two hours (the DS18B20, or a hand), for compensation. */ | |
| 44 | export function waterTemp(log: CareLog, now: number): number | undefined { | |
| 45 | const last = log.entries().filter((e) => e.type === "measurement" && e.kind === "waterTemp" && e.at <= now && e.at > now - 2 * HOUR).pop(); | |
| 46 | return last?.type === "measurement" ? last.value : undefined; | |
| 47 | } | |
| 48 | ||
| 49 | /** What the page and the terminal see of the probes: the Pi's last answer and its age, never a wait on the Pi. */ | |
| 50 | export class ProbeCache { | |
| 51 | readonly hooks: ProbeHooks; | |
| 52 | private view: ProbeView | null = null; | |
| 53 | private at = 0; | |
| 54 | private inflight: Promise<ProbeView | null> | null = null; | |
| 55 | private seen = ""; | |
| 56 | private readonly temp: () => number | undefined; | |
| 57 | private readonly changed: () => void; | |
| 58 | private readonly ttlMs: number; | |
| 59 | private readonly clock: () => number; | |
| 60 | constructor(hooks: ProbeHooks, temp: () => number | undefined, changed: () => void = () => {}, ttlMs = 5000, clock: () => number = Date.now) { | |
| 61 | this.hooks = hooks; | |
| 62 | this.temp = temp; | |
| 63 | this.changed = changed; | |
| 64 | this.ttlMs = ttlMs; | |
| 65 | this.clock = clock; | |
| 66 | } | |
| 67 | ||
| 68 | /** The last view and how old it is; a stale or missing one starts a refresh in the background and is returned anyway. */ | |
| 69 | peek(): { view: ProbeView; ageMs: number } | null { | |
| 70 | if (!this.view || this.clock() - this.at > this.ttlMs) void this.refresh(); | |
| 71 | return this.view ? { view: this.view, ageMs: this.clock() - this.at } : null; | |
| 72 | } | |
| 73 | ||
| 74 | /** Ask the Pi once (calls share the one question); keeps the answer, and tells the listeners when the news changed. */ | |
| 75 | refresh(): Promise<ProbeView | null> { | |
| 76 | this.inflight ??= this.hooks | |
| 77 | .read(this.temp()) | |
| 78 | .then((v) => (v ? this.set(v) : this.view)) | |
| 79 | .catch(() => this.view) | |
| 80 | .finally(() => (this.inflight = null)); | |
| 81 | return this.inflight; | |
| 82 | } | |
| 83 | ||
| 84 | set(view: ProbeView): ProbeView { | |
| 85 | this.view = view; | |
| 86 | this.at = this.clock(); | |
| 87 | const key = JSON.stringify([view.present, view.next, view.pending, view.calibratedAt, view.ph === null, view.ec === null]); | |
| 88 | if (key !== this.seen) { | |
| 89 | this.seen = key; | |
| 90 | this.changed(); | |
| 91 | } | |
| 92 | return view; | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * One calibration step, for the page and the terminal alike: the Pi captures it, and when both probes are calibrated the | |
| 98 | * monthly "calibrate" reminder is marked done. Every finished step after that adds a done entry again (a recalibration | |
| 99 | * is a calibration); a step that leaves the guide unfinished adds none. Viewers on the care event stream hear of it. | |
| 100 | */ | |
| 101 | export async function calibrateProbe(log: CareLog, cache: ProbeCache, step: string, tempC: number): Promise<{ view: ProbeView } | { error: string }> { | |
| 102 | const done = await cache.hooks.calibrate(step, tempC); | |
| 103 | if ("error" in done) return done; | |
| 104 | cache.set(done.view); | |
| 105 | if (done.view.next === null) log.add({ type: "action", what: "done", ref: "calibrate" }); | |
| 106 | log.notify(); | |
| 107 | return done; | |
| 108 | } | |
| 109 | ||
| 110 | /** Read the probes once and write what is calibrated into the log book as sensor readings; returns what it wrote. */ | |
| 111 | export async function sampleProbes(log: CareLog, cache: ProbeCache, now = Date.now()): Promise<string[]> { | |
| 112 | const view = await cache.refresh(); | |
| 113 | const wrote: string[] = []; | |
| 114 | if (!view?.present) return wrote; | |
| 115 | for (const kind of ["ph", "ec"] as const) { | |
| 116 | const value = view[kind]; | |
| 117 | if (value === null) continue; | |
| 118 | try { | |
| 119 | if (log.addReading({ kind, value, at: now, source: "sensor" })) wrote.push(kind); // the book keeps one sensor reading an hour per kind | |
| 120 | } catch (err) { | |
| 121 | if (!(err instanceof LogError)) throw err; // outside physical bounds: a broken probe is not a measurement | |
| 122 | } | |
| 123 | } | |
| 124 | return wrote; | |
| 125 | } | |
| 126 | ||
| 127 | const GUIDE: Record<Step, { title: string; how: string }> = { | |
| 128 | ph7: { title: "pH 7.0 buffer", how: "Rinse the pH probe in clean water, stand it in the pH 7.0 buffer and wait a minute." }, | |
| 129 | ph4: { title: "pH 4.0 buffer", how: "Rinse the pH probe, stand it in the pH 4.0 buffer and wait a minute." }, | |
| 130 | ec1413: { title: "1413 µS/cm solution", how: "Rinse the EC probe, stand it in the 1413 µS/cm solution and wait a minute." }, | |
| 131 | }; | |
| 132 | ||
| 133 | const day = (at: number) => new Date(at).toISOString().slice(0, 10); | |
| 134 | ||
| 135 | /** The lines the terminal prints for the same view: readings, calibration dates, the next step. */ | |
| 136 | export function probeLines(body: unknown): string[] { | |
| 137 | const seen = obj(body); | |
| 138 | const view = readProbeView(seen.view); | |
| 139 | if (!view) return ["probes: the Pi did not answer"]; | |
| 140 | if (!view.present) return [`No probe board answering${view.error ? `: ${view.error}` : ""}`]; | |
| 141 | const age = typeof seen.ageMs === "number" ? `${Math.round(seen.ageMs / 1000)} s ago` : "now"; | |
| 142 | const cal = (at: number | null) => (at === null ? "never calibrated" : `calibrated ${day(at)}`); | |
| 143 | return [ | |
| 144 | `pH ${view.ph === null ? "not calibrated" : view.ph} · ${cal(view.calibratedAt.ph)}`, | |
| 145 | `EC ${view.ec === null ? "not calibrated" : `${view.ec} mS/cm`} · ${cal(view.calibratedAt.ec)}`, | |
| 146 | `Read ${age}, compensated for ${view.tempC} °C`, | |
| 147 | view.next ? `Next: /probes calibrate ${view.next} [tempC] (${GUIDE[view.next].title}: ${GUIDE[view.next].how})` : "Both probes are calibrated", | |
| 148 | ]; | |
| 149 | } | |
| 150 | ||
| 151 | /** The panel: readings, when each probe was calibrated, and the next step of the guide (owner only gets the button). */ | |
| 152 | export function renderProbes(seen: { view: ProbeView; ageMs: number } | null, owner: boolean): string { | |
| 153 | if (!seen) return ""; | |
| 154 | const { view, ageMs } = seen; | |
| 155 | const age = ageMs < 2000 ? "just now" : `${Math.round(ageMs / 1000)} s ago`; | |
| 156 | if (!view.present) return `<section class="panel" data-probes="absent"><h2 class="first">pH and EC probes</h2><p class="dim">No probe board answering${view.error ? `: ${esc(view.error)}` : ""}. Readings can still be logged by hand.</p></section>`; | |
| 157 | const read = (v: number | null, unit: string) => (v === null ? "not calibrated" : `${v}${unit}`); | |
| 158 | const cal = (at: number | null) => (at === null ? "never calibrated" : `calibrated ${day(at)}`); | |
| 159 | const step = view.next ? GUIDE[view.next] : null; | |
| 160 | const guide = step | |
| 161 | ? `<p><strong>${view.pending ? "Next: " : "Calibrate: "}${esc(step.title)}</strong><br><span class="dim">${esc(step.how)}</span></p>${ | |
| 162 | owner | |
| 163 | ? `<form method="post" action="/care/probes/calibrate"><input type="hidden" name="step" value="${esc(view.next!)}"><label>Solution °C <input type="number" name="tempC" step="any" value="${view.tempC}" required></label><button>Capture ${esc(step.title)}</button></form>` | |
| 164 | : "" | |
| 165 | }` | |
| 166 | : `<p class="dim">Both probes are calibrated.</p>${ | |
| 167 | owner ? `<form method="post" action="/care/probes/calibrate"><input type="hidden" name="step" value="ph7"><input type="hidden" name="tempC" value="${view.tempC}"><button class="quiet">Recalibrate pH</button></form><form method="post" action="/care/probes/calibrate"><input type="hidden" name="step" value="ec1413"><input type="hidden" name="tempC" value="${view.tempC}"><button class="quiet">Recalibrate EC</button></form>` : "" | |
| 168 | }`; | |
| 169 | return `<section class="panel" data-probes="present" aria-labelledby="probes"><h2 id="probes" class="first">pH and EC probes</h2> | |
| 170 | <p>pH ${esc(read(view.ph, ""))} <span class="dim">· ${esc(cal(view.calibratedAt.ph))}</span><br>EC ${esc(read(view.ec, " mS/cm"))} <span class="dim">· ${esc(cal(view.calibratedAt.ec))}</span><br><span class="dim">Read ${age}, compensated for ${view.tempC} °C${view.tempSource === "default" ? " (no recent water temperature, so 25 °C)" : ""}.</span></p> | |
| 171 | ${guide}</section>`; | |
| 172 | } |
Software/so101/src/care-profiles/spinach.json +2 −1
| @@ -27,7 +27,7 @@ | ||
| 27 | 27 | { "id": "pump", "label": "Pump ran? Top emitter clear, every level wet", "everyHours": 24, "needs": "pump", "resets": ["action:pumpcheck"] }, |
| 28 | 28 | { "id": "measure", "label": "Measure pH, EC and water temperature", "everyHours": 60, "tower": 36, "resets": ["measurement:ph", "measurement:ec", "measurement:waterTemp"] }, |
| 29 | 29 | { "id": "topoff", "label": "Top off with plain water", "everyHours": 60, "tower": 24, "resets": ["action:topoff", "action:change"] }, |
| 30 | { "id": "calibrate", "label": "Calibrate the pH probe", "everyHours": 168 }, | |
| 30 | { "id": "calibrate", "label": "Calibrate the pH and EC probes (4.0 and 7.0 buffers, 1413 µS/cm)", "everyHours": 720 }, | |
| 31 | 31 | { "id": "air-stone", "label": "Clean the air stone", "everyHours": 168, "resets": ["action:airstone"] }, |
| 32 | 32 | { "id": "roots", "label": "Check the roots (brown or slimy = Pythium); thin or harvest", "everyHours": 168 }, |
| 33 | 33 | { "id": "tower", "label": "Tower: top emitter not clogged, every level wet, no algae on lit parts", "everyHours": 168, "needs": "tower" }, |
| @@ -44,6 +44,7 @@ | ||
| 44 | 44 | { "id": "pump", "label": "Pump ran?", "everyHours": 24, "needs": "pump", "resets": ["action:pumpcheck"] }, |
| 45 | 45 | { "id": "measure", "label": "Measure pH, EC and water temperature", "everyHours": 60, "tower": 36, "resets": ["measurement:ph", "measurement:ec", "measurement:waterTemp"] }, |
| 46 | 46 | { "id": "bolting", "label": "Stalk forming? Look at the side camera (watch when air has been above 24 C)", "everyHours": 48 }, |
| 47 | { "id": "calibrate", "label": "Calibrate the pH and EC probes (4.0 and 7.0 buffers, 1413 µS/cm)", "everyHours": 720 }, | |
| 47 | 48 | { "id": "harvest", "label": "Cut and come again: harvest outer leaves", "everyHours": 168, "resets": ["action:harvest"] }, |
| 48 | 49 | { "id": "change", "label": "Full reservoir change and wipe down", "everyHours": 336, "resets": ["action:change"] } |
| 49 | 50 | ] |
Software/so101/src/care-routes.ts +18 −4
| @@ -4,6 +4,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; | ||
| 4 | 4 | import { boltingWatch, currentStage, health, reminders, snapshot, stageHistory, totals, transplantPrompt } from "./care-rules.ts"; |
| 5 | 5 | import { LogError, type CareLog } from "./care-log.ts"; |
| 6 | 6 | import { renderCare } from "./care-page.ts"; |
| 7 | import { calibrateProbe, renderProbes, type ProbeCache } from "./care-probes.ts"; | |
| 7 | 8 | import type { Profile } from "./care-profile.ts"; |
| 8 | 9 | import type { SignedIn } from "./signed-in.ts"; |
| 9 | 10 | |
| @@ -14,9 +15,11 @@ export interface CareContext { | ||
| 14 | 15 | who: (req: IncomingMessage) => { owner: boolean; signedIn: SignedIn | null; known: boolean }; |
| 15 | 16 | readBody: (req: IncomingMessage, limit?: number) => Promise<Buffer>; |
| 16 | 17 | now?: () => number; |
| 18 | /** The pH/EC probes on the Pi; without them the page has no probe panel. */ | |
| 19 | probes?: ProbeCache; | |
| 17 | 20 | } |
| 18 | 21 | |
| 19 | const NUMBERS = new Set(["value", "amount", "litres", "sites", "hours"]); | |
| 22 | const NUMBERS = new Set(["value", "amount", "litres", "sites", "hours", "tempC"]); | |
| 20 | 23 | |
| 21 | 24 | /** A JSON body, or a form's fields with the numbers made numbers. */ |
| 22 | 25 | function fields(req: IncomingMessage, body: string): Record<string, unknown> { |
| @@ -42,7 +45,8 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path | ||
| 42 | 45 | const json = (status: number, body: unknown) => send(status, "application/json", JSON.stringify(body)); |
| 43 | 46 | |
| 44 | 47 | if (req.method === "GET" && pathname === "/care") { |
| 45 | send(200, "text/html; charset=utf-8", renderCare({ 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) })); | |
| 48 | const probes = renderProbes(ctx.probes?.peek() ?? null, me.owner); | |
outdated
This awaits the Pi before the first paint (up to 1.5 s when the Pi is down). Serve a cached view instead. |
||
| 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) })); | |
| 46 | 50 | return true; |
| 47 | 51 | } |
| 48 | 52 | if (req.method === "GET" && pathname === "/care/events") { |
| @@ -53,6 +57,11 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path | ||
| 53 | 57 | res.on("close", () => { stop(); clearInterval(beat); }); |
| 54 | 58 | return true; |
| 55 | 59 | } |
| 60 | if (req.method === "GET" && pathname === "/care/probes" && ctx.probes) { | |
| 61 | if (!ctx.probes.peek()) await ctx.probes.refresh(); // the one place that waits: a terminal asked, and has nothing to show yet | |
| 62 | json(200, ctx.probes.peek() ?? { view: null }); | |
| 63 | return true; | |
| 64 | } | |
| 56 | 65 | if (req.method === "GET" && pathname === "/care/api") { |
| 57 | 66 | const snap = snapshot(ctx.log); |
| 58 | 67 | const spans = stageHistory(ctx.profile, snap.entries, snap.startedAt); |
| @@ -71,7 +80,8 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path | ||
| 71 | 80 | |
| 72 | 81 | const entry = /^\/care\/log(?:\/(\d+)(\/delete)?)?$/.exec(pathname); |
| 73 | 82 | const reminder = pathname === "/care/reminder"; |
| 74 | if (!entry && !reminder) { | |
| 83 | const calibrate = pathname === "/care/probes/calibrate" && ctx.probes; | |
| 84 | if (!entry && !reminder && !calibrate) { | |
| 75 | 85 | json(404, { error: "no such route" }); |
| 76 | 86 | return true; |
| 77 | 87 | } |
| @@ -92,7 +102,11 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path | ||
| 92 | 102 | const input = fields(req, raw.toString("utf8")); |
| 93 | 103 | let result: unknown = { ok: true }; |
outdated
The done entry is added in the route only. A terminal door needs the same step, so move it into one shared function. Also, finishing a single-probe recalibration adds a done entry each time, which is fine, but say so in a test. |
||
| 94 | 104 | const id = entry?.[1] ? Number(entry[1]) : null; |
| 95 | if (reminder) { | |
| 105 | if (calibrate) { | |
| 106 | const done = await calibrateProbe(ctx.log, ctx.probes!, String(input.step ?? ""), typeof input.tempC === "number" ? input.tempC : 25); | |
| 107 | if ("error" in done) throw new LogError(done.error); | |
| 108 | result = done.view; | |
| 109 | } else if (reminder) { | |
| 96 | 110 | const rid = String(input.id ?? ""); |
| 97 | 111 | if (input.action === "done") result = ctx.log.add({ type: "action", what: "done", ref: rid }); |
| 98 | 112 | else if (input.action === "snooze") ctx.log.snooze(rid, Number(input.hours ?? 24)); |
Software/so101/src/care-terminal.test.ts +38 −3
| @@ -23,18 +23,17 @@ let dir = ""; | ||
| 23 | 23 | let relay: RunningRelay | undefined; |
| 24 | 24 | let lobby: Lobby | undefined; |
| 25 | 25 | afterEach(async () => { |
| 26 | await relay?.close(); | |
| 27 | 26 | lobby?.close(); |
| 28 | 27 | if (dir) rmSync(dir, { recursive: true, force: true }); |
| 29 | 28 | dir = ""; |
| 30 | 29 | relay = lobby = undefined; |
| 31 | 30 | }); |
| 32 | 31 | |
| 33 | async function start() { | |
| 32 | async function start(simulation = "http://127.0.0.1:1") { | |
| 34 | 33 | dir = mkdtempSync(join(tmpdir(), "so101-care-")); |
| 35 | 34 | lobby = new Lobby({ identityKey: KEY, adminFile: join(dir, "admin.json"), countsFile: join(dir, "visitors.json") }); |
| 36 | 35 | const log = new CareLog(); |
| 37 | relay = await startRelay({ port: 0, simulation: "http://127.0.0.1:1", lobby, care: { log, profile } }); | |
| 36 | relay = await startRelay({ port: 0, simulation, lobby, care: { log, profile } }); | |
| 38 | 37 | return { log, port: relay.port, base: `http://127.0.0.1:${relay.port}` }; |
| 39 | 38 | } |
| 40 | 39 | |
| @@ -95,3 +94,39 @@ test("through the relay: a cross-site write is refused, a guest is 401, a plain | ||
| 95 | 94 | expect(log.entries()).toHaveLength(1); |
| 96 | 95 | expect(careLines({ stage: "seedling", health: { ok: true, lines: [] }, reminders: [], entries: [] })[0]).toContain("healthy"); |
| 97 | 96 | }); |
| 97 | ||
| 98 | test("/probes through the relay: anyone reads, the owner calibrates, a web viewer hears it live, and a Pi that is down never delays /care", async () => { | |
| 99 | const view = (next: string | null) => ({ present: true, error: null, ph: null, ec: null, tempC: 21, tempSource: "default", calibratedAt: { ph: null, ec: null }, next, pending: next === "ph4" }); | |
| 100 | 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"))) }); | |
| 101 | try { | |
| 102 | const { port, base, log } = await start(`http://127.0.0.1:${pi.port}`); | |
| 103 | const run = async (env: Record<string, string>, line: string) => { | |
| 104 | const printed: string[] = []; | |
| 105 | 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); | |
| 106 | for (let i = 0; i < 100 && !printed.length; i++) await Bun.sleep(20); | |
| 107 | return printed; | |
| 108 | }; | |
| 109 | const events = await fetch(`${base}/care/events`); | |
| 110 | const reader = events.body!.getReader(); | |
| 111 | await reader.read(); // the retry line | |
| 112 | const guest = await run(terminal(port, null), "/probes"); | |
| 113 | expect(guest.join("\n")).toContain("Next: /probes calibrate ph7"); | |
| 114 | expect((await run(terminal(port, "eve"), "/probes calibrate ph7"))[0]).toMatch(/^error: only the owner/); | |
| 115 | const out = await run(terminal(port, "trevato", "admin"), "/probes calibrate ph7 21"); | |
| 116 | expect(out[0]).toBe("probe captured"); | |
| 117 | expect(out.join("\n")).toContain("Next: /probes calibrate ph4"); | |
| 118 | let heard = ""; | |
| 119 | for (let i = 0; i < 20 && !heard.includes("event: care"); i++) heard += new TextDecoder().decode((await reader.read()).value); | |
| 120 | expect(heard).toContain("event: care"); | |
| 121 | expect(log.entries()).toHaveLength(0); // unfinished: no done entry yet | |
| 122 | await reader.cancel(); | |
| 123 | } finally { | |
| 124 | await pi.stop(true); | |
| 125 | } | |
| 126 | const { base } = await start(); // nothing listens on the simulation port: the Pi is down | |
| 127 | await (await fetch(`${base}/care`)).text(); // starts the background read | |
| 128 | const began = performance.now(); | |
| 129 | const page = await fetch(`${base}/care`); | |
| 130 | expect(page.status).toBe(200); | |
| 131 | expect(performance.now() - began).toBeLessThan(500); | |
| 132 | }); |
Software/so101/src/care-terminal.ts +10
| @@ -14,6 +14,16 @@ export interface CareAsk { | ||
| 14 | 14 | |
| 15 | 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>"; |
| 16 | 16 | |
| 17 | export const PROBES_USAGE = "usage: /probes · /probes calibrate <ph7|ph4|ec1413> [solution tempC] (owner)"; | |
| 18 | ||
| 19 | /** `/probes …` → the ask the page's probe panel makes (GET /care/probes, POST /care/probes/calibrate), or the usage. */ | |
| 20 | export function probesCommand(words: string[]): CareAsk | { print: string } { | |
| 21 | if (!words.length) return { method: "GET", path: "/care/probes" }; | |
| 22 | const [sub, step = "", temp, ...more] = words; | |
| 23 | if (sub?.toLowerCase() !== "calibrate" || !["ph7", "ph4", "ec1413"].includes(step) || more.length || (temp !== undefined && !/^\d{1,2}(\.\d+)?$/.test(temp))) return { print: PROBES_USAGE }; | |
| 24 | return { method: "POST", path: "/care/probes/calibrate", body: { step, ...(temp ? { tempC: Number(temp) } : {}) } }; | |
| 25 | } | |
| 26 | ||
| 17 | 27 | const QUICK = ACTIONS.filter((a) => a !== "transplant" && a !== "done"); |
| 18 | 28 | |
| 19 | 29 | /** `/care …` (the words after the verb, and the line after the sub-verb) → what to ask the server, or the usage. */ |
Software/so101/src/care.test.ts +1 −1
| @@ -120,7 +120,7 @@ test("topping off resets the top-off reminder; done, snooze and an edited cadenc | ||
| 120 | 120 | log.add({ type: "action", what: "topoff" }); |
| 121 | 121 | expect(due(log, clock.now, "topoff")!.dueAt).toBe(T0 + 49 * H); |
| 122 | 122 | log.add({ type: "action", what: "done", ref: "calibrate" }); |
| 123 | expect(due(log, clock.now, "calibrate")!.dueAt).toBe(clock.now + 168 * H); | |
| 123 | expect(due(log, clock.now, "calibrate")!.dueAt).toBe(clock.now + 720 * H); | |
| 124 | 124 | log.snooze("roots", 48); |
| 125 | 125 | expect(due(log, clock.now + 10 * H, "roots")!.snoozedUntil).toBe(clock.now + 48 * H); |
| 126 | 126 | log.setEvery("roots", 24); |
Software/so101/src/probe-math.ts added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | /** | |
| 2 | * pH and EC from probe voltages. Pure functions. | |
| 3 | * pH two buffers (7.0, then 4.0) give volts per pH; the slope is scaled by absolute temperature (Nernst) from the | |
| 4 | * temperature the buffers were at to the water's now. | |
| 5 | * EC one solution (1413 µS/cm) gives a cell constant; the reading is brought to 25 °C at 2 % per °C. | |
| 6 | */ | |
| 7 | ||
| 8 | export interface PhCal { | |
| 9 | /** Volts at pH 7.0 and the slope in volts per pH unit, both taken at `tempC`. */ | |
| 10 | v7: number; | |
| 11 | slope: number; | |
| 12 | tempC: number; | |
| 13 | at: number; | |
| 14 | } | |
| 15 | export interface EcCal { | |
| 16 | /** mS/cm per volt at 25 °C. */ | |
| 17 | k: number; | |
| 18 | at: number; | |
| 19 | } | |
| 20 | ||
| 21 | export class CalError extends Error {} | |
| 22 | ||
| 23 | export const BUFFER_LOW = 4.0; | |
| 24 | export const BUFFER_MID = 7.0; | |
| 25 | export const EC_SOLUTION = 1.413; | |
| 26 | export const DEFAULT_TEMP = 25; | |
| 27 | const EC_ALPHA = 0.02; | |
| 28 | const kelvin = (c: number) => 273.15 + c; | |
| 29 | ||
| 30 | export function phCalibration(v7: number, v4: number, tempC: number, at: number): PhCal { | |
| 31 | const slope = (v4 - v7) / (BUFFER_LOW - BUFFER_MID); | |
| 32 | if (Math.abs(slope) < 0.01) throw new CalError("the pH 4.0 and 7.0 readings are almost the same: is the probe in the right buffer, and the board powered?"); | |
| 33 | return { v7, slope, tempC, at }; | |
| 34 | } | |
| 35 | export function ph(volts: number, cal: PhCal, tempC: number): number { | |
| 36 | const slope = cal.slope * (kelvin(tempC) / kelvin(cal.tempC)); | |
| 37 | return BUFFER_MID + (volts - cal.v7) / slope; | |
| 38 | } | |
| 39 | ||
| 40 | export function ecCalibration(volts: number, tempC: number, at: number): EcCal { | |
| 41 | if (!(volts > 0.01)) throw new CalError("the EC probe reads nothing in the 1413 µS/cm solution: is it in, and the board powered?"); | |
| 42 | return { k: (EC_SOLUTION * (1 + EC_ALPHA * (tempC - 25))) / volts, at }; | |
| 43 | } | |
| 44 | export const ec = (volts: number, cal: EcCal, tempC: number): number => (cal.k * volts) / (1 + EC_ALPHA * (tempC - 25)); |
Software/so101/src/probes.test.ts added +230
| @@ -0,0 +1,230 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 3 | import { createServer } from "node:http"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { ads1115, AdcError, configWord } from "./ads1115.ts"; | |
| 7 | import { CareLog } from "./care-log.ts"; | |
| 8 | import { calibrateProbe, ProbeCache, probeLines, readProbeView, renderProbes, sampleProbes, type ProbeHooks } from "./care-probes.ts"; | |
| 9 | import { BUILT_IN_DIR, loadProfiles } from "./care-profile.ts"; | |
| 10 | import { handleCare } from "./care-routes.ts"; | |
| 11 | import { PROBES_USAGE, probesCommand } from "./care-terminal.ts"; | |
| 12 | import { emptyRoom, typed } from "./terminal.ts"; | |
| 13 | import { ph, phCalibration } from "./probe-math.ts"; | |
| 14 | import { CalError } from "./probe-math.ts"; | |
| 15 | import { Probes } from "./probes.ts"; | |
| 16 | ||
| 17 | /** A fake board: each input holds a voltage the test sets. */ | |
| 18 | const board = (volts: [number, number]) => async (ch: number) => volts[ch]!; | |
| 19 | ||
| 20 | test("the ADS1115 driver speaks i2c-tools and converts the word to volts", async () => { | |
| 21 | const calls: string[] = []; | |
| 22 | const adc = ads1115({ | |
| 23 | wait: async () => {}, | |
| 24 | run: async (cmd, args) => { | |
| 25 | calls.push(`${cmd} ${args.join(" ")}`); | |
| 26 | return cmd === "i2cset" ? { code: 0, out: "" } : { code: 0, out: "0x3412\n" }; // 0x1234 on the wire, swapped | |
| 27 | }, | |
| 28 | }); | |
| 29 | expect(configWord(0)).toBe(0xc383); | |
| 30 | expect(await adc(1)).toBeCloseTo(0x1234 * 0.000125, 6); | |
| 31 | expect(calls[0]).toBe("i2cset -y 1 0x48 0x01 0x83d3 w"); // 0xd383, byte-swapped for SMBus | |
| 32 | expect(calls[1]).toBe("i2cget -y 1 0x48 0x00 w"); | |
| 33 | await expect(ads1115({ run: async () => ({ code: 1, out: "Error: no device" }) })(0)).rejects.toThrow(AdcError); | |
| 34 | await expect(ads1115()(7)).rejects.toThrow(/inputs 0 to 3/); | |
| 35 | const negative = ads1115({ wait: async () => {}, run: async (cmd) => ({ code: 0, out: cmd === "i2cset" ? "" : "0x00ff" }) }); | |
| 36 | expect(await negative(0)).toBeLessThan(0); | |
| 37 | }); | |
| 38 | ||
| 39 | test("pH is scaled to the water's temperature", () => { | |
| 40 | const cal = phCalibration(2.5, 3.0, 25, 1); // volts rise 0.5 V over 3 pH units going from 7 to 4: -0.1667 V/pH... slope = 0.5/-3 | |
| 41 | expect(ph(2.5, cal, 25)).toBeCloseTo(7, 6); | |
| 42 | expect(ph(3.0, cal, 25)).toBeCloseTo(4, 6); | |
| 43 | expect(ph(2.75, cal, 25)).toBeCloseTo(5.5, 6); | |
| 44 | // warmer water: the probe moves more per pH, so the same voltage off the 7.0 point is fewer pH units away | |
| 45 | expect(Math.abs(ph(3.0, cal, 45) - 7)).toBeLessThan(Math.abs(ph(3.0, cal, 25) - 7)); | |
| 46 | expect(() => phCalibration(2.5, 2.5, 25, 1)).toThrow(CalError); | |
| 47 | }); | |
| 48 | ||
| 49 | test("the guided calibration: no value until calibrated, then pH and EC, kept across a restart", async () => { | |
| 50 | const dir = mkdtempSync(join(tmpdir(), "probes-")); | |
| 51 | try { | |
| 52 | const volts: [number, number] = [2.5, 0.5]; | |
| 53 | const file = join(dir, "probes.json"); | |
| 54 | const probes = new Probes(board(volts), file, () => 1000); | |
| 55 | let v = await probes.view(25); | |
| 56 | expect(v).toMatchObject({ present: true, ph: null, ec: null, next: "ph7", tempSource: "given" }); | |
| 57 | await expect(probes.calibrate("ph4")).rejects.toThrow(/pH 7.0 first/); | |
| 58 | v = await probes.calibrate("ph7"); | |
| 59 | expect(v).toMatchObject({ next: "ph4", pending: true, ph: null }); | |
| 60 | volts[0] = 3.0; | |
| 61 | v = await probes.calibrate("ph4"); | |
| 62 | expect(v.next).toBe("ec1413"); | |
| 63 | expect(v.calibratedAt.ph).toBe(1000); | |
| 64 | expect(v.ph).toBeCloseTo(4, 2); | |
| 65 | v = await probes.calibrate("ec1413"); | |
| 66 | expect(v.ec).toBeCloseTo(1.413, 3); | |
| 67 | expect(v.next).toBeNull(); | |
| 68 | volts[1] = 1.0; | |
| 69 | expect((await new Probes(board(volts), file).view(25)).ec).toBeCloseTo(2.826, 3); | |
| 70 | } finally { | |
| 71 | rmSync(dir, { recursive: true, force: true }); | |
| 72 | } | |
| 73 | }); | |
| 74 | ||
| 75 | test("a probe still settling, a dead board and a bad step are refused with what to do", async () => { | |
| 76 | let n = 0; | |
| 77 | const probes = new Probes(async () => 2.5 + (n++ % 2) * 0.05); | |
| 78 | await expect(probes.calibrate("ph7")).rejects.toThrow(/settling/); | |
| 79 | await expect(probes.calibrate("ph9")).rejects.toThrow(/step is one of/); | |
| 80 | await expect(probes.calibrate("ph7", 90)).rejects.toThrow(/temperature/); | |
| 81 | const dead = new Probes(async () => { throw new AdcError("no ADS1115 at 0x48"); }); | |
| 82 | expect(await dead.view()).toMatchObject({ present: false, ph: null, error: expect.stringContaining("no ADS1115") }); | |
| 83 | await expect(dead.calibrate("ph7")).rejects.toThrow(/no ADS1115/); | |
| 84 | await expect(new Probes(board([0.5, 0.001])).calibrate("ec1413")).rejects.toThrow(/reads nothing/); | |
| 85 | }); | |
| 86 | ||
| 87 | const T0 = Date.parse("2026-10-27T08:00:00Z"); | |
| 88 | const fakeHooks = (probes: Probes): ProbeHooks => ({ | |
| 89 | read: async (t) => readProbeView(JSON.parse(JSON.stringify(await probes.view(t)))), | |
| 90 | calibrate: async (step, tempC) => { | |
| 91 | try { | |
| 92 | return { view: await probes.calibrate(step, tempC) }; | |
| 93 | } catch (err) { | |
| 94 | return { error: (err as Error).message }; | |
| 95 | } | |
| 96 | }, | |
| 97 | }); | |
| 98 | ||
| 99 | test("the sampler logs calibrated readings as sensor readings, compensated by the latest water temperature", async () => { | |
| 100 | const volts: [number, number] = [2.5, 0.5]; | |
| 101 | const probes = new Probes(board(volts), undefined, () => T0); | |
| 102 | let clock = T0; | |
| 103 | const log = new CareLog(undefined, () => clock); | |
| 104 | const hooks = new ProbeCache(fakeHooks(probes), () => undefined); | |
| 105 | expect(await sampleProbes(log, hooks, T0)).toEqual([]); // uncalibrated: nothing | |
| 106 | await probes.calibrate("ph7"); | |
| 107 | volts[0] = 3.0; | |
| 108 | await probes.calibrate("ph4"); | |
| 109 | await probes.calibrate("ec1413"); | |
| 110 | log.add({ type: "measurement", kind: "waterTemp", value: 19 }); | |
| 111 | expect(await sampleProbes(log, hooks, T0)).toEqual(["ph", "ec"]); | |
| 112 | const sensor = log.entries().filter((e) => e.type === "measurement" && e.source === "sensor"); | |
| 113 | expect(sensor.map((e) => e.type === "measurement" && e.kind)).toEqual(["ph", "ec"]); | |
| 114 | expect(await sampleProbes(log, hooks, T0 + 60_000)).toEqual([]); // the book keeps one sensor reading an hour per kind | |
| 115 | clock = T0 + 2 * 3_600_000; | |
| 116 | volts[0] = 9; // far off the scale: a broken probe is not a measurement | |
| 117 | expect(await sampleProbes(log, hooks, clock)).toEqual(["ec"]); | |
| 118 | expect(await sampleProbes(log, new ProbeCache({ read: async () => null, calibrate: async () => ({ error: "x" }) }, () => undefined), T0)).toEqual([]); | |
| 119 | }); | |
| 120 | ||
| 121 | test("the care routes walk the calibration for the owner only, and finishing it marks the monthly reminder done", async () => { | |
| 122 | const volts: [number, number] = [2.5, 0.5]; | |
| 123 | const probes = new Probes(board(volts), undefined, () => T0); | |
| 124 | const log = new CareLog(undefined, () => T0); | |
| 125 | const profile = loadProfiles(BUILT_IN_DIR).find((p) => p.id === "spinach")!; | |
| 126 | let owner = true; | |
| 127 | const cache = new ProbeCache(fakeHooks(probes), () => undefined, () => log.notify()); | |
| 128 | await cache.refresh(); | |
| 129 | const server = createServer((req, res) => { | |
| 130 | void handleCare(req, res, new URL(req.url!, "http://x").pathname, { | |
| 131 | profile, log, probes: cache, now: () => T0, | |
| 132 | who: () => ({ owner, signedIn: null, known: owner }), | |
| 133 | readBody: async (r) => Buffer.concat(await Array.fromAsync(r)), | |
| 134 | }); | |
| 135 | }); | |
| 136 | await new Promise<void>((r) => server.listen(0, "127.0.0.1", r)); | |
| 137 | const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`; | |
| 138 | const step = (s: string, tempC = 21) => fetch(`${base}/care/probes/calibrate`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ step: s, tempC }) }); | |
| 139 | try { | |
| 140 | const page = await (await fetch(`${base}/care`)).text(); | |
| 141 | expect(page).toContain('data-probes="present"'); | |
| 142 | expect(page).toContain("pH 7.0 buffer"); | |
| 143 | expect(page).toContain('action="/care/probes/calibrate"'); | |
| 144 | owner = false; | |
| 145 | expect((await step("ph7")).status).toBe(401); | |
| 146 | expect(await (await fetch(`${base}/care`)).text()).not.toContain('action="/care/probes/calibrate"'); | |
| 147 | owner = true; | |
| 148 | expect((await (await step("ph4")).json()) as unknown).toMatchObject({ error: expect.stringContaining("pH 7.0 first") }); | |
| 149 | expect((await step("ph7")).status).toBe(200); | |
| 150 | volts[0] = 3.0; | |
| 151 | expect((await step("ph4")).status).toBe(200); | |
| 152 | expect(log.entries().some((e) => e.type === "action" && e.what === "done")).toBe(false); | |
| 153 | const last = await step("ec1413"); | |
| 154 | expect(last.status).toBe(200); | |
| 155 | expect(log.entries().find((e) => e.type === "action" && e.what === "done")).toMatchObject({ ref: "calibrate" }); | |
| 156 | // a recalibration that finishes the guide again is a calibration again: one more done entry each time | |
| 157 | expect(log.entries().filter((e) => e.type === "action" && e.what === "done")).toHaveLength(1); | |
| 158 | expect((await step("ec1413")).status).toBe(200); | |
| 159 | expect(log.entries().filter((e) => e.type === "action" && e.what === "done")).toHaveLength(2); | |
| 160 | const seen = (await (await fetch(`${base}/care/probes`)).json()) as { view: { ph: number }; ageMs: number }; | |
| 161 | expect(seen.view.ph).toBeCloseTo(4, 1); | |
| 162 | expect(seen.ageMs).toBeGreaterThanOrEqual(0); | |
| 163 | } finally { | |
| 164 | server.close(); | |
| 165 | } | |
| 166 | }); | |
| 167 | ||
| 168 | test("the panel says so when no board answers, and what the Pi sends is read field by field", () => { | |
| 169 | expect(renderProbes(null, true)).toBe(""); | |
| 170 | const absent = readProbeView({ present: false, error: "<b>no board</b>", tempC: 25, tempSource: "default", calibratedAt: {}, next: "ph7" })!; | |
| 171 | const html = renderProbes({ view: absent, ageMs: 0 }, true); | |
| 172 | expect(html).toContain("No probe board answering"); | |
| 173 | expect(html).not.toContain("<b>"); | |
| 174 | expect(readProbeView({ present: "yes" })).toBeNull(); | |
| 175 | expect(readProbeView({ present: true, tempC: 25, next: "rm -rf", calibratedAt: { ph: "x" } })).toMatchObject({ next: null, calibratedAt: { ph: null, ec: null } }); | |
| 176 | }); | |
| 177 | ||
| 178 | test("the care page never waits on the Pi: a slow Pi shows the cached view with its age, and a refresh follows", async () => { | |
| 179 | let t = 0; | |
| 180 | let calls = 0; | |
| 181 | const view = readProbeView({ present: true, error: null, ph: 6.1, ec: 1.9, tempC: 25, tempSource: "default", calibratedAt: { ph: T0, ec: T0 }, next: null, pending: false })!; | |
| 182 | const slow: ProbeHooks = { read: async () => { calls++; await Bun.sleep(300); return view; }, calibrate: async () => ({ error: "x" }) }; | |
| 183 | let changes = 0; | |
| 184 | const cache = new ProbeCache(slow, () => undefined, () => changes++, 5000, () => t); | |
| 185 | const began = performance.now(); | |
| 186 | expect(cache.peek()).toBeNull(); // nothing yet: the page paints without the panel | |
| 187 | expect(performance.now() - began).toBeLessThan(50); | |
| 188 | await cache.refresh(); | |
| 189 | expect(changes).toBe(1); | |
| 190 | t = 3000; | |
| 191 | expect(cache.peek()!.ageMs).toBe(3000); | |
| 192 | expect(calls).toBe(1); // inside the TTL: the Pi is not asked | |
| 193 | expect(renderProbes(cache.peek(), false)).toContain("Read 3 s ago"); | |
| 194 | t = 9000; | |
| 195 | cache.peek(); | |
| 196 | cache.peek(); | |
| 197 | expect(calls).toBe(2); // stale: one background refresh, shared | |
| 198 | await cache.refresh(); | |
| 199 | expect(changes).toBe(1); // the same news does not wake every viewer | |
| 200 | const down = new ProbeCache({ read: async () => null, calibrate: async () => ({ error: "x" }) }, () => undefined); | |
| 201 | await down.refresh(); | |
| 202 | expect(down.peek()).toBeNull(); | |
| 203 | }); | |
| 204 | ||
| 205 | test("the terminal door: /probes reads, /probes calibrate asks what the page's button asks, and viewers hear of it", async () => { | |
| 206 | expect(typed(emptyRoom(), "/probes")).toEqual({ care: { method: "GET", path: "/care/probes" } }); | |
| 207 | expect(typed(emptyRoom(), "/probes calibrate ph7 21.5")).toEqual({ care: { method: "POST", path: "/care/probes/calibrate", body: { step: "ph7", tempC: 21.5 } } }); | |
| 208 | expect(typed(emptyRoom(), "/probes calibrate ec1413")).toEqual({ care: { method: "POST", path: "/care/probes/calibrate", body: { step: "ec1413" } } }); | |
| 209 | for (const bad of ["/probes calibrate", "/probes calibrate ph9", "/probes calibrate ph7 hot", "/probes frob"]) expect(typed(emptyRoom(), bad)).toEqual({ print: PROBES_USAGE }); | |
| 210 | expect(probesCommand([])).toEqual({ method: "GET", path: "/care/probes" }); | |
| 211 | ||
| 212 | const volts: [number, number] = [2.5, 0.5]; | |
| 213 | const probes = new Probes(board(volts), undefined, () => T0); | |
| 214 | const log = new CareLog(undefined, () => T0); | |
| 215 | const heard: number[] = []; | |
| 216 | log.onChange(() => heard.push(1)); | |
| 217 | const cache = new ProbeCache(fakeHooks(probes), () => undefined, () => log.notify()); | |
| 218 | expect(await calibrateProbe(log, cache, "ph7", 21)).toHaveProperty("view"); | |
| 219 | expect(heard.length).toBeGreaterThan(0); // an unfinished step still reaches the event stream | |
| 220 | expect(log.entries().some((e) => e.type === "action" && e.what === "done")).toBe(false); | |
| 221 | expect(await calibrateProbe(log, cache, "ph9", 21)).toHaveProperty("error"); | |
| 222 | volts[0] = 3.0; | |
| 223 | await calibrateProbe(log, cache, "ph4", 21); | |
| 224 | await calibrateProbe(log, cache, "ec1413", 21); | |
| 225 | expect(log.entries().filter((e) => e.type === "action" && e.what === "done")).toHaveLength(1); | |
| 226 | const lines = probeLines({ view: await probes.view(21), ageMs: 4000 }); | |
| 227 | expect(lines.join("\n")).toContain("Both probes are calibrated"); | |
| 228 | expect(lines[2]).toContain("4 s ago"); | |
| 229 | expect(probeLines({ view: null })).toEqual(["probes: the Pi did not answer"]); | |
| 230 | }); |
Software/so101/src/probes.ts added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | /** | |
| 2 | * The pH and EC probes on the ADS1115 (input 0 and 1): live readings from a calibration kept in the data dir | |
| 3 | * (probes.json), and the guided calibration that makes it. Runs on the Pi beside the ADC; the care app reads it over | |
| 4 | * GET /probes and walks the owner through POST /probes/calibrate. | |
| 5 | * | |
| 6 | * An uncalibrated probe gives no value at all, never a guess: a reading nobody can trust must not reach a dose. | |
| 7 | */ | |
| 8 | import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | |
| 9 | import { dirname } from "node:path"; | |
| 10 | import { AdcError, type Adc } from "./ads1115.ts"; | |
| 11 | import { CalError, DEFAULT_TEMP, ec, ecCalibration, ph, phCalibration, type EcCal, type PhCal } from "./probe-math.ts"; | |
| 12 | ||
| 13 | export const CHANNELS = { ph: 0, ec: 1 } as const; | |
| 14 | /** The calibration steps in the order the guide gives them. */ | |
| 15 | export const STEPS = ["ph7", "ph4", "ec1413"] as const; | |
| 16 | export type Step = (typeof STEPS)[number]; | |
| 17 | ||
| 18 | const SAMPLES = 12; | |
| 19 | /** A probe still settling wanders more than this between samples (volts). */ | |
| 20 | const SETTLED = 0.006; | |
| 21 | ||
| 22 | interface Saved { | |
| 23 | ph?: PhCal; | |
| 24 | ec?: EcCal; | |
| 25 | /** The pH 7.0 capture waiting for its pH 4.0. */ | |
| 26 | pending?: { v7: number; tempC: number; at: number }; | |
| 27 | } | |
| 28 | ||
| 29 | export interface ProbeView { | |
| 30 | present: boolean; | |
| 31 | error: string | null; | |
| 32 | ph: number | null; | |
| 33 | ec: number | null; | |
| 34 | /** Which temperature compensated the values; "default" when none was given. */ | |
| 35 | tempC: number; | |
| 36 | tempSource: "given" | "default"; | |
| 37 | calibratedAt: { ph: number | null; ec: number | null }; | |
| 38 | /** What the guide asks next. */ | |
| 39 | next: Step | null; | |
| 40 | pending: boolean; | |
| 41 | } | |
| 42 | ||
| 43 | export class Probes { | |
| 44 | private data: Saved = {}; | |
| 45 | private readonly adc: Adc; | |
| 46 | private readonly file: string | undefined; | |
| 47 | private readonly clock: () => number; | |
| 48 | constructor(adc: Adc, file?: string, clock: () => number = Date.now) { | |
| 49 | this.adc = adc; | |
| 50 | this.file = file; | |
| 51 | this.clock = clock; | |
| 52 | if (file && existsSync(file)) { | |
| 53 | try { | |
| 54 | this.data = JSON.parse(readFileSync(file, "utf8")); | |
| 55 | } catch (err) { | |
| 56 | console.error(`probes: ${file} unreadable (${(err as Error).message}); starting uncalibrated`); | |
| 57 | } | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | private save() { | |
| 62 | if (!this.file) return; | |
| 63 | mkdirSync(dirname(this.file), { recursive: true }); | |
| 64 | writeFileSync(`${this.file}.tmp`, JSON.stringify(this.data)); | |
| 65 | renameSync(`${this.file}.tmp`, this.file); | |
| 66 | } | |
| 67 | ||
| 68 | /** The mean of several samples; refuses while the probe is still settling. */ | |
| 69 | private async steady(channel: number): Promise<number> { | |
| 70 | const v: number[] = []; | |
| 71 | for (let i = 0; i < SAMPLES; i++) v.push(await this.adc(channel)); | |
| 72 | const mean = v.reduce((a, b) => a + b, 0) / v.length; | |
| 73 | if (Math.max(...v) - Math.min(...v) > SETTLED) throw new CalError("the probe is still settling: give it another minute in the solution, then try again"); | |
| 74 | return mean; | |
| 75 | } | |
| 76 | ||
| 77 | next(): Step | null { | |
| 78 | if (this.data.pending) return "ph4"; | |
| 79 | if (!this.data.ph) return "ph7"; | |
| 80 | return this.data.ec ? null : "ec1413"; | |
| 81 | } | |
| 82 | ||
| 83 | async view(tempC?: number): Promise<ProbeView> { | |
| 84 | const temp = tempC !== undefined && tempC > 0 && tempC < 45 ? tempC : DEFAULT_TEMP; | |
| 85 | const base = { tempC: temp, tempSource: temp === tempC ? ("given" as const) : ("default" as const), calibratedAt: { ph: this.data.ph?.at ?? null, ec: this.data.ec?.at ?? null }, next: this.next(), pending: !!this.data.pending }; | |
| 86 | try { | |
| 87 | // Both inputs are read even when uncalibrated: that is how an absent board shows. | |
| 88 | const [vp, ve] = [await this.adc(CHANNELS.ph), await this.adc(CHANNELS.ec)]; | |
| 89 | const p = this.data.ph ? ph(vp, this.data.ph, temp) : null; | |
| 90 | const e = this.data.ec ? ec(ve, this.data.ec, temp) : null; | |
| 91 | return { ...base, present: true, error: null, ph: p === null ? null : Math.round(p * 100) / 100, ec: e === null ? null : Math.round(e * 1000) / 1000 }; | |
| 92 | } catch (err) { | |
| 93 | if (!(err instanceof AdcError)) throw err; | |
| 94 | return { ...base, present: false, error: err.message, ph: null, ec: null }; | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| 98 | /** One guided step, with the buffer's temperature; throws CalError saying what to do. */ | |
| 99 | async calibrate(step: unknown, tempC: unknown = DEFAULT_TEMP): Promise<ProbeView> { | |
| 100 | if (!STEPS.includes(step as Step)) throw new CalError(`the step is one of ${STEPS.join(", ")}`); | |
| 101 | if (typeof tempC !== "number" || !(tempC > 0 && tempC < 45)) throw new CalError("the solution's temperature is 0 to 45 °C"); | |
| 102 | const now = this.clock(); | |
| 103 | try { | |
| 104 | if (step === "ph7") { | |
| 105 | this.data.pending = { v7: await this.steady(CHANNELS.ph), tempC, at: now }; | |
| 106 | } else if (step === "ph4") { | |
| 107 | if (!this.data.pending) throw new CalError("do pH 7.0 first"); | |
| 108 | const v4 = await this.steady(CHANNELS.ph); | |
| 109 | this.data.ph = phCalibration(this.data.pending.v7, v4, (this.data.pending.tempC + tempC) / 2, now); | |
| 110 | delete this.data.pending; | |
| 111 | } else { | |
| 112 | this.data.ec = ecCalibration(await this.steady(CHANNELS.ec), tempC, now); | |
| 113 | } | |
| 114 | } catch (err) { | |
| 115 | if (err instanceof AdcError) throw new CalError(err.message); | |
| 116 | throw err; | |
| 117 | } | |
| 118 | this.save(); | |
| 119 | return this.view(tempC); | |
| 120 | } | |
| 121 | } |
Software/so101/src/relay.ts +32 −3
| @@ -108,6 +108,7 @@ import { CameraError, CameraHub } from "./cameras.ts"; | ||
| 108 | 108 | import { ARM_ONLY, StationError, StationHub } from "./stations.ts"; |
| 109 | 109 | import { CareLog } from "./care-log.ts"; |
| 110 | 110 | import { BUILT_IN_DIR, loadProfiles, type Profile } from "./care-profile.ts"; |
| 111 | import { ProbeCache, readProbeView, sampleProbes, waterTemp, type ProbeHooks } from "./care-probes.ts"; | |
| 111 | 112 | import { handleCare } from "./care-routes.ts"; |
| 112 | 113 | import { renderWorkshop, type StationView } from "./workshop-page.ts"; |
| 113 | 114 | import { DAY_MS, GrowStills, KEEP_DAYS } from "./grow-stills.ts"; |
| @@ -164,6 +165,8 @@ export interface RelayOptions { | ||
| 164 | 165 | stations?: StationHub; |
| 165 | 166 | /** The plant care app's log book and plant profile (src/care-routes.ts); by default in memory, with the built-in spinach profile. */ |
| 166 | 167 | care?: { log: CareLog; profile: Profile }; |
| 168 | /** How often the Pi's pH/EC probes are read into the care log, ms; unset, they are not sampled (the page still shows them and walks the calibration). */ | |
| 169 | probeEveryMs?: number; | |
| 167 | 170 | /** 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. */ |
| 168 | 171 | grow?: { stills: GrowStills; log: GrowLog; everyMs: number }; |
| 169 | 172 | /** How often the arm's sensors are pulled into the care log book, ms; unset, never. */ |
| @@ -239,9 +242,9 @@ const PAGE = /^\/(?:index\.html|arm)?$/; | ||
| 239 | 242 | const STATIC = /^\/(?:index\.html)?$|^\/(?:model|vendor|ui)\//; |
| 240 | 243 | const LEADER = "/leader/calibration"; |
| 241 | 244 | /** Admins only: clearing an emergency stop and changing the settings (the safety limits among them). */ |
| 242 | const ADMIN_ONLY = new Set(["POST /estop/clear", "PUT /settings", "POST /settings/table-here", "POST /settings/table-touch", "POST /settings/joint-zero", "PUT /servo-params", "POST /servo-params/factory", "POST /tuning/p", "POST /grow/light", "POST /grow/pump"]); | |
| 245 | const ADMIN_ONLY = new Set(["POST /estop/clear", "PUT /settings", "POST /settings/table-here", "POST /settings/table-touch", "POST /settings/joint-zero", "PUT /servo-params", "POST /servo-params/factory", "POST /tuning/p", "POST /grow/light", "POST /grow/pump", "POST /probes/calibrate"]); | |
| 243 | 246 | /** Routes only the owner may reach; anyone else is told there is no such page. */ |
| 244 | const OWNER_READS = new Set(["GET /servo-params", "PUT /servo-params", "POST /servo-params/factory", "GET /tuning", "POST /tuning/p", "GET /grow", "POST /grow/light", "POST /grow/pump"]); | |
| 247 | const OWNER_READS = new Set(["GET /servo-params", "PUT /servo-params", "POST /servo-params/factory", "GET /tuning", "POST /tuning/p", "GET /grow", "POST /grow/light", "POST /grow/pump", "GET /probes", "POST /probes/calibrate"]); | |
| 245 | 248 | /** The driver's, and any admin's at any time. */ |
| 246 | 249 | const ADMIN_TOO = new Set(["POST /stop", "POST /resume", "POST /estop"]); |
| 247 | 250 | /** The forge's own paths (/__forge/signin and the like): the forge answers them in front of the app. */ |
| @@ -612,6 +615,29 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 612 | 615 | }; |
| 613 | 616 | /** The grow light lives on the arm server (the Pi runs its schedule); this reads and changes it for the admin page. */ |
| 614 | 617 | const armGrow = (path = "") => `${remote && onArm() ? remote.http : simulation}/grow${path}`; |
| 618 | /** The pH/EC probes live on the arm server beside the ADC (src/probes.ts); the care page reads and calibrates them through this. */ | |
| 619 | const probeHooks: ProbeHooks = { | |
| 620 | read: async (tempC) => { | |
| 621 | try { | |
| 622 | const answer = await fetch(`${remote && onArm() ? remote.http : simulation}/probes${tempC === undefined ? "" : `?temp=${tempC}`}`, { cache: "no-store", signal: AbortSignal.timeout(Math.min(readTimeoutMs, 1500)) }); | |
| 623 | return answer.ok ? readProbeView(await answer.json().catch(() => null)) : null; | |
| 624 | } catch { | |
| 625 | return null; | |
| 626 | } | |
| 627 | }, | |
| 628 | calibrate: async (step, tempC) => { | |
| 629 | try { | |
| 630 | const answer = await fetch(`${remote && onArm() ? remote.http : simulation}/probes/calibrate`, { method: "POST", body: JSON.stringify({ step, tempC }), signal: AbortSignal.timeout(Math.min(readTimeoutMs, 8000)) }); | |
| 631 | const body = (await answer.json().catch(() => null)) as { error?: unknown } | null; | |
| 632 | const view = answer.ok ? readProbeView(body) : null; | |
| 633 | if (view) return { view }; | |
| 634 | return { error: typeof body?.error === "string" ? body.error.replace(/[\p{Cc}\p{Cf}]/gu, "").slice(0, 200) : `the arm server answered ${answer.status}` }; | |
| 635 | } catch (err) { | |
| 636 | return { error: `the arm server did not answer (${reason(err)})` }; | |
| 637 | } | |
| 638 | }, | |
| 639 | }; | |
| 640 | const probeCache = new ProbeCache(probeHooks, () => waterTemp(care.log, Date.now()), () => care.log.notify()); | |
| 615 | 641 | const lightHooks: LightHooks = { |
| 616 | 642 | read: async () => { |
| 617 | 643 | try { |
| @@ -1073,6 +1099,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1073 | 1099 | sensorTimer?.unref?.(); |
| 1074 | 1100 | const stillTimer = options.grow ? setInterval(() => void takeStill(), options.grow.everyMs) : undefined; |
| 1075 | 1101 | stillTimer?.unref?.(); |
| 1102 | const probeTimer = options.probeEveryMs ? setInterval(() => void sampleProbes(care.log, probeCache).catch((err) => console.error(`probes: ${reason(err)}`)), options.probeEveryMs) : undefined; | |
| 1103 | probeTimer?.unref?.(); | |
| 1076 | 1104 | |
| 1077 | 1105 | const http = createServer(async (req, res) => { |
| 1078 | 1106 | for (const [name, value] of Object.entries(FLOOR)) res.setHeader(name, value); |
| @@ -1096,7 +1124,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1096 | 1124 | const person = lobby ? lobby.who(r) : LOCAL_PERSON; |
| 1097 | 1125 | return { owner: Boolean(person?.owner), known: Boolean(person), signedIn: lobby && !lobby.local ? lobby.signedIn(r) : null }; |
| 1098 | 1126 | }; |
| 1099 | if (await handleCare(req, res, url.pathname, { ...care, who, readBody })) return; | |
| 1127 | if (await handleCare(req, res, url.pathname, { ...care, probes: probeCache, who, readBody })) return; | |
| 1100 | 1128 | } |
| 1101 | 1129 | 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 }))); |
| 1102 | 1130 | const cameraAdmin = ADMIN_CAMERA.exec(url.pathname); |
| @@ -1572,6 +1600,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1572 | 1600 | chatFacts?.(); |
| 1573 | 1601 | clearInterval(sampleTimer); |
| 1574 | 1602 | clearInterval(stillTimer); |
| 1603 | clearInterval(probeTimer); | |
| 1575 | 1604 | clearInterval(sensorTimer); |
| 1576 | 1605 | readBacks?.(); |
| 1577 | 1606 | arrived?.(); |
Software/so101/src/server.ts +22
| @@ -51,6 +51,8 @@ | ||
| 51 | 51 | * GET /grow the grow light and the pump (src/light.ts): schedule, switch driver, "On until 22:00", override and the last transitions |
| 52 | 52 | * GET /grow/sensors the sensors listed in sensors.json (src/sensors.ts: ds18b20, sht31, bme280, float): each one's last value, when, and why it failed; and their Readings |
| 53 | 53 | * POST /grow/light {"schedule"?: {"onAt": "06:00", "hours": 12}|null, "switch"?: {"driver": "shelly"|"gpio"|"uhubctl", ...}|null} or {"override": "on"|"off"|"clear"}; 400 says why not |
| 54 | * GET /probes the pH and EC probes on the ADS1115 (src/probes.ts): values (null until calibrated), ?temp= compensates, and the next calibration step | |
| 55 | * POST /probes/calibrate {"step": "ph7"|"ph4"|"ec1413", "tempC"?: number}; 400 says what to do | |
| 54 | 56 | * POST /grow/pump the same, with an interval schedule {"onMinutes": 5, "offMinutes": 25} |
| 55 | 57 | * GET /tuning the run's phase while one is under way, and the last outcome |
| 56 | 58 | * 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) |
| @@ -101,6 +103,9 @@ import { MachineLog, perServo } from "./machine-log.ts"; | ||
| 101 | 103 | import { Metrics } from "./metrics.ts"; |
| 102 | 104 | import { wallMs } from "./clock.ts"; |
| 103 | 105 | import { readPiHealth } from "./pi-health.ts"; |
| 106 | import { ads1115 } from "./ads1115.ts"; | |
| 107 | import { CalError } from "./probe-math.ts"; | |
| 108 | import { Probes } from "./probes.ts"; | |
| 104 | 109 | import { loadSensors, Sensors } from "./sensors.ts"; |
| 105 | 110 | import { DEVICES, Light, LightError, type DeviceName } from "./light.ts"; |
| 106 | 111 | import { ARRIVE_TICKS, LINK_HOPS, parseSnapBack, READ_BACK_HOP, RELAY_HOP, type Cause, type Tracker } from "./precision.ts"; |
| @@ -141,6 +146,8 @@ export interface ServerOptions { | ||
| 141 | 146 | lightMs?: number; |
| 142 | 147 | /** The grow light and the pump, for tests that give them a fake switch. */ |
| 143 | 148 | lights?: Record<DeviceName, Light>; |
| 149 | /** The pH/EC probes on the ADS1115, for tests that give them a fake ADC. */ | |
| 150 | probes?: Probes; | |
| 144 | 151 | /** How often the sensors (src/sensors.ts, listed in sensors.json) are read, ms; unset, they are not run. */ |
| 145 | 152 | sensorsMs?: number; |
| 146 | 153 | /** The sensors, for tests that give them fake sources; by default those listed in sensors.json under the data dir. */ |
| @@ -247,6 +254,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 247 | 254 | } |
| 248 | 255 | const metrics = new Metrics(join(dataDir, "metrics.jsonl")); |
| 249 | 256 | const lights = options.lights ?? { light: new Light({ name: "light", file: join(dataDir, "light.json") }), pump: new Light({ name: "pump", file: join(dataDir, "pump.json") }) }; |
| 257 | const probes = options.probes ?? new Probes(ads1115(), join(dataDir, "probes.json")); | |
| 250 | 258 | const sensors = options.sensors ?? loadSensors(join(dataDir, "sensors.json")); |
| 251 | 259 | const precision = new PrecisionHistory({ file: join(dataDir, "precision.sqlite"), commit: options.commit }); |
| 252 | 260 | /** The stream's precision tracker while one drives; every Goal_Position write starts its bus-to-motion clock. */ |
| @@ -988,6 +996,20 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 988 | 996 | } |
| 989 | 997 | return json(res, 200, device.view()); |
| 990 | 998 | } |
| 999 | case "GET /probes": { | |
| 1000 | const t = url.searchParams.get("temp"); | |
| 1001 | return json(res, 200, await probes.view(t !== null && /^\d{1,2}(\.\d{1,3})?$/.test(t) ? Number(t) : undefined)); | |
| 1002 | } | |
| 1003 | case "POST /probes/calibrate": { | |
| 1004 | const { step, tempC, ...rest } = await readBody(req); | |
| 1005 | if (Object.keys(rest).length) throw new HttpError(400, `no field "${Object.keys(rest)[0]!.slice(0, 40)}"`); | |
| 1006 | try { | |
| 1007 | return json(res, 200, await probes.calibrate(step, tempC)); | |
| 1008 | } catch (err) { | |
| 1009 | if (err instanceof CalError) throw new HttpError(400, err.message); | |
| 1010 | throw err; | |
| 1011 | } | |
| 1012 | } | |
| 991 | 1013 | case "GET /tuning": |
| 992 | 1014 | return json(res, 200, tuning.state); |
| 993 | 1015 | case "POST /tuning/p": { |
Software/so101/src/terminal.ts +9 −3
| @@ -35,6 +35,7 @@ | ||
| 35 | 35 | * /align [joint] admin: list each joint's reference pose and offset; with a joint, record it at its reference (POST /settings/joint-zero) and say Done or record again |
| 36 | 36 | * /health owner or moderator: each open page's link as it last reported it (GET /admin/health, the page's Admin -> Health) |
| 37 | 37 | * /stations [set <json>] the workshop's stations (GET /stations); the owner replaces them with a JSON list (PUT /admin/stations) |
| 38 | * /probes [calibrate <ph7|ph4|ec1413> [tempC]] the pH/EC probes (GET /care/probes); anyone reads, the owner calibrates (POST /care/probes/calibrate) | |
| 38 | 39 | * /care [log|did|note|problem|done|snooze|delete …] the plant care app (GET /care/api); anyone reads, the owner writes (POST /care/log, /care/reminder) |
| 39 | 40 | * /versions owner or moderator: the app's and the Pi's commits and the last sync (GET /admin/versions) |
| 40 | 41 | * /trace <session> owner or moderator: a kept session's 5 Hz trace, per joint (GET /precision/trace) |
| @@ -72,7 +73,8 @@ import type { DeviceName, LightView } from "./light.ts"; | ||
| 72 | 73 | import { readTuning, tuningSentence, type TuningView } from "./tuning-panel.ts"; |
| 73 | 74 | import { JOINTS } from "./sts3215.ts"; |
| 74 | 75 | import { headlineValue, hopLines, NO_HOPS, P95_RULE, readPrecision, sampleSize, sparkline, type PrecisionPanel } from "./precision-panel.ts"; |
| 75 | import { careCommand, careLines, type CareAsk } from "./care-terminal.ts"; | |
| 76 | import { probeLines } from "./care-probes.ts"; | |
| 77 | import { careCommand, careLines, probesCommand, type CareAsk } from "./care-terminal.ts"; | |
| 76 | 78 | import { presenceText } from "./ui/lobby-panel.js"; |
| 77 | 79 | import { entryLine, jointRows, linkRows, stateRows } from "./ui/machine.js"; |
| 78 | 80 | |
| @@ -193,7 +195,7 @@ export function careReader(env: Env, fetcher: typeof fetch = fetch): ReadCare { | ||
| 193 | 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) }); |
| 194 | 196 | const body = (await r.json().catch(() => null)) as unknown; |
| 195 | 197 | if (!r.ok) return { error: plain((body as { error?: unknown } | null)?.error ?? `status ${r.status}`), lines: [] }; |
| 196 | return { error: null, lines: write ? ["care saved"] : careLines(body).map(plain) }; | |
| 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) }; | |
| 197 | 199 | } catch (err) { |
| 198 | 200 | return { error: plain((err as Error).message), lines: [] }; |
| 199 | 201 | } |
| @@ -545,7 +547,7 @@ export function whoami(room: Room): string { | ||
| 545 | 547 | /** The terminal holds no session to end: the forge signs who is at the keyboard afresh on every connection. */ |
| 546 | 548 | export const SIGNOUT_NOTE = "the terminal keeps no session: you are whoever your SSH key signs in as, each time you connect; /quit to leave, and connect with another key to be someone else"; |
| 547 | 549 | |
| 548 | export const HELP = "type to chat · /join /leave /ready /extend · /stop /resume (driver, owner) · /end [who] /table here|touch 1|2|3|<mm> /zero <joint> [deg] /align [joint] (owner) · /mute <who> /unmute <who> (owner, moderator) · /delete <who|#id> (owner, moderator) · /report <who|#id> · /reports /dismiss #<id> (owner, moderator) · /removed /restore #<id> /visitors (owner) · /close [note] /open (owner) · /health /precision /trace <session> /data [hour|day|week|all|#<turn>] /versions (owner, moderator) · /servos /tune [<joint> <P>] /grow [light|pump …] (owner) /grow log|planted <date>|note <text> · /care [log|did|note|problem|done|snooze|delete …] · /stations [set <json>] (set: owner) · /machine [off] · /who /whoami · /quit"; | |
| 550 | export const HELP = "type to chat · /join /leave /ready /extend · /stop /resume (driver, owner) · /end [who] /table here|touch 1|2|3|<mm> /zero <joint> [deg] /align [joint] (owner) · /mute <who> /unmute <who> (owner, moderator) · /delete <who|#id> (owner, moderator) · /report <who|#id> · /reports /dismiss #<id> (owner, moderator) · /removed /restore #<id> /visitors (owner) · /close [note] /open (owner) · /health /precision /trace <session> /data [hour|day|week|all|#<turn>] /versions (owner, moderator) · /servos /tune [<joint> <P>] /grow [light|pump …] (owner) /grow log|planted <date>|note <text> · /care [log|did|note|problem|done|snooze|delete …] · /probes [calibrate <ph7|ph4|ec1413> [tempC]] (calibrate: owner) · /stations [set <json>] (set: owner) · /machine [off] · /who /whoami · /quit"; | |
| 549 | 551 | |
| 550 | 552 | /** The Machine feed's socket beside the lobby's: same app, same port. */ |
| 551 | 553 | export const machineUrl = (live: string): string => live.replace(/\/live$/, "/machine"); |
| @@ -718,6 +720,10 @@ export function typed(room: Room, input: string): Typed { | ||
| 718 | 720 | const r = careCommand([arg, ...rest].filter(Boolean), line.replace(/^\/\S+\s+\S+\s*/, "").trim()); |
| 719 | 721 | return "method" in r ? { care: r } : r; |
| 720 | 722 | } |
| 723 | if (/^probes$/i.test(verb)) { | |
| 724 | const r = probesCommand([arg, ...rest].filter(Boolean)); | |
| 725 | return "method" in r ? { care: r } : r; | |
| 726 | } | |
| 721 | 727 | if (/^align$/i.test(verb)) { |
| 722 | 728 | if (rest.length || (arg && !REFERENCES.some((r) => r.joint === arg))) return { print: `usage: /align [${REFERENCES.map((r) => r.joint).join("|")}]` }; |
| 723 | 729 | return { align: arg || null }; |
Inline style="margin-top:0" in markup. Use a class named in docs/design/areas/ and run design-lint, in case the CSP blocks inline styles.