SmartestHome/chores/check.py

532 lines
24 KiB
Python

"""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/<id>/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())