Skip to content

Commit 5113b5d

Browse files
lxsaahclaude
andcommitted
feat(weather-mesh): wire gamma for mqtts:// mesh profiles
Design 044 §7: an mqtts:// broker in the MESH_CONFIG profile now builds TLS firmware instead of panicking in build.rs. - build.rs sets the mesh_tls cfg, defaults the port to 8883, and embeds the root CA from $MESH_CA (PEM or DER) as MQTT_CA_DER; a mqtts:// profile without MESH_CA fails the build with instructions. Plain profiles and the no-profile local demo generate the same config as before (plus the new MQTT_BROKER_SCHEME const). - main.rs feeds the profile credential to with_credentials() and, under cfg(mesh_tls), moves the TRNG into a static shared with the TLS handshake and passes static record buffers to with_tls(). - StackResources grows 3 → 5 (DNS + SNTP sockets). Verified: builds clean for thumbv8m.main-none-eabihf both with a synthetic mqtts:// profile + CA and without one; on-broker verification happens on the bench once a mesh credential exists (WP7 acceptance). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 746488f commit 5113b5d

3 files changed

Lines changed: 147 additions & 28 deletions

File tree

examples/weather-mesh-demo/weather-station-gamma/Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,16 @@ aimdb-embassy-adapter = { path = "../../../aimdb-embassy-adapter", default-featu
3333
"embassy-runtime",
3434
] }
3535
aimdb-data-contracts = { path = "../../../aimdb-data-contracts", default-features = false }
36+
# `embassy-tls` compiles the mqtts:// transport (design 044). The connector
37+
# picks plain vs TLS at runtime from the URL scheme, so the TLS code is in
38+
# every image (~0.7 MB text of the 2 MB flash, incl. everything else); only
39+
# the `mesh_tls`-gated wiring in main.rs (TRNG handoff, 21 KB of record
40+
# buffers) is compiled out of plain-demo builds.
41+
# `defmt` surfaces the DHCP → SNTP → TLS connect progress on the console.
3642
aimdb-mqtt-connector = { path = "../../../aimdb-mqtt-connector", default-features = false, features = [
3743
"embassy-runtime",
44+
"embassy-tls",
45+
"defmt",
3846
] }
3947

4048
# Embassy ecosystem - STM32H563ZI with Ethernet (same as embassy-mqtt-connector-demo)
@@ -98,6 +106,8 @@ rand = { version = "0.10.1", default-features = false, optional = true }
98106
# Parses the $MESH_CONFIG station profile (station.toml, design 042 §8.2) to
99107
# generate station_config.rs. Host-side only — nothing lands in the firmware.
100108
toml = "0.8"
109+
# Decodes a PEM $MESH_CA into the embedded DER root CA (design 044 §7).
110+
base64 = "0.22"
101111

102112
[package.metadata.embassy]
103113
build = [

examples/weather-mesh-demo/weather-station-gamma/build.rs

Lines changed: 83 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
//! build → flash → live. With `MESH_CONFIG` unset, the generated file carries
88
//! the local docker-compose demo defaults, so the tier-1 demo stays
99
//! zero-setup.
10+
//!
11+
//! An `mqtts://` broker in the profile builds TLS firmware (design 044 §7):
12+
//! the `mesh_tls` cfg gates the TLS wiring in `main.rs`, and `MESH_CA` must
13+
//! point at the broker's root CA certificate (PEM or DER), embedded as
14+
//! `MQTT_CA_DER`.
1015
1116
use std::env;
1217
use std::fs;
@@ -18,6 +23,9 @@ fn main() {
1823
println!("cargo:rustc-link-arg-bins=-Tdefmt.x");
1924

2025
println!("cargo:rerun-if-env-changed=MESH_CONFIG");
26+
println!("cargo:rerun-if-env-changed=MESH_CA");
27+
// Set for mqtts:// profiles; gates the TLS wiring in main.rs (044 §7).
28+
println!("cargo::rustc-check-cfg=cfg(mesh_tls)");
2129

2230
let config = match env::var("MESH_CONFIG") {
2331
Ok(path) => {
@@ -35,7 +43,15 @@ fn main() {
3543
/// The topics mirror the `TempKey::Gamma`/…`::Gamma` link addresses in
3644
/// `weather-mesh-common`.
3745
fn local_demo_config() -> String {
38-
station_config("192.168.1.3", 1883, None, None, "gamma", "sensors/gamma")
46+
station_config(
47+
false,
48+
"192.168.1.3",
49+
1883,
50+
None,
51+
None,
52+
"gamma",
53+
"sensors/gamma",
54+
)
3955
}
4056

4157
/// Configuration from the station profile at `path` (design 043 §4).
@@ -63,24 +79,27 @@ fn mesh_station_config(path: &str) -> String {
6379
};
6480

6581
let broker_url = str_field("broker", "url");
66-
// The Embassy MQTT client has no TLS yet (design 042 §8.1) — a profile for
67-
// the public broker (mqtts://) cannot work; only a plain-TCP broker (e.g.
68-
// the local demo one) is reachable until that lands.
69-
let rest = broker_url.strip_prefix("mqtt://").unwrap_or_else(|| {
70-
panic!(
71-
"MESH_CONFIG: broker URL '{broker_url}' is not mqtt:// — the Embassy \
72-
MQTT client has no TLS support yet (design 042 §8.1), so this \
73-
firmware can only target a plain-TCP broker"
74-
)
75-
});
82+
// `mqtts://` builds TLS firmware (design 044): `mesh_tls` cfg + embedded
83+
// root CA from $MESH_CA. Plain `mqtt://` stays byte-identical to before.
84+
let (tls, rest) = if let Some(rest) = broker_url.strip_prefix("mqtts://") {
85+
(true, rest)
86+
} else if let Some(rest) = broker_url.strip_prefix("mqtt://") {
87+
(false, rest)
88+
} else {
89+
panic!("MESH_CONFIG: broker URL '{broker_url}' must be mqtt:// or mqtts://")
90+
};
91+
if tls {
92+
println!("cargo:rustc-cfg=mesh_tls");
93+
embed_ca();
94+
}
7695
let authority = rest.split('/').next().unwrap_or(rest);
7796
let (host, port) = match authority.rsplit_once(':') {
7897
Some((host, port)) => (
7998
host,
8099
port.parse::<u16>()
81100
.unwrap_or_else(|_| panic!("MESH_CONFIG: invalid broker port in '{broker_url}'")),
82101
),
83-
None => (authority, 1883),
102+
None => (authority, if tls { 8883 } else { 1883 }),
84103
};
85104

86105
let station_id = profile
@@ -101,6 +120,7 @@ fn mesh_station_config(path: &str) -> String {
101120
));
102121

103122
station_config(
123+
tls,
104124
host,
105125
port,
106126
credentials,
@@ -110,9 +130,50 @@ fn mesh_station_config(path: &str) -> String {
110130
)
111131
}
112132

133+
/// Embed the broker's root CA (design 044 §7): `$MESH_CA` names a file with
134+
/// the root CA certificate — PEM or DER — decoded to `OUT_DIR/ca.der` and
135+
/// surfaced as the generated `MQTT_CA_DER` const. The station profile does
136+
/// not carry the CA (044 §9), so for now it travels out of band.
137+
fn embed_ca() {
138+
let path = env::var("MESH_CA").unwrap_or_else(|_| {
139+
panic!(
140+
"MESH_CONFIG names an mqtts:// broker, but MESH_CA is unset — point \
141+
it at the broker's root CA certificate (PEM or DER), e.g. \
142+
MESH_CA=isrg-root-x1.pem (design 044 §7)"
143+
)
144+
});
145+
println!("cargo:rerun-if-changed={path}");
146+
let raw = fs::read(&path).unwrap_or_else(|e| panic!("MESH_CA: cannot read '{path}': {e}"));
147+
let der = if raw.starts_with(b"-----BEGIN") {
148+
pem_to_der(&raw, &path)
149+
} else {
150+
raw
151+
};
152+
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
153+
fs::write(out_dir.join("ca.der"), der).unwrap();
154+
}
155+
156+
/// Decode the first CERTIFICATE block of a PEM file to DER.
157+
fn pem_to_der(pem: &[u8], path: &str) -> Vec<u8> {
158+
use base64::Engine;
159+
160+
let text = std::str::from_utf8(pem)
161+
.unwrap_or_else(|_| panic!("MESH_CA: '{path}' is neither valid PEM nor DER"));
162+
let body: String = text
163+
.lines()
164+
.skip_while(|line| !line.starts_with("-----BEGIN CERTIFICATE-----"))
165+
.skip(1)
166+
.take_while(|line| !line.starts_with("-----END"))
167+
.collect();
168+
base64::engine::general_purpose::STANDARD
169+
.decode(body.trim())
170+
.unwrap_or_else(|e| panic!("MESH_CA: '{path}' has an invalid PEM body: {e}"))
171+
}
172+
113173
/// Render the generated `station_config.rs`. String values go through `{:?}`
114174
/// so arbitrary profile content stays a valid Rust literal.
115175
fn station_config(
176+
tls: bool,
116177
host: &str,
117178
port: u16,
118179
credentials: Option<(&str, &str)>,
@@ -124,24 +185,30 @@ fn station_config(
124185
Some(v) => format!("Some({v:?})"),
125186
None => "None".to_string(),
126187
};
188+
let scheme = if tls { "mqtts" } else { "mqtt" };
127189
let username = credential_const(credentials.map(|(u, _)| u));
128190
let password = credential_const(credentials.map(|(_, p)| p));
129191
let slot = match slot {
130192
Some(n) => format!("Some({n})"),
131193
None => "None".to_string(),
132194
};
195+
let ca = if tls {
196+
"/// Root CA for broker certificate verification, from $MESH_CA (044 §7).\n\
197+
pub const MQTT_CA_DER: &[u8] = include_bytes!(concat!(env!(\"OUT_DIR\"), \"/ca.der\"));\n"
198+
} else {
199+
""
200+
};
133201

134202
format!(
135203
"// Generated by build.rs — station configuration (design 042 §8.2).\n\
136204
// Source: $MESH_CONFIG station profile, or local demo defaults. Do not edit.\n\
205+
pub const MQTT_BROKER_SCHEME: &str = {scheme:?};\n\
137206
pub const MQTT_BROKER_HOST: &str = {host:?};\n\
138207
pub const MQTT_BROKER_PORT: u16 = {port};\n\
139-
/// Broker credential from the profile. Unused until the Embassy MQTT\n\
140-
/// client supports authentication + TLS (design 042 §8.1, WP7).\n\
141-
#[allow(dead_code)]\n\
208+
/// Broker credential from the profile, fed to MQTT CONNECT (design 044 D8).\n\
142209
pub const MQTT_USERNAME: Option<&str> = {username};\n\
143-
#[allow(dead_code)]\n\
144210
pub const MQTT_PASSWORD: Option<&str> = {password};\n\
211+
{ca}\
145212
/// Assigned mesh slot; `None` in the local demo.\n\
146213
pub const STATION_SLOT: Option<u16> = {slot};\n\
147214
pub const STATION_NAME: &str = {name:?};\n\

examples/weather-mesh-demo/weather-station-gamma/src/main.rs

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@
2323
//! cd examples/weather-mesh-demo/weather-station-gamma
2424
//! cargo run --release # build → probe-rs flash → defmt logs
2525
//! ```
26-
//! - **Public mesh:** feed it the profile written by `aimdb join`:
26+
//! - **Public mesh:** feed it the profile written by `aimdb join`, plus the
27+
//! broker's root CA for the TLS broker (design 044 §7):
2728
//! ```bash
28-
//! MESH_CONFIG=../station.toml cargo run --release
29+
//! MESH_CONFIG=../station.toml MESH_CA=../broker-root-ca.pem cargo run --release
2930
//! ```
30-
//! (Reaches the public broker only once the Embassy client speaks TLS —
31-
//! design 042 §8.1; until then a profile must name a plain-TCP broker.)
31+
//! An `mqtts://` profile builds TLS firmware (certificate verification,
32+
//! SNTP time source, broker authentication); a plain `mqtt://` profile
33+
//! builds exactly what it did before.
3234
3335
extern crate alloc;
3436

@@ -37,6 +39,8 @@ use aimdb_core::AimDbBuilder;
3739
use aimdb_data_contracts::{RandomWalkParams, SimProfile, SimulatableRegistrarExt};
3840
use aimdb_embassy_adapter::{EmbassyAdapter, EmbassyBufferType, EmbassyRecordRegistrarExtCustom};
3941
use aimdb_mqtt_connector::embassy_client::MqttConnectorBuilder;
42+
#[cfg(mesh_tls)]
43+
use aimdb_mqtt_connector::embassy_client::TlsOptions;
4044
use defmt::*;
4145
use embassy_executor::Spawner;
4246
use embassy_net::StackResources;
@@ -191,6 +195,14 @@ async fn main(spawner: Spawner) {
191195
rng.fill_bytes(&mut seed);
192196
let seed = u64::from_le_bytes(seed);
193197

198+
// The same TRNG then feeds the TLS handshake (design 044 D3), so it moves
199+
// into a static — the session task outlives this function.
200+
#[cfg(mesh_tls)]
201+
let rng: &'static mut Rng<'static, peripherals::RNG> = {
202+
static TLS_RNG: StaticCell<Rng<'static, peripherals::RNG>> = StaticCell::new();
203+
TLS_RNG.init(rng)
204+
};
205+
194206
info!("🔧 Initializing Ethernet...");
195207

196208
// MAC address for this device
@@ -219,8 +231,10 @@ async fn main(spawner: Spawner) {
219231
// Network configuration (using DHCP)
220232
let config = embassy_net::Config::dhcpv4(Default::default());
221233

222-
// Initialize network stack
223-
static RESOURCES: StaticCell<StackResources<3>> = StaticCell::new();
234+
// Initialize network stack. Socket budget: DHCP + the MQTT TCP socket +
235+
// the DNS socket (created by embassy-net's `dns` feature, pulled in by
236+
// the connector's `embassy-tls`) + the SNTP UDP socket, plus one spare.
237+
static RESOURCES: StaticCell<StackResources<5>> = StaticCell::new();
224238
static STACK_CELL: StaticCell<embassy_net::Stack<'static>> = StaticCell::new();
225239

226240
let (stack_obj, runner) =
@@ -254,14 +268,39 @@ async fn main(spawner: Spawner) {
254268
// Create AimDB database with Embassy adapter
255269
let runtime = alloc::sync::Arc::new(EmbassyAdapter::new());
256270

257-
// Build MQTT broker URL
271+
// Build MQTT broker URL — the scheme comes from the profile (mqtts://
272+
// builds the TLS transport, design 044 D9).
258273
use alloc::format;
259-
let broker_url = format!("mqtt://{}:{}", MQTT_BROKER_HOST, MQTT_BROKER_PORT);
260-
261-
let mut builder = AimDbBuilder::new().runtime(runtime.clone()).with_connector(
262-
MqttConnectorBuilder::new(&broker_url, stack).with_client_id("weather-station-gamma"),
274+
let broker_url = format!(
275+
"{}://{}:{}",
276+
MQTT_BROKER_SCHEME, MQTT_BROKER_HOST, MQTT_BROKER_PORT
263277
);
264278

279+
#[allow(unused_mut)]
280+
let mut mqtt_connector =
281+
MqttConnectorBuilder::new(&broker_url, stack).with_client_id("weather-station-gamma");
282+
if let (Some(username), Some(password)) = (MQTT_USERNAME, MQTT_PASSWORD) {
283+
mqtt_connector = mqtt_connector.with_credentials(username, password);
284+
}
285+
// TLS materials (design 044 §4): the TRNG staticked above, the root CA
286+
// from $MESH_CA, and static record buffers (16 640 read — a TLS 1.3 peer
287+
// may send full-size records — and 4 096 write).
288+
#[cfg(mesh_tls)]
289+
let mqtt_connector = {
290+
static TLS_READ_BUF: StaticCell<[u8; 16_640]> = StaticCell::new();
291+
static TLS_WRITE_BUF: StaticCell<[u8; 4_096]> = StaticCell::new();
292+
mqtt_connector.with_tls(TlsOptions::new(
293+
rng,
294+
MQTT_CA_DER,
295+
TLS_READ_BUF.init([0; 16_640]),
296+
TLS_WRITE_BUF.init([0; 4_096]),
297+
))
298+
};
299+
300+
let mut builder = AimDbBuilder::new()
301+
.runtime(runtime.clone())
302+
.with_connector(mqtt_connector);
303+
265304
// Configure temperature record. The record keys stay the compile-time
266305
// enums (they name the records in *this* db); only the MQTT topics come
267306
// from the generated config, so a mesh profile re-points the publishes to
@@ -389,7 +428,10 @@ async fn main(spawner: Spawner) {
389428
info!(" Temperature: {}", temp_topic);
390429
info!(" Humidity: {}", humidity_topic);
391430
info!(" DewPoint: {}", dew_point_topic);
392-
info!(" Broker: {}:{}", MQTT_BROKER_HOST, MQTT_BROKER_PORT);
431+
info!(
432+
" Broker: {}://{}:{}",
433+
MQTT_BROKER_SCHEME, MQTT_BROKER_HOST, MQTT_BROKER_PORT
434+
);
393435
info!("");
394436

395437
static DB_CELL: StaticCell<aimdb_core::AimDb> = StaticCell::new();

0 commit comments

Comments
 (0)