Demo of an over-the-air update. The web page is only a visual cue to make the swap
visible — esp-ctl ota upload streams a new firmware over the encrypted
link and the device reboots into it in place, no cable, core untouched.
A reusable application-layer firmware on top of ESP-IDF that exposes the full capability surface of any ESP32-family chip to a Linux host over an encrypted TCP shell-style command protocol. Each downstream project builds on top of the core and only adds its own command handlers.
Status: Phases 1–4 implemented (WiFi, TCP, auth, AEAD, OTA, peripherals, filesystem, power). Forward secrecy is not implemented and the
/2handshake design is not final — use/1only on a trusted LAN or VPN.
esp32(Xtensa LX6, dual-core, BT classic + BLE 4.2, DAC, Hall sensor)esp32s3(LX7, dual-core, USB-OTG, BLE 5)esp32c3(RISC-V single-core, BLE 5)esp32c6(RISC-V, BLE 5 + Thread + Zigbee)
Capability matrix in components/core/include/targets.h.
- Transport: TCP over WiFi, single active client per device.
- Handshake (cleartext): server emits
HELLOwith a 32-byte random nonce; client responds withAUTH cnonce=… hmac=…wherehmac = HMAC-SHA256(token, "espshell-auth-v1" || snonce || cnonce). - Session keys:
HKDF-SHA256(token, salt = snonce ‖ cnonce, info = "espshell-session-v1"). - All subsequent frames are binary, length-prefixed, ChaCha20-Poly1305 AEAD. Per-direction monotonic sequence numbers serve as the AEAD nonce → strict anti-replay.
- Per-connection retry delay: 3 failed authentication attempts on one socket → 10-second cool-down, then drop. Reconnecting resets the counter; this is not a global or per-peer rate limiter.
- The token never crosses the wire. A captured handshake is nevertheless an offline verifier for token guesses, so use a randomly generated, high-entropy token rather than a human password.
/1has no forward secrecy: disclosure of a token permits derivation of captured historical sessions. Server authentication is established only after the client validates an encrypted record, not by the cleartextOK.- The authenticated token grants administrative command access. Disabling
MEM_READ/WRITEdoes not make the remaining commands unprivileged. - The handshake timeout applies to individual blocking receives, not to the complete handshake. After authentication, reads are blocking and writes have no explicit deadline. A peer making slow progress or not reading can occupy the single-client server longer than the nominal timeout.
- SHA-256 verification during OTA detects corruption; it does not authenticate who produced the firmware. Firmware provenance requires a signed-boot/update policy appropriate for the deployment.
- When enabled, mDNS announces the service, device name, protocol version, chip target, and firmware version without authentication.
Full protocol and limitations: docs/protocol.md.
PING · INFO · STATS · UPTIME · HEAP · TASKS · RESET_REASON ·
REBOOT [ms] · FACTORY_RESET · CMDS · HELP <cmd>
CFG_GET <key> · CFG_SET <key> <value> · CFG_DEL <key> ·
CFG_LIST [prefix] · CFG_COMMIT
(wifi_pass and auth_token are redacted on read.)
LOG_LEVEL <0..5> · LOG_STREAM ON|OFF
WIFI_STATUS · WIFI_SCAN · WIFI_SET <ssid> <pass> · WIFI_RECONNECT
GPIO_MODE <pin> <INPUT|OUTPUT|INPUT_PULLUP|INPUT_PULLDOWN> ·
GPIO_SET <pin> <HIGH|LOW> · GPIO_GET <pin> · GPIO_TOGGLE <pin> ·
GPIO_WATCH <pin> <RISING|FALLING|ANY> · GPIO_UNWATCH <pin>
ADC_READ <ch> · ADC_READ_MV <ch> · ADC_STREAM <ch> <ms> ·
ADC_STREAM_STOP <ch> · DAC_WRITE <ch> <0..255> (ESP32 only)
PWM_INIT <ch> <pin> <freq> <res> · PWM_SET <ch> <duty> · PWM_STOP <ch>
I2C_INIT <port> <sda> <scl> <hz> · I2C_SCAN <port> ·
I2C_READ <port> <addr> <reg> <n> · I2C_WRITE <port> <addr> <reg> <hex>
SPI_INIT <host> <miso> <mosi> <sclk> <cs> <hz> <mode> · SPI_TXRX <host> <hex>
UART_INIT <port> <tx> <rx> <baud> · UART_WRITE <port> <hex> ·
UART_READ <port> <n> <timeout_ms> · UART_STREAM <port> ON|OFF
FS_INFO · FS_LIST [path] · FS_READ <path> · FS_WRITE <path> <hex> ·
FS_DEL <path> · FS_FORMAT
TIME_GET · TIME_SET <unix_ts> · SNTP_SYNC [server]
SLEEP_LIGHT <ms> · SLEEP_DEEP <ms> · CPU_FREQ <80|160|240>
OTA_BEGIN <size> <sha256> · OTA_DATA <hex> · OTA_END ·
OTA_ABORT · OTA_ROLLBACK
CHIP_TEMP · HALL_READ (ESP32 only) ·
MEM_READ <addr> <n> / MEM_WRITE <addr> <hex> (Kconfig-gated, off by default)
BLE_SCAN <s> · BLE_ADVERTISE <name> · BLE_STOP — stubs in v1; full
NimBLE integration in v2 (adds ~150 KB flash, needs partition rebalance).
LOG · HEALTH · GPIO · ADC · UART,
plus arbitrary EVT PROJECT <data> emitted by downstream code via net_send_event().
#include "core.h"
static bool cmd_read_bme(int argc, char **argv, char *resp, size_t sz) {
/* ... */
snprintf(resp, sz, "t=%.2f h=%.2f", t, h);
return true;
}
void project_init(void) {
cmd_register("READ_BME", cmd_read_bme, "Read BME280 sensor");
}Full step-by-step walkthrough in docs/TUTORIAL.md —
from git clone to running your own custom command over OTA.
git clone https://github.com/AdrianRodriguezM/espshell && cd espshell
. $HOME/esp/esp-idf/export.sh # activate ESP-IDF environment (once per shell session)
idf.py set-target esp32 # or esp32s3 / esp32c3 / esp32c6
idf.py build flash monitorIf you use the get_idf alias (recommended by Espressif), run get_idf instead
of sourcing export.sh directly — both activate the same environment.
Port selection: pass -p <port> explicitly if the device is not on
/dev/ttyUSB0. On Linux, USB-serial adapters usually appear as
/dev/ttyUSB0 (CP210x/CH340) or /dev/ttyACM0 (CDC-ACM). Run
ls /dev/ttyUSB* /dev/ttyACM* to find the right port. If you get
"permission denied", add yourself to the dialout group:
sudo usermod -aG dialout $USER (requires re-login).
Stale build directory: if you move or rename the project folder, the cached
build/ directory will refuse to build with a path mismatch. Fix with:
idf.py fullclean
idf.py buildOn first boot without WiFi credentials, the device starts a temporary open AP:
*** espshell first-boot token ***
<64-hex-chars> ← copy this
╔═══════════════════════════════════════╗
║ espshell — provisioning mode ║
║ 1. Connect to WiFi: espshell-XXYY ║
║ 2. esp-ctl --host 192.168.4.1 shell ║
║ 3. WIFI_SET <ssid> <pass> ║
║ 4. REBOOT ║
╚═══════════════════════════════════════╝
The full encrypted shell runs on 192.168.4.1:9000 — same protocol, same
token auth. After REBOOT the device comes up in STA mode and announces
itself via mDNS (esp-ctl discover). No UART cable needed again.
To skip provisioning mode, set the WiFi SSID/password via idf.py menuconfig
before flashing (espshell core → Default WiFi SSID/Password).
On the very first boot the device prints a generated hex token over UART. Copy it — it is only printed once. Its strength depends on the target's active entropy sources during first boot. If lost, erase the NVS partition to regenerate:
python3 -m esptool --chip esp32 -p /dev/ttyUSB0 -b 115200 \
erase-region 0x9000 0x6000cd tools/esp-ctl
make
cp devices.example.toml ~/.config/esp-ctl/devices.toml
$EDITOR ~/.config/esp-ctl/devices.toml
chmod 600 ~/.config/esp-ctl/devices.toml
./esp-ctl --device default shellThe firmware advertises _espshell._tcp over mDNS (Kconfig:
ESPSHELL_ENABLE_MDNS, hostname from device_name). One shot:
./esp-ctl discover # prints a ready-to-paste devices.toml sectionhost = "name.local" also works in profiles if your system resolves mDNS
names (nss-mdns); discover itself has no such dependency.
Licensed under GNU GPL v3.0 or later (GPL-3.0-or-later, SPDX).
See LICENSE.
