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

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

The workshop on a phone: camera at its real shape (not cropped), both cameras, no broken empty timelapse, controls on one line, offline vs not-planted, owner can set the sow date #629

Merged opened this in answer to

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

Merged

vesper/arm:vesper/625-the-workshop-on-a-phone-camera-at-its-re → main 6 commits fork

  • Merged. vesper/625-the-workshop-on-a-phone-camera-at-its-re was merged into main as b57b2c6.

Review

Sign in to review this change.
13 files changed +702 −108
Software/so101/README.md +5 −4
@@ -128,16 +128,17 @@ ignored and said once on the arm server's stderr. The arm server reads it at sta
128128 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.
129129
130130 - **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.
131 - **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.
131132 - **"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.
132 - **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 the camera list): edit it to match the cameras. With only an arm in it, `/` is the arm's page as before.
133 - **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.
133134 - **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.
134135 - **Three doors.** The page `/admin/stations/edit` (owner; a raw JSON box that needs JavaScript, so it is awkward on a phone: prefer a laptop or the terminal), `GET /stations` (anyone: each station's `present`, why not, and its camera ids and names) and `PUT /admin/stations` (owner; at most 16 KB, 400 on bad JSON or a bad station), and in the terminal `/stations` to list and `/stations set <json list>` (owner) to replace them.
135136
136137 ## The grow box's stills, timelapse and log (on the Grow box card)
137138
138 The hosted app takes a still from the grow station's first camera every 10 minutes (`GROW_STILL_MINUTES`, 1 to 1440) and keeps it under `FORGE_APP_DATA/grow-stills`: 30 days, and no more than `GROW_STILLS_CAP_MB` (500) in all, the oldest going first. A still taken while the light is off is flagged dark and left out of the timelapse. The card plays today, the last 7 days or all of it, scrubbable, built in the browser from the stills (nothing is encoded). Under it: day N since the planted date, the owner's notes, light hours per day from the light's transitions, and "canopy (green area)" per day, the share of green pixels inside the box drawn in Tune. That is a measure of green area, not of health; the owner's browser works it out for stills that have none and sends it back.
139 The hosted app takes a still from the grow station's first camera every 10 minutes (`GROW_STILL_MINUTES`, 1 to 1440) and keeps it under `FORGE_APP_DATA/grow-stills`: 30 days, and no more than `GROW_STILLS_CAP_MB` (500) in all, the oldest going first. A still taken while the light is off is flagged dark and left out of the timelapse. The card plays today, the last 7 days or all of it, scrubbable, built in the browser from the stills (nothing is encoded). With no stills yet it is one line, "The first stills arrive within 10 minutes", never an empty frame or a dead slider; the range is one segmented control beside a play button, with **Corrected** as a small toggle, on one line at 360 px. Under it: day N since the sow logged in the care log (a `planted` date sent to `POST /grow/log` is logged as a sow too), the owner's notes, light hours per day from the light's transitions, and "canopy (green area)" per day, the share of green pixels inside the box drawn in Tune. That is a measure of green area, not of health; the owner's browser works it out for stills that have none and sends it back.
139140
140 **Full size, cropped, corrected.** When the grow camera is one of the Pi's (Tune shows its unit, `so101-camera@<n>`) and the arm is online, each still is grabbed at 1920×1080 (or the camera's nearest size), not taken from the stream. ustreamer holds the camera, so the arm server pauses that camera's stream for the grab: `POST /camera-still/<n>` stops `so101-camera@<n>`, takes one MJPEG frame with `v4l2-ctl` after 15 frames to settle, and starts the stream again whatever happened (src/camera-still.ts). The stream comes back at its own resolution, 640×360 on the grow camera, after a gap of about two seconds every ten minutes. The relay then crops the still to the box drawn on Tune, with `cropRect(look.region, width, height)` on the still's own size, and keeps that crop. When the camera has colour gains (*Measure colour* on Tune), a copy with the gains applied is kept beside it, and the card shows that copy with "corrected" beside its time. The **Corrected** button switches back to the raw colours. The raw still is never corrected; with no region it is the camera's JPEG byte for byte. Decoding a 1080p JPEG takes about half a second, so the relay does it in a worker (src/grow-frame.ts) and pages are not held up. The codec is jpeg-js, the one dependency this added: pure JavaScript with none of its own, since neither Bun nor Node can decode a JPEG. If the Pi cannot grab a still (no unit, arm offline, no sudo rule), the stream's own snapshot is used and cropped the same way. The pause needs the arm server's user to be allowed to stop and start the camera units, and nothing else:
141 **Full size, cropped, corrected.** When the grow camera is one of the Pi's (Tune shows its unit, `so101-camera@<n>`) and the arm is online, each still is grabbed at 1920×1080 (or the camera's nearest size), not taken from the stream. ustreamer holds the camera, so the arm server pauses that camera's stream for the grab: `POST /camera-still/<n>` stops `so101-camera@<n>`, takes one MJPEG frame with `v4l2-ctl` after 15 frames to settle, and starts the stream again whatever happened (src/camera-still.ts). The stream comes back at its own resolution, 640×360 on the grow camera, after a gap of about two seconds every ten minutes. The relay then crops the still to the box drawn on Tune, with `cropRect(look.region, width, height)` on the still's own size, and keeps that crop. When the camera has colour gains (*Measure colour* on Tune), a copy with the gains applied is kept beside it, and the card shows that copy with "corrected" beside its time. The **Corrected** toggle switches back to the raw colours. The raw still is never corrected; with no region it is the camera's JPEG byte for byte. Decoding a 1080p JPEG takes about half a second, so the relay does it in a worker (src/grow-frame.ts) and pages are not held up. The codec is jpeg-js, the one dependency this added: pure JavaScript with none of its own, since neither Bun nor Node can decode a JPEG. If the Pi cannot grab a still (no unit, arm offline, no sudo rule), the stream's own snapshot is used and cropped the same way. The pause needs the arm server's user to be allowed to stop and start the camera units, and nothing else:
141142
142143 ```sh
143144 echo 'so101 ALL=(root) NOPASSWD: /usr/bin/systemctl stop [email protected], /usr/bin/systemctl start [email protected], /usr/bin/systemctl stop [email protected], /usr/bin/systemctl start [email protected]' \
@@ -146,7 +147,7 @@ echo 'so101 ALL=(root) NOPASSWD: /usr/bin/systemctl stop [email protected],
146147
147148 (Replace `so101` with the user `so101.service` runs as.) The white photo light around each still (#589's optional part) is not done: the grow light's switch has no photo light yet.
148149
149 `GET /grow/stills?from=&to=&after=&limit=&dark=1` pages stills (`{stills, next, count, bytes, capBytes}`; each still says `cropped` and `corrected`), `GET /grow/stills/<ms>.jpg` one still, `GET /grow/stills/<ms>-corrected.jpg` its corrected copy, `GET /grow/log` the log and chart numbers; `POST /grow/log {planted?, note?}` and `POST /grow/canopy {readings}` are the owner's, and same-origin only. The chart is empty until the owner has opened the card once, since only that browser works out the readings. Without JavaScript the card shows the latest still and a link to `/grow/log`. From the terminal: `/grow log` reads the log, `/grow planted <YYYY-MM-DD>|none` and `/grow note <text>` (owner) write it through the same routes; the card shows a note on its next load.
150 `GET /grow/stills?from=&to=&after=&limit=&dark=1` pages stills (`{stills, next, count, bytes, capBytes}`; each still says `cropped` and `corrected`), `GET /grow/stills/<ms>.jpg` one still, `GET /grow/stills/<ms>-corrected.jpg` its corrected copy, `GET /grow/log` the log and chart numbers; `POST /grow/log {planted?, note?}` and `POST /grow/canopy {readings}` are the owner's, and same-origin only. The chart is empty until the owner has opened the card once, since only that browser works out the readings. Without JavaScript the card shows the latest still and a link to `/grow/log`. From the terminal: `/grow log` reads the log, `/grow planted <YYYY-MM-DD>|none` and `/grow note <text>` (owner) write it through the same routes. A planted date is logged as a sow in the care log, the one planted date, and the day `GET /grow/log` gives is days since that sow; `none` is refused while a sow is logged ("a sow is logged in /care; delete it there"); the card shows a note on its next load.
150151
151152 ## Plant care: /care
152153
Software/so101/src/care-page.ts +3 −3
@@ -1,6 +1,6 @@
11 /** The grow care page: health strip, reminders, quick-add, charts against the target bands, and the log book as a timeline with stage bands. Every button is a form; the one script only refreshes it live. */
22
3 import { boltingWatch, currentStage, health, LABELS, ago, readings, reminders, snapshot, stageAt, stageHistory, totals, transplantPrompt, type Snapshot } from "./care-rules.ts";
3 import { boltingWatch, currentStage, growing, health, LABELS, ago, readings, reminders, snapshot, stageAt, stageHistory, totals, transplantPrompt, type Snapshot } from "./care-rules.ts";
44 import { ACTIONS, PROBLEMS, SYSTEMS, type Entry, type CareLog } from "./care-log.ts";
55 import { MEASURES, type Band, type Measure, type Profile } from "./care-profile.ts";
66 import type { Doser } from "./doser.ts";
@@ -113,7 +113,7 @@ export function renderCare({ profile, log, now, owner, signedIn, error, dosing,
113113 const prompt = transplantPrompt(profile, snap, now);
114114 const bolt = boltingWatch(profile, snap, now);
115115 const sum = totals(snap.entries, now);
116 const days = Math.floor((now - cur.from) / DAY);
116 const grow = growing(profile, snap, now);
117117
118118 const quick = owner
119119 ? `<section class="panel" aria-labelledby="quick"><h2 id="quick" style="margin-top:0">Quick add</h2>
@@ -177,7 +177,7 @@ ${dosing.simulated ? `<p class="dim">No pumps are wired yet: a dose is only note
177177 <nav class="top" aria-label="Grow"><span>${renderSignedIn(signedIn, "/care")}</span><span><a href="/">Workshop</a></span></nav>
178178 <main id="main">
179179 ${error ? `<p class="alert" role="alert">${esc(error)}</p>` : ""}
180 <h1>${esc(profile.name)} <span class="dim">· ${esc(cur.stage.name)}, day ${days}</span></h1>
180 <h1>${esc(profile.name)} <span class="dim">· ${esc(grow.title)}</span></h1>${grow.hint ? `<p class="dim">${esc(grow.hint)}.</p>` : ""}
181181 <section class="health" data-ok="${h.ok}" aria-label="Health">${h.lines.map((l) => `<p>${esc(l)}</p>`).join("")}</section>
182182 ${prompt ? `<p class="panel" data-prompt="transplant">${esc(prompt)}</p>` : ""}${bolt ? `<p class="panel" data-prompt="bolting">${esc(bolt)}</p>` : ""}
183183 ${quick}
Software/so101/src/care-profile.ts +4
@@ -29,6 +29,8 @@ export interface Stage {
2929 bands: Partial<Record<Measure, Band>>;
3030 reminders: Reminder[];
3131 transplantCheck?: { afterDays: Band; label: string };
32 /** Seeds in this stage sprout within these days of sowing; until the last of them the stage goes by `label` ("Germinating"). */
33 sprout?: { days: Band; label: string };
3234 }
3335
3436 export interface Profile {
@@ -95,12 +97,14 @@ export function parseProfile(input: unknown): Profile {
9597 return { id: rid, label: words(r.label, "a reminder's label"), everyHours: hours(r.everyHours), ...(r.tower !== undefined ? { tower: hours(r.tower) } : {}), ...(r.needs ? { needs: r.needs } : {}), resets: resets as string[] };
9698 });
9799 const tc = s.transplantCheck === undefined ? undefined : obj(s.transplantCheck, "transplantCheck");
100 const sp = s.sprout === undefined ? undefined : obj(s.sprout, "sprout");
98101 return {
99102 id: id(s.id, "a stage"),
100103 name: words(s.name, "a stage's name", 60),
101104 bands,
102105 reminders,
103106 ...(tc ? { transplantCheck: { afterDays: band(tc.afterDays, "afterDays"), label: words(tc.label, "the check's label") } } : {}),
107 ...(sp ? { sprout: { days: band(sp.days, "sprout days"), label: words(sp.label, "the sprout label", 40) } } : {}),
104108 };
105109 });
106110 if (new Set(stages.map((s) => s.id)).size !== stages.length) throw new ProfileError("two stages share an id");
Software/so101/src/care-profiles/spinach.json +2 −1
@@ -15,7 +15,8 @@
1515 { "id": "bottom-water", "label": "Bottom-water or refill the tray at this stage's EC and pH, then log it", "everyHours": 60, "resets": ["action:water", "action:topoff"] },
1616 { "id": "thin", "label": "Thin to the strongest seedling per cube once true leaves show", "everyHours": 168, "resets": ["action:thin"] }
1717 ],
18 "transplantCheck": { "afterDays": [14, 21], "label": "Roots out of the cube bottom and 2-4 true leaves?" }
18 "transplantCheck": { "afterDays": [14, 21], "label": "Roots out of the cube bottom and 2-4 true leaves?" },
19 "sprout": { "days": [3, 7], "label": "Germinating" }
1920 },
2021 {
2122 "id": "hydroponic",
Software/so101/src/care-routes.ts +5 −1
@@ -115,6 +115,10 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path
115115 return true;
116116 }
117117 const input = fields(req, raw.toString("utf8"));
118 // A form may come from the home's grow card, which wants to land back there; nowhere else is offered.
119 const back = input.then === "home" ? "/" : "/care";
120 // A date alone (the sow form's picker) is that day's UTC midnight, and never later than now: a phone east of UTC may already be on tomorrow.
121 if (typeof input.at === "string" && /^\d{4}-\d{2}-\d{2}$/.test(input.at)) input.at = Math.min(Date.parse(`${input.at}T00:00Z`), now);
118122 let result: unknown = { ok: true };
119123 const id = entry?.[1] ? Number(entry[1]) : null;
120124 if (calibrate) {
@@ -135,7 +139,7 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path
135139 } else if (req.method === "PUT") {
136140 result = ctx.log.update(id, input);
137141 } else throw new LogError("PUT edits, DELETE removes");
138 if (form) send(303, "text/plain", "", { location: "/care" });
142 if (form) send(303, "text/plain", "", { location: back });
139143 else json(200, result);
140144 } catch (err) {
141145 const msg = err instanceof LogError ? err.message : err instanceof SyntaxError ? "send JSON" : null;
Software/so101/src/care-rules.ts +43 −1
@@ -27,10 +27,17 @@ const transplantOf = (entries: readonly Entry[]): { at: number; t: Transplant }
2727 return null;
2828 };
2929
30 /** When the seeds went in: the sow action logged last (a corrected date replaces the first), null when none is. */
31 export function sownAt(entries: readonly Entry[]): number | null {
32 let sow: Entry | null = null;
33 for (const e of entries) if (e.type === "action" && e.what === "sow" && (!sow || e.id > sow.id)) sow = e;
34 return sow?.at ?? null;
35 }
36
3037 /** The stages the plant has been through, oldest first: the transplant moves it on and the history stays. */
3138 export function stageHistory(profile: Profile, entries: readonly Entry[], startedAt: number): StageSpan[] {
3239 const [seedling, hydro, harvest] = profile.stages;
33 const first = entries.find((e) => e.type === "action" && e.what === "sow")?.at ?? Math.min(startedAt, entries[0]?.at ?? startedAt);
40 const first = sownAt(entries) ?? Math.min(startedAt, entries[0]?.at ?? startedAt);
3441 const spans: StageSpan[] = [{ stage: seedling!, from: first }];
3542 const moved = transplantOf(entries);
3643 if (moved && hydro) {
@@ -145,3 +152,38 @@ export function boltingWatch(profile: Profile, snap: Snapshot, now: number): str
145152 const hot = readings(snap.entries, "airTemp").some((r) => r.at > now - 3 * DAY && r.value > 24);
146153 return hot ? "Air was above 24 °C lately: is a stalk forming?" : null;
147154 }
155
156 export interface Growing {
157 /** The sow date's instant; null when no sow is logged (the plant's age is then unknown, not zero). */
158 sownAt: number | null;
159 /** Whole days in the current stage: day 0 is the sow day. */
160 day: number;
161 /** "Germinating: day 0", or the stage's name and day. */
162 title: string;
163 /** When the sprouts are due, while they are; else null. */
164 hint: string | null;
165 }
166
167 /** Where the plant is, in the words the grow card and the care page share: the stage (or "Germinating" until its sprouts are due) and its day. */
168 export function growing(profile: Profile, snap: Snapshot, now: number): Growing {
169 const sown = sownAt(snap.entries);
170 const spans = stageHistory(profile, snap.entries, snap.startedAt);
171 const cur = currentStage(spans);
172 const day = Math.max(0, Math.floor((now - cur.from) / DAY));
173 const sprout = spans.length === 1 && sown !== null ? cur.stage.sprout : undefined;
174 if (!sprout || day > sprout.days[1]) return { sownAt: sown, day, title: `${cur.stage.name}: day ${day}`, hint: null };
175 const [lo, hi] = sprout.days;
176 const hint = day < lo ? `Sprouts expected in about ${lo - day}–${hi - day} days` : `Sprouts expected by day ${hi}`;
177 return { sownAt: sown, day, title: `${sprout.label}: day ${day}`, hint };
178 }
179
180 /** The reminder due soonest, snoozed ones left out; null when there is none. */
181 export function nextDue(profile: Profile, snap: Snapshot, now: number): Due | null {
182 return reminders(profile, snap, now).find((d) => !d.snoozedUntil) ?? null;
183 }
184
185 /** When the plants were last watered (a water, top-off or water change logged), null when never. */
186 export function lastWatered(entries: readonly Entry[], now: number): number | null {
187 const e = entries.filter((x) => x.type === "action" && (x.what === "water" || x.what === "topoff" || x.what === "change") && x.at <= now).pop();
188 return e?.at ?? null;
189 }
Software/so101/src/relay.ts +28 −6
@@ -106,17 +106,18 @@ import { readServos } from "./servo-panel.ts";
106106 import { parseFps, type FeedOptions } from "./camera-feed.ts";
107107 import { CameraError, CameraHub } from "./cameras.ts";
108108 import { ARM_ONLY, StationError, StationHub } from "./stations.ts";
109 import { CareLog } from "./care-log.ts";
109 import { CareLog, LogError } from "./care-log.ts";
110 import { growing, lastWatered, nextDue, snapshot, sownAt } from "./care-rules.ts";
110111 import { BUILT_IN_DIR, loadProfiles, type Profile } from "./care-profile.ts";
111112 import { ProbeCache, readProbeView, sampleProbes, waterTemp, type ProbeHooks } from "./care-probes.ts";
112113 import { handleCare } from "./care-routes.ts";
113114 import { handleDosing, makeDoser } from "./care-dosing.ts";
114115 import type { Doser } from "./doser.ts";
115116 import { armPumps, cachedRead, readDosing, type PumpHooks } from "./arm-pumps.ts";
116 import { renderWorkshop, type StationView } from "./workshop-page.ts";
117 import { renderWorkshop, type GrowNow, type StationView } from "./workshop-page.ts";
117118 import { DAY_MS, GrowStills, KEEP_DAYS } from "./grow-stills.ts";
118119 import { finishStillApart } from "./grow-frame.ts";
119 import { canopyByDay, dayNumber, GrowLog, GrowLogError, lightHours } from "./grow-log.ts";
120 import { canopyByDay, dayNumber, dayOf, GrowLog, GrowLogError, lightHours } from "./grow-log.ts";
120121 import { parseProfile, ProfileError, type CameraProfile } from "./camera-profile.ts";
121122 import { VideoFeed, type VideoSources } from "./video-feed.ts";
122123 import type { VideoCard } from "./video-panel.ts";
@@ -234,7 +235,7 @@ const STILL_FRAME = new URL("./ui/still-frame.js", import.meta.url);
234235 const STATIONS_PAGE = readFileSync(new URL("./stations-admin.html", import.meta.url), "utf8");
235236 const GROW_HINT = new URL("./ui/grow-hint.js", import.meta.url);
236237 /** The grow card's hint module, versioned by its own content and the module it imports. */
237 const GROW_MODULES = ["grow-timelapse.js", "grow-canopy.js"].map((f) => new URL(`./ui/${f}`, import.meta.url));
238 const GROW_MODULES = ["grow-timelapse.js", "grow-canopy.js", "station-cams.js"].map((f) => new URL(`./ui/${f}`, import.meta.url));
238239 const SIGHT_VERSION = createHash("sha256").update(readFileSync(GROW_HINT)).update(readFileSync(STILL_FRAME)).update(Buffer.concat(GROW_MODULES.map((f) => readFileSync(f)))).digest("hex").slice(0, 12);
239240 const CAMERA_PAGE = readFileSync(new URL("./camera-list.html", import.meta.url), "utf8").replaceAll(
240241 "/ui/__VERSION__/",
@@ -827,6 +828,17 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
827828 res.end(out);
828829 };
829830
831 /** Whether the Pi's server answers: null when this relay has none (the simulation is the whole box). */
832 const piState = (): "online" | "offline" | null => (remote ? (onArm() ? "online" : "offline") : null);
833 /** The grow card's first paint: the plant's day, the last watering and the next reminder, all from the care log, and the Pi's state. */
834 const growNow = (): GrowNow => {
835 const now = Date.now();
836 const snap = snapshot(care.log);
837 const plant = growing(care.profile, snap, now);
838 const next = nextDue(care.profile, snap, now);
839 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() };
840 };
841
830842 /** 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). */
831843 const stationViews = (): StationView[] => {
832844 const listed = new Map(cameras.list().map((c) => [c.id, c.name]));
@@ -1050,7 +1062,11 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
10501062 const zone = light?.zone || "UTC";
10511063 const now = Date.now();
10521064 const view = grow.log.view();
1053 json(res, 200, { ...view, day: dayNumber(view.planted, now, zone), zone, lightHours: light ? lightHours(light.log, now, zone) : [], canopy: canopyByDay(grow.stills.readings(now - KEEP_DAYS * DAY_MS), zone), stills: grow.stills.usage(), everyMs: grow.everyMs, keepDays: KEEP_DAYS });
1065 // The care log's sow is the one planted date; the grow log's own is kept for a box with no sow logged.
1066 const plant = growing(care.profile, snapshot(care.log), now);
1067 const planted = plant.sownAt !== null ? dayOf(plant.sownAt, zone) : view.planted;
1068 // The day is counted from the sow, whatever stage the plant is in now; the stage's own day is in `stage`.
1069 json(res, 200, { ...view, planted, day: plant.sownAt !== null ? Math.max(0, Math.floor((now - plant.sownAt) / DAY_MS)) : dayNumber(view.planted, now, zone), stage: plant.sownAt !== null ? plant.title : null, pi: piState(), zone, lightHours: light ? lightHours(light.log, now, zone) : [], canopy: canopyByDay(grow.stills.readings(now - KEEP_DAYS * DAY_MS), zone), stills: grow.stills.usage(), everyMs: grow.everyMs, keepDays: KEEP_DAYS });
10541070 return true;
10551071 }
10561072 if (req.method === "POST" && (url.pathname === "/grow/log" || url.pathname === "/grow/canopy")) {
@@ -1063,8 +1079,14 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
10631079 }
10641080 if (url.pathname === "/grow/log") {
10651081 try {
1082 // Clearing the planted date would only clear the fallback while the sow stays logged: the sow is deleted on /care.
1083 if ((body as { planted?: unknown } | null)?.planted === null && sownAt(care.log.entries()) !== null) return json(res, 400, { error: "a sow is logged in /care; delete it there" }), true;
10661084 grow.log.change(body, Date.now());
1085 // A planted date is the care log's sow, so the card, /care and the terminal agree on the plant's day.
1086 const planted = (body as { planted?: unknown }).planted;
1087 if (typeof planted === "string") care.log.add({ type: "action", what: "sow", at: Math.min(Date.parse(`${planted}T00:00Z`), Date.now()), cause: "owner" });
10671088 } catch (err) {
1089 if (err instanceof LogError) return json(res, 400, { error: err.message }), true;
10681090 if (err instanceof GrowLogError) return json(res, 400, { error: err.message }), true;
10691091 throw err;
10701092 }
@@ -1255,7 +1277,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
12551277 if (req.method === "GET" && /^\/(?:index\.html)?$/.test(url.pathname) && stations.isWorkshop()) {
12561278 res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
12571279 const who = lobby?.who(req);
1258 return res.end(renderWorkshop(stationViews(), { signedIn: lobby && !lobby.local ? lobby.signedIn(req) : null, owner: Boolean(who?.owner), grow: Boolean(options.grow), latest: options.grow?.stills.latest()?.at ?? null }).replaceAll("/ui/__VERSION__/", `/ui/${SIGHT_VERSION}/`));
1280 return res.end(renderWorkshop(stationViews(), { signedIn: lobby && !lobby.local ? lobby.signedIn(req) : null, owner: Boolean(who?.owner), grow: Boolean(options.grow), latest: options.grow?.stills.latest()?.at ?? null, everyMs: options.grow?.everyMs, growNow: growNow() }).replaceAll("/ui/__VERSION__/", `/ui/${SIGHT_VERSION}/`));
12591281 }
12601282 if (req.method === "GET" && PAGE.test(url.pathname) && rest) return restingPage(req, res, rest);
12611283 if (req.method === "GET" && PAGE.test(url.pathname)) return await page(req, res, await ask(simulation + path.replace(/^\/arm(?=$|\?)/, "/"), req, undefined, null, false));
Software/so101/src/stations.ts +6 −2
@@ -37,8 +37,12 @@ const text = (value: unknown, what: string, max: number): string => {
3737 /** The arm alone: the home is then the arm's own page. */
3838 export const ARM_ONLY: readonly Station[] = [{ id: "arm", name: "The arm", kind: "arm", cameras: [1], controls: [{ label: "Drive the arm", href: "/arm" }] }];
3939
40 /** What the file holds when there is none: the arm and the grow box (its camera is the second one listed; the owner edits both on /admin/stations/edit). */
41 export const WORKSHOP: readonly Station[] = [...ARM_ONLY, { id: "grow", name: "Grow box", kind: "grow", cameras: [2], controls: [{ label: "Care", href: "/care" }] }];
40 /**
41 * What the file holds when there is none: the arm and the grow box. The grow box is seen from above (the second camera,
42 * whose stills make the timelapse) and from the side (the arm's camera, which sees the plants too, and stays on the
43 * grow card while the arm is unplugged). The owner edits both on /admin/stations/edit.
44 */
45 export const WORKSHOP: readonly Station[] = [...ARM_ONLY, { id: "grow", name: "Grow box", kind: "grow", cameras: [2, 1], controls: [{ label: "Care", href: "/care" }] }];
4246
4347 /** Any JSON → the stations it describes; throws a StationError saying what is wrong. */
4448 export function parseStations(input: unknown): Station[] {
Software/so101/src/terminal.ts +1 −1
@@ -45,7 +45,7 @@
4545 * /servos owner: each joint's servo control registers /admin shows (GET /servo-params, src/servo-panel.ts); 404 to anyone else
4646 * /tune <joint> <P> owner: the P experiment /admin's Servos "Try this P" runs (POST /tuning/p): sweep, write, sweep, keep or put back; 404 to anyone else
4747 * /grow log the grow log (GET /grow/log): day N, the stills' store, light hours, canopy (green area), the latest notes
48 * /grow planted <YYYY-MM-DD>|none, /grow note <text> owner: the planted date, or a note (POST /grow/log); the card shows it at once on its next load
48 * /grow planted <YYYY-MM-DD>|none, /grow note <text> owner: the planted date (logged as a sow; none is refused while one is), or a note (POST /grow/log); the card shows it at once on its next load
4949 * /grow owner: the light and the pump as /admin's Grow card shows them (GET /grow); 404 to anyone else
5050 * /grow [light|pump] on|off|clear owner: (no device word is the light) override until the next change, or give it back to the schedule (POST /grow/<device>)
5151 * /grow <light|pump> schedule 06:00 12 | 5 25 | off owner: a daily photoperiod (on at, hours) or an interval cycle (minutes on, off), or none
Software/so101/src/ui/grow-timelapse.js +80 −64
@@ -3,7 +3,8 @@
33 * The grow card's timelapse, log and canopy chart, built in the browser from the stills the relay keeps
44 * (GET /grow/stills, /grow/stills/<at>.jpg, /grow/log): no video is encoded anywhere. Plain ESM served at /ui/.
55 * A still with a colour-corrected copy (/grow/stills/<at>-corrected.jpg) is shown corrected, labelled so, unless
6 * the "Corrected" button is turned off.
6 * the "Corrected" toggle is turned off. The range is one segmented control beside a play button, on one line at 360 px.
7 * With no stills at all the card is one quiet line: the first ones are on their way.
78 * The page has `[data-grow]` holding the camera's box as `data-region` JSON and `data-owner="1"` for the owner.
89 */
910 import { greenFraction } from "./grow-canopy.js";
@@ -113,8 +114,10 @@ const el = (tag, text, attrs = {}) => {
113114 return e;
114115 };
115116
117 /** The line in place of the timelapse while there are no stills at all. */
118 export const firstStills = (minutes) => `The first stills arrive within ${Math.max(1, Math.round(Number(minutes) || 10))} minutes.`;
119
116120 export async function growCard(root) {
117 root.replaceChildren();
118121 const owner = root.dataset.owner === "1";
119122 let region = null;
120123 try {
@@ -122,52 +125,65 @@ export async function growCard(root) {
122125 } catch {
123126 region = null;
124127 }
125 const view = el("img", undefined, { alt: "The grow box, from the timelapse", width: "640", height: "480" });
126 const slider = el("input", undefined, { type: "range", min: "0", max: "0", value: "0", "aria-label": "Scrub the timelapse" });
127 const when = el("output", "");
128 const play = el("button", "Play", { type: "button" });
129 const fix = el("button", "Corrected", { type: "button", "aria-pressed": "true", hidden: "", title: "Show the stills with the camera's colour correction" });
130 const buttons = Object.entries(RANGES).map(([key, label]) => el("button", label, { type: "button", "data-range": key }));
131 const bar = el("div", undefined, { class: "controls" });
132 bar.append(...buttons, play, fix);
133 const lapse = el("div", undefined, { class: "lapse" });
134 lapse.append(bar, view, slider, when);
135128 const log = el("div", undefined, { class: "growlog" });
136129 const problem = el("p", "", { class: "problem", role: "status", "aria-live": "polite" });
130 const any = await get("/grow/stills?limit=1");
131 if (!any.stills.length) {
132 // Nothing to play: one quiet line, never an empty frame, a broken image or a dead slider.
133 root.replaceChildren(el("p", firstStills(root.dataset.every), { class: "quiet" }), log, problem);
134 return drawLogInto(log, problem, owner);
135 }
136 root.replaceChildren();
137 const view = el("img", undefined, { alt: "", width: "1280", height: "720" });
138 const slider = el("input", undefined, { type: "range", min: "0", max: "0", value: "0", "aria-label": "Scrub the timelapse" });
139 const when = el("output", "");
140 const play = el("button", "▶", { type: "button", class: "play", "aria-label": "Play" });
141 const fixBox = el("input", undefined, { type: "checkbox", checked: "" });
142 const fix = el("label", undefined, { class: "fix", hidden: "", title: "Show the stills with the camera's colour correction" });
143 fix.append(fixBox, document.createTextNode("Corrected"));
144 const buttons = Object.entries(RANGES).map(([key, label]) => el("button", label, { type: "button", "data-range": key }));
145 const seg = el("div", undefined, { class: "seg", role: "group", "aria-label": "Range" });
146 seg.append(...buttons);
147 const bar = el("div", undefined, { class: "bar" });
148 bar.append(seg, play, fix);
149 const lapse = el("div", undefined, { class: "lapse" });
150 lapse.append(bar, view, slider, when);
137151 root.append(lapse, log, problem);
152 const drawLog = () => drawLogInto(log, problem, owner);
138153
139154 let stills = [];
140155 let timer = null;
141156 const show = (i) => {
142157 const s = stills[i];
143158 if (!s) return;
144 const v = stillView(s, fix.getAttribute("aria-pressed") === "true");
159 const v = stillView(s, fixBox.checked);
145160 view.src = v.src;
161 view.alt = `The grow box, ${new Date(s.at).toLocaleString()}`;
146162 when.textContent = new Date(s.at).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }) + (v.label ? ` · ${v.label}` : "");
147163 slider.value = String(i);
148164 };
149165 const stop = () => {
150166 if (timer) clearInterval(timer);
151167 timer = null;
152 play.textContent = "Play";
168 play.textContent = "▶";
169 play.setAttribute("aria-label", "Play");
153170 };
154171 play.addEventListener("click", () => {
155172 if (timer) return stop();
156173 if (Number(slider.value) >= stills.length - 1) show(0);
157 play.textContent = "Pause";
174 play.textContent = "❚❚";
175 play.setAttribute("aria-label", "Pause");
158176 timer = setInterval(() => (Number(slider.value) + 1 >= stills.length ? stop() : show(Number(slider.value) + 1)), 150);
159177 });
160178 slider.addEventListener("input", () => (stop(), show(Number(slider.value))));
161 fix.addEventListener("click", () => {
162 fix.setAttribute("aria-pressed", String(fix.getAttribute("aria-pressed") !== "true"));
163 show(Number(slider.value));
164 });
179 fixBox.addEventListener("change", () => show(Number(slider.value)));
165180 const pick = async (range) => {
166181 stop();
167182 for (const b of buttons) b.setAttribute("aria-pressed", String(b.dataset.range === range));
168183 stills = await load(rangeStart(range));
169184 slider.max = String(Math.max(0, stills.length - 1));
170185 fix.hidden = !stills.some((s) => s.corrected);
186 view.hidden = slider.hidden = !stills.length;
171187 if (!stills.length) {
172188 view.removeAttribute("src");
173189 when.textContent = "No stills in this range yet.";
@@ -178,54 +194,54 @@ export async function growCard(root) {
178194 };
179195 for (const b of buttons) b.addEventListener("click", () => void pick(b.dataset.range));
180196
181 async function drawLog() {
182 const data = await get("/grow/log");
183 log.replaceChildren();
184 log.append(el("h3", data.planted ? `Day ${data.day ?? "?"} · planted ${data.planted}` : "Not planted yet"));
185 log.append(el("p", `Stills: ${data.stills.count}, ${(data.stills.bytes / 1e6).toFixed(1)} MB of ${(data.stills.capBytes / 1e6).toFixed(0)} MB, one every ${Math.round(data.everyMs / 60000)} min, kept ${data.keepDays} days.`));
186 const hours = data.lightHours.filter((d) => d.hours > 0 || data.lightHours.length < 4);
187 log.append(el("p", hours.length ? `Light hours per day: ${hours.slice(-7).map((d) => `${d.day.slice(5)} ${d.hours}`).join(" · ")}` : "Light hours: no schedule log yet."));
188 log.append(el("h3", "Canopy (green area)"));
189 if (data.canopy.length) {
190 const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
191 svg.setAttribute("viewBox", "0 0 300 80");
192 svg.setAttribute("role", "img");
193 svg.setAttribute("aria-label", `Canopy (green area) per day: ${data.canopy.map((d) => `${d.day} ${Math.round(d.green * 100)}%`).join(", ")}`);
194 const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
195 line.setAttribute("points", chartPoints(data.canopy, 300, 80));
196 line.setAttribute("fill", "none");
197 line.setAttribute("stroke", "currentColor");
198 line.setAttribute("stroke-width", "2");
199 svg.append(line);
200 log.append(svg, el("p", `Share of the box that is green, per day: ${Math.round(data.canopy.at(-1).green * 100)}% on ${data.canopy.at(-1).day}. Green area only, not a health reading.`));
201 } else log.append(el("p", "No canopy readings yet" + (owner ? ": they appear as you look at the stills." : ".")));
202 const notes = el("ul");
203 for (const n of data.notes.slice(-10).reverse()) notes.append(el("li", `${new Date(n.at).toLocaleDateString()} ${n.text}`));
204 log.append(notes);
205 if (owner) {
206 const form = el("form");
207 const date = el("input", undefined, { type: "date", name: "planted", "aria-label": "Planted on", value: data.planted ?? "" });
208 const note = el("input", undefined, { type: "text", name: "note", maxlength: "280", placeholder: "Watered, thinned…", "aria-label": "Add a note" });
209 form.append(date, note, el("button", "Save", { type: "submit" }));
210 form.addEventListener("submit", async (e) => {
211 e.preventDefault();
212 const body = {};
213 if (date.value !== (data.planted ?? "")) body.planted = date.value || null;
214 if (note.value.trim()) body.note = note.value;
215 try {
216 if (Object.keys(body).length) await send("/grow/log", body);
217 problem.textContent = "";
218 } catch (err) {
219 problem.textContent = `Could not save: ${err.message}`;
220 }
221 await drawLog();
222 });
223 log.append(form);
224 }
225 }
226197 await Promise.all([pick("today").then(() => (stills.length ? undefined : pick("week"))), drawLog()]);
227198 }
228199
200 async function drawLogInto(log, problem, owner) {
201 const data = await get("/grow/log");
202 log.replaceChildren();
203 const kept = data.stills.count > 0;
204 if (kept) log.append(el("p", `Stills: ${data.stills.count}, ${(data.stills.bytes / 1e6).toFixed(1)} MB of ${(data.stills.capBytes / 1e6).toFixed(0)} MB, one every ${Math.round(data.everyMs / 60000)} min, kept ${data.keepDays} days.`));
205 const hours = data.lightHours.filter((d) => d.hours > 0 || data.lightHours.length < 4);
206 log.append(el("p", hours.length ? `Light hours per day: ${hours.slice(-7).map((d) => `${d.day.slice(5)} ${d.hours}`).join(" · ")}` : "Light hours: no schedule log yet."));
207 if (kept) log.append(el("h3", "Canopy (green area)"));
208 // No stills, no canopy to read: nothing is said about it.
209 if (kept && data.canopy.length) {
210 const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
211 svg.setAttribute("viewBox", "0 0 300 80");
212 svg.setAttribute("role", "img");
213 svg.setAttribute("aria-label", `Canopy (green area) per day: ${data.canopy.map((d) => `${d.day} ${Math.round(d.green * 100)}%`).join(", ")}`);
214 const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
215 line.setAttribute("points", chartPoints(data.canopy, 300, 80));
216 line.setAttribute("fill", "none");
217 line.setAttribute("stroke", "currentColor");
218 line.setAttribute("stroke-width", "2");
219 svg.append(line);
220 log.append(svg, el("p", `Share of the box that is green, per day: ${Math.round(data.canopy.at(-1).green * 100)}% on ${data.canopy.at(-1).day}. Green area only, not a health reading.`));
221 } else if (kept) log.append(el("p", "No canopy readings yet" + (owner ? ": they appear as you look at the stills." : ".")));
222 const notes = el("ul");
223 for (const n of data.notes.slice(-10).reverse()) notes.append(el("li", `${new Date(n.at).toLocaleDateString()} ${n.text}`));
224 log.append(notes);
225 if (owner) {
226 const form = el("form");
227 const note = el("input", undefined, { type: "text", name: "note", maxlength: "280", placeholder: "A note on the box…", "aria-label": "Add a note" });
228 form.append(note, el("button", "Save", { type: "submit" }));
229 form.addEventListener("submit", async (e) => {
230 e.preventDefault();
231 const body = {};
232 if (note.value.trim()) body.note = note.value;
233 try {
234 if (Object.keys(body).length) await send("/grow/log", body);
235 problem.textContent = "";
236 } catch (err) {
237 problem.textContent = `Could not save: ${err.message}`;
238 }
239 await drawLogInto(log, problem, owner);
240 });
241 log.append(form);
242 }
243 }
244
229245 /** @param {{ querySelectorAll: Function }} doc */
230246 export function watchGrow(doc) {
231247 for (const root of doc.querySelectorAll("[data-grow]")) void growCard(root).catch((err) => root.append(el("p", `The timelapse could not load: ${err.message}`, { role: "status" })));
Software/so101/src/ui/station-cams.js added +113
@@ -0,0 +1,113 @@
1 // @ts-nocheck -- DOM code; the project has no DOM lib. The pure helpers are covered by station-cams.test.ts.
2 /**
3 * A station's cameras on the home (src/workshop-page.ts): a row of 16:9 stills to swipe through, buttons to pick one,
4 * the last one picked remembered per station, a tap for full screen (the still itself where the browser has no
5 * full-screen element, so a phone can pinch it), and "Zoom to the box" for a camera with a crop region (#584).
6 * Plain ESM served at /ui/.
7 */
8
9 /**
10 * The CSS transform that fills the frame with a region (fractions of the frame), its centre in the middle; "" for none.
11 * The frame and the still share their shape, so one scale fits the region's larger side.
12 * @param {{ x: number, y: number, w: number, h: number } | null} region
13 */
14 export function boxTransform(region) {
15 if (!region || !(region.w > 0) || !(region.h > 0)) return "";
16 const scale = Math.min(8, 1 / Math.max(region.w, region.h));
17 const round = (n) => Math.round(n * 1000) / 1000;
18 const dx = round((0.5 - (region.x + region.w / 2)) * 100);
19 const dy = round((0.5 - (region.y + region.h / 2)) * 100);
20 return `scale(${round(scale)}) translate(${dx}%, ${dy}%)`;
21 }
22
23 /**
24 * Which camera to show first: the one remembered, when it is still listed; else the first.
25 * @param {number[]} ids
26 * @param {string | null} saved
27 */
28 export function pickedIndex(ids, saved) {
29 const i = ids.indexOf(Number(saved));
30 return i < 0 ? 0 : i;
31 }
32
33 /**
34 * Which still a row scrolled to `left` shows, each `width` wide.
35 * @param {number} left
36 * @param {number} width
37 * @param {number} count
38 */
39 export function indexAt(left, width, count) {
40 if (!(width > 0) || count < 1) return 0;
41 return Math.max(0, Math.min(count - 1, Math.round(left / width)));
42 }
43
44 const KEY = (station) => `workshop-camera:${station}`;
45
46 /**
47 * @param {Document} doc
48 * @param {Storage | undefined} store
49 */
50 export function watchCams(doc, store) {
51 const read = (k) => {
52 try {
53 return store?.getItem(k) ?? null;
54 } catch {
55 return null; // storage refused (private mode): nothing is remembered, which is fine
56 }
57 };
58 const write = (k, v) => {
59 try {
60 store?.setItem(k, v);
61 } catch {}
62 };
63 for (const root of doc.querySelectorAll("[data-cams]")) {
64 const reel = root.querySelector(".reel");
65 const figures = [...root.querySelectorAll(".still")];
66 const ids = figures.map((f) => Number(f.dataset.cam));
67 const picks = [...root.querySelectorAll("[data-pick]")];
68 const zoom = root.querySelector("[data-zoom]");
69 const mark = (i) => {
70 for (const b of picks) b.setAttribute("aria-pressed", String(Number(b.dataset.pick) === ids[i]));
71 };
72 const go = (i, smooth) => {
73 reel.scrollTo({ left: i * reel.clientWidth, behavior: smooth ? "smooth" : "instant" });
74 mark(i);
75 };
76 const start = pickedIndex(ids, read(KEY(root.dataset.cams)));
77 if (start) go(start, false);
78 else mark(0);
79 for (const b of picks) b.addEventListener("click", () => go(Math.max(0, ids.indexOf(Number(b.dataset.pick))), true));
80 let settle = null;
81 reel.addEventListener("scroll", () => {
82 clearTimeout(settle);
83 settle = setTimeout(() => {
84 const i = indexAt(reel.scrollLeft, reel.clientWidth, figures.length);
85 mark(i);
86 write(KEY(root.dataset.cams), String(ids[i]));
87 }, 120);
88 }, { passive: true });
89 zoom?.addEventListener("click", () => {
90 const on = zoom.getAttribute("aria-pressed") !== "true";
91 zoom.setAttribute("aria-pressed", String(on));
92 for (const f of figures) {
93 let region = null;
94 try {
95 region = JSON.parse(f.dataset.box ?? "null");
96 } catch {}
97 f.querySelector("img").style.transform = on ? boxTransform(region) : "";
98 }
99 });
100 for (const f of figures) {
101 const img = f.querySelector("img");
102 img.addEventListener("error", () => f.setAttribute("data-off", ""), { once: true });
103 // Full screen where the browser can put an element there; elsewhere (an iPhone) the link opens the still to pinch.
104 const link = f.querySelector("[data-full]");
105 link.addEventListener("click", (e) => {
106 if (doc.fullscreenElement === f) return void (e.preventDefault(), doc.exitFullscreen());
107 if (!f.requestFullscreen) return;
108 e.preventDefault();
109 f.requestFullscreen().catch(() => (location.href = link.href));
110 });
111 }
112 }
113 }
Software/so101/src/workshop-page.ts +119 −25
@@ -19,19 +19,47 @@ export interface StationView {
1919 const STYLE = `
2020 :root { color-scheme: dark light; --bg: #0b0d10; --panel: #11161d; --fg: #e8eaed; --dim: #9aa4ae; --line: #2a313a; --accent: #f0883e; --on-accent: #1a1206; --link: #79b8ff; }
2121 @media (prefers-color-scheme: light) { :root { --bg: #fbfaf7; --panel: #fff; --fg: #1f2328; --dim: #59636e; --line: #d8dee4; --accent: #bc4c00; --on-accent: #fff; --link: #0969da; } }
22 body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.5 ui-sans-serif, system-ui, sans-serif; }
23 main { max-width: 60rem; margin: 0 auto; padding: 1rem; }
22 body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.5 ui-sans-serif, system-ui, sans-serif; padding-bottom: calc(56px + env(safe-area-inset-bottom)); }
23 main { max-width: 60rem; margin: 0 auto; padding: 1rem max(1rem, env(safe-area-inset-right)) 1rem max(1rem, env(safe-area-inset-left)); }
2424 h1 { margin: .5rem 0 1rem; } h2 { margin: .6rem 0; font-size: 1.15rem; }
2525 .cards { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); }
26 .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: .8rem; }
27 .card img { width: 100%; aspect-ratio: 4/3; object-fit: cover; border-radius: 6px; background: #000; display: block; }
28 .still { position: relative; }
26 .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: .8rem; min-width: 0; }
27 @media (min-width: 48rem) { .card[data-kind=grow] { grid-column: 1 / -1; } }
28 @media (max-width: 30rem) { main { padding: .75rem; } .card { padding: .6rem; } }
29 .card img { width: 100%; height: auto; aspect-ratio: 16/9; object-fit: contain; border-radius: 6px; background: #000; display: block; }
30 .cams { container: cams / inline-size; }
31 .reel { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; gap: .5rem; }
32 .reel::-webkit-scrollbar { display: none; }
33 .still { position: relative; flex: 0 0 100%; scroll-snap-align: center; margin: 0; }
34 .still a { display: block; overflow: hidden; border-radius: 6px; }
35 .still img { transition: transform .2s ease-out; transform-origin: 50% 50%; }
36 .still figcaption { position: absolute; top: .4rem; left: .4rem; padding: 0 .4rem; border-radius: 4px; background: #000a; color: #fff; font-size: .8rem; pointer-events: none; }
37 .still[data-off] a::after { content: "No picture: the camera isn't answering"; position: absolute; inset: 0; display: grid; place-items: center; color: #ccc; font-size: .9rem; }
38 .still:fullscreen { background: #000; display: grid; place-items: center; } .still:fullscreen a { width: 100%; } .still:fullscreen img { aspect-ratio: auto; max-height: 100vh; }
39 @container cams (min-width: 40rem) { .reel { display: grid; grid-template-columns: 1fr 1fr; overflow: visible; } .camrow .seg { display: none; } }
40 .camrow { display: flex; flex-wrap: nowrap; align-items: center; gap: .375rem; margin-top: .4rem; }
41 .seg { display: inline-flex; flex: none; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
42 .card .seg button { min-height: 44px; padding: 0 .6rem; border: 0; border-radius: 0; background: transparent; color: var(--fg); font-size: .875rem; font-weight: 600; justify-content: center; }
43 .card .seg button + button { border-left: 1px solid var(--line); }
44 .card .seg button[aria-pressed=true] { background: var(--accent); color: var(--on-accent); }
45 .card .toggle { min-height: 44px; padding: 0 .6rem; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--fg); font-size: .8125rem; font-weight: 600; margin-left: auto; white-space: nowrap; }
46 .card .toggle[aria-pressed=true] { border-color: var(--accent); color: var(--accent); }
2947 .sight:empty { display: none; }
3048 .cant-see { position: absolute; left: 0; right: 0; bottom: 0; margin: 0; padding: .4rem .6rem; border-radius: 0 0 6px 6px; background: #000d; color: #fff; font-size: .9rem; }
3149 .cant-see a { color: #9ecbff; }
32 .grow { min-height: 22rem; } .grow img { max-width: 100%; height: auto; } .lapse { aspect-ratio: 4/3; } .growlog { min-height: 12rem; } .grow input[type=date] { min-height: 44px; } .grow .problem { color: var(--accent); }
33 .grow h3 { margin: .8rem 0 .3rem; font-size: 1rem; } .grow input[type=range] { width: 100%; } .grow svg { width: 100%; max-height: 6rem; color: var(--accent); }
34 .grow button[aria-pressed=true] { outline: 2px solid var(--fg); } .grow form { display: flex; gap: .5rem; flex-wrap: wrap; } .grow input[type=text] { flex: 1; min-width: 8rem; min-height: 44px; }
50 .growing h3 { margin: .7rem 0 .1rem; font-size: 1.05rem; } .growing .offline { color: var(--accent); font-weight: 600; }
51 .growing form { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin: .3rem 0; } .growing input[type=date] { font: inherit; min-height: 44px; }
52 .growing details summary { cursor: pointer; color: var(--link); min-height: 44px; display: flex; align-items: center; }
53 .grow[data-stills] { min-height: 18rem; } .grow .quiet { margin: .6rem 0; }
54 .grow .lapse img { margin-top: .4rem; }
55 .bar { display: flex; flex-wrap: nowrap; align-items: center; gap: .375rem; margin-top: .6rem; }
56 .bar .seg button { width: 3.4rem; padding: 0; }
57 .card .play { width: 2.75rem; padding: 0; flex: none; justify-content: center; border-radius: 8px; font-size: 1rem; }
58 .fix { flex: 0 1 auto; min-width: 0; margin-left: auto; display: flex; align-items: center; gap: .25rem; min-height: 44px; font-size: .8125rem; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
59 .fix input { width: 1rem; height: 1rem; margin: 0; flex: none; accent-color: var(--accent); }
60 .growlog { min-height: 4rem; } .grow .problem { color: var(--accent); }
61 .grow h3 { margin: .8rem 0 .3rem; font-size: 1rem; } .grow input[type=range] { width: 100%; min-height: 32px; } .grow svg { width: 100%; max-height: 6rem; color: var(--accent); }
62 .grow form { display: flex; gap: .5rem; flex-wrap: wrap; } .grow input[type=text] { flex: 1; min-width: 8rem; min-height: 44px; }
3563 .card p { color: var(--dim); margin: .4rem 0; }
3664 .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); }
3765 .top a, .card button { min-height: 44px; display: inline-flex; align-items: center; }
@@ -41,9 +69,24 @@ h1 { margin: .5rem 0 1rem; } h2 { margin: .6rem 0; font-size: 1.15rem; }
4169 .controls a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 1rem; border-radius: 999px; background: var(--accent); color: var(--on-accent); font-weight: 650; text-decoration: none; }
4270 a { color: var(--link); }
4371 .controls a { color: var(--on-accent); }
44 a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
72 a:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
4573 `;
4674
75 /** The grow card's first paint, from the care log (src/care-rules.ts) and the relay's link: no request waits on the Pi for it. */
76 export interface GrowNow {
77 now: number;
78 /** The latest sow logged; null when none is. */
79 sownAt: number | null;
80 /** "Germinating: day 0". */
81 title: string;
82 /** "Sprouts expected in about 3–7 days", while they are. */
83 hint: string | null;
84 lastWatered: number | null;
85 next: { label: string; dueAt: number } | null;
86 /** Whether the Pi's server answers; null when there is no Pi behind this app. */
87 pi: "online" | "offline" | null;
88 }
89
4790 /** Who is looking: the identity line's reading (src/signed-in.ts), null for a guest, and whether they own the place. */
4891 export interface Looker {
4992 signedIn: SignedIn | null;
@@ -52,38 +95,89 @@ export interface Looker {
5295 grow?: boolean;
5396 /** When the newest lit still was taken: the card shows it without the script. */
5497 latest?: number | null;
98 /** How often a still is taken, ms: the empty timelapse says when the first one comes. */
99 everyMs?: number;
100 /** The plant's day, the last watering and the next reminder. */
101 growNow?: GrowNow;
102 }
103
104 const HOUR = 3_600_000;
105 /** "5 h ago", "just now", "2 d ago". */
106 const ago = (ms: number) => (ms < HOUR ? "just now" : ms < 24 * HOUR ? `${Math.floor(ms / HOUR)} h ago` : `${Math.floor(ms / (24 * HOUR))} d ago`);
107 /** "in 3 h", "overdue 2 h", "in 2 d". */
108 const due = (ms: number) => {
109 const h = Math.max(1, Math.round(Math.abs(ms) / HOUR));
110 const span = h < 48 ? `${h} h` : `${Math.round(h / 24)} d`;
111 return ms < 0 ? `overdue ${span}` : `in ${span}`;
112 };
113 const day = (ms: number) => new Date(ms).toISOString().slice(0, 10);
114
115 /** The sow form: one tap logs a sow in the care log (POST /care/log, the owner's), dated today unless changed. */
116 function sowForm(now: number, label: string): string {
117 const today = day(now);
118 return `<form method="post" action="/care/log"><input type="hidden" name="type" value="action"><input type="hidden" name="what" value="sow"><input type="hidden" name="then" value="home"><input type="date" name="at" value="${today}" max="${today}" required aria-label="Sown on"><button>${esc(label)}</button></form>`;
119 }
120
121 /** Where the plant is: the Pi's state, the day from the sow, the last watering and the next reminder. */
122 function growing(g: GrowNow | undefined, owner: boolean): string {
123 if (!g) return "";
124 const offline = g.pi === "offline" ? `<p class="offline" role="status">Grow box offline: the Pi isn't answering.</p>` : "";
125 const plant = g.sownAt !== null
126 ? `<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>` : ""}`
127 : `<h3>Not planted yet</h3>${owner ? sowForm(g.now, "Set the sow date") : ""}`;
128 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(" · ");
129 return `<div class="growing" data-growing>${offline}${plant}<p>${care}. <a href="/care">The log book</a></p></div>`;
130 }
131
132 /** 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. */
133 function cams(station: Station, cameras: StationView["cameras"], grow: boolean): string {
134 const figures = cameras
135 .map((c, i) => {
136 const img = `<img src="/camera/${c.id}/snapshot" alt="${esc(c.name)}"${i ? ' loading="lazy"' : ""} width="1280" height="720"${grow ? ` data-sight="${c.id}"` : ""}>`;
137 // The hint sits over the foot of the still, so it moves nothing when the page's script fills it in.
138 const hint = grow ? `<div class="sight" data-sight-for="${c.id}" data-region="${esc(JSON.stringify(c.region ?? null))}"></div>` : "";
139 return `<figure class="still" data-cam="${c.id}"${c.region ? ` data-box="${esc(JSON.stringify(c.region))}"` : ""}><a href="/camera/${c.id}/snapshot" data-full aria-label="${esc(c.name)}, full screen">${img}</a>${hint}${cameras.length > 1 ? `<figcaption>${esc(c.name)}</figcaption>` : ""}</figure>`;
140 })
141 .join("");
142 const picks = cameras.length > 1 ? `<div class="seg" role="group" aria-label="Camera">${cameras.map((c, i) => `<button type="button" data-pick="${c.id}" aria-pressed="${i ? "false" : "true"}">${esc(c.name)}</button>`).join("")}</div>` : "";
143 const box = cameras.some((c) => c.region) ? `<button type="button" class="toggle" data-zoom aria-pressed="false">Zoom to the box</button>` : "";
144 return `<div class="cams" data-cams="${esc(station.id)}"><div class="reel">${figures}</div>${picks || box ? `<div class="camrow">${picks}${box}</div>` : ""}</div>`;
55145 }
56146
57147 function card({ station, present, resting, cameras }: StationView, looker: Looker): string {
58148 const head = `<h2>${esc(station.name)}</h2>`;
149 const kind = `data-kind="${esc(station.kind)}"`;
59150 if (!present) {
60151 const reopen = resting && looker.owner ? `<form method="post" action="/admin"><input type="hidden" name="action" value="open"><input type="hidden" name="then" value="home"><button>Open now</button></form>` : "";
61152 // The arm is away; its cameras are not: they stream whenever their own source answers.
62 const stills = cameras.map((c, i) => `<img src="/camera/${c.id}/snapshot" alt="${esc(c.name)}"${i ? ' loading="lazy"' : ""} width="640" height="480">`).join("");
63 return `<section class="card" data-station="${esc(station.id)}" data-present="false">${head}<p>${esc(resting ?? `${station.name}: not connected`)}</p>${stills}${reopen}</section>`;
153 const stills = cameras.length ? cams(station, cameras, false) : "";
154 return `<section class="card" data-station="${esc(station.id)}" data-present="false" ${kind}>${head}<p>${esc(resting ?? `${station.name}: not connected`)}</p>${stills}${reopen}</section>`;
64155 }
156 const grow = station.kind === "grow";
157 const status = grow ? growing(looker.growNow, looker.owner) : "";
65158 if (!cameras.length) {
66159 const setup = looker.owner ? ` <a href="/admin/camera-list">Cameras</a> · <a href="/admin/stations/edit">Stations</a>` : "";
67160 const controls = station.controls.map((c) => `<a href="${esc(c.href)}">${esc(c.label)}</a>`).join("");
68 return `<section class="card" data-station="${esc(station.id)}" data-present="true" data-cameras="none">${head}<p>No camera set up yet.${setup}</p>${controls ? `<div class="controls">${controls}</div>` : ""}</section>`;
161 return `<section class="card" data-station="${esc(station.id)}" data-present="true" data-cameras="none" ${kind}>${head}<p>No camera set up yet.${setup}</p>${status}${controls ? `<div class="controls">${controls}</div>` : ""}</section>`;
69162 }
70 const grow = station.kind === "grow";
71 const thumbs = cameras
72 .map((c, i) => {
73 const img = `<img src="/camera/${c.id}/snapshot" alt="${esc(c.name)}"${i ? ' loading="lazy"' : ""} width="640" height="480"${grow ? ` data-sight="${c.id}"` : ""}>`;
74 // The hint sits over the foot of the still, so it moves nothing when the page's script fills it in.
75 return grow ? `<div class="still">${img}<div class="sight" data-sight-for="${c.id}" data-region="${esc(JSON.stringify(c.region ?? null))}"></div></div>` : img;
76 })
77 .join("");
78163 const controls = station.controls.map((c) => `<a href="${esc(c.href)}">${esc(c.label)}</a>`).join("");
79 const lapse = grow && looker.grow ? `<div class="grow" data-grow data-owner="${looker.owner ? 1 : 0}" data-region="${esc(JSON.stringify(cameras[0]?.region ?? null))}">${looker.latest ? `<img class="latest" src="/grow/stills/${Number(looker.latest)}.jpg" alt="The latest still of the grow box" width="640" height="480" loading="lazy">` : ""}<p><a href="/grow/log">The grow log</a></p><noscript><p>The timelapse and the canopy chart need JavaScript.</p></noscript></div>` : "";
80 return `<section class="card" data-station="${esc(station.id)}" data-present="true">${head}${thumbs}${lapse}${controls ? `<div class="controls">${controls}</div>` : ""}</section>`;
164 const minutes = Math.max(1, Math.round((looker.everyMs ?? 600_000) / 60_000));
165 // No still yet: one quiet line, never an empty frame or a dead slider (the script says the same once it has looked).
166 const first = looker.latest
167 ? `<img class="latest" src="/grow/stills/${Number(looker.latest)}.jpg" alt="The latest still of the grow box" width="1280" height="720" loading="lazy">`
168 : `<p class="quiet">The first stills arrive within ${minutes} minutes.</p>`;
169 const lapse = grow && looker.grow ? `<div class="grow" data-grow data-owner="${looker.owner ? 1 : 0}" data-every="${minutes}"${looker.latest ? " data-stills" : ""} data-region="${esc(JSON.stringify(cameras[0]?.region ?? null))}">${first}<noscript><p>The timelapse and the canopy chart need JavaScript. <a href="/grow/log">The grow log</a></p></noscript></div>` : "";
170 return `<section class="card" data-station="${esc(station.id)}" data-present="true" ${kind}>${head}${cams(station, cameras, grow)}${status}${lapse}${controls ? `<div class="controls">${controls}</div>` : ""}</section>`;
81171 }
82172
83 /** The page's one script: the grow stills' check (src/ui/grow-hint.js). */
84 const SIGHT_SCRIPT = `import { watchStills } from "/ui/__VERSION__/grow-hint.js"; watchStills(document);__LAPSE__`;
173 /** The page's script: the cameras' swipe, pick and full screen (src/ui/station-cams.js); for the grow box, the stills' check (src/ui/grow-hint.js) and the timelapse. */
174 const CAMS_SCRIPT = `import { watchCams } from "/ui/__VERSION__/station-cams.js"; watchCams(document, window.localStorage);`;
175 const SIGHT_SCRIPT = ` import { watchStills } from "/ui/__VERSION__/grow-hint.js"; watchStills(document);__LAPSE__`;
85176
86177 export function renderWorkshop(views: readonly StationView[], looker: Looker = { signedIn: null, owner: false }): string {
178 const anyCams = views.some((v) => v.cameras.length);
179 const growCams = views.some((v) => v.station.kind === "grow" && v.present && v.cameras.length);
180 const script = anyCams ? CAMS_SCRIPT + (growCams ? SIGHT_SCRIPT.replace("__LAPSE__", looker.grow ? ` import { watchGrow } from "/ui/__VERSION__/grow-timelapse.js"; watchGrow(document);` : "") : "") : "";
87181 return `<!doctype html>
88182 <html lang="en">
89183 <head>
@@ -100,7 +194,7 @@ export function renderWorkshop(views: readonly StationView[], looker: Looker = {
100194 <h1>${WORKSHOP_TITLE}</h1>
101195 <div class="cards">${views.map((v) => card(v, looker)).join("\n")}</div>
102196 </main>
103 ${views.some((v) => v.station.kind === "grow" && v.present && v.cameras.length) ? `<script type="module">${SIGHT_SCRIPT.replace("__LAPSE__", looker.grow ? ` import { watchGrow } from "/ui/__VERSION__/grow-timelapse.js"; watchGrow(document);` : "")}</script>` : ""}
197 ${script ? `<script type="module">${script}</script>` : ""}
104198 </body>
105199 </html>
106200 `;
Software/so101/src/workshop-phone.test.ts added +293
@@ -0,0 +1,293 @@
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 { renderWorkshop, type GrowNow, type StationView } from "./workshop-page.ts";
7 import { growing, lastWatered, nextDue, snapshot, sownAt } from "./care-rules.ts";
8 import { CareLog } from "./care-log.ts";
9 import { BUILT_IN_DIR, loadProfiles } from "./care-profile.ts";
10 import { handleCare } from "./care-routes.ts";
11 import { WORKSHOP } from "./stations.ts";
12 import { firstStills, RANGES } from "./ui/grow-timelapse.js";
13 import { boxTransform, indexAt, pickedIndex } from "./ui/station-cams.js";
14
15 // The home on a phone (#625), read rather than rendered (there is no browser here): the page's own CSS, resolved at
16 // 390 and 360 px wide into the widths the camera, its row and the timelapse's controls take.
17
18 const H = 3_600_000;
19 const D = 24 * H;
20 const NOW = Date.parse("2026-10-27T02:00:00Z");
21 const spinach = loadProfiles(BUILT_IN_DIR).find((p) => p.id === "spinach")!;
22 const REM = 16;
23
24 type Rule = { selector: string; body: string; media: string };
25 function flatRules(css: string): Rule[] {
26 const rules: Rule[] = [];
27 const walk = (src: string, media: string) => {
28 let i = 0;
29 while (i < src.length) {
30 const open = src.indexOf("{", i);
31 if (open < 0) break;
32 let depth = 1;
33 let j = open + 1;
34 for (; j < src.length && depth; j++) depth += src[j] === "{" ? 1 : src[j] === "}" ? -1 : 0;
35 const head = src.slice(i, open).trim();
36 if (head.startsWith("@")) walk(src.slice(open + 1, j - 1), head);
37 else rules.push({ selector: head, body: src.slice(open + 1, j - 1), media });
38 i = j;
39 }
40 };
41 walk(css.replace(/\/\*[\s\S]*?\*\//g, ""), "");
42 return rules;
43 }
44 /** A declaration's value in the last rule (any media, or the one named) whose selector list holds exactly `selector`. */
45 function decl(rules: Rule[], selector: string, prop: string, media = ""): string | undefined {
46 let value: string | undefined;
47 for (const r of rules) {
48 if (r.media !== media || !r.selector.split(",").some((s) => s.trim() === selector)) continue;
49 for (const d of r.body.split(/;(?![^(]*\))/)) {
50 const at = d.indexOf(":");
51 if (at > 0 && d.slice(0, at).trim() === prop) value = d.slice(at + 1).trim();
52 }
53 }
54 return value;
55 }
56 /** "3.4rem", "44px", ".375rem" → px. */
57 const px = (v: string | undefined): number => {
58 const m = /^(-?[\d.]+)(rem|px)$/.exec(v ?? "");
59 if (!m) throw new Error(`not a length: ${v}`);
60 return Number(m[1]) * (m[2] === "rem" ? REM : 1);
61 };
62 /** A generous width for a line of text: 0.6 em a character covers system-ui's widest common glyphs. */
63 const textWidth = (s: string, fontPx: number) => s.length * fontPx * 0.6;
64
65 const grow = (cams: StationView["cameras"]): StationView => ({ station: WORKSHOP[1]!, present: true, cameras: cams });
66 const TWO = [
67 { id: 2, name: "Above", region: { x: 0.2, y: 0.1, w: 0.5, h: 0.6 } },
68 { id: 1, name: "Camera 1" },
69 ];
70 const now = (over: Partial<GrowNow> = {}): GrowNow => ({ now: NOW, sownAt: null, title: "Seedling: day 0", hint: null, lastWatered: null, next: null, pi: "online", ...over });
71 const home = (over: Partial<GrowNow> = {}, owner = false, latest: number | null = null) =>
72 renderWorkshop([grow(TWO)], { signedIn: null, owner, grow: true, latest, everyMs: 600_000, growNow: now(over) });
73 const css = (html: string) => flatRules(/<style>([\s\S]*?)<\/style>/.exec(html)![1]!);
74
75 /** The content width of a card on a phone `vw` wide: main's padding, then the card's border and padding. */
76 function cardWidth(rules: Rule[], vw: number): number {
77 const phone = "@media (max-width: 30rem)";
78 const main = px(decl(rules, "main", "padding", phone));
79 const card = px(decl(rules, ".card", "padding", phone));
80 return vw - 2 * main - 2 * card - 2;
81 }
82
83 test("at 390 px the live camera keeps its 16:9 frame, whole and full width, and a tap opens it full screen", () => {
84 const html = home();
85 const rules = css(html);
86 expect(decl(rules, ".card img", "aspect-ratio")).toBe("16/9");
87 expect(decl(rules, ".card img", "object-fit")).toBe("contain");
88 expect(decl(rules, ".card img", "width")).toBe("100%");
89 expect(html).not.toContain("object-fit: cover");
90 // the frame's box at 390: as wide as the card, 9/16 of that tall, so nothing of a 16:9 frame is cut off
91 const w = cardWidth(rules, 390);
92 expect(w).toBeGreaterThan(340);
93 expect(Math.round((w * 9) / 16)).toBeLessThan(w);
94 expect(html).toMatch(/<img src="\/camera\/2\/snapshot"[^>]* width="1280" height="720"/);
95 expect(html).toContain('href="/camera/2/snapshot" data-full');
96 });
97
98 test("both station cameras are on the card: swiped through on a phone, picked by name, side by side when wide", () => {
99 const html = home();
100 const rules = css(html);
101 expect(html).toContain('data-cam="2"');
102 expect(html).toContain('data-cam="1"');
103 expect(html).toContain('<button type="button" data-pick="2" aria-pressed="true">Above</button>');
104 expect(html).toContain('<button type="button" data-pick="1" aria-pressed="false">Camera 1</button>');
105 expect(decl(rules, ".reel", "scroll-snap-type")).toBe("x mandatory");
106 expect(decl(rules, ".still", "flex")).toBe("0 0 100%");
107 expect(decl(rules, ".reel", "grid-template-columns", "@container cams (min-width: 40rem)")).toBe("1fr 1fr");
108 expect(html).toContain("data-zoom"); // the above camera has a box, so the card offers to zoom to it
109 expect(html).toMatch(/\/ui\/__VERSION__\/station-cams\.js/);
110 // the default workshop shows the grow box from above and from the side
111 expect(WORKSHOP[1]!.cameras).toEqual([2, 1]);
112 });
113
114 test("with no stills yet the timelapse is one quiet line: no image, no slider", () => {
115 const html = home({}, false, null);
116 const lapse = /<div class="grow"[\s\S]*?<\/noscript><\/div>/.exec(html)![0];
117 expect(lapse).toContain("The first stills arrive within 10 minutes.");
118 expect(lapse).not.toContain("<img");
119 expect(lapse).not.toContain('type="range"');
120 expect(lapse).not.toContain("data-stills");
121 expect(firstStills(10)).toBe("The first stills arrive within 10 minutes.");
122 expect(firstStills(undefined)).toBe("The first stills arrive within 10 minutes.");
123 // with one, the card shows it at the camera's shape
124 expect(home({}, false, NOW - H)).toMatch(/<img class="latest" src="\/grow\/stills\/\d+\.jpg"[^>]*width="1280" height="720"/);
125 });
126
127 test("the timelapse's controls and the camera row fit one line at 360 px", () => {
128 const rules = css(home());
129 const room = cardWidth(rules, 360);
130 expect(decl(rules, ".bar", "flex-wrap")).toBe("nowrap");
131 expect(decl(rules, ".camrow", "flex-wrap")).toBe("nowrap");
132 const gap = px(decl(rules, ".bar", "gap"));
133 const seg = Object.keys(RANGES).length * px(decl(rules, ".bar .seg button", "width")) + 2;
134 for (const label of Object.values(RANGES)) expect(textWidth(label, 14)).toBeLessThan(px(decl(rules, ".bar .seg button", "width")));
135 const play = px(decl(rules, ".card .play", "width"));
136 const corrected = 16 + 4 + textWidth("Corrected", 13);
137 expect(seg + gap + play + gap + corrected).toBeLessThanOrEqual(room);
138 // and should a font run wider, the toggle shrinks and clips rather than drops to a second line
139 expect(decl(rules, ".fix", "min-width")).toBe("0");
140 expect(decl(rules, ".fix", "white-space")).toBe("nowrap");
141 // the camera row: both names in the segmented control, then "Zoom to the box"
142 const pad = 2 * px(decl(rules, ".card .seg button", "padding")?.split(" ")[1]);
143 const names = TWO.reduce((sum, c) => sum + textWidth(c.name, 14) + pad, 0) + 3;
144 const zoom = textWidth("Zoom to the box", 13) + 2 * px(decl(rules, ".card .toggle", "padding")?.split(" ")[1]) + 2;
145 expect(names + px(decl(rules, ".camrow", "gap")) + zoom).toBeLessThanOrEqual(room);
146 });
147
148 test("the forge's floating button never covers the controls: the page keeps room under its last line", () => {
149 const rules = css(home());
150 expect(decl(rules, "body", "padding-bottom")).toBe("calc(56px + env(safe-area-inset-bottom))");
151 });
152
153 test("offline says so; not planted is not the same as unknown; the owner sets the sow date in one tap", () => {
154 const guestOff = home({ pi: "offline" });
155 expect(guestOff).toContain("Grow box offline: the Pi isn't answering.");
156 expect(home({ pi: "online" })).not.toContain("Grow box offline");
157 expect(home()).toContain("<h3>Not planted yet</h3>");
158 expect(home()).not.toContain('value="sow"'); // a guest sees no form
159 const owner = home({}, true);
160 expect(owner).toContain('<form method="post" action="/care/log"><input type="hidden" name="type" value="action"><input type="hidden" name="what" value="sow"><input type="hidden" name="then" value="home"><input type="date" name="at" value="2026-10-27" max="2026-10-27" required');
161 expect(owner).toContain(">Set the sow date</button>");
162 const sown = home({ sownAt: NOW - 5 * H, title: "Germinating: day 0", hint: "Sprouts expected in about 3–7 days" }, true);
163 expect(sown).toContain("<h3>Germinating: day 0</h3>");
164 expect(sown).toContain("Sprouts expected in about 3–7 days. Sown 2026-10-26.");
165 expect(sown).toContain("Change the sow date");
166 });
167
168 test("the log book is on the home: the last watering and the next reminder", () => {
169 const html = home({ lastWatered: NOW - 2 * H, next: { label: "Check the <tray>", dueAt: NOW + 5 * H } });
170 expect(html).toContain("Last watered: 2 h ago · Next: Check the &lt;tray&gt;, in 5 h.");
171 expect(html).toContain('<a href="/care">The log book</a>');
172 expect(home()).toContain("Not watered yet.");
173 });
174
175 test("the plant's day reads the latest sow in the care log: germinating, with the sprouts' window", () => {
176 const clock = { now: NOW };
177 const log = new CareLog(undefined, () => clock.now);
178 expect(growing(spinach, snapshot(log), NOW).sownAt).toBeNull();
179 log.add({ type: "action", what: "sow", at: NOW - 30 * D });
180 log.add({ type: "action", what: "sow", at: Date.parse("2026-10-26T21:10:00Z") }); // the correction wins
181 log.add({ type: "action", what: "water", amount: 1, at: NOW - H });
182 const snap = snapshot(log);
183 expect(sownAt(snap.entries)).toBe(Date.parse("2026-10-26T21:10:00Z"));
184 expect(growing(spinach, snap, NOW)).toEqual({ sownAt: Date.parse("2026-10-26T21:10:00Z"), day: 0, title: "Germinating: day 0", hint: "Sprouts expected in about 3–7 days" });
185 expect(growing(spinach, snap, NOW + 5 * D).hint).toBe("Sprouts expected by day 7");
186 expect(growing(spinach, snap, NOW + 9 * D)).toMatchObject({ day: 9, title: "Rockwool seedling: day 9", hint: null });
187 expect(lastWatered(snap.entries, NOW)).toBe(NOW - H);
188 expect(nextDue(spinach, snap, NOW)).not.toBeNull();
189 });
190
191 test("the sow form logs a sow dated that day, never in the future, and lands back on the home", async () => {
192 const log = new CareLog(undefined, () => NOW);
193 const server = createServer((req, res) => {
194 const readBody = (r: typeof req) => new Promise<Buffer>((ok) => { const c: Buffer[] = []; r.on("data", (d) => c.push(d)); r.on("end", () => ok(Buffer.concat(c))); });
195 handleCare(req, res, new URL(req.url!, "http://x").pathname, { profile: spinach, log, readBody, now: () => NOW, who: () => ({ owner: true, known: true, signedIn: null }) } as Parameters<typeof handleCare>[3]).then((hit) => hit || (res.writeHead(404), res.end()));
196 });
197 await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
198 const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
199 const post = (body: string) => fetch(`${base}/care/log`, { method: "POST", redirect: "manual", headers: { "content-type": "application/x-www-form-urlencoded" }, body });
200 try {
201 const r = await post("type=action&what=sow&then=home&at=2026-10-26");
202 expect(r.status).toBe(303);
203 expect(r.headers.get("location")).toBe("/");
204 expect(sownAt(log.entries())).toBe(Date.parse("2026-10-26T00:00:00Z"));
205 await post("type=action&what=sow&then=home&at=2099-01-01");
206 expect(sownAt(log.entries())).toBeLessThanOrEqual(Date.now());
207 expect((await post("type=action&what=sow&then=elsewhere")).headers.get("location")).toBe("/care");
208 } finally {
209 server.close();
210 }
211 });
212
213 test("the cameras' script: the remembered camera, the one a swipe landed on, and the zoom to the box", () => {
214 expect(pickedIndex([2, 1], "1")).toBe(1);
215 expect(pickedIndex([2, 1], "7")).toBe(0);
216 expect(pickedIndex([2, 1], null)).toBe(0);
217 expect(indexAt(0, 350, 2)).toBe(0);
218 expect(indexAt(340, 350, 2)).toBe(1);
219 expect(indexAt(9999, 350, 2)).toBe(1);
220 expect(indexAt(10, 0, 2)).toBe(0);
221 expect(boxTransform(null)).toBe("");
222 expect(boxTransform({ x: 0.25, y: 0.25, w: 0.5, h: 0.5 })).toBe("scale(2) translate(0%, 0%)");
223 expect(boxTransform({ x: 0, y: 0, w: 0.5, h: 0.25 })).toBe("scale(2) translate(25%, 37.5%)");
224 });
225
226 test("the relay's home carries the plant's day from the care log, and a planted date from /grow/log becomes a sow", async () => {
227 const { startRelay } = await import("./relay.ts");
228 const { StationHub } = await import("./stations.ts");
229 const { CameraHub } = await import("./cameras.ts");
230 const { GrowStills } = await import("./grow-stills.ts");
231 const { GrowLog } = await import("./grow-log.ts");
232 const dir = mkdtempSync(join(tmpdir(), "so101-phone-"));
233 const stations = new StationHub();
234 stations.replace([{ id: "grow", name: "Grow box", kind: "grow", cameras: [1], controls: [] }]);
235 const care = { log: new CareLog(), profile: spinach };
236 const relay = await startRelay({ port: 0, simulation: "http://127.0.0.1:1", cameras: new CameraHub({ seed: [{ name: "Grow cam", source: "http://127.0.0.1:1/stream" }] }), stations, grow: { stills: new GrowStills(join(dir, "stills")), log: new GrowLog(join(dir, "log.json")), everyMs: 600_000 }, care, env: {} } as Parameters<typeof startRelay>[0]);
237 try {
238 const base = `http://127.0.0.1:${relay.port}`;
239 const first = await (await fetch(`${base}/`)).text();
240 expect(first).toContain("Not planted yet");
241 expect(first).toContain("The first stills arrive within 10 minutes.");
242 // the arm server's /ui/ serves any file there, under a name that changes with its content
243 expect(first).toMatch(/\/ui\/[0-9a-f]{12}\/station-cams\.js/);
244 expect(await Bun.file(new URL("./ui/station-cams.js", import.meta.url)).text()).toContain("export function watchCams");
245 const today = new Date().toISOString().slice(0, 10);
246 expect((await fetch(`${base}/grow/log`, { method: "POST", body: JSON.stringify({ planted: today }) })).status).toBe(200);
247 expect(care.log.entries().some((e) => e.type === "action" && e.what === "sow")).toBe(true);
248 const view = (await (await fetch(`${base}/grow/log`)).json()) as { planted: string; day: number; stage: string };
249 expect(view).toMatchObject({ planted: today, day: 0, stage: "Germinating: day 0" });
250 expect(await (await fetch(`${base}/`)).text()).toContain("<h3>Germinating: day 0</h3>");
251 } finally {
252 await relay.close();
253 rmSync(dir, { recursive: true, force: true });
254 }
255 });
256
257 test("an absent arm's cameras still show, in the same whole 16:9 reel, with its script", () => {
258 const html = renderWorkshop([{ station: WORKSHOP[0]!, present: false, cameras: [{ id: 1, name: "Camera 1" }] }]);
259 expect(html).toContain("The arm: not connected");
260 expect(html).toMatch(/<figure class="still" data-cam="1"><a href="\/camera\/1\/snapshot" data-full[^>]*><img src="\/camera\/1\/snapshot"[^>]* width="1280" height="720">/);
261 expect(html).toContain("station-cams.js");
262 });
263
264 test("the planted date is the sow: the day counts from it past a transplant, and clearing it is refused while a sow is logged", async () => {
265 const { startRelay } = await import("./relay.ts");
266 const { StationHub } = await import("./stations.ts");
267 const { CameraHub } = await import("./cameras.ts");
268 const { GrowStills } = await import("./grow-stills.ts");
269 const { GrowLog } = await import("./grow-log.ts");
270 const { growLogReader } = await import("./terminal.ts");
271 const dir = mkdtempSync(join(tmpdir(), "so101-phone-"));
272 const stations = new StationHub();
273 stations.replace([{ id: "grow", name: "Grow box", kind: "grow", cameras: [1], controls: [] }]);
274 const care = { log: new CareLog(), profile: spinach };
275 const relay = await startRelay({ port: 0, simulation: "http://127.0.0.1:1", cameras: new CameraHub({ seed: [{ name: "Grow cam", source: "http://127.0.0.1:1/stream" }] }), stations, grow: { stills: new GrowStills(join(dir, "stills")), log: new GrowLog(join(dir, "log.json")), everyMs: 600_000 }, care, env: {} } as Parameters<typeof startRelay>[0]);
276 try {
277 const base = `http://127.0.0.1:${relay.port}`;
278 const read = growLogReader({ PORT: String(relay.port) });
279 expect((await read({ planted: null })).error).toBeNull(); // nothing sown: clearing the fallback is fine
280 care.log.add({ type: "action", what: "sow", at: Date.now() - 20 * D - H });
281 care.log.add({ type: "action", what: "transplant", system: "vertical tower", litres: 20, sites: 12, pump: true, at: Date.now() - 2 * D });
282 const view = (await (await fetch(`${base}/grow/log`)).json()) as { day: number; stage: string };
283 expect(view.day).toBe(20);
284 expect(view.stage).toBe("Hydroponic: day 2");
285 const cleared = await read({ planted: null });
286 expect(cleared.error).toBe("a sow is logged in /care; delete it there");
287 expect(care.log.entries().filter((e) => e.type === "action" && e.what === "sow")).toHaveLength(1);
288 expect((await read()).lines[0]).toMatch(/^Day 20, planted \d{4}-\d{2}-\d{2}$/);
289 } finally {
290 await relay.close();
291 rmSync(dir, { recursive: true, force: true });
292 }
293 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs