Emergency Stop

This commit is contained in:
chk
2026-06-12 18:16:15 +02:00
parent 6fc6605080
commit 59d4cf7df4
17 changed files with 1098 additions and 540 deletions

View File

@@ -13,9 +13,12 @@ const DEFAULTS = {
kinematics: { type: 'arm3segmentlinearx', l1: 250, l2: 264, l3: 100 },
motion: { defaultFeedrate: 1000, speedMode: 'legacy', useSpeedCalc: false },
controllers: {
base: { ip: 'fluidNcBase.local', port: 2300, protocol: 'telnet', axes: ['x', 'y', 'z'], heartbeatInterval: 10000 },
elbow: { ip: 'fluidNcEllbow.local', port: 5000, protocol: 'telnet', axes: ['a', null, null], heartbeatInterval: 10000 },
hand: { ip: 'fluidNcHand.local', port: 5000, protocol: 'telnet', axes: ['c', 'e', 'b'], heartbeatInterval: 10000 }
base: { ip: 'fluidNcBase.local', port: 2300, protocol: 'telnet', axes: ['x', 'y', 'z'], heartbeatInterval: 10000 },
elbow: { ip: 'fluidNcEllbow.local', port: 5000, protocol: 'telnet', axes: ['a', null, null], heartbeatInterval: 10000 },
hand: { ip: 'fluidNcHand.local', port: 5000, protocol: 'telnet', axes: ['c', 'e', 'b'], heartbeatInterval: 10000 },
// Shelly Smart Plug: schaltet Strom für Emergency Stop.
// url: null → deaktiviert, wenn nicht in robot.json konfiguriert.
emergencyStop: { protocol: 'shelly', url: null }
}
};
@@ -82,16 +85,26 @@ function load(fsModule, processEnv, consoleObj) {
for (const key of Object.keys(DEFAULTS.controllers)) {
const def = DEFAULTS.controllers[key];
const cfg = jsonControllers[key] ?? {};
const envIpKey = ENV_IP_MAP[key];
controllers[key] = {
ip: env_[envIpKey] ?? cfg.ip ?? def.ip,
port: cfg.port ?? def.port,
protocol: cfg.protocol ?? def.protocol,
axes: cfg.axes ?? def.axes,
// Heartbeat-Intervall in ms: wie oft '?' gesendet wird.
// deadTimeout = 2 × heartbeatInterval (zwei verpasste Heartbeats → Verbindung tot).
heartbeatInterval: cfg.heartbeatInterval ?? def.heartbeatInterval,
};
if (def.protocol === 'shelly') {
// Shelly Smart Plug: nur protocol + url nötig (kein IP/Port/Axes/Heartbeat)
controllers[key] = {
protocol: cfg.protocol ?? def.protocol,
url: cfg.url ?? def.url,
};
} else {
// Telnet (FluidNC): IP + Port + Achsen + Heartbeat
const envIpKey = ENV_IP_MAP[key];
controllers[key] = {
ip: env_[envIpKey] ?? cfg.ip ?? def.ip,
port: cfg.port ?? def.port,
protocol: cfg.protocol ?? def.protocol,
axes: cfg.axes ?? def.axes,
// Heartbeat-Intervall in ms: wie oft '?' gesendet wird.
// deadTimeout = 2 × heartbeatInterval (zwei verpasste Heartbeats → Verbindung tot).
heartbeatInterval: cfg.heartbeatInterval ?? def.heartbeatInterval,
};
}
}
function axesByController(key) {

View File

@@ -14,6 +14,23 @@ class SenderInterface {
disconnect() {
throw new Error('disconnect() must be implemented by sender classes');
}
/**
* Emergency Stop: Bewegung sofort stoppen (best-effort).
* Standard-Implementierung: no-op (wird übersprungen).
* Override in TelnetSenderGRBL → sendet '!' (FluidNC Feed Hold).
* Override in ShellyEmergencyStop → schneidet Strom ab.
* @returns {Promise<{ok: boolean, skipped?: boolean}>}
*/
async emergencyStop() { return { ok: true, skipped: true }; }
/**
* Alarm-Unlock: Controller nach Strom-Neustart entsperren.
* Standard-Implementierung: no-op (wird übersprungen).
* Override in TelnetSenderGRBL → sendet '$X'.
* @returns {Promise<{ok: boolean, skipped?: boolean}>}
*/
async alarmUnlock() { return { ok: true, skipped: true }; }
}
module.exports = SenderInterface;

View File

@@ -0,0 +1,166 @@
'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.log(`[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.log(`[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 };
}
}
};

View File

@@ -305,6 +305,36 @@ module.exports = class TelnetSenderGRBL extends SenderInterface {
this.error = null;
}
/**
* Emergency Stop: sendet '!' (Feed Hold) an FluidNC.
* Best-effort — falls nicht verbunden, wird {ok:false} zurückgegeben.
* '!' ist ein FluidNC-Realtime-Byte (kein Zeilenende nötig, sofortige Wirkung).
*/
async emergencyStop() {
if (!this.tSocket) return { ok: false, error: 'not connected' };
try {
this.tSocket.write('!');
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
}
/**
* Alarm-Unlock: sendet '$X\r\n' an FluidNC.
* Entsperrt den Controller nach einem Power-Loss-Alarm (ALARM:1).
* Nur aufrufen, nachdem sichergestellt wurde, dass der Roboter frei steht.
*/
async alarmUnlock() {
if (!this.tSocket) return { ok: false, error: 'not connected' };
try {
this.tSocket.write('$X\r\n');
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
}
moveTo(mOld, mNew){
this.execCommand("G1", mOld, mNew)
}