import jwt from 'jsonwebtoken'; import { secret } from './auth.js'; import db from './db.js'; const HDR = 9; // binary audio header: type(1) + groupId(4) + memberId(4) const AUDIO = 0x01; const memberById = (id) => db.prepare('SELECT * FROM members WHERE id = ?').get(id); const SOS_SQL = `SELECT a.*, m.name AS member_name FROM sos_alerts a JOIN members m ON m.id = a.member_id`; export default class Hub { constructor(wss) { this.wss = wss; this.clients = new Set(); this.floors = new Map(); // groupId -> { client, memberId, name } setInterval(() => this.heartbeat(), 30000); wss.on('connection', (ws) => this.onConnection(ws)); } heartbeat() { for (const ws of this.clients) { if (ws.isAlive === false) { ws.terminate(); continue; } ws.isAlive = false; ws.ping(); } } onConnection(ws) { ws.isAlive = true; ws.client = null; ws.on('pong', () => { ws.isAlive = true; }); ws.on('message', (data, isBinary) => { try { if (isBinary) return this.onAudio(ws, Buffer.from(data)); const msg = JSON.parse(data.toString('utf8')); if (!ws.client) return this.onHello(ws, msg); this.onMessage(ws, msg); } catch { this.send(ws, { type: 'error', message: 'malformed message' }); } }); ws.on('close', () => this.onClose(ws)); ws.on('error', () => {}); } verify(token) { try { return jwt.verify(token, secret); } catch { return null; } } onHello(ws, msg) { if (msg.type !== 'auth' || !msg.token) { ws.close(); return; } const claims = this.verify(msg.token); if (!claims) { this.send(ws, { type: 'error', message: 'invalid token' }); ws.close(); return; } if (msg.memberId != null) { // device client: represents a radio/phone member const member = memberById(msg.memberId); if (!member) { this.send(ws, { type: 'error', message: 'unknown member' }); ws.close(); return; } ws.client = { kind: 'device', memberId: member.id, name: member.name, listens: new Set() }; if (member.group_id != null) ws.client.listens.add(member.group_id); db.prepare(`UPDATE members SET status = 'online', last_seen = ? WHERE id = ?`).run(Date.now(), member.id); } else { // dispatcher client: sees every talkgroup ws.client = { kind: 'dispatcher', memberId: 0, name: claims.username, listens: new Set() }; for (const g of db.prepare('SELECT id FROM talkgroups').all()) ws.client.listens.add(g.id); } this.clients.add(ws); this.send(ws, this.snapshot()); this.broadcastMembers(); } onMessage(ws, msg) { const c = ws.client; switch (msg.type) { case 'sync': return this.send(ws, this.snapshot()); case 'listen': if (Array.isArray(msg.groupIds)) c.listens = new Set(msg.groupIds.filter(Number.isInteger)); return; case 'gps': return this.onGps(ws, msg); case 'sos': return this.onSos(ws); case 'sos_ack': return this.onSosState(ws, msg.id, 'ack'); case 'sos_resolve': return this.onSosState(ws, msg.id, 'resolved'); case 'ptt_start': return this.onPttStart(ws, msg); case 'ptt_stop': return this.onPttStop(ws); default: return this.send(ws, { type: 'error', message: `unknown type: ${msg.type}` }); } } onGps(ws, msg) { if (ws.client.kind !== 'device') return; const { lat, lng } = msg; if (!Number.isFinite(lat) || !Number.isFinite(lng)) { return this.send(ws, { type: 'error', message: 'bad gps' }); } const battery = Number.isFinite(msg.battery) ? Math.max(0, Math.min(100, Math.round(msg.battery))) : null; const speed = Number.isFinite(msg.speed) ? msg.speed : null; db.prepare(`UPDATE members SET lat = ?, lng = ?, battery = COALESCE(?, battery), last_seen = ?, status = 'online' WHERE id = ?`) .run(lat, lng, battery, Date.now(), ws.client.memberId); db.prepare('INSERT INTO gps_pings (member_id, lat, lng, speed, ts) VALUES (?, ?, ?, ?, ?)') .run(ws.client.memberId, lat, lng, speed, Date.now()); this.broadcastMembers(); } onSos(ws) { if (ws.client.kind !== 'device') return; const m = memberById(ws.client.memberId); const now = Date.now(); const info = db.prepare('INSERT INTO sos_alerts (member_id, lat, lng, state, created_at) VALUES (?, ?, ?, ?, ?)') .run(m.id, m.lat, m.lng, 'open', now); const row = db.prepare(`${SOS_SQL} WHERE a.id = ?`).get(info.lastInsertRowid); this.broadcast({ type: 'sos_new', alert: row }); } onSosState(ws, id, state) { if (ws.client.kind !== 'dispatcher') return; const info = db.prepare('UPDATE sos_alerts SET state = ?, ack_by = ?, updated_at = ? WHERE id = ?') .run(state, ws.client.name, Date.now(), id); if (!info.changes) return; const row = db.prepare(`${SOS_SQL} WHERE a.id = ?`).get(id); this.broadcast({ type: 'sos_update', alert: row }); } onPttStart(ws, msg) { const gid = Number.isInteger(msg.groupId) ? msg.groupId : (ws.client.kind === 'device' ? memberById(ws.client.memberId)?.group_id : null); if (gid == null) return this.send(ws, { type: 'error', message: 'no group' }); const cur = this.floors.get(gid); if (cur && cur.client !== ws) { return this.send(ws, { type: 'ptt_denied', groupId: gid, holder: cur.name }); } this.floors.set(gid, { client: ws, memberId: ws.client.memberId, name: ws.client.name }); db.prepare('INSERT INTO call_log (group_id, member_id, kind, ts) VALUES (?, ?, ?, ?)') .run(gid, ws.client.memberId, 'ptt_start', Date.now()); this.broadcast({ type: 'floor', groupId: gid, memberId: ws.client.memberId, name: ws.client.name }); } onPttStop(ws) { for (const [gid, f] of this.floors) { if (f.client === ws) { this.floors.delete(gid); db.prepare('INSERT INTO call_log (group_id, member_id, kind, ts) VALUES (?, ?, ?, ?)') .run(gid, ws.client.memberId, 'ptt_stop', Date.now()); this.broadcast({ type: 'floor', groupId: gid, memberId: null }); } } } onAudio(ws, buf) { if (!ws.client || buf.length < HDR) return; if (buf.readUInt8(0) !== AUDIO) return; const gid = buf.readUInt32BE(1); const floor = this.floors.get(gid); if (!floor || floor.client !== ws) return; // only the floor holder may transmit for (const other of this.clients) { if (other === ws || !other.client) continue; if (!other.client.listens.has(gid)) continue; if (other.readyState === 1) other.send(buf); } } snapshot() { return { type: 'welcome', groups: db.prepare('SELECT * FROM talkgroups ORDER BY id').all(), members: db.prepare(`SELECT m.*, g.name AS group_name, g.color AS group_color FROM members m LEFT JOIN talkgroups g ON g.id = m.group_id ORDER BY m.id`).all(), sos: db.prepare(`${SOS_SQL} WHERE a.state != 'resolved' ORDER BY a.created_at DESC`).all(), floors: [...this.floors.entries()].map(([groupId, f]) => ({ groupId, memberId: f.memberId, name: f.name })), }; } send(ws, obj) { if (ws.readyState === 1) ws.send(JSON.stringify(obj)); } broadcast(obj) { for (const ws of this.clients) this.send(ws, obj); } broadcastMembers() { this.broadcast({ type: 'members', members: db.prepare(`SELECT m.*, g.name AS group_name, g.color AS group_color FROM members m LEFT JOIN talkgroups g ON g.id = m.group_id ORDER BY m.id`).all(), }); } broadcastGroups() { this.broadcast({ type: 'groups', groups: db.prepare('SELECT * FROM talkgroups ORDER BY id').all() }); } onClose(ws) { this.clients.delete(ws); for (const [gid, f] of this.floors) { if (f.client === ws) { this.floors.delete(gid); this.broadcast({ type: 'floor', groupId: gid, memberId: null }); } } if (ws.client && ws.client.kind === 'device') { const still = [...this.clients].some((c) => c.client && c.client.kind === 'device' && c.client.memberId === ws.client.memberId); if (!still) { db.prepare(`UPDATE members SET status = 'offline', last_seen = ? WHERE id = ?`).run(Date.now(), ws.client.memberId); this.broadcastMembers(); } } } }