secutorpro commited on
Commit
28e21d6
·
verified ·
1 Parent(s): 271f4d0

WebSocket hub: device GPS/SOS, PTT floor control, PCM audio relay

Browse files
Files changed (1) hide show
  1. 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
+ }