Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
workbenchthis machine
Off-due as a real reminder in the care log (follow-up to #639) #642
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
quill/arm:quill/641-off-due-as-a-real-reminder-in-the-care-l → main 5 commits fork
8 files
changed +161
−14
Software/so101/README.md +1 −1
| @@ -160,7 +160,7 @@ The grow box's care app: a plant profile, reminders worked out from it and the l | ||
| 160 | 160 | |
| 161 | 161 | - **The page `/care`** (anyone may read; only the owner has the buttons): the health strip (green, or what is off, like "pH 7.1, above 6.5, measured 2 d ago"), the reminders with Done and Snooze, one-tap "Topped off", "Changed water" and "Watered", forms for a measurement, an action, a note, a problem and the transplant, charts of the last 30 days with the band shaded, and the log book as a timeline with the stage headings. It needs no JavaScript. |
| 162 | 162 | - **Stages.** A station starts as a rockwool seedling; logging a **transplant** (system, litres, sites, pump) moves it to the hydroponic stage and the first harvest after that to the harvest stage. Each stage has its own bands and reminders, and the history stays in the log. Reminders measure every 24 h (a vertical tower every 12 h) and a tower tops off every 24 h; the pump reminder shows only if the transplant said there is a pump. |
| 163 | - **Reminders** fall due a set time after the last log entry that counts for them (a top-off resets "top off", a watering resets the moisture check), or after the stage began. Done, Snooze and a different cadence (`POST /care/reminder {id, action: "done"|"snooze"|"every", hours}`) are the owner's. | |
| 163 | - **Reminders** fall due a set time after the last log entry that counts for them (a top-off resets "top off", a watering resets the moisture check), or after the stage began. Done, Snooze and a different cadence (`POST /care/reminder {id, action: "done"|"snooze"|"every", hours}`) are the owner's. **Switch the grow light off** (`light-off`, src/light-day.ts) is there while the light is logged on: due at the latest `lightOn` plus the schedule's hours as the Pi last said (12 h before it has; held to 10–14 h), gone once a `lightOff` is logged. Its Done logs the `lightOff`, its Snooze is an hour on the page (a new `lightOn` clears it, so last night's snooze never hides today's), its cadence is the schedule's and is not edited here, and its sources are the stage's photoperiod citations. Overdue, it sits on the health strip, in `/care/api` and in the overdue list the grow watchdog reads. | |
| 164 | 164 | - **Profiles are files.** `src/care-profiles/spinach.json` ships with the app; put another plant (lettuce, basil) as `FORGE_APP_DATA/so101/care-profiles/<name>.json` in the same shape (stages, `bands`, `reminders` with `everyHours`, optional `tower`, `needs`, `resets`; see src/care-profile.ts, which names what is wrong with a bad file and skips it). `CARE_PROFILE=<id>` picks which one the app runs (default `spinach`). |
| 165 | 165 | - **Sources.** Every target, reminder and stage cites a reference. A value shows a footnote marker; tapping it opens a card with the reference, what it supports and a link. A value with no source says "grower consensus (unsourced)". `GET /care/learn` is the Learn page: the references by topic, extension and peer-reviewed first, then a Fundamentals reading path (week one, then week two) and a list of items named but not checked. `GET /care/learn.json` and the terminal's `/care learn` give the same with URLs. In a profile, `references` is a list of `{id, title, publisher, url, kind, accessed, topics, covers}`: `kind` is `extension`, `peer-reviewed`, `grower` or `vendor`, `url` is `https`, and ids are unique. `sources` (a list of `{id, claim}`, each id naming a reference) sits on each band (under the stage's `sources`, keyed by measure) and on each reminder, with an optional `why` on a reminder; `light.sources`, `light.warnSources` and `timeline.sources` do the same for the light hours, the bolting warning and the timeline. A source naming no reference makes the profile load fail. `/care/api` and `/care` list each reminder's `why` and source ids. |
| 166 | 166 | - **The log book** is `FORGE_APP_DATA/so101/care.json`. Entries are `measurement` (ph, ec, waterTemp, airTemp, level, lightHours), `action` (sow, water, topoff, change, nutrients, thin, harvest, airstone, pumpcheck, transplant, lightOn, lightOff), `observation` and `problem`, each with a time (now unless given). A measurement is a `Reading {kind, value, at, source: manual|sensor}`, so a sensor can write the same later. Photos on observations and the pump schedule are not part of it yet. |
Software/so101/src/care-log.ts +3
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; |
| 7 | 7 | import { dirname } from "node:path"; |
| 8 | 8 | import { MEASURES, type Measure } from "./care-profile.ts"; |
| 9 | import { LIGHT_OFF } from "./light-day.ts"; | |
| 9 | 10 | |
| 10 | 11 | export const MAX_ENTRIES = 5000; |
| 11 | 12 | export const SENSOR_EVERY_MS = 3_600_000; |
| @@ -182,6 +183,8 @@ export class CareLog { | ||
| 182 | 183 | const entry = { ...body, id: this.data.nextId++ } as Entry; |
| 183 | 184 | this.data.entries.push(entry); |
| 184 | 185 | this.data.entries.sort((a, b) => a.at - b.at || a.id - b.id); |
| 186 | // A new day of light: yesterday's "light still on, snooze" must not hide today's off-time. | |
| 187 | if (entry.type === "action" && entry.what === "lightOn") delete this.data.snoozes[LIGHT_OFF]; | |
| 185 | 188 | this.save(); |
| 186 | 189 | return entry; |
| 187 | 190 | } |
Software/so101/src/care-page.ts +9 −4
| @@ -1,5 +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 { LIGHT_OFF } from "./light-day.ts"; | |
| 3 | 4 | import { boltingWatch, currentStage, growing, health, LABELS, ago, readings, reminders, snapshot, stageAt, stageHistory, totals, transplantPrompt, type Snapshot } from "./care-rules.ts"; |
| 4 | 5 | import { ACTIONS, PROBLEMS, SYSTEMS, type Entry, type CareLog } from "./care-log.ts"; |
| 5 | 6 | import { MEASURES, type Band, type Measure, type Profile } from "./care-profile.ts"; |
| @@ -99,14 +100,16 @@ export interface CareView { | ||
| 99 | 100 | /** The Pi's pumps under the dosing card; null when the Pi did not answer. */ |
| 100 | 101 | pumps?: DosingView | null; |
| 101 | 102 | signedIn: SignedIn | null; |
| 103 | /** The light's scheduled hours as the Pi last said (the light-off reminder's day). */ | |
| 104 | lightHours?: number | null; | |
| 102 | 105 | /** A refused form's message, shown at the top. */ |
| 103 | 106 | error?: string | null; |
| 104 | 107 | /** The probe panel, already rendered (src/care-probes.ts); empty without probes. */ |
| 105 | 108 | probes?: string; |
| 106 | 109 | } |
| 107 | 110 | |
| 108 | export function renderCare({ profile, log, now, owner, signedIn, error, dosing, pumps, probes = "" }: CareView): string { | |
| 109 | const snap = snapshot(log); | |
| 111 | export function renderCare({ profile, log, now, owner, signedIn, error, dosing, pumps, probes = "", lightHours }: CareView): string { | |
| 112 | const snap = snapshot(log, lightHours); | |
| 110 | 113 | const spans = stageHistory(profile, snap.entries, snap.startedAt); |
| 111 | 114 | const cur = currentStage(spans); |
| 112 | 115 | const h = health(profile, snap, now); |
| @@ -145,8 +148,10 @@ ${dosing.simulated ? `<p class="dim">No pumps are wired yet: a dose is only note | ||
| 145 | 148 | .map((d) => { |
| 146 | 149 | const late = d.overdueMs > 0 && !d.snoozedUntil; |
| 147 | 150 | const state = d.snoozedUntil ? `snoozed until ${when(d.snoozedUntil)}` : late ? `overdue ${Math.max(1, Math.floor(d.overdueMs / 3_600_000))} h` : `due ${when(d.dueAt)}`; |
| 148 | const acts = owner ? `<span>${post("/care/reminder", { id: d.reminder.id, action: "done" }, "Done")} ${post("/care/reminder", { id: d.reminder.id, action: "snooze", hours: "24" }, "Snooze 1 d", "quiet")}</span>` : ""; | |
| 149 | return `<li data-reminder="${esc(d.reminder.id)}"><span>${esc(d.reminder.label)}<br>${d.reminder.why ? `<span class="dim">${esc(d.reminder.why)}</span> ` : ""}${cite(d.reminder.sources)}<br><span class="${late ? "late" : "dim"}">${esc(state)} · every ${d.everyHours} h</span></span>${acts}</li>`; | |
| 151 | // The light's day is hours long: its snooze is an hour, and Done logs the light off. | |
| 152 | const light = d.reminder.id === LIGHT_OFF; | |
| 153 | const acts = owner ? `<span>${post("/care/reminder", { id: d.reminder.id, action: "done" }, light ? "It's off" : "Done")} ${post("/care/reminder", { id: d.reminder.id, action: "snooze", hours: light ? "1" : "24" }, light ? "Snooze 1 h" : "Snooze 1 d", "quiet")}</span>` : ""; | |
| 154 | return `<li data-reminder="${esc(d.reminder.id)}"><span>${esc(d.reminder.label)}<br>${d.reminder.why ? `<span class="dim">${esc(d.reminder.why)}</span> ` : ""}${cite(d.reminder.sources)}<br><span class="${late ? "late" : "dim"}">${esc(state)} · ${light ? `${d.everyHours} h day` : `every ${d.everyHours} h`}</span></span>${acts}</li>`; | |
| 150 | 155 | }) |
| 151 | 156 | .join(""); |
| 152 | 157 |
Software/so101/src/care-routes.ts +8 −4
| @@ -5,7 +5,7 @@ import { boltingWatch, currentStage, health, reminders, snapshot, stageHistory, | ||
| 5 | 5 | import { LogError, type CareLog } from "./care-log.ts"; |
| 6 | 6 | import { learnData, renderLearn } from "./care-sources.ts"; |
| 7 | 7 | import { renderCare } from "./care-page.ts"; |
| 8 | import { lastLight } from "./light-day.ts"; | |
| 8 | import { LIGHT_OFF, lastLight } from "./light-day.ts"; | |
| 9 | 9 | import { checkinEntry, renderCheckin } from "./care-checkin.ts"; |
| 10 | 10 | import { calibrateProbe, renderProbes, type ProbeCache } from "./care-probes.ts"; |
| 11 | 11 | import type { Profile } from "./care-profile.ts"; |
| @@ -26,6 +26,8 @@ export interface CareContext { | ||
| 26 | 26 | now?: () => number; |
| 27 | 27 | /** The Pi's time zone, asked of the Pi when not yet known; null when it does not answer. Without it, UTC. */ |
| 28 | 28 | zone?: () => Promise<string | null>; |
| 29 | /** The light's scheduled hours as the Pi last said, null before it has; the light-off reminder's day length. */ | |
| 30 | lightHours?: () => number | null; | |
| 29 | 31 | /** The pH/EC probes on the Pi; without them the page has no probe panel. */ |
| 30 | 32 | probes?: ProbeCache; |
| 31 | 33 | } |
| @@ -57,7 +59,7 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path | ||
| 57 | 59 | |
| 58 | 60 | if (req.method === "GET" && pathname === "/care") { |
| 59 | 61 | const probes = renderProbes(ctx.probes?.peek() ?? null, me.owner); |
| 60 | send(200, "text/html; charset=utf-8", renderCare({ probes, profile: ctx.profile, log: ctx.log, now, owner: me.owner, dosing: me.owner ? ctx.doser?.status() : undefined, pumps: me.owner && ctx.doser ? ((await ctx.pumps?.()) ?? null) : undefined, signedIn: me.signedIn, error: new URL(req.url ?? "/", "http://x").searchParams.get("error")?.slice(0, 200) })); | |
| 62 | send(200, "text/html; charset=utf-8", renderCare({ probes, profile: ctx.profile, log: ctx.log, lightHours: ctx.lightHours?.(), now, owner: me.owner, dosing: me.owner ? ctx.doser?.status() : undefined, pumps: me.owner && ctx.doser ? ((await ctx.pumps?.()) ?? null) : undefined, signedIn: me.signedIn, error: new URL(req.url ?? "/", "http://x").searchParams.get("error")?.slice(0, 200) })); | |
| 61 | 63 | return true; |
| 62 | 64 | } |
| 63 | 65 | if (req.method === "GET" && pathname === "/care/learn") { |
| @@ -91,7 +93,7 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path | ||
| 91 | 93 | return true; |
| 92 | 94 | } |
| 93 | 95 | if (req.method === "GET" && pathname === "/care/api") { |
| 94 | const snap = snapshot(ctx.log); | |
| 96 | const snap = snapshot(ctx.log, ctx.lightHours?.()); | |
| 95 | 97 | const spans = stageHistory(ctx.profile, snap.entries, snap.startedAt); |
| 96 | 98 | json(200, { |
| 97 | 99 | profile: ctx.profile.id, |
| @@ -165,7 +167,9 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path | ||
| 165 | 167 | result = done.view; |
| 166 | 168 | } else if (reminder) { |
| 167 | 169 | const rid = String(input.id ?? ""); |
| 168 | if (input.action === "done") result = ctx.log.add({ type: "action", what: "done", ref: rid }); | |
| 170 | // Done with the light-off reminder is the light going off: logged as such, so the day closes. | |
| 171 | if (input.action === "done") result = ctx.log.add(rid === LIGHT_OFF ? { type: "action", what: "lightOff", cause: "owner" } : { type: "action", what: "done", ref: rid }); | |
| 172 | else if (input.action === "every" && rid === LIGHT_OFF) throw new LogError("the light's day follows its schedule: change the hours on the light"); | |
| 169 | 173 | else if (input.action === "snooze") ctx.log.snooze(rid, Number(input.hours ?? 24)); |
| 170 | 174 | else if (input.action === "every") ctx.log.setEvery(rid, input.hours === null ? null : Number(input.hours)); |
| 171 | 175 | else throw new LogError("action is done, snooze or every"); |
Software/so101/src/care-rules.ts +24 −2
| @@ -5,6 +5,8 @@ | ||
| 5 | 5 | |
| 6 | 6 | import type { Band, Measure, Profile, Reminder, Stage } from "./care-profile.ts"; |
| 7 | 7 | import { keyOf, type Entry, type CareLog, type Reading, type Transplant } from "./care-log.ts"; |
| 8 | import { LIGHT_OFF, openDay } from "./light-day.ts"; | |
| 9 | import { DEFAULT_SCHEDULE } from "./light-panel.ts"; | |
| 8 | 10 | |
| 9 | 11 | const HOUR = 3_600_000; |
| 10 | 12 | const DAY = 24 * HOUR; |
| @@ -14,8 +16,10 @@ export interface Snapshot { | ||
| 14 | 16 | startedAt: number; |
| 15 | 17 | snoozes: Readonly<Record<string, number>>; |
| 16 | 18 | every: Readonly<Record<string, number>>; |
| 19 | /** The light's scheduled hours as the Pi last said; the light-off reminder falls back to the default schedule without it. */ | |
| 20 | lightHours?: number; | |
| 17 | 21 | } |
| 18 | export const snapshot = (log: CareLog): Snapshot => ({ entries: log.entries(), startedAt: log.startedAt, snoozes: log.snoozes, every: log.every }); | |
| 22 | export const snapshot = (log: CareLog, lightHours?: number | null): Snapshot => ({ entries: log.entries(), startedAt: log.startedAt, snoozes: log.snoozes, every: log.every, ...(lightHours ? { lightHours } : {}) }); | |
| 19 | 23 | |
| 20 | 24 | export interface StageSpan { |
| 21 | 25 | stage: Stage; |
| @@ -59,11 +63,28 @@ export interface Due { | ||
| 59 | 63 | snoozedUntil: number | null; |
| 60 | 64 | } |
| 61 | 65 | |
| 62 | /** Each reminder of the current stage with when it falls due: the cadence after the last matching log entry, or after the stage began. */ | |
| 66 | /** While the light is logged on (#641): switch it off at the actual on-time plus the day's hours. A lightOff ends it; its cadence is the schedule's, never edited here. */ | |
| 67 | function lightOff(stage: Stage, snap: Snapshot, now: number): Due | null { | |
| 68 | const day = openDay(snap.entries, now, snap.lightHours ?? DEFAULT_SCHEDULE.hours); | |
| 69 | if (!day) return null; | |
| 70 | const reminder: Reminder = { | |
| 71 | id: LIGHT_OFF, | |
| 72 | label: "Switch the grow light off", | |
| 73 | why: `${day.hours} h of light from when it came on: a longer day pushes spinach to bolt.`, | |
| 74 | sources: stage.sources.lightHours ?? [], | |
| 75 | everyHours: day.hours, | |
| 76 | resets: ["action:lightOff"], | |
| 77 | }; | |
| 78 | const until = snap.snoozes[LIGHT_OFF]; | |
| 79 | return { reminder, everyHours: day.hours, dueAt: day.offDue, overdueMs: now - day.offDue, snoozedUntil: until && until > now ? until : null }; | |
| 80 | } | |
| 81 | ||
| 82 | /** Each reminder of the current stage with when it falls due: the cadence after the last matching log entry, or after the stage began; and the light-off one while the light is on. */ | |
| 63 | 83 | export function reminders(profile: Profile, snap: Snapshot, now: number): Due[] { |
| 64 | 84 | const spans = stageHistory(profile, snap.entries, snap.startedAt); |
| 65 | 85 | const { stage, from } = currentStage(spans); |
| 66 | 86 | const moved = transplantOf(snap.entries)?.t; |
| 87 | const light = lightOff(stage, snap, now); | |
| 67 | 88 | return stage.reminders |
| 68 | 89 | .filter((r) => !r.needs || (r.needs === "pump" ? moved?.pump : moved?.system === "vertical tower")) |
| 69 | 90 | .map((reminder) => { |
| @@ -75,6 +96,7 @@ export function reminders(profile: Profile, snap: Snapshot, now: number): Due[] | ||
| 75 | 96 | const until = snap.snoozes[reminder.id]; |
| 76 | 97 | return { reminder, everyHours, dueAt, overdueMs: now - dueAt, snoozedUntil: until && until > now ? until : null }; |
| 77 | 98 | }) |
| 99 | .concat(light ? [light] : []) | |
| 78 | 100 | .sort((a, b) => a.dueAt - b.dueAt); |
| 79 | 101 | } |
| 80 | 102 |
Software/so101/src/light-day.ts +12
| @@ -50,6 +50,18 @@ export function lastLight(entries: readonly Entry[], now: number): "on" | "off" | ||
| 50 | 50 | return e && e.type === "action" ? (e.what === "lightOn" ? "on" : "off") : null; |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | /** The care reminder that the light goes off (#641): its id in the reminders list, the snoozes and `/care/reminder`. */ | |
| 54 | export const LIGHT_OFF = "light-off"; | |
| 55 | ||
| 56 | /** The day still open: the latest lightOn by `now` with no lightOff after it, within a day and a half; null otherwise. Needs no zone. */ | |
| 57 | export function openDay(entries: readonly Entry[], now: number, scheduledHours: number): { onAt: number; offDue: number; hours: number } | null { | |
| 58 | const on = latest(entries.filter((x) => x.type === "action" && x.what === "lightOn" && x.at <= now)); | |
| 59 | if (!on || now - on.at > STALE_MS) return null; | |
| 60 | if (entries.some((x) => x.type === "action" && x.what === "lightOff" && x.at > on.at && x.at <= now)) return null; | |
| 61 | const hours = dayHours(scheduledHours); | |
| 62 | return { onAt: on.at, offDue: on.at + hours * HOUR, hours }; | |
| 63 | } | |
| 64 | ||
| 53 | 65 | /** The current (or just-finished) day from the log; null when the light was never logged on, or not within a day and a half. */ |
| 54 | 66 | export function lightDay(entries: readonly Entry[], now: number, zone: string, schedule: Photo): LightDay | null { |
| 55 | 67 | const on = latest(entries.filter((x) => x.type === "action" && x.what === "lightOn" && x.at <= now)); |
Software/so101/src/light-off.test.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { createServer } from "node:http"; | |
| 3 | import { CareLog } from "./care-log.ts"; | |
| 4 | import { health, nextDue, overdue, reminders, snapshot } from "./care-rules.ts"; | |
| 5 | import { BUILT_IN_DIR, loadProfiles } from "./care-profile.ts"; | |
| 6 | import { renderCare } from "./care-page.ts"; | |
| 7 | import { handleCare } from "./care-routes.ts"; | |
| 8 | import { LIGHT_OFF } from "./light-day.ts"; | |
| 9 | ||
| 10 | const H = 3_600_000; | |
| 11 | const DAY0 = Date.UTC(2026, 9, 27); | |
| 12 | const spinach = loadProfiles(BUILT_IN_DIR).find((p) => p.id === "spinach")!; | |
| 13 | ||
| 14 | function book() { | |
| 15 | const clock = { now: DAY0 }; | |
| 16 | const log = new CareLog(undefined, () => clock.now); | |
| 17 | const light = (what: "lightOn" | "lightOff", at: number) => ((clock.now = at), log.add({ type: "action", what, at })); | |
| 18 | return { clock, log, light }; | |
| 19 | } | |
| 20 | const off = (log: CareLog, now: number, hours?: number) => reminders(spinach, snapshot(log, hours), now).find((d) => d.reminder.id === LIGHT_OFF); | |
| 21 | ||
| 22 | describe("the light-off reminder (#641)", () => { | |
| 23 | test("is due at the actual on-time plus the schedule's hours, cited, and absent before any lightOn", () => { | |
| 24 | const { log, light } = book(); | |
| 25 | expect(off(log, DAY0 + 9 * H)).toBeUndefined(); | |
| 26 | light("lightOn", DAY0 + 8.5 * H); | |
| 27 | const d = off(log, DAY0 + 10 * H)!; | |
| 28 | expect(d.dueAt).toBe(DAY0 + 20.5 * H); | |
| 29 | expect(d.everyHours).toBe(12); | |
| 30 | expect(d.reminder.sources.length).toBeGreaterThan(0); | |
| 31 | expect(off(log, DAY0 + 10 * H, 13)!.dueAt).toBe(DAY0 + 21.5 * H); | |
| 32 | // Held to the spinach maximum, whatever the Pi says. | |
| 33 | expect(off(log, DAY0 + 10 * H, 18)!.dueAt).toBe(DAY0 + 22.5 * H); | |
| 34 | }); | |
| 35 | ||
| 36 | test("a lightOff ends it, and the next lightOn starts a new one", () => { | |
| 37 | const { log, light } = book(); | |
| 38 | light("lightOn", DAY0 + 8 * H); | |
| 39 | light("lightOff", DAY0 + 20 * H); | |
| 40 | expect(off(log, DAY0 + 21 * H)).toBeUndefined(); | |
| 41 | light("lightOn", DAY0 + 31 * H); | |
| 42 | expect(off(log, DAY0 + 32 * H)!.dueAt).toBe(DAY0 + 43 * H); | |
| 43 | }); | |
| 44 | ||
| 45 | test("overdue lands in the list the watchdog reads and on the health strip; a snooze hides it until the next lightOn", () => { | |
| 46 | const { clock, log, light } = book(); | |
| 47 | light("lightOn", DAY0 + 8 * H); | |
| 48 | const late = DAY0 + 22 * H; | |
| 49 | expect(overdue(reminders(spinach, snapshot(log), late)).map((d) => d.reminder.id)).toContain(LIGHT_OFF); | |
| 50 | expect(health(spinach, snapshot(log), late).lines.join()).toContain("Switch the grow light off is 2 h overdue"); | |
| 51 | clock.now = late; | |
| 52 | log.snooze(LIGHT_OFF, 1); | |
| 53 | expect(overdue(reminders(spinach, snapshot(log), late + 0.5 * H)).map((d) => d.reminder.id)).not.toContain(LIGHT_OFF); | |
| 54 | expect(overdue(reminders(spinach, snapshot(log), late + 1.5 * H)).map((d) => d.reminder.id)).toContain(LIGHT_OFF); | |
| 55 | log.snooze(LIGHT_OFF, 24); | |
| 56 | light("lightOff", late + 2 * H); | |
| 57 | light("lightOn", DAY0 + 32 * H); | |
| 58 | expect(off(log, DAY0 + 45 * H)!.snoozedUntil).toBeNull(); | |
| 59 | }); | |
| 60 | ||
| 61 | test("is the grow card's next due while the light is on", () => { | |
| 62 | const { log, light } = book(); | |
| 63 | light("lightOn", DAY0 + 8 * H); | |
| 64 | expect(nextDue(spinach, snapshot(log), DAY0 + 19 * H)?.reminder.id).toBe(LIGHT_OFF); | |
| 65 | }); | |
| 66 | ||
| 67 | test("the care page lists it with an hour's snooze and an It's off button", () => { | |
| 68 | const { log, light } = book(); | |
| 69 | light("lightOn", DAY0 + 8 * H); | |
| 70 | const html = renderCare({ profile: spinach, log, now: DAY0 + 9 * H, owner: true, signedIn: null }); | |
| 71 | const row = html.slice(html.indexOf(`data-reminder="${LIGHT_OFF}"`)); | |
| 72 | expect(row).toContain("Switch the grow light off"); | |
| 73 | expect(row.slice(0, row.indexOf("</li>"))).toContain("Snooze 1 h"); | |
| 74 | expect(row.slice(0, row.indexOf("</li>"))).toContain("It's off"); | |
| 75 | }); | |
| 76 | ||
| 77 | test("Done on it logs the lightOff, its cadence cannot be edited, and /care/api lists it", async () => { | |
| 78 | const { clock, log, light } = book(); | |
| 79 | light("lightOn", Date.now() - 13 * H); | |
| 80 | clock.now = Date.now(); | |
| 81 | const server = createServer((req, res) => { | |
| 82 | 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))); }); | |
| 83 | handleCare(req, res, new URL(req.url!, "http://x").pathname, { profile: spinach, log, readBody, lightHours: () => 12, who: () => ({ owner: true, known: true, signedIn: null }) }).then((hit) => hit || (res.writeHead(404), res.end())); | |
| 84 | }); | |
| 85 | await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok)); | |
| 86 | const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`; | |
| 87 | const post = (body: object) => fetch(`${base}/care/reminder`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); | |
| 88 | try { | |
| 89 | const api = (await (await fetch(`${base}/care/api`)).json()) as { reminders: { id: string }[]; health: { ok: boolean } }; | |
| 90 | expect(api.reminders.map((r) => r.id)).toContain(LIGHT_OFF); | |
| 91 | expect(api.health.ok).toBe(false); | |
| 92 | expect((await post({ id: LIGHT_OFF, action: "every", hours: 16 })).status).toBe(400); | |
| 93 | expect((await post({ id: LIGHT_OFF, action: "done" })).status).toBe(200); | |
| 94 | expect(log.entries().at(-1)).toMatchObject({ type: "action", what: "lightOff", cause: "owner" }); | |
| 95 | const after = (await (await fetch(`${base}/care/api`)).json()) as { reminders: { id: string }[] }; | |
| 96 | expect(after.reminders.map((r) => r.id)).not.toContain(LIGHT_OFF); | |
| 97 | } finally { | |
| 98 | server.close(); | |
| 99 | } | |
| 100 | }); | |
| 101 | }); |
Software/so101/src/relay.ts +3 −3
| @@ -868,7 +868,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 868 | 868 | /** 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. */ |
| 869 | 869 | const growNow = (): GrowNow => { |
| 870 | 870 | const now = Date.now(); |
| 871 | const snap = snapshot(care.log); | |
| 871 | const snap = snapshot(care.log, lightSeen?.hours); | |
| 872 | 872 | const plant = growing(care.profile, snap, now); |
| 873 | 873 | const next = nextDue(care.profile, snap, now); |
| 874 | 874 | // Until the Pi has answered once its zone is unknown, and a light day worded in UTC would be wrong: say nothing and ask. |
| @@ -1239,7 +1239,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1239 | 1239 | pi, |
| 1240 | 1240 | stills: options.grow ? { latestAt: latest?.at ?? null, everyMs: options.grow.everyMs } : null, |
| 1241 | 1241 | light, |
| 1242 | overdue: overdue(reminders(care.profile, snapshot(care.log), now)).map((d) => ({ id: d.reminder.id, label: d.reminder.label, dueAt: d.dueAt })), | |
| 1242 | overdue: overdue(reminders(care.profile, snapshot(care.log, lightSeen?.hours), now)).map((d) => ({ id: d.reminder.id, label: d.reminder.label, dueAt: d.dueAt })), | |
| 1243 | 1243 | zone: grow?.light.zone || "UTC", |
| 1244 | 1244 | }; |
| 1245 | 1245 | }; |
| @@ -1300,7 +1300,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1300 | 1300 | return { owner: Boolean(person?.owner), known: Boolean(person), id: person?.id, signedIn: lobby && !lobby.local ? lobby.signedIn(r) : null }; |
| 1301 | 1301 | }; |
| 1302 | 1302 | if (await handleDosing(req, res, url.pathname, { doser, log: care.log, who, readBody, sameOrigin, pumps: pumpHooks })) return; |
| 1303 | if (await handleCare(req, res, url.pathname, { profile: care.profile, log: care.log, probes: probeCache, doser, pumps: () => pumpRead.read(), who, readBody, zone: async () => (lightSeen ?? ((await lightHooks.read()) && lightSeen))?.zone ?? null })) return; | |
| 1303 | if (await handleCare(req, res, url.pathname, { profile: care.profile, log: care.log, probes: probeCache, doser, pumps: () => pumpRead.read(), who, readBody, lightHours: () => lightSeen?.hours ?? null, zone: async () => (lightSeen ?? ((await lightHooks.read()) && lightSeen))?.zone ?? null })) return; | |
| 1304 | 1304 | } |
| 1305 | 1305 | if (reading && url.pathname === "/stations") return json(res, 200, stationViews().map(({ station, present, resting, cameras: c }) => ({ id: station.id, name: station.name, kind: station.kind, present, ...(resting ? { resting } : {}), cameras: c }))); |
| 1306 | 1306 | const cameraAdmin = ADMIN_CAMERA.exec(url.pathname); |