UWP BLEで切断できない?Windows GATTサーバーのStopAdvertising問題と実務回避策

UWPでBluetooth Low Energy(BLE)のGATTサーバーを実装すると、接続後にサーバー側から「このクライアントだけ切断」「全員切断してサービス停止」をしたくなる場面があります。ところがStopAdvertisingやCloseでは期待通りにならないことも。本記事では、Windowsの設計背景と実務で通用する回避策を具体例・コード付きで整理します。

日程Fit。無料・登録不要。「いつ空いてる?」を、ひとつのリンクで。リンクを送って、○△×でかんたん日程調整。無料で日程を作る。
目次

結論:UWP(Windows)のGATTサーバーは、サーバー側から“明示的に切断”できない

まず押さえるべきポイントは、UWPのGATTサーバーAPI(GattServiceProvider)には、接続中クライアントをサーバー側から明示的に切断するためのAPIが用意されていないという点です。GattServiceProviderは「サービスを公開し、広告(Advertising)を開始する」ところまでを担いますが、接続が張られた後に「この接続を切れ」とリンク層へ命令する機能は提供されません。

この“できなさ”は、単なる実装不足というより、WindowsのBluetoothスタックが「プラットフォームとして複数のGATTサービスや利用者が同じリンクを共有し得る」前提で設計されていることに起因します。つまり、あなたのアプリが持っているのは「そのサービスの提供者としての権限」であって、「リンク接続そのものの所有者」ではない、という整理になります。

やりたいことUWPのGATTサーバーAPIでの実現可否実務での落としどころ
特定の1クライアントだけ切断(他は継続)不可(切断APIなし)「切断要求」を通知してクライアント主導で切断/以降のRead/Writeを拒否
全クライアントを切断し、サービス自体を利用不可にする不可(StopAdvertisingは“新規接続抑止”)メンテナンスモードで拒否応答+新規接続抑止+クライアントに切断促し
新規接続を受け付けない可(StopAdvertising / IsConnectable=false 等)StopAdvertisingまたは広告パラメータ更新(状況に応じて)

StopAdvertisingで切れないのは“正常”:止まるのは広告であって接続ではない

「StopAdvertising()を呼んだのにクライアントが切断イベントを受けない」「まだWriteできる」といった挙動は、驚きやすい一方で、BLEの役割分担としては自然です。StopAdvertising()は“広告を止める”だけで、既存の接続を強制切断する仕様は明記されていません。

実装面でも、GattServiceProviderクラスの備考(Remarks)に、StartAdvertising後に接続されても、その接続を切るための明示的機能はAPIとして提供されない旨が示されています。StopAdvertisingは「これ以上、新しい接続を呼び込まない」ための操作として理解するのが安全です。

Close/Disposeで“通知だけ止まってWriteは来る”が起きる理由

質問のように「GattSessionをClose(あるいはDispose相当)しても、通知(Notify)ができなくなるだけで、クライアントはWriteできてしまう」というケースはよくあります。ここで大事なのは、Close/DisposeはWinRTオブジェクトの寿命(参照)を閉じる操作であり、リンク層の切断命令ではない、という点です。

特にサーバー側では、接続やリンク管理の主導権はOSスタック側に寄っていて、アプリが握っている“セッションのハンドル”を閉じても、OSがそのリンクを維持している限り、別ルートでATT要求(Write/Read)が届くことがあります。結果として、アプリ視点では「通知が飛ばせない/飛ばしづらいのに、要求は来る」というアンバランスに見えます。

まず押さえる:サーバー側でクライアントを“特定する”方法

「特定の1クライアントだけを対象にしたい」場合、相手を識別する手掛かりが必要です。UWPのGATTサーバーでは、次の2系統で“相手のセッション”を追跡できます。

場面使うAPI何が分かるか注意点
Read/Write要求を受けたGattReadRequestedEventArgs.Session / GattWriteRequestedEventArgs.Sessionその要求を出したクライアントのGattSession要求が来ない限り分からない
Notify購読(CCCD)されたSubscribedClientsChanged → GattLocalCharacteristic.SubscribedClients購読中クライアント一覧(GattSubscribedClient)“接続中”ではなく“購読中”の一覧

公式ドキュメントでも、SubscribedClientsChangedからSubscribedClientsを列挙し、GattSubscribedClient.Session → GattSession.DeviceId.Idを辿って相手を特定できる流れが説明されています。

特に「特定の1クライアントだけ」に何かしたい場合は、DeviceId(文字列)で揃えるのが扱いやすいです。Notify購読側(GattSubscribedClient)と、Read/Write要求側(args.Session)を同じIDで突合できます。

回避策:サーバーから“切断要求”を出して、クライアント主導で切断させる

サーバー側から強制切断できない以上、実務で一番うまく回るのは「切断してほしい」ことをGATTで伝え、クライアント側がDisconnect相当を実行する設計です。これは「アプリレベルのプロトコル」として成立します。

切断要求(Control Point)を作る設計例

  • 制御用キャラクタリスティック(Control Point)を用意する
  • サーバーは必要に応じて、Control PointをNotify(またはIndicate)する
  • クライアントは通知を受け取ったら、自身のAPIで切断する(Androidならclose、iOSならキャンセル、Windowsならセッション解除など)

UWPサーバー側は、Notifyを全購読クライアントへ一斉送信もできますし、特定の購読クライアント(GattSubscribedClient)へピンポイント送信もできます。NotifyValueAsyncにはそのためのオーバーロードが用意されています。

送信方法API用途
購読中の全員へNotifyValueAsync(IBuffer)全クライアントへ「メンテナンス開始」など
購読中の特定1台へNotifyValueAsync(IBuffer, GattSubscribedClient)「この端末だけキック」「この端末へ再接続指示」など

“切断要求”のデータ例(シンプルで運用しやすい形式)

実装が増えるほど互換性で苦しむので、最初は次のような最小構成が扱いやすいです。

フィールドサイズ意図
Command1 byte0x010x01=DisconnectRequest、0x02=Maintenance 等
Reason1 byte0x020x01=Idle、0x02=Kick、0x03=UpdateRequired 等
RetryAfterSec2 bytes0x001E再接続まで待ってほしい秒数(任意)

ここにアプリ固有の理由コードを足すと、フィールド運用で「なぜ切られたのか」が追えるようになります(製造ライン・店頭端末・複数台運用で効きます)。

UWP(C#)サーバー側の実装イメージ

以下は「Control Pointで切断要求を通知し、同時に“拒否リスト”に入れて実質的に使えなくする」骨組みです。ポイントは、切断は依頼(プロトコル)で行い、強制は“拒否応答”で担保することです。

using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading.Tasks;
using Windows.Devices.Bluetooth;
using Windows.Devices.Bluetooth.GenericAttributeProfile;
using Windows.Storage.Streams;

public sealed class BleGattServer
{
    private GattServiceProvider _provider;

    private GattLocalCharacteristic _controlPoint; // Write + Notify
    private GattLocalCharacteristic _data;         // Read/Write (例)

    // DeviceId.Id で管理すると突合しやすい
    private readonly ConcurrentDictionary<string, bool> _denyList = new();

    public async Task StartAsync(Guid serviceUuid, Guid controlPointUuid, Guid dataUuid)
    {
        var create = await GattServiceProvider.CreateAsync(serviceUuid);
        if (create.Error != BluetoothError.Success)
            throw new InvalidOperationException($"CreateAsync failed: {create.Error}");

        _provider = create.ServiceProvider;

        // Control Point
        var cpParams = new GattLocalCharacteristicParameters
        {
            CharacteristicProperties = GattCharacteristicProperties.Write | GattCharacteristicProperties.Notify,
            WriteProtectionLevel = GattProtectionLevel.Plain,
            UserDescription = "Server Control Point"
        };

        var cp = await _provider.Service.CreateCharacteristicAsync(controlPointUuid, cpParams);
        if (cp.Error != BluetoothError.Success)
            throw new InvalidOperationException($"CreateCharacteristicAsync(ControlPoint) failed: {cp.Error}");

        _controlPoint = cp.Characteristic;
        _controlPoint.WriteRequested += ControlPoint_WriteRequested;
        _controlPoint.SubscribedClientsChanged += ControlPoint_SubscribedClientsChanged;

        // Data characteristic(例)
        var dataParams = new GattLocalCharacteristicParameters
        {
            CharacteristicProperties = GattCharacteristicProperties.Read | GattCharacteristicProperties.Write,
            ReadProtectionLevel = GattProtectionLevel.Plain,
            WriteProtectionLevel = GattProtectionLevel.Plain,
            UserDescription = "Data"
        };

        var d = await _provider.Service.CreateCharacteristicAsync(dataUuid, dataParams);
        if (d.Error != BluetoothError.Success)
            throw new InvalidOperationException($"CreateCharacteristicAsync(Data) failed: {d.Error}");

        _data = d.Characteristic;
        _data.ReadRequested += Data_ReadRequested;
        _data.WriteRequested += Data_WriteRequested;

        var adv = new GattServiceProviderAdvertisingParameters
        {
            IsDiscoverable = true,
            IsConnectable = true
        };
        _provider.StartAdvertising(adv);
    }

    private void ControlPoint_SubscribedClientsChanged(GattLocalCharacteristic sender, object args)
    {
        // sender.SubscribedClients に購読中クライアントが並ぶ。
        // ここでログや台数制限(1台運用など)を実装すると運用が安定する。
    }

    private async void ControlPoint_WriteRequested(GattLocalCharacteristic sender, GattWriteRequestedEventArgs args)
    {
        var deferral = args.GetDeferral();
        try
        {
            var request = await args.GetRequestAsync();
            var deviceId = args.Session?.DeviceId?.Id; // クライアント識別キー
            if (string.IsNullOrEmpty(deviceId))
            {
                // 応答が必要なら応答して終わる
                if (request.Option == GattWriteOption.WriteWithResponse) request.Respond();
                return;
            }

            // 受け取ったコマンドを読む(例:先頭1byte)
            byte cmd = request.Value.Length > 0 ? DataReader.FromBuffer(request.Value).ReadByte() : (byte)0x00;

            // 例:cmd=0x10 を受け取ったら「この端末をキック」扱いにする
            if (cmd == 0x10)
            {
                _denyList[deviceId] = true;

                // “切断要求”をこの端末へ通知(購読していればピンポイントで送れる)
                await NotifyDisconnectRequestAsync(deviceId, reason: 0x02, retryAfterSec: 30);
            }

            if (request.Option == GattWriteOption.WriteWithResponse) request.Respond();
        }
        finally
        {
            deferral.Complete();
        }
    }

    private async Task NotifyDisconnectRequestAsync(string targetDeviceId, byte reason, ushort retryAfterSec)
    {
        var client = _controlPoint.SubscribedClients
            .FirstOrDefault(c => c.Session?.DeviceId?.Id == targetDeviceId);

        if (client == null) return; // 購読していない場合は送れない

        var w = new DataWriter();
        w.WriteByte(0x01); // DisconnectRequest
        w.WriteByte(reason);
        w.WriteUInt16(retryAfterSec);

        await _controlPoint.NotifyValueAsync(w.DetachBuffer(), client);
    }

    private async void Data_ReadRequested(GattLocalCharacteristic sender, GattReadRequestedEventArgs args)
    {
        var deferral = args.GetDeferral();
        try
        {
            var request = await args.GetRequestAsync();
            var deviceId = args.Session?.DeviceId?.Id;

            if (!string.IsNullOrEmpty(deviceId) && _denyList.ContainsKey(deviceId))
            {
                // Read拒否(例)
                request.RespondWithProtocolError(GattProtocolError.InsufficientAuthorization);
                return;
            }

            var w = new DataWriter();
            w.WriteString("OK");
            request.RespondWithValue(w.DetachBuffer());
        }
        finally
        {
            deferral.Complete();
        }
    }

    private async void Data_WriteRequested(GattLocalCharacteristic sender, GattWriteRequestedEventArgs args)
    {
        var deferral = args.GetDeferral();
        try
        {
            var request = await args.GetRequestAsync();
            var deviceId = args.Session?.DeviceId?.Id;

            if (!string.IsNullOrEmpty(deviceId) && _denyList.ContainsKey(deviceId))
            {
                // Write拒否(WriteWithResponseならエラー応答が効く)
                if (request.Option == GattWriteOption.WriteWithResponse)
                {
                    request.RespondWithProtocolError(GattProtocolError.InsufficientAuthorization);
                }
                return;
            }

            // ここで通常処理(request.Value を読む等)
            if (request.Option == GattWriteOption.WriteWithResponse) request.Respond();
        }
        finally
        {
            deferral.Complete();
        }
    }
}

上の例では、切断要求(Notify)に加えて、以降のRead/Writeを拒否しています。これにより、クライアントが切断要求を無視しても、サーバーとしては“使わせない”状態を担保できます。

回避策:メンテナンスモードで“全員を実質停止”する(全クライアント向け)

「全クライアントを切断し、サービス自体も利用不可にしたい」という要件は、現場だと意外に多いです(ファーム更新、設定切替、ライセンス更新、危険状態検知など)。この場合は、次の3点セットが運用しやすいです。

  • 新規接続を止める:StopAdvertising() もしくは広告パラメータの見直し(IsConnectable=falseなど)
  • 既存クライアントには“終了通知”を送る:Control Pointで全員へ通知
  • Read/Writeを拒否する:RespondWithProtocolErrorで“使えない”を明確化

StopAdvertising()は「広告停止」であり、既存接続を切る保証ではありませんが、新規接続を呼び込まない目的には有効です。

“拒否応答”に使える代表的なGATTエラー

UWPではWrite要求に対してRespondWithProtocolError(byte)が提供されており、GattProtocolErrorクラスの静的プロパティを使うと定数管理が楽です。

エラー使いどころクライアント側の見え方(例)
GattProtocolError.ReadNotPermittedメンテ中でReadを止めたいReadが失敗(権限/許可なし扱い)
GattProtocolError.WriteNotPermitted書き込み禁止の状態を明確にしたいWriteが失敗(許可なし扱い)
GattProtocolError.InsufficientAuthorization“この端末は拒否”を表現したい認可不足で失敗(アプリ側で再ログイン等の導線を作れる)
GattProtocolError.UnlikelyError想定外状態(例:内部エラー)を返したい汎用エラーで失敗(原因追跡は別途ログ必須)

注意点として、クライアントが「Write Without Response」で書いてくる場合、プロトコル的に“エラー応答を返す”余地が小さく、エラー通知が届きません。重要な書き込みは、WriteWithResponse(Write)を前提に設計しておくと運用が安定します(Control Pointは特に)。

回避策:新規接続だけ確実に止める(StopAdvertising / IsConnectable)

「今は接続させたくない(=新規のCentralを弾きたい)」だけなら、サーバー側でも実現できます。GattServiceProviderAdvertisingParametersには、IsDiscoverable / IsConnectableがあり、StartAdvertising時に設定します。

  • IsDiscoverable:デバイス名を広告に出し、見つけやすくする
  • IsConnectable:接続可能な広告として出す(Peripheral roleでの利用を意図)

ただし、ここで止められるのはあくまで「これからの接続」です。すでに接続済みのクライアントに対しては、前述の通り「切断要求+拒否応答」で対応するのが現実的です。

“特定の1クライアントだけ切りたい”ときの実装テンプレ

要件として一番難しいのがここです。サーバー側から強制切断できない前提で、現場で壊れにくいテンプレは次の通りです。

ステップサーバー側の動作狙い
相手の特定args.Session.DeviceId.Id で識別(Read/Write/Subscribe)対象端末を間違えない
切断要求Control PointをNotify(可能なら対象端末へ)クライアント主導で切断してもらう
実質停止拒否リストに入れ、Read/WriteをProtocolErrorで拒否無視されても“使わせない”
回復導線理由コード+再接続待ち時間、または解除コマンドを用意現場で復旧できる

「切断したい」の本当の目的が、排他制御(1台だけ使わせたい)なのか、危険状態で即停止したいのか、再接続させたいのかで、理由コードや解除条件が変わります。ここをプロトコル化しておくと、後からの仕様変更にも耐えます。

“全クライアント切断+サービス停止”をしたいときの現実解

「サービス停止=全員切断」と考えがちですが、Windows/UWPでは“停止できるのはサービス公開と広告”であり、接続の切断は別です。そこで、実務では次のように設計するのが無難です。

  • StopAdvertisingで新規接続を止める
  • 全購読クライアントへメンテナンス通知(Control Pointを一斉Notify)
  • Read/Writeはエラーで拒否(メンテナンスモード)
  • クライアントが切断したら、必要ならサービス定義自体を作り直す(UUID変更やバージョン特性追加など、クライアントの再探索を促す)

この手順にしておくと、クライアント側が切断イベントを即時に受け取らない実装でも、ユーザー体験としては「使えない(=更新中)」が成立します。逆に、サーバー側で“物理切断が絶対”を要件にすると、UWPの枠内では詰みやすいです。

よくあるハマりどころ:SubscribedClients=接続中一覧ではない

GattLocalCharacteristic.SubscribedClientsは、あくまでその特性を通知購読(CCCD設定)しているクライアントの一覧です。接続していても購読していなければ出てきません。つまり、

  • 「接続しただけで何もしていないクライアント」は一覧に出ない
  • 「購読したクライアント」は一覧に出る
  • 購読解除・切断のタイミングはクライアント実装に依存する

この前提があるため、運用上はControl Point(Notify)を“必須購読”にしてしまう設計が強いです。制御メッセージを届けられる確率が上がり、結果として「切断要求が届かない」問題を減らせます。

どうしても“サーバー側強制切断”が必要な場合

要件として「切断イベントが必ずクライアントに飛び、すぐにリンクが落ちる」ことが絶対条件のプロダクトもあります(安全系・課金/認証境界・不正対策など)。その場合は、UWPのGATTサーバーAPIの範囲を超えるため、選択肢は現実的に次のどれかになります。

  • 設計変更:切断ではなく“拒否+UI誘導”で目的を達成する
  • クライアント実装の統制:必ず切断要求に従うクライアントのみを許可する
  • プラットフォーム変更:リンク層制御ができる環境(別OS・別スタック・別形態)を検討する
  • 機能要望を出す:Feedback HubでAPI追加を要望する

実際、Microsoftの公式Q&Aでも「サーバー側からクライアントを切断する明示的APIはない」旨と、必要ならFeedback Hubで要望する案内が示されています。

まとめ:UWPで“切断”を求めるなら、プロトコル設計で勝つ

UWP(Windows)のBLE GATTサーバーで「サーバー側から切断」は、APIとしては提供されていません。だからこそ、実装の勝ち筋は次の2つに集約されます。

  • クライアント主導で切断させる:Control Point(切断要求)を用意し、Notify/Indicateで促す
  • サーバーは“使えない”を保証する:Read/WriteをProtocolErrorで拒否し、メンテナンスやキックを成立させる

この2本立てにしておくと、StopAdvertisingやCloseだけに期待して不安定になるよりも、運用・保守・障害対応が圧倒的に楽になります。BLEは「接続=正義」ではなく、状態遷移(利用可/利用不可)をアプリプロトコルで明示できるかが安定性の分かれ目です。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次