From ffe6059f4ee0b722396ecd792a62535bd6b261c3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:30:56 +0700 Subject: [PATCH 01/14] Add bounded direct-normal server control binding --- .../ariec61850/mms/static_direct_control.hpp | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 include/ariec61850/mms/static_direct_control.hpp 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 From 20fb154b765076fe81e1150a6b8d44dfadd9e2ff Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:31:23 +0700 Subject: [PATCH 02/14] Implement bounded direct-normal Oper decoder --- src/mms/static_direct_control.cpp | 198 ++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 src/mms/static_direct_control.cpp 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 From f1e7c2225905c0afa3d9c78068ab8681dbcb10bf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:31:47 +0700 Subject: [PATCH 03/14] Add direct-normal server control regression tests --- tests/test_mms_static_direct_control.cpp | 187 +++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 tests/test_mms_static_direct_control.cpp 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; + } +} From 138a41b9a4faf76cc0c6774eb2fdd82776938fe2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:33:05 +0700 Subject: [PATCH 04/14] Add runnable direct-normal IEC 61850 control server --- tools/mms_direct_control_server.cpp | 572 ++++++++++++++++++++++++++++ 1 file changed, 572 insertions(+) create mode 100644 tools/mms_direct_control_server.cpp diff --git a/tools/mms_direct_control_server.cpp b/tools/mms_direct_control_server.cpp new file mode 100644 index 000000000..5ebca71ba --- /dev/null +++ b/tools/mms_direct_control_server.cpp @@ -0,0 +1,572 @@ +// 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: + 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, false}; + objects[1] = {domain, "LPHD1", lphd1_type, read_encoded, &root_values[1], false, nullptr, nullptr, false}; + objects[2] = {domain, "GGIO1", ggio1_type, mms::mms_static_control_read_unavailable, nullptr, false, nullptr, nullptr, false}; + 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; +} From d856cfe39271142d9725056fd19a4c71ece201d8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:33:21 +0700 Subject: [PATCH 05/14] Add standalone direct-control server build profile --- .../mms_direct_control_server/CMakeLists.txt | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tools/mms_direct_control_server/CMakeLists.txt diff --git a/tools/mms_direct_control_server/CMakeLists.txt b/tools/mms_direct_control_server/CMakeLists.txt new file mode 100644 index 000000000..79e2b976c --- /dev/null +++ b/tools/mms_direct_control_server/CMakeLists.txt @@ -0,0 +1,53 @@ +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" + "${ARSTACK_ROOT}/src/mms/static_direct_control.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) + +include(CTest) +enable_testing() + +add_executable(ariec61850_mms_direct_control_tests + "${ARSTACK_ROOT}/tests/test_mms_static_direct_control.cpp" + "${ARSTACK_ROOT}/src/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_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) From 5c36e45939c6ef1714324838d5542cf2564cfb01 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:33:31 +0700 Subject: [PATCH 06/14] Add Windows build helper for MMS control server --- scripts/build_mms_direct_control_server.ps1 | 28 +++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 scripts/build_mms_direct_control_server.ps1 diff --git a/scripts/build_mms_direct_control_server.ps1 b/scripts/build_mms_direct_control_server.ps1 new file mode 100644 index 000000000..52edf3c5e --- /dev/null +++ b/scripts/build_mms_direct_control_server.ps1 @@ -0,0 +1,28 @@ +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 + +cmake -S $Source -B $Build -DCMAKE_BUILD_TYPE=Release +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +cmake --build $Build --config Release --parallel +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +ctest --test-dir $Build -C Release --output-on-failure +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +$candidates = @( + (Join-Path $Build "Release\ariec61850_mms_direct_control_server.exe"), + (Join-Path $Build "ariec61850_mms_direct_control_server.exe") +) +$exe = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $exe) { + throw "Build succeeded but ariec61850_mms_direct_control_server.exe was not found." +} + +Write-Host "[OK] MMS Direct Control server: $exe" From 0ff1b1fcfb3e75c83ee8d71709ce479afcc6e784 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:33:40 +0700 Subject: [PATCH 07/14] Add cross-platform MMS direct-control server CI --- .../mms-server-direct-control-ci.yml | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/mms-server-direct-control-ci.yml 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 From 316e785912f277e0c3bc204fcc4f4dfcd27079fd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:34:02 +0700 Subject: [PATCH 08/14] Document MMS server direct-control trial --- docs/MMS_SERVER_DIRECT_CONTROL.md | 113 ++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 docs/MMS_SERVER_DIRECT_CONTROL.md 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. From d1f9914bd4892770d0cde0f06950fe907e99cd99 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:43:56 +0700 Subject: [PATCH 09/14] Reuse bounded server-side URCB report runtime --- .../mms/information_report_span.hpp | 64 ++ .../mms/static_information_report.hpp | 63 ++ .../mms/static_report_connection.hpp | 52 ++ .../ariec61850/mms/static_urcb_objects.hpp | 89 +++ .../ariec61850/mms/static_urcb_runtime.hpp | 192 ++++++ src/mms/information_report_span.cpp | 606 +++++++++++++++++ src/mms/static_information_report.cpp | 163 +++++ src/mms/static_report_connection.cpp | 215 ++++++ src/mms/static_urcb_objects.cpp | 630 ++++++++++++++++++ src/mms/static_urcb_runtime.cpp | 612 +++++++++++++++++ 10 files changed, 2686 insertions(+) create mode 100644 include/ariec61850/mms/information_report_span.hpp create mode 100644 include/ariec61850/mms/static_information_report.hpp create mode 100644 include/ariec61850/mms/static_report_connection.hpp create mode 100644 include/ariec61850/mms/static_urcb_objects.hpp create mode 100644 include/ariec61850/mms/static_urcb_runtime.hpp create mode 100644 src/mms/information_report_span.cpp create mode 100644 src/mms/static_information_report.cpp create mode 100644 src/mms/static_report_connection.cpp create mode 100644 src/mms/static_urcb_objects.cpp create mode 100644 src/mms/static_urcb_runtime.cpp diff --git a/include/ariec61850/mms/information_report_span.hpp b/include/ariec61850/mms/information_report_span.hpp new file mode 100644 index 000000000..c1d1ee1f6 --- /dev/null +++ b/include/ariec61850/mms/information_report_span.hpp @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/services_span.hpp" +#include "ariec61850/wire/encode_result.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::mms { + +struct MmsInformationReportReferenceInput final { + std::string_view domain; + std::string_view item; +}; + +struct MmsInformationReportSnapshotInput final { + std::string_view report_id; + std::span optional_fields; + std::uint32_t sequence_number{}; + std::span report_time; + MmsInformationReportReferenceInput data_set_reference{}; + bool buffered{}; + bool buffer_overflow{}; + std::span entry_id; + std::uint32_t conf_revision{}; + std::span member_references; + std::span member_results; + std::uint8_t reason_for_inclusion{}; +}; + +struct MmsInformationReportView final { + MmsObjectNameView variable_list_name{}; + std::span access_result_list{}; + std::size_t item_count{}; + + [[nodiscard]] bool try_item( + std::size_t index, + MmsReadAccessResultView& result) const noexcept; +}; + +class MmsInformationReportSpanCodec final { +public: + static constexpr std::size_t maximum_members = MmsServiceSpanCodec::maximum_variables; + static constexpr std::size_t maximum_reference_bytes = 129U; + static constexpr std::size_t maximum_report_id_bytes = 129U; + static constexpr std::size_t optional_field_bytes = 2U; + static constexpr std::size_t binary_time_bytes = 6U; + static constexpr std::size_t entry_id_bytes = 8U; + static constexpr std::size_t maximum_access_results = + (3U * maximum_members) + 16U; + + [[nodiscard]] static bool try_decode_information_report( + std::span mms_pdu, + MmsInformationReportView& report) noexcept; + + [[nodiscard]] static wire::EncodeResult encode_snapshot_into( + const MmsInformationReportSnapshotInput& report, + std::span destination) 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/src/mms/information_report_span.cpp b/src/mms/information_report_span.cpp new file mode 100644 index 000000000..85afbf644 --- /dev/null +++ b/src/mms/information_report_span.cpp @@ -0,0 +1,606 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/information_report_span.hpp" + +#include "ariec61850/asn1/ber_span_reader.hpp" +#include "ariec61850/asn1/ber_span_writer.hpp" +#include "ariec61850/mms/pdu_span.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::mms { +namespace { + +constexpr std::uint8_t kOptSequenceNumber = 0x40U; +constexpr std::uint8_t kOptReportTimeStamp = 0x20U; +constexpr std::uint8_t kOptReasonForInclusion = 0x10U; +constexpr std::uint8_t kOptDataSetName = 0x08U; +constexpr std::uint8_t kOptDataReference = 0x04U; +constexpr std::uint8_t kOptBufferOverflow = 0x02U; +constexpr std::uint8_t kOptEntryId = 0x01U; +constexpr std::uint8_t kOptConfRevision = 0x80U; +constexpr std::uint8_t kOptSegmentation = 0x40U; + +[[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 span_equals( + const std::span bytes, + const std::string_view text) noexcept { + if (bytes.size() != text.size()) { + return false; + } + for (std::size_t index = 0U; index < bytes.size(); ++index) { + if (bytes[index] != static_cast( + static_cast(text[index]))) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional add_size( + const std::optional left, + const std::optional right) noexcept { + if (!left || !right || *right > std::numeric_limits::max() - *left) { + return std::nullopt; + } + return *left + *right; +} + +[[nodiscard]] bool add_to( + std::size_t& total, + const std::optional value) noexcept { + if (!value || *value > std::numeric_limits::max() - total) { + return false; + } + total += *value; + return true; +} + +[[nodiscard]] std::size_t minimal_unsigned_size(std::uint32_t value) noexcept { + std::size_t bytes = 1U; + while (value > 0xFFU) { + ++bytes; + value >>= 8U; + } + return bytes; +} + +[[nodiscard]] bool needs_positive_prefix( + const std::uint32_t value, + const std::size_t minimal_bytes) noexcept { + const auto shift = static_cast((minimal_bytes - 1U) * 8U); + return ((value >> shift) & 0x80U) != 0U; +} + +[[nodiscard]] std::size_t positive_integer_size(const std::uint32_t value) noexcept { + const auto minimal = minimal_unsigned_size(value); + return minimal + (needs_positive_prefix(value, minimal) ? 1U : 0U); +} + +[[nodiscard]] bool write_unsigned_content( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto bytes = minimal_unsigned_size(value); + for (std::size_t index = bytes; index-- > 0U;) { + const auto shift = static_cast(index * 8U); + if (!writer.write_byte(static_cast((value >> shift) & 0xFFU))) { + return false; + } + } + return true; +} + +[[nodiscard]] bool write_positive_content( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto bytes = minimal_unsigned_size(value); + if (needs_positive_prefix(value, bytes) && !writer.write_byte(0x00U)) { + return false; + } + return write_unsigned_content(writer, value); +} + +[[nodiscard]] bool valid_mms_data_tlv(const asn1::BerTlvView& tlv) noexcept { + if (tlv.tag_class != asn1::BerClass::context_specific) { + return false; + } + if (tlv.tag_number == 1 || tlv.tag_number == 2) { + return tlv.constructed; + } + return tlv.tag_number >= 3 && tlv.tag_number <= 17 && !tlv.constructed; +} + +[[nodiscard]] bool decode_access_result( + const std::span encoded, + MmsReadAccessResultView& result) noexcept { + result = {}; + asn1::BerTlvView tlv; + if (!asn1::BerSpanReader::try_read_exact(encoded, tlv)) { + return false; + } + if (tlv.tag_class == asn1::BerClass::context_specific && + tlv.tag_number == 0 && !tlv.constructed) { + const auto failure = asn1::BerSpanReader::read_uint32(tlv); + if (!failure) { + return false; + } + result.success = false; + result.failure_code = *failure; + return true; + } + if (!valid_mms_data_tlv(tlv)) { + return false; + } + result.success = true; + result.encoded_data = encoded; + return true; +} + +[[nodiscard]] std::optional access_result_size( + const MmsReadAccessResultInput& result) noexcept { + if (result.success) { + asn1::BerTlvView data; + if (!asn1::BerSpanReader::try_read_exact(result.encoded_data, data) || + !valid_mms_data_tlv(data)) { + return std::nullopt; + } + return result.encoded_data.size(); + } + return asn1::BerSpanWriter::tlv_size(0, positive_integer_size(result.failure_code)); +} + +[[nodiscard]] bool write_access_result( + asn1::BerSpanWriter& writer, + const MmsReadAccessResultInput& result) noexcept { + if (result.success) { + return writer.write_bytes(result.encoded_data); + } + const auto content = positive_integer_size(result.failure_code); + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, content) && + write_positive_content(writer, result.failure_code); +} + +[[nodiscard]] std::optional reference_content_size( + const MmsInformationReportReferenceInput& reference) noexcept { + if (!visible_ascii(reference.domain, MmsServiceSpanCodec::maximum_identifier_bytes) || + !visible_ascii(reference.item, MmsServiceSpanCodec::maximum_identifier_bytes)) { + return std::nullopt; + } + if (reference.item.size() > + MmsInformationReportSpanCodec::maximum_reference_bytes - 1U || + reference.domain.size() > + MmsInformationReportSpanCodec::maximum_reference_bytes - 1U - reference.item.size()) { + return std::nullopt; + } + return reference.domain.size() + 1U + reference.item.size(); +} + +[[nodiscard]] bool write_reference_data( + asn1::BerSpanWriter& writer, + const MmsInformationReportReferenceInput& reference) noexcept { + const auto content = reference_content_size(reference); + if (!content || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 10, *content) || + !writer.write_bytes(std::span{ + reinterpret_cast(reference.domain.data()), + reference.domain.size()}) || + !writer.write_byte(static_cast('/')) || + !writer.write_bytes(std::span{ + reinterpret_cast(reference.item.data()), + reference.item.size()})) { + return false; + } + return true; +} + +[[nodiscard]] bool write_visible_data( + asn1::BerSpanWriter& writer, + const std::string_view text) noexcept { + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 10, text.size()) && + writer.write_bytes(std::span{ + reinterpret_cast(text.data()), text.size()}); +} + +[[nodiscard]] bool write_bit_string_data( + asn1::BerSpanWriter& writer, + const std::uint8_t unused_bits, + const std::span bytes) noexcept { + return unused_bits <= 7U && + writer.write_tlv_header( + asn1::BerClass::context_specific, false, 4, 1U + bytes.size()) && + writer.write_byte(unused_bits) && writer.write_bytes(bytes); +} + +[[nodiscard]] bool write_unsigned_data( + asn1::BerSpanWriter& writer, + const std::uint32_t value) noexcept { + const auto content = minimal_unsigned_size(value); + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 6, content) && + write_unsigned_content(writer, value); +} + +[[nodiscard]] bool write_boolean_data( + asn1::BerSpanWriter& writer, + const bool value) noexcept { + return writer.write_tlv_header( + asn1::BerClass::context_specific, false, 3, 1U) && + writer.write_byte(value ? 0x01U : 0x00U); +} + +[[nodiscard]] bool optional_selected( + const MmsInformationReportSnapshotInput& report, + const std::size_t byte_index, + const std::uint8_t mask) noexcept { + return report.optional_fields.size() > byte_index && + (report.optional_fields[byte_index] & mask) != 0U; +} + +[[nodiscard]] bool validate_report( + const MmsInformationReportSnapshotInput& report) noexcept { + if (!visible_ascii( + report.report_id, + MmsInformationReportSpanCodec::maximum_report_id_bytes) || + report.optional_fields.size() != + MmsInformationReportSpanCodec::optional_field_bytes || + report.member_results.empty() || + report.member_results.size() > MmsInformationReportSpanCodec::maximum_members || + report.member_references.size() != report.member_results.size()) { + return false; + } + if ((report.optional_fields[0] & 0x80U) != 0U || + (report.optional_fields[1] & kOptSegmentation) != 0U || + (report.optional_fields[1] & 0x3FU) != 0U) { + return false; + } + if (!reference_content_size(report.data_set_reference)) { + return false; + } + for (const auto& reference : report.member_references) { + if (!reference_content_size(reference)) { + return false; + } + } + if (optional_selected(report, 0U, kOptReportTimeStamp) && + report.report_time.size() != MmsInformationReportSpanCodec::binary_time_bytes) { + return false; + } + if ((optional_selected(report, 0U, kOptBufferOverflow) || + optional_selected(report, 0U, kOptEntryId)) && !report.buffered) { + return false; + } + if (optional_selected(report, 0U, kOptEntryId) && + report.entry_id.size() != MmsInformationReportSpanCodec::entry_id_bytes) { + return false; + } + if (optional_selected(report, 0U, kOptReasonForInclusion) && + (report.reason_for_inclusion & 0x03U) != 0U) { + return false; + } + for (const auto& result : report.member_results) { + if (!access_result_size(result)) { + return false; + } + } + return true; +} + +[[nodiscard]] std::optional access_list_content_size( + const MmsInformationReportSnapshotInput& report) noexcept { + std::size_t total = 0U; + if (!add_to(total, asn1::BerSpanWriter::tlv_size(10, report.report_id.size())) || + !add_to(total, asn1::BerSpanWriter::tlv_size( + 4, 1U + report.optional_fields.size()))) { + return std::nullopt; + } + + if (optional_selected(report, 0U, kOptSequenceNumber) && + !add_to(total, asn1::BerSpanWriter::tlv_size( + 6, minimal_unsigned_size(report.sequence_number)))) { + return std::nullopt; + } + if (optional_selected(report, 0U, kOptReportTimeStamp) && + !add_to(total, asn1::BerSpanWriter::tlv_size(12, report.report_time.size()))) { + return std::nullopt; + } + if (optional_selected(report, 0U, kOptDataSetName)) { + const auto content = reference_content_size(report.data_set_reference); + if (!content || !add_to(total, asn1::BerSpanWriter::tlv_size(10, *content))) { + return std::nullopt; + } + } + if (optional_selected(report, 0U, kOptBufferOverflow) && + !add_to(total, asn1::BerSpanWriter::tlv_size(3, 1U))) { + return std::nullopt; + } + if (optional_selected(report, 0U, kOptEntryId) && + !add_to(total, asn1::BerSpanWriter::tlv_size(9, report.entry_id.size()))) { + return std::nullopt; + } + if (optional_selected(report, 1U, kOptConfRevision) && + !add_to(total, asn1::BerSpanWriter::tlv_size( + 6, minimal_unsigned_size(report.conf_revision)))) { + return std::nullopt; + } + + const auto inclusion_bytes = (report.member_results.size() + 7U) / 8U; + if (!add_to(total, asn1::BerSpanWriter::tlv_size(4, 1U + inclusion_bytes))) { + return std::nullopt; + } + + if (optional_selected(report, 0U, kOptDataReference)) { + for (const auto& reference : report.member_references) { + const auto content = reference_content_size(reference); + if (!content || !add_to(total, asn1::BerSpanWriter::tlv_size(10, *content))) { + return std::nullopt; + } + } + } + + for (const auto& result : report.member_results) { + if (!add_to(total, access_result_size(result))) { + return std::nullopt; + } + } + + if (optional_selected(report, 0U, kOptReasonForInclusion)) { + const auto reason_tlv = asn1::BerSpanWriter::tlv_size(4, 2U); + for (std::size_t index = 0U; index < report.member_results.size(); ++index) { + if (!add_to(total, reason_tlv)) { + return std::nullopt; + } + } + } + return total; +} + +[[nodiscard]] bool write_inclusion( + asn1::BerSpanWriter& writer, + const std::size_t member_count) noexcept { + const auto bytes = (member_count + 7U) / 8U; + const auto unused = static_cast((bytes * 8U) - member_count); + if (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 4, 1U + bytes) || + !writer.write_byte(unused)) { + return false; + } + for (std::size_t index = 0U; index < bytes; ++index) { + const auto first_member = index * 8U; + const auto remaining = member_count - first_member; + const auto used = remaining >= 8U ? 8U : remaining; + const auto value = used == 8U + ? std::uint8_t{0xFFU} + : static_cast(0xFFU << (8U - used)); + if (!writer.write_byte(value)) { + return false; + } + } + return true; +} + +} // namespace + +bool MmsInformationReportView::try_item( + const std::size_t index, + MmsReadAccessResultView& result) const noexcept { + result = {}; + if (index >= item_count) { + return false; + } + std::size_t offset = 0U; + std::size_t current = 0U; + while (offset < access_result_list.size()) { + const auto start = offset; + asn1::BerTlvView item; + if (!asn1::BerSpanReader::try_read_tlv(access_result_list, offset, item)) { + return false; + } + if (current == index) { + return decode_access_result( + access_result_list.subspan(start, offset - start), result); + } + ++current; + } + return false; +} + +bool MmsInformationReportSpanCodec::try_decode_information_report( + const std::span mms_pdu, + MmsInformationReportView& report) noexcept { + report = {}; + asn1::BerTlvView outer; + if (!asn1::BerSpanReader::try_read_exact(mms_pdu, outer) || + outer.tag_class != asn1::BerClass::context_specific || + outer.tag_number != 3 || !outer.constructed) { + return false; + } + + asn1::BerTlvView service; + if (!asn1::BerSpanReader::try_read_exact(outer.value, service) || + service.tag_class != asn1::BerClass::context_specific || + service.tag_number != 0 || !service.constructed) { + return false; + } + + std::size_t offset = 0U; + asn1::BerTlvView variable_access; + asn1::BerTlvView access_results; + if (!asn1::BerSpanReader::try_read_tlv(service.value, offset, variable_access) || + !asn1::BerSpanReader::try_read_tlv(service.value, offset, access_results) || + offset != service.value.size() || + variable_access.tag_class != asn1::BerClass::context_specific || + variable_access.tag_number != 1 || !variable_access.constructed || + access_results.tag_class != asn1::BerClass::context_specific || + access_results.tag_number != 0 || !access_results.constructed) { + return false; + } + + MmsObjectNameView list_name; + if (!MmsServiceSpanCodec::try_decode_object_name_view( + variable_access.value, list_name) || + list_name.kind != MmsObjectNameViewKind::vmd_specific || + !span_equals(list_name.item, "RPT")) { + return false; + } + + std::size_t result_offset = 0U; + std::size_t count = 0U; + while (result_offset < access_results.value.size()) { + if (count >= maximum_access_results) { + return false; + } + const auto start = result_offset; + asn1::BerTlvView item; + if (!asn1::BerSpanReader::try_read_tlv( + access_results.value, result_offset, item)) { + return false; + } + MmsReadAccessResultView decoded; + if (!decode_access_result( + access_results.value.subspan(start, result_offset - start), decoded)) { + return false; + } + ++count; + } + if (count == 0U) { + return false; + } + + report.variable_list_name = list_name; + report.access_result_list = access_results.value; + report.item_count = count; + return true; +} + +wire::EncodeResult MmsInformationReportSpanCodec::encode_snapshot_into( + const MmsInformationReportSnapshotInput& report, + const std::span destination) noexcept { + if (!validate_report(report)) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + + const auto access_content = access_list_content_size(report); + const auto access_tlv = access_content + ? asn1::BerSpanWriter::tlv_size(0, *access_content) + : std::nullopt; + const auto vmd_name_tlv = asn1::BerSpanWriter::tlv_size(0, 3U); + const auto variable_access_tlv = vmd_name_tlv + ? asn1::BerSpanWriter::tlv_size(1, *vmd_name_tlv) + : std::nullopt; + const auto information_content = add_size(variable_access_tlv, access_tlv); + const auto service_tlv = information_content + ? asn1::BerSpanWriter::tlv_size(0, *information_content) + : std::nullopt; + const auto required = service_tlv + ? asn1::BerSpanWriter::tlv_size(3, *service_tlv) + : std::nullopt; + if (!access_content || !access_tlv || !vmd_name_tlv || + !variable_access_tlv || !information_content || !service_tlv || !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, 3, *service_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *information_content) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 1, *vmd_name_tlv) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, false, 0, 3U) || + !writer.write_bytes(std::span{ + reinterpret_cast("RPT"), 3U}) || + !writer.write_tlv_header( + asn1::BerClass::context_specific, true, 0, *access_content) || + !write_visible_data(writer, report.report_id) || + !write_bit_string_data(writer, 6U, report.optional_fields)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + if (optional_selected(report, 0U, kOptSequenceNumber) && + !write_unsigned_data(writer, report.sequence_number)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptReportTimeStamp) && + (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 12, report.report_time.size()) || + !writer.write_bytes(report.report_time))) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptDataSetName) && + !write_reference_data(writer, report.data_set_reference)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptBufferOverflow) && + !write_boolean_data(writer, report.buffer_overflow)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 0U, kOptEntryId) && + (!writer.write_tlv_header( + asn1::BerClass::context_specific, false, 9, report.entry_id.size()) || + !writer.write_bytes(report.entry_id))) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (optional_selected(report, 1U, kOptConfRevision) && + !write_unsigned_data(writer, report.conf_revision)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + if (!write_inclusion(writer, report.member_results.size())) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + + if (optional_selected(report, 0U, kOptDataReference)) { + for (const auto& reference : report.member_references) { + if (!write_reference_data(writer, reference)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + } + for (const auto& result : report.member_results) { + if (!write_access_result(writer, result)) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + if (optional_selected(report, 0U, kOptReasonForInclusion)) { + const std::uint8_t reason = report.reason_for_inclusion; + for (std::size_t index = 0U; index < report.member_results.size(); ++index) { + if (!write_bit_string_data( + writer, 2U, std::span{&reason, 1U})) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + } + } + + if (writer.size() != *required) { + return {wire::EncodeStatus::value_out_of_range, 0U, *required}; + } + return {wire::EncodeStatus::ok, *required, *required}; +} + +} // 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 From ced71f71bf5fa80f0eb56e57b5fd8d9c4f151d07 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:45:33 +0700 Subject: [PATCH 10/14] Add server URCB report regression coverage --- tests/test_mms_server_urcb_report.cpp | 309 ++++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 tests/test_mms_server_urcb_report.cpp 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; +} From 02478b403d801b81c2117541a82e7b2081f57449 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:47:59 +0700 Subject: [PATCH 11/14] Add runnable static URCB InformationReport server --- tools/mms_urcb_server.cpp | 522 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 522 insertions(+) create mode 100644 tools/mms_urcb_server.cpp diff --git a/tools/mms_urcb_server.cpp b/tools/mms_urcb_server.cpp new file mode 100644 index 000000000..8c00daccf --- /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.bytes == 0U) return false; + offset += result.bytes; + } + 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; +} From 3c3e7cd8a40524f87c585968f22e0fc744bb1abf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:48:16 +0700 Subject: [PATCH 12/14] Wire server-side URCB reporting into standalone CI --- .../mms_direct_control_server/CMakeLists.txt | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tools/mms_direct_control_server/CMakeLists.txt b/tools/mms_direct_control_server/CMakeLists.txt index 79e2b976c..0a590966c 100644 --- a/tools/mms_direct_control_server/CMakeLists.txt +++ b/tools/mms_direct_control_server/CMakeLists.txt @@ -21,6 +21,13 @@ function(server_strict_warnings target) endif() endfunction() +set(SERVER_REPORT_SOURCES + "${ARSTACK_ROOT}/src/mms/information_report_span.cpp" + "${ARSTACK_ROOT}/src/mms/static_information_report.cpp" + "${ARSTACK_ROOT}/src/mms/static_report_connection.cpp" + "${ARSTACK_ROOT}/src/mms/static_urcb_objects.cpp" + "${ARSTACK_ROOT}/src/mms/static_urcb_runtime.cpp") + add_executable(ariec61850_mms_direct_control_server "${ARSTACK_ROOT}/tools/mms_direct_control_server.cpp" "${ARSTACK_ROOT}/src/mms/static_direct_control.cpp") @@ -35,6 +42,20 @@ 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" + ${SERVER_REPORT_SOURCES}) +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() @@ -49,5 +70,18 @@ target_link_libraries(ariec61850_mms_direct_control_tests PRIVATE 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" + ${SERVER_REPORT_SOURCES}) +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) From 6d811453e6e66143da9fe6b0cdf94b8529917329 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:48:45 +0700 Subject: [PATCH 13/14] Expose both MMS server trial executables --- scripts/build_mms_direct_control_server.ps1 | 23 ++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/scripts/build_mms_direct_control_server.ps1 b/scripts/build_mms_direct_control_server.ps1 index 52edf3c5e..d8c5f9be9 100644 --- a/scripts/build_mms_direct_control_server.ps1 +++ b/scripts/build_mms_direct_control_server.ps1 @@ -16,13 +16,22 @@ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } ctest --test-dir $Build -C Release --output-on-failure if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } -$candidates = @( - (Join-Path $Build "Release\ariec61850_mms_direct_control_server.exe"), - (Join-Path $Build "ariec61850_mms_direct_control_server.exe") -) -$exe = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 -if (-not $exe) { +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: $exe" +Write-Host "[OK] MMS Direct Control server: $controlExe" +Write-Host "[OK] MMS URCB Reporting server: $urcbExe" From c776e2fa3d35f587fa3bb52ad242b5e92de6eb50 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 11 Aug 2026 07:49:03 +0700 Subject: [PATCH 14/14] Document static URCB InformationReport server trial --- docs/MMS_SERVER_URCB_REPORT.md | 107 +++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/MMS_SERVER_URCB_REPORT.md 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.