Skip to content

growatt_server: automatic session re-login for Classic API#166102

Draft
johanzander wants to merge 2 commits intohome-assistant:devfrom
johanzander:fix/growatt-session-relogin
Draft

growatt_server: automatic session re-login for Classic API#166102
johanzander wants to merge 2 commits intohome-assistant:devfrom
johanzander:fix/growatt-session-relogin

Conversation

@johanzander
Copy link
Copy Markdown
Contributor

@johanzander johanzander commented Mar 21, 2026

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:

  • The coordinator detects JSONDecodeError during data fetch (session expiry indicator for Classic API only)
  • It calls _async_re_login() which acquires a shared asyncio.Lock on GrowattRuntimeData to prevent concurrent re-login attempts from multiple coordinators
  • A 60-second cooldown (time.monotonic()) ensures only one actual re-login call is made even if many coordinators detect expiry simultaneously
  • On success, the data fetch is retried transparently — entities never become unavailable
  • If credentials are invalid, ConfigEntryAuthFailed is raised, triggering HA's standard reauth flow
  • V1 API (token-based) is completely unaffected

The shared API instance architecture means a single re-login restores the session for all coordinators at once, since they share the same GrowattApi object (and its session cookie).

Type of change

  • Dependency upgrade
  • Bugfix (non-breaking change which fixes an issue)
  • New integration (thank you!)
  • New feature (which adds functionality to an existing integration)
  • Deprecation (breaking change to happen in the future)
  • Breaking change (fix/feature causing existing functionality to break)
  • Code quality improvements to existing code or addition of tests

Additional information

  • This PR fixes or closes issue: fixes #
  • This PR is related to issue:
  • Link to documentation pull request:
  • Link to developer documentation pull request:
  • Link to frontend pull request:

Checklist

  • I understand the code I am submitting and can explain how it works.
  • The code change is tested and works locally.
  • Local tests pass. Your PR cannot be merged unless tests pass
  • There is no commented out code in this PR.
  • I have followed the development checklist
  • I have followed the perfect PR recommendations
  • The code has been formatted using Ruff (ruff format homeassistant tests)
  • Tests have been added to verify that the new code works.
  • Any generated code has been carefully reviewed for correctness and compliance with project standards.

If user exposed functionality or configuration variables are added/changed:

If the code communicates with devices, web services, or third-party tools:

  • The manifest file has all fields filled out correctly.
    Updated and included derived files by running: python3 -m script.hassfest.
  • New or updated dependencies have been added to requirements_all.txt.
    Updated by running python3 -m script.gen_requirements_all.
  • For the updated dependencies a diff between library versions and ideally a link to the changelog/release notes is added to the PR description.

To help with the load of incoming pull requests:

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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +228 to +235
# 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

Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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).

Copilot uses AI. Check for mistakes.
Comment on lines +291 to +297
# 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,
)
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 343 to 351
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
)
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
@johanzander johanzander marked this pull request as draft March 21, 2026 07:53
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants