Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
Track the light's real day: lightOn/lightOff in the care log, off-due from the actual on-time (today: on 8:30 → off 20:30) #640
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
smith/arm:smith/639-track-the-light-s-real-day-lighton-light → main 3 commits fork 6 threads · 6 unresolved
Software/so101/README.md +3 −3
| @@ -38,7 +38,7 @@ Closing never locks the owner out. Signing in and out keep working while closed, | ||
| 38 | 38 | |
| 39 | 39 | **Good night and Wake up (owner).** Two taps (or two terminal lines) wrap the same switch (src/night-switch.ts). **Good night** (`/admin` → Open / Closed, or `/goodnight [note]`) closes the room with the note "The saplings are sleeping…" and touches nothing else: the grow light keeps running on its schedule, because the arm holds a manual override until the schedule's next change and a forgotten "off" would darken the spinach. **Wake up the garden** (the same section, the card's owner row, or `/wake`) first sets the light's override to on (`POST /grow/light`), and opens the room only when that took; a refused or unreachable light write leaves the room closed and says why, so the room and the light never disagree. The override on the arm ends at the schedule's next transition, then the schedule resumes (with no schedule it holds, a week at most). With no light switch set up it opens the room and says to turn the light on by hand. The terminal door is `POST /admin/night {"action": "wake"|"goodnight", "note"?}`, owner only, same-origin like every other post. While closed, the card shows "Back at sunrise: about 6:00" from the light's schedule. |
| 40 | 40 | |
| 41 | **Morning check-in (owner).** Wake up lands on `/care/checkin`: one screen of taps, each writing one typed entry in the care log (water: cubes moist / topped up / too wet; lid: on / cracked / off; a sprout count 0 to 20, the `sprouts` measurement charted on `/care`; a photo note). The line at the top comes from a short code (`?light=on|manual`), never from the query's words. In the terminal: `/care checkin water <moist|topped|wet>`, `lid <on|cracked|off>`, `sprouts <0-20>`, `note <text>`. All doors use one function (`checkinEntry`, src/care-checkin.ts), and a write reaches open `/care` pages live through `/care/events`. | |
| 41 | **Morning check-in (owner).** Wake up lands on `/care/checkin`: one screen of taps, each writing one typed entry in the care log (water: cubes moist / topped up / too wet; lid: on / cracked / off; a sprout count 0 to 20, the `sprouts` measurement charted on `/care`; a photo note; a **Light on** / **Light off** button, which reads the opposite of the last light action, with an "at…" time to log a late one). The line at the top comes from a short code (`?light=on|manual`), never from the query's words. In the terminal: `/care checkin water <moist|topped|wet>`, `lid <on|cracked|off>`, `sprouts <0-20>`, `note <text>`, and `light <on|off> [HH:MM]` (the time is today in the Pi's zone, or yesterday's if still ahead; `/care did lightOn` always logs now). All doors use one function (`checkinEntry`, src/care-checkin.ts), and a write reaches open `/care` pages live through `/care/events`. | |
| 42 | 42 | |
| 43 | 43 | The switch also works outside the app: |
| 44 | 44 | |
| @@ -133,7 +133,7 @@ ignored and said once on the arm server's stderr. The arm server reads it at sta | ||
| 133 | 133 | The app is a workshop: one Pi, several **stations**, each with its cameras and controls. Today there are two, **the arm** and **the grow box** (spinach under a UV light). `/` is the workshop home, a card per station with its live camera snapshot first and its controls after, and the arm's own page is at `/arm` (its URL is unchanged for anyone who has it). The page and its title say "Workshop"; it shows who is looking ("Signed in as …", or "Watching as a guest · Sign in"), has a skip link, and keeps working while the arm is closed. |
| 134 | 134 | |
| 135 | 135 | - **Any subset present.** The grow box has nothing to enumerate (the light is USB power only), so it is always there. The arm's card is there only while the link to the Pi's `so101 serve` is live; unplugged, closed by the owner or unreachable it says why ("The arm: not connected", "The arm is resting") and offers no controls (its cameras still show), and the owner sees **Open now** on it. Plugging the arm back in brings its card back by itself, with no restart. `so101 serve` runs with the arm unplugged: when the port is missing or cannot be opened it logs `arm not connected (<path>: <reason>); trying again every 5 s` and keeps serving, so the cameras, `/version` and the grow box stay up and the Pi can still update itself. `GET /status` answers 200 with `arm: "not connected"`; every route that needs the servos (moves, poses, torque, calibration, the drive stream) answers 503 `{"error":"arm not connected"}`, and STOP answers 200. The port is tried again every 5 s: when the arm answers, the real bus takes over without a restart (the cameras never blink) and the server logs `arm connected on <path>`. If the port fails mid-run, whatever drives stops (failed, never resumed), open drive streams close with 1013, `/status` goes back to "not connected" and the retries start again (the log line is `arm not connected (its port failed); trying again every 5 s`). A recording under way when the port fails ends there: stopping it saves what was sampled. |
| 136 | - **On a phone.** Each camera is shown whole at 16:9 (`object-fit: contain`), full width; a tap opens it full screen (on an iPhone, the still itself, to pinch). A station with several cameras shows them as a row to swipe, with a segmented control naming each, and side by side when the card is wide; the last one picked is remembered in the browser (src/ui/station-cams.js). A camera with a box drawn on Tune offers **Zoom to the box**. The grow card says where the plant is from the care log's latest `sow` (one source of truth: "Germinating: day 0 · Sprouts expected in about 3–7 days"), the last watering and the next reminder, links the log book, and says "Grow box offline: the Pi isn't answering" while it is down. Not planted yet, the owner gets **Set the sow date** (a date picker, today by default) that logs a sow and lands back on `/`. The page keeps room at its foot so the forge's floating button never sits on a control. | |
| 136 | - **On a phone.** Each camera is shown whole at 16:9 (`object-fit: contain`), full width; a tap opens it full screen (on an iPhone, the still itself, to pinch). A station with several cameras shows them as a row to swipe, with a segmented control naming each, and side by side when the card is wide; the last one picked is remembered in the browser (src/ui/station-cams.js). A camera with a box drawn on Tune offers **Zoom to the box**. The grow card says where the plant is from the care log's latest `sow` (one source of truth: "Germinating: day 0 · Sprouts expected in about 3–7 days"), the last watering and the next reminder, links the log book, shows the light's real day from the care log's `lightOn` / `lightOff` ("Light on 08:30 → off due 20:30 (12 h) · started 1 h 30 m late · 1 h 30 m of light so far"; off due is the on-time plus the schedule's hours, held to 10–14 h; "light still on?" 30 min past due with no `lightOff`), and says "Grow box offline: the Pi isn't answering" while it is down. Not planted yet, the owner gets **Set the sow date** (a date picker, today by default) that logs a sow and lands back on `/`. The page keeps room at its foot so the forge's floating button never sits on a control. | |
| 137 | 137 | - **"Camera can't see the plants."** On the grow box's card, each still is checked in the browser once it has loaded: the part inside the camera's box (set on Tune, `/admin/camera-list`) is counted for blown-out and black pixels (`visibility` in src/ui/still-frame.js), and when the plants cannot be seen the card says so over the foot of the still, with a link to Tune (src/ui/grow-hint.js). The relay never decodes a JPEG, so the terminal and the JSON doors cannot show this hint; they have no pixels to count. |
| 138 | 138 | - **The config** is `so101/stations.json` under `FORGE_APP_DATA`: a list of `{id, name, kind: "arm"|"grow", cameras: [camera ids], controls: [{label, href}]}` (src/stations.ts). Without a file it is the arm (camera 1) and the grow box (camera 2 from above, then camera 1 from the side): edit it to match the cameras. With only an arm in it, `/` is the arm's page as before. |
| 139 | 139 | - **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. |
| @@ -163,7 +163,7 @@ The grow box's care app: a plant profile, reminders worked out from it and the l | ||
| 163 | 163 | - **Reminders** fall due a set time after the last log entry that counts for them (a top-off resets "top off", a watering resets the moisture check), or after the stage began. Done, Snooze and a different cadence (`POST /care/reminder {id, action: "done"|"snooze"|"every", hours}`) are the owner's. |
| 164 | 164 | - **Profiles are files.** `src/care-profiles/spinach.json` ships with the app; put another plant (lettuce, basil) as `FORGE_APP_DATA/so101/care-profiles/<name>.json` in the same shape (stages, `bands`, `reminders` with `everyHours`, optional `tower`, `needs`, `resets`; see src/care-profile.ts, which names what is wrong with a bad file and skips it). `CARE_PROFILE=<id>` picks which one the app runs (default `spinach`). |
| 165 | 165 | - **Sources.** Every target, reminder and stage cites a reference. A value shows a footnote marker; tapping it opens a card with the reference, what it supports and a link. A value with no source says "grower consensus (unsourced)". `GET /care/learn` is the Learn page: the references by topic, extension and peer-reviewed first, then a Fundamentals reading path (week one, then week two) and a list of items named but not checked. `GET /care/learn.json` and the terminal's `/care learn` give the same with URLs. In a profile, `references` is a list of `{id, title, publisher, url, kind, accessed, topics, covers}`: `kind` is `extension`, `peer-reviewed`, `grower` or `vendor`, `url` is `https`, and ids are unique. `sources` (a list of `{id, claim}`, each id naming a reference) sits on each band (under the stage's `sources`, keyed by measure) and on each reminder, with an optional `why` on a reminder; `light.sources`, `light.warnSources` and `timeline.sources` do the same for the light hours, the bolting warning and the timeline. A source naming no reference makes the profile load fail. `/care/api` and `/care` list each reminder's `why` and source ids. |
| 166 | - **The log book** is `FORGE_APP_DATA/so101/care.json`. Entries are `measurement` (ph, ec, waterTemp, airTemp, level, lightHours), `action` (sow, water, topoff, change, nutrients, thin, harvest, airstone, pumpcheck, transplant), `observation` and `problem`, each with a time (now unless given). A measurement is a `Reading {kind, value, at, source: manual|sensor}`, so a sensor can write the same later. Photos on observations and the pump schedule are not part of it yet. | |
| 166 | - **The log book** is `FORGE_APP_DATA/so101/care.json`. Entries are `measurement` (ph, ec, waterTemp, airTemp, level, lightHours), `action` (sow, water, topoff, change, nutrients, thin, harvest, airstone, pumpcheck, transplant, lightOn, lightOff), `observation` and `problem`, each with a time (now unless given). A measurement is a `Reading {kind, value, at, source: manual|sensor}`, so a sensor can write the same later. Photos on observations and the pump schedule are not part of it yet. | |
| 167 | 167 | - **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. |
| 168 | 168 | - **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. |
| 169 | 169 |
Software/so101/src/admin-routes.ts +2 −2
| @@ -93,7 +93,7 @@ const REPORT_ACTIONS = new Set(["report-remove", "report-mute", "report-dismiss" | ||
| 93 | 93 | type ReadBody = (req: IncomingMessage, limit?: number) => Promise<Buffer>; |
| 94 | 94 | |
| 95 | 95 | /** The owner's page. Anyone else is told so; a change is a form post, answered with a redirect back. */ |
| 96 | export async function handleAdmin(lobby: Lobby, req: IncomingMessage, res: ServerResponse, url: URL, readBody: ReadBody, askPrecision?: AskPrecision, askServos?: AskServos, askVideo?: AskVideo, versions?: () => Versions, tune?: TuneP, askLight?: LightHooks): Promise<void> { | |
| 96 | export async function handleAdmin(lobby: Lobby, req: IncomingMessage, res: ServerResponse, url: URL, readBody: ReadBody, askPrecision?: AskPrecision, askServos?: AskServos, askVideo?: AskVideo, versions?: () => Versions, tune?: TuneP, askLight?: LightHooks, recordLight?: (what: "lightOn" | "lightOff") => void): Promise<void> { | |
| 97 | 97 | const session = await serveAdminPage(lobby, req, res, url, askPrecision, askServos, askVideo, versions, askLight); |
| 98 | 98 | if (!session) return; |
| 99 | 99 | const { who, canEdit, show } = session; |
| @@ -148,7 +148,7 @@ export async function handleAdmin(lobby: Lobby, req: IncomingMessage, res: Serve | ||
| 148 | 148 | break; |
| 149 | 149 | case "wake": |
| 150 | 150 | case "goodnight": |
| 151 | wakeCode = await nightSwitch(lobby, who, act === "wake", form.get("note") ?? "", askLight); | |
| 151 | wakeCode = await nightSwitch(lobby, who, act === "wake", form.get("note") ?? "", askLight, recordLight); | |
| 152 | 152 | done = act === "wake" ? "opened" : "closed"; |
| 153 | 153 | break; |
| 154 | 154 | case "end": |
Software/so101/src/care-checkin.ts +18 −3
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | /** The morning check-in (#632): one screen, each tap one typed entry in the care log. */ |
| 2 | 2 | import type { EntryBody } from "./care-log.ts"; |
| 3 | 3 | import { STYLE } from "./care-page.ts"; |
| 4 | import { wallToUtc, offsetAt } from "./light-schedule.ts"; | |
| 4 | 5 | import { WAKE_LINES, type WakeCode } from "./night-switch.ts"; |
| 5 | 6 | import { esc } from "./story-markdown.ts"; |
| 6 | 7 | |
| @@ -8,8 +9,21 @@ const WATER = { moist: "cubes moist", topped: "topped up (bottom)", wet: "too we | ||
| 8 | 9 | const LID = { on: "on", cracked: "cracked", off: "off" } as const; |
| 9 | 10 | export const MAX_SPROUTS = 20; |
| 10 | 11 | |
| 11 | /** What one check-in tap writes: water → an action (topped up) or an observation, lid and photo note → observations, sprouts → a measurement. Null when nothing valid was sent. */ | |
| 12 | export function checkinEntry(input: Record<string, unknown>, at: number): EntryBody | null { | |
| 12 | /** "HH:MM" today in the zone as an instant; a time still ahead of `now` is yesterday's (a late log after midnight). Null when it is not a time. */ | |
| 13 | function clockToday(hhmm: string, now: number, zone: string): number | null { | |
| 14 | const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(hhmm); | |
| 15 | if (!m) return null; | |
| 16 | const local = new Date(now + offsetAt(now, zone)); | |
| 17 | const at = wallToUtc(local.getUTCFullYear(), local.getUTCMonth() + 1, local.getUTCDate(), Number(m[1]), Number(m[2]), zone); | |
| 18 | return at > now + 60_000 ? at - 24 * 3_600_000 : at; | |
| 19 | } | |
| 20 | ||
| 21 | /** What one check-in tap writes: light → lightOn or lightOff (at "HH:MM" today in the zone, else now), water → an action (topped up) or an observation, lid and photo note → observations, sprouts → a measurement. Null when nothing valid was sent. */ | |
| 22 | export function checkinEntry(input: Record<string, unknown>, at: number, zone = "UTC"): EntryBody | null { | |
| 23 | if (input.light === "on" || input.light === "off") { | |
| 24 | const when = typeof input.lightAt === "string" && input.lightAt !== "" ? clockToday(input.lightAt, at, zone) : at; | |
| 25 | return when === null ? null : { at: when, type: "action", what: input.light === "on" ? "lightOn" : "lightOff", cause: "owner" }; | |
| 26 | } | |
| 13 | 27 | const water = input.water; |
| 14 | 28 | if (typeof water === "string" && Object.hasOwn(WATER, water)) { |
| 15 | 29 | return water === "topped" ? { at, type: "action", what: "topoff", cause: "owner" } : { at, type: "observation", text: `Water: ${WATER[water as keyof typeof WATER]}` }; |
| @@ -28,7 +42,7 @@ export function checkinEntry(input: Record<string, unknown>, at: number): EntryB | ||
| 28 | 42 | const one = (name: string, value: string, label: string) => `<form method="post" action="/care/checkin"><input type="hidden" name="${name}" value="${value}"><button>${esc(label)}</button></form>`; |
| 29 | 43 | |
| 30 | 44 | /** The check-in page; `sprouts` is the newest count, `done` a line about the last tap, `light` the wake-up checklist line (fixed text, never the query's). */ |
| 31 | export function renderCheckin(sprouts: number | null, done: boolean, light: string | null): string { | |
| 45 | export function renderCheckin(sprouts: number | null, done: boolean, light: string | null, lit: "on" | "off" | null = null): string { | |
| 32 | 46 | const line = light !== null && Object.hasOwn(WAKE_LINES, light) ? WAKE_LINES[light as WakeCode] : null; |
| 33 | 47 | return `<!doctype html> |
| 34 | 48 | <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> |
| @@ -37,6 +51,7 @@ export function renderCheckin(sprouts: number | null, done: boolean, light: stri | ||
| 37 | 51 | <body><main> |
| 38 | 52 | <h1>Good morning</h1> |
| 39 | 53 | ${line ? `<p class="dim">${esc(line)}</p>` : ""}${done ? `<p role="status">Logged.</p>` : ""} |
| 54 | <section class="panel" aria-label="Light"><h2>Light${lit === null ? "" : ` (last: ${lit})`}</h2><form method="post" action="/care/checkin"><input type="hidden" name="light" value="${lit === "on" ? "off" : "on"}"><button>${lit === "on" ? "Light off" : "Light on"}</button><label>at… <input type="time" name="lightAt" aria-label="Time, if not now"></label></form></section> | |
| 40 | 55 | <section class="panel" aria-label="Water"><h2>Water</h2>${one("water", "moist", "Cubes moist")}${one("water", "topped", "Topped up (bottom)")}${one("water", "wet", "Too wet")}</section> |
| 41 | 56 | <section class="panel" aria-label="Sprouts"><h2>Sprouts${sprouts === null ? "" : ` (last: ${sprouts})`}</h2><form method="post" action="/care/checkin"><input type="number" name="sprouts" min="0" max="${MAX_SPROUTS}" step="1" value="${sprouts ?? 0}" aria-label="Sprout count"><button>Log sprouts</button></form></section> |
| 42 | 57 | <section class="panel" aria-label="Lid"><h2>Lid</h2>${one("lid", "on", "On")}${one("lid", "cracked", "Cracked")}${one("lid", "off", "Off")}</section> |
Software/so101/src/care-log.ts +1 −1
| @@ -9,7 +9,7 @@ import { MEASURES, type Measure } from "./care-profile.ts"; | ||
| 9 | 9 | |
| 10 | 10 | export const MAX_ENTRIES = 5000; |
| 11 | 11 | export const SENSOR_EVERY_MS = 3_600_000; |
| 12 | export const ACTIONS = ["sow", "water", "topoff", "change", "nutrients", "phdown", "thin", "harvest", "airstone", "pumpcheck", "transplant", "done", "end"] as const; | |
| 12 | export const ACTIONS = ["sow", "water", "topoff", "change", "nutrients", "phdown", "thin", "harvest", "airstone", "pumpcheck", "transplant", "done", "end", "lightOn", "lightOff"] as const; | |
| 13 | 13 | export const PROBLEMS = ["bolting", "tip burn", "root rot", "algae", "yellowing", "leggy"] as const; |
| 14 | 14 | export const SYSTEMS = ["vertical tower", "DWC", "NFT", "Kratky", "ebb-and-flow"] as const; |
| 15 | 15 | export type Action = (typeof ACTIONS)[number]; |
Software/so101/src/care-routes.ts +9 −3
| @@ -5,6 +5,7 @@ import { boltingWatch, currentStage, health, reminders, snapshot, stageHistory, | ||
| 5 | 5 | import { LogError, type CareLog } from "./care-log.ts"; |
| 6 | 6 | import { learnData, renderLearn } from "./care-sources.ts"; |
| 7 | 7 | import { renderCare } from "./care-page.ts"; |
| 8 | import { lastLight } from "./light-day.ts"; | |
| 8 | 9 | import { checkinEntry, renderCheckin } from "./care-checkin.ts"; |
| 9 | 10 | import { calibrateProbe, renderProbes, type ProbeCache } from "./care-probes.ts"; |
| 10 | 11 | import type { Profile } from "./care-profile.ts"; |
| @@ -23,6 +24,8 @@ export interface CareContext { | ||
| 23 | 24 | who: (req: IncomingMessage) => { owner: boolean; signedIn: SignedIn | null; known: boolean }; |
| 24 | 25 | readBody: (req: IncomingMessage, limit?: number) => Promise<Buffer>; |
| 25 | 26 | now?: () => number; |
| 27 | /** The Pi's time zone, asked of the Pi when not yet known; null when it does not answer. Without it, UTC. */ | |
| 28 | zone?: () => Promise<string | null>; | |
| 26 | 29 | /** The pH/EC probes on the Pi; without them the page has no probe panel. */ |
| 27 | 30 | probes?: ProbeCache; |
| 28 | 31 | } |
| @@ -108,7 +111,7 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path | ||
| 108 | 111 | const latest = ctx.log.entries().filter((e) => e.type === "measurement" && e.kind === "sprouts").at(-1); |
| 109 | 112 | const sprouts = latest && latest.type === "measurement" ? latest.value : null; |
| 110 | 113 | const query = new URL(req.url ?? "/", "http://x").searchParams; |
| 111 | if (req.method === "GET") return send(200, "text/html; charset=utf-8", renderCheckin(sprouts, query.has("done"), query.get("light"))), true; | |
| 114 | if (req.method === "GET") return send(200, "text/html; charset=utf-8", renderCheckin(sprouts, query.has("done"), query.get("light"), lastLight(ctx.log.entries(), now))), true; | |
| 112 | 115 | if (req.method !== "POST") return json(405, { error: "GET or POST" }), true; |
| 113 | 116 | let raw: Buffer; |
| 114 | 117 | try { |
| @@ -117,8 +120,11 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path | ||
| 117 | 120 | return json(413, { error: "too long" }), true; |
| 118 | 121 | } |
| 119 | 122 | const body = fields(req, raw.toString("utf8")); |
| 120 | const made = checkinEntry(body, now); | |
| 121 | if (!made) return json(400, { error: "send water, lid, sprouts (0 to 20) or a note" }), true; | |
| 123 | // A back-dated time is the Pi's wall clock: refuse it rather than guess the zone. | |
| 124 | const zone = body.lightAt ? ((await ctx.zone?.()) ?? (ctx.zone ? null : "UTC")) : "UTC"; | |
| 125 | if (zone === null) return json(409, { error: "the Pi's time zone is not known yet (the arm server did not answer); log the light now, or try again" }), true; | |
| 126 | const made = checkinEntry(body, now, zone); | |
A form post that gets 409 or 400 shows a person raw JSON. Redirect back to /care/checkin with a short error code and word it on the page. |
||
| 127 | if (!made) return json(400, { error: "send light, water, lid, sprouts (0 to 20) or a note" }), true; | |
| 122 | 128 | const added = ctx.log.add(made); |
| 123 | 129 | if ((req.headers["content-type"] ?? "").includes("json")) json(200, added); |
| 124 | 130 | else send(303, "text/plain", "", { location: "/care/checkin?done=1" }); |
Software/so101/src/care-terminal.ts +5 −1
| @@ -17,7 +17,7 @@ export interface CareAsk { | ||
| 17 | 17 | timeoutMs?: number; |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | export const CARE_USAGE = "usage: /care · /care log <ph|ec|waterTemp|airTemp|level|lightHours|sprouts> <value> · /care did <action> [amount] · /care note <text> · /care checkin water <moist|topped|wet> | lid <on|cracked|off> | sprouts <0-20> | 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 learn · /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)"; | |
| 20 | export const CARE_USAGE = "usage: /care · /care log <ph|ec|waterTemp|airTemp|level|lightHours|sprouts> <value> · /care did <action> [amount] · /care note <text> · /care checkin light <on|off> [HH:MM] | water <moist|topped|wet> | lid <on|cracked|off> | sprouts <0-20> | 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 learn · /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)"; | |
| 21 | 21 | |
| 22 | 22 | export const PROBES_USAGE = "usage: /probes · /probes calibrate <ph7|ph4|ec1413> [solution tempC] (owner)"; |
| 23 | 23 | |
| @@ -50,6 +50,10 @@ export function careCommand(words: string[], tail: string): CareAsk | { print: s | ||
| 50 | 50 | case "checkin": { |
| 51 | 51 | if (!tail.startsWith(a) || !tail.slice(a.length).trim()) return usage; |
| 52 | 52 | const value = tail.slice(a.length).trim(); |
| 53 | if (a === "light") { | |
| 54 | const m = /^(on|off)(?: ([01]\d:[0-5]\d))?$/.exec(value); | |
| 55 | return m ? post("/care/checkin", { light: m[1], ...(m[2] ? { lightAt: m[2] } : {}) }) : usage; | |
| 56 | } | |
| 53 | 57 | if (a === "sprouts") return /^\d{1,2}$/.test(value) ? post("/care/checkin", { sprouts: Number(value) }) : usage; |
| 54 | 58 | return a === "water" || a === "lid" || a === "note" ? post("/care/checkin", { [a]: value }) : usage; |
| 55 | 59 | } |
Software/so101/src/light-checkin.test.ts added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | import { afterEach, 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 { CareLog } from "./care-log.ts"; | |
| 6 | import { BUILT_IN_DIR, loadProfiles } from "./care-profile.ts"; | |
| 7 | import { sign } from "./identity.ts"; | |
| 8 | import { GrowLog } from "./grow-log.ts"; | |
| 9 | import { GrowStills } from "./grow-stills.ts"; | |
| 10 | import { StationHub } from "./stations.ts"; | |
| 11 | import { Lobby } from "./lobby.ts"; | |
| 12 | import { startRelay, type RunningRelay } from "./relay.ts"; | |
| 13 | ||
| 14 | const KEY = "k".repeat(64); | |
| 15 | const as = (name: string, role: "user" | "admin") => { | |
| 16 | const identity = `id-${name}|${name}|${role}|${Date.now() + 600_000}`; | |
| 17 | return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 18 | }; | |
| 19 | const owner = as("trevato", "admin"); | |
| 20 | const post = (headers: Record<string, string>, body: Record<string, string>) => ({ method: "POST", headers: { ...headers, "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(body), redirect: "manual" as const }); | |
| 21 | ||
| 22 | let dir = ""; | |
| 23 | let relay: RunningRelay | undefined; | |
| 24 | let lobby: Lobby | undefined; | |
| 25 | let arm: ReturnType<typeof Bun.serve> | undefined; | |
| 26 | afterEach(async () => { | |
| 27 | await relay?.close(); | |
| 28 | lobby?.close(); | |
| 29 | arm?.stop(true); | |
| 30 | if (dir) rmSync(dir, { recursive: true, force: true }); | |
| 31 | dir = ""; | |
| 32 | relay = lobby = arm = undefined; | |
| 33 | }); | |
| 34 | ||
| 35 | /** A relay over a fake arm in Tokyo (UTC+9), which answers /grow only while `up`. */ | |
| 36 | async function boot(up = true) { | |
| 37 | dir = mkdtempSync(join(tmpdir(), "so101-lightday-")); | |
| 38 | const state = { up }; | |
| 39 | arm = Bun.serve({ | |
| 40 | port: 0, | |
| 41 | fetch(req) { | |
| 42 | if (new URL(req.url).pathname === "/grow" && state.up) return Response.json({ light: { configured: false, text: "On", zone: "Asia/Tokyo", schedule: { onAt: "06:00", hours: 12 }, on: false, log: [] } }); | |
| 43 | return new Response("no", { status: 404 }); | |
| 44 | }, | |
| 45 | }); | |
| 46 | lobby = new Lobby({ identityKey: KEY, adminFile: join(dir, "admin.json"), openFile: join(dir, "open.json") }); | |
| 47 | const log = new CareLog(join(dir, "care.json")); | |
| 48 | const stations = new StationHub(); | |
| 49 | stations.replace([{ id: "grow", name: "Grow box", kind: "grow", cameras: [], controls: [] }]); | |
| 50 | relay = await startRelay({ port: 0, simulation: `http://127.0.0.1:${arm.port}`, lobby, stations, grow: { stills: new GrowStills(join(dir, "stills")), log: new GrowLog(join(dir, "log.json")), everyMs: 600_000 }, env: {}, care: { log, profile: loadProfiles(BUILT_IN_DIR).find((p) => p.id === "spinach")! } }); | |
| 51 | return { base: `http://127.0.0.1:${relay.port}`, log, state }; | |
| 52 | } | |
| 53 | ||
| 54 | test("the light tap is the owner's: a guest is refused and writes nothing; the owner gets a 303", async () => { | |
| 55 | const { base, log } = await boot(); | |
| 56 | expect((await fetch(`${base}/care/checkin`, post({}, { light: "on" }))).status).toBeGreaterThanOrEqual(401); | |
| 57 | expect((await fetch(`${base}/care/checkin`, post(as("bob", "user"), { light: "on" }))).status).toBe(403); | |
| 58 | expect(log.entries()).toEqual([]); | |
| 59 | const r = await fetch(`${base}/care/checkin`, post(owner, { light: "on" })); | |
| 60 | expect(r.status).toBe(303); | |
| 61 | expect(log.entries().map((e) => e.type === "action" && e.what)).toEqual(["lightOn"]); | |
| 62 | }); | |
| 63 | ||
| 64 | test("an 'at' time is the Pi's wall clock, not UTC: 08:30 in Tokyo is 23:30 UTC the day before", async () => { | |
| 65 | const { base, log } = await boot(); | |
| 66 | const r = await fetch(`${base}/care/checkin`, post(owner, { light: "on", lightAt: "08:30" })); | |
| 67 | expect(r.status).toBe(303); | |
| 68 | const at = log.entries()[0]!.at; | |
| 69 | const tokyo = new Date(at + 9 * 3_600_000); | |
| 70 | expect([tokyo.getUTCHours(), tokyo.getUTCMinutes()]).toEqual([8, 30]); | |
| 71 | }); | |
| 72 | ||
| 73 | test("until the Pi's zone is known, an 'at' time is refused and says why; logging now still works", async () => { | |
| 74 | const { base, log, state } = await boot(false); | |
| 75 | const r = await fetch(`${base}/care/checkin`, post(owner, { light: "on", lightAt: "08:30" })); | |
| 76 | expect(r.status).toBe(409); | |
| 77 | expect(await r.text()).toContain("time zone is not known"); | |
| 78 | expect(log.entries()).toEqual([]); | |
| 79 | expect((await fetch(`${base}/care/checkin`, post(owner, { light: "on" }))).status).toBe(303); | |
| 80 | state.up = true; | |
| 81 | expect((await fetch(`${base}/care/checkin`, post(owner, { light: "off", lightAt: "23:59" }))).status).toBe(303); | |
| 82 | }); | |
| 83 | ||
| 84 | test("the card words the day in the Pi's zone once it is known, and says nothing before", async () => { | |
| 85 | const { base } = await boot(); | |
| 86 | await fetch(`${base}/care/checkin`, post(owner, { light: "on", lightAt: "08:30" })); | |
| 87 | const home = await (await fetch(`${base}/`, { headers: owner })).text(); | |
| 88 | expect(home).toContain("data-light>Light on 08:30 → off due 20:30 (12 h)"); | |
| 89 | }); |
Software/so101/src/light-day.test.ts added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { CareLog } from "./care-log.ts"; | |
| 3 | import { checkinEntry, renderCheckin } from "./care-checkin.ts"; | |
| 4 | import { lastLight, lightDay, lightLines } from "./light-day.ts"; | |
| 5 | ||
| 6 | const H = 3_600_000; | |
| 7 | const M = 60_000; | |
| 8 | const DAY0 = Date.UTC(2026, 9, 27); | |
| 9 | const SCHEDULE = { onAt: "07:00", hours: 12 }; | |
| 10 | const logWith = (...events: [string, number][]) => { | |
| 11 | let now = DAY0; | |
| 12 | const log = new CareLog(undefined, () => now); | |
| 13 | for (const [what, at] of events) { | |
| 14 | now = at; | |
| 15 | log.add({ type: "action", what, at }); | |
| 16 | } | |
| 17 | return log; | |
| 18 | }; | |
| 19 | ||
| 20 | describe("the light's real day", () => { | |
| 21 | test("an 8:30 lightOn puts the off due at 20:30, 1 h 30 m late", () => { | |
| 22 | const log = logWith(["lightOn", DAY0 + 8.5 * H]); | |
| 23 | const now = DAY0 + 10 * H; | |
| 24 | const day = lightDay(log.entries(), now, "UTC", SCHEDULE)!; | |
| 25 | expect(day.offDue).toBe(DAY0 + 20.5 * H); | |
| 26 | const lines = lightLines(day, "UTC"); | |
| 27 | expect(lines[0]).toBe("Light on 08:30 → off due 20:30 (12 h)"); | |
| 28 | expect(lines).toContain("started 1 h 30 m late"); | |
| 29 | expect(lines).toContain("1 h 30 m of light so far"); | |
| 30 | }); | |
| 31 | ||
| 32 | test("a 06:00 lightOn with a 12 h schedule puts the off due at 18:00, on time", () => { | |
| 33 | const log = logWith(["lightOn", DAY0 + 6 * H]); | |
| 34 | const day = lightDay(log.entries(), DAY0 + 7 * H, "UTC", { onAt: "06:00", hours: 12 })!; | |
| 35 | expect(lightLines(day, "UTC")[0]).toBe("Light on 06:00 → off due 18:00 (12 h)"); | |
| 36 | expect(lightLines(day, "UTC").join()).not.toContain("late"); | |
| 37 | }); | |
| 38 | ||
| 39 | test("a schedule's hours are held between 10 h and the spinach maximum", () => { | |
| 40 | const on = logWith(["lightOn", DAY0 + 8 * H]).entries(); | |
| 41 | expect(lightDay(on, DAY0 + 9 * H, "UTC", { onAt: "07:00", hours: 8 })!.offDue).toBe(DAY0 + 18 * H); | |
| 42 | expect(lightDay(on, DAY0 + 9 * H, "UTC", { onAt: "07:00", hours: 18 })!.offDue).toBe(DAY0 + 22 * H); | |
| 43 | }); | |
| 44 | ||
| 45 | test("a missing lightOff 30 min past due asks whether the light is still on", () => { | |
| 46 | const entries = logWith(["lightOn", DAY0 + 8.5 * H]).entries(); | |
| 47 | expect(lightLines(lightDay(entries, DAY0 + 20.5 * H + 29 * M, "UTC", SCHEDULE)!, "UTC")).not.toContain("light still on?"); | |
| 48 | expect(lightLines(lightDay(entries, DAY0 + 20.5 * H + 30 * M, "UTC", SCHEDULE)!, "UTC")).toContain("light still on?"); | |
| 49 | }); | |
| 50 | ||
| 51 | test("a lightOff closes the day and sums the hours from the actions", () => { | |
| 52 | const entries = logWith(["lightOn", DAY0 + 8.5 * H], ["lightOff", DAY0 + 20.5 * H]).entries(); | |
| 53 | const day = lightDay(entries, DAY0 + 22 * H, "UTC", SCHEDULE)!; | |
| 54 | expect(day.stillOn).toBe(false); | |
| 55 | expect(lightLines(day, "UTC")).toEqual(["Light 08:30 → 20:30", "started 1 h 30 m late", "12 h of light"]); | |
| 56 | }); | |
| 57 | ||
| 58 | test("nothing logged, or an on from days ago, shows nothing", () => { | |
| 59 | expect(lightDay([], DAY0, "UTC", SCHEDULE)).toBeNull(); | |
| 60 | expect(lightDay(logWith(["lightOn", DAY0]).entries(), DAY0 + 3 * 24 * H, "UTC", SCHEDULE)).toBeNull(); | |
| 61 | }); | |
| 62 | }); | |
| 63 | ||
| 64 | describe("the check-in's light button", () => { | |
| 65 | test("the button reads the opposite of the last action, with an at… time", () => { | |
| 66 | expect(renderCheckin(null, false, null, null)).toContain("<button>Light on</button>"); | |
| 67 | expect(renderCheckin(null, false, null, "on")).toContain("<button>Light off</button>"); | |
| 68 | expect(renderCheckin(null, false, null, "on")).toContain('name="lightAt"'); | |
| 69 | }); | |
| 70 | ||
| 71 | test("a tap writes lightOn now; at 08:30 it writes that time today, or yesterday's when still ahead", () => { | |
| 72 | const now = DAY0 + 10 * H; | |
| 73 | expect(checkinEntry({ light: "on" }, now)).toEqual({ at: now, type: "action", what: "lightOn", cause: "owner" }); | |
| 74 | expect(checkinEntry({ light: "on", lightAt: "08:30" }, now)).toMatchObject({ at: DAY0 + 8.5 * H }); | |
| 75 | expect(checkinEntry({ light: "off", lightAt: "23:00" }, now)).toMatchObject({ what: "lightOff", at: DAY0 - H }); | |
| 76 | expect(checkinEntry({ light: "on", lightAt: "soon" }, now)).toBeNull(); | |
| 77 | }); | |
| 78 | }); | |
| 79 | ||
| 80 | describe("by time, not by the order logged", () => { | |
| 81 | test("a late log of an earlier lightOn after a lightOff does not flip the day or the button", () => { | |
| 82 | let now = DAY0 + 21 * H; | |
| 83 | const log = new CareLog(undefined, () => now); | |
| 84 | log.add({ type: "action", what: "lightOn", at: DAY0 + 8.5 * H }); | |
| 85 | log.add({ type: "action", what: "lightOff", at: DAY0 + 20.5 * H }); | |
| 86 | log.add({ type: "action", what: "lightOn", at: DAY0 + 7.5 * H }); // forgotten earlier tap, logged last | |
| 87 | expect(lastLight(log.entries(), now)).toBe("off"); | |
| 88 | const day = lightDay(log.entries(), now, "UTC", SCHEDULE)!; | |
| 89 | expect(day.onAt).toBe(DAY0 + 8.5 * H); | |
| 90 | expect(day.offAt).toBe(DAY0 + 20.5 * H); | |
| 91 | }); | |
| 92 | }); |
Software/so101/src/light-day.ts added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | /** | |
| 2 | * The light's real day (#639): the lightOn and lightOff actions in the care log say when the light really ran, so the | |
| 3 | * off-time follows the actual on-time instead of the alarm. Pure: (log, now, zone, schedule) in, words out. | |
| 4 | */ | |
| 5 | import type { Entry } from "./care-log.ts"; | |
| 6 | import { clockIn, offsetAt, wallToUtc } from "./light-schedule.ts"; | |
| 7 | import { SPINACH_MAX_HOURS } from "./light-panel.ts"; | |
| 8 | ||
| 9 | const HOUR = 3_600_000; | |
| 10 | const MIN = 60_000; | |
| 11 | /** A day shorter than this starves spinach; the schedule's own hours never push below it. */ | |
| 12 | export const MIN_DAY_HOURS = 10; | |
| 13 | /** How long past the off-due time before the card asks whether the light was left on. */ | |
| 14 | export const STILL_ON_AFTER_MS = 30 * MIN; | |
| 15 | /** A start this much after the schedule counts as late. */ | |
| 16 | const LATE_MS = 15 * MIN; | |
| 17 | /** An on older than this is yesterday's news: the card shows nothing. */ | |
| 18 | const STALE_MS = 36 * HOUR; | |
| 19 | ||
| 20 | export interface LightDay { | |
| 21 | onAt: number; | |
| 22 | offDue: number; | |
| 23 | /** Hours of light planned: the schedule's, held between 10 h and the spinach maximum. */ | |
| 24 | hours: number; | |
| 25 | /** How far after the scheduled start the light came on; 0 when on time or early. */ | |
| 26 | lateMs: number; | |
| 27 | /** When it was switched off; null while it is on. */ | |
| 28 | offAt: number | null; | |
| 29 | /** Light so far (to now while on, else the whole day). */ | |
| 30 | lightMs: number; | |
| 31 | /** On past due + 30 min with no lightOff logged. */ | |
| 32 | stillOn: boolean; | |
| 33 | } | |
| 34 | ||
| 35 | export interface Photo { | |
| 36 | onAt: string; | |
| 37 | hours: number; | |
| 38 | } | |
| 39 | ||
| 40 | /** The hours the day gets: the schedule's, never above the spinach maximum or below 10 h. */ | |
| 41 | export const dayHours = (scheduled: number): number => Math.min(SPINACH_MAX_HOURS, Math.max(MIN_DAY_HOURS, scheduled)); | |
| 42 | ||
| 43 | /** By time, not by the order they were logged: a late log of an earlier light must not win. */ | |
| 44 | const latest = (list: Entry[]): Entry | undefined => list.reduce<Entry | undefined>((a, e) => (!a || e.at > a.at || (e.at === a.at && e.id > a.id) ? e : a), undefined); | |
| 45 | const earliest = (list: Entry[]): Entry | undefined => list.reduce<Entry | undefined>((a, e) => (!a || e.at < a.at ? e : a), undefined); | |
outdated
|
||
| 46 | ||
| 47 | /** The last light action logged by `now`: "on" or "off", null when none is. */ | |
| 48 | export function lastLight(entries: readonly Entry[], now: number): "on" | "off" | null { | |
| 49 | const e = latest(entries.filter((x) => x.type === "action" && (x.what === "lightOn" || x.what === "lightOff") && x.at <= now)); | |
| 50 | return e && e.type === "action" ? (e.what === "lightOn" ? "on" : "off") : null; | |
| 51 | } | |
outdated
Same: choose the latest lightOn by |
||
| 52 | ||
| 53 | /** The current (or just-finished) day from the log; null when the light was never logged on, or not within a day and a half. */ | |
| 54 | export function lightDay(entries: readonly Entry[], now: number, zone: string, schedule: Photo): LightDay | null { | |
| 55 | const on = latest(entries.filter((x) => x.type === "action" && x.what === "lightOn" && x.at <= now)); | |
| 56 | if (!on || now - on.at > STALE_MS) return null; | |
| 57 | const off = earliest(entries.filter((x) => x.type === "action" && x.what === "lightOff" && x.at > on.at && x.at <= now)); | |
| 58 | const hours = dayHours(schedule.hours); | |
| 59 | const local = new Date(on.at + offsetAt(on.at, zone)); | |
| 60 | const [h, m] = schedule.onAt.split(":").map(Number) as [number, number]; | |
| 61 | const planned = wallToUtc(local.getUTCFullYear(), local.getUTCMonth() + 1, local.getUTCDate(), h, m, zone); | |
| 62 | const offDue = on.at + hours * HOUR; | |
| 63 | return { | |
| 64 | onAt: on.at, | |
| 65 | offDue, | |
| 66 | hours, | |
| 67 | lateMs: Math.max(0, on.at - planned), | |
| 68 | offAt: off?.at ?? null, | |
| 69 | lightMs: (off?.at ?? now) - on.at, | |
| 70 | stillOn: !off && now >= offDue + STILL_ON_AFTER_MS, | |
| 71 | }; | |
| 72 | } | |
| 73 | ||
| 74 | /** "1 h 30 m", "45 m", "12 h". */ | |
| 75 | export function span(ms: number): string { | |
| 76 | const total = Math.max(0, Math.round(ms / MIN)); | |
| 77 | const [h, m] = [Math.floor(total / 60), total % 60]; | |
| 78 | return [h ? `${h} h` : "", m || !h ? `${m} m` : ""].filter(Boolean).join(" "); | |
| 79 | } | |
| 80 | ||
| 81 | /** The grow card's lines for the day: on → off due, a late start, the hours so far, and the nag. */ | |
| 82 | export function lightLines(day: LightDay, zone: string): string[] { | |
| 83 | const lines = [day.offAt === null ? `Light on ${clockIn(day.onAt, zone)} → off due ${clockIn(day.offDue, zone)} (${day.hours} h)` : `Light ${clockIn(day.onAt, zone)} → ${clockIn(day.offAt, zone)}`]; | |
| 84 | if (day.lateMs >= LATE_MS) lines.push(`started ${span(day.lateMs)} late`); | |
| 85 | lines.push(day.offAt === null ? `${span(day.lightMs)} of light so far` : `${span(day.lightMs)} of light`); | |
| 86 | if (day.stillOn) lines.push("light still on?"); | |
| 87 | return lines; | |
| 88 | } |
Software/so101/src/night-switch.test.ts +23
| @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; | ||
| 2 | 2 | import { mkdtempSync, rmSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { lastLight } from "./light-day.ts"; | |
| 5 | 6 | import { CareLog } from "./care-log.ts"; |
| 6 | 7 | import { BUILT_IN_DIR, loadProfiles } from "./care-profile.ts"; |
| 7 | 8 | import { CARE_USAGE, careCommand } from "./care-terminal.ts"; |
| @@ -199,3 +200,25 @@ describe("the same at the terminal", () => { | ||
| 199 | 200 | expect(lobby!.resting()).not.toBeNull(); |
| 200 | 201 | }); |
| 201 | 202 | }); |
| 203 | ||
| 204 | describe("the light's real day in the care log (#639)", () => { | |
| 205 | test("wake up writes one lightOn; good night writes nothing and leaves an open day open", async () => { | |
| 206 | const { base, log } = await boot(false); | |
| 207 | await fetch(`${base}/admin`, form(owner, { action: "wake" })); | |
| 208 | expect(log.entries().map((e) => e.type === "action" && e.what)).toEqual(["lightOn"]); | |
| 209 | await fetch(`${base}/admin`, form(owner, { action: "goodnight" })); | |
| 210 | expect(log.entries().map((e) => e.type === "action" && e.what)).toEqual(["lightOn"]); | |
| 211 | expect(lastLight(log.entries(), Date.now())).toBe("on"); | |
| 212 | }); | |
| 213 | ||
| 214 | test("a refused wake up writes nothing", async () => { | |
| 215 | const { base, log } = await boot(true, true); | |
| 216 | await fetch(`${base}/admin`, form(owner, { action: "goodnight" })); | |
| 217 | await fetch(`${base}/admin`, form(owner, { action: "wake" })); | |
| 218 | expect(log.entries().filter((e) => e.type === "action" && e.what === "lightOn")).toEqual([]); | |
| 219 | }); | |
| 220 | }); | |
| 221 | ||
| 222 | test("the terminal can back-date the light through the check-in", () => { | |
| 223 | expect(careCommand(["checkin", "light", "on", "08:30"], "light on 08:30")).toMatchObject({ path: "/care/checkin", body: { light: "on", lightAt: "08:30" } }); | |
| 224 | }); |
Software/so101/src/night-switch.ts +4 −2
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | * Wake up / Good night (#632): the owner's one tap on the page (/admin) and one line at the terminal (/wake, /goodnight). |
| 3 | 3 | * Wake up sets the light first and opens the room only once the light took, so a refused write leaves both as they were. |
| 4 | 4 | * Good night only closes the room: the grow light stays on its schedule, because the arm holds an override until the next |
| 5 | * schedule transition and a forgotten "off" would darken the plants. | |
| 5 | * schedule transition and a forgotten "off" would darken the plants. Wake up also writes one care-log lightOn (#639) once | |
| 6 | * the light write took; Good night writes nothing, because nothing turned the light off: the owner logs lightOff on the check-in. | |
| 6 | 7 | */ |
| 7 | 8 | import { AdminError } from "./admin.ts"; |
| 8 | 9 | import type { LightHooks } from "./admin-routes.ts"; |
| @@ -19,7 +20,7 @@ export const WAKE_LINES = { | ||
| 19 | 20 | export type WakeCode = keyof typeof WAKE_LINES; |
| 20 | 21 | |
| 21 | 22 | /** Wake up (light on, then open) or Good night (close with a note). Throws AdminError when the light refuses or the caller may not. */ |
| 22 | export async function nightSwitch(lobby: Lobby, who: Person, waking: boolean, note: string, askLight?: LightHooks): Promise<WakeCode | null> { | |
| 23 | export async function nightSwitch(lobby: Lobby, who: Person, waking: boolean, note: string, askLight?: LightHooks, record?: (what: "lightOn" | "lightOff") => void): Promise<WakeCode | null> { | |
| 23 | 24 | if (!waking) { |
| 24 | 25 | lobby.setOpen(who, { open: false, note: note.trim().slice(0, 200) || SLEEPING_NOTE }); |
| 25 | 26 | return null; |
outdated
Good night does not switch the light off (see the header comment), so recording lightOff here logs something that did not happen. Remove this line. |
||
| @@ -31,5 +32,6 @@ export async function nightSwitch(lobby: Lobby, who: Person, waking: boolean, no | ||
| 31 | 32 | if (refused) throw new AdminError(`the light did not turn on, so the room stays closed: ${refused}`); |
| 32 | 33 | } |
| 33 | 34 | lobby.setOpen(who, { open: true }); |
| 35 | record?.("lightOn"); | |
| 34 | 36 | return switched ? "on" : "manual"; |
| 35 | 37 | } |
Software/so101/src/relay.ts +15 −4
| @@ -112,6 +112,8 @@ import { CareLog, LogError } from "./care-log.ts"; | ||
| 112 | 112 | import { growing, lastWatered, nextDue, overdue, reminders, snapshot, sownAt } from "./care-rules.ts"; |
| 113 | 113 | import { BUILT_IN_DIR, loadProfiles, type Profile } from "./care-profile.ts"; |
| 114 | 114 | import { ProbeCache, readProbeView, sampleProbes, waterTemp, type ProbeHooks } from "./care-probes.ts"; |
| 115 | import { lightDay, lightLines } from "./light-day.ts"; | |
| 116 | import { DEFAULT_SCHEDULE } from "./light-panel.ts"; | |
| 115 | 117 | import { handleCare } from "./care-routes.ts"; |
| 116 | 118 | import { handleDosing, makeDoser } from "./care-dosing.ts"; |
| 117 | 119 | import type { Doser } from "./doser.ts"; |
| @@ -683,11 +685,16 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 683 | 685 | }, |
| 684 | 686 | }; |
| 685 | 687 | const probeCache = new ProbeCache(probeHooks, () => waterTemp(care.log, Date.now()), () => care.log.notify()); |
Defaults to UTC until the Pi has been read once; the card and back-dated log are wrong by the zone offset until then. Load it at start or on first use. |
||
| 688 | /** The Pi's zone and photoperiod as last heard, so the care log's light day can be worded without waiting on the Pi. */ | |
| 689 | let lightSeen: { zone: string; onAt: string; hours: number } | null = null; | |
| 686 | 690 | const lightHooks: LightHooks = { |
| 687 | 691 | read: async () => { |
| 688 | 692 | try { |
| 689 | 693 | const answer = await fetch(armGrow(), { cache: "no-store", headers: growAuth(), signal: AbortSignal.timeout(Math.min(readTimeoutMs, 1000)) }); |
| 690 | return answer.ok ? readGrow(await answer.json().catch(() => null)) : null; | |
| 694 | const view = answer.ok ? readGrow(await answer.json().catch(() => null)) : null; | |
| 695 | const s = view?.light.schedule; | |
| 696 | if (view) lightSeen = { zone: view.light.zone || "UTC", onAt: s && "onAt" in s ? s.onAt : DEFAULT_SCHEDULE.onAt, hours: s && "onAt" in s ? s.hours : DEFAULT_SCHEDULE.hours }; | |
| 697 | return view; | |
| 691 | 698 | } catch { |
| 692 | 699 | return null; |
| 693 | 700 | } |
| @@ -864,7 +871,11 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 864 | 871 | const snap = snapshot(care.log); |
| 865 | 872 | const plant = growing(care.profile, snap, now); |
| 866 | 873 | const next = nextDue(care.profile, snap, now); |
| 867 | return { now, sownAt: plant.sownAt, title: plant.title, hint: plant.hint, lastWatered: lastWatered(snap.entries, now), next: next ? { label: next.reminder.label, dueAt: next.dueAt } : null, pi: piState() }; | |
| 874 | // Until the Pi has answered once its zone is unknown, and a light day worded in UTC would be wrong: say nothing and ask. | |
| 875 | const seen = lightSeen; | |
| 876 | if (!seen) void lightHooks.read(); | |
| 877 | const day = seen ? lightDay(snap.entries, now, seen.zone, seen) : null; | |
| 878 | return { now, sownAt: plant.sownAt, title: plant.title, hint: plant.hint, lastWatered: lastWatered(snap.entries, now), next: next ? { label: next.reminder.label, dueAt: next.dueAt } : null, pi: piState(), light: seen && day ? lightLines(day, seen.zone) : null }; | |
| 868 | 879 | }; |
| 869 | 880 | |
| 870 | 881 | /** Each station as the home shows it: the arm is present only while the link reaches a real one (so it comes back by itself). */ |
| @@ -1289,14 +1300,14 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1289 | 1300 | return { owner: Boolean(person?.owner), known: Boolean(person), id: person?.id, signedIn: lobby && !lobby.local ? lobby.signedIn(r) : null }; |
| 1290 | 1301 | }; |
| 1291 | 1302 | if (await handleDosing(req, res, url.pathname, { doser, log: care.log, who, readBody, sameOrigin, pumps: pumpHooks })) return; |
| 1292 | if (await handleCare(req, res, url.pathname, { profile: care.profile, log: care.log, probes: probeCache, doser, pumps: () => pumpRead.read(), who, readBody })) return; | |
| 1303 | if (await handleCare(req, res, url.pathname, { profile: care.profile, log: care.log, probes: probeCache, doser, pumps: () => pumpRead.read(), who, readBody, zone: async () => (lightSeen ?? ((await lightHooks.read()) && lightSeen))?.zone ?? null })) return; | |
| 1293 | 1304 | } |
| 1294 | 1305 | 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 }))); |
| 1295 | 1306 | const cameraAdmin = ADMIN_CAMERA.exec(url.pathname); |
| 1296 | 1307 | if (cameraAdmin) return await administer(req, res, cameraAdmin[1] === undefined ? null : Number(cameraAdmin[1]), cameraAdmin[2] !== undefined); |
| 1297 | 1308 | if (CAMERA_PROFILE.test(url.pathname)) return json(res, 404, { error: "no such route" }); |
| 1298 | 1309 | if (lobby && url.pathname === "/admin/lift") return await handleLift(lobby, req, res, readBody); |
| 1299 | if (lobby && (url.pathname === "/admin" || url.pathname === "/admin/health" || url.pathname === "/admin/night")) return await handleAdmin(lobby, req, res, url, readBody, askPrecision, askServos, videoCards, options.piSync ? () => options.piSync!.versions() : undefined, tuneP, lightHooks); | |
| 1310 | if (lobby && (url.pathname === "/admin" || url.pathname === "/admin/health" || url.pathname === "/admin/night")) return await handleAdmin(lobby, req, res, url, readBody, askPrecision, askServos, videoCards, options.piSync ? () => options.piSync!.versions() : undefined, tuneP, lightHooks, (what) => void care.log.add({ type: "action", what, cause: "owner" })); | |
| 1300 | 1311 | if (serveStory(req, res, url.pathname, { cameras: () => cameras.list(), numbers: storyNumbers, signedIn: lobby && !lobby.local ? (r) => lobby.signedIn(r) : undefined })) return; |
| 1301 | 1312 | if (url.pathname === "/admin/versions") return versionsRead(req, res); |
| 1302 | 1313 | if (lobby && url.pathname === "/admin/data") return serveDataPage(lobby, req, res); |
Software/so101/src/workshop-page.ts +4 −1
| @@ -92,6 +92,8 @@ export interface GrowNow { | ||
| 92 | 92 | next: { label: string; dueAt: number } | null; |
| 93 | 93 | /** Whether the Pi's server answers; null when there is no Pi behind this app. */ |
| 94 | 94 | pi: "online" | "offline" | null; |
| 95 | /** The light's real day from the care log (src/light-day.ts), line by line; null when it was not logged lately. */ | |
| 96 | light?: string[] | null; | |
| 95 | 97 | } |
| 96 | 98 | |
| 97 | 99 | /** Who is looking: the identity line's reading (src/signed-in.ts), null for a guest, and whether they own the place. */ |
| @@ -135,7 +137,8 @@ function growing(g: GrowNow | undefined, owner: boolean): string { | ||
| 135 | 137 | ? `<h3>${esc(g.title)}</h3><p>${g.hint ? `${esc(g.hint)}. ` : ""}Sown ${day(g.sownAt)}.</p>${owner ? `<details><summary>Change the sow date</summary>${sowForm(g.now, "Save the sow date")}</details>` : ""}` |
| 136 | 138 | : `<h3>Not planted yet</h3>${owner ? sowForm(g.now, "Set the sow date") : ""}`; |
| 137 | 139 | const care = [g.lastWatered !== null ? `Last watered: ${ago(g.now - g.lastWatered)}` : "Not watered yet", g.next ? `Next: ${esc(g.next.label)}, ${due(g.next.dueAt - g.now)}` : ""].filter(Boolean).join(" · "); |
| 138 | return `<div class="growing" data-growing>${offline}${plant}<p>${care}. <a href="/care">The log book</a></p></div>`; | |
| 140 | const light = g.light?.length ? `<p data-light>${g.light.map(esc).join(" · ")}</p>` : ""; | |
| 141 | return `<div class="growing" data-growing>${offline}${plant}${light}<p>${care}. <a href="/care">The log book</a></p></div>`; | |
| 139 | 142 | } |
| 140 | 143 | |
| 141 | 144 | /** A station's cameras, each at its own 16:9 shape: a row to swipe on a phone, side by side when the card is wide. Tap one for full screen. */ |
Software/so101/src/workshop-phone.test.ts +7
| @@ -291,3 +291,10 @@ test("the planted date is the sow: the day counts from it past a transplant, and | ||
| 291 | 291 | rmSync(dir, { recursive: true, force: true }); |
| 292 | 292 | } |
| 293 | 293 | }); |
| 294 | ||
| 295 | test("the grow card words the light's real day, escaped, and says nothing without a logged day", () => { | |
| 296 | const html = home({ light: ["Light on 08:30 → off due 20:30 (12 h)", "started 1 h 30 m late", "light still on?", "<b>x</b>"] }); | |
| 297 | expect(html).toContain('<p data-light>Light on 08:30 → off due 20:30 (12 h) · started 1 h 30 m late · light still on? · <b>x</b></p>'); | |
| 298 | expect(home({ light: null })).not.toContain("data-light"); | |
| 299 | expect(home()).not.toContain("data-light"); | |
| 300 | }); |
lastLight is imported but unused in this file. Remove the import.