16 KiB
HomeKit-Bridge API-Modell: Analyse der bestehenden Implementierung
Analysedatum: 17. November 2025
Analysierte Dateien:
apps/api/main.pyapps/api/routes/groups_scenes.pyconfig/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 inDeviceInfoexponiert - Lösung: Feld zu
DeviceInfohinzufügen und ausdevice["cap_version"]befüllen
❌ room
- Vorhanden in layout.yaml: Ja, indirekt über Raum-Zuordnung
- Problem: Aktuell nur über separaten Endpoint
/devices/{device_id}/roomverfügbar - Lösung: Room-Mapping in
/devicesintegrieren (Resolver bereits vorhanden inapps/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, nichtmetadata.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
DeviceInfohinzufü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
DeviceInfozurü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
payloadim 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}/statehelfen
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
/devicesroomhat - 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)
-
GET /devices: Fehlende Felder ergänzen
cap_versionaus devices.yamlroomviaget_room()friendly_nameausmetadata.friendly_nametechnologyaus devices.yamlread_onlyberechnen
-
GET /devices/{device_id}/state implementieren
- Neuer Endpoint
- State aus Cache + Metadaten
- Timestamp hinzufügen
🟡 Wichtig (Bridge funktioniert, aber eingeschränkt)
-
SSE /realtime: Events enrichen
device_typehinzufügenroomhinzufügentssicherstellen
-
GET /devices/{device_id} implementieren
- Einzelgerät-Abfrage
- Gleiche Struktur wie
/devices-Eintrag
🟢 Nice-to-have
-
State-Cache mit Timestamps erweitern
- Aktuell:
device_states[id] = payload - Ziel:
device_states[id] = {payload, ts}
- Aktuell:
-
SSE: source-Feld hinzufügen
- Aus
device["technology"]ableiten
- Aus
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
- Freigabe einholen: Sollen wir mit Phase 1 (GET /devices erweitern) starten?
- Testing-Strategie: Sollen Tests für die neuen Endpoints geschrieben werden?
- Backward Compatibility: GET /devices ändert Response-Struktur - ist das OK? (Vermutlich ja, da UI diese Felder ignorieren kann)
- Performance: Device-Lookup-Cache implementieren vor SSE-Enrichment?
Ende der Analyse