mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Add transmission timing details
This commit is contained in:
129
lib/utils/log_rx_route_decoder.dart
Normal file
129
lib/utils/log_rx_route_decoder.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../models/contact.dart';
|
||||
|
||||
class DecodedLogRxRoute {
|
||||
final int payloadType;
|
||||
final List<int> pathHashes;
|
||||
|
||||
const DecodedLogRxRoute({
|
||||
required this.payloadType,
|
||||
required this.pathHashes,
|
||||
});
|
||||
|
||||
int? get originalSenderHash => pathHashes.isEmpty ? null : pathHashes.first;
|
||||
}
|
||||
|
||||
class ResolvedNodeHash {
|
||||
final int hash;
|
||||
final String label;
|
||||
final bool isOwnNode;
|
||||
final bool isUniqueMatch;
|
||||
final int matchCount;
|
||||
|
||||
const ResolvedNodeHash({
|
||||
required this.hash,
|
||||
required this.label,
|
||||
required this.isOwnNode,
|
||||
required this.isUniqueMatch,
|
||||
required this.matchCount,
|
||||
});
|
||||
|
||||
String get hexLabel => '0x${hash.toRadixString(16).padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
class LogRxRouteDecoder {
|
||||
const LogRxRouteDecoder._();
|
||||
|
||||
static DecodedLogRxRoute? decode(Uint8List rawData) {
|
||||
if (rawData.length < 5 || rawData[0] != 0x88) return null;
|
||||
|
||||
final rawPacketData = rawData.sublist(3);
|
||||
if (rawPacketData.length < 2) return null;
|
||||
|
||||
final header = rawPacketData[0];
|
||||
final routeType = header & 0x03;
|
||||
final payloadType = (header >> 2) & 0x0F;
|
||||
|
||||
var index = 1;
|
||||
if (routeType == 0x00 || routeType == 0x03) {
|
||||
if (rawPacketData.length < index + 5) return null;
|
||||
index += 4;
|
||||
}
|
||||
|
||||
if (rawPacketData.length <= index) return null;
|
||||
final pathLen = rawPacketData[index++];
|
||||
if (rawPacketData.length < index + pathLen) return null;
|
||||
|
||||
return DecodedLogRxRoute(
|
||||
payloadType: payloadType,
|
||||
pathHashes: rawPacketData.sublist(index, index + pathLen),
|
||||
);
|
||||
}
|
||||
|
||||
static ResolvedNodeHash resolveHash(
|
||||
int hash, {
|
||||
required Iterable<Contact> contacts,
|
||||
Uint8List? ownPublicKey,
|
||||
String? ownName,
|
||||
}) {
|
||||
final ownHash = ownPublicKey != null && ownPublicKey.isNotEmpty
|
||||
? ownPublicKey.first
|
||||
: null;
|
||||
if (ownHash == hash) {
|
||||
final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
|
||||
? '$ownName (you)'
|
||||
: 'You';
|
||||
return ResolvedNodeHash(
|
||||
hash: hash,
|
||||
label: ownLabel,
|
||||
isOwnNode: true,
|
||||
isUniqueMatch: true,
|
||||
matchCount: 1,
|
||||
);
|
||||
}
|
||||
|
||||
final matches = contacts.where((contact) {
|
||||
return contact.publicKey.isNotEmpty && contact.publicKey.first == hash;
|
||||
}).toList();
|
||||
|
||||
if (matches.isEmpty) {
|
||||
return ResolvedNodeHash(
|
||||
hash: hash,
|
||||
label: 'Unknown',
|
||||
isOwnNode: false,
|
||||
isUniqueMatch: false,
|
||||
matchCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
if (matches.length == 1) {
|
||||
return ResolvedNodeHash(
|
||||
hash: hash,
|
||||
label: matches.first.displayName,
|
||||
isOwnNode: false,
|
||||
isUniqueMatch: true,
|
||||
matchCount: 1,
|
||||
);
|
||||
}
|
||||
|
||||
final candidateNames = matches
|
||||
.map((contact) => contact.displayName)
|
||||
.where((name) => name.trim().isNotEmpty)
|
||||
.take(2)
|
||||
.join(', ');
|
||||
final extraCount = matches.length - 2;
|
||||
final label = candidateNames.isEmpty
|
||||
? '${matches.length} contacts'
|
||||
: extraCount > 0
|
||||
? '$candidateNames +$extraCount'
|
||||
: candidateNames;
|
||||
return ResolvedNodeHash(
|
||||
hash: hash,
|
||||
label: label,
|
||||
isOwnNode: false,
|
||||
isUniqueMatch: false,
|
||||
matchCount: matches.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
149
lib/utils/message_airtime_estimator.dart
Normal file
149
lib/utils/message_airtime_estimator.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import '../models/message.dart';
|
||||
import 'image_message_parser.dart';
|
||||
import 'voice_message_parser.dart';
|
||||
|
||||
const int _defaultLoRaSf = 10;
|
||||
const int _defaultLoRaCr = 5;
|
||||
const int _defaultLoRaBwHz = 250000;
|
||||
const int _defaultLoRaPreambleSymbols = 8;
|
||||
const int _defaultLoRaCrcEnabled = 1;
|
||||
const int _defaultLoRaExplicitHeader = 1;
|
||||
const double _defaultAirtimeBudgetFactor = 1.0;
|
||||
const int _meshPacketHeaderBytes = 2;
|
||||
const int _textFrameBaseBytes = 10;
|
||||
|
||||
Duration estimateMessageTransmitDuration(
|
||||
Message message, {
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
}) {
|
||||
final imageEnvelope = ImageEnvelope.tryParse(message.text);
|
||||
if (imageEnvelope != null) {
|
||||
return estimateImageTransmitDuration(
|
||||
fragmentCount: imageEnvelope.total,
|
||||
sizeBytes: imageEnvelope.sizeBytes,
|
||||
pathLen: message.pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
}
|
||||
|
||||
final voiceEnvelope = VoiceEnvelope.tryParseText(message.text);
|
||||
if (voiceEnvelope != null) {
|
||||
return estimateVoiceTransmitDuration(
|
||||
mode: voiceEnvelope.mode,
|
||||
packetCount: voiceEnvelope.total,
|
||||
durationMs: voiceEnvelope.durationMs,
|
||||
pathLen: message.pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
}
|
||||
|
||||
final voicePacket = VoicePacket.tryParseText(message.text);
|
||||
if (voicePacket != null) {
|
||||
return estimateVoiceTransmitDuration(
|
||||
mode: voicePacket.mode,
|
||||
packetCount: voicePacket.total,
|
||||
durationMs: voicePacket.durationMs * voicePacket.total,
|
||||
pathLen: message.pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
}
|
||||
|
||||
final normalizedPathLen = _normalizedPathLen(message.pathLen);
|
||||
final payloadBytes = _textFrameBaseBytes + message.text.length;
|
||||
final hops = normalizedPathLen + 1;
|
||||
final airtimeMs = _estimateLoRaAirtimeMs(
|
||||
_meshPacketHeaderBytes + normalizedPathLen + payloadBytes,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
|
||||
return Duration(
|
||||
milliseconds: (airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops)
|
||||
.round(),
|
||||
);
|
||||
}
|
||||
|
||||
int _normalizedPathLen(int pathLen) {
|
||||
if (pathLen < 0 || pathLen >= 255) return 0;
|
||||
return pathLen.clamp(0, 64).toInt();
|
||||
}
|
||||
|
||||
double _estimateLoRaAirtimeMs(
|
||||
int payloadLenBytes, {
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
}) {
|
||||
final sf = _normalizeSf(radioSf);
|
||||
final bw = _resolveBandwidthHz(radioBw).toDouble();
|
||||
final cr = (_normalizeCr(radioCr) - 4).clamp(1, 4);
|
||||
final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1;
|
||||
final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0;
|
||||
|
||||
final symbolMs = ((1 << sf) / bw) * 1000.0;
|
||||
final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs;
|
||||
|
||||
final num =
|
||||
(8 * payloadLenBytes) -
|
||||
(4 * sf) +
|
||||
28 +
|
||||
(16 * _defaultLoRaCrcEnabled) -
|
||||
(20 * ih);
|
||||
final den = 4 * (sf - (2 * de));
|
||||
final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil();
|
||||
final payloadSymbols =
|
||||
8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4);
|
||||
final payloadMs = payloadSymbols * symbolMs;
|
||||
|
||||
return preambleMs + payloadMs;
|
||||
}
|
||||
|
||||
int _normalizeSf(int? value) {
|
||||
if (value == null) return _defaultLoRaSf;
|
||||
if (value >= 5 && value <= 12) return value;
|
||||
return _defaultLoRaSf;
|
||||
}
|
||||
|
||||
int _normalizeCr(int? value) {
|
||||
if (value == null) return _defaultLoRaCr;
|
||||
if (value >= 5 && value <= 8) return value;
|
||||
return _defaultLoRaCr;
|
||||
}
|
||||
|
||||
int _resolveBandwidthHz(int? rawBw) {
|
||||
if (rawBw == null) return _defaultLoRaBwHz;
|
||||
if (rawBw > 1000) return rawBw;
|
||||
switch (rawBw) {
|
||||
case 0:
|
||||
return 7800;
|
||||
case 1:
|
||||
return 10400;
|
||||
case 2:
|
||||
return 15600;
|
||||
case 3:
|
||||
return 20800;
|
||||
case 4:
|
||||
return 31250;
|
||||
case 5:
|
||||
return 41700;
|
||||
case 6:
|
||||
return 62500;
|
||||
case 7:
|
||||
return 125000;
|
||||
case 8:
|
||||
return 250000;
|
||||
case 9:
|
||||
return 500000;
|
||||
default:
|
||||
return _defaultLoRaBwHz;
|
||||
}
|
||||
}
|
||||
87
lib/utils/raw_route_probe.dart
Normal file
87
lib/utils/raw_route_probe.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
class RawRouteProbeRequest {
|
||||
static const int _binaryMagic = 0x70; // 'p'
|
||||
|
||||
final int nonce;
|
||||
final String requesterKey6;
|
||||
|
||||
const RawRouteProbeRequest({
|
||||
required this.nonce,
|
||||
required this.requesterKey6,
|
||||
});
|
||||
|
||||
static RawRouteProbeRequest? tryParseBinary(Uint8List payload) {
|
||||
if (payload.length != 11 || payload[0] != _binaryMagic) return null;
|
||||
try {
|
||||
final nonce =
|
||||
(payload[1] << 24) |
|
||||
(payload[2] << 16) |
|
||||
(payload[3] << 8) |
|
||||
payload[4];
|
||||
final requesterKey6 = payload
|
||||
.sublist(5, 11)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
return RawRouteProbeRequest(nonce: nonce, requesterKey6: requesterKey6);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
|
||||
throw ArgumentError.value(
|
||||
requesterKey6,
|
||||
'requesterKey6',
|
||||
'Expected 12 hex chars',
|
||||
);
|
||||
}
|
||||
final out = Uint8List(11);
|
||||
out[0] = _binaryMagic;
|
||||
out[1] = (nonce >> 24) & 0xFF;
|
||||
out[2] = (nonce >> 16) & 0xFF;
|
||||
out[3] = (nonce >> 8) & 0xFF;
|
||||
out[4] = nonce & 0xFF;
|
||||
for (var i = 0; i < 6; i++) {
|
||||
out[5 + i] = int.parse(
|
||||
requesterKey6.substring(i * 2, i * 2 + 2),
|
||||
radix: 16,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
class RawRouteProbeAck {
|
||||
static const int _binaryMagic = 0x71; // 'q'
|
||||
|
||||
final int nonce;
|
||||
|
||||
const RawRouteProbeAck({required this.nonce});
|
||||
|
||||
static RawRouteProbeAck? tryParseBinary(Uint8List payload) {
|
||||
if (payload.length != 5 || payload[0] != _binaryMagic) return null;
|
||||
try {
|
||||
final nonce =
|
||||
(payload[1] << 24) |
|
||||
(payload[2] << 16) |
|
||||
(payload[3] << 8) |
|
||||
payload[4];
|
||||
return RawRouteProbeAck(nonce: nonce);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List encodeBinary() {
|
||||
final out = Uint8List(5);
|
||||
out[0] = _binaryMagic;
|
||||
out[1] = (nonce >> 24) & 0xFF;
|
||||
out[2] = (nonce >> 16) & 0xFF;
|
||||
out[3] = (nonce >> 8) & 0xFF;
|
||||
out[4] = nonce & 0xFF;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import 'dart:typed_data';
|
||||
import '../models/contact.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
|
||||
enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar }
|
||||
enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar, unreachable }
|
||||
|
||||
class TransmissionTargetResolution {
|
||||
final Contact? target;
|
||||
|
||||
Reference in New Issue
Block a user