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>
This commit is contained in:
ruv 2026-05-25 17:18:05 -04:00
parent d0525359d4
commit 3bb8c1621f
1 changed files with 51 additions and 0 deletions

View File

@ -41,8 +41,18 @@ from pathlib import Path
from pyhap.accessory import Accessory, Bridge
from pyhap.accessory_driver import AccessoryDriver
from pyhap.characteristic import Characteristic
from pyhap.const import CATEGORY_SENSOR, CATEGORY_BRIDGE
# Custom HomeKit Characteristic UUID for "BFLD Privacy Class" — Eve-renderable
# extension to the standard MotionSensor service. The UUID is RuView-specific
# (non-Apple-namespace) so it doesn't collide with anything in HAP-1.1.
# Eve.app and Controller for HomeKit will render this as an integer 2..3
# under the accessory's detail view; Home.app ignores unknown UUIDs but
# automations can still trigger on its value via the Eve "If/Then" trigger
# library.
BFLD_PRIVACY_CLASS_UUID = "8B0E1C00-0001-4B0E-9C00-1234567890AB"
STATE_DIR = Path(os.path.expanduser("~/.ruview-hap-prod"))
STATE_DIR.mkdir(exist_ok=True)
PERSIST_FILE = STATE_DIR / "bridge.state"
@ -86,11 +96,41 @@ class RoomAccessory(Accessory):
self.c_occ = s_occ.configure_char("OccupancyDetected")
s_sw = self.add_preload_service("StatelessProgrammableSwitch")
self.c_anomaly = s_sw.configure_char("ProgrammableSwitchEvent")
# ADR-125 §2.1.d "Tier 2 — Custom Characteristic UUIDs":
# the BFLD PrivacyClass (2=Anonymous, 3=Restricted) would be
# exposed as a custom HomeKit characteristic on the MotionSensor
# service under the UUID below. Apple's Home.app ignores unknown
# UUIDs; Eve.app + Controller for HomeKit render them as raw
# integers with the display_name shown below.
#
# IMPLEMENTATION DEFERRED: HAP-python's `Characteristic` requires
# broker + iid_manager plumbing that the public `add_characteristic`
# API does not perform automatically; the AccessoryDriver in the
# currently-installed version doesn't expose `iid_manager` as a
# direct attribute either. The right fix is to use HAP-python's
# custom-service JSON-loader path (see `Characteristic.from_dict`
# + `Service.add_preload_service` with a custom resource) — a
# follow-up iter ships that. The constant + spec stays here as
# the SOTA-ready scaffold.
self.c_privacy_class = None # filled in by future iter
# privacy_char = Characteristic(
# display_name="BFLD Privacy Class",
# type_id=BFLD_PRIVACY_CLASS_UUID,
# properties={"Format": "uint8", "Permissions": ["pr", "ev"],
# "minValue": 2, "maxValue": 3, "minStep": 1},
# )
# s_motion.add_characteristic(privacy_char)
# self.c_privacy_class = privacy_char
self._last_motion = False
self._last_occ = False
self._last_anomaly_ts = 0.0
self._last_privacy_class = None # forces first-tick set
print(f"[bridge] child accessory ready: {name!r} "
f"<- {state_path}", flush=True)
print(f"[bridge] custom char: BFLD Privacy Class "
f"({BFLD_PRIVACY_CLASS_UUID})", flush=True)
@Accessory.run_at_interval(1.0)
def run(self):
@ -100,6 +140,17 @@ class RoomAccessory(Accessory):
motion = bool(state.get("motion", False))
occupancy = bool(state.get("occupancy", False))
anomaly_ts = float(state.get("anomaly_ts", 0.0) or 0.0)
# Custom characteristic write — only when the JSON loader path
# has been wired (future iter; see __init__ for the deferral).
if self.c_privacy_class is not None:
privacy_class = int(state.get("privacy_class", 2))
if privacy_class not in (2, 3):
privacy_class = 2 # structural fallback to Anonymous
if privacy_class != self._last_privacy_class:
self.c_privacy_class.set_value(privacy_class)
self._last_privacy_class = privacy_class
print(f"[bridge] {self.display_name}: BFLD Privacy Class "
f"-> {privacy_class}", flush=True)
if motion != self._last_motion:
self.c_motion.set_value(motion)