Adds the workshop/office assistant and the plumbing several other features were waiting on. The through-line: every new capability that could act on its own proposes instead, and says out loud when it does not know something. New service — workshop/ Project notebook (workshop.db) plus a never-pruned knowledge store (workshop-knowledge.db): standing workflow instructions by activity, keyword facts, durable project learnings, and the household's ONE hardware inventory. GET /context returns everything applying right now in one call, so the assistant is told the standing considerations rather than reminded of them. Two databases because they have different lifetimes: rebuilding the project store must not take the note about how you solder with it. Hardware statuses distinguish reserved (still on the shelf) from in_use (installed and working) — "can I use this right now" has different answers for the two, and naming a project on an in_use item never silently demotes it. Gitea repos with append-only history: commit/push/branch yes, unattended; force-push/rebase/amend/reset/filter-repo never, enforced server-side by branch protection rather than only by this code refusing. When history genuinely must be scrubbed, /scrub-request prints the commands for a human to run — the manual step is the safety mechanism. Fleet scripts: one monitoring-agent script per kind of machine, fetched by each endpoint's fleet-bootstrap timer. Remote code execution by design, so the constraints are the design — upload is a draft, publishing is separate, scripts live in SQLite rather than on the writable share, every version is kept, and the endpoint verifies the checksum and reports pass or fail. Slots exist for the ESP32s and network appliances that cannot run a script at all, holding the CheckMK-server-side config instead. Infrastructure health opnsense becomes a LIST of firewalls, each named, keyed by name rather than index. CheckMK joins it. Both are polled by workshop (always-on) and read by digest-engine, so the digest can say "critical since Tuesday" instead of quoting a six-hour-old snapshot. Three states, because "I could not ask" is not "nothing is wrong". pantry-vision All four stock movements are camera-driven; stock counts individual units and folds brand-free via Grocy product groups. Door-sensor-triggered appliance cameras record sightings as hints with timestamps, never as stock — a camera at a door cannot tell in from out. identity Per-person colour and settable profile picture, assigned to avoid collisions between people sharing an initial, on the 2-bit-per-channel lattice a colour Pebble renders natively. render/ — shared, vendored, dependency-free media-visualiser: two-tier by necessity, since most endpoints have no local audio; the synthetic tier says on screen that it is not an analysis. floorplan-3d: canvas 2D rather than three.js — the scene is prisms on a plane, which an isometric projection draws in ~200 lines, predictably on weak panels, with the frontend still at zero dependencies. Config and fleet plumbing Rooms are one vocabulary (an HA area_id) from CoreSystemConfig through the builders to suggested_area. Keycloak and FreeIPA are coupled as one decision with USR_HA_ group naming, declaration-only for now and validated as such. Immich alongside the photo share, read-only. Thin clients get the full media-key set for a wireless remote. Docs: fridge-item-location, workshop-assistant, rooms-and-endpoints, endpoint-surfaces, pebble-presence-watchface. Testing is stubbed suites and headless unit checks only — no real Grocy, camera, vision model, CheckMK, Gitea, Samba or browser has been involved. The CheckMK API shape and Gitea's branch-protection payload are written from documentation and have version-sensitive field names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FanS1vyE2gLhGkqKq6HtYj |
||
|---|---|---|
| .. | ||
| Dockerfile | ||
| README.md | ||
| check.py | ||
| chores.env.example | ||
| requirements.txt | ||
README.md
chores
Camera-verified household task distribution, from Phase 20 of the project plan.
The principle, stated once because it drives every design choice here: "I don't care who does it, as long as it gets done." This is not an assignment system that picks one fair person and waits on them — it's a nudge system that keeps redirecting to whoever is actually around until the chore is done. Fairness is tracked for comparison, not enforced by any algorithm — see "The tally is passive" below.
Runs every ~2 hours via a systemd timer with RandomizedDelaySec=1800 (systemd's
own built-in jitter — the "+/-30 min in case something else is running" the phase
was specified with, not custom code). Three steps, in order — see check.py's own
module docstring for the full reasoning on each:
- Trash-day-eve — reads the same personal collection-date feed
trash-calendarreads (WASTE_ICS_URL); if pickup is tomorrow, opens a "trash" chore. - Camera checks (opt-in, needs
FRIGATE_URL+CAMERA_WATCHPOINTS) — grabs a Frigate snapshot per configured watch point (optionally moving a PTZ camera to a preset first), asks an Ollama vision model a one-word question ("is this bin FULL/PARTIAL/EMPTY", "is this counter DIRTY/CLEAN", "is there litter left out here, YES/NO", "are there groceries standing out that belong in a fridge/freezer/ cupboard, YES/NO"), opens a chore on "needs attention," auto-closes one on "clear." - Nudging — ASAP, not on a fixed schedule: the first run after a chore opens
nudges whoever
identityreports home right now — preferring anyone assigned that chore type, minus anyonechore_exempt, both below. If the chore is still openNEGLECT_THRESHOLD_HOURSafter the last nudge (and the household calendar isn't showing a busy window), the nudge goes to someone different from who was last asked — "the next person that walks by" — rather than re-nagging the same person.litterandgroceries_out_of_placechores are special-cased to prefer whoever the camera most recently recognized nearby (a best-effort "who left this" guess), since the point there is telling the actual person, not just whoever's around — and both also ignorechore_exemptentirely, see below. Each nudge's wording is a plain template unless the target has achore_reminder_styleset, see below.
Putting the groceries away is litter with a shorter fuse
groceries_out_of_place is built as litter's twin on purpose — same "whoever the
camera just saw" attribution, same exemption-proofing, same unassignability, because
it is the same situation: somebody carried something in and put it down. Its one
deliberate difference is GROCERIES_NEGLECT_THRESHOLD_HOURS, 30 minutes instead of
the usual four, and that is the whole reason it isn't simply another watch point on
the litter prompt: litter left for an afternoon is untidiness, and a tub of ice
cream left for an afternoon is a bin bag. It is the only chore type in check.py
treated as more urgent than the others, because it is the only one where being late
costs something other than tidiness.
Two things worth knowing before pointing a camera at this:
- The prompt names what to ignore, not just what to look for — fruit bowls, bread bins, coffee, oil, spices, appliances, cookware. Ask a vision model the general question ("is anything out of place?") and it will say YES to a kitchen that is simply a kitchen, every two hours, until somebody mutes the topic.
- It is a chore, not an inventory update. It notices that food is standing out; it
does not know what the food is and never books anything into Grocy. That is
pantry-vision's job, on a display someone is actually standing at. Two systems looking at the same counter for two different reasons is the intended shape, not a duplication to collapse.
Assignment is a preference, not a lock
Set in identity's admin panel (admin.html, People → a person → Chores), stored as
that person's chore_assignments and read off the same /presence call as everything
else here.
When someone assigned to a chore type is home, they get nudged instead of whoever happens to be nearest — that's the point of assigning it. When no assignee is home, the nudge falls through to the ordinary whoever's-here rotation rather than waiting, because the stated principle is still "I don't care who does it, as long as it gets done." An absent assignee doesn't get to be the reason the bin stays full.
Set CHORE_ASSIGNMENT_STRICT=true if your household wants the opposite: the chore
waits for its assignee and nobody else is asked. Both readings of "assign chores" are
legitimate and this file can't pick for you, so it's one env var rather than a
hard-coded opinion. Strict mode still never stalls a chore that was assigned to
nobody — that falls through regardless.
Litter and groceries-left-out can't be assigned (_ASSIGNMENTS_DONT_APPLY in
check.py), for the same reason they ignore exemptions: they go to whoever left the
mess, and cleaning up after yourself was never a task anyone could be handed.
Chore-exempt people — everyone except litter and groceries
Set in the same place (identity's admin panel, or POST /people/<id>/chore-settings
directly). A chore_exempt person is
dropped from the nudge rotation entirely — the "cousin visits often but doesn't owe
me chores" case. Litter and groceries-left-out are the deliberate exceptions
(_EXEMPTIONS_DONT_APPLY in check.py): an exempt person still gets told to put
trash they left out into the bin and to put the milk away, because neither is "doing
a chore," both are cleaning up after yourself — and a guest who helped unpack the
shopping is exactly as able to finish the job as anyone else. If everyone currently
home is chore_exempt for one of the other chore types, that run just logs and skips —
the chore stays open until someone eligible is around.
Reminder tone is per-person and LLM-phrased, but never LLM-decided
identity's chore_reminder_style free-text field ("be assertive, don't let up" /
"be gentle, give me a few minutes of grace") is passed to OLLAMA_TEXT_MODEL (if
configured) purely to phrase the ntfy message text — see _compose_message(). This
is deliberately scoped narrower than the general chore-distribution LLM idea from
earlier in the project: it changes how the reminder is worded, never who gets
nudged or when — that decision stays presence/calendar-driven, per the module
docstring's stated principle. No OLLAMA_TEXT_MODEL configured, no style set for
that person, or the call fails/returns nothing for any reason: the plain
un-styled template is used verbatim, same wording as before this feature existed.
The tally is passive
Every nudge is logged with who got it. Nothing reads that log to decide who to
nudge next — that decision is presence/calendar-driven only, per the stated
principle. The log exists purely so a household member can look at the numbers
later and judge fairness for themselves ("comparison for fairness's sake," not an
automated fairness algorithm) — print_tally() logs a rolling 30-day count each
run; nothing renders it anywhere yet, see "What's not built."
The calendar busy-check is household-wide, not per-person
_household_currently_busy() looks for a currently-active event on the one
shared household calendar whose summary contains a configured keyword (busy,
meeting, call, movie, sleep, ...) and pauses nudging for that run if it
finds one. This is a real, honest limitation: it can't tell that only one person is
in that meeting and nudge someone else who's free — everyone's nudges pause
together. A real per-person calendar/availability model would need per-person
calendars, which this project doesn't have (see docs/project-plan.md's open
decisions). Fails open (treats an unreachable/misconfigured calendar as "not busy")
so a broken calendar check can never be the reason chores stop getting nudged
entirely.
Never auto-completes from nudging
Only a fresh camera check finding a watch point clear, or a manual close (not built here — see "What's not built" below), ever marks a chore done. A nudge firing is not the same as the chore being done; conflating the two would let something that was repeatedly nudged but never actually done silently vanish.
Why this is a separate service, not folded into identity or pantry-vision
It reads from identity (who's home, who's nearby, who was recently seen by a
camera) and reuses trash-calendar's own ICS feed config and digest-engine's own
CalDAV credential names, but owns a genuinely different job — task state,
presence-driven nudging, notification escalation — that doesn't belong bolted onto
any of those services' own single responsibilities. Same reasoning as
trash-calendar itself being separate from digest-engine's read-only calendar
ingestion.
What's not built
- No way to manually mark a chore done — no HA button, no voice phrase, no API endpoint. Right now the only way a chore closes is a camera re-check finding it clear, or direct SQLite surgery. A real deployment probably wants an HA button/voice "mark the trash as done" — deliberately left out of this pass rather than guessed at without knowing how the household actually wants to interact with it.
- No per-person ntfy topics — one shared
NTFY_TOPIC, message text names who it's for. Set up real per-person routing yourself if that's not enough. - No web UI / dashboard for chore history or the fairness tally —
chore_eventslogs everything (created, nudged, auto_closed) and the tally is logged each run, but nothing renders either anywhere yet. - No per-person calendar availability — see "household-wide, not per-person" above.
- No re-check of
_compose_message()'s LLM output — whatever the model returns (if anything) is sent as-is, no validation that it's actually on-topic, on-tone, or even non-empty garbage beyond the plain empty-string fallback check. - No confirmation that an assignee actually did it — assignment changes who gets asked, not who gets credited. Attribution when a chore closes is still the same best-effort "whoever was nudged most recently" heuristic, which an assignment doesn't make any more truthful. See "The tally is passive" above.
Configure
cp chores/chores.env.example /opt/smart-home/chores/chores.env
chmod 600 /opt/smart-home/chores/chores.env
$EDITOR /opt/smart-home/chores/chores.env
Works with nothing but IDENTITY_URL/IDENTITY_TOKEN and ntfy filled in — the
trash-day-eve check, calendar busy-check, and camera checks all individually no-op
when left unconfigured.
Manual verification still outstanding
- Frigate's PTZ move-to-preset API shape (
_frigate_snapshot()'sPOST /api/<camera>/ptz/move/<preset>) is assumed from Frigate's general PTZ feature set, not confirmed against a real Tapo PTZ camera wired into Frigate — seedocs/project-plan.md§1.18/Phase 20's own callout. A wrong endpoint just means the snapshot is taken from wherever the camera already was. - Whether the vision model's one-word FULL/PARTIAL/EMPTY/DIRTY/CLEAN/YES/NO
answers are actually reliable for a real bin/sink/hallway from a real camera
angle — completely unmeasured, same caveat as pantry-vision's own vision-model
accuracy note.
groceries_out_of_placeis the one to check first: its whole design rests on a model reliably telling a carton of milk from a fruit bowl, and its failure mode is not a missed chore but a false one every two hours, which is how a household learns to ignore the notification channel. Point it at a clean counter for a day before trusting a YES. _likely_culprit()'s reliance onidentity'sface_seen_recentlyfield assumes Frigate face-recognition presence corroboration is actually wired up and working (identity/README.md's own "Camera face recognition" section is itself unverified) — until then, litter chores just fall back to "whoever's around," same as every other chore type._household_currently_busy()'s CalDAV read has never been run against a real Nextcloud instance from this specific code path (digest-engine's owningest/caldav.pyis a separate, independently-tested read).- No camera hardware has been chosen (
docs/project-plan.md§1.18) — nothing here has been run against a real Tapo camera or Frigate PTZ integration at all. _compose_message()'s LLM-phrased reminders have never been checked against a realOLLAMA_TEXT_MODELfor whether the output actually respects a givenchore_reminder_stylereliably, vs. just producing generically-toned text — same "no measured accuracy" caveat as the vision checks above. A bad or off-style result still gets sent (there's no re-check of the LLM's own output here), just not blocked — see "What's not built" for why there's no re-check.