From 8ffe8a0d785946edf498ca5e7d46b21c4315e9de Mon Sep 17 00:00:00 2001 From: David Federman Date: Tue, 3 Mar 2026 20:53:25 -0800 Subject: [PATCH] Handle incoming Association and AGI commands directed at the controller --- .github/copilot-instructions.md | 7 +- .../AssociationCommandClassTests.Report.cs | 36 +++ ...ociationCommandClassTests.SpecificGroup.cs | 24 ++ ...ionCommandClassTests.SupportedGroupings.cs | 12 + ...nformationCommandClassTests.CommandList.cs | 69 ++++ ...pInformationCommandClassTests.GroupInfo.cs | 50 +++ ...pInformationCommandClassTests.GroupName.cs | 42 +++ .../AssociationCommandClass.Report.cs | 16 + .../AssociationCommandClass.SpecificGroup.cs | 7 + ...ociationCommandClass.SupportedGroupings.cs | 7 + ...roupInformationCommandClass.CommandList.cs | 36 +++ ...nGroupInformationCommandClass.GroupInfo.cs | 38 +++ ...nGroupInformationCommandClass.GroupName.cs | 12 + src/ZWave/Controller.cs | 24 ++ src/ZWave/ControllerCommandHandler.cs | 296 ++++++++++++++++++ src/ZWave/Driver.cs | 7 + src/ZWave/Logging.cs | 6 + 17 files changed, 687 insertions(+), 2 deletions(-) create mode 100644 src/ZWave/ControllerCommandHandler.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3e02138..c43b7c4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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?` 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 **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`. 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`. 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 diff --git a/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.Report.cs b/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.Report.cs index da3609b..d99f489 100644 --- a/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.Report.cs +++ b/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.Report.cs @@ -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 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.Empty); + + List 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); + } } diff --git a/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SpecificGroup.cs b/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SpecificGroup.cs index 1554b66..3d7e0b7 100644 --- a/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SpecificGroup.cs +++ b/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SpecificGroup.cs @@ -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); + } } diff --git a/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SupportedGroupings.cs b/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SupportedGroupings.cs index d5c1ce4..7c4bf35 100644 --- a/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SupportedGroupings.cs +++ b/src/ZWave.CommandClasses.Tests/AssociationCommandClassTests.SupportedGroupings.cs @@ -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); + } } diff --git a/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.CommandList.cs b/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.CommandList.cs index 5be2269..b4a449f 100644 --- a/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.CommandList.cs +++ b/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.CommandList.cs @@ -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 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 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 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); + } } diff --git a/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.GroupInfo.cs b/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.GroupInfo.cs index 25cf0d9..18c920b 100644 --- a/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.GroupInfo.cs +++ b/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.GroupInfo.cs @@ -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 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 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); + } } diff --git a/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.GroupName.cs b/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.GroupName.cs index e202192..7842cf4 100644 --- a/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.GroupName.cs +++ b/src/ZWave.CommandClasses.Tests/AssociationGroupInformationCommandClassTests.GroupName.cs @@ -109,4 +109,46 @@ public void GroupNameReport_Parse_TruncatedName_Throws() () => AssociationGroupInformationCommandClass.GroupNameReportCommand.Parse( frame, NullLogger.Instance)); } + + [TestMethod] + public void GroupNameReport_Create_ParseRoundTrip() + { + AssociationGroupInformationCommandClass.GroupNameReportCommand report = + AssociationGroupInformationCommandClass.GroupNameReportCommand.Create(1, "Lifeline"); + + (byte groupingIdentifier, string name) = + AssociationGroupInformationCommandClass.GroupNameReportCommand.Parse( + report.Frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, groupingIdentifier); + Assert.AreEqual("Lifeline", name); + } + + [TestMethod] + public void GroupNameReport_Create_EmptyName_ParseRoundTrip() + { + AssociationGroupInformationCommandClass.GroupNameReportCommand report = + AssociationGroupInformationCommandClass.GroupNameReportCommand.Create(2, string.Empty); + + (byte groupingIdentifier, string name) = + AssociationGroupInformationCommandClass.GroupNameReportCommand.Parse( + report.Frame, NullLogger.Instance); + + Assert.AreEqual((byte)2, groupingIdentifier); + Assert.AreEqual(string.Empty, name); + } + + [TestMethod] + public void GroupNameReport_Create_Utf8Name_ParseRoundTrip() + { + AssociationGroupInformationCommandClass.GroupNameReportCommand report = + AssociationGroupInformationCommandClass.GroupNameReportCommand.Create(1, "café"); + + (byte groupingIdentifier, string name) = + AssociationGroupInformationCommandClass.GroupNameReportCommand.Parse( + report.Frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, groupingIdentifier); + Assert.AreEqual("café", name); + } } diff --git a/src/ZWave.CommandClasses/AssociationCommandClass.Report.cs b/src/ZWave.CommandClasses/AssociationCommandClass.Report.cs index 1189f08..d30da57 100644 --- a/src/ZWave.CommandClasses/AssociationCommandClass.Report.cs +++ b/src/ZWave.CommandClasses/AssociationCommandClass.Report.cs @@ -114,6 +114,22 @@ public AssociationReportCommand(CommandClassFrame frame) public CommandClassFrame Frame { get; } + public static AssociationReportCommand Create( + byte groupingIdentifier, + byte maxNodesSupported, + byte reportsToFollow, + ReadOnlySpan nodeIdDestinations) + { + Span commandParameters = stackalloc byte[3 + nodeIdDestinations.Length]; + commandParameters[0] = groupingIdentifier; + commandParameters[1] = maxNodesSupported; + commandParameters[2] = reportsToFollow; + nodeIdDestinations.CopyTo(commandParameters[3..]); + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new AssociationReportCommand(frame); + } + /// /// Parse a single Association Report frame, appending NodeID destinations to the provided list. /// diff --git a/src/ZWave.CommandClasses/AssociationCommandClass.SpecificGroup.cs b/src/ZWave.CommandClasses/AssociationCommandClass.SpecificGroup.cs index fc464d8..f940612 100644 --- a/src/ZWave.CommandClasses/AssociationCommandClass.SpecificGroup.cs +++ b/src/ZWave.CommandClasses/AssociationCommandClass.SpecificGroup.cs @@ -58,6 +58,13 @@ public AssociationSpecificGroupReportCommand(CommandClassFrame frame) public CommandClassFrame Frame { get; } + public static AssociationSpecificGroupReportCommand Create(byte group) + { + ReadOnlySpan commandParameters = [group]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new AssociationSpecificGroupReportCommand(frame); + } + public static byte Parse(CommandClassFrame frame, ILogger logger) { if (frame.CommandParameters.Length < 1) diff --git a/src/ZWave.CommandClasses/AssociationCommandClass.SupportedGroupings.cs b/src/ZWave.CommandClasses/AssociationCommandClass.SupportedGroupings.cs index 08a9f53..2cb3099 100644 --- a/src/ZWave.CommandClasses/AssociationCommandClass.SupportedGroupings.cs +++ b/src/ZWave.CommandClasses/AssociationCommandClass.SupportedGroupings.cs @@ -55,6 +55,13 @@ public AssociationSupportedGroupingsReportCommand(CommandClassFrame frame) public CommandClassFrame Frame { get; } + public static AssociationSupportedGroupingsReportCommand Create(byte supportedGroupings) + { + ReadOnlySpan commandParameters = [supportedGroupings]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new AssociationSupportedGroupingsReportCommand(frame); + } + public static byte Parse(CommandClassFrame frame, ILogger logger) { if (frame.CommandParameters.Length < 1) diff --git a/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.CommandList.cs b/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.CommandList.cs index eaf17c6..c0fa821 100644 --- a/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.CommandList.cs +++ b/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.CommandList.cs @@ -89,6 +89,42 @@ public CommandListReportCommand(CommandClassFrame frame) public CommandClassFrame Frame { get; } + public static CommandListReportCommand Create( + byte groupingIdentifier, + IReadOnlyList commands) + { + // Calculate list length: each command is 2 bytes (normal CC) or 3 bytes (extended CC) + int listLength = 0; + for (int i = 0; i < commands.Count; i++) + { + listLength += commands[i].CommandClassId >= 0xF100 ? 3 : 2; + } + + Span commandParameters = stackalloc byte[2 + listLength]; + commandParameters[0] = groupingIdentifier; + commandParameters[1] = (byte)listLength; + + int offset = 2; + for (int i = 0; i < commands.Count; i++) + { + AssociationGroupCommand cmd = commands[i]; + if (cmd.CommandClassId >= 0xF100) + { + commandParameters[offset++] = (byte)(cmd.CommandClassId >> 8); + commandParameters[offset++] = (byte)(cmd.CommandClassId & 0xFF); + } + else + { + commandParameters[offset++] = (byte)cmd.CommandClassId; + } + + commandParameters[offset++] = cmd.CommandId; + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new CommandListReportCommand(frame); + } + /// /// Parse an Association Group Command List Report frame. /// diff --git a/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.GroupInfo.cs b/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.GroupInfo.cs index 7e06f64..a5eec46 100644 --- a/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.GroupInfo.cs +++ b/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.GroupInfo.cs @@ -213,6 +213,44 @@ public GroupInfoReportCommand(CommandClassFrame frame) public CommandClassFrame Frame { get; } + public static GroupInfoReportCommand Create( + bool listMode, + bool dynamicInfo, + IReadOnlyList groups) + { + const int GroupEntrySize = 7; + Span commandParameters = stackalloc byte[1 + (groups.Count * GroupEntrySize)]; + + // Byte 0: [List Mode (1 bit)] [Dynamic Info (1 bit)] [Group Count (6 bits)] + byte flags = (byte)(groups.Count & 0b0011_1111); + if (listMode) + { + flags |= 0b1000_0000; + } + + if (dynamicInfo) + { + flags |= 0b0100_0000; + } + + commandParameters[0] = flags; + + for (int i = 0; i < groups.Count; i++) + { + int offset = 1 + (i * GroupEntrySize); + commandParameters[offset] = groups[i].GroupingIdentifier; + commandParameters[offset + 1] = 0; // Mode = 0 per spec CC:0059.01.04.11.008 + commandParameters[offset + 2] = groups[i].Profile.Category; + commandParameters[offset + 3] = groups[i].Profile.Identifier; + commandParameters[offset + 4] = 0; // Reserved per spec CC:0059.01.04.11.00A + commandParameters[offset + 5] = 0; // Event Code MSB = 0 per spec CC:0059.01.04.11.00B + commandParameters[offset + 6] = 0; // Event Code LSB = 0 + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new GroupInfoReportCommand(frame); + } + /// /// Parse an Association Group Info Report frame. /// diff --git a/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.GroupName.cs b/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.GroupName.cs index ce426c3..5ead43d 100644 --- a/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.GroupName.cs +++ b/src/ZWave.CommandClasses/AssociationGroupInformationCommandClass.GroupName.cs @@ -64,6 +64,18 @@ public GroupNameReportCommand(CommandClassFrame frame) public CommandClassFrame Frame { get; } + public static GroupNameReportCommand Create(byte groupingIdentifier, string name) + { + byte[] nameBytes = System.Text.Encoding.UTF8.GetBytes(name); + Span commandParameters = stackalloc byte[2 + nameBytes.Length]; + commandParameters[0] = groupingIdentifier; + commandParameters[1] = (byte)nameBytes.Length; + nameBytes.CopyTo(commandParameters[2..]); + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new GroupNameReportCommand(frame); + } + /// /// Parse an Association Group Name Report frame. /// diff --git a/src/ZWave/Controller.cs b/src/ZWave/Controller.cs index f5ad119..71061d6 100644 --- a/src/ZWave/Controller.cs +++ b/src/ZWave/Controller.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using Microsoft.Extensions.Logging; +using ZWave.CommandClasses; using ZWave.Serial.Commands; namespace ZWave; @@ -14,6 +15,8 @@ public sealed class Controller private readonly Driver _driver; + private readonly ControllerCommandHandler _commandHandler; + private readonly Dictionary _nodes = new Dictionary(); public Controller( @@ -22,6 +25,7 @@ public Controller( { _logger = logger; _driver = driver; + _commandHandler = new ControllerCommandHandler(this, driver, logger); } /// @@ -104,6 +108,26 @@ public Controller( /// public IReadOnlyDictionary Nodes => _nodes; + /// + /// The maximum number of destinations in the controller's lifeline association group. + /// + internal const int MaxAssociationDestinations = 1; + + /// + /// Gets or sets the controller's lifeline association destinations. + /// Other nodes may add/remove themselves via Association Set/Remove commands. + /// + internal List Associations { get; set; } = []; + + /// + /// Handles an incoming command from another node directed at this controller. + /// Delegates to the internal for CC-specific handling. + /// + internal void HandleCommand(CommandClassFrame frame, ushort sourceNodeId) + { + _commandHandler.HandleCommand(frame, sourceNodeId); + } + /// /// Queries the controller to identify its properties and discover the network nodes. /// diff --git a/src/ZWave/ControllerCommandHandler.cs b/src/ZWave/ControllerCommandHandler.cs new file mode 100644 index 0000000..d57c5e4 --- /dev/null +++ b/src/ZWave/ControllerCommandHandler.cs @@ -0,0 +1,296 @@ +using Microsoft.Extensions.Logging; +using ZWave.CommandClasses; + +namespace ZWave; + +/// +/// Handles incoming commands from other nodes that are directed at the controller. +/// This implements the "supporting side" of command classes — responding to queries +/// about the controller's own state (association groups, version info, etc.). +/// +internal sealed class ControllerCommandHandler +{ + /// + /// The Device Reset Locally Notification command ID (0x01). + /// + private const byte DeviceResetLocallyNotificationCommandId = 0x01; + + private readonly Controller _controller; + private readonly Driver _driver; + private readonly ILogger _logger; + + internal ControllerCommandHandler(Controller controller, Driver driver, ILogger logger) + { + _controller = controller; + _driver = driver; + _logger = logger; + } + + /// + /// Dispatches an incoming command to the appropriate handler. + /// Called from when a command is received + /// from another node (i.e., not from the controller itself). + /// + internal void HandleCommand(CommandClassFrame frame, ushort sourceNodeId) + { + switch (frame.CommandClassId) + { + case CommandClassId.Association: + { + HandleAssociationCommand(frame, sourceNodeId); + break; + } + case CommandClassId.AssociationGroupInformation: + { + HandleAGICommand(frame, sourceNodeId); + break; + } + } + } + + #region Response Helpers + + private void SendResponse(T response, ushort destinationNodeId) where T : struct, ICommand + { + _ = SendResponseAsync(response, destinationNodeId); + } + + private async Task SendResponseAsync(T response, ushort destinationNodeId) + where T : struct, ICommand + { + try + { + await _driver.SendCommandAsync(response, destinationNodeId, 0, CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogControllerResponseFailed(destinationNodeId, ex); + } + } + + #endregion + + #region Association CC Handlers + + private void HandleAssociationCommand(CommandClassFrame frame, ushort sourceNodeId) + { + AssociationCommand command = (AssociationCommand)frame.CommandId; + switch (command) + { + case AssociationCommand.Get: + { + HandleAssociationGet(sourceNodeId); + break; + } + case AssociationCommand.Set: + { + HandleAssociationSet(frame); + break; + } + case AssociationCommand.Remove: + { + HandleAssociationRemove(frame); + break; + } + case AssociationCommand.SupportedGroupingsGet: + { + HandleAssociationSupportedGroupingsGet(sourceNodeId); + break; + } + case AssociationCommand.SpecificGroupGet: + { + HandleAssociationSpecificGroupGet(sourceNodeId); + break; + } + } + } + + private void HandleAssociationGet(ushort sourceNodeId) + { + // Per CC:0085.01.02.12.001: return info for group 1 for unsupported group IDs. + // The controller only supports group 1 (Lifeline). + const byte groupId = 1; + + List associations = _controller.Associations; + + AssociationCommandClass.AssociationReportCommand report = + AssociationCommandClass.AssociationReportCommand.Create( + groupId, + Controller.MaxAssociationDestinations, + reportsToFollow: 0, + [.. associations]); + SendResponse(report, sourceNodeId); + } + + private void HandleAssociationSet(CommandClassFrame frame) + { + if (frame.CommandParameters.Length < 1) + { + return; + } + + ReadOnlySpan span = frame.CommandParameters.Span; + byte groupId = span[0]; + + // We only support the lifeline group (group 1). + // Per CC:0085.01.01.11.003: ignore unsupported grouping identifiers. + if (groupId != 1) + { + return; + } + + ReadOnlySpan nodeIds = span[1..]; + List associations = _controller.Associations; + for (int i = 0; i < nodeIds.Length; i++) + { + byte nodeId = nodeIds[i]; + if (nodeId == 0) + { + continue; + } + + if (!associations.Contains(nodeId)) + { + // Per CC:0085.01.01.13.001: MAY be ignored if the group is already full. + if (associations.Count >= Controller.MaxAssociationDestinations) + { + break; + } + + associations.Add(nodeId); + } + } + } + + private void HandleAssociationRemove(CommandClassFrame frame) + { + if (frame.CommandParameters.Length < 1) + { + return; + } + + ReadOnlySpan span = frame.CommandParameters.Span; + byte groupId = span[0]; + + // Per CC:0085.02.04.11.003: ignore unsupported grouping identifiers, + // except 0 which MUST be accepted (targets all groups). + if (groupId != 0 && groupId != 1) + { + return; + } + + ReadOnlySpan nodeIds = span[1..]; + List associations = _controller.Associations; + if (nodeIds.Length == 0) + { + // Remove all destinations from the group (or all groups if groupId=0). + associations.Clear(); + } + else + { + for (int i = 0; i < nodeIds.Length; i++) + { + associations.Remove(nodeIds[i]); + } + } + } + + private void HandleAssociationSupportedGroupingsGet(ushort sourceNodeId) + { + // The controller has exactly 1 association group (Lifeline). + AssociationCommandClass.AssociationSupportedGroupingsReportCommand report = + AssociationCommandClass.AssociationSupportedGroupingsReportCommand.Create(1); + SendResponse(report, sourceNodeId); + } + + private void HandleAssociationSpecificGroupGet(ushort sourceNodeId) + { + // Per CC:0085.02.0C.12.002: return 0 if not supported. + AssociationCommandClass.AssociationSpecificGroupReportCommand report = + AssociationCommandClass.AssociationSpecificGroupReportCommand.Create(0); + SendResponse(report, sourceNodeId); + } + + #endregion + + #region Association Group Information CC Handlers + + private void HandleAGICommand(CommandClassFrame frame, ushort sourceNodeId) + { + AssociationGroupInformationCommand command = (AssociationGroupInformationCommand)frame.CommandId; + switch (command) + { + case AssociationGroupInformationCommand.GroupNameGet: + { + HandleAGIGroupNameGet(sourceNodeId); + break; + } + case AssociationGroupInformationCommand.GroupInfoGet: + { + HandleAGIGroupInfoGet(frame, sourceNodeId); + break; + } + case AssociationGroupInformationCommand.CommandListGet: + { + HandleAGICommandListGet(sourceNodeId); + break; + } + } + } + + private void HandleAGIGroupNameGet(ushort sourceNodeId) + { + // Per CC:0059.01.01.12.001: return info for group 1 for unsupported group IDs. + // Per CC:0059.01.00.11.006: root device lifeline group MUST be named "Lifeline". + const byte groupId = 1; + + AssociationGroupInformationCommandClass.GroupNameReportCommand report = + AssociationGroupInformationCommandClass.GroupNameReportCommand.Create(groupId, "Lifeline"); + SendResponse(report, sourceNodeId); + } + + private void HandleAGIGroupInfoGet(CommandClassFrame frame, ushort sourceNodeId) + { + // Parse the List Mode flag from the request. + bool listMode = false; + if (frame.CommandParameters.Length >= 1) + { + listMode = (frame.CommandParameters.Span[0] & 0b0100_0000) != 0; + } + + // Per CC:0059.01.00.11.005: lifeline profile MUST be advertised for group 1. + // Profile = General:Lifeline (0x00, 0x01). + AssociationGroupInfo[] groups = + [ + new AssociationGroupInfo(1, new AssociationGroupProfile(0x00, 0x01)), + ]; + + AssociationGroupInformationCommandClass.GroupInfoReportCommand report = + AssociationGroupInformationCommandClass.GroupInfoReportCommand.Create( + listMode, + dynamicInfo: false, + groups); + SendResponse(report, sourceNodeId); + } + + private void HandleAGICommandListGet(ushort sourceNodeId) + { + // Per CC:0059.01.05.12.002: return info for group 1 for unsupported group IDs. + const byte groupId = 1; + + // The controller's lifeline group sends Device Reset Locally Notification. + AssociationGroupCommand[] commands = + [ + new AssociationGroupCommand( + (ushort)CommandClassId.DeviceResetLocally, + DeviceResetLocallyNotificationCommandId), + ]; + + AssociationGroupInformationCommandClass.CommandListReportCommand report = + AssociationGroupInformationCommandClass.CommandListReportCommand.Create(groupId, commands); + SendResponse(report, sourceNodeId); + } + + #endregion +} diff --git a/src/ZWave/Driver.cs b/src/ZWave/Driver.cs index 7cc1623..1018ff0 100644 --- a/src/ZWave/Driver.cs +++ b/src/ZWave/Driver.cs @@ -213,6 +213,13 @@ private void ProcessDataFrame(DataFrame frame) } node.ProcessCommand(commandClassFrame, endpointIndex); + + // Route to controller for supporting-side handling (responding to queries + // from other nodes about the controller's own association groups, etc.). + if (applicationCommandHandler.NodeId != Controller.NodeId) + { + Controller.HandleCommand(commandClassFrame, applicationCommandHandler.NodeId); + } } else { diff --git a/src/ZWave/Logging.cs b/src/ZWave/Logging.cs index df09e22..f5a9219 100644 --- a/src/ZWave/Logging.cs +++ b/src/ZWave/Logging.cs @@ -171,4 +171,10 @@ public static partial void LogInitData( 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); + + [LoggerMessage( + EventId = 223, + Level = LogLevel.Warning, + Message = "Failed to send controller response to node {nodeId}")] + public static partial void LogControllerResponseFailed(this ILogger logger, ushort nodeId, Exception ex); }