Files
appRobotDriver/doc/15_EmergencyStop_done.md
2026-06-12 23:43:21 +02:00

316 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. 510 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 |