Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<byte>.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]));
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace ZWave.CommandClasses.Tests;

[TestClass]
public partial class Crc16EncapsulationCommandClassTests
{
}
Original file line number Diff line number Diff line change
@@ -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<byte> 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<byte> 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<ArgumentException>(
() => MultiCommandCommandClass.CreateEncapsulation(Array.Empty<CommandClassFrame>()));
}

[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<ZWaveException>(
() => 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<ZWaveException>(
() => 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<ZWaveException>(
() => 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<ZWaveException>(
() => MultiCommandCommandClass.ParseEncapsulation(new CommandClassFrame(data), NullLogger.Instance));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace ZWave.CommandClasses.Tests;

[TestClass]
public partial class MultiCommandCommandClassTests
{
}
28 changes: 28 additions & 0 deletions src/ZWave.CommandClasses/Crc16.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace ZWave.CommandClasses;

/// <summary>
/// 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.
/// </summary>
internal static class Crc16
{
private const ushort Polynomial = 0x1021;
private const ushort InitialValue = 0x1D0F;

public static ushort Compute(ReadOnlySpan<byte> 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;
}
}
Loading