growatt_server: automatic session re-login for Classic API#166102
growatt_server: automatic session re-login for Classic API#166102johanzander wants to merge 2 commits intohome-assistant:devfrom
Conversation
When the Growatt Classic API session expires, the server returns an HTML login page instead of JSON, which is detected as a JSONDecodeError. This change adds an `_async_re_login()` method that silently re-authenticates using stored credentials before raising UpdateFailed or ConfigEntryAuthFailed. Key design points: - Shared API instance across coordinators: re-login restores the session for all coordinators simultaneously (one login call, shared session cookie) - asyncio.Lock on GrowattRuntimeData prevents concurrent re-login attempts - 60-second cooldown (via time.monotonic()) avoids redundant logins when multiple coordinators detect expiry at the same time - Re-login on JSONDecodeError only applies to Classic API; V1 API (token) is unaffected - Invalid credentials during re-login raise ConfigEntryAuthFailed, which triggers HA's standard reauth flow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds automatic session re-authentication for the Growatt Classic (username/password) API by detecting session-expiry JSON decode failures, re-logging in, and retrying coordinator fetches so entities don’t stay unavailable until restart.
Changes:
- Pass a shared Growatt API client instance into all coordinators and add Classic-session re-login + retry on
JSONDecodeError. - Add shared runtime state (
login_lock,last_login_time) to coordinate re-login attempts across coordinators. - Update/add tests to cover Classic session expiry behavior and reauth triggering.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
homeassistant/components/growatt_server/coordinator.py |
Adds Classic session re-login logic and retries after JSONDecodeError; coordinator now consumes a provided shared API instance. |
homeassistant/components/growatt_server/models.py |
Extends runtime data with a shared asyncio.Lock and last-login timestamp for re-login coordination. |
homeassistant/components/growatt_server/__init__.py |
Passes the shared API instance into coordinators created during setup. |
tests/components/growatt_server/test_init.py |
Updates Classic auth-failure test and adds a new test for silent re-login and retry on session expiry. |
| # Guard: config_entry may be None or runtime_data not yet set | ||
| # (e.g. during async_config_entry_first_refresh) | ||
| if self.config_entry is None or not hasattr(self.config_entry, "runtime_data"): | ||
| return False | ||
|
|
||
| config_entry = self.config_entry | ||
| runtime_data: GrowattRuntimeData = config_entry.runtime_data | ||
|
|
There was a problem hiding this comment.
_async_re_login() depends on config_entry.runtime_data, but async_setup_entry() assigns runtime_data only after all coordinators have completed async_config_entry_first_refresh(). If a JSONDecodeError (session expiry / HTML response) happens during a coordinator’s first refresh, this guard causes re-login to be skipped and the update fails instead. Consider moving the shared lock/last_login_time to a structure that is initialized before the first refresh (e.g., create and assign runtime_data earlier, or store the lock on hass.data per entry, or pass a shared lock into the coordinators).
| # The server returned an HTML login page instead of JSON — session expired. | ||
| _LOGGER.warning( | ||
| "Data fetch failed for %s (%s: %s), attempting re-login", | ||
| self.device_id, | ||
| type(err).__name__, | ||
| err, | ||
| ) |
There was a problem hiding this comment.
The warning about JSONDecodeError is logged before acquiring the shared login_lock, so on session expiry every coordinator will emit a WARNING even though only one will actually perform the re-login (others will later skip due to cooldown). This can create noisy logs on setups with multiple devices. Consider reducing this log to DEBUG, or logging only from inside _async_re_login() when a login is actually attempted.
| total_coordinator = GrowattCoordinator( | ||
| hass, config_entry, plant_id, "total", plant_id | ||
| hass, config_entry, plant_id, "total", plant_id, api | ||
| ) | ||
|
|
||
| # Create coordinators for each device | ||
| device_coordinators = { | ||
| device["deviceSn"]: GrowattCoordinator( | ||
| hass, config_entry, device["deviceSn"], device["deviceType"], plant_id | ||
| hass, config_entry, device["deviceSn"], device["deviceType"], plant_id, api | ||
| ) |
There was a problem hiding this comment.
All coordinators now share the same api instance. Periodic refreshes can run concurrently across coordinators, which can result in concurrent executor-thread calls into the same underlying client/session (often not thread-safe, especially if it maintains cookies/session state). Consider serializing access to the shared API with a shared lock (separate from the login_lock), or restructuring to use a single coordinator so that only one update hits the API at a time.
| for flow in flows | ||
| ) | ||
| # Verify re-login was called (once for setup, once for re-login) | ||
| assert mock_growatt_classic_api.login.call_count >= 2 |
There was a problem hiding this comment.
The new session re-login test doesn’t currently assert that the retry actually happened and produced usable data (e.g., that tlx_detail was called twice, or that a representative entity/coordinator data updated to a non-unavailable state). As written, it could still pass if the refresh ultimately raises UpdateFailed after the re-login attempt. Add an assertion that confirms the successful retry path.
| assert mock_growatt_classic_api.login.call_count >= 2 | |
| assert mock_growatt_classic_api.login.call_count >= 2 | |
| # Verify that the data fetch was retried and succeeded (error + successful retry) | |
| assert mock_growatt_classic_api.tlx_detail.call_count == 2 |
- Move runtime_data assignment before first_refresh so the shared lock is available to coordinators if session expiry occurs during initial data fetch - Add api_lock to GrowattRuntimeData to serialize Classic API calls across coordinators (requests.Session is not thread-safe; shared instance requires serialized access) - Downgrade JSONDecodeError log from WARNING to DEBUG before re-login attempt (only one coordinator actually re-logs in; others skip via cooldown — a WARNING from every coordinator would be noisy) - Add tlx_detail.call_count assertion to session re-login test to confirm the retry path was exercised and produced data Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Proposed change
The Growatt Classic API (username/password) uses session cookies that expire. When a session expires, the server returns an HTML login page instead of JSON, causing a
JSONDecodeError. Previously this would permanently mark entities unavailable until the next HA restart.This PR adds automatic silent re-authentication:
JSONDecodeErrorduring data fetch (session expiry indicator for Classic API only)_async_re_login()which acquires a sharedasyncio.LockonGrowattRuntimeDatato prevent concurrent re-login attempts from multiple coordinatorstime.monotonic()) ensures only one actual re-login call is made even if many coordinators detect expiry simultaneouslyConfigEntryAuthFailedis raised, triggering HA's standard reauth flowThe shared API instance architecture means a single re-login restores the session for all coordinators at once, since they share the same
GrowattApiobject (and its session cookie).Type of change
Additional information
Checklist
ruff format homeassistant tests)If user exposed functionality or configuration variables are added/changed:
If the code communicates with devices, web services, or third-party tools:
Updated and included derived files by running:
python3 -m script.hassfest.requirements_all.txt.Updated by running
python3 -m script.gen_requirements_all.To help with the load of incoming pull requests: