mirror of
https://github.com/esphome/esphome.git
synced 2026-09-03 19:46:02 +00:00
[nrf52] add upload for native build (#17100)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
This commit is contained in:
co-authored by
Jonathan Swoboda
parent
a0742a9535
commit
690e8c3fb9
@@ -4,6 +4,7 @@ import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from esphome import pins
|
||||
@@ -486,6 +487,16 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
|
||||
from esphome.__main__ import check_permissions
|
||||
from esphome.upload_targets import PortType, get_port_type
|
||||
|
||||
if KEY_ZEPHYR not in CORE.data:
|
||||
platform_config = config.get(CORE.target_platform)
|
||||
if not platform_config:
|
||||
raise EsphomeError(
|
||||
"nRF52 platform configuration is missing; "
|
||||
"please re-validate and recompile."
|
||||
)
|
||||
set_core_data(platform_config)
|
||||
set_framework(platform_config)
|
||||
|
||||
mcumgr_device: str | None = None
|
||||
|
||||
if get_port_type(host) == PortType.SERIAL:
|
||||
@@ -494,17 +505,122 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
|
||||
mcumgr_device = host
|
||||
else:
|
||||
if not CORE.using_toolchain_platformio:
|
||||
raise EsphomeError("Not implemented yet")
|
||||
result = _upload_using_platformio(config, host, ["-t", "upload"])
|
||||
if result != 0:
|
||||
raise EsphomeError(f"Upload failed with result: {result}")
|
||||
return True # Handled: platformio serial upload
|
||||
bootloader = zephyr_data()[KEY_BOOTLOADER]
|
||||
if bootloader not in (
|
||||
BOOTLOADER_ADAFRUIT,
|
||||
BOOTLOADER_ADAFRUIT_NRF52_SD132,
|
||||
BOOTLOADER_ADAFRUIT_NRF52_SD140_V6,
|
||||
BOOTLOADER_ADAFRUIT_NRF52_SD140_V7,
|
||||
):
|
||||
raise EsphomeError("Not implemented yet")
|
||||
check_and_install()
|
||||
paths = get_build_paths()
|
||||
env = get_build_env()
|
||||
build_dir = CORE.relative_pioenvs_path(CORE.name)
|
||||
dfu_package = build_dir / "firmware.zip"
|
||||
if not dfu_package.is_file():
|
||||
raise EsphomeError("Firmware not found. Please compile first.")
|
||||
import time as _time
|
||||
|
||||
import serial as _serial
|
||||
import serial.tools.list_ports as _list_ports
|
||||
|
||||
try:
|
||||
ser = _serial.Serial(host, baudrate=1200, timeout=1)
|
||||
ser.close()
|
||||
except _serial.SerialException as err:
|
||||
raise EsphomeError(f"Failed to open {host}: {err}") from err
|
||||
|
||||
# Wait for device to reset (port disappears)
|
||||
deadline = _time.monotonic() + 5
|
||||
while _time.monotonic() < deadline:
|
||||
_time.sleep(0.1)
|
||||
if host not in {p.device for p in _list_ports.comports()}:
|
||||
break
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Device did not leave %s within 5 s; "
|
||||
"it may not have entered bootloader mode",
|
||||
host,
|
||||
)
|
||||
|
||||
# Wait for DFU port to reappear
|
||||
deadline = _time.monotonic() + 10
|
||||
while _time.monotonic() < deadline:
|
||||
_time.sleep(0.1)
|
||||
if host in {p.device for p in _list_ports.comports()}:
|
||||
break
|
||||
else:
|
||||
raise EsphomeError(
|
||||
f"DFU port {host!r} did not reappear within 10 s. "
|
||||
"Check that the device entered DFU mode."
|
||||
)
|
||||
|
||||
# Wait for udev to finish setting up device permissions
|
||||
deadline = _time.monotonic() + 5
|
||||
while _time.monotonic() < deadline:
|
||||
try:
|
||||
check_permissions(host)
|
||||
break
|
||||
except EsphomeError:
|
||||
_time.sleep(0.05)
|
||||
else:
|
||||
check_permissions(host) # raises with helpful message
|
||||
|
||||
python = str(paths["python_executable"])
|
||||
if not run_command_ok(
|
||||
[
|
||||
python,
|
||||
"-m",
|
||||
"nordicsemi.__main__",
|
||||
"dfu",
|
||||
"serial",
|
||||
"-pkg",
|
||||
str(dfu_package),
|
||||
"-p",
|
||||
host,
|
||||
"-b",
|
||||
"115200",
|
||||
"--singlebank",
|
||||
],
|
||||
env=env,
|
||||
stream_output=True,
|
||||
):
|
||||
raise EsphomeError("nRF52 serial DFU upload failed")
|
||||
else:
|
||||
result = _upload_using_platformio(config, host, ["-t", "upload"])
|
||||
if result != 0:
|
||||
raise EsphomeError(f"Upload failed with result: {result}")
|
||||
return True # Handled: serial upload
|
||||
|
||||
if host == "PYOCD":
|
||||
result = _upload_using_platformio(config, host, ["-t", "flash_pyocd"])
|
||||
if result != 0:
|
||||
raise EsphomeError(f"Upload failed with result: {result}")
|
||||
return True # Handled: platformio PYOCD upload
|
||||
if not CORE.using_toolchain_platformio:
|
||||
check_and_install()
|
||||
paths = get_build_paths()
|
||||
env = get_build_env()
|
||||
build_dir = CORE.relative_pioenvs_path(CORE.name)
|
||||
west_cmd = [
|
||||
str(paths["python_executable"]),
|
||||
"-m",
|
||||
"west",
|
||||
"flash",
|
||||
"--runner",
|
||||
"pyocd",
|
||||
"-d",
|
||||
str(build_dir),
|
||||
]
|
||||
if not run_command_ok(
|
||||
west_cmd,
|
||||
env=env,
|
||||
stream_output=True,
|
||||
cwd=str(paths["framework_path"]),
|
||||
):
|
||||
raise EsphomeError("nRF52 pyocd flash failed")
|
||||
else:
|
||||
result = _upload_using_platformio(config, host, ["-t", "flash_pyocd"])
|
||||
if result != 0:
|
||||
raise EsphomeError(f"Upload failed with result: {result}")
|
||||
return True # Handled: PYOCD upload
|
||||
|
||||
# Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths
|
||||
from .ble_logger import is_mac_address
|
||||
@@ -662,4 +778,43 @@ def run_compile(args, config: ConfigType) -> bool:
|
||||
):
|
||||
raise EsphomeError("nRF52 native build failed")
|
||||
|
||||
# Zephyr's cmake places kernel artifacts in build_dir/zephyr/zephyr/ and
|
||||
# merged.hex at build_dir/. Normalize to build_dir/zephyr/ so paths match
|
||||
# get_download_types (which mirrors the platformio build output layout).
|
||||
zephyr_dir = build_dir / "zephyr"
|
||||
west_out = zephyr_dir / "zephyr"
|
||||
for filename in ["zephyr.uf2"]:
|
||||
src = west_out / filename
|
||||
if src.is_file():
|
||||
shutil.copy2(src, zephyr_dir / filename)
|
||||
|
||||
# (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes
|
||||
_GENPKG_PARAMS = {
|
||||
BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"),
|
||||
BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"),
|
||||
BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"),
|
||||
}
|
||||
bootloader = zephyr_data()[KEY_BOOTLOADER]
|
||||
if bootloader in (
|
||||
BOOTLOADER_ADAFRUIT,
|
||||
BOOTLOADER_ADAFRUIT_NRF52_SD132,
|
||||
BOOTLOADER_ADAFRUIT_NRF52_SD140_V6,
|
||||
BOOTLOADER_ADAFRUIT_NRF52_SD140_V7,
|
||||
):
|
||||
hex_file = west_out / "zephyr.hex"
|
||||
dfu_package = build_dir / "firmware.zip"
|
||||
genpkg_cmd = [
|
||||
str(paths["python_executable"]),
|
||||
"-m",
|
||||
"nordicsemi.__main__",
|
||||
"dfu",
|
||||
"genpkg",
|
||||
]
|
||||
if bootloader in _GENPKG_PARAMS:
|
||||
dev_type, sd_req = _GENPKG_PARAMS[bootloader]
|
||||
genpkg_cmd += ["--dev-type", dev_type, "--sd-req", sd_req]
|
||||
genpkg_cmd += ["--application", str(hex_file), str(dfu_package)]
|
||||
if not run_command_ok(genpkg_cmd, env=env, stream_output=True):
|
||||
raise EsphomeError("Failed to create adafruit DFU package")
|
||||
|
||||
return True
|
||||
|
||||
@@ -111,10 +111,9 @@ def _get_version_str() -> str:
|
||||
|
||||
def get_build_paths() -> dict:
|
||||
version = _get_version_str()
|
||||
env_path = _get_python_env_path(version)
|
||||
return {
|
||||
"python_executable": get_python_env_executable_path(
|
||||
_get_python_env_path(version), "python"
|
||||
),
|
||||
"python_executable": get_python_env_executable_path(env_path, "python"),
|
||||
"framework_path": _get_framework_path(version),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
west==1.5.0
|
||||
ninja==1.13.0
|
||||
cmake==4.3.2
|
||||
adafruit-nrfutil @ git+https://github.com/adafruit/Adafruit_nRF52_nrfutil.git@7fdfe15feee5f304fb7d9b031721dcefa1f72b58
|
||||
|
||||
@@ -12,6 +12,7 @@ from esphome.const import (
|
||||
CONF_DISABLED,
|
||||
CONF_MDNS,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
KEY_TARGET_PLATFORM,
|
||||
Toolchain,
|
||||
@@ -179,6 +180,8 @@ class StorageJSON:
|
||||
|
||||
hardware = esp32.get_esp32_variant(esph)
|
||||
framework_version = str(esp32.idf_version())
|
||||
elif esph.is_nrf52:
|
||||
framework_version = str(esph.data[KEY_CORE][KEY_FRAMEWORK_VERSION])
|
||||
return StorageJSON(
|
||||
storage_version=1,
|
||||
name=esph.name,
|
||||
@@ -334,6 +337,19 @@ class StorageJSON:
|
||||
f"Please clean the build files and recompile."
|
||||
) from err
|
||||
CORE.data[KEY_ESP32] = esp32_data
|
||||
elif target_platform == const.PLATFORM_NRF52 and self.framework_version:
|
||||
import esphome.config_validation as cv
|
||||
|
||||
try:
|
||||
CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse(
|
||||
self.framework_version
|
||||
)
|
||||
except ValueError as err:
|
||||
raise EsphomeError(
|
||||
f"Could not parse the framework version "
|
||||
f"{self.framework_version!r} from {storage_path()}. "
|
||||
f"Please clean the build files and recompile."
|
||||
) from err
|
||||
|
||||
def __eq__(self, o) -> bool:
|
||||
return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict()
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Tests for esphome.components.nrf52 upload_program and run_compile."""
|
||||
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT_NRF52_SD140_V7
|
||||
from esphome.components.zephyr.const import (
|
||||
KEY_BOARD,
|
||||
KEY_BOOTLOADER,
|
||||
KEY_EXTRA_BUILD_FILES,
|
||||
KEY_KCONFIG,
|
||||
KEY_OVERLAY,
|
||||
KEY_PM_STATIC,
|
||||
KEY_PRJ_CONF,
|
||||
KEY_USER,
|
||||
KEY_ZEPHYR,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
KEY_TARGET_PLATFORM,
|
||||
PLATFORM_NRF52,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _setup_nrf52_core(
|
||||
bootloader: str = BOOTLOADER_ADAFRUIT_NRF52_SD140_V7,
|
||||
toolchain: Toolchain = Toolchain.SDK_NRF,
|
||||
build_path: Path | None = None,
|
||||
) -> None:
|
||||
CORE.name = "test_device"
|
||||
if build_path is not None:
|
||||
CORE.build_path = build_path
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: PLATFORM_NRF52,
|
||||
KEY_TARGET_FRAMEWORK: KEY_ZEPHYR,
|
||||
KEY_FRAMEWORK_VERSION: cv.Version(2, 9, 2),
|
||||
}
|
||||
CORE.toolchain = toolchain
|
||||
CORE.data[KEY_ZEPHYR] = {
|
||||
KEY_BOARD: "adafruit_feather_nrf52840",
|
||||
KEY_BOOTLOADER: bootloader,
|
||||
KEY_PRJ_CONF: {},
|
||||
KEY_OVERLAY: {"": ""},
|
||||
KEY_EXTRA_BUILD_FILES: {},
|
||||
KEY_PM_STATIC: [],
|
||||
KEY_USER: {},
|
||||
KEY_KCONFIG: "",
|
||||
}
|
||||
|
||||
|
||||
def _make_paths(tmp_path: Path) -> dict:
|
||||
return {
|
||||
"python_executable": tmp_path / "penv" / "python",
|
||||
"framework_path": tmp_path / "framework",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config-reconstruction guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUploadProgramConfigGuard:
|
||||
def test_missing_platform_config_raises(self, setup_core: Path) -> None:
|
||||
"""upload_program raises EsphomeError when the platform config section is absent."""
|
||||
from esphome.components.nrf52 import upload_program
|
||||
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: PLATFORM_NRF52,
|
||||
KEY_TARGET_FRAMEWORK: KEY_ZEPHYR,
|
||||
}
|
||||
# KEY_ZEPHYR absent → reconstruction branch is entered
|
||||
assert KEY_ZEPHYR not in CORE.data
|
||||
|
||||
with pytest.raises(EsphomeError, match="platform configuration"):
|
||||
upload_program(config={}, args=None, host="PYOCD")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PYOCD upload path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUploadProgramPyocd:
|
||||
def test_pyocd_assembles_west_command(
|
||||
self, setup_core: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""West flash command must include --runner pyocd and the build dir."""
|
||||
from esphome.components.nrf52 import upload_program
|
||||
|
||||
_setup_nrf52_core(build_path=tmp_path / "build")
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
paths = _make_paths(tmp_path)
|
||||
build_dir = CORE.relative_pioenvs_path(CORE.name)
|
||||
|
||||
with (
|
||||
patch("esphome.components.nrf52.check_and_install"),
|
||||
patch("esphome.components.nrf52.get_build_paths", return_value=paths),
|
||||
patch("esphome.components.nrf52.get_build_env", return_value={}),
|
||||
patch(
|
||||
"esphome.components.nrf52.run_command_ok", return_value=True
|
||||
) as mock_run,
|
||||
):
|
||||
result = upload_program(config={}, args=None, host="PYOCD")
|
||||
|
||||
assert result is True
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert str(paths["python_executable"]) == cmd[0]
|
||||
assert "west" in cmd
|
||||
assert "flash" in cmd
|
||||
assert "--runner" in cmd
|
||||
assert "pyocd" in cmd
|
||||
assert "-d" in cmd
|
||||
assert str(build_dir) in cmd
|
||||
|
||||
def test_pyocd_failure_raises(self, setup_core: Path, tmp_path: Path) -> None:
|
||||
"""A failed west flash must raise EsphomeError."""
|
||||
from esphome.components.nrf52 import upload_program
|
||||
|
||||
_setup_nrf52_core(build_path=tmp_path / "build")
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
|
||||
with (
|
||||
patch("esphome.components.nrf52.check_and_install"),
|
||||
patch(
|
||||
"esphome.components.nrf52.get_build_paths",
|
||||
return_value=_make_paths(tmp_path),
|
||||
),
|
||||
patch("esphome.components.nrf52.get_build_env", return_value={}),
|
||||
patch("esphome.components.nrf52.run_command_ok", return_value=False),
|
||||
pytest.raises(EsphomeError, match="pyocd"),
|
||||
):
|
||||
upload_program(config={}, args=None, host="PYOCD")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serial DFU upload path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _enter_serial_dfu_patches(
|
||||
stack: ExitStack, host: str, tmp_path: Path, paths: dict
|
||||
) -> MagicMock:
|
||||
"""Enter all context managers needed for the serial DFU happy path.
|
||||
|
||||
Returns the mock for ``run_command_ok`` so callers can inspect calls.
|
||||
comports() returns [] on the first call (port disappeared) and a list
|
||||
containing the host on every subsequent call (port reappeared). Patches
|
||||
are applied directly on the real pyserial module attributes so they are
|
||||
visible to the deferred ``import serial[.tools.list_ports] as _x``
|
||||
statements inside upload_program.
|
||||
"""
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
|
||||
from esphome.upload_targets import PortType
|
||||
|
||||
_comports_calls = [0]
|
||||
|
||||
def _comports():
|
||||
_comports_calls[0] += 1
|
||||
if _comports_calls[0] == 1:
|
||||
return [] # port disappeared → disappear loop breaks
|
||||
return [MagicMock(device=host)] # port back → reappear loop breaks
|
||||
|
||||
stack.enter_context(
|
||||
patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL)
|
||||
)
|
||||
stack.enter_context(patch("esphome.__main__.check_permissions"))
|
||||
stack.enter_context(patch("esphome.components.nrf52.check_and_install"))
|
||||
stack.enter_context(
|
||||
patch("esphome.components.nrf52.get_build_paths", return_value=paths)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch("esphome.components.nrf52.get_build_env", return_value={})
|
||||
)
|
||||
stack.enter_context(patch("time.sleep"))
|
||||
# Patch directly on the real pyserial module so the deferred imports inside
|
||||
# upload_program see our mocks regardless of how sys.modules is cached.
|
||||
stack.enter_context(patch.object(serial, "Serial"))
|
||||
stack.enter_context(
|
||||
patch.object(serial.tools.list_ports, "comports", side_effect=_comports)
|
||||
)
|
||||
return stack.enter_context(
|
||||
patch("esphome.components.nrf52.run_command_ok", return_value=True)
|
||||
)
|
||||
|
||||
|
||||
class TestUploadProgramSerialDfu:
|
||||
def test_unsupported_bootloader_raises(
|
||||
self, setup_core: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""An unknown bootloader must raise EsphomeError before touching the port."""
|
||||
from esphome.components.nrf52 import upload_program
|
||||
from esphome.upload_targets import PortType
|
||||
|
||||
_setup_nrf52_core(
|
||||
bootloader="unknown_bootloader", build_path=tmp_path / "build"
|
||||
)
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
|
||||
with (
|
||||
patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL),
|
||||
patch("esphome.__main__.check_permissions"),
|
||||
pytest.raises(EsphomeError, match="Not implemented"),
|
||||
):
|
||||
upload_program(config={}, args=None, host="/dev/ttyACM0")
|
||||
|
||||
def test_missing_firmware_raises(self, setup_core: Path, tmp_path: Path) -> None:
|
||||
"""Missing firmware.zip must raise EsphomeError before opening the serial port."""
|
||||
from esphome.components.nrf52 import upload_program
|
||||
from esphome.upload_targets import PortType
|
||||
|
||||
_setup_nrf52_core(build_path=tmp_path / "build")
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
|
||||
with (
|
||||
patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL),
|
||||
patch("esphome.__main__.check_permissions"),
|
||||
patch("esphome.components.nrf52.check_and_install"),
|
||||
patch(
|
||||
"esphome.components.nrf52.get_build_paths",
|
||||
return_value=_make_paths(tmp_path),
|
||||
),
|
||||
patch("esphome.components.nrf52.get_build_env", return_value={}),
|
||||
pytest.raises(EsphomeError, match="Firmware not found"),
|
||||
):
|
||||
# firmware.zip does not exist on disk → is_file() returns False
|
||||
upload_program(config={}, args=None, host="/dev/ttyACM0")
|
||||
|
||||
def test_serial_dfu_assembles_nordicsemi_command(
|
||||
self, setup_core: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""Nordicsemi DFU command must include pkg path, port, and --singlebank."""
|
||||
from esphome.components.nrf52 import upload_program
|
||||
|
||||
_setup_nrf52_core(build_path=tmp_path / "build")
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
paths = _make_paths(tmp_path)
|
||||
build_dir = CORE.relative_pioenvs_path(CORE.name)
|
||||
dfu_package = build_dir / "firmware.zip"
|
||||
dfu_package.parent.mkdir(parents=True, exist_ok=True)
|
||||
dfu_package.touch()
|
||||
|
||||
host = "/dev/ttyACM0"
|
||||
with ExitStack() as stack:
|
||||
mock_run = _enter_serial_dfu_patches(stack, host, tmp_path, paths)
|
||||
result = upload_program(config={}, args=None, host=host)
|
||||
|
||||
assert result is True
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "nordicsemi.__main__" in cmd
|
||||
assert "dfu" in cmd
|
||||
assert "serial" in cmd
|
||||
assert "-pkg" in cmd
|
||||
assert str(dfu_package) in cmd
|
||||
assert "-p" in cmd
|
||||
assert host in cmd
|
||||
assert "--singlebank" in cmd
|
||||
|
||||
def test_serial_dfu_failure_raises(self, setup_core: Path, tmp_path: Path) -> None:
|
||||
"""A failed nordicsemi DFU must raise EsphomeError."""
|
||||
from esphome.components.nrf52 import upload_program
|
||||
|
||||
_setup_nrf52_core(build_path=tmp_path / "build")
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
paths = _make_paths(tmp_path)
|
||||
build_dir = CORE.relative_pioenvs_path(CORE.name)
|
||||
dfu_package = build_dir / "firmware.zip"
|
||||
dfu_package.parent.mkdir(parents=True, exist_ok=True)
|
||||
dfu_package.touch()
|
||||
|
||||
host = "/dev/ttyACM0"
|
||||
with ExitStack() as stack:
|
||||
mock_run = _enter_serial_dfu_patches(stack, host, tmp_path, paths)
|
||||
mock_run.return_value = False
|
||||
with pytest.raises(EsphomeError, match="serial DFU upload failed"):
|
||||
upload_program(config={}, args=None, host=host)
|
||||
@@ -352,6 +352,7 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None:
|
||||
mock_core.web_port = None
|
||||
mock_core.target_platform = "esp8266"
|
||||
mock_core.is_esp32 = False
|
||||
mock_core.is_nrf52 = False
|
||||
mock_core.build_path = "/build"
|
||||
mock_core.firmware_bin = "/build/firmware.bin"
|
||||
mock_core.loaded_integrations = set()
|
||||
@@ -366,6 +367,34 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None:
|
||||
assert result.toolchain is None
|
||||
|
||||
|
||||
def test_storage_json_from_esphome_core_nrf52(setup_core: Path) -> None:
|
||||
"""Test from_esphome_core captures the framework version on nRF52."""
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
|
||||
|
||||
mock_core = MagicMock()
|
||||
mock_core.name = "nrf_device"
|
||||
mock_core.friendly_name = "nRF Device"
|
||||
mock_core.comment = None
|
||||
mock_core.address = "nrf.local"
|
||||
mock_core.web_port = None
|
||||
mock_core.target_platform = "nrf52"
|
||||
mock_core.is_esp32 = False
|
||||
mock_core.is_nrf52 = True
|
||||
mock_core.data = {KEY_CORE: {KEY_FRAMEWORK_VERSION: cv.Version(2, 9, 2)}}
|
||||
mock_core.build_path = "/build/nrf_device"
|
||||
mock_core.firmware_bin = "/build/nrf_device/firmware.bin"
|
||||
mock_core.loaded_integrations = set()
|
||||
mock_core.loaded_platforms = set()
|
||||
mock_core.config = {}
|
||||
mock_core.target_framework = "zephyr"
|
||||
mock_core.toolchain = None
|
||||
|
||||
result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None)
|
||||
|
||||
assert result.target_platform == "NRF52"
|
||||
assert result.framework_version == "2.9.2"
|
||||
|
||||
|
||||
def test_storage_json_load_valid_file(tmp_path: Path) -> None:
|
||||
"""Test StorageJSON.load with valid JSON file."""
|
||||
storage_data = {
|
||||
@@ -787,6 +816,73 @@ def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None:
|
||||
assert result.esphome_version == "1.14.0" # Should map to esphome_version
|
||||
|
||||
|
||||
def _make_nrf52_storage(
|
||||
framework_version: str | None = None,
|
||||
) -> storage_json.StorageJSON:
|
||||
return storage_json.StorageJSON(
|
||||
storage_version=1,
|
||||
name="dev",
|
||||
friendly_name=None,
|
||||
comment=None,
|
||||
esphome_version="2024.1.0",
|
||||
src_version=1,
|
||||
address="dev.local",
|
||||
web_port=None,
|
||||
target_platform="NRF52",
|
||||
build_path=Path("/build"),
|
||||
firmware_bin_path=Path("/build/zephyr/zephyr.bin"),
|
||||
loaded_integrations=set(),
|
||||
loaded_platforms=set(),
|
||||
no_mdns=False,
|
||||
framework="zephyr",
|
||||
core_platform="nrf52",
|
||||
framework_version=framework_version,
|
||||
)
|
||||
|
||||
|
||||
def test_storage_json_nrf52_framework_version_round_trip(setup_core: Path) -> None:
|
||||
"""Sidecar framework_version restores CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]."""
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
|
||||
|
||||
storage = _make_nrf52_storage("2.9.2")
|
||||
path = setup_core / "storage.json"
|
||||
path.write_text(storage.to_json())
|
||||
|
||||
assert json.loads(path.read_text())["framework_version"] == "2.9.2"
|
||||
|
||||
loaded = storage_json.StorageJSON.load(path)
|
||||
assert loaded is not None
|
||||
assert loaded.framework_version == "2.9.2"
|
||||
|
||||
loaded.apply_to_core()
|
||||
assert CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] == cv.Version(2, 9, 2)
|
||||
|
||||
|
||||
def test_storage_json_nrf52_apply_to_core_without_framework_version(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""Older sidecars lacking framework_version don't populate KEY_FRAMEWORK_VERSION."""
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
|
||||
|
||||
loaded = _make_nrf52_storage(framework_version=None)
|
||||
assert loaded.framework_version is None
|
||||
|
||||
loaded.apply_to_core()
|
||||
assert KEY_FRAMEWORK_VERSION not in CORE.data[KEY_CORE]
|
||||
|
||||
|
||||
def test_storage_json_nrf52_apply_to_core_raises_on_invalid_framework_version(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A malformed version string fails with an actionable error at parse time."""
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
loaded = _make_nrf52_storage(framework_version="not-a-version")
|
||||
|
||||
with pytest.raises(EsphomeError, match="clean the build"):
|
||||
loaded.apply_to_core()
|
||||
|
||||
|
||||
def test_storage_json_load_area(tmp_path: Path) -> None:
|
||||
"""``area`` round-trips through load; absence loads as None."""
|
||||
file_path = tmp_path / "with_area.json"
|
||||
|
||||
Reference in New Issue
Block a user