Merge branch 'esp-openclaw-node' into 'main'

Add the esp-openclaw-node component, example and docs

See merge request espressif/esp-openclaw-node!2
This commit is contained in:
Kedar Sovani 2026-04-20 10:55:36 +05:30
commit 057a96a222
73 changed files with 10138 additions and 0 deletions

21
.github/workflows/upload_component.yml vendored Normal file
View File

@ -0,0 +1,21 @@
name: Push esp-openclaw-node to IDF Component Registry
on:
push:
branches:
- 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@v2
with:
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

202
LICENSE Normal file
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,26 @@
![esp-openclaw-node banner](docs/assets/esp-openclaw-node-banner.png)
# esp-openclaw-node
This repository contains the `esp-openclaw-node` ESP-IDF component, example applications, and documentation for running ESP32 boards as [OpenClaw Nodes](https://docs.openclaw.ai/nodes).
<a href="docs/assets/openclaw-gateway-esp-box-3-message-flow.png">
<img src="docs/assets/openclaw-gateway-esp-box-3-message-flow.png" alt="OpenClaw Gateway to ESP-BOX-3 message flow" width="900">
</a>
## Repository Layout
- `components/esp-openclaw-node/`: The `esp-openclaw-node` ESP-IDF component that handles OpenClaw transport, pairing, reconnect, and command dispatch.
See the [Component README](./components/esp-openclaw-node/README.md) for more details on component internals.
- `examples/`: Example applications for supported boards.
- `docs/`: Getting-started and troubleshooting guides.
## Start Here
<a href="https://espressif.github.io/esp-launchpad/?flashConfigURL=https://espressif.github.io/esp-openclaw-node/launchpad.toml">
<img alt="Try it with ESP Launchpad" src="https://espressif.github.io/esp-launchpad/assets/try_with_launchpad.png" width="220" height="62">
</a>
1. Read [Getting Started](./docs/getting-started.md).
2. Choose an example from [Examples](./examples/README.md).
3. Use [Troubleshooting](./docs/troubleshooting.md) if the node pairs, connects, or advertises commands differently than expected.

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,28 @@
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})
# Work around the IDF 6.0/newlib-picolibc Annex K header breakage in the
# managed libsodium component without affecting the rest of the build.
if(TARGET __idf_espressif__libsodium)
target_compile_definitions(__idf_espressif__libsodium PRIVATE __STDC_WANT_LIB_EXT1__=0)
endif()

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,657 @@
# 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 before the first accepted connect request.
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 when no session is active and no connect or
disconnect request is in flight
- 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 `"{}"`
- the component always passes `out_payload_json` and initializes
`*out_payload_json` to `NULL` before calling the handler
- on success, return `ESP_OK` and either leave `*out_payload_json` as `NULL`
to send no payload or assign a UTF-8 JSON string to `*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 when no session is active and no
connect or disconnect request is in flight
- `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 `hello-ok` response:
```json
{
"type": "res",
"id": "connect-1774830385140123",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 3,
"server": {
"version": "2026.4.9",
"connId": "<gateway-connection-id>"
},
"auth": {
"deviceToken": "<node-device-token>",
"role": "node",
"scopes": [],
"deviceTokens": [
{
"deviceToken": "<bounded-operator-token>",
"role": "operator",
"scopes": [
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write"
]
}
]
}
}
}
```
The gateway may return the primary node reconnect token in
`payload.auth.deviceToken` plus additional tokens in
`payload.auth.deviceTokens`.
This component persists and reuses only
`payload.auth.deviceToken`; it ignores any extra `payload.auth.deviceTokens` entries.
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.
This repository's examples provide that behavior outside the component in
[examples/common/esp_openclaw_node_example_saved_session_reconnect.c](../../examples/common/esp_openclaw_node_example_saved_session_reconnect.c).
That helper:
- 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.21"

View File

@ -0,0 +1,349 @@
/*
* 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's internal node 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's internal node 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 Output location for the success payload JSON.
* The component initializes `*out_payload_json` to `NULL` before
* calling the handler. On success, leave `*out_payload_json` as
* `NULL` to send no payload, or assign a `malloc()`-compatible
* UTF-8 JSON string. The component takes ownership of any
* non-`NULL` buffer and frees it after sending `payloadJSON`.
* @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 a session is active, a connect or
* disconnect request is already in progress, or destroy has begun
* - `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 a session is active, a connect or
* disconnect request is already in progress, or destroy has begun
* - `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 a session is already active, another
* connect or disconnect request is already in progress, 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 transport_id; /* Tags the websocket instance that produced this message. */
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 transport_id; /* Stable ID for one websocket transport lifetime. */
} 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_websocket_client_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_websocket_client_ops_t *websocket_client_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_id;
uint32_t active_transport_id;
bool transport_connected; /* True after the active websocket transport reports CONNECTED. */
bool client_started; /* True after client_start() succeeds; cleanup should stop before destroy. */
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_websocket_client_ops_t esp_openclaw_node_default_websocket_client_ops;
__attribute__((weak)) const esp_openclaw_node_websocket_client_ops_t *esp_openclaw_node_test_websocket_client_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_transport_id_locked(
esp_openclaw_node_handle_t node,
uint32_t transport_id);
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_websocket_client_ops_t esp_openclaw_node_default_websocket_client_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_websocket_client_ops_t *esp_openclaw_node_test_websocket_client_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_websocket_client_ops_t *test_websocket_client_ops =
esp_openclaw_node_test_websocket_client_ops();
node->websocket_client_ops = test_websocket_client_ops != NULL
? test_websocket_client_ops
: &esp_openclaw_node_default_websocket_client_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 "sha/sha_parallel_engine.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};
esp_sha(SHA2_256, identity->public_key, ESP_OPENCLAW_NODE_ED25519_PUBLIC_KEY_LEN, digest);
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->websocket_client_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_transport_id_locked(
node,
message->transport_id);
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->client_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_id != 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_transport =
node->active_transport_id == message->transport_id;
esp_openclaw_node_internal_state_t state = node->state;
if (current_transport) {
node->transport_connected = true;
}
if (current_transport &&
node->state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING) {
kick_challenge = true;
}
esp_openclaw_node_unlock_state(node);
if (!current_transport) {
return;
}
if (kick_challenge) {
esp_openclaw_node_send_challenge_kick_ping(node);
}
ESP_LOGI(
ESP_OPENCLAW_NODE_TAG,
"websocket connected: transport_id=%" PRIu32 " state=%s",
message->transport_id,
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_transport =
node->active_transport_id == message->transport_id;
esp_openclaw_node_internal_state_t state = node->state;
if (current_transport) {
complete_transport_event_state_after_disconnect_locked(node);
}
esp_openclaw_node_unlock_state(node);
if (!current_transport) {
return;
}
ESP_LOGW(
ESP_OPENCLAW_NODE_TAG,
"websocket disconnected: transport_id=%" PRIu32 " state=%s err=%s",
message->transport_id,
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_transport =
node->active_transport_id == message->transport_id;
esp_openclaw_node_unlock_state(node);
if (!current_transport) {
return;
}
ESP_LOGW(
ESP_OPENCLAW_NODE_TAG,
"websocket error: transport_id=%" PRIu32 " state=%s err=%s",
message->transport_id,
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_transport =
node->active_transport_id == message->transport_id &&
node->state != ESP_OPENCLAW_NODE_INTERNAL_DESTROYING &&
node->state != ESP_OPENCLAW_NODE_INTERNAL_CLOSED;
esp_openclaw_node_unlock_state(node);
if (current_transport && 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_transport_id_locked(
esp_openclaw_node_handle_t node,
uint32_t transport_id)
{
return node->state != ESP_OPENCLAW_NODE_INTERNAL_DESTROYING &&
node->state != ESP_OPENCLAW_NODE_INTERNAL_CLOSED &&
node->active_transport_id == transport_id &&
transport_id != 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->client_started = false;
node->active_transport_id = 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 client_started = false;
esp_openclaw_node_lock_state(node);
ws = node->ws;
transport_ctx = node->transport_ctx;
gateway_uri = node->transport_gateway_uri;
client_started = node->client_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 && client_started) {
node->websocket_client_ops->client_stop(ws);
}
node->websocket_client_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->websocket_client_ops->client_init(&ws_config);
if (ws == NULL) {
free(gateway_uri_copy);
free(transport_ctx);
return ESP_FAIL;
}
uint32_t transport_id = 0;
esp_openclaw_node_lock_state(node);
transport_id = ++node->next_transport_id;
transport_ctx->node = node;
transport_ctx->transport_id = transport_id;
node->transport_ctx = transport_ctx;
node->transport_gateway_uri = gateway_uri_copy;
node->active_transport_id = transport_id;
node->ws = ws;
node->client_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->websocket_client_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->websocket_client_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->client_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->websocket_client_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_transport_id_locked(
node,
transport_ctx->transport_id);
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,
.transport_id = transport_ctx->transport_id,
.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,
.transport_id = transport_ctx->transport_id,
.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,
.transport_id = transport_ctx->transport_id,
.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: transport_id=%" PRIu32 " opcode=0x%x fin=%d data_len=%d payload_len=%d offset=%d state=%s",
transport_ctx->transport_id,
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_transport_id_locked(
node,
transport_ctx->transport_id)) {
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,
.transport_id = transport_ctx->transport_id,
.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,48 @@
# 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 rule that password auth is not included
in the device-signature payload
- destroy-path notification safety
- transport-state edge cases around challenge ping, clean close, and disconnect
rejection while still connecting
## 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")

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

119
docs/getting-started.md Normal file
View File

@ -0,0 +1,119 @@
# Getting Started
Use this guide to connect an ESP32 board from this repository to an existing OpenClaw gateway.
## What You Need
- OpenClaw installed and `openclaw` available on `PATH`
- An OpenClaw gateway the board can reach
- ESP-IDF `5.x`
- An ESP32 board with Wi-Fi
- A serial connection or board-specific flashing path, depending on the example
Commands below assume the default OpenClaw install. If you use a named profile, add `--profile <profile>` to the `openclaw` commands.
## Choose An Example
- [ESP32 Wi-Fi Node Example](../examples/esp32-node/README.md): Generic ESP32 node with Wi-Fi, GPIO, and ADC commands.
- [ESP-BOX-3 Display Example](../examples/esp-box-3-display/README.md): ESP-BOX-3 node with Wi-Fi and display commands for the built-in screen.
## Prepare The Gateway
If the board will connect over Wi-Fi to a gateway running on another machine, set `gateway.bind` to `lan` first. The default loopback bind is only reachable from the gateway host itself.
Before pairing a board, set `gateway.nodes.allowCommands` for the example you are using. Each example README lists the commands to allow.
```bash
openclaw config set gateway.bind lan
openclaw config set gateway.nodes.allowCommands '<json-array-from-example>' --strict-json
openclaw gateway restart
openclaw gateway status --probe --json
```
If the gateway stays on loopback, the board cannot reach it over Wi-Fi. If the gateway does not allow the example's commands, the node can connect and still show `commands: []`.
## Build And Flash
Use the commands from the example README for the board you are using. The general flow is:
```bash
. ~/esp-idf/export.sh
cd /path/to/example
idf.py set-target <target>
idf.py build
idf.py -p <serial-port> flash monitor
```
## Pair The Board With The Gateway
Use a setup code for the first connection, or one of the other explicit auth
commands described below.
Generate one on the gateway host:
```bash
openclaw qr \
--url ws://<gateway-host-ip>:<gateway-port> \
--setup-code-only
```
The command prints a single setup code:
```text
<setup-code>
```
Sample setup code:
```text
eyJ1cmwiOiJ3czovLzE5Mi4xNjguMS4xMDoxODc4OSIsImJvb3RzdHJhcFRva2VuIjoib2NfYm9vdHN0cmFwX2V4YW1wbGVfdG9rZW4ifQ
```
If the installed gateway already resolves the correct LAN URL, you can omit `--url`.
After flashing, open the serial console for the board and use the REPL:
```text
openclaw> status
openclaw> wifi set <ssid> <passphrase>
openclaw> gateway setup-code <setup-code>
openclaw> status
```
Each example README calls out the console path for that board and the commands it exposes after pairing.
- Use `wifi set <ssid>` for an open network, or `wifi set <ssid> <passphrase>` for a secured network.
- The setup code contains a short-lived `bootstrapToken`, not the gateway's
shared token. `gateway setup-code <setup-code>` requests one explicit connect
attempt.
- If Wi-Fi is still coming up, the REPL waits for the board to obtain an
IP before it submits that attempt.
- After a successful `hello-ok`, the node
stores the issued `{ gateway_uri, device_token }` reconnect session and uses it
on later `gateway connect` attempts.
Other first-connect options from the REPL are:
- `gateway token <ws://host:port> <token>`
- `gateway password <ws://host:port> <password>`
- `gateway no-auth <ws://host:port>`
`gateway connect` is only for reconnecting with a saved session that already
exists on the board.
## Check The Node From The Gateway
```bash
openclaw nodes status --json
openclaw nodes invoke --node <node-id> --command device.info --json
```
Then run one of the commands from the README for the board you are using.
If pairing did not complete as expected, use [Troubleshooting](./troubleshooting.md).
## Troubleshooting And Reference
- [Troubleshooting](./troubleshooting.md)
- [Component README](../components/esp-openclaw-node/README.md)

135
docs/troubleshooting.md Normal file
View File

@ -0,0 +1,135 @@
# Troubleshooting
This guide covers common setup problems for the examples in this repository.
Commands below assume the default OpenClaw install. If you use a named profile, add `--profile <profile>` to the `openclaw` commands.
## Node Shows Capabilities but `commands: []`
This usually means one of three things:
- The gateway does not allow the example's commands
- The node connected before the allowlist was fixed and has not reconnected since
- You are looking at an older disconnected row instead of the live node session
Make sure the gateway includes the allowlist from the example README, then restart it:
```bash
openclaw config set gateway.nodes.allowCommands '<json-array-from-example>' --strict-json
openclaw gateway restart
```
If the board already has a saved reconnect session, it should reconnect
automatically once Wi-Fi and the gateway are back. Otherwise request another
connection attempt from the board or reboot it.
When you check status, use the live row:
```bash
openclaw nodes status --json
```
Look for:
- `"connected": true`
- The current `remoteIp`
- The current `nodeId`
## Setup Code Is Rejected or Pairing Does Not Complete
Common causes:
- The setup code expired
- The setup code points at the wrong gateway URL
- The board still has saved settings from an older pairing attempt
Use this recovery sequence:
1. Generate a fresh setup code:
```bash
openclaw qr \
--url ws://<gateway-host-ip>:<gateway-port> \
--setup-code-only
```
1. Check the board state from the REPL:
```text
status
```
1. If the board may still have an older saved session or Wi-Fi state, erase and
reflash it:
```bash
. ~/esp-idf/export.sh
cd /path/to/example
idf.py -p <serial-port> erase-flash flash monitor
```
1. Provision again from the REPL:
```text
gateway setup-code <setup-code>
```
`gateway setup-code <setup-code>` already requests the connection attempt. It
does not require a second `gateway connect`. If Wi-Fi is still associating, the
REPL waits for the board to obtain an IP before it submits that attempt.
## Node Does Not Show Up in `openclaw nodes status`
Check both sides:
On the board:
```text
status
wifi set <ssid> <passphrase>
```
Use `wifi set <ssid>` instead if the network is open.
On the gateway host:
```bash
openclaw gateway status --probe --json
openclaw nodes status --json
```
If the board is not associated to Wi-Fi yet, fix that first. If the board is on
Wi-Fi but the gateway is not reachable, verify the `ws://<gateway-host>` URL
embedded in the setup code or passed to `gateway token`, `gateway password`, or
`gateway no-auth`.
## Pending Approvals Still Appear After Setup-Code Pairing
Fresh setup-code pairing should not normally require extra approval commands. If the gateway still leaves a pending device or node request, inspect and approve it as a fallback:
```bash
openclaw devices approve --latest
openclaw nodes pending --json
openclaw nodes approve <request-id> --json
```
After approval, a board that already has a saved reconnect session should
reconnect automatically once Wi-Fi and the gateway are back. Otherwise request
another connection attempt or reboot the board.
`openclaw devices` and `openclaw nodes` do different jobs:
- `openclaw devices` works with device pairing records
- `openclaw nodes` works with the live node list and the commands available on each node
## Reset Saved State on the Board
Useful REPL commands:
- `wifi clear`
- `reboot`
- `gateway disconnect`
The examples in this repository do not expose a factory-reset or clear-saved-session REPL command.
When you need to forget both Wi-Fi and pairing state, use `idf.py erase-flash` before reflashing the example.

33
examples/README.md Normal file
View File

@ -0,0 +1,33 @@
# Examples
This directory contains the example applications in this repository and the shared source they build on.
## Available Examples
- [ESP32 Wi-Fi Node Example](./esp32-node/README.md) A general-purpose ESP32 node with `device.*`, `wifi.status`, `gpio.*`, and `adc.read`.
- [ESP-BOX-3 Display Example](./esp-box-3-display/README.md) An ESP-BOX-3 node with the shared device and Wi-Fi commands plus `display.show` and `display.status`.
## Directory Structure
- `common/` Shared source used by more than one example. This is not a standalone example.
- `esp32-node/` The generic ESP32 example.
- `esp-box-3-display/` The ESP-BOX-3 example.
## Naming Convention
- `*_node_cmd.c` OpenClaw Node command handlers and the function that registers those commands with the node.
- `*_repl_cmd.c` REPL command handlers and the function that registers those commands with the console.
- Other `.c` files Helper code, board setup, runtime services, or the main application entry point.
The public `esp_openclaw_node` API passes command parameters as raw JSON text. The examples parse that text with `cJSON` explicitly inside the example sources.
## Common Directory
The shared files under `common/` are split the same way:
- Shared device and Wi-Fi node commands: [esp_openclaw_node_common_device_node_cmd.c](./common/esp_openclaw_node_common_device_node_cmd.c)
- Shared JSON parsing and payload helpers: [esp_openclaw_node_example_json.c](./common/esp_openclaw_node_example_json.c)
- Shared REPL startup: [esp_openclaw_node_example_repl.c](./common/esp_openclaw_node_example_repl.c)
- Shared REPL commands such as `status`, `wifi`, `gateway`, and `reboot`: [esp_openclaw_node_example_repl_cmd.c](./common/esp_openclaw_node_example_repl_cmd.c)
- Shared saved-session reconnect helper: [esp_openclaw_node_example_saved_session_reconnect.c](./common/esp_openclaw_node_example_saved_session_reconnect.c)
- Shared Wi-Fi helpers: [esp_openclaw_node_wifi.c](./common/esp_openclaw_node_wifi.c)

View File

@ -0,0 +1,252 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include "esp_openclaw_node_common_device_node_cmd.h"
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "cJSON.h"
#include "esp_app_desc.h"
#include "esp_check.h"
#include "esp_chip_info.h"
#include "esp_heap_caps.h"
#include "esp_mac.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "esp_openclaw_node_example_json.h"
#include "esp_openclaw_node_wifi.h"
static const char *TAG = "esp_openclaw_node_device_cmd";
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 const char *firmware_version(void)
{
return esp_app_get_description()->version;
}
static const char *wifi_auth_mode_name(uint8_t authmode)
{
switch ((wifi_auth_mode_t)authmode) {
case WIFI_AUTH_OPEN:
return "open";
case WIFI_AUTH_WEP:
return "wep";
case WIFI_AUTH_WPA_PSK:
return "wpa-psk";
case WIFI_AUTH_WPA2_PSK:
return "wpa2-psk";
case WIFI_AUTH_WPA_WPA2_PSK:
return "wpa-wpa2-psk";
case WIFI_AUTH_WPA2_ENTERPRISE:
return "wpa2-enterprise";
case WIFI_AUTH_WPA3_PSK:
return "wpa3-psk";
case WIFI_AUTH_WPA2_WPA3_PSK:
return "wpa2-wpa3-psk";
case WIFI_AUTH_WAPI_PSK:
return "wapi-psk";
default:
return "unknown";
}
}
static void format_mac_address(const uint8_t mac[6], char *buffer, size_t buffer_size)
{
snprintf(
buffer,
buffer_size,
"%02x:%02x:%02x:%02x:%02x:%02x",
mac[0],
mac[1],
mac[2],
mac[3],
mac[4],
mac[5]);
}
static void add_wifi_status_fields(cJSON *object)
{
esp_openclaw_node_wifi_status_t wifi = {0};
esp_openclaw_node_wifi_get_status(&wifi);
cJSON_AddBoolToObject(object, "connected", wifi.connected);
cJSON_AddStringToObject(object, "ssid", wifi.ssid);
if (wifi.ip[0] != '\0') {
cJSON_AddStringToObject(object, "ip", wifi.ip);
cJSON_AddStringToObject(object, "netmask", wifi.netmask);
cJSON_AddStringToObject(object, "gateway", wifi.gateway);
}
if (wifi.connected) {
cJSON_AddNumberToObject(object, "rssi", wifi.rssi);
cJSON_AddNumberToObject(object, "channel", wifi.channel);
cJSON_AddStringToObject(object, "authMode", wifi_auth_mode_name(wifi.authmode));
}
if (wifi.last_disconnect_reason != 0) {
cJSON_AddNumberToObject(object, "lastDisconnectReason", wifi.last_disconnect_reason);
}
}
static cJSON *build_device_info_payload(esp_openclaw_node_handle_t node)
{
const char *device_id = esp_openclaw_node_get_device_id(node);
const esp_app_desc_t *app = esp_app_get_description();
esp_chip_info_t chip = {0};
esp_chip_info(&chip);
uint8_t mac[6] = {0};
char mac_text[18] = {0};
char app_sha256[65] = {0};
esp_read_mac(mac, ESP_MAC_WIFI_STA);
format_mac_address(mac, mac_text, sizeof(mac_text));
bytes_to_lower_hex(app->app_elf_sha256, sizeof(app->app_elf_sha256), app_sha256, sizeof(app_sha256));
cJSON *root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "deviceId", device_id != NULL ? device_id : "");
cJSON_AddStringToObject(root, "firmwareVersion", firmware_version());
cJSON_AddStringToObject(root, "idfVersion", esp_get_idf_version());
cJSON_AddStringToObject(root, "chipModel", CONFIG_IDF_TARGET);
cJSON_AddNumberToObject(root, "chipRevision", chip.revision);
cJSON_AddNumberToObject(root, "cores", chip.cores);
cJSON_AddStringToObject(root, "projectName", app->project_name);
cJSON_AddStringToObject(root, "appVersion", app->version);
cJSON_AddStringToObject(root, "appElfSha256", app_sha256);
cJSON_AddStringToObject(root, "wifiMac", mac_text);
return root;
}
static cJSON *build_device_status_payload(esp_openclaw_node_handle_t node)
{
cJSON *root = cJSON_CreateObject();
cJSON_AddNumberToObject(root, "uptimeMs", esp_timer_get_time() / 1000LL);
cJSON_AddNumberToObject(root, "freeHeap", esp_get_free_heap_size());
cJSON_AddNumberToObject(root, "minFreeHeap", esp_get_minimum_free_heap_size());
cJSON_AddNumberToObject(
root,
"largestFreeBlock",
heap_caps_get_largest_free_block(MALLOC_CAP_8BIT));
cJSON_AddBoolToObject(root, "savedSessionAvailable", esp_openclaw_node_has_saved_session(node));
cJSON *wifi = cJSON_CreateObject();
add_wifi_status_fields(wifi);
cJSON_AddItemToObject(root, "wifi", wifi);
return root;
}
static cJSON *build_wifi_status_payload(void)
{
cJSON *root = cJSON_CreateObject();
add_wifi_status_fields(root);
return root;
}
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)context;
(void)params_json;
(void)params_len;
(void)out_error;
return esp_openclaw_node_example_take_json_payload(
build_device_info_payload(node),
out_payload_json);
}
static esp_err_t handle_device_status(
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)context;
(void)params_json;
(void)params_len;
(void)out_error;
return esp_openclaw_node_example_take_json_payload(
build_device_status_payload(node),
out_payload_json);
}
static esp_err_t handle_wifi_status(
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;
return esp_openclaw_node_example_take_json_payload(
build_wifi_status_payload(),
out_payload_json);
}
esp_err_t esp_openclaw_node_common_register_device_node_commands(esp_openclaw_node_handle_t node)
{
static const esp_openclaw_node_command_t DEVICE_INFO_COMMAND = {
.name = "device.info",
.handler = handle_device_info,
.context = NULL,
};
static const esp_openclaw_node_command_t DEVICE_STATUS_COMMAND = {
.name = "device.status",
.handler = handle_device_status,
.context = NULL,
};
static const esp_openclaw_node_command_t WIFI_STATUS_COMMAND = {
.name = "wifi.status",
.handler = handle_wifi_status,
.context = NULL,
};
ESP_RETURN_ON_ERROR(
esp_openclaw_node_register_capability(node, "device"),
TAG,
"registering device capability failed");
ESP_RETURN_ON_ERROR(
esp_openclaw_node_register_capability(node, "wifi"),
TAG,
"registering wifi capability failed");
ESP_RETURN_ON_ERROR(
esp_openclaw_node_register_command(node, &DEVICE_INFO_COMMAND),
TAG,
"registering device.info failed");
ESP_RETURN_ON_ERROR(
esp_openclaw_node_register_command(node, &DEVICE_STATUS_COMMAND),
TAG,
"registering device.status failed");
ESP_RETURN_ON_ERROR(
esp_openclaw_node_register_command(node, &WIFI_STATUS_COMMAND),
TAG,
"registering wifi.status failed");
return ESP_OK;
}

View File

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include "esp_err.h"
#include "esp_openclaw_node.h"
/**
* @brief Register the common device commands shared by the examples.
*
* The helper registers `device.info`, `device.status`, and `wifi.status`.
*
* @param[in] node OpenClaw Node instance to extend.
*
* @return
* - `ESP_OK` on success
* - an ESP-IDF error code if registration fails
*/
esp_err_t esp_openclaw_node_common_register_device_node_commands(esp_openclaw_node_handle_t node);

View File

@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include "esp_openclaw_node_example_json.h"
#include <stdlib.h>
esp_err_t esp_openclaw_node_example_parse_json_params(
const char *params_json,
cJSON **out_params,
esp_openclaw_node_error_t *out_error)
{
if (out_params == NULL) {
return ESP_ERR_INVALID_ARG;
}
*out_params = NULL;
cJSON *params = cJSON_Parse(params_json != NULL ? params_json : "{}");
if (params == NULL) {
if (out_error != NULL) {
out_error->code = "INVALID_PARAMS";
out_error->message = "paramsJSON is not valid JSON";
}
return ESP_ERR_INVALID_ARG;
}
*out_params = params;
return ESP_OK;
}
esp_err_t esp_openclaw_node_example_take_json_payload(
cJSON *payload,
char **out_payload_json)
{
if (payload == NULL || out_payload_json == NULL) {
cJSON_Delete(payload);
return ESP_ERR_INVALID_ARG;
}
char *json = cJSON_PrintUnformatted(payload);
cJSON_Delete(payload);
if (json == NULL) {
return ESP_ERR_NO_MEM;
}
*out_payload_json = json;
return ESP_OK;
}

View File

@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include "cJSON.h"
#include "esp_err.h"
#include "esp_openclaw_node.h"
/**
* @brief Parse raw command params JSON for an example command handler.
*
* When @p params_json is `NULL`, this helper parses `"{}"` so example handlers
* can treat omitted params as an empty object.
*
* @param[in] params_json UTF-8 JSON params from the OpenClaw command request.
* @param[out] out_params Parsed cJSON tree owned by the caller on success.
* @param[out] out_error Optional structured command error populated when the
* input is not valid JSON.
*
* @return
* - `ESP_OK` on success
* - `ESP_ERR_INVALID_ARG` when the output pointer is `NULL` or the JSON
* payload cannot be parsed
*/
esp_err_t esp_openclaw_node_example_parse_json_params(
const char *params_json,
cJSON **out_params,
esp_openclaw_node_error_t *out_error);
/**
* @brief Serialize a cJSON payload and transfer ownership of the input tree.
*
* "Take" means this helper consumes @p payload. It always deletes the supplied
* cJSON tree before returning, whether serialization succeeds or fails. On
* success, the caller owns the returned `malloc()`-compatible JSON buffer.
*
* @param[in] payload cJSON payload tree to serialize and consume.
* @param[out] out_payload_json Serialized UTF-8 JSON payload string.
*
* @return
* - `ESP_OK` on success
* - `ESP_ERR_INVALID_ARG` when an argument is invalid
* - `ESP_ERR_NO_MEM` when serialization fails due to allocation failure
*/
esp_err_t esp_openclaw_node_example_take_json_payload(
cJSON *payload,
char **out_payload_json);

View File

@ -0,0 +1,69 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include "esp_openclaw_node_example_repl.h"
#include "esp_openclaw_node_example_repl_cmd.h"
#include "esp_check.h"
#include "esp_console.h"
#include "esp_log.h"
static const char *TAG = "esp_openclaw_node_repl";
static esp_console_repl_t *s_repl;
esp_err_t esp_openclaw_node_example_repl_start(esp_openclaw_node_handle_t node)
{
if (node == NULL) {
return ESP_ERR_INVALID_ARG;
}
if (s_repl != NULL) {
return ESP_OK;
}
esp_console_register_help_command();
ESP_RETURN_ON_ERROR(
esp_openclaw_node_example_repl_register_commands(node),
TAG,
"registering REPL commands failed");
esp_console_repl_config_t repl_config = ESP_CONSOLE_REPL_CONFIG_DEFAULT();
repl_config.prompt = "openclaw> ";
repl_config.max_cmdline_length = 512;
const char *console_transport = NULL;
#if defined(CONFIG_ESP_CONSOLE_UART_DEFAULT) || defined(CONFIG_ESP_CONSOLE_UART_CUSTOM)
esp_console_dev_uart_config_t uart_config = ESP_CONSOLE_DEV_UART_CONFIG_DEFAULT();
ESP_RETURN_ON_ERROR(
esp_console_new_repl_uart(&uart_config, &repl_config, &s_repl),
TAG,
"creating UART REPL failed");
console_transport = "UART";
#elif defined(CONFIG_ESP_CONSOLE_USB_CDC)
esp_console_dev_usb_cdc_config_t usb_cdc_config = ESP_CONSOLE_DEV_CDC_CONFIG_DEFAULT();
ESP_RETURN_ON_ERROR(
esp_console_new_repl_usb_cdc(&usb_cdc_config, &repl_config, &s_repl),
TAG,
"creating USB CDC REPL failed");
console_transport = "USB CDC";
#elif defined(CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG)
esp_console_dev_usb_serial_jtag_config_t usb_serial_jtag_config =
ESP_CONSOLE_DEV_USB_SERIAL_JTAG_CONFIG_DEFAULT();
ESP_RETURN_ON_ERROR(
esp_console_new_repl_usb_serial_jtag(&usb_serial_jtag_config, &repl_config, &s_repl),
TAG,
"creating USB Serial/JTAG REPL failed");
console_transport = "USB Serial/JTAG";
#else
#error Unsupported console transport for the OpenClaw REPL
#endif
ESP_RETURN_ON_ERROR(esp_console_start_repl(s_repl), TAG, "starting REPL failed");
ESP_LOGI(
TAG,
"%s REPL ready; use `wifi ...`, `gateway ...`, `reboot`, or `status`",
console_transport);
return ESP_OK;
}

View File

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include "esp_err.h"
#include "esp_openclaw_node.h"
/**
* @brief Start the UART REPL used by the examples.
*
* @param[in] node OpenClaw Node instance exposed to the REPL commands.
*
* @return
* - `ESP_OK` on success
* - an ESP-IDF error code if the REPL cannot be started
*/
esp_err_t esp_openclaw_node_example_repl_start(esp_openclaw_node_handle_t node);

View File

@ -0,0 +1,308 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include "esp_openclaw_node_example_repl_cmd.h"
#include <stdio.h>
#include <string.h>
#include "esp_check.h"
#include "esp_console.h"
#include "esp_system.h"
#include "esp_openclaw_node_wifi.h"
static const char *TAG = "esp_openclaw_node_repl_cmd";
static esp_openclaw_node_handle_t s_node;
#define ESP_OPENCLAW_NODE_REPL_WIFI_CONNECT_TIMEOUT_TICKS pdMS_TO_TICKS(30000)
static int wait_for_wifi_before_connect(const char *request_name)
{
if (esp_openclaw_node_wifi_is_connected()) {
return 0;
}
printf("waiting for Wi-Fi before %s connect\n", request_name);
if (!esp_openclaw_node_wifi_wait_for_connection(ESP_OPENCLAW_NODE_REPL_WIFI_CONNECT_TIMEOUT_TICKS)) {
printf("%s connect not sent because Wi-Fi is still not connected\n", request_name);
return 1;
}
return 0;
}
static int request_gateway_connect(
const esp_openclaw_node_connect_request_t *request,
const char *request_name)
{
if (s_node == NULL) {
printf("node is not initialized\n");
return 1;
}
if (wait_for_wifi_before_connect(request_name) != 0) {
return 1;
}
esp_err_t err = esp_openclaw_node_request_connect(s_node, request);
if (err != ESP_OK) {
printf("%s connect request failed: %s\n", request_name, esp_err_to_name(err));
return 1;
}
printf("%s connect requested\n", request_name);
return 0;
}
static int connect_setup_code_from_repl(const char *setup_code)
{
if (setup_code == NULL || setup_code[0] == '\0') {
printf("setup-code must not be empty\n");
return 1;
}
const esp_openclaw_node_connect_request_t request = {
.source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE,
.gateway_uri = NULL,
.value = setup_code,
};
return request_gateway_connect(&request, "setup-code");
}
static int connect_secret_from_repl(
const char *gateway_uri,
esp_openclaw_node_connect_source_t source,
const char *secret,
const char *secret_name)
{
const esp_openclaw_node_connect_request_t request = {
.source = source,
.gateway_uri = gateway_uri,
.value = secret,
};
return request_gateway_connect(&request, secret_name);
}
static int connect_no_auth_from_repl(const char *gateway_uri)
{
const esp_openclaw_node_connect_request_t request = {
.source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_NO_AUTH,
.gateway_uri = gateway_uri,
.value = NULL,
};
return request_gateway_connect(&request, "no-auth");
}
static int reconnect_saved_session_from_repl(void)
{
const esp_openclaw_node_connect_request_t request =
(esp_openclaw_node_connect_request_t){
.source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION,
.gateway_uri = NULL,
.value = NULL,
};
return request_gateway_connect(&request, "saved-session");
}
static int disconnect_node_from_repl(void)
{
if (s_node == NULL) {
printf("node is not initialized\n");
return 1;
}
esp_err_t err = esp_openclaw_node_request_disconnect(s_node);
if (err != ESP_OK) {
printf("disconnect request failed: %s\n", esp_err_to_name(err));
return 1;
}
printf("disconnect requested\n");
return 0;
}
static int handle_status_command(int argc, char **argv)
{
(void)argc;
(void)argv;
if (s_node == NULL) {
printf("node is not initialized\n");
return 1;
}
esp_openclaw_node_wifi_status_t wifi_status = {0};
esp_openclaw_node_wifi_get_status(&wifi_status);
printf("saved session available: %s\n", esp_openclaw_node_has_saved_session(s_node) ? "yes" : "no");
printf("wifi configured: %s\n", wifi_status.configured ? "yes" : "no");
if (wifi_status.configured && wifi_status.ssid[0] != '\0') {
printf("wifi ssid: %s\n", wifi_status.ssid);
}
printf("wifi connected: %s\n", wifi_status.connected ? "yes" : "no");
if (!wifi_status.connected && wifi_status.last_disconnect_reason != 0) {
printf("wifi disconnect reason: %u\n", (unsigned)wifi_status.last_disconnect_reason);
}
if (wifi_status.connected && wifi_status.ip[0] != '\0') {
printf("wifi ip: %s\n", wifi_status.ip);
}
return 0;
}
static int handle_wifi_command(int argc, char **argv)
{
if (argc >= 3 && strcmp(argv[1], "set") == 0) {
const char *passphrase = argc >= 4 ? argv[3] : "";
esp_err_t err = esp_openclaw_node_wifi_set_credentials(argv[2], passphrase);
if (err != ESP_OK) {
printf("wifi set failed: %s\n", esp_err_to_name(err));
return 1;
}
printf("wifi credentials saved\n");
return 0;
}
if (argc == 2 && strcmp(argv[1], "clear") == 0) {
esp_err_t err = esp_openclaw_node_wifi_clear_credentials();
if (err != ESP_OK) {
printf("wifi clear failed: %s\n", esp_err_to_name(err));
return 1;
}
printf("wifi credentials cleared\n");
return 0;
}
if (argc == 2 && strcmp(argv[1], "connect") == 0) {
esp_err_t err = esp_openclaw_node_wifi_connect();
if (err != ESP_OK) {
printf("wifi connect failed: %s\n", esp_err_to_name(err));
return 1;
}
printf("wifi connect requested\n");
return 0;
}
if (argc == 2 && strcmp(argv[1], "disconnect") == 0) {
esp_err_t err = esp_openclaw_node_wifi_disconnect();
if (err != ESP_OK) {
printf("wifi disconnect failed: %s\n", esp_err_to_name(err));
return 1;
}
printf("wifi disconnect requested\n");
return 0;
}
printf("usage: wifi set <ssid> [passphrase]\n");
printf(" wifi clear\n");
printf(" wifi connect\n");
printf(" wifi disconnect\n");
return 1;
}
static int handle_gateway_command(int argc, char **argv)
{
if (s_node == NULL) {
printf("node is not initialized\n");
return 1;
}
if (argc == 3 && strcmp(argv[1], "setup-code") == 0) {
return connect_setup_code_from_repl(argv[2]);
}
if (argc == 4 && strcmp(argv[1], "token") == 0) {
return connect_secret_from_repl(
argv[2],
ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_TOKEN,
argv[3],
"token");
}
if (argc == 4 && strcmp(argv[1], "password") == 0) {
return connect_secret_from_repl(
argv[2],
ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_PASSWORD,
argv[3],
"password");
}
if (argc == 3 && strcmp(argv[1], "no-auth") == 0) {
return connect_no_auth_from_repl(argv[2]);
}
if (argc == 2 && strcmp(argv[1], "connect") == 0) {
return reconnect_saved_session_from_repl();
}
if (argc == 2 && strcmp(argv[1], "disconnect") == 0) {
return disconnect_node_from_repl();
}
printf("usage: gateway setup-code <code>\n");
printf(" gateway token <ws://host:port> <token>\n");
printf(" gateway password <ws://host:port> <password>\n");
printf(" gateway no-auth <ws://host:port>\n");
printf(" gateway connect\n");
printf(" gateway disconnect\n");
return 1;
}
static int handle_reboot_command(int argc, char **argv)
{
(void)argc;
(void)argv;
printf("rebooting\n");
fflush(stdout);
esp_restart();
return 0;
}
esp_err_t esp_openclaw_node_example_repl_register_commands(esp_openclaw_node_handle_t node)
{
if (node == NULL) {
return ESP_ERR_INVALID_ARG;
}
s_node = node;
const esp_console_cmd_t status_command = {
.command = "status",
.help = "Show saved-session availability and Wi-Fi state",
.hint = NULL,
.func = handle_status_command,
};
ESP_RETURN_ON_ERROR(
esp_console_cmd_register(&status_command),
TAG,
"registering status command failed");
const esp_console_cmd_t wifi_command = {
.command = "wifi",
.help = "Set, clear, connect, or disconnect Wi-Fi credentials",
.hint = "set <ssid> [passphrase] | clear | connect | disconnect",
.func = handle_wifi_command,
};
ESP_RETURN_ON_ERROR(
esp_console_cmd_register(&wifi_command),
TAG,
"registering wifi command failed");
const esp_console_cmd_t gateway_command = {
.command = "gateway",
.help = "Connect with setup-code, explicit auth, or saved session; or disconnect",
.hint = "setup-code <code> | token <uri> <token> | password <uri> <password> | no-auth <uri> | connect | disconnect",
.func = handle_gateway_command,
};
ESP_RETURN_ON_ERROR(
esp_console_cmd_register(&gateway_command),
TAG,
"registering gateway command failed");
const esp_console_cmd_t reboot_command = {
.command = "reboot",
.help = "Reboot the board immediately",
.hint = NULL,
.func = handle_reboot_command,
};
ESP_RETURN_ON_ERROR(
esp_console_cmd_register(&reboot_command),
TAG,
"registering reboot command failed");
return ESP_OK;
}

View File

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include "esp_err.h"
#include "esp_openclaw_node.h"
/**
* @brief Register the common REPL commands used by the examples.
*
* @param[in] node OpenClaw Node instance targeted by REPL actions.
*
* @return
* - `ESP_OK` on success
* - an ESP-IDF error code if registration fails
*/
esp_err_t esp_openclaw_node_example_repl_register_commands(esp_openclaw_node_handle_t node);

View File

@ -0,0 +1,105 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include "esp_openclaw_node_example_saved_session_reconnect.h"
#include "esp_openclaw_node_wifi.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
static const char *TAG = "oc_reconnect";
static const char *DEFAULT_TASK_NAME = "esp_openclaw_node_reconnect";
static void esp_openclaw_node_example_saved_session_reconnect_task(void *arg)
{
esp_openclaw_node_example_saved_session_reconnect_t *state = arg;
const esp_openclaw_node_connect_request_t request = {
.source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION,
.gateway_uri = NULL,
.value = NULL,
};
for (;;) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
if (!esp_openclaw_node_wifi_wait_for_connection(portMAX_DELAY)) {
continue;
}
if (!esp_openclaw_node_has_saved_session(state->node)) {
ESP_LOGI(TAG, "saved-session reconnect skipped because no saved session is available");
continue;
}
vTaskDelay(pdMS_TO_TICKS(1000));
esp_err_t err = esp_openclaw_node_request_connect(state->node, &request);
if (err == ESP_OK) {
ESP_LOGI(TAG, "requested saved-session reconnect");
} else if (err != ESP_ERR_INVALID_STATE) {
ESP_LOGW(TAG, "saved-session reconnect request failed: %s", esp_err_to_name(err));
}
}
}
esp_err_t esp_openclaw_node_example_saved_session_reconnect_start(
esp_openclaw_node_example_saved_session_reconnect_t *state,
esp_openclaw_node_handle_t node,
const char *task_name)
{
if (state == NULL || node == NULL) {
return ESP_ERR_INVALID_ARG;
}
if (state->task != NULL) {
return ESP_ERR_INVALID_STATE;
}
state->node = node;
BaseType_t task_ok = xTaskCreate(
esp_openclaw_node_example_saved_session_reconnect_task,
task_name != NULL ? task_name : DEFAULT_TASK_NAME,
4096,
state,
5,
&state->task);
if (task_ok != pdPASS) {
state->node = NULL;
state->task = NULL;
return ESP_ERR_NO_MEM;
}
xTaskNotifyGive(state->task);
return ESP_OK;
}
void esp_openclaw_node_example_saved_session_reconnect_handle_event(
esp_openclaw_node_example_saved_session_reconnect_t *state,
esp_openclaw_node_event_t event,
const void *event_data)
{
if (state == NULL || state->task == NULL) {
return;
}
bool should_retry = false;
if (event == ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED) {
const esp_openclaw_node_connect_failed_event_t *failed = event_data;
should_retry =
failed != NULL &&
(failed->reason == ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILED ||
failed->reason == ESP_OPENCLAW_NODE_CONNECT_FAILURE_CONNECTION_LOST);
} else if (event == ESP_OPENCLAW_NODE_EVENT_DISCONNECTED) {
const esp_openclaw_node_disconnected_event_t *disconnected = event_data;
should_retry =
disconnected != NULL &&
disconnected->reason == ESP_OPENCLAW_NODE_DISCONNECTED_REASON_CONNECTION_LOST;
}
if (should_retry) {
xTaskNotifyGive(state->task);
}
}

View File

@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_openclaw_node.h"
/**
* @brief Example-owned saved-session reconnect helper state.
*
* The helper retries only the persisted `{ gateway_uri, device_token }`
* reconnect path. It does not retry explicit setup-code, token, password, or
* no-auth requests.
*/
typedef struct {
esp_openclaw_node_handle_t node; /**< Node to reconnect. */
TaskHandle_t task; /**< Background task that waits for Wi-Fi and reissues reconnects. */
} esp_openclaw_node_example_saved_session_reconnect_t;
/**
* @brief Start the example saved-session reconnect helper.
*
* The helper requests one saved-session connect after Wi-Fi first comes up, and
* then retries the saved session after retryable connection-loss events.
*
* @param[in,out] state Helper state to initialize.
* @param[in] node Node to reconnect.
* @param[in] task_name Optional FreeRTOS task name. Pass `NULL` to use the
* built-in default.
*
* @return
* - `ESP_OK` on success
* - `ESP_ERR_INVALID_ARG` if @p state or @p node is `NULL`
* - `ESP_ERR_INVALID_STATE` if the helper is already running
* - `ESP_ERR_NO_MEM` if the helper task could not be created
*/
esp_err_t esp_openclaw_node_example_saved_session_reconnect_start(
esp_openclaw_node_example_saved_session_reconnect_t *state,
esp_openclaw_node_handle_t node,
const char *task_name);
/**
* @brief Feed node terminal events into the saved-session reconnect helper.
*
* Call this from the example's `esp_openclaw_node_event_cb_t`. The helper retries
* only retryable transport-loss outcomes and does not retry auth rejections.
*
* @param[in,out] state Helper state returned from
* @ref esp_openclaw_node_example_saved_session_reconnect_start.
* @param[in] event Event type from the component callback.
* @param[in] event_data Event payload pointer from the component callback.
*/
void esp_openclaw_node_example_saved_session_reconnect_handle_event(
esp_openclaw_node_example_saved_session_reconnect_t *state,
esp_openclaw_node_event_t event,
const void *event_data);

View File

@ -0,0 +1,392 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include "esp_openclaw_node_wifi.h"
#include <stdbool.h>
#include <string.h>
#include "esp_check.h"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "freertos/event_groups.h"
#include "freertos/semphr.h"
static const char *TAG = "esp_openclaw_node_wifi";
static EventGroupHandle_t s_wifi_events;
static SemaphoreHandle_t s_wifi_lock;
static esp_openclaw_node_wifi_status_t s_status;
static bool s_started;
static bool s_should_reconnect;
#define WIFI_CONNECTED_BIT BIT0
static void clear_ip_fields_locked(void)
{
s_status.connected = false;
s_status.ip[0] = '\0';
s_status.netmask[0] = '\0';
s_status.gateway[0] = '\0';
s_status.rssi = 0;
s_status.channel = 0;
s_status.authmode = WIFI_AUTH_OPEN;
}
static void clear_connection_state(void)
{
if (s_wifi_events != NULL) {
xEventGroupClearBits(s_wifi_events, WIFI_CONNECTED_BIT);
}
if (s_wifi_lock != NULL) {
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
clear_ip_fields_locked();
s_status.last_disconnect_reason = 0;
xSemaphoreGive(s_wifi_lock);
}
}
static void update_credentials_status_locked(const char *ssid)
{
size_t ssid_len = ssid != NULL ? strnlen(ssid, sizeof(s_status.ssid) - 1U) : 0;
s_status.configured = ssid_len > 0;
s_status.ssid[0] = '\0';
if (s_status.configured) {
memcpy(s_status.ssid, ssid, ssid_len);
s_status.ssid[sizeof(s_status.ssid) - 1U] = '\0';
}
}
static void update_credentials_status_from_config_locked(const wifi_config_t *config)
{
size_t ssid_len = strnlen((const char *)config->sta.ssid, sizeof(config->sta.ssid));
s_status.configured = ssid_len > 0;
s_status.ssid[0] = '\0';
if (s_status.configured) {
memcpy(s_status.ssid, config->sta.ssid, ssid_len);
s_status.ssid[ssid_len] = '\0';
}
}
static esp_err_t copy_wifi_config_field(
uint8_t *dst,
size_t dst_size,
const char *value,
const char *field_name)
{
memset(dst, 0, dst_size);
if (value == NULL) {
return ESP_OK;
}
size_t value_len = strlen(value);
if (value_len > dst_size) {
ESP_LOGW(
TAG,
"%s length %u exceeds supported maximum %u bytes",
field_name,
(unsigned)value_len,
(unsigned)dst_size);
return ESP_ERR_INVALID_ARG;
}
memcpy(dst, value, value_len);
return ESP_OK;
}
static esp_err_t init_wifi_config(wifi_config_t *config, const char *ssid, const char *passphrase)
{
memset(config, 0, sizeof(*config));
ESP_RETURN_ON_ERROR(
copy_wifi_config_field(config->sta.ssid, sizeof(config->sta.ssid), ssid, "SSID"),
TAG,
"invalid SSID");
ESP_RETURN_ON_ERROR(
copy_wifi_config_field(
config->sta.password,
sizeof(config->sta.password),
passphrase,
"passphrase"),
TAG,
"invalid passphrase");
config->sta.threshold.authmode = WIFI_AUTH_OPEN;
config->sta.pmf_cfg.capable = true;
config->sta.pmf_cfg.required = false;
return ESP_OK;
}
static void wifi_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data)
{
(void)arg;
if (event_base == WIFI_EVENT) {
switch (event_id) {
case WIFI_EVENT_STA_START:
if (s_wifi_lock != NULL) {
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
bool should_connect = s_status.configured && s_should_reconnect;
char ssid[sizeof(s_status.ssid)] = {0};
strncpy(ssid, s_status.ssid, sizeof(ssid) - 1U);
xSemaphoreGive(s_wifi_lock);
if (should_connect) {
ESP_LOGI(TAG, "connecting to SSID %s", ssid);
if (esp_wifi_connect() != ESP_OK) {
ESP_LOGW(TAG, "initial wifi connect failed");
}
} else {
ESP_LOGI(TAG, "wifi ready; waiting for credentials from REPL");
}
}
break;
case WIFI_EVENT_STA_DISCONNECTED: {
const wifi_event_sta_disconnected_t *event =
(const wifi_event_sta_disconnected_t *)event_data;
bool should_retry = false;
if (s_wifi_events != NULL) {
xEventGroupClearBits(s_wifi_events, WIFI_CONNECTED_BIT);
}
if (s_wifi_lock != NULL) {
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
clear_ip_fields_locked();
s_status.last_disconnect_reason = (uint8_t)event->reason;
should_retry = s_status.configured && s_should_reconnect;
xSemaphoreGive(s_wifi_lock);
}
if (should_retry) {
ESP_LOGW(TAG, "wifi disconnected, reason=%u; reconnecting", (unsigned)event->reason);
esp_err_t err = esp_wifi_connect();
if (err != ESP_OK && err != ESP_ERR_WIFI_CONN) {
ESP_LOGW(TAG, "wifi reconnect failed: %s", esp_err_to_name(err));
}
} else {
ESP_LOGI(TAG, "wifi disconnected, reason=%u", (unsigned)event->reason);
}
break;
}
default:
break;
}
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
const ip_event_got_ip_t *event = (const ip_event_got_ip_t *)event_data;
if (s_wifi_lock != NULL) {
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
s_status.connected = true;
s_status.last_disconnect_reason = 0;
snprintf(s_status.ip, sizeof(s_status.ip), IPSTR, IP2STR(&event->ip_info.ip));
snprintf(s_status.netmask, sizeof(s_status.netmask), IPSTR, IP2STR(&event->ip_info.netmask));
snprintf(s_status.gateway, sizeof(s_status.gateway), IPSTR, IP2STR(&event->ip_info.gw));
xSemaphoreGive(s_wifi_lock);
}
if (s_wifi_events != NULL) {
xEventGroupSetBits(s_wifi_events, WIFI_CONNECTED_BIT);
}
ESP_LOGI(TAG, "got IP " IPSTR, IP2STR(&event->ip_info.ip));
}
}
esp_err_t esp_openclaw_node_wifi_start(void)
{
if (s_started) {
return ESP_OK;
}
s_wifi_events = xEventGroupCreate();
s_wifi_lock = xSemaphoreCreateMutex();
if (s_wifi_events == NULL || s_wifi_lock == NULL) {
return ESP_ERR_NO_MEM;
}
memset(&s_status, 0, sizeof(s_status));
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
esp_err_t err = esp_wifi_init(&cfg);
ESP_RETURN_ON_ERROR(err, TAG, "esp_wifi_init failed");
ESP_RETURN_ON_ERROR(
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL),
TAG,
"register WIFI_EVENT failed");
ESP_RETURN_ON_ERROR(
esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL),
TAG,
"register IP_EVENT failed");
ESP_RETURN_ON_ERROR(esp_wifi_set_storage(WIFI_STORAGE_FLASH), TAG, "esp_wifi_set_storage failed");
ESP_RETURN_ON_ERROR(esp_wifi_set_mode(WIFI_MODE_STA), TAG, "esp_wifi_set_mode failed");
wifi_config_t wifi_config = {0};
err = esp_wifi_get_config(WIFI_IF_STA, &wifi_config);
ESP_RETURN_ON_ERROR(err, TAG, "esp_wifi_get_config failed");
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
update_credentials_status_from_config_locked(&wifi_config);
s_should_reconnect = s_status.configured;
clear_ip_fields_locked();
s_status.last_disconnect_reason = 0;
xSemaphoreGive(s_wifi_lock);
ESP_RETURN_ON_ERROR(esp_wifi_start(), TAG, "esp_wifi_start failed");
ESP_RETURN_ON_ERROR(esp_wifi_set_ps(WIFI_PS_NONE), TAG, "esp_wifi_set_ps failed");
s_started = true;
return ESP_OK;
}
esp_err_t esp_openclaw_node_wifi_set_credentials(const char *ssid, const char *passphrase)
{
if (ssid == NULL || ssid[0] == '\0') {
return ESP_ERR_INVALID_ARG;
}
if (!s_started || s_wifi_lock == NULL) {
return ESP_ERR_INVALID_STATE;
}
const char *resolved_passphrase = passphrase != NULL ? passphrase : "";
wifi_config_t new_config = {0};
ESP_RETURN_ON_ERROR(init_wifi_config(&new_config, ssid, resolved_passphrase), TAG, "invalid wifi config");
ESP_RETURN_ON_ERROR(
esp_wifi_set_config(WIFI_IF_STA, &new_config),
TAG,
"esp_wifi_set_config failed");
clear_connection_state();
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
update_credentials_status_locked(ssid);
s_should_reconnect = true;
xSemaphoreGive(s_wifi_lock);
esp_err_t err = esp_wifi_disconnect();
if (err != ESP_OK && err != ESP_ERR_WIFI_NOT_CONNECT && err != ESP_ERR_WIFI_CONN) {
return err;
}
err = esp_wifi_connect();
if (err == ESP_ERR_WIFI_CONN) {
return ESP_OK;
}
ESP_RETURN_ON_ERROR(err, TAG, "starting wifi connect failed");
return ESP_OK;
}
esp_err_t esp_openclaw_node_wifi_clear_credentials(void)
{
if (!s_started || s_wifi_lock == NULL) {
return ESP_ERR_INVALID_STATE;
}
esp_err_t err = esp_wifi_stop();
if (err != ESP_OK && err != ESP_ERR_WIFI_NOT_INIT) {
return err;
}
ESP_RETURN_ON_ERROR(esp_wifi_restore(), TAG, "esp_wifi_restore failed");
ESP_RETURN_ON_ERROR(esp_wifi_set_storage(WIFI_STORAGE_FLASH), TAG, "esp_wifi_set_storage failed");
ESP_RETURN_ON_ERROR(esp_wifi_set_mode(WIFI_MODE_STA), TAG, "esp_wifi_set_mode failed");
clear_connection_state();
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
update_credentials_status_locked(NULL);
s_should_reconnect = false;
xSemaphoreGive(s_wifi_lock);
ESP_RETURN_ON_ERROR(esp_wifi_start(), TAG, "esp_wifi_start failed");
ESP_RETURN_ON_ERROR(esp_wifi_set_ps(WIFI_PS_NONE), TAG, "esp_wifi_set_ps failed");
return ESP_OK;
}
esp_err_t esp_openclaw_node_wifi_connect(void)
{
if (!s_started || s_wifi_lock == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
bool have_credentials = s_status.configured;
s_should_reconnect = have_credentials;
xSemaphoreGive(s_wifi_lock);
if (!have_credentials) {
return ESP_ERR_INVALID_STATE;
}
esp_err_t err = esp_wifi_connect();
if (err == ESP_ERR_WIFI_CONN) {
return ESP_OK;
}
ESP_RETURN_ON_ERROR(err, TAG, "starting wifi connect failed");
return ESP_OK;
}
esp_err_t esp_openclaw_node_wifi_disconnect(void)
{
if (!s_started || s_wifi_lock == NULL) {
return ESP_ERR_INVALID_STATE;
}
clear_connection_state();
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
s_should_reconnect = false;
xSemaphoreGive(s_wifi_lock);
esp_err_t err = esp_wifi_disconnect();
if (err == ESP_ERR_WIFI_NOT_CONNECT) {
err = ESP_OK;
}
return err;
}
bool esp_openclaw_node_wifi_wait_for_connection(TickType_t timeout_ticks)
{
if (s_wifi_events == NULL) {
return false;
}
EventBits_t bits = xEventGroupWaitBits(
s_wifi_events,
WIFI_CONNECTED_BIT,
pdFALSE,
pdFALSE,
timeout_ticks);
return (bits & WIFI_CONNECTED_BIT) != 0;
}
bool esp_openclaw_node_wifi_is_connected(void)
{
if (s_wifi_lock == NULL) {
return false;
}
bool connected = false;
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
connected = s_status.connected;
xSemaphoreGive(s_wifi_lock);
return connected;
}
void esp_openclaw_node_wifi_get_status(esp_openclaw_node_wifi_status_t *status)
{
if (status == NULL) {
return;
}
memset(status, 0, sizeof(*status));
if (s_wifi_lock == NULL) {
return;
}
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
*status = s_status;
xSemaphoreGive(s_wifi_lock);
if (status->connected) {
wifi_ap_record_t ap_info = {0};
if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) {
status->rssi = ap_info.rssi;
status->channel = ap_info.primary;
status->authmode = (uint8_t)ap_info.authmode;
}
}
}

View File

@ -0,0 +1,77 @@
/*
* 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"
#include "freertos/FreeRTOS.h"
/** @brief Snapshot of the example Wi-Fi station state. */
typedef struct {
bool configured; /**< Whether station credentials are currently stored in NVS. */
bool connected; /**< Whether the station currently has a live connection and IP. */
char ssid[33]; /**< Saved station SSID, when configured. */
char ip[16]; /**< Current IPv4 address in dotted-quad form when connected. */
char netmask[16]; /**< Current IPv4 netmask in dotted-quad form when connected. */
char gateway[16]; /**< Current IPv4 gateway in dotted-quad form when connected. */
int8_t rssi; /**< Current RSSI from `esp_wifi_sta_get_ap_info()` when connected. */
uint8_t channel; /**< Current primary Wi-Fi channel when connected. */
uint8_t authmode; /**< Current AP auth mode reported by ESP-IDF when connected. */
uint8_t last_disconnect_reason; /**< Last ESP-IDF disconnect reason code, or `0` when clear. */
} esp_openclaw_node_wifi_status_t;
/**
* @brief Initialize the example Wi-Fi station support.
*
* The helper restores any previously stored station credentials from NVS and
* starts the ESP-IDF station interface.
*/
esp_err_t esp_openclaw_node_wifi_start(void);
/**
* @brief Persist Wi-Fi station credentials and start connecting immediately.
*
* Passing `NULL` for @p passphrase is treated the same as an empty string.
* The helper rejects values that do not fit the ESP-IDF station config fields
* exactly, instead of truncating them before storage.
*/
esp_err_t esp_openclaw_node_wifi_set_credentials(const char *ssid, const char *passphrase);
/**
* @brief Remove any persisted Wi-Fi station credentials.
*
* This helper clears the example's station configuration from flash and
* restarts the station in an unconfigured state.
*/
esp_err_t esp_openclaw_node_wifi_clear_credentials(void);
/** @brief Start or resume a Wi-Fi station connection attempt using saved credentials. */
esp_err_t esp_openclaw_node_wifi_connect(void);
/** @brief Disconnect the Wi-Fi station if it is currently connected. */
esp_err_t esp_openclaw_node_wifi_disconnect(void);
/**
* @brief Wait for the station to connect.
*
* @param[in] timeout_ticks Maximum time to wait, in FreeRTOS ticks.
*
* @return `true` if the station connected before the timeout, otherwise `false`.
*/
bool esp_openclaw_node_wifi_wait_for_connection(TickType_t timeout_ticks);
/** @brief Return whether the Wi-Fi station is currently connected and has an IP. */
bool esp_openclaw_node_wifi_is_connected(void);
/**
* @brief Copy the current Wi-Fi station status into @p status.
*
* @param[out] status Destination status structure to populate.
*/
void esp_openclaw_node_wifi_get_status(esp_openclaw_node_wifi_status_t *status);

6
examples/esp-box-3-display/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
build/
managed_components/
dependencies.lock
sdkconfig
sdkconfig.old
sdkconfig.ci

View File

@ -0,0 +1,7 @@
cmake_minimum_required(VERSION 3.16)
set(EXTRA_COMPONENT_DIRS ../../components)
set(PROJECT_VER "1.0.0")
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(esp_openclaw_node_esp_box_3_display)

View File

@ -0,0 +1,214 @@
# ESP-BOX-3 Display Example
This example runs an ESP-BOX-3 as an OpenClaw Node with display commands for the built-in screen.
<a href="../../docs/assets/openclaw-gateway-esp-box-3-message-flow.png">
<img src="../../docs/assets/openclaw-gateway-esp-box-3-message-flow.png" alt="OpenClaw Gateway to ESP-BOX-3 message flow" width="900">
</a>
Commands below assume the default OpenClaw install. If you use a named profile, add `--profile <profile>` to the `openclaw` commands.
## What This Example Exposes
- `device`
- `wifi`
- `display`
Commands:
- `device.info`
- `device.status`
- `wifi.status`
- `display.show`
- `display.status`
## Display Payload
`display.show` accepts:
- `heading` short title text, up to `64` UTF-8 bytes
- `text` body text, up to `512` UTF-8 bytes
Sample payload:
```json
{
"heading": "Hello",
"text": "OpenClaw is driving the ESP-BOX-3 display."
}
```
On boot, the screen shows a waiting message until the node is paired.
## Prepare The Gateway
If the board will connect over Wi-Fi to a gateway running on another machine, set `gateway.bind` to `lan` first. The default loopback bind is only reachable from the gateway host itself.
Allow this example's commands before pairing the board:
Warning: this command replaces the existing `gateway.nodes.allowCommands` value in the active profile.
```bash
openclaw config set gateway.bind lan
openclaw config set gateway.nodes.allowCommands '[
"device.info",
"device.status",
"wifi.status",
"display.show",
"display.status"
]' --strict-json
openclaw gateway restart
openclaw gateway status --probe --json
```
These steps start from an existing OpenClaw gateway that the board can reach on your LAN.
## Build
```bash
. ~/esp-idf/export.sh
cd /path/to/repo/examples/esp-box-3-display
idf.py set-target esp32s3
idf.py build
```
## Flash
```bash
. ~/esp-idf/export.sh
cd /path/to/repo/examples/esp-box-3-display
idf.py -p <serial-port> flash monitor
```
## Main REPL Commands
After boot, the example starts the same serial REPL used by the generic ESP32
node example. On the ESP-BOX-3 it is exposed over the native USB Serial/JTAG
console.
The example automatically requests saved-session reconnect after Wi-Fi obtains
an IP and after ordinary connection-loss events. If no saved reconnect session
exists yet, those reconnect attempts are skipped and the board waits for an
explicit gateway auth command.
Start with these commands:
- `status` print saved-session availability and Wi-Fi state
- `wifi set <ssid> [passphrase]` store Wi-Fi credentials in NVS and connect immediately
- Use `wifi set <ssid>` for an open network.
- Use `wifi set <ssid> <passphrase>` for a secured network.
- `gateway setup-code <setup-code>` request one setup-code connect attempt; if Wi-Fi is still coming up, the REPL waits for an IP first
- `gateway token <uri> <token>` request one explicit shared-token connect attempt
- `gateway password <uri> <password>` request one explicit password connect attempt
- `gateway no-auth <uri>` request one explicit no-auth connect attempt
- `gateway connect` request one reconnect attempt using the saved reconnect session immediately
- `gateway disconnect` request disconnect of the active session
- `reboot` reboot the board immediately
`status` prints these fields:
- `saved session available`: whether a persisted `{ gateway_uri, device_token }` reconnect session is stored
- `wifi configured`: whether Wi-Fi credentials are saved in NVS
- `wifi ssid`: the saved SSID, when Wi-Fi is configured
- `wifi connected`: whether the board currently has a Wi-Fi connection
- `wifi disconnect reason`: the most recent ESP-IDF disconnect reason, when Wi-Fi is not connected
- `wifi ip`: the current IPv4 address, when Wi-Fi is connected
## First Connection
Generate a setup code on the gateway host:
```bash
openclaw qr \
--url ws://<gateway-host-ip>:<gateway-port> \
--setup-code-only
```
The setup code contains a short-lived `bootstrapToken`, not the gateway's shared token.
Bring the board online from the serial REPL:
```text
openclaw> status
openclaw> wifi set <ssid> <passphrase>
openclaw> gateway setup-code <setup-code>
openclaw> status
```
`gateway setup-code <setup-code>` already requests the connection attempt. If
Wi-Fi is still associating, the REPL waits for an IP before it submits that
attempt. Once a saved reconnect session exists, the example retries it
automatically after Wi-Fi or gateway interruptions. Use `gateway connect` when
you want to trigger that saved-session reconnect immediately.
Then verify the node from the gateway host:
```bash
openclaw nodes status --json
openclaw nodes invoke --node <node-id> --command device.info --json
openclaw nodes invoke --node <node-id> --command display.status --json
```
If pairing did not complete as expected, use [Troubleshooting](../../docs/troubleshooting.md).
## Use The Node
Get basic information:
```bash
openclaw nodes invoke --node <node-id> --command device.info --json
openclaw nodes invoke --node <node-id> --command device.status --json
openclaw nodes invoke --node <node-id> --command wifi.status --json
openclaw nodes invoke --node <node-id> --command display.status --json
```
Update the display:
```bash
openclaw nodes invoke \
--node <node-id> \
--command display.show \
--params '{"heading":"Hello","text":"OpenClaw is driving the ESP-BOX-3 display."}' \
--json
```
## Other CLI Commands
Useful when you want to test more than the standard setup-code flow:
- `wifi clear`
- `wifi connect`
- `wifi disconnect`
- `reboot`
- `gateway setup-code <code>`
- `gateway no-auth <ws://host:port>`
- `gateway token <ws://host:port> <token>`
- `gateway password <ws://host:port> <password>`
- `gateway connect`
- `gateway disconnect`
<details>
<summary>Gateway command behavior</summary>
- `gateway setup-code ...` performs one explicit setup-code connection attempt
after Wi-Fi is online
- `gateway no-auth ...` requests one explicit no-auth connect attempt
- `gateway token ...` requests one explicit shared-token connect attempt
- `gateway password ...` requests one explicit password connect attempt
- `gateway connect` performs one reconnect attempt with the saved reconnect session
- `gateway disconnect` is valid only while the session is connected
- This example automatically retries the saved reconnect session after connection loss once Wi-Fi is back
</details>
## Troubleshooting And Reference
- [Troubleshooting](../../docs/troubleshooting.md)
- [Component README](../../components/esp-openclaw-node/README.md)
## Notes
- The `esp32s3` target is fixed for this example.
- The node display name is `OpenClaw ESP-BOX-3`.

View File

@ -0,0 +1,28 @@
idf_component_register(
SRCS
"app_main.c"
"../../common/esp_openclaw_node_common_device_node_cmd.c"
"../../common/esp_openclaw_node_example_json.c"
"../../common/esp_openclaw_node_example_repl.c"
"../../common/esp_openclaw_node_example_repl_cmd.c"
"../../common/esp_openclaw_node_example_saved_session_reconnect.c"
"../../common/esp_openclaw_node_wifi.c"
"esp_openclaw_node_box_cmd.c"
"esp_openclaw_node_box_display.c"
"esp_openclaw_node_box_display_node_cmd.c"
INCLUDE_DIRS
"."
"../../common"
REQUIRES
esp-openclaw-node
esp-box-3
esp_app_format
console
esp_event
esp_netif
esp_timer
esp_wifi
espressif__cjson
mbedtls
nvs_flash
)

View File

@ -0,0 +1,3 @@
menu "OpenClaw Example"
endmenu

View File

@ -0,0 +1,91 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include "esp_openclaw_node_box_cmd.h"
#include "esp_openclaw_node_example_repl.h"
#include "esp_openclaw_node_example_repl_cmd.h"
#include "esp_openclaw_node_example_saved_session_reconnect.h"
#include "esp_openclaw_node.h"
#include "esp_openclaw_node_wifi.h"
#include "bsp/esp-box-3.h"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "nvs_flash.h"
static const char *TAG = "app_main";
static esp_openclaw_node_handle_t s_node;
static esp_openclaw_node_box_display_t s_display;
static esp_openclaw_node_example_saved_session_reconnect_t s_saved_session_reconnect;
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;
esp_openclaw_node_example_saved_session_reconnect_handle_event(
(esp_openclaw_node_example_saved_session_reconnect_t *)user_ctx,
event,
event_data);
if (event == ESP_OPENCLAW_NODE_EVENT_CONNECTED) {
ESP_LOGI(TAG, "OpenClaw session connected");
} else if (event == ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED) {
const esp_openclaw_node_connect_failed_event_t *failed = event_data;
ESP_LOGW(
TAG,
"OpenClaw connect failed: reason=%d local_err=%s gateway_detail=%s",
failed != NULL ? failed->reason : -1,
failed != NULL ? esp_err_to_name(failed->local_err) : "n/a",
failed != NULL && failed->gateway_detail_code != NULL ? failed->gateway_detail_code : "");
} else if (event == ESP_OPENCLAW_NODE_EVENT_DISCONNECTED) {
const esp_openclaw_node_disconnected_event_t *disconnected = event_data;
ESP_LOGW(
TAG,
"OpenClaw disconnected: reason=%d local_err=%s",
disconnected != NULL ? disconnected->reason : -1,
disconnected != NULL ? esp_err_to_name(disconnected->local_err) : "n/a");
}
}
void app_main(void)
{
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
ESP_ERROR_CHECK(esp_openclaw_node_box_display_start(&s_display));
ESP_ERROR_CHECK(esp_openclaw_node_wifi_start());
esp_openclaw_node_config_t node_config = {0};
esp_openclaw_node_config_init_default(&node_config);
node_config.display_name = "OpenClaw ESP-BOX-3";
node_config.event_cb = handle_node_event;
node_config.event_user_ctx = &s_saved_session_reconnect;
ESP_ERROR_CHECK(esp_openclaw_node_create(&node_config, &s_node));
ESP_ERROR_CHECK(esp_openclaw_node_box_register_node_commands(s_node, &s_display));
ESP_ERROR_CHECK(
esp_openclaw_node_example_saved_session_reconnect_start(
&s_saved_session_reconnect,
s_node,
"esp_openclaw_node_reconnect"));
ESP_ERROR_CHECK(esp_openclaw_node_example_repl_start(s_node));
if (!esp_openclaw_node_wifi_is_connected()) {
ESP_LOGI(TAG, "wifi not connected yet; provision it from the REPL or wait for Wi-Fi to reconnect");
ESP_LOGI(TAG, "after Wi-Fi is up, use `gateway setup-code`, `gateway token`, `gateway password`, or `gateway no-auth` for a first connect");
ESP_LOGI(TAG, "saved-session reconnect runs automatically after Wi-Fi returns when a saved session is present");
}
}

View File

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include "esp_openclaw_node_box_cmd.h"
#include "esp_openclaw_node_box_display_node_cmd.h"
#include "esp_openclaw_node_common_device_node_cmd.h"
#include "esp_check.h"
static const char *TAG = "esp_openclaw_node_box_cmd";
esp_err_t esp_openclaw_node_box_register_node_commands(
esp_openclaw_node_handle_t node,
esp_openclaw_node_box_display_t *display)
{
ESP_RETURN_ON_ERROR(
esp_openclaw_node_common_register_device_node_commands(node),
TAG,
"registering device commands failed");
ESP_RETURN_ON_ERROR(
esp_openclaw_node_box_register_display_node_commands(node, display),
TAG,
"registering display commands failed");
return ESP_OK;
}

View File

@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include "esp_err.h"
#include "esp_openclaw_node_box_display.h"
#include "esp_openclaw_node.h"
/**
* @brief Register the full ESP-BOX-3 command set.
*
* The helper adds the shared `device.*` and `wifi.status` commands plus the
* display commands backed by @p display.
*
* @param[in] node OpenClaw Node instance to extend.
* @param[in] display Display state used by the display command handlers.
*
* @return
* - `ESP_OK` on success
* - `ESP_ERR_INVALID_ARG` if `display` is `NULL`
* - an ESP-IDF error code if registration fails
*/
esp_err_t esp_openclaw_node_box_register_node_commands(
esp_openclaw_node_handle_t node,
esp_openclaw_node_box_display_t *display);

View File

@ -0,0 +1,152 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include "esp_openclaw_node_box_display.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "bsp/esp-box-3.h"
#include "cJSON.h"
#include "esp_check.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "lvgl.h"
#include "esp_openclaw_node_example_json.h"
static const char *TAG = "esp_openclaw_node_box_display";
static const char *DEFAULT_HEADING = "OpenClaw";
static const char *DEFAULT_TEXT = "Waiting for display.show from the OpenClaw gateway.";
esp_err_t esp_openclaw_node_box_display_build_status_payload(
const esp_openclaw_node_box_display_t *display,
char **out_payload_json)
{
if (display == NULL || out_payload_json == NULL) {
return ESP_ERR_INVALID_ARG;
}
cJSON *payload = cJSON_CreateObject();
if (payload == NULL) {
return ESP_ERR_NO_MEM;
}
cJSON_AddBoolToObject(payload, "ready", display->ready);
cJSON_AddStringToObject(payload, "heading", display->heading);
cJSON_AddStringToObject(payload, "text", display->text);
cJSON_AddNumberToObject(payload, "renderCount", display->render_count);
cJSON_AddNumberToObject(payload, "lastRenderMs", (double)display->last_render_ms);
cJSON_AddNumberToObject(payload, "headingMaxLength", ESP_OPENCLAW_NODE_BOX_DISPLAY_MAX_HEADING_LEN);
cJSON_AddNumberToObject(payload, "textMaxLength", ESP_OPENCLAW_NODE_BOX_DISPLAY_MAX_TEXT_LEN);
cJSON_AddNumberToObject(payload, "width", BSP_LCD_H_RES);
cJSON_AddNumberToObject(payload, "height", BSP_LCD_V_RES);
return esp_openclaw_node_example_take_json_payload(payload, out_payload_json);
}
static void apply_render_locked(esp_openclaw_node_box_display_t *display)
{
lv_label_set_text(display->heading_label, display->heading);
lv_label_set_text(display->text_label, display->text);
}
esp_err_t esp_openclaw_node_box_display_render(
esp_openclaw_node_box_display_t *display,
const char *heading,
const char *text)
{
if (display == NULL || heading == NULL || text == NULL || !display->ready) {
return ESP_ERR_INVALID_STATE;
}
if (!bsp_display_lock(0)) {
return ESP_ERR_TIMEOUT;
}
snprintf(display->heading, sizeof(display->heading), "%s", heading);
snprintf(display->text, sizeof(display->text), "%s", text);
apply_render_locked(display);
bsp_display_unlock();
display->render_count += 1U;
display->last_render_ms = esp_timer_get_time() / 1000LL;
return ESP_OK;
}
static void create_display_ui_locked(esp_openclaw_node_box_display_t *display)
{
lv_obj_t *screen = lv_scr_act();
lv_obj_clean(screen);
lv_obj_set_style_bg_color(screen, lv_color_hex(0x0f172a), 0);
lv_obj_set_style_bg_opa(screen, LV_OPA_COVER, 0);
display->container = lv_obj_create(screen);
lv_obj_set_size(display->container, lv_pct(100), lv_pct(100));
lv_obj_center(display->container);
lv_obj_set_style_radius(display->container, 0, 0);
lv_obj_set_style_border_width(display->container, 0, 0);
lv_obj_set_style_bg_opa(display->container, LV_OPA_TRANSP, 0);
lv_obj_set_style_pad_left(display->container, 18, 0);
lv_obj_set_style_pad_right(display->container, 18, 0);
lv_obj_set_style_pad_top(display->container, 20, 0);
lv_obj_set_style_pad_bottom(display->container, 20, 0);
lv_obj_set_style_pad_row(display->container, 12, 0);
lv_obj_set_scrollbar_mode(display->container, LV_SCROLLBAR_MODE_OFF);
lv_obj_set_flex_flow(display->container, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(
display->container,
LV_FLEX_ALIGN_START,
LV_FLEX_ALIGN_START,
LV_FLEX_ALIGN_START);
display->heading_label = lv_label_create(display->container);
lv_obj_set_width(display->heading_label, lv_pct(100));
lv_label_set_long_mode(display->heading_label, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_color(display->heading_label, lv_color_hex(0xf8fafc), 0);
lv_obj_set_style_text_font(display->heading_label, &lv_font_montserrat_22, 0);
display->text_label = lv_label_create(display->container);
lv_obj_set_width(display->text_label, lv_pct(100));
lv_label_set_long_mode(display->text_label, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_color(display->text_label, lv_color_hex(0xcbd5e1), 0);
lv_obj_set_style_text_line_space(display->text_label, 6, 0);
lv_obj_set_style_text_font(display->text_label, &lv_font_montserrat_16, 0);
}
esp_err_t esp_openclaw_node_box_display_start(esp_openclaw_node_box_display_t *display)
{
if (display == NULL) {
return ESP_ERR_INVALID_ARG;
}
memset(display, 0, sizeof(*display));
ESP_RETURN_ON_ERROR(bsp_i2c_init(), TAG, "bsp_i2c_init failed");
bsp_display_cfg_t cfg = {
.lvgl_port_cfg = ESP_LVGL_PORT_INIT_CONFIG(),
.buffer_size = BSP_LCD_H_RES * CONFIG_BSP_LCD_DRAW_BUF_HEIGHT,
.double_buffer = 0,
.flags = {
.buff_dma = true,
},
};
display->lv_display = bsp_display_start_with_config(&cfg);
if (display->lv_display == NULL) {
return ESP_FAIL;
}
ESP_RETURN_ON_ERROR(bsp_display_backlight_on(), TAG, "bsp_display_backlight_on failed");
if (!bsp_display_lock(0)) {
return ESP_ERR_TIMEOUT;
}
create_display_ui_locked(display);
bsp_display_unlock();
display->ready = true;
display->last_render_ms = 0;
return esp_openclaw_node_box_display_render(display, DEFAULT_HEADING, DEFAULT_TEXT);
}

View File

@ -0,0 +1,82 @@
/*
* 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"
/** @brief Maximum UTF-8 heading length accepted by `display.show`. */
#define ESP_OPENCLAW_NODE_BOX_DISPLAY_MAX_HEADING_LEN 64
/** @brief Maximum UTF-8 body length accepted by `display.show`. */
#define ESP_OPENCLAW_NODE_BOX_DISPLAY_MAX_TEXT_LEN 512
typedef struct _lv_display_t lv_display_t;
typedef struct _lv_obj_t lv_obj_t;
/**
* @brief Display state shared by the ESP-BOX-3 example modules.
*/
typedef struct {
bool ready; /**< Whether the display runtime has been initialized successfully. */
char heading[ESP_OPENCLAW_NODE_BOX_DISPLAY_MAX_HEADING_LEN + 1]; /**< Last rendered heading text. */
char text[ESP_OPENCLAW_NODE_BOX_DISPLAY_MAX_TEXT_LEN + 1]; /**< Last rendered body text. */
uint32_t render_count; /**< Number of successful render operations since boot. */
int64_t last_render_ms; /**< Timestamp of the most recent render in milliseconds since boot. */
lv_display_t *lv_display; /**< Underlying LVGL display handle owned by the example. */
lv_obj_t *container; /**< Root LVGL container for the example screen. */
lv_obj_t *heading_label; /**< LVGL label used for the heading line. */
lv_obj_t *text_label; /**< LVGL label used for the body text block. */
} esp_openclaw_node_box_display_t;
/**
* @brief Initialize the ESP-BOX-3 display runtime and render the boot screen.
*
* @param[out] display Display state to initialize.
*
* @return
* - `ESP_OK` on success
* - `ESP_ERR_INVALID_ARG` if `display` is `NULL`
* - an ESP-IDF error code if board or LVGL setup fails
*/
esp_err_t esp_openclaw_node_box_display_start(esp_openclaw_node_box_display_t *display);
/**
* @brief Render new heading and body text on the display.
*
* @param[in,out] display Initialized display state.
* @param[in] heading Short heading text.
* @param[in] text Body text.
*
* @return
* - `ESP_OK` on success
* - `ESP_ERR_INVALID_ARG` if any argument is invalid
* - `ESP_ERR_INVALID_STATE` if the display is not ready
*/
esp_err_t esp_openclaw_node_box_display_render(
esp_openclaw_node_box_display_t *display,
const char *heading,
const char *text);
/**
* @brief Build the JSON payload returned by `display.status` and `display.show`.
*
* Ownership of the returned buffer transfers to the caller, which must free it
* with a `malloc()`-compatible allocator.
*
* @param[in] display Display state to serialize.
* @param[out] out_payload_json Allocated UTF-8 JSON string on success.
*
* @return
* - `ESP_OK` on success
* - `ESP_ERR_INVALID_ARG` if any argument is invalid
* - `ESP_ERR_NO_MEM` if allocation fails
*/
esp_err_t esp_openclaw_node_box_display_build_status_payload(
const esp_openclaw_node_box_display_t *display,
char **out_payload_json);

View File

@ -0,0 +1,147 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include "esp_openclaw_node_box_display_node_cmd.h"
#include <string.h>
#include "cJSON.h"
#include "esp_check.h"
#include "esp_openclaw_node_example_json.h"
static const char *TAG = "esp_openclaw_node_display_cmd";
static esp_err_t parse_required_text(
cJSON *params,
const char *name,
size_t max_len,
const char **out_value,
esp_openclaw_node_error_t *out_error)
{
cJSON *field = cJSON_GetObjectItemCaseSensitive(params, name);
if (!cJSON_IsString(field) || field->valuestring == NULL) {
out_error->code = "INVALID_PARAMS";
out_error->message = "display params must include string heading and text fields";
return ESP_ERR_INVALID_ARG;
}
if (strlen(field->valuestring) > max_len) {
out_error->code = "INVALID_PARAMS";
out_error->message = "display field exceeds maximum supported length";
return ESP_ERR_INVALID_ARG;
}
*out_value = field->valuestring;
return ESP_OK;
}
static esp_err_t handle_display_show(
esp_openclaw_node_handle_t node,
void *context,
const char *params_json,
size_t params_len,
char **out_payload_json,
esp_openclaw_node_error_t *out_error)
{
(void)node;
(void)params_len;
esp_openclaw_node_box_display_t *display = (esp_openclaw_node_box_display_t *)context;
const char *heading = NULL;
const char *text = NULL;
cJSON *params = NULL;
esp_err_t err = esp_openclaw_node_example_parse_json_params(params_json, &params, out_error);
if (err != ESP_OK) {
ESP_RETURN_ON_ERROR(err, TAG, "invalid params");
}
err = parse_required_text(
params,
"heading",
ESP_OPENCLAW_NODE_BOX_DISPLAY_MAX_HEADING_LEN,
&heading,
out_error);
if (err != ESP_OK) {
cJSON_Delete(params);
ESP_RETURN_ON_ERROR(err, TAG, "invalid heading");
}
err = parse_required_text(
params,
"text",
ESP_OPENCLAW_NODE_BOX_DISPLAY_MAX_TEXT_LEN,
&text,
out_error);
if (err != ESP_OK) {
cJSON_Delete(params);
ESP_RETURN_ON_ERROR(err, TAG, "invalid text");
}
if (esp_openclaw_node_box_display_render(display, heading, text) != ESP_OK) {
out_error->code = "UNAVAILABLE";
out_error->message = "display renderer is not ready";
cJSON_Delete(params);
return ESP_ERR_INVALID_STATE;
}
cJSON_Delete(params);
return esp_openclaw_node_box_display_build_status_payload(display, out_payload_json);
}
static esp_err_t handle_display_status(
esp_openclaw_node_handle_t node,
void *context,
const char *params_json,
size_t params_len,
char **out_payload_json,
esp_openclaw_node_error_t *out_error)
{
(void)node;
(void)params_json;
(void)params_len;
(void)out_error;
esp_openclaw_node_box_display_t *display = (esp_openclaw_node_box_display_t *)context;
return esp_openclaw_node_box_display_build_status_payload(display, out_payload_json);
}
esp_err_t esp_openclaw_node_box_register_display_node_commands(
esp_openclaw_node_handle_t node,
esp_openclaw_node_box_display_t *display)
{
if (display == NULL) {
return ESP_ERR_INVALID_ARG;
}
static const esp_openclaw_node_command_t DISPLAY_SHOW_COMMAND = {
.name = "display.show",
.handler = handle_display_show,
.context = NULL,
};
static const esp_openclaw_node_command_t DISPLAY_STATUS_COMMAND = {
.name = "display.status",
.handler = handle_display_status,
.context = NULL,
};
esp_openclaw_node_command_t show_command = DISPLAY_SHOW_COMMAND;
show_command.context = display;
esp_openclaw_node_command_t status_command = DISPLAY_STATUS_COMMAND;
status_command.context = display;
ESP_RETURN_ON_ERROR(
esp_openclaw_node_register_capability(node, "display"),
TAG,
"registering display capability failed");
ESP_RETURN_ON_ERROR(
esp_openclaw_node_register_command(node, &show_command),
TAG,
"registering display.show failed");
ESP_RETURN_ON_ERROR(
esp_openclaw_node_register_command(node, &status_command),
TAG,
"registering display.status failed");
return ESP_OK;
}

View File

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include "esp_err.h"
#include "esp_openclaw_node_box_display.h"
#include "esp_openclaw_node.h"
/**
* @brief Register the display-specific OpenClaw commands for the ESP-BOX-3.
*
* The helper adds the `display.show` and `display.status` commands.
*
* @param[in] node OpenClaw Node instance to extend.
* @param[in] display Display state used by the command handlers.
*
* @return
* - `ESP_OK` on success
* - `ESP_ERR_INVALID_ARG` if `display` is `NULL`
* - an ESP-IDF error code if registration fails
*/
esp_err_t esp_openclaw_node_box_register_display_node_commands(
esp_openclaw_node_handle_t node,
esp_openclaw_node_box_display_t *display);

View File

@ -0,0 +1,4 @@
dependencies:
idf: ">=5.3"
espressif/esp-box-3:
version: "^3.2"

View File

@ -0,0 +1,4 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 0x400000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x6000
3 phy_init data phy 0xf000 0x1000
4 factory app factory 0x10000 0x400000

View File

@ -0,0 +1,34 @@
CONFIG_IDF_TARGET="esp32s3"
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
CONFIG_ESP_SYSTEM_PANIC_PRINT_HALT=y
CONFIG_ESP32S3_DATA_CACHE_64KB=y
CONFIG_ESP32S3_DATA_CACHE_LINE_64B=y
CONFIG_ESP32S3_INSTRUCTION_CACHE_32KB=y
CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_OCT=y
CONFIG_SPIRAM_SPEED_80M=y
CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_LV_FONT_MONTSERRAT_16=y
CONFIG_LV_FONT_MONTSERRAT_22=y
CONFIG_LV_MEM_SIZE_KILOBYTES=64
CONFIG_LV_BUILD_EXAMPLES=n
CONFIG_LV_BUILD_DEMOS=n
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=y
CONFIG_MBEDTLS_SSL_PROTO_TLS1_3=y
# The ESP-BOX-3 BSP stack is substantially larger than the generic examples.
# Use a larger single-app layout on the board's 16 MB flash.
# CONFIG_PARTITION_TABLE_SINGLE_APP is not set
# CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE is not set
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
CONFIG_BSP_LCD_DRAW_BUF_HEIGHT=20

6
examples/esp32-node/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
build/
managed_components/
dependencies.lock
sdkconfig
sdkconfig.old
sdkconfig.ci

View File

@ -0,0 +1,7 @@
cmake_minimum_required(VERSION 3.16)
set(EXTRA_COMPONENT_DIRS ../../components)
set(PROJECT_VER "1.0.0")
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(esp_openclaw_node_esp32)

View File

@ -0,0 +1,204 @@
# ESP32 Wi-Fi Node Example
This example runs a Wi-Fi-capable ESP32 board as an OpenClaw Node. It uses the shared serial REPL included with the examples in this repository.
You can build it for ESP32 targets with Wi-Fi support. By default, the node metadata comes from the selected `idf.py` target.
Commands below assume the default OpenClaw install. If you use a named profile, add `--profile <profile>` to the `openclaw` commands.
## What This Example Exposes
- `device`
- `wifi`
- `gpio`
- `adc` on ADC-capable targets
Commands:
- `device.info`
- `device.status`
- `wifi.status`
- `gpio.mode`
- `gpio.read`
- `gpio.write`
- `adc.read`
`adc.read` is only registered when the selected target supports ADC.
## Prepare The Gateway
If the board will connect over Wi-Fi to a gateway running on another machine, set `gateway.bind` to `lan` first. The default loopback bind is only reachable from the gateway host itself.
Set the command allowlist before pairing the board. Without it, the node can connect and still show `commands: []`.
Warning: this command replaces the existing `gateway.nodes.allowCommands` value in the active profile.
```bash
openclaw config set gateway.bind lan
openclaw config set gateway.nodes.allowCommands '[
"device.info",
"device.status",
"wifi.status",
"gpio.mode",
"gpio.read",
"gpio.write",
"adc.read"
]' --strict-json
openclaw gateway restart
openclaw gateway status --probe --json
```
These steps start from an existing OpenClaw gateway that the board can reach on your LAN.
## Build
```bash
. ~/esp-idf/export.sh
cd /path/to/repo/examples/esp32-node
idf.py set-target <target>
idf.py build
```
## Flash
```bash
. ~/esp-idf/export.sh
cd /path/to/repo/examples/esp32-node
idf.py -p <serial-port> flash monitor
```
## Main REPL Commands
After boot, the example starts a serial REPL on the board's primary console. Most targets use the same console as `idf.py monitor`.
The example automatically requests saved-session reconnect after Wi-Fi obtains
an IP and after ordinary connection-loss events. If no saved reconnect session
exists yet, those reconnect attempts are skipped and the board waits for an
explicit gateway auth command.
Start with these commands:
- `status` print saved-session availability and Wi-Fi state
- `wifi set <ssid> [passphrase]` store Wi-Fi credentials in NVS and connect immediately
- Use `wifi set <ssid>` for an open network.
- Use `wifi set <ssid> <passphrase>` for a secured network.
- `gateway setup-code <setup-code>` request one setup-code connect attempt; if Wi-Fi is still coming up, the REPL waits for an IP first
- `gateway token <uri> <token>` request one explicit shared-token connect attempt
- `gateway password <uri> <password>` request one explicit password connect attempt
- `gateway no-auth <uri>` request one explicit no-auth connect attempt
- `gateway connect` request one reconnect attempt using the saved reconnect session immediately
- `gateway disconnect` request disconnect of the active session
- `reboot` reboot the board immediately
`status` prints these fields:
- `saved session available`: whether a persisted `{ gateway_uri, device_token }` reconnect session is stored
- `wifi configured`: whether Wi-Fi credentials are saved in NVS
- `wifi ssid`: the saved SSID, when Wi-Fi is configured
- `wifi connected`: whether the board currently has a Wi-Fi connection
- `wifi disconnect reason`: the most recent ESP-IDF disconnect reason, when Wi-Fi is not connected
- `wifi ip`: the current IPv4 address, when Wi-Fi is connected
## First Connection
Generate a setup code on the gateway host:
```bash
openclaw qr \
--url ws://<gateway-host-ip>:<gateway-port> \
--setup-code-only
```
The setup code contains a short-lived `bootstrapToken`, not the gateway's shared token.
Bring the board online from the serial REPL:
```text
openclaw> status
openclaw> wifi set <ssid> <passphrase>
openclaw> gateway setup-code <setup-code>
openclaw> status
```
`gateway setup-code <setup-code>` already requests the connection attempt. If
Wi-Fi is still associating, the REPL waits for an IP before it submits that
attempt. Once a saved reconnect session exists, the example retries it
automatically after Wi-Fi or gateway interruptions. Use `gateway connect` when
you want to trigger that saved-session reconnect immediately.
Then verify the node from the gateway host:
```bash
openclaw nodes status --json
openclaw nodes invoke --node <node-id> --command device.info --json
openclaw nodes invoke --node <node-id> --command wifi.status --json
```
If pairing did not complete as expected, use [Troubleshooting](../../docs/troubleshooting.md).
## Use The Node
Get basic information:
```bash
openclaw nodes invoke --node <node-id> --command device.info --json
openclaw nodes invoke --node <node-id> --command device.status --json
openclaw nodes invoke --node <node-id> --command wifi.status --json
```
Configure and drive a GPIO pin with the stable documented path:
```bash
openclaw nodes invoke --node <node-id> --command gpio.mode --params '{"pin":<pin>,"mode":"input_output"}' --json
openclaw nodes invoke --node <node-id> --command gpio.write --params '{"pin":<pin>,"level":1}' --json
openclaw nodes invoke --node <node-id> --command gpio.read --params '{"pin":<pin>}' --json
```
Read ADC:
```bash
openclaw nodes invoke --node <node-id> --command adc.read --params '{"channel":0}' --json
```
## Other CLI Commands
Useful when you want to test more than the standard setup-code flow:
- `wifi set` rejects SSIDs or passphrases that do not fit the ESP-IDF station config exactly, instead of truncating them silently
- `wifi clear`
- `wifi connect`
- `wifi disconnect`
- `reboot`
- `gateway setup-code <code>`
- `gateway no-auth <ws://host:port>`
- `gateway token <ws://host:port> <token>`
- `gateway password <ws://host:port> <password>`
- `gateway connect`
- `gateway disconnect`
<details>
<summary>Gateway command behavior</summary>
- `gateway setup-code ...` performs one explicit setup-code connection attempt
after Wi-Fi is online
- `gateway no-auth ...` performs one explicit no-auth connection attempt
- `gateway token ...` performs one explicit shared-token connection attempt
- `gateway password ...` performs one explicit password connection attempt
- `gateway connect` performs one reconnect attempt with the saved reconnect session
- `gateway disconnect` is valid only while the session is connected
- This example automatically retries the saved reconnect session after connection loss once Wi-Fi is back
</details>
## Troubleshooting And Reference
- [Troubleshooting](../../docs/troubleshooting.md)
- [Component README](../../components/esp-openclaw-node/README.md)
## Notes
- Choose a GPIO pin that is actually broken out, output-capable, and safe on your board before using `gpio.write`.
- The documented write path is `gpio.mode` with `"output"` or `"input_output"` followed by `gpio.write`.
- Open-drain GPIO modes are intentionally not part of the current command surface.

View File

@ -0,0 +1,28 @@
idf_component_register(
SRCS
"app_main.c"
"../../common/esp_openclaw_node_common_device_node_cmd.c"
"../../common/esp_openclaw_node_example_json.c"
"../../common/esp_openclaw_node_example_repl.c"
"../../common/esp_openclaw_node_example_repl_cmd.c"
"../../common/esp_openclaw_node_example_saved_session_reconnect.c"
"../../common/esp_openclaw_node_wifi.c"
"esp_openclaw_node_example_adc_node_cmd.c"
"esp_openclaw_node_example_cmd.c"
"esp_openclaw_node_example_gpio_node_cmd.c"
INCLUDE_DIRS
"."
"../../common"
REQUIRES
esp-openclaw-node
esp_adc
esp_app_format
console
esp_driver_gpio
esp_event
esp_netif
esp_wifi
espressif__cjson
mbedtls
nvs_flash
)

View File

@ -0,0 +1,3 @@
menu "OpenClaw Example"
endmenu

View File

@ -0,0 +1,92 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include "esp_openclaw_node_example_cmd.h"
#include "esp_openclaw_node_example_repl.h"
#include "esp_openclaw_node_example_repl_cmd.h"
#include "esp_openclaw_node_example_saved_session_reconnect.h"
#include "esp_openclaw_node.h"
#include "esp_openclaw_node_wifi.h"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "nvs_flash.h"
#include "soc/soc_caps.h"
static const char *TAG = "app_main";
static esp_openclaw_node_handle_t s_node;
static esp_openclaw_node_example_saved_session_reconnect_t s_saved_session_reconnect;
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;
esp_openclaw_node_example_saved_session_reconnect_handle_event(
(esp_openclaw_node_example_saved_session_reconnect_t *)user_ctx,
event,
event_data);
if (event == ESP_OPENCLAW_NODE_EVENT_CONNECTED) {
ESP_LOGI(TAG, "OpenClaw session connected");
} else if (event == ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED) {
const esp_openclaw_node_connect_failed_event_t *failed = event_data;
ESP_LOGW(
TAG,
"OpenClaw connect failed: reason=%d local_err=%s gateway_detail=%s",
failed != NULL ? failed->reason : -1,
failed != NULL ? esp_err_to_name(failed->local_err) : "n/a",
failed != NULL && failed->gateway_detail_code != NULL ? failed->gateway_detail_code : "");
} else if (event == ESP_OPENCLAW_NODE_EVENT_DISCONNECTED) {
const esp_openclaw_node_disconnected_event_t *disconnected = event_data;
ESP_LOGW(
TAG,
"OpenClaw disconnected: reason=%d local_err=%s",
disconnected != NULL ? disconnected->reason : -1,
disconnected != NULL ? esp_err_to_name(disconnected->local_err) : "n/a");
}
}
#if !SOC_WIFI_SUPPORTED
#error "examples/esp32-node is a Wi-Fi-specific example and requires a Wi-Fi-capable ESP32 target"
#endif
void app_main(void)
{
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
ESP_ERROR_CHECK(esp_openclaw_node_wifi_start());
esp_openclaw_node_config_t node_config = {0};
esp_openclaw_node_config_init_default(&node_config);
node_config.event_cb = handle_node_event;
node_config.event_user_ctx = &s_saved_session_reconnect;
ESP_ERROR_CHECK(esp_openclaw_node_create(&node_config, &s_node));
ESP_ERROR_CHECK(esp_openclaw_node_example_register_node_commands(s_node));
ESP_ERROR_CHECK(
esp_openclaw_node_example_saved_session_reconnect_start(
&s_saved_session_reconnect,
s_node,
"esp_openclaw_node_reconnect"));
ESP_ERROR_CHECK(esp_openclaw_node_example_repl_start(s_node));
if (!esp_openclaw_node_wifi_is_connected()) {
ESP_LOGI(TAG, "wifi not connected yet; provision it from the REPL or wait for Wi-Fi to reconnect");
ESP_LOGI(TAG, "after Wi-Fi is up, use `gateway setup-code`, `gateway token`, `gateway password`, or `gateway no-auth` for a first connect");
ESP_LOGI(TAG, "saved-session reconnect runs automatically after Wi-Fi returns when a saved session is present");
}
}

View File

@ -0,0 +1,177 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include "esp_openclaw_node_example_adc_node_cmd.h"
#include <stdbool.h>
#include <string.h>
#include "cJSON.h"
#include "esp_adc/adc_cali.h"
#include "esp_adc/adc_cali_scheme.h"
#include "esp_adc/adc_oneshot.h"
#include "esp_check.h"
#include "esp_openclaw_node_example_json.h"
#include "soc/adc_channel.h"
#include "soc/soc_caps.h"
#if SOC_ADC_SUPPORTED
typedef struct {
adc_oneshot_unit_handle_t adc_handle;
bool adc_unit_ready;
bool adc_channel_ready[SOC_ADC_CHANNEL_NUM(0)];
adc_cali_handle_t adc_cali[SOC_ADC_CHANNEL_NUM(0)];
bool adc_cali_ready[SOC_ADC_CHANNEL_NUM(0)];
} esp_openclaw_node_example_adc_context_t;
static const char *TAG = "esp_openclaw_node_adc";
static esp_openclaw_node_example_adc_context_t s_adc;
static esp_err_t ensure_adc_channel(
esp_openclaw_node_example_adc_context_t *context,
adc_channel_t channel)
{
if (!context->adc_unit_ready) {
adc_oneshot_unit_init_cfg_t init = {
.unit_id = ADC_UNIT_1,
.ulp_mode = ADC_ULP_MODE_DISABLE,
};
ESP_RETURN_ON_ERROR(adc_oneshot_new_unit(&init, &context->adc_handle), TAG, "adc init failed");
context->adc_unit_ready = true;
}
if (!context->adc_channel_ready[channel]) {
adc_oneshot_chan_cfg_t config = {
.atten = ADC_ATTEN_DB_12,
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
ESP_RETURN_ON_ERROR(
adc_oneshot_config_channel(context->adc_handle, channel, &config),
TAG,
"adc channel config failed");
context->adc_channel_ready[channel] = true;
#if ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED
adc_cali_curve_fitting_config_t cali = {
.unit_id = ADC_UNIT_1,
.chan = channel,
.atten = ADC_ATTEN_DB_12,
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
if (adc_cali_create_scheme_curve_fitting(&cali, &context->adc_cali[channel]) == ESP_OK) {
context->adc_cali_ready[channel] = true;
}
#elif ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED
adc_cali_line_fitting_config_t cali = {
.unit_id = ADC_UNIT_1,
.atten = ADC_ATTEN_DB_12,
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
if (adc_cali_create_scheme_line_fitting(&cali, &context->adc_cali[channel]) == ESP_OK) {
context->adc_cali_ready[channel] = true;
}
#endif
}
return ESP_OK;
}
static esp_err_t handle_adc_read(
esp_openclaw_node_handle_t node,
void *context,
const char *params_json,
size_t params_len,
char **out_payload_json,
esp_openclaw_node_error_t *out_error)
{
(void)node;
(void)params_len;
esp_openclaw_node_example_adc_context_t *adc = (esp_openclaw_node_example_adc_context_t *)context;
cJSON *params = NULL;
ESP_RETURN_ON_ERROR(
esp_openclaw_node_example_parse_json_params(params_json, &params, out_error),
TAG,
"invalid params");
cJSON *channel = cJSON_GetObjectItemCaseSensitive(params, "channel");
if (!cJSON_IsNumber(channel)) {
out_error->message = "channel must be an integer";
cJSON_Delete(params);
return ESP_ERR_INVALID_ARG;
}
int channel_num = channel->valueint;
if (channel_num < 0 || channel_num >= SOC_ADC_CHANNEL_NUM(0)) {
out_error->message = "channel is out of range for ADC1";
cJSON_Delete(params);
return ESP_ERR_INVALID_ARG;
}
adc_channel_t adc_channel = (adc_channel_t)channel_num;
esp_err_t err = ensure_adc_channel(adc, adc_channel);
if (err != ESP_OK) {
cJSON_Delete(params);
ESP_RETURN_ON_ERROR(err, TAG, "adc channel setup failed");
}
int raw = 0;
err = adc_oneshot_read(adc->adc_handle, adc_channel, &raw);
if (err != ESP_OK) {
cJSON_Delete(params);
ESP_RETURN_ON_ERROR(err, TAG, "adc read failed");
}
cJSON *payload = cJSON_CreateObject();
if (payload == NULL) {
cJSON_Delete(params);
return ESP_ERR_NO_MEM;
}
cJSON_AddNumberToObject(payload, "unit", 1);
cJSON_AddNumberToObject(payload, "channel", channel_num);
cJSON_AddNumberToObject(payload, "raw", raw);
int gpio_num = -1;
if (adc_oneshot_channel_to_io(ADC_UNIT_1, adc_channel, &gpio_num) == ESP_OK) {
cJSON_AddNumberToObject(payload, "gpio", gpio_num);
}
if (adc->adc_cali_ready[channel_num]) {
int mv = 0;
if (adc_cali_raw_to_voltage(adc->adc_cali[channel_num], raw, &mv) == ESP_OK) {
cJSON_AddNumberToObject(payload, "millivolts", mv);
}
}
cJSON_Delete(params);
return esp_openclaw_node_example_take_json_payload(payload, out_payload_json);
}
esp_err_t esp_openclaw_node_example_register_adc_node_commands(esp_openclaw_node_handle_t node)
{
static const esp_openclaw_node_command_t ADC_READ_COMMAND = {
.name = "adc.read",
.handler = handle_adc_read,
.context = &s_adc,
};
ESP_RETURN_ON_ERROR(
esp_openclaw_node_register_capability(node, "adc"),
TAG,
"registering adc capability failed");
ESP_RETURN_ON_ERROR(
esp_openclaw_node_register_command(node, &ADC_READ_COMMAND),
TAG,
"registering adc.read failed");
return ESP_OK;
}
#else
esp_err_t esp_openclaw_node_example_register_adc_node_commands(esp_openclaw_node_handle_t node)
{
(void)node;
return ESP_OK;
}
#endif

View File

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include "esp_err.h"
#include "esp_openclaw_node.h"
/**
* @brief Register the ADC command used by the generic ESP32 example.
*
* On ADC-capable targets the helper adds `adc.read`. Targets without ADC
* support compile a no-op implementation that returns `ESP_OK`.
*
* @param[in] node OpenClaw Node instance to extend.
*
* @return
* - `ESP_OK` on success
* - an ESP-IDF error code if registration fails
*/
esp_err_t esp_openclaw_node_example_register_adc_node_commands(esp_openclaw_node_handle_t node);

View File

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include "esp_openclaw_node_example_cmd.h"
#include "esp_openclaw_node_common_device_node_cmd.h"
#include "esp_check.h"
#include "soc/soc_caps.h"
#include "esp_openclaw_node_example_adc_node_cmd.h"
#include "esp_openclaw_node_example_gpio_node_cmd.h"
static const char *TAG = "esp_openclaw_node_example";
esp_err_t esp_openclaw_node_example_register_node_commands(esp_openclaw_node_handle_t node)
{
ESP_RETURN_ON_ERROR(
esp_openclaw_node_common_register_device_node_commands(node),
TAG,
"registering device commands failed");
ESP_RETURN_ON_ERROR(
esp_openclaw_node_example_register_gpio_node_commands(node),
TAG,
"registering GPIO commands failed");
#if SOC_ADC_SUPPORTED
ESP_RETURN_ON_ERROR(
esp_openclaw_node_example_register_adc_node_commands(node),
TAG,
"registering ADC commands failed");
#endif
return ESP_OK;
}

View File

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include "esp_err.h"
#include "esp_openclaw_node.h"
/**
* @brief Register the generic ESP32 example command set.
*
* The helper adds the shared `device.*` and `wifi.status` commands plus the
* example-specific GPIO and ADC commands supported by the selected target.
*
* @param[in] node OpenClaw Node instance to extend.
*
* @return
* - `ESP_OK` on success
* - an ESP-IDF error code if registration fails
*/
esp_err_t esp_openclaw_node_example_register_node_commands(esp_openclaw_node_handle_t node);

View File

@ -0,0 +1,246 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include "esp_openclaw_node_example_gpio_node_cmd.h"
#include <string.h>
#include "cJSON.h"
#include "driver/gpio.h"
#include "esp_check.h"
#include "esp_openclaw_node_example_json.h"
static const char *TAG = "esp_openclaw_node_gpio";
static bool s_gpio_mode_configured[GPIO_NUM_MAX];
static gpio_mode_t s_gpio_modes[GPIO_NUM_MAX];
static bool gpio_mode_supports_output(gpio_mode_t mode)
{
return mode == GPIO_MODE_OUTPUT || mode == GPIO_MODE_INPUT_OUTPUT;
}
static esp_err_t parse_required_pin(cJSON *params, gpio_num_t *out_pin, bool require_output)
{
cJSON *pin = cJSON_GetObjectItemCaseSensitive(params, "pin");
if (!cJSON_IsNumber(pin)) {
return ESP_ERR_INVALID_ARG;
}
int pin_num = pin->valueint;
if (!GPIO_IS_VALID_GPIO(pin_num) ||
(require_output && !GPIO_IS_VALID_OUTPUT_GPIO(pin_num))) {
return ESP_ERR_INVALID_ARG;
}
*out_pin = (gpio_num_t)pin_num;
return ESP_OK;
}
static esp_err_t handle_gpio_mode(
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_len;
cJSON *params = NULL;
ESP_RETURN_ON_ERROR(
esp_openclaw_node_example_parse_json_params(params_json, &params, out_error),
TAG,
"invalid params");
gpio_num_t pin = GPIO_NUM_NC;
if (parse_required_pin(params, &pin, false) != ESP_OK) {
out_error->message = "invalid GPIO pin";
cJSON_Delete(params);
return ESP_ERR_INVALID_ARG;
}
cJSON *mode = cJSON_GetObjectItemCaseSensitive(params, "mode");
if (!cJSON_IsString(mode) || mode->valuestring == NULL) {
out_error->message = "mode must be one of input, output, input_output";
cJSON_Delete(params);
return ESP_ERR_INVALID_ARG;
}
gpio_mode_t gpio_mode = GPIO_MODE_DISABLE;
if (strcmp(mode->valuestring, "input") == 0) {
gpio_mode = GPIO_MODE_INPUT;
} else if (strcmp(mode->valuestring, "output") == 0) {
gpio_mode = GPIO_MODE_OUTPUT;
} else if (strcmp(mode->valuestring, "input_output") == 0) {
gpio_mode = GPIO_MODE_INPUT_OUTPUT;
} else {
out_error->message = "mode must be one of input, output, input_output";
cJSON_Delete(params);
return ESP_ERR_INVALID_ARG;
}
bool pull_up = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(params, "pullUp"));
bool pull_down = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(params, "pullDown"));
gpio_config_t config = {
.pin_bit_mask = 1ULL << pin,
.mode = gpio_mode,
.pull_up_en = pull_up ? GPIO_PULLUP_ENABLE : GPIO_PULLUP_DISABLE,
.pull_down_en = pull_down ? GPIO_PULLDOWN_ENABLE : GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
esp_err_t err = gpio_config(&config);
if (err != ESP_OK) {
cJSON_Delete(params);
ESP_RETURN_ON_ERROR(err, TAG, "gpio_config failed");
}
s_gpio_modes[pin] = gpio_mode;
s_gpio_mode_configured[pin] = true;
cJSON *payload = cJSON_CreateObject();
if (payload == NULL) {
cJSON_Delete(params);
return ESP_ERR_NO_MEM;
}
cJSON_AddNumberToObject(payload, "pin", pin);
cJSON_AddStringToObject(payload, "mode", mode->valuestring);
cJSON_AddBoolToObject(payload, "pullUp", pull_up);
cJSON_AddBoolToObject(payload, "pullDown", pull_down);
cJSON_Delete(params);
return esp_openclaw_node_example_take_json_payload(payload, out_payload_json);
}
static esp_err_t handle_gpio_read(
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_len;
cJSON *params = NULL;
ESP_RETURN_ON_ERROR(
esp_openclaw_node_example_parse_json_params(params_json, &params, out_error),
TAG,
"invalid params");
gpio_num_t pin = GPIO_NUM_NC;
if (parse_required_pin(params, &pin, false) != ESP_OK) {
out_error->message = "invalid GPIO pin";
cJSON_Delete(params);
return ESP_ERR_INVALID_ARG;
}
cJSON *payload = cJSON_CreateObject();
if (payload == NULL) {
cJSON_Delete(params);
return ESP_ERR_NO_MEM;
}
cJSON_AddNumberToObject(payload, "pin", pin);
cJSON_AddNumberToObject(payload, "level", gpio_get_level(pin));
cJSON_Delete(params);
return esp_openclaw_node_example_take_json_payload(payload, out_payload_json);
}
static esp_err_t handle_gpio_write(
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_len;
cJSON *params = NULL;
ESP_RETURN_ON_ERROR(
esp_openclaw_node_example_parse_json_params(params_json, &params, out_error),
TAG,
"invalid params");
gpio_num_t pin = GPIO_NUM_NC;
if (parse_required_pin(params, &pin, true) != ESP_OK) {
out_error->message = "invalid output GPIO pin";
cJSON_Delete(params);
return ESP_ERR_INVALID_ARG;
}
cJSON *level = cJSON_GetObjectItemCaseSensitive(params, "level");
if (!cJSON_IsNumber(level) && !cJSON_IsBool(level)) {
out_error->message = "level must be 0, 1, true, or false";
cJSON_Delete(params);
return ESP_ERR_INVALID_ARG;
}
int level_value = cJSON_IsTrue(level) ? 1 : level->valueint ? 1 : 0;
if (!s_gpio_mode_configured[pin] || !gpio_mode_supports_output(s_gpio_modes[pin])) {
out_error->message = "configure gpio.mode with output or input_output before gpio.write";
cJSON_Delete(params);
return ESP_ERR_INVALID_STATE;
}
esp_err_t err = gpio_set_level(pin, level_value);
if (err != ESP_OK) {
cJSON_Delete(params);
ESP_RETURN_ON_ERROR(err, TAG, "gpio_set_level failed");
}
cJSON *payload = cJSON_CreateObject();
if (payload == NULL) {
cJSON_Delete(params);
return ESP_ERR_NO_MEM;
}
cJSON_AddNumberToObject(payload, "pin", pin);
cJSON_AddNumberToObject(payload, "level", level_value);
cJSON_Delete(params);
return esp_openclaw_node_example_take_json_payload(payload, out_payload_json);
}
esp_err_t esp_openclaw_node_example_register_gpio_node_commands(esp_openclaw_node_handle_t node)
{
memset(s_gpio_mode_configured, 0, sizeof(s_gpio_mode_configured));
memset(s_gpio_modes, 0, sizeof(s_gpio_modes));
static const esp_openclaw_node_command_t GPIO_MODE_COMMAND = {
.name = "gpio.mode",
.handler = handle_gpio_mode,
.context = NULL,
};
static const esp_openclaw_node_command_t GPIO_READ_COMMAND = {
.name = "gpio.read",
.handler = handle_gpio_read,
.context = NULL,
};
static const esp_openclaw_node_command_t GPIO_WRITE_COMMAND = {
.name = "gpio.write",
.handler = handle_gpio_write,
.context = NULL,
};
ESP_RETURN_ON_ERROR(
esp_openclaw_node_register_capability(node, "gpio"),
TAG,
"registering gpio capability failed");
ESP_RETURN_ON_ERROR(
esp_openclaw_node_register_command(node, &GPIO_MODE_COMMAND),
TAG,
"registering gpio.mode failed");
ESP_RETURN_ON_ERROR(
esp_openclaw_node_register_command(node, &GPIO_READ_COMMAND),
TAG,
"registering gpio.read failed");
ESP_RETURN_ON_ERROR(
esp_openclaw_node_register_command(node, &GPIO_WRITE_COMMAND),
TAG,
"registering gpio.write failed");
return ESP_OK;
}

View File

@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include "esp_err.h"
#include "esp_openclaw_node.h"
/**
* @brief Register the GPIO commands used by the generic ESP32 example.
*
* The helper adds the `gpio.mode`, `gpio.read`, and `gpio.write` commands.
* The documented path is:
* - `gpio.mode` with `"input"`, `"output"`, or `"input_output"`
* - `gpio.read`
* - `gpio.write` after `"output"` or `"input_output"`
*
* Open-drain drive modes are intentionally not exposed in this helper.
*
* @param[in] node OpenClaw Node instance to extend.
*
* @return
* - `ESP_OK` on success
* - an ESP-IDF error code if registration fails
*/
esp_err_t esp_openclaw_node_example_register_gpio_node_commands(esp_openclaw_node_handle_t node);

View File

@ -0,0 +1,5 @@
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=y
CONFIG_MBEDTLS_SSL_PROTO_TLS1_3=y
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y

View File

@ -0,0 +1,2 @@
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y