mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00: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.
219 lines
16 KiB
Markdown
219 lines
16 KiB
Markdown
# ADR-130: HOMECORE-API — Wire-compatible REST and WebSocket API
|
|
|
|
| Field | Value |
|
|
|-------|-------|
|
|
| **Status** | Proposed |
|
|
| **Date** | 2026-05-25 |
|
|
| **Deciders** | ruv |
|
|
| **Codename** | **HOMECORE-API** |
|
|
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE), [ADR-055](ADR-055-integrated-sensing-server.md) (sensing-server Axum pattern), [ADR-124](ADR-124-rvagent-mcp-ruvector-npm-integration.md) (SENSE-BRIDGE — bearer auth pattern) |
|
|
| **Tracking issue** | TBD |
|
|
|
|
---
|
|
|
|
## 1. Context
|
|
|
|
Home Assistant's HTTP and WebSocket APIs are the primary interface for every non-frontend client: the iOS companion app, the Android companion app, HACS, Node-RED, the `homeassistant` Python client library, ESPHome native API clients, external automation scripts, and the hundreds of third-party HA dashboard projects.
|
|
|
|
The API surface is defined in two Python modules:
|
|
|
|
1. **`homeassistant/components/api/__init__.py`** — 24 REST API routes mounted at `/api/`. Key routes: `GET /api/`, `GET /api/states`, `GET /api/states/<entity_id>`, `POST /api/states/<entity_id>`, `GET /api/events`, `POST /api/events/<event_type>`, `GET /api/services`, `POST /api/services/<domain>/<service>`, `GET /api/error_log`, `GET /api/config`, `POST /api/template`, `POST /api/check_config`, `GET /api/history/period/<datetime>` (deprecated — recorder), `POST /api/logbook/` (deprecated — recorder).
|
|
|
|
2. **`homeassistant/components/websocket_api/`** — the WebSocket API handler (`connection.py` handles auth handshake; `commands.py` handles 30+ command types). Key commands: `auth`, `subscribe_events`, `unsubscribe_events`, `call_service`, `get_states`, `get_services`, `get_config`, `subscribe_trigger`, `render_template`, `validate_config`, `subscribe_entities` (entity registry updates), `config/entity_registry/list`, and many more.
|
|
|
|
### 1.1 Auth model
|
|
|
|
HA uses **long-lived access tokens (LLAT)** as the primary auth mechanism for non-UI clients. Tokens are created in the HA user profile UI and stored in `.storage/auth`. The REST API accepts `Authorization: Bearer <token>` or the `api_password` legacy header (deprecated since HA 2022.x). The WebSocket API requires an `auth` message with `access_token` as the first message after connection.
|
|
|
|
### 1.2 Why wire-compat matters
|
|
|
|
The iOS and Android HA companion apps (>100,000 installs combined) hardcode the HA API paths and WebSocket command schemas. Any implementation that deviates from the exact JSON schemas causes the apps to fail silently — not with a meaningful error, but by returning empty entity lists or missing state updates. Wire-compat is therefore a hard requirement, not a nice-to-have.
|
|
|
|
The baseline for compatibility is **HA 2025.1** (the version that introduced SQLite recorder schema version 48). Any HOMECORE instance claiming compliance with this ADR must pass the companion app integration test suite.
|
|
|
|
---
|
|
|
|
## 2. Decision
|
|
|
|
Implement the `homecore-api` crate as an Axum-based server that replicates the HA REST and WebSocket API on port 8123. The implementation is informed by — but does not copy — `homeassistant/components/api/__init__.py` and `homeassistant/components/websocket_api/`.
|
|
|
|
The server reuses the Axum + Tokio architecture established in `v2/crates/wifi-densepose-sensing-server/src/main.rs` and its bearer auth pattern (`v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs`).
|
|
|
|
### 2.1 REST API route table
|
|
|
|
| Route | Method | HA source line (approx.) | HOMECORE status |
|
|
|---|---|---|---|
|
|
| `/api/` | GET | `api/__init__.py:74` | P2 — returns `{ "message": "API running." }` |
|
|
| `/api/config` | GET | `api/__init__.py:97` | P2 — returns `homecore.config` as JSON |
|
|
| `/api/states` | GET | `api/__init__.py:116` | P2 — returns `hass.states.all()` as JSON array |
|
|
| `/api/states/<entity_id>` | GET | `api/__init__.py:130` | P2 |
|
|
| `/api/states/<entity_id>` | POST | `api/__init__.py:145` | P2 — writes state; fires `state_changed` |
|
|
| `/api/events` | GET | `api/__init__.py:168` | P3 |
|
|
| `/api/events/<event_type>` | POST | `api/__init__.py:180` | P3 — fires domain event |
|
|
| `/api/services` | GET | `api/__init__.py:192` | P2 |
|
|
| `/api/services/<domain>/<service>` | POST | `api/__init__.py:206` | P2 |
|
|
| `/api/template` | POST | `api/__init__.py:222` | P3 — WASM MiniJinja evaluator (ADR-129) |
|
|
| `/api/check_config` | POST | `api/__init__.py:240` | P4 |
|
|
| `/api/error_log` | GET | `api/__init__.py:252` | P3 |
|
|
| `/api/history/period/<datetime>` | GET | `api/__init__.py:270` | P4 — recorder query (ADR-132) |
|
|
| `/api/logbook/` | POST | `api/__init__.py:310` | P4 — recorder query |
|
|
| `/api/camera_proxy/<entity_id>` | GET | `api/__init__.py:330` | P4 — proxy to camera integration |
|
|
| `/api/calendar/<entity_id>` | GET | `api/__init__.py:348` | P4 |
|
|
| `/api/webhook/<webhook_id>` | POST/GET | `api/__init__.py:368` | P3 — fires `webhook.<id>` event |
|
|
| `/api/intent/handle` | POST | `api/__init__.py:400` | P4 — HOMECORE-ASSIST (ADR-133) |
|
|
| `/auth/token` | POST | `auth/providers/__init__.py` | P2 — issue LLAT from username/password |
|
|
| `/auth/authorize` | GET/POST | `auth/providers/__init__.py` | P3 — OAuth2 flow |
|
|
| `/frontend/` static assets | GET | `frontend/__init__.py` | P1 — serve HA Python frontend static files until ADR-131 ships |
|
|
|
|
### 2.2 WebSocket API command table
|
|
|
|
| WS command type | HA source | HOMECORE status |
|
|
|---|---|---|
|
|
| `auth` (handshake) | `websocket_api/connection.py:55` | P2 |
|
|
| `subscribe_events` | `websocket_api/commands.py:120` | P2 |
|
|
| `unsubscribe_events` | `websocket_api/commands.py:145` | P2 |
|
|
| `call_service` | `websocket_api/commands.py:160` | P2 |
|
|
| `get_states` | `websocket_api/commands.py:200` | P2 |
|
|
| `get_services` | `websocket_api/commands.py:218` | P2 |
|
|
| `get_config` | `websocket_api/commands.py:230` | P2 |
|
|
| `subscribe_trigger` | `websocket_api/commands.py:250` | P3 |
|
|
| `render_template` | `websocket_api/commands.py:280` | P3 |
|
|
| `validate_config` | `websocket_api/commands.py:300` | P3 |
|
|
| `subscribe_entities` | `websocket_api/commands.py:320` | P3 — entity registry update stream |
|
|
| `config/entity_registry/list` | `websocket_api/commands.py:370` | P3 |
|
|
| `config/entity_registry/update` | `websocket_api/commands.py:400` | P3 |
|
|
| `config/area_registry/list` | `websocket_api/commands.py:450` | P3 |
|
|
| `config/device_registry/list` | `websocket_api/commands.py:480` | P3 |
|
|
| `config/config_entries/list` | `websocket_api/commands.py:510` | P3 |
|
|
| `lovelace/config` (dashboard) | `lovelace/dashboard.py` | P4 — reads from HOMECORE storage |
|
|
| `media_player/*` | `websocket_api/commands.py:600` | P4 |
|
|
|
|
### 2.3 Auth implementation
|
|
|
|
HOMECORE-API implements long-lived access tokens as JWTs signed with an Ed25519 key (generated at first startup, stored in `.homecore/auth_key.pem`). Token format:
|
|
|
|
```json
|
|
{
|
|
"sub": "<user_id>",
|
|
"iss": "homecore",
|
|
"iat": <unix_timestamp>,
|
|
"exp": <unix_timestamp or null for LLAT>,
|
|
"type": "long_lived_access_token"
|
|
}
|
|
```
|
|
|
|
The HA companion app sends `Authorization: Bearer <token>` on every REST request. The WebSocket auth handshake sends `{ "type": "auth", "access_token": "<token>" }`. Both paths validate the JWT against the stored Ed25519 key.
|
|
|
|
Legacy `api_password` is deliberately not supported (removed in HA 2022.x and never properly secure).
|
|
|
|
---
|
|
|
|
## 3. HA-side reference table
|
|
|
|
| HA module / file | What it does | HOMECORE preserves | Changes | Drops |
|
|
|---|---|---|---|---|
|
|
| `components/api/__init__.py` | 24 REST routes + JSON response schemas | All response schemas byte-compatible with HA 2025.1 | Axum router instead of HA's custom HTTP component; `serde_json` instead of Python `json` | Python HTTP request context; HA's built-in CORS middleware (replicated in Axum) |
|
|
| `components/websocket_api/connection.py` | WS auth handshake; per-connection state; message dispatch | Auth handshake flow: `auth_required` → `auth` message → `auth_ok` or `auth_invalid` | Axum `WebSocketUpgrade` extractor; per-connection `tokio::task` | Python asyncio message handling |
|
|
| `components/websocket_api/commands.py` | 30+ WS command handlers | All command type strings; response envelope `{ id, type, result }` or error `{ id, type, error: { code, message } }` | Rust match dispatch; Tokio broadcast receiver per subscription | Python class-based command handler registration |
|
|
| `auth/providers/__init__.py` | Auth providers; LLAT issuance; OAuth2 flow | LLAT issuance; token validation | Ed25519 JWT instead of HA's custom token serializer; same token `type` field values | Nabu Casa cloud auth; multi-provider auth chain |
|
|
| `components/http/__init__.py` | Aiohttp-based HTTP server setup; CORS; trusted proxies | CORS headers; `X-Forwarded-For` trusted proxy handling | Axum Tower middleware | Aiohttp; Python SSL context |
|
|
|
|
---
|
|
|
|
## 4. Public API parity table
|
|
|
|
| HA API surface | HOMECORE exact equivalent |
|
|
|---|---|
|
|
| `GET /api/states` → `[{entity_id, state, attributes, last_changed, last_updated, context}]` | Identical JSON schema; `last_changed` / `last_updated` in ISO 8601 |
|
|
| `GET /api/services` → `{domain: {service: {description, fields}}}` | Identical schema; service descriptions read from plugin manifests |
|
|
| WS `subscribe_events` → `{type: "event", event: {event_type, data, origin, time_fired, context}}` | Identical envelope; `time_fired` in ISO 8601 |
|
|
| WS `call_service` → `{type: "result", success: true, result: {context}}` | Identical; `context.id` is a UUID |
|
|
| WS `get_states` → `{type: "result", result: [{entity_id, state, attributes, ...}]}` | Identical schema |
|
|
| REST `POST /api/services/<domain>/<service>` → 200 with called service list | Identical; same `target` field support |
|
|
| REST `POST /api/template` → 200 with evaluated string | Identical; same error response `{message: "..."}` on template error |
|
|
| Auth WS flow: `auth_required` → `auth` → `auth_ok` | Identical message type strings; same `ha_version` field in `auth_required` |
|
|
| REST `Authorization: Bearer <token>` | Identical header name; JWT instead of HA's opaque token format (transparent to clients) |
|
|
|
|
---
|
|
|
|
## 5. Phased implementation plan
|
|
|
|
### P1 — Axum skeleton + static frontend (1 week)
|
|
|
|
- [ ] Create `v2/crates/homecore-api/` workspace member.
|
|
- [ ] Axum router on port 8123; Tower CORS middleware (allow `http://homeassistant.local:8123`).
|
|
- [ ] Static file handler: serve HA's Python frontend build from a configurable path (default `./frontend/build/`). This allows using the Python HA frontend as-is until ADR-131 ships.
|
|
- [ ] `GET /api/` returns `{ "message": "API running." }`.
|
|
- [ ] CI: `cargo check -p homecore-api`; HTTP smoke test.
|
|
|
|
### P2 — Core REST + WebSocket auth + states (3 weeks)
|
|
|
|
- [ ] Axum WebSocket upgrade at `/api/websocket`.
|
|
- [ ] Auth: Ed25519 JWT issuance at `/auth/token`; validation middleware.
|
|
- [ ] WS auth handshake: `auth_required` → `auth` → `auth_ok` / `auth_invalid`.
|
|
- [ ] WS commands: `get_states`, `subscribe_events`, `unsubscribe_events`, `call_service`, `get_services`, `get_config`.
|
|
- [ ] REST: `/api/states`, `/api/states/<entity_id>` (GET + POST), `/api/services`, `/api/services/<domain>/<service>`, `/api/config`.
|
|
- [ ] Integration test: HA iOS companion app authenticates and displays entity list against HOMECORE.
|
|
|
|
### P3 — Remaining WS commands + entity registry API (3 weeks)
|
|
|
|
- [ ] WS: `subscribe_trigger`, `render_template`, `validate_config`, `subscribe_entities`, entity/area/device registry commands.
|
|
- [ ] REST: `/api/template`, `/api/webhook/<id>`, `/api/error_log`, `/api/events`, `/api/events/<type>`.
|
|
- [ ] `/auth/authorize` OAuth2 flow for UI login.
|
|
- [ ] HACS smoke test: HACS connects, lists integrations.
|
|
|
|
### P4 — Recorder + history API (2 weeks)
|
|
|
|
- [ ] `/api/history/period/<datetime>` backed by ADR-132 recorder SQLite.
|
|
- [ ] `/api/logbook/` backed by ADR-132 recorder.
|
|
- [ ] `/api/camera_proxy/`, `/api/calendar/`, `/api/intent/handle`.
|
|
- [ ] Companion app full feature test: automations, notifications, history charts.
|
|
|
|
---
|
|
|
|
## 6. Risks
|
|
|
|
| Risk | Likelihood | Severity | Mitigation | Cross-ADR impact |
|
|
|---|---|---|---|---|
|
|
| **JSON schema drift** — HA updates a response field name between 2025.1 and HOMECORE release | Medium | High | Maintain a JSON-schema test fixture set generated from HA 2025.1; run against HOMECORE in CI | ADR-134: migration tool depends on the same JSON schemas; must stay in sync |
|
|
| **WS subscription fan-out** — 50 concurrent HA companion app sessions each subscribed to `subscribe_events` ALL; every state change creates 50 serialization tasks | Medium | Medium | Broadcast serialized JSON once; clone the `Bytes` arc to each subscriber sender; do not re-serialize per subscriber | ADR-127: broadcast channel capacity must handle subscriber fan-out without lagging |
|
|
| **Auth token format** — HA companion apps may validate the token format (JWT vs opaque). HOMECORE uses JWT; HA uses a custom opaque token. Tokens are never decoded client-side in standard clients, but non-standard clients may inspect them | Low | Low | JWTs are base64url-encoded JSON; any client checking `token.startsWith("ey")` will see a JWT. HA's own tokens are also base64url but not JWTs. Document the difference; test with the iOS app specifically | None |
|
|
| **Port 8123 conflict** — HOMECORE runs on the same port as HA; side-by-side mode (ADR-134) requires HOMECORE on a different port until cutover | High | Medium | ADR-134 side-by-side mode runs HOMECORE on port 8124; companion app can be pointed at port 8124 for testing | ADR-134 owns the cutover mechanism |
|
|
|
|
---
|
|
|
|
## 7. Open questions
|
|
|
|
**Q1**: The HA WebSocket API uses incremental integer IDs (`id: 1, 2, 3, ...`) for command/response correlation within a session. HOMECORE uses the same scheme. What is the maximum `id` value the companion app supports before wrapping? If the app doesn't wrap and HOMECORE processes > 2^31 commands per session, this becomes an overflow issue in extremely long-lived sessions.
|
|
|
|
**Q2**: The `subscribe_entities` WS command (added in HA 2021.x) sends entity registry change events in addition to state change events. The iOS companion app uses this to maintain a local entity list without polling. Is the full `subscribe_entities` delta schema (including `action: "create" | "update" | "remove"`) fully documented, or must it be reverse-engineered from the companion app source?
|
|
|
|
**Q3**: HA's `/auth/token` endpoint accepts `grant_type=password` (username/password) and `grant_type=refresh_token`. HOMECORE's initial implementation supports password grant only. Is refresh token support required for the companion app (it caches tokens between sessions) or does the companion app re-authenticate on each launch?
|
|
|
|
**Q4**: CORS policy: HA's default CORS allows `http://localhost:*` and `http://homeassistant.local:*`. The HOMECORE-UI frontend (ADR-131) will be served from a different origin in development. What CORS policy should HOMECORE-API use in production vs development mode?
|
|
|
|
---
|
|
|
|
## 8. References
|
|
|
|
### HA upstream
|
|
|
|
- `homeassistant/components/api/__init__.py` — 24 REST routes with exact URL paths, methods, and JSON response schemas
|
|
- `homeassistant/components/websocket_api/connection.py` — auth handshake protocol; per-connection state management
|
|
- `homeassistant/components/websocket_api/commands.py` — 30+ command type handlers with exact type strings and result schemas
|
|
- `homeassistant/components/http/__init__.py` — CORS setup; trusted proxy handling; aiohttp-based server
|
|
- `homeassistant/auth/providers/__init__.py` — token issuance; `AuthManager`; LLAT format
|
|
- `homeassistant/auth/__init__.py` — `AuthManager.async_create_long_lived_access_token`
|
|
|
|
### This repo
|
|
|
|
- `v2/crates/wifi-densepose-sensing-server/src/main.rs` — Axum server architecture (REST + WebSocket); pattern for this ADR
|
|
- `v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs` — Bearer auth middleware pattern
|
|
- `docs/adr/ADR-127-homecore-state-machine-rust.md` — state machine that REST/WS routes read from
|
|
- `docs/adr/ADR-126-ruview-native-ha-port-master.md` — §6 compatibility contract with companion apps
|
|
|
|
### External
|
|
|
|
- [HA WebSocket API Developer Docs](https://developers.home-assistant.io/docs/api/websocket/) — authoritative command type catalog
|
|
- [HA REST API](https://developers.home-assistant.io/docs/api/rest/) — REST endpoint schemas
|