diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c43b7c4..1d1334a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -87,12 +87,13 @@ 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. +- **`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 per spec §4.1.3.5 order. On receive (reverse order): Security/CRC-16/Transport Service → Multi Channel → Supervision → Multi Command. On send: payload → Multi Command → Supervision → Multi Channel → Security/CRC-16/Transport Service. Currently implements Multi Channel and Supervision layers; future encapsulation layers (Security, Transport Service) plug into the same hooks. For incoming Supervision Get frames, the Driver de-encapsulates the inner command, processes it, and sends back a Supervision Report (SUCCESS) — unless the frame was received via multicast/broadcast, per spec CC:006C.01.01.11.005. Incoming Supervision Report frames are routed directly to the node's Supervision CC instance. - **`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. +- **`SupervisionCommandClass`** — Implements the Supervision CC (version 2) in `src/ZWave.CommandClasses/`. Provides application-level delivery confirmation for Set-type and unsolicited Report commands. A Transport CC (interviewed after Management CCs) with no mandatory interview. Provides static methods `CreateGet()`, `ParseGet()`, `CreateReport()`, `ParseReport()` used by the Driver for encapsulation/de-encapsulation. The `SupervisionGet` record wraps an encapsulated command with a 6-bit session ID (0-63) and a `StatusUpdates` flag. The `SupervisionReport` record carries status (`NoSupport`, `Working`, `Fail`, `Success`), duration, and v2's `WakeUpRequest` bit. The Driver handles de-encapsulation in its receive path and automatic Supervision Report responses. Future work: wrapping outgoing Set commands with Supervision Get (send-side, requires status state machine). ### Source Generators (`src/ZWave.BuildTools/`) diff --git a/src/ZWave.CommandClasses.Tests/SupervisionCommandClassTests.Report.cs b/src/ZWave.CommandClasses.Tests/SupervisionCommandClassTests.Report.cs new file mode 100644 index 0000000..374239e --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/SupervisionCommandClassTests.Report.cs @@ -0,0 +1,405 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class SupervisionCommandClassTests +{ + [TestMethod] + public void GetCommand_Create_HasCorrectFormat() + { + // Encapsulate a Basic Set (0x20, 0x01, 0xFF) with session ID 5, no status updates + CommandClassFrame innerFrame = CommandClassFrame.Create(CommandClassId.Basic, 0x01, [0xFF]); + CommandClassFrame getFrame = SupervisionCommandClass.CreateGet(statusUpdates: false, sessionId: 5, innerFrame); + + Assert.AreEqual(CommandClassId.Supervision, getFrame.CommandClassId); + Assert.AreEqual((byte)SupervisionCommand.Get, getFrame.CommandId); + + ReadOnlySpan parameters = getFrame.CommandParameters.Span; + // byte 0: StatusUpdates(0) | Reserved(0) | SessionID(5) = 0x05 + Assert.AreEqual((byte)0x05, parameters[0]); + // byte 1: Encapsulated command length = 3 (CC + Cmd + param) + Assert.AreEqual((byte)3, parameters[1]); + // bytes 2..4: Encapsulated command + Assert.AreEqual((byte)0x20, parameters[2]); // Basic CC + Assert.AreEqual((byte)0x01, parameters[3]); // Set + Assert.AreEqual((byte)0xFF, parameters[4]); // Value + } + + [TestMethod] + public void GetCommand_Create_WithStatusUpdates() + { + CommandClassFrame innerFrame = CommandClassFrame.Create(CommandClassId.Basic, 0x01, [0xFF]); + CommandClassFrame getFrame = SupervisionCommandClass.CreateGet(statusUpdates: true, sessionId: 10, innerFrame); + + ReadOnlySpan parameters = getFrame.CommandParameters.Span; + // byte 0: StatusUpdates(1) | Reserved(0) | SessionID(10) = 0x80 | 0x0A = 0x8A + Assert.AreEqual((byte)0x8A, parameters[0]); + } + + [TestMethod] + public void GetCommand_Create_MaxSessionId() + { + CommandClassFrame innerFrame = CommandClassFrame.Create(CommandClassId.Basic, 0x01, [0xFF]); + CommandClassFrame getFrame = SupervisionCommandClass.CreateGet(statusUpdates: false, sessionId: 63, innerFrame); + + ReadOnlySpan parameters = getFrame.CommandParameters.Span; + Assert.AreEqual((byte)63, parameters[0] & 0x3F); + } + + [TestMethod] + public void GetCommand_Create_RejectsSessionIdAbove63() + { + CommandClassFrame innerFrame = CommandClassFrame.Create(CommandClassId.Basic, 0x01, [0xFF]); + Assert.Throws( + () => SupervisionCommandClass.CreateGet(statusUpdates: false, sessionId: 64, innerFrame)); + } + + [TestMethod] + public void GetCommand_Parse_NoStatusUpdates() + { + // CC=0x6C, Cmd=0x01, Flags=0x05 (sessionId=5, statusUpdates=0), Length=3, Basic Set + byte[] data = [0x6C, 0x01, 0x05, 0x03, 0x20, 0x01, 0xFF]; + CommandClassFrame frame = new(data); + + SupervisionGet get = SupervisionCommandClass.ParseGet(frame, NullLogger.Instance); + + Assert.IsFalse(get.StatusUpdates); + Assert.AreEqual((byte)5, get.SessionId); + Assert.AreEqual(CommandClassId.Basic, get.EncapsulatedFrame.CommandClassId); + Assert.AreEqual((byte)0x01, get.EncapsulatedFrame.CommandId); + Assert.AreEqual(1, get.EncapsulatedFrame.CommandParameters.Length); + Assert.AreEqual((byte)0xFF, get.EncapsulatedFrame.CommandParameters.Span[0]); + } + + [TestMethod] + public void GetCommand_Parse_WithStatusUpdates() + { + // StatusUpdates=1, sessionId=42 → 0x80 | 0x2A = 0xAA + byte[] data = [0x6C, 0x01, 0xAA, 0x03, 0x20, 0x01, 0xFF]; + CommandClassFrame frame = new(data); + + SupervisionGet get = SupervisionCommandClass.ParseGet(frame, NullLogger.Instance); + + Assert.IsTrue(get.StatusUpdates); + Assert.AreEqual((byte)42, get.SessionId); + } + + [TestMethod] + public void GetCommand_Parse_IgnoresReservedBit() + { + // Reserved bit (bit 6) set: 0x45 = 0100_0101 → sessionId=5, statusUpdates=0, reserved=1 + byte[] data = [0x6C, 0x01, 0x45, 0x03, 0x20, 0x01, 0xFF]; + CommandClassFrame frame = new(data); + + SupervisionGet get = SupervisionCommandClass.ParseGet(frame, NullLogger.Instance); + + Assert.IsFalse(get.StatusUpdates); + Assert.AreEqual((byte)5, get.SessionId); + } + + [TestMethod] + public void GetCommand_Parse_TooShort_Throws() + { + // Only 2 parameter bytes (need at least 3: flags + length + 1 byte encapsulated) + byte[] data = [0x6C, 0x01, 0x05, 0x01]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => SupervisionCommandClass.ParseGet(frame, NullLogger.Instance)); + } + + [TestMethod] + public void GetCommand_Parse_LengthMismatch_Throws() + { + // Claims 5 bytes of encapsulated data but only 3 available + byte[] data = [0x6C, 0x01, 0x05, 0x05, 0x20, 0x01, 0xFF]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => SupervisionCommandClass.ParseGet(frame, NullLogger.Instance)); + } + + [TestMethod] + public void GetCommand_RoundTrip_PreservesInnerFrame() + { + byte[] innerParams = [0xFF, 0x01, 0x00, 0x05]; + CommandClassFrame innerFrame = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x01, innerParams); + + CommandClassFrame getFrame = SupervisionCommandClass.CreateGet(statusUpdates: true, sessionId: 33, innerFrame); + SupervisionGet parsed = SupervisionCommandClass.ParseGet(getFrame, NullLogger.Instance); + + Assert.IsTrue(parsed.StatusUpdates); + Assert.AreEqual((byte)33, parsed.SessionId); + Assert.AreEqual(CommandClassId.BinarySwitch, parsed.EncapsulatedFrame.CommandClassId); + Assert.AreEqual((byte)0x01, parsed.EncapsulatedFrame.CommandId); + Assert.IsTrue(innerParams.AsSpan().SequenceEqual(parsed.EncapsulatedFrame.CommandParameters.Span)); + } + + [TestMethod] + public void ReportCommand_Create_Success_HasCorrectFormat() + { + CommandClassFrame reportFrame = SupervisionCommandClass.CreateReport( + moreStatusUpdates: false, + wakeUpRequest: false, + sessionId: 10, + status: SupervisionStatus.Success, + duration: new DurationReport(0)); + + Assert.AreEqual(CommandClassId.Supervision, reportFrame.CommandClassId); + Assert.AreEqual((byte)SupervisionCommand.Report, reportFrame.CommandId); + + ReadOnlySpan parameters = reportFrame.CommandParameters.Span; + // byte 0: MoreStatusUpdates(0) | WakeUpRequest(0) | SessionID(10) = 0x0A + Assert.AreEqual((byte)0x0A, parameters[0]); + // byte 1: Status = SUCCESS = 0xFF + Assert.AreEqual((byte)0xFF, parameters[1]); + // byte 2: Duration = 0 + Assert.AreEqual((byte)0x00, parameters[2]); + } + + [TestMethod] + public void ReportCommand_Create_Working_WithDuration() + { + CommandClassFrame reportFrame = SupervisionCommandClass.CreateReport( + moreStatusUpdates: true, + wakeUpRequest: false, + sessionId: 20, + status: SupervisionStatus.Working, + duration: new DurationReport(5)); + + ReadOnlySpan parameters = reportFrame.CommandParameters.Span; + // byte 0: MoreStatusUpdates(1) | WakeUpRequest(0) | SessionID(20) = 0x80 | 0x14 = 0x94 + Assert.AreEqual((byte)0x94, parameters[0]); + // byte 1: Status = WORKING = 0x01 + Assert.AreEqual((byte)0x01, parameters[1]); + // byte 2: Duration = 5 seconds + Assert.AreEqual((byte)0x05, parameters[2]); + } + + [TestMethod] + public void ReportCommand_Create_WithWakeUpRequest_V2() + { + CommandClassFrame reportFrame = SupervisionCommandClass.CreateReport( + moreStatusUpdates: false, + wakeUpRequest: true, + sessionId: 7, + status: SupervisionStatus.Success, + duration: new DurationReport(0)); + + ReadOnlySpan parameters = reportFrame.CommandParameters.Span; + // byte 0: MoreStatusUpdates(0) | WakeUpRequest(1) | SessionID(7) = 0x40 | 0x07 = 0x47 + Assert.AreEqual((byte)0x47, parameters[0]); + } + + [TestMethod] + public void ReportCommand_Create_NoSupport() + { + CommandClassFrame reportFrame = SupervisionCommandClass.CreateReport( + moreStatusUpdates: false, + wakeUpRequest: false, + sessionId: 0, + status: SupervisionStatus.NoSupport, + duration: new DurationReport(0)); + + ReadOnlySpan parameters = reportFrame.CommandParameters.Span; + Assert.AreEqual((byte)0x00, parameters[1]); // NO_SUPPORT = 0x00 + Assert.AreEqual((byte)0x00, parameters[2]); // Zero duration per spec + } + + [TestMethod] + public void ReportCommand_Create_Fail() + { + CommandClassFrame reportFrame = SupervisionCommandClass.CreateReport( + moreStatusUpdates: false, + wakeUpRequest: false, + sessionId: 15, + status: SupervisionStatus.Fail, + duration: new DurationReport(0)); + + ReadOnlySpan parameters = reportFrame.CommandParameters.Span; + Assert.AreEqual((byte)0x02, parameters[1]); // FAIL = 0x02 + } + + [TestMethod] + public void ReportCommand_Create_RejectsSessionIdAbove63() + { + Assert.Throws( + () => SupervisionCommandClass.CreateReport( + moreStatusUpdates: false, + wakeUpRequest: false, + sessionId: 64, + status: SupervisionStatus.Success, + duration: new DurationReport(0))); + } + + [TestMethod] + public void ReportCommand_Parse_Success() + { + // CC=0x6C, Cmd=0x02, Flags=0x0A (sessionId=10, moreUpdates=0, wakeUp=0), Status=0xFF, Duration=0x00 + byte[] data = [0x6C, 0x02, 0x0A, 0xFF, 0x00]; + CommandClassFrame frame = new(data); + + SupervisionReport report = SupervisionCommandClass.ParseReport(frame, NullLogger.Instance); + + Assert.IsFalse(report.MoreStatusUpdates); + Assert.IsFalse(report.WakeUpRequest); + Assert.AreEqual((byte)10, report.SessionId); + Assert.AreEqual(SupervisionStatus.Success, report.Status); + Assert.AreEqual((byte)0x00, report.Duration.Value); + } + + [TestMethod] + public void ReportCommand_Parse_Working_WithDuration() + { + // Flags=0x94 (moreUpdates=1, wakeUp=0, sessionId=20), Status=0x01, Duration=0x05 + byte[] data = [0x6C, 0x02, 0x94, 0x01, 0x05]; + CommandClassFrame frame = new(data); + + SupervisionReport report = SupervisionCommandClass.ParseReport(frame, NullLogger.Instance); + + Assert.IsTrue(report.MoreStatusUpdates); + Assert.IsFalse(report.WakeUpRequest); + Assert.AreEqual((byte)20, report.SessionId); + Assert.AreEqual(SupervisionStatus.Working, report.Status); + Assert.AreEqual((byte)0x05, report.Duration.Value); + Assert.AreEqual(TimeSpan.FromSeconds(5), report.Duration.Duration); + } + + [TestMethod] + public void ReportCommand_Parse_V2_WithWakeUpRequest() + { + // Flags=0x47 (moreUpdates=0, wakeUp=1, sessionId=7), Status=0xFF, Duration=0x00 + byte[] data = [0x6C, 0x02, 0x47, 0xFF, 0x00]; + CommandClassFrame frame = new(data); + + SupervisionReport report = SupervisionCommandClass.ParseReport(frame, NullLogger.Instance); + + Assert.IsFalse(report.MoreStatusUpdates); + Assert.IsTrue(report.WakeUpRequest); + Assert.AreEqual((byte)7, report.SessionId); + Assert.AreEqual(SupervisionStatus.Success, report.Status); + } + + [TestMethod] + public void ReportCommand_Parse_Fail() + { + byte[] data = [0x6C, 0x02, 0x00, 0x02, 0x00]; + CommandClassFrame frame = new(data); + + SupervisionReport report = SupervisionCommandClass.ParseReport(frame, NullLogger.Instance); + + Assert.AreEqual(SupervisionStatus.Fail, report.Status); + } + + [TestMethod] + public void ReportCommand_Parse_NoSupport() + { + byte[] data = [0x6C, 0x02, 0x00, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + SupervisionReport report = SupervisionCommandClass.ParseReport(frame, NullLogger.Instance); + + Assert.AreEqual(SupervisionStatus.NoSupport, report.Status); + } + + [TestMethod] + public void ReportCommand_Parse_TooShort_Throws() + { + // Only 2 parameter bytes (need 3: flags + status + duration) + byte[] data = [0x6C, 0x02, 0x0A, 0xFF]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => SupervisionCommandClass.ParseReport(frame, NullLogger.Instance)); + } + + [TestMethod] + public void ReportCommand_RoundTrip_AllFlags() + { + CommandClassFrame reportFrame = SupervisionCommandClass.CreateReport( + moreStatusUpdates: true, + wakeUpRequest: true, + sessionId: 63, + status: SupervisionStatus.Working, + duration: new DurationReport(0x85)); // 6 minutes + + SupervisionReport parsed = SupervisionCommandClass.ParseReport(reportFrame, NullLogger.Instance); + + Assert.IsTrue(parsed.MoreStatusUpdates); + Assert.IsTrue(parsed.WakeUpRequest); + Assert.AreEqual((byte)63, parsed.SessionId); + Assert.AreEqual(SupervisionStatus.Working, parsed.Status); + Assert.AreEqual((byte)0x85, parsed.Duration.Value); + Assert.AreEqual(TimeSpan.FromMinutes(6), parsed.Duration.Duration); + } + + [TestMethod] + public void ReportCommand_Parse_ReservedStatusValue() + { + // Use a reserved status value (0x03) — spec says "MUST be ignored by a receiving node" + // but we should still parse without throwing + byte[] data = [0x6C, 0x02, 0x00, 0x03, 0x00]; + CommandClassFrame frame = new(data); + + SupervisionReport report = SupervisionCommandClass.ParseReport(frame, NullLogger.Instance); + + Assert.AreEqual((SupervisionStatus)0x03, report.Status); + } + + [TestMethod] + public void ReportCommand_Parse_DurationMinutes() + { + // Duration = 0xFD → 126 minutes + byte[] data = [0x6C, 0x02, 0x00, 0x01, 0xFD]; + CommandClassFrame frame = new(data); + + SupervisionReport report = SupervisionCommandClass.ParseReport(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0xFD, report.Duration.Value); + Assert.AreEqual(TimeSpan.FromMinutes(126), report.Duration.Duration); + } + + [TestMethod] + public void ReportCommand_Parse_UnknownDuration() + { + // Duration = 0xFE → Unknown + byte[] data = [0x6C, 0x02, 0x00, 0x01, 0xFE]; + CommandClassFrame frame = new(data); + + SupervisionReport report = SupervisionCommandClass.ParseReport(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0xFE, report.Duration.Value); + Assert.IsNull(report.Duration.Duration); + } + + [TestMethod] + public void GetCommand_Create_MinimalEncapsulatedCommand() + { + // Minimum valid encapsulated command: just CC ID + Command ID (2 bytes) + CommandClassFrame innerFrame = CommandClassFrame.Create(CommandClassId.Basic, 0x02); + CommandClassFrame getFrame = SupervisionCommandClass.CreateGet(statusUpdates: false, sessionId: 0, innerFrame); + + SupervisionGet parsed = SupervisionCommandClass.ParseGet(getFrame, NullLogger.Instance); + + Assert.AreEqual((byte)0, parsed.SessionId); + Assert.AreEqual(CommandClassId.Basic, parsed.EncapsulatedFrame.CommandClassId); + Assert.AreEqual((byte)0x02, parsed.EncapsulatedFrame.CommandId); + Assert.AreEqual(0, parsed.EncapsulatedFrame.CommandParameters.Length); + } + + [TestMethod] + public void ReportCommand_Create_BothFlags_CorrectBitLayout() + { + // Verify bit layout: bit7=MoreStatusUpdates, bit6=WakeUpRequest, bits5..0=SessionID + CommandClassFrame reportFrame = SupervisionCommandClass.CreateReport( + moreStatusUpdates: true, + wakeUpRequest: true, + sessionId: 0, + status: SupervisionStatus.Success, + duration: new DurationReport(0)); + + ReadOnlySpan parameters = reportFrame.CommandParameters.Span; + // 0xC0 = 1100_0000 + Assert.AreEqual((byte)0xC0, parameters[0]); + } +} diff --git a/src/ZWave.CommandClasses.Tests/SupervisionCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/SupervisionCommandClassTests.cs new file mode 100644 index 0000000..7cfc800 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/SupervisionCommandClassTests.cs @@ -0,0 +1,6 @@ +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public partial class SupervisionCommandClassTests +{ +} diff --git a/src/ZWave.CommandClasses/CommandClass.cs b/src/ZWave.CommandClasses/CommandClass.cs index b15ebbc..3ad3e22 100644 --- a/src/ZWave.CommandClasses/CommandClass.cs +++ b/src/ZWave.CommandClasses/CommandClass.cs @@ -43,7 +43,7 @@ private record struct AwaitedReport( TaskCompletionSource TaskCompletionSource); // Almost all CCs depend on knowing their own version. - private static readonly CommandClassId[] DefaultDependencies = new[] { CommandClassId.Version }; + private static readonly CommandClassId[] DefaultDependencies = [CommandClassId.Version]; // We don't expect this to get very large at all, so using a simple list to save on memory instead // of Dictionary> which would have faster lookups diff --git a/src/ZWave.CommandClasses/SupervisionCommandClass.Report.cs b/src/ZWave.CommandClasses/SupervisionCommandClass.Report.cs new file mode 100644 index 0000000..c6a18d2 --- /dev/null +++ b/src/ZWave.CommandClasses/SupervisionCommandClass.Report.cs @@ -0,0 +1,243 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a parsed Supervision Get command. +/// +public readonly record struct SupervisionGet( + /// + /// Whether the sender requests future status updates (spec Table 4.33). + /// When true, the receiver should send additional Supervision Reports as status changes. + /// + bool StatusUpdates, + + /// + /// The session identifier (0-63) used to correlate Get/Report pairs. + /// + byte SessionId, + + /// + /// The encapsulated command class frame. + /// + CommandClassFrame EncapsulatedFrame); + +/// +/// Represents a parsed Supervision Report command. +/// +public readonly record struct SupervisionReport( + /// + /// Whether more Supervision Reports follow for this session (spec Table 4.34). + /// + bool MoreStatusUpdates, + + /// + /// Whether the receiving node should initiate a Wake Up Period (v2, spec §4.2.9.2). + /// + bool WakeUpRequest, + + /// + /// The session identifier matching the Supervision Get that initiated this session. + /// + byte SessionId, + + /// + /// The current status of the command process. + /// + SupervisionStatus Status, + + /// + /// The time needed to complete the current operation. + /// + DurationReport Duration); + +public sealed partial class SupervisionCommandClass +{ + /// + /// Event raised when a Supervision Report is received. + /// + public event Action? OnSupervisionReportReceived; + + /// + /// Creates a Supervision Get frame wrapping the specified command. + /// + /// Whether to request future status updates. + /// The session identifier (0-63). + /// The command to encapsulate. + public static CommandClassFrame CreateGet(bool statusUpdates, byte sessionId, CommandClassFrame encapsulatedFrame) + => SupervisionGetCommand.Create(statusUpdates, sessionId, encapsulatedFrame).Frame; + + /// + /// Parses a Supervision Get frame. + /// + public static SupervisionGet ParseGet(CommandClassFrame frame, ILogger logger) + => SupervisionGetCommand.Parse(frame, logger); + + /// + /// Creates a Supervision Report frame. + /// + /// Whether more reports follow for this session. + /// Whether to request a Wake Up Period (v2). + /// The session identifier matching the initiating Get. + /// The current status of the command process. + /// The time needed to complete the operation. + public static CommandClassFrame CreateReport( + bool moreStatusUpdates, + bool wakeUpRequest, + byte sessionId, + SupervisionStatus status, + DurationReport duration) + => SupervisionReportCommand.Create(moreStatusUpdates, wakeUpRequest, sessionId, status, duration).Frame; + + /// + /// Parses a Supervision Report frame. + /// + public static SupervisionReport ParseReport(CommandClassFrame frame, ILogger logger) + => SupervisionReportCommand.Parse(frame, logger); + + /// + /// Supervision Get Command (spec §4.2.8.3). + /// + /// + /// Wire format: + /// byte 0: CC = 0x6C + /// byte 1: Command = 0x01 (SUPERVISION_GET) + /// byte 2: StatusUpdates(bit7) | Reserved(bit6) | SessionID(bits5..0) + /// byte 3: Encapsulated Command Length + /// byte 4..N: Encapsulated Command + /// + internal readonly struct SupervisionGetCommand : ICommand + { + public SupervisionGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Supervision; + + public static byte CommandId => (byte)SupervisionCommand.Get; + + public CommandClassFrame Frame { get; } + + public static SupervisionGetCommand Create( + bool statusUpdates, + byte sessionId, + CommandClassFrame encapsulatedFrame) + { + if (sessionId > 63) + { + throw new ArgumentOutOfRangeException(nameof(sessionId), sessionId, "Session ID must be between 0 and 63."); + } + + ReadOnlySpan encapsulatedData = encapsulatedFrame.Data.Span; + if (encapsulatedData.Length == 0 || encapsulatedData.Length > 255) + { + throw new ArgumentOutOfRangeException(nameof(encapsulatedFrame), encapsulatedData.Length, "Encapsulated command length must be in the range 1..255 bytes."); + } + + byte[] parameters = new byte[2 + encapsulatedData.Length]; + parameters[0] = (byte)((statusUpdates ? 0b1000_0000 : 0) | (sessionId & 0b0011_1111)); + parameters[1] = (byte)encapsulatedData.Length; + encapsulatedData.CopyTo(parameters.AsSpan(2)); + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, parameters); + return new SupervisionGetCommand(frame); + } + + public static SupervisionGet Parse(CommandClassFrame frame, ILogger logger) + { + // Minimum: 1 byte (flags+sessionID) + 1 byte (length) + 1 byte (min encapsulated) + if (frame.CommandParameters.Length < 3) + { + logger.LogWarning("Supervision Get frame is too short ({Length} bytes)", frame.CommandParameters.Length); + throw new ZWaveException(ZWaveErrorCode.InvalidPayload, "Supervision Get frame is too short"); + } + + ReadOnlySpan parameters = frame.CommandParameters.Span; + bool statusUpdates = (parameters[0] & 0b1000_0000) != 0; + byte sessionId = (byte)(parameters[0] & 0b0011_1111); + byte encapsulatedLength = parameters[1]; + + if (frame.CommandParameters.Length < 2 + encapsulatedLength || encapsulatedLength < 1) + { + logger.LogWarning( + "Supervision Get frame has invalid encapsulated command length ({EncapLength} bytes, available {Available})", + encapsulatedLength, + frame.CommandParameters.Length - 2); + throw new ZWaveException(ZWaveErrorCode.InvalidPayload, "Supervision Get frame has invalid encapsulated command length"); + } + + CommandClassFrame encapsulatedFrame = new CommandClassFrame(frame.CommandParameters.Slice(2, encapsulatedLength)); + + return new SupervisionGet(statusUpdates, sessionId, encapsulatedFrame); + } + } + + /// + /// Supervision Report Command (spec §4.2.8.4, §4.2.9.2). + /// + /// + /// Wire format: + /// byte 0: CC = 0x6C + /// byte 1: Command = 0x02 (SUPERVISION_REPORT) + /// byte 2: MoreStatusUpdates(bit7) | WakeUpRequest(bit6, v2) | SessionID(bits5..0) + /// byte 3: Status + /// byte 4: Duration + /// + internal readonly struct SupervisionReportCommand : ICommand + { + public SupervisionReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Supervision; + + public static byte CommandId => (byte)SupervisionCommand.Report; + + public CommandClassFrame Frame { get; } + + public static SupervisionReportCommand Create( + bool moreStatusUpdates, + bool wakeUpRequest, + byte sessionId, + SupervisionStatus status, + DurationReport duration) + { + if (sessionId > 63) + { + throw new ArgumentOutOfRangeException(nameof(sessionId), sessionId, "Session ID must be between 0 and 63."); + } + + Span parameters = + [ + (byte)( + (moreStatusUpdates ? 0b1000_0000 : 0) + | (wakeUpRequest ? 0b0100_0000 : 0) + | (sessionId & 0b0011_1111)), + (byte)status, + duration.Value, + ]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, parameters); + return new SupervisionReportCommand(frame); + } + + public static SupervisionReport Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 3) + { + logger.LogWarning("Supervision Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + throw new ZWaveException(ZWaveErrorCode.InvalidPayload, "Supervision Report frame is too short"); + } + + ReadOnlySpan parameters = frame.CommandParameters.Span; + bool moreStatusUpdates = (parameters[0] & 0b1000_0000) != 0; + bool wakeUpRequest = (parameters[0] & 0b0100_0000) != 0; + byte sessionId = (byte)(parameters[0] & 0b0011_1111); + SupervisionStatus status = (SupervisionStatus)parameters[1]; + DurationReport duration = parameters[2]; + + return new SupervisionReport(moreStatusUpdates, wakeUpRequest, sessionId, status, duration); + } + } +} diff --git a/src/ZWave.CommandClasses/SupervisionCommandClass.cs b/src/ZWave.CommandClasses/SupervisionCommandClass.cs new file mode 100644 index 0000000..8769342 --- /dev/null +++ b/src/ZWave.CommandClasses/SupervisionCommandClass.cs @@ -0,0 +1,102 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Supervision Command Class commands (version 2). +/// +public enum SupervisionCommand : byte +{ + /// + /// Initiate the execution of a command and request immediate and future status. + /// + Get = 0x01, + + /// + /// Advertise the status of one or more command processes. + /// + Report = 0x02, +} + +/// +/// Status identifiers for the Supervision Report Command (spec Table 4.35). +/// +public enum SupervisionStatus : byte +{ + /// + /// The command is not supported by the receiver. + /// + NoSupport = 0x00, + + /// + /// The command was accepted and processing has started. + /// A non-zero Duration value is advertised. + /// + Working = 0x01, + + /// + /// The command was accepted but processing failed. + /// + Fail = 0x02, + + /// + /// The requested command has been completed successfully. + /// + Success = 0xFF, +} + +/// +/// Implements the Supervision Command Class (version 2). +/// +/// +/// The Supervision CC provides application-level delivery confirmation for Set-type +/// and unsolicited Report commands. Per spec §4.2.8, a Supervision Get wraps a command +/// and the receiver responds with a Supervision Report indicating the operation status. +/// +/// Per spec §4.1.3.5, the encapsulation order is: +/// payload → Multi Command → Supervision → Multi Channel → Security/CRC-16/Transport Service +/// +[CommandClass(CommandClassId.Supervision)] +public sealed partial class SupervisionCommandClass : CommandClass +{ + internal SupervisionCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + public override bool? IsCommandSupported(SupervisionCommand command) + => command switch + { + SupervisionCommand.Get => true, + SupervisionCommand.Report => true, + _ => false, + }; + + /// + /// Per spec §6.4.5, Supervision is a Transport CC. + /// + internal override CommandClassCategory Category => CommandClassCategory.Transport; + + /// + /// Per spec §6.4.5.1: "There is no mandatory node interview for a node controlling this Command Class." + /// + internal override Task InterviewAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((SupervisionCommand)frame.CommandId) + { + case SupervisionCommand.Report: + { + SupervisionReport report = SupervisionReportCommand.Parse(frame, Logger); + OnSupervisionReportReceived?.Invoke(report); + break; + } + } + } +} diff --git a/src/ZWave/Driver.cs b/src/ZWave/Driver.cs index 1018ff0..68c712d 100644 --- a/src/ZWave/Driver.cs +++ b/src/ZWave/Driver.cs @@ -201,8 +201,9 @@ private void ProcessDataFrame(DataFrame frame) // 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; + SupervisionCommandClass.SupervisionReportCommand? supervisionReport = null; + if (commandClassFrame.CommandClassId == CommandClassId.MultiChannel && commandClassFrame.CommandId == (byte)MultiChannelCommand.CommandEncapsulation) { @@ -212,6 +213,35 @@ private void ProcessDataFrame(DataFrame frame) commandClassFrame = encapsulation.EncapsulatedFrame; } + // Supervision de-encapsulation (spec §4.2.8). + // A Supervision Get wraps an inner command; de-encapsulate and prepare + // the Report to send after processing the inner command. + // A Supervision Report is a response to a Get we sent; it passes through + // to the node's Supervision CC instance without de-encapsulation. + if (commandClassFrame.CommandClassId == CommandClassId.Supervision + && commandClassFrame.CommandId == (byte)SupervisionCommand.Get) + { + SupervisionGet supervisionGet = SupervisionCommandClass.ParseGet(commandClassFrame, _logger); + _logger.LogSupervisionDeEncapsulating(applicationCommandHandler.NodeId, supervisionGet.SessionId); + commandClassFrame = supervisionGet.EncapsulatedFrame; + + // Per spec CC:006C.01.01.11.005: Do not respond if received via multicast. + // Per spec CC:006C.01.00.12.003: A controlling node SHOULD return SUCCESS or NO_SUPPORT. + // TODO: Return NO_SUPPORT or FAIL based on whether ProcessCommand actually + // handled the inner command. Currently always assumes SUCCESS. + ReceivedStatus receivedStatus = applicationCommandHandler.ReceivedStatus; + bool isMulticast = (receivedStatus & (ReceivedStatus.BroadcastAddressing | ReceivedStatus.MulticastAddressing)) != 0; + if (!isMulticast) + { + supervisionReport = SupervisionCommandClass.SupervisionReportCommand.Create( + moreStatusUpdates: false, + wakeUpRequest: false, + supervisionGet.SessionId, + SupervisionStatus.Success, + duration: new DurationReport(0)); + } + } + node.ProcessCommand(commandClassFrame, endpointIndex); // Route to controller for supporting-side handling (responding to queries @@ -220,6 +250,16 @@ private void ProcessDataFrame(DataFrame frame) { Controller.HandleCommand(commandClassFrame, applicationCommandHandler.NodeId); } + + // Send the Supervision Report after processing the inner command and + // controller routing, so the response reflects that we actually handled it. + if (supervisionReport.HasValue) + { + _ = SendSupervisionReportAsync( + applicationCommandHandler.NodeId, + endpointIndex, + supervisionReport.Value); + } } else { @@ -537,6 +577,27 @@ public async Task SendCommandAsync( }); } + /// + /// Sends a Supervision Report back to a node in response to a Supervision Get. + /// + /// + /// Fire-and-forget; failures are logged but not propagated. + /// + private async Task SendSupervisionReportAsync( + ushort nodeId, + byte endpointIndex, + SupervisionCommandClass.SupervisionReportCommand command) + { + try + { + await SendCommandAsync(command, nodeId, endpointIndex, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogSupervisionReportFailed(nodeId, ex); + } + } + /// /// Awaits a callback with timeout, cleaning up the callback registration on failure. /// Per the Serial API Host Application Programming Guide, the host SHOULD guard all diff --git a/src/ZWave/Logging.cs b/src/ZWave/Logging.cs index f5a9219..01dad76 100644 --- a/src/ZWave/Logging.cs +++ b/src/ZWave/Logging.cs @@ -177,4 +177,16 @@ public static partial void LogInitData( Level = LogLevel.Warning, Message = "Failed to send controller response to node {nodeId}")] public static partial void LogControllerResponseFailed(this ILogger logger, ushort nodeId, Exception ex); + + [LoggerMessage( + EventId = 224, + Level = LogLevel.Debug, + Message = "De-encapsulating Supervision Get from node {nodeId} session {sessionId}")] + public static partial void LogSupervisionDeEncapsulating(this ILogger logger, ushort nodeId, byte sessionId); + + [LoggerMessage( + EventId = 225, + Level = LogLevel.Warning, + Message = "Failed to send Supervision Report to node {nodeId}")] + public static partial void LogSupervisionReportFailed(this ILogger logger, ushort nodeId, Exception ex); }