diff --git a/src/ZWave.CommandClasses.Tests/BarrierOperatorCommandClassTests.Report.cs b/src/ZWave.CommandClasses.Tests/BarrierOperatorCommandClassTests.Report.cs index f0f3dda..21c662c 100644 --- a/src/ZWave.CommandClasses.Tests/BarrierOperatorCommandClassTests.Report.cs +++ b/src/ZWave.CommandClasses.Tests/BarrierOperatorCommandClassTests.Report.cs @@ -44,7 +44,7 @@ public void ReportCommand_Parse_Closed() Assert.AreEqual((byte)0x00, report.StateValue); Assert.AreEqual(BarrierOperatorState.Closed, report.State); - Assert.IsNull(report.Position); + Assert.AreEqual((byte)0, report.Position); } [TestMethod] @@ -57,7 +57,7 @@ public void ReportCommand_Parse_Open() Assert.AreEqual((byte)0xFF, report.StateValue); Assert.AreEqual(BarrierOperatorState.Open, report.State); - Assert.IsNull(report.Position); + Assert.AreEqual((byte)100, report.Position); } [TestMethod] diff --git a/src/ZWave.CommandClasses.Tests/EntryControlCommandClassTests.Configuration.cs b/src/ZWave.CommandClasses.Tests/EntryControlCommandClassTests.Configuration.cs new file mode 100644 index 0000000..831b65f --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/EntryControlCommandClassTests.Configuration.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class EntryControlCommandClassTests +{ + [TestMethod] + public void ConfigurationGetCommand_Create_HasCorrectFormat() + { + EntryControlCommandClass.EntryControlConfigurationGetCommand command = + EntryControlCommandClass.EntryControlConfigurationGetCommand.Create(); + + Assert.AreEqual(CommandClassId.EntryControl, EntryControlCommandClass.EntryControlConfigurationGetCommand.CommandClassId); + Assert.AreEqual((byte)EntryControlCommand.ConfigurationGet, EntryControlCommandClass.EntryControlConfigurationGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void ConfigurationSetCommand_Create_HasCorrectFormat() + { + EntryControlCommandClass.EntryControlConfigurationSetCommand command = + EntryControlCommandClass.EntryControlConfigurationSetCommand.Create(keyCacheSize: 4, keyCacheTimeout: 2); + + Assert.AreEqual(CommandClassId.EntryControl, EntryControlCommandClass.EntryControlConfigurationSetCommand.CommandClassId); + Assert.AreEqual((byte)EntryControlCommand.ConfigurationSet, EntryControlCommandClass.EntryControlConfigurationSetCommand.CommandId); + Assert.AreEqual(4, command.Frame.Data.Length); + Assert.AreEqual((byte)4, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual((byte)2, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void ConfigurationSetCommand_Create_MaxValues() + { + EntryControlCommandClass.EntryControlConfigurationSetCommand command = + EntryControlCommandClass.EntryControlConfigurationSetCommand.Create(keyCacheSize: 32, keyCacheTimeout: 10); + + Assert.AreEqual((byte)32, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual((byte)10, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void ConfigurationReport_Parse_DefaultValues() + { + // CC=0x6F, Cmd=0x08, KeyCacheSize=4, KeyCacheTimeout=2 + byte[] data = [0x6F, 0x08, 0x04, 0x02]; + CommandClassFrame frame = new(data); + + EntryControlConfigurationReport report = + EntryControlCommandClass.EntryControlConfigurationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)4, report.KeyCacheSize); + Assert.AreEqual((byte)2, report.KeyCacheTimeout); + } + + [TestMethod] + public void ConfigurationReport_Parse_SingleKeyCache() + { + // KeyCacheSize=1, KeyCacheTimeout=1 + byte[] data = [0x6F, 0x08, 0x01, 0x01]; + CommandClassFrame frame = new(data); + + EntryControlConfigurationReport report = + EntryControlCommandClass.EntryControlConfigurationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, report.KeyCacheSize); + Assert.AreEqual((byte)1, report.KeyCacheTimeout); + } + + [TestMethod] + public void ConfigurationReport_Parse_MaxValues() + { + // KeyCacheSize=32, KeyCacheTimeout=10 + byte[] data = [0x6F, 0x08, 0x20, 0x0A]; + CommandClassFrame frame = new(data); + + EntryControlConfigurationReport report = + EntryControlCommandClass.EntryControlConfigurationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)32, report.KeyCacheSize); + Assert.AreEqual((byte)10, report.KeyCacheTimeout); + } + + [TestMethod] + public void ConfigurationReport_Parse_TooShort_Throws() + { + // Only 1 command parameter byte (need at least 2) + byte[] data = [0x6F, 0x08, 0x04]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => EntryControlCommandClass.EntryControlConfigurationReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/EntryControlCommandClassTests.EventSupported.cs b/src/ZWave.CommandClasses.Tests/EntryControlCommandClassTests.EventSupported.cs new file mode 100644 index 0000000..357cb4d --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/EntryControlCommandClassTests.EventSupported.cs @@ -0,0 +1,192 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class EntryControlCommandClassTests +{ + [TestMethod] + public void EventSupportedGetCommand_Create_HasCorrectFormat() + { + EntryControlCommandClass.EntryControlEventSupportedGetCommand command = + EntryControlCommandClass.EntryControlEventSupportedGetCommand.Create(); + + Assert.AreEqual(CommandClassId.EntryControl, EntryControlCommandClass.EntryControlEventSupportedGetCommand.CommandClassId); + Assert.AreEqual((byte)EntryControlCommand.EventSupportedGet, EntryControlCommandClass.EntryControlEventSupportedGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void EventSupportedReport_Parse_TypicalKeypad() + { + // A typical keypad supporting: + // Data Types: ASCII (0x02) → bit 2 in bitmask byte 0 + // Event Types: CachedKeys(0x01), Enter(0x02), ArmAll(0x04), DisarmAll(0x03) + // → byte 0: bits 1,2,3,4 = 0b00011110 = 0x1E + // KeyCachedSize Min=1, Max=32, Timeout Min=1, Max=10 + byte[] data = + [ + 0x6F, 0x05, // CC + Cmd + 0x01, // Reserved[7:2]=0 | DataTypeBitmaskLength[1:0]=1 + 0x04, // DataType bitmask: bit 2 set (Ascii) + 0x01, // EventTypeBitmaskLength=1 + 0x1E, // EventType bitmask: bits 1,2,3,4 + 0x01, // KeyCachedSizeMin + 0x20, // KeyCachedSizeMax + 0x01, // KeyCachedTimeoutMin + 0x0A, // KeyCachedTimeoutMax + ]; + CommandClassFrame frame = new(data); + + EntryControlEventSupportedReport report = + EntryControlCommandClass.EntryControlEventSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(1, report.SupportedDataTypes); + Assert.Contains(EntryControlDataType.Ascii, report.SupportedDataTypes); + + Assert.HasCount(4, report.SupportedEventTypes); + Assert.Contains(EntryControlEventType.CachedKeys, report.SupportedEventTypes); + Assert.Contains(EntryControlEventType.Enter, report.SupportedEventTypes); + Assert.Contains(EntryControlEventType.DisarmAll, report.SupportedEventTypes); + Assert.Contains(EntryControlEventType.ArmAll, report.SupportedEventTypes); + + Assert.AreEqual((byte)1, report.KeyCachedSizeMinimum); + Assert.AreEqual((byte)32, report.KeyCachedSizeMaximum); + Assert.AreEqual((byte)1, report.KeyCachedTimeoutMinimum); + Assert.AreEqual((byte)10, report.KeyCachedTimeoutMaximum); + } + + [TestMethod] + public void EventSupportedReport_Parse_MultipleDataTypes() + { + // Supports Raw(0x01) and Ascii(0x02): bits 1 and 2 → 0x06 + byte[] data = + [ + 0x6F, 0x05, + 0x01, // DataTypeBitmaskLength=1 + 0x06, // Raw + Ascii + 0x01, // EventTypeBitmaskLength=1 + 0x01, // CachedKeys only (bit 0 = Caching, bit 1 = CachedKeys... wait) + 0x04, 0x20, 0x02, 0x05, + ]; + CommandClassFrame frame = new(data); + + EntryControlEventSupportedReport report = + EntryControlCommandClass.EntryControlEventSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(2, report.SupportedDataTypes); + Assert.Contains(EntryControlDataType.Raw, report.SupportedDataTypes); + Assert.Contains(EntryControlDataType.Ascii, report.SupportedDataTypes); + } + + [TestMethod] + public void EventSupportedReport_Parse_MultiByteEventBitmask() + { + // Event types up to 0x19 (Cancel) need 4 bytes of bitmask + // Set bits for Caching(0x00), RFID(0x0E), Cancel(0x19) + // Byte 0: bit 0 = Caching → 0x01 + // Byte 1: bit 6 = RFID (0x0E = 14, 14-8=6) → 0x40 + // Byte 2: nothing → 0x00 + // Byte 3: bit 1 = Cancel (0x19 = 25, 25-24=1) → 0x02 + byte[] data = + [ + 0x6F, 0x05, + 0x01, // DataTypeBitmaskLength=1 + 0x01, // None data type + 0x04, // EventTypeBitmaskLength=4 + 0x01, 0x40, 0x00, 0x02, // Event bitmask + 0x01, 0x20, 0x01, 0x0A, + ]; + CommandClassFrame frame = new(data); + + EntryControlEventSupportedReport report = + EntryControlCommandClass.EntryControlEventSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(3, report.SupportedEventTypes); + Assert.Contains(EntryControlEventType.Caching, report.SupportedEventTypes); + Assert.Contains(EntryControlEventType.Rfid, report.SupportedEventTypes); + Assert.Contains(EntryControlEventType.Cancel, report.SupportedEventTypes); + } + + [TestMethod] + public void EventSupportedReport_Parse_ZeroDataTypeBitmask() + { + // DataTypeBitmaskLength = 0 (no data type bitmask bytes) + byte[] data = + [ + 0x6F, 0x05, + 0x00, // DataTypeBitmaskLength=0 + 0x01, // EventTypeBitmaskLength=1 + 0x04, // Enter event + 0x01, 0x20, 0x01, 0x0A, + ]; + CommandClassFrame frame = new(data); + + EntryControlEventSupportedReport report = + EntryControlCommandClass.EntryControlEventSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsEmpty(report.SupportedDataTypes); + Assert.HasCount(1, report.SupportedEventTypes); + Assert.Contains(EntryControlEventType.Enter, report.SupportedEventTypes); + } + + [TestMethod] + public void EventSupportedReport_Parse_DataTypeBitmaskLengthMasked() + { + // Reserved bits in byte 0 should be ignored; only lower 2 bits = length + // 0xFD = 11111101 → lower 2 bits = 01 = length 1 + byte[] data = + [ + 0x6F, 0x05, + 0xFD, // Reserved=0x3F | Length=1 + 0x04, // Ascii data type + 0x01, // EventTypeBitmaskLength=1 + 0x02, // CachedKeys + 0x04, 0x20, 0x02, 0x0A, + ]; + CommandClassFrame frame = new(data); + + EntryControlEventSupportedReport report = + EntryControlCommandClass.EntryControlEventSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(1, report.SupportedDataTypes); + Assert.Contains(EntryControlDataType.Ascii, report.SupportedDataTypes); + } + + [TestMethod] + public void EventSupportedReport_Parse_TooShort_Throws() + { + byte[] data = [0x6F, 0x05]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => EntryControlCommandClass.EntryControlEventSupportedReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void EventSupportedReport_Parse_TooShortForDataTypeBitmask_Throws() + { + // Declares 2 bitmask bytes but none present + byte[] data = [0x6F, 0x05, 0x02]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => EntryControlCommandClass.EntryControlEventSupportedReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void EventSupportedReport_Parse_TooShortForConfigFields_Throws() + { + // Has data type bitmask and event type bitmask, but missing config fields + byte[] data = + [ + 0x6F, 0x05, + 0x01, 0x04, // DataType bitmask + 0x01, 0x02, // EventType bitmask + // Missing 4 config bytes + ]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => EntryControlCommandClass.EntryControlEventSupportedReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/EntryControlCommandClassTests.KeySupported.cs b/src/ZWave.CommandClasses.Tests/EntryControlCommandClassTests.KeySupported.cs new file mode 100644 index 0000000..10a6a11 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/EntryControlCommandClassTests.KeySupported.cs @@ -0,0 +1,108 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class EntryControlCommandClassTests +{ + [TestMethod] + public void KeySupportedGetCommand_Create_HasCorrectFormat() + { + EntryControlCommandClass.EntryControlKeySupportedGetCommand command = + EntryControlCommandClass.EntryControlKeySupportedGetCommand.Create(); + + Assert.AreEqual(CommandClassId.EntryControl, EntryControlCommandClass.EntryControlKeySupportedGetCommand.CommandClassId); + Assert.AreEqual((byte)EntryControlCommand.KeySupportedGet, EntryControlCommandClass.EntryControlKeySupportedGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void KeySupportedReport_Parse_NumericKeys() + { + // Bitmask for ASCII codes '0' (0x30) through '9' (0x39) + // '0' = 0x30 = bit 0 in byte 6, '1' = 0x31 = bit 1 in byte 6, ... + // '8' = 0x38 = bit 0 in byte 7, '9' = 0x39 = bit 1 in byte 7 + // We need 8 bitmask bytes (byte 0-7) to cover ASCII codes up to 0x3F + byte[] data = new byte[2 + 1 + 8]; // CC + Cmd + Length + 8 bitmask bytes + data[0] = 0x6F; // CC + data[1] = 0x03; // Cmd (Key Supported Report) + data[2] = 0x08; // Bitmask length = 8 bytes + // Byte 6 (codes 0x30-0x37): bits 0-7 all set = '0' through '7' + data[2 + 1 + 6] = 0xFF; + // Byte 7 (codes 0x38-0x3F): bits 0-1 set = '8' and '9' + data[2 + 1 + 7] = 0x03; + + CommandClassFrame frame = new(data); + + IReadOnlySet keys = + EntryControlCommandClass.EntryControlKeySupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(10, keys); + for (char k = '0'; k <= '9'; k++) + { + Assert.Contains(k, keys); + } + } + + [TestMethod] + public void KeySupportedReport_Parse_SingleByte() + { + // Bitmask length = 1, bitmask = 0b00000110 (codes 1 and 2) + byte[] data = [0x6F, 0x03, 0x01, 0x06]; + CommandClassFrame frame = new(data); + + IReadOnlySet keys = + EntryControlCommandClass.EntryControlKeySupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(2, keys); + Assert.Contains((char)1, keys); + Assert.Contains((char)2, keys); + } + + [TestMethod] + public void KeySupportedReport_Parse_EmptyBitmask() + { + // Bitmask length = 1, all zeros + byte[] data = [0x6F, 0x03, 0x01, 0x00]; + CommandClassFrame frame = new(data); + + IReadOnlySet keys = + EntryControlCommandClass.EntryControlKeySupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsEmpty(keys); + } + + [TestMethod] + public void KeySupportedReport_Parse_ZeroLengthBitmask() + { + // Bitmask length = 0 + byte[] data = [0x6F, 0x03, 0x00]; + CommandClassFrame frame = new(data); + + IReadOnlySet keys = + EntryControlCommandClass.EntryControlKeySupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsEmpty(keys); + } + + [TestMethod] + public void KeySupportedReport_Parse_TooShort_Throws() + { + // No command parameters + byte[] data = [0x6F, 0x03]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => EntryControlCommandClass.EntryControlKeySupportedReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void KeySupportedReport_Parse_BitmaskShorterThanDeclared_Throws() + { + // Declared length = 5 but only 2 bitmask bytes present + byte[] data = [0x6F, 0x03, 0x05, 0xFF, 0xFF]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => EntryControlCommandClass.EntryControlKeySupportedReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/EntryControlCommandClassTests.Notification.cs b/src/ZWave.CommandClasses.Tests/EntryControlCommandClassTests.Notification.cs new file mode 100644 index 0000000..a680f8b --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/EntryControlCommandClassTests.Notification.cs @@ -0,0 +1,176 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class EntryControlCommandClassTests +{ + [TestMethod] + public void Notification_Parse_BasicNotification_NoEventData() + { + // CC=0x6F, Cmd=0x01, SeqNum=0x05, Reserved|DataType=0x00(None), EventType=0x0F(Bell), Length=0 + byte[] data = [0x6F, 0x01, 0x05, 0x00, 0x0F, 0x00]; + CommandClassFrame frame = new(data); + + EntryControlNotification notification = + EntryControlCommandClass.EntryControlNotificationCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x05, notification.SequenceNumber); + Assert.AreEqual(EntryControlDataType.None, notification.DataType); + Assert.AreEqual(EntryControlEventType.Bell, notification.EventType); + Assert.AreEqual(0, notification.EventData.Length); + Assert.IsNull(notification.EventDataString); + } + + [TestMethod] + public void Notification_Parse_RawData() + { + // CC=0x6F, Cmd=0x01, SeqNum=0x01, DataType=0x01(Raw), EventType=0x0E(RFID), Length=3, Data=0xAA,0xBB,0xCC + byte[] data = [0x6F, 0x01, 0x01, 0x01, 0x0E, 0x03, 0xAA, 0xBB, 0xCC]; + CommandClassFrame frame = new(data); + + EntryControlNotification notification = + EntryControlCommandClass.EntryControlNotificationCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x01, notification.SequenceNumber); + Assert.AreEqual(EntryControlDataType.Raw, notification.DataType); + Assert.AreEqual(EntryControlEventType.Rfid, notification.EventType); + Assert.AreEqual(3, notification.EventData.Length); + Assert.AreEqual((byte)0xAA, notification.EventData.Span[0]); + Assert.AreEqual((byte)0xBB, notification.EventData.Span[1]); + Assert.AreEqual((byte)0xCC, notification.EventData.Span[2]); + Assert.IsNull(notification.EventDataString); + } + + [TestMethod] + public void Notification_Parse_AsciiData_PaddingTrimmed() + { + // ASCII "1234" padded with 0xFF to 16 bytes + byte[] data = [0x6F, 0x01, 0x0A, 0x02, 0x02, 0x10, + 0x31, 0x32, 0x33, 0x34, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; + CommandClassFrame frame = new(data); + + EntryControlNotification notification = + EntryControlCommandClass.EntryControlNotificationCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(EntryControlDataType.Ascii, notification.DataType); + Assert.AreEqual(EntryControlEventType.Enter, notification.EventType); + // Padding should be trimmed, leaving only "1234" + Assert.AreEqual(4, notification.EventData.Length); + Assert.AreEqual((byte)'1', notification.EventData.Span[0]); + Assert.AreEqual((byte)'2', notification.EventData.Span[1]); + Assert.AreEqual((byte)'3', notification.EventData.Span[2]); + Assert.AreEqual((byte)'4', notification.EventData.Span[3]); + Assert.AreEqual("1234", notification.EventDataString); + } + + [TestMethod] + public void Notification_Parse_AsciiData_AllPadding_ResultsInEmptyData() + { + // All 0xFF padding (edge case) + byte[] data = [0x6F, 0x01, 0x00, 0x02, 0x00, 0x10, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; + CommandClassFrame frame = new(data); + + EntryControlNotification notification = + EntryControlCommandClass.EntryControlNotificationCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(EntryControlDataType.Ascii, notification.DataType); + Assert.AreEqual(0, notification.EventData.Length); + Assert.AreEqual(string.Empty, notification.EventDataString); + } + + [TestMethod] + public void Notification_Parse_Md5Data_NotTrimmed() + { + // MD5 data should not be trimmed even if it contains 0xFF bytes + byte[] data = [0x6F, 0x01, 0x00, 0x03, 0x0E, 0x10, + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0xFF]; + CommandClassFrame frame = new(data); + + EntryControlNotification notification = + EntryControlCommandClass.EntryControlNotificationCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(EntryControlDataType.Md5, notification.DataType); + Assert.AreEqual(16, notification.EventData.Length); + Assert.AreEqual((byte)0xFF, notification.EventData.Span[15]); + } + + [TestMethod] + public void Notification_Parse_DataTypeExtractedFrom2Bits() + { + // Byte 1 has reserved bits 7-2 set to non-zero (e.g. 0xFE = 11111110, DataType = 10 = Ascii) + byte[] data = [0x6F, 0x01, 0x00, 0xFE, 0x01, 0x00]; + CommandClassFrame frame = new(data); + + EntryControlNotification notification = + EntryControlCommandClass.EntryControlNotificationCommand.Parse(frame, NullLogger.Instance); + + // Only lower 2 bits (0b10 = 2) should be used for DataType + Assert.AreEqual(EntryControlDataType.Ascii, notification.DataType); + } + + [TestMethod] + public void Notification_Parse_EventTypeFullByte() + { + // Event type 0x19 (Cancel) requires more than 4 bits + byte[] data = [0x6F, 0x01, 0x00, 0x00, 0x19, 0x00]; + CommandClassFrame frame = new(data); + + EntryControlNotification notification = + EntryControlCommandClass.EntryControlNotificationCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(EntryControlEventType.Cancel, notification.EventType); + } + + [TestMethod] + public void Notification_Parse_TooShort_Throws() + { + // Only 3 command parameter bytes (need at least 4) + byte[] data = [0x6F, 0x01, 0x00, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => EntryControlCommandClass.EntryControlNotificationCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void Notification_Parse_EventDataLengthExceedsMax_Throws() + { + // Event data length = 33 (exceeds max of 32) + byte[] data = [0x6F, 0x01, 0x00, 0x01, 0x01, 0x21]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => EntryControlCommandClass.EntryControlNotificationCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void Notification_Parse_FrameTooShortForDeclaredData_Throws() + { + // Declared length = 5 but only 2 data bytes present + byte[] data = [0x6F, 0x01, 0x00, 0x01, 0x01, 0x05, 0xAA, 0xBB]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => EntryControlCommandClass.EntryControlNotificationCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void Notification_Parse_CachingEvent_NoData() + { + // Caching event with no data + byte[] data = [0x6F, 0x01, 0x42, 0x00, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + EntryControlNotification notification = + EntryControlCommandClass.EntryControlNotificationCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x42, notification.SequenceNumber); + Assert.AreEqual(EntryControlDataType.None, notification.DataType); + Assert.AreEqual(EntryControlEventType.Caching, notification.EventType); + Assert.AreEqual(0, notification.EventData.Length); + } +} diff --git a/src/ZWave.CommandClasses.Tests/EntryControlCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/EntryControlCommandClassTests.cs new file mode 100644 index 0000000..06032a9 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/EntryControlCommandClassTests.cs @@ -0,0 +1,6 @@ +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public partial class EntryControlCommandClassTests +{ +} diff --git a/src/ZWave.CommandClasses/BarrierOperatorCommandClass.Report.cs b/src/ZWave.CommandClasses/BarrierOperatorCommandClass.Report.cs index 6501998..dcc866d 100644 --- a/src/ZWave.CommandClasses/BarrierOperatorCommandClass.Report.cs +++ b/src/ZWave.CommandClasses/BarrierOperatorCommandClass.Report.cs @@ -39,10 +39,15 @@ public readonly record struct BarrierOperatorReport(byte StateValue) }; /// - /// Gets the exact position percentage (1-99) when the barrier is stopped at a known position, + /// Gets the exact position percentage (0-100) when the barrier is at a known position, /// or otherwise. /// - public byte? Position => StateValue is >= 0x01 and <= 0x63 ? StateValue : null; + public byte? Position => StateValue switch + { + <= 0x63 => StateValue, + 0xFF => 100, + _ => null, + }; } public sealed partial class BarrierOperatorCommandClass diff --git a/src/ZWave.CommandClasses/EntryControlCommandClass.Configuration.cs b/src/ZWave.CommandClasses/EntryControlCommandClass.Configuration.cs new file mode 100644 index 0000000..74869be --- /dev/null +++ b/src/ZWave.CommandClasses/EntryControlCommandClass.Configuration.cs @@ -0,0 +1,125 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents the current configuration of an Entry Control device. +/// +public readonly record struct EntryControlConfigurationReport( + /// + /// Gets the number of key entries cached before sending a notification. + /// + byte KeyCacheSize, + + /// + /// Gets the timeout in seconds between key entries before sending a notification. + /// + byte KeyCacheTimeout); + +public sealed partial class EntryControlCommandClass +{ + /// + /// Gets the last configuration reported by the device. + /// + public EntryControlConfigurationReport? LastConfiguration { get; private set; } + + /// + /// Request the current configuration from the device. + /// + public async Task GetConfigurationAsync(CancellationToken cancellationToken) + { + EntryControlConfigurationGetCommand command = EntryControlConfigurationGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + EntryControlConfigurationReport report = EntryControlConfigurationReportCommand.Parse(reportFrame, Logger); + LastConfiguration = report; + return report; + } + + /// + /// Set the key cache size and timeout on the device. + /// + /// The number of key entries to cache before sending a notification. Must be in the range 1-32. + /// The timeout in seconds between key entries. Should be in the range 1-10. + /// A cancellation token. + public async Task SetConfigurationAsync(byte keyCacheSize, byte keyCacheTimeout, CancellationToken cancellationToken) + { + EntryControlConfigurationSetCommand command = EntryControlConfigurationSetCommand.Create(keyCacheSize, keyCacheTimeout); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + internal readonly struct EntryControlConfigurationGetCommand : ICommand + { + public EntryControlConfigurationGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.EntryControl; + + public static byte CommandId => (byte)EntryControlCommand.ConfigurationGet; + + public CommandClassFrame Frame { get; } + + public static EntryControlConfigurationGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new EntryControlConfigurationGetCommand(frame); + } + } + + internal readonly struct EntryControlConfigurationSetCommand : ICommand + { + public EntryControlConfigurationSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.EntryControl; + + public static byte CommandId => (byte)EntryControlCommand.ConfigurationSet; + + public CommandClassFrame Frame { get; } + + public static EntryControlConfigurationSetCommand Create(byte keyCacheSize, byte keyCacheTimeout) + { + ReadOnlySpan commandParameters = [keyCacheSize, keyCacheTimeout]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new EntryControlConfigurationSetCommand(frame); + } + } + + internal readonly struct EntryControlConfigurationReportCommand : ICommand + { + public EntryControlConfigurationReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.EntryControl; + + public static byte CommandId => (byte)EntryControlCommand.ConfigurationReport; + + public CommandClassFrame Frame { get; } + + public static EntryControlConfigurationReport Parse(CommandClassFrame frame, ILogger logger) + { + // Minimum: KeyCacheSize(1) + KeyCacheTimeout(1) = 2 bytes + if (frame.CommandParameters.Length < 2) + { + logger.LogWarning( + "Entry Control Configuration Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Entry Control Configuration Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + byte keyCacheSize = span[0]; + byte keyCacheTimeout = span[1]; + + return new EntryControlConfigurationReport(keyCacheSize, keyCacheTimeout); + } + } +} diff --git a/src/ZWave.CommandClasses/EntryControlCommandClass.EventSupported.cs b/src/ZWave.CommandClasses/EntryControlCommandClass.EventSupported.cs new file mode 100644 index 0000000..af4b73e --- /dev/null +++ b/src/ZWave.CommandClasses/EntryControlCommandClass.EventSupported.cs @@ -0,0 +1,196 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents the supported events, data types, and configuration ranges of an Entry Control device. +/// +public readonly record struct EntryControlEventSupportedReport( + /// + /// Gets the data types supported by the device. + /// + IReadOnlySet SupportedDataTypes, + + /// + /// Gets the event types supported by the device. + /// + IReadOnlySet SupportedEventTypes, + + /// + /// Gets the minimum configurable key cache size. + /// + byte KeyCachedSizeMinimum, + + /// + /// Gets the maximum configurable key cache size. + /// + byte KeyCachedSizeMaximum, + + /// + /// Gets the minimum configurable key cache timeout in seconds. + /// + byte KeyCachedTimeoutMinimum, + + /// + /// Gets the maximum configurable key cache timeout in seconds. + /// + byte KeyCachedTimeoutMaximum); + +public sealed partial class EntryControlCommandClass +{ + /// + /// Gets the supported events, data types, and configuration ranges reported by the device. + /// + public EntryControlEventSupportedReport? EventCapabilities { get; private set; } + + /// + /// Request the supported events, data types, and configuration ranges from the device. + /// + public async Task GetEventSupportedAsync(CancellationToken cancellationToken) + { + EntryControlEventSupportedGetCommand command = EntryControlEventSupportedGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + EntryControlEventSupportedReport report = EntryControlEventSupportedReportCommand.Parse(reportFrame, Logger); + EventCapabilities = report; + return report; + } + + internal readonly struct EntryControlEventSupportedGetCommand : ICommand + { + public EntryControlEventSupportedGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.EntryControl; + + public static byte CommandId => (byte)EntryControlCommand.EventSupportedGet; + + public CommandClassFrame Frame { get; } + + public static EntryControlEventSupportedGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new EntryControlEventSupportedGetCommand(frame); + } + } + + internal readonly struct EntryControlEventSupportedReportCommand : ICommand + { + public EntryControlEventSupportedReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.EntryControl; + + public static byte CommandId => (byte)EntryControlCommand.EventSupportedReport; + + public CommandClassFrame Frame { get; } + + public static EntryControlEventSupportedReport Parse(CommandClassFrame frame, ILogger logger) + { + // Minimum: Reserved|DataTypeBitmaskLength(1) + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning( + "Entry Control Event Supported Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Entry Control Event Supported Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + int offset = 0; + + // Byte 0: Reserved[7:2] | DataTypeSupportedBitmaskLength[1:0] + byte dataTypeBitmaskLength = (byte)(span[offset] & 0b0000_0011); + offset++; + + // Validate we have enough bytes for the data type bitmask + if (span.Length < offset + dataTypeBitmaskLength) + { + logger.LogWarning( + "Entry Control Event Supported Report too short for data type bitmask ({Length} bytes)", + frame.CommandParameters.Length); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Entry Control Event Supported Report frame is too short for data type bitmask"); + } + + // Parse data type supported bitmask + HashSet supportedDataTypes = []; + for (int byteNum = 0; byteNum < dataTypeBitmaskLength; byteNum++) + { + for (int bitNum = 0; bitNum < 8; bitNum++) + { + if ((span[offset + byteNum] & (1 << bitNum)) != 0) + { + EntryControlDataType dataType = (EntryControlDataType)((byteNum << 3) + bitNum); + supportedDataTypes.Add(dataType); + } + } + } + + offset += dataTypeBitmaskLength; + + // Event Type Supported Bitmask Length (1 byte) + if (span.Length < offset + 1) + { + logger.LogWarning( + "Entry Control Event Supported Report too short for event type bitmask length ({Length} bytes)", + frame.CommandParameters.Length); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Entry Control Event Supported Report frame is too short for event type bitmask length"); + } + + byte eventTypeBitmaskLength = span[offset]; + offset++; + + // Validate we have enough bytes for the event type bitmask + 4 trailing config bytes + if (span.Length < offset + eventTypeBitmaskLength + 4) + { + logger.LogWarning( + "Entry Control Event Supported Report too short for event bitmask and config ({Length} bytes, need {Expected})", + frame.CommandParameters.Length, + offset + eventTypeBitmaskLength + 4); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Entry Control Event Supported Report frame is too short for event bitmask and configuration"); + } + + // Parse event type supported bitmask + HashSet supportedEventTypes = []; + for (int byteNum = 0; byteNum < eventTypeBitmaskLength; byteNum++) + { + for (int bitNum = 0; bitNum < 8; bitNum++) + { + if ((span[offset + byteNum] & (1 << bitNum)) != 0) + { + EntryControlEventType eventTypeValue = (EntryControlEventType)((byteNum << 3) + bitNum); + supportedEventTypes.Add(eventTypeValue); + } + } + } + + offset += eventTypeBitmaskLength; + + // Configuration range fields + byte keyCachedSizeMin = span[offset]; + byte keyCachedSizeMax = span[offset + 1]; + byte keyCachedTimeoutMin = span[offset + 2]; + byte keyCachedTimeoutMax = span[offset + 3]; + + return new EntryControlEventSupportedReport( + supportedDataTypes, + supportedEventTypes, + keyCachedSizeMin, + keyCachedSizeMax, + keyCachedTimeoutMin, + keyCachedTimeoutMax); + } + } +} diff --git a/src/ZWave.CommandClasses/EntryControlCommandClass.KeySupported.cs b/src/ZWave.CommandClasses/EntryControlCommandClass.KeySupported.cs new file mode 100644 index 0000000..6937512 --- /dev/null +++ b/src/ZWave.CommandClasses/EntryControlCommandClass.KeySupported.cs @@ -0,0 +1,102 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class EntryControlCommandClass +{ + /// + /// Gets the supported ASCII keys reported by the device. + /// + public IReadOnlySet? SupportedKeys { get; private set; } + + /// + /// Request the supported keys for credential entry from the device. + /// + public async Task> GetSupportedKeysAsync(CancellationToken cancellationToken) + { + EntryControlKeySupportedGetCommand command = EntryControlKeySupportedGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + IReadOnlySet supportedKeys = EntryControlKeySupportedReportCommand.Parse(reportFrame, Logger); + SupportedKeys = supportedKeys; + return supportedKeys; + } + + internal readonly struct EntryControlKeySupportedGetCommand : ICommand + { + public EntryControlKeySupportedGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.EntryControl; + + public static byte CommandId => (byte)EntryControlCommand.KeySupportedGet; + + public CommandClassFrame Frame { get; } + + public static EntryControlKeySupportedGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new EntryControlKeySupportedGetCommand(frame); + } + } + + internal readonly struct EntryControlKeySupportedReportCommand : ICommand + { + public EntryControlKeySupportedReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.EntryControl; + + public static byte CommandId => (byte)EntryControlCommand.KeySupportedReport; + + public CommandClassFrame Frame { get; } + + public static IReadOnlySet Parse(CommandClassFrame frame, ILogger logger) + { + // Minimum: BitMaskLength(1) + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning( + "Entry Control Key Supported Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Entry Control Key Supported Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + byte bitMaskLength = span[0]; + + if (frame.CommandParameters.Length < 1 + bitMaskLength) + { + logger.LogWarning( + "Entry Control Key Supported Report frame too short for declared bitmask ({Length} bytes, need {Expected})", + frame.CommandParameters.Length, + 1 + bitMaskLength); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Entry Control Key Supported Report frame is too short for bitmask"); + } + + HashSet supportedKeys = []; + ReadOnlySpan bitMask = span.Slice(1, bitMaskLength); + for (int byteNum = 0; byteNum < bitMask.Length; byteNum++) + { + for (int bitNum = 0; bitNum < 8; bitNum++) + { + if ((bitMask[byteNum] & (1 << bitNum)) != 0) + { + char asciiChar = (char)((byteNum << 3) + bitNum); + supportedKeys.Add(asciiChar); + } + } + } + + return supportedKeys; + } + } +} diff --git a/src/ZWave.CommandClasses/EntryControlCommandClass.Notification.cs b/src/ZWave.CommandClasses/EntryControlCommandClass.Notification.cs new file mode 100644 index 0000000..189c4a2 --- /dev/null +++ b/src/ZWave.CommandClasses/EntryControlCommandClass.Notification.cs @@ -0,0 +1,125 @@ +using System.Text; +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents an Entry Control notification received from a device. +/// +public readonly record struct EntryControlNotification( + /// + /// Gets the sequence number used for duplicate detection. + /// + byte SequenceNumber, + + /// + /// Gets the type of data carried in . + /// + EntryControlDataType DataType, + + /// + /// Gets the event type. + /// + EntryControlEventType EventType, + + /// + /// Gets the event data. For ASCII data, trailing 0xFF padding bytes are removed. + /// + ReadOnlyMemory EventData) +{ + /// + /// Gets the event data as a string when is , + /// or otherwise. + /// + public string? EventDataString => DataType == EntryControlDataType.Ascii + ? Encoding.ASCII.GetString(EventData.Span) + : null; +} + +public sealed partial class EntryControlCommandClass +{ + /// + /// Gets the last notification received from the device. + /// + public EntryControlNotification? LastNotification { get; private set; } + + /// + /// Occurs when a notification is received from the device. + /// + public event Action? OnNotificationReceived; + + internal readonly struct EntryControlNotificationCommand : ICommand + { + public EntryControlNotificationCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.EntryControl; + + public static byte CommandId => (byte)EntryControlCommand.Notification; + + public CommandClassFrame Frame { get; } + + public static EntryControlNotification Parse(CommandClassFrame frame, ILogger logger) + { + // Minimum: SequenceNumber(1) + Reserved|DataType(1) + EventType(1) + EventDataLength(1) = 4 bytes + if (frame.CommandParameters.Length < 4) + { + logger.LogWarning( + "Entry Control Notification frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Entry Control Notification frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + + byte sequenceNumber = span[0]; + EntryControlDataType dataType = (EntryControlDataType)(span[1] & 0b0000_0011); + EntryControlEventType eventType = (EntryControlEventType)span[2]; + byte eventDataLength = span[3]; + + if (eventDataLength > 32) + { + logger.LogWarning( + "Entry Control Notification event data length {Length} exceeds maximum of 32", + eventDataLength); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Entry Control Notification event data length exceeds maximum"); + } + + if (frame.CommandParameters.Length < 4 + eventDataLength) + { + logger.LogWarning( + "Entry Control Notification frame too short for declared event data ({Length} bytes, need {Expected})", + frame.CommandParameters.Length, + 4 + eventDataLength); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Entry Control Notification frame is too short for event data"); + } + + ReadOnlyMemory eventData = eventDataLength > 0 + ? frame.CommandParameters.Slice(4, eventDataLength) + : ReadOnlyMemory.Empty; + + // For ASCII data, trim trailing 0xFF padding per spec §2.2.43.2 + if (dataType == EntryControlDataType.Ascii && eventData.Length > 0) + { + ReadOnlySpan eventDataSpan = eventData.Span; + int trimmedLength = eventData.Length; + while (trimmedLength > 0 && eventDataSpan[trimmedLength - 1] == 0xFF) + { + trimmedLength--; + } + + eventData = eventData[..trimmedLength]; + } + + return new EntryControlNotification(sequenceNumber, dataType, eventType, eventData); + } + } +} diff --git a/src/ZWave.CommandClasses/EntryControlCommandClass.cs b/src/ZWave.CommandClasses/EntryControlCommandClass.cs new file mode 100644 index 0000000..374c5ce --- /dev/null +++ b/src/ZWave.CommandClasses/EntryControlCommandClass.cs @@ -0,0 +1,281 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// The type of data carried in an Entry Control notification. +/// +public enum EntryControlDataType : byte +{ + /// + /// No data included. + /// + None = 0x00, + + /// + /// 1 to 32 bytes of arbitrary binary data. + /// + Raw = 0x01, + + /// + /// 1 to 32 ASCII encoded characters (codes 0x00-0xF7), padded with 0xFF to 16-byte blocks. + /// + Ascii = 0x02, + + /// + /// 16 bytes of MD5 hash data. + /// + Md5 = 0x03, +} + +/// +/// The type of event reported by an Entry Control device. +/// +public enum EntryControlEventType : byte +{ + /// + /// Indicates the user has started entering credentials and caching is initiated. + /// + Caching = 0x00, + + /// + /// Sends cached user inputs when the cache is full, timed out, or a command button is pressed. + /// + CachedKeys = 0x01, + + /// + /// The Enter command button was pressed. + /// + Enter = 0x02, + + /// + /// The Disarm command button was pressed. + /// + DisarmAll = 0x03, + + /// + /// The Arm command button was pressed. + /// + ArmAll = 0x04, + + /// + /// The Arm Away command button was pressed. + /// + ArmAway = 0x05, + + /// + /// The Arm Home command button was pressed. + /// + ArmHome = 0x06, + + /// + /// The Exit Delay / Arm Delay command button was pressed. + /// + ExitDelay = 0x07, + + /// + /// The Arm Zone 1 command button was pressed. + /// + Arm1 = 0x08, + + /// + /// The Arm Zone 2 command button was pressed. + /// + Arm2 = 0x09, + + /// + /// The Arm Zone 3 command button was pressed. + /// + Arm3 = 0x0A, + + /// + /// The Arm Zone 4 command button was pressed. + /// + Arm4 = 0x0B, + + /// + /// The Arm Zone 5 command button was pressed. + /// + Arm5 = 0x0C, + + /// + /// The Arm Zone 6 command button was pressed. + /// + Arm6 = 0x0D, + + /// + /// An RFID tag was presented. + /// + Rfid = 0x0E, + + /// + /// The Bell button was pressed. + /// + Bell = 0x0F, + + /// + /// The Fire button was pressed. + /// + Fire = 0x10, + + /// + /// The Police button was pressed. + /// + Police = 0x11, + + /// + /// A panic alert was triggered. + /// + AlertPanic = 0x12, + + /// + /// A medical alert was triggered. + /// + AlertMedical = 0x13, + + /// + /// The Gate Open command button was pressed. + /// + GateOpen = 0x14, + + /// + /// The Gate Close command button was pressed. + /// + GateClose = 0x15, + + /// + /// The Lock command was issued. + /// + Lock = 0x16, + + /// + /// The Unlock command was issued. + /// + Unlock = 0x17, + + /// + /// The Test button was pressed. + /// + Test = 0x18, + + /// + /// The Cancel button was pressed. + /// + Cancel = 0x19, +} + +/// +/// Commands for the Entry Control Command Class. +/// +public enum EntryControlCommand : byte +{ + /// + /// Advertises user input from the Entry Control device. + /// + Notification = 0x01, + + /// + /// Requests the supported keys for credential entry. + /// + KeySupportedGet = 0x02, + + /// + /// Advertises the supported keys for credential entry. + /// + KeySupportedReport = 0x03, + + /// + /// Requests the supported events, data types, and configuration ranges. + /// + EventSupportedGet = 0x04, + + /// + /// Advertises the supported events, data types, and configuration ranges. + /// + EventSupportedReport = 0x05, + + /// + /// Configures the key cache size and timeout. + /// + ConfigurationSet = 0x06, + + /// + /// Requests the current configuration. + /// + ConfigurationGet = 0x07, + + /// + /// Advertises the current configuration. + /// + ConfigurationReport = 0x08, +} + +/// +/// Implementation of the Entry Control Command Class (version 1). +/// +/// +/// The Entry Control Command Class defines a method for advertising user input to a central +/// Entry Control application and for discovery of capabilities. User input may be button presses, +/// RFID tags, or other means. +/// +[CommandClass(CommandClassId.EntryControl)] +public sealed partial class EntryControlCommandClass : CommandClass +{ + private byte? _lastNotificationSequenceNumber; + + internal EntryControlCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + public override bool? IsCommandSupported(EntryControlCommand command) + => command switch + { + EntryControlCommand.KeySupportedGet => true, + EntryControlCommand.EventSupportedGet => true, + EntryControlCommand.ConfigurationSet => true, + EntryControlCommand.ConfigurationGet => true, + _ => false, + }; + + /// + internal override async Task InterviewAsync(CancellationToken cancellationToken) + { + // Per spec Figure 6.11: + // 1. Key Supported Get + // 2. Event Supported Get + // 3. Configuration Get + _ = await GetSupportedKeysAsync(cancellationToken).ConfigureAwait(false); + _ = await GetEventSupportedAsync(cancellationToken).ConfigureAwait(false); + _ = await GetConfigurationAsync(cancellationToken).ConfigureAwait(false); + } + + /// + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((EntryControlCommand)frame.CommandId) + { + case EntryControlCommand.Notification: + { + EntryControlNotification notification = EntryControlNotificationCommand.Parse(frame, Logger); + + // Per spec: "A receiving device MUST use the Sequence Number to detect and ignore duplicates." + if (_lastNotificationSequenceNumber.HasValue + && notification.SequenceNumber == _lastNotificationSequenceNumber.Value) + { + return; + } + + _lastNotificationSequenceNumber = notification.SequenceNumber; + LastNotification = notification; + OnNotificationReceived?.Invoke(notification); + break; + } + } + } +}