55import json
66import socket
77from collections .abc import Callable
8+ from contextlib import suppress
89from dataclasses import dataclass
910from typing import Any
1011
1112import aiohttp
1213import async_timeout
1314import backoff # type: ignore
1415from awesomeversion import AwesomeVersion , AwesomeVersionException
16+ from cachetools import TTLCache
1517from yarl import URL
1618
1719from .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" )
0 commit comments