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..778222fde --- /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.Value).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.Value).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..37e0e2633 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 int? _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 int? 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..5857faf1f 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, + int? 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, + int? 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..48c708349 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, + int? 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, + int? 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..488f1fbdd 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.HasValue) + { + SetOutputLevel(invocation.ChoiceValue.Value); + } + + 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..86ba9ddce 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.HasValue) + { + SetOutputLevel(invocation.ChoiceValue.Value); + } + + 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..512158bcb --- /dev/null +++ b/BrickController2/BrickController2/DeviceManagement/Macros/MacroChoice.cs @@ -0,0 +1,3 @@ +namespace BrickController2.DeviceManagement.Macros; + +public sealed record MacroChoice(string LabelKey, int Value); diff --git a/BrickController2/BrickController2/DeviceManagement/Macros/MacroDescriptor.cs b/BrickController2/BrickController2/DeviceManagement/Macros/MacroDescriptor.cs new file mode 100644 index 000000000..409505ed3 --- /dev/null +++ b/BrickController2/BrickController2/DeviceManagement/Macros/MacroDescriptor.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace BrickController2.DeviceManagement.Macros; + +public sealed class 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..ae475db45 --- /dev/null +++ b/BrickController2/BrickController2/DeviceManagement/Macros/MacroInvocation.cs @@ -0,0 +1,3 @@ +namespace BrickController2.DeviceManagement.Macros; + +public readonly record struct MacroInvocation(string DescriptorId, int? 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/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..f95bd1a6f 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 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 @@ + + + + + + + + + + + + + + + + + +