Add project files.
6097223fdfabd6a57e17688a7c385733537d86bf
20 files changed
RtRobotSharp.slnxRtRobotSharp/Controllers/LogController.csRtRobotSharp/Controllers/SerialController.csRtRobotSharp/Controllers/ServoController.csRtRobotSharp/Controllers/SettingsController.csRtRobotSharp/Models/RobotSerialSettings.csRtRobotSharp/Program.csRtRobotSharp/Properties/launchSettings.jsonRtRobotSharp/RtRobotSharp.csprojRtRobotSharp/Services/RobotSerialService.csRtRobotSharp/appsettings.Development.jsonRtRobotSharp/appsettings.jsonRtRobotSharp/doc/manual_english.pdfRtRobotSharp/wwwroot/css/site.cssRtRobotSharp/wwwroot/index.htmlRtRobotSharp/wwwroot/js/common.jsRtRobotSharp/wwwroot/js/servos.jsRtRobotSharp/wwwroot/js/settings.jsRtRobotSharp/wwwroot/servos.htmlRtRobotSharp/wwwroot/settings.html
diff --git a/RtRobotSharp.slnx b/RtRobotSharp.slnx
new file mode 100644
index 0000000..213c3d5
--- /dev/null
+++ b/RtRobotSharp.slnx
@@ -0,0 +1,3 @@
+<Solution>
+ <Project Path="RtRobotSharp/RtRobotSharp.csproj" />
+</Solution>
diff --git a/RtRobotSharp/Controllers/LogController.cs b/RtRobotSharp/Controllers/LogController.cs
new file mode 100644
index 0000000..44598d7
--- /dev/null
+++ b/RtRobotSharp/Controllers/LogController.cs
@@ -0,0 +1,40 @@
+using Microsoft.AspNetCore.Mvc;
+using System.Text.Json;
+using RtRobotSharp.Services;
+
+namespace RtRobotSharp.Controllers;
+
+[ApiController]
+[Route("api/log")]
+public class LogController(RobotSerialService svc) : ControllerBase
+{
+ private static readonly JsonSerializerOptions SseOptions = new(JsonSerializerDefaults.Web);
+
+ [HttpGet("history")]
+ public ActionResult<IEnumerable<LogEntry>> History() => Ok(svc.GetHistory());
+
+ [HttpGet("stream")]
+ public async Task Stream(CancellationToken cancellationToken)
+ {
+ Response.Headers.ContentType = "text/event-stream";
+ Response.Headers.CacheControl = "no-cache";
+
+ var reader = svc.Subscribe();
+ try
+ {
+ await foreach (var entry in reader.ReadAllAsync(cancellationToken))
+ {
+ var json = JsonSerializer.Serialize(entry, SseOptions);
+ await Response.WriteAsync($"data: {json}\n\n", cancellationToken);
+ await Response.Body.FlushAsync(cancellationToken);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ finally
+ {
+ svc.Unsubscribe(reader);
+ }
+ }
+}
diff --git a/RtRobotSharp/Controllers/SerialController.cs b/RtRobotSharp/Controllers/SerialController.cs
new file mode 100644
index 0000000..e8b99c7
--- /dev/null
+++ b/RtRobotSharp/Controllers/SerialController.cs
@@ -0,0 +1,55 @@
+using Microsoft.AspNetCore.Mvc;
+using RtRobotSharp.Services;
+
+namespace RtRobotSharp.Controllers;
+
+[ApiController]
+[Route("api/serial")]
+public class SerialController(RobotSerialService svc) : ControllerBase
+{
+ [HttpGet("ports")]
+ public ActionResult<IEnumerable<string>> Ports() => Ok(svc.GetAvailablePorts());
+
+ [HttpGet("status")]
+ public ActionResult Status() => Ok(new
+ {
+ connected = svc.IsConnected,
+ portName = svc.Settings.PortName,
+ baudRate = svc.Settings.BaudRate
+ });
+
+ [HttpPost("connect")]
+ public ActionResult Connect()
+ {
+ try
+ {
+ svc.Connect();
+ return Ok(new { connected = svc.IsConnected });
+ }
+ catch (Exception ex)
+ {
+ return Problem(ex.Message, statusCode: StatusCodes.Status400BadRequest);
+ }
+ }
+
+ [HttpPost("disconnect")]
+ public ActionResult Disconnect()
+ {
+ svc.Disconnect();
+ return Ok(new { connected = svc.IsConnected });
+ }
+
+ [HttpPost("restart")]
+ public ActionResult Restart()
+ {
+ try
+ {
+ svc.SendRestart();
+ return Ok(new { connected = svc.IsConnected });
+ }
+ catch (Exception ex)
+ {
+ return Problem(ex.Message, statusCode: StatusCodes.Status400BadRequest);
+ }
+ }
+}
diff --git a/RtRobotSharp/Controllers/ServoController.cs b/RtRobotSharp/Controllers/ServoController.cs
new file mode 100644
index 0000000..4561a97
--- /dev/null
+++ b/RtRobotSharp/Controllers/ServoController.cs
@@ -0,0 +1,28 @@
+using Microsoft.AspNetCore.Mvc;
+using RtRobotSharp.Services;
+
+namespace RtRobotSharp.Controllers;
+
+public record ServoTargetDto(int Channel, int Position);
+public record ServoMoveRequest(List<ServoTargetDto> Targets, int? SpeedMs, int? DelayMs);
+
+[ApiController]
+[Route("api/servo")]
+public class ServoController(RobotSerialService svc) : ControllerBase
+{
+ [HttpPost("move")]
+ public ActionResult Move([FromBody] ServoMoveRequest request)
+ {
+ try
+ {
+ var speed = request.SpeedMs ?? svc.Settings.SpeedMs;
+ var delay = request.DelayMs ?? svc.Settings.DelayMs;
+ svc.SendServoCommand(request.Targets.Select(t => new ServoTarget(t.Channel, t.Position)), speed, delay);
+ return Ok();
+ }
+ catch (Exception ex)
+ {
+ return Problem(ex.Message, statusCode: StatusCodes.Status400BadRequest);
+ }
+ }
+}
diff --git a/RtRobotSharp/Controllers/SettingsController.cs b/RtRobotSharp/Controllers/SettingsController.cs
new file mode 100644
index 0000000..1853d95
--- /dev/null
+++ b/RtRobotSharp/Controllers/SettingsController.cs
@@ -0,0 +1,29 @@
+using Microsoft.AspNetCore.Mvc;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using RtRobotSharp.Models;
+using RtRobotSharp.Services;
+
+namespace RtRobotSharp.Controllers;
+
+[ApiController]
+[Route("api/settings")]
+public class SettingsController(RobotSerialService svc, IWebHostEnvironment env) : ControllerBase
+{
+ [HttpGet]
+ public ActionResult<RobotSerialSettings> Get() => Ok(svc.Settings);
+
+ [HttpPut]
+ public async Task<ActionResult<RobotSerialSettings>> Update([FromBody] RobotSerialSettings input)
+ {
+ svc.UpdateSettings(input);
+
+ var path = Path.Combine(env.ContentRootPath, "appsettings.json");
+ var json = await System.IO.File.ReadAllTextAsync(path);
+ var root = JsonNode.Parse(json)!.AsObject();
+ root[RobotSerialSettings.SectionName] = JsonSerializer.SerializeToNode(input);
+ await System.IO.File.WriteAllTextAsync(path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
+
+ return Ok(svc.Settings);
+ }
+}
diff --git a/RtRobotSharp/Models/RobotSerialSettings.cs b/RtRobotSharp/Models/RobotSerialSettings.cs
new file mode 100644
index 0000000..ce998ca
--- /dev/null
+++ b/RtRobotSharp/Models/RobotSerialSettings.cs
@@ -0,0 +1,13 @@
+namespace RtRobotSharp.Models;
+
+public class RobotSerialSettings
+{
+ public const string SectionName = "RobotSerial";
+
+ public string PortName { get; set; } = "COM6";
+ public int BaudRate { get; set; } = 115200;
+ public int SpeedMs { get; set; } = 500;
+ public int DelayMs { get; set; } = 500;
+ public int ServoCount { get; set; } = 32;
+ public int PostSendSettleDelayMs { get; set; } = 100;
+}
diff --git a/RtRobotSharp/Program.cs b/RtRobotSharp/Program.cs
new file mode 100644
index 0000000..568f479
--- /dev/null
+++ b/RtRobotSharp/Program.cs
@@ -0,0 +1,12 @@
+using RtRobotSharp.Services;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddSingleton<RobotSerialService>();
+builder.Services.AddControllers();
+
+var app = builder.Build();
+app.UseDefaultFiles();
+app.UseStaticFiles();
+app.MapControllers();
+
+app.Run();
diff --git a/RtRobotSharp/Properties/launchSettings.json b/RtRobotSharp/Properties/launchSettings.json
new file mode 100644
index 0000000..58c5153
--- /dev/null
+++ b/RtRobotSharp/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5230",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7186;http://localhost:5230",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/RtRobotSharp/RtRobotSharp.csproj b/RtRobotSharp/RtRobotSharp.csproj
new file mode 100644
index 0000000..c419c72
--- /dev/null
+++ b/RtRobotSharp/RtRobotSharp.csproj
@@ -0,0 +1,17 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>net10.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="System.IO.Ports" Version="10.0.10" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <Folder Include="doc\" />
+ </ItemGroup>
+
+</Project>
diff --git a/RtRobotSharp/Services/RobotSerialService.cs b/RtRobotSharp/Services/RobotSerialService.cs
new file mode 100644
index 0000000..47df744
--- /dev/null
+++ b/RtRobotSharp/Services/RobotSerialService.cs
@@ -0,0 +1,362 @@
+using System.IO.Ports;
+using System.Text;
+using System.Threading.Channels;
+using RtRobotSharp.Models;
+
+namespace RtRobotSharp.Services;
+
+public record ServoTarget(int Channel, int Position);
+
+public record LogEntry(DateTimeOffset Timestamp, string Direction, string Text);
+
+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);
+
+ private readonly Lock gate = new();
+ private readonly List<Channel<LogEntry>> subscribers = [];
+ private readonly List<LogEntry> history = [];
+ private readonly StringBuilder rxBuffer = new();
+ private readonly Timer idleFlushTimer;
+ private SerialPort? port;
+
+ private Channel<string>? outgoing;
+ private CancellationTokenSource? senderCts;
+ private TaskCompletionSource<bool>? ackTcs;
+
+ public RobotSerialService(IConfiguration configuration)
+ {
+ Settings = configuration.GetSection(RobotSerialSettings.SectionName).Get<RobotSerialSettings>()
+ ?? new RobotSerialSettings();
+ idleFlushTimer = new Timer(_ => FlushRxBuffer(), null, Timeout.Infinite, Timeout.Infinite);
+ }
+
+ public RobotSerialSettings Settings { get; private set; }
+
+ public bool IsConnected => port is { IsOpen: true };
+
+ public void UpdateSettings(RobotSerialSettings settings) => Settings = settings;
+
+ public IEnumerable<string> GetAvailablePorts() => SerialPort.GetPortNames();
+
+ public void Connect()
+ {
+ lock (gate)
+ {
+ if (port is { IsOpen: true }) return;
+
+ port = new SerialPort(Settings.PortName, Settings.BaudRate)
+ {
+ NewLine = "\n",
+ ReadTimeout = 2000,
+ WriteTimeout = 2000
+ };
+ port.DataReceived += OnDataReceived;
+ port.Open();
+
+ // Capacity 1 + DropOldest: only the most recent not-yet-sent command is kept.
+ // 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>(
+ new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest });
+ senderCts = new CancellationTokenSource();
+ _ = Task.Run(() => SenderLoopAsync(outgoing.Reader, senderCts.Token));
+
+ AddLog("SYS", $"Connected to {Settings.PortName} @ {Settings.BaudRate}");
+ }
+ }
+
+ public void Disconnect()
+ {
+ lock (gate)
+ {
+ if (port == null) return;
+ ClosePort();
+ AddLog("SYS", "Disconnected");
+ }
+ }
+
+ // Tears down the port and sender loop without logging a message; the caller
+ // (Disconnect or a failed write) decides what to log. Must hold gate.
+ private void ClosePort()
+ {
+ if (port == null) return;
+
+ port.DataReceived -= OnDataReceived;
+ idleFlushTimer.Change(Timeout.Infinite, Timeout.Infinite);
+ FlushRxBuffer();
+
+ senderCts?.Cancel();
+ senderCts = null;
+ outgoing = null;
+ ackTcs?.TrySetCanceled();
+ ackTcs = null;
+
+ try
+ {
+ if (port.IsOpen) port.Close();
+ }
+ catch (Exception)
+ {
+ // The device may already have dropped off the bus (e.g. after a ~RE restart);
+ // closing is best-effort, the port handle is discarded regardless.
+ }
+ finally
+ {
+ port.Dispose();
+ port = null;
+ }
+ }
+
+ // Drops the port reference without calling Close()/Dispose(). Use this when a
+ // write is known to be stuck: SerialPort.Close() blocks until any pending
+ // synchronous Read/Write completes, so calling it on a wedged port would hang
+ // the whole app (including shutdown). We accept the handle leak - the OS
+ // reclaims it once the process actually exits or the USB device is replugged.
+ // Must be called while holding gate.
+ private void AbandonPort()
+ {
+ if (port == null) return;
+
+ try
+ {
+ port.DataReceived -= OnDataReceived;
+ }
+ catch (Exception)
+ {
+ }
+
+ idleFlushTimer.Change(Timeout.Infinite, Timeout.Infinite);
+ rxBuffer.Clear();
+
+ senderCts?.Cancel();
+ senderCts = null;
+ outgoing = null;
+ ackTcs?.TrySetCanceled();
+ ackTcs = null;
+ port = null;
+ }
+
+ private void OnDataReceived(object sender, SerialDataReceivedEventArgs e)
+ {
+ try
+ {
+ var data = port?.ReadExisting();
+ if (string.IsNullOrEmpty(data)) return;
+
+ var needsIdleFallback = true;
+ lock (gate)
+ {
+ rxBuffer.Append(data);
+ FlushCompleteLines();
+
+ // Confirmed on the actual hardware: "OK" is sent as bare "OK" (0x4F 0x4B),
+ // with no CR/LF terminator at all. Match it immediately instead of waiting
+ // for the line to end, so acknowledgement isn't delayed by a guessed timeout.
+ if (rxBuffer.ToString().Trim(' ').Equals("OK", StringComparison.OrdinalIgnoreCase))
+ {
+ rxBuffer.Clear();
+ HandleIncomingLine("OK");
+ needsIdleFallback = false;
+ }
+ }
+
+ if (needsIdleFallback)
+ {
+ // Safety net for any other unterminated reply we don't explicitly
+ // recognize: flush whatever is left once traffic goes quiet.
+ idleFlushTimer.Change(IdleFlushDelay, Timeout.InfiniteTimeSpan);
+ }
+ }
+ catch (TimeoutException)
+ {
+ }
+ }
+
+ // Must be called while holding gate.
+ private void FlushCompleteLines()
+ {
+ var text = rxBuffer.ToString();
+ var lines = text.Split('\n');
+ for (var i = 0; i < lines.Length - 1; i++)
+ {
+ var line = lines[i].Trim('\r', '\n', ' ');
+ if (line.Length > 0) HandleIncomingLine(line);
+ }
+ rxBuffer.Clear();
+ rxBuffer.Append(lines[^1]);
+ }
+
+ private void FlushRxBuffer()
+ {
+ lock (gate)
+ {
+ var line = rxBuffer.ToString().Trim('\r', '\n', ' ');
+ rxBuffer.Clear();
+ if (line.Length > 0) HandleIncomingLine(line);
+ }
+ }
+
+ // Must be called while holding gate.
+ private void HandleIncomingLine(string line)
+ {
+ AddLog("RX", line);
+ if (line.Equals("OK", StringComparison.OrdinalIgnoreCase))
+ ackTcs?.TrySetResult(true);
+ }
+
+ public void SendServoCommand(IEnumerable<ServoTarget> targets, int speedMs, int delayMs)
+ {
+ var sb = new StringBuilder();
+ 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());
+ }
+
+ public void SendRestart()
+ {
+ try
+ {
+ Send("~RE");
+ }
+ catch (Exception)
+ {
+ // The interface may drop off the bus as soon as it receives the restart
+ // command, before the write can be acknowledged. Still proceed to disconnect.
+ }
+ finally
+ {
+ Disconnect();
+ }
+ }
+
+ // 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)
+ {
+ ChannelWriter<string> 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);
+ }
+
+ private async Task SenderLoopAsync(ChannelReader<string> reader, CancellationToken token)
+ {
+ try
+ {
+ await foreach (var command in reader.ReadAllAsync(token))
+ {
+ var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+ SerialPort? currentPort;
+ lock (gate)
+ {
+ ackTcs = tcs;
+ currentPort = port;
+ }
+
+ if (currentPort is not { IsOpen: true })
+ break;
+
+ // SerialPort.Write() can hang indefinitely on a wedged USB-serial adapter
+ // even with WriteTimeout set - a known System.IO.Ports issue. Run it on a
+ // worker thread and give up on it after WriteGuardTimeout so the sender
+ // loop (and the app) never gets stuck waiting for it.
+ var writeTask = Task.Run(() => currentPort.Write(command + "\r\n"));
+ var writeCompleted = await Task.WhenAny(writeTask, Task.Delay(WriteGuardTimeout, token));
+
+ if (writeCompleted != writeTask)
+ {
+ AddLog("SYS", "Poort reageert niet meer op verzenden - verbinding wordt losgekoppeld (kabel/interface controleren)");
+ lock (gate)
+ {
+ AbandonPort();
+ }
+ break;
+ }
+
+ try
+ {
+ await writeTask;
+ AddLog("TX", command);
+ }
+ catch (Exception ex)
+ {
+ // The write actually completed (with an error), so the port isn't
+ // stuck - safe to close it normally.
+ lock (gate)
+ {
+ ClosePort();
+ }
+ AddLog("SYS", $"Verbinding verbroken (verzenden mislukt): {ex.Message}");
+ break;
+ }
+
+ var completed = await Task.WhenAny(tcs.Task, Task.Delay(AckTimeout, token));
+ 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)");
+ }
+
+ 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)
+ {
+ }
+ }
+
+ private void AddLog(string direction, string text)
+ {
+ var entry = new LogEntry(DateTimeOffset.Now, direction, text);
+ lock (gate)
+ {
+ history.Add(entry);
+ if (history.Count > 500) history.RemoveAt(0);
+ }
+ foreach (var sub in subscribers.ToArray())
+ sub.Writer.TryWrite(entry);
+ }
+
+ public IEnumerable<LogEntry> GetHistory()
+ {
+ lock (gate) return [.. history];
+ }
+
+ public ChannelReader<LogEntry> Subscribe()
+ {
+ var channel = Channel.CreateUnbounded<LogEntry>();
+ lock (gate) subscribers.Add(channel);
+ return channel.Reader;
+ }
+
+ public void Unsubscribe(ChannelReader<LogEntry> reader)
+ {
+ lock (gate) subscribers.RemoveAll(c => c.Reader == reader);
+ }
+
+ public void Dispose()
+ {
+ Disconnect();
+ idleFlushTimer.Dispose();
+ }
+}
diff --git a/RtRobotSharp/appsettings.Development.json b/RtRobotSharp/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/RtRobotSharp/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/RtRobotSharp/appsettings.json b/RtRobotSharp/appsettings.json
new file mode 100644
index 0000000..2633c3f
--- /dev/null
+++ b/RtRobotSharp/appsettings.json
@@ -0,0 +1,17 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "RobotSerial": {
+ "PortName": "COM6",
+ "BaudRate": 115200,
+ "SpeedMs": 100,
+ "DelayMs": 200,
+ "ServoCount": 32,
+ "PostSendSettleDelayMs": 1
+ }
+}
\ No newline at end of file
diff --git a/RtRobotSharp/doc/manual_english.pdf b/RtRobotSharp/doc/manual_english.pdf
new file mode 100644
index 0000000..cb1dad9
--- /dev/null
+++ b/RtRobotSharp/doc/manual_english.pdf
@@ -0,0 +1,664 @@
+Servo Motor Controller Instructions
+ for use
+
+ Ver 4.0
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+ Parameters: 32 channels 24 channels 16 channels
+ 5V
+Hardware
+Operating Voltage Based on servo motor requirements
+Servo motor Input Voltage 32bit
+CPU
+Baud Rate(USB) 115200
+Baud Rate
+ 4800、9600、19200、38400、57600、115200
+(Bluetooth、Wi-Fi、UART)
+ 16M
+Flash Capacity
+Servo motor synchronous 32 24 16
+quantity
+Max Action Groups 255
+control precision 1us
+Servo motor signal
+isolation Yes
+Current limiting protection
+MPU6500 No Yes Yes
+External sensor support
+3D Virtual No Yes No
+
+Indicator led No No Yes
+
+Dimensions All All Partial
+Communication Protocol
+ 1.CPU power indicator led(red)
+Computer Software
+ 2.Servo motor power indicator led(green)
+Low pressure alarm
+Servo motor initial value 3. wireless remote control(Yellow)
+Support servo motor type
+Second development 64mm X 45mm 64mm X 47.5mm 58.5mm x 45mm
+
+wireless remote control UART
+
+ Windows 10 or later
+
+ Mac OS 10.8 or later
+
+ Linux (kernel 3.0 or later)
+
+ Default Open
+
+ Default 1500
+
+ 9G~55G
+
+ C51、Arduino、ARM、MSP、DSP、 Wi-Fi、Bluetooth、Compute
+
+ 1. one servo motor control
+
+ 2. action groups control
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+32 channels: 24 channels:
+
+16 channels:
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Instruction:
+
+Communication Protocol:
+
+serial communication baud rate parity data bits stop bits
+ none 8 1
+TTL 9600(default)
+
+Instruction format:
+
+name Instruction description
+ Data 1 refers to the channel of the servo
+Controller single servo #1P1500T1000D800\r\n motor
+motor Data 1500 refers to the position of the
+ servo motor, with a range of 500-2500
+Controller multiple servo #1P1500#2P1500T1000D800\r\n Data 1000 refers to the execution time and
+motor represents the speed, with a range of 0-
+ 9999
+Run action groups G1F3\r\n Data 800 refers to the delay time between
+Stop action groups ~ST instructions, with a range of 0-9999
+Restart CPU ~RE Data 1、2 refers to the servo motor
+ channel
+ Data 1500 Refers to the servo motor
+ location, in the range 500-2500
+ Data 1000 refers to the time of execution
+ and represents the speed, in the range 0-
+ 9999
+ Data 800 refers to the Instruction interval
+ of delay time, in the range 0-9999
+ Data 1 refers to the group's channel
+ Data 3 refers to the frequency of runs
+ Stop running action groups (Note: not
+ pause)
+ Restart CPU
+
+Note: "\r\n” in hexadecimal is "0X0D 0X0A"; all instructions are in ASCII code.
+"0x0D"== "\r "== "CR"
+"0x0A"== "\n "== "LF"
+
+Note: If the function or software used in the program has a "\r\n" feature, it is not
+necessary to add it at the end. After the instruction is executed, the controller will
+provide feedback with "OK".
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Wiring methods:
+
+Ⅰ. Power supply connection method, as shown in Figure 1:
+
+ Figure 1
+
+VCC: This is the input for the servo motor power supply VCC, which can be connected
+to a 4.2V - 12V power supply. Please connect the positive pole of the power supply.
+Note: The VCC interface of the controller is the power input of the servo motor. The
+VCC interface should be selected according to the requirements of the servo motor,
+for example, if one servo motor requires a peak voltage of 6V and a current of 2A, and
+ten servo motors require a power supply of 6V voltage and 10A current.
+GND: This is the overall GND of the servo motor controller, which can be connected to
+the GND of the servo motor power supply or the CPU power supply. Please connect
+the negative pole of the power supply.
+USB (①): This is both the CPU power supply input and the data communication
+interface of the servo motor controller.
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Ⅱ. Servo motor wiring method, as shown in Position ③ in
+Figure 2:
+
+ Figure 2
+
+Yellow pin header: This is the I/O input of the servo motor, generally yellow or beige
+in color.
+
+White pin header: This is the VCC input of the servo motor, generally white, red or
+dark red in color.
+
+Black pin header: This is the GND input of the servo motor, generally brown or black
+in color.
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Ⅲ. UART wiring method, as shown in Position ④ in Figure
+3. Please refer to Figure 4 for details:
+
+ Figure 3
+
+ Figure 4
+Green circle: This is the GND input of the CPU power supply of the servo motor
+controller.
+
+Yellow circle: This is the VCC input of the CPU power supply of the servo motor
+controller, which can only be connected to 5V.
+
+Purple circle: This is the RX port of the UART of the servo motor controller, generally
+connected to the TX port of other UART devices.
+
+Orange circle: This is the TX port of the UART of the servo motor controller, generally
+connected to the RX port of other UART devices.
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Ⅳ. Bluetooth and Wi-Fi sensor wiring method, as shown in
+Position ④ in Figure 5:
+
+ Figure 5
+
+Position ④ in Figure 5 uses four DuPont wires to connect with the Bluetooth or Wi-Fi
+module, 5V-VCC, GND-GND, RX-TX, TX-RX.
+Pair the mobile phone with the Bluetooth module and install the mobile control
+software to perform control. To use the Wi-Fi module, install and open the mobile
+control software, and enter the TCP address set by the Wi-Fi module for control.
+Note: Before using the mobile phone remote control in practice, connect the
+Bluetooth or Wi-Fi module to the computer and use a serial port debugging software
+to see if the corresponding instructions can be received.
+The first time you use the mobile software, you need to enter the verification code,
+which is: RTrobot (pay attention to the case).
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+V. Potentiometer wiring method, as shown in Figure 6:
+
+ Figure 6
+
+The potentiometer module is connected to the servo motor controller, as shown in
+Figure 6, connect 1 - 1, 2 - 2, and 3 - 3...
+Each potentiometer can be set to control which channel of servo motor through the
+upper computer software "Setting"->"Hardware".
+
+ Figure 7
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+VI. Wireless joystick wiring method, as shown in Figure 8:
+
+ Figure 8
+
+Connect the wireless remote receiver with the servo motor controller, as shown in
+figure 8, 1 - 1, 2 - 2, 3 - 3...
+Don't forget that the joystick also needs two batteries for power. (After the wiring is
+correct, the LED lights of the power receiver and the remote control will be on
+constantly, indicating that the pairing is complete.)
+
+The wireless remote control has two control modes.
+Mode 1 (yellow light off) is for controlling a single servo motor.
+Mode 2 (yellow light on) is for controlling an action group.
+The functions of the buttons are different in different modes, but some buttons have
+the same function in both modes.
+
+Note: After the power is turned on, you must press "START" once to start the servo
+motor.
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+ Figure 9
+
+Same function:
+SELECT: Mode Switching
+START: Activate
+Single Servo Motor Control (32 Servo Mode):
+Square: Move all servo motors to 2500
+Cross: Move all servo motors to 1500
+Round: Move all servo motors to 500
+Triangle: Reserved
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Hardware 32 channels 24 channels 16 channels
+First group servo motor
+serial number 1、3、5、7、9、11、13、15 1、3、5、7、9、11 1、3、5、7
+
+First group control button L2: Switch to the previous servo motor in the first group
+ R2: Switch to the next servo motor in the first group
+Second group servo L3-Left: Increase the value of the selected servo motor in the first group
+motor serial number L3-Right: Decrease the value of the selected servo motor in the first group
+
+Second group control 2、4、6、8、10、12、14、16 2、4、6、8、10、12 2、4、6、8
+button
+ L1: Switch to the previous servo motor in the second group.
+Third group servo motor R1: Switch to the next servo motor in the second group.
+serial number R3-Left: Increase the value of the selected servo motor in the second group.
+ R3-Right: Decrease the value of the selected servo motor in the second group.
+Third group control
+button 17、19、21、23、25、27、29、31 13、15、17、19、21、23 9、11、13、15
+
+Fourth group servo motor Left: Switch to the previous servo motor in the third group.
+serial number Right: Switch to the next servo motor in the third group.
+ L3-Up: Increase the value of the selected servo motor in the third group.
+Fourth group control L3-Down: Decrease the value of the selected servo motor in the third group.
+button
+ 18、20、22、24、26、28、30、32 14、16、18、20、22、24 10、12、14、16
+
+ Down: Switch to the previous servo motor in the fourth group.
+ Up: Switch to the next servo motor in the fourth group.
+ R3-Up: Increase the value of the selected servo motor in the fourth group.
+ R3-Down: Decrease the value of the selected servo motor in the fourth group.
+
+ Figure 10
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+Execution of Action Group:
+
+Button L2 L1 R2 R1 Up Left:
+action group 0 1 2 3 4 5
+Button Down Right L3-Up L3-Left L3-Down L3-Right
+action group 6 7 8 9 10 11
+Button R3-Up R3-Left R3-Down R3-Right Square Cross
+action group 12 13 14 15 16 17
+Button Round Triangle
+action group 18 19
+
+ Figure 11
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+Single servo motor control (6 Servo Mode):
+
+Each time the button is pressed, the specified servo motor value will increase or
+decrease. For example, pressing the "L2" button will decrease the value of servo motor
+1 to change its angle.
+
+ Figure 12
+If you need to customize the buttons on the wireless joystick, please use the software
+and click "Setting"->"Wireless controller" to edit.
+Note: When the servo motor controller is plugged into the USB boot, the wireless
+joystick does not work.
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Wiring Examples:
+
+Ⅰ. Using a computer to control:
+
+ Figure 13
+Connect the servo motor and its power supply first, and then use a USB cable to link
+the computer to the servo motor controller.
+
+Refer to Wiring methods: Ⅰ for connecting the servo motor power supply
+
+(remember not to connect the VDD power supply port).
+Note: The servo motor power supply should be selected according to the
+requirements of the servo motor.
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Ⅱ. Automatic run of the controller:
+
+ Figure 14
+
+Before the servo motor automatic operation, set the automatic operation parameters
+using the software, see the software usage section for details.
+Note: After setting the automatic operation parameters with the software, plug and
+unplug the USB port to start automatic operation.
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Ⅲ. Using MCU to control
+
+Connect the servo motor controller 5V to Arduino Uno's 5V, servo motor controller
+GND to Arduino Uno's GND, servo motor controller TX to Arduino Uno's RX, and servo
+motor controller RX to Arduino Uno's TX. See Figure 15.
+
+ Figure 15
+
+Raspberry Pi users can use a USB cable to connect the servo motor controller to the
+Raspberry Pi and treat it as a computer.
+
+ Figure 16
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Software Operation Instructions:
+
+Note: The serial port needs to be opened to use all the functions of the software.
+
+Ⅰ. Language Setting:
+
+Click "Setting" -> "Language" to select the language.
+
+Figure 17
+
+Ⅱ. Software Settings:
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Click the ":" in the servo motor window to name each servo motor individually and set
+
+the maximum and minimum values, colors, and locked position.
+
+Figure 18
+
+Click "Setting" -> "Software" to set the software, as shown in Figure 19.
+Software Panel: Set the software control panel.
+Servo On/Off: Displays the number of the servo motor that needs to be controlled.
+After completing the software settings, click "OK" to automatically restart the
+software.
+
+Figure 19
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+After selecting the Software Panel interface, you can specify the servo motor number
+for each position, as shown in Figure 20.
+
+Note: If there are duplicate servo motor numbers, they cannot be saved.
+
+Figure 20
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Ⅲ. Servo motor controller settings:
+
+Click "Setting" -> "Hardware" to set the controller, as shown in Figure 21.
+
+Servo Initial Value: Set the initial value for each servo motor when powered on.
+
+Servo Deviation Value: Set the deviation value for each servo motor (valid value: -
+99~99), as shown in Figure 22.
+
+UART Baud Rate: Set the serial port baud rate at position ④ in Figure 5.
+
+Buzzer: Low voltage alarm switch.
+
+Start Automatic Run: Switch for automatic running of action group at startup.
+
+Automatic Run Group: Set the action group number to be automatically run at
+startup. This option is invalid when the automatic running is set to action group mode
+only.
+
+Automatic Run Times: The number of times the action group runs at startup. This
+option is invalid when the automatic running is set to action group mode only.
+
+Note: After setting, remember to click "Apply" and wait for the settings to be
+completed. The controller needs to be restarted for the settings to take effect after
+each setting.
+
+Figure 21 Figure 22
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Ⅳ. 3D Display Effect:
+
+Click "Setting" -> "3D Virtual" to display the 3D effect interface.
+
+Note: To display the 3D effect interface, you need to first select the control interface
+on the "Software" settings page and then open the serial port.
+
+Figure 23
+
+Ⅴ. Software Control:
+
+1. Choose a suitable wiring method and connect it to the computer with a USB cable.
+2. Open the "ServoController.exe" software.
+3. Select the serial port and open it. If using Wi-Fi mode, select TCP and enter the TCP
+address and port set by the Wi-Fi module's TCP server.
+Note: Only when connected to the computer with a USB cable can all functions be
+used.
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+1) Single servo motor operation:
+
+ As shown in Figure 24, the angle value of the servo motor can be changed by
+ dragging or filling in the value.
+
+ Figure 24
+
+2) Multiple servo motor operation:
+
+ As shown in Figure 25, first select the action group number to be edited in the
+ "Group" selection box under the instruction information box, then set the first
+ line of the operation value for each servo motor, and set the operating speed
+ "Speed" and the delay time "Delay" after execution. Click "Add" to add, and
+ then set the second line of the operation value for each servo motor and click
+ "Add" to add. After all preset actions are set, click "Run" to test. If "Loop" is
+ selected, it will run indefinitely.
+
+ Servo motor running speed "Speed": Complete the instruction within the
+ specified time (cannot exceed the physical maximum speed of the servo
+ motor).
+
+ Servo motor completion wait time "Delay": Delay the specified time after
+ completing the current instruction before executing the next instruction.
+
+ Figure 25
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+3) Action instruction saving:
+
+Click "Export" to save the action instruction to a text file for future import use.
+Note: Here it is saved as instructions for all action groups.
+
+4) Use file import operation:
+
+Click "Import" to import previously saved action instructions into the software.
+Note: Here it is imported as instructions for all action groups.
+
+5) Action instruction editing:
+
+Click on the instruction that needs to be edited in the instruction information box,
+right-click and select "Edit" or use the shortcut key "Ctrl + E " to edit.
+
+Figure 26
+
+6) Offline operation:
+
+After all instructions are edited, click "Download" to download the instructions for all
+action groups.
+In the "Setting"-> "Hardware" interface, turn on the controller's automatic operation
+switch and select the action group number to be run.
+
+7) Read instructions:
+
+Click "Read" to read all instructions that have been downloaded to the controller.
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+8) Erase all action groups:
+
+Click "Erase" to erase all instructions that have been downloaded to the controller. The
+erasing time is about 30 seconds.
+
+9) Manual instruction editing:
+
+ Click "Instruction" to manually enter or edit instructions in the pop-up dialog box.
+
+Figure 27
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Ⅵ. Editing Action Group:
+
+Click "Action group" to open the action group running and editing window, as shown
+in Figure 28. Here, only the action group instructions can be edited, such as:
+
+G1F3
+G17F5
+……
+
+G1 represents action group 1, and F3 represents running 3 times.
+After editing is complete, you can click "Run" for testing. After successful testing, you
+can download and save it to the controller. When you open the software next time,
+you can use the "Read" function to read the previously downloaded action group
+instructions. If you need to run automatically, please change the automatic running
+switch to "Action Group Only" in the hardware settings.
+
+Figure 28
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Ⅶ. MPU6500
+(Only for 24-channel servo motor controller):
+
+Click "Setting" -> "MPU6500" to open the MPU6500 setting interface, as shown in
+Figure 29. Click the "Disable" button to change the status to "Enable" to turn on the
+MPU6500. Pitch, Roll, and Yaw are the XYZ values of the MPU6500. After filling in the
+allowable deviation value, the action group and the number of runs to be performed
+when the values are exceeded, tilt the controller to trigger the running state and press
+the "Apply” button to make it effective and restart the controller. When the tilt
+direction of the controller reaches the set value, the previously set action group and
+the number of runs will be triggered and executed. (When debugging with the upper
+computer software by connecting to the computer via a USB cable, the MPU6500 will
+not be triggered.)
+
+If the number of runs is set to "0", it will not be triggered, and only the MPU6500
+value will be fed back through the serial port.
+
+ 21 , -2 , -21 \r \n
+0X32 0X31 0X2C 0X2D 0X32 0X2C 0X2D 0X32 0X31 0X0D 0X0A
+
+ Pitch , Roll , Yaw \r \n
+
+Figure 29
+Note: Before executing the action group, the serial port will first feedback "TRIGGER",
+and after executing the specified action group, it will feedback "OK".
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Ⅷ. 6-channel digital level sensor interface
+(Only available on the 16-channel servo
+motor controller):
+
+ Figure 30
+
+The six digital level sensor interfaces highlighted in the red circle of Figure 30 can
+independently control six action groups or maintain the current position of designated
+servo motors., which can independently control six action groups or maintain the
+current position of specified servo motors (only supports 3.3/5V digital level sensors).
+
+If using the USB cable to connect to a computer and debugging with the upper
+computer software, the external sensor will not trigger.
+
+Note: The GND of each sensor needs to be connected to the GND of the controller.
+When multiple INs are triggered at the same time, the one with the lower sequence
+number is effective;
+
+ IN1 > IN2 > IN3 > IN4 > IN5 > IN6
+
+Example 1: IN2 and IN3 are triggered at the same time, only the action group
+specified by IN2 will be executed. If IN2 is released and IN3 is still triggered, the action
+group specified by IN3 will be executed.
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Example 2: IN1 triggers the execution of an action group, while IN6 maintains the
+current position of a servo motor. When both are triggered at the same time, they do
+not affect each other, and both IN1 and IN6 are effective.
+
+ Figure 31
+
+INx three options:
+
+ Disable: Off (Trigger is invalid)
+ High: Trigger on high-level signal
+ Low: Trigger on low-level signal
+
+Group: Action group executed after triggering.
+
+Stop: Stop the servo motor after triggering and maintain the current position.
+
+Note: Before executing the action group, the serial port will first provide feedback
+"TRIGGER", and after completing the specified action group, it will provide feedback
+"OK".
+
+Note: AC level sensors are invalid.
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Ⅸ. Firmware upgrade method:
+
+1. Download the latest PC software from "http://www.rtrobot.org/software"
+ website.
+
+2. Open the latest version PC software.
+
+3. Hold down the button on the servo motor controller while connecting the USB
+ data cable, and then release the button.
+
+ Figure 32
+4. Open the serial port in the latest PC software. The firmware will start to
+
+ upgrade. After the upgrade is complete, a message will be displayed: "Update
+ Success, Restart the controller, please."
+5. Restart the servo motor controller
+6. If the firmware is already up to date, the message "ERROR Don't need update!"
+ will be displayed.
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+Dimensional drawing:
+
+32:
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+24:
+
+RTROBOT 用心为您服务
+Servo Motor Controller Instructions for use http://rtrobot.org
+
+16:
+
+Ⅹ. About
+
+Thank you for using the servo motor controller produced by RTrobot. If you have any
+questions about the controller during use and need to consult, you can email us at
+admin@rtrobot.org.
+
+RTROBOT 用心为您服务
+
diff --git a/RtRobotSharp/wwwroot/css/site.css b/RtRobotSharp/wwwroot/css/site.css
new file mode 100644
index 0000000..4da656d
--- /dev/null
+++ b/RtRobotSharp/wwwroot/css/site.css
@@ -0,0 +1,107 @@
+* { box-sizing: border-box; }
+body {
+ font-family: Segoe UI, Arial, sans-serif;
+ margin: 0;
+ background: #1e1e2b;
+ color: #eee;
+}
+nav {
+ display: flex;
+ gap: 8px;
+ padding: 12px 20px;
+ background: #14141f;
+ align-items: center;
+}
+nav a {
+ color: #ccc;
+ text-decoration: none;
+ padding: 8px 14px;
+ border-radius: 6px;
+}
+nav a.active, nav a:hover { background: #2c2c40; color: #fff; }
+main { padding: 20px; max-width: 1400px; margin: 0 auto; }
+
+h1 { font-size: 20px; margin-top: 0; }
+
+.card {
+ background: #262636;
+ border-radius: 10px;
+ padding: 16px;
+ margin-bottom: 16px;
+}
+
+label { display: block; font-size: 12px; color: #aaa; margin-bottom: 4px; }
+input, select {
+ background: #14141f;
+ color: #eee;
+ border: 1px solid #3a3a52;
+ border-radius: 6px;
+ padding: 8px;
+ width: 100%;
+}
+.row { display: flex; gap: 16px; flex-wrap: wrap; }
+.row > div { flex: 1; min-width: 160px; }
+
+button {
+ background: #3b5bfd;
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 10px 16px;
+ cursor: pointer;
+ font-weight: 600;
+}
+button:hover { background: #2c48e0; }
+button.secondary { background: #444461; }
+button.secondary:hover { background: #55557a; }
+button.danger { background: #d33; }
+button.danger:hover { background: #b22; }
+button:disabled { opacity: .5; cursor: not-allowed; }
+
+.status-dot {
+ display: inline-block;
+ width: 10px; height: 10px;
+ border-radius: 50%;
+ background: #d33;
+ margin-right: 6px;
+}
+.status-dot.on { background: #2ecc71; }
+
+.servo-grid {
+ display: grid;
+ grid-template-columns: repeat(8, 1fr);
+ gap: 10px;
+}
+@media (max-width: 1100px) { .servo-grid { grid-template-columns: repeat(4, 1fr); } }
+@media (max-width: 600px) { .servo-grid { grid-template-columns: repeat(2, 1fr); } }
+
+.servo {
+ border-radius: 8px;
+ padding: 10px;
+ color: #222;
+}
+.servo .head { display: flex; justify-content: space-between; align-items: center; font-weight: 600; margin-bottom: 6px; }
+.servo input[type=number] { width: 68px; padding: 3px; }
+.servo input[type=range] { width: 100%; }
+.servo input[type=checkbox] { width: auto; margin-right: 4px; }
+.servo .sel { display: flex; align-items: center; font-size: 11px; margin-top: 4px; }
+
+.g0 { background: #cfc85a; }
+.g1 { background: #b96dcf; }
+.g2 { background: #f19a92; }
+.g3 { background: #6fc7c2; }
+
+#log {
+ background: #0e0e16;
+ border-radius: 8px;
+ padding: 10px;
+ height: 220px;
+ overflow-y: auto;
+ font-family: Consolas, monospace;
+ font-size: 12px;
+}
+#log .tx { color: #6fb1ff; }
+#log .rx { color: #7ee787; }
+#log .sys { color: #ffb454; }
+
+.actions { display: flex; gap: 10px; align-items: center; margin: 10px 0; }
diff --git a/RtRobotSharp/wwwroot/index.html b/RtRobotSharp/wwwroot/index.html
new file mode 100644
index 0000000..ceca581
--- /dev/null
+++ b/RtRobotSharp/wwwroot/index.html
@@ -0,0 +1,4 @@
+<!doctype html>
+<meta charset="utf-8" />
+<meta http-equiv="refresh" content="0; url=/servos.html" />
+<title>RtRobot</title>
diff --git a/RtRobotSharp/wwwroot/js/common.js b/RtRobotSharp/wwwroot/js/common.js
new file mode 100644
index 0000000..5ad7b24
--- /dev/null
+++ b/RtRobotSharp/wwwroot/js/common.js
@@ -0,0 +1,72 @@
+async function api(path, options)
+{
+ const res = await fetch('/api' + path, {
+ headers: { 'Content-Type': 'application/json' },
+ ...options,
+ });
+ if (!res.ok)
+ {
+ const text = await res.text().catch(() => res.statusText);
+ throw new Error(text || res.statusText);
+ }
+ const ct = res.headers.get('content-type') || '';
+ return ct.includes('application/json') ? res.json() : null;
+}
+
+function fmtTime(ts)
+{
+ return new Date(ts).toLocaleTimeString();
+}
+
+function appendLog(el, entry)
+{
+ const line = document.createElement('div');
+ const cls = entry.direction.toLowerCase();
+ line.className = cls;
+ line.textContent = `[${fmtTime(entry.timestamp)}] ${entry.direction} ${entry.text}`;
+ el.appendChild(line);
+ el.scrollTop = el.scrollHeight;
+ while (el.childElementCount > 300) el.removeChild(el.firstChild);
+}
+
+function startLogStream(el)
+{
+ api('/log/history').then(history => history.forEach(e => appendLog(el, e))).catch(() => { });
+ const source = new EventSource('/api/log/stream');
+ source.onmessage = (ev) => appendLog(el, JSON.parse(ev.data));
+ return source;
+}
+
+async function refreshStatus(dotEl, textEl, onStatus)
+{
+ try
+ {
+ const s = await api('/serial/status');
+ dotEl.classList.toggle('on', s.connected);
+ textEl.textContent = s.connected
+ ? `Verbonden met ${s.portName} @ ${s.baudRate}`
+ : 'Niet verbonden';
+ if (onStatus) onStatus(s);
+ return s;
+ } catch
+ {
+ textEl.textContent = 'Status onbekend';
+ }
+}
+
+async function autoConnect(dotEl, textEl, onStatus)
+{
+ const s = await refreshStatus(dotEl, textEl, onStatus);
+ if (s && !s.connected)
+ {
+ try
+ {
+ await api('/serial/connect', { method: 'POST' });
+ } catch
+ {
+ // No device / wrong settings yet - stay disconnected, the user can
+ // fix the settings and connect manually.
+ }
+ await refreshStatus(dotEl, textEl, onStatus);
+ }
+}
diff --git a/RtRobotSharp/wwwroot/js/servos.js b/RtRobotSharp/wwwroot/js/servos.js
new file mode 100644
index 0000000..af9aa94
--- /dev/null
+++ b/RtRobotSharp/wwwroot/js/servos.js
@@ -0,0 +1,120 @@
+const statusDot = document.getElementById('statusDot');
+const statusText = document.getElementById('statusText');
+const msg = document.getElementById('msg');
+const grid = document.getElementById('servoGrid');
+const speedInput = document.getElementById('speedMs');
+const delayInput = document.getElementById('delayMs');
+
+const MIN = 500, MAX = 2500, DEFAULT = 1500;
+let servoCount = 32;
+
+function setMsg(text, isError)
+{
+ msg.textContent = text;
+ msg.style.color = isError ? '#f66' : '#6f6';
+}
+
+function buildGrid()
+{
+ grid.innerHTML = '';
+ for (let ch = 1; ch <= servoCount; ch++)
+ {
+ const groupClass = 'g' + (Math.floor((ch - 1) / 8) % 4);
+ const div = document.createElement('div');
+ div.className = `servo ${groupClass}`;
+ div.innerHTML = `
+ <div class="head">
+ <span>S${ch}</span>
+ <input type="number" min="${MIN}" max="${MAX}" value="${DEFAULT}" data-role="num" data-ch="${ch}" />
+ </div>
+ <input type="range" min="${MIN}" max="${MAX}" value="${DEFAULT}" data-role="range" data-ch="${ch}" />
+ <label class="sel"><input type="checkbox" data-role="sel" data-ch="${ch}" /> select</label>
+ `;
+ grid.appendChild(div);
+ }
+
+ grid.querySelectorAll('input[data-role=range]').forEach(range =>
+ {
+ range.addEventListener('input', () =>
+ {
+ 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) }]);
+ });
+ });
+ grid.querySelectorAll('input[data-role=num]').forEach(num =>
+ {
+ num.addEventListener('input', () =>
+ {
+ const ch = num.dataset.ch;
+ 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;
+ });
+ });
+}
+
+function collectTargets(onlySelected)
+{
+ const targets = [];
+ for (let ch = 1; ch <= servoCount; ch++)
+ {
+ const sel = grid.querySelector(`input[data-role=sel][data-ch="${ch}"]`);
+ if (onlySelected && !sel.checked) continue;
+ const num = grid.querySelector(`input[data-role=num][data-ch="${ch}"]`);
+ targets.push({ channel: ch, position: Number(num.value) });
+ }
+ return targets;
+}
+
+async function sendTargets(targets)
+{
+ if (targets.length === 0)
+ {
+ setMsg('Geen servo geselecteerd', true);
+ return;
+ }
+ try
+ {
+ await api('/servo/move', {
+ method: 'POST',
+ body: JSON.stringify({
+ targets,
+ speedMs: Number(speedInput.value),
+ delayMs: Number(delayInput.value),
+ }),
+ });
+ setMsg(`Commando verstuurd naar ${targets.length} servo('s)`);
+ } catch (e)
+ {
+ setMsg('Versturen mislukt: ' + e.message, true);
+ }
+}
+
+document.getElementById('sendSelectedBtn').addEventListener('click', () => sendTargets(collectTargets(true)));
+document.getElementById('sendAllBtn').addEventListener('click', () => sendTargets(collectTargets(false)));
+document.getElementById('selectAllBtn').addEventListener('click', () =>
+{
+ grid.querySelectorAll('input[data-role=sel]').forEach(c => c.checked = true);
+});
+document.getElementById('selectNoneBtn').addEventListener('click', () =>
+{
+ grid.querySelectorAll('input[data-role=sel]').forEach(c => c.checked = false);
+});
+
+(async function init()
+{
+ try
+ {
+ const s = await api('/settings');
+ servoCount = s.servoCount || 32;
+ speedInput.value = s.speedMs;
+ delayInput.value = s.delayMs;
+ } catch { }
+ buildGrid();
+ autoConnect(statusDot, statusText);
+ setInterval(() => refreshStatus(statusDot, statusText), 4000);
+ startLogStream(document.getElementById('log'));
+})();
diff --git a/RtRobotSharp/wwwroot/js/settings.js b/RtRobotSharp/wwwroot/js/settings.js
new file mode 100644
index 0000000..3170e7f
--- /dev/null
+++ b/RtRobotSharp/wwwroot/js/settings.js
@@ -0,0 +1,122 @@
+const statusDot = document.getElementById('statusDot');
+const statusText = document.getElementById('statusText');
+const msg = document.getElementById('msg');
+const portSelect = document.getElementById('portName');
+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 connectBtn = document.getElementById('connectBtn');
+const disconnectBtn = document.getElementById('disconnectBtn');
+
+function setMsg(text, isError)
+{
+ msg.textContent = text;
+ msg.style.color = isError ? '#f66' : '#6f6';
+}
+
+function setConnectionButtons(connected)
+{
+ connectBtn.disabled = connected;
+ disconnectBtn.disabled = !connected;
+}
+
+async function loadPorts(selected)
+{
+ try
+ {
+ const ports = await api('/serial/ports');
+ portSelect.innerHTML = '';
+ const list = ports.includes(selected) ? ports : [selected, ...ports];
+ for (const p of list)
+ {
+ const opt = document.createElement('option');
+ opt.value = p;
+ opt.textContent = p;
+ portSelect.appendChild(opt);
+ }
+ portSelect.value = selected;
+ } catch
+ {
+ portSelect.innerHTML = `<option>${selected}</option>`;
+ }
+}
+
+async function loadSettings()
+{
+ const s = await api('/settings');
+ await loadPorts(s.portName);
+ baudSelect.value = s.baudRate;
+ speedInput.value = s.speedMs;
+ delayInput.value = s.delayMs;
+ countInput.value = s.servoCount;
+ settleDelayInput.value = s.postSendSettleDelayMs;
+}
+
+async function saveSettings()
+{
+ return api('/settings', {
+ method: 'PUT',
+ body: JSON.stringify({
+ portName: portSelect.value,
+ baudRate: Number(baudSelect.value),
+ speedMs: Number(speedInput.value),
+ delayMs: Number(delayInput.value),
+ servoCount: Number(countInput.value),
+ postSendSettleDelayMs: Number(settleDelayInput.value),
+ }),
+ });
+}
+
+document.getElementById('saveBtn').addEventListener('click', async () =>
+{
+ try
+ {
+ await saveSettings();
+ setMsg('Instellingen opgeslagen in appsettings.json');
+ } catch (e)
+ {
+ setMsg('Opslaan mislukt: ' + e.message, true);
+ }
+});
+
+connectBtn.addEventListener('click', async () =>
+{
+ try
+ {
+ // Always use whatever is currently in the form, not the last-saved values.
+ await saveSettings();
+ await api('/serial/connect', { method: 'POST' });
+ setMsg('Verbonden');
+ } catch (e)
+ {
+ setMsg('Verbinden mislukt: ' + e.message, true);
+ }
+ refreshStatus(statusDot, statusText, s => setConnectionButtons(s.connected));
+});
+
+disconnectBtn.addEventListener('click', async () =>
+{
+ await api('/serial/disconnect', { method: 'POST' });
+ refreshStatus(statusDot, statusText, s => setConnectionButtons(s.connected));
+});
+
+document.getElementById('restartBtn').addEventListener('click', async () =>
+{
+ if (!confirm('Interface herstarten? De verbinding wordt gesloten en moet opnieuw gemaakt worden.')) return;
+ try
+ {
+ await api('/serial/restart', { method: 'POST' });
+ setMsg('Restart commando verstuurd, verbinding gesloten');
+ } catch (e)
+ {
+ setMsg('Restart mislukt: ' + e.message, true);
+ }
+ refreshStatus(statusDot, statusText, s => setConnectionButtons(s.connected));
+});
+
+loadSettings();
+autoConnect(statusDot, statusText, s => setConnectionButtons(s.connected));
+setInterval(() => refreshStatus(statusDot, statusText, s => setConnectionButtons(s.connected)), 4000);
+startLogStream(document.getElementById('log'));
diff --git a/RtRobotSharp/wwwroot/servos.html b/RtRobotSharp/wwwroot/servos.html
new file mode 100644
index 0000000..f3e7b14
--- /dev/null
+++ b/RtRobotSharp/wwwroot/servos.html
@@ -0,0 +1,49 @@
+<!doctype html>
+<html lang="nl">
+<head>
+<meta charset="utf-8" />
+<title>RtRobot - Servo's</title>
+<link rel="stylesheet" href="/css/site.css" />
+</head>
+<body>
+<nav>
+ <a href="/servos.html" class="active">Servo's</a>
+ <a href="/settings.html">Instellingen</a>
+ <span style="margin-left:auto" id="statusText"><span class="status-dot" id="statusDot"></span></span>
+</nav>
+<main>
+ <h1>Servo besturing</h1>
+
+ <div class="card">
+ <div class="row">
+ <div>
+ <label for="speedMs">Speed (ms)</label>
+ <input id="speedMs" type="number" min="0" max="9999" value="500" />
+ </div>
+ <div>
+ <label for="delayMs">Delay (ms)</label>
+ <input id="delayMs" type="number" min="0" max="9999" value="500" />
+ </div>
+ </div>
+ <div class="actions">
+ <button id="sendSelectedBtn">Verstuur geselecteerde</button>
+ <button id="sendAllBtn" class="secondary">Verstuur alle</button>
+ <button id="selectAllBtn" class="secondary">Selecteer alles</button>
+ <button id="selectNoneBtn" class="secondary">Deselecteer alles</button>
+ </div>
+ <div id="msg"></div>
+ </div>
+
+ <div class="card">
+ <div id="servoGrid" class="servo-grid"></div>
+ </div>
+
+ <div class="card">
+ <h1>Log</h1>
+ <div id="log"></div>
+ </div>
+</main>
+<script src="/js/common.js"></script>
+<script src="/js/servos.js"></script>
+</body>
+</html>
diff --git a/RtRobotSharp/wwwroot/settings.html b/RtRobotSharp/wwwroot/settings.html
new file mode 100644
index 0000000..0e0c52f
--- /dev/null
+++ b/RtRobotSharp/wwwroot/settings.html
@@ -0,0 +1,70 @@
+<!doctype html>
+<html lang="nl">
+<head>
+<meta charset="utf-8" />
+<title>RtRobot - Instellingen</title>
+<link rel="stylesheet" href="/css/site.css" />
+</head>
+<body>
+<nav>
+ <a href="/servos.html">Servo's</a>
+ <a href="/settings.html" class="active">Instellingen</a>
+ <span style="margin-left:auto" id="statusText"><span class="status-dot" id="statusDot"></span></span>
+</nav>
+<main>
+ <h1>Serial interface instellingen</h1>
+
+ <div class="card">
+ <div class="row">
+ <div>
+ <label for="portName">Com poort</label>
+ <select id="portName"></select>
+ </div>
+ <div>
+ <label for="baudRate">Baud rate</label>
+ <select id="baudRate">
+ <option>9600</option>
+ <option>19200</option>
+ <option>38400</option>
+ <option>57600</option>
+ <option>115200</option>
+ </select>
+ </div>
+ </div>
+ <div class="row" style="margin-top:12px">
+ <div>
+ <label for="speedMs">Standaard speed (ms)</label>
+ <input id="speedMs" type="number" min="0" max="9999" />
+ </div>
+ <div>
+ <label for="delayMs">Standaard delay (ms)</label>
+ <input id="delayMs" type="number" min="0" max="9999" />
+ </div>
+ <div>
+ <label for="servoCount">Aantal servo's</label>
+ <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" />
+ </div>
+ </div>
+
+ <div class="actions">
+ <button id="saveBtn">Opslaan</button>
+ <button id="connectBtn" class="secondary">Verbinden</button>
+ <button id="disconnectBtn" class="secondary" disabled>Verbreken</button>
+ <button id="restartBtn" class="danger">Interface Restart (~RE)</button>
+ </div>
+ <div id="msg"></div>
+ </div>
+
+ <div class="card">
+ <h1>Log</h1>
+ <div id="log"></div>
+ </div>
+</main>
+<script src="/js/common.js"></script>
+<script src="/js/settings.js"></script>
+</body>
+</html>