Skip to content

Commit 25f954f

Browse files
authored
Handle incoming Association and AGI commands directed at the controller (#218)
1 parent d786673 commit 25f954f

17 files changed

Lines changed: 687 additions & 2 deletions

.github/copilot-instructions.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ Implements Z-Wave Command Classes (Z-Wave Application Specification). This proje
8888
### High-Level Objects (`src/ZWave/`)
8989

9090
- **`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.
91-
- **`Controller`** — Represents the Z-Wave USB controller. Runs identification sequence on startup. Negotiates `SetNodeIdBaseType(Long)` during init if supported by the module.
91+
- **`Controller`** — Represents the Z-Wave USB controller. Runs identification sequence on startup. Negotiates `SetNodeIdBaseType(Long)` during init if supported by the module. Stores mutable `Associations` list for the controller's lifeline group (modified when other nodes send Association Set/Remove).
92+
- **`ControllerCommandHandler`** — Handles incoming commands from other nodes directed at the controller (the "supporting side"). When another node queries the controller (e.g., Association Get, AGI Group Name Get), this class dispatches to the appropriate handler which constructs and sends the response. Lives in the Driver layer (not the CC layer) because handlers need Driver/Controller context. Uses fire-and-forget for async responses to avoid blocking the frame processing loop.
9293
- **`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.
9394
- **`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.
9495
- **`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<TReport>?` 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.
@@ -133,7 +134,9 @@ Response structs that contain variable-length collections use count + indexer me
133134

134135
**New Sub-Command Based Command**: Several Serial API commands use sub-commands (e.g. `NvmBackupRestore`, `ExtendedNvmBackupRestore`, `NetworkRestore`, `FirmwareUpdateNvm`, `NonceManagement`). These use a `partial struct` with static factory methods for each sub-command. Define the sub-command enum and status enum alongside the struct. The response struct reads the sub-command byte and status from the command parameters.
135136

136-
**New Command Class**: Create a class in `src/ZWave.CommandClasses/` inheriting `CommandClass<TEnum>`. Apply `[CommandClass(CommandClassId.X)]`. Constructor takes `(CommandClassInfo info, IDriver driver, IEndpoint endpoint, ILogger logger)`. Define internal inner structs for each command (Set/Get/Report) implementing `ICommand` (internal enables direct unit testing). The source generator auto-registers it. Use `Endpoint` property to access the endpoint (e.g. `Endpoint.NodeId`, `Endpoint.CommandClasses`). Override `Category` to return the correct `CommandClassCategory` (Management for §6.3 CCs, Transport for §6.4 CCs; Application is the default for §6.2 CCs). The `ProcessUnsolicitedCommand` override should only handle commands that can actually arrive unsolicited (typically just Report); do not add no-op cases for Set/Get. For large CCs with many command groups, use the **partial class pattern**: the main file (`{Name}CommandClass.cs`) contains the command enum, constructor, `IsCommandSupported`, `InterviewAsync`, `ProcessUnsolicitedCommand`, and callbacks; each command group goes in a separate partial file (`{Name}CommandClass.{Group}.cs`) with its report record struct, inner command structs, and public accessor methods. Test classes follow the same split (`{Name}CommandClassTests.cs` + `{Name}CommandClassTests.{Group}.cs`). For CCs with per-key readings (e.g. per-sensor-type values, per-component state), eagerly initialize the readings dictionary to `new()` (non-nullable property); capability/discovery properties (e.g. `SupportedSensorTypes`) start `null` (nullable) and are populated during interview. See the skill for details.
137+
**New Command Class**: Create a class in `src/ZWave.CommandClasses/` inheriting `CommandClass<TEnum>`. Apply `[CommandClass(CommandClassId.X)]`. Constructor takes `(CommandClassInfo info, IDriver driver, IEndpoint endpoint, ILogger logger)`. Define internal inner structs for each command (Set/Get/Report) implementing `ICommand` (internal enables direct unit testing). The source generator auto-registers it. Use `Endpoint` property to access the endpoint (e.g. `Endpoint.NodeId`, `Endpoint.CommandClasses`). Override `Category` to return the correct `CommandClassCategory` (Management for §6.3 CCs, Transport for §6.4 CCs; Application is the default for §6.2 CCs). The `ProcessUnsolicitedCommand` override should only handle commands that can actually arrive unsolicited (typically just Report); do not add no-op cases for Set/Get. For large CCs with many command groups, use the **partial class pattern**: the main file (`{Name}CommandClass.cs`) contains the command enum, constructor, `IsCommandSupported`, `InterviewAsync`, `ProcessUnsolicitedCommand`, and callbacks; each command group goes in a separate partial file (`{Name}CommandClass.{Group}.cs`) with its report record struct, inner command structs, and public accessor methods. Test classes follow the same split (`{Name}CommandClassTests.cs` + `{Name}CommandClassTests.{Group}.cs`). For CCs with per-key readings (e.g. per-sensor-type values, per-component state), eagerly initialize the readings dictionary to `new()` (non-nullable property); capability/discovery properties (e.g. `SupportedSensorTypes`) start `null` (nullable) and are populated during interview. Report command structs should have both `Parse` (for incoming) and `Create` (for outgoing) static methods to make them **bidirectional**. See the skill for details.
138+
139+
**New Controller Command Handler**: When a CC requires the controller to respond to incoming queries from other nodes (the "supporting side"), add handler methods in `src/ZWave/ControllerCommandHandler.cs`. This pattern is used instead of adding virtual methods to `CommandClass` because: (1) handlers need Driver/Controller context that CCs don't have, (2) it avoids polluting the CC base class, (3) it cleanly separates "controlling" (CC layer) from "supporting" (Driver layer) concerns. Steps: add a `case` in the `HandleCommand` dispatch switch for the CC ID, add private handler methods for each command, use the CC's report struct `Create` methods to construct responses, and send via `SendResponse`. CCs that currently have handlers: Association CC (Get/Set/Remove/SupportedGroupingsGet/SpecificGroupGet) and AGI CC (GroupNameGet/GroupInfoGet/CommandListGet). Future CCs needing handlers: Version, Z-Wave Plus Info, Powerlevel, Time, Manufacturer Specific.
137140

138141
## Protocol References
139142

src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.Report.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,40 @@ public void Report_ParseInto_ManyNodes()
137137
Assert.AreEqual((byte)(i + 1), nodeIdDestinations[i]);
138138
}
139139
}
140+
141+
[TestMethod]
142+
public void Report_Create_ParseRoundTrip()
143+
{
144+
byte[] nodeIds = [2, 5, 10];
145+
AssociationCommandClass.AssociationReportCommand report =
146+
AssociationCommandClass.AssociationReportCommand.Create(1, 5, 0, nodeIds);
147+
148+
List<byte> parsedNodeIds = [];
149+
(byte maxNodesSupported, byte reportsToFollow) =
150+
AssociationCommandClass.AssociationReportCommand.ParseInto(
151+
report.Frame, parsedNodeIds, NullLogger.Instance);
152+
153+
Assert.AreEqual((byte)5, maxNodesSupported);
154+
Assert.AreEqual((byte)0, reportsToFollow);
155+
Assert.HasCount(3, parsedNodeIds);
156+
Assert.AreEqual((byte)2, parsedNodeIds[0]);
157+
Assert.AreEqual((byte)5, parsedNodeIds[1]);
158+
Assert.AreEqual((byte)10, parsedNodeIds[2]);
159+
}
160+
161+
[TestMethod]
162+
public void Report_Create_EmptyDestinations_ParseRoundTrip()
163+
{
164+
AssociationCommandClass.AssociationReportCommand report =
165+
AssociationCommandClass.AssociationReportCommand.Create(1, 1, 0, ReadOnlySpan<byte>.Empty);
166+
167+
List<byte> parsedNodeIds = [];
168+
(byte maxNodesSupported, byte reportsToFollow) =
169+
AssociationCommandClass.AssociationReportCommand.ParseInto(
170+
report.Frame, parsedNodeIds, NullLogger.Instance);
171+
172+
Assert.AreEqual((byte)1, maxNodesSupported);
173+
Assert.AreEqual((byte)0, reportsToFollow);
174+
Assert.IsEmpty(parsedNodeIds);
175+
}
140176
}

src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SpecificGroup.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,28 @@ public void SpecificGroupReport_Parse_TooShort_Throws()
6969
() => AssociationCommandClass.AssociationSpecificGroupReportCommand.Parse(
7070
frame, NullLogger.Instance));
7171
}
72+
73+
[TestMethod]
74+
public void SpecificGroupReport_Create_ParseRoundTrip()
75+
{
76+
AssociationCommandClass.AssociationSpecificGroupReportCommand report =
77+
AssociationCommandClass.AssociationSpecificGroupReportCommand.Create(5);
78+
79+
byte group = AssociationCommandClass.AssociationSpecificGroupReportCommand.Parse(
80+
report.Frame, NullLogger.Instance);
81+
82+
Assert.AreEqual((byte)5, group);
83+
}
84+
85+
[TestMethod]
86+
public void SpecificGroupReport_Create_NotSupported()
87+
{
88+
AssociationCommandClass.AssociationSpecificGroupReportCommand report =
89+
AssociationCommandClass.AssociationSpecificGroupReportCommand.Create(0);
90+
91+
byte group = AssociationCommandClass.AssociationSpecificGroupReportCommand.Parse(
92+
report.Frame, NullLogger.Instance);
93+
94+
Assert.AreEqual((byte)0, group);
95+
}
7296
}

src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SupportedGroupings.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,16 @@ public void SupportedGroupingsReport_Parse_TooShort_Throws()
6969
() => AssociationCommandClass.AssociationSupportedGroupingsReportCommand.Parse(
7070
frame, NullLogger.Instance));
7171
}
72+
73+
[TestMethod]
74+
public void SupportedGroupingsReport_Create_ParseRoundTrip()
75+
{
76+
AssociationCommandClass.AssociationSupportedGroupingsReportCommand report =
77+
AssociationCommandClass.AssociationSupportedGroupingsReportCommand.Create(3);
78+
79+
byte groupings = AssociationCommandClass.AssociationSupportedGroupingsReportCommand.Parse(
80+
report.Frame, NullLogger.Instance);
81+
82+
Assert.AreEqual((byte)3, groupings);
83+
}
7284
}

src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.CommandList.cs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,4 +163,73 @@ public void CommandListReport_Parse_MixedNormalAndExtended()
163163
Assert.AreEqual((ushort)CommandClassId.Notification, commands[2].CommandClassId);
164164
Assert.AreEqual((byte)0x05, commands[2].CommandId);
165165
}
166+
167+
[TestMethod]
168+
public void CommandListReport_Create_ParseRoundTrip_SingleCommand()
169+
{
170+
AssociationGroupCommand[] commands =
171+
[
172+
new AssociationGroupCommand((ushort)CommandClassId.DeviceResetLocally, 0x01),
173+
];
174+
175+
AssociationGroupInformationCommandClass.CommandListReportCommand report =
176+
AssociationGroupInformationCommandClass.CommandListReportCommand.Create(1, commands);
177+
178+
(byte groupingIdentifier, IReadOnlyList<AssociationGroupCommand> parsedCommands) =
179+
AssociationGroupInformationCommandClass.CommandListReportCommand.Parse(
180+
report.Frame, NullLogger.Instance);
181+
182+
Assert.AreEqual((byte)1, groupingIdentifier);
183+
Assert.HasCount(1, parsedCommands);
184+
Assert.AreEqual((ushort)CommandClassId.DeviceResetLocally, parsedCommands[0].CommandClassId);
185+
Assert.AreEqual((byte)0x01, parsedCommands[0].CommandId);
186+
}
187+
188+
[TestMethod]
189+
public void CommandListReport_Create_ParseRoundTrip_MultipleCommands()
190+
{
191+
AssociationGroupCommand[] commands =
192+
[
193+
new AssociationGroupCommand((ushort)CommandClassId.Notification, 0x05),
194+
new AssociationGroupCommand((ushort)CommandClassId.Battery, 0x03),
195+
new AssociationGroupCommand((ushort)CommandClassId.DeviceResetLocally, 0x01),
196+
];
197+
198+
AssociationGroupInformationCommandClass.CommandListReportCommand report =
199+
AssociationGroupInformationCommandClass.CommandListReportCommand.Create(1, commands);
200+
201+
(byte groupingIdentifier, IReadOnlyList<AssociationGroupCommand> parsedCommands) =
202+
AssociationGroupInformationCommandClass.CommandListReportCommand.Parse(
203+
report.Frame, NullLogger.Instance);
204+
205+
Assert.AreEqual((byte)1, groupingIdentifier);
206+
Assert.HasCount(3, parsedCommands);
207+
Assert.AreEqual((ushort)CommandClassId.Notification, parsedCommands[0].CommandClassId);
208+
Assert.AreEqual((ushort)CommandClassId.Battery, parsedCommands[1].CommandClassId);
209+
Assert.AreEqual((ushort)CommandClassId.DeviceResetLocally, parsedCommands[2].CommandClassId);
210+
}
211+
212+
[TestMethod]
213+
public void CommandListReport_Create_ParseRoundTrip_ExtendedCC()
214+
{
215+
AssociationGroupCommand[] commands =
216+
[
217+
new AssociationGroupCommand(0xF205, 0x03),
218+
new AssociationGroupCommand((ushort)CommandClassId.Basic, 0x01),
219+
];
220+
221+
AssociationGroupInformationCommandClass.CommandListReportCommand report =
222+
AssociationGroupInformationCommandClass.CommandListReportCommand.Create(2, commands);
223+
224+
(byte groupingIdentifier, IReadOnlyList<AssociationGroupCommand> parsedCommands) =
225+
AssociationGroupInformationCommandClass.CommandListReportCommand.Parse(
226+
report.Frame, NullLogger.Instance);
227+
228+
Assert.AreEqual((byte)2, groupingIdentifier);
229+
Assert.HasCount(2, parsedCommands);
230+
Assert.AreEqual((ushort)0xF205, parsedCommands[0].CommandClassId);
231+
Assert.AreEqual((byte)0x03, parsedCommands[0].CommandId);
232+
Assert.AreEqual((ushort)CommandClassId.Basic, parsedCommands[1].CommandClassId);
233+
Assert.AreEqual((byte)0x01, parsedCommands[1].CommandId);
234+
}
166235
}

src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.GroupInfo.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,4 +238,54 @@ public void GroupInfoReport_Parse_IgnoresReservedFields()
238238
Assert.AreEqual((byte)0x00, groups[0].Profile.Category);
239239
Assert.AreEqual((byte)0x01, groups[0].Profile.Identifier);
240240
}
241+
242+
[TestMethod]
243+
public void GroupInfoReport_Create_ParseRoundTrip_SingleGroup()
244+
{
245+
AssociationGroupInfo[] groups =
246+
[
247+
new AssociationGroupInfo(1, new AssociationGroupProfile(0x00, 0x01)),
248+
];
249+
250+
AssociationGroupInformationCommandClass.GroupInfoReportCommand report =
251+
AssociationGroupInformationCommandClass.GroupInfoReportCommand.Create(
252+
listMode: false, dynamicInfo: false, groups);
253+
254+
(bool dynamicInfo, List<AssociationGroupInfo> parsedGroups) =
255+
AssociationGroupInformationCommandClass.GroupInfoReportCommand.Parse(
256+
report.Frame, NullLogger.Instance);
257+
258+
Assert.IsFalse(dynamicInfo);
259+
Assert.HasCount(1, parsedGroups);
260+
Assert.AreEqual((byte)1, parsedGroups[0].GroupingIdentifier);
261+
Assert.AreEqual((byte)0x00, parsedGroups[0].Profile.Category);
262+
Assert.AreEqual((byte)0x01, parsedGroups[0].Profile.Identifier);
263+
}
264+
265+
[TestMethod]
266+
public void GroupInfoReport_Create_ParseRoundTrip_ListMode()
267+
{
268+
AssociationGroupInfo[] groups =
269+
[
270+
new AssociationGroupInfo(1, new AssociationGroupProfile(0x00, 0x01)),
271+
new AssociationGroupInfo(2, new AssociationGroupProfile(0x20, 0x01)),
272+
];
273+
274+
AssociationGroupInformationCommandClass.GroupInfoReportCommand report =
275+
AssociationGroupInformationCommandClass.GroupInfoReportCommand.Create(
276+
listMode: true, dynamicInfo: true, groups);
277+
278+
(bool dynamicInfo, List<AssociationGroupInfo> parsedGroups) =
279+
AssociationGroupInformationCommandClass.GroupInfoReportCommand.Parse(
280+
report.Frame, NullLogger.Instance);
281+
282+
Assert.IsTrue(dynamicInfo);
283+
Assert.HasCount(2, parsedGroups);
284+
Assert.AreEqual((byte)1, parsedGroups[0].GroupingIdentifier);
285+
Assert.AreEqual((byte)0x00, parsedGroups[0].Profile.Category);
286+
Assert.AreEqual((byte)0x01, parsedGroups[0].Profile.Identifier);
287+
Assert.AreEqual((byte)2, parsedGroups[1].GroupingIdentifier);
288+
Assert.AreEqual((byte)0x20, parsedGroups[1].Profile.Category);
289+
Assert.AreEqual((byte)0x01, parsedGroups[1].Profile.Identifier);
290+
}
241291
}

0 commit comments

Comments
 (0)