Skip to content

Commit cb07293

Browse files
authored
Add possibility to upgrade WLED device (#465)
1 parent 8568d1f commit cb07293

7 files changed

Lines changed: 215 additions & 8 deletions

File tree

.flake8

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[flake8]
22
max-line-length = 88
3-
ignore = D202,W503
3+
ignore = D202,E501,W503
44
per-file-ignores = tests/*:DAR,S101

examples/upgrade.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# pylint: disable=W0621
2+
"""Asynchronous Python client for WLED."""
3+
4+
import asyncio
5+
6+
from wled import WLED
7+
8+
9+
async def main():
10+
"""Show example on upgrade your WLED device."""
11+
async with WLED("10.10.11.54") as led:
12+
device = await led.update()
13+
print(device.info)
14+
15+
await led.upgrade(version="0.13.0-b4")
16+
17+
18+
if __name__ == "__main__":
19+
loop = asyncio.get_event_loop()
20+
loop.run_until_complete(main())

poetry.lock

Lines changed: 25 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ aiohttp = ">=3.0.0"
3131
yarl = ">=1.6.0"
3232
backoff = ">=1.9.0"
3333
awesomeversion = ">=21.10.1"
34+
cachetools = ">=4.0.0"
3435

3536
[tool.poetry.dev-dependencies]
3637
aresponses = "^2.1.4"
@@ -61,6 +62,7 @@ darglint = "^1.8.1"
6162
safety = "^1.10.3"
6263
codespell = "^2.1.0"
6364
bandit = "^1.7.0"
65+
types-cachetools = "^4.2.4"
6466

6567
[tool.poetry.urls]
6668
"Bug Tracker" = "https://github.com/frenck/python-wled/issues"

src/wled/models.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from enum import IntEnum
66
from typing import Any
77

8+
from awesomeversion import AwesomeVersion
9+
810
from .exceptions import WLEDError
911

1012

@@ -316,7 +318,9 @@ class Info: # pylint: disable=too-many-instance-attributes
316318
udp_port: int
317319
uptime: int
318320
version_id: str
319-
version: str
321+
version: AwesomeVersion | None
322+
version_latest_beta: AwesomeVersion | None
323+
version_latest_stable: AwesomeVersion | None
320324
websocket: int | None
321325
wifi: Wifi | None
322326

@@ -333,6 +337,15 @@ def from_dict(data: dict[str, Any]) -> Info:
333337
if (websocket := data.get("ws")) == -1:
334338
websocket = None
335339

340+
if version := data.get("ver"):
341+
version = AwesomeVersion(version)
342+
343+
if version_latest_stable := data.get("version_latest_stable"):
344+
version_latest_stable = AwesomeVersion(version_latest_stable)
345+
346+
if version_latest_beta := data.get("version_latest_beta"):
347+
version_latest_beta = AwesomeVersion(version_latest_beta)
348+
336349
return Info(
337350
architecture=data.get("arch", "Unknown"),
338351
arduino_core_version=data.get("core", "Unknown").replace("_", "."),
@@ -352,7 +365,9 @@ def from_dict(data: dict[str, Any]) -> Info:
352365
udp_port=data.get("udpport", 0),
353366
uptime=data.get("uptime", 0),
354367
version_id=data.get("vid", "Unknown"),
355-
version=data.get("ver", "Unknown"),
368+
version=version,
369+
version_latest_beta=version_latest_beta,
370+
version_latest_stable=version_latest_stable,
356371
websocket=websocket,
357372
wifi=Wifi.from_dict(data),
358373
)

src/wled/wled.py

Lines changed: 139 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
import json
66
import socket
77
from collections.abc import Callable
8+
from contextlib import suppress
89
from dataclasses import dataclass
910
from typing import Any
1011

1112
import aiohttp
1213
import async_timeout
1314
import backoff # type: ignore
1415
from awesomeversion import AwesomeVersion, AwesomeVersionException
16+
from cachetools import TTLCache
1517
from yarl import URL
1618

1719
from .exceptions import (
@@ -37,6 +39,7 @@ class WLED:
3739
_device: Device | None = None
3840
_supports_si_request: bool | None = None
3941
_supports_presets: bool | None = None
42+
_version_cache: TTLCache = TTLCache(maxsize=16, ttl=7200)
4043

4144
@property
4245
def connected(self) -> bool:
@@ -169,7 +172,7 @@ async def request(
169172
data["v"] = True
170173

171174
try:
172-
with async_timeout.timeout(self.request_timeout):
175+
async with async_timeout.timeout(self.request_timeout):
173176
response = await self.session.request(
174177
method,
175178
url,
@@ -240,14 +243,17 @@ async def update(self, full_update: bool = False) -> Device:
240243
except WLEDError:
241244
self._supports_presets = False
242245

246+
versions = await self.get_wled_versions_from_github()
247+
data["info"].update(versions)
248+
243249
self._device = Device(data)
244250

245251
# Try to figure out if this version supports
246252
# a single info and state call
247253
try:
248-
current = AwesomeVersion(self._device.info.version)
249-
supported = AwesomeVersion("0.10.0")
250-
self._supports_si_request = current >= supported
254+
self._supports_si_request = self._device.info.version >= AwesomeVersion(
255+
"0.10.0"
256+
)
251257
except AwesomeVersionException:
252258
# Could be a manual build one? Lets poll for it
253259
try:
@@ -279,6 +285,10 @@ async def update(self, full_update: bool = False) -> Device:
279285
f"WLED device {self.host} returned an empty API"
280286
" response on state update"
281287
)
288+
289+
versions = await self.get_wled_versions_from_github()
290+
info.update(versions)
291+
282292
self._device.update_from_dict({"info": info, "state": state})
283293
return self._device
284294

@@ -287,6 +297,10 @@ async def update(self, full_update: bool = False) -> Device:
287297
f"WLED device at {self.host} returned an empty API"
288298
" response on state & info update"
289299
)
300+
301+
versions = await self.get_wled_versions_from_github()
302+
state_info["info"].update(versions)
303+
290304
self._device.update_from_dict(state_info)
291305

292306
return self._device
@@ -569,6 +583,127 @@ async def nightlight(
569583

570584
await self.request("/json/state", method="POST", data=state)
571585

586+
async def upgrade(self, *, version: str) -> None:
587+
"""Upgrades WLED device to the specified version.
588+
589+
Args:
590+
version: The version to upgrade to.
591+
592+
Raises:
593+
WLEDError: If the upgrade fails.
594+
WLEDConnectionTimeoutError: When a connection timeout occurs.
595+
WLEDConnectionError: When a connection error occurs.
596+
"""
597+
if self._device is None:
598+
await self.update()
599+
600+
if self.session is None or self._device is None:
601+
return
602+
603+
if self._device.info.architecture not in {"esp8266", "esp32"}:
604+
raise WLEDError("Upgrade is only supported on ESP8266 and ESP32")
605+
606+
url = URL.build(scheme="http", host=self.host, port=80, path="/update")
607+
update_file = f"WLED_{version}_{self._device.info.architecture.upper()}.bin"
608+
download_url = f"https://github.com/Aircoookie/WLED/releases/download/v{version}/{update_file}"
609+
610+
try:
611+
async with async_timeout.timeout(self.request_timeout * 10):
612+
async with self.session.get(
613+
download_url, raise_for_status=True
614+
) as download:
615+
form = aiohttp.FormData()
616+
form.add_field("file", await download.read(), filename=update_file)
617+
await self.session.post(url, data=form)
618+
except asyncio.TimeoutError as exception:
619+
raise WLEDConnectionTimeoutError(
620+
"Timeout occurred while fetching WLED version information from GitHub"
621+
) from exception
622+
except aiohttp.ClientResponseError as exception:
623+
if exception.status == 404:
624+
raise WLEDError(
625+
f"Requested WLED version '{version}' does not exists"
626+
) from exception
627+
raise WLEDError(
628+
f"Could not download requested WLED version '{version}' from {download_url}"
629+
) from exception
630+
except (aiohttp.ClientError, socket.gaierror) as exception:
631+
raise WLEDConnectionError(
632+
"Timeout occurred while communicating with GitHub for WLED version information"
633+
) from exception
634+
635+
@backoff.on_exception(backoff.expo, WLEDConnectionError, max_tries=3, logger=None)
636+
async def get_wled_versions_from_github(self) -> dict[str, str | None]:
637+
"""Fetch WLED version information from GitHub.
638+
639+
Returns:
640+
A dictionary of WLED versions, with the key being the version type.
641+
642+
Raises:
643+
WLEDConnectionTimeoutError: Timeout occurred while fetching WLED
644+
version information from GitHub.
645+
WLEDConnectionError: Timeout occurred while communicating with
646+
GitHub for WLED version information.
647+
WLEDError: Didn't get a JSON response from GitHub while retrieving
648+
version information.
649+
"""
650+
with suppress(KeyError):
651+
return {
652+
"version_latest_stable": self._version_cache["stable"],
653+
"version_latest_beta": self._version_cache["beta"],
654+
}
655+
656+
if self.session is None:
657+
return {"version_latest_stable": None, "version_latest_beta": None}
658+
659+
try:
660+
async with async_timeout.timeout(self.request_timeout):
661+
response = await self.session.get(
662+
"https://api.github.com/repos/Aircoookie/WLED/releases"
663+
)
664+
except asyncio.TimeoutError as exception:
665+
raise WLEDConnectionTimeoutError(
666+
"Timeout occurred while fetching WLED version information from GitHub"
667+
) from exception
668+
except (aiohttp.ClientError, socket.gaierror) as exception:
669+
raise WLEDConnectionError(
670+
"Timeout occurred while communicating with GitHub for WLED version"
671+
) from exception
672+
673+
content_type = response.headers.get("Content-Type", "")
674+
if (response.status // 100) in [4, 5]:
675+
contents = await response.read()
676+
response.close()
677+
678+
if content_type == "application/json":
679+
raise WLEDError(response.status, json.loads(contents.decode("utf8")))
680+
raise WLEDError(response.status, {"message": contents.decode("utf8")})
681+
682+
if "application/json" not in content_type:
683+
raise WLEDError(
684+
"Didn't get a JSON response from GitHub while retrieving version information"
685+
)
686+
687+
releases = await response.json()
688+
version_latest = None
689+
version_latest_beta = None
690+
for release in releases:
691+
if release["prerelease"] is False and version_latest is None:
692+
version_latest = release["tag_name"].lstrip("vV")
693+
if release["prerelease"] is True and version_latest_beta is None:
694+
version_latest_beta = release["tag_name"].lstrip("vV")
695+
if version_latest is not None and version_latest_beta is not None:
696+
break
697+
698+
# Cache results
699+
self._version_cache["stable"] = version_latest
700+
self._version_cache["beta"] = version_latest_beta
701+
702+
return {
703+
"version_latest_stable": version_latest,
704+
"version_latest_beta": version_latest_beta,
705+
}
706+
572707
async def reset(self) -> None:
573708
"""Reboot WLED device."""
574709
await self.request("/reset")

tests/test_wled.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for `wled.WLED`."""
22
import asyncio
3+
from unittest.mock import patch
34

45
import aiohttp
56
import pytest
@@ -8,6 +9,16 @@
89
from wled.exceptions import WLEDConnectionError, WLEDError
910

1011

12+
@pytest.fixture(autouse=True)
13+
def mock_get_version_from_github():
14+
"""Patch out connection to GitHub."""
15+
with patch(
16+
"wled.WLED.get_wled_versions_from_github",
17+
return_value={"version_latest_stable": None, "version_latest_beta": None},
18+
):
19+
yield
20+
21+
1122
@pytest.mark.asyncio
1223
async def test_json_request(aresponses):
1324
"""Test JSON response is handled correctly."""

0 commit comments

Comments
 (0)