Files
boc/quixzoom-capture-pipeline/websocket/server.js
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- Add NFC ePassport roadmap (ICAO 9303, eIDAS)
- Add TensorFlow.js edge face detection (BlazeFace)
- Add structured audit logger (GDPR-compliant)
- Risk scoring support

Part of KYC Apple Native UX v1.1.0
2026-06-29 16:24:48 +00:00

634 lines
17 KiB
JavaScript

/**
* QUIXZOOM WebSocket Server
*
* Lågfördröjningskommunikation mellan telefon och backend.
*
* Telefon → Server:
* - Videoström (keyframes)
* - Telemetri (GPS, IMU, kompass)
* - Position
* - Sensordata
*
* Server → Telefon:
* - Instruktioner
* - Uppdragsstatus
* - Prioritet
* - Kvalitetspoäng
*/
const WebSocket = require('ws');
const http = require('http');
const crypto = require('crypto');
class QuixZoomWebSocketServer {
constructor(options = {}) {
this.port = options.port || 8080;
this.clients = new Map(); // clientId -> { ws, session, telemetry }
this.sessions = new Map(); // sessionId -> { clients, data }
// Fleet intelligence
this.fleet = new Map(); // area -> [zoomers]
this.areaKnowledge = new Map(); // area -> knowledge graph
// AI-tillstånd
this.aiState = new Map(); // sessionId -> { mode, instructions, knowledge }
this.server = null;
this.wss = null;
}
start() {
// HTTP-server för hälsokontroll
this.server = http.createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok',
clients: this.clients.size,
sessions: this.sessions.size,
uptime: process.uptime(),
}));
} else {
res.writeHead(404);
res.end();
}
});
// WebSocket-server
this.wss = new WebSocket.Server({ server: this.server });
this.wss.on('connection', (ws, req) => {
this.handleConnection(ws, req);
});
this.server.listen(this.port, () => {
console.log(`[WS] Server listening on port ${this.port}`);
});
// Starta fleet intelligence-loop
this.startFleetIntelligence();
}
handleConnection(ws, req) {
const clientId = crypto.randomUUID();
const client = {
id: clientId,
ws,
session: null,
telemetry: [],
lastPing: Date.now(),
area: null,
};
this.clients.set(clientId, client);
console.log(`[WS] Client connected: ${clientId}`);
// Skicka välkomstmeddelande
this.sendToClient(client, {
type: 'welcome',
clientId,
serverTime: Date.now(),
});
ws.on('message', (data) => {
this.handleMessage(client, data);
});
ws.on('close', () => {
this.handleDisconnection(client);
});
ws.on('error', (error) => {
console.error(`[WS] Client error: ${clientId}`, error);
});
// Ping/pong för att hålla anslutningen vid liv
const pingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.ping();
} else {
clearInterval(pingInterval);
}
}, 30000);
}
handleMessage(client, data) {
try {
const message = JSON.parse(data);
switch (message.type) {
case 'telemetry':
this.handleTelemetry(client, message);
break;
case 'frame':
this.handleFrame(client, message);
break;
case 'register':
this.handleRegister(client, message);
break;
case 'instruction_complete':
this.handleInstructionComplete(client, message);
break;
case 'status':
this.handleStatusRequest(client, message);
break;
default:
console.warn(`[WS] Unknown message type: ${message.type}`);
}
} catch (error) {
console.error('[WS] Failed to parse message:', error);
}
}
handleRegister(client, message) {
const { sessionId, mode, area } = message;
client.session = sessionId;
client.area = area;
// Registrera session
if (!this.sessions.has(sessionId)) {
this.sessions.set(sessionId, {
clients: new Set(),
data: {
telemetry: [],
frames: [],
instructions: [],
},
});
}
this.sessions.get(sessionId).clients.add(client.id);
// Registrera i fleet
if (area) {
if (!this.fleet.has(area)) {
this.fleet.set(area, new Set());
}
this.fleet.get(area).add(client.id);
}
console.log(`[WS] Client ${client.id} registered to session ${sessionId}, mode: ${mode}, area: ${area}`);
// Bekräfta registrering
this.sendToClient(client, {
type: 'registered',
sessionId,
mode,
area,
});
// Om guided eller adaptive mode, skicka första instruktion
if (mode === 'guided' || mode === 'adaptive') {
this.sendInstruction(client, {
text: 'Börja filma. Jag ger instruktioner efterhand.',
priority: 'low',
reason: 'Initial instruction',
});
}
}
handleTelemetry(client, message) {
const { gps, imu, compass, altitude, timestamp } = message;
// Spara telemetri
const telemetry = {
timestamp: timestamp || Date.now(),
gps,
imu,
compass,
altitude,
};
client.telemetry.push(telemetry);
// Håll endast senaste 1000 punkter
if (client.telemetry.length > 1000) {
client.telemetry.shift();
}
// Uppdatera session
if (client.session && this.sessions.has(client.session)) {
this.sessions.get(client.session).data.telemetry.push(telemetry);
}
// Uppdatera area knowledge
if (client.area) {
this.updateAreaKnowledge(client.area, telemetry);
}
// Analysera för att ge instruktioner (om guided/adaptive)
if (client.session) {
const aiState = this.aiState.get(client.session);
if (aiState && (aiState.mode === 'guided' || aiState.mode === 'adaptive')) {
this.analyzeAndInstruct(client);
}
}
}
handleFrame(client, message) {
const { frameId, timestamp, quality, analysis } = message;
// Spara frame-metadata
const frame = {
frameId,
timestamp: timestamp || Date.now(),
quality,
analysis,
};
if (client.session && this.sessions.has(client.session)) {
this.sessions.get(client.session).data.frames.push(frame);
}
// Om frame innehåller analys, uppdatera knowledge
if (analysis) {
this.updateKnowledgeFromAnalysis(client, analysis);
}
}
handleInstructionComplete(client, message) {
const { instructionId } = message;
console.log(`[WS] Client ${client.id} completed instruction ${instructionId}`);
// Uppdatera AI-tillstånd
if (client.session) {
const aiState = this.aiState.get(client.session);
if (aiState) {
aiState.completedInstructions = aiState.completedInstructions || [];
aiState.completedInstructions.push(instructionId);
}
}
// Skicka nästa instruktion om adaptive
if (client.session) {
const aiState = this.aiState.get(client.session);
if (aiState && aiState.mode === 'adaptive') {
this.planNextObservation(client);
}
}
}
handleStatusRequest(client, message) {
const session = client.session ? this.sessions.get(client.session) : null;
this.sendToClient(client, {
type: 'status',
clientId: client.id,
session: client.session,
area: client.area,
telemetryCount: client.telemetry.length,
sessionData: session ? {
clients: session.clients.size,
telemetryCount: session.data.telemetry.length,
frameCount: session.data.frames.length,
} : null,
});
}
handleDisconnection(client) {
console.log(`[WS] Client disconnected: ${client.id}`);
// Ta bort från session
if (client.session && this.sessions.has(client.session)) {
this.sessions.get(client.session).clients.delete(client.id);
}
// Ta bort från fleet
if (client.area && this.fleet.has(client.area)) {
this.fleet.get(client.area).delete(client.id);
}
this.clients.delete(client.id);
}
// ============================================================
// AI INSTRUCTIONS
// ============================================================
sendInstruction(client, instruction) {
const instructionId = crypto.randomUUID();
const message = {
type: 'instruction',
instructionId,
text: instruction.text,
priority: instruction.priority,
reason: instruction.reason,
timestamp: Date.now(),
};
this.sendToClient(client, message);
// Spara i session
if (client.session && this.sessions.has(client.session)) {
this.sessions.get(client.session).data.instructions.push({
instructionId,
...instruction,
timestamp: Date.now(),
});
}
console.log(`[WS] Sent instruction to ${client.id}: ${instruction.text}`);
}
analyzeAndInstruct(client) {
// Förenklad analys — i praktiken AI-modeller
const telemetry = client.telemetry;
if (telemetry.length < 10) return;
const latest = telemetry[telemetry.length - 1];
const previous = telemetry[telemetry.length - 10];
// Kontrollera om användaren rör sig för snabbt
const distance = this.calculateDistance(latest.gps, previous.gps);
const time = (latest.timestamp - previous.timestamp) / 1000;
const speed = distance / time;
if (speed > 2) { // Mer än 2 m/s
this.sendInstruction(client, {
text: 'Gå långsammare för bättre bildkvalitet',
priority: 'medium',
reason: 'Speed too high for quality capture',
});
}
// Kontrollera GPS-accuracy
if (latest.gps.accuracy > 20) {
this.sendInstruction(client, {
text: 'GPS-signal svag. Stanna kort för bättre positionering.',
priority: 'low',
reason: 'GPS accuracy degraded',
});
}
}
planNextObservation(client) {
// I adaptive mode — planera nästa observation
// I praktiken: beräkna information gain för olika möjliga observationer
const area = client.area;
if (!area || !this.areaKnowledge.has(area)) return;
const knowledge = this.areaKnowledge.get(area);
// Hitta kunskapsgap
const gaps = this.identifyKnowledgeGaps(knowledge);
if (gaps.length > 0) {
const topGap = gaps[0];
this.sendInstruction(client, {
text: `Dokumentera ${topGap.description}`,
priority: 'high',
reason: `Knowledge gap: ${topGap.type}`,
});
}
}
// ============================================================
// FLEET INTELLIGENCE
// ============================================================
startFleetIntelligence() {
// Kör var 5:e sekund
setInterval(() => {
this.optimizeFleet();
}, 5000);
}
optimizeFleet() {
// För varje område, optimera zoomer-placering
for (const [area, clientIds] of this.fleet) {
const zoomers = Array.from(clientIds)
.map(id => this.clients.get(id))
.filter(Boolean);
if (zoomers.length < 2) continue;
// Hitta överlappningar och gap
const coverage = this.calculateCoverage(zoomers);
const gaps = this.findCoverageGaps(area, coverage);
// Tilldela uppdrag baserat på position
for (const gap of gaps) {
// Hitta närmaste zoomer
const closest = this.findClosestZoomer(gap, zoomers);
if (closest) {
this.sendInstruction(closest, {
text: `Flytta till ${gap.description} för att täcka kunskapsgap`,
priority: 'medium',
reason: 'Fleet optimization: coverage gap',
});
}
}
// Om flera zoomer på samma plats, diversifiera
if (zoomers.length > 1) {
this.diversifyZoomers(zoomers);
}
}
}
calculateCoverage(zoomers) {
// Beräkna spatial täckning
const coverage = new Map();
for (const zoomer of zoomers) {
for (const telemetry of zoomer.telemetry.slice(-100)) {
const key = `${telemetry.gps.lat.toFixed(4)},${telemetry.gps.lng.toFixed(4)}`;
coverage.set(key, true);
}
}
return coverage;
}
findCoverageGaps(area, coverage) {
// Hitta områden utan täckning
// I praktiken: jämför med area bounds
const gaps = [];
// Förenklad implementation
if (coverage.size < 10) {
gaps.push({
description: 'områdets norra del',
location: { lat: 13.76, lng: 100.50 },
});
}
return gaps;
}
findClosestZoomer(target, zoomers) {
let closest = null;
let closestDistance = Infinity;
for (const zoomer of zoomers) {
const latest = zoomer.telemetry[zoomer.telemetry.length - 1];
if (!latest) continue;
const distance = this.calculateDistance(latest.gps, target.location);
if (distance < closestDistance) {
closestDistance = distance;
closest = zoomer;
}
}
return closest;
}
diversifyZoomers(zoomers) {
// Om flera zoomer på samma plats, be dem dokumentera olika saker
const instructions = [
'Filma från motsatt sida gatan',
'Dokumentera trottoaren i detalj',
'Fokusera på gatlyktor och skyltar',
'Filma byggnadernas fasader',
];
for (let i = 0; i < zoomers.length; i++) {
const instruction = instructions[i % instructions.length];
this.sendInstruction(zoomers[i], {
text: instruction,
priority: 'low',
reason: 'Fleet diversification: avoid duplicate coverage',
});
}
}
// ============================================================
// AREA KNOWLEDGE GRAPH
// ============================================================
updateAreaKnowledge(area, telemetry) {
if (!this.areaKnowledge.has(area)) {
this.areaKnowledge.set(area, {
observations: [],
coverage: new Map(),
objects: new Map(),
gaps: [],
});
}
const knowledge = this.areaKnowledge.get(area);
// Lägg till observation
knowledge.observations.push({
timestamp: telemetry.timestamp,
gps: telemetry.gps,
});
// Uppdatera täckning
const key = `${telemetry.gps.lat.toFixed(4)},${telemetry.gps.lng.toFixed(4)}`;
knowledge.coverage.set(key, true);
}
updateKnowledgeFromAnalysis(client, analysis) {
const area = client.area;
if (!area || !this.areaKnowledge.has(area)) return;
const knowledge = this.areaKnowledge.get(area);
// Uppdatera objekt-lista
for (const obj of analysis.objects || []) {
const key = `${obj.class}_${obj.id || 'unknown'}`;
if (!knowledge.objects.has(key)) {
knowledge.objects.set(key, {
class: obj.class,
firstSeen: Date.now(),
observations: [],
confidence: obj.confidence,
});
}
knowledge.objects.get(key).observations.push({
timestamp: Date.now(),
gps: client.telemetry[client.telemetry.length - 1]?.gps,
confidence: obj.confidence,
});
}
}
identifyKnowledgeGaps(knowledge) {
const gaps = [];
// Kontrollera täckning
if (knowledge.coverage.size < 50) {
gaps.push({
type: 'coverage',
description: 'mer av området',
priority: 'high',
});
}
// Kontrollera objekt-diversitet
const objectTypes = new Set(Array.from(knowledge.objects.values()).map(o => o.class));
if (objectTypes.size < 5) {
gaps.push({
type: 'diversity',
description: 'fler typer av objekt',
priority: 'medium',
});
}
return gaps;
}
// ============================================================
// HJÄLPMETODER
// ============================================================
sendToClient(client, message) {
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.send(JSON.stringify(message));
}
}
calculateDistance(a, b) {
const R = 6371e3;
const φ1 = a.lat * Math.PI / 180;
const φ2 = b.lat * Math.PI / 180;
const Δφ = (b.lat - a.lat) * Math.PI / 180;
const Δλ = (b.lng - b.lng) * Math.PI / 180;
const x = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ/2) * Math.sin(Δλ/2);
const c = 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1-x));
return R * c;
}
broadcastToArea(area, message) {
const clientIds = this.fleet.get(area);
if (!clientIds) return;
for (const clientId of clientIds) {
const client = this.clients.get(clientId);
if (client) {
this.sendToClient(client, message);
}
}
}
}
// Exportera
module.exports = QuixZoomWebSocketServer;
// Starta om filen körs direkt
if (require.main === module) {
const server = new QuixZoomWebSocketServer({ port: 8080 });
server.start();
console.log('[WS] QuixZoom WebSocket Server started');
console.log('[WS] Endpoints:');
console.log(' ws://localhost:8080');
console.log(' http://localhost:8080/health');
}