Skip to content

Commit 0e354b8

Browse files
committed
Track live Bluetooth system state (#273)
1 parent eace728 commit 0e354b8

15 files changed

Lines changed: 868 additions & 52 deletions

native/bluetooth-transport-helper/BluetoothGattBridge.cs

Lines changed: 243 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Text.Json;
44
using Windows.Devices.Bluetooth;
55
using Windows.Devices.Bluetooth.GenericAttributeProfile;
6+
using Windows.Devices.Radios;
67
using Windows.Storage.Streams;
78

89
namespace SwitchifyBluetoothTransport;
@@ -11,6 +12,7 @@ internal sealed class BluetoothGattBridge
1112
{
1213
private const string ConnectionId = "ble";
1314
private static readonly TimeSpan DisconnectGracePeriod = TimeSpan.FromSeconds(10);
15+
private static readonly TimeSpan SystemStatusPollInterval = TimeSpan.FromSeconds(5);
1416

1517
private GattServiceProvider? serviceProvider;
1618
private GattLocalCharacteristic? txCharacteristic;
@@ -19,30 +21,79 @@ internal sealed class BluetoothGattBridge
1921
private string statusPayload = "{}";
2022
private bool connected;
2123
private CancellationTokenSource? disconnectGrace;
24+
private StartCommand? activeStartCommand;
25+
private BluetoothAdapter? currentAdapter;
26+
private Radio? currentRadio;
27+
private CancellationTokenSource? systemStatusPolling;
28+
private string? lastSystemStatusKey;
29+
private bool restartInProgress;
2230

2331
public async Task StartAsync(StartCommand command)
2432
{
2533
Stop();
34+
activeStartCommand = command;
35+
var snapshot = await StartSystemStatusMonitoringAsync();
36+
EmitSystemStatus(snapshot, force: true);
2637

27-
var adapter = await BluetoothAdapter.GetDefaultAsync();
28-
if (adapter is null)
38+
if (!snapshot.AdapterPresent)
2939
{
3040
HelperProtocol.WriteEvent(new { type = "unavailable", reason = "unsupported" });
3141
return;
3242
}
3343

34-
if (!adapter.IsLowEnergySupported)
44+
if (snapshot.IsLowEnergySupported != true || snapshot.IsPeripheralRoleSupported != true)
3545
{
3646
HelperProtocol.WriteEvent(new { type = "unavailable", reason = "unsupported" });
3747
return;
3848
}
3949

40-
if (!adapter.IsPeripheralRoleSupported)
50+
if (snapshot.RadioState is "off" or "disabled")
51+
{
52+
HelperProtocol.WriteEvent(new { type = "unavailable", reason = "adapter_off" });
53+
return;
54+
}
55+
56+
await StartAdvertisingAsync(command, restarted: false);
57+
}
58+
59+
public void Stop()
60+
{
61+
StopSystemStatusMonitoring();
62+
activeStartCommand = null;
63+
CancelDisconnectGrace();
64+
if (connected)
65+
{
66+
EmitDisconnected("helper_stopped");
67+
}
68+
69+
StopAdvertisingOnly();
70+
}
71+
72+
public async Task SendAsync(SendCommand command)
73+
{
74+
if (command.ConnectionId != ConnectionId || txCharacteristic is null)
4175
{
42-
HelperProtocol.WriteEvent(new { type = "unavailable", reason = "unsupported" });
4376
return;
4477
}
4578

79+
var json = JsonSerializer.Serialize(command.Frame, HelperProtocol.JsonOptions);
80+
var bytes = Encoding.UTF8.GetBytes(json);
81+
await txCharacteristic.NotifyValueAsync(bytes.AsBuffer());
82+
}
83+
84+
public void Disconnect(string connectionId)
85+
{
86+
if (connectionId != ConnectionId || !connected)
87+
{
88+
return;
89+
}
90+
91+
CancelDisconnectGrace();
92+
EmitDisconnected("pc_requested");
93+
}
94+
95+
private async Task StartAdvertisingAsync(StartCommand command, bool restarted)
96+
{
4697
statusPayload = JsonSerializer.Serialize(
4798
new
4899
{
@@ -80,46 +131,198 @@ public async Task StartAsync(StartCommand command)
80131
}
81132
);
82133

83-
HelperProtocol.WriteEvent(new { type = "diagnostic", @event = "advertising_started" });
134+
HelperProtocol.WriteEvent(new { type = "diagnostic", @event = restarted ? "advertising_restarted" : "advertising_started" });
84135
HelperProtocol.WriteEvent(new { type = "ready" });
85136
}
86137

87-
public void Stop()
138+
private void StopAdvertisingOnly()
88139
{
89-
CancelDisconnectGrace();
90-
if (connected)
91-
{
92-
EmitDisconnected("helper_stopped");
93-
}
94-
95140
serviceProvider?.StopAdvertising();
96141
serviceProvider = null;
97142
rxCharacteristic = null;
98143
txCharacteristic = null;
99144
statusCharacteristic = null;
100145
}
101146

102-
public async Task SendAsync(SendCommand command)
147+
private async Task<AdapterSnapshot> StartSystemStatusMonitoringAsync()
103148
{
104-
if (command.ConnectionId != ConnectionId || txCharacteristic is null)
149+
StopSystemStatusMonitoring();
150+
systemStatusPolling = new CancellationTokenSource();
151+
var token = systemStatusPolling.Token;
152+
var snapshot = await ReadAdapterSnapshotAsync();
153+
154+
_ = Task.Run(async () =>
155+
{
156+
while (!token.IsCancellationRequested)
157+
{
158+
try
159+
{
160+
await Task.Delay(SystemStatusPollInterval, token);
161+
if (token.IsCancellationRequested)
162+
{
163+
return;
164+
}
165+
166+
var current = await ReadAdapterSnapshotAsync();
167+
await HandleSystemStatusChangeAsync(current);
168+
}
169+
catch (OperationCanceledException)
170+
{
171+
return;
172+
}
173+
catch
174+
{
175+
var unknown = new AdapterSnapshot(false, "unknown", null, null);
176+
await HandleSystemStatusChangeAsync(unknown);
177+
}
178+
}
179+
}, token);
180+
181+
return snapshot;
182+
}
183+
184+
private void StopSystemStatusMonitoring()
185+
{
186+
systemStatusPolling?.Cancel();
187+
systemStatusPolling?.Dispose();
188+
systemStatusPolling = null;
189+
DetachRadioStateChanged();
190+
currentAdapter = null;
191+
lastSystemStatusKey = null;
192+
}
193+
194+
private async Task<AdapterSnapshot> ReadAdapterSnapshotAsync()
195+
{
196+
try
197+
{
198+
var adapter = await BluetoothAdapter.GetDefaultAsync();
199+
if (adapter is null)
200+
{
201+
DetachRadioStateChanged();
202+
currentAdapter = null;
203+
return new AdapterSnapshot(false, "unknown", null, null);
204+
}
205+
206+
currentAdapter = adapter;
207+
var radio = await adapter.GetRadioAsync();
208+
if (!ReferenceEquals(currentRadio, radio))
209+
{
210+
DetachRadioStateChanged();
211+
if (radio is not null)
212+
{
213+
AttachRadioStateChanged(radio);
214+
}
215+
}
216+
217+
return new AdapterSnapshot(
218+
true,
219+
RadioStateToProtocol(radio?.State),
220+
adapter.IsLowEnergySupported,
221+
adapter.IsPeripheralRoleSupported
222+
);
223+
}
224+
catch
225+
{
226+
return new AdapterSnapshot(false, "unknown", null, null);
227+
}
228+
}
229+
230+
private void AttachRadioStateChanged(Radio radio)
231+
{
232+
currentRadio = radio;
233+
currentRadio.StateChanged += OnRadioStateChanged;
234+
}
235+
236+
private void DetachRadioStateChanged()
237+
{
238+
if (currentRadio is not null)
239+
{
240+
currentRadio.StateChanged -= OnRadioStateChanged;
241+
currentRadio = null;
242+
}
243+
}
244+
245+
private async void OnRadioStateChanged(Radio sender, object args)
246+
{
247+
var snapshot = await ReadAdapterSnapshotAsync();
248+
await HandleSystemStatusChangeAsync(snapshot);
249+
}
250+
251+
private void EmitSystemStatus(AdapterSnapshot snapshot, bool force = false)
252+
{
253+
var key = $"{snapshot.AdapterPresent}|{snapshot.RadioState}|{snapshot.IsLowEnergySupported}|{snapshot.IsPeripheralRoleSupported}";
254+
if (!force && key == lastSystemStatusKey)
105255
{
106256
return;
107257
}
108258

109-
var json = JsonSerializer.Serialize(command.Frame, HelperProtocol.JsonOptions);
110-
var bytes = Encoding.UTF8.GetBytes(json);
111-
await txCharacteristic.NotifyValueAsync(bytes.AsBuffer());
259+
lastSystemStatusKey = key;
260+
HelperProtocol.WriteEvent(new
261+
{
262+
type = "systemStatus",
263+
adapterPresent = snapshot.AdapterPresent,
264+
radioState = snapshot.RadioState,
265+
isLowEnergySupported = snapshot.IsLowEnergySupported,
266+
isPeripheralRoleSupported = snapshot.IsPeripheralRoleSupported
267+
});
112268
}
113269

114-
public void Disconnect(string connectionId)
270+
private async Task HandleSystemStatusChangeAsync(AdapterSnapshot snapshot)
115271
{
116-
if (connectionId != ConnectionId || !connected)
272+
var previousKey = lastSystemStatusKey;
273+
EmitSystemStatus(snapshot);
274+
var changed = previousKey != lastSystemStatusKey;
275+
if (!changed)
117276
{
118277
return;
119278
}
120279

121-
CancelDisconnectGrace();
122-
EmitDisconnected("pc_requested");
280+
if (!snapshot.AdapterPresent || snapshot.IsLowEnergySupported != true || snapshot.IsPeripheralRoleSupported != true)
281+
{
282+
if (connected)
283+
{
284+
EmitDisconnected("adapter_off");
285+
}
286+
StopAdvertisingOnly();
287+
HelperProtocol.WriteEvent(new { type = "unavailable", reason = "unsupported" });
288+
return;
289+
}
290+
291+
if (snapshot.RadioState is "off" or "disabled")
292+
{
293+
HelperProtocol.WriteEvent(new { type = "diagnostic", @event = "system_radio_off" });
294+
if (connected)
295+
{
296+
EmitDisconnected("adapter_off");
297+
}
298+
StopAdvertisingOnly();
299+
HelperProtocol.WriteEvent(new { type = "unavailable", reason = "adapter_off" });
300+
return;
301+
}
302+
303+
if (snapshot.RadioState == "on")
304+
{
305+
HelperProtocol.WriteEvent(new { type = "diagnostic", @event = "system_radio_on" });
306+
await RestartAdvertisingIfPossibleAsync();
307+
}
308+
}
309+
310+
private async Task RestartAdvertisingIfPossibleAsync()
311+
{
312+
if (restartInProgress || activeStartCommand is null || serviceProvider is not null)
313+
{
314+
return;
315+
}
316+
317+
restartInProgress = true;
318+
try
319+
{
320+
await StartAdvertisingAsync(activeStartCommand, restarted: true);
321+
}
322+
finally
323+
{
324+
restartInProgress = false;
325+
}
123326
}
124327

125328
private async Task<GattLocalCharacteristic?> CreateRxCharacteristicAsync(Guid uuid)
@@ -337,4 +540,22 @@ private void EmitDisconnected(string reason)
337540
disconnectGrace = null;
338541
HelperProtocol.WriteEvent(new { type = "disconnected", connectionId = ConnectionId, reason });
339542
}
543+
544+
private static string RadioStateToProtocol(RadioState? state)
545+
{
546+
return state switch
547+
{
548+
RadioState.On => "on",
549+
RadioState.Off => "off",
550+
RadioState.Disabled => "disabled",
551+
_ => "unknown"
552+
};
553+
}
554+
555+
private sealed record AdapterSnapshot(
556+
bool AdapterPresent,
557+
string RadioState,
558+
bool? IsLowEnergySupported,
559+
bool? IsPeripheralRoleSupported
560+
);
340561
}

0 commit comments

Comments
 (0)