From 963f86dab31f17880dca181b92aafff205b8ad75 Mon Sep 17 00:00:00 2001 From: David Federman Date: Sun, 30 Aug 2026 14:35:53 -0700 Subject: [PATCH] Implement CRC-16 Encapsulation CC and Multi Command CC --- ...c16EncapsulationCommandClassTests.Crc16.cs | 33 +++ ...sulationCommandClassTests.Encapsulation.cs | 70 ++++++ .../Crc16EncapsulationCommandClassTests.cs | 6 + ...iCommandCommandClassTests.Encapsulation.cs | 145 +++++++++++ .../MultiCommandCommandClassTests.cs | 6 + src/ZWave.CommandClasses/Crc16.cs | 28 +++ ...lationCommandClass.CommandEncapsulation.cs | 121 ++++++++++ .../Crc16EncapsulationCommandClass.cs | 61 +++++ ...ommandCommandClass.CommandEncapsulation.cs | 140 +++++++++++ .../MultiCommandCommandClass.cs | 60 +++++ src/ZWave/Driver.cs | 228 +++++++++++++----- src/ZWave/Logging.cs | 18 ++ 12 files changed, 858 insertions(+), 58 deletions(-) create mode 100644 src/ZWave.CommandClasses.Tests/Crc16EncapsulationCommandClassTests.Crc16.cs create mode 100644 src/ZWave.CommandClasses.Tests/Crc16EncapsulationCommandClassTests.Encapsulation.cs create mode 100644 src/ZWave.CommandClasses.Tests/Crc16EncapsulationCommandClassTests.cs create mode 100644 src/ZWave.CommandClasses.Tests/MultiCommandCommandClassTests.Encapsulation.cs create mode 100644 src/ZWave.CommandClasses.Tests/MultiCommandCommandClassTests.cs create mode 100644 src/ZWave.CommandClasses/Crc16.cs create mode 100644 src/ZWave.CommandClasses/Crc16EncapsulationCommandClass.CommandEncapsulation.cs create mode 100644 src/ZWave.CommandClasses/Crc16EncapsulationCommandClass.cs create mode 100644 src/ZWave.CommandClasses/MultiCommandCommandClass.CommandEncapsulation.cs create mode 100644 src/ZWave.CommandClasses/MultiCommandCommandClass.cs diff --git a/src/ZWave.CommandClasses.Tests/Crc16EncapsulationCommandClassTests.Crc16.cs b/src/ZWave.CommandClasses.Tests/Crc16EncapsulationCommandClassTests.Crc16.cs new file mode 100644 index 0000000..a106d16 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/Crc16EncapsulationCommandClassTests.Crc16.cs @@ -0,0 +1,33 @@ +namespace ZWave.CommandClasses.Tests; + +public partial class Crc16EncapsulationCommandClassTests +{ + [TestMethod] + public void Crc16_Compute_Empty_ReturnsInitialValue() + { + Assert.AreEqual((ushort)0x1D0F, Crc16.Compute(ReadOnlySpan.Empty)); + } + + [TestMethod] + public void Crc16_Compute_SingleByteA() + { + Assert.AreEqual((ushort)0x9479, Crc16.Compute([0x41])); + } + + [TestMethod] + public void Crc16_Compute_CheckSequence() + { + // "123456789" + byte[] data = [0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39]; + Assert.AreEqual((ushort)0xE5CC, Crc16.Compute(data)); + } + + [TestMethod] + public void Crc16_Compute_SpecTable1Vector() + { + // Spec SDS13783 §3.1.2 Table 1: CRC-16 over the bytes + // [0x56, 0x01, 0x20, 0x02] (CC id + cmd id + Basic CC + Basic Get) + // equals 0x4D26 (MSB 0x4D, LSB 0x26). + Assert.AreEqual((ushort)0x4D26, Crc16.Compute([0x56, 0x01, 0x20, 0x02])); + } +} diff --git a/src/ZWave.CommandClasses.Tests/Crc16EncapsulationCommandClassTests.Encapsulation.cs b/src/ZWave.CommandClasses.Tests/Crc16EncapsulationCommandClassTests.Encapsulation.cs new file mode 100644 index 0000000..c54f8f5 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/Crc16EncapsulationCommandClassTests.Encapsulation.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class Crc16EncapsulationCommandClassTests +{ + [TestMethod] + public void CreateEncapsulation_BasicGet_MatchesSpecTable1() + { + // Spec SDS13783 §3.1.2 Table 1: encapsulating a Basic Get yields + // [0x56, 0x01, 0x20, 0x02, 0x4D, 0x26]. + CommandClassFrame innerFrame = CommandClassFrame.Create(CommandClassId.Basic, 0x02); + CommandClassFrame frame = Crc16EncapsulationCommandClass.CreateEncapsulation(innerFrame); + + Assert.AreEqual(CommandClassId.Crc16Encapsulation, frame.CommandClassId); + Assert.AreEqual((byte)Crc16EncapsulationCommand.CommandEncapsulation, frame.CommandId); + + byte[] expected = [0x56, 0x01, 0x20, 0x02, 0x4D, 0x26]; + Assert.IsTrue(frame.Data.Span.SequenceEqual(expected)); + } + + [TestMethod] + public void ParseEncapsulation_RoundTrip_PreservesInnerFrame() + { + CommandClassFrame innerFrame = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x01, [0x25, 0x06]); + CommandClassFrame frame = Crc16EncapsulationCommandClass.CreateEncapsulation(innerFrame); + + Crc16Encapsulation? parsed = Crc16EncapsulationCommandClass.ParseEncapsulation(frame, NullLogger.Instance); + + Assert.IsNotNull(parsed); + Assert.AreEqual(CommandClassId.BinarySwitch, parsed.Value.EncapsulatedFrame.CommandClassId); + Assert.AreEqual((byte)0x01, parsed.Value.EncapsulatedFrame.CommandId); + + byte[] expectedParams = [0x25, 0x06]; + Assert.IsTrue(parsed.Value.EncapsulatedFrame.CommandParameters.Span.SequenceEqual(expectedParams)); + } + + [TestMethod] + public void ParseEncapsulation_ChecksumMismatch_ReturnsNull() + { + CommandClassFrame innerFrame = CommandClassFrame.Create(CommandClassId.Basic, 0x02); + CommandClassFrame frame = Crc16EncapsulationCommandClass.CreateEncapsulation(innerFrame); + + // Corrupt the checksum LSB. + byte[] data = frame.Data.ToArray(); + data[^1] ^= 0x01; + + Assert.IsNull(Crc16EncapsulationCommandClass.ParseEncapsulation(new CommandClassFrame(data), NullLogger.Instance)); + } + + [TestMethod] + public void ParseEncapsulation_TooShort_ReturnsNull() + { + // 5 bytes: CC + Cmd + inner CC + inner Cmd + 1 checksum byte (6 required). + byte[] data = [0x56, 0x01, 0x20, 0x02, 0x00]; + + Assert.IsNull(Crc16EncapsulationCommandClass.ParseEncapsulation(new CommandClassFrame(data), NullLogger.Instance)); + } + + [TestMethod] + public void ParseEncapsulation_ExtendedCommandClass_ReturnsNull() + { + // Inner command class is 16-bit (0xFF-prefixed) — unsupported, so parse returns null + // even though the checksum is valid. + CommandClassFrame innerFrame = new(new byte[] { 0xFF, 0x70, 0x01 }); + CommandClassFrame frame = Crc16EncapsulationCommandClass.CreateEncapsulation(innerFrame); + + Assert.IsNull(Crc16EncapsulationCommandClass.ParseEncapsulation(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/Crc16EncapsulationCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/Crc16EncapsulationCommandClassTests.cs new file mode 100644 index 0000000..434f2ef --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/Crc16EncapsulationCommandClassTests.cs @@ -0,0 +1,6 @@ +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public partial class Crc16EncapsulationCommandClassTests +{ +} diff --git a/src/ZWave.CommandClasses.Tests/MultiCommandCommandClassTests.Encapsulation.cs b/src/ZWave.CommandClasses.Tests/MultiCommandCommandClassTests.Encapsulation.cs new file mode 100644 index 0000000..7ea63e9 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/MultiCommandCommandClassTests.Encapsulation.cs @@ -0,0 +1,145 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class MultiCommandCommandClassTests +{ + [TestMethod] + public void CreateEncapsulation_SingleCommand_HasCorrectFormat() + { + CommandClassFrame innerFrame = CommandClassFrame.Create(CommandClassId.Basic, 0x02); + CommandClassFrame frame = MultiCommandCommandClass.CreateEncapsulation([innerFrame]); + + Assert.AreEqual(CommandClassId.MultiCommand, frame.CommandClassId); + Assert.AreEqual((byte)MultiCommandCommand.CommandEncapsulation, frame.CommandId); + + // Parameters: [count=1][length=2][CC=0x20][cmd=0x02] + ReadOnlySpan parameters = frame.CommandParameters.Span; + Assert.AreEqual(4, parameters.Length); + Assert.AreEqual((byte)0x01, parameters[0]); + Assert.AreEqual((byte)0x02, parameters[1]); + Assert.AreEqual((byte)0x20, parameters[2]); + Assert.AreEqual((byte)0x02, parameters[3]); + } + + [TestMethod] + public void CreateEncapsulation_MultipleCommands_HasCorrectFormat() + { + CommandClassFrame first = CommandClassFrame.Create(CommandClassId.Basic, 0x01, [0xFF]); + CommandClassFrame second = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x02); + CommandClassFrame frame = MultiCommandCommandClass.CreateEncapsulation([first, second]); + + // Parameters: [count=2][length=3][0x20][0x01][0xFF][length=2][0x25][0x02] + ReadOnlySpan parameters = frame.CommandParameters.Span; + Assert.AreEqual(8, parameters.Length); + Assert.AreEqual((byte)0x02, parameters[0]); + Assert.AreEqual((byte)0x03, parameters[1]); + Assert.AreEqual((byte)0x20, parameters[2]); + Assert.AreEqual((byte)0x01, parameters[3]); + Assert.AreEqual((byte)0xFF, parameters[4]); + Assert.AreEqual((byte)0x02, parameters[5]); + Assert.AreEqual((byte)0x25, parameters[6]); + Assert.AreEqual((byte)0x02, parameters[7]); + } + + [TestMethod] + public void CreateEncapsulation_RejectsEmptyList() + { + Assert.Throws( + () => MultiCommandCommandClass.CreateEncapsulation(Array.Empty())); + } + + [TestMethod] + public void ParseEncapsulation_SingleCommand() + { + // [0x8F][0x01][count=1][length=2][0x20][0x02] + byte[] data = [0x8F, 0x01, 0x01, 0x02, 0x20, 0x02]; + + MultiCommandEncapsulation parsed = MultiCommandCommandClass.ParseEncapsulation(new CommandClassFrame(data), NullLogger.Instance); + + Assert.HasCount(1, parsed.Commands); + Assert.AreEqual(CommandClassId.Basic, parsed.Commands[0].CommandClassId); + Assert.AreEqual((byte)0x02, parsed.Commands[0].CommandId); + } + + [TestMethod] + public void ParseEncapsulation_MultipleCommands_PreservesOrder() + { + // [0x8F][0x01][count=2][length=3][0x20][0x01][0xFF][length=2][0x25][0x02] + byte[] data = [0x8F, 0x01, 0x02, 0x03, 0x20, 0x01, 0xFF, 0x02, 0x25, 0x02]; + + MultiCommandEncapsulation parsed = MultiCommandCommandClass.ParseEncapsulation(new CommandClassFrame(data), NullLogger.Instance); + + Assert.HasCount(2, parsed.Commands); + Assert.AreEqual(CommandClassId.Basic, parsed.Commands[0].CommandClassId); + Assert.AreEqual((byte)0x01, parsed.Commands[0].CommandId); + + byte[] expectedParams = [0xFF]; + Assert.IsTrue(parsed.Commands[0].CommandParameters.Span.SequenceEqual(expectedParams)); + + Assert.AreEqual(CommandClassId.BinarySwitch, parsed.Commands[1].CommandClassId); + Assert.AreEqual((byte)0x02, parsed.Commands[1].CommandId); + } + + [TestMethod] + public void ParseEncapsulation_RoundTrip_PreservesCommands() + { + CommandClassFrame[] originals = + [ + CommandClassFrame.Create(CommandClassId.Basic, 0x01, [0x00]), + CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x01, [0xFF]), + CommandClassFrame.Create(CommandClassId.Meter, 0x02, [0x05, 0x00]), + ]; + + CommandClassFrame frame = MultiCommandCommandClass.CreateEncapsulation(originals); + MultiCommandEncapsulation parsed = MultiCommandCommandClass.ParseEncapsulation(frame, NullLogger.Instance); + + Assert.HasCount(3, parsed.Commands); + for (int i = 0; i < originals.Length; i++) + { + Assert.AreEqual(originals[i].CommandClassId, parsed.Commands[i].CommandClassId); + Assert.AreEqual(originals[i].CommandId, parsed.Commands[i].CommandId); + Assert.IsTrue(originals[i].Data.Span.SequenceEqual(parsed.Commands[i].Data.Span)); + } + } + + [TestMethod] + public void ParseEncapsulation_TooShort_Throws() + { + // CC + command only, no parameters (the count byte is missing). + byte[] data = [0x8F, 0x01]; + + Assert.Throws( + () => MultiCommandCommandClass.ParseEncapsulation(new CommandClassFrame(data), NullLogger.Instance)); + } + + [TestMethod] + public void ParseEncapsulation_Truncated_Throws() + { + // Declares 2 commands but only one is present. + byte[] data = [0x8F, 0x01, 0x02, 0x02, 0x20, 0x02]; + + Assert.Throws( + () => MultiCommandCommandClass.ParseEncapsulation(new CommandClassFrame(data), NullLogger.Instance)); + } + + [TestMethod] + public void ParseEncapsulation_ExtendedCommandClass_Throws() + { + // The inner command class is 16-bit (0xFF-prefixed) — unsupported. + byte[] data = [0x8F, 0x01, 0x01, 0x03, 0xFF, 0x70, 0x01]; + + Assert.Throws( + () => MultiCommandCommandClass.ParseEncapsulation(new CommandClassFrame(data), NullLogger.Instance)); + } + + [TestMethod] + public void ParseEncapsulation_TrailingBytes_Throws() + { + // Declares 1 command of length 2, but a trailing byte remains. + byte[] data = [0x8F, 0x01, 0x01, 0x02, 0x20, 0x02, 0x00]; + + Assert.Throws( + () => MultiCommandCommandClass.ParseEncapsulation(new CommandClassFrame(data), NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/MultiCommandCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/MultiCommandCommandClassTests.cs new file mode 100644 index 0000000..413579b --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/MultiCommandCommandClassTests.cs @@ -0,0 +1,6 @@ +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public partial class MultiCommandCommandClassTests +{ +} diff --git a/src/ZWave.CommandClasses/Crc16.cs b/src/ZWave.CommandClasses/Crc16.cs new file mode 100644 index 0000000..6ed9039 --- /dev/null +++ b/src/ZWave.CommandClasses/Crc16.cs @@ -0,0 +1,28 @@ +namespace ZWave.CommandClasses; + +/// +/// CRC-16 (CCIT-FALSE) computation per the Z-Wave CRC-16 Encapsulation Command Class +/// (spec §3.1.2): initial value 0x1D0F, polynomial 0x1021, non-reflected (MSB-first), no final XOR. +/// +internal static class Crc16 +{ + private const ushort Polynomial = 0x1021; + private const ushort InitialValue = 0x1D0F; + + public static ushort Compute(ReadOnlySpan data) + { + ushort crc = InitialValue; + foreach (byte value in data) + { + crc ^= (ushort)(value << 8); + for (int i = 0; i < 8; i++) + { + crc = (crc & 0x8000) != 0 + ? (ushort)((crc << 1) ^ Polynomial) + : (ushort)(crc << 1); + } + } + + return crc; + } +} diff --git a/src/ZWave.CommandClasses/Crc16EncapsulationCommandClass.CommandEncapsulation.cs b/src/ZWave.CommandClasses/Crc16EncapsulationCommandClass.CommandEncapsulation.cs new file mode 100644 index 0000000..c561303 --- /dev/null +++ b/src/ZWave.CommandClasses/Crc16EncapsulationCommandClass.CommandEncapsulation.cs @@ -0,0 +1,121 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a parsed CRC-16 Encapsulation frame. +/// +public readonly record struct Crc16Encapsulation( + /// + /// The encapsulated command class frame (checksum already verified). + /// + CommandClassFrame EncapsulatedFrame); + +public sealed partial class Crc16EncapsulationCommandClass +{ + /// + /// Creates a CRC-16 Encapsulation frame wrapping the specified command. + /// + /// The command to encapsulate. + public static CommandClassFrame CreateEncapsulation(CommandClassFrame encapsulatedFrame) + => CommandEncapsulationCommand.Create(encapsulatedFrame).Frame; + + /// + /// Parses a CRC-16 Encapsulation frame, verifying the checksum. + /// + /// The encapsulated frame, or null if the frame is malformed or the checksum does not match. + /// + /// A checksum mismatch or malformed frame is a normal, expected occurrence on low-speed + /// links (which is the purpose of this CC), so it returns null rather than throwing. + /// + public static Crc16Encapsulation? ParseEncapsulation(CommandClassFrame frame, ILogger logger) + => CommandEncapsulationCommand.Parse(frame, logger); + + /// + /// CRC-16 Encapsulation Command (spec §3.1.2). + /// + /// + /// Wire format: + /// byte 0: CC = 0x56 + /// byte 1: Command = 0x01 (CRC 16 ENCAP) + /// byte 2..N-2: Encapsulated command (Command Class + Command ID + parameters) + /// byte N-1: Checksum MSB + /// byte N: Checksum LSB + /// The CRC-16 (CCIT-FALSE) is computed over bytes 0..N-2 (CC ID through the last payload + /// byte) and stored big-endian (MSB first). + /// + internal readonly struct CommandEncapsulationCommand : ICommand + { + public CommandEncapsulationCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Crc16Encapsulation; + + public static byte CommandId => (byte)Crc16EncapsulationCommand.CommandEncapsulation; + + public CommandClassFrame Frame { get; } + + public static CommandEncapsulationCommand Create(CommandClassFrame encapsulatedFrame) + { + ReadOnlySpan encapsulatedData = encapsulatedFrame.Data.Span; + byte[] parameters = new byte[2 + encapsulatedData.Length]; + + // The CRC-16 checksum covers the CC id, command id, and the encapsulated data (spec + // §3.1.2). Lay the buffer out as [CC][Cmd][data...] so it can be computed over directly. + parameters[0] = (byte)CommandClassId; + parameters[1] = CommandId; + encapsulatedData.CopyTo(parameters.AsSpan(2)); + ushort checksum = Crc16.Compute(parameters); + + // Rebuild the buffer in place as the frame parameters [data...][checksum MSB][checksum + // LSB] by shifting the data bytes down by two, then appending the big-endian checksum. + for (int i = 0; i < encapsulatedData.Length; i++) + { + parameters[i] = parameters[i + 2]; + } + + parameters[^2] = (byte)(checksum >> 8); + parameters[^1] = (byte)(checksum & 0xFF); + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, parameters); + return new CommandEncapsulationCommand(frame); + } + + public static Crc16Encapsulation? Parse(CommandClassFrame frame, ILogger logger) + { + // Minimum frame: CC(1) + Cmd(1) + inner CC(1) + inner Cmd(1) + checksum(2) = 6 bytes. + if (frame.Data.Length < 6) + { + logger.LogWarning("CRC-16 Encapsulation frame is too short ({Length} bytes)", frame.Data.Length); + return null; + } + + ReadOnlySpan data = frame.Data.Span; + + // The inner command class must be 8-bit (a single leading byte). Extended 16-bit + // command classes (0xFF-prefixed) are not representable by CommandClassId and are + // therefore unsupported here. + if (data[2] == 0xFF) + { + logger.LogWarning("CRC-16 Encapsulation frame contains an extended (16-bit) command class which is not supported"); + return null; + } + + ushort storedChecksum = (ushort)((data[^2] << 8) | data[^1]); + ushort computedChecksum = Crc16.Compute(data[..^2]); + if (storedChecksum != computedChecksum) + { + logger.LogWarning( + "CRC-16 Encapsulation checksum mismatch (stored {Stored:X4}, computed {Computed:X4})", + storedChecksum, + computedChecksum); + return null; + } + + CommandClassFrame encapsulatedFrame = new CommandClassFrame(frame.Data.Slice(2, frame.Data.Length - 4)); + return new Crc16Encapsulation(encapsulatedFrame); + } + } +} diff --git a/src/ZWave.CommandClasses/Crc16EncapsulationCommandClass.cs b/src/ZWave.CommandClasses/Crc16EncapsulationCommandClass.cs new file mode 100644 index 0000000..c32cb1e --- /dev/null +++ b/src/ZWave.CommandClasses/Crc16EncapsulationCommandClass.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// CRC-16 Encapsulation Command Class commands (version 1). +/// +public enum Crc16EncapsulationCommand : byte +{ + /// + /// Encapsulate a command with a CRC-16 checksum. + /// + CommandEncapsulation = 0x01, +} + +/// +/// Implements the CRC-16 Encapsulation Command Class (version 1). +/// +/// +/// Per the Transport-Encapsulation spec (SDS13783) §3.1, the CRC-16 Encapsulation CC is +/// [DEPRECATED] but some device types are still required to support it, so it is implemented +/// for receive compatibility. +/// A CRC-16 frame is the outermost encapsulation layer and MUST NOT be encapsulated by any +/// other Command Class (spec §3.1.1.2). +/// +[CommandClass(CommandClassId.Crc16Encapsulation)] +public sealed partial class Crc16EncapsulationCommandClass : CommandClass +{ + internal Crc16EncapsulationCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + public override bool? IsCommandSupported(Crc16EncapsulationCommand command) + => command switch + { + Crc16EncapsulationCommand.CommandEncapsulation => true, + _ => false, + }; + + /// + /// Per spec §2, CRC-16 Encapsulation is a Transport-Encapsulation CC. + /// + internal override CommandClassCategory Category => CommandClassCategory.Transport; + + /// + /// Per spec §3.1, there is no mandatory node interview for this Command Class. + /// + internal override Task InterviewAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + // The Driver de-encapsulates CRC-16 frames upstream (in the receive path), so a + // Command Encapsulation frame is not delivered to this instance as an unsolicited report. + } +} diff --git a/src/ZWave.CommandClasses/MultiCommandCommandClass.CommandEncapsulation.cs b/src/ZWave.CommandClasses/MultiCommandCommandClass.CommandEncapsulation.cs new file mode 100644 index 0000000..1475243 --- /dev/null +++ b/src/ZWave.CommandClasses/MultiCommandCommandClass.CommandEncapsulation.cs @@ -0,0 +1,140 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a parsed Multi Command Encapsulation frame. +/// +public readonly record struct MultiCommandEncapsulation( + /// + /// The encapsulated command class frames, in the order they were transmitted. + /// + CommandClassFrame[] Commands); + +public sealed partial class MultiCommandCommandClass +{ + /// + /// Creates a Multi Command Encapsulation frame bundling the specified commands. + /// + /// The commands to bundle (at least one). + public static CommandClassFrame CreateEncapsulation(CommandClassFrame[] commands) + => CommandEncapsulationCommand.Create(commands).Frame; + + /// + /// Parses a Multi Command Encapsulation frame into its constituent commands. + /// + public static MultiCommandEncapsulation ParseEncapsulation(CommandClassFrame frame, ILogger logger) + => CommandEncapsulationCommand.Parse(frame, logger); + + /// + /// Multi Command Encapsulation Command (spec §3.4.3). + /// + /// + /// Wire format: + /// byte 0: CC = 0x8F + /// byte 1: Command = 0x01 (MULTI CMD ENCAP) + /// byte 2: Number of commands (Count) + /// then, per command: [Length][Command Class (1 or 2 bytes)][Command ID][Data...] + /// where Length is the size of (Command Class + Command ID + Data) for that command. + /// + internal readonly struct CommandEncapsulationCommand : ICommand + { + public CommandEncapsulationCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.MultiCommand; + + public static byte CommandId => (byte)MultiCommandCommand.CommandEncapsulation; + + public CommandClassFrame Frame { get; } + + public static CommandEncapsulationCommand Create(CommandClassFrame[] commands) + { + if (commands.Length == 0) + { + throw new ArgumentException("At least one command is required.", nameof(commands)); + } + + if (commands.Length > 255) + { + throw new ArgumentException("A Multi Command frame may contain at most 255 commands.", nameof(commands)); + } + + int totalLength = 1; // Count byte. + foreach (CommandClassFrame command in commands) + { + int length = command.Data.Length; + if (length < 2 || length > 255) + { + throw new ArgumentException("Each encapsulated command must be between 2 and 255 bytes."); + } + + totalLength += 1 + length; // Length byte + command bytes. + } + + byte[] parameters = new byte[totalLength]; + int offset = 0; + parameters[offset++] = (byte)commands.Length; + foreach (CommandClassFrame command in commands) + { + parameters[offset++] = (byte)command.Data.Length; + command.Data.Span.CopyTo(parameters.AsSpan(offset)); + offset += command.Data.Length; + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, parameters); + return new CommandEncapsulationCommand(frame); + } + + public static MultiCommandEncapsulation Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning("Multi Command frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Multi Command frame is too short"); + } + + ReadOnlySpan parameters = frame.CommandParameters.Span; + byte count = parameters[0]; + + var commands = new CommandClassFrame[count]; + int offset = 1; + for (int i = 0; i < count; i++) + { + if (offset >= parameters.Length) + { + logger.LogWarning("Multi Command frame is truncated: expected {Count} commands but ran out of data at command {Index}", count, i); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Multi Command frame is truncated"); + } + + int length = parameters[offset]; + if (length < 2 || offset + 1 + length > parameters.Length) + { + logger.LogWarning("Multi Command frame is truncated: command {Index} claims {Length} bytes but only {Available} are available", i, length, parameters.Length - offset - 1); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Multi Command frame is truncated"); + } + + // Extended 16-bit command classes (0xFF-prefixed) are not representable by + // CommandClassId and are therefore unsupported here. + if (parameters[offset + 1] == 0xFF) + { + logger.LogWarning("Multi Command frame contains an extended (16-bit) command class which is not supported"); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Multi Command frame contains an unsupported extended command class"); + } + + commands[i] = new CommandClassFrame(frame.Data.Slice(2 + offset + 1, length)); + offset += 1 + length; + } + + if (offset != parameters.Length) + { + logger.LogWarning("Multi Command frame has {Extra} trailing bytes after the declared command count", parameters.Length - offset); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Multi Command frame has trailing bytes"); + } + + return new MultiCommandEncapsulation(commands); + } + } +} diff --git a/src/ZWave.CommandClasses/MultiCommandCommandClass.cs b/src/ZWave.CommandClasses/MultiCommandCommandClass.cs new file mode 100644 index 0000000..b438b0b --- /dev/null +++ b/src/ZWave.CommandClasses/MultiCommandCommandClass.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Multi Command Command Class commands (version 1). +/// +public enum MultiCommandCommand : byte +{ + /// + /// Encapsulate multiple commands in a single frame. + /// + CommandEncapsulation = 0x01, +} + +/// +/// Implements the Multi Command Command Class (version 1). +/// +/// +/// Per the Transport-Encapsulation spec (SDS13783) §3.4, the Multi Command CC bundles multiple +/// command class commands into a single frame. The receiving node MUST process all encapsulated +/// commands in the order they are transmitted. Multi Command is the innermost encapsulation layer. +/// +[CommandClass(CommandClassId.MultiCommand)] +public sealed partial class MultiCommandCommandClass : CommandClass +{ + internal MultiCommandCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + public override bool? IsCommandSupported(MultiCommandCommand command) + => command switch + { + MultiCommandCommand.CommandEncapsulation => true, + _ => false, + }; + + /// + /// Per spec §2, Multi Command is a Transport-Encapsulation CC. + /// + internal override CommandClassCategory Category => CommandClassCategory.Transport; + + /// + /// Per spec §3.4, there is no mandatory node interview for this Command Class. + /// + internal override Task InterviewAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + // The Driver de-encapsulates Multi Command frames upstream (in the receive path) into their + // constituent commands, so a Multi Command frame is not delivered to this instance as an + // unsolicited report. + } +} diff --git a/src/ZWave/Driver.cs b/src/ZWave/Driver.cs index 589da82..741cab7 100644 --- a/src/ZWave/Driver.cs +++ b/src/ZWave/Driver.cs @@ -195,68 +195,33 @@ private void ProcessDataFrame(DataFrame frame) var applicationCommandHandler = ApplicationCommandHandler.Create(frame, context); if (Controller.Nodes.TryGetValue(applicationCommandHandler.NodeId, out Node? node)) { - var commandClassFrame = new CommandClassFrame(applicationCommandHandler.Payload); - - // De-encapsulate per spec §4.1.3.5 (reverse order): - // Security/CRC-16/Transport Service → Multi Channel → Supervision → Multi Command - byte endpointIndex = 0; - SupervisionCommandClass.SupervisionReportCommand? supervisionReport = null; - - 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; - } - - // 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) + if (TryDeencapsulateApplicationCommand( + applicationCommandHandler, + out DeencapsulatedCommands commands, + out byte endpointIndex, + out SupervisionCommandClass.SupervisionReportCommand? supervisionReport)) { - 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) + foreach (CommandClassFrame commandFrame in commands) { - supervisionReport = SupervisionCommandClass.SupervisionReportCommand.Create( - moreStatusUpdates: false, - wakeUpRequest: false, - supervisionGet.SessionId, - SupervisionStatus.Success, - duration: new DurationReport(0)); + node.ProcessCommand(commandFrame, 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(commandFrame, applicationCommandHandler.NodeId); + } } - } - - 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); - } - - // 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); + // 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 @@ -596,6 +561,153 @@ private async Task SendSupervisionReportAsync( } } + /// + /// The de-encapsulated application command: a single command (the common case) or the commands + /// of a Multi Command frame. Enumerating is allocation-free and does not box, unlike iterating + /// over an . + /// + private readonly struct DeencapsulatedCommands + { + private readonly CommandClassFrame? _single; + private readonly CommandClassFrame[]? _commands; + + private DeencapsulatedCommands(CommandClassFrame? single, CommandClassFrame[]? commands) + { + _single = single; + _commands = commands; + } + + public static DeencapsulatedCommands Single(CommandClassFrame command) => new(command, null); + + public static DeencapsulatedCommands Multiple(CommandClassFrame[] commands) => new(null, commands); + + public struct Enumerator + { + private readonly CommandClassFrame? _single; + private readonly CommandClassFrame[]? _commands; + private int _index; + + public Enumerator(DeencapsulatedCommands commands) + { + _single = commands._single; + _commands = commands._commands; + _index = -1; + } + + public CommandClassFrame Current + { + get + { + if (_commands is not null) + { + return _commands[_index]; + } + + return _single!.Value; + } + } + + public bool MoveNext() + { + _index++; + return _commands is not null ? _index < _commands.Length : _index == 0; + } + } + + public Enumerator GetEnumerator() => new(this); + } + + /// + /// De-encapsulates the command in an Application Command Handler request, peeling off the + /// encapsulation layers in reverse order per spec §4.1.3.5: + /// Security/CRC-16/Transport Service → Multi Channel → Supervision → Multi Command. + /// + /// + /// True if the request de-encapsulated into one or more commands to process, or false if the + /// frame should be discarded (a CRC-16 checksum mismatch or malformed frame). + /// + private bool TryDeencapsulateApplicationCommand( + ApplicationCommandHandler applicationCommandHandler, + out DeencapsulatedCommands commands, + out byte endpointIndex, + out SupervisionCommandClass.SupervisionReportCommand? supervisionReport) + { + CommandClassFrame commandClassFrame = new CommandClassFrame(applicationCommandHandler.Payload); + endpointIndex = 0; + supervisionReport = null; + + // CRC-16 (outermost). Per spec §3.1, a checksum mismatch or malformed frame means the + // payload is unreliable, so the frame is discarded rather than processed. + if (commandClassFrame.CommandClassId == CommandClassId.Crc16Encapsulation + && commandClassFrame.CommandId == (byte)Crc16EncapsulationCommand.CommandEncapsulation) + { + Crc16Encapsulation? crc16 = Crc16EncapsulationCommandClass.ParseEncapsulation(commandClassFrame, _logger); + if (crc16 is null) + { + _logger.LogCrc16ChecksumMismatch(applicationCommandHandler.NodeId); + commands = default; + return false; + } + + _logger.LogCrc16DeEncapsulating(applicationCommandHandler.NodeId); + commandClassFrame = crc16.Value.EncapsulatedFrame; + } + + 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; + } + + // 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)); + } + } + + // Multi Command (innermost). Per spec §3.4, the receiver MUST process all encapsulated + // commands in order, so expand the frame into its constituent commands. + if (commandClassFrame.CommandClassId == CommandClassId.MultiCommand + && commandClassFrame.CommandId == (byte)MultiCommandCommand.CommandEncapsulation) + { + MultiCommandEncapsulation multiCommand = MultiCommandCommandClass.ParseEncapsulation(commandClassFrame, _logger); + _logger.LogMultiCommandDeEncapsulating(applicationCommandHandler.NodeId, multiCommand.Commands.Length); + commands = DeencapsulatedCommands.Multiple(multiCommand.Commands); + } + else + { + commands = DeencapsulatedCommands.Single(commandClassFrame); + } + + return true; + } + /// /// 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 01dad76..8d5c924 100644 --- a/src/ZWave/Logging.cs +++ b/src/ZWave/Logging.cs @@ -189,4 +189,22 @@ public static partial void LogInitData( Level = LogLevel.Warning, Message = "Failed to send Supervision Report to node {nodeId}")] public static partial void LogSupervisionReportFailed(this ILogger logger, ushort nodeId, Exception ex); + + [LoggerMessage( + EventId = 226, + Level = LogLevel.Debug, + Message = "De-encapsulating CRC-16 frame from node {nodeId}")] + public static partial void LogCrc16DeEncapsulating(this ILogger logger, ushort nodeId); + + [LoggerMessage( + EventId = 227, + Level = LogLevel.Warning, + Message = "Discarding CRC-16 frame from node {nodeId} due to checksum mismatch or malformed frame")] + public static partial void LogCrc16ChecksumMismatch(this ILogger logger, ushort nodeId); + + [LoggerMessage( + EventId = 228, + Level = LogLevel.Debug, + Message = "De-encapsulating Multi Command frame from node {nodeId} into {commandCount} commands")] + public static partial void LogMultiCommandDeEncapsulating(this ILogger logger, ushort nodeId, int commandCount); }