feat(adr-125 tier1+2 iter 2): sensing-server-equivalent for @ruvnet/rvagent

scripts/ruview-sensing-server.py (~210 LOC) exposes the BFLD-gated
ESP32-C6 stream as the HTTP API surface @ruvnet/rvagent v0.1.0
(ADR-124, npm) expects. Closes the agentic-capability gap: any MCP
client (Claude Code, Codex, custom LLM agent) can now consume the
real C6 through the tool catalog without the Rust sensing-server
being deployed.

Endpoints (mirrors tools/ruview-mcp/src/tools/*.ts):

  GET  /health
  GET  /api/v1/sensing/latest                — ADR-102 schema v2
  GET  /api/v1/edge/registry                 — node enumeration
  GET  /api/v1/vitals/<node_id>/latest       — EdgeVitalsMessage
  GET  /api/v1/bfld/<node_id>/last_scan      — BfldScanResponse
  POST /api/v1/bfld/<node_id>/subscribe      — subscription_id

c6-presence-watcher.py now writes a companion `/tmp/ruview-last-
feature.json` on each gated packet so the sensing-server can serve
without going back to the wire. Atomic tmp+rename. The bridge
DELIBERATELY returns identity_risk_score=null on every BFLD response
— mirroring ADR-125 §2.1.d at the HTTP boundary even though the
rvagent schema's slot is nullable.

Live smoke test against the real C6 (node_id=12):

  $ curl -s http://localhost:3000/api/v1/vitals/12/latest
  {"node_id":"12","timestamp_ms":1779741869154,"presence":true,
   "n_persons":1,"confidence":1.0,"breathing_rate_bpm":18.75,
   "heartrate_bpm":40.0,"motion":1.0}

  $ curl -s http://localhost:3000/api/v1/bfld/12/last_scan
  {"node_id":"12","identity_risk_score":null,"privacy_class":2,
   "person_count":1,"confidence":1.0,"presence":true,
   "timestamp_ns":1779741869154607104}

  $ curl -s -X POST 'http://localhost:3000/api/v1/bfld/12/subscribe?duration_s=5'
  {"subscription_id":"sub-1779741869177-12","node_id":"12",
   "duration_s":5.0,"endpoint_hint":"poll GET ..."}

Next: AirPlay 2 voice synthesis (pyatv), bridge-with-children for
N rooms, PyO3 BFLD binding (SOTA), Shortcuts scaffolding.

Refs ADR-124 (@ruvnet/rvagent contract), ADR-125 §2.1.d, ADR-118.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-25 16:44:58 -04:00
parent 48db60a65e
commit 194a2e1637
2 changed files with 254 additions and 0 deletions

View File

@ -268,6 +268,37 @@ def main() -> int:
except OSError:
pass
# Companion contract for `scripts/ruview-sensing-server.py` (the
# @ruvnet/rvagent compatibility layer): write the full BFLD-gated
# feature snapshot so the sensing-server can serve EdgeVitalsMessage
# and BfldScanResponse without going back to the wire.
feature_path = "/tmp/ruview-last-feature.json"
def write_feature(gated: dict, motion: bool, occupancy: bool,
privacy_cls: int) -> None:
try:
tmp = feature_path + ".tmp"
with open(tmp, "w") as fh:
json.dump({
"node_id": str(gated["node_id"]),
"timestamp_ms": int(time.time() * 1000),
"presence": occupancy, # sustained
"motion": gated["motion"], # 0..1 float
"presence_score": gated["presence"],
"n_persons": 1 if occupancy else 0,
"confidence": min(1.0, max(0.0, gated["motion"])),
"breathing_rate_bpm": (gated["resp_bpm"]
if gated.get("resp_bpm") else None),
"heartrate_bpm": (gated["hb_bpm"]
if gated.get("hb_bpm") else None),
"anomaly_score": gated.get("anomaly"),
"privacy_class": privacy_cls,
"ts": time.time(),
}, fh)
os.replace(tmp, feature_path)
except OSError:
pass
while running:
try:
buf, _addr = sock.recvfrom(2048)
@ -336,6 +367,8 @@ def main() -> int:
if (motion != prev_motion
or not state_path.endswith(".disabled")):
write_state(motion, occupancy, last_anomaly_ts)
write_feature(gated, motion, occupancy,
privacy_class)
# Idle release — if the C6 stops sending entirely, clear motion
# AND occupancy.

View File

@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""
ruview-sensing-server.py ADR-125 Tier 1+2 iter 2.
A tiny HTTP server that speaks the subset of the RuView sensing-server
HTTP API that @ruvnet/rvagent (ADR-124, npm v0.1.0) expects, sourced
from the BFLD-gated state files written by c6-presence-watcher.py.
This is the "sensing-server-equivalent" the cron stop condition names,
and it lets any MCP agent (Claude Code via `claude mcp add rvagent`,
Codex with the matching MCP config, custom LLM client) consume the
real ESP32-C6 stream through the same MCP tool surface that the Rust
sensing-server exposes without needing the Rust binary to be running.
Endpoints (matched against tools/ruview-mcp/src/tools/*.ts):
GET /health liveness
GET /api/v1/sensing/latest ADR-102 schema v2
GET /api/v1/edge/registry node enumeration
GET /api/v1/vitals/<node_id>/latest EdgeVitalsMessage
GET /api/v1/bfld/<node_id>/last_scan BfldScanResponse
POST /api/v1/bfld/<node_id>/subscribe?duration_s=N { subscription_id }
The source-of-truth file is `/tmp/ruview-last-feature.json` written
by the watcher on every BFLD-gated feature_state packet. If absent
or stale (> STALENESS_S seconds old), endpoints return 503 with a
hint so the rvagent tool emits a graceful warn shape.
Bearer-token auth is intentionally OFF in this dev surface the
Rust sensing-server adds it via the #443 middleware; that path is
out of scope for the demo bridge.
"""
from __future__ import annotations
import json
import os
import re
import sys
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
FEATURE_FILE = os.environ.get("RUVIEW_FEATURE_JSON",
"/tmp/ruview-last-feature.json")
STALENESS_S = 10.0
DEFAULT_PORT = int(os.environ.get("PORT", "3000"))
def _load_feature() -> dict | None:
try:
with open(FEATURE_FILE, "r") as fh:
d = json.load(fh)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
if not isinstance(d, dict):
return None
age = time.time() - float(d.get("ts", 0))
if age > STALENESS_S:
return None
return d
def vitals_for(node_id: str) -> dict | None:
f = _load_feature()
if f is None or f.get("node_id") != node_id:
return None
return {
"node_id": f["node_id"],
"timestamp_ms": int(f.get("timestamp_ms",
int(time.time() * 1000))),
"presence": bool(f.get("presence", False)),
"n_persons": int(f.get("n_persons", 0)),
"confidence": float(f.get("confidence", 0.0)),
"breathing_rate_bpm": f.get("breathing_rate_bpm"),
"heartrate_bpm": f.get("heartrate_bpm"),
"motion": float(f.get("motion", 0.0)),
}
def bfld_scan_for(node_id: str) -> dict | None:
f = _load_feature()
if f is None or f.get("node_id") != node_id:
return None
# ADR-125 §2.1.d: identity_risk_score never crosses the HAP
# boundary. We mirror that here — even though rvagent's schema
# has a nullable identity_risk_score slot, we deliberately
# always return None for it on this bridge.
return {
"node_id": f["node_id"],
"identity_risk_score": None, # ADR-125 §2.1.d invariant
"privacy_class": int(f.get("privacy_class", 2)),
"person_count": int(f.get("n_persons", 0)),
"confidence": float(f.get("confidence", 0.0)),
"presence": bool(f.get("presence", False)),
# timestamp_ns matches BFLD wire format (BfldEvent.timestamp_ns)
"timestamp_ns": int(f.get("ts", time.time()) * 1_000_000_000),
}
_PATH_VITALS = re.compile(r"^/api/v1/vitals/([^/]+)/latest$")
_PATH_BFLD_SCAN = re.compile(r"^/api/v1/bfld/([^/]+)/last_scan$")
_PATH_BFLD_SUBSCRIBE = re.compile(r"^/api/v1/bfld/([^/]+)/subscribe$")
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
# Quiet the default per-request log; print on a single line.
sys.stdout.write(
f"[{self.log_date_time_string()}] {self.command} "
f"{self.path} -> {args[1] if len(args) > 1 else '?'}\n"
)
def _json(self, code: int, body: dict) -> None:
payload = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self) -> None:
parsed = urlparse(self.path)
path = parsed.path
if path == "/health":
f = _load_feature()
self._json(200, {
"ok": True,
"feature_age_s": (None if f is None
else round(time.time() - f["ts"], 2)),
"source": FEATURE_FILE,
})
return
if path == "/api/v1/edge/registry":
f = _load_feature()
nodes = ([{"node_id": f["node_id"], "kind": "esp32-c6",
"online": True}] if f else [])
self._json(200, {"nodes": nodes})
return
if path == "/api/v1/sensing/latest":
f = _load_feature()
if f is None:
self._json(503, {"error": "no recent feature_state",
"hint": "is c6-presence-watcher running?"})
return
# ADR-102 sensing/latest schema v2 — the rvagent
# csi-latest tool ingests this shape.
self._json(200, {
"schema_version": 2,
"node_id": f["node_id"],
"timestamp_ms": f["timestamp_ms"],
"presence": f["presence"],
"n_persons": f["n_persons"],
"confidence": f["confidence"],
"motion": f["motion"],
"breathing_rate_bpm": f.get("breathing_rate_bpm"),
"heartrate_bpm": f.get("heartrate_bpm"),
"privacy_class": f.get("privacy_class", 2),
})
return
m = _PATH_VITALS.match(path)
if m:
node_id = m.group(1)
v = vitals_for(node_id)
if v is None:
self._json(503, {"error": f"no recent vitals for {node_id}",
"hint": "watcher running? node_id correct?"})
return
self._json(200, v)
return
m = _PATH_BFLD_SCAN.match(path)
if m:
node_id = m.group(1)
r = bfld_scan_for(node_id)
if r is None:
self._json(503, {"error": f"no recent BFLD scan for {node_id}",
"hint": "watcher running? node_id correct?"})
return
self._json(200, r)
return
self._json(404, {"error": "not found", "path": path})
def do_POST(self) -> None:
parsed = urlparse(self.path)
m = _PATH_BFLD_SUBSCRIBE.match(parsed.path)
if m:
qs = parse_qs(parsed.query)
duration_s = float(qs.get("duration_s", ["10"])[0])
sub_id = f"sub-{int(time.time() * 1000)}-{m.group(1)}"
self._json(200, {
"subscription_id": sub_id,
"node_id": m.group(1),
"duration_s": duration_s,
"endpoint_hint": (f"poll GET /api/v1/bfld/{m.group(1)}"
"/last_scan every 1 s for the window"),
})
return
self._json(404, {"error": "not found", "path": parsed.path})
def main() -> int:
port = DEFAULT_PORT
server = HTTPServer(("0.0.0.0", port), Handler)
print(f"[sensing-server] listening on 0.0.0.0:{port}", flush=True)
print(f"[sensing-server] feature source: {FEATURE_FILE}", flush=True)
print(f"[sensing-server] staleness limit: {STALENESS_S} s", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
server.server_close()
return 0
if __name__ == "__main__":
sys.exit(main())