From 68760773cfe3d176c32ecdf0db5231f0900442cc Mon Sep 17 00:00:00 2001 From: J0EK3R Date: Sat, 30 Aug 2025 13:55:18 +0200 Subject: [PATCH 1/5] added ControllerManagement for iOS cleanup code: * removed unused properties of IGameController and GamepadControllerBase * removed generic in GameControllerServiceBase; use IGameController --- .../Extensions/InputDeviceExtensions.cs | 17 -- .../GameController/GameControllerService.cs | 14 +- .../GameController/GamepadController.cs | 4 - .../GameController/GameControllerService.cs | 6 +- .../GameController/GamepadController.cs | 8 +- .../GameController/GameControllerService.cs | 279 +++--------------- .../GameController/GamepadController.cs | 242 +++++++++++++++ .../GameControllerServiceBase.cs | 26 +- .../GameController/GameControllers.cs | 11 - .../GameController/GamepadControllerBase.cs | 16 +- .../GameController/IGameController.cs | 10 - 11 files changed, 322 insertions(+), 311 deletions(-) delete mode 100644 BrickController2/BrickController2.Android/Extensions/InputDeviceExtensions.cs create mode 100644 BrickController2/BrickController2.iOS/PlatformServices/GameController/GamepadController.cs diff --git a/BrickController2/BrickController2.Android/Extensions/InputDeviceExtensions.cs b/BrickController2/BrickController2.Android/Extensions/InputDeviceExtensions.cs deleted file mode 100644 index 1ddd31af..00000000 --- a/BrickController2/BrickController2.Android/Extensions/InputDeviceExtensions.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Android.Views; - -namespace BrickController2.Droid.Extensions; - -public static class InputDeviceExtensions -{ - /// - /// Get an unique persistant identifier string for the given inputdevice - /// - /// inputdevice - /// deviceidentifier - public static string GetUniquePersistentDeviceId(this InputDevice? inputDevice) => - // https://developer.android.com/develop/ui/views/touch-and-input/game-controllers/multiple-controllers - // Note: On devices running Android 4.1(API level 16) and higher, you can obtain an input device’s descriptor using getDescriptor(), which returns a unique persistent - // string value for the input device.Unlike a device ID, the descriptor value won't change even if the input device is disconnected, reconnected, or reconfigured. - inputDevice?.Descriptor ?? "NoDescriptor"; -} diff --git a/BrickController2/BrickController2.Android/PlatformServices/GameController/GameControllerService.cs b/BrickController2/BrickController2.Android/PlatformServices/GameController/GameControllerService.cs index 7e2f2988..f92642c2 100644 --- a/BrickController2/BrickController2.Android/PlatformServices/GameController/GameControllerService.cs +++ b/BrickController2/BrickController2.Android/PlatformServices/GameController/GameControllerService.cs @@ -7,7 +7,7 @@ namespace BrickController2.Droid.PlatformServices.GameController { - internal class GameControllerService : GameControllerServiceBase + internal class GameControllerService : GameControllerServiceBase { private readonly InputManager _inputManager; @@ -36,9 +36,9 @@ internal void MainActivityOnInputDeviceAdded(int deviceId) /// deviceId of InputDevice internal void MainActivityOnInputDeviceRemoved(int deviceId) { - if (TryRemove(x => x.Gamepad.Id == deviceId, out var controller)) + if (TryRemove(x => x.ControllerDevice.Id == deviceId, out var controller)) { - _logger.LogInformation("Gamepad has been removed DeviceId:{id}, ControllerId:{controllerId}", + _logger.LogInformation("ControllerDevice has been removed DeviceId:{id}, ControllerId:{controllerId}", deviceId, controller.ControllerId); } } @@ -64,14 +64,14 @@ internal void MainActivityOnInputDeviceChanged(int deviceId) else if (controller != null) { // handle change - remove and then add it again - TryRemove(x => x.Gamepad.Id == deviceId, out _); + TryRemove(x => x.ControllerDevice.Id == deviceId, out _); } AddGameControllerDevice(device); } } - else if (TryRemove(x => x.Gamepad.Id == deviceId, out var controller)) + else if (TryRemove(x => x.ControllerDevice.Id == deviceId, out var controller)) { - _logger.LogInformation("Gamepad has been removed DeviceId:{id}, ControllerId:{controllerId}", + _logger.LogInformation("ControllerDevice has been removed DeviceId:{id}, ControllerId:{controllerId}", deviceId, controller.ControllerId); } } @@ -122,7 +122,7 @@ private void AddGameControllerDevice(InputDevice gamepad) } private bool TryGetControllerByDeviceId(int deviceId, [MaybeNullWhen(false)] out GamepadController controller) - => TryGetController(x => x.Gamepad.Id == deviceId, out controller); + => TryGetController(x => x.ControllerDevice.Id == deviceId, out controller); private static bool TryGetGamepadDevice(int deviceId, [MaybeNullWhen(false)] out InputDevice device) { diff --git a/BrickController2/BrickController2.Android/PlatformServices/GameController/GamepadController.cs b/BrickController2/BrickController2.Android/PlatformServices/GameController/GamepadController.cs index 2e0a3e19..01240f4d 100644 --- a/BrickController2/BrickController2.Android/PlatformServices/GameController/GamepadController.cs +++ b/BrickController2/BrickController2.Android/PlatformServices/GameController/GamepadController.cs @@ -1,5 +1,4 @@ using Android.Views; -using BrickController2.Droid.Extensions; using BrickController2.PlatformServices.GameController; using System; using System.Collections.Generic; @@ -25,11 +24,8 @@ public GamepadController(GameControllerService service, InputDevice gamePad) { // initialize properties Name = GetDisplayName(gamePad); - VendorId = gamePad.VendorId; - ProductId = gamePad.ProductId; ControllerNumber = gamePad.ControllerNumber; ControllerId = GetControllerIdFromNumber(gamePad.ControllerNumber); - UniquePersistantDeviceId = gamePad.GetUniquePersistentDeviceId(); } internal bool OnButtonEvent(KeyEvent e, float buttonValue) diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GameControllerService.cs b/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GameControllerService.cs index 296a37ec..9abe20bf 100644 --- a/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GameControllerService.cs +++ b/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GameControllerService.cs @@ -8,7 +8,7 @@ namespace BrickController2.Windows.PlatformServices.GameController; -internal class GameControllerService : GameControllerServiceBase, IGameControllerService +internal class GameControllerService : GameControllerServiceBase, IGameControllerService { private readonly IMainThreadService _mainThreadService; private readonly IDispatcherProvider _dispatcherProvider; @@ -53,9 +53,9 @@ private void Gamepad_GamepadRemoved(object? sender, Gamepad gamepad) // ensure stopped in UI thread _ = _mainThreadService.RunOnMainThread(() => { - if (TryRemove(x => x.Gamepad == gamepad, out var controller)) + if (TryRemove(x => x.ControllerDevice == gamepad, out var controller)) { - _logger.LogInformation("Gamepad has been removed ControllerId:{controllerId}", controller.ControllerId); + _logger.LogInformation("ControllerDevice has been removed ControllerId:{controllerId}", controller.ControllerId); } }); } diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GamepadController.cs b/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GamepadController.cs index ef203590..4972f8ba 100644 --- a/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GamepadController.cs +++ b/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GamepadController.cs @@ -24,14 +24,10 @@ internal class GamepadController : GamepadControllerBase public GamepadController(GameControllerService service, Gamepad gamepad, RawGameController rawController, int controllerNumber, IDispatcherTimer timer) : base(service, gamepad) { + Name = rawController.DisplayName; ControllerNumber = controllerNumber; ControllerId = GetControllerIdFromNumber(controllerNumber); - UniquePersistantDeviceId = rawController.NonRoamableId; - Name = rawController.DisplayName; - VendorId = rawController.HardwareVendorId; - ProductId = rawController.HardwareProductId; - _timer = timer; _timer.Interval = DefaultInterval; @@ -55,7 +51,7 @@ public override void Stop() private void Timer_Tick(object? sender, object e) { - var currentReading = Gamepad.GetCurrentReading(); + var currentReading = ControllerDevice.GetCurrentReading(); var currentEvents = currentReading .Enumerate() diff --git a/BrickController2/BrickController2.iOS/PlatformServices/GameController/GameControllerService.cs b/BrickController2/BrickController2.iOS/PlatformServices/GameController/GameControllerService.cs index e138df48..21cb24cc 100644 --- a/BrickController2/BrickController2.iOS/PlatformServices/GameController/GameControllerService.cs +++ b/BrickController2/BrickController2.iOS/PlatformServices/GameController/GameControllerService.cs @@ -1,285 +1,94 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using BrickController2.PlatformServices.GameController; using Foundation; using GameController; - -using static BrickController2.PlatformServices.GameController.GameControllers; +using Microsoft.Extensions.Logging; namespace BrickController2.iOS.PlatformServices.GameController { - public class GameControllerService : IGameControllerService + internal class GameControllerService : GameControllerServiceBase { - private enum GameControllerType - { - Unknown, - Micro, - Standard, - Extended - }; - - private readonly object _lockObject = new object(); - - private readonly IDictionary _lastControllerEventValueMap = new Dictionary(); - - private GCController? _gameController; - private event EventHandler? GameControllerEventInternal; private NSObject? _didConnectNotification; private NSObject? _didDisconnectNotification; - public event EventHandler GameControllerEvent - { - add - { - lock (_lockObject) - { - if (GameControllerEventInternal is null) - { - if (GCController.Controllers.Length == 0) - { - FindController(); - } - else - { - FoundController(); - } - } - - GameControllerEventInternal += value; - } - } - - remove - { - lock (_lockObject) - { - GameControllerEventInternal -= value; - - if (GameControllerEventInternal is null) - { - GCController.StopWirelessControllerDiscovery(); - _didConnectNotification?.Dispose(); - _didDisconnectNotification?.Dispose(); - _didConnectNotification = null; - _didDisconnectNotification = null; - _gameController?.Dispose(); - _gameController = null; - } - } - } - } - - public event EventHandler? GameControllersChangedEvent; - - public bool IsControllerIdSupported => false; // ToDo: implement ControllerManagement - - private void FindController() + public GameControllerService(ILogger logger) + : base(logger) { - lock (_lockObject) - { - _didConnectNotification = GCController.Notifications.ObserveDidConnect((sender, args) => - { - FoundController(); - }); - - GCController.StartWirelessControllerDiscovery(() => { }); - } } - private void FoundController() - { - lock (_lockObject) - { - _gameController = GCController.Controllers.FirstOrDefault(); - - if (_gameController is not null) - { - GCController.StopWirelessControllerDiscovery(); - _didConnectNotification?.Dispose(); - _didConnectNotification = null; - - _didDisconnectNotification = GCController.Notifications.ObserveDidDisconnect((sender, args) => - { - FindController(); - }); - - switch (GetGameControllerType(_gameController)) - { - case GameControllerType.Micro: - SetupMicroGamePad(_gameController.MicroGamepad!); - break; - - case GameControllerType.Standard: -#pragma warning disable CA1422 // Validate platform compatibility - SetupGamePad(_gameController.Gamepad!); -#pragma warning restore CA1422 // Validate platform compatibility - break; - - case GameControllerType.Extended: - SetupExtendedGamePad(_gameController.ExtendedGamepad!); - break; - } - } - } - } + public override bool IsControllerIdSupported => true; - private GameControllerType GetGameControllerType(GCController controller) + protected override void InitializeCurrentControllers() { - try + // get all available gamepads + if (GCController.Controllers.Any()) { - if (controller.MicroGamepad is not null) - { - return GameControllerType.Micro; - } + AddDevices(GCController.Controllers); } - catch (InvalidCastException) { } - try + // register GCController events + _didDisconnectNotification = GCController.Notifications.ObserveDidDisconnect((sender, args) => { -#pragma warning disable CA1422 // Validate platform compatibility - if (controller.Gamepad is not null) + var controller = args.Notification.Object as GCController; + if (controller != null) { - return GameControllerType.Standard; + ControllerRemoved(controller); } -#pragma warning restore CA1422 // Validate platform compatibility - } - catch (InvalidCastException) { } - - try + }); + _didConnectNotification = GCController.Notifications.ObserveDidConnect((sender, args) => { - if (controller.ExtendedGamepad is not null) + var controller = args.Notification.Object as GCController; + if (controller != null) { - return GameControllerType.Extended; + ControllerAdded(controller); } - } - catch (InvalidCastException) { } - - return GameControllerType.Unknown; - } - - private void SetupMicroGamePad(GCMicroGamepad gamePad) - { - SetupDigitalButtonInput(gamePad.ButtonA, "Button_A"); - SetupDigitalButtonInput(gamePad.ButtonX, "Button_X"); - - SetupDPadInput(gamePad.Dpad, "DPad"); - } - - private void SetupGamePad(GCGamepad gamePad) - { -#pragma warning disable CA1422 // Validate platform compatibility - SetupDigitalButtonInput(gamePad.ButtonA, "Button_A"); - SetupDigitalButtonInput(gamePad.ButtonB, "Button_B"); - SetupDigitalButtonInput(gamePad.ButtonX, "Button_X"); - SetupDigitalButtonInput(gamePad.ButtonY, "Button_Y"); - - SetupDigitalButtonInput(gamePad.LeftShoulder, "LeftShoulder"); - SetupDigitalButtonInput(gamePad.RightShoulder, "RightShoulder"); - - SetupDPadInput(gamePad.DPad, "DPad"); -#pragma warning restore CA1422 // Validate platform compatibility - } - - private void SetupExtendedGamePad(GCExtendedGamepad gamePad) - { - SetupDigitalButtonInput(gamePad.ButtonA, "Button_A"); - SetupDigitalButtonInput(gamePad.ButtonB, "Button_B"); - SetupDigitalButtonInput(gamePad.ButtonX, "Button_X"); - SetupDigitalButtonInput(gamePad.ButtonY, "Button_Y"); - - SetupDigitalButtonInput(gamePad.LeftShoulder, "LeftShoulder"); - SetupDigitalButtonInput(gamePad.RightShoulder, "RightShoulder"); - - SetupAnalogButtonInput(gamePad.LeftTrigger, "LeftTrigger"); - SetupAnalogButtonInput(gamePad.RightTrigger, "RightTrigger"); + }); - SetupDPadInput(gamePad.DPad, "DPad"); - - SetupJoyInput(gamePad.LeftThumbstick, "LeftThumbStick"); - SetupJoyInput(gamePad.RightThumbstick, "RightThumbStick"); + GCController.StartWirelessControllerDiscovery(() => { }); } - private void SetupDigitalButtonInput(GCControllerButtonInput button, string name) - { - button.ValueChangedHandler = (btn, value, isPressed) => - { - value = isPressed ? 1.0F : 0.0F; - - if (!_lastControllerEventValueMap.ContainsKey(name) || !AreAlmostEqual(_lastControllerEventValueMap[name], value)) - { - // ToDo: find ControllerId - string controllerId = GetControllerIdFromIndex(0); - - _lastControllerEventValueMap[name] = value; - GameControllerEventInternal?.Invoke(this, new GameControllerEventArgs(controllerId, GameControllerEventType.Button, name, value)); - } - }; - } - private void SetupAnalogButtonInput(GCControllerButtonInput button, string name) + protected override void RemoveAllControllers() { - button.ValueChangedHandler = (btn, value, isPressed) => - { - value = value < 0.1 ? 0.0F : value; - - if (!_lastControllerEventValueMap.ContainsKey(name) || !AreAlmostEqual(_lastControllerEventValueMap[name], value)) - { - // ToDo: find ControllerId - string controllerId = GetControllerIdFromIndex(0); - - _lastControllerEventValueMap[name] = value; - GameControllerEventInternal?.Invoke(this, new GameControllerEventArgs(controllerId, GameControllerEventType.Axis, name, value)); - } - }; - } + GCController.StopWirelessControllerDiscovery(); + _didConnectNotification?.Dispose(); + _didDisconnectNotification?.Dispose(); + _didConnectNotification = null; + _didDisconnectNotification = null; - private void SetupDPadInput(GCControllerDirectionPad dPad, string name) - { - SetupDigitalAxisInput(dPad.XAxis, $"{name}_X"); - SetupDigitalAxisInput(dPad.YAxis, $"{name}_Y"); + base.RemoveAllControllers(); } - private void SetupDigitalAxisInput(GCControllerAxisInput axis, string name) + private void ControllerRemoved(GCController controller) { - axis.ValueChangedHandler = (ax, value) => + lock (_lockObject) { - if (value < -0.1F) value = -1.0F; - else if (value > 0.1F) value = 1.0F; - else value = 0.0F; - - if (!_lastControllerEventValueMap.ContainsKey(name) || !AreAlmostEqual(_lastControllerEventValueMap[name], value)) + if (TryRemove(x => x.ControllerDevice == controller, out var controllerDevice)) { - // ToDo: find ControllerId - string controllerId = GetControllerIdFromIndex(0); - - GameControllerEventInternal?.Invoke(this, new GameControllerEventArgs(controllerId, GameControllerEventType.Axis, name, value)); - _lastControllerEventValueMap[name] = value; + _logger.LogInformation("ControllerDevice has been removed ControllerId:{controllerId}", controllerDevice.ControllerId); } - }; + } } - private void SetupJoyInput(GCControllerDirectionPad joy, string name) + private void ControllerAdded(GCController controller) { - SetupAnalogAxisInput(joy.XAxis, $"{name}_X"); - SetupAnalogAxisInput(joy.YAxis, $"{name}_Y"); + AddDevices([controller]); } - private void SetupAnalogAxisInput(GCControllerAxisInput axis, string name) + private void AddDevices(IEnumerable controllers) { - axis.ValueChangedHandler = (ax, value) => + lock (_lockObject) { - value = AdjustControllerValue(value); - - if (!_lastControllerEventValueMap.ContainsKey(name) || !AreAlmostEqual(_lastControllerEventValueMap[name], value)) + foreach (var gamepad in controllers) { - // ToDo: find ControllerId - string controllerId = GetControllerIdFromIndex(0); + // get first unused number and apply it + int controllerNumber = GetFirstUnusedControllerNumber(); + var newController = new GamepadController(this, gamepad, controllerNumber); - GameControllerEventInternal?.Invoke(this, new GameControllerEventArgs(controllerId, GameControllerEventType.Axis, name, value)); - _lastControllerEventValueMap[name] = value; + AddController(newController); } - }; + } } } } \ No newline at end of file diff --git a/BrickController2/BrickController2.iOS/PlatformServices/GameController/GamepadController.cs b/BrickController2/BrickController2.iOS/PlatformServices/GameController/GamepadController.cs new file mode 100644 index 00000000..18a0f3cb --- /dev/null +++ b/BrickController2/BrickController2.iOS/PlatformServices/GameController/GamepadController.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using BrickController2.PlatformServices.GameController; +using GameController; + +using static BrickController2.PlatformServices.GameController.GameControllers; + +using BrickController2.iOS.PlatformServices.GameController; + +internal class GamepadController : GamepadControllerBase, IDisposable +{ + private enum GameControllerType + { + Unknown, + Micro, + Standard, + Extended + }; + + private readonly IDictionary _lastControllerEventValueMap = new Dictionary(); + + /// + /// Constructor + /// + /// reference to GameControllerService + /// reference to InputDevice + public GamepadController(GameControllerService service, GCController controller, int controllerNumber) + : base(service, controller) + { + // initialize properties + GameControllerType gameControllerType = GetGameControllerType(controller); + + Name = GetDisplayName(gameControllerType); + ControllerNumber = controllerNumber; + ControllerId = GetControllerIdFromNumber(controllerNumber); + + SetupController(controller, gameControllerType); + } + + public void Dispose() + { + ControllerDevice.Dispose(); + } + + private void SetupController(GCController gameController, GameControllerType gameControllerType) + { + switch (gameControllerType) + { + case GameControllerType.Micro: + SetupMicroGamePad(gameController.MicroGamepad!); + break; + + case GameControllerType.Standard: +#pragma warning disable CA1422 // Validate platform compatibility + SetupGamePad(gameController.Gamepad!); +#pragma warning restore CA1422 // Validate platform compatibility + break; + + case GameControllerType.Extended: + SetupExtendedGamePad(gameController.ExtendedGamepad!); + break; + } + } + + private GameControllerType GetGameControllerType(GCController controller) + { + try + { + if (controller.MicroGamepad is not null) + { + return GameControllerType.Micro; + } + } + catch (InvalidCastException) { } + + try + { +#pragma warning disable CA1422 // Validate platform compatibility + if (controller.Gamepad is not null) + { + return GameControllerType.Standard; + } +#pragma warning restore CA1422 // Validate platform compatibility + } + catch (InvalidCastException) { } + + try + { + if (controller.ExtendedGamepad is not null) + { + return GameControllerType.Extended; + } + } + catch (InvalidCastException) { } + + return GameControllerType.Unknown; + } + + private void SetupMicroGamePad(GCMicroGamepad gamePad) + { + SetupDigitalButtonInput(gamePad.ButtonA, "Button_A"); + SetupDigitalButtonInput(gamePad.ButtonX, "Button_X"); + + SetupDPadInput(gamePad.Dpad, "DPad"); + } + + private void SetupGamePad(GCGamepad gamePad) + { +#pragma warning disable CA1422 // Validate platform compatibility + SetupDigitalButtonInput(gamePad.ButtonA, "Button_A"); + SetupDigitalButtonInput(gamePad.ButtonB, "Button_B"); + SetupDigitalButtonInput(gamePad.ButtonX, "Button_X"); + SetupDigitalButtonInput(gamePad.ButtonY, "Button_Y"); + + SetupDigitalButtonInput(gamePad.LeftShoulder, "LeftShoulder"); + SetupDigitalButtonInput(gamePad.RightShoulder, "RightShoulder"); + + SetupDPadInput(gamePad.DPad, "DPad"); +#pragma warning restore CA1422 // Validate platform compatibility + } + + private void SetupExtendedGamePad(GCExtendedGamepad gamePad) + { + SetupDigitalButtonInput(gamePad.ButtonA, "Button_A"); + SetupDigitalButtonInput(gamePad.ButtonB, "Button_B"); + SetupDigitalButtonInput(gamePad.ButtonX, "Button_X"); + SetupDigitalButtonInput(gamePad.ButtonY, "Button_Y"); + + SetupDigitalButtonInput(gamePad.LeftShoulder, "LeftShoulder"); + SetupDigitalButtonInput(gamePad.RightShoulder, "RightShoulder"); + + SetupAnalogButtonInput(gamePad.LeftTrigger, "LeftTrigger"); + SetupAnalogButtonInput(gamePad.RightTrigger, "RightTrigger"); + + SetupDPadInput(gamePad.DPad, "DPad"); + + SetupJoyInput(gamePad.LeftThumbstick, "LeftThumbStick"); + SetupJoyInput(gamePad.RightThumbstick, "RightThumbStick"); + } + + private void SetupDigitalButtonInput(GCControllerButtonInput button, string name) + { + button.ValueChangedHandler = (btn, value, isPressed) => + { + value = isPressed ? 1.0F : 0.0F; + + if (!_lastControllerEventValueMap.ContainsKey(name) || !AreAlmostEqual(_lastControllerEventValueMap[name], value)) + { + // ToDo: find ControllerId + //string controllerId = GetControllerIdFromIndex(0); + //GameControllerEventInternal?.Invoke(this, new GameControllerEventArgs(controllerId, GameControllerEventType.Button, name, value)); + + RaiseEvent(GameControllerEventType.Button, name, value); + + _lastControllerEventValueMap[name] = value; + } + }; + } + + private void SetupAnalogButtonInput(GCControllerButtonInput button, string name) + { + button.ValueChangedHandler = (btn, value, isPressed) => + { + value = value < 0.1 ? 0.0F : value; + + if (!_lastControllerEventValueMap.ContainsKey(name) || !AreAlmostEqual(_lastControllerEventValueMap[name], value)) + { + // ToDo: find ControllerId + //string controllerId = GetControllerIdFromIndex(0); + //GameControllerEventInternal?.Invoke(this, new GameControllerEventArgs(controllerId, GameControllerEventType.Axis, name, value)); + + RaiseEvent(GameControllerEventType.Axis, name, value); + + _lastControllerEventValueMap[name] = value; + } + }; + } + + private void SetupDPadInput(GCControllerDirectionPad dPad, string name) + { + SetupDigitalAxisInput(dPad.XAxis, $"{name}_X"); + SetupDigitalAxisInput(dPad.YAxis, $"{name}_Y"); + } + + private void SetupDigitalAxisInput(GCControllerAxisInput axis, string name) + { + axis.ValueChangedHandler = (ax, value) => + { + if (value < -0.1F) value = -1.0F; + else if (value > 0.1F) value = 1.0F; + else value = 0.0F; + + if (!_lastControllerEventValueMap.ContainsKey(name) || !AreAlmostEqual(_lastControllerEventValueMap[name], value)) + { + // ToDo: find ControllerId + //string controllerId = GetControllerIdFromIndex(0); + //GameControllerEventInternal?.Invoke(this, new GameControllerEventArgs(controllerId, GameControllerEventType.Axis, name, value)); + + RaiseEvent(GameControllerEventType.Axis, name, value); + + _lastControllerEventValueMap[name] = value; + } + }; + } + + private void SetupJoyInput(GCControllerDirectionPad joy, string name) + { + SetupAnalogAxisInput(joy.XAxis, $"{name}_X"); + SetupAnalogAxisInput(joy.YAxis, $"{name}_Y"); + } + + private void SetupAnalogAxisInput(GCControllerAxisInput axis, string name) + { + axis.ValueChangedHandler = (ax, value) => + { + value = AdjustControllerValue(value); + + if (!_lastControllerEventValueMap.ContainsKey(name) || !AreAlmostEqual(_lastControllerEventValueMap[name], value)) + { + // ToDo: find ControllerId + //string controllerId = GetControllerIdFromIndex(0); + //GameControllerEventInternal?.Invoke(this, new GameControllerEventArgs(controllerId, GameControllerEventType.Axis, name, value)); + + RaiseEvent(GameControllerEventType.Axis, name, value); + + _lastControllerEventValueMap[name] = value; + } + }; + } + + private static string GetDisplayName(GameControllerType device) + { + return device switch + { + GameControllerType.Micro => "Micro Gamepad", + GameControllerType.Standard => "Standard Gamepad", + GameControllerType.Extended => "Extended Gamepad", + _ => "Unknown Gamepad", + }; + } + +} \ No newline at end of file diff --git a/BrickController2/BrickController2/PlatformServices/GameController/GameControllerServiceBase.cs b/BrickController2/BrickController2/PlatformServices/GameController/GameControllerServiceBase.cs index a9f08f8d..a37c145a 100644 --- a/BrickController2/BrickController2/PlatformServices/GameController/GameControllerServiceBase.cs +++ b/BrickController2/BrickController2/PlatformServices/GameController/GameControllerServiceBase.cs @@ -10,8 +10,7 @@ namespace BrickController2.PlatformServices.GameController; /// /// Base class for implementation of /// -public abstract class GameControllerServiceBase : IGameControllerServiceInternal - where TGameController : class, IGameController +public abstract class GameControllerServiceBase : IGameControllerServiceInternal { protected readonly object _lockObject = new(); protected readonly ILogger _logger; @@ -21,7 +20,7 @@ public abstract class GameControllerServiceBase : IGameControll /// /// Collection of available gamepads having /// - private readonly List _availableControllers = []; + private readonly List _availableControllers = []; protected GameControllerServiceBase(ILogger logger) { @@ -97,6 +96,8 @@ protected virtual void RemoveAllControllers() foreach (var controller in _availableControllers) { controller.Stop(); + + (controller as IDisposable)?.Dispose(); } _availableControllers.Clear(); // notify removal @@ -119,7 +120,7 @@ protected int GetFirstUnusedControllerNumber() } } - protected void AddController(TGameController controller) + protected void AddController(IGameController controller) { lock (_lockObject) { @@ -130,27 +131,36 @@ protected void AddController(TGameController controller) } } - protected bool TryRemove(Predicate predicate, [MaybeNullWhen(false)] out TGameController controller) + protected bool TryRemove(Predicate predicate, [MaybeNullWhen(false)] out TGameController controller) + where TGameController : class, IGameController { lock (_lockObject) { // remove and stop the controller - if (_availableControllers.Remove(predicate, out controller)) + if (_availableControllers.Remove(x => x is TGameController tc && predicate(tc), out var removed)) { + controller = (TGameController)removed; // safe due to pattern match above controller.Stop(); + // notify removal OnGameControllersChangedEvent(NotifyGameControllersChangedAction.Disconnected, controller); + + (controller as IDisposable)?.Dispose(); + return true; } + + controller = null; return false; } } - protected bool TryGetController(Predicate predicate, [MaybeNullWhen(false)] out TGameController controller) + protected bool TryGetController(Predicate predicate, [MaybeNullWhen(false)] out TGameController controller) + where TGameController : class, IGameController { lock (_lockObject) { - controller = _availableControllers.FirstOrDefault(x => predicate(x)); + controller = _availableControllers.OfType().FirstOrDefault(x => predicate(x)); return controller is not null; } } diff --git a/BrickController2/BrickController2/PlatformServices/GameController/GameControllers.cs b/BrickController2/BrickController2/PlatformServices/GameController/GameControllers.cs index 9ca61de9..a4dfefaf 100644 --- a/BrickController2/BrickController2/PlatformServices/GameController/GameControllers.cs +++ b/BrickController2/BrickController2/PlatformServices/GameController/GameControllers.cs @@ -14,17 +14,6 @@ public static class GameControllers public const float AXIS_MIN_VALUE = - 1.0f; public const float AXIS_MAX_VALUE = 1.0f; - /// - /// Creates an identifier string for the controller from the given index - /// - /// zero-based index - /// Identifier - public static string GetControllerIdFromIndex(int controllerIndex) - { - // controllerIndex == 0 -> "Controller 1" - return $"Controller {controllerIndex + 1}"; - } - /// /// Creates an identifier string for the controller from the given /// diff --git a/BrickController2/BrickController2/PlatformServices/GameController/GamepadControllerBase.cs b/BrickController2/BrickController2/PlatformServices/GameController/GamepadControllerBase.cs index 55a82f59..a8f33c5c 100644 --- a/BrickController2/BrickController2/PlatformServices/GameController/GamepadControllerBase.cs +++ b/BrickController2/BrickController2/PlatformServices/GameController/GamepadControllerBase.cs @@ -4,7 +4,7 @@ namespace BrickController2.PlatformServices.GameController; -public abstract class GamepadControllerBase : IGameController where TGamepad : class +public abstract class GamepadControllerBase : IGameController where TControllerDevice : class { /// stored last value per axis to detect changes private readonly Dictionary _lastAxisValues = []; @@ -13,10 +13,10 @@ public abstract class GamepadControllerBase : IGameController where TG private readonly IGameControllerServiceInternal _controllerService; protected GamepadControllerBase(IGameControllerServiceInternal controllerService, - TGamepad gamepad) + TControllerDevice controllerDevice) { _controllerService = controllerService; - Gamepad = gamepad; + ControllerDevice = controllerDevice; } /// @@ -30,19 +30,15 @@ protected GamepadControllerBase(IGameControllerServiceInternal controllerService public string ControllerId { get; protected init; } = default!; /// - /// Unique and persistant identifier of device + /// DisplayName of the controller /// - public string UniquePersistantDeviceId { get; protected init; } = default!; - public string Name { get; protected init; } = default!; - public int VendorId { get; protected init; } - public int ProductId { get; protected init; } /// - /// Native instance of gamepad + /// Native instance of controllerdevice /// - public TGamepad Gamepad { get; } + public TControllerDevice ControllerDevice { get; } public virtual void Start() { diff --git a/BrickController2/BrickController2/PlatformServices/GameController/IGameController.cs b/BrickController2/BrickController2/PlatformServices/GameController/IGameController.cs index f0c8e60f..61139b65 100644 --- a/BrickController2/BrickController2/PlatformServices/GameController/IGameController.cs +++ b/BrickController2/BrickController2/PlatformServices/GameController/IGameController.cs @@ -18,16 +18,6 @@ public interface IGameController /// string Name { get; } - /// - /// Vendor ID of the game controller. - /// - int VendorId { get; } - - /// - /// Product ID of the game controller. - /// - int ProductId { get; } - /// /// Start the controller and publishing of its events /// From 8c352f0bedc9cb2f4b9173991ab34314a2b37dcf Mon Sep 17 00:00:00 2001 From: J0EK3R Date: Sun, 31 Aug 2025 11:08:08 +0200 Subject: [PATCH 2/5] McpServer for Win --- BrickController2.sln | 7 + .../BrickController2.WinUI.csproj | 5 + .../Package.appxmanifest | 1 + .../DI/PlatformServicesModule.cs | 3 + .../GameController/GameControllerService.cs | 49 +++ .../GameController/GamepadController.cs | 7 +- .../PlatformServices/McpServer/McpServer.cs | 393 ++++++++++++++++++ .../McpServer/McpServerController.cs | 39 ++ .../McpServer/McpServerService.cs | 109 +++++ BrickController2/BrickController2/App.xaml.cs | 10 +- .../ModelContextProtocol/IMcpServerService.cs | 27 ++ .../ModelContextProtocol/McpServerBase.cs | 10 + .../Resources/TranslationResources.de.resx | 9 + .../Resources/TranslationResources.resx | 15 + .../NoAuthTokenToPlaceholderConverter.cs | 28 ++ .../UI/Pages/SettingsPage.xaml | 25 +- .../UI/ViewModels/SettingsPageViewModel.cs | 113 ++++- .../MCPServer/claude_desktop_config.json | 14 + BrickController2/MCPServer/mcp_http_bridge.py | 215 ++++++++++ Directory.Packages.props | 3 +- 20 files changed, 1069 insertions(+), 13 deletions(-) create mode 100644 BrickController2/BrickController2.WinUI/PlatformServices/McpServer/McpServer.cs create mode 100644 BrickController2/BrickController2.WinUI/PlatformServices/McpServer/McpServerController.cs create mode 100644 BrickController2/BrickController2.WinUI/PlatformServices/McpServer/McpServerService.cs create mode 100644 BrickController2/BrickController2/PlatformServices/ModelContextProtocol/IMcpServerService.cs create mode 100644 BrickController2/BrickController2/PlatformServices/ModelContextProtocol/McpServerBase.cs create mode 100644 BrickController2/BrickController2/UI/Converters/NoAuthTokenToPlaceholderConverter.cs create mode 100644 BrickController2/MCPServer/claude_desktop_config.json create mode 100644 BrickController2/MCPServer/mcp_http_bridge.py diff --git a/BrickController2.sln b/BrickController2.sln index 6eb8555c..d0133806 100644 --- a/BrickController2.sln +++ b/BrickController2.sln @@ -19,6 +19,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{5AFB0DE9 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BrickController2.Tools", "BrickController2\BrickController2.Tools\BrickController2.Tools.csproj", "{C127AB50-E104-2345-5239-13258E2B7474}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MCPSercer", "MCPSercer", "{12E3959D-03BB-49C4-B382-2188D5CCD111}" + ProjectSection(SolutionItems) = preProject + BrickController2\MCPServer\claude_desktop_config.json = BrickController2\MCPServer\claude_desktop_config.json + BrickController2\MCPServer\mcp_http_bridge.py = BrickController2\MCPServer\mcp_http_bridge.py + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -126,6 +132,7 @@ Global GlobalSection(NestedProjects) = preSolution {7981CBBF-BB2F-4AF4-BEF3-3CF0C92DBB23} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {C127AB50-E104-2345-5239-13258E2B7474} = {5AFB0DE9-536E-4C5D-831A-421ADC3EF3D9} + {12E3959D-03BB-49C4-B382-2188D5CCD111} = {5AFB0DE9-536E-4C5D-831A-421ADC3EF3D9} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {024A01F8-4022-4F9E-A006-AC2CABB04620} diff --git a/BrickController2/BrickController2.WinUI/BrickController2.WinUI.csproj b/BrickController2/BrickController2.WinUI/BrickController2.WinUI.csproj index 43c2c91f..2939f136 100644 --- a/BrickController2/BrickController2.WinUI/BrickController2.WinUI.csproj +++ b/BrickController2/BrickController2.WinUI/BrickController2.WinUI.csproj @@ -21,6 +21,7 @@ + @@ -28,6 +29,10 @@ + + + + diff --git a/BrickController2/BrickController2.WinUI/Package.appxmanifest b/BrickController2/BrickController2.WinUI/Package.appxmanifest index 735b3b00..c56248d2 100644 --- a/BrickController2/BrickController2.WinUI/Package.appxmanifest +++ b/BrickController2/BrickController2.WinUI/Package.appxmanifest @@ -43,6 +43,7 @@ + diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/DI/PlatformServicesModule.cs b/BrickController2/BrickController2.WinUI/PlatformServices/DI/PlatformServicesModule.cs index 9f7c30cd..b3e9bab1 100644 --- a/BrickController2/BrickController2.WinUI/PlatformServices/DI/PlatformServicesModule.cs +++ b/BrickController2/BrickController2.WinUI/PlatformServices/DI/PlatformServicesModule.cs @@ -5,6 +5,7 @@ using BrickController2.PlatformServices.GameController; using BrickController2.PlatformServices.Infrared; using BrickController2.PlatformServices.Localization; +using BrickController2.PlatformServices.ModelContextProtocol; using BrickController2.PlatformServices.Permission; using BrickController2.PlatformServices.SharedFileStorage; using BrickController2.Windows.PlatformServices.BluetoothLE; @@ -13,6 +14,7 @@ using BrickController2.Windows.PlatformServices.GameController; using BrickController2.Windows.PlatformServices.Infrared; using BrickController2.Windows.PlatformServices.Localization; +using BrickController2.Windows.PlatformServices.ModelContextProtocol; using BrickController2.Windows.PlatformServices.Permission; using BrickController2.Windows.PlatformServices.SharedFileStorage; @@ -31,5 +33,6 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerDependency(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); + builder.RegisterType().AsSelf().As().SingleInstance(); } } \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GameControllerService.cs b/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GameControllerService.cs index 9abe20bf..121a7b78 100644 --- a/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GameControllerService.cs +++ b/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GameControllerService.cs @@ -5,6 +5,7 @@ using BrickController2.PlatformServices.GameController; using BrickController2.UI.Services.MainThread; using Microsoft.Extensions.Logging; +using BrickController2.Windows.PlatformServices.ModelContextProtocol; namespace BrickController2.Windows.PlatformServices.GameController; @@ -12,19 +13,25 @@ internal class GameControllerService : GameControllerServiceBase, IGameControlle { private readonly IMainThreadService _mainThreadService; private readonly IDispatcherProvider _dispatcherProvider; + private readonly McpServerService _mcpServerService; public GameControllerService(IMainThreadService mainThreadService, IDispatcherProvider dispatcherProvider, + McpServerService mcpServerService, ILogger logger) : base(logger) { _mainThreadService = mainThreadService; _dispatcherProvider = dispatcherProvider; + _mcpServerService = mcpServerService; } public override bool IsControllerIdSupported => true; protected override void InitializeCurrentControllers() { + // add McpServer + AddMcpServerDevice(); + // get all available gamepads if (Gamepad.Gamepads.Any()) { @@ -34,6 +41,9 @@ protected override void InitializeCurrentControllers() // register gamepad events Gamepad.GamepadRemoved += Gamepad_GamepadRemoved; Gamepad.GamepadAdded += Gamepad_GamepadAdded; + + _mcpServerService.McpServerAdded += McpServerAdded; + _mcpServerService.McpServerRemoved += McpServerRemoved; ; } protected override void RemoveAllControllers() @@ -42,6 +52,9 @@ protected override void RemoveAllControllers() Gamepad.GamepadRemoved -= Gamepad_GamepadRemoved; Gamepad.GamepadAdded -= Gamepad_GamepadAdded; + _mcpServerService.McpServerAdded -= McpServerAdded; + _mcpServerService.McpServerRemoved -= McpServerRemoved; ; + // do removal base.RemoveAllControllers(); } @@ -67,6 +80,28 @@ private void Gamepad_GamepadAdded(object? sender, Gamepad e) _ = _mainThreadService.RunOnMainThread(() => AddDevices([e])); } + private void McpServerRemoved(object? sender, McpServer e) + { + lock (_lockObject) + { + // ensure stopped in UI thread + _ = _mainThreadService.RunOnMainThread(() => + { + if (TryRemove(x => x.ControllerDevice is McpServer, out var controller)) + { + _logger.LogInformation("McpServer has been removed"); + } + }); + } + } + + private void McpServerAdded(object? sender, McpServer e) + { + // ensure created in UI thread + _ = _mainThreadService.RunOnMainThread(() => AddMcpServerDevice()); + } + + private void AddDevices(IEnumerable gamepads) { lock (_lockObject) @@ -89,4 +124,18 @@ private void AddDevices(IEnumerable gamepads) } } } + + private void AddMcpServerDevice() + { + if (_mcpServerService?.Server != null) + { + lock (_lockObject) + { + var dispatcher = _dispatcherProvider.GetForCurrentThread(); + var newController = new McpServerController(this, _mcpServerService.Server, dispatcher); + + AddController(newController); + } + } + } } \ No newline at end of file diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GamepadController.cs b/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GamepadController.cs index 4972f8ba..c105a7b3 100644 --- a/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GamepadController.cs +++ b/BrickController2/BrickController2.WinUI/PlatformServices/GameController/GamepadController.cs @@ -4,7 +4,6 @@ using Microsoft.Maui.Dispatching; using BrickController2.PlatformServices.GameController; using BrickController2.Windows.Extensions; - using static BrickController2.PlatformServices.GameController.GameControllers; namespace BrickController2.Windows.PlatformServices.GameController; @@ -13,7 +12,7 @@ internal class GamepadController : GamepadControllerBase { private static readonly TimeSpan DefaultInterval = TimeSpan.FromMilliseconds(10); - private readonly IDispatcherTimer _timer; + private readonly IDispatcherTimer? _timer; /// /// Constructor @@ -39,12 +38,12 @@ public override void Start() base.Start(); // finally start timer - _timer.Start(); + _timer?.Start(); } public override void Stop() { - _timer.Stop(); + _timer?.Stop(); base.Stop(); } diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/McpServer/McpServer.cs b/BrickController2/BrickController2.WinUI/PlatformServices/McpServer/McpServer.cs new file mode 100644 index 00000000..ba403d5b --- /dev/null +++ b/BrickController2/BrickController2.WinUI/PlatformServices/McpServer/McpServer.cs @@ -0,0 +1,393 @@ +using BrickController2.PlatformServices.ModelContextProtocol; +using EmbedIO; +using EmbedIO.Routing; +using EmbedIO.WebApi; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace BrickController2.Windows.PlatformServices.ModelContextProtocol; + +public sealed class McpServer : McpServerBase, IDisposable +{ + private const string ToolSetChannels = "set_channels"; + private const string ToolStop = "stop"; + + private readonly string _url; + private readonly bool _enableCors; + private readonly object _channelLock = new(); + private readonly List _channelStates = new(); + private readonly string _authToken; + + private WebServer? _server; + + public event Action>? ChannelStatesUpdated; + + public McpServer(int port, string authToken = "") + : this($"http://*:{port}", true, authToken) + { + } + + public McpServer(string url = "http://*:5000", bool enableCors = true, string authToken = "") + { + _url = url; + _enableCors = enableCors; + _authToken = authToken; + } + + public void Dispose() + { + if (_server != null) + { + _server.Dispose(); + _server = null; + } + } + + public Task StartAsync(CancellationToken ct = default) + { + if (_server != null) + { + return Task.CompletedTask; + } + + var server = new WebServer(o => o + .WithUrlPrefix(_url) + .WithMode(HttpListenerMode.EmbedIO)) + .WithLocalSessionManager(); + + // CORS (if available in your EmbedIO version) + if (_enableCors) + { + try + { + server.WithCors(origins: "*", headers: "*", methods: "*"); + } + catch + { + // Optional: add a manual CORS ActionModule fallback if needed. + } + } + + server.WithWebApi("/", m => m + .WithController(() => new McpController(this))); + + _server = server; + + // Start is non-blocking + return _server.RunAsync(); + } + + public Task StopAsync(CancellationToken ct = default) + { + if (_server == null) + { + return Task.CompletedTask; + } + + _server.Dispose(); + _server = null; + + return Task.CompletedTask; + } + + // --- State & update helpers --- + + private List Snapshot() + { + lock (_channelLock) { return _channelStates.ToList(); } + } + + private List SetChannels(IDictionary map) + { + List channelStates; + + lock (_channelLock) + { + _channelStates.Clear(); + _channelStates.AddRange(map.Select(kv => new ChannelValue { Channel = kv.Key, Value = kv.Value })); + channelStates = _channelStates.ToList(); + } + + ChannelStatesUpdated?.Invoke(channelStates); + + return channelStates; + } + + private List StopAll() + { + List channelStates; + + lock (_channelLock) + { + for (int i = 0; i < _channelStates.Count; i++) + { + _channelStates[i].Value = 0.0; + } + channelStates = _channelStates.ToList(); + } + + ChannelStatesUpdated?.Invoke(channelStates); + + return channelStates; + } + + + // --- Auth helper --- + private bool IsAuthorized(WebApiController controller) + { + // no token, no check + if (string.IsNullOrEmpty(_authToken)) + { + return true; + } + + var authHeader = controller.Request.Headers["Authorization"]; + + if (string.IsNullOrEmpty(authHeader)) + { + return false; + } + + var token = authHeader.Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase); + return string.Equals(token, _authToken, StringComparison.Ordinal); + } + + // --- Contracts --- + + public record ChannelValue + { + [JsonPropertyName("channel")] + public int Channel { get; set; } + + [JsonPropertyName("value")] + public double Value { get; set; } + } + + public record ExecuteRequest + { + public string name { get; init; } = ""; + public Dictionary? arguments { get; init; } + } + + public record ToolDescription + { + public string name { get; init; } = ""; + public string? description { get; init; } + public JsonSchema? parameters { get; init; } + } + + public record JsonSchema + { + public string type { get; init; } = "object"; + public Dictionary? properties { get; init; } + public string[]? required { get; init; } + public bool additionalProperties { get; init; } = false; + } + + // --- Web API Controller --- + + private sealed class McpController : WebApiController + { + private readonly McpServer _svc; + + public McpController(McpServer svc) => _svc = svc; + + [Route(HttpVerbs.Get, "/health")] + public object Health() + { + if (!_svc.IsAuthorized(this)) + { + return HttpStatusCode.Unauthorized; + } + + return new + { + ok = true, + service = "mcp-brickcontroller", + time = DateTimeOffset.UtcNow + }; + } + + [Route(HttpVerbs.Get, "/mcp/channels")] + public object GetChannels() + { + if (!_svc.IsAuthorized(this)) + { + return HttpStatusCode.Unauthorized; + } + + return new + { + channels = _svc.Snapshot() + }; + } + + [Route(HttpVerbs.Get, "/mcp/capabilities")] + public object Capabilities() + { + if (!_svc.IsAuthorized(this)) + { + return HttpStatusCode.Unauthorized; + } + + var setChannelsTool = new ToolDescription + { + name = ToolSetChannels, + description = "Sets one or more channel values, each as a channel number and a value between -1.0 and 1.0.", + parameters = new JsonSchema + { + type = "object", + properties = new Dictionary + { + ["channels"] = new Dictionary + { + ["type"] = "array", + ["items"] = new Dictionary + { + ["type"] = "object", + ["properties"] = new Dictionary + { + ["channel"] = new Dictionary + { + ["type"] = "integer", + ["description"] = "Channel number (integer)." + }, + ["value"] = new Dictionary + { + ["type"] = "number", + ["minimum"] = -1.0, + ["maximum"] = 1.0, + ["description"] = "Channel value between -1.0 and 1.0." + } + }, + ["required"] = new[] { "channel", "value" } + }, + ["description"] = "Array of channel/value objects." + } + }, + required = new[] { "channels" }, + additionalProperties = false + } + }; + + var stopTool = new ToolDescription + { + name = ToolStop, + description = "Stops all activity by setting all channels to 0.", + parameters = new JsonSchema + { + type = "object", + properties = new Dictionary(), + required = Array.Empty(), + additionalProperties = false + } + }; + + return new + { + capabilities = new[] { "tools" }, + tools = new[] { setChannelsTool, stopTool }, + server = new { name = "android-mcp-brickcontroller", version = "0.1.0" } + }; + } + + [Route(HttpVerbs.Post, "/mcp/tools/execute")] + public async Task Execute() + { + if (!_svc.IsAuthorized(this)) + { + return HttpStatusCode.Unauthorized; + } + + var req = await HttpContext.GetRequestDataAsync(); + + if (string.Equals(req.name, ToolSetChannels, StringComparison.OrdinalIgnoreCase)) + { + if (req.arguments is null || !req.arguments.TryGetValue("channels", out var channelsObj)) + return new { error = "invalid_arguments", message = "channels (array) is required." }; + + var map = new Dictionary(); + + if (channelsObj is JsonElement je && je.ValueKind == JsonValueKind.Array) + { + foreach (var item in je.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) + return new { error = "invalid_arguments", message = "Each channel entry must be an object." }; + + if (!item.TryGetProperty("channel", out var chanProp) || !chanProp.TryGetInt32(out var channel)) + return new { error = "invalid_arguments", message = "channel (int) is required." }; + + if (!item.TryGetProperty("value", out var valProp) || !valProp.TryGetDouble(out var value)) + return new { error = "invalid_arguments", message = "value (float) is required." }; + + if (value < -1.0 || value > 1.0) + return new { error = "invalid_arguments", message = "value must be between -1.0 and 1.0." }; + + map[channel] = value; // last write wins + } + } + else if (channelsObj is IEnumerable arrObj) + { + foreach (var o in arrObj) + { + if (o is not Dictionary dict || + !dict.TryGetValue("channel", out var chO) || + !dict.TryGetValue("value", out var valO)) + return new { error = "invalid_arguments", message = "Invalid channel entry." }; + + var channel = Convert.ToInt32(chO); + var value = Convert.ToDouble(valO); + if (value < -1.0 || value > 1.0) + return new { error = "invalid_arguments", message = "value must be between -1.0 and 1.0." }; + + map[channel] = value; + } + } + else + { + return new { error = "invalid_arguments", message = "channels must be an array." }; + } + + var snapshot = _svc.SetChannels(map); + return new + { + ok = true, + tool = ToolSetChannels, + output = new + { + message = $"Channels updated: {string.Join(", ", snapshot.Select(c => $"ch{c.Channel}={c.Value:0.##}"))}", + channels = snapshot + } + }; + } + else if (string.Equals(req.name, ToolStop, StringComparison.OrdinalIgnoreCase)) + { + var snapshot = _svc.StopAll(); + return new + { + ok = true, + tool = ToolStop, + output = new + { + message = "All channels set to 0 (stopped).", + channels = snapshot + } + }; + } + + return new + { + error = "unknown_tool", + message = $"Tool '{req.name}' is not supported.", + supported = new[] { ToolSetChannels, ToolStop } + }; + } + } +} diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/McpServer/McpServerController.cs b/BrickController2/BrickController2.WinUI/PlatformServices/McpServer/McpServerController.cs new file mode 100644 index 00000000..07900ed4 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/PlatformServices/McpServer/McpServerController.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Maui.Dispatching; +using BrickController2.PlatformServices.GameController; +using BrickController2.Windows.PlatformServices.GameController; + +namespace BrickController2.Windows.PlatformServices.ModelContextProtocol; + +internal class McpServerController : GamepadControllerBase +{ + private readonly IDispatcher? _dispatcher; + + /// + /// Constructor + /// + /// reference to GameControllerService + /// reference to UWP's Gamepad + public McpServerController(GameControllerService service, McpServer mcpServer, IDispatcher? dispatcher) + : base(service, mcpServer) + { + _dispatcher = dispatcher; + ControllerDevice.ChannelStatesUpdated += mcpServer_ChannelStatesUpdated; + + // initialize properties + Name = "McpServerDevice"; + ControllerNumber = -1; + ControllerId = "McpServerDevice"; + } + + private void mcpServer_ChannelStatesUpdated(List obj) + { + // grab all changed axis event + var currentEvents = obj + .Where(x => HasValueChanged($"{x.Channel}", (float)x.Value)) + .ToDictionary(x => (GameControllerEventType.Axis, $"{x.Channel}"), x => (float)x.Value); + + _dispatcher?.Dispatch(() => RaiseEvent(currentEvents)); + } +} diff --git a/BrickController2/BrickController2.WinUI/PlatformServices/McpServer/McpServerService.cs b/BrickController2/BrickController2.WinUI/PlatformServices/McpServer/McpServerService.cs new file mode 100644 index 00000000..66ec7960 --- /dev/null +++ b/BrickController2/BrickController2.WinUI/PlatformServices/McpServer/McpServerService.cs @@ -0,0 +1,109 @@ +using BrickController2.PlatformServices.ModelContextProtocol; +using BrickController2.UI.Services.Preferences; +using System; +using System.Threading.Tasks; + +namespace BrickController2.Windows.PlatformServices.ModelContextProtocol; + +public class McpServerService : IMcpServerService, IDisposable +{ + private readonly object _Lock = new object(); + private readonly IPreferencesService _preferencesService; + private McpServer? _server; + + public McpServerService(IPreferencesService preferencesService) + { + _preferencesService = preferencesService; + _server = null; + + ApplyMcpServer(); + } + + public event EventHandler? McpServerAdded; + public event EventHandler? McpServerRemoved; + + public bool IsMcpServerAvailable => true; + + public bool IsMcpServerEnabled + { + get => _preferencesService.Get("McpServerEnabled", false); + + set + { + if (IsMcpServerEnabled != value) + { + _preferencesService.Set("McpServerEnabled", value); + ApplyMcpServer(); + } + } + } + + public int McpServerPort + { + get => _preferencesService.Get("McpServerPort", McpServerBase.PortDefault); + + set + { + if (McpServerPort != value) + { + _preferencesService.Set("McpServerPort", value); + ApplyMcpServer(); + } + } + } + + public string McpServerAuthToken + { + get => _preferencesService.Get("McpServerAuthToken", McpServerBase.AuthTokenDefault); + + set + { + if (McpServerAuthToken != value) + { + _preferencesService.Set("McpServerAuthToken", value); + ApplyMcpServer(); + } + } + } + + public McpServer? Server => _server; + + public void Dispose() + { + lock (_Lock) + { + _server?.Dispose(); + _server = null; + } + } + + private void ApplyMcpServer() + { + lock (_Lock) + { + if (_server != null) + { + McpServerRemoved?.Invoke(this, _server); + + _server?.Dispose(); + _server = null; + } + + if (IsMcpServerEnabled) + { + if (_server == null) + { + _server = new McpServer(McpServerPort, McpServerAuthToken); + Start(); + McpServerAdded?.Invoke(this, _server); + } + } + } + } + + public void Start() + { + // start non-blocking + Task.Run(() => _server?.StartAsync()); + } +} diff --git a/BrickController2/BrickController2/App.xaml.cs b/BrickController2/BrickController2/App.xaml.cs index a1c4456f..e14eedd7 100644 --- a/BrickController2/BrickController2/App.xaml.cs +++ b/BrickController2/BrickController2/App.xaml.cs @@ -1,8 +1,10 @@ +using BrickController2.UI.Converters; using BrickController2.UI.DI; using BrickController2.UI.Pages; using BrickController2.UI.Services.Background; using BrickController2.UI.Services.Localization; using BrickController2.UI.Services.Theme; +using BrickController2.UI.Services.Translation; using BrickController2.UI.ViewModels; using Microsoft.Maui; using Microsoft.Maui.ApplicationModel; @@ -27,7 +29,8 @@ public App( Func navigationPageFactory, BackgroundService backgroundService, IThemeService themeService, - ILocalizationService localizationService) + ILocalizationService localizationService, + ITranslationService translationService) { InitializeComponent(); @@ -49,7 +52,10 @@ public App( localizationService.ApplyCurrentLanguage(); themeService.ApplyCurrentTheme(); - } + + // set Converter in Application.Resources + Resources["NoAuthTokenToPlaceholderConverter"] = new NoAuthTokenToPlaceholderConverter(translationService); + } internal void ReloadRootPage() { diff --git a/BrickController2/BrickController2/PlatformServices/ModelContextProtocol/IMcpServerService.cs b/BrickController2/BrickController2/PlatformServices/ModelContextProtocol/IMcpServerService.cs new file mode 100644 index 00000000..72992b47 --- /dev/null +++ b/BrickController2/BrickController2/PlatformServices/ModelContextProtocol/IMcpServerService.cs @@ -0,0 +1,27 @@ +namespace BrickController2.PlatformServices.ModelContextProtocol; + +/// +/// Interface for managing the MCP (Model Context Protocol) server service. +/// +public interface IMcpServerService +{ + /// + /// Gets a value indicating whether the MCP server is currently available. + /// + bool IsMcpServerAvailable { get; } + + /// + /// Gets or sets a value indicating whether the MCP server is enabled. + /// + bool IsMcpServerEnabled { get; set; } + + /// + /// Gets or sets the port number for the MCP server. + /// + int McpServerPort { get; set; } + + /// + /// Gets or sets the authentication token for the MCP server. + /// + string McpServerAuthToken { get; set; } +} diff --git a/BrickController2/BrickController2/PlatformServices/ModelContextProtocol/McpServerBase.cs b/BrickController2/BrickController2/PlatformServices/ModelContextProtocol/McpServerBase.cs new file mode 100644 index 00000000..fdf73684 --- /dev/null +++ b/BrickController2/BrickController2/PlatformServices/ModelContextProtocol/McpServerBase.cs @@ -0,0 +1,10 @@ +namespace BrickController2.PlatformServices.ModelContextProtocol; + +public abstract class McpServerBase +{ + public const int PortMin = 1; + public const int PortMax = 65535; + public const int PortDefault = 5000; + + public const string AuthTokenDefault = ""; +} diff --git a/BrickController2/BrickController2/Resources/TranslationResources.de.resx b/BrickController2/BrickController2/Resources/TranslationResources.de.resx index 7d0be6f7..f7978941 100644 --- a/BrickController2/BrickController2/Resources/TranslationResources.de.resx +++ b/BrickController2/BrickController2/Resources/TranslationResources.de.resx @@ -717,4 +717,13 @@ Sprache + + Passwort + + + Passwort oder leer + + + kein Passwort + \ No newline at end of file diff --git a/BrickController2/BrickController2/Resources/TranslationResources.resx b/BrickController2/BrickController2/Resources/TranslationResources.resx index 250fe5c7..49c811f3 100644 --- a/BrickController2/BrickController2/Resources/TranslationResources.resx +++ b/BrickController2/BrickController2/Resources/TranslationResources.resx @@ -717,4 +717,19 @@ Language + + MCP Server + + + Port + + + Password + + + Password or empty + + + no password + \ No newline at end of file diff --git a/BrickController2/BrickController2/UI/Converters/NoAuthTokenToPlaceholderConverter.cs b/BrickController2/BrickController2/UI/Converters/NoAuthTokenToPlaceholderConverter.cs new file mode 100644 index 00000000..6b8d8cc0 --- /dev/null +++ b/BrickController2/BrickController2/UI/Converters/NoAuthTokenToPlaceholderConverter.cs @@ -0,0 +1,28 @@ +using BrickController2.UI.Services.Translation; +using Microsoft.Maui.Controls; +using System; +using System.Globalization; + +namespace BrickController2.UI.Converters; + +/// +/// Converts an authentication token to a placeholder text if the token is null or empty. +/// +public class NoAuthTokenToPlaceholderConverter : IValueConverter +{ + private readonly ITranslationService _translationService; + + public NoAuthTokenToPlaceholderConverter(ITranslationService translationService) + { + _translationService = translationService; + } + + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var text = value as string; + return string.IsNullOrWhiteSpace(text) ? _translationService.Translate("NoAuthToken") : text; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => value; +} diff --git a/BrickController2/BrickController2/UI/Pages/SettingsPage.xaml b/BrickController2/BrickController2/UI/Pages/SettingsPage.xaml index fa6441e2..2d8ba70f 100644 --- a/BrickController2/BrickController2/UI/Pages/SettingsPage.xaml +++ b/BrickController2/BrickController2/UI/Pages/SettingsPage.xaml @@ -29,10 +29,14 @@ - - - - + + + + + + + + @@ -45,6 +49,19 @@