Services, Characteristics, & Descriptors
Overview
Section titled “Overview”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.
Service Discovery
Section titled “Service Discovery”IPeripheral peripheral; // connected peripheral
// Get all servicesvar services = await peripheral.GetServicesAsync();foreach (var svc in services){ Console.WriteLine($"Service: {svc.Uuid}");}
// Get a specific servicevar service = await peripheral.GetServiceAsync("180D");Characteristics
Section titled “Characteristics”Discovering Characteristics
Section titled “Discovering Characteristics”// Get all characteristics for a servicevar characteristics = await peripheral.GetCharacteristicsAsync("180D");foreach (var ch in characteristics){ Console.WriteLine($"Char: {ch.Uuid}, Properties: {ch.Properties}");}
// Get all characteristics across all servicesvar all = await peripheral.GetAllCharacteristicsAsync();Reading
Section titled “Reading”// Reactiveperipheral .ReadCharacteristic("180D", "2A37") .Subscribe(result => { var data = result.Data; });
// Asyncvar result = await peripheral.ReadCharacteristicAsync("180D", "2A37");var data = result.Data;Writing
Section titled “Writing”var data = new byte[] { 0x01, 0x02 };
// Write with response (default)peripheral .WriteCharacteristic("service-uuid", "char-uuid", data, withResponse: true) .Subscribe(result => { });
// Write without responseperipheral .WriteCharacteristic("service-uuid", "char-uuid", data, withResponse: false) .Subscribe(result => { });
// Asyncawait 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.
BLOB Writes
Section titled “BLOB Writes”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}"); });Notifications
Section titled “Notifications”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 });Subscription Changes
Section titled “Subscription Changes”Watch when notification subscriptions change on a characteristic.
peripheral .WhenCharacteristicSubscriptionChanged("service-uuid", "char-uuid") .Subscribe(info => { Console.WriteLine($"IsNotifying: {info.IsNotifying}"); });Checking Characteristic Properties
Section titled “Checking Characteristic Properties”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 */ }Messages Longer Than One Operation
Section titled “Messages Longer Than One Operation”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 notificationsusing 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));Writing
Section titled “Writing”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.
Receiving
Section titled “Receiving”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.
The Format
Section titled “The Format”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.
Descriptors
Section titled “Descriptors”// Get descriptors for a characteristicvar descriptors = await peripheral.GetDescriptorsAsync("service-uuid", "char-uuid");
// Read a descriptorvar result = await peripheral.ReadDescriptorAsync("service-uuid", "char-uuid", "descriptor-uuid");var data = result.Data;
// Write a descriptorawait peripheral.WriteDescriptorAsync("service-uuid", "char-uuid", "descriptor-uuid", new byte[] { 0x01 });Standard Services
Section titled “Standard Services”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();}

