From fedd91b1e40079a956329a3826202622fb7d10cc Mon Sep 17 00:00:00 2001 From: Philippe Leduc Date: Tue, 26 May 2026 14:50:21 +0200 Subject: [PATCH] =?UTF-8?q?ESI=20parser=20step=203:=20Mailbox=20+=20InitCm?= =?UTF-8?q?ds=20Parse=20=20with=20all=20six=20protocols=20(CoE/Eo?= =?UTF-8?q?E/FoE/SoE/AoE/VoE)=20and=20their=20=20lists=20per=20ES?= =?UTF-8?q?M=20Transition.=20Protocol=20blocks=20exposed=20as=20std::optio?= =?UTF-8?q?nal<=E2=80=A6>=20on=20the=20Mailbox=20aggregate;=20schema-manda?= =?UTF-8?q?tory=20/=20enforced=20with=20contextual=20err?= =?UTF-8?q?ors.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/include/kickcat/ESI/Device.h | 108 ++++++++++ lib/include/kickcat/ESI/Parser.h | 1 + lib/src/ESI/Parser.cc | 230 ++++++++++++++++++++- unit/kickcat_esi_test_sm_fmmu.xml | 41 ++++ unit/src/ESI/Parser-t.cc | 325 ++++++++++++++++++++++++++++++ 5 files changed, 703 insertions(+), 2 deletions(-) diff --git a/lib/include/kickcat/ESI/Device.h b/lib/include/kickcat/ESI/Device.h index c3333183..1d54c9f3 100644 --- a/lib/include/kickcat/ESI/Device.h +++ b/lib/include/kickcat/ESI/Device.h @@ -42,6 +42,113 @@ namespace kickcat::ESI bool op_only = false; }; + // ETG.2000 InitCmd Transition: ESM transitions during which the InitCmd applies. + namespace transition + { + enum Type : uint8_t + { + IP = 0, // Init -> PreOp + PS = 1, // PreOp -> SafeOp + SO = 2, // SafeOp -> Op + SP = 3, // SafeOp -> PreOp + OP = 4, // Op -> PreOp + OS = 5, // Op -> SafeOp + }; + char const* toString(Type const& t); + void fromString(std::string_view text, Type& out); + } + + struct Mailbox + { + bool data_link_layer = false; + bool real_time_mode = false; + + struct CoE + { + struct InitCmd + { + std::vector transitions; + uint16_t index = 0; + uint8_t subindex = 0; + std::vector data; + bool adapt_automatically = false; + bool complete_access = false; + bool overwritten_by_module = false; + std::string comment; + }; + + bool sdo_info = false; + bool pdo_assign = false; + bool pdo_config = false; + bool pdo_upload = false; + bool complete_access = false; + bool segmented_sdo = false; + bool diag_history = false; + bool sdo_upload_with_max_length = false; + bool time_distribution = false; + std::string eds_file; + std::vector init_cmds; + }; + + struct EoE + { + struct InitCmd + { + std::vector transitions; + int32_t type = 0; + std::vector data; + std::string comment; + }; + + bool ip = false; + bool mac = false; + bool time_stamp = false; + std::vector init_cmds; + }; + + struct FoE {}; + + struct SoE + { + struct InitCmd + { + std::vector transitions; + int32_t idn = 0; + int32_t channel = 0; + std::vector data; + std::string comment; + }; + + std::optional channel_count; + bool drive_follows_bit3 = false; + std::vector init_cmds; + }; + + struct AoE + { + struct InitCmd + { + std::vector transitions; + std::vector data; + std::string comment; + }; + + bool ads_router = false; + bool generate_own_net_id = false; + bool initialize_own_net_id = false; + std::vector init_cmds; + }; + + struct VoE {}; + + std::optional coe; + std::optional eoe; + std::optional foe; + std::optional soe; + std::optional aoe; + std::optional voe; + }; + struct DeviceSummary { std::string type; @@ -75,6 +182,7 @@ namespace kickcat::ESI std::vector sync_managers; std::vector sync_units; std::vector fmmus; + Mailbox mailbox; CoE::Dictionary dictionary; }; diff --git a/lib/include/kickcat/ESI/Parser.h b/lib/include/kickcat/ESI/Parser.h index f3edb025..fcde4e5b 100644 --- a/lib/include/kickcat/ESI/Parser.h +++ b/lib/include/kickcat/ESI/Parser.h @@ -46,6 +46,7 @@ namespace kickcat::ESI void parseSyncManagers(tinyxml2::XMLElement* device, std::vector& out); void parseSyncUnits (tinyxml2::XMLElement* device, std::vector& out); void parseFmmus (tinyxml2::XMLElement* device, std::vector& out); + void parseMailbox (tinyxml2::XMLElement* device, Mailbox& out); CoE::Dictionary buildDictionary(tinyxml2::XMLElement* profile, std::vector const& sms); diff --git a/lib/src/ESI/Parser.cc b/lib/src/ESI/Parser.cc index bd8d10b4..084c0867 100644 --- a/lib/src/ESI/Parser.cc +++ b/lib/src/ESI/Parser.cc @@ -154,7 +154,10 @@ namespace return buf; } - // xs:boolean per the ESI schema: accepts "true"/"false" and "1"/"0". + // xs:boolean per the ESI schema. Returns false when the attribute is + // absent (schema default for the ESI attrs we read); throws when present + // but not one of the four canonical values, so typos like Virtual="yes" + // fail loudly instead of silently being treated as false. bool readBoolAttr(XMLElement* node, char const* name) { if (node == nullptr) @@ -166,7 +169,77 @@ namespace { return false; } - return std::strcmp(raw, "true") == 0 or std::strcmp(raw, "1") == 0; + if (std::strcmp(raw, "true") == 0 or std::strcmp(raw, "1") == 0) { return true; } + if (std::strcmp(raw, "false") == 0 or std::strcmp(raw, "0") == 0) { return false; } + + std::string what = "ESI: attribute '"; + what += name; + what += "' is not a valid xs:boolean (got '"; + what += raw; + what += "')"; + throw std::invalid_argument(what); + } + + // xs:int attribute: signed decimal, no #x hex prefix. nullopt when truly + // absent; throws when present but not a valid xs:int (e.g. Chn="abc"), + // so callers can use .value_or(default) for the schema-default case + // without silently swallowing malformed input. + std::optional readIntAttr(XMLElement* node, char const* name) + { + if (node == nullptr) + { + return std::nullopt; + } + int32_t value = 0; + auto result = node->QueryIntAttribute(name, &value); + if (result == tinyxml2::XML_NO_ATTRIBUTE) + { + return std::nullopt; + } + if (result != tinyxml2::XML_SUCCESS) + { + std::string what = "ESI: attribute '"; + what += name; + what += "' is not a valid xs:int"; + throw std::invalid_argument(what); + } + return value; + } + + std::vector parseTransitions(XMLElement* parent) + { + std::vector out; + for (auto* t = parent->FirstChildElement("Transition"); t != nullptr; t = t->NextSiblingElement("Transition")) + { + char const* text = t->GetText(); + if (text == nullptr) + { + throw std::invalid_argument("ESI: empty "); + } + transition::Type type; + fromString(text, type); + out.push_back(type); + } + if (out.empty()) + { + throw std::invalid_argument("ESI: InitCmd has no child (schema requires at least one)"); + } + return out; + } + + std::string commentOf(XMLElement* parent) + { + auto* c = parent->FirstChildElement("Comment"); + if (c == nullptr) + { + return {}; + } + char const* text = c->GetText(); + if (text == nullptr) + { + return {}; + } + return text; } } @@ -346,11 +419,164 @@ Device Parser::loadDeviceImpl(DeviceFilter const& filter) parseSyncManagers(device_node, device.sync_managers); parseSyncUnits (device_node, device.sync_units); parseFmmus (device_node, device.fmmus); + parseMailbox (device_node, device.mailbox); device.dictionary = buildDictionary(profile_node, device.sync_managers); return device; } +namespace transition +{ + char const* toString(Type const& t) + { + switch (t) + { + case IP: { return "IP"; } + case PS: { return "PS"; } + case SO: { return "SO"; } + case SP: { return "SP"; } + case OP: { return "OP"; } + case OS: { return "OS"; } + default: { return "unknown"; } + } + } + + void fromString(std::string_view text, Type& out) + { + if (text == "IP") { out = IP; return; } + if (text == "PS") { out = PS; return; } + if (text == "SO") { out = SO; return; } + if (text == "SP") { out = SP; return; } + if (text == "OP") { out = OP; return; } + if (text == "OS") { out = OS; return; } + + std::string what = "ESI: unknown Transition '"; + what.append(text); + what += "'"; + throw std::invalid_argument(what); + } +} + +void Parser::parseMailbox(XMLElement* device, Mailbox& out) +{ + // The / block is distinct from // + // (which carries request/response timeouts). Iterate children only — never + // pick the one nested under . + auto* mbx = device->FirstChildElement("Mailbox"); + if (mbx == nullptr) + { + return; + } + + out.data_link_layer = readBoolAttr(mbx, "DataLinkLayer"); + out.real_time_mode = readBoolAttr(mbx, "RealTimeMode"); + + if (auto* coe = mbx->FirstChildElement("CoE")) + { + Mailbox::CoE block; + block.sdo_info = readBoolAttr(coe, "SdoInfo"); + block.pdo_assign = readBoolAttr(coe, "PdoAssign"); + block.pdo_config = readBoolAttr(coe, "PdoConfig"); + block.pdo_upload = readBoolAttr(coe, "PdoUpload"); + block.complete_access = readBoolAttr(coe, "CompleteAccess"); + block.segmented_sdo = readBoolAttr(coe, "SegmentedSdo"); + block.diag_history = readBoolAttr(coe, "DiagHistory"); + block.sdo_upload_with_max_length = readBoolAttr(coe, "SdoUploadWithMaxLength"); + block.time_distribution = readBoolAttr(coe, "TimeDistribution"); + if (char const* eds = coe->Attribute("EdsFile")) + { + block.eds_file = eds; + } + + // CoE/Object (legacy form per ETG.2000 — flagged obsolete in the XSD) is + // intentionally ignored; modern ESIs use below. + for (auto* ic = coe->FirstChildElement("InitCmd"); ic != nullptr; ic = ic->NextSiblingElement("InitCmd")) + { + Mailbox::CoE::InitCmd cmd; + cmd.transitions = parseTransitions(ic); + cmd.index = requireNumber(ic, "Index", "Mailbox/CoE/InitCmd"); + cmd.subindex = requireNumber (ic, "SubIndex", "Mailbox/CoE/InitCmd"); + auto* data = requireChild(ic, "Data"); + cmd.data = loadHexBinary(data); + cmd.adapt_automatically = readBoolAttr(data, "AdaptAutomatically"); + cmd.complete_access = readBoolAttr(ic, "CompleteAccess"); + cmd.overwritten_by_module = readBoolAttr(ic, "OverwrittenByModule"); + cmd.comment = commentOf(ic); + block.init_cmds.push_back(std::move(cmd)); + } + out.coe = std::move(block); + } + + if (auto* eoe = mbx->FirstChildElement("EoE")) + { + Mailbox::EoE block; + block.ip = readBoolAttr(eoe, "IP"); + block.mac = readBoolAttr(eoe, "MAC"); + block.time_stamp = readBoolAttr(eoe, "TimeStamp"); + + for (auto* ic = eoe->FirstChildElement("InitCmd"); ic != nullptr; ic = ic->NextSiblingElement("InitCmd")) + { + Mailbox::EoE::InitCmd cmd; + cmd.transitions = parseTransitions(ic); + cmd.type = requireNumber(ic, "Type", "Mailbox/EoE/InitCmd"); + cmd.data = loadHexBinary(requireChild(ic, "Data")); + cmd.comment = commentOf(ic); + block.init_cmds.push_back(std::move(cmd)); + } + out.eoe = std::move(block); + } + + if (mbx->FirstChildElement("FoE") != nullptr) + { + out.foe = Mailbox::FoE{}; + } + + if (auto* soe = mbx->FirstChildElement("SoE")) + { + Mailbox::SoE block; + block.channel_count = readIntAttr(soe, "ChannelCount"); + block.drive_follows_bit3 = readBoolAttr(soe, "DriveFollowsBit3Support"); + + for (auto* ic = soe->FirstChildElement("InitCmd"); ic != nullptr; ic = ic->NextSiblingElement("InitCmd")) + { + Mailbox::SoE::InitCmd cmd; + cmd.transitions = parseTransitions(ic); + cmd.idn = requireNumber(ic, "IDN", "Mailbox/SoE/InitCmd"); + cmd.channel = readIntAttr(ic, "Chn").value_or(0); + cmd.data = loadHexBinary(requireChild(ic, "Data")); + cmd.comment = commentOf(ic); + block.init_cmds.push_back(std::move(cmd)); + } + out.soe = std::move(block); + } + + if (auto* aoe = mbx->FirstChildElement("AoE")) + { + Mailbox::AoE block; + block.ads_router = readBoolAttr(aoe, "AdsRouter"); + block.generate_own_net_id = readBoolAttr(aoe, "GenerateOwnNetId"); + block.initialize_own_net_id = readBoolAttr(aoe, "InitializeOwnNetId"); + + for (auto* ic = aoe->FirstChildElement("InitCmd"); ic != nullptr; ic = ic->NextSiblingElement("InitCmd")) + { + Mailbox::AoE::InitCmd cmd; + cmd.transitions = parseTransitions(ic); + cmd.data = loadHexBinary(requireChild(ic, "Data")); + cmd.comment = commentOf(ic); + block.init_cmds.push_back(std::move(cmd)); + } + out.aoe = std::move(block); + } + + if (mbx->FirstChildElement("VoE") != nullptr) + { + out.voe = Mailbox::VoE{}; + } + + // / is part of the schema but intentionally not + // surfaced on the Mailbox aggregate — open vendor content has no consumer. +} + void Parser::parseSyncManagers(XMLElement* device, std::vector& out) { for (auto* sm = device->FirstChildElement("Sm"); sm != nullptr; sm = sm->NextSiblingElement("Sm")) diff --git a/unit/kickcat_esi_test_sm_fmmu.xml b/unit/kickcat_esi_test_sm_fmmu.xml index 779260e0..2e2bd7a2 100644 --- a/unit/kickcat_esi_test_sm_fmmu.xml +++ b/unit/kickcat_esi_test_sm_fmmu.xml @@ -46,6 +46,47 @@ MBoxIn Outputs Inputs + + + + PS + DEADBEEF + AoE init + + + + + IP + PS + 5 + 0102 + + + + + PS + #x1C12 + #x00 + 0001 + Assign RxPDO 0x1600 + + + PS + #x1C13 + #x00 + 0001 + + + + + + PS + 32 + 00 + + + + diff --git a/unit/src/ESI/Parser-t.cc b/unit/src/ESI/Parser-t.cc index 1e8f5a81..dceb8b5b 100644 --- a/unit/src/ESI/Parser-t.cc +++ b/unit/src/ESI/Parser-t.cc @@ -677,6 +677,331 @@ TEST(ESIParser, sync_managers_drive_legacy_0x1C00_synthesis) ASSERT_EQ(sm_kinds[3], 4u); // Inputs } +TEST(ESIParser, loadDevice_parses_mailbox) +{ + ESI::Parser parser; + ESI::Device device = parser.loadDevice("kickcat_esi_test_sm_fmmu.xml"); + + ASSERT_TRUE (device.mailbox.data_link_layer); + ASSERT_FALSE(device.mailbox.real_time_mode); + + ASSERT_TRUE(device.mailbox.coe.has_value()); + auto const& coe = *device.mailbox.coe; + ASSERT_TRUE(coe.sdo_info); + ASSERT_TRUE(coe.complete_access); + ASSERT_TRUE(coe.pdo_assign); + ASSERT_TRUE(coe.pdo_config); + ASSERT_TRUE(coe.segmented_sdo); + ASSERT_FALSE(coe.diag_history); + ASSERT_EQ(coe.eds_file, "MyDevice.eds"); + ASSERT_EQ(coe.init_cmds.size(), 2u); + + auto const& coe_ic0 = coe.init_cmds[0]; + ASSERT_EQ(coe_ic0.transitions.size(), 1u); + ASSERT_EQ(coe_ic0.transitions[0], ESI::transition::PS); + ASSERT_EQ(coe_ic0.index, 0x1C12); + ASSERT_EQ(coe_ic0.subindex, 0x00); + ASSERT_EQ(coe_ic0.data.size(), 2u); + ASSERT_EQ(coe_ic0.data[0], 0x00); + ASSERT_EQ(coe_ic0.data[1], 0x01); + ASSERT_TRUE(coe_ic0.adapt_automatically); + ASSERT_TRUE(coe_ic0.complete_access); + ASSERT_EQ(coe_ic0.comment, "Assign RxPDO 0x1600"); + + ASSERT_TRUE(device.mailbox.eoe.has_value()); + auto const& eoe = *device.mailbox.eoe; + ASSERT_TRUE(eoe.ip); + ASSERT_TRUE(eoe.mac); + ASSERT_FALSE(eoe.time_stamp); + ASSERT_EQ(eoe.init_cmds.size(), 1u); + ASSERT_EQ(eoe.init_cmds[0].transitions.size(), 2u); + ASSERT_EQ(eoe.init_cmds[0].transitions[0], ESI::transition::IP); + ASSERT_EQ(eoe.init_cmds[0].transitions[1], ESI::transition::PS); + ASSERT_EQ(eoe.init_cmds[0].type, 5); + + ASSERT_TRUE(device.mailbox.aoe.has_value()); + auto const& aoe = *device.mailbox.aoe; + ASSERT_TRUE(aoe.ads_router); + ASSERT_TRUE(aoe.generate_own_net_id); + ASSERT_FALSE(aoe.initialize_own_net_id); + ASSERT_EQ(aoe.init_cmds.size(), 1u); + ASSERT_EQ(aoe.init_cmds[0].comment, "AoE init"); + ASSERT_EQ(aoe.init_cmds[0].data.size(), 4u); + + ASSERT_TRUE(device.mailbox.soe.has_value()); + auto const& soe = *device.mailbox.soe; + ASSERT_TRUE(soe.channel_count.has_value()); + ASSERT_EQ(*soe.channel_count, 2); + ASSERT_TRUE(soe.drive_follows_bit3); + ASSERT_EQ(soe.init_cmds.size(), 1u); + ASSERT_EQ(soe.init_cmds[0].idn, 32); + ASSERT_EQ(soe.init_cmds[0].channel, 1); + + ASSERT_TRUE(device.mailbox.foe.has_value()); + ASSERT_TRUE(device.mailbox.voe.has_value()); +} + +TEST(ESIParser, mailbox_absent_when_block_missing) +{ + ESI::Parser parser; + ESI::Device device = parser.loadDevice("kickcat_esi_test_multi_device.xml"); + + ASSERT_FALSE(device.mailbox.data_link_layer); + ASSERT_FALSE(device.mailbox.coe.has_value()); + ASSERT_FALSE(device.mailbox.eoe.has_value()); + ASSERT_FALSE(device.mailbox.foe.has_value()); + ASSERT_FALSE(device.mailbox.soe.has_value()); + ASSERT_FALSE(device.mailbox.aoe.has_value()); + ASSERT_FALSE(device.mailbox.voe.has_value()); +} + +TEST(ESIParser, mailbox_throws_on_unknown_transition) +{ + char const* xml = R"( + + #x1V + + T + 0 + + UDINT32 + + #x1000XUDINT32 + + + + + + + XX + #x1000 + #x00 + 00 + + + + + )"; + + ESI::Parser parser; + try + { + (void) parser.loadString(xml); + FAIL() << "expected invalid_argument"; + } + catch (std::invalid_argument const& e) + { + std::string msg = e.what(); + ASSERT_NE(msg.find("Transition"), std::string::npos) << msg; + ASSERT_NE(msg.find("XX"), std::string::npos) << msg; + } +} + +TEST(ESIParser, mailbox_throws_when_coe_initcmd_missing_data) +{ + char const* xml = R"( + + #x1V + + T + 0 + + UDINT32 + + #x1000XUDINT32 + + + + + + + PS + #x1000 + #x00 + + + + + )"; + + ESI::Parser parser; + ASSERT_THROW((void) parser.loadString(xml), std::invalid_argument); +} + +TEST(ESIParser, mailbox_throws_when_initcmd_has_no_transition) +{ + char const* xml = R"( + + #x1V + + T + 0 + + UDINT32 + + #x1000XUDINT32 + + + + + + + #x1000 + #x00 + 00 + + + + + )"; + + ESI::Parser parser; + try + { + (void) parser.loadString(xml); + FAIL() << "expected invalid_argument"; + } + catch (std::invalid_argument const& e) + { + std::string msg = e.what(); + ASSERT_NE(msg.find("Transition"), std::string::npos) << msg; + } +} + +TEST(ESIParser, throws_on_malformed_bool_attribute) +{ + char const* xml = R"( + + #x1V + + T + 0 + + UDINT32 + + #x1000XUDINT32 + + + + MBoxOut + + )"; + + ESI::Parser parser; + try + { + (void) parser.loadString(xml); + FAIL() << "expected invalid_argument"; + } + catch (std::invalid_argument const& e) + { + std::string msg = e.what(); + ASSERT_NE(msg.find("Virtual"), std::string::npos) << msg; + ASSERT_NE(msg.find("yes"), std::string::npos) << msg; + } +} + +TEST(ESIParser, accepts_canonical_bool_values_false_and_zero) +{ + // "false" and "0" are canonical xs:boolean values; ensure they don't throw. + char const* xml = R"( + + #x1V + + T + 0 + + UDINT32 + + #x1000XUDINT32 + + + + MBoxOut + + )"; + + ESI::Parser parser; + ESI::Device device = parser.loadDeviceString(xml); + ASSERT_EQ(device.sync_managers.size(), 1u); + ASSERT_FALSE(device.sync_managers[0].is_virtual); + ASSERT_FALSE(device.sync_managers[0].op_only); +} + +TEST(ESIParser, mailbox_soe_initcmd_throws_on_malformed_chn) +{ + char const* xml = R"( + + #x1V + + T + 0 + + UDINT32 + + #x1000XUDINT32 + + + + + + + PS + 5 + 00 + + + + + )"; + + ESI::Parser parser; + try + { + (void) parser.loadString(xml); + FAIL() << "expected invalid_argument"; + } + catch (std::invalid_argument const& e) + { + std::string msg = e.what(); + ASSERT_NE(msg.find("Chn"), std::string::npos) << msg; + } +} + +TEST(ESIParser, mailbox_soe_initcmd_channel_defaults_to_zero) +{ + char const* xml = R"( + + #x1V + + T + 0 + + UDINT32 + + #x1000XUDINT32 + + + + + + + PS + 5 + 00 + + + + + )"; + + ESI::Parser parser; + ESI::Device device = parser.loadDeviceString(xml); + ASSERT_TRUE(device.mailbox.soe.has_value()); + ASSERT_EQ(device.mailbox.soe->init_cmds.size(), 1u); + ASSERT_EQ(device.mailbox.soe->init_cmds[0].channel, 0); +} + TEST(ESIParser, throws_on_unknown_fmmu_text) { char const* xml = R"(