ok
c9179c9bb1a2833eba7120b7adbba2d04e59a2b8
7 files changed
RtRobotSharp/Models/RobotSerialSettings.csRtRobotSharp/Services/RobotSerialService.csRtRobotSharp/appsettings.jsonRtRobotSharp/wwwroot/js/servos.jsRtRobotSharp/wwwroot/js/settings.jsRtRobotSharp/wwwroot/servos.htmlRtRobotSharp/wwwroot/settings.html
diff --git a/RtRobotSharp/Models/RobotSerialSettings.cs b/RtRobotSharp/Models/RobotSerialSettings.cs
index ce998ca..620cbb9 100644
--- a/RtRobotSharp/Models/RobotSerialSettings.cs
+++ b/RtRobotSharp/Models/RobotSerialSettings.cs
@@ -9,5 +9,5 @@ public class RobotSerialSettings
public int SpeedMs { get; set; } = 500;
public int DelayMs { get; set; } = 500;
public int ServoCount { get; set; } = 32;
- public int PostSendSettleDelayMs { get; set; } = 100;
+ public int AckTimeoutMarginMs { get; set; } = 100;
}
diff --git a/RtRobotSharp/Services/RobotSerialService.cs b/RtRobotSharp/Services/RobotSerialService.cs
index 47df744..eb57d3c 100644
--- a/RtRobotSharp/Services/RobotSerialService.cs
+++ b/RtRobotSharp/Services/RobotSerialService.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using System.IO.Ports;
using System.Text;
using System.Threading.Channels;
@@ -9,12 +10,21 @@ public record ServoTarget(int Channel, int Position);
public record LogEntry(DateTimeOffset Timestamp, string Direction, string Text);
+internal record QueuedCommand(string Text, TimeSpan AckTimeout);
+
public class RobotSerialService : IDisposable
{
private static readonly TimeSpan IdleFlushDelay = TimeSpan.FromMilliseconds(150);
- private static readonly TimeSpan AckTimeout = TimeSpan.FromSeconds(2);
private static readonly TimeSpan WriteGuardTimeout = TimeSpan.FromSeconds(2);
+ // Measured on hardware: the controller only replies "OK" once it has actually
+ // executed the move, i.e. after roughly Speed + Delay. Waiting a fixed 2s
+ // regardless of that was needlessly generous - size the timeout to the command
+ // instead, with a margin (Settings.AckTimeoutMarginMs) for normal transport/
+ // processing jitter.
+ private static readonly TimeSpan AckTimeoutMax = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan AckTimeoutDefault = TimeSpan.FromSeconds(2);
+
private readonly Lock gate = new();
private readonly List<Channel<LogEntry>> subscribers = [];
private readonly List<LogEntry> history = [];
@@ -22,7 +32,7 @@ public class RobotSerialService : IDisposable
private readonly Timer idleFlushTimer;
private SerialPort? port;
- private Channel<string>? outgoing;
+ private Channel<QueuedCommand>? outgoing;
private CancellationTokenSource? senderCts;
private TaskCompletionSource<bool>? ackTcs;
@@ -60,7 +70,7 @@ public class RobotSerialService : IDisposable
// Even with proper OK-waiting, queuing every single slider tick overwhelms
// the controller after a burst of commands (confirmed on hardware - it drops
// off the bus). Coalescing to the latest position keeps traffic sane.
- outgoing = Channel.CreateBounded<string>(
+ outgoing = Channel.CreateBounded<QueuedCommand>(
new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest });
senderCts = new CancellationTokenSource();
_ = Task.Run(() => SenderLoopAsync(outgoing.Reader, senderCts.Token));
@@ -214,14 +224,20 @@ public class RobotSerialService : IDisposable
foreach (var t in targets)
sb.Append('#').Append(t.Channel).Append('P').Append(t.Position);
sb.Append('T').Append(speedMs).Append('D').Append(delayMs);
- Send(sb.ToString());
+
+ // The controller replies "OK" only after it has actually executed the move,
+ // i.e. after roughly Speed + Delay - size the wait to that instead of a
+ // generic fixed timeout.
+ var marginMs = Settings.AckTimeoutMarginMs;
+ var timeoutMs = Math.Clamp(speedMs + delayMs + marginMs, marginMs, (int)AckTimeoutMax.TotalMilliseconds);
+ Send(sb.ToString(), TimeSpan.FromMilliseconds(timeoutMs));
}
public void SendRestart()
{
try
{
- Send("~RE");
+ Send("~RE", AckTimeoutDefault);
}
catch (Exception)
{
@@ -236,24 +252,25 @@ public class RobotSerialService : IDisposable
// Enqueues the command; the sender loop writes it once any previously
// queued command has been acknowledged with "OK" (or timed out).
- private void Send(string command)
+ private void Send(string command, TimeSpan ackTimeout)
{
- ChannelWriter<string> writer;
+ ChannelWriter<QueuedCommand> writer;
lock (gate)
{
if (port is not { IsOpen: true } || outgoing is null)
throw new InvalidOperationException("Serial port is not connected.");
writer = outgoing.Writer;
}
- writer.TryWrite(command);
+ writer.TryWrite(new QueuedCommand(command, ackTimeout));
}
- private async Task SenderLoopAsync(ChannelReader<string> reader, CancellationToken token)
+ private async Task SenderLoopAsync(ChannelReader<QueuedCommand> reader, CancellationToken token)
{
try
{
- await foreach (var command in reader.ReadAllAsync(token))
+ await foreach (var queued in reader.ReadAllAsync(token))
{
+ var command = queued.Text;
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
SerialPort? currentPort;
lock (gate)
@@ -282,10 +299,12 @@ public class RobotSerialService : IDisposable
break;
}
+ Stopwatch ackStopwatch;
try
{
await writeTask;
AddLog("TX", command);
+ ackStopwatch = Stopwatch.StartNew();
}
catch (Exception ex)
{
@@ -299,25 +318,24 @@ public class RobotSerialService : IDisposable
break;
}
- var completed = await Task.WhenAny(tcs.Task, Task.Delay(AckTimeout, token));
+ var completed = await Task.WhenAny(tcs.Task, Task.Delay(queued.AckTimeout, token));
+ ackStopwatch.Stop();
if (completed != tcs.Task)
{
// Closing and reopening the port here didn't actually recover the
// interface in practice. Just log the miss and keep going with the
// next queued command instead.
- AddLog("SYS", $"Geen OK ontvangen op '{command}' (timeout)");
+ AddLog("SYS", $"Geen OK ontvangen op '{command}' (timeout na {ackStopwatch.ElapsedMilliseconds}ms, verwacht ~{queued.AckTimeout.TotalMilliseconds}ms)");
+ }
+ else
+ {
+ AddLog("SYS", $"OK ontvangen na {ackStopwatch.ElapsedMilliseconds}ms");
}
lock (gate)
{
if (ackTcs == tcs) ackTcs = null;
}
-
- // A flaky interface needs a moment to recover after a command before it
- // can reliably handle the next one, even once it has replied with OK.
- var settleDelayMs = Settings.PostSendSettleDelayMs;
- if (settleDelayMs > 0)
- await Task.Delay(settleDelayMs, token);
}
}
catch (OperationCanceledException)
diff --git a/RtRobotSharp/appsettings.json b/RtRobotSharp/appsettings.json
index 2633c3f..41545b6 100644
--- a/RtRobotSharp/appsettings.json
+++ b/RtRobotSharp/appsettings.json
@@ -12,6 +12,6 @@
"SpeedMs": 100,
"DelayMs": 200,
"ServoCount": 32,
- "PostSendSettleDelayMs": 1
+ "AckTimeoutMarginMs": 50
}
}
\ No newline at end of file
diff --git a/RtRobotSharp/wwwroot/js/servos.js b/RtRobotSharp/wwwroot/js/servos.js
index af9aa94..36a7390 100644
--- a/RtRobotSharp/wwwroot/js/servos.js
+++ b/RtRobotSharp/wwwroot/js/servos.js
@@ -39,8 +39,8 @@ function buildGrid()
{
const ch = range.dataset.ch;
grid.querySelector(`input[data-role=num][data-ch="${ch}"]`).value = range.value;
- grid.querySelector(`input[data-role=sel][data-ch="${ch}"]`).checked = true;
- sendTargets([{ channel: Number(ch), position: Number(range.value) }]);
+ if (grid.querySelector(`input[data-role=sel][data-ch="${ch}"]`).checked)
+ sendTargets([{ channel: Number(ch), position: Number(range.value) }]);
});
});
grid.querySelectorAll('input[data-role=num]').forEach(num =>
@@ -51,7 +51,8 @@ function buildGrid()
let v = Math.min(MAX, Math.max(MIN, Number(num.value) || DEFAULT));
num.value = v;
grid.querySelector(`input[data-role=range][data-ch="${ch}"]`).value = v;
- grid.querySelector(`input[data-role=sel][data-ch="${ch}"]`).checked = true;
+ if (grid.querySelector(`input[data-role=sel][data-ch="${ch}"]`).checked)
+ sendTargets([{ channel: Number(ch), position: v }]);
});
});
}
@@ -94,7 +95,7 @@ async function sendTargets(targets)
}
document.getElementById('sendSelectedBtn').addEventListener('click', () => sendTargets(collectTargets(true)));
-document.getElementById('sendAllBtn').addEventListener('click', () => sendTargets(collectTargets(false)));
+document.getElementById('sendAllBtn').addEventListener('click', () => sendTargets(collectTargets(true)));
document.getElementById('selectAllBtn').addEventListener('click', () =>
{
grid.querySelectorAll('input[data-role=sel]').forEach(c => c.checked = true);
@@ -104,6 +105,67 @@ document.getElementById('selectNoneBtn').addEventListener('click', () =>
grid.querySelectorAll('input[data-role=sel]').forEach(c => c.checked = false);
});
+const demoBtn = document.getElementById('demoBtn');
+let demoTimer = null;
+
+const DEMO_SKIP_CHANCE = 0.2; // per servo, per tick - keeps it from always moving everything at once
+
+function randomPosition()
+{
+ return Math.floor(MIN + Math.random() * (MAX - MIN + 1));
+}
+
+function demoTick()
+{
+ const targets = [];
+ const selected = [];
+ for (let ch = 1; ch <= servoCount; ch++)
+ {
+ const sel = grid.querySelector(`input[data-role=sel][data-ch="${ch}"]`);
+ if (sel.checked) selected.push(ch);
+ }
+
+ for (const ch of selected)
+ {
+ if (Math.random() < DEMO_SKIP_CHANCE) continue;
+ const v = randomPosition();
+ grid.querySelector(`input[data-role=num][data-ch="${ch}"]`).value = v;
+ grid.querySelector(`input[data-role=range][data-ch="${ch}"]`).value = v;
+ targets.push({ channel: ch, position: v });
+ }
+
+ if (selected.length === 0)
+ {
+ setMsg('Demo gestopt: geen servo geselecteerd', true);
+ demoTimer = null;
+ demoBtn.textContent = 'Demo';
+ demoBtn.classList.remove('danger');
+ return;
+ }
+
+ if (targets.length > 0) sendTargets(targets);
+
+ const intervalMs = Number(speedInput.value) + Number(delayInput.value) + 200;
+ demoTimer = setTimeout(demoTick, intervalMs);
+}
+
+demoBtn.addEventListener('click', () =>
+{
+ if (demoTimer)
+ {
+ clearTimeout(demoTimer);
+ demoTimer = null;
+ demoBtn.textContent = 'Demo';
+ demoBtn.classList.remove('danger');
+ setMsg('Demo gestopt');
+ return;
+ }
+
+ demoBtn.textContent = 'Stop demo';
+ demoBtn.classList.add('danger');
+ demoTick();
+});
+
(async function init()
{
try
diff --git a/RtRobotSharp/wwwroot/js/settings.js b/RtRobotSharp/wwwroot/js/settings.js
index 3170e7f..6a35154 100644
--- a/RtRobotSharp/wwwroot/js/settings.js
+++ b/RtRobotSharp/wwwroot/js/settings.js
@@ -6,7 +6,7 @@ const baudSelect = document.getElementById('baudRate');
const speedInput = document.getElementById('speedMs');
const delayInput = document.getElementById('delayMs');
const countInput = document.getElementById('servoCount');
-const settleDelayInput = document.getElementById('postSendSettleDelayMs');
+const ackMarginInput = document.getElementById('ackTimeoutMarginMs');
const connectBtn = document.getElementById('connectBtn');
const disconnectBtn = document.getElementById('disconnectBtn');
@@ -51,7 +51,7 @@ async function loadSettings()
speedInput.value = s.speedMs;
delayInput.value = s.delayMs;
countInput.value = s.servoCount;
- settleDelayInput.value = s.postSendSettleDelayMs;
+ ackMarginInput.value = s.ackTimeoutMarginMs;
}
async function saveSettings()
@@ -64,7 +64,7 @@ async function saveSettings()
speedMs: Number(speedInput.value),
delayMs: Number(delayInput.value),
servoCount: Number(countInput.value),
- postSendSettleDelayMs: Number(settleDelayInput.value),
+ ackTimeoutMarginMs: Number(ackMarginInput.value),
}),
});
}
diff --git a/RtRobotSharp/wwwroot/servos.html b/RtRobotSharp/wwwroot/servos.html
index f3e7b14..7f936e4 100644
--- a/RtRobotSharp/wwwroot/servos.html
+++ b/RtRobotSharp/wwwroot/servos.html
@@ -30,6 +30,7 @@
<button id="sendAllBtn" class="secondary">Verstuur alle</button>
<button id="selectAllBtn" class="secondary">Selecteer alles</button>
<button id="selectNoneBtn" class="secondary">Deselecteer alles</button>
+ <button id="demoBtn" class="secondary">Demo</button>
</div>
<div id="msg"></div>
</div>
diff --git a/RtRobotSharp/wwwroot/settings.html b/RtRobotSharp/wwwroot/settings.html
index 0e0c52f..b62fab5 100644
--- a/RtRobotSharp/wwwroot/settings.html
+++ b/RtRobotSharp/wwwroot/settings.html
@@ -45,8 +45,8 @@
<input id="servoCount" type="number" min="1" max="32" />
</div>
<div>
- <label for="postSendSettleDelayMs">Settle delay na OK (ms)</label>
- <input id="postSendSettleDelayMs" type="number" min="0" max="5000" />
+ <label for="ackTimeoutMarginMs">Ack timeout marge (ms)</label>
+ <input id="ackTimeoutMarginMs" type="number" min="0" max="5000" />
</div>
</div>