Implement last heard display on device details

This commit is contained in:
2026-01-20 00:36:01 +01:00
parent b96214fcba
commit 4aa5fd156f
2 changed files with 227 additions and 27 deletions

View File

@@ -58,37 +58,55 @@ class LastHeardItem {
});
factory LastHeardItem.fromJson(Map<String, dynamic> 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<dynamic>?)
?.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']),
);
}

View File

@@ -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<DeviceDetailView> {
StreamSubscription<Map<String, dynamic>>? _wsSubscription;
int? _autoStaticTalkgroup;
LastHeardWebSocketClient? _lhWsClient;
StreamSubscription<Map<String, dynamic>>? _lhWsSubscription;
final List<LastHeardItem> _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<DeviceDetailView> {
}
}
Future<void> _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<String, dynamic>);
}
});
} catch (e) {
debugPrint('DeviceDetailView: Error connecting to Last Heard WebSocket: $e');
}
}
void _handleLastHeardMessage(Map<String, dynamic> 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<String, dynamic>;
final destinationId = payload['DestinationID'] as int?;
if (destinationId == null) return;
// Get all linked talkgroup IDs (static + auto-static + dynamic)
final linkedTalkgroupIds = <int>{};
// Add static talkgroups
for (final tg in _deviceProfile?.staticSubscriptions ?? <StaticTalkgroup>[]) {
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 ?? <StaticTalkgroup>[]) {
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<void> _loadData() async {
setState(() {
_isLoadingTalkgroups = true;
@@ -275,6 +376,10 @@ class _DeviceDetailViewState extends State<DeviceDetailView> {
children: [
_buildDeviceInfoSection(),
const Divider(height: 1),
if (_lastHeardItems.isNotEmpty) ...[
_buildLastHeardSection(),
const Divider(height: 1),
],
_buildTalkgroupsSection(),
],
);
@@ -326,6 +431,83 @@ class _DeviceDetailViewState extends State<DeviceDetailView> {
);
}
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) ||