Spaces:
Paused
Paused
Download server/src/realtime.js from secutorpro/dispatch-console: direct link, hf CLI and curl.
- Browser
- Download file 8.25 kB
-
https://huggingface.co/spaces/secutorpro/dispatch-console/resolve/main/server/src/realtime.js
- Command line
-
hf download hf://spaces/secutorpro/dispatch-console/server/src/realtime.js
-
curl -L -o realtime.js https://huggingface.co/spaces/secutorpro/dispatch-console/resolve/main/server/src/realtime.js
8.25 kB
| 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(); | |
| } | |
| } | |
| } | |
| } |