diff --git a/README.md b/README.md index 3fd8bc6..ccc5e30 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ Uses [Tmds.DBus](https://github.com/tmds/Tmds.DBus) to access D-Bus. Tmds.DBus.T dotnet add package HashtagChris.DotNetBlueZ --version 1.1.0-alpha ``` +# Events + +C# events are available for several properties. Events are useful for properly handling disconnects and reconnects. + # Usage ## Get a Bluetooth adapter @@ -36,40 +40,52 @@ IAdapter1 adapter = await BlueZManager.GetAdapterAsync(adapterName: "hci0"); ## Scan for Bluetooth devices ```C# +adapter.DeviceFound += adapter_DeviceFoundAsync; + await adapter.StartDiscoveryAsync(); ... await adapter.StopDiscoveryAsync(); ``` -You can optionally use the extension method `IAdapter1.WatchDevicesAddedAsync` to monitor for new devices being found during the scan. - ## Get Devices +`adapter.DeviceFound` (above) will be called immediately for existing devices, and as new devices show up during scanning; `eventArgs.IsStateChange` can be used to distinguish between existing and new devices. Alternatively you can can use `GetDevicesAsync`: + ```C# -IReadOnlyList devices = await adapter.GetDevicesAsync(); +IReadOnlyList devices = await adapter.GetDevicesAsync(); ``` ## Connect to a Device +```C# +device.Connected += device_ConnectedAsync; +device.Disconnected += device_DisconnectedAsync; +device.ServicesResolved += device_ServicesResolvedAsync; + +await device.ConnectAsync(); +``` + +Alternatively you can wait for "Connected" and "ServicesResolved" to equal true: + ```C# TimeSpan timeout = TimeSpan.FromSeconds(15); await device.ConnectAsync(); await device.WaitForPropertyValueAsync("Connected", value: true, timeout); +await device.WaitForPropertyValueAsync("ServicesResolved", value: true, timeout); + ``` ## Retrieve a GATT Service and Characteristic +Prerequisite: You must be connected to a device and services must be resolved. You may need to pair with the device in order to use some services. + Example using GATT Device Information Service UUIDs. ```C# string serviceUUID = "0000180a-0000-1000-8000-00805f9b34fb"; string characteristicUUID = "00002a24-0000-1000-8000-00805f9b34fb"; -TimeSpan timeout = TimeSpan.FromSeconds(15); - -await device.WaitForPropertyValueAsync("ServicesResolved", value: true, timeout); - IGattService1 service = await device.GetServiceAsync(serviceUUID); IGattCharacteristic1 characteristic = await service.GetCharacteristicAsync(characteristicUUID); ``` @@ -82,6 +98,28 @@ byte[] value = await characteristic.ReadValueAsync(timeout); string modelName = Encoding.UTF8.GetString(value); ``` +## Subscribe to GATT Characteristic Notifications + +```C# +characteristic.Value += characteristic_Value; +... + +private static async Task characteristic_Value(GattCharacteristic characteristic, GattCharacteristicValueEventArgs e) +{ + try + { + Console.WriteLine($"Characteristic value (hex): {BitConverter.ToString(e.Value)}"); + + Console.WriteLine($"Characteristic value (UTF-8): \"{Encoding.UTF8.GetString(e.Value)}\""); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } +} + +``` + # Reference * [BlueZ D-Bus API docs](https://git.kernel.org/pub/scm/bluetooth/bluez.git/tree/doc) diff --git a/demo/scan/Program.cs b/demo/scan/Program.cs index 8e54938..f28ffaf 100644 --- a/demo/scan/Program.cs +++ b/demo/scan/Program.cs @@ -1,6 +1,5 @@ using System; using System.Linq; -using System.Text; using System.Threading.Tasks; using HashtagChris.DotNetBlueZ; using HashtagChris.DotNetBlueZ.Extensions; diff --git a/demo/subscribeToCharacteristic/Program.cs b/demo/subscribeToCharacteristic/Program.cs new file mode 100644 index 0000000..93af09b --- /dev/null +++ b/demo/subscribeToCharacteristic/Program.cs @@ -0,0 +1,318 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using HashtagChris.DotNetBlueZ; +using HashtagChris.DotNetBlueZ.Extensions; + +// An event-driven example that subscribes to one GATT characteristic and prints the value on updates. +namespace subscribeToCharacteristic +{ + class Program + { + // TODO: Is there a good characteristic that works for a wide variety of people? + // Battery level doesn't work because BlueZ gives that a separate interface. + // Current time seems promising, but the iPhone doesn't seem to notify current time subscribers. + private const string DefaultServiceUUID = GattConstants.ANCServiceUUID; + private const string DefaultCharacteristicUUID = GattConstants.ANCSNotificationSourceUUID; + + private static string s_deviceFilter; + private static string s_serviceUUID; + private static string s_characteristicUUID; + + private static TimeSpan timeout = TimeSpan.FromSeconds(15); + + private static async Task Main(string[] args) + { + if (args.Length < 1 || args.Length == 3) + { + Console.WriteLine("Usage: subscribeToCharacteristic | [adapterName] [serviceUUID characteristicUUID]"); + Console.WriteLine(@"Examples: + subscribeToCharacteristic phone + subscribeToCharacteristic 8C:8E:F2:AB:73:76 hci0 CAFE CFFE (see https://github.com/hashtagchris/early-iOS-BluetoothLowEnergy-tests/tree/master/myFirstPeripheral)"); + Console.WriteLine(); + Console.WriteLine($"Default service: {DefaultServiceUUID}"); + Console.WriteLine($"Default characteristic: {DefaultCharacteristicUUID}"); + return; + } + + s_deviceFilter = args[0]; + + Adapter adapter; + if (args.Length > 1) + { + adapter = await BlueZManager.GetAdapterAsync(args[1]); + } + else + { + var adapters = await BlueZManager.GetAdaptersAsync(); + if (adapters.Count == 0) + { + throw new Exception("No Bluetooth adapters found."); + } + + adapter = adapters.First(); + } + + s_serviceUUID = BlueZManager.NormalizeUUID(args.Length > 3 + ? args[2] + : DefaultServiceUUID); + + s_characteristicUUID = BlueZManager.NormalizeUUID(args.Length > 3 + ? args[3] + : DefaultCharacteristicUUID); + + var adapterPath = adapter.ObjectPath.ToString(); + var adapterName = adapterPath.Substring(adapterPath.LastIndexOf("/") + 1); + Console.WriteLine($"Using Bluetooth adapter {adapterName}"); + + adapter.PoweredOn += adapter_PoweredOnAsync; + adapter.DeviceFound += adapter_DeviceFoundAsync; + + Console.WriteLine("Waiting for events. Use Control-C to quit."); + Console.WriteLine(); + await Task.Delay(-1); + } + + private static async Task adapter_PoweredOnAsync(Adapter adapter, BlueZEventArgs e) + { + try + { + if (e.IsStateChange) + { + Console.WriteLine("Bluetooth adapter powered on."); + } + else + { + Console.WriteLine("Bluetooth adapter already powered on."); + } + + Console.WriteLine("Starting scan..."); + await adapter.StartDiscoveryAsync(); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } + } + + private static async Task adapter_DeviceFoundAsync(Adapter adapter, DeviceFoundEventArgs e) + { + try + { + var device = e.Device; + + var deviceDescription = await GetDeviceDescriptionAsync(device); + if (e.IsStateChange) + { + Console.WriteLine($"Found: [NEW] {deviceDescription}"); + } + else + { + Console.WriteLine($"Found: {deviceDescription}"); + } + + var deviceAddress = await device.GetAddressAsync(); + var deviceName = await device.GetAliasAsync(); + if (deviceAddress.Equals(s_deviceFilter, StringComparison.OrdinalIgnoreCase) + || deviceName.Contains(s_deviceFilter, StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine("Stopping scan...."); + try + { + await adapter.StopDiscoveryAsync(); + Console.WriteLine("Stopped."); + } + catch (Exception ex) + { + // Best effort. Sometimes BlueZ gets in a state where you can't stop the scan. + Console.Error.WriteLine($"Error stopping scan: {ex.Message}"); + } + + device.Connected += device_ConnectedAsync; + device.Disconnected += device_DisconnectedAsync; + device.ServicesResolved += device_ServicesResolvedAsync; + Console.WriteLine($"Connecting to {await device.GetAddressAsync()}..."); + await device.ConnectAsync(); + } + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } + } + + private static async Task device_ConnectedAsync(Device device, BlueZEventArgs e) + { + try + { + if (e.IsStateChange) + { + Console.WriteLine($"Connected to {await device.GetAddressAsync()}"); + } + else + { + Console.WriteLine($"Already connected to {await device.GetAddressAsync()}"); + } + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } + } + + private static async Task device_DisconnectedAsync(Device device, BlueZEventArgs e) + { + try + { + Console.WriteLine($"Disconnected from {await device.GetAddressAsync()}"); + + await Task.Delay(TimeSpan.FromSeconds(15)); + + Console.WriteLine($"Attempting to reconnect to {await device.GetAddressAsync()}..."); + await device.ConnectAsync(); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } + } + + private static async Task device_ServicesResolvedAsync(Device device, BlueZEventArgs e) + { + try + { + if (e.IsStateChange) + { + Console.WriteLine($"Services resolved for {await device.GetAddressAsync()}"); + } + else + { + Console.WriteLine($"Services already resolved for {await device.GetAddressAsync()}"); + } + + var servicesUUIDs = await device.GetUUIDsAsync(); + Console.WriteLine($"Device offers {servicesUUIDs.Length} service(s)."); + // foreach (var uuid in servicesUUIDs) + // { + // Console.WriteLine(uuid); + // } + + var service = await device.GetServiceAsync(s_serviceUUID); + if (service == null) + { + Console.WriteLine($"Service UUID {s_serviceUUID} not found. Do you need to pair first?"); + return; + } + + var characteristic = await service.GetCharacteristicAsync(s_characteristicUUID); + if (characteristic == null) + { + Console.WriteLine($"Characteristic UUID {s_characteristicUUID} not found within service {s_serviceUUID}."); + return; + } + + Console.WriteLine(); + + // Subscribe to the characteristic's value. Be notified of updates. + characteristic.Value += characteristic_Value; + + // Attempt to read the current value. Some characteristics only support Notify. + byte[] value; + try + { + Console.WriteLine("Reading current characteristic value..."); + value = await characteristic.GetValueAsync(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error reading characteristic value: {ex.Message}"); + return; + } + + PrintCharacteristicValue(s_characteristicUUID, value); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } + } + + private static async Task characteristic_Value(GattCharacteristic characteristic, GattCharacteristicValueEventArgs e) + { + try + { + var uuid = await characteristic.GetUUIDAsync(); + PrintCharacteristicValue(uuid, e.Value); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } + } + + private static void PrintCharacteristicValue(string uuid, byte[] value) + { + if (String.Equals(uuid, GattConstants.CurrentTimeCharacteristicUUID, StringComparison.OrdinalIgnoreCase)) + { + var currentTime = ReadCurrentTime(value); + Console.WriteLine($"Current time: {currentTime}"); + } + else if (String.Equals(uuid, GattConstants.ANCSNotificationSourceUUID, StringComparison.OrdinalIgnoreCase)) + { + PrintAncsDescription(value); + } + else + { + // Default + Console.WriteLine($"[{DateTime.Now}] Characteristic value (hex): {BitConverter.ToString(value)}"); + try + { + var stringValue = Encoding.UTF8.GetString(value); + Console.WriteLine($"[{DateTime.Now}] Characteristic value (UTF-8): \"{stringValue}\""); + } + catch (Exception) {} + } + } + + private static async Task GetDeviceDescriptionAsync(IDevice1 device) + { + var deviceProperties = await device.GetAllAsync(); + return $"{deviceProperties.Address} (Alias: {deviceProperties.Alias}, RSSI: {deviceProperties.RSSI})"; + } + + private static void PrintAncsDescription(byte[] value) + { + if (value.Length < 8) + { + throw new ArgumentException("8 bytes are required for ANCS notifications."); + } + + var eventIds = new string[] { "added", "modified", "removed" }; + var categoryIds = new string[] { "Other", "IncomingCall", "MissedCall", "Voicemail", "Social", "Schedule", "Email", "News", "Health & Fitness", "Business & Finance", "Location", "Entertainment" }; + + byte[] notificationUid = new byte[4]; + Array.Copy(value, 4, notificationUid, 0, 4); + + Console.WriteLine($"{categoryIds[value[2]]} notification {eventIds[value[0]]} (Count: {value[3]}) (UID: {BitConverter.ToString(notificationUid)})"); + } + + private static DateTime ReadCurrentTime(byte[] value) + { + if (value.Length < 7) + { + throw new ArgumentException("7+ bytes are required for the current date time."); + } + + // https://github.com/sputnikdev/bluetooth-gatt-parser/blob/master/src/main/resources/gatt/characteristic/org.bluetooth.characteristic.date_time.xml + var year = value[0] + 256 * value[1]; + var month = value[2]; + var day = value[3]; + var hour = value[4]; + var minute = value[5]; + var second = value[6]; + + return new DateTime(year, month, day, hour, minute, second, DateTimeKind.Local); + } + } +} diff --git a/demo/subscribeToCharacteristic/subscribeToCharacteristic.csproj b/demo/subscribeToCharacteristic/subscribeToCharacteristic.csproj new file mode 100644 index 0000000..d2d9bdd --- /dev/null +++ b/demo/subscribeToCharacteristic/subscribeToCharacteristic.csproj @@ -0,0 +1,12 @@ + + + + Exe + netcoreapp2.2 + + + + + + + diff --git a/events_todo.md b/events_todo.md new file mode 100644 index 0000000..f8fc2a7 --- /dev/null +++ b/events_todo.md @@ -0,0 +1,45 @@ +* Should I fire `DeviceFound` events for already present devices so consumers don't have to check the current state after adding an event handler? I could indicate in the eventArgs if the device is new so existing devices could be ignored or processed differently. Should I fire these events on the event_add thread before returning, or queue a task? Same idea for device `Connected` and adapter state changes (e.g. `PoweredOn` or `Ready`). +* What's the harm in not disposing of the handler that WatchPropertiesAsync returns? +* Should I use a finalizer instead of making consumers deal with disposing every device they get? Would a finalizer be good enough? + +* In C# are you supposed to unsubscribe from every event to prevent leaks? + +Answer: Generally yes, so the garbage collection can collect the subscribing object. + +* Should I call WatchPropertiesAsync only when the first event is subscribed to? I'll have to call it synchronously, and add mutexs to do refcounting reliably. + +Update: Tried this, resulted in a hang on `m_proxy.WatchPropertiesAsync(OnPropertyChanges).GetAwaiter().GetResult()`. + +```C# + public event EventHandler Disconnected + { + add + { + lock (this) + { + if (m_watcher == null) + { + m_watcher = m_proxy.WatchPropertiesAsync(OnPropertyChanges).GetAwaiter().GetResult(); + } + + m_disconnected += value; + m_refcount++; + } + } + remove + { + lock (this) + { + m_disconnected -= value; + m_refcount--; + + // Should we instead check if m_disconnected and other events are all null/empty? + if (m_refcount == 0) + { + m_watcher.Dispose(); + m_watcher = null; + } + } + } + } +``` \ No newline at end of file diff --git a/src/Adapter.cs b/src/Adapter.cs new file mode 100644 index 0000000..2e85b36 --- /dev/null +++ b/src/Adapter.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using HashtagChris.DotNetBlueZ.Extensions; +using Tmds.DBus; + +namespace HashtagChris.DotNetBlueZ +{ + public delegate Task DeviceChangeEventHandlerAsync(Adapter sender, DeviceFoundEventArgs eventArgs); + + public delegate Task AdapterEventHandlerAsync(Adapter sender, BlueZEventArgs eventArgs); + + /// + /// Add events to IAdapter1. + /// + public class Adapter : IAdapter1, IDisposable + { + ~Adapter() + { + Dispose(); + } + + internal static async Task CreateAsync(IAdapter1 proxy) + { + var adapter = new Adapter + { + m_proxy = proxy, + }; + + var objectManager = Connection.System.CreateProxy(BluezConstants.DbusService, "/"); + adapter.m_interfacesWatcher = await objectManager.WatchInterfacesAddedAsync(adapter.OnDeviceAdded); + adapter.m_propertyWatcher = await proxy.WatchPropertiesAsync(adapter.OnPropertyChanges); + + return adapter; + } + + public void Dispose() + { + m_interfacesWatcher?.Dispose(); + m_interfacesWatcher = null; + + GC.SuppressFinalize(this); + } + + public event DeviceChangeEventHandlerAsync DeviceFound + { + add + { + m_deviceFound += value; + FireEventForExistingDevicesAsync(); + } + remove + { + m_deviceFound -= value; + } + } + + public event AdapterEventHandlerAsync PoweredOn + { + add + { + m_poweredOn += value; + FireEventIfPropertyAlreadyTrueAsync(m_poweredOn, "Powered"); + } + remove + { + m_poweredOn -= value; + } + } + + public event AdapterEventHandlerAsync PoweredOff; + + public ObjectPath ObjectPath => m_proxy.ObjectPath; + + public Task GetAllAsync() + { + return m_proxy.GetAllAsync(); + } + + public Task GetAsync(string prop) + { + return m_proxy.GetAsync(prop); + } + + public Task GetDiscoveryFiltersAsync() + { + return m_proxy.GetDiscoveryFiltersAsync(); + } + + public Task RemoveDeviceAsync(ObjectPath Device) + { + return m_proxy.RemoveDeviceAsync(Device); + } + + public Task SetAsync(string prop, object val) + { + return m_proxy.SetAsync(prop, val); + } + + public Task SetDiscoveryFilterAsync(IDictionary Properties) + { + return m_proxy.SetDiscoveryFilterAsync(Properties); + } + + public Task StartDiscoveryAsync() + { + return m_proxy.StartDiscoveryAsync(); + } + + public Task StopDiscoveryAsync() + { + return m_proxy.StopDiscoveryAsync(); + } + + public Task WatchPropertiesAsync(Action handler) + { + return m_proxy.WatchPropertiesAsync(handler); + } + + private async void FireEventForExistingDevicesAsync() + { + var devices = await this.GetDevicesAsync(); + foreach (var device in devices) + { + m_deviceFound?.Invoke(this, new DeviceFoundEventArgs(device, isStateChange: false)); + } + } + + private async void OnDeviceAdded((ObjectPath objectPath, IDictionary> interfaces) args) + { + if (BlueZManager.IsMatch(BluezConstants.DeviceInterface, args.objectPath, args.interfaces, this)) + { + var device = Connection.System.CreateProxy(BluezConstants.DbusService, args.objectPath); + + var dev = await Device.CreateAsync(device); + m_deviceFound?.Invoke(this, new DeviceFoundEventArgs(dev)); + } + } + + private async void FireEventIfPropertyAlreadyTrueAsync(AdapterEventHandlerAsync handler, string prop) + { + try + { + var value = await m_proxy.GetAsync(prop); + if (value) + { + // TODO: Suppress duplicate event from OnPropertyChanges. + handler?.Invoke(this, new BlueZEventArgs(isStateChange: false)); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error checking if '{prop}' is already true: {ex}"); + } + } + + private void OnPropertyChanges(PropertyChanges changes) + { + foreach (var pair in changes.Changed) + { + switch (pair.Key) + { + case "Powered": + if (true.Equals(pair.Value)) + { + m_poweredOn?.Invoke(this, new BlueZEventArgs()); + } + else + { + PoweredOff?.Invoke(this, new BlueZEventArgs()); + } + break; + } + } + } + + private IAdapter1 m_proxy; + private IDisposable m_interfacesWatcher; + private IDisposable m_propertyWatcher; + private DeviceChangeEventHandlerAsync m_deviceFound; + private AdapterEventHandlerAsync m_poweredOn; + } +} diff --git a/src/BlueZManager.cs b/src/BlueZManager.cs index c34e45d..8fa5dcc 100644 --- a/src/BlueZManager.cs +++ b/src/BlueZManager.cs @@ -8,7 +8,7 @@ namespace HashtagChris.DotNetBlueZ { public static class BlueZManager { - public static async Task GetAdapterAsync(string adapterName) + public static async Task GetAdapterAsync(string adapterName) { var adapterObjectPath = $"/org/bluez/{adapterName}"; var adapter = Connection.System.CreateProxy(BluezConstants.DbusService, adapterObjectPath); @@ -22,12 +22,32 @@ public static async Task GetAdapterAsync(string adapterName) throw new Exception($"Bluetooth adapter {adapterName} not found."); } - return adapter; + return await Adapter.CreateAsync(adapter); } - public static Task> GetAdaptersAsync() + public static async Task> GetAdaptersAsync() { - return GetProxiesAsync(BluezConstants.AdapterInterface, rootObject: null); + var adapters = await GetProxiesAsync(BluezConstants.AdapterInterface, rootObject: null); + + return await Task.WhenAll(adapters.Select(Adapter.CreateAsync)); + } + + // Normalize a 16, 32 or 128 bit UUID. + public static string NormalizeUUID(string uuid) + { + // TODO: Improve this validation. + if (uuid.Length == 4) { + return $"0000{uuid}-0000-1000-8000-00805f9b34fb".ToLowerInvariant(); + } + else if (uuid.Length == 8) { + return $"{uuid}-0000-1000-8000-00805f9b34fb".ToLowerInvariant(); + } + else if (uuid.Length == 36) { + return uuid.ToLowerInvariant(); + } + else { + throw new ArgumentException($"'{uuid}' isn't a valid 16, 32 or 128 bit UUID."); + } } /// The interface to search for diff --git a/src/Constants.cs b/src/Constants.cs index 6decfff..08ae230 100644 --- a/src/Constants.cs +++ b/src/Constants.cs @@ -11,11 +11,31 @@ public static class BluezConstants public const string GattCharacteristicInterface = "org.bluez.GattCharacteristic1"; } - public class GattConstants + // https://www.bluetooth.com/specifications/gatt/ + + public static class GattConstants { - // https://www.bluetooth.org/docman/handlers/downloaddoc.ashx?doc_id=244369 + // Device Information public const string DeviceInformationServiceUUID = "0000180a-0000-1000-8000-00805f9b34fb"; public const string ModelNameCharacteristicUUID = "00002a24-0000-1000-8000-00805f9b34fb"; public const string ManufacturerNameCharacteristicUUID = "00002a29-0000-1000-8000-00805f9b34fb"; + + // Current Time + public const string CurrentTimeServiceUUID = "00001805-0000-1000-8000-00805f9b34fb"; + public const string CurrentTimeCharacteristicUUID = "00002a2b-0000-1000-8000-00805f9b34fb"; + + // Battery Service + // BlueZ presents this service a separate interface, Battery1. + // public const string BatteryServiceUUID = "0000180f-0000-1000-8000-00805f9b34fb"; + // public const string BatteryLevelCharacteristicUUID = "00002a19-0000-1000-8000-00805f9b34fb"; + + // Apple Notification Center Service (ANCS) + // https://developer.apple.com/library/ios/documentation/CoreBluetooth/Reference/AppleNotificationCenterServiceSpecification/Introduction/Introduction.html + public const string ANCServiceUUID = "7905f431-b5ce-4e99-a40f-4b1e122d00d0"; + + // TODO: Lowercase these. + public const string ANCSNotificationSourceUUID = "9FBF120D-6301-42D9-8C58-25E699A21DBD"; + public const string ANCSControlPointUUID = "69D1D8F3-45E1-49A8-9821-9BBDFDAAD9D9"; + public const string ANCSDataSourceUUID = "22EAC6E9-24D6-4BB5-BE44-B36ACE7C7BFB"; } } diff --git a/src/Device.cs b/src/Device.cs new file mode 100644 index 0000000..460be94 --- /dev/null +++ b/src/Device.cs @@ -0,0 +1,166 @@ +using System; +using System.Threading.Tasks; +using Tmds.DBus; + +namespace HashtagChris.DotNetBlueZ +{ + public delegate Task DeviceEventHandlerAsync(Device sender, BlueZEventArgs eventArgs); + + /// + /// Adds events to IDevice1. + /// + public class Device : IDevice1, IDisposable + { + ~Device() + { + Dispose(); + } + + internal static async Task CreateAsync(IDevice1 proxy) + { + var device = new Device + { + m_proxy = proxy, + }; + device.m_propertyWatcher = await proxy.WatchPropertiesAsync(device.OnPropertyChanges); + + return device; + } + + public void Dispose() + { + m_propertyWatcher?.Dispose(); + m_propertyWatcher = null; + + GC.SuppressFinalize(this); + } + + public event DeviceEventHandlerAsync Connected + { + add + { + m_connected += value; + FireEventIfPropertyAlreadyTrueAsync(m_connected, "Connected"); + } + remove + { + m_connected -= value; + } + } + + public event DeviceEventHandlerAsync Disconnected; + public event DeviceEventHandlerAsync ServicesResolved + { + add + { + m_resolved += value; + FireEventIfPropertyAlreadyTrueAsync(m_resolved, "ServicesResolved"); + } + remove + { + m_resolved -= value; + } + } + + public ObjectPath ObjectPath => m_proxy.ObjectPath; + + public Task CancelPairingAsync() + { + return m_proxy.CancelPairingAsync(); + } + + public Task ConnectAsync() + { + return m_proxy.ConnectAsync(); + } + + public Task ConnectProfileAsync(string UUID) + { + return m_proxy.ConnectProfileAsync(UUID); + } + + public Task DisconnectAsync() + { + return m_proxy.DisconnectAsync(); + } + + public Task DisconnectProfileAsync(string UUID) + { + return m_proxy.DisconnectProfileAsync(UUID); + } + + public Task GetAllAsync() + { + return m_proxy.GetAllAsync(); + } + + public Task GetAsync(string prop) + { + return m_proxy.GetAsync(prop); + } + + public Task PairAsync() + { + return m_proxy.PairAsync(); + } + + public Task SetAsync(string prop, object val) + { + return m_proxy.SetAsync(prop, val); + } + + public Task WatchPropertiesAsync(Action handler) + { + return m_proxy.WatchPropertiesAsync(handler); + } + + private async void FireEventIfPropertyAlreadyTrueAsync(DeviceEventHandlerAsync handler, string prop) + { + try + { + var value = await m_proxy.GetAsync(prop); + if (value) + { + // TODO: Suppress duplicate event from OnPropertyChanges. + handler?.Invoke(this, new BlueZEventArgs(isStateChange: false)); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error checking if '{prop}' is already true: {ex}"); + } + } + + private void OnPropertyChanges(PropertyChanges changes) + { + foreach (var pair in changes.Changed) + { + switch (pair.Key) + { + case "Connected": + if (true.Equals(pair.Value)) + { + m_connected?.Invoke(this, new BlueZEventArgs()); + } + else + { + Disconnected?.Invoke(this, new BlueZEventArgs()); + } + break; + + case "ServicesResolved": + if (true.Equals(pair.Value)) + { + m_resolved?.Invoke(this, new BlueZEventArgs()); + } + break; + } + } + } + + private IDevice1 m_proxy; + private IDisposable m_propertyWatcher; + private event DeviceEventHandlerAsync m_connected; + private event DeviceEventHandlerAsync m_resolved; + } +} \ No newline at end of file diff --git a/src/EventArgs.cs b/src/EventArgs.cs new file mode 100644 index 0000000..1698a3e --- /dev/null +++ b/src/EventArgs.cs @@ -0,0 +1,35 @@ +using System; + +namespace HashtagChris.DotNetBlueZ +{ + public class BlueZEventArgs : EventArgs + { + public BlueZEventArgs(bool isStateChange = true) + { + IsStateChange = isStateChange; + } + + public bool IsStateChange { get; } + } + + public class DeviceFoundEventArgs : BlueZEventArgs + { + public DeviceFoundEventArgs(Device device, bool isStateChange = true) + : base(isStateChange) + { + Device = device; + } + + public Device Device { get; } + } + + public class GattCharacteristicValueEventArgs : EventArgs + { + public GattCharacteristicValueEventArgs(byte[] value) + { + Value = value; + } + + public byte[] Value { get; } + } +} \ No newline at end of file diff --git a/src/Extensions.cs b/src/Extensions.cs index b1ccb69..9ec8bf0 100644 --- a/src/Extensions.cs +++ b/src/Extensions.cs @@ -8,12 +8,14 @@ namespace HashtagChris.DotNetBlueZ.Extensions { public static class Extensions { - public static Task> GetDevicesAsync(this IAdapter1 adapter) + public static async Task> GetDevicesAsync(this IAdapter1 adapter) { - return BlueZManager.GetProxiesAsync(BluezConstants.DeviceInterface, adapter); + var devices = await BlueZManager.GetProxiesAsync(BluezConstants.DeviceInterface, adapter); + + return await Task.WhenAll(devices.Select(Device.CreateAsync)); } - public static async Task GetDeviceAsync(this IAdapter1 adapter, string deviceAddress) + public static async Task GetDeviceAsync(this IAdapter1 adapter, string deviceAddress) { var devices = await BlueZManager.GetProxiesAsync(BluezConstants.DeviceInterface, adapter); @@ -32,38 +34,29 @@ public static async Task GetDeviceAsync(this IAdapter1 adapter, string throw new Exception($"{matches.Count} devices found with the address {deviceAddress}!"); } - return matches.FirstOrDefault(); - } - - - public static Task WatchDevicesAddedAsync(this IAdapter1 adapter, Action handler) - { - void OnDeviceAdded((ObjectPath objectPath, IDictionary> interfaces) args) + var dev = matches.FirstOrDefault(); + if (dev != null) { - if (BlueZManager.IsMatch(BluezConstants.DeviceInterface, args.objectPath, args.interfaces, adapter)) - { - var device = Connection.System.CreateProxy(BluezConstants.DbusService, args.objectPath); - handler(device); - } + return await Device.CreateAsync(dev); } - - var objectManager = Connection.System.CreateProxy(BluezConstants.DbusService, "/"); - return objectManager.WatchInterfacesAddedAsync(OnDeviceAdded); + return null; } - public static Task WatchDevicesRemovedAsync(this IAdapter1 adapter, Action handler) + public static Task WatchDevicesAddedAsync(this IAdapter1 adapter, Action handler) { - void OnDeviceAdded((ObjectPath objectPath, String[] interfaces) args) + async void OnDeviceAdded((ObjectPath objectPath, IDictionary> interfaces) args) { if (BlueZManager.IsMatch(BluezConstants.DeviceInterface, args.objectPath, args.interfaces, adapter)) { var device = Connection.System.CreateProxy(BluezConstants.DbusService, args.objectPath); - handler(device); + + var dev = await Device.CreateAsync(device); + handler(dev); } } var objectManager = Connection.System.CreateProxy(BluezConstants.DbusService, "/"); - return objectManager.WatchInterfacesRemovedAsync(OnDeviceAdded); + return objectManager.WatchInterfacesAddedAsync(OnDeviceAdded); } public static async Task GetServiceAsync(this IDevice1 device, string serviceUUID) @@ -72,7 +65,9 @@ public static async Task GetServiceAsync(this IDevice1 device, st foreach (var service in services) { - if (await service.GetUUIDAsync() == serviceUUID) + var uuid = await service.GetUUIDAsync(); + // Console.WriteLine($"Checking {uuid}"); + if (String.Equals(uuid, serviceUUID, StringComparison.OrdinalIgnoreCase)) { return service; } @@ -81,15 +76,18 @@ public static async Task GetServiceAsync(this IDevice1 device, st return null; } - public static async Task GetCharacteristicAsync(this IGattService1 service, string characteristicUUID) + public static async Task GetCharacteristicAsync(this IGattService1 service, string characteristicUUID) { var characteristics = await BlueZManager.GetProxiesAsync(BluezConstants.GattCharacteristicInterface, service); foreach (var characteristic in characteristics) { - if (await characteristic.GetUUIDAsync() == characteristicUUID) + var uuid = await characteristic.GetUUIDAsync(); + // Console.WriteLine($"Checking {uuid}"); + if (String.Equals(uuid, characteristicUUID, StringComparison.OrdinalIgnoreCase)) { - return characteristic; + var ch = await GattCharacteristic.CreateAsync(characteristic); + return ch; } } diff --git a/src/GattCharacteristic.cs b/src/GattCharacteristic.cs new file mode 100644 index 0000000..2006028 --- /dev/null +++ b/src/GattCharacteristic.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Tmds.DBus; + +namespace HashtagChris.DotNetBlueZ +{ + public delegate Task GattCharacteristicEventHandlerAsync(GattCharacteristic sender, GattCharacteristicValueEventArgs eventArgs); + + /// + /// Adds events to IGattCharacteristic1. + /// + public class GattCharacteristic : IGattCharacteristic1, IDisposable + { + ~GattCharacteristic() + { + Dispose(); + } + + internal static async Task CreateAsync(IGattCharacteristic1 proxy) + { + var characteristic = new GattCharacteristic + { + m_proxy = proxy, + }; + + characteristic.m_propertyWatcher = await proxy.WatchPropertiesAsync(characteristic.OnPropertyChanges); + + return characteristic; + } + + public void Dispose() + { + Console.WriteLine("GattCharacteristic disposing."); + m_propertyWatcher?.Dispose(); + m_propertyWatcher = null; + + GC.SuppressFinalize(this); + } + + public event GattCharacteristicEventHandlerAsync Value + { + add + { + m_value += value; + + // Subscribe here instead of CreateAsync, because not all GATT characteristics are notifable. + Subscribe(); + } + remove + { + m_value -= value; + } + } + + public ObjectPath ObjectPath => m_proxy.ObjectPath; + + public Task ReadValueAsync(IDictionary Options) + { + return m_proxy.ReadValueAsync(Options); + } + + public Task WriteValueAsync(byte[] Value, IDictionary Options) + { + return m_proxy.WriteValueAsync(Value, Options); + } + + public Task<(CloseSafeHandle fd, ushort mtu)> AcquireWriteAsync(IDictionary Options) + { + return m_proxy.AcquireWriteAsync(Options); + } + + public Task<(CloseSafeHandle fd, ushort mtu)> AcquireNotifyAsync(IDictionary Options) + { + return m_proxy.AcquireNotifyAsync(Options); + } + + public Task StartNotifyAsync() + { + return m_proxy.StartNotifyAsync(); + } + + public Task StopNotifyAsync() + { + return m_proxy.StopNotifyAsync(); + } + + public Task GetAsync(string prop) + { + return m_proxy.GetAsync(prop); + } + + public Task GetAllAsync() + { + return m_proxy.GetAllAsync(); + } + + public Task SetAsync(string prop, object val) + { + return m_proxy.SetAsync(prop, val); + } + + public Task WatchPropertiesAsync(Action handler) + { + return m_proxy.WatchPropertiesAsync(handler); + } + + private async void Subscribe() + { + try + { + await m_proxy.StartNotifyAsync(); + + // Is there a way to check if a characteristic supports Read? + // // Reading the current value will trigger OnPropertyChanges. + // var options = new Dictionary(); + // var value = await m_proxy.ReadValueAsync(options); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error subscribing to characteristic value: {ex}"); + } + } + + private void OnPropertyChanges(PropertyChanges changes) + { + // Console.WriteLine("OnPropertyChanges called."); + foreach (var pair in changes.Changed) + { + switch (pair.Key) + { + case "Value": + m_value?.Invoke(this, new GattCharacteristicValueEventArgs((byte[])pair.Value)); + break; + } + } + } + + private IGattCharacteristic1 m_proxy; + private IDisposable m_propertyWatcher; + private event GattCharacteristicEventHandlerAsync m_value; + } +} \ No newline at end of file