Web Serial

Talk to a serial device — an Arduino or microcontroller, a GPS, a USB-to-serial adapter — straight from C# via ISerial (the Web Serial API): pick a port, write a line, and watch inbound bytes stream into the log. WASM-only: requestPort() needs a live user gesture and the live port stream, and it's Chromium-family only at the time of writing.

RequestPortAsync shows the browser port chooser, opens the port, and starts a read loop that pushes inbound bytes to your callback; it returns null if the user dismisses the chooser (not an error). Dispose the port to stop reading and release it. Gate on IsSupportedAsync.

SerialDemo.cs

using System.Text;
using Rask.Site;
using Rask.Wasm.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="ISerial" /> — talk to a serial device (Arduino / microcontroller, USB-to-serial adapter)
///     from C# in the browser: pick a port from a gesture, write a line, and watch inbound bytes stream into
///     the log. WASM-only: <c>requestPort()</c> needs a live user gesture and the live port stream, and it's
///     Chromium-family only at the time of writing.
/// </summary>
public sealed partial class SerialDemo(ISerial serial) : Component, IAsyncDisposable
{
    private ISerialPort? _port;
    private int _baudRate = 9600;
    private string _outgoing = string.Empty;
    private readonly List<string> _log = [];
    private string _status = "(idle)";

    protected override Component? Render() =>
        Div.Class($"{Tw.Card} shadow-sm border-0")[
            Div.Class(Tw.CardBody)[
                Div.Class("flex gap-2 flex-wrap items-center mb-2")[
                    Label.Class("text-sm text-ui-muted mb-0").For("serial-baud")["Baud"],
                    Input
                        .Value(_baudRate.ToString())
                        .Id("serial-baud")
                        .Type(InputType.Number)
                        .Class(Tw.Input)
                        .Style("width: 7rem")
                        .Disabled(_port is not null)
                        .OnInput(v => int.TryParse(v, out _baudRate)),
                    Button
                        .Class(Tw.BtnPrimary)
                        .Id("serial-connect")
                        .Disabled(_port is not null)
                        .OnClickAsync(Connect)[UiIcon.Name(UiIconName.Cube).Class("me-1"), "Connect"],
                    Button
                        .Class(Tw.BtnOutlineDanger)
                        .Id("serial-disconnect")
                        .Disabled(_port is null)
                        .OnClickAsync(Disconnect)["Disconnect"]
                ],
                Div.Class($"{Tw.InputGroup} mb-2")[
                    Input
                        .Value(_outgoing)
                        .Id("serial-outgoing")
                        .Class(Tw.Input)
                        .Placeholder("Line to send")
                        .Disabled(_port is null)
                        .OnInput(v => _outgoing = v),
                    Button
                        .Class(Tw.BtnPrimary)
                        .Id("serial-send")
                        .Disabled(_port is null)
                        .OnClickAsync(Send)["Send"]
                ],
                Pre
                    .Class("text-sm bg-slate-900 text-slate-100 rounded p-2 mb-2")
                    .Id("serial-log")
                    .Style("min-height: 6rem; max-height: 12rem; overflow: auto")[
                    _log.Count == 0 ? "(no data yet)" : string.Join("\n", _log)],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("serial-status")[_status]]
            ]
        ];

    private async Task Connect()
    {
        try
        {
            if (!await serial.IsSupportedAsync())
            {
                _status = "Web Serial not supported in this browser (Chromium-family only)";
                return;
            }

            _port = await serial.RequestPortAsync(new SerialOptions(BaudRate: _baudRate), OnData, OnClosed);
            _status = _port is null ? "No port selected" : $"Connected at {_baudRate} baud";
        }
        catch (Exception ex)
        {
            _status = "Failed: " + ex.Message;
        }
    }

    private Task OnData(byte[] data)
    {
        _log.Add(Encoding.UTF8.GetString(data));
        if (_log.Count > 100)
        {
            _log.RemoveRange(0, _log.Count - 100);
        }

        StateHasChanged();
        return Task.CompletedTask;
    }

    private Task OnClosed()
    {
        _port = null;
        _status = "Device disconnected";
        StateHasChanged();
        return Task.CompletedTask;
    }

    private async Task Send()
    {
        if (_port is null)
        {
            return;
        }

        try
        {
            await _port.WriteAsync(Encoding.UTF8.GetBytes(_outgoing + "\n"));
            _status = "Sent: " + _outgoing;
            _outgoing = string.Empty;
        }
        catch (Exception ex)
        {
            _status = "Send failed: " + ex.Message;
        }
    }

    private async Task Disconnect()
    {
        await CloseInternal();
        _status = "Disconnected — port released";
    }

    private async Task CloseInternal()
    {
        if (_port is not null)
        {
            await _port.DisposeAsync();
            _port = null;
        }
    }

    public async ValueTask DisposeAsync() => await CloseInternal();
}
Live result
(no data yet)
Status: (idle)