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/README.md b/README.md index dd1352ac..cb5f2a3f 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ Where to go next: - Simulator and emulator -- [docs/SIMULATION.md](docs/SIMULATION.md) - Tools and GUIs -- [docs/TOOLS.md](docs/TOOLS.md) - Real-time performance tuning -- [docs/PERFORMANCE.md](docs/PERFORMANCE.md) +- ENI network configuration (experimental) -- [docs/ENI.md](docs/ENI.md) ## Platform support 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/docs/BUILDING.md b/docs/BUILDING.md index 6726ca6a..430baca9 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -1,177 +1,85 @@ # Building KickCAT -KickCAT uses Conan (for dependencies) and CMake, wrapped by the scripts in -`scripts/`. Prefer the wrappers over invoking `cmake` / `conan` directly. - -## Prerequisites - -- **Linux**: gcc, cmake, conan -- **Python bindings**: uv or pip -- **Hardware access**: a network interface with raw-socket capability - -## Linux (recommended) +The Linux development build uses the wrappers in `scripts/` to configure +Conan dependencies and CMake. You need a C++ compiler, CMake, Make, Python and +Conan 2.10 or newer. Activate the repository's Python environment before +running a script. If `.venv` does not exist yet, create it and install Conan: ```bash -# 1. Configure build options (optional -- defaults are sensible) -./scripts/configure.sh build --with=unit_tests - -# 2. Set up the build environment (installs Conan deps + runs CMake) -./scripts/setup_build.sh build - -# 3. Build -cd build && make -j - -# 4. Grant network capabilities (required for raw-socket access) -sudo setcap 'cap_net_raw,cap_net_admin=+ep' ./tools/your_binary +python3 -m venv .venv +source .venv/bin/activate +python -m pip install 'conan>=2.10' ``` -`configure.sh` stores the selected options in `.buildconfig`, which -`setup_build.sh` consumes. Available options include `unit_tests`, -`code_coverage`, `esi_parser`, `simulation`, `tools`, `master_examples`, -`slave_examples`, `eeprom_editor`, and `kickui`. - -### Rebuild after source changes - -From an existing `build` directory, no need to re-run `configure.sh` / -`setup_build.sh` unless CMakeLists or Conan deps changed: +Then build: ```bash -cd build && make -j -``` - -### GUI tools (KickUI, EEPROM editor) - -These are off by default and need ImGui + GLFW: - -```bash -./scripts/configure.sh build --with=kickui --with=eeprom_editor +source .venv/bin/activate +./scripts/configure.sh build --with=unit_tests ./scripts/setup_build.sh build cd build && make -j ``` -See [TOOLS.md](TOOLS.md) for what each tool does. - -## Python bindings - -```bash -# Install with uv (recommended) -uv pip install . - -# Or for development (faster rebuilds) -uv pip install --no-build-isolation -Cbuild-dir=/tmp/build -v . -``` - -The wheel build enables the EEPROM-editor GUI (`BUILD_EEPROM_EDITOR=ON` in -`pyproject.toml`), which needs `imgui` and `glfw`. The cibuildwheel images -provide them; on a bare environment that lacks them, either install them (via -Conan) or disable the GUI for the build: - -```bash -uv pip install --config-setting=cmake.define.BUILD_EEPROM_EDITOR=OFF . -``` - -The published package is available on PyPI: - -```bash -pip install kickcat -``` - -The Python interpreter needs raw-socket capabilities to drive a bus: - -```bash -./py_bindings/enable_raw_access.sh -``` +`configure.sh` writes options to `build/.buildconfig`; `setup_build.sh` reads +them. The configuration step is optional if the defaults suit you. Run +`./scripts/configure.sh build --show` to see the current options, or +`./scripts/configure.sh build --help` for the complete list. After changing +build options, CMake files or dependencies, rerun `setup_build.sh`. -Examples live in `py_bindings/examples/`. `foe.py` transfers a file over FoE -(`Bus.read_foe` / `Bus.write_foe`, `ErrorFoE` on failure); it runs against the -simulator too: +For source changes, rebuild in the existing build directory: ```bash -./build/simulation/network_simulator -i tap:server -s simulation/slave_configs/ecat402-drive-foe.json & -python py_bindings/examples/foe.py -i tap:client -c write -f firmware.bin +cd build && make -j ``` -### Multi-wheel (CI) - -This project uses cibuildwheel to generate wheels for all supported -configurations. To run it locally: +`setup_build.sh` accepts `--build-type Debug` and `--target linux-aarch64`. +The latter needs the `aarch64-linux-gnu` cross compilers. -```bash -uvx cibuildwheel -``` +## Tests and coverage -## Manual build (without the wrapper scripts) +With `unit_tests` enabled, run the test binary from the build directory: ```bash -# 1. Create the build directory -mkdir -p build - -# 2. Install dependencies with Conan -python3 -m venv kickcat_venv -source kickcat_venv/bin/activate -pip install conan - -conan install conan/conanfile.py -of=build/ \ - -pr:h conan/your_profile_host.txt \ - -pr:b conan/your_profile_target.txt \ - --build=missing -s build_type=Release - -# Or create the Conan package directly -conan create conan/all --build=missing --version 2.5 -pr build/profile.txt - -# 3. Configure and build cd build -cmake .. -DCMAKE_BUILD_TYPE=Release -make -j +./kickcat_unit +./kickcat_unit --gtest_filter='*' ``` -## Windows - -Windows is **not** suitable for real-time use, but it is useful for tools and -testing. - -Requirements: +For coverage, install `gcovr`, enable both `unit_tests` and `code_coverage`, +then rebuild and run `make coverage` from the build directory. -- Conan for Windows (tested with 2.9.1) -- gcc for Windows (tested with w64devkit 2.0.0) -- npcap (driver 1.80 + SDK 1.70) +## Optional GUIs -Follow the manual build instructions above, using the appropriate Windows paths -and the `conan/profile_windows_x86_64.txt` profile. +KickUI and the EEPROM editor are off by default. They require ImGui and GLFW. +Enable them with `--with=kickui` and/or `--with=eeprom_editor`, then rerun +`setup_build.sh` and build. See [TOOLS.md](TOOLS.md) for their commands. -## PikeOS - -Tested on PikeOS 5.1 for the native personality (p4ext). - -Provide a CMake cross-toolchain file that defines the `PIKEOS` variable. Example -process/thread configurations are in `lib/src/OS/PikeOS/p4ext_config.c`. - -## Slave firmware - -Building and flashing NuttX slave firmware (XMC4800, Arduino Due, Freedom K64F) -is covered in [HARDWARE.md](HARDWARE.md). - -## Testing +## Python bindings -### Unit tests +Install the Python package from the repository root with `uv pip install .` +or `pip install .`. The wheel build enables the EEPROM editor GUI by default; +if its GUI dependencies are unavailable, use: ```bash -./scripts/configure.sh build --with=unit_tests -./scripts/setup_build.sh build -cd build && make -j -make test +uv pip install --config-setting=cmake.define.BUILD_EEPROM_EDITOR=OFF . ``` -The test binary is `build/kickcat_unit`. Run a subset with -`./kickcat_unit --gtest_filter='*'`. +The package is also published as `kickcat` on PyPI. To use a physical bus on +Linux, grant raw-socket capabilities to the executable or run +`./py_bindings/enable_raw_access.sh` for the active Python interpreter. See +the [hardware guide](HARDWARE.md) for bus setup and +`py_bindings/examples/` for Python examples. -### Code coverage +## Other platforms -```bash -uv pip install gcovr +The Linux wrappers generate Linux toolchains. Windows builds use the MinGW +profile in `conan/profile_windows_x86_64.txt`; see the +[CI workflow](../.github/workflows/ci.yml) for the current build setup. +Windows is intended for tools and tests, not real-time use. -./scripts/configure.sh build --with=unit_tests --with=code_coverage -./scripts/setup_build.sh build -cd build && make -j -make coverage -``` +PikeOS 5.1 native personality builds require a cross-toolchain file defining +`PIKEOS`. Example process and thread configurations are in +`lib/src/OS/PikeOS/p4ext_config.c`. + +NuttX slave firmware build and flashing instructions are in +[HARDWARE.md](HARDWARE.md). diff --git a/docs/ENI.md b/docs/ENI.md new file mode 100644 index 00000000..29d3a841 --- /dev/null +++ b/docs/ENI.md @@ -0,0 +1,63 @@ +# ENI support + +KickCAT can load an EtherCAT Network Information (ENI) XML file and execute its +startup commands to configure a master bus. This support is **experimental** and +has been tested only with the in-process simulator. + +Enable `eni_parser` to load XML files. The `eni_sequencer` example also needs +`esi_parser` and `master_examples` (all enabled by default). See +[BUILDING.md](BUILDING.md) for prerequisites: + +```bash +source .venv/bin/activate +./scripts/configure.sh build --with=eni_parser --with=esi_parser --with=master_examples +./scripts/setup_build.sh build +cd build && make -j +cd .. +./build/examples/master/eni_sequencer/eni_sequencer \ + -n unit/kickcat_eni_test_drives.xml \ + -e simulation/slave_configs/ecat402-drive.xml +``` + +`eni_sequencer` creates a simulated bus from the ESI file. To inspect an ENI +without starting a bus, run `./build/examples/master/load_eni/load_eni -f FILE`. + +## Using the sequencer + +`ENI::loadFile()` returns a configuration for `ENI::Sequencer`. Keep that +configuration and the link alive for as long as the sequencer uses them. The +sequencer runs the ENI startup commands; do not also call `Bus::init()` or +`Bus::createMapping()`. + +```cpp +ENI::Sequencer sequencer(bus, link, config); +sequencer.requestState(State::SAFE_OP); + +std::vector iomap(sequencer.processImageSize()); +sequencer.mapProcessImage(iomap.data(), iomap.size()); +bus.processDataReadWrite(error); + +sequencer.requestState(State::OPERATIONAL, [&]() +{ + bus.processDataReadWrite(error); +}); +``` + +The process image becomes available at SAFE_OP. Outputs must be exchanged +before requesting OP; the callback keeps them cycling while the transition is +checked. See [`eni_sequencer.cc`](../examples/master/eni_sequencer/eni_sequencer.cc) +for a complete example. `cycleTime()` and `shiftTime()` expose the common DC +timing, when the ENI specifies it, for `Bus::enableDC()`. + +## Current limits + +- The detected slave count and identities must match the ENI and the devices' + SII data. Mailbox and process data layouts are checked against the registers + programmed by the ENI. +- SoE, EoE, FoE, AoE and VoE startup commands are not executed. Bootstrap, + HotConnect and `PreviousPort` checks are not supported. +- ENI `` frames, `` variables and `MailboxStates` are + parsed but not used by the sequencer. The regular `Bus` exchange uses the + process data mapping read back from the slaves. +- Each startup command uses its own round trip; `Requires=frame/cycle` is not + implemented. A failed command throws, without automatic recovery. diff --git a/examples/master/CMakeLists.txt b/examples/master/CMakeLists.txt index 43742ae6..f310a226 100644 --- a/examples/master/CMakeLists.txt +++ b/examples/master/CMakeLists.txt @@ -2,8 +2,10 @@ add_subdirectory(common) add_subdirectory(ds402_drive) add_subdirectory(easycat) add_subdirectory(elmo) +add_subdirectory(eni_sequencer) add_subdirectory(freedom-k64f) add_subdirectory(ingenia) +add_subdirectory(load_eni) add_subdirectory(load_esi) add_subdirectory(simulated_bus) add_subdirectory(wdc_foot) diff --git a/examples/master/eni_sequencer/CMakeLists.txt b/examples/master/eni_sequencer/CMakeLists.txt new file mode 100644 index 00000000..253ffcad --- /dev/null +++ b/examples/master/eni_sequencer/CMakeLists.txt @@ -0,0 +1,5 @@ +if (ENABLE_ENI_PARSER AND ENABLE_ESI_PARSER) + add_executable(eni_sequencer eni_sequencer.cc) + target_link_libraries(eni_sequencer kickcat argparse::argparse) + set_kickcat_properties(eni_sequencer) +endif () diff --git a/examples/master/eni_sequencer/eni_sequencer.cc b/examples/master/eni_sequencer/eni_sequencer.cc new file mode 100644 index 00000000..c55d8e33 --- /dev/null +++ b/examples/master/eni_sequencer/eni_sequencer.cc @@ -0,0 +1,167 @@ +#include +#include +#include + +#include + +#include "kickcat/Bus.h" +#include "kickcat/CoE/mailbox/response.h" +#include "kickcat/ENI/Parser.h" +#include "kickcat/ENI/Sequencer.h" +#include "kickcat/ESC/EmulatedESC.h" +#include "kickcat/ESI/Parser.h" +#include "kickcat/ESI/SIIBuilder.h" +#include "kickcat/Link.h" +#include "kickcat/LoopbackSocket.h" +#include "kickcat/PDO.h" +#include "kickcat/SocketNull.h" +#include "kickcat/slave/Slave.h" + +using namespace kickcat; + +namespace +{ + constexpr uint32_t SLAVE_PD_SIZE = 4096; + + struct SimulatedSlave + { + SimulatedSlave(ESI::Device device) + : dev(std::move(device)) + , pdo(&esc) + , sl(&esc, &pdo) + , inputs(SLAVE_PD_SIZE, 0) + , outputs(SLAVE_PD_SIZE, 0) + { + CoE::materializeStorage(dev.dictionary); + esc.loadEeprom(ESI::buildEepromImage(dev)); + if (dev.mailbox and dev.mailbox->coe) + { + mbx = std::make_unique(&esc, 1024); + mbx->enableCoE(dev.dictionary); + sl.setMailbox(mbx.get()); + sl.setDictionary(&dev.dictionary); + } + pdo.setInput(inputs.data(), SLAVE_PD_SIZE); + pdo.setOutput(outputs.data(), SLAVE_PD_SIZE); + sl.start(); + } + + ESI::Device dev; + EmulatedESC esc; + PDO pdo; + slave::Slave sl; + std::unique_ptr mbx; + std::vector inputs; + std::vector outputs; + }; +} + +int main(int argc, char** argv) +{ + argparse::ArgumentParser program("eni_sequencer"); + + std::string eni_file; + program.add_argument("-n", "--eni") + .help("ENI XML file describing the network") + .required() + .store_into(eni_file); + + std::string esi_file; + program.add_argument("-e", "--esi") + .help("ESI XML file used to emulate every ENI slave (matched by product code)") + .required() + .store_into(esi_file); + + try + { + program.parse_args(argc, argv); + } + catch (std::exception const& e) + { + std::cerr << e.what() << std::endl << program; + return 1; + } + + ENI::Config config; + std::vector> sim; + try + { + config = ENI::loadFile(eni_file); + ESI::Parser parser; + for (auto const& eni_slave : config.slaves) + { + ESI::DeviceFilter filter; + filter.product_code = eni_slave.info.product_code; + sim.push_back(std::make_unique(parser.loadDevice(esi_file, filter))); + } + } + catch (std::exception const& e) + { + std::cerr << "Cannot build the simulated network: " << e.what() << std::endl; + return 1; + } + + bool outputs_valid = false; + auto tick = [&]() + { + for (auto& s : sim) + { + s->sl.routine(); + if (outputs_valid and s->sl.state() == State::SAFE_OP) + { + s->sl.validateOutputData(); + } + } + }; + + std::vector escs; + for (auto& s : sim) + { + escs.push_back(&s->esc); + } + auto link = std::make_shared(std::make_shared(escs, tick), std::make_shared(), [](){}); + Bus bus(link); + + int cyclic_errors = 0; + auto cyclic = [&]() + { + bus.processDataReadWrite([&](DatagramState const&) { ++cyclic_errors; }); + }; + + std::vector iomap; + try + { + ENI::Sequencer sequencer(bus, link, config); + sequencer.requestState(State::SAFE_OP); + + iomap.resize(sequencer.processImageSize()); + sequencer.mapProcessImage(iomap.data(), iomap.size()); + cyclic(); + + outputs_valid = true; + sequencer.requestState(State::OPERATIONAL, cyclic); + } + catch (std::exception const& e) + { + std::cerr << "ENI sequence failed: " << e.what() << std::endl; + return 1; + } + + for (int i = 0; i < 100; ++i) + { + cyclic(); + } + for (std::size_t i = 0; i < bus.slaves().size(); ++i) + { + Slave const& slave = bus.slaves()[i]; + printf("slave %zu: station %u, state 0x%02x, inputs 0x%05x/%d, outputs 0x%05x/%d\n", i, slave.address, + static_cast(sim[i]->sl.state()), slave.input.address, slave.input.bsize, slave.output.address, slave.output.bsize); + } + printf("cyclic errors: %d\n", cyclic_errors); + + if (cyclic_errors != 0) + { + return 1; + } + return 0; +} diff --git a/examples/master/load_eni/CMakeLists.txt b/examples/master/load_eni/CMakeLists.txt new file mode 100644 index 00000000..7d22a472 --- /dev/null +++ b/examples/master/load_eni/CMakeLists.txt @@ -0,0 +1,5 @@ +if (ENABLE_ENI_PARSER) + add_executable(load_eni load_eni.cc) + target_link_libraries(load_eni kickcat argparse::argparse) + set_kickcat_properties(load_eni) +endif () diff --git a/examples/master/load_eni/load_eni.cc b/examples/master/load_eni/load_eni.cc new file mode 100644 index 00000000..90c7f5ae --- /dev/null +++ b/examples/master/load_eni/load_eni.cc @@ -0,0 +1,148 @@ +#include + +#include "kickcat/ENI/Parser.h" +#include "kickcat/OS/Time.h" + +using namespace kickcat; + +namespace +{ + template + void printTransitionCounts(char const* label, std::vector const& cmds) + { + printf(" %-10s %3zu:", label, cmds.size()); + for (uint16_t bit = 0; bit < 15; ++bit) + { + ENI::transition::Type t = static_cast(1 << bit); + int count = 0; + for (auto const& cmd : cmds) + { + if (cmd.transitions & t) + { + ++count; + } + } + if (count > 0) + { + printf(" %s=%d", ENI::transition::toString(t), count); + } + } + printf("\n"); + } + + std::string statesToString(uint8_t states) + { + std::string out; + if (states & ENI::state::INIT) { out += "I"; } + if (states & ENI::state::PREOP) { out += "P"; } + if (states & ENI::state::SAFEOP) { out += "S"; } + if (states & ENI::state::OP) { out += "O"; } + return out; + } + + void printSlave(std::size_t index, ENI::Slave const& slave) + { + ENI::SlaveInfo const& info = slave.info; + printf("[%zu] %s\n", index, info.name.c_str()); + printf(" phys 0x%04x autoinc 0x%04x vendor 0x%08x product 0x%08x revision 0x%08x\n", + info.phys_addr.value_or(0), info.auto_inc_addr.value_or(0), info.vendor_id, info.product_code, info.revision_no); + + uint32_t out_bits = 0; + for (auto const& range : slave.process_data.send) + { + out_bits += range.bit_length; + } + uint32_t in_bits = 0; + for (auto const& range : slave.process_data.recv) + { + in_bits += range.bit_length; + } + printf(" process data: %u output bits, %u input bits, %zu SM, %zu RxPdo, %zu TxPdo\n", + out_bits, in_bits, slave.process_data.sms.size(), slave.process_data.rx_pdos.size(), slave.process_data.tx_pdos.size()); + + if (slave.mailbox) + { + ENI::Mailbox const& mbx = *slave.mailbox; + printf(" mailbox: send 0x%04x/%u recv 0x%04x/%u protocols 0x%02x unsupported InitCmds %u\n", + mbx.send.start, mbx.send.length, mbx.recv.start, mbx.recv.length, mbx.protocols, mbx.unsupported_init_cmds); + printTransitionCounts("CoE", mbx.coe_init_cmds); + } + printTransitionCounts("InitCmds", slave.init_cmds); + + if (slave.dc) + { + printf(" DC: cycle0 %ld ns shift %ld ns\n", + static_cast(slave.dc->cycle_time0.value_or(0ns).count()), static_cast(slave.dc->shift_time.value_or(0ns).count())); + } + } +} + +int main(int argc, char const* argv[]) +{ + argparse::ArgumentParser program("load_eni"); + + std::string eni_file; + program.add_argument("-f", "--file") + .help("ENI XML file") + .required() + .store_into(eni_file); + + try + { + program.parse_args(argc, argv); + } + catch (std::runtime_error const& err) + { + std::cerr << err.what() << std::endl; + std::cerr << program; + return 1; + } + + ENI::Config config; + nanoseconds t1 = now(); + try + { + config = ENI::loadFile(eni_file); + } + catch (std::exception const& e) + { + std::cerr << e.what() << std::endl; + return 1; + } + nanoseconds t2 = now(); + + ENI::Master const& master = config.master; + printf("Master: %s source %02x:%02x:%02x:%02x:%02x:%02x\n", master.name.c_str(), + master.source[0], master.source[1], master.source[2], master.source[3], master.source[4], master.source[5]); + printTransitionCounts("InitCmds", master.init_cmds); + + printf("%zu slave(s)\n", config.slaves.size()); + for (std::size_t i = 0; i < config.slaves.size(); ++i) + { + printSlave(i, config.slaves[i]); + } + + for (auto const& cyclic : config.cyclic) + { + printf("Cyclic: %zu frame(s)\n", cyclic.frames.size()); + for (auto const& frame : cyclic.frames) + { + for (auto const& cmd : frame.cmds) + { + printf(" %-4s %-18s addr 0x%08x len %4zu wkc %u in %d out %d\n", + statesToString(cmd.states).c_str(), toString(cmd.cmd), cmd.address(), cmd.data.size(), cmd.wkc.value_or(0), + static_cast(cmd.input_offs.value_or(-1)), static_cast(cmd.output_offs.value_or(-1))); + } + } + } + + if (config.process_image) + { + printf("ProcessImage: inputs %u bytes (%zu vars), outputs %u bytes (%zu vars)\n", + config.process_image->inputs.byte_size, config.process_image->inputs.variables.size(), + config.process_image->outputs.byte_size, config.process_image->outputs.variables.size()); + } + + printf("Parsed in %fs\n", seconds_f(t2 - t1).count()); + return 0; +} diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt index e751fbf8..f1349df9 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -12,12 +12,16 @@ set(KICKCAT_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/OS/Time.cc ${CMAKE_CURRENT_SOURCE_DIR}/src/OS/Timer.cc + ${CMAKE_CURRENT_SOURCE_DIR}/src/utils/xsd.cc + ${CMAKE_CURRENT_SOURCE_DIR}/src/CoE/OD.cc ${CMAKE_CURRENT_SOURCE_DIR}/src/CoE/protocol.cc ${CMAKE_CURRENT_SOURCE_DIR}/src/CoE/mailbox/request.cc ${CMAKE_CURRENT_SOURCE_DIR}/src/CoE/mailbox/response.cc ${CMAKE_CURRENT_SOURCE_DIR}/src/CoE/CiA/DS402/StateMachine.cc + ${CMAKE_CURRENT_SOURCE_DIR}/src/ENI/Config.cc + ${CMAKE_CURRENT_SOURCE_DIR}/src/EoE/protocol.cc ${CMAKE_CURRENT_SOURCE_DIR}/src/FoE/protocol.cc @@ -85,11 +89,18 @@ 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) +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 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/Config.h b/lib/include/kickcat/ENI/Config.h new file mode 100644 index 00000000..d48bb3d8 --- /dev/null +++ b/lib/include/kickcat/ENI/Config.h @@ -0,0 +1,326 @@ +#ifndef KICKCAT_ENI_CONFIG_H +#define KICKCAT_ENI_CONFIG_H + +#include +#include +#include +#include +#include +#include +#include + +#include "kickcat/OS/Time.h" +#include "kickcat/protocol.h" + +namespace kickcat::ENI +{ + namespace transition + { + enum Type : uint16_t + { + NONE = 0, + IP = 1 << 0, // Init -> PreOp + PS = 1 << 1, // PreOp -> SafeOp + PI = 1 << 2, // PreOp -> Init + SP = 1 << 3, // SafeOp -> PreOp + SO = 1 << 4, // SafeOp -> Op + SI = 1 << 5, // SafeOp -> Init + OS = 1 << 6, // Op -> SafeOp + OP = 1 << 7, // Op -> PreOp + OI = 1 << 8, // Op -> Init + IB = 1 << 9, // Init -> Bootstrap + BI = 1 << 10, // Bootstrap -> Init + II = 1 << 11, // Init -> Init + PP = 1 << 12, // PreOp -> PreOp + SS = 1 << 13, // SafeOp -> SafeOp + PO = 1 << 14, // PreOp -> Op (XSD only) + }; + char const* toString(Type t); + Type fromString(std::string_view text); // throws std::invalid_argument + Type between(State from, State to); // NONE if no ENI transition matches + } + using Transitions = uint16_t; // OR of transition::Type + + namespace requirement + { + enum Type : uint8_t + { + NONE, + FRAME, // command needs its own frame + CYCLE, // command needs its own cycle + }; + } + + namespace compare + { + enum Type : uint8_t + { + EQ, + NOT_EQ, + EQ_OR_G, + EQ_OR_L, + G, + L, + NONE, + }; + } + + struct Validate + { + std::vector data; + std::vector mask; // ANDed with the reply; bytes past its end are compared whole + milliseconds timeout{0}; + compare::Type type = compare::EQ; + bool is_signed = false; + + // Compare the first data.size() bytes of a reply, as little-endian values of that width. + // False when the reply is shorter than data. + bool matches(uint8_t const* reply, std::size_t reply_size) const; + }; + + // An EtherCAT datagram as ENI InitCmds and cyclic commands write it. + struct Datagram + { + std::string comment; + Command cmd = Command::NOP; + uint16_t adp = 0; + uint16_t ado = 0; + std::optional logical_address; + std::vector data; // expands to zeroes + std::optional wkc; + + uint32_t address() const; // logical address, or createAddress(adp, ado) + }; + + // ECatCmdType: raw EtherCAT datagram sent during a transition. + struct InitCmd : Datagram + { + Transitions transitions = 0; + bool before_slave = false; + requirement::Type frame_requirement = requirement::NONE; + uint16_t retries = 0; + std::optional validate; + milliseconds timeout{0}; // only when there is no + }; + + struct CoEInitCmd + { + Transitions transitions = 0; + std::string comment; + milliseconds timeout{0}; + uint8_t ccs = 0; // samples use 1 for download (write), spec table says otherwise + uint16_t index = 0; + uint8_t subindex = 0; + std::vector data; + bool complete_access = false; + bool fixed = false; + bool disabled = false; + }; + + struct PdoEntry + { + uint16_t index = 0; + uint8_t subindex = 0; + uint16_t bitlen = 0; + std::string name; + std::string data_type; + }; + + struct Pdo + { + uint16_t index = 0; + std::string name; + bool fixed = false; + bool mandatory = false; + std::optional sm; + std::vector exclude; + std::vector entries; + }; + + struct SyncManagerSettings + { + uint8_t index = 0; // N of + SyncManager::Type type = SyncManager::Unused; + uint16_t min_size = 0; + uint16_t max_size = 0; + uint16_t default_size = 0; + uint16_t start_address = 0; + uint8_t control_byte = 0; + bool enable = false; + std::vector pdos; + }; + + struct ProcessDataRange + { + uint32_t bit_start = 0; + uint32_t bit_length = 0; + uint16_t sm_mask = 0; // @Sm0..@Sm15 + }; + + struct ProcessData + { + std::vector send; // outputs + std::vector recv; // inputs + std::vector sms; + std::vector rx_pdos; + std::vector tx_pdos; + }; + + namespace protocol + { + enum Type : uint8_t + { + AoE = 1 << 0, + EoE = 1 << 1, + CoE = 1 << 2, + FoE = 1 << 3, + SoE = 1 << 4, + VoE = 1 << 5, + }; + } + + struct MailboxArea + { + uint16_t start = 0; + uint16_t length = 0; + }; + + struct Mailbox + { + MailboxArea send; // master -> slave + MailboxArea recv; // slave -> master + std::optional poll_time; + std::optional status_bit_addr; + std::optional bootstrap_send; + std::optional bootstrap_recv; + uint8_t protocols = 0; // OR of protocol::Type + std::vector coe_init_cmds; + uint32_t unsupported_init_cmds = 0; // AoE/EoE/FoE/SoE/VoE InitCmds, not modelled + }; + + struct PreviousPort + { + bool selected = false; + uint8_t port = 0; // A=0 .. D=3 + std::optional phys_addr; + }; + + struct Dc + { + bool potential_reference_clock = false; + bool reference_clock = false; + std::optional cycle_time0; + std::optional cycle_time1; + std::optional shift_time; + }; + + // Explicit device identification: the slave register at ado holds value. + struct Identification + { + uint16_t ado = 0; + uint16_t value = 0; + }; + + struct SlaveInfo + { + std::string name; + std::optional phys_addr; + std::optional auto_inc_addr; + std::optional identification; + uint32_t vendor_id = 0; + uint32_t product_code = 0; + uint32_t revision_no = 0; + std::optional serial_no; + }; + + struct Slave + { + SlaveInfo info; + ProcessData process_data; + std::optional mailbox; + std::vector init_cmds; + std::vector previous_ports; + std::optional dc; + }; + + struct MailboxStates + { + uint32_t start_addr = 0; + uint16_t count = 0; + }; + + struct Master + { + std::string name; + std::array destination{}; + std::array source{}; + std::vector ether_type; // raw hexBinary as written + std::optional mailbox_states; + std::vector init_cmds; + }; + + namespace state + { + enum Type : uint8_t + { + NONE = 0, + INIT = 1 << 0, + PREOP = 1 << 1, + SAFEOP = 1 << 2, + OP = 1 << 3, + }; + Type from(State s); // NONE if s has no cyclic state + } + + struct CyclicCmd : Datagram + { + uint8_t states = 0; // OR of state::Type + std::optional input_offs; + std::optional output_offs; + }; + + struct Frame + { + std::string comment; + std::vector cmds; + }; + + struct Cyclic + { + std::string comment; + std::optional cycle_time; + std::optional priority; + std::string task_id; + std::vector frames; + }; + + struct Variable + { + std::string name; + std::string data_type; + uint32_t bit_size = 0; + uint32_t bit_offs = 0; + }; + + struct ProcessImageArea + { + uint32_t byte_size = 0; + std::vector variables; + }; + + struct ProcessImage + { + ProcessImageArea inputs; + ProcessImageArea outputs; + }; + + struct Config + { + Master master; + std::vector slaves; + std::vector cyclic; + std::optional process_image; + }; +} + +#endif 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/include/kickcat/utils/xsd.h b/lib/include/kickcat/utils/xsd.h new file mode 100644 index 00000000..e135f224 --- /dev/null +++ b/lib/include/kickcat/utils/xsd.h @@ -0,0 +1,23 @@ +#ifndef KICKCAT_UTILS_XSD_H +#define KICKCAT_UTILS_XSD_H + +#include +#include +#include + +// Values of the XML schema types shared by ESI (ETG.2000) and ENI (ETG.2100) files. +// Errors throw std::invalid_argument without context: callers add the element they were reading. +namespace kickcat::xsd +{ + /// HexDecValue: [+-]?[0-9]+ or #x (0x accepted) followed by hex digits, surrounding spaces ignored. + /// Unsigned values up to 2^64 - 1 keep their bit pattern. + int64_t parseHexDec(std::string_view text); + + /// xs:boolean: 1, 0, true, false. + bool parseBoolean(std::string_view text); + + /// xs:hexBinary, in wire order; embedded whitespace (wrapped lines) ignored. + std::vector parseHexBinary(std::string_view text); +} + +#endif diff --git a/lib/master/CMakeLists.txt b/lib/master/CMakeLists.txt index 84f5f4ed..57c5bbbc 100644 --- a/lib/master/CMakeLists.txt +++ b/lib/master/CMakeLists.txt @@ -3,6 +3,7 @@ target_sources(kickcat PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/CoE.cc ${CMAKE_CURRENT_SOURCE_DIR}/src/CoE/CiA/DS402/Drive.cc ${CMAKE_CURRENT_SOURCE_DIR}/src/dc.cc + ${CMAKE_CURRENT_SOURCE_DIR}/src/ENI/Sequencer.cc ${CMAKE_CURRENT_SOURCE_DIR}/src/FoE.cc ${CMAKE_CURRENT_SOURCE_DIR}/src/Diagnostics.cc ${CMAKE_CURRENT_SOURCE_DIR}/src/Gateway.cc diff --git a/lib/master/include/kickcat/Bus.h b/lib/master/include/kickcat/Bus.h index 6d5b74cf..d546e04e 100644 --- a/lib/master/include/kickcat/Bus.h +++ b/lib/master/include/kickcat/Bus.h @@ -105,6 +105,9 @@ namespace kickcat std::vector& slaves() { return slaves_; } + // Attach the handlers of the messages a slave sends unsolicited (CoE emergencies, unknown messages) + void addMailboxHandlers(Slave& slave); + // asynchrone read/write/mailbox/state methods // It enable users to do one or multiple operations in a row, process something, and process all awaiting frames. void sendGetALStatus(Slave& slave, std::function const& error); @@ -246,7 +249,6 @@ namespace kickcat void configureMailboxes(); void addMailboxConfiguration(Slave& slave); - void addMailboxHandlers(Slave& slave); void switchMailboxLayout(Slave& slave, bool bootstrap); // mapping helpers diff --git a/lib/master/include/kickcat/ENI/Sequencer.h b/lib/master/include/kickcat/ENI/Sequencer.h new file mode 100644 index 00000000..5ff6b7e6 --- /dev/null +++ b/lib/master/include/kickcat/ENI/Sequencer.h @@ -0,0 +1,74 @@ +#ifndef KICKCAT_ENI_SEQUENCER_H +#define KICKCAT_ENI_SEQUENCER_H + +#include +#include +#include +#include + +#include "kickcat/AbstractLink.h" +#include "kickcat/Bus.h" +#include "kickcat/ENI/Config.h" + +namespace kickcat::ENI +{ + /// \brief Configure a bus through the state transitions described by an ENI. + /// \details Startup commands program the slaves; their SII data is checked against the ENI. + /// Mailbox and process data layouts are read back from the programmed registers. + class Sequencer + { + public: + /// \param link the link given to bus: raw InitCmds are sent through it + /// \param config must outlive the sequencer + Sequencer(Bus& bus, std::shared_ptr link, Config const& config); + + /// \brief Run every ENI transition from the current state to target. + /// \details Up: one state at a time (INIT -> PRE_OP -> SAFE_OP -> OPERATIONAL). Down: the direct transition. + /// The first call detects the slaves and checks them against the ENI. + /// \param background called while a Validate polls, e.g. to cycle the process data before OPERATIONAL + void requestState(State target, std::function const& background = [](){}); + State state() const { return state_; } + + /// \return the process image size, known once the ENI programmed the FMMUs (SAFE_OP and above) + std::size_t processImageSize() const; + + /// \brief Bind slave input/output to iomap, see Bus::mapProcessImage. Needs SAFE_OP or above, + /// and no Bus::configureMailboxStatusCheck(): an ENI does not program its FMMUs. + void mapProcessImage(uint8_t* iomap, std::size_t iomap_size); + + /// \return the DC cycle and shift shared by every DC slave of the ENI, nullopt without DC + std::optional cycleTime() const { return cycle_time_; } + std::optional shiftTime() const { return shift_time_; } + + /// \return the ENI slave driven at a bus position + ENI::Slave const& slaveAt(std::size_t position) const; + + private: + void detect(); + void transition(State from, State to, std::function const& background); + void execute(std::string const& who, InitCmd const& cmd, std::function const& background); + void sendCoE(transition::Type t, std::function const& background); + void attachSlaves(); + void checkIdentity(std::size_t position); + void checkMailbox(std::size_t position); + void checkIdentification(); + void readProcessDataLayout(); + std::vector read(std::size_t position, uint16_t ado, uint16_t size); + + Bus& bus_; + std::shared_ptr link_; + Config const& config_; + + std::vector eni_of_position_; + std::vector position_of_eni_; + std::optional cycle_time_; + std::optional shift_time_; + + State state_ = State::INIT; + bool detected_ = false; + bool handlers_attached_ = false; + bool layout_known_ = false; + }; +} + +#endif diff --git a/lib/master/src/ENI/Sequencer.cc b/lib/master/src/ENI/Sequencer.cc new file mode 100644 index 00000000..8b5fbe99 --- /dev/null +++ b/lib/master/src/ENI/Sequencer.cc @@ -0,0 +1,555 @@ +#include +#include +#include +#include + +#include "kickcat/ENI/Sequencer.h" +#include "kickcat/debug.h" + +namespace kickcat::ENI +{ + namespace + { + bool isAlControlWrite(InitCmd const& cmd, State target) + { + bool is_write = (cmd.cmd == Command::APWR) or (cmd.cmd == Command::FPWR) or (cmd.cmd == Command::BWR); + if (not is_write or (cmd.ado != reg::AL_CONTROL) or cmd.data.empty()) + { + return false; + } + return (cmd.data[0] & State::MASK_STATE) == target; + } + + State nextStateUp(State current) + { + switch (current) + { + case State::INIT: { return State::PRE_OP; } + case State::PRE_OP: { return State::SAFE_OP; } + case State::SAFE_OP: { return State::OPERATIONAL; } + default: { return State::INVALID; } + } + } + + bool isRequestable(State state) + { + return (state == State::INIT) or (state == State::PRE_OP) or (state == State::SAFE_OP) or (state == State::OPERATIONAL); + } + + std::runtime_error slaveError(std::string const& name, std::string const& what) + { + return std::runtime_error("ENI: " + name + ": " + what); + } + + std::string hex(uint32_t value) + { + char buffer[16]; + std::snprintf(buffer, sizeof(buffer), "0x%x", value); + return buffer; + } + } + + + Sequencer::Sequencer(Bus& bus, std::shared_ptr link, Config const& config) + : bus_(bus) + , link_(std::move(link)) + , config_(config) + { + std::size_t const count = config_.slaves.size(); + eni_of_position_.assign(count, count); + position_of_eni_.assign(count, count); + for (std::size_t i = 0; i < count; ++i) + { + std::size_t position = i; + if (config_.slaves[i].info.auto_inc_addr) + { + position = static_cast(0 - *config_.slaves[i].info.auto_inc_addr); + } + if ((position >= count) or (eni_of_position_[position] != count)) + { + throw std::invalid_argument("ENI: " + config_.slaves[i].info.name + ": AutoIncAddr does not match a unique bus position"); + } + eni_of_position_[position] = i; + position_of_eni_[i] = position; + } + + for (auto const& slave : config_.slaves) + { + if (not slave.dc or not slave.dc->cycle_time0) + { + continue; + } + nanoseconds shift = slave.dc->shift_time.value_or(0ns); + if (not cycle_time_) + { + cycle_time_ = slave.dc->cycle_time0; + shift_time_ = shift; + } + if ((*cycle_time_ != *slave.dc->cycle_time0) or (*shift_time_ != shift)) + { + throw std::invalid_argument("ENI: " + slave.info.name + ": DC cycle or shift differs from the other DC slaves"); + } + } + } + + + ENI::Slave const& Sequencer::slaveAt(std::size_t position) const + { + return config_.slaves.at(eni_of_position_.at(position)); + } + + + void Sequencer::requestState(State target, std::function const& background) + { + if (not isRequestable(target)) + { + throw std::invalid_argument("ENI: only INIT, PRE_OP, SAFE_OP and OPERATIONAL can be requested"); + } + if (not detected_) + { + detect(); + } + + if (target < state_) + { + transition(state_, target, background); + return; + } + while (state_ < target) + { + transition(state_, nextStateUp(state_), background); + } + } + + + std::size_t Sequencer::processImageSize() const + { + if (not layout_known_) + { + throw std::logic_error("ENI: the process data layout is known from SAFE_OP"); + } + std::size_t size = 0; + for (auto const& slave : bus_.slaves()) + { + size += static_cast(slave.input.bsize + slave.output.bsize); + } + return size; + } + + + void Sequencer::mapProcessImage(uint8_t* iomap, std::size_t iomap_size) + { + if (not layout_known_) + { + throw std::logic_error("ENI: the process data layout is known from SAFE_OP"); + } + if (bus_.mailboxStatusFMMUMode() != MailboxStatusFMMU::NONE) + { + throw std::logic_error("ENI: the mailbox status check needs FMMUs an ENI does not program"); + } + bus_.mapProcessImage(iomap, iomap_size); + } + + + void Sequencer::detect() + { + int32_t count = bus_.detectSlaves(); + if (static_cast(count) != config_.slaves.size()) + { + throw std::runtime_error("ENI: found " + std::to_string(count) + " slave(s), the ENI describes " + std::to_string(config_.slaves.size())); + } + detected_ = true; + } + + + void Sequencer::transition(State from, State to, std::function const& background) + { + transition::Type t = transition::between(from, to); + if (t == transition::NONE) + { + throw std::invalid_argument(std::string{"ENI: no transition from "} + toString(from) + " to " + toString(to)); + } + bus_info("ENI transition %s\n", transition::toString(t)); + + for (auto const& cmd : config_.master.init_cmds) + { + if ((cmd.transitions & t) and cmd.before_slave) + { + execute("master", cmd, background); + } + } + + // IP CoE needs the mailbox, so it runs once PRE_OP is reached. + std::vector split(config_.slaves.size()); + for (std::size_t i = 0; i < config_.slaves.size(); ++i) + { + std::vector const& cmds = config_.slaves[i].init_cmds; + split[i] = cmds.size(); + for (std::size_t c = 0; c < cmds.size(); ++c) + { + if ((cmds[c].transitions & t) and isAlControlWrite(cmds[c], to)) + { + split[i] = c; + break; + } + } + for (std::size_t c = 0; c < split[i]; ++c) + { + if (cmds[c].transitions & t) + { + execute(config_.slaves[i].info.name, cmds[c], background); + } + } + } + + if (t == transition::IP) + { + attachSlaves(); + } + else + { + sendCoE(t, background); + } + + for (std::size_t i = 0; i < config_.slaves.size(); ++i) + { + std::vector const& cmds = config_.slaves[i].init_cmds; + for (std::size_t c = split[i]; c < cmds.size(); ++c) + { + if (cmds[c].transitions & t) + { + execute(config_.slaves[i].info.name, cmds[c], background); + } + } + } + + if (t == transition::IP) + { + sendCoE(t, background); + } + if (t == transition::PS) + { + readProcessDataLayout(); + } + + for (auto const& cmd : config_.master.init_cmds) + { + if ((cmd.transitions & t) and not cmd.before_slave) + { + execute("master", cmd, background); + } + } + + state_ = to; + if (to < State::SAFE_OP) + { + layout_known_ = false; + } + } + + + void Sequencer::execute(std::string const& who, InitCmd const& cmd, std::function const& background) + { + nanoseconds start = now(); + int32_t attempts = cmd.retries + 1; + while (true) + { + std::vector reply(cmd.data.size()); + uint16_t wkc = 0; + bool received = false; + auto process = [&](DatagramHeader const*, uint8_t const* data, uint16_t answer_wkc) + { + std::memcpy(reply.data(), data, reply.size()); + wkc = answer_wkc; + received = true; + return DatagramState::OK; + }; + auto error = [](DatagramState const&) {}; + + link_->addDatagram(cmd.cmd, cmd.address(), cmd.data.data(), static_cast(cmd.data.size()), process, error); + link_->processDatagrams(); + + if (not received or (cmd.wkc and (wkc != *cmd.wkc))) + { + --attempts; + if (attempts <= 0) + { + throw slaveError(who, "'" + cmd.comment + "' failed (working counter " + std::to_string(wkc) + ")"); + } + continue; + } + + if (not cmd.validate or cmd.validate->matches(reply.data(), reply.size())) + { + return; + } + if (elapsed_time(start) > cmd.validate->timeout) + { + throw slaveError(who, "'" + cmd.comment + "' validate timeout"); + } + background(); + sleep(1ms); + } + } + + + void Sequencer::sendCoE(transition::Type t, std::function const& background) + { + for (std::size_t i = 0; i < config_.slaves.size(); ++i) + { + ENI::Slave const& eni = config_.slaves[i]; + if (not eni.mailbox) + { + continue; + } + kickcat::Slave& slave = bus_.slaves().at(position_of_eni_[i]); + for (auto const& cmd : eni.mailbox->coe_init_cmds) + { + if (not (cmd.transitions & t) or cmd.disabled) + { + continue; + } + + nanoseconds timeout = cmd.timeout; + if (timeout == 0ns) + { + timeout = 1s; + } + Bus::Access access = Bus::Access::PARTIAL; + if (cmd.complete_access) + { + access = Bus::Access::COMPLETE; + } + + if (cmd.ccs == 1) + { + bus_.writeSDO(slave, cmd.index, cmd.subindex, access, cmd.data.data(), static_cast(cmd.data.size()), timeout); + } + else + { + std::vector buffer(slave.mailbox.send_size); + uint32_t size = static_cast(buffer.size()); + bus_.readSDO(slave, cmd.index, cmd.subindex, access, buffer.data(), &size, timeout); + } + background(); + } + } + } + + + std::vector Sequencer::read(std::size_t position, uint16_t ado, uint16_t size) + { + std::vector out(size); + auto process = [&out](DatagramHeader const*, uint8_t const* data, uint16_t wkc) + { + if (wkc != 1) + { + return DatagramState::INVALID_WKC; + } + std::memcpy(out.data(), data, out.size()); + return DatagramState::OK; + }; + auto error = [](DatagramState const& state) + { + THROW_ERROR_DATAGRAM("ENI: register read back failed", state); + }; + link_->addDatagram(Command::FPRD, createAddress(bus_.slaves().at(position).address, ado), nullptr, size, process, error); + link_->processDatagrams(); + return out; + } + + + void Sequencer::attachSlaves() + { + std::vector& slaves = bus_.slaves(); + for (std::size_t position = 0; position < slaves.size(); ++position) + { + ENI::Slave const& eni = slaveAt(position); + if (not eni.info.phys_addr) + { + throw std::invalid_argument("ENI: " + eni.info.name + ": no PhysAddr"); + } + slaves[position].address = *eni.info.phys_addr; + } + + bus_.fetchEeprom(); + bus_.fetchESC(); + bus_.fetchDL(); + checkIdentification(); + + for (std::size_t position = 0; position < slaves.size(); ++position) + { + checkIdentity(position); + checkMailbox(position); + } + + if (not handlers_attached_) + { + for (auto& slave : slaves) + { + bus_.addMailboxHandlers(slave); + } + handlers_attached_ = true; + } + } + + + void Sequencer::checkIdentity(std::size_t position) + { + ENI::SlaveInfo const& eni = slaveAt(position).info; + eeprom::InfoEntry const& sii = bus_.slaves()[position].sii.info; + bool same = (sii.vendor_id == eni.vendor_id) and (sii.product_code == eni.product_code) and (sii.revision_number == eni.revision_no); + if (eni.serial_no and (*eni.serial_no != 0)) + { + same = same and (sii.serial_number == *eni.serial_no); + } + if (not same) + { + throw slaveError(eni.name, "identity differs from the SII (vendor " + hex(sii.vendor_id) + + ", product " + hex(sii.product_code) + ", revision " + hex(sii.revision_number) + ")"); + } + } + + + void Sequencer::checkMailbox(std::size_t position) + { + ENI::Slave const& eni = slaveAt(position); + kickcat::Slave& slave = bus_.slaves()[position]; + if (not eni.mailbox) + { + return; + } + if ((eni.mailbox->protocols & ~slave.sii.info.mailbox_protocol) != 0) + { + throw slaveError(eni.info.name, "mailbox protocols not supported by the SII"); + } + + // The mailbox the slave runs is the one written to SM0/SM1, whoever wrote it. + SyncManager::Register sm[2]; + std::memcpy(sm, read(position, reg::SYNC_MANAGER, sizeof(sm)).data(), sizeof(sm)); + bool enabled = (sm[0].activate & 1) and (sm[1].activate & 1) and (sm[0].length > 0) and (sm[1].length > 0); + bool as_eni = (sm[0].start_address == eni.mailbox->send.start) and (sm[0].length == eni.mailbox->send.length) + and (sm[1].start_address == eni.mailbox->recv.start) and (sm[1].length == eni.mailbox->recv.length); + if (not enabled or not as_eni) + { + throw slaveError(eni.info.name, "SM0/SM1 do not hold the mailbox the ENI describes"); + } + + if ((sm[0].start_address != slave.mailbox.recv_offset) or (sm[0].length != slave.mailbox.recv_size) + or (sm[1].start_address != slave.mailbox.send_offset) or (sm[1].length != slave.mailbox.send_size)) + { + bus_info("ENI: %s: mailbox layout differs from the SII default\n", eni.info.name.c_str()); + } + slave.mailbox.recv_offset = sm[0].start_address; + slave.mailbox.recv_size = sm[0].length; + slave.mailbox.send_offset = sm[1].start_address; + slave.mailbox.send_size = sm[1].length; + } + + + void Sequencer::checkIdentification() + { + std::vector& slaves = bus_.slaves(); + for (std::size_t position = 0; position < slaves.size(); ++position) + { + ENI::SlaveInfo const& eni = slaveAt(position).info; + if (not eni.identification) + { + continue; + } + + uint16_t value = 0; + std::memcpy(&value, read(position, eni.identification->ado, sizeof(value)).data(), sizeof(value)); + if (value != eni.identification->value) + { + throw slaveError(eni.name, "explicit identification " + hex(value) + " differs from " + hex(eni.identification->value)); + } + } + } + + + void Sequencer::readProcessDataLayout() + { + std::vector& slaves = bus_.slaves(); + for (std::size_t position = 0; position < slaves.size(); ++position) + { + kickcat::Slave& slave = slaves[position]; + ENI::Slave const& eni = slaveAt(position); + std::vector fmmus = read(position, reg::FMMU, static_cast(slave.esc.fmmus * sizeof(fmmu::Register))); + std::vector sms = read(position, reg::SYNC_MANAGER, static_cast(slave.esc.syncManagers * sizeof(SyncManager::Register))); + slave.input = {}; + slave.output = {}; + + for (uint8_t f = 0; f < slave.esc.fmmus; ++f) + { + fmmu::Register fmmu; + std::memcpy(&fmmu, fmmus.data() + f * sizeof(fmmu::Register), sizeof(fmmu)); + if (not (fmmu.activate & 1) or (fmmu.length == 0)) + { + continue; + } + + int32_t sm_index = -1; + for (uint8_t n = 0; n < slave.esc.syncManagers; ++n) + { + SyncManager::Register sm; + std::memcpy(&sm, sms.data() + n * sizeof(sm), sizeof(sm)); + if ((sm.length > 0) and (sm.start_address == fmmu.physical_address)) + { + sm_index = n; + } + } + if (sm_index < 0) + { + continue; // not a process data FMMU, e.g. a mailbox status bit + } + if ((fmmu.logical_start_bit != 0) or (fmmu.logical_stop_bit != 7) or (fmmu.physical_start_bit != 0)) + { + throw slaveError(eni.info.name, "bit-wise process data mapping is not supported"); + } + + kickcat::Slave::PIMapping* mapping = nullptr; + if (fmmu.type == 1) + { + mapping = &slave.input; + } + if (fmmu.type == 2) + { + mapping = &slave.output; + } + if (mapping == nullptr) + { + throw slaveError(eni.info.name, "read/write process data FMMUs are not supported"); + } + if (mapping->bsize != 0) + { + throw slaveError(eni.info.name, "more than one FMMU per direction is not supported"); + } + mapping->address = fmmu.logical_address; + mapping->bsize = fmmu.length; + mapping->size = fmmu.length * 8; + mapping->sync_manager = sm_index; + } + + if (eni.process_data.send.empty() and eni.process_data.recv.empty()) + { + continue; // ProcessData is optional: the FMMUs are the only description + } + uint32_t in_bits = 0; + for (auto const& range : eni.process_data.recv) + { + in_bits += range.bit_length; + } + uint32_t out_bits = 0; + for (auto const& range : eni.process_data.send) + { + out_bits += range.bit_length; + } + if ((static_cast(slave.input.size) != in_bits) or (static_cast(slave.output.size) != out_bits)) + { + throw slaveError(eni.info.name, "programmed process data sizes differ from the ENI ProcessData"); + } + } + layout_known_ = true; + } +} diff --git a/lib/src/ENI/Config.cc b/lib/src/ENI/Config.cc new file mode 100644 index 00000000..ee3e1400 --- /dev/null +++ b/lib/src/ENI/Config.cc @@ -0,0 +1,158 @@ +#include +#include + +#include "kickcat/ENI/Config.h" + +namespace kickcat::ENI +{ + namespace transition + { + namespace + { + struct Entry + { + Type transition; + char const* name; + State from; + State to; + }; + + constexpr Entry TABLE[] = + { + {IP, "IP", INIT, PRE_OP }, + {PS, "PS", PRE_OP, SAFE_OP }, + {PI, "PI", PRE_OP, INIT }, + {SP, "SP", SAFE_OP, PRE_OP }, + {SO, "SO", SAFE_OP, OPERATIONAL}, + {SI, "SI", SAFE_OP, INIT }, + {OS, "OS", OPERATIONAL, SAFE_OP }, + {OP, "OP", OPERATIONAL, PRE_OP }, + {OI, "OI", OPERATIONAL, INIT }, + {IB, "IB", INIT, BOOT }, + {BI, "BI", BOOT, INIT }, + {II, "II", INIT, INIT }, + {PP, "PP", PRE_OP, PRE_OP }, + {SS, "SS", SAFE_OP, SAFE_OP }, + {PO, "PO", PRE_OP, OPERATIONAL}, + }; + } + + char const* toString(Type t) + { + if (t == NONE) + { + return "NONE"; + } + for (auto const& entry : TABLE) + { + if (entry.transition == t) + { + return entry.name; + } + } + return "unknown"; + } + + Type fromString(std::string_view text) + { + for (auto const& entry : TABLE) + { + if (text == entry.name) + { + return entry.transition; + } + } + throw std::invalid_argument("ENI: unknown Transition '" + std::string{text} + "'"); + } + + Type between(State from, State to) + { + for (auto const& entry : TABLE) + { + if ((entry.from == from) and (entry.to == to)) + { + return entry.transition; + } + } + return NONE; + } + } + + namespace state + { + Type from(State s) + { + switch (s & State::MASK_STATE) + { + case State::INIT: { return INIT; } + case State::PRE_OP: { return PREOP; } + case State::SAFE_OP: { return SAFEOP; } + case State::OPERATIONAL: { return OP; } + default: { return NONE; } + } + } + } + + bool Validate::matches(uint8_t const* reply, std::size_t reply_size) const + { + if (reply_size < data.size()) + { + return false; + } + + // The most significant differing byte decides; only it carries the sign. + int order = 0; + std::size_t i = data.size(); + while (i > 0) + { + --i; + uint8_t byte_mask = 0xFF; + if (i < mask.size()) + { + byte_mask = mask[i]; + } + uint8_t got = reply[i] & byte_mask; // ETG.2100: the mask applies to the reply only + uint8_t expected = data[i]; + if (got == expected) + { + continue; + } + + bool greater = got > expected; + if (is_signed and (i == data.size() - 1)) + { + greater = static_cast(got) > static_cast(expected); + } + if (greater) + { + order = 1; + } + else + { + order = -1; + } + break; + } + + switch (type) + { + case compare::EQ: { return order == 0; } + case compare::NOT_EQ: { return order != 0; } + case compare::EQ_OR_G: { return order >= 0; } + case compare::EQ_OR_L: { return order <= 0; } + case compare::G: { return order > 0; } + case compare::L: { return order < 0; } + case compare::NONE: { return true; } + default: { return false; } + } + } + + uint32_t Datagram::address() const + { + if (logical_address) + { + return *logical_address; + } + return createAddress(adp, ado); + } +} diff --git a/lib/src/ENI/Parser.cc b/lib/src/ENI/Parser.cc new file mode 100644 index 00000000..f6c6bcaf --- /dev/null +++ b/lib/src/ENI/Parser.cc @@ -0,0 +1,856 @@ +#include +#include +#include +#include +#include + +#include + +#include "kickcat/ENI/Parser.h" +#include "kickcat/utils/xsd.h" + +using namespace tinyxml2; + +namespace kickcat::ENI +{ + namespace + { + std::string pathOf(XMLElement const* elem) + { + std::string path; + for (XMLNode const* node = elem; node != nullptr and node->ToElement() != nullptr; node = node->Parent()) + { + XMLElement const* e = node->ToElement(); + std::string part = e->Name(); + + int index = 0; + bool has_siblings = false; + for (XMLElement const* s = e->PreviousSiblingElement(e->Name()); s != nullptr; s = s->PreviousSiblingElement(e->Name())) + { + ++index; + has_siblings = true; + } + if (e->NextSiblingElement(e->Name()) != nullptr) + { + has_siblings = true; + } + if (has_siblings) + { + part += "[" + std::to_string(index) + "]"; + } + + if (path.empty()) + { + path = part; + } + else + { + path = part + "/" + path; + } + } + return path; + } + + [[noreturn]] void fail(XMLElement const* where, std::string const& what) + { + throw std::invalid_argument("ENI: " + what + " in " + pathOf(where)); + } + + XMLElement* require(XMLElement* parent, char const* name) + { + XMLElement* child = parent->FirstChildElement(name); + if (child == nullptr) + { + fail(parent, std::string{"missing mandatory <"} + name + ">"); + } + return child; + } + + std::string trim(std::string text) + { + auto not_space = [](unsigned char c) { return std::isspace(c) == 0; }; + text.erase(text.begin(), std::find_if(text.begin(), text.end(), not_space)); + text.erase(std::find_if(text.rbegin(), text.rend(), not_space).base(), text.end()); + return text; + } + + // Every text and CDATA child: a comment inside a value must not cut it. + std::string textOf(XMLElement const* elem) + { + std::string out; + for (XMLNode const* node = elem->FirstChild(); node != nullptr; node = node->NextSibling()) + { + if (XMLText const* text = node->ToText()) + { + out += text->Value(); + } + } + return trim(out); + } + + std::optional attributeOf(XMLElement const* elem, char const* name) + { + char const* raw = elem->Attribute(name); + if (raw == nullptr) + { + return std::nullopt; + } + return trim(raw); + } + + std::string optionalText(XMLElement* parent, char const* name) + { + XMLElement* child = parent->FirstChildElement(name); + if (child == nullptr) + { + return {}; + } + return textOf(child); + } + + int64_t parseInteger(XMLElement const* elem, std::string const& text) + { + try + { + return xsd::parseHexDec(text); + } + catch (std::invalid_argument const& e) + { + fail(elem, e.what()); + } + } + + template + T narrow(XMLElement const* elem, int64_t value) + { + static_assert(sizeof(T) < sizeof(int64_t), "narrow() needs a type narrower than int64_t"); + int64_t lo = static_cast(std::numeric_limits::min()); + int64_t hi = static_cast(std::numeric_limits::max()); + if (value < lo or value > hi) + { + fail(elem, "value " + std::to_string(value) + " out of range [" + std::to_string(lo) + ", " + std::to_string(hi) + "]"); + } + return static_cast(value); + } + + // For identifiers and addresses only: the XSD declares them xs:int, so a value with the top + // bit set is written negative (RevisionNo, Addr), and auto-increment positions as -1 or 65535. + template + T narrowBits(XMLElement const* elem, int64_t value) + { + using U = std::make_unsigned_t; + static_assert(sizeof(U) < sizeof(int64_t), "narrowBits() needs a type narrower than int64_t"); + int64_t lo = static_cast(std::numeric_limits>::min()); + int64_t hi = static_cast(std::numeric_limits::max()); + if (value < lo or value > hi) + { + fail(elem, "value " + std::to_string(value) + " out of range for a " + std::to_string(std::numeric_limits::digits) + "-bit field"); + } + return static_cast(static_cast(value)); + } + + enum Reading : uint8_t + { + VALUE, + BIT_PATTERN, + }; + + template + T numberOf(XMLElement const* elem, Reading reading = VALUE) + { + int64_t value = parseInteger(elem, textOf(elem)); + if (reading == BIT_PATTERN) + { + return narrowBits(elem, value); + } + return narrow(elem, value); + } + + template + T requireNumber(XMLElement* parent, char const* name, Reading reading = VALUE) + { + return numberOf(require(parent, name), reading); + } + + template + std::optional optionalNumber(XMLElement* parent, char const* name, Reading reading = VALUE) + { + XMLElement* child = parent->FirstChildElement(name); + if (child == nullptr) + { + return std::nullopt; + } + return numberOf(child, reading); + } + + bool parseBool(XMLElement const* elem, std::string const& raw) + { + try + { + return xsd::parseBoolean(raw); + } + catch (std::invalid_argument const& e) + { + fail(elem, e.what()); + } + } + + bool optionalBool(XMLElement* parent, char const* name) + { + XMLElement* child = parent->FirstChildElement(name); + if (child == nullptr) + { + return false; + } + return parseBool(child, textOf(child)); + } + + bool boolAttribute(XMLElement const* elem, char const* name) + { + std::optional raw = attributeOf(elem, name); + if (not raw) + { + return false; + } + return parseBool(elem, *raw); + } + + 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"); + } + } + + std::vector hexBinary(XMLElement const* elem) + { + try + { + return xsd::parseHexBinary(textOf(elem)); + } + catch (std::invalid_argument const& e) + { + fail(elem, e.what()); + } + } + + std::array macAddress(XMLElement* parent, char const* name) + { + XMLElement* elem = require(parent, name); + std::vector raw = hexBinary(elem); + if (raw.size() != 6) + { + fail(elem, "MAC address must be 6 bytes"); + } + std::array mac; + std::copy(raw.begin(), raw.end(), mac.begin()); + return mac; + } + + template + auto all(XMLElement* parent, char const* name, F parse) + { + std::vector out; + for (XMLElement* child = parent->FirstChildElement(name); child != nullptr; child = child->NextSiblingElement(name)) + { + out.push_back(parse(child)); + } + return out; + } + + uint16_t parseIndex(XMLElement* elem) + { + return numberOf(elem); + } + + 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; + } + + void parseAddress(XMLElement* node, uint16_t& adp, uint16_t& ado, std::optional& logical) + { + XMLElement* addr = node->FirstChildElement("Addr"); + XMLElement* ado_node = node->FirstChildElement("Ado"); + if (addr != nullptr) + { + if (ado_node != nullptr or node->FirstChildElement("Adp") != nullptr) + { + fail(node, " cannot be combined with /"); + } + logical = numberOf(addr, BIT_PATTERN); + return; + } + if (ado_node == nullptr) + { + fail(node, "missing or "); + } + ado = numberOf(ado_node); + adp = optionalNumber(node, "Adp", BIT_PATTERN).value_or(0); + } + + 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); + } + + compare::Type parseCompare(XMLElement const* elem) + { + std::optional raw = attributeOf(elem, "Type"); + if (not raw) + { + return compare::EQ; + } + std::string_view text = *raw; + if (text == "EQ") { return compare::EQ; } + if (text == "NOT_EQ") { return compare::NOT_EQ; } + if (text == "EQ_OR_G") { return compare::EQ_OR_G; } + if (text == "EQ_OR_L") { return compare::EQ_OR_L; } + if (text == "G") { return compare::G; } + if (text == "L") { return compare::L; } + if (text == "NONE") { return compare::NONE; } + fail(elem, std::string{"unknown Validate type '"} + *raw + "'"); + } + + void parseDatagram(XMLElement* node, Datagram& datagram) + { + datagram.comment = optionalText(node, "Comment"); + datagram.cmd = parseCommand(node); + parseAddress(node, datagram.adp, datagram.ado, datagram.logical_address); + + 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.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")}; + v.type = parseCompare(validate); + v.is_signed = boolAttribute(validate, "Signed"); + cmd.validate = std::move(v); + } + 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 |= protocol::AoE; } + else if (text == "EoE") { mbx.protocols |= protocol::EoE; } + else if (text == "CoE") { mbx.protocols |= protocol::CoE; } + else if (text == "FoE") { mbx.protocols |= protocol::FoE; } + else if (text == "SoE") { mbx.protocols |= protocol::SoE; } + else if (text == "VoE") { mbx.protocols |= protocol::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; + } + + uint16_t parseSmMask(XMLElement const* node) + { + uint16_t mask = 0; + for (int i = 0; i < 16; ++i) + { + std::string attr = "Sm" + std::to_string(i); + if (boolAttribute(node, attr.c_str())) + { + mask |= static_cast(1 << i); + } + } + return mask; + } + + ProcessDataRange parseRange(XMLElement* node) + { + ProcessDataRange range; + range.bit_start = requireNumber(node, "BitStart"); + range.bit_length = requireNumber(node, "BitLength"); + range.sm_mask = parseSmMask(node); + return range; + } + + SyncManagerSettings parseSm(XMLElement* node, uint8_t index) + { + SyncManagerSettings sm; + sm.index = index; + + XMLElement* type = require(node, "Type"); + try + { + SyncManager::fromString(textOf(type), sm.type); + } + catch (std::exception const& e) + { + fail(type, e.what()); + } + + sm.min_size = optionalNumber(node, "MinSize").value_or(0); + sm.max_size = optionalNumber(node, "MaxSize").value_or(0); + sm.default_size = optionalNumber(node, "DefaultSize").value_or(0); + sm.start_address = requireNumber(node, "StartAddress"); + sm.control_byte = requireNumber(node, "ControlByte"); + XMLElement* enable = require(node, "Enable"); + sm.enable = parseBool(enable, textOf(enable)); + sm.pdos = all(node, "Pdo", parseIndex); + return sm; + } + + Pdo parsePdo(XMLElement* node) + { + Pdo pdo; + pdo.index = requireNumber(node, "Index"); + pdo.name = optionalText(node, "Name"); + pdo.fixed = boolAttribute(node, "Fixed"); + pdo.mandatory = boolAttribute(node, "Mandatory"); + if (std::optional sm = attributeOf(node, "Sm")) + { + pdo.sm = narrow(node, parseInteger(node, *sm)); + } + pdo.exclude = all(node, "Exclude", parseIndex); + for (XMLElement* e = node->FirstChildElement("Entry"); e != nullptr; e = e->NextSiblingElement("Entry")) + { + PdoEntry entry; + entry.index = requireNumber(e, "Index"); + entry.subindex = optionalNumber(e, "SubIndex").value_or(0); + entry.bitlen = requireNumber(e, "BitLen"); + entry.name = optionalText(e, "Name"); + entry.data_type = optionalText(e, "DataType"); + pdo.entries.push_back(std::move(entry)); + } + return pdo; + } + + ProcessData parseProcessData(XMLElement* slave) + { + ProcessData pd; + XMLElement* node = slave->FirstChildElement("ProcessData"); + if (node == nullptr) + { + return pd; + } + + pd.send = all(node, "Send", parseRange); + pd.recv = all(node, "Recv", parseRange); + for (int i = 0; i < 16; ++i) + { + std::string name = "Sm" + std::to_string(i); + if (XMLElement* sm = node->FirstChildElement(name.c_str())) + { + pd.sms.push_back(parseSm(sm, static_cast(i))); + } + } + pd.rx_pdos = all(node, "RxPdo", parsePdo); + pd.tx_pdos = all(node, "TxPdo", parsePdo); + return pd; + } + + 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 = attributeOf(identification, "Value"); + if (not value) + { + fail(identification, "missing mandatory @Value"); + } + Identification id; + id.ado = requireNumber(identification, "Ado"); + id.value = narrow(identification, parseInteger(identification, *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.process_data = parseProcessData(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"); + master.destination = macAddress(info, "Destination"); + master.source = macAddress(info, "Source"); + if (XMLElement* ether_type = info->FirstChildElement("EtherType")) + { + master.ether_type = hexBinary(ether_type); + } + + if (XMLElement* states = node->FirstChildElement("MailboxStates")) + { + MailboxStates ms; + ms.start_addr = requireNumber(states, "StartAddr", BIT_PATTERN); + ms.count = requireNumber(states, "Count"); + master.mailbox_states = ms; + } + + master.init_cmds = parseInitCmds(node); + return master; + } + + uint8_t parseStates(XMLElement* node) + { + uint8_t out = 0; + for (XMLElement* s = node->FirstChildElement("State"); s != nullptr; s = s->NextSiblingElement("State")) + { + std::string text = textOf(s); + if (text == "INIT") { out |= state::INIT; } + else if (text == "PREOP") { out |= state::PREOP; } + else if (text == "SAFEOP") { out |= state::SAFEOP; } + else if (text == "OP") { out |= state::OP; } + else + { + fail(s, "unknown State '" + text + "'"); + } + } + return out; + } + + CyclicCmd parseCyclicCmd(XMLElement* node) + { + CyclicCmd cmd; + parseDatagram(node, cmd); + cmd.states = parseStates(node); + cmd.input_offs = optionalNumber(node, "InputOffs"); + cmd.output_offs = optionalNumber(node, "OutputOffs"); + return cmd; + } + + Cyclic parseCyclic(XMLElement* node) + { + Cyclic cyclic; + cyclic.comment = optionalText(node, "Comment"); + if (std::optional cycle = optionalNumber(node, "CycleTime")) + { + cyclic.cycle_time = microseconds{*cycle}; + } + cyclic.priority = optionalNumber(node, "Priority"); + cyclic.task_id = optionalText(node, "TaskId"); + + for (XMLElement* f = node->FirstChildElement("Frame"); f != nullptr; f = f->NextSiblingElement("Frame")) + { + Frame frame; + frame.comment = optionalText(f, "Comment"); + frame.cmds = all(f, "Cmd", parseCyclicCmd); + cyclic.frames.push_back(std::move(frame)); + } + return cyclic; + } + + ProcessImageArea parseImageArea(XMLElement* node) + { + ProcessImageArea area; + if (node == nullptr) + { + return area; + } + area.byte_size = requireNumber(node, "ByteSize"); + for (XMLElement* v = node->FirstChildElement("Variable"); v != nullptr; v = v->NextSiblingElement("Variable")) + { + Variable var; + var.name = textOf(require(v, "Name")); + var.data_type = optionalText(v, "DataType"); + var.bit_size = requireNumber(v, "BitSize"); + var.bit_offs = requireNumber(v, "BitOffs"); + area.variables.push_back(std::move(var)); + } + return area; + } + + Config parseDocument(XMLDocument& doc) + { + XMLElement* root = doc.FirstChildElement("EtherCATConfig"); + if (root == nullptr) + { + throw std::invalid_argument("ENI: root element not found"); + } + XMLElement* node = require(root, "Config"); + + Config config; + config.master = parseMaster(require(node, "Master")); + config.slaves = all(node, "Slave", parseSlave); + config.cyclic = all(node, "Cyclic", parseCyclic); + if (XMLElement* image = node->FirstChildElement("ProcessImage")) + { + ProcessImage pi; + pi.inputs = parseImageArea(image->FirstChildElement("Inputs")); + pi.outputs = parseImageArea(image->FirstChildElement("Outputs")); + config.process_image = std::move(pi); + } + return config; + } + } + + Config loadFile(std::string const& path) + { + XMLDocument doc; + if (doc.LoadFile(path.c_str()) != XML_SUCCESS) + { + throw std::invalid_argument("ENI: cannot load '" + path + "': " + doc.ErrorStr()); + } + return parseDocument(doc); + } + + Config loadString(std::string const& xml) + { + XMLDocument doc; + if (doc.Parse(xml.c_str(), xml.size()) != XML_SUCCESS) + { + throw std::invalid_argument(std::string{"ENI: cannot parse document: "} + doc.ErrorStr()); + } + return parseDocument(doc); + } +} diff --git a/lib/src/ESI/Parser.cc b/lib/src/ESI/Parser.cc index b27aa8b9..eca82619 100644 --- a/lib/src/ESI/Parser.cc +++ b/lib/src/ESI/Parser.cc @@ -8,6 +8,7 @@ #include "kickcat/CoE/protocol.h" #include "kickcat/debug.h" #include "kickcat/ESI/Parser.h" +#include "kickcat/utils/xsd.h" using namespace tinyxml2; @@ -120,67 +121,23 @@ namespace return elem->GetText(); } - int64_t parseHexDec(std::string text, std::string const& where = {}) + int64_t parseHexDec(std::string const& text, std::string const& where = {}) { - auto fail = [&](char const* msg) + try + { + return xsd::parseHexDec(text); + } + catch (std::invalid_argument const& e) { std::string what = "ESI: "; - what += msg; - what += " '"; - what += text; - what += "'"; + what += e.what(); if (not where.empty()) { what += " in "; what += where; } throw std::invalid_argument(what); - }; - - if (text.empty()) - { - fail("empty numeric value"); - } - - int base = 10; - if (text.rfind("#x", 0) == 0) - { - if (text.size() == 2) { fail("'#x' with no hex digits"); } - text[0] = '0'; - base = 16; - } - else if (text.rfind("0x", 0) == 0 or text.rfind("0X", 0) == 0) - { - if (text.size() == 2) { fail("'0x' with no hex digits"); } - base = 16; - } - - try - { - return std::stoll(text, nullptr, base); - } - catch (std::invalid_argument const&) - { - fail("invalid numeric value"); - } - catch (std::out_of_range const&) - { - // Unsigned values in [2^63, 2^64): keep the 64-bit pattern, consumers - // (DefaultValue copy, narrowChecked) work on raw bits. - if (text[0] != '-') - { - try - { - return static_cast(std::stoull(text, nullptr, base)); - } - catch (std::exception const&) - { - // fall through to the range error - } - } - fail("numeric value exceeds 64-bit range"); } - return 0; // unreachable; fail() always throws } // Narrow an int64_t to T with bounds checking. For signed T, also accepts @@ -262,15 +219,18 @@ namespace { return false; } - 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); + try + { + return xsd::parseBoolean(raw); + } + catch (std::invalid_argument const& e) + { + std::string what = "ESI: attribute '"; + what += name; + what += "': "; + what += e.what(); + throw std::invalid_argument(what); + } } // xs:int attribute: signed decimal, no #x hex prefix. nullopt when truly @@ -1676,38 +1636,18 @@ std::vector Parser::loadHexBinary(XMLElement* node) { return {}; } - std::string field = raw; - // Real ESI files wrap long hex payloads (e.g. ) across lines, so - // strip embedded whitespace before pairing nibbles. - field.erase(std::remove_if(field.begin(), field.end(), - [](unsigned char c){ return std::isspace(c) != 0; }), field.end()); - if (field.size() % 2 != 0) + try { - throw std::invalid_argument("ESI: hex binary <" + std::string{node->Value()} - + "> has odd length (" + std::to_string(field.size()) + " chars)"); + return xsd::parseHexBinary(raw); } - std::vector data; - data.reserve(field.size() / 2); - - for (std::size_t i = 0; i < field.size(); i += 2) + catch (std::invalid_argument const& e) { - char buf[3] = {field[i], field[i + 1], '\0'}; - char* end = nullptr; - unsigned long byte = std::strtoul(buf, &end, 16); - if (end != buf + 2) - { - std::string what = "ESI: hex binary <"; - what += node->Value(); - what += "> contains non-hex pair '"; - what += buf; - what += "' at byte "; - what += std::to_string(i / 2); - throw std::invalid_argument(what); - } - data.push_back(static_cast(byte)); + std::string what = "ESI: <"; + what += node->Value(); + what += ">: "; + what += e.what(); + throw std::invalid_argument(what); } - - return data; } diff --git a/lib/src/utils/xsd.cc b/lib/src/utils/xsd.cc new file mode 100644 index 00000000..d2cc29af --- /dev/null +++ b/lib/src/utils/xsd.cc @@ -0,0 +1,148 @@ +#include +#include +#include + +#include "kickcat/utils/xsd.h" + +namespace kickcat::xsd +{ + namespace + { + bool isSpace(char c) + { + return (c == ' ') or (c == '\t') or (c == '\n') or (c == '\r'); + } + + // -1 when c is not a digit of base + int digitValue(char c, int base) + { + int value = -1; + if ((c >= '0') and (c <= '9')) + { + value = c - '0'; + } + else if ((c >= 'a') and (c <= 'f')) + { + value = c - 'a' + 10; + } + else if ((c >= 'A') and (c <= 'F')) + { + value = c - 'A' + 10; + } + if (value >= base) + { + return -1; + } + return value; + } + + std::string_view trim(std::string_view text) + { + while (not text.empty() and isSpace(text.front())) + { + text.remove_prefix(1); + } + while (not text.empty() and isSpace(text.back())) + { + text.remove_suffix(1); + } + return text; + } + } + + int64_t parseHexDec(std::string_view raw) + { + std::string_view text = trim(raw); + std::string const quoted = "'" + std::string{text} + "'"; + + std::string_view digits = text; + int base = 10; + bool negative = false; + if ((text.rfind("#x", 0) == 0) or (text.rfind("0x", 0) == 0) or (text.rfind("0X", 0) == 0)) + { + digits = text.substr(2); + base = 16; + } + else if (not text.empty() and ((text[0] == '+') or (text[0] == '-'))) + { + negative = (text[0] == '-'); + digits = text.substr(1); + } + if (digits.empty()) + { + throw std::invalid_argument("invalid numeric value " + quoted); + } + + constexpr uint64_t MAX = std::numeric_limits::max(); + uint64_t value = 0; + for (char c : digits) + { + int digit = digitValue(c, base); + if (digit < 0) + { + throw std::invalid_argument("invalid numeric value " + quoted); + } + if (value > (MAX - static_cast(digit)) / static_cast(base)) + { + throw std::invalid_argument("numeric value " + quoted + " is out of range for 64 bits"); + } + value = value * static_cast(base) + static_cast(digit); + } + + if (not negative) + { + return static_cast(value); + } + constexpr uint64_t MIN_MAGNITUDE = uint64_t{1} << 63; + if (value > MIN_MAGNITUDE) + { + throw std::invalid_argument("numeric value " + quoted + " is out of range for 64 bits"); + } + return static_cast(0 - value); + } + + bool parseBoolean(std::string_view raw) + { + std::string_view text = trim(raw); + if ((text == "1") or (text == "true")) + { + return true; + } + if ((text == "0") or (text == "false")) + { + return false; + } + throw std::invalid_argument("invalid xs:boolean '" + std::string{text} + "'"); + } + + std::vector parseHexBinary(std::string_view text) + { + std::string field; + field.reserve(text.size()); + for (char c : text) + { + if (not isSpace(c)) + { + field.push_back(c); + } + } + if (field.size() % 2 != 0) + { + throw std::invalid_argument("hexBinary has odd length (" + std::to_string(field.size()) + " chars)"); + } + + std::vector data; + data.reserve(field.size() / 2); + for (std::size_t i = 0; i < field.size(); i += 2) + { + int high = digitValue(field[i], 16); + int low = digitValue(field[i + 1], 16); + if ((high < 0) or (low < 0)) + { + throw std::invalid_argument("hexBinary contains non-hex pair '" + field.substr(i, 2) + "' at byte " + std::to_string(i / 2)); + } + data.push_back(static_cast((high << 4) | low)); + } + return data; + } +} 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 45b8d109..b743d277 100644 --- a/unit/CMakeLists.txt +++ b/unit/CMakeLists.txt @@ -29,12 +29,14 @@ add_executable(kickcat_unit src/adler32_sum-t.cc src/ESMStatePreOP-t.cc src/ESMStateSafeOP-t.cc src/utils/math-t.cc + src/utils/xsd-t.cc src/Units-t.cc src/Timer-t.cc src/CoE/protocol-t.cc src/CoE/OD-t.cc src/ESI/Parser-t.cc src/ESI/SIIBuilder-t.cc + src/ENI/Config-t.cc src/CoE/DS402StateMachine-t.cc src/CoE/DS402Drive-t.cc src/mailbox/request-t.cc @@ -57,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 @@ -67,6 +69,9 @@ 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_io.xml + ${CMAKE_CURRENT_SOURCE_DIR}/kickcat_eni_test_mailbox.xml + ${CMAKE_CURRENT_SOURCE_DIR}/kickcat_eni_test_drives.xml ) set(KICKCAT_ESI_FIXTURES_COPIED "") @@ -85,6 +90,14 @@ 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() + +if (ENABLE_ENI_PARSER AND ENABLE_ESI_PARSER) + target_sources(kickcat_unit PRIVATE src/ENI/Sequencer-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_drives.xml b/unit/kickcat_eni_test_drives.xml new file mode 100644 index 00000000..6c514a9e --- /dev/null +++ b/unit/kickcat_eni_test_drives.xml @@ -0,0 +1,228 @@ + + + + + + + KickCAT ENI sequencer + ffffffffffff + 020000000001 + 88a4 + + + IP1read slave countcycle700000020 + IP1clear fmmu8015362563 + IP1clear sm8020481283 + PScheck all in SAFEOP70304000023 + + + + + Drive 1 (EVS-NET-01) + 1001 + 0 + 668 + 61935618 + 327685 + + + + 208 + 48 + + + 304 + 88 + + + Outputs + 6 + #x1800 + #x64 + 1 + #x1601 + + + Inputs + 11 + #x1c00 + #x20 + 1 + #x1a00 + + + + + #x1000 + 128 + + + #x1400 + 128 + 10 + + CoE + + + IPoperation mode CSP10001#x6060008 + PSclear RxPDO assign10001#x1c12000 + PSRxPDO assign10001#x1c1210116 + PSRxPDO count10001#x1c12001 + PSclear TxPDO assign10001#x1c13000 + PSTxPDO assign10001#x1c131001a + PSTxPDO count10001#x1c13001 + + + + + PISIOIset device state to INITcycle51001288110013 + PISIOIcheck device state for INITcycle4100130400001301001f005000 + IPset device state to INITcycle20288110013 + IPcheck device state for INITcycle1030400001301001f005000 + IPset physical address2016e90313 + IPset sm 0 (mailbox out)510012048001080002600010013 + IPset sm 1 (mailbox in)510012056001480002200010013 + IPset device state to PREOPcycle51001288020013 + IPcheck device state for PREOPcycle4100130400001302001f003000 + PSset sm 2 (outputs)510012064001806006400010013 + PSset sm 3 (inputs)510012072001c0b002000010013 + PSset fmmu 0 (outputs)5100115360000010006000007001800020100000013 + PSset fmmu 1 (inputs)5100115520c0001000b000007001c00010100000013 + PSset device state to SAFEOPcycle51001288040013 + PScheck device state for SAFEOPcycle4100130400001304001f005000 + SOset device state to OPcycle51001288080013 + SOcheck device state for OPcycle4100130400001308001f005000 + + + + + Drive 2 (EVS-NET-01) + 1002 + 65535 + 668 + 61935618 + 327685 + + + + 256 + 48 + + + 392 + 88 + + + Outputs + 6 + #x1800 + #x64 + 1 + #x1601 + + + Inputs + 11 + #x1c00 + #x20 + 1 + #x1a00 + + + + + #x1000 + 128 + + + #x1400 + 128 + 10 + + CoE + + + IPoperation mode CSP10001#x6060008 + PSclear RxPDO assign10001#x1c12000 + PSRxPDO assign10001#x1c1210116 + PSRxPDO count10001#x1c12001 + PSclear TxPDO assign10001#x1c13000 + PSTxPDO assign10001#x1c131001a + PSTxPDO count10001#x1c13001 + + + + + PISIOIset device state to INITcycle51002288110013 + PISIOIcheck device state for INITcycle4100230400001301001f005000 + IPset device state to INITcycle265535288110013 + IPcheck device state for INITcycle16553530400001301001f005000 + IPset physical address26553516ea0313 + IPset sm 0 (mailbox out)510022048001080002600010013 + IPset sm 1 (mailbox in)510022056001480002200010013 + IPset device state to PREOPcycle51002288020013 + IPcheck device state for PREOPcycle4100230400001302001f003000 + PSset sm 2 (outputs)510022064001806006400010013 + PSset sm 3 (inputs)510022072001c0b002000010013 + PSset fmmu 0 (outputs)5100215360600010006000007001800020100000013 + PSset fmmu 1 (inputs)510021552170001000b000007001c00010100000013 + PSset device state to SAFEOPcycle51002288040013 + PScheck device state for SAFEOPcycle4100230400001304001f005000 + SOset device state to OPcycle51002288080013 + SOcheck device state for OPcycle4100230400001308001f005000 + + + D + 1001 + + + + 1000 + + + SAFEOP + OP + process data + 12 + 65536 + 34 + 6 + 26 + 26 + + + PREOP + SAFEOP + OP + AL status + 7 + 0 + 304 + 2 + 2 + 72 + + + + + + 74 + Drive 1 (EVS-NET-01).Status WordUINT16304 + Drive 1 (EVS-NET-01).Actual positionDINT32320 + Drive 1 (EVS-NET-01).Actual velocityDINT32352 + Drive 1 (EVS-NET-01).Operation mode displaySINT8384 + Drive 2 (EVS-NET-01).Status WordUINT16392 + Drive 2 (EVS-NET-01).Actual positionDINT32408 + Drive 2 (EVS-NET-01).Actual velocityDINT32440 + Drive 2 (EVS-NET-01).Operation mode displaySINT8472 + + + 60 + Drive 1 (EVS-NET-01).Control WordUINT16208 + Drive 1 (EVS-NET-01).Position set-pointDINT32224 + Drive 2 (EVS-NET-01).Control WordUINT16256 + Drive 2 (EVS-NET-01).Position set-pointDINT32272 + + + + diff --git a/unit/kickcat_eni_test_io.xml b/unit/kickcat_eni_test_io.xml new file mode 100644 index 00000000..7255e981 --- /dev/null +++ b/unit/kickcat_eni_test_io.xml @@ -0,0 +1,256 @@ + + + + + + + KickCAT test master + ffffffffffff + 020000000001 + a488 + + + + IP + 1 + read slave count + cycle + 7 + 0 + 0 + 0000 + 0 + + + IP + true + clear fmmu + frame + 8 + 0 + #x0600 + 256 + 3 + + + PS + check all in SAFEOP + 7 + 0 + 304 + 0000 + 3 + + 0400 + 1f00 + 2000 + + + + + + + Term 1 (EK1100) + 1001 + 0 + 2 + #x044c2c52 + #x00110000 + 0 + + + + PI + BI + SI + OI + set device state to INIT + cycle + 2 + 0 + 288 + 0100 + 3 + + + IP + set physical address + 2 + 0 + 16 + e903 + 1 + 3 + + + + 1 + true + 1000000 + 250000 + + + + + Term 2 (EL1014) + 1002 + 65535 + 2 + 66465874 + 65536 + + + + 0 + 4 + + + + + IP + set physical address + 2 + 65535 + 16 + ea03 + 1 + 3 + + + PS + set fmmu 0 (inputs) + 5 + 1002 + 1536 + 00000100010000030010000101000000 + 1 + 3 + + + IP + SP + OP + check device state for PREOP + 4 + 1002 + 304 + 0000 + 1 + 3 + + 0200 + 1f00 + 1000 + + + + + B + 1001 + + + + + Term 3 (EL2004) + 1003 + 65534 + 2 + 131346514 + 1048576 + + + + 0 + 4 + + + Outputs + 1 + #x0f00 + #x44 + 1 + #x1600 + 5633 + + + #x1600 + Channel 1 + + #x7000 + 1 + 1 + Output + BOOL + + + 0 + 3 + + + + + B + 1002 + + + D + + + + 1000 + + + SAFEOP + OP + cyclic cmd + 10 + 69632 + 1 + 1 + 26 + + + SAFEOP + OP + 11 + #x12000 + 1 + 1 + 26 + + + PREOP + SAFEOP + OP + 7 + 0 + 304 + 0000 + 3 + 39 + + + + + + 1536 + + Term 2 (EL1014).Channel 1.Input + BOOL + 1 + 208 + + + + 1536 + + Term 3 (EL2004).Channel 1.Output + first output + BOOL + 1 + 208 + + + + + 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/Config-t.cc b/unit/src/ENI/Config-t.cc new file mode 100644 index 00000000..68080863 --- /dev/null +++ b/unit/src/ENI/Config-t.cc @@ -0,0 +1,149 @@ +#include + +#include "kickcat/ENI/Config.h" + +using namespace kickcat; + +TEST(ENIConfig, transition_string_round_trip) +{ + for (uint16_t bit = 0; bit < 15; ++bit) + { + ENI::transition::Type t = static_cast(1 << bit); + ASSERT_EQ(ENI::transition::fromString(ENI::transition::toString(t)), t); + } + ASSERT_STREQ(ENI::transition::toString(ENI::transition::SO), "SO"); + ASSERT_THROW(ENI::transition::fromString("XX"), std::invalid_argument); + ASSERT_THROW(ENI::transition::fromString(""), std::invalid_argument); +} + +TEST(ENIConfig, transition_between_states) +{ + ASSERT_EQ(ENI::transition::between(State::INIT, State::PRE_OP), ENI::transition::IP); + ASSERT_EQ(ENI::transition::between(State::PRE_OP, State::SAFE_OP), ENI::transition::PS); + ASSERT_EQ(ENI::transition::between(State::SAFE_OP, State::OPERATIONAL), ENI::transition::SO); + ASSERT_EQ(ENI::transition::between(State::OPERATIONAL, State::INIT), ENI::transition::OI); + ASSERT_EQ(ENI::transition::between(State::INIT, State::BOOT), ENI::transition::IB); + ASSERT_EQ(ENI::transition::between(State::BOOT, State::PRE_OP), ENI::transition::NONE); + ASSERT_EQ(ENI::transition::between(State::INIT, State::OPERATIONAL), ENI::transition::NONE); +} + +TEST(ENIConfig, state_from_al_state) +{ + ASSERT_EQ(ENI::state::from(State::INIT), ENI::state::INIT); + ASSERT_EQ(ENI::state::from(State::PRE_OP), ENI::state::PREOP); + ASSERT_EQ(ENI::state::from(State::SAFE_OP), ENI::state::SAFEOP); + ASSERT_EQ(ENI::state::from(State::OPERATIONAL), ENI::state::OP); + ASSERT_EQ(ENI::state::from(static_cast(State::SAFE_OP | State::ERROR_ACK)), ENI::state::SAFEOP); + ASSERT_EQ(ENI::state::from(State::BOOT), ENI::state::NONE); +} + +TEST(ENIConfig, command_address) +{ + ENI::InitCmd cmd; + cmd.adp = 0xFFFF; + cmd.ado = 0x0120; + ASSERT_EQ(cmd.address(), createAddress(0xFFFF, 0x0120)); + + cmd.logical_address = 0x10000; + ASSERT_EQ(cmd.address(), 0x10000u); + + ENI::CyclicCmd cyclic; + cyclic.adp = 0; + cyclic.ado = 0x0130; + ASSERT_EQ(cyclic.address(), createAddress(0, 0x0130)); + cyclic.logical_address = 0x12000; + ASSERT_EQ(cyclic.address(), 0x12000u); +} + +namespace +{ + ENI::Validate makeValidate(std::vector data, std::vector mask, ENI::compare::Type type, bool is_signed = false) + { + ENI::Validate v; + v.data = std::move(data); + v.mask = std::move(mask); + v.type = type; + v.is_signed = is_signed; + return v; + } + + bool matches(ENI::Validate const& v, std::vector const& reply) + { + return v.matches(reply.data(), reply.size()); + } +} + +TEST(ENIConfig, validate_compares_every_byte) +{ + std::vector expected(10, 0); + std::vector reply(10, 0); + ENI::Validate eq = makeValidate(expected, {}, ENI::compare::EQ); + ASSERT_TRUE(matches(eq, reply)); + + reply[9] = 1; + ASSERT_FALSE(matches(eq, reply)); + ASSERT_TRUE(matches(makeValidate(expected, {}, ENI::compare::G), reply)); +} + +TEST(ENIConfig, validate_mask) +{ + // AL status 0x0014: SAFEOP with the error bit set + std::vector al_status = {0x14, 0x00}; + ASSERT_FALSE(matches(makeValidate({0x04, 0x00}, {0x1f, 0x00}, ENI::compare::EQ), al_status)); + ASSERT_TRUE (matches(makeValidate({0x04, 0x00}, {0x0f, 0x00}, ENI::compare::EQ), al_status)); + ASSERT_TRUE (matches(makeValidate({0x04, 0x00}, {}, ENI::compare::NOT_EQ), al_status)); +} + +TEST(ENIConfig, validate_order_is_little_endian) +{ + std::vector reply = {0x00, 0x02}; // 0x0200 + ENI::Validate v = makeValidate({0xff, 0x01}, {}, ENI::compare::G); // 0x01ff + ASSERT_TRUE(matches(v, reply)); + + v.type = ENI::compare::EQ_OR_L; + ASSERT_FALSE(matches(v, reply)); + v.type = ENI::compare::L; + ASSERT_FALSE(matches(v, reply)); + v.type = ENI::compare::EQ_OR_G; + ASSERT_TRUE(matches(v, reply)); + v.type = ENI::compare::NONE; + ASSERT_TRUE(matches(v, reply)); +} + +TEST(ENIConfig, validate_signed) +{ + std::vector minus_one = {0xff}; + ASSERT_TRUE (matches(makeValidate({0x00}, {}, ENI::compare::G, false), minus_one)); + ASSERT_FALSE(matches(makeValidate({0x00}, {}, ENI::compare::G, true), minus_one)); + ASSERT_TRUE (matches(makeValidate({0x00}, {}, ENI::compare::L, true), minus_one)); + + // -32768 < 1, only the most significant byte carries the sign + std::vector min16 = {0x00, 0x80}; + ASSERT_TRUE (matches(makeValidate({0x01, 0x00}, {}, ENI::compare::L, true), min16)); + ASSERT_FALSE(matches(makeValidate({0x01, 0x00}, {}, ENI::compare::L, false), min16)); + std::vector minus_two = {0xfe, 0xff}; + ASSERT_TRUE (matches(makeValidate({0xff, 0xff}, {}, ENI::compare::L, true), minus_two)); +} + +TEST(ENIConfig, validate_uses_the_validate_width) +{ + std::vector reply = {0x02, 0x00, 0xAA, 0xBB}; + ASSERT_TRUE(matches(makeValidate({0x02, 0x00}, {0x1f, 0x00}, ENI::compare::EQ), reply)); + + std::vector short_reply = {0x02}; + ASSERT_FALSE(matches(makeValidate({0x02, 0x00}, {0x1f, 0x00}, ENI::compare::EQ), short_reply)); + ASSERT_FALSE(matches(makeValidate({0x02, 0x00}, {0x1f, 0x00}, ENI::compare::NONE), short_reply)); +} + +TEST(ENIConfig, validate_mask_applies_to_the_reply_only) +{ + // ETG.2100: reply AND mask, then compare with Data as written + std::vector reply = {0x0F}; + ASSERT_TRUE (matches(makeValidate({0x15}, {0x0f}, ENI::compare::L), reply)); + ASSERT_FALSE(matches(makeValidate({0x15}, {0x0f}, ENI::compare::EQ), reply)); + + // A mask shorter than the data leaves the remaining bytes compared whole + std::vector al_status = {0x14, 0x02}; + ASSERT_TRUE (matches(makeValidate({0x04, 0x02}, {0x0f}, ENI::compare::EQ), al_status)); + ASSERT_FALSE(matches(makeValidate({0x04, 0x00}, {0x0f}, ENI::compare::EQ), al_status)); +} diff --git a/unit/src/ENI/Parser-t.cc b/unit/src/ENI/Parser-t.cc new file mode 100644 index 00000000..0693183d --- /dev/null +++ b/unit/src/ENI/Parser-t.cc @@ -0,0 +1,469 @@ +#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, io_master) +{ + ENI::Config config = ENI::loadFile("kickcat_eni_test_io.xml"); + + ENI::Master const& master = config.master; + EXPECT_EQ(master.name, "KickCAT test master"); + EXPECT_EQ(master.destination, (std::array{0xff, 0xff, 0xff, 0xff, 0xff, 0xff})); + EXPECT_EQ(master.source, (std::array{0x02, 0x00, 0x00, 0x00, 0x00, 0x01})); + EXPECT_EQ(master.ether_type, (std::vector{0xa4, 0x88})); + EXPECT_FALSE(master.mailbox_states.has_value()); + + ASSERT_EQ(master.init_cmds.size(), 3); + + ENI::InitCmd const& count = master.init_cmds[0]; + EXPECT_EQ(count.transitions, ENI::transition::IP); + EXPECT_TRUE(count.before_slave); + EXPECT_EQ(count.comment, "read slave count"); + EXPECT_EQ(count.frame_requirement, ENI::requirement::CYCLE); + EXPECT_EQ(count.cmd, Command::BRD); + EXPECT_EQ(count.data, (std::vector{0, 0})); + EXPECT_FALSE(count.wkc.has_value()); + EXPECT_EQ(count.retries, 0); + + ENI::InitCmd const& clear = master.init_cmds[1]; + EXPECT_TRUE(clear.before_slave); + EXPECT_EQ(clear.frame_requirement, ENI::requirement::FRAME); + EXPECT_EQ(clear.cmd, Command::BWR); + EXPECT_EQ(clear.ado, 0x0600); + EXPECT_EQ(clear.data.size(), 256); + EXPECT_EQ(clear.retries, 3); + + ENI::InitCmd const& check = master.init_cmds[2]; + EXPECT_FALSE(check.before_slave); + EXPECT_EQ(check.transitions, ENI::transition::PS); + EXPECT_EQ(check.frame_requirement, ENI::requirement::NONE); + EXPECT_EQ(check.wkc, 3); + ASSERT_TRUE(check.validate.has_value()); + EXPECT_EQ(check.validate->data, (std::vector{0x04, 0x00})); + EXPECT_EQ(check.validate->mask, (std::vector{0x1f, 0x00})); + EXPECT_EQ(check.validate->timeout, 2000ms); + EXPECT_EQ(check.validate->type, ENI::compare::EQ_OR_G); + EXPECT_FALSE(check.validate->is_signed); +} + +TEST(ENIParser, io_slaves) +{ + ENI::Config config = ENI::loadFile("kickcat_eni_test_io.xml"); + ASSERT_EQ(config.slaves.size(), 3); + + ENI::Slave const& coupler = config.slaves[0]; + EXPECT_EQ(coupler.info.name, "Term 1 (EK1100)"); + EXPECT_EQ(coupler.info.phys_addr, 1001); + EXPECT_EQ(coupler.info.auto_inc_addr, 0); + EXPECT_EQ(coupler.info.vendor_id, 2u); + EXPECT_EQ(coupler.info.product_code, 0x044c2c52u); + EXPECT_EQ(coupler.info.revision_no, 0x00110000u); + EXPECT_EQ(coupler.info.serial_no, 0u); + EXPECT_FALSE(coupler.mailbox.has_value()); + EXPECT_TRUE(coupler.previous_ports.empty()); + ASSERT_EQ(coupler.init_cmds.size(), 2); + EXPECT_EQ(coupler.init_cmds[0].transitions, ENI::transition::PI | ENI::transition::BI | ENI::transition::SI | ENI::transition::OI); + EXPECT_EQ(coupler.init_cmds[1].cmd, Command::APWR); + EXPECT_EQ(coupler.init_cmds[1].address(), createAddress(0, 0x10)); + EXPECT_EQ(coupler.init_cmds[1].data, (std::vector{0xe9, 0x03})); + EXPECT_EQ(coupler.init_cmds[1].wkc, 1); + + ASSERT_TRUE(coupler.dc.has_value()); + EXPECT_TRUE(coupler.dc->potential_reference_clock); + EXPECT_TRUE(coupler.dc->reference_clock); + EXPECT_EQ(coupler.dc->cycle_time0, 1ms); + EXPECT_FALSE(coupler.dc->cycle_time1.has_value()); + EXPECT_EQ(coupler.dc->shift_time, 250us); + + ENI::Slave const& input = config.slaves[1]; + EXPECT_EQ(input.info.auto_inc_addr, 0xFFFF); + EXPECT_FALSE(input.info.serial_no.has_value()); + ASSERT_EQ(input.process_data.recv.size(), 1); + EXPECT_TRUE(input.process_data.send.empty()); + EXPECT_EQ(input.process_data.recv[0].bit_start, 0u); + EXPECT_EQ(input.process_data.recv[0].bit_length, 4u); + EXPECT_EQ(input.process_data.recv[0].sm_mask, 0); + ASSERT_EQ(input.init_cmds.size(), 3); + EXPECT_EQ(input.init_cmds[0].address(), createAddress(0xFFFF, 0x10)); + EXPECT_EQ(input.init_cmds[1].cmd, Command::FPWR); + EXPECT_EQ(input.init_cmds[1].data.size(), 16); + EXPECT_EQ(input.init_cmds[2].transitions, ENI::transition::IP | ENI::transition::SP | ENI::transition::OP); + ASSERT_TRUE(input.init_cmds[2].validate.has_value()); + EXPECT_EQ(input.init_cmds[2].validate->type, ENI::compare::EQ); + ASSERT_EQ(input.previous_ports.size(), 1); + EXPECT_TRUE(input.previous_ports[0].selected); + EXPECT_EQ(input.previous_ports[0].port, 1); + EXPECT_EQ(input.previous_ports[0].phys_addr, 1001); + + ENI::Slave const& output = config.slaves[2]; + ASSERT_EQ(output.process_data.send.size(), 1); + EXPECT_EQ(output.process_data.send[0].sm_mask, 1 << 2); + ASSERT_EQ(output.process_data.sms.size(), 1); + ENI::SyncManagerSettings const& sm = output.process_data.sms[0]; + EXPECT_EQ(sm.index, 2); + EXPECT_EQ(sm.type, SyncManager::Output); + EXPECT_EQ(sm.default_size, 1); + EXPECT_EQ(sm.min_size, 0); + EXPECT_EQ(sm.start_address, 0x0f00); + EXPECT_EQ(sm.control_byte, 0x44); + EXPECT_TRUE(sm.enable); + EXPECT_EQ(sm.pdos, (std::vector{0x1600, 0x1601})); + + ASSERT_EQ(output.process_data.rx_pdos.size(), 1); + EXPECT_TRUE(output.process_data.tx_pdos.empty()); + ENI::Pdo const& pdo = output.process_data.rx_pdos[0]; + EXPECT_EQ(pdo.index, 0x1600); + EXPECT_EQ(pdo.name, "Channel 1"); + EXPECT_TRUE(pdo.fixed); + EXPECT_FALSE(pdo.mandatory); + EXPECT_EQ(pdo.sm, 2); + ASSERT_EQ(pdo.entries.size(), 2); + EXPECT_EQ(pdo.entries[0].index, 0x7000); + EXPECT_EQ(pdo.entries[0].subindex, 1); + EXPECT_EQ(pdo.entries[0].bitlen, 1); + EXPECT_EQ(pdo.entries[0].name, "Output"); + EXPECT_EQ(pdo.entries[0].data_type, "BOOL"); + EXPECT_EQ(pdo.entries[1].index, 0); + EXPECT_EQ(pdo.entries[1].bitlen, 3); + + ASSERT_EQ(output.previous_ports.size(), 2); + EXPECT_TRUE(output.previous_ports[0].selected); + EXPECT_FALSE(output.previous_ports[1].selected); + EXPECT_EQ(output.previous_ports[1].port, 3); + EXPECT_FALSE(output.previous_ports[1].phys_addr.has_value()); +} + +TEST(ENIParser, io_cyclic_and_process_image) +{ + ENI::Config config = ENI::loadFile("kickcat_eni_test_io.xml"); + + ASSERT_EQ(config.cyclic.size(), 1); + ENI::Cyclic const& cyclic = config.cyclic[0]; + EXPECT_EQ(cyclic.cycle_time, 1000us); + EXPECT_FALSE(cyclic.priority.has_value()); + ASSERT_EQ(cyclic.frames.size(), 1); + ASSERT_EQ(cyclic.frames[0].cmds.size(), 3); + + ENI::CyclicCmd const& lrd = cyclic.frames[0].cmds[0]; + EXPECT_EQ(lrd.states, ENI::state::SAFEOP | ENI::state::OP); + EXPECT_EQ(lrd.comment, "cyclic cmd"); + EXPECT_EQ(lrd.cmd, Command::LRD); + EXPECT_EQ(lrd.address(), 0x11000u); + EXPECT_EQ(lrd.data.size(), 1); + EXPECT_EQ(lrd.wkc, 1); + EXPECT_EQ(lrd.input_offs, 26u); + EXPECT_FALSE(lrd.output_offs.has_value()); + + ENI::CyclicCmd const& lwr = cyclic.frames[0].cmds[1]; + EXPECT_EQ(lwr.cmd, Command::LWR); + EXPECT_EQ(lwr.address(), 0x12000u); + EXPECT_FALSE(lwr.input_offs.has_value()); + EXPECT_EQ(lwr.output_offs, 26u); + + ENI::CyclicCmd const& brd = cyclic.frames[0].cmds[2]; + EXPECT_EQ(brd.states, ENI::state::PREOP | ENI::state::SAFEOP | ENI::state::OP); + EXPECT_EQ(brd.cmd, Command::BRD); + EXPECT_FALSE(brd.logical_address.has_value()); + EXPECT_EQ(brd.address(), createAddress(0, 0x130)); + EXPECT_EQ(brd.data.size(), 2); + + ASSERT_TRUE(config.process_image.has_value()); + EXPECT_EQ(config.process_image->inputs.byte_size, 1536u); + ASSERT_EQ(config.process_image->inputs.variables.size(), 1); + EXPECT_EQ(config.process_image->inputs.variables[0].name, "Term 2 (EL1014).Channel 1.Input"); + EXPECT_EQ(config.process_image->inputs.variables[0].bit_offs, 208u); + ASSERT_EQ(config.process_image->outputs.variables.size(), 1); + EXPECT_EQ(config.process_image->outputs.variables[0].data_type, "BOOL"); + EXPECT_EQ(config.process_image->outputs.variables[0].bit_size, 1u); +} + +TEST(ENIParser, mailbox_slave) +{ + ENI::Config config = ENI::loadFile("kickcat_eni_test_mailbox.xml"); + + ASSERT_TRUE(config.master.mailbox_states.has_value()); + EXPECT_EQ(config.master.mailbox_states->start_addr, 0x09000000u); + EXPECT_EQ(config.master.mailbox_states->count, 1); + 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, ENI::protocol::EoE | ENI::protocol::CoE | ENI::protocol::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, ENI::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, ENI::transition::PS | ENI::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, ENI::transition::IP | ENI::transition::IB); + EXPECT_EQ(slave.init_cmds[0].data.size(), 8); + ENI::InitCmd const& nop = slave.init_cmds[1]; + EXPECT_EQ(nop.transitions, ENI::transition::II); + EXPECT_EQ(nop.cmd, Command::NOP); + EXPECT_EQ(nop.adp, 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.adp, 0xFFFF); + EXPECT_EQ(cmd.ado, 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("0-8"), "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).logical_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 or "); + expectInvalid(wrapInitCmd("1000"), " cannot be combined"); + 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("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, 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(wrapSlave("Sideways001"), + "ProcessData/Sm2/Type"); + + expectInvalid("ffff000000000000", + "MAC address must be 6 bytes"); + 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( + "Outputs#x1800#x64true" + "Inputs#x1c00#x20false" + "1000000-100000-100000")); + ENI::Slave const& slave = config.slaves.at(0); + ASSERT_EQ(slave.process_data.sms.size(), 2); + EXPECT_TRUE(slave.process_data.sms[0].enable); + EXPECT_FALSE(slave.process_data.sms[1].enable); + ASSERT_TRUE(slave.dc.has_value()); + EXPECT_EQ(slave.dc->cycle_time1, -100us); + + std::string const cyclic = wrapConfig( + "PlcTaskOP12655362"); + EXPECT_EQ(ENI::loadString(cyclic).cyclic.at(0).task_id, "PlcTask"); +} + +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"); + + expectInvalid(wrapConfig("OP12655366000"), + "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.adp, 0xFFFF); + EXPECT_EQ(cmd.ado, 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( + "08" + "#x1600p" + "1000" + "001")); + ENI::Slave const& slave = config.slaves.at(0); + EXPECT_EQ(slave.process_data.send.at(0).sm_mask, 1 << 2); + EXPECT_EQ(slave.process_data.rx_pdos.at(0).sm, 2); + EXPECT_TRUE(slave.process_data.rx_pdos.at(0).fixed); + EXPECT_EQ(slave.init_cmds.at(0).validate->type, ENI::compare::NOT_EQ); +} + +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"); +} diff --git a/unit/src/ENI/Sequencer-t.cc b/unit/src/ENI/Sequencer-t.cc new file mode 100644 index 00000000..962ccb95 --- /dev/null +++ b/unit/src/ENI/Sequencer-t.cc @@ -0,0 +1,350 @@ +#include + +#include +#include + +#include "mocks/Time.h" + +#include "kickcat/Bus.h" +#include "kickcat/CoE/mailbox/response.h" +#include "kickcat/ENI/Parser.h" +#include "kickcat/ENI/Sequencer.h" +#include "kickcat/ESC/EmulatedESC.h" +#include "kickcat/ESI/Parser.h" +#include "kickcat/ESI/SIIBuilder.h" +#include "kickcat/Link.h" +#include "kickcat/LoopbackSocket.h" +#include "kickcat/PDO.h" +#include "kickcat/SocketNull.h" +#include "kickcat/slave/Slave.h" + +using namespace kickcat; + +namespace +{ + constexpr uint32_t SLAVE_PD_SIZE = 256; + + struct Drive + { + Drive(ESI::Device device) + : dev(std::move(device)) + , pdo(&esc) + , sl(&esc, &pdo) + , inputs(SLAVE_PD_SIZE, 0) + , outputs(SLAVE_PD_SIZE, 0) + { + CoE::materializeStorage(dev.dictionary); + esc.loadEeprom(ESI::buildEepromImage(dev)); + mbx = std::make_unique(&esc, 1024); + mbx->enableCoE(dev.dictionary); + sl.setMailbox(mbx.get()); + sl.setDictionary(&dev.dictionary); + pdo.setInput(inputs.data(), SLAVE_PD_SIZE); + pdo.setOutput(outputs.data(), SLAVE_PD_SIZE); + sl.start(); + } + + ESI::Device dev; + EmulatedESC esc; + PDO pdo; + slave::Slave sl; + std::unique_ptr mbx; + std::vector inputs; + std::vector outputs; + }; +} + +class ENISequencer : public testing::Test +{ +public: + void SetUp() override + { + resetMockClock(); + config = ENI::loadFile("kickcat_eni_test_drives.xml"); + for (int i = 0; i < 2; ++i) + { + ESI::Parser parser; + drives.push_back(std::make_unique(parser.loadDevice("ecat402-drive.xml", {}))); + } + } + + // Built on demand so that each test can edit the configuration first + ENI::Sequencer& sequencer() + { + if (seq) + { + return *seq; + } + std::vector escs; + for (auto& drive : drives) + { + escs.push_back(&drive->esc); + } + auto tick = [this]() + { + for (auto& drive : drives) + { + drive->sl.routine(); + if (outputs_valid and drive->sl.state() == State::SAFE_OP) + { + drive->sl.validateOutputData(); + } + } + }; + link = std::make_shared(std::make_shared(escs, tick), std::make_shared(), [](){}); + bus = std::make_unique(link); + bus->configureWaitLatency(0ns, 0ns); + seq = std::make_unique(*bus, link, config); + return *seq; + } + + void cycle() + { + bus->processDataReadWrite([this](DatagramState const&) { ++cyclic_errors; }); + } + + void goToOperational() + { + sequencer().requestState(State::SAFE_OP); + iomap.assign(sequencer().processImageSize(), 0); + sequencer().mapProcessImage(iomap.data(), iomap.size()); + cycle(); + outputs_valid = true; + sequencer().requestState(State::OPERATIONAL, [this]() { cycle(); }); + } + + void removeInitCmds(ENI::Slave& slave, char const* comment) + { + std::vector kept; + for (auto& cmd : slave.init_cmds) + { + if (cmd.comment.find(comment) == std::string::npos) + { + kept.push_back(cmd); + } + } + slave.init_cmds = kept; + } + + void expectThrowContaining(std::function const& action, char const* fragment) + { + try + { + action(); + FAIL() << "expected an exception containing '" << fragment << "'"; + } + catch (std::exception const& e) + { + EXPECT_NE(std::string{e.what()}.find(fragment), std::string::npos) << e.what(); + } + } + + ENI::Config config; + std::vector> drives; + std::shared_ptr link; + std::unique_ptr bus; + std::unique_ptr seq; + std::vector iomap; + bool outputs_valid = false; + int cyclic_errors = 0; +}; + + +TEST_F(ENISequencer, reaches_operational_and_exchanges_process_data) +{ + goToOperational(); + ASSERT_EQ(State::OPERATIONAL, sequencer().state()); + for (auto& drive : drives) + { + ASSERT_EQ(State::OPERATIONAL, drive->sl.state()); + } + + // Layout programmed by the ENI: outputs then inputs, RxPDO 0x1601 (6 bytes), TxPDO 0x1A00 (11 bytes) + ASSERT_EQ(2 * (6 + 11), iomap.size()); + std::vector& slaves = bus->slaves(); + ASSERT_EQ(0x10000u, slaves[0].output.address); + ASSERT_EQ(0x10006u, slaves[1].output.address); + ASSERT_EQ(0x1000Cu, slaves[0].input.address); + ASSERT_EQ(0x10017u, slaves[1].input.address); + ASSERT_EQ(1001, slaves[0].address); + ASSERT_EQ(1002, slaves[1].address); + + for (std::size_t i = 0; i < drives.size(); ++i) + { + uint16_t status = static_cast(0x1230 + i); + std::memcpy(drives[i]->inputs.data(), &status, sizeof(status)); + uint16_t control = static_cast(0x0F00 + i); + std::memcpy(slaves[i].output.data, &control, sizeof(control)); + } + for (int i = 0; i < 5; ++i) + { + cycle(); + } + for (std::size_t i = 0; i < drives.size(); ++i) + { + uint16_t status = 0; + std::memcpy(&status, slaves[i].input.data, sizeof(status)); + uint16_t control = 0; + std::memcpy(&control, drives[i]->outputs.data(), sizeof(control)); + EXPECT_EQ(0x1230 + i, status); + EXPECT_EQ(0x0F00 + i, control); + } + EXPECT_EQ(0, cyclic_errors); + + auto [obj, assign] = CoE::findObject(drives[0]->dev.dictionary, 0x1C12, 1); + EXPECT_EQ(0x1601, *static_cast(assign->data)); +} + +TEST_F(ENISequencer, goes_back_to_init_and_up_again) +{ + goToOperational(); + sequencer().requestState(State::INIT); + ASSERT_EQ(State::INIT, sequencer().state()); + for (auto& drive : drives) + { + ASSERT_EQ(State::INIT, drive->sl.state()); + } + ASSERT_THROW(sequencer().processImageSize(), std::logic_error); + + std::size_t handlers = bus->slaves()[0].mailbox.to_process.size(); + outputs_valid = false; + goToOperational(); + ASSERT_EQ(State::OPERATIONAL, sequencer().state()); + ASSERT_EQ(handlers, bus->slaves()[0].mailbox.to_process.size()); +} + +TEST_F(ENISequencer, pairs_slaves_by_auto_increment_address) +{ + std::swap(config.slaves[0], config.slaves[1]); + goToOperational(); + ASSERT_EQ(1001, bus->slaves()[0].address); + ASSERT_EQ(1002, bus->slaves()[1].address); + ASSERT_EQ("Drive 1 (EVS-NET-01)", sequencer().slaveAt(0).info.name); + ASSERT_EQ(0x10000u, bus->slaves()[0].output.address); +} + +TEST_F(ENISequencer, refuses_inconsistent_configurations) +{ + Bus unused(std::make_shared(std::make_shared(), std::make_shared(), [](){})); + + ENI::Config duplicated = config; + duplicated.slaves[1].info.auto_inc_addr = 0; + ASSERT_THROW(ENI::Sequencer(unused, nullptr, duplicated), std::invalid_argument); + + ENI::Config dc = config; + for (auto& slave : dc.slaves) + { + slave.dc = ENI::Dc{}; + slave.dc->cycle_time0 = 1ms; + slave.dc->shift_time = 250us; + } + ENI::Sequencer with_dc(unused, nullptr, dc); + EXPECT_EQ(1ms, with_dc.cycleTime()); + EXPECT_EQ(250us, with_dc.shiftTime()); + + dc.slaves[1].dc->shift_time = 0ns; + ASSERT_THROW(ENI::Sequencer(unused, nullptr, dc), std::invalid_argument); + + ENI::Sequencer without_dc(unused, nullptr, config); + EXPECT_FALSE(without_dc.cycleTime().has_value()); + ASSERT_THROW(without_dc.requestState(State::BOOT), std::invalid_argument); +} + +TEST_F(ENISequencer, refuses_a_slave_count_mismatch) +{ + config.slaves.pop_back(); + expectThrowContaining([this]() { sequencer().requestState(State::PRE_OP); }, "found 2 slave(s), the ENI describes 1"); +} + +TEST_F(ENISequencer, refuses_an_identity_differing_from_the_sii) +{ + config.slaves[1].info.revision_no += 1; + expectThrowContaining([this]() { sequencer().requestState(State::PRE_OP); }, "Drive 2 (EVS-NET-01): identity differs from the SII"); +} + +TEST_F(ENISequencer, refuses_a_mailbox_protocol_missing_from_the_sii) +{ + config.slaves[0].mailbox->protocols |= ENI::protocol::SoE; + expectThrowContaining([this]() { sequencer().requestState(State::PRE_OP); }, "mailbox protocols not supported by the SII"); +} + +TEST_F(ENISequencer, refuses_mailbox_sync_managers_the_eni_did_not_program) +{ + removeInitCmds(config.slaves[0], "mailbox"); + expectThrowContaining([this]() { sequencer().requestState(State::PRE_OP); }, "SM0/SM1 do not hold the mailbox the ENI describes"); +} + +TEST_F(ENISequencer, checks_explicit_identification) +{ + config.slaves[1].info.identification = ENI::Identification{reg::STATION_ALIAS, 0x0159}; + expectThrowContaining([this]() { sequencer().requestState(State::PRE_OP); }, "explicit identification 0x0 differs from 0x159"); +} + +TEST_F(ENISequencer, accepts_matching_explicit_identification) +{ + config.slaves[1].info.identification = ENI::Identification{reg::STATION_ALIAS, 0}; + sequencer().requestState(State::PRE_OP); + ASSERT_EQ(State::PRE_OP, sequencer().state()); +} + +TEST_F(ENISequencer, refuses_process_data_differing_from_the_eni) +{ + config.slaves[0].process_data.recv[0].bit_length = 80; + expectThrowContaining([this]() { sequencer().requestState(State::SAFE_OP); }, "programmed process data sizes differ from the ENI ProcessData"); +} + +TEST_F(ENISequencer, process_data_description_is_optional) +{ + for (auto& slave : config.slaves) + { + slave.process_data = {}; + } + goToOperational(); + ASSERT_EQ(2 * (6 + 11), iomap.size()); +} + +TEST_F(ENISequencer, reports_a_validate_timeout) +{ + for (auto& cmd : config.slaves[0].init_cmds) + { + if (cmd.comment == "check device state for PREOP") + { + cmd.validate->data = {0x08, 0x00}; + } + } + expectThrowContaining([this]() { sequencer().requestState(State::PRE_OP); }, "'check device state for PREOP' validate timeout"); +} + +TEST_F(ENISequencer, process_image_needs_safe_op) +{ + sequencer().requestState(State::PRE_OP); + uint8_t buffer[64]; + ASSERT_THROW(sequencer().mapProcessImage(buffer, sizeof(buffer)), std::logic_error); +} + +TEST_F(ENISequencer, process_image_refuses_the_mailbox_status_check) +{ + sequencer().requestState(State::SAFE_OP); + bus->configureMailboxStatusCheck(MailboxStatusFMMU::READ_CHECK); + std::vector buffer(sequencer().processImageSize()); + ASSERT_THROW(sequencer().mapProcessImage(buffer.data(), buffer.size()), std::logic_error); +} + +TEST_F(ENISequencer, coe_sent_after_the_safe_op_request_is_refused_by_the_slave) +{ + // The PDO assignment of drive 1 arrives one transition late: SAFE_OP is requested with the + // device default RxPDO 0x1600 (11 bytes) on the 6-byte SM2 the ENI programmed. + for (auto& cmd : config.slaves[0].mailbox->coe_init_cmds) + { + if (cmd.transitions & ENI::transition::PS) + { + cmd.transitions = ENI::transition::SO; + } + } + expectThrowContaining([this]() { sequencer().requestState(State::SAFE_OP); }, "Drive 1 (EVS-NET-01): 'check device state for SAFEOP' validate timeout"); + + ASSERT_EQ(State::PRE_OP, drives[0]->sl.state()); + uint16_t al_status_code = 0; + drives[0]->esc.read(reg::AL_STATUS_CODE, &al_status_code, sizeof(al_status_code)); + ASSERT_EQ(StatusCode::INVALID_OUTPUT_CONFIGURATION, al_status_code); +} diff --git a/unit/src/utils/xsd-t.cc b/unit/src/utils/xsd-t.cc new file mode 100644 index 00000000..25141f98 --- /dev/null +++ b/unit/src/utils/xsd-t.cc @@ -0,0 +1,42 @@ +#include +#include + +#include "kickcat/utils/xsd.h" + +using namespace kickcat; + +TEST(XSD, hexdec) +{ + EXPECT_EQ(xsd::parseHexDec("42"), 42); + EXPECT_EQ(xsd::parseHexDec("+42"), 42); + EXPECT_EQ(xsd::parseHexDec("-1"), -1); + EXPECT_EQ(xsd::parseHexDec(" #x1A "), 0x1A); + EXPECT_EQ(xsd::parseHexDec("0x1a"), 0x1A); + EXPECT_EQ(xsd::parseHexDec("#xFFFFFFFFFFFFFFFF"), -1); // ULINT keeps its bit pattern + EXPECT_EQ(xsd::parseHexDec("18446744073709551615"), -1); + + for (char const* bad : {"", "-", "#x", "12abc", "1 2", "#x-1", "#x0x12", "#x 12", "0x0x12"}) + { + EXPECT_THROW(xsd::parseHexDec(bad), std::invalid_argument) << bad; + } + EXPECT_THROW(xsd::parseHexDec("#x10000000000000000"), std::invalid_argument); +} + +TEST(XSD, boolean) +{ + EXPECT_TRUE (xsd::parseBoolean("1")); + EXPECT_TRUE (xsd::parseBoolean(" true ")); + EXPECT_FALSE(xsd::parseBoolean("0")); + EXPECT_FALSE(xsd::parseBoolean("false")); + EXPECT_THROW(xsd::parseBoolean("yes"), std::invalid_argument); + EXPECT_THROW(xsd::parseBoolean("TRUE"), std::invalid_argument); +} + +TEST(XSD, hexbinary) +{ + EXPECT_EQ(xsd::parseHexBinary("0102ff"), (std::vector{0x01, 0x02, 0xff})); + EXPECT_EQ(xsd::parseHexBinary(" 01 02\n FF "), (std::vector{0x01, 0x02, 0xff})); + EXPECT_TRUE(xsd::parseHexBinary("").empty()); + EXPECT_THROW(xsd::parseHexBinary("012"), std::invalid_argument); + EXPECT_THROW(xsd::parseHexBinary("zz"), std::invalid_argument); +}