diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e203a2c..20d44fb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -78,7 +78,7 @@ 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). - **`[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. @@ -87,11 +87,11 @@ 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. +- **`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/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/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); }