meshcore-open/lib/screens/ble_debug_log_screen.dart
just_stuff_tm f4b18d97a1
Added Line Of Sight Feature for repeater placement, Added app wide Units Setting (#198)
* feat: add LOS workflow, global units, l10n cleanup, and mobile UI overflow fixes

Squashes prior PR commits into one changeset including: LOS map/service/tests, global metric/imperial unit system adoption, notification/BLE safety fixes, app-wide localization backfill/mojibake cleanup, and responsive UI title/overflow hardening.

* l10n: revert unrelated locale churn for LOS feature

* feat: keep LOS with app-wide unit settings

* fix: resolve post-merge app bar/import analyzer errors

* style: format screen files for CI
2026-02-20 22:08:23 -08:00

417 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter/services.dart';
import '../l10n/l10n.dart';
import '../services/ble_debug_log_service.dart';
import '../connector/meshcore_protocol.dart';
import '../widgets/adaptive_app_bar_title.dart';
enum _BleLogView { frames, rawLogRx }
class BleDebugLogScreen extends StatefulWidget {
const BleDebugLogScreen({super.key});
@override
State<BleDebugLogScreen> createState() => _BleDebugLogScreenState();
}
class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
_BleLogView _view = _BleLogView.frames;
@override
Widget build(BuildContext context) {
return Consumer<BleDebugLogService>(
builder: (context, logService, _) {
final entries = logService.entries.reversed.toList();
final rawEntries = logService.rawLogRxEntries.reversed.toList();
final showingFrames = _view == _BleLogView.frames;
final hasEntries = showingFrames
? entries.isNotEmpty
: rawEntries.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: AdaptiveAppBarTitle(context.l10n.debugLog_bleTitle),
actions: [
IconButton(
tooltip: context.l10n.debugLog_copyLog,
icon: const Icon(Icons.copy),
onPressed: hasEntries
? () async {
final text = showingFrames
? entries
.map(
(entry) =>
'${entry.description}\n${entry.hexPreview}\n',
)
.join('\n')
: rawEntries
.map(
(entry) =>
'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n',
)
.join('\n');
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.debugLog_bleCopied),
),
);
}
: null,
),
IconButton(
tooltip: context.l10n.debugLog_clearLog,
icon: const Icon(Icons.delete_outline),
onPressed: hasEntries
? () {
logService.clear();
}
: null,
),
],
),
body: SafeArea(
top: false,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: SegmentedButton<_BleLogView>(
segments: [
ButtonSegment(
value: _BleLogView.frames,
label: Text(context.l10n.debugLog_frames),
),
ButtonSegment(
value: _BleLogView.rawLogRx,
label: Text(context.l10n.debugLog_rawLogRx),
),
],
selected: {_view},
onSelectionChanged: (selection) {
setState(() => _view = selection.first);
},
),
),
const SizedBox(height: 8),
Expanded(
child: hasEntries
? ListView.separated(
itemCount: showingFrames
? entries.length
: rawEntries.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
if (showingFrames) {
final entry = entries[index];
final time =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
return ListTile(
dense: true,
title: Text(entry.description),
subtitle: Text('${entry.hexPreview}\n$time'),
isThreeLine: true,
leading: Icon(
entry.outgoing
? Icons.upload
: Icons.download,
size: 18,
),
);
}
final entry = rawEntries[index];
final info = _decodeRawPacket(entry.payload);
final time =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
return ListTile(
dense: true,
title: Text(info.title),
subtitle: Text('${info.summary}\n$time'),
isThreeLine: true,
leading: const Icon(Icons.download, size: 18),
onTap: () => _showRawDialog(context, info),
);
},
)
: Center(
child: Text(context.l10n.debugLog_noBleActivity),
),
),
],
),
),
);
},
);
}
void _showRawDialog(BuildContext context, _RawPacketInfo info) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(info.title),
content: SingleChildScrollView(child: SelectableText(info.rawHex)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.common_close),
),
],
),
);
}
_RawPacketInfo _decodeRawPacket(Uint8List raw) {
if (raw.length < 2) {
return _RawPacketInfo(
title: 'RX RAW_LOG_RX_DATA • invalid',
summary: 'Packet too short',
rawHex: _bytesToHex(raw),
);
}
var index = 0;
final header = raw[index++];
final routeType = header & 0x03;
final payloadType = (header >> 2) & 0x0F;
final payloadVer = (header >> 6) & 0x03;
final hasTransport = routeType == 0 || routeType == 3;
if (hasTransport) {
if (raw.length < index + 4) {
return _RawPacketInfo(
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
summary: 'Missing transport codes',
rawHex: _bytesToHex(raw),
);
}
index += 4;
}
if (raw.length <= index) {
return _RawPacketInfo(
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
summary: 'Missing path length',
rawHex: _bytesToHex(raw),
);
}
final pathLen = raw[index++];
if (raw.length < index + pathLen) {
return _RawPacketInfo(
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
summary: 'Truncated path',
rawHex: _bytesToHex(raw),
);
}
final pathBytes = raw.sublist(index, index + pathLen);
index += pathLen;
if (raw.length <= index) {
return _RawPacketInfo(
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
summary: 'Missing payload',
rawHex: _bytesToHex(raw),
);
}
final payload = raw.sublist(index);
final title =
'RX ${_payloadTypeLabel(payloadType)}${_routeLabel(routeType)} • v$payloadVer';
final summary = _decodePayloadSummary(payloadType, payload);
final pathSummary = pathLen > 0
? 'Path=${_bytesToHex(pathBytes)}'
: 'Path=none';
final detail = '$summary$pathSummary • len=${raw.length}';
return _RawPacketInfo(
title: title,
summary: detail,
rawHex: _bytesToHex(raw),
);
}
String _decodePayloadSummary(int payloadType, Uint8List payload) {
switch (payloadType) {
case 0x00: // REQ
return 'REQ payload=${payload.length} bytes';
case 0x01: // RESP
return 'RESP payload=${payload.length} bytes';
case 0x02: // TXT
return 'TXT payload=${payload.length} bytes';
case 0x03: // ACK
if (payload.length < 4) return 'ACK (short)';
return 'ACK crc=${_bytesToHex(payload.sublist(0, 4))}';
case 0x04: // ADVERT
return _decodeAdvertSummary(payload);
case 0x05: // GROUP_TXT
if (payload.length < 3) return 'GRP_TXT (short)';
final channelHash = payload[0].toRadixString(16).padLeft(2, '0');
final mac = _bytesToHex(payload.sublist(1, 3));
final cipherLen = payload.length - 3;
return 'GRP_TXT hash=$channelHash mac=$mac cipher=$cipherLen';
case 0x06: // GROUP_DATA
return 'GRP_DATA payload=${payload.length} bytes';
case 0x07: // ANON_REQ
return 'ANON_REQ payload=${payload.length} bytes';
case 0x08: // PATH
return 'PATH payload=${payload.length} bytes';
case 0x09: // TRACE
return 'TRACE payload=${payload.length} bytes';
case 0x0A: // MULTIPART
return 'MULTIPART payload=${payload.length} bytes';
case 0x0B: // CONTROL
return _decodeControlSummary(payload);
case 0x0F: // RAW
return 'RAW payload=${payload.length} bytes';
default:
return 'TYPE_$payloadType payload=${payload.length} bytes';
}
}
String _decodeAdvertSummary(Uint8List payload) {
if (payload.length < 101) {
return 'ADVERT (short)';
}
var offset = 0;
final pubKey = _bytesToHex(
payload.sublist(offset, offset + 32),
spaced: false,
);
offset += 32;
final timestamp = readUint32LE(payload, offset);
offset += 4;
offset += 64; // signature
final flags = payload[offset++];
final role = _deviceRoleLabel(flags & 0x0F);
final hasLocation = (flags & 0x10) != 0;
final hasFeature1 = (flags & 0x20) != 0;
final hasFeature2 = (flags & 0x40) != 0;
final hasName = (flags & 0x80) != 0;
String? name;
double? lat;
double? lon;
if (hasLocation && payload.length >= offset + 8) {
lat = readInt32LE(payload, offset) / 1000000.0;
lon = readInt32LE(payload, offset + 4) / 1000000.0;
offset += 8;
}
if (hasFeature1) offset += 2;
if (hasFeature2) offset += 2;
if (hasName && payload.length > offset) {
final rawName = String.fromCharCodes(payload.sublist(offset));
final nul = rawName.indexOf('\u0000');
name = nul >= 0 ? rawName.substring(0, nul) : rawName;
name = name.trim();
}
final namePart = (name != null && name.isNotEmpty) ? ' name="$name"' : '';
final locPart = (lat != null && lon != null)
? ' loc=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}'
: '';
return 'ADVERT role=$role ts=$timestamp$namePart$locPart key=${pubKey.substring(0, 12)}';
}
String _decodeControlSummary(Uint8List payload) {
if (payload.isEmpty) return 'CONTROL (empty)';
final flags = payload[0];
final subType = flags & 0xF0;
if (subType == 0x80) {
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
final typeFilter = payload[1];
final tag = readUint32LE(payload, 2);
final since = payload.length >= 10 ? readUint32LE(payload, 6) : 0;
return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since';
}
if (subType == 0x90) {
if (payload.length < 14) return 'CONTROL DISCOVER_RESP (short)';
final nodeType = flags & 0x0F;
final snrRaw = payload[1];
final snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw;
final snr = snrSigned / 4.0;
final tag = readUint32LE(payload, 2);
final keyLen = payload.length - 6;
return 'CONTROL DISCOVER_RESP node=${_deviceRoleLabel(nodeType)} snr=${snr.toStringAsFixed(2)} tag=$tag key=$keyLen';
}
return 'CONTROL subtype=0x${subType.toRadixString(16).padLeft(2, '0')}';
}
String _payloadTypeLabel(int payloadType) {
switch (payloadType) {
case 0x00:
return 'REQ';
case 0x01:
return 'RESP';
case 0x02:
return 'TXT';
case 0x03:
return 'ACK';
case 0x04:
return 'ADVERT';
case 0x05:
return 'GRP_TXT';
case 0x06:
return 'GRP_DATA';
case 0x07:
return 'ANON_REQ';
case 0x08:
return 'PATH';
case 0x09:
return 'TRACE';
case 0x0A:
return 'MULTIPART';
case 0x0B:
return 'CONTROL';
case 0x0F:
return 'RAW';
default:
return 'TYPE_$payloadType';
}
}
String _routeLabel(int routeType) {
switch (routeType) {
case 0:
return 'TRANS_FLOOD';
case 1:
return 'FLOOD';
case 2:
return 'DIRECT';
case 3:
return 'TRANS_DIRECT';
default:
return 'ROUTE_$routeType';
}
}
String _deviceRoleLabel(int role) {
switch (role) {
case 0x01:
return 'Chat';
case 0x02:
return 'Repeater';
case 0x03:
return 'Room';
case 0x04:
return 'Sensor';
default:
return 'Unknown';
}
}
String _bytesToHex(Uint8List bytes, {bool spaced = true}) {
if (bytes.isEmpty) return '';
if (!spaced) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
}
}
class _RawPacketInfo {
final String title;
final String summary;
final String rawHex;
_RawPacketInfo({
required this.title,
required this.summary,
required this.rawHex,
});
}