Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1373,7 +1373,10 @@ the app shows as "This region does not support claiming yet."
once per BLE connection and cached on the `MeshCoreConnection`, and every later
`getContacts` sends the firmware's `since` filter (the newest `lastmod` from the
END_OF_CONTACTS frame) and merges what changed, so the 52 KB stream happens once, not per
login; a learned route bumps `lastmod` so it syncs, `resetPath` does not so the cached
login; the read times out on 5 s of silence (120 s overall), never a fixed total, because
a 350-contact list takes about 21 s and a fixed 20 s timeout cut it off on every try, and that
timeout reads "Your radio didn't finish sending its contact list", never the password
sentence; a learned route bumps `lastmod` so it syncs, `resetPath` does not so the cached
record is patched, `addContact` inserts its record, and the cache dies with the connection
object (auto-reconnect builds a new one); `ERR 3` there is "Your radio's
contact list is full."). Step 2 logs in (`login`, `CMD_SEND_LOGIN` with the raw UTF-8
Expand Down
32 changes: 30 additions & 2 deletions lib/services/meshcore/connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,9 @@ class MeshCoreConnection {
Timer? _adminLateResponseTimer;
Completer<List<ContactRecord>>? _contactsCompleter;
List<ContactRecord> _contactsBuffer = [];
// Re-arms the running getContacts call's silence timer. Called on every
// CONTACTS_START and CONTACT frame, so a long list keeps its read alive.
void Function()? _contactsProgress;

/// The radio's contact list as this connection last saw it, keyed by the
/// full public key hex, in the radio's order. Lives exactly as long as
Expand Down Expand Up @@ -1288,12 +1291,14 @@ class MeshCoreConnection {
break;
}
_contactsBuffer = [];
_contactsProgress?.call();
final count =
reader.remainingBytesCount >= 4 ? reader.readUInt32LE() : -1;
debugLog('[CONN] Contact list starting ($count contacts)');
break;
case ResponseCodes.contact:
if (_contactsCompleter == null) break;
_contactsProgress?.call();
try {
_contactsBuffer.add(ContactRecord.parse(reader));
} on FormatException catch (e) {
Expand Down Expand Up @@ -2983,10 +2988,29 @@ class MeshCoreConnection {
}

/// Read the radio's whole contact list (CMD_GET_CONTACTS with since = 0).
Future<List<ContactRecord>> getContacts(
{Duration timeout = const Duration(seconds: 20)}) async {
///
/// The wait follows the stream rather than a fixed total: it times out
/// after [silenceTimeout] with no frame of the list, or at [timeout]
/// overall. The radio streams about one contact per 60 ms, so a 350 entry
/// table takes over 21 s, and a fixed 20 s cut it off every time.
Future<List<ContactRecord>> getContacts({
Duration silenceTimeout = const Duration(seconds: 5),
Duration timeout = const Duration(seconds: 120),
}) async {
final token = _beginAdminCommand('getContacts');
final completer = Completer<List<ContactRecord>>();
Timer? silence;
void armSilence() {
silence?.cancel();
silence = Timer(silenceTimeout, () {
if (completer.isCompleted) return;
debugWarn('[CONN] getContacts: radio silent for '
'${silenceTimeout.inMilliseconds}ms '
'(${_contactsBuffer.length} contacts received)');
completer.completeError(TimeoutException('getContacts timed out'));
});
}

try {
await _drainPollsForAdminCommand('getContacts');
_contactsCompleter = completer;
Expand All @@ -2997,12 +3021,16 @@ class MeshCoreConnection {
..writeByte(CommandCodes.getContacts)
..writeUInt32LE(since);
await _sendToRadio(data);
_contactsProgress = armSilence;
armSilence();
return await completer.future.timeout(timeout, onTimeout: () {
_contactsCompleter = null;
_contactsBuffer = [];
throw TimeoutException('getContacts timed out');
});
} finally {
silence?.cancel();
if (identical(_contactsProgress, armSilence)) _contactsProgress = null;
// A write that threw before completer.future was ever awaited leaves
// this call's completer registered with no listener. Clear it here
// (only if it is still the one this call installed - a later call may
Expand Down
13 changes: 12 additions & 1 deletion lib/services/repeater_admin/repeater_admin_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const String kNeighboursUnansweredSentence =
"This repeater's firmware cannot report neighbours.";
const String kNeighboursTimeoutSentence =
'No reply from the repeater. Try again.';
const String kContactListTimeoutSentence =
"Your radio didn't finish sending its contact list. Try again.";
const String kAdminCommandPendingSentence =
'The radio is still finishing the previous request. '
'Wait for its reply, or close Manage and try again.';
Expand Down Expand Up @@ -531,13 +533,22 @@ class RepeaterAdminSession extends ChangeNotifier {
}

void _fail(String message) {
debugLog('[RADMIN] Failed: $message');
_lastError = message;
_state = RepeaterAdminState.failed;
_notify();
}

Future<void> _ensureContact() async {
final contacts = await _connection.getContacts();
final List<ContactRecord> contacts;
try {
contacts = await _connection.getContacts();
} on TimeoutException {
// A local read, not the mesh: it must not read as a wrong password or
// count towards the automatic route reset.
debugWarn('[RADMIN] Contact list read timed out');
throw const RepeaterAdminFailure(kContactListTimeoutSentence);
}
final existing = _findContact(contacts);
if (existing != null) {
// Leave it alone: an add would wipe the learned route.
Expand Down
57 changes: 57 additions & 0 deletions test/services/meshcore/repeater_admin_frames_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,63 @@ void main() {
expect((await second).single.publicKeyHex, '09' * 32);
});

test('a list that streams longer than the silence window still completes',
() async {
// The radio streams about one contact per 60 ms, so a 350 entry table
// runs past any fixed total. Each frame keeps the read alive.
final future = connection.getContacts(
silenceTimeout: const Duration(milliseconds: 80),
timeout: const Duration(seconds: 5));
await transport.settle();
transport.emit([ResponseCodes.contactsStart, 8, 0, 0, 0]);
for (var i = 1; i <= 8; i++) {
await Future<void>.delayed(const Duration(milliseconds: 40));
transport.emit(
[ResponseCodes.contact, ...contactPayload(pubkey: key(i))]);
}
await Future<void>.delayed(const Duration(milliseconds: 40));
transport.emit([ResponseCodes.endOfContacts, 0, 0, 0, 0]);
expect((await future).length, 8);
});

test('a stream that goes quiet times out after the silence window',
() async {
final future = connection.getContacts(
silenceTimeout: const Duration(milliseconds: 80),
timeout: const Duration(seconds: 5));
await transport.settle();
transport.emit([ResponseCodes.contactsStart, 2, 0, 0, 0]);
transport.emit(
[ResponseCodes.contact, ...contactPayload(pubkey: key(1))]);
await expectLater(future, throwsA(isA<TimeoutException>()));
expect(connection.hasPendingAdminCommand, isFalse);
});

test('no reply at all times out after the silence window', () async {
final future = connection.getContacts(
silenceTimeout: const Duration(milliseconds: 80),
timeout: const Duration(seconds: 5));
await expectLater(future, throwsA(isA<TimeoutException>()));
});

test('a stream that never ends hits the overall ceiling', () async {
final future = connection.getContacts(
silenceTimeout: const Duration(milliseconds: 80),
timeout: const Duration(milliseconds: 300));
await transport.settle();
transport.emit([ResponseCodes.contactsStart, 99, 0, 0, 0]);
var done = false;
unawaited(future.then((_) {}, onError: (_) {}).whenComplete(() {
done = true;
}));
for (var i = 1; i <= 20 && !done; i++) {
await Future<void>.delayed(const Duration(milliseconds: 30));
transport.emit(
[ResponseCodes.contact, ...contactPayload(pubkey: key(i))]);
}
await expectLater(future, throwsA(isA<TimeoutException>()));
});

test('ERR while iterating fails with the radio code', () async {
final future = connection.getContacts();
await transport.settle();
Expand Down
16 changes: 16 additions & 0 deletions test/services/repeater_admin/repeater_admin_session_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,22 @@ void main() {
expect(session.busy, isFalse);
});

test('a contact list that never finishes is not a password problem',
() async {
final future = session.login('admin-pw');
await transport.settle();
expect(commands(), [CommandCodes.getContacts]);
transport.emit([ResponseCodes.contactsStart, 2, 0, 0, 0]);
transport.emit([ResponseCodes.contact, ...contactPayload(otherKey)]);
// The radio goes quiet before END_OF_CONTACTS: the silence window
// (5 s by default) runs out and no login is ever sent.
expect(await future, isFalse);
expect(session.state, RepeaterAdminState.failed);
expect(session.lastError, kContactListTimeoutSentence);
expect(commands(), [CommandCodes.getContacts]);
expect(session.busy, isFalse);
}, timeout: const Timeout(Duration(seconds: 15)));

test('a zero-hop contact reads as a direct route', () async {
final future = session.login('admin-pw');
await answerContacts([contactPayload(repeaterKey, outPathLen: 0x80)]);
Expand Down
Loading