[core] Add esphome logs over web_server HTTP SSE (#17110)

This commit is contained in:
J. Nick Koston
2026-08-12 22:00:07 +12:00
committed by GitHub
parent 3f490fe1ed
commit 22153be4cd
10 changed files with 919 additions and 41 deletions
+17 -1
View File
@@ -14,7 +14,7 @@ import pytest
from esphome import helpers
from esphome.address_cache import AddressCache
from esphome.core import CORE, EsphomeError
from esphome.helpers import ProgressBar
from esphome.helpers import ProgressBar, format_ip_url
@pytest.mark.parametrize(
@@ -135,6 +135,22 @@ def test_is_ip_address__invalid(host):
assert actual is False
@pytest.mark.parametrize(
("family", "sockaddr", "expected"),
(
(socket.AF_INET, ("192.168.1.5", 80), "http://192.168.1.5:80/events"),
(socket.AF_INET6, ("2001:db8::1", 80, 0, 0), "http://[2001:db8::1]:80/events"),
(
socket.AF_INET6,
("fe80::1", 8080, 0, 7),
"http://[fe80::1%257]:8080/events",
),
),
)
def test_format_ip_url(family, sockaddr, expected):
assert format_ip_url(family, sockaddr, sockaddr[1], "/events") == expected
@settings(deadline=None)
@given(value=ip_addresses(v=4).map(str))
def test_is_ip_address__valid(value):
+133
View File
@@ -52,6 +52,7 @@ from esphome.__main__ import (
has_non_ip_address,
has_ota,
has_resolvable_address,
has_web_server_logging,
has_web_server_ota,
mqtt_get_ip,
parse_args,
@@ -80,6 +81,7 @@ from esphome.const import (
CONF_DISABLED,
CONF_ESPHOME,
CONF_LEVEL,
CONF_LOG,
CONF_LOG_TOPIC,
CONF_LOGGER,
CONF_MDNS,
@@ -94,6 +96,7 @@ from esphome.const import (
CONF_TOPIC,
CONF_USE_ADDRESS,
CONF_USERNAME,
CONF_VERSION,
CONF_WEB_SERVER,
CONF_WIFI,
KEY_CORE,
@@ -816,6 +819,30 @@ def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> Non
assert result == ["192.168.1.100"]
def test_choose_upload_log_host_logging_web_server_only_ip() -> None:
"""A web_server-only device with a static IP resolves to that IP for logs."""
setup_core(config={CONF_WEB_SERVER: {}}, address="192.168.1.100")
result = choose_upload_log_host(
default="OTA",
check_default=None,
purpose=Purpose.LOGGING,
)
assert result == ["192.168.1.100"]
def test_choose_upload_log_host_logging_web_server_only_mdns() -> None:
"""A web_server-only device with a .local name resolves to that hostname."""
setup_core(config={CONF_WEB_SERVER: {}}, address="test.local")
result = choose_upload_log_host(
default="OTA",
check_default=None,
purpose=Purpose.LOGGING,
)
assert result == ["test.local"]
def test_choose_upload_log_host_logging_without_api_reports_missing_api() -> None:
"""A resolvable device with only ota: fails logs with a missing-api message."""
setup_core(
@@ -855,6 +882,17 @@ def test_unresolved_default_error_unresolvable_keeps_dashboard_hint() -> None:
assert "set 'use_address'" in msg
def test_unresolved_default_error_logging_suggests_web_server() -> None:
"""The missing-api log message lists web_server among the remediations."""
setup_core(
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
)
msg = _unresolved_default_error(Purpose.LOGGING, ["OTA"])
assert "no 'api:' component is configured" in msg
assert "'web_server:'" in msg
def test_unresolved_default_error_upload_with_ota_is_generic() -> None:
"""With ota: present the upload error stays generic, not transport-specific."""
setup_core(
@@ -2534,6 +2572,30 @@ def test_has_web_server_ota_returns_false_without_config() -> None:
assert has_ota() is True
def test_has_web_server_logging_default() -> None:
"""has_web_server_logging is True for a default web_server (v2, log on)."""
setup_core(config={CONF_WEB_SERVER: {}})
assert has_web_server_logging() is True
def test_has_web_server_logging_without_config() -> None:
"""has_web_server_logging is False when web_server is not configured."""
setup_core(config={CONF_API: {}})
assert has_web_server_logging() is False
def test_has_web_server_logging_v1_has_no_events_stream() -> None:
"""has_web_server_logging is False for v1, which has no /events endpoint."""
setup_core(config={CONF_WEB_SERVER: {CONF_VERSION: 1}})
assert has_web_server_logging() is False
def test_has_web_server_logging_respects_log_disabled() -> None:
"""has_web_server_logging is False when the web_server log option is off."""
setup_core(config={CONF_WEB_SERVER: {CONF_LOG: False}})
assert has_web_server_logging() is False
def test_upload_program_web_server_only_auto_dispatches(
mock_run_web_server_ota: Mock,
mock_run_ota: Mock,
@@ -3102,6 +3164,77 @@ def test_show_logs_network_with_mqtt_only(
)
@patch("esphome.web_server_logs.run_logs")
def test_show_logs_web_server(
mock_run_logs: Mock,
) -> None:
"""A web_server-only device streams logs over the HTTP SSE endpoint."""
setup_core(
config={
"logger": {},
CONF_WEB_SERVER: {CONF_PORT: 80},
# No API or MQTT configured
},
platform=PLATFORM_ESP32,
)
mock_run_logs.return_value = 0
result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"])
assert result == 0
mock_run_logs.assert_called_once_with(["192.168.1.100"], 80, None, None)
@patch("esphome.web_server_logs.run_logs")
def test_show_logs_web_server_with_auth_and_port(
mock_run_logs: Mock,
) -> None:
"""web_server port and basic-auth credentials are forwarded to the streamer."""
setup_core(
config={
"logger": {},
CONF_WEB_SERVER: {
CONF_PORT: 8080,
CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"},
},
},
platform=PLATFORM_ESP32,
)
mock_run_logs.return_value = 0
result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"])
assert result == 0
mock_run_logs.assert_called_once_with(["192.168.1.100"], 8080, "admin", "secret")
@patch("esphome.web_server_logs.run_logs")
@patch("esphome.mqtt.show_logs")
def test_show_logs_mqtt_preferred_over_web_server(
mock_mqtt_show_logs: Mock,
mock_run_logs: Mock,
) -> None:
"""With both MQTT logging and web_server, MQTT wins (API > MQTT > web_server)."""
setup_core(
config={
"logger": {},
"mqtt": {CONF_BROKER: "mqtt.local"},
CONF_WEB_SERVER: {CONF_PORT: 80},
},
platform=PLATFORM_ESP32,
)
mock_mqtt_show_logs.return_value = 0
args = MockArgs(
topic="esphome/logs", username="user", password="pass", client_id="client"
)
result = show_logs(CORE.config, args, ["192.168.1.100"])
assert result == 0
mock_mqtt_show_logs.assert_called_once()
mock_run_logs.assert_not_called()
def test_show_logs_no_method_configured() -> None:
"""Test show_logs when no remote logging method is configured."""
setup_core(
@@ -0,0 +1,64 @@
"""Unit tests for esphome.web_server_helpers module."""
from __future__ import annotations
import socket
import pytest
from esphome.const import (
CONF_AUTH,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
CONF_WEB_SERVER,
)
from esphome.core import EsphomeError
from esphome.web_server_helpers import (
get_web_server_connection,
resolve_web_server_urls,
)
def test_resolve_web_server_urls_maps_ipv4_and_ipv6(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Each resolved address becomes an (ip, url) pair with IPv6 bracketing."""
addr_infos = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80)),
(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 7)),
]
monkeypatch.setattr(
"esphome.web_server_helpers.resolve_ip_address",
lambda *args, **kwargs: addr_infos,
)
assert resolve_web_server_urls("dev.local", 80, "/events") == [
("192.168.1.5", "http://192.168.1.5:80/events"),
("fe80::1", "http://[fe80::1%257]:80/events"),
]
def test_get_web_server_connection_without_auth() -> None:
"""Port is returned and credentials are None when no auth is configured."""
config = {CONF_WEB_SERVER: {CONF_PORT: 80}}
assert get_web_server_connection(config) == (80, None, None)
def test_get_web_server_connection_with_auth() -> None:
"""Port and HTTP Basic credentials are returned when auth is configured."""
config = {
CONF_WEB_SERVER: {
CONF_PORT: 8080,
CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"},
}
}
assert get_web_server_connection(config) == (8080, "admin", "secret")
def test_get_web_server_connection_missing_component() -> None:
"""A config without web_server raises a clear error."""
with pytest.raises(EsphomeError, match="web_server.*not configured"):
get_web_server_connection({})
+397
View File
@@ -0,0 +1,397 @@
"""Unit tests for esphome.web_server_logs module."""
from __future__ import annotations
from collections.abc import Iterator
import logging
import socket
from typing import Self
from unittest.mock import MagicMock
import pytest
import requests
from requests.auth import HTTPBasicAuth
from esphome import web_server_logs
from esphome.core import EsphomeError
from esphome.web_server_logs import (
EVENTS_PATH,
WebServerLogsError,
_build_urls,
_consume,
_stream,
run_logs,
)
# A realistic slice of the web_server /events SSE stream: an initial ping
# carrying the config, a state frame, two log frames (one multi-line), plus
# comment/id/retry lines that must be ignored.
SSE_LINES = [
"retry: 30000",
"id: 12345",
"event: ping",
'data: {"title":"dev","log":true}',
"",
"event: state",
'data: {"id":"sensor-x","state":"ON"}',
"",
"event: log",
"data: \x1b[0;32m[I][main:001]: hello\x1b[0m",
"",
": keepalive-comment",
"event: log",
"data: line one",
"data: line two",
"",
]
class _FakeResponse:
"""Minimal stand-in for a streamed ``requests`` response."""
def __init__(self, status_code: int, lines: list[str]) -> None:
self.status_code = status_code
self._lines = lines
def __enter__(self) -> Self:
return self
def __exit__(self, *exc: object) -> bool:
return False
def iter_lines(self) -> Iterator[bytes]:
for line in self._lines:
yield line.encode("utf8")
@pytest.fixture
def fake_parser() -> MagicMock:
"""A LogParser whose parse_line returns the raw line unchanged."""
parser = MagicMock()
parser.parse_line.side_effect = lambda line, time_str: line
return parser
def _patch_resolve(
monkeypatch: pytest.MonkeyPatch,
addr_infos: list[tuple[int, int, int, str, tuple]],
) -> None:
monkeypatch.setattr(
"esphome.web_server_helpers.resolve_ip_address",
lambda *args, **kwargs: addr_infos,
)
# ---------------------------------------------------------------------------
# _build_urls
# ---------------------------------------------------------------------------
def test_build_urls_ipv4(monkeypatch: pytest.MonkeyPatch) -> None:
"""An IPv4 host resolves to a plain http://ip:port/events URL."""
_patch_resolve(
monkeypatch,
[(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80))],
)
assert _build_urls(["dev.local"], 80) == [
("192.168.1.5", f"http://192.168.1.5:80{EVENTS_PATH}")
]
def test_build_urls_ipv6_brackets_and_zone(monkeypatch: pytest.MonkeyPatch) -> None:
"""IPv6 literals are bracketed; link-local addresses get a %25 zone index."""
_patch_resolve(
monkeypatch,
[(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 8080, 0, 7))],
)
assert _build_urls(["dev.local"], 8080) == [
("fe80::1", f"http://[fe80::1%257]:8080{EVENTS_PATH}")
]
def test_build_urls_dedups_and_skips_unresolvable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Duplicate resolved IPs collapse to one URL; resolve errors are skipped."""
calls: list[str] = []
def fake_resolve(host: str, port: int, **kwargs: object) -> list[tuple]:
calls.append(host)
if host == "bad":
raise EsphomeError("nope")
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("10.0.0.1", port))]
monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", fake_resolve)
# "good" and "dup" both resolve to 10.0.0.1, "bad" raises.
assert _build_urls(["good", "bad", "dup"], 80) == [
("10.0.0.1", f"http://10.0.0.1:80{EVENTS_PATH}")
]
assert calls == ["good", "bad", "dup"]
# ---------------------------------------------------------------------------
# _consume (SSE parsing)
# ---------------------------------------------------------------------------
def test_consume_emits_only_log_frames(
monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock
) -> None:
"""Only event: log data lines are printed; ping/state/comments are ignored."""
printed: list[str] = []
monkeypatch.setattr(web_server_logs, "safe_print", printed.append)
_consume(_FakeResponse(200, SSE_LINES), fake_parser)
assert printed == [
"\x1b[0;32m[I][main:001]: hello\x1b[0m",
"line one",
"line two",
]
def test_consume_ignores_unterminated_trailing_frame(
monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock
) -> None:
"""A log frame without its terminating blank line is not emitted."""
printed: list[str] = []
monkeypatch.setattr(web_server_logs, "safe_print", printed.append)
_consume(_FakeResponse(200, ["event: log", "data: dangling"]), fake_parser)
assert printed == []
# ---------------------------------------------------------------------------
# _stream
# ---------------------------------------------------------------------------
def test_stream_returns_false_when_connect_fails(
monkeypatch: pytest.MonkeyPatch,
fake_parser: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A failed connection logs a warning and reports not-connected."""
def boom(*args: object, **kwargs: object) -> _FakeResponse:
raise requests.ConnectionError("refused")
monkeypatch.setattr(requests, "get", boom)
with caplog.at_level(logging.WARNING):
assert (
_stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is False
)
assert "Could not connect to 10.0.0.1" in caplog.text
def test_stream_returns_true_when_established_then_dropped(
monkeypatch: pytest.MonkeyPatch,
fake_parser: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A mid-stream drop after connecting reports connected so we reconnect."""
printed: list[str] = []
monkeypatch.setattr(web_server_logs, "safe_print", printed.append)
class _DroppingResponse(_FakeResponse):
def iter_lines(self) -> Iterator[bytes]:
yield b"event: log"
yield b"data: before-drop"
yield b""
raise requests.exceptions.ChunkedEncodingError("connection lost")
monkeypatch.setattr(requests, "get", lambda *a, **kw: _DroppingResponse(200, []))
with caplog.at_level(logging.INFO):
assert (
_stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is True
)
assert printed == ["before-drop"]
assert "reconnecting" in caplog.text
# ---------------------------------------------------------------------------
# run_logs
# ---------------------------------------------------------------------------
def test_run_logs_streams_then_reconnects_until_interrupt(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A dropped stream reconnects; KeyboardInterrupt during the pause exits 0."""
monkeypatch.setattr(
web_server_logs,
"_build_urls",
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
)
printed: list[str] = []
monkeypatch.setattr(web_server_logs, "safe_print", printed.append)
monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(200, SSE_LINES))
def stop(_delay: float) -> None:
raise KeyboardInterrupt
monkeypatch.setattr(web_server_logs.time, "sleep", stop)
assert run_logs(["dev.local"], 80, None, None) == 0
# The single stream was consumed before the reconnect pause interrupted us.
# run_logs renders through the real LogParser, which prefixes a timestamp,
# so assert on the payloads rather than exact equality.
assert len(printed) == 3
assert "[I][main:001]: hello" in printed[0]
assert "line one" in printed[1]
assert "line two" in printed[2]
def test_run_logs_passes_basic_auth(monkeypatch: pytest.MonkeyPatch) -> None:
"""Username + password are forwarded as HTTP Basic auth on the request."""
monkeypatch.setattr(
web_server_logs,
"_build_urls",
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
)
monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None)
captured: dict[str, object] = {}
def fake_get(url: str, **kwargs: object) -> _FakeResponse:
captured.update(kwargs)
captured["url"] = url
return _FakeResponse(200, SSE_LINES)
monkeypatch.setattr(requests, "get", fake_get)
monkeypatch.setattr(
web_server_logs.time,
"sleep",
lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()),
)
assert run_logs(["dev.local"], 80, "admin", "secret") == 0
auth = captured["auth"]
assert isinstance(auth, HTTPBasicAuth)
assert (auth.username, auth.password) == ("admin", "secret")
assert captured["stream"] is True
assert captured["headers"] == {"Accept": "text/event-stream"}
def test_run_logs_no_auth_when_credentials_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No auth object is sent when username/password are not configured."""
monkeypatch.setattr(
web_server_logs,
"_build_urls",
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
)
monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None)
captured: dict[str, object] = {}
def fake_get(url: str, **kwargs: object) -> _FakeResponse:
captured.update(kwargs)
return _FakeResponse(200, SSE_LINES)
monkeypatch.setattr(requests, "get", fake_get)
monkeypatch.setattr(
web_server_logs.time,
"sleep",
lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()),
)
assert run_logs(["dev.local"], 80, None, None) == 0
assert captured["auth"] is None
def test_run_logs_raises_on_auth_failure(monkeypatch: pytest.MonkeyPatch) -> None:
"""HTTP 401 aborts with a clear error rather than reconnecting forever."""
monkeypatch.setattr(
web_server_logs,
"_build_urls",
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
)
monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(401, []))
with pytest.raises(WebServerLogsError, match="Authentication failed"):
run_logs(["dev.local"], 80, "admin", "bad")
def test_run_logs_retries_on_transient_status(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A transient non-200 (e.g. 503) is logged and the loop retries."""
monkeypatch.setattr(
web_server_logs,
"_build_urls",
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
)
monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(503, []))
monkeypatch.setattr(
web_server_logs.time,
"sleep",
lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()),
)
with caplog.at_level(logging.ERROR):
assert run_logs(["dev.local"], 80, None, None) == 0
assert "Unexpected HTTP 503" in caplog.text
@pytest.mark.parametrize("status", (403, 404))
def test_run_logs_raises_on_permanent_status(
monkeypatch: pytest.MonkeyPatch, status: int
) -> None:
"""A permanent 403/404 aborts instead of retrying the endpoint forever."""
monkeypatch.setattr(
web_server_logs,
"_build_urls",
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
)
monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(status, []))
with pytest.raises(WebServerLogsError, match=str(status)):
run_logs(["dev.local"], 80, None, None)
def test_run_logs_backs_off_on_repeated_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Consecutive unreachable attempts grow the reconnect delay up to the cap."""
monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: [])
delays: list[float] = []
def record(delay: float) -> None:
delays.append(delay)
if len(delays) >= 4:
raise KeyboardInterrupt
monkeypatch.setattr(web_server_logs.time, "sleep", record)
assert run_logs(["dev.local"], 80, None, None) == 0
# 1 -> 2 -> 4 -> 8 ... doubling, capped at MAX_RECONNECT_DELAY (10.0).
assert delays == [2.0, 4.0, 8.0, 10.0]
def test_run_logs_reports_unresolvable(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""When no host resolves, an error is logged and the loop pauses/retries."""
monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: [])
# Let the first reconnect pause pass so the loop continues, then interrupt
# on the second so the retry path (the ``continue``) is exercised.
sleeps = {"n": 0}
def sleep(_delay: float) -> None:
sleeps["n"] += 1
if sleeps["n"] >= 2:
raise KeyboardInterrupt
monkeypatch.setattr(web_server_logs.time, "sleep", sleep)
with caplog.at_level(logging.ERROR):
assert run_logs(["dev.local"], 80, None, None) == 0
assert sleeps["n"] == 2
assert "Could not resolve" in caplog.text
+7 -7
View File
@@ -46,7 +46,7 @@ def _patch_resolve(
for host, port in hosts
]
monkeypatch.setattr(
"esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos
"esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos
)
@@ -475,7 +475,7 @@ def test_run_ota_resolution_failure(
def _raise(*_args, **_kwargs):
raise EsphomeError("dns failed")
monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise)
monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise)
exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware)
@@ -491,7 +491,7 @@ def test_run_ota_resolution_failure_dashboard_mode(
def _raise(*_args, **_kwargs):
raise EsphomeError("dns failed")
monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise)
monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise)
monkeypatch.setattr(CORE, "dashboard", True)
try:
exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware)
@@ -541,7 +541,7 @@ def test_run_ota_multiple_hosts_first_fails(
def _resolve(host, port, address_cache=None): # noqa: ARG001
return addr_lookup[host]
monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve)
monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve)
with patch(
"esphome.web_server_ota.requests.post",
@@ -570,7 +570,7 @@ def test_run_ota_all_hosts_return_failure_no_exception(
def _resolve(host, port, address_cache=None): # noqa: ARG001
return addr_lookup[host]
monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve)
monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve)
exit_code, host = run_ota(["a.local", "b.local"], 80, None, None, firmware)
@@ -633,7 +633,7 @@ def test_run_ota_ipv6_url_brackets_host(
(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("2001:db8::1", 80, 0, 0)),
]
monkeypatch.setattr(
"esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos
"esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos
)
with patch(
@@ -656,7 +656,7 @@ def test_run_ota_ipv6_link_local_includes_scope_id(
(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 3)),
]
monkeypatch.setattr(
"esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos
"esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos
)
with patch(