From 3bd763ab85f5e926d59dc47d4d5f3696a98f7c5c Mon Sep 17 00:00:00 2001 From: Lukasz Gintowt Date: Wed, 29 Oct 2025 17:47:28 +0100 Subject: [PATCH 1/7] separate ip pools --- src/main.rs | 122 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 116 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 8865f9e..24d6652 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ use esp_wifi_ap::{format_mac, render_sta_table, RssiDbm, RssiRange, StaSnapshot, use heapless::String as HeapString; use log::{info, warn}; use once_cell::sync::Lazy; -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::ffi::CStr; use std::net::Ipv4Addr; use std::num::NonZeroU32; @@ -87,6 +87,10 @@ struct DhcpReservationManager { network_base: u32, netmask: u32, broadcast: u32, + dynamic_assignments: HashMap<[u8; 6], u32>, + active_dynamic_hosts: HashSet, + next_dynamic_host: u32, + dynamic_end_host: u32, } unsafe impl Send for DhcpReservationManager {} @@ -104,14 +108,15 @@ impl DhcpReservationManager { ); let dynamic_start = Ipv4Addr::from(network_base + DYNAMIC_POOL_START); - let dynamic_end = Ipv4Addr::from(network_base + STATIC_POOL_START.saturating_sub(1)); + let dynamic_end_host = STATIC_POOL_START.saturating_sub(1); + let dynamic_end = Ipv4Addr::from(network_base + dynamic_end_host); let desired_default = DhcpsLease::from_range(dynamic_start, dynamic_end); set_dhcp_lease(handle, &desired_default)?; let (start, end) = lease_range(&desired_default); info!("Captured default DHCP pool: {} – {}", start, end,); - Ok(Self { + let mut manager = Self { handle, default: desired_default, queue: VecDeque::new(), @@ -119,7 +124,13 @@ impl DhcpReservationManager { network_base, netmask, broadcast, - }) + dynamic_assignments: HashMap::new(), + active_dynamic_hosts: HashSet::new(), + next_dynamic_host: DYNAMIC_POOL_START, + dynamic_end_host, + }; + manager.recalculate_dynamic_cursor(); + Ok(manager) } fn handle_sta_connected(&mut self, mac: [u8; 6]) -> anyhow::Result<()> { @@ -163,6 +174,11 @@ impl DhcpReservationManager { self.activate_next_in_queue()?; } + if let Some(host) = self.dynamic_assignments.remove(&mac) { + self.active_dynamic_hosts.remove(&host); + self.recalculate_dynamic_cursor(); + } + Ok(()) } @@ -184,6 +200,21 @@ impl DhcpReservationManager { } } + if DHCP_RESERVATIONS.contains_key(&mac) { + if let Some(previous) = self.dynamic_assignments.remove(&mac) { + self.active_dynamic_hosts.remove(&previous); + self.recalculate_dynamic_cursor(); + } + } else if let Some(host) = self.host_id_from_ip(ip) { + if let Some(previous) = self.dynamic_assignments.insert(mac, host) { + if previous != host { + self.active_dynamic_hosts.remove(&previous); + } + } + self.active_dynamic_hosts.insert(host); + self.recalculate_dynamic_cursor(); + } + Ok(()) } @@ -226,12 +257,51 @@ impl DhcpReservationManager { Ipv4Addr::from(self.network_base + max_reservable_host) ); - let next = Ipv4Addr::from(ip_u32 + 1); - Ok(DhcpsLease::from_range(ip, next)) + // IDF requires reservation pools to contain at least two addresses (start < end), + // so extend the range by one. The extra slot stays in the static band and we + // revert the pool immediately once the lease is confirmed. + let reservation_end_host = host_id + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("Reservation IP {} would overflow subnet", ip))?; + let last_usable_host = max_reservable_host + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("Invalid broadcast address calculation"))?; + anyhow::ensure!( + reservation_end_host <= last_usable_host, + "Reservation IP {} requires extending past {}, which is not allowed", + ip, + Ipv4Addr::from( + self.network_base + .checked_add(last_usable_host) + .ok_or_else(|| anyhow::anyhow!("Invalid broadcast address calculation"))? + ) + ); + + let reservation_end = Ipv4Addr::from( + self.network_base + .checked_add(reservation_end_host) + .ok_or_else(|| anyhow::anyhow!("Reservation IP {} exceeds subnet", ip))?, + ); + + Ok(DhcpsLease::from_range(ip, reservation_end)) } fn restore_default(&mut self) -> anyhow::Result<()> { if self.active.is_some() { + let dynamic_start_host = self + .next_dynamic_host + .clamp(DYNAMIC_POOL_START, self.dynamic_end_host); + let dynamic_start = Ipv4Addr::from( + self.network_base + .checked_add(dynamic_start_host) + .ok_or_else(|| anyhow::anyhow!("Dynamic start exceeds subnet bounds"))?, + ); + let dynamic_end = Ipv4Addr::from( + self.network_base + .checked_add(self.dynamic_end_host) + .ok_or_else(|| anyhow::anyhow!("Dynamic end exceeds subnet bounds"))?, + ); + self.default = DhcpsLease::from_range(dynamic_start, dynamic_end); let (start, end) = lease_range(&self.default); info!("Restoring default DHCP pool {} – {}", start, end); set_dhcp_lease(self.handle, &self.default)?; @@ -249,6 +319,46 @@ impl DhcpReservationManager { } Ok(()) } + + fn host_id_from_ip(&self, ip: Ipv4Addr) -> Option { + let ip_u32 = u32::from(ip); + if (ip_u32 & self.netmask) != self.network_base { + return None; + } + ip_u32 + .checked_sub(self.network_base) + .filter(|host| *host >= DYNAMIC_POOL_START && *host <= self.dynamic_end_host) + } + + fn recalculate_dynamic_cursor(&mut self) { + let pool_size = self + .dynamic_end_host + .saturating_sub(DYNAMIC_POOL_START) + .saturating_add(1); + + if pool_size == 0 { + self.next_dynamic_host = DYNAMIC_POOL_START; + return; + } + + let mut candidate = self + .next_dynamic_host + .clamp(DYNAMIC_POOL_START, self.dynamic_end_host); + for _ in 0..pool_size { + if !self.active_dynamic_hosts.contains(&candidate) { + self.next_dynamic_host = candidate; + return; + } + candidate = if candidate >= self.dynamic_end_host { + DYNAMIC_POOL_START + } else { + candidate + 1 + }; + } + + // Pool fully occupied; fall back to the start so caller can decide how to handle exhaustion. + self.next_dynamic_host = DYNAMIC_POOL_START; + } } fn set_dhcp_lease(handle: *mut sys::esp_netif_t, lease: &DhcpsLease) -> anyhow::Result<()> { From 0adbaf29075935c93abe9c3d8ed4e9d3a116909c Mon Sep 17 00:00:00 2001 From: Lukasz Gintowt Date: Thu, 30 Oct 2025 10:37:33 +0100 Subject: [PATCH 2/7] bigger max clients --- sdkconfig.defaults | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sdkconfig.defaults b/sdkconfig.defaults index e5cd6dc..151163c 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -34,6 +34,12 @@ CONFIG_LWIP_IP6_FRAG=y CONFIG_LWIP_IP_REASS_MAX_PBUFS=10 CONFIG_LWIP_IP_FORWARD=y CONFIG_LWIP_IPV4_NAPT=y + +# Allow more simultaneous SoftAP clients (default is 10) +CONFIG_ESP_WIFI_SOFTAP_MAX_CONN=16 +CONFIG_ESP_WIFI_MAX_CONN=16 +# ESP-NOW encrypted peers share HW key slots with SoftAP stations; keep at 0 unless needed +CONFIG_ESP_WIFI_ESPNOW_MAX_ENCRYPT_NUM=0 CONFIG_LWIP_IPV4_NAPT_PORTMAP=y CONFIG_LWIP_ESP_GRATUITOUS_ARP=y CONFIG_LWIP_GARP_TMR_INTERVAL=60 From 85eb7c20784c0bd71a0f61c24be36a67dfc5c3c5 Mon Sep 17 00:00:00 2001 From: Lukasz Gintowt Date: Thu, 30 Oct 2025 10:52:29 +0100 Subject: [PATCH 3/7] add s3 support --- .cargo/config.toml | 5 +++++ Cargo.toml | 1 + justfile | 30 +++++++++++++++++++++++++ readme.md | 55 +++++++++++++++++++++++++++++++++++++-------- rust-toolchain.toml | 7 +++++- 5 files changed, 88 insertions(+), 10 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 004349c..aa50c51 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -12,6 +12,11 @@ runner = "espflash flash --monitor" rustflags = [ "--cfg", "espidf_time64"] +[target.xtensa-esp32s3-espidf] # Esp32-S3 +linker = "ldproxy" +runner = "espflash flash --monitor" +rustflags = [ "--cfg", "espidf_time64"] + [unstable] build-std = ["std", "panic_abort"] diff --git a/Cargo.toml b/Cargo.toml index a81e0a3..b321f55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ opt-level = "z" [features] default = [] esp32c3 = [] +esp32s3 = [] #experimental = ["esp-idf-svc/experimental"] [dependencies] diff --git a/justfile b/justfile index 92f93b7..c33af89 100644 --- a/justfile +++ b/justfile @@ -9,12 +9,18 @@ build *args: build-c3 *args: MCU=esp32c3 cargo build --release --target riscv32imc-esp-espidf --features esp32c3 {{args}} +build-s3 *args: + MCU=esp32s3 cargo build --release --target xtensa-esp32s3-espidf --features esp32s3 {{args}} + flash: espflash flash --monitor --chip esp32c6 target/riscv32imac-esp-espidf/release/esp-wifi-ap flash-c3: espflash flash --monitor --chip esp32c3 target/riscv32imc-esp-espidf/release/esp-wifi-ap +flash-s3: + espflash flash --monitor --chip esp32s3 target/xtensa-esp32s3-espidf/release/esp-wifi-ap + # Default recipe (ESP32-C6) run *args: # Show coloured output in the terminal, @@ -45,6 +51,21 @@ release-c3 *args: # copy a colour-stripped log to the clipboard env MCU=esp32c3 cargo run --release --bin esp-wifi-ap --target riscv32imc-esp-espidf --features esp32c3 {{args}} +# Run with ESP32-S3 +run-s3 *args: + # Show coloured output in the terminal, + # copy a colour-stripped log to the clipboard + unbuffer env MCU=esp32s3 cargo run --bin esp-wifi-ap --target xtensa-esp32s3-espidf --features esp32s3 {{args}} 2>&1 \ + | tee /dev/tty \ + | sed -r 's/${COLOR_RE}//g' \ + | pbcopy + +# Release with ESP32-S3 +release-s3 *args: + # Show coloured output in the terminal, + # copy a colour-stripped log to the clipboard + env MCU=esp32s3 cargo run --release --bin esp-wifi-ap --target xtensa-esp32s3-espidf --features esp32s3 {{args}} + # Run client (ESP32-C6) run-client *args: # Show coloured output in the terminal, @@ -63,5 +84,14 @@ run-client-c3 *args: | sed -r 's/${COLOR_RE}//g' \ | pbcopy +# Run client with ESP32-S3 +run-client-s3 *args: + # Show coloured output in the terminal, + # copy a colour-stripped log to the clipboard + unbuffer env MCU=esp32s3 cargo run --bin esp-wifi-client --target xtensa-esp32s3-espidf --features esp32s3 {{args}} 2>&1 \ + | tee /dev/tty \ + | sed -r 's/${COLOR_RE}//g' \ + | pbcopy + where_my_esp_at: ls -lt /dev/tty.usb* /dev/cu.usb* 2>/dev/null | awk '{print $NF}' diff --git a/readme.md b/readme.md index 230a86e..c98b3f5 100644 --- a/readme.md +++ b/readme.md @@ -12,7 +12,7 @@ This project provides two binaries: - **Distance Measurement**: - AP: RTT (Round Trip Time) for precise ranging - Client: RSSI-based distance estimation -- **Chip Support**: ESP32-C6 (default) and ESP32-C3 +- **Chip Support**: ESP32-C6 (default), ESP32-C3, and ESP32-S3 - **Robust Logging**: Comprehensive Wi-Fi event and connection status logging - **Static DHCP Reservations**: Pin specific client MACs to deterministic IP addresses through `.env` - **Network Cycling**: Client can cycle through multiple Wi-Fi networks with button press @@ -32,15 +32,22 @@ This project provides two binaries: - **Architecture**: RISC-V 32-bit single-core @ 160 MHz - **Memory**: 400 KB SRAM, 384 KB ROM +### ESP32-S3 (Optional Feature) +- **Target**: `xtensa-esp32s3-espidf` +- **Chip**: esp32s3 +- **Feature flag**: `--features esp32s3` +- **Architecture**: Xtensa LX7 dual-core @ 240 MHz +- **Memory**: 512 KB SRAM, 384 KB ROM + ### Key Differences -| Feature | ESP32-C6 | ESP32-C3 | -|---------|----------|----------| -| Architecture | RISC-V 32-bit dual-core | RISC-V 32-bit single-core | -| CPU Speed | 160 MHz | 160 MHz | -| Target | `riscv32imac-esp-espidf` | `riscv32imc-esp-espidf` | -| Wi-Fi | 802.11 b/g/n | 802.11 b/g/n | -| Bluetooth | LE 5.0 + Zigbee/Thread | LE 5.0 | -| Build Command | `just build` | `just build-c3` | +| Feature | ESP32-C6 | ESP32-C3 | ESP32-S3 | +|---------|----------|----------|----------| +| Architecture | RISC-V 32-bit dual-core | RISC-V 32-bit single-core | Xtensa LX7 dual-core | +| CPU Speed | 160 MHz | 160 MHz | 240 MHz | +| Target | `riscv32imac-esp-espidf` | `riscv32imc-esp-espidf` | `xtensa-esp32s3-espidf` | +| Wi-Fi | 802.11 b/g/n | 802.11 b/g/n | 802.11 b/g/n | +| Bluetooth | LE 5.0 + Zigbee/Thread | LE 5.0 | LE 5.0 | +| Build Command | `just build` | `just build-c3` | `just build-s3` | # Setup ```bash @@ -58,6 +65,14 @@ ESP-IDF v5.4.1 ## Build & Flash +### Rust Toolchain +Install the Espressif-patched toolchain (provides the `*-espidf` targets, including Xtensa) if you haven't already: +```bash +cargo install espup +espup install +``` +This installs the `esp` toolchain that the project pins in `rust-toolchain.toml`. + ### Wi-Fi Access Point (C6) ```bash # Using justfile (recommended) @@ -82,6 +97,18 @@ MCU=esp32c3 cargo build --release --target riscv32imc-esp-espidf --features esp3 espflash flash --monitor --chip esp32c3 target/riscv32imc-esp-espidf/release/esp-wifi-ap ``` +### Wi-Fi Access Point (S3) +```bash +# Using justfile (recommended) +just build-s3 # Build for ESP32-S3 +just flash-s3 # Flash to ESP32-S3 +just run-s3 # Build, flash, and monitor ESP32-S3 + +# Or using cargo directly +MCU=esp32s3 cargo build --release --target xtensa-esp32s3-espidf --features esp32s3 +espflash flash --monitor --chip esp32s3 target/xtensa-esp32s3-espidf/release/esp-wifi-ap +``` + ### Wi-Fi Station Client ```bash # ESP32-C6 (default) @@ -91,6 +118,10 @@ cargo espflash flash --release --bin esp-wifi-client # ESP32-C3 MCU=esp32c3 cargo build --bin esp-wifi-client --release --target riscv32imc-esp-espidf --features esp32c3 espflash flash --monitor --chip esp32c3 target/riscv32imc-esp-espidf/release/esp-wifi-client + +# ESP32-S3 +MCU=esp32s3 cargo build --bin esp-wifi-client --release --target xtensa-esp32s3-espidf --features esp32s3 +espflash flash --monitor --chip esp32s3 target/xtensa-esp32s3-espidf/release/esp-wifi-client # OR using tasks cargo run --bin esp-wifi-client ``` @@ -109,6 +140,12 @@ just flash-c3 # Flash ESP32-C3 just run-c3 # Build, flash, and monitor ESP32-C3 (AP mode) just run-client-c3 # Build, flash, and monitor ESP32-C3 (Client mode) +# ESP32-S3 (Feature) +just build-s3 # Build for ESP32-S3 +just flash-s3 # Flash ESP32-S3 +just run-s3 # Build, flash, and monitor ESP32-S3 (AP mode) +just run-client-s3 # Build, flash, and monitor ESP32-S3 (Client mode) + # Utility commands just where_my_esp_at # Find ESP device ports ``` diff --git a/rust-toolchain.toml b/rust-toolchain.toml index f70d225..df2d806 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,8 @@ [toolchain] -channel = "nightly" +channel = "esp" components = ["rust-src"] +targets = [ + "riscv32imac-esp-espidf", + "riscv32imc-esp-espidf", + "xtensa-esp32s3-espidf", +] From 444627179fbc7ca0a9e1be13e4064dabd6138600 Mon Sep 17 00:00:00 2001 From: Lukasz Gintowt Date: Thu, 30 Oct 2025 14:19:35 +0100 Subject: [PATCH 4/7] add s3 support --- Cargo.toml | 1 + sdkconfig.defaults | 1 + src/dhcp.rs | 360 +++++++++++++++++++++++++++++ src/main.rs | 548 ++++++++------------------------------------- 4 files changed, 451 insertions(+), 459 deletions(-) create mode 100644 src/dhcp.rs diff --git a/Cargo.toml b/Cargo.toml index b321f55..d0fe5ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,7 @@ ws2812-esp32-rmt-driver = { version = "0.12", default-features = false, features rgb = "0.8.52" # <-- brings rgb::RGB8 into scope names = "0.14" once_cell = "1.19" # not sure if good idea WDYT? +libc = "0.2" [build-dependencies] embuild = "0.33.1" diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 151163c..2e3faab 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -38,6 +38,7 @@ CONFIG_LWIP_IPV4_NAPT=y # Allow more simultaneous SoftAP clients (default is 10) CONFIG_ESP_WIFI_SOFTAP_MAX_CONN=16 CONFIG_ESP_WIFI_MAX_CONN=16 +CONFIG_LWIP_DHCPS_MAX_STATION_NUM=32 # ESP-NOW encrypted peers share HW key slots with SoftAP stations; keep at 0 unless needed CONFIG_ESP_WIFI_ESPNOW_MAX_ENCRYPT_NUM=0 CONFIG_LWIP_IPV4_NAPT_PORTMAP=y diff --git a/src/dhcp.rs b/src/dhcp.rs new file mode 100644 index 0000000..5ebbd77 --- /dev/null +++ b/src/dhcp.rs @@ -0,0 +1,360 @@ +use crate::{format_mac, STATIC_LEASES}; +use anyhow::{anyhow, Context, Result}; +use esp_idf_sys as sys; +use libc::{calloc, free}; +use once_cell::sync::Lazy; +use std::collections::HashMap; +use std::net::Ipv4Addr; +use std::os::raw::{c_char, c_void}; +use std::ptr::{self, NonNull}; + +pub const DYNAMIC_POOL_START: u32 = 2; +pub const DYNAMIC_POOL_END: u32 = 99; +pub const STATIC_POOL_START: u32 = 100; +const STATIC_LEASE_HOLD_SECONDS: u32 = u32::MAX / 2; + +pub static DHCP_RESERVATIONS: Lazy> = Lazy::new(|| { + let mut map = HashMap::new(); + for lease in STATIC_LEASES { + map.insert( + lease.mac, + Ipv4Addr::new(lease.ip[0], lease.ip[1], lease.ip[2], lease.ip[3]), + ); + } + map +}); + +#[repr(C)] +#[derive(Clone, Copy)] +struct DhcpsLease { + enable: bool, + start_ip: sys::ip4_addr_t, + end_ip: sys::ip4_addr_t, +} + +impl DhcpsLease { + fn from_range(start: Ipv4Addr, end: Ipv4Addr) -> Self { + Self { + enable: true, + start_ip: ip4_from_ipv4(start), + end_ip: ip4_from_ipv4(end), + } + } +} + +#[repr(C)] +struct DhcpsPool { + ip: sys::ip4_addr_t, + mac: [u8; 6], + lease_timer: u32, +} + +#[repr(C)] +struct ListNode { + pnode: *mut DhcpsPool, + pnext: *mut ListNode, +} + +#[repr(C)] +struct Dhcps { + dhcps_netif: *mut sys::netif, + broadcast_dhcps: sys::ip4_addr_t, + server_address: sys::ip4_addr_t, + dns_server: sys::ip4_addr_t, + client_address: sys::ip4_addr_t, + client_address_plus: sys::ip4_addr_t, + dhcps_mask: sys::ip4_addr_t, + plist: *mut ListNode, + renew: bool, + dhcps_poll: DhcpsLease, + dhcps_lease_time: u32, + dhcps_offer: u8, + dhcps_dns: u8, + dhcps_captiveportal_uri: *mut c_char, + dhcps_cb: Option, + dhcps_cb_arg: *mut c_void, + dhcps_pcb: *mut c_void, + state: i32, +} + +#[repr(C)] +struct NetifRelatedData { + is_point2point: bool, + _reserved: [u8; 3], + netif_type: i32, +} + +#[repr(C)] +struct EspNetifObjHead { + mac: [u8; 6], + _mac_padding: [u8; 2], + ip_info: *mut sys::esp_netif_ip_info_t, + ip_info_old: *mut sys::esp_netif_ip_info_t, + lwip_netif: *mut sys::netif, + lwip_init_fn: Option i8>, + lwip_input_fn: Option< + unsafe extern "C" fn(*mut c_void, *mut c_void, usize, *mut c_void) -> sys::esp_err_t, + >, + netif_handle: *mut c_void, + related_data: *mut NetifRelatedData, + dhcps: *mut Dhcps, +} + +pub struct DhcpServerState { + dhcps: NonNull, + network_base: u32, + netmask: u32, + dynamic_start: u32, + dynamic_end: u32, +} + +unsafe impl Send for DhcpServerState {} +unsafe impl Sync for DhcpServerState {} + +impl DhcpServerState { + pub fn new(handle: *mut sys::esp_netif_t) -> Result { + let ip_info = fetch_ip_info(handle)?; + + let ip_host = u32::from_be(ip_info.ip.addr); + let netmask_host = u32::from_be(ip_info.netmask.addr); + let network_base = ip_host & netmask_host; + + anyhow::ensure!( + STATIC_POOL_START > DYNAMIC_POOL_START, + "Static pool start must be greater than dynamic pool start" + ); + + let pool_start_ip = host_to_ipv4(network_base + DYNAMIC_POOL_START); + let pool_end_ip = host_to_ipv4(network_base + DYNAMIC_POOL_END); + let lease = DhcpsLease::from_range(pool_start_ip, pool_end_ip); + set_dhcp_lease(handle, &lease)?; + + let netif_obj = unsafe { &mut *(handle as *mut EspNetifObjHead) }; + let dhcps_ptr = NonNull::new(netif_obj.dhcps).context("DHCP server unavailable")?; + + let state = Self { + dhcps: dhcps_ptr, + network_base, + netmask: netmask_host, + dynamic_start: DYNAMIC_POOL_START, + dynamic_end: DYNAMIC_POOL_END, + }; + + let max_host = state.install_static_leases(STATIC_LEASES)?; + state.extend_pool_end(max_host); + state.clamp_dynamic_cursor(); + + Ok(state) + } + + pub fn clamp_dynamic_cursor(&self) { + unsafe { + let dhcps = self.dhcps.as_ptr(); + let current_host = host_component((*dhcps).client_address_plus.addr, self.network_base); + if current_host < self.dynamic_start || current_host > self.dynamic_end { + let addr = host_to_be(self.network_base + self.dynamic_start); + (*dhcps).client_address_plus.addr = addr; + } + } + } + + pub fn touch_static_lease(&self, mac: &[u8; 6]) { + unsafe { + let mut node = (*self.dhcps.as_ptr()).plist; + while !node.is_null() { + let pool = (*node).pnode; + if !pool.is_null() && (*pool).mac == *mac { + (*pool).lease_timer = STATIC_LEASE_HOLD_SECONDS; + break; + } + node = (*node).pnext; + } + } + } + + fn install_static_leases(&self, reservations: &[crate::StaticLease]) -> Result { + let mut max_host = DYNAMIC_POOL_END; + for lease in reservations { + let ip_host = u32::from_be_bytes(lease.ip); + ensure_same_subnet(ip_host, self.network_base, self.netmask, lease.mac)?; + + let host_part = ip_host - self.network_base; + anyhow::ensure!( + host_part >= STATIC_POOL_START, + "Static reservation {} for {} must be >= {}", + host_to_ipv4(ip_host), + format_mac(&lease.mac), + host_to_ipv4(self.network_base + STATIC_POOL_START) + ); + + if host_part > max_host { + max_host = host_part; + } + + self.upsert_static_entry(lease.mac, ip_host)?; + } + Ok(max_host) + } + + fn upsert_static_entry(&self, mac: [u8; 6], ip_host: u32) -> Result<()> { + unsafe { + let dhcps = self.dhcps.as_ptr(); + let ip_be = host_to_be(ip_host); + let mut prev: *mut ListNode = ptr::null_mut(); + let mut current = (*dhcps).plist; + + while !current.is_null() { + let pool = (*current).pnode; + if pool.is_null() { + prev = current; + current = (*current).pnext; + continue; + } + + let current_ip_host = u32::from_be((*pool).ip.addr); + + if (*pool).mac == mac { + (*pool).ip.addr = ip_be; + (*pool).lease_timer = STATIC_LEASE_HOLD_SECONDS; + return Ok(()); + } + + if current_ip_host == ip_host { + (*pool).mac = mac; + (*pool).lease_timer = STATIC_LEASE_HOLD_SECONDS; + return Ok(()); + } + + if current_ip_host > ip_host { + break; + } + + prev = current; + current = (*current).pnext; + } + + let pool_ptr = calloc(1, std::mem::size_of::()) as *mut DhcpsPool; + if pool_ptr.is_null() { + return Err(anyhow!("Failed to allocate DHCP static pool entry")); + } + + (*pool_ptr).ip.addr = ip_be; + (*pool_ptr).mac = mac; + (*pool_ptr).lease_timer = STATIC_LEASE_HOLD_SECONDS; + + let node_ptr = calloc(1, std::mem::size_of::()) as *mut ListNode; + if node_ptr.is_null() { + free(pool_ptr as *mut c_void); + return Err(anyhow!("Failed to allocate DHCP list node")); + } + + (*node_ptr).pnode = pool_ptr; + (*node_ptr).pnext = current; + + if prev.is_null() { + (*dhcps).plist = node_ptr; + } else { + (*prev).pnext = node_ptr; + } + } + + Ok(()) + } + + fn extend_pool_end(&self, max_host: u32) { + unsafe { + let dhcps = self.dhcps.as_ptr(); + let target_host = max_host.max(self.dynamic_end); + (*dhcps).dhcps_poll.end_ip.addr = + host_to_be(self.network_base.saturating_add(target_host)); + } + } +} + +fn ensure_same_subnet( + ip_host: u32, + network_base: u32, + netmask_host: u32, + mac: [u8; 6], +) -> Result { + if (ip_host & netmask_host) != network_base { + Err(anyhow!( + "Static lease {} for {} outside AP subnet", + host_to_ipv4(ip_host), + format_mac(&mac) + )) + } else { + Ok(ip_host) + } +} + +fn fetch_ip_info(handle: *mut sys::esp_netif_t) -> Result { + let mut info = sys::esp_netif_ip_info_t { + ip: sys::esp_ip4_addr_t { addr: 0 }, + netmask: sys::esp_ip4_addr_t { addr: 0 }, + gw: sys::esp_ip4_addr_t { addr: 0 }, + }; + let err = unsafe { sys::esp_netif_get_ip_info(handle, &mut info) }; + if err == sys::ESP_OK { + Ok(info) + } else { + Err(esp_error(err)) + } +} + +fn set_dhcp_lease(handle: *mut sys::esp_netif_t, lease: &DhcpsLease) -> Result<()> { + let stop_err = unsafe { sys::esp_netif_dhcps_stop(handle) }; + if stop_err != sys::ESP_OK && stop_err != sys::ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED { + return Err(esp_error(stop_err)); + } + + let mut lease_copy = *lease; + let set_err = unsafe { + sys::esp_netif_dhcps_option( + handle, + sys::esp_netif_dhcp_option_mode_t_ESP_NETIF_OP_SET, + sys::esp_netif_dhcp_option_id_t_ESP_NETIF_REQUESTED_IP_ADDRESS, + &mut lease_copy as *mut _ as *mut c_void, + core::mem::size_of::() as u32, + ) + }; + + if set_err != sys::ESP_OK { + return Err(esp_error(set_err)); + } + + let start_err = unsafe { sys::esp_netif_dhcps_start(handle) }; + if start_err != sys::ESP_OK && start_err != sys::ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED { + return Err(esp_error(start_err)); + } + + Ok(()) +} + +fn ip4_from_ipv4(ip: Ipv4Addr) -> sys::ip4_addr_t { + sys::ip4_addr_t { + addr: host_to_be(ipv4_to_host(ip)), + } +} + +fn host_to_ipv4(host: u32) -> Ipv4Addr { + Ipv4Addr::from(host_to_be(host)) +} + +fn ipv4_to_host(ip: Ipv4Addr) -> u32 { + u32::from_be(u32::from(ip)) +} + +fn host_to_be(host: u32) -> u32 { + host.to_be() +} + +fn host_component(addr_be: u32, network_base: u32) -> u32 { + let addr_host = u32::from_be(addr_be); + addr_host.saturating_sub(network_base) +} + +fn esp_error(err: i32) -> anyhow::Error { + let name = unsafe { std::ffi::CStr::from_ptr(sys::esp_err_to_name(err)) }.to_string_lossy(); + anyhow!("ESP error {err}: {name}") +} diff --git a/src/main.rs b/src/main.rs index 24d6652..c0e1aba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,3 @@ -use core::ffi::c_void; use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use esp_idf_svc::hal::delay::FreeRtos; use esp_idf_svc::hal::modem::Modem; @@ -13,13 +12,11 @@ use esp_idf_svc::netif::IpEvent; use esp_idf_svc::nvs::*; use esp_idf_svc::wifi::*; use esp_idf_sys as sys; -use esp_idf_sys::ESP_OK; use esp_wifi_ap::{format_mac, render_sta_table, RssiDbm, RssiRange, StaSnapshot, RGB8, WS2812RMT}; use heapless::String as HeapString; use log::{info, warn}; use once_cell::sync::Lazy; -use std::collections::{HashMap, HashSet, VecDeque}; -use std::ffi::CStr; +use std::collections::HashMap; use std::net::Ipv4Addr; use std::num::NonZeroU32; use std::ptr; @@ -28,412 +25,18 @@ use std::thread; use std::time::{Duration, Instant}; use sys::esp_netif_napt_enable; -include!(concat!(env!("OUT_DIR"), "/wifi_networks.rs")); -include!(concat!(env!("OUT_DIR"), "/dhcp_leases.rs")); - -const DYNAMIC_POOL_START: u32 = 2; -const STATIC_POOL_START: u32 = 100; +use dhcp::{DhcpServerState, DHCP_RESERVATIONS}; -static DHCP_RESERVATIONS: Lazy> = Lazy::new(|| { - let mut map = HashMap::new(); - for lease in STATIC_LEASES { - map.insert( - lease.mac, - Ipv4Addr::new(lease.ip[0], lease.ip[1], lease.ip[2], lease.ip[3]), - ); - } - map -}); - -#[repr(C)] #[derive(Clone, Copy)] -struct DhcpsLease { - enable: bool, - start_ip: sys::ip4_addr_t, - end_ip: sys::ip4_addr_t, -} - -impl Default for DhcpsLease { - fn default() -> Self { - Self { - enable: false, - start_ip: sys::ip4_addr_t { addr: 0 }, - end_ip: sys::ip4_addr_t { addr: 0 }, - } - } -} - -impl DhcpsLease { - fn from_range(start: Ipv4Addr, end: Ipv4Addr) -> Self { - Self { - enable: true, - start_ip: ip4_from_ipv4(start), - end_ip: ip4_from_ipv4(end), - } - } -} - -#[derive(Clone, Copy)] -struct ActiveOverride { - mac: [u8; 6], - ip: Ipv4Addr, -} - -struct DhcpReservationManager { - handle: *mut sys::esp_netif_t, - default: DhcpsLease, - queue: VecDeque<[u8; 6]>, - active: Option, - network_base: u32, - netmask: u32, - broadcast: u32, - dynamic_assignments: HashMap<[u8; 6], u32>, - active_dynamic_hosts: HashSet, - next_dynamic_host: u32, - dynamic_end_host: u32, -} +struct NetifHandle(*mut sys::esp_netif_t); -unsafe impl Send for DhcpReservationManager {} +unsafe impl Send for NetifHandle {} +unsafe impl Sync for NetifHandle {} -impl DhcpReservationManager { - fn new(handle: *mut sys::esp_netif_t) -> anyhow::Result { - let ip_info = fetch_ip_info(handle)?; - let netmask = u32::from(esp_ip4_to_ipv4(ip_info.netmask)); - let network_base = u32::from(esp_ip4_to_ipv4(ip_info.ip)) & netmask; - let broadcast = network_base | (!netmask); - - anyhow::ensure!( - STATIC_POOL_START > DYNAMIC_POOL_START, - "Static pool start must exceed dynamic pool start" - ); - - let dynamic_start = Ipv4Addr::from(network_base + DYNAMIC_POOL_START); - let dynamic_end_host = STATIC_POOL_START.saturating_sub(1); - let dynamic_end = Ipv4Addr::from(network_base + dynamic_end_host); - let desired_default = DhcpsLease::from_range(dynamic_start, dynamic_end); - set_dhcp_lease(handle, &desired_default)?; - - let (start, end) = lease_range(&desired_default); - info!("Captured default DHCP pool: {} – {}", start, end,); - - let mut manager = Self { - handle, - default: desired_default, - queue: VecDeque::new(), - active: None, - network_base, - netmask, - broadcast, - dynamic_assignments: HashMap::new(), - active_dynamic_hosts: HashSet::new(), - next_dynamic_host: DYNAMIC_POOL_START, - dynamic_end_host, - }; - manager.recalculate_dynamic_cursor(); - Ok(manager) - } - - fn handle_sta_connected(&mut self, mac: [u8; 6]) -> anyhow::Result<()> { - let Some(&ip) = DHCP_RESERVATIONS.get(&mac) else { - return Ok(()); - }; - - if self - .active - .as_ref() - .map(|current| current.mac == mac) - .unwrap_or(false) - { - return Ok(()); - } - - if self.active.is_some() { - if !self.queue.iter().any(|queued| queued == &mac) { - self.queue.push_back(mac); - info!( - "Queued DHCP override for {} (waiting for current reservation to finish)", - format_mac(&mac) - ); - } - return Ok(()); - } - - self.apply_override(mac, ip) - } - - fn handle_sta_disconnected(&mut self, mac: [u8; 6]) -> anyhow::Result<()> { - self.queue.retain(|queued| queued != &mac); - - if self - .active - .as_ref() - .map(|current| current.mac == mac) - .unwrap_or(false) - { - self.restore_default()?; - self.activate_next_in_queue()?; - } - - if let Some(host) = self.dynamic_assignments.remove(&mac) { - self.active_dynamic_hosts.remove(&host); - self.recalculate_dynamic_cursor(); - } +mod dhcp; - Ok(()) - } - - fn handle_ip_assigned(&mut self, mac: [u8; 6], ip: Ipv4Addr) -> anyhow::Result<()> { - if let Some(active) = self.active { - if active.mac == mac { - if active.ip != ip { - warn!( - "Reservation mismatch for {}: expected {}, got {}", - format_mac(&mac), - active.ip, - ip - ); - } else { - info!("Reserved IP {} confirmed for {}", ip, format_mac(&mac)); - } - self.restore_default()?; - self.activate_next_in_queue()?; - } - } - - if DHCP_RESERVATIONS.contains_key(&mac) { - if let Some(previous) = self.dynamic_assignments.remove(&mac) { - self.active_dynamic_hosts.remove(&previous); - self.recalculate_dynamic_cursor(); - } - } else if let Some(host) = self.host_id_from_ip(ip) { - if let Some(previous) = self.dynamic_assignments.insert(mac, host) { - if previous != host { - self.active_dynamic_hosts.remove(&previous); - } - } - self.active_dynamic_hosts.insert(host); - self.recalculate_dynamic_cursor(); - } - - Ok(()) - } - - fn apply_override(&mut self, mac: [u8; 6], ip: Ipv4Addr) -> anyhow::Result<()> { - let lease = self.make_reservation(ip)?; - set_dhcp_lease(self.handle, &lease)?; - info!("Temporarily pinning {} to {}", format_mac(&mac), ip); - self.active = Some(ActiveOverride { mac, ip }); - Ok(()) - } - - fn make_reservation(&self, ip: Ipv4Addr) -> anyhow::Result { - let ip_u32 = u32::from(ip); - anyhow::ensure!( - (ip_u32 & self.netmask) == self.network_base, - "Reservation IP {} must be within the AP subnet", - ip - ); - - let host_id = ip_u32 - .checked_sub(self.network_base) - .ok_or_else(|| anyhow::anyhow!("Reservation IP {} underflows network base", ip))?; - - anyhow::ensure!( - host_id >= STATIC_POOL_START, - "Reservation IP {} must be >= {}", - ip, - Ipv4Addr::from(self.network_base + STATIC_POOL_START) - ); - - let max_reservable_host = (self - .broadcast - .checked_sub(self.network_base) - .ok_or_else(|| anyhow::anyhow!("Invalid broadcast address calculation"))?) - .saturating_sub(2); - anyhow::ensure!( - host_id <= max_reservable_host, - "Reservation IP {} must be <= {}", - ip, - Ipv4Addr::from(self.network_base + max_reservable_host) - ); - - // IDF requires reservation pools to contain at least two addresses (start < end), - // so extend the range by one. The extra slot stays in the static band and we - // revert the pool immediately once the lease is confirmed. - let reservation_end_host = host_id - .checked_add(1) - .ok_or_else(|| anyhow::anyhow!("Reservation IP {} would overflow subnet", ip))?; - let last_usable_host = max_reservable_host - .checked_add(1) - .ok_or_else(|| anyhow::anyhow!("Invalid broadcast address calculation"))?; - anyhow::ensure!( - reservation_end_host <= last_usable_host, - "Reservation IP {} requires extending past {}, which is not allowed", - ip, - Ipv4Addr::from( - self.network_base - .checked_add(last_usable_host) - .ok_or_else(|| anyhow::anyhow!("Invalid broadcast address calculation"))? - ) - ); - - let reservation_end = Ipv4Addr::from( - self.network_base - .checked_add(reservation_end_host) - .ok_or_else(|| anyhow::anyhow!("Reservation IP {} exceeds subnet", ip))?, - ); - - Ok(DhcpsLease::from_range(ip, reservation_end)) - } - - fn restore_default(&mut self) -> anyhow::Result<()> { - if self.active.is_some() { - let dynamic_start_host = self - .next_dynamic_host - .clamp(DYNAMIC_POOL_START, self.dynamic_end_host); - let dynamic_start = Ipv4Addr::from( - self.network_base - .checked_add(dynamic_start_host) - .ok_or_else(|| anyhow::anyhow!("Dynamic start exceeds subnet bounds"))?, - ); - let dynamic_end = Ipv4Addr::from( - self.network_base - .checked_add(self.dynamic_end_host) - .ok_or_else(|| anyhow::anyhow!("Dynamic end exceeds subnet bounds"))?, - ); - self.default = DhcpsLease::from_range(dynamic_start, dynamic_end); - let (start, end) = lease_range(&self.default); - info!("Restoring default DHCP pool {} – {}", start, end); - set_dhcp_lease(self.handle, &self.default)?; - } - self.active = None; - Ok(()) - } - - fn activate_next_in_queue(&mut self) -> anyhow::Result<()> { - while let Some(next_mac) = self.queue.pop_front() { - if let Some(&ip) = DHCP_RESERVATIONS.get(&next_mac) { - self.apply_override(next_mac, ip)?; - break; - } - } - Ok(()) - } - - fn host_id_from_ip(&self, ip: Ipv4Addr) -> Option { - let ip_u32 = u32::from(ip); - if (ip_u32 & self.netmask) != self.network_base { - return None; - } - ip_u32 - .checked_sub(self.network_base) - .filter(|host| *host >= DYNAMIC_POOL_START && *host <= self.dynamic_end_host) - } - - fn recalculate_dynamic_cursor(&mut self) { - let pool_size = self - .dynamic_end_host - .saturating_sub(DYNAMIC_POOL_START) - .saturating_add(1); - - if pool_size == 0 { - self.next_dynamic_host = DYNAMIC_POOL_START; - return; - } - - let mut candidate = self - .next_dynamic_host - .clamp(DYNAMIC_POOL_START, self.dynamic_end_host); - for _ in 0..pool_size { - if !self.active_dynamic_hosts.contains(&candidate) { - self.next_dynamic_host = candidate; - return; - } - candidate = if candidate >= self.dynamic_end_host { - DYNAMIC_POOL_START - } else { - candidate + 1 - }; - } - - // Pool fully occupied; fall back to the start so caller can decide how to handle exhaustion. - self.next_dynamic_host = DYNAMIC_POOL_START; - } -} - -fn set_dhcp_lease(handle: *mut sys::esp_netif_t, lease: &DhcpsLease) -> anyhow::Result<()> { - let stop_err = unsafe { sys::esp_netif_dhcps_stop(handle) }; - if stop_err != ESP_OK && stop_err != sys::ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED { - return Err(esp_error(stop_err)); - } - - let mut lease_copy = *lease; - let set_err = unsafe { - sys::esp_netif_dhcps_option( - handle, - sys::esp_netif_dhcp_option_mode_t_ESP_NETIF_OP_SET, - sys::esp_netif_dhcp_option_id_t_ESP_NETIF_REQUESTED_IP_ADDRESS, - &mut lease_copy as *mut _ as *mut c_void, - core::mem::size_of::() as u32, - ) - }; - - let start_err = unsafe { sys::esp_netif_dhcps_start(handle) }; - if start_err != ESP_OK && start_err != sys::ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED { - return Err(esp_error(start_err)); - } - - if set_err == ESP_OK { - Ok(()) - } else { - Err(esp_error(set_err)) - } -} - -fn lease_range(lease: &DhcpsLease) -> (Ipv4Addr, Ipv4Addr) { - (ip4_to_ipv4(lease.start_ip), ip4_to_ipv4(lease.end_ip)) -} - -fn fetch_ip_info(handle: *mut sys::esp_netif_t) -> anyhow::Result { - let mut info = sys::esp_netif_ip_info_t { - ip: sys::esp_ip4_addr_t { addr: 0 }, - netmask: sys::esp_ip4_addr_t { addr: 0 }, - gw: sys::esp_ip4_addr_t { addr: 0 }, - }; - let err = unsafe { sys::esp_netif_get_ip_info(handle, &mut info) }; - if err == ESP_OK { - Ok(info) - } else { - Err(esp_error(err)) - } -} - -fn ip4_from_ipv4(ip: Ipv4Addr) -> sys::ip4_addr_t { - let addr = if cfg!(target_endian = "little") { - u32::from_le_bytes(ip.octets()) - } else { - u32::from_be_bytes(ip.octets()) - }; - sys::ip4_addr_t { addr } -} - -fn ip4_to_ipv4(ip: sys::ip4_addr_t) -> Ipv4Addr { - let bytes = if cfg!(target_endian = "little") { - ip.addr.to_le_bytes() - } else { - ip.addr.to_be_bytes() - }; - Ipv4Addr::new(bytes[0], bytes[1], bytes[2], bytes[3]) -} - -fn esp_ip4_to_ipv4(ip: sys::esp_ip4_addr_t) -> Ipv4Addr { - ip4_to_ipv4(sys::ip4_addr_t { addr: ip.addr }) -} - -fn esp_error(err: i32) -> anyhow::Error { - let name = unsafe { CStr::from_ptr(sys::esp_err_to_name(err)) }.to_string_lossy(); - anyhow::anyhow!("ESP error {err}: {name}") -} +include!(concat!(env!("OUT_DIR"), "/wifi_networks.rs")); +include!(concat!(env!("OUT_DIR"), "/dhcp_leases.rs")); // a global map MAC → human-readable name static MAC_NAMES: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); @@ -606,35 +209,11 @@ fn main() -> anyhow::Result<()> { wifi.connect()?; let ap = wifi.ap_netif(); + let ap_handle = NetifHandle(ap.handle()); - let dhcp_manager = if DHCP_RESERVATIONS.is_empty() { - None - } else { - match DhcpReservationManager::new(ap.handle()) { - Ok(manager) => Some(Arc::new(Mutex::new(manager))), - Err(err) => { - warn!( - "Failed to initialize static DHCP reservations: {:?}. Reservations disabled.", - err - ); - None - } - } - }; + let dhcp_state = Arc::new(Mutex::new(init_dhcp_state(ap_handle))); - match dhcp_manager.as_ref() { - Some(_) => info!( - "Static DHCP reservations enabled for {} device(s)", - DHCP_RESERVATIONS.len() - ), - None if DHCP_RESERVATIONS.is_empty() => info!("No static DHCP reservations configured"), - None => info!( - "Static DHCP reservations currently disabled ({} reservation(s) configured)", - DHCP_RESERVATIONS.len() - ), - } - - let manager_for_wifi = dhcp_manager.as_ref().map(Arc::clone); + let dhcp_state_for_wifi = Arc::clone(&dhcp_state); let client_ips_for_wifi = Arc::clone(&client_ips); let led_for_wifi = Arc::clone(&led); let _wifi_subscription = @@ -685,14 +264,12 @@ fn main() -> anyhow::Result<()> { map.insert(mac, prev_ip); } } - if let Some(manager) = manager_for_wifi.as_ref() { - if let Ok(mut guard) = manager.lock() { - if let Err(err) = guard.handle_sta_connected(mac) { - warn!( - "Failed to schedule DHCP reservation for {}: {:?}", - format_mac(&mac), - err - ); + if DHCP_RESERVATIONS.contains_key(&mac) { + if let Ok(mut guard) = dhcp_state_for_wifi.lock() { + if ensure_dhcp_state(&mut guard, ap_handle) { + if let Some(state) = guard.as_ref() { + state.touch_static_lease(&mac); + } } } } @@ -709,22 +286,23 @@ fn main() -> anyhow::Result<()> { last.insert(mac, ip); } } - if let Some(manager) = manager_for_wifi.as_ref() { - if let Ok(mut guard) = manager.lock() { - if let Err(err) = guard.handle_sta_disconnected(mac) { - warn!( - "Failed to handle disconnect for {}: {:?}", - format_mac(&mac), - err - ); - } + } + WifiEvent::ApStarted => { + if let Ok(mut guard) = dhcp_state_for_wifi.lock() { + *guard = init_dhcp_state(ap_handle); + } + } + WifiEvent::ApStopped => { + if let Ok(mut guard) = dhcp_state_for_wifi.lock() { + if guard.take().is_some() { + info!("Static DHCP reservations suspended (SoftAP stopped)"); } } } _ => {} })?; - let dhcp_manager_for_ip = dhcp_manager.clone(); + let dhcp_state_for_ip = Arc::clone(&dhcp_state); let client_ips_for_ip = Arc::clone(&client_ips); let _ip_subscription = sysloop.subscribe::(move |event: IpEvent| { if let IpEvent::ApStaIpAssigned(assignment) = event { @@ -738,13 +316,13 @@ fn main() -> anyhow::Result<()> { mac_str.to_lowercase() ); - if let Some(manager) = dhcp_manager_for_ip.as_ref() { - if let Ok(mut guard) = manager.lock() { - if let Err(err) = guard.handle_ip_assigned(mac, ip) { - warn!( - "Failed to finalize DHCP reservation for {}: {:?}", - mac_str, err - ); + if let Ok(mut guard) = dhcp_state_for_ip.lock() { + if ensure_dhcp_state(&mut guard, ap_handle) { + if let Some(state) = guard.as_ref() { + state.clamp_dynamic_cursor(); + if DHCP_RESERVATIONS.contains_key(&mac) { + state.touch_static_lease(&mac); + } } } } @@ -779,7 +357,7 @@ fn main() -> anyhow::Result<()> { let led_task = led.clone(); thread::Builder::new() .name("client_blink".into()) - .stack_size(2048) + .stack_size(8192) .spawn(move || { loop { if CLIENT_GOT_CONNECTED.swap(false, Ordering::SeqCst) { @@ -801,7 +379,7 @@ fn main() -> anyhow::Result<()> { const RSSI_COLLECTION_INTERVAL_MS: u32 = 1_000; thread::Builder::new() .name("sta_rssi_logger".into()) - .stack_size(4096) + .stack_size(12288) .spawn(move || { let mut rssi_stats: HashMap<[u8; 6], RssiRange> = HashMap::new(); let mut last_table = Instant::now(); @@ -947,6 +525,58 @@ fn collect_sta_snapshots( } } +fn init_dhcp_state(handle: NetifHandle) -> Option { + if DHCP_RESERVATIONS.is_empty() { + info!("No static DHCP reservations configured"); + return None; + } + + match DhcpServerState::new(handle.0) { + Ok(state) => { + info!( + "Static DHCP reservations enabled for {} device(s)", + DHCP_RESERVATIONS.len() + ); + Some(state) + } + Err(err) => { + warn!( + "Failed to initialize static DHCP reservations: {:?}. Reservations disabled.", + err + ); + None + } + } +} + +fn ensure_dhcp_state(state: &mut Option, handle: NetifHandle) -> bool { + if state.is_some() { + return true; + } + + if DHCP_RESERVATIONS.is_empty() { + return false; + } + + match DhcpServerState::new(handle.0) { + Ok(new_state) => { + info!( + "Static DHCP reservations re-enabled for {} device(s)", + DHCP_RESERVATIONS.len() + ); + *state = Some(new_state); + true + } + Err(err) => { + warn!( + "Failed to initialize static DHCP reservations: {:?}. Reservations disabled.", + err + ); + false + } + } +} + pub fn enable_nat(ap_netif_handle: &EspNetif) -> anyhow::Result<()> { info!( "Attempting to enable NAPT on netif handle: {:?}", From 57a3959e479b5184391f62b6b3853e92f7463125 Mon Sep 17 00:00:00 2001 From: Lukasz Gintowt Date: Thu, 30 Oct 2025 15:40:58 +0100 Subject: [PATCH 5/7] The IP-assignment handler now detects conflicts or reservation mismatches --- readme.md | 1 + src/main.rs | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/readme.md b/readme.md index c98b3f5..d8f3c05 100644 --- a/readme.md +++ b/readme.md @@ -38,6 +38,7 @@ This project provides two binaries: - **Feature flag**: `--features esp32s3` - **Architecture**: Xtensa LX7 dual-core @ 240 MHz - **Memory**: 512 KB SRAM, 384 KB ROM +- **Measured Wi-Fi throughput (STA mode)**: ~4.5 Mbps down / ~5 Mbps up ### Key Differences | Feature | ESP32-C6 | ESP32-C3 | ESP32-S3 | diff --git a/src/main.rs b/src/main.rs index c0e1aba..29c6d5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ use std::time::{Duration, Instant}; use sys::esp_netif_napt_enable; use dhcp::{DhcpServerState, DHCP_RESERVATIONS}; +use esp_idf_sys::esp_wifi_deauth_sta; #[derive(Clone, Copy)] struct NetifHandle(*mut sys::esp_netif_t); @@ -42,6 +43,7 @@ include!(concat!(env!("OUT_DIR"), "/dhcp_leases.rs")); static MAC_NAMES: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); static LAST_KNOWN_IPS: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); +static STA_AIDS: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); // Fresh pool of 100 names, regenerated every boot static NAME_POOL: Lazy>> = Lazy::new(|| { @@ -254,6 +256,9 @@ fn main() -> anyhow::Result<()> { } WifiEvent::ApStaConnected(conn) => { let mac = conn.mac(); + if let Ok(mut aids) = STA_AIDS.lock() { + aids.insert(mac, u16::from(conn.aid())); + } let prev_ip = LAST_KNOWN_IPS .lock() .ok() @@ -276,6 +281,9 @@ fn main() -> anyhow::Result<()> { } WifiEvent::ApStaDisconnected(disc) => { let mac = disc.mac(); + if let Ok(mut aids) = STA_AIDS.lock() { + aids.remove(&mac); + } let previous_ip = if let Ok(mut map) = client_ips_for_wifi.lock() { map.remove(&mac) } else { @@ -327,9 +335,7 @@ fn main() -> anyhow::Result<()> { } } - if let Ok(mut map) = client_ips_for_ip.lock() { - map.insert(mac, ip); - } + resolve_ip_conflicts(&mac, ip, &client_ips_for_ip, &dhcp_state_for_ip); if let Ok(mut last) = LAST_KNOWN_IPS.lock() { last.insert(mac, ip); } @@ -525,6 +531,96 @@ fn collect_sta_snapshots( } } +fn resolve_ip_conflicts( + new_mac: &[u8; 6], + new_ip: Ipv4Addr, + client_ips: &Arc>>, + dhcp_state: &Arc>>, +) { + if let Some(reserved_ip) = DHCP_RESERVATIONS.get(new_mac) { + if *reserved_ip != new_ip { + warn!( + "Reservation mismatch for {}: expected {}, got {}. Forcing reconnect.", + format_mac(new_mac), + reserved_ip, + new_ip + ); + if let Ok(mut guard) = dhcp_state.lock() { + if let Some(state) = guard.as_ref() { + state.enforce_static_ip(new_mac); + } + } + deauth_mac(new_mac); + if let Ok(mut map) = client_ips.lock() { + map.remove(new_mac); + } + return; + } + } + + let mut conflict_mac: Option<[u8; 6]> = None; + if let Ok(map) = client_ips.lock() { + for (mac, ip) in map.iter() { + if mac != new_mac && *ip == new_ip { + conflict_mac = Some(*mac); + break; + } + } + } + + if let Some(conflict) = conflict_mac { + let conflict_reserved = DHCP_RESERVATIONS.contains_key(&conflict); + let new_reserved = DHCP_RESERVATIONS.contains_key(new_mac); + let conflict_ip_reserved = DHCP_RESERVATIONS + .get(new_mac) + .map(|reserved| *reserved == new_ip) + .unwrap_or(false); + + let target = if new_reserved && !conflict_reserved { + conflict + } else if conflict_reserved && !conflict_ip_reserved { + *new_mac + } else if !new_reserved && conflict_reserved { + *new_mac + } else { + conflict + }; + + if let Some(aid) = STA_AIDS + .lock() + .ok() + .and_then(|map| map.get(&target).copied()) + { + warn!( + "Deauthenticating {} to resolve IP {} conflict", + format_mac(&target), + new_ip + ); + unsafe { + let err = esp_wifi_deauth_sta(aid); + if err != sys::ESP_OK { + warn!( + "Failed to deauth {} (AID {}): {:?}", + format_mac(&target), + aid, + err + ); + } + } + } else { + warn!( + "Unable to deauth {} for IP {} conflict (missing AID entry)", + format_mac(&target), + new_ip + ); + } + } + + if let Ok(mut map) = client_ips.lock() { + map.insert(*new_mac, new_ip); + } +} + fn init_dhcp_state(handle: NetifHandle) -> Option { if DHCP_RESERVATIONS.is_empty() { info!("No static DHCP reservations configured"); From 00f07435e9c9ef4c7fae380a5dec88d11f2e6062 Mon Sep 17 00:00:00 2001 From: Lukasz Gintowt Date: Thu, 30 Oct 2025 16:13:35 +0100 Subject: [PATCH 6/7] fix deautch method missing --- src/main.rs | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index 29c6d5f..620abcc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -545,11 +545,6 @@ fn resolve_ip_conflicts( reserved_ip, new_ip ); - if let Ok(mut guard) = dhcp_state.lock() { - if let Some(state) = guard.as_ref() { - state.enforce_static_ip(new_mac); - } - } deauth_mac(new_mac); if let Ok(mut map) = client_ips.lock() { map.remove(new_mac); @@ -621,6 +616,34 @@ fn resolve_ip_conflicts( } } +fn deauth_mac(mac: &[u8; 6]) { + let aid_opt = STA_AIDS.lock().ok().and_then(|map| map.get(mac).copied()); + if let Some(aid) = aid_opt { + unsafe { + let err = esp_wifi_deauth_sta(aid); + if err != sys::ESP_OK { + warn!( + "Failed to deauth {} (AID {}): {:?}", + format_mac(mac), + aid, + err + ); + } else { + info!( + "Deauthenticated {} (AID {}) to force DHCP renewal", + format_mac(mac), + aid + ); + } + } + } else { + warn!( + "Unable to deauth {} – missing association ID", + format_mac(mac) + ); + } +} + fn init_dhcp_state(handle: NetifHandle) -> Option { if DHCP_RESERVATIONS.is_empty() { info!("No static DHCP reservations configured"); From d77352b9dd865d0f5296ad562d85a815836f3f6c Mon Sep 17 00:00:00 2001 From: Lukasz Gintowt Date: Fri, 31 Oct 2025 13:03:20 +0100 Subject: [PATCH 7/7] fix warnings --- src/main.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 620abcc..78268c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -335,7 +335,7 @@ fn main() -> anyhow::Result<()> { } } - resolve_ip_conflicts(&mac, ip, &client_ips_for_ip, &dhcp_state_for_ip); + resolve_ip_conflicts(&mac, ip, &client_ips_for_ip); if let Ok(mut last) = LAST_KNOWN_IPS.lock() { last.insert(mac, ip); } @@ -535,7 +535,6 @@ fn resolve_ip_conflicts( new_mac: &[u8; 6], new_ip: Ipv4Addr, client_ips: &Arc>>, - dhcp_state: &Arc>>, ) { if let Some(reserved_ip) = DHCP_RESERVATIONS.get(new_mac) { if *reserved_ip != new_ip {