L2CAP Channels
Overview
Section titled “Overview”L2CAP Connection-Oriented Channels (CoC) provide a streaming socket-like channel over BLE, suitable for transferring large blobs or high-throughput data without the overhead of GATT characteristic reads/writes.
In Shiny.BluetoothLE the central role exposes L2CAP through the optional ICanL2Cap capability on IPeripheral. The peripheral must have published a PSM (e.g. via Shiny.BluetoothLE.Hosting.OpenL2Cap(...)) — see BluetoothLE Hosting → L2CAP.
Capability Detection
Section titled “Capability Detection”Not every backend supports L2CAP. Use the helpers in FeatureL2Cap to feature-detect at runtime:
using Shiny.BluetoothLE;
if (peripheral.IsL2CapAvailable()){ // Backend supports L2CAP}Or call the safe extension that returns an empty observable on unsupported platforms:
peripheral .TryOpenL2CapChannel(psm: 0x0083, secure: false) .Subscribe(channel => { /* ... */ });Opening a Channel
Section titled “Opening a Channel”When the peripheral implements ICanL2Cap, you can open a channel directly:
if (peripheral is ICanL2Cap l2cap){ l2cap .OpenL2CapChannel(psm: 0x0083, secure: false) .Subscribe(channel => { // channel.Psm — the PSM the channel was opened on // channel.Identifier — the remote peer identifier // channel.DataReceived — IObservable<byte[]> of incoming bytes // channel.Write(bytes) — IObservable<Unit> that completes when bytes are queued });}The returned L2CapChannel is an IDisposable record. Disposing it closes the underlying streams (Apple) or socket (Android) and releases platform resources.
Reading Data
Section titled “Reading Data”DataReceived is a hot observable. Each emission is a right-sized byte[] payload as bytes become available from the remote endpoint. It completes when the remote endpoint closes the channel (Apple EndEncountered, Android InputStream EOF) and surfaces I/O errors via OnError.
channel.DataReceived.Subscribe( payload => Console.WriteLine($"Got {payload.Length} bytes"), ex => Console.WriteLine($"Channel error: {ex.Message}"), () => Console.WriteLine("Channel closed"));Writing Data
Section titled “Writing Data”Write is a Func<byte[], IObservable<Unit>> that returns a single-shot observable completing when the bytes have been queued to the platform write buffer. Await it (via ToTask()) or chain via Rx.
using System.Reactive.Threading.Tasks;
await channel.Write(payload).ToTask();For larger transfers, batch writes through your own back-pressure strategy — the underlying buffer may apply flow control (Apple HasSpaceAvailable).
Closing the Channel
Section titled “Closing the Channel”Dispose the L2CapChannel to close the channel and release resources:
using (var channel = await peripheral .TryOpenL2CapChannel(0x0083, secure: false) .FirstAsync() .ToTask()){ // Use the channel}Platform Notes
Section titled “Platform Notes”| Platform | API | Notes |
|---|---|---|
| iOS / Mac Catalyst / macOS | CBPeripheral.OpenL2CapChannel |
The secure flag is ignored — security is determined by how the peripheral published the channel. |
| Android | BluetoothDevice.CreateL2capChannel / CreateInsecureL2capChannel |
Requires API 29+. Opening throws InvalidOperationException on older versions. The secure flag selects between the two APIs. |
| Linux | AF_BLUETOOTH / BTPROTO_L2CAP / SOCK_SEQPACKET socket |
BlueZ does not expose CoC over D-Bus, so the implementation opens the kernel socket directly. Peer BD-address and address type are read from org.bluez.Device1.Address / AddressType. secure=true sets BT_SECURITY_MEDIUM via setsockopt(SOL_BLUETOOTH, BT_SECURITY); secure=false sets BT_SECURITY_LOW. LE dynamic PSMs (≥ 0x80) do not require CAP_NET_RAW — unprivileged users can open channels. |
| Blazor WASM / Windows | — | Not supported. Web Bluetooth does not expose L2CAP; WinRT has no public LE CoC surface. |
File Upload & Download
Section titled “File Upload & Download”SendFile (below) just pushes bytes — the peer has to already know what it is receiving. The upload/download helpers add a small framed protocol on top of the channel so both peers agree on the file name and the exact byte count before a single body byte moves. That agreement is what makes percent-complete and ETA real on both ends.
The peripheral must be serving the same protocol — see BluetoothLE Hosting → L2CAP File Server.
One-Shot Transfers
Section titled “One-Shot Transfers”UploadFile / DownloadFile on IPeripheral open a channel, run the transfer, and close the channel again:
using Shiny.BluetoothLE;
var result = await peripheral.UploadFile( psm: 0x0083, localFilePath: "/path/to/reading.csv", remoteFileName: "reading.csv", // optional - defaults to the local file name secure: false, onProgress: p => Console.WriteLine( $"{p.PercentComplete:P0} {p.BytesPerSecond / 1024} KB/s, ETA {p.EstimatedTimeRemaining}" ), cancellationToken: ct);
Console.WriteLine($"Sent {result.BytesTransferred} bytes in {result.Elapsed} ({result.BytesPerSecond / 1024} KB/s)");var result = await peripheral.DownloadFile( psm: 0x0083, remoteFileName: "firmware.bin", localFilePath: Path.Combine(FileSystem.AppDataDirectory, "firmware.bin"), onProgress: p => Console.WriteLine($"{p.PercentComplete:P0}"));The download path is created (and overwritten) for you, and truncated to the received length. If the transfer fails, the partial file is deleted rather than left behind.
Rx Variants
Section titled “Rx Variants”UploadFileWithProgress / DownloadFileWithProgress return IObservable<TransferProgress> — progress emissions as the body streams, a final 100% emission, then completion. Disposing the subscription cancels the transfer and closes the channel.
peripheral .DownloadFileWithProgress(0x0083, "firmware.bin", localPath) .Subscribe( p => this.Percent = p.PercentComplete, ex => Console.WriteLine($"Failed: {ex.Message}"), () => Console.WriteLine("Done") );Several Files Over One Channel
Section titled “Several Files Over One Channel”The one-shot methods open and close a channel per file. To move several files over a single channel, open it yourself and call the same helpers on the L2CapChannel:
using var channel = await peripheral.OpenL2CapChannelAsync(psm: 0x0083, secure: false, cancellationToken: ct);
await channel.UploadFile("/path/to/a.bin", cancellationToken: ct);await channel.UploadFile("/path/to/b.bin", cancellationToken: ct);await channel.DownloadFile("config.json", localConfigPath, cancellationToken: ct);OpenL2CapChannelAsync is the awaitable form of ICanL2Cap.OpenL2CapChannel — it throws NotSupportedException on platforms without L2CAP rather than returning an empty observable.
Stream overloads exist on both sides for non-file sources. Uploads require the exact length up front — the peer preallocates against it and uses it for percent-complete:
await channel.UploadFile(sourceStream, "reading.csv", length: sourceStream.Length, cancellationToken: ct);await channel.DownloadFile("config.json", destinationStream, cancellationToken: ct);Serving Requests
Section titled “Serving Requests”A central can also be the serving side — useful when the peripheral drives the exchange. ReadFileRequest waits for the peer’s next request and hands back an L2CapFileRequest to accept or reject:
var request = await channel.ReadFileRequest(cancellationToken: ct);if (request == null) return; // peer closed the channel
if (request.Type == L2CapTransferType.Upload){ // peer wants to send us request.FileName (request.Size bytes) await request.AcceptUpload(Path.Combine(inboxDir, "inbound.bin"), cancellationToken: ct);}else{ // peer wants request.FileName from us await request.AcceptDownload("/path/to/local.bin", cancellationToken: ct);}Every request must be answered — with one of the accept methods or Reject(...) — before the next one is read; the peer is blocked waiting on the answer.
await request.Reject(L2CapTransferError.NotPermitted, "Not accepting uploads right now");request.FileName comes straight off the wire — treat it as untrusted input and never hand it to the filesystem unresolved.
L2CapFileRequest member |
Description |
|---|---|
Type |
Upload (the peer wants to send you a file) or Download (it wants one from you) |
FileName |
The name the peer asked for — untrusted |
Size |
Bytes the peer intends to send; 0 on a download request, where you supply the size |
PeerIdentifier |
The remote peer identifier |
Psm |
The PSM the channel is running on |
IsAnswered |
Whether the request has already been accepted or rejected |
Tuning
Section titled “Tuning”L2CapTransferOptions is accepted by every transfer method:
| Member | Default | Description |
|---|---|---|
BufferSize |
4096 |
Bytes read/written per chunk. Larger values mean fewer syscalls but coarser progress |
ProgressInterval |
2 seconds | How often onProgress fires while the body streams. A completion event always fires regardless |
IdleTimeout |
30 seconds | How long a read may stall before the transfer is abandoned. Use Timeout.InfiniteTimeSpan to wait forever |
var options = new L2CapTransferOptions{ BufferSize = 16 * 1024, ProgressInterval = TimeSpan.FromMilliseconds(500), IdleTimeout = TimeSpan.FromMinutes(2)};
await peripheral.DownloadFile(psm, "firmware.bin", localPath, options: options);Results & Errors
Section titled “Results & Errors”A completed transfer returns an L2CapTransferResult:
| Member | Description |
|---|---|
Type |
Upload or Download, as named by the peer that initiated it — both peers report the same value |
FileName |
The file name agreed with the peer |
BytesTransferred |
Total bytes moved |
Elapsed |
Wall-clock duration |
BytesPerSecond |
Average throughput across the whole transfer |
A refusal or failure throws L2CapTransferException (a BleException) carrying an L2CapTransferError:
| Error | Meaning |
|---|---|
NotFound |
The requested file does not exist on the peer |
NotPermitted |
The peer refused it — uploads/downloads disabled, or the name was rejected |
TooLarge |
The upload exceeds what the peer will accept |
IoError |
The peer hit an IO error reading or writing the file |
ProtocolError |
The peers fell out of sync, or the channel is not speaking this protocol |
Cancelled |
The peer cancelled the transfer |
Unknown |
No cause was reported |
try{ await peripheral.DownloadFile(psm, "firmware.bin", localPath);}catch (L2CapTransferException ex) when (ex.Error == L2CapTransferError.NotFound){ Console.WriteLine("The peripheral doesn't have that file");}A rejection leaves the channel usable for further requests. A ProtocolError does not — open a new channel.
Raw Streaming
Section titled “Raw Streaming”L2CapChannelExtensions.SendFile streams a file (or any Stream) over the channel with HTTP-transfer-style progress metrics — bytes-per-second, bytes-transferred, percent-complete, estimated time remaining. There is no handshake and no framing: the receiver gets raw bytes on DataReceived and is responsible for knowing what they mean. Use the upload/download helpers when you want the peers to agree on a name and length.
using Shiny.BluetoothLE;
await channel.SendFile( "/path/to/file.bin", bufferSize: 4096, onProgress: p => Console.WriteLine( $"{p.PercentComplete:P0} ({p.BytesTransferred}/{p.BytesToTransfer}) " + $"{p.BytesPerSecond / 1024} KB/s, ETA {p.EstimatedTimeRemaining}" ), cancellationToken: ct);The onProgress callback fires roughly every two seconds during the transfer (with BytesPerSecond computed from the window of bytes written since the last emission) and once more on completion with BytesPerSecond = 0 and BytesTransferred = BytesToTransfer.
A Stream overload is available for non-file sources:
await channel.SendFile( source: networkStream, totalBytes: contentLength, // pass null when the length is unknown onProgress: p => ...);When totalBytes is null, TransferProgress.IsDeterministic is false, PercentComplete returns -1, and EstimatedTimeRemaining returns TimeSpan.Zero.
Channel as a Stream
Section titled “Channel as a Stream”AsStream() wraps an open channel as a System.IO.Stream, so it can be handed to anything that reads and writes streams — CopyToAsync, a JSON serializer, a hash, a decoder:
using Shiny.BluetoothLE;
using var channel = await peripheral.OpenL2CapChannelAsync(psm: 0x0083, secure: false, cancellationToken: ct);await using var stream = channel.AsStream();
await stream.WriteAsync(request, ct);
await using var file = File.Create(localPath);await stream.CopyToAsync(file, ct); // completes when the peer closes the channelThe stream subscribes to DataReceived the moment it is created, so create it as soon as the channel opens — bytes the peer sends before anything is subscribed are lost.
| Member | Description |
|---|---|
MaxWriteSize |
The largest single write handed to the channel; larger writes are split. Defaults to 4096 and can be changed after creation |
BytesRead / BytesWritten |
Running totals |
Channel |
The underlying L2CapChannel |
L2CapChannelStream is asynchronous only — the synchronous Read and Write throw NotSupportedException — and it cannot seek. ReadAsync returns 0 once the peer closes the channel. Disposing the stream closes the channel; pass leaveOpen: true to keep it.
Reads go through the same buffered reader the upload/download helpers use, so a stream and a file transfer can take turns on one channel without losing bytes between them.
Ticketed Channels
Section titled “Ticketed Channels”A host running L2CapTicketBroker listens on one PSM for every transfer it offers. It hands out a PSM and a single-use token over an authenticated route — typically the reply to a GATT command — and a channel has to present that token before it carries anything.
OpenL2CapTicketChannel opens the channel, presents the token, and returns the channel as a stream once the host accepts it:
using Shiny.BluetoothLE;
// psm and token came back from the peripheral, e.g. as the reply to a "capture" commandtry{ await using var stream = await peripheral.OpenL2CapTicketChannel(psm, token, cancellationToken: ct);
await using var file = File.Create(localPath); await stream.CopyToAsync(file, ct); // the host closes the channel when its transfer ends}catch (L2CapTicketException ex) when (ex.Status == L2CapTicketStatus.UnknownTicket){ // the ticket expired before the channel presented it - request the transfer again}| Parameter | Default | Description |
|---|---|---|
psm |
— | The PSM the peripheral named alongside the ticket |
token |
— | The ticket’s token |
secure |
true |
Must match how the peripheral’s listener was opened — L2CapTicketBroker defaults to true |
maxWriteSize |
4096 |
The largest single write on the returned stream |
cancellationToken |
— | Cancels the connect and the handshake |
It throws NotSupportedException on platforms that cannot open L2CAP channels, and L2CapTicketException when the host refuses the ticket. Status says why:
L2CapTicketStatus |
Meaning |
|---|---|
UnknownTicket |
The host issued no such ticket, or it expired — request the transfer again |
AlreadyClaimed |
Another channel already claimed this ticket. Tokens are single use |
VersionMismatch |
The two sides speak different versions of the handshake — one side needs updating |
Malformed |
The host rejected the handshake |
Busy |
The host is already carrying as many transfers as it will |
A refused channel has already been closed. Never retry with the same token — ask the host for a new one.
If you opened the channel yourself, ClaimTicket runs the same handshake on it:
var channel = await peripheral.OpenL2CapChannelAsync(psm, secure: true, cancellationToken: ct);await using var stream = await channel.ClaimTicket(token, timeout: TimeSpan.FromSeconds(10), cancellationToken: ct);timeout bounds the wait for the host’s answer and defaults to 20 seconds. If the handshake fails for any reason, the channel is disposed.
TransferProgress
Section titled “TransferProgress”Every progress callback on this page — SendFile, the upload/download helpers, and the hosting file server — reports the same record, which mirrors Shiny.Net.Http.TransferProgress:
| Member | Description |
|---|---|
BytesPerSecond |
Throughput in the most recent ~2s window |
BytesToTransfer |
Total bytes expected, or null when unknown |
BytesTransferred |
Bytes written so far |
IsDeterministic |
true when BytesToTransfer is known |
PercentComplete |
0.0–1.0, or -1 when not deterministic |
EstimatedTimeRemaining |
TimeSpan based on current throughput; Zero when unknown |
Naming collision: if you reference both
Shiny.BluetoothLEandShiny.Net.Httpin the same compilation, both expose aTransferProgresstype. Use a file-levelusingalias or fully-qualified name to disambiguate.
Complete Example
Section titled “Complete Example”using System.Reactive.Linq;using System.Reactive.Threading.Tasks;using Shiny.BluetoothLE;
async Task RunSession(IPeripheral peripheral, ushort psm){ if (!peripheral.IsL2CapAvailable()) throw new PlatformNotSupportedException("L2CAP not supported on this platform.");
var channel = await peripheral .TryOpenL2CapChannel(psm, secure: false) .FirstAsync() .ToTask();
using (channel) { var receiveSub = channel.DataReceived.Subscribe(payload => Console.WriteLine($"<< {payload.Length} bytes") );
await channel.Write(new byte[] { 0x01, 0x02, 0x03 }).ToTask(); await channel.Write(new byte[] { 0xAA, 0xBB, 0xCC }).ToTask();
await Task.Delay(TimeSpan.FromSeconds(5)); receiveSub.Dispose(); }}

