From 5a7318ec004fcb0ebecf58a4f92a2961a7fb42aa Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Thu, 21 May 2026 23:07:50 +0200 Subject: [PATCH 1/8] Use device-reported repo for upgrades --- src/wled/models.py | 4 +++ src/wled/wled.py | 8 ++++-- tests/__snapshots__/test_models.ambr | 13 +++++++++ tests/test_models.py | 13 +++++++++ tests/test_wled.py | 41 +++++++++++++++++++++++++++- 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/wled/models.py b/src/wled/models.py index c068c7d8..d54a8f06 100644 --- a/src/wled/models.py +++ b/src/wled/models.py @@ -15,6 +15,7 @@ from .const import ( CUSTOM_PALETTE_ID_CHANGE_VERSION, + DEFAULT_REPO, MIN_REQUIRED_VERSION, LightCapability, LiveDataOverride, @@ -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. diff --git a/src/wled/wled.py b/src/wled/wled.py index 65b88ef6..5a3220d8 100644 --- a/src/wled/wled.py +++ b/src/wled/wled.py @@ -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: ------ @@ -682,6 +683,9 @@ async def upgrade( # noqa: PLR0912 msg = "Device already running the requested version" raise WLEDUpgradeError(msg) + if repo is None: + repo = self._device.info.repo + # Determine if this is an Ethernet board ethernet = "" if ( diff --git a/tests/__snapshots__/test_models.ambr b/tests/__snapshots__/test_models.ambr index 796cb698..55b29b34 100644 --- a/tests/__snapshots__/test_models.ambr +++ b/tests/__snapshots__/test_models.ambr @@ -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), @@ -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), @@ -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), @@ -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), @@ -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), @@ -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), @@ -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), @@ -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), @@ -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), @@ -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), @@ -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), @@ -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), @@ -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), diff --git a/tests/test_models.py b/tests/test_models.py index 4ec6397d..30e4e4ad 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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, @@ -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 # ========================================================================= diff --git a/tests/test_wled.py b/tests/test_wled.py index 1a4649fc..49a2aee9 100644 --- a/tests/test_wled.py +++ b/tests/test_wled.py @@ -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, @@ -1260,6 +1260,45 @@ async def test_upgrade_success(responses: aioresponses, wled: WLED) -> None: await wled.upgrade(version="0.15.0") +@pytest.mark.parametrize( + ("repo", "download_repo"), + [ + (None, "MoonModules/WLED"), + (DEFAULT_REPO, DEFAULT_REPO), + ], + ids=["device_repo", "explicit_repo"], +) +async def test_upgrade_repo_selection( + responses: aioresponses, + wled: WLED, + repo: str | None, + 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"]["repo"] = "MoonModules/WLED" + 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", + ) + if repo is None: + await wled.upgrade(version="0.15.0") + else: + await wled.upgrade(version="0.15.0", repo=repo) + + 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="") From 93963288ddc93c4606d17e7f2aebe08a42e949b8 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Thu, 21 May 2026 23:28:36 +0200 Subject: [PATCH 2/8] Handle blank upgrade repositories --- src/wled/wled.py | 4 ++-- tests/test_wled.py | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/wled/wled.py b/src/wled/wled.py index 5a3220d8..95d8d88f 100644 --- a/src/wled/wled.py +++ b/src/wled/wled.py @@ -683,8 +683,8 @@ async def upgrade( # noqa: PLR0912 msg = "Device already running the requested version" raise WLEDUpgradeError(msg) - if repo is None: - repo = self._device.info.repo + 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 = "" diff --git a/tests/test_wled.py b/tests/test_wled.py index 49a2aee9..569f17c5 100644 --- a/tests/test_wled.py +++ b/tests/test_wled.py @@ -1261,16 +1261,18 @@ async def test_upgrade_success(responses: aioresponses, wled: WLED) -> None: @pytest.mark.parametrize( - ("repo", "download_repo"), + ("device_repo", "repo", "download_repo"), [ - (None, "MoonModules/WLED"), - (DEFAULT_REPO, DEFAULT_REPO), + ("MoonModules/WLED", None, "MoonModules/WLED"), + ("MoonModules/WLED", DEFAULT_REPO, DEFAULT_REPO), + (" ", None, DEFAULT_REPO), ], - ids=["device_repo", "explicit_repo"], + ids=["device_repo", "explicit_repo", "blank_device_repo"], ) async def test_upgrade_repo_selection( responses: aioresponses, wled: WLED, + device_repo: str, repo: str | None, download_repo: str, ) -> None: @@ -1278,7 +1280,7 @@ async def test_upgrade_repo_selection( wled_data = load_fixture_json("wled") wled_data["info"]["arch"] = "esp32" wled_data["info"]["ver"] = "0.14.0" - wled_data["info"]["repo"] = "MoonModules/WLED" + wled_data["info"]["repo"] = device_repo mock_json_and_presets(responses, wled_data) await wled.update() responses.get( From 3384f38bbc03f909286910c101deaece86c8660e Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Fri, 22 May 2026 12:57:28 +0200 Subject: [PATCH 3/8] Document firmware release asset naming --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/README.md b/README.md index ec98d859..651acb93 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,47 @@ 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 +WLED_{version}_{release}.bin +``` + +`version` is the release tag without the leading `v`, and `release` is the +device-reported release name from `device.info.release`. The official +[WLED releases][wled-releases] show examples of this format. For example, a +device reporting `repo="example/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 +``` + +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] @@ -168,3 +209,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 From 700cba19cecacf2bd993a950f6eb529e77dc85a4 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Fri, 22 May 2026 13:36:50 +0200 Subject: [PATCH 4/8] Clarify firmware release asset brand --- README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 651acb93..6351a5eb 100644 --- a/README.md +++ b/README.md @@ -70,14 +70,16 @@ to `wled/WLED`. Release assets must use the WLED release file naming convention: ```text -WLED_{version}_{release}.bin +{brand}_{version}_{release}.bin ``` -`version` is the release tag without the leading `v`, and `release` is the -device-reported release name from `device.info.release`. The official -[WLED releases][wled-releases] show examples of this format. For example, a -device reporting `repo="example/WLED"` and `release="ESP32"` upgraded to -version `0.15.0` expects this release asset: +`brand` is the device-reported brand from `device.info.brand`, `version` is the +release tag without the leading `v`, and `release` is the device-reported +release name from `device.info.release`. 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 From ba38a0e0a0bae3f594a342155298ae8905c22bb4 Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Sat, 23 May 2026 18:36:36 +0200 Subject: [PATCH 5/8] Add more tests cases --- tests/test_wled.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/test_wled.py b/tests/test_wled.py index 569f17c5..5057cf2c 100644 --- a/tests/test_wled.py +++ b/tests/test_wled.py @@ -1261,19 +1261,24 @@ async def test_upgrade_success(responses: aioresponses, wled: WLED) -> None: @pytest.mark.parametrize( - ("device_repo", "repo", "download_repo"), + ("device_repo", "call_kwargs", "download_repo"), [ - ("MoonModules/WLED", None, "MoonModules/WLED"), - ("MoonModules/WLED", DEFAULT_REPO, DEFAULT_REPO), - (" ", None, DEFAULT_REPO), + pytest.param("MoonModules/WLED", {}, "MoonModules/WLED", id="device_repo"), + pytest.param( + "MoonModules/WLED", {"repo": DEFAULT_REPO}, DEFAULT_REPO, id="explicit_repo" + ), + pytest.param(" ", {}, DEFAULT_REPO, id="missing_device_repo"), + pytest.param(" ", {}, DEFAULT_REPO, id="blank_device_repo"), + pytest.param( + "FORK_A/WLED", {"repo": "FORK_B/WLED"}, "FORK_B/WLED", id="migrate_to_fork" + ), ], - ids=["device_repo", "explicit_repo", "blank_device_repo"], ) async def test_upgrade_repo_selection( responses: aioresponses, wled: WLED, device_repo: str, - repo: str | None, + call_kwargs: dict, download_repo: str, ) -> None: """Test upgrade selects the expected firmware repository.""" @@ -1295,10 +1300,7 @@ async def test_upgrade_repo_selection( body="OK", content_type="text/plain", ) - if repo is None: - await wled.upgrade(version="0.15.0") - else: - await wled.upgrade(version="0.15.0", repo=repo) + await wled.upgrade(version="0.15.0", **call_kwargs) async def test_upgrade_ethernet_board(responses: aioresponses, wled: WLED) -> None: From 138c738fbf1d66651cd72f4aaa85ba9169cb5c70 Mon Sep 17 00:00:00 2001 From: mik-laj <12058428+mik-laj@users.noreply.github.com> Date: Sat, 23 May 2026 18:59:19 +0200 Subject: [PATCH 6/8] fixup! Add more tests cases --- tests/test_wled.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/test_wled.py b/tests/test_wled.py index 5057cf2c..c219416e 100644 --- a/tests/test_wled.py +++ b/tests/test_wled.py @@ -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: @@ -1261,23 +1265,31 @@ async def test_upgrade_success(responses: aioresponses, wled: WLED) -> None: @pytest.mark.parametrize( - ("device_repo", "call_kwargs", "download_repo"), + ("info_override", "call_kwargs", "download_repo"), [ - pytest.param("MoonModules/WLED", {}, "MoonModules/WLED", id="device_repo"), pytest.param( - "MoonModules/WLED", {"repo": DEFAULT_REPO}, DEFAULT_REPO, id="explicit_repo" + {"repo": "MoonModules/WLED"}, {}, "MoonModules/WLED", id="device_repo" + ), + pytest.param( + {"repo": "MoonModules/WLED"}, + {"repo": DEFAULT_REPO}, + DEFAULT_REPO, + id="explicit_repo", ), - pytest.param(" ", {}, DEFAULT_REPO, id="missing_device_repo"), - pytest.param(" ", {}, DEFAULT_REPO, id="blank_device_repo"), + pytest.param({"repo": " "}, {}, DEFAULT_REPO, id="missing_device_repo"), + pytest.param({}, {}, DEFAULT_REPO, id="blank_device_repo"), pytest.param( - "FORK_A/WLED", {"repo": "FORK_B/WLED"}, "FORK_B/WLED", id="migrate_to_fork" + {"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, - device_repo: str, + info_override: dict, call_kwargs: dict, download_repo: str, ) -> None: @@ -1285,7 +1297,7 @@ async def test_upgrade_repo_selection( wled_data = load_fixture_json("wled") wled_data["info"]["arch"] = "esp32" wled_data["info"]["ver"] = "0.14.0" - wled_data["info"]["repo"] = device_repo + wled_data["info"].update(info_override) mock_json_and_presets(responses, wled_data) await wled.update() responses.get( From 53091908886f11023495a86b547e49d133c6fd8d Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Sat, 23 May 2026 19:06:08 +0200 Subject: [PATCH 7/8] Update README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kamil BreguĊ‚a --- README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6351a5eb..b9bb96e7 100644 --- a/README.md +++ b/README.md @@ -73,11 +73,14 @@ Release assets must use the WLED release file naming convention: {brand}_{version}_{release}.bin ``` -`brand` is the device-reported brand from `device.info.brand`, `version` is the -release tag without the leading `v`, and `release` is the device-reported -release name from `device.info.release`. 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`: 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: From 83fc42c9000dbfad771288c865e7b37d18ba92f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Sat, 23 May 2026 19:17:51 +0200 Subject: [PATCH 8/8] Trim trailing spaces --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b9bb96e7..1f232540 100644 --- a/README.md +++ b/README.md @@ -73,11 +73,11 @@ Release assets must use the WLED release file naming convention: {brand}_{version}_{release}.bin ``` -- `brand`: the device-reported brand from `device.info.brand` +- `brand`: the device-reported brand from `device.info.brand` (default `"WLED"`) -- `version`: the release tag without the leading `v` +- `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` +- `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"`,