Files
appRobotDriver/robot/ShellyEmergencyStop.js
2026-06-12 18:47:28 +02:00

167 lines
6.5 KiB
JavaScript

'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;
this._onUrl = url ? url.replace('on=false', 'on=true') : null;
// Switch.GetStatus?id=0 — leitet Status-URL aus der Switch.Set-URL ab
this._statusUrl = 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 };
}
}
};