1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Implement automatic migration for Growatt Server DEFAULT_PLANT_ID entries (#159972)

This commit is contained in:
johanzander
2026-02-13 01:56:50 +01:00
committed by GitHub
parent 036696f4cd
commit 2dc0d32a29
5 changed files with 542 additions and 154 deletions

View File

@@ -16,6 +16,7 @@ from homeassistant.helpers.typing import ConfigType
from .const import (
AUTH_API_TOKEN,
AUTH_PASSWORD,
CACHED_API_KEY,
CONF_AUTH_TYPE,
CONF_PLANT_ID,
DEFAULT_PLANT_ID,
@@ -41,15 +42,163 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
def get_device_list_classic(
api: growattServer.GrowattApi, config: Mapping[str, str]
) -> tuple[list[dict[str, str]], str]:
"""Retrieve the device list for the selected plant."""
plant_id = config[CONF_PLANT_ID]
async def async_migrate_entry(
hass: HomeAssistant, config_entry: GrowattConfigEntry
) -> bool:
"""Migrate old config entries.
# Log in to api and fetch first plant if no plant id is defined.
Migration from version 1.0 to 1.1:
- Resolves DEFAULT_PLANT_ID (legacy value "0") to actual plant_id
- Only applies to Classic API (username/password authentication)
- Caches the logged-in API instance to avoid growatt server API rate limiting
Rate Limiting Workaround:
The Growatt Classic API rate-limits individual endpoints (login, plant_list,
device_list) with 5-minute windows. Without caching, the sequence would be:
Migration: login() → plant_list()
Setup: login() → device_list()
This results in 2 login() calls within seconds, triggering rate limits.
By caching the API instance (which contains the authenticated session), we
achieve:
Migration: login() → plant_list() → [cache API instance]
Setup: [reuse cached API] → device_list()
This reduces to just 1 login() call during the migration+setup cycle and prevent account lockout.
"""
_LOGGER.debug(
"Migrating config entry from version %s.%s",
config_entry.version,
config_entry.minor_version,
)
# Migrate from version 1.0 to 1.1
if config_entry.version == 1 and config_entry.minor_version < 1:
config = config_entry.data
# First, ensure auth_type field exists (legacy config entry migration)
# This handles config entries created before auth_type was introduced
if CONF_AUTH_TYPE not in config:
new_data = dict(config_entry.data)
# Detect auth type based on which fields are present
if CONF_TOKEN in config:
new_data[CONF_AUTH_TYPE] = AUTH_API_TOKEN
hass.config_entries.async_update_entry(config_entry, data=new_data)
config = config_entry.data
_LOGGER.debug("Added auth_type field to V1 API config entry")
elif CONF_USERNAME in config:
new_data[CONF_AUTH_TYPE] = AUTH_PASSWORD
hass.config_entries.async_update_entry(config_entry, data=new_data)
config = config_entry.data
_LOGGER.debug("Added auth_type field to Classic API config entry")
else:
# Config entry has no auth fields - this is invalid but migration
# should still succeed. Setup will fail later with a clearer error.
_LOGGER.warning(
"Config entry has no authentication fields. "
"Setup will fail until the integration is reconfigured"
)
# Handle DEFAULT_PLANT_ID resolution
if config.get(CONF_PLANT_ID) == DEFAULT_PLANT_ID:
# V1 API should never have DEFAULT_PLANT_ID (plant selection happens in config flow)
# If it does, this indicates a corrupted config entry
if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN:
_LOGGER.error(
"V1 API config entry has DEFAULT_PLANT_ID, which indicates a "
"corrupted configuration. Please reconfigure the integration"
)
return False
# Classic API with DEFAULT_PLANT_ID - resolve to actual plant_id
if config.get(CONF_AUTH_TYPE) == AUTH_PASSWORD:
username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
url = config.get(CONF_URL, DEFAULT_URL)
if not username or not password:
# Credentials missing - cannot migrate
_LOGGER.error(
"Cannot migrate DEFAULT_PLANT_ID due to missing credentials"
)
return False
try:
# Create API instance and login
api, login_response = await _create_api_and_login(
hass, username, password, url
)
# Resolve DEFAULT_PLANT_ID to actual plant_id
plant_info = await hass.async_add_executor_job(
api.plant_list, login_response["user"]["id"]
)
except (ConfigEntryError, RequestException, JSONDecodeError) as ex:
# API failure during migration - return False to retry later
_LOGGER.error(
"Failed to resolve plant_id during migration: %s. "
"Migration will retry on next restart",
ex,
)
return False
if not plant_info or "data" not in plant_info or not plant_info["data"]:
_LOGGER.error(
"No plants found for this account. "
"Migration will retry on next restart"
)
return False
first_plant_id = plant_info["data"][0]["plantId"]
# Update config entry with resolved plant_id
new_data = dict(config_entry.data)
new_data[CONF_PLANT_ID] = first_plant_id
hass.config_entries.async_update_entry(
config_entry, data=new_data, minor_version=1
)
# Cache the logged-in API instance for reuse in async_setup_entry()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][f"{CACHED_API_KEY}{config_entry.entry_id}"] = api
_LOGGER.info(
"Migrated config entry to use specific plant_id '%s'",
first_plant_id,
)
else:
# No DEFAULT_PLANT_ID to resolve, just bump version
hass.config_entries.async_update_entry(config_entry, minor_version=1)
_LOGGER.debug("Migration completed to version %s.%s", config_entry.version, 1)
return True
async def _create_api_and_login(
hass: HomeAssistant, username: str, password: str, url: str
) -> tuple[growattServer.GrowattApi, dict]:
"""Create API instance and perform login.
Returns both the API instance (with authenticated session) and the login
response (containing user_id needed for subsequent API calls).
"""
api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username)
api.server_url = url
login_response = await hass.async_add_executor_job(
_login_classic_api, api, username, password
)
return api, login_response
def _login_classic_api(
api: growattServer.GrowattApi, username: str, password: str
) -> dict:
"""Log in to Classic API and return user info."""
try:
login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD])
login_response = api.login(username, password)
except (RequestException, JSONDecodeError) as ex:
raise ConfigEntryError(
f"Error communicating with Growatt API during login: {ex}"
@@ -62,31 +211,7 @@ def get_device_list_classic(
raise ConfigEntryAuthFailed("Username, Password or URL may be incorrect!")
raise ConfigEntryError(f"Growatt login failed: {msg}")
user_id = login_response["user"]["id"]
# Legacy support: DEFAULT_PLANT_ID ("0") triggers auto-selection of first plant.
# Modern config flow always sets a specific plant_id, but old config entries
# from earlier versions may still have plant_id="0".
if plant_id == DEFAULT_PLANT_ID:
try:
plant_info = api.plant_list(user_id)
except (RequestException, JSONDecodeError) as ex:
raise ConfigEntryError(
f"Error communicating with Growatt API during plant list: {ex}"
) from ex
if not plant_info or "data" not in plant_info or not plant_info["data"]:
raise ConfigEntryError("No plants found for this account.")
plant_id = plant_info["data"][0]["plantId"]
# Get a list of devices for specified plant to add sensors for.
try:
devices = api.device_list(plant_id)
except (RequestException, JSONDecodeError) as ex:
raise ConfigEntryError(
f"Error communicating with Growatt API during device list: {ex}"
) from ex
return devices, plant_id
return login_response
def get_device_list_v1(
@@ -94,9 +219,9 @@ def get_device_list_v1(
) -> tuple[list[dict[str, str]], str]:
"""Device list logic for Open API V1.
Note: Plant selection (including auto-selection if only one plant exists)
is handled in the config flow before this function is called. This function
only fetches devices for the already-selected plant_id.
Plant selection is handled in the config flow before this function is called.
This function expects a specific plant_id and fetches devices for that plant.
"""
plant_id = config[CONF_PLANT_ID]
try:
@@ -126,19 +251,6 @@ def get_device_list_v1(
return supported_devices, plant_id
def get_device_list(
api, config: Mapping[str, str], api_version: str
) -> tuple[list[dict[str, str]], str]:
"""Dispatch to correct device list logic based on API version."""
if api_version == "v1":
return get_device_list_v1(api, config)
if api_version == "classic":
return get_device_list_classic(api, config)
# Defensive: api_version is hardcoded in async_setup_entry as "v1" or "classic"
# This line is unreachable through normal execution but kept as a safeguard
raise ConfigEntryError(f"Unknown API version: {api_version}") # pragma: no cover
async def async_setup_entry(
hass: HomeAssistant, config_entry: GrowattConfigEntry
) -> bool:
@@ -154,40 +266,47 @@ async def async_setup_entry(
new_data[CONF_URL] = url
hass.config_entries.async_update_entry(config_entry, data=new_data)
# Migrate legacy config entries without auth_type field
if CONF_AUTH_TYPE not in config:
new_data = dict(config_entry.data)
# Detect auth type based on which fields are present
if CONF_TOKEN in config:
new_data[CONF_AUTH_TYPE] = AUTH_API_TOKEN
elif CONF_USERNAME in config:
new_data[CONF_AUTH_TYPE] = AUTH_PASSWORD
else:
raise ConfigEntryError(
"Unable to determine authentication type from config entry."
)
hass.config_entries.async_update_entry(config_entry, data=new_data)
config = config_entry.data
# Determine API version
# Determine API version and get devices
# Note: auth_type field is guaranteed to exist after migration
if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN:
api_version = "v1"
# V1 API (token-based, no login needed)
token = config[CONF_TOKEN]
api = growattServer.OpenApiV1(token=token)
elif config.get(CONF_AUTH_TYPE) == AUTH_PASSWORD:
api_version = "classic"
username = config[CONF_USERNAME]
api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=username
devices, plant_id = await hass.async_add_executor_job(
get_device_list_v1, api, config
)
api.server_url = url
elif config.get(CONF_AUTH_TYPE) == AUTH_PASSWORD:
# Classic API (username/password with login)
username = config[CONF_USERNAME]
password = config[CONF_PASSWORD]
# Check if migration cached an authenticated API instance for us to reuse.
# This avoids calling login() twice (once in migration, once here) which
# would trigger rate limiting.
cached_api = hass.data.get(DOMAIN, {}).pop(
f"{CACHED_API_KEY}{config_entry.entry_id}", None
)
if cached_api:
# Reuse the logged-in API instance from migration (rate limit optimization)
api = cached_api
_LOGGER.debug("Reusing logged-in session from migration")
else:
# No cached API (normal setup or migration didn't run)
# Create new API instance and login
api, _ = await _create_api_and_login(hass, username, password, url)
# Get plant_id and devices using the authenticated session
plant_id = config[CONF_PLANT_ID]
try:
devices = await hass.async_add_executor_job(api.device_list, plant_id)
except (RequestException, JSONDecodeError) as ex:
raise ConfigEntryError(
f"Error communicating with Growatt API during device list: {ex}"
) from ex
else:
raise ConfigEntryError("Unknown authentication type in config entry.")
devices, plant_id = await hass.async_add_executor_job(
get_device_list, api, config, api_version
)
# Create a coordinator for the total sensors
total_coordinator = GrowattCoordinator(
hass, config_entry, plant_id, "total", plant_id

View File

@@ -40,6 +40,7 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow class."""
VERSION = 1
MINOR_VERSION = 1
api: growattServer.GrowattApi

View File

@@ -53,3 +53,8 @@ ABORT_NO_PLANTS = "no_plants"
BATT_MODE_LOAD_FIRST = 0
BATT_MODE_BATTERY_FIRST = 1
BATT_MODE_GRID_FIRST = 2
# Internal key prefix for caching authenticated API instance
# Used to pass logged-in session from async_migrate_entry to async_setup_entry
# to avoid double login() calls that trigger API rate limiting
CACHED_API_KEY = "_cached_api_"

View File

@@ -155,8 +155,8 @@ def mock_growatt_classic_api():
Individual tests can override specific return values to test error conditions.
Methods mocked for integration setup:
- login: Called during get_device_list_classic to authenticate
- plant_list: Called during setup if plant_id is default (to auto-select plant)
- login: Called during migration (to resolve DEFAULT_PLANT_ID) or async_setup_entry
- plant_list: Called during migration to resolve DEFAULT_PLANT_ID to actual plant_id
- device_list: Called during async_setup_entry to discover devices
Methods mocked for total coordinator refresh:
@@ -265,9 +265,10 @@ def mock_config_entry_classic() -> MockConfigEntry:
def mock_config_entry_classic_default_plant() -> MockConfigEntry:
"""Return a mocked config entry for Classic API with DEFAULT_PLANT_ID.
This config entry uses plant_id="0" which triggers auto-plant-selection logic
in the Classic API path. This is legacy support for old config entries that
didn't have a specific plant_id set during initial configuration.
This config entry uses plant_id="0" which triggers migration logic in
async_setup_entry to resolve to the actual plant_id. This is legacy support
for old config entries that didn't have a specific plant_id set during initial
configuration.
"""
return MockConfigEntry(
domain=DOMAIN,
@@ -276,7 +277,7 @@ def mock_config_entry_classic_default_plant() -> MockConfigEntry:
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_password",
CONF_URL: "https://server.growatt.com/",
CONF_PLANT_ID: DEFAULT_PLANT_ID, # "0" triggers auto-selection
CONF_PLANT_ID: DEFAULT_PLANT_ID, # "0" - should trigger migration
},
unique_id="plant_default",
)

View File

@@ -9,14 +9,24 @@ import pytest
import requests
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.growatt_server import async_migrate_entry
from homeassistant.components.growatt_server.const import (
AUTH_API_TOKEN,
AUTH_PASSWORD,
CACHED_API_KEY,
CONF_AUTH_TYPE,
CONF_PLANT_ID,
DEFAULT_PLANT_ID,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
CONF_URL,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
@@ -126,64 +136,66 @@ async def test_classic_api_setup(
assert device_entry == snapshot
async def test_migrate_legacy_api_token_config(
@pytest.mark.parametrize(
("config_data", "expected_auth_type"),
[
(
{
CONF_TOKEN: "test_token_123",
CONF_URL: "https://openapi.growatt.com/",
"plant_id": "plant_123",
},
AUTH_API_TOKEN,
),
(
{
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_password",
CONF_URL: "https://server.growatt.com/",
"plant_id": "plant_456",
},
AUTH_PASSWORD,
),
],
)
async def test_migrate_config_without_auth_type(
hass: HomeAssistant,
mock_growatt_v1_api,
config_data: dict[str, str],
expected_auth_type: str,
) -> None:
"""Test migration of legacy config entry with API token but no auth_type."""
# Create a legacy config entry without CONF_AUTH_TYPE
legacy_config = {
CONF_TOKEN: "test_token_123",
CONF_URL: "https://openapi.growatt.com/",
"plant_id": "plant_123",
}
"""Test migration adds auth_type field to legacy configs and bumps version.
This test verifies that config entries created before auth_type was introduced
are properly migrated by:
- Adding CONF_AUTH_TYPE with the correct value (AUTH_API_TOKEN or AUTH_PASSWORD)
- Bumping version from 1.0 to 1.1
"""
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data=legacy_config,
unique_id="plant_123",
data=config_data,
unique_id=config_data["plant_id"],
version=1,
minor_version=0,
)
await setup_integration(hass, mock_config_entry)
mock_config_entry.add_to_hass(hass)
# Verify migration occurred and auth_type was added
assert mock_config_entry.data[CONF_AUTH_TYPE] == AUTH_API_TOKEN
assert mock_config_entry.state is ConfigEntryState.LOADED
# Execute migration
migration_result = await async_migrate_entry(hass, mock_config_entry)
assert migration_result is True
# Verify version was updated to 1.1
assert mock_config_entry.version == 1
assert mock_config_entry.minor_version == 1
async def test_migrate_legacy_password_config(
hass: HomeAssistant,
mock_growatt_classic_api,
) -> None:
"""Test migration of legacy config entry with password auth but no auth_type."""
# Create a legacy config entry without CONF_AUTH_TYPE
legacy_config = {
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_password",
CONF_URL: "https://server.growatt.com/",
"plant_id": "plant_456",
}
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data=legacy_config,
unique_id="plant_456",
)
# Classic API doesn't support MIN devices - use TLX device instead
mock_growatt_classic_api.device_list.return_value = [
{"deviceSn": "TLX123456", "deviceType": "tlx"}
]
await setup_integration(hass, mock_config_entry)
# Verify migration occurred and auth_type was added
assert mock_config_entry.data[CONF_AUTH_TYPE] == AUTH_PASSWORD
assert mock_config_entry.state is ConfigEntryState.LOADED
# Verify auth_type field was added during migration
assert mock_config_entry.data[CONF_AUTH_TYPE] == expected_auth_type
async def test_migrate_legacy_config_no_auth_fields(
hass: HomeAssistant,
) -> None:
"""Test that config entry with no recognizable auth fields raises error."""
"""Test migration succeeds but setup fails for config without auth fields."""
# Create a config entry without any auth fields
invalid_config = {
CONF_URL: "https://openapi.growatt.com/",
@@ -193,13 +205,21 @@ async def test_migrate_legacy_config_no_auth_fields(
domain=DOMAIN,
data=invalid_config,
unique_id="plant_789",
version=1,
minor_version=0,
)
await setup_integration(hass, mock_config_entry)
mock_config_entry.add_to_hass(hass)
# The ConfigEntryError is caught by the config entry system
# and the entry state is set to SETUP_ERROR
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
# Migration should succeed (only updates version)
migration_result = await async_migrate_entry(hass, mock_config_entry)
assert migration_result is True
# Verify version was updated
assert mock_config_entry.version == 1
assert mock_config_entry.minor_version == 1
# Note: Setup will fail later due to missing auth fields in async_setup_entry
@pytest.mark.parametrize(
@@ -254,45 +274,58 @@ async def test_classic_api_login_failures(
],
ids=["network_error", "json_error"],
)
async def test_classic_api_plant_list_exceptions(
async def test_classic_api_device_list_exceptions(
hass: HomeAssistant,
mock_growatt_classic_api,
mock_config_entry_classic_default_plant: MockConfigEntry,
exception: Exception,
) -> None:
"""Test Classic API setup with plant list exceptions (default plant_id path)."""
# Login succeeds
mock_growatt_classic_api.login.return_value = {
"success": True,
"user": {"id": 123456},
}
"""Test Classic API setup with device_list exceptions."""
# Create a config entry that won't trigger migration
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_AUTH_TYPE: AUTH_PASSWORD,
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_password",
CONF_URL: "https://server.growatt.com/",
CONF_PLANT_ID: "specific_plant_123", # Specific ID to avoid migration
},
unique_id="plant_123",
)
# But plant_list raises exception
mock_growatt_classic_api.plant_list.side_effect = exception
# device_list raises exception during setup
mock_growatt_classic_api.device_list.side_effect = exception
await setup_integration(hass, mock_config_entry_classic_default_plant)
await setup_integration(hass, mock_config_entry)
assert mock_config_entry_classic_default_plant.state is ConfigEntryState.SETUP_ERROR
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
async def test_classic_api_plant_list_no_plants(
async def test_classic_api_device_list_no_devices(
hass: HomeAssistant,
mock_growatt_classic_api,
mock_config_entry_classic_default_plant: MockConfigEntry,
) -> None:
"""Test Classic API setup when plant list returns no plants."""
# Login succeeds
mock_growatt_classic_api.login.return_value = {
"success": True,
"user": {"id": 123456},
}
"""Test Classic API setup when device list returns no devices."""
# Create a config entry that won't trigger migration
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_AUTH_TYPE: AUTH_PASSWORD,
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_password",
CONF_URL: "https://server.growatt.com/",
CONF_PLANT_ID: "specific_plant_456", # Specific ID to avoid migration
},
unique_id="plant_456",
)
# But plant_list returns empty list
mock_growatt_classic_api.plant_list.return_value = {"data": []}
# device_list returns empty list (no devices)
mock_growatt_classic_api.device_list.return_value = []
await setup_integration(hass, mock_config_entry_classic_default_plant)
await setup_integration(hass, mock_config_entry)
assert mock_config_entry_classic_default_plant.state is ConfigEntryState.SETUP_ERROR
# Should still load successfully even with no devices
assert mock_config_entry.state is ConfigEntryState.LOADED
@pytest.mark.parametrize(
@@ -393,3 +426,232 @@ async def test_v1_api_unsupported_device_type(
assert mock_config_entry.state is ConfigEntryState.LOADED
# Verify warning was logged for unsupported device
assert "Device TLX789012 with type 5 not supported in Open API V1" in caplog.text
async def test_migrate_version_bump(
hass: HomeAssistant,
mock_growatt_classic_api,
) -> None:
"""Test migration from 1.0 to 1.1 resolves DEFAULT_PLANT_ID and bumps version.
This test verifies that:
- Migration successfully resolves DEFAULT_PLANT_ID ("0") to actual plant_id
- Config entry version is bumped from 1.0 to 1.1
- API instance is cached for setup to reuse (rate limit optimization)
"""
# Create a version 1.0 config entry with DEFAULT_PLANT_ID
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_AUTH_TYPE: AUTH_PASSWORD,
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_password",
CONF_URL: "https://server.growatt.com/",
CONF_PLANT_ID: DEFAULT_PLANT_ID,
CONF_NAME: "Test Plant",
},
unique_id="plant_default",
version=1,
minor_version=0,
)
# Mock successful API responses for migration
mock_growatt_classic_api.login.return_value = {
"success": True,
"user": {"id": 123456},
}
mock_growatt_classic_api.plant_list.return_value = {
"data": [{"plantId": "RESOLVED_PLANT_789", "plantName": "My Plant"}]
}
mock_config_entry.add_to_hass(hass)
# Execute migration
migration_result = await async_migrate_entry(hass, mock_config_entry)
assert migration_result is True
# Verify version was updated to 1.1
assert mock_config_entry.version == 1
assert mock_config_entry.minor_version == 1
# Verify plant_id was resolved to actual plant_id (not DEFAULT_PLANT_ID)
assert mock_config_entry.data[CONF_PLANT_ID] == "RESOLVED_PLANT_789"
# Verify API instance was cached for setup to reuse
assert f"{CACHED_API_KEY}{mock_config_entry.entry_id}" in hass.data[DOMAIN]
async def test_setup_reuses_cached_api_from_migration(
hass: HomeAssistant,
mock_growatt_classic_api,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that setup reuses cached API instance from migration.
This test verifies the rate limit optimization where:
1. Migration calls login() and caches the authenticated API instance
2. Setup retrieves and reuses the cached API (avoiding a second login())
3. The cached API is removed after use (one-time use pattern)
Without this caching, we would call login() twice within seconds:
Migration: login() → plant_list()
Setup: login() → device_list()
This would trigger Growatt API rate limiting (5-minute window per endpoint).
With caching, we only call login() once:
Migration: login() → plant_list() → [cache API]
Setup: [reuse API] → device_list()
"""
# Create a version 1.0 config entry with DEFAULT_PLANT_ID
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_AUTH_TYPE: AUTH_PASSWORD,
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_password",
CONF_URL: "https://server.growatt.com/",
CONF_PLANT_ID: DEFAULT_PLANT_ID,
CONF_NAME: "Test Plant",
},
unique_id="plant_default",
version=1,
minor_version=0,
)
# Mock successful API responses
mock_growatt_classic_api.login.return_value = {
"success": True,
"user": {"id": 123456},
}
mock_growatt_classic_api.plant_list.return_value = {
"data": [{"plantId": "RESOLVED_PLANT_789", "plantName": "My Plant"}]
}
mock_growatt_classic_api.device_list.return_value = [
{"deviceSn": "TLX123456", "deviceType": "tlx"}
]
mock_growatt_classic_api.plant_info.return_value = {
"deviceList": [],
"totalEnergy": 1250.0,
"todayEnergy": 12.5,
"invTodayPpv": 2500,
"plantMoneyText": "123.45/USD",
}
mock_growatt_classic_api.tlx_detail.return_value = {
"data": {"deviceSn": "TLX123456"}
}
mock_config_entry.add_to_hass(hass)
# Run migration first (resolves plant_id and caches authenticated API)
await async_migrate_entry(hass, mock_config_entry)
# Verify migration successfully resolved plant_id
assert mock_config_entry.data[CONF_PLANT_ID] == "RESOLVED_PLANT_789"
# Now setup the integration (should reuse cached API from migration)
await setup_integration(hass, mock_config_entry)
# Verify integration loaded successfully
assert mock_config_entry.state is ConfigEntryState.LOADED
# Verify log message confirms API reuse (rate limit optimization)
assert "Reusing logged-in session from migration" in caplog.text
# Verify login was called with correct credentials
# Note: Coordinators also call login() during refresh, so we verify
# the call was made but don't assert it was called exactly once
mock_growatt_classic_api.login.assert_called_with("test_user", "test_password")
# Verify plant_list was called only once (during migration, not during setup)
# This confirms setup did NOT resolve plant_id again (optimization working)
mock_growatt_classic_api.plant_list.assert_called_once_with(123456)
# Verify the cached API was removed after use (should not be in hass.data anymore)
assert f"{CACHED_API_KEY}{mock_config_entry.entry_id}" not in hass.data.get(
DOMAIN, {}
)
async def test_migrate_failure_returns_false(
hass: HomeAssistant,
mock_growatt_classic_api,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test migration returns False on API failure to allow retry.
When migration fails due to API errors (network issues, etc.),
it should return False and NOT bump the version. This allows Home Assistant
to retry the migration on the next restart.
"""
# Create a version 1.0 config entry with DEFAULT_PLANT_ID
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_AUTH_TYPE: AUTH_PASSWORD,
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_password",
CONF_URL: "https://server.growatt.com/",
CONF_PLANT_ID: DEFAULT_PLANT_ID,
CONF_NAME: "Test Plant",
},
unique_id="plant_default",
version=1,
minor_version=0,
)
# Mock API failure (e.g., network error during login)
mock_growatt_classic_api.login.side_effect = requests.exceptions.RequestException(
"Network error"
)
mock_config_entry.add_to_hass(hass)
# Execute migration (should fail gracefully)
migration_result = await async_migrate_entry(hass, mock_config_entry)
# Verify migration returned False (will retry on next restart)
assert migration_result is False
# Verify version was NOT bumped (remains 1.0)
assert mock_config_entry.version == 1
assert mock_config_entry.minor_version == 0
# Verify plant_id was NOT changed (remains DEFAULT_PLANT_ID)
assert mock_config_entry.data[CONF_PLANT_ID] == DEFAULT_PLANT_ID
# Verify error was logged
assert "Failed to resolve plant_id during migration" in caplog.text
assert "Migration will retry on next restart" in caplog.text
async def test_migrate_already_migrated(
hass: HomeAssistant,
) -> None:
"""Test migration is skipped for already migrated entries."""
# Create a config entry already at version 1.1
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_AUTH_TYPE: AUTH_PASSWORD,
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_password",
CONF_URL: "https://server.growatt.com/",
CONF_PLANT_ID: "specific_plant_123",
},
unique_id="plant_specific",
version=1,
minor_version=1,
)
mock_config_entry.add_to_hass(hass)
# Call migration function
migration_result = await async_migrate_entry(hass, mock_config_entry)
assert migration_result is True
# Verify version remains 1.1 (no change)
assert mock_config_entry.version == 1
assert mock_config_entry.minor_version == 1
# Plant ID should remain unchanged
assert mock_config_entry.data[CONF_PLANT_ID] == "specific_plant_123"