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,
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
113fn 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 profile = if options.is_release() {
247 "release"
248 } else {
249 "debug"
250 };
251
252 let chip = esp32_chip(project)?;
253
254 let mut cargo = smol::process::Command::new("cargo");
255 let cargo = command(&mut cargo);
256 cargo.current_dir(&backend_path);
257 cargo.arg("build");
258 cargo.arg("--target-dir").arg(&backend_target_dir);
259 crate::build::configure_generated_crate_compilation(cargo);
260 if let Some(sccache_path) = options.sccache_path() {
261 crate::toolchain::sccache::configure_compilation_cache(cargo, sccache_path);
262 }
263 for (key, value) in esp_toolchain_envs(chip)? {
264 cargo.env(key, value);
265 }
266 if options.is_release() {
267 cargo.arg("--release");
268 }
269
270 let output = cargo.output().await?;
271 if !output.status.success() {
272 bail!(
273 "Failed to build ESP32 firmware with cargo (status {}):\n{}",
274 output.status,
275 command_failure_details(&output)
276 );
277 }
278
279 Ok(backend_target_dir.join(chip.target_triple()).join(profile))
280}
281
282pub async fn built_esp32_binary_path(project: &Project, profile: &str) -> eyre::Result<PathBuf> {
287 let target_dir = project
288 .toolchain_target_dir("esp32")
289 .await?
290 .join(esp32_target_triple(project)?)
291 .join(profile);
292 let binary_name = project.esp32_backend_crate_name();
293 let binary_path = target_dir.join(binary_name.as_str());
294 if binary_path.exists() {
295 return Ok(binary_path);
296 }
297
298 let underscored_path = target_dir.join(binary_name.as_str().replace('-', "_"));
299 if underscored_path.exists() {
300 return Ok(underscored_path);
301 }
302
303 bail!(
304 "Built ESP32 firmware not found at {} or {}",
305 binary_path.display(),
306 underscored_path.display()
307 );
308}
309
310pub async fn run_esp32(
321 project: &Project,
322 options: BuildOptions,
323 device: Option<&str>,
324) -> eyre::Result<()> {
325 let profile = if options.is_release() {
326 "release"
327 } else {
328 "debug"
329 };
330 let chip = esp32_chip(project)?;
331 build_esp32(project, options).await?;
332 let elf = built_esp32_binary_path(project, profile).await?;
333
334 match device {
335 Some("qemu") => qemu_esp32(project, chip, &elf).await,
336 Some(port) => flash_and_monitor(project, &elf, Some(port)).await,
337 None => {
338 #[cfg(feature = "esp32")]
339 {
340 if let Some(port) = detect_esp_serial_port().await? {
341 info!("Flashing ESP32 board on {port}");
342 return flash_and_monitor(project, &elf, Some(&port)).await;
343 }
344 }
345 if locate_qemu(chip).await.is_some() {
346 info!("No ESP32 board connected; running under QEMU");
347 return qemu_esp32(project, chip, &elf).await;
348 }
349 bail!(
350 "No ESP32 board connected and no QEMU for {chip_id} installed.\n\
351 Connect a board (see `water devices --platform esp32`), pass --device <port>,\n\
352 or install Espressif's QEMU fork ({qemu} with the {machine} machine).",
353 chip_id = chip.id(),
354 qemu = chip.qemu_binary(),
355 machine = chip.qemu_machine(),
356 );
357 }
358 }
359}
360
361async fn flash_and_monitor(project: &Project, elf: &Path, port: Option<&str>) -> eyre::Result<()> {
362 let backend_path = project.backend_path::<Esp32Backend>();
363 let espflash = espflash_path().await?;
364
365 let mut espflash_cmd = smol::process::Command::new(espflash);
366 espflash_cmd
367 .current_dir(&backend_path)
368 .arg("flash")
369 .arg("--partition-table")
370 .arg(backend_path.join("partitions.csv"))
371 .arg("--monitor");
372 if let Some(port) = port {
373 espflash_cmd.arg("--port").arg(port);
374 }
375 espflash_cmd
376 .arg(elf)
377 .stdin(Stdio::inherit())
378 .stdout(Stdio::inherit())
379 .stderr(Stdio::inherit())
380 .kill_on_drop(true);
381
382 let status = espflash_cmd.status().await?;
383 if !status.success() {
384 bail!("espflash flash failed with status {status}");
385 }
386 Ok(())
387}
388
389async fn locate_qemu(chip: Esp32Chip) -> Option<PathBuf> {
394 let binary = chip.qemu_binary();
395 if let Ok(home) = home_dir() {
396 let bundled = home.join(".local/esp-qemu/qemu/bin").join(binary);
397 if bundled.exists() {
398 return Some(bundled);
399 }
400 }
401 which(binary).await.ok()
402}
403
404fn qemu_efuse_image() -> Vec<u8> {
410 let mut data = vec![0u8; 1024];
411 data[64] = 0x01;
412 data
413}
414
415pub async fn qemu_esp32(project: &Project, chip: Esp32Chip, elf: &Path) -> eyre::Result<()> {
425 let qemu = locate_qemu(chip).await.ok_or_else(|| {
426 eyre!(
427 "QEMU for {} not found. Expected ~/.local/esp-qemu/qemu/bin/{binary} \
428 or {binary} on PATH (Espressif fork with the {machine} machine).",
429 chip.id(),
430 binary = chip.qemu_binary(),
431 machine = chip.qemu_machine(),
432 )
433 })?;
434 let backend_path = project.backend_path::<Esp32Backend>();
435
436 let staging = tempfile::Builder::new()
437 .prefix("waterui-esp32-qemu")
438 .tempdir()
439 .wrap_err("Failed to create QEMU staging directory")?;
440 let flash_image = staging.path().join("flash.bin");
441
442 save_flash_image(&backend_path, chip, elf, &flash_image).await?;
443
444 let mut qemu_cmd = smol::process::Command::new(qemu);
445 qemu_cmd
446 .arg("-nographic")
447 .arg("-machine")
448 .arg(chip.qemu_machine())
449 .arg("-drive")
450 .arg(format!("file={},if=mtd,format=raw", flash_image.display()));
451
452 let efuse_image = staging.path().join("efuse.bin");
453 if chip.needs_qemu_efuse_workaround() {
454 fs::write(&efuse_image, qemu_efuse_image()).await?;
455 qemu_cmd
456 .arg("-drive")
457 .arg(format!(
458 "file={},if=none,format=raw,id=efuse",
459 efuse_image.display()
460 ))
461 .arg("-global")
462 .arg(format!(
463 "driver=nvram.{}.efuse,property=drive,value=efuse",
464 chip.id()
465 ));
466 }
467
468 qemu_cmd
469 .stdin(Stdio::inherit())
470 .stdout(Stdio::inherit())
471 .stderr(Stdio::inherit())
472 .kill_on_drop(true);
473
474 let status = qemu_cmd.status().await?;
475 if !status.success() {
476 bail!("{} exited with status {status}", chip.qemu_binary());
477 }
478 Ok(())
479}
480
481async fn save_flash_image(
482 backend_path: &Path,
483 chip: Esp32Chip,
484 elf: &Path,
485 image_path: &Path,
486) -> eyre::Result<()> {
487 let espflash = espflash_path().await?;
488 let mut save = smol::process::Command::new(espflash);
489 let save = command(&mut save);
490 save.current_dir(backend_path)
491 .arg("save-image")
492 .arg("--chip")
493 .arg(chip.id())
494 .arg("--merge")
495 .arg("--flash-size")
496 .arg(chip.firmware_params().flash_size_arg())
497 .arg("--partition-table")
498 .arg(backend_path.join("partitions.csv"))
499 .arg(elf)
500 .arg(image_path);
501
502 let output = save.output().await?;
503 if !output.status.success() {
504 bail!(
505 "espflash save-image failed with status {}:\n{}",
506 output.status,
507 command_failure_details(&output)
508 );
509 }
510 Ok(())
511}
512
513pub async fn package_esp32(project: &Project, options: PackageOptions) -> eyre::Result<Artifact> {
518 let profile = if options.is_debug() {
519 "debug"
520 } else {
521 "release"
522 };
523 let elf = built_esp32_binary_path(project, profile).await?;
524 let backend_path = project.backend_path::<Esp32Backend>();
525 let chip = esp32_chip(project)?;
526
527 let dist_dir = backend_path.join("dist");
528 fs::create_dir_all(&dist_dir).await?;
529 let image_path = dist_dir.join(format!("{}.bin", project.esp32_backend_crate_name()));
530 save_flash_image(&backend_path, chip, &elf, &image_path).await?;
531
532 Ok(Artifact::new(project.bundle_identifier(), image_path))
533}
534
535pub async fn clean_esp32(project: &Project) -> eyre::Result<()> {
540 let backend_path = project.backend_path::<Esp32Backend>();
541 let cargo_toml = backend_path.join("Cargo.toml");
542 let backend_target_dir = project.toolchain_target_dir("esp32").await?;
543
544 if !cargo_toml.exists() {
545 return Ok(());
546 }
547
548 let args: Vec<OsString> = vec![
549 "clean".into(),
550 "--manifest-path".into(),
551 cargo_toml.as_os_str().to_owned(),
552 "--target-dir".into(),
553 backend_target_dir.as_os_str().to_owned(),
554 ];
555 run_command_os("cargo", args).await?;
556
557 let dist_dir = backend_path.join("dist");
558 if dist_dir.exists() {
559 fs::remove_dir_all(&dist_dir).await?;
560 }
561 Ok(())
562}