diff --git a/src/ZWave.CommandClasses.Tests/MeterCommandClassTests.Report.cs b/src/ZWave.CommandClasses.Tests/MeterCommandClassTests.Report.cs new file mode 100644 index 0000000..07fe323 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/MeterCommandClassTests.Report.cs @@ -0,0 +1,720 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class MeterCommandClassTests +{ + [TestMethod] + public void GetCommand_Create_Version1_NoParams() + { + var command = MeterCommandClass.MeterGetCommand.Create(version: 1, rateType: null, scale: null, scale2: null); + + Assert.AreEqual(CommandClassId.Meter, MeterCommandClass.MeterGetCommand.CommandClassId); + Assert.AreEqual((byte)MeterCommand.Get, MeterCommandClass.MeterGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void GetCommand_Create_Version2_DefaultScale_SingleByte() + { + var command = MeterCommandClass.MeterGetCommand.Create(version: 2, rateType: null, scale: null, scale2: null); + + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void GetCommand_Create_Version2_KVAh_ScaleInBits() + { + // scale value 1 (kVAh): bits 5-3 = 001 -> 0x08 + var command = MeterCommandClass.MeterGetCommand.Create(version: 2, rateType: null, scale: 1, scale2: null); + + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual(0b0000_1000, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void GetCommand_Create_Version3_W_ScaleInThreeBits() + { + // scale value 2 (W): bits 5-3 = 010 -> 0x10 + var command = MeterCommandClass.MeterGetCommand.Create(version: 3, rateType: null, scale: 2, scale2: null); + + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual(0b0001_0000, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void GetCommand_Create_Version3_RateTypeRequested_Throws() + { + // The Rate Type field only exists at V4+, so it cannot be requested at V3. + Assert.ThrowsExactly(() => MeterCommandClass.MeterGetCommand.Create(version: 3, rateType: (byte)MeterRateType.Import, scale: 0, scale2: null)); + } + + [TestMethod] + public void GetCommand_Create_Version1_ScaleRequested_Throws() + { + // The V1 Meter Get has no parameters, so a specific scale cannot be requested. + Assert.ThrowsExactly(() => MeterCommandClass.MeterGetCommand.Create(version: 1, rateType: null, scale: 0, scale2: null)); + } + + [TestMethod] + public void GetCommand_Create_Version1_RateTypeRequested_Throws() + { + Assert.ThrowsExactly(() => MeterCommandClass.MeterGetCommand.Create(version: 1, rateType: (byte)MeterRateType.Import, scale: null, scale2: null)); + } + + [TestMethod] + public void GetCommand_Create_Version2_ScaleOutOfRange_Throws() + { + // V2 has a 2-bit Scale field (values 0-3); scale 4 (V) is not representable. + Assert.ThrowsExactly(() => MeterCommandClass.MeterGetCommand.Create(version: 2, rateType: null, scale: 4, scale2: null)); + } + + [TestMethod] + public void GetCommand_Create_Version2_RateTypeRequested_Throws() + { + Assert.ThrowsExactly(() => MeterCommandClass.MeterGetCommand.Create(version: 2, rateType: (byte)MeterRateType.Import, scale: null, scale2: null)); + } + + [TestMethod] + public void GetCommand_Create_Version2_MaxScale_EncodesBits() + { + // Boundary: scale 3 (Pulse count) fits the 2-bit field -> bits 5-3 = 011 -> 0x18. + var command = MeterCommandClass.MeterGetCommand.Create(version: 2, rateType: null, scale: 3, scale2: null); + + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual(0b0001_1000, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void GetCommand_Create_Version3_MaxScale_EncodesBits() + { + // Boundary: scale 6 (Power factor) fits the 3-bit field -> bits 5-3 = 110 -> 0x30. + var command = MeterCommandClass.MeterGetCommand.Create(version: 3, rateType: null, scale: 6, scale2: null); + + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual(0b0011_0000, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void GetCommand_Create_Version4_MS_T_WithScale2_Succeeds() + { + // V4 introduces the M.S.T scale (7) together with the Scale 2 field. + var command = MeterCommandClass.MeterGetCommand.Create(version: 4, rateType: null, scale: 7, scale2: 1); + + Assert.AreEqual(4, command.Frame.Data.Length); + Assert.AreEqual(0b0011_1000, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual(0x01, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void GetCommand_Create_Version4_Import_KWh() + { + // rate type 1 (import): bits 7-6 = 01 -> 0x40; scale 0 + var command = MeterCommandClass.MeterGetCommand.Create(version: 4, rateType: (byte)MeterRateType.Import, scale: 0, scale2: null); + + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual(0b0100_0000, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void GetCommand_Create_Version4_Export_V() + { + // rate type 2 (export): bits 7-6 = 10 -> 0x80; scale 4 (V): bits 5-3 = 100 -> 0x20 + var command = MeterCommandClass.MeterGetCommand.Create(version: 4, rateType: (byte)MeterRateType.Export, scale: 4, scale2: null); + + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual(0b1010_0000, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void GetCommand_Create_Version6_KVar_IncludesScale2() + { + // scale value 7 (M.S.T) with scale2 = 0 (kVar): byte0 = 0x38, scale2 byte = 0x00 + var command = MeterCommandClass.MeterGetCommand.Create(version: 6, rateType: null, scale: 7, scale2: 0); + + Assert.AreEqual(4, command.Frame.Data.Length); + Assert.AreEqual(0b0011_1000, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void GetCommand_Create_Version6_KVarh_Scale2IsOne() + { + // scale value 7 with scale2 = 1 (kVarh): byte0 = 0x38, scale2 byte = 0x01 + var command = MeterCommandClass.MeterGetCommand.Create(version: 6, rateType: null, scale: 7, scale2: 1); + + Assert.AreEqual(0b0011_1000, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual(0x01, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void GetCommand_Create_Version3_MS_T_Throws() + { + // The M.S.T scale (7) does not exist before V4, so a V3 Get cannot request it. + Assert.ThrowsExactly(() => MeterCommandClass.MeterGetCommand.Create(version: 3, rateType: null, scale: 7, scale2: 0)); + } + + [TestMethod] + public void Report_Parse_KWh_Precision2_DeltaTimeZero_NoPrevious() + { + // V2+ frame: the Delta Time field is present but set to 0x0000 (no Previous Meter Value). + // byte0: scale(2)=0, rate=0, type=Electric(1) -> 0x01 + // byte1: precision=2, scale(1:0)=0, size=2 -> 0x42 + // value: 1025 = 0x04 0x01 -> 10.25 ; delta: 0x0000 + byte[] data = [0x32, 0x02, 0x01, 0x42, 0x04, 0x01, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + MeterReport report = MeterCommandClass.MeterReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(MeterType.Electric, report.Type); + Assert.AreEqual(MeterRateType.Unspecified, report.RateType); + Assert.AreEqual(MeterScale.kWh, report.Scale); + Assert.AreEqual(10.25, report.Value, 0.001); + Assert.IsNull(report.DeltaTime); + Assert.IsNull(report.PreviousValue); + } + + [TestMethod] + public void Report_Parse_V1_NoDeltaTime_Succeeds() + { + // V1 reports end after the Meter Value; the Delta Time and Previous Meter Value fields do not exist. + // byte0: scale(2)=0, rate=0, type=Electric(1) -> 0x01 + // byte1: precision=2, scale(1:0)=0, size=2 -> 0x42 + // value: 1025 = 0x04 0x01 -> 10.25 + byte[] data = [0x32, 0x02, 0x01, 0x42, 0x04, 0x01]; + CommandClassFrame frame = new(data); + + MeterReport report = MeterCommandClass.MeterReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(MeterType.Electric, report.Type); + Assert.AreEqual(MeterRateType.Unspecified, report.RateType); + Assert.AreEqual(MeterScale.kWh, report.Scale); + Assert.AreEqual(10.25, report.Value, 0.001); + Assert.IsNull(report.DeltaTime); + Assert.IsNull(report.PreviousValue); + } + + [TestMethod] + public void Report_Parse_SingleTrailingByte_Throws() + { + // One byte after the Meter Value is neither a valid V1 ending nor a complete Delta Time field. + // byte0: type=Electric(1) -> 0x01 + // byte1: precision=0, scale(1:0)=0, size=1 -> 0x01 + // value: 1 (0x01) ; trailing byte: 0x00 + byte[] data = [0x32, 0x02, 0x01, 0x01, 0x01, 0x00]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly(() => MeterCommandClass.MeterReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void Report_Parse_MST_MissingScale2_Throws() + { + // The 3-bit scale field is 7 (M.S.T) but the frame ends after the value, so the + // mandatory Scale 2 byte is missing. + // byte0: scale(2)=1, rate=0, type=Electric(1) -> 0x81 + // byte1: precision=0, scale(1:0)=3, size=1 -> 0x19 + // value: 5 (0x05) + byte[] data = [0x32, 0x02, 0x81, 0x19, 0x05]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly(() => MeterCommandClass.MeterReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void Report_Parse_W_Import_WithDeltaAndPrevious() + { + // byte0: rate=1 (import), type=Electric -> 0x21 + // byte1: precision=0, scale(1:0)=2 (W), size=1 -> 0x11 + // value: 100 (0x64) ; delta: 3600 (0x0E 0x10) ; previous: 80 (0x50) + byte[] data = [0x32, 0x02, 0x21, 0x11, 0x64, 0x0E, 0x10, 0x50]; + CommandClassFrame frame = new(data); + + MeterReport report = MeterCommandClass.MeterReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(MeterRateType.Import, report.RateType); + Assert.AreEqual(MeterScale.W, report.Scale); + Assert.AreEqual(100.0, report.Value, 0.001); + Assert.AreEqual(TimeSpan.FromSeconds(3600), report.DeltaTime); + Assert.AreEqual(80.0, report.PreviousValue.GetValueOrDefault(), 0.001); + } + + [TestMethod] + public void Report_Parse_MS_T_Scale2_KVar() + { + // byte0: scale(2)=1, rate=0, type=Electric -> 0x81 + // byte1: precision=1, scale(1:0)=3, size=2 -> 0x3A + // value: 150 (0x00 0x96) -> 15.0 ; delta: 0x0000 ; scale2: 0x00 + byte[] data = [0x32, 0x02, 0x81, 0x3A, 0x00, 0x96, 0x00, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + MeterReport report = MeterCommandClass.MeterReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(MeterScale.kVar, report.Scale); + Assert.AreEqual(15.0, report.Value, 0.001); + } + + [TestMethod] + public void Report_Parse_Gas_Negative_4Byte_UnknownDelta() + { + // byte0: type=Gas(2) -> 0x02 + // byte1: precision=0, scale(1:0)=0, size=4 -> 0x04 + // value: -5 (0xFF 0xFF 0xFF 0xFB) ; delta: 0xFFFF (unknown) ; previous: -10 (0xFF 0xFF 0xFF 0xF6) + byte[] data = + [ + 0x32, 0x02, 0x02, 0x04, + 0xFF, 0xFF, 0xFF, 0xFB, + 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF6, + ]; + CommandClassFrame frame = new(data); + + MeterReport report = MeterCommandClass.MeterReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(MeterType.Gas, report.Type); + Assert.AreEqual(MeterScale.CubicMeters, report.Scale); + Assert.AreEqual(-5.0, report.Value, 0.001); + Assert.IsNull(report.DeltaTime); + Assert.AreEqual(-10.0, report.PreviousValue.GetValueOrDefault(), 0.001); + } + + [TestMethod] + public void Report_Parse_Water_USGallons_Precision3_Export() + { + // byte0: rate=2 (export), type=Water(3) -> 0x43 + // byte1: precision=3, scale(1:0)=2, size=2 -> 0x72 + // value: 456 (0x01 0xC8) -> 0.456 ; delta: 0x0000 + byte[] data = [0x32, 0x02, 0x43, 0x72, 0x01, 0xC8, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + MeterReport report = MeterCommandClass.MeterReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(MeterType.Water, report.Type); + Assert.AreEqual(MeterRateType.Export, report.RateType); + Assert.AreEqual(MeterScale.USGallons, report.Scale); + Assert.AreEqual(0.456, report.Value, 0.0001); + } + + [TestMethod] + public void Report_Parse_EmptyCommandParameters_Throws() + { + byte[] data = [0x32, 0x02]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly(() => MeterCommandClass.MeterReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void Report_Parse_TooShort_Throws() + { + byte[] data = [0x32, 0x02, 0x01]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly(() => MeterCommandClass.MeterReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void Report_Parse_InvalidSize_Throws() + { + // size = 3 (invalid); value byte follows but 3 is not 1/2/4 + byte[] data = [0x32, 0x02, 0x01, 0x03, 0x00]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly(() => MeterCommandClass.MeterReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void Report_Parse_ValueSizeExceedsPayload_Throws() + { + // size = 4 but only 1 value byte provided + byte[] data = [0x32, 0x02, 0x01, 0x04, 0x00]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly(() => MeterCommandClass.MeterReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void Report_Parse_UnknownMeterType_Throws() + { + // type = 6 (reserved) + byte[] data = [0x32, 0x02, 0x06, 0x01, 0x00, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly(() => MeterCommandClass.MeterReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void Report_Parse_UnsupportedScaleForType_Throws() + { + // Gas with scale index 2 (reserved for gas) + byte[] data = [0x32, 0x02, 0x02, 0x11, 0x01, 0x00, 0x00]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly(() => MeterCommandClass.MeterReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void Report_Parse_PreviousValueMissing_Throws() + { + // delta = 3600 (non-zero) but no previous value bytes + byte[] data = [0x32, 0x02, 0x01, 0x01, 0x01, 0x0E, 0x10]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly(() => MeterCommandClass.MeterReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void Report_Create_ParsesBackToSameValues() + { + var command = MeterCommandClass.MeterReportCommand.Create( + meterType: MeterType.Electric, + rateType: MeterRateType.Import, + scale: MeterScale.W, + precision: 1, + value: 12.3, + size: 2, + deltaTime: TimeSpan.FromSeconds(120), + previousValue: 10.1); + + MeterReport report = MeterCommandClass.MeterReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual(MeterType.Electric, report.Type); + Assert.AreEqual(MeterRateType.Import, report.RateType); + Assert.AreEqual(MeterScale.W, report.Scale); + Assert.AreEqual(12.3, report.Value, 0.0001); + Assert.AreEqual(TimeSpan.FromSeconds(120), report.DeltaTime); + Assert.AreEqual(10.1, report.PreviousValue.GetValueOrDefault(), 0.0001); + } + + [TestMethod] + public void Report_Create_MS_T_Scale2_RoundTrips() + { + var command = MeterCommandClass.MeterReportCommand.Create( + meterType: MeterType.Electric, + rateType: MeterRateType.Unspecified, + scale: MeterScale.kVarh, + precision: 0, + value: 5, + size: 1, + deltaTime: null, + previousValue: null); + + MeterReport report = MeterCommandClass.MeterReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual(MeterScale.kVarh, report.Scale); + Assert.AreEqual(5.0, report.Value, 0.001); + Assert.IsNull(report.DeltaTime); + } + + [TestMethod] + public void Report_MatchesRequest_ExplicitScale_MatchesOnlyOwnReport() + { + var reportW = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Unspecified, MeterScale.W, 0, 100, 1, null, null); + var reportKVAh = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Import, MeterScale.kVAh, 1, 12.3, 2, null, null); + + // Each report matches only the request that asked for it. + Assert.IsTrue(MeterCommandClass.MeterReportCommand.MatchesRequest(reportW.Frame, scale: 2, scale2: null, rateType: null)); + Assert.IsFalse(MeterCommandClass.MeterReportCommand.MatchesRequest(reportKVAh.Frame, scale: 2, scale2: null, rateType: null)); + Assert.IsTrue(MeterCommandClass.MeterReportCommand.MatchesRequest(reportKVAh.Frame, scale: 1, scale2: null, rateType: (byte)MeterRateType.Import)); + Assert.IsFalse(MeterCommandClass.MeterReportCommand.MatchesRequest(reportW.Frame, scale: 1, scale2: null, rateType: (byte)MeterRateType.Import)); + } + + [TestMethod] + public void Report_MatchesRequest_DefaultRequest_MatchesAnyReport() + { + var report = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Import, MeterScale.W, 0, 100, 1, null, null); + + // A scale of null or 0 requests the device default; no scale matching is attempted. + Assert.IsTrue(MeterCommandClass.MeterReportCommand.MatchesRequest(report.Frame, scale: null, scale2: null, rateType: null)); + Assert.IsTrue(MeterCommandClass.MeterReportCommand.MatchesRequest(report.Frame, scale: 0, scale2: null, rateType: null)); + Assert.IsTrue(MeterCommandClass.MeterReportCommand.MatchesRequest(report.Frame, scale: null, scale2: null, rateType: (byte)MeterRateType.Import)); + Assert.IsFalse(MeterCommandClass.MeterReportCommand.MatchesRequest(report.Frame, scale: null, scale2: null, rateType: (byte)MeterRateType.Export)); + } + + [TestMethod] + public void Report_MatchesRequest_MST_MatchesScale2AtComputedPosition() + { + // kVar (scale 7, Scale 2 = 0) with a previous value present; kVarh (scale 7, Scale 2 = 1) without. + var reportKVar = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Unspecified, MeterScale.kVar, 1, 3.5, 2, TimeSpan.FromSeconds(60), 2.0); + var reportKVarh = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Unspecified, MeterScale.kVarh, 0, 7, 1, null, null); + + Assert.IsTrue(MeterCommandClass.MeterReportCommand.MatchesRequest(reportKVar.Frame, scale: 7, scale2: 0, rateType: null)); + Assert.IsFalse(MeterCommandClass.MeterReportCommand.MatchesRequest(reportKVar.Frame, scale: 7, scale2: 1, rateType: null)); + Assert.IsTrue(MeterCommandClass.MeterReportCommand.MatchesRequest(reportKVarh.Frame, scale: 7, scale2: 1, rateType: null)); + Assert.IsFalse(MeterCommandClass.MeterReportCommand.MatchesRequest(reportKVarh.Frame, scale: 7, scale2: 0, rateType: null)); + } + + [TestMethod] + public void Report_MatchesRequest_MST_TruncatedOrMalformed_ReturnsFalse() + { + // M.S.T report missing the Delta Time / Scale 2 bytes. + byte[] truncated = [0x32, 0x02, 0x81, 0x19, 0x07, 0x00, 0x00]; + Assert.IsFalse(MeterCommandClass.MeterReportCommand.MatchesRequest(new CommandClassFrame(truncated), scale: 7, scale2: 0, rateType: null)); + + // M.S.T with an invalid value size (6). + byte[] badSize = [0x32, 0x02, 0x81, 0x16, 0x07, 0x00, 0x00]; + Assert.IsFalse(MeterCommandClass.MeterReportCommand.MatchesRequest(new CommandClassFrame(badSize), scale: 7, scale2: 0, rateType: null)); + + // Header too short. + byte[] shortFrame = [0x32, 0x02, 0x81]; + Assert.IsFalse(MeterCommandClass.MeterReportCommand.MatchesRequest(new CommandClassFrame(shortFrame), scale: 7, scale2: 0, rateType: null)); + } + + [TestMethod] + public void Report_MatchesRequest_ExplicitRateType_MatchesOnlyOwnReport() + { + var import = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Import, MeterScale.W, 0, 1, 1, null, null); + var export = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Export, MeterScale.W, 0, 2, 1, null, null); + + Assert.IsTrue(MeterCommandClass.MeterReportCommand.MatchesRequest(import.Frame, scale: null, scale2: null, rateType: (byte)MeterRateType.Import)); + Assert.IsFalse(MeterCommandClass.MeterReportCommand.MatchesRequest(export.Frame, scale: null, scale2: null, rateType: (byte)MeterRateType.Import)); + Assert.IsTrue(MeterCommandClass.MeterReportCommand.MatchesRequest(export.Frame, scale: null, scale2: null, rateType: (byte)MeterRateType.Export)); + } + + [TestMethod] + public async Task GetAsync_ConcurrentGets_ReversedReports_EachCompletesWithOwnReport() + { + (MeterCommandClass meter, RecordingDriver driver) = CreateMeter(version: 6); + + // Both GetAsync calls register their report awaiter synchronously before returning (the + // recording driver completes inline), so the reports below can be delivered directly. + Task taskW = meter.GetAsync(MeterType.Electric, MeterScale.W, rateType: null, CancellationToken.None); + Task taskKVAh = meter.GetAsync(MeterType.Electric, MeterScale.kVAh, MeterRateType.Import, CancellationToken.None); + + var reportW = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Unspecified, MeterScale.W, 0, 100, 1, null, null); + var reportKVAh = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Import, MeterScale.kVAh, 1, 12.3, 2, null, null); + + // Deliver in reversed order: the kVAh report first. + meter.ProcessCommand(reportKVAh.Frame); + meter.ProcessCommand(reportW.Frame); + + MeterReport w = await taskW; + MeterReport kVAh = await taskKVAh; + + Assert.AreEqual(MeterScale.W, w.Scale); + Assert.AreEqual(100.0, w.Value, 0.001); + Assert.AreEqual(MeterScale.kVAh, kVAh.Scale); + Assert.AreEqual(MeterRateType.Import, kVAh.RateType); + Assert.AreEqual(12.3, kVAh.Value, 0.001); + + Assert.HasCount(2, driver.SentFrames); + Assert.AreEqual(0b0001_0000, driver.SentFrames[0].CommandParameters.Span[0]); // W (scale 2) + Assert.AreEqual(0b0100_1000, driver.SentFrames[1].CommandParameters.Span[0]); // kVAh + Import + } + + [TestMethod] + public async Task GetAsync_DefaultGetInFlight_ExplicitGet_Throws() + { + // A default Get (unconstrained scale) matches any report, so an explicit-scale Get issued + // while it is in flight could be answered with the wrong report; it must be rejected instead. + (MeterCommandClass meter, RecordingDriver driver) = CreateMeter(version: 6); + + Task taskDefault = meter.GetAsync(type: null, scale: null, rateType: null, CancellationToken.None); + await Assert.ThrowsExactlyAsync( + () => meter.GetAsync(MeterType.Electric, MeterScale.W, rateType: null, CancellationToken.None)); + + Assert.HasCount(1, driver.SentFrames); + + // Complete the in-flight default Get so its awaiter is cleaned up. + var report = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Unspecified, MeterScale.kWh, 0, 10, 1, null, null); + meter.ProcessCommand(report.Frame); + await taskDefault; + } + + [TestMethod] + public async Task GetAsync_ExplicitGetInFlight_DefaultGet_Throws() + { + (MeterCommandClass meter, RecordingDriver driver) = CreateMeter(version: 6); + + Task taskW = meter.GetAsync(MeterType.Electric, MeterScale.W, rateType: null, CancellationToken.None); + await Assert.ThrowsExactlyAsync( + () => meter.GetAsync(type: null, scale: null, rateType: null, CancellationToken.None)); + + Assert.HasCount(1, driver.SentFrames); + + var report = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Unspecified, MeterScale.W, 0, 100, 1, null, null); + meter.ProcessCommand(report.Frame); + await taskW; + } + + [TestMethod] + public async Task GetAsync_DuplicateGetInFlight_Throws() + { + // An identical duplicate is rejected rather than coalesced: the reports could not be + // attributed, and a shared wait would not offer independent cancellation. + (MeterCommandClass meter, RecordingDriver driver) = CreateMeter(version: 6); + + Task taskA = meter.GetAsync(type: null, scale: null, rateType: null, CancellationToken.None); + await Assert.ThrowsExactlyAsync( + () => meter.GetAsync(type: null, scale: null, rateType: null, CancellationToken.None)); + + Assert.HasCount(1, driver.SentFrames); + + var report = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Unspecified, MeterScale.kWh, 0, 10, 1, null, null); + meter.ProcessCommand(report.Frame); + await taskA; + } + + [TestMethod] + public async Task GetAsync_ExplicitKWh_ConflictsWithDefault_Throws() + { + // Scale 0 (kWh) is the device default on the wire, so it conflicts with a default Get. + (MeterCommandClass meter, RecordingDriver driver) = CreateMeter(version: 6); + + Task taskKWh = meter.GetAsync(MeterType.Electric, MeterScale.kWh, rateType: null, CancellationToken.None); + await Assert.ThrowsExactlyAsync( + () => meter.GetAsync(type: null, scale: null, rateType: null, CancellationToken.None)); + + Assert.HasCount(1, driver.SentFrames); + + var report = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Unspecified, MeterScale.kWh, 0, 10, 1, null, null); + meter.ProcessCommand(report.Frame); + await taskKWh; + } + + [TestMethod] + public async Task GetAsync_SameScale_RateDefaultVsImport_Throws() + { + // Same concrete scale, but one side requests the default rate: the default rate is a + // specific (unknown) value, so the two requests' reports cannot be told apart. + (MeterCommandClass meter, RecordingDriver driver) = CreateMeter(version: 6); + + Task taskW = meter.GetAsync(MeterType.Electric, MeterScale.W, rateType: null, CancellationToken.None); + await Assert.ThrowsExactlyAsync( + () => meter.GetAsync(MeterType.Electric, MeterScale.W, MeterRateType.Import, CancellationToken.None)); + + Assert.HasCount(1, driver.SentFrames); + + var report = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Unspecified, MeterScale.W, 0, 100, 1, null, null); + meter.ProcessCommand(report.Frame); + await taskW; + } + + [TestMethod] + public async Task GetAsync_SameScale_DifferentRates_CompleteSeparately() + { + // Two different concrete rates on the same scale are distinguishable. + (MeterCommandClass meter, RecordingDriver driver) = CreateMeter(version: 6); + + Task taskImport = meter.GetAsync(MeterType.Electric, MeterScale.W, MeterRateType.Import, CancellationToken.None); + Task taskExport = meter.GetAsync(MeterType.Electric, MeterScale.W, MeterRateType.Export, CancellationToken.None); + + Assert.HasCount(2, driver.SentFrames); + + var reportImport = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Import, MeterScale.W, 0, 100, 1, null, null); + // 200 does not fit a signed 1-byte value; use size 2. + var reportExport = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Export, MeterScale.W, 0, 200, 2, null, null); + + meter.ProcessCommand(reportImport.Frame); + meter.ProcessCommand(reportExport.Frame); + + MeterReport import = await taskImport; + MeterReport export = await taskExport; + + Assert.AreEqual(MeterRateType.Import, import.RateType); + Assert.AreEqual(100.0, import.Value, 0.001); + Assert.AreEqual(MeterRateType.Export, export.RateType); + Assert.AreEqual(200.0, export.Value, 0.001); + } + + [TestMethod] + public async Task GetAsync_MS_T_DifferentScale2_CompleteSeparately() + { + // M.S.T requests differ in their Scale 2 value, which is transmitted, so they are distinguishable. + (MeterCommandClass meter, RecordingDriver driver) = CreateMeter(version: 6); + + Task taskKVar = meter.GetAsync(MeterType.Electric, MeterScale.kVar, rateType: null, CancellationToken.None); + Task taskKVarh = meter.GetAsync(MeterType.Electric, MeterScale.kVarh, rateType: null, CancellationToken.None); + + Assert.HasCount(2, driver.SentFrames); + + var reportKVar = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Unspecified, MeterScale.kVar, 0, 5, 1, null, null); + var reportKVarh = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Unspecified, MeterScale.kVarh, 0, 6, 1, null, null); + + meter.ProcessCommand(reportKVarh.Frame); + meter.ProcessCommand(reportKVar.Frame); + + MeterReport kVar = await taskKVar; + MeterReport kVarh = await taskKVarh; + + Assert.AreEqual(MeterScale.kVar, kVar.Scale); + Assert.AreEqual(MeterScale.kVarh, kVarh.Scale); + } + + [TestMethod] + public async Task GetAsync_FollowUpGetFromReportEvent_Succeeds() + { + // The in-flight slot is released before the report event fires, so a handler reacting to + // the report can immediately issue a follow-up Get. + (MeterCommandClass meter, RecordingDriver driver) = CreateMeter(version: 6); + + Task? followUp = null; + // One-shot: the event also fires when the follow-up's own report arrives. + meter.OnMeterReportReceived += _ => + { + if (followUp is null) + { + followUp = meter.GetAsync(MeterType.Electric, MeterScale.W, rateType: null, CancellationToken.None); + } + }; + + Task taskDefault = meter.GetAsync(type: null, scale: null, rateType: null, CancellationToken.None); + + var reportKWh = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Unspecified, MeterScale.kWh, 0, 10, 1, null, null); + meter.ProcessCommand(reportKWh.Frame); + MeterReport defaultReport = await taskDefault; + + Assert.AreEqual(MeterScale.kWh, defaultReport.Scale); + Assert.IsNotNull(followUp); + + var reportW = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Unspecified, MeterScale.W, 0, 100, 1, null, null); + meter.ProcessCommand(reportW.Frame); + MeterReport w = await followUp; + + Assert.AreEqual(MeterScale.W, w.Scale); + Assert.HasCount(2, driver.SentFrames); + } + + [TestMethod] + public async Task GetAsync_SameSignatureFollowUpFromReportEvent_WaitsForNewReport() + { + // A same-signature follow-up started from the report event must wait for a new report + // (the dispatched report already belongs to the completing Get), and it must keep the + // in-flight slot so that conflicting Gets are still rejected. + (MeterCommandClass meter, RecordingDriver driver) = CreateMeter(version: 6); + + Task? followUp = null; + // One-shot: the event also fires when the follow-up's own report arrives. + meter.OnMeterReportReceived += _ => + { + if (followUp is null) + { + followUp = meter.GetAsync(type: null, scale: null, rateType: null, CancellationToken.None); + } + }; + + Task taskFirst = meter.GetAsync(type: null, scale: null, rateType: null, CancellationToken.None); + + var reportFirst = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Unspecified, MeterScale.kWh, 0, 10, 1, null, null); + meter.ProcessCommand(reportFirst.Frame); + MeterReport first = await taskFirst; + + Assert.AreEqual(10.0, first.Value, 0.001); + Assert.IsNotNull(followUp); + + // The dispatched report was already consumed by the first Get; the follow-up waits for a new one. + Assert.IsFalse(followUp.IsCompleted); + + // The follow-up owns the in-flight slot; the first Get's cleanup must not have removed it. + await Assert.ThrowsExactlyAsync( + () => meter.GetAsync(MeterType.Electric, MeterScale.W, rateType: null, CancellationToken.None)); + + var reportSecond = MeterCommandClass.MeterReportCommand.Create(MeterType.Electric, MeterRateType.Unspecified, MeterScale.kWh, 0, 20, 1, null, null); + meter.ProcessCommand(reportSecond.Frame); + MeterReport second = await followUp; + + Assert.AreEqual(20.0, second.Value, 0.001); + Assert.HasCount(2, driver.SentFrames); + } +} diff --git a/src/ZWave.CommandClasses.Tests/MeterCommandClassTests.Reset.cs b/src/ZWave.CommandClasses.Tests/MeterCommandClassTests.Reset.cs new file mode 100644 index 0000000..60ac882 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/MeterCommandClassTests.Reset.cs @@ -0,0 +1,184 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class MeterCommandClassTests +{ + [TestMethod] + public void ResetCommand_CreateResetAll_NoParams() + { + var command = MeterCommandClass.MeterResetCommand.CreateResetAll(); + + Assert.AreEqual(CommandClassId.Meter, MeterCommandClass.MeterResetCommand.CommandClassId); + Assert.AreEqual((byte)MeterCommand.Reset, MeterCommandClass.MeterResetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void ResetCommand_Create_Version5_Targeted_Throws() + { + // V2-5 have no parameterized reset; a targeted reset is only available at V6. + Assert.ThrowsExactly(() => MeterCommandClass.MeterResetCommand.Create(version: 5, meterType: MeterType.Water, value: 42)); + } + + [TestMethod] + public void ResetCommand_Create_Version6_ZeroValue_1Byte() + { + // value 0 -> size 1; byte0 = (1 << 5) | Electric(1) = 0x21 + var command = MeterCommandClass.MeterResetCommand.Create(version: 6, meterType: MeterType.Electric, value: 0); + + Assert.AreEqual(4, command.Frame.Data.Length); + Assert.AreEqual(0b0010_0001, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void ResetCommand_Create_Version6_PositiveValue_2Byte() + { + // value 1000 -> size 2; byte0 = (2 << 5) | Electric(1) = 0x41 + var command = MeterCommandClass.MeterResetCommand.Create(version: 6, meterType: MeterType.Electric, value: 1000); + + Assert.AreEqual(5, command.Frame.Data.Length); + Assert.AreEqual(0b0100_0001, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual(0x03, command.Frame.CommandParameters.Span[1]); + Assert.AreEqual(0xE8, command.Frame.CommandParameters.Span[2]); + } + + [TestMethod] + public void ResetCommand_Create_Version6_NegativeValue_1Byte_Water() + { + // value -5 -> size 1; byte0 = (1 << 5) | Water(3) = 0x23 + var command = MeterCommandClass.MeterResetCommand.Create(version: 6, meterType: MeterType.Water, value: -5); + + Assert.AreEqual(4, command.Frame.Data.Length); + Assert.AreEqual(0b0010_0011, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual(0xFB, command.Frame.CommandParameters.Span[1]); + } + + [TestMethod] + public void ResetCommand_Create_Version6_LargeValue_4Byte() + { + // value = int.MinValue -> size 4; byte0 = (4 << 5) | Gas(2) = 0x82 + var command = MeterCommandClass.MeterResetCommand.Create(version: 6, meterType: MeterType.Gas, value: int.MinValue); + + Assert.AreEqual(7, command.Frame.Data.Length); + Assert.AreEqual(0b1000_0010, command.Frame.CommandParameters.Span[0]); + Assert.AreEqual(0x80, command.Frame.CommandParameters.Span[1]); + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[2]); + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[3]); + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[4]); + } + + [TestMethod] + public async Task ResetAsync_VersionBelow6_ThrowsAndSendsNothing() + { + (MeterCommandClass meter, RecordingDriver driver) = CreateMeter(version: 3); + + await Assert.ThrowsExactlyAsync( + () => meter.ResetAsync(MeterType.Electric, MeterScale.kWh, MeterRateType.Unspecified, 0, CancellationToken.None)); + + Assert.HasCount(0, driver.SentFrames); + } + + [TestMethod] + public async Task ResetAsync_Version6_SendsTargetedReset() + { + (MeterCommandClass meter, RecordingDriver driver) = CreateMeter(version: 6); + await AdvertiseResetSupportAsync(meter, resetSupported: true); + + await meter.ResetAsync(MeterType.Electric, MeterScale.kWh, MeterRateType.Unspecified, 1000, CancellationToken.None); + + // The first sent frame is the SupportedGet from AdvertiseResetSupportAsync. + CommandClassFrame resetFrame = driver.SentFrames[^1]; + Assert.AreEqual(5, resetFrame.Data.Length); + Assert.AreEqual(0b0100_0001, resetFrame.CommandParameters.Span[0]); // size 2, Electric + Assert.AreEqual(0x03, resetFrame.CommandParameters.Span[1]); + Assert.AreEqual(0xE8, resetFrame.CommandParameters.Span[2]); + } + + [TestMethod] + public async Task ResetAllAsync_Version3_SendsParameterlessReset() + { + (MeterCommandClass meter, RecordingDriver driver) = CreateMeter(version: 3); + await AdvertiseResetSupportAsync(meter, resetSupported: true); + + await meter.ResetAllAsync(CancellationToken.None); + + // The first sent frame is the SupportedGet from AdvertiseResetSupportAsync. + CommandClassFrame resetFrame = driver.SentFrames[^1]; + Assert.AreEqual(2, resetFrame.Data.Length); + } + + [TestMethod] + public async Task ResetAllAsync_Version6_ThrowsAndSendsNothing() + { + (MeterCommandClass meter, RecordingDriver driver) = CreateMeter(version: 6); + + await Assert.ThrowsExactlyAsync(() => meter.ResetAllAsync(CancellationToken.None)); + + Assert.HasCount(0, driver.SentFrames); + } + + [TestMethod] + public async Task ResetAsync_Version6_SupportNotDiscovered_ThrowsAndSendsNothing() + { + // The version is known but the Supported Report has not been read, so Reset support is unknown. + (MeterCommandClass meter, RecordingDriver driver) = CreateMeter(version: 6); + + await Assert.ThrowsExactlyAsync( + () => meter.ResetAsync(MeterType.Electric, MeterScale.kWh, MeterRateType.Unspecified, 0, CancellationToken.None)); + + Assert.HasCount(0, driver.SentFrames); + } + + [TestMethod] + public async Task ResetAsync_Version6_ResetNotAdvertised_ThrowsAndSendsNothing() + { + (MeterCommandClass meter, RecordingDriver driver) = CreateMeter(version: 6); + await AdvertiseResetSupportAsync(meter, resetSupported: false); + + await Assert.ThrowsExactlyAsync( + () => meter.ResetAsync(MeterType.Electric, MeterScale.kWh, MeterRateType.Unspecified, 0, CancellationToken.None)); + + // Only the SupportedGet from AdvertiseResetSupportAsync was sent; no reset frame. + Assert.HasCount(1, driver.SentFrames); + Assert.AreEqual((byte)MeterCommand.SupportedGet, driver.SentFrames[0].CommandId); + } + + [TestMethod] + public async Task ResetAllAsync_Version3_SupportNotDiscovered_ThrowsAndSendsNothing() + { + (MeterCommandClass meter, RecordingDriver driver) = CreateMeter(version: 3); + + await Assert.ThrowsExactlyAsync(() => meter.ResetAllAsync(CancellationToken.None)); + + Assert.HasCount(0, driver.SentFrames); + } + + [TestMethod] + public void IsCommandSupported_Reset_SupportNotDiscovered_ReturnsNull() + { + // Reset support is only known after the Supported Report has been read. + (MeterCommandClass meter, _) = CreateMeter(version: 6); + + Assert.IsNull(meter.IsCommandSupported(MeterCommand.Reset)); + } + + [TestMethod] + public async Task IsCommandSupported_Reset_NotAdvertised_ReturnsFalse() + { + (MeterCommandClass meter, _) = CreateMeter(version: 6); + await AdvertiseResetSupportAsync(meter, resetSupported: false); + + Assert.IsFalse(meter.IsCommandSupported(MeterCommand.Reset)); + } + + [TestMethod] + public async Task IsCommandSupported_Reset_Advertised_ReturnsTrue() + { + (MeterCommandClass meter, _) = CreateMeter(version: 6); + await AdvertiseResetSupportAsync(meter, resetSupported: true); + + Assert.IsTrue(meter.IsCommandSupported(MeterCommand.Reset)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/MeterCommandClassTests.Supported.cs b/src/ZWave.CommandClasses.Tests/MeterCommandClassTests.Supported.cs new file mode 100644 index 0000000..68b3213 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/MeterCommandClassTests.Supported.cs @@ -0,0 +1,155 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class MeterCommandClassTests +{ + [TestMethod] + public void SupportedGetCommand_Create_HasCorrectFormat() + { + var command = MeterCommandClass.MeterSupportedGetCommand.Create(); + + Assert.AreEqual(CommandClassId.Meter, MeterCommandClass.MeterSupportedGetCommand.CommandClassId); + Assert.AreEqual((byte)MeterCommand.SupportedGet, MeterCommandClass.MeterSupportedGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void SupportedReport_Parse_Electric_NoMst() + { + // byte0: reset=0, rate=0, type=Electric(1) -> 0x01 + // byte1: mst=0, scale bytes: bits 0 and 2 -> 0x05 (kWh + W) + byte[] data = [0x32, 0x04, 0x01, 0x05]; + CommandClassFrame frame = new(data); + + MeterSupportedReport report = MeterCommandClass.MeterSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(MeterType.Electric, report.Type); + Assert.AreEqual(MeterRateType.Unspecified, report.RateType); + Assert.IsFalse(report.ResetSupported); + Assert.HasCount(2, report.SupportedScales); + Assert.Contains(MeterScale.kWh, report.SupportedScales); + Assert.Contains(MeterScale.W, report.SupportedScales); + } + + [TestMethod] + public void SupportedReport_Parse_Electric_WithMst_KVar() + { + // byte0: reset=1, rate=3 (both), type=Electric -> 0xE1 + // byte1: mst=1, first scale byte bit 0 -> 0x81 (kWh) + // byte2: count = 1 + // byte3: additional byte bit 0 -> index 7 = kVar -> 0x01 + byte[] data = [0x32, 0x04, 0xE1, 0x81, 0x01, 0x01]; + CommandClassFrame frame = new(data); + + MeterSupportedReport report = MeterCommandClass.MeterSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(MeterRateType.Both, report.RateType); + Assert.IsTrue(report.ResetSupported); + Assert.HasCount(2, report.SupportedScales); + Assert.Contains(MeterScale.kWh, report.SupportedScales); + Assert.Contains(MeterScale.kVar, report.SupportedScales); + } + + [TestMethod] + public void SupportedReport_Parse_Gas_ReservedBitsIgnored() + { + // byte0: reset=0, rate=1 (import), type=Gas(2) -> 0x22 + // byte1: mst=0, scale bits 0,1,2,3 -> 0x0F; index 2 is reserved for gas and must be ignored + byte[] data = [0x32, 0x04, 0x22, 0x0F]; + CommandClassFrame frame = new(data); + + MeterSupportedReport report = MeterCommandClass.MeterSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(MeterType.Gas, report.Type); + Assert.AreEqual(MeterRateType.Import, report.RateType); + Assert.HasCount(3, report.SupportedScales); + Assert.Contains(MeterScale.CubicMeters, report.SupportedScales); + Assert.Contains(MeterScale.CubicFeet, report.SupportedScales); + Assert.Contains(MeterScale.PulseCount, report.SupportedScales); + } + + [TestMethod] + public void SupportedReport_Parse_Empty_Throws() + { + byte[] data = [0x32, 0x04]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly(() => MeterCommandClass.MeterSupportedReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void SupportedReport_Parse_TooShort_Throws() + { + byte[] data = [0x32, 0x04, 0x01]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly(() => MeterCommandClass.MeterSupportedReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void SupportedReport_Parse_UnknownMeterType_Throws() + { + byte[] data = [0x32, 0x04, 0x06, 0x01]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly(() => MeterCommandClass.MeterSupportedReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void SupportedReport_Parse_MstButNoCountByte_Throws() + { + // mst=1 but there is no count byte + byte[] data = [0x32, 0x04, 0x81, 0x81]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly(() => MeterCommandClass.MeterSupportedReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void SupportedReport_Parse_MstCountExceedsPayload_Throws() + { + // mst=1, count=3 but only 1 additional byte provided + byte[] data = [0x32, 0x04, 0x81, 0x81, 0x03, 0x01]; + CommandClassFrame frame = new(data); + + Assert.ThrowsExactly(() => MeterCommandClass.MeterSupportedReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void SupportedReport_Create_NoMst_RoundTrips() + { + var command = MeterCommandClass.MeterSupportedReportCommand.Create( + meterType: MeterType.Gas, + rateType: MeterRateType.Import, + resetSupported: false, + supportedScales: new HashSet { MeterScale.CubicMeters, MeterScale.CubicFeet }); + + MeterSupportedReport report = MeterCommandClass.MeterSupportedReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual(MeterType.Gas, report.Type); + Assert.AreEqual(MeterRateType.Import, report.RateType); + Assert.IsFalse(report.ResetSupported); + Assert.HasCount(2, report.SupportedScales); + Assert.Contains(MeterScale.CubicMeters, report.SupportedScales); + Assert.Contains(MeterScale.CubicFeet, report.SupportedScales); + } + + [TestMethod] + public void SupportedReport_Create_WithMst_RoundTrips() + { + var command = MeterCommandClass.MeterSupportedReportCommand.Create( + meterType: MeterType.Electric, + rateType: MeterRateType.Both, + resetSupported: true, + supportedScales: new HashSet { MeterScale.kWh, MeterScale.kVar }); + + MeterSupportedReport report = MeterCommandClass.MeterSupportedReportCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual(MeterRateType.Both, report.RateType); + Assert.IsTrue(report.ResetSupported); + Assert.HasCount(2, report.SupportedScales); + Assert.Contains(MeterScale.kWh, report.SupportedScales); + Assert.Contains(MeterScale.kVar, report.SupportedScales); + } +} diff --git a/src/ZWave.CommandClasses.Tests/MeterCommandClassTests.cs b/src/ZWave.CommandClasses.Tests/MeterCommandClassTests.cs new file mode 100644 index 0000000..8acd46b --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/MeterCommandClassTests.cs @@ -0,0 +1,121 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public partial class MeterCommandClassTests +{ + [TestMethod] + public void MeterScaleHelper_ResolveScale_Electric() + { + Assert.AreEqual(MeterScale.kWh, MeterScaleHelper.ResolveScale(MeterType.Electric, 0)); + Assert.AreEqual(MeterScale.kVAh, MeterScaleHelper.ResolveScale(MeterType.Electric, 1)); + Assert.AreEqual(MeterScale.W, MeterScaleHelper.ResolveScale(MeterType.Electric, 2)); + Assert.AreEqual(MeterScale.PulseCount, MeterScaleHelper.ResolveScale(MeterType.Electric, 3)); + Assert.AreEqual(MeterScale.V, MeterScaleHelper.ResolveScale(MeterType.Electric, 4)); + Assert.AreEqual(MeterScale.A, MeterScaleHelper.ResolveScale(MeterType.Electric, 5)); + Assert.AreEqual(MeterScale.PowerFactor, MeterScaleHelper.ResolveScale(MeterType.Electric, 6)); + Assert.AreEqual(MeterScale.kVar, MeterScaleHelper.ResolveScale(MeterType.Electric, 7)); + Assert.AreEqual(MeterScale.kVarh, MeterScaleHelper.ResolveScale(MeterType.Electric, 8)); + } + + [TestMethod] + public void MeterScaleHelper_ResolveScale_Reserved_ReturnsNull() + { + // Gas index 2 is reserved. + Assert.IsNull(MeterScaleHelper.ResolveScale(MeterType.Gas, 2)); + // Water index 4 is reserved. + Assert.IsNull(MeterScaleHelper.ResolveScale(MeterType.Water, 4)); + // Heating/Cooling only support kWh (index 0). + Assert.IsNull(MeterScaleHelper.ResolveScale(MeterType.Heating, 1)); + Assert.IsNull(MeterScaleHelper.ResolveScale(MeterType.Cooling, 3)); + // Out-of-range index. + Assert.IsNull(MeterScaleHelper.ResolveScale(MeterType.Electric, 15)); + } + + [TestMethod] + public void MeterScaleHelper_TryGetEncoding_Electric() + { + Assert.IsTrue(MeterScaleHelper.TryGetEncoding(MeterType.Electric, MeterScale.kWh, out byte scale, out byte? scale2)); + Assert.AreEqual(0, scale); + Assert.IsNull(scale2); + + Assert.IsTrue(MeterScaleHelper.TryGetEncoding(MeterType.Electric, MeterScale.kVar, out scale, out scale2)); + Assert.AreEqual(7, scale); + Assert.AreEqual((byte)0, scale2); + + Assert.IsTrue(MeterScaleHelper.TryGetEncoding(MeterType.Electric, MeterScale.kVarh, out scale, out scale2)); + Assert.AreEqual(7, scale); + Assert.AreEqual((byte)1, scale2); + } + + [TestMethod] + public void MeterScaleHelper_TryGetEncoding_InvalidForType_ReturnsFalse() + { + // kWh is not a gas scale. + Assert.IsFalse(MeterScaleHelper.TryGetEncoding(MeterType.Gas, MeterScale.kWh, out byte scale, out byte? scale2)); + Assert.AreEqual(0, scale); + Assert.IsNull(scale2); + } + + [TestMethod] + public void MeterScaleHelper_IsKnownMeterType() + { + Assert.IsTrue(MeterScaleHelper.IsKnownMeterType(MeterType.Electric)); + Assert.IsTrue(MeterScaleHelper.IsKnownMeterType(MeterType.Cooling)); + Assert.IsFalse(MeterScaleHelper.IsKnownMeterType((MeterType)0)); + Assert.IsFalse(MeterScaleHelper.IsKnownMeterType((MeterType)6)); + } + + private sealed class FakeEndpoint : IEndpoint + { + public ushort NodeId => 1; + + public byte EndpointIndex => 0; + + public IReadOnlyDictionary CommandClasses => new Dictionary(); + + public CommandClass GetCommandClass(CommandClassId commandClassId) => throw new NotImplementedException(); + } + + // Records the frames a command class sends; never injects a report, which keeps the + // report-delivery tests deterministic (reports are delivered directly via ProcessCommand). + private sealed class RecordingDriver : IDriver + { + private readonly List _sentFrames = []; + + public IReadOnlyList SentFrames => _sentFrames; + + public Task SendCommandAsync(TCommand command, ushort nodeId, byte endpointIndex, CancellationToken cancellationToken) + where TCommand : struct, ICommand + { + _sentFrames.Add(command.Frame); + return Task.CompletedTask; + } + + public INode? GetNode(ushort nodeId) => null; + } + + private static (MeterCommandClass, RecordingDriver) CreateMeter(byte version) + { + var driver = new RecordingDriver(); + var endpoint = new FakeEndpoint(); + var meter = new MeterCommandClass( + new CommandClassInfo(CommandClassId.Meter, true, false), + driver, + endpoint, + NullLogger.Instance); + meter.SetVersion(version); + return (meter, driver); + } + + // Deterministic Supported Report round-trip: GetSupportedAsync registers its awaiter + // synchronously, so the report can be delivered directly via ProcessCommand. + private static async Task AdvertiseResetSupportAsync(MeterCommandClass meter, bool resetSupported) + { + Task supportedTask = meter.GetSupportedAsync(CancellationToken.None); + var report = MeterCommandClass.MeterSupportedReportCommand.Create(MeterType.Electric, MeterRateType.Import, resetSupported, new HashSet { MeterScale.kWh }); + meter.ProcessCommand(report.Frame); + await supportedTask; + } +} diff --git a/src/ZWave.CommandClasses/CommandClass.cs b/src/ZWave.CommandClasses/CommandClass.cs index 12fa8ff..c25d68a 100644 --- a/src/ZWave.CommandClasses/CommandClass.cs +++ b/src/ZWave.CommandClasses/CommandClass.cs @@ -38,7 +38,8 @@ public abstract class CommandClass private record struct AwaitedReport( byte CommandId, Predicate? Predicate, - TaskCompletionSource TaskCompletionSource); + TaskCompletionSource TaskCompletionSource, + int Generation); // Almost all CCs depend on knowing their own version. private static readonly CommandClassId[] DefaultDependencies = [CommandClassId.Version]; @@ -47,6 +48,11 @@ private record struct AwaitedReport( // of Dictionary> which would have faster lookups private readonly List _awaitedReports = new List(); + // Bumped at the start of every ProcessCommand dispatch. An awaiter registered while a frame + // is being dispatched (e.g. a follow-up Get started from a report event) must not match that + // same frame, only frames dispatched after the registration. + private int _dispatchGeneration; + internal CommandClass( CommandClassInfo info, IDriver driver, @@ -134,11 +140,13 @@ internal void ProcessCommand(CommandClassFrame frame) bool solicited = false; lock (_awaitedReports) { + int dispatchGeneration = ++_dispatchGeneration; int i = 0; while (i < _awaitedReports.Count) { AwaitedReport awaitedReport = _awaitedReports[i]; - if (awaitedReport.CommandId == frame.CommandId + if (awaitedReport.Generation < dispatchGeneration + && awaitedReport.CommandId == frame.CommandId && (awaitedReport.Predicate == null || awaitedReport.Predicate(frame))) { awaitedReport.TaskCompletionSource.TrySetResult(frame); @@ -197,9 +205,9 @@ internal async Task AwaitNextReportAsync( } var tcs = new TaskCompletionSource(); - var awaitedReport = new AwaitedReport(TReport.CommandId, predicate, tcs); lock (_awaitedReports) { + var awaitedReport = new AwaitedReport(TReport.CommandId, predicate, tcs, _dispatchGeneration); _awaitedReports.Add(awaitedReport); } diff --git a/src/ZWave.CommandClasses/MeterCommandClass.Report.cs b/src/ZWave.CommandClasses/MeterCommandClass.Report.cs new file mode 100644 index 0000000..af75c27 --- /dev/null +++ b/src/ZWave.CommandClasses/MeterCommandClass.Report.cs @@ -0,0 +1,549 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a meter reading. +/// +public readonly record struct MeterReport( + /// + /// The type of metering physical unit being reported. + /// + MeterType Type, + + /// + /// The rate type of the reading (import, export, or unspecified). + /// + MeterRateType RateType, + + /// + /// The unit of measure (scale) of the reading. + /// + MeterScale Scale, + + /// + /// The value of the reading, with the reported precision applied. + /// + double Value, + + /// + /// The elapsed time between the previous reading and this one, or if + /// there is no previous value or the elapsed time is unknown. + /// + TimeSpan? DeltaTime, + + /// + /// The previous reading, or if not reported. + /// + double? PreviousValue); + +public sealed partial class MeterCommandClass +{ + // Gets currently awaiting their report: the requested signature, and the ownership token of + // the call that registered it (see GetAsync). + private readonly Dictionary _inFlightGets = + new Dictionary(); + + // What a Get requests: the concrete scale (0 on the wire is the device default and is stored + // as null), its Scale 2 value for M.S.T scales, and the rate type. A null field requests the + // device default, which is a specific (unknown) value, not a wildcard. + private readonly record struct MeterGetSignature(byte? Scale, byte? Scale2, byte? RateType) + { + /// + /// Determines whether the reports of the two requests can be told apart: at least one + /// field must carry a different concrete value on both sides. + /// + public bool Distinguishes(MeterGetSignature other) + { + if (Scale.HasValue && other.Scale.HasValue) + { + if (Scale.Value != other.Scale.Value + || (Scale.Value == 7 && Scale2 != other.Scale2)) + { + return true; + } + } + + return RateType.HasValue && other.RateType.HasValue && RateType.Value != other.RateType.Value; + } + } + + /// + /// Gets the last meter report received, or if none has been received. + /// + public MeterReport? LastReport { get; private set; } + + /// + /// Occurs when a Meter Report is received, whether solicited or unsolicited. + /// + public event Action? OnMeterReportReceived; + + /// + /// Requests the current meter reading from the device. + /// + /// + /// The meter type the requested scale applies to. If , the device's + /// known meter type (from the interview) is used. + /// + /// + /// The scale to request, or to request the device's default scale. + /// + /// + /// The rate type to request, or to request the device's default rate type. + /// + /// The cancellation token. + /// The meter reading. + /// + /// The requested scale and rate type must be representable in the negotiated command class + /// version: the Scale field does not exist at V1, holds values 0-3 at V2, and values 0-6 at V3; + /// the M.S.T scales (with the Scale 2 field) and the Rate Type field only exist at V4 and later. + /// A Get that differs from an in-flight Get only in fields where either side requests the + /// device default is rejected, as is an identical duplicate: the device's reports cannot be + /// attributed to such requests, and a duplicate would not offer independent cancellation. + /// + /// + /// if the rate type is Both, the requested + /// scale is not valid for the meter type, or the requested scale or rate type cannot be represented + /// in the negotiated command class version; + /// if a Meter Get that cannot be distinguished from this + /// request, or an identical Get, is already in flight. + /// + public async Task GetAsync( + MeterType? type, + MeterScale? scale, + MeterRateType? rateType, + CancellationToken cancellationToken) + { + if (rateType == MeterRateType.Both) + { + ZWaveException.Throw( + ZWaveErrorCode.CommandInvalidArgument, + "Rate type 'Both' cannot be requested with a Meter Get; request 'Import' or 'Export' instead."); + } + + byte? scaleValue = null; + byte? scale2Value = null; + if (scale.HasValue) + { + MeterType effectiveType = type ?? GetEffectiveMeterType(); + if (!MeterScaleHelper.TryGetEncoding(effectiveType, scale.Value, out byte scaleByte, out byte? scale2Byte)) + { + ZWaveException.Throw( + ZWaveErrorCode.CommandInvalidArgument, + $"Scale '{scale.Value}' is not a valid scale for meter type '{effectiveType}'."); + } + + scaleValue = scaleByte; + scale2Value = scale2Byte; + } + + byte? rateTypeValue = rateType is null or MeterRateType.Unspecified ? null : (byte)rateType.Value; + + // Scale 0 is the device default on the wire, the same as requesting no scale; a missing + // Scale 2 for an M.S.T request defaults to 0, matching the wire encoding. + byte? constrainedScale = scaleValue is > 0 ? scaleValue : null; + byte? constrainedScale2 = constrainedScale == 7 ? (scale2Value ?? 0) : null; + var signature = new MeterGetSignature(constrainedScale, constrainedScale2, rateTypeValue); + + object token = new object(); + lock (_inFlightGets) + { + foreach (MeterGetSignature inFlight in _inFlightGets.Keys) + { + if (!signature.Distinguishes(inFlight)) + { + ZWaveException.Throw( + ZWaveErrorCode.CommandNotReady, + "A Meter Get that cannot be distinguished from this request (including an identical Get) is already in flight; wait for it to complete first."); + } + } + + _inFlightGets.Add(signature, token); + } + + try + { + var command = MeterGetCommand.Create(EffectiveVersion, rateTypeValue, constrainedScale, constrainedScale2); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + + CommandClassFrame reportFrame = await AwaitNextReportAsync( + predicate: frame => MeterReportCommand.MatchesRequest(frame, constrainedScale, constrainedScale2, rateTypeValue), + cancellationToken).ConfigureAwait(false); + + // Release the in-flight slot before the report event so an event handler can issue a + // follow-up Get; the report has been matched and consumed at this point. + RemoveInFlightGet(signature, token); + + MeterReport report = MeterReportCommand.Parse(reportFrame, Logger); + LastReport = report; + OnMeterReportReceived?.Invoke(report); + return report; + } + finally + { + RemoveInFlightGet(signature, token); + } + } + + private void RemoveInFlightGet(MeterGetSignature signature, object token) + { + // Ownership-checked: a follow-up Get issued from the report event may already hold this + // signature's slot, and the completing call must not remove it. + lock (_inFlightGets) + { + if (_inFlightGets.TryGetValue(signature, out object? current) + && ReferenceEquals(current, token)) + { + _inFlightGets.Remove(signature); + } + } + } + + private MeterType GetEffectiveMeterType() + { + if (SupportedMeterType.HasValue) + { + return SupportedMeterType.Value; + } + + ZWaveException.Throw(ZWaveErrorCode.CommandNotReady, "The meter type is not yet known; interview the node first."); + return default; + } + + internal readonly struct MeterGetCommand : ICommand + { + public MeterGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Meter; + + public static byte CommandId => (byte)MeterCommand.Get; + + public CommandClassFrame Frame { get; } + + public static MeterGetCommand Create(byte version, byte? rateType, byte? scale, byte? scale2) + { + if (scale.HasValue && (version <= 1 || (version == 2 && scale.Value > 3) || (version == 3 && scale.Value > 6))) + { + // The Scale field does not exist at V1, holds 0-3 (2 bits) at V2, and 0-6 (3 bits) at + // V3; the M.S.T scale (7) with its Scale 2 field is only available from V4. + ZWaveException.Throw( + ZWaveErrorCode.CommandInvalidArgument, + $"Scale value {scale.Value} cannot be requested with a Meter Get at version {version}."); + } + + if (rateType.HasValue && version < 4) + { + // The Rate Type field only exists at V4+. + ZWaveException.Throw( + ZWaveErrorCode.CommandInvalidArgument, + $"Rate type cannot be requested with a Meter Get at version {version}."); + } + + if (version <= 1) + { + // The V1 Meter Get command has no parameters. + return new MeterGetCommand(CommandClassFrame.Create(CommandClassId, CommandId)); + } + + int scaleValue = scale.GetValueOrDefault(0); + int rateTypeValue = rateType.GetValueOrDefault(0); + + // Scale occupies bits 5-3 for V2+; the rate type occupies bits 7-6 starting at V4. + byte rateTypeBits = (byte)(version >= 4 ? (rateTypeValue & 0b0000_0011) : 0); + byte byte0 = (byte)((rateTypeBits << 6) | ((scaleValue & 0b0000_0111) << 3)); + + // The Scale 2 field is present only at V4+ when the scale field indicates M.S.T (7). + if (version >= 4 && scaleValue == 7) + { + byte scale2Value = scale2.GetValueOrDefault(0); + return new MeterGetCommand(CommandClassFrame.Create(CommandClassId, CommandId, [byte0, scale2Value])); + } + + return new MeterGetCommand(CommandClassFrame.Create(CommandClassId, CommandId, [byte0])); + } + } + + internal readonly struct MeterReportCommand : ICommand + { + public MeterReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Meter; + + public static byte CommandId => (byte)MeterCommand.Report; + + public CommandClassFrame Frame { get; } + + public static MeterReport Parse(CommandClassFrame frame, ILogger logger) + { + ReadOnlySpan span = frame.CommandParameters.Span; + + // Minimum: the two header bytes. + if (span.Length < 2) + { + logger.LogWarning("Meter Report frame is too short ({Length} bytes)", span.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Meter Report frame is too short"); + } + + byte header0 = span[0]; + byte header1 = span[1]; + + MeterType meterType = (MeterType)(header0 & 0b0001_1111); + if (!MeterScaleHelper.IsKnownMeterType(meterType)) + { + logger.LogWarning("Meter Report frame has unknown meter type {MeterType}", (byte)meterType); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, $"Meter Report frame has unknown meter type {(byte)meterType}"); + } + + MeterRateType rateType = (MeterRateType)((header0 >> 5) & 0b0000_0011); + + // The scale is a 3-bit field composed of bit 7 of header0 (Scale 2) and bits 4-3 of header1. + byte scaleByte = (byte)((((header0 & 0b1000_0000) >> 7) << 2) | ((header1 >> 3) & 0b0000_0011)); + byte precision = (byte)((header1 >> 5) & 0b0000_0111); + byte valueSize = (byte)(header1 & 0b0000_0111); + + if (valueSize is not (1 or 2 or 4)) + { + logger.LogWarning("Meter Report frame has invalid value size {Size}", valueSize); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, $"Meter Report frame has invalid value size {valueSize}"); + } + + int offset = 2; + + if (span.Length < offset + valueSize) + { + logger.LogWarning("Meter Report frame is too short for value size {Size} ({Length} bytes)", valueSize, span.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Meter Report frame is too short for value size"); + } + + int rawValue = span.Slice(offset, valueSize).ReadSignedVariableSizeBE(); + offset += valueSize; + double value = rawValue / BinaryExtensions.PowersOfTen[precision]; + + double? previousValue = null; + TimeSpan? deltaTimeResult = null; + byte scale2Byte = 0; + + if (span.Length == offset) + { + // V1 reports end after the Meter Value (Delta Time and Previous Meter Value + // were added in V2). A V2+ frame truncated exactly after the value is + // indistinguishable from a valid V1 frame and is accepted as such. + if (scaleByte == 7) + { + logger.LogWarning("Meter Report frame uses the M.S.T scale without a Scale 2 byte ({Length} bytes)", span.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Meter Report frame uses the M.S.T scale without a Scale 2 byte"); + } + } + else + { + if (span.Length < offset + 2) + { + logger.LogWarning("Meter Report frame has a truncated Delta Time field ({Length} bytes)", span.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Meter Report frame has a truncated Delta Time field"); + } + + ushort deltaTime = span.Slice(offset, 2).ToUInt16BE(); + offset += 2; + + if (deltaTime != 0) + { + if (span.Length < offset + valueSize) + { + logger.LogWarning("Meter Report frame is too short for previous value ({Length} bytes)", span.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Meter Report frame is too short for previous value"); + } + + int rawPrevious = span.Slice(offset, valueSize).ReadSignedVariableSizeBE(); + offset += valueSize; + previousValue = rawPrevious / BinaryExtensions.PowersOfTen[precision]; + + // 0xFFFF indicates an unknown elapsed time. + if (deltaTime != 0xFFFF) + { + deltaTimeResult = TimeSpan.FromSeconds(deltaTime); + } + } + + if (scaleByte == 7) + { + if (span.Length < offset + 1) + { + logger.LogWarning("Meter Report frame is too short for Scale 2 ({Length} bytes)", span.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Meter Report frame is too short for Scale 2"); + } + + scale2Byte = span[offset]; + offset += 1; + } + } + + int scaleIndex = scaleByte < 7 ? scaleByte : 7 + scale2Byte; + MeterScale? resolvedScale = MeterScaleHelper.ResolveScale(meterType, scaleIndex); + if (resolvedScale is null) + { + logger.LogWarning( + "Meter Report frame has unsupported scale (Scale={Scale}, Scale2={Scale2}) for meter type {MeterType}", + scaleByte, + scale2Byte, + meterType); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Meter Report frame has an unsupported scale for the meter type"); + } + + return new MeterReport(meterType, rateType, resolvedScale.Value, value, deltaTimeResult, previousValue); + } + + /// + /// Determines whether a Meter Report frame matches a Meter Get request. + /// + /// The report frame to inspect. + /// + /// The requested 3-bit scale value (0-7), or /0 for the device default. + /// + /// The requested Scale 2 value when is 7 (M.S.T). + /// The requested rate type, or for the device default. + /// + /// True when the frame matches the request, or when the request placed no constraint on a given field. + /// + internal static bool MatchesRequest(CommandClassFrame frame, byte? scale, byte? scale2, byte? rateType) + { + ReadOnlySpan span = frame.CommandParameters.Span; + if (span.Length < 2) + { + return false; + } + + byte header0 = span[0]; + byte header1 = span[1]; + + if (rateType.HasValue) + { + byte reportRateType = (byte)((header0 >> 5) & 0b0000_0011); + if (reportRateType != rateType.Value) + { + return false; + } + } + + // A scale of 0 (or null) requests the device default, which is not known in advance. + if (scale is > 0) + { + // The scale is a 3-bit field composed of bit 7 of header0 and bits 4-3 of header1. + byte reportScale = (byte)((((header0 & 0b1000_0000) >> 7) << 2) | ((header1 >> 3) & 0b0000_0011)); + if (reportScale != scale.Value) + { + return false; + } + + if (scale.Value == 7) + { + // M.S.T: the Scale 2 byte follows the optional Previous Meter Value, so its + // position is computed from the Size field, bounds-checking each step. + byte valueSize = (byte)(header1 & 0b0000_0111); + if (valueSize is not (1 or 2 or 4)) + { + return false; + } + + int offset = 2 + valueSize; + if (span.Length < offset + 2) + { + return false; + } + + ushort deltaTime = span.Slice(offset, 2).ToUInt16BE(); + offset += 2; + if (deltaTime != 0) + { + if (span.Length < offset + valueSize) + { + return false; + } + + offset += valueSize; + } + + if (span.Length < offset + 1) + { + return false; + } + + if (span[offset] != (scale2 ?? 0)) + { + return false; + } + } + } + + return true; + } + + public static MeterReportCommand Create( + MeterType meterType, + MeterRateType rateType, + MeterScale scale, + int precision, + double value, + int size, + TimeSpan? deltaTime, + double? previousValue) + { + if (!MeterScaleHelper.TryGetEncoding(meterType, scale, out byte scaleValue, out byte? scale2Value)) + { + ZWaveException.Throw(ZWaveErrorCode.CommandInvalidArgument, $"Scale '{scale}' is not a valid scale for meter type '{meterType}'."); + } + + if (size is not (1 or 2 or 4)) + { + ZWaveException.Throw(ZWaveErrorCode.CommandInvalidArgument, $"Invalid value size {size}; expected 1, 2, or 4."); + } + + if (precision is < 0 or > 7) + { + ZWaveException.Throw(ZWaveErrorCode.CommandInvalidArgument, $"Invalid precision {precision}; expected 0..7."); + } + + int rawValue = (int)Math.Round(value * BinaryExtensions.PowersOfTen[precision]); + int rawPrevious = previousValue.HasValue ? (int)Math.Round(previousValue.Value * BinaryExtensions.PowersOfTen[precision]) : 0; + + ushort deltaTimeValue = 0; + if (previousValue.HasValue) + { + // A present previous value with an unknown elapsed time is encoded as 0xFFFF. + deltaTimeValue = (ushort)(deltaTime.HasValue ? (int)deltaTime.Value.TotalSeconds : 0xFFFF); + } + + Span parameters = stackalloc byte[2 + size + 2 + (previousValue.HasValue ? size : 0) + (scale2Value.HasValue ? 1 : 0)]; + // Byte 0: Scale (2) | Rate Type (2) | Meter Type (5). Scale (2) is the top bit of the 3-bit scale. + parameters[0] = (byte)((((scaleValue >> 2) & 0b1) << 7) + | (((byte)rateType & 0b0000_0011) << 5) + | ((byte)meterType & 0b0001_1111)); + // Byte 1: Precision (3) | Scale (1:0) (2) | Size (3). Scale (1:0) is the low two bits of the 3-bit scale. + parameters[1] = (byte)(((precision & 0b0000_0111) << 5) + | (((scaleValue & 0b0000_0011) << 3) & 0b0001_1000) + | (byte)(size & 0b0000_0111)); + + int offset = 2; + rawValue.WriteSignedVariableSizeBE(parameters.Slice(offset, size)); + offset += size; + deltaTimeValue.WriteBytesBE(parameters.Slice(offset, 2)); + offset += 2; + if (previousValue.HasValue) + { + rawPrevious.WriteSignedVariableSizeBE(parameters.Slice(offset, size)); + offset += size; + } + if (scale2Value.HasValue) + { + parameters[offset] = scale2Value.Value; + } + + return new MeterReportCommand(CommandClassFrame.Create(CommandClassId, CommandId, parameters)); + } + } +} diff --git a/src/ZWave.CommandClasses/MeterCommandClass.Reset.cs b/src/ZWave.CommandClasses/MeterCommandClass.Reset.cs new file mode 100644 index 0000000..346b93e --- /dev/null +++ b/src/ZWave.CommandClasses/MeterCommandClass.Reset.cs @@ -0,0 +1,152 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class MeterCommandClass +{ + /// + /// Resets the accumulated meter value of the given type at the device to . + /// + /// + /// Only Meter command class version 6 supports a targeted reset. Earlier versions can only + /// reset all accumulated values to zero; use for that. + /// The is validated against but not transmitted: + /// per the specification the device applies the scale it used in its last Meter Report, which + /// cannot be selected or guaranteed. The is not transmitted either. + /// + /// The meter type whose accumulated value is reset. + /// The scale of the value being reset. Validated but not transmitted. + /// + /// Accepted for API symmetry with . + /// Not transmitted; the reset wire format carries no rate type. + /// + /// The accumulated value to reset to. + /// The cancellation token. + /// + /// if the scale is not valid for the meter type; + /// if whether the device supports Reset has not yet + /// been discovered; + /// if the negotiated version is below 6 or the device + /// does not advertise Reset support. + /// + public async Task ResetAsync( + MeterType type, + MeterScale scale, + MeterRateType rateType, + int value, + CancellationToken cancellationToken) + { + if (EffectiveVersion < 6) + { + ZWaveException.Throw( + ZWaveErrorCode.CommandNotSupported, + "A targeted Meter Reset requires command class version 6; use ResetAllAsync to reset all accumulated values to zero."); + } + + RequireResetSupportDiscovered(); + + if (!MeterScaleHelper.TryGetEncoding(type, scale, out _, out _)) + { + ZWaveException.Throw(ZWaveErrorCode.CommandInvalidArgument, $"Scale '{scale}' is not a valid scale for meter type '{type}'."); + } + + // The rate type is not part of the reset wire format; it is accepted for API symmetry only. + _ = rateType; + + var command = MeterResetCommand.Create(EffectiveVersion, type, value); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + /// + /// Resets all accumulated meter values at the device to zero. + /// + /// + /// This is the legacy (version 2-5) reset, which has no parameters. Version 6 has no + /// parameterless reset; use + /// with a value of zero for the device's meter type instead. + /// + /// The cancellation token. + /// + /// if whether the device supports Reset has not yet + /// been discovered; + /// if the negotiated version is below 2 or is 6, or the + /// device does not advertise Reset support. + /// + public async Task ResetAllAsync(CancellationToken cancellationToken) + { + if (EffectiveVersion is < 2 or >= 6) + { + ZWaveException.Throw( + ZWaveErrorCode.CommandNotSupported, + EffectiveVersion >= 6 + ? "Version 6 has no parameterless reset; use ResetAsync with a value of zero instead." + : "This node does not support the Meter Reset command."); + } + + RequireResetSupportDiscovered(); + + var command = MeterResetCommand.CreateResetAll(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + } + + private void RequireResetSupportDiscovered() + { + if (!ResetSupported.HasValue) + { + ZWaveException.Throw( + ZWaveErrorCode.CommandNotReady, + "Whether the device supports the Meter Reset command is not yet known; interview the node first."); + } + + if (ResetSupported is false) + { + ZWaveException.Throw( + ZWaveErrorCode.CommandNotSupported, + "The device does not support the Meter Reset command."); + } + } + + internal readonly struct MeterResetCommand : ICommand + { + public MeterResetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Meter; + + public static byte CommandId => (byte)MeterCommand.Reset; + + public CommandClassFrame Frame { get; } + + /// + /// Creates the V6 targeted reset: Size (3 bits) | Meter Type (5 bits), followed by the signed + /// Meter Value (1, 2, or 4 bytes). + /// + public static MeterResetCommand Create(byte version, MeterType meterType, int value) + { + if (version < 6) + { + ZWaveException.Throw( + ZWaveErrorCode.CommandInvalidArgument, + $"A targeted Meter Reset is only available at version 6; the negotiated version is {version}."); + } + + int size = value.GetSignedVariableSize(); + byte byte0 = (byte)(((size & 0b0000_0111) << 5) | ((byte)meterType & 0b0001_1111)); + + Span parameters = stackalloc byte[1 + size]; + parameters[0] = byte0; + value.WriteSignedVariableSizeBE(parameters.Slice(1, size)); + + return new MeterResetCommand(CommandClassFrame.Create(CommandClassId, CommandId, parameters)); + } + + /// + /// Creates the legacy V2-5 reset, which has no parameters and resets all accumulated + /// values to zero. + /// + public static MeterResetCommand CreateResetAll() + => new(CommandClassFrame.Create(CommandClassId, CommandId)); + } +} diff --git a/src/ZWave.CommandClasses/MeterCommandClass.Supported.cs b/src/ZWave.CommandClasses/MeterCommandClass.Supported.cs new file mode 100644 index 0000000..26b5b0a --- /dev/null +++ b/src/ZWave.CommandClasses/MeterCommandClass.Supported.cs @@ -0,0 +1,257 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents the supported scales and capabilities advertised by a meter. +/// +public readonly record struct MeterSupportedReport( + /// + /// The meter type implemented by the device. + /// + MeterType Type, + + /// + /// The rate type(s) supported by the device (import, export, or both). + /// + MeterRateType RateType, + + /// + /// Indicates whether the device supports the Meter Reset command. + /// + bool ResetSupported, + + /// + /// The set of scales supported by the device for the advertised meter type. + /// + IReadOnlySet SupportedScales); + +public sealed partial class MeterCommandClass +{ + private MeterSupportedReport? _supported; + + /// + /// Gets the meter type implemented by the device, or if not yet known. + /// + public MeterType? SupportedMeterType => _supported?.Type; + + /// + /// Gets the rate type(s) supported by the device, or if not yet known. + /// + public MeterRateType? SupportedRateType => _supported?.RateType; + + /// + /// Gets whether the device supports the Meter Reset command, or if not yet known. + /// + public bool? ResetSupported => _supported?.ResetSupported; + + /// + /// Gets the scales supported by the device, or if not yet known. + /// + public IReadOnlySet? SupportedScales => _supported?.SupportedScales; + + /// + /// Requests the supported scales and capabilities from the device. + /// + /// The cancellation token. + /// The supported scales and capabilities. + public async Task GetSupportedAsync(CancellationToken cancellationToken) + { + var command = MeterSupportedGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + MeterSupportedReport report = MeterSupportedReportCommand.Parse(reportFrame, Logger); + + _supported = report; + + return report; + } + + internal readonly struct MeterSupportedGetCommand : ICommand + { + public MeterSupportedGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Meter; + + public static byte CommandId => (byte)MeterCommand.SupportedGet; + + public CommandClassFrame Frame { get; } + + public static MeterSupportedGetCommand Create() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId); + return new MeterSupportedGetCommand(frame); + } + } + + internal readonly struct MeterSupportedReportCommand : ICommand + { + public MeterSupportedReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Meter; + + public static byte CommandId => (byte)MeterCommand.SupportedReport; + + public CommandClassFrame Frame { get; } + + public static MeterSupportedReport Parse(CommandClassFrame frame, ILogger logger) + { + ReadOnlySpan span = frame.CommandParameters.Span; + + // Minimum: the two header bytes. + if (span.Length < 2) + { + logger.LogWarning("Meter Supported Report frame is too short ({Length} bytes)", span.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Meter Supported Report frame is too short"); + } + + byte header0 = span[0]; + byte header1 = span[1]; + + bool resetSupported = (header0 & 0b1000_0000) != 0; + MeterRateType rateType = (MeterRateType)((header0 >> 5) & 0b0000_0011); + + MeterType meterType = (MeterType)(header0 & 0b0001_1111); + if (!MeterScaleHelper.IsKnownMeterType(meterType)) + { + logger.LogWarning("Meter Supported Report frame has unknown meter type {MeterType}", (byte)meterType); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, $"Meter Supported Report frame has unknown meter type {(byte)meterType}"); + } + + bool moreScaleTypes = (header1 & 0b1000_0000) != 0; + + HashSet scales = new HashSet(); + + // "Scale Supported Byte 1": bits 0-6 map to scale indices 0-6. + byte firstScaleByte = (byte)(header1 & 0b0111_1111); + AddScalesForByte(scales, meterType, firstScaleByte, startIndex: 0, maxBits: 7); + + if (moreScaleTypes) + { + if (span.Length < 3) + { + logger.LogWarning("Meter Supported Report frame is too short for the scale byte count ({Length} bytes)", span.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Meter Supported Report frame is too short for the scale byte count"); + } + + int count = span[2]; + if (span.Length < 3 + count) + { + logger.LogWarning("Meter Supported Report frame is too short for {Count} scale bytes ({Length} bytes)", count, span.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Meter Supported Report frame is too short for the declared scale byte count"); + } + + // Subsequent bytes use all 8 bits, continuing the scale index from 7. + for (int i = 0; i < count; i++) + { + AddScalesForByte(scales, meterType, span[3 + i], startIndex: 7 + (i * 8), maxBits: 8); + } + } + + return new MeterSupportedReport(meterType, rateType, resetSupported, scales); + } + + private static void AddScalesForByte( + HashSet scales, + MeterType meterType, + byte bitMask, + int startIndex, + int maxBits) + { + for (int bit = 0; bit < maxBits; bit++) + { + if ((bitMask & (1 << bit)) != 0) + { + if (MeterScaleHelper.ResolveScale(meterType, startIndex + bit) is MeterScale scale) + { + scales.Add(scale); + } + } + } + } + + public static MeterSupportedReportCommand Create( + MeterType meterType, + MeterRateType rateType, + bool resetSupported, + IReadOnlySet supportedScales) + { + int maxIndex = -1; + int firstByte = 0; + foreach (MeterScale scale in supportedScales) + { + int? index = GetIndex(meterType, scale); + if (index is null) + { + continue; + } + + if (index.Value <= 6) + { + firstByte |= 1 << index.Value; + } + + if (index.Value > maxIndex) + { + maxIndex = index.Value; + } + } + + byte byte0 = (byte)( + (resetSupported ? 0b1000_0000 : 0) + | ((byte)rateType << 5) + | ((byte)meterType & 0b0001_1111)); + + if (maxIndex <= 6) + { + byte header1Compact = (byte)(firstByte & 0b0111_1111); + return new MeterSupportedReportCommand(CommandClassFrame.Create(CommandClassId, CommandId, [byte0, header1Compact])); + } + + // More scale types: set the M.S.T bit, then the byte count, then the scale bytes. + byte header1 = (byte)(0b1000_0000 | (firstByte & 0b0111_1111)); + int count = (maxIndex - 7) / 8 + 1; + Span scaleBytes = stackalloc byte[count]; + for (int i = 0; i < count; i++) + { + int baseIndex = 7 + (i * 8); + int bits = 0; + foreach (MeterScale scale in supportedScales) + { + int? sIndex = GetIndex(meterType, scale); + if (sIndex.HasValue && sIndex.Value >= baseIndex && sIndex.Value < baseIndex + 8) + { + bits |= 1 << (sIndex.Value - baseIndex); + } + } + + scaleBytes[i] = (byte)bits; + } + + Span parameters = stackalloc byte[3 + count]; + parameters[0] = byte0; + parameters[1] = header1; + parameters[2] = (byte)count; + scaleBytes.CopyTo(parameters[3..]); + + return new MeterSupportedReportCommand(CommandClassFrame.Create(CommandClassId, CommandId, parameters)); + } + + private static int? GetIndex(MeterType meterType, MeterScale scale) + { + // Mirror of MeterScaleHelper.TryGetEncoding, but returns the flat index for encoding. + if (MeterScaleHelper.TryGetEncoding(meterType, scale, out byte scaleValue, out byte? scale2Value)) + { + return scaleValue < 7 ? scaleValue : 7 + (scale2Value ?? 0); + } + + return null; + } + } +} diff --git a/src/ZWave.CommandClasses/MeterCommandClass.cs b/src/ZWave.CommandClasses/MeterCommandClass.cs new file mode 100644 index 0000000..6bfb809 --- /dev/null +++ b/src/ZWave.CommandClasses/MeterCommandClass.cs @@ -0,0 +1,323 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// The type of metering physical unit being reported. +/// +public enum MeterType : byte +{ + /// + /// Electric meter (e.g. kWh, kVAh, W, V, A). + /// + Electric = 0x01, + + /// + /// Gas meter (e.g. cubic meters, cubic feet). + /// + Gas = 0x02, + + /// + /// Water meter (e.g. cubic meters, cubic feet, US gallons). + /// + Water = 0x03, + + /// + /// Heating meter (e.g. kWh). + /// + Heating = 0x04, + + /// + /// Cooling meter (e.g. kWh). + /// + Cooling = 0x05, +} + +/// +/// The unit of measure (scale) of a meter reading. +/// +/// +/// The wire encoding of a scale is specific to a . Use +/// to convert between a scale and its wire encoding. +/// The underlying value of this enum is an identifier, not a wire value. +/// +public enum MeterScale +{ + /// + /// Kilowatt-hour (kWh). + /// + kWh, + + /// + /// Kilovolt-ampere-hour (kVAh). + /// + kVAh, + + /// + /// Watt (W). + /// + W, + + /// + /// Pulse count. + /// + PulseCount, + + /// + /// Volt (V). + /// + V, + + /// + /// Ampere (A). + /// + A, + + /// + /// Power factor. + /// + PowerFactor, + + /// + /// Kilovolt-ampere reactive (kVar). + /// + kVar, + + /// + /// Kilovolt-ampere reactive hour (kVarh). + /// + kVarh, + + /// + /// Cubic meters (m³). + /// + CubicMeters, + + /// + /// Cubic feet (ft³). + /// + CubicFeet, + + /// + /// US gallons. + /// + USGallons, +} + +/// +/// The rate type of a meter reading, indicating whether it advertises an import (consumed) or +/// export (produced) value. +/// +public enum MeterRateType : byte +{ + /// + /// The rate type is unspecified, or the device's default rate type should be used. + /// + Unspecified = 0x00, + + /// + /// Import (consumed) value. + /// + Import = 0x01, + + /// + /// Export (produced) value. + /// + Export = 0x02, + + /// + /// Both import and export values are supported. Used in the Supported Report. + /// + Both = 0x03, +} + +/// +/// Commands for the Meter Command Class. +/// +public enum MeterCommand : byte +{ + /// + /// Request the current meter reading from a supporting node. + /// + Get = 0x01, + + /// + /// Advertise the current meter reading from a supporting node. + /// + Report = 0x02, + + /// + /// Request the supported scales and capabilities from a supporting node. + /// + SupportedGet = 0x03, + + /// + /// Advertise the supported scales and capabilities from a supporting node. + /// + SupportedReport = 0x04, + + /// + /// Reset the accumulated meter value(s) at a supporting node. + /// + Reset = 0x05, +} + +/// +/// Implements the Meter Command Class (CC 0x32). +/// +[CommandClass(CommandClassId.Meter)] +public sealed partial class MeterCommandClass : CommandClass +{ + internal MeterCommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + } + + /// + public override bool? IsCommandSupported(MeterCommand command) + => command switch + { + MeterCommand.Get => true, + MeterCommand.SupportedGet => Version.HasValue ? Version >= 2 : null, + MeterCommand.Reset => ResetSupported.HasValue ? ResetSupported.Value : null, + _ => false, + }; + + /// + internal override async Task InterviewAsync(CancellationToken cancellationToken) + { + if (IsCommandSupported(MeterCommand.SupportedGet).GetValueOrDefault()) + { + _ = await GetSupportedAsync(cancellationToken).ConfigureAwait(false); + } + + _ = await GetAsync(type: null, scale: null, rateType: null, cancellationToken).ConfigureAwait(false); + } + + /// + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((MeterCommand)frame.CommandId) + { + case MeterCommand.Report: + { + MeterReport report = MeterReportCommand.Parse(frame, Logger); + LastReport = report; + OnMeterReportReceived?.Invoke(report); + break; + } + } + } +} + +/// +/// Maps between a and its wire encoding, which is specific to a +/// . +/// +/// +/// The spec encodes a scale as a 3-bit value (0..6) plus an optional 8-bit "Scale 2" value when +/// the 3-bit value is 7 (M.S.T). This is flattened into a per-type "scale index" where indices +/// 0..6 map directly to the 3-bit value and indices 7 and 8 map to Scale 2 values 0 and 1. +/// +internal static class MeterScaleHelper +{ + /// + /// Determines whether a meter type value is a defined value per the specification. + /// + public static bool IsKnownMeterType(MeterType meterType) + => meterType is >= MeterType.Electric and <= MeterType.Cooling; + + /// + /// Resolves a scale index to a for the given meter type. + /// + /// The meter type. + /// + /// The scale index (0..6 for the 3-bit scale field, or 7/8 for the Scale 2 field). + /// + /// The scale, or if the index is reserved for this meter type. + public static MeterScale? ResolveScale(MeterType meterType, int scaleIndex) + => (meterType, scaleIndex) switch + { + (MeterType.Electric, 0) => MeterScale.kWh, + (MeterType.Electric, 1) => MeterScale.kVAh, + (MeterType.Electric, 2) => MeterScale.W, + (MeterType.Electric, 3) => MeterScale.PulseCount, + (MeterType.Electric, 4) => MeterScale.V, + (MeterType.Electric, 5) => MeterScale.A, + (MeterType.Electric, 6) => MeterScale.PowerFactor, + (MeterType.Electric, 7) => MeterScale.kVar, + (MeterType.Electric, 8) => MeterScale.kVarh, + (MeterType.Gas, 0) => MeterScale.CubicMeters, + (MeterType.Gas, 1) => MeterScale.CubicFeet, + (MeterType.Gas, 3) => MeterScale.PulseCount, + (MeterType.Water, 0) => MeterScale.CubicMeters, + (MeterType.Water, 1) => MeterScale.CubicFeet, + (MeterType.Water, 2) => MeterScale.USGallons, + (MeterType.Water, 3) => MeterScale.PulseCount, + (MeterType.Heating, 0) => MeterScale.kWh, + (MeterType.Cooling, 0) => MeterScale.kWh, + _ => null, + }; + + /// + /// Gets the wire encoding for a on the given meter type. + /// + /// The meter type. + /// The scale. + /// When the method returns, the 3-bit scale value (0..7). + /// + /// When the method returns, the Scale 2 value, or if not present. + /// + /// if the scale is valid for the meter type. + public static bool TryGetEncoding(MeterType meterType, MeterScale scale, out byte scaleValue, out byte? scale2Value) + { + int? index = GetScaleIndex(meterType, scale); + if (index is null) + { + scaleValue = 0; + scale2Value = null; + return false; + } + + if (index.Value <= 6) + { + scaleValue = (byte)index.Value; + scale2Value = null; + } + else + { + scaleValue = 7; + scale2Value = (byte)(index.Value - 7); + } + + return true; + } + + private static int? GetScaleIndex(MeterType meterType, MeterScale scale) + => (meterType, scale) switch + { + (MeterType.Electric, MeterScale.kWh) => 0, + (MeterType.Electric, MeterScale.kVAh) => 1, + (MeterType.Electric, MeterScale.W) => 2, + (MeterType.Electric, MeterScale.PulseCount) => 3, + (MeterType.Electric, MeterScale.V) => 4, + (MeterType.Electric, MeterScale.A) => 5, + (MeterType.Electric, MeterScale.PowerFactor) => 6, + (MeterType.Electric, MeterScale.kVar) => 7, + (MeterType.Electric, MeterScale.kVarh) => 8, + (MeterType.Gas, MeterScale.CubicMeters) => 0, + (MeterType.Gas, MeterScale.CubicFeet) => 1, + (MeterType.Gas, MeterScale.PulseCount) => 3, + (MeterType.Water, MeterScale.CubicMeters) => 0, + (MeterType.Water, MeterScale.CubicFeet) => 1, + (MeterType.Water, MeterScale.USGallons) => 2, + (MeterType.Water, MeterScale.PulseCount) => 3, + (MeterType.Heating, MeterScale.kWh) => 0, + (MeterType.Cooling, MeterScale.kWh) => 0, + _ => null, + }; +}