mirror of
https://github.com/ruvnet/RuView.git
synced 2026-06-02 00:58:56 +02:00
2bccdf5065
* 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.
403 lines
17 KiB
Python
403 lines
17 KiB
Python
#!/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 json
|
|
import os
|
|
import signal
|
|
import socket
|
|
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
|
|
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,
|
|
semantic: str = SEMANTIC_EVENT_UNKNOWN_PRESENCE) -> 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
|
|
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"):
|
|
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)
|
|
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(*_):
|
|
nonlocal running
|
|
running = False
|
|
signal.signal(signal.SIGTERM, _stop)
|
|
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 = 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:
|
|
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
|
|
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)
|
|
|
|
# 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:
|
|
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} "
|
|
f"motion={motion} occupancy={occupancy} "
|
|
f"anomaly_fires={n_anomaly_fires}",
|
|
flush=True,
|
|
)
|
|
n_total = n_valid = n_crc_bad = n_anomaly_fires = 0
|
|
presence_sum = motion_sum = 0.0
|
|
last_summary = now
|
|
|
|
sock.close()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|