mirror of
https://github.com/esphome/esphome.git
synced 2026-09-21 03:58:41 +00:00
Merge branch 'esp8266-arduino-toolchain' into esp8266-native-pch
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
"""Unit tests for encrypted OTA uploads in esphome.espota2.
|
||||
|
||||
A fake device implementing the responder side of the wire protocol (via
|
||||
noiseprotocol, which esphome already has through aioesphomeapi) serves a real
|
||||
TCP loopback connection, so these exercise the actual handshake, framing, and
|
||||
cipher interop of the client code. Tests that need the client-side crypto skip
|
||||
when the installed aioesphomeapi predates the noise module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import espota2
|
||||
|
||||
PSK = base64.b64encode(bytes(range(32))).decode()
|
||||
OTHER_PSK = base64.b64encode(bytes(range(1, 33))).decode()
|
||||
|
||||
MAGIC = bytes(espota2.MAGIC_BYTES)
|
||||
|
||||
|
||||
def _recv_exact(sock: socket.socket, amount: int) -> bytes:
|
||||
data = b""
|
||||
while len(data) < amount:
|
||||
chunk = sock.recv(amount - len(data))
|
||||
if not chunk:
|
||||
raise ConnectionError("client closed")
|
||||
data += chunk
|
||||
return data
|
||||
|
||||
|
||||
def _frame(payload: bytes) -> bytes:
|
||||
return (
|
||||
bytes([espota2.NOISE_FRAME_INDICATOR, len(payload) >> 8, len(payload) & 0xFF])
|
||||
+ payload
|
||||
)
|
||||
|
||||
|
||||
def _send_frame(sock: socket.socket, payload: bytes) -> None:
|
||||
sock.sendall(_frame(payload))
|
||||
|
||||
|
||||
def _recv_frame(sock: socket.socket) -> bytes:
|
||||
header = _recv_exact(sock, 3)
|
||||
assert header[0] == 0x01
|
||||
return _recv_exact(sock, (header[1] << 8) | header[2])
|
||||
|
||||
|
||||
class FakeEncryptedDevice(threading.Thread):
|
||||
"""Responder side of the encrypted OTA wire protocol."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
psk: str = PSK,
|
||||
version: int = 2,
|
||||
offer_noise: bool = True,
|
||||
require_noise: bool = True,
|
||||
prologue_features_override: int | None = None,
|
||||
) -> None:
|
||||
super().__init__(daemon=True)
|
||||
self.psk = psk
|
||||
self.version = version
|
||||
self.offer_noise = offer_noise
|
||||
self.require_noise = require_noise
|
||||
self.prologue_features_override = prologue_features_override
|
||||
self.received: bytes | None = None
|
||||
self.error: Exception | None = None
|
||||
self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.listener.bind(("127.0.0.1", 0))
|
||||
self.listener.listen(1)
|
||||
self.port = self.listener.getsockname()[1]
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
sock, _ = self.listener.accept()
|
||||
sock.settimeout(10)
|
||||
with sock:
|
||||
self._serve(sock)
|
||||
except Exception as err: # noqa: BLE001 - surfaced via join_and_check
|
||||
self.error = err
|
||||
finally:
|
||||
self.listener.close()
|
||||
|
||||
def join_and_check(self) -> None:
|
||||
self.join(timeout=10)
|
||||
assert not self.is_alive(), "fake device did not finish"
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
def _serve(self, sock: socket.socket) -> None:
|
||||
assert _recv_exact(sock, 5) == MAGIC
|
||||
sock.sendall(bytes([espota2.RESPONSE_OK, self.version]))
|
||||
features = _recv_exact(sock, 1)[0]
|
||||
noise_negotiated = bool(
|
||||
features & espota2.CLIENT_FEATURE_SUPPORTS_NOISE
|
||||
and features & espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
|
||||
)
|
||||
if self.require_noise and not noise_negotiated:
|
||||
sock.sendall(bytes([espota2.RESPONSE_ERROR_ENCRYPTION_REQUIRED]))
|
||||
return
|
||||
server_flags = espota2.SERVER_FEATURE_SUPPORTS_NOISE if self.offer_noise else 0
|
||||
sock.sendall(bytes([espota2.RESPONSE_FEATURE_FLAGS, server_flags]))
|
||||
if not (self.offer_noise and noise_negotiated):
|
||||
return # the client fails closed; nothing further arrives
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from noise.connection import NoiseConnection
|
||||
|
||||
prologue_features = (
|
||||
features
|
||||
if self.prologue_features_override is None
|
||||
else self.prologue_features_override
|
||||
)
|
||||
prologue = (
|
||||
espota2.NOISE_PROLOGUE_INIT
|
||||
+ MAGIC
|
||||
+ bytes([espota2.RESPONSE_OK, self.version, prologue_features])
|
||||
+ bytes([espota2.RESPONSE_FEATURE_FLAGS, server_flags])
|
||||
)
|
||||
proto = NoiseConnection.from_name(b"Noise_NNpsk0_25519_ChaChaPoly_SHA256")
|
||||
proto.set_as_responder()
|
||||
proto.set_psks(base64.b64decode(self.psk))
|
||||
proto.set_prologue(prologue)
|
||||
proto.start_handshake()
|
||||
|
||||
msg1 = _recv_frame(sock)
|
||||
assert msg1[0] == 0x00
|
||||
try:
|
||||
proto.read_message(msg1[1:])
|
||||
except InvalidTag:
|
||||
_send_frame(sock, b"\x01" + espota2.NOISE_MAC_FAILURE_REASON.encode())
|
||||
return
|
||||
_send_frame(sock, b"\x00" + bytes(proto.write_message()))
|
||||
|
||||
def send_byte(byte: int) -> None:
|
||||
_send_frame(sock, proto.encrypt(bytes([byte])))
|
||||
|
||||
def recv_unit(length: int) -> bytes:
|
||||
plaintext = proto.decrypt(_recv_frame(sock))
|
||||
assert len(plaintext) == length, "control units must be one per frame"
|
||||
return plaintext
|
||||
|
||||
send_byte(espota2.RESPONSE_AUTH_OK)
|
||||
recv_unit(1) # ota type
|
||||
size = int.from_bytes(recv_unit(4), "big")
|
||||
send_byte(espota2.RESPONSE_UPDATE_PREPARE_OK)
|
||||
md5_hex = recv_unit(32)
|
||||
send_byte(espota2.RESPONSE_BIN_MD5_OK)
|
||||
|
||||
received = b""
|
||||
acked = 0
|
||||
while len(received) < size:
|
||||
plaintext = proto.decrypt(_recv_frame(sock))
|
||||
assert 0 < len(plaintext) <= espota2.NOISE_MAX_PLAINTEXT
|
||||
received += plaintext
|
||||
if self.version >= espota2.OTA_VERSION_2_0:
|
||||
while acked + espota2.UPLOAD_BLOCK_SIZE <= len(received) or (
|
||||
len(received) == size and acked < size
|
||||
):
|
||||
send_byte(espota2.RESPONSE_CHUNK_OK)
|
||||
acked += espota2.UPLOAD_BLOCK_SIZE
|
||||
assert hashlib.md5(received).hexdigest().encode() == md5_hex
|
||||
send_byte(espota2.RESPONSE_RECEIVE_OK)
|
||||
send_byte(espota2.RESPONSE_UPDATE_END_OK)
|
||||
assert recv_unit(1) == bytes([espota2.RESPONSE_OK])
|
||||
self.received = received
|
||||
|
||||
|
||||
def _upload(
|
||||
device: FakeEncryptedDevice, firmware: bytes, noise_psk: str | None
|
||||
) -> None:
|
||||
device.start()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(10)
|
||||
sock.connect(("127.0.0.1", device.port))
|
||||
try:
|
||||
espota2.perform_ota(
|
||||
sock, None, io.BytesIO(firmware), Path("firmware.bin"), noise_psk=noise_psk
|
||||
)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def test_encrypted_upload_success() -> None:
|
||||
"""A full encrypted v2 upload spanning several 8192-byte blocks."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
firmware = bytes(range(256)) * 80 # 20480 bytes, crosses chunk-ack boundaries
|
||||
device = FakeEncryptedDevice()
|
||||
with patch("time.sleep"):
|
||||
_upload(device, firmware, PSK)
|
||||
device.join_and_check()
|
||||
assert device.received == firmware
|
||||
|
||||
|
||||
def test_encrypted_upload_version_1() -> None:
|
||||
"""Version 1 protocol (no chunk acks) works through the noise transport."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
firmware = b"v1 firmware image" * 100
|
||||
device = FakeEncryptedDevice(version=1)
|
||||
with patch("time.sleep"):
|
||||
_upload(device, firmware, PSK)
|
||||
device.join_and_check()
|
||||
assert device.received == firmware
|
||||
|
||||
|
||||
def test_wrong_key_fails_with_clear_error() -> None:
|
||||
"""A key mismatch surfaces the device's handshake reject readably."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
device = FakeEncryptedDevice(psk=OTHER_PSK)
|
||||
with pytest.raises(espota2.OTAError, match="encryption key correct"):
|
||||
_upload(device, b"firmware", PSK)
|
||||
device.join_and_check()
|
||||
|
||||
|
||||
def test_tampered_negotiation_breaks_handshake() -> None:
|
||||
"""A negotiation byte differing between the sides breaks the prologue MAC."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
device = FakeEncryptedDevice(
|
||||
prologue_features_override=espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
|
||||
)
|
||||
with pytest.raises(espota2.OTAError, match="encryption key correct"):
|
||||
_upload(device, b"firmware", PSK)
|
||||
device.join_and_check()
|
||||
|
||||
|
||||
def test_client_fails_closed_when_device_lacks_encryption() -> None:
|
||||
"""With a key configured, a device not offering noise aborts the upload."""
|
||||
device = FakeEncryptedDevice(offer_noise=False, require_noise=False)
|
||||
with pytest.raises(espota2.OTAError, match="refusing to send the image"):
|
||||
_upload(device, b"firmware", PSK)
|
||||
device.join_and_check()
|
||||
|
||||
|
||||
def test_plaintext_client_gets_encryption_required_error() -> None:
|
||||
"""A client without a key gets the device's 0x94 error message."""
|
||||
device = FakeEncryptedDevice()
|
||||
with pytest.raises(espota2.OTAError, match="requires an encrypted OTA"):
|
||||
_upload(device, b"firmware", None)
|
||||
device.join_and_check()
|
||||
|
||||
|
||||
def test_missing_aioesphomeapi_noise_module_message() -> None:
|
||||
"""An aioesphomeapi without the noise module produces a clear error."""
|
||||
with (
|
||||
patch.dict(sys.modules, {"aioesphomeapi.noise": None}),
|
||||
pytest.raises(espota2.OTAError, match="requires a newer aioesphomeapi"),
|
||||
):
|
||||
espota2.NoiseSocketWrapper(Mock(), PSK, b"prologue")
|
||||
|
||||
|
||||
class ScriptedSocket:
|
||||
"""Serves scripted recv chunks; b"" means the peer closed."""
|
||||
|
||||
def __init__(self, *chunks: bytes | Exception) -> None:
|
||||
self.chunks = list(chunks)
|
||||
self.sent: list[bytes] = []
|
||||
|
||||
def sendall(self, data: bytes) -> None:
|
||||
self.sent.append(data)
|
||||
|
||||
def settimeout(self, timeout: float) -> None:
|
||||
pass
|
||||
|
||||
def recv(self, amount: int) -> bytes:
|
||||
if not self.chunks:
|
||||
return b""
|
||||
chunk = self.chunks[0]
|
||||
if isinstance(chunk, Exception):
|
||||
self.chunks.pop(0)
|
||||
raise chunk
|
||||
take, rest = chunk[:amount], chunk[amount:]
|
||||
if rest:
|
||||
self.chunks[0] = rest
|
||||
else:
|
||||
self.chunks.pop(0)
|
||||
return take
|
||||
|
||||
|
||||
def _wrapper(*chunks: bytes | Exception) -> espota2.NoiseSocketWrapper:
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
return espota2.NoiseSocketWrapper(ScriptedSocket(*chunks), PSK, b"prologue")
|
||||
|
||||
|
||||
def test_wrapper_rejects_malformed_psk() -> None:
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
with pytest.raises(espota2.OTAError, match="Invalid OTA encryption key"):
|
||||
espota2.NoiseSocketWrapper(ScriptedSocket(), "not-base64!!!", b"prologue")
|
||||
|
||||
|
||||
def test_handshake_socket_error_is_network_error() -> None:
|
||||
wrapper = _wrapper(OSError("boom"))
|
||||
with pytest.raises(espota2.OTANetworkError, match="noise handshake"):
|
||||
wrapper.do_handshake()
|
||||
|
||||
|
||||
def test_handshake_closed_at_frame_boundary() -> None:
|
||||
wrapper = _wrapper()
|
||||
with pytest.raises(espota2.OTANetworkError, match="closed connection during"):
|
||||
wrapper.do_handshake()
|
||||
|
||||
|
||||
def test_handshake_reject_with_other_reason() -> None:
|
||||
wrapper = _wrapper(_frame(b"\x01Handshake error"))
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="rejected the noise handshake: Handshake error"
|
||||
):
|
||||
wrapper.do_handshake()
|
||||
|
||||
|
||||
def test_handshake_garbage_second_message() -> None:
|
||||
"""A valid-looking point with a garbage MAC fails cleanly."""
|
||||
wrapper = _wrapper(_frame(b"\x00" + bytes(range(48))))
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="handshake failed; is the OTA encryption key"
|
||||
):
|
||||
wrapper.do_handshake()
|
||||
|
||||
|
||||
def test_handshake_invalid_curve_point() -> None:
|
||||
"""An all-zero x25519 point is rejected as a clean error, not a crash."""
|
||||
wrapper = _wrapper(_frame(b"\x00" + bytes(48)))
|
||||
with pytest.raises(
|
||||
espota2.OTAError, match="handshake failed; is the OTA encryption key"
|
||||
):
|
||||
wrapper.do_handshake()
|
||||
|
||||
|
||||
def test_recv_closed_at_frame_boundary_returns_empty() -> None:
|
||||
wrapper = _wrapper()
|
||||
assert wrapper.recv(1) == b""
|
||||
|
||||
|
||||
def test_recv_corrupt_frame_is_retryable_network_error() -> None:
|
||||
from cryptography.exceptions import InvalidTag
|
||||
|
||||
wrapper = _wrapper(_frame(b"ciphertext"))
|
||||
wrapper._decrypt = Mock(decrypt=Mock(side_effect=InvalidTag()))
|
||||
with pytest.raises(espota2.OTANetworkError, match="decryption failed"):
|
||||
wrapper.recv(1)
|
||||
|
||||
|
||||
def test_wrapper_blocks_unencrypted_socket_methods() -> None:
|
||||
"""Byte-moving socket methods must not bypass the encrypted transport."""
|
||||
wrapper = _wrapper()
|
||||
# The harmless socket controls pass through to the wrapped socket
|
||||
wrapper._sock = Mock()
|
||||
wrapper.settimeout(1)
|
||||
wrapper._sock.settimeout.assert_called_once_with(1)
|
||||
wrapper.setsockopt(6, 1, 1)
|
||||
wrapper._sock.setsockopt.assert_called_once_with(6, 1, 1)
|
||||
wrapper.close()
|
||||
wrapper._sock.close.assert_called_once_with()
|
||||
with pytest.raises(AttributeError):
|
||||
_ = wrapper.send
|
||||
with pytest.raises(AttributeError):
|
||||
_ = wrapper.recv_into
|
||||
|
||||
|
||||
def test_recv_empty_plaintext_frame_is_protocol_error() -> None:
|
||||
"""A MAC-only frame decrypts to nothing; b'' from recv must mean close."""
|
||||
wrapper = _wrapper(_frame(bytes(16)))
|
||||
wrapper._decrypt = Mock(decrypt=Mock(return_value=b""))
|
||||
with pytest.raises(espota2.OTANetworkError, match="empty noise frame"):
|
||||
wrapper.recv(1)
|
||||
|
||||
|
||||
def test_recv_frame_bad_indicator_is_retryable() -> None:
|
||||
wrapper = _wrapper(b"\x02\x00\x01x")
|
||||
with pytest.raises(espota2.OTANetworkError, match="Bad noise frame indicator"):
|
||||
wrapper._recv_frame()
|
||||
|
||||
|
||||
def test_recv_frame_zero_length_is_retryable() -> None:
|
||||
wrapper = _wrapper(bytes([espota2.NOISE_FRAME_INDICATOR, 0, 0]))
|
||||
with pytest.raises(espota2.OTANetworkError, match="empty noise frame"):
|
||||
wrapper._recv_frame()
|
||||
|
||||
|
||||
def test_perform_ota_blank_key_refuses_plaintext() -> None:
|
||||
with pytest.raises(espota2.OTAError, match="empty OTA encryption key"):
|
||||
espota2.perform_ota(
|
||||
ScriptedSocket(), None, io.BytesIO(b"x"), Path("f.bin"), noise_psk=""
|
||||
)
|
||||
|
||||
|
||||
def test_recv_exact_closed_mid_frame() -> None:
|
||||
wrapper = _wrapper(_frame(b"partial")[:5])
|
||||
with pytest.raises(OSError, match="closed inside a noise frame"):
|
||||
wrapper._recv_frame()
|
||||
|
||||
|
||||
def test_recv_serves_buffered_plaintext_without_new_frame() -> None:
|
||||
"""A second recv drains the decrypted buffer without reading another frame."""
|
||||
wrapper = _wrapper(_frame(b"ciphertext"))
|
||||
wrapper._decrypt = Mock(decrypt=Mock(return_value=b"AB"))
|
||||
assert wrapper.recv(1) == b"A" # reads and decrypts one frame
|
||||
assert wrapper.recv(1) == b"B" # served from the buffer, no new frame
|
||||
wrapper._decrypt.decrypt.assert_called_once()
|
||||
@@ -714,6 +714,25 @@ def test_run_git_command_without_git_dir_raises_error(
|
||||
git.run_git_command(["git", "clone", "https://invalid.url/repo.git"])
|
||||
|
||||
|
||||
def test_has_complete_clone(tmp_path: Path) -> None:
|
||||
"""The lock-free probe tracks the completion marker, subpath included."""
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
|
||||
url = "https://github.com/test/repo"
|
||||
subpath = Path("lib")
|
||||
assert not git.has_complete_clone(url, "v1", "test_domain", subpath)
|
||||
|
||||
repo_dir = _compute_repo_dir(url, "v1", "test_domain") / subpath
|
||||
(repo_dir / ".git").mkdir(parents=True)
|
||||
# A directory without the marker is an incomplete clone
|
||||
assert not git.has_complete_clone(url, "v1", "test_domain", subpath)
|
||||
|
||||
_mark_clone_complete(repo_dir)
|
||||
assert git.has_complete_clone(url, "v1", "test_domain", subpath)
|
||||
# The ref is part of the cache key
|
||||
assert not git.has_complete_clone(url, "v2", "test_domain", subpath)
|
||||
|
||||
|
||||
def test_clone_or_update_with_never_refresh(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
|
||||
@@ -87,7 +87,9 @@ from esphome.const import (
|
||||
CONF_BROKER,
|
||||
CONF_DISABLED,
|
||||
CONF_DISCOVER_IP,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_ESPHOME,
|
||||
CONF_KEY,
|
||||
CONF_LEVEL,
|
||||
CONF_LOG,
|
||||
CONF_LOG_TOPIC,
|
||||
@@ -113,6 +115,7 @@ from esphome.const import (
|
||||
PLATFORM_BK72XX,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_ESP8266,
|
||||
PLATFORM_HOST,
|
||||
PLATFORM_NRF52,
|
||||
PLATFORM_RP2,
|
||||
Toolchain,
|
||||
@@ -2106,10 +2109,65 @@ def test_upload_program_ota_success(
|
||||
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
|
||||
)
|
||||
mock_run_ota.assert_called_once_with(
|
||||
["192.168.1.100"], 3232, "secret", expected_firmware, OTA_TYPE_UPDATE_APP
|
||||
["192.168.1.100"], 3232, "secret", expected_firmware, OTA_TYPE_UPDATE_APP, None
|
||||
)
|
||||
|
||||
|
||||
def test_upload_program_ota_encryption_key(
|
||||
mock_run_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The resolved encryption key is passed through to run_ota."""
|
||||
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")
|
||||
|
||||
key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
config = {
|
||||
CONF_OTA: [
|
||||
{
|
||||
CONF_PLATFORM: CONF_ESPHOME,
|
||||
CONF_PORT: 3232,
|
||||
CONF_ENCRYPTION: {CONF_KEY: key},
|
||||
}
|
||||
]
|
||||
}
|
||||
exit_code, host = upload_program(config, MockArgs(), ["192.168.1.100"])
|
||||
|
||||
assert exit_code == 0
|
||||
assert host == "192.168.1.100"
|
||||
expected_firmware = (
|
||||
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
|
||||
)
|
||||
mock_run_ota.assert_called_once_with(
|
||||
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP, key
|
||||
)
|
||||
|
||||
|
||||
def test_upload_program_ota_encryption_without_key_fails_closed(
|
||||
mock_run_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An encryption block with no resolved key must never upload plaintext."""
|
||||
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_ENCRYPTION: {},
|
||||
}
|
||||
]
|
||||
}
|
||||
with pytest.raises(EsphomeError, match="no key was resolved"):
|
||||
upload_program(config, MockArgs(), ["192.168.1.100"])
|
||||
mock_run_ota.assert_not_called()
|
||||
|
||||
|
||||
def test_upload_program_ota_with_file_arg(
|
||||
mock_run_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
@@ -2137,7 +2195,7 @@ def test_upload_program_ota_with_file_arg(
|
||||
assert exit_code == 0
|
||||
assert host == "192.168.1.100"
|
||||
mock_run_ota.assert_called_once_with(
|
||||
["192.168.1.100"], 3232, None, Path("custom.bin"), OTA_TYPE_UPDATE_APP
|
||||
["192.168.1.100"], 3232, None, Path("custom.bin"), OTA_TYPE_UPDATE_APP, None
|
||||
)
|
||||
|
||||
|
||||
@@ -2192,6 +2250,7 @@ def test_upload_program_ota_partition_table_with_file_arg(
|
||||
None,
|
||||
partition_file,
|
||||
OTA_TYPE_UPDATE_PARTITION_TABLE,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@@ -2253,6 +2312,7 @@ def test_upload_program_ota_partition_table_mqttip(
|
||||
None,
|
||||
partition_file,
|
||||
OTA_TYPE_UPDATE_PARTITION_TABLE,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@@ -2440,6 +2500,7 @@ def test_upload_program_ota_bootloader_with_file_arg(
|
||||
None,
|
||||
bootloader_file,
|
||||
OTA_TYPE_UPDATE_BOOTLOADER,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@@ -2602,6 +2663,42 @@ def test_has_web_server_logging_respects_log_disabled() -> None:
|
||||
assert has_web_server_logging() is False
|
||||
|
||||
|
||||
def test_upload_program_web_server_warns_when_encryption_configured(
|
||||
mock_run_web_server_ota: Mock,
|
||||
mock_run_ota: Mock,
|
||||
mock_get_port_type: Mock,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Explicitly picking web_server OTA on an encrypted config warns about
|
||||
the plaintext upload path."""
|
||||
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_ENCRYPTION: {CONF_KEY: "test_key"},
|
||||
},
|
||||
{CONF_PLATFORM: CONF_WEB_SERVER},
|
||||
],
|
||||
CONF_WEB_SERVER: {
|
||||
CONF_PORT: 80,
|
||||
CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "pw"},
|
||||
},
|
||||
}
|
||||
args = MockArgs(ota_platform=CONF_WEB_SERVER)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
exit_code, _ = upload_program(config, args, ["192.168.1.100"])
|
||||
|
||||
assert exit_code == 0
|
||||
assert any("plaintext HTTP" in record.message for record in caplog.records)
|
||||
mock_run_ota.assert_not_called()
|
||||
|
||||
|
||||
def test_upload_program_web_server_only_auto_dispatches(
|
||||
mock_run_web_server_ota: Mock,
|
||||
mock_run_ota: Mock,
|
||||
@@ -2892,7 +2989,7 @@ def test_upload_program_ota_with_mqtt_resolution(
|
||||
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
|
||||
)
|
||||
mock_run_ota.assert_called_once_with(
|
||||
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP
|
||||
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP, None
|
||||
)
|
||||
|
||||
|
||||
@@ -2942,7 +3039,7 @@ def test_upload_program_ota_with_mqtt_empty_broker(
|
||||
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
|
||||
)
|
||||
mock_run_ota.assert_called_once_with(
|
||||
["192.168.1.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP
|
||||
["192.168.1.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP, None
|
||||
)
|
||||
# Verify warning was logged
|
||||
assert "MQTT IP discovery failed" in caplog.text
|
||||
@@ -5114,6 +5211,7 @@ def test_upload_program_ota_static_ip_with_mqttip(
|
||||
None,
|
||||
expected_firmware,
|
||||
OTA_TYPE_UPDATE_APP,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@@ -5163,6 +5261,7 @@ def test_upload_program_ota_multiple_mqttip_resolves_once(
|
||||
None,
|
||||
expected_firmware,
|
||||
OTA_TYPE_UPDATE_APP,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@@ -5340,7 +5439,7 @@ def test_upload_program_ota_mqtt_timeout_fallback(
|
||||
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
|
||||
)
|
||||
mock_run_ota.assert_called_once_with(
|
||||
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP
|
||||
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP, None
|
||||
)
|
||||
|
||||
|
||||
@@ -7513,3 +7612,54 @@ async def test_wrap_to_code_comment_is_insertion_order_independent() -> None:
|
||||
assert first == second
|
||||
assert second.index("alpha") < second.index("beta")
|
||||
assert second.index("a: 2") < second.index("z: 1")
|
||||
|
||||
|
||||
def test_host_program_path_platformio_toolchain() -> None:
|
||||
"""Host + PlatformIO toolchain reads the memoized idedata path."""
|
||||
setup_core(platform=PLATFORM_HOST)
|
||||
idedata = SimpleNamespace(firmware_elf_path="/build/x/.pioenvs/x/program")
|
||||
with patch(
|
||||
"esphome.platformio.toolchain.get_idedata", return_value=idedata
|
||||
) as mock_get:
|
||||
assert main._host_program_path({}) == "/build/x/.pioenvs/x/program"
|
||||
mock_get.assert_called_once_with({})
|
||||
|
||||
|
||||
def test_host_program_path_esp_idf_toolchain() -> None:
|
||||
"""Host + native ESP-IDF toolchain asks the espidf toolchain for the ELF."""
|
||||
setup_core(platform=PLATFORM_HOST)
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
with patch(
|
||||
"esphome.espidf.toolchain.get_elf_path", return_value=Path("/b/app.elf")
|
||||
):
|
||||
assert main._host_program_path({}) == str(Path("/b/app.elf"))
|
||||
|
||||
|
||||
def test_command_compile_host_logs_program_path(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""command_compile on host logs the compiled program path."""
|
||||
setup_core(platform=PLATFORM_HOST)
|
||||
with (
|
||||
patch.object(main, "write_cpp", return_value=0),
|
||||
patch.object(main, "compile_program", return_value=0),
|
||||
patch.object(main, "_host_program_path", return_value="/b/program"),
|
||||
caplog.at_level(logging.INFO),
|
||||
):
|
||||
assert main.command_compile(SimpleNamespace(only_generate=False), {}) == 0
|
||||
assert "Successfully compiled program to path '/b/program'" in caplog.text
|
||||
|
||||
|
||||
def test_command_run_host_executes_program(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""command_run on host logs and executes the compiled program directly."""
|
||||
setup_core(platform=PLATFORM_HOST)
|
||||
with (
|
||||
patch.object(main, "write_cpp", return_value=0),
|
||||
patch.object(main, "compile_program", return_value=0),
|
||||
patch.object(main, "_host_program_path", return_value="/b/program"),
|
||||
patch.object(main, "run_external_process", return_value=0) as mock_run,
|
||||
caplog.at_level(logging.INFO),
|
||||
):
|
||||
assert main.command_run(SimpleNamespace(), {}) == 0
|
||||
mock_run.assert_called_with("/b/program")
|
||||
assert "Running program from path '/b/program'" in caplog.text
|
||||
|
||||
@@ -638,7 +638,7 @@ def test_prefetch_wave_downloads_registry_archives_in_parallel(
|
||||
setup_core, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Registry archives in one wave download concurrently, deduped by URL;
|
||||
git/local sources and failures are left to the sequential call."""
|
||||
local sources and failures are left to the sequential call."""
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_download(
|
||||
@@ -658,7 +658,7 @@ def test_prefetch_wave_downloads_registry_archives_in_parallel(
|
||||
# into the same cache directory)
|
||||
("b2", ConvertedLibrary("b2", "1.0", URLSource("https://x/b.tar.gz", 1))),
|
||||
("c", ConvertedLibrary("c", "1.0", URLSource("https://x/boom.tar.gz", 1))),
|
||||
("g", ConvertedLibrary("g", "*", lib.GitSource("https://x/g.git", None))),
|
||||
("l", ConvertedLibrary("l", "*", LocalSource("/some/lib"))),
|
||||
]
|
||||
lib._prefetch_wave(wave, "", "idf")
|
||||
assert sorted(calls) == [
|
||||
@@ -670,6 +670,83 @@ def test_prefetch_wave_downloads_registry_archives_in_parallel(
|
||||
assert "Prefetch of c failed (retrying sequentially)" in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_wave_clones_git_sources_in_parallel(
|
||||
setup_core, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Git sources join the same prefetch batch as the archives, deduped by
|
||||
clone target; a clone failure warns and is left to the sequential call."""
|
||||
caplog.set_level("INFO")
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_clone(self, dir_suffix, force=False, salt="", namespace=""):
|
||||
calls.append(f"{self}/{dir_suffix}")
|
||||
if "boom" in self.url:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(GitSource, "download", fake_clone)
|
||||
wave = [
|
||||
("a", ConvertedLibrary("a", "1.0", URLSource("https://x/a.tar.gz", 1))),
|
||||
("g", ConvertedLibrary("g", "*", GitSource("https://x/g.git", "v1"))),
|
||||
# Same url@ref and target dir must clone once
|
||||
("g2", ConvertedLibrary("g", "*", GitSource("https://x/g.git", "v1"))),
|
||||
("h", ConvertedLibrary("h", "*", GitSource("https://x/boom.git", None))),
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
URLSource, "download", lambda self, dir_suffix, progress=None, **kw: None
|
||||
)
|
||||
lib._prefetch_wave(wave, "", "idf")
|
||||
assert sorted(calls) == ["https://x/boom.git/h", "https://x/g.git#v1/g"]
|
||||
assert "Cloning 2 library repo(s): g, h" in caplog.text
|
||||
assert "Prefetch of h failed (retrying sequentially)" in caplog.text
|
||||
|
||||
|
||||
def test_source_base_prefetch_defaults() -> None:
|
||||
"""The base Source is not prefetchable and reports cached (nothing to do)."""
|
||||
source = Source()
|
||||
assert source.prefetch_key("x") is None
|
||||
assert source.is_cached("x") is True
|
||||
|
||||
|
||||
def test_prefetch_wave_single_clone_uses_the_batch(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A wave with only git sources still clones through the batch runner."""
|
||||
caplog.set_level("INFO")
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(GitSource, "is_cached", lambda self, *a, **kw: False)
|
||||
monkeypatch.setattr(
|
||||
GitSource,
|
||||
"download",
|
||||
lambda self, dir_suffix, force=False, salt="", namespace="": calls.append(
|
||||
self.url
|
||||
),
|
||||
)
|
||||
lib._prefetch_wave(
|
||||
[("g", ConvertedLibrary("g", "*", GitSource("https://x/g.git", None)))],
|
||||
"",
|
||||
"idf",
|
||||
)
|
||||
assert calls == ["https://x/g.git"]
|
||||
assert "Cloning 1 library repo(s): g" in caplog.text
|
||||
assert "Downloading" not in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_wave_warm_git_cache_is_silent(
|
||||
setup_core, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An already-complete clone is neither re-fetched nor announced."""
|
||||
caplog.set_level("INFO")
|
||||
monkeypatch.setattr(
|
||||
GitSource,
|
||||
"download",
|
||||
lambda self, dir_suffix, **kw: (_ for _ in ()).throw(AssertionError("cloned")),
|
||||
)
|
||||
monkeypatch.setattr(GitSource, "is_cached", lambda self, *a, **kw: True)
|
||||
wave = [("g", ConvertedLibrary("g", "*", GitSource("https://x/g.git", None)))]
|
||||
lib._prefetch_wave(wave, "", "idf")
|
||||
assert "Cloning" not in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_wave_unknown_size_left_to_sequential(
|
||||
setup_core, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
||||
@@ -13,6 +13,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from filelock import Timeout
|
||||
from platformio.dependencies import get_core_dependencies
|
||||
from platformio.package.manager._install import PackageManagerInstallMixin
|
||||
from platformio.package.manager.base import BasePackageManager
|
||||
from platformio.package.manager.library import LibraryPackageManager
|
||||
@@ -1417,6 +1418,76 @@ def test_prefetch_installs_cached_archives_without_downloads(
|
||||
assert not (tmp_path / pf._SENTINEL_NAME).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform_group", "lib_group", "expected"),
|
||||
[
|
||||
(
|
||||
[("toolchain-x@1", _FakeSpec(name="toolchain-x"))],
|
||||
[],
|
||||
["configure", "install", "configure"],
|
||||
),
|
||||
([], [("noise-c@1.0", _FakeSpec(name="noise-c"))], ["configure", "install"]),
|
||||
],
|
||||
)
|
||||
def test_prefetch_reconfigures_only_after_platform_installs(
|
||||
tmp_path: Path, platform_group: list, lib_group: list, expected: list[str]
|
||||
) -> None:
|
||||
"""Installed platform packages get a second configure pass; libraries do not."""
|
||||
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
|
||||
order: list[str] = []
|
||||
fake_platform = MagicMock()
|
||||
fake_platform.packages = {}
|
||||
fake_platform.configure_project_packages.side_effect = lambda env, targets: (
|
||||
order.append("configure")
|
||||
)
|
||||
config = _fake_config(
|
||||
tmp_path, {"platform": "fake/p@1", "lib_deps": ["esphome/noise-c@1.0"]}
|
||||
)
|
||||
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
|
||||
with (
|
||||
patch.dict("sys.modules", modules),
|
||||
patch.object(
|
||||
pf,
|
||||
"_registry_jobs",
|
||||
side_effect=[([], 0, platform_group), ([], 0, lib_group)],
|
||||
),
|
||||
patch.object(pf, "_uri_jobs", return_value=([], 0, [])),
|
||||
patch.object(pf, "_preinstall", side_effect=lambda *_: order.append("install")),
|
||||
):
|
||||
pf._prefetch(tmp_path, "testenv")
|
||||
assert order == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"err", [RuntimeError("idf_tools.py failed"), SystemExit("postinstall exited")]
|
||||
)
|
||||
def test_prefetch_settle_failure_warns_and_continues(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture, err: BaseException
|
||||
) -> None:
|
||||
"""A failing second configure pass only costs the speedup."""
|
||||
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
|
||||
fake_platform = MagicMock()
|
||||
fake_platform.packages = {}
|
||||
fake_platform.configure_project_packages.side_effect = [None, err]
|
||||
config = _fake_config(tmp_path, {"platform": "fake/p@1"})
|
||||
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
|
||||
with (
|
||||
patch.dict("sys.modules", modules),
|
||||
patch.object(
|
||||
pf,
|
||||
"_registry_jobs",
|
||||
side_effect=[
|
||||
([], 0, [("toolchain-x@1", _FakeSpec(name="toolchain-x"))]),
|
||||
([], 0, []),
|
||||
],
|
||||
),
|
||||
patch.object(pf, "_uri_jobs", return_value=([], 0, [])),
|
||||
patch.object(pf, "_preinstall"),
|
||||
):
|
||||
pf._prefetch(tmp_path, "testenv")
|
||||
assert f"Could not settle platform packages: {err}" in caplog.text
|
||||
|
||||
|
||||
def test_preinstall_extracts_in_parallel_under_one_lock(tmp_path: Path) -> None:
|
||||
"""The manager lock wraps the whole batch; per-thread managers share
|
||||
its package dir; one failing install leaves the rest alone."""
|
||||
@@ -1658,30 +1729,31 @@ def test_preinstall_unlocks_even_when_pool_fails(tmp_path: Path) -> None:
|
||||
m.unlock.assert_called_once_with()
|
||||
|
||||
|
||||
def test_prefetch_skips_duplicate_tool_scons(tmp_path: Path) -> None:
|
||||
"""A platform that lists tool-scons itself does not get it appended."""
|
||||
def test_prefetch_replaces_platform_tool_scons_with_core_spec(tmp_path: Path) -> None:
|
||||
"""A platform's own tool-scons spec gives way to the core's registry spec."""
|
||||
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
|
||||
fake_platform = MagicMock()
|
||||
fake_platform.packages = {"tool-scons": {"optional": False}}
|
||||
fake_platform.get_package_spec.side_effect = lambda name: _FakeSpec(
|
||||
uri=None, name=name
|
||||
uri="https://x/scons.zip", name=name, owner=None
|
||||
)
|
||||
config = _fake_config(tmp_path, {"platform": "fake/p@1"})
|
||||
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
|
||||
batches: list[list[str]] = []
|
||||
batches: list[list] = []
|
||||
with (
|
||||
patch.dict("sys.modules", modules),
|
||||
patch.object(
|
||||
pf,
|
||||
"_registry_jobs",
|
||||
side_effect=lambda mgr, specs, seen: (
|
||||
batches.append([s.name for s in specs]) or ([], 0, [])
|
||||
batches.append(list(specs)) or ([], 0, [])
|
||||
),
|
||||
),
|
||||
patch.object(pf, "_uri_jobs", return_value=([], 0, [])),
|
||||
):
|
||||
pf._prefetch(tmp_path, "testenv")
|
||||
assert batches[0] == ["tool-scons"]
|
||||
(spec,) = batches[0]
|
||||
assert (spec.name, spec.owner, spec.uri) == ("tool-scons", "platformio", None)
|
||||
|
||||
|
||||
def test_platformio_private_api_contract() -> None:
|
||||
@@ -1714,6 +1786,8 @@ def test_platformio_private_api_contract() -> None:
|
||||
assert callable(getattr(BasePackageManager, name))
|
||||
# The dependency wave mirrors install_dependency's builtin skip
|
||||
assert callable(LibraryPackageManager.is_builtin_lib)
|
||||
# The prefetch keys tool-scons on this core dependency
|
||||
assert "tool-scons" in get_core_dependencies()
|
||||
# The pre-install passes these positionally / by keyword
|
||||
assert "compatibility" in inspect.signature(BasePackageManager.__init__).parameters
|
||||
lib_params = inspect.signature(LibraryPackageManager.__init__).parameters
|
||||
|
||||
Reference in New Issue
Block a user