Don't let a locally flashed board replace itself a minute later

b76c527 · Gabriel Morell · 2026-09-16 19:19

5 files +65 -18
Message
{commit_body(@commit)}

Files changed

modified CLAUDE.md
+13 −0
@@ -557,6 +557,19 @@ startup, logging `indicator: cycle counter calibrated at N cycles/us`. If that
557 557 line is missing or `new()` errors, the counter is not running and the timing
558 558 would be garbage — the driver refuses rather than emitting nonsense.
559 559
560 +### A locally flashed board does not poll
561 +
562 +`ota_interval_mins` defaults to **0 for a build CI did not stamp**, and to 360
563 +for one it did (`ota::default_interval_mins`). Otherwise the two halves of this
564 +fight: a local build's version is the bare `Cargo.toml` one, which by design
565 +differs from anything published, so a board flashed for debugging would find a
566 +"newer" build a minute later and replace the firmware being debugged. `POST
567 +/ota` still pulls the published build on demand, which is the right way for
568 +that to happen on a desk.
569 +
570 +`cfg.toml` spells "use the default" as `ota_interval_mins = -1`, the same way
571 +the pins do; an explicit `0` or `360` there wins.
572 +
560 573 `indicator_pin` defaults to **-1 (disabled)**, so boards without one are
561 574 unaffected. Pins 12/13 (USB D-/D+) and the strand pins are rejected with a
562 575 logged warning rather than a boot failure — a bad pin number should not brick a
modified README.md
+4 −2
@@ -98,7 +98,7 @@ pin_0 = 4 # strand 0 -> udp_start (-1 = default GPIO5)
98 98 pin_1 = 5 # strand 1 -> udp_start + 1 (-1 = default GPIO6)
99 99 indicator_pin = 8 # -1 (default) disables the status LED
100 100 ota_url = "" # "" = the forge manifest (see OTA below)
101 −ota_interval_mins = 360 # 0 = never poll; POST /ota still works
101 +ota_interval_mins = -1 # -1 = default (0 for local builds, 360 for CI ones)
102 102 ```
103 103
104 104 The section name must match the crate name, so it differs from the C3 project's
@@ -145,7 +145,9 @@ manifest and installs anything that differs from what it is running:
145 145 https://gg.neiam.co/pkg/raw/neiam/lambent-flex-c6/manifest.json
146 146 ```
147 147
148 −Default is every 6 hours; `POST /ota` forces a check immediately. `GET /`
148 +Every 6 hours for a CI build; **a locally flashed one never polls on its own**,
149 +so `cargo run --release` is not quietly undone a minute later by the forge's
150 +build. `POST /ota` forces a check either way. `GET /`
149 151 reports `version`, `ota_slot`, `ota_state`, and whatever the last check found.
150 152
151 153 ```console
modified src/main.rs
+14 −7
@@ -143,9 +143,12 @@ pub struct Config {
143 143 /// [`ota::DEFAULT_MANIFEST_URL`].
144 144 #[default("")]
145 145 ota_url: &'static str,
146 − /// Minutes between update checks; `0` polls never (`POST /ota` still works).
147 − #[default(360)]
148 − ota_interval_mins: u32,
146 + /// Minutes between update checks. `0` polls never (`POST /ota` still
147 + /// works), `-1` takes the built-in default — which is itself `0` for a
148 + /// build CI did not stamp, so a locally flashed board does not replace
149 + /// itself with the forge's build a minute after you flash it.
150 + #[default(- 1)]
151 + ota_interval_mins: i32,
149 152 }
150 153
151 154 /// Runtime config, persisted to NVS by the provisioning portal.
@@ -211,7 +214,7 @@ fn default_ota_url() -> String {
211 214 }
212 215
213 216 fn default_ota_interval_mins() -> u32 {
214 − ota::DEFAULT_INTERVAL_MINS
217 + ota::default_interval_mins()
215 218 }
216 219
217 220 impl Default for DeviceConfig {
@@ -224,7 +227,7 @@ impl Default for DeviceConfig {
224 227 pin_0: DEFAULT_STRAND_PINS[0],
225 228 pin_1: DEFAULT_STRAND_PINS[1],
226 229 ota_url: default_ota_url(),
227 − ota_interval_mins: ota::DEFAULT_INTERVAL_MINS,
230 + ota_interval_mins: ota::default_interval_mins(),
228 231 }
229 232 }
230 233 }
@@ -239,7 +242,7 @@ impl PortalConfig for DeviceConfig {
239 242 pin_0: form.parsed_or("pin_0", DEFAULT_STRAND_PINS[0]),
240 243 pin_1: form.parsed_or("pin_1", DEFAULT_STRAND_PINS[1]),
241 244 ota_url: default_ota_url(),
242 − ota_interval_mins: ota::DEFAULT_INTERVAL_MINS,
245 + ota_interval_mins: ota::default_interval_mins(),
243 246 }
244 247 }
245 248
@@ -276,7 +279,11 @@ fn cfg_toml_seed() -> Option<DeviceConfig> {
276 279 } else {
277 280 app_config.ota_url.to_string()
278 281 },
279 − ota_interval_mins: app_config.ota_interval_mins,
282 + ota_interval_mins: if app_config.ota_interval_mins < 0 {
283 + ota::default_interval_mins()
284 + } else {
285 + app_config.ota_interval_mins as u32
286 + },
280 287 })
281 288 }
282 289
modified src/ota.rs
+34 −9
@@ -65,9 +65,28 @@ use serde::Deserialize;
65 65 pub const DEFAULT_MANIFEST_URL: &str =
66 66 "https://gg.neiam.co/pkg/raw/neiam/lambent-flex-c6/manifest.json";
67 67
68 −/// How often to look, in minutes, when nothing is configured.
68 +/// How often a published build looks for a newer one, in minutes.
69 69 pub const DEFAULT_INTERVAL_MINS: u32 = 360;
70 70
71 +/// The polling interval to use when the config does not name one.
72 +///
73 +/// **Zero for a build CI did not stamp**, i.e. one flashed over USB from a
74 +/// laptop. Otherwise the two halves of this system fight: a local build's
75 +/// version is the bare `Cargo.toml` one, which by design differs from anything
76 +/// the forge has published, so a board flashed for debugging would notice a
77 +/// "newer" build a minute later and replace the very firmware being debugged.
78 +/// That is right for a deployed device and precisely wrong for one on a desk.
79 +///
80 +/// `POST /ota` still works either way, so pulling the published build onto a
81 +/// dev board stays one request — deliberate rather than automatic.
82 +pub fn default_interval_mins() -> u32 {
83 + if option_env!("LAMBENT_FLEX_VERSION").is_some() {
84 + DEFAULT_INTERVAL_MINS
85 + } else {
86 + 0
87 + }
88 +}
89 +
71 90 /// The file within a published version that is this firmware.
72 91 ///
73 92 /// A version may carry other files (the bootloader and partition table are
@@ -79,6 +98,10 @@ const ARTIFACT: &str = "lambent-flex-c6.bin";
79 98 /// buffer rather than this is what sizes the transfer.
80 99 const CHUNK: usize = 4096;
81 100
101 +/// "Block until somebody pokes" — a year, which keeps the timeout arm of
102 +/// `recv_timeout` rather than needing a second code path for `recv`.
103 +const NEVER: Duration = Duration::from_secs(365 * 24 * 60 * 60);
104 +
82 105 /// Refuse a manifest that is obviously not one. Ours is a few hundred bytes
83 106 /// per version; 64KB is a fleet's worth of history and still nothing.
84 107 const MAX_MANIFEST: usize = 64 * 1024;
@@ -407,7 +430,15 @@ pub fn spawn(manifest_url: String, interval: Duration) -> Result<Handle> {
407 430 // Let the device finish coming up before competing with it for
408 431 // heap and airtime — and, on a first boot after an update, before
409 432 // anything has decided whether this image works.
410 − let mut wait = Duration::from_secs(60);
433 + //
434 + // With polling off, that first check is off too: `0` has to mean
435 + // "only when asked", or a board configured never to poll would
436 + // still update itself once, sixty seconds after every boot.
437 + let mut wait = if interval.is_zero() {
438 + NEVER
439 + } else {
440 + Duration::from_secs(60)
441 + };
411 442
412 443 loop {
413 444 match rx.recv_timeout(wait) {
@@ -420,13 +451,7 @@ pub fn spawn(manifest_url: String, interval: Duration) -> Result<Handle> {
420 451
421 452 run_once(&updater, &thread_report);
422 453
423 − wait = if interval.is_zero() {
424 − // Nothing scheduled: block until somebody pokes. A year is
425 − // "forever" without giving up the timeout arm.
426 − Duration::from_secs(365 * 24 * 60 * 60)
427 − } else {
428 − interval
429 − };
454 + wait = if interval.is_zero() { NEVER } else { interval };
430 455 }
431 456 })
432 457 .context("spawning the OTA thread")?;

Parents: 2131e3c