Web Bluetooth

Pair with a Bluetooth Low Energy device and talk to its GATT services from C# — connect, read / write characteristics, and subscribe to notifications (heart-rate monitors, thermometers, fitness sensors, custom hardware) — via IBluetooth (the Web Bluetooth API). WASM-only: requestDevice() needs a live user gesture and the live device handle, and it's Chromium-family only at the time of writing.

RequestDeviceAsync shows the chooser and returns an IBluetoothDevice (null if dismissed). Connect, then GetCharacteristicAsync(service, characteristic) → read/write/WatchAsync (notifications). This demo reads the standard Battery Service. Values cross as byte[]; dispose the device to drop the connection. Gate on IsSupportedAsync.

BluetoothDemo.cs

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

namespace Rask.Site.Features;

/// <summary>
///     <see cref="IBluetooth" /> — pair with a Bluetooth Low Energy device that advertises the standard
///     Battery Service, connect to its GATT server, and read the battery level. WASM-only: requestDevice()
///     needs a live user gesture and the live device handle, and it's Chromium-family only at the time of
///     writing. Reads the <c>battery_level</c> characteristic (0–100%).
/// </summary>
public sealed partial class BluetoothDemo(IBluetooth bluetooth) : Component, IAsyncDisposable
{
    private IBluetoothDevice? _device;
    private IAsyncDisposable? _disconnectWatch;
    private string? _name;
    private string _battery = "—";
    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("bt-request").OnClickAsync(PairAndRead)[
                        UiIcon.Name(UiIconName.Signal).Class("me-1"), "Pair & read battery"],
                    Button
                        .Class(Tw.BtnOutlineDanger)
                        .Id("bt-disconnect")
                        .Disabled(_device is null)
                        .OnClickAsync(Disconnect)["Disconnect"]
                ],
                _name 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("bt-info")[
                        Dt.Class("col-span-5 sm:col-span-4 text-ui-muted")["Device"],
                        Dd.Class("col-span-7 sm:col-span-8")[_name],
                        Dt.Class("col-span-5 sm:col-span-4 text-ui-muted")["Battery"],
                        Dd.Class("col-span-7 sm:col-span-8")[Code.Id("bt-battery")[_battery]]
                    ],
                Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("bt-status")[_status]]
            ]
        ];

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

            await CloseInternal();
            _device = await bluetooth.RequestDeviceAsync(new BluetoothRequestOptions(
                Filters: [new BluetoothFilter(Services: ["battery_service"])]));
            if (_device is null)
            {
                _status = "No device selected";
                return;
            }

            _name = _device.Info.Name ?? _device.Info.Id;
            _disconnectWatch = await _device.WatchDisconnectAsync(OnDisconnect);

            await _device.ConnectAsync();
            var level = await _device.GetCharacteristicAsync("battery_service", "battery_level");
            var bytes = await level.ReadAsync();
            _battery = bytes.Length > 0 ? $"{bytes[0]}%" : "(empty)";
            _status = "Connected — battery read";
        }
        catch (Exception ex)
        {
            _status = "Failed: " + ex.Message;
        }
    }

    private async Task OnDisconnect()
    {
        // The device dropped its GATT link — release the handle (and its watch) so nothing leaks, then reset.
        await CloseInternal();
        _status = "Device disconnected";
        StateHasChanged();
    }

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

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

        if (_device is not null)
        {
            await _device.DisposeAsync();
            _device = null;
            _name = null;
            _battery = "—";
        }
    }

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