Skip to content
Open
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
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,52 @@ if __name__ == "__main__":
asyncio.run(main())
```

### Firmware upgrade release files

`python-wled` can upgrade devices from the official WLED releases, or from your
own GitHub repository if you publish custom WLED builds. This lets vendors,
integrators, and private installations distribute firmware through the same
upgrade flow: the device reports where its firmware lives, `python-wled`
downloads the matching release asset, and the library uploads that file to the
device's `/update` endpoint.

To make a custom GitHub release work with `python-wled`, compile your WLED
firmware with metadata for the repository, brand, and release name, then create
a GitHub release with the firmware attached as a release asset. By default,
`WLED.upgrade()` uses the repository reported by the device as
`device.info.repo`; older firmware that does not report a repository falls back
to `wled/WLED`.

Release assets must use the WLED release file naming convention:

```text
{brand}_{version}_{release}.bin
```

- `brand`: the device-reported brand from `device.info.brand`
(default `"WLED"`)
- `version`: the release tag without the leading `v`
(e.g., `0.15.0` for tag `v0.15.0`)
- `release`: the device-reported release name from `device.info.release`
(e.g., `ESP32`, `ESP32_Ethernet`)

The official [WLED releases][wled-releases] show examples of this format using the default brand `WLED`. For example, a device reporting `repo="example/WLED"`,
`brand="WLED"`, and `release="ESP32"` upgraded to version `0.15.0` expects this
release asset:

```text
https://github.com/example/WLED/releases/download/v0.15.0/WLED_0.15.0_ESP32.bin
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

If you use `WLEDReleases` to check available versions before upgrading, pass the
same repository reported by the device:

```python
device = await led.update()
releases = await WLEDReleases(repo=device.info.repo).releases()
await led.upgrade(version=releases.stable)
```

## Changelog & Releases

This repository keeps a change log using [GitHub's releases][releases]
Expand Down Expand Up @@ -168,3 +214,4 @@ SOFTWARE.
[scorecard]: https://scorecard.dev/viewer/?uri=github.com/frenck/python-wled
[scorecard-shield]: https://api.scorecard.dev/projects/github.com/frenck/python-wled/badge
[semver]: http://semver.org/spec/v2.0.0.html
[wled-releases]: https://github.com/wled/WLED/releases
4 changes: 4 additions & 0 deletions src/wled/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from .const import (
CUSTOM_PALETTE_ID_CHANGE_VERSION,
DEFAULT_REPO,
MIN_REQUIRED_VERSION,
LightCapability,
LiveDataOverride,
Expand Down Expand Up @@ -514,6 +515,9 @@ class Info(BaseModel): # pylint: disable=too-many-instance-attributes
product: str = "DIY Light"
"""The product name. Always FOSS for standard installations."""

repo: str = DEFAULT_REPO
"""GitHub repository in 'owner/repository' format."""

release: str | None = None
"""The release name of the firmware build.

Expand Down
8 changes: 6 additions & 2 deletions src/wled/wled.py
Original file line number Diff line number Diff line change
Expand Up @@ -636,14 +636,15 @@ async def upgrade( # noqa: PLR0912
self,
*,
version: str | AwesomeVersion,
repo: str = DEFAULT_REPO,
repo: str | None = None,
) -> None:
"""Upgrade WLED device to the specified version.

Args:
----
version: The version to upgrade to.
repo: GitHub repository to download firmware from.
repo: GitHub repository to download firmware from. If not specified,
the repository reported by the device firmware is used.

Raises:
------
Expand Down Expand Up @@ -682,6 +683,9 @@ async def upgrade( # noqa: PLR0912
msg = "Device already running the requested version"
raise WLEDUpgradeError(msg)

repo = (repo if repo is not None else self._device.info.repo).strip()
repo = repo or DEFAULT_REPO

# Determine if this is an Ethernet board
ethernet = ""
if (
Expand Down
13 changes: 13 additions & 0 deletions tests/__snapshots__/test_models.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,7 @@
palette_count=75,
product='FOSS',
release='ESP32',
repo='wled/WLED',
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=79769),
Expand Down Expand Up @@ -1994,6 +1995,7 @@
palette_count=71,
product='FOSS',
release=None,
repo='wled/WLED',
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=966),
Expand Down Expand Up @@ -3258,6 +3260,7 @@
palette_count=71,
product='FOSS',
release=None,
repo='wled/WLED',
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=461),
Expand Down Expand Up @@ -4469,6 +4472,7 @@
palette_count=71,
product='FOSS',
release=None,
repo='wled/WLED',
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=461),
Expand Down Expand Up @@ -5680,6 +5684,7 @@
palette_count=71,
product='FOSS',
release=None,
repo='wled/WLED',
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=461),
Expand Down Expand Up @@ -6894,6 +6899,7 @@
palette_count=71,
product='FOSS',
release=None,
repo='wled/WLED',
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=321),
Expand Down Expand Up @@ -8105,6 +8111,7 @@
palette_count=71,
product='FOSS',
release=None,
repo='wled/WLED',
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=72),
Expand Down Expand Up @@ -9320,6 +9327,7 @@
palette_count=71,
product='FOSS',
release='ESP32',
repo='wled/WLED',
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=12),
Expand Down Expand Up @@ -10535,6 +10543,7 @@
palette_count=71,
product='FOSS',
release='ESP32',
repo='wled/WLED',
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=129),
Expand Down Expand Up @@ -11750,6 +11759,7 @@
palette_count=71,
product='FOSS',
release='ESP32',
repo='wled/WLED',
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=119),
Expand Down Expand Up @@ -12965,6 +12975,7 @@
palette_count=71,
product='FOSS',
release='ESP32',
repo='wled/WLED',
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=47),
Expand Down Expand Up @@ -14180,6 +14191,7 @@
palette_count=71,
product='FOSS',
release='ESP32',
repo='wled/WLED',
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=40),
Expand Down Expand Up @@ -15539,6 +15551,7 @@
palette_count=72,
product='FOSS',
release='ESP32',
repo='wled/WLED',
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=75),
Expand Down
13 changes: 13 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from syrupy.assertion import SnapshotAssertion

from wled import Device, Playlist, Preset, Releases
from wled.const import DEFAULT_REPO
from wled.exceptions import WLEDUnsupportedVersionError
from wled.models import (
AwesomeVersionSerializationStrategy,
Expand Down Expand Up @@ -315,6 +316,18 @@ def test_info_version_deserialized() -> None:
assert str(info.version) == "0.14.0"


def test_info_repo_defaults_to_default_repo() -> None:
"""Test repo defaults to DEFAULT_REPO when not present."""
info = Info.from_dict(_base_info())
assert info.repo == DEFAULT_REPO


def test_info_repo_uses_device_value_when_present() -> None:
"""Test repo is deserialized from the device response."""
info = Info.from_dict(_base_info(repo="MoonModules/WLED"))
assert info.repo == "MoonModules/WLED"


# =========================================================================
# State model
# =========================================================================
Expand Down
59 changes: 57 additions & 2 deletions tests/test_wled.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from yarl import URL

from wled import WLED, Device, Releases
from wled.const import LiveDataOverride
from wled.const import DEFAULT_REPO, LiveDataOverride
from wled.exceptions import (
WLEDConnectionClosedError,
WLEDConnectionError,
Expand All @@ -24,7 +24,11 @@
)
from wled.wled import WLEDReleases

from .conftest import full_device_data, load_fixture_json, mock_json_and_presets
from .conftest import (
full_device_data,
load_fixture_json,
mock_json_and_presets,
)


def assert_post_payload(mocked: aioresponses, path: str, expected: dict) -> None:
Expand Down Expand Up @@ -1260,6 +1264,57 @@ async def test_upgrade_success(responses: aioresponses, wled: WLED) -> None:
await wled.upgrade(version="0.15.0")


@pytest.mark.parametrize(
Comment thread
LordMike marked this conversation as resolved.
("info_override", "call_kwargs", "download_repo"),
[
pytest.param(
{"repo": "MoonModules/WLED"}, {}, "MoonModules/WLED", id="device_repo"
),
pytest.param(
{"repo": "MoonModules/WLED"},
{"repo": DEFAULT_REPO},
DEFAULT_REPO,
id="explicit_repo",
),
pytest.param({"repo": " "}, {}, DEFAULT_REPO, id="missing_device_repo"),
pytest.param({}, {}, DEFAULT_REPO, id="blank_device_repo"),
pytest.param(
{"repo": "FORK_A/WLED"},
{"repo": "FORK_B/WLED"},
"FORK_B/WLED",
id="migrate_to_fork",
),
],
)
async def test_upgrade_repo_selection(
responses: aioresponses,
wled: WLED,
info_override: dict,
call_kwargs: dict,
download_repo: str,
) -> None:
"""Test upgrade selects the expected firmware repository."""
wled_data = load_fixture_json("wled")
wled_data["info"]["arch"] = "esp32"
wled_data["info"]["ver"] = "0.14.0"
wled_data["info"].update(info_override)
mock_json_and_presets(responses, wled_data)
await wled.update()
responses.get(
f"https://github.com/{download_repo}/releases/download/v0.15.0/"
"WLED_0.15.0_ESP32.bin",
status=200,
body=b"fake firmware",
)
responses.post(
"http://example.com/update",
status=200,
body="OK",
content_type="text/plain",
)
await wled.upgrade(version="0.15.0", **call_kwargs)


async def test_upgrade_ethernet_board(responses: aioresponses, wled: WLED) -> None:
"""Test upgrade with Ethernet board (empty bssid)."""
await prepare_wled_for_upgrade(responses, wled, wifi_bssid="")
Expand Down
Loading