Compare commits

...

2 Commits

Author SHA1 Message Date
ruv 1f13aa96c2 feat(adr-125 iter 2): real C6 feature_state UDP → HAP characteristic
scripts/c6-presence-watcher.py parses the 60-byte
rv_feature_state_t struct (RV_FEATURE_STATE_MAGIC = 0xC5110006)
emitted by firmware/esp32-csi-node/main/rv_feature_state.[ch] at
1-10 Hz from the real ESP32-C6 on ruv.net, validates the IEEE CRC32
over bytes [0..end-4], gates on RV_QFLAG_PRESENCE_VALID, applies
hysteresis (entry 0.40 / release 0.20) plus a 5 s idle-release
fallback, and toggles /tmp/ruview-motion — the same touch-file
contract that the already-paired HAP bridge consumes.

E2E validated against real hardware (no mocks, no simulation):
  C6 (192.168.1.179, ch 5, RSSI -38)
   └─ UDP/5005 → mac-mini (192.168.1.166)
      └─ c6-presence-watcher.py (pid 8276)
         └─ /tmp/ruview-motion
            └─ hap-test-sensor.py (pid 84602)
               └─ HAP-1.1 over mDNS
                  └─ iPhone Home app: RuView Test Motion = True

10 s sample: pkts=63 valid=51 crc_bad=0  motion -> True

Iter 3 next: insert wifi-densepose-bfld PrivacyGate between the
UDP parse and the threshold so only class-2/3 frames cross the HAP
boundary (ADR-118 §2.2 invariant I1 holds at the HomeKit edge —
ADR-125 §2.1.d).

Refs ADR-125, ADR-118, ADR-081.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-25 16:20:11 -04:00
ruv 19b445f9bb chore(adr-125 iter 1): fix C6 COM port + ship HAP-python reference impl
Two changes from the ADR-125 e2e bootstrap session:

1. CLAUDE.md hardware table: COM4 -> COM12 for ESP32-C6 (the C6 +
   Seeed MR60BHA2 dev kit now enumerates on COM12 on ruvzen, not
   COM4 as previously documented). Same fix applied to the ESP32-S3
   row (COM7 -> COM9) which CLAUDE.local.md already covered but the
   top-level table had not been updated.

2. scripts/hap-test-sensor.py — the ~80 LOC HAP-python sidecar that
   ADR-125 §2.1.a names as the reference implementation. Already
   running on ruv-mac-mini, already paired with operator's iPhone
   (paired_clients: 1), already round-trips a MotionDetected
   characteristic from a touch-file toggle through the HomePod (as
   Home Hub) to the Home app.

Substrate validated for iter 2+:
  - C6 provisioned on ruv.net (IP 192.168.1.179, ch 5, RSSI -38)
  - UDP frames: 44 packets in 8s @ mac-mini:5005 (~5.5 pps)
  - HAP bridge paired and live

Refs ADR-125, #794.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-25 16:14:51 -04:00
3 changed files with 276 additions and 2 deletions
+2 -2
View File
@@ -73,9 +73,9 @@ All 5 ruvector crates integrated in workspace:
| Device | Port | Chip | Role | Cost |
|--------|------|------|------|------|
| ESP32-S3 (8MB flash) | COM7 | Xtensa dual-core | WiFi CSI sensing node | ~$9 |
| ESP32-S3 (8MB flash) | COM9 (ruvzen, was COM7) | Xtensa dual-core | WiFi CSI sensing node | ~$9 |
| ESP32-S3 SuperMini (4MB) | — | Xtensa dual-core | WiFi CSI (compact) | ~$6 |
| ESP32-C6 + Seeed MR60BHA2 | COM4 | RISC-V + 60 GHz FMCW | mmWave HR/BR/presence | ~$15 |
| ESP32-C6 + Seeed MR60BHA2 | COM12 (ruvzen, was COM4) | RISC-V + 60 GHz FMCW | mmWave HR/BR/presence + WiFi CSI | ~$15 |
| HLK-LD2410 | — | 24 GHz FMCW | Presence + distance | ~$3 |
**Not supported:** ESP32 (original), ESP32-C3 — single-core, can't run CSI DSP pipeline.
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""
c6-presence-watcher.py — ADR-125 iter 2.
Bridges real ESP32-C6 ADR-081 `rv_feature_state` UDP frames to the HAP
`MotionSensor` characteristic via the toggle file that
`scripts/hap-test-sensor.py` already pairs against. No mocks, no
simulation — consumes the exact 60-byte struct emitted by
`firmware/esp32-csi-node/main/rv_feature_state.[ch]`.
Wire format (RV_FEATURE_STATE_MAGIC = 0xC5110006, 60 bytes total,
__attribute__((packed))):
offset size field type
0 4 magic u32 = 0xC5110006
4 1 node_id u8
5 1 mode u8
6 2 seq u16
8 8 ts_us u64
16 4 motion_score f32 0..1, 100 ms window
20 4 presence_score f32 0..1, 1 s window
24 4 respiration_bpm f32
28 4 respiration_conf f32
32 4 heartbeat_bpm f32
36 4 heartbeat_conf f32
40 4 anomaly_score f32
44 4 env_shift_score f32
48 4 node_coherence f32
52 2 quality_flags u16
54 2 reserved u16
56 4 crc32 u32
`quality_flags & RV_QFLAG_PRESENCE_VALID (1<<0)` gates presence reads.
`presence_score >= PRESENCE_THRESHOLD` toggles motion ON; below the
release threshold (with hysteresis) toggles OFF. The toggle file
is the contract between this watcher and the paired HAP bridge.
Usage:
python3 c6-presence-watcher.py [--port 5005] [--toggle /tmp/ruview-motion]
"""
from __future__ import annotations
import argparse
import os
import signal
import socket
import struct
import sys
import time
import zlib
RV_FEATURE_STATE_MAGIC = 0xC5110006
RV_QFLAG_PRESENCE_VALID = 1 << 0
PACKET_SIZE = 60
# Hysteresis — entry / exit thresholds keep the HomeKit characteristic
# from flapping when presence_score sits near the boundary.
PRESENCE_ON_THRESHOLD = 0.40
PRESENCE_OFF_THRESHOLD = 0.20
# Idle releases motion after this many seconds with no valid presence
# packets (covers the C6 falling off the air entirely).
IDLE_RELEASE_S = 5.0
# 60-byte packed layout (`<` = little-endian + no padding)
# magic|node|mode|seq|ts|motion|presence|resp_bpm|resp_c|hb_bpm|hb_c|anom|env|coh|qflags|reserved|crc
PACKET_STRUCT = struct.Struct("<IBBHQfffffffffHHI")
assert PACKET_STRUCT.size == PACKET_SIZE, (
f"layout mismatch: struct {PACKET_STRUCT.size}, expected {PACKET_SIZE}"
)
def parse_packet(buf: bytes):
"""Return parsed dict or None if not a feature_state packet."""
if len(buf) != PACKET_SIZE:
return None
fields = PACKET_STRUCT.unpack(buf)
(magic, node_id, mode, seq, ts_us, motion, presence,
resp_bpm, resp_conf, hb_bpm, hb_conf,
anomaly, env_shift, coherence,
qflags, _reserved, crc) = fields
if magic != RV_FEATURE_STATE_MAGIC:
return None
# CRC32 over bytes [0..end-4]. Firmware uses IEEE poly == zlib.crc32.
expected = zlib.crc32(buf[:-4]) & 0xFFFFFFFF
crc_ok = expected == crc
return {
"node_id": node_id, "mode": mode, "seq": seq, "ts_us": ts_us,
"motion": motion, "presence": presence,
"resp_bpm": resp_bpm, "resp_conf": resp_conf,
"hb_bpm": hb_bpm, "hb_conf": hb_conf,
"anomaly": anomaly, "env_shift": env_shift, "coherence": coherence,
"qflags": qflags, "crc_ok": crc_ok,
"presence_valid": bool(qflags & RV_QFLAG_PRESENCE_VALID),
}
def set_motion(toggle_file: str, on: bool, current: bool) -> bool:
"""Touch / unlink the toggle file iff state changes. Return new state."""
if on == current:
return current
if on:
with open(toggle_file, "w") as fh:
fh.write("1\n")
else:
try:
os.unlink(toggle_file)
except FileNotFoundError:
pass
print(f"[{time.strftime('%H:%M:%S')}] motion -> {on}", flush=True)
return on
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--port", type=int, default=5005)
p.add_argument("--toggle", default="/tmp/ruview-motion")
p.add_argument("--bind", default="0.0.0.0")
args = p.parse_args()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if hasattr(socket, "SO_REUSEPORT"):
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
sock.bind((args.bind, args.port))
sock.settimeout(1.0)
print(f"[c6-presence] listening udp {args.bind}:{args.port}", flush=True)
print(f"[c6-presence] toggle file: {args.toggle}", flush=True)
print(f"[c6-presence] thresholds: on>={PRESENCE_ON_THRESHOLD}, "
f"off<={PRESENCE_OFF_THRESHOLD}, idle_release={IDLE_RELEASE_S}s",
flush=True)
running = True
def _stop(*_):
nonlocal running
running = False
signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop)
motion = os.path.exists(args.toggle)
last_packet_ts = 0.0
last_summary = time.time()
n_total = n_valid = n_crc_bad = 0
presence_sum = motion_sum = 0.0
while running:
try:
buf, _addr = sock.recvfrom(2048)
except socket.timeout:
buf = None
now = time.time()
if buf is not None:
n_total += 1
pkt = parse_packet(buf)
if pkt is not None:
if not pkt["crc_ok"]:
n_crc_bad += 1
elif pkt["presence_valid"]:
n_valid += 1
presence_sum += pkt["presence"]
motion_sum += pkt["motion"]
last_packet_ts = now
if not motion and pkt["presence"] >= PRESENCE_ON_THRESHOLD:
motion = set_motion(args.toggle, True, motion)
elif motion and pkt["presence"] <= PRESENCE_OFF_THRESHOLD:
motion = set_motion(args.toggle, False, motion)
# Idle release — if the C6 stops sending entirely, clear motion.
if motion and last_packet_ts and (now - last_packet_ts) > IDLE_RELEASE_S:
motion = set_motion(args.toggle, False, motion)
# Periodic summary line (every 10 s) so we can see the watcher is alive
if now - last_summary >= 10.0:
avg_p = presence_sum / n_valid if n_valid else 0.0
avg_m = motion_sum / n_valid if n_valid else 0.0
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}",
flush=True,
)
n_total = n_valid = n_crc_bad = 0
presence_sum = motion_sum = 0.0
last_summary = now
sock.close()
return 0
if __name__ == "__main__":
sys.exit(main())
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
hap-test-sensor.py — ADR-125 §2.1.a smoke test.
Stands up a single HomeKit Accessory Protocol (HAP-1.1) bridge with one
child MotionSensor named "RuView Test Motion". Once paired in the Apple
Home app, the HomePod (acting as Home Hub) sees state changes when
TOGGLE_FILE (default /tmp/ruview-motion) is touched / removed.
Usage:
python3 hap-test-sensor.py
Pair from iPhone: Home app -> Add Accessory -> More Options -> "RuView Test Bridge".
The setup code is printed on stdout AND written to ~/.ruview-hap/setup-code.txt.
Trigger motion: touch /tmp/ruview-motion
Clear motion: rm /tmp/ruview-motion
State persists across restarts in ~/.ruview-hap/accessory.state.
"""
from pathlib import Path
import os
import sys
import time
import signal
from pyhap.accessory import Accessory, Bridge
from pyhap.accessory_driver import AccessoryDriver
from pyhap.const import CATEGORY_SENSOR, CATEGORY_BRIDGE
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"
TOGGLE_FILE = Path(os.environ.get("RUVIEW_MOTION_TOGGLE", "/tmp/ruview-motion"))
class RuViewMotion(Accessory):
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
@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
print(
f"[hap-test] MotionDetected -> {present} (toggle file: {TOGGLE_FILE})",
flush=True,
)
def main() -> int:
driver = AccessoryDriver(port=51826, persist_file=str(STATE_FILE))
bridge = Bridge(driver, "RuView Test Bridge")
bridge.category = CATEGORY_BRIDGE
bridge.add_accessory(RuViewMotion(driver, "RuView Test Motion"))
driver.add_accessory(accessory=bridge)
setup_code = driver.state.pincode.decode() if hasattr(driver.state.pincode, "decode") else driver.state.pincode
SETUP_CODE_FILE.write_text(str(setup_code) + "\n")
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}")
signal.signal(signal.SIGTERM, lambda *_: driver.stop())
driver.start()
return 0
if __name__ == "__main__":
sys.exit(main())