/** * 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'); }