MASA MAUI Plugin 安卓藍芽低功耗(二)藍芽通訊

語言: CN / TW / HK

專案背景

MAUI的出現,賦予了廣大Net開發者開發多平臺應用的能力,MAUI 是Xamarin.Forms演變而來,但是相比Xamarin效能更好,可擴充套件性更強,結構更簡單。但是MAUI對於平臺相關的實現並不完整。所以MASA團隊開展了一個實驗性專案,意在對微軟MAUI的補充和擴充套件

專案地址 http://github.com/BlazorComponent/MASA.Blazor/tree/main/src/Masa.Blazor.Maui.Plugin

每個功能都有單獨的demo演示專案,考慮到app安裝檔案體積(雖然MAUI已經整合裁剪功能,但是該功能對於程式碼本身有影響),屆時每一個功能都會以單獨的nuget包的形式提供,方便測試,現在專案才剛剛開始,但是相信很快就會有可以交付的內容啦。

前言

本系列文章面向移動開發小白,從零開始進行平臺相關功能開發,演示如何參考平臺的官方文件使用MAUI技術來開發相應功能。

介紹

上一篇文章我們實現了藍芽BLE的掃描功能,這裡我們繼續實現通訊功能。 本文JAVA相關程式碼均來自安卓開發者官網

開發步驟

連線到 GATT 伺服器

通用屬性配置檔案Generic Attribute Profile簡稱GATT。 GATT定義了屬性型別並規定了如何使用,包括了一個數據傳輸和儲存的框架和一些基本操作。中間包含了一些概念如特性characteristics,服務services等。同時還定義了發現服務,特性和服務間的連線的處理過程,也包括讀寫特性值。 我們使用移遠的FC410舉例 在這裡插入圖片描述

通過nRF connect工具可以檢視裝置的配置,該裝置有一個字首為FFFF的主服務,該服務下有一個字首為FF01的特徵,該特徵具有通知Notify 和寫入Write兩種屬性(如果有Notify,那麼就會有描述符)。換句話說我們可以通過這個特徵給裝置傳送資料,而且可以通過訂閱該特徵值變化事件,來獲取裝置通過藍芽的返回資訊。 與 BLE 裝置互動的第一步便是連線到 GATT 伺服器。更具體地說,是連線到裝置上的 GATT 伺服器。 我們先看一下JAVA的實現方式

JAVA程式碼
bluetoothGatt = device.connectGatt(this, false, gattCallback);

連線到 BLE 裝置上的 GATT 伺服器,需要使用 connectGatt() 方法。此方法採用三個引數:一個 Context 物件、autoConnect(布林值,指示是否在可用時自動連線到 BLE 裝置),以及對 BluetoothGattCallback 的引用。該方法 BluetoothGatt 例項,然後可使用該例項執行 GATT 客戶端操作。呼叫方(Android 應用)是 GATT 客戶端。BluetoothGattCallback 用於向客戶端傳遞結果(例如連線狀態),以及任何進一步的 GATT 客戶端操作。 我們再看一下BluetoothGattCallback 的JAVA實現

JAVA 程式碼
// Various callback methods defined by the BLE API.
    private final BluetoothGattCallback gattCallback =
            new BluetoothGattCallback() {
        @Override
        public void onConnectionStateChange(BluetoothGatt gatt, int status,
                int newState) {
            String intentAction;
            if (newState == BluetoothProfile.STATE_CONNECTED) {
                intentAction = ACTION_GATT_CONNECTED;
                connectionState = STATE_CONNECTED;
                broadcastUpdate(intentAction);
                Log.i(TAG, "Connected to GATT server.");
                Log.i(TAG, "Attempting to start service discovery:" +
                        bluetoothGatt.discoverServices());

            } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
                intentAction = ACTION_GATT_DISCONNECTED;
                connectionState = STATE_DISCONNECTED;
                Log.i(TAG, "Disconnected from GATT server.");
                broadcastUpdate(intentAction);
            }
        }

        @Override
        // New services discovered
        public void onServicesDiscovered(BluetoothGatt gatt, int status) {
            if (status == BluetoothGatt.GATT_SUCCESS) {
                broadcastUpdate(ACTION_GATT_SERVICES_DISCOVERED);
            } else {
                Log.w(TAG, "onServicesDiscovered received: " + status);
            }
        }

        @Override
        // Result of a characteristic read operation
        public void onCharacteristicRead(BluetoothGatt gatt,
                BluetoothGattCharacteristic characteristic,
                int status) {
            if (status == BluetoothGatt.GATT_SUCCESS) {
                broadcastUpdate(ACTION_DATA_AVAILABLE, characteristic);
            }
        }
     ...

因為日後還需要實現其他平臺的功能,我們的想法是所有公共部分都放到專案根目錄,平臺相關的實現,放到對應Platforms目錄下對應平臺的資料夾內,然後通過分部類的方式組織類結構。平臺相關的方法起名以Platform為字首。 我們先在Masa.Blazor.Maui.Plugin.Bluetooth專案Platforms->Android目錄新建一個名稱為RemoteGattServer.android.cs的分部類,然後新增初始化方法和BluetoothGattCallback

    partial class RemoteGattServer
    {
        private Android.Bluetooth.BluetoothGatt _gatt;
        private Android.Bluetooth.BluetoothGattCallback _gattCallback;

        private void PlatformInit()
        {
            _gattCallback = new GattCallback(this);
            _gatt = ((Android.Bluetooth.BluetoothDevice)Device).ConnectGatt(Android.App.Application.Context, false, _gattCallback);
        }

        public static implicit operator Android.Bluetooth.BluetoothGatt(RemoteGattServer gatt)
        {
            return gatt._gatt;
        }
        internal event EventHandler<CharacteristicEventArgs> CharacteristicRead;
        internal event EventHandler<GattEventArgs> ServicesDiscovered;
        private bool _servicesDiscovered = false;
...

        internal class GattCallback : Android.Bluetooth.BluetoothGattCallback
        {
            private readonly RemoteGattServer _remoteGattServer;

            internal GattCallback(RemoteGattServer remoteGattServer)
            {
                _remoteGattServer = remoteGattServer;
            }
...
            public override void OnCharacteristicWrite(Android.Bluetooth.BluetoothGatt gatt, Android.Bluetooth.BluetoothGattCharacteristic characteristic, Android.Bluetooth.GattStatus status)
            {
                System.Diagnostics.Debug.WriteLine($"CharacteristicWrite {characteristic.Uuid} Status:{status}");
                _remoteGattServer.CharacteristicWrite?.Invoke(_remoteGattServer, new CharacteristicEventArgs { Characteristic = characteristic, Status = status });
            }
        }
    }
    ...
    internal class ConnectionStateEventArgs : GattEventArgs
    {
        public Android.Bluetooth.ProfileState State
        {
            get; internal set;
        }
    }

    internal class CharacteristicEventArgs : GattEventArgs
    {
        public Android.Bluetooth.BluetoothGattCharacteristic Characteristic
        {
            get; internal set;
        }
    }

在PlatformInit方法中連線到 GATT 伺服器。自定義的GattCallback 整合自 Android.Bluetooth.BluetoothGattCallback,篇幅問題,這裡只展示CharacteristicWrite一個方法的重寫,要實現完整功能還至少需要額外重寫ServicesDiscovered、ConnectionStateChanged、CharacteristicChanged、CharacteristicRead、DescriptorRead、DescriptorWrite四個方法,詳細請參考原始碼。在我們向裝置特徵值傳送資料時,會觸發OnCharacteristicWrite方法,方法內部觸發我們自定義的CharacteristicWrite。

寫入藍芽指令

官方文件示例中沒有給出特徵值寫入的示例,這裡我們自己實現。 我們新建GattCharacteristic類,在專案根目錄新建GattCharacteristic.cs,在Android目錄新建GattCharacteristic.android.cs 在GattCharacteristic.android.cs中新增PlatformWriteValue方法。

        Task PlatformWriteValue(byte[] value, bool requireResponse)
        {
            TaskCompletionSource<bool> tcs = null;

            if (requireResponse)
            {
                tcs = new TaskCompletionSource<bool>();

                void handler(object s, CharacteristicEventArgs e)
                {
                    if (e.Characteristic == _characteristic)
                    {
                        Service.Device.Gatt.CharacteristicWrite -= handler;

                        if (!tcs.Task.IsCompleted)
                        {
                            tcs.SetResult(e.Status == Android.Bluetooth.GattStatus.Success);
                        }
                    }
                };

                Service.Device.Gatt.CharacteristicWrite += handler;
            }

            bool written = _characteristic.SetValue(value);
            _characteristic.WriteType = requireResponse ? Android.Bluetooth.GattWriteType.Default : Android.Bluetooth.GattWriteType.NoResponse;
            written = ((Android.Bluetooth.BluetoothGatt)Service.Device.Gatt).WriteCharacteristic(_characteristic);

            if (written && requireResponse)
                return tcs.Task;

            return Task.CompletedTask;
        }

通過_characteristic.SetValue將需要傳送的位元組陣列儲存到該特徵值的本地儲存中,然後通過WriteCharacteristic傳送到遠端Gatt伺服器。 這裡用到了TaskCompletionSource,主要還是起到非同步轉同步作用。安卓藍芽的寫特徵屬性分為WRITE_TYPE_DEFAULT(寫入)和WRITE_TYPE_NO_RESPONSE(寫入無返回),引數requireResponse就表示是否需要裝置返回,如果需要返回,就將TaskCompletionSource儲存的結果以Task形式返回呼叫者。 我們在GattCharacteristic中新增WriteValueWithResponseAsync方法,表示寫入並等待返回。

        public Task WriteValueWithResponseAsync(byte[] value)
        {
            ThrowOnInvalidValue(value);
            return PlatformWriteValue(value, true);
        }
        
        private void ThrowOnInvalidValue(byte[] value)
        {
            if (value is null)
                throw new ArgumentNullException("value");

            if (value.Length > 512)
                throw new ArgumentOutOfRangeException("value", "Attribute value cannot be longer than 512 bytes");
        }

因為藍芽限制單次寫入的長度最大為512,所以我們這裡做一下長度檢查。 這樣的組織結構,當我們再新增其他平臺的實現程式碼時,就可以直接通過呼叫PlatformWriteValue來呼叫具體平臺的實現程式碼了。 想對藍芽進行寫入操作,當然需要先找到藍芽裝置的服務id和特徵值id才行。所以我們繼續在GattCallback中新增一個OnConnectionStateChange的重寫

internal event EventHandler<GattEventArgs> ServicesDiscovered;
...
internal class GattCallback : Android.Bluetooth.BluetoothGattCallback
        {
        ...
           public override void OnConnectionStateChange(Android.Bluetooth.BluetoothGatt gatt, Android.Bluetooth.GattStatus status, Android.Bluetooth.ProfileState newState)
            {
                System.Diagnostics.Debug.WriteLine($"ConnectionStateChanged Status:{status} NewState:{newState}");
                _remoteGattServer.ConnectionStateChanged?.Invoke(_remoteGattServer, new ConnectionStateEventArgs { Status = status, State = newState });
                if (newState == Android.Bluetooth.ProfileState.Connected)
                {
                    if (!_remoteGattServer._servicesDiscovered)
                        gatt.DiscoverServices();
                }
                else
                {
                    _remoteGattServer.Device.OnGattServerDisconnected();
                }
            }
        }
     private async Task<bool> WaitForServiceDiscovery()
        {
            if (_servicesDiscovered)
                return true;

            TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();

            void handler(object s, GattEventArgs e)
            {
                ServicesDiscovered -= handler;

                if (!tcs.Task.IsCompleted)
                {
                    tcs.SetResult(true);
                }
            };

            ServicesDiscovered += handler;
            return await tcs.Task;
        }

        Task PlatformConnect()
        {
            TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();

            void handler(object s, ConnectionStateEventArgs e)
            {
                ConnectionStateChanged -= handler;

                switch (e.Status)
                {
                    case Android.Bluetooth.GattStatus.Success:
                        tcs.SetResult(e.State == Android.Bluetooth.ProfileState.Connected);
                        break;

                    default:
                        tcs.SetResult(false);
                        break;
                }
            }

            ConnectionStateChanged += handler;
            bool success = _gatt.Connect();
            if (success)
            {
                if (IsConnected)
                    return Task.FromResult(true);

                return tcs.Task;
            }
            else
            {
                ConnectionStateChanged -= handler;
                return Task.FromException(new OperationCanceledException());
            }
        }
       
        async Task<List<GattService>> PlatformGetPrimaryServices(BluetoothUuid? service)
        {
            var services = new List<GattService>();

            await WaitForServiceDiscovery();

            foreach (var serv in _gatt.Services)
            {
                // if a service was specified only add if service uuid is a match
                if (serv.Type == Android.Bluetooth.GattServiceType.Primary && (!service.HasValue || service.Value == serv.Uuid))
                {
                    services.Add(new GattService(Device, serv));
                }
            }

            return services;
        }
        ...
    }
    ...
    internal class GattEventArgs : EventArgs
    {
        public Android.Bluetooth.GattStatus Status
        {
            get; internal set;
        }
    }

當裝置連線或斷開與某個裝置的連線時,會觸發我們重寫的OnConnectionStateChange方法,然後我們在方法內部,判斷如果是連線的狀態(ProfileState.Connected),就去通過gatt服務的DiscoverServices來查詢裝置的服務及特徵值資訊等。 PlatformGetPrimaryServices方法用來找到BLE裝置的所有主服務(通過GattServiceType.Primary來判斷是否為主服務),返回一個GattService列表,GattService類是我們自定義的一個類,鑑於篇幅問題這裡不全部展示

  public sealed partial class GattService
    {
        public Task<IReadOnlyList<GattCharacteristic>> GetCharacteristicsAsync()
        {
            return PlatformGetCharacteristics();
        }
        ...

PlatformGetCharacteristics的具體實現在該類平臺對應的部分類中

    partial class GattService
    {
        private Task<IReadOnlyList<GattCharacteristic>> PlatformGetCharacteristics()
        {
            List<GattCharacteristic> characteristics = new List<GattCharacteristic>();
            foreach (var characteristic in NativeService.Characteristics)
            {
                characteristics.Add(new GattCharacteristic(this, characteristic));
            }
            return Task.FromResult((IReadOnlyList<GattCharacteristic>)characteristics.AsReadOnly());
        }
        ...

開啟藍芽監聽

以上一系列操作我們已經可以拿到具體的這個裝置的服務和具體的特徵值了,對於BLE裝置,大部分都是通過Notify屬性進行廣播的。我們需要開啟一個廣播監聽 我看參考一下JAVA程式碼

JAVA 程式碼
private BluetoothGatt bluetoothGatt;
BluetoothGattCharacteristic characteristic;
boolean enabled;
...
bluetoothGatt.setCharacteristicNotification(characteristic, enabled);
...
BluetoothGattDescriptor descriptor = characteristic.getDescriptor(
        UUID.fromString(SampleGattAttributes.CLIENT_CHARACTERISTIC_CONFIG));
descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
bluetoothGatt.writeDescriptor(descriptor);

開啟廣播監聽的方式是向對應描述符寫入一個指令(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)即可開啟廣播。 我們在GattCharacteristic.android.cs新增PlatformStartNotifications方法

  private async Task PlatformStartNotifications()
        {
            byte[] data;

            if (_characteristic.Properties.HasFlag(Android.Bluetooth.GattProperty.Notify))
                data = Android.Bluetooth.BluetoothGattDescriptor.EnableNotificationValue.ToArray();
            else if (_characteristic.Properties.HasFlag(Android.Bluetooth.GattProperty.Indicate))
                data = Android.Bluetooth.BluetoothGattDescriptor.EnableIndicationValue.ToArray();
            else
                return;

            ((Android.Bluetooth.BluetoothGatt)Service.Device.Gatt).SetCharacteristicNotification(_characteristic, true);
            var descriptor = await GetDescriptorAsync(GattDescriptorUuids.ClientCharacteristicConfiguration);
            await descriptor.WriteValueAsync(data);
        }

這裡判斷是否支援Notify,然後呼叫EnableNotificationValue構造一個開啟監聽的指令data,然後通過GetDescriptorAsync拿到這個特徵值對應的描述符,這裡很簡單隻要呼叫安卓對應特徵值的GetDescriptor即可,這裡就不展示程式碼了。一個BLE裝置如果有通知的屬性,那麼他一定會有描述符,開啟或者關閉通知都需要通過描述符寫入指令來控制,所有對特徵值的操作然後通過WriteValueAsync->PlatformWriteValue來實現。

        Task PlatformWriteValue(byte[] value)
        {
            TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();

            void handler(object s, DescriptorEventArgs e)
            {
                if (e.Descriptor == _descriptor)
                {
                    Characteristic.Service.Device.Gatt.DescriptorWrite -= handler;

                    if (!tcs.Task.IsCompleted)
                    {
                        tcs.SetResult(e.Status == Android.Bluetooth.GattStatus.Success);
                    }
                }
            };

            Characteristic.Service.Device.Gatt.DescriptorWrite += handler;
            bool written = _descriptor.SetValue(value);
            written = ((Android.Bluetooth.BluetoothGatt)Characteristic.Service.Device.Gatt).WriteDescriptor(_descriptor);
            if (written)
                return tcs.Task;

            return Task.FromException(new OperationCanceledException());
        }

接收 GATT 通知

到此我們已經實現了連線裝置、獲取主服務和特徵值、寫入資料、開啟通知監聽,最後還剩一個就是監聽特徵值的變化,為某個特徵啟用通知後,如果遠端裝置上的特徵發生更改(我們收到訊息),則會觸發 onCharacteristicChanged() 回撥:

JAVA程式碼
@Override
// Characteristic notification
public void onCharacteristicChanged(BluetoothGatt gatt,
        BluetoothGattCharacteristic characteristic) {
    broadcastUpdate(ACTION_DATA_AVAILABLE, characteristic);
}

在GattCharacteristic.cs中新增

        void OnCharacteristicValueChanged(GattCharacteristicValueChangedEventArgs args)
        {
            characteristicValueChanged?.Invoke(this, args);
        }
        public event EventHandler<GattCharacteristicValueChangedEventArgs> CharacteristicValueChanged
        {
            add
            {
                characteristicValueChanged += value;
                AddCharacteristicValueChanged();

            }
            remove
            {
                characteristicValueChanged -= value;
                RemoveCharacteristicValueChanged();
            }
        }
        ...
       public sealed class GattCharacteristicValueChangedEventArgs : EventArgs
    	{
	        internal GattCharacteristicValueChangedEventArgs(byte[] newValue)
	        {
	            Value = newValue;
	        }
        public byte[] Value { get; private set; }
    }

在平臺對應的GattCharacteristic.android.cs新增

        void AddCharacteristicValueChanged()
        {
            Service.Device.Gatt.CharacteristicChanged += Gatt_CharacteristicChanged;
        }
        void RemoveCharacteristicValueChanged()
        {
            Service.Device.Gatt.CharacteristicChanged -= Gatt_CharacteristicChanged;
        }
        private void Gatt_CharacteristicChanged(object sender, CharacteristicEventArgs e)
        {
            if (e.Characteristic == _characteristic)
                OnCharacteristicValueChanged(new GattCharacteristicValueChangedEventArgs(e.Characteristic.GetValue()));
        }

這裡的實現思路和之前是一樣的。

測試

我們在MasaMauiBluetoothService新增一個傳送資料的方法

        public async Task SendDataAsync(string deviceName,Guid servicesUuid,Guid? characteristicsUuid, byte[] dataBytes, EventHandler<GattCharacteristicValueChangedEventArgs> gattCharacteristicValueChangedEventArgs)
        {
            BluetoothDevice blueDevice = _discoveredDevices.FirstOrDefault(o => o.Name == deviceName);

            var primaryServices = await blueDevice.Gatt.GetPrimaryServicesAsync();
            var primaryService = primaryServices.First(o => o.Uuid.Value == servicesUuid);

            var characteristics = await primaryService.GetCharacteristicsAsync();
            var characteristic = characteristics.FirstOrDefault(o => (o.Properties & GattCharacteristicProperties.Write) != 0);
            if (characteristicsUuid != null)
            {
                characteristic = characteristics.FirstOrDefault(o => o.Uuid.Value == characteristicsUuid);
            }
            
            await characteristic.StartNotificationsAsync();
            characteristic.CharacteristicValueChanged += gattCharacteristicValueChangedEventArgs;
            await characteristic.WriteValueWithResponseAsync(dataBytes);
        }

在Masa.Blazor.Maui.Plugin.BlueToothSample專案的Index.razor.cs新增測試程式碼

 public partial class Index
    {
        private string SelectedDevice;
        private List<string> _allDeviceResponse = new List<string>();
        [Inject]
        private MasaMauiBluetoothService BluetoothService { get; set; }
...
        private async Task SendDataAsync(string cmd= "AT+QVERSION")
        {
            var byteData = System.Text.Encoding.Default.GetBytes(cmd);
            await SendDataAsync(SelectedDevice, byteData);
        }

        private async Task SendDataAsync(string deviceSerialNo, byte[] byteData)
        {
            if (byteData.Any())
            {
                _allDeviceResponse = new List<string>();
#if ANDROID
                await BluetoothService.SendDataAsync(deviceSerialNo,Guid.Parse("0000ffff-0000-1000-8000-00805f9b34fb"),null, byteData, onCharacteristicChanged);
#endif
            }
        }

        void onCharacteristicChanged(object sender, GattCharacteristicValueChangedEventArgs e)
        {
            var deviceResponse = System.Text.Encoding.Default.GetString(e.Value);
            _allDeviceResponse.Add(deviceResponse);
            InvokeAsync(() => { StateHasChanged(); });
        }
    }

向裝置傳送查詢版本號的指令“AT+QVERSION”,裝置返回通過onCharacteristicChanged方法獲取,裝置返回的是二進位制陣列,所以需要轉成字串顯示出來。 簡單在寫個介面修改Index.razor Masa Blazor元件: Masa Blazor

@page "/"
<MButton OnClick="ScanBLEDeviceAsync">掃描藍芽裝置</MButton>

<div class="text-center">
    <MDialog @bind-Value="ShowProgress" Width="500">
        <ChildContent>
            <MCard>
                <MCardTitle>
                    正在掃描藍芽裝置
                </MCardTitle>
                <MCardText>
                    <MProgressCircular Size="40" Indeterminate Color="primary"></MProgressCircular>
                </MCardText>
            </MCard>
        </ChildContent>
    </MDialog>
</div>


@if (BluetoothDeviceList.Any())
{
    <MSelect style="margin-top:10px"
                 Outlined
                 Items="BluetoothDeviceList"
                 ItemText="u=>u"
                 ItemValue="u=>u"
                 TItem="string"
                 TValue="string"
                 TItemValue="string"
                 @bind-Value="SelectedDevice"
                 OnSelectedItemUpdate="item => SelectedDevice = item">
        </MSelect>
}
@if (!string.IsNullOrEmpty(SelectedDevice))
{
    <MButton OnClick="() => SendDataAsync()">傳送查詢版本指令</MButton>
}

@if (_allDeviceResponse.Any())
{
    <MCard>
        <MTextarea Value="@string.Join(' ',_allDeviceResponse)"></MTextarea>
    </MCard>
}

我們看一下效果

在這裡插入圖片描述

本文到此結束

如果你對我們MASA感興趣,無論是程式碼貢獻、使用、提 Issue,歡迎聯絡我們

  • WeChat:MasaStackTechOps
  • QQ:7424099