mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
Fix contact add failure
This commit is contained in:
@@ -14,9 +14,6 @@ enum ConnectionMode {
|
|||||||
/// Direct BLE connection to MeshCore device (default)
|
/// Direct BLE connection to MeshCore device (default)
|
||||||
ble,
|
ble,
|
||||||
|
|
||||||
/// Act as SSE server - share BLE device with multiple clients
|
|
||||||
sseServer,
|
|
||||||
|
|
||||||
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
|
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
|
||||||
tcp,
|
tcp,
|
||||||
}
|
}
|
||||||
@@ -26,8 +23,6 @@ extension ConnectionModeExtension on ConnectionMode {
|
|||||||
switch (this) {
|
switch (this) {
|
||||||
case ConnectionMode.ble:
|
case ConnectionMode.ble:
|
||||||
return 'Direct (BLE)';
|
return 'Direct (BLE)';
|
||||||
case ConnectionMode.sseServer:
|
|
||||||
return 'Share Device (Server)';
|
|
||||||
case ConnectionMode.tcp:
|
case ConnectionMode.tcp:
|
||||||
return 'Direct (WiFi)';
|
return 'Direct (WiFi)';
|
||||||
}
|
}
|
||||||
@@ -37,8 +32,6 @@ extension ConnectionModeExtension on ConnectionMode {
|
|||||||
switch (this) {
|
switch (this) {
|
||||||
case ConnectionMode.ble:
|
case ConnectionMode.ble:
|
||||||
return 'Direct BLE connection to MeshCore device';
|
return 'Direct BLE connection to MeshCore device';
|
||||||
case ConnectionMode.sseServer:
|
|
||||||
return 'Share BLE device with multiple clients over network';
|
|
||||||
case ConnectionMode.tcp:
|
case ConnectionMode.tcp:
|
||||||
return 'Direct WiFi/TCP connection to MeshCore device';
|
return 'Direct WiFi/TCP connection to MeshCore device';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
/// SSE Server Configuration Model
|
|
||||||
///
|
|
||||||
/// Configuration for the SSE (Server-Sent Events) web server that enables
|
|
||||||
/// multiple app instances to share a single MeshCore BLE device.
|
|
||||||
class SseServerConfig {
|
|
||||||
/// Server bind address (e.g., "0.0.0.0" for all interfaces, "127.0.0.1" for localhost)
|
|
||||||
final String host;
|
|
||||||
|
|
||||||
/// Server port (default: 12929)
|
|
||||||
final int port;
|
|
||||||
|
|
||||||
/// Whether the SSE server is enabled
|
|
||||||
final bool enabled;
|
|
||||||
|
|
||||||
/// Optional authentication token for basic security
|
|
||||||
/// Clients must include this token in Authorization header
|
|
||||||
final String? authToken;
|
|
||||||
|
|
||||||
const SseServerConfig({
|
|
||||||
this.host = '0.0.0.0',
|
|
||||||
this.port = 12929,
|
|
||||||
this.enabled = false,
|
|
||||||
this.authToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Create a copy with updated fields
|
|
||||||
SseServerConfig copyWith({
|
|
||||||
String? host,
|
|
||||||
int? port,
|
|
||||||
bool? enabled,
|
|
||||||
String? authToken,
|
|
||||||
}) {
|
|
||||||
return SseServerConfig(
|
|
||||||
host: host ?? this.host,
|
|
||||||
port: port ?? this.port,
|
|
||||||
enabled: enabled ?? this.enabled,
|
|
||||||
authToken: authToken ?? this.authToken,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get server URL for clients to connect to
|
|
||||||
String getServerUrl({String? ipAddress}) {
|
|
||||||
final ip = ipAddress ?? host;
|
|
||||||
return 'http://$ip:$port';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert to JSON for persistence
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
'host': host,
|
|
||||||
'port': port,
|
|
||||||
'enabled': enabled,
|
|
||||||
'authToken': authToken,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create from JSON
|
|
||||||
factory SseServerConfig.fromJson(Map<String, dynamic> json) {
|
|
||||||
return SseServerConfig(
|
|
||||||
host: json['host'] as String? ?? '0.0.0.0',
|
|
||||||
port: json['port'] as int? ?? 12929,
|
|
||||||
enabled: json['enabled'] as bool? ?? false,
|
|
||||||
authToken: json['authToken'] as String?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return 'SseServerConfig(host: $host, port: $port, enabled: $enabled, hasAuth: ${authToken != null})';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -966,9 +966,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
devicePublicKey: devicePublicKey,
|
devicePublicKey: devicePublicKey,
|
||||||
);
|
);
|
||||||
unawaited(_pathHistoryService.recordLearnedPath(contact));
|
unawaited(_pathHistoryService.recordLearnedPath(contact));
|
||||||
|
|
||||||
// Broadcast to SSE clients if server is running
|
|
||||||
connectionProvider.broadcastContactToSseClients(contact);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// When all contacts are received
|
// When all contacts are received
|
||||||
@@ -982,11 +979,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
unawaited(_pathHistoryService.recordLearnedPath(contact));
|
unawaited(_pathHistoryService.recordLearnedPath(contact));
|
||||||
}
|
}
|
||||||
debugPrint('Received ${contacts.length} contacts');
|
debugPrint('Received ${contacts.length} contacts');
|
||||||
|
|
||||||
// Broadcast all contacts to SSE clients if server is running
|
|
||||||
for (final contact in contacts) {
|
|
||||||
connectionProvider.broadcastContactToSseClients(contact);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Setup callback for ConnectionProvider to query channel info
|
// Setup callback for ConnectionProvider to query channel info
|
||||||
@@ -1212,9 +1204,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
contactLocationSnapshot: contactLocationSnapshot,
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Broadcast drawing message to SSE clients if server is running
|
|
||||||
connectionProvider.broadcastMessageToSseClients(updatedMessage);
|
|
||||||
} else {
|
} else {
|
||||||
debugPrint('⚠️ [AppProvider] Failed to parse drawing message');
|
debugPrint('⚠️ [AppProvider] Failed to parse drawing message');
|
||||||
}
|
}
|
||||||
@@ -1255,7 +1244,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
contactLocationSnapshot: contactLocationSnapshot,
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
||||||
);
|
);
|
||||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1289,7 +1277,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
contactLocationSnapshot: contactLocationSnapshot,
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
||||||
);
|
);
|
||||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1314,9 +1301,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
contactLocationSnapshot: contactLocationSnapshot,
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Broadcast message to SSE clients if server is running
|
|
||||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep a compact receive-time snapshot because packet logs roll over.
|
// Keep a compact receive-time snapshot because packet logs roll over.
|
||||||
@@ -1353,7 +1337,8 @@ class AppProvider with ChangeNotifier {
|
|||||||
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
|
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
|
||||||
final parsedAdvert = _tryParseRawAdvert(payload);
|
final parsedAdvert = _tryParseRawAdvert(payload);
|
||||||
if (parsedAdvert != null) {
|
if (parsedAdvert != null) {
|
||||||
final isNewPendingAdvert = contactsProvider.addOrUpdatePendingAdvertMetadata(
|
final isNewPendingAdvert = contactsProvider
|
||||||
|
.addOrUpdatePendingAdvertMetadata(
|
||||||
publicKey: parsedAdvert.publicKey,
|
publicKey: parsedAdvert.publicKey,
|
||||||
typeValue: parsedAdvert.typeValue,
|
typeValue: parsedAdvert.typeValue,
|
||||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
@@ -1382,7 +1367,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
unawaited(_requestRepeaterStatus(parsedAdvert.publicKey));
|
unawaited(_requestRepeaterStatus(parsedAdvert.publicKey));
|
||||||
unawaited(_maybeRequestRepeaterOwnerInfo(parsedAdvert.publicKey));
|
unawaited(_maybeRequestRepeaterOwnerInfo(parsedAdvert.publicKey));
|
||||||
}
|
}
|
||||||
if (contactsProvider.shouldEnrichPendingAdvert(parsedAdvert.publicKey)) {
|
if (contactsProvider.shouldEnrichPendingAdvert(
|
||||||
|
parsedAdvert.publicKey,
|
||||||
|
)) {
|
||||||
unawaited(connectionProvider.previewContact(parsedAdvert.publicKey));
|
unawaited(connectionProvider.previewContact(parsedAdvert.publicKey));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1669,7 +1656,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
unawaited(
|
unawaited(
|
||||||
_notificationService.showContactDiscoveredNotification(
|
_notificationService.showContactDiscoveredNotification(
|
||||||
contactKey: keyHex,
|
contactKey: keyHex,
|
||||||
contactName: contactsProvider.pendingAdvertByKey(publicKey)?.advName,
|
contactName: contactsProvider
|
||||||
|
.pendingAdvertByKey(publicKey)
|
||||||
|
?.advName,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2248,10 +2237,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final payload = utf8.decode(
|
final payload = utf8
|
||||||
responseData.sublist(4),
|
.decode(responseData.sublist(4), allowMalformed: true)
|
||||||
allowMalformed: true,
|
.trim();
|
||||||
).trim();
|
|
||||||
if (payload.isEmpty) {
|
if (payload.isEmpty) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -2351,10 +2339,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
|
|
||||||
String? advName;
|
String? advName;
|
||||||
if (hasName && reader.remainingBytesCount > 0) {
|
if (hasName && reader.remainingBytesCount > 0) {
|
||||||
final decodedName = utf8.decode(
|
final decodedName = utf8
|
||||||
reader.readRemainingBytes(),
|
.decode(reader.readRemainingBytes(), allowMalformed: true)
|
||||||
allowMalformed: true,
|
.trim();
|
||||||
).trim();
|
|
||||||
if (decodedName.isNotEmpty) {
|
if (decodedName.isNotEmpty) {
|
||||||
advName = decodedName;
|
advName = decodedName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import 'package:crypto/crypto.dart';
|
|||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/device_info.dart';
|
import '../models/device_info.dart';
|
||||||
import '../models/room_login_state.dart';
|
import '../models/room_login_state.dart';
|
||||||
import '../models/sse_server_config.dart';
|
|
||||||
import 'package:meshcore_client/meshcore_client.dart' hide Contact;
|
import 'package:meshcore_client/meshcore_client.dart' hide Contact;
|
||||||
import '../services/sse_server_service.dart';
|
|
||||||
import '../utils/sar_message_parser.dart';
|
import '../utils/sar_message_parser.dart';
|
||||||
import 'helpers/room_login_manager.dart';
|
import 'helpers/room_login_manager.dart';
|
||||||
import 'helpers/message_delivery_tracker.dart';
|
import 'helpers/message_delivery_tracker.dart';
|
||||||
@@ -73,7 +71,6 @@ class _PendingContactRequest {
|
|||||||
class ConnectionProvider with ChangeNotifier {
|
class ConnectionProvider with ChangeNotifier {
|
||||||
static const int _controlTypeNodeDiscoverReq = 0x80;
|
static const int _controlTypeNodeDiscoverReq = 0x80;
|
||||||
final MeshCoreBleService _bleService = MeshCoreBleService();
|
final MeshCoreBleService _bleService = MeshCoreBleService();
|
||||||
final SseServerService _sseServer = SseServerService();
|
|
||||||
MeshCoreTcpService? _tcpService;
|
MeshCoreTcpService? _tcpService;
|
||||||
|
|
||||||
/// Expose BLE service for background location tracking
|
/// Expose BLE service for background location tracking
|
||||||
@@ -89,10 +86,6 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
ConnectionMode _connectionMode = ConnectionMode.ble;
|
ConnectionMode _connectionMode = ConnectionMode.ble;
|
||||||
ConnectionMode get connectionMode => _connectionMode;
|
ConnectionMode get connectionMode => _connectionMode;
|
||||||
|
|
||||||
/// SSE server configuration
|
|
||||||
SseServerConfig _sseServerConfig = const SseServerConfig();
|
|
||||||
SseServerConfig get sseServerConfig => _sseServerConfig;
|
|
||||||
|
|
||||||
/// TCP host last connected to (for display / reconnection info)
|
/// TCP host last connected to (for display / reconnection info)
|
||||||
String? _tcpHost;
|
String? _tcpHost;
|
||||||
String? get tcpHost => _tcpHost;
|
String? get tcpHost => _tcpHost;
|
||||||
@@ -471,11 +464,6 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
|
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
if (_sseServer.isRunning) {
|
|
||||||
_sseServer.setDeviceName(
|
|
||||||
_deviceInfo.deviceName ?? _deviceInfo.selfName,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
service.onSelfInfoReceived = (selfInfo) {
|
service.onSelfInfoReceived = (selfInfo) {
|
||||||
@@ -495,11 +483,6 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
selfName: selfInfo['selfName'] as String?,
|
selfName: selfInfo['selfName'] as String?,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
if (_sseServer.isRunning) {
|
|
||||||
_sseServer.setDeviceName(
|
|
||||||
_deviceInfo.deviceName ?? _deviceInfo.selfName,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
service.onBatteryAndStorage = (millivolts, usedKb, totalKb) {
|
service.onBatteryAndStorage = (millivolts, usedKb, totalKb) {
|
||||||
@@ -857,12 +840,19 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_error = null;
|
||||||
_markSingleContactRequested(
|
_markSingleContactRequested(
|
||||||
publicKey,
|
publicKey,
|
||||||
source: ContactReceiveSource.requestedSingle,
|
source: ContactReceiveSource.requestedSingle,
|
||||||
);
|
);
|
||||||
await _activeService.getContactByKey(publicKey);
|
await _activeService.getContactByKey(publicKey);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
final errorText = e.toString();
|
||||||
|
if (_error == 'Not found' || errorText.contains('Not found')) {
|
||||||
|
_error = 'Not found';
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
_error = 'Failed to get contact: $e';
|
_error = 'Failed to get contact: $e';
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [Provider] Failed to get contact by key, falling back to full contact sync',
|
'⚠️ [Provider] Failed to get contact by key, falling back to full contact sync',
|
||||||
@@ -1360,6 +1350,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_error = null;
|
||||||
await _activeService.addOrUpdateContact(contact);
|
await _activeService.addOrUpdateContact(contact);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Failed to add/update contact: $e';
|
_error = 'Failed to add/update contact: $e';
|
||||||
@@ -2578,110 +2569,6 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
return _roomLoginManager.isLoggedIntoRoom(publicKeyPrefix);
|
return _roomLoginManager.isLoggedIntoRoom(publicKeyPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SSE Server Methods
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Start SSE server to share BLE device with multiple clients
|
|
||||||
Future<void> startSseServer(SseServerConfig config) async {
|
|
||||||
if (_sseServer.isRunning) {
|
|
||||||
debugPrint('⚠️ [ConnectionProvider] SSE server already running');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
debugPrint('🚀 [ConnectionProvider] Starting SSE server...');
|
|
||||||
_sseServerConfig = config;
|
|
||||||
|
|
||||||
// Wire up callbacks
|
|
||||||
_sseServer.onSendMessage = (recipientPublicKey, text) async {
|
|
||||||
// Convert hex string to Uint8List
|
|
||||||
final bytes = <int>[];
|
|
||||||
for (int i = 0; i < recipientPublicKey.length; i += 2) {
|
|
||||||
bytes.add(
|
|
||||||
int.parse(recipientPublicKey.substring(i, i + 2), radix: 16),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return await sendTextMessage(
|
|
||||||
contactPublicKey: Uint8List.fromList(bytes),
|
|
||||||
text: text,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
_sseServer.onSendChannelMessage = (channelIdx, text) async {
|
|
||||||
await sendChannelMessage(channelIdx: channelIdx, text: text);
|
|
||||||
};
|
|
||||||
|
|
||||||
_sseServer.onSyncContacts = () async {
|
|
||||||
await getContacts();
|
|
||||||
};
|
|
||||||
|
|
||||||
await _sseServer.startServer(config);
|
|
||||||
|
|
||||||
// Set initial device name
|
|
||||||
_sseServer.setDeviceName(_deviceInfo.deviceName ?? _deviceInfo.selfName);
|
|
||||||
|
|
||||||
_connectionMode = ConnectionMode.sseServer;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
debugPrint('✅ [ConnectionProvider] SSE server started');
|
|
||||||
} catch (e) {
|
|
||||||
_error = 'Failed to start SSE server: $e';
|
|
||||||
debugPrint('❌ [ConnectionProvider] Failed to start SSE server: $e');
|
|
||||||
notifyListeners();
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop SSE server
|
|
||||||
Future<void> stopSseServer() async {
|
|
||||||
if (!_sseServer.isRunning) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('🛑 [ConnectionProvider] Stopping SSE server...');
|
|
||||||
await _sseServer.stopServer();
|
|
||||||
|
|
||||||
if (_connectionMode == ConnectionMode.sseServer) {
|
|
||||||
_connectionMode = ConnectionMode.ble;
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyListeners();
|
|
||||||
debugPrint('✅ [ConnectionProvider] SSE server stopped');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Broadcast message to SSE clients (call this when receiving messages from BLE)
|
|
||||||
void broadcastMessageToSseClients(Message message) {
|
|
||||||
if (_sseServer.isRunning) {
|
|
||||||
_sseServer.broadcastMessage(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Broadcast contact to SSE clients (call this when receiving contacts from BLE)
|
|
||||||
void broadcastContactToSseClients(Contact contact) {
|
|
||||||
if (_sseServer.isRunning) {
|
|
||||||
_sseServer.broadcastContact(contact);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get SSE server status
|
|
||||||
bool get isSseServerRunning => _sseServer.isRunning;
|
|
||||||
|
|
||||||
/// Get number of connected SSE clients
|
|
||||||
int get sseClientCount => _sseServer.connectedClients;
|
|
||||||
|
|
||||||
/// Set connection mode
|
|
||||||
void setConnectionMode(ConnectionMode mode) {
|
|
||||||
_connectionMode = mode;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update SSE server configuration
|
|
||||||
void updateSseServerConfig(SseServerConfig config) {
|
|
||||||
_sseServerConfig = config;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_rxActivityTimer?.cancel();
|
_rxActivityTimer?.cancel();
|
||||||
@@ -2689,7 +2576,6 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
_stopAckCleanupTimer();
|
_stopAckCleanupTimer();
|
||||||
_bleService.dispose();
|
_bleService.dispose();
|
||||||
_tcpService?.dispose();
|
_tcpService?.dispose();
|
||||||
_sseServer.stopServer();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,15 +125,29 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
|
||||||
|
final canAddDirectly = _canAddPendingAdvertDirectly(advert);
|
||||||
|
if (canAddDirectly) {
|
||||||
|
final added = await _addPendingAdvertToRadio(
|
||||||
|
advert,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
);
|
||||||
|
if (!added) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
await connectionProvider.getContact(advert.publicKey);
|
await connectionProvider.getContact(advert.publicKey);
|
||||||
if (connectionProvider.error == 'Not found') {
|
if (connectionProvider.error == 'Not found') {
|
||||||
connectionProvider.clearError();
|
connectionProvider.clearError();
|
||||||
final fallbackContact = _contactFromPendingAdvert(advert);
|
final added = await _addPendingAdvertToRadio(
|
||||||
await connectionProvider.addOrUpdateContact(fallbackContact);
|
advert,
|
||||||
contactsProvider.addOrUpdateContact(
|
connectionProvider: connectionProvider,
|
||||||
fallbackContact,
|
contactsProvider: contactsProvider,
|
||||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
|
||||||
);
|
);
|
||||||
|
if (!added) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ((advert.typeValue ?? 0) == _sensorAdvertType) {
|
if ((advert.typeValue ?? 0) == _sensorAdvertType) {
|
||||||
await connectionProvider.requestTelemetry(advert.publicKey);
|
await connectionProvider.requestTelemetry(advert.publicKey);
|
||||||
@@ -200,6 +214,37 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _canAddPendingAdvertDirectly(PendingAdvert advert) {
|
||||||
|
return advert.publicKey.length == 32 &&
|
||||||
|
advert.typeValue != null &&
|
||||||
|
advert.typeValue != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _addPendingAdvertToRadio(
|
||||||
|
PendingAdvert advert, {
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
}) async {
|
||||||
|
final contact = _contactFromPendingAdvert(advert);
|
||||||
|
await connectionProvider.addOrUpdateContact(contact);
|
||||||
|
|
||||||
|
final addError = connectionProvider.error;
|
||||||
|
if (addError != null) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to add contact: $addError')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
contactsProvider.addOrUpdateContact(
|
||||||
|
contact,
|
||||||
|
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
String _displayNameForAdvert(
|
String _displayNameForAdvert(
|
||||||
PendingAdvert advert,
|
PendingAdvert advert,
|
||||||
ContactsProvider contactsProvider,
|
ContactsProvider contactsProvider,
|
||||||
@@ -375,8 +420,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
),
|
),
|
||||||
body: FutureBuilder<List<MeshMapNode>>(
|
body: FutureBuilder<List<MeshMapNode>>(
|
||||||
future: _cachedNodesFuture,
|
future: _cachedNodesFuture,
|
||||||
builder: (context, nodesSnapshot) =>
|
builder: (context, nodesSnapshot) => Consumer2<ContactsProvider, ConnectionProvider>(
|
||||||
Consumer2<ContactsProvider, ConnectionProvider>(
|
|
||||||
builder: (context, contactsProvider, connectionProvider, child) {
|
builder: (context, contactsProvider, connectionProvider, child) {
|
||||||
final pendingAdverts = contactsProvider.pendingAdverts;
|
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||||
final isConnected = connectionProvider.deviceInfo.isConnected;
|
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||||
@@ -414,7 +458,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: isConnected &&
|
onPressed:
|
||||||
|
isConnected &&
|
||||||
pendingAdverts.isNotEmpty &&
|
pendingAdverts.isNotEmpty &&
|
||||||
!_isResolvingAll
|
!_isResolvingAll
|
||||||
? () => _resolveAll(pendingAdverts)
|
? () => _resolveAll(pendingAdverts)
|
||||||
@@ -522,9 +567,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(child: Icon(_iconForAdvert(advert))),
|
||||||
child: Icon(_iconForAdvert(advert)),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildAdvertTitle(
|
child: _buildAdvertTitle(
|
||||||
@@ -552,9 +595,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
)
|
)
|
||||||
: IconButton(
|
: IconButton(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
icon: const Icon(
|
icon: const Icon(Icons.person_add_alt_1),
|
||||||
Icons.person_add_alt_1,
|
|
||||||
),
|
|
||||||
tooltip: 'Resolve contact',
|
tooltip: 'Resolve contact',
|
||||||
onPressed: isConnected
|
onPressed: isConnected
|
||||||
? () => _resolveAdvert(advert)
|
? () => _resolveAdvert(advert)
|
||||||
@@ -629,9 +670,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
metric.valueLabel,
|
metric.valueLabel,
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.w700,
|
context,
|
||||||
),
|
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import '../utils/image_message_parser.dart';
|
|||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../widgets/connection_mode_selector.dart';
|
|
||||||
import '../widgets/update_dialog.dart';
|
import '../widgets/update_dialog.dart';
|
||||||
import 'sar_template_management_screen.dart';
|
import 'sar_template_management_screen.dart';
|
||||||
import 'welcome_wizard_screen.dart';
|
import 'welcome_wizard_screen.dart';
|
||||||
@@ -1053,7 +1052,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
await NotificationService().setSarNotificationsEnabled(value);
|
await NotificationService().setSarNotificationsEnabled(value);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
SwitchListTile(
|
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
secondary: const Icon(Icons.contact_page_outlined),
|
secondary: const Icon(Icons.contact_page_outlined),
|
||||||
title: const Text('Discovery notifications'),
|
title: const Text('Discovery notifications'),
|
||||||
@@ -1070,6 +1068,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
SwitchListTile(
|
||||||
secondary: const Icon(Icons.system_update),
|
secondary: const Icon(Icons.system_update),
|
||||||
title: const Text('Update notifications'),
|
title: const Text('Update notifications'),
|
||||||
subtitle: const Text(
|
subtitle: const Text(
|
||||||
@@ -1554,11 +1553,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
if (!kIsWeb) ...[
|
|
||||||
_buildSectionHeader('Network Sharing'),
|
|
||||||
const ConnectionModeSelector(),
|
|
||||||
],
|
|
||||||
|
|
||||||
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
|
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
|
||||||
_buildSettingsCard([
|
_buildSettingsCard([
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ class MapMarkerService {
|
|||||||
double mapRotation = 0,
|
double mapRotation = 0,
|
||||||
Position? userPosition,
|
Position? userPosition,
|
||||||
}) {
|
}) {
|
||||||
return contacts.map((contact) {
|
return contacts
|
||||||
|
.map((contact) {
|
||||||
final location = contact.displayLocation;
|
final location = contact.displayLocation;
|
||||||
if (location == null) return null;
|
if (location == null) return null;
|
||||||
|
|
||||||
@@ -60,7 +61,10 @@ class MapMarkerService {
|
|||||||
children: [
|
children: [
|
||||||
// Location update time indicator
|
// Location update time indicator
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: getLocationAgeColor(contact),
|
color: getLocationAgeColor(contact),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
@@ -79,11 +83,13 @@ class MapMarkerService {
|
|||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
shape: contact.type == ContactType.channel ||
|
shape:
|
||||||
|
contact.type == ContactType.channel ||
|
||||||
contact.type == ContactType.room
|
contact.type == ContactType.room
|
||||||
? BoxShape.rectangle
|
? BoxShape.rectangle
|
||||||
: BoxShape.circle,
|
: BoxShape.circle,
|
||||||
borderRadius: contact.type == ContactType.channel ||
|
borderRadius:
|
||||||
|
contact.type == ContactType.channel ||
|
||||||
contact.type == ContactType.room
|
contact.type == ContactType.room
|
||||||
? BorderRadius.circular(14)
|
? BorderRadius.circular(14)
|
||||||
: null,
|
: null,
|
||||||
@@ -102,7 +108,10 @@ class MapMarkerService {
|
|||||||
// Name label (without emoji)
|
// Name label (without emoji)
|
||||||
Container(
|
Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 80),
|
constraints: const BoxConstraints(maxWidth: 80),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
@@ -124,7 +133,9 @@ class MapMarkerService {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).whereType<Marker>().toList();
|
})
|
||||||
|
.whereType<Marker>()
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate markers for SAR events.
|
/// Generate markers for SAR events.
|
||||||
@@ -157,7 +168,10 @@ class MapMarkerService {
|
|||||||
children: [
|
children: [
|
||||||
// Time ago label
|
// Time ago label
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: getSarMarkerColor(marker.type),
|
color: getSarMarkerColor(marker.type),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
@@ -196,13 +210,17 @@ class MapMarkerService {
|
|||||||
// Type label
|
// Type label
|
||||||
Container(
|
Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 90),
|
constraints: const BoxConstraints(maxWidth: 90),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
marker.displayName, // Uses notes if available, otherwise type.displayName
|
marker
|
||||||
|
.displayName, // Uses notes if available, otherwise type.displayName
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
@@ -269,7 +287,8 @@ class MapMarkerService {
|
|||||||
final dLat = (lat2 - lat1) * pi / 180;
|
final dLat = (lat2 - lat1) * pi / 180;
|
||||||
final dLon = (lon2 - lon1) * pi / 180;
|
final dLon = (lon2 - lon1) * pi / 180;
|
||||||
|
|
||||||
final a = sin(dLat / 2) * sin(dLat / 2) +
|
final a =
|
||||||
|
sin(dLat / 2) * sin(dLat / 2) +
|
||||||
cos(lat1 * pi / 180) *
|
cos(lat1 * pi / 180) *
|
||||||
cos(lat2 * pi / 180) *
|
cos(lat2 * pi / 180) *
|
||||||
sin(dLon / 2) *
|
sin(dLon / 2) *
|
||||||
@@ -299,8 +318,8 @@ class MapMarkerService {
|
|||||||
final lat2Rad = lat2 * pi / 180;
|
final lat2Rad = lat2 * pi / 180;
|
||||||
|
|
||||||
final y = sin(dLon) * cos(lat2Rad);
|
final y = sin(dLon) * cos(lat2Rad);
|
||||||
final x = cos(lat1Rad) * sin(lat2Rad) -
|
final x =
|
||||||
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
||||||
|
|
||||||
final bearing = atan2(y, x) * 180 / pi;
|
final bearing = atan2(y, x) * 180 / pi;
|
||||||
return (bearing + 360) % 360;
|
return (bearing + 360) % 360;
|
||||||
@@ -368,6 +387,8 @@ class MapMarkerService {
|
|||||||
return Colors.deepPurple; // Purple for repeaters
|
return Colors.deepPurple; // Purple for repeaters
|
||||||
case ContactType.room:
|
case ContactType.room:
|
||||||
return Colors.teal; // Teal for rooms
|
return Colors.teal; // Teal for rooms
|
||||||
|
case ContactType.sensor:
|
||||||
|
return Colors.green; // Green for sensors
|
||||||
case ContactType.channel:
|
case ContactType.channel:
|
||||||
return Colors.orange; // Orange for channels
|
return Colors.orange; // Orange for channels
|
||||||
case ContactType.none:
|
case ContactType.none:
|
||||||
@@ -389,6 +410,8 @@ class MapMarkerService {
|
|||||||
return Icons.router; // Router icon for repeaters
|
return Icons.router; // Router icon for repeaters
|
||||||
case ContactType.room:
|
case ContactType.room:
|
||||||
return Icons.forum; // Forum/chat icon for rooms
|
return Icons.forum; // Forum/chat icon for rooms
|
||||||
|
case ContactType.sensor:
|
||||||
|
return Icons.sensors; // Sensors icon for sensor nodes
|
||||||
case ContactType.channel:
|
case ContactType.channel:
|
||||||
return Icons.public; // Public icon for channels
|
return Icons.public; // Public icon for channels
|
||||||
case ContactType.none:
|
case ContactType.none:
|
||||||
@@ -466,7 +489,8 @@ class MapMarkerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (allPoints.isEmpty) {
|
if (allPoints.isEmpty) {
|
||||||
return defaultCenter ?? const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
|
return defaultCenter ??
|
||||||
|
const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
|
||||||
}
|
}
|
||||||
|
|
||||||
double lat = 0, lng = 0;
|
double lat = 0, lng = 0;
|
||||||
|
|||||||
@@ -1,785 +0,0 @@
|
|||||||
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<StreamController<String>> _messageStreams = {};
|
|
||||||
|
|
||||||
/// Active SSE connections for contacts
|
|
||||||
final Set<StreamController<String>> _contactStreams = {};
|
|
||||||
|
|
||||||
/// Message history (for new clients)
|
|
||||||
final List<Message> _messageHistory = [];
|
|
||||||
|
|
||||||
/// Contact list (for new clients)
|
|
||||||
final Map<String, Contact> _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<bool> Function(String recipientPublicKey, String text)? onSendMessage;
|
|
||||||
|
|
||||||
/// Callback for when a client requests to send a channel message
|
|
||||||
Future<void> Function(int channelIdx, String text)? onSendChannelMessage;
|
|
||||||
|
|
||||||
/// Callback for when a client requests contact sync
|
|
||||||
Future<void> 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<void> 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<void> _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<void> 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<shelf.Response> _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<String>();
|
|
||||||
_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<String>();
|
|
||||||
_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<shelf.Response> _handlePostMessage(shelf.Request request) async {
|
|
||||||
try {
|
|
||||||
final body = await request.readAsString();
|
|
||||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
|
||||||
|
|
||||||
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<shelf.Response> _handlePostChannelMessage(
|
|
||||||
shelf.Request request,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
final body = await request.readAsString();
|
|
||||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
|
||||||
|
|
||||||
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<shelf.Response> _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 = '''
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>MeshCore SAR - SSE Server</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<style>
|
|
||||||
body { font-family: sans-serif; margin: 40px; background: #f5f5f5; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
|
||||||
h1 { color: #333; }
|
|
||||||
.status { background: #4CAF50; color: white; padding: 10px; border-radius: 4px; margin: 20px 0; }
|
|
||||||
.endpoint { background: #f9f9f9; padding: 10px; margin: 10px 0; border-left: 3px solid #2196F3; font-family: monospace; }
|
|
||||||
code { background: #eee; padding: 2px 6px; border-radius: 3px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>🚀 MeshCore SAR Server</h1>
|
|
||||||
<div class="status">✅ Server is running</div>
|
|
||||||
<p>This server enables multiple MeshCore SAR clients to share a single BLE device.</p>
|
|
||||||
|
|
||||||
<h2>📡 SSE Endpoints</h2>
|
|
||||||
<div class="endpoint">GET /sse/messages</div>
|
|
||||||
<div class="endpoint">GET /sse/contacts</div>
|
|
||||||
|
|
||||||
<h2>🔧 API Endpoints</h2>
|
|
||||||
<div class="endpoint">POST /api/messages</div>
|
|
||||||
<div class="endpoint">POST /api/messages/channel</div>
|
|
||||||
<div class="endpoint">POST /api/contacts/sync</div>
|
|
||||||
<div class="endpoint">GET /api/messages/history</div>
|
|
||||||
<div class="endpoint">GET /api/contacts</div>
|
|
||||||
<div class="endpoint">GET /api/status</div>
|
|
||||||
|
|
||||||
<h2>📊 Stats</h2>
|
|
||||||
<p>Connected clients: <strong id="clients">Loading...</strong></p>
|
|
||||||
<p>Messages: <strong id="messages">Loading...</strong></p>
|
|
||||||
<p>Contacts: <strong id="contacts">Loading...</strong></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
async function updateStats() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/status');
|
|
||||||
const data = await res.json();
|
|
||||||
document.getElementById('clients').textContent = data.connectedClients;
|
|
||||||
document.getElementById('messages').textContent = data.messageCount;
|
|
||||||
document.getElementById('contacts').textContent = data.contactCount;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch stats:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateStats();
|
|
||||||
setInterval(updateStats, 5000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
''';
|
|
||||||
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 = <StreamController<String>>[];
|
|
||||||
|
|
||||||
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 = <StreamController<String>>[];
|
|
||||||
|
|
||||||
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<String, dynamic> data) {
|
|
||||||
final jsonData = jsonEncode(data);
|
|
||||||
return 'event: $eventType\ndata: $jsonData\n\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert Message to JSON
|
|
||||||
Map<String, dynamic> _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(),
|
|
||||||
'lastEchoSnrRaw': message.lastEchoSnrRaw,
|
|
||||||
'lastEchoRssiDbm': message.lastEchoRssiDbm,
|
|
||||||
'lastEchoAt': message.lastEchoAt?.toIso8601String(),
|
|
||||||
'isDrawing': message.isDrawing,
|
|
||||||
'drawingId': message.drawingId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert Contact to JSON
|
|
||||||
Map<String, dynamic> _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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import '../providers/connection_provider.dart';
|
|
||||||
import '../models/sse_server_config.dart';
|
|
||||||
|
|
||||||
/// Connection Mode Selector Widget
|
|
||||||
///
|
|
||||||
/// Allows user to enable/disable SSE Server mode to share device with multiple clients
|
|
||||||
class ConnectionModeSelector extends StatefulWidget {
|
|
||||||
const ConnectionModeSelector({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ConnectionModeSelector> createState() => _ConnectionModeSelectorState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ConnectionModeSelectorState extends State<ConnectionModeSelector> {
|
|
||||||
List<String> _localIPs = [];
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_loadLocalIPs();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadLocalIPs() async {
|
|
||||||
if (kIsWeb) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final Set<String> ipsSet = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
final interfaces = await NetworkInterface.list();
|
|
||||||
for (final interface in interfaces) {
|
|
||||||
for (final addr in interface.addresses) {
|
|
||||||
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
|
|
||||||
ipsSet.add(addr.address);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error getting network interfaces: $e');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_localIPs = ipsSet.toList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final connectionProvider = Provider.of<ConnectionProvider>(context);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Section Header
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
||||||
child: Text(
|
|
||||||
'Network Sharing',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// SSE Server Toggle
|
|
||||||
SwitchListTile(
|
|
||||||
secondary: const Icon(Icons.share),
|
|
||||||
title: const Text('Share Device (Server)'),
|
|
||||||
subtitle: Text(
|
|
||||||
connectionProvider.isSseServerRunning
|
|
||||||
? 'Server running on port ${connectionProvider.sseServerConfig.port} - ${connectionProvider.sseClientCount} client(s) connected'
|
|
||||||
: 'Share BLE device with multiple clients over network',
|
|
||||||
),
|
|
||||||
value: connectionProvider.isSseServerRunning,
|
|
||||||
onChanged: (enabled) async {
|
|
||||||
if (enabled) {
|
|
||||||
// Start server with default config (port 12929, no auth)
|
|
||||||
final config = const SseServerConfig(port: 12929, enabled: true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connectionProvider.startSseServer(config);
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('SSE server started on port 12929'),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Failed to start server: $e'),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Stop server
|
|
||||||
await connectionProvider.stopSseServer();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
// Show IP addresses when server is running
|
|
||||||
if (connectionProvider.isSseServerRunning && _localIPs.isNotEmpty) ...[
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
||||||
child: Text(
|
|
||||||
'Connect from other devices:',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
..._localIPs.map((ip) {
|
|
||||||
final url = 'http://$ip:${connectionProvider.sseServerConfig.port}';
|
|
||||||
return ListTile(
|
|
||||||
dense: true,
|
|
||||||
leading: const Icon(Icons.wifi, size: 20),
|
|
||||||
title: Text(
|
|
||||||
url,
|
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
|
||||||
),
|
|
||||||
trailing: IconButton(
|
|
||||||
icon: const Icon(Icons.copy, size: 20),
|
|
||||||
tooltip: 'Copy URL',
|
|
||||||
onPressed: () {
|
|
||||||
Clipboard.setData(ClipboardData(text: url));
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Copied $url'),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -147,6 +147,10 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
|
|||||||
try {
|
try {
|
||||||
// Manually add the room contact to the radio's flash storage
|
// Manually add the room contact to the radio's flash storage
|
||||||
await connectionProvider.addOrUpdateContact(widget.contact);
|
await connectionProvider.addOrUpdateContact(widget.contact);
|
||||||
|
final addError = connectionProvider.error;
|
||||||
|
if (addError != null) {
|
||||||
|
throw Exception(addError);
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT',
|
'✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT',
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class CompassContactList extends StatelessWidget {
|
|||||||
// Split contacts by type
|
// Split contacts by type
|
||||||
final persons = <Map<String, dynamic>>[];
|
final persons = <Map<String, dynamic>>[];
|
||||||
final repeaters = <Map<String, dynamic>>[];
|
final repeaters = <Map<String, dynamic>>[];
|
||||||
|
final sensors = <Map<String, dynamic>>[];
|
||||||
final rooms = <Map<String, dynamic>>[];
|
final rooms = <Map<String, dynamic>>[];
|
||||||
|
|
||||||
// Calculate bearings and distances for each contact
|
// Calculate bearings and distances for each contact
|
||||||
@@ -70,6 +71,8 @@ class CompassContactList extends StatelessWidget {
|
|||||||
|
|
||||||
if (contact.isRepeater) {
|
if (contact.isRepeater) {
|
||||||
repeaters.add(item);
|
repeaters.add(item);
|
||||||
|
} else if (contact.isSensor) {
|
||||||
|
sensors.add(item);
|
||||||
} else if (contact.isRoom) {
|
} else if (contact.isRoom) {
|
||||||
rooms.add(item);
|
rooms.add(item);
|
||||||
} else {
|
} else {
|
||||||
@@ -78,9 +81,18 @@ class CompassContactList extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort each list by distance
|
// Sort each list by distance
|
||||||
persons.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
|
persons.sort(
|
||||||
repeaters.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
|
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
|
||||||
rooms.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
|
);
|
||||||
|
repeaters.sort(
|
||||||
|
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
|
||||||
|
);
|
||||||
|
sensors.sort(
|
||||||
|
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
|
||||||
|
);
|
||||||
|
rooms.sort(
|
||||||
|
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
|
||||||
|
);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -91,17 +103,34 @@ class CompassContactList extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 4),
|
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.teamMembers,
|
l10n.teamMembers,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
...persons.map(
|
||||||
...persons.map((item) => _buildContactTile(
|
(item) => _buildContactTile(
|
||||||
context,
|
context,
|
||||||
item,
|
item,
|
||||||
Icons.groups,
|
Icons.groups,
|
||||||
Theme.of(context).colorScheme.primary,
|
Theme.of(context).colorScheme.primary,
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (showContacts && sensors.isNotEmpty) ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
|
||||||
|
child: Text(
|
||||||
|
'Sensors',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...sensors.map(
|
||||||
|
(item) =>
|
||||||
|
_buildContactTile(context, item, Icons.sensors, Colors.green),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
// Repeaters section
|
// Repeaters section
|
||||||
if (showRepeaters && repeaters.isNotEmpty) ...[
|
if (showRepeaters && repeaters.isNotEmpty) ...[
|
||||||
@@ -109,17 +138,15 @@ class CompassContactList extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
|
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.repeaters,
|
l10n.repeaters,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
...repeaters.map((item) => _buildContactTile(
|
|
||||||
context,
|
context,
|
||||||
item,
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
Icons.router,
|
),
|
||||||
Colors.purple,
|
),
|
||||||
)),
|
...repeaters.map(
|
||||||
|
(item) =>
|
||||||
|
_buildContactTile(context, item, Icons.router, Colors.purple),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
// Rooms section
|
// Rooms section
|
||||||
if (rooms.isNotEmpty) ...[
|
if (rooms.isNotEmpty) ...[
|
||||||
@@ -127,17 +154,19 @@ class CompassContactList extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
|
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.rooms,
|
l10n.rooms,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
...rooms.map(
|
||||||
...rooms.map((item) => _buildContactTile(
|
(item) => _buildContactTile(
|
||||||
context,
|
context,
|
||||||
item,
|
item,
|
||||||
Icons.meeting_room,
|
Icons.meeting_room,
|
||||||
Colors.teal,
|
Colors.teal,
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -161,24 +190,14 @@ class CompassContactList extends StatelessWidget {
|
|||||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: selectedContact == contact
|
border: selectedContact == contact
|
||||||
? Border.all(
|
? Border.all(color: Theme.of(context).colorScheme.primary, width: 2)
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
width: 2,
|
|
||||||
)
|
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
leading: contact.roleEmoji != null
|
leading: contact.roleEmoji != null
|
||||||
? Text(
|
? Text(contact.roleEmoji!, style: const TextStyle(fontSize: 24))
|
||||||
contact.roleEmoji!,
|
: Icon(defaultIcon, color: iconColor, size: 24),
|
||||||
style: const TextStyle(fontSize: 24),
|
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
defaultIcon,
|
|
||||||
color: iconColor,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
title: Text(contact.displayName),
|
title: Text(contact.displayName),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'${_bearingToCardinal(bearing)} • ${_formatDistance(distance)}',
|
'${_bearingToCardinal(bearing)} • ${_formatDistance(distance)}',
|
||||||
@@ -190,16 +209,16 @@ class CompassContactList extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${bearing.round()}°',
|
'${bearing.round()}°',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
),
|
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
if (heading != null)
|
if (heading != null)
|
||||||
Text(
|
Text(
|
||||||
_formatRelativeBearing(bearing, heading!, context),
|
_formatRelativeBearing(bearing, heading!, context),
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(
|
||||||
color: Colors.grey,
|
context,
|
||||||
),
|
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -217,15 +236,14 @@ class CompassContactList extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate bearing between two points (in degrees)
|
// Calculate bearing between two points (in degrees)
|
||||||
double _calculateBearing(
|
double _calculateBearing(double lat1, double lon1, double lat2, double lon2) {
|
||||||
double lat1, double lon1, double lat2, double lon2) {
|
|
||||||
final dLon = (lon2 - lon1) * pi / 180;
|
final dLon = (lon2 - lon1) * pi / 180;
|
||||||
final lat1Rad = lat1 * pi / 180;
|
final lat1Rad = lat1 * pi / 180;
|
||||||
final lat2Rad = lat2 * pi / 180;
|
final lat2Rad = lat2 * pi / 180;
|
||||||
|
|
||||||
final y = sin(dLon) * cos(lat2Rad);
|
final y = sin(dLon) * cos(lat2Rad);
|
||||||
final x = cos(lat1Rad) * sin(lat2Rad) -
|
final x =
|
||||||
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
||||||
|
|
||||||
final bearing = atan2(y, x) * 180 / pi;
|
final bearing = atan2(y, x) * 180 / pi;
|
||||||
return (bearing + 360) % 360;
|
return (bearing + 360) % 360;
|
||||||
@@ -233,12 +251,17 @@ class CompassContactList extends StatelessWidget {
|
|||||||
|
|
||||||
// Calculate distance between two points (in meters)
|
// Calculate distance between two points (in meters)
|
||||||
double _calculateDistance(
|
double _calculateDistance(
|
||||||
double lat1, double lon1, double lat2, double lon2) {
|
double lat1,
|
||||||
|
double lon1,
|
||||||
|
double lat2,
|
||||||
|
double lon2,
|
||||||
|
) {
|
||||||
const R = 6371000; // Earth's radius in meters
|
const R = 6371000; // Earth's radius in meters
|
||||||
final dLat = (lat2 - lat1) * pi / 180;
|
final dLat = (lat2 - lat1) * pi / 180;
|
||||||
final dLon = (lon2 - lon1) * pi / 180;
|
final dLon = (lon2 - lon1) * pi / 180;
|
||||||
|
|
||||||
final a = sin(dLat / 2) * sin(dLat / 2) +
|
final a =
|
||||||
|
sin(dLat / 2) * sin(dLat / 2) +
|
||||||
cos(lat1 * pi / 180) *
|
cos(lat1 * pi / 180) *
|
||||||
cos(lat2 * pi / 180) *
|
cos(lat2 * pi / 180) *
|
||||||
sin(dLon / 2) *
|
sin(dLon / 2) *
|
||||||
@@ -262,7 +285,11 @@ class CompassContactList extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatRelativeBearing(double bearing, double heading, BuildContext context) {
|
String _formatRelativeBearing(
|
||||||
|
double bearing,
|
||||||
|
double heading,
|
||||||
|
BuildContext context,
|
||||||
|
) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
// Calculate relative bearing (how much to turn from current heading)
|
// Calculate relative bearing (how much to turn from current heading)
|
||||||
double relative = bearing - heading;
|
double relative = bearing - heading;
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ class MapMarkers {
|
|||||||
Function(Contact)? onContactTap,
|
Function(Contact)? onContactTap,
|
||||||
double mapRotation = 0,
|
double mapRotation = 0,
|
||||||
}) {
|
}) {
|
||||||
return contacts.map((contact) {
|
return contacts
|
||||||
|
.map((contact) {
|
||||||
final location = contact.displayLocation;
|
final location = contact.displayLocation;
|
||||||
if (location == null) return null;
|
if (location == null) return null;
|
||||||
|
|
||||||
@@ -36,7 +37,10 @@ class MapMarkers {
|
|||||||
children: [
|
children: [
|
||||||
// Location update time indicator
|
// Location update time indicator
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _getLocationAgeColor(contact),
|
color: _getLocationAgeColor(contact),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
@@ -81,7 +85,10 @@ class MapMarkers {
|
|||||||
// Name label (without emoji)
|
// Name label (without emoji)
|
||||||
Container(
|
Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 80),
|
constraints: const BoxConstraints(maxWidth: 80),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
@@ -103,7 +110,9 @@ class MapMarkers {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).whereType<Marker>().toList();
|
})
|
||||||
|
.whereType<Marker>()
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<Marker> createSarMarkers(
|
static List<Marker> createSarMarkers(
|
||||||
@@ -133,7 +142,10 @@ class MapMarkers {
|
|||||||
children: [
|
children: [
|
||||||
// Time ago label
|
// Time ago label
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _getSarMarkerColor(marker),
|
color: _getSarMarkerColor(marker),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
@@ -172,7 +184,10 @@ class MapMarkers {
|
|||||||
// Type label
|
// Type label
|
||||||
Container(
|
Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 90),
|
constraints: const BoxConstraints(maxWidth: 90),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
@@ -183,8 +198,12 @@ class MapMarkers {
|
|||||||
debugPrint('🗺️ [MapMarker] Displaying SAR marker:');
|
debugPrint('🗺️ [MapMarker] Displaying SAR marker:');
|
||||||
debugPrint(' marker.notes: "${marker.notes}"');
|
debugPrint(' marker.notes: "${marker.notes}"');
|
||||||
debugPrint(' marker.type: ${marker.type}');
|
debugPrint(' marker.type: ${marker.type}');
|
||||||
debugPrint(' marker.type.displayName: ${marker.type.displayName}');
|
debugPrint(
|
||||||
debugPrint(' marker.displayName: ${marker.displayName}');
|
' marker.type.displayName: ${marker.type.displayName}',
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
' marker.displayName: ${marker.displayName}',
|
||||||
|
);
|
||||||
|
|
||||||
return Text(
|
return Text(
|
||||||
marker.displayName,
|
marker.displayName,
|
||||||
@@ -247,11 +266,19 @@ class MapMarkers {
|
|||||||
_InfoRow('Battery', '${contact.displayBattery!.round()}%'),
|
_InfoRow('Battery', '${contact.displayBattery!.round()}%'),
|
||||||
if (contact.telemetry?.temperature != null)
|
if (contact.telemetry?.temperature != null)
|
||||||
_InfoRow(
|
_InfoRow(
|
||||||
'Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'),
|
'Temperature',
|
||||||
|
'${contact.telemetry!.temperature!.toStringAsFixed(1)}°C',
|
||||||
|
),
|
||||||
if (contact.telemetry?.humidity != null)
|
if (contact.telemetry?.humidity != null)
|
||||||
_InfoRow('Humidity', '${contact.telemetry!.humidity!.toStringAsFixed(1)}%'),
|
_InfoRow(
|
||||||
|
'Humidity',
|
||||||
|
'${contact.telemetry!.humidity!.toStringAsFixed(1)}%',
|
||||||
|
),
|
||||||
if (contact.telemetry?.pressure != null)
|
if (contact.telemetry?.pressure != null)
|
||||||
_InfoRow('Pressure', '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa'),
|
_InfoRow(
|
||||||
|
'Pressure',
|
||||||
|
'${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa',
|
||||||
|
),
|
||||||
_InfoRow('Last Seen', contact.timeSinceLastSeen),
|
_InfoRow('Last Seen', contact.timeSinceLastSeen),
|
||||||
_InfoRow('Public Key', contact.publicKeyShort),
|
_InfoRow('Public Key', contact.publicKeyShort),
|
||||||
],
|
],
|
||||||
@@ -272,7 +299,10 @@ class MapMarkers {
|
|||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(marker.emoji, style: const TextStyle(fontSize: 24)), // Use custom emoji if available
|
Text(
|
||||||
|
marker.emoji,
|
||||||
|
style: const TextStyle(fontSize: 24),
|
||||||
|
), // Use custom emoji if available
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(child: Text(marker.displayName)),
|
Expanded(child: Text(marker.displayName)),
|
||||||
],
|
],
|
||||||
@@ -313,7 +343,9 @@ class MapMarkers {
|
|||||||
|
|
||||||
static Color _getSarMarkerColor(SarMarker marker) {
|
static Color _getSarMarkerColor(SarMarker marker) {
|
||||||
// If marker has a color index, use it (new format)
|
// If marker has a color index, use it (new format)
|
||||||
if (marker.colorIndex != null && marker.colorIndex! >= 0 && marker.colorIndex! < 8) {
|
if (marker.colorIndex != null &&
|
||||||
|
marker.colorIndex! >= 0 &&
|
||||||
|
marker.colorIndex! < 8) {
|
||||||
final colorHex = SarTemplate.getColorFromIndex(marker.colorIndex!);
|
final colorHex = SarTemplate.getColorFromIndex(marker.colorIndex!);
|
||||||
final hexCode = colorHex.replaceAll('#', '');
|
final hexCode = colorHex.replaceAll('#', '');
|
||||||
return Color(int.parse('FF$hexCode', radix: 16));
|
return Color(int.parse('FF$hexCode', radix: 16));
|
||||||
@@ -342,6 +374,8 @@ class MapMarkers {
|
|||||||
return Colors.deepPurple; // Purple for repeaters
|
return Colors.deepPurple; // Purple for repeaters
|
||||||
case ContactType.room:
|
case ContactType.room:
|
||||||
return Colors.teal; // Teal for rooms
|
return Colors.teal; // Teal for rooms
|
||||||
|
case ContactType.sensor:
|
||||||
|
return Colors.green; // Green for sensors
|
||||||
case ContactType.channel:
|
case ContactType.channel:
|
||||||
return Colors.orange; // Orange for channels
|
return Colors.orange; // Orange for channels
|
||||||
case ContactType.none:
|
case ContactType.none:
|
||||||
@@ -357,6 +391,8 @@ class MapMarkers {
|
|||||||
return Icons.router; // Router icon for repeaters
|
return Icons.router; // Router icon for repeaters
|
||||||
case ContactType.room:
|
case ContactType.room:
|
||||||
return Icons.forum; // Forum/chat icon for rooms
|
return Icons.forum; // Forum/chat icon for rooms
|
||||||
|
case ContactType.sensor:
|
||||||
|
return Icons.sensors; // Sensors icon for sensor nodes
|
||||||
case ContactType.channel:
|
case ContactType.channel:
|
||||||
return Icons.public; // Public icon for channels
|
return Icons.public; // Public icon for channels
|
||||||
case ContactType.none:
|
case ContactType.none:
|
||||||
@@ -385,9 +421,7 @@ class _InfoRow extends StatelessWidget {
|
|||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(child: Text(value)),
|
||||||
child: Text(value),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
24
pubspec.lock
24
pubspec.lock
@@ -631,14 +631,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.0"
|
version: "1.6.0"
|
||||||
http_methods:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: http_methods
|
|
||||||
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.1"
|
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1208,22 +1200,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.1"
|
||||||
shelf:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: shelf
|
|
||||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.4.2"
|
|
||||||
shelf_router:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: shelf_router
|
|
||||||
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.4"
|
|
||||||
simple_sparse_list:
|
simple_sparse_list:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -118,10 +118,6 @@ dependencies:
|
|||||||
# XML parsing for GPX import/export
|
# XML parsing for GPX import/export
|
||||||
xml: ^6.5.0
|
xml: ^6.5.0
|
||||||
|
|
||||||
# SSE web server for multi-user support
|
|
||||||
shelf: ^1.4.0
|
|
||||||
shelf_router: ^1.1.0
|
|
||||||
|
|
||||||
# Network Service Discovery (Bonjour/mDNS)
|
# Network Service Discovery (Bonjour/mDNS)
|
||||||
nsd: ^4.0.3
|
nsd: ^4.0.3
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user