diff --git a/.github/workflows/mms-server-direct-control-ci.yml b/.github/workflows/mms-server-direct-control-ci.yml new file mode 100644 index 000000000..f26527e7e --- /dev/null +++ b/.github/workflows/mms-server-direct-control-ci.yml @@ -0,0 +1,38 @@ +name: MMS Direct Control Server CI + +on: + push: + branches: ["agent/mms-server-direct-normal-control"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + cc: gcc + cxx: g++ + - os: ubuntu-latest + cc: clang + cxx: clang++ + - os: windows-latest + cc: "" + cxx: "" + runs-on: ${{ matrix.os }} + env: + CC: ${{ matrix.cc }} + CXX: ${{ matrix.cxx }} + steps: + - uses: actions/checkout@v4 + - name: Configure + run: cmake -S tools/mms_direct_control_server -B build-mms-direct-control -DCMAKE_BUILD_TYPE=Release + - name: Build + run: cmake --build build-mms-direct-control --config Release --parallel + - name: Unit and help contract + run: ctest --test-dir build-mms-direct-control -C Release --output-on-failure diff --git a/CMakeLists.txt b/CMakeLists.txt index b34cdec79..e787655df 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,6 +84,11 @@ add_library(ariec61850_mms_server_core src/mms/static_dispatcher.cpp src/mms/connection_runtime.cpp src/mms/static_server_session.cpp + src/mms/static_direct_control.cpp + src/mms/static_information_report.cpp + src/mms/static_report_connection.cpp + src/mms/static_urcb_objects.cpp + src/mms/static_urcb_runtime.cpp src/mms/static_brcb_runtime.cpp src/mms/static_brcb_control.cpp src/mms/static_brcb_objects.cpp @@ -195,6 +200,32 @@ if(ARIEC61850_BUILD_TOOLS) target_compile_features(ariec61850_static_ied_server PRIVATE cxx_std_20) ariec61850_apply_warnings(ariec61850_static_ied_server) ariec61850_apply_sanitizers(ariec61850_static_ied_server) + + add_executable( + ariec61850_mms_direct_control_server + tools/mms_direct_control_server.cpp) + target_link_libraries( + ariec61850_mms_direct_control_server + PRIVATE ARIEC61850::mms_server_core ARIEC61850::core) + if(WIN32) + target_link_libraries(ariec61850_mms_direct_control_server PRIVATE ws2_32) + endif() + target_compile_features(ariec61850_mms_direct_control_server PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_mms_direct_control_server) + ariec61850_apply_sanitizers(ariec61850_mms_direct_control_server) + + add_executable( + ariec61850_mms_urcb_server + tools/mms_urcb_server.cpp) + target_link_libraries( + ariec61850_mms_urcb_server + PRIVATE ARIEC61850::mms_server_core ARIEC61850::core) + if(WIN32) + target_link_libraries(ariec61850_mms_urcb_server PRIVATE ws2_32) + endif() + target_compile_features(ariec61850_mms_urcb_server PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_mms_urcb_server) + ariec61850_apply_sanitizers(ariec61850_mms_urcb_server) endif() if(ARIEC61850_BUILD_TESTS) @@ -397,6 +428,32 @@ if(ARIEC61850_BUILD_TESTS) NAME ariec61850_mms_connection_runtime_tests COMMAND ariec61850_mms_connection_runtime_tests) + add_executable( + ariec61850_mms_direct_control_tests + tests/test_mms_static_direct_control.cpp) + target_link_libraries( + ariec61850_mms_direct_control_tests + PRIVATE ARIEC61850::mms_server_core ARIEC61850::core) + target_compile_features(ariec61850_mms_direct_control_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_mms_direct_control_tests) + ariec61850_apply_sanitizers(ariec61850_mms_direct_control_tests) + add_test( + NAME ariec61850_mms_direct_control_tests + COMMAND ariec61850_mms_direct_control_tests) + + add_executable( + ariec61850_mms_server_urcb_tests + tests/test_mms_server_urcb_report.cpp) + target_link_libraries( + ariec61850_mms_server_urcb_tests + PRIVATE ARIEC61850::mms_server_core ARIEC61850::core) + target_compile_features(ariec61850_mms_server_urcb_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_mms_server_urcb_tests) + ariec61850_apply_sanitizers(ariec61850_mms_server_urcb_tests) + add_test( + NAME ariec61850_mms_server_urcb_tests + COMMAND ariec61850_mms_server_urcb_tests) + foreach(brcb_smoke IN ITEMS mms_brcb mms_brcb_control @@ -436,6 +493,12 @@ if(ARIEC61850_BUILD_TESTS) add_test( NAME ariec61850_static_ied_server_help COMMAND ariec61850_static_ied_server --help) + add_test( + NAME ariec61850_mms_direct_control_server_help + COMMAND ariec61850_mms_direct_control_server --help) + add_test( + NAME ariec61850_mms_urcb_server_help + COMMAND ariec61850_mms_urcb_server --help) endif() endif() diff --git a/docs/MMS_SERVER_DIRECT_CONTROL.md b/docs/MMS_SERVER_DIRECT_CONTROL.md new file mode 100644 index 000000000..da1d81351 --- /dev/null +++ b/docs/MMS_SERVER_DIRECT_CONTROL.md @@ -0,0 +1,113 @@ +# MMS Server Direct-Normal Control Trial + +This slice advances the clean MMS server prototype from read-only browse/read to a bounded, explicitly writable IEC 61850 control target. + +## Implemented server model + +Domain: `ESP32S3IOLD0` + +Logical Nodes: + +- `LLN0` +- `LPHD1` +- `GGIO1` + +Read-only digital inputs: + +- `GGIO1.Ind1` .. `GGIO1.Ind8` + +Direct-with-normal-security Boolean controls: + +- `ESP32S3IOLD0/GGIO1.SPCSO1` +- `ESP32S3IOLD0/GGIO1.SPCSO2` +- ... +- `ESP32S3IOLD0/GGIO1.SPCSO8` + +Each control exposes: + +- `ST$SPCSO$stVal` +- `CF$SPCSO$ctlModel` = `1` (Direct-with-normal-security) +- `CO$SPCSO$Oper` + +The live `Oper` TypeSpecification is the bounded SPC shape: + +1. `ctlVal` Boolean +2. `origin` structure (`orCat` Unsigned, `orIdent` OctetString) +3. `ctlNum` Unsigned +4. `T` UTC time +5. `Test` Boolean +6. `Check` 2-bit BitString + +A static non-deletable DataSet is also exposed: + +`ESP32S3IOLD0/LLN0.Outputs` + +It contains the eight `SPCSO.stVal` status leaves and is intended to become the first static reporting DataSet in the next server-side RCB tranche. + +## Safety semantics + +The `Oper` decoder is allocation-free and rejects malformed or unexpected structures. + +- `ctlNum=0` is rejected. +- origin category outside the bounded IEC range is rejected. +- synchro/interlock Check requests are fail-closed by default because this prototype does not yet implement those plant checks. +- `Test=true` is accepted but does not change the live output state. +- an application/hardware apply callback can reject the command before the published status changes. +- there is no command retry. + +This tranche implements only Direct-with-normal-security. SBO and enhanced-security CommandTermination belong to later server slices. + +## Windows build + +```powershell +powershell.exe -ExecutionPolicy Bypass -File .\scripts\build_mms_direct_control_server.ps1 +``` + +The script configures, builds, and runs the direct-control unit/help contract. + +## Run + +Use a non-privileged lab port first: + +```powershell +.\build-mms-direct-control\Release\ariec61850_mms_direct_control_server.exe ` + --bind 0.0.0.0 ` + --port 8102 ` + --di-mask 0x55 ` + --do-mask 0x00 +``` + +Expected startup marker: + +```text +MMS_DIRECT_CONTROL_SERVER_READY ... controls=8 ctlModel=1 dataset=ESP32S3IOLD0/LLN0.Outputs +``` + +For a standards-port laboratory run, use TCP 102 only when the host privileges/firewall configuration allow it. + +## IEDScout trial + +Connect IEDScout as an MMS/IEC 61850 client to the host running this executable. + +Browse `ESP32S3IOLD0/GGIO1` and verify that `SPCSO1..8` expose `ctlModel=1`, `Oper`, and `stVal`. Operate one Boolean output and then read its `stVal`. + +After the client disconnects, the server prints a summary such as: + +```text +MMS_DIRECT_CONTROL_STATE doMask=1 acceptedOps=1 rejectedOps=0 +``` + +Retain terminal output and a PCAP/PCAPNG for interoperability evidence. This is simulator/server interoperability evidence, not IEC 61850 conformance certification. + +## Current boundary / next server tranche + +Still pending after this slice: + +1. server-side URCB/BRCB objects and InformationReport transmission; +2. static DataSet report binding and GI/event triggers; +3. SBO normal and enhanced control plus CommandTermination/LastApplError; +4. concurrent-client ownership/contention; +5. server-side FileDirectory/FileOpen/FileRead/FileClose; +6. GPIO/lwIP adapter on the target ESP board; +7. runtime SCL/CID-driven model generation; +8. TLS/security profiles and broader multi-vendor evidence. diff --git a/docs/MMS_SERVER_URCB_REPORT.md b/docs/MMS_SERVER_URCB_REPORT.md new file mode 100644 index 000000000..0ee873da7 --- /dev/null +++ b/docs/MMS_SERVER_URCB_REPORT.md @@ -0,0 +1,107 @@ +# MMS Server Static URCB / InformationReport Trial + +This tranche adds a runnable server-side unbuffered report path on top of the bounded MMS server core. + +## Model + +Domain: `ESP32S3IOLD0` + +Static DataSet: + +`ESP32S3IOLD0/LLN0.Outputs` + +Members, in fixed order: + +- `GGIO1$ST$SPCSO1$stVal` +- `GGIO1$ST$SPCSO2$stVal` +- `GGIO1$ST$SPCSO3$stVal` +- `GGIO1$ST$SPCSO4$stVal` +- `GGIO1$ST$SPCSO5$stVal` +- `GGIO1$ST$SPCSO6$stVal` +- `GGIO1$ST$SPCSO7$stVal` +- `GGIO1$ST$SPCSO8$stVal` + +URCB: + +`ESP32S3IOLD0/LLN0$RP$Outputs01` + +Default `RptID`: `ARSTACK-Outputs01`. + +The object bank exposes `RptID`, `RptEna`, `Resv`, `DatSet`, `ConfRev`, `OptFlds`, `BufTm`, `TrgOps`, `IntgPd`, `GI`, and `SqNum` as MMS variables. + +## First acceptance profile + +The first live gate is deliberately deterministic and GI-driven: + +1. associate; +2. inspect the static DataSet and URCB; +3. reserve the URCB if the client uses `Resv`; +4. set `RptEna=true`; +5. set `GI=true`; +6. receive one MMS `InformationReport` carrying all eight ordered DataSet values; +7. verify `SqNum` advances only after a complete report frame is encoded; +8. disable/release and disconnect. + +The initial `OptFlds` enable sequence number, reason, DataSet name, data references, and configuration revision. Report time is intentionally omitted in this first profile so timestamp semantics do not obscure the basic interoperability gate. + +## Safety and ownership boundary + +This executable currently serves one client at a time. + +`RptEna` and `Resv` are explicitly reset to false at connection boundaries so one disconnected client cannot leave a live reservation/enable state for the next client. + +No dynamic DataSet mutation is available on this server. `LLN0.Outputs` is static and non-deletable. + +No automatic report-control takeover or retry is performed. + +## Build + +```powershell +powershell.exe -ExecutionPolicy Bypass -File .\scripts\build_mms_direct_control_server.ps1 +``` + +The helper builds/tests both server trial executables. + +## Run + +```powershell +.\build-mms-direct-control\Release\ariec61850_mms_urcb_server.exe ` + --bind 0.0.0.0 ` + --port 8102 ` + --mask 0x55 +``` + +Expected startup marker: + +```text +MMS_URCB_SERVER_READY ... dataset=ESP32S3IOLD0/LLN0.Outputs urcb=ESP32S3IOLD0/LLN0$RP$Outputs01 members=8 ... +``` + +On each emitted report the server prints: + +```text +MMS_URCB_REPORT_SENT index=0 sqNum= reason=8 bytes= +``` + +Reason `8` is General Interrogation. + +For TCP 102, use a laboratory host configuration that permits binding the standard MMS port. + +## IEDScout trial + +Connect IEDScout to the server host and browse `ESP32S3IOLD0`. + +Verify the `LLN0.Outputs` DataSet and `LLN0$RP$Outputs01` URCB are visible. Enable the report and issue GI. The expected evidence is one report with eight Boolean values matching the configured `--mask` bit order. + +Retain console output and PCAP/PCAPNG. Until this live run is performed, this tranche is classified as software/offline-tested rather than live-proven server interoperability. + +## Still pending + +- event-on-change/data-change triggering; +- multiple URCBs and concurrent-client ownership; +- BRCB retention/replay/EntryID/PurgeBuf on the runnable server adapter; +- Direct/SBO enhanced server control and CommandTermination; +- server-side MMS file services; +- GPIO/lwIP hardware adapter; +- runtime SCL/CID model generation; +- TLS/security profiles and broader multi-vendor evidence. diff --git a/embedded/association_hard_profile_smoke.cpp b/embedded/association_hard_profile_smoke.cpp index 558234eaf..c9b42a4ba 100644 --- a/embedded/association_hard_profile_smoke.cpp +++ b/embedded/association_hard_profile_smoke.cpp @@ -121,8 +121,6 @@ int main() { return 4; } - // Confirm the deterministic AARE itself is stable and contains the expected - // MMS Initiate response used by the host association profile. std::array aare{}; const auto aare_result = acse::AcseSpanCodec::encode_default_accept_aare_into(aare); if (!aare_result.success() || @@ -143,8 +141,6 @@ int main() { return 5; } - // Association setup is not a realtime hot path, but repeated construction - // still proves the bounded responder does not require owning protocol trees. for (std::uint32_t iteration = 0U; iteration < 10'000U; ++iteration) { const auto repeated = acse::AcseSpanCodec::build_accept_response_into( request, response); @@ -158,9 +154,34 @@ int main() { constexpr std::array malformed{ 0x0DU, 0x02U, 0xC1U, 0x00U}; acse::AssociationRequestView rejected; - if (acse::AcseSpanCodec::try_decode_association_request_view(malformed, rejected)) { + if (acse::AcseSpanCodec::try_decode_association_request_view(malformed, rejected) || + acse::AcseSpanCodec::try_decode_association_request_compat_view(malformed, rejected)) { return 7; } + // Preserve a strict/offline path while proving the live engineering-client + // fallback accepts a BER-valid optional Presentation field. Byte 35 is the + // calling-selector tag in the captured association profile; changing it to + // an otherwise unused context tag keeps all BER lengths identical. + auto engineering_client_variant = kRequest; + engineering_client_variant[35U] = 0x83U; + acse::AssociationRequestView compatible; + if (acse::AcseSpanCodec::try_decode_association_request_view( + engineering_client_variant, compatible) || + !acse::AcseSpanCodec::try_decode_association_request_compat_view( + engineering_client_variant, compatible) || + compatible.acse_presentation_context_id != 1U || + compatible.mms_presentation_context_id != 3U || + compatible.presentation.context_count != 2U || + compatible.presentation.user_data.context_id != 1U) { + return 8; + } + const auto compatible_response = acse::AcseSpanCodec::build_accept_response_into( + compatible, response); + if (!compatible_response.success() || compatible_response.bytes_written == 0U || + response[0] != osi::SessionSpanCodec::accept_code) { + return 9; + } + return 0; } diff --git a/embedded/mms_brcb_connection_hard_profile_smoke.cpp b/embedded/mms_brcb_connection_hard_profile_smoke.cpp index b3ce88e09..606f84232 100644 --- a/embedded/mms_brcb_connection_hard_profile_smoke.cpp +++ b/embedded/mms_brcb_connection_hard_profile_smoke.cpp @@ -20,6 +20,8 @@ using namespace ar::iec61850; constexpr std::array kBooleanType{0x83U, 0x00U}; constexpr std::array kReportTime{ 0x00U, 0x00U, 0x12U, 0x34U, 0x00U, 0x01U}; +constexpr std::array kOwnerA{0xAAU, 0x01U}; +constexpr std::array kOwnerB{0xBBU, 0x01U}; constexpr std::array kAssociationRequest{ 0x0DU,0xB6U,0x05U,0x06U,0x13U,0x01U,0x00U,0x16U,0x01U,0x02U,0x14U,0x02U, @@ -53,6 +55,28 @@ constexpr std::array kAssociationRequest{ return {wire::EncodeStatus::ok, required, required}; } +[[nodiscard]] mms::MmsStaticBrcbClientIdentity identity( + const std::uint64_t association, + const std::array& owner) noexcept { + mms::MmsStaticBrcbClientIdentity result; + result.association_id = association; + result.owner[0] = owner[0]; + result.owner[1] = owner[1]; + result.owner_size = owner.size(); + return result; +} + +[[nodiscard]] mms::MmsStaticConnectionPolicy connection_policy( + const std::uint64_t association, + const std::array& owner) noexcept { + mms::MmsStaticConnectionPolicy result; + result.association_id = association; + result.owner[0] = owner[0]; + result.owner[1] = owner[1]; + result.owner_size = owner.size(); + return result; +} + [[nodiscard]] bool establish( mms::MmsStaticConnectionRuntime& connection, std::span request, @@ -136,7 +160,14 @@ int main() { mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; const mms::MmsStaticDataSetTable data_set_table{data_sets}; const mms::MmsStaticApplicationDispatcher dispatcher{object_table, data_set_table}; - mms::MmsStaticConnectionRuntime connection{dispatcher}; + + const auto owner_a1 = identity(101U, kOwnerA); + const auto owner_a2 = identity(102U, kOwnerA); + const auto foreign_b = identity(201U, kOwnerB); + mms::MmsStaticConnectionRuntime connection{ + dispatcher, connection_policy(owner_a1.association_id, kOwnerA)}; + mms::MmsStaticConnectionRuntime foreign_connection{ + dispatcher, connection_policy(foreign_b.association_id, kOwnerB)}; const mms::MmsStaticBrcbDefinition definition{ "LD0", "LLN0$BR$Events", "LD0/LLN0$BR$Events", @@ -147,10 +178,16 @@ int main() { mms::MmsStaticBrcbPendingState pending{}; mms::MmsStaticBrcbRuntime reports{ definition, pending, slots, object_table, data_set_table}; - if (!reports.initialize() || reports.set_enabled(true) != mms::MmsStaticBrcbStatus::ok || + if (!reports.initialize()) { + return 1; + } + mms::MmsStaticBrcbControl control{reports}; + if (control.reserve(owner_a1, 5U, 100U) != mms::MmsStaticBrcbControlStatus::ok || + control.set_report_enabled(owner_a1, true, 100U) != + mms::MmsStaticBrcbControlStatus::ok || reports.notify(0U, mms::MmsStaticBrcbEventReason::data_change, 100U) != mms::MmsStaticBrcbStatus::ok) { - return 1; + return 2; } mms::MmsStaticBrcbCapturePlan plan; @@ -159,101 +196,146 @@ int main() { if (!reports.next_due(100U, plan) || !reports.capture(plan, kReportTime, staging, value_workspace).success() || reports.queue_size() != 1U) { - return 2; + return 3; } - // Mutate the live backend after capture. Delivery must retain the old true snapshot. value = false; std::array request{}; std::array response{}; std::array workspace{}; std::array scratch{}; auto delivery = mms::MmsStaticBrcbConnection::poll( - connection, reports, response, workspace); + connection, control, reports, 100U, response, workspace); if (delivery.status != mms::MmsStaticBrcbConnectionStatus::not_established || reports.queue_size() != 1U) { - return 3; + return 4; + } + if (!establish(connection, request, response, workspace, scratch) || + !establish(foreign_connection, request, response, workspace, scratch)) { + return 5; + } + + // A foreign established association must not see or consume the owner's entry. + delivery = mms::MmsStaticBrcbConnection::poll( + foreign_connection, control, reports, 100U, response, workspace); + if (delivery.status != mms::MmsStaticBrcbConnectionStatus::access_denied || + reports.queue_size() != 1U) { + return 6; } - if (!establish(connection, request, response, workspace, scratch)) return 4; mms::MmsStaticBrcbEntryView held_entry; if (!reports.front(held_entry) || held_entry.entry_id.size() != 8U || held_entry.entry_id[7U] != 1U || - reports.set_enabled(false) != mms::MmsStaticBrcbStatus::ok) { - return 5; + control.set_report_enabled(owner_a1, false, 100U) != + mms::MmsStaticBrcbControlStatus::ok) { + return 7; } delivery = mms::MmsStaticBrcbConnection::poll( - connection, reports, response, workspace); + connection, control, reports, 100U, response, workspace); if (delivery.status != mms::MmsStaticBrcbConnectionStatus::reporting_disabled || reports.queue_size() != 1U || !reports.front(held_entry) || held_entry.entry_id[7U] != 1U || - reports.set_enabled(true) != mms::MmsStaticBrcbStatus::ok) { - return 6; + control.set_report_enabled(owner_a1, true, 100U) != + mms::MmsStaticBrcbControlStatus::ok) { + return 8; } std::array tiny_response{}; delivery = mms::MmsStaticBrcbConnection::poll( - connection, reports, tiny_response, workspace); + connection, control, reports, 100U, tiny_response, workspace); if (delivery.status != mms::MmsStaticBrcbConnectionStatus::response_buffer_too_small || delivery.required_response_bytes <= tiny_response.size() || - delivery.entry_id[7] != 1U || reports.queue_size() != 1U) { - return 7; + delivery.entry_id[7U] != 1U || reports.queue_size() != 1U) { + return 9; } std::array tiny_workspace{}; delivery = mms::MmsStaticBrcbConnection::poll( - connection, reports, response, tiny_workspace); + connection, control, reports, 100U, response, tiny_workspace); if (delivery.status != mms::MmsStaticBrcbConnectionStatus::workspace_too_small || delivery.required_workspace_bytes <= tiny_workspace.size() || - delivery.entry_id[7] != 1U || reports.queue_size() != 1U) { - return 8; + delivery.entry_id[7U] != 1U || reports.queue_size() != 1U) { + return 10; } + // Staging is retry-safe: no delivery cursor movement until commit_sent(). delivery = mms::MmsStaticBrcbConnection::poll( - connection, reports, response, workspace); + connection, control, reports, 100U, response, workspace); if (!delivery.response_ready() || delivery.bytes_written == 0U || - delivery.entry_id[7] != 1U || delivery.sequence_number != 1U || - delivery.buffer_overflow || reports.queue_size() != 0U) { - return 9; + delivery.entry_id[7U] != 1U || delivery.sequence_number != 1U || + delivery.buffer_overflow || reports.queue_size() != 1U) { + return 11; + } + const auto retry = mms::MmsStaticBrcbConnection::poll( + connection, control, reports, 100U, response, workspace); + if (!retry.response_ready() || retry.entry_id != delivery.entry_id || + reports.queue_size() != 1U) { + return 12; } mms::MmsInformationReportView decoded; if (!decode_report_frame( - std::span{response}.first(delivery.bytes_written), decoded) || + std::span{response}.first(retry.bytes_written), decoded) || decoded.item_count != 12U) { - return 10; + return 13; } mms::MmsReadAccessResultView item; bool historical_value = false; if (!decoded.try_item(10U, item) || !decode_boolean(item, historical_value) || !historical_value) { - return 11; + return 14; } - // Queue another historical entry, then drop the association. The queue must survive reset. + // A staged token is not transferable: a foreign association cannot commit + // an EntryID staged by the legitimate BRCB owner. + if (mms::MmsStaticBrcbConnection::commit_sent( + foreign_connection, control, reports, 100U, retry) || + reports.queue_size() != 1U) { + return 15; + } + if (!mms::MmsStaticBrcbConnection::commit_sent( + connection, control, reports, 100U, retry) || + reports.queue_size() != 0U) { + return 16; + } + + // Queue another historical entry, close A1, then reclaim with the same stable + // Owner on association A2. The retained entry must remain retryable. if (reports.notify(0U, mms::MmsStaticBrcbEventReason::data_update, 200U) != mms::MmsStaticBrcbStatus::ok || !reports.next_due(200U, plan) || !reports.capture(plan, kReportTime, staging, value_workspace).success() || reports.queue_size() != 1U) { - return 12; + return 17; } connection.reset(); + control.on_association_closed(owner_a1.association_id, 200U); delivery = mms::MmsStaticBrcbConnection::poll( - connection, reports, response, workspace); + connection, control, reports, 200U, response, workspace); if (delivery.status != mms::MmsStaticBrcbConnectionStatus::not_established || - reports.queue_size() != 1U) { - return 13; + reports.queue_size() != 1U || + control.reserve(owner_a2, 5U, 201U) != mms::MmsStaticBrcbControlStatus::ok || + control.set_report_enabled(owner_a2, true, 201U) != + mms::MmsStaticBrcbControlStatus::ok) { + return 18; + } + + mms::MmsStaticConnectionRuntime reconnect{ + dispatcher, connection_policy(owner_a2.association_id, kOwnerA)}; + if (!establish(reconnect, request, response, workspace, scratch)) { + return 19; } - if (!establish(connection, request, response, workspace, scratch)) return 14; delivery = mms::MmsStaticBrcbConnection::poll( - connection, reports, response, workspace); - if (!delivery.response_ready() || delivery.entry_id[7] != 2U || + reconnect, control, reports, 201U, response, workspace); + if (!delivery.response_ready() || delivery.entry_id[7U] != 2U || + reports.queue_size() != 1U || + !mms::MmsStaticBrcbConnection::commit_sent( + reconnect, control, reports, 201U, delivery) || reports.queue_size() != 0U || mms::MmsStaticBrcbConnection::poll( - connection, reports, response, workspace).status != + reconnect, control, reports, 201U, response, workspace).status != mms::MmsStaticBrcbConnectionStatus::no_report_available) { - return 15; + return 20; } return 0; diff --git a/embedded/mms_connection_runtime_hard_profile_smoke.cpp b/embedded/mms_connection_runtime_hard_profile_smoke.cpp index e4202ce0d..0af665bd6 100644 --- a/embedded/mms_connection_runtime_hard_profile_smoke.cpp +++ b/embedded/mms_connection_runtime_hard_profile_smoke.cpp @@ -69,6 +69,15 @@ constexpr std::array kReadResponse{ 0x83U, 0x01U, 0xFFU, 0x85U, 0x01U, 0x2AU}; +constexpr std::array kReadResponseAfterWrite{ + 0xA1U, 0x0DU, 0x02U, 0x01U, 0x0CU, + 0xA4U, 0x08U, 0xA1U, 0x06U, + 0x83U, 0x01U, 0x00U, + 0x85U, 0x01U, 0x2AU}; + +constexpr std::array kEmptyFileDirectoryFields{ + 0xA0U, 0x00U, 0x81U, 0x01U, 0x00U}; + constexpr std::array kWriteRequest{ 0xA0U, 0x1BU, 0x02U, 0x01U, 0x0EU, 0xA5U, 0x16U, @@ -82,6 +91,9 @@ constexpr std::array kWriteResponse{ 0xA1U, 0x07U, 0x02U, 0x01U, 0x0EU, 0xA5U, 0x02U, 0x81U, 0x00U}; +constexpr std::array kConcludeRequest{0x8BU, 0x00U}; +constexpr std::array kConcludeResponse{0x8CU, 0x00U}; + template [[nodiscard]] bool matches( const std::span actual, @@ -253,6 +265,7 @@ int main() { osi::CotpTpduView cc; if (!result.response_ready() || result.consumed_bytes != cr_tpkt.bytes_written || runtime.state() != mms::MmsStaticConnectionState::awaiting_association || + runtime.negotiated_tpdu_size_code() != 0x0AU || !extract_cotp( std::span{response}.first(result.bytes_written), cc) || cc.kind != osi::CotpWireKind::connection_confirm || @@ -347,14 +360,74 @@ int main() { std::span{request}.first(identify_tpkt.bytes_written), response, workspace); - if (result.status != mms::MmsStaticConnectionStatus::application_rejected || - result.application_status != mms::MmsStaticDispatchStatus::unsupported_service || + mms::MmsConfirmedPduView identify_response; + if (!result.response_ready() || result.application_service != mms::MmsWireConfirmedService::identify || result.invoke_id != 21U || result.consumed_bytes != identify_tpkt.bytes_written || - runtime.state() != mms::MmsStaticConnectionState::established) { + runtime.state() != mms::MmsStaticConnectionState::established || + !extract_mms( + std::span{response}.first(result.bytes_written), pdv) || + !mms::MmsPduSpanCodec::try_decode_confirmed_response_view( + pdv.single_asn1_type, identify_response) || + identify_response.invoke_id != 21U || + identify_response.service() != mms::MmsWireConfirmedService::identify) { return 11; } + std::array file_directory_mms{}; + const auto file_directory_request = mms::MmsPduSpanCodec::encode_confirmed_request_into( + 22U, + static_cast(mms::MmsWireConfirmedService::file_directory), + true, + {}, + file_directory_mms); + const auto file_directory_tpkt = file_directory_request.success() + ? build_mms_tpkt( + std::span{file_directory_mms}.first( + file_directory_request.bytes_written), + request, + presentation, + scratch) + : wire::EncodeResult{}; + if (!file_directory_request.success() || !file_directory_tpkt.success()) { + return 21; + } + result = runtime.process_tcp_window( + std::span{request}.first(file_directory_tpkt.bytes_written), + response, + workspace); + mms::MmsConfirmedPduView file_directory_response; + if (!result.response_ready() || + result.application_service != mms::MmsWireConfirmedService::file_directory || + result.invoke_id != 22U || + runtime.state() != mms::MmsStaticConnectionState::established || + !extract_mms( + std::span{response}.first(result.bytes_written), pdv) || + !mms::MmsPduSpanCodec::try_decode_confirmed_response_view( + pdv.single_asn1_type, file_directory_response) || + file_directory_response.invoke_id != 22U || + file_directory_response.service() != mms::MmsWireConfirmedService::file_directory || + !matches(file_directory_response.service_value, kEmptyFileDirectoryFields)) { + return 22; + } + + const auto conclude_tpkt = build_mms_tpkt( + kConcludeRequest, request, presentation, scratch); + if (!conclude_tpkt.success()) { + return 18; + } + result = runtime.process_tcp_window( + std::span{request}.first(conclude_tpkt.bytes_written), + response, + workspace); + if (!result.response_ready() || + runtime.state() != mms::MmsStaticConnectionState::established || + !extract_mms( + std::span{response}.first(result.bytes_written), pdv) || + !matches(pdv.single_asn1_type, kConcludeResponse)) { + return 19; + } + const auto write_tpkt = build_mms_tpkt( kWriteRequest, request, presentation, scratch); if (!write_tpkt.success() || !relay_state) { @@ -390,11 +463,29 @@ int main() { response, workspace); if (!segmented_tpkt.success() || - result.status != mms::MmsStaticConnectionStatus::protocol_violation || - runtime.state() != mms::MmsStaticConnectionState::fault) { + result.status != mms::MmsStaticConnectionStatus::consumed_no_response || + result.consumed_bytes != segmented_tpkt.bytes_written || + runtime.state() != mms::MmsStaticConnectionState::established) { return 15; } + const std::span empty_payload{}; + const auto final_segment = build_data_tpkt( + empty_payload, request, scratch, true); + result = runtime.process_tcp_window( + std::span{request}.first(final_segment.bytes_written), + response, + workspace); + if (!final_segment.success() || !result.response_ready() || + result.consumed_bytes != final_segment.bytes_written || + result.application_service != mms::MmsWireConfirmedService::read || + runtime.state() != mms::MmsStaticConnectionState::established || + !extract_mms( + std::span{response}.first(result.bytes_written), pdv) || + !matches(pdv.single_asn1_type, kReadResponseAfterWrite)) { + return 20; + } + runtime.reset(); if (runtime.state() != mms::MmsStaticConnectionState::awaiting_cotp_connect || runtime.mms_presentation_context_id() != 0U) { diff --git a/include/ariec61850/acse/association_span.hpp b/include/ariec61850/acse/association_span.hpp index b18d1d7ea..d8446b363 100644 --- a/include/ariec61850/acse/association_span.hpp +++ b/include/ariec61850/acse/association_span.hpp @@ -51,10 +51,21 @@ class AcseSpanCodec final { std::span bytes, AcseAarqView& aarq) noexcept; + // Strict protocol decoder used by conformance-oriented/offline paths. [[nodiscard]] static bool try_decode_association_request_view( std::span bytes, AssociationRequestView& request) noexcept; + // Bounded engineering-client compatibility decoder. It prefers the strict + // path, then falls back to the tolerant association-inspection behavior + // used by the proven ARIEC61850 simulator/server: Session Connect, + // Presentation contexts, AARQ UserInformation and MMS InitiateRequest are + // required, while harmless optional Presentation/ACSE decorations are + // ignored rather than turning into a disconnect. + [[nodiscard]] static bool try_decode_association_request_compat_view( + std::span bytes, + AssociationRequestView& request) noexcept; + [[nodiscard]] static constexpr std::size_t default_accept_aare_size() noexcept { return 76U; } diff --git a/include/ariec61850/mms/connection_runtime.hpp b/include/ariec61850/mms/connection_runtime.hpp index 85689d2b4..a6be43253 100644 --- a/include/ariec61850/mms/connection_runtime.hpp +++ b/include/ariec61850/mms/connection_runtime.hpp @@ -46,6 +46,15 @@ struct MmsStaticConnectionPolicy final { std::uint8_t maximum_tpdu_size_code{0x0AU}; bool require_end_of_transmission{true}; + // Optional adapter-owned COTP Data reassembly storage. When non-empty, the + // live server accepts a bounded sequence of EOT=false Data TPDUs and + // presents their concatenated user data to ACSE/MMS only after the final + // EOT=true segment. When empty, the runtime may borrow half of the stable + // per-session workspace while reassembly is active, keeping the core free + // of heap/global buffers. Set require_end_of_transmission=false to preserve + // the legacy partial-TPDU opt-out. + std::span cotp_reassembly{}; + // Optional portable association identity used by contextual MMS writes. // The transport/server adapter assigns association_id and stable Owner. // Leaving these zero/empty preserves legacy non-contextual behavior. @@ -92,7 +101,8 @@ class MmsStaticConnectionRuntime final { explicit constexpr MmsStaticConnectionRuntime( const MmsStaticApplicationDispatcher& dispatcher, const MmsStaticConnectionPolicy policy = {}) noexcept - : dispatcher_{dispatcher}, policy_{policy} {} + : dispatcher_{dispatcher}, policy_{policy}, + negotiated_tpdu_size_code_{policy.maximum_tpdu_size_code} {} // Process at most the first complete TPKT frame in a TCP receive window. // `consumed_bytes` identifies the prefix the transport adapter may remove. @@ -120,6 +130,10 @@ class MmsStaticConnectionRuntime final { return mms_presentation_context_id_; } + [[nodiscard]] constexpr std::uint8_t negotiated_tpdu_size_code() const noexcept { + return negotiated_tpdu_size_code_; + } + [[nodiscard]] constexpr MmsStaticRequestAccessContext access_context() const noexcept { return policy_.access_context(); } @@ -130,11 +144,18 @@ class MmsStaticConnectionRuntime final { private: void notify_association_closed() noexcept; + void clear_cotp_reassembly() noexcept; const MmsStaticApplicationDispatcher& dispatcher_; MmsStaticConnectionPolicy policy_{}; MmsStaticConnectionState state_{MmsStaticConnectionState::awaiting_cotp_connect}; std::uint32_t mms_presentation_context_id_{}; + std::size_t cotp_reassembly_size_{}; + bool cotp_reassembly_complete_{}; + bool cotp_reassembly_uses_workspace_{}; + std::uint8_t* cotp_reassembly_workspace_data_{}; + std::size_t cotp_reassembly_workspace_size_{}; + std::uint8_t negotiated_tpdu_size_code_{0x0AU}; bool association_active_{}; bool association_close_notified_{}; }; diff --git a/include/ariec61850/mms/pdu_span.hpp b/include/ariec61850/mms/pdu_span.hpp index 5617c756a..978ca9f54 100644 --- a/include/ariec61850/mms/pdu_span.hpp +++ b/include/ariec61850/mms/pdu_span.hpp @@ -15,6 +15,9 @@ enum class MmsWirePduKind : std::uint8_t { confirmed_response, initiate_request, initiate_response, + confirmed_error, + conclude_request, + conclude_response, }; enum class MmsWireConfirmedService : std::int32_t { @@ -25,6 +28,7 @@ enum class MmsWireConfirmedService : std::int32_t { write = 5, get_variable_access_attributes = 6, get_named_variable_list_attributes = 12, + file_directory = 77, }; struct MmsInitiateDetailView final { @@ -90,6 +94,24 @@ class MmsPduSpanCodec final { bool service_constructed, std::span service_value, std::span destination) noexcept; + + // Engineering-client compatibility primitives ported from the proven + // ARIEC61850 simulator/server behavior. These remain bounded/no-throw and + // are consumed by the server connection runtime rather than weakening the + // strict application dispatcher contract. + [[nodiscard]] static wire::EncodeResult encode_identify_response_into( + std::uint32_t invoke_id, + std::span destination) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_confirmed_error_into( + std::uint32_t invoke_id, + std::span destination) noexcept; + + [[nodiscard]] static bool is_conclude_request( + std::span bytes) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_conclude_response_into( + std::span destination) noexcept; }; } // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_brcb_connection.hpp b/include/ariec61850/mms/static_brcb_connection.hpp index 2bf50ee43..9ad1cf8dd 100644 --- a/include/ariec61850/mms/static_brcb_connection.hpp +++ b/include/ariec61850/mms/static_brcb_connection.hpp @@ -2,6 +2,7 @@ #pragma once #include "ariec61850/mms/connection_runtime.hpp" +#include "ariec61850/mms/static_brcb_control.hpp" #include "ariec61850/mms/static_brcb_runtime.hpp" #include @@ -16,6 +17,7 @@ enum class MmsStaticBrcbConnectionStatus : std::uint8_t { reporting_disabled, response_ready, not_established, + access_denied, response_buffer_too_small, workspace_too_small, frame_encode_failed, @@ -39,15 +41,37 @@ struct MmsStaticBrcbConnectionResult final { class MmsStaticBrcbConnection final { public: - // Deliver at most one already-buffered report while reporting is enabled. - // The queue front is removed only after a complete TPKT image is staged - // successfully. Capacity errors, disabled reporting and disconnected - // sessions preserve the exact same EntryID for retry. + // Stage at most one already-buffered report for the live BRCB owner. + // Reporting must be enabled through `control`, and the established MMS + // connection's association/Owner identity must exactly match the current + // live reservation. Foreign associations are denied without exposing or + // advancing retained report state. + // + // A successful poll only stages a complete TPKT image. It deliberately does + // NOT advance the BRCB delivery cursor. The transport adapter must call + // commit_sent() only after the complete staged frame has been accepted by + // the transport. Capacity errors, denied access, disabled reporting, + // disconnected sessions and send failures therefore preserve the same + // EntryID for retry/reconnect. [[nodiscard]] static MmsStaticBrcbConnectionResult poll( const MmsStaticConnectionRuntime& connection, + MmsStaticBrcbControl& control, MmsStaticBrcbRuntime& reports, + std::uint64_t now_ms, std::span response, std::span workspace) noexcept; + + // Second phase of report delivery. Call only after every byte of the staged + // response was accepted by the transport. The staged EntryID acts as the + // bounded delivery token. Association/Owner authorization is checked again + // at commit time so a token staged by an earlier association cannot advance + // the cursor after ownership changes. + [[nodiscard]] static bool commit_sent( + const MmsStaticConnectionRuntime& connection, + MmsStaticBrcbControl& control, + MmsStaticBrcbRuntime& reports, + std::uint64_t now_ms, + const MmsStaticBrcbConnectionResult& staged) noexcept; }; } // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_direct_control.hpp b/include/ariec61850/mms/static_direct_control.hpp new file mode 100644 index 000000000..49f0f3279 --- /dev/null +++ b/include/ariec61850/mms/static_direct_control.hpp @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +struct MmsStaticDirectBooleanOperate final { + bool control_value{}; + std::uint8_t origin_category{}; + std::uint8_t control_number{}; + bool test{}; + bool synchro_check{}; + bool interlock_check{}; +}; + +struct MmsStaticDirectBooleanControlState final { + std::uint8_t value{}; + std::uint8_t last_control_number{}; + bool last_test{}; + std::size_t accepted_operations{}; + std::size_t rejected_operations{}; +}; + +using MmsStaticBooleanApplyCallback = bool (*)( + void* context, + bool value) noexcept; + +struct MmsStaticDirectBooleanControlPolicy final { + bool allow_test{true}; + bool allow_synchro_check{}; + bool allow_interlock_check{}; + std::uint32_t malformed_failure_code{7U}; // type-inconsistent + std::uint32_t invalid_value_failure_code{11U}; // object-value-invalid + std::uint32_t backend_failure_code{10U}; // object-non-existent/backend +}; + +struct MmsStaticDirectBooleanControlBinding final { + MmsStaticDirectBooleanControlState* state{}; + MmsStaticBooleanApplyCallback apply{}; + void* apply_context{}; + MmsStaticDirectBooleanControlPolicy policy{}; +}; + +// Decode one IEC 61850 Direct-with-normal-security Oper value with the exact +// ordered structure used by the live TypeSpecification exposed by this server: +// ctlVal, origin(orCat,orIdent), ctlNum, T, Test, Check. +// The parser is allocation-free and accepts only the bounded scalar shape. +[[nodiscard]] bool try_decode_static_direct_boolean_operate( + std::span encoded_data, + MmsStaticDirectBooleanOperate& decoded) noexcept; + +// Read callbacks suitable for MmsStaticObjectEntry. +[[nodiscard]] wire::EncodeResult mms_static_direct_boolean_read_state( + const void* context, + std::span destination) noexcept; + +[[nodiscard]] wire::EncodeResult mms_static_direct_normal_read_ctl_model( + const void* context, + std::span destination) noexcept; + +[[nodiscard]] wire::EncodeResult mms_static_control_read_unavailable( + const void* context, + std::span destination) noexcept; + +// Write callback suitable for the CO$...$Oper MmsStaticObjectEntry. +// Test=true is acknowledged without changing the live output state. Check bits +// are denied by default until the application explicitly enables them. +[[nodiscard]] MmsStaticWriteResult mms_static_direct_boolean_write_oper( + void* context, + std::span encoded_data) noexcept; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_information_report.hpp b/include/ariec61850/mms/static_information_report.hpp new file mode 100644 index 000000000..8aa58978a --- /dev/null +++ b/include/ariec61850/mms/static_information_report.hpp @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/information_report_span.hpp" +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_object_table.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticInformationReportStatus : std::uint8_t { + response_ready, + invalid_object_table, + invalid_data_set_table, + data_set_not_found, + object_not_found, + workspace_too_small, + backend_failure, + invalid_report, + response_buffer_too_small, +}; + +struct MmsStaticInformationReportInput final { + std::string_view report_id; + std::span optional_fields; + std::uint32_t sequence_number{}; + std::span report_time; + bool buffered{}; + bool buffer_overflow{}; + std::span entry_id; + std::uint32_t conf_revision{}; + std::uint8_t reason_for_inclusion{}; +}; + +struct MmsStaticInformationReportEncodeResult final { + MmsStaticInformationReportStatus status{ + MmsStaticInformationReportStatus::invalid_report}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; + std::size_t member_count{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == MmsStaticInformationReportStatus::response_ready; + } +}; + +class MmsStaticInformationReportEncoder final { +public: + [[nodiscard]] static MmsStaticInformationReportEncodeResult + encode_data_set_snapshot_into( + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, + const MmsObjectNameView& data_set_name, + const MmsStaticInformationReportInput& report, + std::span destination, + std::span workspace) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_report_connection.hpp b/include/ariec61850/mms/static_report_connection.hpp new file mode 100644 index 000000000..dd37f6cc2 --- /dev/null +++ b/include/ariec61850/mms/static_report_connection.hpp @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/connection_runtime.hpp" +#include "ariec61850/mms/static_urcb_runtime.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticReportConnectionStatus : std::uint8_t { + no_report_due, + response_ready, + not_established, + response_buffer_too_small, + workspace_too_small, + report_encode_failed, + stale_plan, +}; + +struct MmsStaticReportConnectionResult final { + MmsStaticReportConnectionStatus status{MmsStaticReportConnectionStatus::no_report_due}; + MmsStaticUrcbStatus urcb_status{MmsStaticUrcbStatus::no_report_due}; + std::size_t bytes_written{}; + std::size_t required_response_bytes{}; + std::size_t required_workspace_bytes{}; + std::size_t control_block_index{MmsStaticUrcbEmissionPlan::invalid_index}; + std::uint8_t sequence_number{}; + MmsStaticUrcbReportReason reason{MmsStaticUrcbReportReason::none}; + + [[nodiscard]] constexpr bool response_ready() const noexcept { + return status == MmsStaticReportConnectionStatus::response_ready; + } +}; + +class MmsStaticReportConnection final { +public: + // Poll one due URCB report for an already-established MMS connection. + // The URCB plan is committed only after a complete TPKT frame is encoded, + // so capacity retries cannot consume GI/integrity state or skip SqNum. + [[nodiscard]] static MmsStaticReportConnectionResult poll( + const MmsStaticConnectionRuntime& connection, + MmsStaticUrcbRuntime& reports, + std::uint64_t now_ms, + std::span report_time, + std::span response, + std::span workspace) noexcept; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_urcb_objects.hpp b/include/ariec61850/mms/static_urcb_objects.hpp new file mode 100644 index 000000000..ec3f1917c --- /dev/null +++ b/include/ariec61850/mms/static_urcb_objects.hpp @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/mms/static_urcb_runtime.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +using MmsStaticNowMsCallback = std::uint64_t (*)(const void* context) noexcept; + +enum class MmsStaticUrcbAttribute : std::uint8_t { + report_id, + report_enabled, + reserved, + data_set, + conf_revision, + optional_fields, + buffer_time, + trigger_options, + integrity_period, + general_interrogation, + sequence_number, +}; + +struct MmsStaticUrcbObjectContext final { + MmsStaticUrcbRuntime* runtime{}; + std::size_t index{}; + MmsStaticUrcbAttribute attribute{MmsStaticUrcbAttribute::report_id}; + MmsStaticNowMsCallback now_ms{}; + const void* now_context{}; +}; + +class MmsStaticUrcbObjectBank final { +public: + static constexpr std::size_t attributes_per_control_block = 11U; + + MmsStaticUrcbObjectBank( + MmsStaticUrcbRuntime& runtime, + std::span base_objects, + std::span object_storage, + std::span context_storage, + std::span name_storage, + MmsStaticNowMsCallback now_ms = nullptr, + const void* now_context = nullptr) noexcept + : runtime_{&runtime}, + base_objects_{base_objects}, + object_storage_{object_storage}, + context_storage_{context_storage}, + name_storage_{name_storage}, + now_ms_{now_ms}, + now_context_{now_context} {} + + [[nodiscard]] bool initialize() noexcept; + + [[nodiscard]] constexpr bool valid() const noexcept { return initialized_; } + [[nodiscard]] constexpr std::size_t object_count() const noexcept { + return initialized_ ? object_count_ : 0U; + } + [[nodiscard]] constexpr std::size_t name_bytes_used() const noexcept { + return initialized_ ? name_bytes_used_ : 0U; + } + + [[nodiscard]] std::size_t required_object_capacity() const noexcept; + [[nodiscard]] std::size_t required_context_capacity() const noexcept; + [[nodiscard]] std::size_t required_name_bytes() const noexcept; + + [[nodiscard]] constexpr const MmsStaticObjectTable& table() const noexcept { + return table_; + } + +private: + MmsStaticUrcbRuntime* runtime_{}; + std::span base_objects_{}; + std::span object_storage_{}; + std::span context_storage_{}; + std::span name_storage_{}; + MmsStaticNowMsCallback now_ms_{}; + const void* now_context_{}; + MmsStaticObjectTable table_{std::span{}}; + std::size_t object_count_{}; + std::size_t name_bytes_used_{}; + bool initialized_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/include/ariec61850/mms/static_urcb_runtime.hpp b/include/ariec61850/mms/static_urcb_runtime.hpp new file mode 100644 index 000000000..10a6a9b88 --- /dev/null +++ b/include/ariec61850/mms/static_urcb_runtime.hpp @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/static_information_report.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticUrcbStatus : std::uint8_t { + ok, + invalid_runtime, + index_out_of_range, + invalid_value, + object_access_denied, + temporarily_unavailable, + data_set_not_found, + no_report_due, + stale_plan, + workspace_too_small, + backend_failure, + response_buffer_too_small, + report_encode_failed, +}; + +enum class MmsStaticUrcbReportReason : std::uint8_t { + none = 0x00U, + general_interrogation = 0x08U, + integrity = 0x10U, +}; + +struct MmsStaticUrcbDefinition final { + std::string_view domain; + std::string_view item; + std::string_view report_id; + std::string_view data_set_domain; + std::string_view data_set_item; + std::uint32_t conf_revision{1U}; + std::array + optional_fields{}; + std::uint32_t buffer_time_ms{}; + std::uint8_t trigger_options{}; + std::uint32_t integrity_period_ms{}; +}; + +struct MmsStaticUrcbState final { + static constexpr std::size_t maximum_report_id_bytes = + MmsInformationReportSpanCodec::maximum_report_id_bytes; + static constexpr std::size_t maximum_reference_component_bytes = + MmsInformationReportSpanCodec::maximum_reference_bytes; + + std::array report_id_storage{}; + std::size_t report_id_size{}; + std::array data_set_domain_storage{}; + std::size_t data_set_domain_size{}; + std::array data_set_item_storage{}; + std::size_t data_set_item_size{}; + std::array + optional_fields{}; + std::uint32_t conf_revision{1U}; + std::uint32_t buffer_time_ms{}; + std::uint32_t integrity_period_ms{}; + std::uint64_t next_integrity_due_ms{}; + std::uint32_t revision{}; + std::uint8_t trigger_options{}; + std::uint8_t sequence_number{}; + bool enabled{}; + bool reserved{}; + bool general_interrogation_pending{}; + bool integrity_armed{}; + + [[nodiscard]] std::string_view report_id() const noexcept { + return {report_id_storage.data(), report_id_size}; + } + + [[nodiscard]] std::string_view data_set_domain() const noexcept { + return {data_set_domain_storage.data(), data_set_domain_size}; + } + + [[nodiscard]] std::string_view data_set_item() const noexcept { + return {data_set_item_storage.data(), data_set_item_size}; + } +}; + +struct MmsStaticUrcbEmissionPlan final { + static constexpr std::size_t invalid_index = + std::numeric_limits::max(); + + std::size_t index{invalid_index}; + std::uint32_t revision{}; + std::uint8_t sequence_number{}; + MmsStaticUrcbReportReason reason{MmsStaticUrcbReportReason::none}; +}; + +struct MmsStaticUrcbEncodeResult final { + MmsStaticUrcbStatus status{MmsStaticUrcbStatus::invalid_runtime}; + std::size_t bytes_written{}; + std::size_t required_bytes{}; + std::size_t member_count{}; + + [[nodiscard]] constexpr bool success() const noexcept { + return status == MmsStaticUrcbStatus::ok; + } +}; + +class MmsStaticUrcbRuntime final { +public: + static constexpr std::size_t maximum_control_blocks = 16U; + static constexpr std::uint32_t minimum_integrity_period_ms = 100U; + + MmsStaticUrcbRuntime( + std::span definitions, + std::span states, + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets) noexcept + : definitions_{definitions}, + states_{states}, + objects_{&objects}, + data_sets_{&data_sets} {} + + [[nodiscard]] bool initialize() noexcept; + [[nodiscard]] constexpr bool valid() const noexcept { return initialized_; } + [[nodiscard]] constexpr std::size_t size() const noexcept { + return initialized_ ? definitions_.size() : 0U; + } + + [[nodiscard]] const MmsStaticUrcbDefinition* definition( + std::size_t index) const noexcept; + [[nodiscard]] const MmsStaticUrcbState* state(std::size_t index) const noexcept; + [[nodiscard]] MmsStaticUrcbState* state(std::size_t index) noexcept; + + [[nodiscard]] bool find_index( + const MmsObjectNameView& name, + std::size_t& index) const noexcept; + + [[nodiscard]] MmsStaticUrcbStatus set_enabled( + std::size_t index, + bool enabled, + std::uint64_t now_ms) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_reserved( + std::size_t index, + bool reserved) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_report_id( + std::size_t index, + std::string_view report_id) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_data_set( + std::size_t index, + std::string_view domain, + std::string_view item) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_optional_fields( + std::size_t index, + std::span optional_fields) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_buffer_time_ms( + std::size_t index, + std::uint32_t buffer_time_ms) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_trigger_options( + std::size_t index, + std::uint8_t trigger_options) noexcept; + [[nodiscard]] MmsStaticUrcbStatus set_integrity_period_ms( + std::size_t index, + std::uint32_t integrity_period_ms) noexcept; + [[nodiscard]] MmsStaticUrcbStatus request_general_interrogation( + std::size_t index) noexcept; + + [[nodiscard]] bool next_due( + std::uint64_t now_ms, + MmsStaticUrcbEmissionPlan& plan) const noexcept; + + [[nodiscard]] MmsStaticUrcbEncodeResult encode( + const MmsStaticUrcbEmissionPlan& plan, + std::span report_time, + std::span destination, + std::span workspace) const noexcept; + + [[nodiscard]] MmsStaticUrcbStatus commit( + const MmsStaticUrcbEmissionPlan& plan, + std::uint64_t now_ms) noexcept; + +private: + std::span definitions_{}; + std::span states_{}; + const MmsStaticObjectTable* objects_{}; + const MmsStaticDataSetTable* data_sets_{}; + bool initialized_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/scripts/build_mms_direct_control_server.ps1 b/scripts/build_mms_direct_control_server.ps1 new file mode 100644 index 000000000..e6c589e32 --- /dev/null +++ b/scripts/build_mms_direct_control_server.ps1 @@ -0,0 +1,111 @@ +param( + [string]$BuildDir = "build-mms-direct-control" +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent $PSScriptRoot +$Source = Join-Path $Root "tools\mms_direct_control_server" +$Build = Join-Path $Root $BuildDir + +function Invoke-Checked { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$Arguments + ) + + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "$FilePath failed with exit code $LASTEXITCODE." + } +} + +function Repair-MissingTrackedFiles { + $insideWorkTree = (& git -C $Root rev-parse --is-inside-work-tree 2>$null) + if ($LASTEXITCODE -ne 0 -or $insideWorkTree -ne "true") { + return + } + + $tracked = @(& git -C $Root ls-files) + if ($LASTEXITCODE -ne 0) { + throw "Unable to enumerate tracked files before the MMS server build." + } + + $missing = @( + $tracked | Where-Object { + $_ -and -not (Test-Path -LiteralPath (Join-Path $Root $_)) + } + ) + + if ($missing.Count -eq 0) { + return + } + + Write-Host "[repair] Restoring $($missing.Count) tracked file(s) missing from the working tree..." + foreach ($relativePath in $missing) { + Write-Host "[repair] $relativePath" + } + + & git -C $Root restore --worktree --source=HEAD -- @missing + if ($LASTEXITCODE -ne 0) { + throw "Unable to restore tracked files missing from the working tree. Run 'git status --short' and inspect the checkout before retrying." + } +} + +Repair-MissingTrackedFiles + +$requiredPaths = @( + "CMakeLists.txt", + "src\mms\association_runtime_control_wait.cpp", + "src\mms\static_report_session.cpp", + "src\control\guarded_control.cpp", + "src\control\mms_control_structure.cpp", + "src\control\command_termination.cpp", + "src\control\control_discovery.cpp", + "src\control\control_session.cpp", + "src\control\mms_association_control_transport.cpp", + "src\mms\static_direct_control.cpp", + "src\mms\static_information_report.cpp", + "src\mms\static_report_connection.cpp", + "src\mms\static_urcb_objects.cpp", + "src\mms\static_urcb_runtime.cpp", + "tools\mms_direct_control_server.cpp", + "tools\mms_urcb_server.cpp" +) + +$stillMissing = @( + $requiredPaths | Where-Object { + -not (Test-Path -LiteralPath (Join-Path $Root $_)) + } +) +if ($stillMissing.Count -ne 0) { + $formatted = ($stillMissing | ForEach-Object { " - $_" }) -join [Environment]::NewLine + throw "MMS server build prerequisites are still missing after checkout repair:$([Environment]::NewLine)$formatted$([Environment]::NewLine)Verify that HEAD is agent/mms-server-r1-r2-current-main and run 'git status --short'." +} + +Write-Host "[OK] MMS server source preflight passed." + +Invoke-Checked cmake -S $Source -B $Build -DCMAKE_BUILD_TYPE=Release +Invoke-Checked cmake --build $Build --config Release --parallel +Invoke-Checked ctest --test-dir $Build -C Release --output-on-failure + +function Find-BuiltExe([string]$Name) { + $candidates = @( + (Join-Path $Build "Release\$Name.exe"), + (Join-Path $Build "$Name.exe") + ) + return $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +} + +$controlExe = Find-BuiltExe "ariec61850_mms_direct_control_server" +$urcbExe = Find-BuiltExe "ariec61850_mms_urcb_server" +if (-not $controlExe) { + throw "Build succeeded but ariec61850_mms_direct_control_server.exe was not found." +} +if (-not $urcbExe) { + throw "Build succeeded but ariec61850_mms_urcb_server.exe was not found." +} + +Write-Host "[OK] MMS Direct Control server: $controlExe" +Write-Host "[OK] MMS URCB Reporting server: $urcbExe" diff --git a/src/acse/association_span.cpp b/src/acse/association_span.cpp index c7f1ea4d4..f75f78c26 100644 --- a/src/acse/association_span.cpp +++ b/src/acse/association_span.cpp @@ -127,6 +127,7 @@ constexpr std::array kDefaultAcceptAare{ bool saw_direct = false; bool saw_indirect = false; + bool saw_descriptor = false; bool saw_encoding = false; std::size_t offset = 0U; while (offset < outer.value.size()) { @@ -157,6 +158,13 @@ constexpr std::array kDefaultAcceptAare{ } external.indirect_reference = *value; saw_indirect = true; + } else if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 7 && !field.constructed) { + if (saw_descriptor || field.value.size() > AcseSpanCodec::maximum_acse_bytes) { + external = {}; + return false; + } + saw_descriptor = true; } else if (field.tag_class == asn1::BerClass::context_specific && field.tag_number == 0 && field.constructed) { if (saw_encoding || field.value.size() > AcseSpanCodec::maximum_acse_bytes) { @@ -170,7 +178,316 @@ constexpr std::array kDefaultAcceptAare{ return false; } } - return saw_direct && saw_indirect && saw_encoding; + + return saw_encoding; +} + +[[nodiscard]] bool compat_read_positive_integer( + const asn1::BerTlvView& tlv, + std::uint32_t& value) noexcept { + value = 0U; + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded || *decoded == 0U) { + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool compat_count_contexts( + const std::span list, + std::size_t& count) noexcept { + count = 0U; + std::size_t offset = 0U; + while (offset < list.size()) { + if (count >= osi::PresentationSpanCodec::maximum_contexts) { + count = 0U; + return false; + } + asn1::BerTlvView definition; + if (!asn1::BerSpanReader::try_read_tlv(list, offset, definition) || + definition.tag_class != asn1::BerClass::universal || + definition.tag_number != 16 || !definition.constructed) { + count = 0U; + return false; + } + ++count; + } + return count != 0U; +} + +[[nodiscard]] bool compat_decode_pdv( + const asn1::BerTlvView& outer, + osi::PresentationPdvView& pdv) noexcept { + pdv = {}; + if (outer.tag_class != asn1::BerClass::application || + outer.tag_number != 1 || !outer.constructed) { + return false; + } + + asn1::BerTlvView sequence; + if (!asn1::BerSpanReader::try_read_exact(outer.value, sequence) || + sequence.tag_class != asn1::BerClass::universal || + sequence.tag_number != 16 || !sequence.constructed) { + return false; + } + + bool saw_context = false; + bool saw_transfer_syntax = false; + bool saw_payload = false; + std::size_t offset = 0U; + while (offset < sequence.value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(sequence.value, offset, field)) { + pdv = {}; + return false; + } + if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 2 && !field.constructed) { + if (saw_context || !compat_read_positive_integer(field, pdv.context_id)) { + pdv = {}; + return false; + } + saw_context = true; + } else if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 6 && !field.constructed) { + if (saw_transfer_syntax || !valid_oid(field.value)) { + pdv = {}; + return false; + } + saw_transfer_syntax = true; + } else if (field.tag_class == asn1::BerClass::context_specific && + field.tag_number == 0 && field.constructed) { + if (saw_payload || field.value.empty()) { + pdv = {}; + return false; + } + pdv.single_asn1_type = field.value; + saw_payload = true; + } else { + pdv = {}; + return false; + } + } + return saw_context && saw_payload; +} + +[[nodiscard]] bool compat_decode_cp( + const std::span bytes, + osi::PresentationCpView& cp) noexcept { + cp = {}; + cp.mode_selector = 1U; + if (bytes.empty() || bytes.size() > osi::PresentationSpanCodec::maximum_ppdu_bytes) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(bytes, outer) || + outer.tag_class != asn1::BerClass::universal || + outer.tag_number != 17 || !outer.constructed) { + return false; + } + + bool saw_mode = false; + bool saw_normal = false; + std::size_t offset = 0U; + while (offset < outer.value.size()) { + asn1::BerTlvView item; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, item)) { + cp = {}; + return false; + } + if (item.tag_class == asn1::BerClass::context_specific && + item.tag_number == 0 && item.constructed) { + if (saw_mode) { + cp = {}; + return false; + } + asn1::BerTlvView mode; + if (!asn1::BerSpanReader::try_read_exact(item.value, mode) || + mode.tag_class != asn1::BerClass::context_specific || + mode.tag_number != 0 || mode.constructed || + !compat_read_positive_integer(mode, cp.mode_selector)) { + cp = {}; + return false; + } + saw_mode = true; + continue; + } + if (item.tag_class != asn1::BerClass::context_specific || + item.tag_number != 2 || !item.constructed) { + continue; + } + if (saw_normal) { + cp = {}; + return false; + } + + bool saw_calling = false; + bool saw_called = false; + bool saw_contexts = false; + bool saw_user_data = false; + std::size_t normal_offset = 0U; + while (normal_offset < item.value.size()) { + asn1::BerTlvView normal; + if (!asn1::BerSpanReader::try_read_tlv(item.value, normal_offset, normal)) { + cp = {}; + return false; + } + if (normal.tag_class == asn1::BerClass::context_specific && + normal.tag_number == 1 && !normal.constructed) { + if (saw_calling || normal.value.size() > + osi::PresentationSpanCodec::maximum_selector_bytes) { + cp = {}; + return false; + } + cp.calling_selector = normal.value; + saw_calling = true; + } else if (normal.tag_class == asn1::BerClass::context_specific && + normal.tag_number == 2 && !normal.constructed) { + if (saw_called || normal.value.size() > + osi::PresentationSpanCodec::maximum_selector_bytes) { + cp = {}; + return false; + } + cp.called_selector = normal.value; + saw_called = true; + } else if (normal.tag_class == asn1::BerClass::context_specific && + normal.tag_number == 4 && normal.constructed) { + if (saw_contexts || !compat_count_contexts( + normal.value, cp.context_count)) { + cp = {}; + return false; + } + cp.context_definition_list = normal.value; + saw_contexts = true; + } else if (normal.tag_class == asn1::BerClass::application && + normal.tag_number == 1 && normal.constructed) { + if (saw_user_data || !compat_decode_pdv(normal, cp.user_data)) { + cp = {}; + return false; + } + saw_user_data = true; + } else { + // Proven ARIEC61850 behavior: optional Presentation fields that + // are not needed for context negotiation do not reject AARQ. + continue; + } + } + if (!saw_contexts || !saw_user_data) { + cp = {}; + return false; + } + saw_normal = true; + } + return saw_normal && cp.mode_selector != 0U; +} + +[[nodiscard]] bool compat_decode_external( + const asn1::BerTlvView& user_information, + AcseExternalView& external) noexcept { + external = {}; + if (!user_information.constructed) { + return false; + } + + bool saw_external = false; + std::size_t ui_offset = 0U; + while (ui_offset < user_information.value.size()) { + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_tlv( + user_information.value, ui_offset, outer)) { + return false; + } + if (outer.tag_class != asn1::BerClass::universal || + outer.tag_number != 8 || !outer.constructed) { + continue; + } + if (saw_external) { + return false; + } + saw_external = true; + + std::size_t offset = 0U; + while (offset < outer.value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, field)) { + external = {}; + return false; + } + if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 6 && !field.constructed && + external.direct_reference.empty()) { + if (!valid_oid(field.value)) { + external = {}; + return false; + } + external.direct_reference = field.value; + } else if (field.tag_class == asn1::BerClass::universal && + field.tag_number == 2 && !field.constructed && + external.indirect_reference == 0U) { + const auto value = asn1::BerSpanReader::read_uint32(field); + if (!value) { + external = {}; + return false; + } + external.indirect_reference = *value; + } else if (field.tag_class == asn1::BerClass::context_specific && + field.tag_number == 0 && field.constructed) { + if (!external.single_asn1_type.empty() || field.value.empty()) { + external = {}; + return false; + } + external.single_asn1_type = field.value; + } + } + } + return saw_external && !external.single_asn1_type.empty(); +} + +[[nodiscard]] bool compat_decode_aarq( + const std::span bytes, + AcseAarqView& aarq) noexcept { + aarq = {}; + if (bytes.empty() || bytes.size() > AcseSpanCodec::maximum_acse_bytes) { + return false; + } + + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(bytes, outer) || + outer.tag_class != asn1::BerClass::application || + outer.tag_number != 0 || !outer.constructed) { + return false; + } + + bool saw_user_information = false; + std::size_t offset = 0U; + while (offset < outer.value.size()) { + asn1::BerTlvView field; + if (!asn1::BerSpanReader::try_read_tlv(outer.value, offset, field)) { + aarq = {}; + return false; + } + if (field.tag_class == asn1::BerClass::context_specific && + field.tag_number == 30 && field.constructed) { + if (saw_user_information || + !compat_decode_external(field, aarq.user_information)) { + aarq = {}; + return false; + } + saw_user_information = true; + } + } + if (!saw_user_information) { + aarq = {}; + return false; + } + + asn1::BerTlvView initiate; + return asn1::BerSpanReader::try_read_exact( + aarq.user_information.single_asn1_type, initiate) && + initiate.tag_class == asn1::BerClass::context_specific && + initiate.tag_number == 8 && initiate.constructed; } [[nodiscard]] bool request_is_valid_for_response( @@ -288,8 +605,7 @@ bool AcseSpanCodec::try_decode_aarq_view( saw_user_information = true; break; default: - aarq = {}; - return false; + break; } } @@ -327,6 +643,36 @@ bool AcseSpanCodec::try_decode_association_request_view( return true; } +bool AcseSpanCodec::try_decode_association_request_compat_view( + const std::span bytes, + AssociationRequestView& request) noexcept { + if (try_decode_association_request_view(bytes, request)) { + return true; + } + + request = {}; + if (!osi::SessionSpanCodec::try_decode_view(bytes, request.session) || + request.session.kind != osi::SessionWireKind::connect || + !compat_decode_cp(request.session.user_data, request.presentation) || + !compat_decode_aarq( + request.presentation.user_data.single_asn1_type, request.aarq)) { + request = {}; + return false; + } + + if (!request.presentation.try_context_id_for_abstract_syntax( + osi::PresentationSpanCodec::acse_abstract_syntax_name(), + request.acse_presentation_context_id) || + !request.presentation.try_context_id_for_abstract_syntax( + osi::PresentationSpanCodec::mms_abstract_syntax_name(), + request.mms_presentation_context_id) || + !request_is_valid_for_response(request)) { + request = {}; + return false; + } + return true; +} + wire::EncodeResult AcseSpanCodec::encode_default_accept_aare_into( const std::span destination) noexcept { if (destination.size() < kDefaultAcceptAare.size()) { diff --git a/src/mms/connection_runtime.cpp b/src/mms/connection_runtime.cpp index 523981ffd..2229b3e5e 100644 --- a/src/mms/connection_runtime.cpp +++ b/src/mms/connection_runtime.cpp @@ -20,6 +20,15 @@ namespace ar::iec61850::mms { namespace { +constexpr std::array kConservativeStructureType{ + 0xA2U, 0x02U, 0xA1U, 0x00U}; + +// FileDirectory-Response service value: listOfDirectoryEntry [0] empty, +// moreFollows [1] FALSE. Mirrors the bounded deterministic behavior of +// the proven ARIEC61850 engineering-client simulator. +constexpr std::array kEmptyFileDirectoryFields{ + 0xA0U, 0x00U, 0x81U, 0x01U, 0x00U}; + [[nodiscard]] MmsStaticConnectionResult make_result( const MmsStaticConnectionStatus status, const MmsStaticConnectionState state, @@ -63,50 +72,90 @@ namespace { return true; } +[[nodiscard]] bool cotp_stream_size( + const std::size_t payload_bytes, + const std::uint8_t tpdu_size_code, + std::size_t& maximum_user_data, + std::size_t& segment_count, + std::size_t& required) noexcept { + maximum_user_data = 0U; + segment_count = 0U; + required = 0U; + std::size_t tpdu_bytes{}; + if (!osi::CotpSpanCodec::try_tpdu_size_bytes(tpdu_size_code, tpdu_bytes) || + tpdu_bytes <= 3U) { + return false; + } + maximum_user_data = tpdu_bytes - 3U; + segment_count = payload_bytes == 0U + ? 1U + : 1U + ((payload_bytes - 1U) / maximum_user_data); + if (segment_count > + (std::numeric_limits::max() - payload_bytes) / 7U) { + return false; + } + required = payload_bytes + segment_count * 7U; + return true; +} + [[nodiscard]] MmsStaticConnectionResult wrap_cotp_data_response( const std::span session_or_presentation, const std::size_t consumed, const MmsStaticConnectionState state, - const std::span response, - const std::span workspace) noexcept { - // COTP Data adds three bytes and TPKT adds four bytes. Preflight both - // caller-owned buffers so a capacity retry never advances connection state. + const std::uint8_t tpdu_size_code, + const std::span response) noexcept { + std::size_t maximum_user_data{}; + std::size_t segment_count{}; std::size_t required{}; - if (!add_overhead(session_or_presentation.size(), 7U, required)) { + if (!cotp_stream_size( + session_or_presentation.size(), + tpdu_size_code, + maximum_user_data, + segment_count, + required)) { return make_result(MmsStaticConnectionStatus::backend_failure, state); } if (response.size() < required) { return make_response_capacity(state, required); } - if (workspace.size() < required) { - return make_workspace_capacity(state, required); - } - const auto cotp = osi::CotpSpanCodec::encode_data_into( - session_or_presentation, - response.first(required - osi::TpktSpanCodec::header_length)); - if (!cotp.success()) { - if (cotp.status == wire::EncodeStatus::buffer_too_small) { - return make_response_capacity( - state, cotp.required_bytes + osi::TpktSpanCodec::header_length); + std::size_t input_offset = 0U; + std::size_t output_offset = 0U; + for (std::size_t segment = 0U; segment < segment_count; ++segment) { + const auto remaining = session_or_presentation.size() - input_offset; + const auto chunk_size = std::min(remaining, maximum_user_data); + const auto final_segment = segment + 1U == segment_count; + const auto frame_bytes = chunk_size + 7U; + if (frame_bytes > std::numeric_limits::max()) { + return make_result(MmsStaticConnectionStatus::backend_failure, state); } - return make_result(MmsStaticConnectionStatus::backend_failure, state); - } - const auto tpkt = osi::TpktSpanCodec::encode_into( - response.first(cotp.bytes_written), workspace.first(required)); - if (!tpkt.success() || tpkt.bytes_written != required) { - if (tpkt.status == wire::EncodeStatus::buffer_too_small) { - return make_workspace_capacity(state, tpkt.required_bytes); + auto frame = response.subspan(output_offset, frame_bytes); + const auto cotp = osi::CotpSpanCodec::encode_data_into( + session_or_presentation.subspan(input_offset, chunk_size), + frame.subspan(osi::TpktSpanCodec::header_length), + final_segment, + static_cast(segment & 0x7FU)); + if (!cotp.success() || cotp.bytes_written != chunk_size + 3U) { + return make_result(MmsStaticConnectionStatus::backend_failure, state); } + + frame[0] = 0x03U; + frame[1] = 0x00U; + frame[2] = static_cast((frame_bytes >> 8U) & 0xFFU); + frame[3] = static_cast(frame_bytes & 0xFFU); + input_offset += chunk_size; + output_offset += frame_bytes; + } + + if (input_offset != session_or_presentation.size() || output_offset != required) { return make_result(MmsStaticConnectionStatus::backend_failure, state); } - std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin()); return make_result( MmsStaticConnectionStatus::response_ready, state, consumed, - tpkt.bytes_written); + required); } [[nodiscard]] MmsStaticConnectionResult application_rejected( @@ -121,10 +170,78 @@ namespace { return result; } +[[nodiscard]] MmsStaticConnectionResult wrap_mms_response( + const wire::EncodeResult encoded_mms, + const std::size_t consumed, + const MmsStaticConnectionState state, + const std::uint32_t presentation_context_id, + const std::uint8_t tpdu_size_code, + const std::span response, + const std::span workspace, + const MmsStaticDispatchStatus application_status, + const MmsWireConfirmedService service, + const std::uint32_t invoke_id) noexcept { + if (!encoded_mms.success()) { + if (encoded_mms.status == wire::EncodeStatus::buffer_too_small && + encoded_mms.required_bytes > 0U) { + const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( + presentation_context_id, encoded_mms.required_bytes); + if (fully_encoded) { + std::size_t maximum_user_data{}; + std::size_t segment_count{}; + std::size_t required{}; + if (cotp_stream_size( + *fully_encoded, + tpdu_size_code, + maximum_user_data, + segment_count, + required)) { + auto result = make_response_capacity(state, required); + result.application_status = application_status; + result.application_service = service; + result.invoke_id = invoke_id; + return result; + } + } + } + return make_result(MmsStaticConnectionStatus::backend_failure, state); + } + + const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( + response.first(encoded_mms.bytes_written), + workspace, + presentation_context_id, + true); + if (!p_data.success()) { + if (p_data.status == wire::EncodeStatus::buffer_too_small) { + auto result = make_workspace_capacity(state, p_data.required_bytes); + result.application_status = application_status; + result.application_service = service; + result.invoke_id = invoke_id; + return result; + } + return make_result(MmsStaticConnectionStatus::backend_failure, state); + } + + auto wrapped = wrap_cotp_data_response( + workspace.first(p_data.bytes_written), + consumed, + state, + tpdu_size_code, + response); + if (wrapped.response_ready()) { + wrapped.application_status = application_status; + wrapped.application_service = service; + wrapped.invoke_id = invoke_id; + } + return wrapped; +} + [[nodiscard]] bool write_outer_capacity( const MmsConfirmedPduView& confirmed, const MmsStaticApplicationDispatcher& dispatcher, const std::uint32_t presentation_context_id, + const std::uint8_t tpdu_size_code, std::size_t& required) noexcept { required = 0U; if (confirmed.service() != MmsWireConfirmedService::write) { @@ -154,11 +271,37 @@ namespace { } const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( presentation_context_id, probe.required_bytes); - return fully_encoded && add_overhead(*fully_encoded, 11U, required); + if (!fully_encoded) { + return false; + } + std::size_t maximum_user_data{}; + std::size_t segment_count{}; + return cotp_stream_size( + *fully_encoded, + tpdu_size_code, + maximum_user_data, + segment_count, + required); +} + +[[nodiscard]] bool compatibility_error_status( + const MmsStaticDispatchStatus status) noexcept { + return status == MmsStaticDispatchStatus::malformed_request || + status == MmsStaticDispatchStatus::unsupported_service || + status == MmsStaticDispatchStatus::unsupported_request || + status == MmsStaticDispatchStatus::object_not_found; } } // namespace +void MmsStaticConnectionRuntime::clear_cotp_reassembly() noexcept { + cotp_reassembly_size_ = 0U; + cotp_reassembly_complete_ = false; + cotp_reassembly_uses_workspace_ = false; + cotp_reassembly_workspace_data_ = nullptr; + cotp_reassembly_workspace_size_ = 0U; +} + void MmsStaticConnectionRuntime::notify_association_closed() noexcept { if (!association_active_ || association_close_notified_) { return; @@ -181,14 +324,17 @@ void MmsStaticConnectionRuntime::notify_association_closed() noexcept { void MmsStaticConnectionRuntime::close_transport() noexcept { notify_association_closed(); + clear_cotp_reassembly(); state_ = MmsStaticConnectionState::closed; mms_presentation_context_id_ = 0U; } void MmsStaticConnectionRuntime::reset() noexcept { notify_association_closed(); + clear_cotp_reassembly(); state_ = MmsStaticConnectionState::awaiting_cotp_connect; mms_presentation_context_id_ = 0U; + negotiated_tpdu_size_code_ = policy_.maximum_tpdu_size_code; association_active_ = false; association_close_notified_ = false; } @@ -196,7 +342,8 @@ void MmsStaticConnectionRuntime::reset() noexcept { MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( const std::span tcp_bytes, const std::span response, - const std::span workspace) noexcept { + const std::span workspace_storage) noexcept { + auto workspace = workspace_storage; if (state_ == MmsStaticConnectionState::closed) { return make_result(MmsStaticConnectionStatus::closed, state_); } @@ -210,6 +357,7 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( } if (peek.status != osi::TpktPeekStatus::ready || peek.frame_bytes == 0U) { state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); return make_result(MmsStaticConnectionStatus::malformed_transport, state_); } @@ -219,6 +367,7 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( if (!osi::TpktSpanCodec::try_decode_view(frame_bytes, tpkt) || !osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp)) { state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); return make_result( MmsStaticConnectionStatus::malformed_transport, state_, peek.frame_bytes); } @@ -232,6 +381,7 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( if (state_ == MmsStaticConnectionState::awaiting_cotp_connect) { if (cotp.kind != osi::CotpWireKind::connection_request) { state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); return make_result( MmsStaticConnectionStatus::protocol_violation, state_, @@ -251,6 +401,17 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( return make_result(MmsStaticConnectionStatus::backend_failure, state_); } + osi::CotpTpduView confirmed; + std::span selected; + if (!osi::CotpSpanCodec::try_decode_view( + workspace.first(cc.bytes_written), confirmed) || + !confirmed.try_parameter(osi::CotpSpanCodec::tpdu_size_parameter, selected) || + selected.size() != 1U) { + state_ = MmsStaticConnectionState::fault; + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + negotiated_tpdu_size_code_ = selected[0]; + std::size_t required{}; if (!add_overhead(cc.bytes_written, osi::TpktSpanCodec::header_length, required)) { state_ = MmsStaticConnectionState::fault; @@ -277,20 +438,113 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( encoded.bytes_written); } - if (cotp.kind != osi::CotpWireKind::data || - (policy_.require_end_of_transmission && !cotp.end_of_transmission)) { + if (cotp.kind != osi::CotpWireKind::data) { state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); return make_result( MmsStaticConnectionStatus::protocol_violation, state_, peek.frame_bytes); } + bool using_reassembly = false; + std::span reassembly = policy_.cotp_reassembly; + const auto select_workspace_reassembly = [this, workspace_storage, &workspace, &reassembly]() noexcept { + if (workspace_storage.size() < 2U) { + return false; + } + const auto processing_bytes = workspace_storage.size() / 2U; + const auto reassembly_bytes = workspace_storage.size() - processing_bytes; + if (processing_bytes == 0U || reassembly_bytes == 0U) { + return false; + } + if (cotp_reassembly_uses_workspace_) { + if (cotp_reassembly_workspace_data_ != workspace_storage.data() || + cotp_reassembly_workspace_size_ != workspace_storage.size()) { + return false; + } + } else { + cotp_reassembly_uses_workspace_ = true; + cotp_reassembly_workspace_data_ = workspace_storage.data(); + cotp_reassembly_workspace_size_ = workspace_storage.size(); + } + workspace = workspace_storage.first(processing_bytes); + reassembly = workspace_storage.subspan(processing_bytes, reassembly_bytes); + return true; + }; + + if (cotp_reassembly_complete_) { + if (cotp_reassembly_size_ == 0U || !cotp.end_of_transmission) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + if (cotp_reassembly_uses_workspace_ && !select_workspace_reassembly()) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + if (reassembly.empty() || cotp_reassembly_size_ > reassembly.size()) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + cotp.user_data = std::span{reassembly}.first(cotp_reassembly_size_); + using_reassembly = true; + } else if (cotp_reassembly_size_ != 0U || !cotp.end_of_transmission) { + if (reassembly.empty()) { + if (!policy_.require_end_of_transmission) { + // Explicit compatibility opt-out: expose the partial TPDU to the + // upper decoder exactly as the historical strict runtime did. + } else if (!select_workspace_reassembly()) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result(MmsStaticConnectionStatus::backend_failure, state_); + } + } + if (!reassembly.empty()) { + if (cotp_reassembly_size_ > reassembly.size() || + cotp.user_data.size() > reassembly.size() - cotp_reassembly_size_) { + state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); + return make_result( + MmsStaticConnectionStatus::protocol_violation, + state_, + peek.frame_bytes); + } + std::copy( + cotp.user_data.begin(), + cotp.user_data.end(), + reassembly.begin() + static_cast(cotp_reassembly_size_)); + cotp_reassembly_size_ += cotp.user_data.size(); + if (!cotp.end_of_transmission) { + return make_result( + MmsStaticConnectionStatus::consumed_no_response, + state_, + peek.frame_bytes); + } + cotp_reassembly_complete_ = true; + cotp.user_data = std::span{reassembly}.first(cotp_reassembly_size_); + using_reassembly = true; + } + } + + const auto finish = [this, using_reassembly](MmsStaticConnectionResult result) noexcept { + if (using_reassembly && result.consumed_bytes != 0U) { + clear_cotp_reassembly(); + } + return result; + }; + if (state_ == MmsStaticConnectionState::awaiting_association) { acse::AssociationRequestView association; - if (!acse::AcseSpanCodec::try_decode_association_request_view( + if (!acse::AcseSpanCodec::try_decode_association_request_compat_view( cotp.user_data, association)) { state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); return make_result( MmsStaticConnectionStatus::protocol_violation, state_, @@ -302,6 +556,7 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( association.aarq.user_information.single_asn1_type, initiate) || association.mms_presentation_context_id == 0U) { state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); return make_result( MmsStaticConnectionStatus::protocol_violation, state_, @@ -315,6 +570,7 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( return make_workspace_capacity(state_, accept.required_bytes); } state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); return make_result(MmsStaticConnectionStatus::backend_failure, state_); } @@ -322,8 +578,8 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( workspace.first(accept.bytes_written), peek.frame_bytes, MmsStaticConnectionState::established, - response, - workspace); + negotiated_tpdu_size_code_, + response); if (!wrapped.response_ready()) { return wrapped; } @@ -333,11 +589,12 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( association_close_notified_ = false; auto result = wrapped; result.state = state_; - return result; + return finish(result); } if (state_ != MmsStaticConnectionState::established) { state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); return make_result( MmsStaticConnectionStatus::protocol_violation, state_, @@ -351,18 +608,81 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( session.presentation_payload, pdv) || pdv.context_id != mms_presentation_context_id_) { state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); return make_result( MmsStaticConnectionStatus::protocol_violation, state_, peek.frame_bytes); } + if (MmsPduSpanCodec::is_conclude_request(pdv.single_asn1_type)) { + const auto conclude = MmsPduSpanCodec::encode_conclude_response_into(response); + return finish(wrap_mms_response( + conclude, + peek.frame_bytes, + state_, + mms_presentation_context_id_, + negotiated_tpdu_size_code_, + response, + workspace, + MmsStaticDispatchStatus::response_ready, + MmsWireConfirmedService::unknown, + 0U)); + } + MmsConfirmedPduView confirmed; - if (MmsPduSpanCodec::try_decode_confirmed_request_view( - pdv.single_asn1_type, confirmed)) { + const bool confirmed_decoded = MmsPduSpanCodec::try_decode_confirmed_request_view( + pdv.single_asn1_type, confirmed); + if (confirmed_decoded) { + if (confirmed.service() == MmsWireConfirmedService::identify) { + const auto identify = !confirmed.service_constructed && confirmed.service_value.empty() + ? MmsPduSpanCodec::encode_identify_response_into( + confirmed.invoke_id, response) + : MmsPduSpanCodec::encode_confirmed_error_into( + confirmed.invoke_id, response); + return finish(wrap_mms_response( + identify, + peek.frame_bytes, + state_, + mms_presentation_context_id_, + negotiated_tpdu_size_code_, + response, + workspace, + MmsStaticDispatchStatus::response_ready, + MmsWireConfirmedService::identify, + confirmed.invoke_id)); + } + + if (confirmed.service() == MmsWireConfirmedService::file_directory) { + const auto directory = confirmed.service_constructed + ? MmsPduSpanCodec::encode_confirmed_response_into( + confirmed.invoke_id, + static_cast(MmsWireConfirmedService::file_directory), + true, + kEmptyFileDirectoryFields, + response) + : MmsPduSpanCodec::encode_confirmed_error_into( + confirmed.invoke_id, response); + return finish(wrap_mms_response( + directory, + peek.frame_bytes, + state_, + mms_presentation_context_id_, + negotiated_tpdu_size_code_, + response, + workspace, + MmsStaticDispatchStatus::response_ready, + MmsWireConfirmedService::file_directory, + confirmed.invoke_id)); + } + std::size_t write_required{}; if (write_outer_capacity( - confirmed, dispatcher_, mms_presentation_context_id_, write_required)) { + confirmed, + dispatcher_, + mms_presentation_context_id_, + negotiated_tpdu_size_code_, + write_required)) { if (response.size() < write_required) { return make_response_capacity(state_, write_required); } @@ -378,15 +698,25 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( if (application.status == MmsStaticDispatchStatus::response_buffer_too_small) { const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( mms_presentation_context_id_, application.required_bytes); - std::size_t required{}; - if (fully_encoded && add_overhead(*fully_encoded, 11U, required)) { - auto result = make_response_capacity(state_, required); - result.application_status = application.status; - result.application_service = application.service; - result.invoke_id = application.invoke_id; - return result; + if (fully_encoded) { + std::size_t maximum_user_data{}; + std::size_t segment_count{}; + std::size_t required{}; + if (cotp_stream_size( + *fully_encoded, + negotiated_tpdu_size_code_, + maximum_user_data, + segment_count, + required)) { + auto result = make_response_capacity(state_, required); + result.application_status = application.status; + result.application_service = application.service; + result.invoke_id = application.invoke_id; + return result; + } } state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); return make_result(MmsStaticConnectionStatus::backend_failure, state_); } if (application.status == MmsStaticDispatchStatus::workspace_too_small) { @@ -399,6 +729,7 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( if (application.status == MmsStaticDispatchStatus::backend_failure || application.status == MmsStaticDispatchStatus::invalid_object_table) { state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); auto result = make_result( MmsStaticConnectionStatus::backend_failure, state_, @@ -408,7 +739,51 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( result.invoke_id = application.invoke_id; return result; } - return application_rejected(state_, peek.frame_bytes, application); + + if (confirmed_decoded && + application.status == MmsStaticDispatchStatus::object_not_found && + confirmed.service() == MmsWireConfirmedService::get_variable_access_attributes) { + MmsVariableAccessAttributesRequestView attributes; + if (MmsServiceSpanCodec::try_decode_variable_access_attributes_request( + confirmed, attributes) && + attributes.name.kind == MmsObjectNameViewKind::vmd_specific && + !attributes.name.item.empty()) { + const auto fallback = + MmsServiceSpanCodec::encode_variable_access_attributes_response_into( + confirmed.invoke_id, + false, + kConservativeStructureType, + response); + return finish(wrap_mms_response( + fallback, + peek.frame_bytes, + state_, + mms_presentation_context_id_, + negotiated_tpdu_size_code_, + response, + workspace, + application.status, + confirmed.service(), + confirmed.invoke_id)); + } + } + + if (confirmed_decoded && compatibility_error_status(application.status)) { + const auto error = MmsPduSpanCodec::encode_confirmed_error_into( + confirmed.invoke_id, response); + return finish(wrap_mms_response( + error, + peek.frame_bytes, + state_, + mms_presentation_context_id_, + negotiated_tpdu_size_code_, + response, + workspace, + application.status, + confirmed.service(), + confirmed.invoke_id)); + } + return finish(application_rejected(state_, peek.frame_bytes, application)); } const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( @@ -418,12 +793,10 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( true); if (!p_data.success()) { if (p_data.status == wire::EncodeStatus::buffer_too_small) { - std::size_t required{}; - if (add_overhead(p_data.required_bytes, 7U, required)) { - return make_workspace_capacity(state_, required); - } + return make_workspace_capacity(state_, p_data.required_bytes); } state_ = MmsStaticConnectionState::fault; + clear_cotp_reassembly(); return make_result(MmsStaticConnectionStatus::backend_failure, state_); } @@ -431,14 +804,14 @@ MmsStaticConnectionResult MmsStaticConnectionRuntime::process_tcp_window( workspace.first(p_data.bytes_written), peek.frame_bytes, state_, - response, - workspace); + negotiated_tpdu_size_code_, + response); if (wrapped.response_ready()) { wrapped.application_status = application.status; wrapped.application_service = application.service; wrapped.invoke_id = application.invoke_id; } - return wrapped; + return finish(wrapped); } } // namespace ar::iec61850::mms diff --git a/src/mms/pdu_span.cpp b/src/mms/pdu_span.cpp index e0236d03c..ba876c9f5 100644 --- a/src/mms/pdu_span.cpp +++ b/src/mms/pdu_span.cpp @@ -28,6 +28,13 @@ constexpr std::array kDefaultInitiateResponse{ 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, 0x08U, 0x00U, 0x00U, 0x79U, 0xEFU, 0x18U}; +constexpr std::array kIdentifyFields{ + 0x80U, 0x0AU, + 'A','R','I','E','C','6','1','8','5','0', + 0x81U, 0x15U, + 'V','i','r','t','u','a','l',' ','I','E','D',' ','S','i','m','u','l','a','t','o','r', + 0x82U, 0x03U, '1','.','0'}; + [[nodiscard]] bool read_u32( const asn1::BerTlvView& tlv, std::uint32_t& value) noexcept { @@ -149,19 +156,29 @@ constexpr std::array kDefaultInitiateResponse{ } } - if (count != 5U || - !std::all_of(seen.begin(), seen.end(), [](const bool value) { return value; }) || - values[0] < 64U || values[0] > MmsPduSpanCodec::maximum_pdu_bytes || - values[1] == 0U || values[2] == 0U || values[3] == 0U) { + // ISO 9506 InitiateRequest permits localDetailCalling [0] and + // proposedDataStructureNestingLevel [3] to be absent. The original + // ARIEC61850 server accepted such request profiles and several engineering + // clients rely on that tolerance. Keep InitiateResponse decoding strict, + // but do not turn standards-valid optional omissions into a protocol fault. + const bool is_request = kind == MmsWirePduKind::initiate_request; + const bool mandatory_present = seen[1] && seen[2] && seen[4]; + const bool response_complete = seen[0] && seen[1] && seen[2] && seen[3] && seen[4]; + if (!mandatory_present || (!is_request && !response_complete) || + (seen[0] && (values[0] < 64U || values[0] > MmsPduSpanCodec::maximum_pdu_bytes)) || + values[1] == 0U || values[2] == 0U || + (seen[3] && values[3] == 0U)) { result = {}; return false; } result.kind = kind; - result.maximum_mms_pdu_size = values[0]; + result.maximum_mms_pdu_size = seen[0] + ? values[0] + : static_cast(MmsPduSpanCodec::maximum_pdu_bytes); result.maximum_outstanding_calling = values[1]; result.maximum_outstanding_called = values[2]; - result.data_structure_nesting_level = values[3]; + result.data_structure_nesting_level = seen[3] ? values[3] : 5U; result.detail = detail; return true; } @@ -320,6 +337,57 @@ constexpr std::array kDefaultInitiateResponse{ return {wire::EncodeStatus::ok, *required, *required}; } +[[nodiscard]] wire::EncodeResult encode_confirmed_error( + const std::uint32_t invoke_id, + const std::span destination) noexcept { + if (invoke_id > MmsPduSpanCodec::maximum_invoke_id) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + // Confirmed-ErrorPDU [2] { invokeID [0], serviceError [2] + // { errorClass [0] { service [12] = 0 } } }. + const auto invoke_value = positive_integer_size(invoke_id); + const auto invoke_tlv = asn1::BerSpanWriter::tlv_size(0, invoke_value); + const auto class_choice_tlv = asn1::BerSpanWriter::tlv_size(12, 1U); + if (!invoke_tlv || !class_choice_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto error_class_tlv = asn1::BerSpanWriter::tlv_size(0, *class_choice_tlv); + if (!error_class_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto service_error_tlv = asn1::BerSpanWriter::tlv_size(2, *error_class_tlv); + if (!service_error_tlv || + *invoke_tlv > std::numeric_limits::max() - *service_error_tlv) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto outer_content = *invoke_tlv + *service_error_tlv; + const auto required = asn1::BerSpanWriter::tlv_size(2, outer_content); + if (!required || *required > MmsPduSpanCodec::maximum_pdu_bytes) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + if (destination.size() < *required) { + return {wire::EncodeStatus::buffer_too_small, 0U, *required}; + } + + asn1::BerSpanWriter writer{destination.first(*required)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, true, 2, outer_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, invoke_value) || + !write_positive_integer(writer, invoke_id) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 2, *error_class_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *class_choice_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 12, 1U) || + !writer.write_byte(0x00U) || writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + } // namespace MmsWireConfirmedService MmsConfirmedPduView::service() const noexcept { @@ -330,6 +398,7 @@ MmsWireConfirmedService MmsConfirmedPduView::service() const noexcept { case 5: return MmsWireConfirmedService::write; case 6: return MmsWireConfirmedService::get_variable_access_attributes; case 12: return MmsWireConfirmedService::get_named_variable_list_attributes; + case 77: return MmsWireConfirmedService::file_directory; default: return MmsWireConfirmedService::unknown; } } @@ -396,4 +465,35 @@ wire::EncodeResult MmsPduSpanCodec::encode_confirmed_response_into( 1, invoke_id, service_tag, service_constructed, service_value, destination); } +wire::EncodeResult MmsPduSpanCodec::encode_identify_response_into( + const std::uint32_t invoke_id, + const std::span destination) noexcept { + return encode_confirmed(1, invoke_id, 2, true, kIdentifyFields, destination); +} + +wire::EncodeResult MmsPduSpanCodec::encode_confirmed_error_into( + const std::uint32_t invoke_id, + const std::span destination) noexcept { + return encode_confirmed_error(invoke_id, destination); +} + +bool MmsPduSpanCodec::is_conclude_request( + const std::span bytes) noexcept { + asn1::BerTlvView pdu; + return asn1::BerSpanReader::try_read_exact(bytes, pdu) && + pdu.tag_class == asn1::BerClass::context_specific && + pdu.tag_number == 11 && !pdu.constructed && pdu.value.empty(); +} + +wire::EncodeResult MmsPduSpanCodec::encode_conclude_response_into( + const std::span destination) noexcept { + constexpr std::size_t required = 2U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x8CU; + destination[1] = 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + } // namespace ar::iec61850::mms diff --git a/src/mms/static_brcb_connection.cpp b/src/mms/static_brcb_connection.cpp index e5a82557a..912daf556 100644 --- a/src/mms/static_brcb_connection.cpp +++ b/src/mms/static_brcb_connection.cpp @@ -42,21 +42,40 @@ namespace { return result; } +[[nodiscard]] bool same_owner( + const std::span left, + const std::span right) noexcept { + return left.size() == right.size() && !left.empty() && + std::equal(left.begin(), left.end(), right.begin()); +} + } // namespace MmsStaticBrcbConnectionResult MmsStaticBrcbConnection::poll( const MmsStaticConnectionRuntime& connection, + MmsStaticBrcbControl& control, MmsStaticBrcbRuntime& reports, + const std::uint64_t now_ms, const std::span response, const std::span workspace) noexcept { if (connection.state() != MmsStaticConnectionState::established || connection.mms_presentation_context_id() == 0U) { return make_result(MmsStaticBrcbConnectionStatus::not_established); } - if (!reports.enabled()) { + + const auto control_state = control.state(now_ms); + if (!control_state.report_enabled) { return make_result(MmsStaticBrcbConnectionStatus::reporting_disabled); } + const auto access = connection.access_context(); + if (!control.valid() || !control_state.reserved || !control_state.owner_connected || + control_state.association_id == 0U || + access.association_id != control_state.association_id || + !same_owner(access.owner, control_state.owner)) { + return make_result(MmsStaticBrcbConnectionStatus::access_denied); + } + MmsStaticBrcbEntryView entry; if (!reports.front(entry)) { return make_result(MmsStaticBrcbConnectionStatus::no_report_available); @@ -115,12 +134,6 @@ MmsStaticBrcbConnectionResult MmsStaticBrcbConnection::poll( } auto result = make_result(MmsStaticBrcbConnectionStatus::response_ready, &entry); - const auto committed = reports.commit_delivery(entry.entry_id); - if (committed != MmsStaticBrcbStatus::ok) { - result.status = MmsStaticBrcbConnectionStatus::stale_entry; - return result; - } - std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin()); result.bytes_written = tpkt.bytes_written; result.required_response_bytes = final_required; @@ -128,4 +141,31 @@ MmsStaticBrcbConnectionResult MmsStaticBrcbConnection::poll( return result; } +bool MmsStaticBrcbConnection::commit_sent( + const MmsStaticConnectionRuntime& connection, + MmsStaticBrcbControl& control, + MmsStaticBrcbRuntime& reports, + const std::uint64_t now_ms, + const MmsStaticBrcbConnectionResult& staged) noexcept { + if (!staged.response_ready() || + connection.state() != MmsStaticConnectionState::established || + connection.mms_presentation_context_id() == 0U || !control.valid()) { + return false; + } + + const auto control_state = control.state(now_ms); + if (!control_state.report_enabled || !control_state.reserved || + !control_state.owner_connected || control_state.association_id == 0U) { + return false; + } + + const auto access = connection.access_context(); + if (access.association_id != control_state.association_id || + !same_owner(access.owner, control_state.owner)) { + return false; + } + + return reports.commit_delivery(staged.entry_id) == MmsStaticBrcbStatus::ok; +} + } // namespace ar::iec61850::mms diff --git a/src/mms/static_direct_control.cpp b/src/mms/static_direct_control.cpp new file mode 100644 index 000000000..c14d958a3 --- /dev/null +++ b/src/mms/static_direct_control.cpp @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_direct_control.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool context_tag( + const asn1::BerTlvView& tlv, + const std::int32_t tag, + const bool constructed) noexcept { + return tlv.tag_class == asn1::BerClass::context_specific && + tlv.tag_number == tag && tlv.constructed == constructed; +} + +[[nodiscard]] bool decode_boolean( + const asn1::BerTlvView& tlv, + bool& value) noexcept { + if (!context_tag(tlv, 3, false) || tlv.value.size() != 1U) { + return false; + } + value = tlv.value.front() != 0U; + return true; +} + +[[nodiscard]] bool read_next( + const std::span source, + std::size_t& offset, + asn1::BerTlvView& tlv) noexcept { + return asn1::BerSpanReader::try_read_tlv(source, offset, tlv); +} + +[[nodiscard]] bool decode_origin( + const asn1::BerTlvView& origin, + std::uint8_t& category) noexcept { + if (!context_tag(origin, 2, true)) { + return false; + } + std::size_t offset = 0U; + asn1::BerTlvView or_cat; + asn1::BerTlvView or_ident; + if (!read_next(origin.value, offset, or_cat) || + !context_tag(or_cat, 6, false)) { + return false; + } + const auto category_value = asn1::BerSpanReader::read_unsigned_integer(or_cat); + if (!category_value || *category_value > 0xFFU) { + return false; + } + if (!read_next(origin.value, offset, or_ident) || + !context_tag(or_ident, 9, false) || + or_ident.value.size() > 64U || + offset != origin.value.size()) { + return false; + } + category = static_cast(*category_value); + return true; +} + +[[nodiscard]] bool decode_check( + const asn1::BerTlvView& check, + bool& synchro, + bool& interlock) noexcept { + if (!context_tag(check, 4, false) || check.value.size() != 2U || + check.value[0] != 6U || (check.value[1] & 0x3FU) != 0U) { + return false; + } + synchro = (check.value[1] & 0x80U) != 0U; + interlock = (check.value[1] & 0x40U) != 0U; + return true; +} + +} // namespace + +bool try_decode_static_direct_boolean_operate( + const std::span encoded_data, + MmsStaticDirectBooleanOperate& decoded) noexcept { + decoded = {}; + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(encoded_data, outer) || + !context_tag(outer, 2, true)) { + return false; + } + + std::size_t offset = 0U; + asn1::BerTlvView ctl_val; + asn1::BerTlvView origin; + asn1::BerTlvView ctl_num; + asn1::BerTlvView timestamp; + asn1::BerTlvView test; + asn1::BerTlvView check; + + if (!read_next(outer.value, offset, ctl_val) || + !decode_boolean(ctl_val, decoded.control_value) || + !read_next(outer.value, offset, origin) || + !decode_origin(origin, decoded.origin_category) || + !read_next(outer.value, offset, ctl_num) || + !context_tag(ctl_num, 6, false)) { + return false; + } + const auto control_number = asn1::BerSpanReader::read_unsigned_integer(ctl_num); + if (!control_number || *control_number > 0xFFU) { + return false; + } + decoded.control_number = static_cast(*control_number); + + if (!read_next(outer.value, offset, timestamp) || + !context_tag(timestamp, 17, false) || timestamp.value.size() != 8U || + !read_next(outer.value, offset, test) || + !decode_boolean(test, decoded.test) || + !read_next(outer.value, offset, check) || + !decode_check(check, decoded.synchro_check, decoded.interlock_check) || + offset != outer.value.size()) { + return false; + } + return true; +} + +wire::EncodeResult mms_static_direct_boolean_read_state( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + const auto* state = static_cast(context); + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = state->value != 0U ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +wire::EncodeResult mms_static_direct_normal_read_ctl_model( + const void*, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x86U; // MMS unsigned + destination[1] = 0x01U; + destination[2] = 0x01U; // ctlModel = Direct-with-normal-security + return {wire::EncodeStatus::ok, required, required}; +} + +wire::EncodeResult mms_static_control_read_unavailable( + const void*, + const std::span) noexcept { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; +} + +MmsStaticWriteResult mms_static_direct_boolean_write_oper( + void* context, + const std::span encoded_data) noexcept { + auto* binding = static_cast(context); + if (binding == nullptr || binding->state == nullptr) { + return {false, 10U}; + } + + MmsStaticDirectBooleanOperate operate; + if (!try_decode_static_direct_boolean_operate(encoded_data, operate)) { + ++binding->state->rejected_operations; + return {false, binding->policy.malformed_failure_code}; + } + if (operate.origin_category > 8U || operate.control_number == 0U || + (operate.test && !binding->policy.allow_test) || + (operate.synchro_check && !binding->policy.allow_synchro_check) || + (operate.interlock_check && !binding->policy.allow_interlock_check)) { + ++binding->state->rejected_operations; + return {false, binding->policy.invalid_value_failure_code}; + } + + if (!operate.test && binding->apply != nullptr && + !binding->apply(binding->apply_context, operate.control_value)) { + ++binding->state->rejected_operations; + return {false, binding->policy.backend_failure_code}; + } + + if (!operate.test) { + binding->state->value = operate.control_value ? 1U : 0U; + } + binding->state->last_control_number = operate.control_number; + binding->state->last_test = operate.test; + ++binding->state->accepted_operations; + return {true, 0U}; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_information_report.cpp b/src/mms/static_information_report.cpp new file mode 100644 index 000000000..42971f45f --- /dev/null +++ b/src/mms/static_information_report.cpp @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_information_report.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] std::span as_bytes( + const std::string_view text) noexcept { + return { + reinterpret_cast(text.data()), + text.size()}; +} + +[[nodiscard]] bool valid_mms_data( + const std::span encoded) noexcept { + asn1::BerTlvView data; + if (!asn1::BerSpanReader::try_read_exact(encoded, data) || + data.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (data.tag_number == 1 || data.tag_number == 2) { + return data.constructed; + } + return data.tag_number >= 3 && data.tag_number <= 17 && !data.constructed; +} + +[[nodiscard]] MmsStaticInformationReportEncodeResult make_status( + const MmsStaticInformationReportStatus status, + const std::size_t required_bytes = 0U, + const std::size_t member_count = 0U) noexcept { + return {status, 0U, required_bytes, member_count}; +} + +} // namespace + +MmsStaticInformationReportEncodeResult +MmsStaticInformationReportEncoder::encode_data_set_snapshot_into( + const MmsStaticObjectTable& objects, + const MmsStaticDataSetTable& data_sets, + const MmsObjectNameView& data_set_name, + const MmsStaticInformationReportInput& report, + const std::span destination, + const std::span workspace) noexcept { + if (!objects.valid()) { + return make_status(MmsStaticInformationReportStatus::invalid_object_table); + } + if (!data_sets.valid()) { + return make_status(MmsStaticInformationReportStatus::invalid_data_set_table); + } + + const auto* data_set = data_sets.find(data_set_name); + if (data_set == nullptr) { + return make_status(MmsStaticInformationReportStatus::data_set_not_found); + } + if (data_set->members.empty() || + data_set->members.size() > MmsInformationReportSpanCodec::maximum_members) { + return make_status(MmsStaticInformationReportStatus::invalid_data_set_table); + } + + std::array references{}; + std::array results{}; + + std::size_t workspace_offset = 0U; + for (std::size_t index = 0U; index < data_set->members.size(); ++index) { + const auto& member = data_set->members[index]; + references[index] = MmsInformationReportReferenceInput{ + member.domain, member.item}; + + const MmsObjectNameView member_name{ + MmsObjectNameViewKind::domain_specific, + as_bytes(member.domain), + as_bytes(member.item)}; + const auto* object = objects.find(member_name); + if (object == nullptr) { + return make_status( + MmsStaticInformationReportStatus::object_not_found, + 0U, + data_set->members.size()); + } + + const auto remaining = workspace.subspan(workspace_offset); + const auto read = object->read(object->context, remaining); + if (read.status == wire::EncodeStatus::buffer_too_small) { + if (read.required_bytes > + std::numeric_limits::max() - workspace_offset) { + return make_status( + MmsStaticInformationReportStatus::backend_failure, + 0U, + data_set->members.size()); + } + return make_status( + MmsStaticInformationReportStatus::workspace_too_small, + workspace_offset + read.required_bytes, + data_set->members.size()); + } + if (!read.success() || read.bytes_written > remaining.size() || + !valid_mms_data(remaining.first(read.bytes_written))) { + return make_status( + MmsStaticInformationReportStatus::backend_failure, + 0U, + data_set->members.size()); + } + + results[index] = MmsReadAccessResultInput{ + true, + remaining.first(read.bytes_written), + 0U}; + workspace_offset += read.bytes_written; + } + + MmsInformationReportSnapshotInput wire_report; + wire_report.report_id = report.report_id; + wire_report.optional_fields = report.optional_fields; + wire_report.sequence_number = report.sequence_number; + wire_report.report_time = report.report_time; + wire_report.data_set_reference = MmsInformationReportReferenceInput{ + data_set->domain, data_set->item}; + wire_report.buffered = report.buffered; + wire_report.buffer_overflow = report.buffer_overflow; + wire_report.entry_id = report.entry_id; + wire_report.conf_revision = report.conf_revision; + wire_report.member_references = + std::span{references}.first( + data_set->members.size()); + wire_report.member_results = + std::span{results}.first( + data_set->members.size()); + wire_report.reason_for_inclusion = report.reason_for_inclusion; + + const auto encoded = MmsInformationReportSpanCodec::encode_snapshot_into( + wire_report, destination); + if (encoded.success()) { + return { + MmsStaticInformationReportStatus::response_ready, + encoded.bytes_written, + encoded.required_bytes, + data_set->members.size()}; + } + if (encoded.status == wire::EncodeStatus::buffer_too_small) { + return make_status( + MmsStaticInformationReportStatus::response_buffer_too_small, + encoded.required_bytes, + data_set->members.size()); + } + return make_status( + MmsStaticInformationReportStatus::invalid_report, + encoded.required_bytes, + data_set->members.size()); +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_report_connection.cpp b/src/mms/static_report_connection.cpp new file mode 100644 index 000000000..b65961490 --- /dev/null +++ b/src/mms/static_report_connection.cpp @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_report_connection.hpp" + +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +[[nodiscard]] bool add_size( + const std::size_t base, + const std::size_t extra, + std::size_t& total) noexcept { + if (extra > std::numeric_limits::max() - base) { + total = 0U; + return false; + } + total = base + extra; + return true; +} + +[[nodiscard]] MmsStaticReportConnectionResult make_result( + const MmsStaticReportConnectionStatus status, + const MmsStaticUrcbStatus urcb_status, + const MmsStaticUrcbEmissionPlan& plan = {}) noexcept { + MmsStaticReportConnectionResult result; + result.status = status; + result.urcb_status = urcb_status; + result.control_block_index = plan.index; + result.sequence_number = plan.sequence_number; + result.reason = plan.reason; + return result; +} + +[[nodiscard]] bool final_frame_size( + const std::uint32_t presentation_context_id, + const std::size_t mms_bytes, + std::size_t& required) noexcept { + const auto fully_encoded = osi::PresentationSpanCodec::fully_encoded_data_size( + presentation_context_id, mms_bytes); + return fully_encoded && add_size(*fully_encoded, 11U, required); +} + +[[nodiscard]] MmsStaticReportConnectionResult response_capacity( + const MmsStaticUrcbEmissionPlan& plan, + const MmsStaticUrcbStatus urcb_status, + const std::size_t required) noexcept { + auto result = make_result( + MmsStaticReportConnectionStatus::response_buffer_too_small, + urcb_status, + plan); + result.required_response_bytes = required; + return result; +} + +[[nodiscard]] MmsStaticReportConnectionResult workspace_capacity( + const MmsStaticUrcbEmissionPlan& plan, + const MmsStaticUrcbStatus urcb_status, + const std::size_t required) noexcept { + auto result = make_result( + MmsStaticReportConnectionStatus::workspace_too_small, + urcb_status, + plan); + result.required_workspace_bytes = required; + return result; +} + +} // namespace + +MmsStaticReportConnectionResult MmsStaticReportConnection::poll( + const MmsStaticConnectionRuntime& connection, + MmsStaticUrcbRuntime& reports, + const std::uint64_t now_ms, + const std::span report_time, + const std::span response, + const std::span workspace) noexcept { + if (connection.state() != MmsStaticConnectionState::established || + connection.mms_presentation_context_id() == 0U) { + return make_result( + MmsStaticReportConnectionStatus::not_established, + MmsStaticUrcbStatus::temporarily_unavailable); + } + + MmsStaticUrcbEmissionPlan plan; + if (!reports.next_due(now_ms, plan)) { + return make_result( + MmsStaticReportConnectionStatus::no_report_due, + reports.valid() + ? MmsStaticUrcbStatus::no_report_due + : MmsStaticUrcbStatus::invalid_runtime); + } + + // Raw MMS report is written into response. The report encoder may use the + // caller workspace transiently for DataSet member values. Only after the + // raw PDU succeeds is workspace reused for P-DATA and final TPKT staging. + const auto encoded = reports.encode(plan, report_time, response, workspace); + if (!encoded.success()) { + if (encoded.status == MmsStaticUrcbStatus::response_buffer_too_small) { + std::size_t final_required{}; + if (!final_frame_size( + connection.mms_presentation_context_id(), + encoded.required_bytes, + final_required)) { + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + return response_capacity(plan, encoded.status, final_required); + } + if (encoded.status == MmsStaticUrcbStatus::workspace_too_small) { + return workspace_capacity(plan, encoded.status, encoded.required_bytes); + } + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + encoded.status, + plan); + } + + const auto p_data = osi::PresentationSpanCodec::encode_p_data_into( + response.first(encoded.bytes_written), + workspace, + connection.mms_presentation_context_id(), + true); + if (!p_data.success()) { + if (p_data.status == wire::EncodeStatus::buffer_too_small) { + std::size_t final_required{}; + if (!add_size(p_data.required_bytes, 7U, final_required)) { + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + auto result = workspace_capacity( + plan, MmsStaticUrcbStatus::workspace_too_small, final_required); + result.required_response_bytes = final_required; + return result; + } + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + + std::size_t final_required{}; + if (!add_size(p_data.bytes_written, 7U, final_required)) { + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + if (response.size() < final_required) { + return response_capacity( + plan, MmsStaticUrcbStatus::response_buffer_too_small, final_required); + } + if (workspace.size() < final_required) { + auto result = workspace_capacity( + plan, MmsStaticUrcbStatus::workspace_too_small, final_required); + result.required_response_bytes = final_required; + return result; + } + + // P-DATA currently resides in workspace. COTP consumes it into response; + // after that source is no longer needed, so workspace can safely become + // the TPKT staging destination. + const auto cotp = osi::CotpSpanCodec::encode_data_into( + workspace.first(p_data.bytes_written), + response.first(final_required - osi::TpktSpanCodec::header_length)); + if (!cotp.success()) { + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + + const auto tpkt = osi::TpktSpanCodec::encode_into( + response.first(cotp.bytes_written), + workspace.first(final_required)); + if (!tpkt.success() || tpkt.bytes_written != final_required) { + return make_result( + MmsStaticReportConnectionStatus::report_encode_failed, + MmsStaticUrcbStatus::report_encode_failed, + plan); + } + + // Commit only after the complete TPKT image exists. If capacity failed at + // any earlier stage, the same plan/SqNum can be retried without a gap. + const auto committed = reports.commit(plan, now_ms); + if (committed != MmsStaticUrcbStatus::ok) { + return make_result( + MmsStaticReportConnectionStatus::stale_plan, + committed, + plan); + } + + std::copy_n(workspace.begin(), tpkt.bytes_written, response.begin()); + auto result = make_result( + MmsStaticReportConnectionStatus::response_ready, + MmsStaticUrcbStatus::ok, + plan); + result.bytes_written = tpkt.bytes_written; + result.required_response_bytes = final_required; + result.required_workspace_bytes = final_required; + return result; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_urcb_objects.cpp b/src/mms/static_urcb_objects.cpp new file mode 100644 index 000000000..0f5d4f733 --- /dev/null +++ b/src/mms/static_urcb_objects.cpp @@ -0,0 +1,630 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_urcb_objects.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kUnsigned32Type{0x86U, 0x01U, 0x20U}; +constexpr std::array kVisible255Type{0x8AU, 0x02U, 0x00U, 0xFFU}; +constexpr std::array kBitString10Type{0x84U, 0x01U, 0x0AU}; +constexpr std::array kBitString6Type{0x84U, 0x01U, 0x06U}; + +constexpr std::array + kAttributes{ + MmsStaticUrcbAttribute::report_id, + MmsStaticUrcbAttribute::report_enabled, + MmsStaticUrcbAttribute::reserved, + MmsStaticUrcbAttribute::data_set, + MmsStaticUrcbAttribute::conf_revision, + MmsStaticUrcbAttribute::optional_fields, + MmsStaticUrcbAttribute::buffer_time, + MmsStaticUrcbAttribute::trigger_options, + MmsStaticUrcbAttribute::integrity_period, + MmsStaticUrcbAttribute::general_interrogation, + MmsStaticUrcbAttribute::sequence_number}; + +constexpr std::array + kSuffixes{ + "RptID", + "RptEna", + "Resv", + "DatSet", + "ConfRev", + "OptFlds", + "BufTm", + "TrgOps", + "IntgPd", + "GI", + "SqNum"}; + +constexpr std::uint32_t kHardwareFault = 1U; +constexpr std::uint32_t kTemporarilyUnavailable = 2U; +constexpr std::uint32_t kObjectAccessDenied = 3U; +constexpr std::uint32_t kTypeInconsistent = 7U; +constexpr std::uint32_t kObjectNonExistent = 10U; +constexpr std::uint32_t kObjectValueInvalid = 11U; + +[[nodiscard]] std::span as_bytes( + const std::string_view text) noexcept { + return { + reinterpret_cast(text.data()), + text.size()}; +} + +[[nodiscard]] bool visible_ascii(const std::span bytes) noexcept { + if (bytes.empty() || bytes.size() > MmsServiceSpanCodec::maximum_identifier_bytes) { + return false; + } + for (const auto byte : bytes) { + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] std::span type_for( + const MmsStaticUrcbAttribute attribute) noexcept { + switch (attribute) { + case MmsStaticUrcbAttribute::report_id: + case MmsStaticUrcbAttribute::data_set: + return kVisible255Type; + case MmsStaticUrcbAttribute::report_enabled: + case MmsStaticUrcbAttribute::reserved: + case MmsStaticUrcbAttribute::general_interrogation: + return kBooleanType; + case MmsStaticUrcbAttribute::conf_revision: + case MmsStaticUrcbAttribute::buffer_time: + case MmsStaticUrcbAttribute::integrity_period: + case MmsStaticUrcbAttribute::sequence_number: + return kUnsigned32Type; + case MmsStaticUrcbAttribute::optional_fields: + return kBitString10Type; + case MmsStaticUrcbAttribute::trigger_options: + return kBitString6Type; + } + return {}; +} + +[[nodiscard]] bool writable_attribute( + const MmsStaticUrcbAttribute attribute) noexcept { + return attribute != MmsStaticUrcbAttribute::conf_revision && + attribute != MmsStaticUrcbAttribute::sequence_number; +} + +[[nodiscard]] wire::EncodeResult capacity_result( + const std::size_t required, + const std::span destination) noexcept { + return destination.size() < required + ? wire::EncodeResult{wire::EncodeStatus::buffer_too_small, 0U, required} + : wire::EncodeResult{wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] wire::EncodeResult encode_boolean( + const bool value, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + const auto capacity = capacity_result(required, destination); + if (!capacity.success()) { + return capacity; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = value ? 0xFFU : 0x00U; + return capacity; +} + +[[nodiscard]] std::size_t unsigned_content_size(std::uint32_t value) noexcept { + std::size_t size = 1U; + while (value > 0xFFU) { + ++size; + value >>= 8U; + } + return size; +} + +[[nodiscard]] wire::EncodeResult encode_unsigned( + const std::uint32_t value, + const std::span destination) noexcept { + const auto content_size = unsigned_content_size(value); + const auto total = asn1::BerSpanWriter::tlv_size(6, content_size); + if (!total) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto capacity = capacity_result(*total, destination); + if (!capacity.success()) { + return capacity; + } + + asn1::BerSpanWriter writer{destination.first(*total)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 6, content_size)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + for (std::size_t index = content_size; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + } + return writer.size() == *total + ? capacity + : wire::EncodeResult{wire::EncodeStatus::value_out_of_range, 0U, *total}; +} + +[[nodiscard]] wire::EncodeResult encode_visible( + const std::string_view text, + const std::span destination) noexcept { + const auto total = asn1::BerSpanWriter::tlv_size(10, text.size()); + if (!total) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto capacity = capacity_result(*total, destination); + if (!capacity.success()) { + return capacity; + } + asn1::BerSpanWriter writer{destination.first(*total)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 10, text.size()) || + !writer.write_bytes(as_bytes(text)) || writer.size() != *total) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + return capacity; +} + +[[nodiscard]] wire::EncodeResult encode_data_set_reference( + const std::string_view domain, + const std::string_view item, + const std::span destination) noexcept { + if (domain.size() > std::numeric_limits::max() - 1U - item.size()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto content_size = domain.size() + 1U + item.size(); + const auto total = asn1::BerSpanWriter::tlv_size(10, content_size); + if (!total) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto capacity = capacity_result(*total, destination); + if (!capacity.success()) { + return capacity; + } + + asn1::BerSpanWriter writer{destination.first(*total)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 10, content_size) || + !writer.write_bytes(as_bytes(domain)) || + !writer.write_byte(static_cast('/')) || + !writer.write_bytes(as_bytes(item)) || writer.size() != *total) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + return capacity; +} + +[[nodiscard]] wire::EncodeResult encode_bit_string( + const std::uint8_t unused_bits, + const std::span bytes, + const std::span destination) noexcept { + if (unused_bits > 7U || bytes.size() == std::numeric_limits::max()) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto content_size = bytes.size() + 1U; + const auto total = asn1::BerSpanWriter::tlv_size(4, content_size); + if (!total) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto capacity = capacity_result(*total, destination); + if (!capacity.success()) { + return capacity; + } + asn1::BerSpanWriter writer{destination.first(*total)}; + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 4, content_size) || + !writer.write_byte(unused_bits) || !writer.write_bytes(bytes) || + writer.size() != *total) { + return {wire::EncodeStatus::value_out_of_range, 0U, *total}; + } + return capacity; +} + +[[nodiscard]] wire::EncodeResult read_urcb_attribute( + const void* raw_context, + const std::span destination) noexcept { + const auto* context = static_cast(raw_context); + if (context == nullptr || context->runtime == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto* state = context->runtime->state(context->index); + if (state == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + switch (context->attribute) { + case MmsStaticUrcbAttribute::report_id: + return encode_visible(state->report_id(), destination); + case MmsStaticUrcbAttribute::report_enabled: + return encode_boolean(state->enabled, destination); + case MmsStaticUrcbAttribute::reserved: + return encode_boolean(state->reserved, destination); + case MmsStaticUrcbAttribute::data_set: + return encode_data_set_reference( + state->data_set_domain(), state->data_set_item(), destination); + case MmsStaticUrcbAttribute::conf_revision: + return encode_unsigned(state->conf_revision, destination); + case MmsStaticUrcbAttribute::optional_fields: + return encode_bit_string(6U, state->optional_fields, destination); + case MmsStaticUrcbAttribute::buffer_time: + return encode_unsigned(state->buffer_time_ms, destination); + case MmsStaticUrcbAttribute::trigger_options: { + const std::array trigger{state->trigger_options}; + return encode_bit_string(2U, trigger, destination); + } + case MmsStaticUrcbAttribute::integrity_period: + return encode_unsigned(state->integrity_period_ms, destination); + case MmsStaticUrcbAttribute::general_interrogation: + return encode_boolean(state->general_interrogation_pending, destination); + case MmsStaticUrcbAttribute::sequence_number: + return encode_unsigned(state->sequence_number, destination); + } + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; +} + +[[nodiscard]] bool decode_boolean( + const std::span encoded, + bool& value) noexcept { + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 3 || tlv.constructed || tlv.value.size() != 1U) { + return false; + } + value = tlv.value[0] != 0U; + return true; +} + +[[nodiscard]] bool decode_unsigned( + const std::span encoded, + std::uint32_t& value) noexcept { + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 6 || tlv.constructed) { + return false; + } + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded) { + return false; + } + value = *decoded; + return true; +} + +[[nodiscard]] bool decode_visible( + const std::span encoded, + std::string_view& value) noexcept { + value = {}; + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 10 || tlv.constructed || !visible_ascii(tlv.value)) { + return false; + } + value = { + reinterpret_cast(tlv.value.data()), + tlv.value.size()}; + return true; +} + +[[nodiscard]] bool decode_bit_string( + const std::span encoded, + const std::uint8_t expected_unused_bits, + const std::size_t expected_bytes, + std::span& bytes) noexcept { + bytes = {}; + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 4 || tlv.constructed || + tlv.value.size() != expected_bytes + 1U || + tlv.value[0] != expected_unused_bits) { + return false; + } + bytes = tlv.value.subspan(1U); + if (!bytes.empty() && expected_unused_bits != 0U) { + const auto mask = static_cast((1U << expected_unused_bits) - 1U); + if ((bytes.back() & mask) != 0U) { + bytes = {}; + return false; + } + } + return true; +} + +[[nodiscard]] bool split_reference( + const std::string_view reference, + std::string_view& domain, + std::string_view& item) noexcept { + domain = {}; + item = {}; + std::size_t slash = reference.size(); + for (std::size_t index = 0U; index < reference.size(); ++index) { + if (reference[index] != '/') { + continue; + } + if (slash != reference.size()) { + return false; + } + slash = index; + } + if (slash == 0U || slash >= reference.size() - 1U) { + return false; + } + domain = reference.substr(0U, slash); + item = reference.substr(slash + 1U); + return true; +} + +[[nodiscard]] MmsStaticWriteResult map_write_status( + const MmsStaticUrcbStatus status) noexcept { + switch (status) { + case MmsStaticUrcbStatus::ok: + return {true, 0U}; + case MmsStaticUrcbStatus::object_access_denied: + return {false, kObjectAccessDenied}; + case MmsStaticUrcbStatus::temporarily_unavailable: + return {false, kTemporarilyUnavailable}; + case MmsStaticUrcbStatus::data_set_not_found: + return {false, kObjectNonExistent}; + case MmsStaticUrcbStatus::invalid_value: + return {false, kObjectValueInvalid}; + case MmsStaticUrcbStatus::invalid_runtime: + case MmsStaticUrcbStatus::index_out_of_range: + case MmsStaticUrcbStatus::no_report_due: + case MmsStaticUrcbStatus::stale_plan: + case MmsStaticUrcbStatus::workspace_too_small: + case MmsStaticUrcbStatus::backend_failure: + case MmsStaticUrcbStatus::response_buffer_too_small: + case MmsStaticUrcbStatus::report_encode_failed: + return {false, kHardwareFault}; + } + return {false, kHardwareFault}; +} + +[[nodiscard]] MmsStaticWriteResult write_urcb_attribute( + void* raw_context, + const std::span encoded_data) noexcept { + auto* context = static_cast(raw_context); + if (context == nullptr || context->runtime == nullptr) { + return {false, kHardwareFault}; + } + + switch (context->attribute) { + case MmsStaticUrcbAttribute::report_id: { + std::string_view value; + if (!decode_visible(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + return map_write_status(context->runtime->set_report_id(context->index, value)); + } + case MmsStaticUrcbAttribute::report_enabled: { + bool value = false; + if (!decode_boolean(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + const auto now = context->now_ms == nullptr + ? std::uint64_t{0U} + : context->now_ms(context->now_context); + return map_write_status( + context->runtime->set_enabled(context->index, value, now)); + } + case MmsStaticUrcbAttribute::reserved: { + bool value = false; + if (!decode_boolean(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_reserved(context->index, value)); + } + case MmsStaticUrcbAttribute::data_set: { + std::string_view reference; + std::string_view domain; + std::string_view item; + if (!decode_visible(encoded_data, reference) || + !split_reference(reference, domain, item)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_data_set(context->index, domain, item)); + } + case MmsStaticUrcbAttribute::optional_fields: { + std::span bytes; + if (!decode_bit_string(encoded_data, 6U, 2U, bytes)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_optional_fields(context->index, bytes)); + } + case MmsStaticUrcbAttribute::buffer_time: { + std::uint32_t value = 0U; + if (!decode_unsigned(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_buffer_time_ms(context->index, value)); + } + case MmsStaticUrcbAttribute::trigger_options: { + std::span bytes; + if (!decode_bit_string(encoded_data, 2U, 1U, bytes)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_trigger_options(context->index, bytes[0])); + } + case MmsStaticUrcbAttribute::integrity_period: { + std::uint32_t value = 0U; + if (!decode_unsigned(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + return map_write_status( + context->runtime->set_integrity_period_ms(context->index, value)); + } + case MmsStaticUrcbAttribute::general_interrogation: { + bool value = false; + if (!decode_boolean(encoded_data, value)) { + return {false, kTypeInconsistent}; + } + return value + ? map_write_status( + context->runtime->request_general_interrogation(context->index)) + : MmsStaticWriteResult{true, 0U}; + } + case MmsStaticUrcbAttribute::conf_revision: + case MmsStaticUrcbAttribute::sequence_number: + return {false, kObjectAccessDenied}; + } + return {false, kHardwareFault}; +} + +} // namespace + +std::size_t MmsStaticUrcbObjectBank::required_context_capacity() const noexcept { + if (runtime_ == nullptr || !runtime_->valid() || + runtime_->size() > std::numeric_limits::max() / + attributes_per_control_block) { + return std::numeric_limits::max(); + } + return runtime_->size() * attributes_per_control_block; +} + +std::size_t MmsStaticUrcbObjectBank::required_object_capacity() const noexcept { + const auto contexts = required_context_capacity(); + if (contexts == std::numeric_limits::max() || + contexts > std::numeric_limits::max() - base_objects_.size()) { + return std::numeric_limits::max(); + } + return base_objects_.size() + contexts; +} + +std::size_t MmsStaticUrcbObjectBank::required_name_bytes() const noexcept { + if (runtime_ == nullptr || !runtime_->valid()) { + return std::numeric_limits::max(); + } + std::size_t total = 0U; + for (std::size_t index = 0U; index < runtime_->size(); ++index) { + const auto* definition = runtime_->definition(index); + if (definition == nullptr) { + return std::numeric_limits::max(); + } + for (const auto suffix : kSuffixes) { + if (definition->item.size() > + MmsServiceSpanCodec::maximum_identifier_bytes - 1U - suffix.size()) { + return std::numeric_limits::max(); + } + const auto name_size = definition->item.size() + 1U + suffix.size(); + if (name_size > std::numeric_limits::max() - total) { + return std::numeric_limits::max(); + } + total += name_size; + } + } + return total; +} + +bool MmsStaticUrcbObjectBank::initialize() noexcept { + initialized_ = false; + object_count_ = 0U; + name_bytes_used_ = 0U; + table_ = MmsStaticObjectTable{std::span{}}; + + if (runtime_ == nullptr || !runtime_->valid()) { + return false; + } + const auto required_objects = required_object_capacity(); + const auto required_contexts = required_context_capacity(); + const auto required_names = required_name_bytes(); + if (required_objects == std::numeric_limits::max() || + required_contexts == std::numeric_limits::max() || + required_names == std::numeric_limits::max() || + required_objects > object_storage_.size() || + required_objects > MmsStaticObjectTable::maximum_objects || + required_contexts > context_storage_.size() || + required_names > name_storage_.size()) { + return false; + } + + std::copy(base_objects_.begin(), base_objects_.end(), object_storage_.begin()); + std::size_t object_offset = base_objects_.size(); + std::size_t context_offset = 0U; + std::size_t name_offset = 0U; + + for (std::size_t urcb_index = 0U; urcb_index < runtime_->size(); ++urcb_index) { + const auto* definition = runtime_->definition(urcb_index); + if (definition == nullptr) { + return false; + } + for (std::size_t attribute_index = 0U; + attribute_index < attributes_per_control_block; + ++attribute_index) { + const auto suffix = kSuffixes[attribute_index]; + const auto name_size = definition->item.size() + 1U + suffix.size(); + auto* name = name_storage_.data() + name_offset; + std::copy(definition->item.begin(), definition->item.end(), name); + name[definition->item.size()] = '$'; + std::copy( + suffix.begin(), + suffix.end(), + name + definition->item.size() + 1U); + + auto& context = context_storage_[context_offset]; + context = MmsStaticUrcbObjectContext{ + runtime_, + urcb_index, + kAttributes[attribute_index], + now_ms_, + now_context_}; + + const auto attribute = kAttributes[attribute_index]; + object_storage_[object_offset] = MmsStaticObjectEntry{ + definition->domain, + std::string_view{name, name_size}, + type_for(attribute), + read_urcb_attribute, + &context, + false, + writable_attribute(attribute) ? write_urcb_attribute : nullptr, + writable_attribute(attribute) ? &context : nullptr}; + + name_offset += name_size; + ++context_offset; + ++object_offset; + } + } + + table_ = MmsStaticObjectTable{ + std::span{object_storage_.data(), object_offset}}; + if (!table_.valid()) { + table_ = MmsStaticObjectTable{std::span{}}; + return false; + } + + object_count_ = object_offset; + name_bytes_used_ = name_offset; + initialized_ = true; + return true; +} + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_urcb_runtime.cpp b/src/mms/static_urcb_runtime.cpp new file mode 100644 index 000000000..968cd8a2c --- /dev/null +++ b/src/mms/static_urcb_runtime.cpp @@ -0,0 +1,612 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_urcb_runtime.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::uint8_t kAllowedOptionalFirst = 0x7CU; +constexpr std::uint8_t kAllowedOptionalSecond = 0x80U; +constexpr std::uint8_t kAllowedTriggerOptions = 0x7CU; +constexpr std::uint8_t kTriggerIntegrity = 0x08U; + +[[nodiscard]] bool visible_ascii( + const std::string_view text, + const std::size_t maximum_bytes) noexcept { + if (text.empty() || text.size() > maximum_bytes) { + return false; + } + for (const auto character : text) { + const auto byte = static_cast(character); + if (byte == 0U || byte > 0x7FU) { + return false; + } + } + return true; +} + +[[nodiscard]] bool valid_report_id(const std::string_view report_id) noexcept { + return visible_ascii( + report_id, + MmsStaticUrcbState::maximum_report_id_bytes); +} + +[[nodiscard]] bool valid_reference( + const std::string_view domain, + const std::string_view item) noexcept { + return visible_ascii( + domain, + MmsStaticUrcbState::maximum_reference_component_bytes) && + visible_ascii( + item, + MmsStaticUrcbState::maximum_reference_component_bytes) && + domain.size() + 1U + item.size() <= + MmsInformationReportSpanCodec::maximum_reference_bytes; +} + +[[nodiscard]] bool valid_optional_fields( + const std::span optional_fields) noexcept { + return optional_fields.size() == + MmsInformationReportSpanCodec::optional_field_bytes && + (optional_fields[0] & static_cast(~kAllowedOptionalFirst)) == 0U && + (optional_fields[1] & static_cast(~kAllowedOptionalSecond)) == 0U; +} + +[[nodiscard]] bool valid_trigger_options(const std::uint8_t options) noexcept { + return (options & static_cast(~kAllowedTriggerOptions)) == 0U; +} + +template +[[nodiscard]] bool copy_text( + const std::string_view source, + std::array& destination, + std::size_t& length) noexcept { + if (source.empty() || source.size() > destination.size()) { + length = 0U; + return false; + } + std::fill(destination.begin(), destination.end(), '\0'); + std::copy(source.begin(), source.end(), destination.begin()); + length = source.size(); + return true; +} + +[[nodiscard]] std::span as_bytes( + const std::string_view text) noexcept { + return { + reinterpret_cast(text.data()), + text.size()}; +} + +[[nodiscard]] MmsObjectNameView object_name( + const std::string_view domain, + const std::string_view item) noexcept { + return { + MmsObjectNameViewKind::domain_specific, + as_bytes(domain), + as_bytes(item)}; +} + +[[nodiscard]] bool same_name( + const MmsStaticUrcbDefinition& left, + const MmsStaticUrcbDefinition& right) noexcept { + return left.domain == right.domain && left.item == right.item; +} + +void bump_revision(MmsStaticUrcbState& state) noexcept { + state.revision = state.revision == std::numeric_limits::max() + ? 1U + : state.revision + 1U; +} + +[[nodiscard]] std::uint64_t saturating_add( + const std::uint64_t base, + const std::uint64_t delta) noexcept { + return delta > std::numeric_limits::max() - base + ? std::numeric_limits::max() + : base + delta; +} + +[[nodiscard]] std::uint32_t effective_integrity_period( + const MmsStaticUrcbState& state) noexcept { + if (state.integrity_period_ms == 0U) { + return 0U; + } + return state.integrity_period_ms < MmsStaticUrcbRuntime::minimum_integrity_period_ms + ? MmsStaticUrcbRuntime::minimum_integrity_period_ms + : state.integrity_period_ms; +} + +void refresh_integrity_arm( + MmsStaticUrcbState& state, + const std::uint64_t now_ms) noexcept { + const auto period = effective_integrity_period(state); + const bool selected = (state.trigger_options & kTriggerIntegrity) != 0U; + state.integrity_armed = state.enabled && selected && period != 0U; + state.next_integrity_due_ms = state.integrity_armed + ? saturating_add(now_ms, period) + : 0U; +} + +[[nodiscard]] std::uint8_t next_sequence_number( + const std::uint8_t current) noexcept { + return static_cast( + (static_cast(current) + 1U) & 0xFFU); +} + +[[nodiscard]] bool plan_matches_state( + const MmsStaticUrcbEmissionPlan& plan, + const MmsStaticUrcbState& state) noexcept { + if (!state.enabled || plan.revision != state.revision || + plan.reason == MmsStaticUrcbReportReason::none || + plan.sequence_number != next_sequence_number(state.sequence_number)) { + return false; + } + if (plan.reason == MmsStaticUrcbReportReason::general_interrogation) { + return state.general_interrogation_pending; + } + return plan.reason == MmsStaticUrcbReportReason::integrity && state.integrity_armed; +} + +[[nodiscard]] MmsStaticUrcbEncodeResult map_report_result( + const MmsStaticInformationReportEncodeResult& result) noexcept { + MmsStaticUrcbStatus status = MmsStaticUrcbStatus::report_encode_failed; + switch (result.status) { + case MmsStaticInformationReportStatus::response_ready: + status = MmsStaticUrcbStatus::ok; + break; + case MmsStaticInformationReportStatus::data_set_not_found: + status = MmsStaticUrcbStatus::data_set_not_found; + break; + case MmsStaticInformationReportStatus::workspace_too_small: + status = MmsStaticUrcbStatus::workspace_too_small; + break; + case MmsStaticInformationReportStatus::backend_failure: + case MmsStaticInformationReportStatus::object_not_found: + status = MmsStaticUrcbStatus::backend_failure; + break; + case MmsStaticInformationReportStatus::response_buffer_too_small: + status = MmsStaticUrcbStatus::response_buffer_too_small; + break; + case MmsStaticInformationReportStatus::invalid_object_table: + case MmsStaticInformationReportStatus::invalid_data_set_table: + case MmsStaticInformationReportStatus::invalid_report: + status = MmsStaticUrcbStatus::report_encode_failed; + break; + } + return {status, result.bytes_written, result.required_bytes, result.member_count}; +} + +} // namespace + +bool MmsStaticUrcbRuntime::initialize() noexcept { + initialized_ = false; + if (objects_ == nullptr || data_sets_ == nullptr || + !objects_->valid() || !data_sets_->valid() || + !data_sets_->valid_against(*objects_) || + definitions_.size() > maximum_control_blocks || + states_.size() < definitions_.size()) { + return false; + } + + for (std::size_t index = 0U; index < definitions_.size(); ++index) { + const auto& definition = definitions_[index]; + if (!valid_reference(definition.domain, definition.item) || + !valid_report_id(definition.report_id) || + !valid_reference(definition.data_set_domain, definition.data_set_item) || + !valid_optional_fields(definition.optional_fields) || + !valid_trigger_options(definition.trigger_options) || + data_sets_->find(object_name( + definition.data_set_domain, + definition.data_set_item)) == nullptr) { + return false; + } + for (std::size_t earlier = 0U; earlier < index; ++earlier) { + if (same_name(definitions_[earlier], definition)) { + return false; + } + } + } + + for (std::size_t index = 0U; index < definitions_.size(); ++index) { + const auto& definition = definitions_[index]; + auto& state_ref = states_[index]; + state_ref = {}; + if (!copy_text( + definition.report_id, + state_ref.report_id_storage, + state_ref.report_id_size) || + !copy_text( + definition.data_set_domain, + state_ref.data_set_domain_storage, + state_ref.data_set_domain_size) || + !copy_text( + definition.data_set_item, + state_ref.data_set_item_storage, + state_ref.data_set_item_size)) { + return false; + } + state_ref.optional_fields = definition.optional_fields; + state_ref.conf_revision = definition.conf_revision; + state_ref.buffer_time_ms = definition.buffer_time_ms; + state_ref.trigger_options = definition.trigger_options; + state_ref.integrity_period_ms = definition.integrity_period_ms; + state_ref.revision = 1U; + } + + initialized_ = true; + return true; +} + +const MmsStaticUrcbDefinition* MmsStaticUrcbRuntime::definition( + const std::size_t index) const noexcept { + return initialized_ && index < definitions_.size() ? &definitions_[index] : nullptr; +} + +const MmsStaticUrcbState* MmsStaticUrcbRuntime::state( + const std::size_t index) const noexcept { + return initialized_ && index < definitions_.size() ? &states_[index] : nullptr; +} + +MmsStaticUrcbState* MmsStaticUrcbRuntime::state( + const std::size_t index) noexcept { + return initialized_ && index < definitions_.size() ? &states_[index] : nullptr; +} + +bool MmsStaticUrcbRuntime::find_index( + const MmsObjectNameView& name, + std::size_t& index) const noexcept { + index = 0U; + if (!initialized_ || name.kind != MmsObjectNameViewKind::domain_specific) { + return false; + } + for (std::size_t current = 0U; current < definitions_.size(); ++current) { + const auto& definition_ref = definitions_[current]; + if (name.domain.size() != definition_ref.domain.size() || + name.item.size() != definition_ref.item.size()) { + continue; + } + if (std::equal( + name.domain.begin(), + name.domain.end(), + reinterpret_cast(definition_ref.domain.data())) && + std::equal( + name.item.begin(), + name.item.end(), + reinterpret_cast(definition_ref.item.data()))) { + index = current; + return true; + } + } + return false; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_enabled( + const std::size_t index, + const bool enabled, + const std::uint64_t now_ms) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled == enabled) { + return MmsStaticUrcbStatus::ok; + } + + state_ref->enabled = enabled; + state_ref->general_interrogation_pending = false; + if (enabled) { + state_ref->sequence_number = 0U; + refresh_integrity_arm(*state_ref, now_ms); + } else { + state_ref->integrity_armed = false; + state_ref->next_integrity_due_ms = 0U; + } + bump_revision(*state_ref); + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_reserved( + const std::size_t index, + const bool reserved) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->reserved != reserved) { + state_ref->reserved = reserved; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_report_id( + const std::size_t index, + const std::string_view report_id) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (!valid_report_id(report_id)) { + return MmsStaticUrcbStatus::invalid_value; + } + if (state_ref->report_id() == report_id) { + return MmsStaticUrcbStatus::ok; + } + if (!copy_text( + report_id, + state_ref->report_id_storage, + state_ref->report_id_size)) { + return MmsStaticUrcbStatus::invalid_value; + } + bump_revision(*state_ref); + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_data_set( + const std::size_t index, + const std::string_view domain, + const std::string_view item) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (!valid_reference(domain, item)) { + return MmsStaticUrcbStatus::invalid_value; + } + if (data_sets_->find(object_name(domain, item)) == nullptr) { + return MmsStaticUrcbStatus::data_set_not_found; + } + if (state_ref->data_set_domain() == domain && state_ref->data_set_item() == item) { + return MmsStaticUrcbStatus::ok; + } + if (!copy_text( + domain, + state_ref->data_set_domain_storage, + state_ref->data_set_domain_size) || + !copy_text( + item, + state_ref->data_set_item_storage, + state_ref->data_set_item_size)) { + return MmsStaticUrcbStatus::invalid_value; + } + bump_revision(*state_ref); + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_optional_fields( + const std::size_t index, + const std::span optional_fields) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (!valid_optional_fields(optional_fields)) { + return MmsStaticUrcbStatus::invalid_value; + } + const std::array + next{optional_fields[0], optional_fields[1]}; + if (state_ref->optional_fields != next) { + state_ref->optional_fields = next; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_buffer_time_ms( + const std::size_t index, + const std::uint32_t buffer_time_ms) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (state_ref->buffer_time_ms != buffer_time_ms) { + state_ref->buffer_time_ms = buffer_time_ms; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_trigger_options( + const std::size_t index, + const std::uint8_t trigger_options) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (!valid_trigger_options(trigger_options)) { + return MmsStaticUrcbStatus::invalid_value; + } + if (state_ref->trigger_options != trigger_options) { + state_ref->trigger_options = trigger_options; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::set_integrity_period_ms( + const std::size_t index, + const std::uint32_t integrity_period_ms) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (state_ref->enabled) { + return MmsStaticUrcbStatus::object_access_denied; + } + if (state_ref->integrity_period_ms != integrity_period_ms) { + state_ref->integrity_period_ms = integrity_period_ms; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::request_general_interrogation( + const std::size_t index) noexcept { + auto* state_ref = state(index); + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (state_ref == nullptr) { + return MmsStaticUrcbStatus::index_out_of_range; + } + if (!state_ref->enabled) { + return MmsStaticUrcbStatus::temporarily_unavailable; + } + if (!state_ref->general_interrogation_pending) { + state_ref->general_interrogation_pending = true; + bump_revision(*state_ref); + } + return MmsStaticUrcbStatus::ok; +} + +bool MmsStaticUrcbRuntime::next_due( + const std::uint64_t now_ms, + MmsStaticUrcbEmissionPlan& plan) const noexcept { + plan = {}; + if (!initialized_) { + return false; + } + + for (std::size_t index = 0U; index < definitions_.size(); ++index) { + const auto& state_ref = states_[index]; + if (state_ref.enabled && state_ref.general_interrogation_pending) { + plan.index = index; + plan.revision = state_ref.revision; + plan.sequence_number = next_sequence_number(state_ref.sequence_number); + plan.reason = MmsStaticUrcbReportReason::general_interrogation; + return true; + } + } + for (std::size_t index = 0U; index < definitions_.size(); ++index) { + const auto& state_ref = states_[index]; + if (state_ref.enabled && state_ref.integrity_armed && + now_ms >= state_ref.next_integrity_due_ms) { + plan.index = index; + plan.revision = state_ref.revision; + plan.sequence_number = next_sequence_number(state_ref.sequence_number); + plan.reason = MmsStaticUrcbReportReason::integrity; + return true; + } + } + return false; +} + +MmsStaticUrcbEncodeResult MmsStaticUrcbRuntime::encode( + const MmsStaticUrcbEmissionPlan& plan, + const std::span report_time, + const std::span destination, + const std::span workspace) const noexcept { + if (!initialized_ || objects_ == nullptr || data_sets_ == nullptr) { + return {MmsStaticUrcbStatus::invalid_runtime, 0U, 0U, 0U}; + } + if (plan.index >= definitions_.size()) { + return {MmsStaticUrcbStatus::index_out_of_range, 0U, 0U, 0U}; + } + const auto& state_ref = states_[plan.index]; + if (!plan_matches_state(plan, state_ref)) { + return {MmsStaticUrcbStatus::stale_plan, 0U, 0U, 0U}; + } + + const auto data_set_name = object_name( + state_ref.data_set_domain(), + state_ref.data_set_item()); + MmsStaticInformationReportInput report; + report.report_id = state_ref.report_id(); + report.optional_fields = state_ref.optional_fields; + report.sequence_number = plan.sequence_number; + report.report_time = report_time; + report.buffered = false; + report.buffer_overflow = false; + report.entry_id = {}; + report.conf_revision = state_ref.conf_revision; + report.reason_for_inclusion = static_cast(plan.reason); + + return map_report_result( + MmsStaticInformationReportEncoder::encode_data_set_snapshot_into( + *objects_, + *data_sets_, + data_set_name, + report, + destination, + workspace)); +} + +MmsStaticUrcbStatus MmsStaticUrcbRuntime::commit( + const MmsStaticUrcbEmissionPlan& plan, + const std::uint64_t now_ms) noexcept { + if (!initialized_) { + return MmsStaticUrcbStatus::invalid_runtime; + } + if (plan.index >= definitions_.size()) { + return MmsStaticUrcbStatus::index_out_of_range; + } + auto& state_ref = states_[plan.index]; + if (!plan_matches_state(plan, state_ref)) { + return MmsStaticUrcbStatus::stale_plan; + } + + state_ref.sequence_number = plan.sequence_number; + if (plan.reason == MmsStaticUrcbReportReason::general_interrogation) { + state_ref.general_interrogation_pending = false; + } else if (plan.reason == MmsStaticUrcbReportReason::integrity) { + const auto period = effective_integrity_period(state_ref); + state_ref.next_integrity_due_ms = period == 0U + ? 0U + : saturating_add(now_ms, period); + state_ref.integrity_armed = period != 0U && + (state_ref.trigger_options & kTriggerIntegrity) != 0U && state_ref.enabled; + } else { + return MmsStaticUrcbStatus::stale_plan; + } + bump_revision(state_ref); + return MmsStaticUrcbStatus::ok; +} + +} // namespace ar::iec61850::mms diff --git a/tests/test_mms_server_urcb_report.cpp b/tests/test_mms_server_urcb_report.cpp new file mode 100644 index 000000000..e8941198a --- /dev/null +++ b/tests/test_mms_server_urcb_report.cpp @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/mms/information_report_span.hpp" +#include "ariec61850/mms/static_report_connection.hpp" +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/presentation_span.hpp" +#include "ariec61850/osi/session_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +constexpr std::array kBooleanType{0x83U, 0x00U}; +constexpr std::array kReportTime{ + 0x00U, 0x00U, 0x12U, 0x34U, 0x00U, 0x01U}; + +constexpr std::array kAssociationRequest{ + 0x0DU, 0xB6U, 0x05U, 0x06U, 0x13U, 0x01U, 0x00U, 0x16U, 0x01U, 0x02U, 0x14U, 0x02U, + 0x00U, 0x02U, 0x33U, 0x02U, 0x00U, 0x01U, 0x34U, 0x02U, 0x00U, 0x01U, 0xC1U, 0xA0U, + 0x31U, 0x81U, 0x9DU, 0xA0U, 0x03U, 0x80U, 0x01U, 0x01U, 0xA2U, 0x81U, 0x95U, 0x81U, + 0x04U, 0x00U, 0x00U, 0x00U, 0x01U, 0x82U, 0x04U, 0x00U, 0x00U, 0x00U, 0x01U, 0xA4U, + 0x23U, 0x30U, 0x0FU, 0x02U, 0x01U, 0x01U, 0x06U, 0x04U, 0x52U, 0x01U, 0x00U, 0x01U, + 0x30U, 0x04U, 0x06U, 0x02U, 0x51U, 0x01U, 0x30U, 0x10U, 0x02U, 0x01U, 0x03U, 0x06U, + 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x01U, 0x30U, 0x04U, 0x06U, 0x02U, 0x51U, 0x01U, + 0x61U, 0x62U, 0x30U, 0x60U, 0x02U, 0x01U, 0x01U, 0xA0U, 0x5BU, 0x60U, 0x59U, 0xA1U, + 0x07U, 0x06U, 0x05U, 0x28U, 0xCAU, 0x22U, 0x02U, 0x03U, 0xA2U, 0x07U, 0x06U, 0x05U, + 0x29U, 0x01U, 0x87U, 0x67U, 0x01U, 0xA3U, 0x03U, 0x02U, 0x01U, 0x0CU, 0xA6U, 0x06U, + 0x06U, 0x04U, 0x29U, 0x01U, 0x87U, 0x67U, 0xA7U, 0x03U, 0x02U, 0x01U, 0x0CU, 0xBEU, + 0x33U, 0x28U, 0x31U, 0x06U, 0x02U, 0x51U, 0x01U, 0x02U, 0x01U, 0x03U, 0xA0U, 0x28U, + 0xA8U, 0x26U, 0x80U, 0x03U, 0x00U, 0xFDU, 0xE8U, 0x81U, 0x01U, 0x0AU, 0x82U, 0x01U, + 0x0AU, 0x83U, 0x01U, 0x05U, 0xA4U, 0x16U, 0x80U, 0x01U, 0x01U, 0x81U, 0x03U, 0x05U, + 0xF1U, 0x00U, 0x82U, 0x0CU, 0x03U, 0xEEU, 0x1CU, 0x00U, 0x00U, 0x04U, 0x08U, 0x00U, + 0x00U, 0x79U, 0xEFU, 0x18U}; + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +template +[[nodiscard]] wire::EncodeResult build_data_tpkt( + const std::span session_payload, + std::array& output, + std::array& scratch) noexcept { + const auto cotp = osi::CotpSpanCodec::encode_data_into(session_payload, scratch); + if (!cotp.success()) { + return cotp; + } + return osi::TpktSpanCodec::encode_into( + std::span{scratch}.first(cotp.bytes_written), output); +} + +[[nodiscard]] bool establish( + mms::MmsStaticConnectionRuntime& connection, + const std::span request, + const std::span response, + const std::span workspace, + const std::span scratch) noexcept { + constexpr std::array tpdu_size{0x0AU}; + constexpr std::array source_tsap{0x00U, 0x01U}; + constexpr std::array destination_tsap{0x00U, 0x01U}; + const std::array parameters{ + osi::CotpParameterView{osi::CotpSpanCodec::tpdu_size_parameter, tpdu_size}, + osi::CotpParameterView{osi::CotpSpanCodec::source_tsap_parameter, source_tsap}, + osi::CotpParameterView{osi::CotpSpanCodec::destination_tsap_parameter, destination_tsap}}; + + const auto cr = osi::CotpSpanCodec::encode_connection_request_into( + 0x0001U, parameters, scratch); + if (!cr.success()) { + return false; + } + const auto cr_tpkt = osi::TpktSpanCodec::encode_into( + std::span{scratch}.first(cr.bytes_written), request); + if (!cr_tpkt.success()) { + return false; + } + auto result = connection.process_tcp_window( + std::span{request.data(), cr_tpkt.bytes_written}, + response, + workspace); + if (!result.response_ready() || + connection.state() != mms::MmsStaticConnectionState::awaiting_association) { + return false; + } + + const auto cotp = osi::CotpSpanCodec::encode_data_into(kAssociationRequest, scratch); + if (!cotp.success()) { + return false; + } + const auto association = osi::TpktSpanCodec::encode_into( + std::span{scratch}.first(cotp.bytes_written), request); + if (!association.success()) { + return false; + } + result = connection.process_tcp_window( + std::span{request.data(), association.bytes_written}, + response, + workspace); + return result.response_ready() && + connection.state() == mms::MmsStaticConnectionState::established && + connection.mms_presentation_context_id() == 3U; +} + +[[nodiscard]] bool decode_report_frame( + const std::span frame, + mms::MmsInformationReportView& report) noexcept { + osi::TpktFrameView tpkt; + osi::CotpTpduView cotp; + osi::SessionDataTransferView session; + osi::PresentationPdvView pdv; + return osi::TpktSpanCodec::try_decode_view(frame, tpkt) && + osi::CotpSpanCodec::try_decode_view(tpkt.payload, cotp) && + cotp.kind == osi::CotpWireKind::data && cotp.end_of_transmission && + osi::SessionSpanCodec::try_decode_data_transfer_view(cotp.user_data, session) && + osi::PresentationSpanCodec::try_decode_fully_encoded_data_view( + session.presentation_payload, pdv) && + pdv.context_id == 3U && + mms::MmsInformationReportSpanCodec::try_decode_information_report( + pdv.single_asn1_type, report); +} + +[[nodiscard]] bool decode_unsigned_item( + const mms::MmsReadAccessResultView& item, + std::uint32_t& value) noexcept { + value = 0U; + if (!item.success) { + return false; + } + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(item.encoded_data, tlv) || + tlv.tag_class != asn1::BerClass::context_specific || + tlv.tag_number != 6 || tlv.constructed) { + return false; + } + const auto decoded = asn1::BerSpanReader::read_uint32(tlv); + if (!decoded) { + return false; + } + value = *decoded; + return true; +} + +} // namespace + +int main() { + bool relay_state = true; + bool alarm_state = false; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "R1", kBooleanType, read_boolean, &relay_state, false}, + mms::MmsStaticObjectEntry{ + "LD0", "A1", kBooleanType, read_boolean, &alarm_state, false}}; + const mms::MmsStaticObjectTable object_table{objects}; + const std::array members{ + mms::MmsStaticDataSetMember{"LD0", "R1"}, + mms::MmsStaticDataSetMember{"LD0", "A1"}}; + const std::array data_set_entries{ + mms::MmsStaticDataSetEntry{"LD0", "LLN0$Events", members, false}}; + const mms::MmsStaticDataSetTable data_sets{data_set_entries}; + const mms::MmsStaticApplicationDispatcher dispatcher{object_table, data_sets}; + mms::MmsStaticConnectionRuntime connection{dispatcher}; + + const std::array definitions{ + mms::MmsStaticUrcbDefinition{ + "LD0", + "LLN0$RP$Events", + "LD0/LLN0$RP$Events", + "LD0", + "LLN0$Events", + 7U, + {0x7CU, 0x80U}, + 0U, + 0x08U, + 1'000U}}; + std::array states{}; + mms::MmsStaticUrcbRuntime reports{ + definitions, states, object_table, data_sets}; + if (!reports.initialize()) { + return 1; + } + + std::array request{}; + std::array response{}; + std::array workspace{}; + std::array scratch{}; + + auto poll = mms::MmsStaticReportConnection::poll( + connection, reports, 100U, kReportTime, response, workspace); + if (poll.status != mms::MmsStaticReportConnectionStatus::not_established) { + return 2; + } + if (!establish(connection, request, response, workspace, scratch)) { + return 3; + } + if (mms::MmsStaticReportConnection::poll( + connection, reports, 100U, kReportTime, response, workspace).status != + mms::MmsStaticReportConnectionStatus::no_report_due) { + return 4; + } + + if (reports.set_enabled(0U, true, 100U) != mms::MmsStaticUrcbStatus::ok || + reports.request_general_interrogation(0U) != mms::MmsStaticUrcbStatus::ok) { + return 5; + } + const auto* state = reports.state(0U); + if (state == nullptr || state->sequence_number != 0U || + !state->general_interrogation_pending) { + return 6; + } + + std::array tiny_response{}; + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 100U, kReportTime, tiny_response, workspace); + state = reports.state(0U); + if (poll.status != mms::MmsStaticReportConnectionStatus::response_buffer_too_small || + poll.required_response_bytes <= tiny_response.size() || + poll.reason != mms::MmsStaticUrcbReportReason::general_interrogation || + poll.sequence_number != 1U || state == nullptr || state->sequence_number != 0U || + !state->general_interrogation_pending) { + return 7; + } + + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 100U, kReportTime, response, workspace); + state = reports.state(0U); + if (!poll.response_ready() || poll.bytes_written == 0U || + poll.control_block_index != 0U || poll.sequence_number != 1U || + poll.reason != mms::MmsStaticUrcbReportReason::general_interrogation || + state == nullptr || state->sequence_number != 1U || + state->general_interrogation_pending) { + return 8; + } + + mms::MmsInformationReportView report; + if (!decode_report_frame( + std::span{response}.first(poll.bytes_written), report) || + report.item_count != 13U) { + return 9; + } + mms::MmsReadAccessResultView item; + std::uint32_t sequence = 0U; + if (!report.try_item(2U, item) || !decode_unsigned_item(item, sequence) || sequence != 1U) { + return 10; + } + + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 100U, kReportTime, response, workspace); + if (poll.status != mms::MmsStaticReportConnectionStatus::no_report_due) { + return 11; + } + + // Integrity is due at t=1100. A workspace failure must preserve SqNum and due state. + std::array tiny_workspace{}; + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 1'100U, kReportTime, response, tiny_workspace); + state = reports.state(0U); + if (poll.status != mms::MmsStaticReportConnectionStatus::workspace_too_small || + poll.required_workspace_bytes <= tiny_workspace.size() || + poll.reason != mms::MmsStaticUrcbReportReason::integrity || + poll.sequence_number != 2U || state == nullptr || state->sequence_number != 1U || + state->next_integrity_due_ms != 1'100U) { + return 12; + } + + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 1'100U, kReportTime, response, workspace); + state = reports.state(0U); + if (!poll.response_ready() || poll.sequence_number != 2U || + poll.reason != mms::MmsStaticUrcbReportReason::integrity || + state == nullptr || state->sequence_number != 2U || + state->next_integrity_due_ms != 2'100U || + !decode_report_frame( + std::span{response}.first(poll.bytes_written), report) || + !report.try_item(2U, item) || !decode_unsigned_item(item, sequence) || sequence != 2U) { + return 13; + } + + connection.reset(); + poll = mms::MmsStaticReportConnection::poll( + connection, reports, 2'100U, kReportTime, response, workspace); + state = reports.state(0U); + if (poll.status != mms::MmsStaticReportConnectionStatus::not_established || + state == nullptr || state->sequence_number != 2U || + state->next_integrity_due_ms != 2'100U) { + return 14; + } + + return 0; +} diff --git a/tests/test_mms_static_direct_control.cpp b/tests/test_mms_static_direct_control.cpp new file mode 100644 index 000000000..b59f8b07d --- /dev/null +++ b/tests/test_mms_static_direct_control.cpp @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/data_span_codec.hpp" +#include "ariec61850/mms/static_direct_control.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; +using namespace ar::iec61850::mms; + +#define CHECK(condition) do { \ + if (!(condition)) { \ + throw std::runtime_error(std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ +} while (false) + +[[nodiscard]] std::vector make_oper( + const bool value, + const std::uint8_t ctl_num, + const bool test, + const std::uint8_t check_bits = 0U, + const std::uint8_t origin_category = 2U) { + constexpr std::array origin_id{'H', 'M', 'I'}; + constexpr std::array no_check{0U}; + const std::array check_byte{check_bits}; + + auto oper = MmsDataValue::structure({ + MmsDataValue::boolean(value), + MmsDataValue::structure({ + MmsDataValue::unsigned_integer(origin_category), + MmsDataValue::octet_string(origin_id), + }), + MmsDataValue::unsigned_integer(ctl_num), + MmsDataValue::utc_time(Iec61850UtcTime{}), + MmsDataValue::boolean(test), + MmsDataValue::bit_string( + 6U, + check_bits == 0U + ? std::span{no_check} + : std::span{check_byte}), + }); + + const auto required = MmsDataSpanCodec::encoded_size(oper); + CHECK(required.has_value()); + std::vector bytes(*required); + const auto encoded = MmsDataSpanCodec::encode_into(oper, bytes); + CHECK(encoded.success()); + CHECK(encoded.bytes_written == bytes.size()); + return bytes; +} + +void valid_oper_updates_live_state() { + MmsStaticDirectBooleanControlState state{}; + MmsStaticDirectBooleanControlBinding binding{&state}; + const auto bytes = make_oper(true, 7U, false); + + MmsStaticDirectBooleanOperate decoded; + CHECK(try_decode_static_direct_boolean_operate(bytes, decoded)); + CHECK(decoded.control_value); + CHECK(decoded.origin_category == 2U); + CHECK(decoded.control_number == 7U); + CHECK(!decoded.test); + CHECK(!decoded.synchro_check); + CHECK(!decoded.interlock_check); + + const auto result = mms_static_direct_boolean_write_oper(&binding, bytes); + CHECK(result.success); + CHECK(state.value == 1U); + CHECK(state.last_control_number == 7U); + CHECK(!state.last_test); + CHECK(state.accepted_operations == 1U); + CHECK(state.rejected_operations == 0U); +} + +void test_oper_is_non_mutating() { + MmsStaticDirectBooleanControlState state{}; + state.value = 1U; + MmsStaticDirectBooleanControlBinding binding{&state}; + const auto bytes = make_oper(false, 8U, true); + + const auto result = mms_static_direct_boolean_write_oper(&binding, bytes); + CHECK(result.success); + CHECK(state.value == 1U); + CHECK(state.last_control_number == 8U); + CHECK(state.last_test); + CHECK(state.accepted_operations == 1U); +} + +void unsupported_check_bits_fail_closed() { + MmsStaticDirectBooleanControlState state{}; + MmsStaticDirectBooleanControlBinding binding{&state}; + const auto bytes = make_oper(true, 9U, false, 0x80U); + + const auto denied = mms_static_direct_boolean_write_oper(&binding, bytes); + CHECK(!denied.success); + CHECK(denied.failure_code == 11U); + CHECK(state.value == 0U); + CHECK(state.rejected_operations == 1U); + + binding.policy.allow_synchro_check = true; + const auto accepted = mms_static_direct_boolean_write_oper(&binding, bytes); + CHECK(accepted.success); + CHECK(state.value == 1U); +} + +void invalid_shape_and_values_are_rejected() { + MmsStaticDirectBooleanControlState state{}; + MmsStaticDirectBooleanControlBinding binding{&state}; + + auto malformed = make_oper(true, 10U, false); + malformed.pop_back(); + auto result = mms_static_direct_boolean_write_oper(&binding, malformed); + CHECK(!result.success); + CHECK(result.failure_code == 7U); + + result = mms_static_direct_boolean_write_oper(&binding, make_oper(true, 0U, false)); + CHECK(!result.success); + CHECK(result.failure_code == 11U); + + result = mms_static_direct_boolean_write_oper(&binding, make_oper(true, 11U, false, 0U, 9U)); + CHECK(!result.success); + CHECK(result.failure_code == 11U); + CHECK(state.value == 0U); +} + +[[nodiscard]] bool reject_backend(void*, bool) noexcept { + return false; +} + +void backend_failure_does_not_publish_state() { + MmsStaticDirectBooleanControlState state{}; + MmsStaticDirectBooleanControlBinding binding{&state, reject_backend, nullptr}; + const auto result = mms_static_direct_boolean_write_oper( + &binding, make_oper(true, 12U, false)); + CHECK(!result.success); + CHECK(result.failure_code == 10U); + CHECK(state.value == 0U); + CHECK(state.accepted_operations == 0U); + CHECK(state.rejected_operations == 1U); +} + +void read_callbacks_match_mms_types() { + MmsStaticDirectBooleanControlState state{}; + state.value = 1U; + std::array bytes{}; + + auto read = mms_static_direct_boolean_read_state(&state, bytes); + CHECK(read.success()); + CHECK(read.bytes_written == 3U); + CHECK(bytes[0] == 0x83U && bytes[1] == 0x01U && bytes[2] == 0xFFU); + + bytes.fill(0U); + read = mms_static_direct_normal_read_ctl_model(nullptr, bytes); + CHECK(read.success()); + CHECK(read.bytes_written == 3U); + CHECK(bytes[0] == 0x86U && bytes[1] == 0x01U && bytes[2] == 0x01U); + + read = mms_static_control_read_unavailable(nullptr, bytes); + CHECK(!read.success()); +} + +} // namespace + +int main() { + try { + valid_oper_updates_live_state(); + test_oper_is_non_mutating(); + unsupported_check_bits_fail_closed(); + invalid_shape_and_values_are_rejected(); + backend_failure_does_not_publish_state(); + read_callbacks_match_mms_types(); + std::cout << "MMS static direct-control tests passed.\n"; + return 0; + } catch (const std::exception& exception) { + std::cerr << exception.what() << '\n'; + return 1; + } +} diff --git a/tools/mms_direct_control_server.cpp b/tools/mms_direct_control_server.cpp new file mode 100644 index 000000000..1bc2cece4 --- /dev/null +++ b/tools/mms_direct_control_server.cpp @@ -0,0 +1,581 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_direct_control.hpp" +#include "ariec61850/mms/static_server_session.hpp" +#include "ariec61850/mms/services.hpp" + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#else +#include +#include +#include +#include +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +#ifdef _WIN32 +using SocketHandle = SOCKET; +using SocketLength = int; +constexpr SocketHandle k_invalid_socket = INVALID_SOCKET; +#else +using SocketHandle = int; +using SocketLength = socklen_t; +constexpr SocketHandle k_invalid_socket = -1; +#endif + +struct Options final { + std::string bind_address{"0.0.0.0"}; + std::uint16_t port{8102U}; + std::uint8_t digital_input_mask{}; + std::uint8_t digital_output_mask{}; + bool serve_once{}; +}; + +class SocketSystem final { +public: + SocketSystem() noexcept { +#ifdef _WIN32 + WSADATA data{}; + ready_ = WSAStartup(MAKEWORD(2, 2), &data) == 0; +#else + ready_ = true; +#endif + } + ~SocketSystem() { +#ifdef _WIN32 + if (ready_) WSACleanup(); +#endif + } + SocketSystem(const SocketSystem&) = delete; + SocketSystem& operator=(const SocketSystem&) = delete; + [[nodiscard]] bool ready() const noexcept { return ready_; } +private: + bool ready_{}; +}; + +class OwnedSocket final { +public: + OwnedSocket() noexcept = default; + explicit OwnedSocket(const SocketHandle handle) noexcept : handle_{handle} {} + ~OwnedSocket() { reset(); } + OwnedSocket(const OwnedSocket&) = delete; + OwnedSocket& operator=(const OwnedSocket&) = delete; + OwnedSocket(OwnedSocket&& other) noexcept : handle_{other.release()} {} + OwnedSocket& operator=(OwnedSocket&& other) noexcept { + if (this != &other) reset(other.release()); + return *this; + } + [[nodiscard]] SocketHandle get() const noexcept { return handle_; } + [[nodiscard]] bool valid() const noexcept { return handle_ != k_invalid_socket; } + [[nodiscard]] SocketHandle release() noexcept { + const auto value = handle_; + handle_ = k_invalid_socket; + return value; + } + void reset(const SocketHandle replacement = k_invalid_socket) noexcept { + if (valid()) { +#ifdef _WIN32 + closesocket(handle_); +#else + close(handle_); +#endif + } + handle_ = replacement; + } +private: + SocketHandle handle_{k_invalid_socket}; +}; + +[[nodiscard]] int last_socket_error() noexcept { +#ifdef _WIN32 + return WSAGetLastError(); +#else + return errno; +#endif +} + +[[nodiscard]] bool is_would_block(const int error) noexcept { +#ifdef _WIN32 + return error == WSAEWOULDBLOCK; +#else + return error == EAGAIN || error == EWOULDBLOCK; +#endif +} + +[[nodiscard]] bool is_timeout(const int error) noexcept { +#ifdef _WIN32 + return error == WSAETIMEDOUT; +#else + return error == ETIMEDOUT; +#endif +} + +[[nodiscard]] bool is_interrupted(const int error) noexcept { +#ifdef _WIN32 + return error == WSAEINTR; +#else + return error == EINTR; +#endif +} + +[[nodiscard]] embedded::IoResult socket_receive( + void* context, + const std::span destination) noexcept { + const auto socket = *static_cast(context); +#ifdef _WIN32 + const auto requested = static_cast(std::min( + destination.size(), static_cast(std::numeric_limits::max()))); +#else + const auto requested = destination.size(); +#endif + const auto received = recv( + socket, reinterpret_cast(destination.data()), requested, 0); + if (received > 0) return {embedded::IoStatus::ok, static_cast(received)}; + if (received == 0) return {embedded::IoStatus::closed, 0U}; + const auto error = last_socket_error(); + if (is_would_block(error)) return {embedded::IoStatus::would_block, 0U}; + if (is_timeout(error) || is_interrupted(error)) return {embedded::IoStatus::timeout, 0U}; + return {embedded::IoStatus::io_error, 0U}; +} + +[[nodiscard]] embedded::IoResult socket_send( + void* context, + const std::span bytes) noexcept { + const auto socket = *static_cast(context); +#ifdef _WIN32 + const auto requested = static_cast(std::min( + bytes.size(), static_cast(std::numeric_limits::max()))); +#else + const auto requested = bytes.size(); +#endif +#ifdef MSG_NOSIGNAL + constexpr int flags = MSG_NOSIGNAL; +#else + constexpr int flags = 0; +#endif + const auto sent = send( + socket, reinterpret_cast(bytes.data()), requested, flags); + if (sent > 0) return {embedded::IoStatus::ok, static_cast(sent)}; + if (sent == 0) return {embedded::IoStatus::closed, 0U}; + const auto error = last_socket_error(); + if (is_would_block(error)) return {embedded::IoStatus::would_block, 0U}; + if (is_timeout(error) || is_interrupted(error)) return {embedded::IoStatus::timeout, 0U}; + return {embedded::IoStatus::io_error, 0U}; +} + +template +[[nodiscard]] bool parse_integer(const std::string_view text, Integer& output) noexcept { + auto digits = text; + int base = 10; + if (digits.size() > 2U && digits[0] == '0' && + (digits[1] == 'x' || digits[1] == 'X')) { + digits.remove_prefix(2U); + base = 16; + } + unsigned int parsed{}; + const auto result = std::from_chars( + digits.data(), digits.data() + digits.size(), parsed, base); + if (result.ec != std::errc{} || result.ptr != digits.data() + digits.size() || + parsed > static_cast(std::numeric_limits::max())) { + return false; + } + output = static_cast(parsed); + return true; +} + +void print_help() { + std::cout + << "ARStack61850 bounded Direct-Normal MMS control server\n\n" + << "Usage:\n" + << " ariec61850_mms_direct_control_server [options]\n\n" + << "Options:\n" + << " --bind ADDRESS Bind address (default 0.0.0.0)\n" + << " --port PORT TCP port (default 8102; use 102 for IEC 61850)\n" + << " --di-mask VALUE Initial GGIO1 Ind1..Ind8 bit mask\n" + << " --do-mask VALUE Initial GGIO1 SPCSO1..8 status bit mask\n" + << " --once Serve one TCP connection, then exit\n" + << " --help Show this help\n\n" + << "Control objects: ESP32S3IOLD0/GGIO1.SPCSO1 .. SPCSO8\n" + << "ctlModel: Direct-with-normal-security (1).\n" + << "Check bits are fail-closed; Test=true is acknowledged without changing output.\n"; +} + +[[nodiscard]] bool parse_options( + const int argc, + char** argv, + Options& options, + bool& help) { + help = false; + for (int index = 1; index < argc; ++index) { + const std::string_view argument{argv[index]}; + if (argument == "--help" || argument == "-h") { + help = true; + return true; + } + if (argument == "--once") { + options.serve_once = true; + continue; + } + if (index + 1 >= argc) { + std::cerr << "Missing value after " << argument << '\n'; + return false; + } + const std::string_view value{argv[++index]}; + if (argument == "--bind") { + options.bind_address.assign(value); + } else if (argument == "--port") { + if (!parse_integer(value, options.port) || options.port == 0U) return false; + } else if (argument == "--di-mask") { + if (!parse_integer(value, options.digital_input_mask)) return false; + } else if (argument == "--do-mask") { + if (!parse_integer(value, options.digital_output_mask)) return false; + } else { + std::cerr << "Unknown option: " << argument << '\n'; + return false; + } + } + return true; +} + +[[nodiscard]] OwnedSocket create_listener(const Options& options) { + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_protocol = IPPROTO_TCP; + hints.ai_flags = AI_PASSIVE; + const auto service = std::to_string(options.port); + addrinfo* addresses{}; + if (getaddrinfo( + options.bind_address.empty() ? nullptr : options.bind_address.c_str(), + service.c_str(), &hints, &addresses) != 0) { + return {}; + } + + OwnedSocket listener; + for (auto* address = addresses; address != nullptr; address = address->ai_next) { + OwnedSocket candidate{socket( + address->ai_family, address->ai_socktype, address->ai_protocol)}; + if (!candidate.valid()) continue; + constexpr int enabled = 1; + static_cast(setsockopt( + candidate.get(), SOL_SOCKET, SO_REUSEADDR, + reinterpret_cast(&enabled), + static_cast(sizeof(enabled)))); + const auto address_length = static_cast(address->ai_addrlen); + if (bind(candidate.get(), address->ai_addr, address_length) == 0 && + listen(candidate.get(), 8) == 0) { + listener = std::move(candidate); + break; + } + } + freeaddrinfo(addresses); + return listener; +} + +void set_client_timeouts(const SocketHandle socket) noexcept { +#ifdef _WIN32 + constexpr DWORD timeout_ms = 1000U; + static_cast(setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, + reinterpret_cast(&timeout_ms), static_cast(sizeof(timeout_ms)))); + static_cast(setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, + reinterpret_cast(&timeout_ms), static_cast(sizeof(timeout_ms)))); +#else + constexpr timeval timeout{1, 0}; + static_cast(setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout))); + static_cast(setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout))); +#endif +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) return {wire::EncodeStatus::value_out_of_range, 0U, required}; + if (destination.size() < required) return {wire::EncodeStatus::buffer_too_small, 0U, required}; + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) != 0U ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +struct EncodedValue final { std::span bytes; }; + +[[nodiscard]] wire::EncodeResult read_encoded( + const void* context, + const std::span destination) noexcept { + if (context == nullptr) return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + const auto bytes = static_cast(context)->bytes; + if (destination.size() < bytes.size()) return {wire::EncodeStatus::buffer_too_small, 0U, bytes.size()}; + std::copy(bytes.begin(), bytes.end(), destination.begin()); + return {wire::EncodeStatus::ok, bytes.size(), bytes.size()}; +} + +[[nodiscard]] mms::MmsTypeSpecification scalar( + const mms::MmsTypeKind kind, + std::string name, + const std::optional size = std::nullopt) { + mms::MmsTypeSpecification result; + result.kind = kind; + result.name = std::move(name); + result.size = size; + return result; +} + +[[nodiscard]] mms::MmsTypeSpecification structure( + std::string name, + std::vector children) { + mms::MmsTypeSpecification result; + result.kind = mms::MmsTypeKind::structure; + result.name = std::move(name); + result.children = std::move(children); + return result; +} + +[[nodiscard]] mms::MmsTypeSpecification direct_boolean_oper_spec() { + return structure("Oper", { + scalar(mms::MmsTypeKind::boolean, "ctlVal"), + structure("origin", { + scalar(mms::MmsTypeKind::unsigned_integer, "orCat"), + scalar(mms::MmsTypeKind::octet_string, "orIdent", 64U), + }), + scalar(mms::MmsTypeKind::unsigned_integer, "ctlNum"), + scalar(mms::MmsTypeKind::utc_time, "T"), + scalar(mms::MmsTypeKind::boolean, "Test"), + scalar(mms::MmsTypeKind::bit_string, "Check", 2U), + }); +} + +[[nodiscard]] std::vector build_single_status_ln_type( + const std::string& data_object) { + return mms::MmsServiceCodec::encode_type_specification(structure("", { + structure("ST", {structure(data_object, {scalar(mms::MmsTypeKind::boolean, "stVal")})}) + })); +} + +[[nodiscard]] std::vector build_ggio_type() { + std::vector status; + std::vector config; + std::vector controls; + status.reserve(16U); + config.reserve(8U); + controls.reserve(8U); + for (std::size_t index = 0U; index < 8U; ++index) { + const auto number = std::to_string(index + 1U); + status.push_back(structure("Ind" + number, {scalar(mms::MmsTypeKind::boolean, "stVal")})); + status.push_back(structure("SPCSO" + number, {scalar(mms::MmsTypeKind::boolean, "stVal")})); + config.push_back(structure("SPCSO" + number, { + scalar(mms::MmsTypeKind::unsigned_integer, "ctlModel") + })); + controls.push_back(structure("SPCSO" + number, {direct_boolean_oper_spec()})); + } + return mms::MmsServiceCodec::encode_type_specification(structure("", { + structure("ST", std::move(status)), + structure("CF", std::move(config)), + structure("CO", std::move(controls)), + })); +} + +[[nodiscard]] bool serve_client( + const SocketHandle client, + const mms::MmsStaticApplicationDispatcher& dispatcher) { + mms::MmsStaticConnectionRuntime runtime{dispatcher}; + auto socket_copy = client; + embedded::TcpByteStream stream{&socket_copy, socket_send, socket_receive}; + std::array receive{}; + std::array response{}; + std::array workspace{}; + mms::MmsStaticServerSession session{runtime, stream, {receive, response, workspace}}; + for (;;) { + const auto result = session.poll_once(); + switch (result.status) { + case mms::MmsStaticServerSessionStatus::progressed: + case mms::MmsStaticServerSessionStatus::response_pending: + case mms::MmsStaticServerSessionStatus::would_block: + case mms::MmsStaticServerSessionStatus::timed_out: + continue; + case mms::MmsStaticServerSessionStatus::application_rejected: + std::cerr << "MMS request rejected; connection remains open.\n"; + continue; + case mms::MmsStaticServerSessionStatus::peer_closed: + return true; + default: + std::cerr + << "MMS_DIRECT_CONTROL_SESSION_TERMINAL sessionStatus=" + << static_cast(result.status) + << " connectionStatus=" + << static_cast(result.connection_status) + << " bytesReceived=" << result.bytes_received + << " bytesSent=" << result.bytes_sent + << " bufferedInput=" << result.buffered_input_bytes + << " pendingOutput=" << result.pending_output_bytes << '\n'; + return false; + } + } +} + +[[nodiscard]] std::uint8_t output_mask( + const std::array& states) noexcept { + std::uint8_t mask{}; + for (std::size_t index = 0U; index < states.size(); ++index) { + if (states[index].value != 0U) mask = static_cast(mask | (1U << index)); + } + return mask; +} + +} // namespace + +int main(int argc, char** argv) { + Options options; + bool help{}; + if (!parse_options(argc, argv, options, help)) return 2; + if (help) { + print_help(); + return 0; + } + + SocketSystem sockets; + if (!sockets.ready()) return 3; + + constexpr std::string_view domain{"ESP32S3IOLD0"}; + constexpr std::array boolean_type{0x83U, 0x00U}; + constexpr std::array unsigned_type{0x86U, 0x00U}; + const auto oper_type = mms::MmsServiceCodec::encode_type_specification(direct_boolean_oper_spec()); + const auto lln0_type = build_single_status_ln_type("Mod"); + const auto lphd1_type = build_single_status_ln_type("PhyHealth"); + const auto ggio1_type = build_ggio_type(); + + constexpr std::array healthy_ln_data{ + 0xA2U, 0x07U, 0xA2U, 0x05U, 0xA2U, 0x03U, 0x83U, 0x01U, 0xFFU}; + const std::array root_values{ + EncodedValue{healthy_ln_data}, EncodedValue{healthy_ln_data}}; + + std::array read_values{}; + read_values[0] = 1U; + read_values[1] = 1U; + for (std::size_t index = 0U; index < 8U; ++index) { + read_values[index + 2U] = static_cast((options.digital_input_mask >> index) & 0x01U); + } + + std::array control_states{}; + std::array control_bindings{}; + std::array input_items{}; + std::array output_status_items{}; + std::array ctl_model_items{}; + std::array oper_items{}; + + for (std::size_t index = 0U; index < 8U; ++index) { + const auto number = std::to_string(index + 1U); + input_items[index] = "GGIO1$ST$Ind" + number + "$stVal"; + output_status_items[index] = "GGIO1$ST$SPCSO" + number + "$stVal"; + ctl_model_items[index] = "GGIO1$CF$SPCSO" + number + "$ctlModel"; + oper_items[index] = "GGIO1$CO$SPCSO" + number + "$Oper"; + control_states[index].value = static_cast( + (options.digital_output_mask >> index) & 0x01U); + control_bindings[index].state = &control_states[index]; + } + + std::array objects{}; + objects[0] = {domain, "LLN0", lln0_type, read_encoded, &root_values[0], false, nullptr, nullptr, nullptr}; + objects[1] = {domain, "LPHD1", lphd1_type, read_encoded, &root_values[1], false, nullptr, nullptr, nullptr}; + objects[2] = {domain, "GGIO1", ggio1_type, mms::mms_static_control_read_unavailable, nullptr, false, nullptr, nullptr, nullptr}; + objects[3] = {domain, "LLN0$ST$Mod$stVal", boolean_type, read_boolean, &read_values[0]}; + objects[4] = {domain, "LPHD1$ST$PhyHealth$stVal", boolean_type, read_boolean, &read_values[1]}; + for (std::size_t index = 0U; index < 8U; ++index) { + objects[5U + index] = {domain, input_items[index], boolean_type, read_boolean, &read_values[index + 2U]}; + const auto base = 13U + index * 3U; + objects[base] = {domain, output_status_items[index], boolean_type, + mms::mms_static_direct_boolean_read_state, &control_states[index]}; + objects[base + 1U] = {domain, ctl_model_items[index], unsigned_type, + mms::mms_static_direct_normal_read_ctl_model, nullptr}; + objects[base + 2U] = {domain, oper_items[index], oper_type, + mms::mms_static_control_read_unavailable, nullptr, false, + mms::mms_static_direct_boolean_write_oper, &control_bindings[index]}; + } + + const mms::MmsStaticObjectTable object_table{objects}; + if (!object_table.valid()) { + std::cerr << "Static MMS control model is invalid.\n"; + return 4; + } + + std::array output_members{}; + for (std::size_t index = 0U; index < output_members.size(); ++index) { + output_members[index] = {domain, output_status_items[index]}; + } + const std::array data_sets{{ + {domain, "LLN0$Outputs", output_members, false} + }}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + if (!data_set_table.valid_against(object_table)) { + std::cerr << "Static output DataSet is invalid.\n"; + return 4; + } + + mms::MmsStaticDispatchPolicy policy; + policy.maximum_write_variables = 1U; + const mms::MmsStaticApplicationDispatcher dispatcher{object_table, data_set_table, policy}; + + auto listener = create_listener(options); + if (!listener.valid()) { + std::cerr << "Unable to bind/listen on " << options.bind_address << ':' << options.port << ".\n"; + return 5; + } + + std::cout << "MMS_DIRECT_CONTROL_SERVER_READY bind=" << options.bind_address + << " port=" << options.port + << " domain=" << domain + << " controls=8 ctlModel=1 dataset=ESP32S3IOLD0/LLN0.Outputs" + << " doMask=" << static_cast(output_mask(control_states)) << '\n'; + + do { + sockaddr_storage peer{}; + SocketLength peer_size = static_cast(sizeof(peer)); + OwnedSocket client{accept( + listener.get(), reinterpret_cast(&peer), &peer_size)}; + if (!client.valid()) { + const auto error = last_socket_error(); + if (is_interrupted(error)) continue; + std::cerr << "TCP accept failed with platform error " << error << ".\n"; + return 6; + } + set_client_timeouts(client.get()); + std::cout << "MMS_DIRECT_CONTROL_CLIENT_ACCEPTED\n"; + const auto clean_close = serve_client(client.get(), dispatcher); + std::size_t accepted{}; + std::size_t rejected{}; + for (const auto& state : control_states) { + accepted += state.accepted_operations; + rejected += state.rejected_operations; + } + std::cout << "MMS_DIRECT_CONTROL_STATE doMask=" + << static_cast(output_mask(control_states)) + << " acceptedOps=" << accepted + << " rejectedOps=" << rejected << '\n'; + std::cout << (clean_close ? "MMS_DIRECT_CONTROL_CLIENT_CLOSED" + : "MMS_DIRECT_CONTROL_CLIENT_ABORTED") << '\n'; + } while (!options.serve_once); + + return 0; +} diff --git a/tools/mms_direct_control_server/CMakeLists.txt b/tools/mms_direct_control_server/CMakeLists.txt new file mode 100644 index 000000000..6ffae6a11 --- /dev/null +++ b/tools/mms_direct_control_server/CMakeLists.txt @@ -0,0 +1,76 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARStack61850MmsDirectControlServer LANGUAGES CXX) + +get_filename_component(ARSTACK_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) + +set(ARIEC61850_BUILD_TOOLS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_BUILD_FUZZERS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_ENABLE_SANITIZERS OFF CACHE BOOL "" FORCE) +set(ARIEC61850_WARNINGS_AS_ERRORS ON CACHE BOOL "" FORCE) + +add_subdirectory("${ARSTACK_ROOT}" arstack-core) + +function(server_strict_warnings target) + if(MSVC) + target_compile_options(${target} PRIVATE /W4 /WX /permissive-) + else() + target_compile_options(${target} PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) + endif() +endfunction() + +add_executable(ariec61850_mms_direct_control_server + "${ARSTACK_ROOT}/tools/mms_direct_control_server.cpp") +target_include_directories(ariec61850_mms_direct_control_server PRIVATE + "${ARSTACK_ROOT}/include") +target_link_libraries(ariec61850_mms_direct_control_server PRIVATE + ARIEC61850::mms_server_core + ARIEC61850::core) +if(WIN32) + target_link_libraries(ariec61850_mms_direct_control_server PRIVATE ws2_32) +endif() +target_compile_features(ariec61850_mms_direct_control_server PRIVATE cxx_std_20) +server_strict_warnings(ariec61850_mms_direct_control_server) + +add_executable(ariec61850_mms_urcb_server + "${ARSTACK_ROOT}/tools/mms_urcb_server.cpp") +target_include_directories(ariec61850_mms_urcb_server PRIVATE + "${ARSTACK_ROOT}/include") +target_link_libraries(ariec61850_mms_urcb_server PRIVATE + ARIEC61850::mms_server_core + ARIEC61850::core) +if(WIN32) + target_link_libraries(ariec61850_mms_urcb_server PRIVATE ws2_32) +endif() +target_compile_features(ariec61850_mms_urcb_server PRIVATE cxx_std_20) +server_strict_warnings(ariec61850_mms_urcb_server) + +include(CTest) +enable_testing() + +add_executable(ariec61850_mms_direct_control_tests + "${ARSTACK_ROOT}/tests/test_mms_static_direct_control.cpp") +target_include_directories(ariec61850_mms_direct_control_tests PRIVATE + "${ARSTACK_ROOT}/include") +target_link_libraries(ariec61850_mms_direct_control_tests PRIVATE + ARIEC61850::mms_server_core + ARIEC61850::core) +target_compile_features(ariec61850_mms_direct_control_tests PRIVATE cxx_std_20) +server_strict_warnings(ariec61850_mms_direct_control_tests) + +add_executable(ariec61850_mms_server_urcb_tests + "${ARSTACK_ROOT}/tests/test_mms_server_urcb_report.cpp") +target_include_directories(ariec61850_mms_server_urcb_tests PRIVATE + "${ARSTACK_ROOT}/include") +target_link_libraries(ariec61850_mms_server_urcb_tests PRIVATE + ARIEC61850::mms_server_core + ARIEC61850::core) +target_compile_features(ariec61850_mms_server_urcb_tests PRIVATE cxx_std_20) +server_strict_warnings(ariec61850_mms_server_urcb_tests) + +add_test(NAME mms_direct_control_unit COMMAND ariec61850_mms_direct_control_tests) +add_test(NAME mms_direct_control_help COMMAND ariec61850_mms_direct_control_server --help) +add_test(NAME mms_server_urcb_report COMMAND ariec61850_mms_server_urcb_tests) +add_test(NAME mms_urcb_server_help COMMAND ariec61850_mms_urcb_server --help) diff --git a/tools/mms_urcb_server.cpp b/tools/mms_urcb_server.cpp new file mode 100644 index 000000000..af46ab1dd --- /dev/null +++ b/tools/mms_urcb_server.cpp @@ -0,0 +1,522 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_data_set_table.hpp" +#include "ariec61850/mms/static_dispatcher.hpp" +#include "ariec61850/mms/static_report_connection.hpp" +#include "ariec61850/mms/static_server_session.hpp" +#include "ariec61850/mms/static_urcb_objects.hpp" +#include "ariec61850/mms/static_urcb_runtime.hpp" +#include "ariec61850/mms/services.hpp" + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#else +#include +#include +#include +#include +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +#ifdef _WIN32 +using SocketHandle = SOCKET; +using SocketLength = int; +constexpr SocketHandle kInvalidSocket = INVALID_SOCKET; +#else +using SocketHandle = int; +using SocketLength = socklen_t; +constexpr SocketHandle kInvalidSocket = -1; +#endif + +struct Options final { + std::string bind_address{"0.0.0.0"}; + std::uint16_t port{8102U}; + std::uint8_t output_mask{}; + bool serve_once{}; +}; + +class SocketSystem final { +public: + SocketSystem() noexcept { +#ifdef _WIN32 + WSADATA data{}; + ready_ = WSAStartup(MAKEWORD(2, 2), &data) == 0; +#else + ready_ = true; +#endif + } + ~SocketSystem() { +#ifdef _WIN32 + if (ready_) WSACleanup(); +#endif + } + SocketSystem(const SocketSystem&) = delete; + SocketSystem& operator=(const SocketSystem&) = delete; + [[nodiscard]] bool ready() const noexcept { return ready_; } +private: + bool ready_{}; +}; + +class OwnedSocket final { +public: + OwnedSocket() noexcept = default; + explicit OwnedSocket(SocketHandle handle) noexcept : handle_{handle} {} + ~OwnedSocket() { reset(); } + OwnedSocket(const OwnedSocket&) = delete; + OwnedSocket& operator=(const OwnedSocket&) = delete; + OwnedSocket(OwnedSocket&& other) noexcept : handle_{other.release()} {} + OwnedSocket& operator=(OwnedSocket&& other) noexcept { + if (this != &other) reset(other.release()); + return *this; + } + [[nodiscard]] SocketHandle get() const noexcept { return handle_; } + [[nodiscard]] bool valid() const noexcept { return handle_ != kInvalidSocket; } + [[nodiscard]] SocketHandle release() noexcept { + const auto value = handle_; + handle_ = kInvalidSocket; + return value; + } + void reset(SocketHandle replacement = kInvalidSocket) noexcept { + if (valid()) { +#ifdef _WIN32 + closesocket(handle_); +#else + close(handle_); +#endif + } + handle_ = replacement; + } +private: + SocketHandle handle_{kInvalidSocket}; +}; + +[[nodiscard]] int last_socket_error() noexcept { +#ifdef _WIN32 + return WSAGetLastError(); +#else + return errno; +#endif +} + +[[nodiscard]] bool is_would_block(int error) noexcept { +#ifdef _WIN32 + return error == WSAEWOULDBLOCK; +#else + return error == EAGAIN || error == EWOULDBLOCK; +#endif +} + +[[nodiscard]] bool is_timeout(int error) noexcept { +#ifdef _WIN32 + return error == WSAETIMEDOUT; +#else + return error == ETIMEDOUT; +#endif +} + +[[nodiscard]] bool is_interrupted(int error) noexcept { +#ifdef _WIN32 + return error == WSAEINTR; +#else + return error == EINTR; +#endif +} + +[[nodiscard]] embedded::IoResult socket_receive( + void* context, + std::span destination) noexcept { + const auto socket = *static_cast(context); +#ifdef _WIN32 + const auto requested = static_cast(std::min( + destination.size(), static_cast(std::numeric_limits::max()))); +#else + const auto requested = destination.size(); +#endif + const auto received = recv( + socket, reinterpret_cast(destination.data()), requested, 0); + if (received > 0) return {embedded::IoStatus::ok, static_cast(received)}; + if (received == 0) return {embedded::IoStatus::closed, 0U}; + const auto error = last_socket_error(); + if (is_would_block(error)) return {embedded::IoStatus::would_block, 0U}; + if (is_timeout(error) || is_interrupted(error)) return {embedded::IoStatus::timeout, 0U}; + return {embedded::IoStatus::io_error, 0U}; +} + +[[nodiscard]] embedded::IoResult socket_send( + void* context, + std::span bytes) noexcept { + const auto socket = *static_cast(context); +#ifdef _WIN32 + const auto requested = static_cast(std::min( + bytes.size(), static_cast(std::numeric_limits::max()))); +#else + const auto requested = bytes.size(); +#endif +#ifdef MSG_NOSIGNAL + constexpr int flags = MSG_NOSIGNAL; +#else + constexpr int flags = 0; +#endif + const auto sent = send(socket, reinterpret_cast(bytes.data()), requested, flags); + if (sent > 0) return {embedded::IoStatus::ok, static_cast(sent)}; + if (sent == 0) return {embedded::IoStatus::closed, 0U}; + const auto error = last_socket_error(); + if (is_would_block(error)) return {embedded::IoStatus::would_block, 0U}; + if (is_timeout(error) || is_interrupted(error)) return {embedded::IoStatus::timeout, 0U}; + return {embedded::IoStatus::io_error, 0U}; +} + +[[nodiscard]] bool send_all( + SocketHandle socket, + std::span bytes) noexcept { + std::size_t offset{}; + while (offset < bytes.size()) { + auto socket_copy = socket; + const auto result = socket_send(&socket_copy, bytes.subspan(offset)); + if (result.status != embedded::IoStatus::ok || result.transferred == 0U) return false; + offset += result.transferred; + } + return true; +} + +template +[[nodiscard]] bool parse_integer(std::string_view text, Integer& output) noexcept { + auto digits = text; + int base = 10; + if (digits.size() > 2U && digits[0] == '0' && (digits[1] == 'x' || digits[1] == 'X')) { + digits.remove_prefix(2U); + base = 16; + } + unsigned int parsed{}; + const auto result = std::from_chars(digits.data(), digits.data() + digits.size(), parsed, base); + if (result.ec != std::errc{} || result.ptr != digits.data() + digits.size() || + parsed > static_cast(std::numeric_limits::max())) { + return false; + } + output = static_cast(parsed); + return true; +} + +void print_help() { + std::cout + << "ARStack61850 bounded static URCB reporting server\n\n" + << "Usage:\n" + << " ariec61850_mms_urcb_server [options]\n\n" + << "Options:\n" + << " --bind ADDRESS Bind address (default 0.0.0.0)\n" + << " --port PORT TCP port (default 8102; use 102 for IEC 61850)\n" + << " --mask VALUE Initial SPCSO1..8 Boolean status mask\n" + << " --once Serve one TCP connection, then exit\n" + << " --help Show this help\n\n" + << "DataSet: ESP32S3IOLD0/LLN0.Outputs\n" + << "URCB: ESP32S3IOLD0/LLN0$RP$Outputs01\n" + << "First live-report gate: reserve/enable the URCB and write GI=true.\n"; +} + +[[nodiscard]] bool parse_options(int argc, char** argv, Options& options, bool& help) { + help = false; + for (int index = 1; index < argc; ++index) { + const std::string_view argument{argv[index]}; + if (argument == "--help" || argument == "-h") { + help = true; + return true; + } + if (argument == "--once") { + options.serve_once = true; + continue; + } + if (index + 1 >= argc) return false; + const std::string_view value{argv[++index]}; + if (argument == "--bind") { + options.bind_address.assign(value); + } else if (argument == "--port") { + if (!parse_integer(value, options.port) || options.port == 0U) return false; + } else if (argument == "--mask") { + if (!parse_integer(value, options.output_mask)) return false; + } else { + return false; + } + } + return true; +} + +[[nodiscard]] OwnedSocket create_listener(const Options& options) { + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_protocol = IPPROTO_TCP; + hints.ai_flags = AI_PASSIVE; + const auto service = std::to_string(options.port); + addrinfo* addresses{}; + if (getaddrinfo(options.bind_address.c_str(), service.c_str(), &hints, &addresses) != 0) return {}; + OwnedSocket listener; + for (auto* address = addresses; address != nullptr; address = address->ai_next) { + OwnedSocket candidate{socket(address->ai_family, address->ai_socktype, address->ai_protocol)}; + if (!candidate.valid()) continue; + constexpr int enabled = 1; + static_cast(setsockopt(candidate.get(), SOL_SOCKET, SO_REUSEADDR, + reinterpret_cast(&enabled), static_cast(sizeof(enabled)))); + const auto address_length = static_cast(address->ai_addrlen); + if (bind(candidate.get(), address->ai_addr, address_length) == 0 && + listen(candidate.get(), 8) == 0) { + listener = std::move(candidate); + break; + } + } + freeaddrinfo(addresses); + return listener; +} + +void set_client_timeouts(SocketHandle socket) noexcept { +#ifdef _WIN32 + constexpr DWORD timeout_ms = 1000U; + static_cast(setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, + reinterpret_cast(&timeout_ms), static_cast(sizeof(timeout_ms)))); + static_cast(setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, + reinterpret_cast(&timeout_ms), static_cast(sizeof(timeout_ms)))); +#else + constexpr timeval timeout{1, 0}; + static_cast(setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout))); + static_cast(setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout))); +#endif +} + +[[nodiscard]] std::uint64_t monotonic_ms() noexcept { + using namespace std::chrono; + return static_cast(duration_cast( + steady_clock::now().time_since_epoch()).count()); +} + +[[nodiscard]] std::uint64_t report_now_ms(const void*) noexcept { + return monotonic_ms(); +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) return {wire::EncodeStatus::value_out_of_range, 0U, required}; + if (destination.size() < required) return {wire::EncodeStatus::buffer_too_small, 0U, required}; + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) != 0U ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +struct EncodedValue final { std::span bytes; }; + +[[nodiscard]] wire::EncodeResult read_encoded( + const void* context, + std::span destination) noexcept { + if (context == nullptr) return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + const auto bytes = static_cast(context)->bytes; + if (destination.size() < bytes.size()) return {wire::EncodeStatus::buffer_too_small, 0U, bytes.size()}; + std::copy(bytes.begin(), bytes.end(), destination.begin()); + return {wire::EncodeStatus::ok, bytes.size(), bytes.size()}; +} + +[[nodiscard]] mms::MmsTypeSpecification scalar(mms::MmsTypeKind kind, std::string name) { + mms::MmsTypeSpecification result; + result.kind = kind; + result.name = std::move(name); + return result; +} + +[[nodiscard]] mms::MmsTypeSpecification structure( + std::string name, + std::vector children) { + mms::MmsTypeSpecification result; + result.kind = mms::MmsTypeKind::structure; + result.name = std::move(name); + result.children = std::move(children); + return result; +} + +[[nodiscard]] std::vector build_ggio_type() { + std::vector statuses; + statuses.reserve(8U); + for (std::size_t index = 0U; index < 8U; ++index) { + statuses.push_back(structure("SPCSO" + std::to_string(index + 1U), { + scalar(mms::MmsTypeKind::boolean, "stVal") + })); + } + return mms::MmsServiceCodec::encode_type_specification(structure("", { + structure("ST", std::move(statuses)) + })); +} + +[[nodiscard]] bool serve_client( + SocketHandle client, + const mms::MmsStaticApplicationDispatcher& dispatcher, + mms::MmsStaticUrcbRuntime& urcbs) { + mms::MmsStaticConnectionRuntime runtime{dispatcher}; + auto socket_copy = client; + embedded::TcpByteStream stream{&socket_copy, socket_send, socket_receive}; + std::array receive{}; + std::array response{}; + std::array workspace{}; + std::array report_frame{}; + std::array report_workspace{}; + mms::MmsStaticServerSession session{runtime, stream, {receive, response, workspace}}; + + for (;;) { + const auto session_result = session.poll_once(); + switch (session_result.status) { + case mms::MmsStaticServerSessionStatus::peer_closed: + return true; + case mms::MmsStaticServerSessionStatus::transport_error: + case mms::MmsStaticServerSessionStatus::protocol_error: + case mms::MmsStaticServerSessionStatus::receive_buffer_full: + case mms::MmsStaticServerSessionStatus::invalid_configuration: + return false; + default: + break; + } + + if (session.pending_output_bytes() != 0U) continue; + + const auto report = mms::MmsStaticReportConnection::poll( + runtime, urcbs, monotonic_ms(), {}, report_frame, report_workspace); + if (report.status == mms::MmsStaticReportConnectionStatus::response_ready) { + if (!send_all(client, std::span{ + report_frame.data(), report.bytes_written})) { + return false; + } + std::cout << "MMS_URCB_REPORT_SENT index=" << report.control_block_index + << " sqNum=" << static_cast(report.sequence_number) + << " reason=" << static_cast(report.reason) + << " bytes=" << report.bytes_written << '\n'; + } else if (report.status == mms::MmsStaticReportConnectionStatus::report_encode_failed || + report.status == mms::MmsStaticReportConnectionStatus::response_buffer_too_small || + report.status == mms::MmsStaticReportConnectionStatus::workspace_too_small) { + std::cerr << "MMS_URCB_REPORT_ERROR status=" << static_cast(report.status) + << " urcbStatus=" << static_cast(report.urcb_status) << '\n'; + } + } +} + +} // namespace + +int main(int argc, char** argv) { + Options options; + bool help{}; + if (!parse_options(argc, argv, options, help)) return 2; + if (help) { + print_help(); + return 0; + } + + SocketSystem sockets; + if (!sockets.ready()) return 3; + + constexpr std::string_view domain{"ESP32S3IOLD0"}; + constexpr std::array boolean_type{0x83U, 0x00U}; + const auto ggio_type = build_ggio_type(); + const EncodedValue ggio_root{ggio_type}; + + std::array values{}; + std::array status_items{}; + std::array base_objects{}; + base_objects[0] = {domain, "GGIO1", ggio_type, read_encoded, &ggio_root}; + for (std::size_t index = 0U; index < 8U; ++index) { + values[index] = static_cast((options.output_mask >> index) & 0x01U); + status_items[index] = "GGIO1$ST$SPCSO" + std::to_string(index + 1U) + "$stVal"; + base_objects[index + 1U] = { + domain, status_items[index], boolean_type, read_boolean, &values[index] + }; + } + const mms::MmsStaticObjectTable base_table{base_objects}; + if (!base_table.valid()) return 4; + + std::array members{}; + for (std::size_t index = 0U; index < members.size(); ++index) { + members[index] = {domain, status_items[index]}; + } + const std::array data_sets{{ + {domain, "LLN0$Outputs", members, false} + }}; + const mms::MmsStaticDataSetTable data_set_table{data_sets}; + if (!data_set_table.valid_against(base_table)) return 4; + + constexpr std::array opt_fields{0x5CU, 0x80U}; + const std::array definitions{{ + {domain, "LLN0$RP$Outputs01", "ARSTACK-Outputs01", + domain, "LLN0$Outputs", 1U, opt_fields, 0U, 0U, 0U} + }}; + std::array states{}; + mms::MmsStaticUrcbRuntime urcbs{definitions, states, base_table, data_set_table}; + if (!urcbs.initialize()) return 4; + + std::array object_storage{}; + std::array context_storage{}; + std::array name_storage{}; + mms::MmsStaticUrcbObjectBank object_bank{ + urcbs, base_objects, object_storage, context_storage, name_storage, + report_now_ms, nullptr}; + if (!object_bank.initialize()) return 4; + + mms::MmsStaticDispatchPolicy policy; + policy.maximum_write_variables = 1U; + const mms::MmsStaticApplicationDispatcher dispatcher{ + object_bank.table(), data_set_table, policy}; + + auto listener = create_listener(options); + if (!listener.valid()) { + std::cerr << "Unable to bind/listen on " << options.bind_address << ':' << options.port << ".\n"; + return 5; + } + + std::cout << "MMS_URCB_SERVER_READY bind=" << options.bind_address + << " port=" << options.port + << " dataset=ESP32S3IOLD0/LLN0.Outputs" + << " urcb=ESP32S3IOLD0/LLN0$RP$Outputs01" + << " members=8 mask=" << static_cast(options.output_mask) << '\n'; + + do { + static_cast(urcbs.set_enabled(0U, false, monotonic_ms())); + static_cast(urcbs.set_reserved(0U, false)); + sockaddr_storage peer{}; + SocketLength peer_size = static_cast(sizeof(peer)); + OwnedSocket client{accept(listener.get(), reinterpret_cast(&peer), &peer_size)}; + if (!client.valid()) { + const auto error = last_socket_error(); + if (is_interrupted(error)) continue; + return 6; + } + set_client_timeouts(client.get()); + std::cout << "MMS_URCB_CLIENT_ACCEPTED\n"; + const auto clean_close = serve_client(client.get(), dispatcher, urcbs); + const auto state = urcbs.state(0U); + std::cout << "MMS_URCB_STATE enabled=" << (state != nullptr && state->enabled ? "true" : "false") + << " reserved=" << (state != nullptr && state->reserved ? "true" : "false") + << " sqNum=" << (state != nullptr ? static_cast(state->sequence_number) : 0U) + << '\n'; + static_cast(urcbs.set_enabled(0U, false, monotonic_ms())); + static_cast(urcbs.set_reserved(0U, false)); + std::cout << (clean_close ? "MMS_URCB_CLIENT_CLOSED" : "MMS_URCB_CLIENT_ABORTED") << '\n'; + } while (!options.serve_once); + + return 0; +}