ESP32 as a Zigbee console
Star of today’s show, esp32-c6, in the middle
Introduction
This is the seventh post in the Reversing Philips Hue light driver series.
In the sixth part, I documented the final stretch to v1.0.
Context
After finishing up the v1.0 for e32wamb I still have a lot of work to do. But before I do that, I’m taking a short break to tie some loose ends —
One of the useful features that I kept using during development is the Zigbee CLI1.
For that Espressif has an esp_zigbee_all_device_types_app application in the Zigbee SDK that one can flash to a second esp32-c6 or esp32-h2.
And it works okay-ish. There are a few hiccups to fix, though:
- No support for (non-default) Trust Center key
- No support for sending manufacturer-specific commands to clusters2
Plus, the documentation doesn’t fit my jam, so I’ll briefly go over the installation, missing bits, and usage here.
Solution
Installation
To install, it’s super easy:
git clone https://github.com/espressif/esp-zigbee-sdk --depth 1
cd esp-zigbee-sdk
chmod a+w,g+s,o+t examples/esp_zigbee_all_device_types_app/
docker run --rm -v $PWD:/project -w /project -u $UID -e HOME=/tmp \
-it docker.io/espressif/idf:latest
# in docker:
cd examples/esp_zigbee_all_device_types_app/
idf.py set-target esp32-c6
idf.py build
And to flash (outside of docker):
pipx install esptool
cd examples/esp_zigbee_all_device_types_app/build
esptool.py --chip esp32c6 -b 460800 --before default_reset \
--after hard_reset write_flash "@flash_args"
You might want to give -p /dev/ttyACM0
(or similar) to the
esptool
invocation.
If you do this, you will get the console (something like
screen /dev/ttyACM0 115200
to access it).
With a few wrinkles.
First wrinkle: console doesn’t work
The first is that for some boards, the default sdkconfig doesn’t work.
It takes the following diff:
sdkconfig.diff (click to expand)
--- sdkconfig 2025-05-25 19:44:13.185243121 +0200
+++ sdkconfig.working 2025-05-25 19:18:11.842516066 +0200
@@ -645,13 +645,28 @@
#
# Application Level Tracing
#
-# CONFIG_APPTRACE_DEST_JTAG is not set
-CONFIG_APPTRACE_DEST_NONE=y
+CONFIG_APPTRACE_DEST_JTAG=y
+# CONFIG_APPTRACE_DEST_NONE is not set
+# CONFIG_APPTRACE_DEST_UART0 is not set
# CONFIG_APPTRACE_DEST_UART1 is not set
# CONFIG_APPTRACE_DEST_UART2 is not set
CONFIG_APPTRACE_DEST_UART_NONE=y
CONFIG_APPTRACE_UART_TASK_PRIO=1
+CONFIG_APPTRACE_MEMBUFS_APPTRACE_PROTO_ENABLE=y
+CONFIG_APPTRACE_ENABLE=y
CONFIG_APPTRACE_LOCK_ENABLE=y
+CONFIG_APPTRACE_ONPANIC_HOST_FLUSH_TMO=-1
+CONFIG_APPTRACE_POSTMORTEM_FLUSH_THRESH=0
+CONFIG_APPTRACE_BUF_SIZE=16384
+CONFIG_APPTRACE_PENDING_DATA_SIZE_MAX=0
+
+#
+# FreeRTOS SystemView Tracing
+#
+# CONFIG_APPTRACE_SV_ENABLE is not set
+# end of FreeRTOS SystemView Tracing
+
+# CONFIG_APPTRACE_GCOV_ENABLE is not set
# end of Application Level Tracing
#
@@ -1165,17 +1180,14 @@
# CONFIG_ESP_MAIN_TASK_AFFINITY_NO_AFFINITY is not set
CONFIG_ESP_MAIN_TASK_AFFINITY=0x0
CONFIG_ESP_MINIMAL_SHARED_STACK_SIZE=2048
-CONFIG_ESP_CONSOLE_UART_DEFAULT=y
-# CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG is not set
+# CONFIG_ESP_CONSOLE_UART_DEFAULT is not set
+CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
# CONFIG_ESP_CONSOLE_UART_CUSTOM is not set
# CONFIG_ESP_CONSOLE_NONE is not set
-# CONFIG_ESP_CONSOLE_SECONDARY_NONE is not set
-CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
+CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG_ENABLED=y
-CONFIG_ESP_CONSOLE_UART=y
-CONFIG_ESP_CONSOLE_UART_NUM=0
-CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM=0
-CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200
+CONFIG_ESP_CONSOLE_UART_NUM=-1
+CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM=3
CONFIG_ESP_INT_WDT=y
CONFIG_ESP_INT_WDT_TIMEOUT_MS=300
CONFIG_ESP_TASK_WDT_EN=y
@@ -1211,7 +1223,7 @@
#
# IPC (Inter-Processor Call)
#
-CONFIG_ESP_IPC_TASK_STACK_SIZE=1024
+CONFIG_ESP_IPC_TASK_STACK_SIZE=2048
# end of IPC (Inter-Processor Call)
#
@@ -2140,9 +2152,15 @@
# CONFIG_STACK_CHECK_STRONG is not set
# CONFIG_STACK_CHECK_ALL is not set
# CONFIG_WARN_WRITE_STRINGS is not set
-# CONFIG_ESP32_APPTRACE_DEST_TRAX is not set
-CONFIG_ESP32_APPTRACE_DEST_NONE=y
+CONFIG_ESP32_APPTRACE_DEST_TRAX=y
+# CONFIG_ESP32_APPTRACE_DEST_NONE is not set
+CONFIG_ESP32_APPTRACE_ENABLE=y
CONFIG_ESP32_APPTRACE_LOCK_ENABLE=y
+CONFIG_ESP32_APPTRACE_ONPANIC_HOST_FLUSH_TMO=-1
+CONFIG_ESP32_APPTRACE_POSTMORTEM_FLUSH_TRAX_THRESH=0
+CONFIG_ESP32_APPTRACE_PENDING_DATA_SIZE_MAX=0
+# CONFIG_SYSVIEW_ENABLE is not set
+# CONFIG_ESP32_GCOV_ENABLE is not set
CONFIG_SW_COEXIST_ENABLE=y
CONFIG_ESP32_WIFI_SW_COEXIST_ENABLE=y
CONFIG_ESP_WIFI_SW_COEXIST_ENABLE=y
@@ -2166,13 +2184,11 @@
CONFIG_SYSTEM_EVENT_QUEUE_SIZE=32
CONFIG_SYSTEM_EVENT_TASK_STACK_SIZE=2304
CONFIG_MAIN_TASK_STACK_SIZE=3584
-CONFIG_CONSOLE_UART_DEFAULT=y
+# CONFIG_CONSOLE_UART_DEFAULT is not set
# CONFIG_CONSOLE_UART_CUSTOM is not set
# CONFIG_CONSOLE_UART_NONE is not set
# CONFIG_ESP_CONSOLE_UART_NONE is not set
-CONFIG_CONSOLE_UART=y
-CONFIG_CONSOLE_UART_NUM=0
-CONFIG_CONSOLE_UART_BAUDRATE=115200
+CONFIG_CONSOLE_UART_NUM=-1
CONFIG_INT_WDT=y
CONFIG_INT_WDT_TIMEOUT_MS=300
CONFIG_TASK_WDT=y
@@ -2189,7 +2205,7 @@
# CONFIG_BROWNOUT_DET_LVL_SEL_3 is not set
# CONFIG_BROWNOUT_DET_LVL_SEL_2 is not set
CONFIG_BROWNOUT_DET_LVL=7
-CONFIG_IPC_TASK_STACK_SIZE=1024
+CONFIG_IPC_TASK_STACK_SIZE=2048
CONFIG_TIMER_TASK_STACK_SIZE=3584
CONFIG_ESP32_WIFI_ENABLED=y
CONFIG_ESP32_WIFI_STATIC_RX_BUFFER_NUM=10
to make the console work3.
Second wrinkle: it can’t join a Hue network
Just like the esp32-huello-world, without a proper Trust Center key, the CLI won’t be able to join the Hue network.
Sadly, the support isn’t there, yet.
I’ve opened a PR: (TZ-1820) #659 to add the necessary command. It’s a stupidly simple patch:
tc-key.diff (click to expand)
diff --git a/components/esp-zigbee-console/src/cli_cmd_bdb.c b/components/esp-zigbee-console/src/cli_cmd_bdb.c
index fb918f7..2832aa6 100644
--- a/components/esp-zigbee-console/src/cli_cmd_bdb.c
+++ b/components/esp-zigbee-console/src/cli_cmd_bdb.c
@@ -294,6 +294,33 @@ exit:
return ret;
}
+static esp_err_t cli_nwk_tckey(esp_zb_cli_cmd_t *self, int argc, char **argv)
+{
+ struct {
+ arg_hex_t *tckey;
+ arg_lit_t *help;
+ arg_end_t *end;
+ } argtable = {
+ .tckey = arg_hexn(NULL, NULL, "<key128:KEY>", 1, 1, "trust center key, in HEX format"),
+ .help = arg_lit0(NULL, "help", "Print this help message"),
+ .end = arg_end(2),
+ };
+ esp_err_t ret = ESP_OK;
+
+ /* Parse command line arguments */
+ EXIT_ON_FALSE(argc > 1, ESP_OK, arg_print_help((void**)&argtable, argv[0]));
+ int nerrors = arg_parse(argc, argv, (void**)&argtable);
+ EXIT_ON_FALSE(nerrors == 0, ESP_ERR_INVALID_ARG, arg_print_errors(stdout, argtable.end, argv[0]));
+
+ EXIT_ON_FALSE(argtable.tckey->hsize[0] == 16, ESP_ERR_INVALID_ARG);
+ esp_zb_secur_TC_standard_distributed_key_set(argtable.tckey->hval[0]);
+
+exit:
+ arg_hex_free(argtable.tckey);
+ ESP_ZB_CLI_FREE_ARGSTRUCT(&argtable);
+ return ret;
+}
+
static esp_err_t cli_nwk_childmax(esp_zb_cli_cmd_t *self, int argc, char **argv)
{
struct {
@@ -932,6 +959,7 @@ DECLARE_ESP_ZB_CLI_CMD(channel, cli_channel,, "Get/Set 802.15.4 channels for ne
DECLARE_ESP_ZB_CLI_CMD_WITH_SUB(network, "Network configuration",
ESP_ZB_CLI_SUBCMD(type, cli_nwk_type, "Get/Set the network type"),
ESP_ZB_CLI_SUBCMD(key, cli_nwk_key, "Get/Set the network key"),
+ ESP_ZB_CLI_SUBCMD(tckey, cli_nwk_tckey, "Set the trust center key"),
ESP_ZB_CLI_SUBCMD(legacy, cli_nwk_legacy, "Enable/Disable legacy device support"),
ESP_ZB_CLI_SUBCMD(childmax, cli_nwk_childmax, "Get/Set max children number"),
ESP_ZB_CLI_SUBCMD(open, cli_nwk_open, "Open local network"),
which then allows to set the Trust Center key, and join the network4 thusly:
network tckey 0x00000000000000000000000000000000
bdb_comm start steer
Obviously instead of an all-zero key you’ll have to use the proper Philips TC key. ;)
Third wrinkle: it can’t send manufacturer-specific commands to clusters
Third wrinkle is simple: To add functionality beyond standard Zigbee spec, one can use manufacturer-specific commands. But while the app supports them for attributes:
# Read "rf switch" on e32wamb
zcl send_gen read -d 0x4db8 --dst-ep 10 -e 10 -c 0x0 -a 0x7a69 --manuf 0x131B
# Set "rf switch" to "u.fl" on e32wamb
zcl send_gen write -d 0x4db8 --dst-ep 10 -e 10 -c 0x0 -a 0x7a69 --manuf 0x131B -t 0x10 -v 0x01
Same support was missing for command invocation.
So I fixed the glitch (ms-cmds.diff, in-flight PR: (TZ-1819) #658 ):
ms-cmds.diff (click to expand)
diff --git a/components/esp-zigbee-console/src/cli_cmd_zcl.c b/components/esp-zigbee-console/src/cli_cmd_zcl.c
index 5bec939..2c01d1f 100644
--- a/components/esp-zigbee-console/src/cli_cmd_zcl.c
+++ b/components/esp-zigbee-console/src/cli_cmd_zcl.c
@@ -862,12 +862,14 @@ static esp_err_t cli_zcl_send_raw(esp_zb_cli_cmd_t *self, int argc, char **argv)
esp_zb_cli_aps_argtable_t aps;
arg_str_t *peer_role;
arg_u8_t *command;
+ arg_u16_t *manuf_code;
arg_hex_t *payload;
arg_lit_t *dry_run;
arg_end_t *end;
} argtable = {
.peer_role = arg_strn("r", "role", "<sc:C|S>", 0, 1, "role of the peer cluster, default: S"),
.command = arg_u8n(NULL, "cmd", "<u8:CMD_ID>", 1, 1, "identifier of the command"),
+ .manuf_code = arg_u16n(NULL, "manuf", "<u16:CODE>", 0, 1, "set manufacturer's code"),
.payload = arg_hexn("p", "payload", "<hex:DATA>", 0, 1, "ZCL payload of the command, raw HEX data"),
.dry_run = arg_lit0("n", "dry-run", "print the request being sent"),
.end = arg_end(2),
@@ -895,6 +897,13 @@ static esp_err_t cli_zcl_send_raw(esp_zb_cli_cmd_t *self, int argc, char **argv)
&req_params.cluster_id,
&req_params.profile_id));
+ if (argtable.manuf_code->count > 0) {
+ if (argtable.manuf_code->val[0] != ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC) {
+ req_params.manuf_specific = 1;
+ req_params.manuf_code = argtable.manuf_code->val[0];
+ }
+ }
+
if (argtable.peer_role->count > 0) {
switch (argtable.peer_role->sval[0][0]) {
case 'C':
With this, manufacturer-specific commands on standard clusters (e.g. the “reboot” or “clear nvs” on e32wamb) are call-able:
# Reboot e32wamb (with fw e9f1d29 or later)
zcl send_raw -d 0x4db8 --dst-ep 10 -e 10 --profile 0x104 -c 0x0 --cmd 0xaa
-p 0x1337c0d3 --manuf 0x131B
# Clear NVS on e32wamb (with fw e9f1d29 or later)
zcl send_raw -d 0x4db8 --dst-ep 10 -e 10 --profile 0x104 -c 0x0 --cmd 0xb0
-p 0x1337c0d3 --manuf 0x131B
Usage
As I already alluded to, using the Zigbee console is somewhat non-intuitive, for me.
This is my crib sheet that I compiled over time:
Join network
# the tckey needs patch, as described above
network tckey 0x00000000000000000000000000000000
bdb_comm start steer
Read / write attribute
Read attribute 0x4003
(startup on off) on cluster 0x6
on device 0x4db8
with endpoint 10
:
# Read
zcl send_gen read -d 0x4db8 --dst-ep 10 -e 10 -c 0x6 -a 0x4003
# Write
zcl send_gen write -d 0x4db8 --dst-ep 10 -e 10 -c 0x6 -a 0x4003 -t 0x30 -v 0x01
Additional wrinkle: The result of the read won’t be visible in the console5. But you can find it when you run a Zigbee sniffer at the same time. ;)
Send command
To execute “Move to color temperature” (0xa
) on device 0x4db8
with endpoint 10
on cluster 0x300
:
# Temperature: 153
zcl send_raw -d 0x4db8 --dst-ep 10 -e 10 --profile 0x104 -c 0x300 --cmd 0xa -p 0x99000400
# Temperature: 366
zcl send_raw -d 0x4db8 --dst-ep 10 -e 10 --profile 0x104 -c 0x300 --cmd 0xa -p 0x6e010400
# Temperature: 454
zcl send_raw -d 0x4db8 --dst-ep 10 -e 10 --profile 0x104 -c 0x300 --cmd 0xa -p 0xc6010400
Send manufacturer-specific command
As described above, to send manufacturer-specific command 0xaa
(for manufacturer code 0x131B
(Espressif)) to device 0x4db8
to cluster 0x0
(basic) with payload 0x1337c0d3
:
zcl send_raw -d 0x4db8 --dst-ep 10 -e 10 --profile 0x104 -c 0x0 --cmd 0xaa
-p 0x1337c0d3 --manuf 0x131B
Test if device is reachable
zdo request ieee_addr -d 0x0001
Show neighbors
Show table of neighbors of given device:
zdo request neighbors -d 0x4db8
Closing words
I’m sure someone could turn the basic esp_zigbee_all_device_types_app
into a kickass UI6, with all kinds of bells and whistles.
But even in the current form, it’s a light-saber life-saver for
debugging7.
Without that, injecting arbitrary commands would be painful.
Or am I missing some obviously better solution? Happy to hear your thoughts…
-
command line interface… Well, not exactly. More like a serial port interface, as you’ll see shortly. ↩
-
This is essentially a way to expand standard Zigbee device with custom commands that are keyed to a given manufacturer ID. It’s documented in the ZCL (see section 2.3.3). ↩
-
I bet it could be minimized further. Fix it till it’s broken? ↩
-
It should go without saying that you need to “search” for new devices in the Hue app when the CLI wants to join for the first time – to make sure the network is open to join. ↩
-
I’m thinking that maybe if the destination endpoint was configured locally, it would show up. Not sure, though. So far I was happy with the sniffer. ↩
-
And if you do, shut up and take my money! (or ideas what to do exactly; or both?) ↩
-
As long as you also have a Zigbee sniffer running. :) ↩