Add Homematic IP smart lock integration (Access Point + App setup)
The lock in this WG is a Homematic IP door lock (keypad + alarm), not Nuki/Tuya as originally specced — confirmed with the user. Homematic IP has no official self-service partner API for Access-Point-only setups (no local CCU), so this uses the actively-maintained open-source `homematicip` Python library (implements the same reverse-engineered cloud protocol used by the official app) via two scripts rather than reimplementing the HTTP/HMAC handshake from scratch — lower risk of subtly wrong protocol details. - scripts/hmip_register.py: non-interactive pairing (connection request -> wait for the physical blue-button press on the Access Point -> auth token). Writes progress to a status file so the API can poll it instead of blocking a request for up to ~6 minutes. - scripts/hmip_control.py: list-devices / lock / unlock via HMIP_ACCESS_POINT + HMIP_AUTH_TOKEN env vars (not CLI args, so they don't leak into process listings). - routes/smartLock.ts: POST /smartlock/pairing/start + GET .../status (LANDLORD/ADMIN, one-time setup), GET /smartlock/devices (LANDLORD/ADMIN), POST /smartlock/devices/:id/:lock|unlock (any authenticated user — this is the tenant-facing "keyless door" use case from the spec). All lock/unlock routes 409 until pairing has produced HMIP_ACCESS_POINT/HMIP_AUTH_TOKEN. Does NOT cover temporary guest PIN codes on the keypad itself — that's handled through Homematic IP's own "eSchlüssel" app feature, which isn't exposed by this API; guest codes remain DB-only as before.
This commit is contained in:
parent
6333bc70b2
commit
d69e07f00d
72
backend/scripts/hmip_control.py
Normal file
72
backend/scripts/hmip_control.py
Normal file
@ -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 <deviceId>
|
||||
python3 hmip_control.py unlock <deviceId>
|
||||
|
||||
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())
|
||||
77
backend/scripts/hmip_register.py
Normal file
77
backend/scripts/hmip_register.py
Normal file
@ -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 <SGTIN> <STATUS_FILE>
|
||||
|
||||
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 <SGTIN> <STATUS_FILE>", 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())
|
||||
@ -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 }));
|
||||
|
||||
|
||||
117
backend/src/routes/smartLock.ts
Normal file
117
backend/src/routes/smartLock.ts
Normal file
@ -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 });
|
||||
},
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user