neiam
/lambent-flex-c6
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Don't let a locally flashed board replace itself a minute later
b76c527 · Gabriel Morell · 2026-09-16 19:19
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