3131RATE_LIMIT_DELAY = 5 # seconds for 429 responses
3232DEFAULT_TOKEN_EXPIRY_DAYS = 180 # Match JWT Auth Pro default
3333
34+ # Connection settings
35+ USER_AGENT = "HomeAssistant/UltraCardProCloud/1.0"
36+ REQUEST_TIMEOUT = 30 # Total timeout in seconds
37+ CONNECT_TIMEOUT = 10 # Connection establishment timeout
38+
3439
3540def parse_jwt_expiry (token : str ) -> int | None :
3641 """Parse the expiry timestamp from a JWT token.
@@ -71,6 +76,22 @@ def parse_jwt_expiry(token: str) -> int | None:
7176 return None
7277
7378
79+ def _get_timeout () -> aiohttp .ClientTimeout :
80+ """Get standard timeout configuration."""
81+ return aiohttp .ClientTimeout (total = REQUEST_TIMEOUT , connect = CONNECT_TIMEOUT )
82+
83+
84+ def _get_headers (token : str | None = None ) -> dict [str , str ]:
85+ """Get standard request headers with User-Agent."""
86+ headers = {
87+ "User-Agent" : USER_AGENT ,
88+ "Accept" : "application/json" ,
89+ }
90+ if token :
91+ headers ["Authorization" ] = f"Bearer { token } "
92+ return headers
93+
94+
7495class UltraCardProCloudCoordinator (DataUpdateCoordinator ):
7596 """Class to manage fetching Ultra Card Pro Cloud data."""
7697
@@ -155,21 +176,106 @@ async def _async_update_data(self) -> dict[str, Any]:
155176 _LOGGER .debug ("✅ Data update successful" )
156177 return result
157178
158- except Exception as err :
179+ except aiohttp .ClientConnectorError as err :
180+ # DNS resolution failed or connection refused
181+ self ._auth_failure_count += 1
182+ _LOGGER .error (
183+ "❌ Connection error to Ultra Card API: %s "
184+ "(This is usually a DNS or network issue - verify ultracard.io is reachable from your HA instance)" ,
185+ err
186+ )
187+ return {
188+ "authenticated" : False ,
189+ "error" : f"Connection failed: Unable to reach ultracard.io - { err } " ,
190+ "error_type" : "connection" ,
191+ }
192+ except aiohttp .ClientSSLError as err :
193+ # SSL/TLS certificate issues
194+ self ._auth_failure_count += 1
195+ _LOGGER .error (
196+ "❌ SSL/TLS error connecting to Ultra Card API: %s "
197+ "(Certificate validation failed - this may be a network proxy or firewall issue)" ,
198+ err
199+ )
200+ return {
201+ "authenticated" : False ,
202+ "error" : f"SSL certificate error: { err } " ,
203+ "error_type" : "ssl" ,
204+ }
205+ except asyncio .TimeoutError :
206+ # Request timed out
159207 self ._auth_failure_count += 1
160- _LOGGER .error ("❌ Error communicating with Ultra Card API: %s" , err )
208+ _LOGGER .error (
209+ "❌ Timeout connecting to Ultra Card API "
210+ "(Server did not respond within %d seconds - check your internet connection or try again later)" ,
211+ REQUEST_TIMEOUT
212+ )
213+ return {
214+ "authenticated" : False ,
215+ "error" : f"Connection timed out after { REQUEST_TIMEOUT } seconds" ,
216+ "error_type" : "timeout" ,
217+ }
218+ except aiohttp .ServerDisconnectedError as err :
219+ # Server closed connection unexpectedly
220+ self ._auth_failure_count += 1
221+ _LOGGER .error (
222+ "❌ Server disconnected during request: %s "
223+ "(The server closed the connection - this may be temporary, try again)" ,
224+ err
225+ )
226+ return {
227+ "authenticated" : False ,
228+ "error" : f"Server disconnected: { err } " ,
229+ "error_type" : "disconnected" ,
230+ }
231+ except aiohttp .ClientResponseError as err :
232+ # HTTP error response
233+ self ._auth_failure_count += 1
234+ _LOGGER .error (
235+ "❌ HTTP error from Ultra Card API: %s %s" ,
236+ err .status , err .message
237+ )
238+ return {
239+ "authenticated" : False ,
240+ "error" : f"HTTP { err .status } : { err .message } " ,
241+ "error_type" : "http_error" ,
242+ }
243+ except UpdateFailed as err :
244+ # Our own UpdateFailed exceptions
245+ self ._auth_failure_count += 1
246+ _LOGGER .error ("❌ Update failed: %s" , err )
161247
162248 # If we've failed multiple times, clear tokens to force re-auth
163249 if self ._auth_failure_count >= 3 :
164- _LOGGER .warning ("⚠️ Multiple auth failures, clearing tokens for fresh start" )
250+ _LOGGER .warning ("⚠️ Multiple auth failures (%d) , clearing tokens for fresh start" , self . _auth_failure_count )
165251 self ._jwt_token = None
166252 self ._refresh_token = None
167253 self ._token_expires_at = 0
168254
169- # Return unauthenticated state instead of raising
170255 return {
171256 "authenticated" : False ,
172257 "error" : str (err ),
258+ "error_type" : "update_failed" ,
259+ }
260+ except Exception as err :
261+ # Catch-all for unexpected errors
262+ self ._auth_failure_count += 1
263+ _LOGGER .error (
264+ "❌ Unexpected error communicating with Ultra Card API: %s (%s)" ,
265+ err , type (err ).__name__
266+ )
267+
268+ # If we've failed multiple times, clear tokens to force re-auth
269+ if self ._auth_failure_count >= 3 :
270+ _LOGGER .warning ("⚠️ Multiple auth failures (%d), clearing tokens for fresh start" , self ._auth_failure_count )
271+ self ._jwt_token = None
272+ self ._refresh_token = None
273+ self ._token_expires_at = 0
274+
275+ return {
276+ "authenticated" : False ,
277+ "error" : f"{ type (err ).__name__ } : { err } " ,
278+ "error_type" : "unknown" ,
173279 }
174280
175281 async def _authenticate (self ) -> None :
@@ -187,7 +293,8 @@ async def _authenticate(self) -> None:
187293 async with self .session .post (
188294 url ,
189295 json = {"username" : username , "password" : password },
190- timeout = aiohttp .ClientTimeout (total = 15 ),
296+ headers = _get_headers (),
297+ timeout = _get_timeout (),
191298 ) as response :
192299 response_text = await response .text ()
193300 _LOGGER .debug ("📥 Auth response status: %s" , response .status )
@@ -305,11 +412,14 @@ async def _refresh_jwt_token(self) -> None:
305412 try :
306413 # JWT Auth Pro expects the refresh token in Authorization header as Bearer token
307414 # OR in the request body - we'll try both approaches
415+ headers = _get_headers ()
416+ headers ["Authorization" ] = f"Bearer { self ._refresh_token } "
417+
308418 async with self .session .post (
309419 url ,
310420 json = {"refresh_token" : self ._refresh_token },
311- headers = { "Authorization" : f"Bearer { self . _refresh_token } " } ,
312- timeout = aiohttp . ClientTimeout ( total = 15 ),
421+ headers = headers ,
422+ timeout = _get_timeout ( ),
313423 ) as response :
314424 response_text = await response .text ()
315425 _LOGGER .debug ("📥 Refresh response status: %s" , response .status )
@@ -398,8 +508,8 @@ async def _fetch_user_profile(self) -> dict[str, Any]:
398508 try :
399509 async with self .session .get (
400510 url ,
401- headers = { "Authorization" : f"Bearer { self ._jwt_token } " } ,
402- timeout = aiohttp . ClientTimeout ( total = 15 ),
511+ headers = _get_headers ( self ._jwt_token ) ,
512+ timeout = _get_timeout ( ),
403513 ) as response :
404514 response_text = await response .text ()
405515 _LOGGER .debug ("📥 User profile response status: %s" , response .status )
@@ -458,8 +568,8 @@ async def _fetch_subscription(self) -> dict[str, Any]:
458568 try :
459569 async with self .session .get (
460570 url ,
461- headers = { "Authorization" : f"Bearer { self ._jwt_token } " } ,
462- timeout = aiohttp . ClientTimeout ( total = 15 ),
571+ headers = _get_headers ( self ._jwt_token ) ,
572+ timeout = _get_timeout ( ),
463573 ) as response :
464574 response_text = await response .text ()
465575 _LOGGER .debug ("📥 Subscription response status: %s" , response .status )
@@ -505,6 +615,85 @@ async def _fetch_subscription(self) -> dict[str, Any]:
505615
506616 raise UpdateFailed ("Failed to fetch subscription after all retries" )
507617
618+ async def async_test_connectivity (self ) -> dict [str , Any ]:
619+ """Test connectivity to ultracard.io for diagnostics.
620+
621+ Returns a dict with test results for each stage:
622+ - dns: Whether DNS resolution succeeded
623+ - ssl: Whether SSL/TLS handshake succeeded
624+ - api: Whether the API responded
625+ - auth: Whether authentication works (if credentials provided)
626+ """
627+ results = {
628+ "dns" : False ,
629+ "ssl" : False ,
630+ "api" : False ,
631+ "auth" : False ,
632+ "errors" : [],
633+ }
634+
635+ # Test 1: Basic connectivity (DNS + SSL)
636+ try :
637+ async with self .session .get (
638+ "https://ultracard.io/" ,
639+ timeout = aiohttp .ClientTimeout (total = 10 , connect = 5 ),
640+ headers = {"User-Agent" : USER_AGENT },
641+ ) as resp :
642+ results ["dns" ] = True
643+ results ["ssl" ] = True
644+ if resp .status < 500 :
645+ results ["api" ] = True
646+ else :
647+ results ["errors" ].append (f"Server returned status { resp .status } " )
648+ except aiohttp .ClientConnectorError as e :
649+ results ["errors" ].append (f"DNS/Connection failed: { e } " )
650+ except aiohttp .ClientSSLError as e :
651+ results ["dns" ] = True # DNS worked if we got to SSL
652+ results ["errors" ].append (f"SSL/TLS failed: { e } " )
653+ except asyncio .TimeoutError :
654+ results ["errors" ].append ("Connection timed out" )
655+ except Exception as e :
656+ results ["errors" ].append (f"Unexpected error: { type (e ).__name__ } : { e } " )
657+
658+ # Test 2: API endpoint accessibility
659+ if results ["ssl" ]:
660+ try :
661+ url = f"{ API_BASE_URL } { JWT_ENDPOINT } "
662+ async with self .session .get (
663+ url ,
664+ timeout = aiohttp .ClientTimeout (total = 10 , connect = 5 ),
665+ headers = {"User-Agent" : USER_AGENT },
666+ ) as resp :
667+ # JWT endpoint should return 405 for GET (method not allowed) or 200
668+ # Either indicates the API is reachable
669+ if resp .status in (200 , 405 , 401 ):
670+ results ["api" ] = True
671+ else :
672+ results ["errors" ].append (f"API endpoint returned unexpected status { resp .status } " )
673+ except Exception as e :
674+ results ["errors" ].append (f"API test failed: { type (e ).__name__ } : { e } " )
675+
676+ # Test 3: Authentication (if we have credentials)
677+ if results ["api" ] and self ._jwt_token :
678+ results ["auth" ] = True
679+ elif results ["api" ]:
680+ try :
681+ # Try to authenticate
682+ await self ._authenticate ()
683+ if self ._jwt_token :
684+ results ["auth" ] = True
685+ except Exception as e :
686+ results ["errors" ].append (f"Authentication test failed: { e } " )
687+
688+ _LOGGER .info (
689+ "🔍 Connectivity test results - DNS: %s, SSL: %s, API: %s, Auth: %s" ,
690+ results ["dns" ], results ["ssl" ], results ["api" ], results ["auth" ]
691+ )
692+ if results ["errors" ]:
693+ _LOGGER .warning ("⚠️ Connectivity test errors: %s" , results ["errors" ])
694+
695+ return results
696+
508697 async def async_logout (self ) -> None :
509698 """Logout and invalidate tokens."""
510699 _LOGGER .debug ("🚪 Logging out from Ultra Card Pro Cloud" )
@@ -521,7 +710,7 @@ async def async_logout(self) -> None:
521710 try :
522711 async with self .session .post (
523712 url ,
524- headers = { "Authorization" : f"Bearer { self ._jwt_token } " } ,
713+ headers = _get_headers ( self ._jwt_token ) ,
525714 timeout = aiohttp .ClientTimeout (total = 5 ),
526715 ) as response :
527716 if 200 <= response .status < 300 :
0 commit comments