Files
meshcore-sar_android/lib/models/contact.dart
Janez T bdbceece75 fix: Normalize raw transport path for firmware v1.15 photo/audio transfer
CMD_SEND_RAW_DATA (0x19) was passed the encoded MeshCore route descriptor
(e.g. 0x41 for a 1-hop 2-byte-hash route), but the v1.15 companion firmware
raw-data handler treats the path byte as a legacy literal hop count, not an
encoded descriptor. It misread 0x41 as "65 path bytes" and rejected the
command (ERROR: Unsupported command), so media fetch always failed while
normal messaging worked.

- Add ContactRouteCodec.toLegacyRawPath() converting encoded descriptor +
  path into legacy format (literal hop count + first byte of each hop hash).
- Apply it in ConnectionProvider.sendRawVoicePacket, the single chokepoint
  all raw media (voice/image fragments, swarm, route probes) flows through.
- Pace served fragments 350ms apart to avoid firmware "ERROR: Table full"
  when bursting many raw packets.
- Tests for the descriptor conversion.

Fixes #43

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:38:05 +02:00

310 lines
9.9 KiB
Dart

export 'package:meshcore_client/meshcore_client.dart'
show Contact, ContactType, ContactTelemetry, AdvertLocation;
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import 'package:meshcore_client/meshcore_client.dart';
class ParsedContactRoute {
final String canonicalText;
final int hashSize;
final int hopCount;
final int encodedPathLen;
final int signedEncodedPathLen;
final Uint8List pathBytes;
final Uint8List paddedPathBytes;
const ParsedContactRoute({
required this.canonicalText,
required this.hashSize,
required this.hopCount,
required this.encodedPathLen,
required this.signedEncodedPathLen,
required this.pathBytes,
required this.paddedPathBytes,
});
int get byteLength => pathBytes.length;
String get summary => hopCount == 0
? 'Direct'
: '$hopCount hop${hopCount == 1 ? '' : 's'} via $hashSize-byte hashes';
}
class ContactRouteFormatException implements Exception {
final String message;
const ContactRouteFormatException(this.message);
@override
String toString() => message;
}
class ContactRouteCodec {
static const int maxHashSize = 3;
static const int maxPathBytes = 64;
static const int _unknownDescriptor = 0xFF;
static ParsedContactRoute parse(String input, {int? expectedHashSize}) {
final normalized = input.trim().toUpperCase();
if (normalized.isEmpty) {
throw const ContactRouteFormatException('Route cannot be empty.');
}
if (expectedHashSize != null &&
(expectedHashSize < 1 || expectedHashSize > maxHashSize)) {
throw const ContactRouteFormatException(
'Hash size must be 1, 2, or 3 bytes.',
);
}
final hopTokens = normalized
.split(',')
.map((token) => token.trim())
.toList();
if (hopTokens.any((token) => token.isEmpty)) {
throw const ContactRouteFormatException('Route contains an empty hop.');
}
final hopBytes = <List<int>>[];
int? hashSize;
for (final token in hopTokens) {
final compact = token.replaceAll(':', '');
if (compact.isEmpty || !RegExp(r'^[0-9A-F]+$').hasMatch(compact)) {
throw ContactRouteFormatException('Invalid hop "$token".');
}
if (compact.length.isOdd) {
throw ContactRouteFormatException(
'Hop "$token" must contain full bytes.',
);
}
final currentHashSize = compact.length ~/ 2;
if (currentHashSize < 1 || currentHashSize > maxHashSize) {
throw ContactRouteFormatException(
'Hop "$token" must be 1, 2, or 3 bytes.',
);
}
if (expectedHashSize != null && currentHashSize != expectedHashSize) {
throw ContactRouteFormatException(
'Hop "$token" must be $expectedHashSize '
'byte${expectedHashSize == 1 ? '' : 's'}.',
);
}
hashSize ??= currentHashSize;
if (hashSize != currentHashSize) {
throw const ContactRouteFormatException(
'All hops in a route must use the same hash size.',
);
}
final bytes = <int>[];
for (var i = 0; i < compact.length; i += 2) {
bytes.add(int.parse(compact.substring(i, i + 2), radix: 16));
}
hopBytes.add(bytes);
}
final resolvedHashSize = hashSize ?? 1;
final flatBytes = Uint8List.fromList(
hopBytes.expand((hop) => hop).toList(),
);
if (flatBytes.length > maxPathBytes) {
throw const ContactRouteFormatException(
'Route exceeds the 64-byte firmware limit.',
);
}
final encodedPathLen =
((resolvedHashSize - 1) << 6) | (hopBytes.length & 0x3F);
final padded = Uint8List(maxPathBytes);
padded.setRange(0, flatBytes.length, flatBytes);
return ParsedContactRoute(
canonicalText: hopBytes
.map(
(hop) => hop
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase(),
)
.join(','),
hashSize: resolvedHashSize,
hopCount: hopBytes.length,
encodedPathLen: encodedPathLen,
signedEncodedPathLen: toSignedDescriptor(encodedPathLen),
pathBytes: flatBytes,
paddedPathBytes: padded,
);
}
static ParsedContactRoute direct({int hashSize = 1}) {
if (hashSize < 1 || hashSize > maxHashSize) {
throw ArgumentError.value(hashSize, 'hashSize', 'Must be 1, 2, or 3.');
}
final encodedPathLen = (hashSize - 1) << 6;
return ParsedContactRoute(
canonicalText: '',
hashSize: hashSize,
hopCount: 0,
encodedPathLen: encodedPathLen,
signedEncodedPathLen: toSignedDescriptor(encodedPathLen),
pathBytes: Uint8List(0),
paddedPathBytes: Uint8List(maxPathBytes),
);
}
static ParsedContactRoute? fromContact(Contact contact) {
if (!contact.routeHasPath || contact.routeHopCount == 0) {
return null;
}
return ParsedContactRoute(
canonicalText: contact.routeCanonicalText,
hashSize: contact.routeHashSize,
hopCount: contact.routeHopCount,
encodedPathLen: contact.routeEncodedPathLen,
signedEncodedPathLen: contact.routeSignedPathLen,
pathBytes: contact.routePathBytes,
paddedPathBytes: _padPath(contact.routePathBytes),
);
}
static Uint8List _padPath(Uint8List bytes) {
final padded = Uint8List(maxPathBytes);
padded.setRange(0, math.min(bytes.length, maxPathBytes), bytes);
return padded;
}
static int toSignedDescriptor(int encodedPathLen) =>
encodedPathLen > 127 ? encodedPathLen - 256 : encodedPathLen;
static int toUnsignedDescriptor(int signedPathLen) => signedPathLen & 0xFF;
static bool isUnknownDescriptor(int signedPathLen) =>
toUnsignedDescriptor(signedPathLen) == _unknownDescriptor;
static bool isValidDescriptor(int signedPathLen) {
final raw = toUnsignedDescriptor(signedPathLen);
if (raw == _unknownDescriptor) return false;
final hashSize = ((raw >> 6) + 1);
if (hashSize > maxHashSize) return false;
final hopCount = raw & 0x3F;
return hopCount * hashSize <= maxPathBytes;
}
/// Converts an encoded MeshCore route descriptor + path into the legacy raw
/// path format expected by the `CMD_SEND_RAW_DATA` (0x19) handler in
/// companion firmware v1.15.
///
/// The raw-data handler treats the path-length byte as a *literal* hop count
/// (one byte per hop) rather than as an encoded descriptor, so a 1-hop route
/// using 2-byte hashes (descriptor `0x41`) would otherwise be misread as
/// "65 path bytes" and rejected with `ERROR: Unsupported command`. We
/// down-sample every hop to its first hash byte and emit a plain hop count.
///
/// Routes that are already legacy (hash size 1, descriptor `< 0x40`) pass
/// through unchanged. Unknown/flood descriptors collapse to a zero-hop
/// direct path (raw transport does not support flood routing).
static LegacyRawPath toLegacyRawPath(int descriptor, Uint8List path) {
final raw = toUnsignedDescriptor(descriptor);
if (raw == _unknownDescriptor) {
return const LegacyRawPath(length: 0, path: <int>[]);
}
if (raw < 0x40) {
// Hash size 1: the encoded path is already one byte per hop.
final hopCount = raw;
final available = math.min(hopCount, path.length);
return LegacyRawPath(
length: hopCount,
path: Uint8List.fromList(path.sublist(0, available)),
);
}
final hopCount = raw & 0x3F;
final hashSize = ((raw >> 6) & 0x03) + 1;
final legacy = Uint8List(hopCount);
for (var hop = 0; hop < hopCount; hop++) {
final srcIndex = hop * hashSize;
if (srcIndex < path.length) {
legacy[hop] = path[srcIndex];
}
}
return LegacyRawPath(length: hopCount, path: legacy);
}
}
/// Legacy raw-transport path: a literal hop count plus one byte per hop, as
/// expected by the firmware `CMD_SEND_RAW_DATA` handler.
class LegacyRawPath {
const LegacyRawPath({required this.length, required this.path});
final int length;
final List<int> path;
}
extension ContactLocalization on Contact {
/// Returns the localized display name for special contacts (e.g. Public Channel).
/// For all other contacts, returns [displayName].
String getLocalizedDisplayName(BuildContext context) {
if (isPublicChannel) {
return AppLocalizations.of(context)!.publicChannel;
}
return displayName;
}
int get routeEncodedPathLen =>
ContactRouteCodec.toUnsignedDescriptor(outPathLen);
int get routeSignedPathLen =>
ContactRouteCodec.toSignedDescriptor(routeEncodedPathLen);
bool get routeIsUnknown => ContactRouteCodec.isUnknownDescriptor(outPathLen);
bool get routeHasPath =>
!routeIsUnknown && ContactRouteCodec.isValidDescriptor(outPathLen);
int get routeHashSize => routeHasPath ? ((routeEncodedPathLen >> 6) + 1) : 1;
int get routeHopCount => routeHasPath ? (routeEncodedPathLen & 0x3F) : -1;
int get routeByteLength => routeHasPath
? math.min(routeHopCount * routeHashSize, outPath.length)
: 0;
Uint8List get routePathBytes => routeByteLength <= 0
? Uint8List(0)
: Uint8List.fromList(outPath.sublist(0, routeByteLength));
String get routeCanonicalText {
if (!routeHasPath || routeHopCount <= 0) return '';
final bytes = routePathBytes;
final hops = <String>[];
for (var i = 0; i < bytes.length; i += routeHashSize) {
hops.add(
bytes
.sublist(i, i + routeHashSize)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase(),
);
}
return hops.join(',');
}
String get routeSummary {
if (routeIsUnknown) {
return 'Flood/Unknown';
}
if (!routeHasPath || routeHopCount == 0) {
return 'Direct';
}
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
}
bool get routeSupportsLegacyRawTransport => routeHasPath;
}