diff --git a/backend/scripts/hmip_control.py b/backend/scripts/hmip_control.py new file mode 100644 index 0000000..494fa7b --- /dev/null +++ b/backend/scripts/hmip_control.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +""" +Steuerungs-Skript für das Homematic-IP-Türschloss. +Nutzt AUTH_TOKEN/ACCESS_POINT aus Umgebungsvariablen (nicht als CLI-Argument, +damit sie nicht in Prozesslisten/Logs auftauchen). + +Usage: + python3 hmip_control.py list-devices + python3 hmip_control.py lock + python3 hmip_control.py unlock + +Gibt bei list-devices JSON-Liste aller DoorLockDrive-Geräte aus +({"id": ..., "label": ..., "lockState": ...}), sonst {"ok": true}. +""" +import asyncio +import json +import os +import sys + +import homematicip +from homematicip.base.enums import LockState +from homematicip.connection.connection_context import ConnectionContextBuilder +from homematicip.home import Home + + +async def get_home(): + access_point = os.environ["HMIP_ACCESS_POINT"] + auth_token = os.environ["HMIP_AUTH_TOKEN"] + context = await ConnectionContextBuilder.build_context_async(access_point, auth_token=auth_token) + home = Home() + await home.init_async(access_point, auth_token=auth_token) + await home.get_current_state_async() + return home + + +async def main(): + if len(sys.argv) < 2: + print(json.dumps({"error": "Kommando fehlt (list-devices|lock|unlock)"})) + sys.exit(1) + + command = sys.argv[1] + home = await get_home() + + door_locks = [d for d in home.devices if type(d).__name__.startswith("DoorLockDrive")] + + if command == "list-devices": + print(json.dumps([ + {"id": d.id, "label": d.label, "lockState": str(d.lockState)} + for d in door_locks + ])) + return + + if command in ("lock", "unlock"): + if len(sys.argv) != 3: + print(json.dumps({"error": "deviceId fehlt"})) + sys.exit(1) + device_id = sys.argv[2] + target = next((d for d in door_locks if d.id == device_id), None) + if not target: + print(json.dumps({"error": f"Türschloss {device_id} nicht gefunden"})) + sys.exit(1) + state = LockState.LOCKED if command == "lock" else LockState.UNLOCKED + await target.set_lock_state_async(state) + print(json.dumps({"ok": True})) + return + + print(json.dumps({"error": f"Unbekanntes Kommando: {command}"})) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/hmip_register.py b/backend/scripts/hmip_register.py new file mode 100644 index 0000000..57e9680 --- /dev/null +++ b/backend/scripts/hmip_register.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +""" +Nicht-interaktive Registrierung eines neuen Homematic-IP-Clients (analog zu +homematicip.cli.hmip_generate_auth_token, aber ohne input()-Prompts, damit +die Node-API sie als Hintergrundprozess starten und den Fortschritt pollen +kann). + +Usage: python3 hmip_register.py + +Schreibt fortlaufend JSON-Status nach STATUS_FILE: + {"state": "waiting_for_button"} + {"state": "success", "authToken": "...", "accessPoint": "...", "clientId": "..."} + {"state": "error", "message": "..."} +""" +import asyncio +import json +import sys + +import homematicip.auth +from homematicip.connection.connection_context import ConnectionContextBuilder +from homematicip.connection.rest_connection import RestConnection + + +def write_status(path, data): + with open(path, "w") as f: + json.dump(data, f) + + +async def main(): + if len(sys.argv) != 3: + print("Usage: hmip_register.py ", file=sys.stderr) + sys.exit(1) + + access_point = sys.argv[1].replace("-", "").upper() + status_file = sys.argv[2] + + if len(access_point) != 24: + write_status(status_file, {"state": "error", "message": "Ungültige SGTIN (erwartet 24 Zeichen ohne Bindestriche)"}) + sys.exit(1) + + try: + context = await ConnectionContextBuilder.build_context_async(access_point) + connection = RestConnection(context, log_status_exceptions=False) + auth = homematicip.auth.Auth(connection, context.client_auth_token, access_point) + + response = await auth.connection_request(access_point, "wg-verwaltung") + if response.status != 200: + body = json.loads(response.text) + write_status(status_file, {"state": "error", "message": body.get("errorCode", "Unbekannter Fehler")}) + sys.exit(1) + + write_status(status_file, {"state": "waiting_for_button"}) + + attempts = 0 + while not await auth.is_request_acknowledged(): + attempts += 1 + if attempts > 180: # 180 * 2s = 6 Minuten Timeout + write_status(status_file, {"state": "error", "message": "Zeitüberschreitung: Knopf am Access Point wurde nicht rechtzeitig gedrückt"}) + sys.exit(1) + await asyncio.sleep(2) + + auth_token = await auth.request_auth_token() + client_id = await auth.confirm_auth_token(auth_token) + + write_status(status_file, { + "state": "success", + "authToken": auth_token, + "accessPoint": access_point, + "clientId": client_id, + }) + except Exception as e: + write_status(status_file, {"state": "error", "message": str(e)}) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/src/app.ts b/backend/src/app.ts index 0c882c3..7c37e23 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -17,6 +17,7 @@ import { contractsRouter } from './routes/contracts'; import { utilityStatementsRouter } from './routes/utilityStatements'; import { craftsmenRouter } from './routes/craftsmen'; import { ratingsRouter } from './routes/ratings'; +import { smartLockRouter } from './routes/smartLock'; export function createApp() { const app = express(); @@ -52,6 +53,7 @@ export function createApp() { app.use('/v1', utilityStatementsRouter); app.use('/v1', craftsmenRouter); app.use('/v1', ratingsRouter); + app.use('/v1', smartLockRouter); app.get('/health', (_req, res) => res.status(200).json({ ok: true })); diff --git a/backend/src/routes/smartLock.ts b/backend/src/routes/smartLock.ts new file mode 100644 index 0000000..0ac57cd --- /dev/null +++ b/backend/src/routes/smartLock.ts @@ -0,0 +1,117 @@ +import { Router, Response } from 'express'; +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth'; + +/** + * Steuerung des Homematic-IP-Türschlosses (Access Point + App, kein + * lokaler CCU-Zugriff). Da Homematic IP für dieses Setup keine offizielle + * Partner-API anbietet, wird die aktiv gepflegte, quelloffene Community- + * Bibliothek `homematicip` (Python) über zwei Skripte angesprochen + * (backend/scripts/hmip_register.py, hmip_control.py) statt das + * Cloud-Protokoll selbst nachzubauen. + * + * Echte Steuerung ist erst nutzbar, wenn HMIP_ACCESS_POINT/HMIP_AUTH_TOKEN + * gesetzt sind (per einmaliger Kopplung über /smartlock/pairing/*, dabei + * muss der blaue Knopf am Access Point physisch gedrückt werden). + */ +export const smartLockRouter = Router(); + +const SCRIPTS_DIR = path.join(__dirname, '..', '..', 'scripts'); +const PAIRING_STATUS_FILE = path.join('/tmp', 'hmip_pairing_status.json'); + +function runPythonScript(script: string, args: string[], env: NodeJS.ProcessEnv = process.env): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve) => { + const proc = spawn('python3', [path.join(SCRIPTS_DIR, script), ...args], { env }); + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (d) => (stdout += d.toString())); + proc.stderr.on('data', (d) => (stderr += d.toString())); + proc.on('close', (code) => resolve({ stdout, stderr, code: code ?? 1 })); + }); +} + +function hmipConfigured(): boolean { + return Boolean(process.env.HMIP_ACCESS_POINT && process.env.HMIP_AUTH_TOKEN); +} + +// Startet die einmalige Kopplung (Landlord/Admin). Läuft im Hintergrund, +// da bis zu ~6 Minuten auf den Knopfdruck am Access Point gewartet wird. +smartLockRouter.post( + '/smartlock/pairing/start', + requireAuth, + requireRole('LANDLORD', 'ADMIN'), + async (req: AuthedRequest, res: Response) => { + const { sgtin } = req.body || {}; + if (!sgtin || typeof sgtin !== 'string') { + return res.status(400).json({ error: 'sgtin ist erforderlich' }); + } + + try { + fs.writeFileSync(PAIRING_STATUS_FILE, JSON.stringify({ state: 'starting' })); + } catch { + return res.status(500).json({ error: 'Konnte Status-Datei nicht anlegen' }); + } + + const proc = spawn('python3', [path.join(SCRIPTS_DIR, 'hmip_register.py'), sgtin, PAIRING_STATUS_FILE], { + detached: true, + stdio: 'ignore', + }); + proc.unref(); + + res.status(202).json({ message: 'Kopplung gestartet. Bitte jetzt den blauen Knopf am Access Point drücken (bis zu 6 Minuten Zeit). Status via GET /smartlock/pairing/status abfragen.' }); + }, +); + +smartLockRouter.get( + '/smartlock/pairing/status', + requireAuth, + requireRole('LANDLORD', 'ADMIN'), + async (_req: AuthedRequest, res: Response) => { + if (!fs.existsSync(PAIRING_STATUS_FILE)) { + return res.status(200).json({ state: 'idle' }); + } + try { + const data = JSON.parse(fs.readFileSync(PAIRING_STATUS_FILE, 'utf-8')); + res.status(200).json(data); + } catch { + res.status(200).json({ state: 'idle' }); + } + }, +); + +smartLockRouter.get('/smartlock/status', requireAuth, async (_req: AuthedRequest, res: Response) => { + res.status(200).json({ configured: hmipConfigured() }); +}); + +smartLockRouter.get('/smartlock/devices', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (_req: AuthedRequest, res: Response) => { + if (!hmipConfigured()) { + return res.status(409).json({ error: 'Homematic IP ist noch nicht gekoppelt (siehe /smartlock/pairing/start)' }); + } + const { stdout, stderr, code } = await runPythonScript('hmip_control.py', ['list-devices']); + if (code !== 0) { + return res.status(502).json({ error: 'Abfrage fehlgeschlagen', detail: stderr || stdout }); + } + try { + res.status(200).json({ devices: JSON.parse(stdout) }); + } catch { + res.status(502).json({ error: 'Unerwartete Antwort von Homematic IP', detail: stdout }); + } +}); + +smartLockRouter.post( + '/smartlock/devices/:deviceId/:action(lock|unlock)', + requireAuth, + async (req: AuthedRequest, res: Response) => { + if (!hmipConfigured()) { + return res.status(409).json({ error: 'Homematic IP ist noch nicht gekoppelt (siehe /smartlock/pairing/start)' }); + } + const { deviceId, action } = req.params; + const { stdout, stderr, code } = await runPythonScript('hmip_control.py', [action, deviceId]); + if (code !== 0) { + return res.status(502).json({ error: 'Türsteuerung fehlgeschlagen', detail: stderr || stdout }); + } + res.status(200).json({ ok: true }); + }, +);