diff --git a/src/SwitchifyPc.App/BluetoothRuntime.cs b/src/SwitchifyPc.App/BluetoothRuntime.cs index 7f6ed92..bfb21da 100644 --- a/src/SwitchifyPc.App/BluetoothRuntime.cs +++ b/src/SwitchifyPc.App/BluetoothRuntime.cs @@ -25,7 +25,7 @@ public BluetoothRuntime( PairingApprovalManager pairingApprovalManager, BluetoothStatusTracker statusTracker, IBluetoothRemoteFrameProcessor frameProcessor, - Func, IBluetoothTransportServer> serverFactory, + Func, IBluetoothTransportServer> serverFactory, Func, Task> dispatchAsync, Func endControlSessionAsync, Action recordDiagnostic, @@ -96,7 +96,7 @@ public void Dispose() } } - private void HandleTransportEvent(BluetoothHelperEvent transportEvent) + private void HandleTransportEvent(BluetoothTransportEvent transportEvent) { if (transportEvent is BluetoothMessageEvent message) { @@ -107,7 +107,7 @@ private void HandleTransportEvent(BluetoothHelperEvent transportEvent) _ = dispatchAsync(() => ProcessTransportEventAsync(transportEvent)); } - private async Task ProcessTransportEventAsync(BluetoothHelperEvent transportEvent) + private async Task ProcessTransportEventAsync(BluetoothTransportEvent transportEvent) { switch (transportEvent) { diff --git a/src/SwitchifyPc.Core/Bluetooth/BluetoothControlFrameProcessor.cs b/src/SwitchifyPc.Core/Bluetooth/BluetoothControlFrameProcessor.cs deleted file mode 100644 index 1270918..0000000 --- a/src/SwitchifyPc.Core/Bluetooth/BluetoothControlFrameProcessor.cs +++ /dev/null @@ -1,88 +0,0 @@ -using SwitchifyPc.Core.Control; -using SwitchifyPc.Protocol; - -namespace SwitchifyPc.Core.Bluetooth; - -public sealed record BluetoothControlFrameResult( - bool MessageComplete, - string? ErrorReason, - IReadOnlyList ResponseFrames) -{ - public static BluetoothControlFrameResult Incomplete(string? reason = null) => new(false, reason, []); - - public static BluetoothControlFrameResult Complete(IReadOnlyList responseFrames) => new(true, null, responseFrames); - - public static BluetoothControlFrameResult Error(string reason) => new(true, reason, []); -} - -public sealed class BluetoothControlFrameProcessor -{ - private readonly ControlSession controlSession; - private readonly int maxResponseFramePayloadBytes; - private readonly int maxMessageBytes; - private readonly double partialTimeoutMs; - private readonly Func now; - private readonly Dictionary reassemblers = new(StringComparer.Ordinal); - - public BluetoothControlFrameProcessor( - ControlSession controlSession, - int maxResponseFramePayloadBytes = BluetoothFrameCodec.DefaultBluetoothFramePayloadBytes, - int maxMessageBytes = BluetoothFrameCodec.DefaultBluetoothMaxMessageBytes, - double partialTimeoutMs = BluetoothFrameCodec.DefaultBluetoothPartialTimeoutMs, - Func? now = null) - { - this.controlSession = controlSession; - this.maxResponseFramePayloadBytes = maxResponseFramePayloadBytes; - this.maxMessageBytes = maxMessageBytes; - this.partialTimeoutMs = partialTimeoutMs; - this.now = now ?? (() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); - } - - public async Task AcceptAsync( - string connectionId, - BluetoothFrame frame, - CancellationToken cancellationToken = default) - { - BluetoothFrameReassembler reassembler = ReassemblerFor(connectionId); - BluetoothFrameReassemblyResult reassembly = reassembler.Accept(frame); - if (!reassembly.Ok) - { - return reassembly.Reason == "incomplete" - ? BluetoothControlFrameResult.Incomplete(reassembly.Reason) - : BluetoothControlFrameResult.Error(reassembly.Reason ?? "invalid_frame"); - } - - ControlSessionResult sessionResult = await controlSession.ProcessMessageAsync(reassembly.Message ?? "", cancellationToken).ConfigureAwait(false); - if (!sessionResult.HasResponse) - { - return BluetoothControlFrameResult.Complete([]); - } - - IReadOnlyList responseFrames = BluetoothFrameCodec.CreateFrames( - sessionResult.ResponseJson!, - maxPayloadBytes: maxResponseFramePayloadBytes, - maxMessageBytes: maxMessageBytes); - return BluetoothControlFrameResult.Complete(responseFrames); - } - - public void RemoveConnection(string connectionId) - { - reassemblers.Remove(connectionId); - } - - public int ClearExpired() - { - return reassemblers.Values.Sum(reassembler => reassembler.ClearExpired()); - } - - private BluetoothFrameReassembler ReassemblerFor(string connectionId) - { - if (!reassemblers.TryGetValue(connectionId, out BluetoothFrameReassembler? reassembler)) - { - reassembler = new BluetoothFrameReassembler(maxMessageBytes, partialTimeoutMs, now); - reassemblers[connectionId] = reassembler; - } - - return reassembler; - } -} diff --git a/src/SwitchifyPc.Core/Bluetooth/BluetoothHelperProtocol.cs b/src/SwitchifyPc.Core/Bluetooth/BluetoothHelperProtocol.cs deleted file mode 100644 index 10ec9af..0000000 --- a/src/SwitchifyPc.Core/Bluetooth/BluetoothHelperProtocol.cs +++ /dev/null @@ -1,180 +0,0 @@ -using System.Text.Json; -using SwitchifyPc.Protocol; - -namespace SwitchifyPc.Core.Bluetooth; - -public abstract record BluetoothHelperEvent(string Type); -public sealed record BluetoothReadyEvent() : BluetoothHelperEvent("ready"); -public sealed record BluetoothUnavailableEvent(string Reason) : BluetoothHelperEvent("unavailable"); -public sealed record BluetoothConnectedEvent(string ConnectionId, string Label) : BluetoothHelperEvent("connected"); -public sealed record BluetoothMessageEvent(string ConnectionId, BluetoothFrame Frame) : BluetoothHelperEvent("message"); -public sealed record BluetoothDisconnectedEvent(string ConnectionId, string Reason) : BluetoothHelperEvent("disconnected"); -public sealed record BluetoothDiagnosticEvent(string Event) : BluetoothHelperEvent("diagnostic"); -public sealed record BluetoothSystemStatusEvent( - bool AdapterPresent, - string RadioState, - bool? IsLowEnergySupported, - bool? IsPeripheralRoleSupported) : BluetoothHelperEvent("systemStatus"); -public sealed record BluetoothErrorEvent(string Reason) : BluetoothHelperEvent("error"); - -public static class BluetoothHelperProtocol -{ - public static readonly Guid ServiceUuid = Guid.Parse("7a78f7e8-1d6d-4d92-9ef0-1f89d3db21f4"); - public static readonly Guid RxCharacteristicUuid = Guid.Parse("7a78f7e9-1d6d-4d92-9ef0-1f89d3db21f4"); - public static readonly Guid TxCharacteristicUuid = Guid.Parse("7a78f7ea-1d6d-4d92-9ef0-1f89d3db21f4"); - public static readonly Guid StatusCharacteristicUuid = Guid.Parse("7a78f7eb-1d6d-4d92-9ef0-1f89d3db21f4"); - - public static bool TryParseEvent(string json, out BluetoothHelperEvent? helperEvent) - { - helperEvent = null; - try - { - using JsonDocument document = JsonDocument.Parse(json); - JsonElement root = document.RootElement; - if (root.ValueKind != JsonValueKind.Object || !TryGetString(root, "type", out string type)) - { - return false; - } - - helperEvent = type switch - { - "ready" => new BluetoothReadyEvent(), - "unavailable" => ParseUnavailable(root), - "connected" => ParseConnected(root), - "message" => ParseMessage(root), - "disconnected" => ParseDisconnected(root), - "diagnostic" => ParseDiagnostic(root), - "systemStatus" => ParseSystemStatus(root), - "error" => ParseError(root), - _ => null - }; - - return helperEvent is not null; - } - catch (JsonException) - { - return false; - } - } - - private static BluetoothHelperEvent? ParseUnavailable(JsonElement root) - { - return TryGetString(root, "reason", out string reason) && BluetoothStatusModel.UnavailableReasons.Contains(reason) - ? new BluetoothUnavailableEvent(reason) - : null; - } - - private static BluetoothHelperEvent? ParseConnected(JsonElement root) - { - return TryGetString(root, "connectionId", out string connectionId) && - TryGetString(root, "label", out string label) - ? new BluetoothConnectedEvent(connectionId, label) - : null; - } - - private static BluetoothHelperEvent? ParseMessage(JsonElement root) - { - if (!TryGetString(root, "connectionId", out string connectionId) || - !root.TryGetProperty("frame", out JsonElement frameElement) || - frameElement.ValueKind != JsonValueKind.Object) - { - return null; - } - - try - { - BluetoothFrame? frame = JsonSerializer.Deserialize(frameElement.GetRawText(), FrameJsonOptions); - return frame is not null && BluetoothFrameCodec.Validate(frame).Reason == "incomplete" - ? new BluetoothMessageEvent(connectionId, frame) - : null; - } - catch (JsonException) - { - return null; - } - } - - private static BluetoothHelperEvent? ParseDisconnected(JsonElement root) - { - return TryGetString(root, "connectionId", out string connectionId) && - TryGetString(root, "reason", out string reason) && - BluetoothStatusModel.DisconnectReasons.Contains(reason) - ? new BluetoothDisconnectedEvent(connectionId, reason) - : null; - } - - private static BluetoothHelperEvent? ParseDiagnostic(JsonElement root) - { - return TryGetString(root, "event", out string diagnosticEvent) && BluetoothStatusModel.DiagnosticEvents.Contains(diagnosticEvent) - ? new BluetoothDiagnosticEvent(diagnosticEvent) - : null; - } - - private static BluetoothHelperEvent? ParseSystemStatus(JsonElement root) - { - return TryGetBoolean(root, "adapterPresent", out bool adapterPresent) && - TryGetString(root, "radioState", out string radioState) && - BluetoothStatusModel.SystemRadioStates.Contains(radioState) && - TryGetOptionalBoolean(root, "isLowEnergySupported", out bool? isLowEnergySupported) && - TryGetOptionalBoolean(root, "isPeripheralRoleSupported", out bool? isPeripheralRoleSupported) - ? new BluetoothSystemStatusEvent(adapterPresent, radioState, isLowEnergySupported, isPeripheralRoleSupported) - : null; - } - - private static BluetoothHelperEvent? ParseError(JsonElement root) - { - return TryGetString(root, "reason", out string reason) ? new BluetoothErrorEvent(reason) : null; - } - - private static bool TryGetString(JsonElement value, string propertyName, out string result) - { - result = ""; - return value.TryGetProperty(propertyName, out JsonElement property) && - property.ValueKind == JsonValueKind.String && - !string.IsNullOrEmpty(result = property.GetString() ?? ""); - } - - private static bool TryGetBoolean(JsonElement value, string propertyName, out bool result) - { - result = false; - if (!value.TryGetProperty(propertyName, out JsonElement property)) return false; - if (property.ValueKind == JsonValueKind.True) - { - result = true; - return true; - } - - if (property.ValueKind == JsonValueKind.False) - { - result = false; - return true; - } - - return false; - } - - private static bool TryGetOptionalBoolean(JsonElement value, string propertyName, out bool? result) - { - result = null; - if (!value.TryGetProperty(propertyName, out JsonElement property)) return false; - if (property.ValueKind == JsonValueKind.Null) return true; - if (property.ValueKind == JsonValueKind.True) - { - result = true; - return true; - } - - if (property.ValueKind == JsonValueKind.False) - { - result = false; - return true; - } - - return false; - } - - private static readonly JsonSerializerOptions FrameJsonOptions = new() - { - PropertyNameCaseInsensitive = true - }; -} diff --git a/src/SwitchifyPc.Core/Bluetooth/BluetoothTransportProtocol.cs b/src/SwitchifyPc.Core/Bluetooth/BluetoothTransportProtocol.cs new file mode 100644 index 0000000..f321194 --- /dev/null +++ b/src/SwitchifyPc.Core/Bluetooth/BluetoothTransportProtocol.cs @@ -0,0 +1,25 @@ +using SwitchifyPc.Protocol; + +namespace SwitchifyPc.Core.Bluetooth; + +public abstract record BluetoothTransportEvent(string Type); +public sealed record BluetoothReadyEvent() : BluetoothTransportEvent("ready"); +public sealed record BluetoothUnavailableEvent(string Reason) : BluetoothTransportEvent("unavailable"); +public sealed record BluetoothConnectedEvent(string ConnectionId, string Label) : BluetoothTransportEvent("connected"); +public sealed record BluetoothMessageEvent(string ConnectionId, BluetoothFrame Frame) : BluetoothTransportEvent("message"); +public sealed record BluetoothDisconnectedEvent(string ConnectionId, string Reason) : BluetoothTransportEvent("disconnected"); +public sealed record BluetoothDiagnosticEvent(string Event) : BluetoothTransportEvent("diagnostic"); +public sealed record BluetoothSystemStatusEvent( + bool AdapterPresent, + string RadioState, + bool? IsLowEnergySupported, + bool? IsPeripheralRoleSupported) : BluetoothTransportEvent("systemStatus"); +public sealed record BluetoothErrorEvent(string Reason) : BluetoothTransportEvent("error"); + +public static class BluetoothGattProtocol +{ + public static readonly Guid ServiceUuid = Guid.Parse("7a78f7e8-1d6d-4d92-9ef0-1f89d3db21f4"); + public static readonly Guid RxCharacteristicUuid = Guid.Parse("7a78f7e9-1d6d-4d92-9ef0-1f89d3db21f4"); + public static readonly Guid TxCharacteristicUuid = Guid.Parse("7a78f7ea-1d6d-4d92-9ef0-1f89d3db21f4"); + public static readonly Guid StatusCharacteristicUuid = Guid.Parse("7a78f7eb-1d6d-4d92-9ef0-1f89d3db21f4"); +} diff --git a/src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs b/src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs deleted file mode 100644 index 52aff6e..0000000 --- a/src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs +++ /dev/null @@ -1,240 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using SwitchifyPc.Core.Bluetooth; -using SwitchifyPc.Core.Control; -using SwitchifyPc.Core.Input; -using SwitchifyPc.Core.Pairing; -using SwitchifyPc.Core.Settings; -using SwitchifyPc.Protocol; - -namespace SwitchifyPc.Tests; - -public sealed class BluetoothControlFrameProcessorTests -{ - private const string DeviceId = "android-1"; - private const string Token = "shared-token"; - private const double Now = 1_000_000; - - [Fact] - public async Task ReassemblesRequestAndFramesAckResponse() - { - FakeInputAdapter adapter = new(); - BluetoothControlFrameProcessor processor = new(CreateSession(adapter), maxResponseFramePayloadBytes: 20); - IReadOnlyList frames = BluetoothFrameCodec.CreateFrames(SignedCommand("keyboard.key", new { key = "Meta" }), "incoming-1", maxPayloadBytes: 40); - - BluetoothControlFrameResult incomplete = BluetoothControlFrameResult.Incomplete(); - BluetoothControlFrameResult complete = BluetoothControlFrameResult.Incomplete(); - for (int index = 0; index < frames.Count; index++) - { - BluetoothControlFrameResult result = await processor.AcceptAsync("ble", frames[index]); - if (index == 0) - { - incomplete = result; - } - - complete = result; - } - - Assert.False(incomplete.MessageComplete); - Assert.True(complete.MessageComplete); - Assert.Null(complete.ErrorReason); - Assert.NotEmpty(complete.ResponseFrames); - Assert.Equal(["pressKey:Meta"], adapter.Calls); - AssertResponseType(complete.ResponseFrames, "ack"); - } - - [Fact] - public async Task NoAckRequestsCompleteWithoutResponseFrames() - { - FakeInputAdapter adapter = new(); - BluetoothControlFrameProcessor processor = new(CreateSession(adapter)); - IReadOnlyList frames = BluetoothFrameCodec.CreateFrames(SignedCommand("mouse.move", new { dx = 4, dy = 5 }, responseMode: "none"), "incoming-1"); - - BluetoothControlFrameResult result = BluetoothControlFrameResult.Incomplete(); - foreach (BluetoothFrame frame in frames) - { - result = await processor.AcceptAsync("ble", frame); - } - - Assert.True(result.MessageComplete); - Assert.Empty(result.ResponseFrames); - Assert.Equal(["moveMouseBy:4,5"], adapter.Calls); - } - - [Fact] - public async Task InvalidFrameReturnsErrorWithoutSessionExecution() - { - FakeInputAdapter adapter = new(); - BluetoothControlFrameProcessor processor = new(CreateSession(adapter)); - BluetoothFrame invalid = new(99, "incoming-1", 0, true, 2, "e30="); - - BluetoothControlFrameResult result = await processor.AcceptAsync("ble", invalid); - - Assert.True(result.MessageComplete); - Assert.Equal("invalid_frame", result.ErrorReason); - Assert.Empty(result.ResponseFrames); - Assert.Empty(adapter.Calls); - } - - [Fact] - public async Task RemoveConnectionDropsPartialMessages() - { - BluetoothControlFrameProcessor processor = new(CreateSession(new FakeInputAdapter())); - IReadOnlyList frames = BluetoothFrameCodec.CreateFrames(SignedCommand("keyboard.key", new { key = "Meta" }), "incoming-1", maxPayloadBytes: 40); - - Assert.False((await processor.AcceptAsync("ble", frames[0])).MessageComplete); - processor.RemoveConnection("ble"); - BluetoothControlFrameResult result = await processor.AcceptAsync("ble", frames[^1]); - - Assert.False(result.MessageComplete); - Assert.Equal("incomplete", result.ErrorReason); - } - - private static void AssertResponseType(IReadOnlyList frames, string expectedType) - { - BluetoothFrameReassembler reassembler = new(); - BluetoothFrameReassemblyResult result = BluetoothFrameReassemblyResult.Incomplete("incomplete"); - foreach (BluetoothFrame frame in frames) - { - result = reassembler.Accept(frame); - } - - Assert.True(result.Ok); - using JsonDocument response = JsonDocument.Parse(result.Message!); - Assert.Equal(expectedType, response.RootElement.GetProperty("type").GetString()); - } - - private static ControlSession CreateSession(FakeInputAdapter adapter) - { - MemoryPairingStore store = new(new PairingState( - DesktopId: "desktop-1", - PairedDevices: - [ - new PairedDevice(DeviceId, "Phone", Token, PairedAt: 1, LastSeenAt: null) - ])); - - PointerMovementProfile profile = new( - DisplayId: "display-1", - ScaleFactor: 1, - Bounds: new Bounds(0, 0, 1920, 1080), - MaxDelta: ProtocolConstants.MaxPointerDelta, - RecommendedDeltas: new RecommendedDeltas(49, 130, 281), - Capabilities: TestPointerCapabilities()); - - return new ControlSession( - new CommandAuthValidator(store, () => Now), - new DesktopCommandExecutor(adapter), - new FixedPointerProfileProvider(profile)); - } - - private static PointerCapabilities TestPointerCapabilities() - { - return new PointerCapabilities( - true, - ProtocolConstants.NoAckControlCommandTypes.ToArray(), - ProtocolConstants.CommandTypes.ToArray(), - new MouseRepeatCapabilities(true, true, 250, 250, 250, 100, 2000), - PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default), - new DisplayNavigationCapabilities(true, 2)); - } - - private static string SignedCommand(string type, object payload, string id = "request-1", string? responseMode = null) - { - JsonObject command = new() - { - ["version"] = ProtocolConstants.ProtocolVersion, - ["id"] = id, - ["deviceId"] = DeviceId, - ["timestamp"] = Now, - ["type"] = type, - ["payload"] = JsonSerializer.SerializeToNode(payload), - ["auth"] = "" - }; - - if (responseMode is not null) - { - command["responseMode"] = responseMode; - } - - using JsonDocument unsignedDocument = JsonDocument.Parse(command.ToJsonString()); - command["auth"] = CommandAuth.CreateCommandAuthProof(unsignedDocument.RootElement, Token); - return command.ToJsonString(); - } - - private sealed class FakeInputAdapter : IDesktopInputAdapter - { - public List Calls { get; } = []; - - public Task MoveMouseByAsync(double dx, double dy, CancellationToken cancellationToken = default) - { - Calls.Add($"moveMouseBy:{dx},{dy}"); - return Task.CompletedTask; - } - - public Task SetMouseButtonDownAsync(string button, bool down, CancellationToken cancellationToken = default) - { - Calls.Add($"setMouseButtonDown:{button}:{down}"); - return Task.CompletedTask; - } - - public Task ClickMouseAsync(string button, CancellationToken cancellationToken = default) - { - Calls.Add($"clickMouse:{button}"); - return Task.CompletedTask; - } - - public Task DoubleClickMouseAsync(string button, CancellationToken cancellationToken = default) - { - Calls.Add($"doubleClickMouse:{button}"); - return Task.CompletedTask; - } - - public Task ScrollMouseAsync(double dx, double dy, CancellationToken cancellationToken = default) - { - Calls.Add($"scrollMouse:{dx},{dy}"); - return Task.CompletedTask; - } - - public Task PressKeyAsync(string key, CancellationToken cancellationToken = default) - { - Calls.Add($"pressKey:{key}"); - return Task.CompletedTask; - } - - public Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default) - { - Calls.Add($"setKeyDown:{key}:{down}"); - return Task.CompletedTask; - } - - public Task PressShortcutAsync(IReadOnlyList keys, CancellationToken cancellationToken = default) - { - Calls.Add($"pressShortcut:{string.Join("+", keys)}"); - return Task.CompletedTask; - } - - public Task TypeTextAsync(string text, CancellationToken cancellationToken = default) - { - Calls.Add($"typeText:{text}"); - return Task.CompletedTask; - } - - public Task TypeCharacterAsync(string text, CancellationToken cancellationToken = default) - { - Calls.Add($"typeCharacter:{text}"); - return Task.CompletedTask; - } - - public Task MediaControlAsync(string action, CancellationToken cancellationToken = default) - { - Calls.Add($"mediaControl:{action}"); - return Task.CompletedTask; - } - - public Task ControlWindowAsync(string action, CancellationToken cancellationToken = default) - { - Calls.Add($"controlWindow:{action}"); - return Task.CompletedTask; - } - } -} diff --git a/src/SwitchifyPc.Tests/BluetoothGattProtocolTests.cs b/src/SwitchifyPc.Tests/BluetoothGattProtocolTests.cs new file mode 100644 index 0000000..1d87e0b --- /dev/null +++ b/src/SwitchifyPc.Tests/BluetoothGattProtocolTests.cs @@ -0,0 +1,15 @@ +using SwitchifyPc.Core.Bluetooth; + +namespace SwitchifyPc.Tests; + +public sealed class BluetoothGattProtocolTests +{ + [Fact] + public void UsesStableProtocolUuids() + { + Assert.Equal(Guid.Parse("7a78f7e8-1d6d-4d92-9ef0-1f89d3db21f4"), BluetoothGattProtocol.ServiceUuid); + Assert.Equal(Guid.Parse("7a78f7e9-1d6d-4d92-9ef0-1f89d3db21f4"), BluetoothGattProtocol.RxCharacteristicUuid); + Assert.Equal(Guid.Parse("7a78f7ea-1d6d-4d92-9ef0-1f89d3db21f4"), BluetoothGattProtocol.TxCharacteristicUuid); + Assert.Equal(Guid.Parse("7a78f7eb-1d6d-4d92-9ef0-1f89d3db21f4"), BluetoothGattProtocol.StatusCharacteristicUuid); + } +} diff --git a/src/SwitchifyPc.Tests/BluetoothHelperProtocolTests.cs b/src/SwitchifyPc.Tests/BluetoothHelperProtocolTests.cs deleted file mode 100644 index 6b36537..0000000 --- a/src/SwitchifyPc.Tests/BluetoothHelperProtocolTests.cs +++ /dev/null @@ -1,87 +0,0 @@ -using SwitchifyPc.Core.Bluetooth; -using SwitchifyPc.Protocol; - -namespace SwitchifyPc.Tests; - -public sealed class BluetoothHelperProtocolTests -{ - [Fact] - public void ExposesBluetoothUuidConstants() - { - Assert.Equal(Guid.Parse("7a78f7e8-1d6d-4d92-9ef0-1f89d3db21f4"), BluetoothHelperProtocol.ServiceUuid); - Assert.Equal(Guid.Parse("7a78f7e9-1d6d-4d92-9ef0-1f89d3db21f4"), BluetoothHelperProtocol.RxCharacteristicUuid); - Assert.Equal(Guid.Parse("7a78f7ea-1d6d-4d92-9ef0-1f89d3db21f4"), BluetoothHelperProtocol.TxCharacteristicUuid); - Assert.Equal(Guid.Parse("7a78f7eb-1d6d-4d92-9ef0-1f89d3db21f4"), BluetoothHelperProtocol.StatusCharacteristicUuid); - } - - [Fact] - public void ParsesDiagnosticAndDisconnectedEvents() - { - AssertParsed("""{"type":"diagnostic","event":"subscribed"}""", new BluetoothDiagnosticEvent("subscribed")); - AssertParsed("""{"type":"diagnostic","event":"system_radio_on"}""", new BluetoothDiagnosticEvent("system_radio_on")); - AssertParsed("""{"type":"diagnostic","event":"system_radio_off"}""", new BluetoothDiagnosticEvent("system_radio_off")); - AssertParsed("""{"type":"diagnostic","event":"advertising_restarted"}""", new BluetoothDiagnosticEvent("advertising_restarted")); - AssertParsed("""{"type":"disconnected","connectionId":"ble","reason":"adapter_off"}""", new BluetoothDisconnectedEvent("ble", "adapter_off")); - } - - [Fact] - public void ParsesLiveSystemBluetoothStatusWithoutRawIdentifiers() - { - Assert.True(BluetoothHelperProtocol.TryParseEvent( - """{"type":"systemStatus","adapterPresent":true,"radioState":"on","isLowEnergySupported":true,"isPeripheralRoleSupported":true,"deviceId":"not-forwarded"}""", - out BluetoothHelperEvent? helperEvent)); - - BluetoothSystemStatusEvent status = Assert.IsType(helperEvent); - Assert.True(status.AdapterPresent); - Assert.Equal("on", status.RadioState); - Assert.True(status.IsLowEnergySupported); - Assert.True(status.IsPeripheralRoleSupported); - } - - [Fact] - public void ParsesSystemBluetoothStatusWithNullCapabilities() - { - AssertParsed( - """{"type":"systemStatus","adapterPresent":false,"radioState":"unknown","isLowEnergySupported":null,"isPeripheralRoleSupported":null}""", - new BluetoothSystemStatusEvent(false, "unknown", null, null)); - } - - [Fact] - public void RejectsMalformedSystemStatusAndDiagnostics() - { - Assert.False(BluetoothHelperProtocol.TryParseEvent( - """{"type":"systemStatus","adapterPresent":true,"radioState":"pairing-token","isLowEnergySupported":true,"isPeripheralRoleSupported":true}""", - out _)); - Assert.False(BluetoothHelperProtocol.TryParseEvent("""{"type":"diagnostic","event":"payload:secret"}""", out _)); - } - - [Fact] - public void ParsesMessageEventsWithValidFrames() - { - string frame = $$""" - { - "type": "message", - "connectionId": "ble", - "frame": { - "version": {{BluetoothFrameCodec.BluetoothFrameVersion}}, - "messageId": "message-1", - "sequence": 0, - "isFinal": true, - "totalBytes": 2, - "payloadBase64": "e30=" - } - } - """; - - Assert.True(BluetoothHelperProtocol.TryParseEvent(frame, out BluetoothHelperEvent? helperEvent)); - BluetoothMessageEvent message = Assert.IsType(helperEvent); - Assert.Equal("ble", message.ConnectionId); - Assert.Equal("message-1", message.Frame.MessageId); - } - - private static void AssertParsed(string json, BluetoothHelperEvent expected) - { - Assert.True(BluetoothHelperProtocol.TryParseEvent(json, out BluetoothHelperEvent? actual)); - Assert.Equal(expected, actual); - } -} diff --git a/src/SwitchifyPc.Tests/BluetoothRuntimeTests.cs b/src/SwitchifyPc.Tests/BluetoothRuntimeTests.cs index 336400a..4ebe07b 100644 --- a/src/SwitchifyPc.Tests/BluetoothRuntimeTests.cs +++ b/src/SwitchifyPc.Tests/BluetoothRuntimeTests.cs @@ -109,14 +109,14 @@ private sealed record TestContext( private sealed class FakeTransportServer : IBluetoothTransportServer { - private Action? emit; + private Action? emit; public (string DisplayName, string DesktopId)? StartArguments { get; private set; } public int DisconnectAllCalls { get; private set; } public List<(string ConnectionId, BluetoothFrame Frame)> Sent { get; } = []; public List Disconnected { get; } = []; - public void SetEmitter(Action emitter) => emit = emitter; - public void Emit(BluetoothHelperEvent transportEvent) => emit!(transportEvent); + public void SetEmitter(Action emitter) => emit = emitter; + public void Emit(BluetoothTransportEvent transportEvent) => emit!(transportEvent); public Task StartAsync(string displayName, string desktopId) { StartArguments = (displayName, desktopId); diff --git a/src/SwitchifyPc.Tests/WindowsBluetoothGattServerTests.cs b/src/SwitchifyPc.Tests/WindowsBluetoothGattServerTests.cs index 3eb4f8d..7373385 100644 --- a/src/SwitchifyPc.Tests/WindowsBluetoothGattServerTests.cs +++ b/src/SwitchifyPc.Tests/WindowsBluetoothGattServerTests.cs @@ -1,5 +1,6 @@ using SwitchifyPc.Core.Bluetooth; using SwitchifyPc.Windows.Bluetooth; +using Windows.Devices.Radios; using Windows.Devices.Bluetooth.GenericAttributeProfile; namespace SwitchifyPc.Tests; @@ -13,16 +14,16 @@ public void DefaultOptionsUseProtocolBluetoothUuids() Assert.Equal("Switchify PC", options.DisplayName); Assert.Equal("desktop-1", options.DesktopId); - Assert.Equal(BluetoothHelperProtocol.ServiceUuid, options.ServiceUuid); - Assert.Equal(BluetoothHelperProtocol.RxCharacteristicUuid, options.RxCharacteristicUuid); - Assert.Equal(BluetoothHelperProtocol.TxCharacteristicUuid, options.TxCharacteristicUuid); - Assert.Equal(BluetoothHelperProtocol.StatusCharacteristicUuid, options.StatusCharacteristicUuid); + Assert.Equal(BluetoothGattProtocol.ServiceUuid, options.ServiceUuid); + Assert.Equal(BluetoothGattProtocol.RxCharacteristicUuid, options.RxCharacteristicUuid); + Assert.Equal(BluetoothGattProtocol.TxCharacteristicUuid, options.TxCharacteristicUuid); + Assert.Equal(BluetoothGattProtocol.StatusCharacteristicUuid, options.StatusCharacteristicUuid); } [Fact] public void ServerCanBeConstructedAndDisposedWithoutBluetoothHardware() { - List events = []; + List events = []; using WindowsBluetoothGattServer server = new(events.Add); @@ -54,4 +55,14 @@ public void ShutdownAndRadioDisconnectReasonsDoNotRestartAdvertising(string reas { Assert.False(WindowsBluetoothGattServer.ShouldRestartAdvertisingAfterDisconnect(reason)); } + + [Theory] + [InlineData(RadioState.On, "on")] + [InlineData(RadioState.Off, "off")] + [InlineData(RadioState.Disabled, "disabled")] + [InlineData(RadioState.Unknown, "unknown")] + public void SystemMonitorMapsWindowsRadioStates(RadioState state, string expected) + { + Assert.Equal(expected, WindowsBluetoothSystemMonitor.RadioStateToProtocol(state)); + } } diff --git a/src/SwitchifyPc.Windows/Bluetooth/WindowsBluetoothGattServer.cs b/src/SwitchifyPc.Windows/Bluetooth/WindowsBluetoothGattServer.cs index d66dfb0..c4e0000 100644 --- a/src/SwitchifyPc.Windows/Bluetooth/WindowsBluetoothGattServer.cs +++ b/src/SwitchifyPc.Windows/Bluetooth/WindowsBluetoothGattServer.cs @@ -5,7 +5,6 @@ using SwitchifyPc.Protocol; using Windows.Devices.Bluetooth; using Windows.Devices.Bluetooth.GenericAttributeProfile; -using Windows.Devices.Radios; using Windows.Storage.Streams; namespace SwitchifyPc.Windows.Bluetooth; @@ -22,19 +21,19 @@ public static WindowsBluetoothGattServerOptions CreateDefault(string displayName new( displayName, desktopId, - BluetoothHelperProtocol.ServiceUuid, - BluetoothHelperProtocol.RxCharacteristicUuid, - BluetoothHelperProtocol.TxCharacteristicUuid, - BluetoothHelperProtocol.StatusCharacteristicUuid); + BluetoothGattProtocol.ServiceUuid, + BluetoothGattProtocol.RxCharacteristicUuid, + BluetoothGattProtocol.TxCharacteristicUuid, + BluetoothGattProtocol.StatusCharacteristicUuid); } public sealed class WindowsBluetoothGattServer : IBluetoothTransportServer { private const string ConnectionId = "ble"; private static readonly TimeSpan DisconnectGracePeriod = TimeSpan.FromSeconds(10); - private static readonly TimeSpan SystemStatusPollInterval = TimeSpan.FromSeconds(5); - private readonly Action emit; + private readonly Action emit; + private readonly WindowsBluetoothSystemMonitor systemMonitor = new(); private GattServiceProvider? serviceProvider; private GattLocalCharacteristic? txCharacteristic; private GattLocalCharacteristic? rxCharacteristic; @@ -43,14 +42,11 @@ public sealed class WindowsBluetoothGattServer : IBluetoothTransportServer private bool connected; private CancellationTokenSource? disconnectGrace; private WindowsBluetoothGattServerOptions? activeOptions; - private BluetoothAdapter? currentAdapter; - private Radio? currentRadio; - private CancellationTokenSource? systemStatusPolling; private string? lastSystemStatusKey; private bool restartInProgress; private bool disposed; - public WindowsBluetoothGattServer(Action emit) + public WindowsBluetoothGattServer(Action emit) { this.emit = emit; } @@ -60,7 +56,7 @@ public async Task StartAsync(WindowsBluetoothGattServerOptions options) ObjectDisposedException.ThrowIf(disposed, this); Stop(); activeOptions = options; - AdapterSnapshot snapshot = await StartSystemStatusMonitoringAsync().ConfigureAwait(false); + WindowsBluetoothAdapterSnapshot snapshot = await systemMonitor.StartAsync(HandleSystemStatusChangeAsync).ConfigureAwait(false); EmitSystemStatus(snapshot, force: true); if (!snapshot.AdapterPresent) @@ -91,7 +87,8 @@ public Task StartAsync(string displayName, string desktopId) public void Stop() { - StopSystemStatusMonitoring(); + systemMonitor.Stop(); + lastSystemStatusKey = null; activeOptions = null; CancelDisconnectGrace(); if (connected) @@ -138,7 +135,7 @@ public void Dispose() disposed = true; Stop(); disconnectGrace?.Dispose(); - systemStatusPolling?.Dispose(); + systemMonitor.Dispose(); } private async Task StartAdvertisingAsync(WindowsBluetoothGattServerOptions options, bool restarted) @@ -190,106 +187,7 @@ private void StopAdvertisingOnly() statusCharacteristic = null; } - private async Task StartSystemStatusMonitoringAsync() - { - StopSystemStatusMonitoring(); - systemStatusPolling = new CancellationTokenSource(); - CancellationToken token = systemStatusPolling.Token; - AdapterSnapshot snapshot = await ReadAdapterSnapshotAsync().ConfigureAwait(false); - - _ = Task.Run(async () => - { - while (!token.IsCancellationRequested) - { - try - { - await Task.Delay(SystemStatusPollInterval, token).ConfigureAwait(false); - if (token.IsCancellationRequested) return; - - AdapterSnapshot current = await ReadAdapterSnapshotAsync().ConfigureAwait(false); - await HandleSystemStatusChangeAsync(current).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - return; - } - catch - { - await HandleSystemStatusChangeAsync(new AdapterSnapshot(false, "unknown", null, null)).ConfigureAwait(false); - } - } - }, token); - - return snapshot; - } - - private void StopSystemStatusMonitoring() - { - systemStatusPolling?.Cancel(); - systemStatusPolling?.Dispose(); - systemStatusPolling = null; - DetachRadioStateChanged(); - currentAdapter = null; - lastSystemStatusKey = null; - } - - private async Task ReadAdapterSnapshotAsync() - { - try - { - BluetoothAdapter? adapter = await BluetoothAdapter.GetDefaultAsync(); - if (adapter is null) - { - DetachRadioStateChanged(); - currentAdapter = null; - return new AdapterSnapshot(false, "unknown", null, null); - } - - currentAdapter = adapter; - Radio? radio = await adapter.GetRadioAsync(); - if (!ReferenceEquals(currentRadio, radio)) - { - DetachRadioStateChanged(); - if (radio is not null) - { - AttachRadioStateChanged(radio); - } - } - - return new AdapterSnapshot( - true, - RadioStateToProtocol(radio?.State), - adapter.IsLowEnergySupported, - adapter.IsPeripheralRoleSupported); - } - catch - { - return new AdapterSnapshot(false, "unknown", null, null); - } - } - - private void AttachRadioStateChanged(Radio radio) - { - currentRadio = radio; - currentRadio.StateChanged += OnRadioStateChanged; - } - - private void DetachRadioStateChanged() - { - if (currentRadio is not null) - { - currentRadio.StateChanged -= OnRadioStateChanged; - currentRadio = null; - } - } - - private async void OnRadioStateChanged(Radio sender, object args) - { - AdapterSnapshot snapshot = await ReadAdapterSnapshotAsync().ConfigureAwait(false); - await HandleSystemStatusChangeAsync(snapshot).ConfigureAwait(false); - } - - private void EmitSystemStatus(AdapterSnapshot snapshot, bool force = false) + private void EmitSystemStatus(WindowsBluetoothAdapterSnapshot snapshot, bool force = false) { string key = $"{snapshot.AdapterPresent}|{snapshot.RadioState}|{snapshot.IsLowEnergySupported}|{snapshot.IsPeripheralRoleSupported}"; if (!force && key == lastSystemStatusKey) @@ -305,7 +203,7 @@ private void EmitSystemStatus(AdapterSnapshot snapshot, bool force = false) snapshot.IsPeripheralRoleSupported)); } - private async Task HandleSystemStatusChangeAsync(AdapterSnapshot snapshot) + private async Task HandleSystemStatusChangeAsync(WindowsBluetoothAdapterSnapshot snapshot) { string? previousKey = lastSystemStatusKey; EmitSystemStatus(snapshot); @@ -607,7 +505,7 @@ private async Task RestartAdvertisingAfterClientDisconnectAsync() restartInProgress = true; try { - AdapterSnapshot snapshot = await ReadAdapterSnapshotAsync().ConfigureAwait(false); + WindowsBluetoothAdapterSnapshot snapshot = await systemMonitor.ReadAsync().ConfigureAwait(false); EmitSystemStatus(snapshot); if (!snapshot.AdapterPresent || @@ -627,25 +525,9 @@ private async Task RestartAdvertisingAfterClientDisconnectAsync() } } - private static string RadioStateToProtocol(RadioState? state) - { - return state switch - { - RadioState.On => "on", - RadioState.Off => "off", - RadioState.Disabled => "disabled", - _ => "unknown" - }; - } - private static readonly JsonSerializerOptions FrameJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - private sealed record AdapterSnapshot( - bool AdapterPresent, - string RadioState, - bool? IsLowEnergySupported, - bool? IsPeripheralRoleSupported); } diff --git a/src/SwitchifyPc.Windows/Bluetooth/WindowsBluetoothSystemMonitor.cs b/src/SwitchifyPc.Windows/Bluetooth/WindowsBluetoothSystemMonitor.cs new file mode 100644 index 0000000..4628516 --- /dev/null +++ b/src/SwitchifyPc.Windows/Bluetooth/WindowsBluetoothSystemMonitor.cs @@ -0,0 +1,130 @@ +using Windows.Devices.Bluetooth; +using Windows.Devices.Radios; + +namespace SwitchifyPc.Windows.Bluetooth; + +internal sealed record WindowsBluetoothAdapterSnapshot( + bool AdapterPresent, + string RadioState, + bool? IsLowEnergySupported, + bool? IsPeripheralRoleSupported); + +internal sealed class WindowsBluetoothSystemMonitor : IDisposable +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(5); + private Radio? currentRadio; + private CancellationTokenSource? polling; + private Func? onStatusChanged; + + public async Task StartAsync( + Func statusChanged) + { + Stop(); + onStatusChanged = statusChanged; + polling = new CancellationTokenSource(); + CancellationToken token = polling.Token; + WindowsBluetoothAdapterSnapshot snapshot = await ReadAsync().ConfigureAwait(false); + + _ = Task.Run(async () => + { + while (!token.IsCancellationRequested) + { + try + { + await Task.Delay(PollInterval, token).ConfigureAwait(false); + if (token.IsCancellationRequested) return; + await NotifyStatusChangedAsync(await ReadAsync().ConfigureAwait(false)).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + catch + { + await NotifyStatusChangedAsync(UnavailableSnapshot()).ConfigureAwait(false); + } + } + }, token); + + return snapshot; + } + + public async Task ReadAsync() + { + try + { + BluetoothAdapter? adapter = await BluetoothAdapter.GetDefaultAsync(); + if (adapter is null) + { + DetachRadio(); + return UnavailableSnapshot(); + } + + Radio? radio = await adapter.GetRadioAsync(); + if (!ReferenceEquals(currentRadio, radio)) + { + DetachRadio(); + if (radio is not null) + { + currentRadio = radio; + currentRadio.StateChanged += OnRadioStateChanged; + } + } + + return new WindowsBluetoothAdapterSnapshot( + true, + RadioStateToProtocol(radio?.State), + adapter.IsLowEnergySupported, + adapter.IsPeripheralRoleSupported); + } + catch + { + return UnavailableSnapshot(); + } + } + + public void Stop() + { + polling?.Cancel(); + polling?.Dispose(); + polling = null; + onStatusChanged = null; + DetachRadio(); + } + + public void Dispose() + { + Stop(); + } + + internal static string RadioStateToProtocol(RadioState? state) + { + return state switch + { + RadioState.On => "on", + RadioState.Off => "off", + RadioState.Disabled => "disabled", + _ => "unknown" + }; + } + + private async void OnRadioStateChanged(Radio sender, object args) + { + await NotifyStatusChangedAsync(await ReadAsync().ConfigureAwait(false)).ConfigureAwait(false); + } + + private Task NotifyStatusChangedAsync(WindowsBluetoothAdapterSnapshot snapshot) + { + return onStatusChanged?.Invoke(snapshot) ?? Task.CompletedTask; + } + + private void DetachRadio() + { + if (currentRadio is null) return; + currentRadio.StateChanged -= OnRadioStateChanged; + currentRadio = null; + } + + private static WindowsBluetoothAdapterSnapshot UnavailableSnapshot() => + new(false, "unknown", null, null); +}