// Passive SECS/GEM equipment: a small simulated tool. Drives the E30 control // state machine, owns an in-memory data dictionary (SVIDs, DVIDs, ECIDs, // CEIDs, alarms, recipes), answers the GEM SxFy core, and emits S6F11 + // S5F1 primaries when host-subscribed events fire. #include #include #include #include #include #include #include #include #include #include "secsgem/endpoint.hpp" #include "secsgem/gem/control_state.hpp" #include "secsgem/gem/data_model.hpp" #include "secsgem/gem/messages.hpp" #include "secsgem/secs2/item.hpp" #include "secsgem/secs2/message.hpp" using namespace secsgem; namespace s2 = secsgem::secs2; namespace gem = secsgem::gem; namespace { std::string arg(int argc, char** argv, const std::string& key, const std::string& def) { for (int i = 1; i + 1 < argc; ++i) if (key == argv[i]) return argv[i + 1]; return def; } constexpr const char* kModelName = "SECSGEM-SIM"; constexpr const char* kSoftRev = "0.1.0"; // SVIDs constexpr uint32_t kSvidControlState = 1; constexpr uint32_t kSvidClock = 2; constexpr uint32_t kSvidEventsEnabled = 3; // ECIDs constexpr uint32_t kEcidTimeFormat = 10; constexpr uint32_t kEcidEstablishTimeout = 11; // CEIDs constexpr uint32_t kCeidControlStateChanged = 100; constexpr uint32_t kCeidAlarmSetEvent = 200; constexpr uint32_t kCeidProcessStarted = 300; // ALIDs constexpr uint32_t kAlarmChillerTempHigh = 1; constexpr uint32_t kAlarmDoorOpen = 2; void populate(gem::EquipmentDataModel& m, const gem::ControlStateMachine& sm) { m.add_status_variable( {kSvidControlState, "ControlState", "", s2::Item::ascii(gem::control_state_name(sm.state()))}); m.add_status_variable({kSvidClock, "Clock", "", s2::Item::ascii(m.current_time_string())}); m.add_status_variable({kSvidEventsEnabled, "EventsEnabled", "", s2::Item::boolean(true)}); m.add_equipment_constant({kEcidTimeFormat, "TimeFormat", "code", s2::Item::u4(uint32_t{1}), s2::Item::u4(uint32_t{1}), "0", "1"}); m.add_equipment_constant({kEcidEstablishTimeout, "EstablishCommTimeout", "sec", s2::Item::u4(uint32_t{10}), s2::Item::u4(uint32_t{10}), "1", "60"}); m.register_event({kCeidControlStateChanged, "ControlStateChanged"}); m.register_event({kCeidAlarmSetEvent, "AlarmSetEvent"}); m.register_event({kCeidProcessStarted, "ProcessStarted"}); m.add_alarm({kAlarmChillerTempHigh, "Chiller Temp High", 4}); m.add_alarm({kAlarmDoorOpen, "Door Open", 1}); m.add_process_program("RECIPE-A", "STEP CHAMBER ARGON 30s\nSTEP CHAMBER NITROGEN 60s\nEND"); m.add_process_program("RECIPE-B", "STEP HEATER 800C 120s\nEND"); } void refresh_dynamic_svids(gem::EquipmentDataModel& m, const gem::ControlStateMachine& sm) { m.set_status_value(kSvidControlState, s2::Item::ascii(gem::control_state_name(sm.state()))); m.set_status_value(kSvidClock, s2::Item::ascii(m.current_time_string())); } } // namespace int main(int argc, char** argv) { Server::Config cfg; cfg.port = static_cast(std::stoi(arg(argc, argv, "--port", "5000"))); cfg.device_id = static_cast(std::stoi(arg(argc, argv, "--device", "0"))); asio::io_context io; Server server(io, cfg); auto logfn = [](const std::string& m) { std::cout << "[equip] " << m << std::endl; }; server.on_log(logfn); auto sm = std::make_shared(); auto model = std::make_shared(); populate(*model, *sm); auto active_conn = std::make_shared>(); // Emit a CEID via S6F11 to the currently-active host connection, if any // and if the host has subscribed to that CEID via S2F37. auto emit_event = [&io, active_conn, model, logfn](uint32_t ceid) { asio::post(io, [active_conn, model, logfn, ceid]() { auto conn = active_conn->lock(); if (!conn) return; if (!model->is_event_enabled(ceid)) { logfn("CEID " + std::to_string(ceid) + " not enabled; suppressed"); return; } auto reports = model->compose_reports_for(ceid); logfn("emit S6F11 CEID=" + std::to_string(ceid) + " (" + std::to_string(reports.size()) + " reports)"); conn->send_request(gem::s6f11_event_report(0, ceid, reports), [logfn, ceid](std::error_code ec, const s2::Message&) { if (ec) logfn("CEID " + std::to_string(ceid) + " emit failed: " + ec.message()); }); }); }; // Equipment-initiated alarm set: emits S5F1 (if alarm enabled) and the // AlarmSetEvent CEID (if event enabled). auto emit_alarm_set = [&io, active_conn, model, logfn, emit_event](uint32_t alid) { asio::post(io, [active_conn, model, logfn, emit_event, alid]() { auto conn = active_conn->lock(); if (!conn) return; auto alarm = model->alarm(alid); if (!alarm) return; auto alcd = model->alarm_set(alid); if (!alcd) return; if (!model->alarm_enabled(alid)) { logfn("alarm " + std::to_string(alid) + " not enabled; suppressed"); } else { logfn("emit S5F1 alarm set ALID=" + std::to_string(alid)); conn->send_request(gem::s5f1_alarm_report(*alcd, alid, alarm->text), [](std::error_code, const s2::Message&) {}); } emit_event(kCeidAlarmSetEvent); }); }; sm->set_state_change_handler( [logfn, emit_event](gem::ControlState from, gem::ControlState to, gem::ControlEvent ev) { logfn(std::string("control: ") + gem::control_state_name(from) + " -> " + gem::control_state_name(to) + " (" + gem::control_event_name(ev) + ")"); emit_event(kCeidControlStateChanged); }); server.on_connection([sm, model, logfn, active_conn, emit_event, emit_alarm_set](std::shared_ptr conn) { *active_conn = conn; conn->set_closed_handler([active_conn, logfn](const std::string&) { active_conn->reset(); }); conn->set_selected_handler([logfn, sm]() { logfn(std::string("host is online; control=") + gem::control_state_name(sm->state())); }); // Host commands are registered here so they can capture emit_event / // emit_alarm_set without static globals. model->register_command("START", [logfn, emit_event](const auto& params) { logfn("RCMD START (" + std::to_string(params.size()) + " params)"); emit_event(kCeidProcessStarted); return gem::HostCmdAck::Accept; }); model->register_command("STOP", [](const auto&) { return gem::HostCmdAck::Accept; }); model->register_command("PAUSE", [](const auto&) { return gem::HostCmdAck::CannotDoNow; }); model->register_command("FAULT", [logfn, emit_alarm_set](const auto&) { logfn("RCMD FAULT triggers alarm 1"); emit_alarm_set(kAlarmChillerTempHigh); return gem::HostCmdAck::Accept; }); conn->set_message_handler([sm, model, logfn](const s2::Message& msg) -> std::optional { const uint8_t s = msg.stream, f = msg.function; // ---- S1: equipment status ---------------------------------------- if (s == 1 && f == 1) { logfn("S1F1; replying S1F2"); return gem::s1f2_on_line_data(kModelName, kSoftRev); } if (s == 1 && f == 3) { refresh_dynamic_svids(*model, *sm); auto svids = gem::parse_s1f3(msg); if (!svids) return s2::Message(1, 0, false); std::vector> values; if (svids->empty()) { for (const auto& sv : model->all_status_variables()) values.push_back(sv.value); } else { for (auto id : *svids) { auto sv = model->status_variable(id); values.push_back(sv ? std::optional(sv->value) : std::nullopt); } } logfn("S1F3; replying S1F4 (" + std::to_string(values.size()) + " values)"); return gem::s1f4_selected_status_data(values); } if (s == 1 && f == 11) { logfn("S1F11; replying S1F12 (status namelist)"); return gem::s1f12_status_namelist_data(model->all_status_variables()); } if (s == 1 && f == 13) { logfn("S1F13; replying S1F14 (COMMACK=0)"); return gem::s1f14_establish_comms_ack(gem::CommAck::Accept, kModelName, kSoftRev); } if (s == 1 && f == 15) { auto ack = sm->on_host_request_offline(); logfn(std::string("S1F15; replying S1F16 (OFLACK=") + std::to_string(static_cast(ack)) + ")"); return gem::s1f16_offline_ack(ack); } if (s == 1 && f == 17) { auto ack = sm->on_host_request_online(); logfn(std::string("S1F17; replying S1F18 (ONLACK=") + std::to_string(static_cast(ack)) + ")"); return gem::s1f18_online_ack(ack); } // ---- S2: equipment control + reports ----------------------------- if (s == 2 && f == 13) { auto ids = gem::parse_u4_list_body(msg); if (!ids) return s2::Message(2, 0, false); std::vector values; for (auto id : *ids) { auto ec = model->equipment_constant(id); values.push_back(ec ? ec->value : s2::Item::list({})); } logfn("S2F13; replying S2F14 (" + std::to_string(values.size()) + " values)"); return gem::s2f14_ec_data(values); } if (s == 2 && f == 15) { auto sets = gem::parse_s2f15(msg); if (!sets) return gem::s2f16_ec_ack(gem::EquipmentAck::Denied_OutOfRange); auto eac = gem::EquipmentAck::Accept; for (const auto& [id, val] : *sets) { auto r = model->set_equipment_constant_value(id, val); if (r != gem::EquipmentAck::Accept) eac = r; } logfn(std::string("S2F15; replying S2F16 (EAC=") + std::to_string(static_cast(eac)) + ")"); return gem::s2f16_ec_ack(eac); } if (s == 2 && f == 17) { logfn("S2F17; replying S2F18 (clock)"); return gem::s2f18_date_time_data(model->current_time_string()); } if (s == 2 && f == 29) { auto ids = gem::parse_u4_list_body(msg); std::vector ecs; if (ids && ids->empty()) { ecs = model->all_equipment_constants(); } else if (ids) { for (auto id : *ids) { auto ec = model->equipment_constant(id); if (ec) ecs.push_back(*ec); } } logfn("S2F29; replying S2F30 (" + std::to_string(ecs.size()) + " EC entries)"); return gem::s2f30_ec_namelist_data(ecs); } if (s == 2 && f == 31) { auto t = gem::parse_s2f31(msg); auto ack = t ? model->set_time_string(*t) : gem::TimeAck::Error; logfn(std::string("S2F31; replying S2F32 (TIACK=") + std::to_string(static_cast(ack)) + ")"); return gem::s2f32_date_time_ack(ack); } if (s == 2 && f == 33) { auto req = gem::parse_s2f33(msg); auto ack = req ? model->define_reports(req->reports) : gem::DefineReportAck::InvalidFormat; logfn(std::string("S2F33; replying S2F34 (DRACK=") + std::to_string(static_cast(ack)) + ")"); return gem::s2f34_define_report_ack(ack); } if (s == 2 && f == 35) { auto req = gem::parse_s2f35(msg); auto ack = req ? model->link_event_reports(req->links) : gem::LinkEventAck::InvalidFormat; logfn(std::string("S2F35; replying S2F36 (LRACK=") + std::to_string(static_cast(ack)) + ")"); return gem::s2f36_link_event_report_ack(ack); } if (s == 2 && f == 37) { auto req = gem::parse_s2f37(msg); auto ack = req ? model->enable_events(req->enable, req->ceids) : gem::EnableEventAck::UnknownCeid; logfn(std::string("S2F37 ") + (req && req->enable ? "enable" : "disable") + "; replying S2F38 (ERACK=" + std::to_string(static_cast(ack)) + ")"); return gem::s2f38_enable_event_ack(ack); } if (s == 2 && f == 41) { auto cmd = gem::parse_s2f41(msg); if (!cmd) return gem::s2f42_host_command_ack(gem::HostCmdAck::ParameterInvalid); auto ack = model->dispatch_command(cmd->rcmd, cmd->params); logfn("S2F41 RCMD=" + cmd->rcmd + "; replying S2F42 (HCACK=" + std::to_string(static_cast(ack)) + ")"); return gem::s2f42_host_command_ack(ack); } // ---- S5: alarms -------------------------------------------------- if (s == 5 && f == 3) { auto req = gem::parse_s5f3(msg); auto ack = req ? model->set_alarm_enabled(req->alid, req->enable) : gem::AlarmAck::Error; logfn(std::string("S5F3 ALID=") + (req ? std::to_string(req->alid) : "?") + (req && req->enable ? " enable" : " disable") + "; replying S5F4 (ACKC5=" + std::to_string(static_cast(ack)) + ")"); return gem::s5f4_enable_alarm_ack(ack); } if (s == 5 && f == 5) { auto ids = gem::parse_u4_list_body(msg); std::vector alarms; if (ids && ids->empty()) { alarms = model->all_alarms(); } else if (ids) { for (auto id : *ids) { auto a = model->alarm(id); if (a) alarms.push_back(*a); } } logfn("S5F5; replying S5F6 (" + std::to_string(alarms.size()) + " alarms)"); return gem::s5f6_list_alarms_data(alarms, [model](uint32_t id) { return model->alarm_active(id); }); } // ---- S7: process programs ---------------------------------------- if (s == 7 && f == 3) { auto pp = gem::parse_s7f3(msg); if (!pp) return gem::s7f4_process_program_ack(gem::ProcessProgramAck::LengthError); model->add_process_program(pp->ppid, pp->ppbody); logfn("S7F3 PPID=" + pp->ppid + " (" + std::to_string(pp->ppbody.size()) + " bytes); replying S7F4 (ACKC7=0)"); return gem::s7f4_process_program_ack(gem::ProcessProgramAck::Accept); } if (s == 7 && f == 5) { auto ppid = gem::parse_s7f5(msg); if (!ppid) return gem::s7f6_process_program_data("", ""); auto body = model->process_program(*ppid); logfn("S7F5 PPID=" + *ppid + (body ? "; replying S7F6" : "; PPID not found")); return gem::s7f6_process_program_data(*ppid, body ? *body : ""); } if (s == 7 && f == 19) { auto list = model->process_program_list(); logfn("S7F19; replying S7F20 (" + std::to_string(list.size()) + " PPIDs)"); return gem::s7f20_current_eppd_data(list); } // ---- S10: terminal services -------------------------------------- if (s == 10 && f == 1) { auto td = gem::parse_terminal_display(msg); if (td) logfn("TERMINAL[" + std::to_string(td->tid) + "] " + td->text); return gem::s10f2_terminal_display_ack(gem::TerminalAck::Accepted); } // Unhandled primaries expecting a reply get SxF0 (abort). if (msg.reply_expected) { logfn("unhandled " + msg.sml() + "; replying S" + std::to_string(s) + "F0"); return s2::Message(s, 0, false); } return std::nullopt; }); }); server.start(); io.run(); return 0; }