mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
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
180 lines
5.4 KiB
Dart
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|';
|
|
}
|
|
}
|