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
@@ -527,6 +527,70 @@ class EquipmentService final : public pb::Equipment::Service {
return grpc::Status::OK;
}
grpc::Status ReportSubstrate(grpc::ServerContext*, const pb::SubstrateReport* req,
pb::Ack* resp) override {
const std::string sid = req->substrate_id();
const std::string cid = req->carrier_id();
const auto slot = static_cast<uint8_t>(req->slot());
const auto m = req->milestone();
auto outcome = rt_.read_sync([this, sid, cid, slot, m]() -> std::optional<bool> {
auto& subs = rt_.model().substrates;
if (m == pb::SubstrateReport::ARRIVED) {
subs.create(sid, cid, slot); // AtSource / NeedsProcessing
return true;
}
if (!subs.has(sid)) return std::nullopt;
switch (m) {
case pb::SubstrateReport::AT_WORK:
return subs.fire_location_event(sid, gem::SubstrateEvent::Acquire, "");
case pb::SubstrateReport::AT_DESTINATION:
return subs.fire_location_event(sid, gem::SubstrateEvent::Release, "");
case pb::SubstrateReport::PROCESSING:
return subs.fire_processing_event(sid, gem::SubstrateProcessingEvent::StartProcessing);
case pb::SubstrateReport::PROCESSED:
return subs.fire_processing_event(sid, gem::SubstrateProcessingEvent::EndProcessing);
default:
return true;
}
});
ack_from_outcome(outcome, resp, "substrate '" + sid + "'",
"E90 table rejects that transition");
return grpc::Status::OK;
}
grpc::Status ReportModule(grpc::ServerContext*, const pb::ModuleReport* req,
pb::Ack* resp) override {
const std::string mid = req->module_id();
const auto st = req->state();
auto outcome = rt_.read_sync([this, mid, st]() -> bool {
auto& mods = rt_.model().modules;
if (!mods.has(mid)) mods.create(mid); // auto-create; starts NotExecuting
switch (st) {
case pb::ModuleReport::GENERAL_EXECUTING:
return mods.fire(mid, gem::ModuleEvent::StartGeneral);
case pb::ModuleReport::STEP_EXECUTING:
return mods.fire(mid, gem::ModuleEvent::StartStep);
case pb::ModuleReport::STEP_COMPLETED:
return mods.fire(mid, gem::ModuleEvent::CompleteStep);
case pb::ModuleReport::NOT_EXECUTING:
return mods.fire(mid, gem::ModuleEvent::Reset);
default:
return true;
}
});
// Module auto-creates, so "not found" can't happen; only the FSM verdict.
if (!outcome) {
resp->set_code(pb::Ack::CANNOT_DO_NOW);
resp->set_message("engine io thread did not answer");
} else if (!*outcome) {
resp->set_code(pb::Ack::CANNOT_DO_NOW);
resp->set_message("E157 table rejects that transition from the current state");
} else {
resp->set_code(pb::Ack::ACCEPT);
}
return grpc::Status::OK;
}
grpc::Status Describe(grpc::ServerContext*, const pb::Empty*,
pb::EquipmentDescription* resp) override {
resp->set_model_name(descr_model_);
@@ -653,6 +717,26 @@ class EquipmentService final : public pb::Equipment::Service {
}
private:
// Map a read_sync(std::optional<bool>) result to an Ack: outer nullopt =
// io thread didn't answer; inner nullopt = object not found; false = FSM
// rejected; true = accepted.
void ack_from_outcome(const std::optional<std::optional<bool>>& outcome,
pb::Ack* resp, const std::string& what,
const std::string& reject_msg) {
if (!outcome) {
resp->set_code(pb::Ack::CANNOT_DO_NOW);
resp->set_message("engine io thread did not answer (not running?)");
} else if (!*outcome) {
resp->set_code(pb::Ack::INVALID_OBJECT);
resp->set_message("no " + what);
} else if (!**outcome) {
resp->set_code(pb::Ack::CANNOT_DO_NOW);
resp->set_message(reject_msg);
} else {
resp->set_code(pb::Ack::ACCEPT);
}
}
// Fan a HostRequest out to every subscriber (fire-and-forget
// notifications: jobs, recipes, EC changes). No subscriber = dropped,
// matching the no-buffering contract.