diff --git a/src/ZWave.CommandClasses.Tests/WindowCoveringCommandClassTests.LevelChange.cs b/src/ZWave.CommandClasses.Tests/WindowCoveringCommandClassTests.LevelChange.cs new file mode 100644 index 0000000..0140dd7 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/WindowCoveringCommandClassTests.LevelChange.cs @@ -0,0 +1,115 @@ +namespace ZWave.CommandClasses.Tests; + +public partial class WindowCoveringCommandClassTests +{ + [TestMethod] + public void StartLevelChangeCommand_Create_Up() + { + WindowCoveringCommandClass.WindowCoveringStartLevelChangeCommand command = + WindowCoveringCommandClass.WindowCoveringStartLevelChangeCommand.Create( + direction: WindowCoveringChangeDirection.Up, + parameterId: WindowCoveringParameterId.OutboundBottomPosition, + duration: new DurationSet(TimeSpan.FromSeconds(10))); + + Assert.AreEqual(CommandClassId.WindowCovering, WindowCoveringCommandClass.WindowCoveringStartLevelChangeCommand.CommandClassId); + Assert.AreEqual((byte)WindowCoveringCommand.StartLevelChange, WindowCoveringCommandClass.WindowCoveringStartLevelChangeCommand.CommandId); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual(3, parameters.Length); + // Bit 6 = 0 (Up), all other bits reserved = 0 + Assert.AreEqual(0b0000_0000, parameters[0]); + // Parameter ID = OutboundBottomPosition (0x0D) + Assert.AreEqual((byte)WindowCoveringParameterId.OutboundBottomPosition, parameters[1]); + // Duration = 10 seconds + Assert.AreEqual(0x0A, parameters[2]); + } + + [TestMethod] + public void StartLevelChangeCommand_Create_Down() + { + WindowCoveringCommandClass.WindowCoveringStartLevelChangeCommand command = + WindowCoveringCommandClass.WindowCoveringStartLevelChangeCommand.Create( + direction: WindowCoveringChangeDirection.Down, + parameterId: WindowCoveringParameterId.OutboundLeftPosition, + duration: new DurationSet(TimeSpan.FromSeconds(5))); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual(3, parameters.Length); + // Bit 6 = 1 (Down) + Assert.AreEqual(0b0100_0000, parameters[0]); + Assert.AreEqual((byte)WindowCoveringParameterId.OutboundLeftPosition, parameters[1]); + Assert.AreEqual(0x05, parameters[2]); + } + + [TestMethod] + public void StartLevelChangeCommand_Create_MovementParameter() + { + WindowCoveringCommandClass.WindowCoveringStartLevelChangeCommand command = + WindowCoveringCommandClass.WindowCoveringStartLevelChangeCommand.Create( + direction: WindowCoveringChangeDirection.Up, + parameterId: WindowCoveringParameterId.OutboundLeftMovement, + duration: new DurationSet(0x0A)); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)WindowCoveringParameterId.OutboundLeftMovement, parameters[1]); + } + + [TestMethod] + public void StartLevelChangeCommand_Create_ReservedBitsAreZero() + { + WindowCoveringCommandClass.WindowCoveringStartLevelChangeCommand command = + WindowCoveringCommandClass.WindowCoveringStartLevelChangeCommand.Create( + direction: WindowCoveringChangeDirection.Down, + parameterId: WindowCoveringParameterId.OutboundBottomPosition, + duration: new DurationSet(0x05)); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + // Bit 7 (reserved) must be 0, Bits 5-0 (reserved) must be 0 + Assert.AreEqual(0x00, parameters[0] & 0b1000_0000); // bit 7 reserved + Assert.AreEqual(0x00, parameters[0] & 0b0011_1111); // bits 5-0 reserved + } + + [TestMethod] + public void StartLevelChangeCommand_Create_FactoryDefaultDuration() + { + WindowCoveringCommandClass.WindowCoveringStartLevelChangeCommand command = + WindowCoveringCommandClass.WindowCoveringStartLevelChangeCommand.Create( + direction: WindowCoveringChangeDirection.Up, + parameterId: WindowCoveringParameterId.InboundTopBottomPosition, + duration: DurationSet.FactoryDefault); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual(0xFF, parameters[2]); // Factory default + } + + [TestMethod] + public void StopLevelChangeCommand_Create_HasCorrectFormat() + { + WindowCoveringCommandClass.WindowCoveringStopLevelChangeCommand command = + WindowCoveringCommandClass.WindowCoveringStopLevelChangeCommand.Create(WindowCoveringParameterId.OutboundBottomPosition); + + Assert.AreEqual(CommandClassId.WindowCovering, WindowCoveringCommandClass.WindowCoveringStopLevelChangeCommand.CommandClassId); + Assert.AreEqual((byte)WindowCoveringCommand.StopLevelChange, WindowCoveringCommandClass.WindowCoveringStopLevelChangeCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual((byte)WindowCoveringParameterId.OutboundBottomPosition, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void StopLevelChangeCommand_Create_MovementParameter() + { + WindowCoveringCommandClass.WindowCoveringStopLevelChangeCommand command = + WindowCoveringCommandClass.WindowCoveringStopLevelChangeCommand.Create(WindowCoveringParameterId.OutboundLeftMovement); + + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual((byte)WindowCoveringParameterId.OutboundLeftMovement, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void StopLevelChangeCommand_Create_SlatsAngle() + { + WindowCoveringCommandClass.WindowCoveringStopLevelChangeCommand command = + WindowCoveringCommandClass.WindowCoveringStopLevelChangeCommand.Create(WindowCoveringParameterId.HorizontalSlatsAnglePosition); + + Assert.AreEqual((byte)WindowCoveringParameterId.HorizontalSlatsAnglePosition, command.Frame.CommandParameters.Span[0]); + } +} diff --git a/src/ZWave.CommandClasses.Tests/WindowCoveringCommandClassTests.Report.cs b/src/ZWave.CommandClasses.Tests/WindowCoveringCommandClassTests.Report.cs new file mode 100644 index 0000000..689a409 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/WindowCoveringCommandClassTests.Report.cs @@ -0,0 +1,242 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class WindowCoveringCommandClassTests +{ + [TestMethod] + public void GetCommand_Create_HasCorrectFormat() + { + WindowCoveringCommandClass.WindowCoveringGetCommand command = + WindowCoveringCommandClass.WindowCoveringGetCommand.Create(WindowCoveringParameterId.OutboundLeftPosition); + + Assert.AreEqual(CommandClassId.WindowCovering, WindowCoveringCommandClass.WindowCoveringGetCommand.CommandClassId); + Assert.AreEqual((byte)WindowCoveringCommand.Get, WindowCoveringCommandClass.WindowCoveringGetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual((byte)WindowCoveringParameterId.OutboundLeftPosition, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void GetCommand_Create_MovementParameter() + { + WindowCoveringCommandClass.WindowCoveringGetCommand command = + WindowCoveringCommandClass.WindowCoveringGetCommand.Create(WindowCoveringParameterId.OutboundLeftMovement); + + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual((byte)WindowCoveringParameterId.OutboundLeftMovement, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void GetCommand_Create_SlatsAngle() + { + WindowCoveringCommandClass.WindowCoveringGetCommand command = + WindowCoveringCommandClass.WindowCoveringGetCommand.Create(WindowCoveringParameterId.HorizontalSlatsAnglePosition); + + Assert.AreEqual((byte)WindowCoveringParameterId.HorizontalSlatsAnglePosition, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void Report_Parse_PositionParameter() + { + // CC=0x6A, Cmd=0x04, ParameterId=0x01 (OutboundLeftPosition), Current=0x32, Target=0x63, Duration=0x05 + byte[] data = [0x6A, 0x04, 0x01, 0x32, 0x63, 0x05]; + CommandClassFrame frame = new(data); + + WindowCoveringReport report = WindowCoveringCommandClass.WindowCoveringReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(WindowCoveringParameterId.OutboundLeftPosition, report.ParameterId); + Assert.AreEqual((byte)0x32, report.CurrentValue); + Assert.AreEqual((byte)0x63, report.TargetValue); + Assert.AreEqual((byte)0x05, report.Duration.Value); + Assert.AreEqual(TimeSpan.FromSeconds(5), report.Duration.Duration); + } + + [TestMethod] + public void Report_Parse_MovementParameter_Stationary() + { + // Even parameter, not moving: Current=0x00, Target=0x00, Duration=0xFE (unknown) + byte[] data = [0x6A, 0x04, 0x00, 0x00, 0x00, 0xFE]; + CommandClassFrame frame = new(data); + + WindowCoveringReport report = WindowCoveringCommandClass.WindowCoveringReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(WindowCoveringParameterId.OutboundLeftMovement, report.ParameterId); + Assert.AreEqual((byte)0x00, report.CurrentValue); + Assert.AreEqual((byte)0x00, report.TargetValue); + Assert.AreEqual((byte)0xFE, report.Duration.Value); + Assert.IsNull(report.Duration.Duration); + } + + [TestMethod] + public void Report_Parse_FullyOpen() + { + // Position parameter, fully open, transition complete + byte[] data = [0x6A, 0x04, 0x0D, 0x63, 0x63, 0x00]; + CommandClassFrame frame = new(data); + + WindowCoveringReport report = WindowCoveringCommandClass.WindowCoveringReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(WindowCoveringParameterId.OutboundBottomPosition, report.ParameterId); + Assert.AreEqual((byte)0x63, report.CurrentValue); + Assert.AreEqual((byte)0x63, report.TargetValue); + Assert.AreEqual(TimeSpan.Zero, report.Duration.Duration); + } + + [TestMethod] + public void Report_Parse_FullyClosed() + { + byte[] data = [0x6A, 0x04, 0x0D, 0x00, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + WindowCoveringReport report = WindowCoveringCommandClass.WindowCoveringReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x00, report.CurrentValue); + Assert.AreEqual((byte)0x00, report.TargetValue); + } + + [TestMethod] + public void Report_Parse_SlatsAngle_MidPosition() + { + // Vertical slats angle at open (0x32) + byte[] data = [0x6A, 0x04, 0x0B, 0x32, 0x32, 0x00]; + CommandClassFrame frame = new(data); + + WindowCoveringReport report = WindowCoveringCommandClass.WindowCoveringReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(WindowCoveringParameterId.VerticalSlatsAnglePosition, report.ParameterId); + Assert.AreEqual((byte)0x32, report.CurrentValue); + } + + [TestMethod] + public void Report_Parse_DurationInMinutes() + { + // Duration=0x82 (3 minutes per Table 2.10: 0x80=1min, 0x81=2min, 0x82=3min) + byte[] data = [0x6A, 0x04, 0x01, 0x00, 0x63, 0x82]; + CommandClassFrame frame = new(data); + + WindowCoveringReport report = WindowCoveringCommandClass.WindowCoveringReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x82, report.Duration.Value); + Assert.AreEqual(TimeSpan.FromMinutes(3), report.Duration.Duration); + } + + [TestMethod] + public void Report_Parse_TooShort_Throws() + { + // Only 3 parameter bytes (need 4: paramId, current, target, duration) + byte[] data = [0x6A, 0x04, 0x01, 0x32, 0x63]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => WindowCoveringCommandClass.WindowCoveringReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void Report_Parse_NoParameters_Throws() + { + byte[] data = [0x6A, 0x04]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => WindowCoveringCommandClass.WindowCoveringReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void SetCommand_Create_SingleParameter() + { + Dictionary values = new() + { + { WindowCoveringParameterId.OutboundLeftPosition, 0x63 }, + }; + DurationSet duration = new DurationSet(TimeSpan.FromSeconds(5)); + + WindowCoveringCommandClass.WindowCoveringSetCommand command = + WindowCoveringCommandClass.WindowCoveringSetCommand.Create(values, duration); + + Assert.AreEqual(CommandClassId.WindowCovering, WindowCoveringCommandClass.WindowCoveringSetCommand.CommandClassId); + Assert.AreEqual((byte)WindowCoveringCommand.Set, WindowCoveringCommandClass.WindowCoveringSetCommand.CommandId); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + // 1 (count) + 2 (paramId+value) + 1 (duration) = 4 bytes + Assert.AreEqual(4, parameters.Length); + // Parameter count = 1 + Assert.AreEqual(0x01, parameters[0] & 0b0001_1111); + // Reserved bits must be 0 + Assert.AreEqual(0x00, parameters[0] & 0b1110_0000); + // Parameter ID = OutboundLeftPosition (0x01) + Assert.AreEqual((byte)WindowCoveringParameterId.OutboundLeftPosition, parameters[1]); + // Value = 0x63 + Assert.AreEqual(0x63, parameters[2]); + // Duration = 5 seconds + Assert.AreEqual(0x05, parameters[3]); + } + + [TestMethod] + public void SetCommand_Create_MultipleParameters() + { + Dictionary values = new() + { + { WindowCoveringParameterId.OutboundBottomPosition, 0x63 }, + { WindowCoveringParameterId.VerticalSlatsAnglePosition, 0x32 }, + }; + DurationSet duration = new DurationSet(TimeSpan.FromSeconds(10)); + + WindowCoveringCommandClass.WindowCoveringSetCommand command = + WindowCoveringCommandClass.WindowCoveringSetCommand.Create(values, duration); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + // 1 (count) + 4 (2 params × 2 bytes) + 1 (duration) = 6 bytes + Assert.AreEqual(6, parameters.Length); + // Parameter count = 2 + Assert.AreEqual(0x02, parameters[0] & 0b0001_1111); + } + + [TestMethod] + public void SetCommand_Create_FactoryDefaultDuration() + { + Dictionary values = new() + { + { WindowCoveringParameterId.OutboundLeftPosition, 0x00 }, + }; + + WindowCoveringCommandClass.WindowCoveringSetCommand command = + WindowCoveringCommandClass.WindowCoveringSetCommand.Create(values, DurationSet.FactoryDefault); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual(4, parameters.Length); + Assert.AreEqual(0xFF, parameters[3]); // Factory default = 0xFF + } + + [TestMethod] + public void SetCommand_Create_ZeroDuration() + { + Dictionary values = new() + { + { WindowCoveringParameterId.OutboundLeftPosition, 0x63 }, + }; + DurationSet duration = new DurationSet(0x00); + + WindowCoveringCommandClass.WindowCoveringSetCommand command = + WindowCoveringCommandClass.WindowCoveringSetCommand.Create(values, duration); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual(0x00, parameters[3]); // Instant + } + + [TestMethod] + public void SetCommand_Create_ZeroValue() + { + Dictionary values = new() + { + { WindowCoveringParameterId.OutboundBottomPosition, 0x00 }, + }; + DurationSet duration = new DurationSet(0x05); + + WindowCoveringCommandClass.WindowCoveringSetCommand command = + WindowCoveringCommandClass.WindowCoveringSetCommand.Create(values, duration); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)WindowCoveringParameterId.OutboundBottomPosition, parameters[1]); + Assert.AreEqual(0x00, parameters[2]); + } +} diff --git a/src/ZWave.CommandClasses.Tests/WindowCoveringCommandClassTests.Supported.cs b/src/ZWave.CommandClasses.Tests/WindowCoveringCommandClassTests.Supported.cs new file mode 100644 index 0000000..088ee6c --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/WindowCoveringCommandClassTests.Supported.cs @@ -0,0 +1,136 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class WindowCoveringCommandClassTests +{ + [TestMethod] + public void SupportedGetCommand_Create_HasCorrectFormat() + { + WindowCoveringCommandClass.WindowCoveringSupportedGetCommand command = + WindowCoveringCommandClass.WindowCoveringSupportedGetCommand.Create(); + + Assert.AreEqual(CommandClassId.WindowCovering, WindowCoveringCommandClass.WindowCoveringSupportedGetCommand.CommandClassId); + Assert.AreEqual((byte)WindowCoveringCommand.SupportedGet, WindowCoveringCommandClass.WindowCoveringSupportedGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void SupportedReport_Parse_SingleMaskByte_PositionParameters() + { + // CC=0x6A, Cmd=0x02, Header=0x01 (reserved=0, maskCount=1), Mask1=0x0A (bits 1,3 = params 1,3) + byte[] data = [0x6A, 0x02, 0x01, 0x0A]; + CommandClassFrame frame = new(data); + + IReadOnlySet supported = WindowCoveringCommandClass.WindowCoveringSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(2, supported); + Assert.Contains(WindowCoveringParameterId.OutboundLeftPosition, supported); + Assert.Contains(WindowCoveringParameterId.OutboundRightPosition, supported); + } + + [TestMethod] + public void SupportedReport_Parse_ThreeMaskBytes_MultipleParameters() + { + // Header=0x03 (maskCount=3) + // Mask1=0x02 (bit 1 = param 1: OutboundLeftPosition) + // Mask2=0x08 (bit 3 = param 11: VerticalSlatsAnglePosition) + // Mask3=0x20 (bit 5 = param 21: InboundTopBottomPosition) + byte[] data = [0x6A, 0x02, 0x03, 0x02, 0x08, 0x20]; + CommandClassFrame frame = new(data); + + IReadOnlySet supported = WindowCoveringCommandClass.WindowCoveringSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(3, supported); + Assert.Contains(WindowCoveringParameterId.OutboundLeftPosition, supported); + Assert.Contains(WindowCoveringParameterId.VerticalSlatsAnglePosition, supported); + Assert.Contains(WindowCoveringParameterId.InboundTopBottomPosition, supported); + } + + [TestMethod] + public void SupportedReport_Parse_AllParameters() + { + // 24 parameter IDs need 3 mask bytes. All bits 0-23 set. + // Mask1=0xFF (params 0-7), Mask2=0xFF (params 8-15), Mask3=0xFF (params 16-23) + byte[] data = [0x6A, 0x02, 0x03, 0xFF, 0xFF, 0xFF]; + CommandClassFrame frame = new(data); + + IReadOnlySet supported = WindowCoveringCommandClass.WindowCoveringSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(24, supported); + Assert.Contains(WindowCoveringParameterId.OutboundLeftMovement, supported); + Assert.Contains(WindowCoveringParameterId.HorizontalSlatsAnglePosition, supported); + } + + [TestMethod] + public void SupportedReport_Parse_MovementOnlyParameters() + { + // Mask1=0x05 (bits 0,2 = params 0,2: movement-only even IDs) + byte[] data = [0x6A, 0x02, 0x01, 0x05]; + CommandClassFrame frame = new(data); + + IReadOnlySet supported = WindowCoveringCommandClass.WindowCoveringSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(2, supported); + Assert.Contains(WindowCoveringParameterId.OutboundLeftMovement, supported); + Assert.Contains(WindowCoveringParameterId.OutboundRightMovement, supported); + } + + [TestMethod] + public void SupportedReport_Parse_EmptyMask() + { + // Mask byte count=1, mask=0x00 (no parameters) + byte[] data = [0x6A, 0x02, 0x01, 0x00]; + CommandClassFrame frame = new(data); + + IReadOnlySet supported = WindowCoveringCommandClass.WindowCoveringSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsEmpty(supported); + } + + [TestMethod] + public void SupportedReport_Parse_ReservedBitsIgnored() + { + // Header=0xF1 (reserved=0xF, maskCount=1) - reserved nibble should be ignored + byte[] data = [0x6A, 0x02, 0xF1, 0x02]; + CommandClassFrame frame = new(data); + + IReadOnlySet supported = WindowCoveringCommandClass.WindowCoveringSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(1, supported); + Assert.Contains(WindowCoveringParameterId.OutboundLeftPosition, supported); + } + + [TestMethod] + public void SupportedReport_Parse_TooShort_Throws() + { + // CC=0x6A, Cmd=0x02, no parameters + byte[] data = [0x6A, 0x02]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => WindowCoveringCommandClass.WindowCoveringSupportedReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void SupportedReport_Parse_InvalidMaskByteCount_Zero_Throws() + { + // Header=0x00 (maskCount=0, which is below minimum of 1) + byte[] data = [0x6A, 0x02, 0x00]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => WindowCoveringCommandClass.WindowCoveringSupportedReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void SupportedReport_Parse_InsufficientMaskBytes_Throws() + { + // Header=0x03 (maskCount=3) but only 1 mask byte follows + byte[] data = [0x6A, 0x02, 0x03, 0xFF]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => WindowCoveringCommandClass.WindowCoveringSupportedReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/WindowCoveringCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/WindowCoveringCommandClassTests.cs new file mode 100644 index 0000000..4c46b6a --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/WindowCoveringCommandClassTests.cs @@ -0,0 +1,6 @@ +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public partial class WindowCoveringCommandClassTests +{ +} diff --git a/src/ZWave.CommandClasses/WindowCoveringCommandClass.LevelChange.cs b/src/ZWave.CommandClasses/WindowCoveringCommandClass.LevelChange.cs new file mode 100644 index 0000000..77cce9d --- /dev/null +++ b/src/ZWave.CommandClasses/WindowCoveringCommandClass.LevelChange.cs @@ -0,0 +1,77 @@ +namespace ZWave.CommandClasses; + +public sealed partial class WindowCoveringCommandClass +{ + /// + /// Initiate a transition of one parameter to a new level. + /// + public async Task StartLevelChangeAsync( + WindowCoveringChangeDirection direction, + WindowCoveringParameterId parameterId, + DurationSet duration, + CancellationToken cancellationToken) + { + var command = WindowCoveringStartLevelChangeCommand.Create(direction, parameterId, duration); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// + /// Stop an ongoing transition. + /// + public async Task StopLevelChangeAsync(WindowCoveringParameterId parameterId, CancellationToken cancellationToken) + { + var command = WindowCoveringStopLevelChangeCommand.Create(parameterId); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + internal readonly struct WindowCoveringStartLevelChangeCommand : ICommand + { + public WindowCoveringStartLevelChangeCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.WindowCovering; + + public static byte CommandId => (byte)WindowCoveringCommand.StartLevelChange; + + public CommandClassFrame Frame { get; } + + public static WindowCoveringStartLevelChangeCommand Create( + WindowCoveringChangeDirection direction, + WindowCoveringParameterId parameterId, + DurationSet duration) + { + Span commandParameters = + [ + // Byte 0: bit 7 = reserved, bit 6 = Up/Down, bits 5-0 = reserved + (byte)((byte)direction << 6), + (byte)parameterId, + duration.Value, + ]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new WindowCoveringStartLevelChangeCommand(frame); + } + } + + internal readonly struct WindowCoveringStopLevelChangeCommand : ICommand + { + public WindowCoveringStopLevelChangeCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.WindowCovering; + + public static byte CommandId => (byte)WindowCoveringCommand.StopLevelChange; + + public CommandClassFrame Frame { get; } + + public static WindowCoveringStopLevelChangeCommand Create(WindowCoveringParameterId parameterId) + { + ReadOnlySpan commandParameters = [(byte)parameterId]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new WindowCoveringStopLevelChangeCommand(frame); + } + } +} diff --git a/src/ZWave.CommandClasses/WindowCoveringCommandClass.Report.cs b/src/ZWave.CommandClasses/WindowCoveringCommandClass.Report.cs new file mode 100644 index 0000000..16993b8 --- /dev/null +++ b/src/ZWave.CommandClasses/WindowCoveringCommandClass.Report.cs @@ -0,0 +1,163 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a Window Covering Report received from a device. +/// +public readonly record struct WindowCoveringReport( + /// + /// The parameter covered by this report. + /// + WindowCoveringParameterId ParameterId, + + /// + /// The current value of the parameter. + /// + byte CurrentValue, + + /// + /// The target value of an ongoing transition or the most recent transition for the parameter. + /// + byte TargetValue, + + /// + /// The time needed to reach the Target Value at the actual transition rate. + /// + DurationReport Duration); + +public sealed partial class WindowCoveringCommandClass +{ + private Dictionary _parameterValues = new(); + + /// + /// Event raised when a Window Covering Report is received, both solicited and unsolicited. + /// + public event Action? OnWindowCoveringReportReceived; + + /// + /// Gets the state of each supported parameter. + /// + public IReadOnlyDictionary ParameterValues => _parameterValues; + + /// + /// Request the status of a specified covering parameter. + /// + public async Task GetAsync( + WindowCoveringParameterId parameterId, + CancellationToken cancellationToken) + { + WindowCoveringGetCommand command = WindowCoveringGetCommand.Create(parameterId); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync( + predicate: frame => frame.CommandParameters.Length > 0 + && (WindowCoveringParameterId)frame.CommandParameters.Span[0] == parameterId, + cancellationToken).ConfigureAwait(false); + WindowCoveringReport report = WindowCoveringReportCommand.Parse(reportFrame, Logger); + + _parameterValues[report.ParameterId] = report; + + OnWindowCoveringReportReceived?.Invoke(report); + return report; + } + + /// + /// Set the value of one or more covering parameters. + /// + public async Task SetAsync( + IReadOnlyDictionary values, + DurationSet duration, + CancellationToken cancellationToken) + { + var command = WindowCoveringSetCommand.Create(values, duration); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + internal readonly struct WindowCoveringGetCommand : ICommand + { + public WindowCoveringGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.WindowCovering; + + public static byte CommandId => (byte)WindowCoveringCommand.Get; + + public CommandClassFrame Frame { get; } + + public static WindowCoveringGetCommand Create(WindowCoveringParameterId parameterId) + { + ReadOnlySpan commandParameters = [(byte)parameterId]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new WindowCoveringGetCommand(frame); + } + } + + internal readonly struct WindowCoveringReportCommand : ICommand + { + public WindowCoveringReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.WindowCovering; + + public static byte CommandId => (byte)WindowCoveringCommand.Report; + + public CommandClassFrame Frame { get; } + + public static WindowCoveringReport Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 4) + { + logger.LogWarning("Window Covering Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Window Covering Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + + WindowCoveringParameterId parameterId = (WindowCoveringParameterId)span[0]; + byte currentValue = span[1]; + byte targetValue = span[2]; + DurationReport duration = span[3]; + + return new WindowCoveringReport(parameterId, currentValue, targetValue, duration); + } + } + + internal readonly struct WindowCoveringSetCommand : ICommand + { + public WindowCoveringSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.WindowCovering; + + public static byte CommandId => (byte)WindowCoveringCommand.Set; + + public CommandClassFrame Frame { get; } + + public static WindowCoveringSetCommand Create( + IReadOnlyDictionary values, + DurationSet duration) + { + // 1 byte (reserved+count) + 2 bytes per parameter (ID + value) + 1 byte duration + Span commandParameters = stackalloc byte[1 + (2 * values.Count) + 1]; + commandParameters[0] = (byte)(values.Count & 0b0001_1111); + + int idx = 1; + foreach (KeyValuePair pair in values) + { + commandParameters[idx++] = (byte)pair.Key; + commandParameters[idx++] = pair.Value; + } + + commandParameters[idx] = duration.Value; + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new WindowCoveringSetCommand(frame); + } + } +} diff --git a/src/ZWave.CommandClasses/WindowCoveringCommandClass.Supported.cs b/src/ZWave.CommandClasses/WindowCoveringCommandClass.Supported.cs new file mode 100644 index 0000000..245dd4e --- /dev/null +++ b/src/ZWave.CommandClasses/WindowCoveringCommandClass.Supported.cs @@ -0,0 +1,104 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class WindowCoveringCommandClass +{ + /// + /// Gets the parameter IDs supported by the device. + /// + public IReadOnlySet? SupportedParameterIds { get; private set; } + + /// + /// Request the supported properties of a device. + /// + public async Task> GetSupportedAsync(CancellationToken cancellationToken) + { + WindowCoveringSupportedGetCommand command = WindowCoveringSupportedGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + IReadOnlySet supportedParameters = WindowCoveringSupportedReportCommand.Parse(reportFrame, Logger); + + ApplySupportedParameters(supportedParameters); + + return supportedParameters; + } + + private void ApplySupportedParameters(IReadOnlySet supportedParameters) + { + SupportedParameterIds = supportedParameters; + + Dictionary newParameterValues = new Dictionary(); + foreach (WindowCoveringParameterId parameterId in supportedParameters) + { + // Persist any existing known state. + WindowCoveringReport? parameterState = null; + if (_parameterValues.TryGetValue(parameterId, out WindowCoveringReport? existingState)) + { + parameterState = existingState; + } + + newParameterValues.Add(parameterId, parameterState); + } + + _parameterValues = newParameterValues; + } + + internal readonly struct WindowCoveringSupportedGetCommand : ICommand + { + public WindowCoveringSupportedGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.WindowCovering; + + public static byte CommandId => (byte)WindowCoveringCommand.SupportedGet; + + public CommandClassFrame Frame { get; } + + public static WindowCoveringSupportedGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new WindowCoveringSupportedGetCommand(frame); + } + } + + internal readonly struct WindowCoveringSupportedReportCommand : ICommand + { + public WindowCoveringSupportedReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.WindowCovering; + + public static byte CommandId => (byte)WindowCoveringCommand.SupportedReport; + + public CommandClassFrame Frame { get; } + + public static IReadOnlySet Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning("Window Covering Supported Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Window Covering Supported Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + + int maskByteCount = span[0] & 0b0000_1111; + if (maskByteCount < 1 || frame.CommandParameters.Length < 1 + maskByteCount) + { + logger.LogWarning( + "Window Covering Supported Report has invalid mask byte count ({MaskByteCount}) for frame length ({Length} bytes)", + maskByteCount, + frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Window Covering Supported Report has invalid mask byte count"); + } + + ReadOnlySpan maskBytes = span.Slice(1, maskByteCount); + return BitMaskHelper.ParseBitMask(maskBytes); + } + } +} diff --git a/src/ZWave.CommandClasses/WindowCoveringCommandClass.cs b/src/ZWave.CommandClasses/WindowCoveringCommandClass.cs new file mode 100644 index 0000000..7c051fb --- /dev/null +++ b/src/ZWave.CommandClasses/WindowCoveringCommandClass.cs @@ -0,0 +1,250 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Identifies a window covering parameter. +/// +/// +/// Even parameter IDs represent movement-only control (position unknown). +/// Odd parameter IDs represent position control (0x00–0x63). +/// If a device supports a position parameter (odd), it MUST NOT support the corresponding movement parameter (even). +/// +public enum WindowCoveringParameterId : byte +{ + /// + /// Outbound left edge, right/left movement (position unknown). + /// + OutboundLeftMovement = 0, + + /// + /// Outbound left edge, right/left position (0x00 = Closed, 0x63 = Open). + /// + OutboundLeftPosition = 1, + + /// + /// Outbound right edge, right/left movement (position unknown). + /// + OutboundRightMovement = 2, + + /// + /// Outbound right edge, right/left position (0x00 = Closed, 0x63 = Open). + /// + OutboundRightPosition = 3, + + /// + /// Inbound left edge, right/left movement (position unknown). + /// + InboundLeftMovement = 4, + + /// + /// Inbound left edge, right/left position (0x00 = Closed, 0x63 = Open). + /// + InboundLeftPosition = 5, + + /// + /// Inbound right edge, right/left movement (position unknown). + /// + InboundRightMovement = 6, + + /// + /// Inbound right edge, right/left position (0x00 = Closed, 0x63 = Open). + /// + InboundRightPosition = 7, + + /// + /// Inbound edges controlled horizontally as one, right/left movement (position unknown). + /// + InboundRightLeftMovement = 8, + + /// + /// Inbound edges controlled horizontally as one, right/left position (0x00 = Closed, 0x63 = Open). + /// + InboundRightLeftPosition = 9, + + /// + /// Vertical slats angle, right/left movement (position unknown). + /// + VerticalSlatsAngleMovement = 10, + + /// + /// Vertical slats angle, right/left position (0x00 = Closed right, 0x32 = Open, 0x63 = Closed left). + /// + VerticalSlatsAnglePosition = 11, + + /// + /// Outbound bottom edge, up/down movement (position unknown). + /// + OutboundBottomMovement = 12, + + /// + /// Outbound bottom edge, up/down position (0x00 = Closed, 0x63 = Open). + /// + OutboundBottomPosition = 13, + + /// + /// Outbound top edge, up/down movement (position unknown). + /// + OutboundTopMovement = 14, + + /// + /// Outbound top edge, up/down position (0x00 = Closed, 0x63 = Open). + /// + OutboundTopPosition = 15, + + /// + /// Inbound bottom edge, up/down movement (position unknown). + /// + InboundBottomMovement = 16, + + /// + /// Inbound bottom edge, up/down position (0x00 = Closed, 0x63 = Open). + /// + InboundBottomPosition = 17, + + /// + /// Inbound top edge, up/down movement (position unknown). + /// + InboundTopMovement = 18, + + /// + /// Inbound top edge, up/down position (0x00 = Closed, 0x63 = Open). + /// + InboundTopPosition = 19, + + /// + /// Inbound edges controlled vertically as one, up/down movement (position unknown). + /// + InboundTopBottomMovement = 20, + + /// + /// Inbound edges controlled vertically as one, up/down position (0x00 = Closed, 0x63 = Open). + /// + InboundTopBottomPosition = 21, + + /// + /// Horizontal slats angle, up/down movement (position unknown). + /// + HorizontalSlatsAngleMovement = 22, + + /// + /// Horizontal slats angle, up/down position (0x00 = Closed up, 0x32 = Open, 0x63 = Closed down). + /// + HorizontalSlatsAnglePosition = 23, +} + +/// +/// The direction of a window covering level change. +/// +public enum WindowCoveringChangeDirection : byte +{ + /// + /// The level change is increasing (opening). + /// + Up = 0x00, + + /// + /// The level change is decreasing (closing). + /// + Down = 0x01, +} + +/// +/// Defines the commands for the Window Covering Command Class. +/// +public enum WindowCoveringCommand : byte +{ + /// + /// Request the supported properties of a device. + /// + SupportedGet = 0x01, + + /// + /// Report the supported properties of a device. + /// + SupportedReport = 0x02, + + /// + /// Request the status of a specified covering parameter. + /// + Get = 0x03, + + /// + /// Report the status of a covering parameter. + /// + Report = 0x04, + + /// + /// Set the value of one or more covering parameters. + /// + Set = 0x05, + + /// + /// Initiate a transition of one parameter to a new level. + /// + StartLevelChange = 0x06, + + /// + /// Stop an ongoing transition. + /// + StopLevelChange = 0x07, +} + +/// +/// Controls window covering devices by manipulating covering parameters such as position and slats angle. +/// +[CommandClass(CommandClassId.WindowCovering)] +public sealed partial class WindowCoveringCommandClass : CommandClass +{ + internal WindowCoveringCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + public override bool? IsCommandSupported(WindowCoveringCommand command) + => command switch + { + WindowCoveringCommand.SupportedGet => true, + WindowCoveringCommand.Get => true, + WindowCoveringCommand.Set => true, + WindowCoveringCommand.StartLevelChange => true, + WindowCoveringCommand.StopLevelChange => true, + _ => false, + }; + + internal override async Task InterviewAsync(CancellationToken cancellationToken) + { + IReadOnlySet supportedParameters = await GetSupportedAsync(cancellationToken).ConfigureAwait(false); + + foreach (WindowCoveringParameterId parameterId in supportedParameters) + { + _ = await GetAsync(parameterId, cancellationToken).ConfigureAwait(false); + } + } + + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((WindowCoveringCommand)frame.CommandId) + { + case WindowCoveringCommand.SupportedReport: + { + IReadOnlySet supportedParameters = WindowCoveringSupportedReportCommand.Parse(frame, Logger); + ApplySupportedParameters(supportedParameters); + break; + } + case WindowCoveringCommand.Report: + { + WindowCoveringReport report = WindowCoveringReportCommand.Parse(frame, Logger); + _parameterValues[report.ParameterId] = report; + + OnWindowCoveringReportReceived?.Invoke(report); + break; + } + } + } +}