Skip to main content

waterui_cli/esp32/
backend.rs

1//! ESP32 backend configuration and initialization.
2
3use std::path::{Path, PathBuf};
4
5use cargo_toml::Manifest as CargoManifest;
6use serde::{Deserialize, Serialize};
7
8use crate::{
9    backend::Backend,
10    build::BuildOptions,
11    device::Artifact,
12    esp32::{
13        chip::Esp32Chip,
14        platform::{build_esp32, clean_esp32, is_esp32_platform, package_esp32},
15    },
16    platform::{PackageOptions, TargetPlatform},
17    project::Project,
18    templates::{self, Esp32TemplateEntry, TemplateContext},
19};
20
21#[cfg(feature = "esp32")]
22fn subset_font(path: &Path, ranges: &str, output_dir: &Path) -> eyre::Result<PathBuf> {
23    crate::esp32::fonts::subset_into(path, ranges, output_dir)
24}
25
26#[cfg(not(feature = "esp32"))]
27fn subset_font(_path: &Path, _ranges: &str, _output_dir: &Path) -> eyre::Result<PathBuf> {
28    eyre::bail!("[backends.esp32] font_ranges requires the `esp32` feature of waterui-cli")
29}
30
31/// Configuration for the ESP32 backend in a `WaterUI` project.
32///
33/// `[backends.esp32]` in `Water.toml`
34#[derive(Debug, Serialize, Deserialize, Clone)]
35pub struct Esp32Backend {
36    #[serde(
37        default = "default_esp32_project_path",
38        skip_serializing_if = "is_default_esp32_project_path"
39    )]
40    project_path: PathBuf,
41    #[serde(
42        default = "default_esp32_chip",
43        skip_serializing_if = "is_default_esp32_chip"
44    )]
45    chip: String,
46    #[serde(
47        default = "default_esp32_panel_width",
48        skip_serializing_if = "is_default_esp32_panel_width"
49    )]
50    panel_width: u32,
51    #[serde(
52        default = "default_esp32_panel_height",
53        skip_serializing_if = "is_default_esp32_panel_height"
54    )]
55    panel_height: u32,
56    #[serde(
57        default = "default_esp32_band_height",
58        skip_serializing_if = "is_default_esp32_band_height"
59    )]
60    band_height: u32,
61    /// TTF/OTF binaries bundled into flash for dew text shaping, relative to
62    /// the project root. Firmware has no font directory to enumerate, so a
63    /// text-rendering app must list at least one face here.
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    fonts: Vec<PathBuf>,
66    /// Unicode ranges to subset every bundled font to before embedding
67    /// (e.g. `["U+0020-007E", "U+00A0-00FF"]`). Absent means the whole font
68    /// is embedded. Subsetting is explicit because it silently drops glyphs
69    /// outside the ranges; when set, a full Latin face shrinks from
70    /// hundreds of kilobytes of flash to a few dozen.
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    font_ranges: Vec<String>,
73}
74
75impl Esp32Backend {
76    /// Create a new ESP32 backend configuration with default settings.
77    #[must_use]
78    pub fn new() -> Self {
79        Self {
80            project_path: default_esp32_project_path(),
81            chip: default_esp32_chip(),
82            panel_width: default_esp32_panel_width(),
83            panel_height: default_esp32_panel_height(),
84            band_height: default_esp32_band_height(),
85            fonts: Vec::new(),
86            font_ranges: Vec::new(),
87        }
88    }
89
90    /// Set a custom project path (defaults to "esp32").
91    #[must_use]
92    pub fn with_project_path(mut self, path: impl Into<PathBuf>) -> Self {
93        self.project_path = path.into();
94        self
95    }
96
97    /// Set the target chip, returning the updated configuration.
98    #[must_use]
99    pub fn with_chip(mut self, chip: Esp32Chip) -> Self {
100        self.chip = chip.id().to_string();
101        self
102    }
103
104    /// Get the path to the ESP32 harness project within the `WaterUI` project.
105    #[must_use]
106    pub const fn project_path(&self) -> &PathBuf {
107        &self.project_path
108    }
109
110    /// Get the configured target chip identifier (e.g. "esp32s3").
111    #[must_use]
112    pub fn chip(&self) -> &str {
113        &self.chip
114    }
115
116    /// Parse the configured chip into an [`Esp32Chip`].
117    ///
118    /// # Errors
119    ///
120    /// Returns an error when the configured chip string is not a supported
121    /// ESP32 chip.
122    pub fn resolved_chip(&self) -> eyre::Result<Esp32Chip> {
123        self.chip.parse()
124    }
125
126    /// Get the harness parameters substituted into generated templates.
127    ///
128    /// Font paths are resolved against `project_root` so the generated
129    /// harness can `include_bytes!` them from wherever it lives. When
130    /// `font_ranges` is configured, each font is subset to those ranges
131    /// into `harness_fonts_dir` and the subset file is embedded instead.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error when the configured chip string is not a supported
136    /// ESP32 chip, when a configured font file does not exist, or when
137    /// subsetting fails.
138    pub fn template_entry(
139        &self,
140        project_root: &Path,
141        harness_fonts_dir: &Path,
142    ) -> eyre::Result<Esp32TemplateEntry> {
143        let ranges = self.font_ranges.join(",");
144        let fonts = self
145            .fonts
146            .iter()
147            .map(|font| {
148                let path = if font.is_absolute() {
149                    font.clone()
150                } else {
151                    project_root.join(font)
152                };
153                if !path.is_file() {
154                    eyre::bail!(
155                        "[backends.esp32] fonts entry {} does not exist (resolved to {})",
156                        font.display(),
157                        path.display()
158                    );
159                }
160                let path = if ranges.is_empty() {
161                    path
162                } else {
163                    subset_font(&path, &ranges, harness_fonts_dir)?
164                };
165                Ok(path.to_string_lossy().into_owned())
166            })
167            .collect::<eyre::Result<Vec<_>>>()?;
168        Ok(Esp32TemplateEntry::new(
169            self.resolved_chip()?,
170            self.panel_width,
171            self.panel_height,
172            self.band_height,
173        )
174        .with_fonts(fonts))
175    }
176
177    /// Check whether generated ESP32 harness files should be regenerated.
178    ///
179    /// This is used by playground mode where backend glue code is fully managed by the CLI.
180    ///
181    /// # Errors
182    ///
183    /// Returns an error when the harness `Cargo.toml` exists but cannot be parsed.
184    pub fn requires_regeneration(project: &Project) -> eyre::Result<bool> {
185        let backend_path = project.backend_path::<Self>();
186        let cargo_toml_path = backend_path.join("Cargo.toml");
187        if !cargo_toml_path.exists() {
188            return Ok(true);
189        }
190
191        let manifest =
192            CargoManifest::<cargo_toml::Value>::from_path(&cargo_toml_path).map_err(|error| {
193                eyre::eyre!("failed to parse {}: {error}", cargo_toml_path.display())
194            })?;
195        let main_rs = std::fs::read_to_string(backend_path.join("src/main.rs")).unwrap_or_default();
196        let config = project
197            .esp32_backend()
198            .cloned()
199            .unwrap_or_default()
200            .template_entry(project.root(), &backend_path.join("fonts"))?;
201        let main_matches_panel = main_rs.contains(&format!(
202            "PanelConfig::new({}, {}, {})",
203            config.panel_width, config.panel_height, config.band_height
204        ));
205        let main_matches_fonts = main_rs.matches("include_bytes!").count() == config.fonts.len()
206            && config
207                .fonts
208                .iter()
209                .all(|font| main_rs.contains(font.as_str()));
210        let cargo_target_matches = backend_path
211            .join(".cargo/config.toml")
212            .exists()
213            .then(|| std::fs::read_to_string(backend_path.join(".cargo/config.toml")).ok())
214            .flatten()
215            .is_some_and(|cargo_config| {
216                cargo_config.contains(&format!("target = \"{}\"", config.resolved_target_triple()))
217            });
218
219        // A manifest rendered before the backend carried the framework patch
220        // tables lets `waterui-dew`'s own `waterui-*` requirements resolve
221        // beside the project's copies — the recorded framework selection
222        // produces the patch set the manifest must already carry.
223        // The emitter (`generated_crate_patches`) prefers the checkout
224        // whenever `waterui_path` resolves, so the comparison must name the
225        // arms in the same order — a manifest carrying both fields emits the
226        // checkout's set, and expecting the channel's would regenerate
227        // forever.
228        let expected_patches = match (
229            &project.manifest().waterui_path,
230            &project.manifest().framework,
231        ) {
232            (Some(waterui_path), _) => {
233                let path = Path::new(waterui_path);
234                let root = if path.is_absolute() {
235                    path.to_path_buf()
236                } else {
237                    project.root().join(path)
238                };
239                Some(
240                    crate::project_model::templates::collect_workspace_patches(&root).map_err(
241                        |error| {
242                            eyre::eyre!(
243                                "failed to read the WaterUI checkout's patch tables at {}: {error}",
244                                root.display()
245                            )
246                        },
247                    )?,
248                )
249            }
250            (None, Some(framework)) => Some(framework.patches()),
251            (None, None) => None,
252        };
253
254        // The generated package name carries the project-root tag — a
255        // manifest rendered before it did must be rewritten, or artifact
256        // lookups would go looking for the tagged name.
257        let package_name_matches = manifest
258            .package
259            .as_ref()
260            .is_some_and(|package| package.name == project.esp32_backend_crate_name().as_str());
261
262        Ok(!package_name_matches
263            || !manifest.dependencies.contains_key("waterui-dew")
264            || !main_matches_panel
265            || !main_matches_fonts
266            || !cargo_target_matches
267            || expected_patches.is_some_and(|expected| manifest.patch != expected)
268            || !backend_path.join("rust-toolchain.toml").exists()
269            || !backend_path.join(".cargo/config.toml").exists()
270            || !backend_path.join("sdkconfig.defaults").exists()
271            || !backend_path.join("partitions.csv").exists()
272            || !backend_path.join("build.rs").exists())
273    }
274}
275
276impl Default for Esp32Backend {
277    fn default() -> Self {
278        Self::new()
279    }
280}
281
282impl Backend for Esp32Backend {
283    const DEFAULT_PATH: &'static str = "esp32";
284
285    // The ESP32 harness uses Cargo build cache under the project target tree.
286    const CACHE_PATHS: &'static [&'static str] = &[];
287
288    fn path(&self) -> &Path {
289        &self.project_path
290    }
291
292    async fn init(project: &Project) -> Result<Self, crate::backend::FailToInitBackend> {
293        let manifest = project.manifest();
294        let backend = project.esp32_backend().cloned().unwrap_or_default();
295
296        let app_name = manifest
297            .package
298            .name
299            .chars()
300            .filter(|c| c.is_alphanumeric())
301            .collect::<String>();
302        let template_entry = backend
303            .template_entry(
304                project.root(),
305                &project.backend_path::<Self>().join("fonts"),
306            )
307            .map_err(crate::backend::FailToInitBackend::Config)?;
308        if template_entry.fonts.is_empty() {
309            tracing::warn!(
310                "[backends.esp32] bundles no fonts; dew fails fast at the first text layout. \
311                 Add `fonts = [\"path/to/Font.ttf\"]` (relative to the project root) to render text."
312            );
313        }
314        let ctx = TemplateContext::for_project_manifest(
315            manifest,
316            project.crate_name().clone(),
317            app_name,
318            &project
319                .resolved_framework()
320                .await
321                .map_err(crate::backend::FailToInitBackend::Config)?,
322        )
323        .with_backend_project_path(project.backend_path::<Self>())
324        .with_project_root_path(project.root().to_path_buf())
325        .with_esp32(template_entry);
326
327        templates::esp32::scaffold(&project.backend_path::<Self>(), &ctx)
328            .await
329            .map_err(crate::backend::FailToInitBackend::Io)?;
330
331        Ok(backend)
332    }
333
334    fn supports(&self, platform: TargetPlatform) -> bool {
335        is_esp32_platform(platform)
336    }
337
338    async fn build(
339        &self,
340        project: &Project,
341        platform: TargetPlatform,
342        options: BuildOptions,
343    ) -> eyre::Result<PathBuf> {
344        if !is_esp32_platform(platform) {
345            eyre::bail!("ESP32 backend only supports the esp32s3, esp32c3, and esp32p4 platforms");
346        }
347        build_esp32(project, options).await
348    }
349
350    async fn package(
351        &self,
352        project: &Project,
353        platform: TargetPlatform,
354        options: PackageOptions,
355    ) -> eyre::Result<Artifact> {
356        if !is_esp32_platform(platform) {
357            eyre::bail!("ESP32 backend only supports the esp32s3, esp32c3, and esp32p4 platforms");
358        }
359        package_esp32(project, options).await
360    }
361
362    async fn clean(&self, project: &Project, _platform: TargetPlatform) -> eyre::Result<()> {
363        clean_esp32(project).await
364    }
365}
366
367fn default_esp32_project_path() -> PathBuf {
368    PathBuf::from("esp32")
369}
370
371fn is_default_esp32_project_path(path: &Path) -> bool {
372    path == Path::new("esp32")
373}
374
375fn default_esp32_chip() -> String {
376    "esp32s3".to_string()
377}
378
379fn is_default_esp32_chip(chip: &str) -> bool {
380    chip == "esp32s3"
381}
382
383const fn default_esp32_panel_width() -> u32 {
384    410
385}
386
387#[expect(
388    clippy::trivially_copy_pass_by_ref,
389    reason = "serde skip_serializing_if requires a reference predicate"
390)]
391const fn is_default_esp32_panel_width(width: &u32) -> bool {
392    *width == default_esp32_panel_width()
393}
394
395const fn default_esp32_panel_height() -> u32 {
396    502
397}
398
399#[expect(
400    clippy::trivially_copy_pass_by_ref,
401    reason = "serde skip_serializing_if requires a reference predicate"
402)]
403const fn is_default_esp32_panel_height(height: &u32) -> bool {
404    *height == default_esp32_panel_height()
405}
406
407const fn default_esp32_band_height() -> u32 {
408    16
409}
410
411#[expect(
412    clippy::trivially_copy_pass_by_ref,
413    reason = "serde skip_serializing_if requires a reference predicate"
414)]
415const fn is_default_esp32_band_height(band_height: &u32) -> bool {
416    *band_height == default_esp32_band_height()
417}