feat(adr-125 tier1 #1): multi-characteristic accessory + JSON-state IPC

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 <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-25 16:39:27 -04:00
parent 610eae2974
commit 48db60a65e
2 changed files with 160 additions and 14 deletions

View File

@ -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

View File

@ -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()