diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e203a2c..3e02138 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -78,20 +78,20 @@ Implements the Z-Wave Serial API frame-level protocol (as defined by the Z-Wave Implements Z-Wave Command Classes (Z-Wave Application Specification). This project references `ZWave.Protocol` but **not** `ZWave.Serial`, enabling mock driver implementations without a serial dependency. - **`IEndpoint`** — Interface representing a functional sub-unit of a node. Properties: `NodeId`, `EndpointIndex`, `CommandClasses`, `GetCommandClass()`. Endpoint 0 is the "Root Device" (the node itself); endpoints 1–127 are sub-devices discovered via Multi Channel CC. - **`INode`** — Extends `IEndpoint` with node-level properties (`FrequentListeningMode`). A node IS endpoint 0. -- **`IDriver`** — Interface abstracting the driver layer. `SendCommandAsync` accepts `nodeId` and `endpointIndex` parameters. +- **`IDriver`** — Interface abstracting the driver layer. `SendCommandAsync` accepts `nodeId` and `endpointIndex` parameters. The concrete `Driver` implementation automatically applies Multi Channel encapsulation when `endpointIndex > 0` and de-encapsulates incoming Multi Channel frames, so command classes send/receive plain (non-encapsulated) frames regardless of endpoint. - **`CommandClass`** / **`CommandClass`** — Abstract base classes. Each CC (e.g. `BinarySwitchCommandClass`) inherits from `CommandClass` where `TEnum` is a byte-backed enum of commands. Takes `IDriver` and `IEndpoint` interfaces (not concrete types). The `Endpoint` property provides access to the endpoint this CC belongs to. Each CC declares a `Category` (Management, Transport, or Application) which determines interview phase ordering. -- **`CommandClassCategory`** — Enum (`Management`, `Transport`, `Application`) per spec §6.2–6.4. Management CCs (Version, Z-Wave Plus Info, etc.) are interviewed first, then Transport CCs (Multi Channel, Security), then Application CCs (actuators, sensors). New CCs must override `Category` if they are not Application CCs (the default). +- **`CommandClassCategory`** — Enum (`Management`, `Transport`, `Application`) per spec §6.2–6.4. Management CCs (Version, Z-Wave Plus Info, Association, Multi Channel Association, etc.) are interviewed first, then Transport CCs (Multi Channel, Security), then Application CCs (actuators, sensors). New CCs must override `Category` if they are not Application CCs (the default). - **`[CommandClass(CommandClassId.X)]` attribute** — Applied to each CC class. The source generator `CommandClassFactoryGenerator` scans for this attribute and generates `CommandClassFactory` with a mapping from `CommandClassId` → constructor. - **`ICommand` interface** (`CommandClasses/ICommand.cs`) — Different from the Serial API `ICommand`. Used for CC-level commands with `CommandClassId` and `CommandId`. - **`CommandClassFrame`** — Wraps CC payload bytes (CC ID + Command ID + parameters). ### High-Level Objects (`src/ZWave/`) -- **`Driver`** — Entry point. Implements `IDriver`. Opens serial port, manages frame send/receive, processes unsolicited requests, coordinates request-response and callback flows. Tracks `NodeIdType` (default `Short`) and creates `CommandParsingContext` instances to pass when parsing incoming frames. +- **`Driver`** — Entry point. Implements `IDriver`. Opens serial port, manages frame send/receive, processes unsolicited requests, coordinates request-response and callback flows. Tracks `NodeIdType` (default `Short`) and creates `CommandParsingContext` instances to pass when parsing incoming frames. Handles encapsulation/de-encapsulation of Multi Channel frames per spec §4.1.3.5: on send, wraps commands targeting endpoint > 0 in Multi Channel Command Encapsulation (source=0, destination=endpointIndex); on receive, detects incoming Multi Channel encapsulation frames, extracts the inner command and source endpoint, and routes the inner frame to the correct endpoint's CC handlers. Future encapsulation layers (Supervision, Security, Transport Service) plug into the same send/receive hooks in the spec-defined order. - **`Controller`** — Represents the Z-Wave USB controller. Runs identification sequence on startup. Negotiates `SetNodeIdBaseType(Long)` during init if supported by the module. - **`Node`** — Represents a Z-Wave network node. Implements `INode` (and thus `IEndpoint` with `EndpointIndex = 0`). A node IS endpoint 0 (the "Root Device"). Contains a dictionary of child `Endpoint` instances (1–127) discovered via the Multi Channel CC interview. Key methods: `GetEndpoint(byte)`, `GetAllEndpoints()`, `GetOrAddEndpoint(byte)`. The `ProcessCommand` method accepts an `endpointIndex` parameter to route frames to the correct endpoint's CC instance. The interview follows a phased approach per spec: Management CCs → Transport CCs (Multi Channel discovers endpoints) → Application CCs on root, then repeats for each endpoint. Node IDs are `ushort` throughout the codebase to support both classic (1–232) and Long Range (256+) nodes. - **`Endpoint`** — Represents a Multi Channel End Point (1–127). Implements `IEndpoint`. Holds its own CC dictionary (copy-on-write), device class info (`GenericDeviceClass`, `SpecificDeviceClass`), and provides `ProcessCommand`, `AddCommandClasses`, `InterviewCommandClassesAsync` methods. Created during the Multi Channel CC interview. -- **`MultiChannelCommandClass`** — Implements the Multi Channel CC (version 4) in `src/ZWave.CommandClasses/`. Discovers endpoints during interview by sending EndPoint Get and Capability Get commands. Exposes `internal event Action?` events (`OnEndpointReportReceived`, `OnCapabilityReportReceived`, `OnCommandEncapsulationReceived`) that fire on both solicited and unsolicited reports. Node subscribes to `OnCapabilityReportReceived` to create Endpoint instances. The interview flow per spec §6.4.2.1: EndPoint Get → for each EP: Capability Get. If Identical flag is set, queries only EP1 and clones for others. This report-event pattern is the convention for all CC implementations. +- **`MultiChannelCommandClass`** — Implements the Multi Channel CC (version 4) in `src/ZWave.CommandClasses/`. Discovers endpoints during interview by sending EndPoint Get and Capability Get commands. Provides static methods `CreateEncapsulation()` and `ParseEncapsulation()` used by the Driver for its encapsulation pipeline. Exposes `internal event Action?` events (`OnEndpointReportReceived`, `OnCapabilityReportReceived`, `OnCommandEncapsulationReceived`) that fire on both solicited and unsolicited reports. Node subscribes to `OnCapabilityReportReceived` to create Endpoint instances. The interview flow per spec §6.4.2.1: EndPoint Get → for each EP: Capability Get. If Identical flag is set, queries only EP1 and clones for others. Note: The Driver handles Multi Channel de-encapsulation of incoming frames upstream (in `ProcessDataFrame`), so `ProcessUnsolicitedCommand` for `CommandEncapsulation` is only reached if frames are routed to the CC directly (not the normal path). This report-event pattern is the convention for all CC implementations. ### Source Generators (`src/ZWave.BuildTools/`) diff --git a/src/ZWave.CommandClasses.Tests/ClockCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/ClockCommandClassTests.cs index cdf360c..521f058 100644 --- a/src/ZWave.CommandClasses.Tests/ClockCommandClassTests.cs +++ b/src/ZWave.CommandClasses.Tests/ClockCommandClassTests.cs @@ -223,4 +223,15 @@ public void Report_Parse_InvalidMinute_ThrowsZWaveException() Assert.Throws( () => ClockCommandClass.ClockReportCommand.Parse(frame, NullLogger.Instance)); } + + [TestMethod] + public void Report_Parse_InvalidHour_ThrowsZWaveException() + { + // Weekday=0, Hour=31 (5 bits all set, exceeds 0-23 range), Minute=0 + byte[] data = [0x81, 0x06, 0x1F, 0x00]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => ClockCommandClass.ClockReportCommand.Parse(frame, NullLogger.Instance)); + } } diff --git a/src/ZWave.CommandClasses.Tests/MultiChannelAssociationCommandClassTests.Modify.cs b/src/ZWave.CommandClasses.Tests/MultiChannelAssociationCommandClassTests.Modify.cs new file mode 100644 index 0000000..6b34cde --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/MultiChannelAssociationCommandClassTests.Modify.cs @@ -0,0 +1,188 @@ +namespace ZWave.CommandClasses.Tests; + +public partial class MultiChannelAssociationCommandClassTests +{ + [TestMethod] + public void SetCommand_Create_NodeIdDestinationsOnly() + { + MultiChannelAssociationCommandClass.MultiChannelAssociationSetCommand command = + MultiChannelAssociationCommandClass.MultiChannelAssociationSetCommand.Create( + 1, + new byte[] { 2, 3 }, + Array.Empty()); + + Assert.AreEqual(CommandClassId.MultiChannelAssociation, MultiChannelAssociationCommandClass.MultiChannelAssociationSetCommand.CommandClassId); + Assert.AreEqual((byte)MultiChannelAssociationCommand.Set, MultiChannelAssociationCommandClass.MultiChannelAssociationSetCommand.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_EndPointDestinationsOnly() + { + MultiChannelAssociationCommandClass.MultiChannelAssociationSetCommand command = + MultiChannelAssociationCommandClass.MultiChannelAssociationSetCommand.Create( + 2, + Array.Empty(), + new EndPointDestination[] { new EndPointDestination(5, 1) }); + + // CC + Cmd + GroupId + Marker + MCNodeID + Properties = 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)0x00, parameters[1]); // Marker + Assert.AreEqual((byte)5, parameters[2]); // MCNodeID + Assert.AreEqual((byte)0x01, parameters[3]); // IsBitAddress=0|EP=1 + } + + [TestMethod] + public void SetCommand_Create_MixedDestinations() + { + MultiChannelAssociationCommandClass.MultiChannelAssociationSetCommand command = + MultiChannelAssociationCommandClass.MultiChannelAssociationSetCommand.Create( + 1, + new byte[] { 2 }, + new EndPointDestination[] { new EndPointDestination(3, 1) }); + + // CC + Cmd + GroupId + NodeID(2) + Marker + MCNodeID(3) + Properties = 7 bytes + Assert.AreEqual(7, command.Frame.Data.Length); + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)1, parameters[0]); // GroupId + Assert.AreEqual((byte)2, parameters[1]); // NodeID + Assert.AreEqual((byte)0x00, parameters[2]); // Marker + Assert.AreEqual((byte)3, parameters[3]); // MCNodeID + Assert.AreEqual((byte)0x01, parameters[4]); // IsBitAddress=0|EP=1 + } + + [TestMethod] + public void SetCommand_Create_BitAddressEndPoint() + { + MultiChannelAssociationCommandClass.MultiChannelAssociationSetCommand command = + MultiChannelAssociationCommandClass.MultiChannelAssociationSetCommand.Create( + 1, + Array.Empty(), + new EndPointDestination[] { new EndPointDestination(4, new byte[] { 1, 2, 3 }) }); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)1, parameters[0]); // GroupId + Assert.AreEqual((byte)0x00, parameters[1]); // Marker + Assert.AreEqual((byte)4, parameters[2]); // MCNodeID + Assert.AreEqual((byte)0x87, parameters[3]); // IsBitAddress=1|EP=0x07 → 0x80 | 0x07 = 0x87 + } + + [TestMethod] + public void SetCommand_Create_NoDestinations() + { + MultiChannelAssociationCommandClass.MultiChannelAssociationSetCommand command = + MultiChannelAssociationCommandClass.MultiChannelAssociationSetCommand.Create( + 1, + Array.Empty(), + Array.Empty()); + + // CC + Cmd + GroupId = 3 bytes. No marker because no EP destinations. + Assert.AreEqual(3, command.Frame.Data.Length); + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)1, parameters[0]); // GroupId + } + + [TestMethod] + public void RemoveCommand_Create_SpecificNodeIdFromGroup() + { + MultiChannelAssociationCommandClass.MultiChannelAssociationRemoveCommand command = + MultiChannelAssociationCommandClass.MultiChannelAssociationRemoveCommand.Create( + 3, + new byte[] { 5 }, + Array.Empty()); + + Assert.AreEqual(CommandClassId.MultiChannelAssociation, MultiChannelAssociationCommandClass.MultiChannelAssociationRemoveCommand.CommandClassId); + Assert.AreEqual((byte)MultiChannelAssociationCommand.Remove, MultiChannelAssociationCommandClass.MultiChannelAssociationRemoveCommand.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 destinations → remove all from group + MultiChannelAssociationCommandClass.MultiChannelAssociationRemoveCommand command = + MultiChannelAssociationCommandClass.MultiChannelAssociationRemoveCommand.Create( + 3, + Array.Empty(), + 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() + { + // GroupId = 0, no destinations → remove all from all groups + MultiChannelAssociationCommandClass.MultiChannelAssociationRemoveCommand command = + MultiChannelAssociationCommandClass.MultiChannelAssociationRemoveCommand.Create( + 0, + Array.Empty(), + 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() + { + // GroupId = 0, with NodeID → remove from all groups + MultiChannelAssociationCommandClass.MultiChannelAssociationRemoveCommand command = + MultiChannelAssociationCommandClass.MultiChannelAssociationRemoveCommand.Create( + 0, + new byte[] { 7 }, + Array.Empty()); + + 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_EndPointFromGroup() + { + MultiChannelAssociationCommandClass.MultiChannelAssociationRemoveCommand command = + MultiChannelAssociationCommandClass.MultiChannelAssociationRemoveCommand.Create( + 3, + Array.Empty(), + new EndPointDestination[] { new EndPointDestination(5, 2) }); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)3, parameters[0]); // GroupId + Assert.AreEqual((byte)0x00, parameters[1]); // Marker + Assert.AreEqual((byte)5, parameters[2]); // MCNodeID + Assert.AreEqual((byte)0x02, parameters[3]); // IsBitAddress=0|EP=2 + } + + [TestMethod] + public void RemoveCommand_Create_MixedFromGroup() + { + MultiChannelAssociationCommandClass.MultiChannelAssociationRemoveCommand command = + MultiChannelAssociationCommandClass.MultiChannelAssociationRemoveCommand.Create( + 3, + new byte[] { 2 }, + new EndPointDestination[] { new EndPointDestination(5, 1) }); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)3, parameters[0]); // GroupId + Assert.AreEqual((byte)2, parameters[1]); // NodeID + Assert.AreEqual((byte)0x00, parameters[2]); // Marker + Assert.AreEqual((byte)5, parameters[3]); // MCNodeID + Assert.AreEqual((byte)0x01, parameters[4]); // IsBitAddress=0|EP=1 + } +} diff --git a/src/ZWave.CommandClasses.Tests/MultiChannelAssociationCommandClassTests.Report.cs b/src/ZWave.CommandClasses.Tests/MultiChannelAssociationCommandClassTests.Report.cs new file mode 100644 index 0000000..9221ec6 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/MultiChannelAssociationCommandClassTests.Report.cs @@ -0,0 +1,180 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class MultiChannelAssociationCommandClassTests +{ + [TestMethod] + public void GetCommand_Create_HasCorrectFormat() + { + MultiChannelAssociationCommandClass.MultiChannelAssociationGetCommand command = + MultiChannelAssociationCommandClass.MultiChannelAssociationGetCommand.Create(3); + + Assert.AreEqual(CommandClassId.MultiChannelAssociation, MultiChannelAssociationCommandClass.MultiChannelAssociationGetCommand.CommandClassId); + Assert.AreEqual((byte)MultiChannelAssociationCommand.Get, MultiChannelAssociationCommandClass.MultiChannelAssociationGetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); // CC + Cmd + GroupId + Assert.AreEqual((byte)3, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void Report_Parse_NodeIdDestinationsOnly() + { + // CC=0x8E, Cmd=0x03, GroupId=1, MaxNodes=5, ReportsToFollow=0, NodeID=2, NodeID=3 + byte[] data = [0x8E, 0x03, 0x01, 0x05, 0x00, 0x02, 0x03]; + CommandClassFrame frame = new(data); + + MultiChannelAssociationReport report = + MultiChannelAssociationCommandClass.MultiChannelAssociationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, report.GroupingIdentifier); + Assert.AreEqual((byte)5, report.MaxNodesSupported); + Assert.AreEqual((byte)0, report.ReportsToFollow); + Assert.HasCount(2, report.NodeIdDestinations); + Assert.AreEqual((byte)2, report.NodeIdDestinations[0]); + Assert.AreEqual((byte)3, report.NodeIdDestinations[1]); + Assert.IsEmpty(report.EndPointDestinations); + } + + [TestMethod] + public void Report_Parse_EndPointDestinationsOnly() + { + // CC=0x8E, Cmd=0x03, GroupId=2, MaxNodes=10, ReportsToFollow=0, + // Marker=0x00, MCNodeID=5, BitAddr=0|EP=1, MCNodeID=5, BitAddr=0|EP=2 + byte[] data = [0x8E, 0x03, 0x02, 0x0A, 0x00, 0x00, 0x05, 0x01, 0x05, 0x02]; + CommandClassFrame frame = new(data); + + MultiChannelAssociationReport report = + MultiChannelAssociationCommandClass.MultiChannelAssociationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)2, report.GroupingIdentifier); + Assert.AreEqual((byte)10, report.MaxNodesSupported); + Assert.IsEmpty(report.NodeIdDestinations); + Assert.HasCount(2, report.EndPointDestinations); + Assert.AreEqual((byte)5, report.EndPointDestinations[0].NodeId); + Assert.IsFalse(report.EndPointDestinations[0].IsBitAddress); + Assert.AreEqual((byte)1, report.EndPointDestinations[0].Destination); + Assert.AreEqual((byte)5, report.EndPointDestinations[1].NodeId); + Assert.IsFalse(report.EndPointDestinations[1].IsBitAddress); + Assert.AreEqual((byte)2, report.EndPointDestinations[1].Destination); + } + + [TestMethod] + public void Report_Parse_MixedDestinations() + { + // CC=0x8E, Cmd=0x03, GroupId=1, MaxNodes=10, ReportsToFollow=0, + // NodeID=1, NodeID=2, Marker=0x00, MCNodeID=3, BitAddr=0|EP=1 + byte[] data = [0x8E, 0x03, 0x01, 0x0A, 0x00, 0x01, 0x02, 0x00, 0x03, 0x01]; + CommandClassFrame frame = new(data); + + MultiChannelAssociationReport report = + MultiChannelAssociationCommandClass.MultiChannelAssociationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, report.GroupingIdentifier); + Assert.HasCount(2, report.NodeIdDestinations); + Assert.AreEqual((byte)1, report.NodeIdDestinations[0]); + Assert.AreEqual((byte)2, report.NodeIdDestinations[1]); + Assert.HasCount(1, report.EndPointDestinations); + Assert.AreEqual((byte)3, report.EndPointDestinations[0].NodeId); + Assert.IsFalse(report.EndPointDestinations[0].IsBitAddress); + Assert.AreEqual((byte)1, report.EndPointDestinations[0].Destination); + } + + [TestMethod] + public void Report_Parse_EmptyDestinations() + { + // CC=0x8E, Cmd=0x03, GroupId=1, MaxNodes=5, ReportsToFollow=0, no destinations + byte[] data = [0x8E, 0x03, 0x01, 0x05, 0x00]; + CommandClassFrame frame = new(data); + + MultiChannelAssociationReport report = + MultiChannelAssociationCommandClass.MultiChannelAssociationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, report.GroupingIdentifier); + Assert.AreEqual((byte)5, report.MaxNodesSupported); + Assert.AreEqual((byte)0, report.ReportsToFollow); + Assert.IsEmpty(report.NodeIdDestinations); + Assert.IsEmpty(report.EndPointDestinations); + } + + [TestMethod] + public void Report_Parse_BitAddressFlag() + { + // CC=0x8E, Cmd=0x03, GroupId=1, MaxNodes=10, ReportsToFollow=0, + // Marker=0x00, MCNodeID=4, BitAddr=1|EP=0x07 → properties byte = 0x87 + byte[] data = [0x8E, 0x03, 0x01, 0x0A, 0x00, 0x00, 0x04, 0x87]; + CommandClassFrame frame = new(data); + + MultiChannelAssociationReport report = + MultiChannelAssociationCommandClass.MultiChannelAssociationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(1, report.EndPointDestinations); + Assert.AreEqual((byte)4, report.EndPointDestinations[0].NodeId); + Assert.IsTrue(report.EndPointDestinations[0].IsBitAddress); + Assert.AreEqual((byte)0x07, report.EndPointDestinations[0].Destination); + } + + [TestMethod] + public void Report_Parse_EndPointZero_V3() + { + // V3 allows EndPoint 0 (Root Device destination). + // CC=0x8E, Cmd=0x03, GroupId=1, MaxNodes=5, ReportsToFollow=0, + // Marker=0x00, MCNodeID=1, BitAddr=0|EP=0 + byte[] data = [0x8E, 0x03, 0x01, 0x05, 0x00, 0x00, 0x01, 0x00]; + CommandClassFrame frame = new(data); + + MultiChannelAssociationReport report = + MultiChannelAssociationCommandClass.MultiChannelAssociationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(1, report.EndPointDestinations); + Assert.AreEqual((byte)1, report.EndPointDestinations[0].NodeId); + Assert.IsFalse(report.EndPointDestinations[0].IsBitAddress); + Assert.AreEqual((byte)0, report.EndPointDestinations[0].Destination); + } + + [TestMethod] + public void Report_Parse_ReportsToFollow() + { + // CC=0x8E, Cmd=0x03, GroupId=1, MaxNodes=20, ReportsToFollow=2, NodeID=1 + byte[] data = [0x8E, 0x03, 0x01, 0x14, 0x02, 0x01]; + CommandClassFrame frame = new(data); + + MultiChannelAssociationReport report = + MultiChannelAssociationCommandClass.MultiChannelAssociationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)2, report.ReportsToFollow); + Assert.HasCount(1, report.NodeIdDestinations); + } + + [TestMethod] + public void Report_Parse_TooShort_Throws() + { + // Only 2 parameter bytes, need at least 3 (GroupId + MaxNodes + ReportsToFollow) + byte[] data = [0x8E, 0x03, 0x01, 0x05]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => MultiChannelAssociationCommandClass.MultiChannelAssociationReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void Report_Parse_MultipleEndPointsSameNode() + { + // Same NodeID with different endpoints (e.g. power strip with 3 outlets). + // CC=0x8E, Cmd=0x03, GroupId=1, MaxNodes=10, ReportsToFollow=0, + // Marker=0x00, MCNodeID=5 EP=1, MCNodeID=5 EP=2, MCNodeID=5 EP=3 + byte[] data = [0x8E, 0x03, 0x01, 0x0A, 0x00, 0x00, 0x05, 0x01, 0x05, 0x02, 0x05, 0x03]; + CommandClassFrame frame = new(data); + + MultiChannelAssociationReport report = + MultiChannelAssociationCommandClass.MultiChannelAssociationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(3, report.EndPointDestinations); + Assert.AreEqual((byte)1, report.EndPointDestinations[0].Destination); + Assert.AreEqual((byte)2, report.EndPointDestinations[1].Destination); + Assert.AreEqual((byte)3, report.EndPointDestinations[2].Destination); + for (int i = 0; i < 3; i++) + { + Assert.AreEqual((byte)5, report.EndPointDestinations[i].NodeId); + } + } +} diff --git a/src/ZWave.CommandClasses.Tests/MultiChannelAssociationCommandClassTests.SupportedGroupings.cs b/src/ZWave.CommandClasses.Tests/MultiChannelAssociationCommandClassTests.SupportedGroupings.cs new file mode 100644 index 0000000..48e77e4 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/MultiChannelAssociationCommandClassTests.SupportedGroupings.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class MultiChannelAssociationCommandClassTests +{ + [TestMethod] + public void SupportedGroupingsGetCommand_Create_HasCorrectFormat() + { + MultiChannelAssociationCommandClass.MultiChannelAssociationSupportedGroupingsGetCommand command = + MultiChannelAssociationCommandClass.MultiChannelAssociationSupportedGroupingsGetCommand.Create(); + + Assert.AreEqual( + CommandClassId.MultiChannelAssociation, + MultiChannelAssociationCommandClass.MultiChannelAssociationSupportedGroupingsGetCommand.CommandClassId); + Assert.AreEqual( + (byte)MultiChannelAssociationCommand.SupportedGroupingsGet, + MultiChannelAssociationCommandClass.MultiChannelAssociationSupportedGroupingsGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); // CC + Cmd only + } + + [TestMethod] + public void SupportedGroupingsReport_Parse_ValidFrame() + { + // CC=0x8E, Cmd=0x06, SupportedGroupings=5 + byte[] data = [0x8E, 0x06, 0x05]; + CommandClassFrame frame = new(data); + + byte groupings = MultiChannelAssociationCommandClass.MultiChannelAssociationSupportedGroupingsReportCommand.Parse( + frame, NullLogger.Instance); + + Assert.AreEqual((byte)5, groupings); + } + + [TestMethod] + public void SupportedGroupingsReport_Parse_SingleGroup() + { + // CC=0x8E, Cmd=0x06, SupportedGroupings=1 + byte[] data = [0x8E, 0x06, 0x01]; + CommandClassFrame frame = new(data); + + byte groupings = MultiChannelAssociationCommandClass.MultiChannelAssociationSupportedGroupingsReportCommand.Parse( + frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, groupings); + } + + [TestMethod] + public void SupportedGroupingsReport_Parse_MaxGroups() + { + // CC=0x8E, Cmd=0x06, SupportedGroupings=255 + byte[] data = [0x8E, 0x06, 0xFF]; + CommandClassFrame frame = new(data); + + byte groupings = MultiChannelAssociationCommandClass.MultiChannelAssociationSupportedGroupingsReportCommand.Parse( + frame, NullLogger.Instance); + + Assert.AreEqual((byte)255, groupings); + } + + [TestMethod] + public void SupportedGroupingsReport_Parse_TooShort_Throws() + { + // CC=0x8E, Cmd=0x06, no parameters + byte[] data = [0x8E, 0x06]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => MultiChannelAssociationCommandClass.MultiChannelAssociationSupportedGroupingsReportCommand.Parse( + frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/MultiChannelAssociationCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/MultiChannelAssociationCommandClassTests.cs new file mode 100644 index 0000000..41cb4d4 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/MultiChannelAssociationCommandClassTests.cs @@ -0,0 +1,70 @@ +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public partial class MultiChannelAssociationCommandClassTests +{ + [TestMethod] + public void EndPointDestination_SingleEndpoint_HasCorrectProperties() + { + EndPointDestination dest = new EndPointDestination(5, 3); + + Assert.AreEqual((byte)5, dest.NodeId); + Assert.IsFalse(dest.IsBitAddress); + Assert.AreEqual((byte)3, dest.Destination); + } + + [TestMethod] + public void EndPointDestination_SingleEndpoint_Zero_RootDevice() + { + EndPointDestination dest = new EndPointDestination(1, 0); + + Assert.AreEqual((byte)1, dest.NodeId); + Assert.IsFalse(dest.IsBitAddress); + Assert.AreEqual((byte)0, dest.Destination); + } + + [TestMethod] + public void EndPointDestination_MultipleEndpoints_HasCorrectProperties() + { + EndPointDestination dest = new EndPointDestination(4, new byte[] { 1, 2, 3 }); + + Assert.AreEqual((byte)4, dest.NodeId); + Assert.IsTrue(dest.IsBitAddress); + // Endpoints 1, 2, 3 → bits 0, 1, 2 → 0b00000111 = 0x07 + Assert.AreEqual((byte)0x07, dest.Destination); + } + + [TestMethod] + public void EndPointDestination_MultipleEndpoints_SingleEndpoint() + { + EndPointDestination dest = new EndPointDestination(4, new byte[] { 5 }); + + Assert.IsTrue(dest.IsBitAddress); + // Endpoint 5 → bit 4 → 0b00010000 = 0x10 + Assert.AreEqual((byte)0x10, dest.Destination); + } + + [TestMethod] + public void EndPointDestination_MultipleEndpoints_AllEndpoints() + { + EndPointDestination dest = new EndPointDestination(4, new byte[] { 1, 2, 3, 4, 5, 6, 7 }); + + Assert.IsTrue(dest.IsBitAddress); + // Endpoints 1-7 → bits 0-6 → 0b01111111 = 0x7F + Assert.AreEqual((byte)0x7F, dest.Destination); + } + + [TestMethod] + public void EndPointDestination_MultipleEndpoints_EndpointZero_Throws() + { + Assert.Throws( + () => new EndPointDestination(4, new byte[] { 0 })); + } + + [TestMethod] + public void EndPointDestination_MultipleEndpoints_EndpointTooHigh_Throws() + { + Assert.Throws( + () => new EndPointDestination(4, new byte[] { 8 })); + } +} diff --git a/src/ZWave.CommandClasses.Tests/MultiChannelCommandClassTests.CommandEncapsulation.cs b/src/ZWave.CommandClasses.Tests/MultiChannelCommandClassTests.CommandEncapsulation.cs index 9dbbc56..5fb9922 100644 --- a/src/ZWave.CommandClasses.Tests/MultiChannelCommandClassTests.CommandEncapsulation.cs +++ b/src/ZWave.CommandClasses.Tests/MultiChannelCommandClassTests.CommandEncapsulation.cs @@ -146,4 +146,91 @@ public void CommandEncapsulation_Parse_SourceEndpointReservedBitMasked() Assert.AreEqual((byte)2, encap.SourceEndpoint); } + + [TestMethod] + public void CommandEncapsulation_RoundTrip_PreservesInnerFrame() + { + // Create a command with parameters + byte[] innerParams = [0xFF, 0x01, 0x00]; + CommandClassFrame innerFrame = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x03, innerParams); + + // Encapsulate from Root Device (EP0) to EP3 + CommandClassFrame encapFrame = MultiChannelCommandClass.CreateEncapsulation(0, 3, innerFrame); + + // Parse back and verify the inner frame is preserved + MultiChannelCommandEncapsulation encap = MultiChannelCommandClass.ParseEncapsulation(encapFrame, NullLogger.Instance); + + Assert.AreEqual((byte)0, encap.SourceEndpoint); + Assert.AreEqual((byte)3, encap.Destination); + Assert.IsFalse(encap.IsBitAddress); + Assert.AreEqual(CommandClassId.BinarySwitch, encap.EncapsulatedFrame.CommandClassId); + Assert.AreEqual((byte)0x03, encap.EncapsulatedFrame.CommandId); + Assert.AreEqual(innerParams.Length, encap.EncapsulatedFrame.CommandParameters.Length); + Assert.IsTrue(innerParams.AsSpan().SequenceEqual(encap.EncapsulatedFrame.CommandParameters.Span)); + } + + [TestMethod] + public void CommandEncapsulation_RoundTrip_ResponseSwapsEndpoints() + { + // Simulate the spec-defined request/response flow (CC:0060.03.0D.11.009): + // Request: Source=0 (controller), Destination=2 (remote endpoint) + CommandClassFrame getCommand = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x02); + CommandClassFrame requestFrame = MultiChannelCommandClass.CreateEncapsulation(0, 2, getCommand); + + // Parse the request to verify endpoint assignment + MultiChannelCommandEncapsulation requestEncap = MultiChannelCommandClass.ParseEncapsulation(requestFrame, NullLogger.Instance); + Assert.AreEqual((byte)0, requestEncap.SourceEndpoint); + Assert.AreEqual((byte)2, requestEncap.Destination); + + // Simulate the response: Source=2 (remote endpoint), Destination=0 (controller) + // Per CC:0060.03.0D.11.009, response swaps source and destination. + CommandClassFrame reportCommand = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x03, [0xFF]); + CommandClassFrame responseFrame = MultiChannelCommandClass.CreateEncapsulation(2, 0, reportCommand); + + // Parse the response — SourceEndpoint tells us which endpoint sent it + MultiChannelCommandEncapsulation responseEncap = MultiChannelCommandClass.ParseEncapsulation(responseFrame, NullLogger.Instance); + Assert.AreEqual((byte)2, responseEncap.SourceEndpoint); + Assert.AreEqual((byte)0, responseEncap.Destination); + Assert.AreEqual(CommandClassId.BinarySwitch, responseEncap.EncapsulatedFrame.CommandClassId); + Assert.AreEqual((byte)0x03, responseEncap.EncapsulatedFrame.CommandId); + } + + [TestMethod] + public void CommandEncapsulation_Create_MaxEndpoint127() + { + // Per spec CC:0060.03.0D.11.006: Source End Point MUST be in range 0..127 + CommandClassFrame innerFrame = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x02); + CommandClassFrame encapFrame = MultiChannelCommandClass.CreateEncapsulation(0, 127, innerFrame); + + MultiChannelCommandEncapsulation encap = MultiChannelCommandClass.ParseEncapsulation(encapFrame, NullLogger.Instance); + Assert.AreEqual((byte)127, encap.Destination); + } + + [TestMethod] + public void CommandEncapsulation_Create_EndpointToRootDevice() + { + // Endpoint-to-Root: Source=1, Destination=0 (per spec CC:0060.03.0D.11.008, + // Source EP MUST be different from 0 if Destination EP is 0) + CommandClassFrame innerFrame = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x03, [0xFF]); + CommandClassFrame encapFrame = MultiChannelCommandClass.CreateEncapsulation(1, 0, innerFrame); + + MultiChannelCommandEncapsulation encap = MultiChannelCommandClass.ParseEncapsulation(encapFrame, NullLogger.Instance); + Assert.AreEqual((byte)1, encap.SourceEndpoint); + Assert.AreEqual((byte)0, encap.Destination); + } + + [TestMethod] + public void CommandEncapsulation_RoundTrip_WithMultipleParameters() + { + // Use a larger payload to verify all bytes are preserved through encapsulation + byte[] parameters = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]; + CommandClassFrame innerFrame = CommandClassFrame.Create(CommandClassId.MultilevelSensor, 0x05, parameters); + + CommandClassFrame encapFrame = MultiChannelCommandClass.CreateEncapsulation(0, 5, innerFrame); + MultiChannelCommandEncapsulation encap = MultiChannelCommandClass.ParseEncapsulation(encapFrame, NullLogger.Instance); + + Assert.AreEqual(CommandClassId.MultilevelSensor, encap.EncapsulatedFrame.CommandClassId); + Assert.AreEqual((byte)0x05, encap.EncapsulatedFrame.CommandId); + Assert.IsTrue(parameters.AsSpan().SequenceEqual(encap.EncapsulatedFrame.CommandParameters.Span)); + } } diff --git a/src/ZWave.CommandClasses/MultiChannelAssociationCommandClass.Modify.cs b/src/ZWave.CommandClasses/MultiChannelAssociationCommandClass.Modify.cs new file mode 100644 index 0000000..1bff15b --- /dev/null +++ b/src/ZWave.CommandClasses/MultiChannelAssociationCommandClass.Modify.cs @@ -0,0 +1,154 @@ +namespace ZWave.CommandClasses; + +public sealed partial class MultiChannelAssociationCommandClass +{ + /// + /// Add destinations to a given association group. + /// + /// The association group identifier (1–255). + /// NodeID-only destinations to add. + /// End Point destinations to add. + /// A cancellation token. + public async Task SetAsync( + byte groupingIdentifier, + IReadOnlyList nodeIdDestinations, + IReadOnlyList endPointDestinations, + CancellationToken cancellationToken) + { + MultiChannelAssociationSetCommand command = MultiChannelAssociationSetCommand.Create( + groupingIdentifier, + nodeIdDestinations, + endPointDestinations); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// + /// Remove destinations from a given association group. + /// + /// + /// + /// Per the spec, the combination of , + /// , and + /// determines the behavior: + /// + /// + /// GroupId > 0, with destinations: Remove specified destinations from the group. + /// GroupId > 0, no destinations: Remove all destinations from the group. + /// GroupId = 0, with destinations: Remove specified destinations from all groups. + /// GroupId = 0, no destinations: Remove all destinations from all groups. + /// + /// + /// The association group identifier, or 0 to target all groups. + /// NodeID-only destinations to remove. + /// End Point destinations to remove. + /// A cancellation token. + public async Task RemoveAsync( + byte groupingIdentifier, + IReadOnlyList nodeIdDestinations, + IReadOnlyList endPointDestinations, + CancellationToken cancellationToken) + { + MultiChannelAssociationRemoveCommand command = MultiChannelAssociationRemoveCommand.Create( + groupingIdentifier, + nodeIdDestinations, + endPointDestinations); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + private static int ComputeDestinationPayloadLength( + IReadOnlyList nodeIdDestinations, + IReadOnlyList endPointDestinations) + { + int length = nodeIdDestinations.Count; + if (endPointDestinations.Count > 0) + { + // Marker byte + 2 bytes per End Point destination (NodeID + BitAddress|EndPoint). + length += 1 + (endPointDestinations.Count * 2); + } + + return length; + } + + private static void WriteDestinationPayload( + Span buffer, + int offset, + IReadOnlyList nodeIdDestinations, + IReadOnlyList endPointDestinations) + { + // Write NodeID destinations. + for (int i = 0; i < nodeIdDestinations.Count; i++) + { + buffer[offset++] = nodeIdDestinations[i]; + } + + if (endPointDestinations.Count > 0) + { + // Write marker. + buffer[offset++] = Marker; + + // Write End Point destinations. + for (int i = 0; i < endPointDestinations.Count; i++) + { + EndPointDestination dest = endPointDestinations[i]; + buffer[offset++] = dest.NodeId; + buffer[offset++] = (byte)((dest.IsBitAddress ? 0x80 : 0x00) | (dest.Destination & 0x7F)); + } + } + } + + internal readonly struct MultiChannelAssociationSetCommand : ICommand + { + public MultiChannelAssociationSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.MultiChannelAssociation; + + public static byte CommandId => (byte)MultiChannelAssociationCommand.Set; + + public CommandClassFrame Frame { get; } + + public static MultiChannelAssociationSetCommand Create( + byte groupingIdentifier, + IReadOnlyList nodeIdDestinations, + IReadOnlyList endPointDestinations) + { + int payloadLength = 1 + ComputeDestinationPayloadLength(nodeIdDestinations, endPointDestinations); + Span commandParameters = stackalloc byte[payloadLength]; + commandParameters[0] = groupingIdentifier; + WriteDestinationPayload(commandParameters, 1, nodeIdDestinations, endPointDestinations); + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new MultiChannelAssociationSetCommand(frame); + } + } + + internal readonly struct MultiChannelAssociationRemoveCommand : ICommand + { + public MultiChannelAssociationRemoveCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.MultiChannelAssociation; + + public static byte CommandId => (byte)MultiChannelAssociationCommand.Remove; + + public CommandClassFrame Frame { get; } + + public static MultiChannelAssociationRemoveCommand Create( + byte groupingIdentifier, + IReadOnlyList nodeIdDestinations, + IReadOnlyList endPointDestinations) + { + int payloadLength = 1 + ComputeDestinationPayloadLength(nodeIdDestinations, endPointDestinations); + Span commandParameters = stackalloc byte[payloadLength]; + commandParameters[0] = groupingIdentifier; + WriteDestinationPayload(commandParameters, 1, nodeIdDestinations, endPointDestinations); + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new MultiChannelAssociationRemoveCommand(frame); + } + } +} diff --git a/src/ZWave.CommandClasses/MultiChannelAssociationCommandClass.Report.cs b/src/ZWave.CommandClasses/MultiChannelAssociationCommandClass.Report.cs new file mode 100644 index 0000000..43c2eed --- /dev/null +++ b/src/ZWave.CommandClasses/MultiChannelAssociationCommandClass.Report.cs @@ -0,0 +1,179 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents the data from a Multi Channel Association Report command. +/// +public readonly record struct MultiChannelAssociationReport( + /// + /// 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. + /// + byte MaxNodesSupported, + + /// + /// The number of report frames that will follow this report. + /// + byte ReportsToFollow, + + /// + /// The NodeID-only destinations in this association group. + /// + IReadOnlyList NodeIdDestinations, + + /// + /// The End Point destinations in this association group. + /// + IReadOnlyList EndPointDestinations); + +public sealed partial class MultiChannelAssociationCommandClass +{ + /// + /// Event raised when a Multi Channel 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 = new Dictionary(); + + private void UpdateGroupReport(MultiChannelAssociationReport report) + { + _groupReports[report.GroupingIdentifier] = report; + } + + /// + /// Request the current destinations of a given association group. + /// + public async Task GetAsync(byte groupingIdentifier, CancellationToken cancellationToken) + { + MultiChannelAssociationGetCommand command = MultiChannelAssociationGetCommand.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); + MultiChannelAssociationReport report = MultiChannelAssociationReportCommand.Parse(reportFrame, Logger); + UpdateGroupReport(report); + OnReportReceived?.Invoke(report); + return report; + } + + internal readonly struct MultiChannelAssociationGetCommand : ICommand + { + public MultiChannelAssociationGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.MultiChannelAssociation; + + public static byte CommandId => (byte)MultiChannelAssociationCommand.Get; + + public CommandClassFrame Frame { get; } + + public static MultiChannelAssociationGetCommand Create(byte groupingIdentifier) + { + Span commandParameters = [groupingIdentifier]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new MultiChannelAssociationGetCommand(frame); + } + } + + internal readonly struct MultiChannelAssociationReportCommand : ICommand + { + public MultiChannelAssociationReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.MultiChannelAssociation; + + public static byte CommandId => (byte)MultiChannelAssociationCommand.Report; + + public CommandClassFrame Frame { get; } + + public static MultiChannelAssociationReport Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 3) + { + logger.LogWarning( + "Multi Channel Association Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Multi Channel Association Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + byte groupingIdentifier = span[0]; + byte maxNodesSupported = span[1]; + byte reportsToFollow = span[2]; + + ReadOnlySpan destinationData = span[3..]; + + // Find the marker byte (0x00) to split NodeID destinations from End Point destinations. + int markerIndex = destinationData.IndexOf(Marker); + + List nodeIdDestinations; + List endPointDestinations; + + if (markerIndex < 0) + { + // No marker — all destinations are NodeID-only. + nodeIdDestinations = new List(destinationData.Length); + for (int i = 0; i < destinationData.Length; i++) + { + nodeIdDestinations.Add(destinationData[i]); + } + + endPointDestinations = new List(); + } + else + { + // Parse NodeID destinations before the marker. + nodeIdDestinations = new List(markerIndex); + for (int i = 0; i < markerIndex; i++) + { + nodeIdDestinations.Add(destinationData[i]); + } + + // Parse End Point destinations after the marker. + // Each End Point destination is 2 bytes: NodeID + (BitAddress | EndPoint). + ReadOnlySpan endPointData = destinationData[(markerIndex + 1)..]; + int endPointCount = endPointData.Length / 2; + endPointDestinations = new List(endPointCount); + for (int i = 0; i + 1 < endPointData.Length; i += 2) + { + byte nodeId = endPointData[i]; + byte properties = endPointData[i + 1]; + bool bitAddress = (properties & 0x80) != 0; + byte endPoint = (byte)(properties & 0x7F); + endPointDestinations.Add(new EndPointDestination(nodeId, bitAddress, endPoint)); + } + + if (endPointData.Length % 2 != 0) + { + logger.LogWarning( + "Multi Channel Association Report has a trailing byte after the marker (odd End Point data length: {Length})", + endPointData.Length); + } + } + + return new MultiChannelAssociationReport( + groupingIdentifier, + maxNodesSupported, + reportsToFollow, + nodeIdDestinations, + endPointDestinations); + } + } +} diff --git a/src/ZWave.CommandClasses/MultiChannelAssociationCommandClass.SupportedGroupings.cs b/src/ZWave.CommandClasses/MultiChannelAssociationCommandClass.SupportedGroupings.cs new file mode 100644 index 0000000..4649e86 --- /dev/null +++ b/src/ZWave.CommandClasses/MultiChannelAssociationCommandClass.SupportedGroupings.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class MultiChannelAssociationCommandClass +{ + /// + /// 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) + { + MultiChannelAssociationSupportedGroupingsGetCommand command = MultiChannelAssociationSupportedGroupingsGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + byte groupings = MultiChannelAssociationSupportedGroupingsReportCommand.Parse(reportFrame, Logger); + SupportedGroupings = groupings; + return groupings; + } + + internal readonly struct MultiChannelAssociationSupportedGroupingsGetCommand : ICommand + { + public MultiChannelAssociationSupportedGroupingsGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.MultiChannelAssociation; + + public static byte CommandId => (byte)MultiChannelAssociationCommand.SupportedGroupingsGet; + + public CommandClassFrame Frame { get; } + + public static MultiChannelAssociationSupportedGroupingsGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new MultiChannelAssociationSupportedGroupingsGetCommand(frame); + } + } + + internal readonly struct MultiChannelAssociationSupportedGroupingsReportCommand : ICommand + { + public MultiChannelAssociationSupportedGroupingsReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.MultiChannelAssociation; + + public static byte CommandId => (byte)MultiChannelAssociationCommand.SupportedGroupingsReport; + + public CommandClassFrame Frame { get; } + + public static byte Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning( + "Multi Channel Association Supported Groupings Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + throw new ZWaveException( + ZWaveErrorCode.InvalidPayload, + "Multi Channel Association Supported Groupings Report frame is too short"); + } + + return frame.CommandParameters.Span[0]; + } + } +} diff --git a/src/ZWave.CommandClasses/MultiChannelAssociationCommandClass.cs b/src/ZWave.CommandClasses/MultiChannelAssociationCommandClass.cs new file mode 100644 index 0000000..3177700 --- /dev/null +++ b/src/ZWave.CommandClasses/MultiChannelAssociationCommandClass.cs @@ -0,0 +1,169 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public enum MultiChannelAssociationCommand : byte +{ + /// + /// Add destinations to a given association group. + /// + Set = 0x01, + + /// + /// Request the current destinations of a given association group. + /// + Get = 0x02, + + /// + /// Advertise the current destinations for a given association group. + /// + Report = 0x03, + + /// + /// Remove 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, +} + +/// +/// Represents an End Point destination in a Multi Channel association. +/// +/// +/// +/// An End Point destination identifies a specific endpoint on a node. Use the constructor +/// for a single endpoint destination, or the +/// overload for a destination +/// destination targeting multiple endpoints simultaneously. +/// +/// +/// When is true, is a bit mask where +/// bit 0 = endpoint 1, bit 1 = endpoint 2, etc. (endpoints 1–7). +/// When false, is the single endpoint index (0–127). +/// +/// +public readonly record struct EndPointDestination +{ + /// + /// Creates an End Point destination targeting a single endpoint on a node. + /// + /// The NodeID of the destination. + /// The endpoint index (0–127). + public EndPointDestination(byte nodeId, byte endPoint) + { + NodeId = nodeId; + IsBitAddress = false; + Destination = endPoint; + } + + internal EndPointDestination(byte nodeId, bool isBitAddress, byte destination) + { + NodeId = nodeId; + IsBitAddress = isBitAddress; + Destination = destination; + } + + /// + /// Creates an End Point destination targeting multiple endpoints on a node. + /// + /// The NodeID of the destination. + /// The endpoint indices to target (each must be 1–7). + public EndPointDestination(byte nodeId, ReadOnlySpan endPoints) + { + byte bitMask = 0; + foreach (byte ep in endPoints) + { + if (ep < 1 || ep > 7) + { + throw new ArgumentOutOfRangeException(nameof(endPoints), ep, "Bit-addressed endpoints must be between 1 and 7."); + } + + bitMask |= (byte)(1 << (ep - 1)); + } + + NodeId = nodeId; + IsBitAddress = true; + Destination = bitMask; + } + + /// + /// The NodeID of the destination. + /// + public byte NodeId { get; } + + /// + /// Whether the destination is specified as a bit mask targeting multiple endpoints. + /// + public bool IsBitAddress { get; } + + /// + /// The destination endpoint index (0–127) or bit mask (when is true). + /// + public byte Destination { get; } +} + +[CommandClass(CommandClassId.MultiChannelAssociation)] +public sealed partial class MultiChannelAssociationCommandClass : CommandClass +{ + /// + /// The marker byte value used to separate NodeID destinations from End Point destinations. + /// + internal const byte Marker = 0x00; + + internal MultiChannelAssociationCommandClass(CommandClassInfo info, IDriver driver, IEndpoint endpoint, ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + internal override CommandClassCategory Category => CommandClassCategory.Management; + + /// + public override bool? IsCommandSupported(MultiChannelAssociationCommand command) + => command switch + { + MultiChannelAssociationCommand.Set => true, + MultiChannelAssociationCommand.Get => true, + MultiChannelAssociationCommand.Remove => true, + MultiChannelAssociationCommand.SupportedGroupingsGet => true, + _ => 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) + { + switch ((MultiChannelAssociationCommand)frame.CommandId) + { + case MultiChannelAssociationCommand.Report: + { + MultiChannelAssociationReport report = MultiChannelAssociationReportCommand.Parse(frame, Logger); + UpdateGroupReport(report); + OnReportReceived?.Invoke(report); + break; + } + case MultiChannelAssociationCommand.SupportedGroupingsReport: + { + byte groupings = MultiChannelAssociationSupportedGroupingsReportCommand.Parse(frame, Logger); + SupportedGroupings = groupings; + break; + } + } + } +} diff --git a/src/ZWave/Driver.cs b/src/ZWave/Driver.cs index 5060654..7cc1623 100644 --- a/src/ZWave/Driver.cs +++ b/src/ZWave/Driver.cs @@ -198,7 +198,21 @@ private void ProcessDataFrame(DataFrame frame) if (Controller.Nodes.TryGetValue(applicationCommandHandler.NodeId, out Node? node)) { var commandClassFrame = new CommandClassFrame(applicationCommandHandler.Payload); - node.ProcessCommand(commandClassFrame, 0); + + // De-encapsulate per spec §4.1.3.5 (reverse order): + // Security/CRC-16/Transport Service → Multi Channel → Supervision → Multi Command + // Currently only Multi Channel is implemented; future layers plug in here. + byte endpointIndex = 0; + if (commandClassFrame.CommandClassId == CommandClassId.MultiChannel + && commandClassFrame.CommandId == (byte)MultiChannelCommand.CommandEncapsulation) + { + MultiChannelCommandEncapsulation encapsulation = MultiChannelCommandClass.ParseEncapsulation(commandClassFrame, _logger); + _logger.LogMultiChannelDeEncapsulating(applicationCommandHandler.NodeId, encapsulation.SourceEndpoint); + endpointIndex = encapsulation.SourceEndpoint; + commandClassFrame = encapsulation.EncapsulatedFrame; + } + + node.ProcessCommand(commandClassFrame, endpointIndex); } else { @@ -461,15 +475,29 @@ public async Task SendCommandAsync( CancellationToken cancellationToken) where TCommand : struct, ICommand { - // TODO: Multi Channel encapsulation for non-zero endpoints (#138) + const TransmissionOptions transmissionOptions = TransmissionOptions.ACK | TransmissionOptions.AutoRoute | TransmissionOptions.Explore; + byte sessionId = GetNextSessionId(); + + // Apply encapsulation layers per spec §4.1.3.5 order: + // payload → Multi Command → Supervision → Multi Channel → Security/CRC-16/Transport Service + // Currently only Multi Channel is implemented; future layers plug in here. + SendDataRequest sendDataRequest; if (endpointIndex != 0) { - throw new NotImplementedException("Multi Channel encapsulation for non-zero endpoints is not yet implemented."); + // Wrap in Multi Channel encapsulation per spec §4.2.2.9. + // Source endpoint is 0 (Root Device / controller) per CC:0060.03.0D.11.00A. + _logger.LogMultiChannelEncapsulating(nodeId, endpointIndex); + CommandClassFrame encapsulatedFrame = MultiChannelCommandClass.CreateEncapsulation( + sourceEndpoint: 0, + destinationEndpoint: endpointIndex, + request.Frame); + sendDataRequest = SendDataRequest.Create(nodeId, NodeIdType, encapsulatedFrame.Data.Span, transmissionOptions, sessionId); + } + else + { + sendDataRequest = SendDataRequest.Create(nodeId, NodeIdType, request.Frame.Data.Span, transmissionOptions, sessionId); } - const TransmissionOptions transmissionOptions = TransmissionOptions.ACK | TransmissionOptions.AutoRoute | TransmissionOptions.Explore; - byte sessionId = GetNextSessionId(); - SendDataRequest sendDataRequest = SendDataRequest.Create(nodeId, NodeIdType, request.Frame.Data.Span, transmissionOptions, sessionId); ResponseStatusResponse response = await SendCommandAsync(sendDataRequest, cancellationToken) .ConfigureAwait(false); if (!response.WasRequestAccepted) diff --git a/src/ZWave/Logging.cs b/src/ZWave/Logging.cs index b2a9231..df09e22 100644 --- a/src/ZWave/Logging.cs +++ b/src/ZWave/Logging.cs @@ -159,4 +159,16 @@ public static partial void LogInitData( Level = LogLevel.Information, Message = "NodeID base type set to {nodeIdType}")] public static partial void LogSetNodeIdBaseType(this ILogger logger, NodeIdType nodeIdType); + + [LoggerMessage( + EventId = 221, + Level = LogLevel.Debug, + Message = "Encapsulating command for node {nodeId} endpoint {endpointIndex} in Multi Channel frame")] + public static partial void LogMultiChannelEncapsulating(this ILogger logger, ushort nodeId, byte endpointIndex); + + [LoggerMessage( + EventId = 222, + Level = LogLevel.Debug, + Message = "De-encapsulating Multi Channel frame from node {nodeId} source endpoint {sourceEndpoint}")] + public static partial void LogMultiChannelDeEncapsulating(this ILogger logger, ushort nodeId, byte sourceEndpoint); }