mirror of
https://github.com/arendst/Tasmota.git
synced 2026-07-28 04:15:53 +00:00
feat: add qemu emulator for esp32 (#24861)
* feat: add qemu emulator * chore: use current dir instead of cloning repo * Add QEMU emulator tooling under tools/qemu tools/qemu/tasmota-qemu.sh builds the current Tasmota checkout for an ESP32 PlatformIO env and boots it under an Espressif QEMU fork on an amd64 host: serial console, and a browsable web UI via the WiFi-capable fork. NET_WIFI=1 tools/qemu/tasmota-qemu.sh # build -> image -> wifi QEMU -> web UI Notes: - Builds the local working tree (no clone); artifacts and QEMU forks live under a gitignored tasmota-qemu/ at the repo root. - ESP32-only helper; no Tasmota core source is changed. See tools/qemu/README.md for commands, env vars, and panic-debugging notes. * chore: cleanup
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# Running Tasmota (ESP32) under QEMU
|
||||
|
||||
`tasmota-qemu.sh` (in `tools/qemu/`) builds a Tasmota ESP32 firmware with PlatformIO and
|
||||
boots it under an Espressif QEMU fork on an amd64 host — serial console always, and a
|
||||
**browsable web UI** when the WiFi-capable fork is used.
|
||||
|
||||
It builds the **current checkout** (your local changes), not a fresh clone. Build
|
||||
artifacts and the QEMU forks live under `tasmota-qemu/` at the repo root (gitignored).
|
||||
Examples below assume you run it from `tools/qemu/`; from the repo root use
|
||||
`tools/qemu/tasmota-qemu.sh …` — either works, paths resolve the same.
|
||||
|
||||
## Quick start
|
||||
|
||||
Networked Tasmota with a reachable web UI, in one command:
|
||||
|
||||
```bash
|
||||
NET_WIFI=1 ./tasmota-qemu.sh # build -> image -> wifi QEMU -> boot, joins emulated AP
|
||||
# then browse:
|
||||
open http://localhost:8080
|
||||
```
|
||||
|
||||
First run is slow: it clones Tasmota, pulls the toolchain, compiles the firmware, and
|
||||
clones + builds the QEMU fork. Subsequent runs are cached.
|
||||
|
||||
Serial-only (no network, stock QEMU, fastest path):
|
||||
|
||||
```bash
|
||||
./tasmota-qemu.sh # stock fork, serial console only
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
./tasmota-qemu.sh [command]
|
||||
build Compile Tasmota firmware (PlatformIO env $TASMOTA_ENV)
|
||||
make-image Build the QEMU flash image (factory.bin, or merge raw parts)
|
||||
qemu-setup Clone + build the chosen QEMU fork
|
||||
run Boot the firmware in QEMU (serial tee'd to qemu.log)
|
||||
smoketest Boot a minimal Arduino app (same IDF) to sanity-check QEMU itself
|
||||
decode [log] Symbolize the last panic backtrace from a log (default: qemu.log)
|
||||
all build -> make-image -> qemu-setup -> run (default)
|
||||
```
|
||||
|
||||
## Key environment variables
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|-------------------|----------------------|---------|
|
||||
| `NET_WIFI` | `0` | `1` = one-shot networked recipe (see below) |
|
||||
| `WIFI_SSID` | `Open Wifi` | AP the firmware joins when `NET_WIFI=1` |
|
||||
| `WIFI_PASS` | *(empty)* | password for that AP (the emulated one is open) |
|
||||
| `NET_WIFI_ENV` | `tasmota32solo1` | env used by `NET_WIFI=1` |
|
||||
| `TASMOTA_ENV` | `tasmota32` | PlatformIO env (must be an ESP32 one) |
|
||||
| `MINIMAL` | `0` | `1` = build the stripped `tasmota32-safeboot` env |
|
||||
| `QEMU_VARIANT` | `stock` | `stock` (no WiFi) or `wifi` (Ebiroll/calib fork) |
|
||||
| `FLASH_SIZE` | `4MB` | `2MB`/`4MB`/`8MB`/`16MB` only |
|
||||
| `WEB_PORT_HOST` | `8080` | host port forwarded to guest `:80` |
|
||||
| `USE_SYSTEM_QEMU` | `0` | `1` = use `qemu-system-xtensa` from `PATH` |
|
||||
| `INSTALL_DEPS` | `0` | `1` = apt-get the QEMU build deps (needs sudo) |
|
||||
|
||||
## How `NET_WIFI=1` works
|
||||
|
||||
Getting a networked Tasmota into QEMU needs three things to line up; `NET_WIFI=1`
|
||||
sets all of them:
|
||||
|
||||
1. **Survive early IDF init** — the full dual-core `tasmota32` build panics in
|
||||
`esp_timer_impl_init` under QEMU (null interrupt vector). The **unicore
|
||||
`tasmota32solo1`** build does not, so `NET_WIFI=1` selects it.
|
||||
2. **Survive WiFi bring-up** — Tasmota's WiFi blobs poke RF registers (`0x6003xxxx`)
|
||||
that the **stock** QEMU does not emulate (`LoadStorePIFAddrError`). The
|
||||
**`wifi` fork** (Ebiroll/calib) emulates them, so `NET_WIFI=1` forces
|
||||
`QEMU_VARIANT=wifi`.
|
||||
3. **Get an IP the host can reach** — that fork advertises one open AP named
|
||||
**`Open Wifi`**. `NET_WIFI=1` writes station credentials into a managed block in
|
||||
`tasmota/user_config_override.h`, so the firmware joins it in station mode, gets a
|
||||
slirp DHCP lease (`10.0.2.15`), and the `:8080 -> :80` port-forward becomes routable.
|
||||
|
||||
The credential block is delimited and idempotent — re-running rewrites only that block
|
||||
and leaves the rest of `user_config_override.h` intact.
|
||||
|
||||
## Debugging a panic
|
||||
|
||||
The `run` step boots with `-no-reboot` (QEMU exits on the first guest reset instead of
|
||||
looping) and tees the serial console to `tasmota-qemu/qemu.log`. To symbolize the last
|
||||
panic:
|
||||
|
||||
```bash
|
||||
./tasmota-qemu.sh decode # decodes tasmota-qemu/qemu.log
|
||||
./tasmota-qemu.sh decode some-other.log # decodes a specific log
|
||||
```
|
||||
|
||||
`decode` strips the `0xffffffff` sentinel frame, takes the PC of each `pc:sp` pair, and
|
||||
runs `addr2line` against the matching `firmware.elf`. Frames in app flash
|
||||
(`0x4018xxxx`+) are the useful ones. Addresses that resolve to `??` are inside the
|
||||
closed-source WiFi/BT blobs (no symbols) — usually a sign the firmware touched radio
|
||||
hardware the emulator doesn't model.
|
||||
|
||||
Is it QEMU or the firmware? Run `./tasmota-qemu.sh smoketest`: it builds a minimal Arduino
|
||||
app with the *same* IDF/toolchain and boots it. If the minimal app runs, QEMU is fine
|
||||
and the problem is in the Tasmota build.
|
||||
|
||||
## Web UI: only `/` loads, sub-pages give `ERR_EMPTY_RESPONSE`
|
||||
|
||||
This is expected on first use and is **not** a crash. Tasmota's referer check only
|
||||
accepts a `Referer` whose host is its own hostname or local IP (`10.0.2.15`). Through
|
||||
the host port-forward your browser sends `Referer: http://localhost:8080/`, whose host
|
||||
is `localhost` — matching neither — so every page past `/` is dropped with an empty
|
||||
response.
|
||||
|
||||
With `NET_WIFI=1` this is handled automatically: a background watcher waits for the web
|
||||
server to come up and issues `SetOption128 1` for you, so the UI is clickable out of the
|
||||
box. If you ever need to do it by hand (e.g. you ran a plain `run` on a fresh image):
|
||||
|
||||
```bash
|
||||
./tasmota-qemu.sh fixui # sends SetOption128 1 (disable referer check)
|
||||
```
|
||||
|
||||
The setting persists to flash. A fresh `make-image` wipes flash, but the next
|
||||
`NET_WIFI=1` run re-applies it automatically.
|
||||
|
||||
## Caveats
|
||||
|
||||
- The `wifi` fork is older than the firmware's IDF; its RF-register model is good enough
|
||||
to associate but is not a full WiFi stack.
|
||||
- `stock` + a real Tasmota build will boot to a serial console but the web UI will not
|
||||
respond (no WiFi emulation). Use `NET_WIFI=1` for network access.
|
||||
- Stop a running QEMU with `Ctrl-A` then `X`, or:
|
||||
`for p in $(pgrep -f qemu-system-xtensa); do kill -9 $p; done`
|
||||
Executable
+558
@@ -0,0 +1,558 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# tasmota-qemu.sh
|
||||
# Build Tasmota (ESP32) and run it under the Espressif QEMU fork on an amd64 host.
|
||||
#
|
||||
# Pipeline:
|
||||
# 1. build — compile a Tasmota ESP32 firmware with PlatformIO
|
||||
# 2. make-image — pad the .factory.bin into a valid QEMU flash image
|
||||
# 3. qemu-setup — clone + build the chosen QEMU fork (xtensa-softmmu)
|
||||
# 4. run — boot the firmware in QEMU
|
||||
# all — do 1->4 in sequence (default)
|
||||
#
|
||||
# Usage:
|
||||
# ./tasmota-qemu.sh [build|make-image|qemu-setup|run|smoketest|decode|all]
|
||||
#
|
||||
# Networked Tasmota with a browsable web UI in one command:
|
||||
# NET_WIFI=1 ./tasmota-qemu.sh # then open http://localhost:8080
|
||||
#
|
||||
# Everything is configurable via environment variables (see the block below),
|
||||
# e.g.: QEMU_VARIANT=wifi NET=eth ./tasmota-qemu.sh all
|
||||
#
|
||||
# Full docs, env vars, and panic-debugging notes: see README-qemu.md
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# IMPORTANT NETWORKING CAVEAT
|
||||
# Stock Tasmota expects WiFi. The stock Espressif QEMU fork has NO WiFi, only
|
||||
# an emulated OpenCores Ethernet MAC (open_eth). So with QEMU_VARIANT=stock the
|
||||
# firmware boots fine and you get a full SERIAL console, but the web UI / MQTT
|
||||
# will NOT be reachable over the network because Tasmota never brings up an
|
||||
# interface it recognises.
|
||||
#
|
||||
# For actual network access use QEMU_VARIANT=wifi (the Ebiroll/calib fork,
|
||||
# which adds an 'esp32_wifi' NIC model). Exact NIC flags drift between fork
|
||||
# revisions — if networking doesn't come up, check that fork's README and
|
||||
# adjust the -nic line in run_qemu().
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ----------------------------- configuration -------------------------------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# This script lives at <repo>/tools/qemu/, so the repo root is two levels up.
|
||||
TASMOTA_SRC="${TASMOTA_SRC:-$(cd "$SCRIPT_DIR/../.." && pwd)}" # Tasmota checkout to build
|
||||
# (default: this repo — your local changes, no clone)
|
||||
WORKDIR="${WORKDIR:-$TASMOTA_SRC/tasmota-qemu}" # qemu builds, flash images, venv (gitignored)
|
||||
TASMOTA_ENV="${TASMOTA_ENV:-tasmota32}" # PlatformIO env (must be an ESP32 one)
|
||||
MINIMAL="${MINIMAL:-0}" # 1 = build the stripped safeboot env (fewest
|
||||
# IDF drivers -> fewest pre-app init fns;
|
||||
# test if a Tasmota driver init breaks QEMU)
|
||||
MINIMAL_ENV="${MINIMAL_ENV:-tasmota32-safeboot}"
|
||||
[ "$MINIMAL" = "1" ] && TASMOTA_ENV="$MINIMAL_ENV" # overrides TASMOTA_ENV; keys whole pipeline
|
||||
|
||||
# NET_WIFI=1 is the one-shot "networked Tasmota" recipe proven to reach the web UI:
|
||||
# wifi QEMU fork + unicore solo1 build (dodges the dual-core esp_timer crash)
|
||||
# + station creds for the fork's emulated open AP -> slirp DHCP -> hostfwd works.
|
||||
NET_WIFI="${NET_WIFI:-0}"
|
||||
NET_WIFI_ENV="${NET_WIFI_ENV:-tasmota32solo1}" # full-feature unicore env that does STA
|
||||
WIFI_SSID="${WIFI_SSID:-Open Wifi}" # calib fork's emulated AP (esp32_wifi_ap.c)
|
||||
WIFI_PASS="${WIFI_PASS:-}" # that AP is open (no PSK)
|
||||
if [ "$NET_WIFI" = "1" ]; then
|
||||
QEMU_VARIANT="wifi"
|
||||
[ "$TASMOTA_ENV" = "tasmota32" ] && TASMOTA_ENV="$NET_WIFI_ENV" # only if still default
|
||||
fi
|
||||
FLASH_SIZE="${FLASH_SIZE:-4MB}" # QEMU esp32 accepts ONLY 2MB|4MB|8MB|16MB
|
||||
QEMU_VARIANT="${QEMU_VARIANT:-stock}" # stock | wifi
|
||||
NET="${NET:-eth}" # none | eth (eth = add an emulated NIC)
|
||||
WEB_PORT_HOST="${WEB_PORT_HOST:-8080}" # host port forwarded to guest :80 (web UI)
|
||||
DISABLE_WDT="${DISABLE_WDT:-1}" # 1 = disable TG watchdogs (avoids boot reset loops)
|
||||
JOBS="${JOBS:-$(nproc)}"
|
||||
INSTALL_DEPS="${INSTALL_DEPS:-0}" # 1 = apt-get the QEMU build deps (needs sudo)
|
||||
USE_SYSTEM_QEMU="${USE_SYSTEM_QEMU:-0}" # 1 = use qemu-system-xtensa already on PATH
|
||||
|
||||
# QEMU source repos for the two paths
|
||||
QEMU_STOCK_REPO="https://github.com/espressif/qemu.git"
|
||||
QEMU_STOCK_BRANCH="esp-develop"
|
||||
QEMU_WIFI_REPO="https://github.com/Ebiroll/calib.git"
|
||||
QEMU_WIFI_BRANCH="master"
|
||||
ESP32_ROM_URL="https://raw.githubusercontent.com/espressif/qemu/esp-develop/pc-bios/esp32-r0-rom.bin"
|
||||
|
||||
QEMU_BIN="" # resolved by setup_qemu()
|
||||
|
||||
# ------------------------------- helpers -----------------------------------
|
||||
log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; }
|
||||
err() { printf '\033[1;31m[!]\033[0m %s\n' "$*" >&2; }
|
||||
|
||||
flash_bytes() {
|
||||
case "$1" in
|
||||
2MB) echo $(( 2 * 1024 * 1024 ));;
|
||||
4MB) echo $(( 4 * 1024 * 1024 ));;
|
||||
8MB) echo $(( 8 * 1024 * 1024 ));;
|
||||
16MB) echo $(( 16 * 1024 * 1024 ));;
|
||||
*) err "Invalid FLASH_SIZE '$1' (use 2MB|4MB|8MB|16MB)"; exit 1;;
|
||||
esac
|
||||
}
|
||||
|
||||
download() { # download <url> <dest>
|
||||
if command -v curl >/dev/null; then curl -fsSL "$1" -o "$2"
|
||||
elif command -v wget >/dev/null; then wget -qO "$2" "$1"
|
||||
else err "need curl or wget to fetch $1"; exit 1; fi
|
||||
}
|
||||
|
||||
# Write/refresh the WiFi station creds into a managed block in
|
||||
# user_config_override.h so the build joins the emulated AP (NET_WIFI=1).
|
||||
ensure_wifi_creds() {
|
||||
local ovr="$TASMOTA_SRC/tasmota/user_config_override.h"
|
||||
local b="// >>> run-it.sh NET_WIFI creds (managed) >>>"
|
||||
local e="// <<< run-it.sh NET_WIFI creds (managed) <<<"
|
||||
local block
|
||||
block="$(cat <<EOF
|
||||
$b
|
||||
#undef CFG_HOLDER
|
||||
#define CFG_HOLDER 4618 // bump forces SECTION1 (Wi-Fi) to defaults
|
||||
#undef STA_SSID1
|
||||
#define STA_SSID1 "$WIFI_SSID"
|
||||
#undef STA_PASS1
|
||||
#define STA_PASS1 "$WIFI_PASS"
|
||||
#undef WIFI_CONFIG_TOOL
|
||||
#define WIFI_CONFIG_TOOL WIFI_RETRY // keep retrying STA, no captive-AP fallback
|
||||
$e
|
||||
EOF
|
||||
)"
|
||||
if [ ! -f "$ovr" ]; then
|
||||
printf '#ifndef _USER_CONFIG_OVERRIDE_H_\n#define _USER_CONFIG_OVERRIDE_H_\n\n%s\n\n#endif // _USER_CONFIG_OVERRIDE_H_\n' "$block" > "$ovr"
|
||||
log "Wrote WiFi creds override (SSID '$WIFI_SSID'): $ovr"
|
||||
return
|
||||
fi
|
||||
# strip any previous managed block, then insert fresh before the guard #endif
|
||||
awk -v b="$b" -v e="$e" '
|
||||
$0==b {skip=1} skip && $0==e {skip=0; next} !skip' "$ovr" > "$ovr.tmp"
|
||||
awk -v block="$block" '
|
||||
/#endif[[:space:]]*\/\/[[:space:]]*_USER_CONFIG_OVERRIDE_H_/ && !done {print block; done=1}
|
||||
{print}' "$ovr.tmp" > "$ovr"
|
||||
rm -f "$ovr.tmp"
|
||||
log "Refreshed WiFi creds override (SSID '$WIFI_SSID'): $ovr"
|
||||
}
|
||||
|
||||
# --------------------------- step 1: build ---------------------------------
|
||||
build_tasmota() {
|
||||
[ -f "$TASMOTA_SRC/platformio.ini" ] \
|
||||
|| { err "TASMOTA_SRC ($TASMOTA_SRC) is not a Tasmota checkout (no platformio.ini)"; exit 1; }
|
||||
log "Building env '$TASMOTA_ENV' from local source $TASMOTA_SRC (this pulls toolchains on first run) ..."
|
||||
mkdir -p "$WORKDIR"
|
||||
|
||||
[ "$NET_WIFI" = "1" ] && ensure_wifi_creds
|
||||
|
||||
# Keep the venv out of the source tree so the working copy stays clean.
|
||||
local venv="$WORKDIR/.venv"
|
||||
[ -d "$venv" ] || python3 -m venv "$venv"
|
||||
# shellcheck disable=SC1091
|
||||
source "$venv/bin/activate"
|
||||
pip install -q --upgrade pip
|
||||
pip install -q platformio
|
||||
( cd "$TASMOTA_SRC" && pio run -e "$TASMOTA_ENV" )
|
||||
deactivate
|
||||
|
||||
local factory="$TASMOTA_SRC/build_output/firmware/${TASMOTA_ENV}.factory.bin"
|
||||
[ -f "$factory" ] || { err "expected merged image not found: $factory"; exit 1; }
|
||||
log "Firmware built: $factory"
|
||||
}
|
||||
|
||||
# ------------------------ step 2: make flash image -------------------------
|
||||
make_image() {
|
||||
local size; size="$(flash_bytes "$FLASH_SIZE")"
|
||||
local img="$WORKDIR/flash_image.bin"
|
||||
local factory="$TASMOTA_SRC/build_output/firmware/${TASMOTA_ENV}.factory.bin"
|
||||
|
||||
if [ -f "$factory" ]; then
|
||||
# .factory.bin already starts at offset 0x0 (bootloader + partitions + app);
|
||||
# just pad up to a flash size QEMU accepts. truncate zero-fills.
|
||||
cp -f "$factory" "$img"
|
||||
truncate -s "$size" "$img"
|
||||
log "Flash image ready: $img ($FLASH_SIZE, from factory.bin)"
|
||||
return
|
||||
fi
|
||||
|
||||
# No factory image (e.g. the safeboot env ships app-only). Merge the raw parts
|
||||
# at the standard ESP32 offsets ourselves, same as smoketest.
|
||||
local bd="$TASMOTA_SRC/.pio/build/${TASMOTA_ENV}"
|
||||
local boot="$bd/bootloader.bin" part="$bd/partitions.bin" app="$bd/firmware.bin"
|
||||
local f
|
||||
for f in "$boot" "$part" "$app"; do
|
||||
[ -f "$f" ] || { err "no factory.bin and missing part: $f — run '$0 build' first"; exit 1; }
|
||||
done
|
||||
local bootapp0
|
||||
bootapp0="$(ls "$HOME"/.platformio/packages/framework-arduinoespressif32*/tools/partitions/boot_app0.bin 2>/dev/null | head -n1)"
|
||||
|
||||
log "No factory.bin for '$TASMOTA_ENV' — merging raw parts (bootloader/partitions/app)."
|
||||
truncate -s "$size" "$img" # zero-filled blank flash
|
||||
dd if="$boot" of="$img" bs=1024 seek=4 conv=notrunc status=none
|
||||
dd if="$part" of="$img" bs=1024 seek=32 conv=notrunc status=none
|
||||
[ -n "$bootapp0" ] && dd if="$bootapp0" of="$img" bs=1024 seek=56 conv=notrunc status=none
|
||||
dd if="$app" of="$img" bs=1024 seek=64 conv=notrunc status=none
|
||||
log "Flash image ready: $img ($FLASH_SIZE, merged from parts)"
|
||||
}
|
||||
|
||||
# ----------------------- step 3: build / find QEMU -------------------------
|
||||
qemu_build_deps() {
|
||||
local pkgs=(git build-essential ninja-build pkg-config python3-venv \
|
||||
libglib2.0-dev libpixman-1-dev libgcrypt20-dev libslirp-dev \
|
||||
flex bison)
|
||||
if [ "$INSTALL_DEPS" = "1" ]; then
|
||||
log "Installing QEMU build dependencies (sudo) ..."
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y "${pkgs[@]}"
|
||||
else
|
||||
log "QEMU build needs (Debian/Ubuntu): ${pkgs[*]}"
|
||||
log " re-run with INSTALL_DEPS=1 to apt-get them automatically."
|
||||
fi
|
||||
}
|
||||
|
||||
setup_qemu() {
|
||||
if [ "$USE_SYSTEM_QEMU" = "1" ]; then
|
||||
command -v qemu-system-xtensa >/dev/null \
|
||||
|| { err "USE_SYSTEM_QEMU=1 but qemu-system-xtensa is not on PATH"; exit 1; }
|
||||
QEMU_BIN="$(command -v qemu-system-xtensa)"
|
||||
log "Using system QEMU: $QEMU_BIN"
|
||||
return
|
||||
fi
|
||||
|
||||
local repo branch src
|
||||
case "$QEMU_VARIANT" in
|
||||
stock) repo="$QEMU_STOCK_REPO"; branch="$QEMU_STOCK_BRANCH"; src="$WORKDIR/qemu-stock";;
|
||||
wifi) repo="$QEMU_WIFI_REPO"; branch="$QEMU_WIFI_BRANCH"; src="$WORKDIR/qemu-wifi";;
|
||||
*) err "Unknown QEMU_VARIANT '$QEMU_VARIANT' (use stock|wifi)"; exit 1;;
|
||||
esac
|
||||
|
||||
QEMU_BIN="$src/build/qemu-system-xtensa"
|
||||
if [ -x "$QEMU_BIN" ]; then
|
||||
log "QEMU already built: $QEMU_BIN"
|
||||
return
|
||||
fi
|
||||
|
||||
qemu_build_deps
|
||||
mkdir -p "$WORKDIR"
|
||||
if [ ! -d "$src/.git" ]; then
|
||||
log "Cloning $repo ($branch) — large, can take a while ..."
|
||||
git clone --depth 1 -b "$branch" "$repo" "$src"
|
||||
fi
|
||||
cd "$src"
|
||||
git submodule update --init --depth 1 || true
|
||||
|
||||
# The 'wifi' fork needs an ESP32 ROM in pc-bios. Recent forks already ship one
|
||||
# (esp32-v3-rom.bin etc.), so only fetch if none is present. The old r0-rom URL
|
||||
# 404s now, so fall back to copying from the stock clone before downloading.
|
||||
if [ "$QEMU_VARIANT" = "wifi" ]; then
|
||||
mkdir -p "$src/pc-bios"
|
||||
if ls "$src"/pc-bios/esp32*rom*.bin >/dev/null 2>&1; then
|
||||
log "ESP32 ROM already in fork pc-bios ($(ls "$src"/pc-bios/esp32*rom*.bin | xargs -n1 basename | tr '\n' ' ')) — skipping fetch."
|
||||
elif ls "$WORKDIR"/qemu-stock/pc-bios/esp32*rom*.bin >/dev/null 2>&1; then
|
||||
log "Copying ESP32 ROM(s) from the stock clone."
|
||||
cp -f "$WORKDIR"/qemu-stock/pc-bios/esp32*rom*.bin "$src/pc-bios/"
|
||||
else
|
||||
download "$ESP32_ROM_URL" "$src/pc-bios/esp32-r0-rom.bin" \
|
||||
|| { err "no ESP32 ROM in fork and download 404'd — fetch one into $src/pc-bios manually"; exit 1; }
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Configuring QEMU (headless, xtensa only) ..."
|
||||
./configure --target-list=xtensa-softmmu \
|
||||
--enable-gcrypt --enable-slirp \
|
||||
--disable-strip --disable-user --disable-capstone --disable-vnc
|
||||
|
||||
log "Compiling QEMU with $JOBS jobs ..."
|
||||
ninja -C build -j "$JOBS" qemu-system-xtensa
|
||||
[ -x "$QEMU_BIN" ] || { err "QEMU build failed: $QEMU_BIN not found"; exit 1; }
|
||||
log "Built QEMU: $QEMU_BIN"
|
||||
}
|
||||
|
||||
# Kill any QEMU we previously launched against this WORKDIR, so the new run can
|
||||
# grab the hostfwd port (avoids 'Could not set up host forwarding ... address
|
||||
# already in use'). Matches only our own QEMU (cmdline references $WORKDIR); the
|
||||
# script's own process has no 'qemu-system-xtensa' in its cmdline, so no self-kill.
|
||||
kill_stale_qemu() {
|
||||
local pids
|
||||
pids="$(pgrep -f "qemu-system-xtensa.*$WORKDIR" 2>/dev/null || true)"
|
||||
[ -n "$pids" ] || return 0
|
||||
log "Stopping previous QEMU (pid: $(echo $pids | tr '\n' ' ')) to free port ${WEB_PORT_HOST} ..."
|
||||
# shellcheck disable=SC2086
|
||||
kill -9 $pids 2>/dev/null || true
|
||||
sleep 1
|
||||
}
|
||||
|
||||
# ----------------------------- step 4: run ---------------------------------
|
||||
run_qemu() {
|
||||
[ -f "$WORKDIR/flash_image.bin" ] || { err "flash_image.bin missing — run '$0 make-image' first"; exit 1; }
|
||||
if [ -z "$QEMU_BIN" ] || [ ! -x "$QEMU_BIN" ]; then setup_qemu; fi
|
||||
kill_stale_qemu
|
||||
|
||||
# When running from a source build, point QEMU at its bios/rom directory.
|
||||
local src_dir pcbios=""
|
||||
src_dir="$(dirname "$(dirname "$QEMU_BIN")")"
|
||||
[ -d "$src_dir/pc-bios" ] && pcbios="-L $src_dir/pc-bios"
|
||||
|
||||
local args=(-nographic -machine esp32
|
||||
-no-reboot
|
||||
-drive "file=$WORKDIR/flash_image.bin,if=mtd,format=raw")
|
||||
|
||||
if [ "$DISABLE_WDT" = "1" ]; then
|
||||
args+=(-global driver=timer.esp32.timg,property=wdt_disable,value=true)
|
||||
fi
|
||||
|
||||
if [ "$NET" = "eth" ]; then
|
||||
# stock fork -> OpenCores Ethernet MAC; wifi fork -> emulated WiFi NIC
|
||||
local nicmodel="open_eth"
|
||||
[ "$QEMU_VARIANT" = "wifi" ] && nicmodel="esp32_wifi"
|
||||
args+=(-nic "user,model=${nicmodel},hostfwd=tcp::${WEB_PORT_HOST}-:80")
|
||||
log "Network: ${nicmodel}, host :${WEB_PORT_HOST} -> guest :80"
|
||||
if [ "$QEMU_VARIANT" = "stock" ]; then
|
||||
err "NOTE: stock Tasmota uses WiFi; over open_eth the web UI likely won't"
|
||||
err " respond. Use QEMU_VARIANT=wifi for real network access."
|
||||
fi
|
||||
elif [ "$NET" != "none" ]; then
|
||||
err "Unknown NET '$NET' (use none|eth)"; exit 1
|
||||
fi
|
||||
|
||||
log "Web UI (if the guest link comes up): http://localhost:${WEB_PORT_HOST}"
|
||||
log "Quit QEMU with: Ctrl-A then X"
|
||||
log "Serial log: $WORKDIR/qemu.log (decode panic with: $0 decode)"
|
||||
echo
|
||||
# NET_WIFI: auto-run fixui once the web server is up, so the forwarded UI is
|
||||
# clickable with no manual step. A background watcher polls qemu.log while QEMU
|
||||
# stays in the foreground (keeps the interactive serial console / Ctrl-A X).
|
||||
if [ "$NET_WIFI" = "1" ]; then
|
||||
( i=0
|
||||
while [ "$i" -lt 90 ]; do
|
||||
i=$((i+1)); sleep 1
|
||||
[ -f "$WORKDIR/qemu.log" ] || continue
|
||||
grep -qa 'Guru Meditation' "$WORKDIR/qemu.log" && break
|
||||
if grep -qa 'Web server active' "$WORKDIR/qemu.log"; then
|
||||
sleep 1; fix_webui || true; break
|
||||
fi
|
||||
done
|
||||
) &
|
||||
fi
|
||||
# tee serial to qemu.log so a panic backtrace survives the scroll.
|
||||
# -no-reboot makes QEMU exit on the first guest reset, so the loop stops.
|
||||
# shellcheck disable=SC2086
|
||||
"$QEMU_BIN" $pcbios "${args[@]}" 2>&1 | tee "$WORKDIR/qemu.log"
|
||||
}
|
||||
|
||||
# ------------------ make the forwarded web UI clickable --------------------
|
||||
# Tasmota's referer check (xdrv_01_9_webserver.ino) only accepts a Referer whose
|
||||
# host is its own hostname or local IP. Through the host port-forward the browser's
|
||||
# Referer host is 'localhost', which matches neither, so every sub-page past '/'
|
||||
# returns an empty response. SetOption128 1 disables that check; it persists to
|
||||
# flash. We send it once using a Referer that DOES match the guest IP so the
|
||||
# command itself is accepted. Run this after the web server is up (QEMU running).
|
||||
GUEST_IP="${GUEST_IP:-10.0.2.15}" # slirp hands the guest this by default
|
||||
fix_webui() {
|
||||
local url="http://localhost:${WEB_PORT_HOST}"
|
||||
command -v curl >/dev/null || { err "curl needed for fixui"; exit 1; }
|
||||
log "Disabling referer check (SetOption128 1) via accepted referer http://$GUEST_IP/ ..."
|
||||
if curl -fsS -m 8 -H "Referer: http://$GUEST_IP/" "$url/cm?cmnd=SetOption128%201" ; then
|
||||
echo
|
||||
log "Done (persisted to flash). Reload $url — all sub-pages now work."
|
||||
else
|
||||
err "Request failed — is QEMU running with the web server up? ($url)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# --------------------------- decode a panic --------------------------------
|
||||
find_addr2line() {
|
||||
if command -v xtensa-esp32-elf-addr2line >/dev/null; then
|
||||
command -v xtensa-esp32-elf-addr2line; return
|
||||
fi
|
||||
local c
|
||||
# PlatformIO ships the binary as xtensa-esp32-elf-addr2line inside the
|
||||
# toolchain-xtensa-esp-elf (newer) or toolchain-xtensa-esp32 (older) package.
|
||||
for c in "$HOME"/.platformio/packages/toolchain-xtensa-esp*/bin/xtensa-esp32-elf-addr2line; do
|
||||
[ -x "$c" ] && { echo "$c"; return; }
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
decode_panic() {
|
||||
local logfile="${1:-$WORKDIR/qemu.log}"
|
||||
[ -f "$logfile" ] || { err "log not found: $logfile (run '$0 run' first)"; exit 1; }
|
||||
|
||||
# Pick the matching ELF: smoke.log -> the smoketest build, else Tasmota.
|
||||
# The .elf stays in PlatformIO's build dir; build_output/ only gets the .bins.
|
||||
local elf
|
||||
case "$logfile" in
|
||||
*smoke*) elf="$WORKDIR/smoke/.pio/build/smoke/firmware.elf";;
|
||||
*) elf="$TASMOTA_SRC/.pio/build/${TASMOTA_ENV}/firmware.elf";;
|
||||
esac
|
||||
[ -f "$elf" ] || { err "ELF not found: $elf (run the matching build first)"; exit 1; }
|
||||
|
||||
local a2l; a2l="$(find_addr2line)" \
|
||||
|| { err "xtensa-esp32-elf-addr2line not found (build once so PlatformIO installs the toolchain)"; exit 1; }
|
||||
|
||||
# Pull the LAST 'Backtrace:' line, keep the PC half (before ':') of each pc:sp pair.
|
||||
local bt
|
||||
bt="$(grep -a 'Backtrace:' "$logfile" | tail -n1)" \
|
||||
|| { err "no 'Backtrace:' line in $logfile"; exit 1; }
|
||||
|
||||
local addrs
|
||||
addrs="$(echo "$bt" \
|
||||
| grep -oE '0x[0-9a-fA-F]{8}:0x[0-9a-fA-F]{8}' \
|
||||
| cut -d: -f1 \
|
||||
| grep -viE '^0xfffffff' )" # drop the sentinel 0xffffffff/0xfffffffd frame
|
||||
|
||||
[ -n "$addrs" ] || { err "no decodable addresses in backtrace: $bt"; exit 1; }
|
||||
|
||||
log "ELF: $elf"
|
||||
log "Tool: $a2l"
|
||||
log "Decoding last panic backtrace:"
|
||||
echo
|
||||
# shellcheck disable=SC2086
|
||||
"$a2l" -pfiaC -e "$elf" $addrs
|
||||
}
|
||||
|
||||
# --------------------- smoketest: known-good baseline ----------------------
|
||||
# Build a MINIMAL Arduino app with the SAME platform/IDF/toolchain Tasmota uses
|
||||
# (no PSRAM, stock partitions, default sdkconfig) and boot it in the built QEMU.
|
||||
# Bisects "QEMU/IDF baseline broken" vs "Tasmota config breaks it":
|
||||
# boots OK -> QEMU fine, Tasmota's own config/PSRAM is the culprit
|
||||
# same panic-> QEMU/IDF baseline can't boot this IDF; fix QEMU, not Tasmota
|
||||
smoketest() {
|
||||
local smoke="$WORKDIR/smoke" venv="$WORKDIR/.venv"
|
||||
[ -d "$venv" ] || { err "need the build venv+platform — run '$0 build' first"; exit 1; }
|
||||
|
||||
# Reuse the exact espressif32 platform Tasmota pins, so IDF matches.
|
||||
local platform
|
||||
platform="$(grep -hoE 'https://github.com/tasmota/platform-espressif32/[^ ]+\.zip' \
|
||||
"$TASMOTA_SRC/platformio_tasmota32.ini" | head -n1)"
|
||||
[ -n "$platform" ] || { err "espressif32 platform URL not found in platformio_tasmota32.ini"; exit 1; }
|
||||
log "Smoketest platform (matches Tasmota): $platform"
|
||||
|
||||
mkdir -p "$smoke/src"
|
||||
cat > "$smoke/platformio.ini" <<EOF
|
||||
[env:smoke]
|
||||
platform = $platform
|
||||
framework = arduino
|
||||
board = esp32dev
|
||||
board_build.flash_size = ${FLASH_SIZE}
|
||||
board_build.partitions = default.csv
|
||||
monitor_speed = 115200
|
||||
EOF
|
||||
|
||||
cat > "$smoke/src/main.cpp" <<'EOF'
|
||||
#include <Arduino.h>
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(200);
|
||||
Serial.println();
|
||||
Serial.println("=== QEMU SMOKETEST: minimal Arduino app booted OK ===");
|
||||
}
|
||||
void loop() {
|
||||
Serial.println("SMOKE alive");
|
||||
delay(1000);
|
||||
}
|
||||
EOF
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$venv/bin/activate"
|
||||
( cd "$smoke" && pio run -e smoke )
|
||||
|
||||
local bd="$smoke/.pio/build/smoke"
|
||||
local boot="$bd/bootloader.bin" part="$bd/partitions.bin" app="$bd/firmware.bin"
|
||||
local f
|
||||
for f in "$boot" "$part" "$app"; do
|
||||
[ -f "$f" ] || { err "smoketest artifact missing: $f"; deactivate; exit 1; }
|
||||
done
|
||||
|
||||
# otadata image (optional — bootloader falls back to the factory app without it)
|
||||
local bootapp0
|
||||
bootapp0="$(ls "$HOME"/.platformio/packages/framework-arduinoespressif32*/tools/partitions/boot_app0.bin 2>/dev/null | head -n1)"
|
||||
|
||||
deactivate
|
||||
|
||||
# Merge by hand at the standard ESP32 flash offsets (no esptool dependency).
|
||||
# All offsets are 4 KiB-aligned, so dd with bs=1024 + seek-in-KiB is exact.
|
||||
# bootloader 0x1000, partitions 0x8000, otadata 0xe000, factory app 0x10000.
|
||||
local img="$WORKDIR/smoke_flash.bin"
|
||||
truncate -s "$(flash_bytes "$FLASH_SIZE")" "$img" # zero-filled blank flash
|
||||
dd if="$boot" of="$img" bs=1024 seek=4 conv=notrunc status=none
|
||||
dd if="$part" of="$img" bs=1024 seek=32 conv=notrunc status=none
|
||||
[ -n "$bootapp0" ] && dd if="$bootapp0" of="$img" bs=1024 seek=56 conv=notrunc status=none
|
||||
dd if="$app" of="$img" bs=1024 seek=64 conv=notrunc status=none
|
||||
log "Smoketest image: $img ($FLASH_SIZE)"
|
||||
|
||||
if [ -z "$QEMU_BIN" ] || [ ! -x "$QEMU_BIN" ]; then setup_qemu; fi
|
||||
kill_stale_qemu
|
||||
local src_dir pcbios=""
|
||||
src_dir="$(dirname "$(dirname "$QEMU_BIN")")"
|
||||
[ -d "$src_dir/pc-bios" ] && pcbios="-L $src_dir/pc-bios"
|
||||
|
||||
local args=(-nographic -machine esp32 -no-reboot
|
||||
-drive "file=$img,if=mtd,format=raw")
|
||||
[ "$DISABLE_WDT" = "1" ] && args+=(-global driver=timer.esp32.timg,property=wdt_disable,value=true)
|
||||
|
||||
log "Booting minimal app (Ctrl-A X to quit). Serial -> $WORKDIR/smoke.log"
|
||||
log " 'SMOKETEST ... booted OK' -> QEMU fine, Tasmota config is the culprit."
|
||||
log " same InstrFetchProhibited panic -> QEMU/IDF baseline broken, fix QEMU."
|
||||
log "Decode a smoke panic with: $0 decode $WORKDIR/smoke.log"
|
||||
echo
|
||||
# shellcheck disable=SC2086
|
||||
"$QEMU_BIN" $pcbios "${args[@]}" 2>&1 | tee "$WORKDIR/smoke.log"
|
||||
}
|
||||
|
||||
# ------------------------------- usage -------------------------------------
|
||||
usage() {
|
||||
cat <<EOF
|
||||
tasmota-qemu.sh — build & run Tasmota (ESP32) under QEMU on amd64
|
||||
|
||||
Commands:
|
||||
build Compile Tasmota firmware from \$TASMOTA_SRC (PlatformIO env: \$TASMOTA_ENV)
|
||||
make-image Pad the .factory.bin into a QEMU flash image
|
||||
qemu-setup Clone + build the chosen QEMU fork
|
||||
run Boot the firmware in QEMU (serial tee'd to \$WORKDIR/qemu.log)
|
||||
smoketest Boot a minimal Arduino app (same IDF, no PSRAM) to bisect QEMU vs Tasmota
|
||||
fixui Disable Tasmota referer check (SetOption128 1) so forwarded sub-pages work
|
||||
decode [log] Symbolize the last panic backtrace from a log (default: qemu.log)
|
||||
all build -> make-image -> qemu-setup -> run (default)
|
||||
|
||||
Key environment variables (current defaults):
|
||||
WORKDIR=$WORKDIR
|
||||
TASMOTA_SRC=$TASMOTA_SRC
|
||||
Tasmota checkout to build (default: this repo — your changes)
|
||||
TASMOTA_ENV=$TASMOTA_ENV ESP32 PlatformIO env
|
||||
MINIMAL=$MINIMAL 1 = build $MINIMAL_ENV (stripped, fewest drivers)
|
||||
NET_WIFI=$NET_WIFI 1 = one-shot networked recipe (wifi fork + $NET_WIFI_ENV
|
||||
+ emulated-AP creds); browse http://localhost:$WEB_PORT_HOST
|
||||
WIFI_SSID=$WIFI_SSID emulated AP SSID joined when NET_WIFI=1
|
||||
FLASH_SIZE=$FLASH_SIZE 2MB|4MB|8MB|16MB only
|
||||
QEMU_VARIANT=$QEMU_VARIANT stock | wifi
|
||||
NET=$NET none | eth
|
||||
WEB_PORT_HOST=$WEB_PORT_HOST host port -> guest :80
|
||||
DISABLE_WDT=$DISABLE_WDT 1 = disable timer-group watchdogs
|
||||
INSTALL_DEPS=$INSTALL_DEPS 1 = apt-get QEMU build deps (sudo)
|
||||
USE_SYSTEM_QEMU=$USE_SYSTEM_QEMU 1 = use qemu-system-xtensa from PATH
|
||||
JOBS=$JOBS
|
||||
|
||||
Examples:
|
||||
./tasmota-qemu.sh # full pipeline, stock fork, serial console
|
||||
QEMU_VARIANT=wifi ./tasmota-qemu.sh # build the WiFi-capable fork and run networked
|
||||
NET=none ./tasmota-qemu.sh run # just boot, serial only (re-run without rebuilding)
|
||||
INSTALL_DEPS=1 QEMU_VARIANT=wifi ./tasmota-qemu.sh
|
||||
NET_WIFI=1 ./tasmota-qemu.sh # networked Tasmota, web UI at localhost:$WEB_PORT_HOST
|
||||
EOF
|
||||
}
|
||||
|
||||
# ------------------------------- dispatch ----------------------------------
|
||||
mkdir -p "$WORKDIR"
|
||||
case "${1:-all}" in
|
||||
build) build_tasmota;;
|
||||
make-image|image) make_image;;
|
||||
qemu-setup|qemu) setup_qemu;;
|
||||
run) run_qemu;;
|
||||
smoketest|smoke) smoketest;;
|
||||
fixui|fix-webui) fix_webui;;
|
||||
decode) shift; decode_panic "${1:-}";;
|
||||
all) build_tasmota; make_image; setup_qemu; run_qemu;;
|
||||
-h|--help|help) usage;;
|
||||
*) err "Unknown command '${1}'"; usage; exit 1;;
|
||||
esac
|
||||
|
||||
Reference in New Issue
Block a user