Spaces:
Paused
Paused
WebSocket hub: device GPS/SOS, PTT floor control, PCM audio relay
Browse files- server/src/realtime.js +202 -0
server/src/realtime.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import jwt from 'jsonwebtoken';
|
| 2 |
+
import { JWT_SECRET } from './auth.js';
|
| 3 |
+
import db from './db.js';
|
| 4 |
+
|
| 5 |
+
// Binary audio frame: [1B type=0x01][4B groupId BE][4B memberId BE][PCM Int16LE @16kHz]
|
| 6 |
+
const AUDIO_TYPE = 0x01;
|
| 7 |
+
const HDR = 9;
|
| 8 |
+
|
| 9 |
+
const memberById = (id) => db.prepare('SELECT * FROM members WHERE id = ?').get(id);
|
| 10 |
+
const allMembers = () =>
|
| 11 |
+
db.prepare(`SELECT m.*, g.name AS group_name FROM members m
|
| 12 |
+
LEFT JOIN talkgroups g ON g.id = m.group_id ORDER BY m.id`).all();
|
| 13 |
+
|
| 14 |
+
export default class Hub {
|
| 15 |
+
constructor(wss) {
|
| 16 |
+
this.wss = wss;
|
| 17 |
+
this.clients = new Set();
|
| 18 |
+
this.floors = new Map(); // groupId -> { client, memberId, name }
|
| 19 |
+
setInterval(() => this.heartbeat(), 30000);
|
| 20 |
+
wss.on('connection', (ws) => this.onConnection(ws));
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
heartbeat() {
|
| 24 |
+
for (const ws of this.clients) {
|
| 25 |
+
if (ws.isAlive === false) { ws.terminate(); this.onClose(ws); continue; }
|
| 26 |
+
ws.isAlive = false;
|
| 27 |
+
ws.ping();
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
onConnection(ws) {
|
| 32 |
+
ws.isAlive = true;
|
| 33 |
+
ws.client = null;
|
| 34 |
+
ws.on('pong', () => { ws.isAlive = true; });
|
| 35 |
+
ws.on('message', (data, isBinary) => {
|
| 36 |
+
try {
|
| 37 |
+
if (isBinary) return this.onAudio(ws, Buffer.from(data));
|
| 38 |
+
const msg = JSON.parse(data.toString('utf8'));
|
| 39 |
+
if (!ws.client) return this.onHello(ws, msg);
|
| 40 |
+
this.onMessage(ws, msg);
|
| 41 |
+
} catch {
|
| 42 |
+
this.send(ws, { type: 'error', message: 'malformed message' });
|
| 43 |
+
}
|
| 44 |
+
});
|
| 45 |
+
ws.on('close', () => this.onClose(ws));
|
| 46 |
+
ws.on('error', () => {});
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
verify(token) {
|
| 50 |
+
try { return jwt.verify(token, JWT_SECRET); } catch { return null; }
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
onHello(ws, msg) {
|
| 54 |
+
if (msg.type !== 'auth' || !msg.token) { ws.close(); return; }
|
| 55 |
+
const claims = this.verify(msg.token);
|
| 56 |
+
if (!claims) { this.send(ws, { type: 'error', message: 'invalid token' }); ws.close(); return; }
|
| 57 |
+
|
| 58 |
+
if (msg.memberId != null) {
|
| 59 |
+
// device client — speaks as one member
|
| 60 |
+
const member = memberById(msg.memberId);
|
| 61 |
+
if (!member) { this.send(ws, { type: 'error', message: 'unknown member' }); ws.close(); return; }
|
| 62 |
+
ws.client = { kind: 'device', memberId: member.id, name: member.name, listens: new Set() };
|
| 63 |
+
if (member.group_id != null) ws.client.listens.add(member.group_id);
|
| 64 |
+
db.prepare("UPDATE members SET status = 'online', last_seen = ? WHERE id = ?").run(Date.now(), member.id);
|
| 65 |
+
} else {
|
| 66 |
+
// dispatcher client — hears every talkgroup
|
| 67 |
+
ws.client = { kind: 'dispatcher', memberId: 0, name: claims.username, listens: new Set() };
|
| 68 |
+
for (const g of db.prepare('SELECT id FROM talkgroups').all()) ws.client.listens.add(g.id);
|
| 69 |
+
}
|
| 70 |
+
this.clients.add(ws);
|
| 71 |
+
this.send(ws, this.snapshot());
|
| 72 |
+
this.broadcastMembers();
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
onMessage(ws, msg) {
|
| 76 |
+
const c = ws.client;
|
| 77 |
+
switch (msg.type) {
|
| 78 |
+
case 'sync': return this.send(ws, this.snapshot());
|
| 79 |
+
case 'listen':
|
| 80 |
+
if (Array.isArray(msg.groupIds)) c.listens = new Set(msg.groupIds.filter(Number.isInteger));
|
| 81 |
+
return;
|
| 82 |
+
case 'gps': return this.onGps(ws, msg);
|
| 83 |
+
case 'sos': return this.onSos(ws, msg);
|
| 84 |
+
case 'sos_ack': return this.onSosState(ws, msg.id, 'ack', c.name);
|
| 85 |
+
case 'sos_resolve': return this.onSosState(ws, msg.id, 'resolved', c.name);
|
| 86 |
+
case 'ptt_start': return this.onPttStart(ws, msg);
|
| 87 |
+
case 'ptt_stop': return this.onPttStop(ws);
|
| 88 |
+
default: return this.send(ws, { type: 'error', message: `unknown type: ${msg.type}` });
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
onGps(ws, msg) {
|
| 93 |
+
if (ws.client.kind !== 'device') return;
|
| 94 |
+
const { lat, lng } = msg;
|
| 95 |
+
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
|
| 96 |
+
return this.send(ws, { type: 'error', message: 'bad gps' });
|
| 97 |
+
}
|
| 98 |
+
const battery = Number.isFinite(msg.battery) ? Math.max(0, Math.min(100, Math.round(msg.battery))) : null;
|
| 99 |
+
const speed = Number.isFinite(msg.speed) ? msg.speed : null;
|
| 100 |
+
db.prepare(`UPDATE members SET lat = ?, lng = ?, battery = COALESCE(?, battery),
|
| 101 |
+
last_seen = ?, status = 'online' WHERE id = ?`)
|
| 102 |
+
.run(lat, lng, battery, Date.now(), ws.client.memberId);
|
| 103 |
+
db.prepare('INSERT INTO gps_pings (member_id, lat, lng, speed, ts) VALUES (?, ?, ?, ?, ?)')
|
| 104 |
+
.run(ws.client.memberId, lat, lng, speed, Date.now());
|
| 105 |
+
this.broadcastMembers();
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
onSos(ws) {
|
| 109 |
+
if (ws.client.kind !== 'device') return;
|
| 110 |
+
const m = memberById(ws.client.memberId);
|
| 111 |
+
const info = db.prepare(`INSERT INTO sos_alerts (member_id, lat, lng, state, created_at)
|
| 112 |
+
VALUES (?, ?, ?, 'open', ?)`).run(m.id, m.lat, m.lng, Date.now());
|
| 113 |
+
const alert = db.prepare(`SELECT a.*, mm.name AS member_name FROM sos_alerts a
|
| 114 |
+
JOIN members mm ON mm.id = a.member_id WHERE a.id = ?`)
|
| 115 |
+
.get(info.lastInsertRowid);
|
| 116 |
+
this.broadcast({ type: 'sos_new', alert });
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
onSosState(ws, id, state, name) {
|
| 120 |
+
if (ws.client.kind !== 'dispatcher') return;
|
| 121 |
+
db.prepare('UPDATE sos_alerts SET state = ?, ack_by = ?, updated_at = ? WHERE id = ?')
|
| 122 |
+
.run(state, name, Date.now(), id);
|
| 123 |
+
const alert = db.prepare(`SELECT a.*, mm.name AS member_name FROM sos_alerts a
|
| 124 |
+
JOIN members mm ON mm.id = a.member_id WHERE a.id = ?`).get(id);
|
| 125 |
+
if (alert) this.broadcast({ type: 'sos_update', alert });
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
onPttStart(ws, msg) {
|
| 129 |
+
const gid = Number.isInteger(msg?.groupId)
|
| 130 |
+
? msg.groupId
|
| 131 |
+
: memberById(ws.client.memberId)?.group_id ?? null;
|
| 132 |
+
if (gid == null) return this.send(ws, { type: 'error', message: 'no talkgroup' });
|
| 133 |
+
const current = this.floors.get(gid);
|
| 134 |
+
if (current && current.client !== ws) {
|
| 135 |
+
return this.send(ws, { type: 'ptt_denied', groupId: gid, holder: current.name });
|
| 136 |
+
}
|
| 137 |
+
this.floors.set(gid, { client: ws, memberId: ws.client.memberId, name: ws.client.name });
|
| 138 |
+
db.prepare('INSERT INTO call_log (group_id, member_id, kind, ts) VALUES (?, ?, ?, ?)')
|
| 139 |
+
.run(gid, ws.client.memberId, 'ptt_start', Date.now());
|
| 140 |
+
this.broadcast({ type: 'floor', groupId: gid, memberId: ws.client.memberId, name: ws.client.name });
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
onPttStop(ws) {
|
| 144 |
+
for (const [gid, f] of this.floors) {
|
| 145 |
+
if (f.client !== ws) continue;
|
| 146 |
+
this.floors.delete(gid);
|
| 147 |
+
db.prepare('INSERT INTO call_log (group_id, member_id, kind, ts) VALUES (?, ?, ?, ?)')
|
| 148 |
+
.run(gid, ws.client.memberId, 'ptt_stop', Date.now());
|
| 149 |
+
this.broadcast({ type: 'floor', groupId: gid, memberId: null });
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
onAudio(ws, buf) {
|
| 154 |
+
if (!ws.client || buf.length < HDR) return;
|
| 155 |
+
if (buf.readUInt8(0) !== AUDIO_TYPE) return;
|
| 156 |
+
const gid = buf.readUInt32BE(1);
|
| 157 |
+
const floor = this.floors.get(gid);
|
| 158 |
+
if (!floor || floor.client !== ws) return; // only the floor holder may transmit
|
| 159 |
+
for (const other of this.clients) {
|
| 160 |
+
if (other === ws || other.readyState !== 1) continue;
|
| 161 |
+
if (!other.client.listens.has(gid)) continue;
|
| 162 |
+
other.send(buf);
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
snapshot() {
|
| 167 |
+
return {
|
| 168 |
+
type: 'welcome',
|
| 169 |
+
groups: db.prepare('SELECT * FROM talkgroups ORDER BY id').all(),
|
| 170 |
+
members: allMembers(),
|
| 171 |
+
sos: db.prepare(`SELECT a.*, mm.name AS member_name FROM sos_alerts a
|
| 172 |
+
JOIN members mm ON mm.id = a.member_id
|
| 173 |
+
WHERE a.state != 'resolved' ORDER BY a.created_at DESC`).all(),
|
| 174 |
+
floors: [...this.floors.entries()].map(([groupId, f]) => ({ groupId, memberId: f.memberId, name: f.name })),
|
| 175 |
+
};
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
send(ws, obj) { if (ws.readyState === 1) ws.send(JSON.stringify(obj)); }
|
| 179 |
+
broadcast(obj) { for (const ws of this.clients) this.send(ws, obj); }
|
| 180 |
+
|
| 181 |
+
broadcastMembers() { this.broadcast({ type: 'members', members: allMembers() }); }
|
| 182 |
+
broadcastGroups() { this.broadcast({ type: 'groups', groups: db.prepare('SELECT * FROM talkgroups ORDER BY id').all() }); }
|
| 183 |
+
|
| 184 |
+
onClose(ws) {
|
| 185 |
+
this.clients.delete(ws);
|
| 186 |
+
for (const [gid, f] of this.floors) {
|
| 187 |
+
if (f.client !== ws) continue;
|
| 188 |
+
this.floors.delete(gid);
|
| 189 |
+
this.broadcast({ type: 'floor', groupId: gid, memberId: null });
|
| 190 |
+
}
|
| 191 |
+
if (ws.client?.kind === 'device') {
|
| 192 |
+
const stillOnline = [...this.clients].some(
|
| 193 |
+
(c) => c.client?.kind === 'device' && c.client.memberId === ws.client.memberId
|
| 194 |
+
);
|
| 195 |
+
if (!stillOnline) {
|
| 196 |
+
db.prepare("UPDATE members SET status = 'offline', last_seen = ? WHERE id = ?")
|
| 197 |
+
.run(Date.now(), ws.client.memberId);
|
| 198 |
+
this.broadcastMembers();
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
}
|