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

@@ -32,6 +32,28 @@ function request(url) {
});
}
function post(url) {
return new Promise((resolve, reject) => {
const agent = new https.Agent({ rejectUnauthorized: false });
const parsed = new URL(url);
const options = {
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname,
method: 'POST',
agent,
headers: { 'Content-Length': 0 }
};
const req = https.request(options, res => {
let data = '';
res.on('data', c => { data += c.toString(); });
res.on('end', () => resolve({ statusCode: res.statusCode, body: data }));
});
req.on('error', reject);
req.end();
});
}
describe('InfoServer', () => {
let server;
let port;
@@ -189,4 +211,152 @@ describe('InfoServer', () => {
const { statusCode } = await request(`https://127.0.0.1:${port}/api/unknown`);
expect(statusCode).toBe(404);
});
// ── Emergency Stop Endpoints ─────────────────────────────────────────────
test('POST /api/emergency-stop ruft emergencyStop() auf allen Sendern auf', async () => {
const key = fs.readFileSync('https/localhost.key');
const cert = fs.readFileSync('https/localhost.pem');
const httpsOptions = { key, cert, passphrase: 'abcd' };
const sharedState = { connectedClients: [], lastCommands: [], lastPings: [] };
const robot = { x: 0, y: 0, z: 0, phi: 0, theta: 0, psi: 0, xMotor: 0, alpha: 0, beta: 0, a: 0, b: 0, c: 0 };
const stopA = jest.fn(() => Promise.resolve({ ok: true }));
const stopB = jest.fn(() => Promise.resolve({ ok: true, skipped: true }));
const senders = [
{ name: 'Base', instance: { emergencyStop: stopA, alarmUnlock: jest.fn(() => Promise.resolve({ ok: true })) } },
{ name: 'EmergencyStop', instance: { emergencyStop: stopB, alarmUnlock: jest.fn(() => Promise.resolve({ ok: true, skipped: true })) } }
];
server = createInfoServer(httpsOptions, sharedState, robot, GCode, senders);
port = await listen(server);
const { statusCode, body } = await post(`https://127.0.0.1:${port}/api/emergency-stop`);
expect(statusCode).toBe(200);
const json = JSON.parse(body);
expect(json.ok).toBe(true);
expect(stopA).toHaveBeenCalledTimes(1);
expect(stopB).toHaveBeenCalledTimes(1);
expect(json.results).toHaveLength(2);
});
test('POST /api/alarm-unlock ruft alarmUnlock() auf allen Sendern auf', async () => {
const key = fs.readFileSync('https/localhost.key');
const cert = fs.readFileSync('https/localhost.pem');
const httpsOptions = { key, cert, passphrase: 'abcd' };
const sharedState = { connectedClients: [], lastCommands: [], lastPings: [] };
const robot = { x: 0, y: 0, z: 0, phi: 0, theta: 0, psi: 0, xMotor: 0, alpha: 0, beta: 0, a: 0, b: 0, c: 0 };
const unlockA = jest.fn(() => Promise.resolve({ ok: true }));
const unlockB = jest.fn(() => Promise.resolve({ ok: true, skipped: true }));
const senders = [
{ name: 'Base', instance: { emergencyStop: jest.fn(() => Promise.resolve({ ok: true })), alarmUnlock: unlockA } },
{ name: 'EmergencyStop', instance: { emergencyStop: jest.fn(() => Promise.resolve({ ok: true })), alarmUnlock: unlockB } }
];
server = createInfoServer(httpsOptions, sharedState, robot, GCode, senders);
port = await listen(server);
const { statusCode, body } = await post(`https://127.0.0.1:${port}/api/alarm-unlock`);
expect(statusCode).toBe(200);
const json = JSON.parse(body);
expect(json.ok).toBe(true);
expect(unlockA).toHaveBeenCalledTimes(1);
expect(unlockB).toHaveBeenCalledTimes(1);
});
test('POST /api/emergency-stop ok=false wenn ein Sender fehlschlägt', async () => {
const key = fs.readFileSync('https/localhost.key');
const cert = fs.readFileSync('https/localhost.pem');
const httpsOptions = { key, cert, passphrase: 'abcd' };
const sharedState = { connectedClients: [], lastCommands: [], lastPings: [] };
const robot = { x: 0, y: 0, z: 0, phi: 0, theta: 0, psi: 0, xMotor: 0, alpha: 0, beta: 0, a: 0, b: 0, c: 0 };
const senders = [
{ name: 'Base', instance: { emergencyStop: () => Promise.resolve({ ok: false, error: 'not connected' }), alarmUnlock: () => Promise.resolve({ ok: true }) } }
];
server = createInfoServer(httpsOptions, sharedState, robot, GCode, senders);
port = await listen(server);
const { statusCode, body } = await post(`https://127.0.0.1:${port}/api/emergency-stop`);
expect(statusCode).toBe(200);
const json = JSON.parse(body);
expect(json.ok).toBe(false);
expect(json.results[0].ok).toBe(false);
});
test('GET /api/power-status gibt armed=true zurück wenn Shelly output:true', async () => {
const key = fs.readFileSync('https/localhost.key');
const cert = fs.readFileSync('https/localhost.pem');
const httpsOptions = { key, cert, passphrase: 'abcd' };
const sharedState = { connectedClients: [], lastCommands: [], lastPings: [] };
const robot = { x: 0, y: 0, z: 0, phi: 0, theta: 0, psi: 0, xMotor: 0, alpha: 0, beta: 0, a: 0, b: 0, c: 0 };
const getArmedFn = jest.fn(() => Promise.resolve({ ok: true, armed: true, voltage: 234.9, power: 15.5 }));
const senders = [
{ name: 'EmergencyStop', instance: { emergencyStop: () => Promise.resolve({ ok: true }), alarmUnlock: () => Promise.resolve({ ok: true }), getArmed: getArmedFn } }
];
server = createInfoServer(httpsOptions, sharedState, robot, GCode, senders);
port = await listen(server);
const { statusCode, body } = await request(`https://127.0.0.1:${port}/api/power-status`);
expect(statusCode).toBe(200);
const json = JSON.parse(body);
expect(json.ok).toBe(true);
expect(json.armed).toBe(true);
expect(getArmedFn).toHaveBeenCalledTimes(1);
});
test('GET /api/power-status gibt armed=false zurück wenn kein Shelly konfiguriert', async () => {
const key = fs.readFileSync('https/localhost.key');
const cert = fs.readFileSync('https/localhost.pem');
const httpsOptions = { key, cert, passphrase: 'abcd' };
const sharedState = { connectedClients: [], lastCommands: [], lastPings: [] };
const robot = { x: 0, y: 0, z: 0, phi: 0, theta: 0, psi: 0, xMotor: 0, alpha: 0, beta: 0, a: 0, b: 0, c: 0 };
const senders = [
{ name: 'Base', instance: { emergencyStop: () => Promise.resolve({ ok: true }), alarmUnlock: () => Promise.resolve({ ok: true }) } }
];
server = createInfoServer(httpsOptions, sharedState, robot, GCode, senders);
port = await listen(server);
const { statusCode, body } = await request(`https://127.0.0.1:${port}/api/power-status`);
expect(statusCode).toBe(200);
const json = JSON.parse(body);
expect(json.ok).toBe(false);
expect(json.armed).toBe(false);
expect(json.error).toMatch(/no shelly/);
});
test('POST /api/power-on ruft powerOn() auf Shelly-Sender auf', async () => {
const key = fs.readFileSync('https/localhost.key');
const cert = fs.readFileSync('https/localhost.pem');
const httpsOptions = { key, cert, passphrase: 'abcd' };
const sharedState = { connectedClients: [], lastCommands: [], lastPings: [] };
const robot = { x: 0, y: 0, z: 0, phi: 0, theta: 0, psi: 0, xMotor: 0, alpha: 0, beta: 0, a: 0, b: 0, c: 0 };
const powerOnFn = jest.fn(() => Promise.resolve({ ok: true, status: 200 }));
const senders = [
{ name: 'Base', instance: { emergencyStop: () => Promise.resolve({ ok: true }), alarmUnlock: () => Promise.resolve({ ok: true }) } },
{ name: 'EmergencyStop', instance: { emergencyStop: () => Promise.resolve({ ok: true }), alarmUnlock: () => Promise.resolve({ ok: true }), powerOn: powerOnFn } }
];
server = createInfoServer(httpsOptions, sharedState, robot, GCode, senders);
port = await listen(server);
const { statusCode, body } = await post(`https://127.0.0.1:${port}/api/power-on`);
expect(statusCode).toBe(200);
const json = JSON.parse(body);
expect(json.ok).toBe(true);
expect(powerOnFn).toHaveBeenCalledTimes(1);
});
});

View File

@@ -177,3 +177,39 @@ describe('RobotConfig.load — heartbeatInterval', () => {
expect(cfg.controllers.base.heartbeatInterval).toBe(10000);
});
});
describe('RobotConfig.load — emergencyStop (Shelly)', () => {
test('DEFAULTS.controllers.emergencyStop hat protocol=shelly und url=null', () => {
expect(DEFAULTS.controllers.emergencyStop.protocol).toBe('shelly');
expect(DEFAULTS.controllers.emergencyStop.url).toBeNull();
});
test('emergencyStop.url aus robot.json wird übernommen', () => {
const shellyUrl = 'http://shelly.local/rpc/Switch.Set?id=0&on=false';
const json = {
...FULL_ROBOT_JSON,
controllers: {
...FULL_ROBOT_JSON.controllers,
emergencyStop: { protocol: 'shelly', url: shellyUrl }
}
};
const cfg = load(makeFs(JSON.stringify(json)), {}, log);
expect(cfg.controllers.emergencyStop.protocol).toBe('shelly');
expect(cfg.controllers.emergencyStop.url).toBe(shellyUrl);
});
test('fehlendes emergencyStop in robot.json → url=null (Default)', () => {
// FULL_ROBOT_JSON hat kein emergencyStop → fällt auf Default zurück
const cfg = load(makeFs(JSON.stringify(FULL_ROBOT_JSON)), {}, log);
expect(cfg.controllers.emergencyStop.protocol).toBe('shelly');
expect(cfg.controllers.emergencyStop.url).toBeNull();
});
test('emergencyStop hat keine ip/port/axes/heartbeatInterval Felder', () => {
const cfg = load(makeFailFs(), {}, log);
expect(cfg.controllers.emergencyStop.ip).toBeUndefined();
expect(cfg.controllers.emergencyStop.port).toBeUndefined();
expect(cfg.controllers.emergencyStop.axes).toBeUndefined();
expect(cfg.controllers.emergencyStop.heartbeatInterval).toBeUndefined();
});
});

View File

@@ -0,0 +1,119 @@
'use strict';
// Tests für TelnetSenderGRBL.emergencyStop() und alarmUnlock()
const { EventEmitter } = require('events');
const TelnetSenderGRBL = require('../robot/TelnetSenderGRBL');
function makeTelnetSocket() {
const em = new EventEmitter();
return Object.assign(em, {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
});
}
/** Sender im Test-Modus (URL "test.test") — tSocket ist gesetzt. */
function makeConnectedSender() {
const sender = new TelnetSenderGRBL('test.test');
sender.tSocket = makeTelnetSocket();
return sender;
}
/** Sender ohne tSocket (disconnected). */
function makeDisconnectedSender() {
const sender = new TelnetSenderGRBL('test.test');
sender.tSocket = null;
return sender;
}
// ── emergencyStop() ──────────────────────────────────────────────────────────
describe('TelnetSenderGRBL.emergencyStop()', () => {
test('sendet "!" wenn verbunden', async () => {
const sender = makeConnectedSender();
const result = await sender.emergencyStop();
expect(result.ok).toBe(true);
expect(sender.tSocket.write).toHaveBeenCalledWith('!');
});
test('sendet nur "!" — kein Zeilenende (FluidNC Realtime-Byte)', async () => {
const sender = makeConnectedSender();
await sender.emergencyStop();
const arg = sender.tSocket.write.mock.calls[0][0];
expect(arg).toBe('!');
expect(arg).not.toContain('\r\n');
});
test('gibt {ok:false} zurück wenn nicht verbunden', async () => {
const sender = makeDisconnectedSender();
const result = await sender.emergencyStop();
expect(result.ok).toBe(false);
expect(result.error).toBe('not connected');
});
test('gibt {ok:false} zurück wenn write() wirft', async () => {
const sender = makeConnectedSender();
sender.tSocket.write.mockImplementation(() => { throw new Error('socket closed'); });
const result = await sender.emergencyStop();
expect(result.ok).toBe(false);
expect(result.error).toBe('socket closed');
});
});
// ── alarmUnlock() ────────────────────────────────────────────────────────────
describe('TelnetSenderGRBL.alarmUnlock()', () => {
test('sendet "$X\\r\\n" wenn verbunden', async () => {
const sender = makeConnectedSender();
const result = await sender.alarmUnlock();
expect(result.ok).toBe(true);
expect(sender.tSocket.write).toHaveBeenCalledWith('$X\r\n');
});
test('gibt {ok:false} zurück wenn nicht verbunden', async () => {
const sender = makeDisconnectedSender();
const result = await sender.alarmUnlock();
expect(result.ok).toBe(false);
expect(result.error).toBe('not connected');
});
test('gibt {ok:false} zurück wenn write() wirft', async () => {
const sender = makeConnectedSender();
sender.tSocket.write.mockImplementation(() => { throw new Error('write error'); });
const result = await sender.alarmUnlock();
expect(result.ok).toBe(false);
expect(result.error).toBe('write error');
});
});
// ── SenderInterface-Defaults ─────────────────────────────────────────────────
describe('SenderInterface — Default-Implementierungen (no-op)', () => {
test('SenderInterface.emergencyStop() gibt {ok:true, skipped:true}', async () => {
const SenderInterface = require('../robot/SenderInterface');
// Subclass, die nur emergencyStop von der Basis erbt
class MinimalSender extends SenderInterface {
async connect() { return this; }
send() { return false; }
getStatus() { return {}; }
disconnect() {}
}
const s = new MinimalSender();
await expect(s.emergencyStop()).resolves.toEqual({ ok: true, skipped: true });
});
test('SenderInterface.alarmUnlock() gibt {ok:true, skipped:true}', async () => {
const SenderInterface = require('../robot/SenderInterface');
class MinimalSender extends SenderInterface {
async connect() { return this; }
send() { return false; }
getStatus() { return {}; }
disconnect() {}
}
const s = new MinimalSender();
await expect(s.alarmUnlock()).resolves.toEqual({ ok: true, skipped: true });
});
});

View File

@@ -0,0 +1,198 @@
'use strict';
const ShellyEmergencyStop = require('../robot/ShellyEmergencyStop');
const OFF_URL = 'http://shelly.local/rpc/Switch.Set?id=0&on=false';
const ON_URL = 'http://shelly.local/rpc/Switch.Set?id=0&on=true';
const STATUS_URL = 'http://shelly.local/rpc/Switch.GetStatus?id=0';
function makeHttpGetJson(data, statusCode = 200) {
return jest.fn(() => Promise.resolve({
ok: statusCode >= 200 && statusCode < 300,
status: statusCode,
data
}));
}
function makeHttpGet(statusCode = 200) {
return jest.fn(() => Promise.resolve({
ok: statusCode >= 200 && statusCode < 300,
status: statusCode
}));
}
describe('ShellyEmergencyStop — Konstruktor', () => {
test('url wird korrekt gesetzt', () => {
const s = new ShellyEmergencyStop(OFF_URL);
expect(s.url).toBe(OFF_URL);
});
test('_onUrl ersetzt on=false durch on=true', () => {
const s = new ShellyEmergencyStop(OFF_URL);
expect(s._onUrl).toBe(ON_URL);
});
test('_statusUrl zeigt auf Switch.GetStatus', () => {
const s = new ShellyEmergencyStop(OFF_URL);
expect(s._statusUrl).toBe(STATUS_URL);
});
test('null-URL → _offUrl, _onUrl und _statusUrl sind null', () => {
const s = new ShellyEmergencyStop(null);
expect(s._offUrl).toBeNull();
expect(s._onUrl).toBeNull();
expect(s._statusUrl).toBeNull();
});
test('Initialzustand ist ready', () => {
const s = new ShellyEmergencyStop(OFF_URL);
expect(s.state).toBe('ready');
expect(s.error).toBeNull();
});
});
describe('ShellyEmergencyStop — SenderInterface', () => {
test('connect() gibt Instanz zurück', async () => {
const s = new ShellyEmergencyStop(OFF_URL);
await expect(s.connect()).resolves.toBe(s);
});
test('send() gibt false zurück (kein GCode-Empfänger)', () => {
const s = new ShellyEmergencyStop(OFF_URL);
expect(s.send('G1 X10')).toBe(false);
});
test('getStatus() enthält state und url', () => {
const s = new ShellyEmergencyStop(OFF_URL);
const st = s.getStatus();
expect(st.state).toBe('ready');
expect(st.url).toBe(OFF_URL);
});
test('alarmUnlock() gibt {ok:true, skipped:true} zurück', async () => {
const s = new ShellyEmergencyStop(OFF_URL);
await expect(s.alarmUnlock()).resolves.toEqual({ ok: true, skipped: true });
});
});
describe('ShellyEmergencyStop — emergencyStop()', () => {
test('ruft _offUrl auf', async () => {
const httpGet = makeHttpGet(200);
const s = new ShellyEmergencyStop(OFF_URL, { httpGetFn: httpGet });
await s.emergencyStop();
expect(httpGet).toHaveBeenCalledWith(OFF_URL);
});
test('setzt state=stopped bei HTTP 200', async () => {
const s = new ShellyEmergencyStop(OFF_URL, { httpGetFn: makeHttpGet(200) });
const result = await s.emergencyStop();
expect(result.ok).toBe(true);
expect(s.state).toBe('stopped');
expect(s.error).toBeNull();
});
test('setzt state=error bei HTTP 500', async () => {
const s = new ShellyEmergencyStop(OFF_URL, { httpGetFn: makeHttpGet(500) });
const result = await s.emergencyStop();
expect(result.ok).toBe(false);
expect(s.state).toBe('error');
expect(s.error).toBe('HTTP 500');
});
test('setzt state=error bei Netzwerkfehler', async () => {
const httpGet = jest.fn(() => Promise.reject(new Error('ECONNREFUSED')));
const s = new ShellyEmergencyStop(OFF_URL, { httpGetFn: httpGet });
const result = await s.emergencyStop();
expect(result.ok).toBe(false);
expect(result.error).toBe('ECONNREFUSED');
expect(s.state).toBe('error');
});
test('gibt {ok:false} wenn url null ist', async () => {
const s = new ShellyEmergencyStop(null);
const result = await s.emergencyStop();
expect(result.ok).toBe(false);
expect(result.error).toMatch(/no shelly url/);
});
});
describe('ShellyEmergencyStop — powerOn()', () => {
test('ruft _onUrl auf (on=true)', async () => {
const httpGet = makeHttpGet(200);
const s = new ShellyEmergencyStop(OFF_URL, { httpGetFn: httpGet });
await s.powerOn();
expect(httpGet).toHaveBeenCalledWith(ON_URL);
});
test('setzt state=ready bei HTTP 200', async () => {
const s = new ShellyEmergencyStop(OFF_URL, { httpGetFn: makeHttpGet(200) });
const result = await s.powerOn();
expect(result.ok).toBe(true);
expect(s.state).toBe('ready');
});
test('setzt state=error bei HTTP 503', async () => {
const s = new ShellyEmergencyStop(OFF_URL, { httpGetFn: makeHttpGet(503) });
const result = await s.powerOn();
expect(result.ok).toBe(false);
expect(s.state).toBe('error');
});
test('gibt {ok:false} wenn url null ist', async () => {
const s = new ShellyEmergencyStop(null);
const result = await s.powerOn();
expect(result.ok).toBe(false);
});
});
describe('ShellyEmergencyStop — getArmed()', () => {
const SHELLY_ON = { id: 0, source: 'WS_in', output: true, apower: 15.5, voltage: 234.9, freq: 50.1, current: 0.144 };
const SHELLY_OFF = { id: 0, source: 'WS_in', output: false, apower: 0.0, voltage: 235.2, freq: 50.1, current: 0.000 };
test('ruft _statusUrl (Switch.GetStatus?id=0) auf', async () => {
const httpGetJson = makeHttpGetJson(SHELLY_ON);
const s = new ShellyEmergencyStop(OFF_URL, { httpGetJsonFn: httpGetJson });
await s.getArmed();
expect(httpGetJson).toHaveBeenCalledWith(STATUS_URL);
});
test('armed=true wenn output:true', async () => {
const s = new ShellyEmergencyStop(OFF_URL, { httpGetJsonFn: makeHttpGetJson(SHELLY_ON) });
const result = await s.getArmed();
expect(result.ok).toBe(true);
expect(result.armed).toBe(true);
expect(result.voltage).toBe(234.9);
expect(result.power).toBe(15.5);
});
test('armed=false wenn output:false', async () => {
const s = new ShellyEmergencyStop(OFF_URL, { httpGetJsonFn: makeHttpGetJson(SHELLY_OFF) });
const result = await s.getArmed();
expect(result.ok).toBe(true);
expect(result.armed).toBe(false);
});
test('armed=false bei HTTP-Fehler (503)', async () => {
const s = new ShellyEmergencyStop(OFF_URL, { httpGetJsonFn: makeHttpGetJson(null, 503) });
const result = await s.getArmed();
expect(result.ok).toBe(false);
expect(result.armed).toBe(false);
});
test('armed=false bei Netzwerkfehler', async () => {
const httpGetJson = jest.fn(() => Promise.reject(new Error('ECONNREFUSED')));
const s = new ShellyEmergencyStop(OFF_URL, { httpGetJsonFn: httpGetJson });
const result = await s.getArmed();
expect(result.ok).toBe(false);
expect(result.armed).toBe(false);
expect(result.error).toBe('ECONNREFUSED');
});
test('armed=false wenn url null ist', async () => {
const s = new ShellyEmergencyStop(null);
const result = await s.getArmed();
expect(result.ok).toBe(false);
expect(result.armed).toBe(false);
expect(result.error).toMatch(/no shelly url/);
});
});

View File

@@ -21,6 +21,10 @@ describe('startRobot orchestrator', () => {
const createInfoServer = jest.fn(() => infoServerMock);
const TelnetSenderClass = jest.fn(() => ({ tSocket: null }));
// Shelly-Mock: getStatus() liefert state='ready' (kein tSocket, kein isTestMode)
const ShellyClass = jest.fn(() => ({
getStatus: () => ({ state: 'ready', url: null, error: null })
}));
const robotInstances = [];
class RobotClass {
@@ -37,6 +41,7 @@ describe('startRobot orchestrator', () => {
RobotClass,
GCodeModule: { dummy: true },
TelnetSenderClass,
ShellyClass,
initInputWSFn: initInputWS,
createInfoServerFn: createInfoServer,
setTimeoutFn: (fn) => fn(),
@@ -58,9 +63,10 @@ describe('startRobot orchestrator', () => {
expect.any(RobotClass),
{ dummy: true },
expect.arrayContaining([
expect.objectContaining({ name: 'Base', instance: expect.any(Object) }),
expect.objectContaining({ name: 'Elbow', instance: expect.any(Object) }),
expect.objectContaining({ name: 'Hand', instance: expect.any(Object) })
expect.objectContaining({ name: 'Base', instance: expect.any(Object) }),
expect.objectContaining({ name: 'Elbow', instance: expect.any(Object) }),
expect.objectContaining({ name: 'Hand', instance: expect.any(Object) }),
expect.objectContaining({ name: 'EmergencyStop', instance: expect.any(Object) })
]),
expect.objectContaining({}) // options: { apiKey }
);
@@ -71,13 +77,18 @@ describe('startRobot orchestrator', () => {
expect(result).toHaveProperty('httpsServer', httpsServerMock);
expect(result).toHaveProperty('infoServer', infoServerMock);
expect(result).toHaveProperty('senders');
expect(result.senders).toHaveLength(3);
expect(result.senders).toHaveLength(4); // base, elbow, hand, emergencyStop
// Nur Telnet-Sender (3) landen in cmdReceivers — Shelly nicht
expect(robotInstances[0].cmdReceivers).toHaveLength(3);
expect(result.startupStatus).toEqual({
https: { ok: true },
senders: [
{ name: 'Base', status: 'disconnected', reason: 'no active socket connection' },
{ name: 'Elbow', status: 'disconnected', reason: 'no active socket connection' },
{ name: 'Hand', status: 'disconnected', reason: 'no active socket connection' }
{ name: 'Base', status: 'disconnected', reason: 'no active socket connection' },
{ name: 'Elbow', status: 'disconnected', reason: 'no active socket connection' },
{ name: 'Hand', status: 'disconnected', reason: 'no active socket connection' },
{ name: 'EmergencyStop', status: 'ready', reason: undefined }
]
});
expect(result.sharedState.connectedClients).toEqual([]);