From ef1fa8bab82cbf04d826c7984412244a6a3ef4cd Mon Sep 17 00:00:00 2001 From: David Federman Date: Sun, 8 Mar 2026 08:34:15 -0700 Subject: [PATCH] Implement Sound Switch CC --- ...ndSwitchCommandClassTests.Configuration.cs | 125 ++++++++++++ .../SoundSwitchCommandClassTests.ToneInfo.cs | 122 ++++++++++++ .../SoundSwitchCommandClassTests.TonePlay.cs | 182 ++++++++++++++++++ ...oundSwitchCommandClassTests.TonesNumber.cs | 52 +++++ .../SoundSwitchCommandClassTests.cs | 6 + .../SoundSwitchCommandClass.Configuration.cs | 144 ++++++++++++++ .../SoundSwitchCommandClass.ToneInfo.cs | 115 +++++++++++ .../SoundSwitchCommandClass.TonePlay.cs | 159 +++++++++++++++ .../SoundSwitchCommandClass.TonesNumber.cs | 69 +++++++ .../SoundSwitchCommandClass.cs | 128 ++++++++++++ 10 files changed, 1102 insertions(+) create mode 100644 src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.Configuration.cs create mode 100644 src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.ToneInfo.cs create mode 100644 src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.TonePlay.cs create mode 100644 src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.TonesNumber.cs create mode 100644 src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.cs create mode 100644 src/ZWave.CommandClasses/SoundSwitchCommandClass.Configuration.cs create mode 100644 src/ZWave.CommandClasses/SoundSwitchCommandClass.ToneInfo.cs create mode 100644 src/ZWave.CommandClasses/SoundSwitchCommandClass.TonePlay.cs create mode 100644 src/ZWave.CommandClasses/SoundSwitchCommandClass.TonesNumber.cs create mode 100644 src/ZWave.CommandClasses/SoundSwitchCommandClass.cs diff --git a/src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.Configuration.cs b/src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.Configuration.cs new file mode 100644 index 0000000..ede6385 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.Configuration.cs @@ -0,0 +1,125 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class SoundSwitchCommandClassTests +{ + [TestMethod] + public void ConfigurationSetCommand_Create_HasCorrectFormat() + { + SoundSwitchCommandClass.SoundSwitchConfigurationSetCommand command = + SoundSwitchCommandClass.SoundSwitchConfigurationSetCommand.Create(50, 3); + + Assert.AreEqual(CommandClassId.SoundSwitch, SoundSwitchCommandClass.SoundSwitchConfigurationSetCommand.CommandClassId); + Assert.AreEqual((byte)SoundSwitchCommand.ConfigurationSet, SoundSwitchCommandClass.SoundSwitchConfigurationSetCommand.CommandId); + Assert.AreEqual(4, command.Frame.Data.Length); + Assert.AreEqual((byte)50, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual((byte)3, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void ConfigurationSetCommand_Create_MuteVolume() + { + SoundSwitchCommandClass.SoundSwitchConfigurationSetCommand command = + SoundSwitchCommandClass.SoundSwitchConfigurationSetCommand.Create(0x00, 1); + + Assert.AreEqual((byte)0x00, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual((byte)1, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void ConfigurationSetCommand_Create_RestoreVolume_NoDefaultToneChange() + { + // Volume=0xFF (restore), DefaultTone=0x00 (don't change) + SoundSwitchCommandClass.SoundSwitchConfigurationSetCommand command = + SoundSwitchCommandClass.SoundSwitchConfigurationSetCommand.Create(0xFF, 0x00); + + Assert.AreEqual((byte)0xFF, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual((byte)0x00, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void ConfigurationSetCommand_Create_FullVolume() + { + SoundSwitchCommandClass.SoundSwitchConfigurationSetCommand command = + SoundSwitchCommandClass.SoundSwitchConfigurationSetCommand.Create(100, 5); + + Assert.AreEqual((byte)100, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual((byte)5, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void ConfigurationGetCommand_Create_HasCorrectFormat() + { + SoundSwitchCommandClass.SoundSwitchConfigurationGetCommand command = + SoundSwitchCommandClass.SoundSwitchConfigurationGetCommand.Create(); + + Assert.AreEqual(CommandClassId.SoundSwitch, SoundSwitchCommandClass.SoundSwitchConfigurationGetCommand.CommandClassId); + Assert.AreEqual((byte)SoundSwitchCommand.ConfigurationGet, SoundSwitchCommandClass.SoundSwitchConfigurationGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void ConfigurationReport_Parse_NormalValues() + { + // CC=0x79, Cmd=0x07, Volume=75, DefaultTone=2 + byte[] data = [0x79, 0x07, 0x4B, 0x02]; + CommandClassFrame frame = new(data); + + SoundSwitchConfigurationReport report = + SoundSwitchCommandClass.SoundSwitchConfigurationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)75, report.Volume); + Assert.AreEqual((byte)2, report.DefaultToneIdentifier); + } + + [TestMethod] + public void ConfigurationReport_Parse_MutedVolume() + { + // CC=0x79, Cmd=0x07, Volume=0, DefaultTone=1 + byte[] data = [0x79, 0x07, 0x00, 0x01]; + CommandClassFrame frame = new(data); + + SoundSwitchConfigurationReport report = + SoundSwitchCommandClass.SoundSwitchConfigurationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0, report.Volume); + Assert.AreEqual((byte)1, report.DefaultToneIdentifier); + } + + [TestMethod] + public void ConfigurationReport_Parse_MaxVolume() + { + // CC=0x79, Cmd=0x07, Volume=100, DefaultTone=10 + byte[] data = [0x79, 0x07, 0x64, 0x0A]; + CommandClassFrame frame = new(data); + + SoundSwitchConfigurationReport report = + SoundSwitchCommandClass.SoundSwitchConfigurationReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)100, report.Volume); + Assert.AreEqual((byte)10, report.DefaultToneIdentifier); + } + + [TestMethod] + public void ConfigurationReport_Parse_TooShort_Throws() + { + // CC=0x79, Cmd=0x07, only 1 parameter byte + byte[] data = [0x79, 0x07, 0x50]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => SoundSwitchCommandClass.SoundSwitchConfigurationReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void ConfigurationReport_Parse_NoParameters_Throws() + { + // CC=0x79, Cmd=0x07, no parameters + byte[] data = [0x79, 0x07]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => SoundSwitchCommandClass.SoundSwitchConfigurationReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.ToneInfo.cs b/src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.ToneInfo.cs new file mode 100644 index 0000000..b18e605 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.ToneInfo.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class SoundSwitchCommandClassTests +{ + [TestMethod] + public void ToneInfoGetCommand_Create_HasCorrectFormat() + { + SoundSwitchCommandClass.SoundSwitchToneInfoGetCommand command = + SoundSwitchCommandClass.SoundSwitchToneInfoGetCommand.Create(5); + + Assert.AreEqual(CommandClassId.SoundSwitch, SoundSwitchCommandClass.SoundSwitchToneInfoGetCommand.CommandClassId); + Assert.AreEqual((byte)SoundSwitchCommand.ToneInfoGet, SoundSwitchCommandClass.SoundSwitchToneInfoGetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual((byte)5, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void ToneInfoGetCommand_Create_ToneIdentifier1() + { + SoundSwitchCommandClass.SoundSwitchToneInfoGetCommand command = + SoundSwitchCommandClass.SoundSwitchToneInfoGetCommand.Create(1); + + Assert.AreEqual((byte)1, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void ToneInfoReport_Parse_ShortName() + { + // CC=0x79, Cmd=0x04, ToneId=1, Duration=0x000A (10 sec), NameLen=4, Name="Ding" + byte[] data = [0x79, 0x04, 0x01, 0x00, 0x0A, 0x04, 0x44, 0x69, 0x6E, 0x67]; + CommandClassFrame frame = new(data); + + SoundSwitchToneInfo info = SoundSwitchCommandClass.SoundSwitchToneInfoReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, info.ToneIdentifier); + Assert.AreEqual((ushort)10, info.DurationSeconds); + Assert.AreEqual("Ding", info.Name); + } + + [TestMethod] + public void ToneInfoReport_Parse_LongerName() + { + // CC=0x79, Cmd=0x04, ToneId=3, Duration=0x001E (30 sec), NameLen=11, Name="Fire Alarm!" + byte[] nameBytes = "Fire Alarm!"u8.ToArray(); + byte[] data = [0x79, 0x04, 0x03, 0x00, 0x1E, (byte)nameBytes.Length, .. nameBytes]; + CommandClassFrame frame = new(data); + + SoundSwitchToneInfo info = SoundSwitchCommandClass.SoundSwitchToneInfoReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)3, info.ToneIdentifier); + Assert.AreEqual((ushort)30, info.DurationSeconds); + Assert.AreEqual("Fire Alarm!", info.Name); + } + + [TestMethod] + public void ToneInfoReport_Parse_Utf8MultiByte() + { + // CC=0x79, Cmd=0x04, ToneId=2, Duration=0x0005 (5 sec), Name with UTF-8 multi-byte chars "Tö" + byte[] nameBytes = "Tö"u8.ToArray(); + byte[] data = [0x79, 0x04, 0x02, 0x00, 0x05, (byte)nameBytes.Length, .. nameBytes]; + CommandClassFrame frame = new(data); + + SoundSwitchToneInfo info = SoundSwitchCommandClass.SoundSwitchToneInfoReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)2, info.ToneIdentifier); + Assert.AreEqual((ushort)5, info.DurationSeconds); + Assert.AreEqual("Tö", info.Name); + } + + [TestMethod] + public void ToneInfoReport_Parse_ZeroDurationAndEmptyName() + { + // Per spec: unsupported tone identifier returns zero duration and zero-length name + // CC=0x79, Cmd=0x04, ToneId=0, Duration=0x0000, NameLen=0 + byte[] data = [0x79, 0x04, 0x00, 0x00, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + SoundSwitchToneInfo info = SoundSwitchCommandClass.SoundSwitchToneInfoReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0, info.ToneIdentifier); + Assert.AreEqual((ushort)0, info.DurationSeconds); + Assert.AreEqual(string.Empty, info.Name); + } + + [TestMethod] + public void ToneInfoReport_Parse_MaxDuration() + { + // CC=0x79, Cmd=0x04, ToneId=1, Duration=0xFFFF (65535 sec), NameLen=1, Name="X" + byte[] data = [0x79, 0x04, 0x01, 0xFF, 0xFF, 0x01, 0x58]; + CommandClassFrame frame = new(data); + + SoundSwitchToneInfo info = SoundSwitchCommandClass.SoundSwitchToneInfoReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, info.ToneIdentifier); + Assert.AreEqual((ushort)65535, info.DurationSeconds); + Assert.AreEqual("X", info.Name); + } + + [TestMethod] + public void ToneInfoReport_Parse_TooShort_Throws() + { + // CC=0x79, Cmd=0x04, only 2 parameter bytes (need at least 4) + byte[] data = [0x79, 0x04, 0x01, 0x00]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => SoundSwitchCommandClass.SoundSwitchToneInfoReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void ToneInfoReport_Parse_NameLengthExceedsPayload_Throws() + { + // CC=0x79, Cmd=0x04, ToneId=1, Duration=0x000A, NameLen=10 but only 3 name bytes + byte[] data = [0x79, 0x04, 0x01, 0x00, 0x0A, 0x0A, 0x41, 0x42, 0x43]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => SoundSwitchCommandClass.SoundSwitchToneInfoReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.TonePlay.cs b/src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.TonePlay.cs new file mode 100644 index 0000000..2bf4b59 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.TonePlay.cs @@ -0,0 +1,182 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class SoundSwitchCommandClassTests +{ + [TestMethod] + public void TonePlaySetCommand_Create_Version1_PlayTone() + { + SoundSwitchCommandClass.SoundSwitchTonePlaySetCommand command = + SoundSwitchCommandClass.SoundSwitchTonePlaySetCommand.Create(1, 0x05, null); + + Assert.AreEqual(CommandClassId.SoundSwitch, SoundSwitchCommandClass.SoundSwitchTonePlaySetCommand.CommandClassId); + Assert.AreEqual((byte)SoundSwitchCommand.TonePlaySet, SoundSwitchCommandClass.SoundSwitchTonePlaySetCommand.CommandId); + // V1: only tone identifier byte + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual((byte)0x05, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void TonePlaySetCommand_Create_Version1_StopTone() + { + SoundSwitchCommandClass.SoundSwitchTonePlaySetCommand command = + SoundSwitchCommandClass.SoundSwitchTonePlaySetCommand.Create(1, 0x00, null); + + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual((byte)0x00, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void TonePlaySetCommand_Create_Version1_PlayDefault() + { + SoundSwitchCommandClass.SoundSwitchTonePlaySetCommand command = + SoundSwitchCommandClass.SoundSwitchTonePlaySetCommand.Create(1, 0xFF, null); + + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual((byte)0xFF, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void TonePlaySetCommand_Create_Version2_PlayToneWithVolume() + { + SoundSwitchCommandClass.SoundSwitchTonePlaySetCommand command = + SoundSwitchCommandClass.SoundSwitchTonePlaySetCommand.Create(2, 0x03, 80); + + // V2: tone identifier + volume byte + Assert.AreEqual(4, command.Frame.Data.Length); + Assert.AreEqual((byte)0x03, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual((byte)80, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void TonePlaySetCommand_Create_Version2_PlayToneWithConfiguredVolume() + { + // Volume=0x00 means "use configured volume" + SoundSwitchCommandClass.SoundSwitchTonePlaySetCommand command = + SoundSwitchCommandClass.SoundSwitchTonePlaySetCommand.Create(2, 0x03, null); + + Assert.AreEqual(4, command.Frame.Data.Length); + Assert.AreEqual((byte)0x03, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual((byte)0x00, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void TonePlaySetCommand_Create_Version2_PlayDefaultToneWithRestoreVolume() + { + // ToneId=0xFF (default), Volume=0xFF (restore non-zero) + SoundSwitchCommandClass.SoundSwitchTonePlaySetCommand command = + SoundSwitchCommandClass.SoundSwitchTonePlaySetCommand.Create(2, 0xFF, 0xFF); + + Assert.AreEqual(4, command.Frame.Data.Length); + Assert.AreEqual((byte)0xFF, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual((byte)0xFF, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void TonePlaySetCommand_Create_Version2_StopTone_VolumeForced0() + { + // Per spec CC:0079.02.08.11.007: volume MUST be 0x00 when tone identifier is 0x00 + SoundSwitchCommandClass.SoundSwitchTonePlaySetCommand command = + SoundSwitchCommandClass.SoundSwitchTonePlaySetCommand.Create(2, 0x00, 80); + + Assert.AreEqual(4, command.Frame.Data.Length); + Assert.AreEqual((byte)0x00, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual((byte)0x00, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void TonePlayGetCommand_Create_HasCorrectFormat() + { + SoundSwitchCommandClass.SoundSwitchTonePlayGetCommand command = + SoundSwitchCommandClass.SoundSwitchTonePlayGetCommand.Create(); + + Assert.AreEqual(CommandClassId.SoundSwitch, SoundSwitchCommandClass.SoundSwitchTonePlayGetCommand.CommandClassId); + Assert.AreEqual((byte)SoundSwitchCommand.TonePlayGet, SoundSwitchCommandClass.SoundSwitchTonePlayGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void TonePlayReport_Parse_Version1_Playing() + { + // CC=0x79, Cmd=0x0A, ToneId=3 + byte[] data = [0x79, 0x0A, 0x03]; + CommandClassFrame frame = new(data); + + SoundSwitchTonePlayReport report = + SoundSwitchCommandClass.SoundSwitchTonePlayReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)3, report.ToneIdentifier); + Assert.IsNull(report.Volume); + } + + [TestMethod] + public void TonePlayReport_Parse_Version1_NotPlaying() + { + // CC=0x79, Cmd=0x0A, ToneId=0 + byte[] data = [0x79, 0x0A, 0x00]; + CommandClassFrame frame = new(data); + + SoundSwitchTonePlayReport report = + SoundSwitchCommandClass.SoundSwitchTonePlayReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0, report.ToneIdentifier); + Assert.IsNull(report.Volume); + } + + [TestMethod] + public void TonePlayReport_Parse_Version2_PlayingWithVolume() + { + // CC=0x79, Cmd=0x0A, ToneId=5, Volume=80 + byte[] data = [0x79, 0x0A, 0x05, 0x50]; + CommandClassFrame frame = new(data); + + SoundSwitchTonePlayReport report = + SoundSwitchCommandClass.SoundSwitchTonePlayReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)5, report.ToneIdentifier); + Assert.IsNotNull(report.Volume); + Assert.AreEqual((byte)80, report.Volume.Value); + } + + [TestMethod] + public void TonePlayReport_Parse_Version2_NotPlayingWithMutedVolume() + { + // CC=0x79, Cmd=0x0A, ToneId=0, Volume=0 + byte[] data = [0x79, 0x0A, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + SoundSwitchTonePlayReport report = + SoundSwitchCommandClass.SoundSwitchTonePlayReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0, report.ToneIdentifier); + Assert.IsNotNull(report.Volume); + Assert.AreEqual((byte)0, report.Volume.Value); + } + + [TestMethod] + public void TonePlayReport_Parse_Version2_MaxVolume() + { + // CC=0x79, Cmd=0x0A, ToneId=1, Volume=100 (0x64) + byte[] data = [0x79, 0x0A, 0x01, 0x64]; + CommandClassFrame frame = new(data); + + SoundSwitchTonePlayReport report = + SoundSwitchCommandClass.SoundSwitchTonePlayReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, report.ToneIdentifier); + Assert.IsNotNull(report.Volume); + Assert.AreEqual((byte)100, report.Volume.Value); + } + + [TestMethod] + public void TonePlayReport_Parse_TooShort_Throws() + { + // CC=0x79, Cmd=0x0A, no parameters + byte[] data = [0x79, 0x0A]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => SoundSwitchCommandClass.SoundSwitchTonePlayReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.TonesNumber.cs b/src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.TonesNumber.cs new file mode 100644 index 0000000..c4f3e37 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.TonesNumber.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class SoundSwitchCommandClassTests +{ + [TestMethod] + public void TonesNumberGetCommand_Create_HasCorrectFormat() + { + SoundSwitchCommandClass.SoundSwitchTonesNumberGetCommand command = + SoundSwitchCommandClass.SoundSwitchTonesNumberGetCommand.Create(); + + Assert.AreEqual(CommandClassId.SoundSwitch, SoundSwitchCommandClass.SoundSwitchTonesNumberGetCommand.CommandClassId); + Assert.AreEqual((byte)SoundSwitchCommand.TonesNumberGet, SoundSwitchCommandClass.SoundSwitchTonesNumberGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void TonesNumberReport_Parse_SingleTone() + { + // CC=0x79, Cmd=0x02, SupportedTones=1 + byte[] data = [0x79, 0x02, 0x01]; + CommandClassFrame frame = new(data); + + byte tonesCount = SoundSwitchCommandClass.SoundSwitchTonesNumberReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)1, tonesCount); + } + + [TestMethod] + public void TonesNumberReport_Parse_MaxTones() + { + // CC=0x79, Cmd=0x02, SupportedTones=254 (max per spec) + byte[] data = [0x79, 0x02, 0xFE]; + CommandClassFrame frame = new(data); + + byte tonesCount = SoundSwitchCommandClass.SoundSwitchTonesNumberReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)254, tonesCount); + } + + [TestMethod] + public void TonesNumberReport_Parse_TooShort_Throws() + { + // CC=0x79, Cmd=0x02, no parameters + byte[] data = [0x79, 0x02]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => SoundSwitchCommandClass.SoundSwitchTonesNumberReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.cs new file mode 100644 index 0000000..69b3a74 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/SoundSwitchCommandClassTests.cs @@ -0,0 +1,6 @@ +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public partial class SoundSwitchCommandClassTests +{ +} diff --git a/src/ZWave.CommandClasses/SoundSwitchCommandClass.Configuration.cs b/src/ZWave.CommandClasses/SoundSwitchCommandClass.Configuration.cs new file mode 100644 index 0000000..7f7258f --- /dev/null +++ b/src/ZWave.CommandClasses/SoundSwitchCommandClass.Configuration.cs @@ -0,0 +1,144 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents the configuration of a Sound Switch device (volume and default tone). +/// +public readonly record struct SoundSwitchConfigurationReport( + /// + /// The current volume setting (0-100%). + /// + byte Volume, + + /// + /// The currently configured default tone identifier. + /// + byte DefaultToneIdentifier); + +public sealed partial class SoundSwitchCommandClass +{ + /// + /// Gets the last configuration report received from the device. + /// + public SoundSwitchConfigurationReport? LastConfigurationReport { get; private set; } + + /// + /// Event raised when a Configuration Report is received, both solicited and unsolicited. + /// + public event Action? OnConfigurationReportReceived; + + /// + /// Request the current configuration for playing tones at the device. + /// + public async Task GetConfigurationAsync(CancellationToken cancellationToken) + { + SoundSwitchConfigurationGetCommand command = SoundSwitchConfigurationGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + SoundSwitchConfigurationReport report = SoundSwitchConfigurationReportCommand.Parse(reportFrame, Logger); + LastConfigurationReport = report; + OnConfigurationReportReceived?.Invoke(report); + return report; + } + + /// + /// Set only the volume at the device without changing the default tone. + /// + /// + /// The volume level: 0 = mute, 1-100 = volume percentage, 255 = restore most recent non-zero volume. + /// + /// The cancellation token. + public async Task SetVolumeAsync(byte volume, CancellationToken cancellationToken) + { + await SetConfigurationAsync(volume, defaultToneIdentifier: 0x00, cancellationToken).ConfigureAwait(false); + } + + /// + /// Set the volume and default tone configuration at the device. + /// + /// + /// The volume level: 0 = mute, 1-100 = volume percentage, 255 = restore most recent non-zero volume. + /// + /// + /// The default tone identifier. 0 = do not change the default tone (configure volume only). + /// Values 1..N set the specified tone as default. + /// + /// The cancellation token. + public async Task SetConfigurationAsync(byte volume, byte defaultToneIdentifier, CancellationToken cancellationToken) + { + var command = SoundSwitchConfigurationSetCommand.Create(volume, defaultToneIdentifier); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + internal readonly struct SoundSwitchConfigurationSetCommand : ICommand + { + public SoundSwitchConfigurationSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.SoundSwitch; + + public static byte CommandId => (byte)SoundSwitchCommand.ConfigurationSet; + + public CommandClassFrame Frame { get; } + + public static SoundSwitchConfigurationSetCommand Create(byte volume, byte defaultToneIdentifier) + { + ReadOnlySpan commandParameters = [volume, defaultToneIdentifier]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new SoundSwitchConfigurationSetCommand(frame); + } + } + + internal readonly struct SoundSwitchConfigurationGetCommand : ICommand + { + public SoundSwitchConfigurationGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.SoundSwitch; + + public static byte CommandId => (byte)SoundSwitchCommand.ConfigurationGet; + + public CommandClassFrame Frame { get; } + + public static SoundSwitchConfigurationGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new SoundSwitchConfigurationGetCommand(frame); + } + } + + internal readonly struct SoundSwitchConfigurationReportCommand : ICommand + { + public SoundSwitchConfigurationReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.SoundSwitch; + + public static byte CommandId => (byte)SoundSwitchCommand.ConfigurationReport; + + public CommandClassFrame Frame { get; } + + public static SoundSwitchConfigurationReport Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 2) + { + logger.LogWarning("Sound Switch Configuration Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Sound Switch Configuration Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + + byte volume = span[0]; + byte defaultToneIdentifier = span[1]; + + return new SoundSwitchConfigurationReport(volume, defaultToneIdentifier); + } + } +} diff --git a/src/ZWave.CommandClasses/SoundSwitchCommandClass.ToneInfo.cs b/src/ZWave.CommandClasses/SoundSwitchCommandClass.ToneInfo.cs new file mode 100644 index 0000000..bda673b --- /dev/null +++ b/src/ZWave.CommandClasses/SoundSwitchCommandClass.ToneInfo.cs @@ -0,0 +1,115 @@ +using System.Text; +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents the information associated with a tone on a Sound Switch device. +/// +public readonly record struct SoundSwitchToneInfo( + /// + /// The tone identifier (1-based). + /// + byte ToneIdentifier, + + /// + /// The duration in seconds it takes to play this tone. + /// + ushort DurationSeconds, + + /// + /// The name or label assigned to this tone, encoded in UTF-8. + /// + string Name); + +public sealed partial class SoundSwitchCommandClass +{ + private readonly Dictionary _toneInfos = []; + + /// + /// Gets the information for each tone, keyed by tone identifier. + /// + public IReadOnlyDictionary ToneInfos => _toneInfos; + + /// + /// Request the information associated with a specific tone. + /// + public async Task GetToneInfoAsync(byte toneIdentifier, CancellationToken cancellationToken) + { + SoundSwitchToneInfoGetCommand command = SoundSwitchToneInfoGetCommand.Create(toneIdentifier); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync( + predicate: frame => frame.CommandParameters.Length > 0 + && frame.CommandParameters.Span[0] == toneIdentifier, + cancellationToken).ConfigureAwait(false); + SoundSwitchToneInfo toneInfo = SoundSwitchToneInfoReportCommand.Parse(reportFrame, Logger); + _toneInfos[toneInfo.ToneIdentifier] = toneInfo; + return toneInfo; + } + + internal readonly struct SoundSwitchToneInfoGetCommand : ICommand + { + public SoundSwitchToneInfoGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.SoundSwitch; + + public static byte CommandId => (byte)SoundSwitchCommand.ToneInfoGet; + + public CommandClassFrame Frame { get; } + + public static SoundSwitchToneInfoGetCommand Create(byte toneIdentifier) + { + ReadOnlySpan commandParameters = [toneIdentifier]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new SoundSwitchToneInfoGetCommand(frame); + } + } + + internal readonly struct SoundSwitchToneInfoReportCommand : ICommand + { + public SoundSwitchToneInfoReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.SoundSwitch; + + public static byte CommandId => (byte)SoundSwitchCommand.ToneInfoReport; + + public CommandClassFrame Frame { get; } + + public static SoundSwitchToneInfo Parse(CommandClassFrame frame, ILogger logger) + { + // Minimum: Tone Identifier (1) + Duration (2) + Name Length (1) = 4 bytes + if (frame.CommandParameters.Length < 4) + { + logger.LogWarning("Sound Switch Tone Info Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Sound Switch Tone Info Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + + byte toneIdentifier = span[0]; + ushort durationSeconds = span.Slice(1, 2).ToUInt16BE(); + byte nameLength = span[3]; + + if (frame.CommandParameters.Length < 4 + nameLength) + { + logger.LogWarning( + "Sound Switch Tone Info Report frame is too short for declared name length ({NameLength} bytes, but only {Available} available)", + nameLength, + frame.CommandParameters.Length - 4); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Sound Switch Tone Info Report frame is too short for declared name length"); + } + + string name = nameLength > 0 + ? Encoding.UTF8.GetString(span.Slice(4, nameLength)) + : string.Empty; + + return new SoundSwitchToneInfo(toneIdentifier, durationSeconds, name); + } + } +} diff --git a/src/ZWave.CommandClasses/SoundSwitchCommandClass.TonePlay.cs b/src/ZWave.CommandClasses/SoundSwitchCommandClass.TonePlay.cs new file mode 100644 index 0000000..dd28594 --- /dev/null +++ b/src/ZWave.CommandClasses/SoundSwitchCommandClass.TonePlay.cs @@ -0,0 +1,159 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents the current tone play state of a Sound Switch device. +/// +public readonly record struct SoundSwitchTonePlayReport( + /// + /// The tone identifier currently being played, or 0 if no tone is playing. + /// + byte ToneIdentifier, + + /// + /// The actual playing volume (0-100%). Added in version 2. + /// This field is if the sending node uses version 1. + /// + byte? Volume); + +public sealed partial class SoundSwitchCommandClass +{ + /// + /// Gets the last tone play report received from the device. + /// + public SoundSwitchTonePlayReport? LastTonePlayReport { get; private set; } + + /// + /// Event raised when a Tone Play Report is received, both solicited and unsolicited. + /// + public event Action? OnTonePlayReportReceived; + + /// + /// Request the current tone being played by the device. + /// + public async Task GetTonePlayAsync(CancellationToken cancellationToken) + { + SoundSwitchTonePlayGetCommand command = SoundSwitchTonePlayGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + SoundSwitchTonePlayReport report = SoundSwitchTonePlayReportCommand.Parse(reportFrame, Logger); + LastTonePlayReport = report; + OnTonePlayReportReceived?.Invoke(report); + return report; + } + + /// + /// Instruct the device to play or stop playing a tone. + /// + /// + /// The tone to play: 0x00 = stop playing, 0x01-0xFE = play specified tone (unsupported values play default tone), + /// 0xFF = play default tone. + /// + /// + /// The volume for this play command (version 2 only): 0x00 = use configured volume, 1-100 = volume percentage, + /// 0xFF = use most recent non-zero volume if muted, otherwise use configured volume. + /// Pass to omit (version 1 behavior). + /// + /// The cancellation token. + public async Task PlayAsync(byte toneIdentifier, byte? volume, CancellationToken cancellationToken) + { + var command = SoundSwitchTonePlaySetCommand.Create(EffectiveVersion, toneIdentifier, volume); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// + /// Instruct the device to stop playing the current tone. + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + var command = SoundSwitchTonePlaySetCommand.Create(EffectiveVersion, 0x00, null); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + internal readonly struct SoundSwitchTonePlaySetCommand : ICommand + { + public SoundSwitchTonePlaySetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.SoundSwitch; + + public static byte CommandId => (byte)SoundSwitchCommand.TonePlaySet; + + public CommandClassFrame Frame { get; } + + public static SoundSwitchTonePlaySetCommand Create(byte version, byte toneIdentifier, byte? volume) + { + bool includeVolume = version >= 2; + Span commandParameters = stackalloc byte[1 + (includeVolume ? 1 : 0)]; + commandParameters[0] = toneIdentifier; + + if (includeVolume) + { + // Per spec CC:0079.02.08.11.007: volume MUST be 0x00 if tone identifier is 0x00 + commandParameters[1] = toneIdentifier == 0x00 + ? (byte)0x00 + : volume.GetValueOrDefault(0x00); + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new SoundSwitchTonePlaySetCommand(frame); + } + } + + internal readonly struct SoundSwitchTonePlayGetCommand : ICommand + { + public SoundSwitchTonePlayGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.SoundSwitch; + + public static byte CommandId => (byte)SoundSwitchCommand.TonePlayGet; + + public CommandClassFrame Frame { get; } + + public static SoundSwitchTonePlayGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new SoundSwitchTonePlayGetCommand(frame); + } + } + + internal readonly struct SoundSwitchTonePlayReportCommand : ICommand + { + public SoundSwitchTonePlayReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.SoundSwitch; + + public static byte CommandId => (byte)SoundSwitchCommand.TonePlayReport; + + public CommandClassFrame Frame { get; } + + public static SoundSwitchTonePlayReport Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning("Sound Switch Tone Play Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Sound Switch Tone Play Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + + byte toneIdentifier = span[0]; + + // V2 adds Play Command Tone Volume field - check payload length, not version + byte? volume = span.Length > 1 + ? span[1] + : null; + + return new SoundSwitchTonePlayReport(toneIdentifier, volume); + } + } +} diff --git a/src/ZWave.CommandClasses/SoundSwitchCommandClass.TonesNumber.cs b/src/ZWave.CommandClasses/SoundSwitchCommandClass.TonesNumber.cs new file mode 100644 index 0000000..adb114d --- /dev/null +++ b/src/ZWave.CommandClasses/SoundSwitchCommandClass.TonesNumber.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class SoundSwitchCommandClass +{ + /// + /// Gets the number of tones supported by the device. + /// + public byte? SupportedTonesCount { get; private set; } + + /// + /// Request the number of tones supported by the device. + /// + public async Task GetTonesNumberAsync(CancellationToken cancellationToken) + { + SoundSwitchTonesNumberGetCommand command = SoundSwitchTonesNumberGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + byte tonesCount = SoundSwitchTonesNumberReportCommand.Parse(reportFrame, Logger); + SupportedTonesCount = tonesCount; + return tonesCount; + } + + internal readonly struct SoundSwitchTonesNumberGetCommand : ICommand + { + public SoundSwitchTonesNumberGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.SoundSwitch; + + public static byte CommandId => (byte)SoundSwitchCommand.TonesNumberGet; + + public CommandClassFrame Frame { get; } + + public static SoundSwitchTonesNumberGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new SoundSwitchTonesNumberGetCommand(frame); + } + } + + internal readonly struct SoundSwitchTonesNumberReportCommand : ICommand + { + public SoundSwitchTonesNumberReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.SoundSwitch; + + public static byte CommandId => (byte)SoundSwitchCommand.TonesNumberReport; + + public CommandClassFrame Frame { get; } + + public static byte Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning("Sound Switch Tones Number Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Sound Switch Tones Number Report frame is too short"); + } + + return frame.CommandParameters.Span[0]; + } + } +} diff --git a/src/ZWave.CommandClasses/SoundSwitchCommandClass.cs b/src/ZWave.CommandClasses/SoundSwitchCommandClass.cs new file mode 100644 index 0000000..a34a4f1 --- /dev/null +++ b/src/ZWave.CommandClasses/SoundSwitchCommandClass.cs @@ -0,0 +1,128 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Defines the commands for the Sound Switch Command Class. +/// +public enum SoundSwitchCommand : byte +{ + /// + /// Request the number of tones supported by the device. + /// + TonesNumberGet = 0x01, + + /// + /// Report the number of tones supported by the device. + /// + TonesNumberReport = 0x02, + + /// + /// Request the information associated with a specific tone. + /// + ToneInfoGet = 0x03, + + /// + /// Report the information associated with a specific tone. + /// + ToneInfoReport = 0x04, + + /// + /// Set the volume and default tone configuration. + /// + ConfigurationSet = 0x05, + + /// + /// Request the current configuration for playing tones. + /// + ConfigurationGet = 0x06, + + /// + /// Report the current configuration for playing tones. + /// + ConfigurationReport = 0x07, + + /// + /// Instruct the device to play or stop playing a tone. + /// + TonePlaySet = 0x08, + + /// + /// Request the current tone being played by the device. + /// + TonePlayGet = 0x09, + + /// + /// Report the current tone being played by the device. + /// + TonePlayReport = 0x0A, +} + +/// +/// Controls devices with speaker or sound notification capability such as doorbells, alarm clocks, sirens, +/// or any device issuing sound notifications. +/// +[CommandClass(CommandClassId.SoundSwitch)] +public sealed partial class SoundSwitchCommandClass : CommandClass +{ + internal SoundSwitchCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + public override bool? IsCommandSupported(SoundSwitchCommand command) + => command switch + { + SoundSwitchCommand.TonesNumberGet => true, + SoundSwitchCommand.ToneInfoGet => true, + SoundSwitchCommand.ConfigurationSet => true, + SoundSwitchCommand.ConfigurationGet => true, + SoundSwitchCommand.TonePlaySet => true, + SoundSwitchCommand.TonePlayGet => true, + _ => false, + }; + + internal override async Task InterviewAsync(CancellationToken cancellationToken) + { + // Step 1: Get number of supported tones + byte tonesCount = await GetTonesNumberAsync(cancellationToken).ConfigureAwait(false); + + // Step 2: Get info for each tone + for (byte toneId = 1; toneId <= tonesCount; toneId++) + { + _ = await GetToneInfoAsync(toneId, cancellationToken).ConfigureAwait(false); + } + + // Step 3: Get current configuration (volume + default tone) + _ = await GetConfigurationAsync(cancellationToken).ConfigureAwait(false); + + // Step 4: Get current playing state + _ = await GetTonePlayAsync(cancellationToken).ConfigureAwait(false); + } + + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((SoundSwitchCommand)frame.CommandId) + { + case SoundSwitchCommand.ConfigurationReport: + { + SoundSwitchConfigurationReport report = SoundSwitchConfigurationReportCommand.Parse(frame, Logger); + LastConfigurationReport = report; + OnConfigurationReportReceived?.Invoke(report); + break; + } + case SoundSwitchCommand.TonePlayReport: + { + SoundSwitchTonePlayReport report = SoundSwitchTonePlayReportCommand.Parse(frame, Logger); + LastTonePlayReport = report; + OnTonePlayReportReceived?.Invoke(report); + break; + } + } + } +}