ESP32 as a Zigbee console


zigbee modules 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:

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…

  1. command line interface… Well, not exactly. More like a serial port interface, as you’ll see shortly.

  2. 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).

  3. I bet it could be minimized further. Fix it till it’s broken?

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

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

  6. And if you do, shut up and take my money! (or ideas what to do exactly; or both?)

  7. As long as you also have a Zigbee sniffer running. :)