'use strict'; const http = require('http'); const SenderInterface = require('./SenderInterface'); /** * Steuert einen Shelly Smart Plug als Emergency-Stop-Aktor. * * emergencyStop() → Switch.Set?id=0&on=false (Strom abschalten) * powerOn() → Switch.Set?id=0&on=true (Strom einschalten) * alarmUnlock() → no-op / skipped (kein FluidNC-Alarm) * * Implementiert SenderInterface — empfängt aber keinen GCode (send() ist no-op). * startRobot.js trägt diese Klasse NICHT in robot.cmdReceivers ein. */ module.exports = class ShellyEmergencyStop extends SenderInterface { /** * @param {string|null} url Shelly-RPC-URL für Switch.Set?on=false, * z.B. "http://shelly.local/rpc/Switch.Set?id=0&on=false". * null → kein Shelly konfiguriert (alle Methoden liefern {ok:false}). * @param {object} options * @param {function} [options.httpGetFn] DI für Tests; Standard: Node http.get */ constructor(url, options = {}) { super(); this._offUrl = url || null; // Explizite URL-Felder haben Vorrang vor Ableitungslogik. // Das erlaubt IP-Adressen statt .local-Hostnamen (nötig in Docker-Umgebungen, // wo mDNS nicht verfügbar ist). this._onUrl = options.urlOn || (url ? url.replace('on=false', 'on=true') : null); this._statusUrl = options.urlStatus || (url ? url.replace(/\/rpc\/.*$/, '/rpc/Switch.GetStatus?id=0') : null); this.url = url || null; // für getStatus / InfoServer-Anzeige this.state = 'ready'; this.error = null; this._httpGet = options.httpGetFn || ShellyEmergencyStop._defaultHttpGet; this._httpGetJson = options.httpGetJsonFn || ShellyEmergencyStop._defaultHttpGetJson; } /** * Standard-HTTP-GET über Node's eingebautes http-Modul (kein npm-Paket nötig). * Response-Body wird verworfen (nur Status-Code relevant). * @private */ static _defaultHttpGet(url) { return new Promise((resolve, reject) => { http.get(url, res => { resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode }); res.resume(); // Response-Body verwerfen }).on('error', reject); }); } /** * Standard-HTTP-GET mit JSON-Body-Parsing. * Für Switch.GetStatus — Response-Body enthält { output, apower, voltage, ... }. * @private */ static _defaultHttpGetJson(url) { return new Promise((resolve, reject) => { http.get(url, res => { let body = ''; res.on('data', chunk => { body += chunk; }); res.on('end', () => { try { const data = JSON.parse(body); resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, data }); } catch { resolve({ ok: false, status: res.statusCode, data: null, error: 'JSON parse error' }); } }); }).on('error', reject); }); } // ── SenderInterface ────────────────────────────────────────────────────── /** Shelly braucht kein persistentes Socket — sofort ready. */ async connect() { return this; } /** Legt keine Ressourcen frei (kein Socket), aber aktualisiert den State. */ disconnect() { this.state = 'disconnected'; } /** Kein GCode-Empfänger — wird immer ignoriert. */ send(/* cmd */) { return false; } getStatus() { return { state: this.state, url: this.url, error: this.error }; } // ── Emergency Stop / Power ─────────────────────────────────────────────── /** * Strom abschalten. * Wird von POST /api/emergency-stop im InfoServer aufgerufen. * @returns {{ ok: boolean, status?: number, error?: string }} */ async emergencyStop() { if (!this._offUrl) return { ok: false, error: 'no shelly url configured' }; try { const result = await this._httpGet(this._offUrl); this.state = result.ok ? 'stopped' : 'error'; this.error = result.ok ? null : `HTTP ${result.status}`; console.warn(`⚠️ [Shelly] power OFF → ${result.ok ? 'OK' : `HTTP ${result.status}`}`); return result; } catch (err) { this.state = 'error'; this.error = err.message; console.error(`❌ [Shelly] power OFF failed: ${err.message}`); return { ok: false, error: err.message }; } } /** * Strom wieder einschalten. * Wird von POST /api/power-on im InfoServer aufgerufen. * @returns {{ ok: boolean, status?: number, error?: string }} */ async powerOn() { if (!this._onUrl) return { ok: false, error: 'no shelly url configured' }; try { const result = await this._httpGet(this._onUrl); this.state = result.ok ? 'ready' : 'error'; this.error = result.ok ? null : `HTTP ${result.status}`; console.warn(`⚠️ [Shelly] power ON → ${result.ok ? 'OK' : `HTTP ${result.status}`}`); return result; } catch (err) { this.state = 'error'; this.error = err.message; console.error(`[Shelly] power ON failed: ${err.message}`); return { ok: false, error: err.message }; } } /** * Shelly hat keine FluidNC-Alarme — no-op, damit InfoServer-Sammeldurchlauf * den Shelly einfach überspringen kann. */ async alarmUnlock() { return { ok: true, skipped: true }; } // ── Power Status ───────────────────────────────────────────────────────── /** * Liest den aktuellen Schaltzustand vom Shelly (Switch.GetStatus?id=0). * `armed = true` → output:true → Strom AN → Roboter bestromt * `armed = false` → output:false → Strom AUS → Roboter stromlos * * Gibt zusätzlich Spannung und Wirkleistung zurück (für Diagnosezwecke). * @returns {{ ok: boolean, armed: boolean, voltage?: number, power?: number, error?: string }} */ async getArmed() { if (!this._statusUrl) return { ok: false, armed: false, error: 'no shelly url configured' }; try { const result = await this._httpGetJson(this._statusUrl); if (!result.ok || !result.data) { return { ok: false, armed: false, error: result.error || `HTTP ${result.status}` }; } return { ok: true, armed: result.data.output === true, voltage: result.data.voltage, power: result.data.apower, }; } catch (err) { return { ok: false, armed: false, error: err.message }; } } };