diff --git a/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.CommandList.cs b/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.CommandList.cs new file mode 100644 index 0000000..5be2269 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.CommandList.cs @@ -0,0 +1,166 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class AssociationGroupInformationCommandClassTests +{ + [TestMethod] + public void CommandListGetCommand_Create_HasCorrectFormat() + { + AssociationGroupInformationCommandClass.CommandListGetCommand command = + AssociationGroupInformationCommandClass.CommandListGetCommand.Create(5); + + Assert.AreEqual( + CommandClassId.AssociationGroupInformation, + AssociationGroupInformationCommandClass.CommandListGetCommand.CommandClassId); + Assert.AreEqual( + (byte)AssociationGroupInformationCommand.CommandListGet, + AssociationGroupInformationCommandClass.CommandListGetCommand.CommandId); + Assert.AreEqual(4, command.Frame.Data.Length); // CC + Cmd + Flags + GroupId + // Flags: Allow Cache = 0b1000_0000 + Assert.AreEqual((byte)0b1000_0000, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual((byte)5, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void CommandListReport_Parse_NormalCommandClasses() + { + // CC=0x59, Cmd=0x06, GroupId=1, ListLength=4 + // Command 1: Basic (0x20) Set (0x01) + // Command 2: BinarySwitch (0x25) Set (0x01) + byte[] data = [0x59, 0x06, 0x01, 0x04, 0x20, 0x01, 0x25, 0x01]; + CommandClassFrame frame = new(data); + + (byte groupingIdentifier, IReadOnlyList commands) = + AssociationGroupInformationCommandClass.CommandListReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, groupingIdentifier); + Assert.HasCount(2, commands); + Assert.AreEqual((ushort)CommandClassId.Basic, commands[0].CommandClassId); + Assert.AreEqual((byte)0x01, commands[0].CommandId); + Assert.AreEqual((ushort)CommandClassId.BinarySwitch, commands[1].CommandClassId); + Assert.AreEqual((byte)0x01, commands[1].CommandId); + } + + [TestMethod] + public void CommandListReport_Parse_ExtendedCommandClass() + { + // CC=0x59, Cmd=0x06, GroupId=2, ListLength=5 + // Command 1: Extended CC (0xF1, 0x00) Cmd (0x01) - 3 bytes + // Command 2: Basic (0x20) Set (0x01) - 2 bytes + byte[] data = [0x59, 0x06, 0x02, 0x05, 0xF1, 0x00, 0x01, 0x20, 0x01]; + CommandClassFrame frame = new(data); + + (byte groupingIdentifier, IReadOnlyList commands) = + AssociationGroupInformationCommandClass.CommandListReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)2, groupingIdentifier); + Assert.HasCount(2, commands); + Assert.AreEqual((ushort)0xF100, commands[0].CommandClassId); + Assert.AreEqual((byte)0x01, commands[0].CommandId); + Assert.AreEqual((ushort)CommandClassId.Basic, commands[1].CommandClassId); + Assert.AreEqual((byte)0x01, commands[1].CommandId); + } + + [TestMethod] + public void CommandListReport_Parse_EmptyCommandList() + { + // CC=0x59, Cmd=0x06, GroupId=1, ListLength=0 + byte[] data = [0x59, 0x06, 0x01, 0x00]; + CommandClassFrame frame = new(data); + + (byte groupingIdentifier, IReadOnlyList commands) = + AssociationGroupInformationCommandClass.CommandListReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, groupingIdentifier); + Assert.IsEmpty(commands); + } + + [TestMethod] + public void CommandListReport_Parse_SingleCommand() + { + // CC=0x59, Cmd=0x06, GroupId=1, ListLength=2 + // Command: MultilevelSwitch (0x26) Set (0x01) + byte[] data = [0x59, 0x06, 0x01, 0x02, 0x26, 0x01]; + CommandClassFrame frame = new(data); + + (byte groupingIdentifier, IReadOnlyList commands) = + AssociationGroupInformationCommandClass.CommandListReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, groupingIdentifier); + Assert.HasCount(1, commands); + Assert.AreEqual((ushort)0x26, commands[0].CommandClassId); + Assert.AreEqual((byte)0x01, commands[0].CommandId); + } + + [TestMethod] + public void CommandListReport_Parse_LifelineGroup() + { + // Lifeline group typically has multiple report commands: + // CC=0x59, Cmd=0x06, GroupId=1, ListLength=8 + // Notification Report (0x71, 0x05), Battery Report (0x80, 0x03), + // Device Reset (0x5A, 0x01), Sensor Report (0x31, 0x05) + byte[] data = [0x59, 0x06, 0x01, 0x08, 0x71, 0x05, 0x80, 0x03, 0x5A, 0x01, 0x31, 0x05]; + CommandClassFrame frame = new(data); + + (byte groupingIdentifier, IReadOnlyList commands) = + AssociationGroupInformationCommandClass.CommandListReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, groupingIdentifier); + Assert.HasCount(4, commands); + Assert.AreEqual((ushort)CommandClassId.Notification, commands[0].CommandClassId); + Assert.AreEqual((byte)0x05, commands[0].CommandId); + Assert.AreEqual((ushort)CommandClassId.Battery, commands[1].CommandClassId); + Assert.AreEqual((byte)0x03, commands[1].CommandId); + } + + [TestMethod] + public void CommandListReport_Parse_TooShort_Throws() + { + // CC=0x59, Cmd=0x06, only GroupId (need GroupId + ListLength) + byte[] data = [0x59, 0x06, 0x01]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => AssociationGroupInformationCommandClass.CommandListReportCommand.Parse( + frame, NullLogger.Instance)); + } + + [TestMethod] + public void CommandListReport_Parse_TruncatedList_Throws() + { + // CC=0x59, Cmd=0x06, GroupId=1, ListLength=6, but only 2 bytes of data + byte[] data = [0x59, 0x06, 0x01, 0x06, 0x20, 0x01]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => AssociationGroupInformationCommandClass.CommandListReportCommand.Parse( + frame, NullLogger.Instance)); + } + + [TestMethod] + public void CommandListReport_Parse_MixedNormalAndExtended() + { + // CC=0x59, Cmd=0x06, GroupId=1, ListLength=7 + // Normal: Basic (0x20) Set (0x01) = 2 bytes + // Extended: (0xF2, 0x05) Cmd (0x03) = 3 bytes + // Normal: Notification (0x71) Report (0x05) = 2 bytes + byte[] data = [0x59, 0x06, 0x01, 0x07, 0x20, 0x01, 0xF2, 0x05, 0x03, 0x71, 0x05]; + CommandClassFrame frame = new(data); + + (byte groupingIdentifier, IReadOnlyList commands) = + AssociationGroupInformationCommandClass.CommandListReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, groupingIdentifier); + Assert.HasCount(3, commands); + + Assert.AreEqual((ushort)CommandClassId.Basic, commands[0].CommandClassId); + Assert.AreEqual((byte)0x01, commands[0].CommandId); + + Assert.AreEqual((ushort)0xF205, commands[1].CommandClassId); + Assert.AreEqual((byte)0x03, commands[1].CommandId); + + Assert.AreEqual((ushort)CommandClassId.Notification, commands[2].CommandClassId); + Assert.AreEqual((byte)0x05, commands[2].CommandId); + } +} diff --git a/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.GroupInfo.cs b/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.GroupInfo.cs new file mode 100644 index 0000000..25cf0d9 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.GroupInfo.cs @@ -0,0 +1,241 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class AssociationGroupInformationCommandClassTests +{ + [TestMethod] + public void GroupInfoGetCommand_Create_ListMode_HasCorrectFormat() + { + AssociationGroupInformationCommandClass.GroupInfoGetCommand command = + AssociationGroupInformationCommandClass.GroupInfoGetCommand.Create(listMode: true, groupingIdentifier: 0); + + Assert.AreEqual( + CommandClassId.AssociationGroupInformation, + AssociationGroupInformationCommandClass.GroupInfoGetCommand.CommandClassId); + Assert.AreEqual( + (byte)AssociationGroupInformationCommand.GroupInfoGet, + AssociationGroupInformationCommandClass.GroupInfoGetCommand.CommandId); + Assert.AreEqual(4, command.Frame.Data.Length); // CC + Cmd + Flags + GroupId + // Flags: List Mode bit = 0b0100_0000 + Assert.AreEqual((byte)0b0100_0000, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual((byte)0, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void GroupInfoGetCommand_Create_SingleGroup_HasCorrectFormat() + { + AssociationGroupInformationCommandClass.GroupInfoGetCommand command = + AssociationGroupInformationCommandClass.GroupInfoGetCommand.Create(listMode: false, groupingIdentifier: 3); + + Assert.AreEqual(4, command.Frame.Data.Length); // CC + Cmd + Flags + GroupId + Assert.AreEqual((byte)0x00, command.Frame.CommandParameters.Span[0]); // No flags + Assert.AreEqual((byte)3, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void GroupInfoReport_Parse_SingleGroup() + { + // CC=0x59, Cmd=0x04 + // Flags: ListMode=0, DynamicInfo=0, GroupCount=1 + // Group 1: GroupId=1, Mode=0, ProfileMSB=0x00 (General), ProfileLSB=0x01 (Lifeline), Reserved=0, EventCode=0x0000 + byte[] data = [0x59, 0x04, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + (bool dynamicInfo, List groups) = + AssociationGroupInformationCommandClass.GroupInfoReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsFalse(dynamicInfo); + Assert.HasCount(1, groups); + Assert.AreEqual((byte)1, groups[0].GroupingIdentifier); + Assert.AreEqual((byte)0x00, groups[0].Profile.Category); + Assert.AreEqual((byte)0x01, groups[0].Profile.Identifier); + } + + [TestMethod] + public void GroupInfoReport_Parse_MultipleGroups() + { + // CC=0x59, Cmd=0x04 + // Flags: ListMode=1, DynamicInfo=0, GroupCount=3 + // Group 1: GroupId=1, Mode=0, Profile=General:Lifeline (0x00, 0x01), Res=0, Event=0x0000 + // Group 2: GroupId=2, Mode=0, Profile=Control:Key1 (0x20, 0x01), Res=0, Event=0x0000 + // Group 3: GroupId=3, Mode=0, Profile=Control:Key1 (0x20, 0x01), Res=0, Event=0x0000 + byte[] data = + [ + 0x59, 0x04, + 0b1000_0011, // ListMode=1, DynamicInfo=0, GroupCount=3 + 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, // Group 1 + 0x02, 0x00, 0x20, 0x01, 0x00, 0x00, 0x00, // Group 2 + 0x03, 0x00, 0x20, 0x01, 0x00, 0x00, 0x00, // Group 3 + ]; + CommandClassFrame frame = new(data); + + (bool dynamicInfo, List groups) = + AssociationGroupInformationCommandClass.GroupInfoReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsFalse(dynamicInfo); + Assert.HasCount(3, groups); + + Assert.AreEqual((byte)1, groups[0].GroupingIdentifier); + Assert.AreEqual((byte)0x00, groups[0].Profile.Category); + Assert.AreEqual((byte)0x01, groups[0].Profile.Identifier); + + Assert.AreEqual((byte)2, groups[1].GroupingIdentifier); + Assert.AreEqual((byte)0x20, groups[1].Profile.Category); + Assert.AreEqual((byte)0x01, groups[1].Profile.Identifier); + + Assert.AreEqual((byte)3, groups[2].GroupingIdentifier); + Assert.AreEqual((byte)0x20, groups[2].Profile.Category); + Assert.AreEqual((byte)0x01, groups[2].Profile.Identifier); + } + + [TestMethod] + public void GroupInfoReport_Parse_DynamicInfo() + { + // CC=0x59, Cmd=0x04 + // Flags: ListMode=0, DynamicInfo=1, GroupCount=1 + // Group 1: GroupId=1, Mode=0, Profile=General:Lifeline, Res=0, Event=0x0000 + byte[] data = + [ + 0x59, 0x04, + 0b0100_0001, // ListMode=0, DynamicInfo=1, GroupCount=1 + 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + ]; + CommandClassFrame frame = new(data); + + (bool dynamicInfo, List groups) = + AssociationGroupInformationCommandClass.GroupInfoReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsTrue(dynamicInfo); + Assert.HasCount(1, groups); + } + + [TestMethod] + public void GroupInfoReport_Parse_SensorProfile() + { + // CC=0x59, Cmd=0x04 + // Flags: ListMode=0, DynamicInfo=0, GroupCount=1 + // Group 1: GroupId=2, Mode=0, Profile=Sensor:Temperature (0x31, 0x01), Res=0, Event=0x0000 + byte[] data = + [ + 0x59, 0x04, + 0x01, // GroupCount=1 + 0x02, 0x00, 0x31, 0x01, 0x00, 0x00, 0x00, + ]; + CommandClassFrame frame = new(data); + + (bool dynamicInfo, List groups) = + AssociationGroupInformationCommandClass.GroupInfoReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsFalse(dynamicInfo); + Assert.HasCount(1, groups); + Assert.AreEqual((byte)2, groups[0].GroupingIdentifier); + Assert.AreEqual((byte)0x31, groups[0].Profile.Category); + Assert.AreEqual((byte)0x01, groups[0].Profile.Identifier); + } + + [TestMethod] + public void GroupInfoReport_Parse_NotificationProfile() + { + // Group with Notification:SmokeAlarm profile (0x71, 0x01) + byte[] data = + [ + 0x59, 0x04, + 0x01, // GroupCount=1 + 0x02, 0x00, 0x71, 0x01, 0x00, 0x00, 0x00, + ]; + CommandClassFrame frame = new(data); + + (bool _, List groups) = + AssociationGroupInformationCommandClass.GroupInfoReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(1, groups); + Assert.AreEqual((byte)0x71, groups[0].Profile.Category); + Assert.AreEqual((byte)0x01, groups[0].Profile.Identifier); + } + + [TestMethod] + public void GroupInfoReport_Parse_MeterProfile() + { + // Group with Meter:Electric profile (0x32, 0x01) - v2+ + byte[] data = + [ + 0x59, 0x04, + 0x01, // GroupCount=1 + 0x02, 0x00, 0x32, 0x01, 0x00, 0x00, 0x00, + ]; + CommandClassFrame frame = new(data); + + (bool _, List groups) = + AssociationGroupInformationCommandClass.GroupInfoReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(1, groups); + Assert.AreEqual((byte)0x32, groups[0].Profile.Category); + Assert.AreEqual((byte)0x01, groups[0].Profile.Identifier); + } + + [TestMethod] + public void GroupInfoReport_Parse_ZeroGroups() + { + // CC=0x59, Cmd=0x04, Flags: GroupCount=0 + byte[] data = [0x59, 0x04, 0x00]; + CommandClassFrame frame = new(data); + + (bool dynamicInfo, List groups) = + AssociationGroupInformationCommandClass.GroupInfoReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsFalse(dynamicInfo); + Assert.IsEmpty(groups); + } + + [TestMethod] + public void GroupInfoReport_Parse_TooShort_Throws() + { + // CC=0x59, Cmd=0x04, no parameters + byte[] data = [0x59, 0x04]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => AssociationGroupInformationCommandClass.GroupInfoReportCommand.Parse( + frame, NullLogger.Instance)); + } + + [TestMethod] + public void GroupInfoReport_Parse_TruncatedGroups_Throws() + { + // CC=0x59, Cmd=0x04, GroupCount=2, but only 1 group entry (7 bytes) + byte[] data = + [ + 0x59, 0x04, + 0x02, // GroupCount=2 + 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, // Only 1 group + ]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => AssociationGroupInformationCommandClass.GroupInfoReportCommand.Parse( + frame, NullLogger.Instance)); + } + + [TestMethod] + public void GroupInfoReport_Parse_IgnoresReservedFields() + { + // Mode, Reserved, and Event Code fields should be ignored per spec. + // Set them to non-zero values to verify they don't affect parsing. + byte[] data = + [ + 0x59, 0x04, + 0x01, // GroupCount=1 + 0x01, 0xFF, 0x00, 0x01, 0xAB, 0xCD, 0xEF, // Non-zero mode, reserved, event code + ]; + CommandClassFrame frame = new(data); + + (bool _, List groups) = + AssociationGroupInformationCommandClass.GroupInfoReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(1, groups); + Assert.AreEqual((byte)1, groups[0].GroupingIdentifier); + Assert.AreEqual((byte)0x00, groups[0].Profile.Category); + Assert.AreEqual((byte)0x01, groups[0].Profile.Identifier); + } +} diff --git a/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.GroupName.cs b/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.GroupName.cs new file mode 100644 index 0000000..e202192 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.GroupName.cs @@ -0,0 +1,112 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class AssociationGroupInformationCommandClassTests +{ + [TestMethod] + public void GroupNameGetCommand_Create_HasCorrectFormat() + { + AssociationGroupInformationCommandClass.GroupNameGetCommand command = + AssociationGroupInformationCommandClass.GroupNameGetCommand.Create(3); + + Assert.AreEqual( + CommandClassId.AssociationGroupInformation, + AssociationGroupInformationCommandClass.GroupNameGetCommand.CommandClassId); + Assert.AreEqual( + (byte)AssociationGroupInformationCommand.GroupNameGet, + AssociationGroupInformationCommandClass.GroupNameGetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); // CC + Cmd + GroupId + Assert.AreEqual((byte)3, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void GroupNameReport_Parse_ValidName() + { + // CC=0x59, Cmd=0x02, GroupId=1, NameLength=8, "Lifeline" + byte[] data = [0x59, 0x02, 0x01, 0x08, 0x4C, 0x69, 0x66, 0x65, 0x6C, 0x69, 0x6E, 0x65]; + CommandClassFrame frame = new(data); + + (byte groupingIdentifier, string name) = + AssociationGroupInformationCommandClass.GroupNameReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, groupingIdentifier); + Assert.AreEqual("Lifeline", name); + } + + [TestMethod] + public void GroupNameReport_Parse_EmptyName() + { + // CC=0x59, Cmd=0x02, GroupId=2, NameLength=0 + byte[] data = [0x59, 0x02, 0x02, 0x00]; + CommandClassFrame frame = new(data); + + (byte groupingIdentifier, string name) = + AssociationGroupInformationCommandClass.GroupNameReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)2, groupingIdentifier); + Assert.AreEqual(string.Empty, name); + } + + [TestMethod] + public void GroupNameReport_Parse_MaxLengthName() + { + // CC=0x59, Cmd=0x02, GroupId=1, NameLength=42, then 42 ASCII 'A' characters + byte[] data = new byte[4 + 42]; + data[0] = 0x59; + data[1] = 0x02; + data[2] = 0x01; + data[3] = 42; + for (int i = 0; i < 42; i++) + { + data[4 + i] = 0x41; // 'A' + } + + CommandClassFrame frame = new(data); + + (byte groupingIdentifier, string name) = + AssociationGroupInformationCommandClass.GroupNameReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, groupingIdentifier); + Assert.AreEqual(new string('A', 42), name); + } + + [TestMethod] + public void GroupNameReport_Parse_Utf8Characters() + { + // CC=0x59, Cmd=0x02, GroupId=1, NameLength=6, "Ménage" (UTF-8: 4D C3 A9 6E 61 67 65) + // Actually "Ménage" is 7 bytes in UTF-8. Let's use a simpler example: "café" = 63 61 66 C3 A9 = 5 bytes + byte[] data = [0x59, 0x02, 0x01, 0x05, 0x63, 0x61, 0x66, 0xC3, 0xA9]; + CommandClassFrame frame = new(data); + + (byte groupingIdentifier, string name) = + AssociationGroupInformationCommandClass.GroupNameReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, groupingIdentifier); + Assert.AreEqual("café", name); + } + + [TestMethod] + public void GroupNameReport_Parse_TooShort_Throws() + { + // CC=0x59, Cmd=0x02, only 1 parameter byte (need at least 2) + byte[] data = [0x59, 0x02, 0x01]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => AssociationGroupInformationCommandClass.GroupNameReportCommand.Parse( + frame, NullLogger.Instance)); + } + + [TestMethod] + public void GroupNameReport_Parse_TruncatedName_Throws() + { + // CC=0x59, Cmd=0x02, GroupId=1, NameLength=10, but only 3 name bytes + byte[] data = [0x59, 0x02, 0x01, 0x0A, 0x41, 0x42, 0x43]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => AssociationGroupInformationCommandClass.GroupNameReportCommand.Parse( + frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.cs new file mode 100644 index 0000000..c5a0827 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.cs @@ -0,0 +1,6 @@ +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public partial class AssociationGroupInformationCommandClassTests +{ +} diff --git a/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.CommandList.cs b/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.CommandList.cs new file mode 100644 index 0000000..eaf17c6 --- /dev/null +++ b/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.CommandList.cs @@ -0,0 +1,173 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a command (Command Class + Command ID pair) that an association group sends. +/// +/// +/// The command class identifier is a because extended command classes +/// use a 2-byte encoding (first byte 0xF1-0xFF followed by a second byte). Normal command +/// classes (0x20-0xEE) fit in a single byte. +/// +public readonly record struct AssociationGroupCommand( + /// + /// The command class identifier. Normal CCs are in the range 0x20-0xEE (single byte). + /// Extended CCs are in the range 0xF100-0xFFFF (two bytes). + /// + ushort CommandClassId, + + /// + /// The command identifier within the command class. + /// + byte CommandId); + +public sealed partial class AssociationGroupInformationCommandClass +{ + /// + /// Gets the cached command lists, keyed by grouping identifier. + /// + public IReadOnlyDictionary> CommandLists => _commandLists; + + private readonly Dictionary> _commandLists = []; + + /// + /// Request the commands that are sent via a given association group. + /// + /// The association group identifier (1-255). + /// A cancellation token. + /// The list of commands sent via this association group. + public async Task> GetCommandListAsync( + byte groupingIdentifier, + CancellationToken cancellationToken) + { + var command = CommandListGetCommand.Create(groupingIdentifier); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync( + frame => frame.CommandParameters.Length >= 1 && frame.CommandParameters.Span[0] == groupingIdentifier, + cancellationToken).ConfigureAwait(false); + (byte _, IReadOnlyList commands) = CommandListReportCommand.Parse(reportFrame, Logger); + _commandLists[groupingIdentifier] = commands; + return commands; + } + + internal readonly struct CommandListGetCommand : ICommand + { + public CommandListGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.AssociationGroupInformation; + + public static byte CommandId => (byte)AssociationGroupInformationCommand.CommandListGet; + + public CommandClassFrame Frame { get; } + + public static CommandListGetCommand Create(byte groupingIdentifier) + { + // Byte 0: [Allow Cache (1 bit)] [Reserved (7 bits)] + // Per spec CC:0059.01.05.12.001: A requesting node SHOULD allow caching. + byte flags = 0b1000_0000; + + ReadOnlySpan commandParameters = [flags, groupingIdentifier]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new CommandListGetCommand(frame); + } + } + + internal readonly struct CommandListReportCommand : ICommand + { + public CommandListReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.AssociationGroupInformation; + + public static byte CommandId => (byte)AssociationGroupInformationCommand.CommandListReport; + + public CommandClassFrame Frame { get; } + + /// + /// Parse an Association Group Command List Report frame. + /// + /// The grouping identifier and the list of commands. + public static (byte GroupingIdentifier, IReadOnlyList Commands) Parse( + CommandClassFrame frame, + ILogger logger) + { + // Minimum: GroupingIdentifier (1) + ListLength (1) = 2 bytes + if (frame.CommandParameters.Length < 2) + { + logger.LogWarning( + "Association Group Command List Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Association Group Command List Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + byte groupingIdentifier = span[0]; + byte listLength = span[1]; + + if (frame.CommandParameters.Length < 2 + listLength) + { + logger.LogWarning( + "Association Group Command List Report frame is too short for declared list length ({DeclaredLength} bytes, but only {Available} available)", + listLength, + frame.CommandParameters.Length - 2); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Association Group Command List Report frame is too short for declared list length"); + } + + List commands = []; + int offset = 2; + int endOffset = 2 + listLength; + + while (offset < endOffset) + { + byte ccByte = span[offset]; + + ushort ccId; + if (ccByte >= 0xF1) + { + // Extended command class: 2-byte CC ID + if (offset + 2 >= endOffset) + { + logger.LogWarning( + "Association Group Command List Report has truncated extended command class entry at offset {Offset}", + offset); + break; + } + + ccId = (ushort)((ccByte << 8) | span[offset + 1]); + offset += 2; + } + else + { + // Normal command class: 1-byte CC ID + ccId = ccByte; + offset += 1; + } + + if (offset >= endOffset) + { + logger.LogWarning( + "Association Group Command List Report has truncated command entry (missing command ID) at offset {Offset}", + offset); + break; + } + + byte commandId = span[offset]; + offset += 1; + + commands.Add(new AssociationGroupCommand(ccId, commandId)); + } + + return (groupingIdentifier, commands); + } + } +} diff --git a/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.GroupInfo.cs b/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.GroupInfo.cs new file mode 100644 index 0000000..7e06f64 --- /dev/null +++ b/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.GroupInfo.cs @@ -0,0 +1,274 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Well-known AGI profile categories. +/// +/// +/// Profile identifiers consist of a category byte (MSB) and a specific identifier byte (LSB). +/// +public enum AssociationGroupProfileCategory : byte +{ + /// + /// General profile category. LSB 0x00 = N/A, 0x01 = Lifeline. + /// + General = 0x00, + + /// + /// Control profile category. LSB indicates the key number (0x01-0x20). + /// + Control = 0x20, + + /// + /// Sensor profile category. LSB is the multilevel sensor type. + /// + Sensor = 0x31, + + /// + /// Meter profile category (v2+). LSB is the meter type. + /// + Meter = 0x32, + + /// + /// Irrigation profile category (v3+). LSB indicates the channel number (0x01-0x20). + /// + Irrigation = 0x6B, + + /// + /// Notification profile category. LSB is the notification type. + /// + Notification = 0x71, +} + +/// +/// Represents the profile of an association group. +/// +/// +/// The profile defines the scope of events which triggers the transmission of commands +/// to the actual association group. The profile consists of a 2-byte identifier: +/// the MSB identifies the profile category and the LSB identifies the specific profile +/// within the category. +/// +public readonly record struct AssociationGroupProfile( + /// + /// The profile category (MSB). + /// + byte Category, + + /// + /// The profile-specific identifier (LSB). + /// + byte Identifier); + +/// +/// Represents the properties of a single association group from a Group Info Report. +/// +public readonly record struct AssociationGroupInfo( + /// + /// The association group identifier. + /// + byte GroupingIdentifier, + + /// + /// The profile of this association group. + /// + AssociationGroupProfile Profile); + +public sealed partial class AssociationGroupInformationCommandClass +{ + /// + /// Gets the cached group info, keyed by grouping identifier. + /// + public IReadOnlyDictionary GroupInfo => _groupInfo; + + private readonly Dictionary _groupInfo = []; + + /// + /// Gets whether the AGI information is dynamic and may change at runtime. + /// + /// + /// If true, a controlling node should perform periodic cache refreshes. + /// If false, the information is static and should not be re-queried. + /// + public bool? IsDynamic { get; private set; } + + /// + /// Request the properties of all association groups using List Mode. + /// + /// + /// Uses List Mode to request all group properties at once. Per spec + /// CC:0059.01.04.13.001, the response may span multiple report frames. + /// Since the Group Info Report has no "reports to follow" field, the expected + /// group count is determined from the Association or Multi Channel Association CC + /// (whichever has been interviewed). If the expected count is not available, + /// only a single report frame is read. + /// + /// A cancellation token. + /// A list of all association group info entries. + public async Task> GetGroupInfoAsync(CancellationToken cancellationToken) + { + var command = GroupInfoGetCommand.Create(listMode: true, groupingIdentifier: 0); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + + byte expectedGroupCount = GetAssociationGroupCount(); + List allGroups = []; + bool dynamicInfo = false; + + do + { + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + (bool reportDynamicInfo, List groups) = GroupInfoReportCommand.Parse(reportFrame, Logger); + dynamicInfo |= reportDynamicInfo; + allGroups.AddRange(groups); + } + while (expectedGroupCount > 0 && allGroups.Count < expectedGroupCount); + + IsDynamic = dynamicInfo; + + foreach (AssociationGroupInfo info in allGroups) + { + _groupInfo[info.GroupingIdentifier] = info; + } + + return allGroups; + } + + /// + /// Request the properties of a single association group. + /// + /// The association group identifier (1-255). + /// A cancellation token. + /// The association group info for the specified group. + public async Task GetGroupInfoAsync(byte groupingIdentifier, CancellationToken cancellationToken) + { + var command = GroupInfoGetCommand.Create(listMode: false, groupingIdentifier: groupingIdentifier); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + + CommandClassFrame reportFrame = await AwaitNextReportAsync( + frame => + { + // Non-list-mode reports have Group Count = 1, and the first group entry's + // grouping identifier is at offset 1 in the command parameters. + return frame.CommandParameters.Length >= 2 + && frame.CommandParameters.Span[1] == groupingIdentifier; + }, + cancellationToken).ConfigureAwait(false); + (bool dynamicInfo, List groups) = GroupInfoReportCommand.Parse(reportFrame, Logger); + IsDynamic = dynamicInfo; + + if (groups.Count == 0) + { + Logger.LogWarning( + "Association Group Info Report for group {GroupId} contained no group entries", + groupingIdentifier); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Association Group Info Report contained no group entries"); + } + + AssociationGroupInfo info = groups[0]; + _groupInfo[info.GroupingIdentifier] = info; + return info; + } + + internal readonly struct GroupInfoGetCommand : ICommand + { + public GroupInfoGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.AssociationGroupInformation; + + public static byte CommandId => (byte)AssociationGroupInformationCommand.GroupInfoGet; + + public CommandClassFrame Frame { get; } + + public static GroupInfoGetCommand Create(bool listMode, byte groupingIdentifier) + { + // Byte 0: [Refresh Cache (1 bit)] [List Mode (1 bit)] [Reserved (6 bits)] + byte flags = 0; + if (listMode) + { + flags |= 0b0100_0000; + } + + ReadOnlySpan commandParameters = [flags, groupingIdentifier]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new GroupInfoGetCommand(frame); + } + } + + internal readonly struct GroupInfoReportCommand : ICommand + { + public GroupInfoReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.AssociationGroupInformation; + + public static byte CommandId => (byte)AssociationGroupInformationCommand.GroupInfoReport; + + public CommandClassFrame Frame { get; } + + /// + /// Parse an Association Group Info Report frame. + /// + /// The dynamic info flag and the list of group info entries. + public static (bool DynamicInfo, List Groups) Parse(CommandClassFrame frame, ILogger logger) + { + // Minimum: flags byte (1) + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning( + "Association Group Info Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Association Group Info Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + + // Byte 0: [List Mode (1 bit)] [Dynamic Info (1 bit)] [Group Count (6 bits)] + bool dynamicInfo = (span[0] & 0b0100_0000) != 0; + int groupCount = span[0] & 0b0011_1111; + + // Each group entry is 7 bytes: + // Grouping Identifier (1) + Mode (1) + Profile MSB (1) + Profile LSB (1) + // + Reserved (1) + Event Code MSB (1) + Event Code LSB (1) + const int GroupEntrySize = 7; + int requiredLength = 1 + (groupCount * GroupEntrySize); + if (frame.CommandParameters.Length < requiredLength) + { + logger.LogWarning( + "Association Group Info Report frame is too short for {GroupCount} groups (need {Required} bytes, have {Available})", + groupCount, + requiredLength, + frame.CommandParameters.Length); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Association Group Info Report frame is too short for declared group count"); + } + + List groups = new List(groupCount); + for (int i = 0; i < groupCount; i++) + { + int offset = 1 + (i * GroupEntrySize); + byte groupingIdentifier = span[offset]; + // Mode at offset+1 is reserved (ignored per spec CC:0059.01.04.11.008) + byte profileMsb = span[offset + 2]; + byte profileLsb = span[offset + 3]; + // Reserved at offset+4 (ignored per spec CC:0059.01.04.11.00A) + // Event Code at offset+5..6 (ignored per spec CC:0059.01.04.11.00B) + + AssociationGroupProfile profile = new(profileMsb, profileLsb); + groups.Add(new AssociationGroupInfo(groupingIdentifier, profile)); + } + + return (dynamicInfo, groups); + } + } +} diff --git a/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.GroupName.cs b/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.GroupName.cs new file mode 100644 index 0000000..ce426c3 --- /dev/null +++ b/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.GroupName.cs @@ -0,0 +1,106 @@ +using System.Text; +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class AssociationGroupInformationCommandClass +{ + /// + /// Gets the cached group names, keyed by grouping identifier. + /// + public IReadOnlyDictionary GroupNames => _groupNames; + + private readonly Dictionary _groupNames = []; + + /// + /// Request the name of an association group. + /// + /// The association group identifier (1-255). + /// A cancellation token. + /// The UTF-8 encoded name of the association group. + public async Task GetGroupNameAsync(byte groupingIdentifier, CancellationToken cancellationToken) + { + var command = GroupNameGetCommand.Create(groupingIdentifier); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync( + frame => frame.CommandParameters.Length >= 1 && frame.CommandParameters.Span[0] == groupingIdentifier, + cancellationToken).ConfigureAwait(false); + (byte _, string name) = GroupNameReportCommand.Parse(reportFrame, Logger); + _groupNames[groupingIdentifier] = name; + return name; + } + + internal readonly struct GroupNameGetCommand : ICommand + { + public GroupNameGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.AssociationGroupInformation; + + public static byte CommandId => (byte)AssociationGroupInformationCommand.GroupNameGet; + + public CommandClassFrame Frame { get; } + + public static GroupNameGetCommand Create(byte groupingIdentifier) + { + ReadOnlySpan commandParameters = [groupingIdentifier]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new GroupNameGetCommand(frame); + } + } + + internal readonly struct GroupNameReportCommand : ICommand + { + public GroupNameReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.AssociationGroupInformation; + + public static byte CommandId => (byte)AssociationGroupInformationCommand.GroupNameReport; + + public CommandClassFrame Frame { get; } + + /// + /// Parse an Association Group Name Report frame. + /// + /// The grouping identifier and the UTF-8 encoded group name. + public static (byte GroupingIdentifier, string Name) Parse(CommandClassFrame frame, ILogger logger) + { + // Minimum: GroupingIdentifier (1) + NameLength (1) = 2 bytes + if (frame.CommandParameters.Length < 2) + { + logger.LogWarning( + "Association Group Name Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Association Group Name Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + byte groupingIdentifier = span[0]; + byte nameLength = span[1]; + + if (frame.CommandParameters.Length < 2 + nameLength) + { + logger.LogWarning( + "Association Group Name Report frame is too short for declared name length ({DeclaredLength} bytes, but only {Available} available)", + nameLength, + frame.CommandParameters.Length - 2); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Association Group Name Report frame is too short for declared name length"); + } + + string name = nameLength > 0 + ? Encoding.UTF8.GetString(span.Slice(2, nameLength)) + : string.Empty; + + return (groupingIdentifier, name); + } + } +} diff --git a/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.cs b/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.cs new file mode 100644 index 0000000..382f25c --- /dev/null +++ b/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.cs @@ -0,0 +1,144 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Commands for the Association Group Information Command Class. +/// +public enum AssociationGroupInformationCommand : byte +{ + /// + /// Request the name of an association group. + /// + GroupNameGet = 0x01, + + /// + /// Advertise the assigned name of an association group. + /// + GroupNameReport = 0x02, + + /// + /// Request the properties of one or more association groups. + /// + GroupInfoGet = 0x03, + + /// + /// Advertise the properties of one or more association groups. + /// + GroupInfoReport = 0x04, + + /// + /// Request the commands that are sent via a given association group. + /// + CommandListGet = 0x05, + + /// + /// Advertise the commands that are sent via an association group. + /// + CommandListReport = 0x06, +} + +/// +/// Implementation of the Association Group Information (AGI) Command Class (CC:0059, versions 1-3). +/// +/// +/// The AGI Command Class allows a node to advertise the capabilities of each association group +/// supported by a given application resource, including the group name, profile, and the commands +/// that are sent via each group. +/// +[CommandClass(CommandClassId.AssociationGroupInformation)] +public sealed partial class AssociationGroupInformationCommandClass + : CommandClass +{ + // Per spec CC:0059.01.00.21.001, a node supporting AGI MUST support Association CC. + // We depend on Association so its SupportedGroupings is available for our interview. + // We do NOT depend on Multi Channel Association (it may not be present), but we + // check it first in GetAssociationGroupCount since it takes priority when present. + private static readonly CommandClassId[] CcDependencies = + [ + CommandClassId.Version, + CommandClassId.Association, + ]; + + internal AssociationGroupInformationCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + internal override CommandClassCategory Category => CommandClassCategory.Management; + + internal override CommandClassId[] Dependencies => CcDependencies; + + /// + public override bool? IsCommandSupported(AssociationGroupInformationCommand command) + => command switch + { + AssociationGroupInformationCommand.GroupNameGet => true, + AssociationGroupInformationCommand.GroupInfoGet => true, + AssociationGroupInformationCommand.CommandListGet => true, + _ => false, + }; + + internal override async Task InterviewAsync(CancellationToken cancellationToken) + { + byte groupCount = GetAssociationGroupCount(); + if (groupCount == 0) + { + return; + } + + // Query each group individually for name, info, and command list. + // This avoids the multi-report aggregation problem with List Mode + // (the Group Info Report has no "reports to follow" field). + for (byte groupId = 1; groupId <= groupCount; groupId++) + { + _ = await GetGroupNameAsync(groupId, cancellationToken).ConfigureAwait(false); + _ = await GetGroupInfoAsync(groupId, cancellationToken).ConfigureAwait(false); + _ = await GetCommandListAsync(groupId, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Gets the number of association groups from the Association or Multi Channel Association CC. + /// + /// + /// Multi Channel Association takes priority over Association per CL:0085.01.51.01.1. + /// Falls back to Association CC (guaranteed present per CC:0059.01.00.21.001). + /// + private byte GetAssociationGroupCount() + { + // Check Multi Channel Association first (takes priority when present). + if (Endpoint.CommandClasses.ContainsKey(CommandClassId.MultiChannelAssociation)) + { + MultiChannelAssociationCommandClass mcAssocCC = + (MultiChannelAssociationCommandClass)Endpoint.GetCommandClass(CommandClassId.MultiChannelAssociation); + if (mcAssocCC.SupportedGroupings.HasValue) + { + return mcAssocCC.SupportedGroupings.Value; + } + } + + // Fall back to Association CC. + if (Endpoint.CommandClasses.ContainsKey(CommandClassId.Association)) + { + AssociationCommandClass assocCC = + (AssociationCommandClass)Endpoint.GetCommandClass(CommandClassId.Association); + if (assocCC.SupportedGroupings.HasValue) + { + return assocCC.SupportedGroupings.Value; + } + } + + return 0; + } + + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + // All AGI reports are only sent in response to Get commands (per spec). + // There are no unsolicited commands to handle. + } +}