diff --git a/BrickController2.sln b/BrickController2.sln index 00a4a882..987d9bad 100644 --- a/BrickController2.sln +++ b/BrickController2.sln @@ -9,12 +9,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BrickController2.iOS", "Bri EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BrickController2", "BrickController2\BrickController2\BrickController2.csproj", "{852D9034-471A-42D0-8701-63D12E2EDACA}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BrickController2.WinUI", "BrickController2\BrickController2.WinUI\BrickController2.WinUI.csproj", "{47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Debug|ARM64 = Debug|ARM64 + Debug|x64 = Debug|x64 Release|Any CPU = Release|Any CPU Release|ARM64 = Release|ARM64 + Release|x64 = Release|x64 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A82CFFAA-423D-473B-820C-174F7AE48B7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -23,32 +27,66 @@ Global {A82CFFAA-423D-473B-820C-174F7AE48B7E}.Debug|ARM64.ActiveCfg = Debug|Any CPU {A82CFFAA-423D-473B-820C-174F7AE48B7E}.Debug|ARM64.Build.0 = Debug|Any CPU {A82CFFAA-423D-473B-820C-174F7AE48B7E}.Debug|ARM64.Deploy.0 = Debug|Any CPU + {A82CFFAA-423D-473B-820C-174F7AE48B7E}.Debug|x64.ActiveCfg = Debug|Any CPU + {A82CFFAA-423D-473B-820C-174F7AE48B7E}.Debug|x64.Build.0 = Debug|Any CPU + {A82CFFAA-423D-473B-820C-174F7AE48B7E}.Debug|x64.Deploy.0 = Debug|Any CPU {A82CFFAA-423D-473B-820C-174F7AE48B7E}.Release|Any CPU.ActiveCfg = Release|Any CPU {A82CFFAA-423D-473B-820C-174F7AE48B7E}.Release|Any CPU.Build.0 = Release|Any CPU {A82CFFAA-423D-473B-820C-174F7AE48B7E}.Release|Any CPU.Deploy.0 = Release|Any CPU {A82CFFAA-423D-473B-820C-174F7AE48B7E}.Release|ARM64.ActiveCfg = Release|Any CPU {A82CFFAA-423D-473B-820C-174F7AE48B7E}.Release|ARM64.Build.0 = Release|Any CPU {A82CFFAA-423D-473B-820C-174F7AE48B7E}.Release|ARM64.Deploy.0 = Release|Any CPU + {A82CFFAA-423D-473B-820C-174F7AE48B7E}.Release|x64.ActiveCfg = Release|Any CPU + {A82CFFAA-423D-473B-820C-174F7AE48B7E}.Release|x64.Build.0 = Release|Any CPU + {A82CFFAA-423D-473B-820C-174F7AE48B7E}.Release|x64.Deploy.0 = Release|Any CPU {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Debug|Any CPU.Build.0 = Debug|Any CPU {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Debug|Any CPU.Deploy.0 = Debug|Any CPU {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Debug|ARM64.ActiveCfg = Debug|ARM64 {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Debug|ARM64.Build.0 = Debug|ARM64 {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Debug|ARM64.Deploy.0 = Debug|ARM64 + {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Debug|x64.ActiveCfg = Debug|Any CPU + {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Debug|x64.Build.0 = Debug|Any CPU + {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Debug|x64.Deploy.0 = Debug|Any CPU {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Release|Any CPU.ActiveCfg = Release|Any CPU {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Release|Any CPU.Build.0 = Release|Any CPU {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Release|Any CPU.Deploy.0 = Release|Any CPU {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Release|ARM64.ActiveCfg = Release|ARM64 {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Release|ARM64.Build.0 = Release|ARM64 {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Release|ARM64.Deploy.0 = Release|ARM64 + {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Release|x64.ActiveCfg = Release|Any CPU + {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Release|x64.Build.0 = Release|Any CPU + {E52D9D91-6F31-42E2-8261-D85AA62D39D1}.Release|x64.Deploy.0 = Release|Any CPU {852D9034-471A-42D0-8701-63D12E2EDACA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {852D9034-471A-42D0-8701-63D12E2EDACA}.Debug|Any CPU.Build.0 = Debug|Any CPU {852D9034-471A-42D0-8701-63D12E2EDACA}.Debug|ARM64.ActiveCfg = Debug|Any CPU {852D9034-471A-42D0-8701-63D12E2EDACA}.Debug|ARM64.Build.0 = Debug|Any CPU + {852D9034-471A-42D0-8701-63D12E2EDACA}.Debug|x64.ActiveCfg = Debug|Any CPU + {852D9034-471A-42D0-8701-63D12E2EDACA}.Debug|x64.Build.0 = Debug|Any CPU {852D9034-471A-42D0-8701-63D12E2EDACA}.Release|Any CPU.ActiveCfg = Release|Any CPU {852D9034-471A-42D0-8701-63D12E2EDACA}.Release|Any CPU.Build.0 = Release|Any CPU {852D9034-471A-42D0-8701-63D12E2EDACA}.Release|ARM64.ActiveCfg = Release|Any CPU {852D9034-471A-42D0-8701-63D12E2EDACA}.Release|ARM64.Build.0 = Release|Any CPU + {852D9034-471A-42D0-8701-63D12E2EDACA}.Release|x64.ActiveCfg = Release|Any CPU + {852D9034-471A-42D0-8701-63D12E2EDACA}.Release|x64.Build.0 = Release|Any CPU + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Debug|Any CPU.ActiveCfg = Debug|x64 + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Debug|Any CPU.Build.0 = Debug|x64 + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Debug|Any CPU.Deploy.0 = Debug|x64 + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Debug|ARM64.Build.0 = Debug|ARM64 + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Debug|ARM64.Deploy.0 = Debug|ARM64 + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Debug|x64.ActiveCfg = Debug|x64 + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Debug|x64.Build.0 = Debug|x64 + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Debug|x64.Deploy.0 = Debug|x64 + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Release|Any CPU.ActiveCfg = Release|x64 + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Release|Any CPU.Build.0 = Release|x64 + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Release|Any CPU.Deploy.0 = Release|x64 + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Release|ARM64.ActiveCfg = Release|ARM64 + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Release|ARM64.Build.0 = Release|ARM64 + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Release|ARM64.Deploy.0 = Release|ARM64 + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Release|x64.ActiveCfg = Release|x64 + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Release|x64.Build.0 = Release|x64 + {47060ECF-9DBD-424B-8CB7-0B0578DB6D7A}.Release|x64.Deploy.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/BrickController2/BrickController2.WinUI/App.xaml b/BrickController2/BrickController2.WinUI/App.xaml new file mode 100644 index 00000000..8ad5f36e --- /dev/null +++ b/BrickController2/BrickController2.WinUI/App.xaml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/BrickController2/BrickController2.WinUI/App.xaml.cs b/BrickController2/BrickController2.WinUI/App.xaml.cs new file mode 100644 index 00000000..8097ed05 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/App.xaml.cs @@ -0,0 +1,60 @@ +using Autofac; +using Autofac.Extensions.DependencyInjection; +using BrickController2.BusinessLogic.DI; +using BrickController2.CreationManagement.DI; +using BrickController2.Database.DI; +using BrickController2.DeviceManagement.DI; +using BrickController2.UI.Controls; +using BrickController2.UI.DI; +using BrickController2.UI.Pages; +using BrickController2.Windows.PlatformServices.DI; +using BrickController2.Windows.UI.CustomHandlers; +using Microsoft.Maui; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Hosting; +using Microsoft.Maui.Hosting; +using Windows.Devices.Input; + +namespace BrickController2.Windows; + +/// +/// Provides application-specific behavior to supplement the default Application class. +/// +public partial class App : MauiWinUIApplication +{ + public App() + { + InitializeComponent(); + } + protected override MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp() + .ConfigureMauiHandlers(handlers => + { + handlers + .AddHandler() + .AddHandler() + ; + + // handle swipe if there is no touch screen + var capablitities = new TouchCapabilities(); + if (capablitities.TouchPresent == 0) + { + handlers.AddHandler(); + } + }) + .ConfigureContainer(new AutofacServiceProviderFactory(), autofacBuilder => + { + autofacBuilder.RegisterModule(); + autofacBuilder.RegisterModule(); + autofacBuilder.RegisterModule(); + autofacBuilder.RegisterModule(); + autofacBuilder.RegisterModule(); + autofacBuilder.RegisterModule(); + }); + + return builder.Build(); + } +} diff --git a/BrickController2/BrickController2.WinUI/Assets/SplashScreen.scale-200.png b/BrickController2/BrickController2.WinUI/Assets/SplashScreen.scale-200.png new file mode 100644 index 00000000..32e82d21 Binary files /dev/null and b/BrickController2/BrickController2.WinUI/Assets/SplashScreen.scale-200.png differ diff --git a/BrickController2/BrickController2.WinUI/Assets/Square44x44Logo.scale-150.png b/BrickController2/BrickController2.WinUI/Assets/Square44x44Logo.scale-150.png new file mode 100644 index 00000000..dc602880 Binary files /dev/null and b/BrickController2/BrickController2.WinUI/Assets/Square44x44Logo.scale-150.png differ diff --git a/BrickController2/BrickController2.WinUI/Assets/StoreLogo.png b/BrickController2/BrickController2.WinUI/Assets/StoreLogo.png new file mode 100644 index 00000000..b00cbb33 Binary files /dev/null and b/BrickController2/BrickController2.WinUI/Assets/StoreLogo.png differ diff --git a/BrickController2/BrickController2.WinUI/BrickController2.WinUI.csproj b/BrickController2/BrickController2.WinUI/BrickController2.WinUI.csproj new file mode 100644 index 00000000..acad2a6e --- /dev/null +++ b/BrickController2/BrickController2.WinUI/BrickController2.WinUI.csproj @@ -0,0 +1,53 @@ + + + + WinExe + net8.0-windows10.0.19041.0 + 10.0.17763.0 + x64;ARM64 + win10-x64;win10-arm64 + win10-$(Platform).pubxml + + app.manifest + BrickController2.Windows + false + + true + true + + false + + + + + + + + + + + + + + + + + + + + + + + + true + + + \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/Extensions/AdvertismentExtensions.cs b/BrickController2/BrickController2.WinUI/Extensions/AdvertismentExtensions.cs new file mode 100644 index 00000000..5526b3b4 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/Extensions/AdvertismentExtensions.cs @@ -0,0 +1,14 @@ +using Windows.Devices.Bluetooth.Advertisement; + +namespace BrickController2.Windows.Extensions; + +public static class AdvertismentExtensions +{ + public static string GetLocalName(this BluetoothLEAdvertisementReceivedEventArgs args) => args.Advertisement.LocalName.TrimEnd(); + + public static bool IsValidDeviceName(this string deviceName) => !string.IsNullOrEmpty(deviceName); + + public static bool CanCarryData(this BluetoothLEAdvertisementReceivedEventArgs args) => + args.AdvertisementType == BluetoothLEAdvertisementType.ScanResponse || + args.AdvertisementType == BluetoothLEAdvertisementType.ConnectableUndirected; +} diff --git a/BrickController2/BrickController2.WinUI/Extensions/ControllerExtensions.cs b/BrickController2/BrickController2.WinUI/Extensions/ControllerExtensions.cs new file mode 100644 index 00000000..33dcecb7 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/Extensions/ControllerExtensions.cs @@ -0,0 +1,10 @@ +using Windows.Gaming.Input; + +namespace BrickController2.Windows.Extensions; + +public static class ControllerExtensions +{ + public static string GetDeviceId(this Gamepad gamepad) => + // kinda hack + gamepad.User.NonRoamableId; +} diff --git a/BrickController2/BrickController2.WinUI/Extensions/ConvertExtensions.cs b/BrickController2/BrickController2.WinUI/Extensions/ConvertExtensions.cs new file mode 100644 index 00000000..f5c1971f --- /dev/null +++ b/BrickController2/BrickController2.WinUI/Extensions/ConvertExtensions.cs @@ -0,0 +1,58 @@ +namespace BrickController2.Windows.Extensions; + +public static class ConvertExtensions +{ + public static string ToBluetoothAddressString(this ulong bluetoothAddress) + { + // 48bit physical BT address + var a = (byte)((bluetoothAddress >> 40) & 0xFF); + var b = (byte)((bluetoothAddress >> 32) & 0xFF); + var c = (byte)((bluetoothAddress >> 24) & 0xFF); + var d = (byte)((bluetoothAddress >> 16) & 0xFF); + var e = (byte)((bluetoothAddress >> 8) & 0xFF); + var f = (byte)(bluetoothAddress & 0xFF); + + return $"{a:X2}:{b:X2}:{c:X2}:{d:X2}:{e:X2}:{f:X2}"; + } + + public static bool TryParseBluetoothAddressString(this string stringValue, out ulong bluetoothAddress) + { + bluetoothAddress = default; + + if (string.IsNullOrEmpty(stringValue) || stringValue.Length != 17) + { + return false; + } + + ulong value = 0; + + for (int i = 1; i <= stringValue.Length; i++) + { + var ch = (uint)stringValue[i - 1]; + if (i % 3 == 0) + { + if (ch != '-' && ch != ':') + { + // missing dash + return false; + } + } + else if (ch >= 0x30 && ch <= 0x39) + { + value = (value << 4) + ch - 0x30; + } + else if (ch >= 0x41 && ch <= 0x46) + { + value = (value << 4) + ch - 0x37; + } + else + { + // wrong character + return false; + } + } + + bluetoothAddress = value; + return true; + } +} diff --git a/BrickController2/BrickController2.WinUI/Extensions/GamepadReadingExtenions.cs b/BrickController2/BrickController2.WinUI/Extensions/GamepadReadingExtenions.cs new file mode 100644 index 00000000..94789220 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/Extensions/GamepadReadingExtenions.cs @@ -0,0 +1,92 @@ +using BrickController2.PlatformServices.GameController; +using System; +using System.Collections.Generic; +using Windows.Gaming.Input; + +namespace BrickController2.Windows.Extensions; + +internal static class GamepadReadingExtenions +{ + public const float Zero = 0.0f; + public const float Positive = 1.0f; + public const float Negative = -1.0f; + + public const float Delta = 0.05f; + public const float Limit = Positive - Delta; + + public static IEnumerable<(string Name, GameControllerEventType EventType, float Value)> Enumerate(this GamepadReading readings) + { + // native axes + yield return GetAxis("X", readings.LeftThumbstickX, Positive); + yield return GetAxis("Y", readings.LeftThumbstickY, Negative); + yield return GetAxis("Brake", readings.LeftTrigger, Positive); + yield return GetAxis("Z", readings.RightThumbstickX, Positive); + yield return GetAxis("Rz", readings.RightThumbstickY, Negative); + yield return GetAxis("Gas", readings.RightTrigger, Positive); + + // buttons treated as axis + yield return GetHybridButton(readings, GamepadButtons.DPadDown, GamepadButtons.DPadUp, "HatY"); + yield return GetHybridButton(readings, GamepadButtons.DPadRight, GamepadButtons.DPadLeft, "HatX"); + + // get buttons + yield return GetButton(readings, GamepadButtons.A, "ButtonA"); + yield return GetButton(readings, GamepadButtons.B, "ButtonB"); + yield return GetButton(readings, GamepadButtons.X, "ButtonX"); + yield return GetButton(readings, GamepadButtons.Y, "ButtonY"); + yield return GetButton(readings, GamepadButtons.LeftShoulder, "ButtonL1"); + yield return GetButton(readings, GamepadButtons.RightShoulder, "ButtonR1"); + yield return GetButton(readings, GamepadButtons.Menu, "ButtonStart"); + yield return GetButton(readings, GamepadButtons.View, "ButtonSelect"); + yield return GetButton(readings, GamepadButtons.LeftThumbstick, "ButtonThumbl"); + yield return GetButton(readings, GamepadButtons.RightThumbstick, "ButtonThumbr"); + + // TODO Home button - 0x40000000 + + //TODO + // GamepadButtons.Paddle1 + // GamepadButtons.Paddle2 + // GamepadButtons.Paddle3 + // GamepadButtons.Paddle4 + } + + private static (string Name, GameControllerEventType Type, float value) GetHybridButton(this GamepadReading readings, GamepadButtons button, GamepadButtons opositeButton, string name) + { + // get primary button + if (readings.Buttons.HasFlag(button)) + { + return new(name, GameControllerEventType.Axis, Positive); + } + if (readings.Buttons.HasFlag(opositeButton)) + { + return new(name, GameControllerEventType.Axis, Negative); + } + return new(name, GameControllerEventType.Axis, Zero); + } + + private static (string Name, GameControllerEventType Type, float value) GetButton(this GamepadReading readings, GamepadButtons button, string name) + { + // get primary button + if (readings.Buttons.HasFlag(button)) + { + return new(name, GameControllerEventType.Button, Positive); + } + return new(name, GameControllerEventType.Button, Zero); + } + + private static (string Name, GameControllerEventType Type, float value) GetAxis(string name, double value, float maxValue) + { + if (Math.Abs(value) < Delta) + { + return (name, GameControllerEventType.Axis, Zero); + } + if (value > 0.95) + { + return (name, GameControllerEventType.Axis, maxValue); + } + if (value < -0.95) + { + return (name, GameControllerEventType.Axis, -maxValue); + } + return (name, GameControllerEventType.Axis, maxValue * (float)value); + } +} diff --git a/BrickController2/BrickController2.WinUI/Extensions/IBufferExtensions.cs b/BrickController2/BrickController2.WinUI/Extensions/IBufferExtensions.cs new file mode 100644 index 00000000..acc3760b --- /dev/null +++ b/BrickController2/BrickController2.WinUI/Extensions/IBufferExtensions.cs @@ -0,0 +1,25 @@ +using Windows.Storage.Streams; + +namespace BrickController2.Windows.Extensions; + +public static class IBufferExtensions +{ + public static IBuffer ToBuffer(this byte[] data) + { + var writer = new DataWriter(); + writer.WriteBytes(data); + + return writer.DetachBuffer(); + } + + public static byte[] ToByteArray(this IBuffer buffer) + { + using (var reader = DataReader.FromBuffer(buffer)) + { + byte[] input = new byte[reader.UnconsumedBufferLength]; + reader.ReadBytes(input); + + return input; + } + } +} diff --git a/BrickController2/BrickController2.WinUI/Package.appxmanifest b/BrickController2/BrickController2.WinUI/Package.appxmanifest new file mode 100644 index 00000000..df21452f --- /dev/null +++ b/BrickController2/BrickController2.WinUI/Package.appxmanifest @@ -0,0 +1,50 @@ + + + + + + + + BrickController2 + 20e2eb05-a9ce-4df2-b83a-efe50e08ac16 + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/BluetoothLE/BleDevice.cs b/BrickController2/BrickController2.WinUI/PlatformServices/BluetoothLE/BleDevice.cs new file mode 100644 index 00000000..18ba6510 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/PlatformServices/BluetoothLE/BleDevice.cs @@ -0,0 +1,312 @@ +using BrickController2.Helpers; +using BrickController2.PlatformServices.BluetoothLE; +using BrickController2.Windows.Extensions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Windows.Devices.Bluetooth; +using Windows.Devices.Bluetooth.GenericAttributeProfile; + +namespace BrickController2.Windows.PlatformServices.BluetoothLE; + +public class BleDevice : IBluetoothLEDevice +{ + private readonly AsyncLock _lock = new(); + + private BluetoothLEDevice _bluetoothDevice; + private ICollection _services; + + private TaskCompletionSource> _connectCompletionSource; + + private Action _onCharacteristicChanged; + private Action _onDeviceDisconnected; + + public BleDevice(string address) + { + Address = address; + } + + public string Address { get; } + public BluetoothLEDeviceState State { get; private set; } = BluetoothLEDeviceState.Disconnected; + + public async Task> ConnectAndDiscoverServicesAsync( + bool autoConnect, + Action onCharacteristicChanged, + Action onDeviceDisconnected, + CancellationToken token) + { + using (var tokenRegistration = token.Register(async () => + { + using (await _lock.LockAsync()) + { + InternalDisconnect(); + _connectCompletionSource?.TrySetResult(null); + } + })) + { + _services = await ConnectAsync(onCharacteristicChanged, onDeviceDisconnected); + return _services; + } + } + + private async Task> ConnectAsync( + Action onCharacteristicChanged, + Action onDeviceDisconnected) + { + using (await _lock.LockAsync()) + { + if (State != BluetoothLEDeviceState.Disconnected) + { + return null; + } + _onCharacteristicChanged = onCharacteristicChanged; + _onDeviceDisconnected = onDeviceDisconnected; + + State = BluetoothLEDeviceState.Connecting; + + if (Address.TryParseBluetoothAddressString(out var bluetoothAddress)) + { + _bluetoothDevice?.Dispose(); + _bluetoothDevice = await BluetoothLEDevice.FromBluetoothAddressAsync(bluetoothAddress); + } + + if (_bluetoothDevice == null) + { + InternalDisconnect(); + return null; + } + + _bluetoothDevice.ConnectionStatusChanged += _bluetoothDevice_ConnectionStatusChanged; + + _connectCompletionSource = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + } + + // enforce connection check + await OnConnection(); + + var result = await _connectCompletionSource.Task; + _connectCompletionSource = null; + + return result; + } + + public async Task DisconnectAsync() + { + using (await _lock.LockAsync()) + { + InternalDisconnect(); + } + } + + private void InternalDisconnect() + { + _onDeviceDisconnected = null; + _onCharacteristicChanged = null; + + if (_services != null) + { + foreach (var service in _services) + { + service.Dispose(); + } + _services = null; + } + + if (_bluetoothDevice != null) + { + _bluetoothDevice.ConnectionStatusChanged -= _bluetoothDevice_ConnectionStatusChanged; + _bluetoothDevice.Dispose(); + _bluetoothDevice = null; + } + + State = BluetoothLEDeviceState.Disconnected; + } + + public async Task EnableNotificationAsync(IGattCharacteristic characteristic, CancellationToken token) + { + using (await _lock.LockAsync()) + { + if (State == BluetoothLEDeviceState.Connected && + characteristic is BleGattCharacteristic bleGattCharacteristic && + bleGattCharacteristic.CanNotify) + { + return await bleGattCharacteristic + .EnableNotificationAsync(_onCharacteristicChanged); + } + + return false; + } + } + + public async Task DisableNotificationAsync(IGattCharacteristic characteristic, CancellationToken token) + { + using (await _lock.LockAsync()) + { + if (State == BluetoothLEDeviceState.Connected && + characteristic is BleGattCharacteristic bleGattCharacteristic && + bleGattCharacteristic.CanNotify) + { + return await bleGattCharacteristic.DisableNotificationAsync(); + } + + return false; + } + } + + public async Task WriteAsync(IGattCharacteristic characteristic, byte[] data, CancellationToken token) + { + using (await _lock.LockAsync()) + { + if (State == BluetoothLEDeviceState.Connected && + characteristic is BleGattCharacteristic bleGattCharacteristic) + { + + var result = await bleGattCharacteristic.WriteWithResponseAsync(data); + return result.Status == GattCommunicationStatus.Success; + } + return false; + } + } + + public async Task WriteNoResponseAsync(IGattCharacteristic characteristic, byte[] data, CancellationToken token) + { + using (await _lock.LockAsync()) + { + if (State == BluetoothLEDeviceState.Connected && + characteristic is BleGattCharacteristic bleGattCharacteristic) + { + var result = await bleGattCharacteristic.WriteNoResponseAsync(data); + return result == GattCommunicationStatus.Success; + } + return false; + } + } + + public async Task ReadAsync(IGattCharacteristic characteristic, CancellationToken token) + { + using (await _lock.LockAsync()) + { + if (State == BluetoothLEDeviceState.Connected && + characteristic is BleGattCharacteristic bleGattCharacteristic) + { + var result = await bleGattCharacteristic.ReadValueAsync(); + + if (result.Status == GattCommunicationStatus.Success) + { + return result.Value.ToByteArray(); + } + } + return null; + } + } + + private void _bluetoothDevice_ConnectionStatusChanged(BluetoothLEDevice sender, object args) + { + // check for a raise condition + if (sender != _bluetoothDevice) + return; + + // uses lock inside OnXXX methods, execution is not awaited + switch (sender.ConnectionStatus) + { + case BluetoothConnectionStatus.Connected: + _ = OnConnection(); + break; + + case BluetoothConnectionStatus.Disconnected: + _ = OnDisconnection(); + break; + } + } + + private async Task OnConnection() + { + using (await _lock.LockAsync()) + { + if (State == BluetoothLEDeviceState.Connecting) + { + State = BluetoothLEDeviceState.Discovering; + + await DiscoverServices(BluetoothCacheMode.Uncached); + } + else if (State == BluetoothLEDeviceState.Connected) + { + // no need to react + } + else + { + InternalDisconnect(); + _connectCompletionSource?.SetResult(null); + } + } + } + + private async Task OnDisconnection() + { + using (await _lock.LockAsync()) + { + switch (State) + { + case BluetoothLEDeviceState.Connecting: + case BluetoothLEDeviceState.Discovering: + InternalDisconnect(); + _connectCompletionSource?.SetResult(null); + break; + + case BluetoothLEDeviceState.Connected: + + var onDeviceDisconnected = _onDeviceDisconnected; + InternalDisconnect(); + onDeviceDisconnected?.Invoke(this); + break; + + default: + break; + } + } + } + + private async Task DiscoverServices(BluetoothCacheMode cacheMode) + { + // expectation is the method is already called within lock + if (_bluetoothDevice != null && State == BluetoothLEDeviceState.Discovering) + { + var services = new List(); + + var availabelServices = await _bluetoothDevice.GetGattServicesAsync(cacheMode); + + if (availabelServices.Status == GattCommunicationStatus.Success) + { + foreach (var service in availabelServices.Services) + { + var openStatus = await service.OpenAsync(GattSharingMode.SharedReadAndWrite); + + if (openStatus != GattOpenStatus.Success) + { + //TODO log + continue; + } + + var availableCharacteristics = await service.GetCharacteristicsAsync(cacheMode); + + if (availableCharacteristics.Status == GattCommunicationStatus.Success) + { + var characteristics = availableCharacteristics.Characteristics + .Select(ch => new BleGattCharacteristic(ch)) + .ToList(); + + services.Add(new BleGattService(service, characteristics)); + } + } + State = BluetoothLEDeviceState.Connected; + _connectCompletionSource?.SetResult(services); + return true; + } + } + InternalDisconnect(); + _connectCompletionSource?.SetResult(null); + return false; + } +} \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/BluetoothLE/BleGattCharacteristic.cs b/BrickController2/BrickController2.WinUI/PlatformServices/BluetoothLE/BleGattCharacteristic.cs new file mode 100644 index 00000000..3d345cdd --- /dev/null +++ b/BrickController2/BrickController2.WinUI/PlatformServices/BluetoothLE/BleGattCharacteristic.cs @@ -0,0 +1,121 @@ +using BrickController2.PlatformServices.BluetoothLE; +using BrickController2.Windows.Extensions; +using System; +using System.Threading.Tasks; +using Windows.Devices.Bluetooth; +using Windows.Devices.Bluetooth.GenericAttributeProfile; + +namespace BrickController2.Windows.PlatformServices.BluetoothLE; + +internal class BleGattCharacteristic : IGattCharacteristic +{ + private readonly GattCharacteristic _gattCharacteristic; + + private bool isNotifySet; + private Action _valueChangedCallback; + + public BleGattCharacteristic(GattCharacteristic bluetoothGattCharacteristic) + { + _gattCharacteristic = bluetoothGattCharacteristic; + Uuid = bluetoothGattCharacteristic.Uuid; + } + + public Guid Uuid { get; } + + public bool CanNotify => _gattCharacteristic != null && + _gattCharacteristic.CharacteristicProperties.HasFlag(GattCharacteristicProperties.Notify); + + public async Task WriteNoResponseAsync(byte[] data) + { + var buffer = data.ToBuffer(); + + return await _gattCharacteristic + .WriteValueAsync(buffer, GattWriteOption.WriteWithoutResponse) + .AsTask(); + } + + public async Task WriteWithResponseAsync(byte[] data) + { + var buffer = data.ToBuffer(); + + return await _gattCharacteristic + .WriteValueWithResultAsync(buffer, GattWriteOption.WriteWithResponse) + .AsTask(); + } + + public async Task ReadValueAsync() + { + return await _gattCharacteristic + .ReadValueAsync(BluetoothCacheMode.Uncached); + } + + internal async Task EnableNotificationAsync(Action callback) + { + // setup callback before writing client char. so as no event is skipped + _gattCharacteristic.ValueChanged += _gattCharacteristic_ValueChanged; + _valueChangedCallback = callback; + + var result = await ApplyClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue.Notify, isNotifySet) + .ConfigureAwait(false); + + isNotifySet = result; + return result; + } + + internal async Task DisableNotificationAsync() + { + _valueChangedCallback = null; + _gattCharacteristic.ValueChanged -= _gattCharacteristic_ValueChanged; + + var result = await ApplyClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue.None, isNotifySet); + + isNotifySet = result; + return result; + } + + private void _gattCharacteristic_ValueChanged(GattCharacteristic sender, GattValueChangedEventArgs args) + { + if (_valueChangedCallback != null) + { + var eventData = args.CharacteristicValue.ToByteArray(); + + _valueChangedCallback.Invoke(Uuid, eventData); + } + } + + /// + /// Sets the notify / indicate / characteristic + /// + /// If application was successfull (or has been already applied) + private async Task ApplyClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue value, bool currentFlagValue) + { + bool targetFlagValue = value == GattClientCharacteristicConfigurationDescriptorValue.None ? false : true; + + if (currentFlagValue == targetFlagValue) + { + // already applied + return true; + } + + try + { + // write ClientCharacteristicConfigurationDescriptor in order to get notifications + // it's recieved in ValueChanged event handler than + var result = await _gattCharacteristic.WriteClientCharacteristicConfigurationDescriptorWithResultAsync(value); + if (result.Status == GattCommunicationStatus.Success) + { + return true; + } + } + catch (UnauthorizedAccessException) + { + //TODO report + } + catch (Exception) + { + //TODO report + } + + return false; + } +} \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/BluetoothLE/BleGattService.cs b/BrickController2/BrickController2.WinUI/PlatformServices/BluetoothLE/BleGattService.cs new file mode 100644 index 00000000..9d9920dd --- /dev/null +++ b/BrickController2/BrickController2.WinUI/PlatformServices/BluetoothLE/BleGattService.cs @@ -0,0 +1,36 @@ +using BrickController2.PlatformServices.BluetoothLE; +using System; +using System.Collections.Generic; +using Windows.Devices.Bluetooth.GenericAttributeProfile; + +namespace BrickController2.Windows.PlatformServices.BluetoothLE; + +internal class BleGattService : IGattService, IDisposable +{ + public BleGattService(GattDeviceService bluetoothGattService, IEnumerable characteristics) + { + BluetoothGattService = bluetoothGattService; + Characteristics = characteristics; + } + + public GattDeviceService BluetoothGattService { get; } + public Guid Uuid => BluetoothGattService.Uuid; + public IEnumerable Characteristics { get; } + + private bool disposed; + + public void Dispose() + { + try + { + if (!disposed) + { + disposed = true; + BluetoothGattService.Dispose(); + } + } + catch (ObjectDisposedException) + { + } + } +} \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/BluetoothLE/BleScanner.cs b/BrickController2/BrickController2.WinUI/PlatformServices/BluetoothLE/BleScanner.cs new file mode 100644 index 00000000..cf2b09d8 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/PlatformServices/BluetoothLE/BleScanner.cs @@ -0,0 +1,98 @@ +using BrickController2.PlatformServices.BluetoothLE; +using BrickController2.Windows.Extensions; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Windows.Devices.Bluetooth.Advertisement; + +namespace BrickController2.Windows.PlatformServices.BluetoothLE; + +public class BleScanner +{ + private readonly Action _scanCallback; + private readonly ConcurrentDictionary _deviceNameCache; + + private readonly BluetoothLEAdvertisementWatcher _passiveWatcher; + private readonly BluetoothLEAdvertisementWatcher _activeWatcher; + + private static readonly IReadOnlySet AdvertismentDataTypes = new HashSet(new[] + { + BluetoothLEAdvertisementDataTypes.ManufacturerSpecificData, + BluetoothLEAdvertisementDataTypes.IncompleteService128BitUuids, + BluetoothLEAdvertisementDataTypes.CompleteLocalName + }); + + public BleScanner(Action scanCallback) + { + _scanCallback = scanCallback; + _deviceNameCache = new ConcurrentDictionary(); + + // use passive advertisment for name resolution + _passiveWatcher = new BluetoothLEAdvertisementWatcher { ScanningMode = BluetoothLEScanningMode.Passive }; + + _passiveWatcher.Received += _passiveWatcher_Received; + _passiveWatcher.Stopped += _passiveWatcher_Stopped; + + // use active scanner as ScanResult advertisment processor + // because SBrick contains large manufacture data which may not come in single packet with device name + _activeWatcher = new BluetoothLEAdvertisementWatcher { ScanningMode = BluetoothLEScanningMode.Active }; + _activeWatcher.Received += _activeWatcher_Received; + } + + public void Start() + { + _passiveWatcher.Start(); + _activeWatcher.Start(); + } + + private void _passiveWatcher_Received(BluetoothLEAdvertisementWatcher sender, BluetoothLEAdvertisementReceivedEventArgs args) + { + // simply update device name cache - if valid + var deviceName = args.GetLocalName(); + if (deviceName.IsValidDeviceName()) + { + _deviceNameCache.AddOrUpdate(args.BluetoothAddress, deviceName, (key, oldValue) => deviceName); + } + } + + private void _passiveWatcher_Stopped(BluetoothLEAdvertisementWatcher sender, BluetoothLEAdvertisementWatcherStoppedEventArgs args) + { + _deviceNameCache.Clear(); + } + + private void _activeWatcher_Received(BluetoothLEAdvertisementWatcher sender, BluetoothLEAdvertisementReceivedEventArgs args) + { + if (!args.CanCarryData()) + { + return; + } + // prefer local name if set, otherwise use cache (where only valid names can be) + string deviceName = args.GetLocalName(); + if (!deviceName.IsValidDeviceName() && !_deviceNameCache.TryGetValue(args.BluetoothAddress, out deviceName)) + { + return; + } + + var bluetoothAddress = args.BluetoothAddress.ToBluetoothAddressString(); + + var advertismentData = args.Advertisement.DataSections + .Where(s => AdvertismentDataTypes.Contains(s.DataType)) + .ToDictionary(s => s.DataType, s => s.Data.ToByteArray()); + + // enrich data with name manually (SBrick do not like CompleteLocalName, but Buwizz3 requires it) + if (!advertismentData.ContainsKey(BluetoothLEAdvertisementDataTypes.CompleteLocalName)) + { + advertismentData[BluetoothLEAdvertisementDataTypes.CompleteLocalName] = Encoding.ASCII.GetBytes(deviceName); + } + + _scanCallback(new ScanResult(deviceName, bluetoothAddress, advertismentData)); + } + + public void Stop() + { + _passiveWatcher.Stop(); + _activeWatcher.Stop(); + } +} \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/BluetoothLE/BleService.cs b/BrickController2/BrickController2.WinUI/PlatformServices/BluetoothLE/BleService.cs new file mode 100644 index 00000000..240b7f7e --- /dev/null +++ b/BrickController2/BrickController2.WinUI/PlatformServices/BluetoothLE/BleService.cs @@ -0,0 +1,103 @@ +using BrickController2.PlatformServices.BluetoothLE; +using System; +using System.Threading; +using System.Threading.Tasks; +using Windows.Devices.Bluetooth; + +namespace BrickController2.Windows.PlatformServices.BluetoothLE; + +public class BleService : IBluetoothLEService +{ + [Flags] + private enum BluetoothStatus + { + None = 0x00, + ClassicSupported = 0x01, + LowEnergySupported = 0x02, + + AllFeatures = ClassicSupported | LowEnergySupported + } + + private bool _isScanning; + + public BleService() + { + } + + public bool IsBluetoothLESupported => CurrentBluetoothStatus.HasFlag(BluetoothStatus.LowEnergySupported); + public bool IsBluetoothOn => CurrentBluetoothStatus.HasFlag(BluetoothStatus.ClassicSupported); + + private BluetoothStatus CurrentBluetoothStatus + { + get + { + // synchroniously wait + var adapterTask = GetBluetoothAdapter(); + adapterTask.Wait(); + + BluetoothStatus status = (adapterTask.Result?.IsClassicSupported ?? false) ? BluetoothStatus.ClassicSupported : BluetoothStatus.None; + status |= (adapterTask.Result?.IsLowEnergySupported ?? false) ? BluetoothStatus.LowEnergySupported : BluetoothStatus.None; + + return status; + } + } + + private static async Task GetBluetoothAdapter() => await BluetoothAdapter.GetDefaultAsync() + .AsTask() + .ConfigureAwait(false); + + public async Task ScanDevicesAsync(Action scanCallback, CancellationToken token) + { + if (_isScanning || CurrentBluetoothStatus != BluetoothStatus.AllFeatures) + { + return false; + } + + try + { + _isScanning = true; + return await NewScanAsync(scanCallback, token); + } + catch (Exception) + { + return false; + } + finally + { + _isScanning = false; + } + } + + public IBluetoothLEDevice GetKnownDevice(string address) + { + if (!IsBluetoothLESupported) + { + return null; + } + + return new BleDevice(address); + } + + private async Task NewScanAsync(Action scanCallback, CancellationToken token) + { + try + { + var leScanner = new BleScanner(scanCallback); + + leScanner.Start(); + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + token.Register(() => + { + leScanner.Stop(); + tcs.SetResult(true); + }); + + return await tcs.Task; + } + catch (Exception) + { + return false; + } + } +} \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/DI/PlatformServicesModule.cs b/BrickController2/BrickController2.WinUI/PlatformServices/DI/PlatformServicesModule.cs new file mode 100644 index 00000000..999292ee --- /dev/null +++ b/BrickController2/BrickController2.WinUI/PlatformServices/DI/PlatformServicesModule.cs @@ -0,0 +1,32 @@ +using BrickController2.Windows.PlatformServices.BluetoothLE; +using BrickController2.Windows.PlatformServices.Infrared; +using BrickController2.Windows.PlatformServices.Versioning; +using BrickController2.Windows.PlatformServices.Localization; +using BrickController2.Windows.PlatformServices.GameController; +using BrickController2.Windows.PlatformServices.SharedFileStorage; +using BrickController2.Windows.PlatformServices.Permission; +using Autofac; +using BrickController2.PlatformServices.Infrared; +using BrickController2.PlatformServices.GameController; +using BrickController2.PlatformServices.Versioning; +using BrickController2.PlatformServices.BluetoothLE; +using BrickController2.PlatformServices.Localization; +using BrickController2.PlatformServices.SharedFileStorage; +using BrickController2.PlatformServices.Permission; + +namespace BrickController2.Windows.PlatformServices.DI; + +public class PlatformServicesModule : Module +{ + protected override void Load(ContainerBuilder builder) + { + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().AsSelf().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().InstancePerDependency(); + builder.RegisterType().As().InstancePerDependency(); + } +} \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GameControllerService.cs b/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GameControllerService.cs new file mode 100644 index 00000000..bade5a43 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GameControllerService.cs @@ -0,0 +1,129 @@ +using BrickController2.PlatformServices.GameController; +using BrickController2.UI.Services.MainThread; +using BrickController2.Windows.Extensions; +using Microsoft.Maui.Dispatching; +using System; +using System.Collections.Generic; +using System.Linq; +using Windows.Gaming.Input; + +namespace BrickController2.Windows.PlatformServices.GameController; + +public class GameControllerService : IGameControllerService +{ + + private readonly Dictionary _availableControllers = new(); + private readonly object _lockObject = new(); + private readonly IMainThreadService _mainThreadService; + private readonly IDispatcherProvider _dispatcherProvider; + + private event EventHandler GameControllerEventInternal; + + public GameControllerService(IMainThreadService mainThreadService, IDispatcherProvider dispatcherProvider) + { + _mainThreadService = mainThreadService; + _dispatcherProvider = dispatcherProvider; + } + + public event EventHandler GameControllerEvent + { + add + { + lock (_lockObject) + { + if (GameControllerEventInternal == null) + { + InitializeControllers(); + } + + GameControllerEventInternal += value; + } + } + + remove + { + lock (_lockObject) + { + GameControllerEventInternal -= value; + + if (GameControllerEventInternal == null) + { + TerminateControllers(); + } + } + } + } + + internal void RaiseEvent(IDictionary<(GameControllerEventType, string), float> events) + { + if (!events.Any()) + { + return; + } + + GameControllerEventInternal?.Invoke(this, new GameControllerEventArgs(events)); + } + + private void InitializeControllers() + { + // get all available gamepads + if (Gamepad.Gamepads.Any()) + { + AddDevices(Gamepad.Gamepads); + } + + Gamepad.GamepadRemoved += Gamepad_GamepadRemoved; + Gamepad.GamepadAdded += Gamepad_GamepadAdded; + } + + private void TerminateControllers() + { + Gamepad.GamepadRemoved -= Gamepad_GamepadRemoved; + Gamepad.GamepadAdded -= Gamepad_GamepadAdded; + + foreach (var controller in _availableControllers.Values) + { + controller.Stop(); + } + _availableControllers.Clear(); + } + + private void Gamepad_GamepadRemoved(object sender, Gamepad e) + { + lock (_lockObject) + { + var deviceId = e.GetDeviceId(); + + if (_availableControllers.TryGetValue(deviceId, out var controller)) + { + _availableControllers.Remove(deviceId); + + // ensure stopped in UI thread + _ = _mainThreadService.RunOnMainThread(() => controller.Stop()); + } + } + } + + private void Gamepad_GamepadAdded(object sender, Gamepad e) + { + // ensure created in UI thread + _ = _mainThreadService.RunOnMainThread(() => AddDevices(new[] { e })); + } + + private void AddDevices(IEnumerable gamepads) + { + lock (_lockObject) + { + var dispatcher = _dispatcherProvider.GetForCurrentThread(); + foreach (var gamepad in gamepads) + { + var deviceId = gamepad.GetDeviceId(); + + var newController = new GamepadController(this, gamepad, dispatcher.CreateTimer()); + _availableControllers[deviceId] = newController; + + newController.Start(); + } + } + } +} \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GamepadController.cs b/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GamepadController.cs new file mode 100644 index 00000000..a3f0eb06 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GamepadController.cs @@ -0,0 +1,83 @@ +using BrickController2.PlatformServices.GameController; +using BrickController2.Windows.Extensions; +using Microsoft.Maui.Dispatching; +using System; +using System.Collections.Generic; +using System.Linq; +using Windows.Gaming.Input; + +namespace BrickController2.Windows.PlatformServices.GameController; + +internal class GamepadController +{ + private static readonly TimeSpan DefaultInterval = TimeSpan.FromMilliseconds(10); + + private readonly GameControllerService _controllerService; + private readonly Gamepad _gamepad; + private readonly IDispatcherTimer _timer; + + private readonly Dictionary _lastReadingValues = new(); + + public GamepadController(GameControllerService service, Gamepad gamepad, IDispatcherTimer timer) + : this(service, gamepad, timer, DefaultInterval) + { + } + + private GamepadController(GameControllerService service, Gamepad gamepad, IDispatcherTimer timer, TimeSpan timerInterval) + { + _controllerService = service; + _gamepad = gamepad; + _timer = timer; + + _timer.Interval = timerInterval; + _timer.Tick += Timer_Tick; + } + + public string DeviceId => _gamepad.GetDeviceId(); + + public void Start() + { + _lastReadingValues.Clear(); + + // finally start timer + _timer.Start(); + } + + public void Stop() + { + _timer.Stop(); + + _lastReadingValues.Clear(); + } + + private void Timer_Tick(object sender, object e) + { + var currentReading = _gamepad.GetCurrentReading(); + + var currentEvents = currentReading + .Enumerate() + .Where(HasChanged) + .ToDictionary(x => (x.EventType, x.Name), x => x.Value); + + _controllerService.RaiseEvent(currentEvents); + } + + private static bool AreAlmostEqual(float a, float b) + { + return Math.Abs(a - b) < 0.001; + } + + private bool HasChanged((string AxisName, GameControllerEventType EventType, float Value) readingValue) + { + // get last reported value of the default one + _lastReadingValues.TryGetValue(readingValue.AxisName, out float lastValue); + // skip value if there is no change + if (AreAlmostEqual(readingValue.Value, lastValue)) + { + return false; + } + + _lastReadingValues[readingValue.AxisName] = readingValue.Value; + return true; + } +} diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/Infrared/InfraredService.cs b/BrickController2/BrickController2.WinUI/PlatformServices/Infrared/InfraredService.cs new file mode 100644 index 00000000..e6d9d242 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/PlatformServices/Infrared/InfraredService.cs @@ -0,0 +1,14 @@ +using BrickController2.PlatformServices.Infrared; +using System; +using System.Threading.Tasks; + +namespace BrickController2.Windows.PlatformServices.Infrared; + +public class InfraredService : IInfraredService +{ + public bool IsInfraredSupported => false; + + public bool IsCarrierFrequencySupported(int carrierFrequency) => throw new NotImplementedException(); + + public Task SendPacketAsync(int carrierFrequency, int[] packet) => throw new NotImplementedException(); +} \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/Localization/LocalizationService.cs b/BrickController2/BrickController2.WinUI/PlatformServices/Localization/LocalizationService.cs new file mode 100644 index 00000000..54f3e568 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/PlatformServices/Localization/LocalizationService.cs @@ -0,0 +1,25 @@ +using BrickController2.PlatformServices.Localization; +using Microsoft.Maui.Controls; +using System.Globalization; +using System.Threading; + +[assembly: Dependency(typeof(BrickController2.Windows.PlatformServices.Localization.LocalizationService))] +namespace BrickController2.Windows.PlatformServices.Localization; + +public class LocalizationService : ILocalizationService +{ + public CultureInfo CurrentCultureInfo + { + get + { + return CultureInfo.CurrentUICulture; + } + + set + { + CultureInfo.CurrentUICulture = value; + Thread.CurrentThread.CurrentCulture = value; + Thread.CurrentThread.CurrentUICulture = value; + } + } +} \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/Permission/BluetoothPermission.cs b/BrickController2/BrickController2.WinUI/PlatformServices/Permission/BluetoothPermission.cs new file mode 100644 index 00000000..6fb526f0 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/PlatformServices/Permission/BluetoothPermission.cs @@ -0,0 +1,11 @@ +using BrickController2.PlatformServices.Permission; +using System; +using System.Collections.Generic; +using static Microsoft.Maui.ApplicationModel.Permissions; + +namespace BrickController2.Windows.PlatformServices.Permission; + +public class BluetoothPermission : BasePlatformPermission, IBluetoothPermission +{ + protected override Func> RequiredDeclarations => () => ["bluetooth"]; +} \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/Permission/ReadWriteExternalStoragePermission.cs b/BrickController2/BrickController2.WinUI/PlatformServices/Permission/ReadWriteExternalStoragePermission.cs new file mode 100644 index 00000000..cc1b8768 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/PlatformServices/Permission/ReadWriteExternalStoragePermission.cs @@ -0,0 +1,11 @@ +using BrickController2.PlatformServices.Permission; +using System; +using System.Collections.Generic; +using static Microsoft.Maui.ApplicationModel.Permissions; + +namespace BrickController2.Windows.PlatformServices.Permission; + +public class ReadWriteExternalStoragePermission : BasePlatformPermission, IReadWriteExternalStoragePermission +{ + protected override Func> RequiredDeclarations => () => ["removableStorage"]; +} \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/SharedFileStorage/SharedFileStorageService.cs b/BrickController2/BrickController2.WinUI/PlatformServices/SharedFileStorage/SharedFileStorageService.cs new file mode 100644 index 00000000..e26c7a97 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/PlatformServices/SharedFileStorage/SharedFileStorageService.cs @@ -0,0 +1,16 @@ +using BrickController2.PlatformServices.SharedFileStorage; +using Windows.Storage; + +namespace BrickController2.Windows.PlatformServices.SharedFileStorage; + +public class SharedFileStorageService : ISharedFileStorageService +{ + public bool IsSharedStorageAvailable => true; + + public bool IsPermissionGranted { get; set; } + + public string SharedStorageBaseDirectory => ApplicationData.Current.RoamingFolder.Path; + + public string SharedStorageDirectory => SharedStorageBaseDirectory; + +} diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/Versioning/VersionService.cs b/BrickController2/BrickController2.WinUI/PlatformServices/Versioning/VersionService.cs new file mode 100644 index 00000000..b1ea8cab --- /dev/null +++ b/BrickController2/BrickController2.WinUI/PlatformServices/Versioning/VersionService.cs @@ -0,0 +1,23 @@ +using BrickController2.PlatformServices.Versioning; +using Windows.ApplicationModel; + +namespace BrickController2.Windows.PlatformServices.Versioning; + +public class VersionService : IVersionService +{ + public string ApplicationVersion + { + get + { + try + { + var info = Package.Current.Id.Version; + return $"{info.Major}.{info.Minor}.{info.Revision}"; + } + catch + { + return "Unknown version"; + } + } + } +} \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/Properties/AssemblyInfo.cs b/BrickController2/BrickController2.WinUI/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..048d4175 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/Properties/AssemblyInfo.cs @@ -0,0 +1,22 @@ +using System.Reflection; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("BrickController2")] +[assembly: AssemblyDescription("Cross platform mobile application for controlling Lego creations using a bluetooth gamepad.")] +[assembly: AssemblyProduct("BrickController2")] +[assembly: AssemblyCopyright("Copyright © 2024")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +[assembly: AssemblyVersion("1.0.0.0")] + +[assembly: System.Runtime.Versioning.SupportedOSPlatform("Windows10.0.17763.0")] \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/Properties/PublishProfiles/win10-arm64.pubxml b/BrickController2/BrickController2.WinUI/Properties/PublishProfiles/win10-arm64.pubxml new file mode 100644 index 00000000..a7fdd16b --- /dev/null +++ b/BrickController2/BrickController2.WinUI/Properties/PublishProfiles/win10-arm64.pubxml @@ -0,0 +1,20 @@ + + + + + FileSystem + ARM64 + win10-arm64 + bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ + true + False + False + True + + + \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/Properties/PublishProfiles/win10-x64.pubxml b/BrickController2/BrickController2.WinUI/Properties/PublishProfiles/win10-x64.pubxml new file mode 100644 index 00000000..26ea7e55 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/Properties/PublishProfiles/win10-x64.pubxml @@ -0,0 +1,20 @@ + + + + + FileSystem + x64 + win10-x64 + bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ + true + False + False + True + + + \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/Properties/launchSettings.json b/BrickController2/BrickController2.WinUI/Properties/launchSettings.json new file mode 100644 index 00000000..af97d49d --- /dev/null +++ b/BrickController2/BrickController2.WinUI/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Windows Machine": { + "commandName": "MsixPackage", + "nativeDebugging": true + } + } +} \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/UI/CustomHandlers/CustomPageHandler.cs b/BrickController2/BrickController2.WinUI/UI/CustomHandlers/CustomPageHandler.cs new file mode 100644 index 00000000..c8988651 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/UI/CustomHandlers/CustomPageHandler.cs @@ -0,0 +1,43 @@ +using BrickController2.UI.Pages; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Handlers; +using Microsoft.Maui.Platform; +using System; + +namespace BrickController2.Windows.UI.CustomHandlers; + +/// +/// Applies TitleView adjustments to resolve issue: TitleView content does not expand across the entire width +/// https://github.com/dotnet/maui/issues/10703 +/// +internal class CustomPageHandler : PageHandler +{ + protected override void ConnectHandler(ContentPanel platformView) + { + base.ConnectHandler(platformView); + + if (VirtualView is PageBase page) + { + var window = page.Window; + + window.SizeChanged += Window_SizeChanged; + page.Unloaded += (sender, args) => window.SizeChanged -= Window_SizeChanged; + + ApplyTitleViewWidth(page); + } + } + + private void Window_SizeChanged(object sender, EventArgs e) + { + ApplyTitleViewWidth(VirtualView as PageBase); + } + + private static void ApplyTitleViewWidth(PageBase page) + { + var view = NavigationPage.GetTitleView(page); + if (view != null) + { + view.WidthRequest = page.Window.Width; + } + } +} diff --git a/BrickController2/BrickController2.WinUI/UI/CustomHandlers/CustomSwipeViewHandler.cs b/BrickController2/BrickController2.WinUI/UI/CustomHandlers/CustomSwipeViewHandler.cs new file mode 100644 index 00000000..7c8f01c0 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/UI/CustomHandlers/CustomSwipeViewHandler.cs @@ -0,0 +1,33 @@ +using Microsoft.Maui.Handlers; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using System.Linq; + +namespace BrickController2.Windows.UI.CustomHandlers; + +public class CustomSwipeViewHandler : SwipeViewHandler +{ + protected override void ConnectHandler(SwipeControl platformView) + { + base.ConnectHandler(platformView); + + platformView.RightTapped += SwipeControl_RightTapped; + } + + private void SwipeControl_RightTapped(object sender, RightTappedRoutedEventArgs e) + { + // invoke command of the first left item to suppport deletion (workaround for Windouws without touch controls) + if (VirtualView.LeftItems.Count == 1) + { + var item = VirtualView.LeftItems.First(); + item.OnInvoked(); + } + } + + protected override void DisconnectHandler(SwipeControl platformView) + { + platformView.RightTapped -= SwipeControl_RightTapped; + + base.DisconnectHandler(platformView); + } +} \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/UI/CustomHandlers/ExtendedSliderHandler.cs b/BrickController2/BrickController2.WinUI/UI/CustomHandlers/ExtendedSliderHandler.cs new file mode 100644 index 00000000..6fe84e7d --- /dev/null +++ b/BrickController2/BrickController2.WinUI/UI/CustomHandlers/ExtendedSliderHandler.cs @@ -0,0 +1,49 @@ +using BrickController2.UI.Controls; +using Microsoft.Maui; +using Microsoft.Maui.Handlers; +using Microsoft.UI.Xaml; + +namespace BrickController2.Windows.UI.CustomHandlers; + +public class ExtendedSliderHandler : SliderHandler +{ + public static readonly PropertyMapper PropertyMapper = new(Mapper) + { + [ExtendedSlider.StepProperty.PropertyName] = ApplyStep + }; + + public ExtendedSliderHandler() : base(PropertyMapper) + { + } + private ExtendedSlider Slider => VirtualView as ExtendedSlider; + + protected override void ConnectHandler(Microsoft.UI.Xaml.Controls.Slider platformView) + { + base.ConnectHandler(platformView); + + platformView.Loaded += OnPlatformViewLoaded; + platformView.PointerCaptureLost += OnPlatformView_PointerCaptureLost; + } + + protected override void DisconnectHandler(Microsoft.UI.Xaml.Controls.Slider platformView) + { + platformView.Loaded -= OnPlatformViewLoaded; + platformView.PointerCaptureLost -= OnPlatformView_PointerCaptureLost; + + base.DisconnectHandler(platformView); + } + + private void OnPlatformView_PointerCaptureLost(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e) + => Slider?.TouchUp(); + + void OnPlatformViewLoaded(object sender, RoutedEventArgs e) + { + ApplyStep(this, Slider); + } + + private static void ApplyStep(ExtendedSliderHandler handler, ExtendedSlider slider) + { + handler.PlatformView.StepFrequency = slider.Step; + handler.PlatformView.SmallChange = slider.Step; + } +} \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/app.manifest b/BrickController2/BrickController2.WinUI/app.manifest new file mode 100644 index 00000000..8092fdf5 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/app.manifest @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + PerMonitorV2 + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 43a32688..935521e2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,9 +5,9 @@ - - - + + + diff --git a/README.md b/README.md index b4dd8e0c..100036f7 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Cross platform mobile application for controlling your creations using a bluetoo - Android 5.0+ - iOS 11+ +- Windows 10 (experimental) ## Supported receivers