Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

ESP32 Embassy Integration -- Web Server

This page explains how Embassy ties the ESP32-C3 Wi-Fi web server together. It assumes you are comfortable with basic Rust (ownership, traits, match) but new to embedded async. We will work from the bottom up: what the runtime is, how tasks work, and how the network stack rides on top.

A networked device has several things to do at once: keep the Wi-Fi link alive, process incoming and outgoing IP packets, and answer HTTP requests. On a desktop you would reach for threads. A microcontroller like the ESP32-C3 has a single core and very little RAM, so spinning up OS threads is not an option.

The classic embedded answer is a hand-written superloop (one big loop that polls everything in turn). That works but becomes tangled fast, because every task has to be chopped into tiny non-blocking steps and you manage all the state machines yourself.

Embassy offers a third way: cooperative async multitasking. You write each job as a normal-looking async fn that .awaits when it has nothing to do. A small scheduler (the executor) runs whichever task is ready and parks the rest. No threads, no superloop, and the code reads top-to-bottom.

Embassy is a family of crates. Three of them appear here, plus the Espressif-specific glue:

CrateRole
embassy-executorThe async runtime: schedules and runs tasks.
embassy-timeAsync timers — Timer::after(...).await instead of a blocking delay.
embassy-netA full TCP/IP stack (built on smoltcp) exposing async sockets.
esp-rtosEspressif’s scheduler that hosts the executor on the chip.
esp-radioThe Wi-Fi driver, which plugs into embassy-net as a network interface.

The key idea: Embassy defines the async machinery, and the esp-* crates provide the hardware drivers that machinery drives.

When you write async fn, the compiler rewrites it into a state machine that implements the Future trait. Each .await is a point where the state machine can pause and hand control back to the executor. On a desktop, an async runtime like Tokio polls those futures. Embassy is that runtime, shrunk to fit a microcontroller — no heap allocation required for the tasks themselves, and a footprint measured in kilobytes.

The executor’s loop is, in essence:

  1. Run every task that is ready until each one hits an .await it cannot yet pass.
  2. When all tasks are parked, put the CPU to sleep.
  3. Wake when an interrupt (a timer firing, a packet arriving) signals that a parked task can make progress, and go back to step 1.

That sleep step is why async is a good fit for battery-powered devices: the chip idles instead of spinning.

A task is an async fn marked with the #[embassy_executor::task] attribute:

#[embassy_executor::task]
async fn net_task(mut runner: Runner<'static, esp_radio::wifi::Interface<'static>>) -> ! {
runner.run().await
}

Two constraints fall out of how Embassy allocates tasks:

  • Arguments must be 'static. A task may run for the entire life of the program, so it cannot borrow anything shorter-lived than that. This is why the parameters carry 'static lifetimes.
  • Tasks are not generic. Each task compiles to a fixed-size slot, so the argument types must be concrete. That is why we had to find the exact type (Interface<'static>) rather than using a generic parameter.

The entry point receives a Spawner, which is the handle for starting tasks:

spawner.spawn(net_task(runner).unwrap());
spawner.spawn(wifi_task(wifi_controller).unwrap());

Calling net_task(runner) does not run it — it produces a spawn token describing the task and capturing its arguments. spawn then hands that token to the executor.

The program runs four concurrent flows. Splitting the work this way is the heart of the design — each concern owns its resource and its own loop:

graph TD
M[main: bring-up then idle] -->|spawns| N[net_task: packet pump]
M -->|spawns| W[wifi_task: keep link up]
M -->|spawns| H[web_task: serve HTTP]
W -.->|provides link| N
N -.->|provides sockets| H
  • main does hardware bring-up, waits for the network to come up, spawns the others, then idles.
  • net_task owns the Runner and pumps packets forever. Nothing on the network works without it.
  • wifi_task owns the WifiController, connects, and reconnects on drop.
  • web_task owns the listening socket and answers HTTP requests.

They never block each other. While web_task is parked in accept().await waiting for a browser, net_task is free to process DHCP renewals and wifi_task sits parked until the link state changes.

embassy_net::new(...) returns two halves with very different jobs:

let (stack, runner) = embassy_net::new(
interfaces.station, // the Wi-Fi data interface
net_config, // DHCP configuration
STACK_RESOURCES.init(StackResources::new()), // 'static socket storage
seed, // RNG seed for TCP
);
  • runner is the engine. It does the actual packet work and must be driven by net_task.
  • stack is a lightweight, Copy handle. You give a copy to any task that needs to open a socket or read the IP configuration — that is exactly what we pass to web_task.

This split is deliberate: the heavy state lives behind runner in one place, while the cheap stack handle can be shared freely.

StackResources holds the stack’s socket bookkeeping and must live for 'static. You cannot satisfy that with an ordinary local variable. StaticCell reserves the storage statically and lets you initialise it exactly once at runtime:

static STACK_RESOURCES: StaticCell<StackResources<3>> = StaticCell::new();
// ...
STACK_RESOURCES.init(StackResources::new())

The <3> sizes it for up to three concurrent sockets. Calling .init() a second time would panic — which is the safety guarantee that lets you hand out a 'static reference soundly.

Awaiting network readiness, the Embassy way

Section titled “Awaiting network readiness, the Embassy way”

An earlier version of this code polled for a DHCP lease in a loop:

// Non-idiomatic: wakes every 500 ms just to check.
loop {
if let Some(cfg) = stack.config_v4() { break; }
Timer::after(Duration::from_millis(500)).await;
}

Embassy lets you express the same intent as a single suspension:

// Idiomatic: parked until the stack is actually configured.
stack.wait_config_up().await;

The second form is not just shorter. The polling loop wakes the CPU twenty times before the lease arrives; wait_config_up() parks the task entirely and the executor only wakes it when the condition is genuinely met. Preferring event-driven awaits over timed polling is the central habit of writing good Embassy code.

web_task shows the async socket API in miniature:

let mut socket = TcpSocket::new(stack, &mut rx_buffer, &mut tx_buffer);
socket.set_timeout(Some(Duration::from_secs(10)));
socket.accept(80).await?; // park until a client connects
let _ = socket.read(&mut buf).await; // park until the request arrives
socket.write_all(response).await?; // park until the bytes are sent
socket.flush().await;
socket.close();

Every line that touches the network awaits. At each await the task yields, so net_task keeps the packets flowing underneath and the device stays responsive. The write_all and flush methods come from the embedded_io_async::Write trait — the async sibling of the familiar std::io::Write — which is why that trait is imported even though it is never named directly.

The loop builds a fresh socket per connection, serves one client, then starts over. That is the simplest correct structure; a busier server would pre-allocate several sockets and spawn a handler task per connection, all using these same primitives.

  • Embassy gives you threads-like concurrency on a single core with no OS, by scheduling async tasks cooperatively.
  • Each concern becomes a task that owns its resource and loops forever; tasks communicate by sharing cheap handles like stack, not by sharing mutable state.
  • The 'static, non-generic, concrete-type constraints on tasks are a direct consequence of how the executor allocates them — work with the compiler errors, they point straight at the fix.
  • Prefer event-driven awaits (wait_config_up, accept) over timed polling. That is what keeps the chip idle and the code clean.

Important Notes:

  • The state is global, not per-visitor. Since dark lives in the server, if two students load the page at once they share one theme — toggling on one phone changes it for everyone on next refresh. Same is already true of the LED, which is correct there (one physical LED) but is a nice illustration of “server state vs. per-client state” for the theme. The “proper” per-browser way would be a cookie or localStorage + JS, but that’s more complexity than this demo needs.
  • Two independent toggles, same pattern — a clean way to show students how routing scales: each button is just a GET form to its own path, and the match arm decides what happens.
main.rs
//! EEN1097 — ESP32-C3 async Wi-Fi station with a minimal HTTP server.
//!
//! Stack: esp-hal (HAL) + esp-radio (Wi-Fi) + esp-rtos (scheduler) +
//! embassy-executor (async runtime) + embassy-net (TCP/IP).
//!
//! Behaviour: connect to a Wi-Fi access point, obtain an IPv4 address via
//! DHCP, then listen on TCP port 80 and serve a fixed HTML page to every
//! client that connects.
// no_std means we do not link the Rust standard library: there is no operating
// system underneath us, so things like std::println, heap-by-default, and OS
// threads are not available. no_main means we do not use the usual Rust entry
// point; the esp_rtos::main attribute below provides our real entry point.
#![no_std]
#![no_main]
#![deny(
clippy::mem_forget,
reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
holding buffers for the duration of a data transfer."
)]
#![deny(clippy::large_stack_frames)]
// Bring the types we use into scope. Embassy is the async runtime, embassy_net
// is the TCP/IP stack, esp_hal is the chip's hardware abstraction layer, and
// esp_radio drives the Wi-Fi peripheral.
use embassy_executor::Spawner;
use embassy_net::tcp::TcpSocket;
use embassy_net::{Config, Runner, Stack, StackResources};
use embassy_time::{Duration, Timer};
use embedded_io_async::Write;
use esp_hal::clock::CpuClock;
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::timer::timg::TimerGroup;
use static_cell::StaticCell;
// We have no standard library, but we still want a heap (for String, format!,
// etc.). The alloc crate provides those collection types; the actual allocator
// is installed further down with esp_alloc.
extern crate alloc;
use alloc::string::ToString;
// Pulling in esp_backtrace gives us a real panic handler that prints a backtrace
// over the serial link when something goes wrong. Without it the program would
// not even link, because no_std has no default panic handler.
use esp_backtrace as _;
// The network stack needs some memory to track its sockets. We reserve room for
// up to 3 sockets here. StaticCell lets us hand out a 'static reference to this
// storage exactly once, which is what embassy_net::new asks for.
static STACK_RESOURCES: StaticCell<StackResources<3>> = StaticCell::new();
// Emits the application descriptor that the ESP-IDF bootloader looks for when it
// decides whether the flashed image is a valid app to boot.
esp_bootloader_esp_idf::esp_app_desc!();
#[allow(
clippy::large_stack_frames,
reason = "it's not unusual to allocate larger buffers etc. in main"
)]
// This is the program's true starting point. The esp_rtos::main attribute sets
// up the async executor and then calls this function. It is async, and it never
// returns (the -> ! type), because an embedded program runs forever.
#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
// Start logging at Info level so our log::info! lines appear on the serial
// monitor. We set the level explicitly rather than reading it from an
// environment variable, so logging always works without extra setup.
esp_println::logger::init_logger(log::LevelFilter::Info);
// Configure the chip to run at its maximum CPU clock, then initialise the
// hardware. peripherals is our single handle to every piece of hardware on
// the chip; we take ownership of individual pins and peripherals from it.
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let peripherals = esp_hal::init(config);
// Onboard blue LED on GPIO8. It is active-low on the SuperMini, so
// Level::High = OFF at boot. (GPIO8 is also a strapping pin: leaving it
// high at reset keeps the chip in normal flash-boot mode.)
let led = Output::new(peripherals.GPIO8, Level::High, OutputConfig::default());
// Claim these pins so they are owned here and cannot be used by accident
// elsewhere. They are spare for now; consume them with let _ = to silence
// unused-variable warnings.
let _ = peripherals.GPIO11;
let _ = peripherals.GPIO12;
let _ = peripherals.GPIO13;
let _ = peripherals.GPIO14;
let _ = peripherals.GPIO15;
let _ = peripherals.GPIO16;
let _ = peripherals.GPIO17;
// Install the heap allocator. This is what makes String, Vec, and format!
// usable. The size is how many bytes of RAM we set aside for the heap.
esp_alloc::heap_allocator!(#[esp_hal::ram(reclaimed)] size: 66320);
// The async runtime needs a hardware timer to schedule tasks, plus a
// software interrupt to wake the executor. Start the runtime with both.
let timg0 = TimerGroup::new(peripherals.TIMG0);
let sw_interrupt =
esp_hal::interrupt::software::SoftwareInterruptControl::new(peripherals.SW_INTERRUPT);
esp_rtos::start(timg0.timer0, sw_interrupt.software_interrupt0);
// Initialise the Wi-Fi peripheral. This gives us a controller (used to set
// the SSID and connect) and the network interfaces (the data path that the
// TCP/IP stack runs on top of).
let (wifi_controller, interfaces) = esp_radio::wifi::new(peripherals.WIFI, Default::default())
.expect("Failed to initialize Wi-Fi controller");
// Ask for an IP address automatically over DHCP, the way a laptop does when
// it joins a network.
let net_config = Config::dhcpv4(Default::default());
// The TCP/IP stack wants a random seed (used for things like initial
// sequence numbers). Build a 64-bit seed from the hardware random generator.
let rng = esp_hal::rng::Rng::new();
let seed = (rng.random() as u64) << 32 | (rng.random() as u64);
// Create the network stack. It returns two halves: stack, the handle we use
// to open sockets and query our IP, and runner, the background worker that
// actually moves packets and must be polled continuously.
let (stack, runner) = embassy_net::new(
interfaces.station,
net_config,
STACK_RESOURCES.init(StackResources::new()),
seed,
);
// Hand the two background workers off to the executor so they run
// concurrently with main. net_task pumps the stack; wifi_task keeps us
// connected to the access point.
spawner.spawn(net_task(runner).unwrap());
spawner.spawn(wifi_task(wifi_controller).unwrap());
log::info!("Waiting for network link and DHCP lease...");
// Idiomatic Embassy: suspend until the stack is configured, no polling.
// Execution pauses here, freeing the CPU, and resumes once we have a lease.
stack.wait_config_up().await;
// Print the IP address we were given, so we know where to point a browser.
if let Some(config) = stack.config_v4() {
log::info!("Got IP: {}", config.address);
}
log::info!("Network configuration complete. Starting web server.");
// main hands the stack + LED to the web server task and idles.
spawner.spawn(web_task(stack, led).unwrap());
// main has nothing left to do, but it must never return. Sleep forever in
// long naps; the real work now happens in the spawned tasks.
loop {
Timer::after(Duration::from_secs(60)).await;
}
}
// BACKGROUND TASKS
// Task 1. Drives the network stack. This call never returns: it loops forever handling
// the low-level packet work so the rest of the program can use TCP.
#[embassy_executor::task]
async fn net_task(mut runner: Runner<'static, esp_radio::wifi::Interface<'static>>) -> ! {
runner.run().await
}
// Task 2. Connects to the access point and keeps us connected, reconnecting if the link
// drops. Edit the ssid and password below to match your own network.
#[embassy_executor::task]
async fn wifi_task(mut controller: esp_radio::wifi::WifiController<'static>) {
let ssid = "Tesla"; // use your real, correctly-cased SSID
let password = "m0saicmWiFi";
// Describe ourselves as a station (a normal client joining an access point)
// and apply that configuration to the radio.
let station_config = esp_radio::wifi::sta::StationConfig::default()
.with_ssid(ssid)
.with_password(password.to_string());
let radio_config = esp_radio::wifi::Config::Station(station_config);
controller.set_config(&radio_config).unwrap();
log::info!("Radio configuration set, attempting connection...");
// Keep trying to connect. On success we then wait for a disconnect and loop
// round to reconnect; on failure we wait a few seconds and try again.
loop {
match controller.connect_async().await {
Ok(_) => log::info!("Wi-Fi connected!"),
Err(e) => {
log::error!("Failed to connect to Wi-Fi: {:?}", e);
Timer::after(Duration::from_millis(5000)).await;
continue;
}
}
let _ = controller.wait_for_disconnect_async().await;
log::info!("Wi-Fi disconnected. Reconnecting...");
}
}
// Task 3. The web server. It serves one client at a time: accept a connection, read the
// request, update some state, send back a page, close, and repeat forever.
#[embassy_executor::task]
async fn web_task(stack: Stack<'static>, mut led: Output<'static>) -> ! {
// Receive and transmit buffers the socket uses to hold bytes in flight. We
// reuse the same two buffers for every connection.
let mut rx_buffer = [0u8; 1536];
let mut tx_buffer = [0u8; 1536];
// Page theme. true = dark, false = light. Flipped by the /theme route.
//
// Notice that this flag (and the LED itself) is held right here, inside the running web server
// task. It is an ordinary local variable, not a Rust global or static, yet it
// behaves as shared state for everyone. Because this single task serves every
// client one after another, there is exactly ONE copy of the theme and ONE
// physical LED, and all visitors share them. This is server-side state, not
// per-browser state: if two people open the page at the same time on their phones, toggling
// the theme on one phone changes what the other person sees on their next
// refresh. That is exactly right for the LED, because there is only one real
// LED on the board. For the theme it is a useful contrast: a production web
// site would normally remember each visitor's choice separately, using a
// cookie or the browser's local storage plus some JavaScript. We keep one
// shared value here on purpose, to keep the example simple.
let mut dark = true;
loop {
// Build a fresh socket for this connection and give it a 3 second
// idle timeout, so a client that connects but never sends data cannot
// tie up this single-task server for long.
let mut socket = TcpSocket::new(stack, &mut rx_buffer, &mut tx_buffer);
socket.set_timeout(Some(Duration::from_secs(3)));
log::info!("Listening on TCP :80 ...");
// Wait for a browser to connect on port 80. If accepting fails, log it
// and loop round to try again with a clean socket.
if let Err(e) = socket.accept(80).await {
log::warn!("Accept error: {:?}", e);
continue;
}
log::info!("Client connected from {:?}", socket.remote_endpoint());
// Read the whole request. Draining it fully matters: if unread bytes
// are still in the receive buffer when we close, smoltcp sends an RST
// instead of a clean close, which the browser reports as a reset and
// then retries -> slow refresh + errors. A GET has no body, so we read
// until the blank line that ends the headers ("\r\n\r\n").
let mut buf = [0u8; 1024];
let mut len = 0;
loop {
match socket.read(&mut buf[len..]).await {
Ok(0) => break,
Ok(n) => {
len += n;
if buf[..len].windows(4).any(|w| w == b"\r\n\r\n") || len == buf.len() {
break;
}
}
Err(_) => break,
}
}
// Pull the path out of the request. The first line looks like
// "GET /toggle? HTTP/1.1"; splitting on spaces and taking the second
// word gives the target. A GET form with no fields appends a trailing
// "?", so we drop anything from the "?" onwards before routing.
let req = core::str::from_utf8(&buf[..len]).unwrap_or("");
let target = req.split_whitespace().nth(1).unwrap_or("/");
let path = target.split('?').next().unwrap_or("/");
// Routing. This is where each request is turned into an action. Notice
// the pattern: every button on the page is just an HTML form that does a
// GET to its own path, and every path gets exactly one arm in this match.
//
// The LED toggle and the theme toggle are two completely independent
// features, but they are built the same way: a path arrives, we update a
// piece of state, and later we render a page that reflects that state.
// Adding a third button would mean adding one more arm here and one more
// form in the HTML below, and nothing else. That is the point: the design
// scales by repeating the same simple pattern, one arm per action, all
// sharing the same read-route-render machinery.
//
// The LED is active-low, so low = ON and high = OFF.
match path {
"/toggle" => led.toggle(),
"/on" => led.set_low(),
"/off" => led.set_high(),
"/theme" => dark = !dark,
_ => {}
}
// Turn the current state into the text and labels the page will show.
// is_set_low() is true when the LED is on, because it is active-low.
let is_on = led.is_set_low();
let state = if is_on { "ON" } else { "OFF" };
let action = if is_on { "Turn OFF" } else { "Turn ON" };
let theme = if dark { "dark" } else { "light" };
let theme_action = if dark { "Light mode" } else { "Dark mode" };
// Render the page reflecting the current state. The data-theme attribute
// and the dm body class hook into the stylesheet loaded from
// derekmolloy.ie, so the page picks up that site's light/dark palette.
// Each button is a GET form to its own path; clicking one performs that
// action on the server and then reloads the page with the new state.
let body = alloc::format!(
"<!DOCTYPE html><html data-theme=\"{theme}\"><head><meta name=\"viewport\" \
content=\"width=device-width, initial-scale=1\"><title>Derek's LED</title>\
<link rel=\"stylesheet\" href=\"https://derekmolloy.ie/assets/built/theme.css\"></head>\
<body class=\"dm\"><h1>Hello Derek from ESP32-C3</h1>\
<p>Onboard LED (GPIO8) is currently <b>{state}</b>.</p>\
<form action=\"/toggle\" method=\"get\">\
<button type=\"submit\" style=\"font-size:1.5rem;padding:0.6rem 1.4rem\">{action}</button>\
</form>\
<form action=\"/theme\" method=\"get\">\
<button type=\"submit\" style=\"font-size:1.5rem;padding:0.6rem 1.4rem;margin-top:0.6rem\">{theme_action}</button>\
</form>\
<p>Served by Embassy + esp-radio. See [\
<a href=\"https://derekmolloy.ie/\">derekmolloy.ie</a>]</p></body></html>"
);
// Build a tiny HTTP/1.0 response. Content-Length tells the browser how
// many body bytes to expect; Connection: close means we tear down the
// connection after this single request.
let header = alloc::format!(
"HTTP/1.0 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
// Send the header, then the body. If either write fails, log it but keep
// going; we will simply close and serve the next client.
if let Err(e) = socket.write_all(header.as_bytes()).await {
log::warn!("Write error: {:?}", e);
} else if let Err(e) = socket.write_all(body.as_bytes()).await {
log::warn!("Write error: {:?}", e);
}
// Make sure everything queued has actually been sent, then close.
let _ = socket.flush().await;
socket.close();
// Loop back, rebuild the socket, serve the next client.
}
}

Cargo.toml

Cargo.toml
[package]
edition = "2024"
name = "een1097-hello"
rust-version = "1.88"
version = "0.1.0"
[[bin]]
name = "een1097-hello"
path = "./src/bin/main.rs"
[dependencies]
esp-hal = { version = "~1.1.0", features = ["esp32c3", "unstable"] }
log = { version = "0.4.22" }
esp-rtos = { version = "0.3.0", features = [
"embassy",
"esp-alloc",
"esp-radio",
"esp32c3",
] }
esp-bootloader-esp-idf = { version = "0.5.0", features = ["esp32c3"] }
embassy-net = { version = "0.9.1", features = [
"dhcpv4",
"medium-ethernet",
"tcp",
"udp",
] }
embedded-io = "0.7.1"
embedded-io-async = "0.7.0"
esp-alloc = "0.10.0"
# for more networking protocol support see https://crates.io/crates/edge-net
embassy-executor = { version = "0.10.0", features = [] }
embassy-time = "0.5.0"
esp-radio = { version = "0.18.0", features = [
"esp-alloc",
"esp32c3",
"unstable",
"wifi",
] }
smoltcp = { version = "0.13.0", default-features = false, features = [
"medium-ethernet",
"multicast",
"proto-dhcpv4",
"proto-dns",
"proto-ipv4",
"socket-dns",
"socket-icmp",
"socket-raw",
"socket-tcp",
"socket-udp",
] }
critical-section = "1.2.0"
static_cell = "2.1.1"
esp-println = { version = "0.17.0", features = ["esp32c3", "log-04"] }
esp-backtrace = { version = "0.19.0", features = ["esp32c3", "panic-handler", "println"] }
# For fine tuning these settings, please refer to https://doc.rust-lang.org/cargo/reference/profiles.html
[profile.dev]
# The default debug profile is too slow and too big for resource-constrained devices.
# Always build with some optimizations enabled.
opt-level = "s"
[profile.release]
codegen-units = 1 # LLVM can perform better optimizations using a single thread
debug = 2 # prefer slower builds but better debugging experience
lto = 'fat'
opt-level = 's'
Terminal window
PS C:\rust\een1097-hello> cargo run
ESP-ROM:esp32c3-api1-20210207
Build:Feb 7 2021
rst:0x15 (USB_UART_CHIP_RESET),boot:0xd (SPI_FAST_FLASH_BOOT)
Saved PC:0x4038085e
SPIWP:0xee
mode:DIO, clock div:2
load:0x3fcd5820,len:0x15c4
load:0x403cbf10,len:0xc84
load:0x403ce710,len:0x2fd0
entry 0x403cbf1a
I (24) boot: ESP-IDF v5.5.1-838-gd66ebb86d2e 2nd stage bootloader
I (25) boot: compile time Nov 26 2025 12:25:17
I (25) boot: chip revision: v0.4
I (26) boot: efuse block revision: v1.3
I (30) boot.esp32c3: SPI Speed : 40MHz
I (34) boot.esp32c3: SPI Mode : DIO
I (37) boot.esp32c3: SPI Flash Size : 4MB
I (41) boot: Enabling RNG early entropy source...
I (46) boot: Partition Table:
I (48) boot: ## Label Usage Type ST Offset Length
I (54) boot: 0 nvs WiFi data 01 02 00009000 00006000
I (61) boot: 1 phy_init RF data 01 01 0000f000 00001000
I (67) boot: 2 factory factory app 00 00 00010000 003f0000
I (74) boot: End of partition table
I (77) esp_image: segment 0: paddr=00010020 vaddr=3c000020 size=19acch (105164) map
I (108) esp_image: segment 1: paddr=00029af4 vaddr=3fc89320 size=01b40h ( 6976) load
I (110) esp_image: segment 2: paddr=0002b63c vaddr=40380000 size=049dch ( 18908) load
I (116) esp_image: segment 3: paddr=00030020 vaddr=42020020 size=6836ch (426860) map
I (212) esp_image: segment 4: paddr=00098394 vaddr=403849dc size=04940h ( 18752) load
I (220) boot: Loaded app from partition at offset 0x10000
I (220) boot: Disabling RNG early entropy source...
INFO - Waiting for network link and DHCP lease...
INFO - Radio configuration set, attempting connection...
INFO - Wi-Fi connected!
INFO - Got IP: 192.168.1.115/24
INFO - Network configuration complete. Starting web server.
INFO - Listening on TCP :80 ...
INFO - Client connected from Some(Endpoint { addr: Ipv4(192.168.1.4), port: 12564 })
INFO - Listening on TCP :80 ...
INFO - Client connected from Some(Endpoint { addr: Ipv4(192.168.1.4), port: 39320 })
INFO - Listening on TCP :80 ...
INFO - Client connected from Some(Endpoint { addr: Ipv4(192.168.1.4), port: 40013 })
INFO - Listening on TCP :80 ...
INFO - Client connected from Some(Endpoint { addr: Ipv4(192.168.1.4), port: 51025 })
INFO - Listening on TCP :80 ...
INFO - Client connected from Some(Endpoint { addr: Ipv4(192.168.1.4), port: 57067 })
INFO - Listening on TCP :80 ...