//! Network task: WiFi + MQTT, online/offline state machine, gatekeeper for //! button events. //! //! Two threads: //! - **state thread** owns WiFi + MQTT, processes `NetTaskMsg` from a single //! FreeRTOS queue. Routes connection-state changes (Connected/Disconnected) //! and outbound publishes (button events, state snapshots). //! - **forwarder thread** reads `OutboundEvent` from the rest of the firmware //! and re-emits as `NetTaskMsg::Outbound` so the state thread sees a //! single stream. //! //! The MQTT client is constructed with `new_cb`. The callback runs in the //! ESP-IDF MQTT task and forwards Connected/Disconnected to the state queue //! and routes inbound commands directly to `audio_tx` — no extra pump //! thread or borrowed-string copy required. //! //! All cross-thread channels are FreeRTOS queues (see `channels.rs`). //! `std::sync::mpsc` is broken on esp-idf-rs because of the //! `PTHREAD_MUTEX_INITIALIZER` mismatch between newlib (zeros) and ESP-IDF //! (`0xffffffff`); see channels.rs for the gory details. //! //! WiFi auto-reconnects via the default ESP-IDF behavior; the MQTT C client //! auto-reconnects on its own (default `reconnect_timeout` is non-zero), so //! we observe `Connected`/`Disconnected` events instead of running explicit //! retry loops. If the very first WiFi attempt fails, we retry every 60s. use crate::channels::{channel, Receiver, Sender}; use crate::discovery; use crate::events::{ AudioCommand, ButtonEvent, LedSignal, NetStatus, OutboundEvent, StateSnapshot, }; use crate::ota; use crate::secrets::CONFIG; use crate::state::snap_to_preset_index; use anyhow::{anyhow, Result}; use esp_idf_svc::eventloop::EspSystemEventLoop; use esp_idf_svc::hal::modem::Modem; use esp_idf_svc::mqtt::client::{ Details, EspMqttClient, EventPayload, LwtConfiguration, MqttClientConfiguration, QoS, }; use esp_idf_svc::nvs::{EspNvsPartition, NvsDefault}; use esp_idf_svc::wifi::{ BlockingWifi, ClientConfiguration, Configuration, EspWifi, WifiDeviceId, }; use log::{info, warn}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread::{Builder, JoinHandle}; use std::time::{Duration, Instant}; const WIFI_RETRY_INTERVAL: Duration = Duration::from_secs(60); const STATE_THREAD_STACK: usize = 16 * 1024; const FORWARDER_THREAD_STACK: usize = 4 * 1024; /// Spawn the network task. Takes ownership of the modem, an NVS partition /// clone, and the channel endpoints it'll need for the rest of its life. /// The MAC address is read inside the task and logged loudly so first-boot /// units show their identity in the serial monitor before any connect. pub fn spawn( modem: Modem, sys_loop: EspSystemEventLoop, nvs_partition: EspNvsPartition, sw_version: &'static str, audio_tx: Sender, led_tx: Sender, out_rx: Receiver, ) -> Result> { let handle = Builder::new() .name("network".into()) .stack_size(STATE_THREAD_STACK) .spawn(move || { if let Err(e) = run( modem, sys_loop, nvs_partition, sw_version, audio_tx, led_tx, out_rx, ) { warn!("network task exiting on error: {e:?}"); } })?; Ok(handle) } /// Unified message type for the network state thread. All variants are `Copy` /// so they fit in a FreeRTOS queue (which uses byte-copy semantics). #[derive(Debug, Clone, Copy)] enum NetTaskMsg { Connected, Disconnected, Outbound(OutboundEvent), /// New `latest_version` seen on the shared topic. Cached so that an /// install request later can build the download URL from it. LatestVersion(VersionBuf), /// HA published `install` to `cmd/update`. OtaInstall, /// OTA worker reporting download progress (0..=100). OtaProgress(u8), /// OTA worker finished (true = success, false = failure). On success /// the worker also calls `esp_restart` so we may never observe this /// variant for the success case; on failure it lets us repaint the /// LED and clear the in-progress state. OtaFinished(bool), } /// Stack-allocated, `Copy`-friendly version string. FreeRTOS queues copy by /// value, so we can't pass `String`/`heapless::String` (both are non-Copy). /// 31 bytes covers any reasonable semver, including pre-release tags. #[derive(Debug, Clone, Copy)] struct VersionBuf { bytes: [u8; 31], len: u8, } impl VersionBuf { fn from_bytes(b: &[u8]) -> Option { if b.is_empty() || b.len() > 31 { return None; } // Reject anything that isn't valid UTF-8; saves the as_str caller a // failure mode it can't recover from. std::str::from_utf8(b).ok()?; let mut bytes = [0u8; 31]; bytes[..b.len()].copy_from_slice(b); Some(Self { bytes, len: b.len() as u8, }) } fn as_str(&self) -> &str { // SAFETY: from_bytes verified UTF-8 at construction. unsafe { std::str::from_utf8_unchecked(&self.bytes[..self.len as usize]) } } } fn run( modem: Modem, sys_loop: EspSystemEventLoop, nvs_partition: EspNvsPartition, sw_version: &'static str, audio_tx: Sender, led_tx: Sender, out_rx: Receiver, ) -> Result<()> { info!("network task: starting; broker={}", CONFIG.mqtt_url); if CONFIG.wifi_ssid.is_empty() { warn!("cfg.toml: wifi_ssid is empty; network task exiting"); return Err(anyhow!("wifi_ssid empty")); } if CONFIG.wifi_password.is_empty() { warn!("cfg.toml: wifi_password is empty; network task exiting"); return Err(anyhow!("wifi_password empty")); } if CONFIG.mqtt_url.is_empty() { warn!("cfg.toml: mqtt_url is empty; network task exiting"); return Err(anyhow!("mqtt_url empty")); } let _ = led_tx.send(LedSignal::Net(NetStatus::Connecting)); let esp_wifi = EspWifi::new(modem, sys_loop.clone(), Some(nvs_partition)) .map_err(|e| anyhow!("EspWifi::new failed: {e}"))?; let mut wifi = BlockingWifi::wrap(esp_wifi, sys_loop) .map_err(|e| anyhow!("BlockingWifi::wrap failed: {e}"))?; let mac = wifi .wifi() .driver() .get_mac(WifiDeviceId::Sta) .map_err(|e| anyhow!("get_mac: {e}"))?; let mac_hex = format_mac(mac); let topic_prefix = format!("nightstand/{mac_hex}"); let avail_topic = format!("{topic_prefix}/available"); let state_topic = format!("{topic_prefix}/state"); let button_topic = format!("{topic_prefix}/button"); let cmd_filter = format!("{topic_prefix}/cmd/+"); let cmd_play_topic = format!("{topic_prefix}/cmd/play"); let cmd_volume_topic = format!("{topic_prefix}/cmd/volume"); let cmd_update_topic = format!("{topic_prefix}/cmd/update"); // HA's update entity reads this single JSON-state topic for installed // version, in_progress flag, and update_percentage. We keep the file- // path-style suffix `update/state` even though the discovery payload // calls it state_topic — clearer when subscribed via `mosquitto_sub`. let update_state_topic = format!("{topic_prefix}/update/state"); let client_id = format!("nightstand_{mac_hex}"); let hostname = format!("nightstand-{mac_hex}"); info!( "device MAC = {mac_hex} → topic prefix {topic_prefix}/, client_id {client_id}, broker {}", CONFIG.mqtt_url ); set_hostname(wifi.wifi().sta_netif(), &hostname); wifi.set_configuration(&Configuration::Client(ClientConfiguration { ssid: CONFIG .wifi_ssid .try_into() .map_err(|_| anyhow!("wifi_ssid too long for heapless::String<32>"))?, password: CONFIG .wifi_password .try_into() .map_err(|_| anyhow!("wifi_password too long for heapless::String<64>"))?, // Let the driver auto-detect WPA2/WPA3/etc. based on what the AP // advertises. Forcing WPA2Personal can hang association on WPA3 // and WPA2/WPA3-mixed networks. ..Default::default() }))?; connect_wifi_with_retry(&mut wifi); // Unified queue for the state thread. The MQTT callback pushes // Connected/Disconnected into it; the forwarder thread pushes Outbound. let (msg_tx, msg_rx) = channel::(32); // Spawn the outbound forwarder: pulls OutboundEvent from the outside // world and re-emits as NetTaskMsg::Outbound. let fwd_msg_tx = msg_tx.clone(); let _fwd_handle = Builder::new() .name("net-fwd".into()) .stack_size(FORWARDER_THREAD_STACK) .spawn(move || forward_outbound(out_rx, fwd_msg_tx))?; // Build the MQTT callback. It runs in the ESP-IDF MQTT task and routes: // Connected/Disconnected → state-thread queue // Received(/cmd/play) → audio_tx::Play/Stop // Received(/cmd/volume) → audio_tx::SetVolumeIndex(snap_to_preset(pct)) // Received(/cmd/update) → state-thread queue (install request) // Received(shared latest)→ state-thread queue (cache new version) let cb_msg_tx = msg_tx.clone(); let cb_audio_tx = audio_tx.clone(); let cb_cmd_play = cmd_play_topic.clone(); let cb_cmd_volume = cmd_volume_topic.clone(); let cb_cmd_update = cmd_update_topic.clone(); let mqtt_lwt_payload = b"offline"; let mqtt_config = MqttClientConfiguration { client_id: Some(&client_id), lwt: Some(LwtConfiguration { topic: &avail_topic, payload: mqtt_lwt_payload, qos: QoS::AtLeastOnce, retain: true, }), keep_alive_interval: Some(Duration::from_secs(60)), ..Default::default() }; let mut client = EspMqttClient::new_cb(CONFIG.mqtt_url, &mqtt_config, move |event| { mqtt_callback( event, &cb_msg_tx, &cb_audio_tx, &cb_cmd_play, &cb_cmd_volume, &cb_cmd_update, ); }) .map_err(|e| anyhow!("EspMqttClient::new_cb: {e}"))?; // Keep one Sender alive so the OTA worker can post Progress/Finished // back to this loop without racing the MQTT callback's clone. We // intentionally don't drop the original msg_tx — there's no point // detecting a closed channel from this thread, since this thread is // the only consumer and the only loop body. let msg_tx_for_ota = msg_tx; let mut last_snapshot: Option = None; let mut online = false; let boot_at = Instant::now(); // After publishing a button gesture, set this to "now + reset window" so // we know to publish a retained "idle" once the deadline lapses. The // sensor-style button entity in HA needs a stable resting state to show // instead of the "Unknown" of the old event entity. let mut button_idle_at: Option = None; // Most recent `latest_version` seen on the shared topic. None until // we've received our first retained message. The OTA URL is built as // `/sound-machine-.bin` at install time. let mut latest_version: Option = None; // Cancel-rollback runs once on the first healthy MQTT connect of a // boot. Set after the call so re-Connecteds are no-ops. let mut have_marked_valid = false; // Guards against a second OTA being kicked off while one is already // running (e.g., HA Install double-click). Cleared on failure; on // success the device reboots and the flag goes away with it. let ota_in_progress = Arc::new(AtomicBool::new(false)); // OTA progress state surfaced into HA's update entity via JSON state. // None when no OTA is running. Updated in 5% steps from the OTA worker. let mut ota_progress: Option = None; info!("network task: entering main loop"); loop { // Wait for either a queued message or, if a button reset is pending, // the moment we owe an idle publish. let msg = match button_idle_at { Some(deadline) => { let wait = deadline.saturating_duration_since(Instant::now()); msg_rx.recv_timeout(wait) } None => msg_rx.recv(), }; let Some(msg) = msg else { // Timed out -> button idle deadline lapsed. if online { publish_button_idle(&mut client, &button_topic); } button_idle_at = None; continue; }; match msg { NetTaskMsg::Connected => { info!("MQTT connected"); online = true; let _ = led_tx.send(LedSignal::Net(NetStatus::Online)); publish_online_announce( &mut client, &avail_topic, &state_topic, &button_topic, &update_state_topic, &cmd_filter, &mac_hex, sw_version, last_snapshot, ota_progress, boot_at, ); if !have_marked_valid { match ota::mark_app_valid() { Ok(()) => info!("OTA: marked running app as valid (rollback canceled)"), Err(e) => warn!("OTA: mark_app_valid failed: {e}"), } have_marked_valid = true; } } NetTaskMsg::Disconnected => { info!("MQTT disconnected"); online = false; let _ = led_tx.send(LedSignal::Net(NetStatus::Offline)); } NetTaskMsg::Outbound(OutboundEvent::Button(e)) => { handle_outbound_button(e, online, &mut client, &button_topic, &audio_tx); if online { button_idle_at = Some(Instant::now() + Duration::from_millis(BUTTON_IDLE_AFTER_MS)); } } NetTaskMsg::Outbound(OutboundEvent::State(snap)) => { last_snapshot = Some(snap); if online { publish_state(&mut client, &state_topic, snap, boot_at); } } NetTaskMsg::LatestVersion(v) => { info!("OTA: latest_version is now {}", v.as_str()); latest_version = Some(v); } NetTaskMsg::OtaInstall => { if handle_ota_install(latest_version, &led_tx, &ota_in_progress, &msg_tx_for_ota) { ota_progress = Some(0); if online { publish_update_state( &mut client, &update_state_topic, sw_version, ota_progress, ); } } } NetTaskMsg::OtaProgress(pct) => { ota_progress = Some(pct); if online { publish_update_state( &mut client, &update_state_topic, sw_version, ota_progress, ); } } NetTaskMsg::OtaFinished(success) => { ota_progress = None; ota_in_progress.store(false, Ordering::SeqCst); let _ = led_tx.send(LedSignal::UpdateDone); if !success && online { // Republish a non-progress state JSON so HA stops // showing the progress bar. (On success the device // reboots before reaching this, so success path // mainly exists for symmetry.) publish_update_state( &mut client, &update_state_topic, sw_version, ota_progress, ); } } } } } /// How long the button entity holds a gesture state before snapping back /// to "idle". Long enough for HA automations to fire on the transition, /// short enough that the device card always feels at-rest. const BUTTON_IDLE_AFTER_MS: u64 = 800; /// Outbound forwarder: blocking-receive `OutboundEvent` from the rest of the /// firmware, re-emit as `NetTaskMsg::Outbound` for the state thread. fn forward_outbound(out_rx: Receiver, msg_tx: Sender) { info!("outbound forwarder thread ready"); loop { let Some(ev) = out_rx.recv() else { continue }; info!("forwarder: got OutboundEvent {ev:?}"); if msg_tx.send(NetTaskMsg::Outbound(ev)).is_err() { warn!("outbound forwarder: state-queue send failed; exiting"); return; } } } /// MQTT event handler — runs in the ESP-IDF MQTT task. Must be quick. Uses /// `try_send` so it never blocks the MQTT task even if our state queue is /// briefly backed up; messages are events, not durable state. fn mqtt_callback( event: esp_idf_svc::mqtt::client::EspMqttEvent<'_>, msg_tx: &Sender, audio_tx: &Sender, cmd_play: &str, cmd_volume: &str, cmd_update: &str, ) { match event.payload() { EventPayload::Connected(_) => { let _ = msg_tx.try_send(NetTaskMsg::Connected); } EventPayload::Disconnected => { let _ = msg_tx.try_send(NetTaskMsg::Disconnected); } EventPayload::Received { topic, data, details, .. } => { if !matches!(details, Details::Complete) { return; } let Some(topic) = topic else { return; }; if topic == cmd_play { let cmd = match data { b"ON" => Some(AudioCommand::Play), b"OFF" => Some(AudioCommand::Stop), _ => None, }; if let Some(cmd) = cmd { let _ = audio_tx.try_send(cmd); } } else if topic == cmd_volume { if let Ok(s) = std::str::from_utf8(data) { if let Ok(pct) = s.trim().parse::() { let idx = snap_to_preset_index(pct.min(100) as u8); let _ = audio_tx.try_send(AudioCommand::SetVolumeIndex(idx)); } } } else if topic == cmd_update { if data == b"install" { let _ = msg_tx.try_send(NetTaskMsg::OtaInstall); } } else if topic == discovery::SHARED_LATEST_VERSION_TOPIC { let trimmed = trim_ascii(data); if let Some(v) = VersionBuf::from_bytes(trimmed) { let _ = msg_tx.try_send(NetTaskMsg::LatestVersion(v)); } else { warn!( "shared latest_version: invalid payload (len={}, dropped)", data.len() ); } } } EventPayload::Error(e) => { warn!("MQTT error event: {e:?}"); } _ => {} } } /// Strip leading/trailing ASCII whitespace from a byte slice without /// allocating. (`bytes::trim_ascii` is unstable.) fn trim_ascii(b: &[u8]) -> &[u8] { let mut start = 0; let mut end = b.len(); while start < end && b[start].is_ascii_whitespace() { start += 1; } while end > start && b[end - 1].is_ascii_whitespace() { end -= 1; } &b[start..end] } fn connect_wifi_with_retry(wifi: &mut BlockingWifi>) { loop { match try_connect_wifi(wifi) { Ok(()) => { info!("WiFi up"); return; } Err(e) => { warn!("WiFi connect failed: {e:?}; retrying in {WIFI_RETRY_INTERVAL:?}"); std::thread::sleep(WIFI_RETRY_INTERVAL); } } } } fn try_connect_wifi(wifi: &mut BlockingWifi>) -> Result<()> { if !wifi.is_started()? { info!("WiFi: starting driver"); wifi.start()?; info!("WiFi: driver started"); } info!("WiFi: associating with AP"); wifi.connect()?; info!("WiFi: associated; waiting for DHCP lease"); wifi.wait_netif_up()?; Ok(()) } fn handle_outbound_button( e: ButtonEvent, online: bool, client: &mut EspMqttClient<'_>, button_topic: &str, audio_tx: &Sender, ) { info!("handle_outbound_button: {e:?} online={online}"); if online { let payload = format!(r#"{{"event_type":"{}"}}"#, e.as_event_type()); match client.publish(button_topic, QoS::AtMostOnce, false, payload.as_bytes()) { Ok(msg_id) => info!("published button event {e:?} (msg_id={msg_id})"), Err(err) => warn!("publish button event failed: {err}"), } } else { // Offline fallback: only short needs translation to a local toggle. // Long already cycles volume locally (coordinator did that). Double // is a pure-MQTT gesture and is a no-op offline per spec. match e { ButtonEvent::Short => { info!("offline short → AudioCommand::Toggle (local fallback)"); let _ = audio_tx.send(AudioCommand::Toggle); } ButtonEvent::Long => {} // already handled locally ButtonEvent::Double => { info!("offline double → no-op (HA late-night-lights gesture unavailable)"); } } } } #[allow(clippy::too_many_arguments)] fn publish_online_announce( client: &mut EspMqttClient<'_>, avail_topic: &str, state_topic: &str, button_topic: &str, update_state_topic: &str, cmd_filter: &str, mac_hex: &str, sw_version: &str, last_snapshot: Option, ota_progress: Option, boot_at: Instant, ) { if let Err(e) = client.publish(avail_topic, QoS::AtLeastOnce, true, b"online") { warn!("publish availability=online failed: {e}"); } // Tell HA to drop entities we used to ship but no longer do. An empty // retained payload on a discovery topic is the documented "remove this // entity" signal. Add to this list whenever we remove an entity so the // broker stops carrying its retained config. let retired = [ // v0.2.0 dropped the diagnostic RSSI sensor. format!("homeassistant/sensor/nightstand_{mac_hex}/rssi/config"), // v0.2.1 changed the button from `event` (no resting state) to // `sensor` (idle/short/long/double). format!("homeassistant/event/nightstand_{mac_hex}/button/config"), // v0.3.3 split the update entity to a JSON state_topic so we can // surface progress; the old plain-string `update/installed` // retains stale config after the discovery payload changed. format!("nightstand/{mac_hex}/update/installed"), ]; for topic in &retired { if let Err(e) = client.publish(topic, QoS::AtLeastOnce, true, b"") { warn!("publish retired-entity removal {topic} failed: {e}"); } } for entry in discovery::all(mac_hex, sw_version) { if let Err(e) = client.publish( &entry.topic, QoS::AtLeastOnce, true, entry.payload.as_bytes(), ) { warn!("publish discovery {} failed: {e}", entry.topic); } } // Seed the button sensor with a retained "idle" so reconnecting clients // (and HA on first discovery) see a stable resting state. publish_button_idle(client, button_topic); // Publish our installed firmware version + any in-flight OTA progress // as a single retained JSON to the update entity's state_topic. HA // reads installed_version, in_progress, and update_percentage from it. publish_update_state(client, update_state_topic, sw_version, ota_progress); if let Some(snap) = last_snapshot { publish_state(client, state_topic, snap, boot_at); } if let Err(e) = client.subscribe(cmd_filter, QoS::AtLeastOnce) { warn!("subscribe {cmd_filter} failed: {e}"); } // Subscribe to the shared latest-version topic. Retained, so the broker // delivers the current value immediately (if any) and we cache it. if let Err(e) = client.subscribe(discovery::SHARED_LATEST_VERSION_TOPIC, QoS::AtLeastOnce) { warn!( "subscribe {} failed: {e}", discovery::SHARED_LATEST_VERSION_TOPIC ); } } /// Publish the JSON `state_topic` for HA's update entity. `installed_version` /// is always present; `in_progress` and `update_percentage` are added when /// an OTA is mid-download. Retained so HA picks up the current state on /// discovery + restart without waiting for the next change. fn publish_update_state( client: &mut EspMqttClient<'_>, update_state_topic: &str, sw_version: &str, ota_progress: Option, ) { let payload = match ota_progress { Some(pct) => format!( r#"{{"installed_version":"{sw}","in_progress":true,"update_percentage":{pct}}}"#, sw = sw_version, pct = pct, ), None => format!( r#"{{"installed_version":"{sw}","in_progress":false}}"#, sw = sw_version ), }; if let Err(e) = client.publish(update_state_topic, QoS::AtLeastOnce, true, payload.as_bytes()) { warn!("publish update state failed: {e}"); } } /// Triggered when HA publishes "install" to `cmd/update`. Builds the /// firmware URL from the cached latest version and the configured /// `ota_url_base`, then spawns a worker thread that does the chunked /// download with progress callbacks. Returns `true` if the install was /// accepted (so the caller can update its local progress state). fn handle_ota_install( latest: Option, led_tx: &Sender, in_progress: &Arc, msg_tx: &Sender, ) -> bool { let Some(version) = latest else { warn!("OTA install requested but no latest_version cached yet — ignoring"); return false; }; if CONFIG.ota_url_base.is_empty() { warn!("OTA install requested but ota_url_base is empty in cfg.toml — ignoring"); return false; } if in_progress .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) .is_err() { warn!("OTA install requested but one is already in progress — ignoring"); return false; } let base = CONFIG.ota_url_base.trim_end_matches('/'); let url = format!("{base}/sound-machine-{}.bin", version.as_str()); info!("OTA install: kicking download thread for {url}"); let _ = led_tx.send(LedSignal::Updating); let progress_tx = msg_tx.clone(); let finished_tx = msg_tx.clone(); if let Err(e) = Builder::new() .name("ota".into()) .stack_size(OTA_THREAD_STACK) .spawn(move || { let result = ota::download_and_install(&url, |pct| { let _ = progress_tx.try_send(NetTaskMsg::OtaProgress(pct)); }); match result { Ok(()) => { // No need to send OtaFinished(true) — we're about to // reboot, the network state thread won't get a chance // to act on it. esp_restart returns `!`. info!("OTA: rebooting into new firmware"); esp_idf_svc::hal::reset::restart(); } Err(e) => { warn!("OTA: download_and_install failed: {e}"); let _ = finished_tx.send(NetTaskMsg::OtaFinished(false)); } } }) { warn!("OTA: failed to spawn worker thread: {e}"); in_progress.store(false, Ordering::SeqCst); return false; } true } const OTA_THREAD_STACK: usize = 12 * 1024; /// Publish a retained "idle" state to the button topic. Called on connect /// and after the BUTTON_IDLE_AFTER_MS window following any gesture. fn publish_button_idle(client: &mut EspMqttClient<'_>, button_topic: &str) { if let Err(e) = client.publish( button_topic, QoS::AtLeastOnce, true, br#"{"event_type":"idle"}"#, ) { warn!("publish button idle failed: {e}"); } } fn publish_state( client: &mut EspMqttClient<'_>, state_topic: &str, snap: StateSnapshot, boot_at: Instant, ) { let playing = if snap.playing { "ON" } else { "OFF" }; let uptime_s = boot_at.elapsed().as_secs(); let payload = format!( r#"{{"playing":"{}","volume":{},"uptime_s":{}}}"#, playing, snap.volume_pct, uptime_s ); if let Err(e) = client.publish(state_topic, QoS::AtLeastOnce, true, payload.as_bytes()) { warn!("publish state failed: {e}"); } } /// Set the netif hostname via the raw C API. The Rust trait method is /// crate-private in esp-idf-svc 0.51, so we punch through to the C call. /// Failure is logged and ignored — DHCP-visible hostname is a nicety, not /// a correctness requirement. fn set_hostname(netif: &esp_idf_svc::netif::EspNetif, hostname: &str) { use esp_idf_svc::handle::RawHandle; use esp_idf_svc::sys::{esp, esp_netif_set_hostname}; use std::ffi::CString; let Ok(cstr) = CString::new(hostname) else { warn!("hostname contains nul byte; skipping set_hostname"); return; }; let res = unsafe { esp!(esp_netif_set_hostname(netif.handle(), cstr.as_ptr())) }; if let Err(e) = res { warn!("set_hostname failed (continuing): {e:?}"); } } /// Format a 6-byte MAC as a 12-char lowercase hex string. fn format_mac(mac: [u8; 6]) -> String { let mut s = String::with_capacity(12); for b in mac { use std::fmt::Write; let _ = write!(s, "{:02x}", b); } s } #[cfg(test)] mod tests { use super::*; #[test] fn format_mac_lowercase_hex() { assert_eq!( format_mac([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]), "aabbccddeeff" ); assert_eq!(format_mac([0, 0, 0, 0, 0, 0]), "000000000000"); assert_eq!(format_mac([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]), "010203040506"); } }