meshcore-open/lib/services/repeater_command_service.dart
446564 b34d684e67 format dart files
formats all dart files using `dart format .` from the root project dir

this makes the code style repeatable by new contributors and makes PR review easier
2026-02-04 08:32:35 -08:00

180 lines
5.4 KiB
Dart

import 'dart:async';
import '../models/contact.dart';
import '../models/path_selection.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
class RepeaterCommandService {
final MeshCoreConnector _connector;
final Map<String, Completer<String>> _pendingCommands = {};
final Map<String, Timer> _commandTimeouts = {};
final Map<String, String> _commandPrefixes = {};
final Map<String, String> _pendingByPrefix = {};
int _prefixCounter = 0;
static const int maxRetries = 5;
RepeaterCommandService(this._connector);
/// Send a CLI command to a repeater with automatic retries
/// Returns a future that completes when a response is received or after max retries
Future<String> sendCommand(
Contact repeater,
String command, {
Function(String)? onResponse,
Function(int)? onAttempt,
int retries = maxRetries,
}) async {
final repeaterKey = repeater.publicKeyHex;
final hasPending = _pendingCommands.keys.any(
(id) => id.startsWith(repeaterKey),
);
if (hasPending) {
throw Exception('Another command is still awaiting a response.');
}
final attemptCount = retries < 1 ? 1 : retries;
final selection = await _connector.preparePathForContactSend(repeater);
for (int attempt = 0; attempt < attemptCount; attempt++) {
onAttempt?.call(attempt + 1);
try {
final response = await _sendCommandAttempt(
repeater,
command,
selection,
attempt,
);
onResponse?.call(response);
return response;
} catch (e) {
if (attempt == attemptCount - 1) rethrow;
}
}
throw Exception('Command failed after $attemptCount attempts');
}
Future<String> _sendCommandAttempt(
Contact repeater,
String command,
PathSelection selection,
int attempt,
) async {
final repeaterKey = repeater.publicKeyHex;
final commandId = '${repeaterKey}_${DateTime.now().millisecondsSinceEpoch}';
final completer = Completer<String>();
_pendingCommands[commandId] = completer;
try {
final prefix = _nextPrefixToken();
_commandPrefixes[commandId] = prefix;
_pendingByPrefix[prefix] = commandId;
final framedCommand = '$prefix$command';
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
final timestampSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
_connector.trackRepeaterAck(
contact: repeater,
selection: selection,
text: framedCommand,
timestampSeconds: timestampSeconds,
attempt: attempt,
);
final frame = buildSendCliCommandFrame(
repeater.publicKey,
framedCommand,
attempt: attempt,
timestampSeconds: timestampSeconds,
);
final responseBytes = frame.length > maxFrameSize
? frame.length
: maxFrameSize;
final timeoutMs = _connector.calculateTimeout(
pathLength: pathLengthValue,
messageBytes: responseBytes,
);
final timeoutSeconds = (timeoutMs / 1000).ceil();
await _connector.sendFrame(frame);
_commandTimeouts[commandId]?.cancel();
_commandTimeouts[commandId] = Timer(
Duration(milliseconds: timeoutMs),
() {
final completer = _pendingCommands[commandId];
if (completer != null && !completer.isCompleted) {
completer.completeError(
'Command timeout after $timeoutSeconds seconds',
);
_cleanup(commandId);
}
},
);
} catch (e) {
_cleanup(commandId);
throw Exception('Failed to send command: $e');
}
try {
return await completer.future;
} finally {
_cleanup(commandId);
}
}
/// Call this when a text message response is received from a repeater
void handleResponse(Contact repeater, String responseText) {
// Find pending command for this repeater and complete it
final repeaterKey = repeater.publicKeyHex;
String? commandId;
String responsePayload = responseText;
if (responseText.length >= 3 && responseText[2] == '|') {
final prefix = responseText.substring(0, 3);
commandId = _pendingByPrefix[prefix];
responsePayload = responseText.substring(3).trimLeft();
}
commandId ??= _pendingCommands.keys.firstWhere(
(id) => id.startsWith(repeaterKey),
orElse: () => '',
);
if (commandId.isEmpty) return;
final completer = _pendingCommands[commandId];
if (completer != null && !completer.isCompleted) {
completer.complete(responsePayload);
_cleanup(commandId);
}
}
void _cleanup(String commandId) {
_commandTimeouts[commandId]?.cancel();
_commandTimeouts.remove(commandId);
_pendingCommands.remove(commandId);
final prefix = _commandPrefixes.remove(commandId);
if (prefix != null) {
_pendingByPrefix.remove(prefix);
}
}
void dispose() {
for (final timer in _commandTimeouts.values) {
timer.cancel();
}
_commandTimeouts.clear();
_pendingCommands.clear();
_commandPrefixes.clear();
_pendingByPrefix.clear();
}
String _nextPrefixToken() {
for (var i = 0; i < 256; i++) {
final value = _prefixCounter++ & 0xFF;
final token = '${value.toRadixString(16).padLeft(2, '0').toUpperCase()}|';
if (!_pendingByPrefix.containsKey(token)) {
return token;
}
}
return '00|';
}
}