consortium_builder/compile/env.rs
1// Copyright 2026 Ethan Wu
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Compile-time environment-injection carriers.
18//!
19//! The builder lowers high-level build parameters into the `CONSORTIUM_*`
20//! environment variables that firmware/application/HMI crates read with
21//! `option_env!` at compile time. These types describe *what* the builder
22//! injects, not the parsed `Consortium.toml` schema (that lives in
23//! `consortium-cfg-common`); the mapping from the current schema onto these
24//! carriers is still being reconciled, so they stay local to the builder.
25
26use std::path::{Path, PathBuf};
27
28use consortium_cfg_common::config::{Hmi, HmiEngine, MemoryRegion};
29use serde::{Deserialize, Serialize};
30
31/// Shared-memory region layout for an IPC channel.
32///
33/// Injected as compile-time environment variables by the builder before invoking
34/// `cargo build`. The physical base address is **not** carried here: the IPC
35/// transport never resolves its region from the environment. Controller firmware
36/// gets the base baked into its generated init code (via the lowered `Address`),
37/// and the Linux application reaches the *same* memory through the UIO device
38/// named by `uio`; so no side needs a `CONSORTIUM_IPC_BASE`.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields, rename_all = "snake_case")]
41pub struct SharedMemConfig {
42 /// Length of the region in bytes.
43 pub length: u64,
44 /// UIO device index the Linux application mmaps to reach the same region.
45 pub uio: u32,
46 /// Maximum transfer unit (payload bytes per slot) for the channel.
47 pub mtu: u32,
48}
49
50impl SharedMemConfig {
51 /// Builds a resolved config from a `[[ipc.channel]]` shared-memory region
52 /// plus the UIO index and MTU the lowering resolved.
53 ///
54 /// Only the region's `length` is carried; the physical base is resolved by
55 /// the generated code, not the environment (see the type docs). `uio` is the
56 /// AP-side UIO device index the Linux application mmaps (from `LoweredIr`'s
57 /// resolved `ApAddress`), and `mtu` is the channel MTU.
58 pub fn from_region(region: MemoryRegion, uio: u32, mtu: u32) -> Self {
59 Self {
60 length: u64::from(region.length),
61 uio,
62 mtu,
63 }
64 }
65
66 /// Environment variable suffixes used when the builder injects this config.
67 ///
68 /// The builder emits `CONSORTIUM_<PREFIX>_LENGTH`, `_UIO`, and `_MTU`, where
69 /// `<PREFIX>` is `IPC`. Consuming crates read these with `option_env!` and
70 /// parse them in a `const` context. Numeric values are emitted as decimal
71 /// strings even when the source manifest used hexadecimal TOML literals.
72 pub fn env_pairs(&self, prefix: &str) -> [(String, String); 3] {
73 [
74 (
75 format!("CONSORTIUM_{prefix}_LENGTH"),
76 self.length.to_string(),
77 ),
78 (format!("CONSORTIUM_{prefix}_UIO"), self.uio.to_string()),
79 (format!("CONSORTIUM_{prefix}_MTU"), self.mtu.to_string()),
80 ]
81 }
82}
83
84/// Debug-ring layout injected as `CONSORTIUM_DBG_*` compile-time variables.
85///
86/// Unlike IPC, the defmt logger *does* read its region from the environment:
87/// `consortium-dbg`'s `config` module resolves `CONSORTIUM_DBG_BASE` and
88/// `CONSORTIUM_DBG_LENGTH` with `option_env!` in a `const` context (and treats
89/// the presence of `BASE` as "debug configured"). So the debug carrier keeps the
90/// physical base the IPC carrier drops.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(deny_unknown_fields, rename_all = "snake_case")]
93pub struct DbgConfig {
94 /// Physical base of the debug ring, as seen by the logging core (raw memory).
95 pub base: u64,
96 /// Length of the ring in bytes.
97 pub length: u64,
98}
99
100impl DbgConfig {
101 /// Builds a debug carrier from a resolved ring base and length.
102 pub fn new(base: u64, length: u64) -> Self {
103 Self { base, length }
104 }
105
106 /// The `CONSORTIUM_DBG_BASE`/`_LENGTH` pairs `consortium-dbg` reads.
107 /// Numeric values are emitted as decimal strings.
108 pub fn env_pairs(&self) -> [(String, String); 2] {
109 [
110 ("CONSORTIUM_DBG_BASE".to_string(), self.base.to_string()),
111 ("CONSORTIUM_DBG_LENGTH".to_string(), self.length.to_string()),
112 ]
113 }
114}
115
116/// On-target install subdirectory (beneath `[profile] deploy_root`) for each
117/// engine's assets: a served tree for Cog, a bundle/pak pair for PocketJS.
118const WWW_SUBDIR: &str = "www";
119const POCKET_SUBDIR: &str = "hmi";
120
121/// Compile-time HMI carrier the builder injects as environment variables before
122/// invoking the application `cargo build`. The consuming runtime crate reads
123/// them with `option_env!` at compile time, so the *values* are on-target paths
124/// resolved beneath `[profile] deploy_root` — the build host never dereferences
125/// them.
126///
127/// The engine tag selects which variables are emitted:
128/// - [`Cog`](Self::Cog): `consortium-hmi-webkit` serves a dist directory.
129/// - [`Pocket`](Self::Pocket): `consortium-hmi-pocket` loads a bundle + pak.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case", tag = "engine")]
132pub enum HmiConfig {
133 /// Cog/WPE WebKit shell serving a static dist directory via `csti://`.
134 Cog {
135 /// On-target directory the deployed application serves assets from;
136 /// becomes `CONSORTIUM_HMI_DIST`.
137 dist: String,
138 /// Dev-server URL loaded directly in debug builds; when absent, debug
139 /// builds fall back to the `csti://` static handler.
140 #[serde(skip_serializing_if = "Option::is_none")]
141 debug_url: Option<String>,
142 },
143 /// Native PocketJS shell loading a built bundle + pak from on-target paths.
144 Pocket {
145 /// On-target path of the PocketJS `<app>.js` bundle; becomes
146 /// `CONSORTIUM_HMI_POCKET_BUNDLE`.
147 bundle: String,
148 /// On-target path of the PocketJS `<app>.pak`; becomes
149 /// `CONSORTIUM_HMI_POCKET_PAK`.
150 pak: String,
151 /// Logical PocketJS viewport width in pixels.
152 logical_width: u32,
153 /// Logical PocketJS viewport height in pixels.
154 logical_height: u32,
155 },
156}
157
158impl HmiConfig {
159 /// Builds the compile-time HMI carrier from the parsed `[hmi]` section.
160 ///
161 /// Because the build host and the target are different machines, every value
162 /// is an *on-target* path a downstream packaging recipe installs assets to.
163 /// Relative install locations are resolved beneath `deploy_root` (the
164 /// manifest's `[profile] deploy_root` convention): Cog serves
165 /// `{deploy_root}/www`, and PocketJS loads `{deploy_root}/hmi/<app>.{js,pak}`.
166 ///
167 /// Callers must have validated `hmi` (see [`Hmi::validate`]); a PocketJS
168 /// section without `app` falls back to an empty name here.
169 pub fn from_hmi(hmi: &Hmi, deploy_root: &str) -> Self {
170 match hmi.engine {
171 HmiEngine::Cog => Self::Cog {
172 dist: on_target(deploy_root, WWW_SUBDIR)
173 .to_string_lossy()
174 .into_owned(),
175 debug_url: hmi.debug.clone(),
176 },
177 HmiEngine::Pocket => {
178 let app = hmi.app.as_deref().unwrap_or_default();
179 let base = on_target(deploy_root, POCKET_SUBDIR);
180 Self::Pocket {
181 bundle: base
182 .join(format!("{app}.js"))
183 .to_string_lossy()
184 .into_owned(),
185 pak: base
186 .join(format!("{app}.pak"))
187 .to_string_lossy()
188 .into_owned(),
189 logical_width: hmi.logical.width,
190 logical_height: hmi.logical.height,
191 }
192 }
193 }
194 }
195
196 /// Environment variables the builder injects for this config.
197 ///
198 /// Consuming crates read these with `option_env!` at compile time. For Cog,
199 /// `CONSORTIUM_HMI_DEBUG_URL` is emitted only when set, so its absence lets
200 /// debug builds fall back to the static `csti://` handler.
201 pub fn env_pairs(&self) -> Vec<(String, String)> {
202 match self {
203 Self::Cog { dist, debug_url } => {
204 let mut pairs = vec![("CONSORTIUM_HMI_DIST".to_string(), dist.clone())];
205 if let Some(url) = debug_url {
206 pairs.push(("CONSORTIUM_HMI_DEBUG_URL".to_string(), url.clone()));
207 }
208 pairs
209 }
210 Self::Pocket {
211 bundle,
212 pak,
213 logical_width,
214 logical_height,
215 } => vec![
216 ("CONSORTIUM_HMI_POCKET_BUNDLE".to_string(), bundle.clone()),
217 ("CONSORTIUM_HMI_POCKET_PAK".to_string(), pak.clone()),
218 (
219 "CONSORTIUM_HMI_LOGICAL_WIDTH".to_string(),
220 logical_width.to_string(),
221 ),
222 (
223 "CONSORTIUM_HMI_LOGICAL_HEIGHT".to_string(),
224 logical_height.to_string(),
225 ),
226 ],
227 }
228 }
229}
230
231/// Resolves an on-target subdirectory beneath `deploy_root`. An absolute
232/// `deploy_root` yields an absolute path; the subdir is always relative.
233fn on_target(deploy_root: &str, subdir: &str) -> PathBuf {
234 Path::new(deploy_root).join(subdir)
235}
236
237/// Cortex-M core a firmware crate targets; selects the Rust target triple.
238#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
239#[serde(rename_all = "kebab-case")]
240pub enum CortexMcuCore {
241 CortexM0p,
242 CortexM33,
243 CortexM7,
244 CortexM4,
245 CortexM4f,
246}
247
248impl CortexMcuCore {
249 /// Parses a dash-separated core name string (e.g., `"cortex-m33"`) into a variant.
250 pub fn as_key(s: &str) -> Option<Self> {
251 match s {
252 "cortex-m0p" => Some(Self::CortexM0p),
253 "cortex-m33" => Some(Self::CortexM33),
254 "cortex-m7" => Some(Self::CortexM7),
255 "cortex-m4" => Some(Self::CortexM4),
256 "cortex-m4f" => Some(Self::CortexM4f),
257 _ => None,
258 }
259 }
260
261 /// Returns the short abbreviation for this core (e.g., `"cm33"`).
262 pub fn abbr(&self) -> &'static str {
263 match self {
264 Self::CortexM0p => "cm0p",
265 Self::CortexM33 => "cm33",
266 Self::CortexM7 => "cm7",
267 Self::CortexM4 => "cm4",
268 Self::CortexM4f => "cm4f",
269 }
270 }
271
272 /// Parses a short abbreviation (e.g., `"cm33"`) into a variant.
273 pub fn from_abbr(s: &str) -> Option<Self> {
274 match s {
275 "cm0p" => Some(Self::CortexM0p),
276 "cm33" => Some(Self::CortexM33),
277 "cm7" => Some(Self::CortexM7),
278 "cm4" => Some(Self::CortexM4),
279 "cm4f" => Some(Self::CortexM4f),
280 _ => None,
281 }
282 }
283
284 /// Returns the Rust target triple for cross-compiling to this core.
285 pub fn to_rust_target(&self) -> &'static str {
286 match self {
287 Self::CortexM0p => "thumbv6m-none-eabi",
288 Self::CortexM33 => "thumbv8m.main-none-eabihf",
289 Self::CortexM7 | Self::CortexM4f => "thumbv7em-none-eabihf",
290 Self::CortexM4 => "thumbv7em-none-eabi",
291 }
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 const SAMPLE: SharedMemConfig = SharedMemConfig {
300 length: 0x1000,
301 uio: 0,
302 mtu: 64,
303 };
304
305 #[test]
306 fn env_pairs_emit_the_ipc_layout_the_app_reads() {
307 // The IPC carrier no longer carries a base: nothing reads
308 // `CONSORTIUM_IPC_BASE` (the transport resolves its region from generated
309 // code / UIO). Numeric env-var values are decimal strings.
310 let pairs = SAMPLE.env_pairs("IPC");
311 assert_eq!(
312 pairs,
313 [
314 ("CONSORTIUM_IPC_LENGTH".to_string(), "4096".to_string()),
315 ("CONSORTIUM_IPC_UIO".to_string(), "0".to_string()),
316 ("CONSORTIUM_IPC_MTU".to_string(), "64".to_string()),
317 ]
318 );
319 // No base is emitted for IPC.
320 assert!(!pairs.iter().any(|(k, _)| k == "CONSORTIUM_IPC_BASE"));
321 }
322
323 #[test]
324 fn dbg_config_emits_the_base_and_length_the_logger_reads() {
325 // The debug carrier keeps base + length, matching exactly what
326 // `consortium-dbg`'s `config` module reads with `option_env!`.
327 let dbg = DbgConfig::new(0x2040_8000, 0x2000);
328 assert_eq!(
329 dbg.env_pairs(),
330 [
331 ("CONSORTIUM_DBG_BASE".to_string(), "541097984".to_string()),
332 ("CONSORTIUM_DBG_LENGTH".to_string(), "8192".to_string()),
333 ]
334 );
335 }
336
337 #[test]
338 fn hmi_env_pairs_emit_dist_and_optional_debug_url() {
339 let with_url = HmiConfig::Cog {
340 dist: "ui/dist".to_string(),
341 debug_url: Some("http://localhost:5173".to_string()),
342 };
343 assert_eq!(
344 with_url.env_pairs(),
345 [
346 ("CONSORTIUM_HMI_DIST".to_string(), "ui/dist".to_string()),
347 (
348 "CONSORTIUM_HMI_DEBUG_URL".to_string(),
349 "http://localhost:5173".to_string()
350 ),
351 ]
352 );
353
354 let no_url = HmiConfig::Cog {
355 dist: "ui/dist".to_string(),
356 debug_url: None,
357 };
358 assert_eq!(
359 no_url.env_pairs(),
360 [("CONSORTIUM_HMI_DIST".to_string(), "ui/dist".to_string())]
361 );
362 }
363
364 #[test]
365 fn pocket_env_pairs_emit_bundle_and_pak_paths() {
366 let cfg = HmiConfig::Pocket {
367 bundle: "/opt/consortium/hmi/hero-main.js".to_string(),
368 pak: "/opt/consortium/hmi/hero-main.pak".to_string(),
369 logical_width: 800,
370 logical_height: 480,
371 };
372 assert_eq!(
373 cfg.env_pairs(),
374 [
375 (
376 "CONSORTIUM_HMI_POCKET_BUNDLE".to_string(),
377 "/opt/consortium/hmi/hero-main.js".to_string()
378 ),
379 (
380 "CONSORTIUM_HMI_POCKET_PAK".to_string(),
381 "/opt/consortium/hmi/hero-main.pak".to_string()
382 ),
383 (
384 "CONSORTIUM_HMI_LOGICAL_WIDTH".to_string(),
385 "800".to_string()
386 ),
387 (
388 "CONSORTIUM_HMI_LOGICAL_HEIGHT".to_string(),
389 "480".to_string()
390 ),
391 ]
392 );
393 }
394
395 #[test]
396 fn from_region_widens_schema_region_into_injectable_config() {
397 // A resolved `[[ipc.channel]]` region plus the lowered UIO index and MTU
398 // produce the config the builder injects; the region's base is dropped
399 // (the IPC carrier has no base).
400 let region = MemoryRegion {
401 base: 0x2040_0000,
402 length: 0x4000,
403 };
404 let cfg = SharedMemConfig::from_region(region, 0, 128);
405 assert_eq!(
406 cfg,
407 SharedMemConfig {
408 length: 0x4000,
409 uio: 0,
410 mtu: 128,
411 }
412 );
413 // The widened length round-trips through the env injection.
414 let pairs = cfg.env_pairs("IPC");
415 assert_eq!(
416 pairs[0],
417 ("CONSORTIUM_IPC_LENGTH".to_string(), "16384".to_string())
418 );
419 }
420
421 #[test]
422 fn from_hmi_places_cog_dist_under_deploy_root() {
423 use consortium_cfg_common::config::{Hmi, HmiBackend, HmiEngine};
424
425 let hmi = Hmi {
426 engine: HmiEngine::Cog,
427 backend: HmiBackend::Wayland,
428 debug: Some("http://127.0.0.1:5173/".to_string()),
429 source: "website".to_string(),
430 build: "website/dist".to_string(),
431 app: None,
432 logical: Default::default(),
433 };
434
435 // The Cog engine serves `{deploy_root}/www`, and the debug URL carries
436 // through for dev builds.
437 let cfg = HmiConfig::from_hmi(&hmi, "/opt/consortium");
438 assert_eq!(
439 cfg,
440 HmiConfig::Cog {
441 dist: "/opt/consortium/www".to_string(),
442 debug_url: Some("http://127.0.0.1:5173/".to_string()),
443 }
444 );
445 }
446
447 #[test]
448 fn from_hmi_places_pocket_bundle_and_pak_under_deploy_root() {
449 use consortium_cfg_common::config::{Hmi, HmiBackend, HmiEngine};
450
451 let hmi = Hmi {
452 engine: HmiEngine::Pocket,
453 backend: HmiBackend::Wayland,
454 debug: None,
455 source: "submodules/pocketjs".to_string(),
456 build: "dist".to_string(),
457 app: Some("hero-main".to_string()),
458 logical: consortium_cfg_common::config::HmiLogical {
459 width: 800,
460 height: 480,
461 },
462 };
463
464 let cfg = HmiConfig::from_hmi(&hmi, "/opt/consortium");
465 assert_eq!(
466 cfg,
467 HmiConfig::Pocket {
468 bundle: "/opt/consortium/hmi/hero-main.js".to_string(),
469 pak: "/opt/consortium/hmi/hero-main.pak".to_string(),
470 logical_width: 800,
471 logical_height: 480,
472 }
473 );
474 }
475
476 #[test]
477 fn mcu_core_resolves_rust_target_triples() {
478 assert_eq!(
479 CortexMcuCore::CortexM33.to_rust_target(),
480 "thumbv8m.main-none-eabihf"
481 );
482 assert_eq!(
483 CortexMcuCore::CortexM7.to_rust_target(),
484 "thumbv7em-none-eabihf"
485 );
486 assert_eq!(
487 CortexMcuCore::as_key("cortex-m33"),
488 Some(CortexMcuCore::CortexM33)
489 );
490 assert_eq!(
491 CortexMcuCore::from_abbr("cm0p"),
492 Some(CortexMcuCore::CortexM0p)
493 );
494 assert_eq!(CortexMcuCore::CortexM4f.abbr(), "cm4f");
495 }
496}