Emergency Stop
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
166
robot/ShellyEmergencyStop.js
Normal file
166
robot/ShellyEmergencyStop.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user