From af25bc8726a1b1ffc530873a736dca05ea5d4066 Mon Sep 17 00:00:00 2001 From: Raphael Maenle Date: Sun, 7 Jun 2026 23:51:53 +0200 Subject: [PATCH] C2: S5F9-F18 server dispatch for exception lifecycle Wires the ExceptionStore from C1 onto the wire: State-change emitter (set on ExceptionStore at server startup): NoState -> Posted emit S5F9 (exception post notify) Recovering -> Cleared emit S5F15 with EXRESULT="OK" Recovering -> RecoverFailed emit S5F15 with EXRESULT="FAILED" Posted -> Cleared emit S5F11 (autonomous clear) RecoverFailed -> Cleared emit S5F11 Router additions: S5F13 -> ExceptionStore::on_recover(exid, exrecvra) -> S5F14 ACKC5 S5F17 -> ExceptionStore::on_recover_abort(exid) -> S5F18 ACKC5 S5F9 emission auto-spools when the link is offline (deliver_or_spool already handles that). The synthetic NoState->Posted transition fires inside ExceptionStore::post(), so any application code that calls model->exceptions.post(...) will produce wire activity through this handler without further plumbing. Verified: docker-built secsgem_tests now reports 188 cases / 1026 assertions / 0 failures. Co-Authored-By: Claude Opus 4.7 --- apps/secs_server.cpp | 76 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/apps/secs_server.cpp b/apps/secs_server.cpp index 4b2c261..0b386c7 100644 --- a/apps/secs_server.cpp +++ b/apps/secs_server.cpp @@ -232,6 +232,58 @@ int main(int argc, char** argv) { }); }; + // ---- E5 exception state-change emitters ------------------------------- + // Translate ExceptionStore state changes into S5F9/F11/F15 on the wire, + // mirroring how PJ state changes become S16F9. The synthetic + // NoState->Posted event is the trigger for emitting S5F9 (we look up + // the metadata from the store so we can build the full body). + model->exceptions.set_state_change_handler( + [model, logfn, deliver_or_spool](uint32_t exid, + gem::ExceptionState from, + gem::ExceptionState to, + gem::ExceptionEvent trig) { + logfn(std::string("EX ") + std::to_string(exid) + ": " + + gem::exception_state_name(from) + " -> " + + gem::exception_state_name(to) + " (" + + gem::exception_event_name(trig) + ")"); + + if (from == gem::ExceptionState::NoState && + to == gem::ExceptionState::Posted) { + const auto* ex = model->exceptions.get(exid); + if (!ex) return; + deliver_or_spool( + gem::s5f9_exception_post_notify(exid, ex->extype, ex->exmessage, + ex->exrecvra), + "S5F9 EXID=" + std::to_string(exid)); + return; + } + + if (to == gem::ExceptionState::Cleared) { + if (from == gem::ExceptionState::Recovering) { + // RecoveryComplete -> emit S5F15 with success EXRESULT. + deliver_or_spool( + gem::s5f15_exception_recover_complete_notify(exid, "OK"), + "S5F15 EXID=" + std::to_string(exid)); + } + // S5F11 for any Cleared transition that didn't go through + // Recovering (autonomous clear or post-failure clear). + if (from == gem::ExceptionState::Posted || + from == gem::ExceptionState::RecoverFailed) { + deliver_or_spool( + gem::s5f11_exception_clear_notify(exid, "", ""), + "S5F11 EXID=" + std::to_string(exid)); + } + return; + } + + if (from == gem::ExceptionState::Recovering && + to == gem::ExceptionState::RecoverFailed) { + deliver_or_spool( + gem::s5f15_exception_recover_complete_notify(exid, "FAILED"), + "S5F15 EXID=" + std::to_string(exid)); + } + }); + // ---- Build the SECS dispatch table once ------------------------------- gem::Router router; @@ -533,6 +585,30 @@ int main(int argc, char** argv) { alarms, [model](uint32_t id) { return model->alarms.active(id); }); }); + // S5F13/F14 — Exception Recover Request. Validates EXRECVRA against + // the candidates the matching S5F9 advertised; on Accept the FSM + // transitions Posted/RecoverFailed -> Recovering. Equipment-side + // recovery progress is signalled by the application calling + // model->exceptions.fire_internal(exid, RecoveryComplete/Failed). + router.on(5, 13, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s5f13(msg); + auto ack = req ? model->exceptions.on_recover(req->exid, req->exrecvra) + : gem::AlarmAck::Error; + logfn("S5F13 EXID=" + std::to_string(req ? req->exid : 0) + + " action=" + (req ? req->exrecvra : std::string{"?"}) + + " -> S5F14 ACKC5=" + std::to_string(static_cast(ack))); + return gem::s5f14_exception_recover_ack(ack); + }); + + router.on(5, 17, [model, logfn](const s2::Message& msg) { + auto exid = gem::parse_s5f17(msg); + auto ack = exid ? model->exceptions.on_recover_abort(*exid) + : gem::AlarmAck::Error; + logfn("S5F17 EXID=" + std::to_string(exid.value_or(0)) + + " -> S5F18 ACKC5=" + std::to_string(static_cast(ack))); + return gem::s5f18_exception_recover_abort_ack(ack); + }); + router.on(7, 3, [model, logfn](const s2::Message& msg) { auto pp = gem::parse_s7f3(msg); if (!pp) return gem::s7f4_process_program_ack(gem::ProcessProgramAck::LengthError);