From ac05f463e4b25847aab57be848d85c9c7448eff2 Mon Sep 17 00:00:00 2001 From: David Federman Date: Sun, 1 Mar 2026 19:53:02 -0800 Subject: [PATCH] Implement Association CC --- .../AssociationCommandClassTests.Modify.cs | 112 +++++++++++++ .../AssociationCommandClassTests.Report.cs | 140 ++++++++++++++++ ...ociationCommandClassTests.SpecificGroup.cs | 72 +++++++++ ...ionCommandClassTests.SupportedGroupings.cs | 72 +++++++++ .../AssociationCommandClassTests.cs | 6 + .../AssociationCommandClass.Modify.cs | 100 ++++++++++++ .../AssociationCommandClass.Report.cs | 149 ++++++++++++++++++ .../AssociationCommandClass.SpecificGroup.cs | 76 +++++++++ ...ociationCommandClass.SupportedGroupings.cs | 73 +++++++++ .../AssociationCommandClass.cs | 100 ++++++++++++ 10 files changed, 900 insertions(+) create mode 100644 src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.Modify.cs create mode 100644 src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.Report.cs create mode 100644 src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SpecificGroup.cs create mode 100644 src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SupportedGroupings.cs create mode 100644 src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.cs create mode 100644 src/ZWave.CommandClasses/AssociationCommandClass.Modify.cs create mode 100644 src/ZWave.CommandClasses/AssociationCommandClass.Report.cs create mode 100644 src/ZWave.CommandClasses/AssociationCommandClass.SpecificGroup.cs create mode 100644 src/ZWave.CommandClasses/AssociationCommandClass.SupportedGroupings.cs create mode 100644 src/ZWave.CommandClasses/AssociationCommandClass.cs diff --git a/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.Modify.cs b/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.Modify.cs new file mode 100644 index 0000000..139bec2 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.Modify.cs @@ -0,0 +1,112 @@ +namespace ZWave.CommandClasses.Tests; + +public partial class AssociationCommandClassTests +{ + [TestMethod] + public void SetCommand_Create_WithNodeIds() + { + AssociationCommandClass.AssociationSetCommand command = + AssociationCommandClass.AssociationSetCommand.Create(1, new byte[] { 2, 3 }); + + Assert.AreEqual(CommandClassId.Association, AssociationCommandClass.AssociationSetCommand.CommandClassId); + Assert.AreEqual((byte)AssociationCommand.Set, AssociationCommandClass.AssociationSetCommand.CommandId); + + // CC + Cmd + GroupId + NodeID(2) + NodeID(3) = 5 bytes + Assert.AreEqual(5, command.Frame.Data.Length); + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)1, parameters[0]); // GroupId + Assert.AreEqual((byte)2, parameters[1]); // NodeID 1 + Assert.AreEqual((byte)3, parameters[2]); // NodeID 2 + } + + [TestMethod] + public void SetCommand_Create_NoNodeIds() + { + AssociationCommandClass.AssociationSetCommand command = + AssociationCommandClass.AssociationSetCommand.Create(1, Array.Empty()); + + // CC + Cmd + GroupId = 3 bytes + Assert.AreEqual(3, command.Frame.Data.Length); + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)1, parameters[0]); // GroupId + } + + [TestMethod] + public void SetCommand_Create_SingleNodeId() + { + AssociationCommandClass.AssociationSetCommand command = + AssociationCommandClass.AssociationSetCommand.Create(5, new byte[] { 10 }); + + // CC + Cmd + GroupId + NodeID = 4 bytes + Assert.AreEqual(4, command.Frame.Data.Length); + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)5, parameters[0]); // GroupId + Assert.AreEqual((byte)10, parameters[1]); // NodeID + } + + [TestMethod] + public void RemoveCommand_Create_SpecificNodeIdFromGroup() + { + AssociationCommandClass.AssociationRemoveCommand command = + AssociationCommandClass.AssociationRemoveCommand.Create(3, new byte[] { 5 }); + + Assert.AreEqual(CommandClassId.Association, AssociationCommandClass.AssociationRemoveCommand.CommandClassId); + Assert.AreEqual((byte)AssociationCommand.Remove, AssociationCommandClass.AssociationRemoveCommand.CommandId); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)3, parameters[0]); // GroupId + Assert.AreEqual((byte)5, parameters[1]); // NodeID + } + + [TestMethod] + public void RemoveCommand_Create_AllFromGroup() + { + // GroupId > 0, no NodeIDs → remove all from group + AssociationCommandClass.AssociationRemoveCommand command = + AssociationCommandClass.AssociationRemoveCommand.Create(3, Array.Empty()); + + // CC + Cmd + GroupId = 3 bytes + Assert.AreEqual(3, command.Frame.Data.Length); + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)3, parameters[0]); // GroupId + } + + [TestMethod] + public void RemoveCommand_Create_AllFromAllGroups_V2() + { + // GroupId = 0, no NodeIDs → remove all from all groups (V2+) + AssociationCommandClass.AssociationRemoveCommand command = + AssociationCommandClass.AssociationRemoveCommand.Create(0, Array.Empty()); + + Assert.AreEqual(3, command.Frame.Data.Length); + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)0, parameters[0]); // GroupId = 0 + } + + [TestMethod] + public void RemoveCommand_Create_SpecificNodeIdFromAllGroups_V2() + { + // GroupId = 0, with NodeIDs → remove from all groups (V2+) + AssociationCommandClass.AssociationRemoveCommand command = + AssociationCommandClass.AssociationRemoveCommand.Create(0, new byte[] { 7 }); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)0, parameters[0]); // GroupId = 0 + Assert.AreEqual((byte)7, parameters[1]); // NodeID + } + + [TestMethod] + public void RemoveCommand_Create_MultipleNodeIdsFromGroup() + { + AssociationCommandClass.AssociationRemoveCommand command = + AssociationCommandClass.AssociationRemoveCommand.Create(2, new byte[] { 3, 4, 5 }); + + // CC + Cmd + GroupId + 3 NodeIDs = 6 bytes + Assert.AreEqual(6, command.Frame.Data.Length); + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)2, parameters[0]); // GroupId + Assert.AreEqual((byte)3, parameters[1]); // NodeID 1 + Assert.AreEqual((byte)4, parameters[2]); // NodeID 2 + Assert.AreEqual((byte)5, parameters[3]); // NodeID 3 + } +} diff --git a/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.Report.cs b/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.Report.cs new file mode 100644 index 0000000..da3609b --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.Report.cs @@ -0,0 +1,140 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class AssociationCommandClassTests +{ + [TestMethod] + public void GetCommand_Create_HasCorrectFormat() + { + AssociationCommandClass.AssociationGetCommand command = + AssociationCommandClass.AssociationGetCommand.Create(3); + + Assert.AreEqual(CommandClassId.Association, AssociationCommandClass.AssociationGetCommand.CommandClassId); + Assert.AreEqual((byte)AssociationCommand.Get, AssociationCommandClass.AssociationGetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); // CC + Cmd + GroupId + Assert.AreEqual((byte)3, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void Report_ParseInto_NodeIdDestinations() + { + // CC=0x85, Cmd=0x03, GroupId=1, MaxNodes=5, ReportsToFollow=0, NodeID=2, NodeID=3 + byte[] data = [0x85, 0x03, 0x01, 0x05, 0x00, 0x02, 0x03]; + CommandClassFrame frame = new(data); + + List nodeIdDestinations = []; + (byte maxNodesSupported, byte reportsToFollow) = + AssociationCommandClass.AssociationReportCommand.ParseInto( + frame, nodeIdDestinations, NullLogger.Instance); + + Assert.AreEqual((byte)5, maxNodesSupported); + Assert.AreEqual((byte)0, reportsToFollow); + Assert.HasCount(2, nodeIdDestinations); + Assert.AreEqual((byte)2, nodeIdDestinations[0]); + Assert.AreEqual((byte)3, nodeIdDestinations[1]); + } + + [TestMethod] + public void Report_ParseInto_EmptyDestinations() + { + // CC=0x85, Cmd=0x03, GroupId=1, MaxNodes=5, ReportsToFollow=0, no destinations + byte[] data = [0x85, 0x03, 0x01, 0x05, 0x00]; + CommandClassFrame frame = new(data); + + List nodeIdDestinations = []; + (byte maxNodesSupported, byte reportsToFollow) = + AssociationCommandClass.AssociationReportCommand.ParseInto( + frame, nodeIdDestinations, NullLogger.Instance); + + Assert.AreEqual((byte)5, maxNodesSupported); + Assert.AreEqual((byte)0, reportsToFollow); + Assert.IsEmpty(nodeIdDestinations); + } + + [TestMethod] + public void Report_ParseInto_ReportsToFollow() + { + // CC=0x85, Cmd=0x03, GroupId=1, MaxNodes=20, ReportsToFollow=2, NodeID=1 + byte[] data = [0x85, 0x03, 0x01, 0x14, 0x02, 0x01]; + CommandClassFrame frame = new(data); + + List nodeIdDestinations = []; + (byte maxNodesSupported, byte reportsToFollow) = + AssociationCommandClass.AssociationReportCommand.ParseInto( + frame, nodeIdDestinations, NullLogger.Instance); + + Assert.AreEqual((byte)20, maxNodesSupported); + Assert.AreEqual((byte)2, reportsToFollow); + Assert.HasCount(1, nodeIdDestinations); + Assert.AreEqual((byte)1, nodeIdDestinations[0]); + } + + [TestMethod] + public void Report_ParseInto_TooShort_Throws() + { + // Only 2 parameter bytes, need at least 3 (GroupId + MaxNodes + ReportsToFollow) + byte[] data = [0x85, 0x03, 0x01, 0x05]; + CommandClassFrame frame = new(data); + + List nodeIdDestinations = []; + Assert.Throws( + () => AssociationCommandClass.AssociationReportCommand.ParseInto( + frame, nodeIdDestinations, NullLogger.Instance)); + } + + [TestMethod] + public void Report_ParseInto_MultiFrameAggregation() + { + // Frame 1: GroupId=1, MaxNodes=20, ReportsToFollow=1, NodeID=1, NodeID=2 + byte[] data1 = [0x85, 0x03, 0x01, 0x14, 0x01, 0x01, 0x02]; + CommandClassFrame frame1 = new(data1); + + // Frame 2: GroupId=1, MaxNodes=20, ReportsToFollow=0, NodeID=3 + byte[] data2 = [0x85, 0x03, 0x01, 0x14, 0x00, 0x03]; + CommandClassFrame frame2 = new(data2); + + List allNodeIdDestinations = []; + + (byte maxNodesSupported1, byte reportsToFollow1) = + AssociationCommandClass.AssociationReportCommand.ParseInto( + frame1, allNodeIdDestinations, NullLogger.Instance); + + Assert.AreEqual((byte)20, maxNodesSupported1); + Assert.AreEqual((byte)1, reportsToFollow1); + + (byte maxNodesSupported2, byte reportsToFollow2) = + AssociationCommandClass.AssociationReportCommand.ParseInto( + frame2, allNodeIdDestinations, NullLogger.Instance); + + Assert.AreEqual((byte)20, maxNodesSupported2); + Assert.AreEqual((byte)0, reportsToFollow2); + + // Combined result: 3 NodeID destinations + Assert.HasCount(3, allNodeIdDestinations); + Assert.AreEqual((byte)1, allNodeIdDestinations[0]); + Assert.AreEqual((byte)2, allNodeIdDestinations[1]); + Assert.AreEqual((byte)3, allNodeIdDestinations[2]); + } + + [TestMethod] + public void Report_ParseInto_ManyNodes() + { + // CC=0x85, Cmd=0x03, GroupId=1, MaxNodes=10, ReportsToFollow=0, NodeID=1..5 + byte[] data = [0x85, 0x03, 0x01, 0x0A, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05]; + CommandClassFrame frame = new(data); + + List nodeIdDestinations = []; + (byte maxNodesSupported, byte reportsToFollow) = + AssociationCommandClass.AssociationReportCommand.ParseInto( + frame, nodeIdDestinations, NullLogger.Instance); + + Assert.AreEqual((byte)10, maxNodesSupported); + Assert.AreEqual((byte)0, reportsToFollow); + Assert.HasCount(5, nodeIdDestinations); + for (int i = 0; i < 5; i++) + { + Assert.AreEqual((byte)(i + 1), nodeIdDestinations[i]); + } + } +} diff --git a/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SpecificGroup.cs b/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SpecificGroup.cs new file mode 100644 index 0000000..1554b66 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SpecificGroup.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class AssociationCommandClassTests +{ + [TestMethod] + public void SpecificGroupGetCommand_Create_HasCorrectFormat() + { + AssociationCommandClass.AssociationSpecificGroupGetCommand command = + AssociationCommandClass.AssociationSpecificGroupGetCommand.Create(); + + Assert.AreEqual( + CommandClassId.Association, + AssociationCommandClass.AssociationSpecificGroupGetCommand.CommandClassId); + Assert.AreEqual( + (byte)AssociationCommand.SpecificGroupGet, + AssociationCommandClass.AssociationSpecificGroupGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); // CC + Cmd only + } + + [TestMethod] + public void SpecificGroupReport_Parse_ValidGroup() + { + // CC=0x85, Cmd=0x0C, Group=3 + byte[] data = [0x85, 0x0C, 0x03]; + CommandClassFrame frame = new(data); + + byte group = AssociationCommandClass.AssociationSpecificGroupReportCommand.Parse( + frame, NullLogger.Instance); + + Assert.AreEqual((byte)3, group); + } + + [TestMethod] + public void SpecificGroupReport_Parse_NotSupported() + { + // CC=0x85, Cmd=0x0C, Group=0 (not supported or no recent button) + byte[] data = [0x85, 0x0C, 0x00]; + CommandClassFrame frame = new(data); + + byte group = AssociationCommandClass.AssociationSpecificGroupReportCommand.Parse( + frame, NullLogger.Instance); + + Assert.AreEqual((byte)0, group); + } + + [TestMethod] + public void SpecificGroupReport_Parse_MaxGroup() + { + // CC=0x85, Cmd=0x0C, Group=255 + byte[] data = [0x85, 0x0C, 0xFF]; + CommandClassFrame frame = new(data); + + byte group = AssociationCommandClass.AssociationSpecificGroupReportCommand.Parse( + frame, NullLogger.Instance); + + Assert.AreEqual((byte)255, group); + } + + [TestMethod] + public void SpecificGroupReport_Parse_TooShort_Throws() + { + // CC=0x85, Cmd=0x0C, no parameters + byte[] data = [0x85, 0x0C]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => AssociationCommandClass.AssociationSpecificGroupReportCommand.Parse( + frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SupportedGroupings.cs b/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SupportedGroupings.cs new file mode 100644 index 0000000..d5c1ce4 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SupportedGroupings.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class AssociationCommandClassTests +{ + [TestMethod] + public void SupportedGroupingsGetCommand_Create_HasCorrectFormat() + { + AssociationCommandClass.AssociationSupportedGroupingsGetCommand command = + AssociationCommandClass.AssociationSupportedGroupingsGetCommand.Create(); + + Assert.AreEqual( + CommandClassId.Association, + AssociationCommandClass.AssociationSupportedGroupingsGetCommand.CommandClassId); + Assert.AreEqual( + (byte)AssociationCommand.SupportedGroupingsGet, + AssociationCommandClass.AssociationSupportedGroupingsGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); // CC + Cmd only + } + + [TestMethod] + public void SupportedGroupingsReport_Parse_ValidFrame() + { + // CC=0x85, Cmd=0x06, SupportedGroupings=5 + byte[] data = [0x85, 0x06, 0x05]; + CommandClassFrame frame = new(data); + + byte groupings = AssociationCommandClass.AssociationSupportedGroupingsReportCommand.Parse( + frame, NullLogger.Instance); + + Assert.AreEqual((byte)5, groupings); + } + + [TestMethod] + public void SupportedGroupingsReport_Parse_SingleGroup() + { + // CC=0x85, Cmd=0x06, SupportedGroupings=1 + byte[] data = [0x85, 0x06, 0x01]; + CommandClassFrame frame = new(data); + + byte groupings = AssociationCommandClass.AssociationSupportedGroupingsReportCommand.Parse( + frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, groupings); + } + + [TestMethod] + public void SupportedGroupingsReport_Parse_MaxGroups() + { + // CC=0x85, Cmd=0x06, SupportedGroupings=255 + byte[] data = [0x85, 0x06, 0xFF]; + CommandClassFrame frame = new(data); + + byte groupings = AssociationCommandClass.AssociationSupportedGroupingsReportCommand.Parse( + frame, NullLogger.Instance); + + Assert.AreEqual((byte)255, groupings); + } + + [TestMethod] + public void SupportedGroupingsReport_Parse_TooShort_Throws() + { + // CC=0x85, Cmd=0x06, no parameters + byte[] data = [0x85, 0x06]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => AssociationCommandClass.AssociationSupportedGroupingsReportCommand.Parse( + frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.cs new file mode 100644 index 0000000..72ff76d --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.cs @@ -0,0 +1,6 @@ +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public partial class AssociationCommandClassTests +{ +} diff --git a/src/ZWave.CommandClasses/AssociationCommandClass.Modify.cs b/src/ZWave.CommandClasses/AssociationCommandClass.Modify.cs new file mode 100644 index 0000000..a9594b9 --- /dev/null +++ b/src/ZWave.CommandClasses/AssociationCommandClass.Modify.cs @@ -0,0 +1,100 @@ +namespace ZWave.CommandClasses; + +public sealed partial class AssociationCommandClass +{ + /// + /// Add NodeID destinations to a given association group. + /// + /// The association group identifier (1-255). + /// NodeID destinations to add. + /// A cancellation token. + public async Task SetAsync( + byte groupingIdentifier, + IReadOnlyList nodeIdDestinations, + CancellationToken cancellationToken) + { + AssociationSetCommand command = AssociationSetCommand.Create(groupingIdentifier, nodeIdDestinations); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// + /// Remove NodeID destinations from a given association group. + /// + /// + /// + /// Per the spec, the combination of and + /// determines the behavior: + /// + /// + /// GroupId > 0, with NodeIDs: Remove specified NodeIDs from the group. + /// GroupId > 0, no NodeIDs: Remove all NodeIDs from the group. + /// GroupId = 0, with NodeIDs: Remove specified NodeIDs from all groups (V2+). + /// GroupId = 0, no NodeIDs: Remove all NodeIDs from all groups (V2+). + /// + /// + /// The association group identifier, or 0 to target all groups (V2+). + /// NodeID destinations to remove. + /// A cancellation token. + public async Task RemoveAsync( + byte groupingIdentifier, + IReadOnlyList nodeIdDestinations, + CancellationToken cancellationToken) + { + AssociationRemoveCommand command = AssociationRemoveCommand.Create(groupingIdentifier, nodeIdDestinations); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + internal readonly struct AssociationSetCommand : ICommand + { + public AssociationSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Association; + + public static byte CommandId => (byte)AssociationCommand.Set; + + public CommandClassFrame Frame { get; } + + public static AssociationSetCommand Create(byte groupingIdentifier, IReadOnlyList nodeIdDestinations) + { + Span commandParameters = stackalloc byte[1 + nodeIdDestinations.Count]; + commandParameters[0] = groupingIdentifier; + for (int i = 0; i < nodeIdDestinations.Count; i++) + { + commandParameters[1 + i] = nodeIdDestinations[i]; + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new AssociationSetCommand(frame); + } + } + + internal readonly struct AssociationRemoveCommand : ICommand + { + public AssociationRemoveCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Association; + + public static byte CommandId => (byte)AssociationCommand.Remove; + + public CommandClassFrame Frame { get; } + + public static AssociationRemoveCommand Create(byte groupingIdentifier, IReadOnlyList nodeIdDestinations) + { + Span commandParameters = stackalloc byte[1 + nodeIdDestinations.Count]; + commandParameters[0] = groupingIdentifier; + for (int i = 0; i < nodeIdDestinations.Count; i++) + { + commandParameters[1 + i] = nodeIdDestinations[i]; + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new AssociationRemoveCommand(frame); + } + } +} diff --git a/src/ZWave.CommandClasses/AssociationCommandClass.Report.cs b/src/ZWave.CommandClasses/AssociationCommandClass.Report.cs new file mode 100644 index 0000000..1189f08 --- /dev/null +++ b/src/ZWave.CommandClasses/AssociationCommandClass.Report.cs @@ -0,0 +1,149 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents the data from an Association Report command. +/// +public readonly record struct AssociationReport( + /// + /// The association group identifier. + /// + byte GroupingIdentifier, + + /// + /// The maximum number of destinations supported by this association group. + /// Each destination may be a NodeID destination or an End Point destination + /// (if the node supports the Multi Channel Association Command Class). + /// + byte MaxNodesSupported, + + /// + /// The NodeID destinations in this association group. + /// + IReadOnlyList NodeIdDestinations); + +public sealed partial class AssociationCommandClass +{ + /// + /// Event raised when an Association Report is received, both solicited and unsolicited. + /// + public event Action? OnReportReceived; + + /// + /// Gets the last report received for each association group. + /// + public IReadOnlyDictionary GroupReports => _groupReports; + + private readonly Dictionary _groupReports = []; + + private void UpdateGroupReport(AssociationReport report) + { + _groupReports[report.GroupingIdentifier] = report; + } + + /// + /// Request the current destinations of a given association group. + /// + /// + /// The report may span multiple frames if the destination list is large. + /// This method aggregates all frames before returning. + /// + /// The association group identifier (1-255). + /// A cancellation token. + /// The association report for the given group. + public async Task GetAsync(byte groupingIdentifier, CancellationToken cancellationToken) + { + AssociationGetCommand command = AssociationGetCommand.Create(groupingIdentifier); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + + List allNodeIdDestinations = []; + byte maxNodesSupported = 0; + + byte reportsToFollow; + do + { + CommandClassFrame reportFrame = await AwaitNextReportAsync( + frame => frame.CommandParameters.Length >= 1 && frame.CommandParameters.Span[0] == groupingIdentifier, + cancellationToken).ConfigureAwait(false); + (maxNodesSupported, reportsToFollow) = AssociationReportCommand.ParseInto( + reportFrame, allNodeIdDestinations, Logger); + } + while (reportsToFollow > 0); + + AssociationReport report = new( + groupingIdentifier, + maxNodesSupported, + allNodeIdDestinations); + UpdateGroupReport(report); + OnReportReceived?.Invoke(report); + return report; + } + + internal readonly struct AssociationGetCommand : ICommand + { + public AssociationGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Association; + + public static byte CommandId => (byte)AssociationCommand.Get; + + public CommandClassFrame Frame { get; } + + public static AssociationGetCommand Create(byte groupingIdentifier) + { + Span commandParameters = [groupingIdentifier]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new AssociationGetCommand(frame); + } + } + + internal readonly struct AssociationReportCommand : ICommand + { + public AssociationReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Association; + + public static byte CommandId => (byte)AssociationCommand.Report; + + public CommandClassFrame Frame { get; } + + /// + /// Parse a single Association Report frame, appending NodeID destinations to the provided list. + /// + /// The max nodes supported and reports-to-follow count from this frame. + public static (byte MaxNodesSupported, byte ReportsToFollow) ParseInto( + CommandClassFrame frame, + List nodeIdDestinations, + ILogger logger) + { + if (frame.CommandParameters.Length < 3) + { + logger.LogWarning( + "Association Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Association Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + byte maxNodesSupported = span[1]; + byte reportsToFollow = span[2]; + ReadOnlySpan destinationData = span[3..]; + + for (int i = 0; i < destinationData.Length; i++) + { + nodeIdDestinations.Add(destinationData[i]); + } + + return (maxNodesSupported, reportsToFollow); + } + } +} diff --git a/src/ZWave.CommandClasses/AssociationCommandClass.SpecificGroup.cs b/src/ZWave.CommandClasses/AssociationCommandClass.SpecificGroup.cs new file mode 100644 index 0000000..fc464d8 --- /dev/null +++ b/src/ZWave.CommandClasses/AssociationCommandClass.SpecificGroup.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class AssociationCommandClass +{ + /// + /// Request the association group representing the most recently detected button. + /// + /// + /// This command is available in version 2 and later. It allows a portable controller + /// to interactively create associations from a multi-button device to a destination + /// that is out of direct range. + /// A group value of 0 indicates the functionality is not supported or the most recent + /// button event does not map to an association group. + /// + /// A cancellation token. + /// The association group identifier for the most recently detected button, or 0 if not supported. + public async Task GetSpecificGroupAsync(CancellationToken cancellationToken) + { + AssociationSpecificGroupGetCommand command = AssociationSpecificGroupGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + byte group = AssociationSpecificGroupReportCommand.Parse(reportFrame, Logger); + return group; + } + + internal readonly struct AssociationSpecificGroupGetCommand : ICommand + { + public AssociationSpecificGroupGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Association; + + public static byte CommandId => (byte)AssociationCommand.SpecificGroupGet; + + public CommandClassFrame Frame { get; } + + public static AssociationSpecificGroupGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new AssociationSpecificGroupGetCommand(frame); + } + } + + internal readonly struct AssociationSpecificGroupReportCommand : ICommand + { + public AssociationSpecificGroupReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Association; + + public static byte CommandId => (byte)AssociationCommand.SpecificGroupReport; + + public CommandClassFrame Frame { get; } + + public static byte Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning( + "Association Specific Group Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Association Specific Group Report frame is too short"); + } + + return frame.CommandParameters.Span[0]; + } + } +} diff --git a/src/ZWave.CommandClasses/AssociationCommandClass.SupportedGroupings.cs b/src/ZWave.CommandClasses/AssociationCommandClass.SupportedGroupings.cs new file mode 100644 index 0000000..08a9f53 --- /dev/null +++ b/src/ZWave.CommandClasses/AssociationCommandClass.SupportedGroupings.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class AssociationCommandClass +{ + /// + /// Gets the number of association groups supported by this node, or null if not yet queried. + /// + public byte? SupportedGroupings { get; private set; } + + /// + /// Request the number of association groups that this node supports. + /// + public async Task GetSupportedGroupingsAsync(CancellationToken cancellationToken) + { + AssociationSupportedGroupingsGetCommand command = AssociationSupportedGroupingsGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + byte groupings = AssociationSupportedGroupingsReportCommand.Parse(reportFrame, Logger); + SupportedGroupings = groupings; + return groupings; + } + + internal readonly struct AssociationSupportedGroupingsGetCommand : ICommand + { + public AssociationSupportedGroupingsGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Association; + + public static byte CommandId => (byte)AssociationCommand.SupportedGroupingsGet; + + public CommandClassFrame Frame { get; } + + public static AssociationSupportedGroupingsGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new AssociationSupportedGroupingsGetCommand(frame); + } + } + + internal readonly struct AssociationSupportedGroupingsReportCommand : ICommand + { + public AssociationSupportedGroupingsReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Association; + + public static byte CommandId => (byte)AssociationCommand.SupportedGroupingsReport; + + public CommandClassFrame Frame { get; } + + public static byte Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning( + "Association Supported Groupings Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Association Supported Groupings Report frame is too short"); + } + + return frame.CommandParameters.Span[0]; + } + } +} diff --git a/src/ZWave.CommandClasses/AssociationCommandClass.cs b/src/ZWave.CommandClasses/AssociationCommandClass.cs new file mode 100644 index 0000000..3c07573 --- /dev/null +++ b/src/ZWave.CommandClasses/AssociationCommandClass.cs @@ -0,0 +1,100 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Commands for the Association Command Class. +/// +public enum AssociationCommand : byte +{ + /// + /// Add NodeID destinations to a given association group. + /// + Set = 0x01, + + /// + /// Request the current destinations of a given association group. + /// + Get = 0x02, + + /// + /// Advertise the current destinations of a given association group. + /// + Report = 0x03, + + /// + /// Remove NodeID destinations from a given association group. + /// + Remove = 0x04, + + /// + /// Request the number of association groups that this node supports. + /// + SupportedGroupingsGet = 0x05, + + /// + /// Advertise the maximum number of association groups implemented by this node. + /// + SupportedGroupingsReport = 0x06, + + /// + /// Request the association group representing the most recently detected button. + /// + SpecificGroupGet = 0x0B, + + /// + /// Advertise the association group representing the most recently detected button. + /// + SpecificGroupReport = 0x0C, +} + +/// +/// Implementation of the Association Command Class (CC:0085, versions 1-4). +/// +/// +/// The Association Command Class is used to manage associations to NodeID destinations. +/// A NodeID destination may be a simple device or the Root Device of a Multi Channel device. +/// +[CommandClass(CommandClassId.Association)] +public sealed partial class AssociationCommandClass : CommandClass +{ + internal AssociationCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + internal override CommandClassCategory Category => CommandClassCategory.Management; + + /// + public override bool? IsCommandSupported(AssociationCommand command) + => command switch + { + AssociationCommand.Set => true, + AssociationCommand.Get => true, + AssociationCommand.Remove => true, + AssociationCommand.SupportedGroupingsGet => true, + AssociationCommand.SpecificGroupGet => Version.HasValue ? Version >= 2 : null, + _ => false, + }; + + internal override async Task InterviewAsync(CancellationToken cancellationToken) + { + byte supportedGroupings = await GetSupportedGroupingsAsync(cancellationToken).ConfigureAwait(false); + + for (byte groupId = 1; groupId <= supportedGroupings; groupId++) + { + _ = await GetAsync(groupId, cancellationToken).ConfigureAwait(false); + } + } + + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + // Association Reports and Supported Groupings Reports are only sent in response + // to Get commands (per spec CC:0085.01.02.11.001 and CC:0085.01.05.11.001), + // so there are no unsolicited commands to handle. + } +}