From 8b863dd620c907c7bdab3282254948fbb1988bad Mon Sep 17 00:00:00 2001 From: Abhik Roy Date: Tue, 8 Jul 2025 19:56:39 +1000 Subject: [PATCH] feat(websocket): Added remote console example over websocket --- .../CMakeLists.txt | 8 + .../websocket-client-console_exp/README.md | 54 +++++ .../websocket-client-console_exp/console.py | 62 ++++++ .../main/CMakeLists.txt | 4 + .../main/idf_component.yml | 15 ++ .../websocket-client-console_exp/main/main.c | 197 +++++++++++++++++ .../main/websocket_client_vfs.c | 204 ++++++++++++++++++ .../main/websocket_client_vfs.h | 33 +++ 8 files changed, 577 insertions(+) create mode 100644 components/esp_websocket_client/examples/websocket-client-console_exp/CMakeLists.txt create mode 100644 components/esp_websocket_client/examples/websocket-client-console_exp/README.md create mode 100644 components/esp_websocket_client/examples/websocket-client-console_exp/console.py create mode 100644 components/esp_websocket_client/examples/websocket-client-console_exp/main/CMakeLists.txt create mode 100644 components/esp_websocket_client/examples/websocket-client-console_exp/main/idf_component.yml create mode 100644 components/esp_websocket_client/examples/websocket-client-console_exp/main/main.c create mode 100644 components/esp_websocket_client/examples/websocket-client-console_exp/main/websocket_client_vfs.c create mode 100644 components/esp_websocket_client/examples/websocket-client-console_exp/main/websocket_client_vfs.h diff --git a/components/esp_websocket_client/examples/websocket-client-console_exp/CMakeLists.txt b/components/esp_websocket_client/examples/websocket-client-console_exp/CMakeLists.txt new file mode 100644 index 0000000000..eb9b777a68 --- /dev/null +++ b/components/esp_websocket_client/examples/websocket-client-console_exp/CMakeLists.txt @@ -0,0 +1,8 @@ +# For more information about build system see +# https://docs.espressif.com/projects/esp-idf/en/latest/api-guides/build-system.html +# The following five 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.16) +set(COMPONENTS main) +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(websocket-client-console_exp) diff --git a/components/esp_websocket_client/examples/websocket-client-console_exp/README.md b/components/esp_websocket_client/examples/websocket-client-console_exp/README.md new file mode 100644 index 0000000000..74256cdcc5 --- /dev/null +++ b/components/esp_websocket_client/examples/websocket-client-console_exp/README.md @@ -0,0 +1,54 @@ +# ESP32 Remote Console over WebSocket + +This project provides a remote console for ESP32, redirecting standard I/O (`stdin`, `stdout`, `stderr`) to a WebSocket via a VFS driver. It uses a Python WebSocket server (`console.py`) for remote interaction with the ESP32's command-line interface. + +## Features +- WebSocket-based remote console with low-latency bidirectional communication. +- VFS driver redirects I/O to WebSocket, supporting standard C library functions. +- Console REPL for command execution, extensible via ESP-IDF console component. +- WebSocket URI configurable via NVS (default: `ws://192.168.50.231:8080`). + +## Components +- **ESP32 Firmware**: + - `main.c`: Initializes network, WebSocket client, VFS, and console. + - `websocket_client_vfs.c/h`: VFS driver for WebSocket I/O redirection. +- **Python Server** (`console.py`): WebSocket server for console interaction using `websockets` and `aioconsole`. + +## Prerequisites +- ESP-IDF v5.0+ configured. +- ESP32 board with Wi-Fi access. +- Python 3.7+ with `websockets` and `aioconsole` (`pip install websockets aioconsole`). +- Host machine on the same network as the ESP32. + +## Setup + +### ESP32 +1. Clone the repository and navigate to the project directory. +2. Configure Wi-Fi: + ```bash + idf.py menuconfig + ``` + - Set `Example Connection Configuration > WiFi SSID` and `WiFi Password`. +3. Build and flash: + ```bash + idf.py -p /dev/ttyUSB0 build flash monitor + ``` + +### Python Server +1. Run the WebSocket server: + ```bash + python console.py --host 0.0.0.0 --port 8080 + ``` + - Note the host’s IP (e.g., `192.168.50.231`) for ESP32 configuration. + +## Usage +1. Start the Python server (`python console.py --host 0.0.0.0 --port 8080`). +2. Power on the ESP32; it connects to Wi-Fi and the WebSocket server. +3. In the Python server terminal, enter commands (e.g., `help`, `nvs`) and view responses. +4. Stop the server with `Ctrl+C` or reset the ESP32 to halt. + +## Configuration +- **WebSocket URI**: + - Default: `ws://192.168.50.231:8080`. + - Or edit `DEFAULT_WS_URI` in `main.c` and reflash. +- **Wi-Fi**: Update via `menuconfig` or use provisioning (e.g., softAP). diff --git a/components/esp_websocket_client/examples/websocket-client-console_exp/console.py b/components/esp_websocket_client/examples/websocket-client-console_exp/console.py new file mode 100644 index 0000000000..fdc80c22d5 --- /dev/null +++ b/components/esp_websocket_client/examples/websocket-client-console_exp/console.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python +# SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Unlicense OR CC0-1.0 + +import argparse +import asyncio + +import aioconsole +import websockets + +cur_websocket = None +last_input = '' + + +async def handler(websocket): + global cur_websocket + global last_input + cur_websocket = websocket + await aioconsole.aprint('Connected!') + while True: + try: + message = await websocket.recv() + if last_input and message.startswith(last_input): + message = message[len(last_input):] + last_input = '' + await aioconsole.aprint(message.decode('utf-8'), end='') + except websockets.exceptions.ConnectionClosedError: + return + + +async def websocket_loop(host: str, port: int): + async with websockets.serve(handler, host, port): + await asyncio.Future() + + +async def console_loop(): + while True: + cmd = await aioconsole.ainput('') + '\n' + if cur_websocket is not None: + await cur_websocket.send(cmd.encode('utf-8')) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + '--host', type=str, help='Host to listen on', default='localhost' + ) + parser.add_argument( + '-p', '--port', type=int, help='Port to listen on', default=8080 + ) + args = parser.parse_args() + loop = asyncio.get_event_loop() + loop.create_task(websocket_loop(args.host, args.port)) + loop.create_task(console_loop()) + try: + loop.run_forever() + except KeyboardInterrupt: + pass + + +if __name__ == '__main__': + main() diff --git a/components/esp_websocket_client/examples/websocket-client-console_exp/main/CMakeLists.txt b/components/esp_websocket_client/examples/websocket-client-console_exp/main/CMakeLists.txt new file mode 100644 index 0000000000..97f7e12c53 --- /dev/null +++ b/components/esp_websocket_client/examples/websocket-client-console_exp/main/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register(SRCS "websocket_client_vfs.c" + "main.c" + INCLUDE_DIRS "." + PRIV_REQUIRES console vfs nvs_flash esp_event esp_netif esp_wifi esp_ringbuf esp_eth) diff --git a/components/esp_websocket_client/examples/websocket-client-console_exp/main/idf_component.yml b/components/esp_websocket_client/examples/websocket-client-console_exp/main/idf_component.yml new file mode 100644 index 0000000000..aebae28913 --- /dev/null +++ b/components/esp_websocket_client/examples/websocket-client-console_exp/main/idf_component.yml @@ -0,0 +1,15 @@ +dependencies: + idf: ">=5.0" + espressif/esp_websocket_client: + version: "^1.0.1" + override_path: ../../../ + protocol_examples_common: + path: ${IDF_PATH}/examples/common_components/protocol_examples_common + cmd_nvs: + path: ${IDF_PATH}/examples/system/console/advanced/components/cmd_nvs + cmd_system: + path: ${IDF_PATH}/examples/system/console/advanced/components/cmd_system + espressif/console_cmd_ifconfig: + version: "^1.0.0" + espressif/console_cmd_ping: + version: "^1.1.0" diff --git a/components/esp_websocket_client/examples/websocket-client-console_exp/main/main.c b/components/esp_websocket_client/examples/websocket-client-console_exp/main/main.c new file mode 100644 index 0000000000..7e59ac2ded --- /dev/null +++ b/components/esp_websocket_client/examples/websocket-client-console_exp/main/main.c @@ -0,0 +1,197 @@ +/* + * SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Unlicense OR CC0-1.0 + */ + +/** + * @file main.c + * @brief Main application for ESP32 remote console over WebSocket. + * + * This application initializes a WebSocket client, redirects standard I/O to a WebSocket + * via a VFS driver, and runs a console REPL for remote command execution. + */ + +#include +#include "esp_log.h" +#include "esp_vfs.h" +#include "esp_console.h" +#include "nvs_flash.h" +#include "protocol_examples_common.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/ringbuf.h" +#include "linenoise/linenoise.h" +#include "argtable3/argtable3.h" +#include "esp_websocket_client.h" +#include "websocket_client_vfs.h" +#include "cmd_nvs.h" +#include "console_simple_init.h" + +static const char *TAG = "remote_console"; +static const char *DEFAULT_WS_URI = "ws://192.168.50.231:8080"; + +static void websocket_event_handler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data); +static esp_websocket_client_handle_t websocket_app_init(void); +static void websocket_app_exit(esp_websocket_client_handle_t client); +static void run_console_task(void); +static void console_task(void* arg); +static void vfs_exit(FILE* websocket_io); +static FILE* vfs_init(void); + +void app_main(void) +{ + ESP_ERROR_CHECK(nvs_flash_init()); + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + ESP_ERROR_CHECK(example_connect()); + + esp_websocket_client_handle_t client = websocket_app_init(); + if (client == NULL) { + ESP_LOGE(TAG, "Failed to initialize websocket client"); + return; + } + ESP_LOGI(TAG, "Websocket client initialized"); + + FILE* websocket_io = vfs_init(); + if (websocket_io == NULL) { + ESP_LOGE(TAG, "Failed to open websocket I/O file"); + return; + } + + run_console_task(); + + while (true) { + vTaskDelay(pdMS_TO_TICKS(1000)); + } + + vfs_exit(websocket_io); + websocket_app_exit(client); +} + +static esp_websocket_client_handle_t websocket_app_init(void) +{ + websocket_client_vfs_config_t config = { + .base_path = "/websocket", + .send_timeout_ms = 10000, + .recv_timeout_ms = 10000, + .recv_buffer_size = 256, + .fallback_stdout = stdout + }; + ESP_ERROR_CHECK(websocket_client_vfs_register(&config)); + + esp_websocket_client_config_t websocket_cfg = {}; + //websocket_cfg.uri = "ws://192.168.50.231:8080"; + websocket_cfg.uri = DEFAULT_WS_URI; + websocket_cfg.reconnect_timeout_ms = 1000; + websocket_cfg.network_timeout_ms = 10000; + + ESP_LOGI(TAG, "Connecting to %s...", websocket_cfg.uri); + + esp_websocket_client_handle_t client = esp_websocket_client_init(&websocket_cfg); + esp_websocket_register_events(client, WEBSOCKET_EVENT_ANY, websocket_event_handler, (void *)client); + esp_websocket_client_start(client); + websocket_client_vfs_add_client(client, 0); + + return client; +} + +static void websocket_app_exit(esp_websocket_client_handle_t client) +{ + esp_websocket_client_close(client, portMAX_DELAY); + ESP_LOGI(TAG, "Websocket Stopped"); + esp_websocket_client_destroy(client); +} + + +/** + * @brief Initialize VFS for WebSocket I/O redirection. + * @return FILE pointer for WebSocket I/O, or NULL on failure. + */ +static FILE* vfs_init(void) +{ + FILE *websocket_io = fopen("/websocket/0", "r+"); + if (!websocket_io) { + ESP_LOGE(TAG, "Failed to open websocket I/O file"); + return NULL; + } + + stdin = websocket_io; + stdout = websocket_io; + setvbuf(stdin, NULL, _IONBF, 0); + setvbuf(stdout, NULL, _IONBF, 0); + + _GLOBAL_REENT->_stdin = websocket_io; + _GLOBAL_REENT->_stdout = websocket_io; + _GLOBAL_REENT->_stderr = websocket_io; + + return websocket_io; +} + +/** + * @brief Clean up VFS resources. + * @param websocket_io FILE pointer for WebSocket I/O. + */ +static void vfs_exit(FILE* websocket_io) +{ + if (websocket_io) { + fclose(websocket_io); + websocket_io = NULL; + } +} + +/** + * @brief WebSocket event handler. + * @param handler_args User-defined arguments. + * @param base Event base. + * @param event_id Event ID. + * @param event_data Event data. + */ +static void websocket_event_handler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data) +{ + esp_websocket_event_data_t *data = (esp_websocket_event_data_t *)event_data; + + switch (event_id) { + case WEBSOCKET_EVENT_CONNECTED: + ESP_LOGI(TAG, "Websocket connected"); + break; + case WEBSOCKET_EVENT_DISCONNECTED: + ESP_LOGI(TAG, "Websocket disconnected"); + break; + case WEBSOCKET_EVENT_DATA: + if (data->op_code == 0x08 && data->data_len == 2) { + ESP_LOGI(TAG, "Received closed message with code=%d", 256 * data->data_ptr[0] + data->data_ptr[1]); + } + break; + case WEBSOCKET_EVENT_ERROR: + ESP_LOGI(TAG, "Websocket error"); + break; + } + + websocket_client_vfs_event_handler(data->client, event_id, event_data); +} + + +static void run_console_task(void) +{ + vTaskDelay(pdMS_TO_TICKS(1000)); + xTaskCreate(console_task, "console_task", 16 * 1024, NULL, 5, NULL); +} + + +static void console_task(void* arg) +{ + // Initialize console REPL + ESP_ERROR_CHECK(console_cmd_init()); + ESP_ERROR_CHECK(console_cmd_all_register()); + + // start console REPL + ESP_ERROR_CHECK(console_cmd_start()); + + while (true) { + //fprintf(websocket_io, "From: %s(%d)\n", __func__, __LINE__); + vTaskDelay(pdMS_TO_TICKS(5000)); + } + + vTaskDelete(NULL); +} diff --git a/components/esp_websocket_client/examples/websocket-client-console_exp/main/websocket_client_vfs.c b/components/esp_websocket_client/examples/websocket-client-console_exp/main/websocket_client_vfs.c new file mode 100644 index 0000000000..7d30656c23 --- /dev/null +++ b/components/esp_websocket_client/examples/websocket-client-console_exp/main/websocket_client_vfs.c @@ -0,0 +1,204 @@ +/* + * SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Unlicense OR CC0-1.0 + */ +#include +#include +#include +#include +#include +#include "esp_err.h" +#include "esp_log.h" +#include "esp_vfs.h" +#include "freertos/FreeRTOS.h" +#include "freertos/projdefs.h" +#include "freertos/ringbuf.h" +#include "esp_websocket_client.h" +#include "websocket_client_vfs.h" + +#define MAX_CLIENTS 4 +static const char* TAG = "websocket_client_vfs"; + +static ssize_t websocket_client_vfs_write(void* ctx, int fd, const void * data, size_t size); +static ssize_t websocket_client_vfs_read(void* ctx, int fd, void * dst, size_t size); +static int websocket_client_vfs_open(void* ctx, const char * path, int flags, int mode); +static int websocket_client_vfs_close(void* ctx, int fd); +static int websocket_client_vfs_fstat(void* ctx, int fd, struct stat * st); + +typedef struct { + esp_websocket_client_handle_t ws_client_handle; + bool opened; + RingbufHandle_t from_websocket; +} websocket_client_vfs_desc_t; + +static websocket_client_vfs_desc_t s_desc[MAX_CLIENTS]; +static _lock_t s_lock; +static websocket_client_vfs_config_t s_config; + +esp_err_t websocket_client_vfs_register(const websocket_client_vfs_config_t *config) +{ + s_config = *config; + const esp_vfs_t vfs = { + .flags = ESP_VFS_FLAG_CONTEXT_PTR, + .open_p = websocket_client_vfs_open, + .close_p = websocket_client_vfs_close, + .read_p = websocket_client_vfs_read, + .write_p = websocket_client_vfs_write, + .fstat_p = websocket_client_vfs_fstat, + }; + return esp_vfs_register(config->base_path, &vfs, NULL); +} + +esp_err_t websocket_client_vfs_event_handler(esp_websocket_client_handle_t handle, int32_t event_id, const esp_websocket_event_data_t *event_data) +{ + int fd; + for (fd = 0; fd < MAX_CLIENTS; ++fd) { + if (s_desc[fd].ws_client_handle == handle) { + break; + } + } + if (fd == MAX_CLIENTS) { + // didn't find the handle + return ESP_ERR_INVALID_ARG; + } + RingbufHandle_t rb = s_desc[fd].from_websocket; + + switch (event_id) { + case WEBSOCKET_EVENT_DATA: + if (event_data->op_code == 2 /* binary frame */) { + xRingbufferSend(rb, event_data->data_ptr, event_data->data_len, s_config.recv_timeout_ms); + } + break; + } + return ESP_OK; +} + +static ssize_t websocket_client_vfs_write(void* ctx, int fd, const void * data, size_t size) +{ + static int writing = 0; + if (fd < 0 || fd > MAX_CLIENTS) { + errno = EBADF; + return -1; + } + if (writing) { + // avoid re-entry, print to original stdout + return fwrite(data, 1, size, s_config.fallback_stdout); + } + writing = 1; + int sent = esp_websocket_client_send_bin(s_desc[fd].ws_client_handle, data, size, pdMS_TO_TICKS(s_config.send_timeout_ms)); + writing = 0; + return sent; +} + +static ssize_t websocket_client_vfs_read(void* ctx, int fd, void * dst, size_t size) +{ + size_t read_remaining = size; + uint8_t* p_dst = (uint8_t*) dst; + RingbufHandle_t rb = s_desc[fd].from_websocket; + while (read_remaining > 0) { + size_t read_size; + void * ptr = xRingbufferReceiveUpTo(rb, &read_size, portMAX_DELAY, read_remaining); + if (ptr == NULL) { + // timeout + errno = EIO; + break; + } + memcpy(p_dst, ptr, read_size); + vRingbufferReturnItem(rb, ptr); + read_remaining -= read_size; + } + return size - read_remaining; +} + +static int websocket_client_vfs_open(void* ctx, const char * path, int flags, int mode) +{ + if (path[0] != '/') { + errno = ENOENT; + return -1; + } + int fd = strtol(path + 1, NULL, 10); + if (fd < 0 || fd >= MAX_CLIENTS) { + errno = ENOENT; + return -1; + } + int res = -1; + _lock_acquire(&s_lock); + if (s_desc[fd].opened) { + errno = EPERM; + } else { + s_desc[fd].opened = true; + res = fd; + } + _lock_release(&s_lock); + return res; +} + +static int websocket_client_vfs_close(void* ctx, int fd) +{ + if (fd < 0 || fd >= MAX_CLIENTS) { + errno = EBADF; + return -1; + } + int res = -1; + _lock_acquire(&s_lock); + if (!s_desc[fd].opened) { + errno = EBADF; + } else { + s_desc[fd].opened = false; + res = 0; + } + _lock_release(&s_lock); + return res; +} + +static int websocket_client_vfs_fstat(void* ctx, int fd, struct stat * st) +{ + *st = (struct stat) { + 0 + }; + st->st_mode = S_IFCHR; + return 0; +} + +esp_err_t websocket_client_vfs_add_client(esp_websocket_client_handle_t handle, int id) +{ + esp_err_t res = ESP_OK; + _lock_acquire(&s_lock); + if (s_desc[id].ws_client_handle != NULL) { + ESP_LOGE(TAG, "%s: id=%d already in use", __func__, id); + res = ESP_ERR_INVALID_STATE; + } else { + ESP_LOGD(TAG, "%s: id=%d is now in use for websocket client handle=%p", __func__, id, handle); + s_desc[id].ws_client_handle = handle; + s_desc[id].opened = false; + s_desc[id].from_websocket = xRingbufferCreate(s_config.recv_buffer_size, RINGBUF_TYPE_BYTEBUF); + } + _lock_release(&s_lock); + return res; +} + +esp_err_t websocket_client_vfs_del_client(esp_websocket_client_handle_t handle) +{ + esp_err_t res = ESP_ERR_INVALID_ARG; + _lock_acquire(&s_lock); + for (int id = 0; id < MAX_CLIENTS; ++id) { + if (s_desc[id].ws_client_handle != handle) { + continue; + } + if (s_desc[id].ws_client_handle != NULL) { + ESP_LOGE(TAG, "%s: id=%d already in use", __func__, id); + res = ESP_ERR_INVALID_STATE; + break; + } else { + ESP_LOGD(TAG, "%s: id=%d is now in use for websocket client handle=%p", __func__, id, handle); + s_desc[id].ws_client_handle = NULL; + s_desc[id].opened = false; + vRingbufferDelete(s_desc[id].from_websocket); + res = ESP_OK; + break; + } + } + _lock_release(&s_lock); + return res; +} diff --git a/components/esp_websocket_client/examples/websocket-client-console_exp/main/websocket_client_vfs.h b/components/esp_websocket_client/examples/websocket-client-console_exp/main/websocket_client_vfs.h new file mode 100644 index 0000000000..e7840a1b9b --- /dev/null +++ b/components/esp_websocket_client/examples/websocket-client-console_exp/main/websocket_client_vfs.h @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Unlicense OR CC0-1.0 + */ +#pragma once + +#include "esp_err.h" +#include "esp_websocket_client.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + const char* base_path; + int send_timeout_ms; + int recv_timeout_ms; + size_t recv_buffer_size; + FILE* fallback_stdout; +} websocket_client_vfs_config_t; + +esp_err_t websocket_client_vfs_register(const websocket_client_vfs_config_t *config); + +esp_err_t websocket_client_vfs_add_client(esp_websocket_client_handle_t handle, int id); + +esp_err_t websocket_client_vfs_del_client(esp_websocket_client_handle_t handle); + +esp_err_t websocket_client_vfs_event_handler(esp_websocket_client_handle_t handle, int32_t event_id, const esp_websocket_event_data_t *event_data); + +#ifdef __cplusplus +} +#endif