Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ Implements Z-Wave Command Classes (Z-Wave Application Specification). This proje
### 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. 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.
- **`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).
- **`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.
- **`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. 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.
Expand Down Expand Up @@ -133,7 +134,9 @@ Response structs that contain variable-length collections use count + indexer me

**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.

**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.
**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.

**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.

## Protocol References

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,40 @@ public void Report_ParseInto_ManyNodes()
Assert.AreEqual((byte)(i + 1), nodeIdDestinations[i]);
}
}

[TestMethod]
public void Report_Create_ParseRoundTrip()
{
byte[] nodeIds = [2, 5, 10];
AssociationCommandClass.AssociationReportCommand report =
AssociationCommandClass.AssociationReportCommand.Create(1, 5, 0, nodeIds);

List<byte> parsedNodeIds = [];
(byte maxNodesSupported, byte reportsToFollow) =
AssociationCommandClass.AssociationReportCommand.ParseInto(
report.Frame, parsedNodeIds, NullLogger.Instance);

Assert.AreEqual((byte)5, maxNodesSupported);
Assert.AreEqual((byte)0, reportsToFollow);
Assert.HasCount(3, parsedNodeIds);
Assert.AreEqual((byte)2, parsedNodeIds[0]);
Assert.AreEqual((byte)5, parsedNodeIds[1]);
Assert.AreEqual((byte)10, parsedNodeIds[2]);
}

[TestMethod]
public void Report_Create_EmptyDestinations_ParseRoundTrip()
{
AssociationCommandClass.AssociationReportCommand report =
AssociationCommandClass.AssociationReportCommand.Create(1, 1, 0, ReadOnlySpan<byte>.Empty);

List<byte> parsedNodeIds = [];
(byte maxNodesSupported, byte reportsToFollow) =
AssociationCommandClass.AssociationReportCommand.ParseInto(
report.Frame, parsedNodeIds, NullLogger.Instance);

Assert.AreEqual((byte)1, maxNodesSupported);
Assert.AreEqual((byte)0, reportsToFollow);
Assert.IsEmpty(parsedNodeIds);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,28 @@ public void SpecificGroupReport_Parse_TooShort_Throws()
() => AssociationCommandClass.AssociationSpecificGroupReportCommand.Parse(
frame, NullLogger.Instance));
}

[TestMethod]
public void SpecificGroupReport_Create_ParseRoundTrip()
{
AssociationCommandClass.AssociationSpecificGroupReportCommand report =
AssociationCommandClass.AssociationSpecificGroupReportCommand.Create(5);

byte group = AssociationCommandClass.AssociationSpecificGroupReportCommand.Parse(
report.Frame, NullLogger.Instance);

Assert.AreEqual((byte)5, group);
}

[TestMethod]
public void SpecificGroupReport_Create_NotSupported()
{
AssociationCommandClass.AssociationSpecificGroupReportCommand report =
AssociationCommandClass.AssociationSpecificGroupReportCommand.Create(0);

byte group = AssociationCommandClass.AssociationSpecificGroupReportCommand.Parse(
report.Frame, NullLogger.Instance);

Assert.AreEqual((byte)0, group);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,16 @@ public void SupportedGroupingsReport_Parse_TooShort_Throws()
() => AssociationCommandClass.AssociationSupportedGroupingsReportCommand.Parse(
frame, NullLogger.Instance));
}

[TestMethod]
public void SupportedGroupingsReport_Create_ParseRoundTrip()
{
AssociationCommandClass.AssociationSupportedGroupingsReportCommand report =
AssociationCommandClass.AssociationSupportedGroupingsReportCommand.Create(3);

byte groupings = AssociationCommandClass.AssociationSupportedGroupingsReportCommand.Parse(
report.Frame, NullLogger.Instance);

Assert.AreEqual((byte)3, groupings);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,73 @@ public void CommandListReport_Parse_MixedNormalAndExtended()
Assert.AreEqual((ushort)CommandClassId.Notification, commands[2].CommandClassId);
Assert.AreEqual((byte)0x05, commands[2].CommandId);
}

[TestMethod]
public void CommandListReport_Create_ParseRoundTrip_SingleCommand()
{
AssociationGroupCommand[] commands =
[
new AssociationGroupCommand((ushort)CommandClassId.DeviceResetLocally, 0x01),
];

AssociationGroupInformationCommandClass.CommandListReportCommand report =
AssociationGroupInformationCommandClass.CommandListReportCommand.Create(1, commands);

(byte groupingIdentifier, IReadOnlyList<AssociationGroupCommand> parsedCommands) =
AssociationGroupInformationCommandClass.CommandListReportCommand.Parse(
report.Frame, NullLogger.Instance);

Assert.AreEqual((byte)1, groupingIdentifier);
Assert.HasCount(1, parsedCommands);
Assert.AreEqual((ushort)CommandClassId.DeviceResetLocally, parsedCommands[0].CommandClassId);
Assert.AreEqual((byte)0x01, parsedCommands[0].CommandId);
}

[TestMethod]
public void CommandListReport_Create_ParseRoundTrip_MultipleCommands()
{
AssociationGroupCommand[] commands =
[
new AssociationGroupCommand((ushort)CommandClassId.Notification, 0x05),
new AssociationGroupCommand((ushort)CommandClassId.Battery, 0x03),
new AssociationGroupCommand((ushort)CommandClassId.DeviceResetLocally, 0x01),
];

AssociationGroupInformationCommandClass.CommandListReportCommand report =
AssociationGroupInformationCommandClass.CommandListReportCommand.Create(1, commands);

(byte groupingIdentifier, IReadOnlyList<AssociationGroupCommand> parsedCommands) =
AssociationGroupInformationCommandClass.CommandListReportCommand.Parse(
report.Frame, NullLogger.Instance);

Assert.AreEqual((byte)1, groupingIdentifier);
Assert.HasCount(3, parsedCommands);
Assert.AreEqual((ushort)CommandClassId.Notification, parsedCommands[0].CommandClassId);
Assert.AreEqual((ushort)CommandClassId.Battery, parsedCommands[1].CommandClassId);
Assert.AreEqual((ushort)CommandClassId.DeviceResetLocally, parsedCommands[2].CommandClassId);
}

[TestMethod]
public void CommandListReport_Create_ParseRoundTrip_ExtendedCC()
{
AssociationGroupCommand[] commands =
[
new AssociationGroupCommand(0xF205, 0x03),
new AssociationGroupCommand((ushort)CommandClassId.Basic, 0x01),
];

AssociationGroupInformationCommandClass.CommandListReportCommand report =
AssociationGroupInformationCommandClass.CommandListReportCommand.Create(2, commands);

(byte groupingIdentifier, IReadOnlyList<AssociationGroupCommand> parsedCommands) =
AssociationGroupInformationCommandClass.CommandListReportCommand.Parse(
report.Frame, NullLogger.Instance);

Assert.AreEqual((byte)2, groupingIdentifier);
Assert.HasCount(2, parsedCommands);
Assert.AreEqual((ushort)0xF205, parsedCommands[0].CommandClassId);
Assert.AreEqual((byte)0x03, parsedCommands[0].CommandId);
Assert.AreEqual((ushort)CommandClassId.Basic, parsedCommands[1].CommandClassId);
Assert.AreEqual((byte)0x01, parsedCommands[1].CommandId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -238,4 +238,54 @@ public void GroupInfoReport_Parse_IgnoresReservedFields()
Assert.AreEqual((byte)0x00, groups[0].Profile.Category);
Assert.AreEqual((byte)0x01, groups[0].Profile.Identifier);
}

[TestMethod]
public void GroupInfoReport_Create_ParseRoundTrip_SingleGroup()
{
AssociationGroupInfo[] groups =
[
new AssociationGroupInfo(1, new AssociationGroupProfile(0x00, 0x01)),
];

AssociationGroupInformationCommandClass.GroupInfoReportCommand report =
AssociationGroupInformationCommandClass.GroupInfoReportCommand.Create(
listMode: false, dynamicInfo: false, groups);

(bool dynamicInfo, List<AssociationGroupInfo> parsedGroups) =
AssociationGroupInformationCommandClass.GroupInfoReportCommand.Parse(
report.Frame, NullLogger.Instance);

Assert.IsFalse(dynamicInfo);
Assert.HasCount(1, parsedGroups);
Assert.AreEqual((byte)1, parsedGroups[0].GroupingIdentifier);
Assert.AreEqual((byte)0x00, parsedGroups[0].Profile.Category);
Assert.AreEqual((byte)0x01, parsedGroups[0].Profile.Identifier);
}

[TestMethod]
public void GroupInfoReport_Create_ParseRoundTrip_ListMode()
{
AssociationGroupInfo[] groups =
[
new AssociationGroupInfo(1, new AssociationGroupProfile(0x00, 0x01)),
new AssociationGroupInfo(2, new AssociationGroupProfile(0x20, 0x01)),
];

AssociationGroupInformationCommandClass.GroupInfoReportCommand report =
AssociationGroupInformationCommandClass.GroupInfoReportCommand.Create(
listMode: true, dynamicInfo: true, groups);

(bool dynamicInfo, List<AssociationGroupInfo> parsedGroups) =
AssociationGroupInformationCommandClass.GroupInfoReportCommand.Parse(
report.Frame, NullLogger.Instance);

Assert.IsTrue(dynamicInfo);
Assert.HasCount(2, parsedGroups);
Assert.AreEqual((byte)1, parsedGroups[0].GroupingIdentifier);
Assert.AreEqual((byte)0x00, parsedGroups[0].Profile.Category);
Assert.AreEqual((byte)0x01, parsedGroups[0].Profile.Identifier);
Assert.AreEqual((byte)2, parsedGroups[1].GroupingIdentifier);
Assert.AreEqual((byte)0x20, parsedGroups[1].Profile.Category);
Assert.AreEqual((byte)0x01, parsedGroups[1].Profile.Identifier);
}
}
Loading