diff --git a/app/src/main/java/com/grigowashere/loratester/CommandPoller.java b/app/src/main/java/com/grigowashere/loratester/CommandPoller.java index a7d48ca..c25b60f 100644 --- a/app/src/main/java/com/grigowashere/loratester/CommandPoller.java +++ b/app/src/main/java/com/grigowashere/loratester/CommandPoller.java @@ -136,26 +136,34 @@ public class CommandPoller { } switch (cmd.kind) { case "at" -> { + if (uploader.shouldSuppressRemoteAt()) { + Log.i(TAG, "skip remote AT: local radio control active"); + break; + } List lines = extractLines(cmd.payload); if (lines != null && !lines.isEmpty()) { uploader.sendMacroSequence(lines, r -> - Log.i(TAG, "remote macro " + lines + " -> " + r)); + Log.i(TAG, "remote macro " + lines + " -> " + r), true); } else { String line = cmd.payload != null && cmd.payload.get("line") != null ? String.valueOf(cmd.payload.get("line")) : null; if (line != null) { uploader.sendAtCommand(line, r -> - Log.i(TAG, "remote AT " + line + " -> " + r)); + Log.i(TAG, "remote AT " + line + " -> " + r), true); } } } case "mode" -> { + if (uploader.shouldSuppressRemoteAt()) { + Log.i(TAG, "skip remote mode: local radio control active"); + break; + } String role = cmd.payload != null && cmd.payload.get("role") != null ? String.valueOf(cmd.payload.get("role")) : null; if ("TX".equalsIgnoreCase(role)) { - uploader.sendAtCommand(AtCommands.TRANSMIT, r -> {}); + uploader.sendAtCommand(AtCommands.TRANSMIT, r -> {}, true); } else if ("RX".equalsIgnoreCase(role)) { - uploader.sendAtCommand(AtCommands.RECEIVE, r -> {}); + uploader.sendAtCommand(AtCommands.RECEIVE, r -> {}, true); } } case "stats_push" -> peerStatsCache.updateFromPayload(cmd.payload); diff --git a/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java b/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java index 47fadb0..7cebeec 100644 --- a/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java +++ b/app/src/main/java/com/grigowashere/loratester/TelemetryUploader.java @@ -36,6 +36,8 @@ public class TelemetryUploader implements RadioTransport.Listener { private static final String TAG = "TelemetryUploader"; private static final long RESTART_DEBOUNCE_MS = 1500; + /** Ignore web/peer AT macros briefly after local Apply to avoid power/param fighting. */ + private static final long REMOTE_AT_SUPPRESS_MS = 8000; public interface AtSendCallback { void onResult(RadioTransport.SendResult result); @@ -84,6 +86,7 @@ public class TelemetryUploader implements RadioTransport.Listener { private final UploadQueue uploadQueue; private final NetworkMonitor networkMonitor; private volatile long lastRadioRestartAtMs; + private volatile long suppressRemoteAtUntilMs; public TelemetryUploader( Context context, @@ -237,7 +240,14 @@ public class TelemetryUploader implements RadioTransport.Listener { } public void sendAtCommand(String command, AtSendCallback callback) { + sendAtCommand(command, callback, false); + } + + public void sendAtCommand(String command, AtSendCallback callback, boolean fromRemote) { radioExecutor.execute(() -> { + if (!fromRemote) { + noteLocalRadioControl(); + } RadioTransport.SendResult result = sendAtCommandOnWorker(command); if (callback != null) { mainHandler.post(() -> callback.onResult(result)); @@ -246,7 +256,14 @@ public class TelemetryUploader implements RadioTransport.Listener { } public void sendMacroSequence(List lines, AtSendCallback callback) { + sendMacroSequence(lines, callback, false); + } + + public void sendMacroSequence(List lines, AtSendCallback callback, boolean fromRemote) { radioExecutor.execute(() -> { + if (!fromRemote) { + noteLocalRadioControl(); + } RadioTransport.SendResult last = RadioTransport.SendResult.SENT; if (lines != null) { for (int i = 0; i < lines.size(); i++) { @@ -275,6 +292,15 @@ public class TelemetryUploader implements RadioTransport.Listener { }); } + /** Call after local UI Apply so stale web macros do not fight for a few seconds. */ + public void noteLocalRadioControl() { + suppressRemoteAtUntilMs = SystemClock.elapsedRealtime() + REMOTE_AT_SUPPRESS_MS; + } + + public boolean shouldSuppressRemoteAt() { + return SystemClock.elapsedRealtime() < suppressRemoteAtUntilMs; + } + private RadioTransport.SendResult sendLineOnWorker(String line) { if (RadioMacroBuilder.STOP.equals(line)) { appendConsole(">> S\n"); @@ -307,6 +333,7 @@ public class TelemetryUploader implements RadioTransport.Listener { public void sendRawLine(String line, AtSendCallback callback) { radioExecutor.execute(() -> { + noteLocalRadioControl(); String trimmed = line != null ? line.trim() : ""; if (trimmed.isEmpty()) { if (callback != null) { diff --git a/server/static/app.js b/server/static/app.js index b424436..450b2e4 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -38,8 +38,13 @@ let mapInitialFitDone = false; let userMovedMap = false; let programmaticMove = false; let cmdFormDirty = false; +let cmdFormHoldUntil = 0; -const CMD_INPUT_IDS = ['cmdModemMode', 'cmdFq', 'cmdPw', 'cmdSf', 'cmdPl', 'cmdBw', 'cmdCr', 'cmdBr', 'cmdFd', 'cmdTm', 'cmdRole']; +const CMD_INPUT_IDS = [ + 'cmdModemMode', 'cmdFq', 'cmdPw', 'cmdSf', 'cmdPl', 'cmdBw', 'cmdCr', + 'cmdBr', 'cmdFd', 'cmdGd', 'cmdTm', 'cmdRole' +]; +const CMD_FORM_HOLD_MS = 10000; let trackTxLayers = []; let trackRxLayers = []; @@ -552,8 +557,30 @@ function renderTimelineCompare(txTel, rxTel, txId, rxId) { function setCmdFormDirty(dirty) { cmdFormDirty = dirty; + refreshCmdFormHint(); +} + +function setCmdFormHold(ms = CMD_FORM_HOLD_MS) { + cmdFormHoldUntil = Date.now() + ms; + refreshCmdFormHint(); +} + +function isCmdFormHeld() { + return Date.now() < cmdFormHoldUntil; +} + +function refreshCmdFormHint() { const hint = document.getElementById('cmdDraftHint'); - if (hint) hint.style.display = dirty ? 'block' : 'none'; + if (!hint) return; + if (cmdFormDirty) { + hint.style.display = 'block'; + hint.textContent = 'Черновик — значения с устройства не подставляются'; + } else if (isCmdFormHeld()) { + hint.style.display = 'block'; + hint.textContent = 'Ожидание подтверждения с устройства…'; + } else { + hint.style.display = 'none'; + } } function setupCmdFormDirtyTracking() { @@ -647,7 +674,8 @@ function fillCmdFormFromDevice(d, opts = {}) { if (!d) return; const force = opts.force === true; refreshCmdDeviceStatus(d); - if (!force && cmdFormDirty) return; + refreshCmdFormHint(); + if (!force && (cmdFormDirty || isCmdFormHeld())) return; fillCmdInputsFromDevice(d); } @@ -913,6 +941,8 @@ async function applySelectedMacroPreset() { }); if (!ok) return; appliedMacroPresetId = preset.id; + setCmdFormDirty(false); + setCmdFormHold(); renderMacroPresetList(); if (status) status.textContent = `Применён: ${preset.name || preset.id}`; } @@ -4571,6 +4601,7 @@ async function postCommand(toDeviceId, kind, payload) { document.getElementById('btnCmdApply').onclick = () => { postCommand(document.getElementById('cmdTargetSelect').value, 'at', { lines: buildMacroLines() }); setCmdFormDirty(false); + setCmdFormHold(); }; document.getElementById('btnCmdApplySymmetric')?.addEventListener('click', async () => { const params = buildMacroParams(); @@ -4585,7 +4616,10 @@ document.getElementById('btnCmdApplySymmetric')?.addEventListener('click', async const rxOk = await postCommand(rx.device_id, 'at', { lines: macroLinesFromParams({ ...params, role: 'RX' }) }); - if (txOk && rxOk) setCmdFormDirty(false); + if (txOk && rxOk) { + setCmdFormDirty(false); + setCmdFormHold(); + } }); document.getElementById('btnMacroPresetApply')?.addEventListener('click', () => { applySelectedMacroPreset().catch(e => alert(e.message || String(e)));