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
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
vesper/arm:vesper/625-the-workshop-on-a-phone-camera-at-its-re → main 6 commits fork
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 | ||
| 128 | 128 | 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. |
| 129 | 129 | |
| 130 | 130 | - **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. | |
| 131 | 132 | - **"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. | |
| 133 | 134 | - **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. |
| 134 | 135 | - **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. |
| 135 | 136 | |
| 136 | 137 | ## The grow box's stills, timelapse and log (on the Grow box card) |
| 137 | 138 | |
| 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. | |
| 139 | 140 | |
| 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: | |
| 141 | 142 | |
| 142 | 143 | sh |
| 143 | 144 | 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], | ||
| 146 | 147 | |
| 147 | 148 | (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. |
| 148 | 149 | |
| 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. | |
| 150 | 151 | |
| 151 | 152 | ## Plant care: /care |
| 152 | 153 |
Software/so101/src/care-page.ts +3 −3
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** 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. */ |
| 2 | 2 | |
| 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"; | |
| 4 | 4 | import { ACTIONS, PROBLEMS, SYSTEMS, type Entry, type CareLog } from "./care-log.ts"; |
| 5 | 5 | import { MEASURES, type Band, type Measure, type Profile } from "./care-profile.ts"; |
| 6 | 6 | import type { Doser } from "./doser.ts"; |
| @@ -113,7 +113,7 @@ export function renderCare({ profile, log, now, owner, signedIn, error, dosing, | ||
| 113 | 113 | const prompt = transplantPrompt(profile, snap, now); |
| 114 | 114 | const bolt = boltingWatch(profile, snap, now); |
| 115 | 115 | const sum = totals(snap.entries, now); |
| 116 | const days = Math.floor((now - cur.from) / DAY); | |
| 116 | const grow = growing(profile, snap, now); | |
| 117 | 117 | |
| 118 | 118 | const quick = owner |
| 119 | 119 | ? `<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 | ||
| 177 | 177 | <nav class="top" aria-label="Grow"><span>${renderSignedIn(signedIn, "/care")}</span><span><a href="/">Workshop</a></span></nav> |
| 178 | 178 | <main id="main"> |
| 179 | 179 | ${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>` : ""} | |
| 181 | 181 | <section class="health" data-ok="${h.ok}" aria-label="Health">${h.lines.map((l) => `<p>${esc(l)}</p>`).join("")}</section> |
| 182 | 182 | ${prompt ? `<p class="panel" data-prompt="transplant">${esc(prompt)}</p>` : ""}${bolt ? `<p class="panel" data-prompt="bolting">${esc(bolt)}</p>` : ""} |
| 183 | 183 | ${quick} |
Software/so101/src/care-profile.ts +4
| @@ -29,6 +29,8 @@ export interface Stage { | ||
| 29 | 29 | bands: Partial<Record<Measure, Band>>; |
| 30 | 30 | reminders: Reminder[]; |
| 31 | 31 | 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 }; | |
| 32 | 34 | } |
| 33 | 35 | |
| 34 | 36 | export interface Profile { |
| @@ -95,12 +97,14 @@ export function parseProfile(input: unknown): Profile { | ||
| 95 | 97 | 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[] }; |
| 96 | 98 | }); |
| 97 | 99 | const tc = s.transplantCheck === undefined ? undefined : obj(s.transplantCheck, "transplantCheck"); |
| 100 | const sp = s.sprout === undefined ? undefined : obj(s.sprout, "sprout"); | |
| 98 | 101 | return { |
| 99 | 102 | id: id(s.id, "a stage"), |
| 100 | 103 | name: words(s.name, "a stage's name", 60), |
| 101 | 104 | bands, |
| 102 | 105 | reminders, |
| 103 | 106 | ...(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) } } : {}), | |
| 104 | 108 | }; |
| 105 | 109 | }); |
| 106 | 110 | 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 @@ | ||
| 15 | 15 | { "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"] }, |
| 16 | 16 | { "id": "thin", "label": "Thin to the strongest seedling per cube once true leaves show", "everyHours": 168, "resets": ["action:thin"] } |
| 17 | 17 | ], |
| 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" } | |
| 19 | 20 | }, |
| 20 | 21 | { |
| 21 | 22 | "id": "hydroponic", |
Software/so101/src/care-routes.ts +5 −1
| @@ -115,6 +115,10 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path | ||
| 115 | 115 | return true; |
| 116 | 116 | } |
| 117 | 117 | 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); | |
| 118 | 122 | let result: unknown = { ok: true }; |
| 119 | 123 | const id = entry?.[1] ? Number(entry[1]) : null; |
| 120 | 124 | if (calibrate) { |
| @@ -135,7 +139,7 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path | ||
| 135 | 139 | } else if (req.method === "PUT") { |
| 136 | 140 | result = ctx.log.update(id, input); |
| 137 | 141 | } 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 }); | |
| 139 | 143 | else json(200, result); |
| 140 | 144 | } catch (err) { |
| 141 | 145 | 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 } | ||
| 27 | 27 | return null; |
| 28 | 28 | }; |
| 29 | 29 | |
| 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 | ||
| 30 | 37 | /** The stages the plant has been through, oldest first: the transplant moves it on and the history stays. */ |
| 31 | 38 | export function stageHistory(profile: Profile, entries: readonly Entry[], startedAt: number): StageSpan[] { |
| 32 | 39 | 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); | |
| 34 | 41 | const spans: StageSpan[] = [{ stage: seedling!, from: first }]; |
| 35 | 42 | const moved = transplantOf(entries); |
| 36 | 43 | if (moved && hydro) { |
| @@ -145,3 +152,38 @@ export function boltingWatch(profile: Profile, snap: Snapshot, now: number): str | ||
| 145 | 152 | const hot = readings(snap.entries, "airTemp").some((r) => r.at > now - 3 * DAY && r.value > 24); |
| 146 | 153 | return hot ? "Air was above 24 °C lately: is a stalk forming?" : null; |
| 147 | 154 | } |
| 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"; | ||
| 106 | 106 | import { parseFps, type FeedOptions } from "./camera-feed.ts"; |
| 107 | 107 | import { CameraError, CameraHub } from "./cameras.ts"; |
| 108 | 108 | 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"; | |
| 110 | 111 | import { BUILT_IN_DIR, loadProfiles, type Profile } from "./care-profile.ts"; |
| 111 | 112 | import { ProbeCache, readProbeView, sampleProbes, waterTemp, type ProbeHooks } from "./care-probes.ts"; |
| 112 | 113 | import { handleCare } from "./care-routes.ts"; |
| 113 | 114 | import { handleDosing, makeDoser } from "./care-dosing.ts"; |
| 114 | 115 | import type { Doser } from "./doser.ts"; |
| 115 | 116 | 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"; | |
| 117 | 118 | import { DAY_MS, GrowStills, KEEP_DAYS } from "./grow-stills.ts"; |
| 118 | 119 | 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"; | |
| 120 | 121 | import { parseProfile, ProfileError, type CameraProfile } from "./camera-profile.ts"; |
| 121 | 122 | import { VideoFeed, type VideoSources } from "./video-feed.ts"; |
| 122 | 123 | import type { VideoCard } from "./video-panel.ts"; |
| @@ -234,7 +235,7 @@ const STILL_FRAME = new URL("./ui/still-frame.js", import.meta.url); | ||
| 234 | 235 | const STATIONS_PAGE = readFileSync(new URL("./stations-admin.html", import.meta.url), "utf8"); |
| 235 | 236 | const GROW_HINT = new URL("./ui/grow-hint.js", import.meta.url); |
| 236 | 237 | /** 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)); | |
| 238 | 239 | 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); |
| 239 | 240 | const CAMERA_PAGE = readFileSync(new URL("./camera-list.html", import.meta.url), "utf8").replaceAll( |
| 240 | 241 | "/ui/__VERSION__/", |
| @@ -827,6 +828,17 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 827 | 828 | res.end(out); |
| 828 | 829 | }; |
| 829 | 830 | |
| 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 | ||
| 830 | 842 | /** 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). */ |
| 831 | 843 | const stationViews = (): StationView[] => { |
| 832 | 844 | const listed = new Map(cameras.list().map((c) => [c.id, c.name])); |
| @@ -1050,7 +1062,11 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1050 | 1062 | const zone = light?.zone || "UTC"; |
| 1051 | 1063 | const now = Date.now(); |
| 1052 | 1064 | 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 }); | |
| 1054 | 1070 | return true; |
| 1055 | 1071 | } |
| 1056 | 1072 | if (req.method === "POST" && (url.pathname === "/grow/log" || url.pathname === "/grow/canopy")) { |
| @@ -1063,8 +1079,14 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1063 | 1079 | } |
| 1064 | 1080 | if (url.pathname === "/grow/log") { |
| 1065 | 1081 | 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; | |
| 1066 | 1084 | 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" }); | |
| 1067 | 1088 | } catch (err) { |
| 1089 | if (err instanceof LogError) return json(res, 400, { error: err.message }), true; | |
| 1068 | 1090 | if (err instanceof GrowLogError) return json(res, 400, { error: err.message }), true; |
| 1069 | 1091 | throw err; |
| 1070 | 1092 | } |
| @@ -1255,7 +1277,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1255 | 1277 | if (req.method === "GET" && /^\/(?:index\.html)?$/.test(url.pathname) && stations.isWorkshop()) { |
| 1256 | 1278 | res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); |
| 1257 | 1279 | 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}/`)); | |
| 1259 | 1281 | } |
| 1260 | 1282 | if (req.method === "GET" && PAGE.test(url.pathname) && rest) return restingPage(req, res, rest); |
| 1261 | 1283 | 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 => { | ||
| 37 | 37 | /** The arm alone: the home is then the arm's own page. */ |
| 38 | 38 | export const ARM_ONLY: readonly Station[] = [{ id: "arm", name: "The arm", kind: "arm", cameras: [1], controls: [{ label: "Drive the arm", href: "/arm" }] }]; |
| 39 | 39 | |
| 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" }] }]; | |
| 42 | 46 | |
| 43 | 47 | /** Any JSON → the stations it describes; throws a StationError saying what is wrong. */ |
| 44 | 48 | export function parseStations(input: unknown): Station[] { |
Software/so101/src/terminal.ts +1 −1
| @@ -45,7 +45,7 @@ | ||
| 45 | 45 | * /servos owner: each joint's servo control registers /admin shows (GET /servo-params, src/servo-panel.ts); 404 to anyone else |
| 46 | 46 | * /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 |
| 47 | 47 | * /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 | |
| 49 | 49 | * /grow owner: the light and the pump as /admin's Grow card shows them (GET /grow); 404 to anyone else |
| 50 | 50 | * /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>) |
| 51 | 51 | * /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 @@ | ||
| 3 | 3 | * The grow card's timelapse, log and canopy chart, built in the browser from the stills the relay keeps |
| 4 | 4 | * (GET /grow/stills, /grow/stills/<at>.jpg, /grow/log): no video is encoded anywhere. Plain ESM served at /ui/. |
| 5 | 5 | * 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. | |
| 7 | 8 | * The page has `[data-grow]` holding the camera's box as `data-region` JSON and `data-owner="1"` for the owner. |
| 8 | 9 | */ |
| 9 | 10 | import { greenFraction } from "./grow-canopy.js"; |
| @@ -113,8 +114,10 @@ const el = (tag, text, attrs = {}) => { | ||
| 113 | 114 | return e; |
| 114 | 115 | }; |
| 115 | 116 | |
| 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 | ||
| 116 | 120 | export async function growCard(root) { |
| 117 | root.replaceChildren(); | |
| 118 | 121 | const owner = root.dataset.owner === "1"; |
| 119 | 122 | let region = null; |
| 120 | 123 | try { |
| @@ -122,52 +125,65 @@ export async function growCard(root) { | ||
| 122 | 125 | } catch { |
| 123 | 126 | region = null; |
| 124 | 127 | } |
| 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); | |
| 135 | 128 | const log = el("div", undefined, { class: "growlog" }); |
| 136 | 129 | 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); | |
| 137 | 151 | root.append(lapse, log, problem); |
| 152 | const drawLog = () => drawLogInto(log, problem, owner); | |
| 138 | 153 | |
| 139 | 154 | let stills = []; |
| 140 | 155 | let timer = null; |
| 141 | 156 | const show = (i) => { |
| 142 | 157 | const s = stills[i]; |
| 143 | 158 | if (!s) return; |
| 144 | const v = stillView(s, fix.getAttribute("aria-pressed") === "true"); | |
| 159 | const v = stillView(s, fixBox.checked); | |
| 145 | 160 | view.src = v.src; |
| 161 | view.alt = `The grow box, ${new Date(s.at).toLocaleString()}`; | |
| 146 | 162 | when.textContent = new Date(s.at).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }) + (v.label ? ` · ${v.label}` : ""); |
| 147 | 163 | slider.value = String(i); |
| 148 | 164 | }; |
| 149 | 165 | const stop = () => { |
| 150 | 166 | if (timer) clearInterval(timer); |
| 151 | 167 | timer = null; |
| 152 | play.textContent = "Play"; | |
| 168 | play.textContent = "▶"; | |
| 169 | play.setAttribute("aria-label", "Play"); | |
| 153 | 170 | }; |
| 154 | 171 | play.addEventListener("click", () => { |
| 155 | 172 | if (timer) return stop(); |
| 156 | 173 | if (Number(slider.value) >= stills.length - 1) show(0); |
| 157 | play.textContent = "Pause"; | |
| 174 | play.textContent = "❚❚"; | |
| 175 | play.setAttribute("aria-label", "Pause"); | |
| 158 | 176 | timer = setInterval(() => (Number(slider.value) + 1 >= stills.length ? stop() : show(Number(slider.value) + 1)), 150); |
| 159 | 177 | }); |
| 160 | 178 | 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))); | |
| 165 | 180 | const pick = async (range) => { |
| 166 | 181 | stop(); |
| 167 | 182 | for (const b of buttons) b.setAttribute("aria-pressed", String(b.dataset.range === range)); |
| 168 | 183 | stills = await load(rangeStart(range)); |
| 169 | 184 | slider.max = String(Math.max(0, stills.length - 1)); |
| 170 | 185 | fix.hidden = !stills.some((s) => s.corrected); |
| 186 | view.hidden = slider.hidden = !stills.length; | |
| 171 | 187 | if (!stills.length) { |
| 172 | 188 | view.removeAttribute("src"); |
| 173 | 189 | when.textContent = "No stills in this range yet."; |
| @@ -178,54 +194,54 @@ export async function growCard(root) { | ||
| 178 | 194 | }; |
| 179 | 195 | for (const b of buttons) b.addEventListener("click", () => void pick(b.dataset.range)); |
| 180 | 196 | |
| 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 | } | |
| 226 | 197 | await Promise.all([pick("today").then(() => (stills.length ? undefined : pick("week"))), drawLog()]); |
| 227 | 198 | } |
| 228 | 199 | |
| 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 | ||
| 229 | 245 | /** @param {{ querySelectorAll: Function }} doc */ |
| 230 | 246 | export function watchGrow(doc) { |
| 231 | 247 | 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 { | ||
| 19 | 19 | const STYLE = ` |
| 20 | 20 | :root { color-scheme: dark light; --bg: #0b0d10; --panel: #11161d; --fg: #e8eaed; --dim: #9aa4ae; --line: #2a313a; --accent: #f0883e; --on-accent: #1a1206; --link: #79b8ff; } |
| 21 | 21 | @media (prefers-color-scheme: light) { :root { --bg: #fbfaf7; --panel: #fff; --fg: #1f2328; --dim: #59636e; --line: #d8dee4; --accent: #bc4c00; --on-accent: #fff; --link: #0969da; } } |
| 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)); } | |
| 24 | 24 | h1 { margin: .5rem 0 1rem; } h2 { margin: .6rem 0; font-size: 1.15rem; } |
| 25 | 25 | .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); } | |
| 29 | 47 | .sight:empty { display: none; } |
| 30 | 48 | .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; } |
| 31 | 49 | .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; } | |
| 35 | 63 | .card p { color: var(--dim); margin: .4rem 0; } |
| 36 | 64 | .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); } |
| 37 | 65 | .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; } | ||
| 41 | 69 | .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; } |
| 42 | 70 | a { color: var(--link); } |
| 43 | 71 | .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; } | |
| 45 | 73 | `; |
| 46 | 74 | |
| 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 | ||
| 47 | 90 | /** Who is looking: the identity line's reading (src/signed-in.ts), null for a guest, and whether they own the place. */ |
| 48 | 91 | export interface Looker { |
| 49 | 92 | signedIn: SignedIn | null; |
| @@ -52,38 +95,89 @@ export interface Looker { | ||
| 52 | 95 | grow?: boolean; |
| 53 | 96 | /** When the newest lit still was taken: the card shows it without the script. */ |
| 54 | 97 | 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>`; | |
| 55 | 145 | } |
| 56 | 146 | |
| 57 | 147 | function card({ station, present, resting, cameras }: StationView, looker: Looker): string { |
| 58 | 148 | const head = `<h2>${esc(station.name)}</h2>`; |
| 149 | const kind = `data-kind="${esc(station.kind)}"`; | |
| 59 | 150 | if (!present) { |
| 60 | 151 | 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>` : ""; |
| 61 | 152 | // 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>`; | |
| 64 | 155 | } |
| 156 | const grow = station.kind === "grow"; | |
| 157 | const status = grow ? growing(looker.growNow, looker.owner) : ""; | |
| 65 | 158 | if (!cameras.length) { |
| 66 | 159 | const setup = looker.owner ? ` <a href="/admin/camera-list">Cameras</a> · <a href="/admin/stations/edit">Stations</a>` : ""; |
| 67 | 160 | 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>`; | |
| 69 | 162 | } |
| 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(""); | |
| 78 | 163 | 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>`; | |
| 81 | 171 | } |
| 82 | 172 | |
| 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__`; | |
| 85 | 176 | |
| 86 | 177 | 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);` : "") : "") : ""; | |
| 87 | 181 | return `<!doctype html> |
| 88 | 182 | <html lang="en"> |
| 89 | 183 | <head> |
| @@ -100,7 +194,7 @@ export function renderWorkshop(views: readonly StationView[], looker: Looker = { | ||
| 100 | 194 | <h1>${WORKSHOP_TITLE}</h1> |
| 101 | 195 | <div class="cards">${views.map((v) => card(v, looker)).join("\n")}</div> |
| 102 | 196 | </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>` : ""} | |
| 104 | 198 | </body> |
| 105 | 199 | </html> |
| 106 | 200 | `; |
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 <tray>, 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 | }); |