UWPでBluetooth Low Energy(BLE)のGATTサーバーを実装すると、接続後にサーバー側から「このクライアントだけ切断」「全員切断してサービス停止」をしたくなる場面があります。ところがStopAdvertisingやCloseでは期待通りにならないことも。本記事では、Windowsの設計背景と実務で通用する回避策を具体例・コード付きで整理します。
結論: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) | 「この端末だけキック」「この端末へ再接続指示」など |
“切断要求”のデータ例(シンプルで運用しやすい形式)
実装が増えるほど互換性で苦しむので、最初は次のような最小構成が扱いやすいです。
| フィールド | サイズ | 例 | 意図 |
|---|---|---|---|
| Command | 1 byte | 0x01 | 0x01=DisconnectRequest、0x02=Maintenance 等 |
| Reason | 1 byte | 0x02 | 0x01=Idle、0x02=Kick、0x03=UpdateRequired 等 |
| RetryAfterSec | 2 bytes | 0x001E | 再接続まで待ってほしい秒数(任意) |
ここにアプリ固有の理由コードを足すと、フィールド運用で「なぜ切られたのか」が追えるようになります(製造ライン・店頭端末・複数台運用で効きます)。
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は「接続=正義」ではなく、状態遷移(利用可/利用不可)をアプリプロトコルで明示できるかが安定性の分かれ目です。

コメント