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        Ok(!manifest.dependencies.contains_key("waterui-dew")
220            || !main_matches_panel
221            || !main_matches_fonts
222            || !cargo_target_matches
223            || !backend_path.join("rust-toolchain.toml").exists()
224            || !backend_path.join(".cargo/config.toml").exists()
225            || !backend_path.join("sdkconfig.defaults").exists()
226            || !backend_path.join("partitions.csv").exists()
227            || !backend_path.join("build.rs").exists())
228    }
229}
230
231impl Default for Esp32Backend {
232    fn default() -> Self {
233        Self::new()
234    }
235}
236
237impl Backend for Esp32Backend {
238    const DEFAULT_PATH: &'static str = "esp32";
239
240    // The ESP32 harness uses Cargo build cache under the project target tree.
241    const CACHE_PATHS: &'static [&'static str] = &[];
242
243    fn path(&self) -> &Path {
244        &self.project_path
245    }
246
247    async fn init(project: &Project) -> Result<Self, crate::backend::FailToInitBackend> {
248        let manifest = project.manifest();
249        let backend = project.esp32_backend().cloned().unwrap_or_default();
250
251        let app_name = manifest
252            .package
253            .name
254            .chars()
255            .filter(|c| c.is_alphanumeric())
256            .collect::<String>();
257        let template_entry = backend
258            .template_entry(
259                project.root(),
260                &project.backend_path::<Self>().join("fonts"),
261            )
262            .map_err(crate::backend::FailToInitBackend::Config)?;
263        if template_entry.fonts.is_empty() {
264            tracing::warn!(
265                "[backends.esp32] bundles no fonts; dew fails fast at the first text layout. \
266                 Add `fonts = [\"path/to/Font.ttf\"]` (relative to the project root) to render text."
267            );
268        }
269        let ctx = TemplateContext::for_project_manifest(
270            manifest,
271            project.crate_name().clone(),
272            app_name,
273            &project
274                .resolved_framework()
275                .await
276                .map_err(crate::backend::FailToInitBackend::Config)?,
277        )
278        .with_backend_project_path(project.backend_path::<Self>())
279        .with_project_root_path(project.root().to_path_buf())
280        .with_esp32(template_entry);
281
282        templates::esp32::scaffold(&project.backend_path::<Self>(), &ctx)
283            .await
284            .map_err(crate::backend::FailToInitBackend::Io)?;
285
286        Ok(backend)
287    }
288
289    fn supports(&self, platform: TargetPlatform) -> bool {
290        is_esp32_platform(platform)
291    }
292
293    async fn build(
294        &self,
295        project: &Project,
296        platform: TargetPlatform,
297        options: BuildOptions,
298    ) -> eyre::Result<PathBuf> {
299        if !is_esp32_platform(platform) {
300            eyre::bail!("ESP32 backend only supports the esp32s3, esp32c3, and esp32p4 platforms");
301        }
302        build_esp32(project, options).await
303    }
304
305    async fn package(
306        &self,
307        project: &Project,
308        platform: TargetPlatform,
309        options: PackageOptions,
310    ) -> eyre::Result<Artifact> {
311        if !is_esp32_platform(platform) {
312            eyre::bail!("ESP32 backend only supports the esp32s3, esp32c3, and esp32p4 platforms");
313        }
314        package_esp32(project, options).await
315    }
316
317    async fn clean(&self, project: &Project, _platform: TargetPlatform) -> eyre::Result<()> {
318        clean_esp32(project).await
319    }
320}
321
322fn default_esp32_project_path() -> PathBuf {
323    PathBuf::from("esp32")
324}
325
326fn is_default_esp32_project_path(path: &Path) -> bool {
327    path == Path::new("esp32")
328}
329
330fn default_esp32_chip() -> String {
331    "esp32s3".to_string()
332}
333
334fn is_default_esp32_chip(chip: &str) -> bool {
335    chip == "esp32s3"
336}
337
338const fn default_esp32_panel_width() -> u32 {
339    410
340}
341
342#[expect(
343    clippy::trivially_copy_pass_by_ref,
344    reason = "serde skip_serializing_if requires a reference predicate"
345)]
346const fn is_default_esp32_panel_width(width: &u32) -> bool {
347    *width == default_esp32_panel_width()
348}
349
350const fn default_esp32_panel_height() -> u32 {
351    502
352}
353
354#[expect(
355    clippy::trivially_copy_pass_by_ref,
356    reason = "serde skip_serializing_if requires a reference predicate"
357)]
358const fn is_default_esp32_panel_height(height: &u32) -> bool {
359    *height == default_esp32_panel_height()
360}
361
362const fn default_esp32_band_height() -> u32 {
363    16
364}
365
366#[expect(
367    clippy::trivially_copy_pass_by_ref,
368    reason = "serde skip_serializing_if requires a reference predicate"
369)]
370const fn is_default_esp32_band_height(band_height: &u32) -> bool {
371    *band_height == default_esp32_band_height()
372}