diff --git a/.github/workflows/upload_component.yml b/.github/workflows/upload_component.yml index 90bb4b7..69361a8 100644 --- a/.github/workflows/upload_component.yml +++ b/.github/workflows/upload_component.yml @@ -3,17 +3,19 @@ name: Push esp-openclaw-node to IDF Component Registry on: push: branches: - - master + - main jobs: upload_components: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: "recursive" - name: Upload esp-openclaw-node to IDF Component Registry - uses: espressif/upload-components-ci-action@v1 + uses: espressif/upload-components-ci-action@v2 with: - name: esp-openclaw-node + components: "esp-openclaw-node:components/esp-openclaw-node" namespace: "espressif" api_token: ${{ secrets.IDF_COMPONENT_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c31c1a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +**/build*/ +**/managed_components/ +**/dependencies.lock +**/sdkconfig +**/sdkconfig.old +**/sdkconfig.ci +**/__pycache__/ +dist/ +.DS_Store diff --git a/components/esp-openclaw-node/CHANGELOG.md b/components/esp-openclaw-node/CHANGELOG.md new file mode 100644 index 0000000..2b36423 --- /dev/null +++ b/components/esp-openclaw-node/CHANGELOG.md @@ -0,0 +1,5 @@ +# This file contains the list of changes across different versions + +## v1.0.0 + +- Initial public release of the `esp-openclaw-node` ESP-IDF component. diff --git a/components/esp-openclaw-node/CMakeLists.txt b/components/esp-openclaw-node/CMakeLists.txt new file mode 100644 index 0000000..f1c2301 --- /dev/null +++ b/components/esp-openclaw-node/CMakeLists.txt @@ -0,0 +1,22 @@ +set(esp_openclaw_node_component_args + SRCS + "src/esp_openclaw_node_identity.c" + "src/esp_openclaw_node_persisted_session.c" + "src/esp_openclaw_node.c" + "src/esp_openclaw_node_connect_source.c" + "src/esp_openclaw_node_registry.c" + "src/esp_openclaw_node_protocol.c" + "src/esp_openclaw_node_transport.c" + "src/esp_openclaw_node_runtime.c" + INCLUDE_DIRS "include" + PRIV_INCLUDE_DIRS "private_include" + REQUIRES + espressif__cjson + esp_app_format + esp_websocket_client + libsodium + mbedtls + nvs_flash +) + +idf_component_register(${esp_openclaw_node_component_args}) diff --git a/components/esp-openclaw-node/Kconfig b/components/esp-openclaw-node/Kconfig new file mode 100644 index 0000000..b93d7d7 --- /dev/null +++ b/components/esp-openclaw-node/Kconfig @@ -0,0 +1,66 @@ +menu "ESP OpenClaw Node" + + menu "Registration" + + config ESP_OPENCLAW_NODE_MAX_CAPABILITIES + int "Maximum registered capabilities" + range 1 128 + default 16 + help + Maximum number of capability strings that a single node + instance can register while idle before the component rejects + additional capability registrations with ESP_ERR_NO_MEM. + + config ESP_OPENCLAW_NODE_MAX_COMMANDS + int "Maximum registered commands" + range 1 256 + default 32 + help + Maximum number of commands that a single node instance can + register while idle before the component rejects additional + command registrations with ESP_ERR_NO_MEM. + + endmenu + + menu "Runtime" + + config ESP_OPENCLAW_NODE_WORK_QUEUE_LENGTH + int "Worker queue length" + range 17 256 + default 32 + help + Length of the internal FreeRTOS queue used by the component + worker task to serialize connect, disconnect, transport, and + protocol work items. + + config ESP_OPENCLAW_NODE_TASK_STACK_SIZE + int "Worker task stack size" + range 2048 65536 + default 8192 + help + Stack size, in bytes, allocated to the component's internal + worker task created by esp_openclaw_node_create(). + + endmenu + + menu "WebSocket Transport" + + config ESP_OPENCLAW_NODE_TRANSPORT_TASK_STACK_SIZE + int "WebSocket client task stack size" + range 2048 65536 + default 8192 + help + Stack size, in bytes, requested for the esp_websocket_client + task used by the OpenClaw transport. + + config ESP_OPENCLAW_NODE_TRANSPORT_BUFFER_SIZE + int "WebSocket transport buffer size" + range 256 65536 + default 2048 + help + Buffer size, in bytes, requested for the esp_websocket_client + transport buffers used by the OpenClaw connection. + + endmenu + +endmenu diff --git a/components/esp-openclaw-node/LICENSE b/components/esp-openclaw-node/LICENSE new file mode 100644 index 0000000..2b9ce4d --- /dev/null +++ b/components/esp-openclaw-node/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright Espressif Systems + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/components/esp-openclaw-node/README.md b/components/esp-openclaw-node/README.md new file mode 100644 index 0000000..3e9e037 --- /dev/null +++ b/components/esp-openclaw-node/README.md @@ -0,0 +1,626 @@ +# esp-openclaw-node + +`esp-openclaw-node` is the ESP-IDF component package in this repository for +running an ESP32 application as an OpenClaw Node over WebSocket. +The public C API is declared in +[esp_openclaw_node.h](./include/esp_openclaw_node.h) and uses the +`esp_openclaw_node_*` prefix. + +The component provides: + +- Device identity generation and persistence +- OpenClaw `connect.challenge` signing and `connect` request construction +- setup-code, shared-token, password, no-auth, and saved-session connect paths +- capability and command advertisement +- Handling `node.invoke.request` commands and sending `node.invoke.result` replies + +## Contents + +- [Requirements](#requirements) +- [Overview](#overview) + - [What The Component Handles](#what-the-component-handles) + - [What The Application Handles](#what-the-application-handles) +- [Public API](#public-api) + - [Lifecycle](#lifecycle) + - [Registration](#registration) + - [Async Control](#async-control) + - [Inspection](#inspection) +- [Usage](#usage) + - [Basic Lifecycle](#basic-lifecycle) + - [Quick Start](#quick-start) + - [Configuration And Defaults](#configuration-and-defaults) + - [Registering Capabilities And Commands](#registering-capabilities-and-commands) + - [Command Handlers](#command-handlers) +- [Connect and Session Model](#connect-and-session-model) + - [Connect Model](#connect-model) + - [Supported Connect Sources](#supported-connect-sources) + - [Setup Codes](#setup-codes) + - [Events](#events) + - [Stored State](#stored-state) +- [Reference](#reference) + - [TLS](#tls) + - [Examples and Reconnect Policy](#examples-and-reconnect-policy) + - [Component Tests](#component-tests) + +## Requirements + +- ESP-IDF `5.x` +- a board that can reach the OpenClaw Gateway +- application-managed network setup, such as Wi-Fi or Ethernet +- `nvs_flash_init()`, `esp_netif_init()`, and the default event loop before the +node is created or connected + +## Overview + +### What The Component Handles + +- Generating or loading the device seed from NVS +- Deriving the Ed25519 keypair and stable `device_id` +- Opening one WebSocket transport at a time +- Handling `connect.challenge` +- Building and signing the OpenClaw `connect` request +- Advertising capabilities and commands +- Dispatching `node.invoke.request` into registered handlers +- Sending `node.invoke.result` +- Persisting the final `{ gateway_uri, device_token }` reconnect session after a +successful `hello-ok` + +### What The Application Handles + +- Wi-Fi, Ethernet, PPP, or any other route to the gateway +- Local UI, REPL, or provisioning flows +- How setup codes, gateway URIs, tokens, or passwords reach the board +- Deciding whether the next attempt should use a saved session or explicit auth +- Deciding whether and when to retry after `CONNECT_FAILED` or `DISCONNECTED` +- Any factory reset, identity reset, or saved-session clear workflow +- The actual device-specific command handlers + +## Public API + +### Lifecycle + +- `esp_openclaw_node_config_init_default()` +- `esp_openclaw_node_create()` +- `esp_openclaw_node_destroy()` + +### Registration + +- `esp_openclaw_node_register_capability()` +- `esp_openclaw_node_register_command()` + +### Async Control + +- `esp_openclaw_node_request_connect()` +- `esp_openclaw_node_request_disconnect()` + +### Inspection + +- `esp_openclaw_node_get_device_id()` +- `esp_openclaw_node_has_saved_session()` + +The component is driven through registration, one explicit connect request at a +time, and terminal events. + +## Usage + +### Basic Lifecycle + +1. Initialize NVS, `esp_netif`, and the default event loop. +2. Bring up networking, or arrange to wait for it before connecting. +3. Initialize `esp_openclaw_node_config_t` with + `esp_openclaw_node_config_init_default()`. +4. Create the node with `esp_openclaw_node_create()`. +5. Register capabilities and commands while the node is idle. +6. Submit one connect request with `esp_openclaw_node_request_connect()`. +7. Wait for a terminal event before submitting the next control request. +8. Destroy the node with `esp_openclaw_node_destroy()` when finished. + +### Quick Start + +```c +#include +#include "esp_openclaw_node.h" + +static esp_err_t handle_device_info( + esp_openclaw_node_handle_t node, + void *context, + const char *params_json, + size_t params_len, + char **out_payload_json, + esp_openclaw_node_error_t *out_error) +{ + (void)node; + (void)context; + (void)params_json; + (void)params_len; + (void)out_error; + + *out_payload_json = strdup("{\"status\":\"ok\"}"); + return *out_payload_json != NULL ? ESP_OK : ESP_ERR_NO_MEM; +} + +static void handle_node_event( + esp_openclaw_node_handle_t node, + esp_openclaw_node_event_t event, + const void *event_data, + void *user_ctx) +{ + (void)node; + (void)event_data; + (void)user_ctx; + + if (event == ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED) { + /* Decide whether and when to retry in application code. */ + } +} + +void app_main(void) +{ + esp_openclaw_node_config_t config = {0}; + esp_openclaw_node_handle_t node = NULL; + const char *setup_code = ""; + + esp_openclaw_node_config_init_default(&config); + config.event_cb = handle_node_event; + + ESP_ERROR_CHECK(esp_openclaw_node_create(&config, &node)); + ESP_ERROR_CHECK(esp_openclaw_node_register_capability(node, "device")); + + esp_openclaw_node_command_t cmd = { + .name = "device.info", + .handler = handle_device_info, + }; + ESP_ERROR_CHECK(esp_openclaw_node_register_command(node, &cmd)); + + esp_openclaw_node_connect_request_t request = { + .source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE, + .gateway_uri = NULL, + .value = setup_code, + }; + ESP_ERROR_CHECK(esp_openclaw_node_request_connect(node, &request)); +} +``` + +### Configuration And Defaults + +`esp_openclaw_node_config_init_default()` sets: + +- `display_name = "OpenClaw ESP32"` +- `platform = "esp32"` +- `device_family = "ESP32"` +- `client_id = "node-host"` +- `client_mode = "node"` +- `role = "node"` +- `model_identifier = CONFIG_IDF_TARGET` +- `locale = "en-US"` +- `use_cert_bundle = true` +- `tls_common_name = NULL` +- `tls_cert_pem = NULL` +- `skip_cert_common_name_check = false` + +### Registering Capabilities And Commands + +Register everything before the first connect request. + +```c +ESP_ERROR_CHECK(esp_openclaw_node_register_capability(node, "display")); + +esp_openclaw_node_command_t cmd = { + .name = "display.show", + .handler = handle_display_show, + .context = &display_state, +}; +ESP_ERROR_CHECK(esp_openclaw_node_register_command(node, &cmd)); +``` + +Rules: + +- capabilities are plain strings +- commands are plain strings plus a handler and optional context pointer +- Duplicate capability or command names are ignored and return `ESP_OK` +- Registration is allowed only while the node is idle +- Registration and transport resource limits default to +`ESP_OPENCLAW_NODE_MAX_CAPABILITIES`, `ESP_OPENCLAW_NODE_MAX_COMMANDS`, +the internal work-queue length, and the component/WebSocket task and buffer +sizes. These can be tuned in `menuconfig` under +`Component config -> ESP OpenClaw Node`. + +Current `menuconfig` options and defaults: + +- `CONFIG_ESP_OPENCLAW_NODE_MAX_CAPABILITIES` = `16` +- `CONFIG_ESP_OPENCLAW_NODE_MAX_COMMANDS` = `32` +- `CONFIG_ESP_OPENCLAW_NODE_WORK_QUEUE_LENGTH` = `32` +- `CONFIG_ESP_OPENCLAW_NODE_TASK_STACK_SIZE` = `8192` +- `CONFIG_ESP_OPENCLAW_NODE_TRANSPORT_TASK_STACK_SIZE` = `8192` +- `CONFIG_ESP_OPENCLAW_NODE_TRANSPORT_BUFFER_SIZE` = `2048` + +The component advertises capability names and command names only. It does not +currently send parameter schemas to the gateway. + +### Command Handlers + +Handler signature: + +```c +typedef esp_err_t (*esp_openclaw_node_command_handler_t)( + esp_openclaw_node_handle_t node, + void *context, + const char *params_json, + size_t params_len, + char **out_payload_json, + esp_openclaw_node_error_t *out_error); +``` + +Handler behavior: + +- handlers run synchronously on the component task +- `params_json` is the raw UTF-8 JSON string from `payload.paramsJSON` +- when the request omits `paramsJSON`, the component passes `"{}"` +- on success, return `ESP_OK` and optionally allocate a JSON string for +`*out_payload_json` +- on failure, return a non-`ESP_OK` code and populate `out_error` with a stable +error `code` and human-readable `message` +- any non-`NULL` `out_payload_json` buffer must be `malloc()`-compatible; the +component sends it as `payloadJSON` and then frees it + +Because handlers run on the component task, long-running work should be handed +off to another task if it cannot complete quickly. + +## Connect and Session Model + +### Connect Model + +The component performs one connection attempt at a time. It does not run an +automatic reconnect loop and it does not choose between saved-session reconnect +and explicit auth input on behalf of the application. + +Request rules: + +- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION` is valid only when a saved +reconnect session is present +Applications can check saved-session availability with +`esp_openclaw_node_has_saved_session()` before submitting that request. +- explicit connect requests are valid only while the node is idle +- `esp_openclaw_node_request_disconnect()` is valid only while the node is ready +- once destroy begins, new async requests are rejected + +For each accepted connect request, wait for exactly one terminal outcome before +submitting another control request. + +### Supported Connect Sources + +The public API exposes five caller-chosen connect sources: + +- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION` +- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE` +- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_TOKEN` +- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_PASSWORD` +- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_NO_AUTH` + +Field requirements for `esp_openclaw_node_connect_request_t`: + +- `SAVED_SESSION`: `gateway_uri = NULL`, `value = NULL` +- `SETUP_CODE`: `gateway_uri = NULL`, `value = ` +- `GATEWAY_TOKEN`: `gateway_uri = `, `value = ` +- `GATEWAY_PASSWORD`: `gateway_uri = `, +`value = ` +- `NO_AUTH`: `gateway_uri = `, `value = NULL` + +When a connect attempt begins, the component resolves auth material like this: + +- saved session: send `auth.deviceToken` +- explicit gateway token: send `auth.token` +- explicit gateway password: send `auth.password` +- explicit no-auth: omit the `auth` object +- setup code: depends on the decoded credential field + +This selection is per attempt. The component does not fall back from one auth +mode to another automatically. + +### Setup Codes + +In the current component, a setup code is base64url-encoded JSON that must +contain: + +- `url` +- exactly one of: + - `bootstrapToken` + - `token` + - `password` + +Example decoded payload: + +```json +{ + "url": "ws://192.168.1.10:19001", + "bootstrapToken": "oc_bootstrap_example_token" +} +``` + +
+Pairing Flow + +The usual first-pairing path is one explicit setup-code connect attempt. The +component does not stage setup-code state for a later `connect` call. + +```mermaid +sequenceDiagram + participant App as ESP-IDF App + participant Node as esp_openclaw_node + participant NVS as NVS + participant GW as OpenClaw Gateway + + App->>Node: esp_openclaw_node_create() + Node->>NVS: load or create device_seed + Node->>NVS: load saved reconnect session + App->>Node: register capabilities and commands + App->>Node: esp_openclaw_node_request_connect(SETUP_CODE) + Node->>Node: decode setup code + Node->>GW: open websocket to setup-code url + GW-->>Node: connect.challenge + Node->>Node: resolve auth material and sign payload + Node->>GW: connect(auth..., device signature) + alt hello-ok with auth.deviceToken + GW-->>Node: hello-ok + Node->>NVS: store {session_v, session_uri, session_dev_tok} + Node-->>App: ESP_OPENCLAW_NODE_EVENT_CONNECTED + else auth rejected or finalization fails + GW-->>Node: error or incomplete hello-ok + Node-->>App: ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED + end + GW-->>Node: node.invoke.request + Node->>App: registered handler(paramsJSON) + App-->>Node: payloadJSON or error + Node->>GW: node.invoke.result +``` + +
+ + +### Events + +The component emits these events through `esp_openclaw_node_event_cb_t`: + +- `ESP_OPENCLAW_NODE_EVENT_CONNECTED` +- `ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED` +- `ESP_OPENCLAW_NODE_EVENT_DISCONNECTED` + +`ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED` carries +`esp_openclaw_node_connect_failed_event_t` with: + +- `ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILED` +- `ESP_OPENCLAW_NODE_CONNECT_FAILURE_CONNECTION_LOST` +- `ESP_OPENCLAW_NODE_CONNECT_FAILURE_AUTH_REJECTED` +- `ESP_OPENCLAW_NODE_CONNECT_FAILURE_SESSION_FINALIZATION_FAILED` + +`ESP_OPENCLAW_NODE_EVENT_DISCONNECTED` carries +`esp_openclaw_node_disconnected_event_t` with: + +- `ESP_OPENCLAW_NODE_DISCONNECTED_REASON_REQUESTED` +- `ESP_OPENCLAW_NODE_DISCONNECTED_REASON_CONNECTION_LOST` + +Event callback rules: + +- callbacks run on the component task +- keep callback code short and non-blocking +- callbacks may call the async request APIs +- callbacks must not call `esp_openclaw_node_destroy()` + +### Stored State + +The component stores internal state in NVS namespace `openclaw`. + +Identity: + +- `device_seed`: 32-byte Ed25519 seed + +Saved reconnect session: + +- `session_v` +- `session_uri` +- `session_dev_tok` + +Derived at runtime from `device_seed`: + +- `device_id = hex(sha256(public_key))` +- `public_key` +- `private_key` + +Persistence rules: + +- setup-code bootstrap tokens are never persisted +- explicit shared gateway tokens are never persisted +- explicit gateway passwords are never persisted +- explicit no-auth selections are never persisted +- only the final `{ gateway_uri, device_token }` reconnect session is persisted + +## Reference + +
+Example Wire Messages + +Example `connect.challenge` from the gateway: + +```json +{ + "type": "event", + "event": "connect.challenge", + "payload": { + "nonce": "M2QxYjBiNDItYzJlZS00YzA3LWFkMWMtMmE4NGJmZTg4M2E5", + "ts": 1774830385123 + } +} +``` + +Example `connect` from the node: + +```json +{ + "type": "req", + "id": "connect-1774830385140123", + "method": "connect", + "params": { + "minProtocol": 3, + "maxProtocol": 3, + "client": { + "id": "node-host", + "displayName": "OpenClaw ESP32", + "version": "1.0.0", + "platform": "esp32", + "deviceFamily": "ESP32", + "modelIdentifier": "esp32c6", + "mode": "node" + }, + "role": "node", + "scopes": [], + "caps": ["device", "wifi", "gpio"], + "commands": [ + "device.info", + "device.status", + "wifi.status", + "gpio.mode", + "gpio.read", + "gpio.write" + ], + "auth": { + "deviceToken": "" + }, + "userAgent": "esp-openclaw-node/1.0.0", + "locale": "en-US", + "device": { + "id": "", + "publicKey": "", + "signature": "", + "signedAt": 1774830385123, + "nonce": "M2QxYjBiNDItYzJlZS00YzA3LWFkMWMtMmE4NGJmZTg4M2E5" + } + } +} +``` + +`params.auth` variants by connect source: + +```json +{ "bootstrapToken": "" } +``` + +```json +{ "token": "" } +``` + +```json +{ "password": "" } +``` + +For explicit no-auth, the component omits the `auth` object entirely. + +Successful response: + +```json +{ + "type": "res", + "id": "connect-1774830385140123", + "ok": true, + "payload": { + "type": "hello-ok", + "auth": { + "deviceToken": "" + } + } +} +``` + +Example `node.invoke.request` from the gateway: + +```json +{ + "type": "event", + "event": "node.invoke.request", + "payload": { + "id": "inv_01JV0A7X9S1ZQY7V5NXJYQ5V8K", + "nodeId": "", + "command": "display.show", + "paramsJSON": "{\"heading\":\"OpenClaw\",\"text\":\"Hello from the gateway.\"}" + } +} +``` + +Successful `node.invoke.result` from the node: + +```json +{ + "type": "req", + "id": "esp32-1774830401987123", + "method": "node.invoke.result", + "params": { + "id": "inv_01JV0A7X9S1ZQY7V5NXJYQ5V8K", + "nodeId": "", + "ok": true, + "payloadJSON": "{\"heading\":\"OpenClaw\",\"text\":\"Hello from the gateway.\",\"renderCount\":1}" + } +} +``` + +Error `node.invoke.result` from the node: + +```json +{ + "type": "req", + "id": "esp32-1774830402987001", + "method": "node.invoke.result", + "params": { + "id": "inv_01JV0AA4J2QJ4V4X1R7W43G6CE", + "nodeId": "", + "ok": false, + "error": { + "code": "INVALID_PARAMS", + "message": "display params must include string heading and text fields" + } + } +} +``` + +
+ +### TLS + +The component supports both `ws://` and `wss://`. + +For `wss://`, one of these trust paths must be configured: + +- set `tls_cert_pem` to a PEM trust anchor, or +- leave `use_cert_bundle = true` and build with +`CONFIG_MBEDTLS_CERTIFICATE_BUNDLE` + +If neither is true, `wss://` connect requests are rejected before transport +startup. + +Useful fields in `esp_openclaw_node_config_t`: + +- `tls_cert_pem` +- `tls_cert_len` +- `use_cert_bundle` +- `tls_common_name` +- `skip_cert_common_name_check` + +`skip_cert_common_name_check` is available for local development, but should +stay disabled for production-like deployments. + +### Examples and Reconnect Policy + +The component itself does not implement automatic reconnect policy. + +Applications can provide that behavior outside the component. A reconnect helper typically: + +- waits for Wi-Fi to be online +- checks `esp_openclaw_node_has_saved_session()` +- retries only the saved-session path +- retries after retryable `CONNECT_FAILED` and `DISCONNECTED` outcomes + +The application keeps network policy and retry policy, while the component focuses on node identity, protocol, and one attempt +at a time. + +### Component Tests + +Component tests live under +[components/esp-openclaw-node/test_apps](./test_apps/README.md). diff --git a/components/esp-openclaw-node/idf_component.yml b/components/esp-openclaw-node/idf_component.yml new file mode 100644 index 0000000..42dda17 --- /dev/null +++ b/components/esp-openclaw-node/idf_component.yml @@ -0,0 +1,15 @@ +version: "1.0.0" +description: "ESP-IDF component for running OpenClaw Nodes on ESP32 devices." +url: https://github.com/espressif/esp-openclaw-node +repository: https://github.com/espressif/esp-openclaw-node.git +repository_info: + path: "components/esp-openclaw-node" + +dependencies: + idf: ">=5.0" + espressif/cjson: + version: "1.7.19~2" + espressif/esp_websocket_client: + version: "1.6.1" + espressif/libsodium: + version: "1.0.20~4" diff --git a/components/esp-openclaw-node/include/esp_openclaw_node.h b/components/esp-openclaw-node/include/esp_openclaw_node.h new file mode 100644 index 0000000..584a248 --- /dev/null +++ b/components/esp-openclaw-node/include/esp_openclaw_node.h @@ -0,0 +1,342 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* Limits */ + +/** @brief Maximum number of capabilities a node can advertise. */ +#define ESP_OPENCLAW_NODE_MAX_CAPABILITIES CONFIG_ESP_OPENCLAW_NODE_MAX_CAPABILITIES +/** @brief Maximum number of commands a node can register. */ +#define ESP_OPENCLAW_NODE_MAX_COMMANDS CONFIG_ESP_OPENCLAW_NODE_MAX_COMMANDS + +/* Core Types */ + +/** @brief Opaque handle for an OpenClaw Node instance. */ +typedef struct esp_openclaw_node *esp_openclaw_node_handle_t; + +/* Event Types */ + +/** @brief Terminal and maintenance events emitted by the component. */ +typedef enum { + ESP_OPENCLAW_NODE_EVENT_CONNECTED = 0, /**< Handshake completed and the session is ready. */ + ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED, /**< A connect attempt ended without reaching ready state. */ + ESP_OPENCLAW_NODE_EVENT_DISCONNECTED, /**< An established session disconnected or was closed locally. */ +} esp_openclaw_node_event_t; + +/** @brief Failure reasons surfaced on @ref ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED. */ +typedef enum { + ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILED = 0, /**< Transport startup or local connect setup failed. */ + ESP_OPENCLAW_NODE_CONNECT_FAILURE_CONNECTION_LOST, /**< The transport dropped before connect completed. */ + ESP_OPENCLAW_NODE_CONNECT_FAILURE_AUTH_REJECTED, /**< The gateway rejected the auth material or signature. */ + ESP_OPENCLAW_NODE_CONNECT_FAILURE_SESSION_FINALIZATION_FAILED, /**< `hello-ok` handling failed after initial auth acceptance. */ +} esp_openclaw_node_connect_failure_reason_t; + +/** @brief Payload for @ref ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED. */ +typedef struct { + esp_openclaw_node_connect_failure_reason_t reason; /**< High-level failure category. */ + esp_err_t local_err; /**< Error code associated with the failure, or ESP_OK if none applies. */ + const char *gateway_detail_code; /**< Optional gateway detail code such as `AUTH_*`. */ +} esp_openclaw_node_connect_failed_event_t; + +/** @brief Disconnect reasons surfaced on @ref ESP_OPENCLAW_NODE_EVENT_DISCONNECTED. */ +typedef enum { + ESP_OPENCLAW_NODE_DISCONNECTED_REASON_REQUESTED = 0, /**< Disconnect was requested locally. */ + ESP_OPENCLAW_NODE_DISCONNECTED_REASON_CONNECTION_LOST, /**< Disconnect followed a transport loss or remote close. */ +} esp_openclaw_node_disconnected_reason_t; + +/** @brief Payload for @ref ESP_OPENCLAW_NODE_EVENT_DISCONNECTED. */ +typedef struct { + esp_openclaw_node_disconnected_reason_t reason; /**< High-level disconnect category. */ + esp_err_t local_err; /**< Error code associated with the disconnect, or ESP_OK if none applies. */ +} esp_openclaw_node_disconnected_event_t; + +/** + * @brief Node event callback. + * + * Event callbacks run on the component task. Callback code must stay short and + * non-blocking. It may call the async `esp_openclaw_node_request_*()` APIs. It + * must not call @ref esp_openclaw_node_destroy. + * + * For each accepted connect request, wait for exactly one terminal outcome: + * - @ref ESP_OPENCLAW_NODE_EVENT_CONNECTED + * - @ref ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED + * - @ref ESP_OPENCLAW_NODE_EVENT_DISCONNECTED after an accepted disconnect of an + * active session + * + * `event_data` is: + * - `NULL` for @ref ESP_OPENCLAW_NODE_EVENT_CONNECTED + * - @ref esp_openclaw_node_connect_failed_event_t for + * @ref ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED + * - @ref esp_openclaw_node_disconnected_event_t for + * @ref ESP_OPENCLAW_NODE_EVENT_DISCONNECTED + * + * The payload pointer is valid only for the duration of the callback. + */ +typedef void (*esp_openclaw_node_event_cb_t)( + esp_openclaw_node_handle_t node, + esp_openclaw_node_event_t event, + const void *event_data, + void *user_ctx); + +/* Configuration */ + +/** + * @brief Node configuration. + * + * Before creating or connecting the node, the application must initialize the + * ESP-IDF runtime pieces that networking depends on: + * - `nvs_flash_init()` + * - `esp_netif_init()` + * - `esp_event_loop_create_default()` + * + * The application owns network setup. This component does not bring up Wi-Fi, + * Ethernet, PPP, or any other network path; it opens the OpenClaw WebSocket + * session once a route to the configured gateway exists. + */ +typedef struct { + const char *display_name; /**< Human-readable name advertised to the gateway. */ + const char *platform; /**< Lower-level platform string included in auth and identity metadata. */ + const char *device_family; /**< Higher-level device family string included in auth metadata. */ + const char *client_id; /**< Protocol client identifier sent during connect. */ + const char *client_mode; /**< Protocol client mode, typically `node` for these examples. */ + const char *role; /**< Protocol role string expected by the gateway for this client. */ + const char *model_identifier; /**< Optional model string such as `esp32c6` or `esp32s3`. */ + const char *locale; /**< Optional locale metadata advertised to the gateway. */ + const char *tls_common_name; /**< Optional TLS common-name override for certificate checks. */ + const char *tls_cert_pem; /**< Optional PEM trust anchor for `wss://` connections. */ + size_t tls_cert_len; /**< Length of @p tls_cert_pem in bytes, or `0` for NUL-terminated PEM. */ + bool use_cert_bundle; /**< Use the ESP-IDF certificate bundle for server validation. */ + bool skip_cert_common_name_check; /**< Skip common-name validation for TLS server certificates. */ + esp_openclaw_node_event_cb_t event_cb; /**< Optional event callback invoked on the component task. */ + void *event_user_ctx; /**< Opaque caller context passed back to @p event_cb. */ +} esp_openclaw_node_config_t; + +/* Command Types */ + +/** @brief Structured command error returned to the gateway. */ +typedef struct { + const char *code; /**< Stable machine-readable error code returned to the gateway. */ + const char *message; /**< Human-readable error message returned to the gateway. */ +} esp_openclaw_node_error_t; + +/** + * @brief Handler for a single advertised OpenClaw command. + * + * @param node Node instance handling the request. + * @param context Optional user context from the command registration. + * @param params_json UTF-8 JSON parameters from the gateway request. When the + * request omits `paramsJSON`, the component passes `"{}"`. + * @param params_len Length of @p params_json in bytes, excluding the trailing + * `NUL`. + * @param[out] out_payload_json Optional UTF-8 JSON payload to send back to the + * gateway. When non-`NULL`, the handler transfers ownership of a + * `malloc()`-compatible buffer to the component. + * @param[out] out_error Structured command error when the handler fails. + * + * @return + * - `ESP_OK` on success + * - another ESP-IDF error code on failure + */ +typedef esp_err_t (*esp_openclaw_node_command_handler_t)( + esp_openclaw_node_handle_t node, + void *context, + const char *params_json, + size_t params_len, + char **out_payload_json, + esp_openclaw_node_error_t *out_error); + +/** @brief Command registration entry passed to esp_openclaw_node_register_command(). */ +typedef struct { + const char *name; /**< Command name advertised to the gateway, for example `wifi.status`. */ + esp_openclaw_node_command_handler_t handler; /**< Command callback invoked for matching requests. */ + void *context; /**< Opaque caller context passed to @p handler. */ +} esp_openclaw_node_command_t; + +/* Connect Input Types */ + +/** @brief Caller-chosen source for one connect attempt. */ +typedef enum { + ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION = 0, /**< Reconnect with the persisted `{ gateway_uri, device_token }` session. */ + ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE, /**< Decode and use a setup code that contains the gateway URI and exactly one auth secret. */ + ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_TOKEN, /**< Connect with an explicit shared gateway token. */ + ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_PASSWORD, /**< Connect with an explicit shared gateway password. */ + ESP_OPENCLAW_NODE_CONNECT_SOURCE_NO_AUTH, /**< Connect to a gateway that intentionally allows unauthenticated node access. */ +} esp_openclaw_node_connect_source_t; + +/** + * @brief Connect request submitted to @ref esp_openclaw_node_request_connect(). + * + * Field requirements by source: + * - `SAVED_SESSION`: `gateway_uri = NULL`, `value = NULL` + * - `SETUP_CODE`: `gateway_uri = NULL`, `value = ` + * - `GATEWAY_TOKEN`: `gateway_uri = `, `value = ` + * - `GATEWAY_PASSWORD`: `gateway_uri = `, `value = ` + * - `NO_AUTH`: `gateway_uri = `, `value = NULL` + */ +typedef struct { + esp_openclaw_node_connect_source_t source; /**< Caller-chosen source for this one connect attempt. */ + const char *gateway_uri; /**< Explicit `ws://` or `wss://` URI when required by @p source. */ + const char *value; /**< Setup code, token, or password when required by @p source. */ +} esp_openclaw_node_connect_request_t; + +/* Lifecycle APIs */ + +/** + * @brief Populate a config struct with the component's built-in defaults. + * + * @param[out] config Configuration struct to initialize. + */ +void esp_openclaw_node_config_init_default(esp_openclaw_node_config_t *config); + +/** + * @brief Create a node instance from the supplied configuration. + * + * This also creates the component queues and task used to serialize transport + * and session state transitions. + * + * @param[in] config Configuration to copy into the node instance. + * @param[out] out_node Created node handle. + * + * @return + * - `ESP_OK` on success + * - `ESP_ERR_INVALID_ARG` if `config` or `out_node` is `NULL` + * - `ESP_ERR_NO_MEM` if allocation of the node, queues, task, mutex, or + * copied config strings fails + * - another initialization error if identity setup or saved-session + * loading fails + */ +esp_err_t esp_openclaw_node_create( + const esp_openclaw_node_config_t *config, + esp_openclaw_node_handle_t *out_node); + +/** + * @brief Destroy a node instance and release all owned resources. + * + * This API is synchronous. It rejects self-destroy from the component task or + * callback context. + * + * @param[in] node Node handle to destroy. + * + * @return + * - `ESP_OK` on success + * - `ESP_ERR_INVALID_ARG` if `node` is `NULL` + * - `ESP_ERR_INVALID_STATE` if destroy has already begun or the current + * task is the component task + */ +esp_err_t esp_openclaw_node_destroy(esp_openclaw_node_handle_t node); + +/* Registration APIs */ + +/** + * @brief Advertise a capability string such as `device` or `wifi`. + * + * @param[in] node Node handle. + * @param[in] capability Capability name to register. + * + * @return + * - `ESP_OK` on success + * - `ESP_ERR_INVALID_ARG` if `node` or `capability` is invalid + * - `ESP_ERR_INVALID_STATE` if the node is not idle + * - `ESP_ERR_NO_MEM` if the registry is full or the capability copy + * cannot be allocated + */ +esp_err_t esp_openclaw_node_register_capability( + esp_openclaw_node_handle_t node, + const char *capability); + +/** + * @brief Register a handler for one OpenClaw command. + * + * @param[in] node Node handle. + * @param[in] command Command registration entry. + * + * @return + * - `ESP_OK` on success + * - `ESP_ERR_INVALID_ARG` if `node` or `command` is invalid + * - `ESP_ERR_INVALID_STATE` if the node is not idle + * - `ESP_ERR_NO_MEM` if the registry is full or the command name copy + * cannot be allocated + */ +esp_err_t esp_openclaw_node_register_command( + esp_openclaw_node_handle_t node, + const esp_openclaw_node_command_t *command); + +/* Async Request APIs */ + +/** + * @brief Request one connect attempt using an explicit caller-chosen source. + * + * The request source remains explicit at the API boundary: + * - saved reconnect session + * - setup code + * - gateway token + * - gateway password + * - no-auth + * + * @param[in] node Node handle. + * @param[in] request Connect request to submit. + * + * @return + * - `ESP_OK` if the request was accepted into the component queue + * - `ESP_ERR_INVALID_ARG` if `node` or `request` is invalid + * - `ESP_ERR_INVALID_STATE` if the node is not idle, the saved reconnect + * session is missing for `SAVED_SESSION`, or destroy has begun + * - `ESP_ERR_NO_MEM` if the request could not be copied or queued + * - `ESP_FAIL` on an unexpected local submission failure + */ +esp_err_t esp_openclaw_node_request_connect( + esp_openclaw_node_handle_t node, + const esp_openclaw_node_connect_request_t *request); + +/** + * @brief Request disconnect of the active session. + * + * @param[in] node Node handle. + * + * @return + * - `ESP_OK` if the request was accepted into the component queue + * - `ESP_ERR_INVALID_ARG` if `node` is `NULL` + * - `ESP_ERR_INVALID_STATE` if no active session is present or destroy has + * begun + * - `ESP_ERR_NO_MEM` if the request could not be queued + * - `ESP_FAIL` on an unexpected local submission failure + */ +esp_err_t esp_openclaw_node_request_disconnect(esp_openclaw_node_handle_t node); + +/* Inspection APIs */ + +/** + * @brief Return the stable device identifier for this node instance. + * + * @param[in] node Node handle. + * + * @return Device identifier string, or `NULL` if @p node is `NULL`. + */ +const char *esp_openclaw_node_get_device_id(esp_openclaw_node_handle_t node); + +/** + * @brief Query whether a saved reconnect session is currently available. + * + * @param[in] node Node handle. + * + * @return `true` when a saved reconnect session is present. + */ +bool esp_openclaw_node_has_saved_session(esp_openclaw_node_handle_t node); + +#ifdef __cplusplus +} +#endif diff --git a/components/esp-openclaw-node/private_include/esp_openclaw_node_identity.h b/components/esp-openclaw-node/private_include/esp_openclaw_node_identity.h new file mode 100644 index 0000000..930dbcc --- /dev/null +++ b/components/esp-openclaw-node/private_include/esp_openclaw_node_identity.h @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include +#include + +#include "esp_err.h" + +#define ESP_OPENCLAW_NODE_ED25519_SEED_LEN 32 +#define ESP_OPENCLAW_NODE_ED25519_PUBLIC_KEY_LEN 32 +#define ESP_OPENCLAW_NODE_ED25519_PRIVATE_KEY_LEN 64 +#define ESP_OPENCLAW_NODE_DEVICE_ID_HEX_LEN 64 +#define ESP_OPENCLAW_NODE_PUBLIC_KEY_B64URL_LEN 43 +#define ESP_OPENCLAW_NODE_SIGNATURE_B64URL_LEN 86 +#define ESP_OPENCLAW_NODE_PUBLIC_KEY_B64_ENCODED_LEN 44 +#define ESP_OPENCLAW_NODE_SIGNATURE_B64_ENCODED_LEN 88 +#define ESP_OPENCLAW_NODE_PUBLIC_KEY_B64_BUFFER_LEN (ESP_OPENCLAW_NODE_PUBLIC_KEY_B64_ENCODED_LEN + 1) +#define ESP_OPENCLAW_NODE_SIGNATURE_B64_BUFFER_LEN (ESP_OPENCLAW_NODE_SIGNATURE_B64_ENCODED_LEN + 1) + +/** @brief Persisted and derived device identity state. */ +typedef struct { + uint8_t seed[ESP_OPENCLAW_NODE_ED25519_SEED_LEN]; /**< Ed25519 seed stored in NVS. */ + uint8_t public_key[ESP_OPENCLAW_NODE_ED25519_PUBLIC_KEY_LEN]; /**< Derived public key bytes. */ + uint8_t private_key[ESP_OPENCLAW_NODE_ED25519_PRIVATE_KEY_LEN]; /**< Derived private key bytes. */ + char device_id[ESP_OPENCLAW_NODE_DEVICE_ID_HEX_LEN + 1]; /**< Stable hex device identifier. */ + char public_key_b64url[ESP_OPENCLAW_NODE_PUBLIC_KEY_B64_BUFFER_LEN]; /**< Base64url-encoded public key. */ +} esp_openclaw_node_identity_t; + +/** + * @brief Load the node identity from NVS or create and persist a new one. + * + * @param[out] identity Identity struct to populate. + * + * @return + * - `ESP_OK` on success + * - an error code if loading or generation fails + */ +esp_err_t esp_openclaw_node_identity_load_or_create(esp_openclaw_node_identity_t *identity); + +/** + * @brief Persist a caller-provided Ed25519 seed when no identity exists yet. + * + * This helper provisions the seed used by @ref esp_openclaw_node_identity_load_or_create + * before the first node identity is created. It never overwrites an existing + * stored seed. + * + * @param[in] seed Seed bytes to persist. + * @param[in] seed_len Length of @p seed in bytes. Must equal + * @ref ESP_OPENCLAW_NODE_ED25519_SEED_LEN. + * + * @return + * - `ESP_OK` on success + * - `ESP_ERR_INVALID_ARG` if the seed is missing or the wrong length + * - `ESP_ERR_INVALID_STATE` if a seed is already provisioned + * - another error code if persistence fails + */ +esp_err_t esp_openclaw_node_identity_store_seed_if_absent(const uint8_t *seed, size_t seed_len); + +/** + * @brief Release dynamically allocated identity fields. + * + * @param[in] identity Identity struct to clean up. + */ +void esp_openclaw_node_identity_free(esp_openclaw_node_identity_t *identity); + +/** + * @brief Sign the canonical device-auth payload and return it as base64url text. + * + * @param[in] identity Identity state with the private key. + * @param[in] payload Canonical auth payload to sign. + * @param[out] signature_b64url Output buffer for the base64url signature. + * @param[in] signature_b64url_size Size of @p signature_b64url in bytes. + * + * @return + * - `ESP_OK` on success + * - an error code if signing or encoding fails + */ +esp_err_t esp_openclaw_node_identity_sign_payload( + const esp_openclaw_node_identity_t *identity, + const char *payload, + char *signature_b64url, + size_t signature_b64url_size); + +/** + * @brief Build the protocol v3 device-auth payload string prior to signing. + * + * @param[in] identity Identity state. + * @param[in] client_id Client identifier. + * @param[in] client_mode Client mode string. + * @param[in] role Gateway role string. + * @param[in] scopes_csv Comma-separated scopes string. + * @param[in] signed_at_ms Millisecond timestamp for the signature. + * @param[in] token Token value included in the signed payload when applicable. + * @param[in] nonce Gateway challenge nonce. + * @param[in] platform Platform metadata. + * @param[in] device_family Device-family metadata. + * @param[out] out_payload Allocated payload string to sign. + * + * @return + * - `ESP_OK` on success + * - an error code if payload construction fails + */ +esp_err_t esp_openclaw_node_identity_build_auth_payload_v3( + const esp_openclaw_node_identity_t *identity, + const char *client_id, + const char *client_mode, + const char *role, + const char *scopes_csv, + int64_t signed_at_ms, + const char *token, + const char *nonce, + const char *platform, + const char *device_family, + char **out_payload); diff --git a/components/esp-openclaw-node/private_include/esp_openclaw_node_internal.h b/components/esp-openclaw-node/private_include/esp_openclaw_node_internal.h new file mode 100644 index 0000000..62bd7ac --- /dev/null +++ b/components/esp-openclaw-node/private_include/esp_openclaw_node_internal.h @@ -0,0 +1,293 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include +#include +#include + +#include "cJSON.h" +#include "esp_err.h" +#include "esp_event.h" +#include "esp_websocket_client.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "esp_openclaw_node_identity.h" +#include "esp_openclaw_node.h" +#include "esp_openclaw_node_persisted_session.h" + +#define ESP_OPENCLAW_NODE_TAG "esp_openclaw_node" +#define ESP_OPENCLAW_NODE_CONNECT_TIMEOUT_MS 12000LL +#define ESP_OPENCLAW_NODE_WS_PING_INTERVAL_SEC 5 +#define ESP_OPENCLAW_NODE_WS_PINGPONG_TIMEOUT_SEC 10 +#define ESP_OPENCLAW_NODE_TASK_POLL_TICKS pdMS_TO_TICKS(250) +#define ESP_OPENCLAW_NODE_WORK_QUEUE_LENGTH CONFIG_ESP_OPENCLAW_NODE_WORK_QUEUE_LENGTH +#define ESP_OPENCLAW_NODE_TASK_STACK_SIZE CONFIG_ESP_OPENCLAW_NODE_TASK_STACK_SIZE +#define ESP_OPENCLAW_NODE_TRANSPORT_TASK_STACK_SIZE \ + CONFIG_ESP_OPENCLAW_NODE_TRANSPORT_TASK_STACK_SIZE +#define ESP_OPENCLAW_NODE_TRANSPORT_BUFFER_SIZE \ + CONFIG_ESP_OPENCLAW_NODE_TRANSPORT_BUFFER_SIZE + +typedef enum { + ESP_OPENCLAW_NODE_INTERNAL_IDLE = 0, + ESP_OPENCLAW_NODE_INTERNAL_CONNECTING, + ESP_OPENCLAW_NODE_INTERNAL_READY, + ESP_OPENCLAW_NODE_INTERNAL_DESTROYING, + ESP_OPENCLAW_NODE_INTERNAL_CLOSED, +} esp_openclaw_node_internal_state_t; + +typedef enum { + ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_NONE = 0, + ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_CONNECT, + ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_DISCONNECT, +} esp_openclaw_node_pending_control_request_t; + +typedef enum { + ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NONE = 0, + ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SAVED_SESSION, + ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_BOOTSTRAP_TOKEN, + ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SHARED_TOKEN, + ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_PASSWORD, + ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NO_AUTH, +} esp_openclaw_node_connect_source_kind_t; + +typedef struct { + esp_openclaw_node_connect_source_kind_t kind; + char *gateway_uri; + char *secret; +} esp_openclaw_node_connect_request_source_t; + +typedef struct { + esp_openclaw_node_connect_source_kind_t kind; + char *auth_value; + const char *signature_token; +} esp_openclaw_node_connect_material_t; + +typedef enum { + ESP_OPENCLAW_NODE_WORK_MSG_REQUEST_CONNECT = 0, + ESP_OPENCLAW_NODE_WORK_MSG_REQUEST_DISCONNECT, + ESP_OPENCLAW_NODE_WORK_MSG_WS_CONNECTED, + ESP_OPENCLAW_NODE_WORK_MSG_WS_DISCONNECTED, + ESP_OPENCLAW_NODE_WORK_MSG_WS_ERROR, + ESP_OPENCLAW_NODE_WORK_MSG_DATA, + ESP_OPENCLAW_NODE_WORK_MSG_SHUTDOWN, +} esp_openclaw_node_work_message_type_t; + +typedef struct { + esp_openclaw_node_work_message_type_t type; + uint32_t generation; + esp_err_t local_err; + char *text; + esp_openclaw_node_connect_request_source_t connect_source; +} esp_openclaw_node_work_message_t; + +typedef struct { + char *name; + esp_openclaw_node_command_handler_t handler; + void *context; +} esp_openclaw_node_registered_command_t; + +typedef struct esp_openclaw_node_transport_event_ctx { + struct esp_openclaw_node *node; + uint32_t generation; +} esp_openclaw_node_transport_event_ctx_t; + +typedef struct { + esp_websocket_client_handle_t (*client_init)(const esp_websocket_client_config_t *config); + esp_err_t (*register_events)( + esp_websocket_client_handle_t client, + esp_websocket_event_id_t event, + esp_event_handler_t event_handler, + void *event_handler_arg); + esp_err_t (*client_start)(esp_websocket_client_handle_t client); + esp_err_t (*client_stop)(esp_websocket_client_handle_t client); + esp_err_t (*client_destroy)(esp_websocket_client_handle_t client); + int (*send_text)( + esp_websocket_client_handle_t client, + const char *data, + int len, + TickType_t timeout); + int (*send_with_opcode)( + esp_websocket_client_handle_t client, + ws_transport_opcodes_t opcode, + const uint8_t *data, + int len, + TickType_t timeout); +} esp_openclaw_node_transport_ops_t; + +typedef enum { + CONNECT_RESPONSE_OUTCOME_IGNORE = 0, + CONNECT_RESPONSE_OUTCOME_CONNECTED, + CONNECT_RESPONSE_OUTCOME_CONNECT_FAILED, +} connect_response_outcome_t; + +typedef struct { + connect_response_outcome_t outcome; + esp_err_t err; + esp_openclaw_node_internal_state_t state; +} connect_response_finalize_result_t; + +struct esp_openclaw_node { + QueueHandle_t work_queue; + TaskHandle_t task_handle; + SemaphoreHandle_t destroy_done; + SemaphoreHandle_t state_lock; + const esp_openclaw_node_transport_ops_t *transport_ops; + esp_openclaw_node_identity_t identity; + esp_openclaw_node_persisted_session_t persisted_session; + esp_openclaw_node_config_t config; + esp_openclaw_node_internal_state_t state; + esp_openclaw_node_pending_control_request_t pending_control; + esp_websocket_client_handle_t ws; + esp_openclaw_node_transport_event_ctx_t *transport_ctx; + uint32_t next_transport_generation; + uint32_t active_transport_generation; + bool transport_connected; + bool ws_started; + char pending_connect_id[32]; + int64_t connect_started_ms; + char *transport_gateway_uri; + char *rx_buffer; + size_t rx_buffer_len; + esp_openclaw_node_connect_request_source_t active_connect_source; + size_t capability_count; + char *capabilities[ESP_OPENCLAW_NODE_MAX_CAPABILITIES]; + size_t command_count; + esp_openclaw_node_registered_command_t commands[ESP_OPENCLAW_NODE_MAX_COMMANDS]; +}; + +extern const esp_openclaw_node_transport_ops_t esp_openclaw_node_default_transport_ops; + +__attribute__((weak)) const esp_openclaw_node_transport_ops_t *esp_openclaw_node_test_transport_ops(void); + +const char *esp_openclaw_node_firmware_version(void); +char *esp_openclaw_node_duplicate_string(const char *value); +const char *esp_openclaw_node_trimmed_or_null(const char *value); +bool esp_openclaw_node_is_valid_gateway_uri(const char *gateway_uri); + +void esp_openclaw_node_lock_state(esp_openclaw_node_handle_t node); +void esp_openclaw_node_unlock_state(esp_openclaw_node_handle_t node); + +esp_err_t esp_openclaw_node_copy_config( + const esp_openclaw_node_config_t *src, + esp_openclaw_node_config_t *dst); +void esp_openclaw_node_free_config_strings(esp_openclaw_node_config_t *config); + +void esp_openclaw_node_clear_session_wait_state_locked(esp_openclaw_node_handle_t node); +void esp_openclaw_node_set_pending_control_locked( + esp_openclaw_node_handle_t node, + esp_openclaw_node_pending_control_request_t kind); +void esp_openclaw_node_clear_pending_control_locked(esp_openclaw_node_handle_t node); +bool esp_openclaw_node_saved_session_is_present_locked( + const esp_openclaw_node_handle_t node); +bool esp_openclaw_node_state_is_connecting(esp_openclaw_node_internal_state_t state); +const char *esp_openclaw_node_internal_state_name( + esp_openclaw_node_internal_state_t state); +void esp_openclaw_node_clear_data_buffer_locked(esp_openclaw_node_handle_t node); + +void esp_openclaw_node_free_work_message_payload(esp_openclaw_node_work_message_t *message); +void esp_openclaw_node_drain_work_queue(esp_openclaw_node_handle_t node); + +void esp_openclaw_node_emit_connected(esp_openclaw_node_handle_t node); +void esp_openclaw_node_emit_connect_failed( + esp_openclaw_node_handle_t node, + esp_openclaw_node_connect_failure_reason_t reason, + esp_err_t local_err, + const char *gateway_detail_code); +void esp_openclaw_node_emit_disconnected( + esp_openclaw_node_handle_t node, + esp_openclaw_node_disconnected_reason_t reason, + esp_err_t local_err); + +bool esp_openclaw_node_is_node_task_context(esp_openclaw_node_handle_t node); + +void esp_openclaw_node_clear_connect_source_struct( + esp_openclaw_node_connect_request_source_t *source); +esp_err_t esp_openclaw_node_build_connect_source_from_request( + const esp_openclaw_node_connect_request_t *request, + esp_openclaw_node_connect_request_source_t *out_source); +const char *esp_openclaw_node_connect_source_kind_name( + esp_openclaw_node_connect_source_kind_t kind); +void esp_openclaw_node_free_connect_material(esp_openclaw_node_connect_material_t *material); +esp_err_t esp_openclaw_node_resolve_active_connect_material_locked( + esp_openclaw_node_handle_t node, + esp_openclaw_node_connect_material_t *material); +esp_err_t esp_openclaw_node_reserve_connect_request_locked( + esp_openclaw_node_handle_t node, + const esp_openclaw_node_connect_request_source_t *source); + +void esp_openclaw_node_cleanup_registry(esp_openclaw_node_handle_t node); +esp_openclaw_node_registered_command_t *esp_openclaw_node_find_command( + esp_openclaw_node_handle_t node, + const char *name); +esp_err_t esp_openclaw_node_dispatch_command( + esp_openclaw_node_handle_t node, + const char *command, + const char *params_json, + size_t params_len, + char **out_payload_json, + const char **out_error_code, + const char **out_error_message); +void esp_openclaw_node_add_registered_string_array( + cJSON *parent, + const char *name, + char *const *items, + size_t count); +void esp_openclaw_node_add_registered_command_array( + cJSON *parent, + const char *name, + esp_openclaw_node_handle_t node); +esp_err_t esp_openclaw_node_register_capability_internal( + esp_openclaw_node_handle_t node, + const char *capability); +esp_err_t esp_openclaw_node_register_command_internal( + esp_openclaw_node_handle_t node, + const esp_openclaw_node_command_t *command); + +esp_err_t esp_openclaw_node_validate_tls_preflight( + const esp_openclaw_node_config_t *config, + const char *gateway_uri); +esp_err_t esp_openclaw_node_start_transport_for_active_source( + esp_openclaw_node_handle_t node); +void esp_openclaw_node_cleanup_transport_instance( + esp_openclaw_node_handle_t node, + bool stop_client); +bool esp_openclaw_node_should_accept_callback_generation_locked( + esp_openclaw_node_handle_t node, + uint32_t generation); +void esp_openclaw_node_send_challenge_kick_ping(esp_openclaw_node_handle_t node); + +void esp_openclaw_node_process_gateway_message( + esp_openclaw_node_handle_t node, + const char *text); + +void esp_openclaw_node_complete_connect_failed( + esp_openclaw_node_handle_t node, + esp_openclaw_node_connect_failure_reason_t reason, + esp_err_t local_err, + const char *gateway_detail_code, + bool stop_client); +void esp_openclaw_node_complete_disconnected( + esp_openclaw_node_handle_t node, + esp_openclaw_node_disconnected_reason_t reason, + esp_err_t local_err, + bool stop_client); +void esp_openclaw_node_fail_if_connect_timed_out(esp_openclaw_node_handle_t node); +esp_err_t esp_openclaw_node_enqueue_work_message( + esp_openclaw_node_handle_t node, + esp_openclaw_node_work_message_t *message); +void esp_openclaw_node_enqueue_work_message_from_callback( + esp_openclaw_node_handle_t node, + esp_openclaw_node_work_message_t *message); +void esp_openclaw_node_task(void *arg); +esp_err_t esp_openclaw_node_submit_connect_request( + esp_openclaw_node_handle_t node, + esp_openclaw_node_connect_request_source_t *connect_source); +esp_err_t esp_openclaw_node_submit_disconnect_request( + esp_openclaw_node_handle_t node); diff --git a/components/esp-openclaw-node/private_include/esp_openclaw_node_persisted_session.h b/components/esp-openclaw-node/private_include/esp_openclaw_node_persisted_session.h new file mode 100644 index 0000000..b83847d --- /dev/null +++ b/components/esp-openclaw-node/private_include/esp_openclaw_node_persisted_session.h @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include +#include + +#include "esp_err.h" + +typedef struct { + uint8_t version; + char *gateway_uri; + char *device_token; +} esp_openclaw_node_persisted_session_t; + +esp_err_t esp_openclaw_node_persisted_session_load(esp_openclaw_node_persisted_session_t *session); + +void esp_openclaw_node_persisted_session_free(esp_openclaw_node_persisted_session_t *session); + +esp_err_t esp_openclaw_node_persisted_session_store( + esp_openclaw_node_persisted_session_t *session, + const esp_openclaw_node_persisted_session_t *update); + +bool esp_openclaw_node_persisted_session_is_present(const esp_openclaw_node_persisted_session_t *session); diff --git a/components/esp-openclaw-node/src/esp_openclaw_node.c b/components/esp-openclaw-node/src/esp_openclaw_node.c new file mode 100644 index 0000000..27a6d5b --- /dev/null +++ b/components/esp-openclaw-node/src/esp_openclaw_node.c @@ -0,0 +1,583 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "esp_openclaw_node_internal.h" + +#include +#include + +#include "esp_app_desc.h" +#include "esp_check.h" + +static const char *DEFAULT_PLATFORM = "esp32"; +static const char *DEFAULT_DEVICE_FAMILY = "ESP32"; +static const char *DEFAULT_DISPLAY_NAME = "OpenClaw ESP32"; +static const char *DEFAULT_CLIENT_ID = "node-host"; +static const char *DEFAULT_CLIENT_MODE = "node"; +static const char *DEFAULT_ROLE = "node"; +static const char *DEFAULT_LOCALE = "en-US"; + +const esp_openclaw_node_transport_ops_t esp_openclaw_node_default_transport_ops = { + .client_init = esp_websocket_client_init, + .register_events = esp_websocket_register_events, + .client_start = esp_websocket_client_start, + .client_stop = esp_websocket_client_stop, + .client_destroy = esp_websocket_client_destroy, + .send_text = esp_websocket_client_send_text, + .send_with_opcode = esp_websocket_client_send_with_opcode, +}; + +__attribute__((weak)) const esp_openclaw_node_transport_ops_t *esp_openclaw_node_test_transport_ops(void) +{ + return NULL; +} + +const char *esp_openclaw_node_firmware_version(void) +{ + return esp_app_get_description()->version; +} + +char *esp_openclaw_node_duplicate_string(const char *value) +{ + if (value == NULL) { + return NULL; + } + return strdup(value); +} + +const char *esp_openclaw_node_trimmed_or_null(const char *value) +{ + if (value == NULL) { + return NULL; + } + while (*value == ' ' || *value == '\t' || *value == '\r' || + *value == '\n') { + ++value; + } + return value[0] != '\0' ? value : NULL; +} + +bool esp_openclaw_node_is_valid_gateway_uri(const char *gateway_uri) +{ + const char *trimmed = esp_openclaw_node_trimmed_or_null(gateway_uri); + if (trimmed == NULL) { + return false; + } + return strncmp(trimmed, "ws://", 5) == 0 || + strncmp(trimmed, "wss://", 6) == 0; +} + +void esp_openclaw_node_lock_state(esp_openclaw_node_handle_t node) +{ + if (node != NULL && node->state_lock != NULL) { + xSemaphoreTakeRecursive(node->state_lock, portMAX_DELAY); + } +} + +void esp_openclaw_node_unlock_state(esp_openclaw_node_handle_t node) +{ + if (node != NULL && node->state_lock != NULL) { + xSemaphoreGiveRecursive(node->state_lock); + } +} + +static esp_err_t copy_string_field(char **dst, const char *src, bool required) +{ + if (src == NULL || src[0] == '\0') { + return required ? ESP_ERR_INVALID_ARG : ESP_OK; + } + *dst = esp_openclaw_node_duplicate_string(src); + return *dst != NULL ? ESP_OK : ESP_ERR_NO_MEM; +} + +static esp_err_t copy_tls_cert_field( + esp_openclaw_node_config_t *dst, + const esp_openclaw_node_config_t *src) +{ + if (src->tls_cert_pem == NULL) { + dst->tls_cert_pem = NULL; + dst->tls_cert_len = 0; + return ESP_OK; + } + + size_t cert_len = src->tls_cert_len; + if (cert_len == 0) { + cert_len = strlen(src->tls_cert_pem); + } + + char *cert_copy = calloc(cert_len + 1U, sizeof(char)); + if (cert_copy == NULL) { + return ESP_ERR_NO_MEM; + } + + if (cert_len > 0) { + memcpy(cert_copy, src->tls_cert_pem, cert_len); + } + cert_copy[cert_len] = '\0'; + + dst->tls_cert_pem = cert_copy; + dst->tls_cert_len = src->tls_cert_len; + return ESP_OK; +} + +void esp_openclaw_node_free_config_strings(esp_openclaw_node_config_t *config) +{ + free((char *)config->display_name); + free((char *)config->platform); + free((char *)config->device_family); + free((char *)config->client_id); + free((char *)config->client_mode); + free((char *)config->role); + free((char *)config->model_identifier); + free((char *)config->locale); + free((char *)config->tls_common_name); + free((char *)config->tls_cert_pem); + memset(config, 0, sizeof(*config)); +} + +esp_err_t esp_openclaw_node_copy_config( + const esp_openclaw_node_config_t *src, + esp_openclaw_node_config_t *dst) +{ + const char *model_identifier = + (src->model_identifier != NULL && src->model_identifier[0] != '\0') + ? src->model_identifier + : CONFIG_IDF_TARGET; + const char *locale = + (src->locale != NULL && src->locale[0] != '\0') + ? src->locale + : DEFAULT_LOCALE; + + memset(dst, 0, sizeof(*dst)); + ESP_RETURN_ON_ERROR( + copy_string_field((char **)&dst->display_name, src->display_name, true), + ESP_OPENCLAW_NODE_TAG, + "display_name"); + ESP_RETURN_ON_ERROR( + copy_string_field((char **)&dst->platform, src->platform, true), + ESP_OPENCLAW_NODE_TAG, + "platform"); + ESP_RETURN_ON_ERROR( + copy_string_field( + (char **)&dst->device_family, + src->device_family, + true), + ESP_OPENCLAW_NODE_TAG, + "device_family"); + ESP_RETURN_ON_ERROR( + copy_string_field((char **)&dst->client_id, src->client_id, true), + ESP_OPENCLAW_NODE_TAG, + "client_id"); + ESP_RETURN_ON_ERROR( + copy_string_field( + (char **)&dst->client_mode, + src->client_mode, + true), + ESP_OPENCLAW_NODE_TAG, + "client_mode"); + ESP_RETURN_ON_ERROR( + copy_string_field((char **)&dst->role, src->role, true), + ESP_OPENCLAW_NODE_TAG, + "role"); + ESP_RETURN_ON_ERROR( + copy_string_field( + (char **)&dst->model_identifier, + model_identifier, + false), + ESP_OPENCLAW_NODE_TAG, + "model_identifier"); + ESP_RETURN_ON_ERROR( + copy_string_field((char **)&dst->locale, locale, false), + ESP_OPENCLAW_NODE_TAG, + "locale"); + ESP_RETURN_ON_ERROR( + copy_string_field( + (char **)&dst->tls_common_name, + src->tls_common_name, + false), + ESP_OPENCLAW_NODE_TAG, + "tls_common_name"); + ESP_RETURN_ON_ERROR( + copy_tls_cert_field(dst, src), + ESP_OPENCLAW_NODE_TAG, + "tls_cert_pem"); + dst->use_cert_bundle = src->use_cert_bundle; + dst->skip_cert_common_name_check = src->skip_cert_common_name_check; + dst->event_cb = src->event_cb; + dst->event_user_ctx = src->event_user_ctx; + return ESP_OK; +} + +void esp_openclaw_node_clear_session_wait_state_locked(esp_openclaw_node_handle_t node) +{ + node->pending_connect_id[0] = '\0'; + node->connect_started_ms = 0; +} + +void esp_openclaw_node_set_pending_control_locked( + esp_openclaw_node_handle_t node, + esp_openclaw_node_pending_control_request_t kind) +{ + node->pending_control = kind; +} + +void esp_openclaw_node_clear_pending_control_locked(esp_openclaw_node_handle_t node) +{ + esp_openclaw_node_set_pending_control_locked( + node, + ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_NONE); +} + +bool esp_openclaw_node_saved_session_is_present_locked( + const esp_openclaw_node_handle_t node) +{ + return esp_openclaw_node_persisted_session_is_present(&node->persisted_session); +} + +bool esp_openclaw_node_state_is_connecting(esp_openclaw_node_internal_state_t state) +{ + return state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING; +} + +const char *esp_openclaw_node_internal_state_name( + esp_openclaw_node_internal_state_t state) +{ + switch (state) { + case ESP_OPENCLAW_NODE_INTERNAL_IDLE: + return "idle"; + case ESP_OPENCLAW_NODE_INTERNAL_CONNECTING: + return "connecting"; + case ESP_OPENCLAW_NODE_INTERNAL_READY: + return "ready"; + case ESP_OPENCLAW_NODE_INTERNAL_DESTROYING: + return "destroying"; + case ESP_OPENCLAW_NODE_INTERNAL_CLOSED: + return "closed"; + default: + return "unknown"; + } +} + +void esp_openclaw_node_clear_data_buffer_locked(esp_openclaw_node_handle_t node) +{ + free(node->rx_buffer); + node->rx_buffer = NULL; + node->rx_buffer_len = 0; +} + +void esp_openclaw_node_free_work_message_payload(esp_openclaw_node_work_message_t *message) +{ + if (message == NULL) { + return; + } + free(message->text); + message->text = NULL; + esp_openclaw_node_clear_connect_source_struct(&message->connect_source); +} + +void esp_openclaw_node_drain_work_queue(esp_openclaw_node_handle_t node) +{ + esp_openclaw_node_work_message_t message = {0}; + while (node->work_queue != NULL && + xQueueReceive(node->work_queue, &message, 0) == pdTRUE) { + esp_openclaw_node_free_work_message_payload(&message); + } +} + +static void emit_event( + esp_openclaw_node_handle_t node, + esp_openclaw_node_event_t event, + const void *event_data) +{ + esp_openclaw_node_event_cb_t event_cb = NULL; + void *event_user_ctx = NULL; + + esp_openclaw_node_lock_state(node); + event_cb = node->config.event_cb; + event_user_ctx = node->config.event_user_ctx; + esp_openclaw_node_unlock_state(node); + + if (event_cb != NULL) { + event_cb(node, event, event_data, event_user_ctx); + } +} + +void esp_openclaw_node_emit_connected(esp_openclaw_node_handle_t node) +{ + emit_event(node, ESP_OPENCLAW_NODE_EVENT_CONNECTED, NULL); +} + +void esp_openclaw_node_emit_connect_failed( + esp_openclaw_node_handle_t node, + esp_openclaw_node_connect_failure_reason_t reason, + esp_err_t local_err, + const char *gateway_detail_code) +{ + esp_openclaw_node_connect_failed_event_t event = { + .reason = reason, + .local_err = local_err, + .gateway_detail_code = gateway_detail_code, + }; + emit_event(node, ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED, &event); +} + +void esp_openclaw_node_emit_disconnected( + esp_openclaw_node_handle_t node, + esp_openclaw_node_disconnected_reason_t reason, + esp_err_t local_err) +{ + esp_openclaw_node_disconnected_event_t event = { + .reason = reason, + .local_err = local_err, + }; + emit_event(node, ESP_OPENCLAW_NODE_EVENT_DISCONNECTED, &event); +} + +bool esp_openclaw_node_is_node_task_context(esp_openclaw_node_handle_t node) +{ + return node != NULL && node->task_handle != NULL && + xTaskGetCurrentTaskHandle() == node->task_handle; +} + +void esp_openclaw_node_config_init_default(esp_openclaw_node_config_t *config) +{ + if (config == NULL) { + return; + } + + memset(config, 0, sizeof(*config)); + config->display_name = DEFAULT_DISPLAY_NAME; + config->platform = DEFAULT_PLATFORM; + config->device_family = DEFAULT_DEVICE_FAMILY; + config->client_id = DEFAULT_CLIENT_ID; + config->client_mode = DEFAULT_CLIENT_MODE; + config->role = DEFAULT_ROLE; + config->model_identifier = CONFIG_IDF_TARGET; + config->locale = DEFAULT_LOCALE; + config->use_cert_bundle = true; +} + +esp_err_t esp_openclaw_node_create( + const esp_openclaw_node_config_t *config, + esp_openclaw_node_handle_t *out_node) +{ + if (config == NULL || out_node == NULL) { + return ESP_ERR_INVALID_ARG; + } + + esp_openclaw_node_handle_t node = calloc(1, sizeof(*node)); + if (node == NULL) { + return ESP_ERR_NO_MEM; + } + + node->state_lock = xSemaphoreCreateRecursiveMutex(); + if (node->state_lock == NULL) { + free(node); + return ESP_ERR_NO_MEM; + } + + node->destroy_done = xSemaphoreCreateBinary(); + if (node->destroy_done == NULL) { + vSemaphoreDelete(node->state_lock); + free(node); + return ESP_ERR_NO_MEM; + } + + node->work_queue = xQueueCreate( + ESP_OPENCLAW_NODE_WORK_QUEUE_LENGTH, + sizeof(esp_openclaw_node_work_message_t)); + if (node->work_queue == NULL) { + vSemaphoreDelete(node->destroy_done); + vSemaphoreDelete(node->state_lock); + free(node); + return ESP_ERR_NO_MEM; + } + + esp_err_t err = esp_openclaw_node_copy_config(config, &node->config); + if (err != ESP_OK) { + esp_openclaw_node_free_config_strings(&node->config); + vQueueDelete(node->work_queue); + vSemaphoreDelete(node->destroy_done); + vSemaphoreDelete(node->state_lock); + free(node); + return err; + } + + err = esp_openclaw_node_identity_load_or_create(&node->identity); + if (err != ESP_OK) { + esp_openclaw_node_free_config_strings(&node->config); + vQueueDelete(node->work_queue); + vSemaphoreDelete(node->destroy_done); + vSemaphoreDelete(node->state_lock); + free(node); + return err; + } + + err = esp_openclaw_node_persisted_session_load(&node->persisted_session); + if (err != ESP_OK) { + esp_openclaw_node_identity_free(&node->identity); + esp_openclaw_node_free_config_strings(&node->config); + vQueueDelete(node->work_queue); + vSemaphoreDelete(node->destroy_done); + vSemaphoreDelete(node->state_lock); + free(node); + return err; + } + + const esp_openclaw_node_transport_ops_t *test_ops = + esp_openclaw_node_test_transport_ops(); + node->transport_ops = test_ops != NULL + ? test_ops + : &esp_openclaw_node_default_transport_ops; + node->state = ESP_OPENCLAW_NODE_INTERNAL_IDLE; + + BaseType_t task_ok = xTaskCreate( + esp_openclaw_node_task, + "esp_openclaw_node", + ESP_OPENCLAW_NODE_TASK_STACK_SIZE, + node, + 5, + &node->task_handle); + if (task_ok != pdPASS) { + esp_openclaw_node_persisted_session_free(&node->persisted_session); + esp_openclaw_node_identity_free(&node->identity); + esp_openclaw_node_free_config_strings(&node->config); + vQueueDelete(node->work_queue); + vSemaphoreDelete(node->destroy_done); + vSemaphoreDelete(node->state_lock); + free(node); + return ESP_ERR_NO_MEM; + } + + *out_node = node; + return ESP_OK; +} + +esp_err_t esp_openclaw_node_destroy(esp_openclaw_node_handle_t node) +{ + if (node == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (esp_openclaw_node_is_node_task_context(node)) { + return ESP_ERR_INVALID_STATE; + } + + esp_openclaw_node_lock_state(node); + if (node->state == ESP_OPENCLAW_NODE_INTERNAL_DESTROYING || + node->state == ESP_OPENCLAW_NODE_INTERNAL_CLOSED) { + esp_openclaw_node_unlock_state(node); + return ESP_ERR_INVALID_STATE; + } + SemaphoreHandle_t destroy_done = node->destroy_done; + if (destroy_done == NULL) { + esp_openclaw_node_unlock_state(node); + return ESP_FAIL; + } + node->state = ESP_OPENCLAW_NODE_INTERNAL_DESTROYING; + esp_openclaw_node_unlock_state(node); + + (void)xSemaphoreTake(destroy_done, 0); + + esp_openclaw_node_work_message_t message = { + .type = ESP_OPENCLAW_NODE_WORK_MSG_SHUTDOWN, + }; + if (node->work_queue == NULL || + xQueueSend(node->work_queue, &message, portMAX_DELAY) != pdTRUE) { + esp_openclaw_node_lock_state(node); + node->state = ESP_OPENCLAW_NODE_INTERNAL_IDLE; + esp_openclaw_node_unlock_state(node); + return ESP_FAIL; + } + + if (xSemaphoreTake(destroy_done, portMAX_DELAY) != pdTRUE) { + return ESP_FAIL; + } + + if (node->work_queue != NULL) { + vQueueDelete(node->work_queue); + node->work_queue = NULL; + } + if (node->state_lock != NULL) { + vSemaphoreDelete(node->state_lock); + node->state_lock = NULL; + } + if (node->destroy_done != NULL) { + vSemaphoreDelete(node->destroy_done); + node->destroy_done = NULL; + } + + esp_openclaw_node_cleanup_registry(node); + esp_openclaw_node_clear_connect_source_struct(&node->active_connect_source); + esp_openclaw_node_persisted_session_free(&node->persisted_session); + esp_openclaw_node_identity_free(&node->identity); + esp_openclaw_node_free_config_strings(&node->config); + free(node); + return ESP_OK; +} + +esp_err_t esp_openclaw_node_register_capability( + esp_openclaw_node_handle_t node, + const char *capability) +{ + return esp_openclaw_node_register_capability_internal(node, capability); +} + +esp_err_t esp_openclaw_node_register_command( + esp_openclaw_node_handle_t node, + const esp_openclaw_node_command_t *command) +{ + return esp_openclaw_node_register_command_internal(node, command); +} + +esp_err_t esp_openclaw_node_request_connect( + esp_openclaw_node_handle_t node, + const esp_openclaw_node_connect_request_t *request) +{ + if (node == NULL || request == NULL) { + return ESP_ERR_INVALID_ARG; + } + + esp_openclaw_node_connect_request_source_t connect_source = {0}; + esp_err_t err = + esp_openclaw_node_build_connect_source_from_request( + request, + &connect_source); + if (err != ESP_OK) { + return err; + } + + err = esp_openclaw_node_submit_connect_request(node, &connect_source); + if (err != ESP_OK) { + esp_openclaw_node_clear_connect_source_struct(&connect_source); + return err; + } + return ESP_OK; +} + +esp_err_t esp_openclaw_node_request_disconnect(esp_openclaw_node_handle_t node) +{ + if (node == NULL) { + return ESP_ERR_INVALID_ARG; + } + + return esp_openclaw_node_submit_disconnect_request(node); +} + +const char *esp_openclaw_node_get_device_id(esp_openclaw_node_handle_t node) +{ + return node != NULL ? node->identity.device_id : NULL; +} + +bool esp_openclaw_node_has_saved_session(esp_openclaw_node_handle_t node) +{ + if (node == NULL) { + return false; + } + esp_openclaw_node_lock_state(node); + bool present = esp_openclaw_node_saved_session_is_present_locked(node); + esp_openclaw_node_unlock_state(node); + return present; +} diff --git a/components/esp-openclaw-node/src/esp_openclaw_node_connect_source.c b/components/esp-openclaw-node/src/esp_openclaw_node_connect_source.c new file mode 100644 index 0000000..57bcff6 --- /dev/null +++ b/components/esp-openclaw-node/src/esp_openclaw_node_connect_source.c @@ -0,0 +1,420 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "esp_openclaw_node_internal.h" + +#include +#include + +#include "esp_check.h" +#include "mbedtls/base64.h" + +static bool connect_source_requires_secret(esp_openclaw_node_connect_source_kind_t kind) +{ + return kind == ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_BOOTSTRAP_TOKEN || + kind == ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SHARED_TOKEN || + kind == ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_PASSWORD; +} + +void esp_openclaw_node_clear_connect_source_struct( + esp_openclaw_node_connect_request_source_t *source) +{ + if (source == NULL) { + return; + } + free(source->gateway_uri); + source->gateway_uri = NULL; + free(source->secret); + source->secret = NULL; + source->kind = ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NONE; +} + +static esp_err_t validate_connect_source( + const esp_openclaw_node_connect_request_source_t *source) +{ + if (source == NULL) { + return ESP_ERR_INVALID_ARG; + } + switch (source->kind) { + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SAVED_SESSION: + if (source->gateway_uri != NULL || source->secret != NULL) { + return ESP_ERR_INVALID_ARG; + } + return ESP_OK; + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_BOOTSTRAP_TOKEN: + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SHARED_TOKEN: + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_PASSWORD: + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NO_AUTH: + if (!esp_openclaw_node_is_valid_gateway_uri(source->gateway_uri)) { + return ESP_ERR_INVALID_ARG; + } + if (connect_source_requires_secret(source->kind) && + esp_openclaw_node_trimmed_or_null(source->secret) == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (!connect_source_requires_secret(source->kind) && + esp_openclaw_node_trimmed_or_null(source->secret) != NULL) { + return ESP_ERR_INVALID_ARG; + } + return ESP_OK; + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NONE: + default: + return ESP_ERR_INVALID_ARG; + } +} + +static esp_err_t decode_base64url_payload( + const char *encoded, + char **out_decoded) +{ + if (encoded == NULL || out_decoded == NULL) { + return ESP_ERR_INVALID_ARG; + } + + const char *trimmed = esp_openclaw_node_trimmed_or_null(encoded); + if (trimmed == NULL) { + return ESP_ERR_INVALID_ARG; + } + + size_t encoded_len = strlen(trimmed); + size_t padded_len = ((encoded_len + 3U) / 4U) * 4U; + char *padded = calloc(padded_len + 1U, sizeof(char)); + if (padded == NULL) { + return ESP_ERR_NO_MEM; + } + + for (size_t i = 0; i < encoded_len; ++i) { + if (trimmed[i] == '-') { + padded[i] = '+'; + } else if (trimmed[i] == '_') { + padded[i] = '/'; + } else { + padded[i] = trimmed[i]; + } + } + for (size_t i = encoded_len; i < padded_len; ++i) { + padded[i] = '='; + } + + size_t decoded_capacity = ((padded_len / 4U) * 3U) + 1U; + unsigned char *decoded = calloc(decoded_capacity, sizeof(unsigned char)); + if (decoded == NULL) { + free(padded); + return ESP_ERR_NO_MEM; + } + + size_t written = 0; + int rc = mbedtls_base64_decode( + decoded, + decoded_capacity - 1U, + &written, + (const unsigned char *)padded, + padded_len); + free(padded); + if (rc != 0) { + free(decoded); + return ESP_ERR_INVALID_ARG; + } + + decoded[written] = '\0'; + *out_decoded = (char *)decoded; + return ESP_OK; +} + +static esp_err_t parse_setup_code( + const char *setup_code, + esp_openclaw_node_connect_request_source_t *out_source) +{ + if (setup_code == NULL || out_source == NULL) { + return ESP_ERR_INVALID_ARG; + } + + memset(out_source, 0, sizeof(*out_source)); + + char *decoded_json = NULL; + ESP_RETURN_ON_ERROR( + decode_base64url_payload(setup_code, &decoded_json), + ESP_OPENCLAW_NODE_TAG, + "invalid setup code encoding"); + + cJSON *root = cJSON_Parse(decoded_json); + free(decoded_json); + if (root == NULL) { + return ESP_ERR_INVALID_ARG; + } + + cJSON *url = cJSON_GetObjectItemCaseSensitive(root, "url"); + cJSON *bootstrap_token = + cJSON_GetObjectItemCaseSensitive(root, "bootstrapToken"); + cJSON *shared_token = cJSON_GetObjectItemCaseSensitive(root, "token"); + cJSON *password = cJSON_GetObjectItemCaseSensitive(root, "password"); + + const char *bootstrap_text = cJSON_IsString(bootstrap_token) + ? esp_openclaw_node_trimmed_or_null(bootstrap_token->valuestring) + : NULL; + const char *shared_text = cJSON_IsString(shared_token) + ? esp_openclaw_node_trimmed_or_null(shared_token->valuestring) + : NULL; + const char *password_text = cJSON_IsString(password) + ? esp_openclaw_node_trimmed_or_null(password->valuestring) + : NULL; + + size_t credential_count = 0; + credential_count += bootstrap_text != NULL ? 1U : 0U; + credential_count += shared_text != NULL ? 1U : 0U; + credential_count += password_text != NULL ? 1U : 0U; + + if (!cJSON_IsString(url) || url->valuestring == NULL || + !esp_openclaw_node_is_valid_gateway_uri(url->valuestring) || + credential_count != 1U) { + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + out_source->gateway_uri = + esp_openclaw_node_duplicate_string( + esp_openclaw_node_trimmed_or_null(url->valuestring)); + if (bootstrap_text != NULL) { + out_source->kind = ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_BOOTSTRAP_TOKEN; + out_source->secret = esp_openclaw_node_duplicate_string(bootstrap_text); + } else if (shared_text != NULL) { + out_source->kind = ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SHARED_TOKEN; + out_source->secret = esp_openclaw_node_duplicate_string(shared_text); + } else { + out_source->kind = ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_PASSWORD; + out_source->secret = esp_openclaw_node_duplicate_string(password_text); + } + + cJSON_Delete(root); + if (out_source->gateway_uri == NULL || out_source->secret == NULL) { + esp_openclaw_node_clear_connect_source_struct(out_source); + return ESP_ERR_NO_MEM; + } + + return validate_connect_source(out_source); +} + +static esp_err_t duplicate_explicit_connect_source( + esp_openclaw_node_connect_source_kind_t kind, + const char *gateway_uri, + const char *secret, + esp_openclaw_node_connect_request_source_t *out_source) +{ + if (out_source == NULL) { + return ESP_ERR_INVALID_ARG; + } + + memset(out_source, 0, sizeof(*out_source)); + const char *trimmed_gateway_uri = esp_openclaw_node_trimmed_or_null(gateway_uri); + const char *trimmed_secret = esp_openclaw_node_trimmed_or_null(secret); + if (trimmed_gateway_uri == NULL || + (secret != NULL && trimmed_secret == NULL)) { + return ESP_ERR_INVALID_ARG; + } + + out_source->kind = kind; + out_source->gateway_uri = esp_openclaw_node_duplicate_string(trimmed_gateway_uri); + if (secret != NULL) { + out_source->secret = esp_openclaw_node_duplicate_string(trimmed_secret); + } + if (out_source->gateway_uri == NULL || + (secret != NULL && out_source->secret == NULL)) { + esp_openclaw_node_clear_connect_source_struct(out_source); + return ESP_ERR_NO_MEM; + } + + return validate_connect_source(out_source); +} + +esp_err_t esp_openclaw_node_build_connect_source_from_request( + const esp_openclaw_node_connect_request_t *request, + esp_openclaw_node_connect_request_source_t *out_source) +{ + if (request == NULL || out_source == NULL) { + return ESP_ERR_INVALID_ARG; + } + + switch (request->source) { + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION: + if (request->gateway_uri != NULL || request->value != NULL) { + return ESP_ERR_INVALID_ARG; + } + memset(out_source, 0, sizeof(*out_source)); + out_source->kind = ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SAVED_SESSION; + return ESP_OK; + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE: + if (request->gateway_uri != NULL) { + return ESP_ERR_INVALID_ARG; + } + return parse_setup_code(request->value, out_source); + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_TOKEN: + return duplicate_explicit_connect_source( + ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SHARED_TOKEN, + request->gateway_uri, + request->value, + out_source); + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_PASSWORD: + return duplicate_explicit_connect_source( + ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_PASSWORD, + request->gateway_uri, + request->value, + out_source); + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_NO_AUTH: + if (request->value != NULL) { + return ESP_ERR_INVALID_ARG; + } + return duplicate_explicit_connect_source( + ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NO_AUTH, + request->gateway_uri, + NULL, + out_source); + default: + return ESP_ERR_INVALID_ARG; + } +} + +const char *esp_openclaw_node_connect_source_kind_name( + esp_openclaw_node_connect_source_kind_t kind) +{ + switch (kind) { + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SHARED_TOKEN: + return "shared-token"; + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_PASSWORD: + return "password"; + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_BOOTSTRAP_TOKEN: + return "bootstrap-token"; + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SAVED_SESSION: + return "device-token"; + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NO_AUTH: + return "none"; + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NONE: + default: + return "none"; + } +} + +void esp_openclaw_node_free_connect_material(esp_openclaw_node_connect_material_t *material) +{ + if (material == NULL) { + return; + } + free(material->auth_value); + memset(material, 0, sizeof(*material)); +} + +static esp_err_t validate_saved_session_connect_preflight_locked( + esp_openclaw_node_handle_t node) +{ + if (!esp_openclaw_node_saved_session_is_present_locked(node)) { + return ESP_ERR_INVALID_STATE; + } + return esp_openclaw_node_validate_tls_preflight( + &node->config, + node->persisted_session.gateway_uri); +} + +static esp_err_t validate_explicit_connect_preflight_locked( + esp_openclaw_node_handle_t node, + const esp_openclaw_node_connect_request_source_t *source) +{ + ESP_RETURN_ON_ERROR( + validate_connect_source(source), + ESP_OPENCLAW_NODE_TAG, + "invalid connect source"); + if (source->kind == ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SAVED_SESSION) { + return ESP_ERR_INVALID_ARG; + } + return esp_openclaw_node_validate_tls_preflight( + &node->config, + source->gateway_uri); +} + +esp_err_t esp_openclaw_node_resolve_active_connect_material_locked( + esp_openclaw_node_handle_t node, + esp_openclaw_node_connect_material_t *material) +{ + memset(material, 0, sizeof(*material)); + material->kind = node->active_connect_source.kind; + + switch (node->active_connect_source.kind) { + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_BOOTSTRAP_TOKEN: + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SHARED_TOKEN: + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NO_AUTH: + if (node->active_connect_source.secret != NULL) { + material->auth_value = + esp_openclaw_node_duplicate_string( + esp_openclaw_node_trimmed_or_null( + node->active_connect_source.secret)); + if (material->auth_value == NULL) { + esp_openclaw_node_free_connect_material(material); + return ESP_ERR_NO_MEM; + } + material->signature_token = material->auth_value; + } + return ESP_OK; + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_PASSWORD: + if (node->active_connect_source.secret != NULL) { + material->auth_value = + esp_openclaw_node_duplicate_string( + esp_openclaw_node_trimmed_or_null( + node->active_connect_source.secret)); + if (material->auth_value == NULL) { + esp_openclaw_node_free_connect_material(material); + return ESP_ERR_NO_MEM; + } + } + material->signature_token = NULL; + return ESP_OK; + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SAVED_SESSION: { + const char *device_token = + esp_openclaw_node_trimmed_or_null( + node->persisted_session.device_token); + if (device_token == NULL) { + esp_openclaw_node_free_connect_material(material); + return ESP_ERR_INVALID_STATE; + } + material->auth_value = esp_openclaw_node_duplicate_string(device_token); + if (material->auth_value == NULL) { + esp_openclaw_node_free_connect_material(material); + return ESP_ERR_NO_MEM; + } + material->signature_token = material->auth_value; + return ESP_OK; + } + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NONE: + default: + esp_openclaw_node_free_connect_material(material); + return ESP_ERR_INVALID_STATE; + } +} + +esp_err_t esp_openclaw_node_reserve_connect_request_locked( + esp_openclaw_node_handle_t node, + const esp_openclaw_node_connect_request_source_t *source) +{ + if (node->state == ESP_OPENCLAW_NODE_INTERNAL_DESTROYING || + node->state == ESP_OPENCLAW_NODE_INTERNAL_CLOSED) { + return ESP_ERR_INVALID_STATE; + } + if (node->state != ESP_OPENCLAW_NODE_INTERNAL_IDLE) { + return ESP_ERR_INVALID_STATE; + } + if (node->pending_control != ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_NONE) { + return ESP_ERR_INVALID_STATE; + } + + esp_err_t err = source->kind == ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SAVED_SESSION + ? validate_saved_session_connect_preflight_locked(node) + : validate_explicit_connect_preflight_locked(node, source); + if (err != ESP_OK) { + return err; + } + + esp_openclaw_node_set_pending_control_locked( + node, + ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_CONNECT); + return ESP_OK; +} diff --git a/components/esp-openclaw-node/src/esp_openclaw_node_identity.c b/components/esp-openclaw-node/src/esp_openclaw_node_identity.c new file mode 100644 index 0000000..b8caf0c --- /dev/null +++ b/components/esp-openclaw-node/src/esp_openclaw_node_identity.c @@ -0,0 +1,388 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "esp_openclaw_node_identity.h" + +#include +#include +#include +#include +#include + +#include "esp_check.h" +#include "esp_log.h" +#include "esp_random.h" +#include "mbedtls/base64.h" +#include "mbedtls/sha256.h" +#include "nvs.h" +#include "sodium.h" + +static const char *TAG = "esp_openclaw_node_identity"; +static const char *NVS_NAMESPACE = "openclaw"; +static const char *NVS_KEY_SEED = "device_seed"; +static const char *NVS_KEY_SESSION_VERSION = "session_v"; +static const char *NVS_KEY_SESSION_URI = "session_uri"; +static const char *NVS_KEY_SESSION_DEVICE_TOKEN = "session_dev_tok"; +#define ESP_OPENCLAW_NODE_DEVICE_AUTH_PAYLOAD_V3_FORMAT \ + "v3|%s|%s|%s|%s|%s|%" PRId64 "|%s|%s|%s|%s" + +_Static_assert(ESP_OPENCLAW_NODE_ED25519_SEED_LEN == crypto_sign_SEEDBYTES, "unexpected libsodium seed size"); +_Static_assert( + ESP_OPENCLAW_NODE_ED25519_PUBLIC_KEY_LEN == crypto_sign_PUBLICKEYBYTES, + "unexpected libsodium public key size"); +_Static_assert( + ESP_OPENCLAW_NODE_ED25519_PRIVATE_KEY_LEN == crypto_sign_SECRETKEYBYTES, + "unexpected libsodium secret key size"); + +static esp_err_t ensure_sodium_ready(void) +{ + int rc = sodium_init(); + if (rc < 0) { + ESP_LOGE(TAG, "libsodium initialization failed"); + return ESP_FAIL; + } + return ESP_OK; +} + +static void bytes_to_lower_hex(const uint8_t *input, size_t input_len, char *output, size_t output_size) +{ + static const char HEX[] = "0123456789abcdef"; + if (output_size < (input_len * 2U) + 1U) { + if (output_size > 0) { + output[0] = '\0'; + } + return; + } + for (size_t i = 0; i < input_len; ++i) { + output[(i * 2U)] = HEX[(input[i] >> 4) & 0x0f]; + output[(i * 2U) + 1U] = HEX[input[i] & 0x0f]; + } + output[input_len * 2U] = '\0'; +} + +static esp_err_t base64url_encode( + const uint8_t *input, + size_t input_len, + char *output, + size_t output_size) +{ + size_t written = 0; + int rc = mbedtls_base64_encode((unsigned char *)output, output_size, &written, input, input_len); + if (rc != 0) { + return ESP_ERR_NO_MEM; + } + for (size_t i = 0; i < written; ++i) { + if (output[i] == '+') { + output[i] = '-'; + } else if (output[i] == '/') { + output[i] = '_'; + } + } + while (written > 0 && output[written - 1] == '=') { + --written; + } + output[written] = '\0'; + return ESP_OK; +} + +static char *normalize_metadata(const char *value) +{ + const char *start = value ? value : ""; + while (*start != '\0' && isspace((unsigned char)*start)) { + ++start; + } + const char *end = start + strlen(start); + while (end > start && isspace((unsigned char)*(end - 1))) { + --end; + } + + size_t len = (size_t)(end - start); + char *normalized = calloc(len + 1U, sizeof(char)); + if (normalized == NULL) { + return NULL; + } + for (size_t i = 0; i < len; ++i) { + normalized[i] = (char)tolower((unsigned char)start[i]); + } + normalized[len] = '\0'; + return normalized; +} + +static esp_err_t erase_nvs_key_if_present(nvs_handle_t nvs, const char *key) +{ + esp_err_t err = nvs_erase_key(nvs, key); + if (err == ESP_ERR_NVS_NOT_FOUND) { + return ESP_OK; + } + return err; +} + +static esp_err_t clear_identity_bound_session_state(nvs_handle_t nvs) +{ + ESP_RETURN_ON_ERROR(erase_nvs_key_if_present(nvs, NVS_KEY_SEED), TAG, "erase malformed seed"); + ESP_RETURN_ON_ERROR( + erase_nvs_key_if_present(nvs, NVS_KEY_SESSION_VERSION), + TAG, + "erase saved session version"); + ESP_RETURN_ON_ERROR( + erase_nvs_key_if_present(nvs, NVS_KEY_SESSION_URI), + TAG, + "erase saved session uri"); + ESP_RETURN_ON_ERROR( + erase_nvs_key_if_present(nvs, NVS_KEY_SESSION_DEVICE_TOKEN), + TAG, + "erase saved session device token"); + return ESP_OK; +} + +static esp_err_t generate_and_store_seed(nvs_handle_t nvs, uint8_t *seed, bool *created) +{ + esp_fill_random(seed, ESP_OPENCLAW_NODE_ED25519_SEED_LEN); + ESP_RETURN_ON_ERROR( + nvs_set_blob(nvs, NVS_KEY_SEED, seed, ESP_OPENCLAW_NODE_ED25519_SEED_LEN), + TAG, + "failed storing device seed"); + *created = true; + return ESP_OK; +} + +static esp_err_t load_seed_from_nvs(nvs_handle_t nvs, uint8_t *seed, bool *created) +{ + size_t required = ESP_OPENCLAW_NODE_ED25519_SEED_LEN; + esp_err_t err = nvs_get_blob(nvs, NVS_KEY_SEED, seed, &required); + if (err == ESP_OK && required == ESP_OPENCLAW_NODE_ED25519_SEED_LEN) { + *created = false; + return ESP_OK; + } + if (err == ESP_OK || err == ESP_ERR_NVS_INVALID_LENGTH) { + ESP_LOGW( + TAG, + "discarding malformed stored identity seed (size=%u) and clearing saved session", + (unsigned)required); + ESP_RETURN_ON_ERROR( + clear_identity_bound_session_state(nvs), + TAG, + "clear malformed identity state"); + return generate_and_store_seed(nvs, seed, created); + } + if (err != ESP_ERR_NVS_NOT_FOUND) { + return err; + } + + return generate_and_store_seed(nvs, seed, created); +} + +esp_err_t esp_openclaw_node_identity_store_seed_if_absent(const uint8_t *seed, size_t seed_len) +{ + if (seed == NULL || seed_len != ESP_OPENCLAW_NODE_ED25519_SEED_LEN) { + return ESP_ERR_INVALID_ARG; + } + + nvs_handle_t nvs = 0; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs); + if (err != ESP_OK) { + ESP_LOGE(TAG, "failed opening NVS namespace: %s", esp_err_to_name(err)); + return err; + } + + size_t required = 0; + err = nvs_get_blob(nvs, NVS_KEY_SEED, NULL, &required); + if (err == ESP_OK) { + nvs_close(nvs); + return ESP_ERR_INVALID_STATE; + } + if (err != ESP_ERR_NVS_NOT_FOUND) { + nvs_close(nvs); + return err; + } + + err = nvs_set_blob(nvs, NVS_KEY_SEED, seed, seed_len); + if (err == ESP_OK) { + err = nvs_commit(nvs); + } + nvs_close(nvs); + return err; +} + +esp_err_t esp_openclaw_node_identity_load_or_create(esp_openclaw_node_identity_t *identity) +{ + if (identity == NULL) { + return ESP_ERR_INVALID_ARG; + } + + memset(identity, 0, sizeof(*identity)); + + nvs_handle_t nvs = 0; + esp_err_t err = + nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs); + if (err != ESP_OK) { + ESP_LOGE(TAG, "failed opening NVS namespace: %s", esp_err_to_name(err)); + return err; + } + + bool created = false; + err = load_seed_from_nvs(nvs, identity->seed, &created); + if (err != ESP_OK) { + goto fail; + } + + err = ensure_sodium_ready(); + if (err != ESP_OK) { + goto fail; + } + + if (crypto_sign_seed_keypair(identity->public_key, identity->private_key, identity->seed) != 0) { + ESP_LOGE(TAG, "failed deriving Ed25519 keypair"); + err = ESP_FAIL; + goto fail; + } + + uint8_t digest[32] = {0}; + mbedtls_sha256(identity->public_key, ESP_OPENCLAW_NODE_ED25519_PUBLIC_KEY_LEN, digest, 0); + bytes_to_lower_hex(digest, sizeof(digest), identity->device_id, sizeof(identity->device_id)); + err = base64url_encode( + identity->public_key, + ESP_OPENCLAW_NODE_ED25519_PUBLIC_KEY_LEN, + identity->public_key_b64url, + sizeof(identity->public_key_b64url)); + if (err != ESP_OK) { + ESP_LOGE(TAG, "failed encoding public key: %s", esp_err_to_name(err)); + goto fail; + } + + err = created ? nvs_commit(nvs) : ESP_OK; + nvs_close(nvs); + if (err != ESP_OK) { + ESP_LOGE(TAG, "failed committing NVS: %s", esp_err_to_name(err)); + esp_openclaw_node_identity_free(identity); + memset(identity, 0, sizeof(*identity)); + return err; + } + + ESP_LOGI( + TAG, + "device identity ready: %.12s...", + identity->device_id); + return ESP_OK; + +fail: + nvs_close(nvs); + esp_openclaw_node_identity_free(identity); + memset(identity, 0, sizeof(*identity)); + return err; +} + +void esp_openclaw_node_identity_free(esp_openclaw_node_identity_t *identity) +{ + if (identity == NULL) { + return; + } + memset(identity, 0, sizeof(*identity)); +} + +esp_err_t esp_openclaw_node_identity_sign_payload( + const esp_openclaw_node_identity_t *identity, + const char *payload, + char *signature_b64url, + size_t signature_b64url_size) +{ + if (identity == NULL || payload == NULL || signature_b64url == NULL) { + return ESP_ERR_INVALID_ARG; + } + + ESP_RETURN_ON_ERROR(ensure_sodium_ready(), TAG, "libsodium not ready"); + + uint8_t signature[64] = {0}; + unsigned long long signature_len = 0; + if (crypto_sign_detached( + signature, + &signature_len, + (const unsigned char *)payload, + strlen(payload), + identity->private_key) != 0) { + return ESP_FAIL; + } + if (signature_len != sizeof(signature)) { + return ESP_FAIL; + } + + return base64url_encode(signature, sizeof(signature), signature_b64url, signature_b64url_size); +} + +esp_err_t esp_openclaw_node_identity_build_auth_payload_v3( + const esp_openclaw_node_identity_t *identity, + const char *client_id, + const char *client_mode, + const char *role, + const char *scopes_csv, + int64_t signed_at_ms, + const char *token, + const char *nonce, + const char *platform, + const char *device_family, + char **out_payload) +{ + if (identity == NULL || client_id == NULL || client_mode == NULL || role == NULL || + scopes_csv == NULL || nonce == NULL || out_payload == NULL) { + return ESP_ERR_INVALID_ARG; + } + + char *normalized_platform = normalize_metadata(platform); + char *normalized_family = normalize_metadata(device_family); + if (normalized_platform == NULL || normalized_family == NULL) { + free(normalized_platform); + free(normalized_family); + return ESP_ERR_NO_MEM; + } + + const char *safe_token = token ? token : ""; + int required = snprintf( + NULL, + 0, + ESP_OPENCLAW_NODE_DEVICE_AUTH_PAYLOAD_V3_FORMAT, + identity->device_id, + client_id, + client_mode, + role, + scopes_csv, + signed_at_ms, + safe_token, + nonce, + normalized_platform, + normalized_family); + if (required < 0) { + free(normalized_platform); + free(normalized_family); + return ESP_FAIL; + } + + char *payload = calloc((size_t)required + 1U, sizeof(char)); + if (payload == NULL) { + free(normalized_platform); + free(normalized_family); + return ESP_ERR_NO_MEM; + } + + snprintf( + payload, + (size_t)required + 1U, + ESP_OPENCLAW_NODE_DEVICE_AUTH_PAYLOAD_V3_FORMAT, + identity->device_id, + client_id, + client_mode, + role, + scopes_csv, + signed_at_ms, + safe_token, + nonce, + normalized_platform, + normalized_family); + + free(normalized_platform); + free(normalized_family); + *out_payload = payload; + return ESP_OK; +} diff --git a/components/esp-openclaw-node/src/esp_openclaw_node_persisted_session.c b/components/esp-openclaw-node/src/esp_openclaw_node_persisted_session.c new file mode 100644 index 0000000..aae01c4 --- /dev/null +++ b/components/esp-openclaw-node/src/esp_openclaw_node_persisted_session.c @@ -0,0 +1,299 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "esp_openclaw_node_persisted_session.h" + +#include +#include +#include +#include + +#include "esp_check.h" +#include "esp_log.h" +#include "nvs.h" + +static const char *TAG = "esp_openclaw_node_session"; +static const char *NVS_NAMESPACE = "openclaw"; +static const char *NVS_KEY_VERSION = "session_v"; +static const char *NVS_KEY_URI = "session_uri"; +static const char *NVS_KEY_DEVICE_TOKEN = "session_dev_tok"; +static const uint8_t PERSISTED_SESSION_VERSION = 1; + +static esp_err_t write_persisted_session_to_storage(const esp_openclaw_node_persisted_session_t *update); + +static char *duplicate_string(const char *value) +{ + if (value == NULL) { + return NULL; + } + char *copy = strdup(value); + if (copy == NULL) { + return NULL; + } + return copy; +} + +static bool is_valid_gateway_uri(const char *gateway_uri) +{ + if (gateway_uri == NULL || gateway_uri[0] == '\0') { + return false; + } + return strncmp(gateway_uri, "ws://", 5) == 0 || strncmp(gateway_uri, "wss://", 6) == 0; +} + +static void clear_persisted_session_struct(esp_openclaw_node_persisted_session_t *session) +{ + free(session->gateway_uri); + session->gateway_uri = NULL; + free(session->device_token); + session->device_token = NULL; + session->version = 0; +} + +static esp_err_t validate_persisted_session(const esp_openclaw_node_persisted_session_t *session) +{ + if (session == NULL) { + return ESP_ERR_INVALID_ARG; + } + + bool has_uri = session->gateway_uri != NULL && session->gateway_uri[0] != '\0'; + bool has_device_token = session->device_token != NULL && session->device_token[0] != '\0'; + if (has_uri != has_device_token) { + return ESP_ERR_INVALID_ARG; + } + if (!has_uri) { + return ESP_OK; + } + if (!is_valid_gateway_uri(session->gateway_uri)) { + return ESP_ERR_INVALID_ARG; + } + return ESP_OK; +} + +static esp_err_t load_optional_string( + nvs_handle_t nvs, + const char *key, + char **out_value) +{ + size_t required = 0; + esp_err_t err = nvs_get_str(nvs, key, NULL, &required); + if (err == ESP_ERR_NVS_NOT_FOUND) { + *out_value = NULL; + return ESP_OK; + } + ESP_RETURN_ON_ERROR(err, TAG, "failed reading string size"); + + char *value = malloc(required); + if (value == NULL) { + return ESP_ERR_NO_MEM; + } + err = nvs_get_str(nvs, key, value, &required); + if (err != ESP_OK) { + free(value); + return err; + } + if (value[0] == '\0') { + free(value); + value = NULL; + } + *out_value = value; + return ESP_OK; +} + +static esp_err_t persist_optional_string( + nvs_handle_t nvs, + const char *key, + const char *value) +{ + if (value == NULL || value[0] == '\0') { + esp_err_t err = nvs_erase_key(nvs, key); + if (err == ESP_ERR_NVS_NOT_FOUND) { + return ESP_OK; + } + return err; + } + return nvs_set_str(nvs, key, value); +} + +static esp_err_t clear_session_keys(nvs_handle_t nvs) +{ + esp_err_t err = nvs_erase_key(nvs, NVS_KEY_VERSION); + if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND) { + return err; + } + err = nvs_erase_key(nvs, NVS_KEY_URI); + if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND) { + return err; + } + err = nvs_erase_key(nvs, NVS_KEY_DEVICE_TOKEN); + if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND) { + return err; + } + return nvs_commit(nvs); +} + +static esp_err_t clear_invalid_loaded_session( + nvs_handle_t nvs, + esp_openclaw_node_persisted_session_t *session, + const char *reason) +{ + ESP_LOGW(TAG, "discarding malformed persisted session: %s", reason); + clear_persisted_session_struct(session); + + esp_err_t clear_err = clear_session_keys(nvs); + if (clear_err != ESP_OK) { + ESP_LOGW( + TAG, + "failed clearing malformed persisted session: %s", + esp_err_to_name(clear_err)); + } + return ESP_OK; +} + +static esp_err_t copy_persisted_session( + esp_openclaw_node_persisted_session_t *dst, + const esp_openclaw_node_persisted_session_t *src) +{ + memset(dst, 0, sizeof(*dst)); + dst->version = src->gateway_uri != NULL ? PERSISTED_SESSION_VERSION : 0; + dst->gateway_uri = duplicate_string(src->gateway_uri); + dst->device_token = duplicate_string(src->device_token); + if ((src->gateway_uri != NULL && dst->gateway_uri == NULL) || + (src->device_token != NULL && dst->device_token == NULL)) { + clear_persisted_session_struct(dst); + return ESP_ERR_NO_MEM; + } + return ESP_OK; +} + +static esp_err_t write_persisted_session_to_storage(const esp_openclaw_node_persisted_session_t *update) +{ + nvs_handle_t nvs = 0; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs); + if (err != ESP_OK) { + return err; + } + + if (!esp_openclaw_node_persisted_session_is_present(update)) { + err = clear_session_keys(nvs); + nvs_close(nvs); + return err; + } + + err = nvs_set_u8(nvs, NVS_KEY_VERSION, PERSISTED_SESSION_VERSION); + if (err == ESP_OK) { + err = persist_optional_string(nvs, NVS_KEY_URI, update->gateway_uri); + } + if (err == ESP_OK) { + err = persist_optional_string(nvs, NVS_KEY_DEVICE_TOKEN, update->device_token); + } + if (err == ESP_OK) { + err = nvs_commit(nvs); + } + nvs_close(nvs); + return err; +} + +bool esp_openclaw_node_persisted_session_is_present(const esp_openclaw_node_persisted_session_t *session) +{ + return session != NULL && + session->gateway_uri != NULL && + session->gateway_uri[0] != '\0' && + session->device_token != NULL && + session->device_token[0] != '\0'; +} + +esp_err_t esp_openclaw_node_persisted_session_load(esp_openclaw_node_persisted_session_t *session) +{ + if (session == NULL) { + return ESP_ERR_INVALID_ARG; + } + + memset(session, 0, sizeof(*session)); + + nvs_handle_t nvs = 0; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs); + if (err != ESP_OK) { + return err; + } + + uint8_t version = 0; + err = nvs_get_u8(nvs, NVS_KEY_VERSION, &version); + if (err == ESP_ERR_NVS_NOT_FOUND) { + nvs_close(nvs); + return ESP_OK; + } + if (err != ESP_OK) { + nvs_close(nvs); + return err; + } + if (version != PERSISTED_SESSION_VERSION) { + ESP_LOGW( + TAG, + "ignoring unsupported persisted session version %u", + (unsigned)version); + esp_err_t clear_err = clear_session_keys(nvs); + nvs_close(nvs); + if (clear_err != ESP_OK) { + ESP_LOGW( + TAG, + "failed clearing unsupported persisted session: %s", + esp_err_to_name(clear_err)); + } + return ESP_OK; + } + + session->version = version; + err = load_optional_string(nvs, NVS_KEY_URI, &session->gateway_uri); + if (err == ESP_OK) { + err = load_optional_string(nvs, NVS_KEY_DEVICE_TOKEN, &session->device_token); + } + if (err == ESP_OK) { + err = validate_persisted_session(session); + if (err == ESP_ERR_INVALID_ARG) { + err = clear_invalid_loaded_session(nvs, session, "incomplete or invalid fields"); + } + } + nvs_close(nvs); + + if (err != ESP_OK) { + esp_openclaw_node_persisted_session_free(session); + } + return err; +} + +void esp_openclaw_node_persisted_session_free(esp_openclaw_node_persisted_session_t *session) +{ + if (session == NULL) { + return; + } + clear_persisted_session_struct(session); +} + +esp_err_t esp_openclaw_node_persisted_session_store( + esp_openclaw_node_persisted_session_t *session, + const esp_openclaw_node_persisted_session_t *update) +{ + if (session == NULL || update == NULL) { + return ESP_ERR_INVALID_ARG; + } + ESP_RETURN_ON_ERROR(validate_persisted_session(update), TAG, "invalid persisted session"); + + esp_openclaw_node_persisted_session_t copy = {0}; + if (esp_openclaw_node_persisted_session_is_present(update)) { + ESP_RETURN_ON_ERROR(copy_persisted_session(©, update), TAG, "copy session"); + } + + esp_err_t err = write_persisted_session_to_storage(update); + if (err != ESP_OK) { + esp_openclaw_node_persisted_session_free(©); + return err; + } + + esp_openclaw_node_persisted_session_free(session); + *session = copy; + return ESP_OK; +} diff --git a/components/esp-openclaw-node/src/esp_openclaw_node_protocol.c b/components/esp-openclaw-node/src/esp_openclaw_node_protocol.c new file mode 100644 index 0000000..382ce04 --- /dev/null +++ b/components/esp-openclaw-node/src/esp_openclaw_node_protocol.c @@ -0,0 +1,618 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "esp_openclaw_node_internal.h" + +#include +#include +#include +#include + +#include "esp_log.h" +#include "esp_timer.h" + +static bool websocket_send_json(esp_openclaw_node_handle_t node, cJSON *root) +{ + char *json = cJSON_PrintUnformatted(root); + if (json == NULL) { + return false; + } + int written = node->transport_ops->send_text( + node->ws, + json, + (int)strlen(json), + pdMS_TO_TICKS(5000)); + free(json); + return written >= 0; +} + +static bool send_connect_request( + esp_openclaw_node_handle_t node, + const char *nonce, + int64_t signed_at_ms) +{ + esp_openclaw_node_connect_material_t material = {0}; + + esp_openclaw_node_lock_state(node); + esp_err_t selection_err = + esp_openclaw_node_resolve_active_connect_material_locked(node, &material); + esp_openclaw_node_unlock_state(node); + if (selection_err != ESP_OK) { + ESP_LOGE( + ESP_OPENCLAW_NODE_TAG, + "failed resolving connect material: %s", + esp_err_to_name(selection_err)); + return false; + } + + char *payload = NULL; + esp_err_t err = esp_openclaw_node_identity_build_auth_payload_v3( + &node->identity, + node->config.client_id, + node->config.client_mode, + node->config.role, + "", + signed_at_ms, + material.signature_token, + nonce, + node->config.platform, + node->config.device_family, + &payload); + if (err != ESP_OK || payload == NULL) { + esp_openclaw_node_free_connect_material(&material); + ESP_LOGE(ESP_OPENCLAW_NODE_TAG, "failed building auth payload"); + return false; + } + + char signature[ESP_OPENCLAW_NODE_SIGNATURE_B64_BUFFER_LEN] = {0}; + err = esp_openclaw_node_identity_sign_payload( + &node->identity, + payload, + signature, + sizeof(signature)); + free(payload); + if (err != ESP_OK) { + esp_openclaw_node_free_connect_material(&material); + ESP_LOGE(ESP_OPENCLAW_NODE_TAG, "failed signing auth payload"); + return false; + } + + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "type", "req"); + char request_id[32] = {0}; + snprintf( + request_id, + sizeof(request_id), + "connect-%" PRIu64, + (uint64_t)esp_timer_get_time()); + cJSON_AddStringToObject(root, "id", request_id); + cJSON_AddStringToObject(root, "method", "connect"); + + cJSON *params = cJSON_CreateObject(); + cJSON_AddNumberToObject(params, "minProtocol", 3); + cJSON_AddNumberToObject(params, "maxProtocol", 3); + + cJSON *client = cJSON_CreateObject(); + cJSON_AddStringToObject(client, "id", node->config.client_id); + cJSON_AddStringToObject(client, "displayName", node->config.display_name); + cJSON_AddStringToObject( + client, + "version", + esp_openclaw_node_firmware_version()); + cJSON_AddStringToObject(client, "platform", node->config.platform); + cJSON_AddStringToObject( + client, + "deviceFamily", + node->config.device_family); + cJSON_AddStringToObject( + client, + "modelIdentifier", + node->config.model_identifier); + cJSON_AddStringToObject(client, "mode", node->config.client_mode); + cJSON_AddItemToObject(params, "client", client); + + cJSON_AddStringToObject(params, "role", node->config.role); + cJSON_AddItemToObject(params, "scopes", cJSON_CreateArray()); + esp_openclaw_node_add_registered_string_array( + params, + "caps", + node->capabilities, + node->capability_count); + esp_openclaw_node_add_registered_command_array(params, "commands", node); + + if (material.auth_value != NULL) { + cJSON *auth_json = cJSON_CreateObject(); + switch (material.kind) { + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_BOOTSTRAP_TOKEN: + cJSON_AddStringToObject( + auth_json, + "bootstrapToken", + material.auth_value); + break; + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SHARED_TOKEN: + cJSON_AddStringToObject(auth_json, "token", material.auth_value); + break; + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_PASSWORD: + cJSON_AddStringToObject( + auth_json, + "password", + material.auth_value); + break; + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SAVED_SESSION: + cJSON_AddStringToObject( + auth_json, + "deviceToken", + material.auth_value); + break; + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NO_AUTH: + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NONE: + default: + break; + } + cJSON_AddItemToObject(params, "auth", auth_json); + } + + char user_agent[64] = {0}; + snprintf( + user_agent, + sizeof(user_agent), + "esp-openclaw-node/%s", + esp_openclaw_node_firmware_version()); + cJSON_AddStringToObject(params, "userAgent", user_agent); + cJSON_AddStringToObject(params, "locale", node->config.locale); + + cJSON *device = cJSON_CreateObject(); + cJSON_AddStringToObject(device, "id", node->identity.device_id); + cJSON_AddStringToObject( + device, + "publicKey", + node->identity.public_key_b64url); + cJSON_AddStringToObject(device, "signature", signature); + cJSON_AddNumberToObject(device, "signedAt", (double)signed_at_ms); + cJSON_AddStringToObject(device, "nonce", nonce); + cJSON_AddItemToObject(params, "device", device); + cJSON_AddItemToObject(root, "params", params); + + bool ready_to_send = false; + esp_openclaw_node_internal_state_t state = ESP_OPENCLAW_NODE_INTERNAL_IDLE; + esp_openclaw_node_lock_state(node); + state = node->state; + ready_to_send = node->state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING && + node->pending_connect_id[0] == '\0'; + if (ready_to_send) { + snprintf( + node->pending_connect_id, + sizeof(node->pending_connect_id), + "%s", + request_id); + } + esp_openclaw_node_unlock_state(node); + if (!ready_to_send) { + cJSON_Delete(root); + esp_openclaw_node_free_connect_material(&material); + ESP_LOGW( + ESP_OPENCLAW_NODE_TAG, + "connect request ignored in state=%s", + esp_openclaw_node_internal_state_name(state)); + return false; + } + + bool ok = websocket_send_json(node, root); + cJSON_Delete(root); + if (!ok) { + esp_openclaw_node_lock_state(node); + node->pending_connect_id[0] = '\0'; + esp_openclaw_node_unlock_state(node); + esp_openclaw_node_free_connect_material(&material); + return false; + } + + ESP_LOGI( + ESP_OPENCLAW_NODE_TAG, + "sent connect request using %s auth", + esp_openclaw_node_connect_source_kind_name(material.kind)); + esp_openclaw_node_free_connect_material(&material); + return true; +} + +static void send_invoke_result( + esp_openclaw_node_handle_t node, + const char *request_id, + const char *node_id, + bool ok, + const char *payload_json, + const char *error_code, + const char *error_message) +{ + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "type", "req"); + + char ws_request_id[32] = {0}; + snprintf( + ws_request_id, + sizeof(ws_request_id), + "esp32-%" PRIu64, + (uint64_t)esp_timer_get_time()); + cJSON_AddStringToObject(root, "id", ws_request_id); + cJSON_AddStringToObject(root, "method", "node.invoke.result"); + + cJSON *params = cJSON_CreateObject(); + cJSON_AddStringToObject(params, "id", request_id); + cJSON_AddStringToObject(params, "nodeId", node_id); + cJSON_AddBoolToObject(params, "ok", ok); + + if (ok) { + if (payload_json != NULL && payload_json[0] != '\0') { + cJSON_AddStringToObject(params, "payloadJSON", payload_json); + } + } else { + cJSON *error = cJSON_CreateObject(); + cJSON_AddStringToObject( + error, + "code", + error_code ? error_code : "INVALID_REQUEST"); + cJSON_AddStringToObject( + error, + "message", + error_message ? error_message : "command failed"); + cJSON_AddItemToObject(params, "error", error); + } + + cJSON_AddItemToObject(root, "params", params); + if (!websocket_send_json(node, root)) { + ESP_LOGW(ESP_OPENCLAW_NODE_TAG, "failed sending invoke result"); + } + cJSON_Delete(root); +} + +static esp_err_t build_connect_response_session_update( + esp_openclaw_node_handle_t node, + const char *device_token_text, + esp_openclaw_node_persisted_session_t *update) +{ + if (node == NULL || device_token_text == NULL || update == NULL) { + return ESP_ERR_INVALID_ARG; + } + + memset(update, 0, sizeof(*update)); + update->version = 1; + update->gateway_uri = esp_openclaw_node_duplicate_string( + esp_openclaw_node_trimmed_or_null(node->transport_gateway_uri)); + update->device_token = + esp_openclaw_node_duplicate_string(device_token_text); + if (update->gateway_uri == NULL || update->device_token == NULL) { + esp_openclaw_node_persisted_session_free(update); + return ESP_ERR_NO_MEM; + } + return ESP_OK; +} + +static connect_response_finalize_result_t finalize_connect_response_success( + esp_openclaw_node_handle_t node, + const esp_openclaw_node_persisted_session_t *update) +{ + connect_response_finalize_result_t result = { + .outcome = CONNECT_RESPONSE_OUTCOME_IGNORE, + .err = ESP_OK, + .state = ESP_OPENCLAW_NODE_INTERNAL_IDLE, + }; + + esp_openclaw_node_lock_state(node); + result.state = node->state; + if (node->state != ESP_OPENCLAW_NODE_INTERNAL_CONNECTING || + node->pending_connect_id[0] == '\0') { + esp_openclaw_node_unlock_state(node); + return result; + } + + result.err = esp_openclaw_node_persisted_session_store( + &node->persisted_session, + update); + if (result.err != ESP_OK) { + result.outcome = CONNECT_RESPONSE_OUTCOME_CONNECT_FAILED; + } else { + node->state = ESP_OPENCLAW_NODE_INTERNAL_READY; + esp_openclaw_node_clear_pending_control_locked(node); + esp_openclaw_node_clear_session_wait_state_locked(node); + esp_openclaw_node_clear_connect_source_struct(&node->active_connect_source); + result.outcome = CONNECT_RESPONSE_OUTCOME_CONNECTED; + } + esp_openclaw_node_unlock_state(node); + return result; +} + +static void complete_connect_response_outcome( + esp_openclaw_node_handle_t node, + const connect_response_finalize_result_t *result) +{ + switch (result->outcome) { + case CONNECT_RESPONSE_OUTCOME_IGNORE: + ESP_LOGW( + ESP_OPENCLAW_NODE_TAG, + "ignoring connect response in state=%s", + esp_openclaw_node_internal_state_name(result->state)); + break; + case CONNECT_RESPONSE_OUTCOME_CONNECT_FAILED: + esp_openclaw_node_complete_connect_failed( + node, + ESP_OPENCLAW_NODE_CONNECT_FAILURE_SESSION_FINALIZATION_FAILED, + result->err, + NULL, + true); + break; + case CONNECT_RESPONSE_OUTCOME_CONNECTED: + esp_openclaw_node_emit_connected(node); + ESP_LOGI( + ESP_OPENCLAW_NODE_TAG, + "OpenClaw gateway handshake complete"); + break; + default: + break; + } +} + +static void handle_connect_response( + esp_openclaw_node_handle_t node, + cJSON *root) +{ + cJSON *ok = cJSON_GetObjectItemCaseSensitive(root, "ok"); + if (!cJSON_IsBool(ok)) { + return; + } + + if (cJSON_IsTrue(ok)) { + cJSON *payload = cJSON_GetObjectItemCaseSensitive(root, "payload"); + cJSON *type = + payload ? cJSON_GetObjectItemCaseSensitive(payload, "type") : NULL; + if (!cJSON_IsString(type) || + strcmp(type->valuestring, "hello-ok") != 0) { + return; + } + + cJSON *auth = cJSON_GetObjectItemCaseSensitive(payload, "auth"); + cJSON *device_token = auth + ? cJSON_GetObjectItemCaseSensitive(auth, "deviceToken") + : NULL; + const char *device_token_text = cJSON_IsString(device_token) + ? esp_openclaw_node_trimmed_or_null(device_token->valuestring) + : NULL; + if (device_token_text == NULL) { + esp_openclaw_node_complete_connect_failed( + node, + ESP_OPENCLAW_NODE_CONNECT_FAILURE_SESSION_FINALIZATION_FAILED, + ESP_FAIL, + NULL, + true); + return; + } + + esp_openclaw_node_persisted_session_t update = {0}; + esp_err_t err = build_connect_response_session_update( + node, + device_token_text, + &update); + if (err != ESP_OK) { + esp_openclaw_node_complete_connect_failed( + node, + ESP_OPENCLAW_NODE_CONNECT_FAILURE_SESSION_FINALIZATION_FAILED, + err, + NULL, + true); + return; + } + + connect_response_finalize_result_t result = + finalize_connect_response_success(node, &update); + esp_openclaw_node_persisted_session_free(&update); + complete_connect_response_outcome(node, &result); + return; + } + + cJSON *error = cJSON_GetObjectItemCaseSensitive(root, "error"); + cJSON *message = + error ? cJSON_GetObjectItemCaseSensitive(error, "message") : NULL; + cJSON *details = + error ? cJSON_GetObjectItemCaseSensitive(error, "details") : NULL; + cJSON *detail_code = + details ? cJSON_GetObjectItemCaseSensitive(details, "code") : NULL; + cJSON *request_id = + details ? cJSON_GetObjectItemCaseSensitive(details, "requestId") : NULL; + const char *message_text = cJSON_IsString(message) && + message->valuestring != NULL + ? message->valuestring + : "connect failed"; + const char *detail_code_text = cJSON_IsString(detail_code) && + detail_code->valuestring != NULL + ? detail_code->valuestring + : NULL; + const char *request_id_text = cJSON_IsString(request_id) && + request_id->valuestring != NULL + ? request_id->valuestring + : NULL; + + ESP_LOGW( + ESP_OPENCLAW_NODE_TAG, + "connect rejected: %s%s%s%s%s%s", + message_text, + detail_code_text != NULL ? " (" : "", + detail_code_text != NULL ? detail_code_text : "", + detail_code_text != NULL ? ")" : "", + request_id_text != NULL ? ", requestId=" : "", + request_id_text != NULL ? request_id_text : ""); + + esp_openclaw_node_complete_connect_failed( + node, + ESP_OPENCLAW_NODE_CONNECT_FAILURE_AUTH_REJECTED, + ESP_OK, + detail_code_text, + true); +} + +static void handle_connect_challenge( + esp_openclaw_node_handle_t node, + cJSON *payload) +{ + esp_openclaw_node_lock_state(node); + bool accept_challenge = + node->state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING && + node->pending_connect_id[0] == '\0'; + esp_openclaw_node_unlock_state(node); + if (!accept_challenge) { + ESP_LOGW(ESP_OPENCLAW_NODE_TAG, "ignoring unexpected connect.challenge"); + return; + } + + cJSON *nonce = cJSON_GetObjectItemCaseSensitive(payload, "nonce"); + cJSON *ts = cJSON_GetObjectItemCaseSensitive(payload, "ts"); + if (!cJSON_IsString(nonce) || nonce->valuestring == NULL || + nonce->valuestring[0] == '\0') { + ESP_LOGW(ESP_OPENCLAW_NODE_TAG, "connect.challenge missing nonce"); + return; + } + int64_t signed_at_ms = cJSON_IsNumber(ts) ? (int64_t)ts->valuedouble : 0; + if (signed_at_ms <= 0) { + signed_at_ms = esp_timer_get_time() / 1000LL; + } + if (!send_connect_request(node, nonce->valuestring, signed_at_ms)) { + esp_openclaw_node_complete_connect_failed( + node, + ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILED, + ESP_FAIL, + NULL, + true); + } +} + +static void handle_invoke_request( + esp_openclaw_node_handle_t node, + cJSON *payload) +{ + cJSON *id = cJSON_GetObjectItemCaseSensitive(payload, "id"); + cJSON *node_id = cJSON_GetObjectItemCaseSensitive(payload, "nodeId"); + cJSON *command = cJSON_GetObjectItemCaseSensitive(payload, "command"); + cJSON *params_json = + cJSON_GetObjectItemCaseSensitive(payload, "paramsJSON"); + + if (!cJSON_IsString(id) || !cJSON_IsString(node_id) || + !cJSON_IsString(command) || id->valuestring == NULL || + node_id->valuestring == NULL || command->valuestring == NULL) { + ESP_LOGW( + ESP_OPENCLAW_NODE_TAG, + "dropping malformed node.invoke.request"); + return; + } + + const char *effective_params_json = "{}"; + size_t effective_params_len = 2; + if (cJSON_IsString(params_json) && params_json->valuestring != NULL && + params_json->valuestring[0] != '\0') { + effective_params_json = params_json->valuestring; + effective_params_len = strlen(effective_params_json); + } + + char *result_json = NULL; + const char *error_code = NULL; + const char *error_message = NULL; + esp_err_t err = esp_openclaw_node_dispatch_command( + node, + command->valuestring, + effective_params_json, + effective_params_len, + &result_json, + &error_code, + &error_message); + if (err == ESP_OK) { + send_invoke_result( + node, + id->valuestring, + node_id->valuestring, + true, + result_json, + NULL, + NULL); + } else { + send_invoke_result( + node, + id->valuestring, + node_id->valuestring, + false, + NULL, + error_code, + error_message); + } + + free(result_json); +} + +void esp_openclaw_node_process_gateway_message( + esp_openclaw_node_handle_t node, + const char *text) +{ + cJSON *root = cJSON_Parse(text); + if (root == NULL) { + ESP_LOGW(ESP_OPENCLAW_NODE_TAG, "invalid gateway JSON frame"); + return; + } + + cJSON *type = cJSON_GetObjectItemCaseSensitive(root, "type"); + if (!cJSON_IsString(type) || type->valuestring == NULL) { + cJSON_Delete(root); + return; + } + + if (strcmp(type->valuestring, "event") == 0) { + cJSON *event = cJSON_GetObjectItemCaseSensitive(root, "event"); + cJSON *payload = cJSON_GetObjectItemCaseSensitive(root, "payload"); + if (cJSON_IsString(event) && event->valuestring != NULL) { + if (strcmp(event->valuestring, "connect.challenge") == 0 && + cJSON_IsObject(payload)) { + ESP_LOGI(ESP_OPENCLAW_NODE_TAG, "received connect.challenge"); + handle_connect_challenge(node, payload); + } else if ( + strcmp(event->valuestring, "node.invoke.request") == 0 && + cJSON_IsObject(payload)) { + esp_openclaw_node_lock_state(node); + bool ready = node->state == ESP_OPENCLAW_NODE_INTERNAL_READY; + esp_openclaw_node_unlock_state(node); + if (ready) { + handle_invoke_request(node, payload); + } else { + ESP_LOGW( + ESP_OPENCLAW_NODE_TAG, + "ignoring node.invoke.request before session is ready"); + } + } else { + esp_openclaw_node_lock_state(node); + bool connecting = + esp_openclaw_node_state_is_connecting(node->state); + esp_openclaw_node_unlock_state(node); + if (connecting) { + ESP_LOGD( + ESP_OPENCLAW_NODE_TAG, + "received gateway event during connect: %s", + event->valuestring); + } + } + } + } else if (strcmp(type->valuestring, "res") == 0) { + cJSON *id = cJSON_GetObjectItemCaseSensitive(root, "id"); + bool is_pending_connect_response = false; + if (cJSON_IsString(id) && id->valuestring != NULL) { + esp_openclaw_node_lock_state(node); + is_pending_connect_response = + node->transport_connected && + node->state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING && + node->pending_connect_id[0] != '\0' && + strcmp(id->valuestring, node->pending_connect_id) == 0; + esp_openclaw_node_unlock_state(node); + } + if (is_pending_connect_response) { + handle_connect_response(node, root); + } + } + + cJSON_Delete(root); +} diff --git a/components/esp-openclaw-node/src/esp_openclaw_node_registry.c b/components/esp-openclaw-node/src/esp_openclaw_node_registry.c new file mode 100644 index 0000000..4ccd150 --- /dev/null +++ b/components/esp-openclaw-node/src/esp_openclaw_node_registry.c @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "esp_openclaw_node_internal.h" + +#include +#include + +static esp_err_t require_idle_registration_state(esp_openclaw_node_handle_t node) +{ + esp_openclaw_node_lock_state(node); + bool idle = node->state == ESP_OPENCLAW_NODE_INTERNAL_IDLE && + node->pending_control == ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_NONE; + esp_openclaw_node_unlock_state(node); + return idle ? ESP_OK : ESP_ERR_INVALID_STATE; +} + +void esp_openclaw_node_cleanup_registry(esp_openclaw_node_handle_t node) +{ + for (size_t i = 0; i < node->capability_count; ++i) { + free(node->capabilities[i]); + node->capabilities[i] = NULL; + } + node->capability_count = 0; + + for (size_t i = 0; i < node->command_count; ++i) { + free(node->commands[i].name); + node->commands[i].name = NULL; + node->commands[i].handler = NULL; + node->commands[i].context = NULL; + } + node->command_count = 0; +} + +esp_openclaw_node_registered_command_t *esp_openclaw_node_find_command( + esp_openclaw_node_handle_t node, + const char *name) +{ + for (size_t i = 0; i < node->command_count; ++i) { + if (node->commands[i].name != NULL && + strcmp(node->commands[i].name, name) == 0) { + return &node->commands[i]; + } + } + return NULL; +} + +esp_err_t esp_openclaw_node_dispatch_command( + esp_openclaw_node_handle_t node, + const char *command, + const char *params_json, + size_t params_len, + char **out_payload_json, + const char **out_error_code, + const char **out_error_message) +{ + *out_payload_json = NULL; + *out_error_code = "INVALID_REQUEST"; + *out_error_message = "command failed"; + + esp_openclaw_node_registered_command_t *registered = + esp_openclaw_node_find_command(node, command); + if (registered == NULL) { + *out_error_code = "UNSUPPORTED_COMMAND"; + *out_error_message = "unsupported command"; + return ESP_ERR_NOT_SUPPORTED; + } + + esp_openclaw_node_error_t error = { + .code = "INVALID_REQUEST", + .message = "command failed", + }; + esp_err_t err = registered->handler( + node, + registered->context, + params_json, + params_len, + out_payload_json, + &error); + *out_error_code = error.code; + *out_error_message = error.message; + return err; +} + +void esp_openclaw_node_add_registered_string_array( + cJSON *parent, + const char *name, + char *const *items, + size_t count) +{ + cJSON *array = cJSON_CreateArray(); + for (size_t i = 0; i < count; ++i) { + if (items[i] != NULL) { + cJSON_AddItemToArray(array, cJSON_CreateString(items[i])); + } + } + cJSON_AddItemToObject(parent, name, array); +} + +void esp_openclaw_node_add_registered_command_array( + cJSON *parent, + const char *name, + esp_openclaw_node_handle_t node) +{ + cJSON *array = cJSON_CreateArray(); + for (size_t i = 0; i < node->command_count; ++i) { + if (node->commands[i].name != NULL) { + cJSON_AddItemToArray( + array, + cJSON_CreateString(node->commands[i].name)); + } + } + cJSON_AddItemToObject(parent, name, array); +} + +esp_err_t esp_openclaw_node_register_capability_internal( + esp_openclaw_node_handle_t node, + const char *capability) +{ + if (node == NULL || capability == NULL || capability[0] == '\0') { + return ESP_ERR_INVALID_ARG; + } + if (require_idle_registration_state(node) != ESP_OK) { + return ESP_ERR_INVALID_STATE; + } + if (node->capability_count >= ESP_OPENCLAW_NODE_MAX_CAPABILITIES) { + return ESP_ERR_NO_MEM; + } + for (size_t i = 0; i < node->capability_count; ++i) { + if (strcmp(node->capabilities[i], capability) == 0) { + return ESP_OK; + } + } + + char *copy = esp_openclaw_node_duplicate_string(capability); + if (copy == NULL) { + return ESP_ERR_NO_MEM; + } + node->capabilities[node->capability_count++] = copy; + return ESP_OK; +} + +esp_err_t esp_openclaw_node_register_command_internal( + esp_openclaw_node_handle_t node, + const esp_openclaw_node_command_t *command) +{ + if (node == NULL || command == NULL || command->name == NULL || + command->name[0] == '\0' || command->handler == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (require_idle_registration_state(node) != ESP_OK) { + return ESP_ERR_INVALID_STATE; + } + if (node->command_count >= ESP_OPENCLAW_NODE_MAX_COMMANDS) { + return ESP_ERR_NO_MEM; + } + if (esp_openclaw_node_find_command(node, command->name) != NULL) { + return ESP_OK; + } + + esp_openclaw_node_registered_command_t *slot = &node->commands[node->command_count]; + slot->name = esp_openclaw_node_duplicate_string(command->name); + if (slot->name == NULL) { + return ESP_ERR_NO_MEM; + } + slot->handler = command->handler; + slot->context = command->context; + node->command_count += 1; + return ESP_OK; +} diff --git a/components/esp-openclaw-node/src/esp_openclaw_node_runtime.c b/components/esp-openclaw-node/src/esp_openclaw_node_runtime.c new file mode 100644 index 0000000..f8d85ca --- /dev/null +++ b/components/esp-openclaw-node/src/esp_openclaw_node_runtime.c @@ -0,0 +1,460 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "esp_openclaw_node_internal.h" + +#include + +#include + +#include "esp_log.h" +#include "esp_timer.h" + +void esp_openclaw_node_complete_connect_failed( + esp_openclaw_node_handle_t node, + esp_openclaw_node_connect_failure_reason_t reason, + esp_err_t local_err, + const char *gateway_detail_code, + bool stop_client) +{ + esp_openclaw_node_cleanup_transport_instance(node, stop_client); + esp_openclaw_node_lock_state(node); + node->state = ESP_OPENCLAW_NODE_INTERNAL_IDLE; + esp_openclaw_node_clear_pending_control_locked(node); + esp_openclaw_node_unlock_state(node); + esp_openclaw_node_emit_connect_failed( + node, + reason, + local_err, + gateway_detail_code); +} + +void esp_openclaw_node_complete_disconnected( + esp_openclaw_node_handle_t node, + esp_openclaw_node_disconnected_reason_t reason, + esp_err_t local_err, + bool stop_client) +{ + esp_openclaw_node_cleanup_transport_instance(node, stop_client); + esp_openclaw_node_lock_state(node); + node->state = ESP_OPENCLAW_NODE_INTERNAL_IDLE; + esp_openclaw_node_clear_pending_control_locked(node); + esp_openclaw_node_unlock_state(node); + esp_openclaw_node_emit_disconnected(node, reason, local_err); +} + +void esp_openclaw_node_fail_if_connect_timed_out(esp_openclaw_node_handle_t node) +{ + esp_openclaw_node_lock_state(node); + bool connecting = node->state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING; + int64_t connect_started_ms = node->connect_started_ms; + esp_openclaw_node_unlock_state(node); + + if (!connecting || connect_started_ms <= 0) { + return; + } + + int64_t now_ms = esp_timer_get_time() / 1000LL; + int64_t waited_ms = now_ms - connect_started_ms; + if (waited_ms < ESP_OPENCLAW_NODE_CONNECT_TIMEOUT_MS) { + return; + } + + ESP_LOGW( + ESP_OPENCLAW_NODE_TAG, + "timed out waiting for connect completion after %" PRId64 " ms", + waited_ms); + esp_openclaw_node_complete_connect_failed( + node, + ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILED, + ESP_ERR_TIMEOUT, + NULL, + true); +} + +esp_err_t esp_openclaw_node_enqueue_work_message( + esp_openclaw_node_handle_t node, + esp_openclaw_node_work_message_t *message) +{ + if (node->work_queue == NULL) { + esp_openclaw_node_free_work_message_payload(message); + return ESP_FAIL; + } + if (xQueueSend(node->work_queue, message, 0) != pdTRUE) { + esp_openclaw_node_free_work_message_payload(message); + return ESP_ERR_NO_MEM; + } + return ESP_OK; +} + +void esp_openclaw_node_enqueue_work_message_from_callback( + esp_openclaw_node_handle_t node, + esp_openclaw_node_work_message_t *message) +{ + bool accept = false; + + esp_openclaw_node_lock_state(node); + accept = esp_openclaw_node_should_accept_callback_generation_locked( + node, + message->generation); + esp_openclaw_node_unlock_state(node); + if (!accept) { + esp_openclaw_node_free_work_message_payload(message); + return; + } + if (esp_openclaw_node_enqueue_work_message(node, message) != ESP_OK) { + ESP_LOGW( + ESP_OPENCLAW_NODE_TAG, + "dropping websocket work item due to full queue"); + } +} + +static void complete_transport_event_state_after_disconnect_locked( + esp_openclaw_node_handle_t node) +{ + esp_openclaw_node_clear_session_wait_state_locked(node); + node->transport_connected = false; + node->ws_started = false; +} + +static void handle_request_connect( + esp_openclaw_node_handle_t node, + esp_openclaw_node_work_message_t *message) +{ + esp_openclaw_node_lock_state(node); + bool ready_to_start = + node->state == ESP_OPENCLAW_NODE_INTERNAL_IDLE && + node->pending_control == ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_CONNECT; + if (!ready_to_start) { + esp_openclaw_node_unlock_state(node); + return; + } + esp_openclaw_node_clear_pending_control_locked(node); + esp_openclaw_node_clear_connect_source_struct(&node->active_connect_source); + node->active_connect_source = message->connect_source; + memset(&message->connect_source, 0, sizeof(message->connect_source)); + node->state = ESP_OPENCLAW_NODE_INTERNAL_CONNECTING; + node->connect_started_ms = esp_timer_get_time() / 1000LL; + esp_openclaw_node_unlock_state(node); + + esp_err_t err = esp_openclaw_node_start_transport_for_active_source(node); + if (err != ESP_OK) { + esp_openclaw_node_complete_connect_failed( + node, + ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILED, + err, + NULL, + false); + } +} + +static void handle_request_disconnect(esp_openclaw_node_handle_t node) +{ + esp_openclaw_node_lock_state(node); + bool request_pending = + node->pending_control == ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_DISCONNECT; + bool has_transport = + node->ws != NULL || node->active_transport_generation != 0; + esp_openclaw_node_unlock_state(node); + + if (!request_pending) { + return; + } + + esp_openclaw_node_complete_disconnected( + node, + ESP_OPENCLAW_NODE_DISCONNECTED_REASON_REQUESTED, + ESP_OK, + has_transport); +} + +static void handle_ws_connected( + esp_openclaw_node_handle_t node, + const esp_openclaw_node_work_message_t *message) +{ + bool kick_challenge = false; + + esp_openclaw_node_lock_state(node); + bool current_generation = + node->active_transport_generation == message->generation; + esp_openclaw_node_internal_state_t state = node->state; + if (current_generation) { + node->transport_connected = true; + } + if (current_generation && + node->state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING) { + kick_challenge = true; + } + esp_openclaw_node_unlock_state(node); + + if (!current_generation) { + return; + } + + if (kick_challenge) { + esp_openclaw_node_send_challenge_kick_ping(node); + } + + ESP_LOGI( + ESP_OPENCLAW_NODE_TAG, + "websocket connected: gen=%" PRIu32 " state=%s", + message->generation, + esp_openclaw_node_internal_state_name(state)); +} + +static void handle_ws_disconnected( + esp_openclaw_node_handle_t node, + const esp_openclaw_node_work_message_t *message) +{ + esp_openclaw_node_lock_state(node); + bool current_generation = + node->active_transport_generation == message->generation; + esp_openclaw_node_internal_state_t state = node->state; + if (current_generation) { + complete_transport_event_state_after_disconnect_locked(node); + } + esp_openclaw_node_unlock_state(node); + + if (!current_generation) { + return; + } + + ESP_LOGW( + ESP_OPENCLAW_NODE_TAG, + "websocket disconnected: gen=%" PRIu32 " state=%s err=%s", + message->generation, + esp_openclaw_node_internal_state_name(state), + esp_err_to_name(message->local_err)); + + if (state == ESP_OPENCLAW_NODE_INTERNAL_READY) { + esp_openclaw_node_complete_disconnected( + node, + ESP_OPENCLAW_NODE_DISCONNECTED_REASON_CONNECTION_LOST, + message->local_err, + false); + return; + } + + if (esp_openclaw_node_state_is_connecting(state)) { + esp_openclaw_node_complete_connect_failed( + node, + ESP_OPENCLAW_NODE_CONNECT_FAILURE_CONNECTION_LOST, + message->local_err, + NULL, + false); + } +} + +static void handle_ws_error( + esp_openclaw_node_handle_t node, + const esp_openclaw_node_work_message_t *message) +{ + esp_openclaw_node_lock_state(node); + bool current_generation = + node->active_transport_generation == message->generation; + esp_openclaw_node_unlock_state(node); + if (!current_generation) { + return; + } + + ESP_LOGW( + ESP_OPENCLAW_NODE_TAG, + "websocket error: gen=%" PRIu32 " state=%s err=%s", + message->generation, + esp_openclaw_node_internal_state_name(node->state), + esp_err_to_name(message->local_err)); +} + +static void handle_shutdown_request(esp_openclaw_node_handle_t node) +{ + esp_openclaw_node_cleanup_transport_instance(node, true); + esp_openclaw_node_drain_work_queue(node); + + esp_openclaw_node_lock_state(node); + esp_openclaw_node_clear_data_buffer_locked(node); + esp_openclaw_node_clear_pending_control_locked(node); + node->state = ESP_OPENCLAW_NODE_INTERNAL_CLOSED; + esp_openclaw_node_unlock_state(node); +} + +static void exit_node_task(esp_openclaw_node_handle_t node) +{ + SemaphoreHandle_t destroy_done = NULL; + + esp_openclaw_node_lock_state(node); + node->task_handle = NULL; + destroy_done = node->destroy_done; + esp_openclaw_node_unlock_state(node); + + if (destroy_done != NULL) { + xSemaphoreGive(destroy_done); + } + vTaskDelete(NULL); +} + +static bool process_work_message( + esp_openclaw_node_handle_t node, + esp_openclaw_node_work_message_t *message) +{ + switch (message->type) { + case ESP_OPENCLAW_NODE_WORK_MSG_REQUEST_CONNECT: + handle_request_connect(node, message); + break; + case ESP_OPENCLAW_NODE_WORK_MSG_REQUEST_DISCONNECT: + handle_request_disconnect(node); + break; + case ESP_OPENCLAW_NODE_WORK_MSG_WS_CONNECTED: + handle_ws_connected(node, message); + break; + case ESP_OPENCLAW_NODE_WORK_MSG_WS_DISCONNECTED: + handle_ws_disconnected(node, message); + break; + case ESP_OPENCLAW_NODE_WORK_MSG_WS_ERROR: + handle_ws_error(node, message); + break; + case ESP_OPENCLAW_NODE_WORK_MSG_DATA: { + esp_openclaw_node_lock_state(node); + bool current_generation = + node->active_transport_generation == message->generation && + node->state != ESP_OPENCLAW_NODE_INTERNAL_DESTROYING && + node->state != ESP_OPENCLAW_NODE_INTERNAL_CLOSED; + esp_openclaw_node_unlock_state(node); + if (current_generation && message->text != NULL) { + esp_openclaw_node_process_gateway_message(node, message->text); + } + break; + } + case ESP_OPENCLAW_NODE_WORK_MSG_SHUTDOWN: + handle_shutdown_request(node); + return false; + default: + break; + } + return true; +} + +void esp_openclaw_node_task(void *arg) +{ + esp_openclaw_node_handle_t node = (esp_openclaw_node_handle_t)arg; + + for (;;) { + esp_openclaw_node_work_message_t message = {0}; + if (xQueueReceive(node->work_queue, &message, ESP_OPENCLAW_NODE_TASK_POLL_TICKS) != pdTRUE) { + esp_openclaw_node_fail_if_connect_timed_out(node); + continue; + } + + do { + bool keep_running = process_work_message(node, &message); + esp_openclaw_node_free_work_message_payload(&message); + if (!keep_running) { + exit_node_task(node); + return; + } + memset(&message, 0, sizeof(message)); + } while (xQueueReceive(node->work_queue, &message, 0) == pdTRUE); + } +} + +static void rollback_pending_control_locked( + esp_openclaw_node_handle_t node, + esp_openclaw_node_pending_control_request_t expected_request, + esp_openclaw_node_pending_control_request_t rollback_op) +{ + if (node->pending_control == expected_request) { + node->pending_control = rollback_op; + } +} + +static esp_err_t submit_pending_request( + esp_openclaw_node_handle_t node, + esp_openclaw_node_work_message_t *message, + esp_openclaw_node_pending_control_request_t expected_request, + esp_openclaw_node_pending_control_request_t rollback_op) +{ + esp_err_t err = esp_openclaw_node_enqueue_work_message(node, message); + if (err != ESP_OK) { + esp_openclaw_node_lock_state(node); + rollback_pending_control_locked( + node, + expected_request, + rollback_op); + esp_openclaw_node_unlock_state(node); + } + return err; +} + +esp_err_t esp_openclaw_node_submit_connect_request( + esp_openclaw_node_handle_t node, + esp_openclaw_node_connect_request_source_t *connect_source) +{ + esp_openclaw_node_lock_state(node); + esp_err_t err = + esp_openclaw_node_reserve_connect_request_locked(node, connect_source); + esp_openclaw_node_unlock_state(node); + if (err != ESP_OK) { + return err; + } + + esp_openclaw_node_work_message_t message = { + .type = ESP_OPENCLAW_NODE_WORK_MSG_REQUEST_CONNECT, + .connect_source = *connect_source, + }; + memset(connect_source, 0, sizeof(*connect_source)); + + err = submit_pending_request( + node, + &message, + ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_CONNECT, + ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_NONE); + if (err != ESP_OK) { + return err; + } + return ESP_OK; +} + +static esp_err_t reserve_disconnect_request_locked(esp_openclaw_node_handle_t node) +{ + if (node->state == ESP_OPENCLAW_NODE_INTERNAL_DESTROYING || + node->state == ESP_OPENCLAW_NODE_INTERNAL_CLOSED) { + return ESP_ERR_INVALID_STATE; + } + + if (node->state != ESP_OPENCLAW_NODE_INTERNAL_READY) { + return ESP_ERR_INVALID_STATE; + } + if (node->pending_control != ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_NONE) { + return ESP_ERR_INVALID_STATE; + } + + esp_openclaw_node_set_pending_control_locked( + node, + ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_DISCONNECT); + return ESP_OK; +} + +esp_err_t esp_openclaw_node_submit_disconnect_request(esp_openclaw_node_handle_t node) +{ + esp_openclaw_node_lock_state(node); + esp_err_t err = reserve_disconnect_request_locked(node); + esp_openclaw_node_unlock_state(node); + if (err != ESP_OK) { + return err; + } + + esp_openclaw_node_work_message_t message = { + .type = ESP_OPENCLAW_NODE_WORK_MSG_REQUEST_DISCONNECT, + }; + err = submit_pending_request( + node, + &message, + ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_DISCONNECT, + ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_NONE); + if (err != ESP_OK) { + return err; + } + return ESP_OK; +} diff --git a/components/esp-openclaw-node/src/esp_openclaw_node_transport.c b/components/esp-openclaw-node/src/esp_openclaw_node_transport.c new file mode 100644 index 0000000..83a74d2 --- /dev/null +++ b/components/esp-openclaw-node/src/esp_openclaw_node_transport.c @@ -0,0 +1,397 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "esp_openclaw_node_internal.h" + +#include +#include +#include + +#if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE +#include "esp_crt_bundle.h" +#endif +#include "esp_check.h" +#include "esp_log.h" + +static void websocket_event_handler( + void *handler_args, + esp_event_base_t base, + int32_t event_id, + void *event_data); + +bool esp_openclaw_node_should_accept_callback_generation_locked( + esp_openclaw_node_handle_t node, + uint32_t generation) +{ + return node->state != ESP_OPENCLAW_NODE_INTERNAL_DESTROYING && + node->state != ESP_OPENCLAW_NODE_INTERNAL_CLOSED && + node->active_transport_generation == generation && + generation != 0; +} + +esp_err_t esp_openclaw_node_validate_tls_preflight( + const esp_openclaw_node_config_t *config, + const char *gateway_uri) +{ + if (gateway_uri != NULL && strncmp(gateway_uri, "wss://", 6) == 0) { + if (config->tls_cert_pem == NULL && !config->use_cert_bundle) { + return ESP_ERR_INVALID_STATE; + } + } + return ESP_OK; +} + +static void clear_active_transport_fields_locked(esp_openclaw_node_handle_t node) +{ + node->ws = NULL; + node->transport_connected = false; + node->ws_started = false; + node->active_transport_generation = 0; + esp_openclaw_node_clear_session_wait_state_locked(node); + esp_openclaw_node_clear_connect_source_struct(&node->active_connect_source); +} + +void esp_openclaw_node_cleanup_transport_instance( + esp_openclaw_node_handle_t node, + bool stop_client) +{ + esp_websocket_client_handle_t ws = NULL; + esp_openclaw_node_transport_event_ctx_t *transport_ctx = NULL; + char *gateway_uri = NULL; + bool ws_started = false; + + esp_openclaw_node_lock_state(node); + ws = node->ws; + transport_ctx = node->transport_ctx; + gateway_uri = node->transport_gateway_uri; + ws_started = node->ws_started; + node->transport_ctx = NULL; + node->transport_gateway_uri = NULL; + clear_active_transport_fields_locked(node); + esp_openclaw_node_unlock_state(node); + + if (ws != NULL) { + if (stop_client && ws_started) { + node->transport_ops->client_stop(ws); + } + node->transport_ops->client_destroy(ws); + } + + free(transport_ctx); + free(gateway_uri); + + esp_openclaw_node_lock_state(node); + esp_openclaw_node_clear_data_buffer_locked(node); + esp_openclaw_node_unlock_state(node); +} + +esp_err_t esp_openclaw_node_start_transport_for_active_source( + esp_openclaw_node_handle_t node) +{ + const char *gateway_uri = NULL; + switch (node->active_connect_source.kind) { + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SAVED_SESSION: + gateway_uri = + esp_openclaw_node_trimmed_or_null(node->persisted_session.gateway_uri); + break; + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_BOOTSTRAP_TOKEN: + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SHARED_TOKEN: + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_PASSWORD: + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NO_AUTH: + gateway_uri = + esp_openclaw_node_trimmed_or_null(node->active_connect_source.gateway_uri); + break; + case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NONE: + default: + return ESP_ERR_INVALID_STATE; + } + + if (gateway_uri == NULL) { + return ESP_ERR_INVALID_STATE; + } + ESP_RETURN_ON_ERROR( + esp_openclaw_node_validate_tls_preflight(&node->config, gateway_uri), + ESP_OPENCLAW_NODE_TAG, + "TLS preflight failed"); + + char *gateway_uri_copy = esp_openclaw_node_duplicate_string(gateway_uri); + if (gateway_uri_copy == NULL) { + return ESP_ERR_NO_MEM; + } + + esp_openclaw_node_transport_event_ctx_t *transport_ctx = + calloc(1, sizeof(*transport_ctx)); + if (transport_ctx == NULL) { + free(gateway_uri_copy); + return ESP_ERR_NO_MEM; + } + + esp_websocket_client_config_t ws_config = { + .uri = gateway_uri_copy, + .disable_auto_reconnect = true, + .enable_close_reconnect = false, + .network_timeout_ms = 10000, + .ping_interval_sec = ESP_OPENCLAW_NODE_WS_PING_INTERVAL_SEC, + .pingpong_timeout_sec = ESP_OPENCLAW_NODE_WS_PINGPONG_TIMEOUT_SEC, + .keep_alive_enable = true, + .keep_alive_idle = 5, + .keep_alive_interval = 5, + .keep_alive_count = 3, + .task_prio = 5, + .task_stack = ESP_OPENCLAW_NODE_TRANSPORT_TASK_STACK_SIZE, + .buffer_size = ESP_OPENCLAW_NODE_TRANSPORT_BUFFER_SIZE, + .user_context = transport_ctx, + }; + if (strncmp(gateway_uri_copy, "wss://", 6) == 0) { + if (node->config.tls_cert_pem != NULL) { + ws_config.cert_pem = node->config.tls_cert_pem; + ws_config.cert_len = node->config.tls_cert_len; + } else if (node->config.use_cert_bundle) { +#if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE + ws_config.crt_bundle_attach = esp_crt_bundle_attach; +#else + free(gateway_uri_copy); + free(transport_ctx); + return ESP_ERR_INVALID_STATE; +#endif + } + if (node->config.tls_common_name != NULL && + node->config.tls_common_name[0] != '\0') { + ws_config.cert_common_name = node->config.tls_common_name; + } + ws_config.skip_cert_common_name_check = + node->config.skip_cert_common_name_check; + } + + esp_websocket_client_handle_t ws = node->transport_ops->client_init(&ws_config); + if (ws == NULL) { + free(gateway_uri_copy); + free(transport_ctx); + return ESP_FAIL; + } + + uint32_t generation = 0; + esp_openclaw_node_lock_state(node); + generation = ++node->next_transport_generation; + transport_ctx->node = node; + transport_ctx->generation = generation; + node->transport_ctx = transport_ctx; + node->transport_gateway_uri = gateway_uri_copy; + node->active_transport_generation = generation; + node->ws = ws; + node->ws_started = false; + node->transport_connected = false; + esp_openclaw_node_clear_session_wait_state_locked(node); + esp_openclaw_node_unlock_state(node); + + esp_err_t err = node->transport_ops->register_events( + ws, + WEBSOCKET_EVENT_ANY, + websocket_event_handler, + transport_ctx); + if (err != ESP_OK) { + esp_openclaw_node_cleanup_transport_instance(node, false); + return err; + } + + err = node->transport_ops->client_start(ws); + if (err != ESP_OK) { + esp_openclaw_node_cleanup_transport_instance(node, false); + return err; + } + + esp_openclaw_node_lock_state(node); + node->ws_started = true; + esp_openclaw_node_unlock_state(node); + + ESP_LOGI( + ESP_OPENCLAW_NODE_TAG, + "node connect attempt started for %s", + gateway_uri_copy); + return ESP_OK; +} + +void esp_openclaw_node_send_challenge_kick_ping(esp_openclaw_node_handle_t node) +{ + esp_websocket_client_handle_t ws = NULL; + + /* + * Some ESP-IDF websocket runtimes do not drain the first post-upgrade text + * frame until the client transmits something. A zero-length ping forces the + * read loop to wake and flush the already-buffered connect.challenge frame. + */ + esp_openclaw_node_lock_state(node); + ws = node->ws; + esp_openclaw_node_unlock_state(node); + + if (ws == NULL) { + return; + } + + int written = node->transport_ops->send_with_opcode( + ws, + WS_TRANSPORT_OPCODES_PING, + NULL, + 0, + pdMS_TO_TICKS(1000)); + if (written < 0) { + ESP_LOGW( + ESP_OPENCLAW_NODE_TAG, + "failed sending websocket ping to prompt connect.challenge delivery"); + } +} + +static esp_err_t local_err_from_ws_event( + const esp_websocket_event_data_t *data) +{ + if (data == NULL) { + return ESP_OK; + } + if (data->error_handle.esp_tls_last_esp_err != 0) { + return data->error_handle.esp_tls_last_esp_err; + } + if (data->error_handle.esp_transport_sock_errno != 0) { + return data->error_handle.esp_transport_sock_errno; + } + return ESP_OK; +} + +static void websocket_event_handler( + void *handler_args, + esp_event_base_t base, + int32_t event_id, + void *event_data) +{ + (void)base; + + esp_openclaw_node_transport_event_ctx_t *transport_ctx = + (esp_openclaw_node_transport_event_ctx_t *)handler_args; + if (transport_ctx == NULL || transport_ctx->node == NULL) { + return; + } + + esp_openclaw_node_handle_t node = transport_ctx->node; + esp_websocket_event_data_t *data = + (esp_websocket_event_data_t *)event_data; + + esp_openclaw_node_lock_state(node); + bool accept = esp_openclaw_node_should_accept_callback_generation_locked( + node, + transport_ctx->generation); + esp_openclaw_node_internal_state_t state = node->state; + esp_openclaw_node_unlock_state(node); + if (!accept) { + return; + } + + switch (event_id) { + case WEBSOCKET_EVENT_CONNECTED: { + esp_openclaw_node_work_message_t message = { + .type = ESP_OPENCLAW_NODE_WORK_MSG_WS_CONNECTED, + .generation = transport_ctx->generation, + .local_err = ESP_OK, + }; + esp_openclaw_node_enqueue_work_message_from_callback(node, &message); + break; + } + case WEBSOCKET_EVENT_DISCONNECTED: + case WEBSOCKET_EVENT_CLOSED: { + esp_openclaw_node_work_message_t message = { + .type = ESP_OPENCLAW_NODE_WORK_MSG_WS_DISCONNECTED, + .generation = transport_ctx->generation, + .local_err = local_err_from_ws_event(data), + }; + esp_openclaw_node_enqueue_work_message_from_callback(node, &message); + break; + } + case WEBSOCKET_EVENT_ERROR: { + esp_openclaw_node_work_message_t message = { + .type = ESP_OPENCLAW_NODE_WORK_MSG_WS_ERROR, + .generation = transport_ctx->generation, + .local_err = local_err_from_ws_event(data), + }; + esp_openclaw_node_enqueue_work_message_from_callback(node, &message); + break; + } + case WEBSOCKET_EVENT_DATA: + if (esp_openclaw_node_state_is_connecting(state)) { + ESP_LOGD( + ESP_OPENCLAW_NODE_TAG, + "ws frame during connect: gen=%" PRIu32 " opcode=0x%x fin=%d data_len=%d payload_len=%d offset=%d state=%s", + transport_ctx->generation, + data != NULL ? data->op_code : 0, + data != NULL ? data->fin : 0, + data != NULL ? data->data_len : 0, + data != NULL ? data->payload_len : 0, + data != NULL ? data->payload_offset : 0, + esp_openclaw_node_internal_state_name(state)); + } + if (data->op_code == 0x08 || data->op_code == 0x09 || + data->op_code == 0x0a) { + break; + } + if (data->op_code != 0x01 && data->op_code != 0x00) { + ESP_LOGW( + ESP_OPENCLAW_NODE_TAG, + "ignoring unsupported websocket opcode=0x%x", + data->op_code); + break; + } + + esp_openclaw_node_lock_state(node); + if (!esp_openclaw_node_should_accept_callback_generation_locked( + node, + transport_ctx->generation)) { + esp_openclaw_node_unlock_state(node); + break; + } + if (data->payload_offset == 0) { + esp_openclaw_node_clear_data_buffer_locked(node); + node->rx_buffer = + calloc((size_t)data->payload_len + 1U, sizeof(char)); + node->rx_buffer_len = (size_t)data->payload_len; + if (node->rx_buffer == NULL) { + node->rx_buffer_len = 0; + esp_openclaw_node_unlock_state(node); + ESP_LOGE(ESP_OPENCLAW_NODE_TAG, "failed allocating rx buffer"); + break; + } + } + if (node->rx_buffer == NULL || + node->rx_buffer_len < (size_t)data->payload_len || + ((size_t)data->payload_offset + (size_t)data->data_len) > + node->rx_buffer_len) { + esp_openclaw_node_clear_data_buffer_locked(node); + esp_openclaw_node_unlock_state(node); + ESP_LOGW( + ESP_OPENCLAW_NODE_TAG, + "discarding malformed fragmented websocket payload"); + break; + } + memcpy( + node->rx_buffer + data->payload_offset, + data->data_ptr, + (size_t)data->data_len); + if (data->payload_offset + data->data_len >= data->payload_len && + data->fin) { + node->rx_buffer[data->payload_len] = '\0'; + esp_openclaw_node_work_message_t message = { + .type = ESP_OPENCLAW_NODE_WORK_MSG_DATA, + .generation = transport_ctx->generation, + .text = node->rx_buffer, + }; + node->rx_buffer = NULL; + node->rx_buffer_len = 0; + esp_openclaw_node_unlock_state(node); + esp_openclaw_node_enqueue_work_message_from_callback(node, &message); + } else { + esp_openclaw_node_unlock_state(node); + } + break; + default: + break; + } +} diff --git a/components/esp-openclaw-node/test_apps/README.md b/components/esp-openclaw-node/test_apps/README.md new file mode 100644 index 0000000..52c908b --- /dev/null +++ b/components/esp-openclaw-node/test_apps/README.md @@ -0,0 +1,7 @@ +# esp-openclaw-node test apps + +Available test apps: + +- [`esp_openclaw_node_unity_tests`](./esp_openclaw_node_unity_tests/README.md): on-device + Unity tests for persisted reconnect-session storage, identity persistence, + connect-request validation, and transport-state edge cases diff --git a/components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests/CMakeLists.txt b/components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests/CMakeLists.txt new file mode 100644 index 0000000..dc373d7 --- /dev/null +++ b/components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS ../../) +set(COMPONENTS main) + +list(APPEND SDKCONFIG_DEFAULTS "$ENV{IDF_PATH}/tools/test_apps/configs/sdkconfig.debug_helpers") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(esp_openclaw_node_unity_tests) diff --git a/components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests/README.md b/components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests/README.md new file mode 100644 index 0000000..140e5dd --- /dev/null +++ b/components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests/README.md @@ -0,0 +1,57 @@ +# esp_openclaw_node_unity_tests + +This is the ESP-IDF Unity test app for the `esp-openclaw-node` component. + +Files: + +- `CMakeLists.txt`: test app project file +- `main/test_esp_openclaw_node.c`: test cases + +## What these tests do + +The test app runs on the target and executes `unity_run_all_tests()` from +`app_main()`. + +Each test case resets NVS first. The suite focuses on component logic that does +not require a live OpenClaw gateway. + +## Current coverage + +- persisted reconnect-session validation, persistence, reload, and clearing +- identity seed persistence and auth-payload signing +- node-config ownership for runtime-provided TLS PEM data +- setup-code validation for malformed or ambiguous payloads +- explicit connect-request argument validation for saved-session, token, + password, and no-auth sources +- auth-material selection, including the password-signature exclusion rule +- destroy-path notification safety +- transport-state edge cases around challenge ping, clean close, and disconnect + rejection while still connecting + +## Not covered here + +The following still need integration or end-to-end tests: + +- WebSocket handshake behavior against a live gateway +- end-to-end event delivery for `CONNECTED`, `CONNECT_FAILED`, and + `DISCONNECTED` +- persistence of `{ gateway_uri, device_token }` after a real `hello-ok` +- example-application REPL flows and Wi-Fi recovery behavior + +## Build and run + +Example for `esp32s3`: + +```bash +idf.py -C components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests set-target esp32s3 +idf.py -C components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests build +idf.py -C components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests -p /dev/ttyACM0 flash monitor +``` + +The suite runs automatically at boot. A passing run ends with a summary like: + +```text +----------------------- +18 Tests 0 Failures 0 Ignored +OK +``` diff --git a/components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests/main/CMakeLists.txt b/components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests/main/CMakeLists.txt new file mode 100644 index 0000000..569d970 --- /dev/null +++ b/components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests/main/CMakeLists.txt @@ -0,0 +1,12 @@ +idf_component_register( + SRCS "test_esp_openclaw_node.c" + INCLUDE_DIRS "." + REQUIRES + mbedtls + nvs_flash + esp-openclaw-node + unity +) + +idf_component_get_property(esp_openclaw_node_dir esp-openclaw-node COMPONENT_DIR) +target_include_directories(${COMPONENT_LIB} PRIVATE "${esp_openclaw_node_dir}/private_include") diff --git a/components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests/main/test_esp_openclaw_node.c b/components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests/main/test_esp_openclaw_node.c new file mode 100644 index 0000000..4a173aa --- /dev/null +++ b/components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests/main/test_esp_openclaw_node.c @@ -0,0 +1,1248 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include +#include +#include + +#include "esp_event.h" +#include "esp_websocket_client.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "mbedtls/base64.h" +#include "nvs.h" +#include "nvs_flash.h" +#include "esp_openclaw_node_identity.h" +#include "esp_openclaw_node_internal.h" +#include "esp_openclaw_node.h" +#include "esp_openclaw_node_persisted_session.h" +#include "unity.h" + +typedef struct { + volatile bool seen; + volatile esp_openclaw_node_event_t event; + volatile esp_err_t local_err; + volatile int connected_count; + volatile int connect_failed_count; + volatile int disconnected_count; + volatile esp_openclaw_node_connect_failure_reason_t connect_failed_reason; + volatile esp_openclaw_node_disconnected_reason_t disconnected_reason; +} test_event_recorder_t; + +typedef struct { + esp_event_handler_t event_handler; + void *event_handler_arg; + esp_websocket_client_handle_t client; + volatile int start_calls; + volatile int stop_calls; + volatile int destroy_calls; + volatile int send_text_calls; + volatile int send_with_opcode_calls; + volatile ws_transport_opcodes_t last_opcode; + char *last_sent_text; + SemaphoreHandle_t start_entered; + SemaphoreHandle_t start_release; + esp_err_t start_result; + SemaphoreHandle_t send_text_entered; + SemaphoreHandle_t send_text_release; +} test_transport_state_t; + +typedef struct { + SemaphoreHandle_t entered; + SemaphoreHandle_t release; +} blocking_command_ctx_t; + +typedef struct { + esp_openclaw_node_handle_t node; + TaskHandle_t completion_waiter; + volatile esp_err_t destroy_err; + volatile uint32_t remaining_notify_count; +} destroy_task_ctx_t; + +static test_event_recorder_t s_event_recorder; +static test_transport_state_t s_transport_state; +static int s_fake_transport_client; + +static esp_websocket_client_handle_t test_transport_client_init(const esp_websocket_client_config_t *config) +{ + (void)config; + s_transport_state.client = (esp_websocket_client_handle_t)&s_fake_transport_client; + return s_transport_state.client; +} + +static esp_err_t test_transport_register_events( + esp_websocket_client_handle_t client, + esp_websocket_event_id_t event, + esp_event_handler_t event_handler, + void *event_handler_arg) +{ + (void)client; + (void)event; + s_transport_state.event_handler = event_handler; + s_transport_state.event_handler_arg = event_handler_arg; + return ESP_OK; +} + +static esp_err_t test_transport_client_start(esp_websocket_client_handle_t client) +{ + (void)client; + s_transport_state.start_calls++; + if (s_transport_state.start_entered != NULL) { + TEST_ASSERT_TRUE(xSemaphoreGive(s_transport_state.start_entered) == pdTRUE); + } + if (s_transport_state.start_release != NULL) { + TEST_ASSERT_TRUE(xSemaphoreTake(s_transport_state.start_release, portMAX_DELAY) == pdTRUE); + } + return s_transport_state.start_result; +} + +static esp_err_t test_transport_client_stop(esp_websocket_client_handle_t client) +{ + (void)client; + s_transport_state.stop_calls++; + return ESP_OK; +} + +static esp_err_t test_transport_client_destroy(esp_websocket_client_handle_t client) +{ + (void)client; + s_transport_state.destroy_calls++; + return ESP_OK; +} + +static int test_transport_send_text( + esp_websocket_client_handle_t client, + const char *data, + int len, + TickType_t timeout) +{ + (void)client; + (void)timeout; + free(s_transport_state.last_sent_text); + s_transport_state.last_sent_text = calloc((size_t)len + 1U, sizeof(char)); + TEST_ASSERT_NOT_NULL(s_transport_state.last_sent_text); + memcpy(s_transport_state.last_sent_text, data, (size_t)len); + s_transport_state.send_text_calls++; + if (s_transport_state.send_text_entered != NULL) { + TEST_ASSERT_TRUE(xSemaphoreGive(s_transport_state.send_text_entered) == pdTRUE); + } + if (s_transport_state.send_text_release != NULL) { + TEST_ASSERT_TRUE(xSemaphoreTake(s_transport_state.send_text_release, portMAX_DELAY) == pdTRUE); + } + return len; +} + +static int test_transport_send_with_opcode( + esp_websocket_client_handle_t client, + ws_transport_opcodes_t opcode, + const uint8_t *data, + int len, + TickType_t timeout) +{ + (void)client; + (void)data; + (void)len; + (void)timeout; + s_transport_state.last_opcode = opcode; + s_transport_state.send_with_opcode_calls++; + return 0; +} + +static const esp_openclaw_node_transport_ops_t s_test_transport_ops = { + .client_init = test_transport_client_init, + .register_events = test_transport_register_events, + .client_start = test_transport_client_start, + .client_stop = test_transport_client_stop, + .client_destroy = test_transport_client_destroy, + .send_text = test_transport_send_text, + .send_with_opcode = test_transport_send_with_opcode, +}; + +const esp_openclaw_node_transport_ops_t *esp_openclaw_node_test_transport_ops(void) +{ + return &s_test_transport_ops; +} + +static void reset_openclaw_storage(void) +{ + nvs_flash_deinit(); + TEST_ASSERT_EQUAL(ESP_OK, nvs_flash_erase()); + TEST_ASSERT_EQUAL(ESP_OK, nvs_flash_init()); +} + +static char *encode_base64url_string(const char *input) +{ + size_t input_len = strlen(input); + size_t encoded_capacity = (((input_len + 2U) / 3U) * 4U) + 1U; + unsigned char *encoded = calloc(encoded_capacity, sizeof(unsigned char)); + TEST_ASSERT_NOT_NULL(encoded); + + size_t written = 0; + TEST_ASSERT_EQUAL_INT( + 0, + mbedtls_base64_encode(encoded, encoded_capacity, &written, (const unsigned char *)input, input_len)); + + for (size_t i = 0; i < written; ++i) { + if (encoded[i] == '+') { + encoded[i] = '-'; + } else if (encoded[i] == '/') { + encoded[i] = '_'; + } + } + while (written > 0 && encoded[written - 1] == '=') { + --written; + } + encoded[written] = '\0'; + return (char *)encoded; +} + +static esp_err_t request_connect_saved_session(esp_openclaw_node_handle_t node) +{ + const esp_openclaw_node_connect_request_t request = { + .source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION, + .gateway_uri = NULL, + .value = NULL, + }; + return esp_openclaw_node_request_connect(node, &request); +} + +static esp_err_t request_connect_setup_code(esp_openclaw_node_handle_t node, const char *setup_code) +{ + const esp_openclaw_node_connect_request_t request = { + .source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE, + .gateway_uri = NULL, + .value = setup_code, + }; + return esp_openclaw_node_request_connect(node, &request); +} + +static esp_err_t request_connect_gateway_token( + esp_openclaw_node_handle_t node, + const char *gateway_uri, + const char *token) +{ + const esp_openclaw_node_connect_request_t request = { + .source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_TOKEN, + .gateway_uri = gateway_uri, + .value = token, + }; + return esp_openclaw_node_request_connect(node, &request); +} + +static esp_err_t request_connect_gateway_password( + esp_openclaw_node_handle_t node, + const char *gateway_uri, + const char *password) +{ + const esp_openclaw_node_connect_request_t request = { + .source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_PASSWORD, + .gateway_uri = gateway_uri, + .value = password, + }; + return esp_openclaw_node_request_connect(node, &request); +} + +static esp_err_t request_connect_no_auth(esp_openclaw_node_handle_t node, const char *gateway_uri) +{ + const esp_openclaw_node_connect_request_t request = { + .source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_NO_AUTH, + .gateway_uri = gateway_uri, + .value = NULL, + }; + return esp_openclaw_node_request_connect(node, &request); +} + +static void assert_persisted_session_empty(const esp_openclaw_node_persisted_session_t *session) +{ + TEST_ASSERT_NOT_NULL(session); + TEST_ASSERT_EQUAL_UINT8(0, session->version); + TEST_ASSERT_NULL(session->gateway_uri); + TEST_ASSERT_NULL(session->device_token); + TEST_ASSERT_FALSE(esp_openclaw_node_persisted_session_is_present(session)); +} + +static bool contains_only_base64url_chars(const char *value) +{ + if (value == NULL || value[0] == '\0') { + return false; + } + for (const char *p = value; *p != '\0'; ++p) { + bool is_alpha_num = (*p >= 'A' && *p <= 'Z') || (*p >= 'a' && *p <= 'z') || + (*p >= '0' && *p <= '9'); + if (!is_alpha_num && *p != '-' && *p != '_') { + return false; + } + } + return true; +} + +static void reset_event_recorder(void) +{ + memset((void *)&s_event_recorder, 0, sizeof(s_event_recorder)); +} + +static void reset_transport_state(void) +{ + free(s_transport_state.last_sent_text); + memset(&s_transport_state, 0, sizeof(s_transport_state)); +} + +static void test_node_event_cb( + esp_openclaw_node_handle_t node, + esp_openclaw_node_event_t event, + const void *event_data, + void *user_ctx) +{ + (void)node; + (void)user_ctx; + + s_event_recorder.event = event; + s_event_recorder.local_err = ESP_OK; + if (event == ESP_OPENCLAW_NODE_EVENT_CONNECTED) { + s_event_recorder.connected_count++; + } else if (event == ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED) { + s_event_recorder.connect_failed_count++; + const esp_openclaw_node_connect_failed_event_t *failed = event_data; + if (failed != NULL) { + s_event_recorder.local_err = failed->local_err; + s_event_recorder.connect_failed_reason = failed->reason; + } + } else if (event == ESP_OPENCLAW_NODE_EVENT_DISCONNECTED) { + s_event_recorder.disconnected_count++; + const esp_openclaw_node_disconnected_event_t *disconnected = event_data; + if (disconnected != NULL) { + s_event_recorder.local_err = disconnected->local_err; + s_event_recorder.disconnected_reason = disconnected->reason; + } + } + s_event_recorder.seen = true; +} + +static bool wait_for_event(esp_openclaw_node_event_t expected, TickType_t timeout_ticks) +{ + TickType_t start = xTaskGetTickCount(); + while ((xTaskGetTickCount() - start) < timeout_ticks) { + if (s_event_recorder.seen && s_event_recorder.event == expected) { + return true; + } + vTaskDelay(pdMS_TO_TICKS(10)); + } + return false; +} + +static bool wait_for_int_value(volatile int *value, int expected_minimum, TickType_t timeout_ticks) +{ + TickType_t start = xTaskGetTickCount(); + while ((xTaskGetTickCount() - start) < timeout_ticks) { + if (*value >= expected_minimum) { + return true; + } + vTaskDelay(pdMS_TO_TICKS(10)); + } + return false; +} + +static void emit_ws_event(int32_t event_id, const char *text, esp_err_t local_err) +{ + TEST_ASSERT_NOT_NULL(s_transport_state.event_handler); + + esp_websocket_event_data_t event_data = {0}; + if (text != NULL) { + event_data.op_code = 0x01; + event_data.fin = true; + event_data.data_ptr = (char *)text; + event_data.data_len = (int)strlen(text); + event_data.payload_len = (int)strlen(text); + event_data.payload_offset = 0; + } + event_data.error_handle.esp_tls_last_esp_err = local_err; + s_transport_state.event_handler( + s_transport_state.event_handler_arg, + WEBSOCKET_EVENTS, + event_id, + &event_data); +} + +static char *extract_first_json_id(const char *json) +{ + const char *marker = "\"id\":\""; + const char *start = strstr(json, marker); + TEST_ASSERT_NOT_NULL(start); + start += strlen(marker); + const char *end = strchr(start, '"'); + TEST_ASSERT_NOT_NULL(end); + size_t len = (size_t)(end - start); + char *id = calloc(len + 1U, sizeof(char)); + TEST_ASSERT_NOT_NULL(id); + memcpy(id, start, len); + return id; +} + +static esp_err_t blocking_command_handler( + esp_openclaw_node_handle_t node, + void *context, + const char *params_json, + size_t params_len, + char **out_payload_json, + esp_openclaw_node_error_t *out_error) +{ + (void)node; + (void)params_json; + (void)params_len; + (void)out_error; + + blocking_command_ctx_t *ctx = (blocking_command_ctx_t *)context; + TEST_ASSERT_NOT_NULL(ctx); + TEST_ASSERT_TRUE(xSemaphoreGive(ctx->entered) == pdTRUE); + TEST_ASSERT_TRUE(xSemaphoreTake(ctx->release, portMAX_DELAY) == pdTRUE); + + *out_payload_json = calloc(3, sizeof(char)); + TEST_ASSERT_NOT_NULL(*out_payload_json); + memcpy(*out_payload_json, "{}", 3); + return ESP_OK; +} + +static void destroy_with_pending_notification_task(void *arg) +{ + destroy_task_ctx_t *ctx = (destroy_task_ctx_t *)arg; + TEST_ASSERT_NOT_NULL(ctx); + + xTaskNotifyGive(xTaskGetCurrentTaskHandle()); + ctx->destroy_err = esp_openclaw_node_destroy(ctx->node); + ctx->remaining_notify_count = ulTaskNotifyTake(pdTRUE, 0); + xTaskNotifyGive(ctx->completion_waiter); + vTaskDelete(NULL); +} + +TEST_CASE("persisted session stores loads and can be cleared by storing empty state", "[esp_openclaw_node][session]") +{ + reset_openclaw_storage(); + + char gateway_uri[] = "wss://gateway.example/ws"; + char device_token[] = "device-token-123"; + esp_openclaw_node_persisted_session_t session = {0}; + esp_openclaw_node_persisted_session_t update = { + .version = 1, + .gateway_uri = gateway_uri, + .device_token = device_token, + }; + + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_persisted_session_store(&session, &update)); + TEST_ASSERT_TRUE(esp_openclaw_node_persisted_session_is_present(&session)); + TEST_ASSERT_EQUAL_STRING("wss://gateway.example/ws", session.gateway_uri); + TEST_ASSERT_EQUAL_STRING("device-token-123", session.device_token); + + esp_openclaw_node_persisted_session_t loaded = {0}; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_persisted_session_load(&loaded)); + TEST_ASSERT_TRUE(esp_openclaw_node_persisted_session_is_present(&loaded)); + TEST_ASSERT_EQUAL_STRING("wss://gateway.example/ws", loaded.gateway_uri); + TEST_ASSERT_EQUAL_STRING("device-token-123", loaded.device_token); + + esp_openclaw_node_persisted_session_t cleared = {0}; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_persisted_session_store(&session, &cleared)); + assert_persisted_session_empty(&session); + + esp_openclaw_node_persisted_session_free(&loaded); + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_persisted_session_load(&loaded)); + assert_persisted_session_empty(&loaded); + + esp_openclaw_node_persisted_session_free(&loaded); + esp_openclaw_node_persisted_session_free(&session); +} + +TEST_CASE("persisted session load ignores unsupported stored versions", "[esp_openclaw_node][session]") +{ + reset_openclaw_storage(); + + nvs_handle_t nvs = 0; + TEST_ASSERT_EQUAL(ESP_OK, nvs_open("openclaw", NVS_READWRITE, &nvs)); + TEST_ASSERT_EQUAL(ESP_OK, nvs_set_u8(nvs, "session_v", 99)); + TEST_ASSERT_EQUAL(ESP_OK, nvs_set_str(nvs, "session_uri", "wss://gateway.example/ws")); + TEST_ASSERT_EQUAL(ESP_OK, nvs_set_str(nvs, "session_dev_tok", "device-token-123")); + TEST_ASSERT_EQUAL(ESP_OK, nvs_commit(nvs)); + nvs_close(nvs); + + esp_openclaw_node_persisted_session_t loaded = {0}; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_persisted_session_load(&loaded)); + assert_persisted_session_empty(&loaded); + + uint8_t version = 0; + TEST_ASSERT_EQUAL(ESP_OK, nvs_open("openclaw", NVS_READONLY, &nvs)); + TEST_ASSERT_EQUAL(ESP_ERR_NVS_NOT_FOUND, nvs_get_u8(nvs, "session_v", &version)); + nvs_close(nvs); + + esp_openclaw_node_config_t config = {0}; + esp_openclaw_node_config_init_default(&config); + + esp_openclaw_node_handle_t node = NULL; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_create(&config, &node)); + TEST_ASSERT_NOT_NULL(node); + TEST_ASSERT_FALSE(esp_openclaw_node_has_saved_session(node)); + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_destroy(node)); +} + +TEST_CASE("persisted session load clears malformed stored state and node still boots", "[esp_openclaw_node][session]") +{ + reset_openclaw_storage(); + + nvs_handle_t nvs = 0; + TEST_ASSERT_EQUAL(ESP_OK, nvs_open("openclaw", NVS_READWRITE, &nvs)); + TEST_ASSERT_EQUAL(ESP_OK, nvs_set_u8(nvs, "session_v", 1)); + TEST_ASSERT_EQUAL(ESP_OK, nvs_set_str(nvs, "session_uri", "wss://gateway.example/ws")); + TEST_ASSERT_EQUAL(ESP_OK, nvs_commit(nvs)); + nvs_close(nvs); + + esp_openclaw_node_persisted_session_t loaded = {0}; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_persisted_session_load(&loaded)); + assert_persisted_session_empty(&loaded); + + uint8_t version = 0; + TEST_ASSERT_EQUAL(ESP_OK, nvs_open("openclaw", NVS_READONLY, &nvs)); + TEST_ASSERT_EQUAL(ESP_ERR_NVS_NOT_FOUND, nvs_get_u8(nvs, "session_v", &version)); + nvs_close(nvs); + + esp_openclaw_node_config_t config = {0}; + esp_openclaw_node_config_init_default(&config); + + esp_openclaw_node_handle_t node = NULL; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_create(&config, &node)); + TEST_ASSERT_NOT_NULL(node); + TEST_ASSERT_FALSE(esp_openclaw_node_has_saved_session(node)); + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_destroy(node)); +} + +TEST_CASE("node create deep-copies tls cert pem", "[esp_openclaw_node][config]") +{ + reset_openclaw_storage(); + + char tls_cert_pem[] = + "-----BEGIN CERTIFICATE-----\n" + "example-cert\n" + "-----END CERTIFICATE-----\n"; + + esp_openclaw_node_config_t config = {0}; + esp_openclaw_node_config_init_default(&config); + config.tls_cert_pem = tls_cert_pem; + config.tls_cert_len = 0; + config.use_cert_bundle = false; + + esp_openclaw_node_handle_t node = NULL; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_create(&config, &node)); + TEST_ASSERT_NOT_NULL(node); + TEST_ASSERT_NOT_NULL(node->config.tls_cert_pem); + TEST_ASSERT_TRUE(node->config.tls_cert_pem != config.tls_cert_pem); + TEST_ASSERT_EQUAL_STRING(tls_cert_pem, node->config.tls_cert_pem); + TEST_ASSERT_EQUAL_UINT32(0, (uint32_t)node->config.tls_cert_len); + + tls_cert_pem[0] = 'X'; + TEST_ASSERT_EQUAL_STRING( + "-----BEGIN CERTIFICATE-----\n" + "example-cert\n" + "-----END CERTIFICATE-----\n", + node->config.tls_cert_pem); + + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_destroy(node)); +} + +TEST_CASE("persisted session rejects partial state", "[esp_openclaw_node][session]") +{ + reset_openclaw_storage(); + + char gateway_uri[] = "wss://gateway.example/ws"; + char device_token[] = "device-token-123"; + esp_openclaw_node_persisted_session_t session = {0}; + esp_openclaw_node_persisted_session_t invalid = { + .version = 1, + .gateway_uri = gateway_uri, + .device_token = NULL, + }; + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, esp_openclaw_node_persisted_session_store(&session, &invalid)); + assert_persisted_session_empty(&session); + + invalid.gateway_uri = NULL; + invalid.device_token = device_token; + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, esp_openclaw_node_persisted_session_store(&session, &invalid)); + assert_persisted_session_empty(&session); + + esp_openclaw_node_persisted_session_t loaded = {0}; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_persisted_session_load(&loaded)); + assert_persisted_session_empty(&loaded); + esp_openclaw_node_persisted_session_free(&loaded); +} + +TEST_CASE("persisted session store validates inputs before mutating state", "[esp_openclaw_node][session]") +{ + reset_openclaw_storage(); + + char initial_gateway_uri[] = "wss://gateway.example/ws"; + char initial_device_token[] = "device-token-123"; + esp_openclaw_node_persisted_session_t session = {0}; + esp_openclaw_node_persisted_session_t initial = { + .version = 1, + .gateway_uri = initial_gateway_uri, + .device_token = initial_device_token, + }; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_persisted_session_store(&session, &initial)); + + char replacement_gateway_uri[] = "wss://replacement.example/ws"; + char replacement_device_token[] = "device-token-456"; + esp_openclaw_node_persisted_session_t replacement = { + .version = 1, + .gateway_uri = replacement_gateway_uri, + .device_token = replacement_device_token, + }; + + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, esp_openclaw_node_persisted_session_store(NULL, &replacement)); + TEST_ASSERT_EQUAL_STRING("wss://gateway.example/ws", session.gateway_uri); + TEST_ASSERT_EQUAL_STRING("device-token-123", session.device_token); + + esp_openclaw_node_persisted_session_t loaded = {0}; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_persisted_session_load(&loaded)); + TEST_ASSERT_EQUAL_STRING("wss://gateway.example/ws", loaded.gateway_uri); + TEST_ASSERT_EQUAL_STRING("device-token-123", loaded.device_token); + esp_openclaw_node_persisted_session_free(&loaded); + + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, esp_openclaw_node_persisted_session_store(&session, NULL)); + TEST_ASSERT_EQUAL_STRING("wss://gateway.example/ws", session.gateway_uri); + TEST_ASSERT_EQUAL_STRING("device-token-123", session.device_token); + + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_persisted_session_load(&loaded)); + TEST_ASSERT_EQUAL_STRING("wss://gateway.example/ws", loaded.gateway_uri); + TEST_ASSERT_EQUAL_STRING("device-token-123", loaded.device_token); + + esp_openclaw_node_persisted_session_free(&loaded); + esp_openclaw_node_persisted_session_free(&session); +} + +TEST_CASE("identity persists seed and auth payload signing", "[esp_openclaw_node][identity]") +{ + reset_openclaw_storage(); + + uint8_t seed[ESP_OPENCLAW_NODE_ED25519_SEED_LEN] = {0}; + for (size_t i = 0; i < sizeof(seed); ++i) { + seed[i] = (uint8_t)(i + 1U); + } + + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_identity_store_seed_if_absent(seed, sizeof(seed))); + + esp_openclaw_node_identity_t identity = {0}; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_identity_load_or_create(&identity)); + TEST_ASSERT_EQUAL_HEX8_ARRAY(seed, identity.seed, sizeof(seed)); + TEST_ASSERT_NOT_EQUAL('\0', identity.device_id[0]); + TEST_ASSERT_NOT_EQUAL('\0', identity.public_key_b64url[0]); + + char *payload = NULL; + TEST_ASSERT_EQUAL( + ESP_OK, + esp_openclaw_node_identity_build_auth_payload_v3( + &identity, + "node-host", + "node", + "node", + "device,wifi", + 123456, + "device-token-123", + "nonce-xyz", + " ESP32-S3 ", + " Sensor Hub ", + &payload)); + TEST_ASSERT_NOT_NULL(payload); + TEST_ASSERT_NOT_NULL(strstr(payload, "|esp32-s3|sensor hub")); + + char signature[ESP_OPENCLAW_NODE_SIGNATURE_B64_BUFFER_LEN] = {0}; + TEST_ASSERT_EQUAL( + ESP_OK, + esp_openclaw_node_identity_sign_payload(&identity, payload, signature, sizeof(signature))); + TEST_ASSERT_TRUE(contains_only_base64url_chars(signature)); + + esp_openclaw_node_identity_t reloaded = {0}; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_identity_load_or_create(&reloaded)); + TEST_ASSERT_EQUAL_HEX8_ARRAY(seed, reloaded.seed, sizeof(seed)); + TEST_ASSERT_EQUAL_STRING(identity.device_id, reloaded.device_id); + + free(payload); + esp_openclaw_node_identity_free(&reloaded); + esp_openclaw_node_identity_free(&identity); +} + +TEST_CASE("identity load recovers from malformed seed and clears saved session", "[esp_openclaw_node][identity][session]") +{ + reset_openclaw_storage(); + + uint8_t malformed_seed[] = {1, 2, 3, 4, 5, 6, 7}; + + nvs_handle_t nvs = 0; + TEST_ASSERT_EQUAL(ESP_OK, nvs_open("openclaw", NVS_READWRITE, &nvs)); + TEST_ASSERT_EQUAL(ESP_OK, nvs_set_blob(nvs, "device_seed", malformed_seed, sizeof(malformed_seed))); + TEST_ASSERT_EQUAL(ESP_OK, nvs_set_u8(nvs, "session_v", 1)); + TEST_ASSERT_EQUAL(ESP_OK, nvs_set_str(nvs, "session_uri", "wss://gateway.example/ws")); + TEST_ASSERT_EQUAL(ESP_OK, nvs_set_str(nvs, "session_dev_tok", "device-token-123")); + TEST_ASSERT_EQUAL(ESP_OK, nvs_commit(nvs)); + nvs_close(nvs); + + esp_openclaw_node_identity_t identity = {0}; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_identity_load_or_create(&identity)); + TEST_ASSERT_NOT_EQUAL('\0', identity.device_id[0]); + TEST_ASSERT_NOT_EQUAL('\0', identity.public_key_b64url[0]); + + esp_openclaw_node_persisted_session_t loaded_session = {0}; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_persisted_session_load(&loaded_session)); + assert_persisted_session_empty(&loaded_session); + esp_openclaw_node_persisted_session_free(&loaded_session); + + uint8_t stored_seed[ESP_OPENCLAW_NODE_ED25519_SEED_LEN] = {0}; + size_t stored_seed_len = sizeof(stored_seed); + TEST_ASSERT_EQUAL(ESP_OK, nvs_open("openclaw", NVS_READONLY, &nvs)); + TEST_ASSERT_EQUAL(ESP_OK, nvs_get_blob(nvs, "device_seed", stored_seed, &stored_seed_len)); + TEST_ASSERT_EQUAL_UINT32(ESP_OPENCLAW_NODE_ED25519_SEED_LEN, (uint32_t)stored_seed_len); + nvs_close(nvs); + TEST_ASSERT_EQUAL_HEX8_ARRAY(identity.seed, stored_seed, sizeof(stored_seed)); + + esp_openclaw_node_identity_t reloaded = {0}; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_identity_load_or_create(&reloaded)); + TEST_ASSERT_EQUAL_HEX8_ARRAY(identity.seed, reloaded.seed, sizeof(identity.seed)); + TEST_ASSERT_EQUAL_STRING(identity.device_id, reloaded.device_id); + + esp_openclaw_node_identity_free(&reloaded); + esp_openclaw_node_identity_free(&identity); +} + +TEST_CASE("connect saved session without persisted session fails", "[esp_openclaw_node][api]") +{ + reset_openclaw_storage(); + reset_transport_state(); + + esp_openclaw_node_config_t config = {0}; + esp_openclaw_node_config_init_default(&config); + + esp_openclaw_node_handle_t node = NULL; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_create(&config, &node)); + TEST_ASSERT_NOT_NULL(node); + TEST_ASSERT_FALSE(esp_openclaw_node_has_saved_session(node)); + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_STATE, request_connect_saved_session(node)); + + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_destroy(node)); +} + +TEST_CASE("setup code rejects invalid or ambiguous auth shapes", "[esp_openclaw_node][api]") +{ + reset_openclaw_storage(); + reset_transport_state(); + + esp_openclaw_node_config_t config = {0}; + esp_openclaw_node_config_init_default(&config); + + esp_openclaw_node_handle_t node = NULL; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_create(&config, &node)); + + const char *invalid_json[] = { + "{\"url\":\"ws://gateway.example\"}", + "{\"url\":\"ws://gateway.example\",\"bootstrapToken\":\"boot\",\"token\":\"shared\"}", + "{\"url\":\"ws://gateway.example\",\"bootstrapToken\":\"boot\",\"password\":\"secret\"}", + "{\"url\":\"ws://gateway.example\",\"authMode\":\"none\"}", + }; + + for (size_t i = 0; i < sizeof(invalid_json) / sizeof(invalid_json[0]); ++i) { + char *setup_code = encode_base64url_string(invalid_json[i]); + TEST_ASSERT_NOT_NULL(setup_code); + TEST_ASSERT_EQUAL( + ESP_ERR_INVALID_ARG, + request_connect_setup_code(node, setup_code)); + free(setup_code); + } + + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_destroy(node)); +} + +TEST_CASE("explicit connect request validates psk and no-auth arguments", "[esp_openclaw_node][api]") +{ + reset_openclaw_storage(); + reset_transport_state(); + + esp_openclaw_node_config_t config = {0}; + esp_openclaw_node_config_init_default(&config); + + esp_openclaw_node_handle_t node = NULL; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_create(&config, &node)); + + TEST_ASSERT_EQUAL( + ESP_ERR_INVALID_ARG, + request_connect_gateway_token( + node, + "not-a-uri", + "secret-password")); + TEST_ASSERT_EQUAL( + ESP_ERR_INVALID_ARG, + request_connect_gateway_token( + node, + "wss://gateway.example/ws", + "")); + TEST_ASSERT_EQUAL( + ESP_ERR_INVALID_ARG, + request_connect_gateway_password( + node, + "not-a-uri", + "secret-password")); + TEST_ASSERT_EQUAL( + ESP_ERR_INVALID_ARG, + request_connect_gateway_password( + node, + "wss://gateway.example/ws", + "")); + TEST_ASSERT_EQUAL( + ESP_ERR_INVALID_ARG, + request_connect_gateway_token( + node, + "wss://gateway.example/ws", + " ")); + TEST_ASSERT_EQUAL( + ESP_ERR_INVALID_ARG, + request_connect_gateway_token( + node, + "", + "secret-password")); + TEST_ASSERT_EQUAL( + ESP_ERR_INVALID_ARG, + esp_openclaw_node_request_connect( + node, + &(esp_openclaw_node_connect_request_t){ + .source = (esp_openclaw_node_connect_source_t)99, + .gateway_uri = "wss://gateway.example/ws", + .value = "secret-password", + })); + TEST_ASSERT_EQUAL( + ESP_ERR_INVALID_ARG, + request_connect_no_auth( + node, + "not-a-uri")); + TEST_ASSERT_EQUAL( + ESP_ERR_INVALID_ARG, + request_connect_no_auth( + node, + "")); + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_STATE, esp_openclaw_node_request_disconnect(node)); + + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_destroy(node)); +} + +TEST_CASE("password auth is excluded from the signed device payload", "[esp_openclaw_node][auth]") +{ + reset_openclaw_storage(); + reset_transport_state(); + + esp_openclaw_node_config_t config = {0}; + esp_openclaw_node_config_init_default(&config); + + esp_openclaw_node_handle_t node = NULL; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_create(&config, &node)); + + const esp_openclaw_node_connect_request_t password_request = { + .source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_PASSWORD, + .gateway_uri = "ws://gateway.example/ws", + .value = "secret-password", + }; + const esp_openclaw_node_connect_request_t token_request = { + .source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_TOKEN, + .gateway_uri = "ws://gateway.example/ws", + .value = "shared-token", + }; + + esp_openclaw_node_connect_request_source_t password_source = {0}; + esp_openclaw_node_connect_request_source_t token_source = {0}; + TEST_ASSERT_EQUAL( + ESP_OK, + esp_openclaw_node_build_connect_source_from_request( + &password_request, + &password_source)); + TEST_ASSERT_EQUAL( + ESP_OK, + esp_openclaw_node_build_connect_source_from_request( + &token_request, + &token_source)); + + esp_openclaw_node_connect_material_t password_material = {0}; + esp_openclaw_node_connect_material_t token_material = {0}; + + esp_openclaw_node_lock_state(node); + node->active_connect_source = password_source; + memset(&password_source, 0, sizeof(password_source)); + TEST_ASSERT_EQUAL( + ESP_OK, + esp_openclaw_node_resolve_active_connect_material_locked( + node, + &password_material)); + + esp_openclaw_node_clear_connect_source_struct(&node->active_connect_source); + node->active_connect_source = token_source; + memset(&token_source, 0, sizeof(token_source)); + TEST_ASSERT_EQUAL( + ESP_OK, + esp_openclaw_node_resolve_active_connect_material_locked( + node, + &token_material)); + esp_openclaw_node_unlock_state(node); + + TEST_ASSERT_EQUAL( + ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_PASSWORD, + password_material.kind); + TEST_ASSERT_NOT_NULL(password_material.auth_value); + TEST_ASSERT_NULL(password_material.signature_token); + + TEST_ASSERT_EQUAL( + ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SHARED_TOKEN, + token_material.kind); + TEST_ASSERT_NOT_NULL(token_material.auth_value); + TEST_ASSERT_EQUAL_PTR(token_material.auth_value, token_material.signature_token); + + esp_openclaw_node_free_connect_material(&password_material); + esp_openclaw_node_free_connect_material(&token_material); + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_destroy(node)); +} + +TEST_CASE("destroy preserves the caller task notification count", "[esp_openclaw_node][destroy]") +{ + reset_openclaw_storage(); + reset_transport_state(); + + esp_openclaw_node_config_t config = {0}; + esp_openclaw_node_config_init_default(&config); + + esp_openclaw_node_handle_t node = NULL; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_create(&config, &node)); + + destroy_task_ctx_t ctx = { + .node = node, + .completion_waiter = xTaskGetCurrentTaskHandle(), + .destroy_err = ESP_FAIL, + .remaining_notify_count = 0, + }; + + TaskHandle_t destroy_task = NULL; + TEST_ASSERT_EQUAL( + pdPASS, + xTaskCreate( + destroy_with_pending_notification_task, + "node_destroy_test", + 4096, + &ctx, + 5, + &destroy_task)); + TEST_ASSERT_TRUE(ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(2000)) == 1); + TEST_ASSERT_EQUAL(ESP_OK, ctx.destroy_err); + TEST_ASSERT_EQUAL_UINT32(1, ctx.remaining_notify_count); +} + +TEST_CASE("connect kicks challenge ping and disconnect is not dropped while busy", "[esp_openclaw_node][transport]") +{ + reset_openclaw_storage(); + reset_transport_state(); + + esp_openclaw_node_config_t config = {0}; + esp_openclaw_node_config_init_default(&config); + config.event_cb = test_node_event_cb; + + esp_openclaw_node_handle_t node = NULL; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_create(&config, &node)); + + blocking_command_ctx_t command_ctx = { + .entered = xSemaphoreCreateBinary(), + .release = xSemaphoreCreateBinary(), + }; + TEST_ASSERT_NOT_NULL(command_ctx.entered); + TEST_ASSERT_NOT_NULL(command_ctx.release); + + esp_openclaw_node_command_t command = { + .name = "block", + .handler = blocking_command_handler, + .context = &command_ctx, + }; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_register_command(node, &command)); + + reset_event_recorder(); + TEST_ASSERT_EQUAL( + ESP_OK, + request_connect_no_auth(node, "ws://gateway.example/ws")); + TEST_ASSERT_TRUE(wait_for_int_value(&s_transport_state.start_calls, 1, pdMS_TO_TICKS(1000))); + + emit_ws_event(WEBSOCKET_EVENT_CONNECTED, NULL, ESP_OK); + TEST_ASSERT_TRUE( + wait_for_int_value(&s_transport_state.send_with_opcode_calls, 1, pdMS_TO_TICKS(1000))); + TEST_ASSERT_EQUAL(WS_TRANSPORT_OPCODES_PING, s_transport_state.last_opcode); + + emit_ws_event( + WEBSOCKET_EVENT_DATA, + "{\"type\":\"event\",\"event\":\"connect.challenge\",\"payload\":{\"nonce\":\"nonce-1\",\"ts\":123}}", + ESP_OK); + TEST_ASSERT_TRUE(wait_for_int_value(&s_transport_state.send_text_calls, 1, pdMS_TO_TICKS(1000))); + + char *connect_id = extract_first_json_id(s_transport_state.last_sent_text); + TEST_ASSERT_NOT_NULL(connect_id); + + char connect_response[512] = {0}; + snprintf( + connect_response, + sizeof(connect_response), + "{\"type\":\"res\",\"id\":\"%s\",\"ok\":true,\"payload\":{\"type\":\"hello-ok\",\"auth\":{\"deviceToken\":\"device-token-123\"}}}", + connect_id); + free(connect_id); + + emit_ws_event(WEBSOCKET_EVENT_DATA, connect_response, ESP_OK); + TEST_ASSERT_TRUE(wait_for_event(ESP_OPENCLAW_NODE_EVENT_CONNECTED, pdMS_TO_TICKS(1000))); + + reset_event_recorder(); + emit_ws_event( + WEBSOCKET_EVENT_DATA, + "{\"type\":\"event\",\"event\":\"node.invoke.request\",\"payload\":{\"id\":\"invoke-1\",\"nodeId\":\"node-1\",\"command\":\"block\",\"paramsJSON\":\"{}\"}}", + ESP_OK); + TEST_ASSERT_TRUE(xSemaphoreTake(command_ctx.entered, pdMS_TO_TICKS(1000)) == pdTRUE); + + for (int i = 0; i < 16; ++i) { + emit_ws_event(WEBSOCKET_EVENT_ERROR, NULL, ESP_FAIL); + } + emit_ws_event(WEBSOCKET_EVENT_DISCONNECTED, NULL, ESP_ERR_INVALID_STATE); + + TEST_ASSERT_TRUE(xSemaphoreGive(command_ctx.release) == pdTRUE); + TEST_ASSERT_TRUE(wait_for_event(ESP_OPENCLAW_NODE_EVENT_DISCONNECTED, pdMS_TO_TICKS(2000))); + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_STATE, s_event_recorder.local_err); + + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_destroy(node)); + vSemaphoreDelete(command_ctx.entered); + vSemaphoreDelete(command_ctx.release); +} + +TEST_CASE("node.invoke.request is ignored until handshake reaches ready", "[esp_openclaw_node][protocol]") +{ + reset_openclaw_storage(); + reset_transport_state(); + + esp_openclaw_node_config_t config = {0}; + esp_openclaw_node_config_init_default(&config); + config.event_cb = test_node_event_cb; + + esp_openclaw_node_handle_t node = NULL; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_create(&config, &node)); + + blocking_command_ctx_t command_ctx = { + .entered = xSemaphoreCreateBinary(), + .release = xSemaphoreCreateBinary(), + }; + TEST_ASSERT_NOT_NULL(command_ctx.entered); + TEST_ASSERT_NOT_NULL(command_ctx.release); + + esp_openclaw_node_command_t command = { + .name = "block", + .handler = blocking_command_handler, + .context = &command_ctx, + }; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_register_command(node, &command)); + + reset_event_recorder(); + TEST_ASSERT_EQUAL( + ESP_OK, + request_connect_no_auth(node, "ws://gateway.example/ws")); + TEST_ASSERT_TRUE(wait_for_int_value(&s_transport_state.start_calls, 1, pdMS_TO_TICKS(1000))); + + emit_ws_event(WEBSOCKET_EVENT_CONNECTED, NULL, ESP_OK); + TEST_ASSERT_TRUE( + wait_for_int_value(&s_transport_state.send_with_opcode_calls, 1, pdMS_TO_TICKS(1000))); + + emit_ws_event( + WEBSOCKET_EVENT_DATA, + "{\"type\":\"event\",\"event\":\"connect.challenge\",\"payload\":{\"nonce\":\"nonce-1\",\"ts\":123}}", + ESP_OK); + TEST_ASSERT_TRUE(wait_for_int_value(&s_transport_state.send_text_calls, 1, pdMS_TO_TICKS(1000))); + + char *connect_id = extract_first_json_id(s_transport_state.last_sent_text); + TEST_ASSERT_NOT_NULL(connect_id); + + emit_ws_event( + WEBSOCKET_EVENT_DATA, + "{\"type\":\"event\",\"event\":\"node.invoke.request\",\"payload\":{\"id\":\"invoke-early\",\"nodeId\":\"node-1\",\"command\":\"block\",\"paramsJSON\":\"{}\"}}", + ESP_OK); + TEST_ASSERT_TRUE(xSemaphoreTake(command_ctx.entered, pdMS_TO_TICKS(100)) == pdFALSE); + TEST_ASSERT_EQUAL(1, s_transport_state.send_text_calls); + TEST_ASSERT_EQUAL(0, s_event_recorder.connected_count); + + char connect_response[512] = {0}; + snprintf( + connect_response, + sizeof(connect_response), + "{\"type\":\"res\",\"id\":\"%s\",\"ok\":true,\"payload\":{\"type\":\"hello-ok\",\"auth\":{\"deviceToken\":\"device-token-123\"}}}", + connect_id); + free(connect_id); + + emit_ws_event(WEBSOCKET_EVENT_DATA, connect_response, ESP_OK); + TEST_ASSERT_TRUE(wait_for_event(ESP_OPENCLAW_NODE_EVENT_CONNECTED, pdMS_TO_TICKS(1000))); + + emit_ws_event( + WEBSOCKET_EVENT_DATA, + "{\"type\":\"event\",\"event\":\"node.invoke.request\",\"payload\":{\"id\":\"invoke-ready\",\"nodeId\":\"node-1\",\"command\":\"block\",\"paramsJSON\":\"{}\"}}", + ESP_OK); + TEST_ASSERT_TRUE(xSemaphoreTake(command_ctx.entered, pdMS_TO_TICKS(1000)) == pdTRUE); + TEST_ASSERT_TRUE(xSemaphoreGive(command_ctx.release) == pdTRUE); + TEST_ASSERT_TRUE(wait_for_int_value(&s_transport_state.send_text_calls, 2, pdMS_TO_TICKS(1000))); + + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_destroy(node)); + vSemaphoreDelete(command_ctx.entered); + vSemaphoreDelete(command_ctx.release); +} + +TEST_CASE("disconnect is rejected while transport start is in progress", "[esp_openclaw_node][transport]") +{ + reset_openclaw_storage(); + reset_transport_state(); + + esp_openclaw_node_config_t config = {0}; + esp_openclaw_node_config_init_default(&config); + config.event_cb = test_node_event_cb; + + esp_openclaw_node_handle_t node = NULL; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_create(&config, &node)); + + s_transport_state.start_entered = xSemaphoreCreateBinary(); + s_transport_state.start_release = xSemaphoreCreateBinary(); + s_transport_state.start_result = ESP_FAIL; + TEST_ASSERT_NOT_NULL(s_transport_state.start_entered); + TEST_ASSERT_NOT_NULL(s_transport_state.start_release); + + reset_event_recorder(); + TEST_ASSERT_EQUAL( + ESP_OK, + request_connect_no_auth(node, "ws://gateway.example/ws")); + TEST_ASSERT_TRUE(xSemaphoreTake(s_transport_state.start_entered, pdMS_TO_TICKS(1000)) == pdTRUE); + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_STATE, esp_openclaw_node_request_disconnect(node)); + TEST_ASSERT_TRUE(xSemaphoreGive(s_transport_state.start_release) == pdTRUE); + + TEST_ASSERT_TRUE(wait_for_event(ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED, pdMS_TO_TICKS(1000))); + TEST_ASSERT_EQUAL(0, s_event_recorder.connected_count); + TEST_ASSERT_EQUAL(1, s_event_recorder.connect_failed_count); + TEST_ASSERT_EQUAL(0, s_event_recorder.disconnected_count); + TEST_ASSERT_FALSE(esp_openclaw_node_has_saved_session(node)); + + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_destroy(node)); + vSemaphoreDelete(s_transport_state.start_entered); + vSemaphoreDelete(s_transport_state.start_release); + s_transport_state.start_entered = NULL; + s_transport_state.start_release = NULL; +} + +TEST_CASE("disconnect is rejected after connect request send while still connecting", "[esp_openclaw_node][transport]") +{ + reset_openclaw_storage(); + reset_transport_state(); + + esp_openclaw_node_config_t config = {0}; + esp_openclaw_node_config_init_default(&config); + config.event_cb = test_node_event_cb; + + esp_openclaw_node_handle_t node = NULL; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_create(&config, &node)); + + s_transport_state.send_text_entered = xSemaphoreCreateBinary(); + s_transport_state.send_text_release = xSemaphoreCreateBinary(); + TEST_ASSERT_NOT_NULL(s_transport_state.send_text_entered); + TEST_ASSERT_NOT_NULL(s_transport_state.send_text_release); + + reset_event_recorder(); + TEST_ASSERT_EQUAL( + ESP_OK, + request_connect_no_auth(node, "ws://gateway.example/ws")); + TEST_ASSERT_TRUE(wait_for_int_value(&s_transport_state.start_calls, 1, pdMS_TO_TICKS(1000))); + + emit_ws_event(WEBSOCKET_EVENT_CONNECTED, NULL, ESP_OK); + TEST_ASSERT_TRUE( + wait_for_int_value(&s_transport_state.send_with_opcode_calls, 1, pdMS_TO_TICKS(1000))); + emit_ws_event( + WEBSOCKET_EVENT_DATA, + "{\"type\":\"event\",\"event\":\"connect.challenge\",\"payload\":{\"nonce\":\"nonce-1\",\"ts\":123}}", + ESP_OK); + TEST_ASSERT_TRUE(xSemaphoreTake(s_transport_state.send_text_entered, pdMS_TO_TICKS(1000)) == pdTRUE); + + char *connect_id = extract_first_json_id(s_transport_state.last_sent_text); + TEST_ASSERT_NOT_NULL(connect_id); + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_STATE, esp_openclaw_node_request_disconnect(node)); + + char connect_response[512] = {0}; + snprintf( + connect_response, + sizeof(connect_response), + "{\"type\":\"res\",\"id\":\"%s\",\"ok\":true,\"payload\":{\"type\":\"hello-ok\",\"auth\":{\"deviceToken\":\"device-token-123\"}}}", + connect_id); + free(connect_id); + TEST_ASSERT_TRUE(xSemaphoreGive(s_transport_state.send_text_release) == pdTRUE); + emit_ws_event(WEBSOCKET_EVENT_DATA, connect_response, ESP_OK); + + TEST_ASSERT_TRUE(wait_for_event(ESP_OPENCLAW_NODE_EVENT_CONNECTED, pdMS_TO_TICKS(1000))); + TEST_ASSERT_EQUAL(1, s_event_recorder.connected_count); + TEST_ASSERT_EQUAL(0, s_event_recorder.connect_failed_count); + TEST_ASSERT_EQUAL(0, s_event_recorder.disconnected_count); + TEST_ASSERT_TRUE(esp_openclaw_node_has_saved_session(node)); + + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_destroy(node)); + vSemaphoreDelete(s_transport_state.send_text_entered); + vSemaphoreDelete(s_transport_state.send_text_release); + s_transport_state.send_text_entered = NULL; + s_transport_state.send_text_release = NULL; +} + +TEST_CASE("clean websocket close keeps local err clear", "[esp_openclaw_node][transport]") +{ + reset_openclaw_storage(); + reset_transport_state(); + + esp_openclaw_node_config_t config = {0}; + esp_openclaw_node_config_init_default(&config); + config.event_cb = test_node_event_cb; + + esp_openclaw_node_handle_t node = NULL; + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_create(&config, &node)); + + reset_event_recorder(); + TEST_ASSERT_EQUAL( + ESP_OK, + request_connect_no_auth(node, "ws://gateway.example/ws")); + TEST_ASSERT_TRUE(wait_for_int_value(&s_transport_state.start_calls, 1, pdMS_TO_TICKS(1000))); + + emit_ws_event(WEBSOCKET_EVENT_CONNECTED, NULL, ESP_OK); + TEST_ASSERT_TRUE( + wait_for_int_value(&s_transport_state.send_with_opcode_calls, 1, pdMS_TO_TICKS(1000))); + emit_ws_event( + WEBSOCKET_EVENT_DATA, + "{\"type\":\"event\",\"event\":\"connect.challenge\",\"payload\":{\"nonce\":\"nonce-1\",\"ts\":123}}", + ESP_OK); + TEST_ASSERT_TRUE(wait_for_int_value(&s_transport_state.send_text_calls, 1, pdMS_TO_TICKS(1000))); + + char *connect_id = extract_first_json_id(s_transport_state.last_sent_text); + TEST_ASSERT_NOT_NULL(connect_id); + + char connect_response[512] = {0}; + snprintf( + connect_response, + sizeof(connect_response), + "{\"type\":\"res\",\"id\":\"%s\",\"ok\":true,\"payload\":{\"type\":\"hello-ok\",\"auth\":{\"deviceToken\":\"device-token-123\"}}}", + connect_id); + free(connect_id); + emit_ws_event(WEBSOCKET_EVENT_DATA, connect_response, ESP_OK); + TEST_ASSERT_TRUE(wait_for_event(ESP_OPENCLAW_NODE_EVENT_CONNECTED, pdMS_TO_TICKS(1000))); + + reset_event_recorder(); + emit_ws_event(WEBSOCKET_EVENT_DISCONNECTED, NULL, ESP_OK); + TEST_ASSERT_TRUE(wait_for_event(ESP_OPENCLAW_NODE_EVENT_DISCONNECTED, pdMS_TO_TICKS(1000))); + TEST_ASSERT_EQUAL(ESP_OK, s_event_recorder.local_err); + TEST_ASSERT_EQUAL(ESP_OPENCLAW_NODE_DISCONNECTED_REASON_CONNECTION_LOST, s_event_recorder.disconnected_reason); + + TEST_ASSERT_EQUAL(ESP_OK, esp_openclaw_node_destroy(node)); +} + +void app_main(void) +{ + UNITY_BEGIN(); + unity_run_all_tests(); + UNITY_END(); +} diff --git a/idf_component.yml b/idf_component.yml deleted file mode 100644 index ca473a7..0000000 --- a/idf_component.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: "1.0.0" -description: "esp-openclaw-node" -url: https://github.com/espressif/esp-openclaw-node -dependencies: - idf: - version: ">=4.3"