1919REQUEST_TIMEOUT = aiohttp .ClientTimeout (total = 20 )
2020
2121
22+ def _log_identifier (value : str | int | None ) -> str | None :
23+ """Return a shortened identifier suitable for debug logs."""
24+ if value is None :
25+ return None
26+
27+ text = str (value )
28+ if len (text ) <= 8 :
29+ return text
30+ return f"{ text [:8 ]} ..."
31+
32+
2233def auth_header_value (token : str | None ) -> str :
2334 """Return normalized Authorization header value."""
2435 normalized = (token or "" ).strip ()
@@ -27,6 +38,14 @@ def auth_header_value(token: str | None) -> str:
2738 return f"Bearer { normalized } "
2839
2940
41+ def _response_preview (payload : Any ) -> str :
42+ """Return a short safe preview of a response payload for exceptions."""
43+ text = repr (payload )
44+ if len (text ) <= 200 :
45+ return text
46+ return f"{ text [:200 ]} ..."
47+
48+
3049class HomelyClient :
3150 """Small reusable async client for the Homely cloud API."""
3251
@@ -61,19 +80,31 @@ async def authenticate(
6180
6281 Raises a typed SDK exception on failure.
6382 """
64- response , reason = await self .fetch_token_with_reason (username , password )
65- if response :
66- return TokenResponse .from_dict (response )
67- if reason == "invalid_auth" :
83+ response , status = await self ._fetch_token_payload (username , password )
84+ if response is not None :
85+ try :
86+ return TokenResponse .from_dict (response )
87+ except (KeyError , TypeError , ValueError ) as err :
88+ raise HomelyResponseError (
89+ "Homely authentication response missing required fields" ,
90+ status = status ,
91+ body = _response_preview (response ),
92+ ) from err
93+ if status in (400 , 401 , 403 ):
6894 raise HomelyAuthError ("Invalid Homely username or password" )
95+ if status in (200 , 201 ):
96+ raise HomelyResponseError (
97+ "Homely authentication response could not be parsed" ,
98+ status = status ,
99+ )
69100 raise HomelyConnectionError ("Could not connect to Homely" )
70101
71- async def fetch_token_with_reason (
102+ async def _fetch_token_payload (
72103 self ,
73104 username : str ,
74105 password : str ,
75- ) -> tuple [dict [str , Any ] | None , str | None ]:
76- """Fetch access token and return optional reason key on failure ."""
106+ ) -> tuple [dict [str , Any ] | None , int | None ]:
107+ """Fetch access token payload and include HTTP status when available ."""
77108 url = f"{ self ._base_url } oauth/token"
78109 payload = {
79110 "username" : username ,
@@ -83,18 +114,43 @@ async def fetch_token_with_reason(
83114 try :
84115 async with self ._session .post (url , json = payload , timeout = self ._timeout ) as response :
85116 if response .status in (200 , 201 ):
117+ try :
118+ parsed = await response .json ()
119+ except (aiohttp .ContentTypeError , TypeError , ValueError ) as err :
120+ _LOGGER .debug (
121+ "Token fetch returned invalid JSON status=%s: %s" ,
122+ response .status ,
123+ err ,
124+ )
125+ return None , response .status
126+ if not isinstance (parsed , dict ):
127+ _LOGGER .debug (
128+ "Token fetch returned unexpected payload type status=%s payload_type=%s" ,
129+ response .status ,
130+ type (parsed ).__name__ ,
131+ )
132+ return None , response .status
86133 _LOGGER .debug ("Token fetch successful" )
87- return await response .json (), None
88-
89- if response .status in (400 , 401 , 403 ):
90- _LOGGER .debug ("Token fetch rejected with status=%s" , response .status )
91- return None , "invalid_auth"
134+ return parsed , response .status
92135
93- _LOGGER .warning ("Token fetch failed with status=%s" , response .status )
94- return None , "cannot_connect"
136+ _LOGGER .debug ("Token fetch failed with status=%s" , response .status )
137+ return None , response . status
95138 except (aiohttp .ClientError , TimeoutError ) as err :
96- _LOGGER .warning ("Token fetch network error: %s" , err )
97- return None , "cannot_connect"
139+ _LOGGER .debug ("Token fetch network error: %s" , err )
140+ return None , None
141+
142+ async def fetch_token_with_reason (
143+ self ,
144+ username : str ,
145+ password : str ,
146+ ) -> tuple [dict [str , Any ] | None , str | None ]:
147+ """Fetch access token and return optional reason key on failure."""
148+ response , status = await self ._fetch_token_payload (username , password )
149+ if response is not None :
150+ return response , None
151+ if status in (400 , 401 , 403 ):
152+ return None , "invalid_auth"
153+ return None , "cannot_connect"
98154
99155 async def fetch_token (
100156 self ,
@@ -105,8 +161,11 @@ async def fetch_token(
105161 response , _reason = await self .fetch_token_with_reason (username , password )
106162 return response
107163
108- async def fetch_refresh_token (self , refresh_token : str ) -> dict [str , Any ] | None :
109- """Refresh access token using refresh token."""
164+ async def _fetch_refresh_token_payload (
165+ self ,
166+ refresh_token : str ,
167+ ) -> tuple [dict [str , Any ] | None , int | None ]:
168+ """Refresh access token payload and include HTTP status when available."""
110169 url = f"{ self ._base_url } oauth/refresh-token"
111170 payload = {
112171 "refresh_token" : refresh_token ,
@@ -115,42 +174,108 @@ async def fetch_refresh_token(self, refresh_token: str) -> dict[str, Any] | None
115174 try :
116175 async with self ._session .post (url , json = payload , timeout = self ._timeout ) as response :
117176 if response .status in (200 , 201 ):
177+ try :
178+ parsed = await response .json ()
179+ except (aiohttp .ContentTypeError , TypeError , ValueError ) as err :
180+ _LOGGER .debug (
181+ "Token refresh returned invalid JSON status=%s: %s" ,
182+ response .status ,
183+ err ,
184+ )
185+ return None , response .status
186+ if not isinstance (parsed , dict ):
187+ _LOGGER .debug (
188+ "Token refresh returned unexpected payload type status=%s payload_type=%s" ,
189+ response .status ,
190+ type (parsed ).__name__ ,
191+ )
192+ return None , response .status
118193 _LOGGER .debug ("Token refresh successful" )
119- return await response .json ()
194+ return parsed , response .status
120195 _LOGGER .debug ("Token refresh failed with status=%s" , response .status )
121- return None
196+ return None , response . status
122197 except (aiohttp .ClientError , TimeoutError ) as err :
123198 _LOGGER .debug ("Token refresh network error: %s" , err )
124- return None
199+ return None , None
200+
201+ async def fetch_refresh_token (self , refresh_token : str ) -> dict [str , Any ] | None :
202+ """Refresh access token using refresh token."""
203+ response , _status = await self ._fetch_refresh_token_payload (refresh_token )
204+ return response
125205
126206 async def refresh_access_token (self , refresh_token : str ) -> TokenResponse :
127207 """Refresh access token and return a typed token response."""
128- response = await self .fetch_refresh_token (refresh_token )
129- if response :
130- return TokenResponse .from_dict (response )
208+ response , status = await self ._fetch_refresh_token_payload (refresh_token )
209+ if response is not None :
210+ try :
211+ return TokenResponse .from_dict (response )
212+ except (KeyError , TypeError , ValueError ) as err :
213+ raise HomelyResponseError (
214+ "Homely refresh response missing required fields" ,
215+ status = status ,
216+ body = _response_preview (response ),
217+ ) from err
218+ if status in (400 , 401 , 403 ):
219+ raise HomelyAuthError ("Homely rejected the supplied refresh token" )
220+ if status in (200 , 201 ):
221+ raise HomelyResponseError (
222+ "Homely refresh response could not be parsed" ,
223+ status = status ,
224+ )
131225 raise HomelyConnectionError ("Could not refresh Homely access token" )
132226
133- async def get_locations (self , token : str ) -> list [dict [str , Any ]] | None :
134- """Get locations from API."""
227+ async def _get_locations_payload (
228+ self ,
229+ token : str ,
230+ ) -> tuple [list [dict [str , Any ]] | None , int | None ]:
231+ """Get locations payload and include HTTP status when available."""
135232 url = f"{ self ._base_url } locations"
136233 headers = {"Authorization" : auth_header_value (token )}
137234
138235 try :
139236 async with self ._session .get (url , headers = headers , timeout = self ._timeout ) as response :
140237 if response .status == 200 :
238+ try :
239+ parsed = await response .json ()
240+ except (aiohttp .ContentTypeError , TypeError , ValueError ) as err :
241+ _LOGGER .debug (
242+ "Locations fetch returned invalid JSON status=%s: %s" ,
243+ response .status ,
244+ err ,
245+ )
246+ return None , response .status
247+ if not isinstance (parsed , list ):
248+ _LOGGER .debug (
249+ "Locations fetch returned unexpected payload type status=%s payload_type=%s" ,
250+ response .status ,
251+ type (parsed ).__name__ ,
252+ )
253+ return None , response .status
141254 _LOGGER .debug ("Locations fetch successful" )
142- return await response .json ()
255+ return parsed , response .status
143256 _LOGGER .debug ("Locations fetch failed with status=%s" , response .status )
144- return None
257+ return None , response . status
145258 except (aiohttp .ClientError , TimeoutError ) as err :
146259 _LOGGER .debug ("Locations fetch network error: %s" , err )
147- return None
260+ return None , None
261+
262+ async def get_locations (self , token : str ) -> list [dict [str , Any ]] | None :
263+ """Get locations from API."""
264+ locations , _status = await self ._get_locations_payload (token )
265+ return locations
148266
149267 async def get_locations_or_raise (self , token : str ) -> list [dict [str , Any ]]:
150268 """Get locations from API or raise a typed exception."""
151- locations = await self .get_locations (token )
269+ locations , status = await self ._get_locations_payload (token )
152270 if locations is not None :
153271 return locations
272+ if status in (401 , 403 ):
273+ raise HomelyAuthError ("Homely rejected the supplied access token" )
274+ if status == 200 :
275+ raise HomelyResponseError (
276+ "Homely locations response could not be parsed" ,
277+ status = status ,
278+ )
154279 raise HomelyConnectionError ("Could not fetch Homely locations" )
155280
156281 async def get_home_data (
@@ -174,20 +299,35 @@ async def get_home_data_with_status(
174299 try :
175300 async with self ._session .get (url , headers = headers , timeout = self ._timeout ) as response :
176301 if response .status == 200 :
177- return await response .json (), response .status
178- body = await response .text ()
179- body_preview = body .replace ("\n " , " " )[:200 ]
302+ try :
303+ parsed = await response .json ()
304+ except (aiohttp .ContentTypeError , TypeError , ValueError ) as err :
305+ _LOGGER .debug (
306+ "Location data fetch returned invalid JSON status=%s location_id=%s: %s" ,
307+ response .status ,
308+ _log_identifier (location_id ),
309+ err ,
310+ )
311+ return None , response .status
312+ if not isinstance (parsed , dict ):
313+ _LOGGER .debug (
314+ "Location data fetch returned unexpected payload type status=%s location_id=%s payload_type=%s" ,
315+ response .status ,
316+ _log_identifier (location_id ),
317+ type (parsed ).__name__ ,
318+ )
319+ return None , response .status
320+ return parsed , response .status
180321 _LOGGER .debug (
181- "Location data fetch failed with status=%s location_id=%s body_preview=%r " ,
322+ "Location data fetch failed with status=%s location_id=%s" ,
182323 response .status ,
183- location_id ,
184- body_preview ,
324+ _log_identifier (location_id ),
185325 )
186326 return None , response .status
187327 except (aiohttp .ClientError , TimeoutError ) as err :
188328 _LOGGER .debug (
189329 "Location data fetch network error location_id=%s: %s" ,
190- location_id ,
330+ _log_identifier ( location_id ) ,
191331 err ,
192332 )
193333 return None , None
0 commit comments