diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c1bb607c4..faf17f65e 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -165,6 +165,8 @@ jobs: target: esp32s3 - path: 'components/qwiicnes/example' target: esp32 + - path: 'components/remote_debug/example' + target: esp32s3 - path: 'components/rmt/example' target: esp32s3 - path: 'components/rtsp/example' diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml index f0d27c8e9..fae93e79a 100755 --- a/.github/workflows/upload_components.yml +++ b/.github/workflows/upload_components.yml @@ -103,6 +103,7 @@ jobs: components/qmi8658 components/qtpy components/qwiicnes + components/remote_debug components/rmt components/rtsp components/runqueue diff --git a/components/remote_debug/CMakeLists.txt b/components/remote_debug/CMakeLists.txt new file mode 100644 index 000000000..bd6b97c6b --- /dev/null +++ b/components/remote_debug/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + INCLUDE_DIRS "include" + SRC_DIRS "src" + REQUIRES esp_http_server driver adc base_component file_system timer +) diff --git a/components/remote_debug/README.md b/components/remote_debug/README.md new file mode 100644 index 000000000..0903f283a --- /dev/null +++ b/components/remote_debug/README.md @@ -0,0 +1,76 @@ +# Remote Debug + +[![Badge](https://components.espressif.com/components/espp/remote_debug/badge.svg)](https://components.espressif.com/components/espp/remote_debug) + +Web-based remote debugging interface providing GPIO control, real-time ADC +monitoring, and optional console log viewing over HTTP. Uses `espp::Timer` for +efficient, configurable periodic updates. + +https://github.com/user-attachments/assets/ee806c6c-0f6b-4dd0-a5b0-82b80410b5bc + +image + +## Features + +- **GPIO Control**: Configure pins as input/output, read states, control outputs via web interface +- **ADC Monitoring**: Real-time visualization of ADC channels with configurable sample rates +- **Console Log Viewer**: Optional stdout redirection to web-viewable log with ANSI color support +- **Efficient Updates**: Uses `espp::Timer` for optimal performance with configurable priority +- **RESTful API**: JSON endpoints for programmatic access +- **Responsive UI**: Modern web interface that works on desktop and mobile +- **Multi-client Support**: Optimized for multiple concurrent clients through batched updates + +## Performance + +The component has been optimized for efficiency: + +- Uses `espp::Timer` for precise, lightweight periodic updates +- Configurable task priority and stack size +- Batched ADC data updates reduce HTTP overhead +- Ring buffer implementation for efficient data management +- Efficient JSON generation minimizes processing overhead + +## Dependencies + +- `espp::Timer` - Periodic task execution +- `espp::Adc` - ADC channel management +- `espp::FileSystem` - LittleFS for optional log storage +- ESP HTTP Server - Web interface hosting + +## Console Logging + +When `enable_log_capture` is enabled in the config, stdout is redirected to a file +viewable in the web interface. The log viewer supports ANSI color codes. + +**Important**: For real-time log updates, enable LittleFS file flushing: + +``` +CONFIG_LITTLEFS_FLUSH_FILE_EVERY_WRITE=y +``` + +## API Endpoints + +The component exposes the following HTTP endpoints: + +- `GET /` - Main web interface (HTML page) +- `GET /api/gpio/get` - Get all GPIO states and configurations (JSON) +- `POST /api/gpio/set` - Set GPIO output state (JSON: `{"pin": N, "value": 0|1}`) +- `POST /api/gpio/config` - Configure GPIO direction (JSON: `{"pin": N, "mode": 1|3}`) + - Mode values: `1` = INPUT, `3` = INPUT_OUTPUT (OUTPUT is promoted to INPUT_OUTPUT for safety) +- `GET /api/adc/data` - Get ADC readings and plot data (JSON with ring buffer indices) +- `GET /api/logs` - Get console log contents (text/plain with ANSI colors) +- `POST /api/logs/start` - Start log capture (redirects stdout to file) +- `POST /api/logs/stop` - Stop log capture (restores stdout to /dev/console) + +Set this in your `sdkconfig.defaults` or via `idf.py menuconfig` → Component +config → LittleFS. Without this, logs only appear after the buffer fills. + +## Example + +See the example in the `example/` folder for a complete demonstration with WiFi +connection, GPIO control, ADC monitoring, and console log viewing. + +## External Resources + +- [ESP-IDF HTTP Server Documentation](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/protocols/esp_http_server.html) +- [ADC Continuous Mode](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/peripherals/adc_continuous.html) diff --git a/components/remote_debug/example/CMakeLists.txt b/components/remote_debug/example/CMakeLists.txt new file mode 100644 index 000000000..068170289 --- /dev/null +++ b/components/remote_debug/example/CMakeLists.txt @@ -0,0 +1,22 @@ +# The following lines of boilerplate have to be in your project's CMakeLists +# in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.20) + +set(ENV{IDF_COMPONENT_MANAGER} "0") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# add the component directories that we want to use +set(EXTRA_COMPONENT_DIRS + "../../../components/" +) + +set( + COMPONENTS + "main esptool_py remote_debug task timer nvs_flash wifi" + CACHE STRING + "List of components to include" + ) + +project(remote_debug_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/remote_debug/example/README.md b/components/remote_debug/example/README.md new file mode 100644 index 000000000..06d71309e --- /dev/null +++ b/components/remote_debug/example/README.md @@ -0,0 +1,100 @@ +# Remote Debug Example + +This example demonstrates the `espp::RemoteDebug` component, providing a +web-based interface for GPIO control, real-time ADC monitoring, and console log +viewing. + +https://github.com/user-attachments/assets/ee806c6c-0f6b-4dd0-a5b0-82b80410b5bc + +image + +## How to use example + +### Hardware Required + +This example can run on any ESP32 development board. For testing: +- Connect LEDs or other peripherals / inputs to GPIO pins +- Connect analog sensors to ADC-capable pins + +### Configure the project + +``` +idf.py menuconfig +``` + +Navigate to `Remote Debug Example Configuration`: +- WiFi credentials (SSID and password) +- Server configuration (port, title) +- GPIO configuration (number of pins, pin numbers, labels) +- ADC configuration (channels, attenuation, labels, sample rate, buffer size) +- Console logging (enable/disable, buffer size) + +**Important**: If enabling console logging, you must also set: +- Component config → LittleFS → `CONFIG_LITTLEFS_FLUSH_FILE_EVERY_WRITE=y` + +This ensures real-time log updates on the web interface. + +### Build and Flash + +Build the project and flash it to the board, then run monitor tool to view serial output: + +``` +idf.py -p PORT flash monitor +``` + +(Replace PORT with the name of the serial port to use.) + +(To exit the serial monitor, type ``Ctrl-]``.) + +See the Getting Started Guide for full steps to configure and use ESP-IDF to build projects. + +### Access the Interface + +1. Device connects to configured WiFi network +2. Check serial monitor for assigned IP address +3. Open web browser to `http://:` (default port: 8080) +4. Use the interface to control GPIOs, monitor ADCs, and view console logs + +## Example Output + +CleanShot 2026-01-27 at 00 13 39@2x + +Screenshots: +CleanShot 2026-01-27 at 00 12 32@2x +CleanShot 2026-01-27 at 00 13 06@2x + +## Web Interface Features + +- **GPIO Control** + - Configure pins as input or output + - Read current states in real-time + - Set output pins HIGH or LOW + - Visual state indicators + +- **ADC Monitoring** + - Real-time plotting with automatic updates + - Multiple channels displayed simultaneously + - Voltage display (converted from raw values) + - Configurable sample rate and buffer size + +- **Console Log Viewer** (when enabled) + - Live stdout output + - ANSI color code support + - Auto-scrolling display + - Configurable buffer size + +## API Endpoints + +Programmatic access via JSON REST API: + +``` +GET /api/gpio/get - Get all GPIO states and configurations +POST /api/gpio/set - Set GPIO output state (JSON: {"pin": N, "value": 0|1}) +POST /api/gpio/config - Configure GPIO direction (JSON: {"pin": N, "mode": 1|3}) +GET /api/adc/data - Get ADC readings and plot data +POST /api/logs/start - Start log capture (redirects stdout to file) +POST /api/logs/stop - Stop log capture (restores stdout to /dev/console) +GET /api/logs - Get console log content (text/plain with ANSI colors) +``` + +Note: GPIO mode values are `1` for INPUT and `3` for INPUT_OUTPUT. OUTPUT mode (2) is automatically promoted to INPUT_OUTPUT for safety. diff --git a/components/remote_debug/example/main/CMakeLists.txt b/components/remote_debug/example/main/CMakeLists.txt new file mode 100644 index 000000000..052f65ed2 --- /dev/null +++ b/components/remote_debug/example/main/CMakeLists.txt @@ -0,0 +1,3 @@ +idf_component_register(SRC_DIRS "." + INCLUDE_DIRS "." + PRIV_REQUIRES remote_debug wifi nvs file_system timer) diff --git a/components/remote_debug/example/main/Kconfig.projbuild b/components/remote_debug/example/main/Kconfig.projbuild new file mode 100644 index 000000000..c3ca87368 --- /dev/null +++ b/components/remote_debug/example/main/Kconfig.projbuild @@ -0,0 +1,312 @@ +menu "Remote Debug Example Configuration" + + config REMOTE_DEBUG_DEVICE_NAME + string "Device Name" + default "ESP32 Device" + help + Name of the device shown in the web interface title. + + config REMOTE_DEBUG_WIFI_SSID + string "WiFi SSID" + default "" + help + SSID (network name) for the example to connect to. If left blank, it will connect to the last used SSID. + + config REMOTE_DEBUG_WIFI_PASSWORD + string "WiFi Password" + default "" + help + WiFi password (WPA or WPA2) for the example to use. + + config REMOTE_DEBUG_SERVER_PORT + int "Debug Server Port" + default 8080 + range 1 65535 + help + HTTP port for the remote debug web interface. + + menu "GPIO Configuration" + + config REMOTE_DEBUG_NUM_GPIOS + int "Number of GPIOs to expose" + default 4 + range 1 10 + help + Number of GPIO pins to make available for remote control. + + config REMOTE_DEBUG_GPIO_0 + int "GPIO Pin 0" + default 2 + range 0 48 + depends on REMOTE_DEBUG_NUM_GPIOS >= 1 + help + First GPIO pin to control. + + config REMOTE_DEBUG_GPIO_0_LABEL + string "GPIO Pin 0 Label" + default "GPIO 2" + depends on REMOTE_DEBUG_NUM_GPIOS >= 1 + help + Label for first GPIO pin. + + config REMOTE_DEBUG_GPIO_1 + int "GPIO Pin 1" + default 4 + range 0 48 + depends on REMOTE_DEBUG_NUM_GPIOS >= 2 + help + Second GPIO pin to control. + + config REMOTE_DEBUG_GPIO_1_LABEL + string "GPIO Pin 1 Label" + default "GPIO 4" + depends on REMOTE_DEBUG_NUM_GPIOS >= 2 + help + Label for second GPIO pin. + + config REMOTE_DEBUG_GPIO_2 + int "GPIO Pin 2" + default 16 + range 0 48 + depends on REMOTE_DEBUG_NUM_GPIOS >= 3 + help + Third GPIO pin to control. + + config REMOTE_DEBUG_GPIO_2_LABEL + string "GPIO Pin 2 Label" + default "GPIO 16" + depends on REMOTE_DEBUG_NUM_GPIOS >= 3 + help + Label for third GPIO pin. + + config REMOTE_DEBUG_GPIO_3 + int "GPIO Pin 3" + default 17 + range 0 48 + depends on REMOTE_DEBUG_NUM_GPIOS >= 4 + help + Fourth GPIO pin to control. + + config REMOTE_DEBUG_GPIO_3_LABEL + string "GPIO Pin 3 Label" + default "GPIO 17" + depends on REMOTE_DEBUG_NUM_GPIOS >= 4 + help + Label for fourth GPIO pin. + + config REMOTE_DEBUG_GPIO_4 + int "GPIO Pin 4" + default 18 + range 0 48 + depends on REMOTE_DEBUG_NUM_GPIOS >= 5 + help + Fifth GPIO pin to control. + + config REMOTE_DEBUG_GPIO_5 + int "GPIO Pin 5" + default 19 + range 0 48 + depends on REMOTE_DEBUG_NUM_GPIOS >= 6 + help + Sixth GPIO pin to control. + + config REMOTE_DEBUG_GPIO_6 + int "GPIO Pin 6" + default 21 + range 0 48 + depends on REMOTE_DEBUG_NUM_GPIOS >= 7 + help + Seventh GPIO pin to control. + + config REMOTE_DEBUG_GPIO_7 + int "GPIO Pin 7" + default 22 + range 0 48 + depends on REMOTE_DEBUG_NUM_GPIOS >= 8 + help + Eighth GPIO pin to control. + + config REMOTE_DEBUG_GPIO_8 + int "GPIO Pin 8" + default 23 + range 0 48 + depends on REMOTE_DEBUG_NUM_GPIOS >= 9 + help + Ninth GPIO pin to control. + + config REMOTE_DEBUG_GPIO_9 + int "GPIO Pin 9" + default 25 + range 0 48 + depends on REMOTE_DEBUG_NUM_GPIOS >= 10 + help + Tenth GPIO pin to control. + + endmenu + + menu "ADC Configuration" + + config REMOTE_DEBUG_NUM_ADCS + int "Number of ADC channels to monitor" + default 2 + range 0 8 + help + Number of ADC channels to monitor and plot. + + config REMOTE_DEBUG_ADC_0 + int "ADC Channel 0" + default 36 + range 0 48 + depends on REMOTE_DEBUG_NUM_ADCS >= 1 + help + First ADC pin to monitor (GPIO number). + + config REMOTE_DEBUG_ADC_0_LABEL + string "ADC Channel 0 Label" + default "ADC 36" + depends on REMOTE_DEBUG_NUM_ADCS >= 1 + help + Label for first ADC channel. + + config REMOTE_DEBUG_ADC_1 + int "ADC Channel 1" + default 39 + range 0 48 + depends on REMOTE_DEBUG_NUM_ADCS >= 2 + help + Second ADC pin to monitor (GPIO number). + + config REMOTE_DEBUG_ADC_1_LABEL + string "ADC Channel 1 Label" + default "ADC 39" + depends on REMOTE_DEBUG_NUM_ADCS >= 2 + help + Label for second ADC channel. + + config REMOTE_DEBUG_ADC_2 + int "ADC Channel 2" + default 34 + range 0 48 + depends on REMOTE_DEBUG_NUM_ADCS >= 3 + help + Third ADC pin to monitor (GPIO number). + + config REMOTE_DEBUG_ADC_2_LABEL + string "ADC Channel 2 Label" + default "ADC 34" + depends on REMOTE_DEBUG_NUM_ADCS >= 3 + help + Label for third ADC channel. + + config REMOTE_DEBUG_ADC_3 + int "ADC Channel 3" + default 35 + range 0 48 + depends on REMOTE_DEBUG_NUM_ADCS >= 4 + help + Fourth ADC pin to monitor (GPIO number). + + config REMOTE_DEBUG_ADC_3_LABEL + string "ADC Channel 3 Label" + default "ADC 35" + depends on REMOTE_DEBUG_NUM_ADCS >= 4 + help + Label for fourth ADC channel. + + config REMOTE_DEBUG_ADC_4 + int "ADC Channel 4" + default 32 + range 0 48 + depends on REMOTE_DEBUG_NUM_ADCS >= 5 + help + Fifth ADC pin to monitor (GPIO number). + + config REMOTE_DEBUG_ADC_4_LABEL + string "ADC Channel 4 Label" + default "ADC 32" + depends on REMOTE_DEBUG_NUM_ADCS >= 5 + help + Label for fifth ADC channel. + + config REMOTE_DEBUG_ADC_5 + int "ADC Channel 5" + default 33 + range 0 48 + depends on REMOTE_DEBUG_NUM_ADCS >= 6 + help + Sixth ADC pin to monitor (GPIO number). + + config REMOTE_DEBUG_ADC_5_LABEL + string "ADC Channel 5 Label" + default "ADC 33" + depends on REMOTE_DEBUG_NUM_ADCS >= 6 + help + Label for sixth ADC channel. + + config REMOTE_DEBUG_ADC_6 + int "ADC Channel 6" + default 25 + range 0 48 + depends on REMOTE_DEBUG_NUM_ADCS >= 7 + help + Seventh ADC pin to monitor (GPIO number). + + config REMOTE_DEBUG_ADC_6_LABEL + string "ADC Channel 6 Label" + default "ADC 25" + depends on REMOTE_DEBUG_NUM_ADCS >= 7 + help + Label for seventh ADC channel. + + config REMOTE_DEBUG_ADC_7 + int "ADC Channel 7" + default 26 + range 0 48 + depends on REMOTE_DEBUG_NUM_ADCS >= 8 + help + Eighth ADC pin to monitor (GPIO number). + + config REMOTE_DEBUG_ADC_7_LABEL + string "ADC Channel 7 Label" + default "ADC 26" + depends on REMOTE_DEBUG_NUM_ADCS >= 8 + help + Label for eighth ADC channel. + + config REMOTE_DEBUG_ADC_SAMPLE_RATE_HZ + int "ADC Sample Rate (Hz)" + default 25 + range 1 1000 + depends on REMOTE_DEBUG_NUM_ADCS > 0 + help + How often to sample ADC values (samples per second). + + config REMOTE_DEBUG_ADC_BUFFER_SIZE + int "ADC Buffer Size" + default 100 + range 10 10000 + depends on REMOTE_DEBUG_NUM_ADCS > 0 + help + Number of samples to keep in buffer for plotting. + + endmenu + + menu "Log Configuration" + + config REMOTE_DEBUG_ENABLE_LOGS + bool "Enable Log Capture" + default y + help + Enable capturing stdout to a log file that can be viewed remotely. + + config REMOTE_DEBUG_LOG_BUFFER_SIZE + int "Log Buffer Size (bytes)" + default 2048 + range 1024 65536 + depends on REMOTE_DEBUG_ENABLE_LOGS + help + Maximum size of the log buffer/file in bytes. When full, old logs will be overwritten. + + endmenu + +endmenu diff --git a/components/remote_debug/example/main/remote_debug_example.cpp b/components/remote_debug/example/main/remote_debug_example.cpp new file mode 100644 index 000000000..746eb0951 --- /dev/null +++ b/components/remote_debug/example/main/remote_debug_example.cpp @@ -0,0 +1,176 @@ +#include +#include +#include + +#include "file_system.hpp" +#include "logger.hpp" +#include "nvs.hpp" +#include "remote_debug.hpp" +#include "timer.hpp" +#include "wifi_sta.hpp" + +using namespace std::chrono_literals; + +extern "C" void app_main(void) { + espp::Logger logger({.tag = "Remote Debug Example", .level = espp::Logger::Verbosity::INFO}); + + logger.info("Starting Remote Debug Example"); + +#if CONFIG_ESP32_WIFI_NVS_ENABLED + // Initialize NVS + esp_err_t ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + ret = nvs_flash_init(); + } + ESP_ERROR_CHECK(ret); +#endif + + // Initialize filesystem + logger.info("Initializing filesystem..."); + auto &fs = espp::FileSystem::get(); + logger.info("Filesystem mounted at: {}", fs.get_mount_point()); + logger.info("Total space: {}", fs.human_readable(fs.get_total_space())); + logger.info("Free space: {}", fs.human_readable(fs.get_free_space())); + + //! [remote debug example] + + // Connect to WiFi using espp WifiSta + logger.info("Connecting to WiFi SSID: {}", CONFIG_REMOTE_DEBUG_WIFI_SSID); + + espp::WifiSta wifi_sta({.ssid = CONFIG_REMOTE_DEBUG_WIFI_SSID, + .password = CONFIG_REMOTE_DEBUG_WIFI_PASSWORD, + .num_connect_retries = 5, + .on_connected = [&logger]() { logger.info("WiFi connected!"); }, + .on_disconnected = [&logger]() { logger.warn("WiFi disconnected!"); }, + .on_got_ip = + [&logger](ip_event_got_ip_t *event) { + logger.info("got IP: {}.{}.{}.{}", IP2STR(&event->ip_info.ip)); + }}); + + // Wait for connection + while (!wifi_sta.is_connected()) { + std::this_thread::sleep_for(100ms); + } + logger.info("WiFi connected successfully"); + + // Build GPIO list from menuconfig + std::vector gpios; +#if CONFIG_REMOTE_DEBUG_NUM_GPIOS >= 1 + gpios.push_back({.pin = static_cast(CONFIG_REMOTE_DEBUG_GPIO_0), + .mode = GPIO_MODE_INPUT, + .label = CONFIG_REMOTE_DEBUG_GPIO_0_LABEL}); +#endif +#if CONFIG_REMOTE_DEBUG_NUM_GPIOS >= 2 + gpios.push_back({.pin = static_cast(CONFIG_REMOTE_DEBUG_GPIO_1), + .mode = GPIO_MODE_INPUT, + .label = CONFIG_REMOTE_DEBUG_GPIO_1_LABEL}); +#endif +#if CONFIG_REMOTE_DEBUG_NUM_GPIOS >= 3 + gpios.push_back({.pin = static_cast(CONFIG_REMOTE_DEBUG_GPIO_2), + .mode = GPIO_MODE_INPUT, + .label = CONFIG_REMOTE_DEBUG_GPIO_2_LABEL}); +#endif +#if CONFIG_REMOTE_DEBUG_NUM_GPIOS >= 4 + gpios.push_back({.pin = static_cast(CONFIG_REMOTE_DEBUG_GPIO_3), + .mode = GPIO_MODE_INPUT, + .label = CONFIG_REMOTE_DEBUG_GPIO_3_LABEL}); +#endif + + // Build ADC list from menuconfig + size_t task_stack_size = 4096; +#if CONFIG_REMOTE_DEBUG_NUM_ADCS >= 1 + task_stack_size += 2048; + int adc_sample_rate_hz = CONFIG_REMOTE_DEBUG_ADC_SAMPLE_RATE_HZ; + size_t adc_buffer_size = CONFIG_REMOTE_DEBUG_ADC_BUFFER_SIZE; +#else + int adc_sample_rate_hz = 1; // Default to 1 Hz if no ADCs configured + size_t adc_buffer_size = 1; // Default to buffer size of 1 if no ADCs configured +#endif + + std::vector adc1_channels; +#if CONFIG_REMOTE_DEBUG_NUM_ADCS >= 1 + adc1_channels.push_back({.channel = static_cast(CONFIG_REMOTE_DEBUG_ADC_0), + .label = CONFIG_REMOTE_DEBUG_ADC_0_LABEL}); +#endif +#if CONFIG_REMOTE_DEBUG_NUM_ADCS >= 2 + adc1_channels.push_back({.channel = static_cast(CONFIG_REMOTE_DEBUG_ADC_1), + .label = CONFIG_REMOTE_DEBUG_ADC_1_LABEL}); +#endif +#if CONFIG_REMOTE_DEBUG_NUM_ADCS >= 3 + adc1_channels.push_back({.channel = static_cast(CONFIG_REMOTE_DEBUG_ADC_2), + .label = CONFIG_REMOTE_DEBUG_ADC_2_LABEL}); +#endif +#if CONFIG_REMOTE_DEBUG_NUM_ADCS >= 4 + adc1_channels.push_back({.channel = static_cast(CONFIG_REMOTE_DEBUG_ADC_3), + .label = CONFIG_REMOTE_DEBUG_ADC_3_LABEL}); + task_stack_size += 2048; +#endif +#if CONFIG_REMOTE_DEBUG_NUM_ADCS >= 5 + adc1_channels.push_back({.channel = static_cast(CONFIG_REMOTE_DEBUG_ADC_4), + .label = CONFIG_REMOTE_DEBUG_ADC_4_LABEL}); +#endif +#if CONFIG_REMOTE_DEBUG_NUM_ADCS >= 6 + adc1_channels.push_back({.channel = static_cast(CONFIG_REMOTE_DEBUG_ADC_5), + .label = CONFIG_REMOTE_DEBUG_ADC_5_LABEL}); +#endif +#if CONFIG_REMOTE_DEBUG_NUM_ADCS >= 7 + adc1_channels.push_back({.channel = static_cast(CONFIG_REMOTE_DEBUG_ADC_6), + .label = CONFIG_REMOTE_DEBUG_ADC_6_LABEL}); +#endif +#if CONFIG_REMOTE_DEBUG_NUM_ADCS >= 8 + adc1_channels.push_back({.channel = static_cast(CONFIG_REMOTE_DEBUG_ADC_7), + .label = CONFIG_REMOTE_DEBUG_ADC_7_LABEL}); + task_stack_size += 2048; +#endif + + // Configure remote debug + espp::RemoteDebug::Config config { + .device_name = CONFIG_REMOTE_DEBUG_DEVICE_NAME, .gpios = gpios, .adc1_channels = adc1_channels, + .adc2_channels = {}, .server_port = static_cast(CONFIG_REMOTE_DEBUG_SERVER_PORT), + .adc_sample_rate = std::chrono::milliseconds(1000 / adc_sample_rate_hz), + .gpio_update_rate = std::chrono::milliseconds(100), .adc_history_size = adc_buffer_size, + .task_priority = 5, .task_stack_size = task_stack_size, +#if CONFIG_REMOTE_DEBUG_ENABLE_LOGS + .enable_log_capture = true, .max_log_size = CONFIG_REMOTE_DEBUG_LOG_BUFFER_SIZE, +#else + .enable_log_capture = false, +#endif + .log_level = espp::Logger::Verbosity::INFO + }; + + espp::RemoteDebug remote_debug(config); + remote_debug.start(); + + logger.info("Remote Debug Server started on port {}!", CONFIG_REMOTE_DEBUG_SERVER_PORT); + logger.info("GPIO pins available: {}", gpios.size()); + logger.info("ADC channels available: {}", adc1_channels.size()); + + std::this_thread::sleep_for(2s); + + // Create a timer to periodically generate log messages + int counter = 0; + auto timer = espp::Timer(espp::Timer::Config{.name = "Log Timer", + .period = 2s, + .callback = + [&logger, &counter]() { + logger.info("Timer tick #{}", counter++); + if (counter % 3 == 0) { + logger.warn("Warning message every 3 ticks"); + } + if (counter % 5 == 0) { + logger.error("Error message every 5 ticks"); + } + return false; // don't stop + }, + .stack_size_bytes = 6192}); + + logger.info("Timer started - generating log messages every 2 seconds"); + + // Keep running + while (true) { + std::this_thread::sleep_for(1s); + } + + //! [remote debug example] +} diff --git a/components/remote_debug/example/partitions.csv b/components/remote_debug/example/partitions.csv new file mode 100644 index 000000000..419600340 --- /dev/null +++ b/components/remote_debug/example/partitions.csv @@ -0,0 +1,5 @@ +# Name, Type, SubType, Offset, Size +nvs, data, nvs, 0x9000, 0x6000 +phy_init, data, phy, 0xf000, 0x1000 +factory, app, factory, 0x10000, 2M +littlefs, data, littlefs, , 1M diff --git a/components/remote_debug/example/sdkconfig.defaults b/components/remote_debug/example/sdkconfig.defaults new file mode 100644 index 000000000..88a7400fe --- /dev/null +++ b/components/remote_debug/example/sdkconfig.defaults @@ -0,0 +1,39 @@ +# ESP32-specific +CONFIG_IDF_TARGET="esp32s3" + +# set cpu speed to 240MHz +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=240 + +# FreeRTOS tick rate to 1000Hz +CONFIG_FREERTOS_HZ=1000 + +# set the main task stack size to 8k +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 + +# Flash size +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_ESPTOOLPY_FLASHSIZE="4MB" + +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_OFFSET=0x8000 +CONFIG_PARTITION_TABLE_MD5=y + +# littlefs settings +CONFIG_LITTLEFS_FLUSH_FILE_EVERY_WRITE=y +CONFIG_LITTLEFS_MMAP_PARTITION=y + +# Default settings +CONFIG_REMOTE_DEBUG_DEVICE_NAME="Remote Debug Example" +CONFIG_REMOTE_DEBUG_NUM_GPIOS=2 +CONFIG_REMOTE_DEBUG_GPIO_0=17 +CONFIG_REMOTE_DEBUG_GPIO_0_LABEL="LED" +CONFIG_REMOTE_DEBUG_GPIO_1=18 +CONFIG_REMOTE_DEBUG_GPIO_1_LABEL="Button" +CONFIG_REMOTE_DEBUG_NUM_ADCS=2 +CONFIG_REMOTE_DEBUG_ADC_0=7 +CONFIG_REMOTE_DEBUG_ADC_0_LABEL="Joy LX" +CONFIG_REMOTE_DEBUG_ADC_1=8 +CONFIG_REMOTE_DEBUG_ADC_1_LABEL="Joy LY" diff --git a/components/remote_debug/example/sdkconfig.defaults.esp32s3 b/components/remote_debug/example/sdkconfig.defaults.esp32s3 new file mode 100644 index 000000000..eaee743c2 --- /dev/null +++ b/components/remote_debug/example/sdkconfig.defaults.esp32s3 @@ -0,0 +1,3 @@ +# on the ESP32S3, which has native USB, we need to set the console so that the +# CLI can be configured correctly: +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y diff --git a/components/remote_debug/idf_component.yml b/components/remote_debug/idf_component.yml new file mode 100644 index 000000000..8d38d0153 --- /dev/null +++ b/components/remote_debug/idf_component.yml @@ -0,0 +1,24 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "Remote debugging server with GPIO and ADC control" +url: "https://github.com/esp-cpp/espp/tree/main/components/remote_debug" +repository: "git://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/remote_debug.html" +examples: + - path: example +tags: + - cpp + - Debug + - GPIO + - ADC + - HTTP + - WebServer +dependencies: + idf: + version: '>=5.0' + espp/adc: '>=1.0' + espp/base_component: '>=1.0' + espp/file_system: '>=1.0' + espp/timer: '>=1.0' diff --git a/components/remote_debug/include/remote_debug.hpp b/components/remote_debug/include/remote_debug.hpp new file mode 100644 index 000000000..366e57409 --- /dev/null +++ b/components/remote_debug/include/remote_debug.hpp @@ -0,0 +1,216 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "base_component.hpp" +#include "oneshot_adc.hpp" +#include "timer.hpp" + +namespace espp { +/** + * @brief Remote Debug Component + * + * Provides a web-based interface for remote GPIO control and ADC monitoring. + * Allows real-time control of GPIO pins and plotting of ADC values. + * + * Features: + * - GPIO control (set high/low, read state, configure mode) + * - ADC value reading and real-time plotting + * - Configurable sampling rates + * - Mobile-friendly responsive interface + * + * \section remote_debug_ex1 Remote Debug Example + * \snippet remote_debug_example.cpp remote debug example + */ +class RemoteDebug : public BaseComponent { +public: + /** + * @brief GPIO configuration + */ + struct GpioConfig { + gpio_num_t pin; ///< GPIO pin number + gpio_mode_t mode{GPIO_MODE_OUTPUT}; ///< Pin mode (input/output) + std::string label{""}; ///< Optional label for UI + }; + + /** + * @brief ADC configuration + */ + struct AdcChannelConfig { + adc_channel_t channel; ///< ADC channel + adc_atten_t atten{ADC_ATTEN_DB_12}; ///< Attenuation (affects voltage range) + std::string label{""}; ///< Optional label for UI + }; + + /** + * @brief Configuration for remote debug + */ + struct Config { + std::string device_name{"ESP32 Device"}; ///< Device name shown in UI title + std::vector gpios; ///< GPIO pins to expose + std::vector adc1_channels; ///< ADC1 channels to monitor + std::vector adc2_channels; ///< ADC2 channels to monitor + uint16_t server_port{8080}; ///< HTTP server port + std::chrono::milliseconds adc_sample_rate{100}; ///< ADC sampling interval + std::chrono::milliseconds gpio_update_rate{100}; ///< GPIO state update interval + size_t adc_history_size{20}; ///< Number of ADC samples to keep + size_t task_priority{5}; ///< Priority for update tasks + size_t task_stack_size{4096}; ///< Stack size for update tasks + bool enable_log_capture{false}; ///< Enable stdout redirection to file + std::string log_file_path{ + "debug.log"}; ///< Path to log file. Will be appended to espp::FileSystem::get_root_path(). + size_t max_log_size{100000}; ///< Maximum log file size in bytes + Logger::Verbosity log_level{Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /** + * @brief Construct remote debug interface + * @param config Configuration structure + */ + explicit RemoteDebug(const Config &config); + + /** + * @brief Destructor + */ + ~RemoteDebug(); + + /** + * @brief Start the debug server + * @return true if started successfully + */ + bool start(); + + /** + * @brief Stop the debug server + */ + void stop(); + + /** + * @brief Check if server is running + * @return true if active + */ + bool is_active() const { return is_active_; } + + /** + * @brief Set GPIO output level + * @param pin GPIO pin number + * @param level Level (0=low, 1=high) + * @return true if successful + */ + bool set_gpio(gpio_num_t pin, int level); + + /** + * @brief Read GPIO level + * @param pin GPIO pin number + * @return Level (0 or 1), or -1 on error + */ + int get_gpio(gpio_num_t pin); + + /** + * @brief Configure GPIO mode + * @param pin GPIO pin number + * @param mode GPIO mode (input/output) + * @return true if successful + */ + bool configure_gpio(gpio_num_t pin, gpio_mode_t mode); + +protected: + void init(const Config &config); + bool start_server(); + void stop_server(); + void adc_sampling_task(); + void gpio_update_task(); + + // HTTP handlers + static esp_err_t root_handler(httpd_req_t *req); + static esp_err_t gpio_get_handler(httpd_req_t *req); + static esp_err_t gpio_set_handler(httpd_req_t *req); + static esp_err_t gpio_config_handler(httpd_req_t *req); + static esp_err_t adc_data_handler(httpd_req_t *req); + static esp_err_t logs_handler(httpd_req_t *req); + static esp_err_t start_log_handler(httpd_req_t *req); + static esp_err_t stop_log_handler(httpd_req_t *req); + + std::string generate_html() const; + std::string get_gpio_state_json() const; + std::string get_adc_data_json() const; + std::string get_logs() const; + void setup_log_redirection(); + void stop_log_redirection(); + void cleanup_log_redirection(); + + Config config_; + httpd_handle_t server_{nullptr}; + + std::unique_ptr adc1_; + std::unique_ptr adc2_; + + std::unordered_map gpio_map_; + std::unordered_map gpio_state_; // Cached GPIO states + mutable std::mutex gpio_mutex_; + + // ADC data storage - ring buffer implementation + struct AdcData { + std::vector values; + std::vector timestamps; + size_t write_index{0}; // Current write position in ring buffer + size_t count{0}; // Number of valid samples (up to buffer size) + adc_channel_t channel; + std::string label; + }; + std::vector adc1_data_; + std::vector adc2_data_; + mutable std::mutex adc_mutex_; + + std::atomic is_active_{false}; + std::atomic sampling_active_{false}; + std::unique_ptr adc_timer_; + std::unique_ptr gpio_timer_; + + // Log redirection + FILE *log_file_{nullptr}; + mutable std::mutex log_mutex_; +}; +} // namespace espp + +// for libfmt formatting of gpio_mode_t +template <> struct fmt::formatter : fmt::formatter { + template auto format(const gpio_mode_t &mode, FormatContext &ctx) const { + std::string mode_str; + switch (mode) { + case GPIO_MODE_INPUT: + mode_str = "INPUT"; + break; + case GPIO_MODE_OUTPUT: + mode_str = "OUTPUT"; + break; + case GPIO_MODE_INPUT_OUTPUT: + mode_str = "INPUT_OUTPUT"; + break; + case GPIO_MODE_DISABLE: + mode_str = "DISABLE"; + break; + default: + mode_str = "UNKNOWN"; + break; + } + return fmt::formatter::format(mode_str, ctx); + } +}; + +// for libfmt formatting of gpio_num_t +template <> struct fmt::formatter : fmt::formatter { + template auto format(const gpio_num_t &pin, FormatContext &ctx) const { + return fmt::formatter::format(static_cast(pin), ctx); + } +}; diff --git a/components/remote_debug/src/remote_debug.cpp b/components/remote_debug/src/remote_debug.cpp new file mode 100644 index 000000000..721b8fbf3 --- /dev/null +++ b/components/remote_debug/src/remote_debug.cpp @@ -0,0 +1,1168 @@ +#include "remote_debug.hpp" + +#include +#include +#include +#include +#include +#include + +#include "file_system.hpp" + +using namespace espp; + +namespace { +// Helper function to escape JSON strings +std::string json_escape(const std::string &str) { + std::string escaped; + escaped.reserve(str.size()); + for (char c : str) { + switch (c) { + case '\"': + escaped += "\\\""; + break; + case '\\': + escaped += "\\\\"; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + case '\b': + escaped += "\\b"; + break; + case '\f': + escaped += "\\f"; + break; + default: + escaped += c; + } + } + return escaped; +} + +// Helper to read full HTTP request body +bool read_request_body(httpd_req_t *req, std::string &buffer, size_t max_size = 4096) { + size_t content_len = req->content_len; + + if (content_len == 0) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Empty request body"); + return false; + } + + if (content_len > max_size) { + httpd_resp_send_err(req, HTTPD_413_CONTENT_TOO_LARGE, "Request too large"); + return false; + } + + buffer.resize(content_len); + size_t received = 0; + + while (received < content_len) { + int ret = httpd_req_recv(req, &buffer[received], content_len - received); + if (ret < 0) { + if (ret == HTTPD_SOCK_ERR_TIMEOUT) { + continue; + } + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to read request"); + return false; + } + received += ret; + } + + return true; +} +} // anonymous namespace + +RemoteDebug::RemoteDebug(const Config &config) + : BaseComponent("RemoteDebug", config.log_level) { + init(config); +} + +RemoteDebug::~RemoteDebug() { stop(); } + +void RemoteDebug::init(const Config &config) { + config_ = config; + + // Initialize GPIO with configured mode + for (const auto &gpio : config_.gpios) { + gpio_mode_t init_mode = gpio.mode; + + // Promote OUTPUT to INPUT_OUTPUT for bidirectional capability + if (init_mode == GPIO_MODE_OUTPUT) { + init_mode = GPIO_MODE_INPUT_OUTPUT; + logger_.debug("Promoting GPIO {} from OUTPUT to INPUT_OUTPUT", gpio.pin); + } else if (init_mode == GPIO_MODE_OUTPUT_OD) { + init_mode = GPIO_MODE_INPUT_OUTPUT_OD; + logger_.debug("Promoting GPIO {} from OUTPUT_OD to INPUT_OUTPUT_OD", gpio.pin); + } + + gpio_config_t io_conf = {}; + io_conf.pin_bit_mask = (1ULL << gpio.pin); + io_conf.mode = init_mode; + io_conf.pull_up_en = GPIO_PULLUP_DISABLE; + io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; + io_conf.intr_type = GPIO_INTR_DISABLE; + gpio_config(&io_conf); + + // Store the GPIO config + GpioConfig gpio_copy = gpio; + gpio_copy.mode = init_mode; + gpio_map_[gpio.pin] = gpio_copy; + gpio_state_[gpio.pin] = gpio_get_level(gpio.pin); + + logger_.debug("Configured GPIO {} as mode {}, initial state: {}", gpio.pin, init_mode, + gpio_state_[gpio.pin]); + } + + logger_.debug("Configured {} GPIOs", config_.gpios.size()); + + // Calculate actual history size based on sample rate + // adc_history_size is the number of samples we want to keep + // Sample rate determines how many samples per second + // So actual time = adc_history_size / samples_per_second + float samples_per_second = 1000.0f / config_.adc_sample_rate.count(); + float actual_history_seconds = config_.adc_history_size / samples_per_second; + logger_.info("ADC history: {} samples at {} Hz = {:.2f} seconds", config_.adc_history_size, + samples_per_second, actual_history_seconds); + + // Initialize ADC1 + if (!config_.adc1_channels.empty()) { + logger_.info("Initializing ADC1 with {} channels", config_.adc1_channels.size()); + std::vector adc1_configs; + for (const auto &ch : config_.adc1_channels) { + adc1_configs.push_back({.unit = ADC_UNIT_1, .channel = ch.channel, .attenuation = ch.atten}); + + // Initialize data storage + AdcData data; + data.values.resize(config_.adc_history_size, 0); + data.timestamps.resize(config_.adc_history_size, 0); + data.channel = ch.channel; + data.label = ch.label; + adc1_data_.push_back(std::move(data)); + } + adc1_ = std::make_unique(OneshotAdc::Config{ + .unit = ADC_UNIT_1, .channels = adc1_configs, .log_level = config_.log_level}); + } + + // Initialize ADC2 + if (!config_.adc2_channels.empty()) { + logger_.info("Initializing ADC2 with {} channels", config_.adc2_channels.size()); + std::vector adc2_configs; + for (const auto &ch : config_.adc2_channels) { + adc2_configs.push_back({.unit = ADC_UNIT_2, .channel = ch.channel, .attenuation = ch.atten}); + + // Initialize data storage + AdcData data; + data.values.resize(config_.adc_history_size, 0); + data.timestamps.resize(config_.adc_history_size, 0); + data.channel = ch.channel; + data.label = ch.label; + adc2_data_.push_back(std::move(data)); + } + adc2_ = std::make_unique(OneshotAdc::Config{ + .unit = ADC_UNIT_2, .channels = adc2_configs, .log_level = config_.log_level}); + } + + logger_.info("RemoteDebug initialized with {} GPIOs, {} ADC1 channels, {} ADC2 channels", + config_.gpios.size(), config_.adc1_channels.size(), config_.adc2_channels.size()); +} + +bool RemoteDebug::start() { + if (is_active_) { + logger_.warn("Already running"); + return true; + } + + if (!start_server()) { + return false; + } + + // Start ADC sampling timer if we have ADCs + if (adc1_ || adc2_) { + logger_.info("Starting ADC sampling timer with period {} ms", config_.adc_sample_rate.count()); + sampling_active_ = true; + adc_timer_ = std::make_unique(Timer::Config{.name = "adc_timer", + .period = config_.adc_sample_rate, + .callback = + [this]() { + adc_sampling_task(); + return false; + }, + .auto_start = false, + .stack_size_bytes = config_.task_stack_size, + .priority = config_.task_priority, + .log_level = config_.log_level}); + adc_timer_->start(); + } + + // Start GPIO update timer + if (!gpio_map_.empty()) { + gpio_timer_ = std::make_unique(Timer::Config{.name = "gpio_timer", + .period = config_.gpio_update_rate, + .callback = + [this]() { + gpio_update_task(); + return false; + }, + .auto_start = false, + .stack_size_bytes = config_.task_stack_size, + .priority = config_.task_priority, + .log_level = config_.log_level}); + gpio_timer_->start(); + } + + // Register log control endpoints + httpd_uri_t start_log_uri = {.uri = "/api/logs/start", + .method = HTTP_POST, + .handler = start_log_handler, + .user_ctx = this}; + httpd_register_uri_handler(server_, &start_log_uri); + + httpd_uri_t stop_log_uri = { + .uri = "/api/logs/stop", .method = HTTP_POST, .handler = stop_log_handler, .user_ctx = this}; + httpd_register_uri_handler(server_, &stop_log_uri); + + // Setup log redirection if enabled + if (config_.enable_log_capture) { + setup_log_redirection(); + } + + is_active_ = true; + logger_.info("RemoteDebug started on port {}", config_.server_port); + return true; +} + +void RemoteDebug::stop() { + if (!is_active_) { + return; + } + + sampling_active_ = false; + + // Stop timers + if (adc_timer_) { + adc_timer_->stop(); + adc_timer_.reset(); + } + if (gpio_timer_) { + gpio_timer_->stop(); + gpio_timer_.reset(); + } + + cleanup_log_redirection(); + stop_server(); + is_active_ = false; + logger_.info("RemoteDebug stopped"); +} + +bool RemoteDebug::set_gpio(gpio_num_t pin, int level) { + std::lock_guard lock(gpio_mutex_); + if (gpio_map_.find(pin) == gpio_map_.end()) { + logger_.error("GPIO {} not configured", static_cast(pin)); + return false; + } + + // Only set if it's an output + if (gpio_map_[pin].mode != GPIO_MODE_OUTPUT && gpio_map_[pin].mode != GPIO_MODE_OUTPUT_OD && + gpio_map_[pin].mode != GPIO_MODE_INPUT_OUTPUT && + gpio_map_[pin].mode != GPIO_MODE_INPUT_OUTPUT_OD) { + logger_.error("GPIO {} is not configured as output", static_cast(pin)); + return false; + } + + gpio_set_level(pin, level); + gpio_state_[pin] = level; + return true; +} + +int RemoteDebug::get_gpio(gpio_num_t pin) { + std::lock_guard lock(gpio_mutex_); + if (gpio_map_.find(pin) == gpio_map_.end()) { + logger_.error("GPIO {} not configured", static_cast(pin)); + return -1; + } + return gpio_state_[pin]; +} + +bool RemoteDebug::configure_gpio(gpio_num_t pin, gpio_mode_t mode) { + std::lock_guard lock(gpio_mutex_); + if (gpio_map_.find(pin) == gpio_map_.end()) { + logger_.error("GPIO {} not configured", static_cast(pin)); + return false; + } + + logger_.info("Configuring GPIO {} to mode {}", static_cast(pin), mode); + + gpio_config_t io_conf = {}; + io_conf.pin_bit_mask = (1ULL << pin); + io_conf.mode = mode; + io_conf.pull_up_en = GPIO_PULLUP_DISABLE; + io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; + io_conf.intr_type = GPIO_INTR_DISABLE; + gpio_config(&io_conf); + + gpio_map_[pin].mode = mode; + gpio_state_[pin] = gpio_get_level(pin); + return true; +} + +void RemoteDebug::gpio_update_task() { + std::lock_guard lock(gpio_mutex_); + for (auto &[pin, config] : gpio_map_) { + gpio_state_[pin] = gpio_get_level(pin); + } +} + +void RemoteDebug::adc_sampling_task() { + auto now = esp_timer_get_time(); + + std::lock_guard lock(adc_mutex_); + + // Sample ADC1 + if (adc1_) { + auto values = adc1_->read_all_mv(); + for (size_t i = 0; i < values.size() && i < adc1_data_.size(); i++) { + auto &data = adc1_data_[i]; + // Convert mV to V + data.values[data.write_index] = values[i] / 1000.0f; + data.timestamps[data.write_index] = now; + data.write_index = (data.write_index + 1) % config_.adc_history_size; + if (data.count < config_.adc_history_size) { + data.count++; + } + } + } + + // Sample ADC2 + if (adc2_) { + auto values = adc2_->read_all_mv(); + for (size_t i = 0; i < values.size() && i < adc2_data_.size(); i++) { + auto &data = adc2_data_[i]; + // Convert mV to V + data.values[data.write_index] = values[i] / 1000.0f; + data.timestamps[data.write_index] = now; + data.write_index = (data.write_index + 1) % config_.adc_history_size; + if (data.count < config_.adc_history_size) { + data.count++; + } + } + } +} + +bool RemoteDebug::start_server() { + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = config_.server_port; + config.max_uri_handlers = 8; + config.stack_size = 8192; + + if (httpd_start(&server_, &config) != ESP_OK) { + logger_.error("Failed to start HTTP server"); + return false; + } + + // Register URI handlers + httpd_uri_t root_uri = { + .uri = "/", .method = HTTP_GET, .handler = root_handler, .user_ctx = this}; + httpd_register_uri_handler(server_, &root_uri); + + httpd_uri_t gpio_get_uri = { + .uri = "/api/gpio/get", .method = HTTP_GET, .handler = gpio_get_handler, .user_ctx = this}; + httpd_register_uri_handler(server_, &gpio_get_uri); + + httpd_uri_t gpio_set_uri = { + .uri = "/api/gpio/set", .method = HTTP_POST, .handler = gpio_set_handler, .user_ctx = this}; + httpd_register_uri_handler(server_, &gpio_set_uri); + + httpd_uri_t gpio_config_uri = {.uri = "/api/gpio/config", + .method = HTTP_POST, + .handler = gpio_config_handler, + .user_ctx = this}; + httpd_register_uri_handler(server_, &gpio_config_uri); + + httpd_uri_t adc_data_uri = { + .uri = "/api/adc/data", .method = HTTP_GET, .handler = adc_data_handler, .user_ctx = this}; + httpd_register_uri_handler(server_, &adc_data_uri); + + httpd_uri_t logs_uri = { + .uri = "/api/logs", .method = HTTP_GET, .handler = logs_handler, .user_ctx = this}; + httpd_register_uri_handler(server_, &logs_uri); + + return true; +} + +void RemoteDebug::stop_server() { + if (server_) { + httpd_stop(server_); + server_ = nullptr; + } +} + +esp_err_t RemoteDebug::root_handler(httpd_req_t *req) { + auto *self = static_cast(req->user_ctx); + auto html = self->generate_html(); + httpd_resp_set_type(req, "text/html"); + httpd_resp_send(req, html.c_str(), html.length()); + return ESP_OK; +} + +esp_err_t RemoteDebug::gpio_get_handler(httpd_req_t *req) { + auto *self = static_cast(req->user_ctx); + auto json = self->get_gpio_state_json(); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json.c_str(), json.length()); + return ESP_OK; +} + +esp_err_t RemoteDebug::gpio_set_handler(httpd_req_t *req) { + auto *self = static_cast(req->user_ctx); + + // Read request body properly + std::string body; + if (!read_request_body(req, body)) { + return ESP_FAIL; + } + + // Parse pin=X&level=Y + int pin = -1, level = -1; + sscanf(body.c_str(), "pin=%d&level=%d", &pin, &level); + + if (pin < 0 || (level != 0 && level != 1)) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid pin or level"); + return ESP_FAIL; + } + + // Attempt to set GPIO + if (!self->set_gpio(static_cast(pin), level)) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "GPIO not configured or not an output"); + return ESP_FAIL; + } + + httpd_resp_send(req, "OK", 2); + return ESP_OK; +} + +esp_err_t RemoteDebug::gpio_config_handler(httpd_req_t *req) { + auto *self = static_cast(req->user_ctx); + + // Read request body properly + std::string body; + if (!read_request_body(req, body)) { + return ESP_FAIL; + } + + // Parse pin=X&mode=Y + int pin = -1, mode = -1; + sscanf(body.c_str(), "pin=%d&mode=%d", &pin, &mode); + + // Validate mode (ESP-IDF supports modes 0-5) + if (pin < 0 || mode < 0 || mode > 5) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid pin or mode"); + return ESP_FAIL; + } + + // Attempt to configure GPIO + if (!self->configure_gpio(static_cast(pin), static_cast(mode))) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "GPIO configuration failed"); + return ESP_FAIL; + } + + httpd_resp_send(req, "OK", 2); + return ESP_OK; +} + +esp_err_t RemoteDebug::adc_data_handler(httpd_req_t *req) { + auto *self = static_cast(req->user_ctx); + auto json = self->get_adc_data_json(); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json.c_str(), json.length()); + return ESP_OK; +} + +std::string RemoteDebug::generate_html() const { + std::stringstream ss; + ss << R"(Remote Debug - )" << config_.device_name << R"( + + +
+

🔧 )" + << config_.device_name << R"(

)"; + + // GPIO controls + ss << "

GPIO Control

"; + ss << "
"; + ss << "
Pin
Mode
Controls
State
"; + ss << "
"; + for (const auto &[pin, cfg] : gpio_map_) { + ss << "
"; + ss << "" + << (cfg.label.empty() ? "GPIO " + std::to_string(pin) : cfg.label) << " (GPIO " << pin + << ")"; + ss << ""; + ss << "
"; + ss << ""; + ss << ""; + ss << "
"; + ss << "?"; + ss << "
"; + } + ss << "
"; + + // ADC display + if (!adc1_data_.empty() || !adc2_data_.empty()) { + ss << "

ADC Monitoring

"; + for (const auto &data : adc1_data_) { + ss << "
"; + ss << "" + << (data.label.empty() + ? ("ADC1_CH" + std::to_string(static_cast(data.channel))) + : data.label + " (ADC1_CH" + std::to_string(static_cast(data.channel)) + ")") + << ""; + ss << "?"; + ss << "
"; + } + for (const auto &data : adc2_data_) { + ss << "
"; + ss << "" + << (data.label.empty() + ? ("ADC2_CH" + std::to_string(static_cast(data.channel))) + : data.label + " (ADC2_CH" + std::to_string(static_cast(data.channel)) + ")") + << ""; + ss << "?"; + ss << "
"; + } + ss << ""; + ss << "
"; + } + + // Log viewer + if (config_.enable_log_capture) { + ss << R"(
+

Console Logs

+
+ + +
+
+
Loading logs...
+
+
)"; + } + + // JavaScript + ss << R"(
)"; + + return ss.str(); +} + +std::string RemoteDebug::get_gpio_state_json() const { + std::lock_guard lock(const_cast(gpio_mutex_)); + std::stringstream ss; + ss << "{"; + bool first = true; + for (const auto &[pin, cfg] : gpio_map_) { + if (!first) + ss << ","; + ss << "\"" << static_cast(pin) << "\":" << gpio_state_.at(pin); + first = false; + } + ss << "}"; + return ss.str(); +} + +std::string RemoteDebug::get_adc_data_json() const { + std::lock_guard lock(adc_mutex_); + std::stringstream ss; + ss << "{"; + + bool first = true; + + // Process ADC1 channels + for (size_t i = 0; i < adc1_data_.size(); i++) { + const auto &data = adc1_data_[i]; + if (!first) + ss << ","; + + // Use unique key based on unit and channel (not label which can be non-unique) + std::string key = fmt::format("ADC1_{}", static_cast(data.channel)); + + // Get the most recent value + float voltage = 0.0f; + if (data.count > 0) { + size_t latest_idx = (data.write_index + data.values.size() - 1) % data.values.size(); + voltage = data.values[latest_idx]; + } + + ss << "\"" << key << "\":{"; + ss << "\"voltage\":" << std::fixed << std::setprecision(3) << voltage << ","; + ss << "\"current\":" << voltage << ","; + ss << "\"label\":\"" << json_escape(data.label) << "\","; + ss << "\"unit\":1,"; + ss << "\"channel\":" << static_cast(data.channel) << ","; + + // Send all buffered data for plotting + ss << "\"history\":{"; + ss << "\"count\":" << data.count << ","; + ss << "\"values\":["; + + // Send data in chronological order (oldest to newest) + const size_t count = data.count; + const size_t capacity = data.values.size(); + for (size_t j = 0; j < count; j++) { + if (j > 0) + ss << ","; + size_t idx = (data.write_index + capacity - count + j) % capacity; + ss << std::fixed << std::setprecision(3) << data.values[idx]; + } + ss << "],"; + + ss << "\"timestamps\":["; + for (size_t j = 0; j < count; j++) { + if (j > 0) + ss << ","; + size_t idx = (data.write_index + capacity - count + j) % capacity; + ss << data.timestamps[idx]; + } + ss << "]"; + ss << "}"; // close history + ss << "}"; // close channel + first = false; + } + + // Process ADC2 channels + for (size_t i = 0; i < adc2_data_.size(); i++) { + const auto &data = adc2_data_[i]; + if (!first) + ss << ","; + + // Use unique key based on unit and channel (not label which can be non-unique) + std::string key = fmt::format("ADC2_{}", static_cast(data.channel)); + + // Get the most recent value + float voltage = 0.0f; + if (data.count > 0) { + size_t latest_idx = (data.write_index + data.values.size() - 1) % data.values.size(); + voltage = data.values[latest_idx]; + } + + ss << "\"" << key << "\":{"; + ss << "\"voltage\":" << std::fixed << std::setprecision(3) << voltage << ","; + ss << "\"current\":" << voltage << ","; + ss << "\"label\":\"" << json_escape(data.label) << "\","; + ss << "\"unit\":2,"; + ss << "\"channel\":" << static_cast(data.channel) << ","; + + // Send all buffered data for plotting + ss << "\"history\":{"; + ss << "\"count\":" << data.count << ","; + ss << "\"values\":["; + + // Send data in chronological order (oldest to newest) + const size_t count = data.count; + const size_t capacity = data.values.size(); + for (size_t j = 0; j < count; j++) { + if (j > 0) + ss << ","; + size_t idx = (data.write_index + capacity - count + j) % capacity; + ss << std::fixed << std::setprecision(3) << data.values[idx]; + } + ss << "],"; + + ss << "\"timestamps\":["; + for (size_t j = 0; j < count; j++) { + if (j > 0) + ss << ","; + size_t idx = (data.write_index + capacity - count + j) % capacity; + ss << data.timestamps[idx]; + } + ss << "]"; + ss << "}"; // close history + ss << "}"; // close channel + first = false; + } + + ss << "}"; + return ss.str(); +} + +void RemoteDebug::setup_log_redirection() { + if (!config_.enable_log_capture) { + logger_.debug("Log capture not enabled"); + return; + } + + if (log_file_) { + logger_.warn("Stdout already redirected to log file"); + return; + } + +#ifndef CONFIG_LITTLEFS_FLUSH_FILE_EVERY_WRITE + logger_.warn("**************************************************************"); + logger_.warn("WARNING: CONFIG_LITTLEFS_FLUSH_FILE_EVERY_WRITE is not enabled!"); + logger_.warn("Logs will not appear in real-time on the web interface."); + logger_.warn("Enable this option in menuconfig:"); + logger_.warn(" Component config -> LittleFS -> Flush file every write"); + logger_.warn("**************************************************************"); +#endif + + auto &fs = FileSystem::get(); + auto log_path = fs.get_root_path() / config_.log_file_path; + + logger_.info("Attempting to redirect stdout to: {}", log_path); + logger_.info("Log buffer size: {} bytes", config_.max_log_size); + + // Redirect stdout using freopen + FILE *result = freopen(log_path.string().c_str(), "w", stdout); + if (!result) { + logger_.error("Failed to redirect stdout to log file: {} (errno: {})", log_path, + strerror(errno)); + return; + } + + log_file_ = result; + + // remove file buffer so logs are written immediately + setvbuf(log_file_, nullptr, _IONBF, 0); + + // Write test message directly to stdout (which is now the file) + fmt::print("=== Log capture started at {} s ===\n", Logger::get_time()); + fmt::print("Remote Debug log file: {}\n", log_path); + fflush(stdout); +} + +void RemoteDebug::stop_log_redirection() { + if (!log_file_) { + logger_.warn("Stdout not redirected to log file"); + return; + } + + // Write final message + fmt::print("=== Log capture stopped at {} s ===\n", Logger::get_time()); + fflush(stdout); + + // Redirect stdout back to console + FILE *result = freopen("/dev/console", "w", stdout); + if (!result) { + // Can't log this error since stdout is broken + return; + } + + log_file_ = nullptr; + logger_.info("Successfully restored stdout to console"); + // we have to suppress the resource leak warning here because freopen returns + // a new FILE* that we don't own and shouldn't fclose since this is just a + // virtual file system. If we called fclose here, it would close stdout, which + // would then lead to no console output and potential crashes next time we + // tried to redirect stdout to file. +} // cppcheck-suppress resourceLeak + +void RemoteDebug::cleanup_log_redirection() { stop_log_redirection(); } + +std::string RemoteDebug::get_logs() const { + if (!config_.enable_log_capture) { + return "Log capture not enabled"; + } + + // Flush stdout to ensure all logs are written + if (log_file_) { + fflush(log_file_); + } + + auto &fs = FileSystem::get(); + auto log_path = fs.get_root_path() / config_.log_file_path; + + // Open a separate file handle for reading + FILE *read_file = fopen(log_path.string().c_str(), "r"); + if (!read_file) { + return fmt::format("Failed to open log file for reading (errno: {})", strerror(errno)); + } + + // Get file size + fseek(read_file, 0, SEEK_END); + long file_size = ftell(read_file); + + if (file_size <= 0) { + fclose(read_file); + return "Log file is empty"; + } + + // Limit to max_log_size and seek to start of content we want to read + long read_size = std::min(file_size, static_cast(config_.max_log_size)); + long start_pos = file_size - read_size; + fseek(read_file, start_pos, SEEK_SET); + + // Read content + std::string content; + content.resize(read_size); + size_t bytes_read = fread(&content[0], 1, read_size, read_file); + content.resize(bytes_read); + fclose(read_file); + + if (bytes_read == 0) { + return fmt::format("Failed to read log file (errno: {}) (read_size: {})", strerror(errno), + read_size); + } + + return content; +} + +esp_err_t RemoteDebug::logs_handler(httpd_req_t *req) { + RemoteDebug *self = static_cast(req->user_ctx); + + std::string logs = self->get_logs(); + + httpd_resp_set_type(req, "text/plain"); + httpd_resp_send(req, logs.c_str(), logs.length()); + return ESP_OK; +} + +esp_err_t RemoteDebug::start_log_handler(httpd_req_t *req) { + RemoteDebug *self = static_cast(req->user_ctx); + + if (!self->config_.enable_log_capture) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Log capture not enabled"); + return ESP_FAIL; + } + + self->setup_log_redirection(); + httpd_resp_sendstr(req, "OK"); + return ESP_OK; +} + +esp_err_t RemoteDebug::stop_log_handler(httpd_req_t *req) { + RemoteDebug *self = static_cast(req->user_ctx); + + if (!self->config_.enable_log_capture) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Log capture not enabled"); + return ESP_FAIL; + } + + self->stop_log_redirection(); + httpd_resp_sendstr(req, "OK"); + return ESP_OK; +} diff --git a/doc/Doxyfile b/doc/Doxyfile index 6c00a5180..44c99e35f 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -135,6 +135,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/qtpy/example/main/qtpy_example.cpp \ $(PROJECT_PATH)/components/qmi8658/example/main/qmi8658_example.cpp \ $(PROJECT_PATH)/components/qwiicnes/example/main/qwiicnes_example.cpp \ + $(PROJECT_PATH)/components/remote_debug/example/main/remote_debug_example.cpp \ $(PROJECT_PATH)/components/rmt/example/main/rmt_example.cpp \ $(PROJECT_PATH)/components/rtsp/example/main/rtsp_example.cpp \ $(PROJECT_PATH)/components/ping/example/main/ping_example.cpp \ @@ -301,6 +302,7 @@ INPUT = \ $(PROJECT_PATH)/components/qmi8658/include/qmi8658_detail.hpp \ $(PROJECT_PATH)/components/qtpy/include/qtpy.hpp \ $(PROJECT_PATH)/components/qwiicnes/include/qwiicnes.hpp \ + $(PROJECT_PATH)/components/remote_debug/include/remote_debug.hpp \ $(PROJECT_PATH)/components/rmt/include/rmt.hpp \ $(PROJECT_PATH)/components/rmt/include/rmt_encoder.hpp \ $(PROJECT_PATH)/components/rtsp/include/rtsp_client.hpp \ diff --git a/doc/en/remote_debug.rst b/doc/en/remote_debug.rst new file mode 100644 index 000000000..101980a51 --- /dev/null +++ b/doc/en/remote_debug.rst @@ -0,0 +1,24 @@ +Remote Debug APIs +***************** + +The `RemoteDebug` class provides a web-based interface for remote debugging and monitoring of ESP32 devices. It offers: + +* **GPIO Control**: Configure pins as input/output and control their states remotely +* **ADC Monitoring**: Real-time voltage monitoring with live graphs and configurable sampling rates +* **Console Log Viewing**: Capture and display stdout output with ANSI color support +* **Multi-Client Support**: Efficient batched updates to support multiple simultaneous web clients + +The component uses the espp WiFi, ADC, and FileSystem components for functionality, and provides a responsive web interface accessible from any browser on the local network. + +.. ------------------------------- Example ------------------------------------- + +.. toctree:: + + remote_debug_example + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/remote_debug.inc diff --git a/doc/en/remote_debug_example.md b/doc/en/remote_debug_example.md new file mode 100644 index 000000000..3e3e04896 --- /dev/null +++ b/doc/en/remote_debug_example.md @@ -0,0 +1,2 @@ +```{include} ../../components/remote_debug/example/README.md +```