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 ota_report = 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>(())
})?;
// The same report, without doing anything — because `/ota` is a URL
// people paste into a browser, and a bare GET against a POST-only handler
// is a 405 from ESP-IDF's server (httpd_uri.c: URI found, method not
// allowed), which reads like a broken device rather than like "use POST".
//
// A GET that triggered an update would be worse than unfriendly: anything
// that prefetches a link would reboot the device.
status
.server_mut()
.fn_handler("/ota", Method::Get, move |req| {
let report = ota_report.report();
let resp = json!({
"running": ota::running_version(),
"available": report.available,
"last_error": report.last_error,
"checked": report.checked,
// Said plainly, since the reason to be reading this is
// usually "why has nothing happened".
"hint": "POST to this URL to check for an update now",
});
req.into_response(200, None, &[("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!")
}
neiam
/lambent-flex-c6
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
34.4 KiB · text
History
b81a582