33.2 KiB · text History b76c527
mod led;
mod ota;

use std::any::Any;
use std::net::{SocketAddr, UdpSocket};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::sleep;
use std::time::Duration;
use anyhow::{Error, Result};
use esp_idf_svc::{
    eventloop::EspSystemEventLoop,
    hal::{
        i2c::{I2cConfig, I2cDriver},
        // esp-idf-hal 0.46 dropped `prelude`; it re-exported these two.
        peripherals::*,
        units::*,
    },
    http::server::{EspHttpServer},
};
use esp_idf_svc::hal::peripherals;
use esp_idf_svc::hal::delay::FreeRtos;
use esp_idf_svc::hal::gpio::{AnyOutputPin, PinDriver};
use esp_idf_svc::hal::reset;
use esp_idf_svc::hal::rmt::{CHANNEL0, CHANNEL1, CHANNEL2, CHANNEL3, RmtChannel};
use esp_idf_svc::http::Method;
use esp_idf_svc::io::{EspIOError, Write as _};
use esp_idf_svc::nvs::EspDefaultNvsPartition;
// `esp!` / `esp_wifi_set_ps` / `wifi_ps_type_t_WIFI_PS_NONE` moved with the
// power-save call into neiam_portal::wifi::disable_power_save.
use esp_idf_svc::sys::{esp_read_mac, uint};
use log::info;
use neiam_portal::config::PortalConfig;
use neiam_portal::{assets, provision, store::ConfigStore, wifi, Form, Pages, StatusServer, WifiConfig};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::led::indicator::Ws2812BitBang;
use crate::led::ws2812::WS2812RMT;


// Dead code, kept from the C3 tree. The channel types gained a lifetime in
// esp-idf-hal 0.46.
pub struct RMT<'a> {
    pub channel0: CHANNEL0<'a>,
    pub channel1: CHANNEL1<'a>,
    pub channel2: CHANNEL2<'a>,
    pub channel3: CHANNEL3<'a>,
}

/// SoftAP name while provisioning, e.g. `flex-setup-0a1b2c`.
const SETUP_SSID_PREFIX: &str = "flex-setup";

/// DHCP hostname prefix, e.g. `lambent-flex-0a1b2c`.
///
/// Without this every unit reports ESP-IDF's `CONFIG_LWIP_LOCAL_HOSTNAME`
/// default, `espressif`, so a router's client list shows a pile of identically
/// named devices. Deliberately *not* `lambent-flex-c6`: the C3 and C6 builds
/// are the same device as far as anything on the network cares, and the id
/// suffix already distinguishes units.
const HOSTNAME_PREFIX: &str = "lambent-flex";

/// After this many consecutive failed WiFi bring-ups, fall back to the portal
/// instead of rebooting forever.
///
/// Deliberately patient. This counter exists for credentials that are wrong,
/// and a deployed device meets far more outages that are not: a router
/// rebooting, an AP dropping off, a channel change. Falling back too eagerly
/// puts a device that would have recovered on its own into SoftAP mode, where
/// it stays -- off the network and waiting for somebody to walk over to it --
/// which is a much worse failure than a few more minutes of retrying.
///
/// With `WIFI_RETRY_DELAY_MS` between attempts, ten of them span roughly five
/// and a half minutes before the portal comes up.
///
/// The strands moved to GPIO5/GPIO6, so GPIO9 (BOOT on the devkit) is no longer
/// driving a strand and the physical hold-BOOT escape hatch is available again.
/// Nothing in this firmware reads it yet, so this counter is still the only
/// implemented way back into provisioning.
const MAX_BOOT_FAILS: u8 = 10;

/// How long to wait before rebooting after a failed WiFi bring-up.
///
/// Not politeness -- spacing. A join that times out takes ~18s, so without a
/// wait the whole of `MAX_BOOT_FAILS` burns through in a few minutes and the
/// device can drop into provisioning while the AP is still coming back from a
/// power cut. Together the two constants put the fallback about five and a
/// half minutes out, which covers a router reboot comfortably.
const WIFI_RETRY_DELAY_MS: u32 = 15_000;

const DEFAULT_UDP_START: u16 = 7777;
const DEFAULT_PORT_COUNT: u16 = 2;

/// GPIO for the single status LED, or `-1` to disable it.
///
/// Defaults off so a board without one behaves exactly as before. On the
/// C6-DevKitC-1 the on-board addressable LED is GPIO8. It is bit-banged rather
/// than driven by RMT — see `led::indicator` for why there is no channel left.
const DEFAULT_INDICATOR_PIN: i8 = -1;

/// Fallback strand pins, used when `pin_0`/`pin_1` are unset (`-1`).
///
/// Off GPIO8/GPIO9 (the inherited C3 pinout) so GPIO8 is free for the on-board
/// indicator, and GPIO9 — BOOT on the devkit — is free entirely.
const DEFAULT_STRAND_PINS: [i8; 2] = [5, 6];

/// Sentinel meaning "no poke pending" in the shared indicator slot.
const NO_POKE: u32 = u32::MAX;

/// Status code for `GET /`. The packet sender matches on this specific value,
/// so it is deliberately not 200. 218 ("This Is Fine") is unofficial but
/// unreserved, which is why it survives proxies without being mistaken for a
/// real success/error class.
const STATUS_ROUTE_CODE: u16 = 218;

/// Compile-time credentials from `cfg.toml`, section `[lambent-flex-c6]`.
///
/// Enabled by the default `cfg-toml` feature. When this carries a non-empty
/// SSID it seeds an unprovisioned device, so the captive portal never appears
/// — the pre-portal workflow, unchanged. Build with `--no-default-features
/// --features std,esp-idf-svc/native` to drop it and always use the portal.
#[cfg(feature = "cfg-toml")]
#[toml_cfg::toml_config]
pub struct Config {
    #[default("")]
    wifi_ssid: &'static str,
    #[default("")]
    wifi_psk: &'static str,
    #[default(2)]
    port_count: u16,
    #[default(7777)]
    udp_start: u16,
    // #[default(vec![])]
    // pins: Vec<u16>,
    // Only two strand pins: the C6 has two RMT TX channels, so pin_2/pin_3
    // from the C3 tree could never have been used here.
    #[default(- 1)]
    pin_0: i8,
    #[default(- 1)]
    pin_1: i8,
    #[default(- 1)]
    indicator_pin: i8,
    /// Manifest to poll for updates. Empty falls back to
    /// [`ota::DEFAULT_MANIFEST_URL`].
    #[default("")]
    ota_url: &'static str,
    /// Minutes between update checks. `0` polls never (`POST /ota` still
    /// works), `-1` takes the built-in default — which is itself `0` for a
    /// build CI did not stamp, so a locally flashed board does not replace
    /// itself with the forge's build a minute after you flash it.
    #[default(- 1)]
    ota_interval_mins: i32,
}

/// Runtime config, persisted to NVS by the provisioning portal.
///
/// `#[serde(flatten)]` keeps the stored blob flat (`{"wifi_ssid":…,
/// "udp_start":…}`), which is the shape `neiam-portal` documents.
///
/// `udp_start` / `port_count` are read from the portal form if present, but
/// the shipped wifi-only page has no inputs for them, so through the portal
/// they take their defaults. Via `cfg.toml` they are settable as before.
#[derive(Serialize, Deserialize, Clone, Debug)]
struct DeviceConfig {
    #[serde(flatten)]
    wifi: WifiConfig,
    #[serde(default = "default_udp_start")]
    udp_start: u16,
    #[serde(default = "default_port_count")]
    port_count: u16,
    /// GPIO of the status LED, `-1` to disable. Not on the shipped portal page
    /// (like `udp_start`/`port_count`), so in practice it comes from `cfg.toml`
    /// or a hand-written NVS blob.
    #[serde(default = "default_indicator_pin")]
    indicator_pin: i8,
    /// Strand data pins. `-1` falls back to `DEFAULT_STRAND_PINS`, so configs
    /// predating these fields keep the old hardcoded pinout.
    #[serde(default = "default_pin_0")]
    pin_0: i8,
    #[serde(default = "default_pin_1")]
    pin_1: i8,
    /// Manifest polled for updates. Not on the portal page either, so in
    /// practice this is the compiled-in default — which is the point: a device
    /// provisioned before OTA existed has no such key in its stored blob,
    /// serde fills this in, and it starts updating without an NVS erase.
    #[serde(default = "default_ota_url")]
    ota_url: String,
    /// Minutes between checks. `0` disables polling.
    #[serde(default = "default_ota_interval_mins")]
    ota_interval_mins: u32,
}

fn default_udp_start() -> u16 {
    DEFAULT_UDP_START
}

fn default_port_count() -> u16 {
    DEFAULT_PORT_COUNT
}

fn default_indicator_pin() -> i8 {
    DEFAULT_INDICATOR_PIN
}

fn default_pin_0() -> i8 {
    DEFAULT_STRAND_PINS[0]
}

fn default_pin_1() -> i8 {
    DEFAULT_STRAND_PINS[1]
}

fn default_ota_url() -> String {
    ota::DEFAULT_MANIFEST_URL.to_string()
}

fn default_ota_interval_mins() -> u32 {
    ota::default_interval_mins()
}

impl Default for DeviceConfig {
    fn default() -> Self {
        Self {
            wifi: WifiConfig::default(),
            udp_start: DEFAULT_UDP_START,
            port_count: DEFAULT_PORT_COUNT,
            indicator_pin: DEFAULT_INDICATOR_PIN,
            pin_0: DEFAULT_STRAND_PINS[0],
            pin_1: DEFAULT_STRAND_PINS[1],
            ota_url: default_ota_url(),
            ota_interval_mins: ota::default_interval_mins(),
        }
    }
}

impl PortalConfig for DeviceConfig {
    fn from_form(form: &Form) -> Self {
        Self {
            wifi: WifiConfig::from_form(form),
            udp_start: form.parsed_or("udp_start", DEFAULT_UDP_START),
            port_count: form.parsed_or("port_count", DEFAULT_PORT_COUNT),
            indicator_pin: form.parsed_or("indicator_pin", DEFAULT_INDICATOR_PIN),
            pin_0: form.parsed_or("pin_0", DEFAULT_STRAND_PINS[0]),
            pin_1: form.parsed_or("pin_1", DEFAULT_STRAND_PINS[1]),
            ota_url: default_ota_url(),
            ota_interval_mins: ota::default_interval_mins(),
        }
    }

    fn wifi(&self) -> &WifiConfig {
        &self.wifi
    }
}

/// Seed an unprovisioned device from `cfg.toml`, when the feature is on and
/// the file actually has an SSID.
#[cfg(feature = "cfg-toml")]
fn cfg_toml_seed() -> Option<DeviceConfig> {
    // The constant `CONFIG` is auto-generated by `toml_config`.
    let app_config = CONFIG;
    if app_config.wifi_ssid.is_empty() {
        return None;
    }
    println!("pins:{:?},{:?} indicator:{:?}", app_config.pin_0, app_config.pin_1, app_config.indicator_pin);
    Some(DeviceConfig {
        wifi: WifiConfig {
            wifi_ssid: app_config.wifi_ssid.to_string(),
            wifi_pass: app_config.wifi_psk.to_string(),
            // This board is seeded from a single-network `cfg.toml`; the extra
            // networks neiam-portal now carries come from its form instead.
            ..Default::default()
        },
        udp_start: app_config.udp_start,
        port_count: app_config.port_count,
        indicator_pin: app_config.indicator_pin,
        pin_0: or_default(app_config.pin_0, DEFAULT_STRAND_PINS[0]),
        pin_1: or_default(app_config.pin_1, DEFAULT_STRAND_PINS[1]),
        ota_url: if app_config.ota_url.is_empty() {
            default_ota_url()
        } else {
            app_config.ota_url.to_string()
        },
        ota_interval_mins: if app_config.ota_interval_mins < 0 {
            ota::default_interval_mins()
        } else {
            app_config.ota_interval_mins as u32
        },
    })
}

/// `cfg.toml` uses `-1` for "not set"; map that onto the compiled-in default.
#[cfg(feature = "cfg-toml")]
fn or_default(configured: i8, fallback: i8) -> i8 {
    if configured < 0 { fallback } else { configured }
}

#[cfg(not(feature = "cfg-toml"))]
fn cfg_toml_seed() -> Option<DeviceConfig> {
    None
}

fn callback(data: &[u8], source_address: SocketAddr, handler_id: usize, leds: &mut WS2812RMT) {
    // Whole stream, one pixel per RGB triple. This used to slice `&data[0..3]`
    // and drive a single LED, so a 300-byte stream lit only pixel 0 — and a
    // packet shorter than 3 bytes panicked the handler thread.
    if data.len() < 3 {
        eprintln!(
            "Handler {handler_id}: {} byte packet from {source_address} is too short for RGB",
            data.len()
        );
        return;
    }

    if data.len() % 3 != 0 {
        eprintln!(
            "Handler {handler_id}: {} bytes from {source_address} is not a whole number of \
             pixels; ignoring the trailing {} byte(s)",
            data.len(),
            data.len() % 3
        );
    }

    if let Err(err) = leds.set_pixels(data) {
        eprintln!("Handler {handler_id}: set_pixels failed: {err}");
    }
}

struct UdpHandler {
    id: usize,
    socket: UdpSocket,
    buffer: [u8; 1024],
}

impl UdpHandler {
    fn new(id: usize, port: u16) -> Result<Self, std::io::Error> {
        let bind_address = format!("0.0.0.0:{}", port);
        let socket = UdpSocket::bind(bind_address)?;
        let buffer = [0; 1024];

        Ok(UdpHandler { id, socket, buffer })
    }

    fn receive(&mut self, leds: &mut WS2812RMT) {
        loop {
            match self.socket.recv_from(&mut self.buffer) {
                Ok((size, source_address)) => {
                    let data = &self.buffer[..size];
                    callback(data, source_address, self.id, leds);
                }
                Err(err) => eprintln!("Error receiving packet on handler {}: {}", self.id, err),
            }
        }
    }
}

/// Build the indicator if one is configured, refusing pins that are spoken for.
///
/// Returns `Ok(None)` when disabled (`-1`). Errors are advisory: the caller logs
/// them and carries on without an indicator rather than refusing to boot, since
/// a bad pin number should not brick a light controller.
fn build_indicator(indicator_pin: i8, strand_pins: &[i8]) -> Result<Option<Ws2812BitBang>> {
    if indicator_pin < 0 {
        return Ok(None);
    }
    let gpio = indicator_pin as i32;

    if strand_pins.contains(&indicator_pin) {
        return Err(Error::msg(format!(
            "indicator_pin {gpio} is already driving a strand"
        )));
    }
    if gpio == 12 || gpio == 13 {
        return Err(Error::msg(format!(
            "indicator_pin {gpio} is USB D-/D+ on the C6; using it would kill \
             the USB-Serial-JTAG console you flash and monitor over"
        )));
    }

    // SAFETY: `steal` requires this be the only live handle to the pin. The
    // strand pins are owned normally and rejected above; every other peripheral
    // this firmware touches is moved out of `Peripherals` by value, and nothing
    // else steals. This runs once, before the indicator thread starts.
    let pin = unsafe { AnyOutputPin::steal(gpio as u8) };
    Ok(Some(Ws2812BitBang::new(pin, gpio)?))
}

/// Receives RGB triples for the indicator on its own port.
///
/// Deliberately separate from the strand ports: the indicator is addressed
/// explicitly and no longer echoes strand traffic. Unlike the strand path this
/// length-checks the packet instead of slicing blind.
fn indicator_socket_loop(socket: UdpSocket, poke: Arc<AtomicU32>) -> ! {
    let mut buffer = [0u8; 64];
    loop {
        match socket.recv_from(&mut buffer) {
            Ok((size, source_address)) => match buffer[..size].get(0..3) {
                Some(rgb) => {
                    poke.store(
                        ((rgb[0] as u32) << 16) | ((rgb[1] as u32) << 8) | rgb[2] as u32,
                        Ordering::Relaxed,
                    );
                }
                None => eprintln!(
                    "indicator: {size} byte packet from {source_address} is too short for RGB"
                ),
            },
            Err(err) => eprintln!("indicator: error receiving packet: {err}"),
        }
    }
}

/// Owns the indicator and shows whatever colour last arrived on its port.
///
/// Lives on its own thread so the ~30us interrupt-off window inside
/// `Ws2812BitBang::set` never lands in the middle of packet handling.
fn indicator_loop(mut indicator: Ws2812BitBang, poke: Arc<AtomicU32>) -> ! {
    let mut lit = false;
    loop {
        match poke.swap(NO_POKE, Ordering::Relaxed) {
            NO_POKE => {
                // Quiet tick: drop the LED once, then idle cheaply.
                if lit {
                    indicator.off();
                    lit = false;
                }
                FreeRtos::delay_ms(20);
            }
            packed => {
                indicator.set(rgb::RGB8::new(
                    (packed >> 16) as u8,
                    (packed >> 8) as u8,
                    packed as u8,
                ));
                lit = true;
                // Minimum on-time, so a single packet is actually visible.
                // Sustained traffic just keeps re-arming it.
                FreeRtos::delay_ms(40);
            }
        }
    }
}

fn main() -> Result<(), Error> {
    // It is necessary to call this function once. Otherwise some patches to the runtime
    // implemented by esp-idf-sys might not link properly. See https://github.com/esp-rs/esp-idf-template/issues/71
    esp_idf_svc::sys::link_patches();

    // Bind the log crate to the ESP Logging facilities
    esp_idf_svc::log::EspLogger::initialize_default();

    // l'bits
    let peripherals = Peripherals::take()?;
    let sysloop = EspSystemEventLoop::take()?;
    let nvs = EspDefaultNvsPartition::take()?;

    // l'config: NVS is authoritative; cfg.toml only seeds an empty one.
    let store = ConfigStore::new(nvs);
    let cfg: DeviceConfig = store.load_or_seed(cfg_toml_seed());
    let id = neiam_portal::device_id();
    log::info!("lambent-flex-c6 booting, device id = {id}");

    // l'portal: no usable config, or too many failed joins in a row (the
    // bad-credentials escape hatch). With cfg.toml creds present this is
    // never reached.
    let fail_count = store.boot_fail_count();
    let fallback = cfg.is_provisioned() && fail_count >= MAX_BOOT_FAILS;

    if !cfg.is_provisioned() || fallback {
        if fallback {
            log::warn!("{fail_count} consecutive WiFi failures: falling back to provisioning");
        } else {
            log::info!("no stored config: entering provisioning");
        }
        store.clear_boot_fail_count();
        let ssid = neiam_portal::setup_ssid(SETUP_SSID_PREFIX, &id);
        // Never returns: reboots once a config is saved.
        provision::run::<DeviceConfig, _>(peripherals.modem, sysloop, store, &ssid, Pages::default())?;
        return Ok(());
    }

    // Count this attempt before trying; cleared the moment WiFi is up. If
    // bring-up hangs, crashes, or errors and we reboot, the increment persists
    // and eventually trips the fallback above.
    let attempt = fail_count.saturating_add(1);
    store.set_boot_fail_count(attempt);
    log::info!("config present (WiFi attempt {attempt}/{MAX_BOOT_FAILS}): connecting");

    // l'wifi. `connect_sta_blocking` sets the ClientConfiguration (heapless 0.9
    // dropped `From<&str>` for its fixed-capacity String, so SSID/PSK are
    // fallible in there), starts, connects, and waits for the netif. It blocks
    // on events rather than polling, so the old manual `while !is_connected()`
    // spin — which starved the IDLE task and tripped `task_wdt: - IDLE (CPU 0)`
    // without an explicit sleep — is gone along with that hazard.
    // `connect_sta_blocking` consumes the loop, and `keep_connected` below
    // needs it afterwards.
    let sysloop_for_events = sysloop.clone();
    // The hostname has to go in here rather than after the join: lwip puts it
    // in the DHCP DISCOVER the moment the link comes up, so setting it later
    // leaves the router showing the old name until the lease renews.
    let hostname = format!("{HOSTNAME_PREFIX}-{id}");
    let mut wifi_driver = match wifi::connect_sta_blocking_named(
        peripherals.modem,
        sysloop,
        store.partition().clone(),
        cfg.wifi(),
        Some(&hostname),
    ) {
        Ok(wifi) => wifi,
        Err(e) => {
            // Deliberately not `?`. Returning from `main` here leaves the
            // device with nothing to do and no way back -- `main_task:
            // Returned from app_main()` and then silence until somebody
            // power-cycles it. It also meant the attempt counter above could
            // never reach `MAX_BOOT_FAILS`, so the escape hatch it exists for
            // could not fire either: every boot was attempt 1 forever.
            //
            // A failed join has to end in a reboot for any of that to work.
            log::error!(
                "could not join '{}' (attempt {attempt}/{MAX_BOOT_FAILS}): {e:#}",
                cfg.wifi.wifi_ssid
            );
            FreeRtos::delay_ms(WIFI_RETRY_DELAY_MS);
            reset::restart();
        }
    };
    // The default (WIFI_PS_MIN_MODEM) had the radio asleep ~50% of the time and
    // produced `wifi:bcn_timeout` -> disassociation against an 11ax AP even at
    // RSSI -40. esp-idf-svc 0.52 has no safe wrapper, hence the sys call. Must
    // come after start(). Costs idle current — revisit if this ever runs on battery.
    wifi::disable_power_save()?;

    // `connect_sta_blocking` associates once and nothing reconnects if the AP
    // later drops us — not esp-idf-svc, not ESP-IDF's station config. On a
    // device that only listens on a socket that failure is silent: the UDP
    // handlers below simply stop receiving, the strands hold their last frame,
    // and nothing in the log says why. Held for the life of the program;
    // dropping the subscription unregisters the handler.
    //
    // Must come after the connect above, which waits on the same events.
    let _reconnect = wifi::keep_connected(&sysloop_for_events)?;

    store.clear_boot_fail_count();

    // l'info and udp socket building
    let mac = wifi_driver.wifi().sta_netif().get_mac()?;
    let formatted_mac = format!("{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
    let socket_ints = (0..cfg.port_count).map(|i| cfg.udp_start + i).collect::<Vec<u16>>();
    let mut udp_handlers: Vec<UdpHandler> = Vec::new();
    for (id, &port) in socket_ints.iter().enumerate() {
        match UdpHandler::new(id, port) {
            Ok(udp_handler) => udp_handlers.push(udp_handler),
            Err(err) => eprintln!("Failed to bind UDP handler on port {}: {}", port, err),
        }
    }

    // Strand pins come from config now. Two RMT TX channels is the hardware
    // ceiling on the C6, so this is fixed at two however `port_count` is set.
    let strand_pins: [i8; 2] = [cfg.pin_0, cfg.pin_1];
    if strand_pins[0] == strand_pins[1] {
        return Err(Error::msg(format!(
            "pin_0 and pin_1 are both gpio{}; each strand needs its own pin",
            strand_pins[0]
        )));
    }
    for pin in strand_pins {
        if !(0..=30).contains(&pin) {
            return Err(Error::msg(format!("strand pin {pin} out of range for the C6 (0-30)")));
        }
        if pin == 12 || pin == 13 {
            return Err(Error::msg(format!(
                "strand pin {pin} is USB D-/D+ on the C6; that would kill the \
                 USB-Serial-JTAG console you flash and monitor over"
            )));
        }
    }

    // SAFETY: `steal` requires these be the only live handles to the pins. Both
    // are validated distinct and in range above, the indicator rejects any pin
    // in `strand_pins`, and nothing else in this firmware steals a GPIO.
    let mut ledzz: Vec<WS2812RMT> = vec![
        WS2812RMT::new(
            unsafe { AnyOutputPin::steal(strand_pins[0] as u8) },
            peripherals.rmt.channel0,
        )?,
        WS2812RMT::new(
            unsafe { AnyOutputPin::steal(strand_pins[1] as u8) },
            peripherals.rmt.channel1,
        )?,
    ];
    println!("{:?}", ledzz.len());

    // Shared slot the UDP handlers drop colours into for the indicator.
    let poke = Arc::new(AtomicU32::new(NO_POKE));
    // Reports the *working* indicator, not the configured one: a pin that was
    // rejected or failed to calibrate leaves this `None`, so `/` never claims an
    // indicator that is not actually being driven.
    // Sits just *below* the strand ports, so the strand block stays contiguous
    // from `udp_start` upward and growing `port_count` never collides with it.
    // NOT reachable by raising `port_count` anyway: that would build a third
    // UdpHandler while only two WS2812RMT exist, and `ledzz.pop().unwrap()`
    // below would panic.
    let indicator_port = cfg.udp_start.checked_sub(1);

    let mut active_indicator_port: Option<u16> = None;
    let active_indicator: Option<i8> = match build_indicator(cfg.indicator_pin, &strand_pins) {
        Ok(Some(indicator)) => {
            log::info!("indicator: bit-banged WS2812 on gpio{}", cfg.indicator_pin);
            let poke_rx = poke.clone();
            std::thread::Builder::new()
                .stack_size(3 * 1024)
                .spawn(move || indicator_loop(indicator, poke_rx))
                .expect("Failed to spawn indicator thread");

            match indicator_port {
                None => eprintln!(
                    "indicator: udp_start is 0, leaving no room for the \
                     indicator port below it; indicator will stay dark"
                ),
                Some(port) => match UdpSocket::bind(format!("0.0.0.0:{port}")) {
                    Ok(socket) => {
                        println!("Indicator listening on port {port}");
                        active_indicator_port = Some(port);
                        let poke_tx = poke.clone();
                        std::thread::Builder::new()
                            .stack_size(3 * 1024)
                            .spawn(move || indicator_socket_loop(socket, poke_tx))
                            .expect("Failed to spawn indicator socket thread");
                    }
                    // The LED still works, it just cannot be addressed. Worth a
                    // loud line rather than a silent half-feature.
                    Err(err) => eprintln!(
                        "indicator: failed to bind port {port}: {err}; \
                         indicator will stay dark"
                    ),
                },
            }

            Some(cfg.indicator_pin)
        }
        Ok(None) => {
            log::info!("indicator: disabled (indicator_pin = -1)");
            None
        }
        Err(err) => {
            log::warn!("indicator: disabled, {err}");
            None
        }
    };

    // Start receiving loop for each handler.
    //
    // Zipped in order, so pin_0 serves the first port and pin_1 the second.
    // The C3 tree pops off the end instead, which reverses the mapping; that
    // was deliberate here, not an accident. Zip also means a `port_count`
    // above 2 drops the surplus with a warning rather than panicking on
    // `pop().unwrap()` as it used to.
    if udp_handlers.len() > ledzz.len() {
        eprintln!(
            "port_count asks for {} ports but only {} strands exist; ignoring the surplus",
            udp_handlers.len(),
            ledzz.len()
        );
    }
    for (mut handler, mut ws2812) in udp_handlers.into_iter().zip(ledzz.into_iter()) {
        let port = handler.socket.local_addr().unwrap().port();
        println!("Handler {} listening on port {}", handler.id, port);

        // Spawn a separate thread for each handler
        std::thread::Builder::new()
            .stack_size(4 * 1024) // todo: adjust the size
            .spawn(move || handler.receive(&mut ws2812))
            .expect("Failed to spawn thread");
    }

    // Published by the status server; refreshed by the supervision loop below.
    let ip_info = Arc::new(Mutex::new(String::from("(none)")));

    // `/` stays the JSON endpoint it has always been; the HTML status page from
    // neiam-portal is served alongside it at /status, which is why this uses
    // `bare()` (shell assets only, nothing claiming `/`).
    // esp-idf-svc 0.52 requires handlers to be 'static, so the captures move in.
    // The update thread. Started before the server so `POST /ota` always has
    // something to poke; it sleeps a minute before its first check, so it is
    // not competing with bring-up for heap or airtime.
    let updates = ota::spawn(
        cfg.ota_url.clone(),
        Duration::from_secs(u64::from(cfg.ota_interval_mins) * 60),
    )?;
    // Read once: the label cannot change while this image is running, and the
    // state changes exactly once, at the validate call below.
    let (ota_slot, initial_state) = ota::running_slot_info();
    let ota_state = Arc::new(Mutex::new(initial_state));
    log::info!(
        "ota: running {} from {} ({}), polling {} every {} min",
        ota::running_version(),
        ota_slot,
        ota_state.lock().unwrap_or_else(|e| e.into_inner()),
        cfg.ota_url,
        cfg.ota_interval_mins
    );

    let status_ip = ip_info.clone();
    let status_ports = socket_ints.clone();
    let status_hostname = hostname.clone();
    let status_updates = updates.clone();
    let status_ota_slot = ota_slot.clone();
    let status_ota_state = ota_state.clone();
    let ota_updates = updates.clone();
    let mut status = StatusServer::bare()?.asset("/status", assets::STATUS_HTML)?;

    // Registered by hand rather than via `StatusServer::json`, which hardcodes
    // 200 (neiam-portal status.rs:58). Going through `server_mut()` keeps the
    // odd status code local to this firmware instead of changing a helper that
    // nfc-522 also uses.
    status
        .server_mut()
        .fn_handler("/", Method::Get, move |req| {
            let ip = status_ip.lock().unwrap_or_else(|e| e.into_inner()).clone();
            let report = status_updates.report();
            let resp = json!({
                "id": 0,
                "json":"baby",
                "udp_sox": status_ports,
                "mac_addr": formatted_mac,
                "hostname": status_hostname,
                "ip": ip,
                "version": ota::running_version(),
                // `unverified` means this image is on probation and has not
                // reached the validate call below yet; see src/ota.rs.
                "ota_state": *status_ota_state.lock().unwrap_or_else(|e| e.into_inner()),
                "ota_slot": status_ota_slot,
                // What the last check found, `null` when it found nothing.
                "ota_available": report.available,
                "ota_error": report.last_error,
                // Pin number when one is live, `null` when there is none.
                "indicator_pin": active_indicator,
                "has_indicator": active_indicator.is_some(),
                // Its own port, separate from `udp_sox`. `null` if unbound.
                "indicator_port": active_indicator_port
            });
            req.into_response(
                STATUS_ROUTE_CODE,
                Some("This Is Fine"),
                &[("Content-Type", "application/json")],
            )?
            .write_all(resp.to_string().as_bytes())?;
            Ok::<(), EspIOError>(())
        })?;

    // Force a check now, for a device that should not wait out its interval.
    // Deliberately not a GET: it has an effect, and a device that reboots
    // itself because something prefetched a link would be its own bug.
    //
    // Returns immediately — the check and any download happen on the OTA
    // thread, and a successful update ends in a reboot, so this response is
    // the last thing this image says.
    status
        .server_mut()
        .fn_handler("/ota", Method::Post, move |req| {
            let queued = ota_updates.poke();
            let report = ota_updates.report();
            let resp = json!({
                "queued": queued,
                "running": ota::running_version(),
                "available": report.available,
                "last_error": report.last_error,
                "checked": report.checked,
            });
            req.into_response(
                if queued { 202 } else { 503 },
                None,
                &[("Content-Type", "application/json")],
            )?
            .write_all(resp.to_string().as_bytes())?;
            Ok::<(), EspIOError>(())
        })?;

    let _server = status.build();

    // Everything the device exists to do is now running: wifi is up, the UDP
    // handlers are bound and the HTTP server is serving. That is the bar for
    // calling a freshly installed image good — reached here and nowhere
    // earlier, because with rollback enabled the bootloader puts the previous
    // image back if this call is never made.
    match ota::mark_running_slot_valid() {
        Ok(state) => *ota_state.lock().unwrap_or_else(|e| e.into_inner()) = state,
        // Not fatal: a device that has not been migrated onto the two-slot
        // partition table has no otadata to mark, and refusing to run over
        // that would strand exactly the devices that need an update.
        Err(err) => log::warn!("ota: could not mark this image valid: {err:#}"),
    }

    log::info!("Hello, world!");
    loop {
        // Nothing else in the firmware watches the association. `connect()` is
        // called once at startup, so without this a single disassociation left
        // the device off the network permanently until a power cycle.
        if wifi_driver.is_connected()? {
            let info = wifi_driver.wifi().sta_netif().get_ip_info().unwrap();
            println!("IP info: {:?}", info);
            *ip_info.lock().unwrap_or_else(|e| e.into_inner()) = info.ip.to_string();
        } else {
            eprintln!("wifi disconnected, reconnecting");
            *ip_info.lock().unwrap_or_else(|e| e.into_inner()) = String::from("(disconnected)");
            if let Err(err) = wifi_driver.connect() {
                eprintln!("reconnect failed: {err}");
            }
        }
        sleep(Duration::new(10, 0));
    }
}

fn setup_routes(server: &mut EspHttpServer) {}

fn templated(content: impl AsRef<str>) -> String {
    format!(
        r#"
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>esp-rs web server</title>
    </head>
    <body>
        {}
    </body>
</html>
"#,
        content.as_ref()
    )
}

fn index_html() -> String {
    templated("Hello from mcu!")
}