Skip to main content

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}