mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
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
416 lines
14 KiB
Dart
416 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';
|
|
|
|
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: Text(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,
|
|
});
|
|
}
|