diff --git a/BrickController2/BrickController2.Tests/CreationManagement/CreationMacroReferencesTests.cs b/BrickController2/BrickController2.Tests/CreationManagement/CreationMacroReferencesTests.cs new file mode 100644 index 000000000..bdce47397 --- /dev/null +++ b/BrickController2/BrickController2.Tests/CreationManagement/CreationMacroReferencesTests.cs @@ -0,0 +1,95 @@ +using System.Collections.ObjectModel; +using BrickController2.CreationManagement; +using BrickController2.DeviceManagement.Macros; +using FluentAssertions; +using Xunit; + +namespace BrickController2.Tests.CreationManagement; + +public class CreationMacroReferencesTests +{ + [Fact] + public void GetMacroReferences_ReturnsEmpty_WhenNoMacroActions() + { + var creation = BuildCreation(new ControllerAction + { + DeviceId = "dev1", + ButtonType = ControllerButtonType.Sequence, + SequenceName = "seq" + }); + + creation.GetMacroReferences().Should().BeEmpty(); + } + + [Fact] + public void GetMacroReferences_ReturnsChannelScope_ForMacroActions() + { + var creation = BuildCreation(new ControllerAction + { + DeviceId = "dev1", + ButtonType = ControllerButtonType.Macro, + MacroId = "SetOutputLevel" + }); + + creation.GetMacroReferences().Should().ContainSingle() + .Which.Should().Be(("dev1", "SetOutputLevel", MacroScope.Channel)); + } + + [Fact] + public void GetMacroReferences_ReturnsDeviceScope_ForDeviceMacroActions() + { + var creation = BuildCreation(new ControllerAction + { + DeviceId = "dev1", + ButtonType = ControllerButtonType.DeviceMacro, + MacroId = "Reset" + }); + + creation.GetMacroReferences().Should().ContainSingle() + .Which.Should().Be(("dev1", "Reset", MacroScope.Device)); + } + + [Fact] + public void GetMacroReferences_DeduplicatesByDeviceMacroScope() + { + var creation = BuildCreation( + new ControllerAction { DeviceId = "dev1", ButtonType = ControllerButtonType.Macro, MacroId = "m" }, + new ControllerAction { DeviceId = "dev1", ButtonType = ControllerButtonType.Macro, MacroId = "m" }, + new ControllerAction { DeviceId = "dev1", ButtonType = ControllerButtonType.DeviceMacro, MacroId = "m" }); + + var references = creation.GetMacroReferences(); + + references.Should().HaveCount(2); + references.Should().Contain(("dev1", "m", MacroScope.Channel)); + references.Should().Contain(("dev1", "m", MacroScope.Device)); + } + + [Fact] + public void GetMacroReferences_IgnoresMacroActions_WithEmptyMacroId() + { + var creation = BuildCreation(new ControllerAction + { + DeviceId = "dev1", + ButtonType = ControllerButtonType.Macro, + MacroId = string.Empty + }); + + creation.GetMacroReferences().Should().BeEmpty(); + } + + private static Creation BuildCreation(params ControllerAction[] actions) + { + var controllerEvent = new ControllerEvent + { + ControllerActions = new ObservableCollection(actions) + }; + var profile = new ControllerProfile + { + ControllerEvents = new ObservableCollection { controllerEvent } + }; + return new Creation + { + ControllerProfiles = new ObservableCollection { profile } + }; + } +} diff --git a/BrickController2/BrickController2.Tests/DeviceManagement/BuWizz/BuWizzDeviceMacroTests.cs b/BrickController2/BrickController2.Tests/DeviceManagement/BuWizz/BuWizzDeviceMacroTests.cs new file mode 100644 index 000000000..5a33eafc2 --- /dev/null +++ b/BrickController2/BrickController2.Tests/DeviceManagement/BuWizz/BuWizzDeviceMacroTests.cs @@ -0,0 +1,110 @@ +using BrickController2.DeviceManagement; +using BrickController2.DeviceManagement.BuWizz; +using BrickController2.DeviceManagement.Macros; +using BrickController2.PlatformServices.BluetoothLE; +using BrickController2.Settings; +using FluentAssertions; +using Moq; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace BrickController2.Tests.DeviceManagement.BuWizz; + +public class BuWizzDeviceMacroTests +{ + [Fact] + public void BuWizzDevice_AvailableMacros_ReturnsSetOutputLevelWithThreeChoices() + { + var device = new TestBuWizzDevice(); + + device.SupportsMacros.Should().BeTrue(); + device.AvailableMacros.Should().ContainSingle(); + + var macro = device.AvailableMacros.Single(); + macro.Id.Should().Be("SetOutputLevel"); + macro.Scope.Should().Be(MacroScope.Device); + macro.Kind.Should().Be(MacroKind.OneShot); + macro.Choices.Select(c => c.BoxedValue).Should().Equal( + (int)BuWizzOutputLevels.Low, + (int)BuWizzOutputLevels.Normal, + (int)BuWizzOutputLevels.High); + } + + [Fact] + public async Task BuWizzDevice_ExecuteMacroAsync_SetOutputLevelMacro_UsesSelectedChoiceValue() + { + var device = new TestBuWizzDevice(); + + await device.ExecuteMacroAsync(new MacroInvocation("SetOutputLevel", (int)BuWizzOutputLevels.High, null), CancellationToken.None); + + device.LastSetOutputLevel.Should().Be((int)BuWizzOutputLevels.High); + } + + [Fact] + public void BuWizz2Device_AvailableMacros_ReturnsSetOutputLevelWithFourChoices() + { + var device = new TestBuWizz2Device(); + + device.SupportsMacros.Should().BeTrue(); + device.AvailableMacros.Should().ContainSingle(); + + var macro = device.AvailableMacros.Single(); + macro.Id.Should().Be("SetOutputLevel"); + macro.Scope.Should().Be(MacroScope.Device); + macro.Kind.Should().Be(MacroKind.OneShot); + macro.Choices.Select(c => c.BoxedValue).Should().Equal( + (int)BuWizz2OutputLevels.Low, + (int)BuWizz2OutputLevels.Normal, + (int)BuWizz2OutputLevels.High, + (int)BuWizz2OutputLevels.Ludicrous); + } + + [Fact] + public async Task BuWizz2Device_ExecuteMacroAsync_SetOutputLevelMacro_UsesSelectedChoiceValue() + { + var device = new TestBuWizz2Device(); + + await device.ExecuteMacroAsync(new MacroInvocation("SetOutputLevel", (int)BuWizz2OutputLevels.Ludicrous, null), CancellationToken.None); + + device.LastSetOutputLevel.Should().Be((int)BuWizz2OutputLevels.Ludicrous); + } + + private sealed class TestBuWizzDevice : BuWizzDevice + { + public TestBuWizzDevice() + : base("test", "addr", new List(), + new Mock().Object, + new Mock().Object) + { + } + + public int? LastSetOutputLevel { get; private set; } + + public override void SetOutputLevel(int value) + { + LastSetOutputLevel = value; + base.SetOutputLevel(value); + } + } + + private sealed class TestBuWizz2Device : BuWizz2Device + { + public TestBuWizz2Device() + : base("test", "addr", [0x4e, 0x05, 0x42, 0x57, 0x00, 0x1b], new List(), + new Mock().Object, + new Mock().Object) + { + } + + public int? LastSetOutputLevel { get; private set; } + + public override void SetOutputLevel(int value) + { + LastSetOutputLevel = value; + base.SetOutputLevel(value); + } + } +} diff --git a/BrickController2/BrickController2/BusinessLogic/CreationValidationResult.cs b/BrickController2/BrickController2/BusinessLogic/CreationValidationResult.cs index ced816c96..55b4f8990 100644 --- a/BrickController2/BrickController2/BusinessLogic/CreationValidationResult.cs +++ b/BrickController2/BrickController2/BusinessLogic/CreationValidationResult.cs @@ -5,6 +5,7 @@ public enum CreationValidationResult Ok, MissingControllerAction, MissingDevice, - MissingSequence + MissingSequence, + MissingMacro, } } diff --git a/BrickController2/BrickController2/BusinessLogic/PlayLogic.cs b/BrickController2/BrickController2/BusinessLogic/PlayLogic.cs index 10621a262..feb067938 100644 --- a/BrickController2/BrickController2/BusinessLogic/PlayLogic.cs +++ b/BrickController2/BrickController2/BusinessLogic/PlayLogic.cs @@ -3,6 +3,7 @@ using System.Linq; using BrickController2.CreationManagement; using BrickController2.DeviceManagement; +using BrickController2.DeviceManagement.Macros; using BrickController2.PlatformServices.InputDevice; using static BrickController2.PlatformServices.InputDevice.InputDevices; @@ -36,6 +37,7 @@ public CreationValidationResult ValidateCreation(Creation creation) { var deviceIds = creation.GetDeviceIds(); var sequenceNames = creation.GetSequenceNames(); + var macroReferences = creation.GetMacroReferences(); if (deviceIds.Count == 0) { @@ -49,6 +51,14 @@ public CreationValidationResult ValidateCreation(Creation creation) { return CreationValidationResult.MissingSequence; } + else if (macroReferences.Any(mr => + { + var device = _deviceManager.GetDeviceById(mr.DeviceId); + return device == null || !device.AvailableMacros.Any(m => m.Id == mr.MacroId && m.Scope == mr.Scope); + })) + { + return CreationValidationResult.MissingMacro; + } return CreationValidationResult.Ok; } @@ -56,9 +66,27 @@ public CreationValidationResult ValidateCreation(Creation creation) public bool ValidateControllerAction(ControllerAction controllerAction) { var device = _deviceManager.GetDeviceById(controllerAction.DeviceId); - var sequence = _creationManager.Sequences.FirstOrDefault(s => s.Name == controllerAction.SequenceName); + if (device == null) + { + return false; + } - return device != null && (controllerAction.ButtonType != ControllerButtonType.Sequence || sequence != null); + if (controllerAction.ButtonType == ControllerButtonType.Sequence) + { + return _creationManager.Sequences.FirstOrDefault(s => s.Name == controllerAction.SequenceName) != null; + } + + if (controllerAction.ButtonType == ControllerButtonType.Macro) + { + return device.AvailableMacros.Any(m => m.Id == controllerAction.MacroId && m.Scope == MacroScope.Channel); + } + + if (controllerAction.ButtonType == ControllerButtonType.DeviceMacro) + { + return device.AvailableMacros.Any(m => m.Id == controllerAction.MacroId && m.Scope == MacroScope.Device); + } + + return true; } public void StartPlay() @@ -128,6 +156,29 @@ private static bool ShouldProcessButtonEvent(bool isPressed, ControllerAction co return controllerAction.ButtonType == ControllerButtonType.Normal || isPressed; } + private static void InvokeMacro(ControllerAction controllerAction, Device device, MacroScope scope) + { + var macro = device.AvailableMacros.FirstOrDefault(m => m.Id == controllerAction.MacroId && m.Scope == scope); + if (macro == null) + { + return; + } + + int? channel = scope == MacroScope.Channel ? controllerAction.Channel : null; + var invocation = new MacroInvocation(macro.Id, controllerAction.MacroChoiceValue, channel); + _ = System.Threading.Tasks.Task.Run(async () => + { + try + { + await device.ExecuteMacroAsync(invocation, System.Threading.CancellationToken.None); + } + catch + { + // fire-and-forget: swallow macro execution errors + } + }); + } + private float ProcessButtonEvent(bool isPressed, ControllerAction controllerAction, Device device) { var previousOutputs = GetPreviousOutputs(controllerAction); @@ -192,6 +243,20 @@ private float ProcessButtonEvent(bool isPressed, ControllerAction controllerActi _sequencePlayer.ToggleSequence(controllerAction.DeviceId, controllerAction.Channel, controllerAction.IsInvert, sequence); } break; + + case ControllerButtonType.Macro: + if (isPressed) + { + InvokeMacro(controllerAction, device, MacroScope.Channel); + } + break; + + case ControllerButtonType.DeviceMacro: + if (isPressed) + { + InvokeMacro(controllerAction, device, MacroScope.Device); + } + break; } SetPreviousOutput(controllerAction, currentOutput); diff --git a/BrickController2/BrickController2/CreationManagement/ControllerAction.cs b/BrickController2/BrickController2/CreationManagement/ControllerAction.cs index 9b6404a3c..3b7cffae1 100644 --- a/BrickController2/BrickController2/CreationManagement/ControllerAction.cs +++ b/BrickController2/BrickController2/CreationManagement/ControllerAction.cs @@ -21,6 +21,8 @@ public class ControllerAction : NotifyPropertyChangedSource private int _servoBaseAngle; private int _stepperAngle; private string _sequenceName = string.Empty; + private string _macroId = string.Empty; + private object? _macroChoiceValue; [PrimaryKey, AutoIncrement] [JsonIgnore] @@ -118,6 +120,18 @@ public string SequenceName set { _sequenceName = value; RaisePropertyChanged(); } } + public string MacroId + { + get { return _macroId; } + set { _macroId = value; RaisePropertyChanged(); } + } + + public object? MacroChoiceValue + { + get { return _macroChoiceValue; } + set { _macroChoiceValue = value; RaisePropertyChanged(); } + } + public override string ToString() { return $"{DeviceId} - {Channel}"; diff --git a/BrickController2/BrickController2/CreationManagement/ControllerButtonType.cs b/BrickController2/BrickController2/CreationManagement/ControllerButtonType.cs index 8472baba6..84e1e489f 100644 --- a/BrickController2/BrickController2/CreationManagement/ControllerButtonType.cs +++ b/BrickController2/BrickController2/CreationManagement/ControllerButtonType.cs @@ -9,6 +9,8 @@ public enum ControllerButtonType PingPong, Stop, Accelerator, - Sequence + Sequence, + Macro, + DeviceMacro, } } diff --git a/BrickController2/BrickController2/CreationManagement/Creation.cs b/BrickController2/BrickController2/CreationManagement/Creation.cs index 211456592..4de890d0c 100644 --- a/BrickController2/BrickController2/CreationManagement/Creation.cs +++ b/BrickController2/BrickController2/CreationManagement/Creation.cs @@ -5,6 +5,7 @@ using SQLiteNetExtensions.Attributes; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; namespace BrickController2.CreationManagement { @@ -78,5 +79,31 @@ public IReadOnlySet GetSequenceNames() return sequenceNames; } + + public IReadOnlyCollection<(string DeviceId, string MacroId, DeviceManagement.Macros.MacroScope Scope)> GetMacroReferences() + { + var macroReferences = new HashSet<(string, string, DeviceManagement.Macros.MacroScope)>(); + + foreach (var profile in ControllerProfiles) + { + foreach (var controllerEvent in profile.ControllerEvents) + { + foreach (var controllerAction in controllerEvent.ControllerActions + .Where(x => !string.IsNullOrEmpty(x.MacroId))) + { + if (controllerAction.ButtonType == ControllerButtonType.Macro) + { + macroReferences.Add((controllerAction.DeviceId, controllerAction.MacroId, DeviceManagement.Macros.MacroScope.Channel)); + } + else if (controllerAction.ButtonType == ControllerButtonType.DeviceMacro) + { + macroReferences.Add((controllerAction.DeviceId, controllerAction.MacroId, DeviceManagement.Macros.MacroScope.Device)); + } + } + } + } + + return macroReferences; + } } } diff --git a/BrickController2/BrickController2/CreationManagement/CreationManager.cs b/BrickController2/BrickController2/CreationManagement/CreationManager.cs index 6ac7bec6c..39b08a953 100644 --- a/BrickController2/BrickController2/CreationManagement/CreationManager.cs +++ b/BrickController2/BrickController2/CreationManagement/CreationManager.cs @@ -253,7 +253,9 @@ public async Task AddOrUpdateControllerActionAsync( int maxServoAngle, int servoBaseAngle, int stepperAngle, - string sequenceName) + string sequenceName, + string macroId, + object? macroChoiceValue) { using (await _asyncLock.LockAsync()) { @@ -272,6 +274,8 @@ public async Task AddOrUpdateControllerActionAsync( controllerAction.ServoBaseAngle = servoBaseAngle; controllerAction.StepperAngle = stepperAngle; controllerAction.SequenceName = sequenceName; + controllerAction.MacroId = macroId; + controllerAction.MacroChoiceValue = macroChoiceValue; await _creationRepository.UpdateControllerActionAsync(controllerAction); } else @@ -291,7 +295,9 @@ public async Task AddOrUpdateControllerActionAsync( MaxServoAngle = maxServoAngle, ServoBaseAngle = servoBaseAngle, StepperAngle = stepperAngle, - SequenceName = sequenceName + SequenceName = sequenceName, + MacroId = macroId, + MacroChoiceValue = macroChoiceValue }; await _creationRepository.InsertControllerActionAsync(controllerEvent, controllerAction); } @@ -325,7 +331,9 @@ public async Task UpdateControllerActionAsync( int maxServoAngle, int servoBaseAngle, int stepperAngle, - string sequenceName) + string sequenceName, + string macroId, + object? macroChoiceValue) { using (await _asyncLock.LockAsync()) { @@ -351,6 +359,8 @@ public async Task UpdateControllerActionAsync( controllerAction.ServoBaseAngle = servoBaseAngle; controllerAction.StepperAngle = stepperAngle; controllerAction.SequenceName = sequenceName; + controllerAction.MacroId = macroId; + controllerAction.MacroChoiceValue = macroChoiceValue; await _creationRepository.UpdateControllerActionAsync(controllerAction); } } @@ -463,7 +473,9 @@ await UpdateControllerActionAsync( controllerAction.MaxServoAngle, controllerAction.ServoBaseAngle, controllerAction.StepperAngle, - sequenceName); + sequenceName, + controllerAction.MacroId, + controllerAction.MacroChoiceValue); } } } diff --git a/BrickController2/BrickController2/CreationManagement/ICreationManager.cs b/BrickController2/BrickController2/CreationManagement/ICreationManager.cs index 98200db60..2a2958f64 100644 --- a/BrickController2/BrickController2/CreationManagement/ICreationManager.cs +++ b/BrickController2/BrickController2/CreationManagement/ICreationManager.cs @@ -45,7 +45,9 @@ Task AddOrUpdateControllerActionAsync( int maxServoAngle, int servoBaseAngle, int stepperAngle, - string sequenceName); + string sequenceName, + string macroId, + object? macroChoiceValue); Task DeleteControllerActionAsync(ControllerAction controllerAction); Task UpdateControllerActionAsync( ControllerAction controllerAction, @@ -62,7 +64,9 @@ Task UpdateControllerActionAsync( int maxServoAngle, int servoBaseAngle, int stepperAngle, - string sequenceName); + string sequenceName, + string macroId, + object? macroChoiceValue); Task ImportSequenceAsync(string sequenceFilename); Task ImportSequenceAsync(Sequence sequence); diff --git a/BrickController2/BrickController2/DeviceManagement/BuWizz2Device.cs b/BrickController2/BrickController2/DeviceManagement/BuWizz2Device.cs index 143022a5d..72d9193bc 100644 --- a/BrickController2/BrickController2/DeviceManagement/BuWizz2Device.cs +++ b/BrickController2/BrickController2/DeviceManagement/BuWizz2Device.cs @@ -1,5 +1,6 @@ using BrickController2.DeviceManagement.BuWizz; using BrickController2.DeviceManagement.IO; +using BrickController2.DeviceManagement.Macros; using BrickController2.Helpers; using BrickController2.PlatformServices.BluetoothLE; using BrickController2.Settings; @@ -23,6 +24,22 @@ internal class BuWizz2Device : BluetoothDevice, IDeviceType private const string SwapChannelsSettingName = "BuWizz2SwapChannels"; private const string DefaultOutputLevelName = "BuWizz2DefaultOutputLevel"; private const BuWizz2OutputLevels DefaultLevel = BuWizz2OutputLevels.Normal; + private const string SetOutputLevelMacroId = "SetOutputLevel"; + + private static readonly IReadOnlyList Macros = + [ + new MacroDescriptor( + SetOutputLevelMacroId, + "Macro_SetOutputLevel", + MacroScope.Device, + MacroKind.OneShot, + [ + new MacroChoice("MacroChoice_BuWizz_Low", (int)BuWizz2OutputLevels.Low), + new MacroChoice("MacroChoice_BuWizz_Normal", (int)BuWizz2OutputLevels.Normal), + new MacroChoice("MacroChoice_BuWizz_High", (int)BuWizz2OutputLevels.High), + new MacroChoice("MacroChoice_BuWizz_Ludicrous", (int)BuWizz2OutputLevels.Ludicrous), + ]) + ]; private readonly OutputValuesGroup _outputGroup = new(4); @@ -74,12 +91,26 @@ public override void SetOutput(int channel, float value) } public override bool CanSetOutputLevel => true; + public override bool SupportsMacros => true; + public override IReadOnlyList AvailableMacros => Macros; public override void SetOutputLevel(int value) { _outputLevelValue = Math.Max(0, Math.Min(NumberOfOutputLevels - 1, value)); } + public override Task ExecuteMacroAsync(MacroInvocation invocation, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + + if (invocation.DescriptorId == SetOutputLevelMacroId && invocation.ChoiceValue is int intValue) + { + SetOutputLevel(intValue); + } + + return Task.CompletedTask; + } + public override bool CanBePowerSource => true; protected override async Task ValidateServicesAsync(IEnumerable? services, CancellationToken token) diff --git a/BrickController2/BrickController2/DeviceManagement/BuwizzDevice.cs b/BrickController2/BrickController2/DeviceManagement/BuwizzDevice.cs index ea1dae5c5..cd6c08917 100644 --- a/BrickController2/BrickController2/DeviceManagement/BuwizzDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/BuwizzDevice.cs @@ -1,5 +1,6 @@ using BrickController2.DeviceManagement.BuWizz; using BrickController2.DeviceManagement.IO; +using BrickController2.DeviceManagement.Macros; using BrickController2.PlatformServices.BluetoothLE; using BrickController2.Settings; using System; @@ -20,6 +21,21 @@ internal class BuWizzDevice : BluetoothDevice private const string DefaultOutputLevelName = "BuWizzDefaultOutputLevel"; private const BuWizzOutputLevels DefaultLevel = BuWizzOutputLevels.Normal; + private const string SetOutputLevelMacroId = "SetOutputLevel"; + + private static readonly IReadOnlyList Macros = + [ + new MacroDescriptor( + SetOutputLevelMacroId, + "Macro_SetOutputLevel", + MacroScope.Device, + MacroKind.OneShot, + [ + new MacroChoice("MacroChoice_BuWizz_Low", (int)BuWizzOutputLevels.Low), + new MacroChoice("MacroChoice_BuWizz_Normal", (int)BuWizzOutputLevels.Normal), + new MacroChoice("MacroChoice_BuWizz_High", (int)BuWizzOutputLevels.High) + ]) + ]; private readonly OutputValuesGroup _outputGroup = new(5); @@ -52,6 +68,8 @@ public override void SetOutput(int channel, float value) } public override bool CanSetOutputLevel => true; + public override bool SupportsMacros => true; + public override IReadOnlyList AvailableMacros => Macros; public override void SetOutputLevel(int value) { @@ -59,6 +77,18 @@ public override void SetOutputLevel(int value) _outputGroup.SetOutput(4, outputLevelValue); } + public override Task ExecuteMacroAsync(MacroInvocation invocation, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + + if (invocation.DescriptorId == SetOutputLevelMacroId && invocation.ChoiceValue is int intValue) + { + SetOutputLevel(intValue); + } + + return Task.CompletedTask; + } + public override bool CanBePowerSource => true; protected override Task ValidateServicesAsync(IEnumerable? services, CancellationToken token) diff --git a/BrickController2/BrickController2/DeviceManagement/Device.cs b/BrickController2/BrickController2/DeviceManagement/Device.cs index 336b74b97..491fb927f 100644 --- a/BrickController2/BrickController2/DeviceManagement/Device.cs +++ b/BrickController2/BrickController2/DeviceManagement/Device.cs @@ -1,5 +1,6 @@ using BrickController2.CreationManagement; using BrickController2.Helpers; +using BrickController2.DeviceManagement.Macros; using BrickController2.Settings; using System; using System.Collections.Generic; @@ -100,6 +101,10 @@ public abstract Task ConnectAsync( public virtual bool CanSetOutputLevel => false; public virtual void SetOutputLevel(int value) { } + public virtual bool SupportsMacros => false; + public virtual IReadOnlyList AvailableMacros => []; + public virtual Task ExecuteMacroAsync(MacroInvocation invocation, CancellationToken token) => Task.CompletedTask; + public virtual bool CanResetOutput(int channel) => false; public virtual Task ResetOutputAsync(int channel, float value, CancellationToken token) { diff --git a/BrickController2/BrickController2/DeviceManagement/Macros/MacroChoice.cs b/BrickController2/BrickController2/DeviceManagement/Macros/MacroChoice.cs new file mode 100644 index 000000000..dd37b0ad2 --- /dev/null +++ b/BrickController2/BrickController2/DeviceManagement/Macros/MacroChoice.cs @@ -0,0 +1,22 @@ +namespace BrickController2.DeviceManagement.Macros; + +/// +/// Base type for a selectable macro choice. Create instances via , +/// e.g. new MacroChoice<int>(labelKey, 42) or new MacroChoice<string>(labelKey, "foo"). +/// +public abstract record MacroChoice(string LabelKey) +{ + /// + /// The choice value, boxed. Prefer the strongly-typed when the + /// concrete choice type is known at the call site. + /// + public abstract object BoxedValue { get; } +} + +/// +/// A strongly-typed macro choice. +/// +public sealed record MacroChoice(string LabelKey, T Value) : MacroChoice(LabelKey) +{ + public override object BoxedValue => Value!; +} diff --git a/BrickController2/BrickController2/DeviceManagement/Macros/MacroDescriptor.cs b/BrickController2/BrickController2/DeviceManagement/Macros/MacroDescriptor.cs new file mode 100644 index 000000000..8c40d8d8d --- /dev/null +++ b/BrickController2/BrickController2/DeviceManagement/Macros/MacroDescriptor.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace BrickController2.DeviceManagement.Macros; + +public sealed record MacroDescriptor +{ + public MacroDescriptor( + string id, + string nameKey, + MacroScope scope, + MacroKind kind, + IReadOnlyList? choices = null) + { + Id = id; + NameKey = nameKey; + Scope = scope; + Kind = kind; + Choices = choices ?? []; + } + + public string Id { get; } + public string NameKey { get; } + public MacroScope Scope { get; } + public MacroKind Kind { get; } + public IReadOnlyList Choices { get; } +} diff --git a/BrickController2/BrickController2/DeviceManagement/Macros/MacroInvocation.cs b/BrickController2/BrickController2/DeviceManagement/Macros/MacroInvocation.cs new file mode 100644 index 000000000..2fbd6a613 --- /dev/null +++ b/BrickController2/BrickController2/DeviceManagement/Macros/MacroInvocation.cs @@ -0,0 +1,3 @@ +namespace BrickController2.DeviceManagement.Macros; + +public readonly record struct MacroInvocation(string DescriptorId, object? ChoiceValue, int? Channel); diff --git a/BrickController2/BrickController2/DeviceManagement/Macros/MacroKind.cs b/BrickController2/BrickController2/DeviceManagement/Macros/MacroKind.cs new file mode 100644 index 000000000..04a0e38f5 --- /dev/null +++ b/BrickController2/BrickController2/DeviceManagement/Macros/MacroKind.cs @@ -0,0 +1,8 @@ +namespace BrickController2.DeviceManagement.Macros; + +public enum MacroKind +{ + OneShot, + Repeatable, + Continuous +} diff --git a/BrickController2/BrickController2/DeviceManagement/Macros/MacroScope.cs b/BrickController2/BrickController2/DeviceManagement/Macros/MacroScope.cs new file mode 100644 index 000000000..5c9b24b19 --- /dev/null +++ b/BrickController2/BrickController2/DeviceManagement/Macros/MacroScope.cs @@ -0,0 +1,10 @@ +namespace BrickController2.DeviceManagement.Macros; + +/// +/// Defines scope of a macro, i.e. whether it is defined for a device or for a channel. +/// +public enum MacroScope +{ + Device, + Channel +} diff --git a/BrickController2/BrickController2/DeviceManagement/PfxBrickDevice.cs b/BrickController2/BrickController2/DeviceManagement/PfxBrickDevice.cs index ae702cd3b..b02247244 100644 --- a/BrickController2/BrickController2/DeviceManagement/PfxBrickDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/PfxBrickDevice.cs @@ -1,6 +1,8 @@ using BrickController2.DeviceManagement.IO; +using BrickController2.DeviceManagement.Macros; using BrickController2.PlatformServices.BluetoothLE; using BrickController2.Protocols; +using BrickController2.Settings; using System; using System.Collections.Generic; using System.Linq; @@ -13,6 +15,11 @@ internal class PfxBrickDevice : BluetoothDevice { private const int PF_CHANNELS = 2; private const int LIGHT_CHANNELS = 8; + private const string DefaultStartupVolume = "PfxBrickDefaultVolume"; + private const double DefaultVolumeValue = 0.8f; + private const string PlaySoundMacroId = "PlaySound"; + private const string PlaySoundMacroNameKey = "PfxPlaySoundMacroName"; + private static readonly Guid SERVICE_UUID = new("49535343-fe7d-4ae5-8fa9-9fafd205e455"); private static readonly Guid CHARACTERISTIC_UUID_WRITE = new("49535343-8841-43f4-a8d4-ecbe34729bb3"); @@ -20,19 +27,33 @@ internal class PfxBrickDevice : BluetoothDevice private readonly OutputValuesGroup _motorOutputs = new(PF_CHANNELS); private readonly OutputValuesGroup _lightOutputs = new(LIGHT_CHANNELS); + private readonly List _macros = []; + private readonly Dictionary _macroFileIds = []; // MacroChoice.Value (file id as string) -> PFx File ID private IGattCharacteristic? _writeCharacteristic; private IGattCharacteristic? _notifyCharacteristic; - public PfxBrickDevice(string name, string address, IDeviceRepository deviceRepository, IBluetoothLEService bleService) + private TaskCompletionSource? _fileDirTcs; + + private int _filesCount; + + public PfxBrickDevice(string name, string address, IEnumerable settings, IDeviceRepository deviceRepository, IBluetoothLEService bleService) : base(name, address, deviceRepository, bleService) { + // apply values (if any) or default + SetSettingValue(DefaultStartupVolume, settings, DefaultVolume); } public override DeviceType DeviceType => DeviceType.PfxBrick; public override int NumberOfChannels => 10; + public override bool SupportsMacros => true; + + public override IReadOnlyList AvailableMacros => _macros; + + public double DefaultVolume => GetSettingValue(DefaultStartupVolume, DefaultVolumeValue); + protected override bool AutoConnectOnFirstConnect => false; public override void SetOutput(int channel, float value) @@ -55,6 +76,20 @@ public override void SetOutput(int channel, float value) } } + public override Task ExecuteMacroAsync(MacroInvocation invocation, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + + if (invocation.DescriptorId == PlaySoundMacroId + && invocation.ChoiceValue is string fileName + && _macroFileIds.TryGetValue(fileName, out var fileId)) + { + return WriteCommandAsync(PfxProtocol.PlaySoundFile(fileId), token); + } + + return Task.CompletedTask; + } + protected override async Task ValidateServicesAsync(IEnumerable? services, CancellationToken token) { var service = services?.FirstOrDefault(s => s.Uuid == SERVICE_UUID); @@ -74,7 +109,14 @@ protected override void OnCharacteristicChanged(Guid characteristicGuid, byte[] if (characteristicGuid != _notifyCharacteristic?.Uuid || data.Length == 0) return; - if (data.Length == 1) // notification + // check opcode first: file directory responses may be padded/truncated to the same + // fixed BLE notification frame size as status packets (e.g. 48 bytes), so length alone + // isn't a reliable discriminator. + if (data[0] == PfxProtocol.RSP_FILE_DIR) + { + _fileDirTcs?.TrySetResult(data); + } + else if (data.Length == 1) // notification { } else if (data.Length == 48) // status @@ -105,6 +147,7 @@ protected override async Task AfterConnectSetupAsync(bool requestDeviceInf if (requestDeviceInformation) { await ReadDeviceInfo(token); + await GetAvailableMacros(token); } } catch { } @@ -207,4 +250,55 @@ private async Task ReadDeviceInfo(CancellationToken token) // request status update await _bleDevice!.WriteAsync(_writeCharacteristic!, PfxProtocol.GetStatus(), token); } + + private async Task GetAvailableMacros(CancellationToken token) + { + const int MaxDirectorySlots = 64; // reference implementation caps the directory scan at 64 slots + const int FirstDirectoryIndex = 1; // directory index 0 is never a valid file slot + + _macros.Clear(); + _macroFileIds.Clear(); + + var countResponse = await RequestFileDirAsync(PfxProtocol.GetFileCount(), token); + _filesCount = PfxProtocol.ParseFileCount(countResponse) ?? 0; + + var foundCount = 0; + var choices = new List(); + + for (var i = FirstDirectoryIndex; i <= MaxDirectorySlots && foundCount < _filesCount; i++) + { + var entryResponse = await RequestFileDirAsync(PfxProtocol.GetDirEntryAtIndex((byte)i), token); + var entry = PfxProtocol.ParseFileDirEntry(entryResponse); + if (entry is null || !PfxProtocol.IsValidFileDirEntry(entry.Value)) + { + continue; // empty/unused directory slot + } + + foundCount++; + + var soundId = entry.Value.FileId.ToString(); + _macroFileIds[soundId] = (byte)entry.Value.FileId; + + choices.Add(new MacroChoice(entry.Value.FileName, soundId)); + } + + _macros.Add(new MacroDescriptor( + id: PlaySoundMacroId, + nameKey: PlaySoundMacroNameKey, + scope: MacroScope.Device, + kind: MacroKind.Repeatable, + choices: choices)); + } + + private async Task RequestFileDirAsync(byte[] command, CancellationToken token, int timeoutMs = 2000) + { + _fileDirTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await WriteCommandAsync(command, token); + + using var timeoutCts = new CancellationTokenSource(timeoutMs); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(token, timeoutCts.Token); + using var reg = linkedCts.Token.Register(() => _fileDirTcs.TrySetCanceled(linkedCts.Token)); + + return await _fileDirTcs.Task; + } } diff --git a/BrickController2/BrickController2/Protocols/PfxFileDirEntry.cs b/BrickController2/BrickController2/Protocols/PfxFileDirEntry.cs new file mode 100644 index 000000000..766854c0a --- /dev/null +++ b/BrickController2/BrickController2/Protocols/PfxFileDirEntry.cs @@ -0,0 +1,11 @@ +namespace BrickController2.Protocols; + +internal readonly record struct PfxFileDirEntry( + ushort FileId, + uint FileSize, + ushort FirstSector, + ushort Attributes, + uint UserData1, + uint UserData2, + uint Crc32, + string FileName); diff --git a/BrickController2/BrickController2/Protocols/PfxProtocol.cs b/BrickController2/BrickController2/Protocols/PfxProtocol.cs index 48275cb2e..21fae0acb 100644 --- a/BrickController2/BrickController2/Protocols/PfxProtocol.cs +++ b/BrickController2/BrickController2/Protocols/PfxProtocol.cs @@ -1,12 +1,23 @@ using System; +using System.Buffers.Binary; +using System.Text; namespace BrickController2.Protocols; internal static class PfxProtocol { + public const byte CMD_FILE_DIR = 0x45; + public const byte RSP_FILE_DIR = 0xC5; // CMD_FILE_DIR | 0x80 (response/ack opcode) public const byte CMD_PRE_DELIMITER = 0x5B; public const byte CMD_POST_DELIMITER = 0x5D; + // File directory request codes + public const byte PFX_DIR_REQ_GET_FILE_COUNT = 0x00; + public const byte PFX_DIR_REQ_GET_FREE_SPACE = 0x01; + public const byte PFX_DIR_REQ_GET_DIR_ENTRY_IDX = 0x02; + public const byte PFX_DIR_REQ_GET_DIR_ENTRY_ID = 0x03; + public const byte PFX_DIR_REQ_GET_NAMED_FILE_ID = 0x0B; + public const byte CMD_GET_STATUS = 0x01; public const byte CMD_TEST_ACTION = 0x13; @@ -57,6 +68,28 @@ internal static class PfxProtocol public const byte EVT_LIGHTFX_TRANSITION_ON = 0x01; public const byte EVT_LIGHTFX_TRANSITION_OFF = 0x02; + // Sound FX IDs (SOUND_FX_ID, section 6.2.14) + public const byte SOUNDFX_NONE = 0x00; + public const byte SOUNDFX_INC_VOLUME = 0x01; + public const byte SOUNDFX_DEC_VOLUME = 0x02; + public const byte SOUNDFX_SET_VOLUME = 0x03; + public const byte SOUNDFX_PLAY_ONCE = 0x04; + public const byte SOUNDFX_PLAY_CONTINUOUS = 0x05; + public const byte SOUNDFX_PLAY_NTIMES = 0x06; + public const byte SOUNDFX_PLAY_DURATION = 0x07; + public const byte SOUNDFX_PLAY_PITCHBEND_MOTOR = 0x08; + public const byte SOUNDFX_PLAY_GATED_MOTOR = 0x09; + public const byte SOUNDFX_PLAY_AM_MOTOR = 0x0A; + public const byte SOUNDFX_STOP = 0x0B; + public const byte SOUNDFX_PLAY_IDX_MOTOR = 0x0C; + public const byte SOUNDFX_PLAY_RAND = 0x0D; + public const byte SOUNDFX_FILE_SEEK = 0x0E; + public const byte SOUNDFX_FILE_SCRUB = 0x0F; + + // SOUNDFX_PLAY_ONCE / RETRIGGER (SOUND_PARAM1) + public const byte SOUNDFX_RETRIGGER_TOGGLE = 0x00; + public const byte SOUNDFX_RETRIGGER_RESTART = 0x01; + /// /// Set speed of the selected channel /// @@ -106,6 +139,105 @@ public static byte[] SetLight(byte lightOutputMask, byte value) lightOutputMask: lightOutputMask, lightParam4: value); + /// + /// Request the total number of files stored on the PFx Brick file system. + /// + public static byte[] GetFileCount() => [CMD_PRE_DELIMITER, CMD_PRE_DELIMITER, CMD_PRE_DELIMITER, + CMD_FILE_DIR, PFX_DIR_REQ_GET_FILE_COUNT, + CMD_POST_DELIMITER, CMD_POST_DELIMITER, CMD_POST_DELIMITER]; + + /// + /// Request the directory entry (file id, size, name, ...) at the given index. + /// + public static byte[] GetDirEntryAtIndex(byte index) => [CMD_PRE_DELIMITER, CMD_PRE_DELIMITER, CMD_PRE_DELIMITER, + CMD_FILE_DIR, PFX_DIR_REQ_GET_DIR_ENTRY_IDX, index, + CMD_POST_DELIMITER, CMD_POST_DELIMITER, CMD_POST_DELIMITER]; + + /// + /// Parses a "Get Directory Entry" response (request 0x02 / 0x03) into a . + /// + /// + /// Confirmed against a real device response: fields are big-endian, and the layout matches the ICD + /// exactly (no extra echoed request-code byte, unlike the "Get File Count" response). An entry with + /// == 0xFFFF is an empty/unused directory slot. + /// + public static PfxFileDirEntry? ParseFileDirEntry(byte[] data) + { + const int FixedFieldsLength = 24; + const int MaxNameLength = 32; + + if (data.Length < FixedFieldsLength || data[0] != RSP_FILE_DIR) + { + return null; + } + + var fileId = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(2, 2)); + var fileSize = BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(4, 4)); + var firstSector = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(8, 2)); + var attributes = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(10, 2)); + var userData1 = BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(12, 4)); + var userData2 = BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(16, 4)); + var crc32 = BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(20, 4)); + + var nameLength = Math.Min(MaxNameLength, data.Length - FixedFieldsLength); + var name = nameLength > 0 + ? Encoding.UTF8.GetString(data, FixedFieldsLength, nameLength).TrimEnd('\0', ' ') + : string.Empty; + + return new PfxFileDirEntry(fileId, fileSize, firstSector, attributes, userData1, userData2, crc32, name); + } + + /// + /// An empty/unused directory slot is marked with == 0xFFFF. + /// + public static bool IsValidFileDirEntry(PfxFileDirEntry entry) => entry.FirstSector != 0xFFFF && !string.IsNullOrEmpty(entry.FileName); + + /// + /// Parses a "Get File Count" response. + /// + /// + /// The ICD documents a 4-byte response: [0xC5, RequestStatus, FileCount[15:0] (big-endian)]. + /// Observed device responses are 5 bytes: [0xC5, RequestStatus, 0x00, FileCount[15:0] (big-endian)], + /// with an extra byte at offset 2 that appears to echo the request sub-code (0x00 for GET_FILE_COUNT). + /// Reading the count from the last 2 bytes handles both layouts. + /// + public static ushort? ParseFileCount(byte[] data) + { + if (data.Length < 4 || data[0] != RSP_FILE_DIR) + { + return null; + } + + return BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(data.Length - 2, 2)); + } + + /// + /// Plays the sound file identified by a single time. + /// + /// PFx Brick file id (0-255). + /// + /// Behavior if the file is already playing when triggered again: + /// (toggle on/off) or (restart from beginning). + /// Defaults to restart, which is usually the expected behavior for a macro trigger. + /// + /// + /// 2's complement relative volume (dB gain/attenuation) applied from the current playback volume. Range: -8..7. Defaults to 0 (no change). + /// + public static byte[] PlaySoundFile(byte fileId, byte retrigger = SOUNDFX_RETRIGGER_RESTART, sbyte relativeVolume = 0) + => TestEventAction(EVT_COMMAND_NONE, + soundFxId: SOUNDFX_PLAY_ONCE, + soundFileId: fileId, + soundParam1: retrigger, + soundParam2: unchecked((byte)relativeVolume)); + + /// + /// Stops playback of the sound file identified by . + /// + public static byte[] StopSoundFile(byte fileId) + => TestEventAction(EVT_COMMAND_NONE, + soundFxId: SOUNDFX_STOP, + soundFileId: fileId); + /// /// Get the status of the device. /// @@ -132,7 +264,11 @@ public static byte[] TestEventAction(byte command, byte lightParam1 = 0x00, byte lightParam2 = 0x00, byte lightParam3 = 0x00, - byte lightParam4 = 0x00) + byte lightParam4 = 0x00, + byte soundFxId = 0x00, + byte soundFileId = 0x00, + byte soundParam1 = 0x00, + byte soundParam2 = 0x00) => [CMD_PRE_DELIMITER, CMD_PRE_DELIMITER, CMD_PRE_DELIMITER, CMD_TEST_ACTION, command, // command @@ -147,10 +283,10 @@ public static byte[] TestEventAction(byte command, lightParam3, // lightParam3; lightParam4, // lightParam4; 0x00, // lightParam5; - 0x00, // soundFxId; - 0x00, // soundFileId; - 0x00, // soundParam1; - 0x00, // soundParam2; + soundFxId, // soundFxId; + soundFileId, // soundFileId; + soundParam1, // soundParam1; + soundParam2, // soundParam2; CMD_POST_DELIMITER, CMD_POST_DELIMITER, CMD_POST_DELIMITER]; private static byte GetMotorParam(int speed) diff --git a/BrickController2/BrickController2/Resources/TranslationResources.de.resx b/BrickController2/BrickController2/Resources/TranslationResources.de.resx index 988b732ed..630acf668 100644 --- a/BrickController2/BrickController2/Resources/TranslationResources.de.resx +++ b/BrickController2/BrickController2/Resources/TranslationResources.de.resx @@ -210,6 +210,24 @@ BuWizz Ausgabestufe + + Geräteaktionen + + + Ausgabestufe setzen + + + Niedrig + + + Normal + + + Hoch + + + Ludicrous + Kalibriere... diff --git a/BrickController2/BrickController2/Resources/TranslationResources.hu.resx b/BrickController2/BrickController2/Resources/TranslationResources.hu.resx index 096efb94e..7942156aa 100644 --- a/BrickController2/BrickController2/Resources/TranslationResources.hu.resx +++ b/BrickController2/BrickController2/Resources/TranslationResources.hu.resx @@ -210,6 +210,24 @@ BuWizz kimeneti szint + + Eszközműveletek + + + Kimeneti szint beállítása + + + Alacsony + + + Normál + + + Magas + + + Ludicrous + Kalibrálás... diff --git a/BrickController2/BrickController2/Resources/TranslationResources.resx b/BrickController2/BrickController2/Resources/TranslationResources.resx index ed2e179bb..32cc033bf 100644 --- a/BrickController2/BrickController2/Resources/TranslationResources.resx +++ b/BrickController2/BrickController2/Resources/TranslationResources.resx @@ -210,6 +210,24 @@ BuWizz output level + + Device actions + + + Set output level + + + Low + + + Normal + + + High + + + Ludicrous + Calibrating... @@ -420,6 +438,36 @@ Missing sequence + + Missing macro + + + Macro + + + Value + + + Select a macro + + + Select a value + + + The selected device has no macros. + + + Select a macro before saving. + + + Device macro + + + Channel action + + + What do you want to bind? + No @@ -648,6 +696,15 @@ Applying... + + Macros + + + No macros available for this device. + + + Execution of the macro has failed: + Default output level diff --git a/BrickController2/BrickController2/UI/Controls/ExpandableFloatingActionButton.xaml b/BrickController2/BrickController2/UI/Controls/ExpandableFloatingActionButton.xaml new file mode 100644 index 000000000..760775fbc --- /dev/null +++ b/BrickController2/BrickController2/UI/Controls/ExpandableFloatingActionButton.xaml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/BrickController2/BrickController2/UI/Controls/ExpandableFloatingActionButton.xaml.cs b/BrickController2/BrickController2/UI/Controls/ExpandableFloatingActionButton.xaml.cs new file mode 100644 index 000000000..81157ad1f --- /dev/null +++ b/BrickController2/BrickController2/UI/Controls/ExpandableFloatingActionButton.xaml.cs @@ -0,0 +1,105 @@ +using Microsoft.Maui; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Graphics; +using System; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Threading.Tasks; + +namespace BrickController2.UI.Controls; + +[ContentProperty(nameof(SecondaryButtons))] +public partial class ExpandableFloatingActionButton : ContentView +{ + private bool _isMenuOpen = false; + + public ExpandableFloatingActionButton() + { + InitializeComponent(); + + SecondaryButtons.CollectionChanged += OnSecondaryButtonsChanged; + } + + public ObservableCollection SecondaryButtons { get; } = []; + + public static readonly BindableProperty FabIconProperty = + BindableProperty.Create(nameof(FabIcon), typeof(string), typeof(ExpandableFloatingActionButton), "+"); + + public string FabIcon + { + get => (string)GetValue(FabIconProperty); + set => SetValue(FabIconProperty, value); + } + + public static readonly BindableProperty FabColorProperty = + BindableProperty.Create(nameof(FabColor), typeof(Color), typeof(ExpandableFloatingActionButton), Colors.Blue); + + public Color FabColor + { + get => (Color)GetValue(FabColorProperty); + set => SetValue(FabColorProperty, value); + } + + private void OnFabClicked(object sender, EventArgs e) + { + _isMenuOpen = !_isMenuOpen; + AnimateMenu(); + } + + private void OnSecondaryButtonsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.NewItems != null) + { + foreach (IView view in e.NewItems) + { + SecondaryContainer.Children.Add(view); + } + } + + if (e.OldItems != null) + { + foreach (IView view in e.OldItems) + { + SecondaryContainer.Children.Remove(view); + } + } + } + + private void OnOverlayTapped(object sender, EventArgs e) + { + if (_isMenuOpen) + { + _isMenuOpen = false; + AnimateMenu(); + } + } + + private async void AnimateMenu() + { + if (_isMenuOpen) + { + // Make elements physically present before animating + Overlay.IsVisible = true; + SecondaryContainer.IsVisible = true; + + await Task.WhenAll( + SecondaryContainer.FadeToAsync(1, 250, Easing.CubicOut), + SecondaryContainer.TranslateToAsync(0, 0, 250, Easing.CubicOut), + Icon.RotateToAsync(45, 250, Easing.CubicOut) + ); + } + else + { + // Run closing animations + await Task.WhenAll( + SecondaryContainer.FadeToAsync(0, 250, Easing.CubicIn), + SecondaryContainer.TranslateToAsync(20, 0, 250, Easing.CubicIn), + Icon.RotateToAsync(0, 250, Easing.CubicIn) + ); + + // Hide elements entirely after animation finishes + Overlay.IsVisible = false; + SecondaryContainer.IsVisible = false; + } + } +} \ No newline at end of file diff --git a/BrickController2/BrickController2/UI/Pages/ControllerActionPage.xaml b/BrickController2/BrickController2/UI/Pages/ControllerActionPage.xaml index 2d80a2ec3..652e93cde 100644 --- a/BrickController2/BrickController2/UI/Pages/ControllerActionPage.xaml +++ b/BrickController2/BrickController2/UI/Pages/ControllerActionPage.xaml @@ -210,6 +210,37 @@ + + + + + + + + + + + + + + + + + +