Skip to content
Shiny.NET

Services, Characteristics, & Descriptors

GATT (Generic Attribute Profile) defines how BLE devices exchange data. The hierarchy is:

  • Service — A collection of related characteristics
  • Characteristic — A data value with read/write/notify properties
  • Descriptor — Metadata about a characteristic

In Shiny v4, all GATT operations are performed directly on IPeripheral using service and characteristic UUIDs — no need to dig through nested GATT objects.

IPeripheral peripheral; // connected peripheral
// Get all services
var services = await peripheral.GetServicesAsync();
foreach (var svc in services)
{
Console.WriteLine($"Service: {svc.Uuid}");
}
// Get a specific service
var service = await peripheral.GetServiceAsync("180D");
// Get all characteristics for a service
var characteristics = await peripheral.GetCharacteristicsAsync("180D");
foreach (var ch in characteristics)
{
Console.WriteLine($"Char: {ch.Uuid}, Properties: {ch.Properties}");
}
// Get all characteristics across all services
var all = await peripheral.GetAllCharacteristicsAsync();
// Reactive
peripheral
.ReadCharacteristic("180D", "2A37")
.Subscribe(result =>
{
var data = result.Data;
});
// Async
var result = await peripheral.ReadCharacteristicAsync("180D", "2A37");
var data = result.Data;
var data = new byte[] { 0x01, 0x02 };
// Write with response (default)
peripheral
.WriteCharacteristic("service-uuid", "char-uuid", data, withResponse: true)
.Subscribe(result => { });
// Write without response
peripheral
.WriteCharacteristic("service-uuid", "char-uuid", data, withResponse: false)
.Subscribe(result => { });
// Async
await peripheral.WriteCharacteristicAsync("service-uuid", "char-uuid", data);

On iOS, tvOS, Mac Catalyst and macOS a write without response goes through CoreBluetooth’s flow control: if the peripheral’s send buffer is full, the write waits for CoreBluetooth to report it is ready before sending. Back-to-back writes are paced for you rather than dropped - await each one in turn, with no delay of your own.

For writing data larger than a single GATT operation, use blob writes, which automatically chunk the stream to peripheral.Mtu (the usable payload). Do not pre-chunk the data yourself. When the peripheral needs to know where the data ends, send a framed message instead.

using var stream = File.OpenRead("data.bin");
peripheral
.WriteCharacteristicBlob("service-uuid", "char-uuid", stream)
.Subscribe(segment =>
{
Console.WriteLine($"Sent {segment.Position}/{segment.TotalLength}");
});

Subscribe to characteristic value changes.

// Subscribe to notifications (auto-reconnects if observable stays hooked)
peripheral
.NotifyCharacteristic("180D", "2A37")
.Subscribe(result =>
{
var data = result.Data;
// Process incoming notification data
});

Watch when notification subscriptions change on a characteristic.

peripheral
.WhenCharacteristicSubscriptionChanged("service-uuid", "char-uuid")
.Subscribe(info =>
{
Console.WriteLine($"IsNotifying: {info.IsNotifying}");
});
var ch = await peripheral.GetCharacteristicAsync("service-uuid", "char-uuid");
if (ch.CanRead()) { /* read supported */ }
if (ch.CanWrite()) { /* write supported */ }
if (ch.CanWriteWithoutResponse()) { /* write without response */ }
if (ch.CanNotifyOrIndicate()) { /* notifications supported */ }

A blob write chunks a stream, but nothing on the far side knows where it ends. When a command, reply or event can be longer than one GATT operation — a JSON command, a certificate, a scan result — and the peripheral needs it whole, use the message helpers. Each message is split to peripheral.Mtu with a one-byte header per fragment and put back together on the far side.

The peripheral must speak the same format — a Shiny BluetoothLE Hosting service using [RequestResponseCharacteristic(Framed = true)], or NotifyMessage and a BleMessageReassembler.

using Shiny.BluetoothLE;
// subscribe first - a request/response reply comes back as notifications
using var replies = peripheral
.NotifyCharacteristicMessages("service-uuid", "char-uuid")
.Subscribe(message =>
{
// one emission per complete message
});
await peripheral.WriteCharacteristicMessageAsync(
"service-uuid",
"char-uuid",
JsonSerializer.SerializeToUtf8Bytes(command, AppJsonContext.Default.Command)
);

WriteCharacteristicMessageAsync(serviceUuid, characteristicUuid, message, withResponse, cancellationToken, timeoutMs) writes the fragments in order. Messages to the same characteristic are sent one at a time, so concurrent callers cannot interleave fragments. timeoutMs (3 seconds by default) applies to each fragment’s write, not the whole message. Leave withResponse on unless the peripheral only accepts commands.

If a write fails part way through, the peripheral discards the partial message when the next message’s first fragment arrives.

NotifyCharacteristicMessages(serviceUuid, characteristicUuid, useIndicationsIfAvailable, maxMessageBytes) is a cold observable. Each subscription subscribes to the characteristic and gets its own reassembler. A message with a dropped or reordered fragment, or one larger than maxMessageBytes (64 KB by default), is discarded rather than emitted, and the stream carries on with the next one.

Every fragment starts with one header byte — bit 7 START, bit 6 END, bits 0-5 a sequence number that counts fragments within the message and wraps at 64. A message that fits in one fragment has both START and END set, so a short message costs one byte. BleMessageFraming.Encode and BleMessageReassembler are public in Shiny.BluetoothLE.Common if you need to frame over another transport.

// Get descriptors for a characteristic
var descriptors = await peripheral.GetDescriptorsAsync("service-uuid", "char-uuid");
// Read a descriptor
var result = await peripheral.ReadDescriptorAsync("service-uuid", "char-uuid", "descriptor-uuid");
var data = result.Data;
// Write a descriptor
await peripheral.WriteDescriptorAsync("service-uuid", "char-uuid", "descriptor-uuid", new byte[] { 0x01 });

Shiny provides built-in helpers for common BLE services.

// Read device information (service 180A)
var deviceInfo = await peripheral.ReadDeviceInformationAsync();
Console.WriteLine($"Manufacturer: {deviceInfo.ManufacturerName}");
Console.WriteLine($"Model: {deviceInfo.ModelNumber}");
Console.WriteLine($"Firmware: {deviceInfo.FirmwareRevision}");
// Read battery level (service 180F)
var battery = await peripheral.ReadBatteryInformation().ToTask();
Console.WriteLine($"Battery: {battery}%");
// Heart rate sensor (service 180D)
peripheral
.HeartRateSensor()
.Subscribe(bpm => Console.WriteLine($"Heart Rate: {bpm} BPM"));

Reliable Write Transactions (Android Only)

Section titled “Reliable Write Transactions (Android Only)”
var transaction = peripheral.TryBeginTransaction();
if (transaction != null)
{
await transaction
.Write(peripheral, "service-uuid", "char-uuid", data1)
.ToTask();
await transaction
.Write(peripheral, "service-uuid", "char-uuid2", data2)
.ToTask();
// Commit all writes atomically
await transaction.Commit().ToTask();
// Or abort
// transaction.Abort();
transaction.Dispose();
}