esp-openclaw-node: Add ESP-IDF OpenClaw Node component

This commit is contained in:
Dhaval Gujar 2026-04-13 15:24:59 +05:30
parent 90c310f67b
commit 682c4dc05d
26 changed files with 6403 additions and 9 deletions

View File

@ -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 }}

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
**/build*/
**/managed_components/
**/dependencies.lock
**/sdkconfig
**/sdkconfig.old
**/sdkconfig.ci
**/__pycache__/
dist/
.DS_Store

View File

@ -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.

View File

@ -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})

View File

@ -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

View File

@ -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.

View File

@ -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 <string.h>
#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 = "<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 = <setup code>`
- `GATEWAY_TOKEN`: `gateway_uri = <ws://...|wss://...>`, `value = <token>`
- `GATEWAY_PASSWORD`: `gateway_uri = <ws://...|wss://...>`,
`value = <password>`
- `NO_AUTH`: `gateway_uri = <ws://...|wss://...>`, `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"
}
```
<details>
<summary>Pairing Flow</summary>
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
```
</details>
### 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
<details>
<summary>Example Wire Messages</summary>
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": "<saved-device-token>"
},
"userAgent": "esp-openclaw-node/1.0.0",
"locale": "en-US",
"device": {
"id": "<device-id>",
"publicKey": "<base64url-public-key>",
"signature": "<base64url-signature>",
"signedAt": 1774830385123,
"nonce": "M2QxYjBiNDItYzJlZS00YzA3LWFkMWMtMmE4NGJmZTg4M2E5"
}
}
}
```
`params.auth` variants by connect source:
```json
{ "bootstrapToken": "<bootstrap-token>" }
```
```json
{ "token": "<gateway-token>" }
```
```json
{ "password": "<gateway-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": "<gateway-issued-device-token>"
}
}
}
```
Example `node.invoke.request` from the gateway:
```json
{
"type": "event",
"event": "node.invoke.request",
"payload": {
"id": "inv_01JV0A7X9S1ZQY7V5NXJYQ5V8K",
"nodeId": "<node-id>",
"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": "<node-id>",
"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": "<node-id>",
"ok": false,
"error": {
"code": "INVALID_PARAMS",
"message": "display params must include string heading and text fields"
}
}
}
```
</details>
### 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).

View File

@ -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"

View File

@ -0,0 +1,342 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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 = <setup code>`
* - `GATEWAY_TOKEN`: `gateway_uri = <ws://...|wss://...>`, `value = <token>`
* - `GATEWAY_PASSWORD`: `gateway_uri = <ws://...|wss://...>`, `value = <password>`
* - `NO_AUTH`: `gateway_uri = <ws://...|wss://...>`, `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

View File

@ -0,0 +1,119 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include <stddef.h>
#include <stdint.h>
#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);

View File

@ -0,0 +1,293 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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);

View File

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include <stdbool.h>
#include <stdint.h>
#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);

View File

@ -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 <stdlib.h>
#include <string.h>
#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;
}

View File

@ -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 <stdlib.h>
#include <string.h>
#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;
}

View File

@ -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 <ctype.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}

View File

@ -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 <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#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(&copy, update), TAG, "copy session");
}
esp_err_t err = write_persisted_session_to_storage(update);
if (err != ESP_OK) {
esp_openclaw_node_persisted_session_free(&copy);
return err;
}
esp_openclaw_node_persisted_session_free(session);
*session = copy;
return ESP_OK;
}

View File

@ -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 <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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);
}

View File

@ -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 <stdlib.h>
#include <string.h>
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;
}

View File

@ -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 <inttypes.h>
#include <string.h>
#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;
}

View File

@ -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 <inttypes.h>
#include <stdlib.h>
#include <string.h>
#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;
}
}

View File

@ -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

View File

@ -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)

View File

@ -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
```

View File

@ -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")

View File

@ -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"