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
23 changes: 23 additions & 0 deletions docs/tutorials/plugin-system.rst
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,29 @@ New entities in ``new_entities`` only appear in responses when
``allow_new_entities`` is true in the plugin configuration (or an equivalent
policy is set).

If the plugin publishes ROS 2 topics that back an entity's data points (the
PLC-bridge pattern: values mirrored to ``/plc/...`` topics), declare them on
the entity via ``App::topics`` / ``Component::topics``. This works for
entities the plugin itself adds in ``new_entities``. It does NOT work as an
enrichment of an app that runtime discovery already found: for live-data
fields the runtime layer is authoritative and the plugin layer is enrichment,
so a ``topics`` list declared on a shadow of a runtime-discovered app is
dropped by the merge. If you need extra topics on a runtime-discovered app,
bind them through a manifest instead:

.. code-block:: cpp

App app;
app.id = "my_device";
app.topics.publishes.push_back("/plc/main/counter");

Runtime graph discovery attributes a topic to the ROS node that publishes it -
for plugin-published topics that is the gateway's own node, not the plugin
entity. Without the declaration, entity-scoped topic lookups on the plugin
entity return nothing, and in particular data triggers
(``POST /apps/<id>/triggers`` with a ``data/...`` resource) can never resolve
and never fire. Declare only topics that actually get a publisher.

ScriptProvider Example
----------------------

Expand Down
4 changes: 4 additions & 0 deletions src/ros2_medkit_gateway/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,10 @@ if(BUILD_TESTING)
ament_add_gtest(test_fault_trigger_engine test/test_fault_trigger_engine.cpp)
target_link_libraries(test_fault_trigger_engine gateway_core)

# Data-point suggestion helpers (issue #584): header-only, ROS-neutral.
ament_add_gtest(test_data_point_suggest test/test_data_point_suggest.cpp)
target_link_libraries(test_data_point_suggest gateway_core)

# Peer client tests (aggregation module)
ament_add_gtest(test_peer_client test/test_peer_client.cpp)
target_link_libraries(test_peer_client gateway_ros2)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Copyright 2026 mfaferek93
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#pragma once

// Suggestion helpers for "data point does not exist" errors. PLC plugins
// register symbols under a sanitized leaf name (MAIN.counter -> counter), so
// an operator typing the source notation gets no hit; these helpers recover
// the intended name instead of dumping an alphabetical prefix of ~200 symbols.

#include <algorithm>
#include <cctype>
#include <cstdint>
#include <string>
#include <vector>

namespace ros2_medkit_gateway {

inline size_t edit_distance(const std::string & a, const std::string & b) {
std::vector<size_t> prev(b.size() + 1), cur(b.size() + 1);
for (size_t j = 0; j <= b.size(); ++j) {
prev[j] = j;
}
for (size_t i = 1; i <= a.size(); ++i) {
cur[0] = i;
for (size_t j = 1; j <= b.size(); ++j) {
const size_t sub = prev[j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1);
cur[j] = std::min({prev[j] + 1, cur[j - 1] + 1, sub});
}
std::swap(prev, cur);
}
return prev[b.size()];
}

/// MAIN.counter -> counter: leaf after the last '.' or '/', lowercased with
/// non-alphanumerics mapped to '_' - the same shape the PLC bridges produce
/// when they sanitize a symbol into a data point id.
inline std::string sanitized_leaf(const std::string & input) {
const auto pos = input.find_last_of("./");
std::string leaf = (pos == std::string::npos) ? input : input.substr(pos + 1);
std::string out;
out.reserve(leaf.size());
for (char c : leaf) {
if (std::isalnum(static_cast<unsigned char>(c))) {
out += static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
} else if (c == '_' || c == '-') {
out += c;
} else {
out += '_';
}
}
return out;
}

/// Best existing name for a miss, or empty when nothing is close. The
/// sanitized leaf wins exactly (the deterministic mapping); otherwise the
/// closest name within an input-length-scaled edit distance budget.
inline std::string suggest_data_point(const std::string & input, const std::vector<std::string> & names) {
const std::string leaf = sanitized_leaf(input);
if (!leaf.empty() && std::find(names.begin(), names.end(), leaf) != names.end()) {
return leaf;
}
// Each candidate distance gets the budget of the string it was measured
// against: a long namespace prefix must not buy a short leaf a huge budget
// (MAIN.Very.Long.Prefix.rpm would otherwise "suggest" whatever is nearest
// to a three-letter leaf).
const size_t input_budget = std::max<size_t>(2, input.size() / 4);
const size_t leaf_budget = std::max<size_t>(2, leaf.size() / 4);
size_t best_score = SIZE_MAX;
std::string best;
for (const auto & name : names) {
const size_t d_input = edit_distance(input, name);
size_t score = d_input <= input_budget ? d_input : SIZE_MAX;
if (!leaf.empty()) {
const size_t d_leaf = edit_distance(leaf, name);
if (d_leaf <= leaf_budget && d_leaf < score) {
score = d_leaf;
}
}
if (score < best_score || (score == best_score && score != SIZE_MAX && name < best)) {
best_score = score;
best = name;
}
}
return best_score != SIZE_MAX ? best : std::string{};
}

/// Up to n names ordered by edit distance to the input (ties alphabetical),
/// so a truncated "available:" list shows the relevant neighborhood instead
/// of an alphabetical prefix.
inline std::vector<std::string> closest_data_points(const std::string & input, const std::vector<std::string> & names,
size_t n) {
const std::string leaf = sanitized_leaf(input);
std::vector<std::pair<size_t, std::string>> ranked;
ranked.reserve(names.size());
for (const auto & name : names) {
size_t d = edit_distance(input, name);
if (!leaf.empty()) {
d = std::min(d, edit_distance(leaf, name));
}
ranked.emplace_back(d, name);
}
std::sort(ranked.begin(), ranked.end());
std::vector<std::string> out;
out.reserve(std::min(n, ranked.size()));
for (size_t i = 0; i < ranked.size() && i < n; ++i) {
out.push_back(ranked[i].second);
}
return out;
}

} // namespace ros2_medkit_gateway
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,15 @@ class TriggerManager {
/// Set the topic name resolver. Called by GatewayNode after cache is available.
void set_resolve_topic_fn(ResolveTopicFn fn);

/// Operator-facing warning sink (transport-agnostic; GatewayNode wires it to
/// the ROS logger). Used when deferred resolution gives up on a trigger.
using WarnLogFn = std::function<void(const std::string & message)>;
void set_warn_log_fn(WarnLogFn fn);

/// How long deferred resolution keeps retrying before giving up.
/// Default 60 s; tests shrink it to exercise the expiry path.
void set_unresolved_timeout(std::chrono::seconds timeout);

/// Retry resolving data triggers whose topic names were unknown at creation.
/// Called periodically (today: from the rclcpp adapter's retry tick) so
/// that triggers stuck without a topic name get a chance to subscribe.
Expand Down Expand Up @@ -293,7 +302,8 @@ class TriggerManager {
};
std::vector<UnresolvedTrigger> unresolved_data_triggers_; // guarded by triggers_mutex_
ResolveTopicFn resolve_topic_fn_; // guarded by triggers_mutex_
static constexpr int kUnresolvedTimeoutSec = 60;
WarnLogFn warn_log_fn_; // guarded by triggers_mutex_
std::chrono::seconds unresolved_timeout_{60}; // guarded by triggers_mutex_
};

} // namespace ros2_medkit_gateway
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,20 @@ class PluginManager : public LogProviderRegistry {
* thread-safe (already the contract across concurrent HTTP workers; this
* entry point just adds one more caller).
*
* @param item optional data point name; when non-empty the single-item
* route (x-plc-data/<item>) is dispatched instead of the list.
*
* @return Parsed JSON body on a 200 response; nullopt when the entity is
* not plugin-owned, no route matches, or the handler fails.
*/
std::optional<nlohmann::json> fetch_entity_data_via_route(const std::string & entity_id);
std::optional<nlohmann::json> fetch_entity_data_via_route(const std::string & entity_id,
const std::string & item = "");

/// Whether the entity's owning plugin registered a GET data route
/// (x-plc-data) that fetch_entity_data_via_route() could dispatch. Cheap
/// route-table check, no handler invocation - capability advertising uses
/// it so /data is only announced where a read can actually be served.
bool has_entity_data_route(const std::string & entity_id);

/// Check if an entity is owned by a plugin
/// @return Plugin name if owned, nullopt otherwise
Expand Down
27 changes: 19 additions & 8 deletions src/ros2_medkit_gateway/src/core/fault_trigger_engine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
#include <fstream>
#include <utility>

#include "ros2_medkit_gateway/core/data_point_suggest.hpp"

namespace ros2_medkit_gateway {

namespace {
Expand Down Expand Up @@ -150,17 +152,26 @@ tl::expected<FaultTriggerRule, std::pair<int, std::string>> FaultTriggerEngine::
if (data_point_names_) {
const auto names = data_point_names_(rule.app_id);
if (names.has_value() && std::find(names->begin(), names->end(), rule.data_name) == names->end()) {
std::string available;
// ~200 PLC symbols make an alphabetical prefix useless: rank by edit
// distance so the neighborhood of the typo is what gets listed, and
// resolve the deterministic MAIN.counter -> counter mapping outright.
constexpr size_t kMaxListed = 20;
for (size_t i = 0; i < names->size() && i < kMaxListed; ++i) {
available += (i == 0 ? "" : ", ") + (*names)[i];
const auto listed = closest_data_points(rule.data_name, *names, kMaxListed);
std::string available;
for (size_t i = 0; i < listed.size(); ++i) {
available += (i == 0 ? "" : ", ") + listed[i];
}
std::string msg = "data point '" + rule.data_name + "' does not exist on app '" + rule.app_id + "'";
const std::string suggestion = suggest_data_point(rule.data_name, *names);
if (!suggestion.empty()) {
msg += " - did you mean '" + suggestion + "'?";
}
if (names->size() > kMaxListed) {
available += ", ...";
if (!available.empty()) {
msg += names->size() > kMaxListed ? " (" + std::to_string(names->size()) + " available, closest " +
std::to_string(listed.size()) + ": " + available + ")"
: " (available: " + available + ")";
}
return tl::make_unexpected(
std::make_pair(400, "data point '" + rule.data_name + "' does not exist on app '" + rule.app_id + "'" +
(available.empty() ? std::string{} : " (available: " + available + ")")));
return tl::make_unexpected(std::make_pair(400, std::move(msg)));
}
}

Expand Down
Loading
Loading