Skip to main content

waterui_cli/esp32/
platform.rs

1//! ESP32 platform build, flash, emulation, and package utilities.
2//!
3//! The generated harness crate pins its own `esp` Rust toolchain via
4//! `rust-toolchain.toml` and selects the Xtensa target via `.cargo/config.toml`,
5//! so builds simply run `cargo build` inside the harness directory with the
6//! Xtensa GCC and clang library paths exported.
7
8use std::ffi::OsString;
9use std::path::{Path, PathBuf};
10use std::process::Stdio;
11
12use eyre::{Context as _, bail, eyre};
13use smol::fs;
14#[cfg(feature = "esp32")]
15use smol::unblock;
16use tracing::info;
17
18use crate::{
19    build::{BuildOptions, BuildProgress},
20    device::Artifact,
21    esp32::{backend::Esp32Backend, chip::Esp32Chip},
22    platform::{PackageOptions, TargetPlatform},
23    project::Project,
24    utils::{command, run_command_os, which},
25};
26
27const ESP32_INIT_HINT: &str = "water run --platform esp32s3";
28
29/// USB vendor IDs commonly found on ESP32 development boards.
30///
31/// `0x303a` is Espressif's native USB (USB-Serial-JTAG); the others are the
32/// `CP210x`, `CH34x`, and FTDI UART bridges used on classic devkits.
33#[cfg(feature = "esp32")]
34const ESP_USB_VENDOR_IDS: [u16; 4] = [0x303a, 0x10c4, 0x1a86, 0x0403];
35
36/// Check if a platform is supported by the ESP32 backend.
37#[must_use]
38pub const fn is_esp32_platform(platform: TargetPlatform) -> bool {
39    matches!(
40        platform,
41        TargetPlatform::Esp32S3 | TargetPlatform::Esp32C3 | TargetPlatform::Esp32P4
42    )
43}
44
45/// Summary of a host serial port for device listing and board auto-detection.
46#[cfg(feature = "esp32")]
47#[derive(Debug, Clone)]
48pub struct SerialPortSummary {
49    /// Host path of the serial port (e.g. `/dev/cu.usbmodem101`).
50    pub port_name: String,
51    /// USB vendor/product identifiers when the port is a USB device.
52    pub usb_vid_pid: Option<(u16, u16)>,
53    /// USB product string when reported by the device.
54    pub product: Option<String>,
55    /// Whether the USB vendor matches a known ESP32 board or UART bridge.
56    pub likely_esp: bool,
57}
58
59/// List host serial ports, marking ports that look like ESP32 boards.
60///
61/// # Errors
62/// Returns an error when the host serial subsystem cannot be enumerated.
63#[cfg(feature = "esp32")]
64pub async fn scan_serial_ports() -> eyre::Result<Vec<SerialPortSummary>> {
65    let ports = unblock(serialport::available_ports)
66        .await
67        .wrap_err("Failed to enumerate serial ports")?;
68
69    Ok(ports
70        .into_iter()
71        .map(|port| {
72            let (usb_vid_pid, product) = match port.port_type {
73                serialport::SerialPortType::UsbPort(usb) => (Some((usb.vid, usb.pid)), usb.product),
74                _ => (None, None),
75            };
76            let likely_esp = usb_vid_pid.is_some_and(|(vid, _)| ESP_USB_VENDOR_IDS.contains(&vid));
77            SerialPortSummary {
78                port_name: port.port_name,
79                usb_vid_pid,
80                product,
81                likely_esp,
82            }
83        })
84        .collect())
85}
86
87/// Pick the serial port of a connected ESP32 board, if any.
88///
89/// Espressif's native USB vendor ID and the usual UART bridges are
90/// considered; on hosts exposing both `tty` and `cu` nodes the callout
91/// (`cu`) node is preferred.
92///
93/// # Errors
94/// Returns an error when the host serial subsystem cannot be enumerated.
95#[cfg(feature = "esp32")]
96pub async fn detect_esp_serial_port() -> eyre::Result<Option<String>> {
97    let mut candidates: Vec<SerialPortSummary> = scan_serial_ports()
98        .await?
99        .into_iter()
100        .filter(|port| port.likely_esp)
101        .collect();
102    candidates.sort_by_key(|port| {
103        let is_callout = port.port_name.contains("/cu.");
104        (!is_callout, port.port_name.clone())
105    });
106    Ok(candidates.into_iter().next().map(|port| port.port_name))
107}
108
109fn home_dir() -> eyre::Result<PathBuf> {
110    dirs::home_dir().ok_or_else(|| eyre!("Failed to resolve the user home directory"))
111}
112
113/// Find the newest versioned subdirectory of `base` containing `relative`.
114pub(crate) fn newest_toolchain_subpath(base: &Path, relative: &Path) -> Option<PathBuf> {
115    let mut versions: Vec<PathBuf> = std::fs::read_dir(base)
116        .ok()?
117        .filter_map(Result::ok)
118        .map(|entry| entry.path())
119        .filter(|path| path.join(relative).is_dir())
120        .collect();
121    versions.sort();
122    versions.pop().map(|path| path.join(relative))
123}
124
125fn espup_component_dir(component: &str, relative: &Path, what: &str) -> eyre::Result<PathBuf> {
126    let base = home_dir()?.join(".rustup/toolchains/esp").join(component);
127    newest_toolchain_subpath(&base, relative).ok_or_else(|| {
128        eyre!(
129            "{what} not found under {}. Install the Espressif Rust toolchain with `espup install`.",
130            base.display()
131        )
132    })
133}
134
135/// Locate the GCC `bin` directory for `chip`'s architecture.
136///
137/// Xtensa GCC ships inside the espup `esp` toolchain
138/// (`~/.rustup/toolchains/esp/xtensa-esp-elf/...`); the RISC-V GCC is installed
139/// by ESP-IDF under `~/.espressif/tools/riscv32-esp-elf/...`. Both are
140/// version-discovered rather than pinned.
141fn gcc_bin_dir(chip: Esp32Chip) -> eyre::Result<PathBuf> {
142    let component = chip.gcc_component();
143    match chip.arch() {
144        crate::esp32::chip::Esp32Arch::Xtensa => espup_component_dir(
145            component.component,
146            Path::new(component.bin_subpath),
147            component.what,
148        ),
149        crate::esp32::chip::Esp32Arch::RiscV => {
150            let base = home_dir()?
151                .join(".espressif/tools")
152                .join(component.component);
153            newest_toolchain_subpath(&base, Path::new(component.bin_subpath)).ok_or_else(|| {
154                eyre!(
155                    "{} not found under {}. Install it with ESP-IDF tools (`idf_tools.py install`) \
156                     or by building an esp-idf-svc project once.",
157                    component.what,
158                    base.display()
159                )
160            })
161        }
162    }
163}
164
165/// Environment variables required to drive the Espressif Rust toolchain for
166/// `chip`.
167///
168/// Prepends the chip architecture's GCC `bin` directory to `PATH` and points
169/// `LIBCLANG_PATH` at the Espressif clang libraries (shared across
170/// architectures), discovered under the espup `esp` toolchain without assuming
171/// a toolchain version.
172///
173/// # Errors
174/// Returns an error when the Espressif toolchain components are not installed.
175pub fn esp_toolchain_envs(chip: Esp32Chip) -> eyre::Result<Vec<(String, OsString)>> {
176    let gcc_bin = gcc_bin_dir(chip)?;
177    let libclang = espup_component_dir(
178        "xtensa-esp32-elf-clang",
179        Path::new("esp-clang/lib"),
180        "Espressif clang libraries",
181    )?;
182
183    let mut paths = vec![gcc_bin];
184    if let Some(current) = std::env::var_os("PATH") {
185        paths.extend(std::env::split_paths(&current));
186    }
187    let path_value =
188        std::env::join_paths(paths).wrap_err("Failed to compose PATH for the ESP toolchain")?;
189
190    Ok(vec![
191        ("PATH".to_string(), path_value),
192        ("LIBCLANG_PATH".to_string(), libclang.into_os_string()),
193    ])
194}
195
196/// Resolve the configured chip for `project`'s ESP32 backend.
197fn esp32_chip(project: &Project) -> eyre::Result<Esp32Chip> {
198    project
199        .esp32_backend()
200        .cloned()
201        .unwrap_or_default()
202        .resolved_chip()
203}
204
205fn esp32_target_triple(project: &Project) -> eyre::Result<&'static str> {
206    Ok(esp32_chip(project)?.target_triple())
207}
208
209async fn espflash_path() -> eyre::Result<PathBuf> {
210    which("espflash")
211        .await
212        .map_err(|_| eyre!("espflash not found. Install it with `cargo install espflash`."))
213}
214
215fn command_failure_details(output: &std::process::Output) -> String {
216    let stderr = String::from_utf8_lossy(&output.stderr);
217    let stdout = String::from_utf8_lossy(&output.stdout);
218    if stderr.trim().is_empty() {
219        stdout.to_string()
220    } else {
221        stderr.to_string()
222    }
223}
224
225/// Build the ESP32 firmware ELF for the configured chip.
226///
227/// Runs `cargo build` inside the generated harness directory so its
228/// `rust-toolchain.toml` (channel `esp`) and `.cargo/config.toml` (Xtensa
229/// target, `build-std`, ESP-IDF environment) take effect.
230///
231/// # Errors
232/// Returns an error if the harness is missing, the Espressif toolchain is not
233/// installed, or Cargo fails.
234pub async fn build_esp32(project: &Project, options: BuildOptions) -> eyre::Result<PathBuf> {
235    let backend_path = project.backend_path::<Esp32Backend>();
236    let cargo_toml = backend_path.join("Cargo.toml");
237    let backend_target_dir = project.toolchain_target_dir("esp32").await?;
238
239    if !cargo_toml.exists() {
240        bail!(
241            "ESP32 backend not found at {}. Run `{ESP32_INIT_HINT}` to initialize it.",
242            backend_path.display(),
243        );
244    }
245
246    let chip = esp32_chip(project)?;
247
248    let mut cargo = smol::process::Command::new("cargo");
249    cargo.current_dir(&backend_path);
250    cargo.arg("build");
251    cargo.arg("--message-format=json-render-diagnostics");
252    cargo.arg("--target-dir").arg(&backend_target_dir);
253    crate::build::configure_generated_crate_compilation(&mut cargo);
254    if let Some(sccache_path) = options.sccache_path() {
255        crate::toolchain::sccache::configure_compilation_cache(&mut cargo, sccache_path)?;
256    }
257    for (key, value) in esp_toolchain_envs(chip)? {
258        cargo.env(key, value);
259    }
260    if options.is_release() {
261        cargo.arg("--release");
262    }
263    // Piped stdio strips rustc diagnostics of their colors; restore cargo's
264    // coloring while the terminal renders the output.
265    if crate::utils::std_output_enabled() && std::env::var_os("CARGO_TERM_COLOR").is_none() {
266        cargo.env("CARGO_TERM_COLOR", "always");
267    }
268
269    let output =
270        crate::build::command_output_with_progress(&mut cargo, options.progress().cloned()).await?;
271    if !output.status.success() {
272        let details = command_failure_details(&output);
273        // A live-rendered stream is tailed rather than re-dumped in full.
274        let details = if options
275            .progress()
276            .is_some_and(BuildProgress::shows_all_lines)
277        {
278            crate::build::output_tail(&details)
279        } else {
280            details
281        };
282        bail!(
283            "Failed to build ESP32 firmware with cargo (status {}):\n{}",
284            output.status,
285            details
286        );
287    }
288
289    // The ELF path comes from cargo's own artifact report — the shared
290    // toolchain target directory hosts other projects' builds, so a bare
291    // `<profile>/<name>` lookup is not evidence the file is this project's.
292    let crate_name = project.esp32_backend_crate_name();
293    crate::build::reported_artifact(
294        &output.stdout,
295        &backend_path,
296        crate::build::CargoTarget::Binary(crate_name.as_str()),
297        None,
298    )
299    .map_err(|error| eyre!("failed to resolve the built ESP32 firmware: {error}"))
300}
301
302/// Resolve the built ESP32 firmware ELF path for the given profile.
303///
304/// # Errors
305/// Returns an error if neither the canonical nor underscored firmware binary can be found.
306pub async fn built_esp32_binary_path(project: &Project, profile: &str) -> eyre::Result<PathBuf> {
307    let target_dir = project
308        .toolchain_target_dir("esp32")
309        .await?
310        .join(esp32_target_triple(project)?)
311        .join(profile);
312    let binary_name = project.esp32_backend_crate_name();
313    let binary_path = target_dir.join(binary_name.as_str());
314    if binary_path.exists() {
315        return Ok(binary_path);
316    }
317
318    let underscored_path = target_dir.join(binary_name.as_str().replace('-', "_"));
319    if underscored_path.exists() {
320        return Ok(underscored_path);
321    }
322
323    bail!(
324        "Built ESP32 firmware not found at {} or {}",
325        binary_path.display(),
326        underscored_path.display()
327    );
328}
329
330/// Build, then flash and monitor the firmware on a board, or emulate it.
331///
332/// `device` selects the run target: `Some("qemu")` forces the QEMU emulator,
333/// `Some(port)` flashes the given serial port, and `None` flashes the first
334/// connected ESP32 board, falling back to QEMU when no board is connected but
335/// the chip's QEMU emulator is installed.
336///
337/// # Errors
338/// Returns an error when building, flashing, or emulation fails, or when
339/// neither a board nor QEMU is available.
340pub async fn run_esp32(
341    project: &Project,
342    options: BuildOptions,
343    device: Option<&str>,
344) -> eyre::Result<()> {
345    let chip = esp32_chip(project)?;
346    let elf = build_esp32(project, options).await?;
347
348    match device {
349        Some("qemu") => qemu_esp32(project, chip, &elf).await,
350        Some(port) => flash_and_monitor(project, &elf, Some(port)).await,
351        None => {
352            #[cfg(feature = "esp32")]
353            {
354                if let Some(port) = detect_esp_serial_port().await? {
355                    info!("Flashing ESP32 board on {port}");
356                    return flash_and_monitor(project, &elf, Some(&port)).await;
357                }
358            }
359            if locate_qemu(chip).await.is_some() {
360                info!("No ESP32 board connected; running under QEMU");
361                return qemu_esp32(project, chip, &elf).await;
362            }
363            bail!(
364                "No ESP32 board connected and no QEMU for {chip_id} installed.\n\
365                 Connect a board (see `water devices --platform esp32`), pass --device <port>,\n\
366                 or install Espressif's QEMU fork ({qemu} with the {machine} machine).",
367                chip_id = chip.id(),
368                qemu = chip.qemu_binary(),
369                machine = chip.qemu_machine(),
370            );
371        }
372    }
373}
374
375async fn flash_and_monitor(project: &Project, elf: &Path, port: Option<&str>) -> eyre::Result<()> {
376    let backend_path = project.backend_path::<Esp32Backend>();
377    let espflash = espflash_path().await?;
378
379    let mut espflash_cmd = smol::process::Command::new(espflash);
380    espflash_cmd
381        .current_dir(&backend_path)
382        .arg("flash")
383        .arg("--partition-table")
384        .arg(backend_path.join("partitions.csv"))
385        .arg("--monitor");
386    if let Some(port) = port {
387        espflash_cmd.arg("--port").arg(port);
388    }
389    espflash_cmd
390        .arg(elf)
391        .stdin(Stdio::inherit())
392        .stdout(Stdio::inherit())
393        .stderr(Stdio::inherit())
394        .kill_on_drop(true);
395
396    let status = espflash_cmd.status().await?;
397    if !status.success() {
398        bail!("espflash flash failed with status {status}");
399    }
400    Ok(())
401}
402
403/// Locate the QEMU binary that emulates `chip`'s architecture.
404///
405/// Prefers the Espressif QEMU fork bundled under `~/.local/esp-qemu/qemu/bin`,
406/// falling back to the binary on `PATH`.
407async fn locate_qemu(chip: Esp32Chip) -> Option<PathBuf> {
408    let binary = chip.qemu_binary();
409    if let Ok(home) = home_dir() {
410        let bundled = home.join(".local/esp-qemu/qemu/bin").join(binary);
411        if bundled.exists() {
412            return Some(bundled);
413        }
414    }
415    which(binary).await.ok()
416}
417
418/// eFuse image for QEMU: ADC calibration version 1 (BLK2 word 4 bits 0..3).
419///
420/// Without it Xtensa firmware hangs at startup in hardware ADC
421/// self-calibration, which QEMU does not emulate; version 1 makes startup read
422/// the (zeroed) calibration codes from eFuse instead.
423fn qemu_efuse_image() -> Vec<u8> {
424    let mut data = vec![0u8; 1024];
425    data[64] = 0x01;
426    data
427}
428
429/// Run the built firmware ELF under the chip's QEMU, streaming serial output.
430///
431/// Builds a merged flash image with `espflash save-image` and runs the chip's
432/// QEMU with its machine model. Xtensa chips additionally need an eFuse image
433/// to skip unemulated ADC self-calibration; RISC-V chips boot without it.
434///
435/// # Errors
436/// Returns an error when QEMU or espflash is missing, image generation fails,
437/// or the emulator exits with a failure status.
438pub async fn qemu_esp32(project: &Project, chip: Esp32Chip, elf: &Path) -> eyre::Result<()> {
439    let qemu = locate_qemu(chip).await.ok_or_else(|| {
440        eyre!(
441            "QEMU for {} not found. Expected ~/.local/esp-qemu/qemu/bin/{binary} \
442             or {binary} on PATH (Espressif fork with the {machine} machine).",
443            chip.id(),
444            binary = chip.qemu_binary(),
445            machine = chip.qemu_machine(),
446        )
447    })?;
448    let backend_path = project.backend_path::<Esp32Backend>();
449
450    let staging = tempfile::Builder::new()
451        .prefix("waterui-esp32-qemu")
452        .tempdir()
453        .wrap_err("Failed to create QEMU staging directory")?;
454    let flash_image = staging.path().join("flash.bin");
455
456    save_flash_image(&backend_path, chip, elf, &flash_image).await?;
457
458    let mut qemu_cmd = smol::process::Command::new(qemu);
459    qemu_cmd
460        .arg("-nographic")
461        .arg("-machine")
462        .arg(chip.qemu_machine())
463        .arg("-drive")
464        .arg(format!("file={},if=mtd,format=raw", flash_image.display()));
465
466    let efuse_image = staging.path().join("efuse.bin");
467    if chip.needs_qemu_efuse_workaround() {
468        fs::write(&efuse_image, qemu_efuse_image()).await?;
469        qemu_cmd
470            .arg("-drive")
471            .arg(format!(
472                "file={},if=none,format=raw,id=efuse",
473                efuse_image.display()
474            ))
475            .arg("-global")
476            .arg(format!(
477                "driver=nvram.{}.efuse,property=drive,value=efuse",
478                chip.id()
479            ));
480    }
481
482    qemu_cmd
483        .stdin(Stdio::inherit())
484        .stdout(Stdio::inherit())
485        .stderr(Stdio::inherit())
486        .kill_on_drop(true);
487
488    let status = qemu_cmd.status().await?;
489    if !status.success() {
490        bail!("{} exited with status {status}", chip.qemu_binary());
491    }
492    Ok(())
493}
494
495async fn save_flash_image(
496    backend_path: &Path,
497    chip: Esp32Chip,
498    elf: &Path,
499    image_path: &Path,
500) -> eyre::Result<()> {
501    let espflash = espflash_path().await?;
502    let mut save = smol::process::Command::new(espflash);
503    let save = command(&mut save);
504    save.current_dir(backend_path)
505        .arg("save-image")
506        .arg("--chip")
507        .arg(chip.id())
508        .arg("--merge")
509        .arg("--flash-size")
510        .arg(chip.firmware_params().flash_size_arg())
511        .arg("--partition-table")
512        .arg(backend_path.join("partitions.csv"))
513        .arg(elf)
514        .arg(image_path);
515
516    let output = save.output().await?;
517    if !output.status.success() {
518        bail!(
519            "espflash save-image failed with status {}:\n{}",
520            output.status,
521            command_failure_details(&output)
522        );
523    }
524    Ok(())
525}
526
527/// Package the built firmware as a flashable merged image.
528///
529/// # Errors
530/// Returns an error when the built ELF is missing or image merging fails.
531pub async fn package_esp32(project: &Project, options: PackageOptions) -> eyre::Result<Artifact> {
532    let profile = if options.is_debug() {
533        "debug"
534    } else {
535        "release"
536    };
537    let elf = built_esp32_binary_path(project, profile).await?;
538    let backend_path = project.backend_path::<Esp32Backend>();
539    let chip = esp32_chip(project)?;
540
541    let dist_dir = crate::platforming::packaging::dist_dir(&backend_path, "esp32", Some(profile));
542    fs::create_dir_all(&dist_dir).await?;
543    // The image ships under the product name; the tagged crate name is
544    // internal to the shared Cargo target directory.
545    let image_path = dist_dir.join(format!("{}.bin", project.esp32_binary_name()));
546    save_flash_image(&backend_path, chip, &elf, &image_path).await?;
547
548    Ok(Artifact::new(project.bundle_identifier(), image_path))
549}
550
551/// Clean Cargo build artifacts and packaged images for the ESP32 harness.
552///
553/// # Errors
554/// Returns an error if `cargo clean` fails or the dist directory cannot be removed.
555pub async fn clean_esp32(project: &Project) -> eyre::Result<()> {
556    let backend_path = project.backend_path::<Esp32Backend>();
557    let cargo_toml = backend_path.join("Cargo.toml");
558    let backend_target_dir = project.toolchain_target_dir("esp32").await?;
559
560    if !cargo_toml.exists() {
561        return Ok(());
562    }
563
564    let args: Vec<OsString> = vec![
565        "clean".into(),
566        "--manifest-path".into(),
567        cargo_toml.as_os_str().to_owned(),
568        "--target-dir".into(),
569        backend_target_dir.as_os_str().to_owned(),
570    ];
571    run_command_os("cargo", args).await?;
572
573    let dist_dir = backend_path.join("dist");
574    if dist_dir.exists() {
575        fs::remove_dir_all(&dist_dir).await?;
576    }
577    Ok(())
578}