WebHID

Talk to a human-interface device that no higher-level API covers — a gamepad with custom reports, a keyboard with extra keys, simulation controls, point-of-sale hardware — via IHid (the WebHID API): open it, send output / feature reports, and subscribe to its live input-report stream. WASM-only: requestDevice() needs a live user gesture and the live device handle, and it's Chromium-family only at the time of writing.

RequestDevicesAsync shows the browser chooser and returns the granted devices (empty if dismissed). Open a device, then WatchInputReportsAsync pushes each input report (and an optional disconnect signal) to your callback; dispose the watch and the device to release. Report payloads cross as byte[]. Gate on IsSupportedAsync.

HidDemo.cs

using Rask.Site;
using Rask.Wasm.Browser;

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IHid" /> — pair with a HID device from a gesture, open it, and watch its input-report stream
///     live. WASM-only: requestDevice() needs a live user gesture and the live device handle, and it's
///     Chromium-family only at the time of writing. Move/press the device after "Watch" to see reports arrive.
/// </summary>
public sealed partial class HidDemo(IHid hid) : Component, IAsyncDisposable
{
    private IHidDevice? _device;
    private HidDeviceInfo? _info;
    private IAsyncDisposable? _watch;
    private int _reportCount;
    private string _lastReport = "—";
    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 mb-2")[
                    Button.Class(Tw.BtnPrimary).Id("hid-request").OnClickAsync(RequestDevice)[
                        UiIcon.Name(UiIconName.Cube).Class("me-1"), "Pair device"],
                    Button
                        .Class(Tw.BtnOutlinePrimary)
                        .Id("hid-watch")
                        .Disabled(_device is null || _watch is not null)
                        .OnClickAsync(Watch)["Open & watch"],
                    Button
                        .Class(Tw.BtnOutlineDanger)
                        .Id("hid-close")
                        .Disabled(_device is null)
                        .OnClickAsync(Release)["Release"]
                ],
                _info is null
                    ? Div.Class("text-sm text-ui-muted")["No device paired."]
                    : Dl.Class("grid grid-cols-12 gap-4 text-sm mb-2").Id("hid-info")[
                        Dt.Class("col-span-5 sm:col-span-4 text-ui-muted")["Vendor ID"],
                        Dd.Class("col-span-7 sm:col-span-8")[Code["0x" + _info.VendorId.ToString("x4")]],
                        Dt.Class("col-span-5 sm:col-span-4 text-ui-muted")["Product ID"],
                        Dd.Class("col-span-7 sm:col-span-8")[Code["0x" + _info.ProductId.ToString("x4")]],
                        Dt.Class("col-span-5 sm:col-span-4 text-ui-muted")["Product"],
                        Dd.Class("col-span-7 sm:col-span-8")[_info.ProductName ?? "—"]
                    ],
                Div.Class("text-sm text-ui-muted")["Reports: ", Code.Id("hid-count")[_reportCount.ToString()]],
                Div.Class("text-sm text-ui-muted")["Last: ", Code.Id("hid-last")[_lastReport]],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("hid-status")[_status]]
            ]
        ];

    private async Task RequestDevice()
    {
        try
        {
            if (!await hid.IsSupportedAsync())
            {
                _status = "WebHID not supported in this browser (Chromium-family only)";
                return;
            }

            await CloseInternal();
            var devices = await hid.RequestDevicesAsync(); // no filters → offer all devices
            _device = devices.Count > 0 ? devices[0] : null;
            _info = _device?.Info;
            _status = _device is null ? "No device selected" : "Paired — click Open & watch";
        }
        catch (Exception ex)
        {
            _status = "Failed: " + ex.Message;
        }
    }

    private async Task Watch()
    {
        if (_device is null || _watch is not null)
        {
            return;
        }

        try
        {
            await _device.OpenAsync();
            _watch = await _device.WatchInputReportsAsync(OnReport, OnDisconnect);
            _status = "Watching — interact with the device";
        }
        catch (Exception ex)
        {
            _status = "Open/watch failed: " + ex.Message;
        }
    }

    private Task OnReport(HidInputReport report)
    {
        // Input reports can arrive very fast (a gamepad fires ~60–125/s); coalesce so we don't re-render the
        // whole card per report — track every one, but repaint at most every 4th.
        _reportCount++;
        _lastReport = $"#{report.ReportId} [{Convert.ToHexString(report.Data)}]";
        if (_reportCount % 4 == 0)
        {
            StateHasChanged();
        }

        return Task.CompletedTask;
    }

    private Task OnDisconnect()
    {
        _device = null;
        _info = null;
        _watch = null;
        _status = "Device disconnected";
        StateHasChanged();
        return Task.CompletedTask;
    }

    private async Task Release()
    {
        await CloseInternal();
        _status = "Released — device closed";
    }

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

        if (_device is not null)
        {
            await _device.DisposeAsync();
            _device = null;
            _info = null;
        }
    }

    public async ValueTask DisposeAsync() => await CloseInternal();
}
Live result
No device paired.
Reports: 0
Last:
Status: (idle)