diff --git a/lib/models/last_heard_item.dart b/lib/models/last_heard_item.dart index c05ef50..137e816 100644 --- a/lib/models/last_heard_item.dart +++ b/lib/models/last_heard_item.dart @@ -58,37 +58,55 @@ class LastHeardItem { }); factory LastHeardItem.fromJson(Map json) { + int? _toInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } + + String _toString(dynamic value) { + if (value == null) return ''; + return value.toString(); + } + + String? _toStringOrNull(dynamic value) { + if (value == null) return null; + return value.toString(); + } + return LastHeardItem( - linkName: json['LinkName'] as String? ?? '', - sessionID: json['SessionID'] as String? ?? '', - linkType: json['LinkType'] as int? ?? 0, - contextID: json['ContextID'] as int? ?? 0, - sessionType: json['SessionType'] as int? ?? 0, - slot: json['Slot'] as int? ?? 0, - sourceID: json['SourceID'] as int? ?? 0, - destinationID: json['DestinationID'] as int? ?? 0, - route: json['Route'] as String? ?? '', - linkCall: json['LinkCall'] as String? ?? '', - sourceCall: json['SourceCall'] as String? ?? '', - sourceName: json['SourceName'] as String?, - destinationCall: json['DestinationCall'] as String? ?? '', - destinationName: json['DestinationName'] as String?, - start: json['Start'] as int? ?? 0, - stop: json['Stop'] as int? ?? 0, - rssi: json['RSSI'] as int?, - ber: json['BER'] as int?, - reflectorID: json['ReflectorID'] as int? ?? 0, - linkTypeName: json['LinkTypeName'] as String? ?? '', + linkName: _toString(json['LinkName']), + sessionID: _toString(json['SessionID']), + linkType: _toInt(json['LinkType']) ?? 0, + contextID: _toInt(json['ContextID']) ?? 0, + sessionType: _toInt(json['SessionType']) ?? 0, + slot: _toInt(json['Slot']) ?? 0, + sourceID: _toInt(json['SourceID']) ?? 0, + destinationID: _toInt(json['DestinationID']) ?? 0, + route: _toString(json['Route']), + linkCall: _toString(json['LinkCall']), + sourceCall: _toString(json['SourceCall']), + sourceName: _toStringOrNull(json['SourceName']), + destinationCall: _toString(json['DestinationCall']), + destinationName: _toStringOrNull(json['DestinationName']), + start: _toInt(json['Start']) ?? 0, + stop: _toInt(json['Stop']) ?? 0, + rssi: _toInt(json['RSSI']), + ber: _toInt(json['BER']), + reflectorID: _toInt(json['ReflectorID']) ?? 0, + linkTypeName: _toString(json['LinkTypeName']), callTypes: (json['CallTypes'] as List?) - ?.map((e) => e as String) + ?.map((e) => e.toString()) .toList() ?? [], - lossCount: json['LossCount'] as int? ?? 0, - totalCount: json['TotalCount'] as int? ?? 0, - master: json['Master'] as String? ?? '', - talkerAlias: json['TalkerAlias'] as String?, - flagSet: json['FlagSet'] as int? ?? 0, - event: json['Event'] as String? ?? '', + lossCount: _toInt(json['LossCount']) ?? 0, + totalCount: _toInt(json['TotalCount']) ?? 0, + master: _toString(json['Master']), + talkerAlias: _toStringOrNull(json['TalkerAlias']), + flagSet: _toInt(json['FlagSet']) ?? 0, + event: _toString(json['Event']), ); } diff --git a/lib/views/device_detail_view.dart b/lib/views/device_detail_view.dart index 9788d79..ca7869e 100644 --- a/lib/views/device_detail_view.dart +++ b/lib/views/device_detail_view.dart @@ -1,12 +1,15 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/device.dart'; import '../models/static_talkgroup.dart'; import '../models/device_profile.dart'; +import '../models/last_heard_item.dart'; import '../services/authentication_manager.dart'; import '../services/brandmeister_client.dart'; import '../services/brandmeister_websocket_client.dart'; +import '../services/lastheard_websocket_client.dart'; import 'link_talkgroup_view.dart'; class DeviceDetailView extends StatefulWidget { @@ -30,17 +33,25 @@ class _DeviceDetailViewState extends State { StreamSubscription>? _wsSubscription; int? _autoStaticTalkgroup; + LastHeardWebSocketClient? _lhWsClient; + StreamSubscription>? _lhWsSubscription; + final List _lastHeardItems = []; + static const int _maxLastHeardItems = 20; + @override void initState() { super.initState(); _loadData(); _connectWebSocket(); + _connectLastHeardWebSocket(); } @override void dispose() { _wsSubscription?.cancel(); _wsClient?.dispose(); + _lhWsSubscription?.cancel(); + _lhWsClient?.dispose(); super.dispose(); } @@ -90,6 +101,96 @@ class _DeviceDetailViewState extends State { } } + Future _connectLastHeardWebSocket() async { + try { + _lhWsClient = LastHeardWebSocketClient(); + _lhWsClient!.connect(); + + _lhWsSubscription = _lhWsClient!.messageStream.listen((message) { + if (message['event'] == 'mqtt' && message['data'] is Map) { + _handleLastHeardMessage(message['data'] as Map); + } + }); + } catch (e) { + debugPrint('DeviceDetailView: Error connecting to Last Heard WebSocket: $e'); + } + } + + void _handleLastHeardMessage(Map data) { + try { + final topic = data['topic'] as String?; + if (topic == 'LH') { + final payloadStr = data['payload'] as String?; + if (payloadStr == null) return; + + final payload = jsonDecode(payloadStr) as Map; + final destinationId = payload['DestinationID'] as int?; + + if (destinationId == null) return; + + // Get all linked talkgroup IDs (static + auto-static + dynamic) + final linkedTalkgroupIds = {}; + + // Add static talkgroups + for (final tg in _deviceProfile?.staticSubscriptions ?? []) { + if (tg.talkgroup != null) { + final tgId = int.tryParse(tg.talkgroup!); + if (tgId != null) { + linkedTalkgroupIds.add(tgId); + } + } + } + + // Add dynamic/auto-static talkgroups from device profile + for (final tg in _deviceProfile?.dynamicSubscriptions ?? []) { + if (tg.talkgroup != null) { + final tgId = int.tryParse(tg.talkgroup!); + if (tgId != null) { + linkedTalkgroupIds.add(tgId); + } + } + } + + // Add auto-static talkgroup from WebSocket (if available) + if (_autoStaticTalkgroup != null) { + linkedTalkgroupIds.add(_autoStaticTalkgroup!); + } + + debugPrint('DeviceDetailView: Linked talkgroups: $linkedTalkgroupIds, Destination: $destinationId'); + + // Only process if this message is for one of our linked talkgroups + if (linkedTalkgroupIds.contains(destinationId)) { + debugPrint('DeviceDetailView: Match found! Adding to last heard list'); + final item = LastHeardItem.fromJson(payload); + + debugPrint('DeviceDetailView: Item - TalkerAlias: ${item.talkerAlias}, SourceCall: ${item.sourceCall}, DestName: ${item.destinationName}, DestCall: ${item.destinationCall}'); + + if (item.sourceCall.isEmpty) { + debugPrint('DeviceDetailView: Ignoring item with empty SourceCall'); + return; + } + + if (mounted) { + setState(() { + // Remove any existing item with the same sourceID + _lastHeardItems.removeWhere((existing) => existing.sourceID == item.sourceID); + + // Add the new item at the beginning + _lastHeardItems.insert(0, item); + + // Keep only the latest 20 items + if (_lastHeardItems.length > _maxLastHeardItems) { + _lastHeardItems.removeRange(_maxLastHeardItems, _lastHeardItems.length); + } + }); + } + } + } + } catch (e) { + debugPrint('DeviceDetailView: Error handling Last Heard message: $e'); + } + } + Future _loadData() async { setState(() { _isLoadingTalkgroups = true; @@ -275,6 +376,10 @@ class _DeviceDetailViewState extends State { children: [ _buildDeviceInfoSection(), const Divider(height: 1), + if (_lastHeardItems.isNotEmpty) ...[ + _buildLastHeardSection(), + const Divider(height: 1), + ], _buildTalkgroupsSection(), ], ); @@ -326,6 +431,83 @@ class _DeviceDetailViewState extends State { ); } + Widget _buildLastHeardSection() { + return Container( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Last Heard on Talkgroup', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${_lastHeardItems.length}', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + ..._lastHeardItems.map((item) => Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + child: Text( + 'TS${item.slot}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + ), + title: Text(item.talkerAlias ?? item.sourceCall), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('ID: ${item.sourceID}'), + Text('TG: ${item.destinationID}'), + Text( + '${item.timeAgo} • ${item.linkTypeName}', + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + ), + ), + ], + ), + trailing: item.event == 'Session-Start' + ? Icon( + Icons.mic, + color: Theme.of(context).colorScheme.primary, + size: 20, + ) + : null, + isThreeLine: true, + ), + )), + ], + ), + ); + } + Widget _buildTalkgroupsSection() { final hasAnyTalkgroups = _talkgroups.isNotEmpty || (_deviceProfile?.staticSubscriptions.isNotEmpty ?? false) ||