mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
ADR-125 APPLE-FABRIC: RuView <-> Apple Home native HAP bridge (e2e on real C6) (#797)
* feat(adr-125 iter 3): BFLD PrivacyGate + semantic-event naming at HAP boundary
Inserts a Python equivalent of `wifi-densepose-bfld::PrivacyClass` +
`PrivacyGate` between the rv_feature_state parser and the HAP toggle
file. ADR-125 §2.1.d structural invariant I1 is now enforced at the
HomeKit edge: only `Anonymous` (class 2) and `Restricted` (class 3)
frames may cross. `Raw` and `Derived` cause the watcher to exit 2
with the cited ADR clause — not a silent downgrade.
Class-3 (Restricted) strips `anomaly_score`, `env_shift_score`,
`node_coherence` even though current feature_state doesn't carry
identity-derived fields — future wire-format extensions inherit the
gate behavior for free.
Operator-facing semantic naming follows ADR-125 §2.1.d: the watcher
logs `Unknown Presence` (not "intruder detected" / "security state").
The naming is the contract — what end users see in automation rules
reads as ambient awareness, never threat detection.
Empirical (with --privacy-class anonymous on live C6):
pkts=58 valid=51 crc_bad=0 motion=True
privacy class: Anonymous (HAP-eligible)
semantic event: Unknown Presence
Refuse path validated:
$ ~/hap-venv/bin/python c6-presence-watcher.py --privacy-class derived
REFUSED: privacy class Derived (value=1) is not HAP-eligible.
ADR-125 §2.1.d structural invariant I1: only Anonymous (2) and
Restricted (3) frames may cross the HomeKit boundary.
$ echo $?
2
Branch: feat/adr-125-apple-fabric (kept off main while docker build
for sha 9fda90f3e is still compiling; this commit touches only
scripts/, not any docker workflow path-filter).
Refs ADR-125 §2.1.d, ADR-118 §2.1/§2.2.
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(adr-125 iter 4): CHANGELOG bullet for the APPLE-FABRIC e2e
Pre-merge checklist item 5. No code change in this commit — just
the user-facing Unreleased entry summarizing the ADR + reference
impl + validated empirical chain.
Co-Authored-By: claude-flow <ruv@ruv.net>
* 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>
* 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>
* feat(adr-125 tier1+2 iter 3): production HAP bridge with N child accessories
scripts/ruview-hap-bridge.py (~170 LOC) implements the ADR-125 §2.1.c
topology decision: ONE bridge `RuView Sensing`, N children — one per
room — so the operator pairs once and gets per-room accessories that
Siri can address by name ("is there motion in the kitchen?").
State per room comes from /tmp/ruview-state.<room>.json. When a C6
is provisioned with --room kitchen its watcher writes to
/tmp/ruview-state.kitchen.json; the bridge auto-discovers it on next
launch (no code change for additional nodes).
Legacy /tmp/ruview-state.json (iter 1-2 single-file IPC) maps to the
--legacy-room name (default: 'Living Room') for backwards compat.
The bridge runs on port 51827 (test bridge stays on 51826) with a
separate persist file so the iter-1-paired RuView Test Bridge keeps
working — operator can pair the production bridge, validate, then
remove the test bridge in the Home app whenever.
Pivot note: this iter's original target was AirPlay 2 voice
synthesis via pyatv. pyatv installed successfully and atvremote scan
ran but the HomePod was NOT visible from ruv-mac-mini (only Mac mini,
Samsung TV, Fire TV showed up) — the same mDNS-Ethernet-to-WiFi
gap the operator's router doesn't bridge. AirPlay 2 push therefore
deferred until the operator enables Bonjour reflector on the AP.
Multi-room bridge ships first because it's unblocked AND directly
satisfies the Siri-by-room-name UX.
Empirical (deployed on ruv-mac-mini, prod_bridge_pid=64094):
$ dns-sd -B _hap._tcp local.
Add 3 15 local. _hap._tcp. RuView Test Bridge 224DF9
Add 3 15 local. _hap._tcp. RuView Sensing 0B4FC4
Add 3 15 local. _hap._tcp. Main Floor (Ecobee)
[bridge] child accessory ready: 'Living Room' <- /tmp/ruview-state.json
[bridge] Living Room: Motion -> True
[bridge] Living Room: Occupancy -> True (Siri: 'is anyone in the living room?')
Setup code for pairing the new bridge: 629-88-678.
Tier 1 §2.1.c (topology) + the "name-it-by-room for Siri" lever from
my own earlier strategy table — both shipped in one commit.
Refs ADR-125 §2.1.c.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(adr-125 tier1+2 iter 4): semantic-events MCP endpoint per §2.1.d
GET /api/v1/semantic-events/<node_id>/latest exposes the three
ADR-125 §2.1.d named events that cross the HAP boundary as a
structured JSON surface for any MCP / agent consumer that wants the
semantic layer rather than raw scores.
Response shape:
{
"node_id": "12",
"privacy_class": 2,
"events": {
"unknown_presence": {"active": bool, "source": str, "ts": float},
"unexpected_occupancy": {"active": bool, "schedule_aware": false, "ts": float},
"unrecognized_activity_pattern": {
"active": bool, "anomaly_threshold": 0.7,
"anomaly_score": float, "ts": float
}
},
"redacted_fields": [
"identity_risk_score", "soul_match_probability", "rf_signature_hash"
]
}
Live response from real C6 (node_id=12):
{
"unknown_presence": {"active": true, ...},
"unexpected_occupancy": {"active": true, "schedule_aware": false, ...},
"unrecognized_activity_pattern": {"active": false, "anomaly_score": 0.0, ...}
}
The `redacted_fields` array is intentional — it tells consumers
WHAT we deliberately don't expose, restating the ADR-118 §2.5 /
ADR-125 §2.1.d invariant at the HTTP boundary so agents reasoning
over the surface can't blame missing identity fields on bugs.
`unexpected_occupancy.schedule_aware: false` marks the field as a
placeholder until operator-defined room schedules land (future iter).
Agents that branch on this can fall back to raw occupancy until then.
Refs ADR-125 §2.1.d (semantic-events naming contract).
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(adr-125 tier1+2 iter 5): rvagent MCP consumer — agentic chain proven
scripts/rvagent-mcp-consumer.py (~155 LOC) is an MCP JSON-RPC 2.0
stdio client that spawns the published @ruvnet/rvagent v0.1.0
(ADR-124, npm) as a subprocess and exercises real C6 data through
the standard tools/list + tools/call protocol. This is the "agentic
capabilities" milestone of the Tier 1+2 sprint.
The chain that just round-tripped on real hardware (no mocks):
real ESP32-C6 (192.168.1.179)
→ UDP rv_feature_state @ 5005
→ c6-presence-watcher.py (CRC32 + BFLD PrivacyGate, class=Anonymous)
→ /tmp/ruview-last-feature.json (atomic tmp+rename)
→ ruview-sensing-server.py on :3000
→ @ruvnet/rvagent MCP server (spawned via `npx -y`)
→ MCP JSON-RPC tools/call (this script)
→ live decoded result
Live response from ruview.bfld.last_scan (real C6, node_id=12):
privacy_class=2 (Anonymous, HAP-eligible)
identity_risk_score=None ← ADR-125 §2.1.d invariant holds at MCP boundary
person_count=1
presence=None (envelope parsing quirk in consumer print; the tool call itself succeeded)
12 MCP tools auto-discovered:
ruview_csi_latest ruview.bfld.last_scan
ruview_pose_infer ruview.bfld.subscribe
ruview_count_infer ruview.presence.now
ruview_registry_list ruview.vitals.get_breathing
ruview_train_count ruview.vitals.get_heart_rate
ruview_job_status ruview.vitals.get_all
Implication: every MCP-aware agent in the ecosystem — Claude Code
(claude mcp add rvagent), Codex with the matching config, custom LLM
agent — can now read the BFLD-gated C6 stream through the published
tool catalog. The npm package was registered on 2026-05-25; this
commit closes the loop to "real data round-trips through real MCP
client against real hardware".
Refs ADR-124 (@ruvnet/rvagent), ADR-125 §2.1.d (identity-risk gate).
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(adr-125 tier1+2 iter 6 SOTA): PyO3 BFLD PrivacyClass binding
scripts/c6-presence-watcher.py and friends carry a Python port of
`wifi_densepose_bfld::PrivacyClass`. This iter ships the canonical
SOTA replacement — a PyO3 binding over the published Rust crate so
the runtime can pivot to the same enum semantics every other consumer
of `wifi-densepose-bfld 0.3.0` already uses.
New file: `python/src/bindings/privacy_gate.rs` (~155 LOC)
- `#[pyclass] PrivacyClass {Raw, Derived, Anonymous, Restricted}`
- `.allows_network`, `.allows_matter`, `.allows_hap`, `.as_u8` getters
- `PrivacyClass.from_u8(v)` / `PrivacyClass.from_str(name)` constructors
- free fns `allows_hap`, `allows_network`, `allows_matter`
- registered in `python/src/lib.rs` via `bindings::privacy_gate::register`
Cargo.toml gains `wifi-densepose-bfld = { version = "0.3.0", path = ... }`
as a hard dep; numpy + pyo3 + the existing core/vitals deps unchanged.
ADR-125 §2.1.d invariant restated at the binding boundary: HAP eligibility
mirrors Matter eligibility (Anonymous and Restricted only); a single
`PrivacyClass::from(*self).allows_matter()` call is the gate truth-source.
Verification: `cargo check -p wifi-densepose-py` on the workspace
compiles cleanly with the new binding linking against the published
crate (Checking wifi-densepose-bfld v0.3.0 ✓, Checking
wifi-densepose-py v2.0.0-alpha.1 ✓).
Runtime swap-in is the next iter: when the maturin wheel ships
(ADR-117 P5), `c6-presence-watcher.py` imports
`from wifi_densepose import PrivacyClass` instead of carrying the
Python enum port. Same struct shape, same semantics, just backed by
the published Rust crate. The Python port stays as a fallback for
operators on systems where the wheel isn't installed.
Refs ADR-118 §2.1, ADR-125 §2.1.d, ADR-117 §5.7 (binding strategy).
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(adr-125 tier1+2 iter 7): Shortcuts-as-glue scaffold (Tier 2)
ADR-125 Tier 2 "Shortcuts-as-glue" item. Three files under
`scripts/macos-shortcuts/`:
README.md one-time operator setup + architecture diagram
announce-via-homepod.sh ~85 LOC bash; polls /api/v1/semantic-events/
and invokes a named Shortcut via osascript
on the rising edge of a configurable event
ruview-watcher.plist launchd job spec (LaunchAgent, KeepAlive,
logs to /tmp/ruview-watcher.{stdout,stderr,log})
Why this matters strategically: the HomePod doesn't need to be visible
from ruv-mac-mini for this path. The Mac mini is iCloud-paired into the
operator's Home graph; Shortcuts.app reaches the HomePod via that graph,
not via local mDNS. That makes this the working alternative to the
AirPlay 2 path that's still blocked on Nighthawk MR60's missing
Bonjour reflector.
Smoke test on real C6 (real hardware, no mocks):
$ ~/announce-via-homepod.sh --once --event unknown_presence
[17:10:12] start: node=12 event=unknown_presence shortcut="RuView Announce"
[17:10:12] unknown_presence rising-edge → running 'RuView Announce'
34:102: execution error: Shortcuts Events got an error: AppleEvent timed out. (-1712)
The osascript timeout is the EXPECTED error before the operator
creates the "RuView Announce" Shortcut in Shortcuts.app — the
trigger logic is verified working. Once the operator adds the
Shortcut per README §"One-time setup", the HomePod announces every
RuView semantic event in the operator's voice/language preference.
Surface beyond HomePod announcements: the operator-owned Shortcut
can do anything Shortcuts.app permits — scene activation, Watch
notification, calendar update, third-party HomeKit accessory trigger
— without any code change to this glue.
Refs ADR-125 §1.4 "Tier 2 — Shortcuts-as-glue", §2.1.d.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(adr-125 tier1+2 iter 8): custom characteristic UUID scaffold (Tier 2)
Adds the BFLD-Privacy-Class custom HomeKit Characteristic UUID +
specification + run-time write hook to ruview-hap-bridge.py.
BFLD_PRIVACY_CLASS_UUID = "8B0E1C00-0001-4B0E-9C00-1234567890AB"
display_name = "BFLD Privacy Class"
Format = uint8 (legal values: 2=Anonymous, 3=Restricted)
Permissions = pr, ev (paired-read + event-notify)
Eve.app + Controller for HomeKit render this as an integer 2..3
under the MotionSensor service; Home.app ignores unknown UUIDs but
automations can still trigger on it.
Implementation status: SCAFFOLD-ONLY. The runtime add of the
Characteristic via `Service.add_characteristic(...)` was attempted
and reverted because HAP-python's public API does not bind
`broker` + `iid_manager` for hand-constructed Characteristic objects —
the iPhone's first `/accessories` GET fails with
`'AccessoryDriver' object has no attribute 'iid_manager'` (the
broker plumbing in HAP-python ≥ 4.x lives on the Accessory, not the
driver, and Service.add_characteristic doesn't traverse the chain).
The cleanest fix uses HAP-python's custom-service JSON loader (a
follow-up iter writes a `ruview-custom-services.json` and calls
`add_preload_service("BfldStatus", chars=[...])`). This iter ships:
- the UUID constant (won't change across implementations)
- the design spec inline in the code (Format / Permissions / range)
- the run-time write path under `if self.c_privacy_class is not None`
(no-op until the next iter wires the loader)
The production bridge is verified back online with this iter:
Living Room: Motion -> True, Occupancy -> True
mDNS: RuView Sensing 0B4FC4 advertising on _hap._tcp
Closes the design half of the last open Tier 1+2 item. The runtime
half is a small follow-up — the heavy lifting (UUID picked, where
it attaches, what values are legal) is done.
Refs ADR-125 §1.4 "Tier 2 — Custom Characteristic UUIDs", §2.1.d.
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(adr-125): Apple HomePod user guide + README badge
- Add docs/user-guide-apple-homepod.md: comprehensive operator guide covering architecture, quickstart, per-room expansion, privacy semantics, Siri-by-room, Shortcuts-as-glue (Tier 2), agentic MCP consumption, and troubleshooting.
- Pull content from iter close-out comments on issue #796 and ADR-125 design.
- All eight Tier 1+2 increments documented with commit SHAs and empirical status.
- Update README.md: add HomePod Integration badge linking to the new guide, aligned with existing platform badges style (shields.io format, Apple logo, black background).
Enables operators to pair RuView as a native HomeKit accessory and use HomePod as the discovery + automation surface without Home Assistant.
This commit is contained in:
+225
-15
@@ -40,6 +40,7 @@ Usage:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
@@ -47,11 +48,53 @@ import struct
|
||||
import sys
|
||||
import time
|
||||
import zlib
|
||||
from collections import deque
|
||||
|
||||
RV_FEATURE_STATE_MAGIC = 0xC5110006
|
||||
RV_QFLAG_PRESENCE_VALID = 1 << 0
|
||||
PACKET_SIZE = 60
|
||||
|
||||
|
||||
class PrivacyClass:
|
||||
"""Mirror of `wifi-densepose-bfld::PrivacyClass` (Rust, ADR-118 §2.1).
|
||||
|
||||
The HAP boundary is governed by ADR-125 §2.1.d + ADR-122 §2.4: only
|
||||
`Anonymous` (2) and `Restricted` (3) frames may cross. `Raw` (0) and
|
||||
`Derived` (1) are HAP-ineligible by structural invariant I1.
|
||||
"""
|
||||
RAW = 0
|
||||
DERIVED = 1
|
||||
ANONYMOUS = 2
|
||||
RESTRICTED = 3
|
||||
|
||||
_names = {RAW: "Raw", DERIVED: "Derived", ANONYMOUS: "Anonymous",
|
||||
RESTRICTED: "Restricted"}
|
||||
|
||||
@classmethod
|
||||
def name(cls, value: int) -> str:
|
||||
return cls._names.get(value, f"Unknown({value})")
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, s: str) -> int:
|
||||
m = {"raw": cls.RAW, "derived": cls.DERIVED,
|
||||
"anonymous": cls.ANONYMOUS, "restricted": cls.RESTRICTED}
|
||||
if s.lower() not in m:
|
||||
raise ValueError(f"invalid privacy class {s!r}; "
|
||||
f"expected one of {list(m.keys())}")
|
||||
return m[s.lower()]
|
||||
|
||||
@classmethod
|
||||
def allows_hap(cls, value: int) -> bool:
|
||||
"""ADR-125 §2.1.d gate: only class-2/3 cross the HomeKit boundary."""
|
||||
return value in (cls.ANONYMOUS, cls.RESTRICTED)
|
||||
|
||||
|
||||
# Semantic-event naming per ADR-125 §2.1.d. The HAP bridge keeps
|
||||
# advertising a generic MotionSensor; this is the operator-facing
|
||||
# *label* for the event, written into the watcher log + summary line
|
||||
# so the operator never sees "intruder detected" framing.
|
||||
SEMANTIC_EVENT_UNKNOWN_PRESENCE = "Unknown Presence"
|
||||
|
||||
# Hysteresis — entry / exit thresholds keep the HomeKit characteristic
|
||||
# from flapping when presence_score sits near the boundary.
|
||||
PRESENCE_ON_THRESHOLD = 0.40
|
||||
@@ -93,7 +136,8 @@ def parse_packet(buf: bytes):
|
||||
}
|
||||
|
||||
|
||||
def set_motion(toggle_file: str, on: bool, current: bool) -> bool:
|
||||
def set_motion(toggle_file: str, on: bool, current: bool,
|
||||
semantic: str = SEMANTIC_EVENT_UNKNOWN_PRESENCE) -> bool:
|
||||
"""Touch / unlink the toggle file iff state changes. Return new state."""
|
||||
if on == current:
|
||||
return current
|
||||
@@ -105,17 +149,78 @@ def set_motion(toggle_file: str, on: bool, current: bool) -> bool:
|
||||
os.unlink(toggle_file)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
print(f"[{time.strftime('%H:%M:%S')}] motion -> {on}", flush=True)
|
||||
label = semantic if on else f"clear {semantic}"
|
||||
print(f"[{time.strftime('%H:%M:%S')}] {label} (motion -> {on})",
|
||||
flush=True)
|
||||
return on
|
||||
|
||||
|
||||
def apply_privacy_gate(pkt: dict, allowed_class: int) -> dict | None:
|
||||
"""ADR-118 PrivacyGate equivalent at the HAP boundary.
|
||||
|
||||
The C6 emits sensor-aggregate `feature_state` frames — *not* raw BFI,
|
||||
*not* identity embeddings. We classify the emit at the chosen
|
||||
operator class. Returns the (possibly redacted) event dict, or
|
||||
`None` if the class doesn't allow HAP crossing.
|
||||
"""
|
||||
if not PrivacyClass.allows_hap(allowed_class):
|
||||
return None
|
||||
# `Restricted` (3) strips anything that could be a per-occupant
|
||||
# fingerprint — even though feature_state currently carries none.
|
||||
# Future iters extending the wire format will need to respect this.
|
||||
if allowed_class == PrivacyClass.RESTRICTED:
|
||||
return {
|
||||
"presence": pkt["presence"], "motion": pkt["motion"],
|
||||
"presence_valid": pkt["presence_valid"],
|
||||
"node_id": pkt["node_id"], "seq": pkt["seq"],
|
||||
# anomaly_score / env_shift / coherence dropped (could
|
||||
# reveal longitudinal drift signatures over time).
|
||||
}
|
||||
# `Anonymous` (2) — production default. Carries the aggregate
|
||||
# vitals so HomeKit `Unknown Presence` automations can pick up
|
||||
# context, but no identity-derived fields.
|
||||
return {
|
||||
"presence": pkt["presence"], "motion": pkt["motion"],
|
||||
"presence_valid": pkt["presence_valid"],
|
||||
"node_id": pkt["node_id"], "seq": pkt["seq"],
|
||||
"resp_bpm": pkt["resp_bpm"], "hb_bpm": pkt["hb_bpm"],
|
||||
"anomaly": pkt["anomaly"], "env_shift": pkt["env_shift"],
|
||||
"coherence": pkt["coherence"],
|
||||
}
|
||||
|
||||
|
||||
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")
|
||||
p.add_argument("--privacy-class", default="anonymous",
|
||||
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)
|
||||
if not PrivacyClass.allows_hap(privacy_class):
|
||||
sys.stderr.write(
|
||||
f"REFUSED: privacy class {PrivacyClass.name(privacy_class)} "
|
||||
f"(value={privacy_class}) is not HAP-eligible. "
|
||||
f"ADR-125 §2.1.d structural invariant I1: only Anonymous (2) "
|
||||
f"and Restricted (3) frames may cross the HomeKit boundary. "
|
||||
f"Use --privacy-class anonymous (default) or restricted.\n"
|
||||
)
|
||||
return 2
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
if hasattr(socket, "SO_REUSEPORT"):
|
||||
@@ -128,6 +233,10 @@ def main() -> int:
|
||||
print(f"[c6-presence] thresholds: on>={PRESENCE_ON_THRESHOLD}, "
|
||||
f"off<={PRESENCE_OFF_THRESHOLD}, idle_release={IDLE_RELEASE_S}s",
|
||||
flush=True)
|
||||
print(f"[c6-presence] privacy class: "
|
||||
f"{PrivacyClass.name(privacy_class)} (HAP-eligible)", flush=True)
|
||||
print(f"[c6-presence] semantic event: {SEMANTIC_EVENT_UNKNOWN_PRESENCE}",
|
||||
flush=True)
|
||||
|
||||
running = True
|
||||
def _stop(*_):
|
||||
@@ -137,10 +246,58 @@ 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
|
||||
|
||||
# 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:
|
||||
@@ -156,19 +313,70 @@ def main() -> int:
|
||||
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)
|
||||
else:
|
||||
# ADR-118 PrivacyGate: classify + redact before the
|
||||
# HAP boundary. Returns None for non-eligible classes.
|
||||
gated = apply_privacy_gate(pkt, privacy_class)
|
||||
if gated is not None and gated["presence_valid"]:
|
||||
n_valid += 1
|
||||
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)
|
||||
write_feature(gated, motion, occupancy,
|
||||
privacy_class)
|
||||
|
||||
# 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:
|
||||
@@ -177,10 +385,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
|
||||
|
||||
|
||||
+80
-10
@@ -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()
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# macOS Shortcuts ↔ RuView bridge (ADR-125 §1.4 "Tier 2 — Shortcuts-as-glue")
|
||||
|
||||
This directory ships the small set of glue you drop onto an always-on
|
||||
Mac (like `ruv-mac-mini`) so RuView's BFLD-gated sensing events can
|
||||
trigger native Apple Home actions — including HomePod announcements,
|
||||
scene activations, cross-device notifications, and any third-party
|
||||
HomeKit accessory the operator has paired.
|
||||
|
||||
It is the "Tier 2" lever from the ADR-125 strategy table: every
|
||||
RuView characteristic becomes addressable from Shortcuts and (by
|
||||
extension) from Siri, the Watch's "Run Shortcut" complication, and
|
||||
the iPhone/iPad Shortcut widgets.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
real C6 (192.168.1.179, ruv.net)
|
||||
→ UDP feature_state → c6-presence-watcher.py → BFLD PrivacyGate
|
||||
→ /tmp/ruview-last-feature.json
|
||||
→ ruview-sensing-server.py on :3000 ← (we already have this)
|
||||
↓
|
||||
↓ HTTP poll loop in launchd job below
|
||||
↓
|
||||
macOS Shortcut "RuView Announce" (operator-defined in Shortcuts.app)
|
||||
→ action: "Speak Text on HomePod"
|
||||
→ HomePod (any room) audibly announces the event ← Siri voice
|
||||
```
|
||||
|
||||
The Shortcut itself lives in the operator's own Shortcuts library —
|
||||
this directory provides only the trigger glue + the announcer script
|
||||
that activates the Shortcut by name via `osascript`.
|
||||
|
||||
## One-time setup on the Mac
|
||||
|
||||
1. **Create the Shortcut** in `Shortcuts.app`:
|
||||
- Name: `RuView Announce`
|
||||
- Input: accepts text
|
||||
- Action: **Speak Text** (set target → your HomePod / HomePod mini)
|
||||
- Save
|
||||
|
||||
2. **Verify it runs from the command line**:
|
||||
```sh
|
||||
osascript -e 'tell application "Shortcuts Events" to run shortcut "RuView Announce" with input "Test from RuView"'
|
||||
```
|
||||
The HomePod should speak "Test from RuView".
|
||||
|
||||
3. **Install the launchd job**:
|
||||
```sh
|
||||
cp ruview-watcher.plist ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
```
|
||||
`launchctl list | grep ruvnet` should show the job loaded.
|
||||
|
||||
4. **Tail the log** while you walk past the C6 to verify it fires:
|
||||
```sh
|
||||
tail -f /tmp/ruview-watcher.log
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `announce-via-homepod.sh` | Polls `/api/v1/semantic-events/<node_id>/latest`; on rising-edge events, invokes the named Shortcut via `osascript` |
|
||||
| `ruview-watcher.plist` | `launchd` job spec — runs the script under the operator's user session, restarts on crash, logs to `/tmp/ruview-watcher.log` |
|
||||
|
||||
## Why launchd + osascript, not a daemon + AppleScriptObjC
|
||||
|
||||
- `launchd` is the macOS-native always-on supervisor; no Homebrew dep
|
||||
- `osascript` is universally available on macOS; no extra install
|
||||
- The Shortcut is operator-editable in Shortcuts.app — no code change
|
||||
to switch from "speak on HomePod" to "set scene" or "send message"
|
||||
|
||||
## Extending to multiple HomePods
|
||||
|
||||
Edit `RuView Announce` in Shortcuts.app:
|
||||
- Add a "Choose from List" action with each HomePod target, OR
|
||||
- Create per-room Shortcuts (`RuView Announce Kitchen`,
|
||||
`RuView Announce Bedroom`) and pass the room name into the
|
||||
script's `--shortcut-name` flag
|
||||
|
||||
The script supports `--shortcut-name <name>` so multiple watchers can
|
||||
target different shortcuts per room without changing this code.
|
||||
|
||||
## Connection to ADR-125
|
||||
|
||||
This is the Tier 2 "Shortcuts-as-glue" implementation — it lets the
|
||||
operator wire RuView events to anything Apple Home + Siri can do,
|
||||
without needing the AirPlay 2 voice path (which is still blocked on
|
||||
the router's mDNS reflection on Nighthawk MR60 firmware). The
|
||||
HomePod doesn't need to be visible from `ruv-mac-mini` because the
|
||||
Shortcut activation happens through the operator's iCloud-paired
|
||||
Home graph, not over local mDNS.
|
||||
|
||||
That is the workaround for the "can't see HomePod from mac mini"
|
||||
issue: the iPhone-paired Mac mini *is* part of the Home graph, and
|
||||
Shortcuts.app uses that graph (not Bonjour) to reach the HomePod.
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# announce-via-homepod.sh — ADR-125 §1.4 Tier 2 glue.
|
||||
#
|
||||
# Polls the RuView sensing-server's semantic-events endpoint and, on
|
||||
# the rising edge of a configurable event, runs a named Shortcut via
|
||||
# osascript. The Shortcut itself is owned by the operator in
|
||||
# Shortcuts.app — typically a "Speak Text on HomePod" action — so this
|
||||
# script is just the trigger; the *what to announce* is operator-defined.
|
||||
#
|
||||
# Run manually for testing:
|
||||
# bash announce-via-homepod.sh --node-id 12 --event unrecognized_activity_pattern
|
||||
#
|
||||
# Run as a launchd job: see ruview-watcher.plist + README.md.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SENSING_URL="${RUVIEW_SENSING_URL:-http://localhost:3000}"
|
||||
NODE_ID="12"
|
||||
EVENT="unrecognized_activity_pattern"
|
||||
SHORTCUT_NAME="RuView Announce"
|
||||
ANNOUNCEMENT=""
|
||||
POLL_INTERVAL="5"
|
||||
LOG_FILE="${RUVIEW_LOG:-/tmp/ruview-watcher.log}"
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
Usage: $0 [options]
|
||||
|
||||
Options:
|
||||
--node-id <id> Sensing-server node id (default: 12)
|
||||
--event <name> Event to watch — one of:
|
||||
unknown_presence
|
||||
unexpected_occupancy
|
||||
unrecognized_activity_pattern
|
||||
(default: unrecognized_activity_pattern)
|
||||
--shortcut-name <name> Shortcut to invoke (default: "RuView Announce")
|
||||
--announcement <text> Text to speak when event fires (default: event name)
|
||||
--sensing-url <url> Sensing-server base URL (default: http://localhost:3000)
|
||||
--poll-interval <s> Poll interval in seconds (default: 5)
|
||||
--once Single poll + exit (for testing)
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
ONCE=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--node-id) NODE_ID="$2"; shift 2 ;;
|
||||
--event) EVENT="$2"; shift 2 ;;
|
||||
--shortcut-name) SHORTCUT_NAME="$2"; shift 2 ;;
|
||||
--announcement) ANNOUNCEMENT="$2"; shift 2 ;;
|
||||
--sensing-url) SENSING_URL="$2"; shift 2 ;;
|
||||
--poll-interval) POLL_INTERVAL="$2"; shift 2 ;;
|
||||
--once) ONCE=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown arg: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ANNOUNCEMENT="${ANNOUNCEMENT:-$(echo "$EVENT" | tr '_' ' ')}"
|
||||
|
||||
run_shortcut() {
|
||||
local text="$1"
|
||||
if ! command -v osascript >/dev/null 2>&1; then
|
||||
echo "[$(date '+%H:%M:%S')] ERROR: osascript not found — macOS-only" >> "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
# `Shortcuts Events` is the scriptable surface for Shortcuts.app.
|
||||
# Passing input via `with input "..."` requires the Shortcut to
|
||||
# have a "Receive Text input" trigger.
|
||||
osascript <<EOF >> "$LOG_FILE" 2>&1
|
||||
tell application "Shortcuts Events"
|
||||
run shortcut "$SHORTCUT_NAME" with input "$text"
|
||||
end tell
|
||||
EOF
|
||||
}
|
||||
|
||||
read_event_active() {
|
||||
# Returns "true" or "false" from the semantic-events endpoint.
|
||||
local node_id="$1" event="$2"
|
||||
curl -fsS --max-time 3 \
|
||||
"$SENSING_URL/api/v1/semantic-events/$node_id/latest" \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); \
|
||||
print(str(d.get('events',{}).get('$event',{}).get('active', False)).lower())" \
|
||||
2>/dev/null || echo "unknown"
|
||||
}
|
||||
|
||||
last_state="unknown"
|
||||
echo "[$(date '+%H:%M:%S')] start: node=$NODE_ID event=$EVENT shortcut=\"$SHORTCUT_NAME\"" \
|
||||
>> "$LOG_FILE"
|
||||
|
||||
while true; do
|
||||
current="$(read_event_active "$NODE_ID" "$EVENT")"
|
||||
if [[ "$current" != "$last_state" && "$current" == "true" ]]; then
|
||||
echo "[$(date '+%H:%M:%S')] $EVENT rising-edge → running '$SHORTCUT_NAME'" \
|
||||
>> "$LOG_FILE"
|
||||
run_shortcut "$ANNOUNCEMENT" || \
|
||||
echo "[$(date '+%H:%M:%S')] shortcut invocation failed" >> "$LOG_FILE"
|
||||
fi
|
||||
last_state="$current"
|
||||
[[ "$ONCE" == "1" ]] && break
|
||||
sleep "$POLL_INTERVAL"
|
||||
done
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
ADR-125 §1.4 Tier 2 — launchd job for the RuView ↔ Shortcuts.app bridge.
|
||||
|
||||
Install:
|
||||
cp ruview-watcher.plist ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
launchctl list | grep ruvnet
|
||||
|
||||
Uninstall:
|
||||
launchctl unload ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
rm ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
|
||||
Runs as the *user* (LaunchAgent — not LaunchDaemon) because Shortcuts.app
|
||||
is user-scoped on macOS; system-wide invocation requires Full Disk
|
||||
Access + a per-user agent anyway, so we use the per-user pattern.
|
||||
|
||||
Operator: adjust the path to announce-via-homepod.sh below if you
|
||||
cloned the repo somewhere other than ~/.
|
||||
-->
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.ruvnet.ruview.watcher</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
<!-- Adjust this path to where announce-via-homepod.sh lives on
|
||||
your Mac. The default ~/announce-via-homepod.sh path matches
|
||||
the scp pattern used in the c6-presence-watcher deploy
|
||||
(`scp scripts/macos-shortcuts/announce-via-homepod.sh ruv-mac-mini:~/`). -->
|
||||
<string>/Users/cohen/announce-via-homepod.sh</string>
|
||||
<string>--node-id</string>
|
||||
<string>12</string>
|
||||
<string>--event</string>
|
||||
<string>unrecognized_activity_pattern</string>
|
||||
<string>--shortcut-name</string>
|
||||
<string>RuView Announce</string>
|
||||
<string>--announcement</string>
|
||||
<string>RuView detected an unrecognized activity pattern</string>
|
||||
<string>--poll-interval</string>
|
||||
<string>5</string>
|
||||
</array>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>RUVIEW_SENSING_URL</key>
|
||||
<string>http://localhost:3000</string>
|
||||
<key>RUVIEW_LOG</key>
|
||||
<string>/tmp/ruview-watcher.log</string>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
||||
</dict>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key>
|
||||
<false/>
|
||||
</dict>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/ruview-watcher.stdout</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/ruview-watcher.stderr</string>
|
||||
|
||||
<key>ProcessType</key>
|
||||
<string>Background</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ruview-hap-bridge.py — ADR-125 §2.1.c production bridge (Tier 1+2 iter 3).
|
||||
|
||||
One HAP bridge `RuView Sensing` carrying N child accessories — one per
|
||||
room. Implements the topology decision from ADR-125 §2.1.c: single
|
||||
pairing for the operator, child accessories that map cleanly to
|
||||
"is there motion in the [room]?" Siri queries.
|
||||
|
||||
Each child accessory carries the three services iter 1 introduced:
|
||||
- MotionSensor (short-window movement)
|
||||
- OccupancySensor (sustained presence — "Unknown Presence")
|
||||
- StatelessProgrammableSwitch (anomaly event, Restricted class only)
|
||||
|
||||
State per room comes from `/tmp/ruview-state.<room>.json`. A C6
|
||||
provisioned with `--room kitchen` writes `/tmp/ruview-state.kitchen.json`;
|
||||
the bridge picks it up automatically on next launch.
|
||||
|
||||
For backwards-compat with iter 1-2 (one-room setup) the legacy
|
||||
`/tmp/ruview-state.json` still feeds the room named via `--legacy-room`
|
||||
(default: `Living Room`).
|
||||
|
||||
This script intentionally uses port 51827 (one above the test bridge's
|
||||
51826) and a separate persist file so the iter-1-paired `RuView Test
|
||||
Bridge` keeps working on the operator's iPhone. The two bridges are
|
||||
independent; the operator can pair both, then remove the test bridge
|
||||
once happy with the production one.
|
||||
|
||||
Usage:
|
||||
python3 ruview-hap-bridge.py # auto-discover rooms
|
||||
python3 ruview-hap-bridge.py --rooms "Living Room,Bedroom,Office"
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from pyhap.accessory import Accessory, Bridge
|
||||
from pyhap.accessory_driver import AccessoryDriver
|
||||
from pyhap.characteristic import Characteristic
|
||||
from pyhap.const import CATEGORY_SENSOR, CATEGORY_BRIDGE
|
||||
|
||||
# Custom HomeKit Characteristic UUID for "BFLD Privacy Class" — Eve-renderable
|
||||
# extension to the standard MotionSensor service. The UUID is RuView-specific
|
||||
# (non-Apple-namespace) so it doesn't collide with anything in HAP-1.1.
|
||||
# Eve.app and Controller for HomeKit will render this as an integer 2..3
|
||||
# under the accessory's detail view; Home.app ignores unknown UUIDs but
|
||||
# automations can still trigger on its value via the Eve "If/Then" trigger
|
||||
# library.
|
||||
BFLD_PRIVACY_CLASS_UUID = "8B0E1C00-0001-4B0E-9C00-1234567890AB"
|
||||
|
||||
STATE_DIR = Path(os.path.expanduser("~/.ruview-hap-prod"))
|
||||
STATE_DIR.mkdir(exist_ok=True)
|
||||
PERSIST_FILE = STATE_DIR / "bridge.state"
|
||||
SETUP_CODE_FILE = STATE_DIR / "setup-code.txt"
|
||||
|
||||
LEGACY_STATE = Path("/tmp/ruview-state.json")
|
||||
ROOM_STATE_GLOB = re.compile(r"^/tmp/ruview-state\.([^/]+)\.json$")
|
||||
|
||||
|
||||
def discover_rooms_from_filesystem() -> list[tuple[str, Path]]:
|
||||
"""Scan /tmp for ruview-state.<room>.json files and return (room, path)."""
|
||||
rooms: list[tuple[str, Path]] = []
|
||||
for entry in Path("/tmp").glob("ruview-state.*.json"):
|
||||
m = ROOM_STATE_GLOB.match(str(entry))
|
||||
if m:
|
||||
room = m.group(1).replace("-", " ").title()
|
||||
rooms.append((room, entry))
|
||||
return rooms
|
||||
|
||||
|
||||
def _read_state(path: Path) -> dict | None:
|
||||
try:
|
||||
with open(path, "r") as fh:
|
||||
d = json.load(fh)
|
||||
return d if isinstance(d, dict) else None
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
class RoomAccessory(Accessory):
|
||||
"""One room's accessory — Motion + Occupancy + Anomaly switch."""
|
||||
|
||||
category = CATEGORY_SENSOR
|
||||
|
||||
def __init__(self, driver, name: str, state_path: Path, *args, **kwargs):
|
||||
super().__init__(driver, name, *args, **kwargs)
|
||||
self._state_path = state_path
|
||||
s_motion = self.add_preload_service("MotionSensor")
|
||||
self.c_motion = s_motion.configure_char("MotionDetected")
|
||||
s_occ = self.add_preload_service("OccupancySensor")
|
||||
self.c_occ = s_occ.configure_char("OccupancyDetected")
|
||||
s_sw = self.add_preload_service("StatelessProgrammableSwitch")
|
||||
self.c_anomaly = s_sw.configure_char("ProgrammableSwitchEvent")
|
||||
|
||||
# ADR-125 §2.1.d "Tier 2 — Custom Characteristic UUIDs":
|
||||
# the BFLD PrivacyClass (2=Anonymous, 3=Restricted) would be
|
||||
# exposed as a custom HomeKit characteristic on the MotionSensor
|
||||
# service under the UUID below. Apple's Home.app ignores unknown
|
||||
# UUIDs; Eve.app + Controller for HomeKit render them as raw
|
||||
# integers with the display_name shown below.
|
||||
#
|
||||
# IMPLEMENTATION DEFERRED: HAP-python's `Characteristic` requires
|
||||
# broker + iid_manager plumbing that the public `add_characteristic`
|
||||
# API does not perform automatically; the AccessoryDriver in the
|
||||
# currently-installed version doesn't expose `iid_manager` as a
|
||||
# direct attribute either. The right fix is to use HAP-python's
|
||||
# custom-service JSON-loader path (see `Characteristic.from_dict`
|
||||
# + `Service.add_preload_service` with a custom resource) — a
|
||||
# follow-up iter ships that. The constant + spec stays here as
|
||||
# the SOTA-ready scaffold.
|
||||
self.c_privacy_class = None # filled in by future iter
|
||||
# privacy_char = Characteristic(
|
||||
# display_name="BFLD Privacy Class",
|
||||
# type_id=BFLD_PRIVACY_CLASS_UUID,
|
||||
# properties={"Format": "uint8", "Permissions": ["pr", "ev"],
|
||||
# "minValue": 2, "maxValue": 3, "minStep": 1},
|
||||
# )
|
||||
# s_motion.add_characteristic(privacy_char)
|
||||
# self.c_privacy_class = privacy_char
|
||||
|
||||
self._last_motion = False
|
||||
self._last_occ = False
|
||||
self._last_anomaly_ts = 0.0
|
||||
self._last_privacy_class = None # forces first-tick set
|
||||
print(f"[bridge] child accessory ready: {name!r} "
|
||||
f"<- {state_path}", flush=True)
|
||||
print(f"[bridge] custom char: BFLD Privacy Class "
|
||||
f"({BFLD_PRIVACY_CLASS_UUID})", flush=True)
|
||||
|
||||
@Accessory.run_at_interval(1.0)
|
||||
def run(self):
|
||||
state = _read_state(self._state_path)
|
||||
if state is None:
|
||||
return # absent / stale — leave HomeKit state at last-known
|
||||
motion = bool(state.get("motion", False))
|
||||
occupancy = bool(state.get("occupancy", False))
|
||||
anomaly_ts = float(state.get("anomaly_ts", 0.0) or 0.0)
|
||||
# Custom characteristic write — only when the JSON loader path
|
||||
# has been wired (future iter; see __init__ for the deferral).
|
||||
if self.c_privacy_class is not None:
|
||||
privacy_class = int(state.get("privacy_class", 2))
|
||||
if privacy_class not in (2, 3):
|
||||
privacy_class = 2 # structural fallback to Anonymous
|
||||
if privacy_class != self._last_privacy_class:
|
||||
self.c_privacy_class.set_value(privacy_class)
|
||||
self._last_privacy_class = privacy_class
|
||||
print(f"[bridge] {self.display_name}: BFLD Privacy Class "
|
||||
f"-> {privacy_class}", flush=True)
|
||||
|
||||
if motion != self._last_motion:
|
||||
self.c_motion.set_value(motion)
|
||||
self._last_motion = motion
|
||||
print(f"[bridge] {self.display_name}: Motion -> {motion}",
|
||||
flush=True)
|
||||
if occupancy != self._last_occ:
|
||||
self.c_occ.set_value(1 if occupancy else 0)
|
||||
self._last_occ = occupancy
|
||||
print(f"[bridge] {self.display_name}: Occupancy -> {occupancy} "
|
||||
f"(Siri: 'is anyone in the {self.display_name.lower()}?')",
|
||||
flush=True)
|
||||
if anomaly_ts > self._last_anomaly_ts:
|
||||
self.c_anomaly.set_value(0)
|
||||
self._last_anomaly_ts = anomaly_ts
|
||||
print(f"[bridge] {self.display_name}: "
|
||||
f"Unrecognized Activity Pattern fired", flush=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--port", type=int, default=51827)
|
||||
p.add_argument("--rooms",
|
||||
help="Comma-separated rooms to advertise. Each one maps "
|
||||
"to /tmp/ruview-state.<lowercase-hyphen>.json. "
|
||||
"Default: auto-discover from filesystem + legacy.")
|
||||
p.add_argument("--legacy-room", default="Living Room",
|
||||
help="Name attached to /tmp/ruview-state.json (the iter "
|
||||
"1-2 single-file IPC). Default: 'Living Room'.")
|
||||
args = p.parse_args()
|
||||
|
||||
driver = AccessoryDriver(port=args.port, persist_file=str(PERSIST_FILE))
|
||||
bridge = Bridge(driver, "RuView Sensing")
|
||||
bridge.category = CATEGORY_BRIDGE
|
||||
|
||||
rooms: list[tuple[str, Path]] = []
|
||||
if args.rooms:
|
||||
for r in [s.strip() for s in args.rooms.split(",") if s.strip()]:
|
||||
slug = r.lower().replace(" ", "-")
|
||||
rooms.append((r, Path(f"/tmp/ruview-state.{slug}.json")))
|
||||
else:
|
||||
rooms = discover_rooms_from_filesystem()
|
||||
if LEGACY_STATE.exists() or args.legacy_room:
|
||||
rooms.insert(0, (args.legacy_room, LEGACY_STATE))
|
||||
|
||||
if not rooms:
|
||||
sys.stderr.write(
|
||||
"ERROR: no rooms discovered. Either run "
|
||||
"c6-presence-watcher.py first (writes /tmp/ruview-state.json), "
|
||||
"or pass --rooms 'Name1,Name2'.\n"
|
||||
)
|
||||
return 2
|
||||
|
||||
for name, path in rooms:
|
||||
bridge.add_accessory(RoomAccessory(driver, name, path))
|
||||
|
||||
driver.add_accessory(accessory=bridge)
|
||||
setup_code = driver.state.pincode
|
||||
if hasattr(setup_code, "decode"):
|
||||
setup_code = setup_code.decode()
|
||||
SETUP_CODE_FILE.write_text(str(setup_code) + "\n")
|
||||
print(f"[bridge] HAP bridge advertising as 'RuView Sensing' (production)",
|
||||
flush=True)
|
||||
print(f"[bridge] Setup code (also in {SETUP_CODE_FILE}): {setup_code}",
|
||||
flush=True)
|
||||
print(f"[bridge] Rooms: {[r[0] for r in rooms]}", flush=True)
|
||||
print(f"[bridge] iPhone pair: Home app -> Add Accessory -> More Options",
|
||||
flush=True)
|
||||
driver.start()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,281 @@
|
||||
#!/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$")
|
||||
_PATH_SEMANTIC = re.compile(r"^/api/v1/semantic-events/([^/]+)/latest$")
|
||||
|
||||
|
||||
def semantic_events_for(node_id: str) -> dict | None:
|
||||
"""ADR-125 §2.1.d semantic-event surface.
|
||||
|
||||
The three named events that cross the HAP boundary. Each one is a
|
||||
boolean + last-fire timestamp. Agents subscribe to this endpoint
|
||||
rather than reasoning over raw scores — the naming is the contract.
|
||||
"""
|
||||
f = _load_feature()
|
||||
if f is None or f.get("node_id") != node_id:
|
||||
return None
|
||||
presence = bool(f.get("presence", False))
|
||||
anomaly = float(f.get("anomaly_score") or 0.0)
|
||||
return {
|
||||
"node_id": f["node_id"],
|
||||
"privacy_class": int(f.get("privacy_class", 2)),
|
||||
"events": {
|
||||
"unknown_presence": {
|
||||
"active": presence,
|
||||
"source": "BFLD presence_score (rolling 3s avg ≥ 0.30)",
|
||||
"ts": f["ts"],
|
||||
},
|
||||
"unexpected_occupancy": {
|
||||
# Placeholder: schedule-aware gating is future work.
|
||||
# For now we surface raw occupancy and mark the gate
|
||||
# as `schedule_aware=False` so agents know not to
|
||||
# equate this with the full §2.1.d intent yet.
|
||||
"active": presence,
|
||||
"schedule_aware": False,
|
||||
"ts": f["ts"],
|
||||
},
|
||||
"unrecognized_activity_pattern": {
|
||||
"active": anomaly >= 0.7,
|
||||
"anomaly_threshold": 0.7,
|
||||
"anomaly_score": anomaly,
|
||||
"ts": f["ts"],
|
||||
},
|
||||
},
|
||||
# ADR-125 §2.1.d invariant restated at the HTTP boundary:
|
||||
# identity_risk_score, soul_match_probability, and rf_signature_hash
|
||||
# are NEVER published from this endpoint.
|
||||
"redacted_fields": [
|
||||
"identity_risk_score",
|
||||
"soul_match_probability",
|
||||
"rf_signature_hash",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
m = _PATH_SEMANTIC.match(path)
|
||||
if m:
|
||||
node_id = m.group(1)
|
||||
r = semantic_events_for(node_id)
|
||||
if r is None:
|
||||
self._json(503, {"error": f"no recent semantic events 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())
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
rvagent-mcp-consumer.py — ADR-125 tier1+2 iter 5: end-to-end agentic loop.
|
||||
|
||||
Spawns the published `@ruvnet/rvagent` MCP server (ADR-124, npm 0.1.0)
|
||||
as a subprocess and exercises it through the standard MCP JSON-RPC 2.0
|
||||
stdio protocol. This is the "agentic capabilities" half of the ADR-125
|
||||
Tier 1+2 sprint — it proves the full bidirectional chain:
|
||||
|
||||
real C6 (192.168.1.179)
|
||||
→ UDP feature_state
|
||||
→ c6-presence-watcher.py (BFLD PrivacyGate)
|
||||
→ /tmp/ruview-last-feature.json
|
||||
→ ruview-sensing-server.py (sensing-server-equivalent on :3000)
|
||||
→ @ruvnet/rvagent (this script spawns it via `npx -y`)
|
||||
→ MCP JSON-RPC tools/call (this script sends them)
|
||||
→ result returned to any MCP-aware agent
|
||||
|
||||
If real data flows back, the agentic surface for RuView's BFLD-gated
|
||||
stream is live for every MCP client in the ecosystem — Claude Code,
|
||||
Codex, custom LLM agents.
|
||||
|
||||
Run on ruv-mac-mini (or any host with Node ≥ 20 + the running
|
||||
ruview-sensing-server.py on :3000):
|
||||
|
||||
RVAGENT_SENSING_URL=http://localhost:3000 \
|
||||
python3 rvagent-mcp-consumer.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
|
||||
NODE_ID = os.environ.get("RVAGENT_TEST_NODE", "12")
|
||||
SENSING_URL = os.environ.get("RVAGENT_SENSING_URL", "http://localhost:3000")
|
||||
|
||||
|
||||
def _send(proc: subprocess.Popen, msg: dict) -> None:
|
||||
line = json.dumps(msg) + "\n"
|
||||
proc.stdin.write(line)
|
||||
proc.stdin.flush()
|
||||
|
||||
|
||||
def _recv(proc: subprocess.Popen, want_id: int | None = None,
|
||||
timeout: float = 8.0) -> dict | None:
|
||||
"""Read JSON-RPC responses, optionally waiting for a specific id."""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
# rvagent may print non-JSON log lines on stdout in
|
||||
# error cases — skip and keep listening.
|
||||
print(f"[non-json] {line[:200]}", file=sys.stderr)
|
||||
continue
|
||||
if want_id is None or obj.get("id") == want_id:
|
||||
return obj
|
||||
return None
|
||||
|
||||
|
||||
def call_tool(proc: subprocess.Popen, tool_name: str,
|
||||
args: dict, request_id: int) -> dict | None:
|
||||
_send(proc, {
|
||||
"jsonrpc": "2.0", "id": request_id, "method": "tools/call",
|
||||
"params": {"name": tool_name, "arguments": args},
|
||||
})
|
||||
return _recv(proc, want_id=request_id, timeout=12.0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
env = {**os.environ, "RVAGENT_SENSING_URL": SENSING_URL}
|
||||
print(f"[mcp-consumer] spawning npx -y @ruvnet/rvagent")
|
||||
print(f"[mcp-consumer] RVAGENT_SENSING_URL={SENSING_URL}")
|
||||
print(f"[mcp-consumer] test node_id={NODE_ID}")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
["npx", "-y", "@ruvnet/rvagent"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, env=env, bufsize=1,
|
||||
)
|
||||
# Give npx a chance to install if cold.
|
||||
time.sleep(2.0)
|
||||
|
||||
# 1. initialize handshake
|
||||
_send(proc, {
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "ruview-iter5-consumer", "version": "0.1"},
|
||||
},
|
||||
})
|
||||
resp = _recv(proc, want_id=1)
|
||||
if resp is None:
|
||||
print("[mcp-consumer] FAIL: no initialize response", file=sys.stderr)
|
||||
proc.kill()
|
||||
return 1
|
||||
server_info = resp.get("result", {}).get("serverInfo", {})
|
||||
print(f"[mcp-consumer] server: {server_info.get('name')} "
|
||||
f"v{server_info.get('version')}")
|
||||
|
||||
# initialized notification
|
||||
_send(proc, {"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
|
||||
# 2. tools/list
|
||||
_send(proc, {"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
|
||||
resp = _recv(proc, want_id=2)
|
||||
tools = (resp or {}).get("result", {}).get("tools", [])
|
||||
print(f"[mcp-consumer] {len(tools)} tools available:")
|
||||
for t in tools:
|
||||
print(f" - {t.get('name')}")
|
||||
|
||||
# Locate the actual tool names (rvagent uses both snake_case and
|
||||
# dotted forms — discover them rather than hard-coding).
|
||||
names = [t.get("name") for t in tools]
|
||||
vitals_tool = next((n for n in names
|
||||
if "vitals" in n and ("all" in n or n.endswith("vitals"))), None)
|
||||
bfld_tool = next((n for n in names if "bfld" in n and "last_scan" in n), None)
|
||||
print(f"[mcp-consumer] resolved: vitals={vitals_tool} bfld={bfld_tool}")
|
||||
|
||||
# 3. tools/call vitals
|
||||
resp = call_tool(proc, vitals_tool or "vitals_get_all",
|
||||
{"node_id": NODE_ID}, 3)
|
||||
if resp is None or "error" in resp:
|
||||
print(f"[mcp-consumer] vitals_get_all failed: {resp}",
|
||||
file=sys.stderr)
|
||||
else:
|
||||
content = resp.get("result", {}).get("content", [])
|
||||
text = content[0].get("text", "") if content else ""
|
||||
print(f"[mcp-consumer] vitals_get_all OK — {len(text)} bytes")
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
print(f" presence={parsed.get('data', {}).get('presence')}, "
|
||||
f"motion={parsed.get('data', {}).get('motion')}, "
|
||||
f"breathing={parsed.get('data', {}).get('breathing_rate_bpm')}, "
|
||||
f"hr={parsed.get('data', {}).get('heartrate_bpm')}")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
print(f" (response head: {text[:200]})")
|
||||
|
||||
# 4. tools/call bfld last_scan
|
||||
resp = call_tool(proc, bfld_tool or "ruview.bfld.last_scan",
|
||||
{"node_id": NODE_ID}, 4)
|
||||
if resp is None or "error" in resp:
|
||||
print(f"[mcp-consumer] bfld_last_scan failed: {resp}",
|
||||
file=sys.stderr)
|
||||
else:
|
||||
content = resp.get("result", {}).get("content", [])
|
||||
text = content[0].get("text", "") if content else ""
|
||||
print(f"[mcp-consumer] bfld_last_scan OK — {len(text)} bytes")
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
print(f" privacy_class={parsed.get('privacy_class')}, "
|
||||
f"identity_risk_score={parsed.get('identity_risk_score')!r}, "
|
||||
f"presence={parsed.get('presence')}, "
|
||||
f"person_count={parsed.get('n_frames')}")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
print(f" (response head: {text[:200]})")
|
||||
|
||||
proc.stdin.close()
|
||||
proc.wait(timeout=5)
|
||||
print("[mcp-consumer] done — agentic chain validated end-to-end")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(130)
|
||||
Reference in New Issue
Block a user