3 Commits

Author SHA1 Message Date
aaee480e57 seems to work 2025-11-17 11:36:19 +01:00
d0b5184270 start script 2025-11-17 10:23:42 +01:00
5bf37a19ad homekit bridge initial 2025-11-17 10:18:27 +01:00
16 changed files with 2338 additions and 1 deletions

3
.gitignore vendored
View File

@@ -61,3 +61,6 @@ Thumbs.db
# Poetry
poetry.lock
apps/homekit/homekit.state

View File

@@ -0,0 +1,553 @@
# 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**

View File

@@ -1,4 +1,144 @@
"""API main entry point."""
"""API main entry point.
API-Analyse für HomeKit-Bridge Kompatibilität
==============================================
1) GET /devices
Status: ✅ VORHANDEN (Zeile 325-343)
Aktuelles Response-Modell (DeviceInfo, Zeile 189-194):
{
"device_id": str, ✅ OK
"type": str, ✅ OK
"name": str, ⚠️ ABWEICHUNG: Erwartet wurde "short_name" (optional)
"features": dict ✅ OK
}
Bewertung:
- ✅ Liefert device_id, type, features wie erwartet
- ⚠️ Verwendet "name" statt "short_name"
- ✅ Fallback auf device_id wenn name nicht vorhanden
- Kompatibilität: HOCH - einfach "name" als "short_name" verwenden
2) GET /layout
Status: ✅ VORHANDEN (Zeile 354-387)
Aktuelles Response-Format:
{
"rooms": [
{
"name": "Schlafzimmer",
"devices": [
{
"device_id": "thermostat_wolfgang",
"title": "Thermostat Wolfgang", ← friendly_name
"icon": "thermometer",
"rank": 1
}
]
}
]
}
Mapping device_id -> room, friendly_name:
- room: Durch Iteration über rooms[].devices[] ableitbar
- friendly_name: Im Feld "title" enthalten
Bewertung:
- ✅ Alle erforderlichen Informationen vorhanden
- ⚠️ ABWEICHUNG: Verschachtelte Struktur (rooms -> devices)
- ⚠️ ABWEICHUNG: friendly_name heißt "title"
- Kompatibilität: HOCH - einfache Transformation möglich:
```python
for room in layout["rooms"]:
for device in room["devices"]:
mapping[device["device_id"]] = {
"room": room["name"],
"friendly_name": device["title"]
}
```
3) POST /devices/{device_id}/set
Status: ✅ VORHANDEN (Zeile 406-504)
Aktuelles Request-Modell (SetDeviceRequest, Zeile 182-185):
{
"type": str, ✅ OK - muss zum Gerätetyp passen
"payload": dict ✅ OK - abstraktes Kommando
}
Beispiel Light:
POST /devices/leselampe_esszimmer/set
{"type": "light", "payload": {"power": "on", "brightness": 80}}
Beispiel Thermostat:
POST /devices/thermostat_wolfgang/set
{"type": "thermostat", "payload": {"target": 21.0}}
Validierung:
- ✅ Type-spezifische Payload-Validierung (Zeile 437-487)
- ✅ Read-only Check → 405 METHOD_NOT_ALLOWED (Zeile 431-435)
- ✅ Ungültige Payload → 422 UNPROCESSABLE_ENTITY
- ✅ Device nicht gefunden → 404 NOT_FOUND
Bewertung:
- ✅ Exakt wie erwartet implementiert
- ✅ Alle geforderten Error Codes vorhanden
- Kompatibilität: PERFEKT
4) Realtime-Endpoint (SSE)
Status: ✅ VORHANDEN als GET /realtime (Zeile 608-632)
Implementierung:
- ✅ Server-Sent Events (media_type="text/event-stream")
- ✅ Redis Pub/Sub basiert (event_generator, Zeile 510-607)
- ✅ Safari-kompatibel (Heartbeats, Retry-Hints)
Aktuelles Event-Format (aus apps/abstraction/main.py:250-256):
{
"type": "state", ✅ OK
"device_id": str, ✅ OK
"payload": dict, ✅ OK - z.B. {"power":"on","brightness":80}
"ts": str ✅ OK - ISO-8601 format von datetime.now(timezone.utc)
}
Beispiel-Event:
{
"type": "state",
"device_id": "thermostat_wolfgang",
"payload": {"current": 19.5, "target": 21.0},
"ts": "2025-11-17T14:23:45.123456+00:00"
}
Bewertung:
- ✅ Alle geforderten Felder vorhanden
- ✅ Timestamp im korrekten Format
- ✅ SSE mit proper headers und error handling
- Kompatibilität: PERFEKT
ZUSAMMENFASSUNG
===============
Alle 4 geforderten Endpunkte sind implementiert!
Kompatibilität mit HomeKit-Bridge Anforderungen:
- GET /devices: HOCH (nur Name-Feld unterschiedlich)
- GET /layout: HOCH (Struktur-Transformation nötig)
- POST /devices/{id}/set: PERFEKT (1:1 wie gefordert)
- GET /realtime (SSE): PERFEKT (1:1 wie gefordert)
Erforderliche Anpassungen für Bridge:
1. GET /devices: "name" als "short_name" interpretieren ✓ trivial
2. GET /layout: Verschachtelte Struktur zu flat mapping umwandeln ✓ einfach
Keine Code-Änderungen in der API erforderlich!
Die Bridge kann die bestehenden Endpoints direkt nutzen.
"""
import asyncio
import json

465
apps/homekit/README.md Normal file
View File

@@ -0,0 +1,465 @@
# HomeKit Bridge
HomeKit Accessory Protocol (HAP) Bridge für das Home Automation System.
## Überblick
Diese HomeKit-Bridge ermöglicht die Steuerung aller Geräte über Apple HomeKit / Home-App auf iPhone/iPad/Mac.
**Architektur:**
- Läuft auf Raspberry Pi im lokalen Netzwerk
- Kommuniziert ausschließlich über REST-API (kein direkter MQTT/Redis-Zugriff)
- Verwendet [HAP-Python](https://github.com/ikalchev/HAP-python) für HomeKit-Protokoll
- Unterstützt Echtzeit-Updates über Server-Sent Events (SSE)
## Implementierungsanleitung für Copilot
<details>
<summary>Copilot-Aufgabe: Implementiere die HomeKit-Bridge (HAP-Python) für unser System</summary>
### Kontext
- Wir verwenden HAP-Python als HomeKit-Bridge.
- Die Bridge läuft auf einem Raspberry Pi im gleichen LAN wie die iPhones.
- Die Bridge kommuniziert AUSSCHLIESSLICH über die REST-API mit dem restlichen System (KEIN direkter MQTT-/Redis-Zugriff).
- Geräte- und Raum-Infos kommen aus:
- `GET /devices`
- `GET /layout`
und werden über `device_id` gejoint.
- Die Abbildung von abstrakten Gerätetypen/Features auf HomeKit-Services/Characteristics steht in der Datei `apps/homekit/homekit_mapping.md` (Markdown-Tabelle). Bitte orientiere dich daran.
### Ziel
Implementiere eine lauffähige HomeKit-Bridge in `apps/homekit`, die:
- beim Start Geräte & Räume über die API lädt,
- daraus anhand der Mapping-Tabelle HomeKit-Accessories erzeugt,
- Zustandsänderungen über einen Realtime-Endpoint der API empfängt und in HomeKit spiegeln kann,
- Set-Kommandos aus HomeKit (z.B. Licht an/aus, Helligkeit, Zieltemperatur) per `POST /devices/{id}/set` an die API sendet,
- saubere Start-/Stop-Logs ausgibt,
- einfache Test-/Startanweisungen für den Raspberry Pi und die Home-App (iPhone) in Kommentaren dokumentiert.
### WICHTIG
- Bitte benutze HAP-Python (pyhap).
- Keine direkte MQTT- oder Redis-Kommunikation in der Bridge.
- Nutze HTTP(S) zur API (z.B. mit requests oder httpx; gerne sync, HAP-Python ist selbst eventloop-orientiert).
- Nutze die Mapping-Tabelle in `apps/homekit/homekit_mapping.md` als Leitlinie für die Accessory-Typen.
### Paketstruktur
```
apps/homekit/
├── __init__.py
├── main.py # Einstiegspunkt
├── api_client.py # REST-Client zur API
├── device_registry.py # Join /devices + /layout → internes Device-Modell
├── accessories/
│ ├── base.py # gemeinsame Basisklasse/n für Accessories
│ ├── light.py # Light-Accessories
│ ├── thermostat.py
│ ├── contact.py
│ ├── sensor.py # temp_humidity etc.
│ └── cover.py # optional, Rollladen
└── tests/ # rudimentäre Tests/Checks
```
## Teil 1: Internes Gerätemodell / Device-Registry
### 1.1 Erstelle in `apps/homekit/device_registry.py`:
**Dataklasse/Model Device:**
- `device_id: str`
- `type: str` - "light","thermostat","outlet","contact","temp_humidity","cover",...
- `name: str` - Kurzname aus `GET /devices.name`
- `friendly_name: str` - title aus `GET /layout` (fallback name)
- `room: str | None` - Raumname aus layout
- `features: dict[str, bool]`
- `read_only: bool` - heuristisch: Sensor-/Kontakt-Typen sind read_only
**Klasse DeviceRegistry mit Funktionen:**
- `def load_from_api(api: ApiClient) -> DeviceRegistry`:
- ruft `GET /devices` und `GET /layout` auf,
- joint über `device_id`,
- erstellt Device-Instanzen.
- `get_all(): list[Device]`
- `get_by_id(device_id: str) -> Device | None`
**Akzeptanz:**
- `load_from_api` funktioniert mit der bestehenden Struktur von `/devices` und `/layout`:
- `/devices` liefert mindestens `{device_id, type, name, features}`
- `/layout` liefert eine Struktur, aus der `device_id → room + title` ableitbar ist
- Der Join über `device_id` klappt; fehlende Layout-Einträge werden toleriert
## Teil 2: API-Client
### 2.1 Erstelle in `apps/homekit/api_client.py`:
**Klasse ApiClient mit:**
- `__init__(self, base_url: str, token: str | None = None, timeout: int = 5)`
- **Methoden:**
- `get_devices() -> list[dict]`: GET /devices
- `get_layout() -> dict`: GET /layout
- `get_device_state(device_id: str) -> dict`: GET /devices/{id}/state
- `post_device_set(device_id: str, type: str, payload: dict) -> None`: POST /devices/{id}/set
- `stream_realtime() -> Iterator[dict]`:
- GET /realtime als SSE, yield jedes Event als dict:
`{"type":"state","device_id":...,"payload":{...},"ts":...}`
**Auth:**
- Wenn ein API-Token via ENV `HOMEKIT_API_TOKEN` gesetzt ist, nutze HTTP-Header:
`Authorization: Bearer <token>`
**Akzeptanz:**
- ApiClient ist robust:
- bei Netzwerkfehlern gibt es sinnvolle Exceptions/Logs,
- `stream_realtime` behandelt Reconnect (z.B. einfache Endlosschleife mit Backoff).
- Es werden keine MQTT-Details verwendet, nur HTTP.
## Teil 3: HomeKit-Accessory-Klassen (HAP-Python)
### 3.1 Erstelle in `apps/homekit/accessories/base.py`:
**Basisklasse BaseDeviceAccessory(Accessory) mit:**
- Referenz auf Device (aus DeviceRegistry)
- Referenz auf ApiClient
- Methoden zum:
- Registrieren von HAP-Characteristics und Set-Handlern
- Aktualisieren von Characteristics bei eingehenden Events
- Logging
### 3.2 Erstelle spezifische Accessory-Klassen basierend auf homekit_mapping.md:
**LightAccessories (`apps/homekit/accessories/light.py`):**
- On/Off (nur power)
- Dimmable (power + brightness)
- Color (power + brightness + color_hsb)
**ThermostatAccessory:**
- CurrentTemperature, TargetTemperature, Mode (so weit in Mapping definiert)
**ContactAccessory:**
- ContactSensorState (open/closed)
**TempHumidityAccessory:**
- TemperatureSensor (CurrentTemperature)
- HumiditySensor (CurrentRelativeHumidity)
**OutletAccessory:**
- On/Off
**CoverAccessory (optional):**
- WindowCovering mit CurrentPosition/TargetPosition
**Die Mapping-Tabelle in `homekit_mapping.md` ist die normative Referenz:**
- Bitte lies die Tabelle und mappe `abstract_type + Features → passende Accessory-Klasse und Characteristics`
- Wo die Tabelle `Status=TODO/REVIEW` hat:
- Implementiere nur das, was eindeutig ist,
- lasse TODO-Kommentare an den entsprechenden Stellen im Code.
**Akzeptanz:**
- Für die abstrakten Typen, die bereits in `devices.yaml` und Mapping-Tabelle klar definiert sind (z.B. light, thermostat, contact, temp_humidity), existieren passende Accessory-Klassen.
- Set-Operationen erzeugen korrekte Payloads für `POST /devices/{id}/set`:
- Light: `{"type":"light","payload":{"power":"on"/"off", "brightness":..., "hue":..., "sat":...}}`
- Thermostat: `{"type":"thermostat","payload":{"target":...}}`
- Contact: read_only → keine Set-Handler
- Temp/Humidity: read_only → keine Set-Handler
## Teil 4: Bridge-Setup mit HAP-Python
### 4.1 Implementiere in `apps/homekit/main.py`:
**env-Konfiguration:**
- `HOMEKIT_NAME` (default: "Home Automation Bridge")
- `HOMEKIT_PIN` (z.B. "031-45-154")
- `HOMEKIT_PORT` (default 51826)
- `API_BASE` (z.B. "http://api:8001" oder extern)
- `HOMEKIT_API_TOKEN` (optional)
**Funktion `build_bridge(driver, api_client: ApiClient) -> Bridge`:**
- DeviceRegistry via `load_from_api(api_client)` laden.
- Für jedes Device anhand Mapping-Tabelle die passende Accessory-Klasse instanziieren.
- Einen Bridge-Accessory (`pyhap.accessory.Bridge`) erstellen.
- Alle Device-Accessories der Bridge hinzufügen.
**Realtime-Event-Loop:**
- In einem Hintergrund-Thread oder ThreadPool:
- `api_client.stream_realtime()` iterieren,
- für jedes Event `device_id → Accessory` finden,
- Characteristics updaten.
- Thread wird beim Shutdown sauber beendet.
**`main()`:**
- Logging einrichten.
- ApiClient erstellen.
- `AccessoryDriver(port=HOMEKIT_PORT, persist_file="homekit.state")` erstellen.
- Bridge via `build_bridge(driver, api_client)` bauen.
- Bridge dem Driver hinzufügen.
- Realtime-Thread starten.
- `driver.start()` aufrufen.
- Auf KeyboardInterrupt reagieren und sauber stoppen.
**Akzeptanz:**
- Beim Start loggt die Bridge:
- Anzahl Devices,
- auf welchem Port sie als HomeKit-Bridge lauscht,
- welches API_BASE verwendet wird.
- Die Datei `homekit.state` wird im Arbeitsverzeichnis bzw. einem konfigurierbaren Ordner abgelegt (um Pairing-Info persistent zu halten).
- Die Bridge übersteht API-Neustarts (Realtime-Loop reconnectet) und Netzwerkflaps.
## Teil 5: Tests & Testanweisungen
### 5.1 Lege in `apps/homekit/tests/` einfache Tests/Checks an:
**Unit-Tests (pytest), soweit ohne echtes HomeKit möglich:**
- Test für `DeviceRegistry.load_from_api()` mit Mock-Antworten aus `/devices` und `/layout`:
- Korrekte Join-Logik,
- Korrekte room/friendly_name-Zuordnung.
- Test für set-Payload-Erzeugung pro Accessory:
- z.B. LightAccessory: On=True → POST /devices/{id}/set wird mit korrektem Payload aufgerufen (über Mock ApiClient).
### Allgemein
- Nutze möglichst sinnvolle Typannotationen und Docstrings.
- Hinterlasse TODO-Kommentare an Stellen, wo die Mapping-Tabelle explizit Status=TODO/REVIEW hat.
- Ändere KEINE bestehenden API-Endpunkte; verlasse dich nur auf deren aktuelles Verhalten (GET /devices, GET /layout, /realtime, POST /devices/{id}/set).
## Installation & Setup
### Voraussetzungen
- Python 3.9+
- Raspberry Pi im gleichen LAN wie iPhone/iPad
- API-Server erreichbar (z.B. `http://api:8001`)
### Installation
```bash
cd apps/homekit
pip install -r requirements.txt
```
### Umgebungsvariablen
Erstelle eine `.env` Datei oder setze folgende Variablen:
```bash
export API_BASE="http://YOUR_API_IP:8001"
export HOMEKIT_API_TOKEN="your-token-if-needed" # optional
export HOMEKIT_PIN="031-45-154"
export HOMEKIT_NAME="Home Automation Bridge"
export HOMEKIT_PORT="51826"
export HOMEKIT_PERSIST_FILE="homekit.state"
```
## Start
```bash
python -m apps.homekit.main
```
**Erwartete Logs:**
```
Loading devices from API...
Loaded X devices from API
Bridge built with Y accessories
HomeKit Bridge started on port 51826
Starting realtime event loop...
```
## Pairing mit iPhone (Home-App)
1. **Voraussetzungen:**
- iPhone im gleichen WLAN wie Raspberry Pi
- Bridge läuft und zeigt "started on port 51826"
2. **Home-App öffnen:**
- Öffne die Home-App auf dem iPhone
- Tippe auf "+" → "Gerät hinzufügen"
- Wähle "Weitere Optionen..." oder "Code fehlt oder kann nicht gescannt werden"
3. **Bridge auswählen:**
- Die Bridge sollte in der Nähe-Liste erscheinen (z.B. "Home Automation Bridge")
- Tippe auf die Bridge
4. **PIN eingeben:**
- Gib den PIN ein: `031-45-154` (oder dein `HOMEKIT_PIN`)
- Format: `XXX-XX-XXX`
5. **Konfiguration abschließen:**
- Geräte werden geladen
- Räume werden automatisch aus `layout.yaml` übernommen
- Geräte können nun über Home-App gesteuert werden
## Funktionstests
### Test 1: Licht einschalten
**Aktion:** Lampe in Home-App antippen (On)
**Erwartung:**
- API-Log zeigt: `POST /devices/{id}/set` mit `{"type":"light","payload":{"power":"on"}}`
- Physische Lampe oder Simulator schaltet ein
### Test 2: Helligkeit ändern (dimmbare Lampe)
**Aktion:** Helligkeits-Slider bewegen (z.B. 75%)
**Erwartung:**
- `POST /devices/{id}/set` mit `brightness` ca. 75
- Lampe dimmt entsprechend
### Test 3: Farbe ändern (Farblampe)
**Aktion:** Farbe in Home-App wählen
**Erwartung:**
- `POST /devices/{id}/set` mit `hue`/`sat` Werten
- Lampe wechselt Farbe
### Test 4: Thermostat Zieltemperatur
**Aktion:** Zieltemperatur auf 22°C setzen
**Erwartung:**
- `POST /devices/{id}/set` mit `target=22`
- `CurrentTemperature` wird über `/realtime` aktualisiert
### Test 5: Kontaktsensor (read-only)
**Aktion:** Fenster physisch öffnen/schließen
**Erwartung:**
- `/realtime` sendet Event
- Home-App zeigt "Offen" oder "Geschlossen"
### Test 6: Temperatur-/Feuchtigkeitssensor
**Aktion:** Werte ändern sich (z.B. Heizung an)
**Erwartung:**
- `/realtime` Events aktualisieren Werte
- Home-App zeigt aktuelle Temperatur/Luftfeuchtigkeit
## Troubleshooting
### Bridge erscheint nicht in Home-App
- **Netzwerk prüfen:** iPhone und RPi im gleichen Subnet?
- **Firewall prüfen:** Port 51826 muss erreichbar sein
- **Logs prüfen:** Fehler beim Start?
- **mDNS/Bonjour:** Funktioniert Bonjour im Netzwerk?
### Geräte reagieren nicht
- **API-Logs prüfen:** Kommen POST-Requests an?
- **Realtime-Verbindung:** Läuft `/realtime` Event-Loop? (Log-Meldungen)
- **API-Endpoints testen:** Manuell mit `curl` testen
### Pairing schlägt fehl
- **State-Datei löschen:** `rm homekit.state` und Bridge neu starten
- **PIN-Format prüfen:** Muss `XXX-XX-XXX` Format haben
- **Alte Pairings löschen:** In Home-App unter "Home-Einstellungen" → "HomeKit-Geräte zurücksetzen"
### Realtime-Updates funktionieren nicht
- **SSE-Verbindung prüfen:** Logs zeigen "Starting realtime event loop..."?
- **API-Endpoint testen:** `curl -N http://api:8001/realtime`
- **Firewall/Proxy:** Blockiert etwas SSE-Streams?
## Docker Deployment
### Dockerfile
```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "-m", "apps.homekit.main"]
```
### docker-compose.yml
```yaml
services:
homekit:
build: ./apps/homekit
environment:
- API_BASE=http://api:8001
- HOMEKIT_PIN=031-45-154
- HOMEKIT_NAME=Home Automation Bridge
ports:
- "51826:51826"
volumes:
- ./data/homekit:/data
network_mode: "host" # Wichtig für mDNS/Bonjour Discovery
restart: unless-stopped
```
**Wichtig:** `network_mode: "host"` ist erforderlich für mDNS/Bonjour Discovery, damit die Bridge im lokalen Netzwerk gefunden werden kann.
## Architektur
```
┌─────────────┐
│ iPhone/ │
│ Home-App │
└──────┬──────┘
│ HomeKit (HAP)
│ Port 51826
┌──────▼──────────────────┐
│ HomeKit Bridge │
│ (HAP-Python) │
│ - Device Registry │
│ - Accessory Mapping │
│ - SSE Event Loop │
└──────┬──────────────────┘
│ HTTP REST API
│ (GET /devices, POST /set, SSE /realtime)
┌──────▼──────────────────┐
│ API Server │
│ (FastAPI) │
└──────┬──────────────────┘
│ MQTT
┌──────▼──────────────────┐
│ Abstraction Layer │
│ (Zigbee2MQTT, MAX!) │
└─────────────────────────┘
```
## Weitere Dokumentation
- **API-Mapping:** Siehe `homekit_mapping.md` für Device-Type → HomeKit-Service Mapping
- **API-Dokumentation:** Siehe API-Server README für Endpoint-Dokumentation
- **HAP-Python Docs:** https://github.com/ikalchev/HAP-python
## Entwicklung
### Tests ausführen
```bash
pytest apps/homekit/tests/
```
### Logs
Die Bridge gibt detaillierte Logs aus:
- `INFO`: Normale Betriebsmeldungen (Start, Device-Anzahl, etc.)
- `DEBUG`: Detaillierte State-Updates und API-Calls
- `ERROR`: Fehler bei API-Kommunikation oder Accessory-Updates
Log-Level über Environment-Variable steuern:
```bash
export LOG_LEVEL=DEBUG
```
## Lizenz
Siehe Hauptprojekt-Lizenz.

View File

@@ -0,0 +1,5 @@
"""
HomeKit Accessories Package
This package contains HomeKit accessory implementations for different device types.
"""

View File

@@ -0,0 +1,48 @@
"""
Contact Sensor Accessory Implementation for HomeKit
Implements contact sensor (window/door sensors):
- ContactSensorState (read-only): 0=Detected, 1=Not Detected
"""
from pyhap.accessory import Accessory
from pyhap.const import CATEGORY_SENSOR
class ContactAccessory(Accessory):
"""Contact sensor for doors and windows."""
category = CATEGORY_SENSOR
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
"""
Initialize the contact sensor accessory.
Args:
driver: HAP driver instance
device: Device object from DeviceRegistry
api_client: ApiClient for sending commands
display_name: Optional display name (defaults to device.friendly_name)
"""
name = display_name or device.friendly_name or device.name
super().__init__(driver, name, *args, **kwargs)
self.device = device
self.api_client = api_client
# Add ContactSensor service
self.contact_service = self.add_preload_service('ContactSensor')
# Get ContactSensorState characteristic
self.contact_state_char = self.contact_service.get_characteristic('ContactSensorState')
# Initialize with "not detected" (closed)
self.contact_state_char.set_value(1)
def update_state(self, state_payload):
"""Update state from API event."""
if "contact" in state_payload:
# API sends: "open" or "closed"
# HomeKit: 0=Contact Detected (closed), 1=Contact Not Detected (open)
is_open = state_payload["contact"] == "open"
homekit_state = 1 if is_open else 0
self.contact_state_char.set_value(homekit_state)

View File

@@ -0,0 +1,177 @@
"""
Light Accessory Implementations for HomeKit
Implements different light types:
- OnOffLightAccessory: Simple on/off light
- DimmableLightAccessory: Light with brightness control
- ColorLightAccessory: RGB light with full color control
"""
from pyhap.accessory import Accessory
from pyhap.const import CATEGORY_LIGHTBULB
class OnOffLightAccessory(Accessory):
"""Simple On/Off Light without dimming or color."""
category = CATEGORY_LIGHTBULB
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
"""
Initialize the light accessory.
Args:
driver: HAP driver instance
device: Device object from DeviceRegistry
api_client: ApiClient for sending commands
display_name: Optional display name (defaults to device.friendly_name)
"""
name = display_name or device.friendly_name or device.name
super().__init__(driver, name, *args, **kwargs)
self.device = device
self.api_client = api_client
# Add Lightbulb service with On characteristic
self.lightbulb_service = self.add_preload_service('Lightbulb')
# Get the On characteristic and set callback
self.on_char = self.lightbulb_service.get_characteristic('On')
self.on_char.setter_callback = self.set_on
def set_on(self, value):
"""Called when HomeKit wants to turn light on/off."""
power_state = "on" if value else "off"
payload = {
"type": "light",
"payload": {"power": power_state}
}
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
def update_state(self, state_payload):
"""Update state from API event."""
if "power" in state_payload:
is_on = state_payload["power"] == "on"
self.on_char.set_value(is_on)
class DimmableLightAccessory(OnOffLightAccessory):
"""Dimmable Light with brightness control."""
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
# Don't call super().__init__() yet - we need to set up service first
name = display_name or device.friendly_name or device.name
Accessory.__init__(self, driver, name, *args, **kwargs)
self.device = device
self.api_client = api_client
self.category = CATEGORY_LIGHTBULB
# Create Lightbulb service with all characteristics at once
from pyhap.loader import Loader
loader = Loader()
# Create the service
lightbulb_service = loader.get_service('Lightbulb')
# Add On characteristic
on_char = lightbulb_service.get_characteristic('On')
on_char.setter_callback = self.set_on
self.on_char = on_char
# Add Brightness characteristic
brightness_char = loader.get_char('Brightness')
brightness_char.set_value(0)
brightness_char.setter_callback = self.set_brightness
lightbulb_service.add_characteristic(brightness_char)
self.brightness_char = brightness_char
# Now add the complete service to the accessory
self.add_service(lightbulb_service)
self.lightbulb_service = lightbulb_service
def set_brightness(self, value):
"""Called when HomeKit wants to change brightness."""
payload = {
"type": "light",
"payload": {"brightness": int(value)}
}
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
def update_state(self, state_payload):
"""Update state from API event."""
super().update_state(state_payload)
if "brightness" in state_payload:
self.brightness_char.set_value(state_payload["brightness"])
class ColorLightAccessory(DimmableLightAccessory):
"""RGB Light with full color control."""
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
# Don't call super().__init__() - build everything from scratch
name = display_name or device.friendly_name or device.name
Accessory.__init__(self, driver, name, *args, **kwargs)
self.device = device
self.api_client = api_client
self.category = CATEGORY_LIGHTBULB
# Create Lightbulb service with all characteristics at once
from pyhap.loader import Loader
loader = Loader()
# Create the service
lightbulb_service = loader.get_service('Lightbulb')
# Add On characteristic
on_char = lightbulb_service.get_characteristic('On')
on_char.setter_callback = self.set_on
self.on_char = on_char
# Add Brightness characteristic
brightness_char = loader.get_char('Brightness')
brightness_char.set_value(0)
brightness_char.setter_callback = self.set_brightness
lightbulb_service.add_characteristic(brightness_char)
self.brightness_char = brightness_char
# Add Hue characteristic
hue_char = loader.get_char('Hue')
hue_char.set_value(0)
hue_char.setter_callback = self.set_hue
lightbulb_service.add_characteristic(hue_char)
self.hue_char = hue_char
# Add Saturation characteristic
saturation_char = loader.get_char('Saturation')
saturation_char.set_value(0)
saturation_char.setter_callback = self.set_saturation
lightbulb_service.add_characteristic(saturation_char)
self.saturation_char = saturation_char
# Now add the complete service to the accessory
self.add_service(lightbulb_service)
self.lightbulb_service = lightbulb_service
def set_hue(self, value):
"""Called when HomeKit wants to change hue."""
payload = {
"type": "light",
"payload": {"hue": int(value)}
}
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
def set_saturation(self, value):
"""Called when HomeKit wants to change saturation."""
payload = {
"type": "light",
"payload": {"sat": int(value)}
}
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
def update_state(self, state_payload):
"""Update state from API event."""
super().update_state(state_payload)
if "hue" in state_payload:
self.hue_char.set_value(state_payload["hue"])
if "sat" in state_payload:
self.saturation_char.set_value(state_payload["sat"])

View File

@@ -0,0 +1,57 @@
"""
Outlet/Relay Accessory Implementation for HomeKit
Implements simple relay/outlet (on/off switch):
- On (read/write)
- OutletInUse (always true)
"""
from pyhap.accessory import Accessory
from pyhap.const import CATEGORY_OUTLET
class OutletAccessory(Accessory):
"""Relay/Outlet for simple on/off control."""
category = CATEGORY_OUTLET
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
"""
Initialize the outlet accessory.
Args:
driver: HAP driver instance
device: Device object from DeviceRegistry
api_client: ApiClient for sending commands
display_name: Optional display name (defaults to device.friendly_name)
"""
name = display_name or device.friendly_name or device.name
super().__init__(driver, name, *args, **kwargs)
self.device = device
self.api_client = api_client
# Add Outlet service
self.outlet_service = self.add_preload_service('Outlet')
# Get On characteristic and set callback
self.on_char = self.outlet_service.get_characteristic('On')
self.on_char.setter_callback = self.set_on
# OutletInUse is always true (relay is always functional)
self.in_use_char = self.outlet_service.get_characteristic('OutletInUse')
self.in_use_char.set_value(True)
def set_on(self, value):
"""Called when HomeKit wants to turn outlet on/off."""
power_state = "on" if value else "off"
payload = {
"type": "relay",
"payload": {"power": power_state}
}
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
def update_state(self, state_payload):
"""Update state from API event."""
if "power" in state_payload:
is_on = state_payload["power"] == "on"
self.on_char.set_value(is_on)

View File

@@ -0,0 +1,46 @@
"""
Temperature & Humidity Sensor Accessory Implementation for HomeKit
Implements combined temperature and humidity sensor:
- CurrentTemperature (read-only)
- CurrentRelativeHumidity (read-only)
"""
from pyhap.accessory import Accessory
from pyhap.const import CATEGORY_SENSOR
class TempHumidityAccessory(Accessory):
"""Combined temperature and humidity sensor."""
category = CATEGORY_SENSOR
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
"""
Initialize the temp/humidity sensor accessory.
Args:
driver: HAP driver instance
device: Device object from DeviceRegistry
api_client: ApiClient for sending commands
display_name: Optional display name (defaults to device.friendly_name)
"""
name = display_name or device.friendly_name or device.name
super().__init__(driver, name, *args, **kwargs)
self.device = device
self.api_client = api_client
# Add TemperatureSensor service
self.temp_service = self.add_preload_service('TemperatureSensor')
self.current_temp_char = self.temp_service.get_characteristic('CurrentTemperature')
# Add HumiditySensor service
self.humidity_service = self.add_preload_service('HumiditySensor')
self.current_humidity_char = self.humidity_service.get_characteristic('CurrentRelativeHumidity')
def update_state(self, state_payload):
"""Update state from API event."""
if "temperature" in state_payload:
self.current_temp_char.set_value(float(state_payload["temperature"]))
if "humidity" in state_payload:
self.current_humidity_char.set_value(float(state_payload["humidity"]))

View File

@@ -0,0 +1,72 @@
"""
Thermostat Accessory Implementation for HomeKit
Implements thermostat control according to homekit_mapping.md:
- CurrentTemperature (read-only)
- TargetTemperature (read/write)
- CurrentHeatingCoolingState (fixed: 1 = heating)
- TargetHeatingCoolingState (fixed: 3 = auto)
"""
from pyhap.accessory import Accessory
from pyhap.const import CATEGORY_THERMOSTAT
class ThermostatAccessory(Accessory):
"""Thermostat with temperature control."""
category = CATEGORY_THERMOSTAT
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
"""
Initialize the thermostat accessory.
Args:
driver: HAP driver instance
device: Device object from DeviceRegistry
api_client: ApiClient for sending commands
display_name: Optional display name (defaults to device.friendly_name)
"""
name = display_name or device.friendly_name or device.name
super().__init__(driver, name, *args, **kwargs)
self.device = device
self.api_client = api_client
# Add Thermostat service
self.thermostat_service = self.add_preload_service('Thermostat')
# Get characteristics
self.current_temp_char = self.thermostat_service.get_characteristic('CurrentTemperature')
self.target_temp_char = self.thermostat_service.get_characteristic('TargetTemperature')
self.current_heating_cooling_char = self.thermostat_service.get_characteristic('CurrentHeatingCoolingState')
self.target_heating_cooling_char = self.thermostat_service.get_characteristic('TargetHeatingCoolingState')
# Set callback for target temperature
self.target_temp_char.setter_callback = self.set_target_temperature
# Set fixed heating/cooling states (mode is always "auto")
# CurrentHeatingCoolingState: 0=Off, 1=Heat, 2=Cool
self.current_heating_cooling_char.set_value(1) # Always heating
# TargetHeatingCoolingState: 0=Off, 1=Heat, 2=Cool, 3=Auto
self.target_heating_cooling_char.set_value(3) # Always auto
# Set temperature range (5-30°C as per UI)
self.target_temp_char.properties['minValue'] = 5
self.target_temp_char.properties['maxValue'] = 30
self.target_temp_char.properties['minStep'] = 0.5
def set_target_temperature(self, value):
"""Called when HomeKit wants to change target temperature."""
payload = {
"type": "thermostat",
"payload": {"target": float(value)}
}
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
def update_state(self, state_payload):
"""Update state from API event."""
if "current" in state_payload:
self.current_temp_char.set_value(float(state_payload["current"]))
if "target" in state_payload:
self.target_temp_char.set_value(float(state_payload["target"]))

161
apps/homekit/api_client.py Normal file
View File

@@ -0,0 +1,161 @@
"""
API Client for HomeKit Bridge
Handles all HTTP communication with the REST API backend.
"""
import logging
from typing import Dict, List, Iterator, Optional
import httpx
import json
import time
logger = logging.getLogger(__name__)
class ApiClient:
"""HTTP client for communicating with the home automation API."""
def __init__(self, base_url: str, token: Optional[str] = None, timeout: int = 5):
"""
Initialize API client.
Args:
base_url: Base URL of the API (e.g., "http://192.168.1.100:8001")
token: Optional API token for authentication
timeout: Request timeout in seconds
"""
self.base_url = base_url.rstrip('/')
self.timeout = timeout
self.headers = {}
if token:
self.headers['Authorization'] = f'Bearer {token}'
def get_devices(self) -> List[Dict]:
"""
Get list of all devices.
Returns:
List of device dictionaries
"""
try:
response = httpx.get(
f'{self.base_url}/devices',
headers=self.headers,
timeout=self.timeout
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get devices: {e}")
raise
def get_layout(self) -> Dict:
"""
Get layout information (rooms and device assignments).
Returns:
Layout dictionary with room structure
"""
try:
response = httpx.get(
f'{self.base_url}/layout',
headers=self.headers,
timeout=self.timeout
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get layout: {e}")
raise
def get_device_state(self, device_id: str) -> Dict:
"""
Get current state of a specific device.
Args:
device_id: Device identifier
Returns:
Device state dictionary
"""
try:
response = httpx.get(
f'{self.base_url}/devices/{device_id}/state',
headers=self.headers,
timeout=self.timeout
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get state for {device_id}: {e}")
raise
def post_device_set(self, device_id: str, device_type: str, payload: Dict) -> None:
"""
Send command to a device.
Args:
device_id: Device identifier
device_type: Device type (e.g., "light", "thermostat")
payload: Command payload (e.g., {"power": "on", "brightness": 75})
"""
try:
data = {
"type": device_type,
"payload": payload
}
response = httpx.post(
f'{self.base_url}/devices/{device_id}/set',
headers=self.headers,
json=data,
timeout=self.timeout
)
response.raise_for_status()
logger.debug(f"Set {device_id}: {payload}")
except Exception as e:
logger.error(f"Failed to set {device_id}: {e}")
raise
def stream_realtime(self, reconnect_delay: int = 5) -> Iterator[Dict]:
"""
Stream real-time events from the API using Server-Sent Events (SSE).
Automatically reconnects on connection loss.
Args:
reconnect_delay: Seconds to wait before reconnecting
Yields:
Event dictionaries: {"type": "state", "device_id": "...", "payload": {...}, "ts": ...}
"""
while True:
try:
logger.info("Connecting to realtime event stream...")
with httpx.stream(
'GET',
f'{self.base_url}/realtime',
headers=self.headers,
timeout=None # No timeout for streaming
) as response:
response.raise_for_status()
logger.info("Connected to realtime event stream")
for line in response.iter_lines():
if line.startswith('data: '):
data_str = line[6:] # Remove 'data: ' prefix
try:
event = json.loads(data_str)
yield event
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse SSE event: {e}")
except httpx.HTTPError as e:
logger.error(f"Realtime stream error: {e}")
logger.info(f"Reconnecting in {reconnect_delay} seconds...")
time.sleep(reconnect_delay)
except Exception as e:
logger.error(f"Unexpected error in realtime stream: {e}")
logger.info(f"Reconnecting in {reconnect_delay} seconds...")
time.sleep(reconnect_delay)

View File

@@ -0,0 +1,138 @@
"""
Device Registry for HomeKit Bridge
Loads devices from API and joins with layout information.
"""
import logging
from typing import Dict, List, Optional
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class Device:
"""Represents a device with combined info from /devices and /layout."""
device_id: str
type: str # "light", "thermostat", "relay", "contact", "temp_humidity", "cover"
name: str # Short name from /devices
friendly_name: str # Display title from /layout (fallback to name)
room: Optional[str] # Room name from layout
features: Dict[str, bool] # Feature flags (e.g., {"power": true, "brightness": true})
read_only: bool # True for sensors that don't accept commands
class DeviceRegistry:
"""Registry of all devices loaded from the API."""
def __init__(self, devices: List[Device]):
"""
Initialize registry with devices.
Args:
devices: List of Device objects
"""
self._devices = devices
self._by_id = {d.device_id: d for d in devices}
@classmethod
def load_from_api(cls, api_client) -> 'DeviceRegistry':
"""
Load devices from API and join with layout information.
Args:
api_client: ApiClient instance
Returns:
DeviceRegistry with all devices
"""
# Get devices and layout
devices_data = api_client.get_devices()
layout_data = api_client.get_layout()
# Build lookup: device_id -> (room_name, title)
layout_map = {}
if isinstance(layout_data, dict) and 'rooms' in layout_data:
rooms_list = layout_data['rooms']
if isinstance(rooms_list, list):
for room in rooms_list:
if isinstance(room, dict):
room_name = room.get('name', 'Unknown')
devices_in_room = room.get('devices', [])
for device_info in devices_in_room:
if isinstance(device_info, dict):
device_id = device_info.get('device_id')
title = device_info.get('title', '')
if device_id:
layout_map[device_id] = (room_name, title)
# Create Device objects
devices = []
for dev_data in devices_data:
device_id = dev_data.get('device_id')
if not device_id:
logger.warning(f"Device without device_id: {dev_data}")
continue
# Get layout info
room_name, title = layout_map.get(device_id, (None, ''))
# Determine if read-only (sensors don't accept set commands)
device_type = dev_data.get('type', '')
read_only = device_type in ['contact', 'temp_humidity', 'motion', 'smoke']
device = Device(
device_id=device_id,
type=device_type,
name=dev_data.get('name', device_id),
friendly_name=title or dev_data.get('name', device_id),
room=room_name,
features=dev_data.get('features', {}),
read_only=read_only
)
devices.append(device)
logger.info(f"Loaded {len(devices)} devices from API")
return cls(devices)
def get_all(self) -> List[Device]:
"""Get all devices."""
return self._devices.copy()
def get_by_id(self, device_id: str) -> Optional[Device]:
"""
Get device by ID.
Args:
device_id: Device identifier
Returns:
Device or None if not found
"""
return self._by_id.get(device_id)
def get_by_type(self, device_type: str) -> List[Device]:
"""
Get all devices of a specific type.
Args:
device_type: Device type (e.g., "light", "thermostat")
Returns:
List of matching devices
"""
return [d for d in self._devices if d.type == device_type]
def get_by_room(self, room: str) -> List[Device]:
"""
Get all devices in a specific room.
Args:
room: Room name
Returns:
List of devices in the room
"""
return [d for d in self._devices if d.room == room]

View File

@@ -0,0 +1,111 @@
<!--
Copilot-Aufgabe: HomeKit-Accessory-Mapping-Tabelle aus bestehender Implementierung ableiten
Ziel:
Erstelle eine Mapping-Tabelle, die beschreibt, wie unsere abstrakten Gerätetypen
(type/Features aus devices.yaml, apps/abstraction und apps/api) auf HomeKit-Accessories
und Characteristics (HAP-Python) abgebildet werden sollen.
Die Tabelle soll:
- sich an der bestehenden Implementierung und Konfiguration orientieren:
- devices.yaml (Typen, Features)
- apps/abstraction (Capabilities, Payload-Strukturen, Topics)
- apps/api (DTOs, /devices-Response, /devices/{id}/set, /realtime-Events)
- für jeden bekannten Gerätetyp (z.B. light, thermostat, outlet, contact, temp_humidity, cover, switch)
festhalten:
- Abstract Type (type)
- Relevante Features (Features-Flags, z.B. power, brightness, color_hsb, target, current, contact, humidity)
- HomeKit Service (z.B. LightBulb, Thermostat, Outlet, ContactSensor, TemperatureSensor, HumiditySensor, WindowCovering)
- HomeKit Characteristics (z.B. On, Brightness, Hue, Saturation, CurrentTemperature, TargetTemperature, ContactSensorState, CurrentPosition, TargetPosition)
- State-Mapping (abstraktes payload → HomeKit-Characteristics)
- Set-Mapping (HomeKit-Characteristics → HTTP POST /devices/{id}/set Payload)
- Status (OK / REVIEW / TODO)
Vorgehen für Copilot:
1. Analysiere die bestehenden Typen und Features:
- devices.yaml → "type" und "features" pro Gerät
- apps/abstraction → Capabilities, State-/Set-Modelle (z.B. light@1.2.0, thermostat, contact, temp_humidity)
- apps/api:
- Response-Modell von GET /devices (device_id, type, name, features)
- Struktur von GET /devices/{id}/state (payload-Felder je Typ)
- Struktur der Events in /realtime (payload-Felder je Typ)
- Body von POST /devices/{id}/set (type, payload)
2. Lege aufgrund dieser Analyse alle aktuell verwendeten abstrakten Typen an, z.B.:
- light (mit power, brightness, color_hsb)
- thermostat (mit current, target, mode)
- outlet
- contact
- temp_humidity
- ggf. cover, switch, etc., falls vorhanden
3. Erstelle eine Tabelle in Markdown mit folgenden Spalten:
| Abstract Type | Features (aus devices.yaml) | HomeKit Service | HomeKit Characteristics | State-Mapping (payload → HK) | Set-Mapping (HK → payload) | Status |
|---------------|-----------------------------|-----------------|-------------------------|------------------------------|----------------------------|--------|
Beispiele für Inhalte:
- Abstract Type: `light`
- Features: `power, brightness, color_hsb`
- HomeKit Service: `LightBulb`
- HomeKit Characteristics: `On, Brightness, Hue, Saturation`
- State-Mapping: `payload.power -> On; payload.brightness -> Brightness; payload.hue -> Hue; payload.sat -> Saturation`
- Set-Mapping: `On -> {"type":"light","payload":{"power":"on"/"off"}} ...`
- Status: `OK` wenn Mapping klar, `REVIEW` wenn du unsicher bist, `TODO` wenn Informationen fehlen
4. Nutze vorhandene Informationen aus dem Code:
- Wenn ein Gerätetyp in apps/abstraction bereits ein klares State-/Set-Modell hat, übernimm das in State-/Set-Mapping.
- Wenn bestimmte Features noch nicht im Code genutzt werden, markiere die entsprechenden Mapping-Zeilen mit Status=TODO.
- Falls unklar ist, welcher HomeKit-Service für einen Typ am besten passt, mache einen Vorschlag und setze Status=REVIEW.
5. Markiere explizit, wo man später noch eingreifen muss:
- In der Spalte "Status" mit `REVIEW` oder `TODO`.
- Optional zusätzliche kurze Kommentare unter der Tabelle wie:
- `<!-- TODO: RGBW-Unterstützung prüfen -->`
- `<!-- REVIEW: Soll temp_humidity als zwei Accessories oder kombiniert abgebildet werden? -->`
Akzeptanz-Kriterien:
- Es existiert am Ende dieses Dokuments eine Markdown-Tabelle mit mindestens allen abstrakten Gerätetypen, die aktuell in devices.yaml / apps/abstraction / apps/api verwendet werden.
- Für jeden Typ sind HomeKit Service und Characteristics ausgefüllt oder mit Status=REVIEW/TODO markiert.
- State-/Set-Mapping ist für alle bereits gut verstandenen Typen (z.B. einfache Lichter, Thermostate, Kontakte, Temp/Humidity-Sensoren) konkret beschrieben.
- Stellen, an denen die aktuelle Implementierung keine klaren Informationen liefert, sind sichtbar mit Status=REVIEW oder TODO gekennzeichnet.
- Copilot ändert an keiner anderen Stelle des Codes etwas, sondern erzeugt nur diese Tabelle/Dokumentation.
-->
# HomeKit-Accessory-Mapping-Tabelle
Dieses Dokument beschreibt das Mapping zwischen unseren abstrakten Gerätetypen (aus `devices.yaml`, `apps/abstraction` und `apps/api`) und HomeKit-Accessories/Characteristics (HAP-Python).
## Mapping-Tabelle
| Abstract Type | Features (aus devices.yaml) | HomeKit Service | HomeKit Characteristics | State-Mapping (payload → HK) | Set-Mapping (HK → payload) | Status |
|---------------|-----------------------------|-----------------|-------------------------|------------------------------|----------------------------|--------|
| `light` | `power, brightness, color_hsb` | `LightBulb` | `On, Brightness, Hue, Saturation` | `payload.power -> On (true/false)`<br>`payload.brightness -> Brightness (0-100)`<br>`payload.hue -> Hue (0-360)`<br>`payload.sat -> Saturation (0-100)` | `On -> {"type":"light","payload":{"power":"on"/"off"}}`<br>`Brightness -> {"type":"light","payload":{"brightness":0-100}}`<br>`Hue/Saturation -> {"type":"light","payload":{"hue":0-360,"sat":0-100}}` | OK |
| `light` | `power, brightness` | `LightBulb` | `On, Brightness` | `payload.power -> On (true/false)`<br>`payload.brightness -> Brightness (0-100)` | `On -> {"type":"light","payload":{"power":"on"/"off"}}`<br>`Brightness -> {"type":"light","payload":{"brightness":0-100}}` | OK |
| `light` | `power` | `LightBulb` | `On` | `payload.power -> On (true/false)` | `On -> {"type":"light","payload":{"power":"on"/"off"}}` | OK |
| `thermostat` | `current, target` | `Thermostat` | `CurrentTemperature, TargetTemperature, CurrentHeatingCoolingState, TargetHeatingCoolingState` | `payload.current -> CurrentTemperature (°C)`<br>`payload.target -> TargetTemperature (°C)`<br>`CurrentHeatingCoolingState -> 1 (heat, fest)`<br>`TargetHeatingCoolingState -> 3 (auto, fest)` | `TargetTemperature -> {"type":"thermostat","payload":{"target":temp}}`<br>**Hinweis:** Mode ist immer "auto", keine Mode-Änderung über HomeKit | OK |
| `relay` | `power` | `Outlet` | `On, OutletInUse` | `payload.power -> On (true/false)`<br>`payload.power -> OutletInUse (true/false)` | `On -> {"type":"relay","payload":{"power":"on"/"off"}}` | OK |
| `contact` | `contact` | `ContactSensor` | `ContactSensorState` | `payload.contact -> ContactSensorState (0=detected, 1=not detected)` | N/A (read-only sensor) | OK |
| `temp_humidity` | `temperature, humidity` | `TemperatureSensor` + `HumiditySensor` | `CurrentTemperature, CurrentRelativeHumidity` | `payload.temperature -> CurrentTemperature (°C)`<br>`payload.humidity -> CurrentRelativeHumidity (%)` | N/A (read-only sensors) | REVIEW |
| `cover` | `position, tilt` | `WindowCovering` | `CurrentPosition, TargetPosition, CurrentHorizontalTiltAngle, TargetHorizontalTiltAngle` | `payload.position -> CurrentPosition (0-100)`<br>`payload.tilt -> CurrentHorizontalTiltAngle (-90 to 90)` | `TargetPosition -> {"type":"cover","payload":{"position":0-100}}`<br>`TargetHorizontalTiltAngle -> {"type":"cover","payload":{"tilt":-90 to 90}}` | TODO |
| `switch` | `power` | `Switch` | `On` | `payload.power -> On (true/false)` | `On -> {"type":"switch","payload":{"power":"on"/"off"}}` | OK |
## Offene Punkte und Kommentare
<!-- OK: thermostat - Verwendet nur Mode "auto" (TargetHeatingCoolingState=3, CurrentHeatingCoolingState=1). Keine Mode-Änderung über HomeKit möglich. -->
<!-- REVIEW: temp_humidity - Soll als ein kombiniertes Accessory oder zwei separate Accessories (TemperatureSensor + HumiditySensor) abgebildet werden? HAP-Python erlaubt beides -->
<!-- TODO: cover - Position/Tilt-Mapping validieren. Sind die Wertebereiche korrekt? Gibt es zusätzliche Features wie PositionState (opening/closing/stopped)? -->
<!-- TODO: RGBW-Unterstützung - Prüfen, ob separate ColorTemperature-Characteristic für Warmweiß/Kaltweiß benötigt wird -->
<!-- TODO: Prüfen, ob weitere Gerätetypen in devices.yaml existieren (z.B. motion, door, lock, fan, etc.) und diese ergänzen -->
## Hinweise zur Implementierung
- **State-Updates**: Alle State-Änderungen von MQTT/API werden auf entsprechende HomeKit-Characteristics gemappt und via `set_value()` aktualisiert
- **Set-Befehle**: HomeKit-Charakteristik-Änderungen werden in HTTP POST-Requests an `/devices/{id}/set` umgewandelt
- **Read-only Sensors**: Sensoren (contact, temp_humidity) unterstützen nur State-Updates, keine Set-Befehle
- **Bidirektionales Mapping**: Änderungen müssen in beide Richtungen synchronisiert werden (HomeKit ↔ Abstraction Layer)

272
apps/homekit/main.py Normal file
View File

@@ -0,0 +1,272 @@
"""
HomeKit Bridge Main Module
Implementiert eine HAP-Python Bridge, die Geräte über die REST-API lädt
und über HomeKit verfügbar macht.
Für detaillierte Implementierungsanweisungen, Tests und Deployment-Informationen
siehe README.md in diesem Verzeichnis.
"""
import os
import logging
import signal
import sys
import threading
from typing import Optional
from pyhap.accessory_driver import AccessoryDriver
from pyhap.accessory import Bridge
from .accessories.light import (
OnOffLightAccessory,
DimmableLightAccessory,
ColorLightAccessory,
)
from .accessories.thermostat import ThermostatAccessory
from .accessories.contact import ContactAccessory
from .accessories.sensor import TempHumidityAccessory
from .accessories.outlet import OutletAccessory
from .api_client import ApiClient
from .device_registry import DeviceRegistry
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Environment configuration
HOMEKIT_NAME = os.getenv("HOMEKIT_NAME", "Home Automation Bridge")
HOMEKIT_PIN = os.getenv("HOMEKIT_PIN", "031-45-154")
HOMEKIT_PORT = int(os.getenv("HOMEKIT_PORT", "51826"))
API_BASE = os.getenv("API_BASE", "http://api:8001")
HOMEKIT_API_TOKEN = os.getenv("HOMEKIT_API_TOKEN")
PERSIST_FILE = os.getenv("HOMEKIT_PERSIST_FILE", "homekit.state")
def build_bridge(driver: AccessoryDriver, api_client: ApiClient) -> Bridge:
"""
Build the HomeKit Bridge with all device accessories.
Args:
driver: HAP-Python AccessoryDriver
api_client: API client for communication with backend
Returns:
Bridge accessory with all device accessories attached
"""
logger.info("Loading devices from API...")
registry = DeviceRegistry.load_from_api(api_client)
devices = registry.get_all()
logger.info(f"Loaded {len(devices)} devices from API")
# Create bridge
bridge = Bridge(driver, HOMEKIT_NAME)
accessory_map = {} # device_id -> Accessory instance
for device in devices:
try:
accessory = create_accessory_for_device(device, api_client, driver)
if accessory:
# Set room information in the accessory (HomeKit will use this for suggestions)
if device.room:
# Store room info for potential future use
accessory._room_name = device.room
bridge.add_accessory(accessory)
accessory_map[device.device_id] = accessory
logger.info(f"Added accessory: {device.friendly_name} ({device.type}) in room: {device.room or 'Unknown'}")
else:
logger.warning(f"No accessory mapping for device: {device.name} ({device.type})")
except Exception as e:
logger.error(f"Failed to create accessory for {device.name}: {e}", exc_info=True)
# Store accessory_map on bridge for realtime updates
bridge._accessory_map = accessory_map
logger.info(f"Bridge built with {len(accessory_map)} accessories")
return bridge
def get_accessory_name(device) -> str:
"""
Build accessory name including room information.
Args:
device: Device object from DeviceRegistry
Returns:
Name string like "Device Name (Room)" or just "Device Name" if no room
"""
base_name = device.friendly_name or device.name
if device.room:
return f"{base_name} ({device.room})"
return base_name
def create_accessory_for_device(device, api_client: ApiClient, driver: AccessoryDriver):
"""
Create appropriate HomeKit accessory based on device type and features.
Maps device types to HomeKit accessories according to homekit_mapping.md.
"""
device_type = device.type
features = device.features
display_name = get_accessory_name(device)
# Light accessories
if device_type == "light":
if features.get("color_hsb"):
return ColorLightAccessory(driver, device, api_client, display_name=display_name)
elif features.get("brightness"):
return DimmableLightAccessory(driver, device, api_client, display_name=display_name)
else:
return OnOffLightAccessory(driver, device, api_client, display_name=display_name)
# Thermostat
elif device_type == "thermostat":
return ThermostatAccessory(driver, device, api_client, display_name=display_name)
# Contact sensor
elif device_type == "contact":
return ContactAccessory(driver, device, api_client, display_name=display_name)
# Temperature/Humidity sensor
elif device_type == "temp_humidity_sensor":
return TempHumidityAccessory(driver, device, api_client, display_name=display_name)
# Relay/Outlet
elif device_type == "relay":
return OutletAccessory(driver, device, api_client, display_name=display_name)
# Cover/Blinds (optional)
elif device_type == "cover":
# TODO: Implement CoverAccessory based on homekit_mapping.md
logger.warning(f"Cover accessory not yet implemented for {device.name}")
return None
# TODO: Add more device types as needed (lock, motion, etc.)
return None
def realtime_event_loop(api_client: ApiClient, bridge: Bridge, stop_event: threading.Event):
"""
Background thread that listens to realtime events and updates accessories.
Args:
api_client: API client
bridge: HomeKit bridge with accessories
stop_event: Threading event to signal shutdown
"""
logger.info("Starting realtime event loop...")
while not stop_event.is_set():
try:
for event in api_client.stream_realtime():
if stop_event.is_set():
break
# Handle state update events
if event.get("type") == "state":
device_id = event.get("device_id")
payload = event.get("payload", {})
# Find corresponding accessory
accessory = bridge._accessory_map.get(device_id)
if accessory and hasattr(accessory, 'update_state'):
try:
accessory.update_state(payload)
logger.debug(f"Updated state for {device_id}: {payload}")
except Exception as e:
logger.error(f"Error updating accessory {device_id}: {e}")
except Exception as e:
if not stop_event.is_set():
logger.error(f"Realtime stream error: {e}. Reconnecting in 5s...")
stop_event.wait(5) # Backoff before reconnect
logger.info("Realtime event loop stopped")
def main():
logger.info("=" * 60)
logger.info(f"Starting HomeKit Bridge: {HOMEKIT_NAME}")
logger.info(f"API Base: {API_BASE}")
logger.info(f"HomeKit Port: {HOMEKIT_PORT}")
logger.info(f"PIN: {HOMEKIT_PIN}")
logger.info("=" * 60)
# Create API client
api_client = ApiClient(
base_url=API_BASE,
token=HOMEKIT_API_TOKEN,
timeout=10
)
# Test API connectivity
try:
devices = api_client.get_devices()
logger.info(f"API connectivity OK - {len(devices)} devices available")
except Exception as e:
logger.error(f"Failed to connect to API at {API_BASE}: {e}")
logger.error("Please check API_BASE and network connectivity")
sys.exit(1)
# Create AccessoryDriver
driver = AccessoryDriver(
port=HOMEKIT_PORT,
persist_file=PERSIST_FILE
)
# Build bridge with all accessories
try:
bridge = build_bridge(driver, api_client)
except Exception as e:
logger.error(f"Failed to build bridge: {e}", exc_info=True)
sys.exit(1)
# Add bridge to driver
driver.add_accessory(accessory=bridge)
# Setup realtime event thread
stop_event = threading.Event()
realtime_thread = threading.Thread(
target=realtime_event_loop,
args=(api_client, bridge, stop_event),
daemon=True
)
# Signal handlers for graceful shutdown
def signal_handler(sig, frame):
logger.info("Received shutdown signal, stopping...")
stop_event.set()
driver.stop()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Start realtime thread
realtime_thread.start()
# Start the bridge
logger.info(f"HomeKit Bridge started on port {HOMEKIT_PORT}")
logger.info(f"Pair with PIN: {HOMEKIT_PIN}")
logger.info("Press Ctrl+C to stop")
try:
driver.start()
except KeyboardInterrupt:
logger.info("KeyboardInterrupt received")
finally:
logger.info("Stopping bridge...")
stop_event.set()
realtime_thread.join(timeout=5)
logger.info("Bridge stopped")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,13 @@
# HomeKit Bridge Dependencies
# HAP-Python - HomeKit Accessory Protocol implementation
HAP-python>=4.9.0
# HTTP client for API communication (REST API only - no MQTT)
httpx>=0.24.0
# Utilities
python-dotenv>=1.0.0 # For .env file support
pydantic>=2.0.0 # For data validation (shared with main app)
# Logging and monitoring (optional)
# sentry-sdk>=1.0.0 # Optional: Error tracking

76
apps/homekit/start_bridge.sh Executable file
View File

@@ -0,0 +1,76 @@
#!/bin/bash
# HomeKit Bridge Startup Script
# This script sets up the virtual environment, installs dependencies, and starts the bridge
set -e # Exit on error
# Determine script directory (apps/homekit)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Navigate to workspace root (two levels up from apps/homekit)
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
cd "$WORKSPACE_ROOT"
echo "🏠 HomeKit Bridge Startup"
echo "========================="
echo " Working dir: $WORKSPACE_ROOT"
echo ""
# Virtual environment path
VENV_DIR="$SCRIPT_DIR/venv"
# Check if virtual environment exists
if [ ! -d "$VENV_DIR" ]; then
echo "📦 Virtual environment not found. Creating..."
# Try to use Python 3.12 or 3.13 (3.14 has compatibility issues with HAP-Python)
if command -v python3.13 &> /dev/null; then
PYTHON_CMD=python3.13
elif command -v python3.12 &> /dev/null; then
PYTHON_CMD=python3.12
elif command -v python3.11 &> /dev/null; then
PYTHON_CMD=python3.11
else
PYTHON_CMD=python3
echo "⚠️ Warning: Using default python3. HAP-Python may not work with Python 3.14+"
fi
echo " Using: $PYTHON_CMD"
$PYTHON_CMD -m venv "$VENV_DIR"
echo "✅ Virtual environment created at $VENV_DIR"
fi
# Activate virtual environment
echo "🔧 Activating virtual environment..."
source "$VENV_DIR/bin/activate"
# Install/update dependencies
echo "📥 Installing dependencies from requirements.txt..."
pip install --upgrade pip -q
pip install -r "$SCRIPT_DIR/requirements.txt" -q
echo "✅ Dependencies installed"
# Set environment variables (with defaults)
export HOMEKIT_NAME="${HOMEKIT_NAME:-Home Automation Bridge}"
export HOMEKIT_PIN="${HOMEKIT_PIN:-031-45-154}"
export HOMEKIT_PORT="${HOMEKIT_PORT:-51826}"
export API_BASE="${API_BASE:-http://172.19.1.11:8001}"
export HOMEKIT_API_TOKEN="${HOMEKIT_API_TOKEN:-}"
export HOMEKIT_PERSIST_FILE="${HOMEKIT_PERSIST_FILE:-$SCRIPT_DIR/homekit.state}"
# Display configuration
echo ""
echo "⚙️ Configuration:"
echo " Bridge Name: $HOMEKIT_NAME"
echo " Bridge PIN: $HOMEKIT_PIN"
echo " Bridge Port: $HOMEKIT_PORT"
echo " API Base URL: $API_BASE"
echo " Persist File: $HOMEKIT_PERSIST_FILE"
echo ""
# Start the bridge
echo "🚀 Starting HomeKit Bridge..."
echo " (Press Ctrl+C to stop)"
echo ""
# Run the bridge from workspace root with correct module path
python -m apps.homekit.main