diff --git a/lib/services/authentication_manager.dart b/lib/services/authentication_manager.dart index 822dff7..b3d0a01 100644 --- a/lib/services/authentication_manager.dart +++ b/lib/services/authentication_manager.dart @@ -199,4 +199,12 @@ class AuthenticationManager extends ChangeNotifier { slot: slot, ); } + + Future dropAutoStaticGroup(int dmrId) async { + if (_client == null) { + throw BrandmeisterError('Not authenticated'); + } + + await _client!.dropAutoStaticGroup(dmrId); + } } diff --git a/lib/services/brandmeister_client.dart b/lib/services/brandmeister_client.dart index d4fad3c..0efcbcb 100644 --- a/lib/services/brandmeister_client.dart +++ b/lib/services/brandmeister_client.dart @@ -170,6 +170,20 @@ class BrandmeisterClient { } } + Future dropAutoStaticGroup(int dmrId) async { + final response = await http.get( + Uri.parse('$baseUrl/device/$dmrId/action/dropAutoStaticGroup'), + headers: _headers, + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw BrandmeisterError( + 'Failed to drop auto-static group: ${response.body}', + statusCode: response.statusCode, + ); + } + } + // Talkgroup Endpoints Future> getTalkgroups() async { final response = await http.get( diff --git a/lib/services/brandmeister_websocket_client.dart b/lib/services/brandmeister_websocket_client.dart new file mode 100644 index 0000000..3d4b17a --- /dev/null +++ b/lib/services/brandmeister_websocket_client.dart @@ -0,0 +1,284 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class BrandmeisterWebSocketClient { + WebSocketChannel? _channel; + StreamController>? _messageController; + Timer? _heartbeatTimer; + Timer? _reconnectTimer; + bool _isConnecting = false; + bool _shouldReconnect = true; + bool _isReady = false; + int _reconnectAttempts = 0; + static const int _maxReconnectAttempts = 5; + static const Duration _reconnectDelay = Duration(seconds: 5); + + final Completer _readyCompleter = Completer(); + + // Socket.IO Engine.IO v4 WebSocket URL + static const String _wsUrl = + 'wss://api.brandmeister.network/infoService/?EIO=4&transport=websocket'; + + Stream> get messageStream => + _messageController?.stream ?? const Stream.empty(); + + bool get isConnected => _channel != null; + + bool get isReady => _isReady; + + Future get ready => _readyCompleter.future; + + Future connect() async { + if (_isConnecting || isConnected) { + debugPrint('WebSocket: Already connected or connecting (isConnecting: $_isConnecting, isConnected: $isConnected)'); + return; + } + + _isConnecting = true; + _shouldReconnect = true; + + try { + debugPrint('WebSocket: Connecting to $_wsUrl'); + _messageController ??= StreamController>.broadcast(); + + _channel = WebSocketChannel.connect(Uri.parse(_wsUrl)); + + // Listen to incoming messages + _channel!.stream.listen( + _handleMessage, + onError: _handleError, + onDone: _handleDisconnect, + cancelOnError: false, + ); + + _reconnectAttempts = 0; + _isConnecting = false; + debugPrint('WebSocket: WebSocket connected, waiting for handshake (isReady: $_isReady)'); + } catch (e) { + _isConnecting = false; + debugPrint('WebSocket: Connection error: $e'); + _scheduleReconnect(); + } + } + + void _handleMessage(dynamic message) { + try { + final messageStr = message.toString(); + debugPrint('WebSocket: Received message: $messageStr'); + + // Socket.IO protocol uses numbered message types + // 0 = open, 2 = ping, 3 = pong, 4 = message, etc. + if (messageStr.startsWith('0')) { + // Open packet - connection established + _handleOpenPacket(messageStr); + } else if (messageStr.startsWith('2')) { + // Ping packet - respond with pong + _sendPong(); + } else if (messageStr.startsWith('40')) { + // Socket.IO namespace connection acknowledgment (must check before '4') + _handleNamespaceConnect(); + } else if (messageStr.startsWith('42')) { + // Socket.IO event message (42 = message with data) + _handleDataPacket(messageStr); + } else if (messageStr.startsWith('4')) { + // Other Socket.IO message types + debugPrint('WebSocket: Received other type 4 message: $messageStr'); + } + } catch (e) { + debugPrint('WebSocket: Error handling message: $e'); + } + } + + void _handleOpenPacket(String message) { + try { + // Parse open packet: 0{"sid":"xxx","upgrades":[],"pingInterval":25000,"pingTimeout":20000} + final jsonStr = message.substring(1); + final data = jsonDecode(jsonStr) as Map; + + final pingInterval = data['pingInterval'] as int? ?? 25000; + + // Set up heartbeat based on server's ping interval + _heartbeatTimer?.cancel(); + _heartbeatTimer = Timer.periodic( + Duration(milliseconds: pingInterval), + (_) => _sendPing(), + ); + + debugPrint('WebSocket: Open packet received, ping interval: $pingInterval ms'); + + // Send Socket.IO namespace connection (40) + _sendNamespaceConnect(); + } catch (e) { + debugPrint('WebSocket: Error parsing open packet: $e'); + } + } + + void _sendNamespaceConnect() { + if (isConnected) { + try { + _channel!.sink.add('40'); + debugPrint('WebSocket: Sent namespace connect (40)'); + } catch (e) { + debugPrint('WebSocket: Error sending namespace connect: $e'); + } + } + } + + void _handleNamespaceConnect() { + debugPrint('WebSocket: Namespace connected (received 40)'); + + // Mark connection as ready + _isReady = true; + if (!_readyCompleter.isCompleted) { + _readyCompleter.complete(); + } + } + + void _handleDataPacket(String message) { + try { + // Parse data packet: 4{"event":"data","args":[...]} + // or: 42["eventName",{...}] + String jsonStr = message.substring(1); + + // Handle Engine.IO v4 format + if (jsonStr.startsWith('2')) { + jsonStr = jsonStr.substring(1); + } + + final data = jsonDecode(jsonStr); + + Map? eventData; + + if (data is Map) { + eventData = data; + } else if (data is List && data.isNotEmpty) { + // Socket.IO array format: ["eventName", {...}] or ["eventName", [...]] + final eventName = data[0] as String; + if (data.length > 1) { + eventData = { + 'event': eventName, + 'data': data[1], + }; + } else { + eventData = { + 'event': eventName, + }; + } + } + + if (eventData != null && _messageController != null && !_messageController!.isClosed) { + _messageController!.add(eventData); + } + } catch (e) { + debugPrint('WebSocket: Error parsing data packet: $e'); + } + } + + void sendMessage(String event, dynamic data) { + if (!isConnected) { + debugPrint('WebSocket: Cannot send message, not connected'); + return; + } + + if (!isReady) { + debugPrint('WebSocket: Cannot send message, handshake not complete'); + return; + } + + try { + // Socket.IO format: 42["eventName", data] + final message = '42${jsonEncode([event, data])}'; + _channel!.sink.add(message); + debugPrint('WebSocket: Sent message: $message'); + } catch (e) { + debugPrint('WebSocket: Error sending message: $e'); + } + } + + void getDeviceStatus(String deviceId) { + debugPrint('WebSocket: getDeviceStatus called for device: $deviceId (isReady: $isReady, isConnected: $isConnected)'); + sendMessage('getDeviceStatus', deviceId); + } + + void _sendPing() { + if (isConnected) { + try { + _channel!.sink.add('2'); + debugPrint('WebSocket: Sent ping'); + } catch (e) { + debugPrint('WebSocket: Error sending ping: $e'); + } + } + } + + void _sendPong() { + if (isConnected) { + try { + _channel!.sink.add('3'); + debugPrint('WebSocket: Sent pong'); + } catch (e) { + debugPrint('WebSocket: Error sending pong: $e'); + } + } + } + + void _handleError(Object error) { + debugPrint('WebSocket: Stream error: $error'); + } + + void _handleDisconnect() { + debugPrint('WebSocket: Disconnected'); + _cleanup(); + + if (_shouldReconnect) { + _scheduleReconnect(); + } + } + + void _scheduleReconnect() { + if (_reconnectAttempts >= _maxReconnectAttempts) { + debugPrint('WebSocket: Max reconnect attempts reached'); + return; + } + + _reconnectAttempts++; + debugPrint('WebSocket: Scheduling reconnect attempt $_reconnectAttempts in $_reconnectDelay'); + + _reconnectTimer?.cancel(); + _reconnectTimer = Timer(_reconnectDelay, () { + if (_shouldReconnect) { + connect(); + } + }); + } + + void _cleanup() { + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + _channel = null; + _isConnecting = false; + } + + void disconnect() { + debugPrint('WebSocket: Disconnecting'); + _shouldReconnect = false; + _reconnectTimer?.cancel(); + _heartbeatTimer?.cancel(); + + try { + _channel?.sink.close(); + } catch (e) { + debugPrint('WebSocket: Error closing channel: $e'); + } + + _cleanup(); + } + + void dispose() { + disconnect(); + _messageController?.close(); + _messageController = null; + } +} diff --git a/lib/views/device_detail_view.dart b/lib/views/device_detail_view.dart index b7beeaf..9788d79 100644 --- a/lib/views/device_detail_view.dart +++ b/lib/views/device_detail_view.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/device.dart'; @@ -5,6 +6,7 @@ import '../models/static_talkgroup.dart'; import '../models/device_profile.dart'; import '../services/authentication_manager.dart'; import '../services/brandmeister_client.dart'; +import '../services/brandmeister_websocket_client.dart'; import 'link_talkgroup_view.dart'; class DeviceDetailView extends StatefulWidget { @@ -24,10 +26,68 @@ class _DeviceDetailViewState extends State { bool _isLoadingDeviceDetails = false; String? _errorMessage; + BrandmeisterWebSocketClient? _wsClient; + StreamSubscription>? _wsSubscription; + int? _autoStaticTalkgroup; + @override void initState() { super.initState(); _loadData(); + _connectWebSocket(); + } + + @override + void dispose() { + _wsSubscription?.cancel(); + _wsClient?.dispose(); + super.dispose(); + } + + Future _connectWebSocket() async { + _wsClient = BrandmeisterWebSocketClient(); + await _wsClient!.connect(); + + // Wait for the open packet (0{...}) to be received + await _wsClient!.ready; + + // Request device status after connection is ready + _wsClient!.getDeviceStatus(widget.device.id.toString()); + + // Listen for device status updates + _wsSubscription = _wsClient!.messageStream.listen((message) { + if (message['event'] == 'deviceStatus' && message['data'] is List) { + _handleDeviceStatus(message['data'] as List); + } + }); + } + + void _handleDeviceStatus(List deviceStatusList) { + if (deviceStatusList.isEmpty) return; + + for (final status in deviceStatusList) { + if (status is Map) { + final number = status['number']; + if (number == widget.device.id) { + final values = status['values'] as List?; + if (values != null && values.length >= 20) { + final autoStaticValue = values[19]; + if (autoStaticValue != null && + autoStaticValue != 0 && + autoStaticValue != 4000) { + setState(() { + _autoStaticTalkgroup = autoStaticValue as int; + }); + } else { + setState(() { + _autoStaticTalkgroup = null; + }); + } + } + break; + } + } + } } Future _loadData() async { @@ -103,6 +163,35 @@ class _DeviceDetailViewState extends State { } } + Future _unlinkAutoStatic() async { + try { + final authManager = context.read(); + await authManager.dropAutoStaticGroup(widget.device.id); + + setState(() { + _autoStaticTalkgroup = null; + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Auto-static talkgroup unlinked successfully')), + ); + } + } on BrandmeisterError catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to unlink auto-static: ${e.message}')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: ${e.toString()}')), + ); + } + } + } + void _showLinkTalkgroupSheet() { showModalBottomSheet( context: context, @@ -242,7 +331,8 @@ class _DeviceDetailViewState extends State { (_deviceProfile?.staticSubscriptions.isNotEmpty ?? false) || (_deviceProfile?.dynamicSubscriptions.isNotEmpty ?? false) || (_deviceProfile?.timedSubscriptions.isNotEmpty ?? false) || - (_deviceProfile?.blockedGroups.isNotEmpty ?? false); + (_deviceProfile?.blockedGroups.isNotEmpty ?? false) || + _autoStaticTalkgroup != null; return Container( padding: const EdgeInsets.all(16), @@ -299,6 +389,11 @@ class _DeviceDetailViewState extends State { ), const SizedBox(height: 16), ], + // Auto-Static Talkgroup (from WebSocket) + if (_autoStaticTalkgroup != null) ...[ + _buildAutoStaticSection(), + const SizedBox(height: 16), + ], // Dynamic Subscriptions (Autostatic) if (_deviceProfile?.dynamicSubscriptions.isNotEmpty ?? false) ...[ _buildTalkgroupCategory( @@ -337,6 +432,91 @@ class _DeviceDetailViewState extends State { ); } + Widget _buildAutoStaticSection() { + const color = Colors.purple; + final talkgroupName = _allTalkgroups[_autoStaticTalkgroup.toString()] ?? 'Unknown'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.flash_on, size: 20, color: color), + const SizedBox(width: 8), + Text( + 'Auto Static', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + '1', + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar( + backgroundColor: color.withValues(alpha: 0.2), + child: const Icon( + Icons.flash_on, + size: 20, + color: color, + ), + ), + title: Text(talkgroupName), + subtitle: Text('ID: $_autoStaticTalkgroup'), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + color: Colors.red, + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Unlink Auto-Static Talkgroup'), + content: Text( + 'Are you sure you want to unlink auto-static talkgroup $_autoStaticTalkgroup?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _unlinkAutoStatic(); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Unlink'), + ), + ], + ), + ); + }, + ), + ), + ), + ], + ); + } + Widget _buildTalkgroupCategory( String title, List talkgroups, diff --git a/pubspec.lock b/pubspec.lock index 7004d54..e051197 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -461,6 +461,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 255b48d..28fa2ef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,9 @@ dependencies: # State management provider: ^6.1.1 + # WebSocket client for real-time updates + web_socket_channel: ^3.0.1 + dev_dependencies: flutter_test: sdk: flutter