meshcore-open/lib/screens/repeater_status_screen.dart
zach e7a5b9e209 Initial commit: MeshCore Open Flutter client
Open-source Flutter client for MeshCore LoRa mesh networking devices.

Features:
- BLE device scanning and connection
- Nordic UART Service (NUS) integration
- Material 3 design with system theme support
- Provider-based state management
- Placeholder screens for chat, contacts, and settings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-26 11:42:02 -07:00

508 lines
15 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/contact.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../services/repeater_command_service.dart';
class RepeaterStatusScreen extends StatefulWidget {
final Contact repeater;
final String password;
const RepeaterStatusScreen({
super.key,
required this.repeater,
required this.password,
});
@override
State<RepeaterStatusScreen> createState() => _RepeaterStatusScreenState();
}
class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
bool _isLoading = false;
StreamSubscription<Uint8List>? _frameSubscription;
RepeaterCommandService? _commandService;
Timer? _statusTimeout;
DateTime? _statusRequestedAt;
int? _batteryMv;
int? _uptimeSecs;
int? _queueLen;
int? _debugFlags;
int? _lastRssi;
double? _lastSnr;
int? _noiseFloor;
int? _txAirSecs;
int? _rxAirSecs;
int? _packetsSent;
int? _packetsRecv;
int? _floodTx;
int? _directTx;
int? _floodRx;
int? _directRx;
int? _dupFlood;
int? _dupDirect;
@override
void initState() {
super.initState();
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_commandService = RepeaterCommandService(connector);
_setupMessageListener();
_loadStatus();
}
@override
void dispose() {
_frameSubscription?.cancel();
_commandService?.dispose();
_statusTimeout?.cancel();
super.dispose();
}
void _setupMessageListener() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Listen for incoming text messages from the repeater
_frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
// Check if it's a text message response
if (frame[0] == pushCodeStatusResponse) {
_handleStatusResponse(frame);
} else if (frame[0] == respCodeContactMsgRecv ||
frame[0] == respCodeContactMsgRecvV3) {
_handleTextMessageResponse(frame);
}
});
}
void _handleTextMessageResponse(Uint8List frame) {
final parsed = parseContactMessageText(frame);
if (parsed == null) return;
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
// Notify command service of response (for retry handling)
_commandService?.handleResponse(widget.repeater, parsed.text);
// Parse status responses
_parseStatusResponse(parsed.text);
}
void _handleStatusResponse(Uint8List frame) {
if (frame.length < 8) return;
final prefix = frame.sublist(2, 8);
if (!_matchesRepeaterPrefix(prefix)) return;
const payloadOffset = 8;
const statsSize = 52;
if (frame.length < payloadOffset + statsSize) return;
final data = ByteData.sublistView(frame, payloadOffset, payloadOffset + statsSize);
int offset = 0;
final batteryMv = data.getUint16(offset, Endian.little);
offset += 2;
final queueLen = data.getUint16(offset, Endian.little);
offset += 2;
final noiseFloor = data.getInt16(offset, Endian.little);
offset += 2;
final lastRssi = data.getInt16(offset, Endian.little);
offset += 2;
final packetsRecv = data.getUint32(offset, Endian.little);
offset += 4;
final packetsSent = data.getUint32(offset, Endian.little);
offset += 4;
final txAirSecs = data.getUint32(offset, Endian.little);
offset += 4;
final uptimeSecs = data.getUint32(offset, Endian.little);
offset += 4;
final floodTx = data.getUint32(offset, Endian.little);
offset += 4;
final directTx = data.getUint32(offset, Endian.little);
offset += 4;
final floodRx = data.getUint32(offset, Endian.little);
offset += 4;
final directRx = data.getUint32(offset, Endian.little);
offset += 4;
final errEvents = data.getUint16(offset, Endian.little);
offset += 2;
final lastSnrRaw = data.getInt16(offset, Endian.little);
offset += 2;
final directDups = data.getUint16(offset, Endian.little);
offset += 2;
final floodDups = data.getUint16(offset, Endian.little);
offset += 2;
final rxAirSecs = data.getUint32(offset, Endian.little);
_statusTimeout?.cancel();
if (!mounted) return;
setState(() {
_isLoading = false;
_batteryMv = batteryMv;
_queueLen = queueLen;
_noiseFloor = noiseFloor;
_lastRssi = lastRssi;
_packetsRecv = packetsRecv;
_packetsSent = packetsSent;
_txAirSecs = txAirSecs;
_rxAirSecs = rxAirSecs;
_uptimeSecs = uptimeSecs;
_floodTx = floodTx;
_directTx = directTx;
_floodRx = floodRx;
_directRx = directRx;
_debugFlags = errEvents;
_lastSnr = lastSnrRaw / 4.0;
_dupDirect = directDups;
_dupFlood = floodDups;
});
}
bool _matchesRepeaterPrefix(Uint8List prefix) {
final target = widget.repeater.publicKey;
if (target.length < 6 || prefix.length < 6) return false;
for (int i = 0; i < 6; i++) {
if (prefix[i] != target[i]) return false;
}
return true;
}
void _parseStatusResponse(String response) {
final trimmed = response.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
try {
final data = jsonDecode(trimmed) as Map<String, dynamic>;
if (data.containsKey('battery_mv')) {
_batteryMv = _asInt(data['battery_mv']);
_uptimeSecs = _asInt(data['uptime_secs']);
_queueLen = _asInt(data['queue_len']);
_debugFlags = _asInt(data['errors']);
} else if (data.containsKey('noise_floor')) {
_noiseFloor = _asInt(data['noise_floor']);
_lastRssi = _asInt(data['last_rssi']);
_lastSnr = _asDouble(data['last_snr']);
_txAirSecs = _asInt(data['tx_air_secs']);
_rxAirSecs = _asInt(data['rx_air_secs']);
} else if (data.containsKey('recv') || data.containsKey('sent')) {
_packetsRecv = _asInt(data['recv']);
_packetsSent = _asInt(data['sent']);
_floodTx = _asInt(data['flood_tx']);
_directTx = _asInt(data['direct_tx']);
_floodRx = _asInt(data['flood_rx']);
_directRx = _asInt(data['direct_rx']);
_dupFlood = _asInt(data['dup_flood']);
_dupDirect = _asInt(data['dup_direct']);
}
} catch (_) {
// Ignore parse failures for non-JSON responses.
}
}
if (mounted) {
setState(() {});
}
}
Future<void> _loadStatus() async {
if (_commandService == null) return;
setState(() {
_isLoading = true;
_statusRequestedAt = DateTime.now();
_batteryMv = null;
_uptimeSecs = null;
_queueLen = null;
_debugFlags = null;
_lastRssi = null;
_lastSnr = null;
_noiseFloor = null;
_txAirSecs = null;
_rxAirSecs = null;
_packetsSent = null;
_packetsRecv = null;
_floodTx = null;
_directTx = null;
_floodRx = null;
_directRx = null;
_dupFlood = null;
_dupDirect = null;
});
try {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final frame = buildSendStatusRequestFrame(widget.repeater.publicKey);
await connector.sendFrame(frame);
_statusTimeout?.cancel();
_statusTimeout = Timer(const Duration(seconds: 12), () {
if (!mounted) return;
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Status request timed out.'),
backgroundColor: Colors.red,
),
);
});
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error loading status: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Repeater Status'),
Text(
widget.repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
),
],
),
centerTitle: false,
actions: [
IconButton(
icon: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: _isLoading ? null : _loadStatus,
tooltip: 'Refresh',
),
],
),
body: RefreshIndicator(
onRefresh: _loadStatus,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildSystemInfoCard(),
const SizedBox(height: 16),
_buildRadioStatsCard(),
const SizedBox(height: 16),
_buildPacketStatsCard(),
],
),
),
);
}
Widget _buildSystemInfoCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline, color: Theme.of(context).primaryColor),
const SizedBox(width: 8),
const Text(
'System Information',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Divider(),
_buildInfoRow('Battery', _batteryText()),
_buildInfoRow('Clock (at login)', _clockText()),
_buildInfoRow('Uptime', _formatDuration(_uptimeSecs)),
_buildInfoRow('Queue Length', _formatValue(_queueLen)),
_buildInfoRow('Debug Flags', _formatValue(_debugFlags)),
],
),
),
);
}
Widget _buildRadioStatsCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.radio, color: Theme.of(context).primaryColor),
const SizedBox(width: 8),
const Text(
'Radio Statistics',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Divider(),
_buildInfoRow('Last RSSI', _formatValue(_lastRssi, suffix: ' dB')),
_buildInfoRow('Last SNR', _formatSnr(_lastSnr)),
_buildInfoRow('Noise Floor', _formatValue(_noiseFloor, suffix: ' dB')),
_buildInfoRow('TX Airtime', _formatDuration(_txAirSecs)),
_buildInfoRow('RX Airtime', _formatDuration(_rxAirSecs)),
],
),
),
);
}
Widget _buildPacketStatsCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.analytics, color: Theme.of(context).primaryColor),
const SizedBox(width: 8),
const Text(
'Packet Statistics',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Divider(),
_buildInfoRow('Sent', _packetTxText()),
_buildInfoRow('Received', _packetRxText()),
_buildInfoRow('Duplicates', _duplicateText()),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 130,
child: Text(
label,
style: TextStyle(
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(fontWeight: FontWeight.w400),
),
),
],
),
);
}
int? _asInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is double) return value.round();
return int.tryParse(value.toString());
}
double? _asDouble(dynamic value) {
if (value == null) return null;
if (value is double) return value;
if (value is int) return value.toDouble();
return double.tryParse(value.toString());
}
String _batteryText() {
if (_batteryMv == null) return '';
final percent = _batteryPercentFromMv(_batteryMv!);
final volts = (_batteryMv! / 1000.0).toStringAsFixed(2);
return '$percent% / ${volts}V';
}
int _batteryPercentFromMv(int millivolts) {
const minMv = 3000;
const maxMv = 4200;
if (millivolts <= minMv) return 0;
if (millivolts >= maxMv) return 100;
return (((millivolts - minMv) * 100) / (maxMv - minMv)).round();
}
String _clockText() {
if (_statusRequestedAt == null) return '';
final dt = _statusRequestedAt!;
final date = '${dt.day}/${dt.month}/${dt.year}';
final time = '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
return '$date $time';
}
String _formatDuration(int? seconds) {
if (seconds == null) return '';
final days = seconds ~/ 86400;
final hours = (seconds % 86400) ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
final secs = seconds % 60;
return '$days days ${hours}h ${minutes}m ${secs}s';
}
String _packetTxText() {
if (_packetsSent == null) return '';
final flood = _formatValue(_floodTx);
final direct = _formatValue(_directTx);
return 'Total: $_packetsSent, Flood: $flood, Direct: $direct';
}
String _packetRxText() {
if (_packetsRecv == null) return '';
final flood = _formatValue(_floodRx);
final direct = _formatValue(_directRx);
return 'Total: $_packetsRecv, Flood: $flood, Direct: $direct';
}
String _duplicateText() {
if (_dupFlood != null || _dupDirect != null) {
final flood = _formatValue(_dupFlood);
final direct = _formatValue(_dupDirect);
return 'Flood: $flood, Direct: $direct';
}
if (_packetsRecv == null || _floodRx == null || _directRx == null) return '';
final dupTotal = _packetsRecv! - _floodRx! - _directRx!;
if (dupTotal < 0) return '';
return 'Total: $dupTotal';
}
String _formatValue(num? value, {String? suffix}) {
if (value == null) return '';
return suffix == null ? value.toString() : '$value$suffix';
}
String _formatSnr(double? snr) {
if (snr == null) return '';
return snr.toStringAsFixed(2);
}
}