diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fd530f3..1e9f501 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -117,6 +117,7 @@ Response structs that contain variable-length collections use count + indexer me - Allman-style braces (`csharp_new_line_before_open_brace = all`). - NuGet package versions are centrally managed in `Directory.Packages.props`. When adding a package, add the version there and reference it without a version in the `.csproj`. - `InternalsVisibleTo` is set: `ZWave.Protocol` → `ZWave.Serial`, `ZWave.Serial` → `ZWave.Serial.Tests`, `ZWave.CommandClasses` → `ZWave` and `ZWave.CommandClasses.Tests`. +- **Binary literals for bitmasks** — prefer `0b` format (e.g. `0b0000_0010`) over `0x` hex when working with bitmask constants, as it makes the specific bit positions immediately clear. ## Testing Patterns diff --git a/src/ZWave.CommandClasses.Tests/ProtectionCommandClassTests.ExclusiveControl.cs b/src/ZWave.CommandClasses.Tests/ProtectionCommandClassTests.ExclusiveControl.cs new file mode 100644 index 0000000..0e5a600 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/ProtectionCommandClassTests.ExclusiveControl.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class ProtectionCommandClassTests +{ + [TestMethod] + public void ExclusiveControlSetCommand_Create_HasCorrectFormat() + { + ProtectionCommandClass.ProtectionExclusiveControlSetCommand command = + ProtectionCommandClass.ProtectionExclusiveControlSetCommand.Create(5); + + Assert.AreEqual(CommandClassId.Protection, ProtectionCommandClass.ProtectionExclusiveControlSetCommand.CommandClassId); + Assert.AreEqual((byte)ProtectionCommand.ExclusiveControlSet, ProtectionCommandClass.ProtectionExclusiveControlSetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); // CC + Cmd + NodeID + Assert.AreEqual(5, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void ExclusiveControlSetCommand_Create_ResetWithZero() + { + ProtectionCommandClass.ProtectionExclusiveControlSetCommand command = + ProtectionCommandClass.ProtectionExclusiveControlSetCommand.Create(0); + + Assert.AreEqual(0, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void ExclusiveControlGetCommand_Create_HasCorrectFormat() + { + ProtectionCommandClass.ProtectionExclusiveControlGetCommand command = + ProtectionCommandClass.ProtectionExclusiveControlGetCommand.Create(); + + Assert.AreEqual(CommandClassId.Protection, ProtectionCommandClass.ProtectionExclusiveControlGetCommand.CommandClassId); + Assert.AreEqual((byte)ProtectionCommand.ExclusiveControlGet, ProtectionCommandClass.ProtectionExclusiveControlGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void ExclusiveControlReportCommand_Parse_NodeId() + { + byte[] data = [0x75, 0x08, 0x05]; + CommandClassFrame frame = new(data); + + byte nodeId = ProtectionCommandClass.ProtectionExclusiveControlReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)5, nodeId); + } + + [TestMethod] + public void ExclusiveControlReportCommand_Parse_NoExclusiveControl() + { + byte[] data = [0x75, 0x08, 0x00]; + CommandClassFrame frame = new(data); + + byte nodeId = ProtectionCommandClass.ProtectionExclusiveControlReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0, nodeId); + } + + [TestMethod] + public void ExclusiveControlReportCommand_Parse_TooShort_Throws() + { + byte[] data = [0x75, 0x08]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => ProtectionCommandClass.ProtectionExclusiveControlReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void ExclusiveControlReportCommand_Create_HasCorrectFormat() + { + ProtectionCommandClass.ProtectionExclusiveControlReportCommand command = + ProtectionCommandClass.ProtectionExclusiveControlReportCommand.Create(10); + + Assert.AreEqual(1, command.Frame.CommandParameters.Length); + Assert.AreEqual(10, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void ExclusiveControlReportCommand_RoundTrip() + { + ProtectionCommandClass.ProtectionExclusiveControlReportCommand command = + ProtectionCommandClass.ProtectionExclusiveControlReportCommand.Create(42); + + byte nodeId = ProtectionCommandClass.ProtectionExclusiveControlReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual((byte)42, nodeId); + } +} diff --git a/src/ZWave.CommandClasses.Tests/ProtectionCommandClassTests.Report.cs b/src/ZWave.CommandClasses.Tests/ProtectionCommandClassTests.Report.cs new file mode 100644 index 0000000..0ecc12a --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/ProtectionCommandClassTests.Report.cs @@ -0,0 +1,202 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class ProtectionCommandClassTests +{ + [TestMethod] + public void SetCommand_Create_V1_HasCorrectFormat() + { + ProtectionCommandClass.ProtectionSetCommand command = + ProtectionCommandClass.ProtectionSetCommand.Create(1, LocalProtectionState.ProtectionBySequence, RfProtectionState.Unprotected); + + Assert.AreEqual(CommandClassId.Protection, ProtectionCommandClass.ProtectionSetCommand.CommandClassId); + Assert.AreEqual((byte)ProtectionCommand.Set, ProtectionCommandClass.ProtectionSetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); // CC + Cmd + 1 param + Assert.AreEqual(0x01, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void SetCommand_Create_V1_Unprotected() + { + ProtectionCommandClass.ProtectionSetCommand command = + ProtectionCommandClass.ProtectionSetCommand.Create(1, LocalProtectionState.Unprotected, RfProtectionState.NoRfControl); + + // V1 ignores RF state, sends only 1 byte + Assert.AreEqual(1, command.Frame.CommandParameters.Length); + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void SetCommand_Create_V1_NoOperationPossible() + { + ProtectionCommandClass.ProtectionSetCommand command = + ProtectionCommandClass.ProtectionSetCommand.Create(1, LocalProtectionState.NoOperationPossible, RfProtectionState.Unprotected); + + Assert.AreEqual(1, command.Frame.CommandParameters.Length); + Assert.AreEqual(0x02, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void SetCommand_Create_V2_HasCorrectFormat() + { + ProtectionCommandClass.ProtectionSetCommand command = + ProtectionCommandClass.ProtectionSetCommand.Create(2, LocalProtectionState.NoOperationPossible, RfProtectionState.NoRfControl); + + Assert.AreEqual(4, command.Frame.Data.Length); // CC + Cmd + 2 params + Assert.AreEqual(2, command.Frame.CommandParameters.Length); + Assert.AreEqual(0x02, command.Frame.CommandParameters.Span[0]); // Local: NoOperationPossible + Assert.AreEqual(0x01, command.Frame.CommandParameters.Span[1]); // RF: NoRfControl + } + + [TestMethod] + public void SetCommand_Create_V2_NoRfResponse() + { + ProtectionCommandClass.ProtectionSetCommand command = + ProtectionCommandClass.ProtectionSetCommand.Create(2, LocalProtectionState.Unprotected, RfProtectionState.NoRfResponse); + + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[0]); // Local: Unprotected + Assert.AreEqual(0x02, command.Frame.CommandParameters.Span[1]); // RF: NoRfResponse + } + + [TestMethod] + public void GetCommand_Create_HasCorrectFormat() + { + ProtectionCommandClass.ProtectionGetCommand command = + ProtectionCommandClass.ProtectionGetCommand.Create(); + + Assert.AreEqual(CommandClassId.Protection, ProtectionCommandClass.ProtectionGetCommand.CommandClassId); + Assert.AreEqual((byte)ProtectionCommand.Get, ProtectionCommandClass.ProtectionGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); // CC + Cmd only + } + + [TestMethod] + public void ReportCommand_Parse_V1_Unprotected() + { + byte[] data = [0x75, 0x03, 0x00]; + CommandClassFrame frame = new(data); + + ProtectionReport report = ProtectionCommandClass.ProtectionReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(LocalProtectionState.Unprotected, report.LocalProtection); + Assert.IsNull(report.RfProtection); + } + + [TestMethod] + public void ReportCommand_Parse_V1_ProtectionBySequence() + { + byte[] data = [0x75, 0x03, 0x01]; + CommandClassFrame frame = new(data); + + ProtectionReport report = ProtectionCommandClass.ProtectionReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(LocalProtectionState.ProtectionBySequence, report.LocalProtection); + Assert.IsNull(report.RfProtection); + } + + [TestMethod] + public void ReportCommand_Parse_V1_NoOperationPossible() + { + byte[] data = [0x75, 0x03, 0x02]; + CommandClassFrame frame = new(data); + + ProtectionReport report = ProtectionCommandClass.ProtectionReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(LocalProtectionState.NoOperationPossible, report.LocalProtection); + Assert.IsNull(report.RfProtection); + } + + [TestMethod] + public void ReportCommand_Parse_V2_BothStates() + { + // Local: NoOperationPossible (0x02), RF: NoRfControl (0x01) + byte[] data = [0x75, 0x03, 0x02, 0x01]; + CommandClassFrame frame = new(data); + + ProtectionReport report = ProtectionCommandClass.ProtectionReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(LocalProtectionState.NoOperationPossible, report.LocalProtection); + Assert.IsNotNull(report.RfProtection); + Assert.AreEqual(RfProtectionState.NoRfControl, report.RfProtection.Value); + } + + [TestMethod] + public void ReportCommand_Parse_V2_NoRfResponse() + { + byte[] data = [0x75, 0x03, 0x00, 0x02]; + CommandClassFrame frame = new(data); + + ProtectionReport report = ProtectionCommandClass.ProtectionReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(LocalProtectionState.Unprotected, report.LocalProtection); + Assert.AreEqual(RfProtectionState.NoRfResponse, report.RfProtection!.Value); + } + + [TestMethod] + public void ReportCommand_Parse_V2_ReservedBitsIgnored() + { + // Upper nibble set (reserved bits) — should be ignored, lower nibble is the state + byte[] data = [0x75, 0x03, 0xF1, 0xF2]; + CommandClassFrame frame = new(data); + + ProtectionReport report = ProtectionCommandClass.ProtectionReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(LocalProtectionState.ProtectionBySequence, report.LocalProtection); + Assert.AreEqual(RfProtectionState.NoRfResponse, report.RfProtection!.Value); + } + + [TestMethod] + public void ReportCommand_Parse_TooShort_Throws() + { + byte[] data = [0x75, 0x03]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => ProtectionCommandClass.ProtectionReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void ReportCommand_Create_V1() + { + ProtectionCommandClass.ProtectionReportCommand command = + ProtectionCommandClass.ProtectionReportCommand.Create(1, LocalProtectionState.ProtectionBySequence, RfProtectionState.Unprotected); + + Assert.AreEqual(1, command.Frame.CommandParameters.Length); + Assert.AreEqual(0x01, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void ReportCommand_Create_V2() + { + ProtectionCommandClass.ProtectionReportCommand command = + ProtectionCommandClass.ProtectionReportCommand.Create(2, LocalProtectionState.NoOperationPossible, RfProtectionState.NoRfResponse); + + Assert.AreEqual(2, command.Frame.CommandParameters.Length); + Assert.AreEqual(0x02, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual(0x02, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void ReportCommand_RoundTrip_V1() + { + ProtectionCommandClass.ProtectionReportCommand command = + ProtectionCommandClass.ProtectionReportCommand.Create(1, LocalProtectionState.NoOperationPossible, RfProtectionState.Unprotected); + + ProtectionReport report = ProtectionCommandClass.ProtectionReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual(LocalProtectionState.NoOperationPossible, report.LocalProtection); + Assert.IsNull(report.RfProtection); + } + + [TestMethod] + public void ReportCommand_RoundTrip_V2() + { + ProtectionCommandClass.ProtectionReportCommand command = + ProtectionCommandClass.ProtectionReportCommand.Create(2, LocalProtectionState.ProtectionBySequence, RfProtectionState.NoRfControl); + + ProtectionReport report = ProtectionCommandClass.ProtectionReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual(LocalProtectionState.ProtectionBySequence, report.LocalProtection); + Assert.AreEqual(RfProtectionState.NoRfControl, report.RfProtection!.Value); + } +} diff --git a/src/ZWave.CommandClasses.Tests/ProtectionCommandClassTests.Supported.cs b/src/ZWave.CommandClasses.Tests/ProtectionCommandClassTests.Supported.cs new file mode 100644 index 0000000..aa48254 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/ProtectionCommandClassTests.Supported.cs @@ -0,0 +1,164 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class ProtectionCommandClassTests +{ + [TestMethod] + public void SupportedGetCommand_Create_HasCorrectFormat() + { + ProtectionCommandClass.ProtectionSupportedGetCommand command = + ProtectionCommandClass.ProtectionSupportedGetCommand.Create(); + + Assert.AreEqual(CommandClassId.Protection, ProtectionCommandClass.ProtectionSupportedGetCommand.CommandClassId); + Assert.AreEqual((byte)ProtectionCommand.SupportedGet, ProtectionCommandClass.ProtectionSupportedGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void SupportedReportCommand_Parse_AllFeatures() + { + // Flags: EC=1 (bit 1), Timeout=1 (bit 0) → 0x03 + // Local bitmask: states 0,1,2 → byte1=0x07, byte2=0x00 + // RF bitmask: states 0,1,2 → byte1=0x07, byte2=0x00 + byte[] data = [0x75, 0x05, 0x03, 0x07, 0x00, 0x07, 0x00]; + CommandClassFrame frame = new(data); + + ProtectionSupportedReport report = + ProtectionCommandClass.ProtectionSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsTrue(report.SupportsExclusiveControl); + Assert.IsTrue(report.SupportsTimeout); + Assert.Contains(LocalProtectionState.Unprotected, report.SupportedLocalStates); + Assert.Contains(LocalProtectionState.ProtectionBySequence, report.SupportedLocalStates); + Assert.Contains(LocalProtectionState.NoOperationPossible, report.SupportedLocalStates); + Assert.Contains(RfProtectionState.Unprotected, report.SupportedRfStates); + Assert.Contains(RfProtectionState.NoRfControl, report.SupportedRfStates); + Assert.Contains(RfProtectionState.NoRfResponse, report.SupportedRfStates); + } + + [TestMethod] + public void SupportedReportCommand_Parse_NoOptionalFeatures() + { + // Flags: 0x00 (no EC, no Timeout) + // Local bitmask: states 0,1 → byte1=0x03, byte2=0x00 + // RF bitmask: state 0 only → byte1=0x01, byte2=0x00 + byte[] data = [0x75, 0x05, 0x00, 0x03, 0x00, 0x01, 0x00]; + CommandClassFrame frame = new(data); + + ProtectionSupportedReport report = + ProtectionCommandClass.ProtectionSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsFalse(report.SupportsExclusiveControl); + Assert.IsFalse(report.SupportsTimeout); + Assert.HasCount(2, report.SupportedLocalStates); + Assert.Contains(LocalProtectionState.Unprotected, report.SupportedLocalStates); + Assert.Contains(LocalProtectionState.ProtectionBySequence, report.SupportedLocalStates); + Assert.HasCount(1, report.SupportedRfStates); + Assert.Contains(RfProtectionState.Unprotected, report.SupportedRfStates); + } + + [TestMethod] + public void SupportedReportCommand_Parse_ExclusiveControlOnly() + { + // Flags: EC=1 (bit 1) → 0x02 + byte[] data = [0x75, 0x05, 0x02, 0x07, 0x00, 0x07, 0x00]; + CommandClassFrame frame = new(data); + + ProtectionSupportedReport report = + ProtectionCommandClass.ProtectionSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsTrue(report.SupportsExclusiveControl); + Assert.IsFalse(report.SupportsTimeout); + } + + [TestMethod] + public void SupportedReportCommand_Parse_TimeoutOnly() + { + // Flags: Timeout=1 (bit 0) → 0x01 + byte[] data = [0x75, 0x05, 0x01, 0x07, 0x00, 0x07, 0x00]; + CommandClassFrame frame = new(data); + + ProtectionSupportedReport report = + ProtectionCommandClass.ProtectionSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsFalse(report.SupportsExclusiveControl); + Assert.IsTrue(report.SupportsTimeout); + } + + [TestMethod] + public void SupportedReportCommand_Parse_ReservedBitsIgnored() + { + // Flags with reserved bits set: 0xFC | 0x03 = 0xFF + byte[] data = [0x75, 0x05, 0xFF, 0x07, 0x00, 0x07, 0x00]; + CommandClassFrame frame = new(data); + + ProtectionSupportedReport report = + ProtectionCommandClass.ProtectionSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsTrue(report.SupportsExclusiveControl); + Assert.IsTrue(report.SupportsTimeout); + } + + [TestMethod] + public void SupportedReportCommand_Parse_TooShort_Throws() + { + byte[] data = [0x75, 0x05, 0x03, 0x07]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => ProtectionCommandClass.ProtectionSupportedReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void SupportedReportCommand_Create_HasCorrectFormat() + { + HashSet localStates = + [ + LocalProtectionState.Unprotected, + LocalProtectionState.ProtectionBySequence, + LocalProtectionState.NoOperationPossible, + ]; + HashSet rfStates = + [ + RfProtectionState.Unprotected, + RfProtectionState.NoRfControl, + ]; + + ProtectionCommandClass.ProtectionSupportedReportCommand command = + ProtectionCommandClass.ProtectionSupportedReportCommand.Create(true, false, localStates, rfStates); + + Assert.AreEqual(5, command.Frame.CommandParameters.Length); + Assert.AreEqual(0x02, command.Frame.CommandParameters.Span[0]); // EC=1, Timeout=0 + } + + [TestMethod] + public void SupportedReportCommand_RoundTrip() + { + HashSet localStates = + [ + LocalProtectionState.Unprotected, + LocalProtectionState.NoOperationPossible, + ]; + HashSet rfStates = + [ + RfProtectionState.Unprotected, + RfProtectionState.NoRfResponse, + ]; + + ProtectionCommandClass.ProtectionSupportedReportCommand command = + ProtectionCommandClass.ProtectionSupportedReportCommand.Create(true, true, localStates, rfStates); + + ProtectionSupportedReport report = + ProtectionCommandClass.ProtectionSupportedReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.IsTrue(report.SupportsExclusiveControl); + Assert.IsTrue(report.SupportsTimeout); + Assert.HasCount(2, report.SupportedLocalStates); + Assert.Contains(LocalProtectionState.Unprotected, report.SupportedLocalStates); + Assert.Contains(LocalProtectionState.NoOperationPossible, report.SupportedLocalStates); + Assert.HasCount(2, report.SupportedRfStates); + Assert.Contains(RfProtectionState.Unprotected, report.SupportedRfStates); + Assert.Contains(RfProtectionState.NoRfResponse, report.SupportedRfStates); + } +} diff --git a/src/ZWave.CommandClasses.Tests/ProtectionCommandClassTests.Timeout.cs b/src/ZWave.CommandClasses.Tests/ProtectionCommandClassTests.Timeout.cs new file mode 100644 index 0000000..9245760 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/ProtectionCommandClassTests.Timeout.cs @@ -0,0 +1,160 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class ProtectionCommandClassTests +{ + [TestMethod] + public void TimeoutSetCommand_Create_HasCorrectFormat() + { + ProtectionCommandClass.ProtectionTimeoutSetCommand command = + ProtectionCommandClass.ProtectionTimeoutSetCommand.Create(0x3C); + + Assert.AreEqual(CommandClassId.Protection, ProtectionCommandClass.ProtectionTimeoutSetCommand.CommandClassId); + Assert.AreEqual((byte)ProtectionCommand.TimeoutSet, ProtectionCommandClass.ProtectionTimeoutSetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); // CC + Cmd + Timeout + Assert.AreEqual(0x3C, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void TimeoutSetCommand_Create_NoTimer() + { + ProtectionCommandClass.ProtectionTimeoutSetCommand command = + ProtectionCommandClass.ProtectionTimeoutSetCommand.Create(0x00); + + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void TimeoutSetCommand_Create_Infinite() + { + ProtectionCommandClass.ProtectionTimeoutSetCommand command = + ProtectionCommandClass.ProtectionTimeoutSetCommand.Create(0xFF); + + Assert.AreEqual(0xFF, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void TimeoutGetCommand_Create_HasCorrectFormat() + { + ProtectionCommandClass.ProtectionTimeoutGetCommand command = + ProtectionCommandClass.ProtectionTimeoutGetCommand.Create(); + + Assert.AreEqual(CommandClassId.Protection, ProtectionCommandClass.ProtectionTimeoutGetCommand.CommandClassId); + Assert.AreEqual((byte)ProtectionCommand.TimeoutGet, ProtectionCommandClass.ProtectionTimeoutGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void TimeoutReportCommand_Parse_NoTimer() + { + byte[] data = [0x75, 0x0B, 0x00]; + CommandClassFrame frame = new(data); + + TimeSpan? timeout = ProtectionCommandClass.ProtectionTimeoutReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(TimeSpan.Zero, timeout); + } + + [TestMethod] + public void TimeoutReportCommand_Parse_Seconds() + { + // 30 seconds + byte[] data = [0x75, 0x0B, 0x1E]; + CommandClassFrame frame = new(data); + + TimeSpan? timeout = ProtectionCommandClass.ProtectionTimeoutReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(TimeSpan.FromSeconds(30), timeout); + } + + [TestMethod] + public void TimeoutReportCommand_Parse_MaxSeconds() + { + // 60 seconds = 0x3C + byte[] data = [0x75, 0x0B, 0x3C]; + CommandClassFrame frame = new(data); + + TimeSpan? timeout = ProtectionCommandClass.ProtectionTimeoutReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(TimeSpan.FromSeconds(60), timeout); + } + + [TestMethod] + public void TimeoutReportCommand_Parse_Minutes() + { + // 2 minutes = 0x41 + byte[] data = [0x75, 0x0B, 0x41]; + CommandClassFrame frame = new(data); + + TimeSpan? timeout = ProtectionCommandClass.ProtectionTimeoutReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(TimeSpan.FromMinutes(2), timeout); + } + + [TestMethod] + public void TimeoutReportCommand_Parse_MaxMinutes() + { + // 191 minutes = 0xFE + byte[] data = [0x75, 0x0B, 0xFE]; + CommandClassFrame frame = new(data); + + TimeSpan? timeout = ProtectionCommandClass.ProtectionTimeoutReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(TimeSpan.FromMinutes(191), timeout); + } + + [TestMethod] + public void TimeoutReportCommand_Parse_Infinite() + { + byte[] data = [0x75, 0x0B, 0xFF]; + CommandClassFrame frame = new(data); + + TimeSpan? timeout = ProtectionCommandClass.ProtectionTimeoutReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(System.Threading.Timeout.InfiniteTimeSpan, timeout); + } + + [TestMethod] + public void TimeoutReportCommand_Parse_ReservedValue() + { + // 0x3D is in the reserved gap (0x3D-0x40) + byte[] data = [0x75, 0x0B, 0x3D]; + CommandClassFrame frame = new(data); + + TimeSpan? timeout = ProtectionCommandClass.ProtectionTimeoutReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsNull(timeout); + } + + [TestMethod] + public void TimeoutReportCommand_Parse_TooShort_Throws() + { + byte[] data = [0x75, 0x0B]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => ProtectionCommandClass.ProtectionTimeoutReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void TimeoutReportCommand_Create_HasCorrectFormat() + { + ProtectionCommandClass.ProtectionTimeoutReportCommand command = + ProtectionCommandClass.ProtectionTimeoutReportCommand.Create(0x41); + + Assert.AreEqual(1, command.Frame.CommandParameters.Length); + Assert.AreEqual(0x41, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void TimeoutReportCommand_RoundTrip() + { + ProtectionCommandClass.ProtectionTimeoutReportCommand command = + ProtectionCommandClass.ProtectionTimeoutReportCommand.Create(0x3C); + + TimeSpan? timeout = ProtectionCommandClass.ProtectionTimeoutReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual(TimeSpan.FromSeconds(60), timeout); + } +} diff --git a/src/ZWave.CommandClasses.Tests/ProtectionCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/ProtectionCommandClassTests.cs new file mode 100644 index 0000000..4d87452 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/ProtectionCommandClassTests.cs @@ -0,0 +1,6 @@ +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public partial class ProtectionCommandClassTests +{ +} diff --git a/src/ZWave.CommandClasses/ProtectionCommandClass.ExclusiveControl.cs b/src/ZWave.CommandClasses/ProtectionCommandClass.ExclusiveControl.cs new file mode 100644 index 0000000..c2428e2 --- /dev/null +++ b/src/ZWave.CommandClasses/ProtectionCommandClass.ExclusiveControl.cs @@ -0,0 +1,121 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class ProtectionCommandClass +{ + /// + /// Gets the node ID that has exclusive control over this device. + /// A value of 0 means no exclusive control is set. + /// + public byte? ExclusiveControlNodeId { get; private set; } + + /// + /// Event raised when a Protection Exclusive Control Report is received. + /// + public event Action? OnExclusiveControlReportReceived; + + /// + /// Request the exclusive control node ID from a device. + /// + public async Task GetExclusiveControlAsync(CancellationToken cancellationToken) + { + ProtectionExclusiveControlGetCommand command = ProtectionExclusiveControlGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + byte nodeId = ProtectionExclusiveControlReportCommand.Parse(reportFrame, Logger); + ExclusiveControlNodeId = nodeId; + OnExclusiveControlReportReceived?.Invoke(nodeId); + return nodeId; + } + + /// + /// Set the node ID that has exclusive control over a device. + /// + /// + /// The node ID to grant exclusive control. Use 0 to reset exclusive control. + /// + /// The cancellation token. + public async Task SetExclusiveControlAsync(byte nodeId, CancellationToken cancellationToken) + { + var command = ProtectionExclusiveControlSetCommand.Create(nodeId); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + internal readonly struct ProtectionExclusiveControlSetCommand : ICommand + { + public ProtectionExclusiveControlSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Protection; + + public static byte CommandId => (byte)ProtectionCommand.ExclusiveControlSet; + + public CommandClassFrame Frame { get; } + + public static ProtectionExclusiveControlSetCommand Create(byte nodeId) + { + ReadOnlySpan commandParameters = [nodeId]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ProtectionExclusiveControlSetCommand(frame); + } + } + + internal readonly struct ProtectionExclusiveControlGetCommand : ICommand + { + public ProtectionExclusiveControlGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Protection; + + public static byte CommandId => (byte)ProtectionCommand.ExclusiveControlGet; + + public CommandClassFrame Frame { get; } + + public static ProtectionExclusiveControlGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new ProtectionExclusiveControlGetCommand(frame); + } + } + + internal readonly struct ProtectionExclusiveControlReportCommand : ICommand + { + public ProtectionExclusiveControlReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Protection; + + public static byte CommandId => (byte)ProtectionCommand.ExclusiveControlReport; + + public CommandClassFrame Frame { get; } + + public static ProtectionExclusiveControlReportCommand Create(byte nodeId) + { + ReadOnlySpan commandParameters = [nodeId]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ProtectionExclusiveControlReportCommand(frame); + } + + public static byte Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning( + "Protection Exclusive Control Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Protection Exclusive Control Report frame is too short"); + } + + return frame.CommandParameters.Span[0]; + } + } +} diff --git a/src/ZWave.CommandClasses/ProtectionCommandClass.Report.cs b/src/ZWave.CommandClasses/ProtectionCommandClass.Report.cs new file mode 100644 index 0000000..a972f47 --- /dev/null +++ b/src/ZWave.CommandClasses/ProtectionCommandClass.Report.cs @@ -0,0 +1,153 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a Protection Report received from a device. +/// +public readonly record struct ProtectionReport( + /// + /// The local (physical) protection state of the device. + /// + LocalProtectionState LocalProtection, + + /// + /// The RF (wireless) protection state of the device. + /// This is for version 1 devices. + /// + RfProtectionState? RfProtection); + +public sealed partial class ProtectionCommandClass +{ + /// + /// Gets the last protection report received from the device. + /// + public ProtectionReport? LastReport { get; private set; } + + /// + /// Event raised when a Protection Report is received, both solicited and unsolicited. + /// + public event Action? OnProtectionReportReceived; + + /// + /// Request the protection state of a device. + /// + public async Task GetAsync(CancellationToken cancellationToken) + { + ProtectionGetCommand command = ProtectionGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + ProtectionReport report = ProtectionReportCommand.Parse(reportFrame, Logger); + LastReport = report; + OnProtectionReportReceived?.Invoke(report); + return report; + } + + /// + /// Set the protection state of a device. + /// + /// The local (physical) protection state to set. + /// + /// The RF (wireless) protection state to set. + /// Ignored for version 1 devices. + /// + /// The cancellation token. + public async Task SetAsync( + LocalProtectionState localState, + RfProtectionState rfState, + CancellationToken cancellationToken) + { + var command = ProtectionSetCommand.Create(EffectiveVersion, localState, rfState); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + internal readonly struct ProtectionSetCommand : ICommand + { + public ProtectionSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Protection; + + public static byte CommandId => (byte)ProtectionCommand.Set; + + public CommandClassFrame Frame { get; } + + public static ProtectionSetCommand Create( + byte version, + LocalProtectionState localState, + RfProtectionState rfState) + { + ReadOnlySpan commandParameters = version >= 2 + ? [(byte)localState, (byte)rfState] + : [(byte)localState]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ProtectionSetCommand(frame); + } + } + + internal readonly struct ProtectionGetCommand : ICommand + { + public ProtectionGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Protection; + + public static byte CommandId => (byte)ProtectionCommand.Get; + + public CommandClassFrame Frame { get; } + + public static ProtectionGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new ProtectionGetCommand(frame); + } + } + + internal readonly struct ProtectionReportCommand : ICommand + { + public ProtectionReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Protection; + + public static byte CommandId => (byte)ProtectionCommand.Report; + + public CommandClassFrame Frame { get; } + + public static ProtectionReportCommand Create( + byte version, + LocalProtectionState localState, + RfProtectionState rfState) + { + ReadOnlySpan commandParameters = version >= 2 + ? [(byte)localState, (byte)rfState] + : [(byte)localState]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ProtectionReportCommand(frame); + } + + public static ProtectionReport Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning("Protection Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Protection Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + + LocalProtectionState localState = (LocalProtectionState)(span[0] & 0x0F); + RfProtectionState? rfState = span.Length >= 2 + ? (RfProtectionState)(span[1] & 0x0F) + : null; + + return new ProtectionReport(localState, rfState); + } + } +} diff --git a/src/ZWave.CommandClasses/ProtectionCommandClass.Supported.cs b/src/ZWave.CommandClasses/ProtectionCommandClass.Supported.cs new file mode 100644 index 0000000..436ba5c --- /dev/null +++ b/src/ZWave.CommandClasses/ProtectionCommandClass.Supported.cs @@ -0,0 +1,148 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents the protection capabilities of a device. +/// +public readonly record struct ProtectionSupportedReport( + /// + /// Whether the device supports exclusive control. + /// + bool SupportsExclusiveControl, + + /// + /// Whether the device supports an RF protection timeout. + /// + bool SupportsTimeout, + + /// + /// The local protection states supported by the device. + /// + IReadOnlySet SupportedLocalStates, + + /// + /// The RF protection states supported by the device. + /// + IReadOnlySet SupportedRfStates); + +public sealed partial class ProtectionCommandClass +{ + /// + /// Gets the protection capabilities of the device. + /// + public ProtectionSupportedReport? SupportedReport { get; private set; } + + /// + /// Request the protection capabilities of a device. + /// + public async Task GetSupportedAsync(CancellationToken cancellationToken) + { + ProtectionSupportedGetCommand command = ProtectionSupportedGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + ProtectionSupportedReport report = ProtectionSupportedReportCommand.Parse(reportFrame, Logger); + SupportedReport = report; + return report; + } + + internal readonly struct ProtectionSupportedGetCommand : ICommand + { + public ProtectionSupportedGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Protection; + + public static byte CommandId => (byte)ProtectionCommand.SupportedGet; + + public CommandClassFrame Frame { get; } + + public static ProtectionSupportedGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new ProtectionSupportedGetCommand(frame); + } + } + + internal readonly struct ProtectionSupportedReportCommand : ICommand + { + public ProtectionSupportedReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Protection; + + public static byte CommandId => (byte)ProtectionCommand.SupportedReport; + + public CommandClassFrame Frame { get; } + + public static ProtectionSupportedReportCommand Create( + bool supportsExclusiveControl, + bool supportsTimeout, + IReadOnlySet supportedLocalStates, + IReadOnlySet supportedRfStates) + { + Span commandParameters = stackalloc byte[5]; + + byte flags = 0; + if (supportsExclusiveControl) + { + flags |= 0b0000_0010; + } + + if (supportsTimeout) + { + flags |= 0b0000_0001; + } + + commandParameters[0] = flags; + + foreach (LocalProtectionState state in supportedLocalStates) + { + int bitIndex = (byte)state; + commandParameters[1 + (bitIndex / 8)] |= (byte)(1 << (bitIndex % 8)); + } + + foreach (RfProtectionState state in supportedRfStates) + { + int bitIndex = (byte)state; + commandParameters[3 + (bitIndex / 8)] |= (byte)(1 << (bitIndex % 8)); + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ProtectionSupportedReportCommand(frame); + } + + public static ProtectionSupportedReport Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 5) + { + logger.LogWarning( + "Protection Supported Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Protection Supported Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + + bool supportsExclusiveControl = (span[0] & 0b0000_0010) != 0; + bool supportsTimeout = (span[0] & 0b0000_0001) != 0; + + HashSet supportedLocalStates = + BitMaskHelper.ParseBitMask(span.Slice(1, 2)); + HashSet supportedRfStates = + BitMaskHelper.ParseBitMask(span.Slice(3, 2)); + + return new ProtectionSupportedReport( + supportsExclusiveControl, + supportsTimeout, + supportedLocalStates, + supportedRfStates); + } + } +} diff --git a/src/ZWave.CommandClasses/ProtectionCommandClass.Timeout.cs b/src/ZWave.CommandClasses/ProtectionCommandClass.Timeout.cs new file mode 100644 index 0000000..810d39d --- /dev/null +++ b/src/ZWave.CommandClasses/ProtectionCommandClass.Timeout.cs @@ -0,0 +1,143 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class ProtectionCommandClass +{ + /// + /// Gets the last timeout received from the device, or if not yet queried. + /// + /// + /// means no timer is active. + /// means infinite RF protection. + /// A value from indicates a reserved encoding. + /// + public TimeSpan? LastTimeout { get; private set; } + + /// + /// Event raised when a Protection Timeout Report is received. + /// + public event Action? OnTimeoutReportReceived; + + /// + /// Request the remaining RF protection timeout from a device. + /// + /// + /// for no timer, for infinite, + /// a concrete duration for 1–60 seconds or 2–191 minutes, or for reserved values. + /// + public async Task GetTimeoutAsync(CancellationToken cancellationToken) + { + ProtectionTimeoutGetCommand command = ProtectionTimeoutGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + TimeSpan? timeout = ProtectionTimeoutReportCommand.Parse(reportFrame, Logger); + if (timeout.HasValue) + { + LastTimeout = timeout; + OnTimeoutReportReceived?.Invoke(timeout.Value); + } + + return timeout; + } + + /// + /// Set the RF protection timeout for a device. + /// + /// + /// The timeout value encoded per the Z-Wave specification: + /// 0x00 = no timer, 0x010x3C = 1–60 seconds, + /// 0x410xFE = 2–191 minutes, 0xFF = infinite. + /// + /// The cancellation token. + public async Task SetTimeoutAsync(byte timeout, CancellationToken cancellationToken) + { + var command = ProtectionTimeoutSetCommand.Create(timeout); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + internal readonly struct ProtectionTimeoutSetCommand : ICommand + { + public ProtectionTimeoutSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Protection; + + public static byte CommandId => (byte)ProtectionCommand.TimeoutSet; + + public CommandClassFrame Frame { get; } + + public static ProtectionTimeoutSetCommand Create(byte timeout) + { + ReadOnlySpan commandParameters = [timeout]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ProtectionTimeoutSetCommand(frame); + } + } + + internal readonly struct ProtectionTimeoutGetCommand : ICommand + { + public ProtectionTimeoutGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Protection; + + public static byte CommandId => (byte)ProtectionCommand.TimeoutGet; + + public CommandClassFrame Frame { get; } + + public static ProtectionTimeoutGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new ProtectionTimeoutGetCommand(frame); + } + } + + internal readonly struct ProtectionTimeoutReportCommand : ICommand + { + public ProtectionTimeoutReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Protection; + + public static byte CommandId => (byte)ProtectionCommand.TimeoutReport; + + public CommandClassFrame Frame { get; } + + public static ProtectionTimeoutReportCommand Create(byte timeout) + { + ReadOnlySpan commandParameters = [timeout]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ProtectionTimeoutReportCommand(frame); + } + + public static TimeSpan? Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning( + "Protection Timeout Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Protection Timeout Report frame is too short"); + } + + byte value = frame.CommandParameters.Span[0]; + return value switch + { + 0x00 => TimeSpan.Zero, + >= 0x01 and <= 0x3C => TimeSpan.FromSeconds(value), + >= 0x41 and <= 0xFE => TimeSpan.FromMinutes(value - 0x3F), + 0xFF => Timeout.InfiniteTimeSpan, + _ => null, + }; + } + } +} diff --git a/src/ZWave.CommandClasses/ProtectionCommandClass.cs b/src/ZWave.CommandClasses/ProtectionCommandClass.cs new file mode 100644 index 0000000..6445a15 --- /dev/null +++ b/src/ZWave.CommandClasses/ProtectionCommandClass.cs @@ -0,0 +1,176 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// The state of local (physical) protection. +/// +public enum LocalProtectionState : byte +{ + /// + /// The device is not protected and may be operated normally via its user interface. + /// + Unprotected = 0x00, + + /// + /// The device requires a complicated action sequence to operate via its user interface. + /// + ProtectionBySequence = 0x01, + + /// + /// The device cannot be operated via its user interface. + /// + NoOperationPossible = 0x02, +} + +/// +/// The state of RF (wireless) protection. +/// +public enum RfProtectionState : byte +{ + /// + /// The device accepts and responds to all RF commands. + /// + Unprotected = 0x00, + + /// + /// The device ignores runtime RF commands but still responds to status requests. + /// + NoRfControl = 0x01, + + /// + /// The device does not respond to any RF commands at all. + /// + NoRfResponse = 0x02, +} + +/// +/// Defines the commands for the Protection Command Class. +/// +public enum ProtectionCommand : byte +{ + /// + /// Set the protection state of a device. + /// + Set = 0x01, + + /// + /// Request the protection state of a device. + /// + Get = 0x02, + + /// + /// Report the protection state of a device. + /// + Report = 0x03, + + /// + /// Request the protection capabilities of a device. + /// + SupportedGet = 0x04, + + /// + /// Report the protection capabilities of a device. + /// + SupportedReport = 0x05, + + /// + /// Set the exclusive control node for a device. + /// + ExclusiveControlSet = 0x06, + + /// + /// Request the exclusive control node for a device. + /// + ExclusiveControlGet = 0x07, + + /// + /// Report the exclusive control node for a device. + /// + ExclusiveControlReport = 0x08, + + /// + /// Set the RF protection timeout for a device. + /// + TimeoutSet = 0x09, + + /// + /// Request the RF protection timeout for a device. + /// + TimeoutGet = 0x0A, + + /// + /// Report the RF protection timeout for a device. + /// + TimeoutReport = 0x0B, +} + +/// +/// Prevents unintentional control of a device by disabling its local user interface +/// and/or RF command acceptance. +/// +/// +/// Control via Z-Wave is always possible independently of the local protection state. +/// This Command Class is intended for convenience applications and SHOULD NOT be used +/// for safety-critical applications. +/// +[CommandClass(CommandClassId.Protection)] +public sealed partial class ProtectionCommandClass : CommandClass +{ + internal ProtectionCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + public override bool? IsCommandSupported(ProtectionCommand command) + => command switch + { + ProtectionCommand.Set => true, + ProtectionCommand.Get => true, + ProtectionCommand.SupportedGet => Version.HasValue ? Version >= 2 : null, + ProtectionCommand.ExclusiveControlSet => Version.HasValue ? Version >= 2 : null, + ProtectionCommand.ExclusiveControlGet => Version.HasValue ? Version >= 2 : null, + ProtectionCommand.TimeoutSet => Version.HasValue ? Version >= 2 : null, + ProtectionCommand.TimeoutGet => Version.HasValue ? Version >= 2 : null, + _ => false, + }; + + internal override async Task InterviewAsync(CancellationToken cancellationToken) + { + if (IsCommandSupported(ProtectionCommand.SupportedGet).GetValueOrDefault()) + { + _ = await GetSupportedAsync(cancellationToken).ConfigureAwait(false); + } + + _ = await GetAsync(cancellationToken).ConfigureAwait(false); + + if (SupportedReport?.SupportsExclusiveControl == true) + { + _ = await GetExclusiveControlAsync(cancellationToken).ConfigureAwait(false); + } + + if (SupportedReport?.SupportsTimeout == true) + { + _ = await GetTimeoutAsync(cancellationToken).ConfigureAwait(false); + } + } + + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((ProtectionCommand)frame.CommandId) + { + case ProtectionCommand.Report: + { + ProtectionReport report = ProtectionReportCommand.Parse(frame, Logger); + LastReport = report; + OnProtectionReportReceived?.Invoke(report); + break; + } + } + } +}