diff --git a/src/ZWave.CommandClasses.Tests/NodeNamingAndLocationCommandClassTests.Location.cs b/src/ZWave.CommandClasses.Tests/NodeNamingAndLocationCommandClassTests.Location.cs new file mode 100644 index 0000000..567a0e2 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/NodeNamingAndLocationCommandClassTests.Location.cs @@ -0,0 +1,185 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class NodeNamingAndLocationCommandClassTests +{ + [TestMethod] + public void NodeLocationSetCommand_Create_AsciiString() + { + var command = NodeNamingAndLocationCommandClass.NodeLocationSetCommand.Create("Room"); + + Assert.AreEqual(CommandClassId.NodeNamingAndLocation, NodeNamingAndLocationCommandClass.NodeLocationSetCommand.CommandClassId); + Assert.AreEqual((byte)NodeNamingAndLocationCommand.NodeLocationSet, NodeNamingAndLocationCommandClass.NodeLocationSetCommand.CommandId); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual(5, parameters.Length); // 1 byte char presentation + 4 text bytes + Assert.AreEqual((byte)CharPresentation.Ascii, parameters[0]); + Assert.AreEqual((byte)'R', parameters[1]); + Assert.AreEqual((byte)'o', parameters[2]); + Assert.AreEqual((byte)'o', parameters[3]); + Assert.AreEqual((byte)'m', parameters[4]); + } + + [TestMethod] + public void NodeLocationSetCommand_Create_NonAsciiString_UsesUtf16() + { + var command = NodeNamingAndLocationCommandClass.NodeLocationSetCommand.Create("B\u00FCro"); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual(9, parameters.Length); // 1 byte char presentation + 8 text bytes (4 UTF-16 chars) + Assert.AreEqual((byte)CharPresentation.Utf16, parameters[0]); + } + + [TestMethod] + public void NodeLocationSetCommand_Create_EmptyLocation() + { + var command = NodeNamingAndLocationCommandClass.NodeLocationSetCommand.Create(string.Empty); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual(1, parameters.Length); // Only char presentation byte + Assert.AreEqual((byte)CharPresentation.Ascii, parameters[0]); + } + + [TestMethod] + public void NodeLocationSetCommand_Create_TooLongLocation_Throws() + { + Assert.ThrowsExactly( + () => NodeNamingAndLocationCommandClass.NodeLocationSetCommand.Create( + "This location name is way too long")); + } + + [TestMethod] + public void NodeLocationGetCommand_Create_HasCorrectFormat() + { + NodeNamingAndLocationCommandClass.NodeLocationGetCommand command = + NodeNamingAndLocationCommandClass.NodeLocationGetCommand.Create(); + + Assert.AreEqual(CommandClassId.NodeNamingAndLocation, NodeNamingAndLocationCommandClass.NodeLocationGetCommand.CommandClassId); + Assert.AreEqual((byte)NodeNamingAndLocationCommand.NodeLocationGet, NodeNamingAndLocationCommandClass.NodeLocationGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); // CC + Cmd only + } + + [TestMethod] + public void NodeLocationReport_Parse_AsciiLocation() + { + // CC=0x77, Cmd=0x06, CharPres=0x00 (ASCII), "Room" + byte[] data = [0x77, 0x06, 0x00, (byte)'R', (byte)'o', (byte)'o', (byte)'m']; + CommandClassFrame frame = new(data); + + string location = NodeNamingAndLocationCommandClass.NodeLocationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual("Room", location); + } + + [TestMethod] + public void NodeLocationReport_Parse_Utf16Location() + { + // CC=0x77, Cmd=0x06, CharPres=0x02 (UTF-16), "Hi" in UTF-16 BE + byte[] data = [0x77, 0x06, 0x02, 0x00, 0x48, 0x00, 0x69]; + CommandClassFrame frame = new(data); + + string location = NodeNamingAndLocationCommandClass.NodeLocationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual("Hi", location); + } + + [TestMethod] + public void NodeLocationReport_Parse_OemExtendedAscii() + { + // CC=0x77, Cmd=0x06, CharPres=0x01 (OEM), decoded as ASCII + byte[] data = [0x77, 0x06, 0x01, (byte)'R', (byte)'o', (byte)'o', (byte)'m']; + CommandClassFrame frame = new(data); + + string location = NodeNamingAndLocationCommandClass.NodeLocationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual("Room", location); + } + + [TestMethod] + public void NodeLocationReport_Parse_EmptyLocation() + { + // CC=0x77, Cmd=0x06, CharPres=0x00 (ASCII), no text bytes + byte[] data = [0x77, 0x06, 0x00]; + CommandClassFrame frame = new(data); + + string location = NodeNamingAndLocationCommandClass.NodeLocationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(string.Empty, location); + } + + [TestMethod] + public void NodeLocationReport_Parse_ReservedBitsIgnored() + { + // CharPres byte = 0xF9. Reserved bits set, charPresentation = 0x01 (OEM Extended) + byte[] data = [0x77, 0x06, 0xF9, (byte)'X']; + CommandClassFrame frame = new(data); + + string location = NodeNamingAndLocationCommandClass.NodeLocationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual("X", location); + } + + [TestMethod] + public void NodeLocationReport_Parse_TooShort_Throws() + { + // CC=0x77, Cmd=0x06, no parameters + byte[] data = [0x77, 0x06]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => NodeNamingAndLocationCommandClass.NodeLocationReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void NodeLocationSetCommand_RoundTrips_AsciiThroughReportParse() + { + var setCommand = NodeNamingAndLocationCommandClass.NodeLocationSetCommand.Create("Kitchen"); + + // Build a report frame from the same parameter bytes + byte[] reportData = new byte[2 + setCommand.Frame.CommandParameters.Length]; + reportData[0] = 0x77; // CC + reportData[1] = 0x06; // Report command + setCommand.Frame.CommandParameters.Span.CopyTo(reportData.AsSpan(2)); + CommandClassFrame reportFrame = new(reportData); + + string location = NodeNamingAndLocationCommandClass.NodeLocationReportCommand.Parse(reportFrame, NullLogger.Instance); + + Assert.AreEqual("Kitchen", location); + } + + [TestMethod] + public void NodeLocationSetCommand_RoundTrips_NonAsciiThroughReportParse() + { + var setCommand = NodeNamingAndLocationCommandClass.NodeLocationSetCommand.Create("B\u00FCro"); + + byte[] reportData = new byte[2 + setCommand.Frame.CommandParameters.Length]; + reportData[0] = 0x77; + reportData[1] = 0x06; + setCommand.Frame.CommandParameters.Span.CopyTo(reportData.AsSpan(2)); + CommandClassFrame reportFrame = new(reportData); + + string location = NodeNamingAndLocationCommandClass.NodeLocationReportCommand.Parse(reportFrame, NullLogger.Instance); + + Assert.AreEqual("B\u00FCro", location); + } + + [TestMethod] + public void NodeLocationReport_Parse_Max16ByteAsciiLocation() + { + // CC=0x77, Cmd=0x06, CharPres=0x00 (ASCII), 16 bytes of text + byte[] data = new byte[2 + 1 + 16]; + data[0] = 0x77; + data[1] = 0x06; + data[2] = 0x00; // ASCII + for (int i = 0; i < 16; i++) + { + data[3 + i] = (byte)('a' + (i % 26)); + } + + CommandClassFrame frame = new(data); + string location = NodeNamingAndLocationCommandClass.NodeLocationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(16, location.Length); + } +} diff --git a/src/ZWave.CommandClasses.Tests/NodeNamingAndLocationCommandClassTests.Name.cs b/src/ZWave.CommandClasses.Tests/NodeNamingAndLocationCommandClassTests.Name.cs new file mode 100644 index 0000000..96168f9 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/NodeNamingAndLocationCommandClassTests.Name.cs @@ -0,0 +1,199 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class NodeNamingAndLocationCommandClassTests +{ + [TestMethod] + public void NodeNameSetCommand_Create_AsciiString() + { + var command = NodeNamingAndLocationCommandClass.NodeNameSetCommand.Create("Test"); + + Assert.AreEqual(CommandClassId.NodeNamingAndLocation, NodeNamingAndLocationCommandClass.NodeNameSetCommand.CommandClassId); + Assert.AreEqual((byte)NodeNamingAndLocationCommand.NodeNameSet, NodeNamingAndLocationCommandClass.NodeNameSetCommand.CommandId); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual(5, parameters.Length); // 1 byte char presentation + 4 text bytes + Assert.AreEqual((byte)CharPresentation.Ascii, parameters[0]); + Assert.AreEqual((byte)'T', parameters[1]); + Assert.AreEqual((byte)'e', parameters[2]); + Assert.AreEqual((byte)'s', parameters[3]); + Assert.AreEqual((byte)'t', parameters[4]); + } + + [TestMethod] + public void NodeNameSetCommand_Create_NonAsciiString_UsesUtf16() + { + var command = NodeNamingAndLocationCommandClass.NodeNameSetCommand.Create("Caf\u00E9"); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual(9, parameters.Length); // 1 byte char presentation + 8 text bytes (4 UTF-16 chars) + Assert.AreEqual((byte)CharPresentation.Utf16, parameters[0]); + // "Café" in UTF-16 BE + Assert.AreEqual(0x00, parameters[1]); + Assert.AreEqual(0x43, parameters[2]); + } + + [TestMethod] + public void NodeNameSetCommand_Create_EmptyName() + { + var command = NodeNamingAndLocationCommandClass.NodeNameSetCommand.Create(string.Empty); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual(1, parameters.Length); // Only char presentation byte + Assert.AreEqual((byte)CharPresentation.Ascii, parameters[0]); + } + + [TestMethod] + public void NodeNameSetCommand_Create_TooLongName_Throws() + { + Assert.ThrowsExactly( + () => NodeNamingAndLocationCommandClass.NodeNameSetCommand.Create( + "This name is way too long for the limit")); + } + + [TestMethod] + public void NodeNameSetCommand_Create_ReservedBitsAreZero() + { + // Use a non-ASCII string to force UTF-16 (charPresentation = 0x02) + var command = NodeNamingAndLocationCommandClass.NodeNameSetCommand.Create("\u00C0"); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual(0x02, parameters[0]); // Only CharPresentation bits set + Assert.AreEqual(0, parameters[0] & 0b1111_1000); // Reserved bits must be zero + } + + [TestMethod] + public void NodeNameGetCommand_Create_HasCorrectFormat() + { + NodeNamingAndLocationCommandClass.NodeNameGetCommand command = + NodeNamingAndLocationCommandClass.NodeNameGetCommand.Create(); + + Assert.AreEqual(CommandClassId.NodeNamingAndLocation, NodeNamingAndLocationCommandClass.NodeNameGetCommand.CommandClassId); + Assert.AreEqual((byte)NodeNamingAndLocationCommand.NodeNameGet, NodeNamingAndLocationCommandClass.NodeNameGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); // CC + Cmd only + } + + [TestMethod] + public void NodeNameReport_Parse_AsciiName() + { + // CC=0x77, Cmd=0x03, CharPres=0x00 (ASCII), "Test" + byte[] data = [0x77, 0x03, 0x00, (byte)'T', (byte)'e', (byte)'s', (byte)'t']; + CommandClassFrame frame = new(data); + + string name = NodeNamingAndLocationCommandClass.NodeNameReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual("Test", name); + } + + [TestMethod] + public void NodeNameReport_Parse_Utf16Name() + { + // CC=0x77, Cmd=0x03, CharPres=0x02 (UTF-16), "Hi" in UTF-16 BE + byte[] data = [0x77, 0x03, 0x02, 0x00, 0x48, 0x00, 0x69]; + CommandClassFrame frame = new(data); + + string name = NodeNamingAndLocationCommandClass.NodeNameReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual("Hi", name); + } + + [TestMethod] + public void NodeNameReport_Parse_OemExtendedAscii() + { + // CC=0x77, Cmd=0x03, CharPres=0x01 (OEM), decoded as ASCII + byte[] data = [0x77, 0x03, 0x01, (byte)'c', (byte)'a', (byte)'f', (byte)'e']; + CommandClassFrame frame = new(data); + + string name = NodeNamingAndLocationCommandClass.NodeNameReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual("cafe", name); + } + + [TestMethod] + public void NodeNameReport_Parse_EmptyName() + { + // CC=0x77, Cmd=0x03, CharPres=0x00 (ASCII), no text bytes + byte[] data = [0x77, 0x03, 0x00]; + CommandClassFrame frame = new(data); + + string name = NodeNamingAndLocationCommandClass.NodeNameReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(string.Empty, name); + } + + [TestMethod] + public void NodeNameReport_Parse_ReservedBitsIgnored() + { + // CharPres byte = 0xF8 | 0x00 = 0xF8. Reserved bits set, charPresentation = 0x00 (ASCII) + byte[] data = [0x77, 0x03, 0xF8, (byte)'A']; + CommandClassFrame frame = new(data); + + string name = NodeNamingAndLocationCommandClass.NodeNameReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual("A", name); + } + + [TestMethod] + public void NodeNameReport_Parse_TooShort_Throws() + { + // CC=0x77, Cmd=0x03, no parameters + byte[] data = [0x77, 0x03]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => NodeNamingAndLocationCommandClass.NodeNameReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void NodeNameSetCommand_RoundTrips_AsciiThroughReportParse() + { + var setCommand = NodeNamingAndLocationCommandClass.NodeNameSetCommand.Create("MyNode"); + + // Build a report frame from the same parameter bytes + byte[] reportData = new byte[2 + setCommand.Frame.CommandParameters.Length]; + reportData[0] = 0x77; // CC + reportData[1] = 0x03; // Report command + setCommand.Frame.CommandParameters.Span.CopyTo(reportData.AsSpan(2)); + CommandClassFrame reportFrame = new(reportData); + + string name = NodeNamingAndLocationCommandClass.NodeNameReportCommand.Parse(reportFrame, NullLogger.Instance); + + Assert.AreEqual("MyNode", name); + } + + [TestMethod] + public void NodeNameSetCommand_RoundTrips_Utf16ThroughReportParse() + { + var setCommand = NodeNamingAndLocationCommandClass.NodeNameSetCommand.Create("Caf\u00E9"); + + byte[] reportData = new byte[2 + setCommand.Frame.CommandParameters.Length]; + reportData[0] = 0x77; + reportData[1] = 0x03; + setCommand.Frame.CommandParameters.Span.CopyTo(reportData.AsSpan(2)); + CommandClassFrame reportFrame = new(reportData); + + string name = NodeNamingAndLocationCommandClass.NodeNameReportCommand.Parse(reportFrame, NullLogger.Instance); + + Assert.AreEqual("Caf\u00E9", name); + } + + [TestMethod] + public void NodeNameReport_Parse_Max16ByteAsciiName() + { + // CC=0x77, Cmd=0x03, CharPres=0x00 (ASCII), 16 bytes of text + byte[] data = new byte[2 + 1 + 16]; + data[0] = 0x77; + data[1] = 0x03; + data[2] = 0x00; // ASCII + for (int i = 0; i < 16; i++) + { + data[3 + i] = (byte)('A' + (i % 26)); + } + + CommandClassFrame frame = new(data); + string name = NodeNamingAndLocationCommandClass.NodeNameReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(16, name.Length); + } +} diff --git a/src/ZWave.CommandClasses.Tests/NodeNamingAndLocationCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/NodeNamingAndLocationCommandClassTests.cs new file mode 100644 index 0000000..6559c8d --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/NodeNamingAndLocationCommandClassTests.cs @@ -0,0 +1,138 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public partial class NodeNamingAndLocationCommandClassTests +{ + [TestMethod] + public void DecodeText_Ascii() + { + byte[] data = [0x48, 0x65, 0x6C, 0x6C, 0x6F]; // "Hello" + string result = NodeNamingAndLocationCommandClass.DecodeText(CharPresentation.Ascii, data); + Assert.AreEqual("Hello", result); + } + + [TestMethod] + public void DecodeText_OemExtendedAscii_DecodesAsAscii() + { + // OEM Extended ASCII is decoded as ASCII; bytes > 127 become '?' + byte[] data = [0x48, 0x65, 0x6C, 0x6C, 0x81]; + string result = NodeNamingAndLocationCommandClass.DecodeText(CharPresentation.OemExtendedAscii, data); + Assert.AreEqual("Hell?", result); + } + + [TestMethod] + public void DecodeText_Utf16() + { + byte[] data = [0x00, 0x48, 0x00, 0x69]; // "Hi" in UTF-16 BE + string result = NodeNamingAndLocationCommandClass.DecodeText(CharPresentation.Utf16, data); + Assert.AreEqual("Hi", result); + } + + [TestMethod] + public void DecodeText_EmptyData() + { + string result = NodeNamingAndLocationCommandClass.DecodeText(CharPresentation.Ascii, ReadOnlySpan.Empty); + Assert.AreEqual(string.Empty, result); + } + + [TestMethod] + public void DecodeText_UnknownPresentation_FallsBackToAscii() + { + byte[] data = [0x41, 0x42]; // "AB" + string result = NodeNamingAndLocationCommandClass.DecodeText((CharPresentation)0x05, data); + Assert.AreEqual("AB", result); + } + + [TestMethod] + public void GetCharPresentation_AsciiString_ReturnsAscii() + { + CharPresentation result = NodeNamingAndLocationCommandClass.GetCharPresentation("Hello"); + Assert.AreEqual(CharPresentation.Ascii, result); + } + + [TestMethod] + public void GetCharPresentation_NonAsciiString_ReturnsUtf16() + { + CharPresentation result = NodeNamingAndLocationCommandClass.GetCharPresentation("Caf\u00E9"); + Assert.AreEqual(CharPresentation.Utf16, result); + } + + [TestMethod] + public void GetCharPresentation_EmptyString_ReturnsAscii() + { + CharPresentation result = NodeNamingAndLocationCommandClass.GetCharPresentation(string.Empty); + Assert.AreEqual(CharPresentation.Ascii, result); + } + + [TestMethod] + public void EncodeText_Ascii() + { + Span buffer = stackalloc byte[16]; + int written = NodeNamingAndLocationCommandClass.EncodeText("Hello", CharPresentation.Ascii, buffer); + Assert.AreEqual(5, written); + Assert.AreEqual((byte)'H', buffer[0]); + Assert.AreEqual((byte)'e', buffer[1]); + Assert.AreEqual((byte)'l', buffer[2]); + Assert.AreEqual((byte)'l', buffer[3]); + Assert.AreEqual((byte)'o', buffer[4]); + } + + [TestMethod] + public void EncodeText_Utf16() + { + Span buffer = stackalloc byte[16]; + int written = NodeNamingAndLocationCommandClass.EncodeText("Hi", CharPresentation.Utf16, buffer); + Assert.AreEqual(4, written); + // "Hi" in UTF-16 BE: 0x00 0x48 0x00 0x69 + Assert.AreEqual(0x00, buffer[0]); + Assert.AreEqual(0x48, buffer[1]); + Assert.AreEqual(0x00, buffer[2]); + Assert.AreEqual(0x69, buffer[3]); + } + + [TestMethod] + public void EncodeText_Ascii_TooLong_Throws() + { + byte[] buffer = new byte[16]; + Assert.ThrowsExactly( + () => NodeNamingAndLocationCommandClass.EncodeText( + "This is a very long name exceeding 16 bytes", CharPresentation.Ascii, buffer)); + } + + [TestMethod] + public void EncodeText_Utf16_TooLong_Throws() + { + // 9 non-ASCII characters = 18 bytes in UTF-16, exceeds 16 bytes + byte[] buffer = new byte[16]; + Assert.ThrowsExactly( + () => NodeNamingAndLocationCommandClass.EncodeText( + "\u00C0\u00C1\u00C2\u00C3\u00C4\u00C5\u00C6\u00C7\u00C8", CharPresentation.Utf16, buffer)); + } + + [TestMethod] + public void EncodeText_EmptyString() + { + Span buffer = stackalloc byte[16]; + int written = NodeNamingAndLocationCommandClass.EncodeText(string.Empty, CharPresentation.Ascii, buffer); + Assert.AreEqual(0, written); + } + + [TestMethod] + public void EncodeText_Ascii_Exactly16Bytes() + { + Span buffer = stackalloc byte[16]; + int written = NodeNamingAndLocationCommandClass.EncodeText("1234567890ABCDEF", CharPresentation.Ascii, buffer); + Assert.AreEqual(16, written); + } + + [TestMethod] + public void EncodeText_Utf16_Exactly8Characters() + { + Span buffer = stackalloc byte[16]; + int written = NodeNamingAndLocationCommandClass.EncodeText( + "\u00C0\u00C1\u00C2\u00C3\u00C4\u00C5\u00C6\u00C7", CharPresentation.Utf16, buffer); + Assert.AreEqual(16, written); + } +} diff --git a/src/ZWave.CommandClasses/NodeNamingAndLocationCommandClass.Location.cs b/src/ZWave.CommandClasses/NodeNamingAndLocationCommandClass.Location.cs new file mode 100644 index 0000000..247d11b --- /dev/null +++ b/src/ZWave.CommandClasses/NodeNamingAndLocationCommandClass.Location.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class NodeNamingAndLocationCommandClass +{ + /// + /// Gets the location of the node, or if not yet retrieved. + /// + public string? Location { get; private set; } + + /// + /// Occurs when a Node Location Report is received, both solicited and unsolicited. + /// + public event Action? OnNodeLocationReportReceived; + + /// + /// Request the stored location from a node. + /// + public async Task GetLocationAsync(CancellationToken cancellationToken) + { + NodeLocationGetCommand command = NodeLocationGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + string location = NodeLocationReportCommand.Parse(reportFrame, Logger); + Location = location; + OnNodeLocationReportReceived?.Invoke(location); + return location; + } + + /// + /// Set the location of a node. + /// + /// The location to assign to the node. Maximum 16 ASCII characters or 8 Unicode characters. + /// The cancellation token. + /// The location exceeds the maximum encoded length of 16 bytes. + public async Task SetLocationAsync(string location, CancellationToken cancellationToken) + { + NodeLocationSetCommand command = NodeLocationSetCommand.Create(location); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + internal readonly struct NodeLocationSetCommand : ICommand + { + public NodeLocationSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.NodeNamingAndLocation; + + public static byte CommandId => (byte)NodeNamingAndLocationCommand.NodeLocationSet; + + public CommandClassFrame Frame { get; } + + public static NodeLocationSetCommand Create(string location) + { + CharPresentation charPresentation = GetCharPresentation(location); + Span commandParameters = stackalloc byte[1 + MaxTextBytes]; + commandParameters[0] = (byte)((byte)charPresentation & 0b0000_0111); + int bytesWritten = EncodeText(location, charPresentation, commandParameters[1..]); + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters[..(1 + bytesWritten)]); + return new NodeLocationSetCommand(frame); + } + } + + internal readonly struct NodeLocationGetCommand : ICommand + { + public NodeLocationGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.NodeNamingAndLocation; + + public static byte CommandId => (byte)NodeNamingAndLocationCommand.NodeLocationGet; + + public CommandClassFrame Frame { get; } + + public static NodeLocationGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new NodeLocationGetCommand(frame); + } + } + + internal readonly struct NodeLocationReportCommand : ICommand + { + public NodeLocationReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.NodeNamingAndLocation; + + public static byte CommandId => (byte)NodeNamingAndLocationCommand.NodeLocationReport; + + public CommandClassFrame Frame { get; } + + public static string Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning("Node Location Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Node Location Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + CharPresentation charPresentation = (CharPresentation)(span[0] & 0b0000_0111); + ReadOnlySpan textBytes = span.Length > 1 ? span[1..] : []; + return DecodeText(charPresentation, textBytes); + } + } +} diff --git a/src/ZWave.CommandClasses/NodeNamingAndLocationCommandClass.Name.cs b/src/ZWave.CommandClasses/NodeNamingAndLocationCommandClass.Name.cs new file mode 100644 index 0000000..e9dd8de --- /dev/null +++ b/src/ZWave.CommandClasses/NodeNamingAndLocationCommandClass.Name.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class NodeNamingAndLocationCommandClass +{ + /// + /// Gets the name of the node, or if not yet retrieved. + /// + public string? Name { get; private set; } + + /// + /// Occurs when a Node Name Report is received, both solicited and unsolicited. + /// + public event Action? OnNodeNameReportReceived; + + /// + /// Request the stored name from a node. + /// + public async Task GetNameAsync(CancellationToken cancellationToken) + { + NodeNameGetCommand command = NodeNameGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + string name = NodeNameReportCommand.Parse(reportFrame, Logger); + Name = name; + OnNodeNameReportReceived?.Invoke(name); + return name; + } + + /// + /// Set the name of a node. + /// + /// The name to assign to the node. Maximum 16 ASCII characters or 8 Unicode characters. + /// The cancellation token. + /// The name exceeds the maximum encoded length of 16 bytes. + public async Task SetNameAsync(string name, CancellationToken cancellationToken) + { + NodeNameSetCommand command = NodeNameSetCommand.Create(name); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + internal readonly struct NodeNameSetCommand : ICommand + { + public NodeNameSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.NodeNamingAndLocation; + + public static byte CommandId => (byte)NodeNamingAndLocationCommand.NodeNameSet; + + public CommandClassFrame Frame { get; } + + public static NodeNameSetCommand Create(string name) + { + CharPresentation charPresentation = GetCharPresentation(name); + Span commandParameters = stackalloc byte[1 + MaxTextBytes]; + commandParameters[0] = (byte)((byte)charPresentation & 0b0000_0111); + int bytesWritten = EncodeText(name, charPresentation, commandParameters[1..]); + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters[..(1 + bytesWritten)]); + return new NodeNameSetCommand(frame); + } + } + + internal readonly struct NodeNameGetCommand : ICommand + { + public NodeNameGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.NodeNamingAndLocation; + + public static byte CommandId => (byte)NodeNamingAndLocationCommand.NodeNameGet; + + public CommandClassFrame Frame { get; } + + public static NodeNameGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new NodeNameGetCommand(frame); + } + } + + internal readonly struct NodeNameReportCommand : ICommand + { + public NodeNameReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.NodeNamingAndLocation; + + public static byte CommandId => (byte)NodeNamingAndLocationCommand.NodeNameReport; + + public CommandClassFrame Frame { get; } + + public static string Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning("Node Name Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Node Name Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + CharPresentation charPresentation = (CharPresentation)(span[0] & 0b0000_0111); + ReadOnlySpan textBytes = span.Length > 1 ? span[1..] : []; + return DecodeText(charPresentation, textBytes); + } + } +} diff --git a/src/ZWave.CommandClasses/NodeNamingAndLocationCommandClass.cs b/src/ZWave.CommandClasses/NodeNamingAndLocationCommandClass.cs new file mode 100644 index 0000000..31d3d23 --- /dev/null +++ b/src/ZWave.CommandClasses/NodeNamingAndLocationCommandClass.cs @@ -0,0 +1,171 @@ +using System.Text; +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Specifies the character encoding used for node name and location text fields. +/// +internal enum CharPresentation : byte +{ + /// + /// Standard ASCII codes (values 128-255 are ignored). + /// + Ascii = 0x00, + + /// + /// Standard and OEM Extended ASCII codes. + /// + OemExtendedAscii = 0x01, + + /// + /// Unicode UTF-16 (big-endian). + /// + Utf16 = 0x02, +} + +/// +/// Represents the commands in the Node Naming and Location Command Class. +/// +public enum NodeNamingAndLocationCommand : byte +{ + /// + /// Set the name of the receiving node. + /// + NodeNameSet = 0x01, + + /// + /// Request the stored name from a node. + /// + NodeNameGet = 0x02, + + /// + /// Advertise the name assigned to the sending node. + /// + NodeNameReport = 0x03, + + /// + /// Set the location of the receiving node. + /// + NodeLocationSet = 0x04, + + /// + /// Request the stored node location from a node. + /// + NodeLocationGet = 0x05, + + /// + /// Advertise the node location. + /// + NodeLocationReport = 0x06, +} + +/// +/// The Node Naming and Location Command Class is used to assign a name and a location text string to a supporting node. +/// +[CommandClass(CommandClassId.NodeNamingAndLocation)] +public sealed partial class NodeNamingAndLocationCommandClass : CommandClass +{ + /// + /// The maximum number of bytes for a node name or location text field. + /// + internal const int MaxTextBytes = 16; + + internal NodeNamingAndLocationCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + internal override CommandClassCategory Category => CommandClassCategory.Management; + + /// + public override bool? IsCommandSupported(NodeNamingAndLocationCommand command) + => command switch + { + NodeNamingAndLocationCommand.NodeNameSet => true, + NodeNamingAndLocationCommand.NodeNameGet => true, + NodeNamingAndLocationCommand.NodeLocationSet => true, + NodeNamingAndLocationCommand.NodeLocationGet => true, + _ => false, + }; + + internal override async Task InterviewAsync(CancellationToken cancellationToken) + { + _ = await GetNameAsync(cancellationToken).ConfigureAwait(false); + _ = await GetLocationAsync(cancellationToken).ConfigureAwait(false); + } + + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((NodeNamingAndLocationCommand)frame.CommandId) + { + case NodeNamingAndLocationCommand.NodeNameReport: + { + string name = NodeNameReportCommand.Parse(frame, Logger); + Name = name; + OnNodeNameReportReceived?.Invoke(name); + break; + } + case NodeNamingAndLocationCommand.NodeLocationReport: + { + string location = NodeLocationReportCommand.Parse(frame, Logger); + Location = location; + OnNodeLocationReportReceived?.Invoke(location); + break; + } + } + } + + /// + /// Decodes text bytes using the specified character presentation. + /// + internal static string DecodeText(CharPresentation charPresentation, ReadOnlySpan data) + => charPresentation switch + { + CharPresentation.Utf16 => Encoding.BigEndianUnicode.GetString(data), + // The spec defines OEM Extended ASCII (0x01) as a distinct encoding, but no specific + // code page is referenced. In practice, devices use only the 7-bit ASCII subset. + // This matches zwave-js, which also decodes OEM Extended ASCII as plain ASCII. + _ => Encoding.ASCII.GetString(data), + }; + + /// + /// Determines the best character presentation for the given text. + /// Uses ASCII if all characters are in the 7-bit range, otherwise UTF-16 BE. + /// + internal static CharPresentation GetCharPresentation(string text) + { + for (int i = 0; i < text.Length; i++) + { + if (text[i] > 127) + { + return CharPresentation.Utf16; + } + } + + return CharPresentation.Ascii; + } + + /// + /// Encodes text into the destination span using the specified character presentation. + /// + /// The number of bytes written. + /// The encoded text exceeds bytes. + internal static int EncodeText(string text, CharPresentation charPresentation, Span destination) + { + Encoding encoding = charPresentation == CharPresentation.Utf16 + ? Encoding.BigEndianUnicode + : Encoding.ASCII; + + if (!encoding.TryGetBytes(text.AsSpan(), destination[..MaxTextBytes], out int bytesWritten)) + { + throw new ArgumentException($"Text exceeds the maximum of {MaxTextBytes} encoded bytes.", nameof(text)); + } + + return bytesWritten; + } +}