diff --git a/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.AdminCode.cs b/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.AdminCode.cs new file mode 100644 index 0000000..0d61f86 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.AdminCode.cs @@ -0,0 +1,119 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class UserCodeCommandClassTests +{ + [TestMethod] + public void AdminCodeSetCommand_Create_WithCode() + { + UserCodeCommandClass.AdminCodeSetCommand command = + UserCodeCommandClass.AdminCodeSetCommand.Create("1234"); + + Assert.AreEqual(CommandClassId.UserCode, UserCodeCommandClass.AdminCodeSetCommand.CommandClassId); + Assert.AreEqual((byte)UserCodeCommand.AdminCodeSet, UserCodeCommandClass.AdminCodeSetCommand.CommandId); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + // 1 (Reserved|Length) + 4 (Code) = 5 + Assert.AreEqual(5, parameters.Length); + Assert.AreEqual((byte)4, parameters[0]); // Code length + Assert.AreEqual((byte)0x31, parameters[1]); // '1' + Assert.AreEqual((byte)0x32, parameters[2]); // '2' + Assert.AreEqual((byte)0x33, parameters[3]); // '3' + Assert.AreEqual((byte)0x34, parameters[4]); // '4' + } + + [TestMethod] + public void AdminCodeSetCommand_Create_Deactivate() + { + UserCodeCommandClass.AdminCodeSetCommand command = + UserCodeCommandClass.AdminCodeSetCommand.Create(null); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + // 1 (Reserved|Length=0) + Assert.AreEqual(1, parameters.Length); + Assert.AreEqual((byte)0, parameters[0]); // Code length=0 + } + + [TestMethod] + public void AdminCodeSetCommand_Create_TooShort_Throws() + { + Assert.ThrowsExactly( + () => UserCodeCommandClass.AdminCodeSetCommand.Create("AB")); + } + + [TestMethod] + public void AdminCodeSetCommand_Create_NonAscii_Throws() + { + Assert.ThrowsExactly( + () => UserCodeCommandClass.AdminCodeSetCommand.Create("12\u00E934")); + } + + [TestMethod] + public void AdminCodeGetCommand_Create_HasCorrectFormat() + { + UserCodeCommandClass.AdminCodeGetCommand command = + UserCodeCommandClass.AdminCodeGetCommand.Create(); + + Assert.AreEqual(CommandClassId.UserCode, UserCodeCommandClass.AdminCodeGetCommand.CommandClassId); + Assert.AreEqual((byte)UserCodeCommand.AdminCodeGet, UserCodeCommandClass.AdminCodeGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void AdminCodeReport_Parse_WithCode() + { + // CC=0x63, Cmd=0x10, Reserved|Length=0x06, Code="123456" + byte[] data = [0x63, 0x10, 0x06, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36]; + CommandClassFrame frame = new(data); + + string? adminCode = UserCodeCommandClass.AdminCodeReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual("123456", adminCode); + } + + [TestMethod] + public void AdminCodeReport_Parse_Deactivated() + { + // CC=0x63, Cmd=0x10, Reserved|Length=0x00 + byte[] data = [0x63, 0x10, 0x00]; + CommandClassFrame frame = new(data); + + string? adminCode = UserCodeCommandClass.AdminCodeReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsNull(adminCode); + } + + [TestMethod] + public void AdminCodeReport_Parse_MaxLengthCode() + { + // 10-character admin code + byte[] data = [0x63, 0x10, 0x0A, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39]; + CommandClassFrame frame = new(data); + + string? adminCode = UserCodeCommandClass.AdminCodeReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual("0123456789", adminCode); + } + + [TestMethod] + public void AdminCodeReport_Parse_TooShort_Throws() + { + byte[] data = [0x63, 0x10]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => UserCodeCommandClass.AdminCodeReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void AdminCodeReport_Parse_TooShortForCode_Throws() + { + // Length says 4 but only 2 code bytes follow + byte[] data = [0x63, 0x10, 0x04, 0x31, 0x32]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => UserCodeCommandClass.AdminCodeReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.Capabilities.cs b/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.Capabilities.cs new file mode 100644 index 0000000..50e177d --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.Capabilities.cs @@ -0,0 +1,165 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class UserCodeCommandClassTests +{ + [TestMethod] + public void CapabilitiesGetCommand_Create_HasCorrectFormat() + { + UserCodeCommandClass.CapabilitiesGetCommand command = + UserCodeCommandClass.CapabilitiesGetCommand.Create(); + + Assert.AreEqual(CommandClassId.UserCode, UserCodeCommandClass.CapabilitiesGetCommand.CommandClassId); + Assert.AreEqual((byte)UserCodeCommand.CapabilitiesGet, UserCodeCommandClass.CapabilitiesGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void CapabilitiesReport_Parse_AllFeatures() + { + // Byte 0: AC=1, ACD=1, Reserved=0, StatusBitmaskLen=1 + // = 0b1100_0001 = 0xC1 + // Byte 1 (status bitmask): bits 0,1,2,3,4 set (Available, EnabledGrantAccess, Disabled, Messaging, PassageMode) + // = 0b0001_1111 = 0x1F + // Byte 2: UCC=1, MUCR=1, MUCS=1, KeypadModesBitmaskLen=1 + // = 0b1110_0001 = 0xE1 + // Byte 3 (keypad modes bitmask): bits 0,1,2,3 set (Normal, Vacation, Privacy, LockedOut) + // = 0b0000_1111 = 0x0F + // Byte 4: Reserved=0, KeysBitmaskLen=2 + // = 0x02 + // Bytes 5-6 (keys bitmask): support ASCII 0x30-0x39 (digits 0-9) + // Byte 5 covers ASCII 0-7, byte 6 covers ASCII 8-15 + // Digit 0x30=48: byte 48/8=6, bit 48%8=0 → but we only have 2 bitmask bytes covering 0-15 + // Let's support ASCII 0x00-0x03 for simplicity + // Byte 5: bits 0,1,2,3 set = 0x0F + // Byte 6: bit 0 set (ASCII 8) = 0x01 + byte[] data = [0x63, 0x07, 0xC1, 0x1F, 0xE1, 0x0F, 0x02, 0x0F, 0x01]; + CommandClassFrame frame = new(data); + + UserCodeCapabilities capabilities = + UserCodeCommandClass.CapabilitiesReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsTrue(capabilities.AdminCodeSupport); + Assert.IsTrue(capabilities.AdminCodeDeactivationSupport); + Assert.IsTrue(capabilities.ChecksumSupport); + Assert.IsTrue(capabilities.MultipleReportSupport); + Assert.IsTrue(capabilities.MultipleSetSupport); + + // 5 statuses: Available, EnabledGrantAccess, Disabled, Messaging, PassageMode + Assert.HasCount(5, capabilities.SupportedStatuses); + Assert.Contains(UserIdStatus.Available, capabilities.SupportedStatuses); + Assert.Contains(UserIdStatus.EnabledGrantAccess, capabilities.SupportedStatuses); + Assert.Contains(UserIdStatus.Disabled, capabilities.SupportedStatuses); + Assert.Contains(UserIdStatus.Messaging, capabilities.SupportedStatuses); + Assert.Contains(UserIdStatus.PassageMode, capabilities.SupportedStatuses); + + // 4 keypad modes + Assert.HasCount(4, capabilities.SupportedKeypadModes); + Assert.Contains(UserCodeKeypadMode.Normal, capabilities.SupportedKeypadModes); + Assert.Contains(UserCodeKeypadMode.Vacation, capabilities.SupportedKeypadModes); + Assert.Contains(UserCodeKeypadMode.Privacy, capabilities.SupportedKeypadModes); + Assert.Contains(UserCodeKeypadMode.LockedOut, capabilities.SupportedKeypadModes); + + // Keys: ASCII 0,1,2,3 from byte 5, and ASCII 8 from byte 6 + Assert.HasCount(5, capabilities.SupportedKeys); + Assert.Contains((byte)0, capabilities.SupportedKeys); + Assert.Contains((byte)1, capabilities.SupportedKeys); + Assert.Contains((byte)2, capabilities.SupportedKeys); + Assert.Contains((byte)3, capabilities.SupportedKeys); + Assert.Contains((byte)8, capabilities.SupportedKeys); + } + + [TestMethod] + public void CapabilitiesReport_Parse_MinimalFeatures() + { + // AC=0, ACD=0, StatusBitmaskLen=1 + // = 0b0000_0001 = 0x01 + // Status bitmask: bits 0,1,2 set (Available, EnabledGrantAccess, Disabled — mandatory per spec) + // = 0b0000_0111 = 0x07 + // UCC=0, MUCR=0, MUCS=0, KeypadModesBitmaskLen=1 + // = 0b0000_0001 = 0x01 + // Keypad modes bitmask: bit 0 set (Normal — mandatory per spec) + // = 0x01 + // KeysBitmaskLen=0 + // = 0x00 + byte[] data = [0x63, 0x07, 0x01, 0x07, 0x01, 0x01, 0x00]; + CommandClassFrame frame = new(data); + + UserCodeCapabilities capabilities = + UserCodeCommandClass.CapabilitiesReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsFalse(capabilities.AdminCodeSupport); + Assert.IsFalse(capabilities.AdminCodeDeactivationSupport); + Assert.IsFalse(capabilities.ChecksumSupport); + Assert.IsFalse(capabilities.MultipleReportSupport); + Assert.IsFalse(capabilities.MultipleSetSupport); + + Assert.HasCount(3, capabilities.SupportedStatuses); + Assert.HasCount(1, capabilities.SupportedKeypadModes); + Assert.IsEmpty(capabilities.SupportedKeys); + } + + [TestMethod] + public void CapabilitiesReport_Parse_WithDigitKeys() + { + // Test parsing with actual digit keys (0x30-0x39) + // AC=0, ACD=0, StatusBitmaskLen=1 + byte[] data = new byte[2 + 1 + 1 + 1 + 1 + 1 + 7]; + data[0] = 0x63; // CC + data[1] = 0x07; // Cmd + data[2] = 0x01; // StatusBitmaskLen=1 + data[3] = 0x07; // Status bits 0,1,2 + data[4] = 0x01; // KeypadModesBitmaskLen=1 + data[5] = 0x01; // Keypad mode bit 0 + data[6] = 0x07; // KeysBitmaskLen=7 (to cover ASCII 0x30-0x39, need bytes 0-6, since 0x39/8=7.125) + // ASCII 0x30=48: byte 6, bit 0; 0x31=49: byte 6, bit 1; ... 0x37=55: byte 6, bit 7 + // ASCII 0x38=56: byte 7, bit 0; 0x39=57: byte 7, bit 1 + // But we have bytes starting at index 7-13 (7 bytes: covering ASCII 0-55) + // We need at least 8 bytes to cover up to ASCII 63 + // Actually let's simplify: KeysBitmaskLen=7 covers ASCII 0..55 + // 0x30=48 is in byte 6 (48/8=6), bit 0 (48%8=0) + // 0x37=55 is in byte 6 (55/8=6), bit 7 (55%8=7) + // So byte 6 of the bitmask (index 7+6=13) should be 0xFF for digits 0-7 + // But that's only if bitmask length >= 7 + data[7] = 0x00; // ASCII 0-7 + data[8] = 0x00; // ASCII 8-15 + data[9] = 0x00; // ASCII 16-23 + data[10] = 0x00; // ASCII 24-31 + data[11] = 0x00; // ASCII 32-39 + data[12] = 0x00; // ASCII 40-47 + data[13] = 0xFF; // ASCII 48-55 (digits '0' through '7') + CommandClassFrame frame = new(data); + + UserCodeCapabilities capabilities = + UserCodeCommandClass.CapabilitiesReportCommand.Parse(frame, NullLogger.Instance); + + // Should have ASCII 48-55 (0x30-0x37) + Assert.HasCount(8, capabilities.SupportedKeys); + for (byte i = 0x30; i <= 0x37; i++) + { + Assert.Contains(i, capabilities.SupportedKeys); + } + } + + [TestMethod] + public void CapabilitiesReport_Parse_TooShort_Throws() + { + byte[] data = [0x63, 0x07, 0x01]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => UserCodeCommandClass.CapabilitiesReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void CapabilitiesReport_Parse_TooShortForStatusBitmask_Throws() + { + // StatusBitmaskLen=2 but only 1 byte of bitmask follows + byte[] data = [0x63, 0x07, 0x02, 0xFF]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => UserCodeCommandClass.CapabilitiesReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.Checksum.cs b/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.Checksum.cs new file mode 100644 index 0000000..f0306b9 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.Checksum.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class UserCodeCommandClassTests +{ + [TestMethod] + public void ChecksumGetCommand_Create_HasCorrectFormat() + { + UserCodeCommandClass.ChecksumGetCommand command = + UserCodeCommandClass.ChecksumGetCommand.Create(); + + Assert.AreEqual(CommandClassId.UserCode, UserCodeCommandClass.ChecksumGetCommand.CommandClassId); + Assert.AreEqual((byte)UserCodeCommand.ChecksumGet, UserCodeCommandClass.ChecksumGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void ChecksumReport_Parse_ValidChecksum() + { + // CC=0x63, Cmd=0x12, Checksum=0xEAAD (from spec example) + byte[] data = [0x63, 0x12, 0xEA, 0xAD]; + CommandClassFrame frame = new(data); + + ushort checksum = UserCodeCommandClass.ChecksumReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ushort)0xEAAD, checksum); + } + + [TestMethod] + public void ChecksumReport_Parse_NoCodesSet() + { + // Per spec CC:0063.02.12.11.002: MUST be 0x0000 if no User Code is set + byte[] data = [0x63, 0x12, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + ushort checksum = UserCodeCommandClass.ChecksumReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ushort)0x0000, checksum); + } + + [TestMethod] + public void ChecksumReport_Parse_TooShort_Throws() + { + byte[] data = [0x63, 0x12, 0xEA]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => UserCodeCommandClass.ChecksumReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.ExtendedUserCode.cs b/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.ExtendedUserCode.cs new file mode 100644 index 0000000..e9c49cc --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.ExtendedUserCode.cs @@ -0,0 +1,339 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class UserCodeCommandClassTests +{ + [TestMethod] + public void ExtendedUserCodeSetCommand_CreateBulk_SingleEntry() + { + List entries = + [ + new ExtendedUserCodeEntry(1, UserIdStatus.EnabledGrantAccess, "1234"), + ]; + + UserCodeCommandClass.ExtendedUserCodeSetCommand command = + UserCodeCommandClass.ExtendedUserCodeSetCommand.CreateBulk(entries); + + Assert.AreEqual(CommandClassId.UserCode, UserCodeCommandClass.ExtendedUserCodeSetCommand.CommandClassId); + Assert.AreEqual((byte)UserCodeCommand.ExtendedUserCodeSet, UserCodeCommandClass.ExtendedUserCodeSetCommand.CommandId); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + // 1 (count) + 2 (UserID) + 1 (Status) + 1 (Len) + 4 (Code) = 9 + Assert.AreEqual(9, parameters.Length); + Assert.AreEqual((byte)1, parameters[0]); // count + Assert.AreEqual((byte)0x00, parameters[1]); // UserID MSB + Assert.AreEqual((byte)0x01, parameters[2]); // UserID LSB + Assert.AreEqual((byte)UserIdStatus.EnabledGrantAccess, parameters[3]); // Status + Assert.AreEqual((byte)4, parameters[4]); // Code length + Assert.AreEqual((byte)0x31, parameters[5]); // '1' + Assert.AreEqual((byte)0x32, parameters[6]); // '2' + Assert.AreEqual((byte)0x33, parameters[7]); // '3' + Assert.AreEqual((byte)0x34, parameters[8]); // '4' + } + + [TestMethod] + public void ExtendedUserCodeSetCommand_CreateBulk_MultipleEntries() + { + List entries = + [ + new ExtendedUserCodeEntry(1, UserIdStatus.EnabledGrantAccess, "1234"), + new ExtendedUserCodeEntry(2, UserIdStatus.Available, null), + ]; + + UserCodeCommandClass.ExtendedUserCodeSetCommand command = + UserCodeCommandClass.ExtendedUserCodeSetCommand.CreateBulk(entries); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + // 1 (count) + (2+1+1+4) + (2+1+1+0) = 1 + 8 + 4 = 13 + Assert.AreEqual(13, parameters.Length); + Assert.AreEqual((byte)2, parameters[0]); // count=2 + + // Entry 2: Available, no code + Assert.AreEqual((byte)0x00, parameters[9]); // UserID MSB + Assert.AreEqual((byte)0x02, parameters[10]); // UserID LSB + Assert.AreEqual((byte)UserIdStatus.Available, parameters[11]); // Status + Assert.AreEqual((byte)0, parameters[12]); // Code length=0 + } + + [TestMethod] + public void ExtendedUserCodeSetCommand_CreateBulk_TooShortCode_Throws() + { + List entries = + [ + new ExtendedUserCodeEntry(1, UserIdStatus.EnabledGrantAccess, "12"), + ]; + + Assert.ThrowsExactly( + () => UserCodeCommandClass.ExtendedUserCodeSetCommand.CreateBulk(entries)); + } + + [TestMethod] + public void ExtendedUserCodeSetCommand_CreateBulk_NonAsciiCode_Throws() + { + List entries = + [ + new ExtendedUserCodeEntry(1, UserIdStatus.EnabledGrantAccess, "12\u00E934"), + ]; + + Assert.ThrowsExactly( + () => UserCodeCommandClass.ExtendedUserCodeSetCommand.CreateBulk(entries)); + } + + [TestMethod] + public void ExtendedUserCodeSetCommand_CreateBulk_AllowsNonDigitAscii() + { + // V2 Extended commands allow any ASCII, not just digits + List entries = + [ + new ExtendedUserCodeEntry(1, UserIdStatus.EnabledGrantAccess, "AB12CD"), + ]; + + UserCodeCommandClass.ExtendedUserCodeSetCommand command = + UserCodeCommandClass.ExtendedUserCodeSetCommand.CreateBulk(entries); + + // Should not throw — just verify it created successfully + Assert.AreEqual(CommandClassId.UserCode, UserCodeCommandClass.ExtendedUserCodeSetCommand.CommandClassId); + } + + [TestMethod] + public void ExtendedUserCodeSetCommand_CreateSingular_HasCorrectFormat() + { + UserCodeCommandClass.ExtendedUserCodeSetCommand command = + UserCodeCommandClass.ExtendedUserCodeSetCommand.Create(1, UserIdStatus.EnabledGrantAccess, "1234"); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + // 1 (count) + 2 (UserID) + 1 (Status) + 1 (Len) + 4 (Code) = 9 + Assert.AreEqual(9, parameters.Length); + Assert.AreEqual((byte)1, parameters[0]); // count + Assert.AreEqual((byte)0x00, parameters[1]); // UserID MSB + Assert.AreEqual((byte)0x01, parameters[2]); // UserID LSB + Assert.AreEqual((byte)UserIdStatus.EnabledGrantAccess, parameters[3]); + Assert.AreEqual((byte)4, parameters[4]); // Code length + Assert.AreEqual((byte)0x31, parameters[5]); + Assert.AreEqual((byte)0x32, parameters[6]); + Assert.AreEqual((byte)0x33, parameters[7]); + Assert.AreEqual((byte)0x34, parameters[8]); + } + + [TestMethod] + public void ExtendedUserCodeSetCommand_CreateSingular_LargeUserId() + { + UserCodeCommandClass.ExtendedUserCodeSetCommand command = + UserCodeCommandClass.ExtendedUserCodeSetCommand.Create(500, UserIdStatus.Disabled, "5678"); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)0x01, parameters[1]); // 500 = 0x01F4 + Assert.AreEqual((byte)0xF4, parameters[2]); + Assert.AreEqual((byte)UserIdStatus.Disabled, parameters[3]); + } + + [TestMethod] + public void ExtendedUserCodeSetCommand_CreateSingular_TooShort_Throws() + { + Assert.ThrowsExactly( + () => UserCodeCommandClass.ExtendedUserCodeSetCommand.Create(1, UserIdStatus.EnabledGrantAccess, "12")); + } + + [TestMethod] + public void ExtendedUserCodeSetCommand_CreateClear_HasCorrectFormat() + { + UserCodeCommandClass.ExtendedUserCodeSetCommand command = + UserCodeCommandClass.ExtendedUserCodeSetCommand.CreateClear(42); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + // 1 (count) + 2 (UserID) + 1 (Status=Available) + 1 (Len=0) = 5 + Assert.AreEqual(5, parameters.Length); + Assert.AreEqual((byte)1, parameters[0]); // count + Assert.AreEqual((byte)0x00, parameters[1]); // UserID MSB + Assert.AreEqual((byte)42, parameters[2]); // UserID LSB + Assert.AreEqual((byte)UserIdStatus.Available, parameters[3]); + Assert.AreEqual((byte)0, parameters[4]); // Code length=0 + } + + [TestMethod] + public void ExtendedUserCodeGetCommand_Create_WithoutReportMore() + { + UserCodeCommandClass.ExtendedUserCodeGetCommand command = + UserCodeCommandClass.ExtendedUserCodeGetCommand.Create(100, reportMore: false); + + Assert.AreEqual(CommandClassId.UserCode, UserCodeCommandClass.ExtendedUserCodeGetCommand.CommandClassId); + Assert.AreEqual((byte)UserCodeCommand.ExtendedUserCodeGet, UserCodeCommandClass.ExtendedUserCodeGetCommand.CommandId); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual(3, parameters.Length); + Assert.AreEqual((byte)0x00, parameters[0]); // UserID MSB + Assert.AreEqual((byte)100, parameters[1]); // UserID LSB + Assert.AreEqual((byte)0x00, parameters[2]); // ReportMore=false + } + + [TestMethod] + public void ExtendedUserCodeGetCommand_Create_WithReportMore() + { + UserCodeCommandClass.ExtendedUserCodeGetCommand command = + UserCodeCommandClass.ExtendedUserCodeGetCommand.Create(256, reportMore: true); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual(3, parameters.Length); + Assert.AreEqual((byte)0x01, parameters[0]); // UserID MSB (256 = 0x0100) + Assert.AreEqual((byte)0x00, parameters[1]); // UserID LSB + Assert.AreEqual((byte)0x01, parameters[2]); // ReportMore=true + } + + [TestMethod] + public void ExtendedUserCodeReport_ParseBulk_SingleEntry() + { + // CC=0x63, Cmd=0x0D, Count=1, UserID=0x0001, Status=0x01 (Enabled), + // Reserved|Len=0x04, Code="5678", NextUserID=0x0002 + byte[] data = + [ + 0x63, 0x0D, // CC + Cmd + 0x01, // count + 0x00, 0x01, // UserID + 0x01, // Status (EnabledGrantAccess) + 0x04, // Code length + 0x35, 0x36, 0x37, 0x38, // "5678" + 0x00, 0x02, // NextUserID + ]; + CommandClassFrame frame = new(data); + + ExtendedUserCodeReport report = + UserCodeCommandClass.ExtendedUserCodeReportCommand.ParseBulk(frame, NullLogger.Instance); + + Assert.HasCount(1, report.Entries); + Assert.AreEqual((ushort)1, report.Entries[0].UserIdentifier); + Assert.AreEqual(UserIdStatus.EnabledGrantAccess, report.Entries[0].Status); + Assert.AreEqual("5678", report.Entries[0].UserCode); + Assert.AreEqual((ushort)2, report.NextUserIdentifier); + } + + [TestMethod] + public void ExtendedUserCodeReport_Parse_ReturnsFirstEntry() + { + byte[] data = + [ + 0x63, 0x0D, // CC + Cmd + 0x01, // count + 0x00, 0x01, // UserID + 0x01, // Status (EnabledGrantAccess) + 0x04, // Code length + 0x35, 0x36, 0x37, 0x38, // "5678" + 0x00, 0x02, // NextUserID + ]; + CommandClassFrame frame = new(data); + + ExtendedUserCodeEntry entry = + UserCodeCommandClass.ExtendedUserCodeReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ushort)1, entry.UserIdentifier); + Assert.AreEqual(UserIdStatus.EnabledGrantAccess, entry.Status); + Assert.AreEqual("5678", entry.UserCode); + } + + [TestMethod] + public void ExtendedUserCodeReport_ParseBulk_MultipleEntries() + { + // Count=2: Entry1 (ID=1, Enabled, "1234"), Entry2 (ID=3, PassageMode, "9277"), NextUserID=0 + byte[] data = + [ + 0x63, 0x0D, // CC + Cmd + 0x02, // count + 0x00, 0x01, // UserID 1 + 0x01, // Status (EnabledGrantAccess) + 0x04, // Code length + 0x31, 0x32, 0x33, 0x34, // "1234" + 0x00, 0x03, // UserID 3 + 0x04, // Status (PassageMode) + 0x04, // Code length + 0x39, 0x32, 0x37, 0x37, // "9277" + 0x00, 0x00, // NextUserID=0 (no more) + ]; + CommandClassFrame frame = new(data); + + ExtendedUserCodeReport report = + UserCodeCommandClass.ExtendedUserCodeReportCommand.ParseBulk(frame, NullLogger.Instance); + + Assert.HasCount(2, report.Entries); + + Assert.AreEqual((ushort)1, report.Entries[0].UserIdentifier); + Assert.AreEqual(UserIdStatus.EnabledGrantAccess, report.Entries[0].Status); + Assert.AreEqual("1234", report.Entries[0].UserCode); + + Assert.AreEqual((ushort)3, report.Entries[1].UserIdentifier); + Assert.AreEqual(UserIdStatus.PassageMode, report.Entries[1].Status); + Assert.AreEqual("9277", report.Entries[1].UserCode); + + Assert.AreEqual((ushort)0, report.NextUserIdentifier); + } + + [TestMethod] + public void ExtendedUserCodeReport_ParseBulk_AvailableStatus() + { + // Available status: code length=0, no code bytes + byte[] data = + [ + 0x63, 0x0D, // CC + Cmd + 0x01, // count + 0x00, 0x05, // UserID 5 + 0x00, // Status (Available) + 0x00, // Code length=0 + 0x00, 0x06, // NextUserID=6 + ]; + CommandClassFrame frame = new(data); + + ExtendedUserCodeReport report = + UserCodeCommandClass.ExtendedUserCodeReportCommand.ParseBulk(frame, NullLogger.Instance); + + Assert.HasCount(1, report.Entries); + Assert.AreEqual((ushort)5, report.Entries[0].UserIdentifier); + Assert.AreEqual(UserIdStatus.Available, report.Entries[0].Status); + Assert.IsNull(report.Entries[0].UserCode); + Assert.AreEqual((ushort)6, report.NextUserIdentifier); + } + + [TestMethod] + public void ExtendedUserCodeReport_ParseBulk_StatusNotAvailable() + { + // StatusNotAvailable (0xFE): code length=0, NextUserID=0 + byte[] data = + [ + 0x63, 0x0D, // CC + Cmd + 0x01, // count + 0xFF, 0xFF, // UserID 65535 (invalid) + 0xFE, // Status (StatusNotAvailable) + 0x00, // Code length=0 + 0x00, 0x00, // NextUserID=0 + ]; + CommandClassFrame frame = new(data); + + ExtendedUserCodeReport report = + UserCodeCommandClass.ExtendedUserCodeReportCommand.ParseBulk(frame, NullLogger.Instance); + + Assert.HasCount(1, report.Entries); + Assert.AreEqual(UserIdStatus.StatusNotAvailable, report.Entries[0].Status); + Assert.IsNull(report.Entries[0].UserCode); + Assert.AreEqual((ushort)0, report.NextUserIdentifier); + } + + [TestMethod] + public void ExtendedUserCodeReport_ParseBulk_TooShort_Throws() + { + // Only CC + Cmd + count, no user code blocks + byte[] data = [0x63, 0x0D, 0x01]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => UserCodeCommandClass.ExtendedUserCodeReportCommand.ParseBulk(frame, NullLogger.Instance)); + } + + [TestMethod] + public void ExtendedUserCodeReport_ParseBulk_ZeroBlocks_Throws() + { + // Count=0 is invalid per spec + byte[] data = [0x63, 0x0D, 0x00, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => UserCodeCommandClass.ExtendedUserCodeReportCommand.ParseBulk(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.KeypadMode.cs b/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.KeypadMode.cs new file mode 100644 index 0000000..b731fdf --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.KeypadMode.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class UserCodeCommandClassTests +{ + [TestMethod] + public void KeypadModeSetCommand_Create_HasCorrectFormat() + { + UserCodeCommandClass.KeypadModeSetCommand command = + UserCodeCommandClass.KeypadModeSetCommand.Create(UserCodeKeypadMode.Vacation); + + Assert.AreEqual(CommandClassId.UserCode, UserCodeCommandClass.KeypadModeSetCommand.CommandClassId); + Assert.AreEqual((byte)UserCodeCommand.KeypadModeSet, UserCodeCommandClass.KeypadModeSetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual((byte)UserCodeKeypadMode.Vacation, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void KeypadModeGetCommand_Create_HasCorrectFormat() + { + UserCodeCommandClass.KeypadModeGetCommand command = + UserCodeCommandClass.KeypadModeGetCommand.Create(); + + Assert.AreEqual(CommandClassId.UserCode, UserCodeCommandClass.KeypadModeGetCommand.CommandClassId); + Assert.AreEqual((byte)UserCodeCommand.KeypadModeGet, UserCodeCommandClass.KeypadModeGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void KeypadModeReport_Parse_NormalMode() + { + byte[] data = [0x63, 0x0A, 0x00]; + CommandClassFrame frame = new(data); + + UserCodeKeypadMode mode = UserCodeCommandClass.KeypadModeReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(UserCodeKeypadMode.Normal, mode); + } + + [TestMethod] + public void KeypadModeReport_Parse_LockedOutMode() + { + byte[] data = [0x63, 0x0A, 0x03]; + CommandClassFrame frame = new(data); + + UserCodeKeypadMode mode = UserCodeCommandClass.KeypadModeReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(UserCodeKeypadMode.LockedOut, mode); + } + + [TestMethod] + public void KeypadModeReport_Parse_TooShort_Throws() + { + byte[] data = [0x63, 0x0A]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => UserCodeCommandClass.KeypadModeReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.UserCode.cs b/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.UserCode.cs new file mode 100644 index 0000000..46973de --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.UserCode.cs @@ -0,0 +1,211 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class UserCodeCommandClassTests +{ + [TestMethod] + public void UserCodeSetCommand_Create_AvailableStatus() + { + UserCodeCommandClass.UserCodeSetCommand command = + UserCodeCommandClass.UserCodeSetCommand.CreateClear(1); + + Assert.AreEqual(CommandClassId.UserCode, UserCodeCommandClass.UserCodeSetCommand.CommandClassId); + Assert.AreEqual((byte)UserCodeCommand.Set, UserCodeCommandClass.UserCodeSetCommand.CommandId); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + // UserIdentifier(1) + Status(1) + Code(4 zero bytes) + Assert.AreEqual(6, parameters.Length); + Assert.AreEqual((byte)1, parameters[0]); + Assert.AreEqual((byte)UserIdStatus.Available, parameters[1]); + // Per spec CC:0063.01.01.11.009: code MUST be 0x00000000 when status is Available + Assert.AreEqual((byte)0x00, parameters[2]); + Assert.AreEqual((byte)0x00, parameters[3]); + Assert.AreEqual((byte)0x00, parameters[4]); + Assert.AreEqual((byte)0x00, parameters[5]); + } + + [TestMethod] + public void UserCodeSetCommand_Create_OccupiedStatus() + { + UserCodeCommandClass.UserCodeSetCommand command = + UserCodeCommandClass.UserCodeSetCommand.Create(5, UserIdStatus.EnabledGrantAccess, "1234"); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + // UserIdentifier(1) + Status(1) + Code(4) + Assert.AreEqual(6, parameters.Length); + Assert.AreEqual((byte)5, parameters[0]); + Assert.AreEqual((byte)UserIdStatus.EnabledGrantAccess, parameters[1]); + // ASCII "1234" = 0x31, 0x32, 0x33, 0x34 + Assert.AreEqual((byte)0x31, parameters[2]); + Assert.AreEqual((byte)0x32, parameters[3]); + Assert.AreEqual((byte)0x33, parameters[4]); + Assert.AreEqual((byte)0x34, parameters[5]); + } + + [TestMethod] + public void UserCodeSetCommand_CreateClear_AllUsers() + { + UserCodeCommandClass.UserCodeSetCommand command = + UserCodeCommandClass.UserCodeSetCommand.CreateClear(0); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)0, parameters[0]); + Assert.AreEqual((byte)UserIdStatus.Available, parameters[1]); + } + + [TestMethod] + public void UserCodeGetCommand_Create_HasCorrectFormat() + { + UserCodeCommandClass.UserCodeGetCommand command = + UserCodeCommandClass.UserCodeGetCommand.Create(3); + + Assert.AreEqual(CommandClassId.UserCode, UserCodeCommandClass.UserCodeGetCommand.CommandClassId); + Assert.AreEqual((byte)UserCodeCommand.Get, UserCodeCommandClass.UserCodeGetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual((byte)3, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void UserCodeSetCommand_Create_TooShort_Throws() + { + Assert.ThrowsExactly( + () => UserCodeCommandClass.UserCodeSetCommand.Create(1, UserIdStatus.EnabledGrantAccess, "123")); + } + + [TestMethod] + public void UserCodeSetCommand_Create_TooLong_Throws() + { + Assert.ThrowsExactly( + () => UserCodeCommandClass.UserCodeSetCommand.Create(1, UserIdStatus.EnabledGrantAccess, "12345678901")); + } + + [TestMethod] + public void UserCodeSetCommand_Create_NonDigit_Throws() + { + Assert.ThrowsExactly( + () => UserCodeCommandClass.UserCodeSetCommand.Create(1, UserIdStatus.EnabledGrantAccess, "12AB")); + } + + [TestMethod] + public void UserCodeReport_Parse_OccupiedStatus() + { + // CC=0x63, Cmd=0x03, UserID=1, Status=0x01 (Occupied), Code="1234" (0x31-0x34) + byte[] data = [0x63, 0x03, 0x01, 0x01, 0x31, 0x32, 0x33, 0x34]; + CommandClassFrame frame = new(data); + + UserCodeReport report = UserCodeCommandClass.UserCodeReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, report.UserIdentifier); + Assert.AreEqual(UserIdStatus.EnabledGrantAccess, report.Status); + Assert.AreEqual("1234", report.UserCode); + } + + [TestMethod] + public void UserCodeReport_Parse_AvailableStatus() + { + // CC=0x63, Cmd=0x03, UserID=2, Status=0x00 (Available), Code=0x00000000 + byte[] data = [0x63, 0x03, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + UserCodeReport report = UserCodeCommandClass.UserCodeReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)2, report.UserIdentifier); + Assert.AreEqual(UserIdStatus.Available, report.Status); + Assert.IsNull(report.UserCode); + } + + [TestMethod] + public void UserCodeReport_Parse_StatusNotAvailable() + { + // CC=0x63, Cmd=0x03, UserID=255, Status=0xFE (StatusNotAvailable) + byte[] data = [0x63, 0x03, 0xFF, 0xFE]; + CommandClassFrame frame = new(data); + + UserCodeReport report = UserCodeCommandClass.UserCodeReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)255, report.UserIdentifier); + Assert.AreEqual(UserIdStatus.StatusNotAvailable, report.Status); + Assert.IsNull(report.UserCode); + } + + [TestMethod] + public void UserCodeReport_Parse_LongCode() + { + // 10-digit code "1234567890" + byte[] data = [0x63, 0x03, 0x01, 0x01, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x30]; + CommandClassFrame frame = new(data); + + UserCodeReport report = UserCodeCommandClass.UserCodeReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual("1234567890", report.UserCode); + } + + [TestMethod] + public void UserCodeReport_Parse_TooShort_Throws() + { + // Only CC and Cmd, no parameters + byte[] data = [0x63, 0x03, 0x01]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => UserCodeCommandClass.UserCodeReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void UsersNumberGetCommand_Create_HasCorrectFormat() + { + UserCodeCommandClass.UsersNumberGetCommand command = + UserCodeCommandClass.UsersNumberGetCommand.Create(); + + Assert.AreEqual(CommandClassId.UserCode, UserCodeCommandClass.UsersNumberGetCommand.CommandClassId); + Assert.AreEqual((byte)UserCodeCommand.UsersNumberGet, UserCodeCommandClass.UsersNumberGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void UsersNumberReport_Parse_V1() + { + // CC=0x63, Cmd=0x05, SupportedUsers=10 + byte[] data = [0x63, 0x05, 0x0A]; + CommandClassFrame frame = new(data); + + ushort count = UserCodeCommandClass.UsersNumberReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ushort)10, count); + } + + [TestMethod] + public void UsersNumberReport_Parse_V2_Under256() + { + // CC=0x63, Cmd=0x05, SupportedUsers=50, ExtendedSupportedUsers=0x0032 (50) + byte[] data = [0x63, 0x05, 0x32, 0x00, 0x32]; + CommandClassFrame frame = new(data); + + ushort count = UserCodeCommandClass.UsersNumberReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ushort)50, count); + } + + [TestMethod] + public void UsersNumberReport_Parse_V2_Over255() + { + // CC=0x63, Cmd=0x05, SupportedUsers=255, ExtendedSupportedUsers=0x01F4 (500) + byte[] data = [0x63, 0x05, 0xFF, 0x01, 0xF4]; + CommandClassFrame frame = new(data); + + ushort count = UserCodeCommandClass.UsersNumberReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ushort)500, count); + } + + [TestMethod] + public void UsersNumberReport_Parse_TooShort_Throws() + { + byte[] data = [0x63, 0x05]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => UserCodeCommandClass.UsersNumberReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.cs new file mode 100644 index 0000000..a054228 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/UserCodeCommandClassTests.cs @@ -0,0 +1,6 @@ +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public partial class UserCodeCommandClassTests +{ +} diff --git a/src/ZWave.CommandClasses/UserCodeCommandClass.AdminCode.cs b/src/ZWave.CommandClasses/UserCodeCommandClass.AdminCode.cs new file mode 100644 index 0000000..52cace9 --- /dev/null +++ b/src/ZWave.CommandClasses/UserCodeCommandClass.AdminCode.cs @@ -0,0 +1,139 @@ +using System.Text; +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class UserCodeCommandClass +{ + /// + /// Gets the admin code from the device. + /// + /// The cancellation token. + /// The admin code, or if the admin code is deactivated or not supported. + public async Task GetAdminCodeAsync(CancellationToken cancellationToken) + { + var command = AdminCodeGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + string? adminCode = AdminCodeReportCommand.Parse(reportFrame, Logger); + return adminCode; + } + + /// + /// Sets or deactivates the admin code on the device. + /// + /// The admin code to set (4-10 ASCII characters), or to deactivate. + /// The cancellation token. + public async Task SetAdminCodeAsync(string? adminCode, CancellationToken cancellationToken) + { + var command = AdminCodeSetCommand.Create(adminCode); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + internal readonly struct AdminCodeSetCommand : ICommand + { + public AdminCodeSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.AdminCodeSet; + + public CommandClassFrame Frame { get; } + + public static AdminCodeSetCommand Create(string? adminCode) + { + if (adminCode is not null) + { + // Per spec CC:0063.02.0E.11.003: length 0 or 4-10, CC:0063.02.0E.11.006: ASCII + ValidateCode(adminCode, nameof(adminCode), digitsOnly: false); + } + + int codeLength = adminCode is not null ? Encoding.ASCII.GetByteCount(adminCode) : 0; + Span commandParameters = stackalloc byte[1 + codeLength]; + + // Reserved (4 bits) | Admin Code Length (4 bits) + commandParameters[0] = (byte)(codeLength & 0x0F); + + if (adminCode is not null) + { + Encoding.ASCII.GetBytes(adminCode, commandParameters[1..]); + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new AdminCodeSetCommand(frame); + } + } + + internal readonly struct AdminCodeGetCommand : ICommand + { + public AdminCodeGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.AdminCodeGet; + + public CommandClassFrame Frame { get; } + + public static AdminCodeGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new AdminCodeGetCommand(frame); + } + } + + internal readonly struct AdminCodeReportCommand : ICommand + { + public AdminCodeReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.AdminCodeReport; + + public CommandClassFrame Frame { get; } + + public static string? Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning( + "Admin Code Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Admin Code Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + + // Reserved (4 bits) | Admin Code Length (4 bits) + int codeLength = span[0] & 0x0F; + + if (codeLength == 0) + { + return null; + } + + if (span.Length < 1 + codeLength) + { + logger.LogWarning( + "Admin Code Report frame is too short for admin code ({Length} bytes, need {Expected})", + frame.CommandParameters.Length, + 1 + codeLength); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Admin Code Report frame is too short for admin code"); + } + + return Encoding.ASCII.GetString(span.Slice(1, codeLength)); + } + } +} diff --git a/src/ZWave.CommandClasses/UserCodeCommandClass.Capabilities.cs b/src/ZWave.CommandClasses/UserCodeCommandClass.Capabilities.cs new file mode 100644 index 0000000..575ccab --- /dev/null +++ b/src/ZWave.CommandClasses/UserCodeCommandClass.Capabilities.cs @@ -0,0 +1,198 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents the capabilities of a User Code Command Class device. +/// +/// Whether the admin code functionality is supported. +/// Whether the admin code can be deactivated. +/// Whether the user code checksum functionality is supported. +/// Whether reporting multiple user codes in a single command is supported. +/// Whether setting multiple user codes in a single command is supported. +/// The set of supported user ID status values. +/// The set of supported keypad modes. +/// The set of supported ASCII key codes for user code input. +public readonly record struct UserCodeCapabilities( + bool AdminCodeSupport, + bool AdminCodeDeactivationSupport, + bool ChecksumSupport, + bool MultipleReportSupport, + bool MultipleSetSupport, + IReadOnlySet SupportedStatuses, + IReadOnlySet SupportedKeypadModes, + IReadOnlySet SupportedKeys); + +public sealed partial class UserCodeCommandClass +{ + /// + /// Gets the capabilities of the device. Populated during interview for version 2+ devices. + /// + public UserCodeCapabilities? Capabilities { get; private set; } + + /// + /// Gets the user code capabilities from the device. + /// + /// The cancellation token. + /// The capabilities of the device. + public async Task GetCapabilitiesAsync(CancellationToken cancellationToken) + { + var command = CapabilitiesGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + UserCodeCapabilities capabilities = CapabilitiesReportCommand.Parse(reportFrame, Logger); + Capabilities = capabilities; + return capabilities; + } + + internal readonly struct CapabilitiesGetCommand : ICommand + { + public CapabilitiesGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.CapabilitiesGet; + + public CommandClassFrame Frame { get; } + + public static CapabilitiesGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new CapabilitiesGetCommand(frame); + } + } + + internal readonly struct CapabilitiesReportCommand : ICommand + { + public CapabilitiesReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.CapabilitiesReport; + + public CommandClassFrame Frame { get; } + + public static UserCodeCapabilities Parse(CommandClassFrame frame, ILogger logger) + { + // Minimum: 1 byte header for statuses + 0 bitmask + 1 byte header for keypad modes + 0 bitmask + 1 byte header for keys + if (frame.CommandParameters.Length < 3) + { + logger.LogWarning( + "User Code Capabilities Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "User Code Capabilities Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + int offset = 0; + + // Byte 0: AC Support (1) | ACD Support (1) | Reserved (1) | Status Bit Mask Length (5) + bool adminCodeSupport = (span[offset] & 0b1000_0000) != 0; + bool adminCodeDeactivationSupport = (span[offset] & 0b0100_0000) != 0; + int statusBitMaskLength = span[offset] & 0b0001_1111; + offset++; + + if (span.Length < offset + statusBitMaskLength) + { + logger.LogWarning( + "User Code Capabilities Report frame is too short for status bitmask ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "User Code Capabilities Report frame is too short for status bitmask"); + } + + HashSet supportedStatuses = + BitMaskHelper.ParseBitMask(span.Slice(offset, statusBitMaskLength)); + offset += statusBitMaskLength; + + if (span.Length < offset + 1) + { + logger.LogWarning( + "User Code Capabilities Report frame is too short for keypad mode header ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "User Code Capabilities Report frame is too short for keypad mode header"); + } + + // Next byte: UCC Support (1) | MUCR Support (1) | MUCS Support (1) | Keypad Modes Bit Mask Length (5) + bool checksumSupport = (span[offset] & 0b1000_0000) != 0; + bool multipleReportSupport = (span[offset] & 0b0100_0000) != 0; + bool multipleSetSupport = (span[offset] & 0b0010_0000) != 0; + int keypadModesBitMaskLength = span[offset] & 0b0001_1111; + offset++; + + if (span.Length < offset + keypadModesBitMaskLength) + { + logger.LogWarning( + "User Code Capabilities Report frame is too short for keypad modes bitmask ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "User Code Capabilities Report frame is too short for keypad modes bitmask"); + } + + HashSet supportedKeypadModes = + BitMaskHelper.ParseBitMask(span.Slice(offset, keypadModesBitMaskLength)); + offset += keypadModesBitMaskLength; + + if (span.Length < offset + 1) + { + logger.LogWarning( + "User Code Capabilities Report frame is too short for keys header ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "User Code Capabilities Report frame is too short for keys header"); + } + + // Next byte: Reserved (3) | Supported Keys Bit Mask Length (5) + int keysBitMaskLength = span[offset] & 0b0001_1111; + offset++; + + if (span.Length < offset + keysBitMaskLength) + { + logger.LogWarning( + "User Code Capabilities Report frame is too short for keys bitmask ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "User Code Capabilities Report frame is too short for keys bitmask"); + } + + // Parse supported keys (ASCII codes) — manual parsing since byte is not an enum + HashSet supportedKeys = []; + ReadOnlySpan keysBitMask = span.Slice(offset, keysBitMaskLength); + for (int byteNum = 0; byteNum < keysBitMask.Length; byteNum++) + { + for (int bitNum = 0; bitNum < 8; bitNum++) + { + if ((keysBitMask[byteNum] & (1 << bitNum)) != 0) + { + byte asciiCode = (byte)((byteNum * 8) + bitNum); + supportedKeys.Add(asciiCode); + } + } + } + + return new UserCodeCapabilities( + adminCodeSupport, + adminCodeDeactivationSupport, + checksumSupport, + multipleReportSupport, + multipleSetSupport, + supportedStatuses, + supportedKeypadModes, + supportedKeys); + } + } +} diff --git a/src/ZWave.CommandClasses/UserCodeCommandClass.Checksum.cs b/src/ZWave.CommandClasses/UserCodeCommandClass.Checksum.cs new file mode 100644 index 0000000..b007c4a --- /dev/null +++ b/src/ZWave.CommandClasses/UserCodeCommandClass.Checksum.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class UserCodeCommandClass +{ + /// + /// Gets the user code checksum from the device. + /// + /// The cancellation token. + /// The CRC-CCITT checksum representing all user codes, or 0x0000 if no codes are set. + public async Task GetChecksumAsync(CancellationToken cancellationToken) + { + var command = ChecksumGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + ushort checksum = ChecksumReportCommand.Parse(reportFrame, Logger); + return checksum; + } + + internal readonly struct ChecksumGetCommand : ICommand + { + public ChecksumGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.ChecksumGet; + + public CommandClassFrame Frame { get; } + + public static ChecksumGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new ChecksumGetCommand(frame); + } + } + + internal readonly struct ChecksumReportCommand : ICommand + { + public ChecksumReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.ChecksumReport; + + public CommandClassFrame Frame { get; } + + public static ushort Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 2) + { + logger.LogWarning( + "User Code Checksum Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "User Code Checksum Report frame is too short"); + } + + return frame.CommandParameters.Span[0..2].ToUInt16BE(); + } + } +} diff --git a/src/ZWave.CommandClasses/UserCodeCommandClass.ExtendedUserCode.cs b/src/ZWave.CommandClasses/UserCodeCommandClass.ExtendedUserCode.cs new file mode 100644 index 0000000..9a03b5d --- /dev/null +++ b/src/ZWave.CommandClasses/UserCodeCommandClass.ExtendedUserCode.cs @@ -0,0 +1,372 @@ +using System.Text; +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a single user code entry in an Extended User Code Report or Set. +/// +/// The user identifier (1-65535). +/// The status of the user identifier. +/// The user code as an ASCII string, or if not set. +public readonly record struct ExtendedUserCodeEntry( + ushort UserIdentifier, + UserIdStatus Status, + string? UserCode); + +/// +/// Represents an Extended User Code Report from a version 2 device. +/// +/// The user code entries in this report. +/// The next user identifier in use after the last entry, or 0 if this is the last one. +public readonly record struct ExtendedUserCodeReport( + IReadOnlyList Entries, + ushort NextUserIdentifier); + +public sealed partial class UserCodeCommandClass +{ + /// + /// Raised when an Extended User Code Report is received. + /// + public event Action? OnExtendedUserCodeReportReceived; + + /// + /// Gets the user code for a single user identifier. + /// + /// The user identifier to query (1-65535). + /// The cancellation token. + /// The user code entry for the requested identifier. + public async Task GetExtendedUserCodeAsync( + ushort userIdentifier, + CancellationToken cancellationToken) + { + var command = ExtendedUserCodeGetCommand.Create(userIdentifier, reportMore: false); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync( + predicate: frame => frame.CommandParameters.Length >= 3 + && frame.CommandParameters.Span[1..3].ToUInt16BE() == userIdentifier, + cancellationToken).ConfigureAwait(false); + return ExtendedUserCodeReportCommand.Parse(reportFrame, Logger); + } + + /// + /// Gets multiple user codes in bulk starting from the specified user identifier. + /// The device reports as many user codes as possible in a single response. + /// + /// The user identifier to start from (1-65535). + /// The cancellation token. + /// The extended user code report containing one or more user code entries. + public async Task GetBulkExtendedUserCodesAsync( + ushort userIdentifier, + CancellationToken cancellationToken) + { + var command = ExtendedUserCodeGetCommand.Create(userIdentifier, reportMore: true); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync( + predicate: frame => frame.CommandParameters.Length >= 3 + && frame.CommandParameters.Span[1..3].ToUInt16BE() == userIdentifier, + cancellationToken).ConfigureAwait(false); + ExtendedUserCodeReport report = ExtendedUserCodeReportCommand.ParseBulk(reportFrame, Logger); + OnExtendedUserCodeReportReceived?.Invoke(report); + return report; + } + + /// + /// Sets a single user code using the extended format. + /// + /// The user identifier (1-65535). + /// The user ID status to set. Must not be ; use instead. + /// The user code as an ASCII string (4-10 characters). + /// The cancellation token. + public async Task SetExtendedUserCodeAsync( + ushort userIdentifier, + UserIdStatus status, + string userCode, + CancellationToken cancellationToken) + { + var command = ExtendedUserCodeSetCommand.Create(userIdentifier, status, userCode); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// + /// Clears a single user code, or all user codes. + /// + /// The user identifier (1-65535), or 0 to clear all users. + /// The cancellation token. + public async Task ClearExtendedUserCodeAsync(ushort userIdentifier, CancellationToken cancellationToken) + { + var command = ExtendedUserCodeSetCommand.CreateClear(userIdentifier); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// + /// Sets one or more user codes using the extended format. + /// + /// The user code entries to set. + /// The cancellation token. + public async Task SetBulkExtendedUserCodesAsync( + IReadOnlyList entries, + CancellationToken cancellationToken) + { + var command = ExtendedUserCodeSetCommand.CreateBulk(entries); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + internal readonly struct ExtendedUserCodeSetCommand : ICommand + { + public ExtendedUserCodeSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.ExtendedUserCodeSet; + + public CommandClassFrame Frame { get; } + + /// + /// Creates a command to set a single user code (allocation-free). + /// + public static ExtendedUserCodeSetCommand Create( + ushort userIdentifier, + UserIdStatus status, + string userCode) + { + // Per spec CC:0063.02.0B.11.00A: length 4-10, CC:0063.02.0B.11.00C: ASCII + ValidateCode(userCode, nameof(userCode), digitsOnly: false); + + // 1 (count) + 2 (UserID) + 1 (Status) + 1 (Len) + code + Span commandParameters = stackalloc byte[5 + userCode.Length]; + commandParameters[0] = 1; + userIdentifier.WriteBytesBE(commandParameters[1..3]); + commandParameters[3] = (byte)status; + commandParameters[4] = (byte)(userCode.Length & 0x0F); + Encoding.ASCII.GetBytes(userCode, commandParameters[5..]); + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ExtendedUserCodeSetCommand(frame); + } + + /// + /// Creates a command to clear a single user code or all user codes (allocation-free). + /// + public static ExtendedUserCodeSetCommand CreateClear(ushort userIdentifier) + { + // 1 (count) + 2 (UserID) + 1 (Status=Available) + 1 (Len=0) + Span commandParameters = stackalloc byte[5]; + commandParameters[0] = 1; + userIdentifier.WriteBytesBE(commandParameters[1..3]); + commandParameters[3] = (byte)UserIdStatus.Available; + // commandParameters[4] is already 0 (code length = 0) + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ExtendedUserCodeSetCommand(frame); + } + + /// + /// Creates a command to set multiple user codes. + /// + public static ExtendedUserCodeSetCommand CreateBulk(IReadOnlyList entries) + { + // Validate all entries up front + for (int i = 0; i < entries.Count; i++) + { + if (entries[i].Status != UserIdStatus.Available && entries[i].UserCode is not null) + { + // Per spec CC:0063.02.0B.11.00A: length 4-10, CC:0063.02.0B.11.00C: ASCII + ValidateCode(entries[i].UserCode!, nameof(entries), digitsOnly: false); + } + } + + // Calculate total size: 1 (count) + sum of block sizes + // Since validation passed, all code chars are ASCII so string.Length == byte count + int totalSize = 1; + for (int i = 0; i < entries.Count; i++) + { + int codeLength = entries[i].Status != UserIdStatus.Available && entries[i].UserCode is not null + ? entries[i].UserCode!.Length + : 0; + // UserIdentifier(2) + UserIdStatus(1) + Reserved|UserCodeLength(1) + UserCode(N) + totalSize += 2 + 1 + 1 + codeLength; + } + + byte[] buffer = new byte[totalSize]; + buffer[0] = (byte)entries.Count; + int offset = 1; + + for (int i = 0; i < entries.Count; i++) + { + ExtendedUserCodeEntry entry = entries[i]; + entry.UserIdentifier.WriteBytesBE(buffer.AsSpan(offset, 2)); + offset += 2; + + buffer[offset] = (byte)entry.Status; + offset++; + + int codeLength = 0; + if (entry.Status != UserIdStatus.Available && entry.UserCode is not null) + { + codeLength = entry.UserCode.Length; + Encoding.ASCII.GetBytes(entry.UserCode, buffer.AsSpan(offset + 1, codeLength)); + } + + // Reserved (4 bits) | User Code Length (4 bits) + buffer[offset] = (byte)(codeLength & 0x0F); + offset += 1 + codeLength; + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, buffer); + return new ExtendedUserCodeSetCommand(frame); + } + } + + internal readonly struct ExtendedUserCodeGetCommand : ICommand + { + public ExtendedUserCodeGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.ExtendedUserCodeGet; + + public CommandClassFrame Frame { get; } + + public static ExtendedUserCodeGetCommand Create(ushort userIdentifier, bool reportMore) + { + Span commandParameters = stackalloc byte[3]; + userIdentifier.WriteBytesBE(commandParameters[0..2]); + commandParameters[2] = (byte)(reportMore ? 0x01 : 0x00); + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ExtendedUserCodeGetCommand(frame); + } + } + + internal readonly struct ExtendedUserCodeReportCommand : ICommand + { + public ExtendedUserCodeReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.ExtendedUserCodeReport; + + public CommandClassFrame Frame { get; } + + public static ExtendedUserCodeEntry Parse(CommandClassFrame frame, ILogger logger) + { + ValidateReportHeader(frame, logger); + ReadOnlySpan span = frame.CommandParameters.Span; + int offset = 1; + return ParseEntryBlock(frame, span, ref offset, 0, logger); + } + + public static ExtendedUserCodeReport ParseBulk(CommandClassFrame frame, ILogger logger) + { + ValidateReportHeader(frame, logger); + + ReadOnlySpan span = frame.CommandParameters.Span; + byte numberOfUserCodes = span[0]; + + List entries = new(numberOfUserCodes); + int offset = 1; + + for (int i = 0; i < numberOfUserCodes; i++) + { + entries.Add(ParseEntryBlock(frame, span, ref offset, i, logger)); + } + + // Next User Identifier (16 bits) at the end + if (span.Length < offset + 2) + { + logger.LogWarning( + "Extended User Code Report frame is too short for Next User Identifier ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Extended User Code Report frame is too short for Next User Identifier"); + } + + ushort nextUserIdentifier = span[offset..(offset + 2)].ToUInt16BE(); + + return new ExtendedUserCodeReport(entries, nextUserIdentifier); + } + + private static void ValidateReportHeader(CommandClassFrame frame, ILogger logger) + { + // Minimum: 1 (count) + 4 (one block: UserID(2) + Status(1) + Len(1)) + 2 (NextUserID) + if (frame.CommandParameters.Length < 7) + { + logger.LogWarning( + "Extended User Code Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Extended User Code Report frame is too short"); + } + + byte numberOfUserCodes = frame.CommandParameters.Span[0]; + if (numberOfUserCodes == 0) + { + logger.LogWarning("Extended User Code Report has 0 user code blocks"); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Extended User Code Report has 0 user code blocks"); + } + } + + private static ExtendedUserCodeEntry ParseEntryBlock( + CommandClassFrame frame, + ReadOnlySpan span, + ref int offset, + int blockIndex, + ILogger logger) + { + // Each block: UserIdentifier(2) + Status(1) + Reserved|Length(1) = 4 bytes minimum + if (span.Length < offset + 4) + { + logger.LogWarning( + "Extended User Code Report frame is too short for user code block {Index} ({Length} bytes)", + blockIndex, + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Extended User Code Report frame is too short for user code block"); + } + + ushort userIdentifier = span[offset..(offset + 2)].ToUInt16BE(); + offset += 2; + + UserIdStatus status = (UserIdStatus)span[offset]; + offset++; + + // Reserved (4 bits) | User Code Length (4 bits) + int codeLength = span[offset] & 0x0F; + offset++; + + if (span.Length < offset + codeLength) + { + logger.LogWarning( + "Extended User Code Report frame is too short for user code data ({Length} bytes, need {Expected})", + frame.CommandParameters.Length, + offset + codeLength); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Extended User Code Report frame is too short for user code data"); + } + + string? userCode = null; + if (codeLength > 0) + { + userCode = Encoding.ASCII.GetString(span.Slice(offset, codeLength)); + } + + offset += codeLength; + return new ExtendedUserCodeEntry(userIdentifier, status, userCode); + } + } +} diff --git a/src/ZWave.CommandClasses/UserCodeCommandClass.KeypadMode.cs b/src/ZWave.CommandClasses/UserCodeCommandClass.KeypadMode.cs new file mode 100644 index 0000000..e1bab93 --- /dev/null +++ b/src/ZWave.CommandClasses/UserCodeCommandClass.KeypadMode.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class UserCodeCommandClass +{ + /// + /// Gets the last keypad mode reported by the device. + /// + public UserCodeKeypadMode? LastKeypadMode { get; private set; } + + /// + /// Raised when a Keypad Mode Report is received. + /// + public event Action? OnKeypadModeReportReceived; + + /// + /// Gets the current keypad mode from the device. + /// + /// The cancellation token. + /// The current keypad mode. + public async Task GetKeypadModeAsync(CancellationToken cancellationToken) + { + var command = KeypadModeGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + UserCodeKeypadMode keypadMode = KeypadModeReportCommand.Parse(reportFrame, Logger); + LastKeypadMode = keypadMode; + OnKeypadModeReportReceived?.Invoke(keypadMode); + return keypadMode; + } + + /// + /// Sets the keypad mode on the device. + /// + /// The keypad mode to set. + /// The cancellation token. + public async Task SetKeypadModeAsync(UserCodeKeypadMode keypadMode, CancellationToken cancellationToken) + { + var command = KeypadModeSetCommand.Create(keypadMode); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + internal readonly struct KeypadModeSetCommand : ICommand + { + public KeypadModeSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.KeypadModeSet; + + public CommandClassFrame Frame { get; } + + public static KeypadModeSetCommand Create(UserCodeKeypadMode keypadMode) + { + ReadOnlySpan commandParameters = [(byte)keypadMode]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new KeypadModeSetCommand(frame); + } + } + + internal readonly struct KeypadModeGetCommand : ICommand + { + public KeypadModeGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.KeypadModeGet; + + public CommandClassFrame Frame { get; } + + public static KeypadModeGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new KeypadModeGetCommand(frame); + } + } + + internal readonly struct KeypadModeReportCommand : ICommand + { + public KeypadModeReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.KeypadModeReport; + + public CommandClassFrame Frame { get; } + + public static UserCodeKeypadMode Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning( + "User Code Keypad Mode Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "User Code Keypad Mode Report frame is too short"); + } + + return (UserCodeKeypadMode)frame.CommandParameters.Span[0]; + } + } +} diff --git a/src/ZWave.CommandClasses/UserCodeCommandClass.UserCode.cs b/src/ZWave.CommandClasses/UserCodeCommandClass.UserCode.cs new file mode 100644 index 0000000..d54a84a --- /dev/null +++ b/src/ZWave.CommandClasses/UserCodeCommandClass.UserCode.cs @@ -0,0 +1,236 @@ +using System.Text; +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a User Code Report from a device. +/// +/// The user identifier (1-255). +/// The status of the user identifier. +/// The user code as an ASCII string, or if not set. +public readonly record struct UserCodeReport( + byte UserIdentifier, + UserIdStatus Status, + string? UserCode); + +public sealed partial class UserCodeCommandClass +{ + /// + /// Raised when a User Code Report is received. + /// + public event Action? OnUserCodeReportReceived; + + /// + /// Gets the user code for the specified user identifier. + /// + /// The user identifier (1-255). + /// The cancellation token. + /// The user code report. + public async Task GetUserCodeAsync(byte userIdentifier, CancellationToken cancellationToken) + { + var command = UserCodeGetCommand.Create(userIdentifier); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync( + predicate: frame => frame.CommandParameters.Length > 0 + && frame.CommandParameters.Span[0] == userIdentifier, + cancellationToken).ConfigureAwait(false); + UserCodeReport report = UserCodeReportCommand.Parse(reportFrame, Logger); + OnUserCodeReportReceived?.Invoke(report); + return report; + } + + /// + /// Sets the user code for the specified user identifier. + /// + /// The user identifier (1-255). + /// The user ID status to set. Must not be ; use instead. + /// The user code as an ASCII string (4-10 characters). + /// The cancellation token. + public async Task SetUserCodeAsync( + byte userIdentifier, + UserIdStatus status, + string userCode, + CancellationToken cancellationToken) + { + var command = UserCodeSetCommand.Create(userIdentifier, status, userCode); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// + /// Clears the user code for the specified user identifier, or all user identifiers. + /// + /// The user identifier (1-255), or 0 to clear all users. + /// The cancellation token. + public async Task ClearUserCodeAsync(byte userIdentifier, CancellationToken cancellationToken) + { + var command = UserCodeSetCommand.CreateClear(userIdentifier); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets the number of supported user codes. + /// + /// The cancellation token. + /// The number of supported user codes. + public async Task GetSupportedUsersCountAsync(CancellationToken cancellationToken) + { + var command = UsersNumberGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + ushort count = UsersNumberReportCommand.Parse(reportFrame, Logger); + SupportedUsersCount = count; + return count; + } + + internal readonly struct UserCodeSetCommand : ICommand + { + public UserCodeSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.Set; + + public CommandClassFrame Frame { get; } + + public static UserCodeSetCommand Create(byte userIdentifier, UserIdStatus status, string userCode) + { + // Per spec CC:0063.01.01.11.006: length 4-10, CC:0063.01.01.11.007: ASCII digits only + ValidateCode(userCode, nameof(userCode), digitsOnly: true); + + Span commandParameters = stackalloc byte[2 + userCode.Length]; + commandParameters[0] = userIdentifier; + commandParameters[1] = (byte)status; + Encoding.ASCII.GetBytes(userCode, commandParameters[2..]); + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new UserCodeSetCommand(frame); + } + + public static UserCodeSetCommand CreateClear(byte userIdentifier) + { + // Per spec CC:0063.01.01.11.009: User Code MUST be 0x00000000 when status is Available + Span commandParameters = stackalloc byte[6]; + commandParameters[0] = userIdentifier; + commandParameters[1] = (byte)UserIdStatus.Available; + // Bytes 2-5 are already 0x00 from stackalloc + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new UserCodeSetCommand(frame); + } + } + + internal readonly struct UserCodeGetCommand : ICommand + { + public UserCodeGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.Get; + + public CommandClassFrame Frame { get; } + + public static UserCodeGetCommand Create(byte userIdentifier) + { + ReadOnlySpan commandParameters = [userIdentifier]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new UserCodeGetCommand(frame); + } + } + + internal readonly struct UserCodeReportCommand : ICommand + { + public UserCodeReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.Report; + + public CommandClassFrame Frame { get; } + + public static UserCodeReport Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 2) + { + logger.LogWarning("User Code Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "User Code Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + byte userIdentifier = span[0]; + UserIdStatus status = (UserIdStatus)span[1]; + + string? userCode = null; + if (status != UserIdStatus.Available + && status != UserIdStatus.StatusNotAvailable + && span.Length > 2) + { + userCode = Encoding.ASCII.GetString(span[2..]); + } + + return new UserCodeReport(userIdentifier, status, userCode); + } + } + + internal readonly struct UsersNumberGetCommand : ICommand + { + public UsersNumberGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.UsersNumberGet; + + public CommandClassFrame Frame { get; } + + public static UsersNumberGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new UsersNumberGetCommand(frame); + } + } + + internal readonly struct UsersNumberReportCommand : ICommand + { + public UsersNumberReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.UserCode; + + public static byte CommandId => (byte)UserCodeCommand.UsersNumberReport; + + public CommandClassFrame Frame { get; } + + public static ushort Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning("Users Number Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Users Number Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + + // V2 adds a 16-bit Extended Supported Users field; use it when present + if (span.Length >= 3) + { + return span[1..3].ToUInt16BE(); + } + + return span[0]; + } + } +} diff --git a/src/ZWave.CommandClasses/UserCodeCommandClass.cs b/src/ZWave.CommandClasses/UserCodeCommandClass.cs new file mode 100644 index 0000000..e905c77 --- /dev/null +++ b/src/ZWave.CommandClasses/UserCodeCommandClass.cs @@ -0,0 +1,279 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// User ID status values for the User Code Command Class. +/// +public enum UserIdStatus : byte +{ + /// + /// The user identifier is available (not set). + /// + Available = 0x00, + + /// + /// The user identifier is enabled and grants access. + /// + EnabledGrantAccess = 0x01, + + /// + /// The user identifier is in use but disabled. + /// + Disabled = 0x02, + + /// + /// The user identifier is used for messaging/notifications. + /// + Messaging = 0x03, + + /// + /// The user identifier activates passage mode (permanent access toggle). + /// + PassageMode = 0x04, + + /// + /// The requested user identifier is not valid. + /// + StatusNotAvailable = 0xFE, +} + +/// +/// Keypad mode values for the User Code Command Class. +/// +public enum UserCodeKeypadMode : byte +{ + /// + /// Normal mode: all user codes work normally. + /// + Normal = 0x00, + + /// + /// Vacation mode: normal user codes are ignored. + /// + Vacation = 0x01, + + /// + /// Privacy mode: all keypad input is ignored, including admin code. + /// + Privacy = 0x02, + + /// + /// Locked out mode: all keypad input is ignored as a brute-force prevention measure. + /// + LockedOut = 0x03, +} + +/// +/// Commands for the User Code Command Class. +/// +public enum UserCodeCommand : byte +{ + /// + /// Set a user code. + /// + Set = 0x01, + + /// + /// Request a user code. + /// + Get = 0x02, + + /// + /// Report a user code. + /// + Report = 0x03, + + /// + /// Request the number of supported users. + /// + UsersNumberGet = 0x04, + + /// + /// Report the number of supported users. + /// + UsersNumberReport = 0x05, + + /// + /// Request user code capabilities. + /// + CapabilitiesGet = 0x06, + + /// + /// Report user code capabilities. + /// + CapabilitiesReport = 0x07, + + /// + /// Set the keypad mode. + /// + KeypadModeSet = 0x08, + + /// + /// Request the current keypad mode. + /// + KeypadModeGet = 0x09, + + /// + /// Report the current keypad mode. + /// + KeypadModeReport = 0x0A, + + /// + /// Set one or more user codes (extended, 16-bit user IDs). + /// + ExtendedUserCodeSet = 0x0B, + + /// + /// Request a user code (extended, 16-bit user ID). + /// + ExtendedUserCodeGet = 0x0C, + + /// + /// Report one or more user codes (extended, 16-bit user IDs). + /// + ExtendedUserCodeReport = 0x0D, + + /// + /// Set the admin code. + /// + AdminCodeSet = 0x0E, + + /// + /// Request the admin code. + /// + AdminCodeGet = 0x0F, + + /// + /// Report the admin code. + /// + AdminCodeReport = 0x10, + + /// + /// Request the user code checksum. + /// + ChecksumGet = 0x11, + + /// + /// Report the user code checksum. + /// + ChecksumReport = 0x12, +} + +/// +/// Implementation of the User Code Command Class (versions 1-2). +/// +[CommandClass(CommandClassId.UserCode)] +public sealed partial class UserCodeCommandClass : CommandClass +{ + internal UserCodeCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + /// Validates a user code string for length (4-10) and character range. + /// + /// The code to validate. + /// The parameter name for exception messages. + /// + /// If , only ASCII digits (0x30-0x39) are allowed (V1). + /// If , any ASCII character (0x00-0x7F) is allowed (V2). + /// + private static void ValidateCode(string code, string paramName, bool digitsOnly) + { + if (code.Length < 4 || code.Length > 10) + { + throw new ArgumentOutOfRangeException(paramName, code.Length, "Code length must be between 4 and 10 characters."); + } + + for (int i = 0; i < code.Length; i++) + { + char c = code[i]; + if (digitsOnly) + { + if (c < '0' || c > '9') + { + throw new ArgumentException($"User code must contain only ASCII digits (0-9). Found '{c}' at position {i}.", paramName); + } + } + else + { + if (c > 127) + { + throw new ArgumentException($"Code must contain only ASCII characters. Found non-ASCII character at position {i}.", paramName); + } + } + } + } + + /// + /// Gets the number of supported user codes. + /// + public ushort? SupportedUsersCount { get; private set; } + + /// + public override bool? IsCommandSupported(UserCodeCommand command) + => command switch + { + UserCodeCommand.Set => true, + UserCodeCommand.Get => true, + UserCodeCommand.UsersNumberGet => true, + UserCodeCommand.CapabilitiesGet => Version.HasValue ? Version >= 2 : null, + UserCodeCommand.KeypadModeSet => Version.HasValue ? Version >= 2 : null, + UserCodeCommand.KeypadModeGet => Version.HasValue ? Version >= 2 : null, + UserCodeCommand.ExtendedUserCodeSet => Version.HasValue ? Version >= 2 : null, + UserCodeCommand.ExtendedUserCodeGet => Version.HasValue ? Version >= 2 : null, + UserCodeCommand.AdminCodeSet => Version.HasValue ? Version >= 2 : null, + UserCodeCommand.AdminCodeGet => Version.HasValue ? Version >= 2 : null, + UserCodeCommand.ChecksumGet => Version.HasValue ? Version >= 2 : null, + _ => false, + }; + + /// + internal override async Task InterviewAsync(CancellationToken cancellationToken) + { + _ = await GetSupportedUsersCountAsync(cancellationToken).ConfigureAwait(false); + + if (IsCommandSupported(UserCodeCommand.CapabilitiesGet).GetValueOrDefault()) + { + _ = await GetCapabilitiesAsync(cancellationToken).ConfigureAwait(false); + } + + if (IsCommandSupported(UserCodeCommand.KeypadModeGet).GetValueOrDefault()) + { + _ = await GetKeypadModeAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((UserCodeCommand)frame.CommandId) + { + case UserCodeCommand.Report: + { + UserCodeReport report = UserCodeReportCommand.Parse(frame, Logger); + OnUserCodeReportReceived?.Invoke(report); + break; + } + case UserCodeCommand.ExtendedUserCodeReport: + { + ExtendedUserCodeReport report = ExtendedUserCodeReportCommand.ParseBulk(frame, Logger); + OnExtendedUserCodeReportReceived?.Invoke(report); + break; + } + case UserCodeCommand.KeypadModeReport: + { + UserCodeKeypadMode keypadMode = KeypadModeReportCommand.Parse(frame, Logger); + LastKeypadMode = keypadMode; + OnKeypadModeReportReceived?.Invoke(keypadMode); + break; + } + } + } +}