diff --git a/components/desktop/README.md b/components/desktop/README.md index 345b53eb86..443355b7da 100644 --- a/components/desktop/README.md +++ b/components/desktop/README.md @@ -193,4 +193,6 @@ and covered by the host test. Mutually exclusive with The [example](./example) serves the desktop with the Counter, About, System Monitor, Task Manager, Log Viewer, Files + Editor and Settings apps on the native USB port of an ESP32-S3 (WebUSB + Web Serial), next to the standard -System / Monitor / OTA / CoreDump services. +System / Monitor / OTA / CoreDump services, plus three Kconfig-gated hardware +apps: CANopen / DS402 (a simulated node or the TWAI peripheral), an I2C +scanner and Network (Wi-Fi station, Ethernet). diff --git a/components/desktop/example/CMakeLists.txt b/components/desktop/example/CMakeLists.txt index ae118d34b1..b2c15fafd2 100644 --- a/components/desktop/example/CMakeLists.txt +++ b/components/desktop/example/CMakeLists.txt @@ -18,14 +18,48 @@ set(CMAKE_CXX_STANDARD 20) # from the vendored git submodules under external/ and littlefs from the # components/littlefs submodule. include($ENV{IDF_PATH}/tools/cmake/project.cmake) +include($ENV{IDF_PATH}/tools/cmake/version.cmake) + +# The CANopen app (simulated node or TWAI peripheral) is built on espp's twai +# component, which needs the IDF >= 6.0 esp_driver_twai node API (the simulated +# bus is a Twai drop-in and shares its Message type). The rest of the example +# builds on IDF 5.5, so the canopen / twai components are only pulled in when +# the IDF supports them -- or when overridden on the command line: +# idf.py -DDESKTOP_EXAMPLE_CANOPEN=OFF build (leave the CANopen app out) +# With it OFF, CONFIG_DESKTOP_EXAMPLE_ENABLE_CANOPEN has no effect (the app is +# not compiled; app_main logs a warning if it is enabled). +if(IDF_VERSION_MAJOR GREATER_EQUAL 6) + set(DESKTOP_EXAMPLE_CANOPEN_DEFAULT ON) +else() + set(DESKTOP_EXAMPLE_CANOPEN_DEFAULT OFF) +endif() +option(DESKTOP_EXAMPLE_CANOPEN "Build the CANopen / DS402 app (needs IDF >= 6.0 for twai)" + ${DESKTOP_EXAMPLE_CANOPEN_DEFAULT}) +# main/CMakeLists.txt needs the decision for its REQUIRES, which IDF evaluates +# in a separate script process (no CMake cache there): hand it over as a build +# property, which IDF writes into that process. +idf_build_set_property(DESKTOP_EXAMPLE_CANOPEN "${DESKTOP_EXAMPLE_CANOPEN}") +if(DESKTOP_EXAMPLE_CANOPEN) + set(DESKTOP_EXAMPLE_CANOPEN_DIRS "../../../components/canopen" "../../../components/twai") + set(DESKTOP_EXAMPLE_CANOPEN_COMPONENTS "canopen twai") + message(STATUS "desktop example: CANopen / DS402 app ON (IDF ${IDF_VERSION_MAJOR}.${IDF_VERSION_MINOR})") +else() + set(DESKTOP_EXAMPLE_CANOPEN_DIRS "") + set(DESKTOP_EXAMPLE_CANOPEN_COMPONENTS "") + message(STATUS "desktop example: CANopen / DS402 app OFF (DESKTOP_EXAMPLE_CANOPEN; twai needs IDF >= 6.0)") +endif() set(EXTRA_COMPONENT_DIRS "../../../components/base_component" + ${DESKTOP_EXAMPLE_CANOPEN_DIRS} + "../../../components/cli" "../../../components/coredump" "../../../components/desktop" "../../../components/dispatcher" + "../../../components/ethernet" "../../../components/file_system" "../../../components/format" + "../../../components/i2c" "../../../components/logger" "../../../components/monitor" "../../../components/nvs" @@ -34,6 +68,7 @@ set(EXTRA_COMPONENT_DIRS "../../../components/system" "../../../components/task" "../../../components/usb_device" + "../../../components/wifi" ) if(DEFINED ENV{IDF_COMPONENT_MANAGER} AND "$ENV{IDF_COMPONENT_MANAGER}" STREQUAL "0") @@ -44,11 +79,19 @@ if(DEFINED ENV{IDF_COMPONENT_MANAGER} AND "$ENV{IDF_COMPONENT_MANAGER}" STREQUAL ) endif() -set( - COMPONENTS - "main esptool_py base_component desktop dispatcher file_system format logger monitor nvs stream_frame system task usb_device esp_tinyusb esp_psram coredump ota espcoredump littlefs" - CACHE STRING - "List of components to include" - ) +# The component list is a cache entry like in the other examples, so a caller's +# -DCOMPONENTS=... wins; the CANopen-dependent default is only computed when +# the caller did not supply one. (main's REQUIRES still resolve canopen / twai +# from EXTRA_COMPONENT_DIRS whenever DESKTOP_EXAMPLE_CANOPEN is ON, whatever +# the list says: that decision travels as a build property, not through +# COMPONENTS.) +if(NOT DEFINED COMPONENTS) + set( + COMPONENTS + "main esptool_py base_component desktop dispatcher file_system format logger monitor nvs stream_frame system task usb_device esp_tinyusb esp_psram coredump ota espcoredump littlefs i2c wifi ethernet cli ${DESKTOP_EXAMPLE_CANOPEN_COMPONENTS}" + CACHE STRING + "List of components to include" + ) +endif() project(desktop_example) diff --git a/components/desktop/example/README.md b/components/desktop/example/README.md index b0325503d6..2b17e1d87d 100644 --- a/components/desktop/example/README.md +++ b/components/desktop/example/README.md @@ -30,6 +30,34 @@ Apps (`main/apps/*.hpp`, one `register__app()` each): console; the capture itself is the compile-time `CONFIG_DESKTOP_EXAMPLE_LOG_CAPTURE`), all kept in NVS and restored at boot. +Hardware apps, each behind a Kconfig option (see +[Configuration](#configuration)). CANopen (on the simulated node), the I2C +scanner and the Wi-Fi group are on by default, so the CI build compiles them; +the Ethernet group defaults off and is only selectable on SoCs with an EMAC +(ESP32 / ESP32-P4, not the S3): + +- **CANopen / DS402** — a CiA 301 NMT master + SDO client + (`espp::CanopenClient`) and a CiA 402 drive panel (`espp::Ds402Drive`): + node id, NMT Start / Stop / Pre-operational / Reset, NMT + drive state and + statusword, mode of operation, Enable / Disable / Quick stop / Fault reset, + a target-velocity slider, position / velocity, and a raw SDO read / write + row. The bus is either the in-firmware **simulated DS402 node** (the CAN + bridge example's `SimulatedCanBus`, no hardware needed, with an "Inject + fault" button) or the **TWAI peripheral** wired to a CAN transceiver. Every + bus transaction runs on the app's own task (SDO calls block); the window + only queues commands and the task updates the widgets. +- **I2C scanner** — probe every 7-bit address on the configured bus + (`espp::I2c`) from a short task and list what answers; read / write a + device register from the window. A bus that fails to initialize shows a + hint instead. +- **Network** — the Wi-Fi station (`espp::WifiSta`): status / SSID / IP / + RSSI / MAC, Scan (on its own task; a scan disconnects first), the AP list, + password field and Connect / Disconnect / Forget with the credentials kept + in NVS (`desktop` namespace, `wifi_ssid` / `wifi_pass`); and, on SoCs with an + EMAC, the RMII Ethernet link (`espp::Ethernet`): link / IP / MAC / speed. + The interfaces come up on the first launch and stay up when the window is + closed. + ## How to use example ### Hardware Required @@ -39,6 +67,48 @@ console / logs go to UART0 (see `sdkconfig.defaults`); with `CONFIG_DESKTOP_EXAMPLE_LOG_CAPTURE` (default on) they are also captured for the Log Viewer. +The hardware apps need nothing extra by default: the CANopen app talks to a +simulated node, the I2C scanner just reports an empty bus and the Network app +scans for Wi-Fi. For a real CAN bus select the TWAI peripheral and wire a +transceiver (SN65HVD230 or similar) to the configured TX / RX GPIOs; for the +Ethernet group an ESP32-Ethernet-Kit or an ESP32-P4-Function-EV-Board (the +RMII wiring is selected by `DESKTOP_EXAMPLE_ETHERNET_BOARD`; other boards: +edit `rmii_config()` in `main/apps/network_app.hpp`). + +### Configuration + +`idf.py menuconfig` -> *Desktop Example Configuration*: + +| Option | Default | Meaning | +|---|---|---| +| `DESKTOP_EXAMPLE_LOG_CAPTURE` (+ `_BYTES`) | y (16384) | Tee the console into a ring for the Log Viewer | +| `DESKTOP_EXAMPLE_ENABLE_CANOPEN` | y | Register the CANopen / DS402 app | +| `DESKTOP_EXAMPLE_CANOPEN_BUS` | `SIMULATED` | `SIMULATED` (in-firmware DS402 node) or `TWAI` (the peripheral) | +| `DESKTOP_EXAMPLE_CANOPEN_NODE_ID` | 1 | Server node id (1..127; also the simulated node's id) | +| `DESKTOP_EXAMPLE_CAN_TX_GPIO` / `_RX_GPIO` / `_BAUDRATE` | 17 / 16 / 500000 | TWAI wiring and bit rate (TWAI bus only) | +| `DESKTOP_EXAMPLE_ENABLE_I2C` | y | Register the I2C scanner app | +| `DESKTOP_EXAMPLE_I2C_PORT` / `_SDA_GPIO` / `_SCL_GPIO` / `_FREQ_HZ` | 0 / 8 / 9 / 400000 | The I2C bus it scans | +| `DESKTOP_EXAMPLE_ENABLE_WIFI` | y | The Network app's Wi-Fi station group (`SOC_WIFI_SUPPORTED`) | +| `DESKTOP_EXAMPLE_ENABLE_ETHERNET` | n | The Network app's RMII Ethernet group (`SOC_EMAC_SUPPORTED`: ESP32 / -P4) | +| `DESKTOP_EXAMPLE_ETHERNET_BOARD` | per target | RMII wiring: `ETHERNET_KIT` (ESP32-Ethernet-Kit) or `P4_FUNCTION_EV` (ESP32-P4-Function-EV-Board, with its routable data pins) | + +The hardware components (`i2c`, `wifi`, `ethernet`, `cli`) are always part +of the build (`REQUIRES` cannot depend on Kconfig); the options only decide +which apps are registered. + +**Minimum IDF.** The example itself builds on IDF 5.5 (the `desktop`, +`usb_device`, `ethernet` (>= 5.4), `wifi`, `i2c` and `canopen` components +all support it). The CANopen app is the exception: it is built on the `twai` +component (the simulated bus is a `Twai` drop-in), which needs the IDF >= 6.0 +`esp_driver_twai` node API. The CMakeLists therefore adds `canopen` + `twai` +and compiles the app only on IDF >= 6.0, controlled by the CMake option +`DESKTOP_EXAMPLE_CANOPEN` (default ON on IDF >= 6, OFF below; override with +`idf.py -DDESKTOP_EXAMPLE_CANOPEN=OFF build`). On an older IDF +`CONFIG_DESKTOP_EXAMPLE_ENABLE_CANOPEN` has no effect and `app_main` logs a +warning. The simulated CAN bus / DS402 node headers are included from the CAN +bridge example (`components/canopen/can_bridge_example/main`); promoting them +into the `canopen` component is a follow-up. + ### Build and Flash ``` diff --git a/components/desktop/example/main/CMakeLists.txt b/components/desktop/example/main/CMakeLists.txt index 3d6d6ccd8a..9a7fe2d78c 100644 --- a/components/desktop/example/main/CMakeLists.txt +++ b/components/desktop/example/main/CMakeLists.txt @@ -1,4 +1,28 @@ +# The hardware components are required unconditionally (REQUIRES cannot depend +# on Kconfig); the apps themselves are gated by CONFIG_DESKTOP_EXAMPLE_ENABLE_* +# in desktop_example.cpp. The exception is the CANopen app: espp's twai +# component needs IDF >= 6.0, so canopen / twai are only required when the +# top-level CMakeLists enabled DESKTOP_EXAMPLE_CANOPEN (ON by default on IDF +# >= 6; read back as a build property because IDF evaluates REQUIRES in a +# separate script process), and the app is compiled only when +# DESKTOP_EXAMPLE_CANOPEN_AVAILABLE is defined. +idf_build_get_property(desktop_example_canopen DESKTOP_EXAMPLE_CANOPEN) +set(desktop_example_requires desktop system monitor file_system nvs dispatcher task usb_device + esp_tinyusb coredump ota espcoredump i2c wifi ethernet) +set(desktop_example_include_dirs "." "apps") +if(desktop_example_canopen) + list(APPEND desktop_example_requires canopen twai) + # The CANopen app's simulated bus / DS402 node live in the can_bridge example + # for now (simulated_can_bus.hpp, simulated_ds402_node.hpp); promoting them + # into the canopen component is a follow-up. + list(APPEND desktop_example_include_dirs "../../../canopen/can_bridge_example/main") +endif() + idf_component_register( SRC_DIRS "." - INCLUDE_DIRS "." "apps" - REQUIRES desktop system monitor file_system nvs dispatcher task usb_device esp_tinyusb coredump ota espcoredump) + INCLUDE_DIRS ${desktop_example_include_dirs} + REQUIRES ${desktop_example_requires}) + +if(desktop_example_canopen) + target_compile_definitions(${COMPONENT_LIB} PRIVATE DESKTOP_EXAMPLE_CANOPEN_AVAILABLE=1) +endif() diff --git a/components/desktop/example/main/Kconfig.projbuild b/components/desktop/example/main/Kconfig.projbuild index 267be8630b..5c677c031f 100644 --- a/components/desktop/example/main/Kconfig.projbuild +++ b/components/desktop/example/main/Kconfig.projbuild @@ -18,4 +18,125 @@ menu "Desktop Example Configuration" How many of the newest console bytes are kept for the Log Viewer (and shown when it opens). + config DESKTOP_EXAMPLE_ENABLE_CANOPEN + bool "CANopen / DS402 app" + default y + help + Register the CANopen / DS402 app: an NMT master + SDO client + (espp::CanopenClient) and a CiA 402 drive panel (espp::Ds402Drive) + on the bus selected below. + + The app is built on espp's twai component (the simulated bus is a + Twai drop-in), which needs IDF >= 6.0; the example's CMakeLists + therefore only compiles it on IDF >= 6 (or with + -DDESKTOP_EXAMPLE_CANOPEN=ON/OFF on the idf.py command line). On an + older IDF this option has no effect and app_main logs a warning. + + choice DESKTOP_EXAMPLE_CANOPEN_BUS + prompt "CAN bus for the CANopen app" + depends on DESKTOP_EXAMPLE_ENABLE_CANOPEN + default DESKTOP_EXAMPLE_CANOPEN_BUS_SIMULATED + help + Where the CANopen app's frames go: an in-firmware simulated DS402 + node (no hardware needed; the CI default) or the TWAI peripheral + wired to a CAN transceiver. + + config DESKTOP_EXAMPLE_CANOPEN_BUS_SIMULATED + bool "Simulated DS402 node (no CAN hardware)" + config DESKTOP_EXAMPLE_CANOPEN_BUS_TWAI + bool "TWAI peripheral (CAN transceiver on the GPIOs below)" + endchoice + + config DESKTOP_EXAMPLE_CANOPEN_NODE_ID + int "CANopen node id" + depends on DESKTOP_EXAMPLE_ENABLE_CANOPEN + default 1 + range 1 127 + help + The server node id the app talks to at launch (changeable in the + window). With the simulated bus this is also the simulated node's id. + + config DESKTOP_EXAMPLE_CAN_TX_GPIO + int "TWAI TX GPIO" + depends on DESKTOP_EXAMPLE_CANOPEN_BUS_TWAI + default 17 + + config DESKTOP_EXAMPLE_CAN_RX_GPIO + int "TWAI RX GPIO" + depends on DESKTOP_EXAMPLE_CANOPEN_BUS_TWAI + default 16 + + config DESKTOP_EXAMPLE_CAN_BAUDRATE + int "CAN bit rate (bit/s)" + depends on DESKTOP_EXAMPLE_CANOPEN_BUS_TWAI + default 500000 + range 10000 1000000 + + config DESKTOP_EXAMPLE_ENABLE_I2C + bool "I2C scanner app" + default y + help + Register the I2C scanner app: probe every 7-bit address on the + bus below, and read / write device registers. + + config DESKTOP_EXAMPLE_I2C_PORT + int "I2C port" + depends on DESKTOP_EXAMPLE_ENABLE_I2C + default 0 + range 0 1 + + config DESKTOP_EXAMPLE_I2C_SDA_GPIO + int "I2C SDA GPIO" + depends on DESKTOP_EXAMPLE_ENABLE_I2C + default 8 + + config DESKTOP_EXAMPLE_I2C_SCL_GPIO + int "I2C SCL GPIO" + depends on DESKTOP_EXAMPLE_ENABLE_I2C + default 9 + + config DESKTOP_EXAMPLE_I2C_FREQ_HZ + int "I2C clock (Hz)" + depends on DESKTOP_EXAMPLE_ENABLE_I2C + default 400000 + range 1000 1000000 + + config DESKTOP_EXAMPLE_ENABLE_WIFI + bool "Network app: Wi-Fi station" + depends on SOC_WIFI_SUPPORTED + default y + help + Register the Network app's Wi-Fi group: status, scan, connect / + disconnect with the credentials kept in NVS (espp::WifiSta). + + config DESKTOP_EXAMPLE_ENABLE_ETHERNET + bool "Network app: Ethernet (RMII)" + depends on SOC_EMAC_SUPPORTED + default n + help + Register the Network app's Ethernet group: link / IP / MAC / speed + of the internal EMAC over RMII (espp::Ethernet) with the wiring of + the board selected below. + + choice DESKTOP_EXAMPLE_ETHERNET_BOARD + prompt "RMII board wiring" + depends on DESKTOP_EXAMPLE_ENABLE_ETHERNET + default DESKTOP_EXAMPLE_ETHERNET_BOARD_ETHERNET_KIT if IDF_TARGET_ESP32 + default DESKTOP_EXAMPLE_ETHERNET_BOARD_P4_FUNCTION_EV if IDF_TARGET_ESP32P4 + help + Which board's RMII pins network_app.hpp uses: the ESP32-Ethernet-Kit + (IP101 PHY, MDC 23 / MDIO 18 / reset 5, 50 MHz clock in on GPIO 0; + the ESP32's data pins are fixed) or the ESP32-P4-Function-EV-Board + (IP101 PHY, MDC 31 / MDIO 52 / reset 51, clock in on GPIO 50, and + the routable data pins TX_EN 49 / TXD0 34 / TXD1 35 / CRS_DV 28 / + RXD0 29 / RXD1 30). Other boards: edit rmii_config(). + + config DESKTOP_EXAMPLE_ETHERNET_BOARD_ETHERNET_KIT + bool "ESP32-Ethernet-Kit" + depends on IDF_TARGET_ESP32 + config DESKTOP_EXAMPLE_ETHERNET_BOARD_P4_FUNCTION_EV + bool "ESP32-P4-Function-EV-Board" + depends on IDF_TARGET_ESP32P4 + endchoice + endmenu diff --git a/components/desktop/example/main/apps/canopen_app.hpp b/components/desktop/example/main/apps/canopen_app.hpp new file mode 100644 index 0000000000..422276aa4c --- /dev/null +++ b/components/desktop/example/main/apps/canopen_app.hpp @@ -0,0 +1,572 @@ +#pragma once + +// CANopen / DS402: an NMT master + SDO client (espp::CanopenClient) and a CiA +// 402 drive panel (espp::Ds402Drive) on the bus Kconfig selects -- the +// in-firmware simulated DS402 node (the can_bridge example's SimulatedCanBus) +// or the TWAI peripheral. SDO calls block (the bus delivers the responses from +// its own receive task), so every bus transaction runs on the app's drive +// task: the widget handlers only queue commands, and the drive task polls the +// statusword / position / velocity and updates the widgets from there (any +// task may call the mutators). + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sdkconfig.h" + +#include "canopen_client.hpp" +#include "desktop.hpp" +#include "ds402.hpp" +#include "task.hpp" +#if CONFIG_DESKTOP_EXAMPLE_CANOPEN_BUS_SIMULATED +#include "simulated_can_bus.hpp" +#else +#include "twai.hpp" +#endif + +namespace desktop_example { + +#if CONFIG_DESKTOP_EXAMPLE_CANOPEN_BUS_SIMULATED +using CanBus = can_bridge::SimulatedCanBus; +#else +using CanBus = espp::Twai; +#endif + +/// One CANopen window's bus, client, drive and drive task. Rebuilt in place +/// (on the drive task) when the node id changes; shut down from the window's +/// on_close, which runs on the desktop task with no lock held. +struct CanopenSession { + using D = espp::Desktop; + using CanFrame = espp::CanopenClient::CanFrame; + using NmtState = espp::CanopenClient::NmtState; + + explicit CanopenSession(D &desktop) + : desktop(desktop) {} + ~CanopenSession() { shutdown(); } + + D &desktop; + D::Widget nmt_label, bus_label, state_label, status_label, mode_label, pos_label, vel_label, + sdo_result; + uint8_t node_id{1}; + // destroyed in reverse order: task (stopped first), then the bus (its + // receive task feeds the client), then the drive (references the client) + std::unique_ptr client; + std::unique_ptr drive; + std::unique_ptr bus; + std::mutex mutex; + std::condition_variable cv; + std::deque> queue; // bounded by kMaxQueued (see run()) + bool stopping{false}; + bool running{false}; // the drive task consumes `queue` (guarded by `mutex`) + std::unique_ptr task; + + /// One unsigned number taking the whole field (blanks around it allowed; + /// `base` 0 = "0x.." hex or decimal); nullopt when the field is empty, + /// malformed (trailing junk, a sign), overflows or exceeds `max`, so a bad + /// entry is never narrowed into a different object or value. + static std::optional parse_field(const std::string &text, int base, uint32_t max) { + const char *p = text.c_str(); + while (*p == ' ') + ++p; + if (!*p || *p == '-' || *p == '+') + return std::nullopt; + // base 0 would also treat a leading zero as octal ("010" -> 8); the UI + // documents decimal with an optional 0x prefix, so choose the base here + if (base == 0) + base = (p[0] == '0' && (p[1] == 'x' || p[1] == 'X')) ? 16 : 10; + char *end = nullptr; + errno = 0; + const unsigned long v = std::strtoul(p, &end, base); + if (end == p || errno == ERANGE) + return std::nullopt; + while (*end == ' ') + ++end; + if (*end || v > max) + return std::nullopt; + return static_cast(v); + } + + static const char *nmt_name(NmtState s) { + switch (s) { + case NmtState::BootUp: + return "boot-up"; + case NmtState::Stopped: + return "stopped"; + case NmtState::Operational: + return "operational"; + case NmtState::PreOperational: + return "pre-operational"; + default: + return "unknown"; + } + } + + /// (Re)create the bus, client and drive for `id`. Called at launch (before + /// the drive task runs) and from the drive task afterwards. + bool open(uint8_t id, std::error_code &ec) { + bus.reset(); // first: nothing feeds the client while it is replaced + drive.reset(); + client.reset(); + node_id = id; + client = std::make_unique(espp::CanopenClient::Config{ + .node_id = id, + .send = + [this](const CanFrame &f) { + if (!bus) + return false; + CanBus::Message m{ + .id = f.id, .extended = f.extended, .rtr = f.rtr, .dlc = f.dlc, .data = f.data}; + std::error_code tx_ec; + return bus->transmit(m, tx_ec); + }, + .on_heartbeat = + [this](uint8_t node, NmtState s) { // bus task + if (node == node_id) + nmt_label.set_text("NMT state: {}", nmt_name(s)); + }, + }); + drive = std::make_unique(*client); + CanBus::Config cfg; +#if CONFIG_DESKTOP_EXAMPLE_CANOPEN_BUS_SIMULATED + cfg.node_id = id; +#else + cfg.tx_gpio = CONFIG_DESKTOP_EXAMPLE_CAN_TX_GPIO; + cfg.rx_gpio = CONFIG_DESKTOP_EXAMPLE_CAN_RX_GPIO; + cfg.baudrate = CONFIG_DESKTOP_EXAMPLE_CAN_BAUDRATE; +#endif + cfg.on_receive = [this](const CanBus::Message &m) { // bus receive task, never SDO here + if (client) + client->process_frame( + {.id = m.id, .extended = m.extended, .rtr = m.rtr, .dlc = m.dlc, .data = m.data}); + }; + bus = std::make_unique(cfg); + if (!bus->initialize(ec)) { + bus.reset(); + clear_status("no bus"); // nothing polls now: do not leave the old node's values up + return false; + } + nmt_label.set_text("NMT state: (nothing heard from node {} yet)", id); + return true; + } + + /// Reset every status widget (the previous node's values must not stay + /// shown once nothing is talking to it). + void clear_status(std::string_view why) { + nmt_label.set_text("NMT state: - ({})", why); + state_label.set_text("State: - ({})", why); + status_label.set_text("Statusword: -"); + mode_label.set_text("Mode (0x6061): -"); + pos_label.set_text("Position: -"); + vel_label.set_text("Velocity: -"); + } + + /// Queue a bus transaction for the drive task; refused (with a toast) when + /// the task is not running or too many commands are already waiting. + static constexpr size_t kMaxQueued = 64; + bool run(std::function fn) { + const char *refused = nullptr; + { + std::lock_guard lock(mutex); + if (!running) + refused = "the drive task is not running"; + else if (queue.size() >= kMaxQueued) + refused = "too many commands pending; wait for the bus"; + else + queue.push_back(std::move(fn)); + } + if (refused) { + desktop.notify({.title = "CANopen", .text = refused, .level = D::NotifyLevel::Error}); + return false; + } + cv.notify_all(); + return true; + } + + /// `what` failed with `ec`: a toast (level error). `sdo` says the failing + /// call was an SDO transaction, the only case in which the client's + /// last_abort_code() describes THIS failure (NMT sends and bus init never + /// touch it, so appending it there would show a stale, unrelated abort). + void fail(std::string_view what, const std::error_code &ec, bool sdo = false) { + std::string text = fmt::format("{}: {}", what, ec.message()); + if (sdo && client && client->last_abort_code()) + text += fmt::format(" (SDO abort 0x{:08X}: {})", client->last_abort_code(), + espp::CanopenClient::abort_code_to_string(client->last_abort_code())); + desktop.notify({.title = "CANopen", .text = text, .level = D::NotifyLevel::Error}); + } + + /// Start the drive task; false (with a toast) when it could not be started, + /// in which case run() refuses every command. + bool start() { + task = std::make_unique( + espp::Task::Config{.callback = [this]() { return step(); }, + .task_config = {.name = "canopen_ui", .stack_size_bytes = 6 * 1024}}); + const bool started = task->start(); + { + std::lock_guard lock(mutex); + running = started; + } + if (!started) { + task.reset(); + desktop.notify({.title = "CANopen", + .text = "could not start the drive task; the window is inert", + .level = D::NotifyLevel::Error}); + } + return started; + } + + void shutdown() { + { + std::lock_guard lock(mutex); + stopping = true; + running = false; + queue.clear(); // queued commands hold a reference to this session + } + cv.notify_all(); + task.reset(); // joins: the step returns within one poll period + bus.reset(); + drive.reset(); + client.reset(); + } + + /// One drive-task iteration: a queued command, or (every 250 ms) a poll. + bool step() { + using namespace std::chrono_literals; + std::function fn; + { + std::unique_lock lock(mutex); + cv.wait_for(lock, 250ms, [this] { return stopping || !queue.empty(); }); + if (stopping) + return true; + if (!queue.empty()) { + fn = std::move(queue.front()); + queue.pop_front(); + } + } + if (fn) + fn(); + else + poll(); + return false; + } + + void poll() { + if (!bus || !drive) + return; + std::error_code ec; + const uint16_t sw = drive->get_statusword(ec); + if (ec) { + // the dependent fields are stale too: clear them next to the message + clear_status("no response"); + state_label.set_text("State: no response from node {} ({})", node_id, ec.message()); + return; + } + state_label.set_text("State: {}", + espp::Ds402Drive::to_string(espp::Ds402Drive::state_from_statusword(sw))); + status_label.set_text("Statusword: 0x{:04X}", sw); + // one error code per field: the client only sets ec on failure (never + // clears it), so a shared one would hide every field after a failed read + // ... and a field whose read failed shows that, not its previous value + std::error_code mode_ec, pos_ec, vel_ec; + const int8_t mode = drive->get_mode_display(mode_ec); + if (!mode_ec) + mode_label.set_text("Mode (0x6061): {}", mode); + else + mode_label.set_text("Mode (0x6061): unavailable ({})", mode_ec.message()); + const int32_t pos = drive->get_position_actual(pos_ec); + if (!pos_ec) + pos_label.set_text("Position: {}", pos); + else + pos_label.set_text("Position: unavailable ({})", pos_ec.message()); + const int32_t vel = drive->get_velocity_actual(vel_ec); + if (!vel_ec) + vel_label.set_text("Velocity: {}", vel); + else + vel_label.set_text("Velocity: unavailable ({})", vel_ec.message()); + } +}; + +} // namespace desktop_example + +inline void register_canopen_app(espp::Desktop &desktop) { + desktop.register_app({ + .name = "CANopen", + .icon = "\xF0\x9F\x94\x8C", // electric plug + .description = "CiA 301 NMT / SDO master and a CiA 402 drive panel", + .launch = + [](espp::Desktop &d, espp::Desktop::AppId app) { + using D = espp::Desktop; + using Mode = espp::Ds402Drive::OperatingMode; + auto st = std::make_shared(d); + // on_close owns the blocking teardown (it runs on the desktop task + // with no lock held, after the widgets -- and their handler copies + // of `st` -- are gone) + auto win = d.create_window( + {.title = "CANopen / DS402", .app = app, .w = 560, .h = 0, .on_close = [st]() { + st->shutdown(); + }}); + using S = desktop_example::CanopenSession; + + // ---- bus / node ---- + auto top = win.row(); + win.label("Node id", top.id()); + auto node_box = win.textbox(fmt::format("{}", CONFIG_DESKTOP_EXAMPLE_CANOPEN_NODE_ID), + nullptr, top.id(), "1-127"); + node_box.set_size(60, 0); + win.button( + "Apply", + [=]() mutable { + const auto parsed = S::parse_field(node_box.text(), 10, 127); + if (!parsed || *parsed < 1) { + st->desktop.notify({.title = "CANopen", + .text = "node id must be a decimal number 1..127", + .level = D::NotifyLevel::Error}); + return; + } + const uint8_t id = static_cast(*parsed); + st->run([=]() { + std::error_code ec; + if (!st->open(id, ec)) + st->fail("bus init", ec); + else + st->desktop.notify({.title = "CANopen", + .text = fmt::format("talking to node {}", id), + .level = D::NotifyLevel::Ok}); + }); + }, + top.id()); +#if CONFIG_DESKTOP_EXAMPLE_CANOPEN_BUS_SIMULATED + st->bus_label = win.label("simulated DS402 node", top.id()); +#else + st->bus_label = win.label(fmt::format("TWAI tx {} rx {} @ {} bit/s", + CONFIG_DESKTOP_EXAMPLE_CAN_TX_GPIO, + CONFIG_DESKTOP_EXAMPLE_CAN_RX_GPIO, + CONFIG_DESKTOP_EXAMPLE_CAN_BAUDRATE), + top.id()); +#endif + + // ---- NMT ---- + auto nmt = win.group("NMT"); + auto nmt_row = win.row(nmt.id()); + using NmtFn = bool (espp::CanopenClient::*)(std::error_code &); + auto nmt_button = [=](std::string_view text, NmtFn fn) mutable { + win.button( + text, + [=]() { + st->run([=]() { + std::error_code ec; + if (!st->client || !((*st->client).*fn)(ec)) + st->fail(fmt::format("NMT {}", text), ec); + }); + }, + nmt_row.id()); + }; + nmt_button("Start", &espp::CanopenClient::nmt_start); + nmt_button("Stop", &espp::CanopenClient::nmt_stop); + nmt_button("Pre-operational", &espp::CanopenClient::nmt_pre_operational); + nmt_button("Reset node", &espp::CanopenClient::nmt_reset_node); + st->nmt_label = win.label("NMT state: -", nmt.id(), D::kLabelMonospace); + + // ---- drive ---- + auto drv = win.group("Drive (CiA 402)"); + st->state_label = win.label("State: -", drv.id(), D::kLabelBold); + auto info = win.row(drv.id()); + st->status_label = win.label("Statusword: -", info.id(), D::kLabelMonospace); + st->mode_label = win.label("Mode (0x6061): -", info.id(), D::kLabelMonospace); + auto mode_row = win.row(drv.id()); + win.label("Mode", mode_row.id()); + const std::vector modes = {Mode::ProfilePosition, Mode::ProfileVelocity, + Mode::ProfileTorque, Mode::Homing}; + win.select( + // no selection until the user picks one: 0x6060 is only + // written on a change (the label shows the drive's 0x6061) + {"Profile position", "Profile velocity", "Profile torque", "Homing"}, + D::kNoSelection, + [=](int32_t i) { + if (i < 0 || static_cast(i) >= modes.size()) + return; + const Mode m = modes[static_cast(i)]; + st->run([=]() { + std::error_code ec; + if (!st->drive || !st->drive->set_mode(m, ec)) + st->fail("set mode", ec, /*sdo=*/true); + }); + }, + mode_row.id()); + auto ctl = win.row(drv.id()); + using DriveFn = bool (espp::Ds402Drive::*)(std::error_code &); + auto drive_button = [=](std::string_view text, DriveFn fn, uint16_t flags = 0) mutable { + win.button( + text, + [=]() { + st->run([=]() { + std::error_code ec; + if (!st->drive || !((*st->drive).*fn)(ec)) + st->fail(text, ec, /*sdo=*/true); // controlword / statusword SDOs + else + st->desktop.notify({.title = "CANopen", + .text = fmt::format("{}: ok", text), + .level = D::NotifyLevel::Ok, + .timeout = std::chrono::milliseconds(1500)}); + }); + }, + ctl.id(), flags); + }; + drive_button("Enable", &espp::Ds402Drive::enable_operation, D::kButtonPrimary); + drive_button("Disable", &espp::Ds402Drive::disable); + drive_button("Quick stop", &espp::Ds402Drive::quick_stop, D::kButtonDanger); + drive_button("Fault reset", &espp::Ds402Drive::fault_reset); +#if CONFIG_DESKTOP_EXAMPLE_CANOPEN_BUS_SIMULATED + win.button( + "Inject fault", + [=]() { + st->run([=]() { // manufacturer object 0x2000 of the simulated node + std::error_code ec; + if (!st->client || !st->client->write_u8(0x2000, 0, 1, ec)) + st->fail("inject fault", ec, /*sdo=*/true); + }); + }, + ctl.id()); +#endif + + // ---- motion ---- + auto motion = win.group("Motion"); + auto vel_row = win.row(motion.id()); + win.label("Target velocity", vel_row.id()); + auto vel_value = win.label("0", vel_row.id(), D::kLabelMonospace); + auto slider = win.slider( + 0, -1000, 1000, [=](int32_t v) mutable { vel_value.set_text("{}", v); }, + vel_row.id(), 10); + win.button( + "Apply", + [=]() { + const int32_t v = slider.value(); + st->run([=]() { + std::error_code ec; + if (!st->drive || !st->drive->set_target_velocity(v, ec)) + st->fail("target velocity", ec, /*sdo=*/true); + }); + }, + vel_row.id(), D::kButtonPrimary); + auto actual = win.row(motion.id()); + st->pos_label = win.label("Position: -", actual.id(), D::kLabelMonospace); + st->vel_label = win.label("Velocity: -", actual.id(), D::kLabelMonospace); + + // ---- SDO ---- + auto sdo = win.group("SDO"); + auto sdo_row = win.row(sdo.id()); + auto idx_box = win.textbox("6041", nullptr, sdo_row.id(), "index (hex)"); + idx_box.set_size(70, 0); + auto sub_box = win.textbox("0", nullptr, sdo_row.id(), "sub (hex)"); + sub_box.set_size(50, 0); + auto width = win.select({"u8", "u16", "u32"}, 1, nullptr, sdo_row.id()); + auto val_box = win.textbox("0", nullptr, sdo_row.id(), "value (0x.. or decimal)"); + val_box.set_size(110, 0); + st->sdo_result = win.label("", sdo.id(), D::kLabelMonospace | D::kLabelWrap); + // index / sub-index from the text boxes, or nullopt (with the + // reason in the result label) when either field is not a whole + // hex number within its width + auto object = [=]() mutable -> std::optional> { + const auto index = S::parse_field(idx_box.text(), 16, 0xFFFF); + const auto sub = S::parse_field(sub_box.text(), 16, 0xFF); + if (!index || !sub) { + st->sdo_result.set_text(!index ? "index must be a hex value 0000..FFFF" + : "sub-index must be a hex value 00..FF"); + return std::nullopt; + } + return std::pair{static_cast(*index), + static_cast(*sub)}; + }; + win.button( + "Read", + [=]() mutable { + const auto obj = object(); + if (!obj) + return; + const auto [index, sub] = *obj; + // the width selector sizes the upload buffer: when an + // expedited response omits its size, sdo_upload() returns + // the caller's width, so a 4-byte span would show an + // un-sized u8 / u16 object as 4 bytes + const int32_t w = width.selected(); + const size_t want = w == 0 ? 1 : w == 1 ? 2 : 4; + st->run([=]() { + std::error_code ec; + std::array buf{}; + const std::span out(buf.data(), want); + const size_t n = st->client ? st->client->sdo_upload(index, sub, out, ec) : 0; + if (!n) { + st->sdo_result.set_text("read 0x{:04X}:{:02X} failed: {}", index, sub, + ec.message()); + st->fail("SDO read", ec, /*sdo=*/true); + return; + } + const uint32_t v = espp::detail::canopen::get_le(buf.data(), n); + st->sdo_result.set_text("0x{:04X}:{:02X} = 0x{:0{}X} ({}) [{} bytes]", index, + sub, v, n * 2, v, n); + }); + }, + sdo_row.id()); + win.button( + "Write", + [=]() mutable { + const auto obj = object(); + if (!obj) + return; + const auto [index, sub] = *obj; + const int32_t w = width.selected(); + const uint32_t max = w == 0 ? 0xFF : w == 1 ? 0xFFFF : 0xFFFFFFFFu; + const auto parsed = S::parse_field(val_box.text(), 0, max); + if (!parsed) { + st->sdo_result.set_text("value must be a decimal or 0x.. number within {}", + w == 0 ? "u8 (0..255)" + : w == 1 ? "u16 (0..65535)" + : "u32 (0..4294967295)"); + return; + } + const uint32_t value = *parsed; + st->run([=]() { + std::error_code ec; + bool ok = false; + if (st->client) { + if (w == 0) + ok = st->client->write_u8(index, sub, static_cast(value), ec); + else if (w == 1) + ok = st->client->write_u16(index, sub, static_cast(value), ec); + else + ok = st->client->write_u32(index, sub, value, ec); + } + if (ok) + st->sdo_result.set_text("0x{:04X}:{:02X} <- 0x{:X} ok", index, sub, value); + else { + st->sdo_result.set_text("write 0x{:04X}:{:02X} failed: {}", index, sub, + ec.message()); + st->fail("SDO write", ec, /*sdo=*/true); + } + }); + }, + sdo_row.id()); + + // bring the bus up (quick), then start polling + std::error_code ec; + if (!st->open(CONFIG_DESKTOP_EXAMPLE_CANOPEN_NODE_ID, ec)) { + st->state_label.set_text("State: bus init failed ({}); fix the wiring / Kconfig " + "and press Apply", + ec.message()); + st->fail("bus init", ec); + } + if (!st->start()) + st->state_label.set_text("State: the drive task could not be started"); + }, + }); +} diff --git a/components/desktop/example/main/apps/i2c_scanner_app.hpp b/components/desktop/example/main/apps/i2c_scanner_app.hpp new file mode 100644 index 0000000000..927fe9baf7 --- /dev/null +++ b/components/desktop/example/main/apps/i2c_scanner_app.hpp @@ -0,0 +1,318 @@ +#pragma once + +// I2C scanner: probe every 7-bit address on the Kconfig bus (espp::I2c) from +// a short task and list what answers; read / write device registers from the +// window. A bus that fails to initialize shows a hint instead of the tools. + +#include +#include +#include +#include +#include +#include +#include + +#include "sdkconfig.h" + +#include "desktop.hpp" +#include "i2c.hpp" +#include "task.hpp" + +namespace desktop_example { + +struct I2cSession { + std::unique_ptr bus; + std::mutex mutex; + std::vector found; // addresses in table order (scan task writes, handlers read) + std::unique_ptr scan; // the one-shot worker (scan / read / write); joined before + // the bus goes away + + /// Parse a space / comma separated list of hex bytes ("01 ff, 0x10") into + /// a byte list; nullopt when any token is malformed or above 0xFF (the + /// whole field is rejected, nothing is truncated). An empty field is an + /// empty list. + static std::optional> parse_bytes(const std::string &text) { + std::vector out; + const char *p = text.c_str(); + while (*p) { + while (*p == ' ' || *p == ',') + ++p; + if (!*p) + break; + char *end = nullptr; + const unsigned long v = std::strtoul(p, &end, 16); + if (end == p || v > 0xFF || (*end && *end != ' ' && *end != ',')) + return std::nullopt; + out.push_back(static_cast(v)); + p = end; + } + return out; + } + /// One unsigned number in `base` taking the whole field (blanks around it + /// allowed); nullopt when the field is empty, malformed (trailing junk, a + /// sign) or outside `min`..`max`, so a bad entry never becomes a real + /// target (an empty address masked to 0x00 would be the general-call + /// broadcast) or a wrong transfer length. + static std::optional parse_field(const std::string &text, int base, + unsigned long min, unsigned long max) { + const char *p = text.c_str(); + while (*p == ' ') + ++p; + if (!*p || *p == '-' || *p == '+') + return std::nullopt; + char *end = nullptr; + const unsigned long v = std::strtoul(p, &end, base); + if (end == p) + return std::nullopt; + while (*end == ' ') + ++end; + if (*end || v < min || v > max) + return std::nullopt; + return v; + } + /// A 7-bit device address in the addressable range 0x01..0x7F (hex). + static std::optional parse_addr(const std::string &text) { + const auto v = parse_field(text, 16, 0x01, 0x7F); + if (!v) + return std::nullopt; + return static_cast(*v); + } + /// A full 8-bit register value (0x00..0xFF, hex). + static std::optional parse_byte(const std::string &text) { + const auto v = parse_field(text, 16, 0x00, 0xFF); + if (!v) + return std::nullopt; + return static_cast(*v); + } + static std::string hex_dump(const std::vector &bytes) { + std::string s; + for (const auto b : bytes) + s += fmt::format("{:02X} ", b); + return s; + } + static const char *note(uint8_t addr) { + if (addr < 0x08 || addr > 0x77) + return "reserved range"; + return ""; + } +}; + +} // namespace desktop_example + +inline void register_i2c_scanner_app(espp::Desktop &desktop) { + desktop.register_app({ + .name = "I2C", + .icon = "\xF0\x9F\x94\x8D", // magnifying glass + .description = fmt::format( + "Scan and poke the I2C bus (port {}, SDA {}, SCL {})", CONFIG_DESKTOP_EXAMPLE_I2C_PORT, + CONFIG_DESKTOP_EXAMPLE_I2C_SDA_GPIO, CONFIG_DESKTOP_EXAMPLE_I2C_SCL_GPIO), + .launch = + [](espp::Desktop &d, espp::Desktop::AppId app) { + using D = espp::Desktop; + using S = desktop_example::I2cSession; + auto st = std::make_shared(); + auto win = d.create_window( + {.title = "I2C scanner", .app = app, .w = 520, .h = 420, .on_close = [st]() { + st->scan.reset(); // joins a scan in flight + st->bus.reset(); + }}); + win.label(fmt::format("Port {}, SDA GPIO {}, SCL GPIO {}, {} kHz", + CONFIG_DESKTOP_EXAMPLE_I2C_PORT, + CONFIG_DESKTOP_EXAMPLE_I2C_SDA_GPIO, + CONFIG_DESKTOP_EXAMPLE_I2C_SCL_GPIO, + CONFIG_DESKTOP_EXAMPLE_I2C_FREQ_HZ / 1000), + 0, D::kLabelMonospace); + st->bus = std::make_unique(espp::I2c::Config{ + .port = static_cast(CONFIG_DESKTOP_EXAMPLE_I2C_PORT), + .sda_io_num = static_cast(CONFIG_DESKTOP_EXAMPLE_I2C_SDA_GPIO), + .scl_io_num = static_cast(CONFIG_DESKTOP_EXAMPLE_I2C_SCL_GPIO), + .sda_pullup_en = GPIO_PULLUP_ENABLE, + .scl_pullup_en = GPIO_PULLUP_ENABLE, + .clk_speed = CONFIG_DESKTOP_EXAMPLE_I2C_FREQ_HZ, + .auto_init = false, + }); + std::error_code ec; + st->bus->init(ec); + if (ec) { + win.label(fmt::format("The I2C bus failed to initialize: {}. Check the GPIOs in " + "menuconfig (Desktop Example Configuration).", + ec.message()), + 0, D::kLabelWrap); + st->bus.reset(); + return; + } + + // ---- widgets ---- + auto bar = win.row(); + auto status = win.label("Press Scan to probe addresses 0x08..0x77.", bar.id()); + auto table = win.table({"Address", "Decimal", "Note"}, {}, nullptr); + auto scan_btn = win.button("Scan", nullptr, bar.id(), D::kButtonPrimary); + // the reserved 7-bit ranges (0x00..0x07 general call / CBUS / hs-mode + // master codes, 0x78..0x7F 10-bit addressing / device id) are left out + // of a scan unless asked for: traffic there can have side effects + auto include_reserved = std::make_shared(false); + win.checkbox( + "include reserved addresses (0x01..0x07, 0x78..0x7F)", false, + [=](bool on) mutable { + *include_reserved = on; + status.set_text("Press Scan to probe addresses {}.", + on ? "0x01..0x7F" : "0x08..0x77"); + }, + bar.id()); + auto tools = win.group("Register access"); + auto fields = win.row(tools.id()); + auto addr_box = win.textbox("", nullptr, fields.id(), "addr (hex)"); + addr_box.set_size(80, 0); + auto reg_box = win.textbox("0", nullptr, fields.id(), "register (hex)"); + reg_box.set_size(90, 0); + auto len_box = win.textbox("1", nullptr, fields.id(), "length"); + len_box.set_size(60, 0); + auto val_box = win.textbox("", nullptr, fields.id(), "bytes to write (hex, <= 64)"); + auto actions = win.row(tools.id()); + auto result = win.label("", tools.id(), D::kLabelMonospace | D::kLabelWrap); + auto read_btn = win.button("Read", nullptr, actions.id(), D::kButtonPrimary); + auto write_btn = win.button("Write", nullptr, actions.id()); + table.on_event([=](const D::WidgetEvent &e) mutable { + if (e.kind != D::WidgetEventKind::Select || e.value < 0) + return; + std::lock_guard lock(st->mutex); + if (static_cast(e.value) < st->found.size()) + addr_box.set_text("{:02X}", st->found[static_cast(e.value)]); + }); + + // ---- the worker ---- + // Every bus transaction (a scan, a register read or write) runs on + // a single one-shot worker task, never on the desktop task (a scan is up + // to ~1.3 s, a 64-byte write at 100 kHz ~6 ms); the three buttons + // are disabled until it is done and the result is posted back + // through the widget mutators. The bus outlives the task: on_close + // joins it first. The bodies capture raw pointers (not `st`) so the + // task stored in the session does not keep the session alive. + auto set_busy = [=](bool busy) mutable { + scan_btn.set_enabled(!busy); + read_btn.set_enabled(!busy); + write_btn.set_enabled(!busy); + }; + auto run_worker = [=, &d](std::function body) mutable { + if (st->scan && st->scan->is_running()) + return; + st->scan.reset(); // the previous (finished) worker + set_busy(true); + st->scan = std::make_unique(espp::Task::Config{ + .callback = + [=]() mutable { + body(); + set_busy(false); + return true; // one shot + }, + .task_config = {.name = "i2c_worker", .stack_size_bytes = 4 * 1024}}); + if (!st->scan->start()) { + // no task will ever re-enable the controls: restore the window + st->scan.reset(); + set_busy(false); + table.set_enabled(true); + status.set_text("Could not start the I2C worker task."); + d.notify({.title = "I2C", + .text = "could not start the I2C worker task (out of memory?)", + .level = D::NotifyLevel::Error}); + } + }; + + // ---- scan ---- + scan_btn.on_event([=](const D::WidgetEvent &e) mutable { + if (e.kind != D::WidgetEventKind::Click) + return; + if (st->scan && st->scan->is_running()) + return; + // the old rows and their address mapping go BEFORE the worker + // starts (it publishes the new mapping before the new rows) and + // the table stays disabled for the whole scan, so a selection + // can never map an old row to a new address + { + std::lock_guard lock(st->mutex); + st->found.clear(); + } + table.set_items({}); + table.set_selected(D::kNoSelection); + table.set_enabled(false); + status.set_text("Scanning\xE2\x80\xA6"); + auto *bus = st->bus.get(); + auto *session = st.get(); + const uint8_t first = *include_reserved ? 0x01 : 0x08; + const uint8_t last = *include_reserved ? 0x7F : 0x77; + run_worker([=]() mutable { + std::vector rows; + std::vector found; + for (uint8_t addr = first; addr <= last; ++addr) { + if (!bus->probe_device(addr)) + continue; + found.push_back(addr); + rows.push_back(fmt::format("0x{:02X}\t{}\t{}", addr, addr, S::note(addr))); + } + { + std::lock_guard lock(session->mutex); + session->found = found; + } + table.set_items(rows); + table.set_enabled(true); + status.set_text("{} device{} found", rows.size(), rows.size() == 1 ? "" : "s"); + }); + }); + + // ---- register access (parsed on the desktop task, done on the worker) ---- + read_btn.on_event([=](const D::WidgetEvent &e) mutable { + if (e.kind != D::WidgetEventKind::Click) + return; + const auto addr = S::parse_addr(addr_box.text()); + const auto reg = S::parse_byte(reg_box.text()); + if (!addr || !reg) { + result.set_text(!addr ? "address must be a hex value in 0x01..0x7F" + : "register must be a hex value in 0x00..0xFF"); + return; + } + const auto len = S::parse_field(len_box.text(), 10, 1, 64); // decimal + if (!len) { + result.set_text("length must be a decimal number 1..64"); + return; + } + auto *bus = st->bus.get(); + run_worker([=, a = *addr, r = *reg, n = static_cast(*len)]() mutable { + std::vector data(n); + if (bus->read_at_register(a, r, data.data(), data.size())) + result.set_text("0x{:02X} reg 0x{:02X}: {}", a, r, S::hex_dump(data)); + else + result.set_text("0x{:02X} reg 0x{:02X}: read failed (no ACK?)", a, r); + }); + }); + write_btn.on_event([=](const D::WidgetEvent &e) mutable { + if (e.kind != D::WidgetEventKind::Click) + return; + const auto addr = S::parse_addr(addr_box.text()); + const auto reg = S::parse_byte(reg_box.text()); + if (!addr || !reg) { + result.set_text(!addr ? "address must be a hex value in 0x01..0x7F" + : "register must be a hex value in 0x00..0xFF"); + return; + } + const auto bytes = S::parse_bytes(val_box.text()); + if (!bytes) { + result.set_text("bytes must be hex values 00..FF, space / comma separated"); + return; + } + if (bytes->size() > 64) { // the same bound as a read + result.set_text("at most 64 data bytes per write ({} given)", bytes->size()); + return; + } + std::vector data{*reg}; + data.insert(data.end(), bytes->begin(), bytes->end()); + auto *bus = st->bus.get(); + run_worker([=, a = *addr]() mutable { + if (bus->write(a, data.data(), data.size())) + result.set_text("0x{:02X} <- {}ok", a, S::hex_dump(data)); + else + result.set_text("0x{:02X} <- {}failed (no ACK?)", a, S::hex_dump(data)); + }); + }); + }, + }); +} diff --git a/components/desktop/example/main/apps/network_app.hpp b/components/desktop/example/main/apps/network_app.hpp new file mode 100644 index 0000000000..227fa3d5a0 --- /dev/null +++ b/components/desktop/example/main/apps/network_app.hpp @@ -0,0 +1,858 @@ +#pragma once + +// Network: the Wi-Fi station (espp::WifiSta -- status, scan, connect / +// disconnect with the credentials kept in NVS) and, under its own Kconfig +// gate, the RMII Ethernet link (espp::Ethernet). The interfaces are brought +// up on the first launch and kept across windows (closing the window does not +// disconnect); the window's 1 s timer renders the state the driver callbacks +// record. Every station transition (scan, connect, disconnect, forget) runs +// on one worker task as a small explicit state machine (Idle -> Stopping -> +// Configuring -> Associating -> Connected), never on the desktop task, with +// the controls disabled until it is done. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sdkconfig.h" + +#include "nvs.h" + +#include "desktop.hpp" +#include "logger.hpp" +#include "nvs_handle_espp.hpp" +#include "task.hpp" +#if CONFIG_DESKTOP_EXAMPLE_ENABLE_WIFI +#include "esp_wifi.h" +#include "wifi.hpp" +#include "wifi_sta.hpp" +#endif +#if CONFIG_DESKTOP_EXAMPLE_ENABLE_ETHERNET +#include "esp_netif.h" +#include "ethernet.hpp" +#endif + +namespace desktop_example { + +/// The interfaces, shared by every Network window (created on first launch). +struct NetworkState { + using D = espp::Desktop; + + explicit NetworkState(D &desktop) + : desktop(desktop) {} + NetworkState(const NetworkState &) = delete; + NetworkState &operator=(const NetworkState &) = delete; + + /// In practice never reached: the state is owned by the app's launch + /// callback and lives as long as the desktop. Still correct: the worker is + /// joined first (nothing drives the station any more), the STA_STOP + /// handler is withdrawn (registry entry, then the esp_event registration) + /// before the station is destroyed -- ~WifiSta stops the station, which + /// queues a STA_STOP that must not reach a freed `this` -- and the handler + /// itself only touches instances still in the registry. + ~NetworkState() { +#if CONFIG_DESKTOP_EXAMPLE_ENABLE_WIFI + worker.reset(); + { + std::lock_guard lock(registry_mutex()); + live_instances().erase(this); + } + if (sta_stop_handler) { + esp_event_handler_instance_unregister(WIFI_EVENT, WIFI_EVENT_STA_STOP, sta_stop_handler); + sta_stop_handler = nullptr; + } + wifi.reset(); +#endif + } + + /// The live instances a WIFI_EVENT_STA_STOP delivery may touch (a late + /// delivery after an instance withdrew is dropped, whatever the event + /// loop's unregister ordering). + static std::mutex ®istry_mutex() { + static std::mutex m; + return m; + } + static std::set &live_instances() { + static std::set s; + return s; + } + + D &desktop; ///< outlives every task (toasts from the worker) + espp::Logger logger{{.tag = "Network app", .level = espp::Logger::Verbosity::INFO}}; + std::mutex mutex; + std::string wifi_status{"idle"}; + std::string wifi_ip; + std::string wifi_mac; + std::vector scan_ssids; // in AP list order + std::vector scan_rows; // the AP list's items (shown again by a new window) + uint32_t scan_generation{0}; // bumped per completed scan (windows re-sync on change) + + /// The station state machine, driven by the jobs below on the worker task + /// and by the driver callbacks (got-ip -> Connected, retries exhausted -> + /// Idle). `Stopping` is the explicit wait for the DISCONNECTED event. + /// `Recovering` is the deterministic stop / start of the whole station + /// (recover()) after a DISCONNECTED wait timed out. `Failed` is the + /// recoverable error state a transition aborts into when the station could + /// not be stopped (the disconnect was rejected, or the recovery itself + /// failed): nothing is reconfigured, the station is left as it is, the user + /// retries. A later event resolves it (the callbacks still own the phase), + /// and the next job starts with a fresh stop_and_wait(). + enum class Phase : uint8_t { + Idle, + Stopping, + Recovering, + Configuring, + Associating, + Connected, + Failed + }; + std::atomic phase{Phase::Idle}; + /// A job (scan / connect / disconnect / forget) is running on the worker: + /// every station control is disabled, and another job is refused. + std::atomic busy{false}; + /// DESIRED connectivity: set by Connect and by the saved-credentials + /// auto-connect at start, cleared only by Disconnect / Forget -- never by + /// the driver callbacks. A scan stops the station and restores it afterwards + /// when this is set, whatever the transient phase. + std::atomic want_connected{false}; + /// Correlating the asynchronous DISCONNECTED events with the worker's + /// requests. WifiSta fetches its callbacks at DELIVERY time, so "which + /// callback is installed" can never identify an event: the three callbacks + /// are therefore installed ONCE (ensure_wifi) and never replaced -- every + /// reconfigure() passes the same long-lived std::function objects -- and + /// they consult this state under `mutex`. `disconnected_events` counts the + /// deliveries stop_and_wait() waits on. When that wait times out the + /// station is RECOVERED deterministically (recover(): esp_wifi_stop(), + /// wait for WIFI_EVENT_STA_STOP, esp_wifi_start()) -- the stop tears down + /// any in-flight association, every event of the old session is delivered + /// before STA_STOP, and nothing trails after it -- so the worker never + /// resumes with an unconfirmed disconnect outstanding and no credit / + /// drain bookkeeping is needed. + std::condition_variable disconnected_cv; // with `mutex`: a DISCONNECTED event arrived + uint32_t disconnected_events{0}; // under `mutex` + std::condition_variable stopped_cv; // with `mutex`: WIFI_EVENT_STA_STOP arrived + uint32_t stopped_events{0}; // under `mutex` + std::string cur_ssid, cur_pass; // the credentials of the current attempt (under `mutex`) +#if CONFIG_DESKTOP_EXAMPLE_ENABLE_WIFI + std::unique_ptr wifi; + std::unique_ptr worker; // after wifi: joined before it goes away + esp_event_handler_instance_t sta_stop_handler{nullptr}; // WIFI_EVENT_STA_STOP -> stopped_cv + // the long-lived callbacks (see above); they run on the event-loop task + espp::WifiSta::connect_callback on_connected_fn = [this]() { + set_status("connected, waiting for an IP"); + }; + espp::WifiSta::disconnect_callback on_disconnected_fn = [this]() { + { + std::lock_guard lock(mutex); + ++disconnected_events; // a confirmation stop_and_wait() may be waiting on + } + disconnected_cv.notify_all(); + const Phase p = phase.load(); + if (p == Phase::Stopping || p == Phase::Recovering) + return; // the worker owns this transition + phase = Phase::Idle; // retries exhausted + set_status(want_connected ? "disconnected (retries exhausted; Connect or a scan retries)" + : "disconnected"); + }; + espp::WifiSta::ip_callback on_got_ip_fn = [this](ip_event_got_ip_t *e) { + const Phase p = phase.load(); + if (p == Phase::Stopping || p == Phase::Recovering) + return; // being stopped: the DISCONNECTED / STA_STOP that follows settles it + phase = Phase::Connected; + set_status("connected", fmt::format("{}.{}.{}.{}", IP2STR(&e->ip_info.ip))); + }; + /// WIFI_EVENT_STA_STOP (event-loop task): wakes recover(). `arg` is only + /// dereferenced while it is a live instance (see the registry): a delivery + /// that outlives its NetworkState is dropped. + static void on_sta_stop(void *arg, esp_event_base_t, int32_t, void *) { + auto *self = static_cast(arg); + std::lock_guard registry(registry_mutex()); + if (!live_instances().count(self)) + return; // stale: the instance withdrew (or is being destroyed) + { + std::lock_guard lock(self->mutex); + ++self->stopped_events; + } + self->stopped_cv.notify_all(); + } +#endif +#if CONFIG_DESKTOP_EXAMPLE_ENABLE_ETHERNET + std::unique_ptr eth; + std::string eth_init_error{}; ///< why initialize() failed (empty = ok / not attempted) +#endif + + void set_status(std::string_view status, std::string_view ip = "") { + std::lock_guard lock(mutex); + wifi_status = std::string(status); + wifi_ip = std::string(ip); + } + + void toast(std::string text, D::NotifyLevel level) { + desktop.notify({.title = "Wi-Fi", .text = std::move(text), .level = level}); + } + +#if CONFIG_DESKTOP_EXAMPLE_ENABLE_WIFI + static const char *auth_name(wifi_auth_mode_t mode) { + switch (mode) { + case WIFI_AUTH_OPEN: + return "open"; + case WIFI_AUTH_WEP: + return "WEP"; + case WIFI_AUTH_WPA_PSK: + return "WPA"; + case WIFI_AUTH_WPA2_PSK: + return "WPA2"; + case WIFI_AUTH_WPA_WPA2_PSK: + return "WPA/WPA2"; + case WIFI_AUTH_WPA3_PSK: + return "WPA3"; + case WIFI_AUTH_WPA2_WPA3_PSK: + return "WPA2/WPA3"; + default: + return "other"; + } + } + + /// The station config for `ssid` / `password`: only the credentials change + /// between attempts, the callbacks are always the same long-lived objects + /// (WifiSta::reconfigure() replaces its whole config, so they are passed + /// again each time). + espp::WifiSta::Config wifi_config(std::string ssid, std::string password) { + { + std::lock_guard lock(mutex); + cur_ssid = ssid; + cur_pass = password; + } + return {.ssid = std::move(ssid), + .password = std::move(password), + .num_connect_retries = 3, + // never auto-connect from STA_START: every association is issued + // explicitly by the worker (and once at start-up), so a recovery's + // esp_wifi_start() cannot connect to stale credentials by itself + .auto_connect = false, + .on_connected = on_connected_fn, + .on_disconnected = on_disconnected_fn, + .on_got_ip = on_got_ip_fn, + .log_level = espp::Logger::Verbosity::INFO}; + } + + /// Whether a string key exists in the app's namespace: ESP_OK (it does), + /// ESP_ERR_NVS_NOT_FOUND (it does not) or another error (a genuine NVS + /// failure). NvsHandle maps every string read failure to one error code, + /// so the raw nvs_get_str() size query is used to tell the two apart. + static esp_err_t probe_key(const char *key) { + nvs_handle_t h = 0; + esp_err_t err = nvs_open("desktop", NVS_READONLY, &h); + if (err != ESP_OK) + return err; // ESP_ERR_NVS_NOT_FOUND when the namespace was never written + size_t len = 0; + err = nvs_get_str(h, key, nullptr, &len); + nvs_close(h); + return err; + } + + /// A string from NVS: "" when the key is not stored, nullopt on a genuine + /// NVS failure. NvsHandle::get() sizes the string to the stored length + /// INCLUDING the terminating NUL, so trailing NULs are stripped (a 32-byte + /// SSID would otherwise come back as 33 bytes). + static std::optional nvs_string(espp::NvsHandle &nvs, const char *key) { + const esp_err_t probed = probe_key(key); + if (probed == ESP_ERR_NVS_NOT_FOUND) + return std::string{}; + if (probed != ESP_OK) + return std::nullopt; + std::error_code ec; + std::string s; + nvs.get(key, s, ec); // no default: never writes + if (ec) + return std::nullopt; + while (!s.empty() && s.back() == '\0') + s.pop_back(); + return s; + } + + /// Erase one key; true when it is gone (it was erased, or was never + /// stored), false on a genuine NVS failure (probe or erase). + static bool erase_key(espp::NvsHandle &nvs, const char *key) { + const esp_err_t probed = probe_key(key); + if (probed == ESP_ERR_NVS_NOT_FOUND) + return true; + if (probed != ESP_OK) + return false; + std::error_code ec; + return nvs.erase(key, ec); + } + + /// Save the credentials (committed, so they survive a reboot). + bool save_credentials(const std::string &ssid, const std::string &pass) { + std::error_code ec; + espp::NvsHandle nvs("desktop", ec); + if (ec) + return false; + nvs.set("wifi_ssid", ssid, ec); + if (!ec) + nvs.set("wifi_pass", pass, ec); + if (!ec) + nvs.commit(ec); + return !ec; + } + + /// Bring the station up once, with the saved credentials (none = idle). + /// Desktop task, first launch. + void ensure_wifi() { + if (wifi) + return; + std::error_code ec; + espp::NvsHandle nvs("desktop", ec); + std::string ssid, pass; + if (!ec) { + const auto saved_ssid = nvs_string(nvs, "wifi_ssid"); + const auto saved_pass = nvs_string(nvs, "wifi_pass"); + if (saved_ssid && saved_pass) { + ssid = *saved_ssid; + pass = *saved_pass; + } else { + // a genuine NVS failure (not "nothing saved"): start idle, say why + logger.error("could not read the saved Wi-Fi credentials from NVS; starting idle"); + } + } else { + logger.error("could not open the NVS namespace: {}", ec.message()); + } + // the same bounds Connect enforces before saving (the driver fields are + // 32 / 64 bytes); anything else in flash is treated as no credentials + if (ssid.size() > 32 || pass.size() > 63) { + ssid.clear(); + pass.clear(); + } + // The app's NVS keys are the only persisted credentials: keep the + // driver's own copy in RAM so esp_wifi_set_config() does not write a + // second one to flash (which Forget could not clear). + auto &stack = espp::Wifi::get(); + if (stack.init()) + stack.set_storage(WIFI_STORAGE_RAM); + wifi = std::make_unique(wifi_config(ssid, pass)); + wifi_mac = wifi->get_mac(); + // recover() waits for the station's STOP event, which WifiSta does not + // expose: observe it directly + { + std::lock_guard lock(registry_mutex()); + live_instances().insert(this); + } + const esp_err_t reg = esp_event_handler_instance_register( + WIFI_EVENT, WIFI_EVENT_STA_STOP, &on_sta_stop, this, &sta_stop_handler); + if (reg != ESP_OK) { + // recover() then cannot observe STA_STOP (its wait times out -> Failed) + sta_stop_handler = nullptr; + logger.error("could not register the STA_STOP handler: {}; station recovery will not work", + esp_err_to_name(reg)); + } + // the start-up association is issued explicitly (auto_connect is off) + want_connected = !ssid.empty(); + if (ssid.empty()) { + phase = Phase::Idle; + set_status("idle (no saved network)"); + } else { + phase = Phase::Associating; + set_status(fmt::format("connecting to {}", ssid)); + if (!wifi->connect()) { + phase = Phase::Idle; + set_status("connect failed: use Connect"); + } + } + } + + // ---- the worker: one job at a time, every station control disabled ---- + + /// Run `job` on the worker task; refused (with a toast) while another job + /// runs or when the task cannot be started. Desktop task. + bool run_job(const char *name, std::function job) { + if (busy.exchange(true)) { + toast("the station is busy; try again when it is done", D::NotifyLevel::Warn); + return false; + } + worker.reset(); // the previous (finished) job + worker = std::make_unique( + espp::Task::Config{.callback = + [this, job = std::move(job)]() { + job(); + busy = false; + return true; // one shot + }, + .task_config = {.name = name, .stack_size_bytes = 6 * 1024}}); + if (!worker->start()) { + worker.reset(); + busy = false; + toast("could not start the Wi-Fi worker task (out of memory?)", D::NotifyLevel::Error); + return false; + } + return true; + } + + /// Stopping: if the station is connected or associating, disconnect + /// intentionally (that suppresses WifiSta's retries) and WAIT for its + /// DISCONNECTED event (bounded), so a later attempt can never be confused + /// with the stopped one. On an idle station WifiSta::disconnect() is not + /// called at all: no event would come and its private `disconnecting_` + /// would stay set, costing the next attempt its retries. Worker task. + /// + /// The rule on failure: a rejected disconnect ABORTS the transition (phase + /// Failed, a toast, false returned) -- the caller must not reconfigure. A + /// missing DISCONNECTED (the wait timed out) is never resumed from with the + /// confirmation outstanding -- it may never come, and resuming would re-open + /// the stale-event race this wait exists to close: the station is RECOVERED + /// (recover(): stop the whole station, wait for STA_STOP, start it again), + /// after which it is known idle and the transition continues; only a failed + /// recovery aborts into Failed. From Failed, a later event has usually + /// resolved the phase (Idle / Connected); if none came and the driver + /// reports no association, the station is taken as idle. + /// Returns true once the station is idle. + bool stop_and_wait(std::chrono::milliseconds timeout = std::chrono::milliseconds(1000)) { + Phase p = phase.load(); + if (p == Phase::Failed) { + wifi_ap_record_t ap{}; + if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) + p = Phase::Connected; // still associated: stop it like a connected station + else + return true; // nothing associated: idle + } + if (p != Phase::Connected && p != Phase::Associating) + return true; + phase = Phase::Stopping; + set_status("stopping"); + uint32_t seen = 0; + { + std::lock_guard lock(mutex); + seen = disconnected_events; + } + if (!wifi->disconnect()) { + // the driver rejected it (WifiSta rolls its flag back; nothing was + // initiated): a failed transition, not an idle station + phase = Phase::Failed; + set_status("stopping failed: try again"); + toast("the station could not be stopped; try again", D::NotifyLevel::Error); + return false; + } + bool got = false; + { + std::unique_lock lock(mutex); + got = disconnected_cv.wait_for(lock, timeout, [&] { return disconnected_events != seen; }); + } + if (!got) { + logger.warn("no DISCONNECTED event within {} ms; recovering the station", timeout.count()); + return recover(); + } + phase = Phase::Idle; + return true; + } + + /// Recovering: stop the whole station (esp_wifi_stop() tears down any + /// in-flight association; every event of the old session -- a DISCONNECTED + /// included -- is delivered before WIFI_EVENT_STA_STOP and nothing trails + /// after it), wait for STA_STOP (bounded), then start it again. With + /// auto_connect off nothing associates by itself, so the station is known + /// idle afterwards and the interrupted transition continues from there. + /// espp::Wifi::stop() only serves the registry's active interface (this + /// station is standalone), hence the driver calls. Worker task. + bool recover(std::chrono::milliseconds timeout = std::chrono::milliseconds(2000)) { + phase = Phase::Recovering; + set_status("recovering the station"); + uint32_t seen = 0; + { + std::lock_guard lock(mutex); + seen = stopped_events; + } + const esp_err_t stop_err = esp_wifi_stop(); + bool stopped = stop_err == ESP_OK; + if (stopped) { + std::unique_lock lock(mutex); + stopped = stopped_cv.wait_for(lock, timeout, [&] { return stopped_events != seen; }); + } + // start again even after a failed stop (the station may already be + // stopped); WifiSta::start() is esp_wifi_start() with its logging + const bool started = wifi->start(); + if (!stopped || !started) { + logger.error("station recovery failed (stop: {}, STA_STOP: {}, start: {})", + esp_err_to_name(stop_err), stopped ? "seen" : "missing", started); + phase = Phase::Failed; + set_status("recovery failed: try again"); + toast("the station could not be recovered; try again", D::NotifyLevel::Error); + return false; + } + phase = Phase::Idle; + return true; + } + + /// Configuring -> Associating: a new attempt with `ssid` / `pass` (the + /// station must be idle: call stop_and_wait() first). Worker task. + bool start_attempt(const std::string &ssid, const std::string &pass, const char *what) { + phase = Phase::Configuring; + set_status(fmt::format("configuring {}", ssid)); + // after stop_and_wait() the DISCONNECTED event has cleared WifiSta's + // connected_, so reconfigure() does not connect by itself: connect() + // explicitly + if (!wifi->reconfigure(wifi_config(ssid, pass))) { + phase = Phase::Idle; + set_status(fmt::format("{} failed: use Connect", what)); + toast(fmt::format("could not configure the {} to {}", what, ssid), D::NotifyLevel::Error); + return false; + } + // Associating is entered BEFORE connect(): GOT_IP can fire on the + // event-loop task before connect() returns, and the callbacks own the + // phase from here on (Connected / Idle). Nothing below writes the phase + // unconditionally. + phase = Phase::Associating; + set_status(fmt::format("connecting to {}", ssid)); + if (!wifi->connect()) { + // only back to Idle if no callback moved the phase on meanwhile + Phase expected = Phase::Associating; + phase.compare_exchange_strong(expected, Phase::Idle); + set_status(fmt::format("{} failed: use Connect", what)); + toast(fmt::format("could not start the {} to {}", what, ssid), D::NotifyLevel::Error); + return false; + } + return true; + } + + /// Connect: stop whatever is active (waiting for its event), then attempt. + void connect_job(const std::string &ssid, const std::string &pass) { + want_connected = true; + if (!stop_and_wait()) + return; // aborted (Failed): nothing is reconfigured, the user retries + start_attempt(ssid, pass, "connection"); + } + + /// Disconnect: give up the desired state and stop the station. + void disconnect_job() { + want_connected = false; + if (stop_and_wait()) + set_status("disconnected"); + } + + /// Scan: stop the station first (WifiSta::scan() would otherwise drop the + /// link with a bare esp_wifi_disconnect() that its DISCONNECTED handler + /// answers with a retry while the scan starts), scan, publish the rows for + /// every window, then restore the desired state with a fresh attempt. + void scan_job() { + const bool intent = want_connected; + if (!stop_and_wait()) + return; // aborted: scan() would drop the link with a bare disconnect + set_status("scanning"); + const auto aps = wifi->scan(20); + std::vector rows, ssids; + for (const auto &ap : aps) { + const std::string ssid(reinterpret_cast(ap.ssid)); + ssids.push_back(ssid); + rows.push_back(fmt::format("{} ({} dBm, {}, ch {})", ssid.empty() ? "" : ssid, + static_cast(ap.rssi), auth_name(ap.authmode), + static_cast(ap.primary))); + } + { + std::lock_guard lock(mutex); + scan_ssids = ssids; + scan_rows = rows; + ++scan_generation; + } + if (intent) { + std::string ssid, pass; + { + std::lock_guard lock(mutex); + ssid = cur_ssid; + pass = cur_pass; + } + start_attempt(ssid, pass, "reconnect after the scan"); + } else { + set_status("disconnected"); + } + } + + /// Forget: drop the saved credentials everywhere -- the app's NVS keys, + /// the driver's station config and WifiSta's stored config (reconfigured + /// with empty credentials and auto-connect off, so nothing reconnects). + void forget_job() { + std::error_code ec; + espp::NvsHandle nvs("desktop", ec); + bool erased = !ec; + if (erased) { + // both erasures must succeed (a key that was never stored counts as + // already forgotten), and the staged change must be committed + erased = erase_key(nvs, "wifi_ssid"); + erased = erase_key(nvs, "wifi_pass") && erased; + nvs.commit(ec); // erase_item() only stages the change + erased = erased && !ec; + } + want_connected = false; + if (!stop_and_wait()) { + // the credentials are gone (or not: say so), the station is not reset + toast(erased ? "credentials erased, but the station could not be stopped; Forget again " + "to reset it" + : "Forget failed: the credentials may still be saved in NVS", + D::NotifyLevel::Error); + return; + } + wifi_config_t empty{}; + const bool cleared = esp_wifi_set_config(WIFI_IF_STA, &empty) == ESP_OK; + // empty ssid: reconfigure() adopts the (now empty) driver config + auto cfg = wifi_config("", ""); + cfg.auto_connect = false; + const bool station_reset = wifi->reconfigure(cfg) && cleared; + phase = Phase::Idle; + // the persistent status must not claim the credentials are gone when the + // NVS erase / commit failed + set_status(erased ? "idle (no saved network)" + : "Forget failed: the credentials may still be saved"); + if (!erased) + toast("Forget failed: the credentials may still be saved in NVS", D::NotifyLevel::Error); + else if (!station_reset) + toast("credentials erased, but the station could not be reset", D::NotifyLevel::Warn); + else + toast("saved network forgotten", D::NotifyLevel::Ok); + } +#endif + +#if CONFIG_DESKTOP_EXAMPLE_ENABLE_ETHERNET + /// The RMII wiring of the board Kconfig selects (IP101 PHY at address 1, + /// 50 MHz reference clock in). The ESP32's EMAC data pins are fixed by the + /// IO_MUX; the ESP32-P4's are routable and must be given (the pins of the + /// ESP32-P4-Function-EV-Board, as in components/esp32-p4-function-ev-board). + static espp::Ethernet::RmiiConfig rmii_config() { +#if CONFIG_DESKTOP_EXAMPLE_ETHERNET_BOARD_P4_FUNCTION_EV + return {.mdc_gpio = 31, + .mdio_gpio = 52, + .phy_addr = 1, + .phy_reset_gpio = 51, + .clock_ext_in = true, + .clock_gpio = 50, + .data_pins = espp::Ethernet::RmiiConfig::DataPins{ + .tx_en = 49, .txd0 = 34, .txd1 = 35, .crs_dv = 28, .rxd0 = 29, .rxd1 = 30}}; +#else // ESP32-Ethernet-Kit + return {.mdc_gpio = 23, + .mdio_gpio = 18, + .phy_addr = 1, + .phy_reset_gpio = 5, + .clock_ext_in = true, + .clock_gpio = 0}; +#endif + } + + /// Bring the RMII link up once. + void ensure_ethernet() { + if (eth) + return; + eth = std::make_unique( + espp::Ethernet::Config{.interface = rmii_config(), + .hostname = "espp-desktop", + .log_level = espp::Logger::Verbosity::INFO}); + std::error_code ec; + if (!eth->initialize(ec)) { + eth_init_error = ec ? ec.message() : "initialize failed"; + toast("RMII Ethernet init failed: " + eth_init_error + + " (check the PHY wiring / board choice)", + D::NotifyLevel::Error); + } + } +#endif +}; + +} // namespace desktop_example + +inline void register_network_app(espp::Desktop &desktop) { + auto net = std::make_shared(desktop); + desktop.register_app({ + .name = "Network", + .icon = "\xF0\x9F\x93\xA1", // satellite antenna + .description = "Wi-Fi station and Ethernet status", + .launch = + [net](espp::Desktop &d, espp::Desktop::AppId app) { + using namespace std::chrono_literals; + using D = espp::Desktop; + auto win = d.create_window({.title = "Network", .app = app, .w = 520, .h = 0}); + std::vector> refreshers; + + // Ethernet FIRST: espp::Ethernet::initialize() treats + // esp_netif_init()'s ESP_ERR_INVALID_STATE (already initialized by + // the Wi-Fi stack) as fatal, while Wifi::init() tolerates it. +#if CONFIG_DESKTOP_EXAMPLE_ENABLE_ETHERNET + net->ensure_ethernet(); +#endif +#if CONFIG_DESKTOP_EXAMPLE_ENABLE_WIFI + net->ensure_wifi(); + auto *state = net.get(); // outlives every window and task + auto wifi = win.group("Wi-Fi station"); + auto status = win.label("Status: -", wifi.id(), D::kLabelBold); + auto ssid_label = win.label("SSID: -", wifi.id(), D::kLabelMonospace); + auto ip_label = win.label("IP: -", wifi.id(), D::kLabelMonospace); + auto rssi_label = win.label("RSSI: -", wifi.id(), D::kLabelMonospace); + win.label(fmt::format("MAC: {}", net->wifi_mac), wifi.id(), D::kLabelMonospace); + auto scan_bar = win.row(wifi.id()); + auto scan_btn = win.button("Scan", nullptr, scan_bar.id()); + auto scan_label = win.label("", scan_bar.id()); + // THIS window's AP list and the SSIDs behind its rows: snapshotted + // together whenever the rows are replaced, so a selection always + // maps to the SSID of the row the user sees (another window's scan + // may replace the shared rows at any time) + auto ssids = std::make_shared>(); + auto seen_generation = std::make_shared(0); + std::vector rows_now; + { + std::lock_guard lock(net->mutex); + rows_now = net->scan_rows; // the last scan, if any + *ssids = net->scan_ssids; + *seen_generation = net->scan_generation; + } + auto ap_list = win.list(rows_now, nullptr, wifi.id()); + ap_list.set_size(0, 140); + if (!rows_now.empty()) + scan_label.set_text("{} networks", rows_now.size()); + auto pass_row = win.row(wifi.id()); + win.label("Password", pass_row.id()); + auto pass_box = win.textbox("", nullptr, pass_row.id(), + "leave empty for an open network", D::kTextBoxPassword); + auto actions = win.row(wifi.id()); + auto connect_btn = win.button("Connect", nullptr, actions.id(), D::kButtonPrimary); + auto disconnect_btn = win.button("Disconnect", nullptr, actions.id()); + auto forget_btn = win.button("Forget", nullptr, actions.id(), D::kButtonDanger); + // every station control is disabled while a job runs on the + // worker (and a click is refused by run_job() anyway) + auto set_busy = [=](bool busy) mutable { + scan_btn.set_enabled(!busy); + connect_btn.set_enabled(!busy); + disconnect_btn.set_enabled(!busy); + forget_btn.set_enabled(!busy); + }; + set_busy(net->busy); + + refreshers.push_back([=]() mutable { + std::string st, ip; + std::optional> new_rows; + { + std::lock_guard lock(net->mutex); + st = net->wifi_status; + ip = net->wifi_ip; + // a scan completed since this window last synced (from any + // window): replace the rows AND the SSIDs behind them together, + // and drop the selection, which pointed at the old rows + if (net->scan_generation != *seen_generation) { + *seen_generation = net->scan_generation; + new_rows = net->scan_rows; + *ssids = net->scan_ssids; + } + } + if (new_rows) { + ap_list.set_items(*new_rows); + ap_list.set_selected(D::kNoSelection); + scan_label.set_text("{} networks", new_rows->size()); + } + // also re-syncs a window opened while a job started elsewhere + set_busy(net->busy); + const bool connected = net->wifi->is_connected(); + status.set_text("Status: {}", st); + ssid_label.set_text("SSID: {}", connected ? net->wifi->get_ssid() : "-"); + ip_label.set_text("IP: {}", ip.empty() ? "-" : ip); + if (connected) + rssi_label.set_text("RSSI: {} dBm", net->wifi->get_rssi()); + else + rssi_label.set_text("RSSI: -"); + }); + + scan_btn.on_event([=](const D::WidgetEvent &e) mutable { + if (e.kind != D::WidgetEventKind::Click) + return; + if (net->run_job("wifi_scan", [state]() { state->scan_job(); })) { + set_busy(true); + scan_label.set_text("scanning\xE2\x80\xA6 (stops the station first)"); + } + }); + + connect_btn.on_event([=, &d](const D::WidgetEvent &e) mutable { + if (e.kind != D::WidgetEventKind::Click) + return; + std::string ssid; + const int32_t i = ap_list.selected(); + if (i >= 0 && static_cast(i) < ssids->size()) + ssid = (*ssids)[static_cast(i)]; + if (ssid.empty()) { + d.notify({.title = "Wi-Fi", + .text = "scan, then select a network", + .level = D::NotifyLevel::Warn}); + return; + } + const std::string pass = pass_box.text(); + // the driver fields are 32 / 64 bytes (WifiSta::reconfigure + // copies the strings as given); the text boxes are unbounded + if (ssid.size() > 32 || pass.size() > 63) { + d.notify({.title = "Wi-Fi", + .text = "SSID must be at most 32 bytes and the password at " + "most 63 bytes", + .level = D::NotifyLevel::Error}); + return; + } + // the credentials are saved by the ACCEPTED job (after the busy + // check and a started worker): a refused Connect leaves NVS alone + if (net->run_job("wifi_connect", [state, ssid, pass]() { + if (!state->save_credentials(ssid, pass)) + state->toast("could not save the credentials to NVS (connecting anyway)", + D::NotifyLevel::Warn); + state->connect_job(ssid, pass); + })) + set_busy(true); + }); + disconnect_btn.on_event([=](const D::WidgetEvent &e) mutable { + if (e.kind != D::WidgetEventKind::Click) + return; + if (net->run_job("wifi_disconnect", [state]() { state->disconnect_job(); })) + set_busy(true); + }); + forget_btn.on_event([=](const D::WidgetEvent &e) mutable { + if (e.kind != D::WidgetEventKind::Click) + return; + if (net->run_job("wifi_forget", [state]() { state->forget_job(); })) + set_busy(true); + }); +#endif + +#if CONFIG_DESKTOP_EXAMPLE_ENABLE_ETHERNET + // (the link was brought up above, before the Wi-Fi stack) + auto eth = win.group("Ethernet (RMII)"); + auto link = win.label("Link: -", eth.id(), D::kLabelBold); + auto eth_ip = win.label("IP: -", eth.id(), D::kLabelMonospace); + win.label(fmt::format("MAC: {}", net->eth->get_mac_address()), eth.id(), + D::kLabelMonospace); + auto speed = win.label("Speed: -", eth.id(), D::kLabelMonospace); + refreshers.push_back([=]() mutable { + const bool up = net->eth->link_up(); + if (!net->eth->is_initialized()) + link.set_text("Link: not initialized{}", + net->eth_init_error.empty() + ? "" + : " (init failed: " + net->eth_init_error + ")"); + else + link.set_text("Link: {}", up ? "up" : "down"); + eth_ip.set_text("IP: {}", + net->eth->is_connected() ? net->eth->get_ip_address() : "-"); + if (const auto sd = net->eth->link_speed_duplex(); sd) + speed.set_text("Speed: {} Mbit/s {}-duplex", sd->first, + sd->second ? "full" : "half"); + else + speed.set_text("Speed: -"); + }); +#endif + + // (the app is only registered when at least one group is enabled) + auto refresh = [refreshers]() { + for (const auto &fn : refreshers) + fn(); + }; + refresh(); + win.add_timer(1s, refresh); + }, + }); +} diff --git a/components/desktop/example/main/desktop_example.cpp b/components/desktop/example/main/desktop_example.cpp index 6c7a6f2556..c396366fa9 100644 --- a/components/desktop/example/main/desktop_example.cpp +++ b/components/desktop/example/main/desktop_example.cpp @@ -29,6 +29,21 @@ #include "settings_app.hpp" #include "system_monitor_app.hpp" #include "task_manager_app.hpp" +// hardware apps, each behind its Kconfig option (Desktop Example Configuration). +// The CANopen app additionally needs the canopen / twai components, which the +// CMakeLists only add on IDF >= 6.0 (DESKTOP_EXAMPLE_CANOPEN_AVAILABLE). +#if CONFIG_DESKTOP_EXAMPLE_ENABLE_CANOPEN && defined(DESKTOP_EXAMPLE_CANOPEN_AVAILABLE) +#define DESKTOP_EXAMPLE_CANOPEN_APP 1 +#include "canopen_app.hpp" +#else +#define DESKTOP_EXAMPLE_CANOPEN_APP 0 +#endif +#if CONFIG_DESKTOP_EXAMPLE_ENABLE_I2C +#include "i2c_scanner_app.hpp" +#endif +#if CONFIG_DESKTOP_EXAMPLE_ENABLE_WIFI || CONFIG_DESKTOP_EXAMPLE_ENABLE_ETHERNET +#include "network_app.hpp" +#endif using namespace std::chrono_literals; @@ -45,7 +60,9 @@ using namespace std::chrono_literals; // - espp::OtaService (module 0, `espp.ota`): firmware update // - espp::CoreDumpService (module 4, `espp.coredump`): last-crash report // Apps: Counter, About, System Monitor, Task Manager, Log Viewer, Files (+ -// Editor), Settings (main/apps/*.hpp; Counter is the API reference). +// Editor), Settings (main/apps/*.hpp; Counter is the API reference), plus the +// Kconfig-gated hardware apps CANopen / DS402 (simulated node or TWAI), I2C +// scanner and Network (Wi-Fi station, Ethernet). extern "C" void app_main(void) { //! [console_capture] @@ -104,6 +121,18 @@ extern "C" void app_main(void) { register_task_manager_app(desktop); register_log_viewer_app(desktop); register_files_app(desktop); +#if DESKTOP_EXAMPLE_CANOPEN_APP + register_canopen_app(desktop); +#elif CONFIG_DESKTOP_EXAMPLE_ENABLE_CANOPEN + logger.warn("CONFIG_DESKTOP_EXAMPLE_ENABLE_CANOPEN is set but the CANopen app was not built: " + "it needs IDF >= 6.0 (twai) and the CMake option DESKTOP_EXAMPLE_CANOPEN=ON"); +#endif +#if CONFIG_DESKTOP_EXAMPLE_ENABLE_I2C + register_i2c_scanner_app(desktop); +#endif +#if CONFIG_DESKTOP_EXAMPLE_ENABLE_WIFI || CONFIG_DESKTOP_EXAMPLE_ENABLE_ETHERNET + register_network_app(desktop); +#endif register_settings_app(desktop, "espp Desktop"); desktop_example::apply_saved_settings(desktop); diff --git a/components/wifi/include/wifi_sta.hpp b/components/wifi/include/wifi_sta.hpp index 3a0088bf21..e991f6973e 100644 --- a/components/wifi/include/wifi_sta.hpp +++ b/components/wifi/include/wifi_sta.hpp @@ -602,12 +602,19 @@ class WifiSta : public WifiBase { * @return true if the disconnection was initiated successfully, false otherwise. */ bool disconnect() { - connected_ = false; + // Only INITIATES the disconnect: connected_ is cleared by the event + // handler when the DISCONNECTED event reports it done (touching it here + // would race the event loop), so is_connected() stays true until then. attempts_ = 0; disconnecting_ = true; esp_err_t err = esp_wifi_disconnect(); if (err != ESP_OK) { logger_.error("Could not disconnect from WiFi: {}", esp_err_to_name(err)); + // Nothing was initiated, so no DISCONNECTED event will arrive to consume + // the intentional-disconnect flag; clear it, otherwise the next + // (unintentional) disconnect would be taken for this one and skip its + // retries. + disconnecting_ = false; return false; } return true;