mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-16 05:40:46 +10:00
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>
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter/services.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: const Text('BLE Debug Log'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Copy log',
|
||||
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(
|
||||
const SnackBar(content: Text('BLE log copied')),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Clear log',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: hasEntries
|
||||
? () {
|
||||
logService.clear();
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: SegmentedButton<_BleLogView>(
|
||||
segments: const [
|
||||
ButtonSegment(value: _BleLogView.frames, label: Text('Frames')),
|
||||
ButtonSegment(value: _BleLogView.rawLogRx, label: Text('Raw Log-RX')),
|
||||
],
|
||||
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),
|
||||
);
|
||||
},
|
||||
)
|
||||
: const Center(
|
||||
child: Text('No BLE activity yet'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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: const Text('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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user