Merge branch 'esp-openclaw-node' into 'main'
Add the esp-openclaw-node component, example and docs See merge request espressif/esp-openclaw-node!2
This commit is contained in:
commit
057a96a222
21
.github/workflows/upload_component.yml
vendored
Normal file
21
.github/workflows/upload_component.yml
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
name: Push esp-openclaw-node to IDF Component Registry
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
upload_components:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Upload esp-openclaw-node to IDF Component Registry
|
||||
uses: espressif/upload-components-ci-action@v2
|
||||
with:
|
||||
components: "esp-openclaw-node:components/esp-openclaw-node"
|
||||
namespace: "espressif"
|
||||
api_token: ${{ secrets.IDF_COMPONENT_API_TOKEN }}
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
**/build*/
|
||||
**/managed_components/
|
||||
**/dependencies.lock
|
||||
**/sdkconfig
|
||||
**/sdkconfig.old
|
||||
**/sdkconfig.ci
|
||||
**/__pycache__/
|
||||
dist/
|
||||
.DS_Store
|
||||
202
LICENSE
Normal file
202
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.
|
||||
26
README.md
26
README.md
@ -0,0 +1,26 @@
|
||||

|
||||
|
||||
# esp-openclaw-node
|
||||
|
||||
This repository contains the `esp-openclaw-node` ESP-IDF component, example applications, and documentation for running ESP32 boards as [OpenClaw Nodes](https://docs.openclaw.ai/nodes).
|
||||
|
||||
<a href="docs/assets/openclaw-gateway-esp-box-3-message-flow.png">
|
||||
<img src="docs/assets/openclaw-gateway-esp-box-3-message-flow.png" alt="OpenClaw Gateway to ESP-BOX-3 message flow" width="900">
|
||||
</a>
|
||||
|
||||
## Repository Layout
|
||||
|
||||
- `components/esp-openclaw-node/`: The `esp-openclaw-node` ESP-IDF component that handles OpenClaw transport, pairing, reconnect, and command dispatch.
|
||||
See the [Component README](./components/esp-openclaw-node/README.md) for more details on component internals.
|
||||
- `examples/`: Example applications for supported boards.
|
||||
- `docs/`: Getting-started and troubleshooting guides.
|
||||
|
||||
## Start Here
|
||||
|
||||
<a href="https://espressif.github.io/esp-launchpad/?flashConfigURL=https://espressif.github.io/esp-openclaw-node/launchpad.toml">
|
||||
<img alt="Try it with ESP Launchpad" src="https://espressif.github.io/esp-launchpad/assets/try_with_launchpad.png" width="220" height="62">
|
||||
</a>
|
||||
|
||||
1. Read [Getting Started](./docs/getting-started.md).
|
||||
2. Choose an example from [Examples](./examples/README.md).
|
||||
3. Use [Troubleshooting](./docs/troubleshooting.md) if the node pairs, connects, or advertises commands differently than expected.
|
||||
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.
|
||||
28
components/esp-openclaw-node/CMakeLists.txt
Normal file
28
components/esp-openclaw-node/CMakeLists.txt
Normal file
@ -0,0 +1,28 @@
|
||||
set(esp_openclaw_node_component_args
|
||||
SRCS
|
||||
"src/esp_openclaw_node_identity.c"
|
||||
"src/esp_openclaw_node_persisted_session.c"
|
||||
"src/esp_openclaw_node.c"
|
||||
"src/esp_openclaw_node_connect_source.c"
|
||||
"src/esp_openclaw_node_registry.c"
|
||||
"src/esp_openclaw_node_protocol.c"
|
||||
"src/esp_openclaw_node_transport.c"
|
||||
"src/esp_openclaw_node_runtime.c"
|
||||
INCLUDE_DIRS "include"
|
||||
PRIV_INCLUDE_DIRS "private_include"
|
||||
REQUIRES
|
||||
espressif__cjson
|
||||
esp_app_format
|
||||
esp_websocket_client
|
||||
libsodium
|
||||
mbedtls
|
||||
nvs_flash
|
||||
)
|
||||
|
||||
idf_component_register(${esp_openclaw_node_component_args})
|
||||
|
||||
# Work around the IDF 6.0/newlib-picolibc Annex K header breakage in the
|
||||
# managed libsodium component without affecting the rest of the build.
|
||||
if(TARGET __idf_espressif__libsodium)
|
||||
target_compile_definitions(__idf_espressif__libsodium PRIVATE __STDC_WANT_LIB_EXT1__=0)
|
||||
endif()
|
||||
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.
|
||||
657
components/esp-openclaw-node/README.md
Normal file
657
components/esp-openclaw-node/README.md
Normal file
@ -0,0 +1,657 @@
|
||||
# esp-openclaw-node
|
||||
|
||||
`esp-openclaw-node` is the ESP-IDF component package in this repository for
|
||||
running an ESP32 application as an OpenClaw Node over WebSocket.
|
||||
The public C API is declared in
|
||||
[esp_openclaw_node.h](./include/esp_openclaw_node.h) and uses the
|
||||
`esp_openclaw_node_*` prefix.
|
||||
|
||||
The component provides:
|
||||
|
||||
- Device identity generation and persistence
|
||||
- OpenClaw `connect.challenge` signing and `connect` request construction
|
||||
- setup-code, shared-token, password, no-auth, and saved-session connect paths
|
||||
- capability and command advertisement
|
||||
- Handling `node.invoke.request` commands and sending `node.invoke.result` replies
|
||||
|
||||
## Contents
|
||||
|
||||
- [Requirements](#requirements)
|
||||
- [Overview](#overview)
|
||||
- [What The Component Handles](#what-the-component-handles)
|
||||
- [What The Application Handles](#what-the-application-handles)
|
||||
- [Public API](#public-api)
|
||||
- [Lifecycle](#lifecycle)
|
||||
- [Registration](#registration)
|
||||
- [Async Control](#async-control)
|
||||
- [Inspection](#inspection)
|
||||
- [Usage](#usage)
|
||||
- [Basic Lifecycle](#basic-lifecycle)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Configuration And Defaults](#configuration-and-defaults)
|
||||
- [Registering Capabilities And Commands](#registering-capabilities-and-commands)
|
||||
- [Command Handlers](#command-handlers)
|
||||
- [Connect and Session Model](#connect-and-session-model)
|
||||
- [Connect Model](#connect-model)
|
||||
- [Supported Connect Sources](#supported-connect-sources)
|
||||
- [Setup Codes](#setup-codes)
|
||||
- [Events](#events)
|
||||
- [Stored State](#stored-state)
|
||||
- [Reference](#reference)
|
||||
- [TLS](#tls)
|
||||
- [Examples and Reconnect Policy](#examples-and-reconnect-policy)
|
||||
- [Component Tests](#component-tests)
|
||||
|
||||
## Requirements
|
||||
|
||||
- ESP-IDF `5.x`
|
||||
- a board that can reach the OpenClaw Gateway
|
||||
- application-managed network setup, such as Wi-Fi or Ethernet
|
||||
- `nvs_flash_init()`, `esp_netif_init()`, and the default event loop before the
|
||||
node is created or connected
|
||||
|
||||
## Overview
|
||||
|
||||
### What The Component Handles
|
||||
|
||||
- Generating or loading the device seed from NVS
|
||||
- Deriving the Ed25519 keypair and stable `device_id`
|
||||
- Opening one WebSocket transport at a time
|
||||
- Handling `connect.challenge`
|
||||
- Building and signing the OpenClaw `connect` request
|
||||
- Advertising capabilities and commands
|
||||
- Dispatching `node.invoke.request` into registered handlers
|
||||
- Sending `node.invoke.result`
|
||||
- Persisting the final `{ gateway_uri, device_token }` reconnect session after a
|
||||
successful `hello-ok`
|
||||
|
||||
### What The Application Handles
|
||||
|
||||
- Wi-Fi, Ethernet, PPP, or any other route to the gateway
|
||||
- Local UI, REPL, or provisioning flows
|
||||
- How setup codes, gateway URIs, tokens, or passwords reach the board
|
||||
- Deciding whether the next attempt should use a saved session or explicit auth
|
||||
- Deciding whether and when to retry after `CONNECT_FAILED` or `DISCONNECTED`
|
||||
- Any factory reset, identity reset, or saved-session clear workflow
|
||||
- The actual device-specific command handlers
|
||||
|
||||
## Public API
|
||||
|
||||
### Lifecycle
|
||||
|
||||
- `esp_openclaw_node_config_init_default()`
|
||||
- `esp_openclaw_node_create()`
|
||||
- `esp_openclaw_node_destroy()`
|
||||
|
||||
### Registration
|
||||
|
||||
- `esp_openclaw_node_register_capability()`
|
||||
- `esp_openclaw_node_register_command()`
|
||||
|
||||
### Async Control
|
||||
|
||||
- `esp_openclaw_node_request_connect()`
|
||||
- `esp_openclaw_node_request_disconnect()`
|
||||
|
||||
### Inspection
|
||||
|
||||
- `esp_openclaw_node_get_device_id()`
|
||||
- `esp_openclaw_node_has_saved_session()`
|
||||
|
||||
The component is driven through registration, one explicit connect request at a
|
||||
time, and terminal events.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Lifecycle
|
||||
|
||||
1. Initialize NVS, `esp_netif`, and the default event loop.
|
||||
2. Bring up networking, or arrange to wait for it before connecting.
|
||||
3. Initialize `esp_openclaw_node_config_t` with
|
||||
`esp_openclaw_node_config_init_default()`.
|
||||
4. Create the node with `esp_openclaw_node_create()`.
|
||||
5. Register capabilities and commands before the first accepted connect request.
|
||||
6. Submit one connect request with `esp_openclaw_node_request_connect()`.
|
||||
7. Wait for a terminal event before submitting the next control request.
|
||||
8. Destroy the node with `esp_openclaw_node_destroy()` when finished.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```c
|
||||
#include <string.h>
|
||||
#include "esp_openclaw_node.h"
|
||||
|
||||
static esp_err_t handle_device_info(
|
||||
esp_openclaw_node_handle_t node,
|
||||
void *context,
|
||||
const char *params_json,
|
||||
size_t params_len,
|
||||
char **out_payload_json,
|
||||
esp_openclaw_node_error_t *out_error)
|
||||
{
|
||||
(void)node;
|
||||
(void)context;
|
||||
(void)params_json;
|
||||
(void)params_len;
|
||||
(void)out_error;
|
||||
|
||||
*out_payload_json = strdup("{\"status\":\"ok\"}");
|
||||
return *out_payload_json != NULL ? ESP_OK : ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
static void handle_node_event(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_event_t event,
|
||||
const void *event_data,
|
||||
void *user_ctx)
|
||||
{
|
||||
(void)node;
|
||||
(void)event_data;
|
||||
(void)user_ctx;
|
||||
|
||||
if (event == ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED) {
|
||||
/* Decide whether and when to retry in application code. */
|
||||
}
|
||||
}
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
esp_openclaw_node_config_t config = {0};
|
||||
esp_openclaw_node_handle_t node = NULL;
|
||||
const char *setup_code = "<setup-code>";
|
||||
|
||||
esp_openclaw_node_config_init_default(&config);
|
||||
config.event_cb = handle_node_event;
|
||||
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_create(&config, &node));
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_register_capability(node, "device"));
|
||||
|
||||
esp_openclaw_node_command_t cmd = {
|
||||
.name = "device.info",
|
||||
.handler = handle_device_info,
|
||||
};
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_register_command(node, &cmd));
|
||||
|
||||
esp_openclaw_node_connect_request_t request = {
|
||||
.source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE,
|
||||
.gateway_uri = NULL,
|
||||
.value = setup_code,
|
||||
};
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_request_connect(node, &request));
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration And Defaults
|
||||
|
||||
`esp_openclaw_node_config_init_default()` sets:
|
||||
|
||||
- `display_name = "OpenClaw ESP32"`
|
||||
- `platform = "esp32"`
|
||||
- `device_family = "ESP32"`
|
||||
- `client_id = "node-host"`
|
||||
- `client_mode = "node"`
|
||||
- `role = "node"`
|
||||
- `model_identifier = CONFIG_IDF_TARGET`
|
||||
- `locale = "en-US"`
|
||||
- `use_cert_bundle = true`
|
||||
- `tls_common_name = NULL`
|
||||
- `tls_cert_pem = NULL`
|
||||
- `skip_cert_common_name_check = false`
|
||||
|
||||
### Registering Capabilities And Commands
|
||||
|
||||
Register everything before the first connect request.
|
||||
|
||||
```c
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_register_capability(node, "display"));
|
||||
|
||||
esp_openclaw_node_command_t cmd = {
|
||||
.name = "display.show",
|
||||
.handler = handle_display_show,
|
||||
.context = &display_state,
|
||||
};
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_register_command(node, &cmd));
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- capabilities are plain strings
|
||||
- commands are plain strings plus a handler and optional context pointer
|
||||
- Duplicate capability or command names are ignored and return `ESP_OK`
|
||||
- Registration is allowed only when no session is active and no connect or
|
||||
disconnect request is in flight
|
||||
- Registration and transport resource limits default to
|
||||
`ESP_OPENCLAW_NODE_MAX_CAPABILITIES`, `ESP_OPENCLAW_NODE_MAX_COMMANDS`,
|
||||
the internal work-queue length, and the component/WebSocket task and buffer
|
||||
sizes. These can be tuned in `menuconfig` under
|
||||
`Component config -> ESP OpenClaw Node`.
|
||||
|
||||
Current `menuconfig` options and defaults:
|
||||
|
||||
- `CONFIG_ESP_OPENCLAW_NODE_MAX_CAPABILITIES` = `16`
|
||||
- `CONFIG_ESP_OPENCLAW_NODE_MAX_COMMANDS` = `32`
|
||||
- `CONFIG_ESP_OPENCLAW_NODE_WORK_QUEUE_LENGTH` = `32`
|
||||
- `CONFIG_ESP_OPENCLAW_NODE_TASK_STACK_SIZE` = `8192`
|
||||
- `CONFIG_ESP_OPENCLAW_NODE_TRANSPORT_TASK_STACK_SIZE` = `8192`
|
||||
- `CONFIG_ESP_OPENCLAW_NODE_TRANSPORT_BUFFER_SIZE` = `2048`
|
||||
|
||||
The component advertises capability names and command names only. It does not
|
||||
currently send parameter schemas to the gateway.
|
||||
|
||||
### Command Handlers
|
||||
|
||||
Handler signature:
|
||||
|
||||
```c
|
||||
typedef esp_err_t (*esp_openclaw_node_command_handler_t)(
|
||||
esp_openclaw_node_handle_t node,
|
||||
void *context,
|
||||
const char *params_json,
|
||||
size_t params_len,
|
||||
char **out_payload_json,
|
||||
esp_openclaw_node_error_t *out_error);
|
||||
```
|
||||
|
||||
Handler behavior:
|
||||
|
||||
- handlers run synchronously on the component task
|
||||
- `params_json` is the raw UTF-8 JSON string from `payload.paramsJSON`
|
||||
- when the request omits `paramsJSON`, the component passes `"{}"`
|
||||
- the component always passes `out_payload_json` and initializes
|
||||
`*out_payload_json` to `NULL` before calling the handler
|
||||
- on success, return `ESP_OK` and either leave `*out_payload_json` as `NULL`
|
||||
to send no payload or assign a UTF-8 JSON string to `*out_payload_json`
|
||||
- on failure, return a non-`ESP_OK` code and populate `out_error` with a stable
|
||||
error `code` and human-readable `message`
|
||||
- any non-`NULL` `*out_payload_json` buffer must be `malloc()`-compatible; the
|
||||
component sends it as `payloadJSON` and then frees it
|
||||
|
||||
Because handlers run on the component task, long-running work should be handed
|
||||
off to another task if it cannot complete quickly.
|
||||
|
||||
## Connect and Session Model
|
||||
|
||||
### Connect Model
|
||||
|
||||
The component performs one connection attempt at a time. It does not run an
|
||||
automatic reconnect loop and it does not choose between saved-session reconnect
|
||||
and explicit auth input on behalf of the application.
|
||||
|
||||
Request rules:
|
||||
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION` is valid only when a saved
|
||||
reconnect session is present
|
||||
Applications can check saved-session availability with
|
||||
`esp_openclaw_node_has_saved_session()` before submitting that request.
|
||||
- explicit connect requests are valid only when no session is active and no
|
||||
connect or disconnect request is in flight
|
||||
- `esp_openclaw_node_request_disconnect()` is valid only while the node is ready
|
||||
- once destroy begins, new async requests are rejected
|
||||
|
||||
For each accepted connect request, wait for exactly one terminal outcome before
|
||||
submitting another control request.
|
||||
|
||||
### Supported Connect Sources
|
||||
|
||||
The public API exposes five caller-chosen connect sources:
|
||||
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION`
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE`
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_TOKEN`
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_PASSWORD`
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_NO_AUTH`
|
||||
|
||||
Field requirements for `esp_openclaw_node_connect_request_t`:
|
||||
|
||||
- `SAVED_SESSION`: `gateway_uri = NULL`, `value = NULL`
|
||||
- `SETUP_CODE`: `gateway_uri = NULL`, `value = <setup code>`
|
||||
- `GATEWAY_TOKEN`: `gateway_uri = <ws://...|wss://...>`, `value = <token>`
|
||||
- `GATEWAY_PASSWORD`: `gateway_uri = <ws://...|wss://...>`,
|
||||
`value = <password>`
|
||||
- `NO_AUTH`: `gateway_uri = <ws://...|wss://...>`, `value = NULL`
|
||||
|
||||
When a connect attempt begins, the component resolves auth material like this:
|
||||
|
||||
- saved session: send `auth.deviceToken`
|
||||
- explicit gateway token: send `auth.token`
|
||||
- explicit gateway password: send `auth.password`
|
||||
- explicit no-auth: omit the `auth` object
|
||||
- setup code: depends on the decoded credential field
|
||||
|
||||
This selection is per attempt. The component does not fall back from one auth
|
||||
mode to another automatically.
|
||||
|
||||
### Setup Codes
|
||||
|
||||
In the current component, a setup code is base64url-encoded JSON that must
|
||||
contain:
|
||||
|
||||
- `url`
|
||||
- exactly one of:
|
||||
- `bootstrapToken`
|
||||
- `token`
|
||||
- `password`
|
||||
|
||||
Example decoded payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "ws://192.168.1.10:19001",
|
||||
"bootstrapToken": "oc_bootstrap_example_token"
|
||||
}
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Pairing Flow</summary>
|
||||
|
||||
The usual first-pairing path is one explicit setup-code connect attempt. The
|
||||
component does not stage setup-code state for a later `connect` call.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as ESP-IDF App
|
||||
participant Node as esp_openclaw_node
|
||||
participant NVS as NVS
|
||||
participant GW as OpenClaw Gateway
|
||||
|
||||
App->>Node: esp_openclaw_node_create()
|
||||
Node->>NVS: load or create device_seed
|
||||
Node->>NVS: load saved reconnect session
|
||||
App->>Node: register capabilities and commands
|
||||
App->>Node: esp_openclaw_node_request_connect(SETUP_CODE)
|
||||
Node->>Node: decode setup code
|
||||
Node->>GW: open websocket to setup-code url
|
||||
GW-->>Node: connect.challenge
|
||||
Node->>Node: resolve auth material and sign payload
|
||||
Node->>GW: connect(auth..., device signature)
|
||||
alt hello-ok with auth.deviceToken
|
||||
GW-->>Node: hello-ok
|
||||
Node->>NVS: store {session_v, session_uri, session_dev_tok}
|
||||
Node-->>App: ESP_OPENCLAW_NODE_EVENT_CONNECTED
|
||||
else auth rejected or finalization fails
|
||||
GW-->>Node: error or incomplete hello-ok
|
||||
Node-->>App: ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED
|
||||
end
|
||||
GW-->>Node: node.invoke.request
|
||||
Node->>App: registered handler(paramsJSON)
|
||||
App-->>Node: payloadJSON or error
|
||||
Node->>GW: node.invoke.result
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
### Events
|
||||
|
||||
The component emits these events through `esp_openclaw_node_event_cb_t`:
|
||||
|
||||
- `ESP_OPENCLAW_NODE_EVENT_CONNECTED`
|
||||
- `ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED`
|
||||
- `ESP_OPENCLAW_NODE_EVENT_DISCONNECTED`
|
||||
|
||||
`ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED` carries
|
||||
`esp_openclaw_node_connect_failed_event_t` with:
|
||||
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILED`
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_FAILURE_CONNECTION_LOST`
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_FAILURE_AUTH_REJECTED`
|
||||
- `ESP_OPENCLAW_NODE_CONNECT_FAILURE_SESSION_FINALIZATION_FAILED`
|
||||
|
||||
`ESP_OPENCLAW_NODE_EVENT_DISCONNECTED` carries
|
||||
`esp_openclaw_node_disconnected_event_t` with:
|
||||
|
||||
- `ESP_OPENCLAW_NODE_DISCONNECTED_REASON_REQUESTED`
|
||||
- `ESP_OPENCLAW_NODE_DISCONNECTED_REASON_CONNECTION_LOST`
|
||||
|
||||
Event callback rules:
|
||||
|
||||
- callbacks run on the component task
|
||||
- keep callback code short and non-blocking
|
||||
- callbacks may call the async request APIs
|
||||
- callbacks must not call `esp_openclaw_node_destroy()`
|
||||
|
||||
### Stored State
|
||||
|
||||
The component stores internal state in NVS namespace `openclaw`.
|
||||
|
||||
Identity:
|
||||
|
||||
- `device_seed`: 32-byte Ed25519 seed
|
||||
|
||||
Saved reconnect session:
|
||||
|
||||
- `session_v`
|
||||
- `session_uri`
|
||||
- `session_dev_tok`
|
||||
|
||||
Derived at runtime from `device_seed`:
|
||||
|
||||
- `device_id = hex(sha256(public_key))`
|
||||
- `public_key`
|
||||
- `private_key`
|
||||
|
||||
Persistence rules:
|
||||
|
||||
- setup-code bootstrap tokens are never persisted
|
||||
- explicit shared gateway tokens are never persisted
|
||||
- explicit gateway passwords are never persisted
|
||||
- explicit no-auth selections are never persisted
|
||||
- only the final `{ gateway_uri, device_token }` reconnect session is persisted
|
||||
|
||||
## Reference
|
||||
|
||||
<details>
|
||||
<summary>Example Wire Messages</summary>
|
||||
|
||||
Example `connect.challenge` from the gateway:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "connect.challenge",
|
||||
"payload": {
|
||||
"nonce": "M2QxYjBiNDItYzJlZS00YzA3LWFkMWMtMmE4NGJmZTg4M2E5",
|
||||
"ts": 1774830385123
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example `connect` from the node:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "req",
|
||||
"id": "connect-1774830385140123",
|
||||
"method": "connect",
|
||||
"params": {
|
||||
"minProtocol": 3,
|
||||
"maxProtocol": 3,
|
||||
"client": {
|
||||
"id": "node-host",
|
||||
"displayName": "OpenClaw ESP32",
|
||||
"version": "1.0.0",
|
||||
"platform": "esp32",
|
||||
"deviceFamily": "ESP32",
|
||||
"modelIdentifier": "esp32c6",
|
||||
"mode": "node"
|
||||
},
|
||||
"role": "node",
|
||||
"scopes": [],
|
||||
"caps": ["device", "wifi", "gpio"],
|
||||
"commands": [
|
||||
"device.info",
|
||||
"device.status",
|
||||
"wifi.status",
|
||||
"gpio.mode",
|
||||
"gpio.read",
|
||||
"gpio.write"
|
||||
],
|
||||
"auth": {
|
||||
"deviceToken": "<saved-device-token>"
|
||||
},
|
||||
"userAgent": "esp-openclaw-node/1.0.0",
|
||||
"locale": "en-US",
|
||||
"device": {
|
||||
"id": "<device-id>",
|
||||
"publicKey": "<base64url-public-key>",
|
||||
"signature": "<base64url-signature>",
|
||||
"signedAt": 1774830385123,
|
||||
"nonce": "M2QxYjBiNDItYzJlZS00YzA3LWFkMWMtMmE4NGJmZTg4M2E5"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`params.auth` variants by connect source:
|
||||
|
||||
```json
|
||||
{ "bootstrapToken": "<bootstrap-token>" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "token": "<gateway-token>" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "password": "<gateway-password>" }
|
||||
```
|
||||
|
||||
For explicit no-auth, the component omits the `auth` object entirely.
|
||||
|
||||
Successful `hello-ok` response:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "res",
|
||||
"id": "connect-1774830385140123",
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"type": "hello-ok",
|
||||
"protocol": 3,
|
||||
"server": {
|
||||
"version": "2026.4.9",
|
||||
"connId": "<gateway-connection-id>"
|
||||
},
|
||||
"auth": {
|
||||
"deviceToken": "<node-device-token>",
|
||||
"role": "node",
|
||||
"scopes": [],
|
||||
"deviceTokens": [
|
||||
{
|
||||
"deviceToken": "<bounded-operator-token>",
|
||||
"role": "operator",
|
||||
"scopes": [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The gateway may return the primary node reconnect token in
|
||||
`payload.auth.deviceToken` plus additional tokens in
|
||||
`payload.auth.deviceTokens`.
|
||||
This component persists and reuses only
|
||||
`payload.auth.deviceToken`; it ignores any extra `payload.auth.deviceTokens` entries.
|
||||
|
||||
Example `node.invoke.request` from the gateway:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "node.invoke.request",
|
||||
"payload": {
|
||||
"id": "inv_01JV0A7X9S1ZQY7V5NXJYQ5V8K",
|
||||
"nodeId": "<node-id>",
|
||||
"command": "display.show",
|
||||
"paramsJSON": "{\"heading\":\"OpenClaw\",\"text\":\"Hello from the gateway.\"}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Successful `node.invoke.result` from the node:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "req",
|
||||
"id": "esp32-1774830401987123",
|
||||
"method": "node.invoke.result",
|
||||
"params": {
|
||||
"id": "inv_01JV0A7X9S1ZQY7V5NXJYQ5V8K",
|
||||
"nodeId": "<node-id>",
|
||||
"ok": true,
|
||||
"payloadJSON": "{\"heading\":\"OpenClaw\",\"text\":\"Hello from the gateway.\",\"renderCount\":1}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Error `node.invoke.result` from the node:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "req",
|
||||
"id": "esp32-1774830402987001",
|
||||
"method": "node.invoke.result",
|
||||
"params": {
|
||||
"id": "inv_01JV0AA4J2QJ4V4X1R7W43G6CE",
|
||||
"nodeId": "<node-id>",
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "INVALID_PARAMS",
|
||||
"message": "display params must include string heading and text fields"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### TLS
|
||||
|
||||
The component supports both `ws://` and `wss://`.
|
||||
|
||||
For `wss://`, one of these trust paths must be configured:
|
||||
|
||||
- set `tls_cert_pem` to a PEM trust anchor, or
|
||||
- leave `use_cert_bundle = true` and build with
|
||||
`CONFIG_MBEDTLS_CERTIFICATE_BUNDLE`
|
||||
|
||||
If neither is true, `wss://` connect requests are rejected before transport
|
||||
startup.
|
||||
|
||||
Useful fields in `esp_openclaw_node_config_t`:
|
||||
|
||||
- `tls_cert_pem`
|
||||
- `tls_cert_len`
|
||||
- `use_cert_bundle`
|
||||
- `tls_common_name`
|
||||
- `skip_cert_common_name_check`
|
||||
|
||||
`skip_cert_common_name_check` is available for local development, but should
|
||||
stay disabled for production-like deployments.
|
||||
|
||||
### Examples and Reconnect Policy
|
||||
|
||||
The component itself does not implement automatic reconnect policy.
|
||||
|
||||
This repository's examples provide that behavior outside the component in
|
||||
[examples/common/esp_openclaw_node_example_saved_session_reconnect.c](../../examples/common/esp_openclaw_node_example_saved_session_reconnect.c).
|
||||
That helper:
|
||||
|
||||
- waits for Wi-Fi to be online
|
||||
- checks `esp_openclaw_node_has_saved_session()`
|
||||
- retries only the saved-session path
|
||||
- retries after retryable `CONNECT_FAILED` and `DISCONNECTED` outcomes
|
||||
|
||||
The application keeps network policy and retry policy, while the component focuses on node identity, protocol, and one attempt
|
||||
at a time.
|
||||
|
||||
### Component Tests
|
||||
|
||||
Component tests live under
|
||||
[components/esp-openclaw-node/test_apps](./test_apps/README.md).
|
||||
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.21"
|
||||
349
components/esp-openclaw-node/include/esp_openclaw_node.h
Normal file
349
components/esp-openclaw-node/include/esp_openclaw_node.h
Normal file
@ -0,0 +1,349 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Limits */
|
||||
|
||||
/** @brief Maximum number of capabilities a node can advertise. */
|
||||
#define ESP_OPENCLAW_NODE_MAX_CAPABILITIES CONFIG_ESP_OPENCLAW_NODE_MAX_CAPABILITIES
|
||||
/** @brief Maximum number of commands a node can register. */
|
||||
#define ESP_OPENCLAW_NODE_MAX_COMMANDS CONFIG_ESP_OPENCLAW_NODE_MAX_COMMANDS
|
||||
|
||||
/* Core Types */
|
||||
|
||||
/** @brief Opaque handle for an OpenClaw Node instance. */
|
||||
typedef struct esp_openclaw_node *esp_openclaw_node_handle_t;
|
||||
|
||||
/* Event Types */
|
||||
|
||||
/** @brief Terminal and maintenance events emitted by the component. */
|
||||
typedef enum {
|
||||
ESP_OPENCLAW_NODE_EVENT_CONNECTED = 0, /**< Handshake completed and the session is ready. */
|
||||
ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED, /**< A connect attempt ended without reaching ready state. */
|
||||
ESP_OPENCLAW_NODE_EVENT_DISCONNECTED, /**< An established session disconnected or was closed locally. */
|
||||
} esp_openclaw_node_event_t;
|
||||
|
||||
/** @brief Failure reasons surfaced on @ref ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED. */
|
||||
typedef enum {
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILED = 0, /**< Transport startup or local connect setup failed. */
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_CONNECTION_LOST, /**< The transport dropped before connect completed. */
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_AUTH_REJECTED, /**< The gateway rejected the auth material or signature. */
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_SESSION_FINALIZATION_FAILED, /**< `hello-ok` handling failed after initial auth acceptance. */
|
||||
} esp_openclaw_node_connect_failure_reason_t;
|
||||
|
||||
/** @brief Payload for @ref ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED. */
|
||||
typedef struct {
|
||||
esp_openclaw_node_connect_failure_reason_t reason; /**< High-level failure category. */
|
||||
esp_err_t local_err; /**< Error code associated with the failure, or ESP_OK if none applies. */
|
||||
const char *gateway_detail_code; /**< Optional gateway detail code such as `AUTH_*`. */
|
||||
} esp_openclaw_node_connect_failed_event_t;
|
||||
|
||||
/** @brief Disconnect reasons surfaced on @ref ESP_OPENCLAW_NODE_EVENT_DISCONNECTED. */
|
||||
typedef enum {
|
||||
ESP_OPENCLAW_NODE_DISCONNECTED_REASON_REQUESTED = 0, /**< Disconnect was requested locally. */
|
||||
ESP_OPENCLAW_NODE_DISCONNECTED_REASON_CONNECTION_LOST, /**< Disconnect followed a transport loss or remote close. */
|
||||
} esp_openclaw_node_disconnected_reason_t;
|
||||
|
||||
/** @brief Payload for @ref ESP_OPENCLAW_NODE_EVENT_DISCONNECTED. */
|
||||
typedef struct {
|
||||
esp_openclaw_node_disconnected_reason_t reason; /**< High-level disconnect category. */
|
||||
esp_err_t local_err; /**< Error code associated with the disconnect, or ESP_OK if none applies. */
|
||||
} esp_openclaw_node_disconnected_event_t;
|
||||
|
||||
/**
|
||||
* @brief Node event callback.
|
||||
*
|
||||
* Event callbacks run on the component's internal node task. Callback code
|
||||
* must stay short and non-blocking. It may call the async
|
||||
* `esp_openclaw_node_request_*()` APIs. It must not call
|
||||
* @ref esp_openclaw_node_destroy.
|
||||
*
|
||||
* For each accepted connect request, wait for exactly one terminal outcome:
|
||||
* - @ref ESP_OPENCLAW_NODE_EVENT_CONNECTED
|
||||
* - @ref ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED
|
||||
* - @ref ESP_OPENCLAW_NODE_EVENT_DISCONNECTED after an accepted disconnect of an
|
||||
* active session
|
||||
*
|
||||
* `event_data` is:
|
||||
* - `NULL` for @ref ESP_OPENCLAW_NODE_EVENT_CONNECTED
|
||||
* - @ref esp_openclaw_node_connect_failed_event_t for
|
||||
* @ref ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED
|
||||
* - @ref esp_openclaw_node_disconnected_event_t for
|
||||
* @ref ESP_OPENCLAW_NODE_EVENT_DISCONNECTED
|
||||
*
|
||||
* The payload pointer is valid only for the duration of the callback.
|
||||
*/
|
||||
typedef void (*esp_openclaw_node_event_cb_t)(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_event_t event,
|
||||
const void *event_data,
|
||||
void *user_ctx);
|
||||
|
||||
/* Configuration */
|
||||
|
||||
/**
|
||||
* @brief Node configuration.
|
||||
*
|
||||
* Before creating or connecting the node, the application must initialize the
|
||||
* ESP-IDF runtime pieces that networking depends on:
|
||||
* - `nvs_flash_init()`
|
||||
* - `esp_netif_init()`
|
||||
* - `esp_event_loop_create_default()`
|
||||
*
|
||||
* The application owns network setup. This component does not bring up Wi-Fi,
|
||||
* Ethernet, PPP, or any other network path; it opens the OpenClaw WebSocket
|
||||
* session once a route to the configured gateway exists.
|
||||
*/
|
||||
typedef struct {
|
||||
const char *display_name; /**< Human-readable name advertised to the gateway. */
|
||||
const char *platform; /**< Lower-level platform string included in auth and identity metadata. */
|
||||
const char *device_family; /**< Higher-level device family string included in auth metadata. */
|
||||
const char *client_id; /**< Protocol client identifier sent during connect. */
|
||||
const char *client_mode; /**< Protocol client mode, typically `node` for these examples. */
|
||||
const char *role; /**< Protocol role string expected by the gateway for this client. */
|
||||
const char *model_identifier; /**< Optional model string such as `esp32c6` or `esp32s3`. */
|
||||
const char *locale; /**< Optional locale metadata advertised to the gateway. */
|
||||
const char *tls_common_name; /**< Optional TLS common-name override for certificate checks. */
|
||||
const char *tls_cert_pem; /**< Optional PEM trust anchor for `wss://` connections. */
|
||||
size_t tls_cert_len; /**< Length of @p tls_cert_pem in bytes, or `0` for NUL-terminated PEM. */
|
||||
bool use_cert_bundle; /**< Use the ESP-IDF certificate bundle for server validation. */
|
||||
bool skip_cert_common_name_check; /**< Skip common-name validation for TLS server certificates. */
|
||||
esp_openclaw_node_event_cb_t event_cb; /**< Optional event callback invoked on the component's internal node task. */
|
||||
void *event_user_ctx; /**< Opaque caller context passed back to @p event_cb. */
|
||||
} esp_openclaw_node_config_t;
|
||||
|
||||
/* Command Types */
|
||||
|
||||
/** @brief Structured command error returned to the gateway. */
|
||||
typedef struct {
|
||||
const char *code; /**< Stable machine-readable error code returned to the gateway. */
|
||||
const char *message; /**< Human-readable error message returned to the gateway. */
|
||||
} esp_openclaw_node_error_t;
|
||||
|
||||
/**
|
||||
* @brief Handler for a single advertised OpenClaw command.
|
||||
*
|
||||
* @param node Node instance handling the request.
|
||||
* @param context Optional user context from the command registration.
|
||||
* @param params_json UTF-8 JSON parameters from the gateway request. When the
|
||||
* request omits `paramsJSON`, the component passes `"{}"`.
|
||||
* @param params_len Length of @p params_json in bytes, excluding the trailing
|
||||
* `NUL`.
|
||||
* @param[out] out_payload_json Output location for the success payload JSON.
|
||||
* The component initializes `*out_payload_json` to `NULL` before
|
||||
* calling the handler. On success, leave `*out_payload_json` as
|
||||
* `NULL` to send no payload, or assign a `malloc()`-compatible
|
||||
* UTF-8 JSON string. The component takes ownership of any
|
||||
* non-`NULL` buffer and frees it after sending `payloadJSON`.
|
||||
* @param[out] out_error Structured command error when the handler fails.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - another ESP-IDF error code on failure
|
||||
*/
|
||||
typedef esp_err_t (*esp_openclaw_node_command_handler_t)(
|
||||
esp_openclaw_node_handle_t node,
|
||||
void *context,
|
||||
const char *params_json,
|
||||
size_t params_len,
|
||||
char **out_payload_json,
|
||||
esp_openclaw_node_error_t *out_error);
|
||||
|
||||
/** @brief Command registration entry passed to esp_openclaw_node_register_command(). */
|
||||
typedef struct {
|
||||
const char *name; /**< Command name advertised to the gateway, for example `wifi.status`. */
|
||||
esp_openclaw_node_command_handler_t handler; /**< Command callback invoked for matching requests. */
|
||||
void *context; /**< Opaque caller context passed to @p handler. */
|
||||
} esp_openclaw_node_command_t;
|
||||
|
||||
/* Connect Input Types */
|
||||
|
||||
/** @brief Caller-chosen source for one connect attempt. */
|
||||
typedef enum {
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION = 0, /**< Reconnect with the persisted `{ gateway_uri, device_token }` session. */
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE, /**< Decode and use a setup code that contains the gateway URI and exactly one auth secret. */
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_TOKEN, /**< Connect with an explicit shared gateway token. */
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_PASSWORD, /**< Connect with an explicit shared gateway password. */
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_NO_AUTH, /**< Connect to a gateway that intentionally allows unauthenticated node access. */
|
||||
} esp_openclaw_node_connect_source_t;
|
||||
|
||||
/**
|
||||
* @brief Connect request submitted to @ref esp_openclaw_node_request_connect().
|
||||
*
|
||||
* Field requirements by source:
|
||||
* - `SAVED_SESSION`: `gateway_uri = NULL`, `value = NULL`
|
||||
* - `SETUP_CODE`: `gateway_uri = NULL`, `value = <setup code>`
|
||||
* - `GATEWAY_TOKEN`: `gateway_uri = <ws://...|wss://...>`, `value = <token>`
|
||||
* - `GATEWAY_PASSWORD`: `gateway_uri = <ws://...|wss://...>`, `value = <password>`
|
||||
* - `NO_AUTH`: `gateway_uri = <ws://...|wss://...>`, `value = NULL`
|
||||
*/
|
||||
typedef struct {
|
||||
esp_openclaw_node_connect_source_t source; /**< Caller-chosen source for this one connect attempt. */
|
||||
const char *gateway_uri; /**< Explicit `ws://` or `wss://` URI when required by @p source. */
|
||||
const char *value; /**< Setup code, token, or password when required by @p source. */
|
||||
} esp_openclaw_node_connect_request_t;
|
||||
|
||||
/* Lifecycle APIs */
|
||||
|
||||
/**
|
||||
* @brief Populate a config struct with the component's built-in defaults.
|
||||
*
|
||||
* @param[out] config Configuration struct to initialize.
|
||||
*/
|
||||
void esp_openclaw_node_config_init_default(esp_openclaw_node_config_t *config);
|
||||
|
||||
/**
|
||||
* @brief Create a node instance from the supplied configuration.
|
||||
*
|
||||
* This also creates the component queues and task used to serialize transport
|
||||
* and session state transitions.
|
||||
*
|
||||
* @param[in] config Configuration to copy into the node instance.
|
||||
* @param[out] out_node Created node handle.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - `ESP_ERR_INVALID_ARG` if `config` or `out_node` is `NULL`
|
||||
* - `ESP_ERR_NO_MEM` if allocation of the node, queues, task, mutex, or
|
||||
* copied config strings fails
|
||||
* - another initialization error if identity setup or saved-session
|
||||
* loading fails
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_create(
|
||||
const esp_openclaw_node_config_t *config,
|
||||
esp_openclaw_node_handle_t *out_node);
|
||||
|
||||
/**
|
||||
* @brief Destroy a node instance and release all owned resources.
|
||||
*
|
||||
* This API is synchronous. It rejects self-destroy from the component task or
|
||||
* callback context.
|
||||
*
|
||||
* @param[in] node Node handle to destroy.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - `ESP_ERR_INVALID_ARG` if `node` is `NULL`
|
||||
* - `ESP_ERR_INVALID_STATE` if destroy has already begun or the current
|
||||
* task is the component task
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_destroy(esp_openclaw_node_handle_t node);
|
||||
|
||||
/* Registration APIs */
|
||||
|
||||
/**
|
||||
* @brief Advertise a capability string such as `device` or `wifi`.
|
||||
*
|
||||
* @param[in] node Node handle.
|
||||
* @param[in] capability Capability name to register.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - `ESP_ERR_INVALID_ARG` if `node` or `capability` is invalid
|
||||
* - `ESP_ERR_INVALID_STATE` if a session is active, a connect or
|
||||
* disconnect request is already in progress, or destroy has begun
|
||||
* - `ESP_ERR_NO_MEM` if the registry is full or the capability copy
|
||||
* cannot be allocated
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_register_capability(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *capability);
|
||||
|
||||
/**
|
||||
* @brief Register a handler for one OpenClaw command.
|
||||
*
|
||||
* @param[in] node Node handle.
|
||||
* @param[in] command Command registration entry.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - `ESP_ERR_INVALID_ARG` if `node` or `command` is invalid
|
||||
* - `ESP_ERR_INVALID_STATE` if a session is active, a connect or
|
||||
* disconnect request is already in progress, or destroy has begun
|
||||
* - `ESP_ERR_NO_MEM` if the registry is full or the command name copy
|
||||
* cannot be allocated
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_register_command(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_command_t *command);
|
||||
|
||||
/* Async Request APIs */
|
||||
|
||||
/**
|
||||
* @brief Request one connect attempt using an explicit caller-chosen source.
|
||||
*
|
||||
* The request source remains explicit at the API boundary:
|
||||
* - saved reconnect session
|
||||
* - setup code
|
||||
* - gateway token
|
||||
* - gateway password
|
||||
* - no-auth
|
||||
*
|
||||
* @param[in] node Node handle.
|
||||
* @param[in] request Connect request to submit.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` if the request was accepted into the component queue
|
||||
* - `ESP_ERR_INVALID_ARG` if `node` or `request` is invalid
|
||||
* - `ESP_ERR_INVALID_STATE` if a session is already active, another
|
||||
* connect or disconnect request is already in progress, the saved
|
||||
* reconnect session is missing for `SAVED_SESSION`, or destroy has begun
|
||||
* - `ESP_ERR_NO_MEM` if the request could not be copied or queued
|
||||
* - `ESP_FAIL` on an unexpected local submission failure
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_request_connect(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_connect_request_t *request);
|
||||
|
||||
/**
|
||||
* @brief Request disconnect of the active session.
|
||||
*
|
||||
* @param[in] node Node handle.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` if the request was accepted into the component queue
|
||||
* - `ESP_ERR_INVALID_ARG` if `node` is `NULL`
|
||||
* - `ESP_ERR_INVALID_STATE` if no active session is present or destroy has
|
||||
* begun
|
||||
* - `ESP_ERR_NO_MEM` if the request could not be queued
|
||||
* - `ESP_FAIL` on an unexpected local submission failure
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_request_disconnect(esp_openclaw_node_handle_t node);
|
||||
|
||||
/* Inspection APIs */
|
||||
|
||||
/**
|
||||
* @brief Return the stable device identifier for this node instance.
|
||||
*
|
||||
* @param[in] node Node handle.
|
||||
*
|
||||
* @return Device identifier string, or `NULL` if @p node is `NULL`.
|
||||
*/
|
||||
const char *esp_openclaw_node_get_device_id(esp_openclaw_node_handle_t node);
|
||||
|
||||
/**
|
||||
* @brief Query whether a saved reconnect session is currently available.
|
||||
*
|
||||
* @param[in] node Node handle.
|
||||
*
|
||||
* @return `true` when a saved reconnect session is present.
|
||||
*/
|
||||
bool esp_openclaw_node_has_saved_session(esp_openclaw_node_handle_t node);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@ -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 transport_id; /* Tags the websocket instance that produced this message. */
|
||||
esp_err_t local_err;
|
||||
char *text;
|
||||
esp_openclaw_node_connect_request_source_t connect_source;
|
||||
} esp_openclaw_node_work_message_t;
|
||||
|
||||
typedef struct {
|
||||
char *name;
|
||||
esp_openclaw_node_command_handler_t handler;
|
||||
void *context;
|
||||
} esp_openclaw_node_registered_command_t;
|
||||
|
||||
typedef struct esp_openclaw_node_transport_event_ctx {
|
||||
struct esp_openclaw_node *node;
|
||||
uint32_t transport_id; /* Stable ID for one websocket transport lifetime. */
|
||||
} esp_openclaw_node_transport_event_ctx_t;
|
||||
|
||||
typedef struct {
|
||||
esp_websocket_client_handle_t (*client_init)(const esp_websocket_client_config_t *config);
|
||||
esp_err_t (*register_events)(
|
||||
esp_websocket_client_handle_t client,
|
||||
esp_websocket_event_id_t event,
|
||||
esp_event_handler_t event_handler,
|
||||
void *event_handler_arg);
|
||||
esp_err_t (*client_start)(esp_websocket_client_handle_t client);
|
||||
esp_err_t (*client_stop)(esp_websocket_client_handle_t client);
|
||||
esp_err_t (*client_destroy)(esp_websocket_client_handle_t client);
|
||||
int (*send_text)(
|
||||
esp_websocket_client_handle_t client,
|
||||
const char *data,
|
||||
int len,
|
||||
TickType_t timeout);
|
||||
int (*send_with_opcode)(
|
||||
esp_websocket_client_handle_t client,
|
||||
ws_transport_opcodes_t opcode,
|
||||
const uint8_t *data,
|
||||
int len,
|
||||
TickType_t timeout);
|
||||
} esp_openclaw_node_websocket_client_ops_t;
|
||||
|
||||
typedef enum {
|
||||
CONNECT_RESPONSE_OUTCOME_IGNORE = 0,
|
||||
CONNECT_RESPONSE_OUTCOME_CONNECTED,
|
||||
CONNECT_RESPONSE_OUTCOME_CONNECT_FAILED,
|
||||
} connect_response_outcome_t;
|
||||
|
||||
typedef struct {
|
||||
connect_response_outcome_t outcome;
|
||||
esp_err_t err;
|
||||
esp_openclaw_node_internal_state_t state;
|
||||
} connect_response_finalize_result_t;
|
||||
|
||||
struct esp_openclaw_node {
|
||||
QueueHandle_t work_queue;
|
||||
TaskHandle_t task_handle;
|
||||
SemaphoreHandle_t destroy_done;
|
||||
SemaphoreHandle_t state_lock;
|
||||
const esp_openclaw_node_websocket_client_ops_t *websocket_client_ops;
|
||||
esp_openclaw_node_identity_t identity;
|
||||
esp_openclaw_node_persisted_session_t persisted_session;
|
||||
esp_openclaw_node_config_t config;
|
||||
esp_openclaw_node_internal_state_t state;
|
||||
esp_openclaw_node_pending_control_request_t pending_control;
|
||||
esp_websocket_client_handle_t ws;
|
||||
esp_openclaw_node_transport_event_ctx_t *transport_ctx;
|
||||
uint32_t next_transport_id;
|
||||
uint32_t active_transport_id;
|
||||
bool transport_connected; /* True after the active websocket transport reports CONNECTED. */
|
||||
bool client_started; /* True after client_start() succeeds; cleanup should stop before destroy. */
|
||||
char pending_connect_id[32];
|
||||
int64_t connect_started_ms;
|
||||
char *transport_gateway_uri;
|
||||
char *rx_buffer;
|
||||
size_t rx_buffer_len;
|
||||
esp_openclaw_node_connect_request_source_t active_connect_source;
|
||||
size_t capability_count;
|
||||
char *capabilities[ESP_OPENCLAW_NODE_MAX_CAPABILITIES];
|
||||
size_t command_count;
|
||||
esp_openclaw_node_registered_command_t commands[ESP_OPENCLAW_NODE_MAX_COMMANDS];
|
||||
};
|
||||
|
||||
extern const esp_openclaw_node_websocket_client_ops_t esp_openclaw_node_default_websocket_client_ops;
|
||||
|
||||
__attribute__((weak)) const esp_openclaw_node_websocket_client_ops_t *esp_openclaw_node_test_websocket_client_ops(void);
|
||||
|
||||
const char *esp_openclaw_node_firmware_version(void);
|
||||
char *esp_openclaw_node_duplicate_string(const char *value);
|
||||
const char *esp_openclaw_node_trimmed_or_null(const char *value);
|
||||
bool esp_openclaw_node_is_valid_gateway_uri(const char *gateway_uri);
|
||||
|
||||
void esp_openclaw_node_lock_state(esp_openclaw_node_handle_t node);
|
||||
void esp_openclaw_node_unlock_state(esp_openclaw_node_handle_t node);
|
||||
|
||||
esp_err_t esp_openclaw_node_copy_config(
|
||||
const esp_openclaw_node_config_t *src,
|
||||
esp_openclaw_node_config_t *dst);
|
||||
void esp_openclaw_node_free_config_strings(esp_openclaw_node_config_t *config);
|
||||
|
||||
void esp_openclaw_node_clear_session_wait_state_locked(esp_openclaw_node_handle_t node);
|
||||
void esp_openclaw_node_set_pending_control_locked(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_pending_control_request_t kind);
|
||||
void esp_openclaw_node_clear_pending_control_locked(esp_openclaw_node_handle_t node);
|
||||
bool esp_openclaw_node_saved_session_is_present_locked(
|
||||
const esp_openclaw_node_handle_t node);
|
||||
bool esp_openclaw_node_state_is_connecting(esp_openclaw_node_internal_state_t state);
|
||||
const char *esp_openclaw_node_internal_state_name(
|
||||
esp_openclaw_node_internal_state_t state);
|
||||
void esp_openclaw_node_clear_data_buffer_locked(esp_openclaw_node_handle_t node);
|
||||
|
||||
void esp_openclaw_node_free_work_message_payload(esp_openclaw_node_work_message_t *message);
|
||||
void esp_openclaw_node_drain_work_queue(esp_openclaw_node_handle_t node);
|
||||
|
||||
void esp_openclaw_node_emit_connected(esp_openclaw_node_handle_t node);
|
||||
void esp_openclaw_node_emit_connect_failed(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_connect_failure_reason_t reason,
|
||||
esp_err_t local_err,
|
||||
const char *gateway_detail_code);
|
||||
void esp_openclaw_node_emit_disconnected(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_disconnected_reason_t reason,
|
||||
esp_err_t local_err);
|
||||
|
||||
bool esp_openclaw_node_is_node_task_context(esp_openclaw_node_handle_t node);
|
||||
|
||||
void esp_openclaw_node_clear_connect_source_struct(
|
||||
esp_openclaw_node_connect_request_source_t *source);
|
||||
esp_err_t esp_openclaw_node_build_connect_source_from_request(
|
||||
const esp_openclaw_node_connect_request_t *request,
|
||||
esp_openclaw_node_connect_request_source_t *out_source);
|
||||
const char *esp_openclaw_node_connect_source_kind_name(
|
||||
esp_openclaw_node_connect_source_kind_t kind);
|
||||
void esp_openclaw_node_free_connect_material(esp_openclaw_node_connect_material_t *material);
|
||||
esp_err_t esp_openclaw_node_resolve_active_connect_material_locked(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_connect_material_t *material);
|
||||
esp_err_t esp_openclaw_node_reserve_connect_request_locked(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_connect_request_source_t *source);
|
||||
|
||||
void esp_openclaw_node_cleanup_registry(esp_openclaw_node_handle_t node);
|
||||
esp_openclaw_node_registered_command_t *esp_openclaw_node_find_command(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *name);
|
||||
esp_err_t esp_openclaw_node_dispatch_command(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *command,
|
||||
const char *params_json,
|
||||
size_t params_len,
|
||||
char **out_payload_json,
|
||||
const char **out_error_code,
|
||||
const char **out_error_message);
|
||||
void esp_openclaw_node_add_registered_string_array(
|
||||
cJSON *parent,
|
||||
const char *name,
|
||||
char *const *items,
|
||||
size_t count);
|
||||
void esp_openclaw_node_add_registered_command_array(
|
||||
cJSON *parent,
|
||||
const char *name,
|
||||
esp_openclaw_node_handle_t node);
|
||||
esp_err_t esp_openclaw_node_register_capability_internal(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *capability);
|
||||
esp_err_t esp_openclaw_node_register_command_internal(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_command_t *command);
|
||||
|
||||
esp_err_t esp_openclaw_node_validate_tls_preflight(
|
||||
const esp_openclaw_node_config_t *config,
|
||||
const char *gateway_uri);
|
||||
esp_err_t esp_openclaw_node_start_transport_for_active_source(
|
||||
esp_openclaw_node_handle_t node);
|
||||
void esp_openclaw_node_cleanup_transport_instance(
|
||||
esp_openclaw_node_handle_t node,
|
||||
bool stop_client);
|
||||
bool esp_openclaw_node_should_accept_callback_transport_id_locked(
|
||||
esp_openclaw_node_handle_t node,
|
||||
uint32_t transport_id);
|
||||
void esp_openclaw_node_send_challenge_kick_ping(esp_openclaw_node_handle_t node);
|
||||
|
||||
void esp_openclaw_node_process_gateway_message(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *text);
|
||||
|
||||
void esp_openclaw_node_complete_connect_failed(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_connect_failure_reason_t reason,
|
||||
esp_err_t local_err,
|
||||
const char *gateway_detail_code,
|
||||
bool stop_client);
|
||||
void esp_openclaw_node_complete_disconnected(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_disconnected_reason_t reason,
|
||||
esp_err_t local_err,
|
||||
bool stop_client);
|
||||
void esp_openclaw_node_fail_if_connect_timed_out(esp_openclaw_node_handle_t node);
|
||||
esp_err_t esp_openclaw_node_enqueue_work_message(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_work_message_t *message);
|
||||
void esp_openclaw_node_enqueue_work_message_from_callback(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_work_message_t *message);
|
||||
void esp_openclaw_node_task(void *arg);
|
||||
esp_err_t esp_openclaw_node_submit_connect_request(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_connect_request_source_t *connect_source);
|
||||
esp_err_t esp_openclaw_node_submit_disconnect_request(
|
||||
esp_openclaw_node_handle_t node);
|
||||
@ -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_websocket_client_ops_t esp_openclaw_node_default_websocket_client_ops = {
|
||||
.client_init = esp_websocket_client_init,
|
||||
.register_events = esp_websocket_register_events,
|
||||
.client_start = esp_websocket_client_start,
|
||||
.client_stop = esp_websocket_client_stop,
|
||||
.client_destroy = esp_websocket_client_destroy,
|
||||
.send_text = esp_websocket_client_send_text,
|
||||
.send_with_opcode = esp_websocket_client_send_with_opcode,
|
||||
};
|
||||
|
||||
__attribute__((weak)) const esp_openclaw_node_websocket_client_ops_t *esp_openclaw_node_test_websocket_client_ops(void)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const char *esp_openclaw_node_firmware_version(void)
|
||||
{
|
||||
return esp_app_get_description()->version;
|
||||
}
|
||||
|
||||
char *esp_openclaw_node_duplicate_string(const char *value)
|
||||
{
|
||||
if (value == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
return strdup(value);
|
||||
}
|
||||
|
||||
const char *esp_openclaw_node_trimmed_or_null(const char *value)
|
||||
{
|
||||
if (value == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
while (*value == ' ' || *value == '\t' || *value == '\r' ||
|
||||
*value == '\n') {
|
||||
++value;
|
||||
}
|
||||
return value[0] != '\0' ? value : NULL;
|
||||
}
|
||||
|
||||
bool esp_openclaw_node_is_valid_gateway_uri(const char *gateway_uri)
|
||||
{
|
||||
const char *trimmed = esp_openclaw_node_trimmed_or_null(gateway_uri);
|
||||
if (trimmed == NULL) {
|
||||
return false;
|
||||
}
|
||||
return strncmp(trimmed, "ws://", 5) == 0 ||
|
||||
strncmp(trimmed, "wss://", 6) == 0;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_lock_state(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
if (node != NULL && node->state_lock != NULL) {
|
||||
xSemaphoreTakeRecursive(node->state_lock, portMAX_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
void esp_openclaw_node_unlock_state(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
if (node != NULL && node->state_lock != NULL) {
|
||||
xSemaphoreGiveRecursive(node->state_lock);
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t copy_string_field(char **dst, const char *src, bool required)
|
||||
{
|
||||
if (src == NULL || src[0] == '\0') {
|
||||
return required ? ESP_ERR_INVALID_ARG : ESP_OK;
|
||||
}
|
||||
*dst = esp_openclaw_node_duplicate_string(src);
|
||||
return *dst != NULL ? ESP_OK : ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
static esp_err_t copy_tls_cert_field(
|
||||
esp_openclaw_node_config_t *dst,
|
||||
const esp_openclaw_node_config_t *src)
|
||||
{
|
||||
if (src->tls_cert_pem == NULL) {
|
||||
dst->tls_cert_pem = NULL;
|
||||
dst->tls_cert_len = 0;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
size_t cert_len = src->tls_cert_len;
|
||||
if (cert_len == 0) {
|
||||
cert_len = strlen(src->tls_cert_pem);
|
||||
}
|
||||
|
||||
char *cert_copy = calloc(cert_len + 1U, sizeof(char));
|
||||
if (cert_copy == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
if (cert_len > 0) {
|
||||
memcpy(cert_copy, src->tls_cert_pem, cert_len);
|
||||
}
|
||||
cert_copy[cert_len] = '\0';
|
||||
|
||||
dst->tls_cert_pem = cert_copy;
|
||||
dst->tls_cert_len = src->tls_cert_len;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_free_config_strings(esp_openclaw_node_config_t *config)
|
||||
{
|
||||
free((char *)config->display_name);
|
||||
free((char *)config->platform);
|
||||
free((char *)config->device_family);
|
||||
free((char *)config->client_id);
|
||||
free((char *)config->client_mode);
|
||||
free((char *)config->role);
|
||||
free((char *)config->model_identifier);
|
||||
free((char *)config->locale);
|
||||
free((char *)config->tls_common_name);
|
||||
free((char *)config->tls_cert_pem);
|
||||
memset(config, 0, sizeof(*config));
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_copy_config(
|
||||
const esp_openclaw_node_config_t *src,
|
||||
esp_openclaw_node_config_t *dst)
|
||||
{
|
||||
const char *model_identifier =
|
||||
(src->model_identifier != NULL && src->model_identifier[0] != '\0')
|
||||
? src->model_identifier
|
||||
: CONFIG_IDF_TARGET;
|
||||
const char *locale =
|
||||
(src->locale != NULL && src->locale[0] != '\0')
|
||||
? src->locale
|
||||
: DEFAULT_LOCALE;
|
||||
|
||||
memset(dst, 0, sizeof(*dst));
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field((char **)&dst->display_name, src->display_name, true),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"display_name");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field((char **)&dst->platform, src->platform, true),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"platform");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field(
|
||||
(char **)&dst->device_family,
|
||||
src->device_family,
|
||||
true),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"device_family");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field((char **)&dst->client_id, src->client_id, true),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"client_id");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field(
|
||||
(char **)&dst->client_mode,
|
||||
src->client_mode,
|
||||
true),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"client_mode");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field((char **)&dst->role, src->role, true),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"role");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field(
|
||||
(char **)&dst->model_identifier,
|
||||
model_identifier,
|
||||
false),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"model_identifier");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field((char **)&dst->locale, locale, false),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"locale");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_string_field(
|
||||
(char **)&dst->tls_common_name,
|
||||
src->tls_common_name,
|
||||
false),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"tls_common_name");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_tls_cert_field(dst, src),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"tls_cert_pem");
|
||||
dst->use_cert_bundle = src->use_cert_bundle;
|
||||
dst->skip_cert_common_name_check = src->skip_cert_common_name_check;
|
||||
dst->event_cb = src->event_cb;
|
||||
dst->event_user_ctx = src->event_user_ctx;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_clear_session_wait_state_locked(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
node->pending_connect_id[0] = '\0';
|
||||
node->connect_started_ms = 0;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_set_pending_control_locked(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_pending_control_request_t kind)
|
||||
{
|
||||
node->pending_control = kind;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_clear_pending_control_locked(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
esp_openclaw_node_set_pending_control_locked(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_NONE);
|
||||
}
|
||||
|
||||
bool esp_openclaw_node_saved_session_is_present_locked(
|
||||
const esp_openclaw_node_handle_t node)
|
||||
{
|
||||
return esp_openclaw_node_persisted_session_is_present(&node->persisted_session);
|
||||
}
|
||||
|
||||
bool esp_openclaw_node_state_is_connecting(esp_openclaw_node_internal_state_t state)
|
||||
{
|
||||
return state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING;
|
||||
}
|
||||
|
||||
const char *esp_openclaw_node_internal_state_name(
|
||||
esp_openclaw_node_internal_state_t state)
|
||||
{
|
||||
switch (state) {
|
||||
case ESP_OPENCLAW_NODE_INTERNAL_IDLE:
|
||||
return "idle";
|
||||
case ESP_OPENCLAW_NODE_INTERNAL_CONNECTING:
|
||||
return "connecting";
|
||||
case ESP_OPENCLAW_NODE_INTERNAL_READY:
|
||||
return "ready";
|
||||
case ESP_OPENCLAW_NODE_INTERNAL_DESTROYING:
|
||||
return "destroying";
|
||||
case ESP_OPENCLAW_NODE_INTERNAL_CLOSED:
|
||||
return "closed";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
void esp_openclaw_node_clear_data_buffer_locked(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
free(node->rx_buffer);
|
||||
node->rx_buffer = NULL;
|
||||
node->rx_buffer_len = 0;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_free_work_message_payload(esp_openclaw_node_work_message_t *message)
|
||||
{
|
||||
if (message == NULL) {
|
||||
return;
|
||||
}
|
||||
free(message->text);
|
||||
message->text = NULL;
|
||||
esp_openclaw_node_clear_connect_source_struct(&message->connect_source);
|
||||
}
|
||||
|
||||
void esp_openclaw_node_drain_work_queue(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
esp_openclaw_node_work_message_t message = {0};
|
||||
while (node->work_queue != NULL &&
|
||||
xQueueReceive(node->work_queue, &message, 0) == pdTRUE) {
|
||||
esp_openclaw_node_free_work_message_payload(&message);
|
||||
}
|
||||
}
|
||||
|
||||
static void emit_event(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_event_t event,
|
||||
const void *event_data)
|
||||
{
|
||||
esp_openclaw_node_event_cb_t event_cb = NULL;
|
||||
void *event_user_ctx = NULL;
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
event_cb = node->config.event_cb;
|
||||
event_user_ctx = node->config.event_user_ctx;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
if (event_cb != NULL) {
|
||||
event_cb(node, event, event_data, event_user_ctx);
|
||||
}
|
||||
}
|
||||
|
||||
void esp_openclaw_node_emit_connected(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
emit_event(node, ESP_OPENCLAW_NODE_EVENT_CONNECTED, NULL);
|
||||
}
|
||||
|
||||
void esp_openclaw_node_emit_connect_failed(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_connect_failure_reason_t reason,
|
||||
esp_err_t local_err,
|
||||
const char *gateway_detail_code)
|
||||
{
|
||||
esp_openclaw_node_connect_failed_event_t event = {
|
||||
.reason = reason,
|
||||
.local_err = local_err,
|
||||
.gateway_detail_code = gateway_detail_code,
|
||||
};
|
||||
emit_event(node, ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED, &event);
|
||||
}
|
||||
|
||||
void esp_openclaw_node_emit_disconnected(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_disconnected_reason_t reason,
|
||||
esp_err_t local_err)
|
||||
{
|
||||
esp_openclaw_node_disconnected_event_t event = {
|
||||
.reason = reason,
|
||||
.local_err = local_err,
|
||||
};
|
||||
emit_event(node, ESP_OPENCLAW_NODE_EVENT_DISCONNECTED, &event);
|
||||
}
|
||||
|
||||
bool esp_openclaw_node_is_node_task_context(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
return node != NULL && node->task_handle != NULL &&
|
||||
xTaskGetCurrentTaskHandle() == node->task_handle;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_config_init_default(esp_openclaw_node_config_t *config)
|
||||
{
|
||||
if (config == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
memset(config, 0, sizeof(*config));
|
||||
config->display_name = DEFAULT_DISPLAY_NAME;
|
||||
config->platform = DEFAULT_PLATFORM;
|
||||
config->device_family = DEFAULT_DEVICE_FAMILY;
|
||||
config->client_id = DEFAULT_CLIENT_ID;
|
||||
config->client_mode = DEFAULT_CLIENT_MODE;
|
||||
config->role = DEFAULT_ROLE;
|
||||
config->model_identifier = CONFIG_IDF_TARGET;
|
||||
config->locale = DEFAULT_LOCALE;
|
||||
config->use_cert_bundle = true;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_create(
|
||||
const esp_openclaw_node_config_t *config,
|
||||
esp_openclaw_node_handle_t *out_node)
|
||||
{
|
||||
if (config == NULL || out_node == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
esp_openclaw_node_handle_t node = calloc(1, sizeof(*node));
|
||||
if (node == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
node->state_lock = xSemaphoreCreateRecursiveMutex();
|
||||
if (node->state_lock == NULL) {
|
||||
free(node);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
node->destroy_done = xSemaphoreCreateBinary();
|
||||
if (node->destroy_done == NULL) {
|
||||
vSemaphoreDelete(node->state_lock);
|
||||
free(node);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
node->work_queue = xQueueCreate(
|
||||
ESP_OPENCLAW_NODE_WORK_QUEUE_LENGTH,
|
||||
sizeof(esp_openclaw_node_work_message_t));
|
||||
if (node->work_queue == NULL) {
|
||||
vSemaphoreDelete(node->destroy_done);
|
||||
vSemaphoreDelete(node->state_lock);
|
||||
free(node);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
esp_err_t err = esp_openclaw_node_copy_config(config, &node->config);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_free_config_strings(&node->config);
|
||||
vQueueDelete(node->work_queue);
|
||||
vSemaphoreDelete(node->destroy_done);
|
||||
vSemaphoreDelete(node->state_lock);
|
||||
free(node);
|
||||
return err;
|
||||
}
|
||||
|
||||
err = esp_openclaw_node_identity_load_or_create(&node->identity);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_free_config_strings(&node->config);
|
||||
vQueueDelete(node->work_queue);
|
||||
vSemaphoreDelete(node->destroy_done);
|
||||
vSemaphoreDelete(node->state_lock);
|
||||
free(node);
|
||||
return err;
|
||||
}
|
||||
|
||||
err = esp_openclaw_node_persisted_session_load(&node->persisted_session);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_identity_free(&node->identity);
|
||||
esp_openclaw_node_free_config_strings(&node->config);
|
||||
vQueueDelete(node->work_queue);
|
||||
vSemaphoreDelete(node->destroy_done);
|
||||
vSemaphoreDelete(node->state_lock);
|
||||
free(node);
|
||||
return err;
|
||||
}
|
||||
|
||||
const esp_openclaw_node_websocket_client_ops_t *test_websocket_client_ops =
|
||||
esp_openclaw_node_test_websocket_client_ops();
|
||||
node->websocket_client_ops = test_websocket_client_ops != NULL
|
||||
? test_websocket_client_ops
|
||||
: &esp_openclaw_node_default_websocket_client_ops;
|
||||
node->state = ESP_OPENCLAW_NODE_INTERNAL_IDLE;
|
||||
|
||||
BaseType_t task_ok = xTaskCreate(
|
||||
esp_openclaw_node_task,
|
||||
"esp_openclaw_node",
|
||||
ESP_OPENCLAW_NODE_TASK_STACK_SIZE,
|
||||
node,
|
||||
5,
|
||||
&node->task_handle);
|
||||
if (task_ok != pdPASS) {
|
||||
esp_openclaw_node_persisted_session_free(&node->persisted_session);
|
||||
esp_openclaw_node_identity_free(&node->identity);
|
||||
esp_openclaw_node_free_config_strings(&node->config);
|
||||
vQueueDelete(node->work_queue);
|
||||
vSemaphoreDelete(node->destroy_done);
|
||||
vSemaphoreDelete(node->state_lock);
|
||||
free(node);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
*out_node = node;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_destroy(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
if (node == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (esp_openclaw_node_is_node_task_context(node)) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
if (node->state == ESP_OPENCLAW_NODE_INTERNAL_DESTROYING ||
|
||||
node->state == ESP_OPENCLAW_NODE_INTERNAL_CLOSED) {
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
SemaphoreHandle_t destroy_done = node->destroy_done;
|
||||
if (destroy_done == NULL) {
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
node->state = ESP_OPENCLAW_NODE_INTERNAL_DESTROYING;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
(void)xSemaphoreTake(destroy_done, 0);
|
||||
|
||||
esp_openclaw_node_work_message_t message = {
|
||||
.type = ESP_OPENCLAW_NODE_WORK_MSG_SHUTDOWN,
|
||||
};
|
||||
if (node->work_queue == NULL ||
|
||||
xQueueSend(node->work_queue, &message, portMAX_DELAY) != pdTRUE) {
|
||||
esp_openclaw_node_lock_state(node);
|
||||
node->state = ESP_OPENCLAW_NODE_INTERNAL_IDLE;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
if (xSemaphoreTake(destroy_done, portMAX_DELAY) != pdTRUE) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
if (node->work_queue != NULL) {
|
||||
vQueueDelete(node->work_queue);
|
||||
node->work_queue = NULL;
|
||||
}
|
||||
if (node->state_lock != NULL) {
|
||||
vSemaphoreDelete(node->state_lock);
|
||||
node->state_lock = NULL;
|
||||
}
|
||||
if (node->destroy_done != NULL) {
|
||||
vSemaphoreDelete(node->destroy_done);
|
||||
node->destroy_done = NULL;
|
||||
}
|
||||
|
||||
esp_openclaw_node_cleanup_registry(node);
|
||||
esp_openclaw_node_clear_connect_source_struct(&node->active_connect_source);
|
||||
esp_openclaw_node_persisted_session_free(&node->persisted_session);
|
||||
esp_openclaw_node_identity_free(&node->identity);
|
||||
esp_openclaw_node_free_config_strings(&node->config);
|
||||
free(node);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_register_capability(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *capability)
|
||||
{
|
||||
return esp_openclaw_node_register_capability_internal(node, capability);
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_register_command(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_command_t *command)
|
||||
{
|
||||
return esp_openclaw_node_register_command_internal(node, command);
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_request_connect(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_connect_request_t *request)
|
||||
{
|
||||
if (node == NULL || request == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
esp_openclaw_node_connect_request_source_t connect_source = {0};
|
||||
esp_err_t err =
|
||||
esp_openclaw_node_build_connect_source_from_request(
|
||||
request,
|
||||
&connect_source);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
err = esp_openclaw_node_submit_connect_request(node, &connect_source);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_clear_connect_source_struct(&connect_source);
|
||||
return err;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_request_disconnect(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
if (node == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
return esp_openclaw_node_submit_disconnect_request(node);
|
||||
}
|
||||
|
||||
const char *esp_openclaw_node_get_device_id(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
return node != NULL ? node->identity.device_id : NULL;
|
||||
}
|
||||
|
||||
bool esp_openclaw_node_has_saved_session(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
if (node == NULL) {
|
||||
return false;
|
||||
}
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool present = esp_openclaw_node_saved_session_is_present_locked(node);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
return present;
|
||||
}
|
||||
@ -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 "sha/sha_parallel_engine.h"
|
||||
#include "nvs.h"
|
||||
#include "sodium.h"
|
||||
|
||||
static const char *TAG = "esp_openclaw_node_identity";
|
||||
static const char *NVS_NAMESPACE = "openclaw";
|
||||
static const char *NVS_KEY_SEED = "device_seed";
|
||||
static const char *NVS_KEY_SESSION_VERSION = "session_v";
|
||||
static const char *NVS_KEY_SESSION_URI = "session_uri";
|
||||
static const char *NVS_KEY_SESSION_DEVICE_TOKEN = "session_dev_tok";
|
||||
#define ESP_OPENCLAW_NODE_DEVICE_AUTH_PAYLOAD_V3_FORMAT \
|
||||
"v3|%s|%s|%s|%s|%s|%" PRId64 "|%s|%s|%s|%s"
|
||||
|
||||
_Static_assert(ESP_OPENCLAW_NODE_ED25519_SEED_LEN == crypto_sign_SEEDBYTES, "unexpected libsodium seed size");
|
||||
_Static_assert(
|
||||
ESP_OPENCLAW_NODE_ED25519_PUBLIC_KEY_LEN == crypto_sign_PUBLICKEYBYTES,
|
||||
"unexpected libsodium public key size");
|
||||
_Static_assert(
|
||||
ESP_OPENCLAW_NODE_ED25519_PRIVATE_KEY_LEN == crypto_sign_SECRETKEYBYTES,
|
||||
"unexpected libsodium secret key size");
|
||||
|
||||
static esp_err_t ensure_sodium_ready(void)
|
||||
{
|
||||
int rc = sodium_init();
|
||||
if (rc < 0) {
|
||||
ESP_LOGE(TAG, "libsodium initialization failed");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void bytes_to_lower_hex(const uint8_t *input, size_t input_len, char *output, size_t output_size)
|
||||
{
|
||||
static const char HEX[] = "0123456789abcdef";
|
||||
if (output_size < (input_len * 2U) + 1U) {
|
||||
if (output_size > 0) {
|
||||
output[0] = '\0';
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (size_t i = 0; i < input_len; ++i) {
|
||||
output[(i * 2U)] = HEX[(input[i] >> 4) & 0x0f];
|
||||
output[(i * 2U) + 1U] = HEX[input[i] & 0x0f];
|
||||
}
|
||||
output[input_len * 2U] = '\0';
|
||||
}
|
||||
|
||||
static esp_err_t base64url_encode(
|
||||
const uint8_t *input,
|
||||
size_t input_len,
|
||||
char *output,
|
||||
size_t output_size)
|
||||
{
|
||||
size_t written = 0;
|
||||
int rc = mbedtls_base64_encode((unsigned char *)output, output_size, &written, input, input_len);
|
||||
if (rc != 0) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
for (size_t i = 0; i < written; ++i) {
|
||||
if (output[i] == '+') {
|
||||
output[i] = '-';
|
||||
} else if (output[i] == '/') {
|
||||
output[i] = '_';
|
||||
}
|
||||
}
|
||||
while (written > 0 && output[written - 1] == '=') {
|
||||
--written;
|
||||
}
|
||||
output[written] = '\0';
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static char *normalize_metadata(const char *value)
|
||||
{
|
||||
const char *start = value ? value : "";
|
||||
while (*start != '\0' && isspace((unsigned char)*start)) {
|
||||
++start;
|
||||
}
|
||||
const char *end = start + strlen(start);
|
||||
while (end > start && isspace((unsigned char)*(end - 1))) {
|
||||
--end;
|
||||
}
|
||||
|
||||
size_t len = (size_t)(end - start);
|
||||
char *normalized = calloc(len + 1U, sizeof(char));
|
||||
if (normalized == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
normalized[i] = (char)tolower((unsigned char)start[i]);
|
||||
}
|
||||
normalized[len] = '\0';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
static esp_err_t erase_nvs_key_if_present(nvs_handle_t nvs, const char *key)
|
||||
{
|
||||
esp_err_t err = nvs_erase_key(nvs, key);
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND) {
|
||||
return ESP_OK;
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
static esp_err_t clear_identity_bound_session_state(nvs_handle_t nvs)
|
||||
{
|
||||
ESP_RETURN_ON_ERROR(erase_nvs_key_if_present(nvs, NVS_KEY_SEED), TAG, "erase malformed seed");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
erase_nvs_key_if_present(nvs, NVS_KEY_SESSION_VERSION),
|
||||
TAG,
|
||||
"erase saved session version");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
erase_nvs_key_if_present(nvs, NVS_KEY_SESSION_URI),
|
||||
TAG,
|
||||
"erase saved session uri");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
erase_nvs_key_if_present(nvs, NVS_KEY_SESSION_DEVICE_TOKEN),
|
||||
TAG,
|
||||
"erase saved session device token");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t generate_and_store_seed(nvs_handle_t nvs, uint8_t *seed, bool *created)
|
||||
{
|
||||
esp_fill_random(seed, ESP_OPENCLAW_NODE_ED25519_SEED_LEN);
|
||||
ESP_RETURN_ON_ERROR(
|
||||
nvs_set_blob(nvs, NVS_KEY_SEED, seed, ESP_OPENCLAW_NODE_ED25519_SEED_LEN),
|
||||
TAG,
|
||||
"failed storing device seed");
|
||||
*created = true;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t load_seed_from_nvs(nvs_handle_t nvs, uint8_t *seed, bool *created)
|
||||
{
|
||||
size_t required = ESP_OPENCLAW_NODE_ED25519_SEED_LEN;
|
||||
esp_err_t err = nvs_get_blob(nvs, NVS_KEY_SEED, seed, &required);
|
||||
if (err == ESP_OK && required == ESP_OPENCLAW_NODE_ED25519_SEED_LEN) {
|
||||
*created = false;
|
||||
return ESP_OK;
|
||||
}
|
||||
if (err == ESP_OK || err == ESP_ERR_NVS_INVALID_LENGTH) {
|
||||
ESP_LOGW(
|
||||
TAG,
|
||||
"discarding malformed stored identity seed (size=%u) and clearing saved session",
|
||||
(unsigned)required);
|
||||
ESP_RETURN_ON_ERROR(
|
||||
clear_identity_bound_session_state(nvs),
|
||||
TAG,
|
||||
"clear malformed identity state");
|
||||
return generate_and_store_seed(nvs, seed, created);
|
||||
}
|
||||
if (err != ESP_ERR_NVS_NOT_FOUND) {
|
||||
return err;
|
||||
}
|
||||
|
||||
return generate_and_store_seed(nvs, seed, created);
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_identity_store_seed_if_absent(const uint8_t *seed, size_t seed_len)
|
||||
{
|
||||
if (seed == NULL || seed_len != ESP_OPENCLAW_NODE_ED25519_SEED_LEN) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
nvs_handle_t nvs = 0;
|
||||
esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "failed opening NVS namespace: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
size_t required = 0;
|
||||
err = nvs_get_blob(nvs, NVS_KEY_SEED, NULL, &required);
|
||||
if (err == ESP_OK) {
|
||||
nvs_close(nvs);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (err != ESP_ERR_NVS_NOT_FOUND) {
|
||||
nvs_close(nvs);
|
||||
return err;
|
||||
}
|
||||
|
||||
err = nvs_set_blob(nvs, NVS_KEY_SEED, seed, seed_len);
|
||||
if (err == ESP_OK) {
|
||||
err = nvs_commit(nvs);
|
||||
}
|
||||
nvs_close(nvs);
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_identity_load_or_create(esp_openclaw_node_identity_t *identity)
|
||||
{
|
||||
if (identity == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
memset(identity, 0, sizeof(*identity));
|
||||
|
||||
nvs_handle_t nvs = 0;
|
||||
esp_err_t err =
|
||||
nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "failed opening NVS namespace: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
bool created = false;
|
||||
err = load_seed_from_nvs(nvs, identity->seed, &created);
|
||||
if (err != ESP_OK) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
err = ensure_sodium_ready();
|
||||
if (err != ESP_OK) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
if (crypto_sign_seed_keypair(identity->public_key, identity->private_key, identity->seed) != 0) {
|
||||
ESP_LOGE(TAG, "failed deriving Ed25519 keypair");
|
||||
err = ESP_FAIL;
|
||||
goto fail;
|
||||
}
|
||||
|
||||
uint8_t digest[32] = {0};
|
||||
esp_sha(SHA2_256, identity->public_key, ESP_OPENCLAW_NODE_ED25519_PUBLIC_KEY_LEN, digest);
|
||||
bytes_to_lower_hex(digest, sizeof(digest), identity->device_id, sizeof(identity->device_id));
|
||||
err = base64url_encode(
|
||||
identity->public_key,
|
||||
ESP_OPENCLAW_NODE_ED25519_PUBLIC_KEY_LEN,
|
||||
identity->public_key_b64url,
|
||||
sizeof(identity->public_key_b64url));
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "failed encoding public key: %s", esp_err_to_name(err));
|
||||
goto fail;
|
||||
}
|
||||
|
||||
err = created ? nvs_commit(nvs) : ESP_OK;
|
||||
nvs_close(nvs);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "failed committing NVS: %s", esp_err_to_name(err));
|
||||
esp_openclaw_node_identity_free(identity);
|
||||
memset(identity, 0, sizeof(*identity));
|
||||
return err;
|
||||
}
|
||||
|
||||
ESP_LOGI(
|
||||
TAG,
|
||||
"device identity ready: %.12s...",
|
||||
identity->device_id);
|
||||
return ESP_OK;
|
||||
|
||||
fail:
|
||||
nvs_close(nvs);
|
||||
esp_openclaw_node_identity_free(identity);
|
||||
memset(identity, 0, sizeof(*identity));
|
||||
return err;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_identity_free(esp_openclaw_node_identity_t *identity)
|
||||
{
|
||||
if (identity == NULL) {
|
||||
return;
|
||||
}
|
||||
memset(identity, 0, sizeof(*identity));
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_identity_sign_payload(
|
||||
const esp_openclaw_node_identity_t *identity,
|
||||
const char *payload,
|
||||
char *signature_b64url,
|
||||
size_t signature_b64url_size)
|
||||
{
|
||||
if (identity == NULL || payload == NULL || signature_b64url == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
ESP_RETURN_ON_ERROR(ensure_sodium_ready(), TAG, "libsodium not ready");
|
||||
|
||||
uint8_t signature[64] = {0};
|
||||
unsigned long long signature_len = 0;
|
||||
if (crypto_sign_detached(
|
||||
signature,
|
||||
&signature_len,
|
||||
(const unsigned char *)payload,
|
||||
strlen(payload),
|
||||
identity->private_key) != 0) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
if (signature_len != sizeof(signature)) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
return base64url_encode(signature, sizeof(signature), signature_b64url, signature_b64url_size);
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_identity_build_auth_payload_v3(
|
||||
const esp_openclaw_node_identity_t *identity,
|
||||
const char *client_id,
|
||||
const char *client_mode,
|
||||
const char *role,
|
||||
const char *scopes_csv,
|
||||
int64_t signed_at_ms,
|
||||
const char *token,
|
||||
const char *nonce,
|
||||
const char *platform,
|
||||
const char *device_family,
|
||||
char **out_payload)
|
||||
{
|
||||
if (identity == NULL || client_id == NULL || client_mode == NULL || role == NULL ||
|
||||
scopes_csv == NULL || nonce == NULL || out_payload == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
char *normalized_platform = normalize_metadata(platform);
|
||||
char *normalized_family = normalize_metadata(device_family);
|
||||
if (normalized_platform == NULL || normalized_family == NULL) {
|
||||
free(normalized_platform);
|
||||
free(normalized_family);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
const char *safe_token = token ? token : "";
|
||||
int required = snprintf(
|
||||
NULL,
|
||||
0,
|
||||
ESP_OPENCLAW_NODE_DEVICE_AUTH_PAYLOAD_V3_FORMAT,
|
||||
identity->device_id,
|
||||
client_id,
|
||||
client_mode,
|
||||
role,
|
||||
scopes_csv,
|
||||
signed_at_ms,
|
||||
safe_token,
|
||||
nonce,
|
||||
normalized_platform,
|
||||
normalized_family);
|
||||
if (required < 0) {
|
||||
free(normalized_platform);
|
||||
free(normalized_family);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
char *payload = calloc((size_t)required + 1U, sizeof(char));
|
||||
if (payload == NULL) {
|
||||
free(normalized_platform);
|
||||
free(normalized_family);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
snprintf(
|
||||
payload,
|
||||
(size_t)required + 1U,
|
||||
ESP_OPENCLAW_NODE_DEVICE_AUTH_PAYLOAD_V3_FORMAT,
|
||||
identity->device_id,
|
||||
client_id,
|
||||
client_mode,
|
||||
role,
|
||||
scopes_csv,
|
||||
signed_at_ms,
|
||||
safe_token,
|
||||
nonce,
|
||||
normalized_platform,
|
||||
normalized_family);
|
||||
|
||||
free(normalized_platform);
|
||||
free(normalized_family);
|
||||
*out_payload = payload;
|
||||
return ESP_OK;
|
||||
}
|
||||
@ -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->websocket_client_ops->send_text(
|
||||
node->ws,
|
||||
json,
|
||||
(int)strlen(json),
|
||||
pdMS_TO_TICKS(5000));
|
||||
free(json);
|
||||
return written >= 0;
|
||||
}
|
||||
|
||||
static bool send_connect_request(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *nonce,
|
||||
int64_t signed_at_ms)
|
||||
{
|
||||
esp_openclaw_node_connect_material_t material = {0};
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
esp_err_t selection_err =
|
||||
esp_openclaw_node_resolve_active_connect_material_locked(node, &material);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (selection_err != ESP_OK) {
|
||||
ESP_LOGE(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"failed resolving connect material: %s",
|
||||
esp_err_to_name(selection_err));
|
||||
return false;
|
||||
}
|
||||
|
||||
char *payload = NULL;
|
||||
esp_err_t err = esp_openclaw_node_identity_build_auth_payload_v3(
|
||||
&node->identity,
|
||||
node->config.client_id,
|
||||
node->config.client_mode,
|
||||
node->config.role,
|
||||
"",
|
||||
signed_at_ms,
|
||||
material.signature_token,
|
||||
nonce,
|
||||
node->config.platform,
|
||||
node->config.device_family,
|
||||
&payload);
|
||||
if (err != ESP_OK || payload == NULL) {
|
||||
esp_openclaw_node_free_connect_material(&material);
|
||||
ESP_LOGE(ESP_OPENCLAW_NODE_TAG, "failed building auth payload");
|
||||
return false;
|
||||
}
|
||||
|
||||
char signature[ESP_OPENCLAW_NODE_SIGNATURE_B64_BUFFER_LEN] = {0};
|
||||
err = esp_openclaw_node_identity_sign_payload(
|
||||
&node->identity,
|
||||
payload,
|
||||
signature,
|
||||
sizeof(signature));
|
||||
free(payload);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_free_connect_material(&material);
|
||||
ESP_LOGE(ESP_OPENCLAW_NODE_TAG, "failed signing auth payload");
|
||||
return false;
|
||||
}
|
||||
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(root, "type", "req");
|
||||
char request_id[32] = {0};
|
||||
snprintf(
|
||||
request_id,
|
||||
sizeof(request_id),
|
||||
"connect-%" PRIu64,
|
||||
(uint64_t)esp_timer_get_time());
|
||||
cJSON_AddStringToObject(root, "id", request_id);
|
||||
cJSON_AddStringToObject(root, "method", "connect");
|
||||
|
||||
cJSON *params = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(params, "minProtocol", 3);
|
||||
cJSON_AddNumberToObject(params, "maxProtocol", 3);
|
||||
|
||||
cJSON *client = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(client, "id", node->config.client_id);
|
||||
cJSON_AddStringToObject(client, "displayName", node->config.display_name);
|
||||
cJSON_AddStringToObject(
|
||||
client,
|
||||
"version",
|
||||
esp_openclaw_node_firmware_version());
|
||||
cJSON_AddStringToObject(client, "platform", node->config.platform);
|
||||
cJSON_AddStringToObject(
|
||||
client,
|
||||
"deviceFamily",
|
||||
node->config.device_family);
|
||||
cJSON_AddStringToObject(
|
||||
client,
|
||||
"modelIdentifier",
|
||||
node->config.model_identifier);
|
||||
cJSON_AddStringToObject(client, "mode", node->config.client_mode);
|
||||
cJSON_AddItemToObject(params, "client", client);
|
||||
|
||||
cJSON_AddStringToObject(params, "role", node->config.role);
|
||||
cJSON_AddItemToObject(params, "scopes", cJSON_CreateArray());
|
||||
esp_openclaw_node_add_registered_string_array(
|
||||
params,
|
||||
"caps",
|
||||
node->capabilities,
|
||||
node->capability_count);
|
||||
esp_openclaw_node_add_registered_command_array(params, "commands", node);
|
||||
|
||||
if (material.auth_value != NULL) {
|
||||
cJSON *auth_json = cJSON_CreateObject();
|
||||
switch (material.kind) {
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_BOOTSTRAP_TOKEN:
|
||||
cJSON_AddStringToObject(
|
||||
auth_json,
|
||||
"bootstrapToken",
|
||||
material.auth_value);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SHARED_TOKEN:
|
||||
cJSON_AddStringToObject(auth_json, "token", material.auth_value);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_PASSWORD:
|
||||
cJSON_AddStringToObject(
|
||||
auth_json,
|
||||
"password",
|
||||
material.auth_value);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SAVED_SESSION:
|
||||
cJSON_AddStringToObject(
|
||||
auth_json,
|
||||
"deviceToken",
|
||||
material.auth_value);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NO_AUTH:
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NONE:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
cJSON_AddItemToObject(params, "auth", auth_json);
|
||||
}
|
||||
|
||||
char user_agent[64] = {0};
|
||||
snprintf(
|
||||
user_agent,
|
||||
sizeof(user_agent),
|
||||
"esp-openclaw-node/%s",
|
||||
esp_openclaw_node_firmware_version());
|
||||
cJSON_AddStringToObject(params, "userAgent", user_agent);
|
||||
cJSON_AddStringToObject(params, "locale", node->config.locale);
|
||||
|
||||
cJSON *device = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(device, "id", node->identity.device_id);
|
||||
cJSON_AddStringToObject(
|
||||
device,
|
||||
"publicKey",
|
||||
node->identity.public_key_b64url);
|
||||
cJSON_AddStringToObject(device, "signature", signature);
|
||||
cJSON_AddNumberToObject(device, "signedAt", (double)signed_at_ms);
|
||||
cJSON_AddStringToObject(device, "nonce", nonce);
|
||||
cJSON_AddItemToObject(params, "device", device);
|
||||
cJSON_AddItemToObject(root, "params", params);
|
||||
|
||||
bool ready_to_send = false;
|
||||
esp_openclaw_node_internal_state_t state = ESP_OPENCLAW_NODE_INTERNAL_IDLE;
|
||||
esp_openclaw_node_lock_state(node);
|
||||
state = node->state;
|
||||
ready_to_send = node->state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING &&
|
||||
node->pending_connect_id[0] == '\0';
|
||||
if (ready_to_send) {
|
||||
snprintf(
|
||||
node->pending_connect_id,
|
||||
sizeof(node->pending_connect_id),
|
||||
"%s",
|
||||
request_id);
|
||||
}
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (!ready_to_send) {
|
||||
cJSON_Delete(root);
|
||||
esp_openclaw_node_free_connect_material(&material);
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"connect request ignored in state=%s",
|
||||
esp_openclaw_node_internal_state_name(state));
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = websocket_send_json(node, root);
|
||||
cJSON_Delete(root);
|
||||
if (!ok) {
|
||||
esp_openclaw_node_lock_state(node);
|
||||
node->pending_connect_id[0] = '\0';
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
esp_openclaw_node_free_connect_material(&material);
|
||||
return false;
|
||||
}
|
||||
|
||||
ESP_LOGI(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"sent connect request using %s auth",
|
||||
esp_openclaw_node_connect_source_kind_name(material.kind));
|
||||
esp_openclaw_node_free_connect_material(&material);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void send_invoke_result(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *request_id,
|
||||
const char *node_id,
|
||||
bool ok,
|
||||
const char *payload_json,
|
||||
const char *error_code,
|
||||
const char *error_message)
|
||||
{
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(root, "type", "req");
|
||||
|
||||
char ws_request_id[32] = {0};
|
||||
snprintf(
|
||||
ws_request_id,
|
||||
sizeof(ws_request_id),
|
||||
"esp32-%" PRIu64,
|
||||
(uint64_t)esp_timer_get_time());
|
||||
cJSON_AddStringToObject(root, "id", ws_request_id);
|
||||
cJSON_AddStringToObject(root, "method", "node.invoke.result");
|
||||
|
||||
cJSON *params = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(params, "id", request_id);
|
||||
cJSON_AddStringToObject(params, "nodeId", node_id);
|
||||
cJSON_AddBoolToObject(params, "ok", ok);
|
||||
|
||||
if (ok) {
|
||||
if (payload_json != NULL && payload_json[0] != '\0') {
|
||||
cJSON_AddStringToObject(params, "payloadJSON", payload_json);
|
||||
}
|
||||
} else {
|
||||
cJSON *error = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(
|
||||
error,
|
||||
"code",
|
||||
error_code ? error_code : "INVALID_REQUEST");
|
||||
cJSON_AddStringToObject(
|
||||
error,
|
||||
"message",
|
||||
error_message ? error_message : "command failed");
|
||||
cJSON_AddItemToObject(params, "error", error);
|
||||
}
|
||||
|
||||
cJSON_AddItemToObject(root, "params", params);
|
||||
if (!websocket_send_json(node, root)) {
|
||||
ESP_LOGW(ESP_OPENCLAW_NODE_TAG, "failed sending invoke result");
|
||||
}
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
|
||||
static esp_err_t build_connect_response_session_update(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *device_token_text,
|
||||
esp_openclaw_node_persisted_session_t *update)
|
||||
{
|
||||
if (node == NULL || device_token_text == NULL || update == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
memset(update, 0, sizeof(*update));
|
||||
update->version = 1;
|
||||
update->gateway_uri = esp_openclaw_node_duplicate_string(
|
||||
esp_openclaw_node_trimmed_or_null(node->transport_gateway_uri));
|
||||
update->device_token =
|
||||
esp_openclaw_node_duplicate_string(device_token_text);
|
||||
if (update->gateway_uri == NULL || update->device_token == NULL) {
|
||||
esp_openclaw_node_persisted_session_free(update);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static connect_response_finalize_result_t finalize_connect_response_success(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_persisted_session_t *update)
|
||||
{
|
||||
connect_response_finalize_result_t result = {
|
||||
.outcome = CONNECT_RESPONSE_OUTCOME_IGNORE,
|
||||
.err = ESP_OK,
|
||||
.state = ESP_OPENCLAW_NODE_INTERNAL_IDLE,
|
||||
};
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
result.state = node->state;
|
||||
if (node->state != ESP_OPENCLAW_NODE_INTERNAL_CONNECTING ||
|
||||
node->pending_connect_id[0] == '\0') {
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.err = esp_openclaw_node_persisted_session_store(
|
||||
&node->persisted_session,
|
||||
update);
|
||||
if (result.err != ESP_OK) {
|
||||
result.outcome = CONNECT_RESPONSE_OUTCOME_CONNECT_FAILED;
|
||||
} else {
|
||||
node->state = ESP_OPENCLAW_NODE_INTERNAL_READY;
|
||||
esp_openclaw_node_clear_pending_control_locked(node);
|
||||
esp_openclaw_node_clear_session_wait_state_locked(node);
|
||||
esp_openclaw_node_clear_connect_source_struct(&node->active_connect_source);
|
||||
result.outcome = CONNECT_RESPONSE_OUTCOME_CONNECTED;
|
||||
}
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
return result;
|
||||
}
|
||||
|
||||
static void complete_connect_response_outcome(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const connect_response_finalize_result_t *result)
|
||||
{
|
||||
switch (result->outcome) {
|
||||
case CONNECT_RESPONSE_OUTCOME_IGNORE:
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"ignoring connect response in state=%s",
|
||||
esp_openclaw_node_internal_state_name(result->state));
|
||||
break;
|
||||
case CONNECT_RESPONSE_OUTCOME_CONNECT_FAILED:
|
||||
esp_openclaw_node_complete_connect_failed(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_SESSION_FINALIZATION_FAILED,
|
||||
result->err,
|
||||
NULL,
|
||||
true);
|
||||
break;
|
||||
case CONNECT_RESPONSE_OUTCOME_CONNECTED:
|
||||
esp_openclaw_node_emit_connected(node);
|
||||
ESP_LOGI(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"OpenClaw gateway handshake complete");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_connect_response(
|
||||
esp_openclaw_node_handle_t node,
|
||||
cJSON *root)
|
||||
{
|
||||
cJSON *ok = cJSON_GetObjectItemCaseSensitive(root, "ok");
|
||||
if (!cJSON_IsBool(ok)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cJSON_IsTrue(ok)) {
|
||||
cJSON *payload = cJSON_GetObjectItemCaseSensitive(root, "payload");
|
||||
cJSON *type =
|
||||
payload ? cJSON_GetObjectItemCaseSensitive(payload, "type") : NULL;
|
||||
if (!cJSON_IsString(type) ||
|
||||
strcmp(type->valuestring, "hello-ok") != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON *auth = cJSON_GetObjectItemCaseSensitive(payload, "auth");
|
||||
cJSON *device_token = auth
|
||||
? cJSON_GetObjectItemCaseSensitive(auth, "deviceToken")
|
||||
: NULL;
|
||||
const char *device_token_text = cJSON_IsString(device_token)
|
||||
? esp_openclaw_node_trimmed_or_null(device_token->valuestring)
|
||||
: NULL;
|
||||
if (device_token_text == NULL) {
|
||||
esp_openclaw_node_complete_connect_failed(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_SESSION_FINALIZATION_FAILED,
|
||||
ESP_FAIL,
|
||||
NULL,
|
||||
true);
|
||||
return;
|
||||
}
|
||||
|
||||
esp_openclaw_node_persisted_session_t update = {0};
|
||||
esp_err_t err = build_connect_response_session_update(
|
||||
node,
|
||||
device_token_text,
|
||||
&update);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_complete_connect_failed(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_SESSION_FINALIZATION_FAILED,
|
||||
err,
|
||||
NULL,
|
||||
true);
|
||||
return;
|
||||
}
|
||||
|
||||
connect_response_finalize_result_t result =
|
||||
finalize_connect_response_success(node, &update);
|
||||
esp_openclaw_node_persisted_session_free(&update);
|
||||
complete_connect_response_outcome(node, &result);
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON *error = cJSON_GetObjectItemCaseSensitive(root, "error");
|
||||
cJSON *message =
|
||||
error ? cJSON_GetObjectItemCaseSensitive(error, "message") : NULL;
|
||||
cJSON *details =
|
||||
error ? cJSON_GetObjectItemCaseSensitive(error, "details") : NULL;
|
||||
cJSON *detail_code =
|
||||
details ? cJSON_GetObjectItemCaseSensitive(details, "code") : NULL;
|
||||
cJSON *request_id =
|
||||
details ? cJSON_GetObjectItemCaseSensitive(details, "requestId") : NULL;
|
||||
const char *message_text = cJSON_IsString(message) &&
|
||||
message->valuestring != NULL
|
||||
? message->valuestring
|
||||
: "connect failed";
|
||||
const char *detail_code_text = cJSON_IsString(detail_code) &&
|
||||
detail_code->valuestring != NULL
|
||||
? detail_code->valuestring
|
||||
: NULL;
|
||||
const char *request_id_text = cJSON_IsString(request_id) &&
|
||||
request_id->valuestring != NULL
|
||||
? request_id->valuestring
|
||||
: NULL;
|
||||
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"connect rejected: %s%s%s%s%s%s",
|
||||
message_text,
|
||||
detail_code_text != NULL ? " (" : "",
|
||||
detail_code_text != NULL ? detail_code_text : "",
|
||||
detail_code_text != NULL ? ")" : "",
|
||||
request_id_text != NULL ? ", requestId=" : "",
|
||||
request_id_text != NULL ? request_id_text : "");
|
||||
|
||||
esp_openclaw_node_complete_connect_failed(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_AUTH_REJECTED,
|
||||
ESP_OK,
|
||||
detail_code_text,
|
||||
true);
|
||||
}
|
||||
|
||||
static void handle_connect_challenge(
|
||||
esp_openclaw_node_handle_t node,
|
||||
cJSON *payload)
|
||||
{
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool accept_challenge =
|
||||
node->state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING &&
|
||||
node->pending_connect_id[0] == '\0';
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (!accept_challenge) {
|
||||
ESP_LOGW(ESP_OPENCLAW_NODE_TAG, "ignoring unexpected connect.challenge");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON *nonce = cJSON_GetObjectItemCaseSensitive(payload, "nonce");
|
||||
cJSON *ts = cJSON_GetObjectItemCaseSensitive(payload, "ts");
|
||||
if (!cJSON_IsString(nonce) || nonce->valuestring == NULL ||
|
||||
nonce->valuestring[0] == '\0') {
|
||||
ESP_LOGW(ESP_OPENCLAW_NODE_TAG, "connect.challenge missing nonce");
|
||||
return;
|
||||
}
|
||||
int64_t signed_at_ms = cJSON_IsNumber(ts) ? (int64_t)ts->valuedouble : 0;
|
||||
if (signed_at_ms <= 0) {
|
||||
signed_at_ms = esp_timer_get_time() / 1000LL;
|
||||
}
|
||||
if (!send_connect_request(node, nonce->valuestring, signed_at_ms)) {
|
||||
esp_openclaw_node_complete_connect_failed(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILED,
|
||||
ESP_FAIL,
|
||||
NULL,
|
||||
true);
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_invoke_request(
|
||||
esp_openclaw_node_handle_t node,
|
||||
cJSON *payload)
|
||||
{
|
||||
cJSON *id = cJSON_GetObjectItemCaseSensitive(payload, "id");
|
||||
cJSON *node_id = cJSON_GetObjectItemCaseSensitive(payload, "nodeId");
|
||||
cJSON *command = cJSON_GetObjectItemCaseSensitive(payload, "command");
|
||||
cJSON *params_json =
|
||||
cJSON_GetObjectItemCaseSensitive(payload, "paramsJSON");
|
||||
|
||||
if (!cJSON_IsString(id) || !cJSON_IsString(node_id) ||
|
||||
!cJSON_IsString(command) || id->valuestring == NULL ||
|
||||
node_id->valuestring == NULL || command->valuestring == NULL) {
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"dropping malformed node.invoke.request");
|
||||
return;
|
||||
}
|
||||
|
||||
const char *effective_params_json = "{}";
|
||||
size_t effective_params_len = 2;
|
||||
if (cJSON_IsString(params_json) && params_json->valuestring != NULL &&
|
||||
params_json->valuestring[0] != '\0') {
|
||||
effective_params_json = params_json->valuestring;
|
||||
effective_params_len = strlen(effective_params_json);
|
||||
}
|
||||
|
||||
char *result_json = NULL;
|
||||
const char *error_code = NULL;
|
||||
const char *error_message = NULL;
|
||||
esp_err_t err = esp_openclaw_node_dispatch_command(
|
||||
node,
|
||||
command->valuestring,
|
||||
effective_params_json,
|
||||
effective_params_len,
|
||||
&result_json,
|
||||
&error_code,
|
||||
&error_message);
|
||||
if (err == ESP_OK) {
|
||||
send_invoke_result(
|
||||
node,
|
||||
id->valuestring,
|
||||
node_id->valuestring,
|
||||
true,
|
||||
result_json,
|
||||
NULL,
|
||||
NULL);
|
||||
} else {
|
||||
send_invoke_result(
|
||||
node,
|
||||
id->valuestring,
|
||||
node_id->valuestring,
|
||||
false,
|
||||
NULL,
|
||||
error_code,
|
||||
error_message);
|
||||
}
|
||||
|
||||
free(result_json);
|
||||
}
|
||||
|
||||
void esp_openclaw_node_process_gateway_message(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *text)
|
||||
{
|
||||
cJSON *root = cJSON_Parse(text);
|
||||
if (root == NULL) {
|
||||
ESP_LOGW(ESP_OPENCLAW_NODE_TAG, "invalid gateway JSON frame");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON *type = cJSON_GetObjectItemCaseSensitive(root, "type");
|
||||
if (!cJSON_IsString(type) || type->valuestring == NULL) {
|
||||
cJSON_Delete(root);
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(type->valuestring, "event") == 0) {
|
||||
cJSON *event = cJSON_GetObjectItemCaseSensitive(root, "event");
|
||||
cJSON *payload = cJSON_GetObjectItemCaseSensitive(root, "payload");
|
||||
if (cJSON_IsString(event) && event->valuestring != NULL) {
|
||||
if (strcmp(event->valuestring, "connect.challenge") == 0 &&
|
||||
cJSON_IsObject(payload)) {
|
||||
ESP_LOGI(ESP_OPENCLAW_NODE_TAG, "received connect.challenge");
|
||||
handle_connect_challenge(node, payload);
|
||||
} else if (
|
||||
strcmp(event->valuestring, "node.invoke.request") == 0 &&
|
||||
cJSON_IsObject(payload)) {
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool ready = node->state == ESP_OPENCLAW_NODE_INTERNAL_READY;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (ready) {
|
||||
handle_invoke_request(node, payload);
|
||||
} else {
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"ignoring node.invoke.request before session is ready");
|
||||
}
|
||||
} else {
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool connecting =
|
||||
esp_openclaw_node_state_is_connecting(node->state);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (connecting) {
|
||||
ESP_LOGD(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"received gateway event during connect: %s",
|
||||
event->valuestring);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (strcmp(type->valuestring, "res") == 0) {
|
||||
cJSON *id = cJSON_GetObjectItemCaseSensitive(root, "id");
|
||||
bool is_pending_connect_response = false;
|
||||
if (cJSON_IsString(id) && id->valuestring != NULL) {
|
||||
esp_openclaw_node_lock_state(node);
|
||||
is_pending_connect_response =
|
||||
node->transport_connected &&
|
||||
node->state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING &&
|
||||
node->pending_connect_id[0] != '\0' &&
|
||||
strcmp(id->valuestring, node->pending_connect_id) == 0;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
}
|
||||
if (is_pending_connect_response) {
|
||||
handle_connect_response(node, root);
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
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_transport_id_locked(
|
||||
node,
|
||||
message->transport_id);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (!accept) {
|
||||
esp_openclaw_node_free_work_message_payload(message);
|
||||
return;
|
||||
}
|
||||
if (esp_openclaw_node_enqueue_work_message(node, message) != ESP_OK) {
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"dropping websocket work item due to full queue");
|
||||
}
|
||||
}
|
||||
|
||||
static void complete_transport_event_state_after_disconnect_locked(
|
||||
esp_openclaw_node_handle_t node)
|
||||
{
|
||||
esp_openclaw_node_clear_session_wait_state_locked(node);
|
||||
node->transport_connected = false;
|
||||
node->client_started = false;
|
||||
}
|
||||
|
||||
static void handle_request_connect(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_work_message_t *message)
|
||||
{
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool ready_to_start =
|
||||
node->state == ESP_OPENCLAW_NODE_INTERNAL_IDLE &&
|
||||
node->pending_control == ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_CONNECT;
|
||||
if (!ready_to_start) {
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
return;
|
||||
}
|
||||
esp_openclaw_node_clear_pending_control_locked(node);
|
||||
esp_openclaw_node_clear_connect_source_struct(&node->active_connect_source);
|
||||
node->active_connect_source = message->connect_source;
|
||||
memset(&message->connect_source, 0, sizeof(message->connect_source));
|
||||
node->state = ESP_OPENCLAW_NODE_INTERNAL_CONNECTING;
|
||||
node->connect_started_ms = esp_timer_get_time() / 1000LL;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
esp_err_t err = esp_openclaw_node_start_transport_for_active_source(node);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_complete_connect_failed(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILED,
|
||||
err,
|
||||
NULL,
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_request_disconnect(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool request_pending =
|
||||
node->pending_control == ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_DISCONNECT;
|
||||
bool has_transport =
|
||||
node->ws != NULL || node->active_transport_id != 0;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
if (!request_pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
esp_openclaw_node_complete_disconnected(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_DISCONNECTED_REASON_REQUESTED,
|
||||
ESP_OK,
|
||||
has_transport);
|
||||
}
|
||||
|
||||
static void handle_ws_connected(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_work_message_t *message)
|
||||
{
|
||||
bool kick_challenge = false;
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool current_transport =
|
||||
node->active_transport_id == message->transport_id;
|
||||
esp_openclaw_node_internal_state_t state = node->state;
|
||||
if (current_transport) {
|
||||
node->transport_connected = true;
|
||||
}
|
||||
if (current_transport &&
|
||||
node->state == ESP_OPENCLAW_NODE_INTERNAL_CONNECTING) {
|
||||
kick_challenge = true;
|
||||
}
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
if (!current_transport) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (kick_challenge) {
|
||||
esp_openclaw_node_send_challenge_kick_ping(node);
|
||||
}
|
||||
|
||||
ESP_LOGI(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"websocket connected: transport_id=%" PRIu32 " state=%s",
|
||||
message->transport_id,
|
||||
esp_openclaw_node_internal_state_name(state));
|
||||
}
|
||||
|
||||
static void handle_ws_disconnected(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_work_message_t *message)
|
||||
{
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool current_transport =
|
||||
node->active_transport_id == message->transport_id;
|
||||
esp_openclaw_node_internal_state_t state = node->state;
|
||||
if (current_transport) {
|
||||
complete_transport_event_state_after_disconnect_locked(node);
|
||||
}
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
if (!current_transport) {
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"websocket disconnected: transport_id=%" PRIu32 " state=%s err=%s",
|
||||
message->transport_id,
|
||||
esp_openclaw_node_internal_state_name(state),
|
||||
esp_err_to_name(message->local_err));
|
||||
|
||||
if (state == ESP_OPENCLAW_NODE_INTERNAL_READY) {
|
||||
esp_openclaw_node_complete_disconnected(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_DISCONNECTED_REASON_CONNECTION_LOST,
|
||||
message->local_err,
|
||||
false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (esp_openclaw_node_state_is_connecting(state)) {
|
||||
esp_openclaw_node_complete_connect_failed(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_CONNECT_FAILURE_CONNECTION_LOST,
|
||||
message->local_err,
|
||||
NULL,
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_ws_error(
|
||||
esp_openclaw_node_handle_t node,
|
||||
const esp_openclaw_node_work_message_t *message)
|
||||
{
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool current_transport =
|
||||
node->active_transport_id == message->transport_id;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (!current_transport) {
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"websocket error: transport_id=%" PRIu32 " state=%s err=%s",
|
||||
message->transport_id,
|
||||
esp_openclaw_node_internal_state_name(node->state),
|
||||
esp_err_to_name(message->local_err));
|
||||
}
|
||||
|
||||
static void handle_shutdown_request(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
esp_openclaw_node_cleanup_transport_instance(node, true);
|
||||
esp_openclaw_node_drain_work_queue(node);
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
esp_openclaw_node_clear_data_buffer_locked(node);
|
||||
esp_openclaw_node_clear_pending_control_locked(node);
|
||||
node->state = ESP_OPENCLAW_NODE_INTERNAL_CLOSED;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
}
|
||||
|
||||
static void exit_node_task(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
SemaphoreHandle_t destroy_done = NULL;
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
node->task_handle = NULL;
|
||||
destroy_done = node->destroy_done;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
if (destroy_done != NULL) {
|
||||
xSemaphoreGive(destroy_done);
|
||||
}
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
static bool process_work_message(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_work_message_t *message)
|
||||
{
|
||||
switch (message->type) {
|
||||
case ESP_OPENCLAW_NODE_WORK_MSG_REQUEST_CONNECT:
|
||||
handle_request_connect(node, message);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_WORK_MSG_REQUEST_DISCONNECT:
|
||||
handle_request_disconnect(node);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_WORK_MSG_WS_CONNECTED:
|
||||
handle_ws_connected(node, message);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_WORK_MSG_WS_DISCONNECTED:
|
||||
handle_ws_disconnected(node, message);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_WORK_MSG_WS_ERROR:
|
||||
handle_ws_error(node, message);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_WORK_MSG_DATA: {
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool current_transport =
|
||||
node->active_transport_id == message->transport_id &&
|
||||
node->state != ESP_OPENCLAW_NODE_INTERNAL_DESTROYING &&
|
||||
node->state != ESP_OPENCLAW_NODE_INTERNAL_CLOSED;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (current_transport && message->text != NULL) {
|
||||
esp_openclaw_node_process_gateway_message(node, message->text);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_OPENCLAW_NODE_WORK_MSG_SHUTDOWN:
|
||||
handle_shutdown_request(node);
|
||||
return false;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_task(void *arg)
|
||||
{
|
||||
esp_openclaw_node_handle_t node = (esp_openclaw_node_handle_t)arg;
|
||||
|
||||
for (;;) {
|
||||
esp_openclaw_node_work_message_t message = {0};
|
||||
if (xQueueReceive(node->work_queue, &message, ESP_OPENCLAW_NODE_TASK_POLL_TICKS) != pdTRUE) {
|
||||
esp_openclaw_node_fail_if_connect_timed_out(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
do {
|
||||
bool keep_running = process_work_message(node, &message);
|
||||
esp_openclaw_node_free_work_message_payload(&message);
|
||||
if (!keep_running) {
|
||||
exit_node_task(node);
|
||||
return;
|
||||
}
|
||||
memset(&message, 0, sizeof(message));
|
||||
} while (xQueueReceive(node->work_queue, &message, 0) == pdTRUE);
|
||||
}
|
||||
}
|
||||
|
||||
static void rollback_pending_control_locked(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_pending_control_request_t expected_request,
|
||||
esp_openclaw_node_pending_control_request_t rollback_op)
|
||||
{
|
||||
if (node->pending_control == expected_request) {
|
||||
node->pending_control = rollback_op;
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t submit_pending_request(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_work_message_t *message,
|
||||
esp_openclaw_node_pending_control_request_t expected_request,
|
||||
esp_openclaw_node_pending_control_request_t rollback_op)
|
||||
{
|
||||
esp_err_t err = esp_openclaw_node_enqueue_work_message(node, message);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_lock_state(node);
|
||||
rollback_pending_control_locked(
|
||||
node,
|
||||
expected_request,
|
||||
rollback_op);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_submit_connect_request(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_connect_request_source_t *connect_source)
|
||||
{
|
||||
esp_openclaw_node_lock_state(node);
|
||||
esp_err_t err =
|
||||
esp_openclaw_node_reserve_connect_request_locked(node, connect_source);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_openclaw_node_work_message_t message = {
|
||||
.type = ESP_OPENCLAW_NODE_WORK_MSG_REQUEST_CONNECT,
|
||||
.connect_source = *connect_source,
|
||||
};
|
||||
memset(connect_source, 0, sizeof(*connect_source));
|
||||
|
||||
err = submit_pending_request(
|
||||
node,
|
||||
&message,
|
||||
ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_CONNECT,
|
||||
ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_NONE);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t reserve_disconnect_request_locked(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
if (node->state == ESP_OPENCLAW_NODE_INTERNAL_DESTROYING ||
|
||||
node->state == ESP_OPENCLAW_NODE_INTERNAL_CLOSED) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
if (node->state != ESP_OPENCLAW_NODE_INTERNAL_READY) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (node->pending_control != ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_NONE) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
esp_openclaw_node_set_pending_control_locked(
|
||||
node,
|
||||
ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_DISCONNECT);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_submit_disconnect_request(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
esp_openclaw_node_lock_state(node);
|
||||
esp_err_t err = reserve_disconnect_request_locked(node);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_openclaw_node_work_message_t message = {
|
||||
.type = ESP_OPENCLAW_NODE_WORK_MSG_REQUEST_DISCONNECT,
|
||||
};
|
||||
err = submit_pending_request(
|
||||
node,
|
||||
&message,
|
||||
ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_DISCONNECT,
|
||||
ESP_OPENCLAW_NODE_PENDING_CONTROL_REQUEST_NONE);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
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_transport_id_locked(
|
||||
esp_openclaw_node_handle_t node,
|
||||
uint32_t transport_id)
|
||||
{
|
||||
return node->state != ESP_OPENCLAW_NODE_INTERNAL_DESTROYING &&
|
||||
node->state != ESP_OPENCLAW_NODE_INTERNAL_CLOSED &&
|
||||
node->active_transport_id == transport_id &&
|
||||
transport_id != 0;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_validate_tls_preflight(
|
||||
const esp_openclaw_node_config_t *config,
|
||||
const char *gateway_uri)
|
||||
{
|
||||
if (gateway_uri != NULL && strncmp(gateway_uri, "wss://", 6) == 0) {
|
||||
if (config->tls_cert_pem == NULL && !config->use_cert_bundle) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void clear_active_transport_fields_locked(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
node->ws = NULL;
|
||||
node->transport_connected = false;
|
||||
node->client_started = false;
|
||||
node->active_transport_id = 0;
|
||||
esp_openclaw_node_clear_session_wait_state_locked(node);
|
||||
esp_openclaw_node_clear_connect_source_struct(&node->active_connect_source);
|
||||
}
|
||||
|
||||
void esp_openclaw_node_cleanup_transport_instance(
|
||||
esp_openclaw_node_handle_t node,
|
||||
bool stop_client)
|
||||
{
|
||||
esp_websocket_client_handle_t ws = NULL;
|
||||
esp_openclaw_node_transport_event_ctx_t *transport_ctx = NULL;
|
||||
char *gateway_uri = NULL;
|
||||
bool client_started = false;
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
ws = node->ws;
|
||||
transport_ctx = node->transport_ctx;
|
||||
gateway_uri = node->transport_gateway_uri;
|
||||
client_started = node->client_started;
|
||||
node->transport_ctx = NULL;
|
||||
node->transport_gateway_uri = NULL;
|
||||
clear_active_transport_fields_locked(node);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
if (ws != NULL) {
|
||||
if (stop_client && client_started) {
|
||||
node->websocket_client_ops->client_stop(ws);
|
||||
}
|
||||
node->websocket_client_ops->client_destroy(ws);
|
||||
}
|
||||
|
||||
free(transport_ctx);
|
||||
free(gateway_uri);
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
esp_openclaw_node_clear_data_buffer_locked(node);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_start_transport_for_active_source(
|
||||
esp_openclaw_node_handle_t node)
|
||||
{
|
||||
const char *gateway_uri = NULL;
|
||||
switch (node->active_connect_source.kind) {
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SAVED_SESSION:
|
||||
gateway_uri =
|
||||
esp_openclaw_node_trimmed_or_null(node->persisted_session.gateway_uri);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_BOOTSTRAP_TOKEN:
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_SHARED_TOKEN:
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_PASSWORD:
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NO_AUTH:
|
||||
gateway_uri =
|
||||
esp_openclaw_node_trimmed_or_null(node->active_connect_source.gateway_uri);
|
||||
break;
|
||||
case ESP_OPENCLAW_NODE_CONNECT_SOURCE_KIND_NONE:
|
||||
default:
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
if (gateway_uri == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_validate_tls_preflight(&node->config, gateway_uri),
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"TLS preflight failed");
|
||||
|
||||
char *gateway_uri_copy = esp_openclaw_node_duplicate_string(gateway_uri);
|
||||
if (gateway_uri_copy == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
esp_openclaw_node_transport_event_ctx_t *transport_ctx =
|
||||
calloc(1, sizeof(*transport_ctx));
|
||||
if (transport_ctx == NULL) {
|
||||
free(gateway_uri_copy);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
esp_websocket_client_config_t ws_config = {
|
||||
.uri = gateway_uri_copy,
|
||||
.disable_auto_reconnect = true,
|
||||
.enable_close_reconnect = false,
|
||||
.network_timeout_ms = 10000,
|
||||
.ping_interval_sec = ESP_OPENCLAW_NODE_WS_PING_INTERVAL_SEC,
|
||||
.pingpong_timeout_sec = ESP_OPENCLAW_NODE_WS_PINGPONG_TIMEOUT_SEC,
|
||||
.keep_alive_enable = true,
|
||||
.keep_alive_idle = 5,
|
||||
.keep_alive_interval = 5,
|
||||
.keep_alive_count = 3,
|
||||
.task_prio = 5,
|
||||
.task_stack = ESP_OPENCLAW_NODE_TRANSPORT_TASK_STACK_SIZE,
|
||||
.buffer_size = ESP_OPENCLAW_NODE_TRANSPORT_BUFFER_SIZE,
|
||||
.user_context = transport_ctx,
|
||||
};
|
||||
if (strncmp(gateway_uri_copy, "wss://", 6) == 0) {
|
||||
if (node->config.tls_cert_pem != NULL) {
|
||||
ws_config.cert_pem = node->config.tls_cert_pem;
|
||||
ws_config.cert_len = node->config.tls_cert_len;
|
||||
} else if (node->config.use_cert_bundle) {
|
||||
#if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE
|
||||
ws_config.crt_bundle_attach = esp_crt_bundle_attach;
|
||||
#else
|
||||
free(gateway_uri_copy);
|
||||
free(transport_ctx);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
#endif
|
||||
}
|
||||
if (node->config.tls_common_name != NULL &&
|
||||
node->config.tls_common_name[0] != '\0') {
|
||||
ws_config.cert_common_name = node->config.tls_common_name;
|
||||
}
|
||||
ws_config.skip_cert_common_name_check =
|
||||
node->config.skip_cert_common_name_check;
|
||||
}
|
||||
|
||||
esp_websocket_client_handle_t ws = node->websocket_client_ops->client_init(&ws_config);
|
||||
if (ws == NULL) {
|
||||
free(gateway_uri_copy);
|
||||
free(transport_ctx);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
uint32_t transport_id = 0;
|
||||
esp_openclaw_node_lock_state(node);
|
||||
transport_id = ++node->next_transport_id;
|
||||
transport_ctx->node = node;
|
||||
transport_ctx->transport_id = transport_id;
|
||||
node->transport_ctx = transport_ctx;
|
||||
node->transport_gateway_uri = gateway_uri_copy;
|
||||
node->active_transport_id = transport_id;
|
||||
node->ws = ws;
|
||||
node->client_started = false;
|
||||
node->transport_connected = false;
|
||||
esp_openclaw_node_clear_session_wait_state_locked(node);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
esp_err_t err = node->websocket_client_ops->register_events(
|
||||
ws,
|
||||
WEBSOCKET_EVENT_ANY,
|
||||
websocket_event_handler,
|
||||
transport_ctx);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_cleanup_transport_instance(node, false);
|
||||
return err;
|
||||
}
|
||||
|
||||
err = node->websocket_client_ops->client_start(ws);
|
||||
if (err != ESP_OK) {
|
||||
esp_openclaw_node_cleanup_transport_instance(node, false);
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
node->client_started = true;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
ESP_LOGI(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"node connect attempt started for %s",
|
||||
gateway_uri_copy);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_send_challenge_kick_ping(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
esp_websocket_client_handle_t ws = NULL;
|
||||
|
||||
/*
|
||||
* Some ESP-IDF websocket runtimes do not drain the first post-upgrade text
|
||||
* frame until the client transmits something. A zero-length ping forces the
|
||||
* read loop to wake and flush the already-buffered connect.challenge frame.
|
||||
*/
|
||||
esp_openclaw_node_lock_state(node);
|
||||
ws = node->ws;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
|
||||
if (ws == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
int written = node->websocket_client_ops->send_with_opcode(
|
||||
ws,
|
||||
WS_TRANSPORT_OPCODES_PING,
|
||||
NULL,
|
||||
0,
|
||||
pdMS_TO_TICKS(1000));
|
||||
if (written < 0) {
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"failed sending websocket ping to prompt connect.challenge delivery");
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t local_err_from_ws_event(
|
||||
const esp_websocket_event_data_t *data)
|
||||
{
|
||||
if (data == NULL) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (data->error_handle.esp_tls_last_esp_err != 0) {
|
||||
return data->error_handle.esp_tls_last_esp_err;
|
||||
}
|
||||
if (data->error_handle.esp_transport_sock_errno != 0) {
|
||||
return data->error_handle.esp_transport_sock_errno;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void websocket_event_handler(
|
||||
void *handler_args,
|
||||
esp_event_base_t base,
|
||||
int32_t event_id,
|
||||
void *event_data)
|
||||
{
|
||||
(void)base;
|
||||
|
||||
esp_openclaw_node_transport_event_ctx_t *transport_ctx =
|
||||
(esp_openclaw_node_transport_event_ctx_t *)handler_args;
|
||||
if (transport_ctx == NULL || transport_ctx->node == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
esp_openclaw_node_handle_t node = transport_ctx->node;
|
||||
esp_websocket_event_data_t *data =
|
||||
(esp_websocket_event_data_t *)event_data;
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
bool accept = esp_openclaw_node_should_accept_callback_transport_id_locked(
|
||||
node,
|
||||
transport_ctx->transport_id);
|
||||
esp_openclaw_node_internal_state_t state = node->state;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
if (!accept) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event_id) {
|
||||
case WEBSOCKET_EVENT_CONNECTED: {
|
||||
esp_openclaw_node_work_message_t message = {
|
||||
.type = ESP_OPENCLAW_NODE_WORK_MSG_WS_CONNECTED,
|
||||
.transport_id = transport_ctx->transport_id,
|
||||
.local_err = ESP_OK,
|
||||
};
|
||||
esp_openclaw_node_enqueue_work_message_from_callback(node, &message);
|
||||
break;
|
||||
}
|
||||
case WEBSOCKET_EVENT_DISCONNECTED:
|
||||
case WEBSOCKET_EVENT_CLOSED: {
|
||||
esp_openclaw_node_work_message_t message = {
|
||||
.type = ESP_OPENCLAW_NODE_WORK_MSG_WS_DISCONNECTED,
|
||||
.transport_id = transport_ctx->transport_id,
|
||||
.local_err = local_err_from_ws_event(data),
|
||||
};
|
||||
esp_openclaw_node_enqueue_work_message_from_callback(node, &message);
|
||||
break;
|
||||
}
|
||||
case WEBSOCKET_EVENT_ERROR: {
|
||||
esp_openclaw_node_work_message_t message = {
|
||||
.type = ESP_OPENCLAW_NODE_WORK_MSG_WS_ERROR,
|
||||
.transport_id = transport_ctx->transport_id,
|
||||
.local_err = local_err_from_ws_event(data),
|
||||
};
|
||||
esp_openclaw_node_enqueue_work_message_from_callback(node, &message);
|
||||
break;
|
||||
}
|
||||
case WEBSOCKET_EVENT_DATA:
|
||||
if (esp_openclaw_node_state_is_connecting(state)) {
|
||||
ESP_LOGD(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"ws frame during connect: transport_id=%" PRIu32 " opcode=0x%x fin=%d data_len=%d payload_len=%d offset=%d state=%s",
|
||||
transport_ctx->transport_id,
|
||||
data != NULL ? data->op_code : 0,
|
||||
data != NULL ? data->fin : 0,
|
||||
data != NULL ? data->data_len : 0,
|
||||
data != NULL ? data->payload_len : 0,
|
||||
data != NULL ? data->payload_offset : 0,
|
||||
esp_openclaw_node_internal_state_name(state));
|
||||
}
|
||||
if (data->op_code == 0x08 || data->op_code == 0x09 ||
|
||||
data->op_code == 0x0a) {
|
||||
break;
|
||||
}
|
||||
if (data->op_code != 0x01 && data->op_code != 0x00) {
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"ignoring unsupported websocket opcode=0x%x",
|
||||
data->op_code);
|
||||
break;
|
||||
}
|
||||
|
||||
esp_openclaw_node_lock_state(node);
|
||||
if (!esp_openclaw_node_should_accept_callback_transport_id_locked(
|
||||
node,
|
||||
transport_ctx->transport_id)) {
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
break;
|
||||
}
|
||||
if (data->payload_offset == 0) {
|
||||
esp_openclaw_node_clear_data_buffer_locked(node);
|
||||
node->rx_buffer =
|
||||
calloc((size_t)data->payload_len + 1U, sizeof(char));
|
||||
node->rx_buffer_len = (size_t)data->payload_len;
|
||||
if (node->rx_buffer == NULL) {
|
||||
node->rx_buffer_len = 0;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
ESP_LOGE(ESP_OPENCLAW_NODE_TAG, "failed allocating rx buffer");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (node->rx_buffer == NULL ||
|
||||
node->rx_buffer_len < (size_t)data->payload_len ||
|
||||
((size_t)data->payload_offset + (size_t)data->data_len) >
|
||||
node->rx_buffer_len) {
|
||||
esp_openclaw_node_clear_data_buffer_locked(node);
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
ESP_LOGW(
|
||||
ESP_OPENCLAW_NODE_TAG,
|
||||
"discarding malformed fragmented websocket payload");
|
||||
break;
|
||||
}
|
||||
memcpy(
|
||||
node->rx_buffer + data->payload_offset,
|
||||
data->data_ptr,
|
||||
(size_t)data->data_len);
|
||||
if (data->payload_offset + data->data_len >= data->payload_len &&
|
||||
data->fin) {
|
||||
node->rx_buffer[data->payload_len] = '\0';
|
||||
esp_openclaw_node_work_message_t message = {
|
||||
.type = ESP_OPENCLAW_NODE_WORK_MSG_DATA,
|
||||
.transport_id = transport_ctx->transport_id,
|
||||
.text = node->rx_buffer,
|
||||
};
|
||||
node->rx_buffer = NULL;
|
||||
node->rx_buffer_len = 0;
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
esp_openclaw_node_enqueue_work_message_from_callback(node, &message);
|
||||
} else {
|
||||
esp_openclaw_node_unlock_state(node);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
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,48 @@
|
||||
# esp_openclaw_node_unity_tests
|
||||
|
||||
This is the ESP-IDF Unity test app for the `esp-openclaw-node` component.
|
||||
|
||||
Files:
|
||||
|
||||
- `CMakeLists.txt`: test app project file
|
||||
- `main/test_esp_openclaw_node.c`: test cases
|
||||
|
||||
## What these tests do
|
||||
|
||||
The test app runs on the target and executes `unity_run_all_tests()` from
|
||||
`app_main()`.
|
||||
|
||||
Each test case resets NVS first. The suite focuses on component logic that does
|
||||
not require a live OpenClaw gateway.
|
||||
|
||||
## Current coverage
|
||||
|
||||
- persisted reconnect-session validation, persistence, reload, and clearing
|
||||
- identity seed persistence and auth-payload signing
|
||||
- node-config ownership for runtime-provided TLS PEM data
|
||||
- setup-code validation for malformed or ambiguous payloads
|
||||
- explicit connect-request argument validation for saved-session, token,
|
||||
password, and no-auth sources
|
||||
- auth-material selection, including the rule that password auth is not included
|
||||
in the device-signature payload
|
||||
- destroy-path notification safety
|
||||
- transport-state edge cases around challenge ping, clean close, and disconnect
|
||||
rejection while still connecting
|
||||
|
||||
## Build and run
|
||||
|
||||
Example for `esp32s3`:
|
||||
|
||||
```bash
|
||||
idf.py -C components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests set-target esp32s3
|
||||
idf.py -C components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests build
|
||||
idf.py -C components/esp-openclaw-node/test_apps/esp_openclaw_node_unity_tests -p /dev/ttyACM0 flash monitor
|
||||
```
|
||||
|
||||
The suite runs automatically at boot. A passing run ends with a summary like:
|
||||
|
||||
```text
|
||||
-----------------------
|
||||
18 Tests 0 Failures 0 Ignored
|
||||
OK
|
||||
```
|
||||
@ -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
BIN
docs/assets/esp-openclaw-node-banner.png
Normal file
BIN
docs/assets/esp-openclaw-node-banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
BIN
docs/assets/openclaw-gateway-esp-box-3-message-flow.png
Normal file
BIN
docs/assets/openclaw-gateway-esp-box-3-message-flow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 854 KiB |
119
docs/getting-started.md
Normal file
119
docs/getting-started.md
Normal file
@ -0,0 +1,119 @@
|
||||
# Getting Started
|
||||
|
||||
Use this guide to connect an ESP32 board from this repository to an existing OpenClaw gateway.
|
||||
|
||||
## What You Need
|
||||
|
||||
- OpenClaw installed and `openclaw` available on `PATH`
|
||||
- An OpenClaw gateway the board can reach
|
||||
- ESP-IDF `5.x`
|
||||
- An ESP32 board with Wi-Fi
|
||||
- A serial connection or board-specific flashing path, depending on the example
|
||||
|
||||
Commands below assume the default OpenClaw install. If you use a named profile, add `--profile <profile>` to the `openclaw` commands.
|
||||
|
||||
## Choose An Example
|
||||
|
||||
- [ESP32 Wi-Fi Node Example](../examples/esp32-node/README.md): Generic ESP32 node with Wi-Fi, GPIO, and ADC commands.
|
||||
- [ESP-BOX-3 Display Example](../examples/esp-box-3-display/README.md): ESP-BOX-3 node with Wi-Fi and display commands for the built-in screen.
|
||||
|
||||
## Prepare The Gateway
|
||||
|
||||
If the board will connect over Wi-Fi to a gateway running on another machine, set `gateway.bind` to `lan` first. The default loopback bind is only reachable from the gateway host itself.
|
||||
|
||||
Before pairing a board, set `gateway.nodes.allowCommands` for the example you are using. Each example README lists the commands to allow.
|
||||
|
||||
```bash
|
||||
openclaw config set gateway.bind lan
|
||||
openclaw config set gateway.nodes.allowCommands '<json-array-from-example>' --strict-json
|
||||
openclaw gateway restart
|
||||
openclaw gateway status --probe --json
|
||||
```
|
||||
|
||||
If the gateway stays on loopback, the board cannot reach it over Wi-Fi. If the gateway does not allow the example's commands, the node can connect and still show `commands: []`.
|
||||
|
||||
## Build And Flash
|
||||
|
||||
Use the commands from the example README for the board you are using. The general flow is:
|
||||
|
||||
```bash
|
||||
. ~/esp-idf/export.sh
|
||||
cd /path/to/example
|
||||
idf.py set-target <target>
|
||||
idf.py build
|
||||
idf.py -p <serial-port> flash monitor
|
||||
```
|
||||
|
||||
## Pair The Board With The Gateway
|
||||
|
||||
Use a setup code for the first connection, or one of the other explicit auth
|
||||
commands described below.
|
||||
|
||||
Generate one on the gateway host:
|
||||
|
||||
```bash
|
||||
openclaw qr \
|
||||
--url ws://<gateway-host-ip>:<gateway-port> \
|
||||
--setup-code-only
|
||||
```
|
||||
|
||||
The command prints a single setup code:
|
||||
|
||||
```text
|
||||
<setup-code>
|
||||
```
|
||||
|
||||
Sample setup code:
|
||||
|
||||
```text
|
||||
eyJ1cmwiOiJ3czovLzE5Mi4xNjguMS4xMDoxODc4OSIsImJvb3RzdHJhcFRva2VuIjoib2NfYm9vdHN0cmFwX2V4YW1wbGVfdG9rZW4ifQ
|
||||
```
|
||||
|
||||
If the installed gateway already resolves the correct LAN URL, you can omit `--url`.
|
||||
|
||||
After flashing, open the serial console for the board and use the REPL:
|
||||
|
||||
```text
|
||||
openclaw> status
|
||||
openclaw> wifi set <ssid> <passphrase>
|
||||
openclaw> gateway setup-code <setup-code>
|
||||
openclaw> status
|
||||
```
|
||||
|
||||
Each example README calls out the console path for that board and the commands it exposes after pairing.
|
||||
|
||||
- Use `wifi set <ssid>` for an open network, or `wifi set <ssid> <passphrase>` for a secured network.
|
||||
- The setup code contains a short-lived `bootstrapToken`, not the gateway's
|
||||
shared token. `gateway setup-code <setup-code>` requests one explicit connect
|
||||
attempt.
|
||||
- If Wi-Fi is still coming up, the REPL waits for the board to obtain an
|
||||
IP before it submits that attempt.
|
||||
- After a successful `hello-ok`, the node
|
||||
stores the issued `{ gateway_uri, device_token }` reconnect session and uses it
|
||||
on later `gateway connect` attempts.
|
||||
|
||||
Other first-connect options from the REPL are:
|
||||
|
||||
- `gateway token <ws://host:port> <token>`
|
||||
- `gateway password <ws://host:port> <password>`
|
||||
- `gateway no-auth <ws://host:port>`
|
||||
|
||||
`gateway connect` is only for reconnecting with a saved session that already
|
||||
exists on the board.
|
||||
|
||||
## Check The Node From The Gateway
|
||||
|
||||
```bash
|
||||
openclaw nodes status --json
|
||||
openclaw nodes invoke --node <node-id> --command device.info --json
|
||||
```
|
||||
|
||||
Then run one of the commands from the README for the board you are using.
|
||||
|
||||
If pairing did not complete as expected, use [Troubleshooting](./troubleshooting.md).
|
||||
|
||||
## Troubleshooting And Reference
|
||||
|
||||
- [Troubleshooting](./troubleshooting.md)
|
||||
- [Component README](../components/esp-openclaw-node/README.md)
|
||||
|
||||
135
docs/troubleshooting.md
Normal file
135
docs/troubleshooting.md
Normal file
@ -0,0 +1,135 @@
|
||||
# Troubleshooting
|
||||
|
||||
This guide covers common setup problems for the examples in this repository.
|
||||
|
||||
Commands below assume the default OpenClaw install. If you use a named profile, add `--profile <profile>` to the `openclaw` commands.
|
||||
|
||||
## Node Shows Capabilities but `commands: []`
|
||||
|
||||
This usually means one of three things:
|
||||
|
||||
- The gateway does not allow the example's commands
|
||||
- The node connected before the allowlist was fixed and has not reconnected since
|
||||
- You are looking at an older disconnected row instead of the live node session
|
||||
|
||||
Make sure the gateway includes the allowlist from the example README, then restart it:
|
||||
|
||||
```bash
|
||||
openclaw config set gateway.nodes.allowCommands '<json-array-from-example>' --strict-json
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
If the board already has a saved reconnect session, it should reconnect
|
||||
automatically once Wi-Fi and the gateway are back. Otherwise request another
|
||||
connection attempt from the board or reboot it.
|
||||
|
||||
When you check status, use the live row:
|
||||
|
||||
```bash
|
||||
openclaw nodes status --json
|
||||
```
|
||||
|
||||
Look for:
|
||||
|
||||
- `"connected": true`
|
||||
- The current `remoteIp`
|
||||
- The current `nodeId`
|
||||
|
||||
## Setup Code Is Rejected or Pairing Does Not Complete
|
||||
|
||||
Common causes:
|
||||
|
||||
- The setup code expired
|
||||
- The setup code points at the wrong gateway URL
|
||||
- The board still has saved settings from an older pairing attempt
|
||||
|
||||
Use this recovery sequence:
|
||||
|
||||
1. Generate a fresh setup code:
|
||||
|
||||
```bash
|
||||
openclaw qr \
|
||||
--url ws://<gateway-host-ip>:<gateway-port> \
|
||||
--setup-code-only
|
||||
```
|
||||
|
||||
1. Check the board state from the REPL:
|
||||
|
||||
```text
|
||||
status
|
||||
```
|
||||
|
||||
1. If the board may still have an older saved session or Wi-Fi state, erase and
|
||||
reflash it:
|
||||
|
||||
```bash
|
||||
. ~/esp-idf/export.sh
|
||||
cd /path/to/example
|
||||
idf.py -p <serial-port> erase-flash flash monitor
|
||||
```
|
||||
|
||||
1. Provision again from the REPL:
|
||||
|
||||
```text
|
||||
gateway setup-code <setup-code>
|
||||
```
|
||||
|
||||
`gateway setup-code <setup-code>` already requests the connection attempt. It
|
||||
does not require a second `gateway connect`. If Wi-Fi is still associating, the
|
||||
REPL waits for the board to obtain an IP before it submits that attempt.
|
||||
|
||||
## Node Does Not Show Up in `openclaw nodes status`
|
||||
|
||||
Check both sides:
|
||||
|
||||
On the board:
|
||||
|
||||
```text
|
||||
status
|
||||
wifi set <ssid> <passphrase>
|
||||
```
|
||||
|
||||
Use `wifi set <ssid>` instead if the network is open.
|
||||
|
||||
On the gateway host:
|
||||
|
||||
```bash
|
||||
openclaw gateway status --probe --json
|
||||
openclaw nodes status --json
|
||||
```
|
||||
|
||||
If the board is not associated to Wi-Fi yet, fix that first. If the board is on
|
||||
Wi-Fi but the gateway is not reachable, verify the `ws://<gateway-host>` URL
|
||||
embedded in the setup code or passed to `gateway token`, `gateway password`, or
|
||||
`gateway no-auth`.
|
||||
|
||||
## Pending Approvals Still Appear After Setup-Code Pairing
|
||||
|
||||
Fresh setup-code pairing should not normally require extra approval commands. If the gateway still leaves a pending device or node request, inspect and approve it as a fallback:
|
||||
|
||||
```bash
|
||||
openclaw devices approve --latest
|
||||
openclaw nodes pending --json
|
||||
openclaw nodes approve <request-id> --json
|
||||
```
|
||||
|
||||
After approval, a board that already has a saved reconnect session should
|
||||
reconnect automatically once Wi-Fi and the gateway are back. Otherwise request
|
||||
another connection attempt or reboot the board.
|
||||
|
||||
`openclaw devices` and `openclaw nodes` do different jobs:
|
||||
|
||||
- `openclaw devices` works with device pairing records
|
||||
- `openclaw nodes` works with the live node list and the commands available on each node
|
||||
|
||||
## Reset Saved State on the Board
|
||||
|
||||
Useful REPL commands:
|
||||
|
||||
- `wifi clear`
|
||||
- `reboot`
|
||||
- `gateway disconnect`
|
||||
|
||||
The examples in this repository do not expose a factory-reset or clear-saved-session REPL command.
|
||||
|
||||
When you need to forget both Wi-Fi and pairing state, use `idf.py erase-flash` before reflashing the example.
|
||||
33
examples/README.md
Normal file
33
examples/README.md
Normal file
@ -0,0 +1,33 @@
|
||||
# Examples
|
||||
|
||||
This directory contains the example applications in this repository and the shared source they build on.
|
||||
|
||||
## Available Examples
|
||||
|
||||
- [ESP32 Wi-Fi Node Example](./esp32-node/README.md) A general-purpose ESP32 node with `device.*`, `wifi.status`, `gpio.*`, and `adc.read`.
|
||||
- [ESP-BOX-3 Display Example](./esp-box-3-display/README.md) An ESP-BOX-3 node with the shared device and Wi-Fi commands plus `display.show` and `display.status`.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
- `common/` Shared source used by more than one example. This is not a standalone example.
|
||||
- `esp32-node/` The generic ESP32 example.
|
||||
- `esp-box-3-display/` The ESP-BOX-3 example.
|
||||
|
||||
## Naming Convention
|
||||
|
||||
- `*_node_cmd.c` OpenClaw Node command handlers and the function that registers those commands with the node.
|
||||
- `*_repl_cmd.c` REPL command handlers and the function that registers those commands with the console.
|
||||
- Other `.c` files Helper code, board setup, runtime services, or the main application entry point.
|
||||
|
||||
The public `esp_openclaw_node` API passes command parameters as raw JSON text. The examples parse that text with `cJSON` explicitly inside the example sources.
|
||||
|
||||
## Common Directory
|
||||
|
||||
The shared files under `common/` are split the same way:
|
||||
|
||||
- Shared device and Wi-Fi node commands: [esp_openclaw_node_common_device_node_cmd.c](./common/esp_openclaw_node_common_device_node_cmd.c)
|
||||
- Shared JSON parsing and payload helpers: [esp_openclaw_node_example_json.c](./common/esp_openclaw_node_example_json.c)
|
||||
- Shared REPL startup: [esp_openclaw_node_example_repl.c](./common/esp_openclaw_node_example_repl.c)
|
||||
- Shared REPL commands such as `status`, `wifi`, `gateway`, and `reboot`: [esp_openclaw_node_example_repl_cmd.c](./common/esp_openclaw_node_example_repl_cmd.c)
|
||||
- Shared saved-session reconnect helper: [esp_openclaw_node_example_saved_session_reconnect.c](./common/esp_openclaw_node_example_saved_session_reconnect.c)
|
||||
- Shared Wi-Fi helpers: [esp_openclaw_node_wifi.c](./common/esp_openclaw_node_wifi.c)
|
||||
252
examples/common/esp_openclaw_node_common_device_node_cmd.c
Normal file
252
examples/common/esp_openclaw_node_common_device_node_cmd.c
Normal file
@ -0,0 +1,252 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_common_device_node_cmd.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_app_desc.h"
|
||||
#include "esp_check.h"
|
||||
#include "esp_chip_info.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_mac.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_openclaw_node_example_json.h"
|
||||
#include "esp_openclaw_node_wifi.h"
|
||||
|
||||
static const char *TAG = "esp_openclaw_node_device_cmd";
|
||||
|
||||
static void bytes_to_lower_hex(const uint8_t *input, size_t input_len, char *output, size_t output_size)
|
||||
{
|
||||
static const char HEX[] = "0123456789abcdef";
|
||||
if (output_size < (input_len * 2U) + 1U) {
|
||||
if (output_size > 0) {
|
||||
output[0] = '\0';
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (size_t i = 0; i < input_len; ++i) {
|
||||
output[(i * 2U)] = HEX[(input[i] >> 4) & 0x0f];
|
||||
output[(i * 2U) + 1U] = HEX[input[i] & 0x0f];
|
||||
}
|
||||
output[input_len * 2U] = '\0';
|
||||
}
|
||||
|
||||
static const char *firmware_version(void)
|
||||
{
|
||||
return esp_app_get_description()->version;
|
||||
}
|
||||
|
||||
static const char *wifi_auth_mode_name(uint8_t authmode)
|
||||
{
|
||||
switch ((wifi_auth_mode_t)authmode) {
|
||||
case WIFI_AUTH_OPEN:
|
||||
return "open";
|
||||
case WIFI_AUTH_WEP:
|
||||
return "wep";
|
||||
case WIFI_AUTH_WPA_PSK:
|
||||
return "wpa-psk";
|
||||
case WIFI_AUTH_WPA2_PSK:
|
||||
return "wpa2-psk";
|
||||
case WIFI_AUTH_WPA_WPA2_PSK:
|
||||
return "wpa-wpa2-psk";
|
||||
case WIFI_AUTH_WPA2_ENTERPRISE:
|
||||
return "wpa2-enterprise";
|
||||
case WIFI_AUTH_WPA3_PSK:
|
||||
return "wpa3-psk";
|
||||
case WIFI_AUTH_WPA2_WPA3_PSK:
|
||||
return "wpa2-wpa3-psk";
|
||||
case WIFI_AUTH_WAPI_PSK:
|
||||
return "wapi-psk";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
static void format_mac_address(const uint8_t mac[6], char *buffer, size_t buffer_size)
|
||||
{
|
||||
snprintf(
|
||||
buffer,
|
||||
buffer_size,
|
||||
"%02x:%02x:%02x:%02x:%02x:%02x",
|
||||
mac[0],
|
||||
mac[1],
|
||||
mac[2],
|
||||
mac[3],
|
||||
mac[4],
|
||||
mac[5]);
|
||||
}
|
||||
|
||||
static void add_wifi_status_fields(cJSON *object)
|
||||
{
|
||||
esp_openclaw_node_wifi_status_t wifi = {0};
|
||||
esp_openclaw_node_wifi_get_status(&wifi);
|
||||
cJSON_AddBoolToObject(object, "connected", wifi.connected);
|
||||
cJSON_AddStringToObject(object, "ssid", wifi.ssid);
|
||||
if (wifi.ip[0] != '\0') {
|
||||
cJSON_AddStringToObject(object, "ip", wifi.ip);
|
||||
cJSON_AddStringToObject(object, "netmask", wifi.netmask);
|
||||
cJSON_AddStringToObject(object, "gateway", wifi.gateway);
|
||||
}
|
||||
if (wifi.connected) {
|
||||
cJSON_AddNumberToObject(object, "rssi", wifi.rssi);
|
||||
cJSON_AddNumberToObject(object, "channel", wifi.channel);
|
||||
cJSON_AddStringToObject(object, "authMode", wifi_auth_mode_name(wifi.authmode));
|
||||
}
|
||||
if (wifi.last_disconnect_reason != 0) {
|
||||
cJSON_AddNumberToObject(object, "lastDisconnectReason", wifi.last_disconnect_reason);
|
||||
}
|
||||
}
|
||||
|
||||
static cJSON *build_device_info_payload(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
const char *device_id = esp_openclaw_node_get_device_id(node);
|
||||
const esp_app_desc_t *app = esp_app_get_description();
|
||||
esp_chip_info_t chip = {0};
|
||||
esp_chip_info(&chip);
|
||||
|
||||
uint8_t mac[6] = {0};
|
||||
char mac_text[18] = {0};
|
||||
char app_sha256[65] = {0};
|
||||
esp_read_mac(mac, ESP_MAC_WIFI_STA);
|
||||
format_mac_address(mac, mac_text, sizeof(mac_text));
|
||||
bytes_to_lower_hex(app->app_elf_sha256, sizeof(app->app_elf_sha256), app_sha256, sizeof(app_sha256));
|
||||
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(root, "deviceId", device_id != NULL ? device_id : "");
|
||||
cJSON_AddStringToObject(root, "firmwareVersion", firmware_version());
|
||||
cJSON_AddStringToObject(root, "idfVersion", esp_get_idf_version());
|
||||
cJSON_AddStringToObject(root, "chipModel", CONFIG_IDF_TARGET);
|
||||
cJSON_AddNumberToObject(root, "chipRevision", chip.revision);
|
||||
cJSON_AddNumberToObject(root, "cores", chip.cores);
|
||||
cJSON_AddStringToObject(root, "projectName", app->project_name);
|
||||
cJSON_AddStringToObject(root, "appVersion", app->version);
|
||||
cJSON_AddStringToObject(root, "appElfSha256", app_sha256);
|
||||
cJSON_AddStringToObject(root, "wifiMac", mac_text);
|
||||
return root;
|
||||
}
|
||||
|
||||
static cJSON *build_device_status_payload(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(root, "uptimeMs", esp_timer_get_time() / 1000LL);
|
||||
cJSON_AddNumberToObject(root, "freeHeap", esp_get_free_heap_size());
|
||||
cJSON_AddNumberToObject(root, "minFreeHeap", esp_get_minimum_free_heap_size());
|
||||
cJSON_AddNumberToObject(
|
||||
root,
|
||||
"largestFreeBlock",
|
||||
heap_caps_get_largest_free_block(MALLOC_CAP_8BIT));
|
||||
cJSON_AddBoolToObject(root, "savedSessionAvailable", esp_openclaw_node_has_saved_session(node));
|
||||
cJSON *wifi = cJSON_CreateObject();
|
||||
add_wifi_status_fields(wifi);
|
||||
cJSON_AddItemToObject(root, "wifi", wifi);
|
||||
return root;
|
||||
}
|
||||
|
||||
static cJSON *build_wifi_status_payload(void)
|
||||
{
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
add_wifi_status_fields(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
static esp_err_t handle_device_info(
|
||||
esp_openclaw_node_handle_t node,
|
||||
void *context,
|
||||
const char *params_json,
|
||||
size_t params_len,
|
||||
char **out_payload_json,
|
||||
esp_openclaw_node_error_t *out_error)
|
||||
{
|
||||
(void)context;
|
||||
(void)params_json;
|
||||
(void)params_len;
|
||||
(void)out_error;
|
||||
return esp_openclaw_node_example_take_json_payload(
|
||||
build_device_info_payload(node),
|
||||
out_payload_json);
|
||||
}
|
||||
|
||||
static esp_err_t handle_device_status(
|
||||
esp_openclaw_node_handle_t node,
|
||||
void *context,
|
||||
const char *params_json,
|
||||
size_t params_len,
|
||||
char **out_payload_json,
|
||||
esp_openclaw_node_error_t *out_error)
|
||||
{
|
||||
(void)context;
|
||||
(void)params_json;
|
||||
(void)params_len;
|
||||
(void)out_error;
|
||||
return esp_openclaw_node_example_take_json_payload(
|
||||
build_device_status_payload(node),
|
||||
out_payload_json);
|
||||
}
|
||||
|
||||
static esp_err_t handle_wifi_status(
|
||||
esp_openclaw_node_handle_t node,
|
||||
void *context,
|
||||
const char *params_json,
|
||||
size_t params_len,
|
||||
char **out_payload_json,
|
||||
esp_openclaw_node_error_t *out_error)
|
||||
{
|
||||
(void)node;
|
||||
(void)context;
|
||||
(void)params_json;
|
||||
(void)params_len;
|
||||
(void)out_error;
|
||||
return esp_openclaw_node_example_take_json_payload(
|
||||
build_wifi_status_payload(),
|
||||
out_payload_json);
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_common_register_device_node_commands(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
static const esp_openclaw_node_command_t DEVICE_INFO_COMMAND = {
|
||||
.name = "device.info",
|
||||
.handler = handle_device_info,
|
||||
.context = NULL,
|
||||
};
|
||||
static const esp_openclaw_node_command_t DEVICE_STATUS_COMMAND = {
|
||||
.name = "device.status",
|
||||
.handler = handle_device_status,
|
||||
.context = NULL,
|
||||
};
|
||||
static const esp_openclaw_node_command_t WIFI_STATUS_COMMAND = {
|
||||
.name = "wifi.status",
|
||||
.handler = handle_wifi_status,
|
||||
.context = NULL,
|
||||
};
|
||||
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_register_capability(node, "device"),
|
||||
TAG,
|
||||
"registering device capability failed");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_register_capability(node, "wifi"),
|
||||
TAG,
|
||||
"registering wifi capability failed");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_register_command(node, &DEVICE_INFO_COMMAND),
|
||||
TAG,
|
||||
"registering device.info failed");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_register_command(node, &DEVICE_STATUS_COMMAND),
|
||||
TAG,
|
||||
"registering device.status failed");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_register_command(node, &WIFI_STATUS_COMMAND),
|
||||
TAG,
|
||||
"registering wifi.status failed");
|
||||
return ESP_OK;
|
||||
}
|
||||
23
examples/common/esp_openclaw_node_common_device_node_cmd.h
Normal file
23
examples/common/esp_openclaw_node_common_device_node_cmd.h
Normal file
@ -0,0 +1,23 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_openclaw_node.h"
|
||||
|
||||
/**
|
||||
* @brief Register the common device commands shared by the examples.
|
||||
*
|
||||
* The helper registers `device.info`, `device.status`, and `wifi.status`.
|
||||
*
|
||||
* @param[in] node OpenClaw Node instance to extend.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - an ESP-IDF error code if registration fails
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_common_register_device_node_commands(esp_openclaw_node_handle_t node);
|
||||
51
examples/common/esp_openclaw_node_example_json.c
Normal file
51
examples/common/esp_openclaw_node_example_json.c
Normal file
@ -0,0 +1,51 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_example_json.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
|
||||
esp_err_t esp_openclaw_node_example_parse_json_params(
|
||||
const char *params_json,
|
||||
cJSON **out_params,
|
||||
esp_openclaw_node_error_t *out_error)
|
||||
{
|
||||
if (out_params == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
*out_params = NULL;
|
||||
cJSON *params = cJSON_Parse(params_json != NULL ? params_json : "{}");
|
||||
if (params == NULL) {
|
||||
if (out_error != NULL) {
|
||||
out_error->code = "INVALID_PARAMS";
|
||||
out_error->message = "paramsJSON is not valid JSON";
|
||||
}
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
*out_params = params;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_example_take_json_payload(
|
||||
cJSON *payload,
|
||||
char **out_payload_json)
|
||||
{
|
||||
if (payload == NULL || out_payload_json == NULL) {
|
||||
cJSON_Delete(payload);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
char *json = cJSON_PrintUnformatted(payload);
|
||||
cJSON_Delete(payload);
|
||||
if (json == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
*out_payload_json = json;
|
||||
return ESP_OK;
|
||||
}
|
||||
51
examples/common/esp_openclaw_node_example_json.h
Normal file
51
examples/common/esp_openclaw_node_example_json.h
Normal file
@ -0,0 +1,51 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_openclaw_node.h"
|
||||
|
||||
/**
|
||||
* @brief Parse raw command params JSON for an example command handler.
|
||||
*
|
||||
* When @p params_json is `NULL`, this helper parses `"{}"` so example handlers
|
||||
* can treat omitted params as an empty object.
|
||||
*
|
||||
* @param[in] params_json UTF-8 JSON params from the OpenClaw command request.
|
||||
* @param[out] out_params Parsed cJSON tree owned by the caller on success.
|
||||
* @param[out] out_error Optional structured command error populated when the
|
||||
* input is not valid JSON.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - `ESP_ERR_INVALID_ARG` when the output pointer is `NULL` or the JSON
|
||||
* payload cannot be parsed
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_example_parse_json_params(
|
||||
const char *params_json,
|
||||
cJSON **out_params,
|
||||
esp_openclaw_node_error_t *out_error);
|
||||
|
||||
/**
|
||||
* @brief Serialize a cJSON payload and transfer ownership of the input tree.
|
||||
*
|
||||
* "Take" means this helper consumes @p payload. It always deletes the supplied
|
||||
* cJSON tree before returning, whether serialization succeeds or fails. On
|
||||
* success, the caller owns the returned `malloc()`-compatible JSON buffer.
|
||||
*
|
||||
* @param[in] payload cJSON payload tree to serialize and consume.
|
||||
* @param[out] out_payload_json Serialized UTF-8 JSON payload string.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - `ESP_ERR_INVALID_ARG` when an argument is invalid
|
||||
* - `ESP_ERR_NO_MEM` when serialization fails due to allocation failure
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_example_take_json_payload(
|
||||
cJSON *payload,
|
||||
char **out_payload_json);
|
||||
69
examples/common/esp_openclaw_node_example_repl.c
Normal file
69
examples/common/esp_openclaw_node_example_repl.c
Normal file
@ -0,0 +1,69 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_example_repl.h"
|
||||
#include "esp_openclaw_node_example_repl_cmd.h"
|
||||
|
||||
#include "esp_check.h"
|
||||
#include "esp_console.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
static const char *TAG = "esp_openclaw_node_repl";
|
||||
static esp_console_repl_t *s_repl;
|
||||
|
||||
esp_err_t esp_openclaw_node_example_repl_start(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
if (node == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (s_repl != NULL) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_console_register_help_command();
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_example_repl_register_commands(node),
|
||||
TAG,
|
||||
"registering REPL commands failed");
|
||||
|
||||
esp_console_repl_config_t repl_config = ESP_CONSOLE_REPL_CONFIG_DEFAULT();
|
||||
repl_config.prompt = "openclaw> ";
|
||||
repl_config.max_cmdline_length = 512;
|
||||
|
||||
const char *console_transport = NULL;
|
||||
#if defined(CONFIG_ESP_CONSOLE_UART_DEFAULT) || defined(CONFIG_ESP_CONSOLE_UART_CUSTOM)
|
||||
esp_console_dev_uart_config_t uart_config = ESP_CONSOLE_DEV_UART_CONFIG_DEFAULT();
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_console_new_repl_uart(&uart_config, &repl_config, &s_repl),
|
||||
TAG,
|
||||
"creating UART REPL failed");
|
||||
console_transport = "UART";
|
||||
#elif defined(CONFIG_ESP_CONSOLE_USB_CDC)
|
||||
esp_console_dev_usb_cdc_config_t usb_cdc_config = ESP_CONSOLE_DEV_CDC_CONFIG_DEFAULT();
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_console_new_repl_usb_cdc(&usb_cdc_config, &repl_config, &s_repl),
|
||||
TAG,
|
||||
"creating USB CDC REPL failed");
|
||||
console_transport = "USB CDC";
|
||||
#elif defined(CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG)
|
||||
esp_console_dev_usb_serial_jtag_config_t usb_serial_jtag_config =
|
||||
ESP_CONSOLE_DEV_USB_SERIAL_JTAG_CONFIG_DEFAULT();
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_console_new_repl_usb_serial_jtag(&usb_serial_jtag_config, &repl_config, &s_repl),
|
||||
TAG,
|
||||
"creating USB Serial/JTAG REPL failed");
|
||||
console_transport = "USB Serial/JTAG";
|
||||
#else
|
||||
#error Unsupported console transport for the OpenClaw REPL
|
||||
#endif
|
||||
ESP_RETURN_ON_ERROR(esp_console_start_repl(s_repl), TAG, "starting REPL failed");
|
||||
|
||||
ESP_LOGI(
|
||||
TAG,
|
||||
"%s REPL ready; use `wifi ...`, `gateway ...`, `reboot`, or `status`",
|
||||
console_transport);
|
||||
return ESP_OK;
|
||||
}
|
||||
21
examples/common/esp_openclaw_node_example_repl.h
Normal file
21
examples/common/esp_openclaw_node_example_repl.h
Normal file
@ -0,0 +1,21 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_openclaw_node.h"
|
||||
|
||||
/**
|
||||
* @brief Start the UART REPL used by the examples.
|
||||
*
|
||||
* @param[in] node OpenClaw Node instance exposed to the REPL commands.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - an ESP-IDF error code if the REPL cannot be started
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_example_repl_start(esp_openclaw_node_handle_t node);
|
||||
308
examples/common/esp_openclaw_node_example_repl_cmd.c
Normal file
308
examples/common/esp_openclaw_node_example_repl_cmd.c
Normal file
@ -0,0 +1,308 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_example_repl_cmd.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_check.h"
|
||||
#include "esp_console.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_openclaw_node_wifi.h"
|
||||
|
||||
static const char *TAG = "esp_openclaw_node_repl_cmd";
|
||||
static esp_openclaw_node_handle_t s_node;
|
||||
|
||||
#define ESP_OPENCLAW_NODE_REPL_WIFI_CONNECT_TIMEOUT_TICKS pdMS_TO_TICKS(30000)
|
||||
|
||||
static int wait_for_wifi_before_connect(const char *request_name)
|
||||
{
|
||||
if (esp_openclaw_node_wifi_is_connected()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
printf("waiting for Wi-Fi before %s connect\n", request_name);
|
||||
if (!esp_openclaw_node_wifi_wait_for_connection(ESP_OPENCLAW_NODE_REPL_WIFI_CONNECT_TIMEOUT_TICKS)) {
|
||||
printf("%s connect not sent because Wi-Fi is still not connected\n", request_name);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int request_gateway_connect(
|
||||
const esp_openclaw_node_connect_request_t *request,
|
||||
const char *request_name)
|
||||
{
|
||||
if (s_node == NULL) {
|
||||
printf("node is not initialized\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (wait_for_wifi_before_connect(request_name) != 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
esp_err_t err = esp_openclaw_node_request_connect(s_node, request);
|
||||
if (err != ESP_OK) {
|
||||
printf("%s connect request failed: %s\n", request_name, esp_err_to_name(err));
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("%s connect requested\n", request_name);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int connect_setup_code_from_repl(const char *setup_code)
|
||||
{
|
||||
if (setup_code == NULL || setup_code[0] == '\0') {
|
||||
printf("setup-code must not be empty\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const esp_openclaw_node_connect_request_t request = {
|
||||
.source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE,
|
||||
.gateway_uri = NULL,
|
||||
.value = setup_code,
|
||||
};
|
||||
return request_gateway_connect(&request, "setup-code");
|
||||
}
|
||||
|
||||
static int connect_secret_from_repl(
|
||||
const char *gateway_uri,
|
||||
esp_openclaw_node_connect_source_t source,
|
||||
const char *secret,
|
||||
const char *secret_name)
|
||||
{
|
||||
const esp_openclaw_node_connect_request_t request = {
|
||||
.source = source,
|
||||
.gateway_uri = gateway_uri,
|
||||
.value = secret,
|
||||
};
|
||||
return request_gateway_connect(&request, secret_name);
|
||||
}
|
||||
|
||||
static int connect_no_auth_from_repl(const char *gateway_uri)
|
||||
{
|
||||
const esp_openclaw_node_connect_request_t request = {
|
||||
.source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_NO_AUTH,
|
||||
.gateway_uri = gateway_uri,
|
||||
.value = NULL,
|
||||
};
|
||||
return request_gateway_connect(&request, "no-auth");
|
||||
}
|
||||
|
||||
static int reconnect_saved_session_from_repl(void)
|
||||
{
|
||||
const esp_openclaw_node_connect_request_t request =
|
||||
(esp_openclaw_node_connect_request_t){
|
||||
.source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION,
|
||||
.gateway_uri = NULL,
|
||||
.value = NULL,
|
||||
};
|
||||
return request_gateway_connect(&request, "saved-session");
|
||||
}
|
||||
|
||||
static int disconnect_node_from_repl(void)
|
||||
{
|
||||
if (s_node == NULL) {
|
||||
printf("node is not initialized\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
esp_err_t err = esp_openclaw_node_request_disconnect(s_node);
|
||||
if (err != ESP_OK) {
|
||||
printf("disconnect request failed: %s\n", esp_err_to_name(err));
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("disconnect requested\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int handle_status_command(int argc, char **argv)
|
||||
{
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
|
||||
if (s_node == NULL) {
|
||||
printf("node is not initialized\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
esp_openclaw_node_wifi_status_t wifi_status = {0};
|
||||
esp_openclaw_node_wifi_get_status(&wifi_status);
|
||||
|
||||
printf("saved session available: %s\n", esp_openclaw_node_has_saved_session(s_node) ? "yes" : "no");
|
||||
printf("wifi configured: %s\n", wifi_status.configured ? "yes" : "no");
|
||||
if (wifi_status.configured && wifi_status.ssid[0] != '\0') {
|
||||
printf("wifi ssid: %s\n", wifi_status.ssid);
|
||||
}
|
||||
printf("wifi connected: %s\n", wifi_status.connected ? "yes" : "no");
|
||||
if (!wifi_status.connected && wifi_status.last_disconnect_reason != 0) {
|
||||
printf("wifi disconnect reason: %u\n", (unsigned)wifi_status.last_disconnect_reason);
|
||||
}
|
||||
if (wifi_status.connected && wifi_status.ip[0] != '\0') {
|
||||
printf("wifi ip: %s\n", wifi_status.ip);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int handle_wifi_command(int argc, char **argv)
|
||||
{
|
||||
if (argc >= 3 && strcmp(argv[1], "set") == 0) {
|
||||
const char *passphrase = argc >= 4 ? argv[3] : "";
|
||||
esp_err_t err = esp_openclaw_node_wifi_set_credentials(argv[2], passphrase);
|
||||
if (err != ESP_OK) {
|
||||
printf("wifi set failed: %s\n", esp_err_to_name(err));
|
||||
return 1;
|
||||
}
|
||||
printf("wifi credentials saved\n");
|
||||
return 0;
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "clear") == 0) {
|
||||
esp_err_t err = esp_openclaw_node_wifi_clear_credentials();
|
||||
if (err != ESP_OK) {
|
||||
printf("wifi clear failed: %s\n", esp_err_to_name(err));
|
||||
return 1;
|
||||
}
|
||||
printf("wifi credentials cleared\n");
|
||||
return 0;
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "connect") == 0) {
|
||||
esp_err_t err = esp_openclaw_node_wifi_connect();
|
||||
if (err != ESP_OK) {
|
||||
printf("wifi connect failed: %s\n", esp_err_to_name(err));
|
||||
return 1;
|
||||
}
|
||||
printf("wifi connect requested\n");
|
||||
return 0;
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "disconnect") == 0) {
|
||||
esp_err_t err = esp_openclaw_node_wifi_disconnect();
|
||||
if (err != ESP_OK) {
|
||||
printf("wifi disconnect failed: %s\n", esp_err_to_name(err));
|
||||
return 1;
|
||||
}
|
||||
printf("wifi disconnect requested\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
printf("usage: wifi set <ssid> [passphrase]\n");
|
||||
printf(" wifi clear\n");
|
||||
printf(" wifi connect\n");
|
||||
printf(" wifi disconnect\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int handle_gateway_command(int argc, char **argv)
|
||||
{
|
||||
if (s_node == NULL) {
|
||||
printf("node is not initialized\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (argc == 3 && strcmp(argv[1], "setup-code") == 0) {
|
||||
return connect_setup_code_from_repl(argv[2]);
|
||||
}
|
||||
if (argc == 4 && strcmp(argv[1], "token") == 0) {
|
||||
return connect_secret_from_repl(
|
||||
argv[2],
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_TOKEN,
|
||||
argv[3],
|
||||
"token");
|
||||
}
|
||||
if (argc == 4 && strcmp(argv[1], "password") == 0) {
|
||||
return connect_secret_from_repl(
|
||||
argv[2],
|
||||
ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_PASSWORD,
|
||||
argv[3],
|
||||
"password");
|
||||
}
|
||||
if (argc == 3 && strcmp(argv[1], "no-auth") == 0) {
|
||||
return connect_no_auth_from_repl(argv[2]);
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "connect") == 0) {
|
||||
return reconnect_saved_session_from_repl();
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "disconnect") == 0) {
|
||||
return disconnect_node_from_repl();
|
||||
}
|
||||
|
||||
printf("usage: gateway setup-code <code>\n");
|
||||
printf(" gateway token <ws://host:port> <token>\n");
|
||||
printf(" gateway password <ws://host:port> <password>\n");
|
||||
printf(" gateway no-auth <ws://host:port>\n");
|
||||
printf(" gateway connect\n");
|
||||
printf(" gateway disconnect\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int handle_reboot_command(int argc, char **argv)
|
||||
{
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
|
||||
printf("rebooting\n");
|
||||
fflush(stdout);
|
||||
esp_restart();
|
||||
return 0;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_example_repl_register_commands(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
if (node == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
s_node = node;
|
||||
|
||||
const esp_console_cmd_t status_command = {
|
||||
.command = "status",
|
||||
.help = "Show saved-session availability and Wi-Fi state",
|
||||
.hint = NULL,
|
||||
.func = handle_status_command,
|
||||
};
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_console_cmd_register(&status_command),
|
||||
TAG,
|
||||
"registering status command failed");
|
||||
|
||||
const esp_console_cmd_t wifi_command = {
|
||||
.command = "wifi",
|
||||
.help = "Set, clear, connect, or disconnect Wi-Fi credentials",
|
||||
.hint = "set <ssid> [passphrase] | clear | connect | disconnect",
|
||||
.func = handle_wifi_command,
|
||||
};
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_console_cmd_register(&wifi_command),
|
||||
TAG,
|
||||
"registering wifi command failed");
|
||||
|
||||
const esp_console_cmd_t gateway_command = {
|
||||
.command = "gateway",
|
||||
.help = "Connect with setup-code, explicit auth, or saved session; or disconnect",
|
||||
.hint = "setup-code <code> | token <uri> <token> | password <uri> <password> | no-auth <uri> | connect | disconnect",
|
||||
.func = handle_gateway_command,
|
||||
};
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_console_cmd_register(&gateway_command),
|
||||
TAG,
|
||||
"registering gateway command failed");
|
||||
|
||||
const esp_console_cmd_t reboot_command = {
|
||||
.command = "reboot",
|
||||
.help = "Reboot the board immediately",
|
||||
.hint = NULL,
|
||||
.func = handle_reboot_command,
|
||||
};
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_console_cmd_register(&reboot_command),
|
||||
TAG,
|
||||
"registering reboot command failed");
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
21
examples/common/esp_openclaw_node_example_repl_cmd.h
Normal file
21
examples/common/esp_openclaw_node_example_repl_cmd.h
Normal file
@ -0,0 +1,21 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_openclaw_node.h"
|
||||
|
||||
/**
|
||||
* @brief Register the common REPL commands used by the examples.
|
||||
*
|
||||
* @param[in] node OpenClaw Node instance targeted by REPL actions.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - an ESP-IDF error code if registration fails
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_example_repl_register_commands(esp_openclaw_node_handle_t node);
|
||||
@ -0,0 +1,105 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_example_saved_session_reconnect.h"
|
||||
|
||||
#include "esp_openclaw_node_wifi.h"
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
static const char *TAG = "oc_reconnect";
|
||||
static const char *DEFAULT_TASK_NAME = "esp_openclaw_node_reconnect";
|
||||
|
||||
static void esp_openclaw_node_example_saved_session_reconnect_task(void *arg)
|
||||
{
|
||||
esp_openclaw_node_example_saved_session_reconnect_t *state = arg;
|
||||
const esp_openclaw_node_connect_request_t request = {
|
||||
.source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION,
|
||||
.gateway_uri = NULL,
|
||||
.value = NULL,
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
|
||||
|
||||
if (!esp_openclaw_node_wifi_wait_for_connection(portMAX_DELAY)) {
|
||||
continue;
|
||||
}
|
||||
if (!esp_openclaw_node_has_saved_session(state->node)) {
|
||||
ESP_LOGI(TAG, "saved-session reconnect skipped because no saved session is available");
|
||||
continue;
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
|
||||
esp_err_t err = esp_openclaw_node_request_connect(state->node, &request);
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "requested saved-session reconnect");
|
||||
} else if (err != ESP_ERR_INVALID_STATE) {
|
||||
ESP_LOGW(TAG, "saved-session reconnect request failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_example_saved_session_reconnect_start(
|
||||
esp_openclaw_node_example_saved_session_reconnect_t *state,
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *task_name)
|
||||
{
|
||||
if (state == NULL || node == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (state->task != NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
state->node = node;
|
||||
BaseType_t task_ok = xTaskCreate(
|
||||
esp_openclaw_node_example_saved_session_reconnect_task,
|
||||
task_name != NULL ? task_name : DEFAULT_TASK_NAME,
|
||||
4096,
|
||||
state,
|
||||
5,
|
||||
&state->task);
|
||||
if (task_ok != pdPASS) {
|
||||
state->node = NULL;
|
||||
state->task = NULL;
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
xTaskNotifyGive(state->task);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_example_saved_session_reconnect_handle_event(
|
||||
esp_openclaw_node_example_saved_session_reconnect_t *state,
|
||||
esp_openclaw_node_event_t event,
|
||||
const void *event_data)
|
||||
{
|
||||
if (state == NULL || state->task == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
bool should_retry = false;
|
||||
if (event == ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED) {
|
||||
const esp_openclaw_node_connect_failed_event_t *failed = event_data;
|
||||
should_retry =
|
||||
failed != NULL &&
|
||||
(failed->reason == ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILED ||
|
||||
failed->reason == ESP_OPENCLAW_NODE_CONNECT_FAILURE_CONNECTION_LOST);
|
||||
} else if (event == ESP_OPENCLAW_NODE_EVENT_DISCONNECTED) {
|
||||
const esp_openclaw_node_disconnected_event_t *disconnected = event_data;
|
||||
should_retry =
|
||||
disconnected != NULL &&
|
||||
disconnected->reason == ESP_OPENCLAW_NODE_DISCONNECTED_REASON_CONNECTION_LOST;
|
||||
}
|
||||
|
||||
if (should_retry) {
|
||||
xTaskNotifyGive(state->task);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_openclaw_node.h"
|
||||
|
||||
/**
|
||||
* @brief Example-owned saved-session reconnect helper state.
|
||||
*
|
||||
* The helper retries only the persisted `{ gateway_uri, device_token }`
|
||||
* reconnect path. It does not retry explicit setup-code, token, password, or
|
||||
* no-auth requests.
|
||||
*/
|
||||
typedef struct {
|
||||
esp_openclaw_node_handle_t node; /**< Node to reconnect. */
|
||||
TaskHandle_t task; /**< Background task that waits for Wi-Fi and reissues reconnects. */
|
||||
} esp_openclaw_node_example_saved_session_reconnect_t;
|
||||
|
||||
/**
|
||||
* @brief Start the example saved-session reconnect helper.
|
||||
*
|
||||
* The helper requests one saved-session connect after Wi-Fi first comes up, and
|
||||
* then retries the saved session after retryable connection-loss events.
|
||||
*
|
||||
* @param[in,out] state Helper state to initialize.
|
||||
* @param[in] node Node to reconnect.
|
||||
* @param[in] task_name Optional FreeRTOS task name. Pass `NULL` to use the
|
||||
* built-in default.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - `ESP_ERR_INVALID_ARG` if @p state or @p node is `NULL`
|
||||
* - `ESP_ERR_INVALID_STATE` if the helper is already running
|
||||
* - `ESP_ERR_NO_MEM` if the helper task could not be created
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_example_saved_session_reconnect_start(
|
||||
esp_openclaw_node_example_saved_session_reconnect_t *state,
|
||||
esp_openclaw_node_handle_t node,
|
||||
const char *task_name);
|
||||
|
||||
/**
|
||||
* @brief Feed node terminal events into the saved-session reconnect helper.
|
||||
*
|
||||
* Call this from the example's `esp_openclaw_node_event_cb_t`. The helper retries
|
||||
* only retryable transport-loss outcomes and does not retry auth rejections.
|
||||
*
|
||||
* @param[in,out] state Helper state returned from
|
||||
* @ref esp_openclaw_node_example_saved_session_reconnect_start.
|
||||
* @param[in] event Event type from the component callback.
|
||||
* @param[in] event_data Event payload pointer from the component callback.
|
||||
*/
|
||||
void esp_openclaw_node_example_saved_session_reconnect_handle_event(
|
||||
esp_openclaw_node_example_saved_session_reconnect_t *state,
|
||||
esp_openclaw_node_event_t event,
|
||||
const void *event_data);
|
||||
392
examples/common/esp_openclaw_node_wifi.c
Normal file
392
examples/common/esp_openclaw_node_wifi.c
Normal file
@ -0,0 +1,392 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_wifi.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_check.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "freertos/event_groups.h"
|
||||
#include "freertos/semphr.h"
|
||||
|
||||
static const char *TAG = "esp_openclaw_node_wifi";
|
||||
|
||||
static EventGroupHandle_t s_wifi_events;
|
||||
static SemaphoreHandle_t s_wifi_lock;
|
||||
static esp_openclaw_node_wifi_status_t s_status;
|
||||
static bool s_started;
|
||||
static bool s_should_reconnect;
|
||||
|
||||
#define WIFI_CONNECTED_BIT BIT0
|
||||
|
||||
static void clear_ip_fields_locked(void)
|
||||
{
|
||||
s_status.connected = false;
|
||||
s_status.ip[0] = '\0';
|
||||
s_status.netmask[0] = '\0';
|
||||
s_status.gateway[0] = '\0';
|
||||
s_status.rssi = 0;
|
||||
s_status.channel = 0;
|
||||
s_status.authmode = WIFI_AUTH_OPEN;
|
||||
}
|
||||
|
||||
static void clear_connection_state(void)
|
||||
{
|
||||
if (s_wifi_events != NULL) {
|
||||
xEventGroupClearBits(s_wifi_events, WIFI_CONNECTED_BIT);
|
||||
}
|
||||
if (s_wifi_lock != NULL) {
|
||||
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
|
||||
clear_ip_fields_locked();
|
||||
s_status.last_disconnect_reason = 0;
|
||||
xSemaphoreGive(s_wifi_lock);
|
||||
}
|
||||
}
|
||||
|
||||
static void update_credentials_status_locked(const char *ssid)
|
||||
{
|
||||
size_t ssid_len = ssid != NULL ? strnlen(ssid, sizeof(s_status.ssid) - 1U) : 0;
|
||||
s_status.configured = ssid_len > 0;
|
||||
s_status.ssid[0] = '\0';
|
||||
if (s_status.configured) {
|
||||
memcpy(s_status.ssid, ssid, ssid_len);
|
||||
s_status.ssid[sizeof(s_status.ssid) - 1U] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
static void update_credentials_status_from_config_locked(const wifi_config_t *config)
|
||||
{
|
||||
size_t ssid_len = strnlen((const char *)config->sta.ssid, sizeof(config->sta.ssid));
|
||||
s_status.configured = ssid_len > 0;
|
||||
s_status.ssid[0] = '\0';
|
||||
if (s_status.configured) {
|
||||
memcpy(s_status.ssid, config->sta.ssid, ssid_len);
|
||||
s_status.ssid[ssid_len] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t copy_wifi_config_field(
|
||||
uint8_t *dst,
|
||||
size_t dst_size,
|
||||
const char *value,
|
||||
const char *field_name)
|
||||
{
|
||||
memset(dst, 0, dst_size);
|
||||
if (value == NULL) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
size_t value_len = strlen(value);
|
||||
if (value_len > dst_size) {
|
||||
ESP_LOGW(
|
||||
TAG,
|
||||
"%s length %u exceeds supported maximum %u bytes",
|
||||
field_name,
|
||||
(unsigned)value_len,
|
||||
(unsigned)dst_size);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
memcpy(dst, value, value_len);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t init_wifi_config(wifi_config_t *config, const char *ssid, const char *passphrase)
|
||||
{
|
||||
memset(config, 0, sizeof(*config));
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_wifi_config_field(config->sta.ssid, sizeof(config->sta.ssid), ssid, "SSID"),
|
||||
TAG,
|
||||
"invalid SSID");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
copy_wifi_config_field(
|
||||
config->sta.password,
|
||||
sizeof(config->sta.password),
|
||||
passphrase,
|
||||
"passphrase"),
|
||||
TAG,
|
||||
"invalid passphrase");
|
||||
config->sta.threshold.authmode = WIFI_AUTH_OPEN;
|
||||
config->sta.pmf_cfg.capable = true;
|
||||
config->sta.pmf_cfg.required = false;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void wifi_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data)
|
||||
{
|
||||
(void)arg;
|
||||
|
||||
if (event_base == WIFI_EVENT) {
|
||||
switch (event_id) {
|
||||
case WIFI_EVENT_STA_START:
|
||||
if (s_wifi_lock != NULL) {
|
||||
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
|
||||
bool should_connect = s_status.configured && s_should_reconnect;
|
||||
char ssid[sizeof(s_status.ssid)] = {0};
|
||||
strncpy(ssid, s_status.ssid, sizeof(ssid) - 1U);
|
||||
xSemaphoreGive(s_wifi_lock);
|
||||
if (should_connect) {
|
||||
ESP_LOGI(TAG, "connecting to SSID %s", ssid);
|
||||
if (esp_wifi_connect() != ESP_OK) {
|
||||
ESP_LOGW(TAG, "initial wifi connect failed");
|
||||
}
|
||||
} else {
|
||||
ESP_LOGI(TAG, "wifi ready; waiting for credentials from REPL");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case WIFI_EVENT_STA_DISCONNECTED: {
|
||||
const wifi_event_sta_disconnected_t *event =
|
||||
(const wifi_event_sta_disconnected_t *)event_data;
|
||||
bool should_retry = false;
|
||||
if (s_wifi_events != NULL) {
|
||||
xEventGroupClearBits(s_wifi_events, WIFI_CONNECTED_BIT);
|
||||
}
|
||||
if (s_wifi_lock != NULL) {
|
||||
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
|
||||
clear_ip_fields_locked();
|
||||
s_status.last_disconnect_reason = (uint8_t)event->reason;
|
||||
should_retry = s_status.configured && s_should_reconnect;
|
||||
xSemaphoreGive(s_wifi_lock);
|
||||
}
|
||||
if (should_retry) {
|
||||
ESP_LOGW(TAG, "wifi disconnected, reason=%u; reconnecting", (unsigned)event->reason);
|
||||
esp_err_t err = esp_wifi_connect();
|
||||
if (err != ESP_OK && err != ESP_ERR_WIFI_CONN) {
|
||||
ESP_LOGW(TAG, "wifi reconnect failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
} else {
|
||||
ESP_LOGI(TAG, "wifi disconnected, reason=%u", (unsigned)event->reason);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
|
||||
const ip_event_got_ip_t *event = (const ip_event_got_ip_t *)event_data;
|
||||
if (s_wifi_lock != NULL) {
|
||||
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
|
||||
s_status.connected = true;
|
||||
s_status.last_disconnect_reason = 0;
|
||||
snprintf(s_status.ip, sizeof(s_status.ip), IPSTR, IP2STR(&event->ip_info.ip));
|
||||
snprintf(s_status.netmask, sizeof(s_status.netmask), IPSTR, IP2STR(&event->ip_info.netmask));
|
||||
snprintf(s_status.gateway, sizeof(s_status.gateway), IPSTR, IP2STR(&event->ip_info.gw));
|
||||
xSemaphoreGive(s_wifi_lock);
|
||||
}
|
||||
if (s_wifi_events != NULL) {
|
||||
xEventGroupSetBits(s_wifi_events, WIFI_CONNECTED_BIT);
|
||||
}
|
||||
ESP_LOGI(TAG, "got IP " IPSTR, IP2STR(&event->ip_info.ip));
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_wifi_start(void)
|
||||
{
|
||||
if (s_started) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
s_wifi_events = xEventGroupCreate();
|
||||
s_wifi_lock = xSemaphoreCreateMutex();
|
||||
if (s_wifi_events == NULL || s_wifi_lock == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
memset(&s_status, 0, sizeof(s_status));
|
||||
|
||||
esp_netif_create_default_wifi_sta();
|
||||
|
||||
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||
esp_err_t err = esp_wifi_init(&cfg);
|
||||
ESP_RETURN_ON_ERROR(err, TAG, "esp_wifi_init failed");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL),
|
||||
TAG,
|
||||
"register WIFI_EVENT failed");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL),
|
||||
TAG,
|
||||
"register IP_EVENT failed");
|
||||
|
||||
ESP_RETURN_ON_ERROR(esp_wifi_set_storage(WIFI_STORAGE_FLASH), TAG, "esp_wifi_set_storage failed");
|
||||
ESP_RETURN_ON_ERROR(esp_wifi_set_mode(WIFI_MODE_STA), TAG, "esp_wifi_set_mode failed");
|
||||
|
||||
wifi_config_t wifi_config = {0};
|
||||
err = esp_wifi_get_config(WIFI_IF_STA, &wifi_config);
|
||||
ESP_RETURN_ON_ERROR(err, TAG, "esp_wifi_get_config failed");
|
||||
|
||||
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
|
||||
update_credentials_status_from_config_locked(&wifi_config);
|
||||
s_should_reconnect = s_status.configured;
|
||||
clear_ip_fields_locked();
|
||||
s_status.last_disconnect_reason = 0;
|
||||
xSemaphoreGive(s_wifi_lock);
|
||||
|
||||
ESP_RETURN_ON_ERROR(esp_wifi_start(), TAG, "esp_wifi_start failed");
|
||||
ESP_RETURN_ON_ERROR(esp_wifi_set_ps(WIFI_PS_NONE), TAG, "esp_wifi_set_ps failed");
|
||||
|
||||
s_started = true;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_wifi_set_credentials(const char *ssid, const char *passphrase)
|
||||
{
|
||||
if (ssid == NULL || ssid[0] == '\0') {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (!s_started || s_wifi_lock == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
const char *resolved_passphrase = passphrase != NULL ? passphrase : "";
|
||||
wifi_config_t new_config = {0};
|
||||
ESP_RETURN_ON_ERROR(init_wifi_config(&new_config, ssid, resolved_passphrase), TAG, "invalid wifi config");
|
||||
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_wifi_set_config(WIFI_IF_STA, &new_config),
|
||||
TAG,
|
||||
"esp_wifi_set_config failed");
|
||||
|
||||
clear_connection_state();
|
||||
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
|
||||
update_credentials_status_locked(ssid);
|
||||
s_should_reconnect = true;
|
||||
xSemaphoreGive(s_wifi_lock);
|
||||
|
||||
esp_err_t err = esp_wifi_disconnect();
|
||||
if (err != ESP_OK && err != ESP_ERR_WIFI_NOT_CONNECT && err != ESP_ERR_WIFI_CONN) {
|
||||
return err;
|
||||
}
|
||||
err = esp_wifi_connect();
|
||||
if (err == ESP_ERR_WIFI_CONN) {
|
||||
return ESP_OK;
|
||||
}
|
||||
ESP_RETURN_ON_ERROR(err, TAG, "starting wifi connect failed");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_wifi_clear_credentials(void)
|
||||
{
|
||||
if (!s_started || s_wifi_lock == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
esp_err_t err = esp_wifi_stop();
|
||||
if (err != ESP_OK && err != ESP_ERR_WIFI_NOT_INIT) {
|
||||
return err;
|
||||
}
|
||||
|
||||
ESP_RETURN_ON_ERROR(esp_wifi_restore(), TAG, "esp_wifi_restore failed");
|
||||
ESP_RETURN_ON_ERROR(esp_wifi_set_storage(WIFI_STORAGE_FLASH), TAG, "esp_wifi_set_storage failed");
|
||||
ESP_RETURN_ON_ERROR(esp_wifi_set_mode(WIFI_MODE_STA), TAG, "esp_wifi_set_mode failed");
|
||||
|
||||
clear_connection_state();
|
||||
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
|
||||
update_credentials_status_locked(NULL);
|
||||
s_should_reconnect = false;
|
||||
xSemaphoreGive(s_wifi_lock);
|
||||
|
||||
ESP_RETURN_ON_ERROR(esp_wifi_start(), TAG, "esp_wifi_start failed");
|
||||
ESP_RETURN_ON_ERROR(esp_wifi_set_ps(WIFI_PS_NONE), TAG, "esp_wifi_set_ps failed");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_wifi_connect(void)
|
||||
{
|
||||
if (!s_started || s_wifi_lock == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
|
||||
bool have_credentials = s_status.configured;
|
||||
s_should_reconnect = have_credentials;
|
||||
xSemaphoreGive(s_wifi_lock);
|
||||
if (!have_credentials) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
esp_err_t err = esp_wifi_connect();
|
||||
if (err == ESP_ERR_WIFI_CONN) {
|
||||
return ESP_OK;
|
||||
}
|
||||
ESP_RETURN_ON_ERROR(err, TAG, "starting wifi connect failed");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_wifi_disconnect(void)
|
||||
{
|
||||
if (!s_started || s_wifi_lock == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
clear_connection_state();
|
||||
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
|
||||
s_should_reconnect = false;
|
||||
xSemaphoreGive(s_wifi_lock);
|
||||
|
||||
esp_err_t err = esp_wifi_disconnect();
|
||||
if (err == ESP_ERR_WIFI_NOT_CONNECT) {
|
||||
err = ESP_OK;
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
bool esp_openclaw_node_wifi_wait_for_connection(TickType_t timeout_ticks)
|
||||
{
|
||||
if (s_wifi_events == NULL) {
|
||||
return false;
|
||||
}
|
||||
EventBits_t bits = xEventGroupWaitBits(
|
||||
s_wifi_events,
|
||||
WIFI_CONNECTED_BIT,
|
||||
pdFALSE,
|
||||
pdFALSE,
|
||||
timeout_ticks);
|
||||
return (bits & WIFI_CONNECTED_BIT) != 0;
|
||||
}
|
||||
|
||||
bool esp_openclaw_node_wifi_is_connected(void)
|
||||
{
|
||||
if (s_wifi_lock == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool connected = false;
|
||||
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
|
||||
connected = s_status.connected;
|
||||
xSemaphoreGive(s_wifi_lock);
|
||||
return connected;
|
||||
}
|
||||
|
||||
void esp_openclaw_node_wifi_get_status(esp_openclaw_node_wifi_status_t *status)
|
||||
{
|
||||
if (status == NULL) {
|
||||
return;
|
||||
}
|
||||
memset(status, 0, sizeof(*status));
|
||||
if (s_wifi_lock == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_wifi_lock, portMAX_DELAY);
|
||||
*status = s_status;
|
||||
xSemaphoreGive(s_wifi_lock);
|
||||
|
||||
if (status->connected) {
|
||||
wifi_ap_record_t ap_info = {0};
|
||||
if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) {
|
||||
status->rssi = ap_info.rssi;
|
||||
status->channel = ap_info.primary;
|
||||
status->authmode = (uint8_t)ap_info.authmode;
|
||||
}
|
||||
}
|
||||
}
|
||||
77
examples/common/esp_openclaw_node_wifi.h
Normal file
77
examples/common/esp_openclaw_node_wifi.h
Normal file
@ -0,0 +1,77 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
|
||||
/** @brief Snapshot of the example Wi-Fi station state. */
|
||||
typedef struct {
|
||||
bool configured; /**< Whether station credentials are currently stored in NVS. */
|
||||
bool connected; /**< Whether the station currently has a live connection and IP. */
|
||||
char ssid[33]; /**< Saved station SSID, when configured. */
|
||||
char ip[16]; /**< Current IPv4 address in dotted-quad form when connected. */
|
||||
char netmask[16]; /**< Current IPv4 netmask in dotted-quad form when connected. */
|
||||
char gateway[16]; /**< Current IPv4 gateway in dotted-quad form when connected. */
|
||||
int8_t rssi; /**< Current RSSI from `esp_wifi_sta_get_ap_info()` when connected. */
|
||||
uint8_t channel; /**< Current primary Wi-Fi channel when connected. */
|
||||
uint8_t authmode; /**< Current AP auth mode reported by ESP-IDF when connected. */
|
||||
uint8_t last_disconnect_reason; /**< Last ESP-IDF disconnect reason code, or `0` when clear. */
|
||||
} esp_openclaw_node_wifi_status_t;
|
||||
|
||||
/**
|
||||
* @brief Initialize the example Wi-Fi station support.
|
||||
*
|
||||
* The helper restores any previously stored station credentials from NVS and
|
||||
* starts the ESP-IDF station interface.
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_wifi_start(void);
|
||||
|
||||
/**
|
||||
* @brief Persist Wi-Fi station credentials and start connecting immediately.
|
||||
*
|
||||
* Passing `NULL` for @p passphrase is treated the same as an empty string.
|
||||
* The helper rejects values that do not fit the ESP-IDF station config fields
|
||||
* exactly, instead of truncating them before storage.
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_wifi_set_credentials(const char *ssid, const char *passphrase);
|
||||
|
||||
/**
|
||||
* @brief Remove any persisted Wi-Fi station credentials.
|
||||
*
|
||||
* This helper clears the example's station configuration from flash and
|
||||
* restarts the station in an unconfigured state.
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_wifi_clear_credentials(void);
|
||||
|
||||
/** @brief Start or resume a Wi-Fi station connection attempt using saved credentials. */
|
||||
esp_err_t esp_openclaw_node_wifi_connect(void);
|
||||
|
||||
/** @brief Disconnect the Wi-Fi station if it is currently connected. */
|
||||
esp_err_t esp_openclaw_node_wifi_disconnect(void);
|
||||
|
||||
/**
|
||||
* @brief Wait for the station to connect.
|
||||
*
|
||||
* @param[in] timeout_ticks Maximum time to wait, in FreeRTOS ticks.
|
||||
*
|
||||
* @return `true` if the station connected before the timeout, otherwise `false`.
|
||||
*/
|
||||
bool esp_openclaw_node_wifi_wait_for_connection(TickType_t timeout_ticks);
|
||||
|
||||
/** @brief Return whether the Wi-Fi station is currently connected and has an IP. */
|
||||
bool esp_openclaw_node_wifi_is_connected(void);
|
||||
|
||||
/**
|
||||
* @brief Copy the current Wi-Fi station status into @p status.
|
||||
*
|
||||
* @param[out] status Destination status structure to populate.
|
||||
*/
|
||||
void esp_openclaw_node_wifi_get_status(esp_openclaw_node_wifi_status_t *status);
|
||||
6
examples/esp-box-3-display/.gitignore
vendored
Normal file
6
examples/esp-box-3-display/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
build/
|
||||
managed_components/
|
||||
dependencies.lock
|
||||
sdkconfig
|
||||
sdkconfig.old
|
||||
sdkconfig.ci
|
||||
7
examples/esp-box-3-display/CMakeLists.txt
Normal file
7
examples/esp-box-3-display/CMakeLists.txt
Normal file
@ -0,0 +1,7 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
set(EXTRA_COMPONENT_DIRS ../../components)
|
||||
set(PROJECT_VER "1.0.0")
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
project(esp_openclaw_node_esp_box_3_display)
|
||||
214
examples/esp-box-3-display/README.md
Normal file
214
examples/esp-box-3-display/README.md
Normal file
@ -0,0 +1,214 @@
|
||||
# ESP-BOX-3 Display Example
|
||||
|
||||
This example runs an ESP-BOX-3 as an OpenClaw Node with display commands for the built-in screen.
|
||||
|
||||
<a href="../../docs/assets/openclaw-gateway-esp-box-3-message-flow.png">
|
||||
<img src="../../docs/assets/openclaw-gateway-esp-box-3-message-flow.png" alt="OpenClaw Gateway to ESP-BOX-3 message flow" width="900">
|
||||
</a>
|
||||
|
||||
Commands below assume the default OpenClaw install. If you use a named profile, add `--profile <profile>` to the `openclaw` commands.
|
||||
|
||||
## What This Example Exposes
|
||||
|
||||
- `device`
|
||||
- `wifi`
|
||||
- `display`
|
||||
|
||||
Commands:
|
||||
|
||||
- `device.info`
|
||||
- `device.status`
|
||||
- `wifi.status`
|
||||
- `display.show`
|
||||
- `display.status`
|
||||
|
||||
## Display Payload
|
||||
|
||||
`display.show` accepts:
|
||||
|
||||
- `heading` short title text, up to `64` UTF-8 bytes
|
||||
- `text` body text, up to `512` UTF-8 bytes
|
||||
|
||||
Sample payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"heading": "Hello",
|
||||
"text": "OpenClaw is driving the ESP-BOX-3 display."
|
||||
}
|
||||
```
|
||||
|
||||
On boot, the screen shows a waiting message until the node is paired.
|
||||
|
||||
## Prepare The Gateway
|
||||
|
||||
If the board will connect over Wi-Fi to a gateway running on another machine, set `gateway.bind` to `lan` first. The default loopback bind is only reachable from the gateway host itself.
|
||||
|
||||
Allow this example's commands before pairing the board:
|
||||
|
||||
Warning: this command replaces the existing `gateway.nodes.allowCommands` value in the active profile.
|
||||
|
||||
```bash
|
||||
openclaw config set gateway.bind lan
|
||||
openclaw config set gateway.nodes.allowCommands '[
|
||||
"device.info",
|
||||
"device.status",
|
||||
"wifi.status",
|
||||
"display.show",
|
||||
"display.status"
|
||||
]' --strict-json
|
||||
|
||||
openclaw gateway restart
|
||||
openclaw gateway status --probe --json
|
||||
```
|
||||
|
||||
These steps start from an existing OpenClaw gateway that the board can reach on your LAN.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
. ~/esp-idf/export.sh
|
||||
cd /path/to/repo/examples/esp-box-3-display
|
||||
idf.py set-target esp32s3
|
||||
idf.py build
|
||||
```
|
||||
|
||||
## Flash
|
||||
|
||||
```bash
|
||||
. ~/esp-idf/export.sh
|
||||
cd /path/to/repo/examples/esp-box-3-display
|
||||
idf.py -p <serial-port> flash monitor
|
||||
```
|
||||
|
||||
## Main REPL Commands
|
||||
|
||||
After boot, the example starts the same serial REPL used by the generic ESP32
|
||||
node example. On the ESP-BOX-3 it is exposed over the native USB Serial/JTAG
|
||||
console.
|
||||
|
||||
The example automatically requests saved-session reconnect after Wi-Fi obtains
|
||||
an IP and after ordinary connection-loss events. If no saved reconnect session
|
||||
exists yet, those reconnect attempts are skipped and the board waits for an
|
||||
explicit gateway auth command.
|
||||
|
||||
Start with these commands:
|
||||
|
||||
- `status` print saved-session availability and Wi-Fi state
|
||||
- `wifi set <ssid> [passphrase]` store Wi-Fi credentials in NVS and connect immediately
|
||||
- Use `wifi set <ssid>` for an open network.
|
||||
- Use `wifi set <ssid> <passphrase>` for a secured network.
|
||||
- `gateway setup-code <setup-code>` request one setup-code connect attempt; if Wi-Fi is still coming up, the REPL waits for an IP first
|
||||
- `gateway token <uri> <token>` request one explicit shared-token connect attempt
|
||||
- `gateway password <uri> <password>` request one explicit password connect attempt
|
||||
- `gateway no-auth <uri>` request one explicit no-auth connect attempt
|
||||
- `gateway connect` request one reconnect attempt using the saved reconnect session immediately
|
||||
- `gateway disconnect` request disconnect of the active session
|
||||
- `reboot` reboot the board immediately
|
||||
|
||||
`status` prints these fields:
|
||||
|
||||
- `saved session available`: whether a persisted `{ gateway_uri, device_token }` reconnect session is stored
|
||||
- `wifi configured`: whether Wi-Fi credentials are saved in NVS
|
||||
- `wifi ssid`: the saved SSID, when Wi-Fi is configured
|
||||
- `wifi connected`: whether the board currently has a Wi-Fi connection
|
||||
- `wifi disconnect reason`: the most recent ESP-IDF disconnect reason, when Wi-Fi is not connected
|
||||
- `wifi ip`: the current IPv4 address, when Wi-Fi is connected
|
||||
|
||||
## First Connection
|
||||
|
||||
Generate a setup code on the gateway host:
|
||||
|
||||
```bash
|
||||
openclaw qr \
|
||||
--url ws://<gateway-host-ip>:<gateway-port> \
|
||||
--setup-code-only
|
||||
```
|
||||
|
||||
The setup code contains a short-lived `bootstrapToken`, not the gateway's shared token.
|
||||
|
||||
Bring the board online from the serial REPL:
|
||||
|
||||
```text
|
||||
openclaw> status
|
||||
openclaw> wifi set <ssid> <passphrase>
|
||||
openclaw> gateway setup-code <setup-code>
|
||||
openclaw> status
|
||||
```
|
||||
|
||||
`gateway setup-code <setup-code>` already requests the connection attempt. If
|
||||
Wi-Fi is still associating, the REPL waits for an IP before it submits that
|
||||
attempt. Once a saved reconnect session exists, the example retries it
|
||||
automatically after Wi-Fi or gateway interruptions. Use `gateway connect` when
|
||||
you want to trigger that saved-session reconnect immediately.
|
||||
|
||||
Then verify the node from the gateway host:
|
||||
|
||||
```bash
|
||||
openclaw nodes status --json
|
||||
openclaw nodes invoke --node <node-id> --command device.info --json
|
||||
openclaw nodes invoke --node <node-id> --command display.status --json
|
||||
```
|
||||
|
||||
If pairing did not complete as expected, use [Troubleshooting](../../docs/troubleshooting.md).
|
||||
|
||||
## Use The Node
|
||||
|
||||
Get basic information:
|
||||
|
||||
```bash
|
||||
openclaw nodes invoke --node <node-id> --command device.info --json
|
||||
openclaw nodes invoke --node <node-id> --command device.status --json
|
||||
openclaw nodes invoke --node <node-id> --command wifi.status --json
|
||||
openclaw nodes invoke --node <node-id> --command display.status --json
|
||||
```
|
||||
|
||||
Update the display:
|
||||
|
||||
```bash
|
||||
openclaw nodes invoke \
|
||||
--node <node-id> \
|
||||
--command display.show \
|
||||
--params '{"heading":"Hello","text":"OpenClaw is driving the ESP-BOX-3 display."}' \
|
||||
--json
|
||||
```
|
||||
|
||||
## Other CLI Commands
|
||||
|
||||
Useful when you want to test more than the standard setup-code flow:
|
||||
|
||||
- `wifi clear`
|
||||
- `wifi connect`
|
||||
- `wifi disconnect`
|
||||
- `reboot`
|
||||
|
||||
- `gateway setup-code <code>`
|
||||
- `gateway no-auth <ws://host:port>`
|
||||
- `gateway token <ws://host:port> <token>`
|
||||
- `gateway password <ws://host:port> <password>`
|
||||
- `gateway connect`
|
||||
- `gateway disconnect`
|
||||
|
||||
<details>
|
||||
<summary>Gateway command behavior</summary>
|
||||
|
||||
- `gateway setup-code ...` performs one explicit setup-code connection attempt
|
||||
after Wi-Fi is online
|
||||
- `gateway no-auth ...` requests one explicit no-auth connect attempt
|
||||
- `gateway token ...` requests one explicit shared-token connect attempt
|
||||
- `gateway password ...` requests one explicit password connect attempt
|
||||
- `gateway connect` performs one reconnect attempt with the saved reconnect session
|
||||
- `gateway disconnect` is valid only while the session is connected
|
||||
- This example automatically retries the saved reconnect session after connection loss once Wi-Fi is back
|
||||
|
||||
</details>
|
||||
|
||||
## Troubleshooting And Reference
|
||||
|
||||
- [Troubleshooting](../../docs/troubleshooting.md)
|
||||
- [Component README](../../components/esp-openclaw-node/README.md)
|
||||
|
||||
## Notes
|
||||
|
||||
- The `esp32s3` target is fixed for this example.
|
||||
- The node display name is `OpenClaw ESP-BOX-3`.
|
||||
28
examples/esp-box-3-display/main/CMakeLists.txt
Normal file
28
examples/esp-box-3-display/main/CMakeLists.txt
Normal file
@ -0,0 +1,28 @@
|
||||
idf_component_register(
|
||||
SRCS
|
||||
"app_main.c"
|
||||
"../../common/esp_openclaw_node_common_device_node_cmd.c"
|
||||
"../../common/esp_openclaw_node_example_json.c"
|
||||
"../../common/esp_openclaw_node_example_repl.c"
|
||||
"../../common/esp_openclaw_node_example_repl_cmd.c"
|
||||
"../../common/esp_openclaw_node_example_saved_session_reconnect.c"
|
||||
"../../common/esp_openclaw_node_wifi.c"
|
||||
"esp_openclaw_node_box_cmd.c"
|
||||
"esp_openclaw_node_box_display.c"
|
||||
"esp_openclaw_node_box_display_node_cmd.c"
|
||||
INCLUDE_DIRS
|
||||
"."
|
||||
"../../common"
|
||||
REQUIRES
|
||||
esp-openclaw-node
|
||||
esp-box-3
|
||||
esp_app_format
|
||||
console
|
||||
esp_event
|
||||
esp_netif
|
||||
esp_timer
|
||||
esp_wifi
|
||||
espressif__cjson
|
||||
mbedtls
|
||||
nvs_flash
|
||||
)
|
||||
3
examples/esp-box-3-display/main/Kconfig.projbuild
Normal file
3
examples/esp-box-3-display/main/Kconfig.projbuild
Normal file
@ -0,0 +1,3 @@
|
||||
menu "OpenClaw Example"
|
||||
|
||||
endmenu
|
||||
91
examples/esp-box-3-display/main/app_main.c
Normal file
91
examples/esp-box-3-display/main/app_main.c
Normal file
@ -0,0 +1,91 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_box_cmd.h"
|
||||
#include "esp_openclaw_node_example_repl.h"
|
||||
#include "esp_openclaw_node_example_repl_cmd.h"
|
||||
#include "esp_openclaw_node_example_saved_session_reconnect.h"
|
||||
#include "esp_openclaw_node.h"
|
||||
#include "esp_openclaw_node_wifi.h"
|
||||
|
||||
#include "bsp/esp-box-3.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_netif.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
static const char *TAG = "app_main";
|
||||
static esp_openclaw_node_handle_t s_node;
|
||||
static esp_openclaw_node_box_display_t s_display;
|
||||
static esp_openclaw_node_example_saved_session_reconnect_t s_saved_session_reconnect;
|
||||
|
||||
static void handle_node_event(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_event_t event,
|
||||
const void *event_data,
|
||||
void *user_ctx)
|
||||
{
|
||||
(void)node;
|
||||
|
||||
esp_openclaw_node_example_saved_session_reconnect_handle_event(
|
||||
(esp_openclaw_node_example_saved_session_reconnect_t *)user_ctx,
|
||||
event,
|
||||
event_data);
|
||||
|
||||
if (event == ESP_OPENCLAW_NODE_EVENT_CONNECTED) {
|
||||
ESP_LOGI(TAG, "OpenClaw session connected");
|
||||
} else if (event == ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED) {
|
||||
const esp_openclaw_node_connect_failed_event_t *failed = event_data;
|
||||
ESP_LOGW(
|
||||
TAG,
|
||||
"OpenClaw connect failed: reason=%d local_err=%s gateway_detail=%s",
|
||||
failed != NULL ? failed->reason : -1,
|
||||
failed != NULL ? esp_err_to_name(failed->local_err) : "n/a",
|
||||
failed != NULL && failed->gateway_detail_code != NULL ? failed->gateway_detail_code : "");
|
||||
} else if (event == ESP_OPENCLAW_NODE_EVENT_DISCONNECTED) {
|
||||
const esp_openclaw_node_disconnected_event_t *disconnected = event_data;
|
||||
ESP_LOGW(
|
||||
TAG,
|
||||
"OpenClaw disconnected: reason=%d local_err=%s",
|
||||
disconnected != NULL ? disconnected->reason : -1,
|
||||
disconnected != NULL ? esp_err_to_name(disconnected->local_err) : "n/a");
|
||||
}
|
||||
}
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
esp_err_t err = nvs_flash_init();
|
||||
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||
ESP_ERROR_CHECK(nvs_flash_erase());
|
||||
err = nvs_flash_init();
|
||||
}
|
||||
ESP_ERROR_CHECK(err);
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_box_display_start(&s_display));
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_wifi_start());
|
||||
|
||||
esp_openclaw_node_config_t node_config = {0};
|
||||
esp_openclaw_node_config_init_default(&node_config);
|
||||
node_config.display_name = "OpenClaw ESP-BOX-3";
|
||||
node_config.event_cb = handle_node_event;
|
||||
node_config.event_user_ctx = &s_saved_session_reconnect;
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_create(&node_config, &s_node));
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_box_register_node_commands(s_node, &s_display));
|
||||
ESP_ERROR_CHECK(
|
||||
esp_openclaw_node_example_saved_session_reconnect_start(
|
||||
&s_saved_session_reconnect,
|
||||
s_node,
|
||||
"esp_openclaw_node_reconnect"));
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_example_repl_start(s_node));
|
||||
|
||||
if (!esp_openclaw_node_wifi_is_connected()) {
|
||||
ESP_LOGI(TAG, "wifi not connected yet; provision it from the REPL or wait for Wi-Fi to reconnect");
|
||||
ESP_LOGI(TAG, "after Wi-Fi is up, use `gateway setup-code`, `gateway token`, `gateway password`, or `gateway no-auth` for a first connect");
|
||||
ESP_LOGI(TAG, "saved-session reconnect runs automatically after Wi-Fi returns when a saved session is present");
|
||||
}
|
||||
}
|
||||
28
examples/esp-box-3-display/main/esp_openclaw_node_box_cmd.c
Normal file
28
examples/esp-box-3-display/main/esp_openclaw_node_box_cmd.c
Normal file
@ -0,0 +1,28 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_box_cmd.h"
|
||||
|
||||
#include "esp_openclaw_node_box_display_node_cmd.h"
|
||||
#include "esp_openclaw_node_common_device_node_cmd.h"
|
||||
#include "esp_check.h"
|
||||
|
||||
static const char *TAG = "esp_openclaw_node_box_cmd";
|
||||
|
||||
esp_err_t esp_openclaw_node_box_register_node_commands(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_box_display_t *display)
|
||||
{
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_common_register_device_node_commands(node),
|
||||
TAG,
|
||||
"registering device commands failed");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_box_register_display_node_commands(node, display),
|
||||
TAG,
|
||||
"registering display commands failed");
|
||||
return ESP_OK;
|
||||
}
|
||||
29
examples/esp-box-3-display/main/esp_openclaw_node_box_cmd.h
Normal file
29
examples/esp-box-3-display/main/esp_openclaw_node_box_cmd.h
Normal file
@ -0,0 +1,29 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_openclaw_node_box_display.h"
|
||||
#include "esp_openclaw_node.h"
|
||||
|
||||
/**
|
||||
* @brief Register the full ESP-BOX-3 command set.
|
||||
*
|
||||
* The helper adds the shared `device.*` and `wifi.status` commands plus the
|
||||
* display commands backed by @p display.
|
||||
*
|
||||
* @param[in] node OpenClaw Node instance to extend.
|
||||
* @param[in] display Display state used by the display command handlers.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - `ESP_ERR_INVALID_ARG` if `display` is `NULL`
|
||||
* - an ESP-IDF error code if registration fails
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_box_register_node_commands(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_box_display_t *display);
|
||||
152
examples/esp-box-3-display/main/esp_openclaw_node_box_display.c
Normal file
152
examples/esp-box-3-display/main/esp_openclaw_node_box_display.c
Normal file
@ -0,0 +1,152 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_box_display.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "bsp/esp-box-3.h"
|
||||
#include "cJSON.h"
|
||||
#include "esp_check.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "lvgl.h"
|
||||
#include "esp_openclaw_node_example_json.h"
|
||||
|
||||
static const char *TAG = "esp_openclaw_node_box_display";
|
||||
static const char *DEFAULT_HEADING = "OpenClaw";
|
||||
static const char *DEFAULT_TEXT = "Waiting for display.show from the OpenClaw gateway.";
|
||||
|
||||
esp_err_t esp_openclaw_node_box_display_build_status_payload(
|
||||
const esp_openclaw_node_box_display_t *display,
|
||||
char **out_payload_json)
|
||||
{
|
||||
if (display == NULL || out_payload_json == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
cJSON *payload = cJSON_CreateObject();
|
||||
if (payload == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(payload, "ready", display->ready);
|
||||
cJSON_AddStringToObject(payload, "heading", display->heading);
|
||||
cJSON_AddStringToObject(payload, "text", display->text);
|
||||
cJSON_AddNumberToObject(payload, "renderCount", display->render_count);
|
||||
cJSON_AddNumberToObject(payload, "lastRenderMs", (double)display->last_render_ms);
|
||||
cJSON_AddNumberToObject(payload, "headingMaxLength", ESP_OPENCLAW_NODE_BOX_DISPLAY_MAX_HEADING_LEN);
|
||||
cJSON_AddNumberToObject(payload, "textMaxLength", ESP_OPENCLAW_NODE_BOX_DISPLAY_MAX_TEXT_LEN);
|
||||
cJSON_AddNumberToObject(payload, "width", BSP_LCD_H_RES);
|
||||
cJSON_AddNumberToObject(payload, "height", BSP_LCD_V_RES);
|
||||
|
||||
return esp_openclaw_node_example_take_json_payload(payload, out_payload_json);
|
||||
}
|
||||
|
||||
static void apply_render_locked(esp_openclaw_node_box_display_t *display)
|
||||
{
|
||||
lv_label_set_text(display->heading_label, display->heading);
|
||||
lv_label_set_text(display->text_label, display->text);
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_box_display_render(
|
||||
esp_openclaw_node_box_display_t *display,
|
||||
const char *heading,
|
||||
const char *text)
|
||||
{
|
||||
if (display == NULL || heading == NULL || text == NULL || !display->ready) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (!bsp_display_lock(0)) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
snprintf(display->heading, sizeof(display->heading), "%s", heading);
|
||||
snprintf(display->text, sizeof(display->text), "%s", text);
|
||||
apply_render_locked(display);
|
||||
bsp_display_unlock();
|
||||
|
||||
display->render_count += 1U;
|
||||
display->last_render_ms = esp_timer_get_time() / 1000LL;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void create_display_ui_locked(esp_openclaw_node_box_display_t *display)
|
||||
{
|
||||
lv_obj_t *screen = lv_scr_act();
|
||||
lv_obj_clean(screen);
|
||||
lv_obj_set_style_bg_color(screen, lv_color_hex(0x0f172a), 0);
|
||||
lv_obj_set_style_bg_opa(screen, LV_OPA_COVER, 0);
|
||||
|
||||
display->container = lv_obj_create(screen);
|
||||
lv_obj_set_size(display->container, lv_pct(100), lv_pct(100));
|
||||
lv_obj_center(display->container);
|
||||
lv_obj_set_style_radius(display->container, 0, 0);
|
||||
lv_obj_set_style_border_width(display->container, 0, 0);
|
||||
lv_obj_set_style_bg_opa(display->container, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_pad_left(display->container, 18, 0);
|
||||
lv_obj_set_style_pad_right(display->container, 18, 0);
|
||||
lv_obj_set_style_pad_top(display->container, 20, 0);
|
||||
lv_obj_set_style_pad_bottom(display->container, 20, 0);
|
||||
lv_obj_set_style_pad_row(display->container, 12, 0);
|
||||
lv_obj_set_scrollbar_mode(display->container, LV_SCROLLBAR_MODE_OFF);
|
||||
lv_obj_set_flex_flow(display->container, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(
|
||||
display->container,
|
||||
LV_FLEX_ALIGN_START,
|
||||
LV_FLEX_ALIGN_START,
|
||||
LV_FLEX_ALIGN_START);
|
||||
|
||||
display->heading_label = lv_label_create(display->container);
|
||||
lv_obj_set_width(display->heading_label, lv_pct(100));
|
||||
lv_label_set_long_mode(display->heading_label, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_style_text_color(display->heading_label, lv_color_hex(0xf8fafc), 0);
|
||||
lv_obj_set_style_text_font(display->heading_label, &lv_font_montserrat_22, 0);
|
||||
|
||||
display->text_label = lv_label_create(display->container);
|
||||
lv_obj_set_width(display->text_label, lv_pct(100));
|
||||
lv_label_set_long_mode(display->text_label, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_style_text_color(display->text_label, lv_color_hex(0xcbd5e1), 0);
|
||||
lv_obj_set_style_text_line_space(display->text_label, 6, 0);
|
||||
lv_obj_set_style_text_font(display->text_label, &lv_font_montserrat_16, 0);
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_box_display_start(esp_openclaw_node_box_display_t *display)
|
||||
{
|
||||
if (display == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
memset(display, 0, sizeof(*display));
|
||||
ESP_RETURN_ON_ERROR(bsp_i2c_init(), TAG, "bsp_i2c_init failed");
|
||||
|
||||
bsp_display_cfg_t cfg = {
|
||||
.lvgl_port_cfg = ESP_LVGL_PORT_INIT_CONFIG(),
|
||||
.buffer_size = BSP_LCD_H_RES * CONFIG_BSP_LCD_DRAW_BUF_HEIGHT,
|
||||
.double_buffer = 0,
|
||||
.flags = {
|
||||
.buff_dma = true,
|
||||
},
|
||||
};
|
||||
display->lv_display = bsp_display_start_with_config(&cfg);
|
||||
if (display->lv_display == NULL) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ESP_RETURN_ON_ERROR(bsp_display_backlight_on(), TAG, "bsp_display_backlight_on failed");
|
||||
|
||||
if (!bsp_display_lock(0)) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
create_display_ui_locked(display);
|
||||
bsp_display_unlock();
|
||||
|
||||
display->ready = true;
|
||||
display->last_render_ms = 0;
|
||||
return esp_openclaw_node_box_display_render(display, DEFAULT_HEADING, DEFAULT_TEXT);
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
/** @brief Maximum UTF-8 heading length accepted by `display.show`. */
|
||||
#define ESP_OPENCLAW_NODE_BOX_DISPLAY_MAX_HEADING_LEN 64
|
||||
/** @brief Maximum UTF-8 body length accepted by `display.show`. */
|
||||
#define ESP_OPENCLAW_NODE_BOX_DISPLAY_MAX_TEXT_LEN 512
|
||||
|
||||
typedef struct _lv_display_t lv_display_t;
|
||||
typedef struct _lv_obj_t lv_obj_t;
|
||||
|
||||
/**
|
||||
* @brief Display state shared by the ESP-BOX-3 example modules.
|
||||
*/
|
||||
typedef struct {
|
||||
bool ready; /**< Whether the display runtime has been initialized successfully. */
|
||||
char heading[ESP_OPENCLAW_NODE_BOX_DISPLAY_MAX_HEADING_LEN + 1]; /**< Last rendered heading text. */
|
||||
char text[ESP_OPENCLAW_NODE_BOX_DISPLAY_MAX_TEXT_LEN + 1]; /**< Last rendered body text. */
|
||||
uint32_t render_count; /**< Number of successful render operations since boot. */
|
||||
int64_t last_render_ms; /**< Timestamp of the most recent render in milliseconds since boot. */
|
||||
lv_display_t *lv_display; /**< Underlying LVGL display handle owned by the example. */
|
||||
lv_obj_t *container; /**< Root LVGL container for the example screen. */
|
||||
lv_obj_t *heading_label; /**< LVGL label used for the heading line. */
|
||||
lv_obj_t *text_label; /**< LVGL label used for the body text block. */
|
||||
} esp_openclaw_node_box_display_t;
|
||||
|
||||
/**
|
||||
* @brief Initialize the ESP-BOX-3 display runtime and render the boot screen.
|
||||
*
|
||||
* @param[out] display Display state to initialize.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - `ESP_ERR_INVALID_ARG` if `display` is `NULL`
|
||||
* - an ESP-IDF error code if board or LVGL setup fails
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_box_display_start(esp_openclaw_node_box_display_t *display);
|
||||
|
||||
/**
|
||||
* @brief Render new heading and body text on the display.
|
||||
*
|
||||
* @param[in,out] display Initialized display state.
|
||||
* @param[in] heading Short heading text.
|
||||
* @param[in] text Body text.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - `ESP_ERR_INVALID_ARG` if any argument is invalid
|
||||
* - `ESP_ERR_INVALID_STATE` if the display is not ready
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_box_display_render(
|
||||
esp_openclaw_node_box_display_t *display,
|
||||
const char *heading,
|
||||
const char *text);
|
||||
|
||||
/**
|
||||
* @brief Build the JSON payload returned by `display.status` and `display.show`.
|
||||
*
|
||||
* Ownership of the returned buffer transfers to the caller, which must free it
|
||||
* with a `malloc()`-compatible allocator.
|
||||
*
|
||||
* @param[in] display Display state to serialize.
|
||||
* @param[out] out_payload_json Allocated UTF-8 JSON string on success.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - `ESP_ERR_INVALID_ARG` if any argument is invalid
|
||||
* - `ESP_ERR_NO_MEM` if allocation fails
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_box_display_build_status_payload(
|
||||
const esp_openclaw_node_box_display_t *display,
|
||||
char **out_payload_json);
|
||||
@ -0,0 +1,147 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_box_display_node_cmd.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_check.h"
|
||||
#include "esp_openclaw_node_example_json.h"
|
||||
|
||||
static const char *TAG = "esp_openclaw_node_display_cmd";
|
||||
|
||||
static esp_err_t parse_required_text(
|
||||
cJSON *params,
|
||||
const char *name,
|
||||
size_t max_len,
|
||||
const char **out_value,
|
||||
esp_openclaw_node_error_t *out_error)
|
||||
{
|
||||
cJSON *field = cJSON_GetObjectItemCaseSensitive(params, name);
|
||||
if (!cJSON_IsString(field) || field->valuestring == NULL) {
|
||||
out_error->code = "INVALID_PARAMS";
|
||||
out_error->message = "display params must include string heading and text fields";
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (strlen(field->valuestring) > max_len) {
|
||||
out_error->code = "INVALID_PARAMS";
|
||||
out_error->message = "display field exceeds maximum supported length";
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
*out_value = field->valuestring;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t handle_display_show(
|
||||
esp_openclaw_node_handle_t node,
|
||||
void *context,
|
||||
const char *params_json,
|
||||
size_t params_len,
|
||||
char **out_payload_json,
|
||||
esp_openclaw_node_error_t *out_error)
|
||||
{
|
||||
(void)node;
|
||||
(void)params_len;
|
||||
|
||||
esp_openclaw_node_box_display_t *display = (esp_openclaw_node_box_display_t *)context;
|
||||
const char *heading = NULL;
|
||||
const char *text = NULL;
|
||||
cJSON *params = NULL;
|
||||
|
||||
esp_err_t err = esp_openclaw_node_example_parse_json_params(params_json, ¶ms, out_error);
|
||||
if (err != ESP_OK) {
|
||||
ESP_RETURN_ON_ERROR(err, TAG, "invalid params");
|
||||
}
|
||||
|
||||
err = parse_required_text(
|
||||
params,
|
||||
"heading",
|
||||
ESP_OPENCLAW_NODE_BOX_DISPLAY_MAX_HEADING_LEN,
|
||||
&heading,
|
||||
out_error);
|
||||
if (err != ESP_OK) {
|
||||
cJSON_Delete(params);
|
||||
ESP_RETURN_ON_ERROR(err, TAG, "invalid heading");
|
||||
}
|
||||
err = parse_required_text(
|
||||
params,
|
||||
"text",
|
||||
ESP_OPENCLAW_NODE_BOX_DISPLAY_MAX_TEXT_LEN,
|
||||
&text,
|
||||
out_error);
|
||||
if (err != ESP_OK) {
|
||||
cJSON_Delete(params);
|
||||
ESP_RETURN_ON_ERROR(err, TAG, "invalid text");
|
||||
}
|
||||
|
||||
if (esp_openclaw_node_box_display_render(display, heading, text) != ESP_OK) {
|
||||
out_error->code = "UNAVAILABLE";
|
||||
out_error->message = "display renderer is not ready";
|
||||
cJSON_Delete(params);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
cJSON_Delete(params);
|
||||
return esp_openclaw_node_box_display_build_status_payload(display, out_payload_json);
|
||||
}
|
||||
|
||||
static esp_err_t handle_display_status(
|
||||
esp_openclaw_node_handle_t node,
|
||||
void *context,
|
||||
const char *params_json,
|
||||
size_t params_len,
|
||||
char **out_payload_json,
|
||||
esp_openclaw_node_error_t *out_error)
|
||||
{
|
||||
(void)node;
|
||||
(void)params_json;
|
||||
(void)params_len;
|
||||
(void)out_error;
|
||||
|
||||
esp_openclaw_node_box_display_t *display = (esp_openclaw_node_box_display_t *)context;
|
||||
return esp_openclaw_node_box_display_build_status_payload(display, out_payload_json);
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_box_register_display_node_commands(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_box_display_t *display)
|
||||
{
|
||||
if (display == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
static const esp_openclaw_node_command_t DISPLAY_SHOW_COMMAND = {
|
||||
.name = "display.show",
|
||||
.handler = handle_display_show,
|
||||
.context = NULL,
|
||||
};
|
||||
static const esp_openclaw_node_command_t DISPLAY_STATUS_COMMAND = {
|
||||
.name = "display.status",
|
||||
.handler = handle_display_status,
|
||||
.context = NULL,
|
||||
};
|
||||
|
||||
esp_openclaw_node_command_t show_command = DISPLAY_SHOW_COMMAND;
|
||||
show_command.context = display;
|
||||
esp_openclaw_node_command_t status_command = DISPLAY_STATUS_COMMAND;
|
||||
status_command.context = display;
|
||||
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_register_capability(node, "display"),
|
||||
TAG,
|
||||
"registering display capability failed");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_register_command(node, &show_command),
|
||||
TAG,
|
||||
"registering display.show failed");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_register_command(node, &status_command),
|
||||
TAG,
|
||||
"registering display.status failed");
|
||||
return ESP_OK;
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_openclaw_node_box_display.h"
|
||||
#include "esp_openclaw_node.h"
|
||||
|
||||
/**
|
||||
* @brief Register the display-specific OpenClaw commands for the ESP-BOX-3.
|
||||
*
|
||||
* The helper adds the `display.show` and `display.status` commands.
|
||||
*
|
||||
* @param[in] node OpenClaw Node instance to extend.
|
||||
* @param[in] display Display state used by the command handlers.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - `ESP_ERR_INVALID_ARG` if `display` is `NULL`
|
||||
* - an ESP-IDF error code if registration fails
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_box_register_display_node_commands(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_box_display_t *display);
|
||||
4
examples/esp-box-3-display/main/idf_component.yml
Normal file
4
examples/esp-box-3-display/main/idf_component.yml
Normal file
@ -0,0 +1,4 @@
|
||||
dependencies:
|
||||
idf: ">=5.3"
|
||||
espressif/esp-box-3:
|
||||
version: "^3.2"
|
||||
4
examples/esp-box-3-display/partitions.csv
Normal file
4
examples/esp-box-3-display/partitions.csv
Normal file
@ -0,0 +1,4 @@
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x6000,
|
||||
phy_init, data, phy, 0xf000, 0x1000,
|
||||
factory, app, factory, 0x10000, 0x400000,
|
||||
|
34
examples/esp-box-3-display/sdkconfig.defaults
Normal file
34
examples/esp-box-3-display/sdkconfig.defaults
Normal file
@ -0,0 +1,34 @@
|
||||
CONFIG_IDF_TARGET="esp32s3"
|
||||
|
||||
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
||||
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
|
||||
CONFIG_ESP_SYSTEM_PANIC_PRINT_HALT=y
|
||||
|
||||
CONFIG_ESP32S3_DATA_CACHE_64KB=y
|
||||
CONFIG_ESP32S3_DATA_CACHE_LINE_64B=y
|
||||
CONFIG_ESP32S3_INSTRUCTION_CACHE_32KB=y
|
||||
CONFIG_SPIRAM=y
|
||||
CONFIG_SPIRAM_MODE_OCT=y
|
||||
CONFIG_SPIRAM_SPEED_80M=y
|
||||
|
||||
CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
|
||||
|
||||
CONFIG_LV_FONT_MONTSERRAT_16=y
|
||||
CONFIG_LV_FONT_MONTSERRAT_22=y
|
||||
CONFIG_LV_MEM_SIZE_KILOBYTES=64
|
||||
CONFIG_LV_BUILD_EXAMPLES=n
|
||||
CONFIG_LV_BUILD_DEMOS=n
|
||||
|
||||
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=y
|
||||
CONFIG_MBEDTLS_SSL_PROTO_TLS1_3=y
|
||||
|
||||
# The ESP-BOX-3 BSP stack is substantially larger than the generic examples.
|
||||
# Use a larger single-app layout on the board's 16 MB flash.
|
||||
# CONFIG_PARTITION_TABLE_SINGLE_APP is not set
|
||||
# CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE is not set
|
||||
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
|
||||
|
||||
CONFIG_BSP_LCD_DRAW_BUF_HEIGHT=20
|
||||
6
examples/esp32-node/.gitignore
vendored
Normal file
6
examples/esp32-node/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
build/
|
||||
managed_components/
|
||||
dependencies.lock
|
||||
sdkconfig
|
||||
sdkconfig.old
|
||||
sdkconfig.ci
|
||||
7
examples/esp32-node/CMakeLists.txt
Normal file
7
examples/esp32-node/CMakeLists.txt
Normal file
@ -0,0 +1,7 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
set(EXTRA_COMPONENT_DIRS ../../components)
|
||||
set(PROJECT_VER "1.0.0")
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
project(esp_openclaw_node_esp32)
|
||||
204
examples/esp32-node/README.md
Normal file
204
examples/esp32-node/README.md
Normal file
@ -0,0 +1,204 @@
|
||||
# ESP32 Wi-Fi Node Example
|
||||
|
||||
This example runs a Wi-Fi-capable ESP32 board as an OpenClaw Node. It uses the shared serial REPL included with the examples in this repository.
|
||||
|
||||
You can build it for ESP32 targets with Wi-Fi support. By default, the node metadata comes from the selected `idf.py` target.
|
||||
|
||||
Commands below assume the default OpenClaw install. If you use a named profile, add `--profile <profile>` to the `openclaw` commands.
|
||||
|
||||
## What This Example Exposes
|
||||
|
||||
- `device`
|
||||
- `wifi`
|
||||
- `gpio`
|
||||
- `adc` on ADC-capable targets
|
||||
|
||||
Commands:
|
||||
|
||||
- `device.info`
|
||||
- `device.status`
|
||||
- `wifi.status`
|
||||
- `gpio.mode`
|
||||
- `gpio.read`
|
||||
- `gpio.write`
|
||||
- `adc.read`
|
||||
|
||||
`adc.read` is only registered when the selected target supports ADC.
|
||||
|
||||
## Prepare The Gateway
|
||||
|
||||
If the board will connect over Wi-Fi to a gateway running on another machine, set `gateway.bind` to `lan` first. The default loopback bind is only reachable from the gateway host itself.
|
||||
|
||||
Set the command allowlist before pairing the board. Without it, the node can connect and still show `commands: []`.
|
||||
|
||||
Warning: this command replaces the existing `gateway.nodes.allowCommands` value in the active profile.
|
||||
|
||||
```bash
|
||||
openclaw config set gateway.bind lan
|
||||
openclaw config set gateway.nodes.allowCommands '[
|
||||
"device.info",
|
||||
"device.status",
|
||||
"wifi.status",
|
||||
"gpio.mode",
|
||||
"gpio.read",
|
||||
"gpio.write",
|
||||
"adc.read"
|
||||
]' --strict-json
|
||||
|
||||
openclaw gateway restart
|
||||
openclaw gateway status --probe --json
|
||||
```
|
||||
|
||||
These steps start from an existing OpenClaw gateway that the board can reach on your LAN.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
. ~/esp-idf/export.sh
|
||||
cd /path/to/repo/examples/esp32-node
|
||||
idf.py set-target <target>
|
||||
idf.py build
|
||||
```
|
||||
|
||||
## Flash
|
||||
|
||||
```bash
|
||||
. ~/esp-idf/export.sh
|
||||
cd /path/to/repo/examples/esp32-node
|
||||
idf.py -p <serial-port> flash monitor
|
||||
```
|
||||
|
||||
## Main REPL Commands
|
||||
|
||||
After boot, the example starts a serial REPL on the board's primary console. Most targets use the same console as `idf.py monitor`.
|
||||
|
||||
The example automatically requests saved-session reconnect after Wi-Fi obtains
|
||||
an IP and after ordinary connection-loss events. If no saved reconnect session
|
||||
exists yet, those reconnect attempts are skipped and the board waits for an
|
||||
explicit gateway auth command.
|
||||
|
||||
Start with these commands:
|
||||
|
||||
- `status` print saved-session availability and Wi-Fi state
|
||||
- `wifi set <ssid> [passphrase]` store Wi-Fi credentials in NVS and connect immediately
|
||||
- Use `wifi set <ssid>` for an open network.
|
||||
- Use `wifi set <ssid> <passphrase>` for a secured network.
|
||||
- `gateway setup-code <setup-code>` request one setup-code connect attempt; if Wi-Fi is still coming up, the REPL waits for an IP first
|
||||
- `gateway token <uri> <token>` request one explicit shared-token connect attempt
|
||||
- `gateway password <uri> <password>` request one explicit password connect attempt
|
||||
- `gateway no-auth <uri>` request one explicit no-auth connect attempt
|
||||
- `gateway connect` request one reconnect attempt using the saved reconnect session immediately
|
||||
- `gateway disconnect` request disconnect of the active session
|
||||
- `reboot` reboot the board immediately
|
||||
|
||||
`status` prints these fields:
|
||||
|
||||
- `saved session available`: whether a persisted `{ gateway_uri, device_token }` reconnect session is stored
|
||||
- `wifi configured`: whether Wi-Fi credentials are saved in NVS
|
||||
- `wifi ssid`: the saved SSID, when Wi-Fi is configured
|
||||
- `wifi connected`: whether the board currently has a Wi-Fi connection
|
||||
- `wifi disconnect reason`: the most recent ESP-IDF disconnect reason, when Wi-Fi is not connected
|
||||
- `wifi ip`: the current IPv4 address, when Wi-Fi is connected
|
||||
|
||||
## First Connection
|
||||
|
||||
Generate a setup code on the gateway host:
|
||||
|
||||
```bash
|
||||
openclaw qr \
|
||||
--url ws://<gateway-host-ip>:<gateway-port> \
|
||||
--setup-code-only
|
||||
```
|
||||
|
||||
The setup code contains a short-lived `bootstrapToken`, not the gateway's shared token.
|
||||
|
||||
Bring the board online from the serial REPL:
|
||||
|
||||
```text
|
||||
openclaw> status
|
||||
openclaw> wifi set <ssid> <passphrase>
|
||||
openclaw> gateway setup-code <setup-code>
|
||||
openclaw> status
|
||||
```
|
||||
|
||||
`gateway setup-code <setup-code>` already requests the connection attempt. If
|
||||
Wi-Fi is still associating, the REPL waits for an IP before it submits that
|
||||
attempt. Once a saved reconnect session exists, the example retries it
|
||||
automatically after Wi-Fi or gateway interruptions. Use `gateway connect` when
|
||||
you want to trigger that saved-session reconnect immediately.
|
||||
|
||||
Then verify the node from the gateway host:
|
||||
|
||||
```bash
|
||||
openclaw nodes status --json
|
||||
openclaw nodes invoke --node <node-id> --command device.info --json
|
||||
openclaw nodes invoke --node <node-id> --command wifi.status --json
|
||||
```
|
||||
|
||||
If pairing did not complete as expected, use [Troubleshooting](../../docs/troubleshooting.md).
|
||||
|
||||
## Use The Node
|
||||
|
||||
Get basic information:
|
||||
|
||||
```bash
|
||||
openclaw nodes invoke --node <node-id> --command device.info --json
|
||||
openclaw nodes invoke --node <node-id> --command device.status --json
|
||||
openclaw nodes invoke --node <node-id> --command wifi.status --json
|
||||
```
|
||||
|
||||
Configure and drive a GPIO pin with the stable documented path:
|
||||
|
||||
```bash
|
||||
openclaw nodes invoke --node <node-id> --command gpio.mode --params '{"pin":<pin>,"mode":"input_output"}' --json
|
||||
openclaw nodes invoke --node <node-id> --command gpio.write --params '{"pin":<pin>,"level":1}' --json
|
||||
openclaw nodes invoke --node <node-id> --command gpio.read --params '{"pin":<pin>}' --json
|
||||
```
|
||||
|
||||
Read ADC:
|
||||
|
||||
```bash
|
||||
openclaw nodes invoke --node <node-id> --command adc.read --params '{"channel":0}' --json
|
||||
```
|
||||
|
||||
## Other CLI Commands
|
||||
|
||||
Useful when you want to test more than the standard setup-code flow:
|
||||
|
||||
- `wifi set` rejects SSIDs or passphrases that do not fit the ESP-IDF station config exactly, instead of truncating them silently
|
||||
- `wifi clear`
|
||||
- `wifi connect`
|
||||
- `wifi disconnect`
|
||||
- `reboot`
|
||||
|
||||
- `gateway setup-code <code>`
|
||||
- `gateway no-auth <ws://host:port>`
|
||||
- `gateway token <ws://host:port> <token>`
|
||||
- `gateway password <ws://host:port> <password>`
|
||||
- `gateway connect`
|
||||
- `gateway disconnect`
|
||||
|
||||
<details>
|
||||
<summary>Gateway command behavior</summary>
|
||||
|
||||
- `gateway setup-code ...` performs one explicit setup-code connection attempt
|
||||
after Wi-Fi is online
|
||||
- `gateway no-auth ...` performs one explicit no-auth connection attempt
|
||||
- `gateway token ...` performs one explicit shared-token connection attempt
|
||||
- `gateway password ...` performs one explicit password connection attempt
|
||||
- `gateway connect` performs one reconnect attempt with the saved reconnect session
|
||||
- `gateway disconnect` is valid only while the session is connected
|
||||
- This example automatically retries the saved reconnect session after connection loss once Wi-Fi is back
|
||||
|
||||
</details>
|
||||
|
||||
## Troubleshooting And Reference
|
||||
|
||||
- [Troubleshooting](../../docs/troubleshooting.md)
|
||||
- [Component README](../../components/esp-openclaw-node/README.md)
|
||||
|
||||
## Notes
|
||||
|
||||
- Choose a GPIO pin that is actually broken out, output-capable, and safe on your board before using `gpio.write`.
|
||||
- The documented write path is `gpio.mode` with `"output"` or `"input_output"` followed by `gpio.write`.
|
||||
- Open-drain GPIO modes are intentionally not part of the current command surface.
|
||||
28
examples/esp32-node/main/CMakeLists.txt
Normal file
28
examples/esp32-node/main/CMakeLists.txt
Normal file
@ -0,0 +1,28 @@
|
||||
idf_component_register(
|
||||
SRCS
|
||||
"app_main.c"
|
||||
"../../common/esp_openclaw_node_common_device_node_cmd.c"
|
||||
"../../common/esp_openclaw_node_example_json.c"
|
||||
"../../common/esp_openclaw_node_example_repl.c"
|
||||
"../../common/esp_openclaw_node_example_repl_cmd.c"
|
||||
"../../common/esp_openclaw_node_example_saved_session_reconnect.c"
|
||||
"../../common/esp_openclaw_node_wifi.c"
|
||||
"esp_openclaw_node_example_adc_node_cmd.c"
|
||||
"esp_openclaw_node_example_cmd.c"
|
||||
"esp_openclaw_node_example_gpio_node_cmd.c"
|
||||
INCLUDE_DIRS
|
||||
"."
|
||||
"../../common"
|
||||
REQUIRES
|
||||
esp-openclaw-node
|
||||
esp_adc
|
||||
esp_app_format
|
||||
console
|
||||
esp_driver_gpio
|
||||
esp_event
|
||||
esp_netif
|
||||
esp_wifi
|
||||
espressif__cjson
|
||||
mbedtls
|
||||
nvs_flash
|
||||
)
|
||||
3
examples/esp32-node/main/Kconfig.projbuild
Normal file
3
examples/esp32-node/main/Kconfig.projbuild
Normal file
@ -0,0 +1,3 @@
|
||||
menu "OpenClaw Example"
|
||||
|
||||
endmenu
|
||||
92
examples/esp32-node/main/app_main.c
Normal file
92
examples/esp32-node/main/app_main.c
Normal file
@ -0,0 +1,92 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_example_cmd.h"
|
||||
#include "esp_openclaw_node_example_repl.h"
|
||||
#include "esp_openclaw_node_example_repl_cmd.h"
|
||||
#include "esp_openclaw_node_example_saved_session_reconnect.h"
|
||||
#include "esp_openclaw_node.h"
|
||||
#include "esp_openclaw_node_wifi.h"
|
||||
|
||||
#include "esp_event.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_netif.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "soc/soc_caps.h"
|
||||
|
||||
static const char *TAG = "app_main";
|
||||
static esp_openclaw_node_handle_t s_node;
|
||||
static esp_openclaw_node_example_saved_session_reconnect_t s_saved_session_reconnect;
|
||||
|
||||
static void handle_node_event(
|
||||
esp_openclaw_node_handle_t node,
|
||||
esp_openclaw_node_event_t event,
|
||||
const void *event_data,
|
||||
void *user_ctx)
|
||||
{
|
||||
(void)node;
|
||||
|
||||
esp_openclaw_node_example_saved_session_reconnect_handle_event(
|
||||
(esp_openclaw_node_example_saved_session_reconnect_t *)user_ctx,
|
||||
event,
|
||||
event_data);
|
||||
|
||||
if (event == ESP_OPENCLAW_NODE_EVENT_CONNECTED) {
|
||||
ESP_LOGI(TAG, "OpenClaw session connected");
|
||||
} else if (event == ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED) {
|
||||
const esp_openclaw_node_connect_failed_event_t *failed = event_data;
|
||||
ESP_LOGW(
|
||||
TAG,
|
||||
"OpenClaw connect failed: reason=%d local_err=%s gateway_detail=%s",
|
||||
failed != NULL ? failed->reason : -1,
|
||||
failed != NULL ? esp_err_to_name(failed->local_err) : "n/a",
|
||||
failed != NULL && failed->gateway_detail_code != NULL ? failed->gateway_detail_code : "");
|
||||
} else if (event == ESP_OPENCLAW_NODE_EVENT_DISCONNECTED) {
|
||||
const esp_openclaw_node_disconnected_event_t *disconnected = event_data;
|
||||
ESP_LOGW(
|
||||
TAG,
|
||||
"OpenClaw disconnected: reason=%d local_err=%s",
|
||||
disconnected != NULL ? disconnected->reason : -1,
|
||||
disconnected != NULL ? esp_err_to_name(disconnected->local_err) : "n/a");
|
||||
}
|
||||
}
|
||||
|
||||
#if !SOC_WIFI_SUPPORTED
|
||||
#error "examples/esp32-node is a Wi-Fi-specific example and requires a Wi-Fi-capable ESP32 target"
|
||||
#endif
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
esp_err_t err = nvs_flash_init();
|
||||
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||
ESP_ERROR_CHECK(nvs_flash_erase());
|
||||
err = nvs_flash_init();
|
||||
}
|
||||
ESP_ERROR_CHECK(err);
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_wifi_start());
|
||||
|
||||
esp_openclaw_node_config_t node_config = {0};
|
||||
esp_openclaw_node_config_init_default(&node_config);
|
||||
node_config.event_cb = handle_node_event;
|
||||
node_config.event_user_ctx = &s_saved_session_reconnect;
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_create(&node_config, &s_node));
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_example_register_node_commands(s_node));
|
||||
ESP_ERROR_CHECK(
|
||||
esp_openclaw_node_example_saved_session_reconnect_start(
|
||||
&s_saved_session_reconnect,
|
||||
s_node,
|
||||
"esp_openclaw_node_reconnect"));
|
||||
ESP_ERROR_CHECK(esp_openclaw_node_example_repl_start(s_node));
|
||||
|
||||
if (!esp_openclaw_node_wifi_is_connected()) {
|
||||
ESP_LOGI(TAG, "wifi not connected yet; provision it from the REPL or wait for Wi-Fi to reconnect");
|
||||
ESP_LOGI(TAG, "after Wi-Fi is up, use `gateway setup-code`, `gateway token`, `gateway password`, or `gateway no-auth` for a first connect");
|
||||
ESP_LOGI(TAG, "saved-session reconnect runs automatically after Wi-Fi returns when a saved session is present");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,177 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_example_adc_node_cmd.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_adc/adc_cali.h"
|
||||
#include "esp_adc/adc_cali_scheme.h"
|
||||
#include "esp_adc/adc_oneshot.h"
|
||||
#include "esp_check.h"
|
||||
#include "esp_openclaw_node_example_json.h"
|
||||
#include "soc/adc_channel.h"
|
||||
#include "soc/soc_caps.h"
|
||||
|
||||
#if SOC_ADC_SUPPORTED
|
||||
|
||||
typedef struct {
|
||||
adc_oneshot_unit_handle_t adc_handle;
|
||||
bool adc_unit_ready;
|
||||
bool adc_channel_ready[SOC_ADC_CHANNEL_NUM(0)];
|
||||
adc_cali_handle_t adc_cali[SOC_ADC_CHANNEL_NUM(0)];
|
||||
bool adc_cali_ready[SOC_ADC_CHANNEL_NUM(0)];
|
||||
} esp_openclaw_node_example_adc_context_t;
|
||||
|
||||
static const char *TAG = "esp_openclaw_node_adc";
|
||||
static esp_openclaw_node_example_adc_context_t s_adc;
|
||||
|
||||
static esp_err_t ensure_adc_channel(
|
||||
esp_openclaw_node_example_adc_context_t *context,
|
||||
adc_channel_t channel)
|
||||
{
|
||||
if (!context->adc_unit_ready) {
|
||||
adc_oneshot_unit_init_cfg_t init = {
|
||||
.unit_id = ADC_UNIT_1,
|
||||
.ulp_mode = ADC_ULP_MODE_DISABLE,
|
||||
};
|
||||
ESP_RETURN_ON_ERROR(adc_oneshot_new_unit(&init, &context->adc_handle), TAG, "adc init failed");
|
||||
context->adc_unit_ready = true;
|
||||
}
|
||||
|
||||
if (!context->adc_channel_ready[channel]) {
|
||||
adc_oneshot_chan_cfg_t config = {
|
||||
.atten = ADC_ATTEN_DB_12,
|
||||
.bitwidth = ADC_BITWIDTH_DEFAULT,
|
||||
};
|
||||
ESP_RETURN_ON_ERROR(
|
||||
adc_oneshot_config_channel(context->adc_handle, channel, &config),
|
||||
TAG,
|
||||
"adc channel config failed");
|
||||
context->adc_channel_ready[channel] = true;
|
||||
|
||||
#if ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED
|
||||
adc_cali_curve_fitting_config_t cali = {
|
||||
.unit_id = ADC_UNIT_1,
|
||||
.chan = channel,
|
||||
.atten = ADC_ATTEN_DB_12,
|
||||
.bitwidth = ADC_BITWIDTH_DEFAULT,
|
||||
};
|
||||
if (adc_cali_create_scheme_curve_fitting(&cali, &context->adc_cali[channel]) == ESP_OK) {
|
||||
context->adc_cali_ready[channel] = true;
|
||||
}
|
||||
#elif ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED
|
||||
adc_cali_line_fitting_config_t cali = {
|
||||
.unit_id = ADC_UNIT_1,
|
||||
.atten = ADC_ATTEN_DB_12,
|
||||
.bitwidth = ADC_BITWIDTH_DEFAULT,
|
||||
};
|
||||
if (adc_cali_create_scheme_line_fitting(&cali, &context->adc_cali[channel]) == ESP_OK) {
|
||||
context->adc_cali_ready[channel] = true;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t handle_adc_read(
|
||||
esp_openclaw_node_handle_t node,
|
||||
void *context,
|
||||
const char *params_json,
|
||||
size_t params_len,
|
||||
char **out_payload_json,
|
||||
esp_openclaw_node_error_t *out_error)
|
||||
{
|
||||
(void)node;
|
||||
(void)params_len;
|
||||
|
||||
esp_openclaw_node_example_adc_context_t *adc = (esp_openclaw_node_example_adc_context_t *)context;
|
||||
cJSON *params = NULL;
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_example_parse_json_params(params_json, ¶ms, out_error),
|
||||
TAG,
|
||||
"invalid params");
|
||||
cJSON *channel = cJSON_GetObjectItemCaseSensitive(params, "channel");
|
||||
if (!cJSON_IsNumber(channel)) {
|
||||
out_error->message = "channel must be an integer";
|
||||
cJSON_Delete(params);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
int channel_num = channel->valueint;
|
||||
if (channel_num < 0 || channel_num >= SOC_ADC_CHANNEL_NUM(0)) {
|
||||
out_error->message = "channel is out of range for ADC1";
|
||||
cJSON_Delete(params);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
adc_channel_t adc_channel = (adc_channel_t)channel_num;
|
||||
esp_err_t err = ensure_adc_channel(adc, adc_channel);
|
||||
if (err != ESP_OK) {
|
||||
cJSON_Delete(params);
|
||||
ESP_RETURN_ON_ERROR(err, TAG, "adc channel setup failed");
|
||||
}
|
||||
|
||||
int raw = 0;
|
||||
err = adc_oneshot_read(adc->adc_handle, adc_channel, &raw);
|
||||
if (err != ESP_OK) {
|
||||
cJSON_Delete(params);
|
||||
ESP_RETURN_ON_ERROR(err, TAG, "adc read failed");
|
||||
}
|
||||
|
||||
cJSON *payload = cJSON_CreateObject();
|
||||
if (payload == NULL) {
|
||||
cJSON_Delete(params);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
cJSON_AddNumberToObject(payload, "unit", 1);
|
||||
cJSON_AddNumberToObject(payload, "channel", channel_num);
|
||||
cJSON_AddNumberToObject(payload, "raw", raw);
|
||||
int gpio_num = -1;
|
||||
if (adc_oneshot_channel_to_io(ADC_UNIT_1, adc_channel, &gpio_num) == ESP_OK) {
|
||||
cJSON_AddNumberToObject(payload, "gpio", gpio_num);
|
||||
}
|
||||
if (adc->adc_cali_ready[channel_num]) {
|
||||
int mv = 0;
|
||||
if (adc_cali_raw_to_voltage(adc->adc_cali[channel_num], raw, &mv) == ESP_OK) {
|
||||
cJSON_AddNumberToObject(payload, "millivolts", mv);
|
||||
}
|
||||
}
|
||||
cJSON_Delete(params);
|
||||
return esp_openclaw_node_example_take_json_payload(payload, out_payload_json);
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_example_register_adc_node_commands(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
static const esp_openclaw_node_command_t ADC_READ_COMMAND = {
|
||||
.name = "adc.read",
|
||||
.handler = handle_adc_read,
|
||||
.context = &s_adc,
|
||||
};
|
||||
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_register_capability(node, "adc"),
|
||||
TAG,
|
||||
"registering adc capability failed");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_register_command(node, &ADC_READ_COMMAND),
|
||||
TAG,
|
||||
"registering adc.read failed");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
esp_err_t esp_openclaw_node_example_register_adc_node_commands(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
(void)node;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
#endif
|
||||
@ -0,0 +1,24 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_openclaw_node.h"
|
||||
|
||||
/**
|
||||
* @brief Register the ADC command used by the generic ESP32 example.
|
||||
*
|
||||
* On ADC-capable targets the helper adds `adc.read`. Targets without ADC
|
||||
* support compile a no-op implementation that returns `ESP_OK`.
|
||||
*
|
||||
* @param[in] node OpenClaw Node instance to extend.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - an ESP-IDF error code if registration fails
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_example_register_adc_node_commands(esp_openclaw_node_handle_t node);
|
||||
34
examples/esp32-node/main/esp_openclaw_node_example_cmd.c
Normal file
34
examples/esp32-node/main/esp_openclaw_node_example_cmd.c
Normal file
@ -0,0 +1,34 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_example_cmd.h"
|
||||
|
||||
#include "esp_openclaw_node_common_device_node_cmd.h"
|
||||
#include "esp_check.h"
|
||||
#include "soc/soc_caps.h"
|
||||
#include "esp_openclaw_node_example_adc_node_cmd.h"
|
||||
#include "esp_openclaw_node_example_gpio_node_cmd.h"
|
||||
|
||||
static const char *TAG = "esp_openclaw_node_example";
|
||||
|
||||
esp_err_t esp_openclaw_node_example_register_node_commands(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_common_register_device_node_commands(node),
|
||||
TAG,
|
||||
"registering device commands failed");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_example_register_gpio_node_commands(node),
|
||||
TAG,
|
||||
"registering GPIO commands failed");
|
||||
#if SOC_ADC_SUPPORTED
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_example_register_adc_node_commands(node),
|
||||
TAG,
|
||||
"registering ADC commands failed");
|
||||
#endif
|
||||
return ESP_OK;
|
||||
}
|
||||
24
examples/esp32-node/main/esp_openclaw_node_example_cmd.h
Normal file
24
examples/esp32-node/main/esp_openclaw_node_example_cmd.h
Normal file
@ -0,0 +1,24 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_openclaw_node.h"
|
||||
|
||||
/**
|
||||
* @brief Register the generic ESP32 example command set.
|
||||
*
|
||||
* The helper adds the shared `device.*` and `wifi.status` commands plus the
|
||||
* example-specific GPIO and ADC commands supported by the selected target.
|
||||
*
|
||||
* @param[in] node OpenClaw Node instance to extend.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - an ESP-IDF error code if registration fails
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_example_register_node_commands(esp_openclaw_node_handle_t node);
|
||||
@ -0,0 +1,246 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include "esp_openclaw_node_example_gpio_node_cmd.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_check.h"
|
||||
#include "esp_openclaw_node_example_json.h"
|
||||
|
||||
static const char *TAG = "esp_openclaw_node_gpio";
|
||||
static bool s_gpio_mode_configured[GPIO_NUM_MAX];
|
||||
static gpio_mode_t s_gpio_modes[GPIO_NUM_MAX];
|
||||
|
||||
static bool gpio_mode_supports_output(gpio_mode_t mode)
|
||||
{
|
||||
return mode == GPIO_MODE_OUTPUT || mode == GPIO_MODE_INPUT_OUTPUT;
|
||||
}
|
||||
|
||||
static esp_err_t parse_required_pin(cJSON *params, gpio_num_t *out_pin, bool require_output)
|
||||
{
|
||||
cJSON *pin = cJSON_GetObjectItemCaseSensitive(params, "pin");
|
||||
if (!cJSON_IsNumber(pin)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
int pin_num = pin->valueint;
|
||||
if (!GPIO_IS_VALID_GPIO(pin_num) ||
|
||||
(require_output && !GPIO_IS_VALID_OUTPUT_GPIO(pin_num))) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
*out_pin = (gpio_num_t)pin_num;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t handle_gpio_mode(
|
||||
esp_openclaw_node_handle_t node,
|
||||
void *context,
|
||||
const char *params_json,
|
||||
size_t params_len,
|
||||
char **out_payload_json,
|
||||
esp_openclaw_node_error_t *out_error)
|
||||
{
|
||||
(void)node;
|
||||
(void)context;
|
||||
(void)params_len;
|
||||
|
||||
cJSON *params = NULL;
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_example_parse_json_params(params_json, ¶ms, out_error),
|
||||
TAG,
|
||||
"invalid params");
|
||||
|
||||
gpio_num_t pin = GPIO_NUM_NC;
|
||||
if (parse_required_pin(params, &pin, false) != ESP_OK) {
|
||||
out_error->message = "invalid GPIO pin";
|
||||
cJSON_Delete(params);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
cJSON *mode = cJSON_GetObjectItemCaseSensitive(params, "mode");
|
||||
if (!cJSON_IsString(mode) || mode->valuestring == NULL) {
|
||||
out_error->message = "mode must be one of input, output, input_output";
|
||||
cJSON_Delete(params);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
gpio_mode_t gpio_mode = GPIO_MODE_DISABLE;
|
||||
if (strcmp(mode->valuestring, "input") == 0) {
|
||||
gpio_mode = GPIO_MODE_INPUT;
|
||||
} else if (strcmp(mode->valuestring, "output") == 0) {
|
||||
gpio_mode = GPIO_MODE_OUTPUT;
|
||||
} else if (strcmp(mode->valuestring, "input_output") == 0) {
|
||||
gpio_mode = GPIO_MODE_INPUT_OUTPUT;
|
||||
} else {
|
||||
out_error->message = "mode must be one of input, output, input_output";
|
||||
cJSON_Delete(params);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
bool pull_up = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(params, "pullUp"));
|
||||
bool pull_down = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(params, "pullDown"));
|
||||
|
||||
gpio_config_t config = {
|
||||
.pin_bit_mask = 1ULL << pin,
|
||||
.mode = gpio_mode,
|
||||
.pull_up_en = pull_up ? GPIO_PULLUP_ENABLE : GPIO_PULLUP_DISABLE,
|
||||
.pull_down_en = pull_down ? GPIO_PULLDOWN_ENABLE : GPIO_PULLDOWN_DISABLE,
|
||||
.intr_type = GPIO_INTR_DISABLE,
|
||||
};
|
||||
esp_err_t err = gpio_config(&config);
|
||||
if (err != ESP_OK) {
|
||||
cJSON_Delete(params);
|
||||
ESP_RETURN_ON_ERROR(err, TAG, "gpio_config failed");
|
||||
}
|
||||
s_gpio_modes[pin] = gpio_mode;
|
||||
s_gpio_mode_configured[pin] = true;
|
||||
|
||||
cJSON *payload = cJSON_CreateObject();
|
||||
if (payload == NULL) {
|
||||
cJSON_Delete(params);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
cJSON_AddNumberToObject(payload, "pin", pin);
|
||||
cJSON_AddStringToObject(payload, "mode", mode->valuestring);
|
||||
cJSON_AddBoolToObject(payload, "pullUp", pull_up);
|
||||
cJSON_AddBoolToObject(payload, "pullDown", pull_down);
|
||||
cJSON_Delete(params);
|
||||
return esp_openclaw_node_example_take_json_payload(payload, out_payload_json);
|
||||
}
|
||||
|
||||
static esp_err_t handle_gpio_read(
|
||||
esp_openclaw_node_handle_t node,
|
||||
void *context,
|
||||
const char *params_json,
|
||||
size_t params_len,
|
||||
char **out_payload_json,
|
||||
esp_openclaw_node_error_t *out_error)
|
||||
{
|
||||
(void)node;
|
||||
(void)context;
|
||||
(void)params_len;
|
||||
|
||||
cJSON *params = NULL;
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_example_parse_json_params(params_json, ¶ms, out_error),
|
||||
TAG,
|
||||
"invalid params");
|
||||
|
||||
gpio_num_t pin = GPIO_NUM_NC;
|
||||
if (parse_required_pin(params, &pin, false) != ESP_OK) {
|
||||
out_error->message = "invalid GPIO pin";
|
||||
cJSON_Delete(params);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
cJSON *payload = cJSON_CreateObject();
|
||||
if (payload == NULL) {
|
||||
cJSON_Delete(params);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
cJSON_AddNumberToObject(payload, "pin", pin);
|
||||
cJSON_AddNumberToObject(payload, "level", gpio_get_level(pin));
|
||||
cJSON_Delete(params);
|
||||
return esp_openclaw_node_example_take_json_payload(payload, out_payload_json);
|
||||
}
|
||||
|
||||
static esp_err_t handle_gpio_write(
|
||||
esp_openclaw_node_handle_t node,
|
||||
void *context,
|
||||
const char *params_json,
|
||||
size_t params_len,
|
||||
char **out_payload_json,
|
||||
esp_openclaw_node_error_t *out_error)
|
||||
{
|
||||
(void)node;
|
||||
(void)context;
|
||||
(void)params_len;
|
||||
|
||||
cJSON *params = NULL;
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_example_parse_json_params(params_json, ¶ms, out_error),
|
||||
TAG,
|
||||
"invalid params");
|
||||
|
||||
gpio_num_t pin = GPIO_NUM_NC;
|
||||
if (parse_required_pin(params, &pin, true) != ESP_OK) {
|
||||
out_error->message = "invalid output GPIO pin";
|
||||
cJSON_Delete(params);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
cJSON *level = cJSON_GetObjectItemCaseSensitive(params, "level");
|
||||
if (!cJSON_IsNumber(level) && !cJSON_IsBool(level)) {
|
||||
out_error->message = "level must be 0, 1, true, or false";
|
||||
cJSON_Delete(params);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
int level_value = cJSON_IsTrue(level) ? 1 : level->valueint ? 1 : 0;
|
||||
if (!s_gpio_mode_configured[pin] || !gpio_mode_supports_output(s_gpio_modes[pin])) {
|
||||
out_error->message = "configure gpio.mode with output or input_output before gpio.write";
|
||||
cJSON_Delete(params);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
esp_err_t err = gpio_set_level(pin, level_value);
|
||||
if (err != ESP_OK) {
|
||||
cJSON_Delete(params);
|
||||
ESP_RETURN_ON_ERROR(err, TAG, "gpio_set_level failed");
|
||||
}
|
||||
|
||||
cJSON *payload = cJSON_CreateObject();
|
||||
if (payload == NULL) {
|
||||
cJSON_Delete(params);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
cJSON_AddNumberToObject(payload, "pin", pin);
|
||||
cJSON_AddNumberToObject(payload, "level", level_value);
|
||||
cJSON_Delete(params);
|
||||
return esp_openclaw_node_example_take_json_payload(payload, out_payload_json);
|
||||
}
|
||||
|
||||
esp_err_t esp_openclaw_node_example_register_gpio_node_commands(esp_openclaw_node_handle_t node)
|
||||
{
|
||||
memset(s_gpio_mode_configured, 0, sizeof(s_gpio_mode_configured));
|
||||
memset(s_gpio_modes, 0, sizeof(s_gpio_modes));
|
||||
|
||||
static const esp_openclaw_node_command_t GPIO_MODE_COMMAND = {
|
||||
.name = "gpio.mode",
|
||||
.handler = handle_gpio_mode,
|
||||
.context = NULL,
|
||||
};
|
||||
static const esp_openclaw_node_command_t GPIO_READ_COMMAND = {
|
||||
.name = "gpio.read",
|
||||
.handler = handle_gpio_read,
|
||||
.context = NULL,
|
||||
};
|
||||
static const esp_openclaw_node_command_t GPIO_WRITE_COMMAND = {
|
||||
.name = "gpio.write",
|
||||
.handler = handle_gpio_write,
|
||||
.context = NULL,
|
||||
};
|
||||
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_register_capability(node, "gpio"),
|
||||
TAG,
|
||||
"registering gpio capability failed");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_register_command(node, &GPIO_MODE_COMMAND),
|
||||
TAG,
|
||||
"registering gpio.mode failed");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_register_command(node, &GPIO_READ_COMMAND),
|
||||
TAG,
|
||||
"registering gpio.read failed");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_openclaw_node_register_command(node, &GPIO_WRITE_COMMAND),
|
||||
TAG,
|
||||
"registering gpio.write failed");
|
||||
return ESP_OK;
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_openclaw_node.h"
|
||||
|
||||
/**
|
||||
* @brief Register the GPIO commands used by the generic ESP32 example.
|
||||
*
|
||||
* The helper adds the `gpio.mode`, `gpio.read`, and `gpio.write` commands.
|
||||
* The documented path is:
|
||||
* - `gpio.mode` with `"input"`, `"output"`, or `"input_output"`
|
||||
* - `gpio.read`
|
||||
* - `gpio.write` after `"output"` or `"input_output"`
|
||||
*
|
||||
* Open-drain drive modes are intentionally not exposed in this helper.
|
||||
*
|
||||
* @param[in] node OpenClaw Node instance to extend.
|
||||
*
|
||||
* @return
|
||||
* - `ESP_OK` on success
|
||||
* - an ESP-IDF error code if registration fails
|
||||
*/
|
||||
esp_err_t esp_openclaw_node_example_register_gpio_node_commands(esp_openclaw_node_handle_t node);
|
||||
5
examples/esp32-node/sdkconfig.defaults
Normal file
5
examples/esp32-node/sdkconfig.defaults
Normal file
@ -0,0 +1,5 @@
|
||||
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
||||
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=y
|
||||
CONFIG_MBEDTLS_SSL_PROTO_TLS1_3=y
|
||||
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
|
||||
2
examples/esp32-node/sdkconfig.defaults.esp32s3
Normal file
2
examples/esp32-node/sdkconfig.defaults.esp32s3
Normal file
@ -0,0 +1,2 @@
|
||||
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
|
||||
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
|
||||
Loading…
Reference in New Issue
Block a user