Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update powerwall for tesla_powerwall 0.5.0 which is async #107164

Merged
merged 17 commits into from
Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 99 additions & 69 deletions homeassistant/components/powerwall/__init__.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
"""The Tesla Powerwall integration."""
from __future__ import annotations

import contextlib
import asyncio
from contextlib import AsyncExitStack
from datetime import timedelta
import logging
from typing import Optional

import requests
from aiohttp import CookieJar
from tesla_powerwall import (
AccessDeniedError,
APIError,
ApiError,
MissingAttributeError,
Powerwall,
PowerwallError,
PowerwallUnreachableError,
)

Expand All @@ -20,17 +21,12 @@
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.aiohttp_client import async_create_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.network import is_ip_address

from .const import (
DOMAIN,
POWERWALL_API_CHANGED,
POWERWALL_COORDINATOR,
POWERWALL_HTTP_SESSION,
UPDATE_INTERVAL,
)
from .const import DOMAIN, POWERWALL_API_CHANGED, POWERWALL_COORDINATOR, UPDATE_INTERVAL
from .models import PowerwallBaseInfo, PowerwallData, PowerwallRuntimeData

CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
Expand Down Expand Up @@ -70,29 +66,29 @@ def api_changed(self) -> int:
"""Return true if the api has changed out from under us."""
return self.runtime_data[POWERWALL_API_CHANGED]

def _recreate_powerwall_login(self) -> None:
async def _recreate_powerwall_login(self) -> None:
"""Recreate the login on auth failure."""
if self.power_wall.is_authenticated():
self.power_wall.logout()
self.power_wall.login(self.password or "")
await self.power_wall.logout()
await self.power_wall.login(self.password or "")

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.hass.async_add_executor_job(self._update_data)
return await self._update_data()

def _update_data(self) -> PowerwallData:
async def _update_data(self) -> PowerwallData:
"""Fetch data from API endpoint."""
_LOGGER.debug("Updating data")
for attempt in range(2):
try:
if attempt == 1:
self._recreate_powerwall_login()
data = _fetch_powerwall_data(self.power_wall)
except PowerwallUnreachableError as err:
await self._recreate_powerwall_login()
data = await _fetch_powerwall_data(self.power_wall)
except (asyncio.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))
Expand All @@ -112,7 +108,7 @@ def _update_data(self) -> PowerwallData:
_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:
except ApiError as err:
raise UpdateFailed(f"Updated failed due to {err}, will retry") from err
else:
return data
Expand All @@ -121,33 +117,38 @@ def _update_data(self) -> PowerwallData:

async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Tesla Powerwall from a config entry."""
http_session = requests.Session()
ip_address: str = entry.data[CONF_IP_ADDRESS]

password: str | None = entry.data.get(CONF_PASSWORD)
power_wall = Powerwall(ip_address, http_session=http_session)
try:
base_info = await hass.async_add_executor_job(
_login_and_fetch_base_info, power_wall, ip_address, password
)
except PowerwallUnreachableError as err:
http_session.close()
raise ConfigEntryNotReady from err
except MissingAttributeError as err:
http_session.close()
# 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:
_LOGGER.debug("Authentication failed", exc_info=err)
http_session.close()
raise ConfigEntryAuthFailed from err
except APIError as err:
http_session.close()
raise ConfigEntryNotReady from err
http_session = async_create_clientsession(
hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True)
)

async with AsyncExitStack() as stack:
bdraco marked this conversation as resolved.
Show resolved Hide resolved
power_wall = Powerwall(ip_address, http_session=http_session, verify_ssl=False)
stack.push_async_callback(power_wall.close)

try:
base_info = await _login_and_fetch_base_info(
power_wall, ip_address, password
)

# Cancel closing power_wall on success
stack.pop_all()
except (asyncio.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:
_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 gateway_din and entry.unique_id is not None and is_ip_address(entry.unique_id):
Expand All @@ -156,7 +157,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
runtime_data = PowerwallRuntimeData(
api_changed=False,
base_info=base_info,
http_session=http_session,
coordinator=None,
api_instance=power_wall,
)
Expand All @@ -183,44 +183,76 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True


def _login_and_fetch_base_info(
async def _login_and_fetch_base_info(
power_wall: Powerwall, host: str, password: str | None
) -> PowerwallBaseInfo:
"""Login to the powerwall and fetch the base info."""
if password is not None:
power_wall.login(password)
return call_base_info(power_wall, host)
await power_wall.login(password)
return await _call_base_info(power_wall, host)


def call_base_info(power_wall: Powerwall, host: str) -> PowerwallBaseInfo:
async def _call_base_info(power_wall: Powerwall, host: str) -> PowerwallBaseInfo:
"""Return PowerwallBaseInfo for the device."""
# Make sure the serial numbers always have the same order
gateway_din = None
with contextlib.suppress(AssertionError, PowerwallError):
gateway_din = power_wall.get_gateway_din().upper()

(
gateway_din,
site_info,
status,
device_type,
serial_numbers,
) = await asyncio.gather(
bdraco marked this conversation as resolved.
Show resolved Hide resolved
power_wall.get_gateway_din(),
power_wall.get_site_info(),
power_wall.get_status(),
power_wall.get_device_type(),
power_wall.get_serial_numbers(),
)

# Serial numbers MUST be sorted to ensure the unique_id is always the same
# for backwards compatibility.
return PowerwallBaseInfo(
gateway_din=gateway_din,
site_info=power_wall.get_site_info(),
status=power_wall.get_status(),
device_type=power_wall.get_device_type(),
serial_numbers=sorted(power_wall.get_serial_numbers()),
gateway_din=gateway_din.upper(),
site_info=site_info,
status=status,
device_type=device_type,
serial_numbers=sorted(serial_numbers),
url=f"https://{host}",
)


def _fetch_powerwall_data(power_wall: Powerwall) -> PowerwallData:
"""Process and update powerwall data."""
async def get_backup_reserve_percentage(power_wall: Powerwall) -> Optional[float]:
"""Return the backup reserve percentage."""
try:
backup_reserve = power_wall.get_backup_reserve_percentage()
return await power_wall.get_backup_reserve_percentage()
except MissingAttributeError:
backup_reserve = None
return None


async def _fetch_powerwall_data(power_wall: Powerwall) -> PowerwallData:
"""Process and update powerwall data."""
(
backup_reserve,
charge,
site_master,
meters,
grid_services_active,
grid_status,
) = await asyncio.gather(
get_backup_reserve_percentage(power_wall),
power_wall.get_charge(),
power_wall.get_sitemaster(),
power_wall.get_meters(),
power_wall.is_grid_services_active(),
power_wall.get_grid_status(),
)

return PowerwallData(
charge=power_wall.get_charge(),
site_master=power_wall.get_sitemaster(),
meters=power_wall.get_meters(),
grid_services_active=power_wall.is_grid_services_active(),
grid_status=power_wall.get_grid_status(),
charge=charge,
site_master=site_master,
meters=meters,
grid_services_active=grid_services_active,
grid_status=grid_status,
backup_reserve=backup_reserve,
)

Expand All @@ -240,8 +272,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

hass.data[DOMAIN][entry.entry_id][POWERWALL_HTTP_SESSION].close()

if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)

Expand Down
8 changes: 7 additions & 1 deletion homeassistant/components/powerwall/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Support for powerwall binary sensors."""

from typing import TYPE_CHECKING

from tesla_powerwall import GridStatus, MeterType

from homeassistant.components.binary_sensor import (
Expand Down Expand Up @@ -131,5 +133,9 @@ def unique_id(self) -> str:
@property
def is_on(self) -> bool:
"""Powerwall is charging."""
meter = self.data.meters.get_meter(MeterType.BATTERY)
# Meter cannot be None because of the available property
if TYPE_CHECKING:
assert meter is not None
# is_sending_to returns true for values greater than 100 watts
return self.data.meters.get_meter(MeterType.BATTERY).is_sending_to()
return meter.is_sending_to()
Loading