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
32 changes: 30 additions & 2 deletions include/rcp/cli.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,33 @@ inline std::string version_text() {

// ── §12.2 capabilities document (always JSON) ────────────────────────────────
// Field set is constrained by cli-capabilities.json (additionalProperties:false).
//
// "features" (parity-audit gap-closure vs c-RCP's cli.c): alongside the
// existing physical-endpoint-type names, this now also reports the five
// TC18 §12.7.5 Table 20 svr_implemented_options names (REQ-RMAP-030,
// rcp/regmap.hpp's kOptCompoundWait/kOptTrigger/kOptChained/kOptTimeSync/
// kOptEnhCancel) — matching c-RCP's own cli.c features_json()/
// RCP_CLI_IMPLEMENTED_OPTIONS, which reports the identical five names from
// the identical five bits. This is a static, build-time answer (this CLI
// never connects to a live RC Server whose own runtime
// svr_implemented_options could be read instead — same rationale as
// c-RCP's own comment) reflecting that rcp/request.hpp implements
// compound/compound-wait/triggered/chained/timed requests and both
// clear-all/clear-single cancellation, and rcp/regmap.hpp defines the five
// corresponding bits. Unlike c-RCP's cli.c, no per-feature wire-conformance
// caveat is asserted here one way or the other — that would need a
// dedicated audit of request.hpp/e2e.hpp against the spec, out of scope for
// this pass. One known related gap, left alone here: request.hpp/e2e.hpp's
// own conditional-request gating (implemented_options_bits()/
// timed_feature_enabled()) still reads regmap.hpp's coarser legacy
// kOptConditionalRequests bit rather than these five independent ones
// directly (regmap.hpp's own "Legacy...retained ONLY because" comment) —
// neither header is in this batch's scope to rewire.
//
// "transports" additionally reports "tsn" (rcp/tsn.hpp, REQ-TSN-001..006, a
// real 802.1p PCP-tagging transport wrapper, not a stub) — c-RCP's own
// cli.c advertises its structurally-equivalent tsn.c; cpp-RCP's cli.hpp
// previously did not.

inline std::string capabilities_json() {
std::string spec(relay::kRelaySpecVersion);
Expand All @@ -103,9 +130,10 @@ inline std::string capabilities_json() {
+ "\"version\":\"" + std::string(kVersion) + "\","
+ "\"spec_version\":\"" + spec + "\","
+ "\"commands\":[\"version\",\"capabilities\",\"status\",\"send\"],"
+ "\"transports\":[\"udp\",\"shmem\",\"mock\"],"
+ "\"transports\":[\"udp\",\"shmem\",\"mock\",\"tsn\"],"
+ "\"features\":[\"gpio\",\"spi\",\"i2c\",\"uart\",\"adc\",\"pwm\",\"lin\",\"can\","
"\"iseled\",\"mdio\",\"wakeup\"],"
"\"iseled\",\"mdio\",\"wakeup\",\"time_sync\",\"enhanced_cancel\",\"trigger\","
"\"chained\",\"compound_bundles\"],"
+ "\"interfaces\":[\"Node\",\"Caller\"],"
+ "\"optional_interfaces\":[],"
+ "\"adapt\":true"
Expand Down
367 changes: 317 additions & 50 deletions include/rcp/config.hpp

Large diffs are not rendered by default.

79 changes: 61 additions & 18 deletions include/rcp/observe.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ using RequestFn = std::function<std::error_code(const rcp::Context&,
struct Span {
std::string name;
avtp::ByteBusId byte_bus_id = 0;
// stream_key identifies which stream this span's request traveled over
// (typically avtp::StreamId::to_u64()) — added alongside byte_bus_id so
// a Span carries the same full (stream, endpoint) address c-RCP's own
// rcp_span_t.addr (rcp_avtp_addr_t) does, rather than byte_bus_id alone.
// Populated by ObservingClient::request() from its own constructor-supplied
// stream_key, and by record() (below) from its own stream_key parameter.
uint64_t stream_key = 0;
uint8_t acf_msg_type = acf::kAcfMsgTypeAbb;
std::chrono::steady_clock::time_point start_time;
std::chrono::steady_clock::time_point end_time;
Expand All @@ -65,12 +72,19 @@ struct Span {
// ── Metric ────────────────────────────────────────────────────────────────────
// `stream_key` replaces the old Metric::zone — the same opaque uint64_t
// per-sender identity (typically avtp::StreamId::to_u64()) rcp/watchdog.hpp's
// Manager and rcp/shmem.hpp's Registry already key on.
// Manager and rcp/shmem.hpp's Registry already key on. `byte_bus_id` is
// carried alongside it (not folded into a single opaque key) so a metric
// keyed on the same stream but a different endpoint within it is still
// distinguishable — c-RCP's rcp_metric_t/record_counter carry the full
// rcp_avtp_addr_t (stream_id + byte_bus_id) for the same reason; this
// module previously dropped byte_bus_id here, which meant two different
// byte_bus_ids on one stream were indistinguishable in every counter/gauge.

struct Metric {
std::string name;
double value;
uint64_t stream_key;
std::string name;
double value;
uint64_t stream_key;
avtp::ByteBusId byte_bus_id = 0;
};

// ── MetricsSink ───────────────────────────────────────────────────────────────
Expand All @@ -80,14 +94,15 @@ class MetricsSink {
virtual ~MetricsSink() = default;
virtual void record_span(const Span&) = 0;
virtual void record_gauge(const Metric&) = 0;
virtual void record_counter(const std::string& name, uint64_t stream_key, double delta) = 0;
virtual void record_counter(const std::string& name, uint64_t stream_key,
avtp::ByteBusId byte_bus_id, double delta) = 0;
};

class NoopSink final : public MetricsSink {
public:
void record_span(const Span&) override {}
void record_gauge(const Metric&) override {}
void record_counter(const std::string&, uint64_t, double) override {}
void record_counter(const std::string&, uint64_t, avtp::ByteBusId, double) override {}
};

// ── InMemorySink ──────────────────────────────────────────────────────────────
Expand All @@ -100,7 +115,7 @@ class InMemorySink final : public MetricsSink {
spans_.push_back(s);
}
void record_gauge(const Metric&) override {}
void record_counter(const std::string&, uint64_t, double) override {}
void record_counter(const std::string&, uint64_t, avtp::ByteBusId, double) override {}

std::vector<Span> spans() const {
std::lock_guard<std::mutex> lk(mu_);
Expand All @@ -116,6 +131,41 @@ class InMemorySink final : public MetricsSink {
std::vector<Span> spans_;
};

// ── record ────────────────────────────────────────────────────────────────────
//
// record is the single, caller-driven recording primitive analogous to
// c-RCP's rcp_observe_record() (ROADMAP.md milestone 80's "Satellite
// Package Rework" rebind of observe.h): builds a Span from caller-supplied
// name/addressing/timestamps/result and forwards it to sink, then
// increments sink's "rcp.requests.total" counter (and, iff result is set,
// "rcp.requests.errors" too) — all without requiring the call to have gone
// through an ObservingClient-wrapped RequestFn at all. A caller that drives
// its own endpoint-specific send outside RequestFn's fixed shape (e.g.
// directly against rcp::mock::Server, or from a transport this module
// doesn't itself wrap) can call this directly and supply its own span name
// and pre-measured start/end timestamps, the same caller-driven convention
// rcp/watchdog.hpp's Manager::on_request_received and rcp/deadline.hpp's
// Monitor already use. ObservingClient::request() below is now implemented
// in terms of this function rather than duplicating its body.
inline void record(const std::shared_ptr<MetricsSink>& sink, const std::string& name,
avtp::ByteBusId byte_bus_id, uint64_t stream_key, uint8_t acf_msg_type,
std::chrono::steady_clock::time_point start_time,
std::chrono::steady_clock::time_point end_time,
std::error_code result) {
Span span;
span.name = name;
span.byte_bus_id = byte_bus_id;
span.stream_key = stream_key;
span.acf_msg_type = acf_msg_type;
span.start_time = start_time;
span.end_time = end_time;
span.result = result;

sink->record_span(span);
sink->record_counter("rcp.requests.total", stream_key, byte_bus_id, 1.0);
if (result) sink->record_counter("rcp.requests.errors", stream_key, byte_bus_id, 1.0);
}

// ── ObservingClient ───────────────────────────────────────────────────────────

class ObservingClient {
Expand All @@ -135,19 +185,12 @@ class ObservingClient {
const std::vector<uint8_t>& req_payload,
acf::AcfMessageInfo& out_resp,
std::vector<uint8_t>& out_resp_payload) {
Span span;
span.name = "rcp.request";
span.byte_bus_id = req.byte_bus_id;
span.acf_msg_type = req.acf_msg_type;
span.start_time = std::chrono::steady_clock::now();

auto start_time = std::chrono::steady_clock::now();
auto ec = inner_(ctx, req, req_payload, out_resp, out_resp_payload);
auto end_time = std::chrono::steady_clock::now();

span.end_time = std::chrono::steady_clock::now();
span.result = ec;
sink_->record_span(span);
sink_->record_counter("rcp.requests.total", stream_key_, 1.0);
if (ec) sink_->record_counter("rcp.requests.errors", stream_key_, 1.0);
record(sink_, "rcp.request", req.byte_bus_id, stream_key_, req.acf_msg_type,
start_time, end_time, ec);

return ec;
}
Expand Down
37 changes: 37 additions & 0 deletions tests/test_cli.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,40 @@ TEST_CASE("cli: send with an invalid --endpoint value returns invalid-args (2)",
capture({"send", "--server", "0", "--endpoint", "300", "--op", "read"}, code);
REQUIRE(code == rcp::cli::kInvalidArgs);
}

// ── Test-gap closure (parity audit vs c-RCP's test_cli.c) ──────────────────────

TEST_CASE("cli: capabilities reports the tsn transport and REQ-RMAP-030 feature bits",
"[cli][conformance]") {
int code = 0;
auto s = capture({"capabilities"}, code);
REQUIRE(code == rcp::cli::kOk);
REQUIRE(s.find("\"tsn\"") != std::string::npos);
for (auto k : {"time_sync", "enhanced_cancel", "trigger", "chained", "compound_bundles"}) {
REQUIRE(s.find(std::string("\"") + k + "\"") != std::string::npos);
}
}

TEST_CASE("cli: status default format is text", "[cli][conformance]") {
int code = 0;
auto s = capture({"status"}, code);
REQUIRE(code == rcp::cli::kOk);
REQUIRE(s.find("cpp-rcp: healthy=true") != std::string::npos);
REQUIRE(s.find("{") == std::string::npos); // not JSON
}

TEST_CASE("cli: --format with no following value returns invalid-args (2)",
"[cli][conformance]") {
int code = 0;
capture({"version", "--format"}, code);
REQUIRE(code == rcp::cli::kInvalidArgs);
}

TEST_CASE("cli: help/--help/-h all print usage and return OK", "[cli][conformance]") {
for (const char* spelling : {"help", "--help", "-h"}) {
int code = 0;
auto s = capture({spelling}, code);
REQUIRE(code == rcp::cli::kOk);
REQUIRE(s.find("Usage: cpp-rcp") != std::string::npos);
}
}
156 changes: 156 additions & 0 deletions tests/test_config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,159 @@ TEST_CASE("config: ParseError is a std::runtime_error subclass", "[config][REQ-C
REQUIRE(caught);
REQUIRE(std::is_base_of<std::runtime_error, config::ParseError>::value);
}

// ── Gap-closure (parity audit vs c-RCP's config.c/config.h) ───────────────────

TEST_CASE("config: parse_json test-gap closure — \"extra\" metadata is actually asserted",
"[config]") {
const std::string json = R"({
"endpoints": [ { "stream_key": "0", "byte_bus_id": 1, "extra": "note-42" } ]
})";
auto m = config::parse_json(json);
REQUIRE(m.endpoints.size() == 1);
REQUIRE(m.endpoints[0].extra == "note-42");
}

TEST_CASE("config: bug-fix — an endpoint entry missing byte_bus_id is now rejected, "
"not silently skipped", "[config]") {
// Previously: an object carrying only "stream_key" (or only
// "byte_bus_id") failed the old "both keys present" routing check and
// was dropped with no error at all — the same latent defect class
// c-RCP's own config.c independently found and fixed. It must now be
// rejected as malformed.
const std::string json = R"({ "endpoints": [{ "stream_key": "0" }] })";
REQUIRE_THROWS_AS(config::parse_json(json), config::ParseError);
}

TEST_CASE("config: bug-fix — an endpoint entry missing stream_key is now rejected, "
"not silently skipped", "[config]") {
const std::string json = R"({ "endpoints": [{ "byte_bus_id": 1 }] })";
REQUIRE_THROWS_AS(config::parse_json(json), config::ParseError);
}

TEST_CASE("config: parse_json parses the \"server\" block (vendor_id/device_id/magic)",
"[config]") {
const std::string json =
R"({ "server": { "vendor_id": 17, "device_id": 42, "magic": 12345 } })";
auto m = config::parse_json(json);
REQUIRE(m.server.vendor_id == 17);
REQUIRE(m.server.device_id == 42);
REQUIRE(m.server.magic == 12345);
}

TEST_CASE("config: parse_json parses svr_implemented_options named bits "
"(REQ-RMAP-030 five independent bits)", "[config]") {
const std::string json =
R"({ "server": { "svr_implemented_options": ["time_sync", "compound_bundles"] } })";
auto m = config::parse_json(json);
REQUIRE((m.server.svr_implemented_options & regmap::kOptTimeSync) != 0);
REQUIRE((m.server.svr_implemented_options & regmap::kOptCompoundWait) != 0);
REQUIRE((m.server.svr_implemented_options & regmap::kOptEnhCancel) == 0);
REQUIRE((m.server.svr_implemented_options & regmap::kOptTrigger) == 0);
REQUIRE((m.server.svr_implemented_options & regmap::kOptChained) == 0);
}

TEST_CASE("config: parse_json parses svr_implemented_options' trigger/chained bits",
"[config]") {
const std::string json =
R"({ "server": { "svr_implemented_options": ["trigger", "chained"] } })";
auto m = config::parse_json(json);
REQUIRE((m.server.svr_implemented_options & regmap::kOptTrigger) != 0);
REQUIRE((m.server.svr_implemented_options & regmap::kOptChained) != 0);
}

TEST_CASE("config: parse_json parses a \"hw_pin_map\" array", "[config]") {
const std::string json = R"({
"hw_pin_map": [
{ "hw_ep_nr": 0, "hw_ep_pin_nr": 3, "hw_pin_type": ["push_pull", "pull_up"] }
]
})";
auto m = config::parse_json(json);
REQUIRE(m.hw_pin_map.size() == 1);
REQUIRE(m.hw_pin_map[0].hw_ep_nr == 0);
REQUIRE(m.hw_pin_map[0].hw_ep_pin_nr == 3);
REQUIRE((m.hw_pin_map[0].hw_pin_type & regmap::hw_pin::kStagePushPull) != 0);
REQUIRE((m.hw_pin_map[0].hw_pin_type & regmap::hw_pin::kPullUp) != 0);
}

TEST_CASE("config: parse_json hw_pin_map entry missing hw_ep_pin_nr throws", "[config]") {
const std::string json = R"({ "hw_pin_map": [{ "hw_ep_nr": 0 }] })";
REQUIRE_THROWS_AS(config::parse_json(json), config::ParseError);
}

TEST_CASE("config: parse_json empty object parses to an all-default manifest", "[config]") {
auto m = config::parse_json("{}");
REQUIRE(m.endpoints.empty());
REQUIRE(m.hw_pin_map.empty());
REQUIRE(m.server.vendor_id == 0);
}

TEST_CASE("config: apply_to_mock writes vendor_id/device_id/magic/svr_implemented_options "
"into the mock::Server's regmap", "[config]") {
const std::string json = R"({
"server": { "vendor_id": 7, "device_id": 9, "magic": 555,
"svr_implemented_options": ["time_sync"] }
})";
mock::Server srv;
REQUIRE_FALSE(config::apply_to_mock(config::parse_json(json), srv));

REQUIRE(srv.registers().general.vendor_id == 7);
REQUIRE(srv.registers().general.device_id == 9);
REQUIRE(srv.registers().general.magic == 555);
REQUIRE((srv.registers().general.svr_implemented_options & regmap::kOptTimeSync) != 0);
}

TEST_CASE("config: apply_to_mock preserves the existing magic when the manifest's is zero",
"[config]") {
mock::Server srv;
auto original_magic = srv.registers().general.magic;
REQUIRE(original_magic != 0); // GeneralMap defaults magic to a real, nonzero value

REQUIRE_FALSE(config::apply_to_mock(config::parse_json(R"({ "server": { "vendor_id": 1 } })"), srv));
REQUIRE(srv.registers().general.magic == original_magic); // untouched
}

TEST_CASE("config: apply_to_mock ORs svr_implemented_options into existing bits, "
"never clearing what was already set", "[config]") {
mock::Server srv;
srv.registers().general.svr_implemented_options = regmap::kOptChained;

REQUIRE_FALSE(config::apply_to_mock(
config::parse_json(R"({ "server": { "svr_implemented_options": ["time_sync"] } })"), srv));

REQUIRE((srv.registers().general.svr_implemented_options & regmap::kOptChained) != 0);
REQUIRE((srv.registers().general.svr_implemented_options & regmap::kOptTimeSync) != 0);
}

TEST_CASE("config: apply_to_mock installs hw_pin_map rows into the mock::Server's regmap",
"[config]") {
const std::string json = R"({
"hw_pin_map": [
{ "hw_ep_nr": 1, "hw_ep_pin_nr": 2, "hw_pin_type": ["schmitt_trigger"] }
]
})";
mock::Server srv;
REQUIRE_FALSE(config::apply_to_mock(config::parse_json(json), srv));

REQUIRE(srv.registers().hw_pin_map.size() == 1);
REQUIRE(srv.registers().hw_pin_map[0].hw_ep_nr == 1);
REQUIRE(srv.registers().hw_pin_map[0].hw_ep_pin_nr == 2);
REQUIRE((srv.registers().hw_pin_map[0].hw_pin_type & regmap::hw_pin::kSchmittTrigger) != 0);
REQUIRE(srv.registers().hw_pin_map_table.capacity == 1);
}

TEST_CASE("config: apply_to_mock rejects a hw_pin_map larger than kMaxEntries", "[config]") {
config::Manifest m;
m.hw_pin_map.resize(regmap::hw_pin_map::kMaxEntries + 1);

mock::Server srv;
auto ec = config::apply_to_mock(m, srv);
REQUIRE(ec == std::make_error_code(std::errc::value_too_large));
}

TEST_CASE("config: load_to_mock combines parse_json + apply_to_mock in one call", "[config]") {
const std::string json = R"({ "server": { "vendor_id": 3 } })";
mock::Server srv;
REQUIRE_FALSE(config::load_to_mock(json, srv));
REQUIRE(srv.registers().general.vendor_id == 3);
}
Loading
Loading