fix(#224): decode contact path-len byte (mask high bits + divide by width)

The firmware path-len byte packs a hash-mode hint in the high 2 bits and the
path byte-length in the low 6. Contact.fromFrame stored it raw, so a direct
node at 2-byte mode (0x40) read as pathLength 64: "64 hops" in the UI, a ~195s
ACK timeout, and up to 64 junk bytes pulled into contact.path that broke
repeater status/telemetry/neighbors until the path was cleared (#222).

- contact.dart: decode via pathHopCount (& 0x3F); keep 0xFF as the flood
  sentinel; use the decoded length for safePathLen so path bytes stay clean.
- chat_screen _currentPathLabel: show realHopCount(pathLength, pathHashByteWidth)
  so 2/3-byte nodes read the correct hop count; 0 -> Direct.
- Regression test for 0x40/0x00/0x03/0x44/0xFF; updated two model_changes tests
  that codified the old raw mapping.

Part of #222 (stays open for on-hardware validation by the reporter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/236/head
Strycher 1 week ago
parent 8cacf59781
commit 7179d201c5

@ -166,9 +166,14 @@ class Contact {
final type = reader.readByte(); final type = reader.readByte();
final flags = reader.readByte(); final flags = reader.readByte();
final pathLen = reader.readByte(); final pathLen = reader.readByte();
final safePathLen = pathLen > 0 // The firmware path-len byte packs a hash-mode hint in the high 2 bits and
? (pathLen > maxPathSize ? maxPathSize : pathLen) // the path BYTE length in the low 6 (#222). Decode before use: a direct
: 0; // node at 2-byte mode sends 0x40, whose raw value would otherwise read as
// 64 hops and pull 64 junk bytes into the path. 0xFF stays the flood
// sentinel; the device-configured pathHashByteWidth converts bytes->hops
// at display time.
final byteLen = pathLen == 0xFF ? -1 : pathHopCount(pathLen);
final safePathLen = byteLen > 0 ? byteLen : 0;
final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen); final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
final name = reader.readCStringGreedy(maxNameSize); final name = reader.readCStringGreedy(maxNameSize);
@ -213,7 +218,7 @@ class Contact {
name: name.isEmpty ? 'Unknown' : name, name: name.isEmpty ? 'Unknown' : name,
type: type, type: type,
flags: flags, flags: flags,
pathLength: (pathLen == 0xFF || pathLen > maxPathSize) ? -1 : pathLen, pathLength: byteLen, // decoded low-6-bit byte length; -1 = flood (#222)
path: pathBytes, path: pathBytes,
latitude: lat, latitude: lat,
longitude: lon, longitude: lon,

@ -1239,10 +1239,17 @@ class _ChatScreenState extends State<ChatScreen> {
return context.l10n.chat_hopsForced(contact.pathOverride!); return context.l10n.chat_hopsForced(contact.pathOverride!);
} }
// Use device's path // Use device's path. pathLength is a BYTE length; divide by the device's
// configured hash width to get the true hop count (#222).
if (contact.pathLength < 0) return context.l10n.chat_floodAuto; if (contact.pathLength < 0) return context.l10n.chat_floodAuto;
if (contact.pathLength == 0) return context.l10n.chat_direct; final hops =
return context.l10n.chat_hopsCount(contact.pathLength); realHopCount(
contact.pathLength,
context.read<MeshCoreConnector>().pathHashByteWidth,
) ??
0;
if (hops == 0) return context.l10n.chat_direct;
return context.l10n.chat_hopsCount(hops);
} }
Future<void> _notifyPathSet( Future<void> _notifyPathSet(

@ -0,0 +1,69 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/models/contact.dart';
/// Builds a minimal RESP_CODE_CONTACT frame with a caller-chosen path-len byte.
Uint8List _frame({required int pathLen, List<int> pathBytes = const []}) {
final frame = Uint8List(contactFrameSize);
frame[0] = respCodeContact;
for (var i = contactPubKeyOffset; i < contactPubKeyOffset + pubKeySize; i++) {
frame[i] = 0x11; // non-zero public key passes the zeroed-key guard
}
frame[contactTypeOffset] = advTypeRepeater;
frame[contactFlagsOffset] = 0;
frame[contactPathLenOffset] = pathLen;
for (var i = 0; i < pathBytes.length && i < maxPathSize; i++) {
frame[contactPathOffset + i] = pathBytes[i];
}
const name = 'TestNode';
for (var i = 0; i < name.length; i++) {
frame[contactNameOffset + i] = name.codeUnitAt(i);
}
return frame;
}
void main() {
group('Contact.fromFrame path-len decode (#222)', () {
test('direct at 2-byte mode (0x40) -> 0 hops, empty path', () {
final c = Contact.fromFrame(_frame(pathLen: 0x40))!;
expect(c.pathLength, 0); // was 64 pre-#222
expect(c.path, isEmpty); // was 64 junk bytes pre-#222
});
test('plain direct (0x00) -> 0 hops, empty path', () {
final c = Contact.fromFrame(_frame(pathLen: 0x00))!;
expect(c.pathLength, 0);
expect(c.path, isEmpty);
});
test('1-byte-mode 3-byte path keeps its length and bytes', () {
final c = Contact.fromFrame(
_frame(pathLen: 0x03, pathBytes: [0xAA, 0xBB, 0xCC]),
)!;
expect(c.pathLength, 3);
expect(c.path, [0xAA, 0xBB, 0xCC]);
});
test('2-byte-mode multi-hop (0x44) decodes to 4 bytes, not flood', () {
// High bits = mode-1 hint, low bits = 4 bytes = a 2-hop path at 2-byte
// width. Pre-#222 the raw 68 (> maxPathSize) was mis-flagged as flood.
final c = Contact.fromFrame(
_frame(pathLen: 0x44, pathBytes: [1, 2, 3, 4]),
)!;
expect(c.pathLength, 4);
expect(c.path, [1, 2, 3, 4]);
expect(
realHopCount(c.pathLength, 2),
2,
); // 4 bytes / 2-byte width = 2 hops
});
test('flood sentinel (0xFF) stays -1, empty path', () {
final c = Contact.fromFrame(_frame(pathLen: 0xFF))!;
expect(c.pathLength, -1);
expect(c.path, isEmpty);
});
});
}

@ -53,12 +53,16 @@ void main() {
expect(contact!.pathLength, equals(1)); expect(contact!.pathLength, equals(1));
}); });
test('pathLen == 64 (maxPathSize) → pathLength == 64', () { test(
final frame = _buildContactFrame(pathLen: maxPathSize); 'pathLen == 0x40 (2-byte-mode hint + 0 length) → pathLength == 0 (#222)',
final contact = Contact.fromFrame(frame); () {
expect(contact, isNotNull); // High 2 bits = hash-mode hint (2-byte), low 6 bits = 0 direct.
expect(contact!.pathLength, equals(maxPathSize)); final frame = _buildContactFrame(pathLen: 0x40);
}); final contact = Contact.fromFrame(frame);
expect(contact, isNotNull);
expect(contact!.pathLength, equals(0));
},
);
test('pathLen == 0xFF → pathLength == -1 (flood)', () { test('pathLen == 0xFF → pathLength == -1 (flood)', () {
final frame = _buildContactFrame(pathLen: 0xFF); final frame = _buildContactFrame(pathLen: 0xFF);
@ -67,12 +71,16 @@ void main() {
expect(contact!.pathLength, equals(-1)); expect(contact!.pathLength, equals(-1));
}); });
test('pathLen == 65 (over maxPathSize) → pathLength == -1 (flood)', () { test(
final frame = _buildContactFrame(pathLen: 65); 'pathLen == 0x41 (2-byte-mode hint + 1 byte) → pathLength == 1 (#222)',
final contact = Contact.fromFrame(frame); () {
expect(contact, isNotNull); // High 2 bits = hash-mode hint, low 6 bits = 1 1-byte path, not flood.
expect(contact!.pathLength, equals(-1)); final frame = _buildContactFrame(pathLen: 0x41);
}); final contact = Contact.fromFrame(frame);
expect(contact, isNotNull);
expect(contact!.pathLength, equals(1));
},
);
}); });
group('Contact.fromFrame — corrupt contact guards', () { group('Contact.fromFrame — corrupt contact guards', () {

Loading…
Cancel
Save

Powered by TurnKey Linux.