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 = >[]; 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 = []; 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: []); } 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 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 = []; 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; }