mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 12:17:23 +00:00
[cli] Add --ota-platform flag to pick web_server or native API OTA (#16207)
This commit is contained in:
@@ -43,6 +43,7 @@ from esphome.__main__ import (
|
||||
has_non_ip_address,
|
||||
has_ota,
|
||||
has_resolvable_address,
|
||||
has_web_server_ota,
|
||||
mqtt_get_ip,
|
||||
run_esphome,
|
||||
run_miniterm,
|
||||
@@ -58,6 +59,7 @@ from esphome.components import esp32
|
||||
from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32
|
||||
from esphome.const import (
|
||||
CONF_API,
|
||||
CONF_AUTH,
|
||||
CONF_BAUD_RATE,
|
||||
CONF_BROKER,
|
||||
CONF_DISABLED,
|
||||
@@ -76,6 +78,8 @@ from esphome.const import (
|
||||
CONF_SUBSTITUTIONS,
|
||||
CONF_TOPIC,
|
||||
CONF_USE_ADDRESS,
|
||||
CONF_USERNAME,
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WIFI,
|
||||
KEY_CORE,
|
||||
KEY_TARGET_PLATFORM,
|
||||
@@ -213,6 +217,13 @@ def mock_run_ota() -> Generator[Mock]:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_run_web_server_ota() -> Generator[Mock]:
|
||||
"""Mock web_server_ota.run_ota for testing."""
|
||||
with patch("esphome.web_server_ota.run_ota") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_is_ip_address() -> Generator[Mock]:
|
||||
"""Mock is_ip_address for testing."""
|
||||
@@ -1114,6 +1125,7 @@ class MockArgs:
|
||||
reset: bool = False
|
||||
list_only: bool = False
|
||||
output: str | None = None
|
||||
ota_platform: str | None = None
|
||||
partition_table: bool = False
|
||||
|
||||
|
||||
@@ -1878,6 +1890,277 @@ def test_upload_program_ota_no_config(
|
||||
upload_program(config, args, devices)
|
||||
|
||||
|
||||
def test_has_web_server_ota_detects_platform() -> None:
|
||||
"""has_web_server_ota returns True when web_server OTA platform is configured."""
|
||||
setup_core(
|
||||
config={
|
||||
CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}],
|
||||
}
|
||||
)
|
||||
assert has_web_server_ota() is True
|
||||
assert has_ota() is True
|
||||
|
||||
|
||||
def test_has_web_server_ota_returns_false_without_config() -> None:
|
||||
"""has_web_server_ota returns False when only native OTA is configured."""
|
||||
setup_core(
|
||||
config={
|
||||
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
|
||||
}
|
||||
)
|
||||
assert has_web_server_ota() is False
|
||||
assert has_ota() is True
|
||||
|
||||
|
||||
def test_upload_program_web_server_only_auto_dispatches(
|
||||
mock_run_web_server_ota: Mock,
|
||||
mock_run_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""When only web_server OTA is configured, upload_program picks it automatically."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
mock_get_port_type.return_value = "NETWORK"
|
||||
mock_run_web_server_ota.return_value = (0, "192.168.1.100")
|
||||
|
||||
config = {
|
||||
CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}],
|
||||
CONF_WEB_SERVER: {
|
||||
CONF_PORT: 80,
|
||||
CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "pw"},
|
||||
},
|
||||
}
|
||||
args = MockArgs()
|
||||
devices = ["192.168.1.100"]
|
||||
|
||||
exit_code, host = upload_program(config, args, devices)
|
||||
|
||||
assert exit_code == 0
|
||||
assert host == "192.168.1.100"
|
||||
expected_firmware = (
|
||||
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
|
||||
)
|
||||
mock_run_web_server_ota.assert_called_once_with(
|
||||
["192.168.1.100"], 80, "admin", "pw", expected_firmware
|
||||
)
|
||||
mock_run_ota.assert_not_called()
|
||||
|
||||
|
||||
def test_upload_program_web_server_no_auth(
|
||||
mock_run_web_server_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""web_server OTA works without an auth block (passes None for credentials)."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
mock_get_port_type.return_value = "NETWORK"
|
||||
mock_run_web_server_ota.return_value = (0, "192.168.1.100")
|
||||
|
||||
config = {
|
||||
CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}],
|
||||
CONF_WEB_SERVER: {CONF_PORT: 8080},
|
||||
}
|
||||
args = MockArgs()
|
||||
devices = ["192.168.1.100"]
|
||||
|
||||
exit_code, host = upload_program(config, args, devices)
|
||||
|
||||
assert exit_code == 0
|
||||
assert host == "192.168.1.100"
|
||||
expected_firmware = (
|
||||
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
|
||||
)
|
||||
mock_run_web_server_ota.assert_called_once_with(
|
||||
["192.168.1.100"], 8080, None, None, expected_firmware
|
||||
)
|
||||
|
||||
|
||||
def test_upload_program_both_platforms_default_prefers_native(
|
||||
mock_run_ota: Mock,
|
||||
mock_run_web_server_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""When both OTA platforms are configured, default selection is native API."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
mock_get_port_type.return_value = "NETWORK"
|
||||
mock_run_ota.return_value = (0, "192.168.1.100")
|
||||
|
||||
config = {
|
||||
CONF_OTA: [
|
||||
{
|
||||
CONF_PLATFORM: CONF_ESPHOME,
|
||||
CONF_PORT: 3232,
|
||||
CONF_PASSWORD: "secret",
|
||||
},
|
||||
{CONF_PLATFORM: CONF_WEB_SERVER},
|
||||
],
|
||||
CONF_WEB_SERVER: {CONF_PORT: 80},
|
||||
}
|
||||
args = MockArgs()
|
||||
devices = ["192.168.1.100"]
|
||||
|
||||
exit_code, host = upload_program(config, args, devices)
|
||||
|
||||
assert exit_code == 0
|
||||
assert host == "192.168.1.100"
|
||||
mock_run_ota.assert_called_once()
|
||||
mock_run_web_server_ota.assert_not_called()
|
||||
|
||||
|
||||
def test_upload_program_ota_platform_override_to_web_server(
|
||||
mock_run_ota: Mock,
|
||||
mock_run_web_server_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""--ota-platform web_server forces web_server OTA even when native is configured."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
mock_get_port_type.return_value = "NETWORK"
|
||||
mock_run_web_server_ota.return_value = (0, "192.168.1.100")
|
||||
|
||||
config = {
|
||||
CONF_OTA: [
|
||||
{
|
||||
CONF_PLATFORM: CONF_ESPHOME,
|
||||
CONF_PORT: 3232,
|
||||
CONF_PASSWORD: "secret",
|
||||
},
|
||||
{CONF_PLATFORM: CONF_WEB_SERVER},
|
||||
],
|
||||
CONF_WEB_SERVER: {CONF_PORT: 80},
|
||||
}
|
||||
args = MockArgs(ota_platform=CONF_WEB_SERVER)
|
||||
devices = ["192.168.1.100"]
|
||||
|
||||
exit_code, host = upload_program(config, args, devices)
|
||||
|
||||
assert exit_code == 0
|
||||
assert host == "192.168.1.100"
|
||||
mock_run_ota.assert_not_called()
|
||||
mock_run_web_server_ota.assert_called_once()
|
||||
|
||||
|
||||
def test_upload_program_ota_platform_unavailable(
|
||||
mock_get_port_type: Mock,
|
||||
) -> None:
|
||||
"""--ota-platform must reference a platform that is actually configured."""
|
||||
setup_core(platform=PLATFORM_ESP32)
|
||||
mock_get_port_type.return_value = "NETWORK"
|
||||
|
||||
config = {
|
||||
CONF_OTA: [
|
||||
{
|
||||
CONF_PLATFORM: CONF_ESPHOME,
|
||||
CONF_PORT: 3232,
|
||||
CONF_PASSWORD: "secret",
|
||||
}
|
||||
],
|
||||
}
|
||||
args = MockArgs(ota_platform=CONF_WEB_SERVER)
|
||||
devices = ["192.168.1.100"]
|
||||
|
||||
with pytest.raises(EsphomeError, match="--ota-platform web_server"):
|
||||
upload_program(config, args, devices)
|
||||
|
||||
|
||||
def test_upload_program_web_server_missing_component(
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""web_server OTA without a web_server component fails with a clear error."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
mock_get_port_type.return_value = "NETWORK"
|
||||
|
||||
config = {
|
||||
CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}],
|
||||
# No CONF_WEB_SERVER
|
||||
}
|
||||
args = MockArgs()
|
||||
devices = ["192.168.1.100"]
|
||||
|
||||
with pytest.raises(EsphomeError, match="web_server.*not configured"):
|
||||
upload_program(config, args, devices)
|
||||
|
||||
|
||||
def test_upload_program_unrelated_ota_platform_ignored(
|
||||
mock_run_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""OTA list entries that are neither esphome nor web_server are ignored.
|
||||
|
||||
Covers the false branch in _choose_ota_platform's filter loop and the
|
||||
no-match branch in _upload_via_native_api's lookup loop.
|
||||
"""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
mock_get_port_type.return_value = "NETWORK"
|
||||
mock_run_ota.return_value = (0, "192.168.1.100")
|
||||
|
||||
config = {
|
||||
CONF_OTA: [
|
||||
{CONF_PLATFORM: "http_request"}, # unrelated platform; ignored
|
||||
{
|
||||
CONF_PLATFORM: CONF_ESPHOME,
|
||||
CONF_PORT: 3232,
|
||||
CONF_PASSWORD: "secret",
|
||||
},
|
||||
],
|
||||
}
|
||||
args = MockArgs()
|
||||
devices = ["192.168.1.100"]
|
||||
|
||||
exit_code, host = upload_program(config, args, devices)
|
||||
|
||||
assert exit_code == 0
|
||||
assert host == "192.168.1.100"
|
||||
mock_run_ota.assert_called_once()
|
||||
|
||||
|
||||
def test_upload_program_duplicate_platform_dedup_in_error(
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Duplicate same-platform OTA entries don't repeat in --ota-platform errors."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
mock_get_port_type.return_value = "NETWORK"
|
||||
|
||||
config = {
|
||||
CONF_OTA: [
|
||||
{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232},
|
||||
{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3233},
|
||||
],
|
||||
}
|
||||
args = MockArgs(ota_platform=CONF_WEB_SERVER)
|
||||
devices = ["192.168.1.100"]
|
||||
|
||||
with pytest.raises(EsphomeError) as excinfo:
|
||||
upload_program(config, args, devices)
|
||||
|
||||
# Error mentions esphome once in the platform list, not "esphome, esphome".
|
||||
msg = str(excinfo.value)
|
||||
assert "esphome, esphome" not in msg
|
||||
assert msg.endswith(": esphome")
|
||||
|
||||
|
||||
def test_upload_program_only_unrelated_ota_platforms(
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Only unrelated OTA platforms configured -> raises like missing OTA."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
mock_get_port_type.return_value = "NETWORK"
|
||||
|
||||
config = {
|
||||
CONF_OTA: [{CONF_PLATFORM: "http_request"}],
|
||||
}
|
||||
args = MockArgs()
|
||||
devices = ["192.168.1.100"]
|
||||
|
||||
with pytest.raises(EsphomeError, match="Cannot upload Over the Air"):
|
||||
upload_program(config, args, devices)
|
||||
|
||||
|
||||
def test_upload_program_ota_with_mqtt_resolution(
|
||||
mock_mqtt_get_ip: Mock,
|
||||
mock_is_ip_address: Mock,
|
||||
|
||||
670
tests/unit_tests/test_web_server_ota.py
Normal file
670
tests/unit_tests/test_web_server_ota.py
Normal file
@@ -0,0 +1,670 @@
|
||||
"""Unit tests for esphome.web_server_ota module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import socket
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.helpers import ProgressBar
|
||||
from esphome.web_server_ota import (
|
||||
OTA_PATH,
|
||||
WebServerOTAError,
|
||||
_MultipartStreamer,
|
||||
run_ota,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def firmware(tmp_path: Path) -> Path:
|
||||
binary = tmp_path / "firmware.bin"
|
||||
binary.write_bytes(b"\x00\x01\x02FIRMWARE\xff" * 64)
|
||||
return binary
|
||||
|
||||
|
||||
def _make_response(status: int, body: str) -> MagicMock:
|
||||
response = MagicMock(spec=requests.Response)
|
||||
response.status_code = status
|
||||
response.text = body
|
||||
response.reason = ""
|
||||
return response
|
||||
|
||||
|
||||
def _patch_resolve(
|
||||
monkeypatch: pytest.MonkeyPatch, hosts: list[tuple[str, int]]
|
||||
) -> None:
|
||||
"""Replace resolve_ip_address so tests don't actually do DNS."""
|
||||
addr_infos = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", (host, port))
|
||||
for host, port in hosts
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _MultipartStreamer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_multipart_streamer_emits_full_body() -> None:
|
||||
"""Streaming the whole body in one call yields prefix + file + suffix."""
|
||||
data = b"abcdef" * 100
|
||||
streamer = _MultipartStreamer(io.BytesIO(data), len(data), "fw.bin")
|
||||
|
||||
body = streamer.read()
|
||||
while True:
|
||||
chunk = streamer.read()
|
||||
if not chunk:
|
||||
break
|
||||
body += chunk
|
||||
|
||||
assert body.startswith(f"--{streamer.boundary}\r\n".encode())
|
||||
assert b'name="update"' in body
|
||||
assert b'filename="fw.bin"' in body
|
||||
assert data in body
|
||||
assert body.endswith(f"\r\n--{streamer.boundary}--\r\n".encode())
|
||||
|
||||
|
||||
def test_multipart_streamer_chunked_read_matches_full_read() -> None:
|
||||
"""Chunked reads (urllib3 calls read(8192) repeatedly) yield the same body."""
|
||||
data = b"abcdef" * 1000 # 6000 bytes
|
||||
full = _MultipartStreamer(io.BytesIO(data), len(data), "fw.bin").read()
|
||||
|
||||
streamed = bytearray()
|
||||
s = _MultipartStreamer(io.BytesIO(data), len(data), "fw.bin")
|
||||
# Same boundary lengths -> identical total length.
|
||||
while True:
|
||||
chunk = s.read(64)
|
||||
if not chunk:
|
||||
break
|
||||
streamed += chunk
|
||||
# Boundaries are random per instance, so compare lengths and structure.
|
||||
assert len(streamed) == len(full)
|
||||
assert streamed.startswith(f"--{s.boundary}\r\n".encode())
|
||||
assert streamed.endswith(f"\r\n--{s.boundary}--\r\n".encode())
|
||||
|
||||
|
||||
def test_multipart_streamer_len_matches_emitted_bytes() -> None:
|
||||
"""``__len__`` is what urllib3 uses to set Content-Length, so it must
|
||||
equal the total bytes emitted by ``read``."""
|
||||
data = b"x" * 12345
|
||||
s = _MultipartStreamer(io.BytesIO(data), len(data), "fw.bin")
|
||||
declared = len(s)
|
||||
|
||||
emitted = 0
|
||||
while True:
|
||||
chunk = s.read(1024)
|
||||
if not chunk:
|
||||
break
|
||||
emitted += len(chunk)
|
||||
|
||||
assert emitted == declared
|
||||
|
||||
|
||||
def test_multipart_streamer_progress_ticks_during_read() -> None:
|
||||
"""Each read advances the progress bar (this is the whole point of
|
||||
streaming via ``data=``: progress reflects bytes leaving the host)."""
|
||||
data = b"x" * 1000
|
||||
s = _MultipartStreamer(io.BytesIO(data), len(data), "fw.bin")
|
||||
|
||||
updates: list[float] = []
|
||||
s.progress.update = updates.append # type: ignore[method-assign]
|
||||
|
||||
while True:
|
||||
chunk = s.read(128)
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
assert updates, "progress.update was never called"
|
||||
# Strictly non-decreasing.
|
||||
assert updates == sorted(updates)
|
||||
# Final update reaches (within FP) 1.0 because all bytes were read.
|
||||
assert updates[-1] == pytest.approx(1.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_multipart_streamer_content_type_includes_boundary() -> None:
|
||||
s = _MultipartStreamer(io.BytesIO(b""), 0, "fw.bin")
|
||||
assert s.content_type == f"multipart/form-data; boundary={s.boundary}"
|
||||
|
||||
|
||||
def test_multipart_streamer_zero_size_file() -> None:
|
||||
"""A zero-byte file still produces a well-formed body and progress is
|
||||
skipped (avoiding a divide-by-zero on the empty file segment)."""
|
||||
s = _MultipartStreamer(io.BytesIO(b""), 0, "empty.bin")
|
||||
body = b""
|
||||
while True:
|
||||
chunk = s.read(64)
|
||||
if not chunk:
|
||||
break
|
||||
body += chunk
|
||||
assert body.startswith(f"--{s.boundary}".encode())
|
||||
assert body.endswith(f"--{s.boundary}--\r\n".encode())
|
||||
|
||||
|
||||
def test_multipart_streamer_unique_boundary_per_instance() -> None:
|
||||
a = _MultipartStreamer(io.BytesIO(b""), 0, "a")
|
||||
b = _MultipartStreamer(io.BytesIO(b""), 0, "a")
|
||||
assert a.boundary != b.boundary
|
||||
|
||||
|
||||
def test_multipart_streamer_zero_size_read_returns_empty() -> None:
|
||||
"""``read(0)`` short-circuits without touching state."""
|
||||
s = _MultipartStreamer(io.BytesIO(b"x" * 10), 10, "fw.bin")
|
||||
assert s.read(0) == b""
|
||||
# No bytes consumed.
|
||||
assert s._sent == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_ota
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_run_ota_success(monkeypatch: pytest.MonkeyPatch, firmware: Path) -> None:
|
||||
_patch_resolve(monkeypatch, [("192.168.1.50", 80)])
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
return_value=_make_response(200, "Update Successful!"),
|
||||
) as post:
|
||||
exit_code, host = run_ota(["device.local"], 80, None, None, firmware)
|
||||
|
||||
assert exit_code == 0
|
||||
assert host == "192.168.1.50"
|
||||
post.assert_called_once()
|
||||
args, kwargs = post.call_args
|
||||
assert args == (f"http://192.168.1.50:80{OTA_PATH}",)
|
||||
assert kwargs["auth"] is None
|
||||
# Streaming body, not files=, so progress fires during transmission.
|
||||
assert "files" not in kwargs
|
||||
assert isinstance(kwargs["data"], _MultipartStreamer)
|
||||
assert kwargs["headers"]["Content-Type"] == kwargs["data"].content_type
|
||||
assert kwargs["headers"]["Connection"] == "close"
|
||||
|
||||
|
||||
def test_run_ota_logs_device_response_body(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The device's HTTP response body is surfaced on success."""
|
||||
_patch_resolve(monkeypatch, [("192.168.1.50", 80)])
|
||||
caplog.set_level(logging.INFO, logger="esphome.web_server_ota")
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
return_value=_make_response(200, "Update Successful!"),
|
||||
):
|
||||
run_ota(["192.168.1.50"], 80, None, None, firmware)
|
||||
|
||||
assert "Device response: Update Successful!" in caplog.text
|
||||
assert "OTA successful" in caplog.text
|
||||
|
||||
|
||||
def test_run_ota_log_says_via_web_server(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The upload-start log line names the transport explicitly."""
|
||||
_patch_resolve(monkeypatch, [("192.168.1.50", 80)])
|
||||
caplog.set_level(logging.INFO, logger="esphome.web_server_ota")
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
return_value=_make_response(200, "Update Successful!"),
|
||||
):
|
||||
run_ota(["192.168.1.50"], 80, None, None, firmware)
|
||||
|
||||
assert "via web_server OTA" in caplog.text
|
||||
|
||||
|
||||
def test_run_ota_sends_basic_auth(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path
|
||||
) -> None:
|
||||
_patch_resolve(monkeypatch, [("192.168.1.50", 80)])
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
return_value=_make_response(200, "Update Successful!"),
|
||||
) as post:
|
||||
exit_code, _ = run_ota(["192.168.1.50"], 80, "admin", "secret", firmware)
|
||||
|
||||
assert exit_code == 0
|
||||
auth = post.call_args.kwargs["auth"]
|
||||
assert isinstance(auth, HTTPBasicAuth)
|
||||
assert auth.username == "admin"
|
||||
assert auth.password == "secret"
|
||||
|
||||
|
||||
def test_run_ota_skips_auth_when_no_credentials(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path
|
||||
) -> None:
|
||||
_patch_resolve(monkeypatch, [("192.168.1.50", 80)])
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
return_value=_make_response(200, "Update Successful!"),
|
||||
) as post:
|
||||
run_ota(["192.168.1.50"], 80, None, None, firmware)
|
||||
|
||||
assert post.call_args.kwargs["auth"] is None
|
||||
|
||||
|
||||
def test_run_ota_skips_auth_when_only_username(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path
|
||||
) -> None:
|
||||
"""Both username and password are required to send Basic auth."""
|
||||
_patch_resolve(monkeypatch, [("192.168.1.50", 80)])
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
return_value=_make_response(200, "Update Successful!"),
|
||||
) as post:
|
||||
run_ota(["192.168.1.50"], 80, "admin", None, firmware)
|
||||
|
||||
assert post.call_args.kwargs["auth"] is None
|
||||
|
||||
|
||||
def test_run_ota_uses_update_url(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path
|
||||
) -> None:
|
||||
_patch_resolve(monkeypatch, [("192.168.1.50", 8080)])
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
return_value=_make_response(200, "Update Successful!"),
|
||||
) as post:
|
||||
run_ota(["192.168.1.50"], 8080, None, None, firmware)
|
||||
|
||||
url = post.call_args.args[0]
|
||||
assert url == f"http://192.168.1.50:8080{OTA_PATH}"
|
||||
assert OTA_PATH == "/update"
|
||||
|
||||
|
||||
def test_run_ota_failure_response(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
_patch_resolve(monkeypatch, [("192.168.1.50", 80)])
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
return_value=_make_response(200, "Update Failed!"),
|
||||
):
|
||||
exit_code, host = run_ota(["192.168.1.50"], 80, None, None, firmware)
|
||||
|
||||
assert exit_code == 1
|
||||
assert host is None
|
||||
assert "OTA failure" in caplog.text
|
||||
|
||||
|
||||
def test_run_ota_failure_response_empty_body(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
_patch_resolve(monkeypatch, [("192.168.1.50", 80)])
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
return_value=_make_response(200, ""),
|
||||
):
|
||||
exit_code, host = run_ota(["192.168.1.50"], 80, None, None, firmware)
|
||||
|
||||
assert exit_code == 1
|
||||
assert host is None
|
||||
assert "no response body" in caplog.text
|
||||
|
||||
|
||||
def test_run_ota_auth_failed(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
_patch_resolve(monkeypatch, [("192.168.1.50", 80)])
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
return_value=_make_response(401, "Unauthorized"),
|
||||
):
|
||||
exit_code, host = run_ota(["192.168.1.50"], 80, "user", "wrong", firmware)
|
||||
|
||||
assert exit_code == 1
|
||||
assert host is None
|
||||
assert "Authentication failed" in caplog.text
|
||||
|
||||
|
||||
def test_run_ota_unexpected_status_code(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
_patch_resolve(monkeypatch, [("192.168.1.50", 80)])
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
return_value=_make_response(500, "Internal Error"),
|
||||
):
|
||||
exit_code, host = run_ota(["192.168.1.50"], 80, None, None, firmware)
|
||||
|
||||
assert exit_code == 1
|
||||
assert host is None
|
||||
assert "Unexpected HTTP 500" in caplog.text
|
||||
|
||||
|
||||
def test_run_ota_unexpected_status_empty_body_falls_back(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Empty response body uses response.reason / a fallback in the error."""
|
||||
_patch_resolve(monkeypatch, [("192.168.1.50", 80)])
|
||||
|
||||
response = _make_response(503, "")
|
||||
response.reason = "Service Unavailable"
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
return_value=response,
|
||||
):
|
||||
exit_code, host = run_ota(["192.168.1.50"], 80, None, None, firmware)
|
||||
|
||||
assert exit_code == 1
|
||||
assert host is None
|
||||
assert "Service Unavailable" in caplog.text
|
||||
|
||||
|
||||
def test_run_ota_unexpected_status_no_body_no_reason(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Empty body and empty reason still produce a usable error message."""
|
||||
_patch_resolve(monkeypatch, [("192.168.1.50", 80)])
|
||||
|
||||
response = _make_response(599, "")
|
||||
response.reason = ""
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
return_value=response,
|
||||
):
|
||||
run_ota(["192.168.1.50"], 80, None, None, firmware)
|
||||
|
||||
assert "no response body" in caplog.text
|
||||
|
||||
|
||||
def test_run_ota_connection_error_then_success(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path
|
||||
) -> None:
|
||||
"""First resolved address fails to connect, second succeeds."""
|
||||
_patch_resolve(
|
||||
monkeypatch,
|
||||
[("192.168.1.10", 80), ("192.168.1.50", 80)],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
side_effect=[
|
||||
requests.ConnectionError("refused"),
|
||||
_make_response(200, "Update Successful!"),
|
||||
],
|
||||
) as post:
|
||||
exit_code, host = run_ota(["device.local"], 80, None, None, firmware)
|
||||
|
||||
assert exit_code == 0
|
||||
assert host == "192.168.1.50"
|
||||
assert post.call_count == 2
|
||||
|
||||
|
||||
def test_run_ota_request_exception_falls_through(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path
|
||||
) -> None:
|
||||
"""A non-ConnectionError RequestException (e.g. timeout) falls through too."""
|
||||
_patch_resolve(
|
||||
monkeypatch,
|
||||
[("192.168.1.10", 80), ("192.168.1.50", 80)],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
side_effect=[
|
||||
requests.Timeout("read timeout"),
|
||||
_make_response(200, "Update Successful!"),
|
||||
],
|
||||
):
|
||||
exit_code, host = run_ota(["device.local"], 80, None, None, firmware)
|
||||
|
||||
assert exit_code == 0
|
||||
assert host == "192.168.1.50"
|
||||
|
||||
|
||||
def test_run_ota_all_addresses_unreachable(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""When every resolved address fails to connect, run_ota returns failure."""
|
||||
_patch_resolve(
|
||||
monkeypatch,
|
||||
[("192.168.1.10", 80), ("192.168.1.20", 80)],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
side_effect=requests.ConnectionError("refused"),
|
||||
):
|
||||
exit_code, host = run_ota(["device.local"], 80, None, None, firmware)
|
||||
|
||||
assert exit_code == 1
|
||||
assert host is None
|
||||
# Per-address failure is logged for each attempt; final summary follows.
|
||||
assert caplog.text.count("OTA upload to ") >= 2
|
||||
assert "OTA upload failed." in caplog.text
|
||||
|
||||
|
||||
def test_run_ota_no_resolved_addresses(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""If resolve_ip_address returns no candidates, log and return failure."""
|
||||
_patch_resolve(monkeypatch, [])
|
||||
|
||||
exit_code, host = run_ota(["192.168.1.50"], 80, None, None, firmware)
|
||||
|
||||
assert exit_code == 1
|
||||
assert host is None
|
||||
assert "Could not resolve 192.168.1.50" in caplog.text
|
||||
|
||||
|
||||
def test_run_ota_resolution_failure(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path
|
||||
) -> None:
|
||||
def _raise(*_args, **_kwargs):
|
||||
raise EsphomeError("dns failed")
|
||||
|
||||
monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise)
|
||||
|
||||
exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware)
|
||||
|
||||
assert exit_code == 1
|
||||
assert host is None
|
||||
|
||||
|
||||
def test_run_ota_resolution_failure_dashboard_mode(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Dashboard mode skips the '--device <IP>' tip on resolution failure."""
|
||||
|
||||
def _raise(*_args, **_kwargs):
|
||||
raise EsphomeError("dns failed")
|
||||
|
||||
monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise)
|
||||
monkeypatch.setattr(CORE, "dashboard", True)
|
||||
try:
|
||||
exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware)
|
||||
finally:
|
||||
monkeypatch.setattr(CORE, "dashboard", False)
|
||||
|
||||
assert exit_code == 1
|
||||
assert host is None
|
||||
assert "--device <IP>" not in caplog.text
|
||||
|
||||
|
||||
def test_run_ota_empty_hosts(firmware: Path) -> None:
|
||||
exit_code, host = run_ota([], 80, None, None, firmware)
|
||||
assert exit_code == 1
|
||||
assert host is None
|
||||
|
||||
|
||||
def test_run_ota_string_host_accepted(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path
|
||||
) -> None:
|
||||
"""A bare string is accepted in addition to a list of hosts."""
|
||||
_patch_resolve(monkeypatch, [("10.0.0.5", 80)])
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
return_value=_make_response(200, "Update Successful!"),
|
||||
):
|
||||
exit_code, host = run_ota("10.0.0.5", 80, None, None, firmware)
|
||||
|
||||
assert exit_code == 0
|
||||
assert host == "10.0.0.5"
|
||||
|
||||
|
||||
def test_run_ota_multiple_hosts_first_fails(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path
|
||||
) -> None:
|
||||
"""Multi-host fallthrough: first host's addresses all fail, second host wins."""
|
||||
addr_lookup = {
|
||||
"primary.local": [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.10", 80)),
|
||||
],
|
||||
"secondary.local": [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.50", 80)),
|
||||
],
|
||||
}
|
||||
|
||||
def _resolve(host, port, address_cache=None): # noqa: ARG001
|
||||
return addr_lookup[host]
|
||||
|
||||
monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve)
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
side_effect=[
|
||||
requests.ConnectionError("refused"),
|
||||
_make_response(200, "Update Successful!"),
|
||||
],
|
||||
):
|
||||
exit_code, host = run_ota(
|
||||
["primary.local", "secondary.local"], 80, None, None, firmware
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
assert host == "192.168.1.50"
|
||||
|
||||
|
||||
def test_run_ota_all_hosts_return_failure_no_exception(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""All hosts resolve to no addresses; run_ota cleanly returns failure."""
|
||||
addr_lookup = {
|
||||
"a.local": [],
|
||||
"b.local": [],
|
||||
}
|
||||
|
||||
def _resolve(host, port, address_cache=None): # noqa: ARG001
|
||||
return addr_lookup[host]
|
||||
|
||||
monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve)
|
||||
|
||||
exit_code, host = run_ota(["a.local", "b.local"], 80, None, None, firmware)
|
||||
|
||||
assert exit_code == 1
|
||||
assert host is None
|
||||
# Each host gets its own "Could not resolve" log line + final summary.
|
||||
assert caplog.text.count("Could not resolve") == 2
|
||||
assert "OTA upload failed." in caplog.text
|
||||
|
||||
|
||||
def test_web_server_ota_error_is_esphome_error() -> None:
|
||||
assert issubclass(WebServerOTAError, EsphomeError)
|
||||
|
||||
|
||||
def test_run_ota_finalizes_progress_bar_on_success(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path
|
||||
) -> None:
|
||||
"""progress.done() fires on the success path (finally block)."""
|
||||
_patch_resolve(monkeypatch, [("192.168.1.50", 80)])
|
||||
|
||||
done_called: list[bool] = []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
return_value=_make_response(200, "Update Successful!"),
|
||||
),
|
||||
patch.object(ProgressBar, "done", lambda self: done_called.append(True)),
|
||||
):
|
||||
run_ota(["192.168.1.50"], 80, None, None, firmware)
|
||||
|
||||
assert done_called
|
||||
|
||||
|
||||
def test_run_ota_finalizes_progress_bar_on_failure(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path
|
||||
) -> None:
|
||||
"""progress.done() fires when the request itself raises (finally block)."""
|
||||
_patch_resolve(monkeypatch, [("192.168.1.50", 80)])
|
||||
|
||||
done_called: list[bool] = []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
side_effect=requests.ConnectionError("boom"),
|
||||
),
|
||||
patch.object(ProgressBar, "done", lambda self: done_called.append(True)),
|
||||
):
|
||||
run_ota(["192.168.1.50"], 80, None, None, firmware)
|
||||
|
||||
assert done_called
|
||||
|
||||
|
||||
def test_run_ota_ipv6_url_brackets_host(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path
|
||||
) -> None:
|
||||
"""IPv6 candidates are bracketed in the URL so the port parses correctly."""
|
||||
addr_infos = [
|
||||
(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
|
||||
)
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
return_value=_make_response(200, "Update Successful!"),
|
||||
) as post:
|
||||
exit_code, host = run_ota(["device.local"], 80, None, None, firmware)
|
||||
|
||||
assert exit_code == 0
|
||||
assert host == "2001:db8::1"
|
||||
url = post.call_args.args[0]
|
||||
assert url == f"http://[2001:db8::1]:80{OTA_PATH}"
|
||||
|
||||
|
||||
def test_run_ota_ipv6_link_local_includes_scope_id(
|
||||
monkeypatch: pytest.MonkeyPatch, firmware: Path
|
||||
) -> None:
|
||||
"""Link-local IPv6 candidates include the percent-encoded zone index."""
|
||||
addr_infos = [
|
||||
(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
|
||||
)
|
||||
|
||||
with patch(
|
||||
"esphome.web_server_ota.requests.post",
|
||||
return_value=_make_response(200, "Update Successful!"),
|
||||
) as post:
|
||||
exit_code, _ = run_ota(["device.local"], 80, None, None, firmware)
|
||||
|
||||
assert exit_code == 0
|
||||
url = post.call_args.args[0]
|
||||
assert url == f"http://[fe80::1%253]:80{OTA_PATH}"
|
||||
Reference in New Issue
Block a user