Update powerwall for tesla_powerwall 0.5.0 which is async (#107164)
Co-authored-by: J. Nick Koston <nick@koston.org>pull/107509/head
parent
bdba6f41c9
commit
c74bef265a
|
@ -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,
|
||||
)
|
||||
|
||||
|
@ -20,17 +21,12 @@ 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.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)
|
||||
|
@ -70,11 +66,11 @@ class PowerwallDataManager:
|
|||
"""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."""
|
||||
|
@ -82,17 +78,17 @@ class PowerwallDataManager:
|
|||
_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))
|
||||
|
@ -112,7 +108,7 @@ class PowerwallDataManager:
|
|||
_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
|
||||
|
@ -121,33 +117,38 @@ class PowerwallDataManager:
|
|||
|
||||
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:
|
||||
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):
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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(
|
||||
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,
|
||||
)
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
|
|
@ -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 (
|
||||
|
@ -131,5 +133,9 @@ class PowerWallChargingStatusSensor(PowerWallEntity, BinarySensorEntity):
|
|||
@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()
|
||||
|
|
|
@ -1,16 +1,18 @@
|
|||
"""Config flow for Tesla Powerwall integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import CookieJar
|
||||
from tesla_powerwall import (
|
||||
AccessDeniedError,
|
||||
MissingAttributeError,
|
||||
Powerwall,
|
||||
PowerwallUnreachableError,
|
||||
SiteInfo,
|
||||
SiteInfoResponse,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
|
@ -18,6 +20,7 @@ from homeassistant import config_entries, core, exceptions
|
|||
from homeassistant.components import dhcp
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.util.network import is_ip_address
|
||||
|
||||
from . import async_last_update_was_successful
|
||||
|
@ -32,19 +35,23 @@ ENTRY_FAILURE_STATES = {
|
|||
}
|
||||
|
||||
|
||||
def _login_and_fetch_site_info(
|
||||
async def _login_and_fetch_site_info(
|
||||
power_wall: Powerwall, password: str
|
||||
) -> tuple[SiteInfo, str]:
|
||||
) -> tuple[SiteInfoResponse, str]:
|
||||
"""Login to the powerwall and fetch the base info."""
|
||||
if password is not None:
|
||||
power_wall.login(password)
|
||||
return power_wall.get_site_info(), power_wall.get_gateway_din()
|
||||
await power_wall.login(password)
|
||||
|
||||
return await asyncio.gather(
|
||||
power_wall.get_site_info(), power_wall.get_gateway_din()
|
||||
)
|
||||
|
||||
|
||||
def _powerwall_is_reachable(ip_address: str, password: str) -> bool:
|
||||
async def _powerwall_is_reachable(ip_address: str, password: str) -> bool:
|
||||
"""Check if the powerwall is reachable."""
|
||||
try:
|
||||
Powerwall(ip_address).login(password)
|
||||
async with Powerwall(ip_address) as power_wall:
|
||||
await power_wall.login(password)
|
||||
except AccessDeniedError:
|
||||
return True
|
||||
except PowerwallUnreachableError:
|
||||
|
@ -59,21 +66,23 @@ async def validate_input(
|
|||
|
||||
Data has the keys from schema with values provided by the user.
|
||||
"""
|
||||
session = async_create_clientsession(
|
||||
hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True)
|
||||
)
|
||||
async with Powerwall(data[CONF_IP_ADDRESS], http_session=session) as power_wall:
|
||||
password = data[CONF_PASSWORD]
|
||||
|
||||
power_wall = Powerwall(data[CONF_IP_ADDRESS])
|
||||
password = data[CONF_PASSWORD]
|
||||
try:
|
||||
site_info, gateway_din = await _login_and_fetch_site_info(
|
||||
power_wall, password
|
||||
)
|
||||
except MissingAttributeError as err:
|
||||
# Only log the exception without the traceback
|
||||
_LOGGER.error(str(err))
|
||||
raise WrongVersion from err
|
||||
|
||||
try:
|
||||
site_info, gateway_din = await hass.async_add_executor_job(
|
||||
_login_and_fetch_site_info, power_wall, password
|
||||
)
|
||||
except MissingAttributeError as err:
|
||||
# Only log the exception without the traceback
|
||||
_LOGGER.error(str(err))
|
||||
raise WrongVersion from err
|
||||
|
||||
# Return info that you want to store in the config entry.
|
||||
return {"title": site_info.site_name, "unique_id": gateway_din.upper()}
|
||||
# Return info that you want to store in the config entry.
|
||||
return {"title": site_info.site_name, "unique_id": gateway_din.upper()}
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
@ -102,9 +111,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
return bool(
|
||||
entry.state in ENTRY_FAILURE_STATES
|
||||
or not async_last_update_was_successful(self.hass, entry)
|
||||
) and not await self.hass.async_add_executor_job(
|
||||
_powerwall_is_reachable, ip_address, password
|
||||
)
|
||||
) and not await _powerwall_is_reachable(ip_address, password)
|
||||
|
||||
async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult:
|
||||
"""Handle dhcp discovery."""
|
||||
|
@ -137,7 +144,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
"name": gateway_din,
|
||||
"ip_address": self.ip_address,
|
||||
}
|
||||
errors, info = await self._async_try_connect(
|
||||
errors, info, _ = await self._async_try_connect(
|
||||
{CONF_IP_ADDRESS: self.ip_address, CONF_PASSWORD: gateway_din[-5:]}
|
||||
)
|
||||
if errors:
|
||||
|
@ -152,23 +159,28 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
|
||||
async def _async_try_connect(
|
||||
self, user_input: dict[str, Any]
|
||||
) -> tuple[dict[str, Any] | None, dict[str, str] | None]:
|
||||
) -> tuple[dict[str, Any] | None, dict[str, str] | None, dict[str, str]]:
|
||||
"""Try to connect to the powerwall."""
|
||||
info = None
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
try:
|
||||
info = await validate_input(self.hass, user_input)
|
||||
except PowerwallUnreachableError:
|
||||
except PowerwallUnreachableError as ex:
|
||||
errors[CONF_IP_ADDRESS] = "cannot_connect"
|
||||
except WrongVersion:
|
||||
description_placeholders = {"error": str(ex)}
|
||||
except WrongVersion as ex:
|
||||
errors["base"] = "wrong_version"
|
||||
except AccessDeniedError:
|
||||
description_placeholders = {"error": str(ex)}
|
||||
except AccessDeniedError as ex:
|
||||
errors[CONF_PASSWORD] = "invalid_auth"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
description_placeholders = {"error": str(ex)}
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
description_placeholders = {"error": str(ex)}
|
||||
|
||||
return errors, info
|
||||
return errors, info, description_placeholders
|
||||
|
||||
async def async_step_confirm_discovery(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
|
@ -204,8 +216,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] | None = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
errors, info = await self._async_try_connect(user_input)
|
||||
errors, info, description_placeholders = await self._async_try_connect(
|
||||
user_input
|
||||
)
|
||||
if not errors:
|
||||
assert info is not None
|
||||
if info["unique_id"]:
|
||||
|
@ -227,6 +242,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
|
@ -235,9 +251,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
"""Handle reauth confirmation."""
|
||||
assert self.reauth_entry is not None
|
||||
errors: dict[str, str] | None = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
entry_data = self.reauth_entry.data
|
||||
errors, _ = await self._async_try_connect(
|
||||
errors, _, description_placeholders = await self._async_try_connect(
|
||||
{CONF_IP_ADDRESS: entry_data[CONF_IP_ADDRESS], **user_input}
|
||||
)
|
||||
if not errors:
|
||||
|
@ -251,6 +268,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({vol.Optional(CONF_PASSWORD): str}),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
|
||||
|
|
|
@ -7,7 +7,6 @@ POWERWALL_BASE_INFO: Final = "base_info"
|
|||
POWERWALL_COORDINATOR: Final = "coordinator"
|
||||
POWERWALL_API: Final = "api_instance"
|
||||
POWERWALL_API_CHANGED: Final = "api_changed"
|
||||
POWERWALL_HTTP_SESSION: Final = "http_session"
|
||||
|
||||
UPDATE_INTERVAL = 30
|
||||
|
||||
|
|
|
@ -14,5 +14,5 @@
|
|||
"documentation": "https://www.home-assistant.io/integrations/powerwall",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["tesla_powerwall"],
|
||||
"requirements": ["tesla-powerwall==0.3.19"]
|
||||
"requirements": ["tesla-powerwall==0.5.0"]
|
||||
}
|
||||
|
|
|
@ -4,15 +4,14 @@ from __future__ import annotations
|
|||
from dataclasses import dataclass
|
||||
from typing import TypedDict
|
||||
|
||||
from requests import Session
|
||||
from tesla_powerwall import (
|
||||
DeviceType,
|
||||
GridStatus,
|
||||
MetersAggregates,
|
||||
MetersAggregatesResponse,
|
||||
Powerwall,
|
||||
PowerwallStatus,
|
||||
SiteInfo,
|
||||
SiteMaster,
|
||||
PowerwallStatusResponse,
|
||||
SiteInfoResponse,
|
||||
SiteMasterResponse,
|
||||
)
|
||||
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
@ -23,8 +22,8 @@ class PowerwallBaseInfo:
|
|||
"""Base information for the powerwall integration."""
|
||||
|
||||
gateway_din: None | str
|
||||
site_info: SiteInfo
|
||||
status: PowerwallStatus
|
||||
site_info: SiteInfoResponse
|
||||
status: PowerwallStatusResponse
|
||||
device_type: DeviceType
|
||||
serial_numbers: list[str]
|
||||
url: str
|
||||
|
@ -35,8 +34,8 @@ class PowerwallData:
|
|||
"""Point in time data for the powerwall integration."""
|
||||
|
||||
charge: float
|
||||
site_master: SiteMaster
|
||||
meters: MetersAggregates
|
||||
site_master: SiteMasterResponse
|
||||
meters: MetersAggregatesResponse
|
||||
grid_services_active: bool
|
||||
grid_status: GridStatus
|
||||
backup_reserve: float | None
|
||||
|
@ -49,4 +48,3 @@ class PowerwallRuntimeData(TypedDict):
|
|||
api_instance: Powerwall
|
||||
base_info: PowerwallBaseInfo
|
||||
api_changed: bool
|
||||
http_session: Session
|
||||
|
|
|
@ -3,8 +3,9 @@ from __future__ import annotations
|
|||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from tesla_powerwall import Meter, MeterType
|
||||
from tesla_powerwall import MeterResponse, MeterType
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
|
@ -36,7 +37,7 @@ _METER_DIRECTION_IMPORT = "import"
|
|||
class PowerwallRequiredKeysMixin:
|
||||
"""Mixin for required keys."""
|
||||
|
||||
value_fn: Callable[[Meter], float]
|
||||
value_fn: Callable[[MeterResponse], float]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
@ -46,24 +47,24 @@ class PowerwallSensorEntityDescription(
|
|||
"""Describes Powerwall entity."""
|
||||
|
||||
|
||||
def _get_meter_power(meter: Meter) -> float:
|
||||
def _get_meter_power(meter: MeterResponse) -> float:
|
||||
"""Get the current value in kW."""
|
||||
return meter.get_power(precision=3)
|
||||
|
||||
|
||||
def _get_meter_frequency(meter: Meter) -> float:
|
||||
def _get_meter_frequency(meter: MeterResponse) -> float:
|
||||
"""Get the current value in Hz."""
|
||||
return round(meter.frequency, 1)
|
||||
|
||||
|
||||
def _get_meter_total_current(meter: Meter) -> float:
|
||||
def _get_meter_total_current(meter: MeterResponse) -> float:
|
||||
"""Get the current value in A."""
|
||||
return meter.get_instant_total_current()
|
||||
|
||||
|
||||
def _get_meter_average_voltage(meter: Meter) -> float:
|
||||
def _get_meter_average_voltage(meter: MeterResponse) -> float:
|
||||
"""Get the current value in V."""
|
||||
return round(meter.average_voltage, 1)
|
||||
return round(meter.instant_average_voltage, 1)
|
||||
|
||||
|
||||
POWERWALL_INSTANT_SENSORS = (
|
||||
|
@ -171,9 +172,13 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity):
|
|||
self._attr_unique_id = f"{self.base_unique_id}_{meter.value}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
def native_value(self) -> float | None:
|
||||
"""Get the current value."""
|
||||
return self.entity_description.value_fn(self.data.meters.get_meter(self._meter))
|
||||
meter = self.data.meters.get_meter(self._meter)
|
||||
if meter is not None:
|
||||
return self.entity_description.value_fn(meter)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class PowerWallBackupReserveSensor(PowerWallEntity, SensorEntity):
|
||||
|
@ -224,10 +229,10 @@ class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity):
|
|||
we do not want to include in statistics and its a
|
||||
transient data error.
|
||||
"""
|
||||
return super().available and self.native_value != 0
|
||||
return super().available and self.meter is not None
|
||||
|
||||
@property
|
||||
def meter(self) -> Meter:
|
||||
def meter(self) -> MeterResponse | None:
|
||||
"""Get the meter for the sensor."""
|
||||
return self.data.meters.get_meter(self._meter)
|
||||
|
||||
|
@ -244,9 +249,12 @@ class PowerWallExportSensor(PowerWallEnergyDirectionSensor):
|
|||
super().__init__(powerwall_data, meter, _METER_DIRECTION_EXPORT)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
def native_value(self) -> float | None:
|
||||
"""Get the current value in kWh."""
|
||||
return self.meter.get_energy_exported()
|
||||
meter = self.meter
|
||||
if TYPE_CHECKING:
|
||||
assert meter is not None
|
||||
return meter.get_energy_exported()
|
||||
|
||||
|
||||
class PowerWallImportSensor(PowerWallEnergyDirectionSensor):
|
||||
|
@ -261,6 +269,9 @@ class PowerWallImportSensor(PowerWallEnergyDirectionSensor):
|
|||
super().__init__(powerwall_data, meter, _METER_DIRECTION_IMPORT)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
def native_value(self) -> float | None:
|
||||
"""Get the current value in kWh."""
|
||||
return self.meter.get_energy_imported()
|
||||
meter = self.meter
|
||||
if TYPE_CHECKING:
|
||||
assert meter is not None
|
||||
return meter.get_energy_imported()
|
||||
|
|
|
@ -23,10 +23,10 @@
|
|||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"wrong_version": "Your Powerwall uses a software version that is not supported. Please consider upgrading or reporting this issue so it can be resolved.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
"cannot_connect": "A connection error occurred while connecting to the Powerwall: {error}",
|
||||
"wrong_version": "Your Powerwall uses a software version that is not supported. Please consider upgrading or reporting this issue so it can be resolved: {error}",
|
||||
"unknown": "An unknown error occurred: {error}",
|
||||
"invalid_auth": "Authentication failed with error: {error}"
|
||||
},
|
||||
"abort": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
|
|
|
@ -59,9 +59,7 @@ class PowerwallOffGridEnabledEntity(PowerWallEntity, SwitchEntity):
|
|||
async def _async_set_island_mode(self, island_mode: IslandMode) -> None:
|
||||
"""Toggles off-grid mode using the island_mode argument."""
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.power_wall.set_island_mode, island_mode
|
||||
)
|
||||
await self.power_wall.set_island_mode(island_mode)
|
||||
except PowerwallError as ex:
|
||||
raise HomeAssistantError(
|
||||
f"Setting off-grid operation to {island_mode} failed: {ex}"
|
||||
|
|
|
@ -2635,7 +2635,7 @@ temperusb==1.6.1
|
|||
# tensorflow==2.5.0
|
||||
|
||||
# homeassistant.components.powerwall
|
||||
tesla-powerwall==0.3.19
|
||||
tesla-powerwall==0.5.0
|
||||
|
||||
# homeassistant.components.tesla_wall_connector
|
||||
tesla-wall-connector==1.0.2
|
||||
|
|
|
@ -1991,7 +1991,7 @@ temescal==0.5
|
|||
temperusb==1.6.1
|
||||
|
||||
# homeassistant.components.powerwall
|
||||
tesla-powerwall==0.3.19
|
||||
tesla-powerwall==0.5.0
|
||||
|
||||
# homeassistant.components.tesla_wall_connector
|
||||
tesla-wall-connector==1.0.2
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
{}
|
|
@ -1 +1,6 @@
|
|||
{ "connected_to_tesla": true, "running": true, "status": "StatusUp" }
|
||||
{
|
||||
"connected_to_tesla": true,
|
||||
"power_supply_mode": false,
|
||||
"running": true,
|
||||
"status": "StatusUp"
|
||||
}
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
{
|
||||
"start_time": "2020-03-10 11:57:25 +0800",
|
||||
"up_time_seconds": "217h40m57.470801079s",
|
||||
"commission_count": 0,
|
||||
"device_type": "hec",
|
||||
"git_hash": "d0e69bde519634961cca04a616d2d4dae80b9f61",
|
||||
"is_new": false,
|
||||
"version": "1.45.1",
|
||||
"git_hash": "13bf684a633175f884079ec79f42997080d90310"
|
||||
"start_time": "2020-10-28 20:14:11 +0800",
|
||||
"sync_type": "v1",
|
||||
"up_time_seconds": "17h11m31.214751424s",
|
||||
"version": "1.50.1 c58c2df3"
|
||||
}
|
||||
|
|
|
@ -1,17 +1,18 @@
|
|||
"""Mocks for powerwall."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from tesla_powerwall import (
|
||||
DeviceType,
|
||||
GridStatus,
|
||||
MetersAggregates,
|
||||
MetersAggregatesResponse,
|
||||
Powerwall,
|
||||
PowerwallStatus,
|
||||
SiteInfo,
|
||||
SiteMaster,
|
||||
PowerwallStatusResponse,
|
||||
SiteInfoResponse,
|
||||
SiteMasterResponse,
|
||||
)
|
||||
|
||||
from tests.common import load_fixture
|
||||
|
@ -19,29 +20,31 @@ from tests.common import load_fixture
|
|||
MOCK_GATEWAY_DIN = "111-0----2-000000000FFA"
|
||||
|
||||
|
||||
async def _mock_powerwall_with_fixtures(hass):
|
||||
async def _mock_powerwall_with_fixtures(hass, empty_meters: bool = False) -> MagicMock:
|
||||
"""Mock data used to build powerwall state."""
|
||||
meters = await _async_load_json_fixture(hass, "meters.json")
|
||||
sitemaster = await _async_load_json_fixture(hass, "sitemaster.json")
|
||||
site_info = await _async_load_json_fixture(hass, "site_info.json")
|
||||
status = await _async_load_json_fixture(hass, "status.json")
|
||||
device_type = await _async_load_json_fixture(hass, "device_type.json")
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
meters_file = "meters_empty.json" if empty_meters else "meters.json"
|
||||
meters = tg.create_task(_async_load_json_fixture(hass, meters_file))
|
||||
sitemaster = tg.create_task(_async_load_json_fixture(hass, "sitemaster.json"))
|
||||
site_info = tg.create_task(_async_load_json_fixture(hass, "site_info.json"))
|
||||
status = tg.create_task(_async_load_json_fixture(hass, "status.json"))
|
||||
device_type = tg.create_task(_async_load_json_fixture(hass, "device_type.json"))
|
||||
|
||||
return _mock_powerwall_return_value(
|
||||
site_info=SiteInfo(site_info),
|
||||
return await _mock_powerwall_return_value(
|
||||
site_info=SiteInfoResponse.from_dict(site_info.result()),
|
||||
charge=47.34587394586,
|
||||
sitemaster=SiteMaster(sitemaster),
|
||||
meters=MetersAggregates(meters),
|
||||
sitemaster=SiteMasterResponse.from_dict(sitemaster.result()),
|
||||
meters=MetersAggregatesResponse.from_dict(meters.result()),
|
||||
grid_services_active=True,
|
||||
grid_status=GridStatus.CONNECTED,
|
||||
status=PowerwallStatus(status),
|
||||
device_type=DeviceType(device_type["device_type"]),
|
||||
status=PowerwallStatusResponse.from_dict(status.result()),
|
||||
device_type=DeviceType(device_type.result()["device_type"]),
|
||||
serial_numbers=["TG0123456789AB", "TG9876543210BA"],
|
||||
backup_reserve_percentage=15.0,
|
||||
)
|
||||
|
||||
|
||||
def _mock_powerwall_return_value(
|
||||
async def _mock_powerwall_return_value(
|
||||
site_info=None,
|
||||
charge=None,
|
||||
sitemaster=None,
|
||||
|
@ -53,38 +56,46 @@ def _mock_powerwall_return_value(
|
|||
serial_numbers=None,
|
||||
backup_reserve_percentage=None,
|
||||
):
|
||||
powerwall_mock = MagicMock(Powerwall("1.2.3.4"))
|
||||
powerwall_mock.get_site_info = Mock(return_value=site_info)
|
||||
powerwall_mock.get_charge = Mock(return_value=charge)
|
||||
powerwall_mock.get_sitemaster = Mock(return_value=sitemaster)
|
||||
powerwall_mock.get_meters = Mock(return_value=meters)
|
||||
powerwall_mock.get_grid_status = Mock(return_value=grid_status)
|
||||
powerwall_mock.get_status = Mock(return_value=status)
|
||||
powerwall_mock.get_device_type = Mock(return_value=device_type)
|
||||
powerwall_mock.get_serial_numbers = Mock(return_value=serial_numbers)
|
||||
powerwall_mock.get_backup_reserve_percentage = Mock(
|
||||
return_value=backup_reserve_percentage
|
||||
powerwall_mock = MagicMock(Powerwall)
|
||||
powerwall_mock.__aenter__.return_value = powerwall_mock
|
||||
|
||||
powerwall_mock.get_site_info.return_value = site_info
|
||||
powerwall_mock.get_charge.return_value = charge
|
||||
powerwall_mock.get_sitemaster.return_value = sitemaster
|
||||
powerwall_mock.get_meters.return_value = meters
|
||||
powerwall_mock.get_grid_status.return_value = grid_status
|
||||
powerwall_mock.get_status.return_value = status
|
||||
powerwall_mock.get_device_type.return_value = device_type
|
||||
powerwall_mock.get_serial_numbers.return_value = serial_numbers
|
||||
powerwall_mock.get_backup_reserve_percentage.return_value = (
|
||||
backup_reserve_percentage
|
||||
)
|
||||
powerwall_mock.is_grid_services_active = Mock(return_value=grid_services_active)
|
||||
powerwall_mock.is_grid_services_active.return_value = grid_services_active
|
||||
powerwall_mock.get_gateway_din.return_value = MOCK_GATEWAY_DIN
|
||||
|
||||
return powerwall_mock
|
||||
|
||||
|
||||
async def _mock_powerwall_site_name(hass, site_name):
|
||||
powerwall_mock = MagicMock(Powerwall("1.2.3.4"))
|
||||
powerwall_mock = MagicMock(Powerwall)
|
||||
powerwall_mock.__aenter__.return_value = powerwall_mock
|
||||
|
||||
site_info_resp = SiteInfo(await _async_load_json_fixture(hass, "site_info.json"))
|
||||
# Sets site_info_resp.site_name to return site_name
|
||||
site_info_resp.response["site_name"] = site_name
|
||||
powerwall_mock.get_site_info = Mock(return_value=site_info_resp)
|
||||
powerwall_mock.get_gateway_din = Mock(return_value=MOCK_GATEWAY_DIN)
|
||||
site_info_resp = SiteInfoResponse.from_dict(
|
||||
await _async_load_json_fixture(hass, "site_info.json")
|
||||
)
|
||||
site_info_resp._raw["site_name"] = site_name
|
||||
site_info_resp.site_name = site_name
|
||||
powerwall_mock.get_site_info.return_value = site_info_resp
|
||||
powerwall_mock.get_gateway_din.return_value = MOCK_GATEWAY_DIN
|
||||
|
||||
return powerwall_mock
|
||||
|
||||
|
||||
def _mock_powerwall_side_effect(site_info=None):
|
||||
powerwall_mock = MagicMock(Powerwall("1.2.3.4"))
|
||||
powerwall_mock.get_site_info = Mock(side_effect=site_info)
|
||||
async def _mock_powerwall_side_effect(site_info=None):
|
||||
powerwall_mock = MagicMock(Powerwall)
|
||||
powerwall_mock.__aenter__.return_value = powerwall_mock
|
||||
|
||||
powerwall_mock.get_site_info.side_effect = site_info
|
||||
return powerwall_mock
|
||||
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.components.powerwall.const import DOMAIN
|
||||
from homeassistant.const import CONF_IP_ADDRESS, STATE_ON
|
||||
from homeassistant.const import CONF_IP_ADDRESS, STATE_ON, STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .mocks import _mock_powerwall_with_fixtures
|
||||
|
@ -75,3 +75,23 @@ async def test_sensors(hass: HomeAssistant) -> None:
|
|||
# Only test for a subset of attributes in case
|
||||
# HA changes the implementation and a new one appears
|
||||
assert all(item in state.attributes.items() for item in expected_attributes.items())
|
||||
|
||||
|
||||
async def test_sensors_with_empty_meters(hass: HomeAssistant) -> None:
|
||||
"""Test creation of the binary sensors with empty meters."""
|
||||
|
||||
mock_powerwall = await _mock_powerwall_with_fixtures(hass, empty_meters=True)
|
||||
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4"})
|
||||
config_entry.add_to_hass(hass)
|
||||
with patch(
|
||||
"homeassistant.components.powerwall.config_flow.Powerwall",
|
||||
return_value=mock_powerwall,
|
||||
), patch(
|
||||
"homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall
|
||||
):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("binary_sensor.mysite_charging")
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
"""Test the Powerwall config flow."""
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tesla_powerwall import (
|
||||
|
@ -14,6 +16,7 @@ from homeassistant.components.powerwall.const import DOMAIN
|
|||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .mocks import (
|
||||
MOCK_GATEWAY_DIN,
|
||||
|
@ -22,7 +25,7 @@ from .mocks import (
|
|||
_mock_powerwall_with_fixtures,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
VALID_CONFIG = {CONF_IP_ADDRESS: "1.2.3.4", CONF_PASSWORD: "00GGX"}
|
||||
|
||||
|
@ -36,7 +39,7 @@ async def test_form_source_user(hass: HomeAssistant) -> None:
|
|||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
mock_powerwall = await _mock_powerwall_site_name(hass, "My site")
|
||||
mock_powerwall = await _mock_powerwall_site_name(hass, "MySite")
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.powerwall.config_flow.Powerwall",
|
||||
|
@ -52,7 +55,7 @@ async def test_form_source_user(hass: HomeAssistant) -> None:
|
|||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == "create_entry"
|
||||
assert result2["title"] == "My site"
|
||||
assert result2["title"] == "MySite"
|
||||
assert result2["data"] == VALID_CONFIG
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
@ -63,7 +66,9 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None:
|
|||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
mock_powerwall = _mock_powerwall_side_effect(site_info=PowerwallUnreachableError)
|
||||
mock_powerwall = await _mock_powerwall_side_effect(
|
||||
site_info=PowerwallUnreachableError
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.powerwall.config_flow.Powerwall",
|
||||
|
@ -84,7 +89,9 @@ async def test_invalid_auth(hass: HomeAssistant) -> None:
|
|||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
mock_powerwall = _mock_powerwall_side_effect(site_info=AccessDeniedError("any"))
|
||||
mock_powerwall = await _mock_powerwall_side_effect(
|
||||
site_info=AccessDeniedError("any")
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.powerwall.config_flow.Powerwall",
|
||||
|
@ -105,7 +112,7 @@ async def test_form_unknown_exeption(hass: HomeAssistant) -> None:
|
|||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
mock_powerwall = _mock_powerwall_side_effect(site_info=ValueError)
|
||||
mock_powerwall = await _mock_powerwall_side_effect(site_info=ValueError)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.powerwall.config_flow.Powerwall",
|
||||
|
@ -125,7 +132,7 @@ async def test_form_wrong_version(hass: HomeAssistant) -> None:
|
|||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
mock_powerwall = _mock_powerwall_side_effect(
|
||||
mock_powerwall = await _mock_powerwall_side_effect(
|
||||
site_info=MissingAttributeError({}, "")
|
||||
)
|
||||
|
||||
|
@ -286,7 +293,9 @@ async def test_dhcp_discovery_auto_configure(hass: HomeAssistant) -> None:
|
|||
|
||||
async def test_dhcp_discovery_cannot_connect(hass: HomeAssistant) -> None:
|
||||
"""Test we can process the discovery from dhcp and we cannot connect."""
|
||||
mock_powerwall = _mock_powerwall_side_effect(site_info=PowerwallUnreachableError)
|
||||
mock_powerwall = await _mock_powerwall_side_effect(
|
||||
site_info=PowerwallUnreachableError
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.powerwall.config_flow.Powerwall",
|
||||
|
@ -354,6 +363,7 @@ async def test_dhcp_discovery_update_ip_address(hass: HomeAssistant) -> None:
|
|||
)
|
||||
entry.add_to_hass(hass)
|
||||
mock_powerwall = MagicMock(login=MagicMock(side_effect=PowerwallUnreachableError))
|
||||
mock_powerwall.__aenter__.return_value = mock_powerwall
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.powerwall.config_flow.Powerwall",
|
||||
|
@ -547,3 +557,49 @@ async def test_discovered_wifi_does_not_update_ip_if_is_still_online(
|
|||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert entry.data[CONF_IP_ADDRESS] == "1.2.3.4"
|
||||
|
||||
|
||||
async def test_discovered_wifi_does_not_update_ip_online_but_access_denied(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test a discovery does not update the ip unless the powerwall at the old ip is offline."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=VALID_CONFIG,
|
||||
unique_id=MOCK_GATEWAY_DIN,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
mock_powerwall = await _mock_powerwall_with_fixtures(hass)
|
||||
mock_powerwall_no_access = await _mock_powerwall_with_fixtures(hass)
|
||||
mock_powerwall_no_access.login.side_effect = AccessDeniedError("any")
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.powerwall.config_flow.Powerwall",
|
||||
return_value=mock_powerwall_no_access,
|
||||
), patch(
|
||||
"homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall
|
||||
):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Now mock the powerwall to be offline to force
|
||||
# the discovery flow to probe to see if its online
|
||||
# which will result in an access denied error, which
|
||||
# means its still online and we should not update the ip
|
||||
mock_powerwall.get_meters.side_effect = asyncio.TimeoutError
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_DHCP},
|
||||
data=dhcp.DhcpServiceInfo(
|
||||
ip="1.2.3.5",
|
||||
macaddress="AA:BB:CC:DD:EE:FF",
|
||||
hostname=MOCK_GATEWAY_DIN.lower(),
|
||||
),
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert entry.data[CONF_IP_ADDRESS] == "1.2.3.4"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
"""Tests for the PowerwallDataManager."""
|
||||
|
||||
import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
from tesla_powerwall import AccessDeniedError, LoginResponse
|
||||
|
||||
|
@ -24,12 +24,17 @@ async def test_update_data_reauthenticate_on_access_denied(hass: HomeAssistant)
|
|||
# 1. login success on entry setup
|
||||
# 2. login success after reauthentication
|
||||
# 3. login failure after reauthentication
|
||||
mock_powerwall.login = MagicMock(name="login", return_value=LoginResponse({}))
|
||||
mock_powerwall.get_charge = MagicMock(name="get_charge", return_value=90.0)
|
||||
mock_powerwall.is_authenticated = MagicMock(
|
||||
name="is_authenticated", return_value=True
|
||||
mock_powerwall.login.return_value = LoginResponse.from_dict(
|
||||
{
|
||||
"firstname": "firstname",
|
||||
"lastname": "lastname",
|
||||
"token": "token",
|
||||
"roles": [],
|
||||
"loginTime": "loginTime",
|
||||
}
|
||||
)
|
||||
mock_powerwall.logout = MagicMock(name="logout")
|
||||
mock_powerwall.get_charge.return_value = 90.0
|
||||
mock_powerwall.is_authenticated.return_value = True
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4", CONF_PASSWORD: "password"}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
"""The sensor tests for the powerwall platform."""
|
||||
from datetime import timedelta
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from tesla_powerwall import MetersAggregatesResponse
|
||||
from tesla_powerwall.error import MissingAttributeError
|
||||
|
||||
from homeassistant.components.powerwall.const import DOMAIN
|
||||
|
@ -11,13 +13,15 @@ from homeassistant.const import (
|
|||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
CONF_IP_ADDRESS,
|
||||
PERCENTAGE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .mocks import _mock_powerwall_with_fixtures
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
|
||||
async def test_sensors(
|
||||
|
@ -43,7 +47,7 @@ async def test_sensors(
|
|||
identifiers={("powerwall", "TG0123456789AB_TG9876543210BA")},
|
||||
)
|
||||
assert reg_device.model == "PowerWall 2 (GW1)"
|
||||
assert reg_device.sw_version == "1.45.1"
|
||||
assert reg_device.sw_version == "1.50.1 c58c2df3"
|
||||
assert reg_device.manufacturer == "Tesla"
|
||||
assert reg_device.name == "MySite"
|
||||
|
||||
|
@ -118,13 +122,23 @@ async def test_sensors(
|
|||
for key, value in expected_attributes.items():
|
||||
assert state.attributes[key] == value
|
||||
|
||||
mock_powerwall.get_meters.return_value = MetersAggregatesResponse.from_dict({})
|
||||
mock_powerwall.get_backup_reserve_percentage.return_value = None
|
||||
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("sensor.mysite_load_power").state == STATE_UNKNOWN
|
||||
assert hass.states.get("sensor.mysite_load_frequency").state == STATE_UNKNOWN
|
||||
assert hass.states.get("sensor.mysite_backup_reserve").state == STATE_UNKNOWN
|
||||
|
||||
|
||||
async def test_sensor_backup_reserve_unavailable(hass: HomeAssistant) -> None:
|
||||
"""Confirm that backup reserve sensor is not added if data is unavailable from the device."""
|
||||
|
||||
mock_powerwall = await _mock_powerwall_with_fixtures(hass)
|
||||
mock_powerwall.get_backup_reserve_percentage = Mock(
|
||||
side_effect=MissingAttributeError(Mock(), "backup_reserve_percent", "operation")
|
||||
mock_powerwall.get_backup_reserve_percentage.side_effect = MissingAttributeError(
|
||||
Mock(), "backup_reserve_percent", "operation"
|
||||
)
|
||||
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4"})
|
||||
|
@ -140,3 +154,22 @@ async def test_sensor_backup_reserve_unavailable(hass: HomeAssistant) -> None:
|
|||
|
||||
state = hass.states.get("sensor.powerwall_backup_reserve")
|
||||
assert state is None
|
||||
|
||||
|
||||
async def test_sensors_with_empty_meters(hass: HomeAssistant) -> None:
|
||||
"""Test creation of the sensors with empty meters."""
|
||||
|
||||
mock_powerwall = await _mock_powerwall_with_fixtures(hass, empty_meters=True)
|
||||
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4"})
|
||||
config_entry.add_to_hass(hass)
|
||||
with patch(
|
||||
"homeassistant.components.powerwall.config_flow.Powerwall",
|
||||
return_value=mock_powerwall,
|
||||
), patch(
|
||||
"homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall
|
||||
):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("sensor.mysite_solar_power") is None
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
"""Test for Powerwall off-grid switch."""
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from tesla_powerwall import GridStatus, PowerwallError
|
||||
|
@ -43,7 +43,7 @@ async def test_entity_registry(
|
|||
) -> None:
|
||||
"""Test powerwall off-grid switch device."""
|
||||
|
||||
mock_powerwall.get_grid_status = Mock(return_value=GridStatus.CONNECTED)
|
||||
mock_powerwall.get_grid_status.return_value = GridStatus.CONNECTED
|
||||
|
||||
assert ENTITY_ID in entity_registry.entities
|
||||
|
||||
|
@ -51,7 +51,7 @@ async def test_entity_registry(
|
|||
async def test_initial(hass: HomeAssistant, mock_powerwall) -> None:
|
||||
"""Test initial grid status without off grid switch selected."""
|
||||
|
||||
mock_powerwall.get_grid_status = Mock(return_value=GridStatus.CONNECTED)
|
||||
mock_powerwall.get_grid_status.return_value = GridStatus.CONNECTED
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.state == STATE_OFF
|
||||
|
@ -60,7 +60,7 @@ async def test_initial(hass: HomeAssistant, mock_powerwall) -> None:
|
|||
async def test_on(hass: HomeAssistant, mock_powerwall) -> None:
|
||||
"""Test state once offgrid switch has been turned on."""
|
||||
|
||||
mock_powerwall.get_grid_status = Mock(return_value=GridStatus.ISLANDED)
|
||||
mock_powerwall.get_grid_status.return_value = GridStatus.ISLANDED
|
||||
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
|
@ -76,7 +76,7 @@ async def test_on(hass: HomeAssistant, mock_powerwall) -> None:
|
|||
async def test_off(hass: HomeAssistant, mock_powerwall) -> None:
|
||||
"""Test state once offgrid switch has been turned off."""
|
||||
|
||||
mock_powerwall.get_grid_status = Mock(return_value=GridStatus.CONNECTED)
|
||||
mock_powerwall.get_grid_status.return_value = GridStatus.CONNECTED
|
||||
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
|
@ -95,9 +95,7 @@ async def test_exception_on_powerwall_error(
|
|||
"""Ensure that an exception in the tesla_powerwall library causes a HomeAssistantError."""
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="Setting off-grid operation to"):
|
||||
mock_powerwall.set_island_mode = Mock(
|
||||
side_effect=PowerwallError("Mock exception")
|
||||
)
|
||||
mock_powerwall.set_island_mode.side_effect = PowerwallError("Mock exception")
|
||||
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
|
|
Loading…
Reference in New Issue