diff --git a/CMakeLists.txt b/CMakeLists.txt index 567e1b4d..de9f1db5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,7 +14,7 @@ else() set(KICKCAT_INSTALL_DEFAULT OFF) endif() -# Host-only components (tests, simulation, GUI tools, ESI parser, master examples) need desktop +# Host-only components (tests, simulation, GUI tools, ESI/ENI parsers, master examples) need desktop # deps and default ON for Linux/Windows only; embedded targets get the library + their slave example. if (UNIX OR WIN32) set(KICKCAT_HOST ON) @@ -28,6 +28,7 @@ option(BUILD_UNIT_TESTS "Build unit tests" OFF) option(BUILD_SIMULATION "Build simulation" ${KICKCAT_HOST}) option(BUILD_TOOLS "Build tools" ${KICKCAT_HOST}) option(ENABLE_ESI_PARSER "Enable ESI XML parser" ${KICKCAT_HOST}) +option(ENABLE_ENI_PARSER "Enable ENI XML parser" ${KICKCAT_HOST}) option(ENABLE_AF_XDP "Enable AF_XDP socket for improved performance (requires libxdp, libbpf, clang)" OFF) option(ENABLE_CODE_COVERAGE "Enable code coverage (requires gcovr)" OFF) option(ENABLE_ASAN "Build with AddressSanitizer" OFF) diff --git a/cmake/KickCATConfig.cmake.in b/cmake/KickCATConfig.cmake.in index 636cc80c..cfe41266 100644 --- a/cmake/KickCATConfig.cmake.in +++ b/cmake/KickCATConfig.cmake.in @@ -3,9 +3,10 @@ include(CMakeFindDependencyMacro) set(KICKCAT_ENABLE_ESI_PARSER @ENABLE_ESI_PARSER@) +set(KICKCAT_ENABLE_ENI_PARSER @ENABLE_ENI_PARSER@) set(KICKCAT_ENABLE_AF_XDP @ENABLE_AF_XDP@) -if(KICKCAT_ENABLE_ESI_PARSER) +if(KICKCAT_ENABLE_ESI_PARSER OR KICKCAT_ENABLE_ENI_PARSER) find_dependency(tinyxml2 CONFIG) endif() diff --git a/conan/all/conanfile.py b/conan/all/conanfile.py index 07ca5a80..4f8afaf6 100644 --- a/conan/all/conanfile.py +++ b/conan/all/conanfile.py @@ -18,8 +18,8 @@ class KickCATRecipe(ConanFile): topics = ("ethercat") package_type = "library" settings = "os", "compiler", "build_type", "arch" - options = {"shared": [True, False], "fPIC": [True, False], "with_esi_parser": [True, False]} - default_options = {"shared": False, "fPIC": True, "with_esi_parser": False} + options = {"shared": [True, False], "fPIC": [True, False], "with_esi_parser": [True, False], "with_eni_parser": [True, False]} + default_options = {"shared": False, "fPIC": True, "with_esi_parser": False, "with_eni_parser": False} def export_sources(self): # This method is used for local development to copy sources into the recipe @@ -61,13 +61,14 @@ def validate(self): def requirements(self): self.requires("argparse/3.2") - if self.options.with_esi_parser: + if self.options.with_esi_parser or self.options.with_eni_parser: self.requires("tinyxml2/11.0.0") def generate(self): tc = CMakeToolchain(self) tc.cache_variables["ENABLE_ESI_PARSER"] = bool(self.options.with_esi_parser) + tc.cache_variables["ENABLE_ENI_PARSER"] = bool(self.options.with_eni_parser) tc.cache_variables["BUILD_UNIT_TESTS"] = "OFF" tc.cache_variables["BUILD_MASTER_EXAMPLES"] = "OFF" tc.cache_variables["BUILD_SLAVE_EXAMPLES"] = "OFF" diff --git a/conan/conanfile.py b/conan/conanfile.py index b6e6e8ee..04b41e5b 100644 --- a/conan/conanfile.py +++ b/conan/conanfile.py @@ -12,6 +12,7 @@ class KickCATDev(ConanFile): options = { "unit_tests": [True, False], "esi_parser": [True, False], + "eni_parser": [True, False], "simulation": [True, False], "tools": [True, False], "master_examples": [True, False], @@ -21,6 +22,7 @@ class KickCATDev(ConanFile): default_options = { "unit_tests": False, "esi_parser": True, + "eni_parser": True, "simulation": True, "tools": True, "master_examples": True, @@ -34,7 +36,7 @@ def requirements(self): if self.options.unit_tests: self.requires("gtest/1.15.0") - if self.options.esi_parser: + if self.options.esi_parser or self.options.eni_parser: self.requires("tinyxml2/11.0.0") if self.options.simulation: diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt index 264a53b1..d52e3fcc 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -89,12 +89,19 @@ elseif (WIN32) set(OS_LIBRARIES npcap::npcap pthread) endif() -if (ENABLE_ESI_PARSER) +if (ENABLE_ESI_PARSER OR ENABLE_ENI_PARSER) find_package(tinyxml2 CONFIG REQUIRED) + list(APPEND OS_LIBRARIES tinyxml2::tinyxml2) + list(APPEND KICKCAT_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/utils/xml.cc) +endif() + +if (ENABLE_ESI_PARSER) list(APPEND KICKCAT_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/ESI/Parser.cc) list(APPEND KICKCAT_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/ESI/SIIBuilder.cc) - list(APPEND KICKCAT_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/utils/xml.cc) - list(APPEND OS_LIBRARIES tinyxml2::tinyxml2) +endif() + +if (ENABLE_ENI_PARSER) + list(APPEND KICKCAT_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/ENI/Parser.cc) endif() if (ENABLE_AF_XDP AND LINUX) diff --git a/lib/include/kickcat/ENI/Parser.h b/lib/include/kickcat/ENI/Parser.h new file mode 100644 index 00000000..dd51b298 --- /dev/null +++ b/lib/include/kickcat/ENI/Parser.h @@ -0,0 +1,15 @@ +#ifndef KICKCAT_ENI_PARSER_H +#define KICKCAT_ENI_PARSER_H + +#include + +#include "kickcat/ENI/Config.h" + +namespace kickcat::ENI +{ + // Throw std::invalid_argument on malformed input, with the offending element path. + Config loadFile(std::string const& path); + Config loadString(std::string const& xml); +} + +#endif diff --git a/lib/src/ENI/Parser.cc b/lib/src/ENI/Parser.cc new file mode 100644 index 00000000..393f9886 --- /dev/null +++ b/lib/src/ENI/Parser.cc @@ -0,0 +1,480 @@ +#include + +#include "kickcat/ENI/Parser.h" +#include "kickcat/utils/xml.h" + +using namespace tinyxml2; +using namespace kickcat::xml; + +namespace kickcat::ENI +{ + namespace + { + void checkDatagramSize(XMLElement const* elem, std::size_t size) + { + if (size > MAX_ETHERCAT_PAYLOAD_SIZE) + { + fail(elem, "datagram data of " + std::to_string(size) + " bytes does not fit in a frame"); + } + } + + // Checked, not kept: the master sends its own addresses and EtherCAT frames. + void checkFrameInfo(XMLElement* info) + { + for (char const* name : {"Destination", "Source"}) + { + XMLElement* mac = require(info, name); + if (hexBinary(mac).size() != 6) + { + fail(mac, "MAC address must be 6 bytes"); + } + } + + // In network order (88a4) as ETG.2100 writes it, or byte-swapped (a488) as TwinCAT does. + if (XMLElement* ether_type = info->FirstChildElement("EtherType")) + { + std::vector raw = hexBinary(ether_type); + bool network = (raw == std::vector{0x88, 0xa4}); + bool swapped = (raw == std::vector{0xa4, 0x88}); + if (not network and not swapped) + { + fail(ether_type, "EtherType must be EtherCAT (88a4)"); + } + } + } + + Transitions parseTransitions(XMLElement* parent) + { + Transitions out = 0; + for (XMLElement* t = parent->FirstChildElement("Transition"); t != nullptr; t = t->NextSiblingElement("Transition")) + { + try + { + out |= transition::fromString(textOf(t)); + } + catch (std::invalid_argument const& e) + { + fail(t, e.what()); + } + } + return out; + } + + uint32_t parseAddress(XMLElement* node, Command cmd) + { + XMLElement* addr = node->FirstChildElement("Addr"); + XMLElement* ado_node = node->FirstChildElement("Ado"); + bool logical = (cmd == Command::LRD) or (cmd == Command::LWR) or (cmd == Command::LRW); + if (logical) + { + if (ado_node != nullptr or node->FirstChildElement("Adp") != nullptr) + { + fail(node, "a logical command is addressed by , not /"); + } + if (addr == nullptr) + { + fail(node, "missing for a logical command"); + } + return numberOf(addr, BIT_PATTERN); + } + + if (addr != nullptr) + { + fail(node, " is only for logical commands (LRD, LWR, LRW)"); + } + if (ado_node == nullptr) + { + fail(node, "missing "); + } + uint16_t ado = numberOf(ado_node); + uint16_t adp = optionalNumber(node, "Adp", BIT_PATTERN).value_or(0); + return createAddress(adp, ado); + } + + Command parseCommand(XMLElement* node) + { + XMLElement* elem = require(node, "Cmd"); + uint8_t raw = numberOf(elem); + if (raw > static_cast(Command::FRMW)) + { + fail(elem, "unknown command " + std::to_string(raw)); + } + return static_cast(raw); + } + + // True when the reply is compared (EQ), false when it is not (NONE). + bool comparesReply(XMLElement const* elem) + { + std::optional raw = attributeOf(elem, "Type"); + if (not raw) + { + return true; + } + std::string_view text = *raw; + if (text == "EQ") + { + return true; + } + if (text == "NONE") + { + return false; + } + if (text == "NOT_EQ" or text == "EQ_OR_G" or text == "EQ_OR_L" or text == "G" or text == "L") + { + fail(elem, std::string{"unsupported Validate type '"} + *raw + "', only EQ and NONE are"); + } + fail(elem, std::string{"unknown Validate type '"} + *raw + "'"); + } + + void parseDatagram(XMLElement* node, Datagram& datagram) + { + datagram.comment = optionalText(node, "Comment"); + datagram.cmd = parseCommand(node); + datagram.address = parseAddress(node, datagram.cmd); + + XMLElement* data = node->FirstChildElement("Data"); + XMLElement* length = node->FirstChildElement("DataLength"); + if (data != nullptr and length != nullptr) + { + fail(node, " cannot be combined with "); + } + if (data != nullptr) + { + datagram.data = hexBinary(data); + checkDatagramSize(data, datagram.data.size()); + } + if (length != nullptr) + { + uint16_t size = numberOf(length); + checkDatagramSize(length, size); + datagram.data.resize(size, 0); + } + datagram.expected_wkc = optionalNumber(node, "Cnt"); + } + + InitCmd parseInitCmd(XMLElement* node) + { + InitCmd cmd; + parseDatagram(node, cmd); + cmd.transitions = parseTransitions(node); + cmd.before_slave = optionalBool(node, "BeforeSlave"); + + if (XMLElement* req = node->FirstChildElement("Requires")) + { + std::string text = textOf(req); + if (text == "frame") + { + cmd.frame_requirement = requirement::FRAME; + } + else if (text == "cycle") + { + cmd.frame_requirement = requirement::CYCLE; + } + else + { + fail(req, "unknown Requires '" + text + "'"); + } + } + + cmd.retries = optionalNumber(node, "Retries").value_or(0); + + if (XMLElement* validate = node->FirstChildElement("Validate")) + { + Validate v; + v.data = hexBinary(require(validate, "Data")); + if (XMLElement* mask = validate->FirstChildElement("DataMask")) + { + v.mask = hexBinary(mask); + if (v.mask.size() != v.data.size()) + { + fail(mask, "DataMask size differs from Data size"); + } + } + if (v.data.size() > cmd.data.size()) + { + fail(validate, "Validate Data is longer than the command data"); + } + v.timeout = milliseconds{requireNumber(validate, "Timeout")}; + if (comparesReply(validate)) + { + cmd.validate = std::move(v); + } + else + { + cmd.timeout = v.timeout; + } + } + else + { + cmd.timeout = milliseconds{optionalNumber(node, "Timeout").value_or(0)}; + } + return cmd; + } + + std::vector parseInitCmds(XMLElement* parent) + { + XMLElement* list = parent->FirstChildElement("InitCmds"); + if (list == nullptr) + { + return {}; + } + return all(list, "InitCmd", parseInitCmd); + } + + CoEInitCmd parseCoEInitCmd(XMLElement* node) + { + CoEInitCmd cmd; + cmd.transitions = parseTransitions(node); + if (cmd.transitions == 0) + { + fail(node, "missing mandatory "); + } + cmd.comment = optionalText(node, "Comment"); + cmd.timeout = milliseconds{requireNumber(node, "Timeout")}; + cmd.ccs = requireNumber(node, "Ccs"); + cmd.index = requireNumber(node, "Index"); + cmd.subindex = requireNumber(node, "SubIndex"); + cmd.disabled = optionalBool(node, "Disabled"); + cmd.fixed = boolAttribute(node, "Fixed"); + cmd.complete_access = boolAttribute(node, "CompleteAccess"); + if (XMLElement* data = node->FirstChildElement("Data")) + { + cmd.data = hexBinary(data); + } + return cmd; + } + + MailboxArea parseMailboxArea(XMLElement* node) + { + MailboxArea area; + area.start = requireNumber(node, "Start"); + area.length = requireNumber(node, "Length"); + return area; + } + + void parseProtocol(XMLElement* node, Mailbox& mbx) + { + std::string text = textOf(node); + if (text == "AoE") { mbx.protocols |= eeprom::MailboxProtocol::AoE; } + else if (text == "EoE") { mbx.protocols |= eeprom::MailboxProtocol::EoE; } + else if (text == "CoE") { mbx.protocols |= eeprom::MailboxProtocol::CoE; } + else if (text == "FoE") { mbx.protocols |= eeprom::MailboxProtocol::FoE; } + else if (text == "SoE") { mbx.protocols |= eeprom::MailboxProtocol::SoE; } + else if (text == "VoE") { mbx.protocols |= eeprom::MailboxProtocol::VoE; } + else + { + fail(node, "unknown mailbox protocol '" + text + "'"); + } + } + + std::optional parseMailbox(XMLElement* slave) + { + XMLElement* node = slave->FirstChildElement("Mailbox"); + if (node == nullptr) + { + return std::nullopt; + } + + Mailbox mbx; + mbx.send = parseMailboxArea(require(node, "Send")); + XMLElement* recv = require(node, "Recv"); + mbx.recv = parseMailboxArea(recv); + if (std::optional poll = optionalNumber(recv, "PollTime")) + { + mbx.poll_time = milliseconds{*poll}; + } + mbx.status_bit_addr = optionalNumber(recv, "StatusBitAddr"); + + if (XMLElement* boot = node->FirstChildElement("BootStrap")) + { + mbx.bootstrap_send = parseMailboxArea(require(boot, "Send")); + mbx.bootstrap_recv = parseMailboxArea(require(boot, "Recv")); + } + + for (XMLElement* p = node->FirstChildElement("Protocol"); p != nullptr; p = p->NextSiblingElement("Protocol")) + { + parseProtocol(p, mbx); + } + + if (XMLElement* coe = node->FirstChildElement("CoE")) + { + if (XMLElement* list = coe->FirstChildElement("InitCmds")) + { + mbx.coe_init_cmds = all(list, "InitCmd", parseCoEInitCmd); + } + } + + for (char const* name : {"SoE", "AoE", "EoE", "FoE", "VoE"}) + { + XMLElement* block = node->FirstChildElement(name); + if (block == nullptr) + { + continue; + } + XMLElement* list = block->FirstChildElement("InitCmds"); + if (list == nullptr) + { + continue; + } + for (XMLElement* ic = list->FirstChildElement("InitCmd"); ic != nullptr; ic = ic->NextSiblingElement("InitCmd")) + { + ++mbx.unsupported_init_cmds; + } + } + return mbx; + } + + SlaveInfo parseSlaveInfo(XMLElement* slave) + { + XMLElement* node = require(slave, "Info"); + SlaveInfo info; + info.name = optionalText(node, "Name"); + info.phys_addr = optionalNumber(node, "PhysAddr"); + info.auto_inc_addr = optionalNumber(node, "AutoIncAddr", BIT_PATTERN); + if (XMLElement* identification = node->FirstChildElement("Identification")) + { + std::optional value = numberAttribute(identification, "Value"); + if (not value) + { + fail(identification, "missing mandatory @Value"); + } + Identification id; + id.ado = requireNumber(identification, "Ado"); + id.value = *value; + info.identification = id; + } + info.vendor_id = requireNumber(node, "VendorId", BIT_PATTERN); + info.product_code = requireNumber(node, "ProductCode", BIT_PATTERN); + info.revision_no = requireNumber(node, "RevisionNo", BIT_PATTERN); + info.serial_no = optionalNumber(node, "SerialNo", BIT_PATTERN); + return info; + } + + std::vector parsePreviousPorts(XMLElement* slave) + { + std::vector out; + for (XMLElement* node = slave->FirstChildElement("PreviousPort"); node != nullptr; node = node->NextSiblingElement("PreviousPort")) + { + PreviousPort prev; + prev.selected = boolAttribute(node, "Selected"); + + XMLElement* port = require(node, "Port"); + std::string letter = textOf(port); + if (letter.size() != 1 or letter[0] < 'A' or letter[0] > 'D') + { + fail(port, "port must be A, B, C or D"); + } + prev.port = static_cast(letter[0] - 'A'); + prev.phys_addr = optionalNumber(node, "PhysAddr"); + out.push_back(prev); + } + return out; + } + + std::optional parseDc(XMLElement* slave) + { + XMLElement* node = slave->FirstChildElement("DC"); + if (node == nullptr) + { + return std::nullopt; + } + + auto time = [&](char const* name, bool is_signed) -> std::optional + { + if (is_signed) + { + std::optional shift = optionalNumber(node, name); + if (not shift) + { + return std::nullopt; + } + return nanoseconds{*shift}; + } + std::optional cycle = optionalNumber(node, name); + if (not cycle) + { + return std::nullopt; + } + return nanoseconds{*cycle}; + }; + + Dc dc; + dc.potential_reference_clock = optionalBool(node, "PotentialReferenceClock"); + dc.reference_clock = optionalBool(node, "ReferenceClock"); + dc.cycle_time0 = time("CycleTime0", false); + dc.cycle_time1 = time("CycleTime1", true); // Sync1 - Sync0 cycle + Sync0 shift: may be negative + dc.shift_time = time("ShiftTime", true); + return dc; + } + + Slave parseSlave(XMLElement* node) + { + Slave slave; + slave.info = parseSlaveInfo(node); + slave.mailbox = parseMailbox(node); + slave.init_cmds = parseInitCmds(node); + slave.previous_ports = parsePreviousPorts(node); + slave.dc = parseDc(node); + return slave; + } + + Master parseMaster(XMLElement* node) + { + Master master; + XMLElement* info = require(node, "Info"); + master.name = optionalText(info, "Name"); + checkFrameInfo(info); + + // Checked, not kept: mailboxes are polled per slave, not through the ENI status FMMUs. + if (XMLElement* states = node->FirstChildElement("MailboxStates")) + { + (void) requireNumber(states, "StartAddr", BIT_PATTERN); + (void) requireNumber(states, "Count"); + } + + master.init_cmds = parseInitCmds(node); + return master; + } + + Config parseDocument(XMLDocument& doc) + { + XMLElement* root = doc.FirstChildElement("EtherCATConfig"); + if (root == nullptr) + { + throw std::invalid_argument("root element not found"); + } + XMLElement* node = require(root, "Config"); + + Config config; + config.master = parseMaster(require(node, "Master")); + config.slaves = all(node, "Slave", parseSlave); + return config; + } + } + + Config loadFile(std::string const& path) + { + return withContext("ENI", [&]() + { + XMLDocument doc; + if (doc.LoadFile(path.c_str()) != XML_SUCCESS) + { + throw std::invalid_argument("cannot load '" + path + "': " + doc.ErrorStr()); + } + return parseDocument(doc); + }); + } + + Config loadString(std::string const& xml) + { + return withContext("ENI", [&]() + { + XMLDocument doc; + if (doc.Parse(xml.c_str(), xml.size()) != XML_SUCCESS) + { + throw std::invalid_argument(std::string{"cannot parse document: "} + doc.ErrorStr()); + } + return parseDocument(doc); + }); + } +} diff --git a/pyproject.toml b/pyproject.toml index ea6d5b57..0b6dc0e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ CMAKE_INSTALL_RPATH = '$ORIGIN' CMAKE_INTERPROCEDURAL_OPTIMIZATION = true CMAKE_SHARED_LINKER_FLAGS_RELEASE = "-Wl,--gc-sections -Wl,--strip-all" ENABLE_ESI_PARSER = "OFF" +ENABLE_ENI_PARSER = "OFF" BUILD_UNIT_TESTS = "OFF" ENABLE_CODE_COVERAGE = "OFF" BUILD_SIMULATION = "OFF" diff --git a/scripts/lib/build_options.sh b/scripts/lib/build_options.sh index 2e4f516d..f22233b5 100644 --- a/scripts/lib/build_options.sh +++ b/scripts/lib/build_options.sh @@ -7,6 +7,7 @@ declare -A OPT_DEFAULTS=( [master_examples]=ON [slave_examples]=ON [esi_parser]=ON + [eni_parser]=ON [simulation]=ON [tools]=ON [eeprom_editor]=OFF @@ -22,6 +23,7 @@ declare -A OPT_DESCRIPTIONS=( [master_examples]="Build master examples" [slave_examples]="Build slave examples" [esi_parser]="Enable ESI XML parser" + [eni_parser]="Enable ENI XML parser" [simulation]="Build simulation" [tools]="Build tools" [eeprom_editor]="Build EEPROM editor GUI" @@ -37,6 +39,7 @@ declare -A OPT_CMAKE_FLAGS=( [master_examples]="BUILD_MASTER_EXAMPLES" [slave_examples]="BUILD_SLAVE_EXAMPLES" [esi_parser]="ENABLE_ESI_PARSER" + [eni_parser]="ENABLE_ENI_PARSER" [simulation]="BUILD_SIMULATION" [tools]="BUILD_TOOLS" [eeprom_editor]="BUILD_EEPROM_EDITOR" @@ -52,6 +55,7 @@ declare -A OPT_CONAN_FLAGS=( [unit_tests]="unit_tests" [master_examples]="master_examples" [esi_parser]="esi_parser" + [eni_parser]="eni_parser" [simulation]="simulation" [tools]="tools" [eeprom_editor]="eeprom_editor" diff --git a/scripts/test_install.sh b/scripts/test_install.sh index 6d1dee4c..c6c4562b 100755 --- a/scripts/test_install.sh +++ b/scripts/test_install.sh @@ -49,13 +49,14 @@ if pkg-config --libs --static kickcat >/dev/null 2>&1; then "$CONSUMER_DIR/pkgconfig/consumer" >/dev/null else echo "[test_install] SKIP pkg-config link test: transitive pkg-config dep not on PKG_CONFIG_PATH" - echo " (e.g. install libtinyxml2-dev for the ESI_PARSER build)" + echo " (e.g. install libtinyxml2-dev for the ESI/ENI parser build)" fi echo "[test_install] KICKCAT_INSTALL=OFF configure" cmake -S "$ROOT_DIR" -B "$CONSUMER_DIR/install-off" \ -DKICKCAT_INSTALL=OFF \ -DENABLE_ESI_PARSER=OFF \ + -DENABLE_ENI_PARSER=OFF \ -DBUILD_MASTER_EXAMPLES=OFF \ -DBUILD_SLAVE_EXAMPLES=OFF \ -DBUILD_SIMULATION=OFF \ diff --git a/unit/CMakeLists.txt b/unit/CMakeLists.txt index ff2d7473..eec9b3ba 100644 --- a/unit/CMakeLists.txt +++ b/unit/CMakeLists.txt @@ -59,7 +59,7 @@ add_executable(kickcat_unit src/adler32_sum-t.cc ${CMAKE_SOURCE_DIR}/lib/src/OS/Test/Time.cc ) -# ESI fixtures for Parser-t.cc, copied next to the test binary as a build +# ESI/ENI fixtures for the parser tests, copied next to the test binary as a build # dependency so editing a fixture re-copies it without re-running CMake. set(KICKCAT_ESI_FIXTURES ${CMAKE_SOURCE_DIR}/examples/slave/xmc4800/wdc_foot/nuttx/foot.xml @@ -69,6 +69,7 @@ set(KICKCAT_ESI_FIXTURES ${CMAKE_CURRENT_SOURCE_DIR}/kickcat_esi_test_sm_fmmu.xml ${CMAKE_SOURCE_DIR}/simulation/slave_configs/ecat402-drive.json ${CMAKE_SOURCE_DIR}/simulation/slave_configs/ecat402-drive.xml + ${CMAKE_CURRENT_SOURCE_DIR}/kickcat_eni_test_mailbox.xml ) set(KICKCAT_ESI_FIXTURES_COPIED "") @@ -87,6 +88,10 @@ add_dependencies(kickcat_unit kickcat_unit_fixtures) target_link_libraries(kickcat_unit kickcat GTest::gmock_main) +if (ENABLE_ENI_PARSER) + target_sources(kickcat_unit PRIVATE src/ENI/Parser-t.cc) +endif() + # Simulation-support tests. lib/simulation is processed after unit/, so gate on # the build condition (not TARGET); the link resolves at generation time. if (ENABLE_ESI_PARSER AND BUILD_SIMULATION) diff --git a/unit/kickcat_eni_test_mailbox.xml b/unit/kickcat_eni_test_mailbox.xml new file mode 100644 index 00000000..ba1bbc01 --- /dev/null +++ b/unit/kickcat_eni_test_mailbox.xml @@ -0,0 +1,123 @@ + + + + + + + + ffffffffffff + 0003474a3b99 + a488 + + + #x09000000 + 1 + + + + + + 1001 + 2 + 73411618 + 655097856 + + + + 0 + 32 + + + 0 + 32 + + + + + 6144 + 264 + + + 7168 + 264 + 20 + 0 + + + + #x1000 + #x200 + + + #x1200 + #x200 + + + EoE + CoE + FoE + + + + PS + + 0 + 1 + 4369 + 1 + e903fea9 + + + PS + IP + RxPDO assign + 500 + 1 + #x1c12 + 0 + 01000016 + 0 + + + SO + 100 + 2 + #x1018 + 1 + true + + + + + + + PS + 0 + 0102 + + + + + + + IP + IB + + 5 + 1001 + 2048 + 0018080126000100 + 1 + 3 + + + II + wait + 0 + 0 + 50 + + + + + diff --git a/unit/src/ENI/Parser-t.cc b/unit/src/ENI/Parser-t.cc new file mode 100644 index 00000000..2881072c --- /dev/null +++ b/unit/src/ENI/Parser-t.cc @@ -0,0 +1,296 @@ +#include + +#include "kickcat/ENI/Parser.h" + +using namespace kickcat; + +namespace +{ + std::string wrapConfig(std::string const& body) + { + return "m" + "ffffffffffff000000000000" + + body + + ""; + } + + std::string wrapSlave(std::string const& slave_body) + { + return wrapConfig("s123" + + slave_body + ""); + } + + std::string wrapInitCmd(std::string const& cmd_body) + { + return wrapSlave("" + cmd_body + ""); + } + + void expectInvalid(std::string const& xml, std::string const& fragment) + { + try + { + (void) ENI::loadString(xml); + FAIL() << "expected std::invalid_argument containing '" << fragment << "'"; + } + catch (std::invalid_argument const& e) + { + EXPECT_NE(std::string{e.what()}.find(fragment), std::string::npos) << e.what(); + } + } +} + +TEST(ENIParser, load_errors) +{ + ASSERT_THROW((void) ENI::loadFile("does_not_exist.xml"), std::invalid_argument); + ASSERT_THROW((void) ENI::loadString(""), std::invalid_argument); + expectInvalid("", ""); + expectInvalid("", "missing mandatory "); + expectInvalid("", "missing mandatory "); +} + +TEST(ENIParser, mailbox_slave) +{ + ENI::Config config = ENI::loadFile("kickcat_eni_test_mailbox.xml"); + + EXPECT_TRUE(config.master.init_cmds.empty()); + EXPECT_TRUE(config.cyclic.empty()); + EXPECT_FALSE(config.process_image.has_value()); + + ASSERT_EQ(config.slaves.size(), 1); + ENI::Slave const& slave = config.slaves[0]; + EXPECT_EQ(slave.info.name, "Box 1 (BK1120)"); + EXPECT_FALSE(slave.info.auto_inc_addr.has_value()); + EXPECT_FALSE(slave.info.serial_no.has_value()); + + ASSERT_TRUE(slave.mailbox.has_value()); + ENI::Mailbox const& mbx = *slave.mailbox; + EXPECT_EQ(mbx.send.start, 6144); + EXPECT_EQ(mbx.send.length, 264); + EXPECT_EQ(mbx.recv.start, 7168); + EXPECT_EQ(mbx.recv.length, 264); + EXPECT_EQ(mbx.poll_time, 20ms); + EXPECT_EQ(mbx.status_bit_addr, 0u); + ASSERT_TRUE(mbx.bootstrap_send.has_value()); + EXPECT_EQ(mbx.bootstrap_send->start, 0x1000); + EXPECT_EQ(mbx.bootstrap_recv->start, 0x1200); + EXPECT_EQ(mbx.bootstrap_recv->length, 0x200); + EXPECT_EQ(mbx.protocols, eeprom::MailboxProtocol::EoE | eeprom::MailboxProtocol::CoE | eeprom::MailboxProtocol::FoE); + EXPECT_EQ(mbx.unsupported_init_cmds, 1u); + + ASSERT_EQ(mbx.coe_init_cmds.size(), 3); + ENI::CoEInitCmd const& ip = mbx.coe_init_cmds[0]; + EXPECT_EQ(ip.transitions, transition::PS); + EXPECT_EQ(ip.comment, "IP address"); + EXPECT_EQ(ip.timeout, 0ms); + EXPECT_EQ(ip.ccs, 1); + EXPECT_EQ(ip.index, 0x1111); + EXPECT_EQ(ip.subindex, 1); + EXPECT_EQ(ip.data, (std::vector{0xe9, 0x03, 0xfe, 0xa9})); + EXPECT_FALSE(ip.complete_access); + EXPECT_FALSE(ip.fixed); + EXPECT_FALSE(ip.disabled); + + ENI::CoEInitCmd const& assign = mbx.coe_init_cmds[1]; + EXPECT_EQ(assign.transitions, transition::PS | transition::IP); + EXPECT_EQ(assign.timeout, 500ms); + EXPECT_EQ(assign.index, 0x1c12); + EXPECT_TRUE(assign.complete_access); + EXPECT_TRUE(assign.fixed); + EXPECT_FALSE(assign.disabled); + + ENI::CoEInitCmd const& read = mbx.coe_init_cmds[2]; + EXPECT_EQ(read.ccs, 2); + EXPECT_TRUE(read.data.empty()); + EXPECT_TRUE(read.disabled); + + ASSERT_EQ(slave.init_cmds.size(), 2); + EXPECT_EQ(slave.init_cmds[0].transitions, transition::IP | transition::IB); + EXPECT_EQ(slave.init_cmds[0].data.size(), 8); + ENI::InitCmd const& nop = slave.init_cmds[1]; + EXPECT_EQ(nop.transitions, transition::II); + EXPECT_EQ(nop.cmd, Command::NOP); + EXPECT_EQ(nop.address, createAddress(0, 0)); + EXPECT_TRUE(nop.data.empty()); + EXPECT_EQ(nop.timeout, 50ms); + EXPECT_FALSE(nop.validate.has_value()); +} + +TEST(ENIParser, hexdec_and_signed_values) +{ + ENI::Config config = ENI::loadString(wrapInitCmd("#x2-10x01200100")); + ENI::InitCmd const& cmd = config.slaves.at(0).init_cmds.at(0); + EXPECT_EQ(cmd.cmd, Command::APWR); + EXPECT_EQ(cmd.address, createAddress(0xFFFF, 0x0120)); + EXPECT_EQ(cmd.transitions, 0); +} + +TEST(ENIParser, negative_values) +{ + expectInvalid(wrapInitCmd("10-1"), "value -1 out of range [0, 65535]"); + expectInvalid(wrapInitCmd("1000-1"), "out of range"); + expectInvalid(wrapInitCmd("1-16"), "out of range"); + expectInvalid(wrapSlave("-1000000"), "out of range"); + + // xs:int identifiers and addresses written with the top bit set + ENI::Config config = ENI::loadString(wrapConfig( + "s-12-1" + "-2147483647-250000" + "10-655361")); + ENI::Slave const& s = config.slaves.at(0); + EXPECT_EQ(s.info.auto_inc_addr, 0xFFFF); + EXPECT_EQ(s.info.product_code, 0xFFFFFFFFu); + EXPECT_EQ(s.info.revision_no, 0x80000001u); + EXPECT_EQ(s.dc->shift_time, -250us); + EXPECT_EQ(s.init_cmds.at(0).address, 0xFFFF0000u); +} + +TEST(ENIParser, malformed_init_cmds) +{ + expectInvalid(wrapInitCmd("00"), "missing mandatory "); + expectInvalid(wrapInitCmd("150"), "unknown command 15"); + expectInvalid(wrapInitCmd("abc0"), "invalid numeric value"); + expectInvalid(wrapInitCmd("10"), "missing "); + expectInvalid(wrapInitCmd("101"), "missing for a logical command"); + expectInvalid(wrapInitCmd("1000"), "not /"); + expectInvalid(wrapInitCmd("120"), "not /"); + expectInvalid(wrapInitCmd("4#x10000"), " is only for logical commands"); + expectInvalid(wrapInitCmd("10001"), ""); + expectInvalid(wrapInitCmd("100"), "odd length"); + expectInvalid(wrapInitCmd("10zz"), "non-hex"); + expectInvalid(wrapInitCmd("170000"), "out of range"); + expectInvalid(wrapInitCmd("XY10"), "unknown Transition 'XY'"); + expectInvalid(wrapInitCmd("10always"), "unknown Requires"); + expectInvalid(wrapInitCmd("yes10"), "invalid xs:boolean"); + expectInvalid(wrapInitCmd("100000"), "missing mandatory "); + expectInvalid(wrapInitCmd("1000000000001"), "DataMask size"); + expectInvalid(wrapInitCmd("1000001"), "unknown Validate type"); + expectInvalid(wrapInitCmd("1000001"), "unsupported Validate type 'EQ_OR_G'"); + expectInvalid(wrapInitCmd("100000001"), "longer than the command data"); +} + +TEST(ENIParser, validate_shorter_than_command_data) +{ + ENI::Config config = ENI::loadString(wrapInitCmd("43044" + "04001f001")); + ENI::InitCmd const& cmd = config.slaves.at(0).init_cmds.at(0); + EXPECT_EQ(cmd.data.size(), 4); + ASSERT_TRUE(cmd.validate.has_value()); + EXPECT_EQ(cmd.validate->data.size(), 2); +} + +TEST(ENIParser, validate_types) +{ + ENI::Config config = ENI::loadString(wrapInitCmd("43040000" + "04007")); + ENI::InitCmd const& eq = config.slaves.at(0).init_cmds.at(0); + ASSERT_TRUE(eq.validate.has_value()); + EXPECT_EQ(eq.validate->timeout, 7ms); + + config = ENI::loadString(wrapInitCmd("43040000" + "04007")); + ENI::InitCmd const& none = config.slaves.at(0).init_cmds.at(0); + EXPECT_FALSE(none.validate.has_value()); + EXPECT_EQ(none.timeout, 7ms); +} + +TEST(ENIParser, malformed_slave_and_master) +{ + expectInvalid(wrapSlave("E"), "port must be A, B, C or D"); + expectInvalid(wrapSlave("01"), "missing mandatory "); + expectInvalid(wrapSlave("0101" + "XoE"), "unknown mailbox protocol"); + expectInvalid(wrapSlave("0101" + "0100"), + "missing mandatory "); + + expectInvalid("ffff000000000000", + "MAC address must be 6 bytes"); + expectInvalid("ffffffffffff0000", + "MAC address must be 6 bytes in EtherCATConfig/Config/Master/Info/Source"); + expectInvalid("ffffffffffff000000000000" + "0800", + "EtherType must be EtherCAT (88a4)"); + expectInvalid("ffffffffffff000000000000" + "#x9000000", + "missing mandatory in EtherCATConfig/Config/Master/MailboxStates"); + expectInvalid("ffffffffffff000000000000" + "s12", + "missing mandatory "); +} + +TEST(ENIParser, error_path_names_the_element) +{ + std::string const good = "a123"; + std::string const bad = "b123" + "1"; + expectInvalid(wrapConfig(good + bad), "EtherCATConfig/Config/Slave[1]/InitCmds/InitCmd"); +} + + +TEST(ENIParser, xsd_types) +{ + ENI::Config config = ENI::loadString(wrapSlave( + "1000000-100000-100000")); + ENI::Slave const& slave = config.slaves.at(0); + ASSERT_TRUE(slave.dc.has_value()); + EXPECT_EQ(slave.dc->cycle_time1, -100us); +} + +TEST(ENIParser, datagram_must_fit_in_a_frame) +{ + constexpr std::size_t MAX_DATA = MAX_ETHERCAT_PAYLOAD_SIZE; + std::string const fits = "80" + std::to_string(MAX_DATA) + ""; + EXPECT_EQ(ENI::loadString(wrapInitCmd(fits)).slaves.at(0).init_cmds.at(0).data.size(), MAX_DATA); + expectInvalid(wrapInitCmd("80" + std::to_string(MAX_DATA + 1) + ""), "does not fit in a frame"); + expectInvalid(wrapInitCmd("80" + std::string(2 * (MAX_DATA + 1), '0') + ""), "does not fit in a frame"); +} + +TEST(ENIParser, strict_numbers) +{ + expectInvalid(wrapInitCmd("1#x0x120"), "invalid numeric value"); + expectInvalid(wrapInitCmd("10x0x120"), "invalid numeric value"); + expectInvalid(wrapInitCmd("1#x 12"), "invalid numeric value"); + expectInvalid(wrapInitCmd("1#x-10"), "invalid numeric value"); + expectInvalid(wrapInitCmd("11 2"), "invalid numeric value"); + expectInvalid(wrapInitCmd("1#x"), "invalid numeric value"); + expectInvalid(wrapInitCmd("1-"), "invalid numeric value"); + expectInvalid(wrapInitCmd("1#xffffffffffffffffff"), "out of range"); + + ENI::Config config = ENI::loadString(wrapInitCmd("+2-1 #x120 ")); + ENI::InitCmd const& cmd = config.slaves.at(0).init_cmds.at(0); + EXPECT_EQ(cmd.cmd, Command::APWR); + EXPECT_EQ(cmd.address, createAddress(0xFFFF, 0x120)); +} + +TEST(ENIParser, text_spans_comments_and_cdata) +{ + ENI::Config config = ENI::loadString(wrapInitCmd("2#x1200102")); + ENI::InitCmd const& cmd = config.slaves.at(0).init_cmds.at(0); + EXPECT_EQ(cmd.data, (std::vector{0x01, 0x02, 0x03})); +} + +TEST(ENIParser, attributes_are_trimmed) +{ + ENI::Config config = ENI::loadString(wrapSlave( + "1000" + "001")); + ENI::Slave const& slave = config.slaves.at(0); + EXPECT_FALSE(slave.init_cmds.at(0).validate.has_value()); +} + +TEST(ENIParser, identification) +{ + std::string const slave = wrapConfig( + "s10010" + "#x0012" + "234"); + ENI::Config config = ENI::loadString(slave); + ENI::SlaveInfo const& info = config.slaves.at(0).info; + ASSERT_TRUE(info.identification.has_value()); + EXPECT_EQ(info.identification->ado, 0x0012); + EXPECT_EQ(info.identification->value, 0x0159); + + std::string missing = slave; + missing.replace(missing.find(" Value=\"#x0159\""), std::string{" Value=\"#x0159\""}.size(), ""); + expectInvalid(missing, "missing mandatory @Value"); +}