diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index dfdc02158a..426861cbf7 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -53,9 +53,14 @@ def AUTO_LOAD() -> list[str]: # ota.web_server's dependency on web_server_base would not be satisfied in time. auto_load = ["json", "web_server_base"] # The AP mode DNS server (web_server_base/dns_server_esp32_idf) uses socket; only - # configs with a WiFi access point can end up in AP mode. + # configs with a WiFi access point can end up in AP mode. CORE.raw_config is set + # after package merging, so a wifi block from a package is visible here. wifi = CORE.raw_config.get(CONF_WIFI) if CORE.raw_config else None - if CORE.is_esp32 and (not isinstance(wifi, dict) or CONF_AP in wifi): + if ( + CORE.is_esp32 + and wifi is not None + and (not isinstance(wifi, dict) or CONF_AP in wifi) + ): auto_load.append("socket") return auto_load @@ -384,7 +389,8 @@ def _final_validate_ap_mode(config: ConfigType) -> None: if CONF_LOCAL not in config: _LOGGER.info( "WiFi is AP only: embedding the web interface in the firmware " - "(local: true) and serving it as a captive portal on the access point. " + "(local: true, roughly 13 KB of flash for version 2, 78 KB for " + "version 3) and serving it as a captive portal on the access point. " "Set 'local: false' to load it from the internet instead." ) elif wifi_is_ap_only(wifi_config) and not serve_local(config, wifi_config): diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 0c004715bc..ccb8680f54 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -452,9 +452,11 @@ void WebServer::handle_not_found_(AsyncWebServerRequest *request) { // to the real page. A redirect rather than the page itself, because the interface resolves // its /events and REST paths relative to the page URL. if (this->dns_.is_running() && request->method() == HTTP_GET) { - char location[7 + network::IP_ADDRESS_BUFFER_SIZE]; + // Captive mode requires port 80 (enforced at validation), so no port suffix is needed. + char location[7 + network::IP_ADDRESS_BUFFER_SIZE + 1]; size_t pos = buf_append_str(location, sizeof(location), 0, "http://"); wifi::global_wifi_component->wifi_soft_ap_ip().str_to(location + pos); + buf_append_str(location, sizeof(location), strlen(location), "/"); request->redirect(location); return; } diff --git a/tests/components/web_server/common-ap-fallback.yaml b/tests/components/web_server/common-ap-fallback.yaml new file mode 100644 index 0000000000..3a074f4a61 --- /dev/null +++ b/tests/components/web_server/common-ap-fallback.yaml @@ -0,0 +1,11 @@ +# STA with AP fallback plus local: true opts the fallback into captive AP mode; exercises +# the runtime start (fallback branch in wifi loop) and end (on STA connect) paths. +wifi: + ssid: MySSID + password: password1 + ap: + ssid: "ESPHome-Test" + password: "Test1234!" + +web_server: + local: true diff --git a/tests/components/web_server/test-ap-fallback.esp32-idf.yaml b/tests/components/web_server/test-ap-fallback.esp32-idf.yaml new file mode 100644 index 0000000000..172fb7de71 --- /dev/null +++ b/tests/components/web_server/test-ap-fallback.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + web_server: !include common-ap-fallback.yaml