From f26a82222c86caba7c3fa68bef1d4962aee7f654 Mon Sep 17 00:00:00 2001 From: David Federman Date: Sat, 7 Mar 2026 20:49:30 -0800 Subject: [PATCH] Implement 4 Thermostat CCs --- .github/skills/zwave-implement-cc/SKILL.md | 22 +- .../ThermostatFanModeCommandClassTests.cs | 273 +++++++++++ .../ThermostatFanStateCommandClassTests.cs | 122 +++++ ...ermostatOperatingStateCommandClassTests.cs | 402 ++++++++++++++++ .../ThermostatSetbackCommandClassTests.cs | 264 +++++++++++ .../ThermostatFanModeCommandClass.cs | 353 ++++++++++++++ .../ThermostatFanStateCommandClass.cs | 193 ++++++++ .../ThermostatOperatingStateCommandClass.cs | 435 ++++++++++++++++++ .../ThermostatSetbackCommandClass.cs | 269 +++++++++++ 9 files changed, 2329 insertions(+), 4 deletions(-) create mode 100644 src/ZWave.CommandClasses.Tests/ThermostatFanModeCommandClassTests.cs create mode 100644 src/ZWave.CommandClasses.Tests/ThermostatFanStateCommandClassTests.cs create mode 100644 src/ZWave.CommandClasses.Tests/ThermostatOperatingStateCommandClassTests.cs create mode 100644 src/ZWave.CommandClasses.Tests/ThermostatSetbackCommandClassTests.cs create mode 100644 src/ZWave.CommandClasses/ThermostatFanModeCommandClass.cs create mode 100644 src/ZWave.CommandClasses/ThermostatFanStateCommandClass.cs create mode 100644 src/ZWave.CommandClasses/ThermostatOperatingStateCommandClass.cs create mode 100644 src/ZWave.CommandClasses/ThermostatSetbackCommandClass.cs diff --git a/.github/skills/zwave-implement-cc/SKILL.md b/.github/skills/zwave-implement-cc/SKILL.md index 20576a9..0c3ba80 100644 --- a/.github/skills/zwave-implement-cc/SKILL.md +++ b/.github/skills/zwave-implement-cc/SKILL.md @@ -197,20 +197,34 @@ The inner command struct names should still match the spec ordering for traceabi Some commands return results across multiple report frames (indicated by a "Reports to Follow" field). These **must be aggregated** so the public API returns a single complete result. The caller should not need to know about the multi-frame nature of the response. +The report command struct should expose a `ParseInto` method that takes the collection to append to (avoiding intermediate list allocations) and returns only the metadata (e.g. `reportsToFollow`). See `AssociationReportCommand.ParseInto` for the reference implementation. + ```csharp +// In the report command struct: +public static byte ParseInto( + CommandClassFrame frame, + List<{Item}> items, + ILogger logger) +{ + // validate frame... + byte reportsToFollow = span[0]; + // parse items and add to the provided list... + items.Add(...); + return reportsToFollow; +} + +// In the CC class: public async Task> GetAllItemsAsync(CancellationToken cancellationToken) { var command = {Name}GetCommand.Create(); await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); - List<{Item}> allItems = new List<{Item}>(); + List<{Item}> allItems = []; byte reportsToFollow; do { CommandClassFrame reportFrame = await AwaitNextReportAsync<{Name}ReportCommand>(cancellationToken).ConfigureAwait(false); - {Name}Report report = {Name}ReportCommand.Parse(reportFrame, Logger); - allItems.AddRange(report.Items); - reportsToFollow = report.ReportsToFollow; + reportsToFollow = {Name}ReportCommand.ParseInto(reportFrame, allItems, Logger); } while (reportsToFollow > 0); diff --git a/src/ZWave.CommandClasses.Tests/ThermostatFanModeCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/ThermostatFanModeCommandClassTests.cs new file mode 100644 index 0000000..f108f89 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/ThermostatFanModeCommandClassTests.cs @@ -0,0 +1,273 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public class ThermostatFanModeCommandClassTests +{ + [TestMethod] + public void SetCommand_Create_AutoLow_NotOff() + { + ThermostatFanModeCommandClass.ThermostatFanModeSetCommand command = + ThermostatFanModeCommandClass.ThermostatFanModeSetCommand.Create(2, ThermostatFanMode.AutoLow, off: false); + + Assert.AreEqual(CommandClassId.ThermostatFanMode, ThermostatFanModeCommandClass.ThermostatFanModeSetCommand.CommandClassId); + Assert.AreEqual((byte)ThermostatFanModeCommand.Set, ThermostatFanModeCommandClass.ThermostatFanModeSetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); + // Bit 7 = Off (0), bits 3-0 = 0x00 (AutoLow) + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void SetCommand_Create_Low_NotOff() + { + ThermostatFanModeCommandClass.ThermostatFanModeSetCommand command = + ThermostatFanModeCommandClass.ThermostatFanModeSetCommand.Create(2, ThermostatFanMode.Low, off: false); + + Assert.AreEqual(3, command.Frame.Data.Length); + // Bit 7 = Off (0), bits 3-0 = 0x01 (Low) + Assert.AreEqual(0x01, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void SetCommand_Create_High_WithOff() + { + ThermostatFanModeCommandClass.ThermostatFanModeSetCommand command = + ThermostatFanModeCommandClass.ThermostatFanModeSetCommand.Create(2, ThermostatFanMode.High, off: true); + + Assert.AreEqual(3, command.Frame.Data.Length); + // Bit 7 = Off (1), bits 3-0 = 0x03 (High) → 0x83 + Assert.AreEqual(0x83, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void SetCommand_Create_ExternalCirculation_NotOff() + { + ThermostatFanModeCommandClass.ThermostatFanModeSetCommand command = + ThermostatFanModeCommandClass.ThermostatFanModeSetCommand.Create(5, ThermostatFanMode.ExternalCirculation, off: false); + + Assert.AreEqual(3, command.Frame.Data.Length); + // Bit 7 = Off (0), bits 3-0 = 0x0B (ExternalCirculation) + Assert.AreEqual(0x0B, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void SetCommand_Create_Version1_OffIgnored() + { + // V1 does not have the Off bit; reserved bits MUST be 0 + ThermostatFanModeCommandClass.ThermostatFanModeSetCommand command = + ThermostatFanModeCommandClass.ThermostatFanModeSetCommand.Create(1, ThermostatFanMode.High, off: true); + + Assert.AreEqual(3, command.Frame.Data.Length); + // Off is ignored for V1, so bit 7 = 0, bits 3-0 = 0x03 (High) + Assert.AreEqual(0x03, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void GetCommand_Create_HasCorrectFormat() + { + ThermostatFanModeCommandClass.ThermostatFanModeGetCommand command = + ThermostatFanModeCommandClass.ThermostatFanModeGetCommand.Create(); + + Assert.AreEqual(CommandClassId.ThermostatFanMode, ThermostatFanModeCommandClass.ThermostatFanModeGetCommand.CommandClassId); + Assert.AreEqual((byte)ThermostatFanModeCommand.Get, ThermostatFanModeCommandClass.ThermostatFanModeGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void Report_Parse_AutoLow_NotOff() + { + // CC=0x44, Cmd=0x03, Value=0x00 (Off=0, Mode=AutoLow) + byte[] data = [0x44, 0x03, 0x00]; + CommandClassFrame frame = new(data); + + ThermostatFanModeReport report = + ThermostatFanModeCommandClass.ThermostatFanModeReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatFanMode.AutoLow, report.FanMode); + Assert.IsFalse(report.Off); + } + + [TestMethod] + public void Report_Parse_Low_NotOff() + { + // CC=0x44, Cmd=0x03, Value=0x01 (Off=0, Mode=Low) + byte[] data = [0x44, 0x03, 0x01]; + CommandClassFrame frame = new(data); + + ThermostatFanModeReport report = + ThermostatFanModeCommandClass.ThermostatFanModeReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatFanMode.Low, report.FanMode); + Assert.IsFalse(report.Off); + } + + [TestMethod] + public void Report_Parse_High_WithOff() + { + // CC=0x44, Cmd=0x03, Value=0x83 (Off=1, Mode=High) + byte[] data = [0x44, 0x03, 0x83]; + CommandClassFrame frame = new(data); + + ThermostatFanModeReport report = + ThermostatFanModeCommandClass.ThermostatFanModeReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatFanMode.High, report.FanMode); + Assert.IsTrue(report.Off); + } + + [TestMethod] + public void Report_Parse_Circulation_NotOff() + { + // CC=0x44, Cmd=0x03, Value=0x06 (Off=0, Mode=Circulation) + byte[] data = [0x44, 0x03, 0x06]; + CommandClassFrame frame = new(data); + + ThermostatFanModeReport report = + ThermostatFanModeCommandClass.ThermostatFanModeReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatFanMode.Circulation, report.FanMode); + Assert.IsFalse(report.Off); + } + + [TestMethod] + public void Report_Parse_ExternalCirculation_WithOff() + { + // CC=0x44, Cmd=0x03, Value=0x8B (Off=1, Mode=ExternalCirculation=0x0B) + byte[] data = [0x44, 0x03, 0x8B]; + CommandClassFrame frame = new(data); + + ThermostatFanModeReport report = + ThermostatFanModeCommandClass.ThermostatFanModeReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatFanMode.ExternalCirculation, report.FanMode); + Assert.IsTrue(report.Off); + } + + [TestMethod] + public void Report_Parse_ReservedMode_Preserved() + { + // CC=0x44, Cmd=0x03, Value=0x0E (Off=0, Mode=0x0E reserved) + byte[] data = [0x44, 0x03, 0x0E]; + CommandClassFrame frame = new(data); + + ThermostatFanModeReport report = + ThermostatFanModeCommandClass.ThermostatFanModeReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ThermostatFanMode)0x0E, report.FanMode); + Assert.IsFalse(report.Off); + } + + [TestMethod] + public void Report_Parse_TooShort_Throws() + { + // CC=0x44, Cmd=0x03, no parameters + byte[] data = [0x44, 0x03]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => ThermostatFanModeCommandClass.ThermostatFanModeReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void SupportedGetCommand_Create_HasCorrectFormat() + { + ThermostatFanModeCommandClass.ThermostatFanModeSupportedGetCommand command = + ThermostatFanModeCommandClass.ThermostatFanModeSupportedGetCommand.Create(); + + Assert.AreEqual(CommandClassId.ThermostatFanMode, ThermostatFanModeCommandClass.ThermostatFanModeSupportedGetCommand.CommandClassId); + Assert.AreEqual((byte)ThermostatFanModeCommand.SupportedGet, ThermostatFanModeCommandClass.ThermostatFanModeSupportedGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void SupportedReport_Parse_AutoLowAutoHighAutoMedium() + { + // Per spec example: bits 0, 2, 4 set = AutoLow(0), AutoHigh(2), AutoMedium(4) + // CC=0x44, Cmd=0x05, BitMask=0b0001_0101 = 0x15 + byte[] data = [0x44, 0x05, 0x15]; + CommandClassFrame frame = new(data); + + HashSet supported = + ThermostatFanModeCommandClass.ThermostatFanModeSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(3, supported); + Assert.Contains(ThermostatFanMode.AutoLow, supported); + Assert.Contains(ThermostatFanMode.AutoHigh, supported); + Assert.Contains(ThermostatFanMode.AutoMedium, supported); + } + + [TestMethod] + public void SupportedReport_Parse_LowAndHigh() + { + // Bits 1 and 3 set = Low(1) and High(3) + // CC=0x44, Cmd=0x05, BitMask=0b0000_1010 = 0x0A + byte[] data = [0x44, 0x05, 0x0A]; + CommandClassFrame frame = new(data); + + HashSet supported = + ThermostatFanModeCommandClass.ThermostatFanModeSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(2, supported); + Assert.Contains(ThermostatFanMode.Low, supported); + Assert.Contains(ThermostatFanMode.High, supported); + } + + [TestMethod] + public void SupportedReport_Parse_TwoBytes_IncludesHighModes() + { + // BitMask1: 0b0000_0001 (bit 0 = AutoLow) + // BitMask2: 0b0000_0111 (bits 8, 9, 10 = LeftRight, UpDown, Quiet) + byte[] data = [0x44, 0x05, 0x01, 0x07]; + CommandClassFrame frame = new(data); + + HashSet supported = + ThermostatFanModeCommandClass.ThermostatFanModeSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(4, supported); + Assert.Contains(ThermostatFanMode.AutoLow, supported); + Assert.Contains(ThermostatFanMode.LeftRight, supported); + Assert.Contains(ThermostatFanMode.UpDown, supported); + Assert.Contains(ThermostatFanMode.Quiet, supported); + } + + [TestMethod] + public void SupportedReport_Parse_ExternalCirculation() + { + // BitMask1: 0x00, BitMask2: 0b0000_1000 (bit 11 = ExternalCirculation) + byte[] data = [0x44, 0x05, 0x00, 0x08]; + CommandClassFrame frame = new(data); + + HashSet supported = + ThermostatFanModeCommandClass.ThermostatFanModeSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(1, supported); + Assert.Contains(ThermostatFanMode.ExternalCirculation, supported); + } + + [TestMethod] + public void SupportedReport_Parse_EmptyBitmask_ReturnsEmpty() + { + // CC=0x44, Cmd=0x05, no bitmask bytes + byte[] data = [0x44, 0x05]; + CommandClassFrame frame = new(data); + + HashSet supported = + ThermostatFanModeCommandClass.ThermostatFanModeSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsEmpty(supported); + } + + [TestMethod] + public void SupportedReport_Parse_AllZeros_ReturnsEmpty() + { + // CC=0x44, Cmd=0x05, BitMask=0x00 + byte[] data = [0x44, 0x05, 0x00]; + CommandClassFrame frame = new(data); + + HashSet supported = + ThermostatFanModeCommandClass.ThermostatFanModeSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsEmpty(supported); + } +} diff --git a/src/ZWave.CommandClasses.Tests/ThermostatFanStateCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/ThermostatFanStateCommandClassTests.cs new file mode 100644 index 0000000..17e5eae --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/ThermostatFanStateCommandClassTests.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public class ThermostatFanStateCommandClassTests +{ + [TestMethod] + public void GetCommand_Create_HasCorrectFormat() + { + ThermostatFanStateCommandClass.ThermostatFanStateGetCommand command = + ThermostatFanStateCommandClass.ThermostatFanStateGetCommand.Create(); + + Assert.AreEqual(CommandClassId.ThermostatFanState, ThermostatFanStateCommandClass.ThermostatFanStateGetCommand.CommandClassId); + Assert.AreEqual((byte)ThermostatFanStateCommand.Get, ThermostatFanStateCommandClass.ThermostatFanStateGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void Report_Parse_Idle() + { + // CC=0x45, Cmd=0x03, State=0x00 (Idle) + byte[] data = [0x45, 0x03, 0x00]; + CommandClassFrame frame = new(data); + + ThermostatFanStateReport report = + ThermostatFanStateCommandClass.ThermostatFanStateReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatFanOperatingState.Idle, report.FanOperatingState); + } + + [TestMethod] + public void Report_Parse_RunningLow() + { + // CC=0x45, Cmd=0x03, State=0x01 (Running Low) + byte[] data = [0x45, 0x03, 0x01]; + CommandClassFrame frame = new(data); + + ThermostatFanStateReport report = + ThermostatFanStateCommandClass.ThermostatFanStateReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatFanOperatingState.RunningLow, report.FanOperatingState); + } + + [TestMethod] + public void Report_Parse_RunningHigh() + { + // CC=0x45, Cmd=0x03, State=0x02 (Running High) + byte[] data = [0x45, 0x03, 0x02]; + CommandClassFrame frame = new(data); + + ThermostatFanStateReport report = + ThermostatFanStateCommandClass.ThermostatFanStateReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatFanOperatingState.RunningHigh, report.FanOperatingState); + } + + [TestMethod] + public void Report_Parse_RunningMedium_V2() + { + // CC=0x45, Cmd=0x03, State=0x03 (Running Medium, added in V2) + byte[] data = [0x45, 0x03, 0x03]; + CommandClassFrame frame = new(data); + + ThermostatFanStateReport report = + ThermostatFanStateCommandClass.ThermostatFanStateReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatFanOperatingState.RunningMedium, report.FanOperatingState); + } + + [TestMethod] + public void Report_Parse_QuietCirculationMode_V2() + { + // CC=0x45, Cmd=0x03, State=0x08 (Quiet Circulation Mode, added in V2) + byte[] data = [0x45, 0x03, 0x08]; + CommandClassFrame frame = new(data); + + ThermostatFanStateReport report = + ThermostatFanStateCommandClass.ThermostatFanStateReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatFanOperatingState.QuietCirculationMode, report.FanOperatingState); + } + + [TestMethod] + public void Report_Parse_ReservedValue_Preserved() + { + // A reserved/unknown value should be preserved (forward compatibility) + // CC=0x45, Cmd=0x03, State=0x0F (reserved) + byte[] data = [0x45, 0x03, 0x0F]; + CommandClassFrame frame = new(data); + + ThermostatFanStateReport report = + ThermostatFanStateCommandClass.ThermostatFanStateReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ThermostatFanOperatingState)0x0F, report.FanOperatingState); + } + + [TestMethod] + public void Report_Parse_FullByteValue_ForwardCompatible() + { + // V1 defines the state as 4 bits, but we parse the full byte for forward compatibility. + // CC=0x45, Cmd=0x03, State=0x10 (upper nibble set) + byte[] data = [0x45, 0x03, 0x10]; + CommandClassFrame frame = new(data); + + ThermostatFanStateReport report = + ThermostatFanStateCommandClass.ThermostatFanStateReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ThermostatFanOperatingState)0x10, report.FanOperatingState); + } + + [TestMethod] + public void Report_Parse_TooShort_Throws() + { + // CC=0x45, Cmd=0x03, no parameters + byte[] data = [0x45, 0x03]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => ThermostatFanStateCommandClass.ThermostatFanStateReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/ThermostatOperatingStateCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/ThermostatOperatingStateCommandClassTests.cs new file mode 100644 index 0000000..224b8b8 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/ThermostatOperatingStateCommandClassTests.cs @@ -0,0 +1,402 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public class ThermostatOperatingStateCommandClassTests +{ + [TestMethod] + public void GetCommand_Create_HasCorrectFormat() + { + ThermostatOperatingStateCommandClass.ThermostatOperatingStateGetCommand command = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateGetCommand.Create(); + + Assert.AreEqual(CommandClassId.ThermostatOperatingState, ThermostatOperatingStateCommandClass.ThermostatOperatingStateGetCommand.CommandClassId); + Assert.AreEqual((byte)ThermostatOperatingStateCommand.Get, ThermostatOperatingStateCommandClass.ThermostatOperatingStateGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void Report_Parse_Idle() + { + // CC=0x42, Cmd=0x03, State=0x00 (Idle) + byte[] data = [0x42, 0x03, 0x00]; + CommandClassFrame frame = new(data); + + ThermostatOperatingStateReport report = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatOperatingState.Idle, report.OperatingState); + } + + [TestMethod] + public void Report_Parse_Heating() + { + // CC=0x42, Cmd=0x03, State=0x01 (Heating) + byte[] data = [0x42, 0x03, 0x01]; + CommandClassFrame frame = new(data); + + ThermostatOperatingStateReport report = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatOperatingState.Heating, report.OperatingState); + } + + [TestMethod] + public void Report_Parse_Cooling() + { + // CC=0x42, Cmd=0x03, State=0x02 (Cooling) + byte[] data = [0x42, 0x03, 0x02]; + CommandClassFrame frame = new(data); + + ThermostatOperatingStateReport report = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatOperatingState.Cooling, report.OperatingState); + } + + [TestMethod] + public void Report_Parse_VentEconomizer() + { + // CC=0x42, Cmd=0x03, State=0x06 (Vent/Economizer) + byte[] data = [0x42, 0x03, 0x06]; + CommandClassFrame frame = new(data); + + ThermostatOperatingStateReport report = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatOperatingState.VentEconomizer, report.OperatingState); + } + + [TestMethod] + public void Report_Parse_AuxHeating_V2() + { + // CC=0x42, Cmd=0x03, State=0x07 (Aux Heating, added in V2) + byte[] data = [0x42, 0x03, 0x07]; + CommandClassFrame frame = new(data); + + ThermostatOperatingStateReport report = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatOperatingState.AuxHeating, report.OperatingState); + } + + [TestMethod] + public void Report_Parse_ThirdStageAuxHeat_V2() + { + // CC=0x42, Cmd=0x03, State=0x0B (3rd Stage Aux Heat, added in V2) + byte[] data = [0x42, 0x03, 0x0B]; + CommandClassFrame frame = new(data); + + ThermostatOperatingStateReport report = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatOperatingState.ThirdStageAuxHeat, report.OperatingState); + } + + [TestMethod] + public void Report_Parse_ReservedValue_Preserved() + { + // Forward compatibility: reserved/unknown values should be preserved + // CC=0x42, Cmd=0x03, State=0x0F (reserved) + byte[] data = [0x42, 0x03, 0x0F]; + CommandClassFrame frame = new(data); + + ThermostatOperatingStateReport report = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ThermostatOperatingState)0x0F, report.OperatingState); + } + + [TestMethod] + public void Report_Parse_TooShort_Throws() + { + // CC=0x42, Cmd=0x03, no parameters + byte[] data = [0x42, 0x03]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => ThermostatOperatingStateCommandClass.ThermostatOperatingStateReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void LoggingSupportedGetCommand_Create_HasCorrectFormat() + { + ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingSupportedGetCommand command = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingSupportedGetCommand.Create(); + + Assert.AreEqual(CommandClassId.ThermostatOperatingState, ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingSupportedGetCommand.CommandClassId); + Assert.AreEqual((byte)ThermostatOperatingStateCommand.LoggingSupportedGet, ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingSupportedGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void LoggingSupportedReport_Parse_HeatingAndCooling() + { + // CC=0x42, Cmd=0x04, BitMask=0b0000_0110 (bits 1 and 2 = Heating and Cooling) + // Per spec: bit 0 is NOT allocated and must be zero + byte[] data = [0x42, 0x04, 0x06]; + CommandClassFrame frame = new(data); + + HashSet supported = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(2, supported); + Assert.Contains(ThermostatOperatingState.Heating, supported); + Assert.Contains(ThermostatOperatingState.Cooling, supported); + } + + [TestMethod] + public void LoggingSupportedReport_Parse_MultipleV2States() + { + // BitMask1: 0b0000_0010 (bit 1 = Heating) + // BitMask2: 0b0000_0001 (bit 8 = SecondStageHeating) + byte[] data = [0x42, 0x04, 0x02, 0x01]; + CommandClassFrame frame = new(data); + + HashSet supported = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(2, supported); + Assert.Contains(ThermostatOperatingState.Heating, supported); + Assert.Contains(ThermostatOperatingState.SecondStageHeating, supported); + } + + [TestMethod] + public void LoggingSupportedReport_Parse_Bit0NotAllocated_Skipped() + { + // Per spec: bit 0 is not allocated. Even if set, startBit:1 causes it to be skipped. + // CC=0x42, Cmd=0x04, BitMask=0b0000_0011 (bits 0 and 1) + byte[] data = [0x42, 0x04, 0x03]; + CommandClassFrame frame = new(data); + + HashSet supported = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingSupportedReportCommand.Parse(frame, NullLogger.Instance); + + // Only Heating (bit 1) should be included; bit 0 is skipped + Assert.HasCount(1, supported); + Assert.Contains(ThermostatOperatingState.Heating, supported); + } + + [TestMethod] + public void LoggingSupportedReport_Parse_EmptyBitmask_ReturnsEmpty() + { + // CC=0x42, Cmd=0x04, no bitmask bytes + byte[] data = [0x42, 0x04]; + CommandClassFrame frame = new(data); + + HashSet supported = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.IsEmpty(supported); + } + + [TestMethod] + public void LoggingGetCommand_Create_HeatingAndCooling() + { + HashSet requestedStates = + [ + ThermostatOperatingState.Heating, + ThermostatOperatingState.Cooling, + ]; + + ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingGetCommand command = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingGetCommand.Create(requestedStates); + + Assert.AreEqual(CommandClassId.ThermostatOperatingState, ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingGetCommand.CommandClassId); + Assert.AreEqual((byte)ThermostatOperatingStateCommand.LoggingGet, ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingGetCommand.CommandId); + + // Heating=1 (bit 1), Cooling=2 (bit 2) → 0b0000_0110 = 0x06 + Assert.AreEqual(1, command.Frame.CommandParameters.Length); + Assert.AreEqual(0x06, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void LoggingGetCommand_Create_SecondStageHeating() + { + HashSet requestedStates = + [ + ThermostatOperatingState.SecondStageHeating, + ]; + + ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingGetCommand command = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingGetCommand.Create(requestedStates); + + // SecondStageHeating=8 (bit 8) → byte 0: 0x00, byte 1: 0x01 + Assert.AreEqual(2, command.Frame.CommandParameters.Length); + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual(0x01, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void LoggingReport_ParseInto_SingleEntry() + { + // CC=0x42, Cmd=0x06 + // ReportsToFollow=0x00 + // Entry 1: Reserved(4bits)+LogType=0x01 (Heating), TodayH=2, TodayM=30, YestH=5, YestM=45 + byte[] data = [0x42, 0x06, 0x00, 0x01, 0x02, 0x1E, 0x05, 0x2D]; + CommandClassFrame frame = new(data); + + List entries = []; + byte reportsToFollow = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingReportCommand.ParseInto(frame, entries, NullLogger.Instance); + + Assert.AreEqual(0, reportsToFollow); + Assert.HasCount(1, entries); + Assert.AreEqual(ThermostatOperatingState.Heating, entries[0].OperatingState); + Assert.AreEqual(new TimeSpan(2, 30, 0), entries[0].UsageToday); + Assert.AreEqual(new TimeSpan(5, 45, 0), entries[0].UsageYesterday); + } + + [TestMethod] + public void LoggingReport_ParseInto_MultipleEntries() + { + // CC=0x42, Cmd=0x06 + // ReportsToFollow=0x00 + // Entry 1: LogType=0x01 (Heating), TodayH=1, TodayM=15, YestH=3, YestM=30 + // Entry 2: LogType=0x02 (Cooling), TodayH=0, TodayM=45, YestH=2, YestM=0 + byte[] data = [0x42, 0x06, 0x00, 0x01, 0x01, 0x0F, 0x03, 0x1E, 0x02, 0x00, 0x2D, 0x02, 0x00]; + CommandClassFrame frame = new(data); + + List entries = []; + byte reportsToFollow = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingReportCommand.ParseInto(frame, entries, NullLogger.Instance); + + Assert.AreEqual(0, reportsToFollow); + Assert.HasCount(2, entries); + + Assert.AreEqual(ThermostatOperatingState.Heating, entries[0].OperatingState); + Assert.AreEqual(new TimeSpan(1, 15, 0), entries[0].UsageToday); + Assert.AreEqual(new TimeSpan(3, 30, 0), entries[0].UsageYesterday); + + Assert.AreEqual(ThermostatOperatingState.Cooling, entries[1].OperatingState); + Assert.AreEqual(new TimeSpan(0, 45, 0), entries[1].UsageToday); + Assert.AreEqual(new TimeSpan(2, 0, 0), entries[1].UsageYesterday); + } + + [TestMethod] + public void LoggingReport_ParseInto_WithReportsToFollow() + { + // CC=0x42, Cmd=0x06 + // ReportsToFollow=0x02 + // Entry 1: LogType=0x03 (FanOnly), TodayH=0, TodayM=10, YestH=0, YestM=20 + byte[] data = [0x42, 0x06, 0x02, 0x03, 0x00, 0x0A, 0x00, 0x14]; + CommandClassFrame frame = new(data); + + List entries = []; + byte reportsToFollow = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingReportCommand.ParseInto(frame, entries, NullLogger.Instance); + + Assert.AreEqual(2, reportsToFollow); + Assert.HasCount(1, entries); + Assert.AreEqual(ThermostatOperatingState.FanOnly, entries[0].OperatingState); + } + + [TestMethod] + public void LoggingReport_ParseInto_ZeroUsage() + { + // CC=0x42, Cmd=0x06 + // ReportsToFollow=0x00 + // Entry 1: LogType=0x01 (Heating), all zeros + byte[] data = [0x42, 0x06, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + List entries = []; + byte reportsToFollow = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingReportCommand.ParseInto(frame, entries, NullLogger.Instance); + + Assert.AreEqual(0, reportsToFollow); + Assert.HasCount(1, entries); + Assert.AreEqual(TimeSpan.Zero, entries[0].UsageToday); + Assert.AreEqual(TimeSpan.Zero, entries[0].UsageYesterday); + } + + [TestMethod] + public void LoggingReport_ParseInto_NoEntries() + { + // CC=0x42, Cmd=0x06, ReportsToFollow=0x00, no entries + byte[] data = [0x42, 0x06, 0x00]; + CommandClassFrame frame = new(data); + + List entries = []; + byte reportsToFollow = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingReportCommand.ParseInto(frame, entries, NullLogger.Instance); + + Assert.AreEqual(0, reportsToFollow); + Assert.IsEmpty(entries); + } + + [TestMethod] + public void LoggingReport_ParseInto_PartialEntry_Ignored() + { + // CC=0x42, Cmd=0x06, ReportsToFollow=0x00, incomplete entry (only 3 of 5 bytes) + byte[] data = [0x42, 0x06, 0x00, 0x01, 0x02, 0x1E]; + CommandClassFrame frame = new(data); + + List entries = []; + byte reportsToFollow = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingReportCommand.ParseInto(frame, entries, NullLogger.Instance); + + // Partial entries should be skipped (not enough bytes for a complete entry) + Assert.AreEqual(0, reportsToFollow); + Assert.IsEmpty(entries); + } + + [TestMethod] + public void LoggingReport_ParseInto_TooShort_Throws() + { + // CC=0x42, Cmd=0x06, no parameters + byte[] data = [0x42, 0x06]; + CommandClassFrame frame = new(data); + + List entries = []; + Assert.Throws( + () => ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingReportCommand.ParseInto(frame, entries, NullLogger.Instance)); + } + + [TestMethod] + public void LoggingReport_ParseInto_ReservedBitsInLogType_Ignored() + { + // CC=0x42, Cmd=0x06 + // ReportsToFollow=0x00 + // Entry 1: upper nibble=0xF0 (reserved) + LogType=0x01 → byte=0xF1 + // TodayH=1, TodayM=0, YestH=2, YestM=0 + byte[] data = [0x42, 0x06, 0x00, 0xF1, 0x01, 0x00, 0x02, 0x00]; + CommandClassFrame frame = new(data); + + List entries = []; + byte reportsToFollow = + ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingReportCommand.ParseInto(frame, entries, NullLogger.Instance); + + Assert.HasCount(1, entries); + // Only lower 4 bits are the log type + Assert.AreEqual(ThermostatOperatingState.Heating, entries[0].OperatingState); + Assert.AreEqual(new TimeSpan(1, 0, 0), entries[0].UsageToday); + Assert.AreEqual(new TimeSpan(2, 0, 0), entries[0].UsageYesterday); + } + + [TestMethod] + public void LoggingReport_ParseInto_AppendsToExistingList() + { + // Verify ParseInto appends to an existing list (simulating multi-frame aggregation) + List entries = []; + + // First frame: Heating entry, 1 report to follow + byte[] data1 = [0x42, 0x06, 0x01, 0x01, 0x01, 0x00, 0x02, 0x00]; + CommandClassFrame frame1 = new(data1); + byte reportsToFollow = ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingReportCommand.ParseInto(frame1, entries, NullLogger.Instance); + Assert.AreEqual(1, reportsToFollow); + Assert.HasCount(1, entries); + + // Second frame: Cooling entry, 0 reports to follow + byte[] data2 = [0x42, 0x06, 0x00, 0x02, 0x03, 0x00, 0x04, 0x00]; + CommandClassFrame frame2 = new(data2); + reportsToFollow = ThermostatOperatingStateCommandClass.ThermostatOperatingStateLoggingReportCommand.ParseInto(frame2, entries, NullLogger.Instance); + Assert.AreEqual(0, reportsToFollow); + + // Both entries accumulated in the same list + Assert.HasCount(2, entries); + Assert.AreEqual(ThermostatOperatingState.Heating, entries[0].OperatingState); + Assert.AreEqual(ThermostatOperatingState.Cooling, entries[1].OperatingState); + } +} diff --git a/src/ZWave.CommandClasses.Tests/ThermostatSetbackCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/ThermostatSetbackCommandClassTests.cs new file mode 100644 index 0000000..e01c8f6 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/ThermostatSetbackCommandClassTests.cs @@ -0,0 +1,264 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public class ThermostatSetbackCommandClassTests +{ + [TestMethod] + public void SetCommand_Create_NoOverride_ZeroSetback() + { + ThermostatSetbackCommandClass.ThermostatSetbackSetCommand command = + ThermostatSetbackCommandClass.ThermostatSetbackSetCommand.Create( + ThermostatSetbackType.NoOverride, + new ThermostatSetbackState(0)); + + Assert.AreEqual(CommandClassId.ThermostatSetback, ThermostatSetbackCommandClass.ThermostatSetbackSetCommand.CommandClassId); + Assert.AreEqual((byte)ThermostatSetbackCommand.Set, ThermostatSetbackCommandClass.ThermostatSetbackSetCommand.CommandId); + Assert.AreEqual(4, command.Frame.Data.Length); + // Byte 0: SetbackType=0x00 (NoOverride) + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[0]); + // Byte 1: SetbackState=0x00 (0 degrees) + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void SetCommand_Create_TemporaryOverride_PositiveSetback() + { + // +2.0 degrees = raw value 20 + ThermostatSetbackCommandClass.ThermostatSetbackSetCommand command = + ThermostatSetbackCommandClass.ThermostatSetbackSetCommand.Create( + ThermostatSetbackType.TemporaryOverride, + new ThermostatSetbackState(20)); + + Assert.AreEqual(4, command.Frame.Data.Length); + // Byte 0: SetbackType=0x01 (TemporaryOverride) + Assert.AreEqual(0x01, command.Frame.CommandParameters.Span[0]); + // Byte 1: SetbackState=20 (0x14) + Assert.AreEqual(0x14, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void SetCommand_Create_PermanentOverride_NegativeSetback() + { + // -1.5 degrees = raw value -15 + ThermostatSetbackCommandClass.ThermostatSetbackSetCommand command = + ThermostatSetbackCommandClass.ThermostatSetbackSetCommand.Create( + ThermostatSetbackType.PermanentOverride, + new ThermostatSetbackState(-15)); + + Assert.AreEqual(4, command.Frame.Data.Length); + // Byte 0: SetbackType=0x02 (PermanentOverride) + Assert.AreEqual(0x02, command.Frame.CommandParameters.Span[0]); + // Byte 1: SetbackState=-15 as unsigned = 0xF1 + Assert.AreEqual(0xF1, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void SetCommand_Create_FrostProtection() + { + ThermostatSetbackCommandClass.ThermostatSetbackSetCommand command = + ThermostatSetbackCommandClass.ThermostatSetbackSetCommand.Create( + ThermostatSetbackType.NoOverride, + ThermostatSetbackState.FrostProtection); + + // Byte 1: 0x79 = 121 (Frost Protection) + Assert.AreEqual(0x79, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void SetCommand_Create_EnergySavingMode() + { + ThermostatSetbackCommandClass.ThermostatSetbackSetCommand command = + ThermostatSetbackCommandClass.ThermostatSetbackSetCommand.Create( + ThermostatSetbackType.NoOverride, + ThermostatSetbackState.EnergySavingMode); + + // Byte 1: 0x7A = 122 (Energy Saving Mode) + Assert.AreEqual(0x7A, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void GetCommand_Create_HasCorrectFormat() + { + ThermostatSetbackCommandClass.ThermostatSetbackGetCommand command = + ThermostatSetbackCommandClass.ThermostatSetbackGetCommand.Create(); + + Assert.AreEqual(CommandClassId.ThermostatSetback, ThermostatSetbackCommandClass.ThermostatSetbackGetCommand.CommandClassId); + Assert.AreEqual((byte)ThermostatSetbackCommand.Get, ThermostatSetbackCommandClass.ThermostatSetbackGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void Report_Parse_NoOverride_ZeroSetback() + { + // CC=0x47, Cmd=0x03, Type=0x00 (NoOverride), State=0x00 (0 degrees) + byte[] data = [0x47, 0x03, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + ThermostatSetbackReport report = + ThermostatSetbackCommandClass.ThermostatSetbackReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatSetbackType.NoOverride, report.SetbackType); + Assert.AreEqual(0, report.SetbackState.RawValue); + Assert.IsTrue(report.SetbackState.IsTemperatureSetback); + Assert.AreEqual(0m, report.SetbackState.TemperatureSetbackKelvin); + } + + [TestMethod] + public void Report_Parse_TemporaryOverride_PositiveSetback() + { + // CC=0x47, Cmd=0x03, Type=0x01 (TemporaryOverride), State=0x14 (20 = +2.0 degrees) + byte[] data = [0x47, 0x03, 0x01, 0x14]; + CommandClassFrame frame = new(data); + + ThermostatSetbackReport report = + ThermostatSetbackCommandClass.ThermostatSetbackReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatSetbackType.TemporaryOverride, report.SetbackType); + Assert.AreEqual(20, report.SetbackState.RawValue); + Assert.IsTrue(report.SetbackState.IsTemperatureSetback); + Assert.AreEqual(2.0m, report.SetbackState.TemperatureSetbackKelvin); + } + + [TestMethod] + public void Report_Parse_PermanentOverride_NegativeSetback() + { + // CC=0x47, Cmd=0x03, Type=0x02 (PermanentOverride), State=0xF1 (-15 = -1.5 degrees) + byte[] data = [0x47, 0x03, 0x02, 0xF1]; + CommandClassFrame frame = new(data); + + ThermostatSetbackReport report = + ThermostatSetbackCommandClass.ThermostatSetbackReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(ThermostatSetbackType.PermanentOverride, report.SetbackType); + Assert.AreEqual(-15, report.SetbackState.RawValue); + Assert.IsTrue(report.SetbackState.IsTemperatureSetback); + Assert.AreEqual(-1.5m, report.SetbackState.TemperatureSetbackKelvin); + } + + [TestMethod] + public void Report_Parse_FrostProtection() + { + // CC=0x47, Cmd=0x03, Type=0x00, State=0x79 (121 = Frost Protection) + byte[] data = [0x47, 0x03, 0x00, 0x79]; + CommandClassFrame frame = new(data); + + ThermostatSetbackReport report = + ThermostatSetbackCommandClass.ThermostatSetbackReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(121, report.SetbackState.RawValue); + Assert.IsFalse(report.SetbackState.IsTemperatureSetback); + Assert.IsNull(report.SetbackState.TemperatureSetbackKelvin); + } + + [TestMethod] + public void Report_Parse_EnergySavingMode() + { + // CC=0x47, Cmd=0x03, Type=0x00, State=0x7A (122 = Energy Saving Mode) + byte[] data = [0x47, 0x03, 0x00, 0x7A]; + CommandClassFrame frame = new(data); + + ThermostatSetbackReport report = + ThermostatSetbackCommandClass.ThermostatSetbackReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(122, report.SetbackState.RawValue); + Assert.IsFalse(report.SetbackState.IsTemperatureSetback); + Assert.IsNull(report.SetbackState.TemperatureSetbackKelvin); + } + + [TestMethod] + public void Report_Parse_UnusedState() + { + // CC=0x47, Cmd=0x03, Type=0x00, State=0x7F (127 = Unused State) + byte[] data = [0x47, 0x03, 0x00, 0x7F]; + CommandClassFrame frame = new(data); + + ThermostatSetbackReport report = + ThermostatSetbackCommandClass.ThermostatSetbackReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(127, report.SetbackState.RawValue); + Assert.IsFalse(report.SetbackState.IsTemperatureSetback); + Assert.IsNull(report.SetbackState.TemperatureSetbackKelvin); + } + + [TestMethod] + public void Report_Parse_MaxPositiveSetback() + { + // CC=0x47, Cmd=0x03, Type=0x00, State=0x78 (120 = +12.0 degrees, max temperature setback) + byte[] data = [0x47, 0x03, 0x00, 0x78]; + CommandClassFrame frame = new(data); + + ThermostatSetbackReport report = + ThermostatSetbackCommandClass.ThermostatSetbackReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(120, report.SetbackState.RawValue); + Assert.IsTrue(report.SetbackState.IsTemperatureSetback); + Assert.AreEqual(12.0m, report.SetbackState.TemperatureSetbackKelvin); + } + + [TestMethod] + public void Report_Parse_MaxNegativeSetback() + { + // CC=0x47, Cmd=0x03, Type=0x00, State=0x80 (-128 = -12.8 degrees, max negative setback) + byte[] data = [0x47, 0x03, 0x00, 0x80]; + CommandClassFrame frame = new(data); + + ThermostatSetbackReport report = + ThermostatSetbackCommandClass.ThermostatSetbackReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(-128, report.SetbackState.RawValue); + Assert.IsTrue(report.SetbackState.IsTemperatureSetback); + Assert.AreEqual(-12.8m, report.SetbackState.TemperatureSetbackKelvin); + } + + [TestMethod] + public void Report_Parse_ReservedSetbackType_Preserved() + { + // CC=0x47, Cmd=0x03, Type=0x03 (reserved), State=0x00 + byte[] data = [0x47, 0x03, 0x03, 0x00]; + CommandClassFrame frame = new(data); + + ThermostatSetbackReport report = + ThermostatSetbackCommandClass.ThermostatSetbackReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((ThermostatSetbackType)0x03, report.SetbackType); + } + + [TestMethod] + public void Report_Parse_ReservedBitsInType_Ignored() + { + // CC=0x47, Cmd=0x03, Type byte=0xFD (upper 6 bits set, lower 2 = 0x01 TemporaryOverride), State=0x00 + byte[] data = [0x47, 0x03, 0xFD, 0x00]; + CommandClassFrame frame = new(data); + + ThermostatSetbackReport report = + ThermostatSetbackCommandClass.ThermostatSetbackReportCommand.Parse(frame, NullLogger.Instance); + + // Only lower 2 bits extracted for setback type + Assert.AreEqual(ThermostatSetbackType.TemporaryOverride, report.SetbackType); + } + + [TestMethod] + public void Report_Parse_TooShort_Throws() + { + // CC=0x47, Cmd=0x03, only 1 parameter byte (need 2) + byte[] data = [0x47, 0x03, 0x00]; + CommandClassFrame frame = new(data); + + Assert.Throws( + () => ThermostatSetbackCommandClass.ThermostatSetbackReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void SetbackState_SpecialValues_AreCorrect() + { + Assert.AreEqual(121, ThermostatSetbackState.FrostProtection.RawValue); + Assert.AreEqual(122, ThermostatSetbackState.EnergySavingMode.RawValue); + Assert.AreEqual(127, ThermostatSetbackState.UnusedState.RawValue); + + Assert.IsFalse(ThermostatSetbackState.FrostProtection.IsTemperatureSetback); + Assert.IsFalse(ThermostatSetbackState.EnergySavingMode.IsTemperatureSetback); + Assert.IsFalse(ThermostatSetbackState.UnusedState.IsTemperatureSetback); + } +} diff --git a/src/ZWave.CommandClasses/ThermostatFanModeCommandClass.cs b/src/ZWave.CommandClasses/ThermostatFanModeCommandClass.cs new file mode 100644 index 0000000..b011fab --- /dev/null +++ b/src/ZWave.CommandClasses/ThermostatFanModeCommandClass.cs @@ -0,0 +1,353 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// The fan mode of a thermostat device. +/// +public enum ThermostatFanMode : byte +{ + /// + /// Auto Low — turns the manual fan operation off unless turned on by the manufacturer-specific + /// "auto low" algorithms. + /// + AutoLow = 0x00, + + /// + /// Low — turns the manual fan operation on at low speed. + /// + Low = 0x01, + + /// + /// Auto High — turns the manual fan operation off unless turned on by the manufacturer-specific + /// "auto high" algorithms. + /// + AutoHigh = 0x02, + + /// + /// High — turns the manual fan operation on at high speed. + /// + High = 0x03, + + /// + /// Auto Medium — turns the manual fan operation off unless turned on by the manufacturer-specific + /// "auto medium" algorithms. + /// + AutoMedium = 0x04, + + /// + /// Medium — turns the manual fan operation on at medium speed. + /// + Medium = 0x05, + + /// + /// Circulation — turns the manual fan operation off unless turned on by the manufacturer-specific + /// circulation algorithms. + /// + Circulation = 0x06, + + /// + /// Humidity Circulation — turns the manual fan operation off unless turned on by the manufacturer-specific + /// "humidity circulation" algorithms. + /// + HumidityCirculation = 0x07, + + /// + /// Left & Right — turns the manual fan operation off unless turned on by the manufacturer-specific + /// "left & right" circulation algorithms. + /// + LeftRight = 0x08, + + /// + /// Up & Down — turns the manual fan operation off unless turned on by the manufacturer-specific + /// "up & down" circulation algorithms. + /// + UpDown = 0x09, + + /// + /// Quiet — turns the manual fan operation off unless turned on by the manufacturer-specific + /// "quiet" algorithms. + /// + Quiet = 0x0A, + + /// + /// External Circulation — turns the manual fan operation off unless turned on by the manufacturer-specific + /// circulation algorithms. This mode will circulate fresh air from the outside. + /// + ExternalCirculation = 0x0B, +} + +public enum ThermostatFanModeCommand : byte +{ + /// + /// Set the fan mode in the device. + /// + Set = 0x01, + + /// + /// Request the fan mode in the device. + /// + Get = 0x02, + + /// + /// Report the fan mode in a device. + /// + Report = 0x03, + + /// + /// Request the supported fan modes from the device. + /// + SupportedGet = 0x04, + + /// + /// Report the supported fan modes from the device. + /// + SupportedReport = 0x05, +} + +/// +/// Represents a Thermostat Fan Mode Report received from a device. +/// +public readonly record struct ThermostatFanModeReport( + /// + /// The current fan mode. + /// + ThermostatFanMode FanMode, + + /// + /// Indicates whether the fan is fully off. When true, the fan is off regardless of the fan mode. + /// Added in version 2; version 1 devices always report false (reserved bit). + /// + bool Off); + +/// +/// The Thermostat Fan Mode Command Class is used to control the fan mode of HVAC systems. +/// +[CommandClass(CommandClassId.ThermostatFanMode)] +public sealed class ThermostatFanModeCommandClass : CommandClass +{ + internal ThermostatFanModeCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + /// Gets the last report received from the device. + /// + public ThermostatFanModeReport? LastReport { get; private set; } + + /// + /// Gets the set of fan modes supported by the device, or null if not yet queried. + /// + public IReadOnlySet? SupportedFanModes { get; private set; } + + /// + /// Event raised when a Thermostat Fan Mode Report is received, both solicited and unsolicited. + /// + public event Action? OnThermostatFanModeReportReceived; + + /// + public override bool? IsCommandSupported(ThermostatFanModeCommand command) + => command switch + { + ThermostatFanModeCommand.Set => true, + ThermostatFanModeCommand.Get => true, + ThermostatFanModeCommand.SupportedGet => true, + _ => false, + }; + + internal override async Task InterviewAsync(CancellationToken cancellationToken) + { + _ = await GetSupportedAsync(cancellationToken).ConfigureAwait(false); + _ = await GetAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Request the current fan mode from the device. + /// + public async Task GetAsync(CancellationToken cancellationToken) + { + ThermostatFanModeGetCommand command = ThermostatFanModeGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + ThermostatFanModeReport report = ThermostatFanModeReportCommand.Parse(reportFrame, Logger); + LastReport = report; + OnThermostatFanModeReportReceived?.Invoke(report); + return report; + } + + /// + /// Set the fan mode in the device. + /// + /// The desired fan mode. + /// + /// When true, the fan is switched fully off regardless of the fan mode. + /// Requires version 2 or later; ignored on version 1 devices. + /// + /// The cancellation token. + public async Task SetAsync( + ThermostatFanMode fanMode, + bool off, + CancellationToken cancellationToken) + { + var command = ThermostatFanModeSetCommand.Create(EffectiveVersion, fanMode, off); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// + /// Request the supported fan modes from the device. + /// + public async Task> GetSupportedAsync(CancellationToken cancellationToken) + { + ThermostatFanModeSupportedGetCommand command = ThermostatFanModeSupportedGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + HashSet supportedModes = ThermostatFanModeSupportedReportCommand.Parse(reportFrame, Logger); + SupportedFanModes = supportedModes; + return supportedModes; + } + + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((ThermostatFanModeCommand)frame.CommandId) + { + case ThermostatFanModeCommand.Report: + { + ThermostatFanModeReport report = ThermostatFanModeReportCommand.Parse(frame, Logger); + LastReport = report; + OnThermostatFanModeReportReceived?.Invoke(report); + break; + } + } + } + + internal readonly struct ThermostatFanModeSetCommand : ICommand + { + public ThermostatFanModeSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.ThermostatFanMode; + + public static byte CommandId => (byte)ThermostatFanModeCommand.Set; + + public CommandClassFrame Frame { get; } + + public static ThermostatFanModeSetCommand Create(byte version, ThermostatFanMode fanMode, bool off) + { + // V2+ format: bit 7 = Off, bits 6-4 = reserved (0), bits 3-0 = Fan Mode + // V1 format: bits 7-4 = reserved (MUST be 0), bits 3-0 = Fan Mode + byte value = (byte)((byte)fanMode & 0x0F); + if (off && version >= 2) + { + value |= 0x80; + } + + ReadOnlySpan commandParameters = [value]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ThermostatFanModeSetCommand(frame); + } + } + + internal readonly struct ThermostatFanModeGetCommand : ICommand + { + public ThermostatFanModeGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.ThermostatFanMode; + + public static byte CommandId => (byte)ThermostatFanModeCommand.Get; + + public CommandClassFrame Frame { get; } + + public static ThermostatFanModeGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new ThermostatFanModeGetCommand(frame); + } + } + + internal readonly struct ThermostatFanModeReportCommand : ICommand + { + public ThermostatFanModeReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.ThermostatFanMode; + + public static byte CommandId => (byte)ThermostatFanModeCommand.Report; + + public CommandClassFrame Frame { get; } + + public static ThermostatFanModeReport Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning("Thermostat Fan Mode Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Thermostat Fan Mode Report frame is too short"); + } + + byte value = frame.CommandParameters.Span[0]; + + // V2+: bit 7 = Off flag + // V1: bit 7 is reserved, but per forward-compatibility we parse it unconditionally + bool off = (value & 0x80) != 0; + + // Bits 3-0 = Fan Mode + ThermostatFanMode fanMode = (ThermostatFanMode)(value & 0x0F); + + return new ThermostatFanModeReport(fanMode, off); + } + } + + internal readonly struct ThermostatFanModeSupportedGetCommand : ICommand + { + public ThermostatFanModeSupportedGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.ThermostatFanMode; + + public static byte CommandId => (byte)ThermostatFanModeCommand.SupportedGet; + + public CommandClassFrame Frame { get; } + + public static ThermostatFanModeSupportedGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new ThermostatFanModeSupportedGetCommand(frame); + } + } + + internal readonly struct ThermostatFanModeSupportedReportCommand : ICommand + { + public ThermostatFanModeSupportedReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.ThermostatFanMode; + + public static byte CommandId => (byte)ThermostatFanModeCommand.SupportedReport; + + public CommandClassFrame Frame { get; } + + public static HashSet Parse(CommandClassFrame frame, ILogger logger) + { + // The bitmask length is determined from the frame length. It may be empty if the + // device supports no modes (unusual but valid per frame format). + ReadOnlySpan bitMask = frame.CommandParameters.Span; + HashSet supportedModes = BitMaskHelper.ParseBitMask(bitMask); + return supportedModes; + } + } +} diff --git a/src/ZWave.CommandClasses/ThermostatFanStateCommandClass.cs b/src/ZWave.CommandClasses/ThermostatFanStateCommandClass.cs new file mode 100644 index 0000000..91d5397 --- /dev/null +++ b/src/ZWave.CommandClasses/ThermostatFanStateCommandClass.cs @@ -0,0 +1,193 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// The fan operating state of a thermostat device. +/// +public enum ThermostatFanOperatingState : byte +{ + /// + /// The fan is idle or off. + /// + Idle = 0x00, + + /// + /// The fan is running. For single-speed devices, this indicates the fan is running. + /// For multi-speed devices, this indicates the fan is running at low speed. + /// + RunningLow = 0x01, + + /// + /// The fan is running at high speed. + /// + RunningHigh = 0x02, + + /// + /// The fan is running at medium speed. + /// + RunningMedium = 0x03, + + /// + /// The fan is in circulation mode. + /// + CirculationMode = 0x04, + + /// + /// The fan is in humidity circulation mode. + /// + HumidityCirculationMode = 0x05, + + /// + /// The fan is in right-left circulation mode. + /// + RightLeftCirculationMode = 0x06, + + /// + /// The fan is in up-down circulation mode. + /// + UpDownCirculationMode = 0x07, + + /// + /// The fan is in quiet circulation mode. + /// + QuietCirculationMode = 0x08, +} + +public enum ThermostatFanStateCommand : byte +{ + /// + /// Request the fan operating state from the device. + /// + Get = 0x02, + + /// + /// Report the fan operating state of the device. + /// + Report = 0x03, +} + +/// +/// Represents a Thermostat Fan State Report received from a device. +/// +public readonly record struct ThermostatFanStateReport( + /// + /// The current fan operating state. + /// + ThermostatFanOperatingState FanOperatingState); + +/// +/// The Thermostat Fan State Command Class is used to obtain the fan operating state of the thermostat. +/// +[CommandClass(CommandClassId.ThermostatFanState)] +public sealed class ThermostatFanStateCommandClass : CommandClass +{ + internal ThermostatFanStateCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + /// Gets the last report received from the device. + /// + public ThermostatFanStateReport? LastReport { get; private set; } + + /// + /// Event raised when a Thermostat Fan State Report is received, both solicited and unsolicited. + /// + public event Action? OnThermostatFanStateReportReceived; + + /// + public override bool? IsCommandSupported(ThermostatFanStateCommand command) + => command switch + { + ThermostatFanStateCommand.Get => true, + _ => false, + }; + + internal override async Task InterviewAsync(CancellationToken cancellationToken) + { + _ = await GetAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Request the fan operating state from the device. + /// + public async Task GetAsync(CancellationToken cancellationToken) + { + ThermostatFanStateGetCommand command = ThermostatFanStateGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + ThermostatFanStateReport report = ThermostatFanStateReportCommand.Parse(reportFrame, Logger); + LastReport = report; + OnThermostatFanStateReportReceived?.Invoke(report); + return report; + } + + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((ThermostatFanStateCommand)frame.CommandId) + { + case ThermostatFanStateCommand.Report: + { + ThermostatFanStateReport report = ThermostatFanStateReportCommand.Parse(frame, Logger); + LastReport = report; + OnThermostatFanStateReportReceived?.Invoke(report); + break; + } + } + } + + internal readonly struct ThermostatFanStateGetCommand : ICommand + { + public ThermostatFanStateGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.ThermostatFanState; + + public static byte CommandId => (byte)ThermostatFanStateCommand.Get; + + public CommandClassFrame Frame { get; } + + public static ThermostatFanStateGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new ThermostatFanStateGetCommand(frame); + } + } + + internal readonly struct ThermostatFanStateReportCommand : ICommand + { + public ThermostatFanStateReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.ThermostatFanState; + + public static byte CommandId => (byte)ThermostatFanStateCommand.Report; + + public CommandClassFrame Frame { get; } + + public static ThermostatFanStateReport Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning("Thermostat Fan State Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Thermostat Fan State Report frame is too short"); + } + + // V1: 4-bit field (bits 0-3), upper 4 bits reserved + // V2: full 8-bit field (adds states 3-8) + // Per forward-compatibility rules, do NOT mask reserved bits — parse all 8 bits. + ThermostatFanOperatingState fanOperatingState = (ThermostatFanOperatingState)frame.CommandParameters.Span[0]; + return new ThermostatFanStateReport(fanOperatingState); + } + } +} diff --git a/src/ZWave.CommandClasses/ThermostatOperatingStateCommandClass.cs b/src/ZWave.CommandClasses/ThermostatOperatingStateCommandClass.cs new file mode 100644 index 0000000..f46234a --- /dev/null +++ b/src/ZWave.CommandClasses/ThermostatOperatingStateCommandClass.cs @@ -0,0 +1,435 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// The operating state of a thermostat device. +/// +public enum ThermostatOperatingState : byte +{ + /// + /// The thermostat is idle. + /// + Idle = 0x00, + + /// + /// The thermostat is heating. + /// + Heating = 0x01, + + /// + /// The thermostat is cooling. + /// + Cooling = 0x02, + + /// + /// The thermostat is running the fan only. + /// + FanOnly = 0x03, + + /// + /// Pending heat. Short cycle prevention feature used in heat pump applications to protect the compressor. + /// + PendingHeat = 0x04, + + /// + /// Pending cool. Short cycle prevention feature used in heat pump applications to protect the compressor. + /// + PendingCool = 0x05, + + /// + /// The thermostat is in vent/economizer mode. + /// + VentEconomizer = 0x06, + + /// + /// The thermostat is using auxiliary heating. + /// + AuxHeating = 0x07, + + /// + /// The thermostat is in 2nd stage heating. + /// + SecondStageHeating = 0x08, + + /// + /// The thermostat is in 2nd stage cooling. + /// + SecondStageCooling = 0x09, + + /// + /// The thermostat is in 2nd stage auxiliary heat. + /// + SecondStageAuxHeat = 0x0A, + + /// + /// The thermostat is in 3rd stage auxiliary heat. + /// + ThirdStageAuxHeat = 0x0B, +} + +public enum ThermostatOperatingStateCommand : byte +{ + /// + /// Request the operating state logging supported by the device. + /// + LoggingSupportedGet = 0x01, + + /// + /// Request the operating state from the device. + /// + Get = 0x02, + + /// + /// Report the operating state of the device. + /// + Report = 0x03, + + /// + /// Report the operating state logging supported by the device. + /// + LoggingSupportedReport = 0x04, + + /// + /// Request the operating state logging from the device. + /// + LoggingGet = 0x05, + + /// + /// Report the operating state logged for requested operating states. + /// + LoggingReport = 0x06, +} + +/// +/// Represents a Thermostat Operating State Report received from a device. +/// +public readonly record struct ThermostatOperatingStateReport( + /// + /// The current operating state of the thermostat. + /// + ThermostatOperatingState OperatingState); + +/// +/// Represents a logged operating state entry with usage statistics for today and yesterday. +/// +public readonly record struct ThermostatOperatingStateLogEntry( + /// + /// The operating state this log entry is for. + /// + ThermostatOperatingState OperatingState, + + /// + /// The time the thermostat has been in this operating state since 12:00 AM of the current day. + /// + TimeSpan UsageToday, + + /// + /// The time the thermostat was in this operating state between 12:00 AM and 11:59 PM of the previous day. + /// + TimeSpan UsageYesterday); + +/// +/// The Thermostat Operating State Command Class is used to obtain the operating state and +/// operating state logs of the thermostat. +/// +[CommandClass(CommandClassId.ThermostatOperatingState)] +public sealed class ThermostatOperatingStateCommandClass : CommandClass +{ + internal ThermostatOperatingStateCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + /// Gets the last operating state report received from the device. + /// + public ThermostatOperatingStateReport? LastReport { get; private set; } + + /// + /// Gets the set of operating states for which logging is supported, or null if not yet queried or + /// the device does not support logging (version 1). + /// + public IReadOnlySet? SupportedLoggingStates { get; private set; } + + /// + /// Event raised when a Thermostat Operating State Report is received, both solicited and unsolicited. + /// + public event Action? OnThermostatOperatingStateReportReceived; + + /// + public override bool? IsCommandSupported(ThermostatOperatingStateCommand command) + => command switch + { + ThermostatOperatingStateCommand.Get => true, + ThermostatOperatingStateCommand.LoggingSupportedGet => Version.HasValue ? Version >= 2 : null, + ThermostatOperatingStateCommand.LoggingGet => Version.HasValue ? Version >= 2 : null, + _ => false, + }; + + internal override async Task InterviewAsync(CancellationToken cancellationToken) + { + _ = await GetAsync(cancellationToken).ConfigureAwait(false); + + if (IsCommandSupported(ThermostatOperatingStateCommand.LoggingSupportedGet).GetValueOrDefault()) + { + _ = await GetSupportedLoggingStatesAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Request the current operating state from the device. + /// + public async Task GetAsync(CancellationToken cancellationToken) + { + ThermostatOperatingStateGetCommand command = ThermostatOperatingStateGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + ThermostatOperatingStateReport report = ThermostatOperatingStateReportCommand.Parse(reportFrame, Logger); + LastReport = report; + OnThermostatOperatingStateReportReceived?.Invoke(report); + return report; + } + + /// + /// Request the supported operating state logging types from the device. + /// + public async Task> GetSupportedLoggingStatesAsync(CancellationToken cancellationToken) + { + ThermostatOperatingStateLoggingSupportedGetCommand command = ThermostatOperatingStateLoggingSupportedGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + HashSet supportedStates = ThermostatOperatingStateLoggingSupportedReportCommand.Parse(reportFrame, Logger); + SupportedLoggingStates = supportedStates; + return supportedStates; + } + + /// + /// Request operating state logs for the specified states from the device. + /// Results are aggregated across multiple report frames if necessary. + /// + public async Task> GetLoggingAsync( + IReadOnlySet requestedStates, + CancellationToken cancellationToken) + { + var command = ThermostatOperatingStateLoggingGetCommand.Create(requestedStates); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + + List allEntries = []; + byte reportsToFollow; + do + { + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + reportsToFollow = ThermostatOperatingStateLoggingReportCommand.ParseInto(reportFrame, allEntries, Logger); + } + while (reportsToFollow > 0); + + return allEntries; + } + + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((ThermostatOperatingStateCommand)frame.CommandId) + { + case ThermostatOperatingStateCommand.Report: + { + ThermostatOperatingStateReport report = ThermostatOperatingStateReportCommand.Parse(frame, Logger); + LastReport = report; + OnThermostatOperatingStateReportReceived?.Invoke(report); + break; + } + } + } + + internal readonly struct ThermostatOperatingStateGetCommand : ICommand + { + public ThermostatOperatingStateGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.ThermostatOperatingState; + + public static byte CommandId => (byte)ThermostatOperatingStateCommand.Get; + + public CommandClassFrame Frame { get; } + + public static ThermostatOperatingStateGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new ThermostatOperatingStateGetCommand(frame); + } + } + + internal readonly struct ThermostatOperatingStateReportCommand : ICommand + { + public ThermostatOperatingStateReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.ThermostatOperatingState; + + public static byte CommandId => (byte)ThermostatOperatingStateCommand.Report; + + public CommandClassFrame Frame { get; } + + public static ThermostatOperatingStateReport Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning("Thermostat Operating State Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Thermostat Operating State Report frame is too short"); + } + + // V1: upper 4 bits reserved, lower 4 bits = operating state + // V2: full 8-bit operating state field + // Per forward-compatibility, do NOT mask reserved bits. + ThermostatOperatingState operatingState = (ThermostatOperatingState)frame.CommandParameters.Span[0]; + return new ThermostatOperatingStateReport(operatingState); + } + } + + internal readonly struct ThermostatOperatingStateLoggingSupportedGetCommand : ICommand + { + public ThermostatOperatingStateLoggingSupportedGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.ThermostatOperatingState; + + public static byte CommandId => (byte)ThermostatOperatingStateCommand.LoggingSupportedGet; + + public CommandClassFrame Frame { get; } + + public static ThermostatOperatingStateLoggingSupportedGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new ThermostatOperatingStateLoggingSupportedGetCommand(frame); + } + } + + internal readonly struct ThermostatOperatingStateLoggingSupportedReportCommand : ICommand + { + public ThermostatOperatingStateLoggingSupportedReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.ThermostatOperatingState; + + public static byte CommandId => (byte)ThermostatOperatingStateCommand.LoggingSupportedReport; + + public CommandClassFrame Frame { get; } + + public static HashSet Parse(CommandClassFrame frame, ILogger logger) + { + // Per spec: bit 0 in bitmask 1 is NOT allocated and MUST be zero. + // Use startBit: 1 to skip bit 0. + ReadOnlySpan bitMask = frame.CommandParameters.Span; + HashSet supportedStates = BitMaskHelper.ParseBitMask(bitMask, startBit: 1); + return supportedStates; + } + } + + internal readonly struct ThermostatOperatingStateLoggingGetCommand : ICommand + { + public ThermostatOperatingStateLoggingGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.ThermostatOperatingState; + + public static byte CommandId => (byte)ThermostatOperatingStateCommand.LoggingGet; + + public CommandClassFrame Frame { get; } + + public static ThermostatOperatingStateLoggingGetCommand Create(IReadOnlySet requestedStates) + { + // Build bitmask for the requested states. + // Per spec: bit 0 in bitmask 1 is not allocated and MUST be set to zero. + // Find the highest bit needed to determine the bitmask size. + int maxBit = 0; + foreach (ThermostatOperatingState state in requestedStates) + { + int bit = (byte)state; + if (bit > maxBit) + { + maxBit = bit; + } + } + + int byteCount = (maxBit / 8) + 1; + Span bitMask = stackalloc byte[byteCount]; + + foreach(ThermostatOperatingState state in requestedStates) + { + int bit = (byte)state; + bitMask[bit / 8] |= (byte)(1 << (bit % 8)); + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, bitMask); + return new ThermostatOperatingStateLoggingGetCommand(frame); + } + } + + internal readonly struct ThermostatOperatingStateLoggingReportCommand : ICommand + { + public ThermostatOperatingStateLoggingReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.ThermostatOperatingState; + + public static byte CommandId => (byte)ThermostatOperatingStateCommand.LoggingReport; + + public CommandClassFrame Frame { get; } + + /// + /// Parse a single Thermostat Operating State Logging Report frame, appending log entries to the provided list. + /// + /// The reports-to-follow count from this frame. + public static byte ParseInto( + CommandClassFrame frame, + List entries, + ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning("Thermostat Operating State Logging Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Thermostat Operating State Logging Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + byte reportsToFollow = span[0]; + + // Each log entry is 5 bytes: Reserved(4 bits) + LogType(4 bits), UsageTodayHours, UsageTodayMinutes, UsageYesterdayHours, UsageYesterdayMinutes + int offset = 1; + + while (offset + 5 <= span.Length) + { + // Byte layout: upper 4 bits = reserved, lower 4 bits = operating state log type + ThermostatOperatingState logType = (ThermostatOperatingState)(span[offset] & 0x0F); + byte todayHours = span[offset + 1]; + byte todayMinutes = span[offset + 2]; + byte yesterdayHours = span[offset + 3]; + byte yesterdayMinutes = span[offset + 4]; + + TimeSpan usageToday = new(todayHours, todayMinutes, 0); + TimeSpan usageYesterday = new(yesterdayHours, yesterdayMinutes, 0); + + entries.Add(new ThermostatOperatingStateLogEntry(logType, usageToday, usageYesterday)); + offset += 5; + } + + return reportsToFollow; + } + } +} diff --git a/src/ZWave.CommandClasses/ThermostatSetbackCommandClass.cs b/src/ZWave.CommandClasses/ThermostatSetbackCommandClass.cs new file mode 100644 index 0000000..00ca692 --- /dev/null +++ b/src/ZWave.CommandClasses/ThermostatSetbackCommandClass.cs @@ -0,0 +1,269 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// The setback type for a thermostat. +/// +public enum ThermostatSetbackType : byte +{ + /// + /// No override. + /// + NoOverride = 0x00, + + /// + /// Temporary override. If a timer is implemented in the device, the override will be terminated + /// by the timer. If no timer is implemented, this acts as a permanent override. + /// + TemporaryOverride = 0x01, + + /// + /// Permanent override. + /// + PermanentOverride = 0x02, +} + +/// +/// The setback state for a thermostat, representing a temperature offset in 1/10 degree Kelvin steps, +/// or a special state such as frost protection or energy saving mode. +/// +public readonly record struct ThermostatSetbackState +{ + /// + /// The raw setback state value as a signed byte. + /// Values -128 to 120 represent temperature setback in 1/10 degree Kelvin steps. + /// Value 121 (0x79) represents Frost Protection. + /// Value 122 (0x7A) represents Energy Saving Mode. + /// Value 127 (0x7F) represents Unused State. + /// + public sbyte RawValue { get; } + + /// + /// Initializes a new instance of the struct with a raw value. + /// + public ThermostatSetbackState(sbyte rawValue) + { + RawValue = rawValue; + } + + /// + /// Gets the Frost Protection setback state (0x79 = 121). + /// + public static ThermostatSetbackState FrostProtection => new(121); + + /// + /// Gets the Energy Saving Mode setback state (0x7A = 122). + /// + public static ThermostatSetbackState EnergySavingMode => new(122); + + /// + /// Gets the Unused State setback state (0x7F = 127). + /// + public static ThermostatSetbackState UnusedState => new(127); + + /// + /// Gets whether this state represents a temperature setback value (as opposed to a special state). + /// + public bool IsTemperatureSetback => RawValue >= -128 && RawValue <= 120; + + /// + /// Gets the temperature setback in degrees Kelvin, or null if this is a special state. + /// + public decimal? TemperatureSetbackKelvin => IsTemperatureSetback ? RawValue / 10m : null; +} + +public enum ThermostatSetbackCommand : byte +{ + /// + /// Set the setback state of the thermostat. + /// + Set = 0x01, + + /// + /// Request the current setback state of the thermostat. + /// + Get = 0x02, + + /// + /// Report the current setback state of the thermostat. + /// + Report = 0x03, +} + +/// +/// Represents a Thermostat Setback Report received from a device. +/// +public readonly record struct ThermostatSetbackReport( + /// + /// The current setback type. + /// + ThermostatSetbackType SetbackType, + + /// + /// The current setback state. + /// + ThermostatSetbackState SetbackState); + +/// +/// The Thermostat Setback Command Class is used to change the current state of a non-schedule +/// setback thermostat. +/// +[CommandClass(CommandClassId.ThermostatSetback)] +public sealed class ThermostatSetbackCommandClass : CommandClass +{ + internal ThermostatSetbackCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + /// Gets the last report received from the device. + /// + public ThermostatSetbackReport? LastReport { get; private set; } + + /// + /// Event raised when a Thermostat Setback Report is received, both solicited and unsolicited. + /// + public event Action? OnThermostatSetbackReportReceived; + + /// + public override bool? IsCommandSupported(ThermostatSetbackCommand command) + => command switch + { + ThermostatSetbackCommand.Set => true, + ThermostatSetbackCommand.Get => true, + _ => false, + }; + + internal override async Task InterviewAsync(CancellationToken cancellationToken) + { + _ = await GetAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Request the current setback state from the device. + /// + public async Task GetAsync(CancellationToken cancellationToken) + { + ThermostatSetbackGetCommand command = ThermostatSetbackGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + ThermostatSetbackReport report = ThermostatSetbackReportCommand.Parse(reportFrame, Logger); + LastReport = report; + OnThermostatSetbackReportReceived?.Invoke(report); + return report; + } + + /// + /// Set the setback state of the thermostat. + /// + public async Task SetAsync( + ThermostatSetbackType setbackType, + ThermostatSetbackState setbackState, + CancellationToken cancellationToken) + { + var command = ThermostatSetbackSetCommand.Create(setbackType, setbackState); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((ThermostatSetbackCommand)frame.CommandId) + { + case ThermostatSetbackCommand.Report: + { + ThermostatSetbackReport report = ThermostatSetbackReportCommand.Parse(frame, Logger); + LastReport = report; + OnThermostatSetbackReportReceived?.Invoke(report); + break; + } + } + } + + internal readonly struct ThermostatSetbackSetCommand : ICommand + { + public ThermostatSetbackSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.ThermostatSetback; + + public static byte CommandId => (byte)ThermostatSetbackCommand.Set; + + public CommandClassFrame Frame { get; } + + public static ThermostatSetbackSetCommand Create( + ThermostatSetbackType setbackType, + ThermostatSetbackState setbackState) + { + // Byte 0: bits 7-2 = reserved (0), bits 1-0 = Setback Type + // Byte 1: Setback State (signed byte) + ReadOnlySpan commandParameters = + [ + (byte)((byte)setbackType & 0b0000_0011), + (byte)setbackState.RawValue, + ]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new ThermostatSetbackSetCommand(frame); + } + } + + internal readonly struct ThermostatSetbackGetCommand : ICommand + { + public ThermostatSetbackGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.ThermostatSetback; + + public static byte CommandId => (byte)ThermostatSetbackCommand.Get; + + public CommandClassFrame Frame { get; } + + public static ThermostatSetbackGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new ThermostatSetbackGetCommand(frame); + } + } + + internal readonly struct ThermostatSetbackReportCommand : ICommand + { + public ThermostatSetbackReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.ThermostatSetback; + + public static byte CommandId => (byte)ThermostatSetbackCommand.Report; + + public CommandClassFrame Frame { get; } + + public static ThermostatSetbackReport Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 2) + { + logger.LogWarning("Thermostat Setback Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Thermostat Setback Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + + // Byte 0: bits 7-2 = reserved, bits 1-0 = Setback Type + ThermostatSetbackType setbackType = (ThermostatSetbackType)(span[0] & 0b0000_0011); + + // Byte 1: Setback State (signed byte, -128 to 127) + ThermostatSetbackState setbackState = new((sbyte)span[1]); + + return new ThermostatSetbackReport(setbackType, setbackState); + } + } +}