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 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 23:51:53 +02:00
parent a1f7da4a7d
commit af25bc8726
+76
View File
@@ -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<int>(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<int>(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);