diff --git a/src/AR.Iec61850/Binding/Iec61850ValueBindingEngine.cs b/src/AR.Iec61850/Binding/Iec61850ValueBindingEngine.cs index 68df6ad..e7d283c 100644 --- a/src/AR.Iec61850/Binding/Iec61850ValueBindingEngine.cs +++ b/src/AR.Iec61850/Binding/Iec61850ValueBindingEngine.cs @@ -576,6 +576,30 @@ public static string DecodeOriginCategory(MmsDataValue? value) } if (value.Kind == MmsDataKind.Boolean) return value.Value is bool b ? b ? 1 : 0 : null; + if (value.Kind == MmsDataKind.BitString) + { + var encoded = value.RawValue.ToArray(); + if (encoded.Length < 2 || encoded[0] > 7) + return null; + + var bitCount = checked((encoded.Length - 1) * 8 - encoded[0]); + if (bitCount is <= 0 or > 63) + return null; + + ulong numeric = 0; + for (var encodedBit = 0; encodedBit < bitCount; encodedBit++) + { + var byteIndex = 1 + encodedBit / 8; + var bitInByte = encodedBit % 8; + if ((encoded[byteIndex] & (0x80 >> bitInByte)) == 0) + continue; + + var numericBit = bitCount - 1 - encodedBit; + numeric |= 1UL << numericBit; + } + + return numeric <= long.MaxValue ? (long)numeric : null; + } return null; } } diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs index c63a8ba..b09f7b4 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs @@ -182,6 +182,51 @@ public async Task StartPersistentReportMo dataSetSnapshots.Add(dataSetBefore); } + if (isDynamic) + { + if (!rcb.Attributes.Contains("TrgOps", StringComparer.OrdinalIgnoreCase) || + !MmsReportControlFieldCodec.TryEncodeTriggerOptions(rcb.TriggerOptions, out var triggerOptions)) + { + return new MmsPersistentReportMonitorStartResult + { + IsSuccess = false, + WriteSteps = writes, + Warnings = warnings, + RcbSnapshots = rcbSnapshots, + DataSetSnapshots = dataSetSnapshots, + Message = "Dynamic report monitor requires a writable TrgOps field with explicit dchg trigger configuration." + }; + } + + var triggerWrite = await WriteReportAttributeAsync(rcb, "TrgOps", triggerOptions, cancellationToken).ConfigureAwait(false); + writes.Add(triggerWrite); + if (!triggerWrite.IsSuccess) + { + return new MmsPersistentReportMonitorStartResult + { + IsSuccess = false, + WriteSteps = writes, + Warnings = warnings, + RcbSnapshots = rcbSnapshots, + DataSetSnapshots = dataSetSnapshots, + Message = "RCB.TrgOps write failed; dynamic reporting was not armed because dchg could not be guaranteed." + }; + } + + if (rcb.Attributes.Contains("OptFlds", StringComparer.OrdinalIgnoreCase) && + MmsReportControlFieldCodec.TryEncodeOptionalFields(rcb.OptionalFields, out var optionalFields)) + { + var optionalWrite = await WriteReportAttributeAsync(rcb, "OptFlds", optionalFields, cancellationToken).ConfigureAwait(false); + writes.Add(optionalWrite); + if (!optionalWrite.IsSuccess) + warnings.Add("RCB.OptFlds write failed. Reporting can continue, but report timestamp/reason diagnostics may be incomplete."); + } + else + { + warnings.Add("Dynamic RCB has no writable OptFlds mapping. Reporting can continue, but source timestamp/reason diagnostics may be incomplete."); + } + } + if (rcb.Buffered && rcb.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase)) { warnings.Add("BRCB ResvTms pre-reserve was skipped. This keeps the first monitor attach compatible with relays that accept ownership through RptEna=true."); diff --git a/src/AR.Iec61850/Mms/MmsReportControlFieldCodec.cs b/src/AR.Iec61850/Mms/MmsReportControlFieldCodec.cs new file mode 100644 index 0000000..088169b --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsReportControlFieldCodec.cs @@ -0,0 +1,90 @@ +namespace AR.Iec61850.Mms; + +/// +/// Encodes the IEC 61850 RCB bit-string fields from engineer-readable names. +/// Bit indexes follow IEC 61850-7-2 ordering (MSB first in the MMS bit-string). +/// +public static class MmsReportControlFieldCodec +{ + private static readonly IReadOnlyDictionary TriggerOptionBits = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["dchg"] = 0, + ["data-change"] = 0, + ["datachange"] = 0, + ["qchg"] = 1, + ["quality-change"] = 1, + ["qualitychange"] = 1, + ["dupd"] = 2, + ["data-update"] = 2, + ["dataupdate"] = 2, + ["integrity"] = 3, + ["intg"] = 3, + ["gi"] = 4, + ["general-interrogation"] = 4, + ["generalinterrogation"] = 4, + ["application-trigger"] = 5, + ["applicationtrigger"] = 5 + }; + + private static readonly IReadOnlyDictionary OptionalFieldBits = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["sequence-number"] = 1, + ["sequencenumber"] = 1, + ["sqnum"] = 1, + ["report-timestamp"] = 2, + ["reporttimestamp"] = 2, + ["time-of-entry"] = 2, + ["timeofentry"] = 2, + ["reason-for-inclusion"] = 3, + ["reasonforinclusion"] = 3, + ["data-set"] = 4, + ["dataset"] = 4, + ["data-reference"] = 5, + ["datareference"] = 5, + ["buffer-overflow"] = 6, + ["bufferoverflow"] = 6, + ["entryid"] = 7, + ["entry-id"] = 7, + ["conf-revision"] = 8, + ["confrevision"] = 8, + ["confrev"] = 8, + ["segmentation"] = 9 + }; + + public static bool TryEncodeTriggerOptions(string? text, out MmsDataValue value) + => TryEncode(text, TriggerOptionBits, bitCount: 6, out value); + + public static bool TryEncodeOptionalFields(string? text, out MmsDataValue value) + => TryEncode(text, OptionalFieldBits, bitCount: 10, out value); + + private static bool TryEncode( + string? text, + IReadOnlyDictionary map, + int bitCount, + out MmsDataValue value) + { + value = MmsDataValue.BitString((byte)((8 - bitCount % 8) % 8), ReadOnlySpan.Empty); + var bits = Tokenize(text) + .Select(token => map.TryGetValue(token, out var bit) ? bit : -1) + .Where(bit => bit >= 0 && bit < bitCount) + .Distinct() + .ToArray(); + if (bits.Length == 0) + return false; + + var bytes = new byte[(bitCount + 7) / 8]; + foreach (var bit in bits) + bytes[bit / 8] |= (byte)(0x80 >> (bit % 8)); + + var unusedBits = checked((byte)(bytes.Length * 8 - bitCount)); + value = MmsDataValue.BitString(unusedBits, bytes); + return true; + } + + private static IEnumerable Tokenize(string? text) + => (text ?? string.Empty) + .Split(new[] { ' ', ',', ';', '|', '+', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(token => token.Trim().Trim('[', ']', '(', ')').ToLowerInvariant()); +} diff --git a/src/AR.Iec61850/Mms/MmsReportSubscriptionPlan.cs b/src/AR.Iec61850/Mms/MmsReportSubscriptionPlan.cs index c2ce21a..93c0af2 100644 --- a/src/AR.Iec61850/Mms/MmsReportSubscriptionPlan.cs +++ b/src/AR.Iec61850/Mms/MmsReportSubscriptionPlan.cs @@ -221,7 +221,7 @@ private static IReadOnlyList BuildDynamicSteps(MmsReportControlCandidate [ $"Create dynamic DataSet {dataSetReference} with {points.Count} resolved member(s).", $"Write RCB.DatSet={dataSetReference} on free RCB {rcb.Reference}.", - "Keep current OptFlds/TrgOps for first dynamic test unless the IED requires explicit configuration.", + "Write explicit TrgOps with dchg enabled and request diagnostic OptFlds before RptEna=true.", rcb.Buffered ? "Do not pre-write BRCB ResvTms for first live tests; enable RptEna after DatSet is configured." : "Reserve URCB with Resv=true when supported.", "Install report receiver/dispatcher before enabling RptEna.", "Write RptEna=true, then write GI=true for first full refresh.", diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportControlFieldCodecTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportControlFieldCodecTests.cs new file mode 100644 index 0000000..4f07e90 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsReportControlFieldCodecTests.cs @@ -0,0 +1,28 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsReportControlFieldCodecTests +{ + [Fact] + public void TriggerOptions_Encodes_Dchg_Qchg_Dupd_Integrity_And_Gi() + { + Assert.True(MmsReportControlFieldCodec.TryEncodeTriggerOptions( + "dchg qchg dupd integrity GI", + out var value)); + + Assert.Equal(MmsDataKind.BitString, value.Kind); + Assert.Equal(new byte[] { 2, 0xF8 }, value.RawValue); + } + + [Fact] + public void OptionalFields_Encodes_Event_Diagnostics_And_ConfRev() + { + Assert.True(MmsReportControlFieldCodec.TryEncodeOptionalFields( + "sequence-number report-timestamp reason-for-inclusion data-set data-reference conf-revision", + out var value)); + + Assert.Equal(MmsDataKind.BitString, value.Kind); + Assert.Equal(new byte[] { 6, 0x7C, 0x80 }, value.RawValue); + } +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs index 9aa51be..90819c9 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs @@ -113,4 +113,37 @@ public void Project_Preserves_Q_Only_Report_As_A_Partial_Companion_Update() Assert.False(update.HasTimestamp); Assert.Equal("companion-only", update.ProjectionStatus); } + + [Theory] + [InlineData(0x40, "off")] + [InlineData(0x80, "on")] + public void Project_Decodes_TwoBit_Dbpos_Report_For_Both_Directions(byte encoded, string expected) + { + var frame = new MmsReportFrame + { + ReceivedAt = DateTimeOffset.UtcNow, + Values = + [ + new MmsReportValue + { + Index = 0, + Member = new MmsDataSetDirectoryMember + { + UserReference = "LD0/XCBR1.Pos.stVal", + FunctionalConstraint = "ST" + }, + Value = MmsDataValue.BitString(6, [encoded]), + ReasonForInclusion = ["data-change"] + } + ] + }; + + var update = Assert.Single(MmsReportValueProjector.Project(frame).Updates); + + Assert.Equal("LD0/XCBR1.Pos.stVal", update.Reference); + Assert.Equal(expected, update.Value); + Assert.True(update.HasValue); + Assert.Equal("data-change", update.Reason); + } + }