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
96 changes: 96 additions & 0 deletions docs/config/manifest-schema.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ A manifest file has the following top-level structure:

areas: [] # Optional - area definitions
components: [] # Optional - component definitions
assets: [] # Optional - manual asset inventory entries
apps: [] # Optional - app definitions
functions: [] # Optional - function definitions
scripts: [] # Optional - pre-defined script entries
Expand Down Expand Up @@ -350,6 +351,101 @@ Example
type: "sensor"
area: perception

Assets
------

Assets declare manually inventoried equipment that no protocol layer can
describe (or fully describe): unnetworked devices, third-party hardware, spare
nameplate data. Each asset becomes a Component with ``source: "inventory"`` and
a structured asset identity carrying per-field provenance ``"inventory"``, and
merges into the entity tree by ``id`` alongside protocol-discovered structure.
In the identity merge, ``inventory`` ranks below ``manifest`` and live protocol
reads but above runtime guesses.

Schema
~~~~~~

.. code-block:: yaml

assets:
- id: string # Required - stable asset id (merge key)
manufacturer: string # Optional - vendor / OEM
model: string # Optional - model / order code
serial: string # Optional - serial number
hardware_rev: string # Optional - hardware revision
firmware: string # Optional - firmware / software version
endpoint: string # Optional - network endpoint (URL / host:port)
role: string # Optional - functional role
area: string # Optional - Area id placing the asset in the tree
namespace: string # Optional - operator-declared placement (sets fqn)
name: string # Optional - display name (default: "<manufacturer> <model>")
description: string # Optional - detailed description
variant: string # Optional - hardware variant identifier
type: string # Optional - component type
translation_id: string # Optional - internationalization key
parent_component_id: string # Optional - parent component ID
depends_on: [string] # Optional - component IDs this asset depends on
tags: [string] # Optional - tags for filtering
any_other_key: string # Kept verbatim as an identity extra

Fields
~~~~~~

The identity keys accept the same aliases as the CSV import:
``serial_number`` for ``serial``, ``hardware_revision`` / ``hw_rev`` for
``hardware_rev``, and ``firmware_version`` / ``fw`` for ``firmware``. Aliased
keys land on the typed identity fields, not in the extras. Any scalar key not
listed above is preserved as an identity extra.

Placement is optional: without ``area`` (and ``namespace``) the asset is
reachable at ``/components/{id}`` and in the flat component list, but does not
appear under any Area. An ``area`` must reference an area defined in the
manifest, otherwise validation fails (rule R006).

Example
~~~~~~~

.. code-block:: yaml

areas:
- id: cell-3
name: "Cell 3"

assets:
- id: hyd-pump-2
manufacturer: Grundfos
model: CR-5
serial_number: "GP-2214-0087"
area: cell-3
role: pump
rack: R2 # kept as identity extra "rack"

CSV Inventory Import
~~~~~~~~~~~~~~~~~~~~

The same asset entries can be bulk-imported from a CSV file via the
``discovery.inventory.csv_path`` gateway parameter (requires a manifest-backed
discovery mode: ``manifest_only``, or ``hybrid`` with
``discovery.manifest_path`` set; empty = disabled). The CSV is re-read on every
manifest load / reload and appended to the merged manifest before validation.

- **Columns**: the header row is matched case-insensitively after trimming.
Canonical columns are ``id`` (required), ``manufacturer``, ``model``,
``serial``, ``hardware_rev``, ``firmware``, ``endpoint``, ``role`` and
``area``, with the same aliases as the ``assets:`` list. Any other column is
kept as an identity extra keyed by its original header.
- **Quoting**: RFC-4180-style; double-quoted fields may contain commas,
newlines and escaped quotes (``""``). Unquoted fields are whitespace-trimmed;
a UTF-8 BOM (Excel "CSV UTF-8" export) is stripped.
- **Size cap**: the file is rejected before reading if it exceeds 1 MiB.
- **Row policy**: rows without an ``id`` are skipped with a warning; for
duplicate ids within the CSV the first row wins. A row whose id is already a
manifest component keeps the manifest definition and folds the row's identity
in as gap-fill; a row whose id collides with any other manifest entity is
skipped, and an unknown ``area`` value is dropped (asset kept, placement-less).
None of these fail the load. A missing file is skipped with a warning; an
unreadable or malformed file (e.g. no ``id`` column) fails the load.

Apps
----

Expand Down
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). Both paths recognize the canonical names ``id, manufacturer, model, serial, hardware_rev, firmware, endpoint, role, area`` plus the shared aliases (``serial_number``, ``hardware_revision`` / ``hw_rev``, ``firmware_version`` / ``fw``) and keep any other column / key as an extra; RFC-4180-style quoting is honored. 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; ``area`` places the asset under an Area, without it the asset appears only in the flat component list. CSV rows never fail the load: rows without an ``id`` are skipped with a warning, for duplicate ids the first row wins, a row whose id is already a manifest component keeps the manifest definition (the row's identity is folded in as gap-fill), and an unknown ``area`` is dropped with a warning. 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
12 changes: 6 additions & 6 deletions src/ros2_medkit_gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1585,12 +1585,12 @@ records **per-field provenance** (which source set each field) under `_provenanc
and is deliberately **decoupled from the structural `MergePolicy`**: a manifest can be the
authoritative *structure* source while a live protocol read is the authoritative *identity*
source. Authority is ranked on each contributing entity's canonical `Component.source` tag
("manifest", "plugin", "runtime", "node", "config", or a protocol-class tag a provider sets
such as "opcua"), **not** the free-form discovery-layer name. Default order: protocol
device-info (`opcua`, `s7`, `ethernet_ip`, `modbus`, `ads`, `profinet`, and the generic
`plugin`) > `manifest` > `config` > runtime sources. A higher-authority source overrides a
field; lower-authority sources only fill gaps; unknown sources rank lowest. Empty values never
overwrite.
("manifest", "inventory", "plugin", "runtime", "node", "config", or a protocol-class tag a
provider sets such as "opcua"), **not** the free-form discovery-layer name. Default order:
protocol device-info (`opcua`, `s7`, `ethernet_ip`, `modbus`, `ads`, `profinet`, and the
generic `plugin`) > `manifest` > `inventory` (CSV import / manifest `assets:` list) >
`config` > runtime sources. A higher-authority source overrides a field; lower-authority
sources only fill gaps; unknown sources rank lowest. Empty values never overwrite.

Components are correlated for merging by `Component.id`; identity is merged whenever two
sources contribute the same Component id (in the discovery pipeline, and gap-filled across
Expand Down
13 changes: 13 additions & 0 deletions src/ros2_medkit_gateway/config/gateway_params.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,19 @@ ros2_medkit_gateway:
# Strict manifest validation (reject invalid manifests)
manifest_strict_validation: true

# Manual asset inventory CSV (requires a manifest-backed mode:
# manifest_only, or hybrid with manifest_path set). Each row becomes a
# Component with source "inventory", appended to the manifest on every
# load/reload and merged into the tree by id. Canonical columns:
# id (required), manufacturer, model, serial, hardware_rev, firmware,
# endpoint, role, area (aliases: serial_number, hardware_revision/hw_rev,
# firmware_version/fw); any other column is kept as an identity extra.
# RFC-4180 quoting, 1 MiB size cap. Malformed rows / id collisions are
# skipped with a warning (manifest wins); a missing file is skipped, an
# unreadable or malformed file fails the load. Empty = disabled (default).
inventory:
csv_path: ""

# Configurable main layers (hybrid mode only)
# Set false to disable a layer - useful when running manifest+plugin or runtime+plugin
# without the other default layer.
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,147 @@
// 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, ...)
std::string area; ///< Optional Area id placing the asset in the tree

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

/**
* @brief Canonical destination of an inventory column / manifest asset key.
*/
enum class AssetColumn {
kIgnore, ///< Empty header cell: drop the value
kExtra, ///< Not a canonical name: keep as identity extra
kId,
kManufacturer,
kModel,
kSerial,
kHardwareRev,
kFirmware,
kEndpoint,
kRole,
kArea,
};

/**
* @brief Map a column / key name to its canonical destination.
*
* The single alias table shared by both import paths (CSV header cells and
* manifest `assets:` keys): names are trimmed and matched case-insensitively;
* `serial_number`, `hardware_revision` / `hw_rev` and `firmware_version` / `fw`
* map to their typed fields. Empty names yield kIgnore; unknown names kExtra.
*/
AssetColumn asset_column(const std::string & name);

/**
* @brief Assign `value` to the @ref AssetEntry field selected by `column`.
*
* kExtra appends (`header`, `value`) to `entry.extras` (empty values are
* dropped); kIgnore is a no-op.
*/
void assign_asset_field(AssetEntry & entry, AssetColumn column, const std::string & header, const std::string & value);

/**
* @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 via
* ::asset_column; recognized canonical names are
* `id, manufacturer, model, serial, hardware_rev, firmware, endpoint, role,
* area` (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; so are rows repeating an earlier row's id
* (first row wins).
*
* @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. An
* `area` on the entry lands on `Component::area` (structural placement, no
* provenance entry); without one the asset appears only in the flat component
* list, never under an Area.
*
* @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
Loading
Loading