1use 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#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 fonts: Vec<PathBuf>,
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
72 font_ranges: Vec<String>,
73}
74
75impl Esp32Backend {
76 #[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 #[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 #[must_use]
99 pub fn with_chip(mut self, chip: Esp32Chip) -> Self {
100 self.chip = chip.id().to_string();
101 self
102 }
103
104 #[must_use]
106 pub const fn project_path(&self) -> &PathBuf {
107 &self.project_path
108 }
109
110 #[must_use]
112 pub fn chip(&self) -> &str {
113 &self.chip
114 }
115
116 pub fn resolved_chip(&self) -> eyre::Result<Esp32Chip> {
123 self.chip.parse()
124 }
125
126 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 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 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 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 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}