Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
28cf089
Add temporary engine inventory workflow
masarray Jul 12, 2026
75eacf4
Run temporary engine inventory on pull requests
masarray Jul 12, 2026
920ce26
Relocate tracked engine source with git mv
masarray Jul 12, 2026
3620791
Relocate shared engine source under ARSVIN.Engine
github-actions[bot] Jul 12, 2026
f7cc923
Add deterministic HexDump regression tests
masarray Jul 12, 2026
4a2c53b
Add PCAP reader and writer regression tests
masarray Jul 12, 2026
e748c81
Add MMS data codec regression tests
masarray Jul 12, 2026
10b9b32
Add MMS object reference regression tests
masarray Jul 12, 2026
14f14c2
Add COMTRADE parser and scaling regression tests
masarray Jul 12, 2026
f46b688
Add SCL parser stream and warning regression tests
masarray Jul 12, 2026
efe9fc2
Add Sampled Values publisher session regression tests
masarray Jul 12, 2026
7fc0bd6
Apply configured sample counter wrap within multi-ASDU frames
masarray Jul 12, 2026
51c2c10
Pass session sample counter wrap into frame construction
masarray Jul 12, 2026
eae5ae1
Raise whole-engine and protocol-core coverage floors
masarray Jul 12, 2026
231dbde
Raise enforced engine coverage baselines
masarray Jul 12, 2026
e9aa404
Document relocated engine and expanded test baselines
masarray Jul 12, 2026
bc8e57b
Document relocated engine and raised coverage gates
masarray Jul 12, 2026
fa1d1e5
Record engine relocation, expanded tests, and smpCnt wrap fix
masarray Jul 12, 2026
7967a15
Remove temporary engine relocation workflow
masarray Jul 12, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jobs:

- name: Test with whole-engine and protocol-core coverage gates
shell: pwsh
run: .\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 5.5 -MinimumLineCoverage 50 -NoRestore
run: .\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 10.5 -MinimumLineCoverage 60 -NoRestore

- name: Upload test and coverage evidence
if: always()
Expand Down
12 changes: 9 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ All notable ARSVIN changes are documented here using a lightweight Keep a Change
- Added recursive validation for all HTML metadata, structured data, canonical uniqueness, local references, search-index targets, sitemap coverage, web-manifest icons, and robots metadata.
- Added committed NuGet lock files for Publisher, Subscriber, the shared engine, and Tests.
- Added CI evidence upload for the committed dependency lock graph.
- Added a 5.5% whole-engine line-coverage regression floor alongside the existing 50% protocol-core floor.
- Added whole-engine and protocol-core regression floors of 10.5% and 60% respectively.
- Expanded the deterministic test suite from 26 to 54 tests across SCL, COMTRADE, PCAP, MMS, diagnostics, and Sampled Values publisher sessions.

### Changed

Expand All @@ -23,6 +24,12 @@ All notable ARSVIN changes are documented here using a lightweight Keep a Change
- README, build guidance, contributor guidance, repository structure, release examples, and coverage baselines now match the current shared-engine implementation.
- CI, CodeQL, and release validation now use the explicit `windows-2025` runner image; Pages and release publication use `ubuntu-24.04`.
- GitHub Pages now retriggers when the Python public-site validator changes.
- Moved all shared engine source physically from `src/ARSVIN/Engine` into the owning `src/ARSVIN.Engine` project without duplicating compilation.
- Raised whole-engine line coverage from 5.64% to 10.74% and protocol-core coverage from 57.89% to 64.97%.

### Fixed

- Multi-ASDU Publisher sessions now apply the configured `smpCnt` wrap to every ASDU inside a frame, preventing counters such as `4000` and `4001` when a 4,000-sample wrap is configured.

### Security

Expand All @@ -32,8 +39,7 @@ All notable ARSVIN changes are documented here using a lightweight Keep a Change

### Planned

- Move the engine source directory physically under `src/ARSVIN.Engine` after the shared-assembly transition has proven stable.
- Expand protocol regression tests and raise the whole-engine coverage baseline progressively.
- Continue expanding deterministic regression tests for remaining live capture, MMS service, scheduling, and device-interoperability paths.
- Add Windows Authenticode signing when a trusted certificate becomes available.

## 0.3.1 — 2026-07-12
Expand Down
30 changes: 16 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,13 @@ Compatibility wrapper:
.\publish-win-x64.ps1 -Version 0.3.1
```

Run tests with the repository coverage gate and retain TRX/Cobertura evidence:
Run tests with both repository coverage gates and retain TRX/Cobertura evidence:

```powershell
.\scripts\test-with-coverage.ps1 -MinimumLineCoverage 50
.\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 10.5 -MinimumLineCoverage 60
```

The complete shared `ARSVIN.Engine` baseline currently measures 5.64% line coverage across 15,726 instrumented production lines. The established protocol-core regression surface measures 57.89% across 1,534 lines, with 888 covered lines; CI enforces a 50% floor on that tested protocol-core surface while broader engine tests are added.
The current suite contains 54 deterministic tests. The complete shared `ARSVIN.Engine` baseline measures 10.74% line coverage across 15,742 instrumented production lines, with 1,691 covered lines. The protocol-core regression surface measures 64.97% across 1,550 lines, with 1,007 covered lines. CI enforces floors of 10.5% for the whole engine and 60% for protocol core.

Generate a CycloneDX SBOM after restoring the solution:

Expand All @@ -171,19 +171,21 @@ Generate a CycloneDX SBOM after restoring the solution:
## Repository structure

```text
src/ARSVIN.Engine/ Shared IEC 61850 protocol, SCL, capture, transport, and SV engine project
src/ARSVIN/ Publisher application
src/ARSVIN.Subscriber/ ArSubsv subscriber and visualization companion
tests/ARSVIN.Tests/ Protocol and publisher helper tests
installer/ Inno Setup definition for the Windows suite
scripts/ Repeatable release packaging and validation scripts
docs/ Engineering, safety, and contributor documentation
samples/ SCL, COMTRADE, scenario, and evidence samples
site/ Static, SEO-ready GitHub Pages product site
.github/workflows/ CI, CodeQL, Pages, and release automation
src/ARSVIN.Engine/ Shared production engine project and source ownership
src/ARSVIN.Engine/AR.Iec61850/ IEC 61850, SCL, SV, MMS, capture, diagnostics, and protocol code
src/ARSVIN.Engine/AR.Iec61850.Transports.Npcap/ Npcap transport implementation
src/ARSVIN/ Publisher application
src/ARSVIN.Subscriber/ ArSubsv subscriber and visualization companion
tests/ARSVIN.Tests/ Deterministic engine and publisher regression tests
installer/ Inno Setup definition for the Windows suite
scripts/ Repeatable release packaging and validation scripts
docs/ Engineering, safety, and contributor documentation
samples/ SCL, COMTRADE, scenario, and evidence samples
site/ Static, SEO-ready GitHub Pages product site
.github/workflows/ CI, CodeQL, Pages, and release automation
```

The shared engine is compiled once as `ARSVIN.Engine`; its source files are still physically located under `src/ARSVIN/Engine` during the staged directory migration.
The shared engine is compiled once as `ARSVIN.Engine`, and its source is physically owned by the same `src/ARSVIN.Engine` project used by Publisher, Subscriber, and Tests.

## Documentation

Expand Down
30 changes: 17 additions & 13 deletions docs/build-and-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,35 +48,39 @@ The script restores the solution in locked mode, then builds and tests:
- `src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj`
- `tests/ARSVIN.Tests/ARSVIN.Tests.csproj`

The shared production source is physically owned by `src/ARSVIN.Engine`, including `AR.Iec61850` and the Npcap transport implementation. Publisher, Subscriber, and Tests all reference that same compiled assembly.

External command exit codes are checked, and compiler warnings are treated as errors for the validated build path.

### Coverage evidence

```powershell
.\scripts\test-with-coverage.ps1 -MinimumLineCoverage 50
.\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 10.5 -MinimumLineCoverage 60
```

The script:

1. runs the xUnit suite using pinned Coverlet MSBuild instrumentation,
2. instruments the complete shared production `ARSVIN.Engine` assembly,
3. writes TRX, the complete `dotnet test` log, and Cobertura evidence under `artifacts/test-results`,
4. reports whole-engine coverage transparently,
5. calculates the regression gate over the established protocol-core surface,
6. fails when no production lines are instrumented or protocol-core coverage falls below the configured threshold.
4. calculates and enforces the whole-engine regression floor,
5. calculates and enforces the established protocol-core regression floor,
6. fails when no production lines are instrumented or either configured floor is missed.

Current verified baselines:
Current verified baselines from 54 deterministic tests:

| Metric | Result |
|---|---:|
| Whole `ARSVIN.Engine` instrumented lines | 15,726 |
| Whole-engine line coverage | 5.64% |
| Protocol-core instrumented lines | 1,534 |
| Protocol-core covered lines | 888 |
| Protocol-core line coverage | 57.89% |
| Enforced protocol-core floor | 50% |

This is not a claim that the complete WPF UI or every live-network path is covered. Whole-engine coverage is intentionally shown as a transparent baseline and must rise as SCL, COMTRADE, capture, diagnostics, MMS, scheduling, and transport tests are expanded.
| Whole `ARSVIN.Engine` instrumented lines | 15,742 |
| Whole-engine covered lines | 1,691 |
| Whole-engine line coverage | 10.74% |
| Enforced whole-engine floor | 10.5% |
| Protocol-core instrumented lines | 1,550 |
| Protocol-core covered lines | 1,007 |
| Protocol-core line coverage | 64.97% |
| Enforced protocol-core floor | 60% |

The expanded suite covers deterministic behavior across Sampled Values, SCL parsing, COMTRADE parsing and scaling, PCAP read/write handling, MMS data and object references, diagnostics, Ethernet framing, transport helpers, and publisher session behavior. This is not a claim that the complete WPF UI or every live-network path is covered; live Npcap, timing, and device-interoperability behavior still require controlled laboratory validation.

## Validate the public site

Expand Down
6 changes: 3 additions & 3 deletions scripts/test-with-coverage.ps1
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
[CmdletBinding()]
param(
[ValidateRange(0, 100)]
[double] $MinimumLineCoverage = 50,
[double] $MinimumLineCoverage = 60,

[ValidateRange(0, 100)]
[double] $MinimumWholeEngineLineCoverage = 5.5,
[double] $MinimumWholeEngineLineCoverage = 10.5,

[switch] $NoRestore
)
Expand Down Expand Up @@ -33,7 +33,7 @@ if ($NoRestore) {
$arguments += '--no-restore'
}

# Instrument the complete production engine. CI now protects both the truthful
# Instrument the complete production engine. CI protects both the truthful
# whole-engine baseline and the higher protocol-core regression baseline.
$arguments += @(
'/p:RestoreLockedMode=true',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,32 +53,43 @@ public SampledValuesFrame CreateFrame(
ushort sampleCount,
ReadOnlySpan<byte> samplePayload,
Iec61850UtcTime? referenceTime = null,
byte sampleSynchronization = 2)
byte sampleSynchronization = 2,
ushort? sampleCounterWrap = null)
{
if (AsduPerFrame != 1)
throw new InvalidOperationException($"SV {Stream.ControlBlockReference} declares nofASDU={AsduPerFrame}. Use the multi-ASDU CreateFrame overload.");

return CreateFrame(source, sampleCount, new[] { samplePayload.ToArray() }, referenceTime, sampleSynchronization);
return CreateFrame(
source,
sampleCount,
new[] { samplePayload.ToArray() },
referenceTime,
sampleSynchronization,
sampleCounterWrap);
}

public SampledValuesFrame CreateFrame(
MacAddress source,
ushort sampleCount,
IReadOnlyList<byte[]> samplePayloads,
Iec61850UtcTime? referenceTime = null,
byte sampleSynchronization = 2)
byte sampleSynchronization = 2,
ushort? sampleCounterWrap = null)
{
ArgumentNullException.ThrowIfNull(samplePayloads);
ValidateAsduPayloadBatch(samplePayloads);

if (sampleCounterWrap is 1)
throw new ArgumentOutOfRangeException(nameof(sampleCounterWrap), "SV sample counter wrap must be greater than 1 when supplied.");

var asdus = new List<SampledValueAsdu>(samplePayloads.Count);
for (var i = 0; i < samplePayloads.Count; i++)
{
asdus.Add(new SampledValueAsdu
{
SvId = Stream.SvId,
DataSetReference = Stream.DataSetReference,
SampleCount = SampleCounterPolicy.Increment(sampleCount, null, i),
SampleCount = SampleCounterPolicy.Increment(sampleCount, sampleCounterWrap, i),
ConfigurationRevision = Stream.ConfigurationRevision,
ReferenceTime = referenceTime,
SampleSynchronization = sampleSynchronization,
Expand All @@ -103,19 +114,23 @@ public byte[] BuildEthernetFrame(
ushort sampleCount,
ReadOnlySpan<byte> samplePayload,
Iec61850UtcTime? referenceTime = null,
byte sampleSynchronization = 2)
byte sampleSynchronization = 2,
ushort? sampleCounterWrap = null)
{
return SampledValuesFrameBuilder.BuildEthernetFrame(CreateFrame(source, sampleCount, samplePayload, referenceTime, sampleSynchronization));
return SampledValuesFrameBuilder.BuildEthernetFrame(
CreateFrame(source, sampleCount, samplePayload, referenceTime, sampleSynchronization, sampleCounterWrap));
}

public byte[] BuildEthernetFrame(
MacAddress source,
ushort sampleCount,
IReadOnlyList<byte[]> samplePayloads,
Iec61850UtcTime? referenceTime = null,
byte sampleSynchronization = 2)
byte sampleSynchronization = 2,
ushort? sampleCounterWrap = null)
{
return SampledValuesFrameBuilder.BuildEthernetFrame(CreateFrame(source, sampleCount, samplePayloads, referenceTime, sampleSynchronization));
return SampledValuesFrameBuilder.BuildEthernetFrame(
CreateFrame(source, sampleCount, samplePayloads, referenceTime, sampleSynchronization, sampleCounterWrap));
}

public byte[] BuildPayload(IReadOnlyList<MmsDataValue> values)
Expand Down Expand Up @@ -182,7 +197,6 @@ public static double ResolvePublicationRate(double sampleRateHz, ushort noAsdu)
{
if (sampleRateHz <= 0)
throw new ArgumentOutOfRangeException(nameof(sampleRateHz), "Sample rate must be greater than 0.");

if (noAsdu == 0)
throw new ArgumentOutOfRangeException(nameof(noAsdu), "nofASDU must be greater than 0.");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,13 @@ public async ValueTask<byte[]> PublishNextBatchAsync(
var sampleCount = NextSampleCount;
NextSampleCount = SampleCounterPolicy.Increment(sampleCount, SampleCounterWrap, samplePayloads.Count);

var frame = _profile.BuildEthernetFrame(_source, sampleCount, samplePayloads, referenceTime, sampleSynchronization);
var frame = _profile.BuildEthernetFrame(
_source,
sampleCount,
samplePayloads,
referenceTime,
sampleSynchronization,
SampleCounterWrap);
await _transport.SendAsync(frame, cancellationToken).ConfigureAwait(false);
return frame;
}
Expand Down
4 changes: 0 additions & 4 deletions src/ARSVIN.Engine/ARSVIN.Engine.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@
<PackageReference Include="SharpPcap" />
</ItemGroup>

<ItemGroup>
<Compile Include="..\ARSVIN\Engine\**\*.cs" Link="Engine\%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>

<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>ARSVIN</_Parameter1>
Expand Down
1 change: 0 additions & 1 deletion src/ARSVIN/ARSVIN.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
</PropertyGroup>

<ItemGroup>
<Compile Remove="Engine\**\*.cs" />
<ProjectReference Include="..\ARSVIN.Engine\ARSVIN.Engine.csproj" />
</ItemGroup>

Expand Down
66 changes: 66 additions & 0 deletions tests/ARSVIN.Tests/Capture/PcapReaderWriterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System.Buffers.Binary;
using AR.Iec61850.Capture;
using Xunit;

namespace ARSVIN.Tests.Capture;

public sealed class PcapReaderWriterTests
{
[Fact]
public void RoundTripPreservesTimestampAndEthernetFrame()
{
var timestamp = DateTimeOffset.UnixEpoch.AddSeconds(123).AddTicks(12_340);
var frame = new byte[] { 0x01, 0x0C, 0xCD, 0x04, 0x00, 0x01, 0x88, 0xBA };
using var stream = new MemoryStream();

using (var writer = new PcapWriter(stream, leaveOpen: true))
writer.WritePacket(timestamp, frame);

stream.Position = 0;
var packets = PcapReader.ReadAll(stream);

var packet = Assert.Single(packets);
Assert.Equal(timestamp, packet.Timestamp);
Assert.Equal(frame, packet.Frame);
}

[Fact]
public void ReaderRejectsUnsupportedLinkType()
{
using var stream = new MemoryStream();
using (var writer = new PcapWriter(stream, leaveOpen: true))
{
}

var bytes = stream.ToArray();
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(20, 4), 101);

var ex = Assert.Throws<PcapFormatException>(() => PcapReader.ReadAll(new MemoryStream(bytes)));

Assert.Contains("link type", ex.Message, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void ReaderRejectsTruncatedPacketPayload()
{
using var stream = new MemoryStream();
using (var writer = new PcapWriter(stream, leaveOpen: true))
writer.WritePacket(DateTimeOffset.UnixEpoch, new byte[] { 1, 2, 3, 4 });

var truncated = stream.ToArray()[..^1];

var ex = Assert.Throws<PcapFormatException>(() => PcapReader.ReadAll(new MemoryStream(truncated)));

Assert.Contains("payload", ex.Message, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void WriterRejectsWritesAfterDispose()
{
var stream = new MemoryStream();
var writer = new PcapWriter(stream, leaveOpen: true);
writer.Dispose();

Assert.Throws<ObjectDisposedException>(() => writer.WritePacket(DateTimeOffset.UnixEpoch, new byte[] { 1 }));
}
}
Loading
Loading