Files
ruvnet--RuView/docs/adr/ADR-130-homecore-rest-websocket-api.md
T
rUv 2bccdf5065 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.
2026-05-25 17:36:40 -04:00

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