From 48db60a65ee4152159ec92e0e1bf1ee8a36ef8a7 Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 25 May 2026 16:39:27 -0400 Subject: [PATCH] feat(adr-125 tier1 #1): multi-characteristic accessory + JSON-state IPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HAP accessory now carries three services on the same paired entity (HomeKit allows multiple services per accessory; iPhone refetches /accessories when config_number bumps): - MotionSensor — short-window motion_score, immediate - OccupancySensor — rolling-3s avg presence_score, sustained - StatelessProgrammableSwitch — "Unrecognized Activity Pattern" event (Restricted-class only; fires on anomaly_score >= 0.7); ADR-125 §2.1.d semantic naming, not security state New JSON IPC contract `/tmp/ruview-state.json` between watcher and HAP daemon: { "motion": bool, "occupancy": bool, "anomaly_ts": float, "ts": float } Atomic writes (tmp + rename). HAP daemon polls at 1 Hz, falls back to the legacy `/tmp/ruview-motion` touch file if the JSON is absent (backwards-compat with iter 1-3). Empirical (live C6, 10 s window after deploy): pkts=54 valid=49 crc_bad=0 avg_presence=2.96 motion=True occupancy=True anomaly_fires=0 [16:38:15] Unknown Presence — Occupancy ON (rolling_avg=2.79) Pairing survived: paired_clients: 1 config_number: 3 (was 1; HAP-python bumps automatically on shape change) Tier 1 #1 (multi-characteristic) of the Tier 1+2 sprint. Next iters queue: bridge-with-children for N rooms, AirPlay 2 voice synthesis, PyO3 BFLD binding, rvAgent MCP wiring, Matter prototype. Refs ADR-125 §2.1.c (bridge topology), §2.1.d (semantic events), ADR-118. Co-Authored-By: claude-flow --- scripts/c6-presence-watcher.py | 84 +++++++++++++++++++++++++++++-- scripts/hap-test-sensor.py | 90 ++++++++++++++++++++++++++++++---- 2 files changed, 160 insertions(+), 14 deletions(-) diff --git a/scripts/c6-presence-watcher.py b/scripts/c6-presence-watcher.py index 950f714b..fbb46b24 100644 --- a/scripts/c6-presence-watcher.py +++ b/scripts/c6-presence-watcher.py @@ -40,6 +40,7 @@ Usage: """ from __future__ import annotations import argparse +import json import os import signal import socket @@ -47,6 +48,7 @@ import struct import sys import time import zlib +from collections import deque RV_FEATURE_STATE_MAGIC = 0xC5110006 RV_QFLAG_PRESENCE_VALID = 1 << 0 @@ -196,6 +198,16 @@ def main() -> int: choices=["raw", "derived", "anonymous", "restricted"], help="ADR-118 PrivacyClass; only anonymous/restricted " "may cross the HAP boundary (ADR-125 §2.1.d).") + p.add_argument("--state-json", default="/tmp/ruview-state.json", + help="JSON state IPC file written for the HAP daemon. " + "Contains motion/occupancy/anomaly_ts.") + p.add_argument("--occupancy-window", type=float, default=3.0, + help="Seconds of rolling presence_score average for " + "OccupancyDetected (vs short-window MotionDetected).") + p.add_argument("--anomaly-threshold", type=float, default=0.7, + help="anomaly_score crossing this fires the " + "'Unrecognized Activity Pattern' event " + "(Restricted class only; ADR-125 §2.1.d).") args = p.parse_args() privacy_class = PrivacyClass.from_str(args.privacy_class) @@ -234,10 +246,27 @@ def main() -> int: signal.signal(signal.SIGINT, _stop) motion = os.path.exists(args.toggle) + occupancy = False + last_anomaly_ts = 0.0 last_packet_ts = 0.0 last_summary = time.time() - n_total = n_valid = n_crc_bad = 0 + n_total = n_valid = n_crc_bad = n_anomaly_fires = 0 presence_sum = motion_sum = 0.0 + # Rolling window of (timestamp, presence_score) for occupancy detect + occ_window: deque[tuple[float, float]] = deque() + OCC_ON_THRESH = 0.30 + OCC_OFF_THRESH = 0.15 + state_path = args.state_json + + def write_state(motion: bool, occupancy: bool, anomaly_ts: float) -> None: + try: + tmp = state_path + ".tmp" + with open(tmp, "w") as fh: + json.dump({"motion": motion, "occupancy": occupancy, + "anomaly_ts": anomaly_ts, "ts": time.time()}, fh) + os.replace(tmp, state_path) + except OSError: + pass while running: try: @@ -262,14 +291,59 @@ def main() -> int: presence_sum += gated["presence"] motion_sum += gated["motion"] last_packet_ts = now + # MotionDetected — short-window (each packet) + prev_motion = motion if not motion and gated["presence"] >= PRESENCE_ON_THRESHOLD: motion = set_motion(args.toggle, True, motion) elif motion and gated["presence"] <= PRESENCE_OFF_THRESHOLD: motion = set_motion(args.toggle, False, motion) - # Idle release — if the C6 stops sending entirely, clear motion. + # OccupancyDetected — rolling-window avg (§2.1.d + # "Unexpected Occupancy" is a future iter; for now + # we expose Occupancy as sustained presence). + occ_window.append((now, gated["presence"])) + cutoff = now - args.occupancy_window + while occ_window and occ_window[0][0] < cutoff: + occ_window.popleft() + if occ_window: + occ_avg = (sum(p for _, p in occ_window) + / len(occ_window)) + if not occupancy and occ_avg >= OCC_ON_THRESH: + occupancy = True + print(f"[{time.strftime('%H:%M:%S')}] " + f"Unknown Presence — Occupancy ON " + f"(rolling_avg={occ_avg:.2f})", + flush=True) + elif occupancy and occ_avg <= OCC_OFF_THRESH: + occupancy = False + print(f"[{time.strftime('%H:%M:%S')}] " + f"Occupancy OFF " + f"(rolling_avg={occ_avg:.2f})", + flush=True) + + # Anomaly — only when class allows (Restricted + # gate drops anomaly_score entirely; the dict + # missing the key is the type-level enforcement). + if ("anomaly" in gated + and gated["anomaly"] >= args.anomaly_threshold): + last_anomaly_ts = now + n_anomaly_fires += 1 + print(f"[{time.strftime('%H:%M:%S')}] " + f"Unrecognized Activity Pattern " + f"(anomaly={gated['anomaly']:.2f})", + flush=True) + + if (motion != prev_motion + or not state_path.endswith(".disabled")): + write_state(motion, occupancy, last_anomaly_ts) + + # Idle release — if the C6 stops sending entirely, clear motion + # AND occupancy. if motion and last_packet_ts and (now - last_packet_ts) > IDLE_RELEASE_S: motion = set_motion(args.toggle, False, motion) + occupancy = False + occ_window.clear() + write_state(motion, occupancy, last_anomaly_ts) # Periodic summary line (every 10 s) so we can see the watcher is alive if now - last_summary >= 10.0: @@ -278,10 +352,12 @@ def main() -> int: print( f"[{time.strftime('%H:%M:%S')}] 10s stats: " f"pkts={n_total} valid={n_valid} crc_bad={n_crc_bad} " - f"avg_presence={avg_p:.2f} avg_motion={avg_m:.2f} motion={motion}", + f"avg_presence={avg_p:.2f} avg_motion={avg_m:.2f} " + f"motion={motion} occupancy={occupancy} " + f"anomaly_fires={n_anomaly_fires}", flush=True, ) - n_total = n_valid = n_crc_bad = 0 + n_total = n_valid = n_crc_bad = n_anomaly_fires = 0 presence_sum = motion_sum = 0.0 last_summary = now diff --git a/scripts/hap-test-sensor.py b/scripts/hap-test-sensor.py index fa319389..bee8d133 100644 --- a/scripts/hap-test-sensor.py +++ b/scripts/hap-test-sensor.py @@ -20,6 +20,7 @@ State persists across restarts in ~/.ruview-hap/accessory.state. """ from pathlib import Path +import json import os import sys import time @@ -33,26 +34,93 @@ STATE_DIR = Path(os.path.expanduser("~/.ruview-hap")) STATE_DIR.mkdir(exist_ok=True) STATE_FILE = STATE_DIR / "accessory.state" SETUP_CODE_FILE = STATE_DIR / "setup-code.txt" + +# Legacy single-bool toggle (iter 1-3 contract). Still honored for +# backwards-compat with the original c6-presence-watcher.py path. TOGGLE_FILE = Path(os.environ.get("RUVIEW_MOTION_TOGGLE", "/tmp/ruview-motion")) +# New JSON-state IPC contract (iter 4+). When present, takes precedence +# over the legacy toggle file. Schema: +# { +# "motion": bool, # short-window movement (100 ms feature_state) +# "occupancy": bool, # rolling-window sustained presence (1 s+) +# "anomaly": bool, # BFLD anomaly drift gate fired (class-3 only) +# "ts": float, # unix epoch when the watcher last wrote +# } +STATE_JSON = Path(os.environ.get("RUVIEW_STATE_JSON", "/tmp/ruview-state.json")) + + +def _read_state_json(): + """Best-effort read of the JSON IPC file. Returns None on any error.""" + try: + with open(STATE_JSON, "r") as fh: + data = json.load(fh) + if not isinstance(data, dict): + return None + return data + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + class RuViewMotion(Accessory): + """Three-service HomeKit accessory per ADR-125 §2.1.c. + + Same accessory carries: + - MotionSensor — short-window movement (motion_score) + - OccupancySensor — sustained occupancy (presence_score rolling avg) + - StatelessProgrammableSwitch — "Unrecognized Activity Pattern" + event (BFLD anomaly gate; Restricted-class only; momentary fire) + + The HomeKit pairing stays intact when adding services to an existing + accessory — the iPhone re-reads `/accessories` after the bridge's + config-number bumps and surfaces the new characteristics under the + same paired entity. + """ category = CATEGORY_SENSOR def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - serv = self.add_preload_service("MotionSensor") - self.char_motion = serv.configure_char("MotionDetected") - self._last = False + s_motion = self.add_preload_service("MotionSensor") + self.char_motion = s_motion.configure_char("MotionDetected") + s_occ = self.add_preload_service("OccupancySensor") + self.char_occ = s_occ.configure_char("OccupancyDetected") + s_sw = self.add_preload_service("StatelessProgrammableSwitch") + self.char_anomaly = s_sw.configure_char("ProgrammableSwitchEvent") + self._last_motion = False + self._last_occ = False + self._last_anomaly_ts = 0.0 + + def _legacy_motion(self) -> bool: + return TOGGLE_FILE.exists() @Accessory.run_at_interval(1.0) def run(self): - present = TOGGLE_FILE.exists() - if present != self._last: - self.char_motion.set_value(present) - self._last = present + state = _read_state_json() + if state is None: + motion = self._legacy_motion() + occupancy = motion + anomaly_fire = False + else: + motion = bool(state.get("motion", False)) + occupancy = bool(state.get("occupancy", False)) + anomaly_ts = float(state.get("anomaly_ts", 0.0) or 0.0) + anomaly_fire = anomaly_ts > self._last_anomaly_ts + if anomaly_fire: + self._last_anomaly_ts = anomaly_ts + + if motion != self._last_motion: + self.char_motion.set_value(motion) + self._last_motion = motion + print(f"[hap] MotionDetected -> {motion}", flush=True) + if occupancy != self._last_occ: + self.char_occ.set_value(1 if occupancy else 0) + self._last_occ = occupancy + print(f"[hap] OccupancyDetected -> {occupancy}", flush=True) + if anomaly_fire: + # 0 = single press; semantic-event = "Unrecognized Activity Pattern" + self.char_anomaly.set_value(0) print( - f"[hap-test] MotionDetected -> {present} (toggle file: {TOGGLE_FILE})", + "[hap] Unrecognized Activity Pattern fired (ProgrammableSwitch=0)", flush=True, ) @@ -70,8 +138,10 @@ def main() -> int: print(f"[hap-test] HAP bridge advertising as 'RuView Test Bridge'") print(f"[hap-test] iPhone pair flow: Home app -> Add Accessory -> More Options") print(f"[hap-test] Setup code (also in {SETUP_CODE_FILE}): {setup_code}") - print(f"[hap-test] Motion toggle file: {TOGGLE_FILE}") - print(f"[hap-test] State persists in: {STATE_FILE}") + print(f"[hap-test] State sources:") + print(f"[hap-test] primary: {STATE_JSON} (multi-characteristic JSON)") + print(f"[hap-test] fallback: {TOGGLE_FILE} (motion-only touch file)") + print(f"[hap-test] Pair state persists in: {STATE_FILE}") signal.signal(signal.SIGTERM, lambda *_: driver.stop()) driver.start()