Files
home-automation/HOMEKIT_BRIDGE_API_ANALYSIS.md

16 KiB
Raw Blame History

HomeKit-Bridge API-Modell: Analyse der bestehenden Implementierung

Analysedatum: 17. November 2025
Analysierte Dateien:

  • apps/api/main.py
  • apps/api/routes/groups_scenes.py
  • config/devices.yaml

Zusammenfassung

Die bestehende API-Implementierung erfüllt ~60% der Anforderungen des HomeKit-Bridge Ziel-Modells. Die meisten Kernfunktionalitäten sind vorhanden, aber es fehlen wichtige Metadaten-Felder und ein dedizierter State-Endpoint.


1. GET /devices

Status: VORHANDEN mit Abweichungen

Implementierung (apps/api/main.py:325-343)

@app.get("/devices")
async def get_devices() -> list[DeviceInfo]:
    devices = load_devices()
    return [
        DeviceInfo(
            device_id=device["device_id"],
            type=device["type"],
            name=device.get("name", device["device_id"]),
            features=device.get("features", {})
        )
        for device in devices
    ]

Response-Modell (DeviceInfo)

class DeviceInfo(BaseModel):
    device_id: str
    type: str
    name: str
    features: dict[str, Any] = {}

Abweichungen vom Ziel-Modell

Feld Ziel-Modell Ist-Zustand Status
device_id Erforderlich Vorhanden OK
type Erforderlich Vorhanden OK
cap_version Erforderlich FEHLT FEHLT
room Erforderlich FEHLT FEHLT
friendly_name Erforderlich ⚠️ Heißt name UMBENENNUNG
technology Erforderlich FEHLT FEHLT
features Erforderlich Vorhanden OK
read_only Erforderlich FEHLT FEHLT
tags Optional FEHLT FEHLT

Details zu fehlenden Feldern

cap_version

  • Vorhanden in devices.yaml: Ja, als cap_version (z.B. "light@1.2.0")
  • Problem: Wird von load_devices() geladen, aber nicht in DeviceInfo exponiert
  • Lösung: Feld zu DeviceInfo hinzufügen und aus device["cap_version"] befüllen

room

  • Vorhanden in layout.yaml: Ja, indirekt über Raum-Zuordnung
  • Problem: Aktuell nur über separaten Endpoint /devices/{device_id}/room verfügbar
  • Lösung: Room-Mapping in /devices integrieren (Resolver bereits vorhanden in apps/api/resolvers.py)

⚠️ friendly_name vs. name

  • Vorhanden in devices.yaml: Ja, als metadata.friendly_name
  • Problem: Aktuell wird device.get("name", device["device_id"]) verwendet, nicht metadata.friendly_name
  • Lösung: Priorisierung: metadata.friendly_name > name > device_id

technology

  • Vorhanden in devices.yaml: Ja, als technology (z.B. "zigbee2mqtt")
  • Problem: Wird nicht in Response exponiert
  • Lösung: Feld zu DeviceInfo hinzufügen

read_only

  • Implizit vorhanden: Ja, über topics.set (wenn fehlt → read-only)
  • Problem: Muss berechnet werden
  • Lösung: read_only = "set" not in device.get("topics", {})

tags

  • Vorhanden in devices.yaml: Nein
  • Status: Nicht kritisch, kann später ergänzt werden

2. GET /devices/{device_id}

Status: FEHLT KOMPLETT

Aktuell vorhanden

  • /devices/{device_id}/room (liefert nur {"device_id": str, "room": str | None})

Erforderlich

Ein Endpoint, der das gleiche Schema wie ein Eintrag aus /devices zurückgibt:

@app.get("/devices/{device_id}")
async def get_device(device_id: str) -> DeviceInfo:
    # Load device, enrich with room, return DeviceInfo

Implementierung

  • Device aus load_devices() filtern
  • Mit get_room(device_id) anreichern
  • Als DeviceInfo zurückgeben
  • 404 bei nicht gefunden

3. GET /devices/{device_id}/state

Status: FEHLT KOMPLETT

Aktuell vorhanden

  • /devices/states (liefert alle Device-States als Dict)
    @app.get("/devices/states")
    async def get_device_states() -> dict[str, dict[str, Any]]:
        return device_states  # In-memory cache
    

Ziel-Format

{
  "device_id": "thermostat_wolfgang",
  "type": "thermostat",
  "room": "Schlafzimmer",
  "payload": {
    "current": 19.5,
    "target": 21.0,
    "mode": "heat"
  },
  "ts": "2025-11-17T14:23:45.123Z"
}

Erforderlich

@app.get("/devices/{device_id}/state")
async def get_device_state(device_id: str) -> DeviceStateResponse:
    # Get from device_states cache
    # Enrich with metadata (type, room)
    # Add timestamp
    # Return structured response

Problem

  • Aktuell wird nur payload im Cache gespeichert
  • Timestamp fehlt im Cache (müsste bei SSE-Updates mitgespeichert werden)
  • Metadaten (type, room) müssen aus devices.yaml/layout.yaml ergänzt werden

4. SSE-Endpoint /realtime

Status: VORHANDEN mit kleineren Abweichungen

Implementierung (apps/api/main.py:608-637)

@app.get("/realtime")
async def realtime_events(request: Request) -> StreamingResponse:
    return StreamingResponse(
        event_generator(request),
        media_type="text/event-stream",
        # ... headers
    )

Aktuelles Event-Format (aus Redis)

{
  "type": "state",
  "device_id": "thermostat_wolfgang",
  "payload": {
    "current": 19.5,
    "target": 21.0
  }
}

Ziel-Format

{
  "type": "state",
  "device_id": "thermostat_wolfgang",
  "device_type": "thermostat",     // ← FEHLT
  "room": "Schlafzimmer",           // ← FEHLT
  "payload": {
    "current": 19.5,
    "target": 21.0
  },
  "ts": "2025-11-17T14:23:45.123Z", // ← FEHLT
  "source": "zigbee2mqtt"           // ← FEHLT (optional)
}

Abweichungen

Feld Ziel-Modell Ist-Zustand Status
type OK
device_id OK
device_type FEHLT FEHLT
room FEHLT FEHLT
payload OK
ts FEHLT FEHLT
source Optional FEHLT FEHLT

Problem

Events werden direkt aus Redis weitergeleitet ohne Enrichment.

Lösungsansätze

Option A: Enrichment im SSE-Generator

# Im event_generator() nach JSON-Parse:
state_data = json.loads(data)
if state_data.get("type") == "state":
    # Enrich with metadata
    device_id = state_data["device_id"]
    device = get_device_from_cache(device_id)
    state_data["device_type"] = device["type"]
    state_data["room"] = get_room(device_id)
    if "ts" not in state_data:
        state_data["ts"] = datetime.utcnow().isoformat()
    data = json.dumps(state_data)

Option B: Enrichment im Publisher (apps/abstraction)

  • Besser: Events bereits vollständig beim Publizieren
  • Würde auch /devices/{id}/state helfen

5. POST /devices/{device_id}/set

Status: VORHANDEN mit kleinen Abweichungen

Implementierung (apps/api/main.py:406-504)

@app.post("/devices/{device_id}/set", status_code=status.HTTP_202_ACCEPTED)
async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str]:
    # Validierung, MQTT publish

Request-Modell

class SetDeviceRequest(BaseModel):
    type: str
    payload: dict[str, Any]

Vergleich mit Ziel-Modell

Aspekt Ziel-Modell Ist-Zustand Status
Body-Format {type, payload} {type, payload} OK
Type-Validierung Erforderlich Vorhanden OK
Payload-Validierung Per Device-Type Vorhanden OK
Read-Only Check → 405 → 405 OK
Response Code 200/202 202 OK

Validierungs-Details

Gut implementiert:

  • Type-spezifische Pydantic-Validierung (LightState, ThermostatState, etc.)
  • Whitelist für erlaubte Felder bei Thermostaten
  • Read-only Detection über topics.set
  • Proper HTTP Status Codes (404, 405, 422)

⚠️ Kleine Abweichung:

  • Thermostat-Validierung erlaubt nur {mode, target} beim SET
  • Ziel-Modell erwähnt dies nicht explizit
  • Bewertung: Ist sinnvolle Einschränkung, kein Problem

MQTT-Publishing

topic = f"home/{request.type}/{device_id}/set"
mqtt_payload = {
    "type": request.type,
    "payload": request.payload
}
await publish_mqtt(topic, mqtt_payload)

Korrekt implementiert


6. Zusätzliche Endpoints (nicht im Ziel-Modell)

Vorhanden, aber nicht gefordert

  • GET /spec - Capability-Versionen
  • GET /devices/states - Alle States (könnte nützlich für Bridge sein)
  • GET /layout - UI-spezifisch
  • GET /devices/{device_id}/room - Wird obsolet wenn /devices room hat
  • GET /groups, POST /groups/{id}/set - Gruppen-Feature
  • GET /scenes, POST /scenes/{id}/run - Szenen-Feature

Bewertung: Nicht störend, können bleiben. Bridge muss diese nicht nutzen.


7. Datenquellen-Analyse

devices.yaml

Enthält alle benötigten Felder:

- device_id: leselampe_esszimmer
  type: light
  cap_version: "light@1.2.0"         # ← Vorhanden
  technology: zigbee2mqtt             # ← Vorhanden
  features:
    power: true
    brightness: true
  topics:
    state: "..."
    set: "..."                        # ← Für read_only Detection
  metadata:
    friendly_name: "Leselampe Esszimmer"  # ← Vorhanden
    ieee_address: "..."
    model: "LED1842G3"
    vendor: "IKEA"

layout.yaml

Enthält Room-Mapping:

rooms:
  - name: "Schlafzimmer"
    devices:
      - device_id: thermostat_wolfgang

Resolver bereits vorhanden: apps/api/resolvers.py::get_room(device_id)


8. Priorisierte To-Do-Liste

🔴 Kritisch (Bridge funktioniert nicht ohne)

  1. GET /devices: Fehlende Felder ergänzen

    • cap_version aus devices.yaml
    • room via get_room()
    • friendly_name aus metadata.friendly_name
    • technology aus devices.yaml
    • read_only berechnen
  2. GET /devices/{device_id}/state implementieren

    • Neuer Endpoint
    • State aus Cache + Metadaten
    • Timestamp hinzufügen

🟡 Wichtig (Bridge funktioniert, aber eingeschränkt)

  1. SSE /realtime: Events enrichen

    • device_type hinzufügen
    • room hinzufügen
    • ts sicherstellen
  2. GET /devices/{device_id} implementieren

    • Einzelgerät-Abfrage
    • Gleiche Struktur wie /devices-Eintrag

🟢 Nice-to-have

  1. State-Cache mit Timestamps erweitern

    • Aktuell: device_states[id] = payload
    • Ziel: device_states[id] = {payload, ts}
  2. SSE: source-Feld hinzufügen

    • Aus device["technology"] ableiten

9. Implementierungs-Reihenfolge

Phase 1: GET /devices erweitern

Dateien:

  • apps/api/main.py (DeviceInfo-Modell, get_devices())

Änderungen:

class DeviceInfo(BaseModel):
    device_id: str
    type: str
    cap_version: str
    room: str | None
    friendly_name: str
    technology: str
    features: dict[str, Any]
    read_only: bool
    tags: list[str] | None = None

@app.get("/devices")
async def get_devices() -> list[DeviceInfo]:
    devices = load_devices()
    return [
        DeviceInfo(
            device_id=device["device_id"],
            type=device["type"],
            cap_version=device["cap_version"],
            room=get_room(device["device_id"]),
            friendly_name=device.get("metadata", {}).get("friendly_name", device["device_id"]),
            technology=device["technology"],
            features=device.get("features", {}),
            read_only="set" not in device.get("topics", {}),
            tags=device.get("tags")
        )
        for device in devices
    ]

Phase 2: GET /devices/{device_id}/state

Dateien:

  • apps/api/main.py

Neues Modell:

class DeviceStateResponse(BaseModel):
    device_id: str
    type: str
    room: str | None
    payload: dict[str, Any]
    ts: str

@app.get("/devices/{device_id}/state")
async def get_device_state(device_id: str) -> DeviceStateResponse:
    if device_id not in device_states:
        raise HTTPException(404, f"No state for {device_id}")
    
    devices = load_devices()
    device = next((d for d in devices if d["device_id"] == device_id), None)
    if not device:
        raise HTTPException(404, f"Device {device_id} not found")
    
    return DeviceStateResponse(
        device_id=device_id,
        type=device["type"],
        room=get_room(device_id),
        payload=device_states[device_id],
        ts=datetime.utcnow().isoformat() + "Z"
    )

Phase 3: SSE Enrichment

Dateien:

  • apps/api/main.py (event_generator())

Im event_generator() nach JSON-Parse:

if message and message["type"] == "message":
    data = message["data"]
    state_data = json.loads(data)
    
    # Enrich events
    if state_data.get("type") == "state" and state_data.get("device_id"):
        device_id = state_data["device_id"]
        devices = load_devices()
        device = next((d for d in devices if d["device_id"] == device_id), None)
        
        if device:
            state_data["device_type"] = device["type"]
            state_data["room"] = get_room(device_id)
            if "ts" not in state_data:
                state_data["ts"] = datetime.utcnow().isoformat() + "Z"
            state_data["source"] = device.get("technology")
        
        data = json.dumps(state_data)
    
    yield f"event: message\ndata: {data}\n\n"

Phase 4: GET /devices/{device_id}

Dateien:

  • apps/api/main.py
@app.get("/devices/{device_id}")
async def get_device(device_id: str) -> DeviceInfo:
    devices = load_devices()
    device = next((d for d in devices if d["device_id"] == device_id), None)
    
    if not device:
        raise HTTPException(404, f"Device {device_id} not found")
    
    return DeviceInfo(
        device_id=device["device_id"],
        type=device["type"],
        cap_version=device["cap_version"],
        room=get_room(device["device_id"]),
        friendly_name=device.get("metadata", {}).get("friendly_name", device["device_id"]),
        technology=device["technology"],
        features=device.get("features", {}),
        read_only="set" not in device.get("topics", {}),
        tags=device.get("tags")
    )

10. Zusammenfassung der Abweichungen

Bereits konform (40%)

  • POST /devices/{id}/set - Vollständig implementiert
  • SSE /realtime - Grundfunktion vorhanden
  • GET /devices - Grundstruktur vorhanden

⚠️ Teilweise konform (40%)

  • GET /devices - Fehlen wichtige Felder (cap_version, room, friendly_name, technology, read_only)
  • SSE /realtime - Events ohne device_type, room, ts

Nicht vorhanden (20%)

  • GET /devices/{device_id}/state - Komplett fehlend
  • GET /devices/{device_id} - Komplett fehlend

11. Risiko-Bewertung

🟢 Geringes Risiko

  • Alle Daten sind in devices.yaml/layout.yaml vorhanden
  • Resolver-Funktionen existieren bereits
  • Pydantic-Modelle sind etabliert
  • Keine Breaking Changes an bestehenden Endpoints nötig

🟡 Mittleres Risiko

  • SSE-Enrichment könnte Performance beeinflussen (load_devices() bei jedem Event)
    • Mitigation: Device-Lookup cachen
  • Timestamp-Handling muss konsistent sein
    • Mitigation: UTC + ISO8601 + "Z" Suffix

🔴 Kein hohes Risiko identifiziert


12. Nächste Schritte

  1. Freigabe einholen: Sollen wir mit Phase 1 (GET /devices erweitern) starten?
  2. Testing-Strategie: Sollen Tests für die neuen Endpoints geschrieben werden?
  3. Backward Compatibility: GET /devices ändert Response-Struktur - ist das OK? (Vermutlich ja, da UI diese Felder ignorieren kann)
  4. Performance: Device-Lookup-Cache implementieren vor SSE-Enrichment?

Ende der Analyse