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.
118 lines
4.5 KiB
TypeScript
118 lines
4.5 KiB
TypeScript
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 });
|
|
},
|
|
);
|