mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 10:12:52 +00:00
[web_server] Self contained AP mode: embedded interface served as a captive portal (#18517)
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
"""Tests for the web_server AP mode helpers."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.web_server import (
|
||||
_final_validate_ap_mode,
|
||||
serve_captive,
|
||||
serve_local,
|
||||
)
|
||||
from esphome.const import (
|
||||
CONF_AP,
|
||||
CONF_LOCAL,
|
||||
CONF_NETWORKS,
|
||||
CONF_PORT,
|
||||
CONF_SSID,
|
||||
CONF_VERSION,
|
||||
CONF_WIFI,
|
||||
)
|
||||
import esphome.final_validate as fv
|
||||
|
||||
AP_ONLY = {CONF_AP: {}}
|
||||
AP_FALLBACK = {CONF_AP: {}, CONF_NETWORKS: [{CONF_SSID: "x"}]}
|
||||
STA_ONLY = {CONF_NETWORKS: [{CONF_SSID: "x"}]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("web_server_config", "wifi_config", "expected"),
|
||||
[
|
||||
# AP only: embed the interface, the AP has no internet.
|
||||
({CONF_VERSION: 2}, AP_ONLY, True),
|
||||
({CONF_VERSION: 3}, AP_ONLY, True),
|
||||
# Explicit setting always wins.
|
||||
({CONF_VERSION: 2, CONF_LOCAL: False}, AP_ONLY, False),
|
||||
({CONF_VERSION: 2, CONF_LOCAL: True}, STA_ONLY, True),
|
||||
# AP fallback, no AP, no wifi, or version 1 (no local mode): hosted page.
|
||||
({CONF_VERSION: 2}, AP_FALLBACK, False),
|
||||
({CONF_VERSION: 2}, STA_ONLY, False),
|
||||
({CONF_VERSION: 2}, None, False),
|
||||
({CONF_VERSION: 1}, AP_ONLY, False),
|
||||
],
|
||||
)
|
||||
def test_serve_local(
|
||||
web_server_config: dict, wifi_config: dict | None, expected: bool
|
||||
) -> None:
|
||||
"""The interface is embedded for AP only WiFi unless local is set explicitly."""
|
||||
assert serve_local(web_server_config, wifi_config) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("web_server_config", "full_config", "expected"),
|
||||
[
|
||||
# AP only: local is implied, web_server is the captive portal.
|
||||
({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, True),
|
||||
# Captive portal probes only work on port 80.
|
||||
({CONF_VERSION: 2, CONF_PORT: 8080}, {CONF_WIFI: AP_ONLY}, False),
|
||||
# AP fallback needs an explicit local: true to be captive.
|
||||
({CONF_VERSION: 2}, {CONF_WIFI: AP_FALLBACK}, False),
|
||||
({CONF_VERSION: 2, CONF_LOCAL: True}, {CONF_WIFI: AP_FALLBACK}, True),
|
||||
# captive_portal owns the role when configured.
|
||||
({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY, "captive_portal": {}}, False),
|
||||
# No AP, no wifi, hosted page, or version 1: never captive.
|
||||
({CONF_VERSION: 2, CONF_LOCAL: True}, {CONF_WIFI: STA_ONLY}, False),
|
||||
({CONF_VERSION: 2, CONF_LOCAL: True}, {}, False),
|
||||
({CONF_VERSION: 2, CONF_LOCAL: False}, {CONF_WIFI: AP_ONLY}, False),
|
||||
({CONF_VERSION: 1}, {CONF_WIFI: AP_ONLY}, False),
|
||||
],
|
||||
)
|
||||
def test_serve_captive(
|
||||
web_server_config: dict, full_config: dict, expected: bool
|
||||
) -> None:
|
||||
web_server_config.setdefault(CONF_PORT, 80)
|
||||
assert serve_captive(web_server_config, full_config) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("web_server_config", "expect_warning"),
|
||||
[
|
||||
# Explicit local: false on an AP only device: the hosted page will stay blank.
|
||||
({CONF_VERSION: 2, CONF_PORT: 80, CONF_LOCAL: False}, True),
|
||||
# Default: embedded and captive, nothing to warn about.
|
||||
({CONF_VERSION: 2, CONF_PORT: 80}, False),
|
||||
],
|
||||
)
|
||||
def test_final_validate_ap_mode_warns_for_hosted_page(
|
||||
web_server_config: dict, expect_warning: bool, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
token = fv.full_config.set({"web_server": web_server_config, CONF_WIFI: AP_ONLY})
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_final_validate_ap_mode(web_server_config)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
assert ("stays blank" in caplog.text) is expect_warning
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("web_server_config", "wifi_config", "expected"),
|
||||
[
|
||||
# AP only: local is implied, so the port alone blocks captive mode.
|
||||
({CONF_VERSION: 2, CONF_PORT: 8080}, AP_ONLY, "on the access point"),
|
||||
# Fallback AP with local: true opted into captive mode; say why it is skipped.
|
||||
(
|
||||
{CONF_VERSION: 2, CONF_PORT: 8080, CONF_LOCAL: True},
|
||||
AP_FALLBACK,
|
||||
"on the fallback access point",
|
||||
),
|
||||
# Fallback AP without local: hosted page, nothing was promised.
|
||||
({CONF_VERSION: 2, CONF_PORT: 8080}, AP_FALLBACK, None),
|
||||
],
|
||||
)
|
||||
def test_final_validate_ap_mode_warns_for_non_default_port(
|
||||
web_server_config: dict,
|
||||
wifi_config: dict,
|
||||
expected: str | None,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Captive portal detection needs port 80; other ports get a hint, not captive mode."""
|
||||
token = fv.full_config.set(
|
||||
{"web_server": web_server_config, CONF_WIFI: wifi_config}
|
||||
)
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_final_validate_ap_mode(web_server_config)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
if expected is None:
|
||||
assert "cannot open automatically" not in caplog.text
|
||||
return
|
||||
assert expected in caplog.text
|
||||
assert "http://192.168.4.1:8080/" in caplog.text
|
||||
|
||||
|
||||
def test_final_validate_ap_mode_port_warning_uses_manual_ip(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The manual URL in the port warning honors wifi.ap.manual_ip."""
|
||||
from esphome.const import CONF_MANUAL_IP, CONF_STATIC_IP
|
||||
|
||||
config = {CONF_VERSION: 2, CONF_PORT: 8080}
|
||||
wifi = {CONF_AP: {CONF_MANUAL_IP: {CONF_STATIC_IP: "10.0.0.1"}}}
|
||||
token = fv.full_config.set({"web_server": config, CONF_WIFI: wifi})
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_final_validate_ap_mode(config)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
assert "http://10.0.0.1:8080/" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("wifi_config", "expected"),
|
||||
[
|
||||
# Explicit local: true on a fallback AP: announce the captive fallback role.
|
||||
(AP_FALLBACK, "fallback access point"),
|
||||
# Explicit local: true on AP only skips the implied-local info; still announce.
|
||||
(AP_ONLY, "captive portal while the access point"),
|
||||
],
|
||||
)
|
||||
def test_final_validate_ap_mode_informs_explicit_local_captive(
|
||||
wifi_config: dict, expected: str, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Explicit local: true logs that web_server becomes the captive portal."""
|
||||
config = {CONF_VERSION: 2, CONF_PORT: 80, CONF_LOCAL: True}
|
||||
token = fv.full_config.set({"web_server": config, CONF_WIFI: wifi_config})
|
||||
try:
|
||||
with caplog.at_level(logging.INFO):
|
||||
_final_validate_ap_mode(config)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
assert expected in caplog.text
|
||||
Reference in New Issue
Block a user