bae705aa97
- 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
634 lines
17 KiB
JavaScript
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');
|
|
}
|