Enable strict typing for powerwall (#65577)

This commit is contained in:
J. Nick Koston 2022-02-23 01:15:31 -10:00 committed by GitHub
parent 34bae4dcd4
commit e1989e2858
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 326 additions and 394 deletions

View file

@ -143,6 +143,7 @@ homeassistant.components.openuv.*
homeassistant.components.overkiz.* homeassistant.components.overkiz.*
homeassistant.components.persistent_notification.* homeassistant.components.persistent_notification.*
homeassistant.components.pi_hole.* homeassistant.components.pi_hole.*
homeassistant.components.powerwall.*
homeassistant.components.proximity.* homeassistant.components.proximity.*
homeassistant.components.pvoutput.* homeassistant.components.pvoutput.*
homeassistant.components.pure_energie.* homeassistant.components.pure_energie.*

View file

@ -1,4 +1,6 @@
"""The Tesla Powerwall integration.""" """The Tesla Powerwall integration."""
from __future__ import annotations
import contextlib import contextlib
from datetime import timedelta from datetime import timedelta
import logging import logging
@ -16,9 +18,8 @@ from tesla_powerwall import (
from homeassistant.components import persistent_notification from homeassistant.components import persistent_notification
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, Platform from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import entity_registry
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.network import is_ip_address from homeassistant.util.network import is_ip_address
@ -26,21 +27,12 @@ from homeassistant.util.network import is_ip_address
from .const import ( from .const import (
DOMAIN, DOMAIN,
POWERWALL_API_CHANGED, POWERWALL_API_CHANGED,
POWERWALL_API_CHARGE,
POWERWALL_API_DEVICE_TYPE,
POWERWALL_API_GATEWAY_DIN,
POWERWALL_API_GRID_SERVICES_ACTIVE,
POWERWALL_API_GRID_STATUS,
POWERWALL_API_METERS,
POWERWALL_API_SERIAL_NUMBERS,
POWERWALL_API_SITE_INFO,
POWERWALL_API_SITEMASTER,
POWERWALL_API_STATUS,
POWERWALL_COORDINATOR, POWERWALL_COORDINATOR,
POWERWALL_HTTP_SESSION, POWERWALL_HTTP_SESSION,
POWERWALL_OBJECT, POWERWALL_LOGIN_FAILED_COUNT,
UPDATE_INTERVAL, UPDATE_INTERVAL,
) )
from .models import PowerwallBaseInfo, PowerwallData, PowerwallRuntimeData
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
@ -50,211 +42,194 @@ _LOGGER = logging.getLogger(__name__)
MAX_LOGIN_FAILURES = 5 MAX_LOGIN_FAILURES = 5
API_CHANGED_ERROR_BODY = (
async def _migrate_old_unique_ids(hass, entry_id, powerwall_data): "It seems like your powerwall uses an unsupported version. "
serial_numbers = powerwall_data[POWERWALL_API_SERIAL_NUMBERS] "Please update the software of your powerwall or if it is "
site_info = powerwall_data[POWERWALL_API_SITE_INFO] "already the newest consider reporting this issue.\nSee logs for more information"
)
@callback API_CHANGED_TITLE = "Unknown powerwall software version"
def _async_migrator(entity_entry: entity_registry.RegistryEntry):
parts = entity_entry.unique_id.split("_")
# Check if the unique_id starts with the serial_numbers of the powerwalls
if parts[0 : len(serial_numbers)] != serial_numbers:
# The old unique_id ended with the nomianal_system_engery_kWh so we can use that
# to find the old base unique_id and extract the device_suffix.
normalized_energy_index = (
len(parts) - 1 - parts[::-1].index(str(site_info.nominal_system_energy))
)
device_suffix = parts[normalized_energy_index + 1 :]
new_unique_id = "_".join([*serial_numbers, *device_suffix])
_LOGGER.info(
"Migrating unique_id from [%s] to [%s]",
entity_entry.unique_id,
new_unique_id,
)
return {"new_unique_id": new_unique_id}
return None
await entity_registry.async_migrate_entries(hass, entry_id, _async_migrator)
async def _async_handle_api_changed_error( class PowerwallDataManager:
hass: HomeAssistant, error: MissingAttributeError """Class to manager powerwall data and relogin on failure."""
):
# The error might include some important information about what exactly changed. def __init__(
_LOGGER.error(str(error)) self,
persistent_notification.async_create( hass: HomeAssistant,
hass, power_wall: Powerwall,
"It seems like your powerwall uses an unsupported version. " ip_address: str,
"Please update the software of your powerwall or if it is " password: str | None,
"already the newest consider reporting this issue.\nSee logs for more information", runtime_data: PowerwallRuntimeData,
title="Unknown powerwall software version", ) -> 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
@property
def login_failed_count(self) -> int:
"""Return the current number of failed logins."""
return self.runtime_data[POWERWALL_LOGIN_FAILED_COUNT]
@property
def api_changed(self) -> int:
"""Return true if the api has changed out from under us."""
return self.runtime_data[POWERWALL_API_CHANGED]
def _increment_failed_logins(self) -> None:
self.runtime_data[POWERWALL_LOGIN_FAILED_COUNT] += 1
def _clear_failed_logins(self) -> None:
self.runtime_data[POWERWALL_LOGIN_FAILED_COUNT] = 0
def _recreate_powerwall_login(self) -> None:
"""Recreate the login on auth failure."""
http_session = self.runtime_data[POWERWALL_HTTP_SESSION]
http_session.close()
http_session = requests.Session()
self.runtime_data[POWERWALL_HTTP_SESSION] = http_session
self.power_wall = Powerwall(self.ip_address, http_session=http_session)
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)
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:
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:
self._increment_failed_logins()
raise ConfigEntryAuthFailed from err
if self.password is None:
raise ConfigEntryAuthFailed from err
raise UpdateFailed(
f"Login attempt {self.login_failed_count}/{MAX_LOGIN_FAILURES} failed, will retry: {err}"
) from err
except APIError as err:
raise UpdateFailed(f"Updated failed due to {err}, will retry") from err
else:
self._clear_failed_logins()
return data
raise RuntimeError("unreachable")
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Tesla Powerwall from a config entry.""" """Set up Tesla Powerwall from a config entry."""
entry_id = entry.entry_id
hass.data.setdefault(DOMAIN, {})
http_session = requests.Session() http_session = requests.Session()
ip_address = entry.data[CONF_IP_ADDRESS] ip_address = entry.data[CONF_IP_ADDRESS]
password = entry.data.get(CONF_PASSWORD) password = entry.data.get(CONF_PASSWORD)
power_wall = Powerwall(ip_address, http_session=http_session) power_wall = Powerwall(ip_address, http_session=http_session)
try: try:
powerwall_data = await hass.async_add_executor_job( base_info = await hass.async_add_executor_job(
_login_and_fetch_base_info, power_wall, password _login_and_fetch_base_info, power_wall, ip_address, password
) )
except PowerwallUnreachableError as err: except PowerwallUnreachableError as err:
http_session.close() http_session.close()
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
except MissingAttributeError as err: except MissingAttributeError as err:
http_session.close() http_session.close()
await _async_handle_api_changed_error(hass, 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 return False
except AccessDeniedError as err: except AccessDeniedError as err:
_LOGGER.debug("Authentication failed", exc_info=err) _LOGGER.debug("Authentication failed", exc_info=err)
http_session.close() http_session.close()
raise ConfigEntryAuthFailed from err raise ConfigEntryAuthFailed from err
await _migrate_old_unique_ids(hass, entry_id, powerwall_data) gateway_din = base_info.gateway_din
gateway_din = powerwall_data[POWERWALL_API_GATEWAY_DIN]
if gateway_din and entry.unique_id is not None and is_ip_address(entry.unique_id): if gateway_din and entry.unique_id is not None and is_ip_address(entry.unique_id):
hass.config_entries.async_update_entry(entry, unique_id=gateway_din) hass.config_entries.async_update_entry(entry, unique_id=gateway_din)
login_failed_count = 0 runtime_data = PowerwallRuntimeData(
api_changed=False,
base_info=base_info,
http_session=http_session,
login_failed_count=0,
coordinator=None,
)
runtime_data = hass.data[DOMAIN][entry.entry_id] = { manager = PowerwallDataManager(hass, power_wall, ip_address, password, runtime_data)
POWERWALL_API_CHANGED: False,
POWERWALL_HTTP_SESSION: http_session,
}
def _recreate_powerwall_login():
nonlocal http_session
nonlocal power_wall
http_session.close()
http_session = requests.Session()
power_wall = Powerwall(ip_address, http_session=http_session)
runtime_data[POWERWALL_OBJECT] = power_wall
runtime_data[POWERWALL_HTTP_SESSION] = http_session
power_wall.login(password)
async def _async_login_and_retry_update_data():
"""Retry the update after a failed login."""
nonlocal login_failed_count
# If the session expired, recreate, relogin, and try again
_LOGGER.debug("Retrying login and updating data")
try:
await hass.async_add_executor_job(_recreate_powerwall_login)
data = await _async_update_powerwall_data(hass, entry, power_wall)
except AccessDeniedError as err:
login_failed_count += 1
if login_failed_count == MAX_LOGIN_FAILURES:
raise ConfigEntryAuthFailed from err
raise UpdateFailed(
f"Login attempt {login_failed_count}/{MAX_LOGIN_FAILURES} failed, will retry: {err}"
) from err
except APIError as err:
raise UpdateFailed(f"Updated failed due to {err}, will retry") from err
else:
login_failed_count = 0
return data
async def async_update_data():
"""Fetch data from API endpoint."""
# Check if we had an error before
nonlocal login_failed_count
_LOGGER.debug("Checking if update failed")
if runtime_data[POWERWALL_API_CHANGED]:
return runtime_data[POWERWALL_COORDINATOR].data
_LOGGER.debug("Updating data")
try:
data = await _async_update_powerwall_data(hass, entry, power_wall)
except AccessDeniedError as err:
if password is None:
raise ConfigEntryAuthFailed from err
return await _async_login_and_retry_update_data()
except APIError as err:
raise UpdateFailed(f"Updated failed due to {err}, will retry") from err
else:
login_failed_count = 0
return data
coordinator = DataUpdateCoordinator( coordinator = DataUpdateCoordinator(
hass, hass,
_LOGGER, _LOGGER,
name="Powerwall site", name="Powerwall site",
update_method=async_update_data, update_method=manager.async_update_data,
update_interval=timedelta(seconds=UPDATE_INTERVAL), update_interval=timedelta(seconds=UPDATE_INTERVAL),
) )
runtime_data.update(
{
**powerwall_data,
POWERWALL_OBJECT: power_wall,
POWERWALL_COORDINATOR: coordinator,
}
)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
runtime_data[POWERWALL_COORDINATOR] = coordinator
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data
hass.config_entries.async_setup_platforms(entry, PLATFORMS) hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True return True
async def _async_update_powerwall_data( def _login_and_fetch_base_info(
hass: HomeAssistant, entry: ConfigEntry, power_wall: Powerwall power_wall: Powerwall, host: str, password: str
): ) -> PowerwallBaseInfo:
"""Fetch updated powerwall data."""
try:
return await hass.async_add_executor_job(_fetch_powerwall_data, power_wall)
except PowerwallUnreachableError as err:
raise UpdateFailed("Unable to fetch data from powerwall") from err
except MissingAttributeError as err:
await _async_handle_api_changed_error(hass, err)
hass.data[DOMAIN][entry.entry_id][POWERWALL_API_CHANGED] = True
# Returns the cached data. This data can also be None
return hass.data[DOMAIN][entry.entry_id][POWERWALL_COORDINATOR].data
def _login_and_fetch_base_info(power_wall: Powerwall, password: str):
"""Login to the powerwall and fetch the base info.""" """Login to the powerwall and fetch the base info."""
if password is not None: if password is not None:
power_wall.login(password) power_wall.login(password)
power_wall.detect_and_pin_version() return call_base_info(power_wall, host)
return call_base_info(power_wall)
def call_base_info(power_wall): def call_base_info(power_wall: Powerwall, host: str) -> PowerwallBaseInfo:
"""Wrap powerwall properties to be a callable.""" """Return PowerwallBaseInfo for the device."""
# Make sure the serial numbers always have the same order # Make sure the serial numbers always have the same order
gateway_din = None gateway_din = None
with contextlib.suppress((AssertionError, PowerwallError)): with contextlib.suppress(AssertionError, PowerwallError):
gateway_din = power_wall.get_gateway_din().upper() gateway_din = power_wall.get_gateway_din().upper()
return { return PowerwallBaseInfo(
POWERWALL_API_SITE_INFO: power_wall.get_site_info(), gateway_din=gateway_din,
POWERWALL_API_STATUS: power_wall.get_status(), site_info=power_wall.get_site_info(),
POWERWALL_API_DEVICE_TYPE: power_wall.get_device_type(), status=power_wall.get_status(),
POWERWALL_API_SERIAL_NUMBERS: sorted(power_wall.get_serial_numbers()), device_type=power_wall.get_device_type(),
POWERWALL_API_GATEWAY_DIN: gateway_din, serial_numbers=sorted(power_wall.get_serial_numbers()),
} url=f"https://{host}",
)
def _fetch_powerwall_data(power_wall): def _fetch_powerwall_data(power_wall: Powerwall) -> PowerwallData:
"""Process and update powerwall data.""" """Process and update powerwall data."""
return { return PowerwallData(
POWERWALL_API_CHARGE: power_wall.get_charge(), charge=power_wall.get_charge(),
POWERWALL_API_SITEMASTER: power_wall.get_sitemaster(), site_master=power_wall.get_sitemaster(),
POWERWALL_API_METERS: power_wall.get_meters(), meters=power_wall.get_meters(),
POWERWALL_API_GRID_SERVICES_ACTIVE: power_wall.is_grid_services_active(), grid_services_active=power_wall.is_grid_services_active(),
POWERWALL_API_GRID_STATUS: power_wall.get_grid_status(), grid_status=power_wall.get_grid_status(),
} )
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

View file

@ -1,4 +1,5 @@
"""Support for powerwall binary sensors.""" """Support for powerwall binary sensors."""
from tesla_powerwall import GridStatus, MeterType from tesla_powerwall import GridStatus, MeterType
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
@ -9,19 +10,9 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ( from .const import DOMAIN
DOMAIN,
POWERWALL_API_DEVICE_TYPE,
POWERWALL_API_GRID_SERVICES_ACTIVE,
POWERWALL_API_GRID_STATUS,
POWERWALL_API_METERS,
POWERWALL_API_SERIAL_NUMBERS,
POWERWALL_API_SITE_INFO,
POWERWALL_API_SITEMASTER,
POWERWALL_API_STATUS,
POWERWALL_COORDINATOR,
)
from .entity import PowerWallEntity from .entity import PowerWallEntity
from .models import PowerwallRuntimeData
async def async_setup_entry( async def async_setup_entry(
@ -29,152 +20,103 @@ async def async_setup_entry(
config_entry: ConfigEntry, config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the August sensors.""" """Set up the powerwall sensors."""
powerwall_data = hass.data[DOMAIN][config_entry.entry_id] powerwall_data: PowerwallRuntimeData = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
coordinator = powerwall_data[POWERWALL_COORDINATOR] [
site_info = powerwall_data[POWERWALL_API_SITE_INFO] sensor_class(powerwall_data)
device_type = powerwall_data[POWERWALL_API_DEVICE_TYPE] for sensor_class in (
status = powerwall_data[POWERWALL_API_STATUS] PowerWallRunningSensor,
powerwalls_serial_numbers = powerwall_data[POWERWALL_API_SERIAL_NUMBERS] PowerWallGridServicesActiveSensor,
PowerWallGridStatusSensor,
entities = [] PowerWallConnectedSensor,
for sensor_class in ( PowerWallChargingStatusSensor,
PowerWallRunningSensor,
PowerWallGridServicesActiveSensor,
PowerWallGridStatusSensor,
PowerWallConnectedSensor,
PowerWallChargingStatusSensor,
):
entities.append(
sensor_class(
coordinator, site_info, status, device_type, powerwalls_serial_numbers
) )
) ]
)
async_add_entities(entities, True)
class PowerWallRunningSensor(PowerWallEntity, BinarySensorEntity): class PowerWallRunningSensor(PowerWallEntity, BinarySensorEntity):
"""Representation of an Powerwall running sensor.""" """Representation of an Powerwall running sensor."""
@property _attr_name = "Powerwall Status"
def name(self): _attr_device_class = BinarySensorDeviceClass.POWER
"""Device Name."""
return "Powerwall Status"
@property @property
def device_class(self): def unique_id(self) -> str:
"""Device Class."""
return BinarySensorDeviceClass.POWER
@property
def unique_id(self):
"""Device Uniqueid.""" """Device Uniqueid."""
return f"{self.base_unique_id}_running" return f"{self.base_unique_id}_running"
@property @property
def is_on(self): def is_on(self) -> bool:
"""Get the powerwall running state.""" """Get the powerwall running state."""
return self.coordinator.data[POWERWALL_API_SITEMASTER].is_running return self.data.site_master.is_running
class PowerWallConnectedSensor(PowerWallEntity, BinarySensorEntity): class PowerWallConnectedSensor(PowerWallEntity, BinarySensorEntity):
"""Representation of an Powerwall connected sensor.""" """Representation of an Powerwall connected sensor."""
@property _attr_name = "Powerwall Connected to Tesla"
def name(self): _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
"""Device Name."""
return "Powerwall Connected to Tesla"
@property @property
def device_class(self): def unique_id(self) -> str:
"""Device Class."""
return BinarySensorDeviceClass.CONNECTIVITY
@property
def unique_id(self):
"""Device Uniqueid.""" """Device Uniqueid."""
return f"{self.base_unique_id}_connected_to_tesla" return f"{self.base_unique_id}_connected_to_tesla"
@property @property
def is_on(self): def is_on(self) -> bool:
"""Get the powerwall connected to tesla state.""" """Get the powerwall connected to tesla state."""
return self.coordinator.data[POWERWALL_API_SITEMASTER].is_connected_to_tesla return self.data.site_master.is_connected_to_tesla
class PowerWallGridServicesActiveSensor(PowerWallEntity, BinarySensorEntity): class PowerWallGridServicesActiveSensor(PowerWallEntity, BinarySensorEntity):
"""Representation of a Powerwall grid services active sensor.""" """Representation of a Powerwall grid services active sensor."""
@property _attr_name = "Grid Services Active"
def name(self): _attr_device_class = BinarySensorDeviceClass.POWER
"""Device Name."""
return "Grid Services Active"
@property @property
def device_class(self): def unique_id(self) -> str:
"""Device Class."""
return BinarySensorDeviceClass.POWER
@property
def unique_id(self):
"""Device Uniqueid.""" """Device Uniqueid."""
return f"{self.base_unique_id}_grid_services_active" return f"{self.base_unique_id}_grid_services_active"
@property @property
def is_on(self): def is_on(self) -> bool:
"""Grid services is active.""" """Grid services is active."""
return self.coordinator.data[POWERWALL_API_GRID_SERVICES_ACTIVE] return self.data.grid_services_active
class PowerWallGridStatusSensor(PowerWallEntity, BinarySensorEntity): class PowerWallGridStatusSensor(PowerWallEntity, BinarySensorEntity):
"""Representation of an Powerwall grid status sensor.""" """Representation of an Powerwall grid status sensor."""
@property _attr_name = "Grid Status"
def name(self): _attr_device_class = BinarySensorDeviceClass.POWER
"""Device Name."""
return "Grid Status"
@property @property
def device_class(self): def unique_id(self) -> str:
"""Device Class."""
return BinarySensorDeviceClass.POWER
@property
def unique_id(self):
"""Device Uniqueid.""" """Device Uniqueid."""
return f"{self.base_unique_id}_grid_status" return f"{self.base_unique_id}_grid_status"
@property @property
def is_on(self): def is_on(self) -> bool:
"""Grid is online.""" """Grid is online."""
return self.coordinator.data[POWERWALL_API_GRID_STATUS] == GridStatus.CONNECTED return self.data.grid_status == GridStatus.CONNECTED
class PowerWallChargingStatusSensor(PowerWallEntity, BinarySensorEntity): class PowerWallChargingStatusSensor(PowerWallEntity, BinarySensorEntity):
"""Representation of an Powerwall charging status sensor.""" """Representation of an Powerwall charging status sensor."""
@property _attr_name = "Powerwall Charging"
def name(self): _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
"""Device Name."""
return "Powerwall Charging"
@property @property
def device_class(self): def unique_id(self) -> str:
"""Device Class."""
return BinarySensorDeviceClass.BATTERY_CHARGING
@property
def unique_id(self):
"""Device Uniqueid.""" """Device Uniqueid."""
return f"{self.base_unique_id}_powerwall_charging" return f"{self.base_unique_id}_powerwall_charging"
@property @property
def is_on(self): def is_on(self) -> bool:
"""Powerwall is charging.""" """Powerwall is charging."""
# is_sending_to returns true for values greater than 100 watts # is_sending_to returns true for values greater than 100 watts
return ( return self.data.meters.get_meter(MeterType.BATTERY).is_sending_to()
self.coordinator.data[POWERWALL_API_METERS]
.get_meter(MeterType.BATTERY)
.is_sending_to()
)

View file

@ -9,6 +9,7 @@ from tesla_powerwall import (
MissingAttributeError, MissingAttributeError,
Powerwall, Powerwall,
PowerwallUnreachableError, PowerwallUnreachableError,
SiteInfo,
) )
import voluptuous as vol import voluptuous as vol
@ -23,11 +24,12 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def _login_and_fetch_site_info(power_wall: Powerwall, password: str): def _login_and_fetch_site_info(
power_wall: Powerwall, password: str
) -> tuple[SiteInfo, str]:
"""Login to the powerwall and fetch the base info.""" """Login to the powerwall and fetch the base info."""
if password is not None: if password is not None:
power_wall.login(password) power_wall.login(password)
power_wall.detect_and_pin_version()
return power_wall.get_site_info(), power_wall.get_gateway_din() return power_wall.get_site_info(), power_wall.get_gateway_din()
@ -60,7 +62,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
def __init__(self): def __init__(self) -> None:
"""Initialize the powerwall flow.""" """Initialize the powerwall flow."""
self.ip_address: str | None = None self.ip_address: str | None = None
self.title: str | None = None self.title: str | None = None
@ -101,7 +103,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_confirm_discovery() return await self.async_step_confirm_discovery()
async def _async_try_connect( async def _async_try_connect(
self, user_input self, user_input: dict[str, Any]
) -> tuple[dict[str, Any] | None, dict[str, str] | None]: ) -> tuple[dict[str, Any] | None, dict[str, str] | None]:
"""Try to connect to the powerwall.""" """Try to connect to the powerwall."""
info = None info = None
@ -120,7 +122,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return errors, info return errors, info
async def async_step_confirm_discovery(self, user_input=None) -> FlowResult: async def async_step_confirm_discovery(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm a discovered powerwall.""" """Confirm a discovered powerwall."""
assert self.ip_address is not None assert self.ip_address is not None
assert self.unique_id is not None assert self.unique_id is not None
@ -148,9 +152,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
}, },
) )
async def async_step_user(self, user_input=None): async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step.""" """Handle the initial step."""
errors = {} errors: dict[str, str] | None = {}
if user_input is not None: if user_input is not None:
errors, info = await self._async_try_connect(user_input) errors, info = await self._async_try_connect(user_input)
if not errors: if not errors:
@ -176,9 +182,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors=errors, errors=errors,
) )
async def async_step_reauth_confirm(self, user_input=None): async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle reauth confirmation.""" """Handle reauth confirmation."""
errors = {} assert self.reauth_entry is not None
errors: dict[str, str] | None = {}
if user_input is not None: if user_input is not None:
entry_data = self.reauth_entry.data entry_data = self.reauth_entry.data
errors, _ = await self._async_try_connect( errors, _ = await self._async_try_connect(
@ -197,7 +206,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors=errors, errors=errors,
) )
async def async_step_reauth(self, data): async def async_step_reauth(self, data: dict[str, str]) -> FlowResult:
"""Handle configuration by re-auth.""" """Handle configuration by re-auth."""
self.reauth_entry = self.hass.config_entries.async_get_entry( self.reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"] self.context["entry_id"]

View file

@ -1,34 +1,20 @@
"""Constants for the Tesla Powerwall integration.""" """Constants for the Tesla Powerwall integration."""
from typing import Final
DOMAIN = "powerwall" DOMAIN = "powerwall"
POWERWALL_OBJECT = "powerwall" POWERWALL_BASE_INFO: Final = "base_info"
POWERWALL_COORDINATOR = "coordinator" POWERWALL_COORDINATOR: Final = "coordinator"
POWERWALL_API_CHANGED = "api_changed" POWERWALL_API_CHANGED: Final = "api_changed"
POWERWALL_HTTP_SESSION: Final = "http_session"
POWERWALL_LOGIN_FAILED_COUNT: Final = "login_failed_count"
UPDATE_INTERVAL = 30 UPDATE_INTERVAL = 5
ATTR_FREQUENCY = "frequency" ATTR_FREQUENCY = "frequency"
ATTR_INSTANT_AVERAGE_VOLTAGE = "instant_average_voltage" ATTR_INSTANT_AVERAGE_VOLTAGE = "instant_average_voltage"
ATTR_INSTANT_TOTAL_CURRENT = "instant_total_current" ATTR_INSTANT_TOTAL_CURRENT = "instant_total_current"
ATTR_IS_ACTIVE = "is_active" ATTR_IS_ACTIVE = "is_active"
STATUS_VERSION = "version"
POWERWALL_SITE_NAME = "site_name"
POWERWALL_API_METERS = "meters"
POWERWALL_API_CHARGE = "charge"
POWERWALL_API_GRID_SERVICES_ACTIVE = "grid_services_active"
POWERWALL_API_GRID_STATUS = "grid_status"
POWERWALL_API_SITEMASTER = "sitemaster"
POWERWALL_API_STATUS = "status"
POWERWALL_API_DEVICE_TYPE = "device_type"
POWERWALL_API_SITE_INFO = "site_info"
POWERWALL_API_SERIAL_NUMBERS = "serial_numbers"
POWERWALL_API_GATEWAY_DIN = "gateway_din"
POWERWALL_HTTP_SESSION = "http_session"
MODEL = "PowerWall 2" MODEL = "PowerWall 2"
MANUFACTURER = "Tesla" MANUFACTURER = "Tesla"

View file

@ -3,30 +3,37 @@
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER, MODEL from .const import (
DOMAIN,
MANUFACTURER,
MODEL,
POWERWALL_BASE_INFO,
POWERWALL_COORDINATOR,
)
from .models import PowerwallData, PowerwallRuntimeData
class PowerWallEntity(CoordinatorEntity): class PowerWallEntity(CoordinatorEntity[PowerwallData]):
"""Base class for powerwall entities.""" """Base class for powerwall entities."""
def __init__( def __init__(self, powerwall_data: PowerwallRuntimeData) -> None:
self, coordinator, site_info, status, device_type, powerwalls_serial_numbers """Initialize the entity."""
): base_info = powerwall_data[POWERWALL_BASE_INFO]
"""Initialize the sensor.""" coordinator = powerwall_data[POWERWALL_COORDINATOR]
assert coordinator is not None
super().__init__(coordinator) super().__init__(coordinator)
self._site_info = site_info
self._device_type = device_type
self._version = status.version
# The serial numbers of the powerwalls are unique to every site # The serial numbers of the powerwalls are unique to every site
self.base_unique_id = "_".join(powerwalls_serial_numbers) self.base_unique_id = "_".join(base_info.serial_numbers)
self._attr_device_info = DeviceInfo(
@property
def device_info(self) -> DeviceInfo:
"""Powerwall device info."""
return DeviceInfo(
identifiers={(DOMAIN, self.base_unique_id)}, identifiers={(DOMAIN, self.base_unique_id)},
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
model=f"{MODEL} ({self._device_type.name})", model=f"{MODEL} ({base_info.device_type.name})",
name=self._site_info.site_name, name=base_info.site_info.site_name,
sw_version=self._version, sw_version=base_info.status.version,
configuration_url=base_info.url,
) )
@property
def data(self) -> PowerwallData:
"""Return the coordinator data."""
return self.coordinator.data

View file

@ -3,7 +3,7 @@
"name": "Tesla Powerwall", "name": "Tesla Powerwall",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/powerwall", "documentation": "https://www.home-assistant.io/integrations/powerwall",
"requirements": ["tesla-powerwall==0.3.15"], "requirements": ["tesla-powerwall==0.3.17"],
"codeowners": ["@bdraco", "@jrester"], "codeowners": ["@bdraco", "@jrester"],
"dhcp": [ "dhcp": [
{ {

View file

@ -0,0 +1,50 @@
"""The powerwall integration models."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TypedDict
from requests import Session
from tesla_powerwall import (
DeviceType,
GridStatus,
MetersAggregates,
PowerwallStatus,
SiteInfo,
SiteMaster,
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@dataclass
class PowerwallBaseInfo:
"""Base information for the powerwall integration."""
gateway_din: None | str
site_info: SiteInfo
status: PowerwallStatus
device_type: DeviceType
serial_numbers: list[str]
url: str
@dataclass
class PowerwallData:
"""Point in time data for the powerwall integration."""
charge: float
site_master: SiteMaster
meters: MetersAggregates
grid_services_active: bool
grid_status: GridStatus
class PowerwallRuntimeData(TypedDict):
"""Run time data for the powerwall."""
coordinator: DataUpdateCoordinator | None
login_failed_count: int
base_info: PowerwallBaseInfo
api_changed: bool
http_session: Session

View file

@ -1,7 +1,7 @@
"""Support for August sensors.""" """Support for powerwall sensors."""
from __future__ import annotations from __future__ import annotations
import logging from typing import Any
from tesla_powerwall import MeterType from tesla_powerwall import MeterType
@ -21,72 +21,43 @@ from .const import (
ATTR_INSTANT_TOTAL_CURRENT, ATTR_INSTANT_TOTAL_CURRENT,
ATTR_IS_ACTIVE, ATTR_IS_ACTIVE,
DOMAIN, DOMAIN,
POWERWALL_API_CHARGE,
POWERWALL_API_DEVICE_TYPE,
POWERWALL_API_METERS,
POWERWALL_API_SERIAL_NUMBERS,
POWERWALL_API_SITE_INFO,
POWERWALL_API_STATUS,
POWERWALL_COORDINATOR, POWERWALL_COORDINATOR,
) )
from .entity import PowerWallEntity from .entity import PowerWallEntity
from .models import PowerwallData, PowerwallRuntimeData
_METER_DIRECTION_EXPORT = "export" _METER_DIRECTION_EXPORT = "export"
_METER_DIRECTION_IMPORT = "import" _METER_DIRECTION_IMPORT = "import"
_METER_DIRECTIONS = [_METER_DIRECTION_EXPORT, _METER_DIRECTION_IMPORT] _METER_DIRECTIONS = [_METER_DIRECTION_EXPORT, _METER_DIRECTION_IMPORT]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the August sensors.""" """Set up the powerwall sensors."""
powerwall_data = hass.data[DOMAIN][config_entry.entry_id] powerwall_data: PowerwallRuntimeData = hass.data[DOMAIN][config_entry.entry_id]
_LOGGER.debug("Powerwall_data: %s", powerwall_data)
coordinator = powerwall_data[POWERWALL_COORDINATOR] coordinator = powerwall_data[POWERWALL_COORDINATOR]
site_info = powerwall_data[POWERWALL_API_SITE_INFO] assert coordinator is not None
device_type = powerwall_data[POWERWALL_API_DEVICE_TYPE] data: PowerwallData = coordinator.data
status = powerwall_data[POWERWALL_API_STATUS] entities: list[
powerwalls_serial_numbers = powerwall_data[POWERWALL_API_SERIAL_NUMBERS] PowerWallEnergySensor | PowerWallEnergyDirectionSensor | PowerWallChargeSensor
] = []
entities: list[SensorEntity] = [] for meter in data.meters.meters:
# coordinator.data[POWERWALL_API_METERS].meters holds all meters that are available entities.append(PowerWallEnergySensor(powerwall_data, meter))
for meter in coordinator.data[POWERWALL_API_METERS].meters:
entities.append(
PowerWallEnergySensor(
meter,
coordinator,
site_info,
status,
device_type,
powerwalls_serial_numbers,
)
)
for meter_direction in _METER_DIRECTIONS: for meter_direction in _METER_DIRECTIONS:
entities.append( entities.append(
PowerWallEnergyDirectionSensor( PowerWallEnergyDirectionSensor(
powerwall_data,
meter, meter,
coordinator,
site_info,
status,
device_type,
powerwalls_serial_numbers,
meter_direction, meter_direction,
) )
) )
entities.append( entities.append(PowerWallChargeSensor(powerwall_data))
PowerWallChargeSensor(
coordinator, site_info, status, device_type, powerwalls_serial_numbers
)
)
async_add_entities(entities, True) async_add_entities(entities)
class PowerWallChargeSensor(PowerWallEntity, SensorEntity): class PowerWallChargeSensor(PowerWallEntity, SensorEntity):
@ -98,14 +69,14 @@ class PowerWallChargeSensor(PowerWallEntity, SensorEntity):
_attr_device_class = SensorDeviceClass.BATTERY _attr_device_class = SensorDeviceClass.BATTERY
@property @property
def unique_id(self): def unique_id(self) -> str:
"""Device Uniqueid.""" """Device Uniqueid."""
return f"{self.base_unique_id}_charge" return f"{self.base_unique_id}_charge"
@property @property
def native_value(self): def native_value(self) -> int:
"""Get the current value in percentage.""" """Get the current value in percentage."""
return round(self.coordinator.data[POWERWALL_API_CHARGE]) return round(self.data.charge)
class PowerWallEnergySensor(PowerWallEntity, SensorEntity): class PowerWallEnergySensor(PowerWallEntity, SensorEntity):
@ -115,19 +86,9 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity):
_attr_native_unit_of_measurement = POWER_KILO_WATT _attr_native_unit_of_measurement = POWER_KILO_WATT
_attr_device_class = SensorDeviceClass.POWER _attr_device_class = SensorDeviceClass.POWER
def __init__( def __init__(self, powerwall_data: PowerwallRuntimeData, meter: MeterType) -> None:
self,
meter: MeterType,
coordinator,
site_info,
status,
device_type,
powerwalls_serial_numbers,
):
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__( super().__init__(powerwall_data)
coordinator, site_info, status, device_type, powerwalls_serial_numbers
)
self._meter = meter self._meter = meter
self._attr_name = f"Powerwall {self._meter.value.title()} Now" self._attr_name = f"Powerwall {self._meter.value.title()} Now"
self._attr_unique_id = ( self._attr_unique_id = (
@ -135,18 +96,14 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity):
) )
@property @property
def native_value(self): def native_value(self) -> float:
"""Get the current value in kW.""" """Get the current value in kW."""
return ( return self.data.meters.get_meter(self._meter).get_power(precision=3)
self.coordinator.data[POWERWALL_API_METERS]
.get_meter(self._meter)
.get_power(precision=3)
)
@property @property
def extra_state_attributes(self): def extra_state_attributes(self) -> dict[str, Any]:
"""Return the device specific state attributes.""" """Return the device specific state attributes."""
meter = self.coordinator.data[POWERWALL_API_METERS].get_meter(self._meter) meter = self.data.meters.get_meter(self._meter)
return { return {
ATTR_FREQUENCY: round(meter.frequency, 1), ATTR_FREQUENCY: round(meter.frequency, 1),
ATTR_INSTANT_AVERAGE_VOLTAGE: round(meter.average_voltage, 1), ATTR_INSTANT_AVERAGE_VOLTAGE: round(meter.average_voltage, 1),
@ -164,18 +121,12 @@ class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity):
def __init__( def __init__(
self, self,
powerwall_data: PowerwallRuntimeData,
meter: MeterType, meter: MeterType,
coordinator, meter_direction: str,
site_info, ) -> None:
status,
device_type,
powerwalls_serial_numbers,
meter_direction,
):
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__( super().__init__(powerwall_data)
coordinator, site_info, status, device_type, powerwalls_serial_numbers
)
self._meter = meter self._meter = meter
self._meter_direction = meter_direction self._meter_direction = meter_direction
self._attr_name = ( self._attr_name = (
@ -186,9 +137,9 @@ class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity):
) )
@property @property
def native_value(self): def native_value(self) -> float:
"""Get the current value in kWh.""" """Get the current value in kWh."""
meter = self.coordinator.data[POWERWALL_API_METERS].get_meter(self._meter) meter = self.data.meters.get_meter(self._meter)
if self._meter_direction == _METER_DIRECTION_EXPORT: if self._meter_direction == _METER_DIRECTION_EXPORT:
return meter.get_energy_exported() return meter.get_energy_exported()
return meter.get_energy_imported() return meter.get_energy_imported()

View file

@ -1382,6 +1382,17 @@ no_implicit_optional = true
warn_return_any = true warn_return_any = true
warn_unreachable = true warn_unreachable = true
[mypy-homeassistant.components.powerwall.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.proximity.*] [mypy-homeassistant.components.proximity.*]
check_untyped_defs = true check_untyped_defs = true
disallow_incomplete_defs = true disallow_incomplete_defs = true

View file

@ -2348,7 +2348,7 @@ temperusb==1.5.3
# tensorflow==2.5.0 # tensorflow==2.5.0
# homeassistant.components.powerwall # homeassistant.components.powerwall
tesla-powerwall==0.3.15 tesla-powerwall==0.3.17
# homeassistant.components.tesla_wall_connector # homeassistant.components.tesla_wall_connector
tesla-wall-connector==1.0.1 tesla-wall-connector==1.0.1

View file

@ -1445,7 +1445,7 @@ tailscale==0.2.0
tellduslive==0.10.11 tellduslive==0.10.11
# homeassistant.components.powerwall # homeassistant.components.powerwall
tesla-powerwall==0.3.15 tesla-powerwall==0.3.17
# homeassistant.components.tesla_wall_connector # homeassistant.components.tesla_wall_connector
tesla-wall-connector==1.0.1 tesla-wall-connector==1.0.1