2025-12-26 11:42:02 -07:00
|
|
|
import 'dart:async';
|
|
|
|
|
import '../models/contact.dart';
|
2026-01-02 14:22:39 -07:00
|
|
|
import '../models/path_selection.dart';
|
2025-12-26 11:42:02 -07:00
|
|
|
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,
|
2026-01-02 14:22:39 -07:00
|
|
|
int retries = maxRetries,
|
2025-12-26 11:42:02 -07:00
|
|
|
}) async {
|
|
|
|
|
final repeaterKey = repeater.publicKeyHex;
|
2026-02-04 08:32:35 -08:00
|
|
|
final hasPending = _pendingCommands.keys.any(
|
|
|
|
|
(id) => id.startsWith(repeaterKey),
|
|
|
|
|
);
|
2025-12-26 11:42:02 -07:00
|
|
|
if (hasPending) {
|
|
|
|
|
throw Exception('Another command is still awaiting a response.');
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-02 14:22:39 -07:00
|
|
|
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;
|
2025-12-26 11:42:02 -07:00
|
|
|
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';
|
2026-01-02 14:22:39 -07:00
|
|
|
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,
|
|
|
|
|
);
|
2026-02-04 08:32:35 -08:00
|
|
|
final responseBytes = frame.length > maxFrameSize
|
|
|
|
|
? frame.length
|
|
|
|
|
: maxFrameSize;
|
2026-01-11 17:40:19 -07:00
|
|
|
final timeoutMs = _connector.calculateTimeout(
|
|
|
|
|
pathLength: pathLengthValue,
|
|
|
|
|
messageBytes: responseBytes,
|
|
|
|
|
);
|
|
|
|
|
final timeoutSeconds = (timeoutMs / 1000).ceil();
|
2025-12-26 11:42:02 -07:00
|
|
|
await _connector.sendFrame(frame);
|
2026-01-02 14:22:39 -07:00
|
|
|
_commandTimeouts[commandId]?.cancel();
|
|
|
|
|
_commandTimeouts[commandId] = Timer(
|
|
|
|
|
Duration(milliseconds: timeoutMs),
|
|
|
|
|
() {
|
|
|
|
|
final completer = _pendingCommands[commandId];
|
|
|
|
|
if (completer != null && !completer.isCompleted) {
|
2026-02-04 08:32:35 -08:00
|
|
|
completer.completeError(
|
|
|
|
|
'Command timeout after $timeoutSeconds seconds',
|
|
|
|
|
);
|
2026-01-02 14:22:39 -07:00
|
|
|
_cleanup(commandId);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
2025-12-26 11:42:02 -07:00
|
|
|
} catch (e) {
|
|
|
|
|
_cleanup(commandId);
|
|
|
|
|
throw Exception('Failed to send command: $e');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-02 14:22:39 -07:00
|
|
|
return await completer.future;
|
2025-12-26 11:42:02 -07:00
|
|
|
} 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|';
|
|
|
|
|
}
|
|
|
|
|
}
|