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:
@@ -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
|
||||
|
||||
@@ -40,6 +40,7 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow class."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
api: growattServer.GrowattApi
|
||||
|
||||
|
||||
@@ -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_"
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user