Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 4 additions & 0 deletions src/ros2_medkit_gateway/CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
Changelog for package ros2_medkit_gateway
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Forthcoming
-----------
* Manual asset inventory: a manifest ``assets:`` list and a new ``discovery.inventory.csv_path`` parameter declare assets that no protocol layer can describe (or fully describe). The CSV recognizes the canonical columns ``id, manufacturer, model, serial, hardware_rev, firmware, endpoint, role`` (plus common aliases) and keeps any other column as an extra; RFC-4180-style quoting is honored and rows without an ``id`` are skipped with a warning. Each asset becomes a Component with ``source = "inventory"`` and a structured asset identity carrying per-field provenance, appended to the base manifest on every load / reload and merged into the tree by id alongside protocol-discovered structure. The CSV is size-capped at 1 MiB before being read; a missing file is skipped with a warning (mirrors ``fragments_dir``), while an unreadable or malformed one fails the load / reload. Requires a manifest-backed discovery mode (``manifest_only`` / ``hybrid`` with ``discovery.manifest_path`` set); empty = disabled (default) (`#490 <https://github.com/selfpatch/ros2_medkit/issues/490>`_)

0.6.0 (2026-06-22)
------------------
* SOVD entity status and lifecycle control endpoints: ``GET /apps/{id}/status`` and ``GET /components/{id}/status``, plus lifecycle control routes backed by a new ``LifecycleProvider`` plugin interface and plugin-manager routing. Control returns ``501 Not Implemented`` until a provider is registered; the routes are RBAC-gated, advertised via a ``status`` link on app and component detail, and declared under the OpenAPI ``Lifecycle`` tag (`#437 <https://github.com/selfpatch/ros2_medkit/pull/437>`_)
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 @@ -646,6 +646,10 @@ if(BUILD_TESTING)
ament_add_gtest(test_asset_identity test/test_asset_identity.cpp)
target_link_libraries(test_asset_identity gateway_ros2)

# Asset inventory: CSV / manifest asset-list parse + merge-by-id
ament_add_gtest(test_asset_inventory test/test_asset_inventory.cpp)
target_link_libraries(test_asset_inventory gateway_ros2)

# Add capability builder tests
ament_add_gtest(test_capability_builder test/test_capability_builder.cpp)
target_link_libraries(test_capability_builder gateway_ros2)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,26 +30,28 @@ namespace discovery {
*
* `source_precedence` ranks identity authority from highest to lowest. Entries are
* matched against a source's canonical identifier - the contributing entity's
* `Component.source` field ("manifest", "plugin", "runtime", "node", "heuristic",
* "config", or a protocol-class tag a provider sets such as "opcua"/"s7"), NOT the
* free-form discovery-layer / plugin name. A source not in the list ranks lowest: it
* can still fill empty fields but never overrides a known source.
* `Component.source` field ("manifest", "inventory", "plugin", "runtime", "node",
* "heuristic", "config", or a protocol-class tag a provider sets such as
* "opcua"/"s7"), NOT the free-form discovery-layer / plugin name. A source not in
* the list ranks lowest: it can still fill empty fields but never overrides a known
* source.
*
* Identity authority is deliberately decoupled from the structural merge policy: a
* manifest may be the authoritative *structure* source while a live protocol read is
* the authoritative *identity* source.
*
* Default precedence (highest first): a live protocol device-info read (a `plugin`
* source, or a protocol-specific source tag) beats the hand-authored `manifest`,
* which beats whatever runtime discovery guessed. The protocol-specific tags lead the
* list so that a provider which sets a concrete `Component.source` (e.g. "opcua") is
* honoured; the generic "plugin" tag covers the common case where the plugin layer
* stamps every plugin entity with source="plugin".
* source, or a protocol-specific source tag) beats the hand-authored `manifest` and
* `inventory` (CSV / manifest `assets:` list) declarations, which beat whatever
* runtime discovery guessed. The protocol-specific tags lead the list so that a
* provider which sets a concrete `Component.source` (e.g. "opcua") is honoured; the
* generic "plugin" tag covers the common case where the plugin layer stamps every
* plugin entity with source="plugin".
*/
struct IdentityMergeConfig {
std::vector<std::string> source_precedence{"opcua", "s7", "ethernet_ip", "modbus", "ads",
"profinet", "plugin", "manifest", "config", "runtime",
"node", "topic", "heuristic"};
std::vector<std::string> source_precedence{"opcua", "s7", "ethernet_ip", "modbus", "ads",
"profinet", "plugin", "manifest", "inventory", "config",
"runtime", "node", "topic", "heuristic"};
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright 2026 bburda
//
// 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

#include "ros2_medkit_gateway/core/discovery/models/component.hpp"

#include <string>
#include <utility>
#include <vector>

namespace ros2_medkit_gateway {
namespace discovery {

/**
* @brief A manually declared inventory asset (hand-authored or CSV-imported).
*
* Represents one physical/logical asset the operator knows about but that a
* protocol layer may not fully describe (or may not describe at all). The
* canonical identity fields mirror the INV1 asset-identity model; anything not
* covered by a canonical column is retained verbatim in @ref extras.
*
* @note `asset_entry_to_component` populates the Component's structured
* `AssetIdentity` directly, with per-field provenance "inventory", so
* identity merging ranks hand-authored values against other sources per
* field. This struct is the single place the two import paths (CSV and
* manifest `assets:` list) converge.
*/
struct AssetEntry {
std::string id; ///< Stable asset id (required); merge key into the tree
std::string manufacturer; ///< Vendor / OEM
std::string model; ///< Model / order code
std::string serial; ///< Serial number
std::string hardware_rev; ///< Hardware revision
std::string firmware; ///< Firmware / software version
std::string endpoint; ///< Network endpoint (URL / host:port)
std::string role; ///< Functional role (controller, sensor, ...)

/// Non-canonical columns, kept in declared order (header -> value).
std::vector<std::pair<std::string, std::string>> extras;
};

/**
* @brief Result of parsing a CSV inventory document.
*/
struct AssetCsvResult {
std::vector<AssetEntry> entries; ///< One entry per non-empty data row with an id
std::vector<std::string> warnings; ///< Non-fatal issues (skipped rows, ...)
};

/**
* @brief Parse a CSV inventory document into asset entries.
*
* The first non-empty line is the header. Column names are matched
* case-insensitively after trimming; recognized canonical names are
* `id, manufacturer, model, serial, hardware_rev, firmware, endpoint, role`
* (a small set of common aliases is also accepted). Any other column is
* preserved as an extra keyed by its original (trimmed) header name.
*
* RFC-4180-style quoting is honored: double-quoted fields may contain commas,
* newlines, and escaped quotes (`""`). Unquoted fields are whitespace-trimmed.
* Blank lines are skipped. Rows whose `id` is empty are skipped and reported in
* @ref AssetCsvResult::warnings.
*
* @param csv_text Full CSV document.
* @return Parsed entries plus any non-fatal warnings.
* @throws std::runtime_error if the document has no header or no `id` column.
*/
AssetCsvResult parse_asset_csv(const std::string & csv_text);

/**
* @brief Convert an inventory asset into a SOVD Component.
*
* The component is tagged `source = "inventory"` so its provenance stays
* visible after the merge pipeline combines it (by id) with protocol-discovered
* structure. Canonical fields land on the structured `Component::identity`
* with per-field provenance `"inventory"` (see the AssetEntry note):
* - name <- "<manufacturer> <model>" (left empty when neither is set so
* consumers fall back to the id)
* - identity.manufacturer / model / serial_number / hardware_revision /
* firmware_version / network_endpoint / role <- the matching entry
* fields; empty fields are skipped and record no provenance
* - identity.extra[header] <- non-empty extras, provenance keyed
* `extra.<header>`
*
* `fqn` / `namespace_path` are left empty: a bare inventory asset carries no
* placement, so a discovered node it merges with keeps its real path.
*
* @param entry Asset entry (must have a non-empty id).
* @return Component with `source = "inventory"` and structured identity
* populated.
*/
Component asset_entry_to_component(const AssetEntry & entry);

} // namespace discovery
} // namespace ros2_medkit_gateway
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ class ManifestParser {
/// Recursively parse area and its nested subareas
void parse_area_recursive(const YAML::Node & node, const std::string & parent_id, std::vector<Area> & areas) const;
Component parse_component(const YAML::Node & node) const;
/// Parse a manual-inventory asset entry into a Component (identity populated).
Component parse_asset(const YAML::Node & node) const;
App parse_app(const YAML::Node & node) const;
Function parse_function(const YAML::Node & node) const;
ManifestConfig parse_config(const YAML::Node & node) const;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,12 +166,13 @@ inline bool is_runtime_source(const std::string & source) {
/**
* @brief Check if an entity source is protected from orphan suppression
*
* Whitelist approach: only manifest and plugin sources are preserved during
* orphan filtering. Everything else (heuristic, topic, synthetic, node, runtime,
* peer:xxx) is eligible for suppression.
* Whitelist approach: manifest, inventory (operator-declared assets), and
* plugin sources are preserved during orphan filtering. Everything else
* (heuristic, topic, synthetic, node, runtime, peer:xxx) is eligible for
* suppression.
*/
inline bool is_protected_source(const std::string & source) {
return source == "manifest" || source.rfind("plugin", 0) == 0;
return source == "manifest" || source == "inventory" || source.rfind("plugin", 0) == 0;
}

} // namespace discovery
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ struct DiscoveryConfig {
/// manifest. See `ManifestManager::set_fragments_dir`.
std::string manifest_fragments_dir;

/// CSV file describing manually inventoried assets. When non-empty, each row
/// becomes a Component (identity populated) appended to the manifest on every
/// load / reload and merged into the tree by id. Empty = disabled. See
/// `ManifestManager::set_inventory_csv_path`.
std::string inventory_csv_path;

/**
* @brief Runtime (heuristic) discovery options
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,29 @@ class ManifestManager {
*/
std::string get_fragments_dir() const;

/**
* @brief Configure a CSV file describing manually inventoried assets.
*
* When set, every `load_manifest` / `reload_manifest` call parses the CSV
* (columns `id, manufacturer, model, serial, hardware_rev, firmware,
* endpoint, role`, plus any extra columns) and appends one Component per row
* to the base manifest before validation. Each asset merges into the entity
* tree by id, combining with protocol-discovered structure the same way a
* manifest component does. A parse failure (or a missing `id` column) is
* recorded as a validation error and fails the load.
*
* Call with an empty string to disable CSV import.
*
* @param path Path to the inventory CSV. Does not need to exist at call time;
* a missing file on load is treated as "no inventory".
*/
void set_inventory_csv_path(const std::string & path);

/**
* @brief Get the currently configured inventory CSV path (empty if unset).
*/
std::string get_inventory_csv_path() const;

/**
* @brief Unload current manifest (revert to runtime-only mode)
*/
Expand Down Expand Up @@ -266,9 +289,17 @@ class ManifestManager {
/// `validation_result_` so callers see them in the normal error flow.
bool apply_fragments(Manifest & base);

/// Parse the configured inventory CSV (if any) and append the resulting
/// asset Components to `base`. Called with `mutex_` held. Returns true on
/// success (or when no CSV is configured / the file is absent); false when
/// the CSV cannot be read or parsed. Parse errors are appended to
/// `validation_result_` so callers see them in the normal error flow.
bool apply_inventory_csv(Manifest & base);

std::optional<Manifest> manifest_;
std::string manifest_path_;
std::string fragments_dir_;
std::string inventory_csv_path_;
ValidationResult validation_result_;
bool strict_mode_{true};

Expand Down
Loading
Loading