316 lines
11 KiB
Markdown
316 lines
11 KiB
Markdown
# 15 — Emergency Stop (NotAus) — Implementierung
|
||
|
||
Datum: 2026-06-12
|
||
Status: ✅ implementiert und getestet
|
||
|
||
---
|
||
|
||
## Überblick
|
||
|
||
Der Emergency Stop schaltet den Roboter auf zwei Wegen sofort ab:
|
||
|
||
1. **Feed Hold** — alle FluidNC-Controller erhalten das Realtime-Byte `!` (sofortiger Bewegungs-Stopp, keine Verzögerung)
|
||
2. **Stromabschaltung** — ein Shelly Smart Plug schneidet die Versorgungsspannung über einen HTTP-GET-Aufruf ab
|
||
|
||
Beide Aktionen laufen **parallel** (Promise.allSettled). Fällt der Shelly-Aufruf aus, stoppt der Feed-Hold trotzdem die Bewegung — und umgekehrt.
|
||
|
||
Nach dem Neustart der Anlage wird mit **Alarm-Unlock** (`$X`) jeder FluidNC-Controller aus dem ALARM-Zustand befreit, bevor neue Bewegungsbefehle akzeptiert werden.
|
||
|
||
---
|
||
|
||
## Architektur
|
||
|
||
```
|
||
robot.json (controllers.emergencyStop)
|
||
│
|
||
▼
|
||
RobotConfig.load() ← liest protocol, url, urlOn, urlStatus
|
||
│
|
||
▼
|
||
startRobot.js ← verzweigt auf ShellyEmergencyStop (isGCodeReceiver: false)
|
||
│ oder TelnetSenderGRBL (isGCodeReceiver: true)
|
||
▼
|
||
InfoServer (senders[]) ← hält alle Sender-Instanzen
|
||
│
|
||
├── POST /api/emergency-stop → instance.emergencyStop() auf ALLEN Sendern
|
||
├── POST /api/power-on → instance.powerOn() nur auf Shelly-Sendern
|
||
├── POST /api/alarm-unlock → instance.alarmUnlock() auf ALLEN Sendern
|
||
└── GET /api/power-status → instance.getArmed() des ersten Shelly-Senders
|
||
```
|
||
|
||
---
|
||
|
||
## Beteiligte Dateien
|
||
|
||
| Datei | Rolle |
|
||
|---|---|
|
||
| `robot/SenderInterface.js` | Abstrakte Basisklasse; Default-no-ops für `emergencyStop()` und `alarmUnlock()` |
|
||
| `robot/TelnetSenderGRBL.js` | Überschreibt `emergencyStop()` → sendet `!`; `alarmUnlock()` → sendet `$X\r\n` |
|
||
| `robot/ShellyEmergencyStop.js` | Neue Klasse: steuert Shelly Smart Plug per HTTP GET |
|
||
| `robot/RobotConfig.js` | Liest `emergencyStop`-Eintrag aus `robot.json` (protocol, url, urlOn, urlStatus) |
|
||
| `startRobot.js` | Erzeugt `ShellyEmergencyStop`-Instanz, setzt `isGCodeReceiver: false` |
|
||
| `server/InfoServer.js` | Stellt die vier API-Endpunkte bereit, gibt `isGCodeReceiver` im Status aus |
|
||
| `public/app.js` | SVG-Button, Polling `/api/power-status`, Click-Handler mit console.warn |
|
||
| `public/index.html` | Emergency-Stop-Panel (SVG-Button, Alarm-Unlock-Button, Status-Divs) |
|
||
| `public/style.css` | Stile für Panel, SVG-Button, Alarm-Unlock-Button, Status-Texte |
|
||
| `data/robot/robot.json` | Konfiguration: `controllers.emergencyStop` mit allen drei URLs |
|
||
|
||
---
|
||
|
||
## robot.json — Konfiguration
|
||
|
||
```json
|
||
"controllers": {
|
||
"base": { "ip": "...", "port": 2300, "protocol": "telnet", ... },
|
||
"elbow": { ... },
|
||
"hand": { ... },
|
||
"emergencyStop": {
|
||
"protocol": "shelly",
|
||
"url": "http://<SHELLY-IP>/rpc/Switch.Set?id=0&on=false",
|
||
"urlOn": "http://<SHELLY-IP>/rpc/Switch.Set?id=0&on=true",
|
||
"urlStatus": "http://<SHELLY-IP>/rpc/Switch.GetStatus?id=0"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Wichtig — Docker/mDNS:** `.local`-Hostnamen (mDNS) werden innerhalb von Docker-Containern **nicht** aufgelöst. Die FluidNC-Controller werden über `GRBL_BASE_IP` etc. als echte IPs konfiguriert; für den Shelly muss entweder die IP direkt in `robot.json` eingetragen werden, oder die Env-Variable `SHELLY_URL` wird gesetzt.
|
||
|
||
### Env-Variable `SHELLY_URL`
|
||
|
||
```
|
||
SHELLY_URL=http://192.168.0.99/rpc/Switch.Set?id=0&on=false
|
||
```
|
||
|
||
Überschreibt `url` aus `robot.json` (analog zu `GRBL_BASE_IP`). `urlOn` und `urlStatus` werden dann automatisch aus `SHELLY_URL` abgeleitet. Werden `urlOn`/`urlStatus` explizit in `robot.json` gesetzt, haben diese Vorrang.
|
||
|
||
---
|
||
|
||
## ShellyEmergencyStop.js
|
||
|
||
### Konstruktor
|
||
|
||
```js
|
||
new ShellyEmergencyStop(url, options = {})
|
||
// url = Switch.Set?id=0&on=false (Off-URL)
|
||
// options.urlOn = Switch.Set?id=0&on=true (optional, sonst aus url abgeleitet)
|
||
// options.urlStatus = Switch.GetStatus?id=0 (optional, sonst aus url abgeleitet)
|
||
```
|
||
|
||
URL-Ableitung (Fallback, wenn nicht explizit konfiguriert):
|
||
- `_onUrl = url.replace('on=false', 'on=true')`
|
||
- `_statusUrl = url.replace(/\/rpc\/.*$/, '/rpc/Switch.GetStatus?id=0')`
|
||
|
||
### Methoden
|
||
|
||
| Methode | Aktion | Shelly-RPC |
|
||
|---|---|---|
|
||
| `emergencyStop()` | Strom abschalten | `Switch.Set?id=0&on=false` |
|
||
| `powerOn()` | Strom einschalten | `Switch.Set?id=0&on=true` |
|
||
| `alarmUnlock()` | no-op (kein FluidNC-Alarm) | — |
|
||
| `getArmed()` | Schaltzustand abfragen | `Switch.GetStatus?id=0` |
|
||
| `getStatus()` | Sender-Status für InfoServer | — |
|
||
|
||
### `getArmed()` — Rückgabe
|
||
|
||
```json
|
||
{ "ok": true, "armed": true, "voltage": 234.9, "power": 15.5 }
|
||
```
|
||
|
||
`armed: true` = `output: true` im Shelly-Response = Strom eingeschaltet = Roboter bestromt.
|
||
|
||
### State-Machine
|
||
|
||
| State | Bedeutung |
|
||
|---|---|
|
||
| `ready` | Shelly bereit, noch kein Aufruf |
|
||
| `stopped` | `emergencyStop()` erfolgreich ausgeführt |
|
||
| `error` | HTTP-Fehler oder Netzwerkfehler |
|
||
| `disconnected` | `disconnect()` aufgerufen |
|
||
|
||
---
|
||
|
||
## TelnetSenderGRBL — Emergency Stop
|
||
|
||
### `emergencyStop()`
|
||
|
||
Sendet das **Realtime-Byte `!`** (FluidNC Feed Hold):
|
||
- Kein Zeilenende (`\r\n`) — sofortige Wirkung, unabhängig vom aktuellen Befehlspuffer
|
||
- Loggt: `⚠️ [EmergencyStop] Feed Hold '!' → <hostname>`
|
||
- Gibt `{ ok: false, error: 'not connected' }` wenn kein Socket
|
||
|
||
### `alarmUnlock()`
|
||
|
||
Sendet `$X\r\n` (FluidNC Alarm Unlock):
|
||
- Entsperrt den ALARM-Zustand nach Power-Loss (z.B. `ALARM:1`)
|
||
- Nur aufrufen nachdem Roboterstellung manuell geprüft wurde
|
||
|
||
---
|
||
|
||
## SenderInterface — Default-no-ops
|
||
|
||
`SenderInterface.js` definiert Basisimplementierungen, damit alle Sender das Interface erfüllen ohne jede Methode selbst zu implementieren:
|
||
|
||
```js
|
||
async emergencyStop() { return { ok: true, skipped: true }; }
|
||
async alarmUnlock() { return { ok: true, skipped: true }; }
|
||
```
|
||
|
||
`skipped: true` wird von `InfoServer` als Erfolg gewertet (zählt nicht als Fehler).
|
||
|
||
---
|
||
|
||
## InfoServer — API-Endpunkte
|
||
|
||
### `GET /api/power-status`
|
||
|
||
Fragt den Shelly (ersten Sender mit `getArmed()`-Methode) nach dem Schaltzustand:
|
||
|
||
```json
|
||
{ "ok": true, "armed": true, "voltage": 234.9, "power": 15.5 }
|
||
```
|
||
|
||
Kein Shelly konfiguriert:
|
||
```json
|
||
{ "ok": false, "armed": false, "error": "no shelly configured" }
|
||
```
|
||
|
||
### `POST /api/emergency-stop`
|
||
|
||
Ruft `emergencyStop()` auf **allen** Sendern parallel auf:
|
||
- FluidNC: sendet `!`
|
||
- Shelly: schneidet Strom ab
|
||
- Loggt: `⚠️ [EmergencyStop] 2026-06-12T... — [Base:ok, Elbow:ok, Hand:ok, EmergencyStop:ok]`
|
||
|
||
```json
|
||
{ "ok": true, "at": "2026-06-12T16:34:08.863Z", "results": [...] }
|
||
```
|
||
|
||
### `POST /api/power-on`
|
||
|
||
Ruft `powerOn()` nur auf Sendern auf, die diese Methode haben (Shelly):
|
||
- Loggt: `⚠️ [PowerOn] 2026-06-12T...`
|
||
|
||
### `POST /api/alarm-unlock`
|
||
|
||
Ruft `alarmUnlock()` auf allen Sendern auf:
|
||
- FluidNC: sendet `$X\r\n`
|
||
- Shelly: skipped
|
||
- Loggt: `⚠️ [AlarmUnlock] 2026-06-12T...`
|
||
|
||
### `GET /api/status` — `isGCodeReceiver`
|
||
|
||
Das Status-Objekt jedes Senders enthält neu das Feld `isGCodeReceiver`:
|
||
|
||
```json
|
||
{
|
||
"name": "EmergencyStop",
|
||
"isGCodeReceiver": false,
|
||
"state": "stopped",
|
||
...
|
||
}
|
||
```
|
||
|
||
`false` = Shelly (kein G-Code-Empfänger, keine URL in der UI anzeigen).
|
||
|
||
---
|
||
|
||
## startRobot.js — Sender-Erzeugung
|
||
|
||
```js
|
||
for (const [key, ctrl] of Object.entries(cfg.controllers)) {
|
||
if (ctrl.protocol === 'shelly') {
|
||
const instance = new ShellyClass(ctrl.url, {
|
||
urlOn: ctrl.urlOn,
|
||
urlStatus: ctrl.urlStatus,
|
||
});
|
||
senders.push({ name, instance, isGCodeReceiver: false });
|
||
} else {
|
||
// Telnet (FluidNC)
|
||
const instance = new TelnetSenderClass(ctrl.ip, ctrl.port, ...axes7, { heartbeatInterval });
|
||
senders.push({ name, instance, isGCodeReceiver: true });
|
||
}
|
||
}
|
||
|
||
// Nur Telnet-Sender empfangen G-Code
|
||
senders.filter(s => s.isGCodeReceiver).forEach(s => robot.cmdReceivers.push(s.instance));
|
||
```
|
||
|
||
---
|
||
|
||
## Frontend — app.js
|
||
|
||
### SVG-Button (Zwei-Zustands-Design)
|
||
|
||
Der SVG-Button wechselt komplett das Erscheinungsbild je nach `armed`-Zustand:
|
||
|
||
| Zustand | Outer Ring | Inner Button | Gradient | Text |
|
||
|---|---|---|---|---|
|
||
| `armed: true` | Gold `#FFD700` | Rot, r=21 | `#ff5555 → #cc0000 → #880000` | EMERGENCY STOP |
|
||
| `armed: false` | Navy `#1a3050` | Blau, r=19 | `#5c9fcf → #255f96 → #0c3660` | START ROBOT |
|
||
|
||
### Polling
|
||
|
||
```js
|
||
// Alle 2 Sekunden + sofort beim Start
|
||
pollPowerStatus();
|
||
setInterval(pollPowerStatus, 2000);
|
||
```
|
||
|
||
`GET /api/power-status` → `updateEmergencyStopButton(data.armed)`.
|
||
Startzustand: `updateEmergencyStopButton(false)` — damit der onclick sofort aktiv ist, bevor der erste Poll antwortet.
|
||
|
||
### Click-Handler (`handleEstopClick`)
|
||
|
||
- Liest `_lastArmed` dynamisch zum Klick-Zeitpunkt
|
||
- Zeigt Ladezustand (`⏳ …`), Erfolg (`✅ EmergencyStop OK`) oder Fehler (`⚠️ Teilfehler: …`) im `#estop-action-status`-Div
|
||
- Schreibt `console.warn('⚠️ [EmergencyStop] …')` für Browser-DevTools-Log
|
||
|
||
### FluidNC-Controller-Panel
|
||
|
||
Shelly-Sender werden ohne URL angezeigt (`isGCodeReceiver === false`), mit CSS-Kreisen statt Emoji:
|
||
|
||
| State | Farbe |
|
||
|---|---|
|
||
| `ready` | Hellblau `#89bcde` |
|
||
| `stopped` | Fast-Weiß `#dce9f5` |
|
||
| `error` | Gedämpft-Rot `#9a3a3a` |
|
||
| `disconnected` | Grau `#4a5a6a` |
|
||
|
||
---
|
||
|
||
## Restart-Ablauf nach NotAus
|
||
|
||
```
|
||
1. Roboter mechanisch prüfen — steht er frei?
|
||
2. [START ROBOT] klicken → POST /api/power-on
|
||
└── Shelly schaltet Strom ein
|
||
3. FluidNC bootet (ca. 5–10 s)
|
||
4. [Restart — Alarm-Unlock] klicken → POST /api/alarm-unlock
|
||
└── alle FluidNC-Controller erhalten $X\r\n
|
||
5. Roboter ist wieder betriebsbereit
|
||
```
|
||
|
||
---
|
||
|
||
## Logging
|
||
|
||
| Ort | Level | Beispiel |
|
||
|---|---|---|
|
||
| Node / Container | `console.warn` | `⚠️ [EmergencyStop] 2026-06-12T16:34:08Z — [Base:ok, Elbow:ok, Hand:ok, EmergencyStop:ok]` |
|
||
| Node / Container | `console.warn` | `⚠️ [EmergencyStop] Feed Hold '!' → fluidNcBase.local` |
|
||
| Node / Container | `console.warn` | `⚠️ [Shelly] power OFF → OK` |
|
||
| Browser DevTools | `console.warn` | `⚠️ [EmergencyStop] wird ausgeführt …` |
|
||
| Browser DevTools | `console.warn` | `⚠️ [EmergencyStop] OK` |
|
||
|
||
---
|
||
|
||
## Tests
|
||
|
||
| Datei | Abgedeckte Szenarien |
|
||
|---|---|
|
||
| `test/ShellyEmergencyStop.test.js` | Konstruktor, URL-Ableitung, explizite urlOn/urlStatus, emergencyStop/powerOn/getArmed (ok, HTTP-Fehler, Netzwerkfehler, null-URL) |
|
||
| `test/Sender.Telnet.emergencyStop.test.js` | TelnetSenderGRBL.emergencyStop/alarmUnlock (verbunden, getrennt, write-Fehler); SenderInterface-Defaults |
|
||
| `test/RobotConfig.test.js` | DEFAULTS.emergencyStop, url/urlOn/urlStatus aus robot.json, SHELLY_URL-Env-Override, keine ip/port/axes |
|
||
| `test/StartRobot.test.js` | 4 Sender inkl. Shelly; isGCodeReceiver:false; Shelly nicht in cmdReceivers |
|
||
| `test/InfoServer.test.js` | POST /api/emergency-stop, /api/alarm-unlock, /api/power-on; GET /api/power-status (mit/ohne Shelly); isGCodeReceiver im Status |
|