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