1515import hashlib
1616import logging
1717import secrets
18+ from collections .abc import Callable
1819from time import time
1920from typing import Any
2021
@@ -98,6 +99,7 @@ def __init__(
9899
99100 # State cache (matches Gizwits interface)
100101 self ._state_cache : dict [str , BestwayDeviceStatus ] = {}
102+ self ._token_update_callback : Callable [[str ], None ] | None = None
101103
102104 @staticmethod
103105 def generate_visitor_id () -> str :
@@ -281,13 +283,13 @@ async def authenticate(
281283 async with session .post (
282284 url , headers = headers , json = payload , ssl = False
283285 ) as resp :
286+ if resp .status in (401 , 403 ):
287+ raise AwsIotAuthException ("Authentication rejected" )
288+
284289 data = await resp .json ()
285290 _LOGGER .debug ("Auth response: %s" , data )
286291 _LOGGER .debug ("Response status: %s" , resp .status )
287292
288- if resp .status in (401 , 403 ):
289- raise AwsIotAuthException ("Authentication rejected" )
290-
291293 token = data .get ("data" , {}).get ("token" )
292294 if not token :
293295 _LOGGER .error ("No token in response. Full response: %s" , data )
@@ -302,6 +304,12 @@ async def authenticate(
302304 def update_token (self , token : str ) -> None :
303305 """Replace the token used by subsequent API requests."""
304306 self ._token = token
307+ if self ._token_update_callback is not None :
308+ self ._token_update_callback (token )
309+
310+ def set_token_update_callback (self , callback : Callable [[str ], None ] | None ) -> None :
311+ """Set a callback for persisting and propagating refreshed tokens."""
312+ self ._token_update_callback = callback
305313
306314 @staticmethod
307315 async def bind_qr_code (
@@ -420,15 +428,14 @@ async def _do_get(self, path: str) -> dict[str, Any]:
420428
421429 async with asyncio .timeout (TIMEOUT ):
422430 async with self ._session .get (url , headers = headers , ssl = False ) as response :
423- data = await response .json ()
424-
425431 # Check for errors
426- if response .status in (400 , 401 ):
432+ if response .status in (400 , 401 , 403 ):
427433 raise AwsIotAuthException ("Token expired or invalid" )
428434
429435 if response .status != 200 :
430436 raise AwsIotException (f"API error: { response .status } " )
431437
438+ data = await response .json ()
432439 return dict (data )
433440
434441 async def _do_post (self , path : str , data : dict [str , Any ]) -> dict [str , Any ]:
@@ -454,19 +461,17 @@ async def _do_post(self, path: str, data: dict[str, Any]) -> dict[str, Any]:
454461 async with self ._session .post (
455462 url , headers = headers , json = data , ssl = False
456463 ) as response :
457- result = await response .json ()
458-
459- _LOGGER .debug (
460- "POST %s response (status=%d): %s" , path , response .status , result
461- )
462-
463464 # Check for errors
464- if response .status in (400 , 401 ):
465+ if response .status in (400 , 401 , 403 ):
465466 raise AwsIotAuthException ("Token expired or invalid" )
466467
467468 if response .status != 200 :
468469 raise AwsIotException (f"API error: { response .status } " )
469470
471+ result = await response .json ()
472+ _LOGGER .debug (
473+ "POST %s response (status=%d): %s" , path , response .status , result
474+ )
470475 return dict (result )
471476
472477 async def refresh_bindings (self ) -> None :
@@ -586,23 +591,10 @@ async def refresh_bindings(self) -> None:
586591
587592 self .devices [device_id ] = device
588593
589- async def fetch_data (self ) -> Any : # Returns BestwayApiResults
590- """Fetch latest state for all devices.
591-
592- Implements the same interface as Gizwits BestwayApi.fetch_data().
593-
594- For each device:
595- 1. POST /api/device/thing_shadow/ with device_id + product_id
596- 2. Parse shadow.state.reported or shadow.state.desired
597- 3. Return raw AWS field names (water_temperature, temperature_setting, etc.)
598- 4. Store in state cache
599-
600- Returns:
601- BestwayApiResults with devices dict
602- """
603- # Import here to avoid circular dependency
604- from ..bestway .api import BestwayApiResults
605-
594+ async def _poll_all_devices (self ) -> tuple [int , bool ]:
595+ """Poll every device once and return success and auth-failure status."""
596+ refreshed = 0
597+ auth_failed = False
606598 for device_id in self .devices :
607599 try :
608600 # Get device metadata
@@ -656,14 +648,22 @@ async def fetch_data(self) -> Any: # Returns BestwayApiResults
656648 self ._state_cache [device_id ] = BestwayDeviceStatus (
657649 timestamp = int (time ()), attrs = mapped
658650 )
651+ refreshed += 1
659652
660653 _LOGGER .debug (
661654 "Fetched state for device %s: %d fields" ,
662655 device_id [:12 ],
663656 len (mapped ),
664657 )
665658
666- except Exception as err :
659+ except AwsIotAuthException as err :
660+ auth_failed = True
661+ _LOGGER .warning (
662+ "Authentication failure fetching device %s: %s" ,
663+ device_id [:12 ],
664+ err ,
665+ )
666+ except Exception as err : # pylint: disable=broad-except
667667 _LOGGER .warning (
668668 "Failed to fetch state for device %s: %s" , device_id [:12 ], err
669669 )
@@ -673,6 +673,36 @@ async def fetch_data(self) -> Any: # Returns BestwayApiResults
673673 timestamp = int (time ()), attrs = {}
674674 )
675675
676+ return refreshed , auth_failed
677+
678+ async def fetch_data (self ) -> Any : # Returns BestwayApiResults
679+ """Fetch state, refreshing an expired token once before failing."""
680+ from homeassistant .exceptions import ConfigEntryAuthFailed
681+ from homeassistant .helpers .update_coordinator import UpdateFailed
682+
683+ from ..bestway .api import BestwayApiResults
684+
685+ refreshed , auth_failed = await self ._poll_all_devices ()
686+
687+ if self .devices and refreshed == 0 and auth_failed :
688+ _LOGGER .info ("Re-authenticating after auth failure during poll" )
689+ try :
690+ token = await self .authenticate (
691+ self ._session , self ._visitor_id , self ._location , self ._api_base
692+ )
693+ except AwsIotAuthException as err :
694+ raise ConfigEntryAuthFailed from err
695+ except AwsIotConnectionError as err :
696+ raise UpdateFailed (
697+ "Unable to reach Bestway authentication service"
698+ ) from err
699+
700+ self .update_token (token )
701+ refreshed , _ = await self ._poll_all_devices ()
702+
703+ if self .devices and refreshed == 0 :
704+ raise UpdateFailed ("Unable to refresh any Bestway device state" )
705+
676706 return BestwayApiResults (devices = self ._state_cache )
677707
678708 async def set_device_state (
0 commit comments