Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/AR.Iec61850/Binding/Iec61850ValueBindingEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
45 changes: 45 additions & 0 deletions src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,51 @@ public async Task<MmsPersistentReportMonitorStartResult> StartPersistentReportMo
dataSetSnapshots.Add(dataSetBefore);
}

if (isDynamic)
{
if (!rcb.Attributes.Contains("TrgOps", StringComparer.OrdinalIgnoreCase) ||
!MmsReportControlFieldCodec.TryEncodeTriggerOptions(rcb.TriggerOptions, out var triggerOptions))
Comment on lines +187 to +188

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid returning after mutating the dynamic RCB

In dynamic mode this check runs after the temporary DataSet has already been created and RCB.DatSet has already been written, but this failure path returns without registering a session, deleting the DataSet, or restoring the original DatSet. Any relay whose TrgOps is missing or whose current value cannot be tokenized will be left with the temporary DataSet bound even though start reports failure.

Useful? React with 👍 / 👎.

{
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.");
Expand Down
90 changes: 90 additions & 0 deletions src/AR.Iec61850/Mms/MmsReportControlFieldCodec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
namespace AR.Iec61850.Mms;

/// <summary>
/// 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).
/// </summary>
public static class MmsReportControlFieldCodec
{
private static readonly IReadOnlyDictionary<string, int> TriggerOptionBits =
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["dchg"] = 0,
["data-change"] = 0,
["datachange"] = 0,
Comment on lines +12 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the reserved TrgOps bit

When dynamic monitoring writes TrgOps, this mapping shifts every trigger one bit too far left: the existing report runtime documents and parses TrgOps with bit 0 reserved and dchg at bit 1, so a request such as dchg qchg GI is encoded as reserved+dchg+integrity rather than data-change/quality-change/general-interrogation. On live IEDs or the in-repo simulator this can leave the intended triggers disabled or make the RCB reject the write before reports are enabled.

Useful? React with 👍 / 👎.

["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<string, int> OptionalFieldBits =
new Dictionary<string, int>(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,
Comment on lines +33 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep existing OptFlds aliases when re-encoding

The new encoder silently drops unknown optional-field tokens while still writing any recognized ones, but existing discovery/simulator text uses aliases such as seqNum, timeStamp, reasonCode, and report-time-stamp; those are not in this map. With those common inputs the OptFlds write strips sequence number, timestamp, and reason diagnostics instead of preserving/requesting them, so subsequent report decoding loses the evidence this path is trying to enable.

Useful? React with 👍 / 👎.

["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<string, int> map,
int bitCount,
out MmsDataValue value)
{
value = MmsDataValue.BitString((byte)((8 - bitCount % 8) % 8), ReadOnlySpan<byte>.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<string> Tokenize(string? text)
=> (text ?? string.Empty)
.Split(new[] { ' ', ',', ';', '|', '+', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(token => token.Trim().Trim('[', ']', '(', ')').ToLowerInvariant());
}
2 changes: 1 addition & 1 deletion src/AR.Iec61850/Mms/MmsReportSubscriptionPlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ private static IReadOnlyList<string> 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.",
Expand Down
28 changes: 28 additions & 0 deletions tests/AR.Iec61850.Tests/Mms/MmsReportControlFieldCodecTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
33 changes: 33 additions & 0 deletions tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

}
Loading