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
8 changes: 4 additions & 4 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TCommand>`** — Abstract base classes. Each CC (e.g. `BinarySwitchCommandClass`) inherits from `CommandClass<TEnum>` 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<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. 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<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.

### Source Generators (`src/ZWave.BuildTools/`)

Expand Down
11 changes: 11 additions & 0 deletions src/ZWave.CommandClasses.Tests/ClockCommandClassTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -223,4 +223,15 @@ public void Report_Parse_InvalidMinute_ThrowsZWaveException()
Assert.Throws<ZWaveException>(
() => 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<ZWaveException>(
() => ClockCommandClass.ClockReportCommand.Parse(frame, NullLogger.Instance));
}
}
Original file line number Diff line number Diff line change
@@ -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<EndPointDestination>());

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<byte> 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<byte>(),
new EndPointDestination[] { new EndPointDestination(5, 1) });

// CC + Cmd + GroupId + Marker + MCNodeID + Properties = 6 bytes
Assert.AreEqual(6, command.Frame.Data.Length);
ReadOnlySpan<byte> 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<byte> 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<byte>(),
new EndPointDestination[] { new EndPointDestination(4, new byte[] { 1, 2, 3 }) });

ReadOnlySpan<byte> 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<byte>(),
Array.Empty<EndPointDestination>());

// CC + Cmd + GroupId = 3 bytes. No marker because no EP destinations.
Assert.AreEqual(3, command.Frame.Data.Length);
ReadOnlySpan<byte> 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<EndPointDestination>());

Assert.AreEqual(CommandClassId.MultiChannelAssociation, MultiChannelAssociationCommandClass.MultiChannelAssociationRemoveCommand.CommandClassId);
Assert.AreEqual((byte)MultiChannelAssociationCommand.Remove, MultiChannelAssociationCommandClass.MultiChannelAssociationRemoveCommand.CommandId);

ReadOnlySpan<byte> 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<byte>(),
Array.Empty<EndPointDestination>());

// CC + Cmd + GroupId = 3 bytes
Assert.AreEqual(3, command.Frame.Data.Length);
ReadOnlySpan<byte> 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<byte>(),
Array.Empty<EndPointDestination>());

Assert.AreEqual(3, command.Frame.Data.Length);
ReadOnlySpan<byte> 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<EndPointDestination>());

ReadOnlySpan<byte> 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<byte>(),
new EndPointDestination[] { new EndPointDestination(5, 2) });

ReadOnlySpan<byte> 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<byte> 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
}
}
Loading