mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-10-11 04:35:04 +00:00
chore: Initial commit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPovRHpejR4zRxuhjY4Qs3
This commit is contained in:
309
lib/models/contact.dart
Normal file
309
lib/models/contact.dart
Normal file
@@ -0,0 +1,309 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user