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:
parent
610eae2974
commit
48db60a65e
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue