diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1e9f501..1c7e30b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -117,6 +117,7 @@ Response structs that contain variable-length collections use count + indexer me - Allman-style braces (`csharp_new_line_before_open_brace = all`). - NuGet package versions are centrally managed in `Directory.Packages.props`. When adding a package, add the version there and reference it without a version in the `.csproj`. - `InternalsVisibleTo` is set: `ZWave.Protocol` → `ZWave.Serial`, `ZWave.Serial` → `ZWave.Serial.Tests`, `ZWave.CommandClasses` → `ZWave` and `ZWave.CommandClasses.Tests`. +- **Public method naming** — Use natural English verb phrases for public CC methods, not the spec command names. The spec command names (e.g. `SupportedGet`, `DefaultReset`, `PropertiesGet`) are used for the command **enum values** and **internal command struct names**, but public methods use English word order: `GetSupportedAsync`, `ResetToDefaultAsync`, `GetPropertiesAsync`. Examples: spec `NameGet` → method `GetNameAsync`, spec `EventSupportedGet` → method `GetEventSupportedAsync`, spec `DefaultReset` → method `ResetToDefaultAsync`. - **Binary literals for bitmasks** — prefer `0b` format (e.g. `0b0000_0010`) over `0x` hex when working with bitmask constants, as it makes the specific bit positions immediately clear. ## Testing Patterns diff --git a/src/Shared/BinaryExtensions.cs b/src/Shared/BinaryExtensions.cs index d6488b4..5f068cd 100644 --- a/src/Shared/BinaryExtensions.cs +++ b/src/Shared/BinaryExtensions.cs @@ -56,6 +56,35 @@ public static int ReadSignedVariableSizeBE(this ReadOnlySpan bytes) } } + /// + /// Read an unsigned big-endian integer from a span of 1, 2, or 4 bytes. + /// + public static uint ReadUnsignedVariableSizeBE(this ReadOnlySpan bytes) + { + switch (bytes.Length) + { + case 1: + { + return bytes[0]; + } + case 2: + { + return BinaryPrimitives.ReadUInt16BigEndian(bytes); + } + case 4: + { + return BinaryPrimitives.ReadUInt32BigEndian(bytes); + } + default: + { + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + $"Invalid value size {bytes.Length}. Expected 1, 2, or 4."); + return default; + } + } + } + /// /// Get the minimum number of bytes (1, 2, or 4) needed to represent a signed integer. /// diff --git a/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.Bulk.cs b/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.Bulk.cs new file mode 100644 index 0000000..3434041 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.Bulk.cs @@ -0,0 +1,243 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class ConfigurationCommandClassTests +{ + [TestMethod] + public void BulkSetCommand_Create_HasCorrectFormat() + { + ConfigurationCommandClass.ConfigurationBulkSetCommand command = + ConfigurationCommandClass.ConfigurationBulkSetCommand.Create( + parameterOffset: 10, + size: 2, + values: [100, 200], + restoreDefault: false, + handshake: false); + + Assert.AreEqual(CommandClassId.Configuration, ConfigurationCommandClass.ConfigurationBulkSetCommand.CommandClassId); + Assert.AreEqual((byte)ConfigurationCommand.BulkSet, ConfigurationCommandClass.ConfigurationBulkSetCommand.CommandId); + + ReadOnlySpan span = command.Frame.CommandParameters.Span; + Assert.AreEqual(0x00, span[0]); // Offset MSB + Assert.AreEqual(0x0A, span[1]); // Offset LSB = 10 + Assert.AreEqual(0x02, span[2]); // Number of parameters + Assert.AreEqual(0x02, span[3]); // Flags: size=2, no default, no handshake + // Parameter 1: 100 = 0x0064 + Assert.AreEqual(0x00, span[4]); + Assert.AreEqual(0x64, span[5]); + // Parameter 2: 200 = 0x00C8 + Assert.AreEqual(0x00, span[6]); + Assert.AreEqual(0xC8, span[7]); + } + + [TestMethod] + public void BulkSetCommand_Create_WithDefaultFlag() + { + ConfigurationCommandClass.ConfigurationBulkSetCommand command = + ConfigurationCommandClass.ConfigurationBulkSetCommand.Create( + parameterOffset: 1, + size: 1, + values: [0], + restoreDefault: true, + handshake: false); + + byte flags = command.Frame.CommandParameters.Span[3]; + Assert.AreNotEqual((byte)0, (byte)(flags & 0b1000_0000)); // Default bit set + } + + [TestMethod] + public void BulkSetCommand_Create_WithHandshakeFlag() + { + ConfigurationCommandClass.ConfigurationBulkSetCommand command = + ConfigurationCommandClass.ConfigurationBulkSetCommand.Create( + parameterOffset: 1, + size: 1, + values: [0], + restoreDefault: false, + handshake: true); + + byte flags = command.Frame.CommandParameters.Span[3]; + Assert.AreNotEqual((byte)0, (byte)(flags & 0b0100_0000)); // Handshake bit set + } + + [TestMethod] + public void BulkSetCommand_CreateDefault_HasDefaultFlag() + { + ConfigurationCommandClass.ConfigurationBulkSetCommand command = + ConfigurationCommandClass.ConfigurationBulkSetCommand.CreateDefault(5, 3); + + ReadOnlySpan span = command.Frame.CommandParameters.Span; + Assert.AreEqual(0x00, span[0]); // Offset MSB + Assert.AreEqual(0x05, span[1]); // Offset LSB = 5 + Assert.AreEqual(0x03, span[2]); // Number of parameters = 3 + Assert.AreNotEqual((byte)0, (byte)(span[3] & 0b1000_0000)); // Default bit set + } + + [TestMethod] + public void BulkGetCommand_Create_HasCorrectFormat() + { + ConfigurationCommandClass.ConfigurationBulkGetCommand command = + ConfigurationCommandClass.ConfigurationBulkGetCommand.Create(256, 5); + + Assert.AreEqual(CommandClassId.Configuration, ConfigurationCommandClass.ConfigurationBulkGetCommand.CommandClassId); + Assert.AreEqual((byte)ConfigurationCommand.BulkGet, ConfigurationCommandClass.ConfigurationBulkGetCommand.CommandId); + + ReadOnlySpan span = command.Frame.CommandParameters.Span; + Assert.AreEqual(3, command.Frame.CommandParameters.Length); + Assert.AreEqual(0x01, span[0]); // Offset MSB = 256 >> 8 + Assert.AreEqual(0x00, span[1]); // Offset LSB + Assert.AreEqual(0x05, span[2]); // Number of parameters + } + + [TestMethod] + public void BulkReportCommand_Parse_SingleParameter() + { + byte[] data = + [ + 0x70, 0x09, // CC + Cmd + 0x00, 0x01, // Offset = 1 + 0x01, // Number of parameters = 1 + 0x00, // Reports to follow = 0 + 0x01, // Flags: size=1, no default, no handshake + 0x2A, // Value = 42 + ]; + CommandClassFrame frame = new(data); + + (ConfigurationBulkReport report, byte reportsToFollow) = + ConfigurationCommandClass.ConfigurationBulkReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ushort)1, report.ParameterOffset); + Assert.IsFalse(report.IsDefault); + Assert.IsFalse(report.IsHandshake); + Assert.AreEqual((byte)1, report.Size); + Assert.HasCount(1, report.Values); + Assert.AreEqual(42, report.Values[0]); + Assert.AreEqual((byte)0, reportsToFollow); + } + + [TestMethod] + public void BulkReportCommand_Parse_MultipleParameters() + { + byte[] data = + [ + 0x70, 0x09, // CC + Cmd + 0x00, 0x0A, // Offset = 10 + 0x03, // Number of parameters = 3 + 0x00, // Reports to follow = 0 + 0x02, // Flags: size=2 + 0x00, 0x64, // Param 10 = 100 + 0x00, 0xC8, // Param 11 = 200 + 0x01, 0x2C, // Param 12 = 300 + ]; + CommandClassFrame frame = new(data); + + (ConfigurationBulkReport report, byte reportsToFollow) = + ConfigurationCommandClass.ConfigurationBulkReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ushort)10, report.ParameterOffset); + Assert.AreEqual((byte)2, report.Size); + Assert.HasCount(3, report.Values); + Assert.AreEqual(100, report.Values[0]); + Assert.AreEqual(200, report.Values[1]); + Assert.AreEqual(300, report.Values[2]); + Assert.AreEqual((byte)0, reportsToFollow); + } + + [TestMethod] + public void BulkReportCommand_Parse_WithFlags() + { + byte[] data = + [ + 0x70, 0x09, + 0x00, 0x01, // Offset = 1 + 0x01, // Number of parameters = 1 + 0x02, // Reports to follow = 2 + 0b1100_0001, // Default=1, Handshake=1, Size=1 + 0x00, // Value + ]; + CommandClassFrame frame = new(data); + + (ConfigurationBulkReport report, byte reportsToFollow) = + ConfigurationCommandClass.ConfigurationBulkReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsTrue(report.IsDefault); + Assert.IsTrue(report.IsHandshake); + Assert.AreEqual((byte)2, reportsToFollow); + } + + [TestMethod] + public void BulkReportCommand_Parse_TooShort_Throws() + { + byte[] data = [0x70, 0x09, 0x00, 0x01]; // Only 2 bytes of parameters + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => ConfigurationCommandClass.ConfigurationBulkReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void BulkReportCommand_Parse_TooShortForDeclaredParams_Throws() + { + byte[] data = + [ + 0x70, 0x09, + 0x00, 0x01, // Offset = 1 + 0x03, // Number of parameters = 3 + 0x00, // Reports to follow + 0x04, // Size = 4 (needs 12 bytes of values, but only 4 present) + 0x00, 0x00, 0x00, 0x01, + ]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => ConfigurationCommandClass.ConfigurationBulkReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void BulkReportCommand_Create_HasCorrectFormat() + { + ConfigurationCommandClass.ConfigurationBulkReportCommand command = + ConfigurationCommandClass.ConfigurationBulkReportCommand.Create( + parameterOffset: 5, + reportsToFollow: 1, + isDefault: false, + isHandshake: false, + size: 1, + values: [10, 20]); + + ReadOnlySpan span = command.Frame.CommandParameters.Span; + Assert.AreEqual(0x00, span[0]); // Offset MSB + Assert.AreEqual(0x05, span[1]); // Offset LSB + Assert.AreEqual(0x02, span[2]); // Number of params + Assert.AreEqual(0x01, span[3]); // Reports to follow + Assert.AreEqual(0x01, span[4]); // Size=1 + Assert.AreEqual(10, span[5]); + Assert.AreEqual(20, span[6]); + } + + [TestMethod] + public void BulkReportCommand_RoundTrip() + { + ConfigurationCommandClass.ConfigurationBulkReportCommand command = + ConfigurationCommandClass.ConfigurationBulkReportCommand.Create( + parameterOffset: 100, + reportsToFollow: 0, + isDefault: true, + isHandshake: false, + size: 2, + values: [-1, 32767]); + + (ConfigurationBulkReport report, byte reportsToFollow) = + ConfigurationCommandClass.ConfigurationBulkReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual((ushort)100, report.ParameterOffset); + Assert.IsTrue(report.IsDefault); + Assert.IsFalse(report.IsHandshake); + Assert.AreEqual((byte)2, report.Size); + Assert.HasCount(2, report.Values); + Assert.AreEqual(-1, report.Values[0]); + Assert.AreEqual(32767, report.Values[1]); + Assert.AreEqual((byte)0, reportsToFollow); + } +} diff --git a/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.Info.cs b/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.Info.cs new file mode 100644 index 0000000..1824d19 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.Info.cs @@ -0,0 +1,92 @@ +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class ConfigurationCommandClassTests +{ + [TestMethod] + public void InfoGetCommand_Create_HasCorrectFormat() + { + ConfigurationCommandClass.ConfigurationInfoGetCommand command = + ConfigurationCommandClass.ConfigurationInfoGetCommand.Create(1); + + Assert.AreEqual(CommandClassId.Configuration, ConfigurationCommandClass.ConfigurationInfoGetCommand.CommandClassId); + Assert.AreEqual((byte)ConfigurationCommand.InfoGet, ConfigurationCommandClass.ConfigurationInfoGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.CommandParameters.Length); + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual(0x01, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void InfoReportCommand_ParseInto_SingleReport() + { + byte[] infoBytes = Encoding.UTF8.GetBytes("Controls dimming speed"); + byte[] data = new byte[2 + 3 + infoBytes.Length]; + data[0] = 0x70; + data[1] = 0x0D; + data[2] = 0x00; + data[3] = 0x01; + data[4] = 0x00; // reports to follow = 0 + Array.Copy(infoBytes, 0, data, 5, infoBytes.Length); + CommandClassFrame frame = new(data); + + List result = []; + byte reportsToFollow = ConfigurationCommandClass.ConfigurationInfoReportCommand.ParseInto(frame, result, NullLogger.Instance); + + Assert.AreEqual((byte)0, reportsToFollow); + Assert.AreEqual("Controls dimming speed", Encoding.UTF8.GetString(result.ToArray())); + } + + [TestMethod] + public void InfoReportCommand_ParseInto_EmptyInfo() + { + byte[] data = [0x70, 0x0D, 0x00, 0x01, 0x00]; + CommandClassFrame frame = new(data); + + List result = []; + byte reportsToFollow = ConfigurationCommandClass.ConfigurationInfoReportCommand.ParseInto(frame, result, NullLogger.Instance); + + Assert.AreEqual((byte)0, reportsToFollow); + Assert.IsEmpty(result); + } + + [TestMethod] + public void InfoReportCommand_ParseInto_TooShort_Throws() + { + byte[] data = [0x70, 0x0D, 0x00]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => ConfigurationCommandClass.ConfigurationInfoReportCommand.ParseInto(frame, [], NullLogger.Instance)); + } + + [TestMethod] + public void InfoReportCommand_Create_HasCorrectFormat() + { + byte[] infoBytes = Encoding.UTF8.GetBytes("Info"); + ConfigurationCommandClass.ConfigurationInfoReportCommand command = + ConfigurationCommandClass.ConfigurationInfoReportCommand.Create(5, 0, infoBytes); + + ReadOnlySpan span = command.Frame.CommandParameters.Span; + Assert.AreEqual(0x00, span[0]); + Assert.AreEqual(0x05, span[1]); + Assert.AreEqual(0x00, span[2]); + Assert.AreEqual((byte)'I', span[3]); + } + + [TestMethod] + public void InfoReportCommand_RoundTrip() + { + byte[] infoBytes = Encoding.UTF8.GetBytes("Threshold value for sensor trigger"); + ConfigurationCommandClass.ConfigurationInfoReportCommand command = + ConfigurationCommandClass.ConfigurationInfoReportCommand.Create(200, 0, infoBytes); + + List result = []; + byte reportsToFollow = ConfigurationCommandClass.ConfigurationInfoReportCommand.ParseInto( + command.Frame, result, NullLogger.Instance); + + Assert.AreEqual((byte)0, reportsToFollow); + Assert.AreEqual("Threshold value for sensor trigger", Encoding.UTF8.GetString(result.ToArray())); + } +} diff --git a/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.Name.cs b/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.Name.cs new file mode 100644 index 0000000..9b4b546 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.Name.cs @@ -0,0 +1,131 @@ +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class ConfigurationCommandClassTests +{ + [TestMethod] + public void NameGetCommand_Create_HasCorrectFormat() + { + ConfigurationCommandClass.ConfigurationNameGetCommand command = + ConfigurationCommandClass.ConfigurationNameGetCommand.Create(256); + + Assert.AreEqual(CommandClassId.Configuration, ConfigurationCommandClass.ConfigurationNameGetCommand.CommandClassId); + Assert.AreEqual((byte)ConfigurationCommand.NameGet, ConfigurationCommandClass.ConfigurationNameGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.CommandParameters.Length); + Assert.AreEqual(0x01, command.Frame.CommandParameters.Span[0]); // MSB + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[1]); // LSB + } + + [TestMethod] + public void NameReportCommand_ParseInto_SingleReport() + { + byte[] nameBytes = Encoding.UTF8.GetBytes("Dimming Rate"); + byte[] data = new byte[2 + 3 + nameBytes.Length]; // CC + Cmd + 2(param#) + 1(reports) + name + data[0] = 0x70; + data[1] = 0x0B; + data[2] = 0x00; // param# MSB + data[3] = 0x01; // param# LSB = 1 + data[4] = 0x00; // reports to follow = 0 + Array.Copy(nameBytes, 0, data, 5, nameBytes.Length); + CommandClassFrame frame = new(data); + + List result = []; + byte reportsToFollow = ConfigurationCommandClass.ConfigurationNameReportCommand.ParseInto(frame, result, NullLogger.Instance); + + Assert.AreEqual((byte)0, reportsToFollow); + Assert.AreEqual("Dimming Rate", Encoding.UTF8.GetString(result.ToArray())); + } + + [TestMethod] + public void NameReportCommand_ParseInto_MultipleReports() + { + // First report: "Hello " with 1 report to follow + byte[] part1 = Encoding.UTF8.GetBytes("Hello "); + byte[] data1 = new byte[2 + 3 + part1.Length]; + data1[0] = 0x70; + data1[1] = 0x0B; + data1[2] = 0x00; + data1[3] = 0x01; + data1[4] = 0x01; // 1 report to follow + Array.Copy(part1, 0, data1, 5, part1.Length); + + // Second report: "World" with 0 reports to follow + byte[] part2 = Encoding.UTF8.GetBytes("World"); + byte[] data2 = new byte[2 + 3 + part2.Length]; + data2[0] = 0x70; + data2[1] = 0x0B; + data2[2] = 0x00; + data2[3] = 0x01; + data2[4] = 0x00; // last report + Array.Copy(part2, 0, data2, 5, part2.Length); + + List result = []; + + byte reportsToFollow1 = ConfigurationCommandClass.ConfigurationNameReportCommand.ParseInto( + new CommandClassFrame(data1), result, NullLogger.Instance); + Assert.AreEqual((byte)1, reportsToFollow1); + + byte reportsToFollow2 = ConfigurationCommandClass.ConfigurationNameReportCommand.ParseInto( + new CommandClassFrame(data2), result, NullLogger.Instance); + Assert.AreEqual((byte)0, reportsToFollow2); + + Assert.AreEqual("Hello World", Encoding.UTF8.GetString(result.ToArray())); + } + + [TestMethod] + public void NameReportCommand_ParseInto_EmptyName() + { + byte[] data = [0x70, 0x0B, 0x00, 0x01, 0x00]; // CC + Cmd + param# + reports=0, no name bytes + CommandClassFrame frame = new(data); + + List result = []; + byte reportsToFollow = ConfigurationCommandClass.ConfigurationNameReportCommand.ParseInto(frame, result, NullLogger.Instance); + + Assert.AreEqual((byte)0, reportsToFollow); + Assert.IsEmpty(result); + } + + [TestMethod] + public void NameReportCommand_ParseInto_TooShort_Throws() + { + byte[] data = [0x70, 0x0B, 0x00]; // Only param# MSB, missing LSB and reports + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => ConfigurationCommandClass.ConfigurationNameReportCommand.ParseInto(frame, [], NullLogger.Instance)); + } + + [TestMethod] + public void NameReportCommand_Create_HasCorrectFormat() + { + byte[] nameBytes = Encoding.UTF8.GetBytes("Test"); + ConfigurationCommandClass.ConfigurationNameReportCommand command = + ConfigurationCommandClass.ConfigurationNameReportCommand.Create(42, 0, nameBytes); + + ReadOnlySpan span = command.Frame.CommandParameters.Span; + Assert.AreEqual(0x00, span[0]); // param# MSB + Assert.AreEqual(0x2A, span[1]); // param# LSB = 42 + Assert.AreEqual(0x00, span[2]); // reports to follow + Assert.AreEqual((byte)'T', span[3]); + Assert.AreEqual((byte)'e', span[4]); + Assert.AreEqual((byte)'s', span[5]); + Assert.AreEqual((byte)'t', span[6]); + } + + [TestMethod] + public void NameReportCommand_RoundTrip() + { + byte[] nameBytes = Encoding.UTF8.GetBytes("Sensitivity"); + ConfigurationCommandClass.ConfigurationNameReportCommand command = + ConfigurationCommandClass.ConfigurationNameReportCommand.Create(100, 0, nameBytes); + + List result = []; + byte reportsToFollow = ConfigurationCommandClass.ConfigurationNameReportCommand.ParseInto( + command.Frame, result, NullLogger.Instance); + + Assert.AreEqual((byte)0, reportsToFollow); + Assert.AreEqual("Sensitivity", Encoding.UTF8.GetString(result.ToArray())); + } +} diff --git a/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.Properties.cs b/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.Properties.cs new file mode 100644 index 0000000..e1404e0 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.Properties.cs @@ -0,0 +1,328 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class ConfigurationCommandClassTests +{ + [TestMethod] + public void DefaultResetCommand_Create_HasCorrectFormat() + { + ConfigurationCommandClass.ConfigurationDefaultResetCommand command = + ConfigurationCommandClass.ConfigurationDefaultResetCommand.Create(); + + Assert.AreEqual(CommandClassId.Configuration, ConfigurationCommandClass.ConfigurationDefaultResetCommand.CommandClassId); + Assert.AreEqual((byte)ConfigurationCommand.DefaultReset, ConfigurationCommandClass.ConfigurationDefaultResetCommand.CommandId); + Assert.AreEqual(0, command.Frame.CommandParameters.Length); // No parameters + } + + [TestMethod] + public void PropertiesGetCommand_Create_HasCorrectFormat() + { + ConfigurationCommandClass.ConfigurationPropertiesGetCommand command = + ConfigurationCommandClass.ConfigurationPropertiesGetCommand.Create(0); + + Assert.AreEqual(CommandClassId.Configuration, ConfigurationCommandClass.ConfigurationPropertiesGetCommand.CommandClassId); + Assert.AreEqual((byte)ConfigurationCommand.PropertiesGet, ConfigurationCommandClass.ConfigurationPropertiesGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.CommandParameters.Length); + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void PropertiesGetCommand_Create_HighParameterNumber() + { + ConfigurationCommandClass.ConfigurationPropertiesGetCommand command = + ConfigurationCommandClass.ConfigurationPropertiesGetCommand.Create(0x1234); + + Assert.AreEqual(0x12, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual(0x34, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void PropertiesReportCommand_Parse_V3_Size1_SignedInteger() + { + byte[] data = + [ + 0x70, 0x0F, // CC + Cmd + 0x00, 0x01, // Parameter Number = 1 + 0b0000_0001, // Format=SignedInteger(0), Size=1 + 0x00, // Min = 0 + 0x64, // Max = 100 + 0x32, // Default = 50 + 0x00, 0x02, // Next Parameter = 2 + ]; + CommandClassFrame frame = new(data); + + (ConfigurationParameterProperties props, ushort nextParameterNumber) = + ConfigurationCommandClass.ConfigurationPropertiesReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ushort)1, props.ParameterNumber); + Assert.AreEqual(ConfigurationParameterFormat.SignedInteger, props.Format); + Assert.AreEqual((byte)1, props.Size); + Assert.AreEqual(0L, props.MinValue); + Assert.AreEqual(100L, props.MaxValue); + Assert.AreEqual(50L, props.DefaultValue); + Assert.AreEqual((ushort)2, nextParameterNumber); + Assert.IsNull(props.ReadOnly); + Assert.IsNull(props.AlteringCapabilities); + Assert.IsNull(props.Advanced); + Assert.IsNull(props.NoBulkSupport); + } + + [TestMethod] + public void PropertiesReportCommand_Parse_V3_Size2_UnsignedInteger() + { + byte[] data = + [ + 0x70, 0x0F, + 0x00, 0x05, // Parameter Number = 5 + 0b0000_1010, // Format=UnsignedInteger(1), Size=2 + 0x00, 0x00, // Min = 0 + 0xFF, 0xFF, // Max = 65535 (unsigned) + 0x00, 0x0A, // Default = 10 + 0x00, 0x06, // Next Parameter = 6 + ]; + CommandClassFrame frame = new(data); + + (ConfigurationParameterProperties props, ushort nextParameterNumber) = + ConfigurationCommandClass.ConfigurationPropertiesReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ushort)5, props.ParameterNumber); + Assert.AreEqual(ConfigurationParameterFormat.UnsignedInteger, props.Format); + Assert.AreEqual((byte)2, props.Size); + Assert.AreEqual(0L, props.MinValue); + Assert.AreEqual(65535L, props.MaxValue); + Assert.AreEqual(10L, props.DefaultValue); + Assert.AreEqual((ushort)6, nextParameterNumber); + } + + [TestMethod] + public void PropertiesReportCommand_Parse_V3_Size4_Enumerated() + { + byte[] data = + [ + 0x70, 0x0F, + 0x00, 0x0A, // Parameter Number = 10 + 0b0001_0100, // Format=Enumerated(2), Size=4 + 0x00, 0x00, 0x00, 0x00, // Min = 0 + 0x00, 0x00, 0x00, 0x03, // Max = 3 + 0x00, 0x00, 0x00, 0x01, // Default = 1 + 0x00, 0x00, // Next Parameter = 0 (last) + ]; + CommandClassFrame frame = new(data); + + (ConfigurationParameterProperties props, ushort nextParameterNumber) = + ConfigurationCommandClass.ConfigurationPropertiesReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ushort)10, props.ParameterNumber); + Assert.AreEqual(ConfigurationParameterFormat.Enumerated, props.Format); + Assert.AreEqual((byte)4, props.Size); + Assert.AreEqual(0L, props.MinValue); + Assert.AreEqual(3L, props.MaxValue); + Assert.AreEqual(1L, props.DefaultValue); + Assert.AreEqual((ushort)0, nextParameterNumber); + } + + [TestMethod] + public void PropertiesReportCommand_Parse_V3_Size0_UnassignedParameter() + { + byte[] data = + [ + 0x70, 0x0F, + 0x00, 0x00, // Parameter Number = 0 + 0b0000_0000, // Format=0, Size=0 (unassigned) + 0x00, 0x05, // Next Parameter = 5 + ]; + CommandClassFrame frame = new(data); + + (ConfigurationParameterProperties props, ushort nextParameterNumber) = + ConfigurationCommandClass.ConfigurationPropertiesReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ushort)0, props.ParameterNumber); + Assert.AreEqual((byte)0, props.Size); + Assert.AreEqual(0L, props.MinValue); + Assert.AreEqual(0L, props.MaxValue); + Assert.AreEqual(0L, props.DefaultValue); + Assert.AreEqual((ushort)5, nextParameterNumber); + } + + [TestMethod] + public void PropertiesReportCommand_Parse_V4_WithFlags() + { + byte[] data = + [ + 0x70, 0x0F, + 0x00, 0x01, // Parameter Number = 1 + 0b1100_0001, // AlteringCapabilities=1, ReadOnly=1, Format=SignedInteger(0), Size=1 + 0x00, // Min = 0 + 0x64, // Max = 100 + 0x32, // Default = 50 + 0x00, 0x02, // Next Parameter = 2 + 0b0000_0011, // NoBulkSupport=1, Advanced=1 + ]; + CommandClassFrame frame = new(data); + + (ConfigurationParameterProperties props, ushort _) = + ConfigurationCommandClass.ConfigurationPropertiesReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ushort)1, props.ParameterNumber); + Assert.IsTrue(props.ReadOnly!.Value); + Assert.IsTrue(props.AlteringCapabilities!.Value); + Assert.IsTrue(props.Advanced!.Value); + Assert.IsTrue(props.NoBulkSupport!.Value); + } + + [TestMethod] + public void PropertiesReportCommand_Parse_V4_AllFlagsFalse() + { + byte[] data = + [ + 0x70, 0x0F, + 0x00, 0x01, // Parameter Number = 1 + 0b0000_0001, // AlteringCapabilities=0, ReadOnly=0, Format=SignedInteger(0), Size=1 + 0x00, // Min = 0 + 0x64, // Max = 100 + 0x32, // Default = 50 + 0x00, 0x02, // Next Parameter = 2 + 0b0000_0000, // NoBulkSupport=0, Advanced=0 + ]; + CommandClassFrame frame = new(data); + + (ConfigurationParameterProperties props, ushort _) = + ConfigurationCommandClass.ConfigurationPropertiesReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsFalse(props.ReadOnly!.Value); + Assert.IsFalse(props.AlteringCapabilities!.Value); + Assert.IsFalse(props.Advanced!.Value); + Assert.IsFalse(props.NoBulkSupport!.Value); + } + + [TestMethod] + public void PropertiesReportCommand_Parse_V4_Size0_WithFlags() + { + byte[] data = + [ + 0x70, 0x0F, + 0x00, 0x00, // Parameter Number = 0 + 0b0000_0000, // Size=0, unassigned + 0x00, 0x01, // Next Parameter = 1 + 0b0000_0000, // V4 flags: all false + ]; + CommandClassFrame frame = new(data); + + (ConfigurationParameterProperties props, ushort nextParameterNumber) = + ConfigurationCommandClass.ConfigurationPropertiesReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0, props.Size); + Assert.AreEqual((ushort)1, nextParameterNumber); + Assert.IsFalse(props.ReadOnly!.Value); + Assert.IsFalse(props.Advanced!.Value); + } + + [TestMethod] + public void PropertiesReportCommand_Parse_TooShort_Throws() + { + byte[] data = [0x70, 0x0F, 0x00, 0x01]; // Only param#, missing format/size and next + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => ConfigurationCommandClass.ConfigurationPropertiesReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void PropertiesReportCommand_Parse_TooShortForDeclaredSize_Throws() + { + byte[] data = + [ + 0x70, 0x0F, + 0x00, 0x01, // Parameter Number = 1 + 0b0000_0100, // Size=4, but not enough bytes follow + 0x00, 0x00, // Only 2 bytes, need 3*4 + 2 = 14 + ]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => ConfigurationCommandClass.ConfigurationPropertiesReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void PropertiesReportCommand_Create_V3() + { + ConfigurationCommandClass.ConfigurationPropertiesReportCommand command = + ConfigurationCommandClass.ConfigurationPropertiesReportCommand.Create( + parameterNumber: 1, + format: ConfigurationParameterFormat.SignedInteger, + size: 1, + minValue: 0, + maxValue: 100, + defaultValue: 50, + nextParameterNumber: 2); + + // Verify we can parse what we created + (ConfigurationParameterProperties props, ushort nextParameterNumber) = + ConfigurationCommandClass.ConfigurationPropertiesReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual((ushort)1, props.ParameterNumber); + Assert.AreEqual(ConfigurationParameterFormat.SignedInteger, props.Format); + Assert.AreEqual((byte)1, props.Size); + Assert.AreEqual(0L, props.MinValue); + Assert.AreEqual(100L, props.MaxValue); + Assert.AreEqual(50L, props.DefaultValue); + Assert.AreEqual((ushort)2, nextParameterNumber); + Assert.IsNull(props.ReadOnly); + } + + [TestMethod] + public void PropertiesReportCommand_Create_V4() + { + ConfigurationCommandClass.ConfigurationPropertiesReportCommand command = + ConfigurationCommandClass.ConfigurationPropertiesReportCommand.Create( + parameterNumber: 3, + format: ConfigurationParameterFormat.BitField, + size: 2, + minValue: 0, + maxValue: 0x00FF, + defaultValue: 0x000F, + nextParameterNumber: 0, + readOnly: true, + alteringCapabilities: false, + advanced: true, + noBulkSupport: false); + + (ConfigurationParameterProperties props, ushort nextParameterNumber) = + ConfigurationCommandClass.ConfigurationPropertiesReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual((ushort)3, props.ParameterNumber); + Assert.AreEqual(ConfigurationParameterFormat.BitField, props.Format); + Assert.AreEqual((byte)2, props.Size); + Assert.AreEqual(0L, props.MinValue); + Assert.AreEqual(255L, props.MaxValue); + Assert.AreEqual(15L, props.DefaultValue); + Assert.AreEqual((ushort)0, nextParameterNumber); + Assert.IsTrue(props.ReadOnly!.Value); + Assert.IsFalse(props.AlteringCapabilities!.Value); + Assert.IsTrue(props.Advanced!.Value); + Assert.IsFalse(props.NoBulkSupport!.Value); + } + + [TestMethod] + public void PropertiesReportCommand_RoundTrip_Size0() + { + ConfigurationCommandClass.ConfigurationPropertiesReportCommand command = + ConfigurationCommandClass.ConfigurationPropertiesReportCommand.Create( + parameterNumber: 0, + format: ConfigurationParameterFormat.SignedInteger, + size: 0, + minValue: 0, + maxValue: 0, + defaultValue: 0, + nextParameterNumber: 5); + + (ConfigurationParameterProperties props, ushort nextParameterNumber) = + ConfigurationCommandClass.ConfigurationPropertiesReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual((ushort)0, props.ParameterNumber); + Assert.AreEqual((byte)0, props.Size); + Assert.AreEqual((ushort)5, nextParameterNumber); + } +} diff --git a/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.Report.cs b/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.Report.cs new file mode 100644 index 0000000..cf91b1d --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.Report.cs @@ -0,0 +1,336 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class ConfigurationCommandClassTests +{ + [TestMethod] + public void SetCommand_Create_Size1() + { + ConfigurationCommandClass.ConfigurationSetCommand command = + ConfigurationCommandClass.ConfigurationSetCommand.Create(1, 1, 42); + + Assert.AreEqual(CommandClassId.Configuration, ConfigurationCommandClass.ConfigurationSetCommand.CommandClassId); + Assert.AreEqual((byte)ConfigurationCommand.Set, ConfigurationCommandClass.ConfigurationSetCommand.CommandId); + Assert.AreEqual(3, command.Frame.CommandParameters.Length); // param# + flags + 1 byte value + Assert.AreEqual(0x01, command.Frame.CommandParameters.Span[0]); // param# + Assert.AreEqual(0x01, command.Frame.CommandParameters.Span[1]); // size=1 + Assert.AreEqual(42, command.Frame.CommandParameters.Span[2]); // value + } + + [TestMethod] + public void SetCommand_Create_Size2() + { + ConfigurationCommandClass.ConfigurationSetCommand command = + ConfigurationCommandClass.ConfigurationSetCommand.Create(5, 2, 0x1234); + + Assert.AreEqual(4, command.Frame.CommandParameters.Length); // param# + flags + 2 byte value + Assert.AreEqual(0x05, command.Frame.CommandParameters.Span[0]); // param# + Assert.AreEqual(0x02, command.Frame.CommandParameters.Span[1]); // size=2 + Assert.AreEqual(0x12, command.Frame.CommandParameters.Span[2]); // MSB + Assert.AreEqual(0x34, command.Frame.CommandParameters.Span[3]); // LSB + } + + [TestMethod] + public void SetCommand_Create_Size4() + { + ConfigurationCommandClass.ConfigurationSetCommand command = + ConfigurationCommandClass.ConfigurationSetCommand.Create(10, 4, 0x12345678); + + Assert.AreEqual(6, command.Frame.CommandParameters.Length); // param# + flags + 4 byte value + Assert.AreEqual(0x0A, command.Frame.CommandParameters.Span[0]); // param# + Assert.AreEqual(0x04, command.Frame.CommandParameters.Span[1]); // size=4 + Assert.AreEqual(0x12, command.Frame.CommandParameters.Span[2]); + Assert.AreEqual(0x34, command.Frame.CommandParameters.Span[3]); + Assert.AreEqual(0x56, command.Frame.CommandParameters.Span[4]); + Assert.AreEqual(0x78, command.Frame.CommandParameters.Span[5]); + } + + [TestMethod] + public void SetCommand_CreateDefault_HasDefaultBitSet() + { + ConfigurationCommandClass.ConfigurationSetCommand command = + ConfigurationCommandClass.ConfigurationSetCommand.CreateDefault(7); + + Assert.AreEqual(0x07, command.Frame.CommandParameters.Span[0]); // param# + Assert.AreNotEqual((byte)0, (byte)(command.Frame.CommandParameters.Span[1] & 0b1000_0000)); // Default bit + } + + [TestMethod] + public void SetCommand_Create_NegativeValue_Size1() + { + ConfigurationCommandClass.ConfigurationSetCommand command = + ConfigurationCommandClass.ConfigurationSetCommand.Create(1, 1, -1); + + Assert.AreEqual(0xFF, command.Frame.CommandParameters.Span[2]); // -1 as unsigned byte + } + + [TestMethod] + public void SetCommand_Create_NegativeValue_Size2() + { + ConfigurationCommandClass.ConfigurationSetCommand command = + ConfigurationCommandClass.ConfigurationSetCommand.Create(1, 2, -256); + + Assert.AreEqual(0xFF, command.Frame.CommandParameters.Span[2]); // -256 = 0xFF00 + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[3]); + } + + [TestMethod] + public void GetCommand_Create_HasCorrectFormat() + { + ConfigurationCommandClass.ConfigurationGetCommand command = + ConfigurationCommandClass.ConfigurationGetCommand.Create(42); + + Assert.AreEqual(CommandClassId.Configuration, ConfigurationCommandClass.ConfigurationGetCommand.CommandClassId); + Assert.AreEqual((byte)ConfigurationCommand.Get, ConfigurationCommandClass.ConfigurationGetCommand.CommandId); + Assert.AreEqual(1, command.Frame.CommandParameters.Length); + Assert.AreEqual(42, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void ReportCommand_Parse_Size1_PositiveValue() + { + byte[] data = [0x70, 0x06, 0x01, 0x01, 0x2A]; // CC, Cmd, param#=1, size=1, value=42 + CommandClassFrame frame = new(data); + + ConfigurationReport report = ConfigurationCommandClass.ConfigurationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ushort)1, report.ParameterNumber); + Assert.AreEqual((byte)1, report.Size); + Assert.AreEqual(42L, report.Value); + } + + [TestMethod] + public void ReportCommand_Parse_Size1_NegativeValue() + { + byte[] data = [0x70, 0x06, 0x01, 0x01, 0xFF]; // value = -1 + CommandClassFrame frame = new(data); + + ConfigurationReport report = ConfigurationCommandClass.ConfigurationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(-1L, report.Value); + } + + [TestMethod] + public void ReportCommand_Parse_Size2() + { + byte[] data = [0x70, 0x06, 0x02, 0x02, 0x01, 0x00]; // param#=2, size=2, value=256 + CommandClassFrame frame = new(data); + + ConfigurationReport report = ConfigurationCommandClass.ConfigurationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ushort)2, report.ParameterNumber); + Assert.AreEqual((byte)2, report.Size); + Assert.AreEqual(256L, report.Value); + } + + [TestMethod] + public void ReportCommand_Parse_Size2_NegativeValue() + { + byte[] data = [0x70, 0x06, 0x02, 0x02, 0xFF, 0x00]; // value = -256 + CommandClassFrame frame = new(data); + + ConfigurationReport report = ConfigurationCommandClass.ConfigurationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(-256L, report.Value); + } + + [TestMethod] + public void ReportCommand_Parse_Size4() + { + byte[] data = [0x70, 0x06, 0x03, 0x04, 0x00, 0x01, 0x00, 0x00]; // param#=3, size=4, value=65536 + CommandClassFrame frame = new(data); + + ConfigurationReport report = ConfigurationCommandClass.ConfigurationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ushort)3, report.ParameterNumber); + Assert.AreEqual((byte)4, report.Size); + Assert.AreEqual(65536L, report.Value); + } + + [TestMethod] + public void ReportCommand_Parse_Size4_NegativeValue() + { + byte[] data = [0x70, 0x06, 0x03, 0x04, 0xFF, 0xFF, 0xFF, 0xFE]; // value = -2 + CommandClassFrame frame = new(data); + + ConfigurationReport report = ConfigurationCommandClass.ConfigurationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(-2L, report.Value); + } + + [TestMethod] + public void ReportCommand_Parse_TooShort_Throws() + { + byte[] data = [0x70, 0x06]; // CC + Cmd only, no parameters + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => ConfigurationCommandClass.ConfigurationReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void ReportCommand_Parse_TooShortForDeclaredSize_Throws() + { + byte[] data = [0x70, 0x06, 0x01, 0x04, 0x00]; // size=4 but only 1 value byte + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => ConfigurationCommandClass.ConfigurationReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void ReportCommand_Parse_ReservedBitsIgnored() + { + // Upper bits of flags byte are reserved but should be ignored, size=1 + byte[] data = [0x70, 0x06, 0x01, 0xF9, 0x2A]; // 0xF9 = reserved bits + size=1 + CommandClassFrame frame = new(data); + + ConfigurationReport report = ConfigurationCommandClass.ConfigurationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, report.Size); // Only lower 3 bits matter + Assert.AreEqual(42L, report.Value); + } + + [TestMethod] + public void ReportCommand_Parse_NullFormat_InterpretedAsSigned() + { + byte[] data = [0x70, 0x06, 0x01, 0x01, 0x2A]; // size=1, value=42 + CommandClassFrame frame = new(data); + + ConfigurationReport report = ConfigurationCommandClass.ConfigurationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsNull(report.Format); + Assert.AreEqual(42L, report.Value); + } + + [TestMethod] + public void ReportCommand_Parse_SignedFormat_InterpretedAsSigned() + { + byte[] data = [0x70, 0x06, 0x01, 0x01, 0x2A]; + CommandClassFrame frame = new(data); + + ConfigurationReport report = ConfigurationCommandClass.ConfigurationReportCommand.Parse( + frame, NullLogger.Instance, ConfigurationParameterFormat.SignedInteger); + + Assert.AreEqual(ConfigurationParameterFormat.SignedInteger, report.Format); + Assert.AreEqual(42L, report.Value); + } + + [TestMethod] + public void ReportCommand_Parse_UnsignedFormat_InterpretedAsUnsigned() + { + byte[] data = [0x70, 0x06, 0x01, 0x01, 0x2A]; + CommandClassFrame frame = new(data); + + ConfigurationReport report = ConfigurationCommandClass.ConfigurationReportCommand.Parse( + frame, NullLogger.Instance, ConfigurationParameterFormat.UnsignedInteger); + + Assert.AreEqual(ConfigurationParameterFormat.UnsignedInteger, report.Format); + Assert.AreEqual(42L, report.Value); + } + + [TestMethod] + public void ReportCommand_Parse_Size1_0xFF_SignedVsUnsigned() + { + byte[] data = [0x70, 0x06, 0x01, 0x01, 0xFF]; + CommandClassFrame frame = new(data); + + // Without format (null) → signed interpretation + ConfigurationReport signed = ConfigurationCommandClass.ConfigurationReportCommand.Parse(frame, NullLogger.Instance); + Assert.AreEqual(-1L, signed.Value); + + // With unsigned format → unsigned interpretation + ConfigurationReport unsigned = ConfigurationCommandClass.ConfigurationReportCommand.Parse( + frame, NullLogger.Instance, ConfigurationParameterFormat.UnsignedInteger); + Assert.AreEqual(255L, unsigned.Value); + } + + [TestMethod] + public void ReportCommand_Parse_Size2_0xFFFF_UnsignedFormat() + { + byte[] data = [0x70, 0x06, 0x01, 0x02, 0xFF, 0xFF]; // param#=1, size=2, value=0xFFFF + CommandClassFrame frame = new(data); + + ConfigurationReport report = ConfigurationCommandClass.ConfigurationReportCommand.Parse( + frame, NullLogger.Instance, ConfigurationParameterFormat.UnsignedInteger); + + Assert.AreEqual(65535L, report.Value); + } + + [TestMethod] + public void ReportCommand_Parse_EnumeratedFormat_InterpretedAsUnsigned() + { + byte[] data = [0x70, 0x06, 0x01, 0x01, 0x03]; + CommandClassFrame frame = new(data); + + ConfigurationReport report = ConfigurationCommandClass.ConfigurationReportCommand.Parse( + frame, NullLogger.Instance, ConfigurationParameterFormat.Enumerated); + + Assert.AreEqual(ConfigurationParameterFormat.Enumerated, report.Format); + Assert.AreEqual(3L, report.Value); + } + + [TestMethod] + public void ReportCommand_Parse_BitFieldFormat_InterpretedAsUnsigned() + { + byte[] data = [0x70, 0x06, 0x01, 0x01, 0xA5]; + CommandClassFrame frame = new(data); + + ConfigurationReport report = ConfigurationCommandClass.ConfigurationReportCommand.Parse( + frame, NullLogger.Instance, ConfigurationParameterFormat.BitField); + + Assert.AreEqual(ConfigurationParameterFormat.BitField, report.Format); + Assert.AreEqual(0xA5L, report.Value); + } + + [TestMethod] + public void ReportCommand_Create_Size1() + { + ConfigurationCommandClass.ConfigurationReportCommand command = + ConfigurationCommandClass.ConfigurationReportCommand.Create(1, 1, 42); + + Assert.AreEqual(CommandClassId.Configuration, ConfigurationCommandClass.ConfigurationReportCommand.CommandClassId); + Assert.AreEqual((byte)ConfigurationCommand.Report, ConfigurationCommandClass.ConfigurationReportCommand.CommandId); + Assert.AreEqual(3, command.Frame.CommandParameters.Length); + } + + [TestMethod] + public void ReportCommand_RoundTrip_Size1() + { + ConfigurationCommandClass.ConfigurationReportCommand command = + ConfigurationCommandClass.ConfigurationReportCommand.Create(5, 1, -100); + + ConfigurationReport report = ConfigurationCommandClass.ConfigurationReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual((ushort)5, report.ParameterNumber); + Assert.AreEqual((byte)1, report.Size); + Assert.AreEqual(-100L, report.Value); + } + + [TestMethod] + public void ReportCommand_RoundTrip_Size2() + { + ConfigurationCommandClass.ConfigurationReportCommand command = + ConfigurationCommandClass.ConfigurationReportCommand.Create(10, 2, 12345); + + ConfigurationReport report = ConfigurationCommandClass.ConfigurationReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual((ushort)10, report.ParameterNumber); + Assert.AreEqual((byte)2, report.Size); + Assert.AreEqual(12345L, report.Value); + } + + [TestMethod] + public void ReportCommand_RoundTrip_Size4() + { + ConfigurationCommandClass.ConfigurationReportCommand command = + ConfigurationCommandClass.ConfigurationReportCommand.Create(255, 4, -100000); + + ConfigurationReport report = ConfigurationCommandClass.ConfigurationReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual((ushort)255, report.ParameterNumber); + Assert.AreEqual((byte)4, report.Size); + Assert.AreEqual(-100000L, report.Value); + } +} diff --git a/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.cs new file mode 100644 index 0000000..c4acb66 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/ConfigurationCommandClassTests.cs @@ -0,0 +1,6 @@ +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public partial class ConfigurationCommandClassTests +{ +} diff --git a/src/ZWave.CommandClasses/ConfigurationCommandClass.Bulk.cs b/src/ZWave.CommandClasses/ConfigurationCommandClass.Bulk.cs new file mode 100644 index 0000000..a16b92d --- /dev/null +++ b/src/ZWave.CommandClasses/ConfigurationCommandClass.Bulk.cs @@ -0,0 +1,324 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a Configuration Bulk Report received from a device for consecutive parameters. +/// +public readonly record struct ConfigurationBulkReport( + /// + /// The first parameter number in the reported range. + /// + ushort ParameterOffset, + + /// + /// Whether all reported parameters have their factory default values. + /// + bool IsDefault, + + /// + /// Whether this report is a handshake response to a Bulk Set command. + /// + bool IsHandshake, + + /// + /// The size of each parameter value in bytes (1, 2, or 4). + /// + byte Size, + + /// + /// The parameter values interpreted as signed integers, in order starting from + /// . + /// + IReadOnlyList Values); + +public sealed partial class ConfigurationCommandClass +{ + /// + /// Event raised when a Configuration Bulk Report is received, both solicited and unsolicited. + /// + public event Action? OnConfigurationBulkReportReceived; + + /// + /// Request the values of one or more consecutive configuration parameters. + /// + /// The first parameter number in the range (0-65535). + /// The number of consecutive parameters to query (1-255). + /// The cancellation token. + /// A list of bulk reports. If the response spans multiple frames, all are aggregated. + public async Task BulkGetAsync( + ushort parameterOffset, + byte numberOfParameters, + CancellationToken cancellationToken) + { + ConfigurationBulkGetCommand command = ConfigurationBulkGetCommand.Create(parameterOffset, numberOfParameters); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + + List allValues = []; + ushort reportedOffset; + bool isDefault; + bool isHandshake; + byte size; + byte reportsToFollow; + + do + { + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken) + .ConfigureAwait(false); + (ConfigurationBulkReport partialReport, reportsToFollow) = + ConfigurationBulkReportCommand.Parse(reportFrame, Logger); + + reportedOffset = partialReport.ParameterOffset; + isDefault = partialReport.IsDefault; + isHandshake = partialReport.IsHandshake; + size = partialReport.Size; + + for (int i = 0; i < partialReport.Values.Count; i++) + { + allValues.Add(partialReport.Values[i]); + } + } + while (reportsToFollow > 0); + + ConfigurationBulkReport result = new(reportedOffset, isDefault, isHandshake, size, allValues); + + // Update per-parameter cache + for (int i = 0; i < allValues.Count; i++) + { + ushort paramNumber = (ushort)(reportedOffset + i); + ConfigurationParameterFormat? format = GetParameterFormat(paramNumber); + long value = format is ConfigurationParameterFormat.UnsignedInteger + or ConfigurationParameterFormat.Enumerated + or ConfigurationParameterFormat.BitField + ? (uint)allValues[i] + : allValues[i]; + _parameterValues[paramNumber] = new ConfigurationReport(paramNumber, size, format, value); + } + + OnConfigurationBulkReportReceived?.Invoke(result); + return result; + } + + /// + /// Set the value of one or more consecutive configuration parameters. + /// + /// + /// The parameter size is resolved from cached properties or a prior report for + /// . If the size is not yet known, a Bulk Get is issued + /// automatically to discover it. + /// + /// The first parameter number in the range (0-65535). + /// The values to set, one per consecutive parameter. + /// The cancellation token. + public async Task BulkSetAsync( + ushort parameterOffset, + IReadOnlyList values, + CancellationToken cancellationToken) + { + byte? size = TryGetParameterSize(parameterOffset); + if (!size.HasValue) + { + _ = await BulkGetAsync(parameterOffset, (byte)values.Count, cancellationToken).ConfigureAwait(false); + size = _parameterValues[parameterOffset].Size; + } + + var command = ConfigurationBulkSetCommand.Create(parameterOffset, size.Value, values, restoreDefault: false, handshake: false); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// + /// Restore the default factory values of one or more consecutive configuration parameters. + /// + /// The first parameter number in the range (0-65535). + /// The number of consecutive parameters to reset (1-255). + /// The cancellation token. + public async Task BulkSetDefaultAsync( + ushort parameterOffset, + byte numberOfParameters, + CancellationToken cancellationToken) + { + var command = ConfigurationBulkSetCommand.CreateDefault(parameterOffset, numberOfParameters); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + internal readonly struct ConfigurationBulkSetCommand : ICommand + { + public ConfigurationBulkSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Configuration; + + public static byte CommandId => (byte)ConfigurationCommand.BulkSet; + + public CommandClassFrame Frame { get; } + + public static ConfigurationBulkSetCommand Create( + ushort parameterOffset, + byte size, + IReadOnlyList values, + bool restoreDefault, + bool handshake) + { + int parameterCount = values.Count; + Span commandParameters = stackalloc byte[4 + (parameterCount * size)]; + parameterOffset.WriteBytesBE(commandParameters); + commandParameters[2] = (byte)parameterCount; + + byte flags = (byte)(size & 0b0000_0111); + if (restoreDefault) + { + flags |= 0b1000_0000; + } + + if (handshake) + { + flags |= 0b0100_0000; + } + + commandParameters[3] = flags; + + for (int i = 0; i < parameterCount; i++) + { + unchecked((int)values[i]).WriteSignedVariableSizeBE(commandParameters.Slice(4 + (i * size), size)); + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ConfigurationBulkSetCommand(frame); + } + + public static ConfigurationBulkSetCommand CreateDefault(ushort parameterOffset, byte numberOfParameters) + { + // Default bit set, size = 1, no values needed (they are ignored per spec) + Span commandParameters = stackalloc byte[4]; + parameterOffset.WriteBytesBE(commandParameters); + commandParameters[2] = numberOfParameters; + commandParameters[3] = 0b1000_0001; // Default=1, Size=1 + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ConfigurationBulkSetCommand(frame); + } + } + + internal readonly struct ConfigurationBulkGetCommand : ICommand + { + public ConfigurationBulkGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Configuration; + + public static byte CommandId => (byte)ConfigurationCommand.BulkGet; + + public CommandClassFrame Frame { get; } + + public static ConfigurationBulkGetCommand Create(ushort parameterOffset, byte numberOfParameters) + { + Span commandParameters = stackalloc byte[3]; + parameterOffset.WriteBytesBE(commandParameters); + commandParameters[2] = numberOfParameters; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ConfigurationBulkGetCommand(frame); + } + } + + internal readonly struct ConfigurationBulkReportCommand : ICommand + { + public ConfigurationBulkReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Configuration; + + public static byte CommandId => (byte)ConfigurationCommand.BulkReport; + + public CommandClassFrame Frame { get; } + + public static ConfigurationBulkReportCommand Create( + ushort parameterOffset, + byte reportsToFollow, + bool isDefault, + bool isHandshake, + byte size, + IReadOnlyList values) + { + int parameterCount = values.Count; + Span commandParameters = stackalloc byte[5 + (parameterCount * size)]; + parameterOffset.WriteBytesBE(commandParameters); + commandParameters[2] = (byte)parameterCount; + commandParameters[3] = reportsToFollow; + + byte flags = (byte)(size & 0b0000_0111); + if (isDefault) + { + flags |= 0b1000_0000; + } + + if (isHandshake) + { + flags |= 0b0100_0000; + } + + commandParameters[4] = flags; + + for (int i = 0; i < parameterCount; i++) + { + values[i].WriteSignedVariableSizeBE(commandParameters.Slice(5 + (i * size), size)); + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ConfigurationBulkReportCommand(frame); + } + + public static (ConfigurationBulkReport Report, byte ReportsToFollow) Parse( + CommandClassFrame frame, + ILogger logger) + { + // Minimum: 2 (offset) + 1 (count) + 1 (reports to follow) + 1 (flags) = 5 + if (frame.CommandParameters.Length < 5) + { + logger.LogWarning( + "Configuration Bulk Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Configuration Bulk Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + + ushort parameterOffset = span[..2].ToUInt16BE(); + byte numberOfParameters = span[2]; + byte reportsToFollow = span[3]; + + byte flags = span[4]; + bool isDefault = (flags & 0b1000_0000) != 0; + bool isHandshake = (flags & 0b0100_0000) != 0; + byte size = (byte)(flags & 0b0000_0111); + + int expectedLength = 5 + (numberOfParameters * size); + if (frame.CommandParameters.Length < expectedLength) + { + logger.LogWarning( + "Configuration Bulk Report frame is too short for declared parameters ({Length} bytes, expected {Expected})", + frame.CommandParameters.Length, + expectedLength); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Configuration Bulk Report frame is too short for declared parameters"); + } + + List values = new(numberOfParameters); + for (int i = 0; i < numberOfParameters; i++) + { + int value = span.Slice(5 + (i * size), size).ReadSignedVariableSizeBE(); + values.Add(value); + } + + ConfigurationBulkReport report = new(parameterOffset, isDefault, isHandshake, size, values); + return (report, reportsToFollow); + } + } +} diff --git a/src/ZWave.CommandClasses/ConfigurationCommandClass.Info.cs b/src/ZWave.CommandClasses/ConfigurationCommandClass.Info.cs new file mode 100644 index 0000000..53cd508 --- /dev/null +++ b/src/ZWave.CommandClasses/ConfigurationCommandClass.Info.cs @@ -0,0 +1,91 @@ +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class ConfigurationCommandClass +{ + /// + /// Request usage information for a configuration parameter. + /// + /// + /// The info text may span multiple report frames which are automatically aggregated. + /// + /// The parameter number to query (0-65535). + /// The cancellation token. + /// The UTF-8 encoded info text of the parameter. + public async Task GetInfoAsync(ushort parameterNumber, CancellationToken cancellationToken) + { + ConfigurationInfoGetCommand command = ConfigurationInfoGetCommand.Create(parameterNumber); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + + List infoBytes = []; + byte reportsToFollow; + do + { + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken) + .ConfigureAwait(false); + reportsToFollow = ConfigurationInfoReportCommand.ParseInto(reportFrame, infoBytes, Logger); + } + while (reportsToFollow > 0); + + return Encoding.UTF8.GetString(CollectionsMarshal.AsSpan(infoBytes)); + } + + internal readonly struct ConfigurationInfoGetCommand : ICommand + { + public ConfigurationInfoGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Configuration; + + public static byte CommandId => (byte)ConfigurationCommand.InfoGet; + + public CommandClassFrame Frame { get; } + + public static ConfigurationInfoGetCommand Create(ushort parameterNumber) + { + Span commandParameters = stackalloc byte[2]; + parameterNumber.WriteBytesBE(commandParameters); + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ConfigurationInfoGetCommand(frame); + } + } + + internal readonly struct ConfigurationInfoReportCommand : ICommand + { + public ConfigurationInfoReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Configuration; + + public static byte CommandId => (byte)ConfigurationCommand.InfoReport; + + public CommandClassFrame Frame { get; } + + public static ConfigurationInfoReportCommand Create( + ushort parameterNumber, + byte reportsToFollow, + ReadOnlySpan infoBytes) + { + Span commandParameters = stackalloc byte[3 + infoBytes.Length]; + parameterNumber.WriteBytesBE(commandParameters); + commandParameters[2] = reportsToFollow; + infoBytes.CopyTo(commandParameters[3..]); + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ConfigurationInfoReportCommand(frame); + } + + /// + /// Parses an Info Report frame and appends the info bytes to the provided list. + /// + /// The number of reports still to follow. + public static byte ParseInto(CommandClassFrame frame, List infoBytes, ILogger logger) + => ParseTextReportInto(frame, infoBytes, logger, "Info"); + } +} diff --git a/src/ZWave.CommandClasses/ConfigurationCommandClass.Name.cs b/src/ZWave.CommandClasses/ConfigurationCommandClass.Name.cs new file mode 100644 index 0000000..0fb7de6 --- /dev/null +++ b/src/ZWave.CommandClasses/ConfigurationCommandClass.Name.cs @@ -0,0 +1,91 @@ +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class ConfigurationCommandClass +{ + /// + /// Request the name of a configuration parameter. + /// + /// + /// The name may span multiple report frames which are automatically aggregated. + /// + /// The parameter number to query (0-65535). + /// The cancellation token. + /// The UTF-8 encoded name of the parameter. + public async Task GetNameAsync(ushort parameterNumber, CancellationToken cancellationToken) + { + ConfigurationNameGetCommand command = ConfigurationNameGetCommand.Create(parameterNumber); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + + List nameBytes = []; + byte reportsToFollow; + do + { + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken) + .ConfigureAwait(false); + reportsToFollow = ConfigurationNameReportCommand.ParseInto(reportFrame, nameBytes, Logger); + } + while (reportsToFollow > 0); + + return Encoding.UTF8.GetString(CollectionsMarshal.AsSpan(nameBytes)); + } + + internal readonly struct ConfigurationNameGetCommand : ICommand + { + public ConfigurationNameGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Configuration; + + public static byte CommandId => (byte)ConfigurationCommand.NameGet; + + public CommandClassFrame Frame { get; } + + public static ConfigurationNameGetCommand Create(ushort parameterNumber) + { + Span commandParameters = stackalloc byte[2]; + parameterNumber.WriteBytesBE(commandParameters); + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ConfigurationNameGetCommand(frame); + } + } + + internal readonly struct ConfigurationNameReportCommand : ICommand + { + public ConfigurationNameReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Configuration; + + public static byte CommandId => (byte)ConfigurationCommand.NameReport; + + public CommandClassFrame Frame { get; } + + public static ConfigurationNameReportCommand Create( + ushort parameterNumber, + byte reportsToFollow, + ReadOnlySpan nameBytes) + { + Span commandParameters = stackalloc byte[3 + nameBytes.Length]; + parameterNumber.WriteBytesBE(commandParameters); + commandParameters[2] = reportsToFollow; + nameBytes.CopyTo(commandParameters[3..]); + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ConfigurationNameReportCommand(frame); + } + + /// + /// Parses a Name Report frame and appends the name bytes to the provided list. + /// + /// The number of reports still to follow. + public static byte ParseInto(CommandClassFrame frame, List nameBytes, ILogger logger) + => ParseTextReportInto(frame, nameBytes, logger, "Name"); + } +} diff --git a/src/ZWave.CommandClasses/ConfigurationCommandClass.Properties.cs b/src/ZWave.CommandClasses/ConfigurationCommandClass.Properties.cs new file mode 100644 index 0000000..f9d4597 --- /dev/null +++ b/src/ZWave.CommandClasses/ConfigurationCommandClass.Properties.cs @@ -0,0 +1,324 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents the properties of a configuration parameter as advertised by the device. +/// +public readonly record struct ConfigurationParameterProperties( + /// + /// The parameter number. + /// + ushort ParameterNumber, + + /// + /// The format of the parameter value. + /// + ConfigurationParameterFormat Format, + + /// + /// The size of the parameter value in bytes (0, 1, 2, or 4). + /// A size of 0 indicates the parameter is unassigned. + /// + byte Size, + + /// + /// The minimum value the parameter can assume. Zero when is 0. + /// Interpreted according to . + /// + long MinValue, + + /// + /// The maximum value the parameter can assume. Zero when is 0. + /// For bit field parameters, each supported bit is set to 1. + /// Interpreted according to . + /// + long MaxValue, + + /// + /// The default value of the parameter. Zero when is 0. + /// Interpreted according to . + /// + long DefaultValue, + + /// + /// Whether the parameter is read-only (version 4+). + /// if the field is not present in the report. + /// + bool? ReadOnly, + + /// + /// Whether changing the parameter alters the node's capabilities and requires re-inclusion (version 4+). + /// if the field is not present in the report. + /// + bool? AlteringCapabilities, + + /// + /// Whether the parameter is intended for advanced use only (version 4+). + /// if the field is not present in the report. + /// + bool? Advanced, + + /// + /// Whether the node ignores Bulk commands (version 4+). + /// if the field is not present in the report. + /// + bool? NoBulkSupport); + +public sealed partial class ConfigurationCommandClass +{ + private Dictionary? _parameterProperties; + + /// + /// Gets the cached parameter properties discovered during interview, keyed by parameter number. + /// if properties have not been queried (version 1-2 devices, or not yet interviewed). + /// + public IReadOnlyDictionary? ParameterProperties => _parameterProperties; + + /// + /// Request the properties of a configuration parameter. + /// + /// The parameter number to query (0-65535). + /// The cancellation token. + /// The properties of the parameter. + public async Task GetPropertiesAsync( + ushort parameterNumber, + CancellationToken cancellationToken) + { + ConfigurationPropertiesGetCommand command = ConfigurationPropertiesGetCommand.Create(parameterNumber); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken) + .ConfigureAwait(false); + (ConfigurationParameterProperties properties, _) = ConfigurationPropertiesReportCommand.Parse(reportFrame, Logger); + + // Cache properties for non-unassigned parameters + if (properties.Size > 0) + { + _parameterProperties ??= []; + _parameterProperties[properties.ParameterNumber] = properties; + } + + return properties; + } + + /// + /// Reset all configuration parameters to their default values. + /// + /// The cancellation token. + public async Task ResetToDefaultAsync(CancellationToken cancellationToken) + { + var command = ConfigurationDefaultResetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + internal readonly struct ConfigurationDefaultResetCommand : ICommand + { + public ConfigurationDefaultResetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Configuration; + + public static byte CommandId => (byte)ConfigurationCommand.DefaultReset; + + public CommandClassFrame Frame { get; } + + public static ConfigurationDefaultResetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new ConfigurationDefaultResetCommand(frame); + } + } + + internal readonly struct ConfigurationPropertiesGetCommand : ICommand + { + public ConfigurationPropertiesGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Configuration; + + public static byte CommandId => (byte)ConfigurationCommand.PropertiesGet; + + public CommandClassFrame Frame { get; } + + public static ConfigurationPropertiesGetCommand Create(ushort parameterNumber) + { + Span commandParameters = stackalloc byte[2]; + parameterNumber.WriteBytesBE(commandParameters); + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ConfigurationPropertiesGetCommand(frame); + } + } + + internal readonly struct ConfigurationPropertiesReportCommand : ICommand + { + public ConfigurationPropertiesReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Configuration; + + public static byte CommandId => (byte)ConfigurationCommand.PropertiesReport; + + public CommandClassFrame Frame { get; } + + public static ConfigurationPropertiesReportCommand Create( + ushort parameterNumber, + ConfigurationParameterFormat format, + byte size, + long minValue, + long maxValue, + long defaultValue, + ushort nextParameterNumber, + bool? readOnly = null, + bool? alteringCapabilities = null, + bool? advanced = null, + bool? noBulkSupport = null) + { + // V3: 2 (param#) + 1 (format|size) + 3*size (min/max/default) + 2 (next param#) = 5 + 3*size + // V4: + 1 (flags) = 6 + 3*size + bool hasV4Fields = readOnly.HasValue || alteringCapabilities.HasValue || advanced.HasValue || noBulkSupport.HasValue; + int length = 5 + (3 * size) + (hasV4Fields ? 1 : 0); + Span commandParameters = stackalloc byte[length]; + parameterNumber.WriteBytesBE(commandParameters); + + byte formatAndSize = (byte)(((byte)format << 3) | (size & 0b0000_0111)); + if (readOnly == true) + { + formatAndSize |= 0b0100_0000; + } + + if (alteringCapabilities == true) + { + formatAndSize |= 0b1000_0000; + } + + commandParameters[2] = formatAndSize; + + int offset = 3; + if (size > 0) + { + unchecked((int)minValue).WriteSignedVariableSizeBE(commandParameters.Slice(offset, size)); + offset += size; + unchecked((int)maxValue).WriteSignedVariableSizeBE(commandParameters.Slice(offset, size)); + offset += size; + unchecked((int)defaultValue).WriteSignedVariableSizeBE(commandParameters.Slice(offset, size)); + offset += size; + } + + nextParameterNumber.WriteBytesBE(commandParameters.Slice(offset, 2)); + offset += 2; + + if (hasV4Fields) + { + byte v4Flags = 0; + if (noBulkSupport == true) + { + v4Flags |= 0b0000_0010; + } + + if (advanced == true) + { + v4Flags |= 0b0000_0001; + } + + commandParameters[offset] = v4Flags; + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ConfigurationPropertiesReportCommand(frame); + } + + public static (ConfigurationParameterProperties Properties, ushort NextParameterNumber) Parse( + CommandClassFrame frame, + ILogger logger) + { + // Minimum: 2 (param#) + 1 (format|size) + 2 (next param#) = 5 (when size=0) + if (frame.CommandParameters.Length < 5) + { + logger.LogWarning( + "Configuration Properties Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Configuration Properties Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + + ushort parameterNumber = span[..2].ToUInt16BE(); + + byte formatAndSize = span[2]; + ConfigurationParameterFormat format = (ConfigurationParameterFormat)((formatAndSize >> 3) & 0b0000_0111); + byte size = (byte)(formatAndSize & 0b0000_0111); + bool readOnlyBit = (formatAndSize & 0b0100_0000) != 0; + bool alteringCapabilitiesBit = (formatAndSize & 0b1000_0000) != 0; + + long minValue = 0; + long maxValue = 0; + long defaultValue = 0; + + int valueFieldsLength = 3 * size; + int expectedMinLength = 3 + valueFieldsLength + 2; + + if (size > 0) + { + if (frame.CommandParameters.Length < expectedMinLength) + { + logger.LogWarning( + "Configuration Properties Report frame is too short for declared size ({Length} bytes, expected {Expected})", + frame.CommandParameters.Length, + expectedMinLength); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Configuration Properties Report frame is too short for declared size"); + } + + int offset = 3; + minValue = ReadValue(span.Slice(offset, size), format); + offset += size; + maxValue = ReadValue(span.Slice(offset, size), format); + offset += size; + defaultValue = ReadValue(span.Slice(offset, size), format); + } + + int nextParamOffset = 3 + valueFieldsLength; + ushort nextParameterNumber = span.Slice(nextParamOffset, 2).ToUInt16BE(); + + // V4 fields: check if additional byte exists after the next parameter number + bool? readOnly = null; + bool? alteringCapabilities = null; + bool? advanced = null; + bool? noBulkSupport = null; + + int v4FlagsOffset = nextParamOffset + 2; + if (frame.CommandParameters.Length > v4FlagsOffset) + { + // V4 fields present + readOnly = readOnlyBit; + alteringCapabilities = alteringCapabilitiesBit; + + byte v4Flags = span[v4FlagsOffset]; + noBulkSupport = (v4Flags & 0b0000_0010) != 0; + advanced = (v4Flags & 0b0000_0001) != 0; + } + + ConfigurationParameterProperties properties = new( + parameterNumber, + format, + size, + minValue, + maxValue, + defaultValue, + readOnly, + alteringCapabilities, + advanced, + noBulkSupport); + return (properties, nextParameterNumber); + } + } +} diff --git a/src/ZWave.CommandClasses/ConfigurationCommandClass.Report.cs b/src/ZWave.CommandClasses/ConfigurationCommandClass.Report.cs new file mode 100644 index 0000000..936f43c --- /dev/null +++ b/src/ZWave.CommandClasses/ConfigurationCommandClass.Report.cs @@ -0,0 +1,256 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a Configuration Report received from a device for a single parameter. +/// +public readonly record struct ConfigurationReport( + /// + /// The parameter number. + /// + ushort ParameterNumber, + + /// + /// The size of the parameter value in bytes (1, 2, or 4). + /// + byte Size, + + /// + /// The parameter format, or if unknown (version 1-2 devices or not yet interviewed). + /// When , the value is interpreted as signed per the V1-V2 spec default. + /// + ConfigurationParameterFormat? Format, + + /// + /// The parameter value, correctly interpreted based on . + /// Signed values are sign-extended; unsigned values are zero-extended. + /// + long Value); + +public sealed partial class ConfigurationCommandClass +{ + private readonly Dictionary _parameterValues = []; + + /// + /// Gets the cached parameter values, keyed by parameter number. + /// + public IReadOnlyDictionary ParameterValues => _parameterValues; + + /// + /// Event raised when a Configuration Report is received, both solicited and unsolicited. + /// + public event Action? OnConfigurationReportReceived; + + /// + /// Request the value of a configuration parameter. + /// + /// The parameter number to query (0-255). + /// The cancellation token. + /// The configuration report for the requested parameter. + public async Task GetAsync(byte parameterNumber, CancellationToken cancellationToken) + { + ConfigurationGetCommand command = ConfigurationGetCommand.Create(parameterNumber); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync( + predicate: frame => frame.CommandParameters.Length > 0 + && frame.CommandParameters.Span[0] == parameterNumber, + cancellationToken).ConfigureAwait(false); + ConfigurationReport report = ConfigurationReportCommand.Parse(reportFrame, Logger, GetParameterFormat(parameterNumber)); + _parameterValues[report.ParameterNumber] = report; + OnConfigurationReportReceived?.Invoke(report); + return report; + } + + /// + /// Set the value of a configuration parameter. + /// + /// + /// The parameter size is resolved from cached properties (V3+ interview) or a prior + /// result. If the size is not yet known, a Get is issued automatically + /// to discover it. + /// + /// The parameter number to set (0-255). + /// The value to set. + /// The cancellation token. + public async Task SetAsync(byte parameterNumber, long value, CancellationToken cancellationToken) + { + byte size = await GetParameterSizeAsync(parameterNumber, cancellationToken).ConfigureAwait(false); + var command = ConfigurationSetCommand.Create(parameterNumber, size, value); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// + /// Restore the default factory value of a configuration parameter. + /// + /// The parameter number to reset (0-255). + /// The cancellation token. + public async Task SetDefaultAsync(byte parameterNumber, CancellationToken cancellationToken) + { + var command = ConfigurationSetCommand.CreateDefault(parameterNumber); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + private ConfigurationParameterFormat? GetParameterFormat(ushort parameterNumber) + => _parameterProperties?.TryGetValue(parameterNumber, out ConfigurationParameterProperties props) == true + ? props.Format + : null; + + private byte? TryGetParameterSize(ushort parameterNumber) + { + if (_parameterProperties?.TryGetValue(parameterNumber, out ConfigurationParameterProperties props) == true + && props.Size > 0) + { + return props.Size; + } + + if (_parameterValues.TryGetValue(parameterNumber, out ConfigurationReport report) + && report.Size > 0) + { + return report.Size; + } + + return null; + } + + private async Task GetParameterSizeAsync(byte parameterNumber, CancellationToken cancellationToken) + { + byte? size = TryGetParameterSize(parameterNumber); + if (size.HasValue) + { + return size.Value; + } + + // Size not cached — issue a Get to discover it. + _ = await GetAsync(parameterNumber, cancellationToken).ConfigureAwait(false); + return _parameterValues[parameterNumber].Size; + } + + /// + /// Reads a configuration value using signed or unsigned interpretation based on the format. + /// Returns which can hold both and ranges. + /// + internal static long ReadValue(ReadOnlySpan bytes, ConfigurationParameterFormat? format) + => format is ConfigurationParameterFormat.UnsignedInteger + or ConfigurationParameterFormat.Enumerated + or ConfigurationParameterFormat.BitField + ? bytes.ReadUnsignedVariableSizeBE() + : bytes.ReadSignedVariableSizeBE(); + + internal readonly struct ConfigurationSetCommand : ICommand + { + public ConfigurationSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Configuration; + + public static byte CommandId => (byte)ConfigurationCommand.Set; + + public CommandClassFrame Frame { get; } + + public static ConfigurationSetCommand Create(byte parameterNumber, byte size, long value) + { + Span commandParameters = stackalloc byte[2 + size]; + commandParameters[0] = parameterNumber; + commandParameters[1] = (byte)(size & 0b0000_0111); + unchecked((int)value).WriteSignedVariableSizeBE(commandParameters[2..]); + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ConfigurationSetCommand(frame); + } + + public static ConfigurationSetCommand CreateDefault(byte parameterNumber) + { + // Default bit is bit 7 of byte 1, size can be anything valid (use 1). + ReadOnlySpan commandParameters = [parameterNumber, 0b1000_0001, 0x00]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ConfigurationSetCommand(frame); + } + } + + internal readonly struct ConfigurationGetCommand : ICommand + { + public ConfigurationGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Configuration; + + public static byte CommandId => (byte)ConfigurationCommand.Get; + + public CommandClassFrame Frame { get; } + + public static ConfigurationGetCommand Create(byte parameterNumber) + { + ReadOnlySpan commandParameters = [parameterNumber]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ConfigurationGetCommand(frame); + } + } + + internal readonly struct ConfigurationReportCommand : ICommand + { + public ConfigurationReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Configuration; + + public static byte CommandId => (byte)ConfigurationCommand.Report; + + public CommandClassFrame Frame { get; } + + public static ConfigurationReportCommand Create( + byte parameterNumber, + byte size, + long value, + ConfigurationParameterFormat? format = null) + { + Span commandParameters = stackalloc byte[2 + size]; + commandParameters[0] = parameterNumber; + commandParameters[1] = (byte)(size & 0b0000_0111); + unchecked((int)value).WriteSignedVariableSizeBE(commandParameters[2..]); + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ConfigurationReportCommand(frame); + } + + public static ConfigurationReport Parse( + CommandClassFrame frame, + ILogger logger, + ConfigurationParameterFormat? format = null) + { + if (frame.CommandParameters.Length < 2) + { + logger.LogWarning( + "Configuration Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Configuration Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + + byte parameterNumber = span[0]; + byte size = (byte)(span[1] & 0b0000_0111); + + if (frame.CommandParameters.Length < 2 + size) + { + logger.LogWarning( + "Configuration Report frame is too short for declared size ({Length} bytes, expected {Expected})", + frame.CommandParameters.Length, + 2 + size); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Configuration Report frame is too short for declared size"); + } + + long value = ReadValue(span.Slice(2, size), format); + + return new ConfigurationReport(parameterNumber, size, format, value); + } + } +} diff --git a/src/ZWave.CommandClasses/ConfigurationCommandClass.cs b/src/ZWave.CommandClasses/ConfigurationCommandClass.cs new file mode 100644 index 0000000..d41dca1 --- /dev/null +++ b/src/ZWave.CommandClasses/ConfigurationCommandClass.cs @@ -0,0 +1,234 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// The format of a configuration parameter value as advertised by the Configuration Properties Report. +/// +public enum ConfigurationParameterFormat : byte +{ + /// + /// The parameter value is a signed integer using 2's complement encoding. + /// + SignedInteger = 0x00, + + /// + /// The parameter value is an unsigned integer. + /// + UnsignedInteger = 0x01, + + /// + /// The parameter value is an enumerated value (treated as unsigned integer). + /// + Enumerated = 0x02, + + /// + /// The parameter value is a bit field where each individual bit can be set or reset. + /// + BitField = 0x03, +} + +/// +/// Defines the commands for the Configuration Command Class. +/// +public enum ConfigurationCommand : byte +{ + /// + /// Reset all configuration parameters to their default values. + /// + DefaultReset = 0x01, + + /// + /// Set the value of a configuration parameter. + /// + Set = 0x04, + + /// + /// Request the value of a configuration parameter. + /// + Get = 0x05, + + /// + /// Report the value of a configuration parameter. + /// + Report = 0x06, + + /// + /// Set the value of one or more consecutive configuration parameters. + /// + BulkSet = 0x07, + + /// + /// Request the value of one or more consecutive configuration parameters. + /// + BulkGet = 0x08, + + /// + /// Report the value of one or more consecutive configuration parameters. + /// + BulkReport = 0x09, + + /// + /// Request the name of a configuration parameter. + /// + NameGet = 0x0A, + + /// + /// Report the name of a configuration parameter. + /// + NameReport = 0x0B, + + /// + /// Request usage information for a configuration parameter. + /// + InfoGet = 0x0C, + + /// + /// Report usage information for a configuration parameter. + /// + InfoReport = 0x0D, + + /// + /// Request the properties of a configuration parameter. + /// + PropertiesGet = 0x0E, + + /// + /// Report the properties of a configuration parameter. + /// + PropertiesReport = 0x0F, +} + +/// +/// The Configuration Command Class allows product-specific configuration parameters to be changed. +/// +[CommandClass(CommandClassId.Configuration)] +public sealed partial class ConfigurationCommandClass : CommandClass +{ + internal ConfigurationCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + public override bool? IsCommandSupported(ConfigurationCommand command) + => command switch + { + ConfigurationCommand.Set => true, + ConfigurationCommand.Get => true, + ConfigurationCommand.BulkSet => Version.HasValue ? Version >= 2 : null, + ConfigurationCommand.BulkGet => Version.HasValue ? Version >= 2 : null, + ConfigurationCommand.NameGet => Version.HasValue ? Version >= 3 : null, + ConfigurationCommand.InfoGet => Version.HasValue ? Version >= 3 : null, + ConfigurationCommand.PropertiesGet => Version.HasValue ? Version >= 3 : null, + ConfigurationCommand.DefaultReset => Version.HasValue ? Version >= 4 : null, + _ => false, + }; + + /// + internal override async Task InterviewAsync(CancellationToken cancellationToken) + { + // V3+ supports parameter discovery via the Properties Get chain. + // Start at parameter 0 to discover the first available parameter, then follow + // the next-parameter chain until it returns 0x0000 (no more parameters). + if (IsCommandSupported(ConfigurationCommand.PropertiesGet).GetValueOrDefault()) + { + ushort nextParameter = 0; + do + { + ConfigurationPropertiesGetCommand command = ConfigurationPropertiesGetCommand.Create(nextParameter); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken) + .ConfigureAwait(false); + (ConfigurationParameterProperties properties, nextParameter) = + ConfigurationPropertiesReportCommand.Parse(reportFrame, Logger); + + // Cache properties for non-unassigned parameters (Size 0 = unassigned). + if (properties.Size > 0) + { + _parameterProperties ??= []; + _parameterProperties[properties.ParameterNumber] = properties; + } + } + while (nextParameter != 0); + } + } + + /// + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((ConfigurationCommand)frame.CommandId) + { + case ConfigurationCommand.Report: + { + ConfigurationReport report = ConfigurationReportCommand.Parse(frame, Logger, GetParameterFormat(frame.CommandParameters.Span[0])); + _parameterValues[report.ParameterNumber] = report; + OnConfigurationReportReceived?.Invoke(report); + break; + } + case ConfigurationCommand.BulkReport: + { + (ConfigurationBulkReport bulkReport, _) = ConfigurationBulkReportCommand.Parse(frame, Logger); + for (int i = 0; i < bulkReport.Values.Count; i++) + { + ushort parameterNumber = (ushort)(bulkReport.ParameterOffset + i); + ConfigurationParameterFormat? format = GetParameterFormat(parameterNumber); + long value = format is ConfigurationParameterFormat.UnsignedInteger + or ConfigurationParameterFormat.Enumerated + or ConfigurationParameterFormat.BitField + ? (uint)bulkReport.Values[i] + : bulkReport.Values[i]; + ConfigurationReport singleReport = new( + parameterNumber, + bulkReport.Size, + format, + value); + _parameterValues[parameterNumber] = singleReport; + } + + OnConfigurationBulkReportReceived?.Invoke(bulkReport); + break; + } + } + } + + /// + /// Shared parser for Name Report and Info Report frames. + /// Both have the same structure: 2 bytes param#, 1 byte reports-to-follow, then UTF-8 text bytes. + /// + internal static byte ParseTextReportInto( + CommandClassFrame frame, + List textBytes, + ILogger logger, + string reportName) + { + if (frame.CommandParameters.Length < 3) + { + logger.LogWarning( + "Configuration {ReportName} Report frame is too short ({Length} bytes)", + reportName, + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + $"Configuration {reportName} Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + byte reportsToFollow = span[2]; + + if (span.Length > 3) + { + ReadOnlySpan textData = span[3..]; + for (int i = 0; i < textData.Length; i++) + { + textBytes.Add(textData[i]); + } + } + + return reportsToFollow; + } +}