"""The Tesla Powerwall integration.""" from contextlib import AsyncExitStack import logging from aiohttp import CookieJar from tesla_powerwall import ( AccessDeniedError, ApiError, MissingAttributeError, OperationMode, Powerwall, PowerwallUnreachableError, ) from yarl import URL from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util.network import is_ip_address from .const import ( AUTH_COOKIE_KEY, CONFIG_ENTRY_COOKIE, DOMAIN, MANUFACTURER, MODEL, POWERWALL_API_CHANGED, POWERWALL_COORDINATOR, ) from .coordinator import ( PowerwallBaseInfo, PowerwallConfigEntry, PowerwallData, PowerwallRuntimeData, PowerwallUpdateCoordinator, ) from .helpers import is_api_404 PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) API_CHANGED_ERROR_BODY = ( "It seems like your powerwall uses an unsupported version. " "Please update the software of your powerwall or if it is " "already the newest consider reporting this issue.\nSee logs for more information" ) API_CHANGED_TITLE = "Unknown powerwall software version" class PowerwallDataManager: """Class to manager powerwall data and relogin on failure.""" def __init__( self, hass: HomeAssistant, power_wall: Powerwall, cookie_jar: CookieJar, entry: PowerwallConfigEntry, ip_address: str, password: str | None, runtime_data: PowerwallRuntimeData, ) -> None: """Init the data manager.""" self.hass = hass self.ip_address = ip_address self.password = password self.runtime_data = runtime_data self.power_wall = power_wall self.cookie_jar = cookie_jar self.entry = entry self.restricted = runtime_data["base_info"].restricted @property def api_changed(self) -> int: """Return true if the api has changed out from under us.""" return self.runtime_data[POWERWALL_API_CHANGED] async def _recreate_powerwall_login(self) -> None: """Recreate the login on auth failure.""" if self.power_wall.is_authenticated(): await self.power_wall.logout() # Always use the password when recreating the login await self.power_wall.login(self.password or "") self.save_auth_cookie() async def async_update_data(self) -> PowerwallData: """Fetch data from API endpoint.""" # Check if we had an error before _LOGGER.debug("Checking if update failed") if self.api_changed: raise UpdateFailed("The powerwall api has changed") return await self._update_data() async def _update_data(self) -> PowerwallData: """Fetch data from API endpoint.""" _LOGGER.debug("Updating data") for attempt in range(2): try: if attempt == 1: await self._recreate_powerwall_login() data = await _fetch_powerwall_data( self.power_wall, restricted=self.restricted ) except (TimeoutError, PowerwallUnreachableError) as err: raise UpdateFailed("Unable to fetch data from powerwall") from err except MissingAttributeError as err: _LOGGER.error("The powerwall api has changed: %s", str(err)) # The error might include some important information # about what exactly changed. persistent_notification.create( self.hass, API_CHANGED_ERROR_BODY, API_CHANGED_TITLE ) self.runtime_data[POWERWALL_API_CHANGED] = True raise UpdateFailed("The powerwall api has changed") from err except AccessDeniedError as err: if attempt == 1: # failed to authenticate => the credentials must be wrong raise ConfigEntryAuthFailed from err if self.password is None: raise ConfigEntryAuthFailed from err _LOGGER.debug("Access denied, trying to reauthenticate") # there is still an attempt left to authenticate, # so we continue in the loop except ApiError as err: raise UpdateFailed(f"Updated failed due to {err}, will retry") from err else: return data raise RuntimeError("unreachable") @callback def save_auth_cookie(self) -> None: """Save the auth cookie.""" for cookie in self.cookie_jar: if cookie.key == AUTH_COOKIE_KEY: self.hass.config_entries.async_update_entry( self.entry, data={**self.entry.data, CONFIG_ENTRY_COOKIE: cookie.value}, ) _LOGGER.debug("Saved auth cookie") break async def async_setup_entry(hass: HomeAssistant, entry: PowerwallConfigEntry) -> bool: """Set up Tesla Powerwall from a config entry.""" ip_address: str = entry.data[CONF_IP_ADDRESS] password: str | None = entry.data.get(CONF_PASSWORD) cookie_jar: CookieJar = CookieJar(unsafe=True) use_auth_cookie: bool = False # Try to reuse the auth cookie auth_cookie_value: str | None = entry.data.get(CONFIG_ENTRY_COOKIE) if auth_cookie_value: cookie_jar.update_cookies( {AUTH_COOKIE_KEY: auth_cookie_value}, URL(f"http://{ip_address}"), ) _LOGGER.debug("Using existing auth cookie") use_auth_cookie = True http_session = async_create_clientsession( hass, verify_ssl=False, cookie_jar=cookie_jar ) async with AsyncExitStack() as stack: power_wall = Powerwall(ip_address, http_session=http_session, verify_ssl=False) stack.push_async_callback(power_wall.close) for tries in range(2): try: base_info = await _login_and_fetch_base_info( power_wall, ip_address, password, use_auth_cookie, fallback_din=entry.unique_id, ) # Cancel closing power_wall on success stack.pop_all() break except (TimeoutError, PowerwallUnreachableError) as err: raise ConfigEntryNotReady from err except MissingAttributeError as err: # The error might include some important # information about what exactly changed. _LOGGER.error("The powerwall api has changed: %s", str(err)) persistent_notification.async_create( hass, API_CHANGED_ERROR_BODY, API_CHANGED_TITLE ) return False except AccessDeniedError as err: if use_auth_cookie and tries == 0: _LOGGER.debug( "Authentication failed with cookie, retrying with password" ) use_auth_cookie = False continue _LOGGER.debug("Authentication failed", exc_info=err) raise ConfigEntryAuthFailed from err except ApiError as err: raise ConfigEntryNotReady from err gateway_din = base_info.gateway_din if entry.unique_id is not None and is_ip_address(entry.unique_id): hass.config_entries.async_update_entry(entry, unique_id=gateway_din) runtime_data = PowerwallRuntimeData( api_changed=False, base_info=base_info, coordinator=None, api_instance=power_wall, ) manager = PowerwallDataManager( hass, power_wall, cookie_jar, entry, ip_address, password, runtime_data, ) manager.save_auth_cookie() coordinator = PowerwallUpdateCoordinator(hass, entry, manager) await coordinator.async_config_entry_first_refresh() runtime_data[POWERWALL_COORDINATOR] = coordinator entry.runtime_data = runtime_data await async_migrate_entity_unique_ids(hass, entry, base_info) # Register the gateway device so battery devices can link to it via # via_device_id, which must resolve to an already-registered device. model = ( f"{MODEL} ({base_info.device_type.name})" if base_info.device_type is not None else MODEL ) device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, gateway_din)}, manufacturer=MANUFACTURER, model=model, name=base_info.site_name, sw_version=base_info.status.version if base_info.status else None, configuration_url=base_info.url, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_migrate_entity_unique_ids( hass: HomeAssistant, entry: PowerwallConfigEntry, base_info: PowerwallBaseInfo ) -> None: """Migrate old entity unique ids to use gateway_din.""" if not base_info.serial_numbers: return old_base_unique_id = "_".join(base_info.serial_numbers) new_base_unique_id = base_info.gateway_din dev_reg = dr.async_get(hass) if device := dev_reg.async_get_device_by_identifier( (DOMAIN, old_base_unique_id), entry.entry_id ): dev_reg.async_update_device( device.id, new_identifiers={(DOMAIN, new_base_unique_id)} ) ent_reg = er.async_get(hass) for ent_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id): current_unique_id = ent_entry.unique_id if current_unique_id.startswith(old_base_unique_id): unique_id_postfix = current_unique_id.removeprefix(old_base_unique_id) new_unique_id = f"{new_base_unique_id}{unique_id_postfix}" ent_reg.async_update_entity( ent_entry.entity_id, new_unique_id=new_unique_id ) async def _login_and_fetch_base_info( power_wall: Powerwall, host: str, password: str | None, use_auth_cookie: bool, fallback_din: str | None = None, ) -> PowerwallBaseInfo: """Login to the powerwall and fetch the base info.""" # Login step is skipped if password is None or if we are using the auth cookie if not (password is None or use_auth_cookie): await power_wall.login(password) return await _call_base_info(power_wall, host, fallback_din) async def _call_base_info( power_wall: Powerwall, host: str, fallback_din: str | None = None ) -> PowerwallBaseInfo: """Return PowerwallBaseInfo for the device.""" # We await each call individually since the powerwall # supports http keep-alive and we want to reuse the connection # as its faster than establishing a new connection when # run concurrently. url = f"https://{host}" # Probe via get_gateway_din (hits /api/powerwalls) to detect the restricted # PW3-style surface. PW3 (and recent firmware revisions) return HTTP 404 on # /api/powerwalls and most other legacy endpoints; only meters/aggregates, # system_status/soe, and system_status/grid_status are guaranteed to work. try: gateway_din = await power_wall.get_gateway_din() except ApiError as err: if not is_api_404(err): raise # No DIN available — fall back to the caller-supplied identifier # (typically the MAC saved as the config-entry unique_id) so the device # identifier is stable across restarts. identifier = fallback_din or host return PowerwallBaseInfo( gateway_din=identifier, site_name=f"Powerwall {host}", site_info=None, status=None, device_type=None, serial_numbers=[], url=url, batteries={}, restricted=True, ) status = await power_wall.get_status() site_info = await power_wall.get_site_info() device_type = await power_wall.get_device_type() serial_numbers = await power_wall.get_serial_numbers() batteries = await power_wall.get_batteries() # Serial numbers MUST be sorted to ensure the unique_id is always the same # for backwards compatibility. return PowerwallBaseInfo( gateway_din=gateway_din, site_name=site_info.site_name, site_info=site_info, status=status, device_type=device_type, serial_numbers=sorted(serial_numbers), url=url, batteries={battery.serial_number: battery for battery in batteries}, ) async def get_backup_reserve_percentage(power_wall: Powerwall) -> float | None: """Return the backup reserve percentage.""" try: return await power_wall.get_backup_reserve_percentage() except MissingAttributeError: return None async def get_max_charge_power(power_wall: Powerwall) -> int | None: """Return the instantaneous max charge power.""" try: return await power_wall.get_instantaneous_max_charge_power() except MissingAttributeError: return None async def get_max_discharge_power(power_wall: Powerwall) -> int | None: """Return the instantaneous max discharge power.""" try: return await power_wall.get_instantaneous_max_discharge_power() except MissingAttributeError: return None async def get_operation_mode(power_wall: Powerwall) -> OperationMode | None: """Return the operation mode.""" try: return await power_wall.get_operation_mode() except MissingAttributeError: return None async def _fetch_powerwall_data( power_wall: Powerwall, restricted: bool = False ) -> PowerwallData: """Process and update powerwall data.""" # We await each call individually since the powerwall # supports http keep-alive and we want to reuse the connection # as its faster than establishing a new connection when # run concurrently. charge = await power_wall.get_charge() meters = await power_wall.get_meters() grid_services_active = await power_wall.is_grid_services_active() grid_status = await power_wall.get_grid_status() if restricted: return PowerwallData( charge=charge, site_master=None, meters=meters, grid_services_active=grid_services_active, grid_status=grid_status, backup_reserve=None, max_charge_power=None, max_discharge_power=None, operation_mode=None, batteries={}, ) backup_reserve = await get_backup_reserve_percentage(power_wall) max_charge_power = await get_max_charge_power(power_wall) max_discharge_power = await get_max_discharge_power(power_wall) operation_mode = await get_operation_mode(power_wall) site_master = await power_wall.get_sitemaster() batteries = await power_wall.get_batteries() return PowerwallData( charge=charge, site_master=site_master, meters=meters, grid_services_active=grid_services_active, grid_status=grid_status, backup_reserve=backup_reserve, max_charge_power=max_charge_power, max_discharge_power=max_discharge_power, operation_mode=operation_mode, batteries={battery.serial_number: battery for battery in batteries}, ) @callback def async_last_update_was_successful( hass: HomeAssistant, entry: PowerwallConfigEntry ) -> bool: """Return True if the last update was successful.""" return bool( hasattr(entry, "runtime_data") and (runtime_data := entry.runtime_data) and (coordinator := runtime_data.get(POWERWALL_COORDINATOR)) and coordinator.last_update_success ) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)