import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:shelf/shelf.dart' as shelf; import 'package:shelf/shelf_io.dart' as io; import 'package:nsd/nsd.dart'; import '../models/message.dart'; import '../models/contact.dart'; import '../models/sse_server_config.dart'; import 'network_scanner_service.dart'; /// SSE Server Service /// /// Provides a web server with SSE (Server-Sent Events) endpoints for /// real-time message and contact updates, enabling multiple app instances /// to share a single MeshCore BLE device. /// /// Endpoints: /// - GET /sse/messages - SSE stream for message updates /// - GET /sse/contacts - SSE stream for contact updates /// - POST /api/messages - Send message /// - POST /api/messages/channel - Send channel message /// - POST /api/contacts/sync - Trigger contact sync /// - GET /api/messages/history - Get all messages /// - GET /api/contacts - Get all contacts /// - GET /api/status - Server health check class SseServerService { HttpServer? _server; SseServerConfig? _config; Registration? _bonjourRegistration; /// Active SSE connections for messages final Set> _messageStreams = {}; /// Active SSE connections for contacts final Set> _contactStreams = {}; /// Message history (for new clients) final List _messageHistory = []; /// Contact list (for new clients) final Map _contacts = {}; /// Timer for cleaning up dead connections Timer? _cleanupTimer; /// Device name (for status endpoint) String? _deviceName; /// Set device name void setDeviceName(String? name) { _deviceName = name; debugPrint('๐Ÿ“ [SseServer] Device name set to: $name'); } /// Callback for when a client requests to send a message Future Function(String recipientPublicKey, String text)? onSendMessage; /// Callback for when a client requests to send a channel message Future Function(int channelIdx, String text)? onSendChannelMessage; /// Callback for when a client requests contact sync Future Function()? onSyncContacts; /// Check if server is running bool get isRunning => _server != null; /// Get current configuration SseServerConfig? get config => _config; /// Get number of connected clients int get connectedClients => _messageStreams.length; /// CORS middleware static shelf.Middleware get _corsHeaders { return shelf.createMiddleware( responseHandler: (shelf.Response response) { return response.change(headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Origin, Content-Type, Authorization', }); }, ); } /// Start the SSE server Future startServer(SseServerConfig config) async { if (_server != null) { debugPrint('โš ๏ธ [SseServer] Server already running'); return; } _config = config; try { debugPrint('๐Ÿš€ [SseServer] Starting server on ${config.host}:${config.port}'); // Create shelf handler with CORS support final handler = const shelf.Pipeline() .addMiddleware(_corsHeaders) .addMiddleware(shelf.logRequests()) .addHandler(_handleRequest); // Start HTTP server _server = await io.serve( handler, config.host, config.port, ); debugPrint('โœ… [SseServer] Server started on ${config.getServerUrl()}'); // Start cleanup timer for dead connections _startCleanupTimer(); // Register Bonjour/mDNS service await _registerBonjourService(config); } catch (e) { debugPrint('โŒ [SseServer] Failed to start server: $e'); _server = null; rethrow; } } /// Register Bonjour/mDNS service for network discovery Future _registerBonjourService(SseServerConfig config) async { try { debugPrint('๐Ÿ“ก [SseServer] Registering Bonjour service ${NetworkScannerService.serviceType}...'); _bonjourRegistration = await register( const Service( name: 'MeshCore SSE Server', type: NetworkScannerService.serviceType, port: 0, // Will be set dynamically ), ); // Update with actual port if (_bonjourRegistration != null) { // Unregister and re-register with correct port await unregister(_bonjourRegistration!); _bonjourRegistration = await register( Service( name: 'MeshCore SSE Server', type: NetworkScannerService.serviceType, port: config.port, ), ); debugPrint('โœ… [SseServer] Bonjour service registered on port ${config.port}'); } } catch (e) { debugPrint('โš ๏ธ [SseServer] Failed to register Bonjour service: $e'); // Don't throw - server can still work without Bonjour } } /// Start cleanup timer to remove dead connections void _startCleanupTimer() { _cleanupTimer?.cancel(); _cleanupTimer = Timer.periodic(const Duration(seconds: 60), (timer) { _cleanupDeadConnections(); }); debugPrint('๐Ÿงน [SseServer] Cleanup timer started (60s interval)'); } /// Clean up dead/closed connections void _cleanupDeadConnections() { // Clean up message streams final deadMessageStreams = _messageStreams.where((s) => s.isClosed).toList(); for (final stream in deadMessageStreams) { _messageStreams.remove(stream); } // Clean up contact streams final deadContactStreams = _contactStreams.where((s) => s.isClosed).toList(); for (final stream in deadContactStreams) { _contactStreams.remove(stream); } if (deadMessageStreams.isNotEmpty || deadContactStreams.isNotEmpty) { debugPrint('๐Ÿงน [SseServer] Cleaned up ${deadMessageStreams.length} dead message streams, ${deadContactStreams.length} dead contact streams'); debugPrint(' Active: ${_messageStreams.length} message clients, ${_contactStreams.length} contact clients'); } } /// Stop the SSE server Future stopServer() async { if (_server == null) { return; } debugPrint('๐Ÿ›‘ [SseServer] Stopping server...'); // Stop cleanup timer _cleanupTimer?.cancel(); _cleanupTimer = null; // Close all SSE streams for (final stream in _messageStreams) { await stream.close(); } _messageStreams.clear(); for (final stream in _contactStreams) { await stream.close(); } _contactStreams.clear(); // Unregister Bonjour service if (_bonjourRegistration != null) { try { await unregister(_bonjourRegistration!); debugPrint('โœ… [SseServer] Bonjour service unregistered'); } catch (e) { debugPrint('โš ๏ธ [SseServer] Failed to unregister Bonjour service: $e'); } _bonjourRegistration = null; } // Close HTTP server await _server!.close(force: true); _server = null; _config = null; debugPrint('โœ… [SseServer] Server stopped'); } /// Main request handler Future _handleRequest(shelf.Request request) async { // Check authentication if token is configured if (_config?.authToken != null) { final authHeader = request.headers['authorization']; if (authHeader != 'Bearer ${_config!.authToken}') { return shelf.Response.forbidden('Invalid authentication token'); } } final path = request.url.path; final method = request.method; debugPrint('๐Ÿ“จ [SseServer] $method /$path'); // Route requests if (method == 'GET' && path == 'sse/messages') { return _handleSseMessages(request); } else if (method == 'GET' && path == 'sse/contacts') { return _handleSseContacts(request); } else if (method == 'POST' && path == 'api/messages') { return _handlePostMessage(request); } else if (method == 'POST' && path == 'api/messages/channel') { return _handlePostChannelMessage(request); } else if (method == 'POST' && path == 'api/contacts/sync') { return _handlePostContactsSync(request); } else if (method == 'GET' && path == 'api/messages/history') { return _handleGetMessageHistory(request); } else if (method == 'GET' && path == 'api/contacts') { return _handleGetContacts(request); } else if (method == 'GET' && path == 'api/status') { return _handleGetStatus(request); } else if (method == 'GET' && path == '') { return _handleRoot(request); } return shelf.Response.notFound('Not found'); } /// Handle SSE messages stream shelf.Response _handleSseMessages(shelf.Request request) { return request.hijack((channel) async { debugPrint('๐Ÿ“ฅ [SseServer] New SSE client connected (messages) via hijack'); // Set up the sink for sending data final sink = utf8.encoder.startChunkedConversion(channel.sink); // Send SSE headers sink.add('HTTP/1.1 200 OK\r\n'); sink.add('Content-Type: text/event-stream\r\n'); sink.add('Cache-Control: no-cache\r\n'); sink.add('Connection: keep-alive\r\n'); sink.add('\r\n'); // Create controller for this connection final controller = StreamController(); _messageStreams.add(controller); debugPrint(' Total clients: ${_messageStreams.length}'); // Send initial connection event sink.add(': connected\n\n'); // Send initial message history for (final message in _messageHistory) { final event = _formatSseEvent('message', _messageToJson(message)); sink.add(event); } // Start keep-alive timer final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (timer) { try { sink.add(': keepalive\n\n'); } catch (e) { debugPrint('โš ๏ธ [SseServer] Keep-alive failed: $e'); timer.cancel(); } }); // Listen to controller for new messages to broadcast final subscription = controller.stream.listen( (data) { try { sink.add(data); } catch (e) { debugPrint('โš ๏ธ [SseServer] Failed to send data: $e'); } }, onDone: () { debugPrint('๐Ÿ“ค [SseServer] Controller stream closed'); }, ); // Wait for channel to close await channel.stream.drain(); // Cleanup keepAliveTimer.cancel(); await subscription.cancel(); _messageStreams.remove(controller); await controller.close(); debugPrint('๐Ÿ“ค [SseServer] SSE client disconnected (messages)'); debugPrint(' Total clients: ${_messageStreams.length}'); }); } /// Handle SSE contacts stream shelf.Response _handleSseContacts(shelf.Request request) { return request.hijack((channel) async { debugPrint('๐Ÿ“ฅ [SseServer] New SSE client connected (contacts) via hijack'); // Set up the sink for sending data final sink = utf8.encoder.startChunkedConversion(channel.sink); // Send SSE headers sink.add('HTTP/1.1 200 OK\r\n'); sink.add('Content-Type: text/event-stream\r\n'); sink.add('Cache-Control: no-cache\r\n'); sink.add('Connection: keep-alive\r\n'); sink.add('\r\n'); // Create controller for this connection final controller = StreamController(); _contactStreams.add(controller); debugPrint(' Total clients: ${_contactStreams.length}'); // Send initial connection event sink.add(': connected\n\n'); // Send initial contact list for (final contact in _contacts.values) { final event = _formatSseEvent('contact', _contactToJson(contact)); sink.add(event); } // Start keep-alive timer final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (timer) { try { sink.add(': keepalive\n\n'); } catch (e) { debugPrint('โš ๏ธ [SseServer] Keep-alive failed: $e'); timer.cancel(); } }); // Listen to controller for new messages to broadcast final subscription = controller.stream.listen( (data) { try { sink.add(data); } catch (e) { debugPrint('โš ๏ธ [SseServer] Failed to send data: $e'); } }, onDone: () { debugPrint('๐Ÿ“ค [SseServer] Controller stream closed'); }, ); // Wait for channel to close await channel.stream.drain(); // Cleanup keepAliveTimer.cancel(); await subscription.cancel(); _contactStreams.remove(controller); await controller.close(); debugPrint('๐Ÿ“ค [SseServer] SSE client disconnected (contacts)'); debugPrint(' Total clients: ${_contactStreams.length}'); }); } /// Handle POST message request Future _handlePostMessage(shelf.Request request) async { try { final body = await request.readAsString(); final json = jsonDecode(body) as Map; final recipientPublicKey = json['recipientPublicKey'] as String; final text = json['text'] as String; if (onSendMessage == null) { return shelf.Response.internalServerError( body: jsonEncode({'error': 'Send message callback not configured'}), ); } final success = await onSendMessage!(recipientPublicKey, text); return shelf.Response.ok( jsonEncode({'success': success}), headers: {'content-type': 'application/json'}, ); } catch (e) { debugPrint('โŒ [SseServer] Error handling POST message: $e'); return shelf.Response.internalServerError( body: jsonEncode({'error': e.toString()}), ); } } /// Handle POST channel message request Future _handlePostChannelMessage(shelf.Request request) async { try { final body = await request.readAsString(); final json = jsonDecode(body) as Map; final channelIdx = json['channelIdx'] as int; final text = json['text'] as String; if (onSendChannelMessage == null) { return shelf.Response.internalServerError( body: jsonEncode({'error': 'Send channel message callback not configured'}), ); } await onSendChannelMessage!(channelIdx, text); return shelf.Response.ok( jsonEncode({'success': true}), headers: {'content-type': 'application/json'}, ); } catch (e) { debugPrint('โŒ [SseServer] Error handling POST channel message: $e'); return shelf.Response.internalServerError( body: jsonEncode({'error': e.toString()}), ); } } /// Handle POST contacts sync request Future _handlePostContactsSync(shelf.Request request) async { try { if (onSyncContacts == null) { return shelf.Response.internalServerError( body: jsonEncode({'error': 'Sync contacts callback not configured'}), ); } await onSyncContacts!(); return shelf.Response.ok( jsonEncode({'success': true}), headers: {'content-type': 'application/json'}, ); } catch (e) { debugPrint('โŒ [SseServer] Error handling POST contacts sync: $e'); return shelf.Response.internalServerError( body: jsonEncode({'error': e.toString()}), ); } } /// Handle GET message history request shelf.Response _handleGetMessageHistory(shelf.Request request) { final messages = _messageHistory.map(_messageToJson).toList(); return shelf.Response.ok( jsonEncode({'messages': messages}), headers: {'content-type': 'application/json'}, ); } /// Handle GET contacts request shelf.Response _handleGetContacts(shelf.Request request) { final contacts = _contacts.values.map(_contactToJson).toList(); return shelf.Response.ok( jsonEncode({'contacts': contacts}), headers: {'content-type': 'application/json'}, ); } /// Handle GET status request shelf.Response _handleGetStatus(shelf.Request request) { return shelf.Response.ok( jsonEncode({ 'status': 'running', 'connectedClients': connectedClients, 'messageCount': _messageHistory.length, 'contactCount': _contacts.length, 'deviceName': _deviceName, }), headers: {'content-type': 'application/json'}, ); } /// Handle root request (landing page) shelf.Response _handleRoot(shelf.Request request) { final html = ''' MeshCore SAR - SSE Server

๐Ÿš€ MeshCore SAR Server

โœ… Server is running

This server enables multiple MeshCore SAR clients to share a single BLE device.

๐Ÿ“ก SSE Endpoints

GET /sse/messages
GET /sse/contacts

๐Ÿ”ง API Endpoints

POST /api/messages
POST /api/messages/channel
POST /api/contacts/sync
GET /api/messages/history
GET /api/contacts
GET /api/status

๐Ÿ“Š Stats

Connected clients: Loading...

Messages: Loading...

Contacts: Loading...

'''; return shelf.Response.ok( html, headers: {'content-type': 'text/html'}, ); } /// Broadcast a new message to all SSE clients void broadcastMessage(Message message) { // Add to history (limit to 1000 messages) _messageHistory.add(message); if (_messageHistory.length > 1000) { _messageHistory.removeAt(0); } // Broadcast to all connected clients final event = _formatSseEvent('message', _messageToJson(message)); final deadStreams = >[]; for (final stream in _messageStreams) { if (stream.isClosed) { deadStreams.add(stream); } else { try { stream.add(event); } catch (e) { debugPrint('โš ๏ธ [SseServer] Failed to send to stream, marking as dead: $e'); deadStreams.add(stream); } } } // Remove dead streams for (final stream in deadStreams) { _messageStreams.remove(stream); stream.close().catchError((e) => debugPrint('โš ๏ธ [SseServer] Error closing dead stream: $e')); } if (deadStreams.isNotEmpty) { debugPrint('๐Ÿงน [SseServer] Removed ${deadStreams.length} dead message streams during broadcast'); } debugPrint('๐Ÿ“ข [SseServer] Broadcasted message to ${_messageStreams.length} clients'); } /// Broadcast a new or updated contact to all SSE clients void broadcastContact(Contact contact) { // Update contact list _contacts[contact.publicKeyHex] = contact; // Broadcast to all connected clients final event = _formatSseEvent('contact', _contactToJson(contact)); final deadStreams = >[]; for (final stream in _contactStreams) { if (stream.isClosed) { deadStreams.add(stream); } else { try { stream.add(event); } catch (e) { debugPrint('โš ๏ธ [SseServer] Failed to send to stream, marking as dead: $e'); deadStreams.add(stream); } } } // Remove dead streams for (final stream in deadStreams) { _contactStreams.remove(stream); stream.close().catchError((e) => debugPrint('โš ๏ธ [SseServer] Error closing dead stream: $e')); } if (deadStreams.isNotEmpty) { debugPrint('๐Ÿงน [SseServer] Removed ${deadStreams.length} dead contact streams during broadcast'); } debugPrint('๐Ÿ“ข [SseServer] Broadcasted contact to ${_contactStreams.length} clients'); } /// Format SSE event String _formatSseEvent(String eventType, Map data) { final jsonData = jsonEncode(data); return 'event: $eventType\ndata: $jsonData\n\n'; } /// Convert Message to JSON Map _messageToJson(Message message) { return { 'id': message.id, 'messageType': message.messageType.name, 'senderPublicKeyPrefix': message.senderPublicKeyPrefix?.toList(), 'channelIdx': message.channelIdx, 'pathLen': message.pathLen, 'textType': message.textType.value, 'senderTimestamp': message.senderTimestamp, 'text': message.text, 'isSarMarker': message.isSarMarker, 'sarGpsCoordinates': message.sarGpsCoordinates != null ? { 'latitude': message.sarGpsCoordinates!.latitude, 'longitude': message.sarGpsCoordinates!.longitude, } : null, 'sarNotes': message.sarNotes, 'sarCustomEmoji': message.sarCustomEmoji, 'sarColorIndex': message.sarColorIndex, 'receivedAt': message.receivedAt.toIso8601String(), 'senderName': message.senderName, 'deliveryStatus': message.deliveryStatus.name, 'expectedAckTag': message.expectedAckTag, 'suggestedTimeoutMs': message.suggestedTimeoutMs, 'roundTripTimeMs': message.roundTripTimeMs, 'deliveredAt': message.deliveredAt?.toIso8601String(), 'recipientPublicKey': message.recipientPublicKey?.toList(), 'retryAttempt': message.retryAttempt, 'lastRetryAt': message.lastRetryAt?.toIso8601String(), 'usedFloodFallback': message.usedFloodFallback, 'isRead': message.isRead, 'echoCount': message.echoCount, 'firstEchoAt': message.firstEchoAt?.toIso8601String(), 'isDrawing': message.isDrawing, 'drawingId': message.drawingId, }; } /// Convert Contact to JSON Map _contactToJson(Contact contact) { return { 'publicKey': contact.publicKey.toList(), 'publicKeyHex': contact.publicKeyHex, 'type': contact.type.value, 'flags': contact.flags, 'outPathLen': contact.outPathLen, 'outPath': contact.outPath.toList(), 'advName': contact.advName, 'lastAdvert': contact.lastAdvert, 'advLat': contact.advLat, 'advLon': contact.advLon, 'lastMod': contact.lastMod, 'telemetry': contact.telemetry != null ? { 'batteryPercentage': contact.telemetry!.batteryPercentage, 'batteryMilliVolts': contact.telemetry!.batteryMilliVolts, 'temperature': contact.telemetry!.temperature, 'humidity': contact.telemetry!.humidity, 'pressure': contact.telemetry!.pressure, 'gpsLocation': contact.telemetry!.gpsLocation != null ? { 'latitude': contact.telemetry!.gpsLocation!.latitude, 'longitude': contact.telemetry!.gpsLocation!.longitude, } : null, 'timestamp': contact.telemetry!.timestamp.toIso8601String(), } : null, }; } /// Clear message history void clearMessageHistory() { _messageHistory.clear(); } /// Clear contact list void clearContacts() { _contacts.clear(); } }