mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-14 22:55:12 +10:00
b34d684e67
formats all dart files using `dart format .` from the root project dir this makes the code style repeatable by new contributors and makes PR review easier
666 lines
20 KiB
Dart
666 lines
20 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../l10n/l10n.dart';
|
|
import '../models/contact.dart';
|
|
import '../models/path_selection.dart';
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../connector/meshcore_protocol.dart';
|
|
import '../services/repeater_command_service.dart';
|
|
import '../widgets/path_management_dialog.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> {
|
|
static const int _statusPayloadOffset = 8;
|
|
static const int _statusStatsSize = 52;
|
|
static const int _statusResponseBytes =
|
|
_statusPayloadOffset + _statusStatsSize;
|
|
|
|
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;
|
|
PathSelection? _pendingStatusSelection;
|
|
|
|
@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);
|
|
}
|
|
});
|
|
}
|
|
|
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
|
return connector.contacts.firstWhere(
|
|
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
|
orElse: () => widget.repeater,
|
|
);
|
|
}
|
|
|
|
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);
|
|
_recordStatusResult(true);
|
|
}
|
|
|
|
void _handleStatusResponse(Uint8List frame) {
|
|
if (frame.length < 8) return;
|
|
final prefix = frame.sublist(2, 8);
|
|
if (!_matchesRepeaterPrefix(prefix)) return;
|
|
|
|
if (frame.length < _statusResponseBytes) return;
|
|
|
|
final data = ByteData.sublistView(
|
|
frame,
|
|
_statusPayloadOffset,
|
|
_statusResponseBytes,
|
|
);
|
|
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;
|
|
});
|
|
_recordStatusResult(true);
|
|
}
|
|
|
|
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();
|
|
_pendingStatusSelection = null;
|
|
_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 repeater = _resolveRepeater(connector);
|
|
final selection = await connector.preparePathForContactSend(repeater);
|
|
_pendingStatusSelection = selection;
|
|
final frame = buildSendStatusRequestFrame(repeater.publicKey);
|
|
await connector.sendFrame(frame);
|
|
|
|
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
|
var messageBytes = frame.length >= _statusResponseBytes
|
|
? frame.length
|
|
: _statusResponseBytes;
|
|
if (messageBytes < maxFrameSize) {
|
|
messageBytes = maxFrameSize;
|
|
}
|
|
final timeoutMs = connector.calculateTimeout(
|
|
pathLength: pathLengthValue,
|
|
messageBytes: messageBytes,
|
|
);
|
|
_statusTimeout?.cancel();
|
|
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.repeater_statusRequestTimeout),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
_recordStatusResult(false);
|
|
});
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.repeater_errorLoadingStatus(e.toString()),
|
|
),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
_recordStatusResult(false);
|
|
}
|
|
}
|
|
|
|
void _recordStatusResult(bool success) {
|
|
final selection = _pendingStatusSelection;
|
|
if (selection == null) return;
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
final repeater = _resolveRepeater(connector);
|
|
connector.recordRepeaterPathResult(repeater, selection, success, null);
|
|
_pendingStatusSelection = null;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final connector = context.watch<MeshCoreConnector>();
|
|
final repeater = _resolveRepeater(connector);
|
|
final isFloodMode = repeater.pathOverride == -1;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(l10n.repeater_statusTitle),
|
|
Text(
|
|
repeater.name,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
centerTitle: false,
|
|
actions: [
|
|
PopupMenuButton<String>(
|
|
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
|
tooltip: l10n.repeater_routingMode,
|
|
onSelected: (mode) async {
|
|
if (mode == 'flood') {
|
|
await connector.setPathOverride(repeater, pathLen: -1);
|
|
} else {
|
|
await connector.setPathOverride(repeater, pathLen: null);
|
|
}
|
|
},
|
|
itemBuilder: (context) => [
|
|
PopupMenuItem(
|
|
value: 'auto',
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.auto_mode,
|
|
size: 20,
|
|
color: !isFloodMode
|
|
? Theme.of(context).primaryColor
|
|
: null,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
l10n.repeater_autoUseSavedPath,
|
|
style: TextStyle(
|
|
fontWeight: !isFloodMode
|
|
? FontWeight.bold
|
|
: FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'flood',
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.waves,
|
|
size: 20,
|
|
color: isFloodMode
|
|
? Theme.of(context).primaryColor
|
|
: null,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
l10n.repeater_forceFloodMode,
|
|
style: TextStyle(
|
|
fontWeight: isFloodMode
|
|
? FontWeight.bold
|
|
: FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.timeline),
|
|
tooltip: l10n.repeater_pathManagement,
|
|
onPressed: () =>
|
|
PathManagementDialog.show(context, contact: repeater),
|
|
),
|
|
IconButton(
|
|
icon: _isLoading
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.refresh),
|
|
onPressed: _isLoading ? null : _loadStatus,
|
|
tooltip: l10n.repeater_refresh,
|
|
),
|
|
],
|
|
),
|
|
body: SafeArea(
|
|
top: false,
|
|
child: RefreshIndicator(
|
|
onRefresh: _loadStatus,
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
_buildSystemInfoCard(),
|
|
const SizedBox(height: 16),
|
|
_buildRadioStatsCard(),
|
|
const SizedBox(height: 16),
|
|
_buildPacketStatsCard(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSystemInfoCard() {
|
|
final l10n = context.l10n;
|
|
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).textTheme.headlineSmall?.color,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
l10n.repeater_systemInformation,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const Divider(),
|
|
_buildInfoRow(l10n.repeater_battery, _batteryText()),
|
|
_buildInfoRow(l10n.repeater_clockAtLogin, _clockText()),
|
|
_buildInfoRow(l10n.repeater_uptime, _formatDuration(_uptimeSecs)),
|
|
_buildInfoRow(l10n.repeater_queueLength, _formatValue(_queueLen)),
|
|
_buildInfoRow(l10n.repeater_debugFlags, _formatValue(_debugFlags)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRadioStatsCard() {
|
|
final l10n = context.l10n;
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.radio,
|
|
color: Theme.of(context).textTheme.headlineSmall?.color,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
l10n.repeater_radioStatistics,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const Divider(),
|
|
_buildInfoRow(
|
|
l10n.repeater_lastRssi,
|
|
_formatValue(_lastRssi, suffix: ' dB'),
|
|
),
|
|
_buildInfoRow(l10n.repeater_lastSnr, _formatSnr(_lastSnr)),
|
|
_buildInfoRow(
|
|
l10n.repeater_noiseFloor,
|
|
_formatValue(_noiseFloor, suffix: ' dB'),
|
|
),
|
|
_buildInfoRow(l10n.repeater_txAirtime, _formatDuration(_txAirSecs)),
|
|
_buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPacketStatsCard() {
|
|
final l10n = context.l10n;
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.analytics,
|
|
color: Theme.of(context).textTheme.headlineSmall?.color,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
l10n.repeater_packetStatistics,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const Divider(),
|
|
_buildInfoRow(l10n.repeater_sent, _packetTxText()),
|
|
_buildInfoRow(l10n.repeater_received, _packetRxText()),
|
|
_buildInfoRow(l10n.repeater_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 l10n = context.l10n;
|
|
final days = seconds ~/ 86400;
|
|
final hours = (seconds % 86400) ~/ 3600;
|
|
final minutes = (seconds % 3600) ~/ 60;
|
|
final secs = seconds % 60;
|
|
return l10n.repeater_daysHoursMinsSecs(days, hours, minutes, secs);
|
|
}
|
|
|
|
String _packetTxText() {
|
|
if (_packetsSent == null) return '—';
|
|
final l10n = context.l10n;
|
|
final flood = _formatValue(_floodTx);
|
|
final direct = _formatValue(_directTx);
|
|
return l10n.repeater_packetTxTotal(_packetsSent!, flood, direct);
|
|
}
|
|
|
|
String _packetRxText() {
|
|
if (_packetsRecv == null) return '—';
|
|
final l10n = context.l10n;
|
|
final flood = _formatValue(_floodRx);
|
|
final direct = _formatValue(_directRx);
|
|
return l10n.repeater_packetRxTotal(_packetsRecv!, flood, direct);
|
|
}
|
|
|
|
String _duplicateText() {
|
|
final l10n = context.l10n;
|
|
if (_dupFlood != null || _dupDirect != null) {
|
|
final flood = _formatValue(_dupFlood);
|
|
final direct = _formatValue(_dupDirect);
|
|
return l10n.repeater_duplicatesFloodDirect(flood, direct);
|
|
}
|
|
if (_packetsRecv == null || _floodRx == null || _directRx == null)
|
|
return '—';
|
|
final dupTotal = _packetsRecv! - _floodRx! - _directRx!;
|
|
if (dupTotal < 0) return '—';
|
|
return l10n.repeater_duplicatesTotal(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);
|
|
}
|
|
}
|