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
|
from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
|
|
@ -47,6 +48,7 @@ import struct
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import zlib
|
import zlib
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
RV_FEATURE_STATE_MAGIC = 0xC5110006
|
RV_FEATURE_STATE_MAGIC = 0xC5110006
|
||||||
RV_QFLAG_PRESENCE_VALID = 1 << 0
|
RV_QFLAG_PRESENCE_VALID = 1 << 0
|
||||||
|
|
@ -196,6 +198,16 @@ def main() -> int:
|
||||||
choices=["raw", "derived", "anonymous", "restricted"],
|
choices=["raw", "derived", "anonymous", "restricted"],
|
||||||
help="ADR-118 PrivacyClass; only anonymous/restricted "
|
help="ADR-118 PrivacyClass; only anonymous/restricted "
|
||||||
"may cross the HAP boundary (ADR-125 §2.1.d).")
|
"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()
|
args = p.parse_args()
|
||||||
|
|
||||||
privacy_class = PrivacyClass.from_str(args.privacy_class)
|
privacy_class = PrivacyClass.from_str(args.privacy_class)
|
||||||
|
|
@ -234,10 +246,27 @@ def main() -> int:
|
||||||
signal.signal(signal.SIGINT, _stop)
|
signal.signal(signal.SIGINT, _stop)
|
||||||
|
|
||||||
motion = os.path.exists(args.toggle)
|
motion = os.path.exists(args.toggle)
|
||||||
|
occupancy = False
|
||||||
|
last_anomaly_ts = 0.0
|
||||||
last_packet_ts = 0.0
|
last_packet_ts = 0.0
|
||||||
last_summary = time.time()
|
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
|
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:
|
while running:
|
||||||
try:
|
try:
|
||||||
|
|
@ -262,14 +291,59 @@ def main() -> int:
|
||||||
presence_sum += gated["presence"]
|
presence_sum += gated["presence"]
|
||||||
motion_sum += gated["motion"]
|
motion_sum += gated["motion"]
|
||||||
last_packet_ts = now
|
last_packet_ts = now
|
||||||
|
# MotionDetected — short-window (each packet)
|
||||||
|
prev_motion = motion
|
||||||
if not motion and gated["presence"] >= PRESENCE_ON_THRESHOLD:
|
if not motion and gated["presence"] >= PRESENCE_ON_THRESHOLD:
|
||||||
motion = set_motion(args.toggle, True, motion)
|
motion = set_motion(args.toggle, True, motion)
|
||||||
elif motion and gated["presence"] <= PRESENCE_OFF_THRESHOLD:
|
elif motion and gated["presence"] <= PRESENCE_OFF_THRESHOLD:
|
||||||
motion = set_motion(args.toggle, False, motion)
|
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:
|
if motion and last_packet_ts and (now - last_packet_ts) > IDLE_RELEASE_S:
|
||||||
motion = set_motion(args.toggle, False, motion)
|
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
|
# Periodic summary line (every 10 s) so we can see the watcher is alive
|
||||||
if now - last_summary >= 10.0:
|
if now - last_summary >= 10.0:
|
||||||
|
|
@ -278,10 +352,12 @@ def main() -> int:
|
||||||
print(
|
print(
|
||||||
f"[{time.strftime('%H:%M:%S')}] 10s stats: "
|
f"[{time.strftime('%H:%M:%S')}] 10s stats: "
|
||||||
f"pkts={n_total} valid={n_valid} crc_bad={n_crc_bad} "
|
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,
|
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
|
presence_sum = motion_sum = 0.0
|
||||||
last_summary = now
|
last_summary = now
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ State persists across restarts in ~/.ruview-hap/accessory.state.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
@ -33,26 +34,93 @@ STATE_DIR = Path(os.path.expanduser("~/.ruview-hap"))
|
||||||
STATE_DIR.mkdir(exist_ok=True)
|
STATE_DIR.mkdir(exist_ok=True)
|
||||||
STATE_FILE = STATE_DIR / "accessory.state"
|
STATE_FILE = STATE_DIR / "accessory.state"
|
||||||
SETUP_CODE_FILE = STATE_DIR / "setup-code.txt"
|
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"))
|
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):
|
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
|
category = CATEGORY_SENSOR
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
serv = self.add_preload_service("MotionSensor")
|
s_motion = self.add_preload_service("MotionSensor")
|
||||||
self.char_motion = serv.configure_char("MotionDetected")
|
self.char_motion = s_motion.configure_char("MotionDetected")
|
||||||
self._last = False
|
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)
|
@Accessory.run_at_interval(1.0)
|
||||||
def run(self):
|
def run(self):
|
||||||
present = TOGGLE_FILE.exists()
|
state = _read_state_json()
|
||||||
if present != self._last:
|
if state is None:
|
||||||
self.char_motion.set_value(present)
|
motion = self._legacy_motion()
|
||||||
self._last = present
|
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(
|
print(
|
||||||
f"[hap-test] MotionDetected -> {present} (toggle file: {TOGGLE_FILE})",
|
"[hap] Unrecognized Activity Pattern fired (ProgrammableSwitch=0)",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -70,8 +138,10 @@ def main() -> int:
|
||||||
print(f"[hap-test] HAP bridge advertising as 'RuView Test Bridge'")
|
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] 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] Setup code (also in {SETUP_CODE_FILE}): {setup_code}")
|
||||||
print(f"[hap-test] Motion toggle file: {TOGGLE_FILE}")
|
print(f"[hap-test] State sources:")
|
||||||
print(f"[hap-test] State persists in: {STATE_FILE}")
|
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())
|
signal.signal(signal.SIGTERM, lambda *_: driver.stop())
|
||||||
driver.start()
|
driver.start()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue