diff --git a/README.md b/README.md index ec98d859..1f232540 100644 --- a/README.md +++ b/README.md @@ -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 +``` + +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 +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 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..95d8d88f 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) + 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 ( 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..c219416e 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, @@ -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: @@ -1260,6 +1264,57 @@ async def test_upgrade_success(responses: aioresponses, wled: WLED) -> None: await wled.upgrade(version="0.15.0") +@pytest.mark.parametrize( + ("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="")