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 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 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}