esp-openclaw-node: Add ESP-IDF OpenClaw Node component
This commit is contained in:
parent
90c310f67b
commit
682c4dc05d
8
.github/workflows/upload_component.yml
vendored
8
.github/workflows/upload_component.yml
vendored
@ -3,17 +3,19 @@ name: Push esp-openclaw-node to IDF Component Registry
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
|
||||
jobs:
|
||||
upload_components:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Upload esp-openclaw-node to IDF Component Registry
|
||||
uses: espressif/upload-components-ci-action@v1
|
||||
uses: espressif/upload-components-ci-action@v2
|
||||
with:
|
||||
name: esp-openclaw-node
|
||||
components: "esp-openclaw-node:components/esp-openclaw-node"
|
||||
namespace: "espressif"
|
||||
api_token: ${{ secrets.IDF_COMPONENT_API_TOKEN }}
|
||||
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
**/build*/
|
||||
**/managed_components/
|
||||
**/dependencies.lock
|
||||
**/sdkconfig
|
||||
**/sdkconfig.old
|
||||
**/sdkconfig.ci
|
||||
**/__pycache__/
|
||||
dist/
|
||||
.DS_Store
|
||||
5
components/esp-openclaw-node/CHANGELOG.md
Normal file
5
components/esp-openclaw-node/CHANGELOG.md
Normal 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.
|
||||
22
components/esp-openclaw-node/CMakeLists.txt
Normal file
22
components/esp-openclaw-node/CMakeLists.txt
Normal file
@ -0,0 +1,22 @@
|
||||
set(esp_openclaw_node_component_args
|
||||
SRCS
|
||||
"src/esp_openclaw_node_identity.c"
|
||||
"src/esp_openclaw_node_persisted_session.c"
|
||||
"src/esp_openclaw_node.c"
|
||||
"src/esp_openclaw_node_connect_source.c"
|
||||
"src/esp_openclaw_node_registry.c"
|
||||
"src/esp_openclaw_node_protocol.c"
|
||||
"src/esp_openclaw_node_transport.c"
|
||||
"src/esp_openclaw_node_runtime.c"
|
||||
INCLUDE_DIRS "include"
|
||||
PRIV_INCLUDE_DIRS "private_include"
|
||||
REQUIRES
|
||||
espressif__cjson
|
||||
esp_app_format
|
||||
esp_websocket_client
|
||||
libsodium
|
||||
mbedtls
|
||||
nvs_flash
|
||||
)
|
||||
|
||||
idf_component_register(${esp_openclaw_node_component_args})
|
||||
66
components/esp-openclaw-node/Kconfig
Normal file
66
components/esp-openclaw-node/Kconfig
Normal 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
|
||||
202
components/esp-openclaw-node/LICENSE
Normal file
202
components/esp-openclaw-node/LICENSE
Normal 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.
|
||||
626
components/esp-openclaw-node/README.md
Normal file
626
components/esp-openclaw-node/README.md
Normal file
@ -0,0 +1,626 @@
|
||||
# esp-openclaw-node
|
||||
|
||||
`esp-openclaw-node` is the ESP-IDF component package in this repository for
|
||||
running an ESP32 application as an OpenClaw Node over WebSocket.
|
||||
The public C API is declared in
|
||||
[esp_openclaw_node.h](./include/esp_openclaw_node.h) and uses the
|
||||
`esp_openclaw_node_*` prefix.
|
||||
|
||||
The component provides:
|
||||
|
||||
- Device identity generation and persistence
|
||||
- OpenClaw `connect.challenge` signing and `connect` request construction
|
||||
- setup-code, shared-token, password, no-auth, and saved-session connect paths
|
||||
- capability and command advertisement
|
||||
- Handling `node.invoke.request` commands and sending `node.invoke.result` replies
|
||||
|
||||
## Contents
|
||||
|
||||
- [Requirements](#requirements)
|
||||
- [Overview](#overview)
|
||||
- [What The Component Handles](#what-the-component-handles)
|
||||
- [What The Application Handles](#what-the-application-handles)
|
||||
- [Public API](#public-api)
|
||||
- [Lifecycle](#lifecycle)
|
||||
- [Registration](#registration)
|
||||
- [Async Control](#async-control)
|
||||
- [Inspection](#inspection)
|
||||
- [Usage](#usage)
|
||||
- [Basic Lifecycle](#basic-lifecycle)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Configuration And Defaults](#configuration-and-defaults)
|
||||
- [Registering Capabilities And Commands](#registering-capabilities-and-commands)
|
||||
- [Command Handlers](#command-handlers)
|
||||
- [Connect and Session Model](#connect-and-session-model)
|
||||
- [Connect Model](#connect-model)
|
||||
- [Supported Connect Sources](#supported-connect-sources)
|
||||
- [Setup Codes](#setup-codes)
|
||||
- [Events](#events)
|
||||
- [Stored State](#stored-state)
|
||||
- [Reference](#reference)
|
||||
- [TLS](#tls)
|
||||
- [Examples and Reconnect Policy](#examples-and-reconnect-policy)
|
||||
- [Component Tests](#component-tests)
|
||||
|
||||
## Requirements
|
||||
|
||||
- ESP-IDF `5.x`
|
||||
- a board that can reach the OpenClaw Gateway
|
||||
- application-managed network setup, such as Wi-Fi or Ethernet
|
||||
- `nvs_flash_init()`, `esp_netif_init()`, and the default event loop before the
|
||||
node is created or connected
|
||||
|
||||
## Overview
|
||||
|
||||
### What The Component Handles
|
||||
|
||||
- Generating or loading the device seed from NVS
|
||||
- Deriving the Ed25519 keypair and stable `device_id`
|
||||
- Opening one WebSocket transport at a time
|
||||
- Handling `connect.challenge`
|
||||
- Building and signing the OpenClaw `connect` request
|
||||
- Advertising capabilities and commands
|
||||
- Dispatching `node.invoke.request` into registered handlers
|
||||
- Sending `node.invoke.result`
|
||||
- Persisting the final `{ gateway_uri, device_token }` reconnect session after a
|
||||
successful `hello-ok`
|
||||
|
||||
### What The Application Handles
|
||||
|
||||
- Wi-Fi, Ethernet, PPP, or any other route to the gateway
|
||||
- Local UI, REPL, or provisioning flows
|
||||
- How setup codes, gateway URIs, tokens, or passwords reach the board
|
||||
- Deciding whether the next attempt should use a saved session or explicit auth
|
||||
- Deciding whether and when to retry after `CONNECT_FAILED` or `DISCONNECTED`
|
||||
- Any factory reset, identity reset, or saved-session clear workflow
|
||||
- The actual device-specific command handlers
|
||||
|
||||
## Public API
|
||||
|
||||
### Lifecycle
|
||||
|
||||
- `esp_openclaw_node_config_init_default()`
|
||||
- `esp_openclaw_node_create()`
|
||||
- `esp_openclaw_node_destroy()`
|
||||
|
||||
### Registration
|
||||
|
||||
- `esp_openclaw_node_register_capability()`
|
||||
- `esp_openclaw_node_register_command()`
|
||||
|
||||
### Async Control
|
||||
|
||||
- `esp_openclaw_node_request_connect()`
|
||||
- `esp_openclaw_node_request_disconnect()`
|
||||
|
||||
### Inspection
|
||||
|
||||
- `esp_openclaw_node_get_device_id()`
|
||||
- `esp_openclaw_node_has_saved_session()`
|
||||
|
||||
The component is driven through registration, one explicit connect request at a
|
||||
time, and terminal events.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Lifecycle
|
||||
|
||||
1. Initialize NVS, `esp_netif`, and the default event loop.
|
||||
2. Bring up networking, or arrange to wait for it before connecting.
|
||||
3. Initialize `esp_openclaw_node_config_t` with
|
||||
`esp_openclaw_node_config_init_default()`.
|
||||
4. Create the node with `esp_openclaw_node_create()`.
|
||||
5. Register capabilities and commands while the node is idle.
|
||||
6. Submit one connect request with `esp_openclaw_node_request_connect()`.
|
||||
7. Wait for a terminal event before submitting the next control request.
|
||||
8. Destroy the node with `esp_openclaw_node_destroy()` when finished.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```c
|
||||
#include <string.h>
|
||||
#include "esp_openclaw_node.h"
|
||||
|
||||
static esp_err_t handle_device_info(
|
||||
esp_openclaw_node_handle_t node,
|
||||
void *context,
|
||||
const char *params_json,
|
||||
size_t params_len,
|
||||
char **out_payload_json,
|
||||
esp_openclaw_node_error_t *out_error)
|
||||
{
|
||||
(void)node;
|
||||
(void)context;
|
||||
(void)params_json;
|
||||
(void)params_len;
|
||||
(void)out_error;
|
||||
|
||||
*out_payload_json = strdup("{\"status\":\"ok\"}");
|
||||
return *out_payload_json != NULL ? ESP_OK : ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
static void handle_node_event(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_event_t event,
|
||||
const void *event_data,
|
||||
void *user_ctx)
|
||||
{
|
||||
(void)node;
|
||||
(void)event_data;
|
||||
(void)user_ctx;
|
||||
|
||||
if (event == ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED) {
|
||||
/* Decide whether and when to retry in application code. */
|
||||
}
|
||||
}
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
esp_openclaw_node_config_t config = {0};
|
||||
esp_openclaw_node_handle_t node = NULL;
|
||||
const char *setup_code = "<setup-code>";
|
||||
|
||||
esp_openclaw_node_config_init_default(&config);
|
||||
config.event_cb = handle_node_event;
|
||||
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_create(&config, &node));
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_register_capability(node, "device"));
|
||||
|
||||
esp_openclaw_node_command_t cmd = {
|
||||
.name = "device.info",
|
||||
.handler = handle_device_info,
|
||||
};
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_register_command(node, &cmd));
|
||||
|
||||
esp_openclaw_node_connect_request_t request = {
|
||||
.source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE,
|
||||
.gateway_uri = NULL,
|
||||
.value = setup_code,
|
||||
};
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_request_connect(node, &request));
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration And Defaults
|
||||
|
||||
`esp_openclaw_node_config_init_default()` sets:
|
||||
|
||||
- `display_name = "OpenClaw ESP32"`
|
||||
- `platform = "esp32"`
|
||||
- `device_family = "ESP32"`
|
||||
- `client_id = "node-host"`
|
||||
- `client_mode = "node"`
|
||||
- `role = "node"`
|
||||
- `model_identifier = CONFIG_IDF_TARGET`
|
||||
- `locale = "en-US"`
|
||||
- `use_cert_bundle = true`
|
||||
- `tls_common_name = NULL`
|
||||
- `tls_cert_pem = NULL`
|
||||
- `skip_cert_common_name_check = false`
|
||||
|
||||
### Registering Capabilities And Commands
|
||||
|
||||
Register everything before the first connect request.
|
||||
|
||||
```c
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_register_capability(node, "display"));
|
||||
|
||||
esp_openclaw_node_command_t cmd = {
|
||||
.name = "display.show",
|
||||
.handler = handle_display_show,
|
||||
.context = &display_state,
|
||||
};
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_register_command(node, &cmd));
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- capabilities are plain strings
|
||||
- commands are plain strings plus a handler and optional context pointer
|
||||
- Duplicate capability or command names are ignored and return `ESP_OK`
|
||||
- Registration is allowed only while the node is idle
|
||||
- Registration and transport resource limits default to
|
||||
`ESP_OPENCLAW_NODE_MAX_CAPABILITIES`, `ESP_OPENCLAW_NODE_MAX_COMMANDS`,
|
||||
the internal work-queue length, and the component/WebSocket task and buffer
|
||||
sizes. These can be tuned in `menuconfig` under
|
||||
`Component config -> ESP OpenClaw Node`.
|
||||
|
||||
Current `menuconfig` options and defaults:
|
||||
|
||||
- `CONFIG_ESP_OPENCLAW_NODE_MAX_CAPABILITIES` = `16`
|
||||
- `CONFIG_ESP_OPENCLAW_NODE_MAX_COMMANDS` = `32`
|
||||
- `CONFIG_ESP_OPENCLAW_NODE_WORK_QUEUE_LENGTH` = `32`
|
||||
- `CONFIG_ESP_OPENCLAW_NODE_TASK_STACK_SIZE` = `8192`
|
||||
- `CONFIG_ESP_OPENCLAW_NODE_TRANSPORT_TASK_STACK_SIZE` = `8192`
|
||||
- `CONFIG_ESP_OPENCLAW_NODE_TRANSPORT_BUFFER_SIZE` = `2048`
|
||||
|
||||
The component advertises capability names and command names only. It does not
|
||||
currently send parameter schemas to the gateway.
|
||||
|
||||
### Command Handlers
|
||||
|
||||
Handler signature:
|
||||
|
||||
```c
|
||||
typedef esp_err_t (*esp_openclaw_node_command_handler_t)(
|
||||
esp_openclaw_node_handle_t node,
|
||||
void *context,
|
||||
const char *params_json,
|
||||
size_t params_len,
|
||||
char **out_payload_json,
|
||||
esp_openclaw_node_error_t *out_error);
|
||||
```
|
||||
|
||||
Handler behavior:
|
||||
|
||||
- handlers run synchronously on the component task
|
||||
- `params_json` is the raw UTF-8 JSON string from `payload.paramsJSON`
|
||||
- when the request omits `paramsJSON`, the component passes `"{}"`
|
||||
- on success, return `ESP_OK` and optionally allocate a JSON string for
|
||||
`*out_payload_json`
|
||||
- on failure, return a non-`ESP_OK` code and populate `out_error` with a stable
|
||||
error `code` and human-readable `message`
|
||||
- any non-`NULL` `out_payload_json` buffer must be `malloc()`-compatible; the
|
||||
component sends it as `payloadJSON` and then frees it
|
||||
|
||||
Because handlers run on the component task, long-running work should be handed
|
||||
off to another task if it cannot complete quickly.
|
||||
|
||||
## Connect and Session Model
|
||||
|
||||
### Connect Model
|
||||
|
||||
The component performs one connection attempt at a time. It does not run an
|
||||
automatic reconnect loop and it does not choose between saved-session reconnect
|
||||
and explicit auth input on behalf of the application.
|
||||
|
||||
Request rules:
|
||||
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION` is valid only when a saved
|
||||
reconnect session is present
|
||||
Applications can check saved-session availability with
|
||||
`esp_openclaw_node_has_saved_session()` before submitting that request.
|
||||
- explicit connect requests are valid only while the node is idle
|
||||
- `esp_openclaw_node_request_disconnect()` is valid only while the node is ready
|
||||
- once destroy begins, new async requests are rejected
|
||||
|
||||
For each accepted connect request, wait for exactly one terminal outcome before
|
||||
submitting another control request.
|
||||
|
||||
### Supported Connect Sources
|
||||
|
||||
The public API exposes five caller-chosen connect sources:
|
||||
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION`
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE`
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_TOKEN`
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_PASSWORD`
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_NO_AUTH`
|
||||
|
||||
Field requirements for `esp_openclaw_node_connect_request_t`:
|
||||
|
||||
- `SAVED_SESSION`: `gateway_uri = NULL`, `value = NULL`
|
||||
- `SETUP_CODE`: `gateway_uri = NULL`, `value = <setup code>`
|
||||
- `GATEWAY_TOKEN`: `gateway_uri = <ws://...|wss://...>`, `value = <token>`
|
||||
- `GATEWAY_PASSWORD`: `gateway_uri = <ws://...|wss://...>`,
|
||||
`value = <password>`
|
||||
- `NO_AUTH`: `gateway_uri = <ws://...|wss://...>`, `value = NULL`
|
||||
|
||||
When a connect attempt begins, the component resolves auth material like this:
|
||||
|
||||
- saved session: send `auth.deviceToken`
|
||||
- explicit gateway token: send `auth.token`
|
||||
- explicit gateway password: send `auth.password`
|
||||
- explicit no-auth: omit the `auth` object
|
||||
- setup code: depends on the decoded credential field
|
||||
|
||||
This selection is per attempt. The component does not fall back from one auth
|
||||
mode to another automatically.
|
||||
|
||||
### Setup Codes
|
||||
|
||||
In the current component, a setup code is base64url-encoded JSON that must
|
||||
contain:
|
||||
|
||||
- `url`
|
||||
- exactly one of:
|
||||
- `bootstrapToken`
|
||||
- `token`
|
||||
- `password`
|
||||
|
||||
Example decoded payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "ws://192.168.1.10:19001",
|
||||
"bootstrapToken": "oc_bootstrap_example_token"
|
||||
}
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Pairing Flow</summary>
|
||||
|
||||
The usual first-pairing path is one explicit setup-code connect attempt. The
|
||||
component does not stage setup-code state for a later `connect` call.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as ESP-IDF App
|
||||
participant Node as esp_openclaw_node
|
||||
participant NVS as NVS
|
||||
participant GW as OpenClaw Gateway
|
||||
|
||||
App->>Node: esp_openclaw_node_create()
|
||||
Node->>NVS: load or create device_seed
|
||||
Node->>NVS: load saved reconnect session
|
||||
App->>Node: register capabilities and commands
|
||||
App->>Node: esp_openclaw_node_request_connect(SETUP_CODE)
|
||||
Node->>Node: decode setup code
|
||||
Node->>GW: open websocket to setup-code url
|
||||
GW-->>Node: connect.challenge
|
||||
Node->>Node: resolve auth material and sign payload
|
||||
Node->>GW: connect(auth..., device signature)
|
||||
alt hello-ok with auth.deviceToken
|
||||
GW-->>Node: hello-ok
|
||||
Node->>NVS: store {session_v, session_uri, session_dev_tok}
|
||||
Node-->>App: ESP_OPENCLAW_NODE_EVENT_CONNECTED
|
||||
else auth rejected or finalization fails
|
||||
GW-->>Node: error or incomplete hello-ok
|
||||
Node-->>App: ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED
|
||||
end
|
||||
GW-->>Node: node.invoke.request
|
||||
Node->>App: registered handler(paramsJSON)
|
||||
App-->>Node: payloadJSON or error
|
||||
Node->>GW: node.invoke.result
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
### Events
|
||||
|
||||
The component emits these events through `esp_openclaw_node_event_cb_t`:
|
||||
|
||||
- `ESP_OPENCLAW_NODE_EVENT_CONNECTED`
|
||||
- `ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED`
|
||||
- `ESP_OPENCLAW_NODE_EVENT_DISCONNECTED`
|
||||
|
||||
`ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED` carries
|
||||
`esp_openclaw_node_connect_failed_event_t` with:
|
||||
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILED`
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_FAILURE_CONNECTION_LOST`
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_FAILURE_AUTH_REJECTED`
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_FAILURE_SESSION_FINALIZATION_FAILED`
|
||||
|
||||
`ESP_OPENCLAW_NODE_EVENT_DISCONNECTED` carries
|
||||
`esp_openclaw_node_disconnected_event_t` with:
|
||||
|
||||
- `ESP_OPENCLAW_NODE_DISCONNECTED_REASON_REQUESTED`
|
||||
- `ESP_OPENCLAW_NODE_DISCONNECTED_REASON_CONNECTION_LOST`
|
||||
|
||||
Event callback rules:
|
||||
|
||||
- callbacks run on the component task
|
||||
- keep callback code short and non-blocking
|
||||
- callbacks may call the async request APIs
|
||||
- callbacks must not call `esp_openclaw_node_destroy()`
|
||||
|
||||
### Stored State
|
||||
|
||||
The component stores internal state in NVS namespace `openclaw`.
|
||||
|
||||
Identity:
|
||||
|
||||
- `device_seed`: 32-byte Ed25519 seed
|
||||
|
||||
Saved reconnect session:
|
||||
|
||||
- `session_v`
|
||||
- `session_uri`
|
||||
- `session_dev_tok`
|
||||
|
||||
Derived at runtime from `device_seed`:
|
||||
|
||||
- `device_id = hex(sha256(public_key))`
|
||||
- `public_key`
|
||||
- `private_key`
|
||||
|
||||
Persistence rules:
|
||||
|
||||
- setup-code bootstrap tokens are never persisted
|
||||
- explicit shared gateway tokens are never persisted
|
||||
- explicit gateway passwords are never persisted
|
||||
- explicit no-auth selections are never persisted
|
||||
- only the final `{ gateway_uri, device_token }` reconnect session is persisted
|
||||
|
||||
## Reference
|
||||
|
||||
<details>
|
||||
<summary>Example Wire Messages</summary>
|
||||
|
||||
Example `connect.challenge` from the gateway:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "connect.challenge",
|
||||
"payload": {
|
||||
"nonce": "M2QxYjBiNDItYzJlZS00YzA3LWFkMWMtMmE4NGJmZTg4M2E5",
|
||||
"ts": 1774830385123
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example `connect` from the node:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "req",
|
||||
"id": "connect-1774830385140123",
|
||||
"method": "connect",
|
||||
"params": {
|
||||
"minProtocol": 3,
|
||||
"maxProtocol": 3,
|
||||
"client": {
|
||||
"id": "node-host",
|
||||
"displayName": "OpenClaw ESP32",
|
||||
"version": "1.0.0",
|
||||
"platform": "esp32",
|
||||
"deviceFamily": "ESP32",
|
||||
"modelIdentifier": "esp32c6",
|
||||
"mode": "node"
|
||||
},
|
||||
"role": "node",
|
||||
"scopes": [],
|
||||
"caps": ["device", "wifi", "gpio"],
|
||||
"commands": [
|
||||
"device.info",
|
||||
"device.status",
|
||||
"wifi.status",
|
||||
"gpio.mode",
|
||||
"gpio.read",
|
||||
"gpio.write"
|
||||
],
|
||||
"auth": {
|
||||
"deviceToken": "<saved-device-token>"
|
||||
},
|
||||
"userAgent": "esp-openclaw-node/1.0.0",
|
||||
"locale": "en-US",
|
||||
"device": {
|
||||
"id": "<device-id>",
|
||||
"publicKey": "<base64url-public-key>",
|
||||
"signature": "<base64url-signature>",
|
||||
"signedAt": 1774830385123,
|
||||
"nonce": "M2QxYjBiNDItYzJlZS00YzA3LWFkMWMtMmE4NGJmZTg4M2E5"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`params.auth` variants by connect source:
|
||||
|
||||
```json
|
||||
{ "bootstrapToken": "<bootstrap-token>" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "token": "<gateway-token>" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "password": "<gateway-password>" }
|
||||
```
|
||||
|
||||
For explicit no-auth, the component omits the `auth` object entirely.
|
||||
|
||||
Successful response:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "res",
|
||||
"id": "connect-1774830385140123",
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"type": "hello-ok",
|
||||
"auth": {
|
||||
"deviceToken": "<gateway-issued-device-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example `node.invoke.request` from the gateway:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "node.invoke.request",
|
||||
"payload": {
|
||||
"id": "inv_01JV0A7X9S1ZQY7V5NXJYQ5V8K",
|
||||
"nodeId": "<node-id>",
|
||||
"command": "display.show",
|
||||
"paramsJSON": "{\"heading\":\"OpenClaw\",\"text\":\"Hello from the gateway.\"}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Successful `node.invoke.result` from the node:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "req",
|
||||
"id": "esp32-1774830401987123",
|
||||
"method": "node.invoke.result",
|
||||
"params": {
|
||||
"id": "inv_01JV0A7X9S1ZQY7V5NXJYQ5V8K",
|
||||
"nodeId": "<node-id>",
|
||||
"ok": true,
|
||||
"payloadJSON": "{\"heading\":\"OpenClaw\",\"text\":\"Hello from the gateway.\",\"renderCount\":1}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Error `node.invoke.result` from the node:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "req",
|
||||
"id": "esp32-1774830402987001",
|
||||
"method": "node.invoke.result",
|
||||
"params": {
|
||||
"id": "inv_01JV0AA4J2QJ4V4X1R7W43G6CE",
|
||||
"nodeId": "<node-id>",
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "INVALID_PARAMS",
|
||||
"message": "display params must include string heading and text fields"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### TLS
|
||||
|
||||
The component supports both `ws://` and `wss://`.
|
||||
|
||||
For `wss://`, one of these trust paths must be configured:
|
||||
|
||||
- set `tls_cert_pem` to a PEM trust anchor, or
|
||||
- leave `use_cert_bundle = true` and build with
|
||||
`CONFIG_MBEDTLS_CERTIFICATE_BUNDLE`
|
||||
|
||||
If neither is true, `wss://` connect requests are rejected before transport
|
||||
startup.
|
||||
|
||||
Useful fields in `esp_openclaw_node_config_t`:
|
||||
|
||||
- `tls_cert_pem`
|
||||
- `tls_cert_len`
|
||||
- `use_cert_bundle`
|
||||
- `tls_common_name`
|
||||
- `skip_cert_common_name_check`
|
||||
|
||||
`skip_cert_common_name_check` is available for local development, but should
|
||||
stay disabled for production-like deployments.
|
||||
|
||||
### Examples and Reconnect Policy
|
||||
|
||||
The component itself does not implement automatic reconnect policy.
|
||||
|
||||
Applications can provide that behavior outside the component. A reconnect helper typically:
|
||||
|
||||
- waits for Wi-Fi to be online
|
||||
- checks `esp_openclaw_node_has_saved_session()`
|
||||
- retries only the saved-session path
|
||||
- retries after retryable `CONNECT_FAILED` and `DISCONNECTED` outcomes
|
||||
|
||||
The application keeps network policy and retry policy, while the component focuses on node identity, protocol, and one attempt
|
||||
at a time.
|
||||
|
||||
### Component Tests
|
||||
|
||||
Component tests live under
|
||||
[components/esp-openclaw-node/test_apps](./test_apps/README.md).
|
||||
15
components/esp-openclaw-node/idf_component.yml
Normal file
15
components/esp-openclaw-node/idf_component.yml
Normal file
@ -0,0 +1,15 @@
|
||||
version: "1.0.0"
|
||||
description: "ESP-IDF component for running OpenClaw Nodes on ESP32 devices."
|
||||
url: https://github.com/espressif/esp-openclaw-node
|
||||
repository: https://github.com/espressif/esp-openclaw-node.git
|
||||
repository_info:
|
||||
path: "components/esp-openclaw-node"
|
||||
|
||||
dependencies:
|
||||
idf: ">=5.0"
|
||||
espressif/cjson:
|
||||
version: "1.7.19~2"
|
||||
espressif/esp_websocket_client:
|
||||
version: "1.6.1"
|
||||
espressif/libsodium:
|
||||
version: "1.0.20~4"
|
||||
342
components/esp-openclaw-node/include/esp_openclaw_node.h
Normal file
342
components/esp-openclaw-node/include/esp_openclaw_node.h
Normal file
@ -0,0 +1,342 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Limits */
|
||||
|
||||
/** @brief Maximum number of capabilities a node can advertise. */
|
||||
#define ESP_OPENCLAW_NODE_MAX_CAPABILITIES CONFIG_ESP_OPENCLAW_NODE_MAX_CAPABILITIES
|
||||
/** @brief Maximum number of commands a node can register. */
|
||||
#define ESP_OPENCLAW_NODE_MAX_COMMANDS CONFIG_ESP_OPENCLAW_NODE_MAX_COMMANDS
|
||||
|
||||
/* Core Types */
|
||||
|
||||
/** @brief Opaque handle for an OpenClaw Node instance. */
|
||||
typedef struct esp_openclaw_node *esp_openclaw_node_handle_t;
|
||||
|
||||
/* Event Types */
|
||||
|
||||
/** @brief Terminal and maintenance events emitted by the component. */
|
||||
typedef enum {
|
||||
ESP_OPENCLAW_NODE_EVENT_CONNECTED = 0, /**< Handshake completed and the session is ready. */
|
||||
ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED, /**< A connect attempt ended without reaching ready state. */
|
||||
ESP_OPENCLAW_NODE_EVENT_DISCONNECTED, /**< An established session disconnected or was closed locally. */
|
||||
} esp_openclaw_node_event_t;
|
||||
|
||||
/** @brief Failure reasons surfaced on @ref ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED. */
|
||||
typedef enum {
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILED = 0, /**< Transport startup or local connect setup failed. */
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_CONNECTION_LOST, /**< The transport dropped before connect completed. */
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_AUTH_REJECTED, /**< The gateway rejected the auth material or signature. */
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_SESSION_FINALIZATION_FAILED, /**< `hello-ok` handling failed after initial auth acceptance. */
|
||||
} esp_openclaw_node_connect_failure_reason_t;
|
||||
|
||||
/** @brief Payload for @ref ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED. */
|
||||
typedef struct {
|
||||
esp_openclaw_node_connect_failure_reason_t reason; /**< High-level failure category. */
|
||||
esp_err_t local_err; /**< Error code associated with the failure, or ESP_OK if none applies. */
|
||||
const char *gateway_detail_code; /**< Optional gateway detail code such as `AUTH_*`. */
|
||||
} esp_openclaw_node_connect_failed_event_t;
|
||||
|
||||
/** @brief Disconnect reasons surfaced on @ref ESP_OPENCLAW_NODE_EVENT_DISCONNECTED. */
|
||||
typedef enum {
|
||||
ESP_OPENCLAW_NODE_DISCONNECTED_REASON_REQUESTED = 0, /**< Disconnect was requested locally. */
|
||||
ESP_OPENCLAW_NODE_DISCONNECTED_REASON_CONNECTION_LOST, /**< Disconnect followed a transport loss or remote close. */
|
||||
} esp_openclaw_node_disconnected_reason_t;
|
||||
|
||||
/** @brief Payload for @ref ESP_OPENCLAW_NODE_EVENT_DISCONNECTED. */
|
||||
typedef struct {
|
||||
esp_openclaw_node_disconnected_reason_t reason; /**< High-level disconnect category. */
|
||||
esp_err_t local_err; /**< Error code associated with the disconnect, or ESP_OK if none applies. */
|
||||
} esp_openclaw_node_disconnected_event_t;
|
||||
|
||||
/**
|
||||
* @brief Node event callback.
|
||||
*
|
||||
* Event callbacks run on the component task. Callback code must stay short and
|
||||
* non-blocking. It may call the async `esp_openclaw_node_request_*()` APIs. It
|
||||
* must not call @ref esp_openclaw_node_destroy.
|
||||
*
|
||||
* For each accepted connect request, wait for exactly one terminal outcome:
|
||||
* - @ref ESP_OPENCLAW_NODE_EVENT_CONNECTED
|
||||
* - @ref ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED
|
||||
* - @ref ESP_OPENCLAW_NODE_EVENT_DISCONNECTED after an accepted disconnect of an
|
||||
* active session
|
||||
*
|
||||
* `event_data` is:
|
||||
* - `NULL` for @ref ESP_OPENCLAW_NODE_EVENT_CONNECTED
|
||||
* - @ref esp_openclaw_node_connect_failed_event_t for
|
||||
* @ref ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED
|
||||
* - @ref esp_openclaw_node_disconnected_event_t for
|
||||
* @ref ESP_OPENCLAW_NODE_EVENT_DISCONNECTED
|
||||
*
|
||||
* The payload pointer is valid only for the duration of the callback.
|
||||
*/
|
||||
typedef void (*esp_openclaw_node_event_cb_t)(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_event_t event,
|
||||
const void *event_data,
|
||||
void *user_ctx);
|
||||
|
||||
/* Configuration */
|
||||
|
||||
/**
|
||||
* @brief Node configuration.
|
||||
*
|
||||
* Before creating or connecting the node, the application must initialize the
|
||||
* ESP-IDF runtime pieces that networking depends on:
|
||||
* - `nvs_flash_init()`
|
||||
* - `esp_netif_init()`
|
||||
* - `esp_event_loop_create_default()`
|
||||
*
|
||||
* The application owns network setup. This component does not bring up Wi-Fi,
|
||||
* Ethernet, PPP, or any other network path; it opens the OpenClaw WebSocket
|
||||
* session once a route to the configured gateway exists.
|
||||
*/
|
||||
typedef struct {
|
||||
const char *display_name; /**< Human-readable name advertised to the gateway. */
|
||||
const char *platform; /**< Lower-level platform string included in auth and identity metadata. */
|
||||
const char *device_family; /**< Higher-level device family string included in auth metadata. */
|
||||
const char *client_id; /**< Protocol client identifier sent during connect. */
|
||||
const char *client_mode; /**< Protocol client mode, typically `node` for these examples. */
|
||||
const char *role; /**< Protocol role string expected by the gateway for this client. */
|
||||
const char *model_identifier; /**< Optional model string such as `esp32c6` or `esp32s3`. */
|
||||
const char *locale; /**< Optional locale metadata advertised to the gateway. */
|
||||
const char *tls_common_name; /**< Optional TLS common-name override for certificate checks. */
|
||||
const char *tls_cert_pem; /**< Optional PEM trust anchor for `wss://` connections. */
|
||||
size_t tls_cert_len; /**< Length of @p tls_cert_pem in bytes, or `0` for NUL-terminated PEM. */
|
||||
bool use_cert_bundle; /**< Use the ESP-IDF certificate bundle for server validation. */
|
||||
bool skip_cert_common_name_check; /**< Skip common-name validation for TLS server certificates. */
|
||||
esp_openclaw_node_event_cb_t event_cb; /**< Optional event callback invoked on the component task. */
|
||||
void *event_user_ctx; /**< Opaque caller context passed back to @p event_cb. */
|
||||
} esp_openclaw_node_config_t;
|
||||
|
||||
/* Command Types */
|
||||
|
||||
/** @brief Structured command error returned to the gateway. */
|
||||
typedef struct {
|
||||
const char *code; /**< Stable machine-readable error code returned to the gateway. */
|
||||
const char *message; /**< Human-readable error message returned to the gateway. */
|
||||
} esp_openclaw_node_error_t;
|
||||
|
||||
/**
|
||||
* @brief Handler for a single advertised OpenClaw command.
|
||||
*
|
||||
* @param node Node instance handling the request.
|
||||
* @param context Optional user context from the command registration.
|
||||
* @param params_json UTF-8 JSON parameters from the gateway request. When the
|
||||
* request omits `paramsJSON`, the component passes `"{}"`.
|
||||
* @param params_len Length of @p params_json in bytes, excluding the trailing
|
||||
* `NUL`.
|
||||
* @param[out] out_payload_json Optional UTF-8 JSON payload to send back to the
|
||||
* gateway. When non-`NULL`, the handler transfers ownership of a
|
||||
* `malloc()`-compatible buffer to the component.
|
||||
* @param[out] out_error Structured command error when the handler fails.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - another ESP-IDF error code on failure
|
||||
*/
|
||||
typedef esp_err_t (*esp_openclaw_node_command_handler_t)(
|
||||
esp_openclaw_node_handle_t node,
|
||||
void *context,
|
||||
const char *params_json,
|
||||
size_t params_len,
|
||||
char **out_payload_json,
|
||||
esp_openclaw_node_error_t *out_error);
|
||||
|
||||
/** @brief Command registration entry passed to esp_openclaw_node_register_command(). */
|
||||
typedef struct {
|
||||
const char *name; /**< Command name advertised to the gateway, for example `wifi.status`. */
|
||||
esp_openclaw_node_command_handler_t handler; /**< Command callback invoked for matching requests. */
|
||||
void *context; /**< Opaque caller context passed to @p handler. */
|
||||
} esp_openclaw_node_command_t;
|
||||
|
||||
/* Connect Input Types */
|
||||
|
||||
/** @brief Caller-chosen source for one connect attempt. */
|
||||
typedef enum {
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION = 0, /**< Reconnect with the persisted `{ gateway_uri, device_token }` session. */
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE, /**< Decode and use a setup code that contains the gateway URI and exactly one auth secret. */
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_TOKEN, /**< Connect with an explicit shared gateway token. */
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_PASSWORD, /**< Connect with an explicit shared gateway password. */
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_NO_AUTH, /**< Connect to a gateway that intentionally allows unauthenticated node access. */
|
||||
} esp_openclaw_node_connect_source_t;
|
||||
|
||||
/**
|
||||
* @brief Connect request submitted to @ref esp_openclaw_node_request_connect().
|
||||
*
|
||||
* Field requirements by source:
|
||||
* - `SAVED_SESSION`: `gateway_uri = NULL`, `value = NULL`
|
||||
* - `SETUP_CODE`: `gateway_uri = NULL`, `value = <setup code>`
|
||||
* - `GATEWAY_TOKEN`: `gateway_uri = <ws://...|wss://...>`, `value = <token>`
|
||||
* - `GATEWAY_PASSWORD`: `gateway_uri = <ws://...|wss://...>`, `value = <password>`
|
||||
* - `NO_AUTH`: `gateway_uri = <ws://...|wss://...>`, `value = NULL`
|
||||
*/
|
||||
typedef struct {
|
||||
esp_openclaw_node_connect_source_t source; /**< Caller-chosen source for this one connect attempt. */
|
||||
const char *gateway_uri; /**< Explicit `ws://` or `wss://` URI when required by @p source. */
|
||||
const char *value; /**< Setup code, token, or password when required by @p source. */
|
||||
} esp_openclaw_node_connect_request_t;
|
||||
|
||||
/* Lifecycle APIs */
|
||||
|
||||
/**
|
||||
* @brief Populate a config struct with the component's built-in defaults.
|
||||
*
|
||||
* @param[out] config Configuration struct to initialize.
|
||||
*/
|
||||
void esp_openclaw_node_config_init_default(esp_openclaw_node_config_t *config);
|
||||
|
||||
/**
|
||||
* @brief Create a node instance from the supplied configuration.
|
||||
*
|
||||
* This also creates the component queues and task used to serialize transport
|
||||
* and session state transitions.
|
||||
*
|
||||
* @param[in] config Configuration to copy into the node instance.
|
||||
* @param[out] out_node Created node handle.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - `ESP_ERR_INVALID_ARG` if `config` or `out_node` is `NULL`
|
||||
* - `ESP_ERR_NO_MEM` if allocation of the node, queues, task, mutex, or
|
||||
* copied config strings fails
|
||||
* - another initialization error if identity setup or saved-session
|
||||
* loading fails
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_create(
|
||||
const esp_openclaw_node_config_t *config,
|
||||
esp_openclaw_node_handle_t *out_node);
|
||||
|
||||
/**
|
||||
* @brief Destroy a node instance and release all owned resources.
|
||||
*
|
||||
* This API is synchronous. It rejects self-destroy from the component task or
|
||||
* callback context.
|
||||
*
|
||||
* @param[in] node Node handle to destroy.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - `ESP_ERR_INVALID_ARG` if `node` is `NULL`
|
||||
* - `ESP_ERR_INVALID_STATE` if destroy has already begun or the current
|
||||
* task is the component task
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_destroy(esp_openclaw_node_handle_t node);
|
||||
|
||||
/* Registration APIs */
|
||||
|
||||
/**
|
||||
* @brief Advertise a capability string such as `device` or `wifi`.
|
||||
*
|
||||
* @param[in] node Node handle.
|
||||
* @param[in] capability Capability name to register.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - `ESP_ERR_INVALID_ARG` if `node` or `capability` is invalid
|
||||
* - `ESP_ERR_INVALID_STATE` if the node is not idle
|
||||
* - `ESP_ERR_NO_MEM` if the registry is full or the capability copy
|
||||
* cannot be allocated
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_register_capability(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *capability);
|
||||
|
||||
/**
|
||||
* @brief Register a handler for one OpenClaw command.
|
||||
*
|
||||
* @param[in] node Node handle.
|
||||
* @param[in] command Command registration entry.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - `ESP_ERR_INVALID_ARG` if `node` or `command` is invalid
|
||||
* - `ESP_ERR_INVALID_STATE` if the node is not idle
|
||||
* - `ESP_ERR_NO_MEM` if the registry is full or the command name copy
|
||||
* cannot be allocated
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_register_command(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_command_t *command);
|
||||
|
||||
/* Async Request APIs */
|
||||
|
||||
/**
|
||||
* @brief Request one connect attempt using an explicit caller-chosen source.
|
||||
*
|
||||
* The request source remains explicit at the API boundary:
|
||||
* - saved reconnect session
|
||||
* - setup code
|
||||
* - gateway token
|
||||
* - gateway password
|
||||
* - no-auth
|
||||
*
|
||||
* @param[in] node Node handle.
|
||||
* @param[in] request Connect request to submit.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` if the request was accepted into the component queue
|
||||
* - `ESP_ERR_INVALID_ARG` if `node` or `request` is invalid
|
||||
* - `ESP_ERR_INVALID_STATE` if the node is not idle, the saved reconnect
|
||||
* session is missing for `SAVED_SESSION`, or destroy has begun
|
||||
* - `ESP_ERR_NO_MEM` if the request could not be copied or queued
|
||||
* - `ESP_FAIL` on an unexpected local submission failure
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_request_connect(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_connect_request_t *request);
|
||||
|
||||
/**
|
||||
* @brief Request disconnect of the active session.
|
||||
*
|
||||
* @param[in] node Node handle.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` if the request was accepted into the component queue
|
||||
* - `ESP_ERR_INVALID_ARG` if `node` is `NULL`
|
||||
* - `ESP_ERR_INVALID_STATE` if no active session is present or destroy has
|
||||
* begun
|
||||
* - `ESP_ERR_NO_MEM` if the request could not be queued
|
||||
* - `ESP_FAIL` on an unexpected local submission failure
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_request_disconnect(esp_openclaw_node_handle_t node);
|
||||
|
||||
/* Inspection APIs */
|
||||
|
||||
/**
|
||||
* @brief Return the stable device identifier for this node instance.
|
||||
*
|
||||
* @param[in] node Node handle.
|
||||
*
|
||||
* @return Device identifier string, or `NULL` if @p node is `NULL`.
|
||||
*/
|
||||
const char *esp_openclaw_node_get_device_id(esp_openclaw_node_handle_t node);
|
||||
|
||||
/**
|
||||
* @brief Query whether a saved reconnect session is currently available.
|
||||
*
|
||||
* @param[in] node Node handle.
|
||||
*
|
||||
* @return `true` when a saved reconnect session is present.
|
||||
*/
|
||||
bool esp_openclaw_node_has_saved_session(esp_openclaw_node_handle_t node);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@ -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);
|
||||
@ -0,0 +1,293 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_websocket_client.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_openclaw_node_identity.h"
|
||||
#include "esp_openclaw_node.h"
|
||||
#include "esp_openclaw_node_persisted_session.h"
|
||||
|
||||
#define ESP_OPENCLAW_NODE_TAG "esp_openclaw_node"
|
||||
#define ESP_OPENCLAW_NODE_CONNECT_TIMEOUT_MS 12000LL
|
||||
#define ESP_OPENCLAW_NODE_WS_PING_INTERVAL_SEC 5
|
||||
#define ESP_OPENCLAW_NODE_WS_PINGPONG_TIMEOUT_SEC 10
|
||||
#define ESP_OPENCLAW_NODE_TASK_POLL_TICKS pdMS_TO_TICKS(250)
|
||||
#define ESP_OPENCLAW_NODE_WORK_QUEUE_LENGTH CONFIG_ESP_OPENCLAW_NODE_WORK_QUEUE_LENGTH
|
||||
#define ESP_OPENCLAW_NODE_TASK_STACK_SIZE CONFIG_ESP_OPENCLAW_NODE_TASK_STACK_SIZE
|
||||
#define ESP_OPENCLAW_NODE_TRANSPORT_TASK_STACK_SIZE \
|
||||
CONFIG_ESP_OPENCLAW_NODE_TRANSPORT_TASK_STACK_SIZE
|
||||
#define ESP_OPENCLAW_NODE_TRANSPORT_BUFFER_SIZE \
|
||||
CONFIG_ESP_OPENCLAW_NODE_TRANSPORT_BUFFER_SIZE
|
||||
|
||||
typedef enum {
|
||||
ESP_OPENCLAW_NODE_INTERNAL_IDLE = 0,
|
||||
ESP_OPENCLAW_NODE_INTERNAL_CONNECTING,
|
||||
ESP_OPENCLAW_NODE_INTERNAL_READY,
|
||||
ESP_OPENCLAW_NODE_INTERNAL_DESTROYING,
|
||||
ESP_OPENCLAW_NODE_INTERNAL_CLOSED,
|
||||
} esp_openclaw_node_internal_state_t;
|
||||
|
||||
typedef enum {
|
||||
ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_NONE = 0,
|
||||
ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_CONNECT,
|
||||
ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_DISCONNECT,
|
||||
} esp_openclaw_node_pending_control_request_t;
|
||||
|
||||
typedef enum {
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NONE = 0,
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SAVED_SESSION,
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_BOOTSTRAP_TOKEN,
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SHARED_TOKEN,
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_PASSWORD,
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NO_AUTH,
|
||||
} esp_openclaw_node_connect_source_kind_t;
|
||||
|
||||
typedef struct {
|
||||
esp_openclaw_node_connect_source_kind_t kind;
|
||||
char *gateway_uri;
|
||||
char *secret;
|
||||
} esp_openclaw_node_connect_request_source_t;
|
||||
|
||||
typedef struct {
|
||||
esp_openclaw_node_connect_source_kind_t kind;
|
||||
char *auth_value;
|
||||
const char *signature_token;
|
||||
} esp_openclaw_node_connect_material_t;
|
||||
|
||||
typedef enum {
|
||||
ESP_OPENCLAW_NODE_WORK_MSG_REQUEST_CONNECT = 0,
|
||||
ESP_OPENCLAW_NODE_WORK_MSG_REQUEST_DISCONNECT,
|
||||
ESP_OPENCLAW_NODE_WORK_MSG_WS_CONNECTED,
|
||||
ESP_OPENCLAW_NODE_WORK_MSG_WS_DISCONNECTED,
|
||||
ESP_OPENCLAW_NODE_WORK_MSG_WS_ERROR,
|
||||
ESP_OPENCLAW_NODE_WORK_MSG_DATA,
|
||||
ESP_OPENCLAW_NODE_WORK_MSG_SHUTDOWN,
|
||||
} esp_openclaw_node_work_message_type_t;
|
||||
|
||||
typedef struct {
|
||||
esp_openclaw_node_work_message_type_t type;
|
||||
uint32_t generation;
|
||||
esp_err_t local_err;
|
||||
char *text;
|
||||
esp_openclaw_node_connect_request_source_t connect_source;
|
||||
} esp_openclaw_node_work_message_t;
|
||||
|
||||
typedef struct {
|
||||
char *name;
|
||||
esp_openclaw_node_command_handler_t handler;
|
||||
void *context;
|
||||
} esp_openclaw_node_registered_command_t;
|
||||
|
||||
typedef struct esp_openclaw_node_transport_event_ctx {
|
||||
struct esp_openclaw_node *node;
|
||||
uint32_t generation;
|
||||
} esp_openclaw_node_transport_event_ctx_t;
|
||||
|
||||
typedef struct {
|
||||
esp_websocket_client_handle_t (*client_init)(const esp_websocket_client_config_t *config);
|
||||
esp_err_t (*register_events)(
|
||||
esp_websocket_client_handle_t client,
|
||||
esp_websocket_event_id_t event,
|
||||
esp_event_handler_t event_handler,
|
||||
void *event_handler_arg);
|
||||
esp_err_t (*client_start)(esp_websocket_client_handle_t client);
|
||||
esp_err_t (*client_stop)(esp_websocket_client_handle_t client);
|
||||
esp_err_t (*client_destroy)(esp_websocket_client_handle_t client);
|
||||
int (*send_text)(
|
||||
esp_websocket_client_handle_t client,
|
||||
const char *data,
|
||||
int len,
|
||||
TickType_t timeout);
|
||||
int (*send_with_opcode)(
|
||||
esp_websocket_client_handle_t client,
|
||||
ws_transport_opcodes_t opcode,
|
||||
const uint8_t *data,
|
||||
int len,
|
||||
TickType_t timeout);
|
||||
} esp_openclaw_node_transport_ops_t;
|
||||
|
||||
typedef enum {
|
||||
CONNECT_RESPONSE_OUTCOME_IGNORE = 0,
|
||||
CONNECT_RESPONSE_OUTCOME_CONNECTED,
|
||||
CONNECT_RESPONSE_OUTCOME_CONNECT_FAILED,
|
||||
} connect_response_outcome_t;
|
||||
|
||||
typedef struct {
|
||||
connect_response_outcome_t outcome;
|
||||
esp_err_t err;
|
||||
esp_openclaw_node_internal_state_t state;
|
||||
} connect_response_finalize_result_t;
|
||||
|
||||
struct esp_openclaw_node {
|
||||
QueueHandle_t work_queue;
|
||||
TaskHandle_t task_handle;
|
||||
SemaphoreHandle_t destroy_done;
|
||||
SemaphoreHandle_t state_lock;
|
||||
const esp_openclaw_node_transport_ops_t *transport_ops;
|
||||
esp_openclaw_node_identity_t identity;
|
||||
esp_openclaw_node_persisted_session_t persisted_session;
|
||||
esp_openclaw_node_config_t config;
|
||||
esp_openclaw_node_internal_state_t state;
|
||||
esp_openclaw_node_pending_control_request_t pending_control;
|
||||
esp_websocket_client_handle_t ws;
|
||||
esp_openclaw_node_transport_event_ctx_t *transport_ctx;
|
||||
uint32_t next_transport_generation;
|
||||
uint32_t active_transport_generation;
|
||||
bool transport_connected;
|
||||
bool ws_started;
|
||||
char pending_connect_id[32];
|
||||
int64_t connect_started_ms;
|
||||
char *transport_gateway_uri;
|
||||
char *rx_buffer;
|
||||
size_t rx_buffer_len;
|
||||
esp_openclaw_node_connect_request_source_t active_connect_source;
|
||||
size_t capability_count;
|
||||
char *capabilities[ESP_OPENCLAW_NODE_MAX_CAPABILITIES];
|
||||
size_t command_count;
|
||||
esp_openclaw_node_registered_command_t commands[ESP_OPENCLAW_NODE_MAX_COMMANDS];
|
||||
};
|
||||
|
||||
extern const esp_openclaw_node_transport_ops_t esp_openclaw_node_default_transport_ops;
|
||||
|
||||
__attribute__((weak)) const esp_openclaw_node_transport_ops_t *esp_openclaw_node_test_transport_ops(void);
|
||||
|
||||
const char *esp_openclaw_node_firmware_version(void);
|
||||
char *esp_openclaw_node_duplicate_string(const char *value);
|
||||
const char *esp_openclaw_node_trimmed_or_null(const char *value);
|
||||
bool esp_openclaw_node_is_valid_gateway_uri(const char *gateway_uri);
|
||||
|
||||
void esp_openclaw_node_lock_state(esp_openclaw_node_handle_t node);
|
||||
void esp_openclaw_node_unlock_state(esp_openclaw_node_handle_t node);
|
||||
|
||||
esp_err_t esp_openclaw_node_copy_config(
|
||||
const esp_openclaw_node_config_t *src,
|
||||
esp_openclaw_node_config_t *dst);
|
||||
void esp_openclaw_node_free_config_strings(esp_openclaw_node_config_t *config);
|
||||
|
||||
void esp_openclaw_node_clear_session_wait_state_locked(esp_openclaw_node_handle_t node);
|
||||
void esp_openclaw_node_set_pending_control_locked(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_pending_control_request_t kind);
|
||||
void esp_openclaw_node_clear_pending_control_locked(esp_openclaw_node_handle_t node);
|
||||
bool esp_openclaw_node_saved_session_is_present_locked(
|
||||
const esp_openclaw_node_handle_t node);
|
||||
bool esp_openclaw_node_state_is_connecting(esp_openclaw_node_internal_state_t state);
|
||||
const char *esp_openclaw_node_internal_state_name(
|
||||
esp_openclaw_node_internal_state_t state);
|
||||
void esp_openclaw_node_clear_data_buffer_locked(esp_openclaw_node_handle_t node);
|
||||
|
||||
void esp_openclaw_node_free_work_message_payload(esp_openclaw_node_work_message_t *message);
|
||||
void esp_openclaw_node_drain_work_queue(esp_openclaw_node_handle_t node);
|
||||
|
||||
void esp_openclaw_node_emit_connected(esp_openclaw_node_handle_t node);
|
||||
void esp_openclaw_node_emit_connect_failed(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_connect_failure_reason_t reason,
|
||||
esp_err_t local_err,
|
||||
const char *gateway_detail_code);
|
||||
void esp_openclaw_node_emit_disconnected(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_disconnected_reason_t reason,
|
||||
esp_err_t local_err);
|
||||
|
||||
bool esp_openclaw_node_is_node_task_context(esp_openclaw_node_handle_t node);
|
||||
|
||||
void esp_openclaw_node_clear_connect_source_struct(
|
||||
esp_openclaw_node_connect_request_source_t *source);
|
||||
esp_err_t esp_openclaw_node_build_connect_source_from_request(
|
||||
const esp_openclaw_node_connect_request_t *request,
|
||||
esp_openclaw_node_connect_request_source_t *out_source);
|
||||
const char *esp_openclaw_node_connect_source_kind_name(
|
||||
esp_openclaw_node_connect_source_kind_t kind);
|
||||
void esp_openclaw_node_free_connect_material(esp_openclaw_node_connect_material_t *material);
|
||||
esp_err_t esp_openclaw_node_resolve_active_connect_material_locked(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_connect_material_t *material);
|
||||
esp_err_t esp_openclaw_node_reserve_connect_request_locked(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_connect_request_source_t *source);
|
||||
|
||||
void esp_openclaw_node_cleanup_registry(esp_openclaw_node_handle_t node);
|
||||
esp_openclaw_node_registered_command_t *esp_openclaw_node_find_command(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *name);
|
||||
esp_err_t esp_openclaw_node_dispatch_command(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *command,
|
||||
const char *params_json,
|
||||
size_t params_len,
|
||||
char **out_payload_json,
|
||||
const char **out_error_code,
|
||||
const char **out_error_message);
|
||||
void esp_openclaw_node_add_registered_string_array(
|
||||
cJSON *parent,
|
||||
const char *name,
|
||||
char *const *items,
|
||||
size_t count);
|
||||
void esp_openclaw_node_add_registered_command_array(
|
||||
cJSON *parent,
|
||||
const char *name,
|
||||
esp_openclaw_node_handle_t node);
|
||||
esp_err_t esp_openclaw_node_register_capability_internal(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *capability);
|
||||
esp_err_t esp_openclaw_node_register_command_internal(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_command_t *command);
|
||||
|
||||
esp_err_t esp_openclaw_node_validate_tls_preflight(
|
||||
const esp_openclaw_node_config_t *config,
|
||||
const char *gateway_uri);
|
||||
esp_err_t esp_openclaw_node_start_transport_for_active_source(
|
||||
esp_openclaw_node_handle_t node);
|
||||
void esp_openclaw_node_cleanup_transport_instance(
|
||||
esp_openclaw_node_handle_t node,
|
||||
bool stop_client);
|
||||
bool esp_openclaw_node_should_accept_callback_generation_locked(
|
||||
esp_openclaw_node_handle_t node,
|
||||
uint32_t generation);
|
||||
void esp_openclaw_node_send_challenge_kick_ping(esp_openclaw_node_handle_t node);
|
||||
|
||||
void esp_openclaw_node_process_gateway_message(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *text);
|
||||
|
||||
void esp_openclaw_node_complete_connect_failed(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_connect_failure_reason_t reason,
|
||||
esp_err_t local_err,
|
||||
const char *gateway_detail_code,
|
||||
bool stop_client);
|
||||
void esp_openclaw_node_complete_disconnected(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_disconnected_reason_t reason,
|
||||
esp_err_t local_err,
|
||||
bool stop_client);
|
||||
void esp_openclaw_node_fail_if_connect_timed_out(esp_openclaw_node_handle_t node);
|
||||
esp_err_t esp_openclaw_node_enqueue_work_message(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_work_message_t *message);
|
||||
void esp_openclaw_node_enqueue_work_message_from_callback(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_work_message_t *message);
|
||||
void esp_openclaw_node_task(void *arg);
|
||||
esp_err_t esp_openclaw_node_submit_connect_request(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_connect_request_source_t *connect_source);
|
||||
esp_err_t esp_openclaw_node_submit_disconnect_request(
|
||||
esp_openclaw_node_handle_t node);
|
||||
@ -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);
|
||||
583
components/esp-openclaw-node/src/esp_openclaw_node.c
Normal file
583
components/esp-openclaw-node/src/esp_openclaw_node.c
Normal file
@ -0,0 +1,583 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_internal.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_app_desc.h"
|
||||
#include "esp_check.h"
|
||||
|
||||
static const char *DEFAULT_PLATFORM = "esp32";
|
||||
static const char *DEFAULT_DEVICE_FAMILY = "ESP32";
|
||||
static const char *DEFAULT_DISPLAY_NAME = "OpenClaw ESP32";
|
||||
static const char *DEFAULT_CLIENT_ID = "node-host";
|
||||
static const char *DEFAULT_CLIENT_MODE = "node";
|
||||
static const char *DEFAULT_ROLE = "node";
|
||||
static const char *DEFAULT_LOCALE = "en-US";
|
||||
|
||||
const esp_openclaw_node_transport_ops_t esp_openclaw_node_default_transport_ops = {
|
||||
.client_init = esp_websocket_client_init,
|
||||
.register_events = esp_websocket_register_events,
|
||||
.client_start = esp_websocket_client_start,
|
||||
.client_stop = esp_websocket_client_stop,
|
||||
.client_destroy = esp_websocket_client_destroy,
|
||||
.send_text = esp_websocket_client_send_text,
|
||||
.send_with_opcode = esp_websocket_client_send_with_opcode,
|
||||
};
|
||||
|
||||
__attribute__((weak)) const esp_openclaw_node_transport_ops_t *esp_openclaw_node_test_transport_ops(void)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const char *esp_openclaw_node_firmware_version(void)
|
||||
{
|
||||
return esp_app_get_description()->version;
|
||||
}
|
||||
|
||||
char *esp_openclaw_node_duplicate_string(const char *value)
|
||||
{
|
||||
if (value == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
return strdup(value);
|
||||
}
|
||||
|
||||
const char *esp_openclaw_node_trimmed_or_null(const char *value)
|
||||
{
|
||||
if (value == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
while (*value == ' ' || *value == '\t' || *value == '\r' ||
|
||||
*value == '\n') {
|
||||
++value;
|
||||
}
|
||||
return value[0] != '\0' ? value : NULL;
|
||||
}
|
||||
|
||||
bool esp_openclaw_node_is_valid_gateway_uri(const char *gateway_uri)
|
||||
{
|
||||
const char *trimmed = esp_openclaw_node_trimmed_or_null(gateway_uri);
|
||||
if (trimmed == NULL) {
|
||||
return false;
|
||||
}
|
||||
return strncmp(trimmed, "ws://", 5) == 0 ||
|
||||
strncmp(trimmed, "wss://", 6) == 0;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_lock_state(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
if (node != NULL && node->state_lock != NULL) {
|
||||
xSemaphoreTakeRecursive(node->state_lock, portMAX_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
void esp_openclaw_node_unlock_state(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
if (node != NULL && node->state_lock != NULL) {
|
||||
xSemaphoreGiveRecursive(node->state_lock);
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t copy_string_field(char **dst, const char *src, bool required)
|
||||
{
|
||||
if (src == NULL || src[0] == '\0') {
|
||||
return required ? ESP_ERR_INVALID_ARG : ESP_OK;
|
||||
}
|
||||
*dst = esp_openclaw_node_duplicate_string(src);
|
||||
return *dst != NULL ? ESP_OK : ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
static esp_err_t copy_tls_cert_field(
|
||||
esp_openclaw_node_config_t *dst,
|
||||
const esp_openclaw_node_config_t *src)
|
||||
{
|
||||
if (src->tls_cert_pem == NULL) {
|
||||
dst->tls_cert_pem = NULL;
|
||||
dst->tls_cert_len = 0;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
size_t cert_len = src->tls_cert_len;
|
||||
if (cert_len == 0) {
|
||||
cert_len = strlen(src->tls_cert_pem);
|
||||
}
|
||||
|
||||
char *cert_copy = calloc(cert_len + 1U, sizeof(char));
|
||||
if (cert_copy == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
if (cert_len > 0) {
|
||||
memcpy(cert_copy, src->tls_cert_pem, cert_len);
|
||||
}
|
||||
cert_copy[cert_len] = '\0';
|
||||
|
||||
dst->tls_cert_pem = cert_copy;
|
||||
dst->tls_cert_len = src->tls_cert_len;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_free_config_strings(esp_openclaw_node_config_t *config)
|
||||
{
|
||||
free((char *)config->display_name);
|
||||
free((char *)config->platform);
|
||||
free((char *)config->device_family);
|
||||
free((char *)config->client_id);
|
||||
free((char *)config->client_mode);
|
||||
free((char *)config->role);
|
||||
free((char *)config->model_identifier);
|
||||
free((char *)config->locale);
|
||||
free((char *)config->tls_common_name);
|
||||
free((char *)config->tls_cert_pem);
|
||||
memset(config, 0, sizeof(*config));
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_copy_config(
|
||||
const esp_openclaw_node_config_t *src,
|
||||
esp_openclaw_node_config_t *dst)
|
||||
{
|
||||
const char *model_identifier =
|
||||
(src->model_identifier != NULL && src->model_identifier[0] != '\0')
|
||||
? src->model_identifier
|
||||
: CONFIG_IDF_TARGET;
|
||||
const char *locale =
|
||||
(src->locale != NULL && src->locale[0] != '\0')
|
||||
? src->locale
|
||||
: DEFAULT_LOCALE;
|
||||
|
||||
memset(dst, 0, sizeof(*dst));
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field((char **)&dst->display_name, src->display_name, true),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"display_name");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field((char **)&dst->platform, src->platform, true),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"platform");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field(
|
||||
(char **)&dst->device_family,
|
||||
src->device_family,
|
||||
true),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"device_family");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field((char **)&dst->client_id, src->client_id, true),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"client_id");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field(
|
||||
(char **)&dst->client_mode,
|
||||
src->client_mode,
|
||||
true),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"client_mode");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field((char **)&dst->role, src->role, true),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"role");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field(
|
||||
(char **)&dst->model_identifier,
|
||||
model_identifier,
|
||||
false),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"model_identifier");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field((char **)&dst->locale, locale, false),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"locale");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field(
|
||||
(char **)&dst->tls_common_name,
|
||||
src->tls_common_name,
|
||||
false),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"tls_common_name");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_tls_cert_field(dst, src),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"tls_cert_pem");
|
||||
dst->use_cert_bundle = src->use_cert_bundle;
|
||||
dst->skip_cert_common_name_check = src->skip_cert_common_name_check;
|
||||
dst->event_cb = src->event_cb;
|
||||
dst->event_user_ctx = src->event_user_ctx;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_clear_session_wait_state_locked(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
node->pending_connect_id[0] = '\0';
|
||||
node->connect_started_ms = 0;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_set_pending_control_locked(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_pending_control_request_t kind)
|
||||
{
|
||||
node->pending_control = kind;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_clear_pending_control_locked(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
esp_openclaw_node_set_pending_control_locked(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_NONE);
|
||||
}
|
||||
|
||||
bool esp_openclaw_node_saved_session_is_present_locked(
|
||||
const esp_openclaw_node_handle_t node)
|
||||
{
|
||||
return esp_openclaw_node_persisted_session_is_present(&node->persisted_session);
|
||||
}
|
||||
|
||||
bool esp_openclaw_node_state_is_connecting(esp_openclaw_node_internal_state_t state)
|
||||
{
|
||||
return state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING;
|
||||
}
|
||||
|
||||
const char *esp_openclaw_node_internal_state_name(
|
||||
esp_openclaw_node_internal_state_t state)
|
||||
{
|
||||
switch (state) {
|
||||
case ESP_OPENCLAW_NODE_INTERNAL_IDLE:
|
||||
return "idle";
|
||||
case ESP_OPENCLAW_NODE_INTERNAL_CONNECTING:
|
||||
return "connecting";
|
||||
case ESP_OPENCLAW_NODE_INTERNAL_READY:
|
||||
return "ready";
|
||||
case ESP_OPENCLAW_NODE_INTERNAL_DESTROYING:
|
||||
return "destroying";
|
||||
case ESP_OPENCLAW_NODE_INTERNAL_CLOSED:
|
||||
return "closed";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
void esp_openclaw_node_clear_data_buffer_locked(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
free(node->rx_buffer);
|
||||
node->rx_buffer = NULL;
|
||||
node->rx_buffer_len = 0;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_free_work_message_payload(esp_openclaw_node_work_message_t *message)
|
||||
{
|
||||
if (message == NULL) {
|
||||
return;
|
||||
}
|
||||
free(message->text);
|
||||
message->text = NULL;
|
||||
esp_openclaw_node_clear_connect_source_struct(&message->connect_source);
|
||||
}
|
||||
|
||||
void esp_openclaw_node_drain_work_queue(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
esp_openclaw_node_work_message_t message = {0};
|
||||
while (node->work_queue != NULL &&
|
||||
xQueueReceive(node->work_queue, &message, 0) == pdTRUE) {
|
||||
esp_openclaw_node_free_work_message_payload(&message);
|
||||
}
|
||||
}
|
||||
|
||||
static void emit_event(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_event_t event,
|
||||
const void *event_data)
|
||||
{
|
||||
esp_openclaw_node_event_cb_t event_cb = NULL;
|
||||
void *event_user_ctx = NULL;
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
event_cb = node->config.event_cb;
|
||||
event_user_ctx = node->config.event_user_ctx;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
if (event_cb != NULL) {
|
||||
event_cb(node, event, event_data, event_user_ctx);
|
||||
}
|
||||
}
|
||||
|
||||
void esp_openclaw_node_emit_connected(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
emit_event(node, ESP_OPENCLAW_NODE_EVENT_CONNECTED, NULL);
|
||||
}
|
||||
|
||||
void esp_openclaw_node_emit_connect_failed(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_connect_failure_reason_t reason,
|
||||
esp_err_t local_err,
|
||||
const char *gateway_detail_code)
|
||||
{
|
||||
esp_openclaw_node_connect_failed_event_t event = {
|
||||
.reason = reason,
|
||||
.local_err = local_err,
|
||||
.gateway_detail_code = gateway_detail_code,
|
||||
};
|
||||
emit_event(node, ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED, &event);
|
||||
}
|
||||
|
||||
void esp_openclaw_node_emit_disconnected(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_disconnected_reason_t reason,
|
||||
esp_err_t local_err)
|
||||
{
|
||||
esp_openclaw_node_disconnected_event_t event = {
|
||||
.reason = reason,
|
||||
.local_err = local_err,
|
||||
};
|
||||
emit_event(node, ESP_OPENCLAW_NODE_EVENT_DISCONNECTED, &event);
|
||||
}
|
||||
|
||||
bool esp_openclaw_node_is_node_task_context(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
return node != NULL && node->task_handle != NULL &&
|
||||
xTaskGetCurrentTaskHandle() == node->task_handle;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_config_init_default(esp_openclaw_node_config_t *config)
|
||||
{
|
||||
if (config == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
memset(config, 0, sizeof(*config));
|
||||
config->display_name = DEFAULT_DISPLAY_NAME;
|
||||
config->platform = DEFAULT_PLATFORM;
|
||||
config->device_family = DEFAULT_DEVICE_FAMILY;
|
||||
config->client_id = DEFAULT_CLIENT_ID;
|
||||
config->client_mode = DEFAULT_CLIENT_MODE;
|
||||
config->role = DEFAULT_ROLE;
|
||||
config->model_identifier = CONFIG_IDF_TARGET;
|
||||
config->locale = DEFAULT_LOCALE;
|
||||
config->use_cert_bundle = true;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_create(
|
||||
const esp_openclaw_node_config_t *config,
|
||||
esp_openclaw_node_handle_t *out_node)
|
||||
{
|
||||
if (config == NULL || out_node == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
esp_openclaw_node_handle_t node = calloc(1, sizeof(*node));
|
||||
if (node == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
node->state_lock = xSemaphoreCreateRecursiveMutex();
|
||||
if (node->state_lock == NULL) {
|
||||
free(node);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
node->destroy_done = xSemaphoreCreateBinary();
|
||||
if (node->destroy_done == NULL) {
|
||||
vSemaphoreDelete(node->state_lock);
|
||||
free(node);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
node->work_queue = xQueueCreate(
|
||||
ESP_OPENCLAW_NODE_WORK_QUEUE_LENGTH,
|
||||
sizeof(esp_openclaw_node_work_message_t));
|
||||
if (node->work_queue == NULL) {
|
||||
vSemaphoreDelete(node->destroy_done);
|
||||
vSemaphoreDelete(node->state_lock);
|
||||
free(node);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
esp_err_t err = esp_openclaw_node_copy_config(config, &node->config);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_free_config_strings(&node->config);
|
||||
vQueueDelete(node->work_queue);
|
||||
vSemaphoreDelete(node->destroy_done);
|
||||
vSemaphoreDelete(node->state_lock);
|
||||
free(node);
|
||||
return err;
|
||||
}
|
||||
|
||||
err = esp_openclaw_node_identity_load_or_create(&node->identity);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_free_config_strings(&node->config);
|
||||
vQueueDelete(node->work_queue);
|
||||
vSemaphoreDelete(node->destroy_done);
|
||||
vSemaphoreDelete(node->state_lock);
|
||||
free(node);
|
||||
return err;
|
||||
}
|
||||
|
||||
err = esp_openclaw_node_persisted_session_load(&node->persisted_session);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_identity_free(&node->identity);
|
||||
esp_openclaw_node_free_config_strings(&node->config);
|
||||
vQueueDelete(node->work_queue);
|
||||
vSemaphoreDelete(node->destroy_done);
|
||||
vSemaphoreDelete(node->state_lock);
|
||||
free(node);
|
||||
return err;
|
||||
}
|
||||
|
||||
const esp_openclaw_node_transport_ops_t *test_ops =
|
||||
esp_openclaw_node_test_transport_ops();
|
||||
node->transport_ops = test_ops != NULL
|
||||
? test_ops
|
||||
: &esp_openclaw_node_default_transport_ops;
|
||||
node->state = ESP_OPENCLAW_NODE_INTERNAL_IDLE;
|
||||
|
||||
BaseType_t task_ok = xTaskCreate(
|
||||
esp_openclaw_node_task,
|
||||
"esp_openclaw_node",
|
||||
ESP_OPENCLAW_NODE_TASK_STACK_SIZE,
|
||||
node,
|
||||
5,
|
||||
&node->task_handle);
|
||||
if (task_ok != pdPASS) {
|
||||
esp_openclaw_node_persisted_session_free(&node->persisted_session);
|
||||
esp_openclaw_node_identity_free(&node->identity);
|
||||
esp_openclaw_node_free_config_strings(&node->config);
|
||||
vQueueDelete(node->work_queue);
|
||||
vSemaphoreDelete(node->destroy_done);
|
||||
vSemaphoreDelete(node->state_lock);
|
||||
free(node);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
*out_node = node;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_destroy(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
if (node == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (esp_openclaw_node_is_node_task_context(node)) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
if (node->state == ESP_OPENCLAW_NODE_INTERNAL_DESTROYING ||
|
||||
node->state == ESP_OPENCLAW_NODE_INTERNAL_CLOSED) {
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
SemaphoreHandle_t destroy_done = node->destroy_done;
|
||||
if (destroy_done == NULL) {
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
node->state = ESP_OPENCLAW_NODE_INTERNAL_DESTROYING;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
(void)xSemaphoreTake(destroy_done, 0);
|
||||
|
||||
esp_openclaw_node_work_message_t message = {
|
||||
.type = ESP_OPENCLAW_NODE_WORK_MSG_SHUTDOWN,
|
||||
};
|
||||
if (node->work_queue == NULL ||
|
||||
xQueueSend(node->work_queue, &message, portMAX_DELAY) != pdTRUE) {
|
||||
esp_openclaw_node_lock_state(node);
|
||||
node->state = ESP_OPENCLAW_NODE_INTERNAL_IDLE;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
if (xSemaphoreTake(destroy_done, portMAX_DELAY) != pdTRUE) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
if (node->work_queue != NULL) {
|
||||
vQueueDelete(node->work_queue);
|
||||
node->work_queue = NULL;
|
||||
}
|
||||
if (node->state_lock != NULL) {
|
||||
vSemaphoreDelete(node->state_lock);
|
||||
node->state_lock = NULL;
|
||||
}
|
||||
if (node->destroy_done != NULL) {
|
||||
vSemaphoreDelete(node->destroy_done);
|
||||
node->destroy_done = NULL;
|
||||
}
|
||||
|
||||
esp_openclaw_node_cleanup_registry(node);
|
||||
esp_openclaw_node_clear_connect_source_struct(&node->active_connect_source);
|
||||
esp_openclaw_node_persisted_session_free(&node->persisted_session);
|
||||
esp_openclaw_node_identity_free(&node->identity);
|
||||
esp_openclaw_node_free_config_strings(&node->config);
|
||||
free(node);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_register_capability(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *capability)
|
||||
{
|
||||
return esp_openclaw_node_register_capability_internal(node, capability);
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_register_command(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_command_t *command)
|
||||
{
|
||||
return esp_openclaw_node_register_command_internal(node, command);
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_request_connect(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_connect_request_t *request)
|
||||
{
|
||||
if (node == NULL || request == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
esp_openclaw_node_connect_request_source_t connect_source = {0};
|
||||
esp_err_t err =
|
||||
esp_openclaw_node_build_connect_source_from_request(
|
||||
request,
|
||||
&connect_source);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
err = esp_openclaw_node_submit_connect_request(node, &connect_source);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_clear_connect_source_struct(&connect_source);
|
||||
return err;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_request_disconnect(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
if (node == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
return esp_openclaw_node_submit_disconnect_request(node);
|
||||
}
|
||||
|
||||
const char *esp_openclaw_node_get_device_id(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
return node != NULL ? node->identity.device_id : NULL;
|
||||
}
|
||||
|
||||
bool esp_openclaw_node_has_saved_session(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
if (node == NULL) {
|
||||
return false;
|
||||
}
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool present = esp_openclaw_node_saved_session_is_present_locked(node);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
return present;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
388
components/esp-openclaw-node/src/esp_openclaw_node_identity.c
Normal file
388
components/esp-openclaw-node/src/esp_openclaw_node_identity.c
Normal file
@ -0,0 +1,388 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_identity.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_check.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_random.h"
|
||||
#include "mbedtls/base64.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "nvs.h"
|
||||
#include "sodium.h"
|
||||
|
||||
static const char *TAG = "esp_openclaw_node_identity";
|
||||
static const char *NVS_NAMESPACE = "openclaw";
|
||||
static const char *NVS_KEY_SEED = "device_seed";
|
||||
static const char *NVS_KEY_SESSION_VERSION = "session_v";
|
||||
static const char *NVS_KEY_SESSION_URI = "session_uri";
|
||||
static const char *NVS_KEY_SESSION_DEVICE_TOKEN = "session_dev_tok";
|
||||
#define ESP_OPENCLAW_NODE_DEVICE_AUTH_PAYLOAD_V3_FORMAT \
|
||||
"v3|%s|%s|%s|%s|%s|%" PRId64 "|%s|%s|%s|%s"
|
||||
|
||||
_Static_assert(ESP_OPENCLAW_NODE_ED25519_SEED_LEN == crypto_sign_SEEDBYTES, "unexpected libsodium seed size");
|
||||
_Static_assert(
|
||||
ESP_OPENCLAW_NODE_ED25519_PUBLIC_KEY_LEN == crypto_sign_PUBLICKEYBYTES,
|
||||
"unexpected libsodium public key size");
|
||||
_Static_assert(
|
||||
ESP_OPENCLAW_NODE_ED25519_PRIVATE_KEY_LEN == crypto_sign_SECRETKEYBYTES,
|
||||
"unexpected libsodium secret key size");
|
||||
|
||||
static esp_err_t ensure_sodium_ready(void)
|
||||
{
|
||||
int rc = sodium_init();
|
||||
if (rc < 0) {
|
||||
ESP_LOGE(TAG, "libsodium initialization failed");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void bytes_to_lower_hex(const uint8_t *input, size_t input_len, char *output, size_t output_size)
|
||||
{
|
||||
static const char HEX[] = "0123456789abcdef";
|
||||
if (output_size < (input_len * 2U) + 1U) {
|
||||
if (output_size > 0) {
|
||||
output[0] = '\0';
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (size_t i = 0; i < input_len; ++i) {
|
||||
output[(i * 2U)] = HEX[(input[i] >> 4) & 0x0f];
|
||||
output[(i * 2U) + 1U] = HEX[input[i] & 0x0f];
|
||||
}
|
||||
output[input_len * 2U] = '\0';
|
||||
}
|
||||
|
||||
static esp_err_t base64url_encode(
|
||||
const uint8_t *input,
|
||||
size_t input_len,
|
||||
char *output,
|
||||
size_t output_size)
|
||||
{
|
||||
size_t written = 0;
|
||||
int rc = mbedtls_base64_encode((unsigned char *)output, output_size, &written, input, input_len);
|
||||
if (rc != 0) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
for (size_t i = 0; i < written; ++i) {
|
||||
if (output[i] == '+') {
|
||||
output[i] = '-';
|
||||
} else if (output[i] == '/') {
|
||||
output[i] = '_';
|
||||
}
|
||||
}
|
||||
while (written > 0 && output[written - 1] == '=') {
|
||||
--written;
|
||||
}
|
||||
output[written] = '\0';
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static char *normalize_metadata(const char *value)
|
||||
{
|
||||
const char *start = value ? value : "";
|
||||
while (*start != '\0' && isspace((unsigned char)*start)) {
|
||||
++start;
|
||||
}
|
||||
const char *end = start + strlen(start);
|
||||
while (end > start && isspace((unsigned char)*(end - 1))) {
|
||||
--end;
|
||||
}
|
||||
|
||||
size_t len = (size_t)(end - start);
|
||||
char *normalized = calloc(len + 1U, sizeof(char));
|
||||
if (normalized == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
normalized[i] = (char)tolower((unsigned char)start[i]);
|
||||
}
|
||||
normalized[len] = '\0';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
static esp_err_t erase_nvs_key_if_present(nvs_handle_t nvs, const char *key)
|
||||
{
|
||||
esp_err_t err = nvs_erase_key(nvs, key);
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND) {
|
||||
return ESP_OK;
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
static esp_err_t clear_identity_bound_session_state(nvs_handle_t nvs)
|
||||
{
|
||||
ESP_RETURN_ON_ERROR(erase_nvs_key_if_present(nvs, NVS_KEY_SEED), TAG, "erase malformed seed");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
erase_nvs_key_if_present(nvs, NVS_KEY_SESSION_VERSION),
|
||||
TAG,
|
||||
"erase saved session version");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
erase_nvs_key_if_present(nvs, NVS_KEY_SESSION_URI),
|
||||
TAG,
|
||||
"erase saved session uri");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
erase_nvs_key_if_present(nvs, NVS_KEY_SESSION_DEVICE_TOKEN),
|
||||
TAG,
|
||||
"erase saved session device token");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t generate_and_store_seed(nvs_handle_t nvs, uint8_t *seed, bool *created)
|
||||
{
|
||||
esp_fill_random(seed, ESP_OPENCLAW_NODE_ED25519_SEED_LEN);
|
||||
ESP_RETURN_ON_ERROR(
|
||||
nvs_set_blob(nvs, NVS_KEY_SEED, seed, ESP_OPENCLAW_NODE_ED25519_SEED_LEN),
|
||||
TAG,
|
||||
"failed storing device seed");
|
||||
*created = true;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t load_seed_from_nvs(nvs_handle_t nvs, uint8_t *seed, bool *created)
|
||||
{
|
||||
size_t required = ESP_OPENCLAW_NODE_ED25519_SEED_LEN;
|
||||
esp_err_t err = nvs_get_blob(nvs, NVS_KEY_SEED, seed, &required);
|
||||
if (err == ESP_OK && required == ESP_OPENCLAW_NODE_ED25519_SEED_LEN) {
|
||||
*created = false;
|
||||
return ESP_OK;
|
||||
}
|
||||
if (err == ESP_OK || err == ESP_ERR_NVS_INVALID_LENGTH) {
|
||||
ESP_LOGW(
|
||||
TAG,
|
||||
"discarding malformed stored identity seed (size=%u) and clearing saved session",
|
||||
(unsigned)required);
|
||||
ESP_RETURN_ON_ERROR(
|
||||
clear_identity_bound_session_state(nvs),
|
||||
TAG,
|
||||
"clear malformed identity state");
|
||||
return generate_and_store_seed(nvs, seed, created);
|
||||
}
|
||||
if (err != ESP_ERR_NVS_NOT_FOUND) {
|
||||
return err;
|
||||
}
|
||||
|
||||
return generate_and_store_seed(nvs, seed, created);
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_identity_store_seed_if_absent(const uint8_t *seed, size_t seed_len)
|
||||
{
|
||||
if (seed == NULL || seed_len != ESP_OPENCLAW_NODE_ED25519_SEED_LEN) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
nvs_handle_t nvs = 0;
|
||||
esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "failed opening NVS namespace: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
size_t required = 0;
|
||||
err = nvs_get_blob(nvs, NVS_KEY_SEED, NULL, &required);
|
||||
if (err == ESP_OK) {
|
||||
nvs_close(nvs);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (err != ESP_ERR_NVS_NOT_FOUND) {
|
||||
nvs_close(nvs);
|
||||
return err;
|
||||
}
|
||||
|
||||
err = nvs_set_blob(nvs, NVS_KEY_SEED, seed, seed_len);
|
||||
if (err == ESP_OK) {
|
||||
err = nvs_commit(nvs);
|
||||
}
|
||||
nvs_close(nvs);
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_identity_load_or_create(esp_openclaw_node_identity_t *identity)
|
||||
{
|
||||
if (identity == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
memset(identity, 0, sizeof(*identity));
|
||||
|
||||
nvs_handle_t nvs = 0;
|
||||
esp_err_t err =
|
||||
nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "failed opening NVS namespace: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
bool created = false;
|
||||
err = load_seed_from_nvs(nvs, identity->seed, &created);
|
||||
if (err != ESP_OK) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
err = ensure_sodium_ready();
|
||||
if (err != ESP_OK) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
if (crypto_sign_seed_keypair(identity->public_key, identity->private_key, identity->seed) != 0) {
|
||||
ESP_LOGE(TAG, "failed deriving Ed25519 keypair");
|
||||
err = ESP_FAIL;
|
||||
goto fail;
|
||||
}
|
||||
|
||||
uint8_t digest[32] = {0};
|
||||
mbedtls_sha256(identity->public_key, ESP_OPENCLAW_NODE_ED25519_PUBLIC_KEY_LEN, digest, 0);
|
||||
bytes_to_lower_hex(digest, sizeof(digest), identity->device_id, sizeof(identity->device_id));
|
||||
err = base64url_encode(
|
||||
identity->public_key,
|
||||
ESP_OPENCLAW_NODE_ED25519_PUBLIC_KEY_LEN,
|
||||
identity->public_key_b64url,
|
||||
sizeof(identity->public_key_b64url));
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "failed encoding public key: %s", esp_err_to_name(err));
|
||||
goto fail;
|
||||
}
|
||||
|
||||
err = created ? nvs_commit(nvs) : ESP_OK;
|
||||
nvs_close(nvs);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "failed committing NVS: %s", esp_err_to_name(err));
|
||||
esp_openclaw_node_identity_free(identity);
|
||||
memset(identity, 0, sizeof(*identity));
|
||||
return err;
|
||||
}
|
||||
|
||||
ESP_LOGI(
|
||||
TAG,
|
||||
"device identity ready: %.12s...",
|
||||
identity->device_id);
|
||||
return ESP_OK;
|
||||
|
||||
fail:
|
||||
nvs_close(nvs);
|
||||
esp_openclaw_node_identity_free(identity);
|
||||
memset(identity, 0, sizeof(*identity));
|
||||
return err;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_identity_free(esp_openclaw_node_identity_t *identity)
|
||||
{
|
||||
if (identity == NULL) {
|
||||
return;
|
||||
}
|
||||
memset(identity, 0, sizeof(*identity));
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_identity_sign_payload(
|
||||
const esp_openclaw_node_identity_t *identity,
|
||||
const char *payload,
|
||||
char *signature_b64url,
|
||||
size_t signature_b64url_size)
|
||||
{
|
||||
if (identity == NULL || payload == NULL || signature_b64url == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
ESP_RETURN_ON_ERROR(ensure_sodium_ready(), TAG, "libsodium not ready");
|
||||
|
||||
uint8_t signature[64] = {0};
|
||||
unsigned long long signature_len = 0;
|
||||
if (crypto_sign_detached(
|
||||
signature,
|
||||
&signature_len,
|
||||
(const unsigned char *)payload,
|
||||
strlen(payload),
|
||||
identity->private_key) != 0) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
if (signature_len != sizeof(signature)) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
return base64url_encode(signature, sizeof(signature), signature_b64url, signature_b64url_size);
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_identity_build_auth_payload_v3(
|
||||
const esp_openclaw_node_identity_t *identity,
|
||||
const char *client_id,
|
||||
const char *client_mode,
|
||||
const char *role,
|
||||
const char *scopes_csv,
|
||||
int64_t signed_at_ms,
|
||||
const char *token,
|
||||
const char *nonce,
|
||||
const char *platform,
|
||||
const char *device_family,
|
||||
char **out_payload)
|
||||
{
|
||||
if (identity == NULL || client_id == NULL || client_mode == NULL || role == NULL ||
|
||||
scopes_csv == NULL || nonce == NULL || out_payload == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
char *normalized_platform = normalize_metadata(platform);
|
||||
char *normalized_family = normalize_metadata(device_family);
|
||||
if (normalized_platform == NULL || normalized_family == NULL) {
|
||||
free(normalized_platform);
|
||||
free(normalized_family);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
const char *safe_token = token ? token : "";
|
||||
int required = snprintf(
|
||||
NULL,
|
||||
0,
|
||||
ESP_OPENCLAW_NODE_DEVICE_AUTH_PAYLOAD_V3_FORMAT,
|
||||
identity->device_id,
|
||||
client_id,
|
||||
client_mode,
|
||||
role,
|
||||
scopes_csv,
|
||||
signed_at_ms,
|
||||
safe_token,
|
||||
nonce,
|
||||
normalized_platform,
|
||||
normalized_family);
|
||||
if (required < 0) {
|
||||
free(normalized_platform);
|
||||
free(normalized_family);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
char *payload = calloc((size_t)required + 1U, sizeof(char));
|
||||
if (payload == NULL) {
|
||||
free(normalized_platform);
|
||||
free(normalized_family);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
snprintf(
|
||||
payload,
|
||||
(size_t)required + 1U,
|
||||
ESP_OPENCLAW_NODE_DEVICE_AUTH_PAYLOAD_V3_FORMAT,
|
||||
identity->device_id,
|
||||
client_id,
|
||||
client_mode,
|
||||
role,
|
||||
scopes_csv,
|
||||
signed_at_ms,
|
||||
safe_token,
|
||||
nonce,
|
||||
normalized_platform,
|
||||
normalized_family);
|
||||
|
||||
free(normalized_platform);
|
||||
free(normalized_family);
|
||||
*out_payload = payload;
|
||||
return ESP_OK;
|
||||
}
|
||||
@ -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(©, update), TAG, "copy session");
|
||||
}
|
||||
|
||||
esp_err_t err = write_persisted_session_to_storage(update);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_persisted_session_free(©);
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_openclaw_node_persisted_session_free(session);
|
||||
*session = copy;
|
||||
return ESP_OK;
|
||||
}
|
||||
618
components/esp-openclaw-node/src/esp_openclaw_node_protocol.c
Normal file
618
components/esp-openclaw-node/src/esp_openclaw_node_protocol.c
Normal file
@ -0,0 +1,618 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_internal.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
|
||||
static bool websocket_send_json(esp_openclaw_node_handle_t node, cJSON *root)
|
||||
{
|
||||
char *json = cJSON_PrintUnformatted(root);
|
||||
if (json == NULL) {
|
||||
return false;
|
||||
}
|
||||
int written = node->transport_ops->send_text(
|
||||
node->ws,
|
||||
json,
|
||||
(int)strlen(json),
|
||||
pdMS_TO_TICKS(5000));
|
||||
free(json);
|
||||
return written >= 0;
|
||||
}
|
||||
|
||||
static bool send_connect_request(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *nonce,
|
||||
int64_t signed_at_ms)
|
||||
{
|
||||
esp_openclaw_node_connect_material_t material = {0};
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
esp_err_t selection_err =
|
||||
esp_openclaw_node_resolve_active_connect_material_locked(node, &material);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (selection_err != ESP_OK) {
|
||||
ESP_LOGE(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"failed resolving connect material: %s",
|
||||
esp_err_to_name(selection_err));
|
||||
return false;
|
||||
}
|
||||
|
||||
char *payload = NULL;
|
||||
esp_err_t err = esp_openclaw_node_identity_build_auth_payload_v3(
|
||||
&node->identity,
|
||||
node->config.client_id,
|
||||
node->config.client_mode,
|
||||
node->config.role,
|
||||
"",
|
||||
signed_at_ms,
|
||||
material.signature_token,
|
||||
nonce,
|
||||
node->config.platform,
|
||||
node->config.device_family,
|
||||
&payload);
|
||||
if (err != ESP_OK || payload == NULL) {
|
||||
esp_openclaw_node_free_connect_material(&material);
|
||||
ESP_LOGE(ESP_OPENCLAW_NODE_TAG, "failed building auth payload");
|
||||
return false;
|
||||
}
|
||||
|
||||
char signature[ESP_OPENCLAW_NODE_SIGNATURE_B64_BUFFER_LEN] = {0};
|
||||
err = esp_openclaw_node_identity_sign_payload(
|
||||
&node->identity,
|
||||
payload,
|
||||
signature,
|
||||
sizeof(signature));
|
||||
free(payload);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_free_connect_material(&material);
|
||||
ESP_LOGE(ESP_OPENCLAW_NODE_TAG, "failed signing auth payload");
|
||||
return false;
|
||||
}
|
||||
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(root, "type", "req");
|
||||
char request_id[32] = {0};
|
||||
snprintf(
|
||||
request_id,
|
||||
sizeof(request_id),
|
||||
"connect-%" PRIu64,
|
||||
(uint64_t)esp_timer_get_time());
|
||||
cJSON_AddStringToObject(root, "id", request_id);
|
||||
cJSON_AddStringToObject(root, "method", "connect");
|
||||
|
||||
cJSON *params = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(params, "minProtocol", 3);
|
||||
cJSON_AddNumberToObject(params, "maxProtocol", 3);
|
||||
|
||||
cJSON *client = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(client, "id", node->config.client_id);
|
||||
cJSON_AddStringToObject(client, "displayName", node->config.display_name);
|
||||
cJSON_AddStringToObject(
|
||||
client,
|
||||
"version",
|
||||
esp_openclaw_node_firmware_version());
|
||||
cJSON_AddStringToObject(client, "platform", node->config.platform);
|
||||
cJSON_AddStringToObject(
|
||||
client,
|
||||
"deviceFamily",
|
||||
node->config.device_family);
|
||||
cJSON_AddStringToObject(
|
||||
client,
|
||||
"modelIdentifier",
|
||||
node->config.model_identifier);
|
||||
cJSON_AddStringToObject(client, "mode", node->config.client_mode);
|
||||
cJSON_AddItemToObject(params, "client", client);
|
||||
|
||||
cJSON_AddStringToObject(params, "role", node->config.role);
|
||||
cJSON_AddItemToObject(params, "scopes", cJSON_CreateArray());
|
||||
esp_openclaw_node_add_registered_string_array(
|
||||
params,
|
||||
"caps",
|
||||
node->capabilities,
|
||||
node->capability_count);
|
||||
esp_openclaw_node_add_registered_command_array(params, "commands", node);
|
||||
|
||||
if (material.auth_value != NULL) {
|
||||
cJSON *auth_json = cJSON_CreateObject();
|
||||
switch (material.kind) {
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_BOOTSTRAP_TOKEN:
|
||||
cJSON_AddStringToObject(
|
||||
auth_json,
|
||||
"bootstrapToken",
|
||||
material.auth_value);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SHARED_TOKEN:
|
||||
cJSON_AddStringToObject(auth_json, "token", material.auth_value);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_PASSWORD:
|
||||
cJSON_AddStringToObject(
|
||||
auth_json,
|
||||
"password",
|
||||
material.auth_value);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SAVED_SESSION:
|
||||
cJSON_AddStringToObject(
|
||||
auth_json,
|
||||
"deviceToken",
|
||||
material.auth_value);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NO_AUTH:
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NONE:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
cJSON_AddItemToObject(params, "auth", auth_json);
|
||||
}
|
||||
|
||||
char user_agent[64] = {0};
|
||||
snprintf(
|
||||
user_agent,
|
||||
sizeof(user_agent),
|
||||
"esp-openclaw-node/%s",
|
||||
esp_openclaw_node_firmware_version());
|
||||
cJSON_AddStringToObject(params, "userAgent", user_agent);
|
||||
cJSON_AddStringToObject(params, "locale", node->config.locale);
|
||||
|
||||
cJSON *device = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(device, "id", node->identity.device_id);
|
||||
cJSON_AddStringToObject(
|
||||
device,
|
||||
"publicKey",
|
||||
node->identity.public_key_b64url);
|
||||
cJSON_AddStringToObject(device, "signature", signature);
|
||||
cJSON_AddNumberToObject(device, "signedAt", (double)signed_at_ms);
|
||||
cJSON_AddStringToObject(device, "nonce", nonce);
|
||||
cJSON_AddItemToObject(params, "device", device);
|
||||
cJSON_AddItemToObject(root, "params", params);
|
||||
|
||||
bool ready_to_send = false;
|
||||
esp_openclaw_node_internal_state_t state = ESP_OPENCLAW_NODE_INTERNAL_IDLE;
|
||||
esp_openclaw_node_lock_state(node);
|
||||
state = node->state;
|
||||
ready_to_send = node->state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING &&
|
||||
node->pending_connect_id[0] == '\0';
|
||||
if (ready_to_send) {
|
||||
snprintf(
|
||||
node->pending_connect_id,
|
||||
sizeof(node->pending_connect_id),
|
||||
"%s",
|
||||
request_id);
|
||||
}
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (!ready_to_send) {
|
||||
cJSON_Delete(root);
|
||||
esp_openclaw_node_free_connect_material(&material);
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"connect request ignored in state=%s",
|
||||
esp_openclaw_node_internal_state_name(state));
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = websocket_send_json(node, root);
|
||||
cJSON_Delete(root);
|
||||
if (!ok) {
|
||||
esp_openclaw_node_lock_state(node);
|
||||
node->pending_connect_id[0] = '\0';
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
esp_openclaw_node_free_connect_material(&material);
|
||||
return false;
|
||||
}
|
||||
|
||||
ESP_LOGI(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"sent connect request using %s auth",
|
||||
esp_openclaw_node_connect_source_kind_name(material.kind));
|
||||
esp_openclaw_node_free_connect_material(&material);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void send_invoke_result(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *request_id,
|
||||
const char *node_id,
|
||||
bool ok,
|
||||
const char *payload_json,
|
||||
const char *error_code,
|
||||
const char *error_message)
|
||||
{
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(root, "type", "req");
|
||||
|
||||
char ws_request_id[32] = {0};
|
||||
snprintf(
|
||||
ws_request_id,
|
||||
sizeof(ws_request_id),
|
||||
"esp32-%" PRIu64,
|
||||
(uint64_t)esp_timer_get_time());
|
||||
cJSON_AddStringToObject(root, "id", ws_request_id);
|
||||
cJSON_AddStringToObject(root, "method", "node.invoke.result");
|
||||
|
||||
cJSON *params = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(params, "id", request_id);
|
||||
cJSON_AddStringToObject(params, "nodeId", node_id);
|
||||
cJSON_AddBoolToObject(params, "ok", ok);
|
||||
|
||||
if (ok) {
|
||||
if (payload_json != NULL && payload_json[0] != '\0') {
|
||||
cJSON_AddStringToObject(params, "payloadJSON", payload_json);
|
||||
}
|
||||
} else {
|
||||
cJSON *error = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(
|
||||
error,
|
||||
"code",
|
||||
error_code ? error_code : "INVALID_REQUEST");
|
||||
cJSON_AddStringToObject(
|
||||
error,
|
||||
"message",
|
||||
error_message ? error_message : "command failed");
|
||||
cJSON_AddItemToObject(params, "error", error);
|
||||
}
|
||||
|
||||
cJSON_AddItemToObject(root, "params", params);
|
||||
if (!websocket_send_json(node, root)) {
|
||||
ESP_LOGW(ESP_OPENCLAW_NODE_TAG, "failed sending invoke result");
|
||||
}
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
|
||||
static esp_err_t build_connect_response_session_update(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *device_token_text,
|
||||
esp_openclaw_node_persisted_session_t *update)
|
||||
{
|
||||
if (node == NULL || device_token_text == NULL || update == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
memset(update, 0, sizeof(*update));
|
||||
update->version = 1;
|
||||
update->gateway_uri = esp_openclaw_node_duplicate_string(
|
||||
esp_openclaw_node_trimmed_or_null(node->transport_gateway_uri));
|
||||
update->device_token =
|
||||
esp_openclaw_node_duplicate_string(device_token_text);
|
||||
if (update->gateway_uri == NULL || update->device_token == NULL) {
|
||||
esp_openclaw_node_persisted_session_free(update);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static connect_response_finalize_result_t finalize_connect_response_success(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_persisted_session_t *update)
|
||||
{
|
||||
connect_response_finalize_result_t result = {
|
||||
.outcome = CONNECT_RESPONSE_OUTCOME_IGNORE,
|
||||
.err = ESP_OK,
|
||||
.state = ESP_OPENCLAW_NODE_INTERNAL_IDLE,
|
||||
};
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
result.state = node->state;
|
||||
if (node->state != ESP_OPENCLAW_NODE_INTERNAL_CONNECTING ||
|
||||
node->pending_connect_id[0] == '\0') {
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.err = esp_openclaw_node_persisted_session_store(
|
||||
&node->persisted_session,
|
||||
update);
|
||||
if (result.err != ESP_OK) {
|
||||
result.outcome = CONNECT_RESPONSE_OUTCOME_CONNECT_FAILED;
|
||||
} else {
|
||||
node->state = ESP_OPENCLAW_NODE_INTERNAL_READY;
|
||||
esp_openclaw_node_clear_pending_control_locked(node);
|
||||
esp_openclaw_node_clear_session_wait_state_locked(node);
|
||||
esp_openclaw_node_clear_connect_source_struct(&node->active_connect_source);
|
||||
result.outcome = CONNECT_RESPONSE_OUTCOME_CONNECTED;
|
||||
}
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
return result;
|
||||
}
|
||||
|
||||
static void complete_connect_response_outcome(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const connect_response_finalize_result_t *result)
|
||||
{
|
||||
switch (result->outcome) {
|
||||
case CONNECT_RESPONSE_OUTCOME_IGNORE:
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"ignoring connect response in state=%s",
|
||||
esp_openclaw_node_internal_state_name(result->state));
|
||||
break;
|
||||
case CONNECT_RESPONSE_OUTCOME_CONNECT_FAILED:
|
||||
esp_openclaw_node_complete_connect_failed(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_SESSION_FINALIZATION_FAILED,
|
||||
result->err,
|
||||
NULL,
|
||||
true);
|
||||
break;
|
||||
case CONNECT_RESPONSE_OUTCOME_CONNECTED:
|
||||
esp_openclaw_node_emit_connected(node);
|
||||
ESP_LOGI(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"OpenClaw gateway handshake complete");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_connect_response(
|
||||
esp_openclaw_node_handle_t node,
|
||||
cJSON *root)
|
||||
{
|
||||
cJSON *ok = cJSON_GetObjectItemCaseSensitive(root, "ok");
|
||||
if (!cJSON_IsBool(ok)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cJSON_IsTrue(ok)) {
|
||||
cJSON *payload = cJSON_GetObjectItemCaseSensitive(root, "payload");
|
||||
cJSON *type =
|
||||
payload ? cJSON_GetObjectItemCaseSensitive(payload, "type") : NULL;
|
||||
if (!cJSON_IsString(type) ||
|
||||
strcmp(type->valuestring, "hello-ok") != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON *auth = cJSON_GetObjectItemCaseSensitive(payload, "auth");
|
||||
cJSON *device_token = auth
|
||||
? cJSON_GetObjectItemCaseSensitive(auth, "deviceToken")
|
||||
: NULL;
|
||||
const char *device_token_text = cJSON_IsString(device_token)
|
||||
? esp_openclaw_node_trimmed_or_null(device_token->valuestring)
|
||||
: NULL;
|
||||
if (device_token_text == NULL) {
|
||||
esp_openclaw_node_complete_connect_failed(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_SESSION_FINALIZATION_FAILED,
|
||||
ESP_FAIL,
|
||||
NULL,
|
||||
true);
|
||||
return;
|
||||
}
|
||||
|
||||
esp_openclaw_node_persisted_session_t update = {0};
|
||||
esp_err_t err = build_connect_response_session_update(
|
||||
node,
|
||||
device_token_text,
|
||||
&update);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_complete_connect_failed(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_SESSION_FINALIZATION_FAILED,
|
||||
err,
|
||||
NULL,
|
||||
true);
|
||||
return;
|
||||
}
|
||||
|
||||
connect_response_finalize_result_t result =
|
||||
finalize_connect_response_success(node, &update);
|
||||
esp_openclaw_node_persisted_session_free(&update);
|
||||
complete_connect_response_outcome(node, &result);
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON *error = cJSON_GetObjectItemCaseSensitive(root, "error");
|
||||
cJSON *message =
|
||||
error ? cJSON_GetObjectItemCaseSensitive(error, "message") : NULL;
|
||||
cJSON *details =
|
||||
error ? cJSON_GetObjectItemCaseSensitive(error, "details") : NULL;
|
||||
cJSON *detail_code =
|
||||
details ? cJSON_GetObjectItemCaseSensitive(details, "code") : NULL;
|
||||
cJSON *request_id =
|
||||
details ? cJSON_GetObjectItemCaseSensitive(details, "requestId") : NULL;
|
||||
const char *message_text = cJSON_IsString(message) &&
|
||||
message->valuestring != NULL
|
||||
? message->valuestring
|
||||
: "connect failed";
|
||||
const char *detail_code_text = cJSON_IsString(detail_code) &&
|
||||
detail_code->valuestring != NULL
|
||||
? detail_code->valuestring
|
||||
: NULL;
|
||||
const char *request_id_text = cJSON_IsString(request_id) &&
|
||||
request_id->valuestring != NULL
|
||||
? request_id->valuestring
|
||||
: NULL;
|
||||
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"connect rejected: %s%s%s%s%s%s",
|
||||
message_text,
|
||||
detail_code_text != NULL ? " (" : "",
|
||||
detail_code_text != NULL ? detail_code_text : "",
|
||||
detail_code_text != NULL ? ")" : "",
|
||||
request_id_text != NULL ? ", requestId=" : "",
|
||||
request_id_text != NULL ? request_id_text : "");
|
||||
|
||||
esp_openclaw_node_complete_connect_failed(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_AUTH_REJECTED,
|
||||
ESP_OK,
|
||||
detail_code_text,
|
||||
true);
|
||||
}
|
||||
|
||||
static void handle_connect_challenge(
|
||||
esp_openclaw_node_handle_t node,
|
||||
cJSON *payload)
|
||||
{
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool accept_challenge =
|
||||
node->state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING &&
|
||||
node->pending_connect_id[0] == '\0';
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (!accept_challenge) {
|
||||
ESP_LOGW(ESP_OPENCLAW_NODE_TAG, "ignoring unexpected connect.challenge");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON *nonce = cJSON_GetObjectItemCaseSensitive(payload, "nonce");
|
||||
cJSON *ts = cJSON_GetObjectItemCaseSensitive(payload, "ts");
|
||||
if (!cJSON_IsString(nonce) || nonce->valuestring == NULL ||
|
||||
nonce->valuestring[0] == '\0') {
|
||||
ESP_LOGW(ESP_OPENCLAW_NODE_TAG, "connect.challenge missing nonce");
|
||||
return;
|
||||
}
|
||||
int64_t signed_at_ms = cJSON_IsNumber(ts) ? (int64_t)ts->valuedouble : 0;
|
||||
if (signed_at_ms <= 0) {
|
||||
signed_at_ms = esp_timer_get_time() / 1000LL;
|
||||
}
|
||||
if (!send_connect_request(node, nonce->valuestring, signed_at_ms)) {
|
||||
esp_openclaw_node_complete_connect_failed(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILED,
|
||||
ESP_FAIL,
|
||||
NULL,
|
||||
true);
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_invoke_request(
|
||||
esp_openclaw_node_handle_t node,
|
||||
cJSON *payload)
|
||||
{
|
||||
cJSON *id = cJSON_GetObjectItemCaseSensitive(payload, "id");
|
||||
cJSON *node_id = cJSON_GetObjectItemCaseSensitive(payload, "nodeId");
|
||||
cJSON *command = cJSON_GetObjectItemCaseSensitive(payload, "command");
|
||||
cJSON *params_json =
|
||||
cJSON_GetObjectItemCaseSensitive(payload, "paramsJSON");
|
||||
|
||||
if (!cJSON_IsString(id) || !cJSON_IsString(node_id) ||
|
||||
!cJSON_IsString(command) || id->valuestring == NULL ||
|
||||
node_id->valuestring == NULL || command->valuestring == NULL) {
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"dropping malformed node.invoke.request");
|
||||
return;
|
||||
}
|
||||
|
||||
const char *effective_params_json = "{}";
|
||||
size_t effective_params_len = 2;
|
||||
if (cJSON_IsString(params_json) && params_json->valuestring != NULL &&
|
||||
params_json->valuestring[0] != '\0') {
|
||||
effective_params_json = params_json->valuestring;
|
||||
effective_params_len = strlen(effective_params_json);
|
||||
}
|
||||
|
||||
char *result_json = NULL;
|
||||
const char *error_code = NULL;
|
||||
const char *error_message = NULL;
|
||||
esp_err_t err = esp_openclaw_node_dispatch_command(
|
||||
node,
|
||||
command->valuestring,
|
||||
effective_params_json,
|
||||
effective_params_len,
|
||||
&result_json,
|
||||
&error_code,
|
||||
&error_message);
|
||||
if (err == ESP_OK) {
|
||||
send_invoke_result(
|
||||
node,
|
||||
id->valuestring,
|
||||
node_id->valuestring,
|
||||
true,
|
||||
result_json,
|
||||
NULL,
|
||||
NULL);
|
||||
} else {
|
||||
send_invoke_result(
|
||||
node,
|
||||
id->valuestring,
|
||||
node_id->valuestring,
|
||||
false,
|
||||
NULL,
|
||||
error_code,
|
||||
error_message);
|
||||
}
|
||||
|
||||
free(result_json);
|
||||
}
|
||||
|
||||
void esp_openclaw_node_process_gateway_message(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *text)
|
||||
{
|
||||
cJSON *root = cJSON_Parse(text);
|
||||
if (root == NULL) {
|
||||
ESP_LOGW(ESP_OPENCLAW_NODE_TAG, "invalid gateway JSON frame");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON *type = cJSON_GetObjectItemCaseSensitive(root, "type");
|
||||
if (!cJSON_IsString(type) || type->valuestring == NULL) {
|
||||
cJSON_Delete(root);
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(type->valuestring, "event") == 0) {
|
||||
cJSON *event = cJSON_GetObjectItemCaseSensitive(root, "event");
|
||||
cJSON *payload = cJSON_GetObjectItemCaseSensitive(root, "payload");
|
||||
if (cJSON_IsString(event) && event->valuestring != NULL) {
|
||||
if (strcmp(event->valuestring, "connect.challenge") == 0 &&
|
||||
cJSON_IsObject(payload)) {
|
||||
ESP_LOGI(ESP_OPENCLAW_NODE_TAG, "received connect.challenge");
|
||||
handle_connect_challenge(node, payload);
|
||||
} else if (
|
||||
strcmp(event->valuestring, "node.invoke.request") == 0 &&
|
||||
cJSON_IsObject(payload)) {
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool ready = node->state == ESP_OPENCLAW_NODE_INTERNAL_READY;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (ready) {
|
||||
handle_invoke_request(node, payload);
|
||||
} else {
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"ignoring node.invoke.request before session is ready");
|
||||
}
|
||||
} else {
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool connecting =
|
||||
esp_openclaw_node_state_is_connecting(node->state);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (connecting) {
|
||||
ESP_LOGD(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"received gateway event during connect: %s",
|
||||
event->valuestring);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (strcmp(type->valuestring, "res") == 0) {
|
||||
cJSON *id = cJSON_GetObjectItemCaseSensitive(root, "id");
|
||||
bool is_pending_connect_response = false;
|
||||
if (cJSON_IsString(id) && id->valuestring != NULL) {
|
||||
esp_openclaw_node_lock_state(node);
|
||||
is_pending_connect_response =
|
||||
node->transport_connected &&
|
||||
node->state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING &&
|
||||
node->pending_connect_id[0] != '\0' &&
|
||||
strcmp(id->valuestring, node->pending_connect_id) == 0;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
}
|
||||
if (is_pending_connect_response) {
|
||||
handle_connect_response(node, root);
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
173
components/esp-openclaw-node/src/esp_openclaw_node_registry.c
Normal file
173
components/esp-openclaw-node/src/esp_openclaw_node_registry.c
Normal 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;
|
||||
}
|
||||
460
components/esp-openclaw-node/src/esp_openclaw_node_runtime.c
Normal file
460
components/esp-openclaw-node/src/esp_openclaw_node_runtime.c
Normal file
@ -0,0 +1,460 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_internal.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
|
||||
void esp_openclaw_node_complete_connect_failed(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_connect_failure_reason_t reason,
|
||||
esp_err_t local_err,
|
||||
const char *gateway_detail_code,
|
||||
bool stop_client)
|
||||
{
|
||||
esp_openclaw_node_cleanup_transport_instance(node, stop_client);
|
||||
esp_openclaw_node_lock_state(node);
|
||||
node->state = ESP_OPENCLAW_NODE_INTERNAL_IDLE;
|
||||
esp_openclaw_node_clear_pending_control_locked(node);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
esp_openclaw_node_emit_connect_failed(
|
||||
node,
|
||||
reason,
|
||||
local_err,
|
||||
gateway_detail_code);
|
||||
}
|
||||
|
||||
void esp_openclaw_node_complete_disconnected(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_disconnected_reason_t reason,
|
||||
esp_err_t local_err,
|
||||
bool stop_client)
|
||||
{
|
||||
esp_openclaw_node_cleanup_transport_instance(node, stop_client);
|
||||
esp_openclaw_node_lock_state(node);
|
||||
node->state = ESP_OPENCLAW_NODE_INTERNAL_IDLE;
|
||||
esp_openclaw_node_clear_pending_control_locked(node);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
esp_openclaw_node_emit_disconnected(node, reason, local_err);
|
||||
}
|
||||
|
||||
void esp_openclaw_node_fail_if_connect_timed_out(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool connecting = node->state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING;
|
||||
int64_t connect_started_ms = node->connect_started_ms;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
if (!connecting || connect_started_ms <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t now_ms = esp_timer_get_time() / 1000LL;
|
||||
int64_t waited_ms = now_ms - connect_started_ms;
|
||||
if (waited_ms < ESP_OPENCLAW_NODE_CONNECT_TIMEOUT_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"timed out waiting for connect completion after %" PRId64 " ms",
|
||||
waited_ms);
|
||||
esp_openclaw_node_complete_connect_failed(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILED,
|
||||
ESP_ERR_TIMEOUT,
|
||||
NULL,
|
||||
true);
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_enqueue_work_message(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_work_message_t *message)
|
||||
{
|
||||
if (node->work_queue == NULL) {
|
||||
esp_openclaw_node_free_work_message_payload(message);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
if (xQueueSend(node->work_queue, message, 0) != pdTRUE) {
|
||||
esp_openclaw_node_free_work_message_payload(message);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_enqueue_work_message_from_callback(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_work_message_t *message)
|
||||
{
|
||||
bool accept = false;
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
accept = esp_openclaw_node_should_accept_callback_generation_locked(
|
||||
node,
|
||||
message->generation);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (!accept) {
|
||||
esp_openclaw_node_free_work_message_payload(message);
|
||||
return;
|
||||
}
|
||||
if (esp_openclaw_node_enqueue_work_message(node, message) != ESP_OK) {
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"dropping websocket work item due to full queue");
|
||||
}
|
||||
}
|
||||
|
||||
static void complete_transport_event_state_after_disconnect_locked(
|
||||
esp_openclaw_node_handle_t node)
|
||||
{
|
||||
esp_openclaw_node_clear_session_wait_state_locked(node);
|
||||
node->transport_connected = false;
|
||||
node->ws_started = false;
|
||||
}
|
||||
|
||||
static void handle_request_connect(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_work_message_t *message)
|
||||
{
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool ready_to_start =
|
||||
node->state == ESP_OPENCLAW_NODE_INTERNAL_IDLE &&
|
||||
node->pending_control == ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_CONNECT;
|
||||
if (!ready_to_start) {
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
return;
|
||||
}
|
||||
esp_openclaw_node_clear_pending_control_locked(node);
|
||||
esp_openclaw_node_clear_connect_source_struct(&node->active_connect_source);
|
||||
node->active_connect_source = message->connect_source;
|
||||
memset(&message->connect_source, 0, sizeof(message->connect_source));
|
||||
node->state = ESP_OPENCLAW_NODE_INTERNAL_CONNECTING;
|
||||
node->connect_started_ms = esp_timer_get_time() / 1000LL;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
esp_err_t err = esp_openclaw_node_start_transport_for_active_source(node);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_complete_connect_failed(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILED,
|
||||
err,
|
||||
NULL,
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_request_disconnect(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool request_pending =
|
||||
node->pending_control == ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_DISCONNECT;
|
||||
bool has_transport =
|
||||
node->ws != NULL || node->active_transport_generation != 0;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
if (!request_pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
esp_openclaw_node_complete_disconnected(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_DISCONNECTED_REASON_REQUESTED,
|
||||
ESP_OK,
|
||||
has_transport);
|
||||
}
|
||||
|
||||
static void handle_ws_connected(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_work_message_t *message)
|
||||
{
|
||||
bool kick_challenge = false;
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool current_generation =
|
||||
node->active_transport_generation == message->generation;
|
||||
esp_openclaw_node_internal_state_t state = node->state;
|
||||
if (current_generation) {
|
||||
node->transport_connected = true;
|
||||
}
|
||||
if (current_generation &&
|
||||
node->state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING) {
|
||||
kick_challenge = true;
|
||||
}
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
if (!current_generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (kick_challenge) {
|
||||
esp_openclaw_node_send_challenge_kick_ping(node);
|
||||
}
|
||||
|
||||
ESP_LOGI(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"websocket connected: gen=%" PRIu32 " state=%s",
|
||||
message->generation,
|
||||
esp_openclaw_node_internal_state_name(state));
|
||||
}
|
||||
|
||||
static void handle_ws_disconnected(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_work_message_t *message)
|
||||
{
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool current_generation =
|
||||
node->active_transport_generation == message->generation;
|
||||
esp_openclaw_node_internal_state_t state = node->state;
|
||||
if (current_generation) {
|
||||
complete_transport_event_state_after_disconnect_locked(node);
|
||||
}
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
if (!current_generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"websocket disconnected: gen=%" PRIu32 " state=%s err=%s",
|
||||
message->generation,
|
||||
esp_openclaw_node_internal_state_name(state),
|
||||
esp_err_to_name(message->local_err));
|
||||
|
||||
if (state == ESP_OPENCLAW_NODE_INTERNAL_READY) {
|
||||
esp_openclaw_node_complete_disconnected(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_DISCONNECTED_REASON_CONNECTION_LOST,
|
||||
message->local_err,
|
||||
false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (esp_openclaw_node_state_is_connecting(state)) {
|
||||
esp_openclaw_node_complete_connect_failed(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_CONNECTION_LOST,
|
||||
message->local_err,
|
||||
NULL,
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_ws_error(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_work_message_t *message)
|
||||
{
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool current_generation =
|
||||
node->active_transport_generation == message->generation;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (!current_generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"websocket error: gen=%" PRIu32 " state=%s err=%s",
|
||||
message->generation,
|
||||
esp_openclaw_node_internal_state_name(node->state),
|
||||
esp_err_to_name(message->local_err));
|
||||
}
|
||||
|
||||
static void handle_shutdown_request(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
esp_openclaw_node_cleanup_transport_instance(node, true);
|
||||
esp_openclaw_node_drain_work_queue(node);
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
esp_openclaw_node_clear_data_buffer_locked(node);
|
||||
esp_openclaw_node_clear_pending_control_locked(node);
|
||||
node->state = ESP_OPENCLAW_NODE_INTERNAL_CLOSED;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
}
|
||||
|
||||
static void exit_node_task(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
SemaphoreHandle_t destroy_done = NULL;
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
node->task_handle = NULL;
|
||||
destroy_done = node->destroy_done;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
if (destroy_done != NULL) {
|
||||
xSemaphoreGive(destroy_done);
|
||||
}
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
static bool process_work_message(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_work_message_t *message)
|
||||
{
|
||||
switch (message->type) {
|
||||
case ESP_OPENCLAW_NODE_WORK_MSG_REQUEST_CONNECT:
|
||||
handle_request_connect(node, message);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_WORK_MSG_REQUEST_DISCONNECT:
|
||||
handle_request_disconnect(node);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_WORK_MSG_WS_CONNECTED:
|
||||
handle_ws_connected(node, message);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_WORK_MSG_WS_DISCONNECTED:
|
||||
handle_ws_disconnected(node, message);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_WORK_MSG_WS_ERROR:
|
||||
handle_ws_error(node, message);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_WORK_MSG_DATA: {
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool current_generation =
|
||||
node->active_transport_generation == message->generation &&
|
||||
node->state != ESP_OPENCLAW_NODE_INTERNAL_DESTROYING &&
|
||||
node->state != ESP_OPENCLAW_NODE_INTERNAL_CLOSED;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (current_generation && message->text != NULL) {
|
||||
esp_openclaw_node_process_gateway_message(node, message->text);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_OPENCLAW_NODE_WORK_MSG_SHUTDOWN:
|
||||
handle_shutdown_request(node);
|
||||
return false;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_task(void *arg)
|
||||
{
|
||||
esp_openclaw_node_handle_t node = (esp_openclaw_node_handle_t)arg;
|
||||
|
||||
for (;;) {
|
||||
esp_openclaw_node_work_message_t message = {0};
|
||||
if (xQueueReceive(node->work_queue, &message, ESP_OPENCLAW_NODE_TASK_POLL_TICKS) != pdTRUE) {
|
||||
esp_openclaw_node_fail_if_connect_timed_out(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
do {
|
||||
bool keep_running = process_work_message(node, &message);
|
||||
esp_openclaw_node_free_work_message_payload(&message);
|
||||
if (!keep_running) {
|
||||
exit_node_task(node);
|
||||
return;
|
||||
}
|
||||
memset(&message, 0, sizeof(message));
|
||||
} while (xQueueReceive(node->work_queue, &message, 0) == pdTRUE);
|
||||
}
|
||||
}
|
||||
|
||||
static void rollback_pending_control_locked(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_pending_control_request_t expected_request,
|
||||
esp_openclaw_node_pending_control_request_t rollback_op)
|
||||
{
|
||||
if (node->pending_control == expected_request) {
|
||||
node->pending_control = rollback_op;
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t submit_pending_request(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_work_message_t *message,
|
||||
esp_openclaw_node_pending_control_request_t expected_request,
|
||||
esp_openclaw_node_pending_control_request_t rollback_op)
|
||||
{
|
||||
esp_err_t err = esp_openclaw_node_enqueue_work_message(node, message);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_lock_state(node);
|
||||
rollback_pending_control_locked(
|
||||
node,
|
||||
expected_request,
|
||||
rollback_op);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_submit_connect_request(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_connect_request_source_t *connect_source)
|
||||
{
|
||||
esp_openclaw_node_lock_state(node);
|
||||
esp_err_t err =
|
||||
esp_openclaw_node_reserve_connect_request_locked(node, connect_source);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_openclaw_node_work_message_t message = {
|
||||
.type = ESP_OPENCLAW_NODE_WORK_MSG_REQUEST_CONNECT,
|
||||
.connect_source = *connect_source,
|
||||
};
|
||||
memset(connect_source, 0, sizeof(*connect_source));
|
||||
|
||||
err = submit_pending_request(
|
||||
node,
|
||||
&message,
|
||||
ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_CONNECT,
|
||||
ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_NONE);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t reserve_disconnect_request_locked(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
if (node->state == ESP_OPENCLAW_NODE_INTERNAL_DESTROYING ||
|
||||
node->state == ESP_OPENCLAW_NODE_INTERNAL_CLOSED) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
if (node->state != ESP_OPENCLAW_NODE_INTERNAL_READY) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (node->pending_control != ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_NONE) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
esp_openclaw_node_set_pending_control_locked(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_DISCONNECT);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_submit_disconnect_request(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
esp_openclaw_node_lock_state(node);
|
||||
esp_err_t err = reserve_disconnect_request_locked(node);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_openclaw_node_work_message_t message = {
|
||||
.type = ESP_OPENCLAW_NODE_WORK_MSG_REQUEST_DISCONNECT,
|
||||
};
|
||||
err = submit_pending_request(
|
||||
node,
|
||||
&message,
|
||||
ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_DISCONNECT,
|
||||
ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_NONE);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
397
components/esp-openclaw-node/src/esp_openclaw_node_transport.c
Normal file
397
components/esp-openclaw-node/src/esp_openclaw_node_transport.c
Normal file
@ -0,0 +1,397 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_internal.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE
|
||||
#include "esp_crt_bundle.h"
|
||||
#endif
|
||||
#include "esp_check.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
static void websocket_event_handler(
|
||||
void *handler_args,
|
||||
esp_event_base_t base,
|
||||
int32_t event_id,
|
||||
void *event_data);
|
||||
|
||||
bool esp_openclaw_node_should_accept_callback_generation_locked(
|
||||
esp_openclaw_node_handle_t node,
|
||||
uint32_t generation)
|
||||
{
|
||||
return node->state != ESP_OPENCLAW_NODE_INTERNAL_DESTROYING &&
|
||||
node->state != ESP_OPENCLAW_NODE_INTERNAL_CLOSED &&
|
||||
node->active_transport_generation == generation &&
|
||||
generation != 0;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_validate_tls_preflight(
|
||||
const esp_openclaw_node_config_t *config,
|
||||
const char *gateway_uri)
|
||||
{
|
||||
if (gateway_uri != NULL && strncmp(gateway_uri, "wss://", 6) == 0) {
|
||||
if (config->tls_cert_pem == NULL && !config->use_cert_bundle) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void clear_active_transport_fields_locked(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
node->ws = NULL;
|
||||
node->transport_connected = false;
|
||||
node->ws_started = false;
|
||||
node->active_transport_generation = 0;
|
||||
esp_openclaw_node_clear_session_wait_state_locked(node);
|
||||
esp_openclaw_node_clear_connect_source_struct(&node->active_connect_source);
|
||||
}
|
||||
|
||||
void esp_openclaw_node_cleanup_transport_instance(
|
||||
esp_openclaw_node_handle_t node,
|
||||
bool stop_client)
|
||||
{
|
||||
esp_websocket_client_handle_t ws = NULL;
|
||||
esp_openclaw_node_transport_event_ctx_t *transport_ctx = NULL;
|
||||
char *gateway_uri = NULL;
|
||||
bool ws_started = false;
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
ws = node->ws;
|
||||
transport_ctx = node->transport_ctx;
|
||||
gateway_uri = node->transport_gateway_uri;
|
||||
ws_started = node->ws_started;
|
||||
node->transport_ctx = NULL;
|
||||
node->transport_gateway_uri = NULL;
|
||||
clear_active_transport_fields_locked(node);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
if (ws != NULL) {
|
||||
if (stop_client && ws_started) {
|
||||
node->transport_ops->client_stop(ws);
|
||||
}
|
||||
node->transport_ops->client_destroy(ws);
|
||||
}
|
||||
|
||||
free(transport_ctx);
|
||||
free(gateway_uri);
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
esp_openclaw_node_clear_data_buffer_locked(node);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_start_transport_for_active_source(
|
||||
esp_openclaw_node_handle_t node)
|
||||
{
|
||||
const char *gateway_uri = NULL;
|
||||
switch (node->active_connect_source.kind) {
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SAVED_SESSION:
|
||||
gateway_uri =
|
||||
esp_openclaw_node_trimmed_or_null(node->persisted_session.gateway_uri);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_BOOTSTRAP_TOKEN:
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SHARED_TOKEN:
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_PASSWORD:
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NO_AUTH:
|
||||
gateway_uri =
|
||||
esp_openclaw_node_trimmed_or_null(node->active_connect_source.gateway_uri);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NONE:
|
||||
default:
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
if (gateway_uri == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_validate_tls_preflight(&node->config, gateway_uri),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"TLS preflight failed");
|
||||
|
||||
char *gateway_uri_copy = esp_openclaw_node_duplicate_string(gateway_uri);
|
||||
if (gateway_uri_copy == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
esp_openclaw_node_transport_event_ctx_t *transport_ctx =
|
||||
calloc(1, sizeof(*transport_ctx));
|
||||
if (transport_ctx == NULL) {
|
||||
free(gateway_uri_copy);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
esp_websocket_client_config_t ws_config = {
|
||||
.uri = gateway_uri_copy,
|
||||
.disable_auto_reconnect = true,
|
||||
.enable_close_reconnect = false,
|
||||
.network_timeout_ms = 10000,
|
||||
.ping_interval_sec = ESP_OPENCLAW_NODE_WS_PING_INTERVAL_SEC,
|
||||
.pingpong_timeout_sec = ESP_OPENCLAW_NODE_WS_PINGPONG_TIMEOUT_SEC,
|
||||
.keep_alive_enable = true,
|
||||
.keep_alive_idle = 5,
|
||||
.keep_alive_interval = 5,
|
||||
.keep_alive_count = 3,
|
||||
.task_prio = 5,
|
||||
.task_stack = ESP_OPENCLAW_NODE_TRANSPORT_TASK_STACK_SIZE,
|
||||
.buffer_size = ESP_OPENCLAW_NODE_TRANSPORT_BUFFER_SIZE,
|
||||
.user_context = transport_ctx,
|
||||
};
|
||||
if (strncmp(gateway_uri_copy, "wss://", 6) == 0) {
|
||||
if (node->config.tls_cert_pem != NULL) {
|
||||
ws_config.cert_pem = node->config.tls_cert_pem;
|
||||
ws_config.cert_len = node->config.tls_cert_len;
|
||||
} else if (node->config.use_cert_bundle) {
|
||||
#if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE
|
||||
ws_config.crt_bundle_attach = esp_crt_bundle_attach;
|
||||
#else
|
||||
free(gateway_uri_copy);
|
||||
free(transport_ctx);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
#endif
|
||||
}
|
||||
if (node->config.tls_common_name != NULL &&
|
||||
node->config.tls_common_name[0] != '\0') {
|
||||
ws_config.cert_common_name = node->config.tls_common_name;
|
||||
}
|
||||
ws_config.skip_cert_common_name_check =
|
||||
node->config.skip_cert_common_name_check;
|
||||
}
|
||||
|
||||
esp_websocket_client_handle_t ws = node->transport_ops->client_init(&ws_config);
|
||||
if (ws == NULL) {
|
||||
free(gateway_uri_copy);
|
||||
free(transport_ctx);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
uint32_t generation = 0;
|
||||
esp_openclaw_node_lock_state(node);
|
||||
generation = ++node->next_transport_generation;
|
||||
transport_ctx->node = node;
|
||||
transport_ctx->generation = generation;
|
||||
node->transport_ctx = transport_ctx;
|
||||
node->transport_gateway_uri = gateway_uri_copy;
|
||||
node->active_transport_generation = generation;
|
||||
node->ws = ws;
|
||||
node->ws_started = false;
|
||||
node->transport_connected = false;
|
||||
esp_openclaw_node_clear_session_wait_state_locked(node);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
esp_err_t err = node->transport_ops->register_events(
|
||||
ws,
|
||||
WEBSOCKET_EVENT_ANY,
|
||||
websocket_event_handler,
|
||||
transport_ctx);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_cleanup_transport_instance(node, false);
|
||||
return err;
|
||||
}
|
||||
|
||||
err = node->transport_ops->client_start(ws);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_cleanup_transport_instance(node, false);
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
node->ws_started = true;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
ESP_LOGI(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"node connect attempt started for %s",
|
||||
gateway_uri_copy);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_send_challenge_kick_ping(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
esp_websocket_client_handle_t ws = NULL;
|
||||
|
||||
/*
|
||||
* Some ESP-IDF websocket runtimes do not drain the first post-upgrade text
|
||||
* frame until the client transmits something. A zero-length ping forces the
|
||||
* read loop to wake and flush the already-buffered connect.challenge frame.
|
||||
*/
|
||||
esp_openclaw_node_lock_state(node);
|
||||
ws = node->ws;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
if (ws == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
int written = node->transport_ops->send_with_opcode(
|
||||
ws,
|
||||
WS_TRANSPORT_OPCODES_PING,
|
||||
NULL,
|
||||
0,
|
||||
pdMS_TO_TICKS(1000));
|
||||
if (written < 0) {
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"failed sending websocket ping to prompt connect.challenge delivery");
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t local_err_from_ws_event(
|
||||
const esp_websocket_event_data_t *data)
|
||||
{
|
||||
if (data == NULL) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (data->error_handle.esp_tls_last_esp_err != 0) {
|
||||
return data->error_handle.esp_tls_last_esp_err;
|
||||
}
|
||||
if (data->error_handle.esp_transport_sock_errno != 0) {
|
||||
return data->error_handle.esp_transport_sock_errno;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void websocket_event_handler(
|
||||
void *handler_args,
|
||||
esp_event_base_t base,
|
||||
int32_t event_id,
|
||||
void *event_data)
|
||||
{
|
||||
(void)base;
|
||||
|
||||
esp_openclaw_node_transport_event_ctx_t *transport_ctx =
|
||||
(esp_openclaw_node_transport_event_ctx_t *)handler_args;
|
||||
if (transport_ctx == NULL || transport_ctx->node == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
esp_openclaw_node_handle_t node = transport_ctx->node;
|
||||
esp_websocket_event_data_t *data =
|
||||
(esp_websocket_event_data_t *)event_data;
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool accept = esp_openclaw_node_should_accept_callback_generation_locked(
|
||||
node,
|
||||
transport_ctx->generation);
|
||||
esp_openclaw_node_internal_state_t state = node->state;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (!accept) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event_id) {
|
||||
case WEBSOCKET_EVENT_CONNECTED: {
|
||||
esp_openclaw_node_work_message_t message = {
|
||||
.type = ESP_OPENCLAW_NODE_WORK_MSG_WS_CONNECTED,
|
||||
.generation = transport_ctx->generation,
|
||||
.local_err = ESP_OK,
|
||||
};
|
||||
esp_openclaw_node_enqueue_work_message_from_callback(node, &message);
|
||||
break;
|
||||
}
|
||||
case WEBSOCKET_EVENT_DISCONNECTED:
|
||||
case WEBSOCKET_EVENT_CLOSED: {
|
||||
esp_openclaw_node_work_message_t message = {
|
||||
.type = ESP_OPENCLAW_NODE_WORK_MSG_WS_DISCONNECTED,
|
||||
.generation = transport_ctx->generation,
|
||||
.local_err = local_err_from_ws_event(data),
|
||||
};
|
||||
esp_openclaw_node_enqueue_work_message_from_callback(node, &message);
|
||||
break;
|
||||
}
|
||||
case WEBSOCKET_EVENT_ERROR: {
|
||||
esp_openclaw_node_work_message_t message = {
|
||||
.type = ESP_OPENCLAW_NODE_WORK_MSG_WS_ERROR,
|
||||
.generation = transport_ctx->generation,
|
||||
.local_err = local_err_from_ws_event(data),
|
||||
};
|
||||
esp_openclaw_node_enqueue_work_message_from_callback(node, &message);
|
||||
break;
|
||||
}
|
||||
case WEBSOCKET_EVENT_DATA:
|
||||
if (esp_openclaw_node_state_is_connecting(state)) {
|
||||
ESP_LOGD(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"ws frame during connect: gen=%" PRIu32 " opcode=0x%x fin=%d data_len=%d payload_len=%d offset=%d state=%s",
|
||||
transport_ctx->generation,
|
||||
data != NULL ? data->op_code : 0,
|
||||
data != NULL ? data->fin : 0,
|
||||
data != NULL ? data->data_len : 0,
|
||||
data != NULL ? data->payload_len : 0,
|
||||
data != NULL ? data->payload_offset : 0,
|
||||
esp_openclaw_node_internal_state_name(state));
|
||||
}
|
||||
if (data->op_code == 0x08 || data->op_code == 0x09 ||
|
||||
data->op_code == 0x0a) {
|
||||
break;
|
||||
}
|
||||
if (data->op_code != 0x01 && data->op_code != 0x00) {
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"ignoring unsupported websocket opcode=0x%x",
|
||||
data->op_code);
|
||||
break;
|
||||
}
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
if (!esp_openclaw_node_should_accept_callback_generation_locked(
|
||||
node,
|
||||
transport_ctx->generation)) {
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
break;
|
||||
}
|
||||
if (data->payload_offset == 0) {
|
||||
esp_openclaw_node_clear_data_buffer_locked(node);
|
||||
node->rx_buffer =
|
||||
calloc((size_t)data->payload_len + 1U, sizeof(char));
|
||||
node->rx_buffer_len = (size_t)data->payload_len;
|
||||
if (node->rx_buffer == NULL) {
|
||||
node->rx_buffer_len = 0;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
ESP_LOGE(ESP_OPENCLAW_NODE_TAG, "failed allocating rx buffer");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (node->rx_buffer == NULL ||
|
||||
node->rx_buffer_len < (size_t)data->payload_len ||
|
||||
((size_t)data->payload_offset + (size_t)data->data_len) >
|
||||
node->rx_buffer_len) {
|
||||
esp_openclaw_node_clear_data_buffer_locked(node);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"discarding malformed fragmented websocket payload");
|
||||
break;
|
||||
}
|
||||
memcpy(
|
||||
node->rx_buffer + data->payload_offset,
|
||||
data->data_ptr,
|
||||
(size_t)data->data_len);
|
||||
if (data->payload_offset + data->data_len >= data->payload_len &&
|
||||
data->fin) {
|
||||
node->rx_buffer[data->payload_len] = '\0';
|
||||
esp_openclaw_node_work_message_t message = {
|
||||
.type = ESP_OPENCLAW_NODE_WORK_MSG_DATA,
|
||||
.generation = transport_ctx->generation,
|
||||
.text = node->rx_buffer,
|
||||
};
|
||||
node->rx_buffer = NULL;
|
||||
node->rx_buffer_len = 0;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
esp_openclaw_node_enqueue_work_message_from_callback(node, &message);
|
||||
} else {
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
7
components/esp-openclaw-node/test_apps/README.md
Normal file
7
components/esp-openclaw-node/test_apps/README.md
Normal 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
|
||||
@ -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)
|
||||
@ -0,0 +1,57 @@
|
||||
# esp_openclaw_node_unity_tests
|
||||
|
||||
This is the ESP-IDF Unity test app for the `esp-openclaw-node` component.
|
||||
|
||||
Files:
|
||||
|
||||
- `CMakeLists.txt`: test app project file
|
||||
- `main/test_esp_openclaw_node.c`: test cases
|
||||
|
||||
## What these tests do
|
||||
|
||||
The test app runs on the target and executes `unity_run_all_tests()` from
|
||||
`app_main()`.
|
||||
|
||||
Each test case resets NVS first. The suite focuses on component logic that does
|
||||
not require a live OpenClaw gateway.
|
||||
|
||||
## Current coverage
|
||||
|
||||
- persisted reconnect-session validation, persistence, reload, and clearing
|
||||
- identity seed persistence and auth-payload signing
|
||||
- node-config ownership for runtime-provided TLS PEM data
|
||||
- setup-code validation for malformed or ambiguous payloads
|
||||
- explicit connect-request argument validation for saved-session, token,
|
||||
password, and no-auth sources
|
||||
- auth-material selection, including the password-signature exclusion rule
|
||||
- destroy-path notification safety
|
||||
- transport-state edge cases around challenge ping, clean close, and disconnect
|
||||
rejection while still connecting
|
||||
|
||||
## Not covered here
|
||||
|
||||
The following still need integration or end-to-end tests:
|
||||
|
||||
- WebSocket handshake behavior against a live gateway
|
||||
- end-to-end event delivery for `CONNECTED`, `CONNECT_FAILED`, and
|
||||
`DISCONNECTED`
|
||||
- persistence of `{ gateway_uri, device_token }` after a real `hello-ok`
|
||||
- example-application REPL flows and Wi-Fi recovery behavior
|
||||
|
||||
## Build and run
|
||||
|
||||
Example for `esp32s3`:
|
||||
|
||||
```bash
|
||||
idf.py -C components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests set-target esp32s3
|
||||
idf.py -C components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests build
|
||||
idf.py -C components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests -p /dev/ttyACM0 flash monitor
|
||||
```
|
||||
|
||||
The suite runs automatically at boot. A passing run ends with a summary like:
|
||||
|
||||
```text
|
||||
-----------------------
|
||||
18 Tests 0 Failures 0 Ignored
|
||||
OK
|
||||
```
|
||||
@ -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")
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +0,0 @@
|
||||
version: "1.0.0"
|
||||
description: "esp-openclaw-node"
|
||||
url: https://github.com/espressif/esp-openclaw-node
|
||||
dependencies:
|
||||
idf:
|
||||
version: ">=4.3"
|
||||
Loading…
Reference in New Issue
Block a user