mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-20 01:15:35 +10:00
Merge branch 'main' into dev-mapOverlap
This commit is contained in:
@@ -283,66 +283,66 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
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;
|
||||
final reader = BufferReader(payload);
|
||||
try {
|
||||
final pubKey = _bytesToHex(reader.readBytes(pubKeySize), spaced: false);
|
||||
|
||||
final timestamp = reader.readUInt32LE();
|
||||
reader.skipBytes(signatureSize);
|
||||
final flags = reader.readByte();
|
||||
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) {
|
||||
lat = reader.readInt32LE() / 1000000.0;
|
||||
lon = reader.readInt32LE() / 1000000.0;
|
||||
}
|
||||
if (hasFeature1) reader.skipBytes(2);
|
||||
if (hasFeature2) reader.skipBytes(2);
|
||||
if (hasName) {
|
||||
name = reader.readCStringGreedy(maxNameSize);
|
||||
}
|
||||
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)}…';
|
||||
} catch (e) {
|
||||
return 'ADVERT (invalid)';
|
||||
}
|
||||
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';
|
||||
final reader = BufferReader(payload);
|
||||
try {
|
||||
final flags = reader.readByte();
|
||||
final subType = flags & 0xF0;
|
||||
if (subType == 0x80) {
|
||||
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
|
||||
final typeFilter = reader.readByte();
|
||||
final tag = reader.readInt32LE();
|
||||
final since = payload.length >= 10 ? reader.readInt32LE() : 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 = reader.readInt32LE();
|
||||
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')}';
|
||||
} catch (e) {
|
||||
return 'CONTROL (invalid)';
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -40,8 +40,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
final primaryPath = !channelMessage && !message.isOutgoing
|
||||
? Uint8List.fromList(primaryPathTmp.reversed.toList())
|
||||
: primaryPathTmp;
|
||||
final contacts = connector.allContacts;
|
||||
final hops = _buildPathHops(primaryPath, contacts, l10n);
|
||||
final hops = _buildPathHops(primaryPath, connector, l10n);
|
||||
final hasHopDetails = primaryPath.isNotEmpty;
|
||||
final observedLabel = _formatObservedHops(
|
||||
primaryPath.length,
|
||||
@@ -303,10 +302,12 @@ class _ChannelMessagePathMapScreenState
|
||||
extends State<ChannelMessagePathMapScreen> {
|
||||
static const double _labelZoomThreshold = 8.5;
|
||||
|
||||
final MapController _mapController = MapController();
|
||||
Uint8List? _selectedPath;
|
||||
double _pathDistance = 0.0;
|
||||
bool _showNodeLabels = true;
|
||||
bool _didReceivePositionUpdate = false;
|
||||
int? _focusedHopIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -337,6 +338,22 @@ class _ChannelMessagePathMapScreenState
|
||||
return totalDistance;
|
||||
}
|
||||
|
||||
void _focusHop(_PathHop hop) {
|
||||
if (!hop.hasLocation) return;
|
||||
final targetZoom = _didReceivePositionUpdate
|
||||
? max(_mapController.camera.zoom, 10.0)
|
||||
: 12.0;
|
||||
_mapController.move(hop.position!, targetZoom);
|
||||
}
|
||||
|
||||
void _onHopTapped(_PathHop hop) {
|
||||
_focusHop(hop);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_focusedHopIndex = hop.index;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
@@ -365,8 +382,7 @@ class _ChannelMessagePathMapScreenState
|
||||
: selectedPathTmp;
|
||||
|
||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||
final contacts = connector.allContacts;
|
||||
final hops = _buildPathHops(selectedPath, contacts, context.l10n);
|
||||
final hops = _buildPathHops(selectedPath, connector, context.l10n);
|
||||
|
||||
final points = <LatLng>[];
|
||||
|
||||
@@ -421,6 +437,7 @@ class _ChannelMessagePathMapScreenState
|
||||
children: [
|
||||
FlutterMap(
|
||||
key: mapKey,
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: initialCenter,
|
||||
initialZoom: initialZoom,
|
||||
@@ -472,6 +489,7 @@ class _ChannelMessagePathMapScreenState
|
||||
) {
|
||||
setState(() {
|
||||
_selectedPath = observedPaths[index].pathBytes;
|
||||
_focusedHopIndex = null;
|
||||
});
|
||||
}),
|
||||
if (points.isEmpty)
|
||||
@@ -727,8 +745,17 @@ class _ChannelMessagePathMapScreenState
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final hop = hops[index];
|
||||
final isFocused = _focusedHopIndex == hop.index;
|
||||
return ListTile(
|
||||
dense: true,
|
||||
enabled: hop.hasLocation,
|
||||
selected: isFocused,
|
||||
selectedTileColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.12),
|
||||
onTap: hop.hasLocation
|
||||
? () => _onHopTapped(hop)
|
||||
: null,
|
||||
leading: CircleAvatar(
|
||||
radius: 14,
|
||||
child: Text(
|
||||
@@ -787,19 +814,71 @@ class _ObservedPath {
|
||||
|
||||
List<_PathHop> _buildPathHops(
|
||||
Uint8List pathBytes,
|
||||
List<Contact> contacts,
|
||||
MeshCoreConnector connector,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
if (pathBytes.isEmpty) return const [];
|
||||
final candidatesByPrefix = <int, List<Contact>>{};
|
||||
for (final contact in connector.allContacts) {
|
||||
if (contact.publicKey.isEmpty) continue;
|
||||
if (contact.type != advTypeRepeater && contact.type != advTypeRoom) {
|
||||
continue;
|
||||
}
|
||||
final prefix = contact.publicKey.first;
|
||||
candidatesByPrefix.putIfAbsent(prefix, () => <Contact>[]).add(contact);
|
||||
}
|
||||
for (final candidates in candidatesByPrefix.values) {
|
||||
candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
|
||||
}
|
||||
final startPoint =
|
||||
(connector.selfLatitude != null && connector.selfLongitude != null)
|
||||
? LatLng(connector.selfLatitude!, connector.selfLongitude!)
|
||||
: null;
|
||||
var previousPosition = startPoint;
|
||||
final distance = Distance();
|
||||
|
||||
final hops = <_PathHop>[];
|
||||
for (var i = 0; i < pathBytes.length; i++) {
|
||||
final prefix = pathBytes[i];
|
||||
final contact = _matchContactForPrefix(contacts, prefix);
|
||||
final searchPoint = i == 0 ? startPoint : previousPosition;
|
||||
final candidates = candidatesByPrefix[pathBytes[i]];
|
||||
Contact? contact;
|
||||
if (candidates != null && candidates.isNotEmpty) {
|
||||
var bestIndex = 0;
|
||||
if (searchPoint != null) {
|
||||
var bestDistance = double.infinity;
|
||||
for (var j = 0; j < candidates.length; j++) {
|
||||
final candidate = candidates[j];
|
||||
if (!candidate.hasLocation ||
|
||||
candidate.latitude == null ||
|
||||
candidate.longitude == null) {
|
||||
continue;
|
||||
}
|
||||
final currentDistance = distance(
|
||||
searchPoint,
|
||||
LatLng(candidate.latitude!, candidate.longitude!),
|
||||
);
|
||||
if (currentDistance < bestDistance) {
|
||||
bestDistance = currentDistance;
|
||||
bestIndex = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
contact = candidates.removeAt(bestIndex);
|
||||
if (candidates.isEmpty) {
|
||||
candidatesByPrefix.remove(pathBytes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
final resolvedPosition = _resolvePosition(contact);
|
||||
if (resolvedPosition != null) {
|
||||
previousPosition = resolvedPosition;
|
||||
}
|
||||
hops.add(
|
||||
_PathHop(
|
||||
index: i + 1,
|
||||
prefix: prefix,
|
||||
prefix: pathBytes[i],
|
||||
contact: contact,
|
||||
position: _resolvePosition(contact),
|
||||
position: resolvedPosition,
|
||||
l10n: l10n,
|
||||
),
|
||||
);
|
||||
@@ -807,42 +886,13 @@ List<_PathHop> _buildPathHops(
|
||||
return hops;
|
||||
}
|
||||
|
||||
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
|
||||
final matches = contacts
|
||||
.where(
|
||||
(contact) =>
|
||||
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
||||
contact.publicKey.isNotEmpty &&
|
||||
contact.publicKey[0] == prefix,
|
||||
)
|
||||
.toList();
|
||||
if (matches.isEmpty) return null;
|
||||
|
||||
Contact? pickWhere(bool Function(Contact) predicate) {
|
||||
for (final contact in matches) {
|
||||
if (predicate(contact)) return contact;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return pickWhere((c) => c.type == advTypeRepeater && _hasValidLocation(c)) ??
|
||||
pickWhere((c) => c.type == advTypeRepeater) ??
|
||||
pickWhere(_hasValidLocation) ??
|
||||
matches.first;
|
||||
}
|
||||
|
||||
LatLng? _resolvePosition(Contact? contact) {
|
||||
if (contact == null) return null;
|
||||
if (!_hasValidLocation(contact)) return null;
|
||||
return LatLng(contact.latitude!, contact.longitude!);
|
||||
}
|
||||
|
||||
bool _hasValidLocation(Contact contact) {
|
||||
final lat = contact.latitude;
|
||||
final lon = contact.longitude;
|
||||
if (lat == null || lon == null) return false;
|
||||
if (lat == 0 && lon == 0) return false;
|
||||
return true;
|
||||
if (!contact.hasLocation) return null;
|
||||
final latitude = contact.latitude;
|
||||
final longitude = contact.longitude;
|
||||
if (latitude == null || longitude == null) return null;
|
||||
return LatLng(latitude, longitude);
|
||||
}
|
||||
|
||||
String _formatPrefix(int prefix) {
|
||||
|
||||
@@ -970,7 +970,6 @@ void _privacySettings(BuildContext context, MeshCoreConnector connector) {
|
||||
value: advertLocPolicy,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => advertLocPolicy = value);
|
||||
advertLocPolicy = value;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Reference in New Issue
Block a user