From 39e7fb156ee45c5576e55c2f3768773bf54d1372 Mon Sep 17 00:00:00 2001 From: David Federman Date: Sat, 7 Mar 2026 21:28:38 -0800 Subject: [PATCH] Implement Scene-related CCs --- ...ralSceneCommandClassTests.Configuration.cs | 100 ++++++++ ...tralSceneCommandClassTests.Notification.cs | 107 ++++++++ ...CentralSceneCommandClassTests.Supported.cs | 151 +++++++++++ .../CentralSceneCommandClassTests.cs | 6 + .../SceneActivationCommandClassTests.cs | 127 ++++++++++ ...eActuatorConfigurationCommandClassTests.cs | 126 ++++++++++ ...ontrollerConfigurationCommandClassTests.cs | 137 ++++++++++ .../CentralSceneCommandClass.Configuration.cs | 135 ++++++++++ .../CentralSceneCommandClass.Notification.cs | 90 +++++++ .../CentralSceneCommandClass.Supported.cs | 159 ++++++++++++ .../CentralSceneCommandClass.cs | 158 ++++++++++++ src/ZWave.CommandClasses/DurationEncoding.cs | 63 +++++ src/ZWave.CommandClasses/DurationReport.cs | 19 +- src/ZWave.CommandClasses/DurationSet.cs | 23 +- .../SceneActivationCommandClass.cs | 150 +++++++++++ .../SceneActuatorConfigurationCommandClass.cs | 238 ++++++++++++++++++ ...ceneControllerConfigurationCommandClass.cs | 232 +++++++++++++++++ 17 files changed, 1981 insertions(+), 40 deletions(-) create mode 100644 src/ZWave.CommandClasses.Tests/CentralSceneCommandClassTests.Configuration.cs create mode 100644 src/ZWave.CommandClasses.Tests/CentralSceneCommandClassTests.Notification.cs create mode 100644 src/ZWave.CommandClasses.Tests/CentralSceneCommandClassTests.Supported.cs create mode 100644 src/ZWave.CommandClasses.Tests/CentralSceneCommandClassTests.cs create mode 100644 src/ZWave.CommandClasses.Tests/SceneActivationCommandClassTests.cs create mode 100644 src/ZWave.CommandClasses.Tests/SceneActuatorConfigurationCommandClassTests.cs create mode 100644 src/ZWave.CommandClasses.Tests/SceneControllerConfigurationCommandClassTests.cs create mode 100644 src/ZWave.CommandClasses/CentralSceneCommandClass.Configuration.cs create mode 100644 src/ZWave.CommandClasses/CentralSceneCommandClass.Notification.cs create mode 100644 src/ZWave.CommandClasses/CentralSceneCommandClass.Supported.cs create mode 100644 src/ZWave.CommandClasses/CentralSceneCommandClass.cs create mode 100644 src/ZWave.CommandClasses/DurationEncoding.cs create mode 100644 src/ZWave.CommandClasses/SceneActivationCommandClass.cs create mode 100644 src/ZWave.CommandClasses/SceneActuatorConfigurationCommandClass.cs create mode 100644 src/ZWave.CommandClasses/SceneControllerConfigurationCommandClass.cs diff --git a/src/ZWave.CommandClasses.Tests/CentralSceneCommandClassTests.Configuration.cs b/src/ZWave.CommandClasses.Tests/CentralSceneCommandClassTests.Configuration.cs new file mode 100644 index 0000000..db27185 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/CentralSceneCommandClassTests.Configuration.cs @@ -0,0 +1,100 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class CentralSceneCommandClassTests +{ + [TestMethod] + public void ConfigurationSet_Create_SlowRefreshEnabled() + { + CentralSceneCommandClass.CentralSceneConfigurationSetCommand command = + CentralSceneCommandClass.CentralSceneConfigurationSetCommand.Create(slowRefresh: true); + + Assert.AreEqual(CommandClassId.CentralScene, CentralSceneCommandClass.CentralSceneConfigurationSetCommand.CommandClassId); + Assert.AreEqual((byte)CentralSceneCommand.ConfigurationSet, CentralSceneCommandClass.CentralSceneConfigurationSetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); // CC + Cmd + Properties1 + Assert.AreEqual((byte)0x80, command.Frame.CommandParameters.Span[0]); // Bit 7 set + } + + [TestMethod] + public void ConfigurationSet_Create_SlowRefreshDisabled() + { + CentralSceneCommandClass.CentralSceneConfigurationSetCommand command = + CentralSceneCommandClass.CentralSceneConfigurationSetCommand.Create(slowRefresh: false); + + Assert.AreEqual((byte)0x00, command.Frame.CommandParameters.Span[0]); // Bit 7 clear + } + + [TestMethod] + public void ConfigurationGet_Create_HasCorrectFormat() + { + CentralSceneCommandClass.CentralSceneConfigurationGetCommand command = + CentralSceneCommandClass.CentralSceneConfigurationGetCommand.Create(); + + Assert.AreEqual(CommandClassId.CentralScene, CentralSceneCommandClass.CentralSceneConfigurationGetCommand.CommandClassId); + Assert.AreEqual((byte)CentralSceneCommand.ConfigurationGet, CentralSceneCommandClass.CentralSceneConfigurationGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); // CC + Cmd only + } + + [TestMethod] + public void ConfigurationReport_Parse_SlowRefreshEnabled() + { + // CC=0x5B, Cmd=0x06, Properties1=0x80 (SlowRefresh=1) + byte[] data = [0x5B, 0x06, 0x80]; + CommandClassFrame frame = new(data); + + CentralSceneConfigurationReport report = + CentralSceneCommandClass.CentralSceneConfigurationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsTrue(report.SlowRefresh); + } + + [TestMethod] + public void ConfigurationReport_Parse_SlowRefreshDisabled() + { + // CC=0x5B, Cmd=0x06, Properties1=0x00 (SlowRefresh=0) + byte[] data = [0x5B, 0x06, 0x00]; + CommandClassFrame frame = new(data); + + CentralSceneConfigurationReport report = + CentralSceneCommandClass.CentralSceneConfigurationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsFalse(report.SlowRefresh); + } + + [TestMethod] + public void ConfigurationReport_Parse_ReservedBitsIgnored() + { + // CC=0x5B, Cmd=0x06, Properties1=0x7F (SlowRefresh=0, Reserved bits set) + byte[] data = [0x5B, 0x06, 0x7F]; + CommandClassFrame frame = new(data); + + CentralSceneConfigurationReport report = + CentralSceneCommandClass.CentralSceneConfigurationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsFalse(report.SlowRefresh); + } + + [TestMethod] + public void ConfigurationReport_Parse_TooShort_Throws() + { + // CC=0x5B, Cmd=0x06, no parameters + byte[] data = [0x5B, 0x06]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => CentralSceneCommandClass.CentralSceneConfigurationReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void ConfigurationSet_Create_RoundTrips() + { + CentralSceneCommandClass.CentralSceneConfigurationSetCommand command = + CentralSceneCommandClass.CentralSceneConfigurationSetCommand.Create(slowRefresh: true); + + CentralSceneConfigurationReport report = + CentralSceneCommandClass.CentralSceneConfigurationReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.IsTrue(report.SlowRefresh); + } +} diff --git a/src/ZWave.CommandClasses.Tests/CentralSceneCommandClassTests.Notification.cs b/src/ZWave.CommandClasses.Tests/CentralSceneCommandClassTests.Notification.cs new file mode 100644 index 0000000..f6bfdcb --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/CentralSceneCommandClassTests.Notification.cs @@ -0,0 +1,107 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class CentralSceneCommandClassTests +{ + [TestMethod] + public void Notification_Parse_Version1_KeyPressed() + { + // CC=0x5B, Cmd=0x03, SeqNum=0x01, Properties1=0x00 (KeyPressed), SceneNumber=0x01 + byte[] data = [0x5B, 0x03, 0x01, 0x00, 0x01]; + CommandClassFrame frame = new(data); + + CentralSceneNotification notification = + CentralSceneCommandClass.CentralSceneNotificationCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x01, notification.SequenceNumber); + Assert.AreEqual(CentralSceneKeyAttribute.KeyPressed, notification.KeyAttribute); + Assert.AreEqual((byte)0x01, notification.SceneNumber); + Assert.IsFalse(notification.SlowRefresh); + } + + [TestMethod] + public void Notification_Parse_KeyReleased() + { + // CC=0x5B, Cmd=0x03, SeqNum=0x05, Properties1=0x01 (KeyReleased), SceneNumber=0x03 + byte[] data = [0x5B, 0x03, 0x05, 0x01, 0x03]; + CommandClassFrame frame = new(data); + + CentralSceneNotification notification = + CentralSceneCommandClass.CentralSceneNotificationCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x05, notification.SequenceNumber); + Assert.AreEqual(CentralSceneKeyAttribute.KeyReleased, notification.KeyAttribute); + Assert.AreEqual((byte)0x03, notification.SceneNumber); + } + + [TestMethod] + public void Notification_Parse_KeyHeldDown() + { + // CC=0x5B, Cmd=0x03, SeqNum=0x0A, Properties1=0x02 (KeyHeldDown), SceneNumber=0x02 + byte[] data = [0x5B, 0x03, 0x0A, 0x02, 0x02]; + CommandClassFrame frame = new(data); + + CentralSceneNotification notification = + CentralSceneCommandClass.CentralSceneNotificationCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x0A, notification.SequenceNumber); + Assert.AreEqual(CentralSceneKeyAttribute.KeyHeldDown, notification.KeyAttribute); + Assert.AreEqual((byte)0x02, notification.SceneNumber); + } + + [TestMethod] + public void Notification_Parse_Version2_KeyPressed2Times() + { + // CC=0x5B, Cmd=0x03, SeqNum=0x10, Properties1=0x03 (KeyPressed2Times), SceneNumber=0x01 + byte[] data = [0x5B, 0x03, 0x10, 0x03, 0x01]; + CommandClassFrame frame = new(data); + + CentralSceneNotification notification = + CentralSceneCommandClass.CentralSceneNotificationCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x10, notification.SequenceNumber); + Assert.AreEqual(CentralSceneKeyAttribute.KeyPressed2Times, notification.KeyAttribute); + Assert.AreEqual((byte)0x01, notification.SceneNumber); + } + + [TestMethod] + public void Notification_Parse_Version3_SlowRefreshEnabled() + { + // CC=0x5B, Cmd=0x03, SeqNum=0x20, Properties1=0x82 (SlowRefresh=1, KeyHeldDown=0x02), SceneNumber=0x01 + byte[] data = [0x5B, 0x03, 0x20, 0x82, 0x01]; + CommandClassFrame frame = new(data); + + CentralSceneNotification notification = + CentralSceneCommandClass.CentralSceneNotificationCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x20, notification.SequenceNumber); + Assert.AreEqual(CentralSceneKeyAttribute.KeyHeldDown, notification.KeyAttribute); + Assert.AreEqual((byte)0x01, notification.SceneNumber); + Assert.IsTrue(notification.SlowRefresh); + } + + [TestMethod] + public void Notification_Parse_KeyAttributeExtractedFromLower3Bits() + { + // Properties1=0xFD: bits 7-3 = 11111, bits 2-0 = 101 (KeyPressed4Times=0x05) + byte[] data = [0x5B, 0x03, 0x01, 0xFD, 0x01]; + CommandClassFrame frame = new(data); + + CentralSceneNotification notification = + CentralSceneCommandClass.CentralSceneNotificationCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(CentralSceneKeyAttribute.KeyPressed4Times, notification.KeyAttribute); + } + + [TestMethod] + public void Notification_Parse_TooShort_Throws() + { + // CC=0x5B, Cmd=0x03, only 2 parameter bytes (need 3) + byte[] data = [0x5B, 0x03, 0x01, 0x00]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => CentralSceneCommandClass.CentralSceneNotificationCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/CentralSceneCommandClassTests.Supported.cs b/src/ZWave.CommandClasses.Tests/CentralSceneCommandClassTests.Supported.cs new file mode 100644 index 0000000..fdbc313 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/CentralSceneCommandClassTests.Supported.cs @@ -0,0 +1,151 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class CentralSceneCommandClassTests +{ + [TestMethod] + public void SupportedGet_Create_HasCorrectFormat() + { + CentralSceneCommandClass.CentralSceneSupportedGetCommand command = + CentralSceneCommandClass.CentralSceneSupportedGetCommand.Create(); + + Assert.AreEqual(CommandClassId.CentralScene, CentralSceneCommandClass.CentralSceneSupportedGetCommand.CommandClassId); + Assert.AreEqual((byte)CentralSceneCommand.SupportedGet, CentralSceneCommandClass.CentralSceneSupportedGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); // CC + Cmd only + } + + [TestMethod] + public void SupportedReport_Parse_Version1_SupportedScenesOnly() + { + // CC=0x5B, Cmd=0x02, SupportedScenes=4 + byte[] data = [0x5B, 0x02, 0x04]; + CommandClassFrame frame = new(data); + + CentralSceneSupportedReport report = + CentralSceneCommandClass.CentralSceneSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)4, report.SupportedScenes); + Assert.IsNull(report.SlowRefreshSupport); + Assert.IsNull(report.Identical); + Assert.IsNull(report.SupportedKeyAttributesPerScene); + } + + [TestMethod] + public void SupportedReport_Parse_Version2_IdenticalScenes() + { + // CC=0x5B, Cmd=0x02, SupportedScenes=3, Properties1=0x03 (Identical=1, BitMaskBytes=1), + // KeyAttributes for all scenes: 0b00001111 (KeyPressed, KeyReleased, KeyHeldDown, KeyPressed2Times) + byte[] data = [0x5B, 0x02, 0x03, 0x03, 0x0F]; + CommandClassFrame frame = new(data); + + CentralSceneSupportedReport report = + CentralSceneCommandClass.CentralSceneSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)3, report.SupportedScenes); + Assert.IsNotNull(report.SlowRefreshSupport); + Assert.IsFalse(report.SlowRefreshSupport.Value); + Assert.IsNotNull(report.Identical); + Assert.IsTrue(report.Identical.Value); + Assert.IsNotNull(report.SupportedKeyAttributesPerScene); + Assert.HasCount(1, report.SupportedKeyAttributesPerScene); // Identical=true, only 1 set + Assert.Contains(CentralSceneKeyAttribute.KeyPressed, report.SupportedKeyAttributesPerScene[0]); + Assert.Contains(CentralSceneKeyAttribute.KeyReleased, report.SupportedKeyAttributesPerScene[0]); + Assert.Contains(CentralSceneKeyAttribute.KeyHeldDown, report.SupportedKeyAttributesPerScene[0]); + Assert.Contains(CentralSceneKeyAttribute.KeyPressed2Times, report.SupportedKeyAttributesPerScene[0]); + } + + [TestMethod] + public void SupportedReport_Parse_Version2_DifferentScenes() + { + // CC=0x5B, Cmd=0x02, SupportedScenes=2, Properties1=0x02 (Identical=0, BitMaskBytes=1), + // Scene 1: 0b00000111 (KeyPressed, KeyReleased, KeyHeldDown) + // Scene 2: 0b00000001 (KeyPressed only) + byte[] data = [0x5B, 0x02, 0x02, 0x02, 0x07, 0x01]; + CommandClassFrame frame = new(data); + + CentralSceneSupportedReport report = + CentralSceneCommandClass.CentralSceneSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)2, report.SupportedScenes); + Assert.IsNotNull(report.Identical); + Assert.IsFalse(report.Identical.Value); + Assert.IsNotNull(report.SupportedKeyAttributesPerScene); + Assert.HasCount(2, report.SupportedKeyAttributesPerScene); + + // Scene 1 + Assert.Contains(CentralSceneKeyAttribute.KeyPressed, report.SupportedKeyAttributesPerScene[0]); + Assert.Contains(CentralSceneKeyAttribute.KeyReleased, report.SupportedKeyAttributesPerScene[0]); + Assert.Contains(CentralSceneKeyAttribute.KeyHeldDown, report.SupportedKeyAttributesPerScene[0]); + Assert.HasCount(3, report.SupportedKeyAttributesPerScene[0]); + + // Scene 2 + Assert.Contains(CentralSceneKeyAttribute.KeyPressed, report.SupportedKeyAttributesPerScene[1]); + Assert.HasCount(1, report.SupportedKeyAttributesPerScene[1]); + } + + [TestMethod] + public void SupportedReport_Parse_Version3_SlowRefreshSupport() + { + // CC=0x5B, Cmd=0x02, SupportedScenes=2, Properties1=0x83 (SlowRefresh=1, Identical=1, BitMaskBytes=1), + // KeyAttributes: 0b00000111 + byte[] data = [0x5B, 0x02, 0x02, 0x83, 0x07]; + CommandClassFrame frame = new(data); + + CentralSceneSupportedReport report = + CentralSceneCommandClass.CentralSceneSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)2, report.SupportedScenes); + Assert.IsNotNull(report.SlowRefreshSupport); + Assert.IsTrue(report.SlowRefreshSupport.Value); + Assert.IsNotNull(report.Identical); + Assert.IsTrue(report.Identical.Value); + } + + [TestMethod] + public void SupportedReport_Parse_MultiByteBitmask() + { + // CC=0x5B, Cmd=0x02, SupportedScenes=1, Properties1=0x05 (Identical=1, BitMaskBytes=2), + // KeyAttributes: 0b01111111 0b00000000 (bits 0-6 set in first byte) + byte[] data = [0x5B, 0x02, 0x01, 0x05, 0x7F, 0x00]; + CommandClassFrame frame = new(data); + + CentralSceneSupportedReport report = + CentralSceneCommandClass.CentralSceneSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, report.SupportedScenes); + Assert.IsNotNull(report.SupportedKeyAttributesPerScene); + Assert.HasCount(1, report.SupportedKeyAttributesPerScene); + Assert.Contains(CentralSceneKeyAttribute.KeyPressed, report.SupportedKeyAttributesPerScene[0]); + Assert.Contains(CentralSceneKeyAttribute.KeyReleased, report.SupportedKeyAttributesPerScene[0]); + Assert.Contains(CentralSceneKeyAttribute.KeyHeldDown, report.SupportedKeyAttributesPerScene[0]); + Assert.Contains(CentralSceneKeyAttribute.KeyPressed2Times, report.SupportedKeyAttributesPerScene[0]); + Assert.Contains(CentralSceneKeyAttribute.KeyPressed3Times, report.SupportedKeyAttributesPerScene[0]); + Assert.Contains(CentralSceneKeyAttribute.KeyPressed4Times, report.SupportedKeyAttributesPerScene[0]); + Assert.Contains(CentralSceneKeyAttribute.KeyPressed5Times, report.SupportedKeyAttributesPerScene[0]); + Assert.HasCount(7, report.SupportedKeyAttributesPerScene[0]); + } + + [TestMethod] + public void SupportedReport_Parse_TooShort_Throws() + { + // CC=0x5B, Cmd=0x02, no parameters + byte[] data = [0x5B, 0x02]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => CentralSceneCommandClass.CentralSceneSupportedReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void SupportedReport_Parse_BitmaskDataTooShort_Throws() + { + // CC=0x5B, Cmd=0x02, SupportedScenes=2, Properties1=0x02 (Identical=0, BitMaskBytes=1) + // Missing bitmask data for 2 scenes + byte[] data = [0x5B, 0x02, 0x02, 0x02, 0x07]; // Only 1 bitmask byte, need 2 + CommandClassFrame frame = new(data); + + Assert.Throws( + () => CentralSceneCommandClass.CentralSceneSupportedReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/CentralSceneCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/CentralSceneCommandClassTests.cs new file mode 100644 index 0000000..6fc5a23 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/CentralSceneCommandClassTests.cs @@ -0,0 +1,6 @@ +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public partial class CentralSceneCommandClassTests +{ +} diff --git a/src/ZWave.CommandClasses.Tests/SceneActivationCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/SceneActivationCommandClassTests.cs new file mode 100644 index 0000000..10c3be2 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/SceneActivationCommandClassTests.cs @@ -0,0 +1,127 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public class SceneActivationCommandClassTests +{ + [TestMethod] + public void SetCommand_Create_HasCorrectFormat() + { + SceneActivationCommandClass.SceneActivationSetCommand command = + SceneActivationCommandClass.SceneActivationSetCommand.Create(0x05, TimeSpan.FromSeconds(10)); + + Assert.AreEqual(CommandClassId.SceneActivation, SceneActivationCommandClass.SceneActivationSetCommand.CommandClassId); + Assert.AreEqual((byte)SceneActivationCommand.Set, SceneActivationCommandClass.SceneActivationSetCommand.CommandId); + Assert.AreEqual(4, command.Frame.Data.Length); // CC + Cmd + SceneId + DimmingDuration + Assert.AreEqual((byte)0x05, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual((byte)0x0A, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void SetCommand_Create_WithConfiguredDuration() + { + // null = use duration configured by Scene Actuator Configuration Set (wire value 0xFF) + SceneActivationCommandClass.SceneActivationSetCommand command = + SceneActivationCommandClass.SceneActivationSetCommand.Create(0x01, null); + + Assert.AreEqual((byte)0x01, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual((byte)0xFF, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void SetCommand_Parse_ValidFrame() + { + // CC=0x2B, Cmd=0x01, SceneId=10, DimmingDuration=0x05 (5 seconds) + byte[] data = [0x2B, 0x01, 0x0A, 0x05]; + CommandClassFrame frame = new(data); + + SceneActivation activation = + SceneActivationCommandClass.SceneActivationSetCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)10, activation.SceneId); + Assert.IsNotNull(activation.DimmingDuration); + Assert.AreEqual(TimeSpan.FromSeconds(5), activation.DimmingDuration.Value); + } + + [TestMethod] + public void SetCommand_Parse_InstantDuration() + { + // CC=0x2B, Cmd=0x01, SceneId=1, DimmingDuration=0x00 (Instantly) + byte[] data = [0x2B, 0x01, 0x01, 0x00]; + CommandClassFrame frame = new(data); + + SceneActivation activation = + SceneActivationCommandClass.SceneActivationSetCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, activation.SceneId); + Assert.IsNotNull(activation.DimmingDuration); + Assert.AreEqual(TimeSpan.Zero, activation.DimmingDuration.Value); + } + + [TestMethod] + public void SetCommand_Parse_ConfiguredDuration() + { + // CC=0x2B, Cmd=0x01, SceneId=255, DimmingDuration=0xFF (use configured duration) + byte[] data = [0x2B, 0x01, 0xFF, 0xFF]; + CommandClassFrame frame = new(data); + + SceneActivation activation = + SceneActivationCommandClass.SceneActivationSetCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)255, activation.SceneId); + Assert.IsNull(activation.DimmingDuration); + } + + [TestMethod] + public void SetCommand_Parse_MinuteDuration() + { + // CC=0x2B, Cmd=0x01, SceneId=50, DimmingDuration=0x80 (1 minute) + byte[] data = [0x2B, 0x01, 0x32, 0x80]; + CommandClassFrame frame = new(data); + + SceneActivation activation = + SceneActivationCommandClass.SceneActivationSetCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)50, activation.SceneId); + Assert.IsNotNull(activation.DimmingDuration); + Assert.AreEqual(TimeSpan.FromMinutes(1), activation.DimmingDuration.Value); + } + + [TestMethod] + public void SetCommand_Parse_MaxMinuteDuration() + { + // CC=0x2B, Cmd=0x01, SceneId=1, DimmingDuration=0xFE (127 minutes) + byte[] data = [0x2B, 0x01, 0x01, 0xFE]; + CommandClassFrame frame = new(data); + + SceneActivation activation = + SceneActivationCommandClass.SceneActivationSetCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(TimeSpan.FromMinutes(127), activation.DimmingDuration!.Value); + } + + [TestMethod] + public void SetCommand_Parse_TooShort_Throws() + { + // CC=0x2B, Cmd=0x01, only 1 parameter byte (need 2) + byte[] data = [0x2B, 0x01, 0x0A]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => SceneActivationCommandClass.SceneActivationSetCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void SetCommand_Create_RoundTrips() + { + SceneActivationCommandClass.SceneActivationSetCommand command = + SceneActivationCommandClass.SceneActivationSetCommand.Create(0x0A, TimeSpan.FromSeconds(5)); + + SceneActivation activation = + SceneActivationCommandClass.SceneActivationSetCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x0A, activation.SceneId); + Assert.AreEqual(TimeSpan.FromSeconds(5), activation.DimmingDuration!.Value); + } +} diff --git a/src/ZWave.CommandClasses.Tests/SceneActuatorConfigurationCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/SceneActuatorConfigurationCommandClassTests.cs new file mode 100644 index 0000000..b7d2c7a --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/SceneActuatorConfigurationCommandClassTests.cs @@ -0,0 +1,126 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public class SceneActuatorConfigurationCommandClassTests +{ + [TestMethod] + public void SetCommand_Create_HasCorrectFormat() + { + SceneActuatorConfigurationCommandClass.SceneActuatorConfigurationSetCommand command = + SceneActuatorConfigurationCommandClass.SceneActuatorConfigurationSetCommand.Create( + sceneId: 0x05, dimmingDuration: TimeSpan.FromSeconds(10), overrideLevel: true, level: 0x63); + + Assert.AreEqual( + CommandClassId.SceneActuatorConfiguration, + SceneActuatorConfigurationCommandClass.SceneActuatorConfigurationSetCommand.CommandClassId); + Assert.AreEqual( + (byte)SceneActuatorConfigurationCommand.Set, + SceneActuatorConfigurationCommandClass.SceneActuatorConfigurationSetCommand.CommandId); + // CC + Cmd + SceneId + DimmingDuration + Override|Reserved + Level = 6 bytes + Assert.AreEqual(6, command.Frame.Data.Length); + Assert.AreEqual((byte)0x05, command.Frame.CommandParameters.Span[0]); // SceneId + Assert.AreEqual((byte)0x0A, command.Frame.CommandParameters.Span[1]); // DimmingDuration = 10 seconds + Assert.AreEqual((byte)0x80, command.Frame.CommandParameters.Span[2]); // Override=1, Reserved=0 + Assert.AreEqual((byte)0x63, command.Frame.CommandParameters.Span[3]); // Level + } + + [TestMethod] + public void SetCommand_Create_OverrideFalse_UsesCurrentSettings() + { + SceneActuatorConfigurationCommandClass.SceneActuatorConfigurationSetCommand command = + SceneActuatorConfigurationCommandClass.SceneActuatorConfigurationSetCommand.Create( + sceneId: 0x01, dimmingDuration: TimeSpan.Zero, overrideLevel: false, level: 0xFF); + + Assert.AreEqual((byte)0x01, command.Frame.CommandParameters.Span[0]); // SceneId + Assert.AreEqual((byte)0x00, command.Frame.CommandParameters.Span[1]); // DimmingDuration = instantly + Assert.AreEqual((byte)0x00, command.Frame.CommandParameters.Span[2]); // Override=0 + Assert.AreEqual((byte)0xFF, command.Frame.CommandParameters.Span[3]); // Level (ignored by receiver) + } + + [TestMethod] + public void GetCommand_Create_HasCorrectFormat() + { + SceneActuatorConfigurationCommandClass.SceneActuatorConfigurationGetCommand command = + SceneActuatorConfigurationCommandClass.SceneActuatorConfigurationGetCommand.Create(0x05); + + Assert.AreEqual( + CommandClassId.SceneActuatorConfiguration, + SceneActuatorConfigurationCommandClass.SceneActuatorConfigurationGetCommand.CommandClassId); + Assert.AreEqual( + (byte)SceneActuatorConfigurationCommand.Get, + SceneActuatorConfigurationCommandClass.SceneActuatorConfigurationGetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); // CC + Cmd + SceneId + Assert.AreEqual((byte)0x05, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void GetCommand_Create_CurrentActiveScene() + { + SceneActuatorConfigurationCommandClass.SceneActuatorConfigurationGetCommand command = + SceneActuatorConfigurationCommandClass.SceneActuatorConfigurationGetCommand.Create(0x00); + + Assert.AreEqual((byte)0x00, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void Report_Parse_ValidFrame() + { + // CC=0x2C, Cmd=0x03, SceneId=5, Level=0x63, DimmingDuration=0x0A (10 seconds) + byte[] data = [0x2C, 0x03, 0x05, 0x63, 0x0A]; + CommandClassFrame frame = new(data); + + SceneActuatorConfigurationReport report = + SceneActuatorConfigurationCommandClass.SceneActuatorConfigurationReportCommand.Parse( + frame, NullLogger.Instance); + + Assert.AreEqual((byte)5, report.SceneId); + Assert.AreEqual((byte)0x63, report.Level); + Assert.AreEqual(TimeSpan.FromSeconds(10), report.DimmingDuration); + } + + [TestMethod] + public void Report_Parse_NoActiveScene() + { + // CC=0x2C, Cmd=0x03, SceneId=0 (no active scene), Level=0x00, DimmingDuration=0x00 + byte[] data = [0x2C, 0x03, 0x00, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + SceneActuatorConfigurationReport report = + SceneActuatorConfigurationCommandClass.SceneActuatorConfigurationReportCommand.Parse( + frame, NullLogger.Instance); + + Assert.AreEqual((byte)0, report.SceneId); + Assert.AreEqual((byte)0x00, report.Level); + Assert.AreEqual(TimeSpan.Zero, report.DimmingDuration); + } + + [TestMethod] + public void Report_Parse_MaxMinuteDuration() + { + // CC=0x2C, Cmd=0x03, SceneId=10, Level=0xFF, DimmingDuration=0xFE (127 minutes) + byte[] data = [0x2C, 0x03, 0x0A, 0xFF, 0xFE]; + CommandClassFrame frame = new(data); + + SceneActuatorConfigurationReport report = + SceneActuatorConfigurationCommandClass.SceneActuatorConfigurationReportCommand.Parse( + frame, NullLogger.Instance); + + Assert.AreEqual((byte)10, report.SceneId); + Assert.AreEqual((byte)0xFF, report.Level); + Assert.AreEqual(TimeSpan.FromMinutes(127), report.DimmingDuration); + } + + [TestMethod] + public void Report_Parse_TooShort_Throws() + { + // CC=0x2C, Cmd=0x03, only 2 parameter bytes (need 3) + byte[] data = [0x2C, 0x03, 0x05, 0x63]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => SceneActuatorConfigurationCommandClass.SceneActuatorConfigurationReportCommand.Parse( + frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/SceneControllerConfigurationCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/SceneControllerConfigurationCommandClassTests.cs new file mode 100644 index 0000000..adbb501 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/SceneControllerConfigurationCommandClassTests.cs @@ -0,0 +1,137 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public class SceneControllerConfigurationCommandClassTests +{ + [TestMethod] + public void SetCommand_Create_HasCorrectFormat() + { + SceneControllerConfigurationCommandClass.SceneControllerConfigurationSetCommand command = + SceneControllerConfigurationCommandClass.SceneControllerConfigurationSetCommand.Create( + groupId: 0x02, sceneId: 0x05, dimmingDuration: TimeSpan.FromSeconds(10)); + + Assert.AreEqual( + CommandClassId.SceneControllerConfiguration, + SceneControllerConfigurationCommandClass.SceneControllerConfigurationSetCommand.CommandClassId); + Assert.AreEqual( + (byte)SceneControllerConfigurationCommand.Set, + SceneControllerConfigurationCommandClass.SceneControllerConfigurationSetCommand.CommandId); + // CC + Cmd + GroupId + SceneId + DimmingDuration = 5 bytes + Assert.AreEqual(5, command.Frame.Data.Length); + Assert.AreEqual((byte)0x02, command.Frame.CommandParameters.Span[0]); // GroupId + Assert.AreEqual((byte)0x05, command.Frame.CommandParameters.Span[1]); // SceneId + Assert.AreEqual((byte)0x0A, command.Frame.CommandParameters.Span[2]); // DimmingDuration = 10 seconds + } + + [TestMethod] + public void SetCommand_Create_DisableScene() + { + SceneControllerConfigurationCommandClass.SceneControllerConfigurationSetCommand command = + SceneControllerConfigurationCommandClass.SceneControllerConfigurationSetCommand.Create( + groupId: 0x03, sceneId: 0x00, dimmingDuration: TimeSpan.Zero); + + Assert.AreEqual((byte)0x03, command.Frame.CommandParameters.Span[0]); // GroupId + Assert.AreEqual((byte)0x00, command.Frame.CommandParameters.Span[1]); // SceneId=0 (disable) + Assert.AreEqual((byte)0x00, command.Frame.CommandParameters.Span[2]); // DimmingDuration + } + + [TestMethod] + public void GetCommand_Create_SpecificGroup() + { + SceneControllerConfigurationCommandClass.SceneControllerConfigurationGetCommand command = + SceneControllerConfigurationCommandClass.SceneControllerConfigurationGetCommand.Create(0x02); + + Assert.AreEqual( + CommandClassId.SceneControllerConfiguration, + SceneControllerConfigurationCommandClass.SceneControllerConfigurationGetCommand.CommandClassId); + Assert.AreEqual( + (byte)SceneControllerConfigurationCommand.Get, + SceneControllerConfigurationCommandClass.SceneControllerConfigurationGetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); // CC + Cmd + GroupId + Assert.AreEqual((byte)0x02, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void GetCommand_Create_CurrentlyActiveGroup() + { + SceneControllerConfigurationCommandClass.SceneControllerConfigurationGetCommand command = + SceneControllerConfigurationCommandClass.SceneControllerConfigurationGetCommand.Create(0x00); + + Assert.AreEqual((byte)0x00, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void Report_Parse_ValidFrame() + { + // CC=0x2D, Cmd=0x03, GroupId=2, SceneId=5, DimmingDuration=0x0A (10 seconds) + byte[] data = [0x2D, 0x03, 0x02, 0x05, 0x0A]; + CommandClassFrame frame = new(data); + + SceneControllerConfigurationReport report = + SceneControllerConfigurationCommandClass.SceneControllerConfigurationReportCommand.Parse( + frame, NullLogger.Instance); + + Assert.AreEqual((byte)2, report.GroupId); + Assert.AreEqual((byte)5, report.SceneId); + Assert.AreEqual(TimeSpan.FromSeconds(10), report.DimmingDuration); + } + + [TestMethod] + public void Report_Parse_DisabledScene() + { + // CC=0x2D, Cmd=0x03, GroupId=3, SceneId=0 (disabled), DimmingDuration=0x00 + byte[] data = [0x2D, 0x03, 0x03, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + SceneControllerConfigurationReport report = + SceneControllerConfigurationCommandClass.SceneControllerConfigurationReportCommand.Parse( + frame, NullLogger.Instance); + + Assert.AreEqual((byte)3, report.GroupId); + Assert.AreEqual((byte)0, report.SceneId); + Assert.AreEqual(TimeSpan.Zero, report.DimmingDuration); + } + + [TestMethod] + public void Report_Parse_MaxMinuteDuration() + { + // CC=0x2D, Cmd=0x03, GroupId=0xFF, SceneId=0xFF, DimmingDuration=0xFE (127 minutes) + byte[] data = [0x2D, 0x03, 0xFF, 0xFF, 0xFE]; + CommandClassFrame frame = new(data); + + SceneControllerConfigurationReport report = + SceneControllerConfigurationCommandClass.SceneControllerConfigurationReportCommand.Parse( + frame, NullLogger.Instance); + + Assert.AreEqual((byte)0xFF, report.GroupId); + Assert.AreEqual((byte)0xFF, report.SceneId); + Assert.AreEqual(TimeSpan.FromMinutes(127), report.DimmingDuration); + } + + [TestMethod] + public void Report_Parse_TooShort_Throws() + { + // CC=0x2D, Cmd=0x03, only 2 parameter bytes (need 3) + byte[] data = [0x2D, 0x03, 0x02, 0x05]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => SceneControllerConfigurationCommandClass.SceneControllerConfigurationReportCommand.Parse( + frame, NullLogger.Instance)); + } + + [TestMethod] + public void SetCommand_Create_RoundTrips() + { + SceneControllerConfigurationCommandClass.SceneControllerConfigurationSetCommand command = + SceneControllerConfigurationCommandClass.SceneControllerConfigurationSetCommand.Create( + groupId: 0x02, sceneId: 0x0A, dimmingDuration: TimeSpan.FromSeconds(5)); + + // Verify the bytes can be read back + Assert.AreEqual((byte)0x02, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual((byte)0x0A, command.Frame.CommandParameters.Span[1]); + Assert.AreEqual((byte)0x05, command.Frame.CommandParameters.Span[2]); + } +} diff --git a/src/ZWave.CommandClasses/CentralSceneCommandClass.Configuration.cs b/src/ZWave.CommandClasses/CentralSceneCommandClass.Configuration.cs new file mode 100644 index 0000000..8e0f592 --- /dev/null +++ b/src/ZWave.CommandClasses/CentralSceneCommandClass.Configuration.cs @@ -0,0 +1,135 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a Central Scene Configuration Report received from a device. +/// +public readonly record struct CentralSceneConfigurationReport( + /// + /// Whether the Slow Refresh capability is enabled. + /// When , the device sends Key Held Down refreshes every 55 seconds instead of 200ms. + /// + bool SlowRefresh); + +public sealed partial class CentralSceneCommandClass +{ + /// + /// Gets the last configuration report received from the device. + /// + public CentralSceneConfigurationReport? LastConfiguration { get; private set; } + + /// + /// Event raised when a Central Scene Configuration Report is received, both solicited and unsolicited. + /// + public event Action? OnConfigurationReportReceived; + + /// + /// Request the configuration of optional node capabilities for scene notifications. + /// + /// The cancellation token. + /// The configuration report. + public async Task GetConfigurationAsync(CancellationToken cancellationToken) + { + CentralSceneConfigurationGetCommand command = CentralSceneConfigurationGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + CentralSceneConfigurationReport report = CentralSceneConfigurationReportCommand.Parse(reportFrame, Logger); + LastConfiguration = report; + OnConfigurationReportReceived?.Invoke(report); + return report; + } + + /// + /// Configure the use of optional node capabilities for scene notifications. + /// + /// + /// to enable Slow Refresh (refreshes every 55 seconds). + /// to disable Slow Refresh (refreshes every 200ms). + /// + /// The cancellation token. + public async Task SetConfigurationAsync(bool slowRefresh, CancellationToken cancellationToken) + { + var command = CentralSceneConfigurationSetCommand.Create(slowRefresh); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + internal readonly struct CentralSceneConfigurationSetCommand : ICommand + { + public CentralSceneConfigurationSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.CentralScene; + + public static byte CommandId => (byte)CentralSceneCommand.ConfigurationSet; + + public CommandClassFrame Frame { get; } + + public static CentralSceneConfigurationSetCommand Create(bool slowRefresh) + { + // Configuration Set: Properties1 (1 byte) + // Bit 7: Slow Refresh + // Bits 6-0: Reserved (set to 0) + ReadOnlySpan commandParameters = [(byte)(slowRefresh ? 0b1000_0000 : 0)]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new CentralSceneConfigurationSetCommand(frame); + } + } + + internal readonly struct CentralSceneConfigurationGetCommand : ICommand + { + public CentralSceneConfigurationGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.CentralScene; + + public static byte CommandId => (byte)CentralSceneCommand.ConfigurationGet; + + public CommandClassFrame Frame { get; } + + public static CentralSceneConfigurationGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new CentralSceneConfigurationGetCommand(frame); + } + } + + internal readonly struct CentralSceneConfigurationReportCommand : ICommand + { + public CentralSceneConfigurationReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.CentralScene; + + public static byte CommandId => (byte)CentralSceneCommand.ConfigurationReport; + + public CommandClassFrame Frame { get; } + + public static CentralSceneConfigurationReport Parse(CommandClassFrame frame, ILogger logger) + { + // Configuration Report: Properties1 (1 byte) + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning( + "Central Scene Configuration Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Central Scene Configuration Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + + // Properties1: Bit 7 = Slow Refresh, Bits 6-0 = Reserved + bool slowRefresh = (span[0] & 0b1000_0000) != 0; + + return new CentralSceneConfigurationReport(slowRefresh); + } + } +} diff --git a/src/ZWave.CommandClasses/CentralSceneCommandClass.Notification.cs b/src/ZWave.CommandClasses/CentralSceneCommandClass.Notification.cs new file mode 100644 index 0000000..1afe28f --- /dev/null +++ b/src/ZWave.CommandClasses/CentralSceneCommandClass.Notification.cs @@ -0,0 +1,90 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a Central Scene Notification received from a device. +/// +public readonly record struct CentralSceneNotification( + /// + /// The sequence number for duplicate detection. Incremented each time a notification is issued. + /// + byte SequenceNumber, + + /// + /// The key attribute specifying the state of the key (e.g. pressed, released, held down, multi-tap). + /// + CentralSceneKeyAttribute KeyAttribute, + + /// + /// The scene number that was activated. + /// + byte SceneNumber, + + /// + /// Whether the Slow Refresh capability is active for this notification (version 3). + /// Only meaningful when is . + /// For version 1–2 devices, this will always be since the bit is reserved. + /// + bool SlowRefresh); + +public sealed partial class CentralSceneCommandClass +{ + /// + /// Gets the last notification received from the device. + /// + public CentralSceneNotification? LastNotification { get; private set; } + + /// + /// Event raised when a Central Scene Notification is received. + /// + public event Action? OnNotificationReceived; + + internal readonly struct CentralSceneNotificationCommand : ICommand + { + public CentralSceneNotificationCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.CentralScene; + + public static byte CommandId => (byte)CentralSceneCommand.Notification; + + public CommandClassFrame Frame { get; } + + public static CentralSceneNotification Parse(CommandClassFrame frame, ILogger logger) + { + // Notification: Sequence Number (1) + Properties1 (1) + Scene Number (1) = 3 bytes + if (frame.CommandParameters.Length < 3) + { + logger.LogWarning( + "Central Scene Notification frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Central Scene Notification frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + byte sequenceNumber = span[0]; + + // Properties1 byte layout (V3): + // Bit 7: Slow Refresh (V3, reserved in V1-V2) + // Bits 6-3: Reserved + // Bits 2-0: Key Attributes + // Per forward-compatibility rules, we do not mask reserved bits. + CentralSceneKeyAttribute keyAttribute = (CentralSceneKeyAttribute)(span[1] & 0b0000_0111); + + // Slow Refresh is bit 7. Per spec: "A receiving node MUST ignore this field if the + // command is not carrying the Key Held Down key attribute." + // We parse it unconditionally for forward-compatibility. V1/V2 devices will always + // send 0 for this reserved bit, so SlowRefresh will be false for older devices. + bool slowRefresh = (span[1] & 0b1000_0000) != 0; + + byte sceneNumber = span[2]; + + return new CentralSceneNotification(sequenceNumber, keyAttribute, sceneNumber, slowRefresh); + } + } +} diff --git a/src/ZWave.CommandClasses/CentralSceneCommandClass.Supported.cs b/src/ZWave.CommandClasses/CentralSceneCommandClass.Supported.cs new file mode 100644 index 0000000..6baaf6c --- /dev/null +++ b/src/ZWave.CommandClasses/CentralSceneCommandClass.Supported.cs @@ -0,0 +1,159 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a Central Scene Supported Report received from a device. +/// +public readonly record struct CentralSceneSupportedReport( + /// + /// The maximum number of scenes supported by the device. + /// Scenes are numbered in the range 1 to . + /// + byte SupportedScenes, + + /// + /// Whether the device supports the Slow Refresh capability (version 3). + /// if the payload does not include this field (version 1). + /// + bool? SlowRefreshSupport, + + /// + /// Whether all scenes support the same key attributes. + /// When , contains a single entry + /// that applies to all scenes. for version 1 payloads. + /// + bool? Identical, + + /// + /// The supported key attributes for each scene. + /// For version 1, this is . + /// For version 2+, when is , this contains one entry + /// that applies to all scenes. Otherwise, it contains one entry per scene. + /// + IReadOnlyList>? SupportedKeyAttributesPerScene); + +public sealed partial class CentralSceneCommandClass +{ + /// + /// Gets the supported scenes report received from the device. + /// + public CentralSceneSupportedReport? SupportedReport { get; private set; } + + /// + /// Request the supported scenes and key attributes from the device. + /// + /// The cancellation token. + /// The supported scenes report. + public async Task GetSupportedAsync(CancellationToken cancellationToken) + { + CentralSceneSupportedGetCommand command = CentralSceneSupportedGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + CentralSceneSupportedReport report = CentralSceneSupportedReportCommand.Parse(reportFrame, Logger); + SupportedReport = report; + return report; + } + + internal readonly struct CentralSceneSupportedGetCommand : ICommand + { + public CentralSceneSupportedGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.CentralScene; + + public static byte CommandId => (byte)CentralSceneCommand.SupportedGet; + + public CommandClassFrame Frame { get; } + + public static CentralSceneSupportedGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new CentralSceneSupportedGetCommand(frame); + } + } + + internal readonly struct CentralSceneSupportedReportCommand : ICommand + { + public CentralSceneSupportedReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.CentralScene; + + public static byte CommandId => (byte)CentralSceneCommand.SupportedReport; + + public CommandClassFrame Frame { get; } + + public static CentralSceneSupportedReport Parse(CommandClassFrame frame, ILogger logger) + { + // Minimum: Supported Scenes (1 byte) + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning( + "Central Scene Supported Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Central Scene Supported Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + byte supportedScenes = span[0]; + + // Version 1: Only Supported Scenes, no bitmask data + if (span.Length < 2) + { + return new CentralSceneSupportedReport( + supportedScenes, + SlowRefreshSupport: null, + Identical: null, + SupportedKeyAttributesPerScene: null); + } + + // Version 2+: Properties1 byte layout: + // Bit 7: Slow Refresh Support (V3, reserved in V2) + // Bits 6-3: Reserved + // Bits 2-1: Number of Bit Mask Bytes + // Bit 0: Identical + byte properties1 = span[1]; + + // Per forward-compatibility: do not mask reserved bits for Slow Refresh Support + bool slowRefreshSupport = (properties1 & 0b1000_0000) != 0; + int numberOfBitMaskBytes = (properties1 >> 1) & 0b0000_0011; + bool identical = (properties1 & 0b0000_0001) != 0; + + // Parse supported key attributes bitmasks + int sceneBitmaskCount = identical ? 1 : supportedScenes; + int expectedBitmaskDataLength = sceneBitmaskCount * numberOfBitMaskBytes; + + if (span.Length < 2 + expectedBitmaskDataLength) + { + logger.LogWarning( + "Central Scene Supported Report frame bitmask data is too short (expected {Expected}, got {Actual} bytes)", + expectedBitmaskDataLength, + span.Length - 2); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Central Scene Supported Report frame bitmask data is too short"); + } + + List> keyAttributesPerScene = new(sceneBitmaskCount); + for (int sceneIndex = 0; sceneIndex < sceneBitmaskCount; sceneIndex++) + { + int offset = 2 + (sceneIndex * numberOfBitMaskBytes); + ReadOnlySpan bitMask = span.Slice(offset, numberOfBitMaskBytes); + keyAttributesPerScene.Add(BitMaskHelper.ParseBitMask(bitMask)); + } + + return new CentralSceneSupportedReport( + supportedScenes, + slowRefreshSupport, + identical, + keyAttributesPerScene); + } + } +} diff --git a/src/ZWave.CommandClasses/CentralSceneCommandClass.cs b/src/ZWave.CommandClasses/CentralSceneCommandClass.cs new file mode 100644 index 0000000..a986dd9 --- /dev/null +++ b/src/ZWave.CommandClasses/CentralSceneCommandClass.cs @@ -0,0 +1,158 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Key attribute values for the Central Scene Notification. +/// +public enum CentralSceneKeyAttribute : byte +{ + /// + /// Key Pressed 1 time. + /// + KeyPressed = 0x00, + + /// + /// Key Released. + /// + KeyReleased = 0x01, + + /// + /// Key Held Down. + /// + KeyHeldDown = 0x02, + + /// + /// Key Pressed 2 times. + /// + KeyPressed2Times = 0x03, + + /// + /// Key Pressed 3 times. + /// + KeyPressed3Times = 0x04, + + /// + /// Key Pressed 4 times. + /// + KeyPressed4Times = 0x05, + + /// + /// Key Pressed 5 times. + /// + KeyPressed5Times = 0x06, +} + +/// +/// Commands for the Central Scene Command Class. +/// +public enum CentralSceneCommand : byte +{ + /// + /// Request the supported scenes and key attributes. + /// + SupportedGet = 0x01, + + /// + /// Advertise the supported scenes and key attributes. + /// + SupportedReport = 0x02, + + /// + /// Advertise a scene activation event. + /// + Notification = 0x03, + + /// + /// Configure optional node capabilities for scene notifications (version 3). + /// + ConfigurationSet = 0x04, + + /// + /// Request the configuration of optional node capabilities (version 3). + /// + ConfigurationGet = 0x05, + + /// + /// Advertise the configuration of optional node capabilities (version 3). + /// + ConfigurationReport = 0x06, +} + +/// +/// Implementation of the Central Scene Command Class (versions 1–3). +/// +/// +/// The Central Scene Command Class is used to communicate central scene activations +/// to a central controller using the lifeline concept. A scene is typically activated via +/// a push button on the device. +/// Version 2 extends version 1 by adding per-scene key attribute bitmasks and additional +/// key attributes (multi-tap). +/// Version 3 adds the Slow Refresh capability and Configuration commands. +/// +[CommandClass(CommandClassId.CentralScene)] +public sealed partial class CentralSceneCommandClass : CommandClass +{ + private byte? _lastNotificationSequenceNumber; + + internal CentralSceneCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + public override bool? IsCommandSupported(CentralSceneCommand command) + => command switch + { + CentralSceneCommand.SupportedGet => true, + CentralSceneCommand.ConfigurationSet => Version.HasValue ? Version >= 3 : null, + CentralSceneCommand.ConfigurationGet => Version.HasValue ? Version >= 3 : null, + _ => false, + }; + + /// + internal override async Task InterviewAsync(CancellationToken cancellationToken) + { + _ = await GetSupportedAsync(cancellationToken).ConfigureAwait(false); + + if (IsCommandSupported(CentralSceneCommand.ConfigurationGet).GetValueOrDefault()) + { + _ = await GetConfigurationAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((CentralSceneCommand)frame.CommandId) + { + case CentralSceneCommand.Notification: + { + CentralSceneNotification notification = CentralSceneNotificationCommand.Parse(frame, Logger); + + // Per spec: "The receiving device uses the sequence number to ignore duplicates." + if (_lastNotificationSequenceNumber.HasValue + && notification.SequenceNumber == _lastNotificationSequenceNumber.Value) + { + return; + } + + _lastNotificationSequenceNumber = notification.SequenceNumber; + LastNotification = notification; + OnNotificationReceived?.Invoke(notification); + break; + } + case CentralSceneCommand.ConfigurationReport: + { + CentralSceneConfigurationReport report = CentralSceneConfigurationReportCommand.Parse(frame, Logger); + LastConfiguration = report; + OnConfigurationReportReceived?.Invoke(report); + break; + } + } + } +} diff --git a/src/ZWave.CommandClasses/DurationEncoding.cs b/src/ZWave.CommandClasses/DurationEncoding.cs new file mode 100644 index 0000000..8ca5371 --- /dev/null +++ b/src/ZWave.CommandClasses/DurationEncoding.cs @@ -0,0 +1,63 @@ +namespace ZWave.CommandClasses; + +/// +/// Shared encoding/decoding logic for Z-Wave duration fields. +/// +/// +/// Multiple Z-Wave command classes use a common seconds/minutes encoding scheme: +/// +/// 0x00: Instantly () +/// 0x010x7F: 1 to 127 seconds +/// 0x80maxMinuteByte: 1 to N minutes +/// +/// The upper bound of the minutes range varies by command class (e.g. 0xFD for generic Duration, 0xFE for Scene dimming duration). +/// Values above the minutes range have command-class-specific meanings (Unknown, Factory Default, etc.). +/// +internal static class DurationEncoding +{ + /// + /// Decodes a duration byte using the common seconds/minutes encoding. + /// + /// The raw duration byte. + /// + /// The highest byte value that encodes minutes. + /// Use 0xFD for generic Duration (Table 8), 0xFE for Scene dimming duration (Tables 2.496/2.497). + /// + /// The decoded duration, or for values above . + internal static TimeSpan? Decode(byte value, byte maxMinuteByte) + { + return value switch + { + 0 => TimeSpan.Zero, + >= 0x01 and <= 0x7F => TimeSpan.FromSeconds(value), + _ when value >= 0x80 && value <= maxMinuteByte => TimeSpan.FromMinutes(value - 0x7F), + _ => null, + }; + } + + /// + /// Encodes a as a duration byte using the common seconds/minutes encoding. + /// + /// The duration to encode. Must be between and 127 minutes. + /// The encoded duration byte. + /// Thrown when exceeds 127 minutes. + internal static byte Encode(TimeSpan duration) + { + if (duration == TimeSpan.Zero) + { + return 0; + } + + if (duration <= TimeSpan.FromSeconds(127)) + { + return (byte)Math.Round(duration.TotalSeconds); + } + + if (duration <= TimeSpan.FromMinutes(127)) + { + return (byte)(Math.Round(duration.TotalMinutes) + 0x7F); + } + + throw new ArgumentException("Value must be less than or equal to 127 minutes", nameof(duration)); + } +} diff --git a/src/ZWave.CommandClasses/DurationReport.cs b/src/ZWave.CommandClasses/DurationReport.cs index c69beaf..b28a94c 100644 --- a/src/ZWave.CommandClasses/DurationReport.cs +++ b/src/ZWave.CommandClasses/DurationReport.cs @@ -21,24 +21,7 @@ public DurationReport(byte value) /// /// Gets the interpreted duration, or null if unknown. /// - public TimeSpan? Duration => - Value switch - { - // 0 seconds. Already at the Target Value. - 0 => TimeSpan.Zero, - - // 1 second (0x01) to 127 seconds (0x7F) in 1 second resolution. - >= 0x01 and <= 0x7f => TimeSpan.FromSeconds(Value), - - // 1 minute (0x80) to 126 minutes (0xFD) in 1 minute resolution. - >= 0x80 and <= 0xfd => TimeSpan.FromMinutes(Value - 0x7f), - - // Unknown duration - 0xfe => null, - - // Reserved. Treat the same as unknown? - 0xff => null, - }; + public TimeSpan? Duration => DurationEncoding.Decode(Value, maxMinuteByte: 0xFD); public static implicit operator DurationReport(byte b) => new DurationReport(b); } diff --git a/src/ZWave.CommandClasses/DurationSet.cs b/src/ZWave.CommandClasses/DurationSet.cs index 871f3d2..18fa363 100644 --- a/src/ZWave.CommandClasses/DurationSet.cs +++ b/src/ZWave.CommandClasses/DurationSet.cs @@ -15,28 +15,7 @@ public DurationSet(byte value) public DurationSet(TimeSpan duration) { - // Instantly - if (duration == TimeSpan.Zero) - { - Value = 0; - } - - // 1 second (0x01) to 127 seconds (0x7F) in 1 second resolution. - else if (duration <= TimeSpan.FromSeconds(127)) - { - Value = (byte)Math.Round(duration.TotalSeconds); - } - - // 1 minute (0x80) to 127 minutes (0xFE) in 1 minute resolution. - else if (duration <= TimeSpan.FromMinutes(127)) - { - Value = (byte)(Math.Round(duration.TotalMinutes) + 0x7f); - } - - else - { - throw new ArgumentException("Value must be less or equal to 127 minutes", nameof(duration)); - } + Value = DurationEncoding.Encode(duration); } /// diff --git a/src/ZWave.CommandClasses/SceneActivationCommandClass.cs b/src/ZWave.CommandClasses/SceneActivationCommandClass.cs new file mode 100644 index 0000000..5d1a2c8 --- /dev/null +++ b/src/ZWave.CommandClasses/SceneActivationCommandClass.cs @@ -0,0 +1,150 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Commands for the Scene Activation Command Class. +/// +public enum SceneActivationCommand : byte +{ + /// + /// Activate the setting associated with a scene ID. + /// + Set = 0x01, +} + +/// +/// Represents a Scene Activation Set received from a device. +/// +/// +/// A dimming duration indicates that the device should use the duration +/// configured by Scene Actuator Configuration Set / Scene Controller Configuration Set (wire value 0xFF). +/// +public readonly record struct SceneActivation( + /// + /// The scene ID to activate. Valid range is 1–255. + /// + byte SceneId, + + /// + /// The dimming duration for the transition to the target level. + /// means the device should use the previously configured duration. + /// + TimeSpan? DimmingDuration); + +/// +/// Implementation of the Scene Activation Command Class (version 1). +/// +/// +/// The Scene Activation Command Class is used for launching scenes in a number of actuator nodes. +/// A node supporting this command class MUST also support the Scene Actuator Configuration Command Class. +/// +[CommandClass(CommandClassId.SceneActivation)] +public sealed class SceneActivationCommandClass : CommandClass +{ + internal SceneActivationCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + /// Gets the last scene activation received from the device. + /// + public SceneActivation? LastActivation { get; private set; } + + /// + /// Event raised when a Scene Activation Set is received, both solicited and unsolicited. + /// + public event Action? OnActivationReceived; + + /// + public override bool? IsCommandSupported(SceneActivationCommand command) + => command switch + { + SceneActivationCommand.Set => true, + _ => false, + }; + + /// + internal override Task InterviewAsync(CancellationToken cancellationToken) + { + // Scene Activation CC has no Get command, so there is nothing to query during interview. + return Task.CompletedTask; + } + + /// + /// Activate the specified scene on supporting devices. + /// + /// The scene ID to activate. Must be in the range 1–255. + /// + /// The dimming duration for the transition. + /// Use to use the duration configured by Scene Actuator Configuration Set. + /// + /// The cancellation token. + public async Task SetAsync(byte sceneId, TimeSpan? dimmingDuration, CancellationToken cancellationToken) + { + var command = SceneActivationSetCommand.Create(sceneId, dimmingDuration); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((SceneActivationCommand)frame.CommandId) + { + case SceneActivationCommand.Set: + { + SceneActivation activation = SceneActivationSetCommand.Parse(frame, Logger); + LastActivation = activation; + OnActivationReceived?.Invoke(activation); + break; + } + } + } + + internal readonly struct SceneActivationSetCommand : ICommand + { + public SceneActivationSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.SceneActivation; + + public static byte CommandId => (byte)SceneActivationCommand.Set; + + public CommandClassFrame Frame { get; } + + public static SceneActivationSetCommand Create(byte sceneId, TimeSpan? dimmingDuration) + { + byte durationByte = dimmingDuration.HasValue + ? DurationEncoding.Encode(dimmingDuration.Value) + : (byte)0xFF; + ReadOnlySpan commandParameters = [sceneId, durationByte]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new SceneActivationSetCommand(frame); + } + + public static SceneActivation Parse(CommandClassFrame frame, ILogger logger) + { + // Scene Activation Set: Scene ID (1 byte) + Dimming Duration (1 byte) + if (frame.CommandParameters.Length < 2) + { + logger.LogWarning("Scene Activation Set frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Scene Activation Set frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + byte sceneId = span[0]; + + // 0xFF = use configured duration (null), 0x00-0xFE decoded by DurationEncoding + TimeSpan? dimmingDuration = DurationEncoding.Decode(span[1], maxMinuteByte: 0xFE); + + return new SceneActivation(sceneId, dimmingDuration); + } + } +} diff --git a/src/ZWave.CommandClasses/SceneActuatorConfigurationCommandClass.cs b/src/ZWave.CommandClasses/SceneActuatorConfigurationCommandClass.cs new file mode 100644 index 0000000..7da58d7 --- /dev/null +++ b/src/ZWave.CommandClasses/SceneActuatorConfigurationCommandClass.cs @@ -0,0 +1,238 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Commands for the Scene Actuator Configuration Command Class. +/// +public enum SceneActuatorConfigurationCommand : byte +{ + /// + /// Associate a scene ID with actuator settings. + /// + Set = 0x01, + + /// + /// Request the settings for a given scene ID. + /// + Get = 0x02, + + /// + /// Advertise the settings associated with a scene ID. + /// + Report = 0x03, +} + +/// +/// Represents a Scene Actuator Configuration Report received from a device. +/// +/// +/// A scene ID of 0 indicates that no scene is currently active at the sending node +/// and the Level and Dimming Duration fields should be ignored. +/// +public readonly record struct SceneActuatorConfigurationReport( + /// + /// The scene ID for which settings are being advertised. + /// Values 1–255 indicate an actual scene ID. + /// The value 0 indicates no scene is currently active. + /// + byte SceneId, + + /// + /// The actuator setting (level) associated with this scene. + /// Corresponds to the Value field of the Basic Set Command. + /// + byte Level, + + /// + /// The dimming duration for the transition to the target level. + /// + TimeSpan DimmingDuration); + +/// +/// Implementation of the Scene Actuator Configuration Command Class (version 1). +/// +/// +/// The Scene Actuator Configuration Command Class is used to configure scene settings +/// for a node supporting an actuator Command Class (e.g. Multilevel Switch, Binary Switch). +/// A node supporting this command class MUST support 255 Scene IDs (1 to 255). +/// +[CommandClass(CommandClassId.SceneActuatorConfiguration)] +public sealed class SceneActuatorConfigurationCommandClass : CommandClass +{ + internal SceneActuatorConfigurationCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + /// Gets the last report received from the device. + /// + public SceneActuatorConfigurationReport? LastReport { get; private set; } + + /// + /// Event raised when a Scene Actuator Configuration Report is received, both solicited and unsolicited. + /// + public event Action? OnReportReceived; + + /// + public override bool? IsCommandSupported(SceneActuatorConfigurationCommand command) + => command switch + { + SceneActuatorConfigurationCommand.Set => true, + SceneActuatorConfigurationCommand.Get => true, + _ => false, + }; + + /// + internal override Task InterviewAsync(CancellationToken cancellationToken) + { + // The device supports 255 scenes; querying all of them during interview is impractical. + // Scene configuration is queried on demand via GetAsync. + return Task.CompletedTask; + } + + /// + /// Request the settings for a given scene ID. + /// + /// + /// The scene ID to query. Values 1–255 request a specific scene. + /// The value 0 requests the currently active scene (if any). + /// + /// The cancellation token. + /// The scene actuator configuration report. + public async Task GetAsync(byte sceneId, CancellationToken cancellationToken) + { + var command = SceneActuatorConfigurationGetCommand.Create(sceneId); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + SceneActuatorConfigurationReport report = SceneActuatorConfigurationReportCommand.Parse(reportFrame, Logger); + LastReport = report; + OnReportReceived?.Invoke(report); + return report; + } + + /// + /// Associate a scene ID with actuator settings. + /// + /// The scene ID to configure. Must be in the range 1–255. + /// The dimming duration for the transition to the target level. + /// + /// If , the value is used for the scene. + /// If , the current actuator settings are captured for the scene + /// and the value is ignored. + /// + /// + /// The actuator level to associate with the scene. Only used when is . + /// Corresponds to the Value field of the Basic Set Command. + /// + /// The cancellation token. + public async Task SetAsync(byte sceneId, TimeSpan dimmingDuration, bool overrideLevel, byte level, CancellationToken cancellationToken) + { + var command = SceneActuatorConfigurationSetCommand.Create(sceneId, dimmingDuration, overrideLevel, level); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((SceneActuatorConfigurationCommand)frame.CommandId) + { + case SceneActuatorConfigurationCommand.Report: + { + SceneActuatorConfigurationReport report = SceneActuatorConfigurationReportCommand.Parse(frame, Logger); + LastReport = report; + OnReportReceived?.Invoke(report); + break; + } + } + } + + internal readonly struct SceneActuatorConfigurationSetCommand : ICommand + { + public SceneActuatorConfigurationSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.SceneActuatorConfiguration; + + public static byte CommandId => (byte)SceneActuatorConfigurationCommand.Set; + + public CommandClassFrame Frame { get; } + + public static SceneActuatorConfigurationSetCommand Create(byte sceneId, TimeSpan dimmingDuration, bool overrideLevel, byte level) + { + // Set: Scene ID (1) + Dimming Duration (1) + Override|Reserved (1) + Level (1) = 4 bytes + Span commandParameters = + [ + sceneId, + DurationEncoding.Encode(dimmingDuration), + (byte)(overrideLevel ? 0b1000_0000 : 0), + level, + ]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new SceneActuatorConfigurationSetCommand(frame); + } + } + + internal readonly struct SceneActuatorConfigurationGetCommand : ICommand + { + public SceneActuatorConfigurationGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.SceneActuatorConfiguration; + + public static byte CommandId => (byte)SceneActuatorConfigurationCommand.Get; + + public CommandClassFrame Frame { get; } + + public static SceneActuatorConfigurationGetCommand Create(byte sceneId) + { + ReadOnlySpan commandParameters = [sceneId]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new SceneActuatorConfigurationGetCommand(frame); + } + } + + internal readonly struct SceneActuatorConfigurationReportCommand : ICommand + { + public SceneActuatorConfigurationReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.SceneActuatorConfiguration; + + public static byte CommandId => (byte)SceneActuatorConfigurationCommand.Report; + + public CommandClassFrame Frame { get; } + + public static SceneActuatorConfigurationReport Parse(CommandClassFrame frame, ILogger logger) + { + // Report: Scene ID (1) + Level (1) + Dimming Duration (1) = 3 bytes + if (frame.CommandParameters.Length < 3) + { + logger.LogWarning( + "Scene Actuator Configuration Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Scene Actuator Configuration Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + byte sceneId = span[0]; + byte level = span[1]; + TimeSpan dimmingDuration = DurationEncoding.Decode(span[2], maxMinuteByte: 0xFE) ?? TimeSpan.Zero; + + return new SceneActuatorConfigurationReport(sceneId, level, dimmingDuration); + } + } +} diff --git a/src/ZWave.CommandClasses/SceneControllerConfigurationCommandClass.cs b/src/ZWave.CommandClasses/SceneControllerConfigurationCommandClass.cs new file mode 100644 index 0000000..e29ce1f --- /dev/null +++ b/src/ZWave.CommandClasses/SceneControllerConfigurationCommandClass.cs @@ -0,0 +1,232 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Commands for the Scene Controller Configuration Command Class. +/// +public enum SceneControllerConfigurationCommand : byte +{ + /// + /// Configure the scene settings for an association group. + /// + Set = 0x01, + + /// + /// Request the scene settings for an association group. + /// + Get = 0x02, + + /// + /// Advertise the current scene controller settings. + /// + Report = 0x03, +} + +/// +/// Represents a Scene Controller Configuration Report received from a device. +/// +/// +/// A scene ID of 0 indicates that the scene is disabled for the specified group. +/// +public readonly record struct SceneControllerConfigurationReport( + /// + /// The association group ID for which settings are being advertised. + /// + byte GroupId, + + /// + /// The scene ID associated with the group. + /// Values 1–255 indicate an actual scene ID. + /// The value 0 indicates the group/scene is disabled. + /// + byte SceneId, + + /// + /// The dimming duration associated with the group. + /// + TimeSpan DimmingDuration); + +/// +/// Implementation of the Scene Controller Configuration Command Class (version 1). +/// +/// +/// The Scene Controller Configuration Command Class is used to configure nodes +/// launching scenes using their association groups. +/// A node supporting this command class MUST support 255 scene IDs (1 to 255) +/// and MUST support the Association Command Class. +/// +[CommandClass(CommandClassId.SceneControllerConfiguration)] +public sealed class SceneControllerConfigurationCommandClass : CommandClass +{ + internal SceneControllerConfigurationCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + /// Gets the last report received from the device. + /// + public SceneControllerConfigurationReport? LastReport { get; private set; } + + /// + /// Event raised when a Scene Controller Configuration Report is received, both solicited and unsolicited. + /// + public event Action? OnReportReceived; + + /// + public override bool? IsCommandSupported(SceneControllerConfigurationCommand command) + => command switch + { + SceneControllerConfigurationCommand.Set => true, + SceneControllerConfigurationCommand.Get => true, + _ => false, + }; + + /// + internal override Task InterviewAsync(CancellationToken cancellationToken) + { + // The number of groups depends on the Association CC; querying all groups during interview + // is impractical. Scene controller configuration is queried on demand via GetAsync. + return Task.CompletedTask; + } + + /// + /// Request the scene settings for a given association group. + /// + /// + /// The association group ID to query. Values 1–255 request a specific group. + /// The value 0 requests the currently active group and scene (last activated). + /// + /// The cancellation token. + /// The scene controller configuration report. + public async Task GetAsync(byte groupId, CancellationToken cancellationToken) + { + var command = SceneControllerConfigurationGetCommand.Create(groupId); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + SceneControllerConfigurationReport report = SceneControllerConfigurationReportCommand.Parse(reportFrame, Logger); + LastReport = report; + OnReportReceived?.Invoke(report); + return report; + } + + /// + /// Configure the scene settings for an association group. + /// + /// + /// The association group ID to configure. + /// Values MUST be a sequence starting from 1. + /// Group ID 1 SHOULD NOT be used as it is reserved for the Lifeline association group. + /// + /// + /// The scene ID to associate with the group. + /// Values 1–255 associate a scene. The value 0 disables the scene for the group. + /// + /// + /// The dimming duration the node should use in the Scene Activation Set command + /// when issuing it via the specified group. + /// + /// The cancellation token. + public async Task SetAsync(byte groupId, byte sceneId, TimeSpan dimmingDuration, CancellationToken cancellationToken) + { + var command = SceneControllerConfigurationSetCommand.Create(groupId, sceneId, dimmingDuration); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((SceneControllerConfigurationCommand)frame.CommandId) + { + case SceneControllerConfigurationCommand.Report: + { + SceneControllerConfigurationReport report = SceneControllerConfigurationReportCommand.Parse(frame, Logger); + LastReport = report; + OnReportReceived?.Invoke(report); + break; + } + } + } + + internal readonly struct SceneControllerConfigurationSetCommand : ICommand + { + public SceneControllerConfigurationSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.SceneControllerConfiguration; + + public static byte CommandId => (byte)SceneControllerConfigurationCommand.Set; + + public CommandClassFrame Frame { get; } + + public static SceneControllerConfigurationSetCommand Create(byte groupId, byte sceneId, TimeSpan dimmingDuration) + { + ReadOnlySpan commandParameters = [groupId, sceneId, DurationEncoding.Encode(dimmingDuration)]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new SceneControllerConfigurationSetCommand(frame); + } + } + + internal readonly struct SceneControllerConfigurationGetCommand : ICommand + { + public SceneControllerConfigurationGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.SceneControllerConfiguration; + + public static byte CommandId => (byte)SceneControllerConfigurationCommand.Get; + + public CommandClassFrame Frame { get; } + + public static SceneControllerConfigurationGetCommand Create(byte groupId) + { + ReadOnlySpan commandParameters = [groupId]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new SceneControllerConfigurationGetCommand(frame); + } + } + + internal readonly struct SceneControllerConfigurationReportCommand : ICommand + { + public SceneControllerConfigurationReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.SceneControllerConfiguration; + + public static byte CommandId => (byte)SceneControllerConfigurationCommand.Report; + + public CommandClassFrame Frame { get; } + + public static SceneControllerConfigurationReport Parse(CommandClassFrame frame, ILogger logger) + { + // Report: Group ID (1) + Scene ID (1) + Dimming Duration (1) = 3 bytes + if (frame.CommandParameters.Length < 3) + { + logger.LogWarning( + "Scene Controller Configuration Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Scene Controller Configuration Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + byte groupId = span[0]; + byte sceneId = span[1]; + TimeSpan dimmingDuration = DurationEncoding.Decode(span[2], maxMinuteByte: 0xFE) ?? TimeSpan.Zero; + + return new SceneControllerConfigurationReport(groupId, sceneId, dimmingDuration); + } + } +}