1use 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#[cfg(feature = "esp32")]
34const ESP_USB_VENDOR_IDS: [u16; 4] = [0x303a, 0x10c4, 0x1a86, 0x0403];
35
36#[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#[cfg(feature = "esp32")]
47#[derive(Debug, Clone)]
48pub struct SerialPortSummary {
49 pub port_name: String,
51 pub usb_vid_pid: Option<(u16, u16)>,
53 pub product: Option<String>,
55 pub likely_esp: bool,
57}
58
59#[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#[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
113pub(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
135fn 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
165pub 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(¤t));
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
196fn 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
225pub 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 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 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 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
302pub 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
330pub 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
403async 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
418fn qemu_efuse_image() -> Vec<u8> {
424 let mut data = vec![0u8; 1024];
425 data[64] = 0x01;
426 data
427}
428
429pub 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
527pub 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 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
551pub 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}