// Simulated fleet for the dispatch console. // Usage (server running on :8080): node simulator.js // Env: SERVER_URL, SIM_UNITS, SIM_CENTER="lat,lng", ADMIN_USER, ADMIN_PASS, SIM_MINUTES import WebSocket from 'ws'; const BASE = process.env.SERVER_URL || 'http://localhost:8080'; const WS_URL = BASE.replace(/^http/, 'ws') + '/ws'; const N = Number(process.env.SIM_UNITS || 5); const CENTER = (process.env.SIM_CENTER || '48.8566,2.3522').split(',').map(Number); async function main() { const login = await fetch(`${BASE}/api/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: process.env.ADMIN_USER || 'admin', password: process.env.ADMIN_PASS || 'admin' }), }); if (!login.ok) throw new Error(`login failed: ${login.status}`); const { token } = await login.json(); const auth = { authorization: `Bearer ${token}`, 'content-type': 'application/json' }; const groups = await (await fetch(`${BASE}/api/groups`, { headers: auth })).json(); if (!groups.length) throw new Error('no talkgroups exist — create one first'); const groupId = groups[0].id; const existing = await (await fetch(`${BASE}/api/members`, { headers: auth })).json(); const units = []; for (let i = 1; i <= N; i++) { const deviceId = `sim-${i}`; let m = existing.find((x) => x.device_id === deviceId); if (!m) { const res = await fetch(`${BASE}/api/members`, { method: 'POST', headers: auth, body: JSON.stringify({ name: `SIM Unit ${i}`, callsign: `SIM-${i}`, device_id: deviceId, group_id: groupId }), }); m = await res.json(); } units.push({ m, ws: null, lat: CENTER[0] + (Math.random() - 0.5) * 0.08, lng: CENTER[1] + (Math.random() - 0.5) * 0.12, speed: 20 + Math.random() * 20, battery: 60 + Math.floor(Math.random() * 40), heading: Math.random() * 2 * Math.PI, }); } // Per-unit connection with automatic reconnect on any drop. function connectUnit(u) { const ws = new WebSocket(WS_URL); u.ws = ws; ws.on('open', () => { ws.send(JSON.stringify({ type: 'auth', token, memberId: u.m.id })); console.log(`[${u.m.name}] connected`); }); ws.on('message', (d) => { const msg = JSON.parse(d.toString()); if (msg.type === 'ptt_denied') console.log(`[${u.m.name}] ptt denied (held by ${msg.holder})`); }); ws.on('close', () => { u.ws = null; console.log(`[${u.m.name}] disconnected — reconnecting in 5 s`); setTimeout(() => connectUnit(u), 5000); }); ws.on('error', () => ws.terminate()); } units.forEach(connectUnit); const alive = (u) => u.ws && u.ws.readyState === WebSocket.OPEN; // GPS random walk every 5 s setInterval(() => { for (const u of units) { if (!alive(u)) continue; u.heading += (Math.random() - 0.5) * 0.8; u.speed = Math.max(8, Math.min(65, u.speed + (Math.random() - 0.5) * 12)); const d = (u.speed * 5) / 111000; u.lat += d * Math.cos(u.heading); u.lng += (d * Math.sin(u.heading)) / Math.cos((u.lat * Math.PI) / 180); if (Math.random() < 0.1) u.battery = Math.max(10, u.battery - 1); u.ws.send(JSON.stringify({ type: 'gps', lat: u.lat, lng: u.lng, speed: u.speed, battery: u.battery })); } }, 5000); // One SOS at ~25 s setTimeout(() => { const u = units.find(alive); if (u) { u.ws.send(JSON.stringify({ type: 'sos' })); console.log(`[${u.m.name}] SOS sent`); } }, 25000); // PTT floor turns with a 440 Hz tone, every ~29 s let turn = 0; let phase = 0; setInterval(() => { const u = units[turn % units.length]; turn++; if (!alive(u)) return; u.ws.send(JSON.stringify({ type: 'ptt_start' })); let sent = 0; const tone = setInterval(() => { if (!alive(u)) { clearInterval(tone); return; } const samples = Buffer.alloc(1600 * 2); for (let k = 0; k < 1600; k++) { samples.writeInt16LE(Math.round(12000 * Math.sin(phase + (k * 2 * Math.PI * 440) / 16000)), k * 2); } phase += (1600 * 2 * Math.PI * 440) / 16000; const head = Buffer.alloc(9); head.writeUInt8(0x01, 0); head.writeUInt32BE(groupId, 1); head.writeUInt32BE(u.m.id, 5); u.ws.send(Buffer.concat([head, samples])); if (++sent >= 40) { clearInterval(tone); if (alive(u)) u.ws.send(JSON.stringify({ type: 'ptt_stop' })); console.log(`[${u.m.name}] held the floor (4 s tone)`); } }, 100); }, 29000); const RUN_MS = (Number(process.env.SIM_MINUTES || 0) || 0) * 60 * 1000; if (RUN_MS) setTimeout(() => { console.log('sim duration reached — exiting'); process.exit(0); }, RUN_MS); console.log(`Simulating ${N} units against ${BASE} — Ctrl+C to stop.`); } main().catch((e) => { console.error(e); process.exit(1); });