"""chores — camera-verified household task distribution, from docs/project-plan.md Phase 20. THE PRINCIPLE, stated once because it drives every design choice below: **"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 the algorithm — see "The tally is passive" below. Runs every ~2 hours (systemd timer, `RandomizedDelaySec=1800` gives the "+/-30 min in case something else is running" jitter) and does three things, in order: 1. **Trash-day-eve**: reads the same personal collection-date ICS feed `trash-calendar` already reads (`WASTE_ICS_URL`, own env file, own read — this script never writes to the calendar, `trash-calendar` owns that). If pickup is tomorrow and no open "trash" chore exists yet, creates one. 2. **Camera checks** (optional, off unless `CAMERA_WATCHPOINTS` is configured — no camera hardware has been chosen yet, see docs/project-plan.md §1.18): for each configured watch point (a Frigate camera + optional PTZ preset), grabs a snapshot via Frigate's own API and asks an Ollama vision model whether it shows a full bin / dirty dishes. A "needs attention" result opens a chore if one isn't already open; a "clear" result auto-closes one if it was. 3. **Nudging**: for every open chore, ASAP, not "wait for a schedule" — the first run after a chore opens nudges whoever `identity` reports home right now (using `identity`'s own room field to prefer someone actually near the relevant spot, e.g. the kitchen for dishes, when that data is available). If the chore is still open `NEGLECT_THRESHOLD_HOURS` after the last nudge, the household calendar is checked for anyone currently marked busy (a lightweight, honest- about-its-limits check — see `_household_currently_busy()`), and if nobody's in a flagged busy window, the next available person — **preferring someone different from who was last nudged**, "the next person that walks by" — gets nudged instead. There is no fixed "assignee" who owns a chore; `last_nudged` just remembers who to avoid re-nagging immediately and who to give tally credit to if the chore resolves shortly after. `identity`'s `chore_exempt` flag (set via `POST /people//chore-settings`, see identity/README.md) takes a person out of the nudge rotation entirely — a frequent guest who isn't a household member doesn't owe chores. **`litter` is the one exception** (`_EXEMPTIONS_DONT_APPLY` below): everyone, exempt or not, still gets told to put trash they left out into the bin — that isn't "doing a chore," it's cleaning up after yourself. `identity`'s `chore_reminder_style` free-text field (same endpoint) is passed to an LLM that **phrases** the ntfy message in that person's preferred tone ("be assertive," "be gentle, give me a few minutes of grace") — see `_compose_message()`. This is wording only. It never touches who gets nudged or when, which stays presence/calendar-driven per the principle above; if `OLLAMA_TEXT_MODEL` is unset, the style is empty, or the call fails for any reason, the plain deterministic template is used verbatim — same "never build against a guess" fallback discipline as the vision checks above. ## The tally is passive `chore_events`' `nudged` rows are the only record kept of who got asked about what. **Nothing in the nudge logic above reads this tally to decide who to ask next** — that decision is presence/calendar-driven only, exactly per the stated principle. The tally 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). Attribution when a chore closes is a **best-effort heuristic** (whoever was nudged most recently before it resolved) — nobody here actually confirms who did it, see "What's not built." NEVER auto-completes a chore from the nudge step itself — only a fresh camera check (finding the watch point clear) or a manual close (not built here, see README.md) ever marks one done. A nudge firing is not the same as the chore being done; conflating the two would let a repeatedly-nudged, never-actually-done chore quietly disappear. """ from __future__ import annotations import json import logging import os import sqlite3 import sys import time import urllib.request from datetime import date, datetime, timedelta, timezone from pathlib import Path LOG = logging.getLogger("chores") DB_PATH = Path(os.environ.get("CHORES_DB_PATH", "/data/chores.db")) IDENTITY_URL = os.environ.get("IDENTITY_URL", "").rstrip("/") IDENTITY_TOKEN = os.environ.get("IDENTITY_TOKEN", "") FRIGATE_URL = os.environ.get("FRIGATE_URL", "").rstrip("/") OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://llm-host:11434").rstrip("/") OLLAMA_VISION_MODEL = os.environ.get("OLLAMA_VISION_MODEL", "llava") # Optional — phrases (never decides) reminders per-person, see _compose_message(). # Unset means every reminder uses the plain template, same as before this existed. OLLAMA_TEXT_MODEL = os.environ.get("OLLAMA_TEXT_MODEL", "").strip() NTFY_URL = os.environ.get("NTFY_URL", "").rstrip("/") NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "") NEGLECT_THRESHOLD_HOURS = float(os.environ.get("NEGLECT_THRESHOLD_HOURS", "4")) WASTE_ICS_URL = os.environ.get("WASTE_ICS_URL", "").strip() # Household calendar busy-check (see _household_currently_busy()) — reuses # digest-engine's own CALDAV_* credential names for the same "one Nextcloud app # password" reasoning as trash-calendar, but this is a THIRD, independent read of # it (never a write) — a lightweight, honestly-scoped check, not per-person # availability, see README.md's limitation note. CALDAV_URL = os.environ.get("CALDAV_URL", "").strip() CALDAV_USERNAME = os.environ.get("CALDAV_USERNAME", "").strip() CALDAV_PASSWORD = os.environ.get("CALDAV_PASSWORD", "") CALDAV_VERIFY_TLS = os.environ.get("CALDAV_VERIFY_TLS", "true").strip().lower() == "true" CALDAV_QUIET_KEYWORDS = [ k.strip().lower() for k in os.environ.get("CALDAV_QUIET_KEYWORDS", "busy,meeting,call,movie,sleep").split(",") if k.strip() ] _CHORE_PROMPTS = { "trash": None, # never camera-checked — trash-day-eve driven only, see module docstring "bin_full": "Look at this photo of a trash/recycling bin. Answer with exactly one word: FULL, PARTIAL, or EMPTY.", "dishes": "Look at this photo of a kitchen sink/counter area. Answer with exactly one word: DIRTY or CLEAN.", "litter": ( "Look at this photo of a household area. Is there any trash/garbage/litter left out " "that does not belong there (not properly disposed of in a bin)? Answer with exactly " "one word: YES or NO." ), } # Which watch points get "who was just seen here" culprit-attribution treatment # (see _likely_culprit()) instead of the general "whoever's around" nudge — litter # is specifically about telling whoever left it, not just whoever's nearby now. _ATTRIBUTE_TO_RECENT_VIEWER = {"litter"} # Chore types where identity's chore_exempt flag does NOT apply — everyone still # gets told to clean up litter they left out, exempt household member or not (see # module docstring). Currently the same set as _ATTRIBUTE_TO_RECENT_VIEWER, but # they mean different things — one is about attribution, this is about eligibility # — so they're kept as separate names rather than reusing one for both purposes. _EXEMPTIONS_DONT_APPLY = {"litter"} def _now() -> str: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") def _db() -> sqlite3.Connection: DB_PATH.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def init_db(conn: sqlite3.Connection) -> None: conn.executescript( """ CREATE TABLE IF NOT EXISTS chores ( id INTEGER PRIMARY KEY, type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', assigned_to TEXT, created_at TEXT NOT NULL, assigned_at TEXT, done_at TEXT, reminder_count INTEGER NOT NULL DEFAULT 0, last_reminder_at TEXT ); CREATE TABLE IF NOT EXISTS chore_events ( id INTEGER PRIMARY KEY, chore_id INTEGER, event TEXT NOT NULL, detail TEXT, created_at TEXT NOT NULL ); """ ) def _log_event(conn, chore_id, event, detail="") -> None: conn.execute( "INSERT INTO chore_events (chore_id, event, detail, created_at) VALUES (?, ?, ?, ?)", (chore_id, event, detail, _now()), ) def _open_chore(conn, chore_type: str): return conn.execute("SELECT * FROM chores WHERE type = ? AND status = 'open'", (chore_type,)).fetchone() def _create_chore(conn, chore_type: str) -> int: cur = conn.execute( "INSERT INTO chores (type, status, created_at) VALUES (?, 'open', ?)", (chore_type, _now()) ) assert cur.lastrowid is not None _log_event(conn, cur.lastrowid, "created") LOG.info("chores: created a new %r chore", chore_type) return cur.lastrowid def _close_chore(conn, chore_row) -> None: conn.execute("UPDATE chores SET status = 'done', done_at = ? WHERE id = ?", (_now(), chore_row["id"])) _log_event(conn, chore_row["id"], "auto_closed") LOG.info("chores: %r chore #%d auto-closed (camera check came back clear)", chore_row["type"], chore_row["id"]) # --- 1. Trash-day-eve ------------------------------------------------------------ def check_trash_day(conn) -> None: if not WASTE_ICS_URL: return from icalendar import Calendar as ICalendar try: req = urllib.request.Request(WASTE_ICS_URL, headers={"User-Agent": "smartesthome-chores/1"}) with urllib.request.urlopen(req, timeout=30) as resp: raw = resp.read() tomorrow = date.today() + timedelta(days=1) pickup_tomorrow = False for component in ICalendar.from_ical(raw).walk("VEVENT"): dtstart = component.get("dtstart") if dtstart is None: continue start = dtstart.dt start_date = start.date() if isinstance(start, datetime) else start if start_date == tomorrow: pickup_tomorrow = True break except Exception: LOG.warning("chores: could not check WASTE_ICS_URL for trash-day-eve", exc_info=True) return if pickup_tomorrow and _open_chore(conn, "trash") is None: _create_chore(conn, "trash") # --- 2. Camera checks -------------------------------------------------------------- def _watchpoints() -> list[tuple[str, str, str | None]]: """CAMERA_WATCHPOINTS format: "type:camera[:preset],type:camera[:preset],...". type must be a key in _CHORE_PROMPTS other than "trash" (bin_full, dishes). """ raw = os.environ.get("CAMERA_WATCHPOINTS", "").strip() if not raw: return [] points = [] for entry in raw.split(","): parts = [p.strip() for p in entry.split(":")] if len(parts) < 2 or parts[0] not in _CHORE_PROMPTS or parts[0] == "trash": LOG.warning("chores: ignoring malformed CAMERA_WATCHPOINTS entry %r", entry) continue points.append((parts[0], parts[1], parts[2] if len(parts) > 2 else None)) return points def _frigate_snapshot(camera: str, preset: str | None) -> bytes | None: # VERIFY: Frigate's PTZ-move-to-preset API shape is assumed, not confirmed # against a real Frigate PTZ camera — see README.md. A failure here just means # the snapshot is taken from wherever the camera already was, not a hard error. if preset: try: move_req = urllib.request.Request( f"{FRIGATE_URL}/api/{camera}/ptz/move/{preset}", method="POST" ) urllib.request.urlopen(move_req, timeout=10).close() time.sleep(3) # give the camera time to physically move before snapshotting except Exception: LOG.warning("chores: could not move camera %r to preset %r (continuing anyway)", camera, preset, exc_info=True) try: with urllib.request.urlopen(f"{FRIGATE_URL}/api/{camera}/latest.jpg", timeout=15) as resp: return resp.read() except Exception: LOG.warning("chores: could not fetch snapshot for camera %r", camera, exc_info=True) return None def _ask_vision(image: bytes, prompt: str) -> str | None: import base64 payload = { "model": OLLAMA_VISION_MODEL, "prompt": prompt, "images": [base64.b64encode(image).decode("ascii")], "stream": False, "options": {"temperature": 0.1}, } try: req = urllib.request.Request( f"{OLLAMA_HOST}/api/generate", data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"} ) with urllib.request.urlopen(req, timeout=90) as resp: result = json.loads(resp.read()) return (result.get("response") or "").strip().upper() except Exception: LOG.warning("chores: Ollama vision call failed", exc_info=True) return None def check_cameras(conn) -> None: for chore_type, camera, preset in _watchpoints(): if not FRIGATE_URL: LOG.warning("chores: CAMERA_WATCHPOINTS configured but FRIGATE_URL is unset, skipping") return image = _frigate_snapshot(camera, preset) if image is None: continue answer = _ask_vision(image, _CHORE_PROMPTS[chore_type]) if answer is None: continue needs_attention = ("FULL" in answer and "PARTIAL" not in answer) or "DIRTY" in answer or ( chore_type == "litter" and "YES" in answer ) existing = _open_chore(conn, chore_type) if needs_attention and existing is None: _create_chore(conn, chore_type) elif not needs_attention and existing is not None: _close_chore(conn, existing) # --- 3. Nudging — presence/calendar-driven, "whoever's around," see module docstring def _presence() -> list[dict]: """Each dict: {"name", "home", "room", "face_seen_recently", "chore_exempt", "chore_reminder_style"} — the raw shape identity's own /presence returns. Empty list (never raises past this point) if identity is unreachable — nudging just waits for the next run. """ if not IDENTITY_URL or not IDENTITY_TOKEN: return [] try: req = urllib.request.Request(f"{IDENTITY_URL}/presence") req.add_header("Authorization", f"Bearer {IDENTITY_TOKEN}") with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read()) return [p for p in data.get("people", []) if p.get("home")] except Exception: LOG.warning("chores: could not reach identity for /presence", exc_info=True) return [] def _household_currently_busy() -> bool: """A deliberately lightweight check, not per-person availability: is there a CURRENTLY ACTIVE event on the one shared household calendar whose summary contains one of CALDAV_QUIET_KEYWORDS? If so, nudges wait for the next run rather than interrupting a meeting/movie/whoever's asleep. This is a real limitation, not a bug: it can't tell that only ONE person is busy and nudge someone else anyway — everyone's nudges pause together. See README.md. Fails open (returns False, i.e. "not busy") on any error — a broken calendar check must never be the reason chores stop getting nudged at all. """ if not (CALDAV_URL and CALDAV_USERNAME and CALDAV_PASSWORD) or not CALDAV_QUIET_KEYWORDS: return False try: import caldav as caldav_lib now = datetime.now(timezone.utc) with caldav_lib.DAVClient( url=CALDAV_URL, username=CALDAV_USERNAME, password=CALDAV_PASSWORD, ssl_verify_cert=CALDAV_VERIFY_TLS ) as client: for calendar in client.principal().calendars(): for item in calendar.search(start=now, end=now + timedelta(minutes=1), event=True, expand=True): summary = str(item.icalendar_component.get("summary", "")).lower() if any(keyword in summary for keyword in CALDAV_QUIET_KEYWORDS): return True except Exception: LOG.warning("chores: calendar busy-check failed, proceeding as not-busy", exc_info=True) return False return False def _likely_culprit(candidates: list[dict]) -> dict | None: """For litter-type chores: prefer whoever was MOST RECENTLY seen by camera face recognition — a best-effort "who was just here" guess, not a certainty. Falls back to None (caller then treats it like any other chore) if nobody's face_seen_recently. """ seen = [p for p in candidates if p.get("face_seen_recently")] return seen[0] if seen else None def nudge_open_chores(conn) -> None: open_chores = conn.execute("SELECT * FROM chores WHERE status = 'open'").fetchall() if not open_chores: return home = _presence() if not home: LOG.info("chores: %d open chore(s), but nobody is home yet — nothing to do", len(open_chores)) return if _household_currently_busy(): LOG.info("chores: household calendar shows a busy/quiet window right now — skipping nudges this run") return for chore in open_chores: last_nudged = chore["assigned_to"] last_nudged_at = chore["assigned_at"] if last_nudged_at is not None: elapsed_hours = (datetime.now(timezone.utc) - datetime.fromisoformat(last_nudged_at.replace("Z", "+00:00"))).total_seconds() / 3600 if elapsed_hours < NEGLECT_THRESHOLD_HOURS: continue # not neglected yet — leave whoever was last nudged alone for now # chore_exempt people are out of the rotation entirely, EXCEPT litter — see # _EXEMPTIONS_DONT_APPLY's module-level comment. eligible = home if chore["type"] in _EXEMPTIONS_DONT_APPLY else [p for p in home if not p.get("chore_exempt")] if not eligible: LOG.info( "chores: %r chore #%d open, but everyone home right now is chore_exempt — skipping", chore["type"], chore["id"], ) continue # "The next person that walks by": prefer someone home right now who ISN'T # who we last nudged (a real redirect, not the same person nagged again) — # falls back to re-nudging the same person if they're genuinely the only # one home. Litter gets a different preference first: whoever the camera # most recently saw, since the point there is telling the actual culprit. target = None if chore["type"] in _ATTRIBUTE_TO_RECENT_VIEWER: target = _likely_culprit(eligible) if target is None: different = [p for p in eligible if p["name"] != last_nudged] target = (different or eligible)[0] name = target["name"] conn.execute( "UPDATE chores SET assigned_to = ?, assigned_at = ?, reminder_count = reminder_count + 1, " "last_reminder_at = ? WHERE id = ?", (name, _now(), _now(), chore["id"]), ) redirected = last_nudged is not None and last_nudged != name _log_event(conn, chore["id"], "nudged", name) redirect_note = f" (redirected from {last_nudged})" if redirected and last_nudged else "" LOG.info("chores: nudged %s about %r chore #%d%s", name, chore["type"], chore["id"], redirect_note) _notify( name, chore["type"], redirected, is_culprit=chore["type"] in _ATTRIBUTE_TO_RECENT_VIEWER, reminder_style=target.get("chore_reminder_style"), ) def _default_message(name: str, chore_type: str, redirected: bool, is_culprit: bool) -> str: if is_culprit: return f"{name}, looks like something was left out — could you put it in the bin?" if redirected: return f"{name}, this one's still open — could you take care of: {chore_type}?" return f"{name}, could you take care of: {chore_type}?" def _compose_message(name: str, chore_type: str, redirected: bool, is_culprit: bool, reminder_style: str | None) -> str: """Wording only — see module docstring's OLLAMA_TEXT_MODEL paragraph. Falls back to _default_message() verbatim whenever there's no model configured, no style set for this person, or the call fails/returns nothing — never blocks a nudge from going out over an LLM hiccup. """ fallback = _default_message(name, chore_type, redirected, is_culprit) if not OLLAMA_TEXT_MODEL or not reminder_style: return fallback prompt = ( f"Write ONE short household chore reminder (max 2 sentences) addressed to {name}. " f"The chore is: {chore_type}. " f"Follow {name}'s own stated preference for how they like to be reminded: \"{reminder_style}\". " + ("Something was left out and may belong to them specifically — ask them to put it away, don't accuse them outright. " if is_culprit else "") + ("They were already asked about this once before and it's still not done. " if redirected else "") + "Reply with ONLY the message text itself — no preamble, no quotation marks." ) try: payload = {"model": OLLAMA_TEXT_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.4}} req = urllib.request.Request( f"{OLLAMA_HOST}/api/generate", data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"} ) with urllib.request.urlopen(req, timeout=30) as resp: result = json.loads(resp.read()) text = (result.get("response") or "").strip() return text if text else fallback except Exception: LOG.warning("chores: Ollama message-phrasing call failed, using plain template", exc_info=True) return fallback def _notify(name: str, chore_type: str, redirected: bool, is_culprit: bool, reminder_style: str | None = None) -> None: if not NTFY_URL or not NTFY_TOPIC: return message = _compose_message(name, chore_type, redirected, is_culprit, reminder_style) try: req = urllib.request.Request(f"{NTFY_URL}/{NTFY_TOPIC}", data=message.encode("utf-8"), method="POST") urllib.request.urlopen(req, timeout=10).close() except Exception: LOG.warning("chores: ntfy notification failed", exc_info=True) def print_tally(conn) -> None: """Read-only reporting, per the module docstring — never feeds back into nudge_open_chores()'s choice of who to nudge next. Logged, not served anywhere yet — see README.md's "What's not built." """ rows = conn.execute( "SELECT detail AS name, COUNT(*) AS n FROM chore_events " "WHERE event = 'nudged' AND created_at >= ? GROUP BY detail ORDER BY n DESC", ((datetime.now(timezone.utc) - timedelta(days=30)).isoformat(),), ).fetchall() if rows: LOG.info("chores: 30-day nudge tally (for comparison only, not used to pick who's next): %s", ", ".join(f"{r['name']}: {r['n']}" for r in rows)) def main() -> int: logging.basicConfig( level=os.environ.get("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", ) conn = _db() try: init_db(conn) check_trash_day(conn) check_cameras(conn) conn.commit() nudge_open_chores(conn) conn.commit() print_tally(conn) finally: conn.close() return 0 if __name__ == "__main__": sys.exit(main())