feat(gem): add host-command behaviour hook to HostCommandRegistry

Host commands were declarative-only: dispatch() returned the YAML-defined
HCACK plus side effects, and ignored the command parameters entirely (the
param list was a commented-out argument). Equipment could acknowledge a
command but never run anything in response — the pvd_tool example worked
around this by hard-coding behaviour in a C++ router handler.

Add set_handler(rcmd, fn): a registered handler receives the live CPNAME/
CPVAL parameters and returns the HCACK, overriding the declarative default.
Live on S2F41/F21/F49 via the shared dispatch(). No handler => byte-for-byte
the previous declarative behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 18:00:44 +02:00
parent dae6bfd747
commit 0090791968
2 changed files with 92 additions and 2 deletions
+67
View File
@@ -117,6 +117,73 @@ TEST_CASE("host command registry returns spec + result") {
CHECK(r.dispatch("UNKNOWN", {}).ack == HostCmdAck::InvalidCommand);
}
TEST_CASE("host command handler runs vendor behaviour and decides the ack") {
HostCommandRegistry r;
r.register_command("START", {HostCmdAck::Accept, 300, std::nullopt});
// Behaviour: record that we ran, read a parameter, and choose the ack.
bool ran = false;
std::string seen_ppid;
r.set_handler("START", [&](const std::string& rcmd,
const std::vector<CommandParameter>& params) {
ran = true;
CHECK(rcmd == "START");
for (const auto& p : params)
if (p.name == "PPID") seen_ppid = p.value.as_ascii();
return HostCmdAck::Accept;
});
CHECK(r.has_handler("START"));
CHECK_FALSE(r.has_handler("STOP"));
std::vector<CommandParameter> params{{"PPID", s2::Item::ascii("RECIPE-A")}};
auto res = r.dispatch("START", params);
CHECK(ran);
CHECK(seen_ppid == "RECIPE-A");
CHECK(res.ack == HostCmdAck::Accept);
// Declarative side effects from the Spec still travel with the result.
CHECK(res.emit_ceid.value_or(0) == 300);
}
TEST_CASE("host command handler can reject, overriding the declarative ack") {
HostCommandRegistry r;
// YAML default says Accept; the handler decides otherwise at runtime.
r.register_command("START", {HostCmdAck::Accept, 300, std::nullopt});
r.set_handler("START", [](const std::string&,
const std::vector<CommandParameter>&) {
return HostCmdAck::CannotDoNow; // e.g. equipment busy
});
auto res = r.dispatch("START", {});
CHECK(res.ack == HostCmdAck::CannotDoNow);
// emit_ceid is still carried, but the router only applies it on Accept,
// so a rejecting handler naturally suppresses the event.
CHECK(res.emit_ceid.value_or(0) == 300);
}
TEST_CASE("host command with no handler stays purely declarative") {
HostCommandRegistry r;
r.register_command("STOP", {HostCmdAck::Accept, std::nullopt, std::nullopt});
// No set_handler call: behaviour is exactly the pre-existing YAML default.
CHECK_FALSE(r.has_handler("STOP"));
CHECK(r.dispatch("STOP", {}).ack == HostCmdAck::Accept);
}
TEST_CASE("host command handler on an unregistered command is never reached") {
HostCommandRegistry r;
bool ran = false;
// Handler attached but the command was never registered in the dictionary.
r.set_handler("GHOST", [&](const std::string&,
const std::vector<CommandParameter>&) {
ran = true;
return HostCmdAck::Accept;
});
auto res = r.dispatch("GHOST", {});
CHECK(res.ack == HostCmdAck::InvalidCommand);
CHECK_FALSE(ran); // unknown commands short-circuit before the handler
}
// ---- Event reports -------------------------------------------------------
TEST_CASE("define reports rejects unknown VID") {