diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1c7e30b..8a5f5fa 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -137,7 +137,7 @@ Response structs that contain variable-length collections use count + indexer me **New Sub-Command Based Command**: Several Serial API commands use sub-commands (e.g. `NvmBackupRestore`, `ExtendedNvmBackupRestore`, `NetworkRestore`, `FirmwareUpdateNvm`, `NonceManagement`). These use a `partial struct` with static factory methods for each sub-command. Define the sub-command enum and status enum alongside the struct. The response struct reads the sub-command byte and status from the command parameters. -**New Command Class**: Create a class in `src/ZWave.CommandClasses/` inheriting `CommandClass`. Apply `[CommandClass(CommandClassId.X)]`. Constructor takes `(CommandClassInfo info, IDriver driver, IEndpoint endpoint, ILogger logger)`. Define internal inner structs for each command (Set/Get/Report) implementing `ICommand` (internal enables direct unit testing). The source generator auto-registers it. Use `Endpoint` property to access the endpoint (e.g. `Endpoint.NodeId`, `Endpoint.CommandClasses`). Override `Category` to return the correct `CommandClassCategory` (Management for §6.3 CCs, Transport for §6.4 CCs; Application is the default for §6.2 CCs). The `ProcessUnsolicitedCommand` override should only handle commands that can actually arrive unsolicited (typically just Report); do not add no-op cases for Set/Get. For large CCs with many command groups, use the **partial class pattern**: the main file (`{Name}CommandClass.cs`) contains the command enum, constructor, `IsCommandSupported`, `InterviewAsync`, `ProcessUnsolicitedCommand`, and callbacks; each command group goes in a separate partial file (`{Name}CommandClass.{Group}.cs`) with its report record struct, inner command structs, and public accessor methods. Test classes follow the same split (`{Name}CommandClassTests.cs` + `{Name}CommandClassTests.{Group}.cs`). For CCs with per-key readings (e.g. per-sensor-type values, per-component state), eagerly initialize the readings dictionary to `new()` (non-nullable property); capability/discovery properties (e.g. `SupportedSensorTypes`) start `null` (nullable) and are populated during interview. Report command structs should have both `Parse` (for incoming) and `Create` (for outgoing) static methods to make them **bidirectional**. See the skill for details. +**New Command Class**: Create a class in `src/ZWave.CommandClasses/` inheriting `CommandClass`. Apply `[CommandClass(CommandClassId.X)]`. Constructor takes `(CommandClassInfo info, IDriver driver, IEndpoint endpoint, ILogger logger)`. Define internal inner structs for each command (Set/Get/Report) implementing `ICommand` (internal enables direct unit testing). The source generator auto-registers it. Use `Endpoint` property to access the endpoint (e.g. `Endpoint.NodeId`, `Endpoint.CommandClasses`). Override `Category` to return the correct `CommandClassCategory` (Management for §6.3 CCs, Transport for §6.4 CCs; Application is the default for §6.2 CCs). The `ProcessUnsolicitedCommand` override should only handle commands that can actually arrive unsolicited (typically just Report); do not add no-op cases for Set/Get. For large CCs with many command groups, use the **partial class pattern**: the main file (`{Name}CommandClass.cs`) contains the command enum, constructor, `IsCommandSupported`, `InterviewAsync`, `ProcessUnsolicitedCommand`, and callbacks; each command group goes in a separate partial file (`{Name}CommandClass.{Group}.cs`) with its report record struct, inner command structs, and public accessor methods. Test classes follow the same split (`{Name}CommandClassTests.cs` + `{Name}CommandClassTests.{Group}.cs`). For CCs with per-key readings (e.g. per-sensor-type values, per-component state), eagerly initialize the readings dictionary to `new()` (non-nullable property); capability/discovery properties (e.g. `SupportedSensorTypes`) start `null` (nullable) and are populated during interview. Report command structs should have both `Parse` (for incoming) and `Create` (for outgoing) static methods to make them **bidirectional**. When a report contains a "Next" field used for discovery chaining (e.g. `NextIndicatorId` in Indicator Supported Report), that field is an interview implementation detail and MUST NOT be exposed in the public report record struct. Instead, have `Parse` return a value tuple `(TReport Report, TId NextId)` — the public `GetSupportedAsync` discards the next ID with `_`, while the interview loop destructures both. See the skill for details. **New Controller Command Handler**: When a CC requires the controller to respond to incoming queries from other nodes (the "supporting side"), add handler methods in `src/ZWave/ControllerCommandHandler.cs`. This pattern is used instead of adding virtual methods to `CommandClass` because: (1) handlers need Driver/Controller context that CCs don't have, (2) it avoids polluting the CC base class, (3) it cleanly separates "controlling" (CC layer) from "supporting" (Driver layer) concerns. Steps: add a `case` in the `HandleCommand` dispatch switch for the CC ID, add private handler methods for each command, use the CC's report struct `Create` methods to construct responses, and send via `SendResponse`. CCs that currently have handlers: Association CC (Get/Set/Remove/SupportedGroupingsGet/SpecificGroupGet) and AGI CC (GroupNameGet/GroupInfoGet/CommandListGet). Future CCs needing handlers: Version, Z-Wave Plus Info, Powerlevel, Time, Manufacturer Specific. diff --git a/.github/skills/zwave-implement-cc/SKILL.md b/.github/skills/zwave-implement-cc/SKILL.md index 0c3ba80..2fd68bf 100644 --- a/.github/skills/zwave-implement-cc/SKILL.md +++ b/.github/skills/zwave-implement-cc/SKILL.md @@ -289,6 +289,7 @@ Key points: - Name the type `{Name}Report` (not `{Name}State`). The CC class property is `LastReport` (not `State`). - For fields added in later CC versions, make the type nullable (e.g., `GenericValue?`). - XML doc comments go on each positional parameter. +- **"Next" fields for discovery chaining** (e.g. `NextIndicatorId` in Indicator Supported Report) are interview implementation details and MUST NOT appear in the public report struct. Instead, have the `Parse` method return a value tuple `(TReport Report, TId NextId)`. The public `GetSupportedAsync` discards the next ID with `_`, while the interview loop destructures both values to follow the chain. ### 3. Implement the CC Class @@ -563,7 +564,7 @@ Key points for report commands: - For bitmask fields, use bit manipulation: `(span[N] & 0b0000_1111)`. - **Do NOT mask reserved bits.** If a field has reserved bits in one version but they are defined in a later version, parse all bits unconditionally. This ensures forward compatibility. - For optional fields added in later versions, check **payload length** to determine if the field is present. Never use version checks for this. -- Report commands do **not** have a `Create` method — they are only used to identify the command ID for `AwaitNextReportAsync` dispatch. +- Report commands should also have a **static `Create` method** for **bidirectional** support (constructing outgoing frames, e.g. for controller-side responses and unit testing round-trips). ## Common Patterns diff --git a/src/ZWave.CommandClasses.Tests/IndicatorCommandClassTests.Report.cs b/src/ZWave.CommandClasses.Tests/IndicatorCommandClassTests.Report.cs new file mode 100644 index 0000000..572ee9b --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/IndicatorCommandClassTests.Report.cs @@ -0,0 +1,348 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class IndicatorCommandClassTests +{ + #region Get Command + + [TestMethod] + public void GetCommand_Create_V1_HasCorrectFormat() + { + IndicatorCommandClass.IndicatorGetCommand command = + IndicatorCommandClass.IndicatorGetCommand.Create(); + + Assert.AreEqual(CommandClassId.Indicator, IndicatorCommandClass.IndicatorGetCommand.CommandClassId); + Assert.AreEqual((byte)IndicatorCommand.Get, IndicatorCommandClass.IndicatorGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void GetCommand_Create_V2_HasCorrectFormat() + { + IndicatorCommandClass.IndicatorGetCommand command = + IndicatorCommandClass.IndicatorGetCommand.Create(IndicatorId.NodeIdentify); + + Assert.AreEqual(CommandClassId.Indicator, IndicatorCommandClass.IndicatorGetCommand.CommandClassId); + Assert.AreEqual((byte)IndicatorCommand.Get, IndicatorCommandClass.IndicatorGetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual((byte)IndicatorId.NodeIdentify, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void GetCommand_Create_V2_Armed() + { + IndicatorCommandClass.IndicatorGetCommand command = + IndicatorCommandClass.IndicatorGetCommand.Create(IndicatorId.Armed); + + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual((byte)IndicatorId.Armed, command.Frame.CommandParameters.Span[0]); + } + + #endregion + + #region Set Command + + [TestMethod] + public void SetCommand_Create_V1_HasCorrectFormat() + { + IndicatorCommandClass.IndicatorSetCommand command = + IndicatorCommandClass.IndicatorSetCommand.Create(0xFF); + + Assert.AreEqual(CommandClassId.Indicator, IndicatorCommandClass.IndicatorSetCommand.CommandClassId); + Assert.AreEqual((byte)IndicatorCommand.Set, IndicatorCommandClass.IndicatorSetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual(0xFF, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void SetCommand_Create_V1_Off() + { + IndicatorCommandClass.IndicatorSetCommand command = + IndicatorCommandClass.IndicatorSetCommand.Create(0x00); + + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void SetCommand_Create_V1_Level() + { + IndicatorCommandClass.IndicatorSetCommand command = + IndicatorCommandClass.IndicatorSetCommand.Create(0x32); + + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual(0x32, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void SetCommand_Create_V2_SingleObject() + { + IndicatorObject[] objects = + [ + new(IndicatorId.Armed, IndicatorPropertyId.Binary, 0xFF), + ]; + + IndicatorCommandClass.IndicatorSetCommand command = + IndicatorCommandClass.IndicatorSetCommand.Create(objects); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + // Indicator0Value(1) + Reserved|Count(1) + 1 object(3) = 5 + Assert.AreEqual(5, parameters.Length); + Assert.AreEqual(0x00, parameters[0]); // Indicator 0 Value + Assert.AreEqual(0x01, parameters[1] & 0b0001_1111); // Object count + Assert.AreEqual(0x00, parameters[1] & 0b1110_0000); // Reserved bits + Assert.AreEqual((byte)IndicatorId.Armed, parameters[2]); + Assert.AreEqual((byte)IndicatorPropertyId.Binary, parameters[3]); + Assert.AreEqual(0xFF, parameters[4]); + } + + [TestMethod] + public void SetCommand_Create_V2_MultipleObjects() + { + IndicatorObject[] objects = + [ + new(IndicatorId.NodeIdentify, IndicatorPropertyId.OnOffPeriod, 0x08), + new(IndicatorId.NodeIdentify, IndicatorPropertyId.OnOffCycles, 0x03), + new(IndicatorId.NodeIdentify, IndicatorPropertyId.OnTimeWithinOnOffPeriod, 0x06), + ]; + + IndicatorCommandClass.IndicatorSetCommand command = + IndicatorCommandClass.IndicatorSetCommand.Create(objects); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + // Indicator0Value(1) + Reserved|Count(1) + 3 objects(9) = 11 + Assert.AreEqual(11, parameters.Length); + Assert.AreEqual(0x00, parameters[0]); // Indicator 0 Value + Assert.AreEqual(0x03, parameters[1] & 0b0001_1111); // Object count + + // Object 1: NodeIdentify, OnOffPeriod, 0x08 + Assert.AreEqual((byte)IndicatorId.NodeIdentify, parameters[2]); + Assert.AreEqual((byte)IndicatorPropertyId.OnOffPeriod, parameters[3]); + Assert.AreEqual(0x08, parameters[4]); + + // Object 2: NodeIdentify, OnOffCycles, 0x03 + Assert.AreEqual((byte)IndicatorId.NodeIdentify, parameters[5]); + Assert.AreEqual((byte)IndicatorPropertyId.OnOffCycles, parameters[6]); + Assert.AreEqual(0x03, parameters[7]); + + // Object 3: NodeIdentify, OnTimeWithinOnOffPeriod, 0x06 + Assert.AreEqual((byte)IndicatorId.NodeIdentify, parameters[8]); + Assert.AreEqual((byte)IndicatorPropertyId.OnTimeWithinOnOffPeriod, parameters[9]); + Assert.AreEqual(0x06, parameters[10]); + } + + [TestMethod] + public void SetCommand_Create_IdentifyPattern() + { + // Verify the exact Identify pattern from spec Table 6.45 + IndicatorObject[] objects = + [ + new(IndicatorId.NodeIdentify, IndicatorPropertyId.OnOffPeriod, 0x08), + new(IndicatorId.NodeIdentify, IndicatorPropertyId.OnOffCycles, 0x03), + new(IndicatorId.NodeIdentify, IndicatorPropertyId.OnTimeWithinOnOffPeriod, 0x06), + ]; + + IndicatorCommandClass.IndicatorSetCommand command = + IndicatorCommandClass.IndicatorSetCommand.Create(objects); + + // Verify the exact wire bytes match spec Table 6.45 + ReadOnlySpan data = command.Frame.Data.Span; + Assert.AreEqual(0x87, data[0]); // CC = COMMAND_CLASS_INDICATOR + Assert.AreEqual(0x01, data[1]); // Command = INDICATOR_SET + Assert.AreEqual(0x00, data[2]); // Indicator 0 Value = 0x00 + Assert.AreEqual(0x03, data[3]); // Object count = 3 + Assert.AreEqual(0x50, data[4]); // Indicator ID 1 = Node Identify + Assert.AreEqual(0x03, data[5]); // Property ID 1 = On/Off Period + Assert.AreEqual(0x08, data[6]); // Value 1 = 0x08 + Assert.AreEqual(0x50, data[7]); // Indicator ID 2 = Node Identify + Assert.AreEqual(0x04, data[8]); // Property ID 2 = On/Off Cycles + Assert.AreEqual(0x03, data[9]); // Value 2 = 0x03 + Assert.AreEqual(0x50, data[10]); // Indicator ID 3 = Node Identify + Assert.AreEqual(0x05, data[11]); // Property ID 3 = On time within On/Off period + Assert.AreEqual(0x06, data[12]); // Value 3 = 0x06 + } + + #endregion + + #region Report Parse + + [TestMethod] + public void Report_Parse_V1_On() + { + byte[] data = [0x87, 0x03, 0xFF]; + CommandClassFrame frame = new(data); + + IndicatorReport report = IndicatorCommandClass.IndicatorReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0xFF, report.Indicator0Value); + Assert.IsEmpty(report.Objects); + } + + [TestMethod] + public void Report_Parse_V1_Off() + { + byte[] data = [0x87, 0x03, 0x00]; + CommandClassFrame frame = new(data); + + IndicatorReport report = IndicatorCommandClass.IndicatorReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x00, report.Indicator0Value); + Assert.IsEmpty(report.Objects); + } + + [TestMethod] + public void Report_Parse_V1_MidLevel() + { + byte[] data = [0x87, 0x03, 0x32]; + CommandClassFrame frame = new(data); + + IndicatorReport report = IndicatorCommandClass.IndicatorReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x32, report.Indicator0Value); + Assert.IsEmpty(report.Objects); + } + + [TestMethod] + public void Report_Parse_V2_WithObjects() + { + byte[] data = + [ + 0x87, 0x03, // CC + Command + 0x00, // Indicator 0 Value + 0x02, // Object count = 2 + 0x01, 0x01, 0x63, // Indicator=Armed, Property=Multilevel, Value=99 + 0x01, 0x02, 0xFF, // Indicator=Armed, Property=Binary, Value=0xFF + ]; + CommandClassFrame frame = new(data); + + IndicatorReport report = IndicatorCommandClass.IndicatorReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x00, report.Indicator0Value); + Assert.HasCount(2, report.Objects); + + Assert.AreEqual(IndicatorId.Armed, report.Objects[0].IndicatorId); + Assert.AreEqual(IndicatorPropertyId.Multilevel, report.Objects[0].PropertyId); + Assert.AreEqual((byte)0x63, report.Objects[0].Value); + + Assert.AreEqual(IndicatorId.Armed, report.Objects[1].IndicatorId); + Assert.AreEqual(IndicatorPropertyId.Binary, report.Objects[1].PropertyId); + Assert.AreEqual((byte)0xFF, report.Objects[1].Value); + } + + [TestMethod] + public void Report_Parse_V2_ZeroObjectCount() + { + // V2 report with object count = 0 (just the Indicator 0 Value field is relevant) + byte[] data = [0x87, 0x03, 0x63, 0x00]; + CommandClassFrame frame = new(data); + + IndicatorReport report = IndicatorCommandClass.IndicatorReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x63, report.Indicator0Value); + Assert.IsEmpty(report.Objects); + } + + [TestMethod] + public void Report_Parse_TooShort_Throws() + { + byte[] data = [0x87, 0x03]; // No parameters + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => IndicatorCommandClass.IndicatorReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void Report_Parse_V2_ObjectCountExceedsData_Throws() + { + // Declares 2 objects but only has data for 1 + byte[] data = + [ + 0x87, 0x03, + 0x00, // Indicator 0 Value + 0x02, // Object count = 2 + 0x01, 0x01, 0x63, // Only 1 object provided + ]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => IndicatorCommandClass.IndicatorReportCommand.Parse(frame, NullLogger.Instance)); + } + + #endregion + + #region Report Create (Bidirectional) + + [TestMethod] + public void ReportCommand_Create_V1() + { + IndicatorCommandClass.IndicatorReportCommand command = + IndicatorCommandClass.IndicatorReportCommand.Create(0xFF); + + Assert.AreEqual(1, command.Frame.CommandParameters.Length); + Assert.AreEqual(0xFF, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void ReportCommand_Create_V2_WithObjects() + { + IndicatorObject[] objects = + [ + new(IndicatorId.NodeIdentify, IndicatorPropertyId.Binary, 0xFF), + ]; + + IndicatorCommandClass.IndicatorReportCommand command = + IndicatorCommandClass.IndicatorReportCommand.Create(0x00, objects); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual(5, parameters.Length); + Assert.AreEqual(0x00, parameters[0]); // Indicator 0 Value + Assert.AreEqual(0x01, parameters[1]); // Object count + Assert.AreEqual((byte)IndicatorId.NodeIdentify, parameters[2]); + Assert.AreEqual((byte)IndicatorPropertyId.Binary, parameters[3]); + Assert.AreEqual(0xFF, parameters[4]); + } + + #endregion + + #region Report Round-trip + + [TestMethod] + public void Report_RoundTrip_V1() + { + IndicatorCommandClass.IndicatorReportCommand command = + IndicatorCommandClass.IndicatorReportCommand.Create(0x63); + + IndicatorReport report = IndicatorCommandClass.IndicatorReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x63, report.Indicator0Value); + Assert.IsEmpty(report.Objects); + } + + [TestMethod] + public void Report_RoundTrip_V2() + { + IndicatorObject[] objects = + [ + new(IndicatorId.Armed, IndicatorPropertyId.Multilevel, 0x32), + new(IndicatorId.Armed, IndicatorPropertyId.Binary, 0xFF), + ]; + + IndicatorCommandClass.IndicatorReportCommand command = + IndicatorCommandClass.IndicatorReportCommand.Create(0x00, objects); + + IndicatorReport report = IndicatorCommandClass.IndicatorReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x00, report.Indicator0Value); + Assert.HasCount(2, report.Objects); + Assert.AreEqual(IndicatorId.Armed, report.Objects[0].IndicatorId); + Assert.AreEqual(IndicatorPropertyId.Multilevel, report.Objects[0].PropertyId); + Assert.AreEqual((byte)0x32, report.Objects[0].Value); + Assert.AreEqual(IndicatorId.Armed, report.Objects[1].IndicatorId); + Assert.AreEqual(IndicatorPropertyId.Binary, report.Objects[1].PropertyId); + Assert.AreEqual((byte)0xFF, report.Objects[1].Value); + } + + #endregion +} diff --git a/src/ZWave.CommandClasses.Tests/IndicatorCommandClassTests.Supported.cs b/src/ZWave.CommandClasses.Tests/IndicatorCommandClassTests.Supported.cs new file mode 100644 index 0000000..1bb35f7 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/IndicatorCommandClassTests.Supported.cs @@ -0,0 +1,284 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class IndicatorCommandClassTests +{ + #region Supported Get Command + + [TestMethod] + public void SupportedGetCommand_Create_HasCorrectFormat() + { + IndicatorCommandClass.IndicatorSupportedGetCommand command = + IndicatorCommandClass.IndicatorSupportedGetCommand.Create(IndicatorId.NodeIdentify); + + Assert.AreEqual(CommandClassId.Indicator, IndicatorCommandClass.IndicatorSupportedGetCommand.CommandClassId); + Assert.AreEqual((byte)IndicatorCommand.SupportedGet, IndicatorCommandClass.IndicatorSupportedGetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual((byte)IndicatorId.NodeIdentify, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void SupportedGetCommand_Create_Discovery() + { + // Indicator ID 0x00 is used to discover the first supported indicator. + IndicatorCommandClass.IndicatorSupportedGetCommand command = + IndicatorCommandClass.IndicatorSupportedGetCommand.Create(0); + + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[0]); + } + + #endregion + + #region Supported Report Parse + + [TestMethod] + public void SupportedReport_Parse_WithProperties() + { + byte[] data = + [ + 0x87, 0x05, // CC + Command + 0x50, // Indicator ID = Node Identify + 0xF0, // Next Indicator ID = Buzzer + 0x01, // Bitmask length = 1 byte + 0b0001_1110, // Bits 1-4 set = Multilevel, Binary, OnOffPeriod, OnOffCycles + ]; + CommandClassFrame frame = new(data); + + (IndicatorId nextIndicatorId, IReadOnlySet propertyIds) = + IndicatorCommandClass.IndicatorSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(IndicatorId.Buzzer, nextIndicatorId); + Assert.HasCount(4, propertyIds); + Assert.Contains(IndicatorPropertyId.Multilevel, propertyIds); + Assert.Contains(IndicatorPropertyId.Binary, propertyIds); + Assert.Contains(IndicatorPropertyId.OnOffPeriod, propertyIds); + Assert.Contains(IndicatorPropertyId.OnOffCycles, propertyIds); + } + + [TestMethod] + public void SupportedReport_Parse_NoMoreIndicators() + { + byte[] data = + [ + 0x87, 0x05, + 0x01, // Indicator ID = Armed + 0x00, // Next Indicator ID = 0x00 (none) + 0x01, // Bitmask length = 1 + 0b0000_0110, // Bits 1,2 = Multilevel, Binary + ]; + CommandClassFrame frame = new(data); + + (IndicatorId nextIndicatorId, IReadOnlySet propertyIds) = + IndicatorCommandClass.IndicatorSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((IndicatorId)0x00, nextIndicatorId); + Assert.HasCount(2, propertyIds); + Assert.Contains(IndicatorPropertyId.Multilevel, propertyIds); + Assert.Contains(IndicatorPropertyId.Binary, propertyIds); + } + + [TestMethod] + public void SupportedReport_Parse_UnsupportedIndicator() + { + // Per spec: if unsupported Indicator ID, all fields set to 0x00. + byte[] data = + [ + 0x87, 0x05, + 0x00, // Indicator ID = 0x00 + 0x00, // Next Indicator ID = 0x00 + 0x00, // Bitmask length = 0 + ]; + CommandClassFrame frame = new(data); + + (IndicatorId nextIndicatorId, IReadOnlySet propertyIds) = + IndicatorCommandClass.IndicatorSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual((IndicatorId)0x00, nextIndicatorId); + Assert.IsEmpty(propertyIds); + } + + [TestMethod] + public void SupportedReport_Parse_MultiByteBitmask() + { + // Bitmask spanning 2 bytes to include property IDs > 7 + byte[] data = + [ + 0x87, 0x05, + 0x50, // Indicator ID = Node Identify + 0x00, // Next Indicator ID = 0x00 + 0x02, // Bitmask length = 2 bytes + 0b0001_1110, // Byte 0: bits 1-4 = Multilevel(1), Binary(2), OnOffPeriod(3), OnOffCycles(4) + 0b0000_0010, // Byte 1: bit 1 (= property 9) = SoundLevel + ]; + CommandClassFrame frame = new(data); + + (_, IReadOnlySet propertyIds) = + IndicatorCommandClass.IndicatorSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.HasCount(5, propertyIds); + Assert.Contains(IndicatorPropertyId.Multilevel, propertyIds); + Assert.Contains(IndicatorPropertyId.Binary, propertyIds); + Assert.Contains(IndicatorPropertyId.OnOffPeriod, propertyIds); + Assert.Contains(IndicatorPropertyId.OnOffCycles, propertyIds); + Assert.Contains(IndicatorPropertyId.SoundLevel, propertyIds); + } + + [TestMethod] + public void SupportedReport_Parse_ReservedBit0Ignored() + { + // Bit 0 in the first bitmask byte is reserved and MUST be set to zero per spec. + // Even if set, it should not produce a property ID. + byte[] data = + [ + 0x87, 0x05, + 0x50, // Indicator ID = Node Identify + 0x00, // Next Indicator ID = 0x00 + 0x01, // Bitmask length = 1 + 0b0000_0111, // Bit 0 (reserved) + bits 1,2 = Multilevel, Binary + ]; + CommandClassFrame frame = new(data); + + (_, IReadOnlySet propertyIds) = + IndicatorCommandClass.IndicatorSupportedReportCommand.Parse(frame, NullLogger.Instance); + + // Bit 0 is skipped due to startBit: 1 in ParseBitMask + Assert.HasCount(2, propertyIds); + Assert.Contains(IndicatorPropertyId.Multilevel, propertyIds); + Assert.Contains(IndicatorPropertyId.Binary, propertyIds); + } + + [TestMethod] + public void SupportedReport_Parse_TooShort_Throws() + { + byte[] data = [0x87, 0x05, 0x50]; // Only 1 parameter byte (need at least 3) + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly( + () => IndicatorCommandClass.IndicatorSupportedReportCommand.Parse(frame, NullLogger.Instance)); + } + + #endregion + + #region Supported Report Create (Bidirectional) + + [TestMethod] + public void SupportedReportCommand_Create_HasCorrectFormat() + { + HashSet properties = + [ + IndicatorPropertyId.Multilevel, + IndicatorPropertyId.Binary, + ]; + + IndicatorCommandClass.IndicatorSupportedReportCommand command = + IndicatorCommandClass.IndicatorSupportedReportCommand.Create( + IndicatorId.NodeIdentify, + IndicatorId.Buzzer, + properties); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual((byte)IndicatorId.NodeIdentify, parameters[0]); + Assert.AreEqual((byte)IndicatorId.Buzzer, parameters[1]); + int bitmaskLength = parameters[2] & 0b0001_1111; + Assert.AreEqual(1, bitmaskLength); + // Bits 1 and 2 should be set for Multilevel(1) and Binary(2) + Assert.AreEqual(0b0000_0110, parameters[3]); + } + + [TestMethod] + public void SupportedReportCommand_Create_EmptyProperties() + { + HashSet properties = []; + + IndicatorCommandClass.IndicatorSupportedReportCommand command = + IndicatorCommandClass.IndicatorSupportedReportCommand.Create( + (IndicatorId)0x00, + (IndicatorId)0x00, + properties); + + ReadOnlySpan parameters = command.Frame.CommandParameters.Span; + Assert.AreEqual(0x00, parameters[0]); // Indicator ID + Assert.AreEqual(0x00, parameters[1]); // Next Indicator ID + Assert.AreEqual(0x00, parameters[2] & 0b0001_1111); // Bitmask length = 0 + } + + #endregion + + #region Supported Report Round-trip + + [TestMethod] + public void SupportedReport_RoundTrip() + { + HashSet properties = + [ + IndicatorPropertyId.Multilevel, + IndicatorPropertyId.Binary, + IndicatorPropertyId.OnOffPeriod, + IndicatorPropertyId.OnOffCycles, + IndicatorPropertyId.OnTimeWithinOnOffPeriod, + ]; + + IndicatorCommandClass.IndicatorSupportedReportCommand command = + IndicatorCommandClass.IndicatorSupportedReportCommand.Create( + IndicatorId.NodeIdentify, + IndicatorId.Buzzer, + properties); + + (IndicatorId nextIndicatorId, IReadOnlySet propertyIds) = + IndicatorCommandClass.IndicatorSupportedReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual(IndicatorId.Buzzer, nextIndicatorId); + Assert.HasCount(5, propertyIds); + Assert.Contains(IndicatorPropertyId.Multilevel, propertyIds); + Assert.Contains(IndicatorPropertyId.Binary, propertyIds); + Assert.Contains(IndicatorPropertyId.OnOffPeriod, propertyIds); + Assert.Contains(IndicatorPropertyId.OnOffCycles, propertyIds); + Assert.Contains(IndicatorPropertyId.OnTimeWithinOnOffPeriod, propertyIds); + } + + [TestMethod] + public void SupportedReport_RoundTrip_WithSoundLevel() + { + HashSet properties = + [ + IndicatorPropertyId.Multilevel, + IndicatorPropertyId.SoundLevel, + ]; + + IndicatorCommandClass.IndicatorSupportedReportCommand command = + IndicatorCommandClass.IndicatorSupportedReportCommand.Create( + IndicatorId.Buzzer, + (IndicatorId)0x00, + properties); + + (IndicatorId nextIndicatorId, IReadOnlySet propertyIds) = + IndicatorCommandClass.IndicatorSupportedReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual((IndicatorId)0x00, nextIndicatorId); + Assert.HasCount(2, propertyIds); + Assert.Contains(IndicatorPropertyId.Multilevel, propertyIds); + Assert.Contains(IndicatorPropertyId.SoundLevel, propertyIds); + } + + [TestMethod] + public void SupportedReport_RoundTrip_Empty() + { + HashSet properties = []; + + IndicatorCommandClass.IndicatorSupportedReportCommand command = + IndicatorCommandClass.IndicatorSupportedReportCommand.Create( + (IndicatorId)0x00, + (IndicatorId)0x00, + properties); + + (IndicatorId nextIndicatorId, IReadOnlySet propertyIds) = + IndicatorCommandClass.IndicatorSupportedReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual((IndicatorId)0x00, nextIndicatorId); + Assert.IsEmpty(propertyIds); + } + + #endregion +} diff --git a/src/ZWave.CommandClasses.Tests/IndicatorCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/IndicatorCommandClassTests.cs new file mode 100644 index 0000000..ff7b27c --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/IndicatorCommandClassTests.cs @@ -0,0 +1,6 @@ +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public partial class IndicatorCommandClassTests +{ +} diff --git a/src/ZWave.CommandClasses/IndicatorCommandClass.Report.cs b/src/ZWave.CommandClasses/IndicatorCommandClass.Report.cs new file mode 100644 index 0000000..0fbefa8 --- /dev/null +++ b/src/ZWave.CommandClasses/IndicatorCommandClass.Report.cs @@ -0,0 +1,277 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class IndicatorCommandClass +{ + private readonly Dictionary _indicators = []; + + /// + /// Gets the last indicator report received from the device (version 1 compatibility). + /// + public IndicatorReport? LastReport { get; private set; } + + /// + /// Gets the indicator state per indicator ID (version 2+). + /// + public IReadOnlyDictionary Indicators => _indicators; + + /// + /// Event raised when an Indicator Report is received, both solicited and unsolicited. + /// + public event Action? OnIndicatorReportReceived; + + /// + /// Request the state of the indicator (version 1). + /// + public async Task GetAsync(CancellationToken cancellationToken) + { + IndicatorGetCommand command = IndicatorGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + IndicatorReport report = IndicatorReportCommand.Parse(reportFrame, Logger); + ApplyReport(report); + return report; + } + + /// + /// Request the state of a specific indicator (version 2+). + /// + /// The indicator resource to query. + /// The cancellation token. + public async Task GetAsync(IndicatorId indicatorId, CancellationToken cancellationToken) + { + IndicatorGetCommand command = IndicatorGetCommand.Create(indicatorId); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + IndicatorReport report = IndicatorReportCommand.Parse(reportFrame, Logger); + ApplyReport(report); + return report; + } + + /// + /// Set the indicator state (version 1 format). + /// + /// + /// The indicator value: 0x00=off, 0x01-0x63=on, 0xFF=on. + /// + /// The cancellation token. + public async Task SetAsync(byte value, CancellationToken cancellationToken) + { + IndicatorSetCommand command = IndicatorSetCommand.Create(value); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// + /// Set one or more indicator resources (version 2+ format). + /// + /// The indicator objects to set. + /// The cancellation token. + public async Task SetAsync(IReadOnlyList objects, CancellationToken cancellationToken) + { + IndicatorSetCommand command = IndicatorSetCommand.Create(objects); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// + /// Instruct the device to identify itself by blinking/beeping. + /// + /// + /// + /// Per spec CL:0087.01.31.02.1, this sets Indicator 0x50 (Node Identify) to blink + /// with an 800ms period (600ms ON, 200ms OFF) for 3 cycles. + /// + /// + /// Requires version 3 or newer on both the controlling and supporting node. + /// + /// + /// The cancellation token. + public async Task IdentifyAsync(CancellationToken cancellationToken) + { + // Per spec Table 6.45: Indicator::Identify + IndicatorObject[] objects = + [ + new(IndicatorId.NodeIdentify, IndicatorPropertyId.OnOffPeriod, 0x08), + new(IndicatorId.NodeIdentify, IndicatorPropertyId.OnOffCycles, 0x03), + new(IndicatorId.NodeIdentify, IndicatorPropertyId.OnTimeWithinOnOffPeriod, 0x06), + ]; + await SetAsync(objects, cancellationToken).ConfigureAwait(false); + } + + private void ApplyReport(IndicatorReport report) + { + LastReport = report; + + // Track per-indicator state for V2+ reports. + if (report.Objects.Count > 0) + { + IndicatorId indicatorId = report.Objects[0].IndicatorId; + _indicators[indicatorId] = report; + } + + OnIndicatorReportReceived?.Invoke(report); + } + + internal readonly struct IndicatorGetCommand : ICommand + { + public IndicatorGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Indicator; + + public static byte CommandId => (byte)IndicatorCommand.Get; + + public CommandClassFrame Frame { get; } + + /// + /// Create a version 1 Get command (no indicator ID). + /// + public static IndicatorGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new IndicatorGetCommand(frame); + } + + /// + /// Create a version 2+ Get command for a specific indicator. + /// + public static IndicatorGetCommand Create(IndicatorId indicatorId) + { + ReadOnlySpan commandParameters = [(byte)indicatorId]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new IndicatorGetCommand(frame); + } + } + + internal readonly struct IndicatorSetCommand : ICommand + { + public IndicatorSetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Indicator; + + public static byte CommandId => (byte)IndicatorCommand.Set; + + public CommandClassFrame Frame { get; } + + /// + /// Create a version 1 Set command with a single indicator value. + /// + public static IndicatorSetCommand Create(byte value) + { + ReadOnlySpan commandParameters = [value]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new IndicatorSetCommand(frame); + } + + /// + /// Create a version 2+ Set command with indicator objects. + /// + public static IndicatorSetCommand Create(IReadOnlyList objects) + { + // Format: Indicator0Value(1) + Reserved|ObjectCount(1) + [IndicatorID + PropertyID + Value](3*N) + Span commandParameters = stackalloc byte[2 + (3 * objects.Count)]; + commandParameters[0] = 0x00; // Indicator 0 Value = 0 when objects are present + commandParameters[1] = (byte)(objects.Count & 0b0001_1111); + + for (int i = 0; i < objects.Count; i++) + { + int offset = 2 + (i * 3); + commandParameters[offset] = (byte)objects[i].IndicatorId; + commandParameters[offset + 1] = (byte)objects[i].PropertyId; + commandParameters[offset + 2] = objects[i].Value; + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new IndicatorSetCommand(frame); + } + } + + internal readonly struct IndicatorReportCommand : ICommand + { + public IndicatorReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Indicator; + + public static byte CommandId => (byte)IndicatorCommand.Report; + + public CommandClassFrame Frame { get; } + + /// + /// Create a version 1 Report command. + /// + public static IndicatorReportCommand Create(byte indicator0Value) + { + ReadOnlySpan commandParameters = [indicator0Value]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new IndicatorReportCommand(frame); + } + + /// + /// Create a version 2+ Report command with indicator objects. + /// + public static IndicatorReportCommand Create(byte indicator0Value, IReadOnlyList objects) + { + Span commandParameters = stackalloc byte[2 + (3 * objects.Count)]; + commandParameters[0] = indicator0Value; + commandParameters[1] = (byte)(objects.Count & 0b0001_1111); + + for (int i = 0; i < objects.Count; i++) + { + int offset = 2 + (i * 3); + commandParameters[offset] = (byte)objects[i].IndicatorId; + commandParameters[offset + 1] = (byte)objects[i].PropertyId; + commandParameters[offset + 2] = objects[i].Value; + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new IndicatorReportCommand(frame); + } + + public static IndicatorReport Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning("Indicator Report frame is too short ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Indicator Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + byte indicator0Value = span[0]; + + List objects = []; + if (span.Length >= 2) + { + int objectCount = span[1] & 0b0001_1111; + int expectedLength = 2 + (3 * objectCount); + if (span.Length < expectedLength) + { + logger.LogWarning( + "Indicator Report frame has {ObjectCount} objects but only {Length} bytes (expected {ExpectedLength})", + objectCount, + span.Length, + expectedLength); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Indicator Report frame is too short for the declared object count"); + } + + for (int i = 0; i < objectCount; i++) + { + int offset = 2 + (i * 3); + IndicatorId indicatorId = (IndicatorId)span[offset]; + IndicatorPropertyId propertyId = (IndicatorPropertyId)span[offset + 1]; + byte value = span[offset + 2]; + objects.Add(new IndicatorObject(indicatorId, propertyId, value)); + } + } + + return new IndicatorReport(indicator0Value, objects); + } + } +} diff --git a/src/ZWave.CommandClasses/IndicatorCommandClass.Supported.cs b/src/ZWave.CommandClasses/IndicatorCommandClass.Supported.cs new file mode 100644 index 0000000..3798c27 --- /dev/null +++ b/src/ZWave.CommandClasses/IndicatorCommandClass.Supported.cs @@ -0,0 +1,153 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class IndicatorCommandClass +{ + private readonly Dictionary> _supportedIndicators = []; + + /// + /// Gets the supported property IDs for each discovered indicator. + /// + public IReadOnlyDictionary> SupportedIndicators => _supportedIndicators; + + /// + /// Request the supported properties of a specific indicator. + /// + /// + /// + /// To discover all supported indicators, start with indicator ID 0x00. The interview + /// performs this discovery automatically by following the internal next-indicator chain. + /// + /// + /// + /// The indicator resource to query. Set to 0x00 to discover the first supported indicator. + /// + /// The cancellation token. + public async Task> GetSupportedAsync( + IndicatorId indicatorId, + CancellationToken cancellationToken) + { + IndicatorSupportedGetCommand command = IndicatorSupportedGetCommand.Create(indicatorId); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync( + predicate: frame => frame.CommandParameters.Length >= 1 + && (IndicatorId)frame.CommandParameters.Span[0] == indicatorId, + cancellationToken).ConfigureAwait(false); + (IndicatorId _, IReadOnlySet propertyIds) = + IndicatorSupportedReportCommand.Parse(reportFrame, Logger); + + if (propertyIds.Count > 0) + { + _supportedIndicators[indicatorId] = propertyIds; + } + + return propertyIds; + } + + internal readonly struct IndicatorSupportedGetCommand : ICommand + { + public IndicatorSupportedGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Indicator; + + public static byte CommandId => (byte)IndicatorCommand.SupportedGet; + + public CommandClassFrame Frame { get; } + + public static IndicatorSupportedGetCommand Create(IndicatorId indicatorId) + { + ReadOnlySpan commandParameters = [(byte)indicatorId]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new IndicatorSupportedGetCommand(frame); + } + } + + internal readonly struct IndicatorSupportedReportCommand : ICommand + { + public IndicatorSupportedReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Indicator; + + public static byte CommandId => (byte)IndicatorCommand.SupportedReport; + + public CommandClassFrame Frame { get; } + + /// + /// Create a Supported Report command. + /// + public static IndicatorSupportedReportCommand Create( + IndicatorId indicatorId, + IndicatorId nextIndicatorId, + IReadOnlySet supportedPropertyIds) + { + // Determine the minimum bitmask length needed. + int maxPropertyId = 0; + foreach (IndicatorPropertyId propertyId in supportedPropertyIds) + { + if ((byte)propertyId > maxPropertyId) + { + maxPropertyId = (byte)propertyId; + } + } + + int bitmaskLength = maxPropertyId > 0 + ? ((maxPropertyId / 8) + 1) + : 0; + + // Format: IndicatorID(1) + NextIndicatorID(1) + Reserved|BitmaskLength(1) + Bitmask(N) + Span commandParameters = stackalloc byte[3 + bitmaskLength]; + commandParameters[0] = (byte)indicatorId; + commandParameters[1] = (byte)nextIndicatorId; + commandParameters[2] = (byte)(bitmaskLength & 0b0001_1111); + + foreach (IndicatorPropertyId propertyId in supportedPropertyIds) + { + int bitIndex = (byte)propertyId; + commandParameters[3 + (bitIndex / 8)] |= (byte)(1 << (bitIndex % 8)); + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters); + return new IndicatorSupportedReportCommand(frame); + } + + public static (IndicatorId NextIndicatorId, IReadOnlySet SupportedPropertyIds) Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 3) + { + logger.LogWarning( + "Indicator Supported Report frame is too short ({Length} bytes)", + frame.CommandParameters.Length); + ZWaveException.Throw( + ZWaveErrorCode.InvalidPayload, + "Indicator Supported Report frame is too short"); + } + + ReadOnlySpan span = frame.CommandParameters.Span; + + IndicatorId nextIndicatorId = (IndicatorId)span[1]; + int bitmaskLength = span[2] & 0b0001_1111; + + HashSet supportedPropertyIds; + if (bitmaskLength > 0 && span.Length >= 3 + bitmaskLength) + { + // Per spec: bit 0 in bitmask 1 is reserved and must be set to zero. + supportedPropertyIds = BitMaskHelper.ParseBitMask( + span.Slice(3, bitmaskLength), + startBit: 1); + } + else + { + supportedPropertyIds = []; + } + + return (nextIndicatorId, supportedPropertyIds); + } + } +} diff --git a/src/ZWave.CommandClasses/IndicatorCommandClass.cs b/src/ZWave.CommandClasses/IndicatorCommandClass.cs new file mode 100644 index 0000000..0248001 --- /dev/null +++ b/src/ZWave.CommandClasses/IndicatorCommandClass.cs @@ -0,0 +1,662 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Identifies an indicator resource on a supporting node. +/// +/// +/// Values are defined in the "Indicator Command Class, list of assigned indicators and Property IDs" document. +/// +public enum IndicatorId : byte +{ + /// + /// Indicates that the alarm is armed. + /// + Armed = 0x01, + + /// + /// Indicates that the alarm is disarmed. + /// + NotArmed = 0x02, + + /// + /// Indicates that the device is ready. + /// + Ready = 0x03, + + /// + /// Indicates a general error. + /// + Fault = 0x04, + + /// + /// Indicates that the device is temporarily busy. + /// + Busy = 0x05, + + /// + /// Signals that the device is waiting for an ID. + /// + EnterId = 0x06, + + /// + /// Signals that the device is waiting for a PIN code. + /// + EnterPin = 0x07, + + /// + /// Indicates that the entered code is accepted. + /// + CodeAccepted = 0x08, + + /// + /// Indicates that the entered code is not accepted. + /// + CodeNotAccepted = 0x09, + + /// + /// Indicates that the alarm is armed in stay mode. + /// + ArmedStay = 0x0A, + + /// + /// Indicates that the alarm is armed in away mode. + /// + ArmedAway = 0x0B, + + /// + /// Indicates that the alarm is triggered with no specific reason. + /// + Alarming = 0x0C, + + /// + /// Indicates that the alarm is triggered due to a burglar event. + /// + AlarmingBurglar = 0x0D, + + /// + /// Indicates that the alarm is triggered due to a fire alarm event. + /// + AlarmingSmokeFire = 0x0E, + + /// + /// Indicates that the alarm is triggered due to a carbon monoxide event. + /// + AlarmingCarbonMonoxide = 0x0F, + + /// + /// Indicates that the device expects a bypass challenge code. + /// + BypassChallenge = 0x10, + + /// + /// Indicates that the alarm is about to be activated unless disarmed. + /// + EntryDelay = 0x11, + + /// + /// Indicates that the alarm will be active after the exit delay. + /// + ExitDelay = 0x12, + + /// + /// Indicates that the alarm is triggered due to a medical emergency. + /// + AlarmingMedical = 0x13, + + /// + /// Indicates that the alarm is triggered due to a freeze warning. + /// + AlarmingFreezeWarning = 0x14, + + /// + /// Indicates that the alarm is triggered due to a water leak. + /// + AlarmingWaterLeak = 0x15, + + /// + /// Indicates that the alarm is triggered due to a panic alarm. + /// + AlarmingPanic = 0x16, + + /// + /// Indicates that alarm zone 1 is armed. + /// + Zone1Armed = 0x20, + + /// + /// Indicates that alarm zone 2 is armed. + /// + Zone2Armed = 0x21, + + /// + /// Indicates that alarm zone 3 is armed. + /// + Zone3Armed = 0x22, + + /// + /// Indicates that alarm zone 4 is armed. + /// + Zone4Armed = 0x23, + + /// + /// Indicates that alarm zone 5 is armed. + /// + Zone5Armed = 0x24, + + /// + /// Indicates that alarm zone 6 is armed. + /// + Zone6Armed = 0x25, + + /// + /// Indicates that alarm zone 7 is armed. + /// + Zone7Armed = 0x26, + + /// + /// Indicates that alarm zone 8 is armed. + /// + Zone8Armed = 0x27, + + /// + /// LCD backlight indicator. + /// + LcdBacklight = 0x30, + + /// + /// Button backlight for letters. + /// + ButtonBacklightLetters = 0x40, + + /// + /// Button backlight for digits. + /// + ButtonBacklightDigits = 0x41, + + /// + /// Button backlight for command buttons. + /// + ButtonBacklightCommand = 0x42, + + /// + /// Indication for button 1. + /// + Button1Indication = 0x43, + + /// + /// Indication for button 2. + /// + Button2Indication = 0x44, + + /// + /// Indication for button 3. + /// + Button3Indication = 0x45, + + /// + /// Indication for button 4. + /// + Button4Indication = 0x46, + + /// + /// Indication for button 5. + /// + Button5Indication = 0x47, + + /// + /// Indication for button 6. + /// + Button6Indication = 0x48, + + /// + /// Indication for button 7. + /// + Button7Indication = 0x49, + + /// + /// Indication for button 8. + /// + Button8Indication = 0x4A, + + /// + /// Indication for button 9. + /// + Button9Indication = 0x4B, + + /// + /// Indication for button 10. + /// + Button10Indication = 0x4C, + + /// + /// Indication for button 11. + /// + Button11Indication = 0x4D, + + /// + /// Indication for button 12. + /// + Button12Indication = 0x4E, + + /// + /// Used to identify the node (e.g. make an LED blink). + /// + NodeIdentify = 0x50, + + /// + /// Generic event sound notification 1. + /// + GenericEventSoundNotification1 = 0x60, + + /// + /// Generic event sound notification 2. + /// + GenericEventSoundNotification2 = 0x61, + + /// + /// Generic event sound notification 3. + /// + GenericEventSoundNotification3 = 0x62, + + /// + /// Generic event sound notification 4. + /// + GenericEventSoundNotification4 = 0x63, + + /// + /// Generic event sound notification 5. + /// + GenericEventSoundNotification5 = 0x64, + + /// + /// Generic event sound notification 6. + /// + GenericEventSoundNotification6 = 0x65, + + /// + /// Generic event sound notification 7. + /// + GenericEventSoundNotification7 = 0x66, + + /// + /// Generic event sound notification 8. + /// + GenericEventSoundNotification8 = 0x67, + + /// + /// Generic event sound notification 9. + /// + GenericEventSoundNotification9 = 0x68, + + /// + /// Generic event sound notification 10. + /// + GenericEventSoundNotification10 = 0x69, + + /// + /// Generic event sound notification 11. + /// + GenericEventSoundNotification11 = 0x6A, + + /// + /// Generic event sound notification 12. + /// + GenericEventSoundNotification12 = 0x6B, + + /// + /// Generic event sound notification 13. + /// + GenericEventSoundNotification13 = 0x6C, + + /// + /// Generic event sound notification 14. + /// + GenericEventSoundNotification14 = 0x6D, + + /// + /// Generic event sound notification 15. + /// + GenericEventSoundNotification15 = 0x6E, + + /// + /// Generic event sound notification 16. + /// + GenericEventSoundNotification16 = 0x6F, + + /// + /// Generic event sound notification 17. + /// + GenericEventSoundNotification17 = 0x70, + + /// + /// Generic event sound notification 18. + /// + GenericEventSoundNotification18 = 0x71, + + /// + /// Generic event sound notification 19. + /// + GenericEventSoundNotification19 = 0x72, + + /// + /// Generic event sound notification 20. + /// + GenericEventSoundNotification20 = 0x73, + + /// + /// Generic event sound notification 21. + /// + GenericEventSoundNotification21 = 0x74, + + /// + /// Generic event sound notification 22. + /// + GenericEventSoundNotification22 = 0x75, + + /// + /// Generic event sound notification 23. + /// + GenericEventSoundNotification23 = 0x76, + + /// + /// Generic event sound notification 24. + /// + GenericEventSoundNotification24 = 0x77, + + /// + /// Generic event sound notification 25. + /// + GenericEventSoundNotification25 = 0x78, + + /// + /// Generic event sound notification 26. + /// + GenericEventSoundNotification26 = 0x79, + + /// + /// Generic event sound notification 27. + /// + GenericEventSoundNotification27 = 0x7A, + + /// + /// Generic event sound notification 28. + /// + GenericEventSoundNotification28 = 0x7B, + + /// + /// Generic event sound notification 29. + /// + GenericEventSoundNotification29 = 0x7C, + + /// + /// Generic event sound notification 30. + /// + GenericEventSoundNotification30 = 0x7D, + + /// + /// Generic event sound notification 31. + /// + GenericEventSoundNotification31 = 0x7E, + + /// + /// Generic event sound notification 32. + /// + GenericEventSoundNotification32 = 0x7F, + + /// + /// Buzzer indicator. + /// + Buzzer = 0xF0, +} + +/// +/// Identifies a property of an indicator resource. +/// +/// +/// Values are defined in the "Indicator Command Class, list of assigned indicators and Property IDs" document. +/// +public enum IndicatorPropertyId : byte +{ + /// + /// Multilevel indicator (light or sound level). + /// Values: 0x00=OFF, 0x01-0x63=lowest to 100%, 0xFF=restore most recent level. + /// + Multilevel = 0x01, + + /// + /// Binary indicator (on or off). + /// Values: 0x00=OFF, 0x01-0x63=ON, 0xFF=ON. + /// + Binary = 0x02, + + /// + /// On/Off period duration in tenths of a second (0x00-0xFF = 0-25.5 seconds). + /// If specified, the property MUST also be specified. + /// + OnOffPeriod = 0x03, + + /// + /// Number of On/Off periods to run (0x00-0xFE = 0-254, 0xFF = run until stopped). + /// If specified, the property MUST also be specified. + /// + OnOffCycles = 0x04, + + /// + /// On time within an On/Off period in tenths of a second, allowing asymmetric periods. + /// 0x00 = symmetric (On time equals Off time), 0x01-0xFF = 0.1-25.5 seconds. + /// + OnTimeWithinOnOffPeriod = 0x05, + + /// + /// Timeout in minutes (0x00-0xFF = 0-255 minutes). + /// + TimeoutMinutes = 0x06, + + /// + /// Timeout in seconds (0x00-0x3B = 0-59 seconds; 0x3C-0xFF reserved). + /// + TimeoutSeconds = 0x07, + + /// + /// Timeout in hundredths of a second (0x00-0x63 = 0.00-0.99 seconds; 0x64-0xFF reserved). + /// + TimeoutHundredths = 0x08, + + /// + /// Sound volume level (0x00=mute, 0x01-0x64=1-100%, 0xFF=restore most recent level). + /// This property MUST NOT switch on the indication. + /// + SoundLevel = 0x09, + + /// + /// Timeout in hours (0x00-0xFF = 0-255 hours). + /// + TimeoutHours = 0x0A, + + /// + /// Advertise-only property indicating the indicator can continue working during sleep. + /// MUST NOT be used in a controlling command. + /// + LowPower = 0x10, +} + +/// +/// Defines the commands for the Indicator Command Class. +/// +public enum IndicatorCommand : byte +{ + /// + /// Set one or more indicator resources on a supporting node. + /// + Set = 0x01, + + /// + /// Request the state of an indicator resource. + /// + Get = 0x02, + + /// + /// Report the state of an indicator resource. + /// + Report = 0x03, + + /// + /// Request the supported properties of an indicator resource. + /// + SupportedGet = 0x04, + + /// + /// Report the supported properties of an indicator resource. + /// + SupportedReport = 0x05, +} + +/// +/// Represents a single indicator object in a Set or Report command (version 2+). +/// +public readonly record struct IndicatorObject( + /// + /// The indicator resource identifier. + /// + IndicatorId IndicatorId, + + /// + /// The property of the indicator resource. + /// + IndicatorPropertyId PropertyId, + + /// + /// The value to assign to (or reported for) the property. + /// + byte Value); + +/// +/// Represents an Indicator Report received from a device. +/// +/// +/// For version 1 devices, only is populated and +/// is empty. +/// For version 2+ devices, provides backward compatibility +/// and contains the detailed indicator state. +/// +public readonly record struct IndicatorReport( + /// + /// The backward-compatible indicator value. + /// For version 1 devices, this is the sole indicator state (0x00=off, 0x01-0x63=on, 0xFF=on). + /// For version 2+ devices, a controlling node SHOULD ignore this if is non-empty. + /// + byte Indicator0Value, + + /// + /// The indicator objects reported for the queried indicator (version 2+). + /// All objects carry the same . + /// Empty for version 1 devices. + /// + IReadOnlyList Objects); + +/// +/// Controls indicator resources (LEDs, LCDs, buzzers) on a supporting node. +/// +/// +/// +/// Version 1 provides a single unspecified indicator that can be turned on or off. +/// Version 2 introduces multiple named indicator resources with typed properties. +/// Version 3 adds new indicator IDs and property IDs, including the Node Identify indicator (0x50). +/// +/// +/// The Identify feature (Indicator 0x50) is mandatory for all Z-Wave Plus v2 nodes and allows +/// a controller to make a device blink/beep so the user can physically locate it. +/// +/// +[CommandClass(CommandClassId.Indicator)] +public sealed partial class IndicatorCommandClass : CommandClass +{ + internal IndicatorCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + public override bool? IsCommandSupported(IndicatorCommand command) + => command switch + { + IndicatorCommand.Set => true, + IndicatorCommand.Get => true, + IndicatorCommand.SupportedGet => Version.HasValue ? Version >= 2 : null, + _ => false, + }; + + /// + /// Interviews the device to discover its indicator capabilities and current state. + /// + /// + /// + /// Per spec CL:0087.01.21.01.1, for version 2+, the interview walks the + /// next-indicator-ID chain starting from indicator ID 0x00, + /// then queries the current state of each supported indicator. + /// + /// + /// For version 1, the interview simply queries the single indicator state. + /// + /// + internal override async Task InterviewAsync(CancellationToken cancellationToken) + { + if (IsCommandSupported(IndicatorCommand.SupportedGet).GetValueOrDefault()) + { + // Walk the supported indicator chain per spec Figure 6.38. + IndicatorId nextIndicatorId = 0; + List supportedIndicators = []; + do + { + IndicatorSupportedGetCommand command = IndicatorSupportedGetCommand.Create(nextIndicatorId); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync( + predicate: frame => frame.CommandParameters.Length >= 1 + && (IndicatorId)frame.CommandParameters.Span[0] == nextIndicatorId, + cancellationToken).ConfigureAwait(false); + (IndicatorId nextId, IReadOnlySet propertyIds) = + IndicatorSupportedReportCommand.Parse(reportFrame, Logger); + + if (propertyIds.Count > 0) + { + _supportedIndicators[nextIndicatorId] = propertyIds; + supportedIndicators.Add(nextIndicatorId); + } + + nextIndicatorId = nextId; + } + while (nextIndicatorId != 0); + + // Get the current state of each supported indicator. + foreach (IndicatorId indicatorId in supportedIndicators) + { + _ = await GetAsync(indicatorId, cancellationToken).ConfigureAwait(false); + } + } + else + { + // Version 1: simple get. + _ = await GetAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((IndicatorCommand)frame.CommandId) + { + case IndicatorCommand.Report: + { + IndicatorReport report = IndicatorReportCommand.Parse(frame, Logger); + ApplyReport(report); + break; + } + } + } +}