diff --git a/CMakeLists.txt b/CMakeLists.txt index 7beeadc13..9cb1d57ce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -562,10 +562,15 @@ if(ARIEC61850_BUILD_TESTS) add_test(NAME ariec61850_ptp_sv_wire_audit_selftest COMMAND ariec61850_ptp_sv_wire_audit --self-test) add_test( - NAME ariec61850_pcap_interop_smoke + NAME ariec61850_pcap_interop_detects_noncanonical_legacy COMMAND ariec61850_pcap_interop_check ${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/process_bus_csharp_vectors.pcap --json) + # Historical fixture has min-width integers; under canonical IEC + # widths the audit must reject an exact byte-for-byte re-encode. + # The C++ evidence test also asserts the precise mismatch reason/count. + set_tests_properties(ariec61850_pcap_interop_detects_noncanonical_legacy + PROPERTIES WILL_FAIL TRUE) add_test( NAME ariec61850_comtrade_inspect_smoke COMMAND ariec61850_comtrade_inspect diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 055b56d42..cfa8b0d80 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -187,12 +187,14 @@ qt_add_resources(arstack_studio "reference_templates" PREFIX "/arstack" FILES templates/arstack_4i4v_9-2le_reference.scd + templates/arstack_4i4v_9-2le_metadata_lab.scd ) qt_add_resources(arstack_session_harness "session_harness_reference_templates" PREFIX "/arstack" FILES templates/arstack_4i4v_9-2le_reference.scd + templates/arstack_4i4v_9-2le_metadata_lab.scd ) target_link_libraries(arstack_studio PRIVATE diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index b3c659652..d480d4f10 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -299,6 +299,25 @@ class DeterministicSessionHarness final { firmware.firmwareBuildId_ = QStringLiteral("0123456789abcdef"); firmware.bundleReady_ = true; profileReady = profiles.loadReferenceTemplate(); + if (profileReady) { + // Production Quick Start must compile the VLAN0/PCP4 metadata + // SCL, NOT silently reuse the older no-datSet/no-rate artifact. + const auto metadata = profiles.selectedProfile(); + profileReady = + metadata.value(QStringLiteral("svId")).toString() == QStringLiteral("ARSTACK_SV01") && + metadata.value(QStringLiteral("appId")).toInt() == 0x4000 && + metadata.value(QStringLiteral("vlanPresent")).toBool() && + metadata.value(QStringLiteral("vlanId")).toInt() == 0 && + metadata.value(QStringLiteral("vlanPriority")).toInt() == 4 && + metadata.value(QStringLiteral("includeDataSet")).toBool() && + metadata.value(QStringLiteral("includeSampleRate")).toBool() && + metadata.value(QStringLiteral("wireSmpRate")).toULongLong() == 4000ULL && + metadata.value(QStringLiteral("wireSmpMod")).toInt() == 1 && + metadata.value(QStringLiteral("payloadBytes")).toULongLong() == 64ULL && + metadata.value(QStringLiteral("channelLeafCount")).toULongLong() == 16ULL; + if (!profileReady) + qCritical() << "Quick Start semantic SCL metadata regression"; + } session.setProfiles(&profiles); session.setFirmware(&firmware); session.setDevice(&device); diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index 8bdcbc894..c6c2542cc 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -40,8 +40,8 @@ using ar::iec61850::sampled_values::sv_profile_family_resolution_name; using ar::iec61850::sampled_values::sv_transport_mode_name; constexpr std::uint16_t kReferenceCounterModulus = 4000; -constexpr auto kReferenceResource = ":/arstack/templates/arstack_4i4v_9-2le_reference.scd"; -constexpr auto kReferenceSourceName = "ARStack 4I+4V 9-2LE Reference"; +constexpr auto kReferenceResource = ":/arstack/templates/arstack_4i4v_9-2le_metadata_lab.scd"; +constexpr auto kReferenceSourceName = "ARStack 4I+4V VLAN-0 Metadata Laboratory Reference"; QString qstring(const std::string& value) { return QString::fromStdString(value); @@ -185,7 +185,7 @@ QString SclProfileModel::sourceName() const { QString SclProfileModel::documentStatus() const { if (!document_.has_value()) return QStringLiteral("No engineering file loaded"); if (referenceTemplateActive_) { - return QStringLiteral("4I+4V 9-2LE reference · %1 resolved SV stream%2") + return QStringLiteral("4I+4V VLAN-0 metadata lab · %1 resolved SV stream%2") .arg(static_cast(rows_.size())) .arg(rows_.size() == 1U ? QString{} : QStringLiteral("s")); } diff --git a/apps/arstack_studio/templates/README.md b/apps/arstack_studio/templates/README.md index 27d6c4424..bbfff323c 100644 --- a/apps/arstack_studio/templates/README.md +++ b/apps/arstack_studio/templates/README.md @@ -1,6 +1,10 @@ # ARStack Studio reference templates -The first public-release target intentionally ships one built-in Sampled Values reference profile: +The default 4I+4V Quick Start now uses a metadata-enabled, priority-tagged +IEC 61850 SV laboratory profile. The previously published minimal 9-2LE +reference remains unchanged and available for strict legacy interoperability. + +The default laboratory reference profile has: - **4 current + 4 voltage** (`Ia, Ib, Ic, In, Ua, Ub, Uc, Un`) - `INT32 value + Quality` for each channel @@ -13,7 +17,14 @@ The first public-release target intentionally ships one built-in Sampled Values - VLAN VID 0 / PCP 4 - configuration revision 1 -`arstack_4i4v_9-2le_reference.scd` is a convenience engineering source, not a second runtime profile implementation. ARStack Studio loads it through the same `SclParser -> SvPublisherProfileCompiler -> ESP32-P4 support classifier` path used for external SCL/CID/SCD/IID files. +`arstack_4i4v_9-2le_metadata_lab.scd` is the packaged **Quick Start** source +(802.1Q priority-tagged VID 0 / PCP 4, dataSet=true, sampleRate=true, +smpRate=4000, smpMod=SmpPerSec, 16 ordered leaves, one ASDU). +`arstack_4i4v_9-2le_reference.scd` remains the separate minimal 9-2LE +compatibility fixture with these optional fields absent. Use the matching SCL +and wire profile required by the receiving relay/analyzer. + +Both are engineering sources, not secondary runtime profile implementations. ARStack Studio loads the selected profile through the same `SclParser -> SvPublisherProfileCompiler -> ESP32-P4 support classifier` path used for external SCL/CID/SCD/IID files. ## Counter policy diff --git a/apps/arstack_studio/templates/arstack_4i4v_9-2le_metadata_lab.scd b/apps/arstack_studio/templates/arstack_4i4v_9-2le_metadata_lab.scd new file mode 100644 index 000000000..069ad668f --- /dev/null +++ b/apps/arstack_studio/templates/arstack_4i4v_9-2le_metadata_lab.scd @@ -0,0 +1,72 @@ + +
+ + + + +
+

0

+

4

+

01-0C-CD-04-00-01

+

4000

+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/arstack_studio/templates/arstack_4i4v_untagged_lab.scd b/apps/arstack_studio/templates/arstack_4i4v_untagged_lab.scd index ad5186469..72b2a8e4e 100644 --- a/apps/arstack_studio/templates/arstack_4i4v_untagged_lab.scd +++ b/apps/arstack_studio/templates/arstack_4i4v_untagged_lab.scd @@ -1,5 +1,5 @@ -
+
@@ -36,7 +36,7 @@ - + diff --git a/docs/SV_WIRE_METADATA_INTEROP_2026_11_04.md b/docs/SV_WIRE_METADATA_INTEROP_2026_11_04.md new file mode 100644 index 000000000..36eee2b8c --- /dev/null +++ b/docs/SV_WIRE_METADATA_INTEROP_2026_11_04.md @@ -0,0 +1,21 @@ +# SV wire metadata / analyzer interoperability — 2026-11-04 + +## Independent wire evidence +Operator-supplied 35.03 s classic Ethernet PCAP: 140,116 SV frames, approximately 3,999.91 frame/s, no sample-counter gaps, duplicates or malformed frames in the independent report. One ASDU per frame; each seqData is 64 bytes of eight signed INT32 + UINT32 quality pairs. The current capture sends svID=ARSTACK_SV01, confRev=1, smpCnt with 4,000-count rollover, and smpSynch 0/2. It contains **no ASDU datSet [1], smpRate [6], or smpMod [8]**; it does contain optional gmIdentity [9] despite the bundled SCL setting synchSourceId=false. This capture **does not** establish global time synchronization. + +The older analyzer screenshot says `Mode: Live` and `Scope view · Synthetic locked timebase`; this is **not** the current SVGen WPF raw analyzer (`RawAnalyzerDataSource`). Update the app to the exact tested PR #3 preview before interpreting the new raw waveform path. + +## Standards and profile truth +IEC 61850-9-2 Ed2.1 ASDU requires svID, smpCnt (two octets), confRev (four octets), smpSynch (one octet), seqData. datSet, smpRate, smpMod and gmIdentity are OPTIONAL on the wire; their field presence must match a supported SCL SmvOpts profile and the receiving device's expected dataset. + +- The **default 4I+4V Quick Start** now loads `arstack_4i4v_9-2le_metadata_lab.scd`, matching the operator's actual **802.1Q VID 0 / PCP 4** capture; it explicitly selects `SmvOpts dataSet=true sampleRate=true` and emits configured datSet, smpRate=4000, smpMod=1 (SmpPerSec) through the existing immutable binary profile pipeline. +- `arstack_4i4v_untagged_lab.scd` is separately metadata-enabled for untagged capture networks only; do NOT switch a priority-tagged relay to untagged silently. +- The original `arstack_4i4v_9-2le_reference.scd` stays minimal for strict legacy receivers and historical PCAP regression. This remains an explicitly selected legacy option, not the Quick Start default. +- Canonical BER ASDU now encodes smpCnt=2 bytes, confRev=4, smpSynch=1, smpRate=2 and smpMod=2; firmware still prebuilds packet bytes before 4 kHz TX. +- Do not advertise globally synchronized (2) from an internally generated lab grandmaster. Local measured phase/MAC evidence may support 1, otherwise advertise 0. The unsupported synchSourceId option does not authorize gmIdentity [9]. No real UTC traceability, PTP lock, synchronized MAC egress, accuracy or calibration is claimed. +- The original capture will not gain fields retroactively. To apply new SCL options, operator must **STOP SV**, load the updated lab SCL profile, inspect compiled values, **Deploy with authoritative readback**, then operator **START**. To apply codec/synchronization changes, a revision-qualified firmware update with full stopped-board proof and physical re-test is separately required; never auto-flash or replace a running board. + +## Independent acceptance +Run `python tools/smv_wire_capture_audit.py --minimum-seconds 10 --require-metadata` for metadata-enabled lab profile. This requires both field presence and rate/mode consistency and fails when fields are absent; legacy captures can still pass ordinary parser-only acceptance with no `--require-metadata`. Use `--require-unsynchronized` only in a lab where no qualified source reference exists. Failures do not mean every standards-compliant legacy ASDU must contain optional fields. + +For physical acceptance: corroborate exact source MAC/APPID/VLAN/VID/PCP, svID/confRev/ASDU metadata, SCL DataSet leaf sequence, 4k fps and smpCnt 3999→0, per-channel RMS/phase and quality, capture-drop counters, PTP state and independent UTC traceability. Do not convert synthetic/unit/CI evidence into an interoperability certification. diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 76ab75168..320402947 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -370,37 +370,10 @@ SampledValuesFrame make_runtime_frame( } asdu.sample_payload = make_4i4v_payload(); -#if CONFIG_AR_PTP_LAB_TX - // IEC 61850-9-2 Ed2.1 optional gmIdentity / SynchSrcID. The eight - // bytes MUST equal the GrandmasterIdentity in Announce. On the - // simulated GM bench this is the board's own clock identity; in - // RECEIVER mode it is the measured selected external source. - // Keep legacy 9-2LE unchanged unless a bench GM is selected. - if (!diagnostic_mirror) { - ar_ptp_lab_config_t ptp_config{}; - ar_ptp_timing_authority_t authority{}; - if (ar_ptp_lab_get_config(&ptp_config) && - ar_ptp_lab_get_timing_authority(&authority) && - authority.clock_usable) { - if (ptp_config.role == AR_PTP_ROLE_LAB_SOURCE && - ptp_config.bench_global_gm) { - if (ptp_config.clock_identity_override) { - std::array identity{}; - std::copy_n(ptp_config.clock_identity, 8U, identity.begin()); - asdu.gm_identity = identity; - } else { - asdu.gm_identity = - ar::iec61850::time_sync::ptp_clock_identity_from_mac(source_mac); - } - } else if (ptp_config.role == AR_PTP_ROLE_TIME_RECEIVER && - authority.source_selected) { - std::array identity{}; - std::copy_n(authority.selected_source_clock_identity, 8U, identity.begin()); - asdu.gm_identity = identity; - } - } - } -#endif + // The deployed device capability rejects SmvOpts synchSourceId=true. + // Never append optional [9] gmIdentity when SCL declares it absent. + // Future support must be explicitly compiled/validated from SCL and + // backed by an independently identified reference clock source. SampledValuesFrame frame; frame.destination = MacAddress{std::span{profile.destination_mac}}; diff --git a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp index 88630cd76..c4a05f402 100644 --- a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp +++ b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp @@ -110,12 +110,9 @@ void record_transition(const SvSyncLossReason reason, // originates exclusively from the sample phase + canonical TX gate. // Global sync is reserved for measured external time traceability. if (g_source_local_qualified.load(std::memory_order_acquire)) { -#if defined(ESP_PLATFORM) && CONFIG_AR_PTP_LAB_TX - // The same measured phase/MAC gate qualifies both local and bench - // global simulation. A UI flag alone can never promote AUTO to 2. - if (ar_ptp_lab_bench_global_ready()) - return SmpSynchValue::global_synchronized; -#endif + // An internally generated bench GM is not independently traceable + // to global time. A measured local sample/MAC phase can justify + // smpSynch=1, never smpSynch=2. Keep AUTO fail-closed globally. return SmpSynchValue::local_synchronized; } return ar::iec61850::time_sync::qualified_sv_sync_value( diff --git a/src/sampled_values/pdu_codec.cpp b/src/sampled_values/pdu_codec.cpp index e09566155..d0c63917b 100644 --- a/src/sampled_values/pdu_codec.cpp +++ b/src/sampled_values/pdu_codec.cpp @@ -100,8 +100,9 @@ constexpr std::uint8_t context_constructed_base = 0xA0U; !add_tlv(context_bytes_size(asdu.data_set_reference.size()))) { return std::nullopt; } - if (!add_tlv(context_unsigned_size(asdu.sample_count)) || - !add_tlv(context_unsigned_size(asdu.configuration_revision))) { + // IEC 61850-9-2 wire OCTET STRING SIZE(2) and SIZE(4). + if (!add_tlv(context_bytes_size(2U)) || + !add_tlv(context_bytes_size(4U))) { return std::nullopt; } if (asdu.reference_time.has_value()) { @@ -111,18 +112,18 @@ constexpr std::uint8_t context_constructed_base = 0xA0U; return std::nullopt; } } - if (!add_tlv(context_unsigned_size(asdu.sample_synchronization))) { + if (!add_tlv(context_bytes_size(1U))) { return std::nullopt; } if (asdu.sample_rate.has_value() && - !add_tlv(context_unsigned_size(*asdu.sample_rate))) { + !add_tlv(context_bytes_size(2U))) { return std::nullopt; } if (!add_tlv(context_bytes_size(asdu.sample_payload.size()))) { return std::nullopt; } if (asdu.sample_mode.has_value() && - !add_tlv(context_unsigned_size(*asdu.sample_mode))) { + !add_tlv(context_bytes_size(2U))) { return std::nullopt; } if (asdu.gm_identity.has_value() && @@ -228,6 +229,20 @@ void write_unsigned( } } +// IEC-specific field widths, not minimal BER integer widths. This runs +// only when constructing the prebuilt frame, never in the 4 kHz TX hot path. +void write_unsigned_fixed( + const std::span dst, std::size_t& off, + const std::uint8_t tag, const std::uint64_t value, + const std::size_t width, + SampledValuesEncodedFieldRegion* const region = nullptr) noexcept { + write_tlv_header(dst, off, + static_cast(context_primitive_base | tag), width); + if (region != nullptr) *region = {off, width, true}; + for (std::size_t index = width; index-- > 0U;) + dst[off++] = static_cast((value >> (index * 8U)) & 0xFFU); +} + [[nodiscard]] bool write_asdu( const SampledValueAsdu& asdu, const std::span destination, @@ -246,11 +261,11 @@ void write_unsigned( if (!asdu.data_set_reference.empty()) { write_string(destination, offset, 1U, asdu.data_set_reference); } - write_unsigned( - destination, offset, 2U, asdu.sample_count, + write_unsigned_fixed( + destination, offset, 2U, asdu.sample_count, 2U, layout != nullptr ? &layout->sample_count : nullptr); - write_unsigned( - destination, offset, 3U, asdu.configuration_revision, + write_unsigned_fixed( + destination, offset, 3U, asdu.configuration_revision, 4U, layout != nullptr ? &layout->configuration_revision : nullptr); if (asdu.reference_time.has_value()) { @@ -268,12 +283,12 @@ void write_unsigned( write_bytes(destination, offset, timestamp); } - write_unsigned( - destination, offset, 5U, asdu.sample_synchronization, + write_unsigned_fixed( + destination, offset, 5U, asdu.sample_synchronization, 1U, layout != nullptr ? &layout->sample_synchronization : nullptr); if (asdu.sample_rate.has_value()) { - write_unsigned( - destination, offset, 6U, *asdu.sample_rate, + write_unsigned_fixed( + destination, offset, 6U, *asdu.sample_rate, 2U, layout != nullptr ? &layout->sample_rate : nullptr); } @@ -287,8 +302,8 @@ void write_unsigned( write_bytes(destination, offset, asdu.sample_payload); if (asdu.sample_mode.has_value()) { - write_unsigned( - destination, offset, 8U, *asdu.sample_mode, + write_unsigned_fixed( + destination, offset, 8U, *asdu.sample_mode, 2U, layout != nullptr ? &layout->sample_mode : nullptr); } if (asdu.gm_identity.has_value()) { diff --git a/tests/test_evidence.cpp b/tests/test_evidence.cpp index db945a31f..bb1f35d55 100644 --- a/tests/test_evidence.cpp +++ b/tests/test_evidence.cpp @@ -78,11 +78,11 @@ ByteVector goose_frame() { ByteVector sampled_values_frame() { return from_hex( - "010CCD040001020000000002810080C888BA4001006800000000" - "605E800101A259305780134D55303146312F4C4C4E30244D535643423031" - "81144D55303146312F4C4C4E30245068734D65617331820178830103" + "010CCD040001020000000002810080C888BA4001006D00000000" + "6063800101A25E305C80134D55303146312F4C4C4E30244D535643423031" + "81144D55303146312F4C4C4E30245068734D6561733182020078830400000003" "84086A2BDFE40000000085010286020FA087100000006400000001000000C8" - "00000003880101"); + "0000000388020001"); } std::vector reference_packets() { @@ -131,6 +131,23 @@ void pcap_analyzer_reports_exact_goose_and_sv_round_trips() { CHECK(json.find("Sampled Values") != std::string::npos); } +void pcap_analyzer_detects_old_noncanonical_sv_fixed_field_widths() { + using namespace ar::iec61850; + const auto path = std::filesystem::path{ARIEC61850_SOURCE_DIR} / + "tests/fixtures/process_bus_csharp_vectors.pcap"; + const auto historical = evidence::PcapEquivalenceAnalyzer::analyze(path); + // Preserve historical PCAP evidence unchanged. Legacy minimal-width SV + // decodes, but cannot exactly re-encode under the corrected IEC contract. + CHECK(!historical.passed()); + CHECK(historical.packet_count == 2U); + CHECK(historical.sampled_values_packet_count == 1U); + CHECK(historical.malformed_packet_count == 0U); + CHECK(historical.reencode_mismatch_count == 1U); + CHECK(historical.exact_reencode_match_count == 1U); + CHECK(historical.packets[1].decoded); + CHECK(!historical.packets[1].exact_reencode_match); +} + void pcap_analyzer_rejects_malformed_process_bus_packets() { using namespace ar::iec61850; auto packets = reference_packets(); @@ -182,7 +199,8 @@ void pcap_analyzer_requires_at_least_one_process_bus_packet() { int main() { const std::vector>> tests{ {"PCAP canonical round trip", pcap_reader_writer_preserves_canonical_process_bus_evidence}, - {"GOOSE and SV PCAP equivalence", pcap_analyzer_reports_exact_goose_and_sv_round_trips}, + {"GOOSE and canonical SV PCAP equivalence", pcap_analyzer_reports_exact_goose_and_sv_round_trips}, + {"Historical SV PCAP is decoded but noncanonical", pcap_analyzer_detects_old_noncanonical_sv_fixed_field_widths}, {"Malformed process-bus evidence", pcap_analyzer_rejects_malformed_process_bus_packets}, {"PCAP allocation bounds", pcap_reader_rejects_unbounded_allocation_lengths}, {"Require process-bus evidence", pcap_analyzer_requires_at_least_one_process_bus_packet}}; diff --git a/tests/test_sampled_values.cpp b/tests/test_sampled_values.cpp index 970dafac0..fdf58e786 100644 --- a/tests/test_sampled_values.cpp +++ b/tests/test_sampled_values.cpp @@ -81,16 +81,16 @@ ar::iec61850::sampled_values::SampledValueAsdu make_reference_asdu() { std::nullopt}; } -void sampled_values_pdu_matches_csharp_golden_vector() { +void sampled_values_pdu_encodes_fixed_iec_widths() { using namespace ar::iec61850::sampled_values; const SampledValuesPdu pdu{{make_reference_asdu()}}; const auto encoded = SampledValuesPduCodec::encode(pdu); CHECK(to_hex(encoded) == - "605E800101A259305780134D55303146312F4C4C4E30244D535643423031" - "81144D55303146312F4C4C4E30245068734D65617331820178830103" + "6063800101A25E305C80134D55303146312F4C4C4E30244D535643423031" + "81144D55303146312F4C4C4E30245068734D6561733182020078830400000003" "84086A2BDFE40000000085010286020FA087100000006400000001000000C8" - "00000003880101"); + "0000000388020001"); SampledValuesPdu decoded; CHECK(SampledValuesPduCodec::try_decode(encoded, decoded)); @@ -119,20 +119,23 @@ void sampled_values_frame_round_trips_vlan_process_bus_header() { CHECK(layout_result.bytes_written == encoded.size()); CHECK(encoded_with_layout == encoded); CHECK(frame_layout[0].sample_count.present); - CHECK(frame_layout[0].sample_count.value_size == 1U); - CHECK(encoded[frame_layout[0].sample_count.value_offset] == 120U); + CHECK(frame_layout[0].sample_count.value_size == 2U); + CHECK(encoded[frame_layout[0].sample_count.value_offset] == 0U); + CHECK(encoded[frame_layout[0].sample_count.value_offset + 1U] == 120U); CHECK(frame_layout[0].configuration_revision.present); - CHECK(frame_layout[0].configuration_revision.value_size == 1U); - CHECK(encoded[frame_layout[0].configuration_revision.value_offset] == 3U); + CHECK(frame_layout[0].configuration_revision.value_size == 4U); + CHECK(encoded[frame_layout[0].configuration_revision.value_offset + 3U] == 3U); CHECK(frame_layout[0].reference_time.present); CHECK(frame_layout[0].reference_time.value_size == 8U); CHECK(frame_layout[0].sample_synchronization.present); CHECK(frame_layout[0].sample_synchronization.value_size == 1U); CHECK(encoded[frame_layout[0].sample_synchronization.value_offset] == 2U); CHECK(frame_layout[0].sample_rate.present); + CHECK(frame_layout[0].sample_rate.value_size == 2U); CHECK(frame_layout[0].sample_payload.present); CHECK(frame_layout[0].sample_payload.value_size == 16U); CHECK(frame_layout[0].sample_mode.present); + CHECK(frame_layout[0].sample_mode.value_size == 2U); auto untagged_frame = frame; untagged_frame.vlan.reset(); @@ -148,11 +151,11 @@ void sampled_values_frame_round_trips_vlan_process_bus_header() { untagged_layout[0].sample_payload.value_offset + 4U); CHECK(to_hex(encoded) == - "010CCD040001020000000002810080C888BA4001006800000000" - "605E800101A259305780134D55303146312F4C4C4E30244D535643423031" - "81144D55303146312F4C4C4E30245068734D65617331820178830103" + "010CCD040001020000000002810080C888BA4001006D00000000" + "6063800101A25E305C80134D55303146312F4C4C4E30244D535643423031" + "81144D55303146312F4C4C4E30245068734D6561733182020078830400000003" "84086A2BDFE40000000085010286020FA087100000006400000001000000C8" - "00000003880101"); + "0000000388020001"); SampledValuesFrame decoded; CHECK(SampledValuesFrameCodec::try_decode(encoded, decoded)); @@ -227,10 +230,12 @@ void sampled_values_codec_handles_multiple_asdus_and_rejects_malformed_input() { pdu, encoded_with_layout, insufficient_layout).success()); auto patched = encoded_with_layout; - CHECK(layouts[0].sample_count.value_size == 1U); - CHECK(layouts[1].sample_count.value_size == 1U); - patched[layouts[0].sample_count.value_offset] = 122U; - patched[layouts[1].sample_count.value_offset] = 123U; + CHECK(layouts[0].sample_count.value_size == 2U); + CHECK(layouts[1].sample_count.value_size == 2U); + patched[layouts[0].sample_count.value_offset] = 0U; + patched[layouts[0].sample_count.value_offset + 1U] = 122U; + patched[layouts[1].sample_count.value_offset] = 0U; + patched[layouts[1].sample_count.value_offset + 1U] = 123U; SampledValuesPdu patched_decoded; CHECK(SampledValuesPduCodec::try_decode(patched, patched_decoded)); CHECK(patched_decoded.asdus.size() == 2U); @@ -718,7 +723,7 @@ void sampled_values_gm_identity_matches_ptp_and_rejects_truncation() { int main() { const std::vector>> tests{ - {"SV PDU golden vector", sampled_values_pdu_matches_csharp_golden_vector}, + {"SV canonical fixed-width BER", sampled_values_pdu_encodes_fixed_iec_widths}, {"SV Ed2.1 GM source identity", sampled_values_gm_identity_matches_ptp_and_rejects_truncation}, {"SV Ethernet frame", sampled_values_frame_round_trips_vlan_process_bus_header}, {"SV malformed input", sampled_values_codec_handles_multiple_asdus_and_rejects_malformed_input}, diff --git a/tests/test_smv_wire_capture_audit.py b/tests/test_smv_wire_capture_audit.py index e6d5732fd..557a4e214 100644 --- a/tests/test_smv_wire_capture_audit.py +++ b/tests/test_smv_wire_capture_audit.py @@ -17,15 +17,21 @@ def tlv(tag, value): size = bytes([n]) if n < 128 else bytes([0x82]) + n.to_bytes(2, "big") return bytes([tag]) + size + value -def packet(seq=0, change=0, quality=0, samples=None): +def packet(seq=0, change=0, quality=0, samples=None, metadata=False, synch=1): values = samples if samples is not None else [1000 * i + change for i in range(8)] payload = b"".join(struct.pack(">iI", value, quality) for value in values) - asdu = tlv(0x30, - tlv(0x80, b"ARSTACK_SV01") + - tlv(0x82, seq.to_bytes(2, "big")) + - tlv(0x83, b"\x01") + - tlv(0x85, b"\x01") + - tlv(0x87, payload)) + fields = tlv(0x80, b"ARSTACK_SV01") + if metadata: + fields += tlv(0x81, b"ARSTACK_MU01MU01/LLN0$PhsMeas1") + fields += (tlv(0x82, seq.to_bytes(2, "big")) + + tlv(0x83, b"\x00\x00\x00\x01") + + tlv(0x85, bytes([synch]))) + if metadata: + fields += tlv(0x86, b"\x0f\xa0") + fields += tlv(0x87, payload) + if metadata: + fields += tlv(0x88, b"\x00\x01") + asdu = tlv(0x30, fields) pdu = tlv(0x60, tlv(0x80, b"\x01") + tlv(0xa2, asdu)) eth = bytes.fromhex("011ccd04000102000000000188ba") return eth + struct.pack(">HHHH", 0x4000, len(pdu) + 8, 0, 0) + pdu @@ -48,6 +54,77 @@ def test_decode_eight_channels_and_quality(self): self.assertEqual(parsed["channels"][7], (7000, 0x12345678)) self.assertEqual(parsed["synch"], 1) + def test_metadata_enabled_profile_and_strict_gate(self): + decoded = module.decode_frame(packet(42, metadata=True, synch=0)) + self.assertEqual(decoded["confRev"], 1) + self.assertEqual(decoded["smpRate"], 4000) + self.assertEqual(decoded["smpMod"], 1) + self.assertEqual(decoded["dataSet"], "ARSTACK_MU01MU01/LLN0$PhsMeas1") + self.assertIsNone(decoded["gmIdentity"]) + with tempfile.TemporaryDirectory() as directory: + good = Path(directory) / "metadata.pcap" + write_pcap(good, [(10 + i / 4000, packet(i, metadata=True, synch=0)) + for i in range(12)]) + report = module.analyze(good, 0x4000, "ARSTACK_SV01", 4000, .002, 4000, + require_metadata=True, require_unsynchronized=True) + self.assertEqual(report["status"], "PASS_PCAPPARSER_ONLY", report["blocking_findings"]) + self.assertEqual(report["optional_asdu_field_presence"]["smpRate"], 12) + + missing = Path(directory) / "legacy.pcap" + write_pcap(missing, [(10 + i / 4000, packet(i)) + for i in range(12)]) + legacy = module.analyze(missing, 0x4000, "ARSTACK_SV01", 4000, + .002, 4000, require_metadata=True) + self.assertEqual(legacy["status"], "FAIL") + self.assertTrue(any("smpRate missing" in x for x in legacy["blocking_findings"])) + not_synchronized = module.analyze(missing, 0x4000, "ARSTACK_SV01", 4000, + .002, 4000, require_unsynchronized=True) + self.assertEqual(not_synchronized["status"], "FAIL") + + def test_standard_fixed_width_rejects_bad_metadata(self): + bad = packet(42).replace(b"\x83\x04\x00\x00\x00\x01", + b"\x83\x01\x01") + with self.assertRaises(ValueError): + module.decode_frame(bad) + + def test_untagged_lab_scl_metadata_selection(self): + import xml.etree.ElementTree as ET + root = ET.parse(ROOT / "apps/arstack_studio/templates/arstack_4i4v_untagged_lab.scd").getroot() + ns = {"scl": "http://www.iec.ch/61850/2003/SCL"} + ctrl = root.find(".//scl:SampledValueControl", ns) + self.assertEqual(ctrl.get("smvID"), "ARSTACK_SV01") + self.assertEqual(ctrl.get("smpRate"), "4000") + self.assertEqual(ctrl.get("smpMod"), "SmpPerSec") + options = ctrl.find("scl:SmvOpts", ns) + self.assertEqual(options.get("dataSet"), "true") + self.assertEqual(options.get("sampleRate"), "true") + self.assertEqual(options.get("synchSourceId"), "false") + self.assertEqual(len(root.findall(".//scl:DataSet/scl:FCDA", ns)), 16) + + def test_priority_tagged_quick_start_metadata_is_deliverable(self): + import xml.etree.ElementTree as ET + ns = {"scl": "http://www.iec.ch/61850/2003/SCL"} + path = ROOT / "apps/arstack_studio/templates/arstack_4i4v_9-2le_metadata_lab.scd" + root = ET.parse(path).getroot() + control = root.find(".//scl:SampledValueControl", ns) + opts = control.find("scl:SmvOpts", ns) + self.assertEqual(control.get("smvID"), "ARSTACK_SV01") + self.assertEqual(control.get("smpRate"), "4000") + self.assertEqual(opts.get("sampleRate"), "true") + self.assertEqual(opts.get("dataSet"), "true") + self.assertEqual(opts.get("synchSourceId"), "false") + address = root.find(".//scl:SMV/scl:Address", ns) + fields = {x.get("type"): x.text for x in address} + self.assertEqual(fields["VLAN-ID"], "0") + self.assertEqual(fields["VLAN-PRIORITY"], "4") + self.assertEqual(fields["APPID"], "4000") + self.assertEqual(len(root.findall(".//scl:DataSet/scl:FCDA", ns)), 16) + # Old strict 9-2LE reference must remain opt-in and unmodified. + legacy = ET.parse(ROOT / "apps/arstack_studio/templates/arstack_4i4v_9-2le_reference.scd") + legacyopts = legacy.getroot().find(".//scl:SmvOpts", ns) + self.assertEqual(legacyopts.get("sampleRate"), "false") + self.assertEqual(legacyopts.get("dataSet"), "false") + def test_continuous_stream_and_wrap(self): with tempfile.TemporaryDirectory() as directory: name = Path(directory) / "ok.pcap" diff --git a/tools/smv_wire_capture_audit.py b/tools/smv_wire_capture_audit.py index 781a08971..73ae12685 100644 --- a/tools/smv_wire_capture_audit.py +++ b/tools/smv_wire_capture_audit.py @@ -63,16 +63,28 @@ def decode_frame(frame): for tag, asdu in tlvs(seq): if tag != 0x30: raise ValueError("unexpected ASDU tag") - fields = dict(tlvs(asdu)) - if 0x82 not in fields or 0x87 not in fields or 0x80 not in fields: - raise ValueError("missing smpCnt, seqData or svID") + parsed_fields = list(tlvs(asdu)) + fields = dict(parsed_fields) + if len(fields) != len(parsed_fields): + raise ValueError("duplicate ASDU field tag") + if any(tag not in fields for tag in (0x80, 0x82, 0x83, 0x85, 0x87)): + raise ValueError("missing mandatory SV ASDU metadata") + for tag, width in ((0x82, 2), (0x83, 4), (0x85, 1), + (0x86, 2), (0x88, 2), (0x89, 8)): + if tag in fields and len(fields[tag]) != width: + raise ValueError(f"SV field 0x{tag:02X} must be {width} bytes") payload = fields[0x87] if len(payload) != 64: raise ValueError(f"expected 8 x 8 bytes of seqData, got {len(payload)}") values = [struct.unpack_from(">iI", payload, i * 8) for i in range(8)] records.append({"appid": appid, "sv_id": fields[0x80].decode("ascii"), "count": int.from_bytes(fields[0x82], "big"), - "synch": int.from_bytes(fields.get(0x85, b"\x00"), "big"), + "confRev": int.from_bytes(fields[0x83], "big"), + "synch": int.from_bytes(fields[0x85], "big"), + "dataSet": fields[0x81].decode("ascii") if 0x81 in fields else None, + "smpRate": int.from_bytes(fields[0x86], "big") if 0x86 in fields else None, + "smpMod": int.from_bytes(fields[0x88], "big") if 0x88 in fields else None, + "gmIdentity": fields[0x89].hex() if 0x89 in fields else None, "channels": values}) if len(records) != 1: raise ValueError("9-2LE acceptance expects exactly one ASDU per SV Ethernet frame") @@ -112,7 +124,8 @@ def frames(path): yield seconds + sub / resolution, data def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_fps, - signal_frequency_hz=50.0, expected_channels=None): + signal_frequency_hz=50.0, expected_channels=None, require_metadata=False, + require_unsynchronized=False): counts = collections.Counter() previous = None first_ts = last_ts = None @@ -156,6 +169,15 @@ def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_ reversals += 1 previous = current counts["smpSynch_" + str(record["synch"])] += 1 + for field in ("dataSet", "smpRate", "smpMod", "gmIdentity"): + if record[field] is not None: + counts["present_" + field] += 1 + if record["smpRate"] is not None: + counts["rate_" + str(record["smpRate"])] += 1 + if record["smpMod"] is not None: + counts["mod_" + str(record["smpMod"])] += 1 + if record["dataSet"]: + counts["dataset_" + record["dataSet"]] += 1 theta = 2.0 * math.pi * signal_frequency_hz * current / expected_fps sin_theta, cos_theta = math.sin(theta), math.cos(theta) for i, (value, q) in enumerate(record["channels"]): @@ -192,6 +214,16 @@ def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_ if key.startswith("smpSynch_")}, "streams_seen": [{"appid": appid, "svID": svid, "frames": n} for (appid, svid), n in sorted(sources.items())], + "optional_asdu_field_presence": { + field: counts["present_" + field] for field in + ("dataSet", "smpRate", "smpMod", "gmIdentity") + }, + "sample_rates_on_wire": {key[5:]: value for key, value in counts.items() + if key.startswith("rate_")}, + "sample_modes_on_wire": {key[4:]: value for key, value in counts.items() + if key.startswith("mod_")}, + "dataset_references_on_wire": {key[8:]: value for key, value in counts.items() + if key.startswith("dataset_")}, "channels": [ {"name": NAMES[i], "min": minimums[i], "max": maximums[i], @@ -215,6 +247,16 @@ def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_ issues.append("average frame rate outside ±2%") if sample_counts[0] < 2 or any(n == 0 for n in sample_counts): issues.append("incomplete 8-channel wire payload") + if require_metadata: + for field in ("dataSet", "smpRate", "smpMod"): + if counts["present_" + field] != counts["matched"]: + issues.append(f"{field} missing in metadata-enabled laboratory profile") + if counts["rate_" + str(int(expected_fps))] != counts["matched"]: + issues.append("wire smpRate does not match expected samples/second") + if counts["mod_1"] != counts["matched"]: + issues.append("wire smpMod is not 1 (samples per second)") + if require_unsynchronized and counts["smpSynch_0"] != counts["matched"]: + issues.append("smpSynch>0 without independently qualified time reference") if expected_channels is not None: for i, name in enumerate(NAMES): expected = expected_channels[name] @@ -241,6 +283,10 @@ def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_ delta = (observed["phase_degrees"] - intended_phase + 180) % 360 - 180 if abs(delta) > phase_tolerance: issues.append(f"{name}: fundamental phase differs from operator scenario") + report["sync_warning"] = ( + "smpSynch 1/2 is only a publisher claim; verify local/global clock " + "traceability independently before accepting synchronization" + if counts["smpSynch_1"] or counts["smpSynch_2"] else None) report["blocking_findings"] = issues report["status"] = "PASS_PCAPPARSER_ONLY" if not issues else "FAIL" report["disclaimer"] = ( @@ -258,6 +304,10 @@ def main(): ap.add_argument("--minimum-seconds", type=float, default=120.0) ap.add_argument("--fps", type=float, default=4000.0) ap.add_argument("--signal-frequency-hz", type=float, default=50.0) + ap.add_argument("--require-metadata", action="store_true", + help="Require datSet, smpRate=expected fps and smpMod=1 in every ASDU") + ap.add_argument("--require-unsynchronized", action="store_true", + help="Fail if any SV advertises smpSynch 1/2 in an unqualified lab") ap.add_argument("--expected-json", type=Path, help="Scenario JSON containing channels IA..UN with rms_counts and phase_degrees") args = ap.parse_args() @@ -273,7 +323,8 @@ def main(): if config.get("ct_shaping_enabled", False): raise ValueError("fundamental equivalence with nonlinear CT shaping is not supported") report = analyze(args.pcap, args.appid, args.sv_id, args.wrap, - args.minimum_seconds, args.fps, args.signal_frequency_hz, expected) + args.minimum_seconds, args.fps, args.signal_frequency_hz, expected, + args.require_metadata, args.require_unsynchronized) print(json.dumps(report, indent=2)) return 0 if report["status"] == "PASS_PCAPPARSER_ONLY" else 2 except (ValueError, OSError, KeyError, TypeError) as exc: