Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ The import lands the main YAML plus its `!include`s and local external component
| `firmware/cancel` | `{job_id}` | — | Cancel queued or running job |
| `firmware/clear` | `{status?}` | — | Remove finished jobs |

**`firmware/get_binaries`**: returns only artifacts that exist in the build directory (an empty list means "compile first"), each `{title, file, type?, description?}`. `description` is optional subtext from the platform's `get_download_types`. `type` is a stable tag (`factory` / `ota` / `bin` / `uf2` / `elf`) the frontend maps to a localized label, falling back to `title` when absent or unrecognized. A `firmware.elf` entry (debug symbols for the ESP stack trace decoder) is appended when present; `get_download_types` itself never lists it.
**`firmware/get_binaries`**: returns only artifacts that exist in the build directory (an empty list means "compile first"), each `{title, file, type?, description?}`. `description` is optional subtext from the platform's `get_download_types`. `type` is a stable tag (`factory` / `ota` / `bin` / `uf2` / `elf`) the frontend maps to a localized label, falling back to `title` when absent or unrecognized. A `firmware.elf` entry (debug symbols for the ESP stack trace decoder) is appended when present; `get_download_types` itself never lists it. An nRF52 build's MCUboot update image (`zephyr/app_update.bin`) is appended the same way, since `get_download_types` lists it only for a build with no UF2.

**Downloading a `file`** goes over **HTTP**, not the WebSocket: call `firmware/download_token` to mint a token, then `GET /api/firmware/download?token=<token>`, which streams the raw file with `Content-Disposition: attachment`. A 14 MB `firmware.elf` as a single base64 WS message exceeds a proxy's WebSocket `max_msg_size` (HA ingress, nginx); HTTP has no such cap, skips the base64 overhead, and a plain `<a href>` navigation streams it straight to disk (no in-browser buffering — works on mobile). This matches the legacy ESPHome dashboard, which served downloads over HTTP.

Expand Down
24 changes: 21 additions & 3 deletions esphome_device_builder/controllers/firmware/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ def _platform_sets() -> _DownloadRouting:
_platform_sets()


# An nRF52 build with the mcumgr OTA chains MCUboot behind the Adafruit
# bootloader, so it has a UF2 and this update image; ``get_download_types``
# lists the image only for a build with no UF2.
_MCUBOOT_UPDATE_IMAGE = "zephyr/app_update.bin"


def _helper_cmd() -> tuple[str, ...]:
"""Argv prefix for the device-builder-helper child (cached by _find_sibling_cli)."""
return helper_cli_cmd()
Expand All @@ -93,8 +99,9 @@ async def get_binaries(controller: FirmwareController, *, configuration: str) ->

The platform's ``get_download_types`` entries that exist, plus a
``firmware.elf`` entry when present (``get_download_types`` never
lists it). Empty means nothing is built yet. Each ``file`` is fetched
over HTTP via ``GET /api/firmware/download`` (see :func:`http_download`).
lists it) and the MCUboot update image. Empty means nothing is built
yet. Each ``file`` is fetched over HTTP via
``GET /api/firmware/download`` (see :func:`http_download`).
"""
# ``resolve_storage_path`` collapses to
# ``<data_dir>/storage/<Path(configuration).name>.json``; a
Expand All @@ -120,7 +127,8 @@ def collect_download_entries(

The platform's ``get_download_types`` entries that exist under
``firmware_bin_path.parent`` (the build dir, ``.pioenvs/<name>/`` or
native-IDF ``build/``), plus ``firmware.elf`` when present. Empty
native-IDF ``build/``), plus ``firmware.elf`` and the MCUboot update
image when present. Empty
when nothing is built. The single source of truth for what a build
offers; ``get_binaries`` is its async wrapper. *label* identifies the
build in the failure log -- the caller's configuration filename when it
Expand Down Expand Up @@ -150,6 +158,16 @@ def collect_download_entries(
"file": "firmware.elf",
}
)
if (build_dir / _MCUBOOT_UPDATE_IMAGE).is_file() and not any(
t["file"] == _MCUBOOT_UPDATE_IMAGE for t in downloads
):
downloads.append(
{
"title": "App update package",
"description": "For updating over mcumgr using BLE or USB CDC.",
"file": _MCUBOOT_UPDATE_IMAGE,
}
)
for entry in downloads:
artifact_type = _ARTIFACT_TYPES.get(entry["file"])
if artifact_type:
Expand Down
78 changes: 78 additions & 0 deletions tests/controllers/firmware/test_get_binaries.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,84 @@ async def test_get_binaries_does_not_duplicate_elf_listed_upstream(
assert sum(entry["file"] == "firmware.elf" for entry in result) == 1


# The entry ``get_binaries`` appends when the MCUboot update image is on disk.
_MCUBOOT_ENTRY = {
"title": "App update package",
"description": "For updating over mcumgr using BLE or USB CDC.",
"file": "zephyr/app_update.bin",
}


def _make_nrf52_build(tmp_path: Path, *files: str) -> Path:
"""``_make_build`` with its ``zephyr/`` directory in place."""
build_dir = _make_build(tmp_path)
(build_dir / "zephyr").mkdir(exist_ok=True)
return _make_build(tmp_path, *files)


async def test_get_binaries_appends_mcuboot_image_beside_a_uf2(
tmp_path: Path, monkeypatch: Any, firmware_controller_factory: FirmwareControllerFactory
) -> None:
"""A build with a UF2 still offers the update image ``get_download_types`` leaves out."""
uf2 = {"title": "UF2 package (recommended)", "file": "zephyr/zephyr.uf2"}
_stub_download_types(monkeypatch, [uf2])

build_dir = _make_nrf52_build(tmp_path, "zephyr/zephyr.uf2", "zephyr/app_update.bin")
write_storage_json(
tmp_path,
"kitchen.yaml",
firmware_bin_path=build_dir / "firmware.bin",
overrides={"esp_platform": "nrf52"},
)
controller = firmware_controller_factory()

result = await controller.get_binaries(configuration="kitchen.yaml")

assert result == [uf2, _MCUBOOT_ENTRY]


async def test_get_binaries_does_not_duplicate_mcuboot_image_listed_upstream(
tmp_path: Path, monkeypatch: Any, firmware_controller_factory: FirmwareControllerFactory
) -> None:
"""A build with no UF2 has the update image listed upstream, so it appears once."""
upstream = {"title": "App update package", "file": "zephyr/app_update.bin"}
_stub_download_types(monkeypatch, [upstream])

build_dir = _make_nrf52_build(tmp_path, "zephyr/app_update.bin")
write_storage_json(
tmp_path,
"kitchen.yaml",
firmware_bin_path=build_dir / "firmware.bin",
overrides={"esp_platform": "nrf52"},
)
controller = firmware_controller_factory()

result = await controller.get_binaries(configuration="kitchen.yaml")

assert result == [upstream]


async def test_get_binaries_omits_mcuboot_image_when_absent(
tmp_path: Path, monkeypatch: Any, firmware_controller_factory: FirmwareControllerFactory
) -> None:
"""A build without MCUboot has no update image to offer."""
uf2 = {"title": "UF2 package (recommended)", "file": "zephyr/zephyr.uf2"}
_stub_download_types(monkeypatch, [uf2])

build_dir = _make_nrf52_build(tmp_path, "zephyr/zephyr.uf2")
write_storage_json(
tmp_path,
"kitchen.yaml",
firmware_bin_path=build_dir / "firmware.bin",
overrides={"esp_platform": "nrf52"},
)
controller = firmware_controller_factory()

result = await controller.get_binaries(configuration="kitchen.yaml")

assert result == [uf2]


async def test_get_binaries_omits_elf_entry_when_absent(
tmp_path: Path, monkeypatch: Any, firmware_controller_factory: FirmwareControllerFactory
) -> None:
Expand Down
Loading