554 lines
16 KiB
Markdown
554 lines
16 KiB
Markdown
# 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)
|
||
```python
|
||
@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)
|
||
```python
|
||
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:
|
||
```python
|
||
@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)
|
||
```python
|
||
@app.get("/devices/states")
|
||
async def get_device_states() -> dict[str, dict[str, Any]]:
|
||
return device_states # In-memory cache
|
||
```
|
||
|
||
### Ziel-Format
|
||
```json
|
||
{
|
||
"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
|
||
```python
|
||
@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)
|
||
```python
|
||
@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)
|
||
```json
|
||
{
|
||
"type": "state",
|
||
"device_id": "thermostat_wolfgang",
|
||
"payload": {
|
||
"current": 19.5,
|
||
"target": 21.0
|
||
}
|
||
}
|
||
```
|
||
|
||
### Ziel-Format
|
||
```json
|
||
{
|
||
"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**
|
||
```python
|
||
# 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)
|
||
```python
|
||
@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
|
||
```python
|
||
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
|
||
```python
|
||
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:**
|
||
```yaml
|
||
- 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:**
|
||
```yaml
|
||
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)
|
||
|
||
3. **SSE /realtime: Events enrichen**
|
||
- `device_type` hinzufügen
|
||
- `room` hinzufügen
|
||
- `ts` sicherstellen
|
||
|
||
4. **GET /devices/{device_id} implementieren**
|
||
- Einzelgerät-Abfrage
|
||
- Gleiche Struktur wie `/devices`-Eintrag
|
||
|
||
### 🟢 **Nice-to-have**
|
||
|
||
5. **State-Cache mit Timestamps erweitern**
|
||
- Aktuell: `device_states[id] = payload`
|
||
- Ziel: `device_states[id] = {payload, ts}`
|
||
|
||
6. **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:**
|
||
```python
|
||
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:**
|
||
```python
|
||
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:**
|
||
```python
|
||
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`
|
||
|
||
```python
|
||
@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**
|