feat(client)+feat(daemon): eq.names, @eq.command, E90/E157 RPCs

Python client:
- eq.names.event.* / .alarm.* / .command.* / .var.* / .constant.*  —
  autocomplete-able, typo-safe name lookup backed by the Describe RPC
  (lazy, cached; AttributeError on bad name with close-match hints)
- @eq.command decorator — binds a handler by function name, validated
  against the equipment's real command set at decoration time
- eq.report_substrate() — E90 wafer milestone reporting
- eq.report_module() — E157 module state reporting (auto-create)

Daemon (C++ service):
- ReportSubstrate RPC — drives E90 location + processing FSMs
- ReportModule RPC — drives E157 module FSM (auto-create on first report)
- ack_from_outcome() helper — consistent Ack mapping for read_sync results

Proto: SubstrateReport, ModuleReport, EquipmentDescription,
       SpoolFlushRequest, TerminalMessage; Describe, FlushSpool,
       SendTerminalMessage RPCs

Tests: C++ FSM test (journey + ghost rejection + E157 illegal jump);
       interop coverage for names API and E90/E157 round-trip

Docs: ch42 RPC table + Python example updated; ch16 daemon-path section added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 21:43:07 +02:00
parent 9876dd9b5a
commit a2ebbf7c65
14 changed files with 602 additions and 27 deletions
+68
View File
@@ -735,3 +735,71 @@ TEST_CASE("randomized concurrent RPC stress (seeded)") {
server->Shutdown();
rt.stop();
}
// Phase 16 tail: E90 substrate + E157 module reporting (observe-and-report).
TEST_CASE("ReportSubstrate (E90) and ReportModule (E157) drive the FSMs") {
gem::EquipmentRuntime rt(test_config());
gem::register_default_handlers(rt);
dmn::EquipmentService svc(rt);
rt.run_async();
grpc::ServerBuilder builder;
builder.RegisterService(&svc);
std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
REQUIRE(server);
auto stub = pb::Equipment::NewStub(server->InProcessChannel(grpc::ChannelArguments{}));
auto sub = [&](const std::string& sid, pb::SubstrateReport::Milestone m) {
grpc::ClientContext ctx;
pb::SubstrateReport req;
pb::Ack ack;
req.set_substrate_id(sid);
req.set_milestone(m);
REQUIRE(stub->ReportSubstrate(&ctx, req, &ack).ok());
return ack.code();
};
// A wafer's journey: arrive -> picked up -> process -> done -> deposited.
CHECK(sub("WFR-1", pb::SubstrateReport::ARRIVED) == pb::Ack::ACCEPT);
CHECK(sub("WFR-1", pb::SubstrateReport::AT_WORK) == pb::Ack::ACCEPT);
CHECK(sub("WFR-1", pb::SubstrateReport::PROCESSING) == pb::Ack::ACCEPT);
CHECK(sub("WFR-1", pb::SubstrateReport::PROCESSED) == pb::Ack::ACCEPT);
CHECK(sub("WFR-1", pb::SubstrateReport::AT_DESTINATION) == pb::Ack::ACCEPT);
auto loc = rt.read_sync([&rt]() {
const auto* s = rt.model().substrates.get("WFR-1");
return s ? s->fsm->location_state() : gem::SubstrateState::NoState;
});
REQUIRE(loc.has_value());
CHECK(*loc == gem::SubstrateState::AtDestination);
// Reporting on a substrate that never ARRIVED is rejected.
CHECK(sub("WFR-GHOST", pb::SubstrateReport::AT_WORK) == pb::Ack::INVALID_OBJECT);
auto mod = [&](const std::string& mid, pb::ModuleReport::State st) {
grpc::ClientContext ctx;
pb::ModuleReport req;
pb::Ack ack;
req.set_module_id(mid);
req.set_state(st);
REQUIRE(stub->ReportModule(&ctx, req, &ack).ok());
return ack.code();
};
// Module: auto-created, then walked General -> Step -> StepCompleted.
CHECK(mod("MOD-1", pb::ModuleReport::GENERAL_EXECUTING) == pb::Ack::ACCEPT);
CHECK(mod("MOD-1", pb::ModuleReport::STEP_EXECUTING) == pb::Ack::ACCEPT);
CHECK(mod("MOD-1", pb::ModuleReport::STEP_COMPLETED) == pb::Ack::ACCEPT);
auto mstate = rt.read_sync([&rt]() {
const auto* m = rt.model().modules.get("MOD-1");
return m ? m->fsm->state() : gem::ModuleState::NotExecuting;
});
REQUIRE(mstate.has_value());
CHECK(*mstate == gem::ModuleState::StepCompleted);
// An illegal jump (StepExecuting straight from a fresh NotExecuting module)
// is rejected by the E157 table.
CHECK(mod("MOD-2", pb::ModuleReport::STEP_EXECUTING) == pb::Ack::CANNOT_DO_NOW);
server->Shutdown();
rt.stop();
}