From d4bb4d75b1aa6da84b0aaf0a805ee74f00711ec1 Mon Sep 17 00:00:00 2001 From: Adrian Wedd Date: Sat, 11 Jul 2026 16:11:29 +1000 Subject: [PATCH 1/7] test(adhdo2): harden schedule/_parse_at and quiet-hours boundaries (#114) - _parse_at: accept ISO-8601 datetime, date-only (midnight), and HH:MM[:SS] time-of-day; invalid input now raises ToolError('usage') instead of an unhandled ValueError (previously '09:30:00' crashed). - Add _parse_at tests covering ISO full/space/date-only, HH:MM rollover, HH:MM:SS, unpadded hour, and invalid inputs. - Add quiet-hours boundary tests (exact start, exact end, wrap-around midnight, non-wrap window, equal start==end) pinning half-open [start, end) semantics; no off-by-one bugs found in nudgelimits. Claude-Session: https://claude.ai/code/session_01Tbd7qVjznvPwwYaWmUnWHP --- adhdo2/bin/wake | 23 +++++++++---- adhdo2/tests/test_nudgelimits.py | 47 ++++++++++++++++++++++++++ adhdo2/tests/test_schedule.py | 58 ++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 7 deletions(-) diff --git a/adhdo2/bin/wake b/adhdo2/bin/wake index 4773e47..eca83ac 100755 --- a/adhdo2/bin/wake +++ b/adhdo2/bin/wake @@ -39,13 +39,22 @@ def inject(text: str) -> str: return "sent" def _parse_at(s: str) -> float: - if ":" in s and len(s) <= 5: - h, m = map(int, s.split(":")) - due = datetime.now().replace(hour=h, minute=m, second=0, microsecond=0) - if due.timestamp() <= time.time(): - due = due + timedelta(days=1) - return due.timestamp() - return datetime.fromisoformat(s).timestamp() + try: + if ":" in s and "-" not in s and "T" not in s and " " not in s: + # time-of-day: HH:MM or HH:MM:SS (next occurrence, today or tomorrow) + parts = s.split(":") + if len(parts) not in (2, 3): + raise ValueError(s) + h, m = int(parts[0]), int(parts[1]) + sec = int(parts[2]) if len(parts) == 3 else 0 + due = datetime.now().replace(hour=h, minute=m, second=sec, microsecond=0) + if due.timestamp() <= time.time(): + due = due + timedelta(days=1) + return due.timestamp() + # ISO-8601 datetime or date (date-only means midnight) + return datetime.fromisoformat(s).timestamp() + except ValueError: + raise ToolError("usage", f"invalid --at value: {s!r} (use HH:MM[:SS] or ISO-8601)") def run(argv, _test_conn=None): ap = ToolArgumentParser(prog="wake") diff --git a/adhdo2/tests/test_nudgelimits.py b/adhdo2/tests/test_nudgelimits.py index 0e78a79..06abe26 100644 --- a/adhdo2/tests/test_nudgelimits.py +++ b/adhdo2/tests/test_nudgelimits.py @@ -23,3 +23,50 @@ def test_high_urgency_bypasses_quiet_hours_only(): def test_high_urgency_requires_enumerated_event(): assert check_allowed(NIGHT, "high", "party", 0, DEFAULTS) == (False, "bad_override") assert check_allowed(NIGHT, "high", None, 0, DEFAULTS) == (False, "bad_override") + +# --- quiet-hours boundary semantics: half-open [start, end) ---------------- + +def _quiet(hh, mm, window): + from adhdolib.nudgelimits import _in_quiet_hours + return _in_quiet_hours(datetime(2026, 7, 10, hh, mm), window) + +WRAP = ["22:00", "07:00"] # wraps midnight (the default) +DAYTIME = ["13:00", "14:00"] # non-wrapping window + +def test_wrap_exactly_at_start_is_quiet(): + assert _quiet(22, 0, WRAP) is True + +def test_wrap_just_before_start_is_not_quiet(): + assert _quiet(21, 59, WRAP) is False + +def test_wrap_exactly_at_end_is_not_quiet(): + assert _quiet(7, 0, WRAP) is False + +def test_wrap_just_before_end_is_quiet(): + assert _quiet(6, 59, WRAP) is True + +def test_wrap_midnight_is_quiet(): + assert _quiet(0, 0, WRAP) is True + +def test_nonwrap_exactly_at_start_is_quiet(): + assert _quiet(13, 0, DAYTIME) is True + +def test_nonwrap_exactly_at_end_is_not_quiet(): + assert _quiet(14, 0, DAYTIME) is False + +def test_nonwrap_inside_and_outside(): + assert _quiet(13, 30, DAYTIME) is True + assert _quiet(12, 59, DAYTIME) is False + assert _quiet(14, 1, DAYTIME) is False + +def test_equal_start_end_window_is_never_quiet(): + assert _quiet(3, 0, ["07:00", "07:00"]) is False + assert _quiet(7, 0, ["07:00", "07:00"]) is False + +def test_check_allowed_at_quiet_start_boundary_blocks_low(): + at_start = datetime(2026, 7, 10, 22, 0) + assert check_allowed(at_start, "low", None, 0, DEFAULTS) == (False, "quiet_hours") + +def test_check_allowed_at_quiet_end_boundary_allows_low(): + at_end = datetime(2026, 7, 10, 7, 0) + assert check_allowed(at_end, "low", None, 0, DEFAULTS) == (True, "ok") diff --git a/adhdo2/tests/test_schedule.py b/adhdo2/tests/test_schedule.py index 768ffa6..d587a6e 100644 --- a/adhdo2/tests/test_schedule.py +++ b/adhdo2/tests/test_schedule.py @@ -1,5 +1,11 @@ import time +from datetime import datetime, timedelta + +import pytest + from adhdolib import db, schedule +from adhdolib.binload import load_bin_module +from adhdolib.envelope import ToolError def test_add_dedup_and_due(adhdo_home): conn = db.connect() @@ -24,3 +30,55 @@ def test_consolidate_none_when_recent(adhdo_home): msg = schedule.consolidate_missed(conn, [{"tag": "meds", "time": "08:00"}], last_wake_ts=now - 60, now=now) assert msg is None + +# --- _parse_at (bin/wake) ------------------------------------------------- + +@pytest.fixture(scope="module") +def parse_at(): + return load_bin_module("wake")._parse_at + + +def test_parse_at_full_iso_datetime(parse_at): + assert parse_at("2026-07-12T09:30:00") == datetime(2026, 7, 12, 9, 30).timestamp() + + +def test_parse_at_iso_datetime_with_space(parse_at): + assert parse_at("2026-07-12 09:30:00") == datetime(2026, 7, 12, 9, 30).timestamp() + + +def test_parse_at_iso_date_only_is_midnight(parse_at): + assert parse_at("2026-07-12") == datetime(2026, 7, 12).timestamp() + + +def test_parse_at_hh_mm_future_today(parse_at): + soon = (datetime.now() + timedelta(minutes=5)).replace(second=0, microsecond=0) + got = parse_at(soon.strftime("%H:%M")) + assert got == soon.timestamp() + + +def test_parse_at_hh_mm_past_rolls_to_tomorrow(parse_at): + past = (datetime.now() - timedelta(minutes=5)).replace(second=0, microsecond=0) + got = parse_at(past.strftime("%H:%M")) + assert got == (past + timedelta(days=1)).timestamp() + assert got > time.time() + + +def test_parse_at_iso_time_with_seconds(parse_at): + soon = (datetime.now() + timedelta(minutes=5)).replace(microsecond=0) + got = parse_at(soon.strftime("%H:%M:%S")) + assert got == soon.timestamp() + + +def test_parse_at_unpadded_hour(parse_at): + # "9:30" style (not strict ISO) must keep working + soon = (datetime.now() + timedelta(minutes=5)).replace(second=0, microsecond=0) + got = parse_at("%d:%02d" % (soon.hour, soon.minute)) + expected = soon if soon.timestamp() > time.time() else soon + timedelta(days=1) + assert got == expected.timestamp() + + +@pytest.mark.parametrize("bad", ["nonsense", "25:00", "12:60", "2026-13-01", ":", "12:", "1:2:3:4"]) +def test_parse_at_invalid_raises_usage_error(parse_at, bad): + with pytest.raises(ToolError) as exc: + parse_at(bad) + assert exc.value.code == "usage" From dc161d9525236c131d2f86ba61af42f94556de8e Mon Sep 17 00:00:00 2001 From: Adrian Wedd Date: Sat, 11 Jul 2026 16:11:40 +1000 Subject: [PATCH 2/7] =?UTF-8?q?fix(adhdo2):=20cast=20symmetry=20=E2=80=94?= =?UTF-8?q?=20journal=20stop=20events,=20uniform=20no=5Fdevices=20errors,?= =?UTF-8?q?=20logical=20device=20names,=20jellyfin=20fallthrough=20tests?= =?UTF-8?q?=20(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cast stop now journals a cast event ({action: stop, device}) like play - device-not-found is now the same ToolError('no_devices') shape across play/stop/volume via shared resolve_device/device_not_found helpers; stop no longer silently ignores a missing device - output and errors report the logical (configured) device name, with the real cast name as cast_name; status rows carry both device+name - volume arg validation returns a usage envelope instead of crashing - tests: stop journaling, symmetric not-found across subcommands, jellyfin fallthrough (unreachable + empty playlist + happy path), logical-name reporting Claude-Session: https://claude.ai/code/session_01Tbd7qVjznvPwwYaWmUnWHP --- adhdo2/bin/cast | 66 ++++++++++----- adhdo2/tests/test_cast.py | 171 +++++++++++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 24 deletions(-) diff --git a/adhdo2/bin/cast b/adhdo2/bin/cast index 393023c..0a9f392 100755 --- a/adhdo2/bin/cast +++ b/adhdo2/bin/cast @@ -32,12 +32,26 @@ def resolve_mood(mood_or_url: str, cfg: dict): return random.choice(streams), "stream" raise ToolError("unknown_mood", f"mood '{mood_or_url}' has no playable source") -def play_on_device(url: str, device_name: str): +def device_not_found(device: str, real: str) -> ToolError: + """Uniform device-not-found error across all cast subcommands. + Reports the logical (configured) name, plus the real cast name if + they differ.""" + detail = (f"device '{device}' ('{real}') not found" + if real != device else f"device '{device}' not found") + return ToolError("no_devices", detail) + +def resolve_device(requested, cfg): + """Map a logical device name (or None for the default) to its real + discovered/cast name via the config alias table.""" + device = requested or cfg["default_device"] + return device, cfg["devices"].get(device, device) + +def play_on_device(url: str, device: str, real: str): import pychromecast - casts, browser = pychromecast.get_listed_chromecasts(friendly_names=[device_name]) + casts, browser = pychromecast.get_listed_chromecasts(friendly_names=[real]) try: if not casts: - raise ToolError("no_devices", f"{device_name} not found") + raise device_not_found(device, real) cc = casts[0]; cc.wait() cc.media_controller.play_media(url, "audio/mpeg") cc.media_controller.block_until_active(timeout=15) @@ -46,7 +60,8 @@ def play_on_device(url: str, device_name: str): def device_status(cfg): import pychromecast - names = list(cfg["devices"].values()) + aliases = cfg["devices"] # logical name -> real cast name + names = list(aliases.values()) casts, browser = pychromecast.get_listed_chromecasts(friendly_names=names) try: now_playing = {} @@ -57,9 +72,9 @@ def device_status(cfg): now_playing[cc.name] = s.title if s and s.player_is_playing else None except Exception: pass # this device errored; treat as offline below - return [{"name": n, "online": n in now_playing, - "now_playing": now_playing.get(n)} - for n in names] + return [{"device": logical, "name": real, "online": real in now_playing, + "now_playing": now_playing.get(real)} + for logical, real in aliases.items()] finally: browser.stop_discovery() @@ -77,44 +92,51 @@ def run(argv, _test_conn=None, _test_cfg=None): if not args.arg: raise ToolError("usage", "cast play ") url, source = resolve_mood(args.arg, cfg) - device = args.device or cfg["default_device"] - real = cfg["devices"].get(device, device) - play_on_device(url, real) + device, real = resolve_device(args.device, cfg) + play_on_device(url, device, real) np_path.parent.mkdir(parents=True, exist_ok=True) write_atomic(np_path, json.dumps( {"url": url, "device": real, "mood": args.arg, "started_ts": time.time()})) os.chmod(np_path, 0o600) db.log_event(conn, "cast", "cast", - json.dumps({"mood": args.arg, "device": device})) - out = {"playing": scrub(url), "device": device, "source": source} + json.dumps({"action": "play", "mood": args.arg, + "device": device})) + out = {"playing": scrub(url), "device": device, + "cast_name": real, "source": source} elif args.cmd == "stop": - device = args.device or cfg["default_device"] - real = cfg["devices"].get(device, device) + device, real = resolve_device(args.device, cfg) import pychromecast casts, browser = pychromecast.get_listed_chromecasts(friendly_names=[real]) try: - if casts: - casts[0].wait(); casts[0].media_controller.stop() + if not casts: + raise device_not_found(device, real) + casts[0].wait(); casts[0].media_controller.stop() finally: browser.stop_discovery() np_path.unlink(missing_ok=True) - out = {"stopped": True} + db.log_event(conn, "cast", "cast", + json.dumps({"action": "stop", "device": device})) + out = {"stopped": True, "device": device, "cast_name": real} elif args.cmd == "volume": - level = float(args.arg) + if args.arg is None: + raise ToolError("usage", "cast volume <0..1>") + try: + level = float(args.arg) + except ValueError: + raise ToolError("usage", "volume must be 0..1") if not 0 <= level <= 1: raise ToolError("usage", "volume must be 0..1") - device = args.device or cfg["default_device"] - real = cfg["devices"].get(device, device) + device, real = resolve_device(args.device, cfg) import pychromecast casts, browser = pychromecast.get_listed_chromecasts(friendly_names=[real]) try: if not casts: - raise ToolError("no_devices", f"{real} not found") + raise device_not_found(device, real) casts[0].wait(); casts[0].set_volume(level) finally: browser.stop_discovery() - out = {"volume": level} + out = {"volume": level, "device": device, "cast_name": real} else: out = {"devices": device_status(cfg)} db.audit(conn, "cast", argv, True, "") diff --git a/adhdo2/tests/test_cast.py b/adhdo2/tests/test_cast.py index 97164c9..bfadcb6 100644 --- a/adhdo2/tests/test_cast.py +++ b/adhdo2/tests/test_cast.py @@ -33,7 +33,7 @@ def test_raw_url_passthrough(): def test_play_writes_now_playing(adhdo_home, monkeypatch): cast = load_cast() - monkeypatch.setattr(cast, "play_on_device", lambda url, dev: None) + monkeypatch.setattr(cast, "play_on_device", lambda url, dev, real: None) from adhdolib import db out = cast.run(["play", "http://x/y.mp3", "--device", "office"], _test_conn=db.connect(), @@ -44,7 +44,7 @@ def test_play_writes_now_playing(adhdo_home, monkeypatch): def test_play_result_redacts_api_key(adhdo_home, monkeypatch): cast = load_cast() - monkeypatch.setattr(cast, "play_on_device", lambda url, dev: None) + monkeypatch.setattr(cast, "play_on_device", lambda url, dev, real: None) from adhdolib import db out = cast.run(["play", "http://x/y.mp3?api_key=SECRET123", "--device", "office"], _test_conn=db.connect(), @@ -55,6 +55,173 @@ def test_play_result_redacts_api_key(adhdo_home, monkeypatch): np = json.loads((adhdo_home / "data" / "now_playing.json").read_text()) assert "SECRET123" in np["url"] # real URL kept for resume +class _FakeBrowser: + def __init__(self, log): + self._log = log + def stop_discovery(self): + self._log.append(True) + +def _fake_pychromecast(casts, stopped): + class FakePychromecast: + @staticmethod + def get_listed_chromecasts(friendly_names=None): + return list(casts), _FakeBrowser(stopped) + return FakePychromecast() + +class _FakeMC: + def __init__(self): + self.stopped = False + self.status = None + def stop(self): + self.stopped = True + +class _FakeCast: + def __init__(self, name="Nest Hub Max"): + self.name = name + self.media_controller = _FakeMC() + self.volume = None + def wait(self, timeout=None): + pass + def set_volume(self, level): + self.volume = level + +CFG_DEV = {**CFG, "devices": {"office": "Nest Hub Max"}, "default_device": "office"} + +def _events(conn): + return [json.loads(r[0]) for r in + conn.execute("SELECT payload_json FROM events WHERE type='cast'")] + +def test_stop_journals_event(adhdo_home, monkeypatch): + cast = load_cast() + import sys + stopped = [] + cc = _FakeCast() + monkeypatch.setitem(sys.modules, "pychromecast", + _fake_pychromecast([cc], stopped)) + from adhdolib import db + conn = db.connect() + out = cast.run(["stop"], _test_conn=conn, _test_cfg=CFG_DEV) + assert out == {"stopped": True, "device": "office", + "cast_name": "Nest Hub Max"} + assert cc.media_controller.stopped + assert {"action": "stop", "device": "office"} in _events(conn) + assert stopped == [True] + +def test_play_journals_action(adhdo_home, monkeypatch): + cast = load_cast() + monkeypatch.setattr(cast, "play_on_device", lambda url, dev, real: None) + from adhdolib import db + conn = db.connect() + out = cast.run(["play", "http://x/y.mp3"], _test_conn=conn, _test_cfg=CFG_DEV) + assert out["device"] == "office" and out["cast_name"] == "Nest Hub Max" + assert {"action": "play", "mood": "http://x/y.mp3", + "device": "office"} in _events(conn) + +@pytest.mark.parametrize("argv", [["play", "http://x/y.mp3"], + ["stop"], + ["volume", "0.5"]]) +def test_device_not_found_symmetric(adhdo_home, monkeypatch, argv): + cast = load_cast() + import sys + stopped = [] + monkeypatch.setitem(sys.modules, "pychromecast", + _fake_pychromecast([], stopped)) + from adhdolib import db + with pytest.raises(ToolError) as e: + cast.run(argv, _test_conn=db.connect(), _test_cfg=CFG_DEV) + assert e.value.code == "no_devices" + assert e.value.detail == "device 'office' ('Nest Hub Max') not found" + assert stopped == [True] # discovery always cleaned up + +def test_device_not_found_unaliased_name(adhdo_home, monkeypatch): + cast = load_cast() + import sys + monkeypatch.setitem(sys.modules, "pychromecast", + _fake_pychromecast([], [])) + from adhdolib import db + with pytest.raises(ToolError) as e: + cast.run(["stop", "--device", "garage"], + _test_conn=db.connect(), _test_cfg=CFG_DEV) + assert e.value.code == "no_devices" + assert e.value.detail == "device 'garage' not found" + +def test_volume_reports_logical_device(adhdo_home, monkeypatch): + cast = load_cast() + import sys + cc = _FakeCast() + monkeypatch.setitem(sys.modules, "pychromecast", + _fake_pychromecast([cc], [])) + from adhdolib import db + out = cast.run(["volume", "0.3"], _test_conn=db.connect(), _test_cfg=CFG_DEV) + assert out == {"volume": 0.3, "device": "office", + "cast_name": "Nest Hub Max"} + assert cc.volume == 0.3 + +def test_volume_non_numeric_is_usage_error(adhdo_home, monkeypatch): + cast = load_cast() + from adhdolib import db + with pytest.raises(ToolError) as e: + cast.run(["volume", "loud"], _test_conn=db.connect(), _test_cfg=CFG_DEV) + assert e.value.code == "usage" + +def test_jellyfin_fallthrough_to_stream(monkeypatch): + """When Jellyfin is configured but unreachable, resolve_mood falls + through to the mood's stream list instead of erroring.""" + cast = load_cast() + import urllib.request + + def boom(*a, **k): + raise OSError("jellyfin down") + monkeypatch.setattr(urllib.request, "urlopen", boom) + cfg = {"moods": {"focus": {"jellyfin_playlist": "pl1", + "streams": ["http://stream.example/focus"]}}, + "jellyfin": {"url": "http://jf.local:8096", "api_key": "K"}} + url, source = cast.resolve_mood("focus", cfg) + assert url == "http://stream.example/focus" and source == "stream" + +def test_jellyfin_empty_playlist_falls_through(monkeypatch): + """Jellyfin reachable but playlist empty -> stream fallback too.""" + cast = load_cast() + import io, urllib.request + monkeypatch.setattr(urllib.request, "urlopen", + lambda *a, **k: io.BytesIO(b'{"Items": []}')) + cfg = {"moods": {"focus": {"jellyfin_playlist": "pl1", + "streams": ["http://stream.example/focus"]}}, + "jellyfin": {"url": "http://jf.local:8096", "api_key": "K"}} + url, source = cast.resolve_mood("focus", cfg) + assert url == "http://stream.example/focus" and source == "stream" + +def test_jellyfin_used_when_available(monkeypatch): + cast = load_cast() + import io, urllib.request + monkeypatch.setattr(urllib.request, "urlopen", + lambda *a, **k: io.BytesIO(b'{"Items": [{"Id": "abc"}]}')) + cfg = {"moods": {"focus": {"jellyfin_playlist": "pl1", + "streams": ["http://stream.example/focus"]}}, + "jellyfin": {"url": "http://jf.local:8096", "api_key": "K"}} + url, source = cast.resolve_mood("focus", cfg) + assert source == "jellyfin" + assert url.startswith("http://jf.local:8096/Audio/abc/universal") + +def test_status_reports_logical_and_real_names(monkeypatch): + cast = load_cast() + import sys + + class MC: + status = None + + class CC: + name = "Nest Hub Max" + media_controller = MC() + def wait(self, timeout=None): + pass + + monkeypatch.setitem(sys.modules, "pychromecast", + _fake_pychromecast([CC()], [])) + result = cast.device_status({"devices": {"office": "Nest Hub Max"}}) + assert result == [{"device": "office", "name": "Nest Hub Max", + "online": True, "now_playing": None}] + def test_device_status_isolates_offline_device(monkeypatch): cast = load_cast() From 7f20a007de3f616a7b8e7fae9db8ce6577642c77 Mon Sep 17 00:00:00 2001 From: Adrian Wedd Date: Sat, 11 Jul 2026 16:12:21 +1000 Subject: [PATCH 3/7] feat(adhdo2): P3 read-only LAN dashboard (state + journal) Separate service from adhdo-httpd: bin/adhdo-dashboard serves a single self-contained HTML page (inline CSS/JS, 30s auto-refresh) plus JSON APIs /api/state and /api/journal. GET-only (405 otherwise), output scrubbed via envelope.scrub, bind/port from config (dashboard.bind falls back to lan_ip, port 8766). Includes systemd unit, config example, contract tests, and E2E checklist entry for on-device verification. Claude-Session: https://claude.ai/code/session_01Tbd7qVjznvPwwYaWmUnWHP --- adhdo2/E2E_CHECKLIST.md | 10 ++ adhdo2/adhdolib/config.py | 1 + adhdo2/bin/adhdo-dashboard | 157 +++++++++++++++++++++++++ adhdo2/config.example.yaml | 3 + adhdo2/systemd/adhdo-dashboard.service | 11 ++ adhdo2/tests/test_dashboard.py | 94 +++++++++++++++ 6 files changed, 276 insertions(+) create mode 100755 adhdo2/bin/adhdo-dashboard create mode 100644 adhdo2/systemd/adhdo-dashboard.service create mode 100644 adhdo2/tests/test_dashboard.py diff --git a/adhdo2/E2E_CHECKLIST.md b/adhdo2/E2E_CHECKLIST.md index a0f2ce3..bdfc3e1 100644 --- a/adhdo2/E2E_CHECKLIST.md +++ b/adhdo2/E2E_CHECKLIST.md @@ -197,3 +197,13 @@ installed as a safety net per Step 1). `media_controller.status` until idle, max 30s) was NOT implemented — it requires audible hardware observation to tune correctly, which is a human step. +8. P3 dashboard (`bin/adhdo-dashboard`, port 8766): on-device verification — + `systemctl --user enable --now adhdo-dashboard` (unit in + `systemd/adhdo-dashboard.service`), then open + `http://:8766/` from another LAN machine and confirm the page + shows live state (devices, disk, last-event ages) and recent journal rows, + auto-refreshing every 30s. Confirm `curl -X POST http://:8766/` + returns 405 (read-only) and that the service is NOT reachable from outside + the LAN (binds to `dashboard.bind`, defaulting to `lan_ip`). Contract + tests (routing, JSON schemas, secret scrubbing, method rejection) are + covered in `tests/test_dashboard.py`. diff --git a/adhdo2/adhdolib/config.py b/adhdo2/adhdolib/config.py index aed9374..0955697 100644 --- a/adhdo2/adhdolib/config.py +++ b/adhdo2/adhdolib/config.py @@ -15,6 +15,7 @@ "high_urgency_events": ["medication", "safety", "user_requested"], }, "disk_warn_pct": 94, + "dashboard": {"bind": None, "port": 8766}, "crisis": {"contacts": ["Lifeline Australia 13 11 14"]}, "telegram": {"chat_id_allowlist": []}, } diff --git a/adhdo2/bin/adhdo-dashboard b/adhdo2/bin/adhdo-dashboard new file mode 100755 index 0000000..2d9da8f --- /dev/null +++ b/adhdo2/bin/adhdo-dashboard @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) + +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from adhdolib import db +from adhdolib.binload import load_bin_module +from adhdolib.config import load_config +from adhdolib.envelope import scrub + +JOURNAL_LIMIT = 50 + +def get_state(): + state = load_bin_module("state") + return state.run(["--via", "dashboard"]) + +def get_journal(limit=JOURNAL_LIMIT): + conn = db.connect() + try: + rows = conn.execute( + "SELECT ts, source, type, payload_json FROM events " + "ORDER BY ts DESC LIMIT ?", (limit,)).fetchall() + return {"events": [ + {"ts": r[0], "source": r[1], "type": r[2], + "payload": json.loads(r[3]) if r[3] else None} for r in rows]} + finally: + conn.close() + +PAGE = """ + + + + +ADHDo Dashboard + + + +

ADHDo read-only dashboard

+

+

State

+
+

Devices

+
+

Recent journal

+
+ + + +""" + +class DashboardHandler(BaseHTTPRequestHandler): + server_version = "adhdo-dashboard" + + def _send(self, code, body, ctype): + data = body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(data) + + def _send_json(self, code, obj): + self._send(code, scrub(json.dumps(obj, default=str)), "application/json") + + def do_GET(self): + path = self.path.split("?", 1)[0] + if path == "/": + self._send(200, PAGE, "text/html; charset=utf-8") + elif path == "/api/state": + self._api(get_state) + elif path == "/api/journal": + self._api(get_journal) + else: + self._send_json(404, {"error": "not_found", "detail": path}) + + def _api(self, fn): + try: + self._send_json(200, fn()) + except Exception as e: + self._send_json(500, {"error": "internal", + "detail": f"{type(e).__name__}: {e}"}) + + def _reject(self): + self._send_json(405, {"error": "method_not_allowed", + "detail": "dashboard is read-only (GET only)"}) + + do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = do_OPTIONS = _reject + +def main(): + cfg = load_config() + bind = cfg["dashboard"]["bind"] or cfg["lan_ip"] + httpd = ThreadingHTTPServer((bind, cfg["dashboard"]["port"]), DashboardHandler) + httpd.serve_forever() + +if __name__ == "__main__": + main() diff --git a/adhdo2/config.example.yaml b/adhdo2/config.example.yaml index c380256..d5254fb 100644 --- a/adhdo2/config.example.yaml +++ b/adhdo2/config.example.yaml @@ -13,6 +13,9 @@ nudge: quiet_hours: ["22:00", "07:00"] high_urgency_events: [medication, safety, user_requested] disk_warn_pct: 94 +dashboard: # P3 read-only LAN dashboard (separate service) + bind: null # null → falls back to lan_ip + port: 8766 crisis: contacts: ["Lifeline Australia 13 11 14"] telegram: {chat_id_allowlist: []} # P3 diff --git a/adhdo2/systemd/adhdo-dashboard.service b/adhdo2/systemd/adhdo-dashboard.service new file mode 100644 index 0000000..49cc1bf --- /dev/null +++ b/adhdo2/systemd/adhdo-dashboard.service @@ -0,0 +1,11 @@ +[Unit] +Description=ADHDo read-only LAN dashboard +After=network-online.target + +[Service] +Environment=ADHDO_HOME=%h/adhdo2 +ExecStart=%h/adhdo2/venv/bin/python %h/adhdo2/bin/adhdo-dashboard +Restart=always + +[Install] +WantedBy=default.target diff --git a/adhdo2/tests/test_dashboard.py b/adhdo2/tests/test_dashboard.py new file mode 100644 index 0000000..bd3d773 --- /dev/null +++ b/adhdo2/tests/test_dashboard.py @@ -0,0 +1,94 @@ +import json, threading, urllib.error, urllib.request +from http.server import ThreadingHTTPServer + +import pytest +from adhdolib import db +from adhdolib.binload import load_bin_module +from adhdolib.config import load_config + +FAKE_STATE = {"ts": 1000.0, "day_part": "afternoon", + "devices": [{"name": "office", "online": True, "now_playing": None}], + "last": {"med": 60.0, "meal": None, "break": None, "nudge": None}, + "scheduled": [], "disk": {"pct": 50.0, "warn": False}} + +@pytest.fixture +def dash(adhdo_home, monkeypatch): + mod = load_bin_module("adhdo-dashboard") + monkeypatch.setattr(mod, "get_state", lambda: dict(FAKE_STATE)) + return mod + +@pytest.fixture +def server(dash): + httpd = ThreadingHTTPServer(("127.0.0.1", 0), dash.DashboardHandler) + t = threading.Thread(target=httpd.serve_forever, daemon=True) + t.start() + yield f"http://127.0.0.1:{httpd.server_address[1]}" + httpd.shutdown() + httpd.server_close() + +def _get(url): + with urllib.request.urlopen(url) as r: + return r.status, r.headers.get("Content-Type", ""), r.read().decode() + +def test_config_defaults(adhdo_home): + cfg = load_config() + assert cfg["dashboard"]["port"] == 8766 + assert cfg["dashboard"]["bind"] is None # falls back to lan_ip + +def test_html_page(server): + status, ctype, body = _get(server + "/") + assert status == 200 + assert ctype.startswith("text/html") + assert "ADHDo" in body + assert "/api/state" in body and "/api/journal" in body # self-contained JS + +def test_api_state(server): + status, ctype, body = _get(server + "/api/state") + assert status == 200 + assert ctype.startswith("application/json") + assert json.loads(body) == FAKE_STATE + +def test_api_journal_reads_db(server): + conn = db.connect() + db.log_event(conn, "session", "med", "morning dose") + db.log_event(conn, "tool", "nudge", json.dumps({"text": "hi", "urgency": "low"})) + conn.close() + status, _, body = _get(server + "/api/journal") + assert status == 200 + events = json.loads(body)["events"] + assert len(events) == 2 + assert events[0]["type"] == "nudge" # newest first + assert events[0]["payload"]["urgency"] == "low" + assert events[1]["payload"] == "morning dose" + +def test_journal_secrets_scrubbed(server): + conn = db.connect() + db.log_event(conn, "session", "error", "jellyfin call failed: api_key=supersecret123") + conn.close() + _, _, body = _get(server + "/api/journal") + assert "supersecret123" not in body + assert "REDACTED" in body + +def test_unknown_path_404(server): + with pytest.raises(urllib.error.HTTPError) as e: + _get(server + "/nope") + assert e.value.code == 404 + +def test_write_methods_rejected(server): + for method in ("POST", "PUT", "DELETE", "PATCH", "OPTIONS"): + req = urllib.request.Request(server + "/api/state", data=b"{}", method=method) + with pytest.raises(urllib.error.HTTPError) as e: + urllib.request.urlopen(req) + assert e.value.code == 405 + assert json.loads(e.value.read())["error"] == "method_not_allowed" + +def test_state_failure_returns_500_envelope(server, dash, monkeypatch): + def boom(): + raise RuntimeError("scan exploded") + monkeypatch.setattr(dash, "get_state", boom) + with pytest.raises(urllib.error.HTTPError) as e: + _get(server + "/api/state") + assert e.value.code == 500 + payload = json.loads(e.value.read()) + assert payload["error"] == "internal" + assert "RuntimeError" in payload["detail"] From ca4101de64f1123b563667703deb831eaece7782 Mon Sep 17 00:00:00 2001 From: Adrian Wedd Date: Sat, 11 Jul 2026 16:12:43 +1000 Subject: [PATCH 4/7] =?UTF-8?q?feat(adhdo2):=20P2=20personalization=20loop?= =?UTF-8?q?=20=E2=80=94=20journal=20outcome=20logging,=20outcome=20pattern?= =?UTF-8?q?s,=20nightly=20rollup+prune,=20wake-up=20routine=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - journal outcome [feedback] - journal patterns now surfaces outcome_by_intervention, outcome_by_daypart, success_rate_by_intervention - journal rollup aggregates complete days into rollups and prunes raw events/audit rows older than 90 days (idempotent, skips today) - scripts/adhdo-rollup.sh nightly at 03:15 via install-cron.sh, with journal.db backup copy - session/CLAUDE.md wake-up routine v2: connectors, patterns-before-acting, outcome logging, re-auth nudge on connector failure Claude-Session: https://claude.ai/code/session_01Tbd7qVjznvPwwYaWmUnWHP --- adhdo2/E2E_CHECKLIST.md | 6 +++ adhdo2/bin/journal | 84 +++++++++++++++++++++++++++-- adhdo2/scripts/adhdo-rollup.sh | 15 ++++++ adhdo2/scripts/install-cron.sh | 1 + adhdo2/session/CLAUDE.md | 22 ++++++-- adhdo2/tests/test_journal.py | 97 +++++++++++++++++++++++++++++++++- 6 files changed, 216 insertions(+), 9 deletions(-) create mode 100755 adhdo2/scripts/adhdo-rollup.sh diff --git a/adhdo2/E2E_CHECKLIST.md b/adhdo2/E2E_CHECKLIST.md index a0f2ce3..6fd9c5c 100644 --- a/adhdo2/E2E_CHECKLIST.md +++ b/adhdo2/E2E_CHECKLIST.md @@ -81,6 +81,12 @@ that requires a human physically listening at the Pi's location). SKIPPED — explicitly instructed not to reboot the Pi during this automated pass. +- [ ] Nightly rollup: after re-running `install-cron.sh` (adds a 03:15 + `adhdo-rollup.sh` entry), verify next morning that `journal rollup` + wrote rows (`sqlite3 ~/adhdo2/data/journal.db "SELECT * FROM rollups"`) + and `~/adhdo2/data/backup/journal.db` exists. Depends on cron being + enabled (human-gated switch below). + - [ ] Full heartbeat observed: cron fires, session runs state, journals a decision SKIPPED — install-cron.sh intentionally NOT run yet (final human-gated switch, see Step 4 notes). No heartbeat cron installed, so nothing to diff --git a/adhdo2/bin/journal b/adhdo2/bin/journal index ed9543f..ca70ef4 100755 --- a/adhdo2/bin/journal +++ b/adhdo2/bin/journal @@ -15,6 +15,21 @@ def cmd_log(conn, args): db.log_event(conn, "session", type_, text) return {"logged": type_} +OUTCOMES = frozenset({"worked", "partial", "ignored", "backfired"}) + +def cmd_outcome(conn, args): + if len(args) < 2: + raise ToolError( + "usage", "journal outcome [feedback...]") + intervention, outcome = args[0], args[1] + if outcome not in OUTCOMES: + raise ToolError("bad_outcome", f"outcome must be one of {sorted(OUTCOMES)}") + payload = {"intervention": intervention, "outcome": outcome} + if len(args) > 2: + payload["feedback"] = " ".join(args[2:]) + db.log_event(conn, "session", "outcome", json.dumps(payload)) + return {"logged": "outcome", "intervention": intervention, "outcome": outcome} + def cmd_recent(conn, args): n = int(args[0]) if args else 20 rows = conn.execute( @@ -58,15 +73,78 @@ def cmd_patterns(conn, args): "FROM events WHERE type IN ('med','meal','break') AND ts>? GROUP BY 1", (cutoff,)): streaks[t] = n + outcome_by_intervention, outcome_by_daypart = {}, {} + for (intervention, outcome, n) in conn.execute( + "SELECT COALESCE(json_extract(payload_json,'$.intervention'),'unknown'), " + "COALESCE(json_extract(payload_json,'$.outcome'),'unknown'), COUNT(*) " + "FROM events WHERE type='outcome' AND ts>? GROUP BY 1, 2", (cutoff,)): + bucket = outcome_by_intervention.setdefault(intervention, {}) + bucket[outcome] = bucket.get(outcome, 0) + n + for (h, outcome, n) in conn.execute( + "SELECT strftime('%H', ts, 'unixepoch', 'localtime'), " + "COALESCE(json_extract(payload_json,'$.outcome'),'unknown'), COUNT(*) " + "FROM events WHERE type='outcome' AND ts>? GROUP BY 1, 2", (cutoff,)): + dp = _daypart(int(h)) + bucket = outcome_by_daypart.setdefault(dp, {}) + bucket[outcome] = bucket.get(outcome, 0) + n + success_rate_by_intervention = {} + for intervention, counts in outcome_by_intervention.items(): + total = sum(counts.values()) + good = counts.get("worked", 0) + counts.get("partial", 0) * 0.5 + success_rate_by_intervention[intervention] = round(good / total, 3) if total else None return {"window_days": 30, "nudge_response_rate_by_hour": by_hour, "nudge_response_rate_by_type": by_type, - "mood_by_daypart": moods, "event_streaks": streaks} + "mood_by_daypart": moods, "event_streaks": streaks, + "outcome_by_intervention": outcome_by_intervention, + "outcome_by_daypart": outcome_by_daypart, + "success_rate_by_intervention": success_rate_by_intervention} + +RETENTION_DAYS = 90 + +def cmd_rollup(conn, args): + """Aggregate complete days of events into rollups, then prune raw + events/audit rows older than RETENTION_DAYS. Idempotent.""" + today = time.strftime("%Y-%m-%d", time.localtime()) + days = [r[0] for r in conn.execute( + "SELECT DISTINCT date(ts,'unixepoch','localtime') FROM events " + "WHERE date(ts,'unixepoch','localtime') < ? " + "AND date(ts,'unixepoch','localtime') NOT IN " + "(SELECT day FROM rollups WHERE metric='events_by_type')", (today,))] + for day in days: + by_type = {t: n for (t, n) in conn.execute( + "SELECT type, COUNT(*) FROM events " + "WHERE date(ts,'unixepoch','localtime')=? GROUP BY 1", (day,))} + outcomes = {} + for (intervention, outcome, n) in conn.execute( + "SELECT COALESCE(json_extract(payload_json,'$.intervention'),'unknown'), " + "COALESCE(json_extract(payload_json,'$.outcome'),'unknown'), COUNT(*) " + "FROM events WHERE type='outcome' " + "AND date(ts,'unixepoch','localtime')=? GROUP BY 1, 2", (day,)): + outcomes[f"{intervention}:{outcome}"] = n + nudges_by_hour = {h: n for (h, n) in conn.execute( + "SELECT strftime('%H', ts, 'unixepoch', 'localtime'), COUNT(*) " + "FROM events WHERE type='nudge' " + "AND date(ts,'unixepoch','localtime')=? GROUP BY 1", (day,))} + for metric, value in (("events_by_type", by_type), + ("outcomes", outcomes), + ("nudges_by_hour", nudges_by_hour)): + conn.execute("INSERT OR REPLACE INTO rollups(day, metric, value_json) " + "VALUES(?,?,?)", (day, metric, json.dumps(value))) + prune_cutoff = time.time() - RETENTION_DAYS * 86400 + pruned_events = conn.execute( + "DELETE FROM events WHERE ts ...") + raise ToolError("usage", "journal ...") conn = db.connect() - cmd = {"log": cmd_log, "recent": cmd_recent, "patterns": cmd_patterns}.get(sys.argv[1]) + cmd = {"log": cmd_log, "outcome": cmd_outcome, "recent": cmd_recent, + "patterns": cmd_patterns, "rollup": cmd_rollup}.get(sys.argv[1]) if not cmd: raise ToolError("usage", f"unknown subcommand {sys.argv[1]}") return cmd(conn, sys.argv[2:]) diff --git a/adhdo2/scripts/adhdo-rollup.sh b/adhdo2/scripts/adhdo-rollup.sh new file mode 100755 index 0000000..bfd4c42 --- /dev/null +++ b/adhdo2/scripts/adhdo-rollup.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Nightly 03:15: aggregate daily journal outcomes into rollup rows, prune +# raw rows older than 90 days, and keep a backup copy of journal.db. +set -u +ADHDO_HOME=${ADHDO_HOME:-$HOME/adhdo2} +export ADHDO_HOME +"$ADHDO_HOME"/venv/bin/python "$ADHDO_HOME"/bin/journal rollup || \ + "$ADHDO_HOME"/venv/bin/python "$ADHDO_HOME"/bin/journal log error "nightly rollup failed" || true +mkdir -p "$ADHDO_HOME"/data/backup +if command -v sqlite3 >/dev/null 2>&1; then + sqlite3 "$ADHDO_HOME"/data/journal.db \ + ".backup '$ADHDO_HOME/data/backup/journal.db'" || true +else + cp "$ADHDO_HOME"/data/journal.db "$ADHDO_HOME"/data/backup/journal.db || true +fi diff --git a/adhdo2/scripts/install-cron.sh b/adhdo2/scripts/install-cron.sh index 415b57d..eb85768 100755 --- a/adhdo2/scripts/install-cron.sh +++ b/adhdo2/scripts/install-cron.sh @@ -11,6 +11,7 @@ cat >> "$TMP" </dev/null 2>&1 */5 * * * * ADHDO_HOME=\$HOME/adhdo2 $PY \$HOME/adhdo2/bin/adhdo-dispatch >/dev/null 2>&1 */5 * * * * \$HOME/adhdo2/scripts/adhdo-watchdog.sh >/dev/null 2>&1 +15 3 * * * \$HOME/adhdo2/scripts/adhdo-rollup.sh >/dev/null 2>&1 30 3 * * * \$HOME/adhdo2/scripts/adhdo-recycle.sh >/dev/null 2>&1 @reboot \$HOME/adhdo2/scripts/adhdo-catchup.sh >/dev/null 2>&1 # ADHDO-END diff --git a/adhdo2/session/CLAUDE.md b/adhdo2/session/CLAUDE.md index 02906b3..2e24e48 100644 --- a/adhdo2/session/CLAUDE.md +++ b/adhdo2/session/CLAUDE.md @@ -10,14 +10,26 @@ events (`[event:meds]`, `[event:bedtime]`), scheduled follow-ups - `... bin/cast play [--device D]` / `cast stop` / `cast volume 0.4` / `cast status` - `... bin/nudge "text" [--device D] [--urgency low|med|high] [--event medication|safety|user_requested]` - `... bin/journal log ""` — types: med, meal, break, decision, outcome, feedback, error +- `... bin/journal outcome [feedback]` — log how an intervention landed - `... bin/journal recent 20` · `... bin/journal patterns` - `... bin/wake --at "HH:MM" --tag ` — schedule your own follow-up -## Wake-up routine -1. Run `state`. 2. Check calendar/Gmail connectors if the situation warrants. -3. Consult `journal patterns` before choosing intervention type/timing. -4. Act — or deliberately do nothing (often correct). 5. `journal log decision` -with one line of reasoning, and later `journal log outcome` when observable. +## Wake-up routine (v2) +1. Run `state`. Always. +2. Consult the calendar/Gmail connectors when relevant — upcoming events, + deadlines, anything time-sensitive. If a connector fails or needs re-auth: + `journal log error`, nudge Adrian once to re-auth, then continue on local + state. Do not retry the connector this cycle. +3. Consult `journal patterns` BEFORE choosing intervention type and timing — + `success_rate_by_intervention` and `outcome_by_daypart` tell you what has + actually worked for Adrian and when. Prefer what works; avoid what gets + ignored or backfires at this time of day. +4. Act — or deliberately do nothing (often correct). +5. `journal log decision` with one line of reasoning. When the result of an + intervention becomes observable (Adrian responded, took the break, ignored + the nudge), log it: `journal outcome nudge worked` / + `journal outcome cast ignored "kept hyperfocusing"`. Schedule a + `wake --at` follow-up if you need to check. ## Policies (non-negotiable) - If a tool returns `rate_limited` or `quiet_hours`, do NOT retry this cycle. diff --git a/adhdo2/tests/test_journal.py b/adhdo2/tests/test_journal.py index 22fa019..337bd9b 100644 --- a/adhdo2/tests/test_journal.py +++ b/adhdo2/tests/test_journal.py @@ -1,4 +1,6 @@ -import importlib.util, json, pathlib, subprocess, sys, os +import importlib.util, json, pathlib, subprocess, sys, os, time + +from adhdolib import db BIN = pathlib.Path(__file__).resolve().parents[1] / "bin" / "journal" @@ -35,6 +37,49 @@ def test_patterns_nudge_response_rate_by_type_values(adhdo_home): assert code == 0 assert out["nudge_response_rate_by_type"] == {"high": 1} +def test_outcome_logs_structured_row(adhdo_home): + code, out = run_cli(adhdo_home, "outcome", "nudge", "worked", "took", "the", "break") + assert code == 0 + assert out == {"logged": "outcome", "intervention": "nudge", "outcome": "worked"} + code, out = run_cli(adhdo_home, "recent", "1") + ev = out["events"][0] + assert ev["type"] == "outcome" + assert ev["payload"] == {"intervention": "nudge", "outcome": "worked", + "feedback": "took the break"} + +def test_outcome_without_feedback(adhdo_home): + code, out = run_cli(adhdo_home, "outcome", "cast", "ignored") + assert code == 0 + _, out = run_cli(adhdo_home, "recent", "1") + assert out["events"][0]["payload"] == {"intervention": "cast", "outcome": "ignored"} + +def test_outcome_bad_value_is_enveloped(adhdo_home): + code, out = run_cli(adhdo_home, "outcome", "nudge", "amazing") + assert code == 1 and out["error"] == "bad_outcome" + +def test_outcome_usage_error(adhdo_home): + code, out = run_cli(adhdo_home, "outcome", "nudge") + assert code == 1 and out["error"] == "usage" + +def test_patterns_surfaces_outcomes(adhdo_home): + run_cli(adhdo_home, "outcome", "nudge", "worked") + run_cli(adhdo_home, "outcome", "nudge", "ignored") + run_cli(adhdo_home, "outcome", "cast", "partial") + code, out = run_cli(adhdo_home, "patterns") + assert code == 0 + assert out["outcome_by_intervention"] == { + "nudge": {"worked": 1, "ignored": 1}, "cast": {"partial": 1}} + assert out["success_rate_by_intervention"] == {"nudge": 0.5, "cast": 0.5} + dayparts = out["outcome_by_daypart"] + assert sum(n for b in dayparts.values() for n in b.values()) == 3 + +def test_patterns_outcome_keys_present_when_empty(adhdo_home): + code, out = run_cli(adhdo_home, "patterns") + assert code == 0 + assert out["outcome_by_intervention"] == {} + assert out["outcome_by_daypart"] == {} + assert out["success_rate_by_intervention"] == {} + def test_patterns_mood_by_daypart_nested(adhdo_home): run_cli(adhdo_home, "log", "cast", json.dumps({"mood": "focused"})) code, out = run_cli(adhdo_home, "patterns") @@ -44,3 +89,53 @@ def test_patterns_mood_by_daypart_nested(adhdo_home): found = any(mood_counts.get("focused") == 1 for mood_counts in moods.values()) assert found + +def _insert_event(adhdo_home, ts, type_, payload): + conn = db.connect() + conn.execute("INSERT INTO events(ts, source, type, payload_json) VALUES(?,?,?,?)", + (ts, "test", type_, json.dumps(payload))) + conn.commit() + conn.close() + +def test_rollup_aggregates_past_days(adhdo_home): + yesterday = time.time() - 86400 + _insert_event(adhdo_home, yesterday, "outcome", + {"intervention": "nudge", "outcome": "worked"}) + _insert_event(adhdo_home, yesterday, "nudge", {"urgency": "low"}) + code, out = run_cli(adhdo_home, "rollup") + assert code == 0 + assert len(out["rolled_up_days"]) == 1 + day = out["rolled_up_days"][0] + conn = db.connect() + rows = {m: json.loads(v) for (m, v) in conn.execute( + "SELECT metric, value_json FROM rollups WHERE day=?", (day,))} + conn.close() + assert rows["events_by_type"] == {"outcome": 1, "nudge": 1} + assert rows["outcomes"] == {"nudge:worked": 1} + assert sum(rows["nudges_by_hour"].values()) == 1 + +def test_rollup_is_idempotent_and_skips_today(adhdo_home): + run_cli(adhdo_home, "log", "med", "today dose") # today: not rolled up + _insert_event(adhdo_home, time.time() - 86400, "med", "yesterday dose") + code, out = run_cli(adhdo_home, "rollup") + assert code == 0 and len(out["rolled_up_days"]) == 1 + code, out = run_cli(adhdo_home, "rollup") + assert code == 0 and out["rolled_up_days"] == [] + +def test_rollup_prunes_rows_older_than_90_days(adhdo_home): + old = time.time() - 91 * 86400 + _insert_event(adhdo_home, old, "nudge", {"urgency": "low"}) + conn = db.connect() + conn.execute("INSERT INTO audit(ts, tool, argv, ok, detail) VALUES(?,?,?,?,?)", + (old, "nudge", "[]", 1, "")) + conn.commit() + conn.close() + run_cli(adhdo_home, "log", "med", "fresh row") + code, out = run_cli(adhdo_home, "rollup") + assert code == 0 + assert out["pruned_events"] == 1 and out["pruned_audit"] == 1 + conn = db.connect() + assert conn.execute("SELECT COUNT(*) FROM events").fetchone()[0] == 1 + # the old day was still rolled up before pruning + assert conn.execute("SELECT COUNT(*) FROM rollups").fetchone()[0] > 0 + conn.close() From 819eb491705b4a8ed30bf1a0c79f20101f161431 Mon Sep 17 00:00:00 2001 From: Adrian Wedd Date: Sat, 11 Jul 2026 16:12:55 +1000 Subject: [PATCH 5/7] fix(adhdo2): P2 quality backlog (#114) - envelope: narrow BaseException to Exception so KeyboardInterrupt and SystemExit propagate; drop redundant SystemExit re-raise - nudge: extract prune_cache() and run it unconditionally on every invocation (previously skipped on TTS cache hits) - heartbeat: replace cron */25 (10-min gap at hour boundaries) with a systemd user timer (OnUnitActiveSec=25min) for a true 25-min cadence; install-cron.sh installs and enables it - deploy.sh: install piper-tts idempotently; copy systemd .timer units - docs: catchup-vs-schedule interplay doc, referenced from E2E_CHECKLIST Claude-Session: https://claude.ai/code/session_01Tbd7qVjznvPwwYaWmUnWHP --- adhdo2/E2E_CHECKLIST.md | 10 +++++++ adhdo2/adhdolib/envelope.py | 6 ++-- adhdo2/bin/nudge | 23 +++++++++++---- adhdo2/docs/catchup-vs-schedule.md | 40 +++++++++++++++++++++++++ adhdo2/scripts/deploy.sh | 4 ++- adhdo2/scripts/install-cron.sh | 22 ++++++++++++-- adhdo2/systemd/adhdo-heartbeat.service | 7 +++++ adhdo2/systemd/adhdo-heartbeat.timer | 15 ++++++++++ adhdo2/tests/test_envelope.py | 13 ++++++++ adhdo2/tests/test_install_scripts.py | 41 ++++++++++++++++++++++++++ adhdo2/tests/test_nudge.py | 34 +++++++++++++++++++++ 11 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 adhdo2/docs/catchup-vs-schedule.md create mode 100644 adhdo2/systemd/adhdo-heartbeat.service create mode 100644 adhdo2/systemd/adhdo-heartbeat.timer create mode 100644 adhdo2/tests/test_install_scripts.py diff --git a/adhdo2/E2E_CHECKLIST.md b/adhdo2/E2E_CHECKLIST.md index a0f2ce3..b147be1 100644 --- a/adhdo2/E2E_CHECKLIST.md +++ b/adhdo2/E2E_CHECKLIST.md @@ -1,5 +1,15 @@ # ADHDo 2.0 P1 Hardware E2E (pi5-hailo) +> How boot catch-up interacts with the dispatcher and fixed-event schedule +> (and why they never double-fire) is documented in +> [docs/catchup-vs-schedule.md](docs/catchup-vs-schedule.md). +> +> Note (P2 quality pass): the heartbeat is no longer a `*/25` cron line — +> `install-cron.sh` now installs the `adhdo-heartbeat.timer` systemd user +> timer (true 25-min cadence). On-device step: re-run +> `~/adhdo2/scripts/install-cron.sh` after the next deploy so the old cron +> heartbeat line is replaced and the timer is enabled. + Run 2026-07-10, automated portion only (no audible verification performed — that requires a human physically listening at the Pi's location). diff --git a/adhdo2/adhdolib/envelope.py b/adhdo2/adhdolib/envelope.py index df604de..850dbc9 100644 --- a/adhdo2/adhdolib/envelope.py +++ b/adhdo2/adhdolib/envelope.py @@ -35,9 +35,9 @@ def cli_main(fn): except ToolError as e: print(json.dumps({"error": e.code, "detail": scrub(e.detail)})) sys.exit(1) - except SystemExit: - raise - except BaseException as e: + except Exception as e: + # Deliberately Exception, not BaseException: KeyboardInterrupt and + # SystemExit must propagate so Ctrl-C / exit codes behave normally. tail = "".join(traceback.format_tb(e.__traceback__)[-3:]) print(json.dumps({"error": "crash", "detail": scrub(f"{type(e).__name__}: {e}"), diff --git a/adhdo2/bin/nudge b/adhdo2/bin/nudge index b456cb8..55a65c2 100755 --- a/adhdo2/bin/nudge +++ b/adhdo2/bin/nudge @@ -10,6 +10,20 @@ from adhdolib.config import load_config, home from adhdolib.envelope import ToolArgumentParser, ToolError, cli_main from adhdolib.nudgelimits import check_allowed +def prune_cache(cache_dir: Path, max_age: float = 86400) -> int: + """Delete cached mp3s older than max_age seconds. Runs on every nudge + invocation (not just on cache misses) so the cache can't grow unbounded. + Returns the number of files removed.""" + if not cache_dir.is_dir(): + return 0 + cutoff = time.time() - max_age + removed = 0 + for f in cache_dir.glob("*.mp3"): + if f.stat().st_mtime < cutoff: + f.unlink() + removed += 1 + return removed + def synthesize(text: str, cache_dir: Path) -> Path: cache_dir.mkdir(parents=True, exist_ok=True) out = cache_dir / (hashlib.sha1(text.encode()).hexdigest()[:16] + ".mp3") @@ -29,11 +43,6 @@ def synthesize(text: str, cache_dir: Path) -> Path: subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(wav), str(out)], check=True, timeout=60) wav.unlink(missing_ok=True) - # prune cache >24h - cutoff = time.time() - 86400 - for f in cache_dir.glob("*.mp3"): - if f.stat().st_mtime < cutoff and f != out: - f.unlink() return out def play_url_on_device(url: str, device_name: str, cfg: dict): @@ -104,7 +113,9 @@ def run(argv, _test_conn=None): if not device: raise ToolError("no_device_configured", "set default_device in config") real_name = cfg["devices"].get(device, device) - mp3 = synthesize(args.text, home() / "tts-cache") + cache_dir = home() / "tts-cache" + prune_cache(cache_dir) + mp3 = synthesize(args.text, cache_dir) url = f"http://{cfg['lan_ip']}:{cfg['httpd_port']}/{mp3.name}" play_url_on_device(url, real_name, cfg) wait_until_idle(real_name, cfg) diff --git a/adhdo2/docs/catchup-vs-schedule.md b/adhdo2/docs/catchup-vs-schedule.md new file mode 100644 index 0000000..cccebf1 --- /dev/null +++ b/adhdo2/docs/catchup-vs-schedule.md @@ -0,0 +1,40 @@ +# Catch-up vs. schedule: how missed events are handled + +Two mechanisms deliver time-based wakes, and they must not double-fire. + +## Normal operation (Pi is up) + +- **Fixed events** (meds 08:00, bedtime 22:30) fire from cron entries + installed by `scripts/install-cron.sh` (`bin/wake --event ...`). +- **Heartbeat** fires from the `adhdo-heartbeat.timer` systemd user timer + every 25 minutes (a true 25-min cadence; cron's `*/25` leaves a 10-minute + gap at each hour boundary, which is why this is a timer, not a cron line). +- **One-off follow-ups** (`bin/wake --at