From 9d89920280dedfadce1d6ea25cfd51616ceb4d2a Mon Sep 17 00:00:00 2001 From: Philippe Leduc Date: Mon, 15 Jun 2026 17:39:34 +0200 Subject: [PATCH] KickUI: a GUI tool to inspect, configure and operate an EtherCAT bus KickUI is an imgui/glfw bench tool built on the KickCAT master. It connects to a real NIC or to the embedded software simulator (tap), scans the bus and shows the discovered topology as a drawn graph (DL_STATUS port states, cable break/heal on the simulator), per-slave diagnostics (AL states, error-counter totals, event log with alert banner) and a per-device detail view: SDO transfers and object-dictionary discovery, PDO mapping read-back and manual editing, live process-data bytes, and full DS402 drive control (bring-up, state machine, manual/jog/generator setpoints). Rework README to lead with stack capabilities and move build, hardware, simulation, tooling, and performance instructions into dedicated docs/ files. --- CMakeLists.txt | 10 +- conan/conanfile.py | 4 +- lib/include/kickcat/CoE/OD.h | 30 + lib/include/kickcat/CoE/protocol.h | 26 + lib/include/kickcat/OS/Mutex.h | 15 + lib/include/kickcat/Ring.h | 1 + lib/include/kickcat/SimulatorControl.h | 79 +- lib/include/kickcat/protocol.h | 1 + .../simulation/SimulatorControlServer.h | 6 +- lib/simulation/src/SimulatorControlServer.cc | 34 +- lib/src/ESI/Parser.cc | 16 +- lib/src/OS/Unix/Mutex.cc | 16 + lib/src/protocol.cc | 14 + scripts/lib/build_options.sh | 4 + simulation/network_simulator.cc | 24 +- tools/kickui/BusProtocol.h | 207 ++ tools/kickui/BusSession.cc | 2411 +++++++++++++++++ tools/kickui/BusSession.h | 465 ++++ tools/kickui/CMakeLists.txt | 29 + tools/kickui/EtherCATPanel.cc | 225 ++ tools/kickui/EtherCATPanel.h | 36 + tools/kickui/EventLog.cc | 121 + tools/kickui/EventLog.h | 54 + tools/kickui/MotorPanel.cc | 514 ++++ tools/kickui/MotorPanel.h | 63 + tools/kickui/Panel.h | 33 + tools/kickui/PdoValuesPanel.cc | 68 + tools/kickui/PdoValuesPanel.h | 19 + tools/kickui/PrivilegeHelper.cc | 123 + tools/kickui/PrivilegeHelper.h | 49 + tools/kickui/Profile.cc | 42 + tools/kickui/Profile.h | 39 + tools/kickui/SdoPanel.cc | 524 ++++ tools/kickui/SdoPanel.h | 47 + tools/kickui/Simulator.cc | 387 +++ tools/kickui/Simulator.h | 90 + tools/kickui/Theme.cc | 67 + tools/kickui/Theme.h | 62 + tools/kickui/TopologyView.cc | 679 +++++ tools/kickui/TopologyView.h | 81 + tools/kickui/main.cc | 1213 +++++++++ unit/CMakeLists.txt | 1 + unit/src/CoE/protocol-t.cc | 15 + unit/src/Mutex-t.cc | 49 + unit/src/SimulatorControl-t.cc | 103 +- unit/src/mailbox/CoE/response-t.cc | 6 +- 46 files changed, 8025 insertions(+), 77 deletions(-) create mode 100644 tools/kickui/BusProtocol.h create mode 100644 tools/kickui/BusSession.cc create mode 100644 tools/kickui/BusSession.h create mode 100644 tools/kickui/CMakeLists.txt create mode 100644 tools/kickui/EtherCATPanel.cc create mode 100644 tools/kickui/EtherCATPanel.h create mode 100644 tools/kickui/EventLog.cc create mode 100644 tools/kickui/EventLog.h create mode 100644 tools/kickui/MotorPanel.cc create mode 100644 tools/kickui/MotorPanel.h create mode 100644 tools/kickui/Panel.h create mode 100644 tools/kickui/PdoValuesPanel.cc create mode 100644 tools/kickui/PdoValuesPanel.h create mode 100644 tools/kickui/PrivilegeHelper.cc create mode 100644 tools/kickui/PrivilegeHelper.h create mode 100644 tools/kickui/Profile.cc create mode 100644 tools/kickui/Profile.h create mode 100644 tools/kickui/SdoPanel.cc create mode 100644 tools/kickui/SdoPanel.h create mode 100644 tools/kickui/Simulator.cc create mode 100644 tools/kickui/Simulator.h create mode 100644 tools/kickui/Theme.cc create mode 100644 tools/kickui/Theme.h create mode 100644 tools/kickui/TopologyView.cc create mode 100644 tools/kickui/TopologyView.h create mode 100644 tools/kickui/main.cc create mode 100644 unit/src/Mutex-t.cc diff --git a/CMakeLists.txt b/CMakeLists.txt index bec1359e..c0f43bb3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,6 +25,7 @@ option(ENABLE_CODE_COVERAGE "Enable code coverage (requires gcovr)" OFF) option(ENABLE_ASAN "Build with AddressSanitizer" OFF) option(ENABLE_UBSAN "Build with UndefinedBehaviorSanitizer" OFF) option(BUILD_EEPROM_EDITOR "Build EEPROM editor GUI (requires imgui, glfw)" OFF) +option(BUILD_KICKUI "Build KickUI, the KickCAT GUI (requires imgui, glfw)" OFF) option(KICKCAT_INSTALL "Install KickCAT targets, headers, CMake config and pkg-config files" ${KICKCAT_INSTALL_DEFAULT}) include(Install) @@ -104,9 +105,16 @@ if (BUILD_TOOLS) add_subdirectory(tools) endif() -if (BUILD_EEPROM_EDITOR) +if (BUILD_EEPROM_EDITOR OR BUILD_KICKUI) add_subdirectory(tools/common) +endif() + +if (BUILD_EEPROM_EDITOR) add_subdirectory(tools/eeprom_editor) endif() +if (BUILD_KICKUI) + add_subdirectory(tools/kickui) +endif() + add_subdirectory(py_bindings) diff --git a/conan/conanfile.py b/conan/conanfile.py index bae902ef..4642e12d 100644 --- a/conan/conanfile.py +++ b/conan/conanfile.py @@ -16,6 +16,7 @@ class KickCATDev(ConanFile): "tools": [True, False], "master_examples": [True, False], "eeprom_editor": [True, False], + "kickui": [True, False], } default_options = { "unit_tests": False, @@ -24,6 +25,7 @@ class KickCATDev(ConanFile): "tools": True, "master_examples": True, "eeprom_editor": False, + "kickui": False, } generators = "CMakeDeps" @@ -41,7 +43,7 @@ def requirements(self): if self.options.tools or self.options.master_examples or self.options.simulation: self.requires("argparse/3.2") - if self.options.eeprom_editor: + if self.options.eeprom_editor or self.options.kickui: self.requires("imgui/1.92.6") self.requires("portable-file-dialogs/0.1.0") diff --git a/lib/include/kickcat/CoE/OD.h b/lib/include/kickcat/CoE/OD.h index 29e2d9b2..e3457c61 100644 --- a/lib/include/kickcat/CoE/OD.h +++ b/lib/include/kickcat/CoE/OD.h @@ -1,6 +1,7 @@ #ifndef KICKCAT_COE_OD_H #define KICKCAT_COE_OD_H +#include #include #include #include @@ -107,6 +108,32 @@ namespace kickcat::CoE }; char const* toString(enum DataType type); + // Wire properties of a basic CoE data type, for code that interprets raw entry + // bytes (the name is already available via toString). size 0 = string. + struct DataTypeInfo + { + DataType type; + uint16_t size; // fixed size in bytes; 0 for variable-length strings + bool is_signed; + bool is_real; + bool is_string; + }; + + // The fixed-width scalar + string data types, in a stable order. + constexpr std::array BASIC_DATA_TYPES = {{ + {DataType::UNSIGNED8, 1, false, false, false}, + {DataType::UNSIGNED16, 2, false, false, false}, + {DataType::UNSIGNED32, 4, false, false, false}, + {DataType::UNSIGNED64, 8, false, false, false}, + {DataType::INTEGER8, 1, true, false, false}, + {DataType::INTEGER16, 2, true, false, false}, + {DataType::INTEGER32, 4, true, false, false}, + {DataType::INTEGER64, 8, true, false, false}, + {DataType::REAL32, 4, false, true, false}, + {DataType::REAL64, 8, false, true, false}, + {DataType::VISIBLE_STRING, 0, false, false, true }, + }}; + constexpr bool isBasic(DataType type) { @@ -213,6 +240,9 @@ namespace kickcat::CoE std::string description; void* data{nullptr}; + // Internal ownership flag: true once PDO mapping redirects `data` into the + // process image (dtor must not free it). Set only on the data object a PDO + // maps; NOT a per-field "ready" API and never set on a 0x16xx/0x1Axx mapping object. bool is_mapped{false}; /// Called before access diff --git a/lib/include/kickcat/CoE/protocol.h b/lib/include/kickcat/CoE/protocol.h index fdb5ec39..95f4ba2b 100644 --- a/lib/include/kickcat/CoE/protocol.h +++ b/lib/include/kickcat/CoE/protocol.h @@ -8,6 +8,32 @@ namespace kickcat::CoE constexpr uint16_t SM_COM_TYPE = 0x1C00; // each sub-entry described SM[x] com type (mailbox in/out, PDO in/out, not used) constexpr uint16_t SM_CHANNEL = 0x1C10; // each entry is associated with the mapped PDOs (if in used) + // One entry of a PDO mapping object (0x1600.../0x1A00...): the 32-bit subindex + // value packs the mapped object's index, subindex and bit length (ETG.1000.6 / + // CiA-301). A padding gap has index 0. + struct PdoMappingEntry + { + uint16_t index; + uint8_t subindex; + uint8_t bitlen; + }; + + constexpr uint32_t toMappingWord(PdoMappingEntry entry) + { + return (static_cast(entry.index) << 16) + | (static_cast(entry.subindex) << 8) + | static_cast(entry.bitlen); + } + + constexpr PdoMappingEntry fromMappingWord(uint32_t word) + { + return PdoMappingEntry{ + static_cast(word >> 16), + static_cast(word >> 8), + static_cast(word), + }; + } + struct Header { uint16_t number : 9, diff --git a/lib/include/kickcat/OS/Mutex.h b/lib/include/kickcat/OS/Mutex.h index 1435a1a8..b0ced738 100644 --- a/lib/include/kickcat/OS/Mutex.h +++ b/lib/include/kickcat/OS/Mutex.h @@ -47,6 +47,21 @@ namespace kickcat private: Mutex& mutex_; ///< Reference to the mutex managed by the guard }; + + /// RAII manager for a non-blocking lock attempt. owns() reports whether the + /// lock was taken; the mutex is released on destruction only if owned. + class TryLockGuard + { + public: + TryLockGuard(Mutex& mutex); + ~TryLockGuard(); + + bool owns() const { return owned_; } + + private: + Mutex& mutex_; + bool owned_; + }; } #endif diff --git a/lib/include/kickcat/Ring.h b/lib/include/kickcat/Ring.h index e63e9d0c..49efa09e 100644 --- a/lib/include/kickcat/Ring.h +++ b/lib/include/kickcat/Ring.h @@ -2,6 +2,7 @@ #define KICKCAT_RING_H #include +#include namespace kickcat { diff --git a/lib/include/kickcat/SimulatorControl.h b/lib/include/kickcat/SimulatorControl.h index 85b88f94..94021a2b 100644 --- a/lib/include/kickcat/SimulatorControl.h +++ b/lib/include/kickcat/SimulatorControl.h @@ -41,16 +41,49 @@ namespace kickcat::sim ControlPayload payload; }; - struct ControlResponse + // Acknowledgement of a command: the echoed argument plus the outcome. + struct SetLinkAck { - ControlCommand::Type type; - uint8_t ok; // 0: command rejected (bad arguments / unknown type) - ControlPayload payload; // result, valid only when ok == 1 + SetLink link; + uint8_t ok; // 0: command rejected (bad arguments / unknown type) + }; + + // Frame-timing window the simulator emits unsolicited (one per N frames). + // Times are nanoseconds over the last `window` frames. + struct SimStats + { + uint64_t window; + uint64_t min_ns; + uint64_t max_ns; + uint64_t avg_ns; + }; + + union EventPayload + { + SetLinkAck set_link_ack; + SimStats stats; + }; + + // Everything the simulator sends back to the host travels on one return stream: + // command acks AND unsolicited events (frame stats today; more later). The host + // drains the stream and dispatches on `type`. Adding an event kind means a new + // Type tag and an EventPayload member -- no new channel. + struct ControlEvent + { + enum class Type : uint16_t + { + SetLinkAck, + FrameStats, + }; + + Type type; + EventPayload payload; }; // The messages are byte-copied through a shared-memory ring. static_assert(std::is_trivially_copyable_v); - static_assert(std::is_trivially_copyable_v); + static_assert(std::is_trivially_copyable_v); + static_assert(std::is_trivially_copyable_v); // Shared-memory transport: the segment holds a small header plus the POD ring // Contexts; each side wraps them with its own LockedRing whose pointers are @@ -62,8 +95,8 @@ namespace kickcat::sim { public: static constexpr uint32_t RING_SIZE = 64; // power of two - using CommandRing = LockedRing; - using ResponseRing = LockedRing; + using CommandRing = LockedRing; + using EventRing = LockedRing; ControlChannel() = default; ControlChannel(ControlChannel const&) = delete; @@ -76,22 +109,20 @@ namespace kickcat::sim bool sendCommand(ControlCommand const& cmd) { return commands_->push(cmd); } bool nextCommand(ControlCommand& out) { return commands_->tryPop(out); } - bool sendResponse(ControlResponse const& r) { return responses_->push(r); } - bool nextResponse(ControlResponse& out) { return responses_->tryPop(out); } + bool sendEvent(ControlEvent const& e) { return events_->push(e); } + bool nextEvent(ControlEvent& out) { return events_->tryPop(out); } - // Command and response rings are the same size and used 1:1, so room for a - // response guarantees the matching command's ack will fit. - bool responseSpaceAvailable() { return responses_->size() < RING_SIZE; } + bool eventSpaceAvailable() { return events_->size() < RING_SIZE; } private: static constexpr uint32_t MAGIC = 0x53494d43; // 'SIMC' struct Storage { - uint32_t magic; - uint32_t layout_size; - CommandRing::Context commands; - ResponseRing::Context responses; + uint32_t magic; + uint32_t layout_size; + CommandRing::Context commands; + EventRing::Context events; }; void open(std::string const& name, bool init) @@ -106,12 +137,12 @@ namespace kickcat::sim { std::memset(storage, 0, sizeof(Storage)); } - commands_ = std::make_unique(storage->commands); - responses_ = std::make_unique(storage->responses); + commands_ = std::make_unique(storage->commands); + events_ = std::make_unique(storage->events); if (init) { commands_->init(); - responses_->init(); + events_->init(); storage->layout_size = sizeof(Storage); storage->magic = MAGIC; // stamp last: a peer only sees it once ready } @@ -121,9 +152,9 @@ namespace kickcat::sim } } - SharedMemory shm_{}; - std::unique_ptr commands_; - std::unique_ptr responses_; + SharedMemory shm_{}; + std::unique_ptr commands_; + std::unique_ptr events_; }; // Master-side producer: creates and owns the channel. @@ -135,8 +166,8 @@ namespace kickcat::sim bool breakLink(uint16_t a, uint16_t b) { return setLink(a, b, 0); } bool healLink(uint16_t a, uint16_t b) { return setLink(a, b, 1); } - bool send(ControlCommand const& cmd) { return channel_.sendCommand(cmd); } - bool nextResponse(ControlResponse& out) { return channel_.nextResponse(out); } + bool send(ControlCommand const& cmd) { return channel_.sendCommand(cmd); } + bool nextEvent(ControlEvent& out) { return channel_.nextEvent(out); } private: bool setLink(uint16_t a, uint16_t b, uint8_t up) diff --git a/lib/include/kickcat/protocol.h b/lib/include/kickcat/protocol.h index 41d5e357..a313d762 100644 --- a/lib/include/kickcat/protocol.h +++ b/lib/include/kickcat/protocol.h @@ -138,6 +138,7 @@ namespace kickcat ERROR_ACK = 0x10 // Acknowledge flag request - check AL_STATUS }; char const* toString(State state); + char const* toShortString(State state); char const* ALStatus_to_string(int32_t code); diff --git a/lib/simulation/include/kickcat/simulation/SimulatorControlServer.h b/lib/simulation/include/kickcat/simulation/SimulatorControlServer.h index c2589a4f..f6aace98 100644 --- a/lib/simulation/include/kickcat/simulation/SimulatorControlServer.h +++ b/lib/simulation/include/kickcat/simulation/SimulatorControlServer.h @@ -18,8 +18,12 @@ namespace kickcat::sim void attach(std::string const& name); void drain(); // no-op until attached + // Publish the latest frame-timing window for the host to display. + // No-op until attached. + void publishStats(SimStats const& s); + private: - ControlResponse apply(ControlCommand const& cmd); + ControlEvent apply(ControlCommand const& cmd); ControlChannel channel_; EmulatedNetwork& network_; diff --git a/lib/simulation/src/SimulatorControlServer.cc b/lib/simulation/src/SimulatorControlServer.cc index 61c46a3a..a1b4e450 100644 --- a/lib/simulation/src/SimulatorControlServer.cc +++ b/lib/simulation/src/SimulatorControlServer.cc @@ -20,20 +20,34 @@ namespace kickcat::sim { return; } - // Stop if the response ring can't take an ack, so a command is never - // applied without being acknowledged (host fell behind draining responses). + // Stop if the event ring can't take an ack, so a command is never applied + // without being acknowledged (host fell behind draining the return stream). ControlCommand cmd{}; - while (channel_.responseSpaceAvailable() and channel_.nextCommand(cmd)) + while (channel_.eventSpaceAvailable() and channel_.nextCommand(cmd)) { - channel_.sendResponse(apply(cmd)); + channel_.sendEvent(apply(cmd)); } } - ControlResponse SimulatorControlServer::apply(ControlCommand const& cmd) + void SimulatorControlServer::publishStats(SimStats const& s) { - ControlResponse response{}; - response.type = cmd.type; - response.ok = 0; + if (not attached_) + { + return; + } + // Best-effort event: stats are lossy by nature, so a full ring just drops + // this window. Acks keep their guaranteed slot via drain()'s space check. + ControlEvent event{}; + event.type = ControlEvent::Type::FrameStats; + event.payload.stats = s; + channel_.sendEvent(event); + } + + ControlEvent SimulatorControlServer::apply(ControlCommand const& cmd) + { + ControlEvent response{}; + response.type = ControlEvent::Type::SetLinkAck; + response.payload.set_link_ack.ok = 0; if (cmd.type == ControlCommand::Type::SetLink) { @@ -45,8 +59,8 @@ namespace kickcat::sim { bool const up = (link.up != 0); network_.setLinkState(link.node_a, link.node_b, up); - response.ok = 1; - response.payload.set_link = link; + response.payload.set_link_ack.ok = 1; + response.payload.set_link_ack.link = link; } } diff --git a/lib/src/ESI/Parser.cc b/lib/src/ESI/Parser.cc index 8280b9f8..9e6972a0 100644 --- a/lib/src/ESI/Parser.cc +++ b/lib/src/ESI/Parser.cc @@ -5,6 +5,7 @@ #include #include +#include "kickcat/CoE/protocol.h" #include "kickcat/debug.h" #include "kickcat/ESI/Parser.h" @@ -930,9 +931,7 @@ CoE::Object Parser::buildMappingObject(Pdo const& pdo, bool is_rx) entry.description = "Entry " + std::to_string(i + 1); } entry.data = std::malloc(sizeof(uint32_t)); - uint32_t packed = (static_cast(e.index) << 16) - | (static_cast(e.subindex) << 8) - | static_cast(e.bit_len); + uint32_t packed = CoE::toMappingWord({e.index, e.subindex, static_cast(e.bit_len)}); std::memcpy(entry.data, &packed, sizeof(uint32_t)); obj.entries.push_back(std::move(entry)); bitoff = static_cast(bitoff + 32); @@ -1287,9 +1286,10 @@ void Parser::synthesizePdoMappingObjects(Device& device) } uint32_t mapping; std::memcpy(&mapping, entry.data, sizeof(uint32_t)); - uint16_t index = static_cast(mapping >> 16); - uint8_t sub = static_cast(mapping >> 8); - uint8_t bits = static_cast(mapping); + CoE::PdoMappingEntry me = CoE::fromMappingWord(mapping); + uint16_t index = me.index; + uint8_t sub = me.subindex; + uint8_t bits = me.bitlen; if (index == 0) { continue; // padding gap @@ -1317,9 +1317,7 @@ void Parser::synthesizePdoMappingObjects(Device& device) continue; } - uint32_t fixed = (static_cast(index) << 16) - | (static_cast(found_sub) << 8) - | bits; + uint32_t fixed = CoE::toMappingWord({index, static_cast(found_sub), bits}); std::memcpy(entry.data, &fixed, sizeof(uint32_t)); esi_warning("PDO 0x%04x: retargeted mapping 0x%04x:%u -> 0x%04x:%d (object declares data there)\n", obj.index, index, sub, index, found_sub); diff --git a/lib/src/OS/Unix/Mutex.cc b/lib/src/OS/Unix/Mutex.cc index fb4fd0bd..a54a838f 100644 --- a/lib/src/OS/Unix/Mutex.cc +++ b/lib/src/OS/Unix/Mutex.cc @@ -115,4 +115,20 @@ namespace kickcat { mutex_.unlock(); } + + + TryLockGuard::TryLockGuard(Mutex& mutex) + : mutex_(mutex) + , owned_(mutex.tryLock()) + { + } + + + TryLockGuard::~TryLockGuard() + { + if (owned_) + { + mutex_.unlock(); + } + } } diff --git a/lib/src/protocol.cc b/lib/src/protocol.cc index 54968e39..cb3ed719 100644 --- a/lib/src/protocol.cc +++ b/lib/src/protocol.cc @@ -114,6 +114,20 @@ namespace kickcat } } + char const* toShortString(State state) + { + uint8_t raw_state = state & 0xF; + switch (raw_state) + { + case INIT: { return "INIT"; } + case PRE_OP: { return "PRE-OP"; } + case BOOT: { return "BOOT"; } + case SAFE_OP: { return "SAFE-OP"; } + case OPERATIONAL: { return "OP"; } + default: { return "?"; } + } + } + char const* toString(Command cmd) { diff --git a/scripts/lib/build_options.sh b/scripts/lib/build_options.sh index 1973472c..2e4f516d 100644 --- a/scripts/lib/build_options.sh +++ b/scripts/lib/build_options.sh @@ -10,6 +10,7 @@ declare -A OPT_DEFAULTS=( [simulation]=ON [tools]=ON [eeprom_editor]=OFF + [kickui]=OFF [code_coverage]=OFF [af_xdp]=OFF [asan]=OFF @@ -24,6 +25,7 @@ declare -A OPT_DESCRIPTIONS=( [simulation]="Build simulation" [tools]="Build tools" [eeprom_editor]="Build EEPROM editor GUI" + [kickui]="Build KickUI GUI" [code_coverage]="Enable code coverage" [af_xdp]="Enable AF_XDP socket" [asan]="Build with AddressSanitizer" @@ -38,6 +40,7 @@ declare -A OPT_CMAKE_FLAGS=( [simulation]="BUILD_SIMULATION" [tools]="BUILD_TOOLS" [eeprom_editor]="BUILD_EEPROM_EDITOR" + [kickui]="BUILD_KICKUI" [code_coverage]="ENABLE_CODE_COVERAGE" [af_xdp]="ENABLE_AF_XDP" [asan]="ENABLE_ASAN" @@ -52,6 +55,7 @@ declare -A OPT_CONAN_FLAGS=( [simulation]="simulation" [tools]="tools" [eeprom_editor]="eeprom_editor" + [kickui]="kickui" ) # Load .buildconfig into the CONFIG associative array. diff --git a/simulation/network_simulator.cc b/simulation/network_simulator.cc index 7277d906..4e3abbb5 100644 --- a/simulation/network_simulator.cc +++ b/simulation/network_simulator.cc @@ -143,8 +143,6 @@ namespace while (running) { - control.drain(); - auto t1 = since_epoch(); bool serviced = serviceFrame(socket, socket_redundancy, false); @@ -157,6 +155,8 @@ namespace continue; // both ports idle: re-check `running` (shutdown) and retry } + control.drain(); + for (auto& sim_slave : slaves) { sim_slave.slave->routine(); @@ -199,10 +199,22 @@ namespace if (stats.size() >= 1000) { std::sort(stats.begin(), stats.end()); - printf("[%f] frame processing time: \n\t min: %f\n\t max: %f\n\t avg: %f\n", seconds_f(since_start()).count(), - stats.front().count() / 1000.0, - stats.back().count() / 1000.0, - (std::reduce(stats.begin(), stats.end()) / stats.size()).count() / 1000.0); + sim::SimStats s{}; + s.window = stats.size(); + s.min_ns = static_cast(stats.front().count()); + s.max_ns = static_cast(stats.back().count()); + s.avg_ns = static_cast((std::reduce(stats.begin(), stats.end()) / stats.size()).count()); + + control.publishStats(s); + + // One overwriting line (\r + trailing pad) instead of a scrolling + // log; the GUI shows the live history when launched through KickUI. + printf("\rframe proc: min %4llu max %5llu avg %4llu \xc2\xb5s (n=%zu, t=%.0fs) ", + static_cast(s.min_ns / 1000), + static_cast(s.max_ns / 1000), + static_cast(s.avg_ns / 1000), + s.window, seconds_f(since_start()).count()); + fflush(stdout); stats.clear(); } } diff --git a/tools/kickui/BusProtocol.h b/tools/kickui/BusProtocol.h new file mode 100644 index 00000000..d2fb7a7a --- /dev/null +++ b/tools/kickui/BusProtocol.h @@ -0,0 +1,207 @@ +#ifndef KICKCAT_TOOLS_KICKUI_BUS_PROTOCOL_H +#define KICKCAT_TOOLS_KICKUI_BUS_PROTOCOL_H + +#include +#include +#include +#include + +#include "kickcat/CoE/CiA/DS402/Drive.h" // UnitConfig, ControlMode +#include "kickcat/CoE/OD.h" // DataType, ObjectCode, Access +#include "kickcat/protocol.h" // State + +namespace kickcat::kickui +{ + // The data exchanged between the UI thread and the bus-owning actor: + // plain value types, the discrete Event stream and the published snapshot. + // No live Bus/Link types belong here. + + enum class SetpointSource { Manual, Jog, Generator }; + enum class Waveform { Sine, Step, Triangle }; + + // Snapshot of the controlled drive, published by the RT thread each cycle + // and read by the UI thread. + struct DriveFeedback + { + uint16_t status_word = 0; + uint16_t control_word = 0; + uint16_t error_code = 0; + int8_t mode_display = 0; + int32_t actual_pos_raw = 0; + int32_t actual_vel_raw = 0; + int16_t actual_torque_raw = 0; + double actual_pos = 0.0; + double actual_vel = 0.0; + double actual_torque = 0.0; + double target = 0.0; + bool enabled = false; + bool faulted = false; + bool operational = false; + }; + + // One mapped PDO entry (which object/subindex, and its bit length). + struct PdoEntry + { + uint16_t pdo = 0; + uint16_t index = 0; + uint8_t sub = 0; + uint8_t bits = 0; + }; + + // Per-drive bring-up parameters for operate(). PDO indices default to the + // canonical 0x1600/0x1A00 but are overridable (Marvin uses 0x1601/0x1A01). + // DC is a bus-wide setting (see setDcConfig), not per-drive. + struct OperateConfig + { + CoE::CiA::DS402::UnitConfig units; + CoE::CiA::DS402::control::ControlMode mode = CoE::CiA::DS402::control::POSITION_CYCLIC; + uint16_t rx_pdo_map = 0x1600; + uint16_t tx_pdo_map = 0x1A00; + + // Manual PDO mapping: when set, the bring-up writes these entries to the + // rx_pdo_map/tx_pdo_map objects (CoE remap sequence) before createMapping + // instead of relying on the slave's SII default. Each entry's pdo field is + // ignored on apply (the owning object is rx_pdo_map/tx_pdo_map). + bool manual_mapping = false; + std::vector manual_rx; // -> rx_pdo_map / 0x1C12 + std::vector manual_tx; // -> tx_pdo_map / 0x1C13 + }; + + // Result of one async SDO read/write; the UI polls `done`. + struct SdoResult + { + std::atomic done{false}; + bool ok = false; + std::string message; + std::vector data; + }; + + // One subindex of a RECORD/ARRAY object (its own description + value). + struct OdEntry + { + uint8_t subindex = 0; + CoE::DataType data_type = CoE::DataType::UNKNOWN; + uint16_t access = 0; // CoE::Access bitmask + std::string name; + std::vector value; + std::string value_error; + }; + + // One object discovered via the SDO-Information service. + struct OdObject + { + uint16_t index = 0; + uint8_t max_subindex = 0; + CoE::ObjectCode object_code = CoE::ObjectCode::NIL; + CoE::DataType data_type = CoE::DataType::UNKNOWN; + uint16_t access = 0; // CoE::Access bitmask of subindex 0 (from GetED) + std::string name; + std::vector value; // sub0 value (object value for VAR, count for RECORD/ARRAY) + std::string value_error; // abort/error text when a value read failed (surfaced to UI) + std::vector entries; // sub1..N for RECORD/ARRAY (empty for VAR) + }; + + struct PdoMapping + { + int slave = -1; + bool valid = false; + std::string error; + std::vector rx; // SM2 / 0x1C12 outputs + std::vector tx; // SM3 / 0x1C13 inputs + }; + + // One slave as the master sees it on the wire: its address, the per-port link + // state read from DL_STATUS (0x110), and its parent in the discovered tree. + struct TopologyNode + { + int index = -1; + uint16_t address = 0; + uint16_t parent_address = 0; // == address for a root (connected to the master) + std::string name; + int open_ports = 0; + bool port_loop[4] = {false, false, false, false}; // LOOP_portX (open / looped back) + bool port_com[4] = {false, false, false, false}; // COM_portX (communicating) + }; + + // The bus topology discovered from the master's point of view. + struct TopologyInfo + { + std::vector nodes; // in bus (discovery) order + bool valid = false; + std::string error; // set when the tree could not be derived + }; + + // ---- UI -> bus actor ---------------------------------------------------- + // The complete motor command set, sent whole so multi-field edits (STOP, + // mode-change-with-reseed) are atomic by construction. + struct MotorCmd + { + bool enable = false; + int target_state = static_cast(State::SAFE_OP); + int mode = static_cast(CoE::CiA::DS402::control::POSITION_CYCLIC); + int source = static_cast(SetpointSource::Manual); + double manual_target = 0.0; + double manual_ramp = 0.0; // unit/s toward manual_target; <=0 = step + double jog_rate = 0.0; + int gen_wave = static_cast(Waveform::Sine); + double gen_amp = 0.0; + double gen_freq = 0.0; + double gen_offset = 0.0; + }; + + // ---- bus actor -> UI (discrete, must not be lost) ----------------------- + struct Event + { + enum class Kind + { + OdScanObject, OdScanProgress, OdScanDone, + MappingResult, + StateActionResult, // per-slave; an empty message means success + }; + + Kind kind = Kind::OdScanObject; + int slave = -1; + std::string message; + + OdObject od_object; // OdScanObject + int count = 0; // OdScanProgress + int total = 0; + PdoMapping mapping; // MappingResult + }; + + // ---- bus actor -> UI (high-rate, lossy snapshot) ------------------------ + // Per-slave error counters, accumulated by the bus thread and published as + // one block (a new counter is a new field here, nothing else to keep in sync). + // The slave counters are uint8 and saturate, so the bus thread reads the + // delta each sweep, sums it here, and clears the slave before saturation. + // Totals are "since the last reset". + struct SlaveErrorStats + { + uint16_t al_status_code = 0; // reason when the AL error bit is set + uint64_t lost_total[4] = {0, 0, 0, 0}; // lost-link per port + uint64_t rxerr_total[4] = {0, 0, 0, 0}; // rx invalid-frame per port + bool saturated = false; // a raw read hit 255 -> totals are a lower bound + }; + + struct SlaveSnapshot + { + uint8_t al_status = 0; + bool port_com[4] = {false, false, false, false}; // live link state per port + DriveFeedback fb; + std::vector in_raw; + std::vector out_raw; + std::string motor_error; + SlaveErrorStats stats; + }; + + struct BusSnapshot + { + bool redundancy_active = false; + std::string status; + std::string error; + TopologyInfo topology; + std::vector slaves; // index-aligned with the device list + }; +} + +#endif diff --git a/tools/kickui/BusSession.cc b/tools/kickui/BusSession.cc new file mode 100644 index 00000000..fe2d15b7 --- /dev/null +++ b/tools/kickui/BusSession.cc @@ -0,0 +1,2411 @@ +#include "BusSession.h" + +#include +#include +#include +#include + +#include "kickcat/Bus.h" +#include "kickcat/Diagnostics.h" +#include "kickcat/CoE/OD.h" +#include "kickcat/CoE/protocol.h" +#include "kickcat/Error.h" +#include "kickcat/Frame.h" +#include "kickcat/Link.h" +#include "kickcat/Mailbox.h" +#include "kickcat/MailboxSequencer.h" +#include "kickcat/OS/Timer.h" +#include "kickcat/Units.h" +#include "kickcat/helpers.h" + +using namespace std::chrono; +using namespace kickcat::CoE::CiA::DS402; + +namespace kickcat::kickui +{ + namespace + { + // what() plus the decoded AL status when the failure is an AL error. + std::string describeError(std::exception const& e) + { + std::string msg = e.what(); + auto const* al = dynamic_cast(&e); + if (al != nullptr) + { + msg += std::string(" [AL status: ") + ALStatus_to_string(al->code()) + "]"; + } + return msg; + } + + // Read each slave's DL_STATUS port bits and derive the parent/child tree + // (getTopology). Runs on whichever thread currently owns the bus. With + // refetch the DL status is re-read from the wire first (live refresh); + // at connect time bus.init() has already populated it. + TopologyInfo computeTopology(Bus& bus, bool refetch) + { + TopologyInfo info; + auto& slaves = bus.slaves(); + if (refetch) + { + for (auto& slave : slaves) + { + bus.sendGetDLStatus(slave, [](DatagramState const&){}); + } + bus.processAwaitingFrames(); + } + + std::unordered_map parents; + try + { + parents = getTopology(slaves); // child address -> parent address + info.valid = true; + } + catch (std::exception const& e) + { + // A slave reporting zero open ports (mid break/heal) throws; fall + // back to a flat list so the per-port state is still shown. + info.error = e.what(); + } + + info.nodes.reserve(slaves.size()); + for (int i = 0; i < static_cast(slaves.size()); ++i) + { + auto& slave = slaves[i]; + auto& dl = slave.dl_status; + TopologyNode node; + node.index = i; + node.address = slave.address; + node.name = slave.name(); + node.parent_address = slave.address; + auto it = parents.find(slave.address); + if (it != parents.end()) + { + node.parent_address = it->second; + } + node.open_ports = slave.countOpenPorts(); + + for (int p = 0; p < 4; ++p) + { + node.port_loop[p] = dl.loop(p); + node.port_com[p] = dl.communication(p); + } + + info.nodes.push_back(std::move(node)); + } + return info; + } + } + + BusSession::BusSession() + { + events_.init(); + refreshInterfaces(); + } + + // The bus-owning thread posts a discrete result; the UI thread applies it in + // drainEvents() (called from update()). false when the queue is full. + bool BusSession::pushEvent(Event ev) + { + return events_.push(ev); + } + + void BusSession::drainEvents() + { + Event ev; + while (events_.tryPop(ev)) + { + switch (ev.kind) + { + case Event::Kind::OdScanProgress: + { + auto& s = od_scans_[ev.slave]; + s.count = ev.count; + s.total = ev.total; + break; + } + case Event::Kind::OdScanObject: + { + auto& s = od_scans_[ev.slave]; + s.objects.push_back(std::move(ev.od_object)); + s.count = static_cast(s.objects.size()); + break; + } + case Event::Kind::OdScanDone: + { + auto& s = od_scans_[ev.slave]; + s.running = false; + s.error = ev.message; + break; + } + case Event::Kind::MappingResult: + { + auto& s = pdo_scans_[ev.slave]; + s.mapping = std::move(ev.mapping); + s.running = false; + break; + } + case Event::Kind::StateActionResult: + { + state_errors_[ev.slave] = ev.message; // empty = success (clears the error) + break; + } + } + } + } + + BusSession::~BusSession() + { + stopBusThread(); + joinWorker(); + joinDetectWorker(); + } + + void BusSession::refreshInterfaces() + { + interfaces_ = listInterfaces(); + // The GUI master attaches to the software simulator as the TapSocket client + // (tap:server is the simulator's role). createSockets() needs the literal + // "tap:client"; the combo shows the friendly description instead. + interfaces_.push_back({"tap:client", "tap to simulation"}); + } + + void BusSession::detectNetworks() + { + if (detecting_) + { + return; + } + joinDetectWorker(); + { + LockGuard lock(detect_mtx_); + detect_results_.clear(); + detect_status_ = "Probing interfaces..."; + } + detecting_ = true; + detect_done_ = false; + + std::vector names; + for (auto const& nif : interfaces_) + { + if (nif.name.rfind("tap:", 0) == 0) + { + continue; + } + names.push_back(nif.name); + } + + detect_worker_.emplace("kickui-detect", [this, names]() + { + int networks = 0; + bool denied = false; + for (auto const& name : names) + { + int count = 0; + try + { + auto [socket, no_red] = createSockets(name, ""); + auto link = std::make_shared(socket, no_red, []{}); + link->setTimeout(2ms); + + Frame frame; + frame.addDatagram(0, Command::BRD, createAddress(0, reg::TYPE), nullptr, 1); + link->writeThenRead(frame); + auto [header, _, wkc] = frame.nextDatagram(); + count = wkc; + } + catch (std::system_error const& e) + { + if ((e.code().value() == EPERM) or (e.code().value() == EACCES)) + { + denied = true; + needs_privilege_ = true; + } + } + catch (std::exception const&) + { + // No answer on the wire (timeout): not an EtherCAT network. + } + if (count > 0) + { + networks += 1; + } + LockGuard lock(detect_mtx_); + detect_results_[name] = count; + } + + std::string summary; + if (denied) + { + summary = "Detection needs elevated privileges (raw sockets)."; + } + else if (networks == 0) + { + summary = "No EtherCAT network found."; + } + else if (networks == 1) + { + summary = "1 EtherCAT network found."; + } + else + { + summary = std::to_string(networks) + " EtherCAT networks found."; + } + { + LockGuard lock(detect_mtx_); + detect_status_ = summary; + } + detect_done_ = true; + }, 0); // priority 0: SCHED_OTHER (probing is not real-time) + detect_worker_->start(); + } + + int BusSession::detectResult(std::string const& interface) const + { + LockGuard lock(detect_mtx_); + auto it = detect_results_.find(interface); + if (it == detect_results_.end()) + { + return -1; + } + return it->second; + } + + std::string BusSession::detectStatus() const + { + LockGuard lock(detect_mtx_); + return detect_status_; + } + + void BusSession::joinDetectWorker() + { + if (detect_worker_) + { + detect_worker_->join(); + detect_worker_.reset(); + } + } + + void BusSession::setDcConfig(bool enable, int cycle_ms) + { + dc_enabled_ = enable; + cycle_ms_ = cycle_ms; + if (cycle_ms_ < 1) + { + cycle_ms_ = 1; + } + } + + void BusSession::setRedundancy(std::string const& redundant_interface) + { + redundancy_interface_ = redundant_interface; + } + + bool BusSession::redundancyEnabled() const + { + return not redundancy_interface_.empty(); + } + + void BusSession::joinWorker() + { + if (worker_) + { + worker_->join(); + worker_.reset(); + } + } + + std::string BusSession::interfaceName() const + { + return interface_name_; + } + + // The read-only display getters source from the published snapshot instead of + // the per-field mutexes. Null-safe before the first publish. + std::shared_ptr BusSession::snapshot() const + { + LockGuard lock(snap_mtx_); + return snapshot_; + } + + std::string BusSession::status() const + { + auto s = snapshot(); + if (s) { return s->status; } + return {}; + } + + std::string BusSession::error() const + { + auto s = snapshot(); + if (s) { return s->error; } + return {}; + } + + // Build a fresh BusSnapshot and swap it in, once per UI frame from update(). + // Not on the bus thread: that would put allocations on the 1 kHz cyclic path. + void BusSession::publishSnapshot() + { + auto snap = std::make_shared(); + + snap->redundancy_active = redundancy_active_; + { + LockGuard lock(mtx_); + snap->status = status_; + snap->error = error_; + } + { + LockGuard lock(topo_mtx_); + snap->topology = topology_; + } + { + LockGuard lock(state_mtx_); + snap->slaves.resize(slave_states_.size()); + for (size_t i = 0; i < slave_states_.size(); ++i) + { + snap->slaves[i].al_status = slave_states_[i]; + if (i < slave_diag_.size()) + { + snap->slaves[i].stats = slave_diag_[i].stats; + } + } + } + // Live per-port link state (topology node index == device index). + for (auto const& node : snap->topology.nodes) + { + if ((node.index >= 0) and (node.index < static_cast(snap->slaves.size()))) + { + for (int p = 0; p < 4; ++p) { snap->slaves[node.index].port_com[p] = node.port_com[p]; } + } + } + { + LockGuard lock(controls_mtx_); + for (auto const& c : controls_) + { + int idx = c->slave_index_; + if ((idx >= 0) and (idx < static_cast(snap->slaves.size()))) + { + SlaveSnapshot& ss = snap->slaves[idx]; + ss.fb = c->feedback(); + ss.in_raw = c->inputPdo(); + ss.out_raw = c->outputPdo(); + ss.motor_error = c->error(); + } + } + } + { + LockGuard lock(snap_mtx_); + snapshot_ = snap; + } + } + + Device* BusSession::selectedDevice() + { + if ((selected_ < 0) or (selected_ >= static_cast(devices_.size()))) + { + return nullptr; + } + return &devices_[selected_]; + } + + TopologyInfo BusSession::topology() const + { + auto s = snapshot(); + if (s) { return s->topology; } + return {}; + } + + void BusSession::refreshTopology() + { + if (not connected_) + { + return; + } + SdoCommand cmd; + cmd.kind = SdoCommand::Kind::Topology; + enqueue(std::move(cmd)); + } + + void BusSession::clearErrorCounters() + { + if (not connected_) + { + return; + } + SdoCommand cmd; + cmd.kind = SdoCommand::Kind::ClearErrors; + enqueue(std::move(cmd)); + } + + // --- Device: forwards to the owning BusSession's thread-safe paths --- + // (session is wired in update() when the device set is promoted.) + void Device::requestState(uint8_t state) { session->requestSlaveState(index, state); } + + bool Device::sdoAvailable() const { return session->sdoAvailable(); } + std::shared_ptr Device::readSDO(uint16_t obj_index, uint8_t subindex, int access) + { + return session->readSDO(index, obj_index, subindex, access); + } + std::shared_ptr Device::writeSDO(uint16_t obj_index, uint8_t subindex, int access, + std::vector data) + { + return session->writeSDO(index, obj_index, subindex, access, std::move(data)); + } + void Device::discoverOD() { session->discoverOD(index); } + OdScan Device::odScan() const { return session->odScan(index); } + void Device::readPdoMapping() { session->readPdoMapping(index); } + PdoScan Device::pdoScan() const { return session->pdoScan(index); } + + void Device::configureSlave(OperateConfig const& config) { session->configureSlave(index, config); } + void Device::includeSlave() { session->includeSlave(index); } + void Device::includeSlave(OperateConfig const& c) { session->includeSlave(index, c); } + void Device::unconfigureSlave() { session->unconfigureSlave(index); } + bool Device::isConfigured() const { return session->isConfigured(index); } + bool Device::isOperating() const { return session->isOperating(index); } + std::shared_ptr Device::motor() const { return session->motor(index); } + + void BusSession::connect(std::string const& interface) + { + if (connecting_) + { + return; + } + joinWorker(); + + needs_privilege_ = false; + done_ = false; + connecting_ = true; + interface_name_ = interface; + redundancy_active_ = false; + { + LockGuard lock(mtx_); + dropStagedConnection(); // a leftover staged bus must never be promoted + status_ = "Connecting to " + interface + "..."; + error_.clear(); + } + + std::string interface_name = interface; + std::string redundant_interface = redundancy_interface_; + worker_.emplace("kickui-connect", [this, interface_name, redundant_interface]() + { + auto set_status = [this](std::string const& s) + { + LockGuard lock(mtx_); + status_ = s; + }; + + try + { + auto [nominal, redundancy] = createSockets(interface_name, redundant_interface); + auto report_redundancy = [this]{ redundancy_active_ = true; }; + auto link = std::make_shared(nominal, redundancy, report_redundancy); + link->setTimeout(2ms); + + if (not redundant_interface.empty()) + { + // Probe the ring once: if the redundant port answers, the wire + // is intact. A probe failure is non-fatal -- the nominal path + // still runs, just without a backup. + try + { + link->checkRedundancyNeeded(); + } + catch (std::exception const& e) + { + set_status(std::string("Redundancy probe failed: ") + e.what()); + } + } + + auto bus = std::make_unique(link); + bus->configureWaitLatency(1ms, 10ms); + + set_status("Scanning bus (INIT -> PRE-OP)..."); + bus->init(); // ends in PRE-OP + + if (dc_enabled_) + { + // DC is enabled here (PRE-OP) and the cyclic timer is left + // unaligned on purpose: these drives tolerate a free-running + // master cycle, and phase-aligning the RT loop to the + // connect-time sync point (stale by the time operate() runs) + // mis-phases against SYNC0 and makes CSP drives fault. + set_status("Enabling distributed clocks..."); + bus->enableDC(milliseconds(cycle_ms_.load()), 500us, 100ms); + } + + set_status("Detecting devices..."); + std::vector devices; + auto& slaves = bus->slaves(); + + // Device-emulation ESCs (ESC_CONFIG bit 0: no PDI application, + // AL status mirrored from AL control) echo the master's ERROR_ACK + // request bit back as a phantom error indication. Flag them so the + // AL error bit can be masked when polling their state. + std::vector emulated(slaves.size(), 0); + try + { + for (size_t i = 0; i < slaves.size(); ++i) + { + uint8_t config = 0; + link->addDatagram(Command::FPRD, + createAddress(slaves[i].address, reg::ESC_CONFIG), config, + [&emulated, i](DatagramHeader const*, uint8_t const* data, uint16_t wkc) + { + if (wkc == 1) + { + emulated[i] = data[0] & 0x01; + } + return DatagramState::OK; + }, + [](DatagramState const&){}); + } + link->processDatagrams(); + } + catch (std::exception const&) + { + // No answer: treat every slave as a regular ESC. + } + for (int i = 0; i < static_cast(slaves.size()); ++i) + { + auto& slave = slaves[i]; + Device dev; + dev.index = i; + dev.address = slave.address; + dev.name = slave.name(); + dev.vendor_id = slave.sii.info.vendor_id; + dev.product_code = slave.sii.info.product_code; + + // Mailbox capabilities come straight from the SII mailbox + // protocol bits (what the device declares it speaks). + uint16_t mbx = slave.sii.info.mailbox_protocol; + dev.has_coe = (mbx & eeprom::MailboxProtocol::CoE) != 0; + dev.has_foe = (mbx & eeprom::MailboxProtocol::FoE) != 0; + dev.has_eoe = (mbx & eeprom::MailboxProtocol::EoE) != 0; + dev.is_emulated = (emulated[i] != 0); + + // The CiA profile is the low word of the device-type object (0x1000). + if (dev.has_coe) + { + uint32_t device_type = 0; + uint32_t size = sizeof(device_type); + try + { + bus->readSDO(slave, 0x1000, 0, Bus::Access::PARTIAL, &device_type, &size, 100ms); + dev.detected_profile = profileFromCoeDeviceType(static_cast(device_type & 0xFFFF)); + } + catch (std::exception const&) + { + // 0x1000 unreadable (abort/busy/write-only) is not proof CoE + // is dead -- the SII declared it. Keep the SDO/OD panels; + // treat the profile as generic until the user forces one. + dev.detected_profile = Profile::Generic; + } + } + devices.push_back(std::move(dev)); + } + + TopologyInfo topo = computeTopology(*bus, false); + + { + LockGuard lock(mtx_); + staged_link_ = std::move(link); + staged_bus_ = std::move(bus); + staged_devices_ = std::move(devices); + staged_topology_ = std::move(topo); + status_ = "Connected."; + } + done_ = true; + } + catch (std::system_error const& e) + { + LockGuard lock(mtx_); + if ((e.code().value() == EPERM) or (e.code().value() == EACCES)) + { + needs_privilege_ = true; + error_ = "Permission denied: raw socket requires elevated privileges."; + } + else + { + error_ = std::string("Connection failed: ") + e.what(); + } + done_ = true; + } + catch (std::exception const& e) + { + LockGuard lock(mtx_); + error_ = std::string("Connection failed: ") + e.what(); + done_ = true; + } + }, 0); // priority 0: SCHED_OTHER (connect is not real-time) + worker_->start(); + } + + void BusSession::rescan() + { + if (connecting_) + { + return; + } + std::string interface = interface_name_; + if (interface.empty()) + { + return; + } + disconnect(); // tear down the current link before re-opening + connect(interface); + } + + void BusSession::update() + { + drainEvents(); // apply discrete bus->UI results before the frame reads them + + if (detecting_ and detect_done_) + { + joinDetectWorker(); + detecting_ = false; + detect_done_ = false; + } + + bool just_connected = false; + if (connecting_ and done_) + { + joinWorker(); + connecting_ = false; + done_ = false; + + LockGuard lock(mtx_); + if (staged_bus_) + { + link_ = std::move(staged_link_); + bus_ = std::move(staged_bus_); + devices_ = std::move(staged_devices_); + for (auto& dev : devices_) { dev.session = this; } // wire the forwarding handle + { + LockGuard tlock(topo_mtx_); + topology_ = std::move(staged_topology_); + } + connected_ = true; + selected_ = -1; + if (not devices_.empty()) + { + selected_ = 0; + } + LockGuard state_lock(state_mtx_); + slave_states_.assign(devices_.size(), static_cast(State::PRE_OP)); + just_connected = true; + } + } + + if (just_connected) + { + // Snapshot the immutable per-slave flags for the bus thread before + // it exists (no lock needed: thread start orders the writes). + slave_emulated_.clear(); + for (auto const& dev : devices_) + { + uint8_t flag = 0; + if (dev.is_emulated) + { + flag = 1; + } + slave_emulated_.push_back(flag); + } + startBusThread(); // one bus-owning thread, idle phase first + } + + // Republish the UI view from the current authoritative state (once per + // frame; render() calls update() before reading the getters). + publishSnapshot(); + } + + // Under mtx_: discard an unpromoted connect result, so a later connect's + // update() can never promote a stale Bus. + void BusSession::dropStagedConnection() + { + staged_link_.reset(); + staged_bus_.reset(); + staged_devices_.clear(); + staged_topology_ = TopologyInfo{}; + } + + void BusSession::disconnect() + { + stopBusThread(); // graceful-stops the cyclic phase, then joins the one thread + { + LockGuard lock(controls_mtx_); + controls_.clear(); + } + joinWorker(); + connecting_ = false; + connected_ = false; + done_ = false; + bus_.reset(); + link_.reset(); + devices_.clear(); + slave_emulated_.clear(); + selected_ = -1; + interface_name_.clear(); + { + LockGuard state_lock(state_mtx_); + slave_states_.clear(); + slave_diag_.clear(); + } + // UI-thread-only now (the bus threads were joined above); drop any stale + // events so they don't repopulate the cleared scan state next frame. + Event discard; + while (events_.tryPop(discard)) {} + od_scans_.clear(); + pdo_scans_.clear(); + state_errors_.clear(); + LockGuard lock(mtx_); + dropStagedConnection(); // an in-flight connect result dies with the session + status_.clear(); + error_.clear(); + } + + std::shared_ptr BusSession::findControl(int slave_index) const + { + LockGuard lock(controls_mtx_); + for (auto const& c : controls_) + { + if (c->slave_index_ == slave_index) + { + return c; + } + } + return nullptr; + } + + bool BusSession::isOperating(int slave_index) const + { + return rt_running_ and (findControl(slave_index) != nullptr); + } + + void BusSession::configureSlave(int slave_index, OperateConfig const& config) + { + // operate_requested_: the cyclic bring-up snapshots the controls/configs + // asynchronously -- no edits once it is requested. + if (not connected_ or rt_running_ or operate_requested_) + { + return; // mapping is fixed while operating; backToPreOp() first + } + if ((slave_index < 0) or (slave_index >= static_cast(devices_.size()))) + { + return; + } + + LockGuard lock(controls_mtx_); + std::shared_ptr control; + for (auto const& c : controls_) + { + if (c->slave_index_ == slave_index) { control = c; break; } + } + if (control == nullptr) + { + control = std::make_shared(slave_index); + controls_.push_back(control); + } + control->session_ = this; + control->is_drive_ = true; + control->config_ = config; + // UI-side mirror only; the bus actor seeds DriveRuntime.cmd from config_ at + // operate (these defaults match), then takes live updates off the queue. + control->cmd_ = MotorControl::Command{}; + control->cmd_.mode = static_cast(config.mode); + control->cmd_.source = static_cast(SetpointSource::Manual); + control->cmd_.target_state = static_cast(State::SAFE_OP); + { + LockGuard f(control->fb_mtx_); + control->fb_ = DriveFeedback{}; + control->error_.clear(); + } + } + + void BusSession::includeSlave(int slave_index) + { + includeSlave(slave_index, OperateConfig{}); // SII default mapping + } + + void BusSession::includeSlave(int slave_index, OperateConfig const& config) + { + // Generic CoE slave: include it in the bus mapping (via its SII PDO, or a + // manual PDO mapping if config.manual_mapping) and let the loop transition + // it, but give it NO DS402 Drive. + if (not connected_ or rt_running_ or operate_requested_) + { + return; + } + if ((slave_index < 0) or (slave_index >= static_cast(devices_.size()))) + { + return; + } + LockGuard lock(controls_mtx_); + std::shared_ptr control; + for (auto const& c : controls_) + { + if (c->slave_index_ == slave_index) { control = c; break; } + } + if (control == nullptr) + { + control = std::make_shared(slave_index); + controls_.push_back(control); + } + control->session_ = this; + control->is_drive_ = false; + control->config_ = config; // re-including updates the config, same as configureSlave + control->cmd_.target_state = static_cast(State::SAFE_OP); // UI mirror; generic slave has no Drive + } + + void BusSession::unconfigureSlave(int slave_index) + { + if (rt_running_ or operate_requested_) + { + return; // changing the operated set is a PRE-OP action + } + LockGuard lock(controls_mtx_); + for (auto it = controls_.begin(); it != controls_.end(); ++it) + { + if ((*it)->slave_index_ == slave_index) { controls_.erase(it); break; } + } + } + + void BusSession::applyMapping() + { + // PRE-OP -> map every configured slave ONCE and run the cyclic phase + // continuously. Per-slave state changes after this never rebuild. + if (rt_running_ or not connected_) + { + return; + } + bool any = false; + { + LockGuard lock(controls_mtx_); + any = not controls_.empty(); + } + if (any) + { + // Ask the bus thread to leave the idle phase and enter rtLoop(). + operate_requested_ = true; + LockGuard lock(sdo_mtx_); + sdo_cv_.signal(); + } + } + + void BusSession::backToPreOp() + { + // Leave the cyclic phase: rtLoop graceful-stops and the bus thread returns + // to the idle phase by itself (same thread -- no hand-off, no restart race). + rt_stop_ = true; + } + + // The single bus-owning thread: alternate between the idle phase (serviceLoop) + // and the cyclic phase (rtLoop). One thread owns the Bus for the whole connected + // lifetime, so the old connect-worker -> service -> RT hand-off invariants are gone. + void BusSession::busActor() + { + while (not bus_stop_) + { + serviceLoop(); // idle: returns on operate_requested_ or bus_stop_ + if (bus_stop_) { break; } + if (operate_requested_) + { + operate_requested_ = false; + rt_stop_ = false; + rt_running_ = true; + rtLoop(); // cyclic: graceful-stops + clears rt_running_ on exit + } + } + } + + void BusSession::startBusThread() + { + bus_stop_ = false; + operate_requested_ = false; + rt_stop_ = false; + rt_running_ = false; + + // SCHED_FIFO for low cyclic jitter; the idle phase just CV-waits, so running + // it at RT priority is harmless. Fall back to SCHED_OTHER without cap_sys_nice. + constexpr int RT_PRIORITY = 80; + std::function body = [this]{ busActor(); }; + bus_thread_.emplace("kickui-bus", body, RT_PRIORITY); + try + { + bus_thread_->start(); + } + catch (std::exception const&) + { + bus_thread_.emplace("kickui-bus", body, 0); + try + { + bus_thread_->start(); + } + catch (std::exception const& e) + { + // No worker means a wedged "connected" session; surface it instead. + bus_thread_.reset(); + connected_ = false; + LockGuard lock(mtx_); + error_ = std::string("could not start the bus thread: ") + e.what(); + } + } + } + + void BusSession::stopBusThread() + { + bus_stop_ = true; + rt_stop_ = true; // leave the cyclic phase if we are in it + { + LockGuard lock(sdo_mtx_); + sdo_cv_.signal(); + } + if (bus_thread_) + { + bus_thread_->join(); + bus_thread_.reset(); + } + rt_running_ = false; + + // Fail any still-queued SDO requests so the UI stops polling them. + LockGuard lock(sdo_mtx_); + for (auto& cmd : sdo_queue_) + { + if (cmd.result) + { + cmd.result->ok = false; + cmd.result->message = "cancelled"; + cmd.result->done = true; + } + } + sdo_queue_.clear(); + } + + namespace + { + // Per-operated-drive runtime state living only on the RT thread. config/ + // is_drive are copied under controls_mtx_ at bring-up, so a UI-side + // configure racing the phase start can never tear them. + struct DriveRuntime + { + std::shared_ptr control; + OperateConfig config; + bool is_drive = true; + std::unique_ptr drive; + MotorCmd cmd; // bus-owned, fed by the command queue + CoE::CiA::DS402::UnitConfig pending_units; // last live unit change + bool units_dirty = false; + double jog_accum = 0.0; + double ramped = 0.0; // current manual-ramp position (SI) + double last_target = 0.0; + nanoseconds gen_start{0}; + int prev_source = -1; + bool was_enabled = false; + bool prev_cmd_enable = false; + bool seeded = false; + int current_state = static_cast(State::PRE_OP); + }; + + // Write a hand-edited PDO mapping to one mapping object (e.g. 0x1600) and + // assign it to its sync manager (0x1C12/0x1C13) -- the standard CoE remap + // sequence, reusing the lib's mapPDO. No-op for an empty entry list. + void writeManualMapping(Bus& bus, Slave& slave, uint16_t pdo_obj, + std::vector const& entries, uint16_t sm_map) + { + if (entries.empty()) { return; } + std::vector words; + words.reserve(entries.size()); + for (auto const& e : entries) + { + words.push_back(CoE::toMappingWord({e.index, e.sub, e.bits})); + } + mapPDO(bus, slave, pdo_obj, words.data(), static_cast(words.size()), sm_map); + } + } + + void BusSession::rtLoop() + { + // Snapshot the operated set (shared_ptrs keep the controls alive) and copy + // each control's config/is_drive while still holding controls_mtx_. + std::vector rts; + { + LockGuard lock(controls_mtx_); + rts.reserve(controls_.size()); + for (auto const& control : controls_) + { + DriveRuntime r; + r.control = control; + r.config = control->config_; + r.is_drive = control->is_drive_; + rts.push_back(std::move(r)); + } + } + if (rts.empty()) + { + rt_running_ = false; + return; + } + + auto set_all_errors = [&](std::string const& msg) + { + for (auto& r : rts) + { + LockGuard lock(r.control->fb_mtx_); + r.control->error_ = msg; + } + }; + + int const cycle_ms = cycle_ms_.load(); // bus cycle: loop period + interpolation + + Bus* bus = bus_.get(); + + // True when at least one operated drive exchanges process data; gates the + // LRD/LWR sends each tick. + auto pd_active_any = [&] + { + for (auto const& r : rts) + { + if ((r.current_state == static_cast(State::SAFE_OP)) + or (r.current_state == static_cast(State::OPERATIONAL))) { return true; } + } + return false; + }; + + try + { + bus->requestState(State::PRE_OP); + bus->waitForState(State::PRE_OP, 2000ms); + + // Configure every operated DS402 drive BEFORE the single createMapping. + // Generic (non-DS402) participants get NO Drive -- they are mapped via + // their SII PDO and just transitioned/exchanged (like the freedom and + // xmc4800 master examples). + for (auto& r : rts) + { + Slave& slave = bus->slaves().at(r.control->slave_index_); + // Seed the bus-owned command from config (matches configureSlave's + // defaults); live UI changes then arrive via the command queue. + r.cmd.mode = static_cast(r.config.mode); + r.cmd.source = static_cast(SetpointSource::Manual); + r.cmd.target_state = static_cast(State::SAFE_OP); + if (r.is_drive) + { + r.drive = std::make_unique(*bus, slave); + r.drive->setUnits(r.config.units); + // Auto: try the spec dummy-entry padding, fall back to a widened + // object for motors that lack dummy support (most real drives). + r.drive->configure(r.config.mode, r.config.rx_pdo_map, r.config.tx_pdo_map, + Drive::PaddingStyle::Auto); + int ip_ms = cycle_ms; + if (ip_ms > 255) { ip_ms = 255; } // 0x60C2/sub1 is uint8_t (ms) + r.drive->setInterpolationTimePeriod(static_cast(ip_ms), -3); + } + else if (r.config.manual_mapping) + { + // Generic slave with a hand-edited mapping: remap via SDO before + // createMapping sizes the process image. + writeManualMapping(*bus, slave, r.config.rx_pdo_map, r.config.manual_rx, 0x1C12); + writeManualMapping(*bus, slave, r.config.tx_pdo_map, r.config.manual_tx, 0x1C13); + } + } + + // Map each slave's mailbox status (can_read/can_write) into the cyclic + // PDO frame so SDO/OD interleave without extra polling. Needs spare FMMUs. + try { bus->configureMailboxStatusCheck(MailboxStatusFMMU::READ_CHECK | MailboxStatusFMMU::WRITE_CHECK); } + catch (std::exception const&) {} + + // One mapping for the whole bus. createMapping throws before writing if + // the buffer is too small (it never overflows), so grow and retry until + // the whole process image fits. + io_map_.resize(8192); + while (true) + { + try + { + bus->createMapping(io_map_.data(), io_map_.size()); + break; + } + catch (std::exception const&) + { + if (io_map_.size() >= 1u << 20) { throw; } // 1 MiB: far beyond any real bus + io_map_.resize(io_map_.size() * 2); + } + } + for (auto& r : rts) + { + if (r.drive) { r.drive->attach(); } + // Pre-size the display mirrors now (process image is mapped, bsizes + // known) so the cyclic copy never allocates under fb_mtx_. + Slave& slave = bus->slaves().at(r.control->slave_index_); + LockGuard lock(r.control->fb_mtx_); + if (slave.input.bsize > 0) { r.control->in_raw_.reserve(slave.input.bsize); } + if (slave.output.bsize > 0) { r.control->out_raw_.reserve(slave.output.bsize); } + } + + // Arm the AL-event IRQ: the callback fires inline on this RT thread + // during frame processing and only sets a flag, which we drain each + // tick to refresh per-slave AL state. Best-effort -- a sim/ESC that + // does not raise AL events just leaves us on the idle-loop poll. + al_status_dirty_ = false; + try + { + bus->enableIRQ(EcatEvent::AL_STATUS, [this]{ al_status_dirty_ = true; }); + } + catch (std::exception const&) {} + + link_->setTimeout(500us); + + for (auto& r : rts) + { + r.current_state = static_cast(State::PRE_OP); + setSlaveState(r.control->slave_index_, static_cast(State::PRE_OP)); + } + + Timer timer{milliseconds(cycle_ms)}; + timer.start(); + double const dt = cycle_ms * 1.0e-3; + + int consecutive_pd_errors = 0; + constexpr int PD_ERROR_LIMIT = 1000; // ~1 s of dead bus at 1 ms + + MailboxSequencer sequencer(*bus); + bool const fmmu_opt = (bus->mailboxStatusFMMUMode() != MailboxStatusFMMU::NONE); + + // Tolerate working-counter mismatches without aborting, exactly like + // the marvin example's false_alarm. A short WKC is expected here (the + // mapping covers the whole bus but only the operated drives are at + // SAFE-OP/OP) and WKC is not the disconnect signal anyway -- the + // per-slave EtherCAT watchdog de-energizes a drive on bus loss. + auto bus_error = [](DatagramState const&){}; + + // Set while servicing a mailbox transfer for a slave that is NOT in the + // operated mapping (or a whole-bus topology read): the FMMU mailbox-status + // path only pumps mapped slaves, so force the generic sequencer step so the + // unmapped slave's mailbox actually advances. Reset right after the command. + bool service_unmapped = false; + + // One full cyclic tick: compute + apply each drive's setpoint, exchange + // all PDOs together, advance one mailbox step, publish per-drive feedback. + auto rt_cyclic = [&] + { + timer.wait_next_tick(); + + for (auto& r : rts) + { + if (not r.drive) { continue; } // generic slave: mapped + transitioned, no Drive control + Drive& drive = *r.drive; + + if (r.units_dirty) + { + try { drive.setUnits(r.pending_units); } + catch (std::exception const&) {} + r.units_dirty = false; + } + + // The bus-owned command set for this tick (fed by the queue). + MotorControl::Command cmd = r.cmd; + + control::ControlMode m = static_cast(cmd.mode); + drive.setModeOfOperationRaw(static_cast(m)); + bool position_mode = (m == control::POSITION_CYCLIC); + bool velocity_mode = (m == control::VELOCITY_CYCLIC); + bool torque_mode = (m == control::TORQUE_CYCLIC); + + // Seed the command to the actual position the instant enable is + // commanded (marvin sets target_position = actual_position right + // before enable()): a CSP drive faults if commanded a step. + bool enable_now = cmd.enable; + if (enable_now and not r.prev_cmd_enable) + { + double seed = 0.0; + if (position_mode) { seed = drive.actualPosition(); } + cmd.manual_target = seed; // this tick + r.cmd.manual_target = seed; // persist for the next ticks + r.jog_accum = drive.actualPosition(); + r.ramped = seed; + r.gen_start = since_epoch(); + } + + int source = cmd.source; + double si_target = 0.0; + if (source == static_cast(SetpointSource::Manual)) + { + // Slew toward the manual target at the ramp rate so a new + // setpoint never steps (which would fault a CSP drive). + double tgt = cmd.manual_target; + double rate = cmd.manual_ramp; + if (rate > 0.0) + { + double step = rate * dt; + double delta = tgt - r.ramped; + if (delta > step) { delta = step; } + if (delta < -step) { delta = -step; } + r.ramped += delta; + } + else + { + r.ramped = tgt; + } + si_target = r.ramped; + } + else if (source == static_cast(SetpointSource::Jog)) + { + if (r.prev_source != source) { r.jog_accum = drive.actualPosition(); } + if (position_mode) { r.jog_accum += cmd.jog_rate * dt; si_target = r.jog_accum; } + else { si_target = cmd.jog_rate; } + } + else // Generator + { + // Reset the phase origin (and re-center on the actual + // position) when the generator is first selected, so the + // waveform starts at zero -- mirrors marvin resetting + // start_time/initial_position when the sine begins. + if (r.prev_source != source) + { + r.gen_start = since_epoch(); + if (position_mode) + { + double a = drive.actualPosition(); + cmd.manual_target = a; + r.cmd.manual_target = a; + } + } + double t = seconds_f(elapsed_time(r.gen_start)).count(); + double amp = cmd.gen_amp; + double phase = tau * cmd.gen_freq * t; + double wave = 0.0; + int wf = cmd.gen_wave; + if (wf == static_cast(Waveform::Sine)) { wave = amp * std::sin(phase); } + else if (wf == static_cast(Waveform::Step)) + { + if (std::sin(phase) >= 0.0) { wave = amp; } + else { wave = -amp; } + } + else { wave = amp * (2.0 / pi) * std::asin(std::sin(phase)); } + double center = cmd.gen_offset; + if (position_mode) { center = cmd.manual_target; } + si_target = center + wave; + } + r.prev_source = source; + + if (position_mode) + { + drive.setTargetPosition(si_target); + drive.setTargetVelocityRaw(0); + drive.setTargetTorqueRaw(0); + } + else if (velocity_mode) + { + drive.setTargetVelocity(si_target); + drive.setTargetPositionRaw(drive.actualPositionRaw()); + drive.setTargetTorqueRaw(0); + } + else if (torque_mode) + { + drive.setTargetTorque(si_target); + drive.setTargetPositionRaw(drive.actualPositionRaw()); + drive.setTargetVelocityRaw(0); + } + + if (enable_now) { drive.enable(); } + else { drive.disable(); } + r.prev_cmd_enable = enable_now; + drive.update(); + if (drive.isFaulted() and r.was_enabled) { r.cmd.enable = false; } + r.was_enabled = drive.isEnabled(); + r.last_target = si_target; + } + + // Exchange every drive's process data in the same frame batch. + bool any_pd = pd_active_any(); + std::string cyclic_error; + try + { + if (any_pd) + { + bus->sendLogicalRead(bus_error); + bus->sendLogicalWrite(bus_error); + } + if (service_unmapped) + { + // PDO only in this frame; the unmapped target's mailbox has no + // mailbox-status FMMU in the process image, so service ALL + // mailboxes below with the explicit-check path (same as idle). + } + else if (fmmu_opt and any_pd) + { + bus->sendReadMessages(bus_error); + bus->sendWriteMessages(bus_error); + } + else + { + sequencer.step(bus_error); // generic: pumps EVERY slave's mailbox + } + bus->finalizeDatagrams(); + bus->processAwaitingFrames(); + // Explicit FPRD SM-status check + read/write for every slave (covers + // slaves with no mailbox-status FMMU). One extra frame round-trip, + // only while a mailbox transfer to an unmapped slave is in flight. + if (service_unmapped) + { + bus->checkMailboxes(bus_error); + bus->processMessages(bus_error); + } + consecutive_pd_errors = 0; + } + catch (std::exception const& e) + { + ++consecutive_pd_errors; + cyclic_error = e.what(); + } + + for (auto& r : rts) + { + Slave& slave = bus->slaves().at(r.control->slave_index_); + // Non-blocking: this is the 1 kHz RT thread; if the GUI is mid-read + // we skip this slave's display mirror this tick (<=1 ms stale, never + // affects the wire) rather than stall on the lock. + TryLockGuard lock(r.control->fb_mtx_); + if (not lock.owns()) + { + continue; + } + r.control->error_ = cyclic_error; + // Raw process-image snapshot (works for generic + DS402 slaves). + if (slave.input.data and slave.input.bsize > 0) + { + r.control->in_raw_.assign(slave.input.data, slave.input.data + slave.input.bsize); + } + if (slave.output.data and slave.output.bsize > 0) + { + r.control->out_raw_.assign(slave.output.data, slave.output.data + slave.output.bsize); + } + if (not r.drive) // generic slave: no DS402 feedback + { + continue; + } + Drive& drive = *r.drive; + r.control->fb_.status_word = drive.statusWord(); + r.control->fb_.control_word = drive.controlWord(); + r.control->fb_.error_code = drive.errorCode(); + r.control->fb_.mode_display = drive.modeOfOperationDisplay(); + r.control->fb_.actual_pos_raw = drive.actualPositionRaw(); + r.control->fb_.actual_vel_raw = drive.actualVelocityRaw(); + r.control->fb_.actual_torque_raw = drive.actualTorqueRaw(); + r.control->fb_.actual_pos = drive.actualPosition(); + r.control->fb_.actual_vel = drive.actualVelocity(); + r.control->fb_.actual_torque = drive.actualTorque(); + r.control->fb_.target = r.last_target; + r.control->fb_.enabled = drive.isEnabled(); + r.control->fb_.faulted = drive.isFaulted(); + r.control->fb_.operational = (r.current_state == static_cast(State::OPERATIONAL)); + } + }; + + // Apply one queued motor/units command to its DriveRuntime (latest-wins). + auto apply_motor = [&](SdoCommand const& c) + { + for (auto& r : rts) + { + if (r.control->slave_index_ != c.slave_index) { continue; } + if (c.kind == SdoCommand::Kind::Motor) + { + r.cmd = c.motor; + } + else // MotorUnits + { + r.pending_units = c.motor_units; + r.units_dirty = true; + } + break; + } + }; + + // Seed pass: drain any motor/units commands already queued (e.g. from + // configure or pre-operate edits) into the runtimes; keep blocking ones. + { + LockGuard lock(sdo_mtx_); + std::deque keep; + for (auto& c : sdo_queue_) + { + if ((c.kind == SdoCommand::Kind::Motor) or (c.kind == SdoCommand::Kind::MotorUnits)) + { + apply_motor(c); + } + else + { + keep.push_back(std::move(c)); + } + } + sdo_queue_.swap(keep); + } + + // Warm up the FMMU mailbox-status bits before the loop services any + // mailbox transfer: the can_read/can_write flags are read from the LRD + // image, so without a first read they are stale on the opening tick and + // the first SDO/OD message would not be sent (then a retry succeeds). + if (fmmu_opt) + { + bus->sendLogicalRead(bus_error); + bus->finalizeDatagrams(); + bus->processAwaitingFrames(); + } + + while (not rt_stop_) + { + rt_cyclic(); + + // Bus lost: de-energize every drive and bail out so the UI reflects + // the dead bus instead of spinning forever on commanded drives. + if (consecutive_pd_errors >= PD_ERROR_LIMIT) + { + for (auto& r : rts) { r.cmd.enable = false; } + set_all_errors("Bus communication lost (" + std::to_string(consecutive_pd_errors) + + " consecutive cycle errors) -- drives disabled."); + break; + } + + // Per-drive EtherCAT state changes requested from the UI. + for (auto& r : rts) + { + int target_state = r.cmd.target_state; + if (target_state == r.current_state) { continue; } + + Slave& slave = bus->slaves().at(r.control->slave_index_); + std::string transition_error; + try + { + bus->requestState(slave, static_cast(target_state)); + bus->waitForState(slave, static_cast(target_state), 1000ms, rt_cyclic); + r.current_state = target_state; + setSlaveState(r.control->slave_index_, static_cast(target_state)); + + bool pd_now = (target_state == static_cast(State::SAFE_OP)) + or (target_state == static_cast(State::OPERATIONAL)); + if (not r.seeded and pd_now and r.drive) + { + double seed = 0.0; + if (static_cast(r.cmd.mode) == control::POSITION_CYCLIC) + { + seed = r.drive->actualPosition(); + } + r.cmd.manual_target = seed; + r.jog_accum = r.drive->actualPosition(); + r.ramped = seed; + r.seeded = true; + } + } + catch (std::exception const& e) + { + transition_error = "State change failed: " + describeError(e); + r.cmd.target_state = r.current_state; // abandon the request, hold + } + Event ev; + ev.kind = Event::Kind::StateActionResult; + ev.slave = r.control->slave_index_; + ev.message = transition_error; + pushEvent(std::move(ev)); + } + + // An AL-event IRQ means a slave changed state on its own (e.g. a + // drive dropped to SAFE-OP+error): re-read every slave's AL status. + if (al_status_dirty_.exchange(false)) + { + refreshSlaveStates(); + } + // NOTE: no diagnostics sweep (error counters / topology re-read) in the + // cyclic loop -- those do synchronous register reads that stall the PDO + // and risk the SM watchdog (OP->SAFE-OP flap). Diagnostics refresh only + // in the idle phase, which is the connect-and-scan path the UI uses. + + // Leaving the cyclic phase (backToPreOp, or an INIT request that + // just stopped it): leave the queue untouched. A State command + // consumed here would die with the loop (translated into a runtime + // that never ticks again); the idle phase services it instead. + if (rt_stop_) + { + continue; + } + + // Apply all queued motor/units updates and state requests for operated + // slaves (non-blocking, latest-wins), then service at most one blocking + // mailbox request (SDO/OD/state/topology) this tick so a slow transfer + // never stalls live motor control. + auto runtime_for = [&](int slave_index) -> DriveRuntime* + { + for (auto& r : rts) + { + if (r.control->slave_index_ == slave_index) { return &r; } + } + return nullptr; + }; + SdoCommand cmd; + bool have_cmd = false; + { + LockGuard lock(sdo_mtx_); + while (not sdo_queue_.empty()) + { + SdoCommand& front = sdo_queue_.front(); + SdoCommand::Kind k = front.kind; + if ((k == SdoCommand::Kind::Motor) or (k == SdoCommand::Kind::MotorUnits)) + { + apply_motor(front); + sdo_queue_.pop_front(); + continue; + } + // A state change for an operated slave is a per-drive transition + // serviced by this loop (no mailbox work, never rebuilds). + if (k == SdoCommand::Kind::State) + { + DriveRuntime* r = runtime_for(front.slave_index); + if (r != nullptr) + { + r->cmd.target_state = front.state; + sdo_queue_.pop_front(); + continue; + } + } + cmd = std::move(front); + sdo_queue_.pop_front(); + have_cmd = true; + break; + } + } + if (have_cmd) + { + // A slave not in the operated mapping has no mailbox-status FMMU, so + // its mailbox only advances under the generic step. Topology reads the + // whole bus, so treat it the same. + service_unmapped = (cmd.kind == SdoCommand::Kind::Topology) + or (runtime_for(cmd.slave_index) == nullptr); + dispatchCommand(cmd, rt_cyclic); + service_unmapped = false; + } + } + + try { bus->disableIRQ(EcatEvent::AL_STATUS); } catch (std::exception const&) {} + + // Graceful stop: de-energize all and cycle until the state machines settle. + for (auto& r : rts) { if (r.drive) { r.drive->disable(); } } + for (int i = 0; i < 300; ++i) + { + bool busy = false; + for (auto& r : rts) { if (r.drive and (r.drive->isEnabled() or r.drive->isFaulted())) { busy = true; } } + if (not busy) { break; } + timer.wait_next_tick(); + try + { + bus->sendLogicalRead(bus_error); + bus->sendLogicalWrite(bus_error); + bus->finalizeDatagrams(); + bus->processAwaitingFrames(); + } + catch (std::exception const&) {} + for (auto& r : rts) { if (r.drive) { r.drive->update(); } } + } + + try + { + link_->setTimeout(2ms); + bus->requestState(State::PRE_OP); + bus->waitForState(State::PRE_OP, 2000ms); + } + catch (std::exception const&) {} + } + catch (std::exception const& e) + { + set_all_errors("Operation failed: " + describeError(e)); + } + + for (auto& r : rts) + { + setSlaveState(r.control->slave_index_, static_cast(State::PRE_OP)); + } + rt_running_ = false; + } + + // ---------------- SDO / Object Dictionary service ---------------- + + void BusSession::enqueue(SdoCommand cmd) + { + LockGuard lock(sdo_mtx_); + sdo_queue_.push_back(std::move(cmd)); + sdo_cv_.signal(); + } + + // --- MotorControl -> bus actor (no shared command mutex; latest-wins) --- + void MotorControl::submit() + { + if (session_ == nullptr) { return; } + BusSession::SdoCommand c; + c.kind = BusSession::SdoCommand::Kind::Motor; + c.slave_index = slave_index_; + c.motor = cmd_; + session_->enqueue(std::move(c)); + } + + void MotorControl::setUnits(CoE::CiA::DS402::UnitConfig const& u) + { + if (session_ == nullptr) { return; } + BusSession::SdoCommand c; + c.kind = BusSession::SdoCommand::Kind::MotorUnits; + c.slave_index = slave_index_; + c.motor_units = u; + session_->enqueue(std::move(c)); + } + + void BusSession::serviceLoop() + { + // Idle mailbox step: no cyclic PDO exchange, just advance the mailbox + // queue and poll states. + auto idle_cyclic = [this] + { + auto noerr = [](DatagramState const&){}; + bus_->checkMailboxes(noerr); + bus_->processMessages(noerr); + sleep(200us); // pace the poll, don't busy-spin + }; + + while (true) + { + SdoCommand cmd; + bool have_cmd = false; + { + LockGuard lock(sdo_mtx_); + sdo_cv_.wait_until(sdo_mtx_, 200ms, + [this]{ return bus_stop_ or operate_requested_ or not sdo_queue_.empty(); }); + if (bus_stop_ or operate_requested_) + { + return; // leave the idle phase (shutdown, or enter cyclic) + } + if (not sdo_queue_.empty()) + { + cmd = std::move(sdo_queue_.front()); + sdo_queue_.pop_front(); + have_cmd = true; + } + } + + if (not have_cmd) + { + refreshSlaveStates(); // periodic AL-status poll while idle + refreshDiagnostics(); // + per-port error counters / AL codes + } + else if ((cmd.kind == SdoCommand::Kind::Motor) or (cmd.kind == SdoCommand::Kind::MotorUnits)) + { + // No drive runs while idle; motor commands are seeded from config at + // operate, so any that arrive here are simply dropped. + } + else + { + dispatchCommand(cmd, idle_cyclic); + } + } + } + + void BusSession::dispatchCommand(SdoCommand& cmd, std::function const& cyclic) + { + switch (cmd.kind) + { + case SdoCommand::Kind::Discover: { executeDiscover(cmd.slave_index, cyclic, cmd.od_resume); break; } + case SdoCommand::Kind::State: { executeState(cmd.slave_index, cmd.state, cyclic); break; } + case SdoCommand::Kind::ReadMapping: { executeReadMapping(cmd.slave_index, cyclic); break; } + case SdoCommand::Kind::Topology: { executeTopology(cyclic); break; } + case SdoCommand::Kind::ClearErrors: { diag_clear_pending_ = true; refreshDiagnostics(); break; } // apply now, any phase + case SdoCommand::Kind::Read: + case SdoCommand::Kind::Write: { executeSdo(cmd, cyclic); break; } + case SdoCommand::Kind::Motor: + case SdoCommand::Kind::MotorUnits: { break; } // phase-local; handled before dispatch + } + } + + bool BusSession::aborting() const + { + // Abort an in-flight mailbox op on any phase change: the whole thread is + // stopping (bus_stop_), we are leaving the cyclic phase (rt_stop_), or an + // idle scan must yield to an operate request. An OD scan that hits this + // re-enqueues itself and resumes in the new phase (executeDiscover). + if (bus_stop_) + { + return true; + } + if (rt_running_) + { + return rt_stop_; + } + return operate_requested_; // idle phase: yield to applyMapping + } + + bool BusSession::driveMessage(std::shared_ptr const& msg, + std::function const& cyclic) + { + while ((msg->status() == mailbox::request::MessageStatus::RUNNING) and (not aborting())) + { + cyclic(); + } + return msg->status() == mailbox::request::MessageStatus::SUCCESS; + } + + void BusSession::executeState(int slave_index, uint8_t state, std::function const& cyclic) + { + Bus* bus = bus_.get(); + std::string error; + try + { + if (bus == nullptr) + { + THROW_ERROR("no bus"); + } + Slave& slave = bus->slaves().at(slave_index); + bus->requestState(slave, static_cast(state)); + bus->waitForState(slave, static_cast(state), 1500ms, cyclic); // keeps PDO alive + } + catch (std::exception const& e) + { + error = std::string("State change to ") + toString(static_cast(state)) + + " failed: " + describeError(e); + } + Event ev; + ev.kind = Event::Kind::StateActionResult; + ev.slave = slave_index; + ev.message = error; + pushEvent(std::move(ev)); + refreshSlaveStates(); + } + + void BusSession::executeReadMapping(int slave_index, std::function const& cyclic) + { + Bus* bus = bus_.get(); + PdoMapping mapping; + mapping.slave = slave_index; + + auto readU = [&](Slave& s, uint16_t index, uint8_t sub, int nbytes) -> uint32_t + { + uint8_t buf[4] = {0, 0, 0, 0}; + uint32_t size = static_cast(nbytes); + auto msg = s.mailbox.createSDO(index, sub, false, CoE::SDO::request::UPLOAD, buf, &size, 1s); + if (not driveMessage(msg, cyclic)) + { + THROW_ERROR("mailbox read failed"); + } + uint32_t value = 0; + for (uint32_t i = 0; (i < size) and (i < 4); ++i) + { + value |= static_cast(buf[i]) << (8 * i); + } + return value; + }; + + try + { + if (bus == nullptr) + { + THROW_ERROR("no bus"); + } + Slave& slave = bus->slaves().at(slave_index); + + auto readAssign = [&](uint16_t sm_assign, std::vector& out) + { + uint32_t pdo_count = readU(slave, sm_assign, 0, 1); + for (uint32_t i = 1; i <= pdo_count; ++i) + { + uint16_t pdo = static_cast(readU(slave, sm_assign, static_cast(i), 2)); + if (pdo == 0) + { + continue; + } + uint32_t entry_count = readU(slave, pdo, 0, 1); + for (uint32_t j = 1; j <= entry_count; ++j) + { + uint32_t word = readU(slave, pdo, static_cast(j), 4); + CoE::PdoMappingEntry me = CoE::fromMappingWord(word); + PdoEntry pe; + pe.pdo = pdo; + pe.index = me.index; + pe.sub = me.subindex; + pe.bits = me.bitlen; + out.push_back(pe); + } + } + }; + + readAssign(0x1C12, mapping.rx); + readAssign(0x1C13, mapping.tx); + mapping.valid = true; + } + catch (std::exception const& e) + { + mapping.error = e.what(); + } + + Event ev; + ev.kind = Event::Kind::MappingResult; + ev.slave = slave_index; + ev.mapping = std::move(mapping); + pushEvent(std::move(ev)); + } + + void BusSession::executeTopology(std::function const& /*cyclic*/) + { + if (bus_ == nullptr) + { + return; + } + TopologyInfo info = computeTopology(*bus_, true); // re-read DL status from the wire + LockGuard lock(topo_mtx_); + topology_ = std::move(info); + } + + void BusSession::refreshSlaveStates() + { + Bus* bus = bus_.get(); + if (bus == nullptr) + { + return; + } + // One batched round-trip for every slave: per-slave getCurrentState would + // be N sequential reads, each racing the link timeout (500us during the + // cyclic phase, where this runs on the AL-event IRQ) and stalling the PDO. + auto& slaves = bus->slaves(); + auto noerr = [](DatagramState const&){}; + try + { + for (auto& slave : slaves) + { + bus->sendGetALStatus(slave, noerr); + } + bus->processAwaitingFrames(); + } + catch (std::exception const&) {} + + std::vector states; + { + LockGuard lock(state_mtx_); + states = slave_states_; + } + states.resize(slaves.size(), 0); + for (size_t i = 0; i < slaves.size(); ++i) + { + uint8_t s = static_cast(slaves[i].al_status); + // A late answer leaves State::INVALID (the lib clobbers the cache + // before sending): no new information, keep the last-known state + // rather than flashing "?" -- the ESC answered, the deadline lost. + if (s != static_cast(State::INVALID)) + { + // Device emulation mirrors AL control: the error bit is the + // master's own ack request echoed back, not an error. + if ((i < slave_emulated_.size()) and (slave_emulated_[i] != 0)) + { + s = static_cast(s & ~AL_STATUS_ERR_IND); + } + states[i] = s; + } + } + LockGuard lock(state_mtx_); + slave_states_ = std::move(states); + } + + void BusSession::refreshDiagnostics() + { + Bus* bus = bus_.get(); + if (bus == nullptr) + { + return; + } + auto noerr = [](DatagramState const&){}; + try + { + bus->sendRefreshErrorCounters(noerr); // FPRD per slave -> slave.error_counters + bus->processAwaitingFrames(); + } + catch (std::exception const&) + { + return; // a dead/garbled bus: keep the last good diagnostics + } + + bool const manual = diag_clear_pending_; + diag_clear_pending_ = false; + bool need_clear = manual; + constexpr uint8_t HIGH_WATER = 0xC0; // clear before the uint8 saturates at 0xFF + + auto& slaves = bus->slaves(); + { + LockGuard lock(state_mtx_); + slave_diag_.resize(slaves.size()); + for (size_t i = 0; i < slaves.size(); ++i) + { + SlaveDiag& d = slave_diag_[i]; + ErrorCounters const& cur = slaves[i].errorCounters(); + d.stats.al_status_code = slaves[i].al_status_code; // valid when the AL error bit is set + if (manual) + { + for (int p = 0; p < 4; ++p) { d.stats.lost_total[p] = 0; d.stats.rxerr_total[p] = 0; } + d.stats.saturated = false; + } + for (int p = 0; p < 4; ++p) + { + // Accumulate the monotone delta; a drop means it was cleared. + uint8_t ll = cur.lost_link[p]; + uint8_t dll = ll; + if (ll >= d.last_lost[p]) { dll = ll - d.last_lost[p]; } + d.stats.lost_total[p] += dll; + d.last_lost[p] = ll; + + uint8_t rx = cur.rx[p].invalid_frame; + uint8_t drx = rx; + if (rx >= d.last_rxerr[p]) { drx = rx - d.last_rxerr[p]; } + d.stats.rxerr_total[p] += drx; + d.last_rxerr[p] = rx; + + if ((ll >= HIGH_WATER) or (rx >= HIGH_WATER)) { need_clear = true; } + if ((ll == 0xFF) or (rx == 0xFF)) { d.stats.saturated = true; } + } + } + } + + if (need_clear) + { + try { bus->clearErrorCounters(); } // broadcast write; throws on WKC short -- tolerated + catch (std::exception const&) {} + LockGuard lock(state_mtx_); + for (auto& d : slave_diag_) + { + for (int p = 0; p < 4; ++p) { d.last_lost[p] = 0; d.last_rxerr[p] = 0; } + } + } + } + + void BusSession::setSlaveState(int index, uint8_t state) + { + LockGuard lock(state_mtx_); + if ((index >= 0) and (index < static_cast(slave_states_.size()))) + { + slave_states_[index] = state; + } + } + + uint8_t BusSession::slaveAlStatus(int index) const + { + auto s = snapshot(); + if (s and (index >= 0) and (index < static_cast(s->slaves.size()))) + { + return s->slaves[index].al_status; + } + return 0; + } + + std::shared_ptr BusSession::readSDO(int slave_index, uint16_t index, uint8_t subindex, int access) + { + auto result = std::make_shared(); + if (not sdoAvailable()) + { + result->message = "SDO unavailable (not connected)."; + result->done = true; + return result; + } + SdoCommand cmd; + cmd.kind = SdoCommand::Kind::Read; + cmd.slave_index = slave_index; + cmd.index = index; + cmd.subindex = subindex; + cmd.access = access; + cmd.result = result; + enqueue(std::move(cmd)); + return result; + } + + std::shared_ptr BusSession::writeSDO(int slave_index, uint16_t index, uint8_t subindex, + int access, std::vector data) + { + auto result = std::make_shared(); + if (not sdoAvailable()) + { + result->message = "SDO unavailable (not connected)."; + result->done = true; + return result; + } + SdoCommand cmd; + cmd.kind = SdoCommand::Kind::Write; + cmd.slave_index = slave_index; + cmd.index = index; + cmd.subindex = subindex; + cmd.access = access; + cmd.payload = std::move(data); + cmd.result = result; + enqueue(std::move(cmd)); + return result; + } + + void BusSession::requestSlaveState(int slave_index, uint8_t state) + { + if (not sdoAvailable()) + { + return; + } + State const target = static_cast(state); + auto c = findControl(slave_index); + + // Safety: never tear the PDO down underneath a moving motor. An enabled + // operated drive leaving OP is de-energized first. + if (c and rt_running_ and (target != State::OPERATIONAL)) + { + bool enabled = false; + { + LockGuard lock(c->fb_mtx_); + enabled = c->fb_.enabled; + } + if (enabled) + { + c->enable(false); + } + } + // INIT tears down the mailbox/FMMU config; the cyclic loop can't take an + // OPERATED slave there (its mailbox step fails mid-transition and the + // request is abandoned). Stop the loop first -- same as backToPreOp -- + // then the request runs on the idle bus owner. A slave outside the + // operated set transitions live without disturbing the loop. + if ((target == State::INIT) and rt_running_ and c) + { + backToPreOp(); + } + // Keep the UI mirror coherent: a later motor verb must not carry a stale + // target_state and silently revert this request. + if (c) + { + c->cmd_.target_state = static_cast(target); + } + // The cyclic loop translates this into the per-drive runtime when it + // operates the slave; otherwise it runs as a mailbox transition. + SdoCommand cmd; + cmd.kind = SdoCommand::Kind::State; + cmd.slave_index = slave_index; + cmd.state = state; + enqueue(std::move(cmd)); + } + + std::string BusSession::stateActionError(int slave_index) const + { + auto it = state_errors_.find(slave_index); + if (it == state_errors_.end()) + { + return {}; + } + return it->second; + } + + void BusSession::readPdoMapping(int slave_index) + { + if (not sdoAvailable()) + { + return; + } + auto& s = pdo_scans_[slave_index]; // UI thread + if (s.running) { return; } + s.running = true; + SdoCommand cmd; + cmd.kind = SdoCommand::Kind::ReadMapping; + cmd.slave_index = slave_index; + enqueue(std::move(cmd)); + } + + PdoScan BusSession::pdoScan(int slave_index) const + { + auto it = pdo_scans_.find(slave_index); + if (it == pdo_scans_.end()) { return PdoScan{}; } + return it->second; + } + + OdScan BusSession::odScan(int slave_index) const + { + auto it = od_scans_.find(slave_index); + if (it == od_scans_.end()) { return OdScan{}; } + OdScan scan = it->second; + scan.scanned = true; + return scan; + } + + void BusSession::discoverOD(int slave_index) + { + if (not sdoAvailable()) + { + return; + } + auto& s = od_scans_[slave_index]; // UI thread + if (s.running) { return; } + s.running = true; + s.count = 0; + s.total = 0; + s.objects.clear(); + s.error.clear(); + SdoCommand cmd; + cmd.kind = SdoCommand::Kind::Discover; + cmd.slave_index = slave_index; + enqueue(std::move(cmd)); + } + + void BusSession::executeSdo(SdoCommand& cmd, std::function const& cyclic) + { + Bus* bus = bus_.get(); + if ((bus == nullptr) or (cmd.result == nullptr)) + { + if (cmd.result != nullptr) // never leave a caller polling forever + { + cmd.result->ok = false; + cmd.result->message = "bus not available"; + cmd.result->done = true; + } + return; + } + bool complete = (cmd.access == static_cast(Bus::Access::COMPLETE)); + try + { + Slave& slave = bus->slaves().at(cmd.slave_index); + if (cmd.kind == SdoCommand::Kind::Read) + { + std::vector buf(1024); + uint32_t const capacity = static_cast(buf.size()); + uint32_t size = capacity; + auto msg = slave.mailbox.createSDO(cmd.index, cmd.subindex, complete, + CoE::SDO::request::UPLOAD, buf.data(), &size, 1s); + if (driveMessage(msg, cyclic)) + { + buf.resize(size); + cmd.result->data = std::move(buf); + cmd.result->ok = true; + cmd.result->message = "read OK (" + std::to_string(size) + " bytes)"; + if (size >= capacity) + { + cmd.result->message += " -- may be truncated to " + std::to_string(capacity) + " bytes"; + } + } + else + { + cmd.result->ok = false; + cmd.result->message = CoE::SDO::abort_to_str(msg->status()); + } + } + else + { + uint32_t size = static_cast(cmd.payload.size()); + auto msg = slave.mailbox.createSDO(cmd.index, cmd.subindex, complete, + CoE::SDO::request::DOWNLOAD, cmd.payload.data(), &size, 1s); + if (driveMessage(msg, cyclic)) + { + cmd.result->ok = true; + cmd.result->message = "write OK (" + std::to_string(cmd.payload.size()) + " bytes)"; + } + else + { + cmd.result->ok = false; + cmd.result->message = CoE::SDO::abort_to_str(msg->status()); + } + } + } + catch (std::exception const& e) + { + cmd.result->ok = false; + cmd.result->message = e.what(); + } + cmd.result->done = true; + } + + void BusSession::executeDiscover(int slave_index, std::function const& cyclic, int resume_from) + { + namespace info = CoE::SDO::information; + Bus* bus = bus_.get(); + std::string error; + + // Continue this scan in the next bus phase from object `from` (the OD list is + // deterministic, so the resumed call just re-fetches it). Used when a phase + // change -- backToPreOp / applyMapping -- interrupts a scan that is NOT done. + auto requeue = [&](int from) + { + SdoCommand r; + r.kind = SdoCommand::Kind::Discover; + r.slave_index = slave_index; + r.od_resume = from; + enqueue(std::move(r)); + }; + + try + { + if (bus == nullptr) + { + THROW_ERROR("no bus"); + } + Slave& slave = bus->slaves().at(slave_index); + + // The OD index list is deterministic, so a resumed scan just re-fetches it + // (one cheap transaction) and skips to where it left off -- no need to carry + // the list across the phase change. + uint16_t list[2048]; + uint32_t list_size = sizeof(list); + auto od_list = slave.mailbox.createSDOInfoGetODList(info::ListType::ALL, list, &list_size, 500ms); + if (not driveMessage(od_list, cyclic)) + { + if (aborting() and (not bus_stop_)) + { + requeue(resume_from); // interrupted before the list -> continue next phase + return; + } + THROW_ERROR("GetODList failed"); + } + + int n = static_cast(list_size / 2) - 1; // list[0] is the list type echo + if (n < 0) + { + n = 0; + } + if (resume_from == 0) // a fresh scan announces the total; a resume keeps it + { + Event ev; + ev.kind = Event::Kind::OdScanProgress; + ev.slave = slave_index; + ev.total = n; + pushEvent(std::move(ev)); + } + + // Bail if reads keep timing out: a desynced mailbox would otherwise hang + // the scan (and block mapping). Reset on every successful read. + int consec_fail = 0; + constexpr int FAIL_LIMIT = 10; + + // On resume the list must still hold objects past resume_from. If it + // shrank to at or below it (slave reloaded, or a transient SDO-Info error + // returned a short list), the loop below skips silently -- without this + // we'd report a complete scan that is actually truncated. + if ((resume_from > 0) and (resume_from >= n)) + { + error = "discovery incomplete: object list shrank on resume (slave changed)"; + } + + int i = resume_from; + for (; (i < n) and (consec_fail < FAIL_LIMIT); ++i) + { + if (aborting()) { break; } // don't start object i (resume from it later) + OdObject obj; + obj.index = list[i + 1]; + + char buf[1024]; + uint32_t bsize = sizeof(buf); + auto od = slave.mailbox.createSDOInfoGetOD(obj.index, buf, &bsize, 500ms); + if (driveMessage(od, cyclic) and (bsize >= sizeof(info::ObjectDescription))) + { + auto const* d = reinterpret_cast(buf); + obj.data_type = static_cast(d->data_type); + obj.max_subindex = d->max_subindex; + obj.object_code = static_cast(d->object_code); + obj.name.assign(buf + sizeof(info::ObjectDescription), + bsize - sizeof(info::ObjectDescription)); + } + + // Describe one subindex (GetED) and, when readable, read its value. + // ed_ok reports whether the entry exists; fallback reads the value + // when GetED is unavailable (sub0 only). Reading a non-existent or + // write-only entry aborts, so we never do it. + auto readEntry = [&](uint8_t sub, bool& ed_ok, bool fallback) -> OdEntry + { + OdEntry e; + e.subindex = sub; + char ed[512]; + uint32_t esize = sizeof(ed); + auto edm = slave.mailbox.createSDOInfoGetED(obj.index, sub, 0, ed, &esize, 200ms); + ed_ok = driveMessage(edm, cyclic) and (esize >= sizeof(info::EntryDescription)); + uint32_t bytes = 256; // fallback size when the entry size is unknown + if (ed_ok) + { + auto const* d = reinterpret_cast(ed); + e.access = d->access; + e.data_type = static_cast(d->data_type); + e.name.assign(ed + sizeof(info::EntryDescription), esize - sizeof(info::EntryDescription)); + // Size the read buffer to the entry: a too-small buffer aborts + // with CLIENT_BUFFER_TOO_SMALL and desyncs the mailbox. + bytes = (d->bit_length + 7u) / 8u; + if (bytes == 0) { bytes = 1; } + } + bool readable = (e.access & CoE::Access::READ) != 0; + if ((ed_ok and readable) or (not ed_ok and fallback)) + { + std::vector v(bytes); + uint32_t vsize = static_cast(v.size()); + auto vm = slave.mailbox.createSDO(obj.index, sub, false, CoE::SDO::request::UPLOAD, + v.data(), &vsize, 200ms); + if (driveMessage(vm, cyclic)) + { + v.resize(vsize); + e.value = std::move(v); + consec_fail = 0; + } + else + { + e.value_error = CoE::SDO::abort_to_str(vm->status()); + ++consec_fail; + } + } + return e; + }; + + bool ed0 = false; + OdEntry sub0 = readEntry(0, ed0, true); + obj.access = sub0.access; + obj.value_error = sub0.value_error; + int count = obj.max_subindex; // sub0 holds the populated count + if (not sub0.value.empty()) { count = sub0.value[0]; } + obj.value = std::move(sub0.value); + + // RECORD/ARRAY: enumerate the actual fields -- otherwise only sub0 + // (the count) is visible, which is useless without the entries. Stop + // at the first absent subindex: sub0 often over-reports the count and + // reading the missing tail just aborts. + bool is_complex = (obj.object_code == CoE::ObjectCode::ARRAY) + or (obj.object_code == CoE::ObjectCode::RECORD); + if (is_complex) + { + if (count > obj.max_subindex) { count = obj.max_subindex; } + for (int sub = 1; (sub <= count) and (not aborting()) and (consec_fail < FAIL_LIMIT); ++sub) + { + bool ed_sub = false; + OdEntry e = readEntry(static_cast(sub), ed_sub, false); + if (not ed_sub) { break; } + obj.entries.push_back(std::move(e)); + } + } + + if (aborting()) { break; } // aborted mid-object: drop the partial, redo it on resume + + Event ev; + ev.kind = Event::Kind::OdScanObject; + ev.slave = slave_index; + ev.od_object = std::move(obj); + if (not pushEvent(std::move(ev))) + { + // The UI has not drained for thousands of objects -- it is + // wedged. Stop rather than spin; the bound did its job. + error = "discovery stopped: UI event queue full (consumer stalled)"; + break; + } + } + + // Interrupted by a phase change (not a disconnect, not a desync) before the + // end: continue from object i in the next phase instead of stopping short. + if ((i < n) and (consec_fail < FAIL_LIMIT) and aborting() and (not bus_stop_)) + { + requeue(i); + return; + } + if (consec_fail >= FAIL_LIMIT) + { + error = "discovery stopped after repeated SDO timeouts (mailbox out of sync)"; + } + } + catch (std::exception const& e) + { + error = e.what(); + } + + Event done; + done.kind = Event::Kind::OdScanDone; + done.slave = slave_index; + done.message = error; + pushEvent(std::move(done)); + } + +} diff --git a/tools/kickui/BusSession.h b/tools/kickui/BusSession.h new file mode 100644 index 00000000..698c7542 --- /dev/null +++ b/tools/kickui/BusSession.h @@ -0,0 +1,465 @@ +#ifndef KICKCAT_TOOLS_KICKUI_BUS_SESSION_H +#define KICKCAT_TOOLS_KICKUI_BUS_SESSION_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "kickcat/AbstractSocket.h" +#include "kickcat/CoE/CiA/DS402/Drive.h" +#include "kickcat/OS/ConditionVariable.h" +#include "kickcat/OS/Mutex.h" +#include "kickcat/OS/Thread.h" +#include "kickcat/protocol.h" + +#include "BusProtocol.h" +#include "Profile.h" +#include "kickcat/LockedRing.h" + +namespace kickcat +{ + class Bus; + class Link; + namespace mailbox::request { class AbstractMessage; } +} + +namespace kickcat::kickui +{ + + // Per-slave OD discovery state, fed by the bus actor's events. The whole + // struct is returned by value so a panel reads one coherent scan per frame. + struct OdScan + { + bool scanned = false; // a discovery has produced results for this slave + bool running = false; + int count = 0; + int total = 0; + std::vector objects; + std::string error; + }; + + // Per-slave PDO-mapping read-back state. + struct PdoScan + { + bool running = false; + PdoMapping mapping; + }; + + class BusSession; + + // The motor "object": a UI-side handle to one operated DS402 drive. The UI + // calls its verbs (enable/setJog/edit/...); each verb mutates the UI-side mirror + // cmd_ and submits the whole command set to the bus actor via the command + // queue (no shared command mutex). Feedback is read from the published + // snapshot, never from here. Heap-held (shared_ptr) for a stable address. + class MotorControl + { + public: + // One coherent command set. Each UI verb mutates the mirror cmd_ and submits + // the WHOLE struct as one queued command, so the bus actor never observes a + // half-applied multi-field change. Use edit() to set several fields then + // submit once (e.g. a mode change that also reseeds target). + using Command = MotorCmd; + + explicit MotorControl(int slave_index) : slave_index_(slave_index) {} + + void enable(bool on) { cmd_.enable = on; submit(); } + void setSetpointSource(SetpointSource s) { cmd_.source = static_cast(s); submit(); } + void setManualTarget(double v) { cmd_.manual_target = v; submit(); } + void setManualRampRate(double rate) { cmd_.manual_ramp = rate; submit(); } + void setJog(double signed_rate) { cmd_.jog_rate = signed_rate; submit(); } + void setGenerator(Waveform w, double amplitude, double frequency, double offset) + { + cmd_.gen_wave = static_cast(w); + cmd_.gen_amp = amplitude; + cmd_.gen_freq = frequency; + cmd_.gen_offset = offset; + submit(); + } + // Apply several fields atomically (e.g. a mode change that also reseeds + // target) and submit the whole set as one command. + template void edit(Fn&& fn) { fn(cmd_); submit(); } + + void setUnits(CoE::CiA::DS402::UnitConfig const& u); + + private: + // Only the bus actor touches these members: it seeds/configures the + // control and publishes feedback (fb_mtx_) into the snapshot. + friend class BusSession; + + void submit(); // enqueue cmd_ to the bus actor (defined after BusSession) + + DriveFeedback feedback() const { LockGuard lock(fb_mtx_); return fb_; } + std::string error() const { LockGuard lock(fb_mtx_); return error_; } + std::vector inputPdo() const { LockGuard lock(fb_mtx_); return in_raw_; } + std::vector outputPdo() const { LockGuard lock(fb_mtx_); return out_raw_; } + + int slave_index_ = -1; + bool is_drive_ = true; // false = generic CoE slave (mapped, no DS402 Drive) + OperateConfig config_; + + BusSession* session_ = nullptr; // set by BusSession; submit() enqueues here + Command cmd_; // UI-side mirror (UI thread only) + + mutable Mutex fb_mtx_; + DriveFeedback fb_; + std::string error_; + std::vector in_raw_; // last TxPDO bytes (slave -> master) + std::vector out_raw_; // last RxPDO bytes (master -> slave) + }; + + // One EtherCAT device (slave): the UI-side object for a detected slave. It + // carries identity + mailbox capabilities and forwards control to the owning + // BusSession's thread-safe paths (it never touches the live Bus directly). + // Capabilities gate which panels apply. CoE gives SDO/OD; a CoE device whose + // device-type is 402 is a DS402 motor (motor() handle). FoE/EoE are detected + // here too -- their behaviours/panels are planned and slot in as more methods + // on BusSession (this forwarding facade should not grow per capability). + class Device + { + public: + Device() = default; + + // --- identity (public data: set by BusSession on connect) --- + int index = -1; + uint16_t address = 0; + std::string name; + uint32_t vendor_id = 0; + uint32_t product_code = 0; + bool has_coe = false; // CoE mailbox (SDO/OD) + bool has_foe = false; // FoE mailbox (file transfer) + bool has_eoe = false; // EoE mailbox (Ethernet over EtherCAT) + Profile detected_profile = Profile::Unknown; // discovered (see profileFromCoeDeviceType) + // ESC_CONFIG (0x141) bit 0: device emulation, AL status mirrors AL control. + // The master's ERROR_ACK request bit echoes back as a phantom error + // indication, so the AL error bit is masked for these slaves. + bool is_emulated = false; + // Manual override of the discovered profile; Unknown = follow detection. + Profile forced_profile = Profile::Unknown; + BusSession* session = nullptr; + + // --- capabilities --- + // The effective profile: the manual override if set, else what was detected. + Profile profile() const + { + if (forced_profile != Profile::Unknown) { return forced_profile; } + return detected_profile; + } + bool isMotor() const { return profileInfo(profile()).is_drive; } + void forceProfile(Profile p) { forced_profile = p; } + + // --- EtherCAT state (every device) --- + void requestState(uint8_t state); + + // --- CoE behaviours (valid when has_coe) --- + bool sdoAvailable() const; + std::shared_ptr readSDO(uint16_t obj_index, uint8_t subindex, int access); + std::shared_ptr writeSDO(uint16_t obj_index, uint8_t subindex, int access, + std::vector data); + void discoverOD(); + OdScan odScan() const; + void readPdoMapping(); + PdoScan pdoScan() const; + + // --- DS402 motor (valid when isMotor) --- + void configureSlave(OperateConfig const& config); // record config (PRE-OP); applyMapping() starts the loop + void includeSlave(); // generic CoE slave: map via SII, no DS402 Drive + void includeSlave(OperateConfig const& config); // generic CoE slave with a manual PDO mapping + void unconfigureSlave(); + bool isConfigured() const; + bool isOperating() const; + std::shared_ptr motor() const; + }; + + // Owns the EtherCAT link/bus and the connection lifecycle. Connect runs on a + // background worker (raw-socket open + bus bring-up to PRE-OP can block and + // can throw on missing privileges); the UI polls for completion. The live + // Bus is only touched from one thread at a time. + class BusSession + { + friend class MotorControl; // MotorControl::submit() enqueues SdoCommands here + public: + BusSession(); + ~BusSession(); + + BusSession(BusSession const&) = delete; + BusSession& operator=(BusSession const&) = delete; + + // --- UI thread --- + void refreshInterfaces(); + std::vector const& interfaces() const { return interfaces_; } + + // Probe every real NIC for an EtherCAT network (background worker): one + // broadcast read of reg::TYPE per interface, the WKC is the slave count. + // tap: pseudo-interfaces are skipped (opening the shared-memory pair as a + // probe would claim one of its two endpoint slots). + void detectNetworks(); + bool isDetecting() const { return detecting_; } + // -1 = not probed, 0 = probed and no EtherCAT answer, >0 = slaves found. + int detectResult(std::string const& interface) const; + std::string detectStatus() const; // one-line outcome summary + + // Bus-wide distributed-clock setting; applied during connect (PRE-OP). + void setDcConfig(bool enable, int cycle_ms); + + // Cable redundancy: a non-empty redundant interface makes connect open a + // second socket and probe the ring (Link::checkRedundancyNeeded). Set on + // the UI thread before connect(). redundancyActive() reflects the live + // ring state (set by the Link callback when a cable break is detected). + void setRedundancy(std::string const& redundant_interface); + bool redundancyEnabled() const; + bool redundancyActive() const { return redundancy_active_; } + + void connect(std::string const& interface); + void rescan(); // re-detect slaves on the current interface + void disconnect(); + + // Per-frame UI-side housekeeping: promote a finished connect worker's + // results, reap a stopped RT thread, (re)start the idle service, and + // republish the snapshot. Call once per frame before reading the getters. + void update(); + + bool isConnected() const { return connected_; } + bool isConnecting() const { return connecting_; } + bool needsPrivilege() const { return needs_privilege_; } + void clearNeedsPrivilege() { needs_privilege_ = false; } + + std::string interfaceName() const; + std::string status() const; + std::string error() const; + + // devices()/selectedDevice()/select() are UI-thread-only: devices_ is read + // and mutated from the render loop and only ever rebuilt by update() on that + // same thread. Do not call these off the UI thread. + std::vector& devices() { return devices_; } + int selected() const { return selected_; } + void select(int index) { selected_ = index; } + Device* selectedDevice(); + + // The bus topology discovered from the master POV: per-port link state + // (DL_STATUS) and the parent/child tree. Captured at connect/rescan; + // refreshTopology() re-reads it live (e.g. after a cable break/heal). + TopologyInfo topology() const; + void refreshTopology(); + void clearErrorCounters(); // force a reset (slaves + totals) + + // Coherent per-frame view of everything the UI displays, published by the + // bus-owning side. The read-only getters above (topology/status/error/ + // slaveAlStatus) source from it. Null until the first publish. + std::shared_ptr snapshot() const; + + // Raw AL status byte of a slave (low nibble = state, bit 4 = error), + // polled by the bus-owning thread; 0 if unknown. + uint8_t slaveAlStatus(int index) const; + + // --- DS402 operation (one shared real-time loop over a set of drives) --- + // Map-once / always-cycling model (mirrors the master examples): + // - configureSlave(): record a drive's intended PDO config (PRE-OP only); + // no bus work. unconfigureSlave() drops it from the operated set. + // - applyMapping(): map ALL configured slaves ONCE and start the cyclic + // loop, which then runs continuously. Per-slave state changes (enable, + // PRE-OP/SAFE-OP/OP) NEVER rebuild the mapping or disturb siblings. + // - backToPreOp(): stop the loop and return the whole bus to PRE-OP so the + // operated set / mapping can be changed and re-applied. + void configureSlave(int slave_index, OperateConfig const& config); + void includeSlave(int slave_index); // generic CoE slave: mapped via SII, no DS402 Drive + void includeSlave(int slave_index, OperateConfig const& config); // generic + manual PDO mapping + void unconfigureSlave(int slave_index); + void applyMapping(); + void backToPreOp(); + bool isConfigured(int slave_index) const { return findControl(slave_index) != nullptr; } + + bool isOperatingAny() const { return rt_running_; } + bool isOperating(int slave_index) const; // configured AND the loop is running + + // The motor handle for a slave once operate() has been called on it (null + // otherwise). Control the drive through it: motor->enable(), edit(), ... + std::shared_ptr motor(int slave_index) const { return findControl(slave_index); } + + // --- Generic SDO / Object Dictionary --- + // Available whenever connected: transfers are serviced by the idle phase, + // or interleave with the cyclic loop (one blocking command per tick). + bool sdoAvailable() const { return connected_; } + + std::shared_ptr readSDO(int slave_index, uint16_t index, uint8_t subindex, int access); + std::shared_ptr writeSDO(int slave_index, uint16_t index, uint8_t subindex, + int access, std::vector data); + + // --- Generic EtherCAT state + PDO mapping (any slave, bus-level) --- + // The only state-change path. An enabled operated drive is de-energized + // first when leaving OP; INIT stops the cyclic loop (same as backToPreOp). + void requestSlaveState(int slave_index, uint8_t state); + std::string stateActionError(int slave_index) const; // empty = last action succeeded + + // OD/PDO discovery is per-slave (no session-global "busy" flags): each + // slave's scan/results are keyed by its index. + void readPdoMapping(int slave_index); + PdoScan pdoScan(int slave_index) const; + + void discoverOD(int slave_index); + OdScan odScan(int slave_index) const; + + private: + void joinWorker(); + // One bus-owning thread runs busActor(), which alternates between the idle + // phase (serviceLoop: SDO/OD/diagnostics) and the cyclic phase (rtLoop: + // 1 kHz DS402). applyMapping()/backToPreOp() flip operate_requested_/rt_stop_ + // to move between phases; the thread is never handed off. + void busActor(); + void startBusThread(); // spawn the actor (SCHED_FIFO, fallback SCHED_OTHER) + void stopBusThread(); // signal stop + join + void rtLoop(); // cyclic phase; returns on rt_stop_ / bus-lost + std::shared_ptr findControl(int slave_index) const; + + struct SdoCommand + { + enum class Kind { Read, Write, Discover, State, ReadMapping, Topology, ClearErrors, + Motor, MotorUnits }; + Kind kind = Kind::Read; + int slave_index = 0; + uint16_t index = 0; + uint8_t subindex = 0; + int access = 0; + uint8_t state = 0; + int od_resume = 0; // Kind::Discover: resume the scan from this object index + std::vector payload; + std::shared_ptr result; + MotorCmd motor; // Kind::Motor: whole coherent command set + CoE::CiA::DS402::UnitConfig motor_units; // Kind::MotorUnits: live unit change + }; + + void serviceLoop(); // idle phase; returns on operate_requested_ / bus_stop_ + // One dispatch for both phases. Motor/MotorUnits are phase-local (applied + // to the runtimes by rtLoop, dropped while idle) and never reach it. + void dispatchCommand(SdoCommand& cmd, std::function const& cyclic); + // `cyclic` is run once per mailbox poll: it carries the cyclic PDO + one + // mailbox step (RT thread), or just a mailbox step (idle), so SDO/OD + // transfers interleave with process data instead of blocking it. + bool driveMessage(std::shared_ptr const& msg, + std::function const& cyclic); + void executeSdo(SdoCommand& cmd, std::function const& cyclic); + void executeDiscover(int slave_index, std::function const& cyclic, int resume_from = 0); + void executeState(int slave_index, uint8_t state, std::function const& cyclic); + void executeReadMapping(int slave_index, std::function const& cyclic); + void executeTopology(std::function const& cyclic); + void enqueue(SdoCommand cmd); + void refreshSlaveStates(); + void refreshDiagnostics(); // bus thread: error counters + AL code + port link -> slave_diag_ + void setSlaveState(int index, uint8_t state); + // True when an in-flight mailbox op should abort: the whole bus thread is + // stopping (bus_stop_), or the cyclic phase is being left (rt_stop_). + bool aborting() const; + + std::vector interfaces_; + + std::shared_ptr link_; + std::unique_ptr bus_; + std::string interface_name_; + + std::vector devices_; + int selected_ = -1; + + std::atomic dc_enabled_{false}; + std::atomic cycle_ms_{1}; + + std::string redundancy_interface_; // UI-thread; read at connect() + std::atomic redundancy_active_{false}; + + // Interface probe worker (detectNetworks); reaped by update(). + std::optional detect_worker_; + std::atomic detecting_{false}; + std::atomic detect_done_{false}; + mutable Mutex detect_mtx_; + std::map detect_results_; // interface -> slave count + std::string detect_status_; + void joinDetectWorker(); + + std::optional worker_; + std::atomic connecting_{false}; + std::atomic done_{false}; + std::atomic connected_{false}; + std::atomic needs_privilege_{false}; + + mutable Mutex mtx_; + std::string status_; + std::string error_; + std::shared_ptr staged_link_; + std::unique_ptr staged_bus_; + std::vector staged_devices_; + TopologyInfo staged_topology_; + void dropStagedConnection(); // under mtx_: discard an unpromoted connect result + + // Discovered topology, read by the UI thread and written by update() (promote) + // and the idle service thread (refresh); guarded by its own mutex. + mutable Mutex topo_mtx_; + TopologyInfo topology_; + + // Published UI view. Built from the current authoritative fields and + // swapped under snap_mtx_; the read-only getters source from it. The + // pointer swap is guarded by a dedicated mutex rather than + // atomic (C++20) for -std=c++17 portability; the lock only + // covers a pointer copy. + mutable Mutex snap_mtx_; + std::shared_ptr snapshot_; + void publishSnapshot(); // build from current state + swap + + // --- single bus-owning thread (idle <-> cyclic) --- + // Per-slave device-emulation flags, copied from devices_ before the bus + // thread starts (immutable while it runs): refreshSlaveStates masks the + // AL error bit for these slaves (it is the echoed ack bit, not an error). + std::vector slave_emulated_; + std::vector io_map_; + std::optional bus_thread_; + std::atomic bus_stop_{false}; // tear the whole actor down + std::atomic operate_requested_{false};// idle -> cyclic (applyMapping) + std::atomic rt_running_{false}; // true while in the cyclic phase + std::atomic rt_stop_{false}; // leave the cyclic phase (backToPreOp) + std::atomic al_status_dirty_{false}; // set by the AL-event IRQ callback + + // The operated set: UI-thread manages the vector (operate/stop), the RT + // loop snapshots the shared_ptrs (and copies each config) at bring-up. + // controls_mtx_ guards the vector structure. + mutable Mutex controls_mtx_; + std::vector> controls_; + + // --- command intake (drained by both phases of the bus thread) --- + mutable Mutex sdo_mtx_; + ConditionVariable sdo_cv_; + std::deque sdo_queue_; + + mutable Mutex state_mtx_; + std::vector slave_states_; // raw AL status per slave (state_mtx_) + // Per-slave diagnostics, refreshed by the bus thread, copied by publishSnapshot + // (all under state_mtx_). stats is published as-is; last_* are the previous + // raw reads used to accumulate the deltas. + struct SlaveDiag + { + SlaveErrorStats stats; + uint8_t last_lost[4] = {0, 0, 0, 0}; + uint8_t last_rxerr[4] = {0, 0, 0, 0}; + }; + std::vector slave_diag_; + bool diag_clear_pending_ = false; // bus thread only; set by a ClearErrors command + + // Discrete bus->UI results (SDO-info/mapping/state-action). The bus-owning + // thread emits Events; the UI thread drains them in update() into the + // scan state below, which is therefore UI-thread-only (no mutex). + LockedRing::Context events_ctx_{}; + LockedRing events_{events_ctx_}; + bool pushEvent(Event ev); // bus-owning thread; false when the queue is full + void drainEvents(); // UI thread (update()) + + // Per-slave OD / PDO discovery + state-action results (keyed by slave + // index; UI-thread-only, fed by drainEvents()). + std::map state_errors_; // last state-change error per slave + std::map pdo_scans_; + std::map od_scans_; + }; +} + +#endif diff --git a/tools/kickui/CMakeLists.txt b/tools/kickui/CMakeLists.txt new file mode 100644 index 00000000..8fe606ad --- /dev/null +++ b/tools/kickui/CMakeLists.txt @@ -0,0 +1,29 @@ +find_package(portable-file-dialogs REQUIRED CONFIG) + +add_executable(kickui + main.cc + BusSession.cc + EtherCATPanel.cc + EventLog.cc + MotorPanel.cc + PdoValuesPanel.cc + PrivilegeHelper.cc + Profile.cc + SdoPanel.cc + Simulator.cc + Theme.cc + TopologyView.cc +) + +target_link_libraries(kickui PRIVATE + kickcat + kickcat_imgui + portable-file-dialogs::portable-file-dialogs +) + +set_kickcat_properties(kickui) +set_target_properties(kickui PROPERTIES OUTPUT_NAME "kickui") + +if (SKBUILD) + install(TARGETS kickui DESTINATION ${SKBUILD_SCRIPTS_DIR}) +endif() diff --git a/tools/kickui/EtherCATPanel.cc b/tools/kickui/EtherCATPanel.cc new file mode 100644 index 00000000..f520fd3a --- /dev/null +++ b/tools/kickui/EtherCATPanel.cc @@ -0,0 +1,225 @@ +#include "EtherCATPanel.h" + +#include + +#include "imgui.h" + +#include "BusSession.h" +#include "Theme.h" + +namespace kickcat::kickui +{ + namespace + { + void pdoTable(char const* title, std::vector const& entries) + { + ImGui::SeparatorText(title); + if (entries.empty()) + { + ImGui::TextDisabled("(none)"); + return; + } + if (not ImGui::BeginTable(title, 4, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingFixedFit)) + { + return; + } + ImGui::TableSetupColumn("PDO"); + ImGui::TableSetupColumn("Object"); + ImGui::TableSetupColumn("Sub"); + ImGui::TableSetupColumn("Bits"); + ImGui::TableHeadersRow(); + for (auto const& e : entries) + { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::Text("0x%04X", e.pdo); + ImGui::TableNextColumn(); + ImGui::Text("0x%04X", e.index); + ImGui::TableNextColumn(); + ImGui::Text("%u", e.sub); + ImGui::TableNextColumn(); + ImGui::Text("%u", e.bits); + } + ImGui::EndTable(); + } + + // Editable entry table for one mapping object (0x16xx / 0x1Axx). Lets the + // user add/remove entries (mapped object index/subindex/bit-length) and + // pick the owning mapping object index. + void entryEditor(char const* label, int* pdo_obj, std::vector& entries) + { + ImGui::PushID(label); + ImGui::SeparatorText(label); + ImGui::SetNextItemWidth(px(120.0f)); + ImGui::InputInt("mapping object (hex)", pdo_obj, 0, 0, ImGuiInputTextFlags_CharsHexadecimal); + + int total_bits = 0; + for (auto const& e : entries) { total_bits += e.bits; } + ImGui::SameLine(); + ImGui::TextDisabled("total: %d bits (%d bytes)", total_bits, total_bits / 8); + + int remove = -1; + if (ImGui::BeginTable("entries", 4, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingFixedFit)) + { + ImGui::TableSetupColumn("Object (hex)"); + ImGui::TableSetupColumn("Sub"); + ImGui::TableSetupColumn("Bits"); + ImGui::TableSetupColumn(""); + ImGui::TableHeadersRow(); + for (size_t i = 0; i < entries.size(); ++i) + { + ImGui::TableNextRow(); + ImGui::PushID(static_cast(i)); + PdoEntry& e = entries[i]; + int idx = e.index, sub = e.sub, bits = e.bits; + ImGui::TableNextColumn(); + ImGui::SetNextItemWidth(px(90.0f)); + if (ImGui::InputInt("##idx", &idx, 0, 0, ImGuiInputTextFlags_CharsHexadecimal)) + { + e.index = static_cast(idx); + } + ImGui::TableNextColumn(); + ImGui::SetNextItemWidth(px(50.0f)); + if (ImGui::InputInt("##sub", &sub, 0, 0)) { e.sub = static_cast(sub); } + ImGui::TableNextColumn(); + ImGui::SetNextItemWidth(px(50.0f)); + if (ImGui::InputInt("##bits", &bits, 0, 0)) { e.bits = static_cast(bits); } + ImGui::TableNextColumn(); + if (ImGui::SmallButton("X")) { remove = static_cast(i); } + ImGui::PopID(); + } + ImGui::EndTable(); + } + if (remove >= 0) { entries.erase(entries.begin() + remove); } + if (ImGui::SmallButton("+ entry")) { entries.push_back(PdoEntry{}); } + ImGui::PopID(); + } + } + + bool EtherCATPanel::seedFromReadback(Device& device, bool set_objects) + { + PdoMapping m = device.pdoScan().mapping; + if ((m.slave != device.index) or (not m.valid)) { return false; } + edit_rx_ = m.rx; + edit_tx_ = m.tx; + if (set_objects) + { + if (not m.rx.empty()) { rx_obj_ = m.rx.front().pdo; } + if (not m.tx.empty()) { tx_obj_ = m.tx.front().pdo; } + } + return true; + } + + void EtherCATPanel::renderMappingEditor(Device& device) + { + // Pre-fill from the last read-back so the user edits the real layout; keep + // trying until one actually arrives (the first frames have no read-back yet). + if (not seeded_) + { + seeded_ = seedFromReadback(device, true); + } + ImGui::TextDisabled("Entries are written to the mapping object via SDO before\n" + "Apply mapping. \"Read PDO mapping\" first to start from the\n" + "slave's current layout."); + if (ImGui::SmallButton("Load read-back")) + { + if (seedFromReadback(device, false)) + { + readback_msg_.clear(); + } + else + { + readback_msg_ = "no read-back yet -- \"Read PDO mapping\" or reach OP first"; + } + } + if (not readback_msg_.empty()) + { + ImGui::TextDisabled("%s", readback_msg_.c_str()); + } + entryEditor("RxPDO (outputs / 0x1C12)", &rx_obj_, edit_rx_); + entryEditor("TxPDO (inputs / 0x1C13)", &tx_obj_, edit_tx_); + if (ImGui::Button("Include with manual mapping")) + { + OperateConfig cfg; + cfg.manual_mapping = true; + cfg.rx_pdo_map = static_cast(rx_obj_); + cfg.tx_pdo_map = static_cast(tx_obj_); + cfg.manual_rx = edit_rx_; + cfg.manual_tx = edit_tx_; + device.includeSlave(cfg); + } + } + + bool EtherCATPanel::appliesTo(Device const& device) const + { + return device.has_coe; + } + + void EtherCATPanel::render(BusSession& session, Device& device) + { + // Generic (non-motor) slaves can still be mapped via their SII PDO and + // exchanged. DS402 motors are included from the Control tab instead. + if (not device.isMotor()) + { + ImGui::SeparatorText("Mapping"); + if (device.isConfigured()) + { + ImGui::TextColored(COLOR_GREEN, "included \xe2\x80\x94 Apply mapping (sidebar) to operate"); + ImGui::SameLine(); + ImGui::BeginDisabled(session.isOperatingAny()); + if (ImGui::Button("Remove")) { device.unconfigureSlave(); } + ImGui::EndDisabled(); + } + else + { + ImGui::BeginDisabled(session.isOperatingAny()); + ImGui::Checkbox("Define PDO mapping manually", &manual_map_); + if (manual_map_) + { + renderMappingEditor(device); + } + else + { + if (ImGui::Button("Include in mapping")) { device.includeSlave(); } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Map this slave (via its SII PDO) when Apply mapping runs;\n" + "no DS402 control, just process-data exchange."); + } + } + ImGui::EndDisabled(); + } + } + + ImGui::SeparatorText("PDO mapping (read-back)"); + PdoScan const scan = device.pdoScan(); + bool can_read = device.sdoAvailable() and not scan.running; + ImGui::BeginDisabled(not can_read); + if (ImGui::Button("Read PDO mapping")) + { + device.readPdoMapping(); + } + ImGui::EndDisabled(); + if (scan.running) + { + ImGui::SameLine(); + ImGui::TextDisabled("reading..."); + } + + PdoMapping const& mapping = scan.mapping; + if (mapping.slave == device.index) + { + if (not mapping.error.empty()) + { + ImGui::TextColored(COLOR_RED, "%s", mapping.error.c_str()); + } + else if (mapping.valid) + { + pdoTable("RxPDO (outputs / 0x1C12)", mapping.rx); + pdoTable("TxPDO (inputs / 0x1C13)", mapping.tx); + } + } + } +} diff --git a/tools/kickui/EtherCATPanel.h b/tools/kickui/EtherCATPanel.h new file mode 100644 index 00000000..cf64d1e8 --- /dev/null +++ b/tools/kickui/EtherCATPanel.h @@ -0,0 +1,36 @@ +#ifndef KICKCAT_TOOLS_KICKUI_ETHERCAT_PANEL_H +#define KICKCAT_TOOLS_KICKUI_ETHERCAT_PANEL_H + +#include +#include + +#include "Panel.h" +#include "BusProtocol.h" + +namespace kickcat::kickui +{ + // PDO mapping viewer (the ECAT FSM state control lives in the common strip). + class EtherCATPanel : public Panel + { + public: + char const* title() const override { return "PDO"; } + bool appliesTo(Device const& device) const override; + void render(BusSession& session, Device& device) override; + + private: + void renderMappingEditor(Device& device); + // Load the last read-back into the editor; set_objects also adopts its PDO indices. + bool seedFromReadback(Device& device, bool set_objects); + + // Manual PDO mapping editor state (per-device instance, so it persists). + bool manual_map_ = false; + int rx_obj_ = 0x1600; + int tx_obj_ = 0x1A00; + std::vector edit_rx_; + std::vector edit_tx_; + bool seeded_ = false; // pre-filled from the read-back once + std::string readback_msg_; // shown when "Load read-back" has nothing yet + }; +} + +#endif diff --git a/tools/kickui/EventLog.cc b/tools/kickui/EventLog.cc new file mode 100644 index 00000000..8b8ffd84 --- /dev/null +++ b/tools/kickui/EventLog.cc @@ -0,0 +1,121 @@ +#include "EventLog.h" + +#include +#include +#include +#include + +#include "Theme.h" + +namespace kickcat::kickui +{ + std::string EventLog::nowHHMMSS() + { + std::time_t t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); + std::tm tm{}; +#ifdef _WIN32 + localtime_s(&tm, &t); +#else + localtime_r(&t, &tm); +#endif + char buf[16]; + std::snprintf(buf, sizeof(buf), "%02d:%02d:%02d", tm.tm_hour, tm.tm_min, tm.tm_sec); + return buf; + } + + void EventLog::log(std::string text, bool severe) + { + entries_.push_back(Entry{nowHHMMSS(), std::move(text), severe}); + while (entries_.size() > 500) + { + entries_.pop_front(); + if (acked_ > 0) { --acked_; } + } + } + + size_t EventLog::unacked() const + { + if (entries_.size() < acked_) { return 0; } + return entries_.size() - acked_; + } + + bool EventLog::unackedSevere() const + { + for (size_t i = acked_; i < entries_.size(); ++i) + { + if (entries_[i].severe) { return true; } + } + return false; + } + + void EventLog::clear() + { + entries_.clear(); + acked_ = 0; + } + + void EventLog::update(std::shared_ptr const& snap, bool connected, bool bus_lost, + std::function const& slave_label) + { + if (not connected or (snap == nullptr)) + { + have_prev_ = false; // don't log stale deltas across (re)connect + return; + } + int const n = static_cast(snap->slaves.size()); + + if (bus_lost and not prev_bus_lost_) { log("Bus communication lost", true); } + prev_bus_lost_ = bus_lost; + + bool red = snap->redundancy_active; + if (red and not prev_redundancy_) { log(REDUNDANCY_ACTIVE_TEXT, false); } + if (not red and prev_redundancy_) { log("Cable redundancy cleared \xe2\x80\x94 ring intact", false); } + prev_redundancy_ = red; + + if (have_prev_ and (static_cast(prev_al_.size()) == n)) + { + for (int i = 0; i < n; ++i) + { + uint8_t a = snap->slaves[i].al_status; + if (a != prev_al_[i]) + { + char line[160]; + bool sev = (a & AL_ERROR_BIT) != 0; + if (sev) + { + std::snprintf(line, sizeof(line), "S%d %s \xe2\x86\x92 %s+ERR (0x%04X %s)", + slave_label(i), stateLabel(prev_al_[i]), stateLabel(a), + snap->slaves[i].stats.al_status_code, + ALStatus_to_string(snap->slaves[i].stats.al_status_code)); + } + else + { + std::snprintf(line, sizeof(line), "S%d %s \xe2\x86\x92 %s", + slave_label(i), stateLabel(prev_al_[i]), stateLabel(a)); + } + log(line, sev); + } + for (int p = 0; p < 4; ++p) + { + uint64_t lost = snap->slaves[i].stats.lost_total[p]; + if ((i < static_cast(prev_lost_.size())) and (lost > prev_lost_[i][p])) + { + char line[96]; + std::snprintf(line, sizeof(line), "S%d port %d: link lost (total %llu)", + slave_label(i), p, static_cast(lost)); + log(line, true); + } + } + } + } + + prev_al_.resize(n); + prev_lost_.resize(n); + for (int i = 0; i < n; ++i) + { + prev_al_[i] = snap->slaves[i].al_status; + for (int p = 0; p < 4; ++p) { prev_lost_[i][p] = snap->slaves[i].stats.lost_total[p]; } + } + have_prev_ = true; + } +} diff --git a/tools/kickui/EventLog.h b/tools/kickui/EventLog.h new file mode 100644 index 00000000..697fc590 --- /dev/null +++ b/tools/kickui/EventLog.h @@ -0,0 +1,54 @@ +#ifndef KICKCAT_TOOLS_KICKUI_EVENT_LOG_H +#define KICKCAT_TOOLS_KICKUI_EVENT_LOG_H + +#include +#include +#include +#include +#include +#include +#include + +#include "BusProtocol.h" + +namespace kickcat::kickui +{ + // The diagnostics event log: derives discrete entries (per-slave AL state + // transitions, lost-link rises, redundancy flips, bus loss) from successive + // snapshots. Pure model -- the alert banner and the Diagnostics tab render it. + class EventLog + { + public: + struct Entry + { + std::string when; // HH:MM:SS + std::string text; + bool severe = false; // error (red) vs notice (yellow) + }; + + // Diff this frame's snapshot against the previous one. slave_label maps a + // scan index to the displayed S# (the shell owns that mapping). + void update(std::shared_ptr const& snap, bool connected, bool bus_lost, + std::function const& slave_label); + + std::deque const& entries() const { return entries_; } + size_t unacked() const; + bool unackedSevere() const; // at least one unacknowledged severe entry + void ackAll() { acked_ = entries_.size(); } + void clear(); + + private: + static std::string nowHHMMSS(); + void log(std::string text, bool severe); + + std::deque entries_; + size_t acked_ = 0; // entries the user has acknowledged + bool have_prev_ = false; + std::vector prev_al_; // per-slave AL status, previous frame + std::vector> prev_lost_; // per-slave lost-link TOTALS (monotone) + bool prev_redundancy_ = false; + bool prev_bus_lost_ = false; + }; +} + +#endif diff --git a/tools/kickui/MotorPanel.cc b/tools/kickui/MotorPanel.cc new file mode 100644 index 00000000..ea411f7c --- /dev/null +++ b/tools/kickui/MotorPanel.cc @@ -0,0 +1,514 @@ +#include "MotorPanel.h" + +#include + +#include "imgui.h" + +#include "kickcat/CoE/CiA/DS402/Drive.h" +#include "kickcat/CoE/CiA/DS402/StateMachine.h" + +#include "BusSession.h" +#include "BusProtocol.h" +#include "Theme.h" + +namespace kickcat::kickui +{ + namespace + { + namespace ctrl = CoE::CiA::DS402::control; + namespace status = CoE::CiA::DS402::status; + + char const* const MODE_LABELS[] = {"CSP — cyclic position", + "CSV — cyclic velocity", + "CST — cyclic torque"}; + ctrl::ControlMode const MODE_VALUES[] = {ctrl::POSITION_CYCLIC, + ctrl::VELOCITY_CYCLIC, + ctrl::TORQUE_CYCLIC}; + char const* const WAVE_LABELS[] = {"Sine", "Step", "Triangle"}; + + // Unit of the active setpoint quantity, by mode index. + char const* setpointUnit(int mode_index) + { + if (mode_index == 1) { return "rad/s"; } + if (mode_index == 2) { return "Nm"; } + return "rad"; + } + } + + bool MotorPanel::appliesTo(Device const& device) const + { + return device.isMotor(); + } + + float MotorPanel::safeManualTarget(DriveFeedback const& fb) const + { + if (mode_index_ == 0) // CSP: hold the current position + { + return static_cast(fb.actual_pos); + } + return 0.0f; // velocity / torque: a stop is zero, not the position value + } + + void MotorPanel::render(BusSession& session, Device& device) + { + bool operating_this = device.isOperating(); + + // Units stay visible always; while operating they are applied live (they + // are just conversion factors). Guard against non-positive values, which + // the Drive rejects. + ImGui::SeparatorText("Units"); + bool changed = false; + changed |= ImGui::InputFloat("Encoder ticks / rev", &ticks_per_rev_); + changed |= ImGui::InputFloat("Gear ratio", &gear_ratio_); + changed |= ImGui::InputFloat("Rated torque (Nm)", &rated_torque_); + bool units_ok = (ticks_per_rev_ > 0.0f) and (gear_ratio_ > 0.0f) and (rated_torque_ > 0.0f); + if (not units_ok) + { + ImGui::TextColored(COLOR_RED, "units must be > 0"); + } + else if (changed and operating_this) + { + CoE::CiA::DS402::UnitConfig u; + u.encoder_ticks_per_rev = ticks_per_rev_; + u.gear_ratio = gear_ratio_; + u.rated_torque_Nm = rated_torque_; + if (auto m = device.motor()) { m->setUnits(u); } // applied live by the RT thread + } + + if (operating_this) + { + renderControl(session, device); + } + else + { + synced_ = false; + renderSetup(session, device, units_ok); + } + } + + void MotorPanel::renderSetup(BusSession& session, Device& device, bool units_ok) + { + if (session.isOperatingAny()) + { + ImGui::TextDisabled("Bus is operating. Use \"Back to PRE-OP (all)\" above to change the " + "mapped set, then re-configure and Apply mapping."); + } + + ImGui::SeparatorText("Bring-up"); + ImGui::Combo("Initial mode", &mode_index_, MODE_LABELS, IM_ARRAYSIZE(MODE_LABELS)); + ImGui::SetNextItemWidth(px(110.0f)); + ImGui::InputText("RxPDO map", rx_pdo_buf_, sizeof(rx_pdo_buf_)); + ImGui::SameLine(); + ImGui::SetNextItemWidth(px(110.0f)); + ImGui::InputText("TxPDO map", tx_pdo_buf_, sizeof(tx_pdo_buf_)); + + // Validate the PDO assignment indices up front (RxPDO 0x1600-0x17FF, + // TxPDO 0x1A00-0x1BFF per CiA-301) instead of letting a stray 0x0000 + // fail deep inside bring-up as a generic error. + long rx_pdo = std::strtol(rx_pdo_buf_, nullptr, 0); + long tx_pdo = std::strtol(tx_pdo_buf_, nullptr, 0); + bool rx_ok = (rx_pdo >= 0x1600) and (rx_pdo <= 0x17FF); + bool tx_ok = (tx_pdo >= 0x1A00) and (tx_pdo <= 0x1BFF); + bool pdo_ok = rx_ok and tx_ok; + if (not pdo_ok) + { + ImGui::TextColored(COLOR_RED, "PDO map index out of range (RxPDO 0x1600-0x17FF, TxPDO 0x1A00-0x1BFF)"); + } + + // Record this drive's config into the operated set. The mapping is applied + // bus-wide later via "Apply mapping" (the strip above); this button only + // marks the drive to participate. Disabled while the bus is operating. + ImGui::BeginDisabled(not units_ok or not pdo_ok or session.isOperatingAny()); + if (ImGui::Button("Include in mapping")) + { + OperateConfig cfg; + cfg.units.encoder_ticks_per_rev = ticks_per_rev_; + cfg.units.gear_ratio = gear_ratio_; + cfg.units.rated_torque_Nm = rated_torque_; + cfg.mode = MODE_VALUES[mode_index_]; + cfg.rx_pdo_map = static_cast(rx_pdo); + cfg.tx_pdo_map = static_cast(tx_pdo); + device.configureSlave(cfg); + } + ImGui::EndDisabled(); + if (device.isConfigured()) + { + ImGui::SameLine(); + ImGui::TextColored(COLOR_GREEN, "included \xe2\x80\x94 Apply mapping to operate"); + ImGui::SameLine(); + ImGui::BeginDisabled(session.isOperatingAny()); + if (ImGui::Button("Remove")) { device.unconfigureSlave(); } + ImGui::EndDisabled(); + } + + // The motor error comes from the published snapshot, like the feedback. + std::string error; + auto snap = session.snapshot(); + if (snap and (device.index >= 0) and (device.index < static_cast(snap->slaves.size()))) + { + error = snap->slaves[device.index].motor_error; + } + if (not error.empty()) + { + ImGui::Spacing(); + ImGui::TextColored(COLOR_RED, "%s", error.c_str()); + } + } + + void MotorPanel::renderControl(BusSession& session, Device& device) + { + auto motor = device.motor(); // commands only (enable/setpoint/...) + if (not motor) + { + return; // not operating this device (shouldn't happen: caller gated on it) + } + // Feedback comes from the published snapshot: the UI never reaches into + // the RT thread's state. + DriveFeedback fb; + auto snap = session.snapshot(); + if (snap and (device.index >= 0) and (device.index < static_cast(snap->slaves.size()))) + { + fb = snap->slaves[device.index].fb; + } + + // --- state badge --- + uint16_t sw = fb.status_word; + if (fb.faulted) + { + ImGui::TextColored(COLOR_RED, "\xe2\x97\x8f FAULT"); + } + else if (sw & status::masks::OPERATION_ENABLE) + { + ImGui::TextColored(COLOR_GREEN, "\xe2\x97\x8f OPERATION ENABLED"); + } + else if (sw & status::masks::SWITCHED_ON) + { + ImGui::TextColored(COLOR_YELLOW, "\xe2\x97\x8f switched on"); + } + else if (sw & status::masks::READY_TO_SWITCH_ON) + { + ImGui::TextColored(COLOR_YELLOW, "\xe2\x97\x8f ready to switch on"); + } + else + { + ImGui::TextDisabled("\xe2\x97\x8f switch-on disabled"); + } + ImGui::SameLine(); + ImGui::TextDisabled("status 0x%04X ctrl 0x%04X err 0x%04X", sw, fb.control_word, fb.error_code); + + // EtherCAT state is controlled by the common strip above the tabs. + // The drive can only be enabled once the bus reaches OP. Reserve the line + // height either way so the controls below don't jump when it appears. + if (not fb.operational) + { + ImGui::TextColored(COLOR_YELLOW, "Not OPERATIONAL \xe2\x80\x94 set the device to OP (strip above) to enable."); + } + else + { + ImGui::NewLine(); + } + + // --- drive (DS402) --- + // One energize toggle (the DS402 OPERATION-ENABLED bit) and an emergency + // STOP (kills power AND any commanded motion). Clear fault appears only + // while faulted. To stop driving entirely, set the device to PRE-OP on the + // EtherCAT strip above (the shared ESM is the single state control). + ImGui::SeparatorText("Drive"); + if (fb.enabled) + { + if (ImGui::Button("Disable")) { motor->enable(false); } + } + else + { + ImGui::BeginDisabled(not fb.operational); + if (ImGui::Button("Enable")) { motor->enable(true); } + ImGui::EndDisabled(); + } + if (fb.faulted) + { + ImGui::SameLine(); + if (ImGui::Button("Clear fault")) + { + motor->enable(true); // DS402 acknowledges the fault during the enable sequence + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Acknowledges the fault and energizes the drive\n" + "(DS402 clears faults during the enable sequence)."); + } + } + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.1f, 0.12f, 1.0f)); + if (ImGui::Button("\xe2\x96\xa0 STOP")) + { + // Emergency: de-energize and neutralize the setpoint as ONE coherent + // command so the RT loop never sees a half-applied stop. + source_index_ = 0; // also flip the UI selector, else it reverts next frame + manual_target_ = safeManualTarget(fb); + motor->edit([&](MotorControl::Command& c) + { + c.jog_rate = 0.0; + c.source = static_cast(SetpointSource::Manual); + c.manual_target = manual_target_; + c.enable = false; + }); + } + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Emergency stop: de-energize and zero the setpoint."); + } + // To stop driving this device entirely, set it to PRE-OP on the EtherCAT + // strip above -- that releases it from the cyclic loop (shared ESM is the + // single state control; no separate "release" action). + + // --- mode --- + ImGui::SeparatorText("Mode"); + if (ImGui::Combo("##mode", &mode_index_, MODE_LABELS, IM_ARRAYSIZE(MODE_LABELS))) + { + // The Manual target's units changed with the mode; reset it to a safe + // value and apply mode+target+jog as ONE coherent command. + manual_target_ = safeManualTarget(fb); + motor->edit([&](MotorControl::Command& c) + { + c.mode = static_cast(MODE_VALUES[mode_index_]); + c.manual_target = manual_target_; + c.jog_rate = 0.0; + }); + } + ImGui::SameLine(); + ImGui::TextDisabled("display: %d", fb.mode_display); + + // --- setpoint --- + ImGui::SeparatorText("Setpoint"); + char const* unit = setpointUnit(mode_index_); + + auto sendSource = [&](SetpointSource s) + { + if (sent_source_ != static_cast(s)) + { + motor->setSetpointSource(s); + sent_source_ = static_cast(s); + } + }; + + // Fixed-height area sized for the tallest source (Generator) so the + // Feedback table below never jumps when the source or OP state changes. + ImGui::BeginChild("##setpoint_area", ImVec2(0.0f, ImGui::GetFrameHeightWithSpacing() * 7.0f), false); + if (not fb.operational) + { + ImGui::TextDisabled("waiting for operational state..."); + // Left OP (e.g. dropped to SAFE-OP): stop driving from the generator/jog + // (once) so the waveform doesn't keep advancing and the motor won't jump + // when it returns to OP. Re-seed the hold point on the way back in. + if (not hold_sent_) + { + gen_running_ = false; + jog_current_ = 0.0f; + synced_ = false; + motor->edit([](MotorControl::Command& c) + { + c.source = static_cast(SetpointSource::Manual); + c.jog_rate = 0.0; + }); + sent_source_ = static_cast(SetpointSource::Manual); + sent_jog_ = 0.0f; + hold_sent_ = true; + } + } + else + { + hold_sent_ = false; + // Seed the panel's setpoint and the live command to the actual + // position once, the instant the drive is operational, so enabling + // (or starting the generator) holds position instead of slamming to a + // stale target captured earlier at SAFE-OP. + if (not synced_) + { + manual_target_ = safeManualTarget(fb); + motor->setManualTarget(manual_target_); + synced_ = true; + } + + ImGui::RadioButton("Manual", &source_index_, 0); + ImGui::SameLine(); + ImGui::RadioButton("Jog", &source_index_, 1); + ImGui::SameLine(); + ImGui::RadioButton("Generator", &source_index_, 2); + + if (source_index_ == 0) + { + sendSource(SetpointSource::Manual); + ImGui::InputFloat("target", &manual_target_); + if (ImGui::IsItemDeactivatedAfterEdit()) // commit on edit-complete, like the generator center + { + motor->setManualTarget(manual_target_); + } + ImGui::SameLine(); + ImGui::TextDisabled("%s", unit); + if (ImGui::Button("Send")) + { + motor->setManualTarget(manual_target_); + } + ImGui::SameLine(); + if (ImGui::Button("= actual")) + { + manual_target_ = static_cast(fb.actual_pos); + motor->setManualTarget(manual_target_); + } + // Slew rate toward the target so a new setpoint ramps instead of + // stepping (0 = step immediately). + ImGui::SetNextItemWidth(px(120.0f)); + ImGui::InputFloat("ramp (/s)", &ramp_rate_); + if (ramp_rate_ < 0.0f) { ramp_rate_ = 0.0f; } + if (ramp_rate_ != sent_ramp_) + { + motor->setManualRampRate(ramp_rate_); + sent_ramp_ = ramp_rate_; + } + } + else if (source_index_ == 1) + { + sendSource(SetpointSource::Jog); + ImGui::InputFloat("jog rate", &jog_rate_); + ImGui::SameLine(); + ImGui::TextDisabled("%s", unit); + ImGui::SetNextItemWidth(px(120.0f)); + ImGui::InputFloat("jog ramp (/s\xc2\xb2)", &jog_accel_); + if (jog_accel_ < 0.0f) { jog_accel_ = 0.0f; } + + ImGui::Button("\xe2\x97\x80 jog \xe2\x88\x92"); + bool minus = ImGui::IsItemActive(); + ImGui::SameLine(); + ImGui::Button("jog + \xe2\x96\xb6"); + bool plus = ImGui::IsItemActive(); + + // Trapezoid: accelerate the commanded rate toward +/-jog_rate while + // held and decelerate back to 0 on release (0 ramp = step to rate). + float target = 0.0f; + if (plus) { target = jog_rate_; } + else if (minus) { target = -jog_rate_; } + if (jog_accel_ <= 0.0f) + { + jog_current_ = target; + } + else + { + float step = jog_accel_ * ImGui::GetIO().DeltaTime; + if (jog_current_ < target) + { + jog_current_ += step; + if (jog_current_ > target) { jog_current_ = target; } + } + else if (jog_current_ > target) + { + jog_current_ -= step; + if (jog_current_ < target) { jog_current_ = target; } + } + } + if (jog_current_ != sent_jog_) // streams while ramping, silent once settled + { + motor->setJog(jog_current_); + sent_jog_ = jog_current_; + } + } + else + { + ImGui::Combo("waveform", &wave_index_, WAVE_LABELS, IM_ARRAYSIZE(WAVE_LABELS)); + ImGui::InputFloat("amplitude", &gen_amp_); + ImGui::InputFloat("frequency (Hz)", &gen_freq_); + if (mode_index_ == 0) + { + ImGui::InputFloat("center", &manual_target_); + // Commit on edit-complete, while running only: paused, the hold + // target must stand (the RT loop re-centers on play anyway). + if (ImGui::IsItemDeactivatedAfterEdit() and gen_running_) + { + motor->setManualTarget(manual_target_); + } + } + else + { + ImGui::InputFloat("offset", &gen_offset_); + } + + // Play/Pause: the generator only advances while playing AND the drive + // is enabled -- pausing (or a disable) holds position so the waveform + // doesn't run continuously or jump on resume. + char const* play_label = "\xe2\x96\xb6 Play"; + if (gen_running_) { play_label = "Pause"; } + ImGui::BeginDisabled(not fb.enabled); // the generator only runs once enabled + if (ImGui::Button(play_label)) { gen_running_ = not gen_running_; } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (not fb.enabled) { ImGui::TextDisabled("enable the drive first"); } + else if (gen_running_) { ImGui::TextColored(COLOR_GREEN, "running"); } + else { ImGui::TextDisabled("paused"); } + + if (gen_running_ and fb.enabled) + { + sendSource(SetpointSource::Generator); + bool gen_changed = (wave_index_ != sent_wave_) or (gen_amp_ != sent_amp_) + or (gen_freq_ != sent_freq_) or (gen_offset_ != sent_goffset_); + if (gen_changed) + { + motor->setGenerator(static_cast(wave_index_), gen_amp_, gen_freq_, gen_offset_); + sent_wave_ = wave_index_; + sent_amp_ = gen_amp_; + sent_freq_ = gen_freq_; + sent_goffset_ = gen_offset_; + } + } + else + { + if (not fb.enabled) { gen_running_ = false; } + // Pause/disable: hold the position captured NOW, as one command. + bool leaving_generator = (sent_source_ == static_cast(SetpointSource::Generator)); + sendSource(SetpointSource::Manual); + if (leaving_generator) + { + motor->setManualTarget(safeManualTarget(fb)); + } + } + } + } + ImGui::EndChild(); + + // --- readouts --- + ImGui::SeparatorText("Feedback"); + if (ImGui::BeginTable("##fb", 3, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) + { + ImGui::TableSetupColumn("quantity", ImGuiTableColumnFlags_WidthFixed, px(90.0f)); + ImGui::TableSetupColumn("raw", ImGuiTableColumnFlags_WidthFixed, px(130.0f)); + ImGui::TableSetupColumn("SI"); + ImGui::TableHeadersRow(); + + auto row = [](char const* k, std::string const& raw, std::string const& si) + { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::TextDisabled("%s", k); + ImGui::TableNextColumn(); + ImGui::TextUnformatted(raw.c_str()); + ImGui::TableNextColumn(); + ImGui::TextUnformatted(si.c_str()); + }; + + char raw[48]; + char si[48]; + std::snprintf(raw, sizeof(raw), "%d ticks", fb.actual_pos_raw); + std::snprintf(si, sizeof(si), "%.4f rad", fb.actual_pos); + row("position", raw, si); + std::snprintf(raw, sizeof(raw), "%d", fb.actual_vel_raw); + std::snprintf(si, sizeof(si), "%.4f rad/s", fb.actual_vel); + row("velocity", raw, si); + std::snprintf(raw, sizeof(raw), "%d", fb.actual_torque_raw); + std::snprintf(si, sizeof(si), "%.4f Nm", fb.actual_torque); + row("torque", raw, si); + std::snprintf(si, sizeof(si), "%.4f %s", fb.target, unit); + row("target", "\xe2\x80\x94", si); + ImGui::EndTable(); + } + } +} diff --git a/tools/kickui/MotorPanel.h b/tools/kickui/MotorPanel.h new file mode 100644 index 00000000..7667f924 --- /dev/null +++ b/tools/kickui/MotorPanel.h @@ -0,0 +1,63 @@ +#ifndef KICKCAT_TOOLS_KICKUI_MOTOR_PANEL_H +#define KICKCAT_TOOLS_KICKUI_MOTOR_PANEL_H + +#include "Panel.h" + +namespace kickcat::kickui +{ + struct DriveFeedback; + + // DS402 drive control: bring-up, state machine, jog/step, motion generator. + class MotorPanel : public Panel + { + public: + char const* title() const override { return "Control"; } + bool appliesTo(Device const& device) const override; + void render(BusSession& session, Device& device) override; + + private: + void renderSetup(BusSession& session, Device& device, bool units_ok); + void renderControl(BusSession& session, Device& device); + + // Safe Manual setpoint for the current mode: hold actual position in CSP, + // zero (stop) in velocity/torque modes (different units per mode). + float safeManualTarget(DriveFeedback const& fb) const; + + // Bring-up parameters (UnitConfig + initial mode). + float ticks_per_rev_ = 524288.0f; + float gear_ratio_ = 1.0f; + float rated_torque_ = 1.0f; + int mode_index_ = 0; // 0=CSP, 1=CSV, 2=CST + char rx_pdo_buf_[12] = "0x1600"; + char tx_pdo_buf_[12] = "0x1A00"; + + // Setpoint controls. + int source_index_ = 0; // 0=Manual, 1=Jog, 2=Generator + float manual_target_ = 0.0f; + float ramp_rate_ = 1.0f; // manual slew rate (unit/s); 0 = step + float jog_rate_ = 1.0f; + float jog_accel_ = 5.0f; // jog ramp (unit/s^2); 0 = step to rate + float jog_current_ = 0.0f; // currently-commanded jog rate (ramped) + int wave_index_ = 0; // 0=Sine, 1=Step, 2=Triangle + float gen_amp_ = 0.5f; + float gen_freq_ = 0.5f; + float gen_offset_ = 0.0f; + bool gen_running_ = false; // generator play/pause (paused holds position) + + // True once the setpoint fields have been synced to the live position for + // the current operation (cleared when the drive leaves OP). + bool synced_ = false; + + // Last values pushed to the bus actor; verbs are submitted on change only. + int sent_source_ = -1; + float sent_ramp_ = -1.0f; // ramp is clamped >= 0, so -1 = nothing sent yet + float sent_jog_ = 0.0f; + int sent_wave_ = -1; + float sent_amp_ = 0.0f; + float sent_freq_ = 0.0f; + float sent_goffset_ = 0.0f; + bool hold_sent_ = false; // the leave-OP stop command went out + }; +} + +#endif diff --git a/tools/kickui/Panel.h b/tools/kickui/Panel.h new file mode 100644 index 00000000..fc1a2d8b --- /dev/null +++ b/tools/kickui/Panel.h @@ -0,0 +1,33 @@ +#ifndef KICKCAT_TOOLS_KICKUI_PANEL_H +#define KICKCAT_TOOLS_KICKUI_PANEL_H + +namespace kickcat::kickui +{ + class BusSession; + class Device; + + // A feature shown as one tab. The shell renders the tab only when + // appliesTo() is true for the currently-selected device's capabilities + // (e.g. has_coe, isMotor, later FoE/EoE). Adding a feature is one Panel + // subclass + one registration in the shell. `session` is for bus-wide + // queries; per-device control goes through `device`. + // + // Lifecycle: the shell creates one instance of every panel PER DEVICE and + // destroys them together on disconnect/rescan, so an instance is bound to a + // single device for its whole life -- per-device widget state is plain + // members, no device-index-change guards. + // + // Data access: periodic state comes from session.snapshot(); discrete results + // (SDO transfers, OD scans) from the session's event-fed getters. + class Panel + { + public: + virtual ~Panel() = default; + + virtual char const* title() const = 0; + virtual bool appliesTo(Device const& device) const = 0; + virtual void render(BusSession& session, Device& device) = 0; + }; +} + +#endif diff --git a/tools/kickui/PdoValuesPanel.cc b/tools/kickui/PdoValuesPanel.cc new file mode 100644 index 00000000..2db20b4f --- /dev/null +++ b/tools/kickui/PdoValuesPanel.cc @@ -0,0 +1,68 @@ +#include "PdoValuesPanel.h" + +#include +#include +#include + +#include "imgui.h" + +#include "BusSession.h" +#include "BusProtocol.h" +#include "Theme.h" + +namespace kickcat::kickui +{ + namespace + { + void pdoBytes(char const* title, std::vector const& data) + { + ImGui::SeparatorText(title); + if (data.empty()) + { + ImGui::TextDisabled("(no data)"); + return; + } + // Fixed-width font so digits don't jitter as values change at 1 kHz. + bool mono = (g_mono_font != nullptr); + if (mono) { ImGui::PushFont(g_mono_font); } + // 16 bytes per row: offset, hex, ascii-ish. + char line[128]; + for (size_t row = 0; row < data.size(); row += 16) + { + int n = std::snprintf(line, sizeof(line), "%04zu: ", row); + for (size_t i = row; (i < row + 16) and (i < data.size()); ++i) + { + n += std::snprintf(line + n, sizeof(line) - n, "%02X ", data[i]); + } + ImGui::TextUnformatted(line); + } + if (mono) { ImGui::PopFont(); } + } + } + + bool PdoValuesPanel::appliesTo(Device const& device) const + { + return device.has_coe; + } + + void PdoValuesPanel::render(BusSession& session, Device& device) + { + if (not device.isOperating()) + { + ImGui::TextDisabled("Include this slave and Apply mapping, then bring it to\n" + "SAFE-OP/OP to see its process data."); + return; + } + // Process-image bytes come from the published snapshot, not the live + // MotorControl, so the UI never reaches into the RT thread's state. + auto snap = session.snapshot(); + if (not snap or (device.index < 0) or (device.index >= static_cast(snap->slaves.size()))) + { + ImGui::TextDisabled("(no data)"); + return; + } + SlaveSnapshot const& ss = snap->slaves[device.index]; + pdoBytes("Input (TxPDO, slave \xe2\x86\x92 master)", ss.in_raw); + pdoBytes("Output (RxPDO, master \xe2\x86\x92 slave)", ss.out_raw); + } +} diff --git a/tools/kickui/PdoValuesPanel.h b/tools/kickui/PdoValuesPanel.h new file mode 100644 index 00000000..3c61216c --- /dev/null +++ b/tools/kickui/PdoValuesPanel.h @@ -0,0 +1,19 @@ +#ifndef KICKCAT_TOOLS_KICKUI_PDO_VALUES_PANEL_H +#define KICKCAT_TOOLS_KICKUI_PDO_VALUES_PANEL_H + +#include "Panel.h" + +namespace kickcat::kickui +{ + // Live raw process-image view: the input (TxPDO) and output (RxPDO) bytes of + // the selected slave, refreshed each cycle while operating. + class PdoValuesPanel : public Panel + { + public: + char const* title() const override { return "PDO values"; } + bool appliesTo(Device const& device) const override; + void render(BusSession& session, Device& device) override; + }; +} + +#endif diff --git a/tools/kickui/PrivilegeHelper.cc b/tools/kickui/PrivilegeHelper.cc new file mode 100644 index 00000000..a70cfaee --- /dev/null +++ b/tools/kickui/PrivilegeHelper.cc @@ -0,0 +1,123 @@ +#include "PrivilegeHelper.h" + +#include + +#ifdef __linux__ +#include +#include +#endif + +namespace kickcat::kickui +{ +#ifdef __linux__ + namespace + { + // Wrap a `setcap cap_net_raw` in whatever graphical privilege-prompt is + // available; empty if none found (caller shows the manual command). + std::string buildSetcapCommand(std::string const& exe_path) + { + std::string setcap = "/usr/sbin/setcap cap_net_raw,cap_net_admin+ep " + exe_path; + + if (std::system("which pkexec >/dev/null 2>&1") == 0) + { + return "pkexec " + setcap; + } + + constexpr char const* KDESU_PATHS[] = + { + "/usr/lib/x86_64-linux-gnu/libexec/kf6/kdesu", + "/usr/lib/x86_64-linux-gnu/libexec/kf5/kdesu", + "/usr/lib/libexec/kf6/kdesu", + "/usr/lib/libexec/kf5/kdesu", + }; + for (auto const* kdesu : KDESU_PATHS) + { + if (access(kdesu, X_OK) == 0) + { + return std::string(kdesu) + " -c \"" + setcap + "\""; + } + } + + return {}; + } + } +#endif + + PrivilegeHelper::~PrivilegeHelper() + { + if (thread_) + { + thread_->join(); // don't abandon a running setcap worker + } + } + + void PrivilegeHelper::ensureCommand() + { + if (command_ready_) + { + return; + } +#ifdef __linux__ + char exe_buf[PATH_MAX]; + ssize_t n = readlink("/proc/self/exe", exe_buf, sizeof(exe_buf) - 1); + if (n > 0) + { + exe_buf[n] = '\0'; + exe_path_ = exe_buf; + command_ = buildSetcapCommand(exe_path_); + } +#endif + command_ready_ = true; + } + + void PrivilegeHelper::grant() + { + if (running_ or command_.empty()) + { + return; + } + done_ = false; + ok_ = false; + running_ = true; + std::string cmd = command_; + thread_.emplace("kickui-setcap", [this, cmd]() + { + bool ok = (std::system(cmd.c_str()) == 0); + ok_ = ok; + done_ = true; + }, 0); + try + { + thread_->start(); + } + catch (std::exception const& e) + { + // start() failed: drop the never-started Thread so the destructor and + // reap() don't join() it (which throws), and clear the flag so the + // Grant button stays usable. + thread_.reset(); + running_ = false; + error_ = std::string("Could not start privilege helper: ") + e.what(); + } + } + + void PrivilegeHelper::reap() + { + if (running_ and done_) + { + thread_->join(); + thread_.reset(); + running_ = false; + if (ok_) + { + granted_ = true; + error_.clear(); + } + else + { + error_ = "Failed. Run manually:\n" + " sudo /usr/sbin/setcap cap_net_raw,cap_net_admin+ep " + exe_path_; + } + } + } +} diff --git a/tools/kickui/PrivilegeHelper.h b/tools/kickui/PrivilegeHelper.h new file mode 100644 index 00000000..fa945dae --- /dev/null +++ b/tools/kickui/PrivilegeHelper.h @@ -0,0 +1,49 @@ +#ifndef KICKCAT_TOOLS_KICKUI_PRIVILEGE_HELPER_H +#define KICKCAT_TOOLS_KICKUI_PRIVILEGE_HELPER_H + +#include +#include +#include + +#include "kickcat/OS/Thread.h" + +namespace kickcat::kickui +{ + // Grants CAP_NET_RAW to this executable: resolves a graphical privilege + // prompt (pkexec/kdesu) once, then runs the blocking, password-prompting + // setcap command on a worker thread so the render loop keeps drawing. + // Linux-only; on other platforms command() stays empty. + class PrivilegeHelper + { + public: + ~PrivilegeHelper(); // joins a running worker + + // Resolve the executable path and the prompt command once; the probes + // (which/access) fork a shell, so they must not run per frame. + void ensureCommand(); + void grant(); // start the worker (no-op while running / no command) + void reap(); // join a finished worker; call once per frame + + bool running() const { return running_; } + bool granted() const { return granted_; } + void resetGranted() { granted_ = false; } + std::string const& exePath() const { return exe_path_; } + std::string const& command() const { return command_; } + std::string const& error() const { return error_; } + void setError(std::string e) { error_ = std::move(e); } + void clearError() { error_.clear(); } + + private: + std::string exe_path_; + std::string command_; + bool command_ready_ = false; + bool granted_ = false; + std::string error_; + std::optional thread_; + std::atomic running_{false}; + std::atomic done_{false}; + std::atomic ok_{false}; + }; +} + +#endif diff --git a/tools/kickui/Profile.cc b/tools/kickui/Profile.cc new file mode 100644 index 00000000..d5f81bb1 --- /dev/null +++ b/tools/kickui/Profile.cc @@ -0,0 +1,42 @@ +#include "Profile.h" + +#include + +namespace kickcat::kickui +{ + namespace + { + // Single source of truth: register a profile by adding one line here (plus a + // discovery case below, and -- if it has dedicated UI -- a Panel that + // appliesTo it). is_drive contributes the motor control + MotorPanel. + constexpr ProfileInfo REGISTRY[] = { + {Profile::Generic, "generic", false}, + {Profile::CiA_DS402, "DS402 (drive)", true }, + }; + constexpr ProfileInfo UNKNOWN{Profile::Unknown, "unknown", false}; + } + + ProfileInfo const& profileInfo(Profile profile) + { + for (auto const& info : REGISTRY) + { + if (info.profile == profile) { return info; } + } + return UNKNOWN; + } + + std::vector const& selectableProfiles() + { + static std::vector const list(std::begin(REGISTRY), std::end(REGISTRY)); + return list; + } + + Profile profileFromCoeDeviceType(uint16_t device_type_low) + { + switch (device_type_low) + { + case 402: { return Profile::CiA_DS402; } + } + return Profile::Generic; + } +} diff --git a/tools/kickui/Profile.h b/tools/kickui/Profile.h new file mode 100644 index 00000000..fbf38923 --- /dev/null +++ b/tools/kickui/Profile.h @@ -0,0 +1,39 @@ +#ifndef KICKCAT_TOOLS_KICKUI_PROFILE_H +#define KICKCAT_TOOLS_KICKUI_PROFILE_H + +#include +#include + +namespace kickcat::kickui +{ + // A device's application profile. CoE/CiA profiles are discovered today; a + // non-CoE profile (a SERCOS drive over SoE, a vendor profile) is added by + // appending one enumerator, one registry entry, and one discovery mapping -- + // nothing in Device, the panels or the shell hardcodes a specific profile. + // Standards-body-prefixed so non-CiA profiles (a SERCOS drive over SoE, a + // vendor profile) read unambiguously in this flat enum: Sercos_*, Vendor_*, ... + enum class Profile : uint16_t + { + Unknown, // not discovered (no profile, or no mailbox that carries one) + Generic, // a device with a mailbox but no profile we specialise for + CiA_DS402, // CiA-402 drive + }; + + struct ProfileInfo + { + Profile profile; + char const* name; // human label, e.g. "DS402 (drive)" + bool is_drive; // contributes the motor/drive control + panel + }; + + // Metadata for a profile (a safe "unknown" entry for anything not registered). + ProfileInfo const& profileInfo(Profile profile); + + // The profiles a user can force from the UI (everything but Unknown/"Auto"). + std::vector const& selectableProfiles(); + + // Discovery: map the CoE device-type (0x1000) low word to a Profile. + Profile profileFromCoeDeviceType(uint16_t device_type_low); +} + +#endif diff --git a/tools/kickui/SdoPanel.cc b/tools/kickui/SdoPanel.cc new file mode 100644 index 00000000..aaf628b6 --- /dev/null +++ b/tools/kickui/SdoPanel.cc @@ -0,0 +1,524 @@ +#include "SdoPanel.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" + +#include "kickcat/Bus.h" +#include "kickcat/CoE/OD.h" + +#include "BusSession.h" +#include "Theme.h" + +namespace kickcat::kickui +{ + namespace + { + // Writer type picker = the library's basic data types (sizes/signedness from + // CoE::basicDataTypes(), names from CoE::toString -- no per-type knowledge + // duplicated here) plus a UI-only raw-hex fallback (DataType::UNKNOWN, last). + std::vector const& writerTypes() + { + static std::vector const list = [] + { + std::vector v(CoE::BASIC_DATA_TYPES.begin(), CoE::BASIC_DATA_TYPES.end()); + v.push_back({CoE::DataType::UNKNOWN, 0, false, false, false}); // RAW (hex) + return v; + }(); + return list; + } + + char const* typeLabel(CoE::DataTypeInfo const& t) + { + if (t.type == CoE::DataType::UNKNOWN) { return "RAW (hex)"; } + return CoE::toString(t.type); + } + + // The writerTypes() index for a discovered CoE DataType; the raw-hex entry + // (last) if the type is not one we offer. + int typeIndexFor(CoE::DataType dt) + { + auto const& types = writerTypes(); + for (int i = 0; i < static_cast(types.size()); ++i) + { + if (types[i].type == dt) { return i; } + } + return static_cast(types.size()) - 1; + } + + // The combo order must match Bus::Access (the index is sent verbatim). + char const* const ACCESS_LABELS[] = {"PARTIAL", "COMPLETE", "EMULATE_COMPLETE"}; + static_assert(static_cast(Bus::Access::PARTIAL) == 0, "access combo order"); + static_assert(static_cast(Bus::Access::COMPLETE) == 1, "access combo order"); + static_assert(static_cast(Bus::Access::EMULATE_COMPLETE) == 2, "access combo order"); + + std::string hexDump(std::vector const& data) + { + std::string out; + char byte[4]; + for (auto b : data) + { + std::snprintf(byte, sizeof(byte), "%02X ", b); + out += byte; + } + return out; + } + + std::string formatValue(CoE::DataTypeInfo const& t, std::vector const& data) + { + if (data.empty()) + { + return "(empty)"; + } + if (t.is_string) + { + return std::string(data.begin(), data.end()); + } + if (t.size == 0) // raw-hex (or any variable-length type) + { + return hexDump(data); + } + + if (t.is_real) + { + char rbuf[64]; + if ((t.size == 4) and (data.size() >= 4)) + { + float f = 0.0f; + std::memcpy(&f, data.data(), 4); + std::snprintf(rbuf, sizeof(rbuf), "%g", static_cast(f)); + return rbuf; + } + if ((t.size == 8) and (data.size() >= 8)) + { + double d = 0.0; + std::memcpy(&d, data.data(), 8); + std::snprintf(rbuf, sizeof(rbuf), "%g", d); + return rbuf; + } + return hexDump(data); + } + + uint64_t raw = 0; + int width = t.size; + if (width > static_cast(data.size())) + { + width = static_cast(data.size()); + } + for (int i = 0; i < width; ++i) + { + raw |= static_cast(data[i]) << (8 * i); + } + + char buf[64]; + if (t.is_signed) + { + int64_t sval = static_cast(raw); + int shift = 64 - 8 * width; + // shift unsigned: a signed left-shift into the sign bit is UB. The + // guard drops width 0 and 8, whose shift counts are out of range. + if (shift > 0 and shift < 64) + { + sval = static_cast(raw << shift) >> shift; + } + std::snprintf(buf, sizeof(buf), "%lld (0x%llX)", + static_cast(sval), static_cast(raw)); + } + else + { + std::snprintf(buf, sizeof(buf), "%llu (0x%llX)", + static_cast(raw), static_cast(raw)); + } + return buf; + } + + std::vector bytesFromHex(char const* text) + { + std::vector out; + char const* p = text; + while (*p != '\0') + { + if ((*p == ' ') or (*p == ',') or (*p == ':') or (*p == '\t')) + { + ++p; + continue; + } + if ((p[0] == '0') and ((p[1] == 'x') or (p[1] == 'X'))) + { + p += 2; + continue; + } + if (std::isxdigit(static_cast(*p)) == 0) + { + ++p; // skip a stray non-hex char rather than spin or misparse + continue; + } + char pair[3] = {*p, '\0', '\0'}; + ++p; + if (std::isxdigit(static_cast(*p)) != 0) + { + pair[1] = *p; + ++p; + } + out.push_back(static_cast(std::strtol(pair, nullptr, 16))); + } + return out; + } + + std::vector bytesFromValue(CoE::DataTypeInfo const& t, char const* text, bool& ok) + { + ok = true; + if (t.is_string) + { + return std::vector(text, text + std::strlen(text)); + } + if (t.size == 0) // raw-hex + { + return bytesFromHex(text); + } + if (t.is_real) + { + char* end = nullptr; + double d = std::strtod(text, &end); + ok = (end != text); // at least one char consumed + std::vector out(t.size); + if (t.size == 4) + { + float f = static_cast(d); + std::memcpy(out.data(), &f, 4); + } + else + { + std::memcpy(out.data(), &d, 8); + } + return out; + } + char* end = nullptr; + errno = 0; + long long v = std::strtoll(text, &end, 0); + ok = (end != text) and (errno != ERANGE); // parsed something, not overflowed + std::vector out(t.size); + for (int i = 0; i < t.size; ++i) + { + out[i] = static_cast((static_cast(v) >> (8 * i)) & 0xFF); + } + return out; + } + + uint16_t parseIndex(char const* text) + { + return static_cast(std::strtol(text, nullptr, 0)); + } + } + + bool SdoPanel::appliesTo(Device const& device) const + { + return device.has_coe; + } + + void SdoPanel::render(BusSession&, Device& device) + { + if (not device.sdoAvailable()) + { + ImGui::TextDisabled("Connect to a bus to use SDO."); + return; + } + + auto const& types = writerTypes(); + CoE::DataTypeInfo const& type = types[type_]; + bool transfer_busy = (last_ and not last_->done); + + // --- read / write --- + ImGui::SeparatorText("SDO transfer"); + ImGui::SetNextItemWidth(px(110.0f)); + ImGui::InputText("Index", index_buf_, sizeof(index_buf_)); + ImGui::SameLine(); + ImGui::SetNextItemWidth(px(80.0f)); + ImGui::InputInt("Sub", &subindex_); + if (subindex_ < 0) { subindex_ = 0; } + if (subindex_ > 255) { subindex_ = 255; } + + ImGui::SetNextItemWidth(px(160.0f)); + ImGui::Combo("Access", &access_, ACCESS_LABELS, IM_ARRAYSIZE(ACCESS_LABELS)); + ImGui::SameLine(); + ImGui::SetNextItemWidth(px(150.0f)); + if (ImGui::BeginCombo("Type", typeLabel(type))) + { + for (int i = 0; i < static_cast(types.size()); ++i) + { + bool selected = (i == type_); + if (ImGui::Selectable(typeLabel(types[i]), selected)) + { + type_ = i; + } + } + ImGui::EndCombo(); + } + + // Gate submissions while a transfer is pending: the result is single-slot + // (last_), so a second click would orphan the in-flight result and queue a + // competing mailbox command with no feedback. + ImGui::BeginDisabled(transfer_busy); + if (ImGui::Button("Read")) + { + last_type_ = type_; // freeze the interpretation used for this result + last_ = device.readSDO(parseIndex(index_buf_), + static_cast(subindex_), access_); + } + ImGui::SameLine(); + if (ImGui::Button("Write value")) + { + bool ok = false; + std::vector bytes = bytesFromValue(type, value_buf_, ok); + if (ok) + { + last_ = device.writeSDO(parseIndex(index_buf_), + static_cast(subindex_), access_, bytes); + write_msg_.clear(); + } + else + { + write_msg_ = "invalid numeric value"; + } + } + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1.0f); + ImGui::InputText("##value", value_buf_, sizeof(value_buf_)); + if (not write_msg_.empty()) + { + ImGui::TextDisabled("%s", write_msg_.c_str()); + } + + // --- raw "absolute" entry --- + ImGui::SeparatorText("Absolute (raw bytes, sent verbatim)"); + ImGui::SetNextItemWidth(-px(90.0f)); + ImGui::InputTextWithHint("##raw", "e.g. 0F 00 7A 60", raw_buf_, sizeof(raw_buf_)); + ImGui::SameLine(); + ImGui::BeginDisabled(transfer_busy); + if (ImGui::Button("Write raw")) + { + last_ = device.writeSDO(parseIndex(index_buf_), + static_cast(subindex_), access_, + bytesFromHex(raw_buf_)); + } + ImGui::EndDisabled(); + + // --- result --- + if (last_) + { + if (not last_->done) + { + ImGui::TextDisabled("transfer in progress..."); + } + else if (last_->ok) + { + ImGui::TextColored(COLOR_GREEN, "%s", last_->message.c_str()); + if (not last_->data.empty()) + { + ImGui::Text("value: %s", formatValue(writerTypes()[last_type_], last_->data).c_str()); + ImGui::TextDisabled("bytes: %s", hexDump(last_->data).c_str()); + } + } + else + { + ImGui::TextColored(COLOR_RED, "%s", last_->message.c_str()); + } + } + + // --- object dictionary discovery --- + ImGui::SeparatorText("Object dictionary"); + OdScan const scan = device.odScan(); // one coherent scan per frame + ImGui::BeginDisabled(scan.running); + if (ImGui::Button("Discover OD")) + { + device.discoverOD(); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (scan.running) + { + ImGui::Text("scanning %d / %d...", scan.count, scan.total); + } + else if (scan.scanned) + { + if ((scan.total > 0) and (scan.count < scan.total)) + { + // The scan was aborted (e.g. Back-to-PRE-OP mid-scan): say so instead + // of showing the partial count as if it were the whole dictionary. + ImGui::TextColored(COLOR_YELLOW, "%d / %d objects (incomplete \xe2\x80\x94 re-scan)", + scan.count, scan.total); + } + else + { + ImGui::TextDisabled("%d objects", scan.count); + } + } + else + { + ImGui::TextDisabled("not discovered"); + } + + if (not scan.error.empty()) + { + ImGui::TextColored(COLOR_RED, "%s", scan.error.c_str()); + } + + ImGui::SetNextItemWidth(px(200.0f)); + ImGui::InputTextWithHint("##odfilter", "filter by name/index", filter_buf_, sizeof(filter_buf_)); + + if (scan.scanned) + { + renderOdTable(scan.objects); + } + } + + void SdoPanel::renderOdTable(std::vector const& objects) + { + if (objects.empty()) + { + return; + } + + std::string filter = filter_buf_; + for (auto& c : filter) + { + c = static_cast(std::tolower(c)); + } + + ImGui::TextDisabled("Access: ro/rw/wo = read-only/read-write/write-only; " + "Rx/Tx = PDO-mappable (hover a cell for per-state detail)"); + if (not ImGui::BeginTable("##od", 6, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY, + ImVec2(0.0f, ImGui::GetContentRegionAvail().y))) // fill remaining height + { + return; + } + ImGui::TableSetupColumn("Index", ImGuiTableColumnFlags_WidthFixed, px(64.0f)); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, px(120.0f)); + ImGui::TableSetupColumn("Sub", ImGuiTableColumnFlags_WidthFixed, px(36.0f)); + ImGui::TableSetupColumn("Access", ImGuiTableColumnFlags_WidthFixed, px(72.0f)); + ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableHeadersRow(); + + // Access bits -> "ro"/"rw"/"wo" plus PDO mappability. + auto accessStr = [](uint16_t a) -> std::string + { + bool r = (a & CoE::Access::READ) != 0; + bool w = (a & CoE::Access::WRITE) != 0; + std::string s = "--"; + if (r and w) { s = "rw"; } + else if (r) { s = "ro"; } + else if (w) { s = "wo"; } + if (a & CoE::Access::RxPDO) { s += " Rx"; } + if (a & CoE::Access::TxPDO) { s += " Tx"; } + return s; + }; + // Compact code in the cell, full per-state breakdown on hover. + auto accessCell = [&](uint16_t a) + { + ImGui::TextUnformatted(accessStr(a).c_str()); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", CoE::Access::toString(a).c_str()); + } + }; + + // Value cell: formatted value, else the read error, else a write-only note. + auto valueCell = [&](CoE::DataType data_type, std::vector const& value, + std::string const& err, uint16_t access) + { + if (not value.empty()) + { + ImGui::TextUnformatted(formatValue(writerTypes()[typeIndexFor(data_type)], value).c_str()); + } + else if (not err.empty()) + { + ImGui::TextColored(COLOR_RED, "%s", err.c_str()); + } + else if ((access & CoE::Access::WRITE) and not (access & CoE::Access::READ)) + { + ImGui::TextDisabled("(write-only)"); + } + }; + + for (auto const& obj : objects) + { + if (not filter.empty()) + { + char idx_hex[8]; + std::snprintf(idx_hex, sizeof(idx_hex), "%04x", obj.index); + std::string name_lower = obj.name; + for (auto& c : name_lower) + { + c = static_cast(std::tolower(c)); + } + if ((name_lower.find(filter) == std::string::npos) + and (std::string(idx_hex).find(filter) == std::string::npos)) + { + continue; + } + } + + bool complex = (obj.object_code == CoE::ObjectCode::ARRAY) + or (obj.object_code == CoE::ObjectCode::RECORD); + + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + char label[16]; + std::snprintf(label, sizeof(label), "0x%04X", obj.index); + if (ImGui::Selectable(label, false, ImGuiSelectableFlags_SpanAllColumns)) + { + std::snprintf(index_buf_, sizeof(index_buf_), "0x%04X", obj.index); + subindex_ = 0; + type_ = typeIndexFor(obj.data_type); // prefill the discovered type + } + ImGui::TableNextColumn(); + ImGui::TextUnformatted(obj.name.c_str()); + ImGui::TableNextColumn(); + if (complex) { ImGui::TextUnformatted(CoE::toString(obj.object_code)); } + else { ImGui::TextUnformatted(CoE::toString(obj.data_type)); } + ImGui::TableNextColumn(); + ImGui::Text("%u", obj.max_subindex); + ImGui::TableNextColumn(); + accessCell(obj.access); + ImGui::TableNextColumn(); + valueCell(obj.data_type, obj.value, obj.value_error, obj.access); + + // RECORD/ARRAY sub-entries, indented under the object. + for (auto const& se : obj.entries) + { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + char slabel[24]; + std::snprintf(slabel, sizeof(slabel), " .%u##%04x.%u", se.subindex, obj.index, se.subindex); + if (ImGui::Selectable(slabel, false, ImGuiSelectableFlags_SpanAllColumns)) + { + std::snprintf(index_buf_, sizeof(index_buf_), "0x%04X", obj.index); + subindex_ = se.subindex; + type_ = typeIndexFor(se.data_type); + } + ImGui::TableNextColumn(); + ImGui::TextUnformatted(se.name.c_str()); + ImGui::TableNextColumn(); + ImGui::TextUnformatted(CoE::toString(se.data_type)); + ImGui::TableNextColumn(); + ImGui::Text("%u", se.subindex); + ImGui::TableNextColumn(); + accessCell(se.access); + ImGui::TableNextColumn(); + valueCell(se.data_type, se.value, se.value_error, se.access); + } + } + ImGui::EndTable(); + } +} diff --git a/tools/kickui/SdoPanel.h b/tools/kickui/SdoPanel.h new file mode 100644 index 00000000..a97de6f2 --- /dev/null +++ b/tools/kickui/SdoPanel.h @@ -0,0 +1,47 @@ +#ifndef KICKCAT_TOOLS_KICKUI_SDO_PANEL_H +#define KICKCAT_TOOLS_KICKUI_SDO_PANEL_H + +#include +#include +#include + +#include "BusProtocol.h" +#include "Panel.h" + +namespace kickcat::kickui +{ + // Generic SDO read/write, a raw "absolute" entry, and opt-in OD discovery. + class SdoPanel : public Panel + { + public: + char const* title() const override { return "SDO & Dictionary"; } + bool appliesTo(Device const& device) const override; + void render(BusSession& session, Device& device) override; + + private: + void renderOdTable(std::vector const& objects); + + // InputText backing buffers; sizes are generous fixed caps for the kind + // of text each field holds (hex index, decimal/real value, raw hex dump). + static constexpr int INDEX_BUF = 16; + static constexpr int VALUE_BUF = 128; + static constexpr int RAW_BUF = 256; + static constexpr int FILTER_BUF = 64; + + char index_buf_[INDEX_BUF] = "0x6041"; + int subindex_ = 0; + // Index into ACCESS_LABELS, sent verbatim as Bus::Access (order asserted + // to match the enum next to the labels): 0 PARTIAL, 1 COMPLETE, 2 EMULATE. + int access_ = 0; + int type_ = 1; // datatype index (default UNSIGNED16) + int last_type_ = 1; // type used for the in-hand read result + char value_buf_[VALUE_BUF] = ""; + char raw_buf_[RAW_BUF] = ""; + char filter_buf_[FILTER_BUF] = ""; + + std::shared_ptr last_; + std::string write_msg_; // parse error for the typed value field + }; +} + +#endif diff --git a/tools/kickui/Simulator.cc b/tools/kickui/Simulator.cc new file mode 100644 index 00000000..049f4660 --- /dev/null +++ b/tools/kickui/Simulator.cc @@ -0,0 +1,387 @@ +#include "Simulator.h" + +#include +#include +#include + +#ifdef __linux__ +#include +#include +#include +#include +#include +#include + +#include "kickcat/OS/SharedMemory.h" +#endif + +namespace kickcat::kickui +{ + namespace + { + // Downstream ports handed out to a parent's children in add order; port 0 is + // the upstream link. The 1 -> 3 -> 2 order matches EtherCAT processing order. + constexpr int SIM_DOWNSTREAM_PORTS[3] = {1, 3, 2}; + } + + std::vector SimScene::assignedPorts() const + { + std::vector ports(slaves.size(), -1); + std::vector used(slaves.size(), 0); // downstream ports taken per parent + for (int i = 0; i < static_cast(slaves.size()); ++i) + { + int p = slaves[i].parent; + if (p < 0) { continue; } + if ((p < static_cast(used.size())) and (used[p] < 3)) + { + ports[i] = SIM_DOWNSTREAM_PORTS[used[p]]; + used[p] += 1; + } + } + return ports; + } + + void SimScene::save(char const* path, std::string& message) const + { + std::ofstream f(path); + if (not f) { message = std::string("cannot write ") + path; return; } + int red = 0; + if (redundancy) { red = 1; } + f << "# kickui sim scene\n"; + f << "redundancy " << red << "\n"; + for (SimSlave const& s : slaves) + { + f << "slave " << s.config << " " << s.parent << "\n"; + } + if (not f) + { + message = std::string("write error on ") + path; + return; + } + message = std::string("saved ") + path; + } + + void SimScene::load(char const* path, std::string& message) + { + std::ifstream f(path); + if (not f) { message = std::string("cannot open ") + path; return; } + std::vector loaded; + bool red = false; + std::string tok; + while (f >> tok) + { + if (tok == "redundancy") + { + int v = 0; + f >> v; + red = (v != 0); + } + else if (tok == "slave") + { + SimSlave s; + std::string cfg; + int parent = -1; + f >> cfg >> parent; + std::snprintf(s.config, sizeof(s.config), "%s", cfg.c_str()); + s.parent = parent; + loaded.push_back(s); + } + else + { + std::string rest; // comment / unknown: skip the line + std::getline(f, rest); + } + } + if (loaded.empty()) { message = std::string("no slaves in ") + path; return; } + // Keep the tree acyclic: a parent must reference an earlier slave. + for (int i = 0; i < static_cast(loaded.size()); ++i) + { + if (loaded[i].parent >= i) { loaded[i].parent = -1; } + } + slaves = std::move(loaded); + redundancy = red; + message = std::string("loaded ") + path; + } + + bool SimScene::writeTopologyFile(std::string const& path, std::string& error) const + { + int roots = 0; + int root_index = 0; + for (int i = 0; i < static_cast(slaves.size()); ++i) + { + if (slaves[i].parent < 0) + { + ++roots; + root_index = i; + } + } + if (roots != 1) + { + error = "topology needs exactly one master-connected slave (root)"; + return false; + } + + // Each child gets a DISTINCT downstream port on its parent: two children + // sharing a port would overwrite each other in EmulatedNetwork::connect() + // and silently orphan a slave. + std::vector ports = assignedPorts(); + + std::string json = "{\n"; + json += " \"injection\": {\"node\": " + std::to_string(root_index) + ", \"port\": 0},\n"; + json += " \"links\": [\n"; + bool first = true; + for (int i = 0; i < static_cast(slaves.size()); ++i) + { + SimSlave const& s = slaves[i]; + if (s.parent < 0) { continue; } + if (ports[i] < 0) + { + error = "S" + std::to_string(s.parent) + " has more than 3 children (max 3 downstream ports)"; + return false; + } + if (not first) { json += ",\n"; } + first = false; + json += " {\"a\": " + std::to_string(s.parent) + + ", \"port_a\": " + std::to_string(ports[i]) + + ", \"b\": " + std::to_string(i) + ", \"port_b\": 0}"; + } + json += "\n ]"; + if (redundancy) + { + // The redundant master port closes the ring on the tail slave's + // spare port (port 1, the downstream side of the last slave). + int tail = static_cast(slaves.size()) - 1; + json += ",\n \"redundancy_injection\": {\"node\": " + std::to_string(tail) + ", \"port\": 1}"; + } + json += "\n}\n"; + + std::ofstream out(path); + if (not out.is_open()) + { + error = "cannot write topology file " + path; + return false; + } + out << json; + if (not out) + { + error = "write error on topology file " + path; + return false; + } + return true; + } + + namespace + { + // Visit a configured-tree node and its children in EtherCAT processing + // order (downstream ports 3,1,2 -- the order the master scans), recording + // editor indices. ports[child] is the auto-assigned downstream port. + void dfsEditorOrder(int node, std::vector> const& children, + std::vector const& ports, std::vector& order) + { + order.push_back(node); + auto procRank = [](int port) -> int + { + if (port == 3) { return 0; } + if (port == 1) { return 1; } + if (port == 2) { return 2; } + return 3; + }; + std::vector> ranked; // (processing rank, child) + for (int child : children[node]) + { + ranked.push_back({procRank(ports[child]), child}); + } + std::sort(ranked.begin(), ranked.end(), + [](auto const& a, auto const& b) { return a.first < b.first; }); + for (auto const& pr : ranked) + { + dfsEditorOrder(pr.second, children, ports, order); + } + } + } + + std::vector SimScene::scanToEditorOrder() const + { + int const n = static_cast(slaves.size()); + std::vector> children(n); + int root = -1; + for (int i = 0; i < n; ++i) + { + int p = slaves[i].parent; + if (p < 0) + { + if (root >= 0) { return {}; } // more than one root + root = i; + } + else if (p < i) + { + children[p].push_back(i); + } + else + { + return {}; // forward/self reference: not a valid tree + } + } + if (root < 0) { return {}; } + std::vector order; + dfsEditorOrder(root, children, assignedPorts(), order); + if (static_cast(order.size()) != n) { return {}; } + return order; + } + +#ifdef __linux__ + // Resolve the network_simulator binary next to this executable + // (build/<...>/simulation/network_simulator vs .../tools/kickui/kickui). + std::string SimulatorProcess::simulatorPath() const + { + char buf[PATH_MAX]; + ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1); + if (n <= 0) { return "network_simulator"; } + buf[n] = '\0'; + std::string exe = buf; + std::string dir = exe.substr(0, exe.find_last_of('/')); + return dir + "/../../simulation/network_simulator"; + } + + bool SimulatorProcess::launch(SimScene const& scene, std::string& error) + { + if (pid_ > 0) { return true; } + if (scene.slaves.empty()) + { + error = "no slaves configured"; + return false; + } + + // Per-PID path so concurrent KickUI instances don't clobber each other. + topo_path_ = "/tmp/kickui_topology_" + std::to_string(getpid()) + ".json"; + if (not scene.writeTopologyFile(topo_path_, error)) { return false; } + + // Stale tap shm from a crashed sim desyncs the frame stream -> connect fails on WKC. + ::unlink("/dev/shm/tap_nominal"); + ::unlink("/dev/shm/tap_redundant"); + + // Control bus, created + stamped before the fork so the child can attach. + control_shm_ = "/kickui_ctrl_" + std::to_string(getpid()); + control_ = std::make_unique(); + try + { + control_->open(control_shm_); + } + catch (std::exception const& e) + { + error = std::string("control channel: ") + e.what(); + releaseControl(); // drop the (possibly half-created) segment + ::unlink(topo_path_.c_str()); // topology file was written just above + topo_path_.clear(); + return false; + } + + std::string bin = simulatorPath(); + // network_simulator -i tap:server [-r tap:server] --control --topology -s ... + // --topology / -s come last; -s consumes the remaining arguments. + std::vector args = {bin, "-i", "tap:server"}; + if (scene.redundancy) + { + args.push_back("-r"); + args.push_back("tap:server"); // redundant pair uses the default tap_redundant shm + } + args.push_back("--control"); + args.push_back(control_shm_); + args.push_back("--topology"); + args.push_back(topo_path_); + args.push_back("-s"); + for (auto const& s : scene.slaves) { args.push_back(s.config); } + std::vector argv; + argv.reserve(args.size() + 1); + for (auto& a : args) { argv.push_back(a.data()); } + argv.push_back(nullptr); + + pid_t pid = fork(); + if (pid < 0) + { + releaseControl(); + ::unlink(topo_path_.c_str()); + topo_path_.clear(); + error = "fork failed"; + return false; + } + if (pid == 0) + { + // Die with the GUI even on an abnormal exit (Ctrl-C, kill, crash) + // that never runs the destructors. Re-check the parent didn't + // already exit between fork and here, else we'd miss the signal. + prctl(PR_SET_PDEATHSIG, SIGTERM); + if (getppid() == 1) { _exit(0); } + execv(bin.c_str(), argv.data()); + _exit(127); // exec failed + } + pid_ = pid; + broken_links_.clear(); + return true; + } + + void SimulatorProcess::stop() + { + if (pid_ <= 0) { return; } + kill(pid_, SIGTERM); + waitpid(pid_, nullptr, 0); + pid_ = -1; + releaseControl(); + if (not topo_path_.empty()) + { + ::unlink(topo_path_.c_str()); + topo_path_.clear(); // match reap()'s postcondition + } + } + + bool SimulatorProcess::reap() + { + if (pid_ <= 0) { return false; } + int status = 0; + if (waitpid(pid_, &status, WNOHANG) == pid_) // exited on its own + { + pid_ = -1; + releaseControl(); + if (not topo_path_.empty()) + { + ::unlink(topo_path_.c_str()); + topo_path_.clear(); + } + return true; + } + return false; + } + + void SimulatorProcess::releaseControl() + { + control_.reset(); + if (not control_shm_.empty()) + { + SharedMemory::unlink(control_shm_); + control_shm_.clear(); + } + } + + bool SimulatorProcess::nextEvent(sim::ControlEvent& out) + { + if (not control_) { return false; } + return control_->nextEvent(out); + } + + void SimulatorProcess::setLinkBroken(int a, int b, bool broken) + { + if ((not control_) or (a < 0) or (b < 0) or (a == b)) { return; } + if (broken) + { + control_->breakLink(static_cast(a), static_cast(b)); + } + else + { + control_->healLink(static_cast(a), static_cast(b)); + } + auto key = std::make_pair(std::min(a, b), std::max(a, b)); + if (broken) { broken_links_.insert(key); } + else { broken_links_.erase(key); } + } +#endif +} diff --git a/tools/kickui/Simulator.h b/tools/kickui/Simulator.h new file mode 100644 index 00000000..7dc6a0d4 --- /dev/null +++ b/tools/kickui/Simulator.h @@ -0,0 +1,90 @@ +#ifndef KICKCAT_TOOLS_KICKUI_SIMULATOR_H +#define KICKCAT_TOOLS_KICKUI_SIMULATOR_H + +#include +#include +#include +#include +#include + +#include "kickcat/SimulatorControl.h" + +namespace kickcat::kickui +{ + // One row of the sim-scene editor: the slave's config file + its parent in + // the tree (-1 = master/root; else an EARLIER slave index, keeping it acyclic). + struct SimSlave + { + char config[256] = "simulation/slave_configs/ecat402-drive.json"; + int parent = -1; + }; + + // The simulator editor scene: slave rows + ring flag, scene persistence, the + // network_simulator topology file, and the editor<->scan order mapping. + class SimScene + { + public: + std::vector slaves{SimSlave{}}; + bool redundancy = false; // ring: -r + redundancy_injection + + // Per-slave downstream port on its parent; -1 for the root or when the + // parent has no free port left (more than 3 children). + std::vector assignedPorts() const; + + // Save/load the editor scene as a small text file, so a topology is easy + // to reproduce and share. + void save(char const* path, std::string& message) const; + void load(char const* path, std::string& message); + + // Serialize into a network_simulator --topology file: a master injection + // point on the root + one connect() edge per non-root slave. False (and + // sets error) if the tree is not a single segment. + bool writeTopologyFile(std::string const& path, std::string& error) const; + + // scan position -> editor S#, from the configured tree (the master scans + // in EtherCAT processing order, not add order). Empty if the editor is + // not a single-rooted valid tree. + std::vector scanToEditorOrder() const; + }; + +#ifdef __linux__ + // The network_simulator child process: launch/stop/reap plus the out-of-band + // control bus used to break/heal links at runtime. + class SimulatorProcess + { + public: + ~SimulatorProcess() { stop(); } + + bool running() const { return pid_ > 0; } + int pid() const { return pid_; } + + // Launch with the given scene (topology file path is per-PID). The caller + // must have released any live session on the tap first. + bool launch(SimScene const& scene, std::string& error); + void stop(); // SIGTERM + waitpid; no-op when not running + bool reap(); // true when the child exited on its own since the last call + + // Break/heal the wire between two SIM slave indices and remember it so + // the link draws broken until healed. No-op without a running simulator. + void setLinkBroken(int a, int b, bool broken); + std::set> const& brokenLinks() const { return broken_links_; } + + // Pop one message from the simulator's return stream (acks + events). + // False when nothing is pending (or when not running). The caller drains + // in a loop each frame and dispatches on `out.type`. + bool nextEvent(sim::ControlEvent& out); + + private: + std::string simulatorPath() const; + void releaseControl(); // tear down the client + unlink the segment + + int pid_ = -1; + std::string topo_path_; // temp topology file, written on launch + std::string control_shm_; // control-bus segment name (per-PID) + std::unique_ptr control_; // created per launch + std::set> broken_links_; // sim-index pairs (a(al_status & AL_STATE_MASK)); + } + + ImVec4 esmColor(uint8_t al_status) + { + if (al_status & AL_ERROR_BIT) + { + return COLOR_RED; + } + State st = static_cast(al_status & AL_STATE_MASK); + if (st == State::OPERATIONAL) { return COLOR_ESM_OP; } + if (st == State::SAFE_OP) { return COLOR_ESM_SAFEOP; } + if (st == State::PRE_OP) { return COLOR_ESM_PREOP; } + if (st == State::INIT) { return COLOR_ESM_INIT; } + return COLOR_ESM_OTHER; + } + + ImVec4 esmColor(uint8_t al_status, bool bus_lost) + { + if (bus_lost) { return COLOR_ESM_OTHER; } + return esmColor(al_status); + } + + std::array const STATE_BUTTONS = {{ + {"INIT", State::INIT}, + {"PRE-OP", State::PRE_OP}, + {"SAFE-OP", State::SAFE_OP}, + {"OP", State::OPERATIONAL}, + }}; + + void renderRedundancyStatus(bool active) + { + if (active) + { + ImGui::TextColored(COLOR_YELLOW, "%s", REDUNDANCY_ACTIVE_TEXT); + } + else + { + ImGui::TextColored(COLOR_GREEN, "%s", REDUNDANCY_OK_TEXT); + } + } +} diff --git a/tools/kickui/Theme.h b/tools/kickui/Theme.h new file mode 100644 index 00000000..dcf7cc55 --- /dev/null +++ b/tools/kickui/Theme.h @@ -0,0 +1,62 @@ +#ifndef KICKCAT_TOOLS_KICKUI_THEME_H +#define KICKCAT_TOOLS_KICKUI_THEME_H + +#include +#include + +#include "imgui.h" + +#include "kickcat/protocol.h" + +namespace kickcat::kickui +{ + // Fixed-width font for streaming numbers (set once from GuiApp::monoFont() in + // main); null until then, callers fall back to the default font. + extern ImFont* g_mono_font; + + // HiDPI UI scale (set from GuiApp::scale() in main). ImGui scales the font + // and style but not hardcoded dimensions. + extern float g_ui_scale; + + // Explicit pixel sizes (fixed table columns, item widths) go through this. + float px(float v); + + extern ImVec4 const COLOR_RED; + extern ImVec4 const COLOR_GREEN; + extern ImVec4 const COLOR_YELLOW; + extern ImVec4 const COLOR_DS402; + + // Per-ESM-state palette (shared by the topology borders and the legend). + extern ImVec4 const COLOR_ESM_INIT; + extern ImVec4 const COLOR_ESM_PREOP; + extern ImVec4 const COLOR_ESM_SAFEOP; + extern ImVec4 const COLOR_ESM_OP; + extern ImVec4 const COLOR_ESM_OTHER; + + // AL status byte layout (ETG.1000): low nibble = state, bit 4 = error. + constexpr uint8_t AL_STATE_MASK = State::MASK_STATE; // 0x0F + constexpr uint8_t AL_ERROR_BIT = State::ERROR_ACK; // 0x10 + + char const* stateLabel(uint8_t al_status); + + // ESM colour from a raw AL status byte: the error bit wins, else by state. + ImVec4 esmColor(uint8_t al_status); + // Greyed to "unknown" when the bus has stopped responding. + ImVec4 esmColor(uint8_t al_status, bool bus_lost); + + struct StateButton + { + char const* label; + State state; + }; + // EtherCAT state buttons for the common strip and the topology context menu. + extern std::array const STATE_BUTTONS; + + // One wording (and one colour scheme) for the redundancy status, everywhere. + constexpr char const* REDUNDANCY_ACTIVE_TEXT = + "Cable redundancy: ACTIVE \xe2\x80\x94 a cable is broken (ring still closed)"; + constexpr char const* REDUNDANCY_OK_TEXT = "Cable redundancy: ring intact"; + void renderRedundancyStatus(bool active); +} + +#endif diff --git a/tools/kickui/TopologyView.cc b/tools/kickui/TopologyView.cc new file mode 100644 index 00000000..fcf2da64 --- /dev/null +++ b/tools/kickui/TopologyView.cc @@ -0,0 +1,679 @@ +#include "TopologyView.h" + +#include +#include +#include + +#include "BusSession.h" +#include "Theme.h" + +namespace kickcat::kickui +{ + PortRouting computePortRouting(TopologyInfo const& topo) + { + int const N = static_cast(topo.nodes.size()); + PortRouting r; + r.upstream.assign(N, -1); + r.parent_port.assign(N, -1); + for (int i = 0; i < N; ++i) + { + for (int p = 0; p < 4; ++p) + { + if (topo.nodes[i].port_com[p]) { r.upstream[i] = p; break; } + } + } + + // Downstream ports in EtherCAT processing order (0 -> 3 -> 1 -> 2), + // skipping the upstream/entry port. getTopology discovers children in + // that same order, so child[k] leaves the parent by down[k] -- collecting + // them numerically (1,2,3) mislabels branches (e.g. a port-3 branch + // shown as port 1). + static constexpr int PORT_ORDER[4] = {0, 3, 1, 2}; + for (int i = 0; i < N; ++i) + { + std::vector down; + for (int oi = 0; oi < 4; ++oi) + { + int p = PORT_ORDER[oi]; + if (topo.nodes[i].port_com[p] and (p != r.upstream[i])) { down.push_back(p); } + } + int k = 0; + for (int j = 0; j < N; ++j) + { + if (j == i) { continue; } + if (topo.nodes[j].parent_address == topo.nodes[i].address) + { + if (k < static_cast(down.size())) { r.parent_port[j] = down[k]; } + else { r.parent_port[j] = 1; } + k += 1; + } + } + } + return r; + } + + ImVec4 TopologyView::slaveStateColor(int slave_index) const + { + return esmColor(session_->slaveAlStatus(slave_index), bus_lost_); + } + + bool TopologyView::linkBroken(int a, int b) const + { + if ((sim_->broken_links == nullptr) or (a < 0) or (b < 0)) { return false; } + return sim_->broken_links->count(std::make_pair(std::min(a, b), std::max(a, b))) > 0; + } + + // ESM colour key for the topology view. + void TopologyView::renderEsmLegend() + { + struct LegendItem { ImVec4 color; char const* label; }; + LegendItem const items[] = { + {COLOR_ESM_INIT, "INIT"}, + {COLOR_ESM_PREOP, "PRE-OP"}, + {COLOR_ESM_SAFEOP, "SAFE-OP"}, + {COLOR_ESM_OP, "OP"}, + {COLOR_RED, "Error"}, + }; + for (int i = 0; i < static_cast(IM_ARRAYSIZE(items)); ++i) + { + if (i > 0) { ImGui::SameLine(0.0f, 12.0f); } + ImGui::TextColored(items[i].color, "\xe2\x97\x8f"); // filled circle + ImGui::SameLine(0.0f, 4.0f); + ImGui::TextUnformatted(items[i].label); + } + } + + // One slave's four ESC ports, as the master reads them from DL_STATUS: + // green "up" = physical link + communicating, "loop" = open/looped back. + void TopologyView::renderPortChips(TopologyNode const& node) + { + for (int p = 0; p < 4; ++p) + { + if (p > 0) { ImGui::SameLine(); } + if (node.port_com[p]) + { + ImGui::TextColored(COLOR_GREEN, "P%d:up", p); + } + else if (node.port_loop[p]) + { + ImGui::TextDisabled("P%d:loop", p); + } + else + { + ImGui::TextDisabled("P%d:off", p); + } + } + } + + void TopologyView::drawTreeNode(TopologyInfo const& topo, + std::unordered_map> const& children_of, + int i) + { + TopologyNode const& node = topo.nodes[i]; + auto it = children_of.find(node.address); + bool has_children = (it != children_of.end()) and (not it->second.empty()); + + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_DefaultOpen | ImGuiTreeNodeFlags_OpenOnArrow; + if (not has_children) + { + flags |= ImGuiTreeNodeFlags_Leaf; + } + + bool open = ImGui::TreeNodeEx(reinterpret_cast(static_cast(node.address)), + flags, "S%d @%u %s", label(node.index), node.address, node.name.c_str()); + ImGui::SameLine(); + ImGui::TextDisabled(" "); + ImGui::SameLine(); + renderPortChips(node); + + if (open) + { + if (has_children) + { + for (int child : it->second) + { + drawTreeNode(topo, children_of, child); + } + } + ImGui::TreePop(); + } + } + + // Assign each node a column (leaf order; parents centre over children) and + // a row (depth). Returns this node's column so the parent can centre on it. + float TopologyView::layoutNode(TopologyInfo const& topo, + std::unordered_map> const& children_of, + int i, int depth, float& next_col, + std::vector& col, std::vector& row) + { + row[i] = depth; + auto it = children_of.find(topo.nodes[i].address); + if ((it == children_of.end()) or it->second.empty()) + { + col[i] = next_col; + next_col += 1.0f; + return col[i]; + } + float first = 0.0f; + float last = 0.0f; + bool seen = false; + for (int child : it->second) + { + float c = layoutNode(topo, children_of, child, depth + 1, next_col, col, row); + if (not seen) { first = c; seen = true; } + last = c; + } + col[i] = (first + last) * 0.5f; + return col[i]; + } + + // A bus diagram drawn like the physical layer: each ESC is a square with + // its four ports on the faces (0 top, 1 bottom, 2 left, 3 right) and cables + // run port-to-port. The port mapping comes from computePortRouting(). + void TopologyView::renderGraph(TopologyInfo const& topo, + std::unordered_map> const& children_of, + std::vector const& roots) + { + int const N = static_cast(topo.nodes.size()); + + PortRouting routing = computePortRouting(topo); + auto upstream = [&](int i) -> int + { + if (routing.upstream[i] < 0) { return 0; } // no com port: draw from port 0 + return routing.upstream[i]; + }; + + // Horizontal placement follows the port the child hangs off: a left + // port (2) goes to the left, a right port (3) to the right, the main + // downstream (1) straight below. So the cable leaves the actual port + // toward where the child sits, instead of crossing back over siblings. + auto portRank = [](int p) -> int + { + if (p == 2) { return 0; } // left + if (p == 3) { return 2; } // right + return 1; // down / default centre + }; + auto children = children_of; // local, sortable copy + for (auto& entry : children) + { + std::sort(entry.second.begin(), entry.second.end(), + [&](int a, int b) { return portRank(routing.parent_port[a]) < portRank(routing.parent_port[b]); }); + } + + std::vector col(N, 0.0f); + std::vector row(N, 0); + float next_col = 0.0f; + for (int r : roots) + { + layoutNode(topo, children, r, 1, next_col, col, row); // master is row 0 + } + int max_row = 1; + for (int r : row) { if (r > max_row) { max_row = r; } } + float n_cols = next_col; + if (n_cols < 1.0f) { n_cols = 1.0f; } + + constexpr float BOX = 78.0f; // ESC square + constexpr float CELL_W = 150.0f; // box + horizontal room for branch cables + constexpr float CELL_H = 130.0f; // box + vertical room for the cable run + constexpr float PAD = 24.0f; + float total_w = n_cols * CELL_W + 2.0f * PAD; + float total_h = (max_row + 1) * CELL_H + 2.0f * PAD; + + float avail_h = ImGui::GetContentRegionAvail().y; + float canvas_h = avail_h - 90.0f; + if (canvas_h < 200.0f) { canvas_h = 200.0f; } + if (total_h + 12.0f < canvas_h) { canvas_h = total_h + 12.0f; } + + ImGui::BeginChild("##topo_canvas", ImVec2(0.0f, canvas_h), + ImGuiChildFlags_Borders | ImGuiChildFlags_ResizeY, + ImGuiWindowFlags_HorizontalScrollbar); + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImVec2 origin = ImGui::GetCursorScreenPos(); + + // Box top-left from grid (col centred within its cell), and the centre + // of a given port's face. + auto boxTL = [&](float c, int r) -> ImVec2 + { + return ImVec2(origin.x + PAD + c * CELL_W + (CELL_W - BOX) * 0.5f, + origin.y + PAD + r * CELL_H); + }; + auto portPt = [&](ImVec2 tl, int p) -> ImVec2 + { + if (p == 0) { return ImVec2(tl.x + BOX * 0.5f, tl.y); } + if (p == 1) { return ImVec2(tl.x + BOX * 0.5f, tl.y + BOX); } + if (p == 2) { return ImVec2(tl.x, tl.y + BOX * 0.5f); } + return ImVec2(tl.x + BOX, tl.y + BOX * 0.5f); + }; + auto portStub = [&](ImVec2 tl, int p) -> ImVec2 + { + ImVec2 q = portPt(tl, p); + if (p == 0) { q.y -= 14.0f; } + if (p == 1) { q.y += 14.0f; } + if (p == 2) { q.x -= 14.0f; } + if (p == 3) { q.x += 14.0f; } + return q; + }; + + float master_col = (n_cols - 1.0f) * 0.5f; + ImVec2 master_tl = boxTL(master_col, 0); + + // Cables first, so boxes paint over the endpoints. A cable is red when + // the child's upstream port is not communicating (broken / redundant). + auto cableColor = [&](int child) -> ImU32 + { + if (topo.nodes[child].port_com[upstream(child)]) { return ImGui::GetColorU32(ImVec4(0.45f, 0.72f, 0.55f, 1.0f)); } + return ImGui::GetColorU32(COLOR_RED); + }; + // Collect each cable's polyline + its SIM slave endpoints so a right-click + // can hit-test it and break/heal the wire. Master end = -1 (the tap link is + // not a sim node, so it can't be broken). + struct Cable + { + int a = -1; // sim slave index (-1 = master) + int b = -1; + ImVec2 p[4]; + }; + std::vector cables; + + // Each cable leaves the parent's actual port face, runs to the child's + // entry-port face via short stubs. Children are placed on the side + // their port faces (above), so these stay short and don't cross. + auto drawCable = [&](ImVec2 from_face, ImVec2 from_stub, ImVec2 to_stub, ImVec2 to_face, ImU32 c) + { + dl->AddLine(from_face, from_stub, c, 2.0f); + dl->AddLine(from_stub, to_stub, c, 2.0f); + dl->AddLine(to_stub, to_face, c, 2.0f); + }; + // Red X at a broken cable's midpoint so the break LOCATION is unmistakable. + ImU32 const red = ImGui::GetColorU32(COLOR_RED); + auto markBreak = [&](ImVec2 a, ImVec2 b) + { + ImVec2 m{(a.x + b.x) * 0.5f, (a.y + b.y) * 0.5f}; + float r = 6.0f; + dl->AddCircleFilled(m, r + 2.0f, ImGui::GetColorU32(ImVec4(0.0f, 0.0f, 0.0f, 0.7f))); + dl->AddLine(ImVec2(m.x - r, m.y - r), ImVec2(m.x + r, m.y + r), red, 2.5f); + dl->AddLine(ImVec2(m.x - r, m.y + r), ImVec2(m.x + r, m.y - r), red, 2.5f); + }; + + for (int r : roots) // master -> root upstream port + { + ImVec2 rtl = boxTL(col[r], row[r]); + int up = upstream(r); + ImVec2 mf{master_tl.x + BOX * 0.5f, master_tl.y + BOX}; + ImVec2 ms{master_tl.x + BOX * 0.5f, master_tl.y + BOX + 14.0f}; + drawCable(mf, ms, portStub(rtl, up), portPt(rtl, up), cableColor(r)); + cables.push_back(Cable{-1, label(topo.nodes[r].index), {mf, ms, portStub(rtl, up), portPt(rtl, up)}}); + if (not topo.nodes[r].port_com[up]) { markBreak(ms, portStub(rtl, up)); } + } + for (int i = 0; i < N; ++i) + { + auto it = children.find(topo.nodes[i].address); + if (it == children.end()) { continue; } + ImVec2 ptl = boxTL(col[i], row[i]); + for (int child : it->second) + { + ImVec2 ctl = boxTL(col[child], row[child]); + int pp = routing.parent_port[child]; + int cp = upstream(child); + int sa = label(topo.nodes[i].index); + int sb = label(topo.nodes[child].index); + bool down = (not topo.nodes[child].port_com[cp]) or linkBroken(sa, sb); + ImU32 c = cableColor(child); + if (linkBroken(sa, sb)) + { + c = red; + } + drawCable(portPt(ptl, pp), portStub(ptl, pp), portStub(ctl, cp), portPt(ctl, cp), c); + cables.push_back(Cable{sa, sb, {portPt(ptl, pp), portStub(ptl, pp), portStub(ctl, cp), portPt(ctl, cp)}}); + if (down) { markBreak(portStub(ptl, pp), portStub(ctl, cp)); } + } + } + + // Master box. + dl->AddRectFilled(master_tl, ImVec2(master_tl.x + BOX, master_tl.y + BOX), + ImGui::GetColorU32(ImVec4(0.20f, 0.28f, 0.42f, 1.0f)), 5.0f); + dl->AddRect(master_tl, ImVec2(master_tl.x + BOX, master_tl.y + BOX), + ImGui::GetColorU32(ImVec4(0.50f, 0.60f, 0.90f, 1.0f)), 5.0f, 0, 2.0f); + ImVec2 msz = ImGui::CalcTextSize("Master"); + dl->AddText(ImVec2(master_tl.x + (BOX - msz.x) * 0.5f, master_tl.y + (BOX - msz.y) * 0.5f), + ImGui::GetColorU32(ImVec4(0.92f, 0.92f, 0.95f, 1.0f)), "Master"); + + for (int i = 0; i < N; ++i) + { + TopologyNode const& n = topo.nodes[i]; + ImVec2 tl = boxTL(col[i], row[i]); + + ImGui::SetCursorScreenPos(tl); + ImGui::PushID(i); + ImGui::InvisibleButton("##node", ImVec2(BOX, BOX)); + bool hovered = ImGui::IsItemHovered(); + if (ImGui::IsItemClicked()) { session_->select(n.index); } + // Right-click: set this slave's EtherCAT state. + if (ImGui::BeginPopupContextItem("##esm")) + { + session_->select(n.index); + ImGui::TextDisabled("S%d \xe2\x86\x92 state", label(n.index)); + ImGui::Separator(); + auto& devs = session_->devices(); + if ((n.index >= 0) and (n.index < static_cast(devs.size()))) + { + for (auto const& btn : STATE_BUTTONS) + { + if (ImGui::MenuItem(btn.label)) + { + devs[n.index].requestState(static_cast(btn.state)); + } + } + } + ImGui::EndPopup(); + } + ImGui::PopID(); + + ImU32 fill = ImGui::GetColorU32(ImVec4(0.16f, 0.17f, 0.20f, 1.0f)); + if (n.index == session_->selected()) + { + fill = ImGui::GetColorU32(ImVec4(0.24f, 0.27f, 0.34f, 1.0f)); + } + float border_w = 2.0f; + if (hovered) { border_w = 3.0f; } + dl->AddRectFilled(tl, ImVec2(tl.x + BOX, tl.y + BOX), fill, 5.0f); + dl->AddRect(tl, ImVec2(tl.x + BOX, tl.y + BOX), + ImGui::GetColorU32(slaveStateColor(n.index)), 5.0f, 0, border_w); + + char head[24]; + snprintf(head, sizeof(head), "S%d", label(n.index)); + char addr[16]; + snprintf(addr, sizeof(addr), "%u", n.address); + ImVec2 hsz = ImGui::CalcTextSize(head); + ImVec2 asz = ImGui::CalcTextSize(addr); + float line_h = ImGui::GetTextLineHeight(); + float top = tl.y + (BOX - (line_h * 2.0f + 3.0f)) * 0.5f; + dl->AddText(ImVec2(tl.x + (BOX - hsz.x) * 0.5f, top), + ImGui::GetColorU32(ImVec4(0.90f, 0.90f, 0.92f, 1.0f)), head); + dl->AddText(ImVec2(tl.x + (BOX - asz.x) * 0.5f, top + line_h + 3.0f), + ImGui::GetColorU32(ImVec4(0.62f, 0.64f, 0.68f, 1.0f)), addr); + + // Port markers on the four faces, coloured by link state, labelled. + for (int p = 0; p < 4; ++p) + { + ImVec2 c = portPt(tl, p); + ImU32 pc = ImGui::GetColorU32(ImVec4(0.32f, 0.32f, 0.36f, 1.0f)); // off + if (n.port_com[p]) { pc = ImGui::GetColorU32(COLOR_GREEN); } + else if (n.port_loop[p]) { pc = ImGui::GetColorU32(ImVec4(0.48f, 0.48f, 0.54f, 1.0f)); } + dl->AddRectFilled(ImVec2(c.x - 6.0f, c.y - 6.0f), ImVec2(c.x + 6.0f, c.y + 6.0f), pc, 2.0f); + dl->AddRect(ImVec2(c.x - 6.0f, c.y - 6.0f), ImVec2(c.x + 6.0f, c.y + 6.0f), + ImGui::GetColorU32(ImVec4(0.0f, 0.0f, 0.0f, 0.6f)), 2.0f); + char pn[2] = {static_cast('0' + p), '\0'}; + dl->AddText(ImVec2(c.x - 3.5f, c.y - 7.0f), + ImGui::GetColorU32(ImVec4(0.05f, 0.05f, 0.05f, 1.0f)), pn); + } + + if (hovered) + { + ImGui::SetTooltip("S%d @%u %s\nopen ports: %d (click to select)", + label(n.index), n.address, n.name.c_str(), n.open_ports); + } + } + + // Right-click a cable (not a box) to break/heal that wire. Sim-only: the + // emulator breaks the link, then the master discovers the change (redundancy + // activates, or downstream goes dark) -- exactly like a real cable fault. + if (sim_->running and ImGui::IsWindowHovered() and (not ImGui::IsAnyItemHovered()) + and ImGui::IsMouseClicked(ImGuiMouseButton_Right)) + { + ImVec2 m = ImGui::GetMousePos(); + auto segDist2 = [](ImVec2 p, ImVec2 a, ImVec2 b) -> float + { + float vx = b.x - a.x, vy = b.y - a.y; + float wx = p.x - a.x, wy = p.y - a.y; + float len2 = vx * vx + vy * vy; + float t = 0.0f; + if (len2 > 0.0f) + { + t = (wx * vx + wy * vy) / len2; + } + if (t < 0.0f) { t = 0.0f; } + if (t > 1.0f) { t = 1.0f; } + float dx = p.x - (a.x + t * vx), dy = p.y - (a.y + t * vy); + return dx * dx + dy * dy; + }; + int best = -1; + float best_d2 = 100.0f; // (10 px)^2 pick radius + for (int ci = 0; ci < static_cast(cables.size()); ++ci) + { + for (int s = 0; s < 3; ++s) + { + float d2 = segDist2(m, cables[ci].p[s], cables[ci].p[s + 1]); + if (d2 < best_d2) + { + best_d2 = d2; + best = ci; + } + } + } + if (best >= 0) + { + link_ctx_a_ = cables[best].a; + link_ctx_b_ = cables[best].b; + link_ctx_master_ = (cables[best].a < 0); + ImGui::OpenPopup("##link_ctx"); + } + } + if (ImGui::BeginPopup("##link_ctx")) + { + if (link_ctx_master_) + { + ImGui::TextDisabled("Master cable"); + ImGui::Separator(); + ImGui::TextDisabled("cannot be cut (it is the tap link,\nnot a simulated wire)"); + } + else + { + ImGui::TextDisabled("Link S%d \xe2\x80\x94 S%d", link_ctx_a_, link_ctx_b_); + ImGui::Separator(); + if (linkBroken(link_ctx_a_, link_ctx_b_)) + { + if (ImGui::MenuItem("Heal link") and sim_->set_link_broken) + { + sim_->set_link_broken(link_ctx_a_, link_ctx_b_, false); + } + } + else + { + if (ImGui::MenuItem("Break link") and sim_->set_link_broken) + { + sim_->set_link_broken(link_ctx_a_, link_ctx_b_, true); + } + } + } + ImGui::EndPopup(); + } + + ImGui::SetCursorScreenPos(origin); + ImGui::Dummy(ImVec2(total_w, total_h)); // reserve the scroll region + ImGui::EndChild(); + } + + // Dump the configured (sim-editor) tree and the discovered (master-POV) + // tree to the clipboard + /tmp/kickui_topology.txt, for side-by-side + // comparison when the discovered wiring looks wrong. + void TopologyView::exportText(TopologyInfo const& topo) + { + std::string out = "== Configured (sim editor) ==\n"; + { + std::vector ports = scene_->assignedPorts(); + for (int i = 0; i < static_cast(scene_->slaves.size()); ++i) + { + SimSlave const& s = scene_->slaves[i]; + char line[320]; // sized for the 256-byte config path + if (s.parent < 0) + { + std::snprintf(line, sizeof(line), "S%d cfg=%s parent=master port=0\n", i, s.config); + } + else + { + int port = ports[i]; + if (port < 0) { port = 1; } + std::snprintf(line, sizeof(line), "S%d cfg=%s parent=S%d port=%d\n", + i, s.config, s.parent, port); + } + out += line; + } + } + + out += "\n== Discovered (master POV) ==\n"; + int const N = static_cast(topo.nodes.size()); + PortRouting routing = computePortRouting(topo); + std::unordered_map addr_to_index; // for labelling the parent by its S# + for (int i = 0; i < N; ++i) + { + addr_to_index[topo.nodes[i].address] = i; + } + for (int i = 0; i < N; ++i) + { + TopologyNode const& n = topo.nodes[i]; + auto pc = [&](int p) -> char const* + { + if (n.port_com[p]) + { + return "up"; + } + if (n.port_loop[p]) + { + return "loop"; + } + return "off"; + }; + char ports[80]; + std::snprintf(ports, sizeof(ports), "P0:%s P1:%s P2:%s P3:%s", pc(0), pc(1), pc(2), pc(3)); + char line[256]; + if (n.parent_address == n.address) + { + std::snprintf(line, sizeof(line), "S%d @%u parent=master %s open=%d\n", + label(n.index), n.address, ports, n.open_ports); + } + else + { + int parent_label = label(addr_to_index[n.parent_address]); + std::snprintf(line, sizeof(line), "S%d @%u parent=S%d port=%d %s open=%d\n", + label(n.index), n.address, parent_label, routing.parent_port[i], ports, n.open_ports); + } + out += line; + } + if (not topo.valid and not topo.error.empty()) + { + out += "(tree incomplete: " + topo.error + ")\n"; + } + + ImGui::SetClipboardText(out.c_str()); + std::ofstream f("/tmp/kickui_topology.txt"); + if (f) { f << out; } + export_msg_ = "copied to clipboard + /tmp/kickui_topology.txt"; + } + + void TopologyView::render(BusSession& session, SimScene const& scene, + std::function const& label_fn, bool bus_lost, SimHooks const& sim) + { + session_ = &session; + scene_ = &scene; + label_fn_ = &label_fn; + bus_lost_ = bus_lost; + sim_ = ∼ + + TopologyInfo topo = session.topology(); + + if (ImGui::SmallButton("Refresh topology")) + { + session.refreshTopology(); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Export topology")) + { + exportText(topo); + } + ImGui::SameLine(); + ImGui::TextDisabled("master POV \xe2\x80\x94 DL_STATUS port links"); + if (not export_msg_.empty()) + { + ImGui::TextDisabled("%s", export_msg_.c_str()); + } + + if (session.redundancyEnabled()) + { + renderRedundancyStatus(session.redundancyActive()); + } + + // Where it broke: a red cable in the graph below is a link whose child + // does not communicate on its entry port. List them (and any sim-injected + // breaks) so the location is explicit, not just visual. + PortRouting routing = computePortRouting(topo); + std::string broken; + for (int i = 0; i < static_cast(topo.nodes.size()); ++i) + { + // Discovered but no communicating port = isolated/break. + if ((routing.upstream[i] < 0) and (topo.nodes[i].index >= 0)) + { + if (not broken.empty()) { broken += ", "; } + broken += "S" + std::to_string(label(topo.nodes[i].index)); + } + } + if ((sim.broken_links != nullptr) and (not sim.broken_links->empty())) + { + std::string inj; + for (auto const& pr : *sim.broken_links) + { + if (not inj.empty()) { inj += ", "; } + inj += "S" + std::to_string(pr.first) + "\xe2\x80\x94S" + std::to_string(pr.second); + } + ImGui::TextColored(COLOR_RED, "Broken link(s): %s", inj.c_str()); + } + if (not broken.empty()) + { + ImGui::TextColored(COLOR_RED, "No link on: %s (break upstream \xe2\x80\x94 see red cable below)", broken.c_str()); + } + + if (topo.nodes.empty()) + { + ImGui::TextDisabled("No slaves discovered."); + return; + } + if (not topo.valid and not topo.error.empty()) + { + ImGui::TextColored(COLOR_YELLOW, "Tree incomplete: %s", topo.error.c_str()); + } + + // children_of[parent_address] = node indices whose parent is that + // address; a root is a node whose parent is itself (master-connected). + std::unordered_map> children_of; + std::vector roots; + for (int i = 0; i < static_cast(topo.nodes.size()); ++i) + { + TopologyNode const& node = topo.nodes[i]; + if (node.parent_address == node.address) + { + roots.push_back(i); + } + else + { + children_of[node.parent_address].push_back(i); + } + } + + ImGui::Separator(); + renderEsmLegend(); + renderGraph(topo, children_of, roots); + + ImGui::Spacing(); + if (ImGui::CollapsingHeader("Tree (text)", ImGuiTreeNodeFlags_DefaultOpen)) + { + if (ImGui::TreeNodeEx("Master", ImGuiTreeNodeFlags_DefaultOpen)) + { + for (int r : roots) + { + drawTreeNode(topo, children_of, r); + } + ImGui::TreePop(); + } + } + } +} diff --git a/tools/kickui/TopologyView.h b/tools/kickui/TopologyView.h new file mode 100644 index 00000000..d681b7e6 --- /dev/null +++ b/tools/kickui/TopologyView.h @@ -0,0 +1,81 @@ +#ifndef KICKCAT_TOOLS_KICKUI_TOPOLOGY_VIEW_H +#define KICKCAT_TOOLS_KICKUI_TOPOLOGY_VIEW_H + +#include +#include +#include +#include +#include +#include + +#include "imgui.h" + +#include "BusProtocol.h" +#include "Simulator.h" + +namespace kickcat::kickui +{ + class BusSession; + + // Upstream/parent-port inference from DL_STATUS, shared by the graph, the + // text export and the break list: the frame enters a slave on its lowest + // communicating port; the parent reaches each child through its remaining + // communicating ports in EtherCAT processing order (0 -> 3 -> 1 -> 2). + struct PortRouting + { + std::vector upstream; // entry port per node (-1 = no communicating port) + std::vector parent_port; // port on the parent feeding this node (-1 = root) + }; + PortRouting computePortRouting(TopologyInfo const& topo); + + // The "Bus Topology" tab: legend, drawn graph (boxes + cables + break marks + + // break/heal context menu), text tree, and the clipboard/file export. + class TopologyView + { + public: + // Wiring back into the simulator (all optional: inactive without a sim). + struct SimHooks + { + bool running = false; + std::set> const* broken_links = nullptr; // sim S# pairs (a set_link_broken; // sim S# pair -> break/heal + }; + + // label maps a scan index to the displayed S#; bus_lost greys the ESM + // colours (stale states must not look live). + void render(BusSession& session, SimScene const& scene, + std::function const& label, bool bus_lost, SimHooks const& sim); + + private: + int label(int scan_index) const { return (*label_fn_)(scan_index); } + ImVec4 slaveStateColor(int slave_index) const; + bool linkBroken(int a, int b) const; + + void renderEsmLegend(); + void renderPortChips(TopologyNode const& node); + void drawTreeNode(TopologyInfo const& topo, + std::unordered_map> const& children_of, int i); + float layoutNode(TopologyInfo const& topo, + std::unordered_map> const& children_of, + int i, int depth, float& next_col, + std::vector& col, std::vector& row); + void renderGraph(TopologyInfo const& topo, + std::unordered_map> const& children_of, + std::vector const& roots); + void exportText(TopologyInfo const& topo); + + // Set at the top of render(); valid for the duration of the frame. + BusSession* session_ = nullptr; + SimScene const* scene_ = nullptr; + std::function const* label_fn_ = nullptr; + bool bus_lost_ = false; + SimHooks const* sim_ = nullptr; + + std::string export_msg_; // confirmation after Export topology + int link_ctx_a_ = -1; // cable picked for the break/heal popup + int link_ctx_b_ = -1; + bool link_ctx_master_ = false; + }; +} + +#endif diff --git a/tools/kickui/main.cc b/tools/kickui/main.cc new file mode 100644 index 00000000..710ba98c --- /dev/null +++ b/tools/kickui/main.cc @@ -0,0 +1,1213 @@ +#include +#include +#include +#include +#include +#include +#include + +#ifdef __linux__ +#include +#endif + +#include "imgui.h" + +#include "GuiApp.h" + +#include "BusProtocol.h" +#include "BusSession.h" +#include "EtherCATPanel.h" +#include "EventLog.h" +#include "MotorPanel.h" +#include "Panel.h" +#include "PdoValuesPanel.h" +#include "PrivilegeHelper.h" +#include "SdoPanel.h" +#include "Simulator.h" +#include "Theme.h" +#include "TopologyView.h" + +namespace kickcat::kickui +{ + namespace + { + // Config for including a motor straight from the slave-list checkbox: a + // real encoder resolution (matching the MotorPanel default) so SI + // setpoints map to meaningful raw ticks. A bare OperateConfig{} would use + // 1 tick/rev and scale every setpoint to ~0 (motor never moves). The user + // refines units in the Control tab, which re-configures. + OperateConfig defaultMotorConfig() + { + OperateConfig c; + c.units.encoder_ticks_per_rev = 524288.0; + c.units.gear_ratio = 1.0; + c.units.rated_torque_Nm = 1.0; + return c; + } + } + + // Tabbed shell: interface + slave list on the left, panels (as tabs) for the + // selected slave on the right. + class Shell + { + public: + Shell() = default; + + ~Shell() + { + // Stop the bus thread (release the Bus/Link) BEFORE the sim/tap it talks + // to goes away, so the RT loop never cycles against a half-torn-down tap + // during shutdown. + session_.disconnect(); +#ifdef __linux__ + stopSimulator(); // don't leave a child network_simulator running +#endif + } + + // Bottom status bar: connection state + unacknowledged bus events (red if + // any unacked event is severe). Its own window pinned to the viewport + // bottom, so it stays visible regardless of the host window's scrolling. + void renderStatusBar(float height) + { + ImGuiViewport const* vp = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x, vp->WorkPos.y + vp->WorkSize.y - height)); + ImGui::SetNextWindowSize(ImVec2(vp->WorkSize.x, height)); + ImGuiWindowFlags const flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize + | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar + | ImGuiWindowFlags_NoSavedSettings + | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav; + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 4.0f)); + bool open = ImGui::Begin("##status_bar", nullptr, flags); + ImGui::PopStyleVar(); + if (not open) + { + ImGui::End(); + return; + } + if (session_.isConnected()) + { + ImGui::TextColored(COLOR_GREEN, "\xe2\x97\x8f %s (%d slaves)", + session_.interfaceName().c_str(), + static_cast(session_.devices().size())); + if (bus_lost_) + { + ImGui::SameLine(); + ImGui::TextColored(COLOR_YELLOW, "\xe2\x80\x94 bus not responding"); + } + } + else if (session_.isConnecting()) + { + ImGui::TextDisabled("\xe2\x97\x8f connecting..."); + } + else + { + ImGui::TextDisabled("\xe2\x97\x8f disconnected"); + } + + size_t n = event_log_.unacked(); + if (n > 0) + { + ImVec4 col = COLOR_YELLOW; + if (event_log_.unackedSevere()) { col = COLOR_RED; } + ImGui::SameLine(0.0f, 30.0f); + ImGui::TextColored(col, "\xe2\x9a\xa0 %zu bus event(s)", n); + ImGui::SameLine(); + ImGui::TextDisabled("%s", event_log_.entries().back().text.c_str()); + ImGui::SameLine(); + if (ImGui::SmallButton("Ack")) + { + event_log_.ackAll(); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Acknowledge all (details in the Diagnostics tab)"); + } + } + ImGui::End(); + } + + void render() + { + session_.update(); + if (not session_.isConnected()) { bus_lost_ = false; } // reset across reconnects + + // Map discovered (scan-order) slaves back to editor S# so all views + // label a given slave identically -- only when this is our sim (tap) + // and the counts line up; otherwise fall back to the scan index. + scan_to_editor_ = scene_.scanToEditorOrder(); + use_editor_labels_ = false; + auto snap = session_.snapshot(); + if (session_.isConnected() and (session_.interfaceName().rfind("tap:", 0) == 0)) + { + if (snap and (snap->slaves.size() == scan_to_editor_.size())) + { + use_editor_labels_ = true; + } + } + event_log_.update(snap, session_.isConnected(), bus_lost_, + [this](int i) { return slaveLabel(i); }); + + renderMenuBar(); + + // Resizable two-pane split (drag the divider to widen the sidebar). + // Fill the window height minus the room kept for the status bar, else + // the panes collapse to content size. + float status_h = ImGui::GetFrameHeight() + 8.0f; // matches the bar's 4px paddings + float body_h = ImGui::GetContentRegionAvail().y - status_h; + if (body_h < 1.0f) + { + body_h = 1.0f; + } + if (ImGui::BeginTable("##layout", 2, + ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV, + ImVec2(0.0f, body_h))) + { + ImGui::TableSetupColumn("##side", ImGuiTableColumnFlags_WidthFixed, px(300.0f)); + ImGui::TableSetupColumn("##main", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableNextRow(); + + ImGui::TableSetColumnIndex(0); + ImGui::BeginChild("##sidebar", ImVec2(0.0f, 0.0f), false); + renderSidebar(); + ImGui::EndChild(); + + ImGui::TableSetColumnIndex(1); + ImGui::BeginChild("##content", ImVec2(0.0f, 0.0f), false); + renderContent(); + ImGui::EndChild(); + + ImGui::EndTable(); + } + + renderStatusBar(status_h); + renderPrivilegeDialog(); + } + + private: + // Connection state lives in the status bar; the menu bar is the title. + void renderMenuBar() + { + if (not ImGui::BeginMenuBar()) + { + return; + } + ImGui::TextUnformatted("KickUI"); + ImGui::EndMenuBar(); + } + +#ifdef __linux__ + void launchSimulator() + { + sim_error_.clear(); + // The launch unlinks the tap shm, which would desync a live session + // sharing it. + if (session_.isConnected() or session_.isConnecting()) + { + sim_error_ = "disconnect before launching a simulator"; + return; + } + if (not sim_proc_.launch(scene_, sim_error_)) + { + return; + } + // Point the connection selector at the simulator so the next click is + // just "Connect" (instead of leaving it on the first NIC, e.g. lo). + auto const& ifs = session_.interfaces(); + for (int i = 0; i < static_cast(ifs.size()); ++i) + { + if (ifs[i].name == "tap:client") { iface_index_ = i; break; } + } + } + + void stopSimulator() + { + if (not sim_proc_.running()) { return; } + // Release the bus before killing the tap peer (same reason as ~Shell): + // a live session on this sim must not keep cycling once the tap is gone. + if (session_.isConnected() or session_.isConnecting()) + { + session_.disconnect(); + } + sim_proc_.stop(); + onSimGone(); + } + + void reapSimulator() + { + if (sim_proc_.reap()) + { + sim_error_ = "simulator exited (check the binary/config path)"; + onSimGone(); + } + } + + // Drain the simulator's return stream once per frame and dispatch each + // message on its type. Runs even when the panel is collapsed so the ring + // never backs up (which would otherwise stall command acks). + void pollSimulator() + { + sim::ControlEvent ev; + while (sim_proc_.nextEvent(ev)) + { + if (ev.type == sim::ControlEvent::Type::FrameStats) + { + sim_last_stats_ = ev.payload.stats; + sim_has_stats_ = true; + sim_avg_history_.push_back(static_cast(ev.payload.stats.avg_ns) / 1000.0f); + constexpr size_t MAX_SAMPLES = 240; + if (sim_avg_history_.size() > MAX_SAMPLES) + { + sim_avg_history_.erase(sim_avg_history_.begin(), + sim_avg_history_.end() - MAX_SAMPLES); + } + } + // SetLinkAck: draining it is what matters (keeps the ring clear); + // the editor already tracks broken links optimistically. + } + } + + // Live frame-timing of the running simulator: min/max/avg of the last + // window plus a sparkline of the per-window average over time. + void renderSimStats() + { + if (not sim_has_stats_) + { + ImGui::TextDisabled("frame timing: waiting for first window..."); + return; + } + + double const min_us = sim_last_stats_.min_ns / 1000.0; + double const max_us = sim_last_stats_.max_ns / 1000.0; + double const avg_us = sim_last_stats_.avg_ns / 1000.0; + ImGui::Text("frame timing (n=%llu)", static_cast(sim_last_stats_.window)); + ImGui::Text(" min %.1f max %.1f avg %.1f \xc2\xb5s (jitter \xc2\xb1%.1f)", + min_us, max_us, avg_us, (max_us - min_us) / 2.0); + + if (not sim_avg_history_.empty()) + { + char overlay[32]; + std::snprintf(overlay, sizeof(overlay), "avg %.0f \xc2\xb5s", avg_us); + ImGui::PlotLines("##sim_avg", sim_avg_history_.data(), + static_cast(sim_avg_history_.size()), + 0, overlay, FLT_MAX, FLT_MAX, ImVec2(px(220.0f), px(40.0f))); + } + } + + void renderSimulator() + { + reapSimulator(); + pollSimulator(); + if (not ImGui::CollapsingHeader("Simulator (no hardware)")) { return; } + ImGui::TextDisabled("Spawns network_simulator on tap:server;\nthen connect with interface tap:client."); + + bool running = sim_proc_.running(); + // Frozen only while the sim is running (you can't reconfigure a live + // sim). Once stopped, the config is editable again even if a stale + // session is still up -- launchSimulator() refuses while connected. + ImGui::BeginDisabled(running); + + // One row per simulated slave: config + its parent. Just pick a parent; + // the downstream port on that parent is assigned automatically, so + // branches never collide. The assigned port is shown read-only. S0 is + // the root, wired to the master. + ImGui::TextDisabled("Pick each slave's parent; branch ports are auto-assigned."); + std::vector ports = scene_.assignedPorts(); + for (int i = 0; i < static_cast(scene_.slaves.size()); ++i) + { + ImGui::PushID(i); + SimSlave& s = scene_.slaves[i]; + ImGui::AlignTextToFramePadding(); + ImGui::Text("S%d", i); + ImGui::SameLine(); + ImGui::SetNextItemWidth(px(150.0f)); + ImGui::InputText("##cfg", s.config, sizeof(s.config)); + + ImGui::SameLine(); + ImGui::SetNextItemWidth(px(80.0f)); + std::string parent_label = "master"; + if (s.parent >= 0) { parent_label = "S" + std::to_string(s.parent); } + if (ImGui::BeginCombo("##parent", parent_label.c_str())) + { + if (ImGui::Selectable("master", s.parent == -1)) { s.parent = -1; } + for (int p = 0; p < i; ++p) // only earlier slaves: keeps the tree acyclic + { + std::string lbl = "S" + std::to_string(p); + if (ImGui::Selectable(lbl.c_str(), s.parent == p)) { s.parent = p; } + } + ImGui::EndCombo(); + } + if (s.parent >= 0) + { + ImGui::SameLine(); + if (ports[i] >= 0) + { + ImGui::TextDisabled("\xe2\x86\x92 S%d port %d", s.parent, ports[i]); + } + else + { + ImGui::TextColored(COLOR_RED, "\xe2\x86\x92 S%d (no free port!)", s.parent); + } + } + ImGui::PopID(); + } + + if (ImGui::Button("Add slave")) + { + SimSlave s; + s.parent = static_cast(scene_.slaves.size()) - 1; // default: chain off the tail + scene_.slaves.push_back(s); + } + ImGui::SameLine(); + ImGui::BeginDisabled(scene_.slaves.size() <= 1); + if (ImGui::Button("Remove slave")) + { + scene_.slaves.pop_back(); + for (SimSlave& s : scene_.slaves) + { + if (s.parent >= static_cast(scene_.slaves.size())) { s.parent = -1; } + } + } + ImGui::EndDisabled(); + + ImGui::Checkbox("Cable redundancy (ring)", &scene_.redundancy); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Close the ring on the tail slave and run a redundant tap pair.\n" + "Connect via tap:client to use the redundant path too."); + } + + ImGui::EndDisabled(); + + // Save/load the editor scene. Save is read-only, so it stays enabled + // while the sim runs (snapshot the live config); Load would desync the + // editor from the running sim, so it is gated like the rest. + ImGui::SetNextItemWidth(px(170.0f)); + ImGui::InputText("scene", sim_scene_path_, sizeof(sim_scene_path_)); + ImGui::SameLine(); + if (ImGui::Button("Save")) { scene_.save(sim_scene_path_, sim_scene_msg_); } + ImGui::SameLine(); + ImGui::BeginDisabled(running or session_.isConnected() or session_.isConnecting()); + if (ImGui::Button("Load")) { scene_.load(sim_scene_path_, sim_scene_msg_); } + ImGui::EndDisabled(); + if (not sim_scene_msg_.empty()) + { + ImGui::TextDisabled("%s", sim_scene_msg_.c_str()); + } + + if (running) + { + ImGui::TextColored(COLOR_GREEN, "\xe2\x97\x8f simulator running (pid %d)", sim_proc_.pid()); + renderSimStats(); + if (ImGui::Button("Stop simulator")) { stopSimulator(); } + } + else + { + if (ImGui::Button("Launch simulator")) { launchSimulator(); } + } + if (not sim_error_.empty()) + { + ImGui::TextColored(COLOR_YELLOW, "%s", sim_error_.c_str()); + } + } +#endif + + // The simulator just died (stopped or crashed). The tap is shared memory, + // so the master sees no socket error -- its frames simply stop being + // answered, and per-slave state goes stale. Flag it so the slaves render + // greyed (state unknown) while the link stays up, rather than silently + // showing a stale state as if it were live. + void onSimGone() + { + sim_has_stats_ = false; + sim_avg_history_.clear(); + if (session_.isConnected() and (session_.interfaceName().rfind("tap:", 0) == 0)) + { + bus_lost_ = true; + } + } + + ImVec4 slaveStateColor(int slave_index) + { + return esmColor(session_.slaveAlStatus(slave_index), bus_lost_); + } + + void renderSidebar() + { + ImGui::SeparatorText("Connection"); +#ifdef __linux__ + renderSimulator(); +#endif + + // The simulator pseudo-interface "tap:client" shows its friendly + // description instead of the raw connection string. Probed interfaces + // carry their detection outcome. + auto ifaceLabel = [this](NetworkInterface const& nif) -> std::string + { + std::string label; + if (nif.name == "tap:client") + { + label = nif.description; + } + else + { + label = nif.name + " (" + nif.description + ")"; + } + int found = session_.detectResult(nif.name); + if (found > 0) + { + char const* plural = "s"; + if (found == 1) + { + plural = ""; + } + label += " \xe2\x80\x94 EtherCAT: " + std::to_string(found) + " slave" + plural; + } + else if (found == 0) + { + label += " \xe2\x80\x94 no EtherCAT"; + } + return label; + }; + + auto const& ifaces = session_.interfaces(); + std::string preview = "(no interface)"; + if ((iface_index_ >= 0) and (iface_index_ < static_cast(ifaces.size()))) + { + preview = ifaceLabel(ifaces[iface_index_]); + } + + ImGui::SetNextItemWidth(-1.0f); + if (ImGui::BeginCombo("##iface", preview.c_str())) + { + for (int i = 0; i < static_cast(ifaces.size()); ++i) + { + bool selected = (i == iface_index_); + if (ImGui::Selectable(ifaceLabel(ifaces[i]).c_str(), selected)) + { + iface_index_ = i; + } + if (selected) + { + ImGui::SetItemDefaultFocus(); + } + } + ImGui::EndCombo(); + } + + ImGui::BeginDisabled(session_.isConnected() or session_.isConnecting()); + ImGui::SetNextItemWidth(px(90.0f)); + ImGui::InputInt("Bus cycle (ms)", &cycle_ms_); + if (cycle_ms_ < 1) + { + cycle_ms_ = 1; + } + ImGui::Checkbox("DC sync (SYNC0)", &dc_enable_); + ImGui::EndDisabled(); + + bool has_iface = (iface_index_ >= 0) and (iface_index_ < static_cast(ifaces.size())); + bool can_connect = has_iface and not session_.isConnecting() and not session_.isConnected(); + + ImGui::BeginDisabled(not can_connect); + if (ImGui::Button("Connect")) + { + session_.setDcConfig(dc_enable_, cycle_ms_); + // A redundant simulator (ring) exposes its second port on the + // default tap_redundant pair: connect the master to it too. + std::string redundant = ""; + if (scene_.redundancy and (ifaces[iface_index_].name == "tap:client")) + { + redundant = "tap:client"; + } + session_.setRedundancy(redundant); + session_.connect(ifaces[iface_index_].name); + } + ImGui::EndDisabled(); + + ImGui::SameLine(); + ImGui::BeginDisabled(not session_.isConnected()); + if (ImGui::Button("Disconnect")) + { + session_.disconnect(); + } + ImGui::EndDisabled(); + + ImGui::SameLine(); + ImGui::BeginDisabled(session_.isConnecting()); + if (ImGui::Button("Refresh")) + { + session_.refreshInterfaces(); + } + ImGui::EndDisabled(); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Re-list available network interfaces"); + } + + ImGui::SameLine(); + ImGui::BeginDisabled(session_.isConnected() or session_.isConnecting() or session_.isDetecting()); + if (ImGui::Button("Detect")) + { + session_.detectNetworks(); + detect_was_running_ = true; + } + ImGui::EndDisabled(); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Probe each interface for an EtherCAT network\n(broadcast read; the answer count is the number of slaves)"); + } + + if (session_.isDetecting()) + { + ImGui::TextDisabled("\xe2\x97\x8f detecting..."); + } + else + { + if (detect_was_running_) + { + // Detection just finished: pre-select the first interface where + // an EtherCAT network answered (unless one is already chosen). + detect_was_running_ = false; + bool keep = (iface_index_ >= 0) and (iface_index_ < static_cast(ifaces.size())) + and (session_.detectResult(ifaces[iface_index_].name) > 0); + if (not keep) + { + for (int i = 0; i < static_cast(ifaces.size()); ++i) + { + if (session_.detectResult(ifaces[i].name) > 0) + { + iface_index_ = i; + break; + } + } + } + } + std::string detect_status = session_.detectStatus(); + if (not detect_status.empty()) + { + ImGui::TextDisabled("%s", detect_status.c_str()); + } + } + + if (session_.isConnected()) + { + ImGui::TextColored(COLOR_GREEN, "\xe2\x97\x8f connected"); + if (bus_lost_) + { + ImGui::TextColored(COLOR_YELLOW, "bus not responding (simulator stopped?)"); + } + } + else if (session_.isConnecting()) + { + ImGui::TextDisabled("\xe2\x97\x8f connecting..."); + } + else + { + ImGui::TextDisabled("\xe2\x97\x8f disconnected"); + } + + std::string status = session_.status(); + if (not status.empty()) + { + ImGui::TextWrapped("%s", status.c_str()); + } + std::string conn_error = session_.error(); + if (not conn_error.empty()) + { + ImGui::PushStyleColor(ImGuiCol_Text, COLOR_RED); + ImGui::TextWrapped("%s", conn_error.c_str()); + ImGui::PopStyleColor(); + } + + ImGui::SeparatorText("Slaves"); + ImGui::BeginDisabled(not session_.isConnected() or session_.isConnecting()); + if (ImGui::Button("Rescan bus")) + { + session_.rescan(); + } + ImGui::EndDisabled(); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Re-detect slaves on the current interface"); + } + renderSlaveList(); + + if (session_.isConnected()) + { + renderOperationControls(); + } + } + + // Bus-level operation: the PDO mapping is built ONCE (at PRE-OP) over every + // drive marked "Include in mapping", then the cyclic loop runs continuously. + // To change the mapped set, drop back to PRE-OP and re-apply. + void renderOperationControls() + { + ImGui::SeparatorText("Operation"); + bool operating = session_.isOperatingAny(); + if (operating) + { + ImGui::TextColored(COLOR_GREEN, "\xe2\x97\x8f operating (mapping applied)"); + if (ImGui::Button("Back to PRE-OP (all)")) + { + session_.backToPreOp(); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Stop the cyclic loop and return the whole bus to PRE-OP\n" + "so the mapped set can be changed and re-applied."); + } + } + else + { + ImGui::TextDisabled("PRE-OP \xe2\x80\x94 include drives, then apply the mapping"); + if (ImGui::Button("Apply mapping")) + { + session_.applyMapping(); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Map every included drive at once and start the cyclic loop.\n" + "Each drive is then brought to SAFE-OP/OP independently."); + } + } + } + + void renderSlaveList() + { + auto& slaves = session_.devices(); + if (slaves.empty()) + { + ImGui::TextDisabled("No slaves."); + return; + } + + // Fixed columns keep the state/profile aligned across rows (varying + // state-name length no longer shifts them). The leading checkbox is + // "include in the PDO mapping" (disabled once the loop is running). + bool operating = session_.isOperatingAny(); + if (not ImGui::BeginTable("##slaves", 4, ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingFixedFit)) + { + return; + } + ImGui::TableSetupColumn("##map", ImGuiTableColumnFlags_WidthFixed, px(22.0f)); + ImGui::TableSetupColumn("##name", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("##state", ImGuiTableColumnFlags_WidthFixed, px(62.0f)); + ImGui::TableSetupColumn("##profile", ImGuiTableColumnFlags_WidthFixed, px(86.0f)); + + for (int i = 0; i < static_cast(slaves.size()); ++i) + { + Device& slave = slaves[i]; + ImGui::TableNextRow(); + ImGui::PushID(i); + + ImGui::TableSetColumnIndex(0); + if (slave.has_coe) + { + bool mapped = slave.isConfigured(); + ImGui::BeginDisabled(operating); + if (ImGui::Checkbox("##map", &mapped)) + { + if (not mapped) { slave.unconfigureSlave(); } + else if (slave.isMotor()) { slave.configureSlave(defaultMotorConfig()); } + else { slave.includeSlave(); } + } + ImGui::EndDisabled(); + if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Include in the PDO mapping"); } + } + + ImGui::TableSetColumnIndex(1); + char label[128]; + std::snprintf(label, sizeof(label), "S%d %s", slaveLabel(i), slave.name.c_str()); + if (ImGui::Selectable(label, i == session_.selected())) + { + session_.select(i); + } + + ImGui::TableSetColumnIndex(2); + uint8_t al = session_.slaveAlStatus(i); + ImGui::TextColored(slaveStateColor(i), "%s", stateLabel(al)); + + ImGui::TableSetColumnIndex(3); + if (slave.isMotor()) + { + ImGui::TextColored(COLOR_DS402, "DS402"); + } + if (slave.is_emulated) + { + if (slave.isMotor()) + { + ImGui::SameLine(); + } + ImGui::TextDisabled("emu"); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Device emulation (no PDI application):\n" + "AL status mirrors AL control, the error bit is\n" + "the echoed ack request and is filtered out."); + } + } + + ImGui::PopID(); + } + ImGui::EndTable(); + } + + void renderContent() + { + if (not session_.isConnected()) + { + std::string error = session_.error(); + if (not error.empty()) + { + ImGui::TextColored(COLOR_RED, "Connection failed"); + ImGui::Separator(); + ImGui::TextWrapped("%s", error.c_str()); + } + else + { + ImGui::TextDisabled("Select a network interface and connect to a bus."); + } + device_panels_.clear(); // drop per-device panels; indices change on reconnect/rescan + return; + } + + // Top-level tabs: the bus-wide topology (master POV) vs the detail of + // the selected slave. The topology tab is independent of the selection. + if (ImGui::BeginTabBar("##content_tabs")) + { + if (ImGui::BeginTabItem("Bus Topology")) + { + TopologyView::SimHooks hooks; +#ifdef __linux__ + hooks.running = sim_proc_.running(); + hooks.broken_links = &sim_proc_.brokenLinks(); + hooks.set_link_broken = [this](int a, int b, bool broken) + { + sim_proc_.setLinkBroken(a, b, broken); + }; +#endif + topo_view_.render(session_, scene_, + [this](int i) { return slaveLabel(i); }, bus_lost_, hooks); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Device")) + { + renderDeviceDetail(); + ImGui::EndTabItem(); + } + // The "###diag" suffix fixes the tab's ImGui ID, so the visible count + // can change (or the banner ack fire) WITHOUT the tab losing selection. + char diag_label[40] = "Diagnostics###diag"; + if (event_log_.unacked() > 0) + { + std::snprintf(diag_label, sizeof(diag_label), "Diagnostics (%zu)###diag", + event_log_.unacked()); + } + if (ImGui::BeginTabItem(diag_label)) + { + renderDiagnostics(session_.snapshot()); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + } + + void renderDeviceDetail() + { + Device* slave = session_.selectedDevice(); + if (slave == nullptr) + { + ImGui::TextDisabled("Select a slave."); + return; + } + + ImGui::Text("%s", slave->name.c_str()); + ImGui::SameLine(); + if (slave->isMotor()) + { + ImGui::TextColored(COLOR_DS402, "[DS402]"); + if (slave->is_emulated) + { + ImGui::SameLine(); + } + } + if (slave->is_emulated) + { + ImGui::TextDisabled("[emulated]"); + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Device emulation (ESC config 0x141 bit 0): AL status\n" + "mirrors AL control, so the AL error bit only echoes the\n" + "master's ack request. It is filtered from the state display."); + } + } + ImGui::TextDisabled("S%d @%u vendor 0x%08X product 0x%08X", + slaveLabel(slave->index), slave->address, slave->vendor_id, slave->product_code); + + if (slave->has_coe) + { + // Override combo, built from the registry: slot 0 is "Auto" (follow + // detection), then one entry per selectable profile. + auto const& profiles = selectableProfiles(); + std::vector labels{"Auto"}; + int current = 0; + for (size_t i = 0; i < profiles.size(); ++i) + { + labels.push_back(profiles[i].name); + if (profiles[i].profile == slave->forced_profile) { current = static_cast(i + 1); } + } + ImGui::SetNextItemWidth(px(150.0f)); + if (ImGui::Combo("Profile", ¤t, labels.data(), static_cast(labels.size()))) + { + if (current == 0) { slave->forceProfile(Profile::Unknown); } + else { slave->forceProfile(profiles[current - 1].profile); } + } + ImGui::SameLine(); + ImGui::TextDisabled("(detected: %s)", profileInfo(slave->detected_profile).name); + } + + // EtherCAT FSM state: slave-level, common to all tabs. + ImGui::Separator(); + uint8_t al = session_.slaveAlStatus(slave->index); + ImGui::TextUnformatted("EtherCAT:"); + ImGui::SameLine(); + if (al & AL_ERROR_BIT) + { + ImGui::TextColored(slaveStateColor(slave->index), "%s (error)", stateLabel(al)); + } + else + { + ImGui::TextColored(slaveStateColor(slave->index), "%s", stateLabel(al)); + } + for (auto const& btn : STATE_BUTTONS) + { + ImGui::SameLine(); + if (ImGui::SmallButton(btn.label)) + { + slave->requestState(static_cast(btn.state)); + } + } + std::string state_error = session_.stateActionError(slave->index); + if (not state_error.empty()) + { + ImGui::TextColored(COLOR_RED, "%s", state_error.c_str()); + } + + ImGui::Separator(); + + // Scope panel widget IDs by slave so per-widget ImGui state (text + // edits, table scroll) does not bleed across slave selection. + ImGui::PushID(slave->index); + int applicable = 0; + if (ImGui::BeginTabBar("##panels")) + { + for (auto& panel : panelsFor(slave->index)) + { + if (not panel->appliesTo(*slave)) + { + continue; + } + ++applicable; + if (ImGui::BeginTabItem(panel->title())) + { + panel->render(session_, *slave); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); + } + ImGui::PopID(); + + if (applicable == 0) + { + ImGui::TextDisabled("No applicable panels for this slave " + "(no CoE mailbox / not a DS402 drive)."); + } + } + + void renderDiagnostics(std::shared_ptr const& snap) + { + if (session_.redundancyEnabled()) + { + renderRedundancyStatus(snap and snap->redundancy_active); + } + if (bus_lost_) + { + ImGui::TextColored(COLOR_RED, "Bus communication lost (simulator/cable down)"); + } + + if (ImGui::SmallButton("Reset error counters")) + { + session_.clearErrorCounters(); // slaves + the GUI totals + } + ImGui::SameLine(); + ImGui::TextDisabled("totals auto-accumulate; slave counters auto-clear before saturation"); + + ImGui::SeparatorText("Per-slave"); + if (snap and not snap->slaves.empty() + and ImGui::BeginTable("##diag", 3, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) + { + ImGui::TableSetupColumn("Slave / AL state", ImGuiTableColumnFlags_WidthFixed, px(220.0f)); + ImGui::TableSetupColumn("Ports link / lost / rxerr"); + ImGui::TableSetupColumn("AL status code", ImGuiTableColumnFlags_WidthFixed, px(230.0f)); + ImGui::TableHeadersRow(); + for (int i = 0; i < static_cast(snap->slaves.size()); ++i) + { + SlaveSnapshot const& s = snap->slaves[i]; + bool err = (s.al_status & AL_ERROR_BIT) != 0; + + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImVec4 sc = esmColor(s.al_status); + char const* suffix = ""; + if (err) { suffix = "+ERR"; } + ImGui::TextColored(sc, "S%d %s%s", slaveLabel(i), stateLabel(s.al_status), suffix); + + ImGui::TableNextColumn(); + for (int p = 0; p < 4; ++p) + { + char const* link = "down"; + ImVec4 lc = COLOR_RED; + if (s.port_com[p]) { link = "up"; lc = COLOR_GREEN; } + if (p > 0) { ImGui::SameLine(); } + ImGui::TextColored(lc, "p%d:%s", p, link); + ImGui::SameLine(); + // Totals since the last reset. + ImVec4 tc = ImVec4(0.70f, 0.72f, 0.76f, 1.0f); + if ((s.stats.lost_total[p] > 0) or (s.stats.rxerr_total[p] > 0)) { tc = COLOR_YELLOW; } + ImGui::TextColored(tc, "lost %llu rx %llu", + static_cast(s.stats.lost_total[p]), + static_cast(s.stats.rxerr_total[p])); + } + if (s.stats.saturated) + { + ImGui::SameLine(); + ImGui::TextColored(COLOR_RED, "(saturated!)"); + } + + ImGui::TableNextColumn(); + if (err) + { + ImGui::TextColored(COLOR_RED, "0x%04X %s", s.stats.al_status_code, + ALStatus_to_string(s.stats.al_status_code)); + } + else + { + ImGui::TextDisabled("\xe2\x80\x94"); + } + } + ImGui::EndTable(); + } + + ImGui::SeparatorText("Event log"); + if (ImGui::SmallButton("Acknowledge all")) { event_log_.ackAll(); } + ImGui::SameLine(); + if (ImGui::SmallButton("Clear log")) { event_log_.clear(); } + ImGui::SameLine(); + ImGui::TextDisabled("%zu entries", event_log_.entries().size()); + + ImGui::BeginChild("##log", ImVec2(0.0f, 0.0f), ImGuiChildFlags_Borders); + auto const& entries = event_log_.entries(); + for (auto it = entries.rbegin(); it != entries.rend(); ++it) // newest first + { + ImVec4 c = ImVec4(0.70f, 0.72f, 0.76f, 1.0f); + if (it->severe) { c = COLOR_RED; } + ImGui::TextDisabled("%s", it->when.c_str()); + ImGui::SameLine(); + ImGui::TextColored(c, "%s", it->text.c_str()); + } + if (entries.empty()) + { + ImGui::TextDisabled("no events yet"); + } + ImGui::EndChild(); + } + + // The label to show for a discovered slave at scan index `i`: its editor S# + // when we can map it, else the scan index itself. + int slaveLabel(int scan_index) const + { + if (use_editor_labels_ and (scan_index >= 0) + and (scan_index < static_cast(scan_to_editor_.size()))) + { + return scan_to_editor_[scan_index]; + } + return scan_index; + } + + void renderPrivilegeDialog() + { + priv_.reap(); + if (session_.needsPrivilege() and not session_.isConnecting()) + { + show_privilege_dialog_ = true; + session_.clearNeedsPrivilege(); + } + if (show_privilege_dialog_) + { + ImGui::OpenPopup("Privilege Required"); + show_privilege_dialog_ = false; + } + + ImVec2 center = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + ImGui::SetNextWindowSize(ImVec2(px(520.0f), 0.0f), ImGuiCond_Always); + + if (not ImGui::BeginPopupModal("Privilege Required")) + { + return; + } + + ImGui::TextWrapped( + "Raw Ethernet access requires the CAP_NET_RAW capability.\n\n" + "Click \"Grant\" to set the capability on this executable " + "(the system will prompt for your password)."); + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + +#ifdef __linux__ + priv_.ensureCommand(); + bool have_path = not priv_.exePath().empty(); + + if (priv_.granted()) + { + ImGui::TextColored(COLOR_GREEN, "Capability granted. Restart to apply."); + ImGui::Spacing(); + if (have_path) + { + if (ImGui::Button("Restart Now", ImVec2(px(160.0f), 0.0f))) + { + // execv skips destructors: stop the bus and the child sim + // first, else the sim is orphaned holding its tap shm. + session_.disconnect(); + stopSimulator(); + std::vector exe(priv_.exePath().begin(), priv_.exePath().end()); + exe.push_back('\0'); + char* args[] = {exe.data(), nullptr}; + execv(exe.data(), args); + priv_.setError("Restart failed. Please close and reopen KickUI."); + priv_.resetGranted(); + } + } + else + { + ImGui::TextDisabled("Restart KickUI manually to apply."); + } + } + else + { + if (priv_.running()) + { + ImGui::TextColored(COLOR_YELLOW, "Granting (answer the password prompt)..."); + } + ImGui::BeginDisabled(priv_.command().empty() or priv_.running()); + if (ImGui::Button("Grant Capability", ImVec2(px(160.0f), 0.0f))) + { + priv_.grant(); + } + ImGui::EndDisabled(); + + if (priv_.command().empty() and have_path) + { + ImGui::TextWrapped("No pkexec/kdesu found. Run manually:\n" + " sudo /usr/sbin/setcap cap_net_raw,cap_net_admin+ep %s", priv_.exePath().c_str()); + } + if (not priv_.error().empty()) + { + ImGui::TextColored(COLOR_YELLOW, "%s", priv_.error().c_str()); + } + } +#else + ImGui::TextDisabled("Automatic privilege escalation is only available on Linux."); +#endif + + ImGui::SameLine(); + ImGui::BeginDisabled(priv_.running()); + if (ImGui::Button("Cancel", ImVec2(px(120.0f), 0.0f))) + { + priv_.resetGranted(); + priv_.clearError(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndDisabled(); + + ImGui::EndPopup(); + } + + // The set of feature panels for one device. Built per device so each + // device gets its OWN panel instances -- no widget state bleeds across + // slaves. Adding a feature (ESM, error counters, EoE/FoE/SoE, ...) = one + // push_back here + a Panel subclass that appliesTo the right capability. + static std::vector> makePanels() + { + std::vector> panels; + panels.push_back(std::make_unique()); + panels.push_back(std::make_unique()); + panels.push_back(std::make_unique()); + panels.push_back(std::make_unique()); + return panels; + } + + std::vector>& panelsFor(int device_index) + { + auto it = device_panels_.find(device_index); + if (it == device_panels_.end()) + { + it = device_panels_.emplace(device_index, makePanels()).first; + } + return it->second; + } + + BusSession session_; + std::unordered_map>> device_panels_; + int iface_index_ = 0; + bool dc_enable_ = false; + int cycle_ms_ = 1; + bool detect_was_running_ = false; // edge: auto-select once when a probe ends + + bool show_privilege_dialog_ = false; + PrivilegeHelper priv_; + + // The simulator: editor scene (all platforms) + child process (Linux). + SimScene scene_; + std::string sim_error_; + char sim_scene_path_[256] = "sim_scene.txt"; // save/load the editor scene + std::string sim_scene_msg_; +#ifdef __linux__ + SimulatorProcess sim_proc_; + sim::SimStats sim_last_stats_{}; // most recent window drained + bool sim_has_stats_ = false; + std::vector sim_avg_history_; // per-window avg (µs), for the sparkline +#endif + + EventLog event_log_; + TopologyView topo_view_; + bool bus_lost_ = false; // sim died: slaves stale, render greyed + + // Coherent slave labels: the master discovers slaves in EtherCAT scan + // (processing) order, which differs from the editor's add order. When we + // launched the sim from the editor we map each scan position back to its + // editor S# so every view (slave list, device, topology) shows the SAME + // label for a given slave. Empty / disabled => fall back to the scan index. + std::vector scan_to_editor_; + bool use_editor_labels_ = false; + }; +} + +int main(int, char**) +{ + kickcat::gui::GuiApp gui{"KickUI"}; + if (not gui.valid()) + { + return 1; + } + kickcat::kickui::g_mono_font = gui.monoFont(); + kickcat::kickui::g_ui_scale = gui.scale(); + + kickcat::kickui::Shell shell; + gui.run([&shell]{ shell.render(); }); + + return 0; +} diff --git a/unit/CMakeLists.txt b/unit/CMakeLists.txt index 80fe4f1c..8790c56f 100644 --- a/unit/CMakeLists.txt +++ b/unit/CMakeLists.txt @@ -40,6 +40,7 @@ add_executable(kickcat_unit src/adler32_sum-t.cc src/masterOD-gateway-t.cc src/slave/slave-t.cc src/slave/PDO-t.cc + src/Mutex-t.cc src/Time.cc ) diff --git a/unit/src/CoE/protocol-t.cc b/unit/src/CoE/protocol-t.cc index e65fe85b..55d32266 100644 --- a/unit/src/CoE/protocol-t.cc +++ b/unit/src/CoE/protocol-t.cc @@ -129,3 +129,18 @@ TEST(CoE, SDO_Information_EntryDescription_to_string) ASSERT_EQ(toString(desc), EXPECTED); } + +TEST(CoE, pdo_mapping_word_pack_unpack) +{ + // Packed subindex value of a PDO mapping entry: index<<16 | sub<<8 | bitlen. + EXPECT_EQ(toMappingWord({0x607A, 0x00, 0x20}), 0x607A0020u); + EXPECT_EQ(toMappingWord({0x6041, 0x00, 0x10}), 0x60410010u); + + PdoMappingEntry e = fromMappingWord(0x60410010); + EXPECT_EQ(e.index, 0x6041); + EXPECT_EQ(e.subindex, 0x00); + EXPECT_EQ(e.bitlen, 0x10); + + // A padding gap (index 0) round-trips its bit length. + EXPECT_EQ(fromMappingWord(toMappingWord({0x0000, 0x00, 0x08})).bitlen, 0x08); +} diff --git a/unit/src/Mutex-t.cc b/unit/src/Mutex-t.cc new file mode 100644 index 00000000..e82f24e7 --- /dev/null +++ b/unit/src/Mutex-t.cc @@ -0,0 +1,49 @@ +#include + +#include "kickcat/OS/Mutex.h" + +using namespace kickcat; + +TEST(TryLockGuard, acquires_a_free_mutex) +{ + Mutex m; + TryLockGuard guard(m); + EXPECT_TRUE(guard.owns()); +} + +TEST(TryLockGuard, fails_on_a_held_mutex) +{ + Mutex m; + m.lock(); + { + TryLockGuard guard(m); + EXPECT_FALSE(guard.owns()); // already held: non-blocking attempt fails + } + m.unlock(); +} + +TEST(TryLockGuard, releases_on_scope_exit) +{ + Mutex m; + { + TryLockGuard guard(m); + ASSERT_TRUE(guard.owns()); + } + // The guard owned the lock and must have released it: a fresh attempt succeeds. + TryLockGuard again(m); + EXPECT_TRUE(again.owns()); +} + +TEST(TryLockGuard, does_not_release_a_lock_it_never_took) +{ + Mutex m; + m.lock(); + { + TryLockGuard guard(m); + EXPECT_FALSE(guard.owns()); + } // must NOT unlock m here -- it never owned it + // m is still held by us; releasing it is the only valid next step. + m.unlock(); + TryLockGuard guard(m); + EXPECT_TRUE(guard.owns()); +} diff --git a/unit/src/SimulatorControl-t.cc b/unit/src/SimulatorControl-t.cc index 75594b35..1aac087b 100644 --- a/unit/src/SimulatorControl-t.cc +++ b/unit/src/SimulatorControl-t.cc @@ -52,17 +52,54 @@ TEST_F(SimulatorControlTest, command_and_response_round_trip) EXPECT_EQ(got.payload.set_link.up, 0); EXPECT_FALSE(sim.nextCommand(got)); - ControlResponse outgoing{}; - outgoing.type = ControlCommand::Type::SetLink; - outgoing.ok = 1; - outgoing.payload.set_link = {2, 3, 0}; - ASSERT_TRUE(sim.sendResponse(outgoing)); - ControlResponse response; - ASSERT_TRUE(host.nextResponse(response)); - EXPECT_EQ(response.ok, 1); - EXPECT_EQ(response.payload.set_link.node_a, 2); - EXPECT_EQ(response.payload.set_link.node_b, 3); - EXPECT_FALSE(host.nextResponse(response)); + ControlEvent outgoing{}; + outgoing.type = ControlEvent::Type::SetLinkAck; + outgoing.payload.set_link_ack.ok = 1; + outgoing.payload.set_link_ack.link = {2, 3, 0}; + ASSERT_TRUE(sim.sendEvent(outgoing)); + ControlEvent response; + ASSERT_TRUE(host.nextEvent(response)); + EXPECT_EQ(response.type, ControlEvent::Type::SetLinkAck); + EXPECT_EQ(response.payload.set_link_ack.ok, 1); + EXPECT_EQ(response.payload.set_link_ack.link.node_a, 2); + EXPECT_EQ(response.payload.set_link_ack.link.node_b, 3); + EXPECT_FALSE(host.nextEvent(response)); +} + +TEST_F(SimulatorControlTest, stats_events_stream_in_order) +{ + ControlChannel host; + host.create(SHM_NAME); + ControlChannel sim; + sim.attach(SHM_NAME); + + // Stats ride the return stream as FrameStats events, queued losslessly and + // delivered in order alongside acks. + auto statsEvent = [](uint64_t avg) + { + ControlEvent e{}; + e.type = ControlEvent::Type::FrameStats; + e.payload.stats = SimStats{1000, 900, 1100, avg}; + return e; + }; + + ControlEvent out; + EXPECT_FALSE(host.nextEvent(out)); // nothing emitted yet + + ASSERT_TRUE(sim.sendEvent(statsEvent(1000))); + ASSERT_TRUE(sim.sendEvent(statsEvent(1234))); + + ASSERT_TRUE(host.nextEvent(out)); + EXPECT_EQ(out.type, ControlEvent::Type::FrameStats); + EXPECT_EQ(out.payload.stats.window, 1000u); + EXPECT_EQ(out.payload.stats.min_ns, 900u); + EXPECT_EQ(out.payload.stats.max_ns, 1100u); + EXPECT_EQ(out.payload.stats.avg_ns, 1000u); + + ASSERT_TRUE(host.nextEvent(out)); + EXPECT_EQ(out.payload.stats.avg_ns, 1234u); + + EXPECT_FALSE(host.nextEvent(out)); } TEST_F(SimulatorControlTest, commands_preserve_order) @@ -87,6 +124,27 @@ TEST_F(SimulatorControlTest, commands_preserve_order) EXPECT_FALSE(sim.nextCommand(c)); } +TEST_F(SimulatorControlTest, server_publishes_stats_as_events) +{ + EmulatedNetwork network(std::vector{}); + + SimulatorControlClient client; + client.open(SHM_NAME); + SimulatorControlServer server(network, network.size()); + + // No-op until attached: nothing lands on the return stream. + server.publishStats(SimStats{1000, 900, 1100, 1000}); + ControlEvent out; + EXPECT_FALSE(client.nextEvent(out)); + + server.attach(SHM_NAME); + server.publishStats(SimStats{1000, 900, 1100, 1000}); + + ASSERT_TRUE(client.nextEvent(out)); + EXPECT_EQ(out.type, ControlEvent::Type::FrameStats); + EXPECT_EQ(out.payload.stats.avg_ns, 1000u); +} + TEST_F(SimulatorControlTest, client_drives_server_against_network) { std::vector> escs; @@ -120,26 +178,27 @@ TEST_F(SimulatorControlTest, client_drives_server_against_network) ASSERT_TRUE(client.breakLink(0, 1)); server.drain(); - ControlResponse response; - ASSERT_TRUE(client.nextResponse(response)); - EXPECT_EQ(response.ok, 1); - EXPECT_EQ(response.payload.set_link.up, 0); + ControlEvent response; + ASSERT_TRUE(client.nextEvent(response)); + EXPECT_EQ(response.type, ControlEvent::Type::SetLinkAck); + EXPECT_EQ(response.payload.set_link_ack.ok, 1); + EXPECT_EQ(response.payload.set_link_ack.link.up, 0); EXPECT_FALSE(reachesNode1(0x2002)); ASSERT_TRUE(client.healLink(0, 1)); server.drain(); - ASSERT_TRUE(client.nextResponse(response)); - EXPECT_EQ(response.ok, 1); - EXPECT_EQ(response.payload.set_link.up, 1); + ASSERT_TRUE(client.nextEvent(response)); + EXPECT_EQ(response.payload.set_link_ack.ok, 1); + EXPECT_EQ(response.payload.set_link_ack.link.up, 1); EXPECT_TRUE(reachesNode1(0x3003)); // Out-of-range node and a self-link are rejected, leaving the network untouched. ASSERT_TRUE(client.breakLink(0, 9)); ASSERT_TRUE(client.breakLink(2, 2)); server.drain(); - ASSERT_TRUE(client.nextResponse(response)); - EXPECT_EQ(response.ok, 0); - ASSERT_TRUE(client.nextResponse(response)); - EXPECT_EQ(response.ok, 0); + ASSERT_TRUE(client.nextEvent(response)); + EXPECT_EQ(response.payload.set_link_ack.ok, 0); + ASSERT_TRUE(client.nextEvent(response)); + EXPECT_EQ(response.payload.set_link_ack.ok, 0); EXPECT_TRUE(reachesNode1(0x4004)); } diff --git a/unit/src/mailbox/CoE/response-t.cc b/unit/src/mailbox/CoE/response-t.cc index 1d766772..456f0721 100644 --- a/unit/src/mailbox/CoE/response-t.cc +++ b/unit/src/mailbox/CoE/response-t.cc @@ -109,12 +109,12 @@ class CoE_Response : public ::testing::Test public: void SetUp() override { - dict = createTestDictionary(); - mbx.enableCoE(dict); + default_dict = createTestDictionary(); + mbx.enableCoE(default_dict); } MockESC esc; - CoE::Dictionary dict{}; // declared before mbx so it outlives the reference + CoE::Dictionary default_dict{}; // declared before mbx so it outlives the reference Mailbox mbx{&esc, TEST_MAILBOX_SIZE, 1}; };