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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion include/ariec61850/mms/association_runtime.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,11 @@ class MmsAssociationRuntime final {
[[nodiscard]] MmsConfirmedExchangeResult exchange_confirmed(
std::span<const std::uint8_t> presentation_payload,
std::uint32_t expected_invoke_id,
std::stop_token stop_token = {});
std::stop_token stop_token = {},
// Optional per-call receipt, independent of the bounded event log.
// True only when the complete confirmed request was sent to transport.
// A response timeout after a successful send still leaves this true.
bool* request_sent = nullptr);

[[nodiscard]] MmsPduEnvelope poll_once(
std::stop_token stop_token = {});
Expand Down
17 changes: 17 additions & 0 deletions include/ariec61850/mms/live_discovery.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,26 @@ struct MmsReportControlEvidence final {
[[nodiscard]] bool success() const noexcept { return state.has_value(); }
};

// Completed outbound confirmed MMS request sends (including responses that
// later fail or time out). Pre-send failures and post-disconnect probes do not
// count. Pure accounting: no extra MMS service or scheduling side effects.
struct MmsLiveDiscoveryWireBudget final {
std::size_t get_name_list{};
std::size_t get_variable_access_attributes{};
std::size_t get_named_variable_list_attributes{};
std::size_t read_report_controls{};

[[nodiscard]] std::size_t total() const noexcept {
return get_name_list + get_variable_access_attributes +
get_named_variable_list_attributes + read_report_controls;
}
};

struct MmsLiveDiscoveryResult final {
MmsEndpoint endpoint;
std::string association_profile;
std::vector<MmsAssociationAttemptEvidence> association_attempts;
MmsLiveDiscoveryWireBudget wire_budget;
MmsDiscoverySnapshot names;
MmsReportInventory report_inventory;
std::vector<MmsVariableTypeEvidence> variable_types;
Expand Down Expand Up @@ -116,6 +132,7 @@ class MmsLiveDiscoveryClient final {
MmsGetNameListObjectClass object_class,
const std::string& domain,
const MmsLiveDiscoveryOptions& options,
MmsLiveDiscoveryWireBudget& wire_budget,
std::stop_token stop_token);

MmsAssociationRuntime& association_;
Expand Down
5 changes: 4 additions & 1 deletion src/mms/association_runtime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -668,7 +668,9 @@ void MmsAssociationRuntime::disconnect(const std::stop_token stop_token) noexcep
MmsConfirmedExchangeResult MmsAssociationRuntime::exchange_confirmed(
const std::span<const std::uint8_t> presentation_payload,
const std::uint32_t expected_invoke_id,
const std::stop_token stop_token) {
const std::stop_token stop_token,
bool* const request_sent) {
if (request_sent != nullptr) *request_sent = false;
require_associated();
if (expected_invoke_id == 0U ||
expected_invoke_id > MmsPduCodec::maximum_invoke_id) {
Expand All @@ -689,6 +691,7 @@ MmsConfirmedExchangeResult MmsAssociationRuntime::exchange_confirmed(
const auto deadline = deadline_after(options_.request_timeout);
try {
send_application_payload(presentation_payload, deadline, stop_token);
if (request_sent != nullptr) *request_sent = true;
add_event(MmsAssociationEventKind::confirmed_request_sent,
"Confirmed MMS request sent.", expected_invoke_id);

Expand Down
50 changes: 41 additions & 9 deletions src/mms/live_discovery.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -266,13 +266,39 @@ std::string MmsLiveDiscoveryResult::summary() const {
return stream.str();
}

namespace {

// Capture the per-exchange send receipt even when the response times out.
// This scoped counter is independent of shared event history and keeps the
// correct service category if a later optional probe fails before sending.
struct ConfirmedSendCount final {
std::size_t& service_count;
bool sent{};
~ConfirmedSendCount() {
if (sent) ++service_count;
}
};

[[nodiscard]] MmsConfirmedExchangeResult exchange_counted(
MmsAssociationRuntime& association,
const std::span<const std::uint8_t> request,
const std::uint32_t invoke_id,
const std::stop_token stop_token,
std::size_t& service_count) {
ConfirmedSendCount receipt{service_count};
return association.exchange_confirmed(request, invoke_id, stop_token, &receipt.sent);
}

} // namespace

MmsLiveDiscoveryClient::MmsLiveDiscoveryClient(MmsAssociationRuntime& association)
: association_{association} {}

std::vector<std::string> MmsLiveDiscoveryClient::get_name_list(
const MmsGetNameListObjectClass object_class,
const std::string& domain,
const MmsLiveDiscoveryOptions& options,
MmsLiveDiscoveryWireBudget& wire_budget,
const std::stop_token stop_token) {
std::vector<std::string> names;
std::set<std::string, std::less<>> seen;
Expand All @@ -290,8 +316,9 @@ std::vector<std::string> MmsLiveDiscoveryClient::get_name_list(

const auto encoded = MmsServiceCodec::encode_get_name_list_request_p_data(
request, association_.negotiated().presentation_context_id);
const auto exchange = association_.exchange_confirmed(
encoded, request.invoke_id, stop_token);
const auto exchange = exchange_counted(
association_, encoded, request.invoke_id, stop_token,
wire_budget.get_name_list);
const auto payload = response_payload(exchange, "GetNameList");
const auto response = MmsServiceCodec::decode_get_name_list_response(
payload, request.invoke_id);
Expand Down Expand Up @@ -342,7 +369,7 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover(
result.association_attempts = association_.association_attempts();

const auto domains = get_name_list(
MmsGetNameListObjectClass::domain, {}, options, stop_token);
MmsGetNameListObjectClass::domain, {}, options, result.wire_budget, stop_token);
if (domains.size() > options.maximum_domains) {
throw MmsLiveDiscoveryError(
"Domain discovery exceeded the configured domain bound.");
Expand All @@ -356,6 +383,7 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover(
MmsGetNameListObjectClass::named_variable,
domain,
options,
result.wire_budget,
stop_token));
} catch (const std::exception& exception) {
if (!options.continue_on_optional_probe_error) {
Expand All @@ -373,6 +401,7 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover(
MmsGetNameListObjectClass::named_variable_list,
domain,
options,
result.wire_budget,
stop_token));
} catch (const std::exception& exception) {
if (!options.continue_on_optional_probe_error) {
Expand Down Expand Up @@ -403,8 +432,9 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover(
evidence.candidate.reference);
const auto encoded = MmsDataSetDirectoryCodec::encode_request_p_data(
request, association_.negotiated().presentation_context_id);
const auto exchange = association_.exchange_confirmed(
encoded, request.invoke_id, stop_token);
const auto exchange = exchange_counted(
association_, encoded, request.invoke_id, stop_token,
result.wire_budget.get_named_variable_list_attributes);
const auto payload = response_payload(
exchange, "GetNamedVariableListAttributes");
evidence.directory = MmsDataSetDirectoryCodec::decode_response(
Expand Down Expand Up @@ -452,8 +482,9 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover(
encode_variable_access_attributes_request_p_data(
request,
association_.negotiated().presentation_context_id);
const auto exchange = association_.exchange_confirmed(
encoded, request.invoke_id, stop_token);
const auto exchange = exchange_counted(
association_, encoded, request.invoke_id, stop_token,
result.wire_budget.get_variable_access_attributes);
const auto payload = response_payload(
exchange, "GetVariableAccessAttributes");
evidence.attributes = MmsServiceCodec::
Expand Down Expand Up @@ -509,8 +540,9 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover(
evidence.requested_attributes);
const auto encoded = MmsServiceCodec::encode_read_request_p_data(
request, association_.negotiated().presentation_context_id);
const auto exchange = association_.exchange_confirmed(
encoded, invoke_id, stop_token);
const auto exchange = exchange_counted(
association_, encoded, invoke_id, stop_token,
result.wire_budget.read_report_controls);
const auto payload = response_payload(
exchange, "Read RCB attributes");
const auto response = MmsServiceCodec::decode_read_response(
Expand Down
55 changes: 55 additions & 0 deletions tests/test_live_discovery.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,11 @@ void live_discovery_builds_csharp_compatible_read_only_model() {
++read_only_requests;
}
CHECK(read_only_requests == 6U);
CHECK(result.wire_budget.get_name_list == 3U);
CHECK(result.wire_budget.get_variable_access_attributes == 1U);
CHECK(result.wire_budget.get_named_variable_list_attributes == 1U);
CHECK(result.wire_budget.read_report_controls == 1U);
CHECK(result.wire_budget.total() == read_only_requests);
}


Expand Down Expand Up @@ -628,6 +633,55 @@ void verified_data_set_hints_restore_late_ln_type_schema() {
}
CHECK(found_cswi);
CHECK(transport.sent().size() == 8U); // association (2) + 6 reads
CHECK(result.wire_budget.get_name_list == 3U);
CHECK(result.wire_budget.get_named_variable_list_attributes == 1U);
CHECK(result.wire_budget.get_variable_access_attributes == 2U);
CHECK(result.wire_budget.read_report_controls == 0U);
CHECK(result.wire_budget.total() == 6U);
}

void disconnected_optional_probes_do_not_inflate_wire_budget() {
ScriptedTransport transport;
queue_handshake(transport);
transport.push_receive(wrap_application(
mms::MmsServiceCodec::encode_get_name_list_response_p_data(
{1U, {"OLS501LD0"}, false})));
transport.push_receive(wrap_application(
mms::MmsServiceCodec::encode_get_name_list_response_p_data(
{2U, {"LLN0$ST$Mod$stVal", "LLN0$RP$urcbA01$RptEna"}, false})));
transport.push_receive(wrap_application(
mms::MmsServiceCodec::encode_get_name_list_response_p_data(
{3U, {"LLN0$DataSetA"}, false})));
// Do not enqueue an answer to the GVA request: the exchange sends one
// confirmed request, then times out and closes this association.
// DataSet and RCB optional phases continue but can no longer send.
mms::MmsAssociationOptions transportOptions;
transportOptions.maximum_events = 1U; // Bounded events must not affect accounting.
mms::MmsAssociationRuntime association{transport, transportOptions};
association.connect({"127.0.0.1", 102U});

mms::MmsLiveDiscoveryOptions options;
options.maximum_variable_type_probes = 1U;
options.maximum_data_set_directories = 1U;
options.maximum_report_control_probes = 1U;
options.continue_on_optional_probe_error = true;
const auto result = mms::MmsLiveDiscoveryClient{association}.discover(options);

CHECK(!association.associated());
CHECK(result.partial());
CHECK(result.domain_count() == 1U);
CHECK(result.wire_budget.get_name_list == 3U);
CHECK(result.wire_budget.get_variable_access_attributes == 1U);
CHECK(result.wire_budget.get_named_variable_list_attributes == 0U);
CHECK(result.wire_budget.read_report_controls == 0U);
CHECK(result.wire_budget.total() == 4U);
CHECK(transport.sent().size() == 6U); // 2 handshake + 4 actual requests
CHECK(result.variable_types.size() == 1U);
CHECK(!result.variable_types.front().success());
CHECK(result.data_set_directories.size() == 1U);
CHECK(!result.data_set_directories.front().success());
CHECK(result.report_controls.size() == 1U);
CHECK(!result.report_controls.front().success());
}

void pagination_without_forward_progress_is_rejected() {
Expand Down Expand Up @@ -661,6 +715,7 @@ int main() {
case_distinct_references_are_not_deduplicated();
case_distinct_ln_type_probes_are_preserved();
verified_data_set_hints_restore_late_ln_type_schema();
disconnected_optional_probes_do_not_inflate_wire_budget();
pagination_without_forward_progress_is_rejected();
std::cout << "Live MMS discovery and model parity tests passed.\n";
return 0;
Expand Down
Loading