diff --git a/CODEOWNERS b/CODEOWNERS index 9730c96a573..31db27145e4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -277,6 +277,7 @@ homeassistant/components/plant/* @ChristianKuehnel homeassistant/components/plex/* @jjlawren homeassistant/components/plugwise/* @laetificat @CoMPaTech @bouwew homeassistant/components/point/* @fredrike +homeassistant/components/powerwall/* @bdraco homeassistant/components/proxmoxve/* @k4ds3 homeassistant/components/ps4/* @ktnrg45 homeassistant/components/ptvsd/* @swamp-ig diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py new file mode 100644 index 00000000000..a5401206379 --- /dev/null +++ b/homeassistant/components/powerwall/__init__.py @@ -0,0 +1,130 @@ +"""The Tesla Powerwall integration.""" +import asyncio +from datetime import timedelta +import logging + +from tesla_powerwall import ( + ApiError, + MetersResponse, + PowerWall, + PowerWallUnreachableError, +) +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + DOMAIN, + POWERWALL_API_CHARGE, + POWERWALL_API_GRID_STATUS, + POWERWALL_API_METERS, + POWERWALL_API_SITEMASTER, + POWERWALL_COORDINATOR, + POWERWALL_OBJECT, + POWERWALL_SITE_INFO, + UPDATE_INTERVAL, +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({vol.Required(CONF_IP_ADDRESS): cv.string})}, + extra=vol.ALLOW_EXTRA, +) + +PLATFORMS = ["binary_sensor", "sensor"] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Tesla Powerwall component.""" + hass.data.setdefault(DOMAIN, {}) + conf = config.get(DOMAIN) + + if not conf: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf, + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Tesla Powerwall from a config entry.""" + + entry_id = entry.entry_id + + hass.data[DOMAIN].setdefault(entry_id, {}) + power_wall = PowerWall(entry.data[CONF_IP_ADDRESS]) + try: + site_info = await hass.async_add_executor_job(call_site_info, power_wall) + except (PowerWallUnreachableError, ApiError, ConnectionError): + raise ConfigEntryNotReady + + async def async_update_data(): + """Fetch data from API endpoint.""" + return await hass.async_add_executor_job(_fetch_powerwall_data, power_wall) + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="Powerwall site", + update_method=async_update_data, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + + hass.data[DOMAIN][entry.entry_id] = { + POWERWALL_OBJECT: power_wall, + POWERWALL_COORDINATOR: coordinator, + POWERWALL_SITE_INFO: site_info, + } + + await coordinator.async_refresh() + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +def call_site_info(power_wall): + """Wrap site_info to be a callable.""" + return power_wall.site_info + + +def _fetch_powerwall_data(power_wall): + """Process and update powerwall data.""" + meters = power_wall.meters + return { + POWERWALL_API_CHARGE: power_wall.charge, + POWERWALL_API_SITEMASTER: power_wall.sitemaster, + POWERWALL_API_METERS: { + meter: MetersResponse(meters[meter]) for meter in meters + }, + POWERWALL_API_GRID_STATUS: power_wall.grid_status, + } + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/powerwall/binary_sensor.py b/homeassistant/components/powerwall/binary_sensor.py new file mode 100644 index 00000000000..52b82531472 --- /dev/null +++ b/homeassistant/components/powerwall/binary_sensor.py @@ -0,0 +1,131 @@ +"""Support for August sensors.""" +import logging + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + BinarySensorDevice, +) +from homeassistant.const import DEVICE_CLASS_POWER + +from .const import ( + ATTR_GRID_CODE, + ATTR_NOMINAL_SYSTEM_POWER, + ATTR_REGION, + DOMAIN, + POWERWALL_API_GRID_STATUS, + POWERWALL_API_SITEMASTER, + POWERWALL_CONNECTED_KEY, + POWERWALL_COORDINATOR, + POWERWALL_GRID_ONLINE, + POWERWALL_RUNNING_KEY, + POWERWALL_SITE_INFO, + SITE_INFO_GRID_CODE, + SITE_INFO_NOMINAL_SYSTEM_POWER_KW, + SITE_INFO_REGION, +) +from .entity import PowerWallEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the August sensors.""" + powerwall_data = hass.data[DOMAIN][config_entry.entry_id] + + coordinator = powerwall_data[POWERWALL_COORDINATOR] + site_info = powerwall_data[POWERWALL_SITE_INFO] + + entities = [] + for sensor_class in ( + PowerWallRunningSensor, + PowerWallGridStatusSensor, + PowerWallConnectedSensor, + ): + entities.append(sensor_class(coordinator, site_info)) + + async_add_entities(entities, True) + + +class PowerWallRunningSensor(PowerWallEntity, BinarySensorDevice): + """Representation of an Powerwall running sensor.""" + + @property + def name(self): + """Device Name.""" + return "Powerwall Status" + + @property + def device_class(self): + """Device Class.""" + return DEVICE_CLASS_POWER + + @property + def unique_id(self): + """Device Uniqueid.""" + return f"{self.base_unique_id}_running" + + @property + def is_on(self): + """Get the powerwall running state.""" + return self._coordinator.data[POWERWALL_API_SITEMASTER][POWERWALL_RUNNING_KEY] + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return { + ATTR_REGION: self._site_info[SITE_INFO_REGION], + ATTR_GRID_CODE: self._site_info[SITE_INFO_GRID_CODE], + ATTR_NOMINAL_SYSTEM_POWER: self._site_info[ + SITE_INFO_NOMINAL_SYSTEM_POWER_KW + ], + } + + +class PowerWallConnectedSensor(PowerWallEntity, BinarySensorDevice): + """Representation of an Powerwall connected sensor.""" + + @property + def name(self): + """Device Name.""" + return "Powerwall Connected to Tesla" + + @property + def device_class(self): + """Device Class.""" + return DEVICE_CLASS_CONNECTIVITY + + @property + def unique_id(self): + """Device Uniqueid.""" + return f"{self.base_unique_id}_connected_to_tesla" + + @property + def is_on(self): + """Get the powerwall connected to tesla state.""" + return self._coordinator.data[POWERWALL_API_SITEMASTER][POWERWALL_CONNECTED_KEY] + + +class PowerWallGridStatusSensor(PowerWallEntity, BinarySensorDevice): + """Representation of an Powerwall grid status sensor.""" + + @property + def name(self): + """Device Name.""" + return "Grid Status" + + @property + def device_class(self): + """Device Class.""" + return DEVICE_CLASS_POWER + + @property + def unique_id(self): + """Device Uniqueid.""" + return f"{self.base_unique_id}_grid_status" + + @property + def is_on(self): + """Get the current value in kWh.""" + return ( + self._coordinator.data[POWERWALL_API_GRID_STATUS] == POWERWALL_GRID_ONLINE + ) diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py new file mode 100644 index 00000000000..e94b0cd4056 --- /dev/null +++ b/homeassistant/components/powerwall/config_flow.py @@ -0,0 +1,72 @@ +"""Config flow for Tesla Powerwall integration.""" +import logging + +from tesla_powerwall import ApiError, PowerWall, PowerWallUnreachableError +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_IP_ADDRESS + +from . import call_site_info +from .const import DOMAIN # pylint:disable=unused-import +from .const import POWERWALL_SITE_NAME + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_IP_ADDRESS): str}) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + + power_wall = PowerWall(data[CONF_IP_ADDRESS]) + + try: + site_info = await hass.async_add_executor_job(call_site_info, power_wall) + except (PowerWallUnreachableError, ApiError, ConnectionError): + raise CannotConnect + + # Return info that you want to store in the config entry. + return {"title": site_info[POWERWALL_SITE_NAME]} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Tesla Powerwall.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if "base" not in errors: + await self.async_set_unique_id(user_input[CONF_IP_ADDRESS]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, user_input): + """Handle import.""" + await self.async_set_unique_id(user_input[CONF_IP_ADDRESS]) + self._abort_if_unique_id_configured() + + return await self.async_step_user(user_input) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/powerwall/const.py b/homeassistant/components/powerwall/const.py new file mode 100644 index 00000000000..59accc9e9a3 --- /dev/null +++ b/homeassistant/components/powerwall/const.py @@ -0,0 +1,40 @@ +"""Constants for the Tesla Powerwall integration.""" + +DOMAIN = "powerwall" + +POWERWALL_SITE_NAME = "site_name" + +POWERWALL_OBJECT = "powerwall" +POWERWALL_COORDINATOR = "coordinator" +POWERWALL_SITE_INFO = "site_info" + +UPDATE_INTERVAL = 60 + +ATTR_REGION = "region" +ATTR_GRID_CODE = "grid_code" +ATTR_FREQUENCY = "frequency" +ATTR_ENERGY_EXPORTED = "energy_exported" +ATTR_ENERGY_IMPORTED = "energy_imported" +ATTR_INSTANT_AVERAGE_VOLTAGE = "instant_average_voltage" +ATTR_NOMINAL_SYSTEM_POWER = "nominal_system_power_kW" + +SITE_INFO_UTILITY = "utility" +SITE_INFO_GRID_CODE = "grid_code" +SITE_INFO_NOMINAL_SYSTEM_POWER_KW = "nominal_system_power_kW" +SITE_INFO_NOMINAL_SYSTEM_ENERGY_KWH = "nominal_system_energy_kWh" +SITE_INFO_REGION = "region" + +POWERWALL_SITE_NAME = "site_name" + +POWERWALL_API_METERS = "meters" +POWERWALL_API_CHARGE = "charge" +POWERWALL_API_GRID_STATUS = "grid_status" +POWERWALL_API_SITEMASTER = "sitemaster" + +POWERWALL_GRID_ONLINE = "SystemGridConnected" +POWERWALL_CONNECTED_KEY = "connected_to_tesla" +POWERWALL_RUNNING_KEY = "running" + + +MODEL = "PowerWall 2" +MANUFACTURER = "Tesla" diff --git a/homeassistant/components/powerwall/entity.py b/homeassistant/components/powerwall/entity.py new file mode 100644 index 00000000000..0411f956bdc --- /dev/null +++ b/homeassistant/components/powerwall/entity.py @@ -0,0 +1,67 @@ +"""The Tesla Powerwall integration base entity.""" + +from homeassistant.helpers.entity import Entity + +from .const import ( + DOMAIN, + MANUFACTURER, + MODEL, + POWERWALL_SITE_NAME, + SITE_INFO_GRID_CODE, + SITE_INFO_NOMINAL_SYSTEM_ENERGY_KWH, + SITE_INFO_NOMINAL_SYSTEM_POWER_KW, + SITE_INFO_UTILITY, +) + + +class PowerWallEntity(Entity): + """Base class for powerwall entities.""" + + def __init__(self, coordinator, site_info): + """Initialize the sensor.""" + super().__init__() + self._coordinator = coordinator + self._site_info = site_info + # This group of properties will be unique to to the site + unique_group = ( + site_info[SITE_INFO_UTILITY], + site_info[SITE_INFO_GRID_CODE], + str(site_info[SITE_INFO_NOMINAL_SYSTEM_POWER_KW]), + str(site_info[SITE_INFO_NOMINAL_SYSTEM_ENERGY_KWH]), + ) + self.base_unique_id = "_".join(unique_group) + + @property + def device_info(self): + """Powerwall device info.""" + return { + "identifiers": {(DOMAIN, self.base_unique_id)}, + "name": self._site_info[POWERWALL_SITE_NAME], + "manufacturer": MANUFACTURER, + "model": MODEL, + } + + @property + def available(self): + """Return True if entity is available.""" + return self._coordinator.last_update_success + + @property + def should_poll(self): + """Return False, updates are controlled via coordinator.""" + return False + + async def async_update(self): + """Update the entity. + + Only used by the generic entity update service. + """ + await self._coordinator.async_request_refresh() + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self._coordinator.async_add_listener(self.async_write_ha_state) + + async def async_will_remove_from_hass(self): + """Undo subscription.""" + self._coordinator.async_remove_listener(self.async_write_ha_state) diff --git a/homeassistant/components/powerwall/manifest.json b/homeassistant/components/powerwall/manifest.json new file mode 100644 index 00000000000..ed90bc339fc --- /dev/null +++ b/homeassistant/components/powerwall/manifest.json @@ -0,0 +1,16 @@ +{ + "domain": "powerwall", + "name": "Tesla Powerwall", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/powerwall", + "requirements": [ + "tesla-powerwall==0.1.1" + ], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": [ + "@bdraco" + ] +} \ No newline at end of file diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py new file mode 100644 index 00000000000..3ebb467d4fc --- /dev/null +++ b/homeassistant/components/powerwall/sensor.py @@ -0,0 +1,116 @@ +"""Support for August sensors.""" +import logging + +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, + UNIT_PERCENTAGE, +) + +from .const import ( + ATTR_ENERGY_EXPORTED, + ATTR_ENERGY_IMPORTED, + ATTR_FREQUENCY, + ATTR_INSTANT_AVERAGE_VOLTAGE, + DOMAIN, + POWERWALL_API_CHARGE, + POWERWALL_API_METERS, + POWERWALL_COORDINATOR, + POWERWALL_SITE_INFO, +) +from .entity import PowerWallEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the August sensors.""" + powerwall_data = hass.data[DOMAIN][config_entry.entry_id] + _LOGGER.debug("Powerwall_data: %s", powerwall_data) + + coordinator = powerwall_data[POWERWALL_COORDINATOR] + site_info = powerwall_data[POWERWALL_SITE_INFO] + + entities = [] + for meter in coordinator.data[POWERWALL_API_METERS]: + entities.append(PowerWallEnergySensor(meter, coordinator, site_info)) + + entities.append(PowerWallChargeSensor(coordinator, site_info)) + + async_add_entities(entities, True) + + +class PowerWallChargeSensor(PowerWallEntity): + """Representation of an Powerwall charge sensor.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return UNIT_PERCENTAGE + + @property + def name(self): + """Device Name.""" + return "Powerwall Charge" + + @property + def device_class(self): + """Device Class.""" + return DEVICE_CLASS_BATTERY + + @property + def unique_id(self): + """Device Uniqueid.""" + return f"{self.base_unique_id}_charge" + + @property + def state(self): + """Get the current value in percentage.""" + return round(self._coordinator.data[POWERWALL_API_CHARGE], 3) + + +class PowerWallEnergySensor(PowerWallEntity): + """Representation of an Powerwall Energy sensor.""" + + def __init__(self, meter, coordinator, site_info): + """Initialize the sensor.""" + super().__init__(coordinator, site_info) + self._meter = meter + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return ENERGY_KILO_WATT_HOUR + + @property + def name(self): + """Device Name.""" + return f"Powerwall {self._meter.title()} Now" + + @property + def device_class(self): + """Device Class.""" + return DEVICE_CLASS_POWER + + @property + def unique_id(self): + """Device Uniqueid.""" + return f"{self.base_unique_id}_{self._meter}_instant_power" + + @property + def state(self): + """Get the current value in kWh.""" + meter = self._coordinator.data[POWERWALL_API_METERS][self._meter] + return round(float(meter.instant_power / 1000), 3) + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + meter = self._coordinator.data[POWERWALL_API_METERS][self._meter] + return { + ATTR_FREQUENCY: meter.frequency, + ATTR_ENERGY_EXPORTED: meter.energy_exported, + ATTR_ENERGY_IMPORTED: meter.energy_imported, + ATTR_INSTANT_AVERAGE_VOLTAGE: meter.instant_average_voltage, + } diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json new file mode 100644 index 00000000000..92f0fd19464 --- /dev/null +++ b/homeassistant/components/powerwall/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "title": "Tesla Powerwall", + "step": { + "user": { + "title": "Connect to the powerwall", + "data": { + "ip_address": "IP Address" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "The powerwall is already configured" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0ca18cec442..fb2f2bdb7a9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -79,6 +79,7 @@ FLOWS = [ "plaato", "plex", "point", + "powerwall", "ps4", "rachio", "rainmachine", diff --git a/requirements_all.txt b/requirements_all.txt index a6925149f53..df442551bb2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1995,6 +1995,9 @@ temperusb==1.5.3 # homeassistant.components.tensorflow # tensorflow==1.13.2 +# homeassistant.components.powerwall +tesla-powerwall==0.1.1 + # homeassistant.components.tesla teslajsonpy==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f27b756f3bd..614ad22605e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -692,6 +692,9 @@ sunwatcher==0.2.1 # homeassistant.components.tellduslive tellduslive==0.10.10 +# homeassistant.components.powerwall +tesla-powerwall==0.1.1 + # homeassistant.components.tesla teslajsonpy==0.5.1 diff --git a/tests/components/powerwall/__init__.py b/tests/components/powerwall/__init__.py new file mode 100644 index 00000000000..0e43ec085eb --- /dev/null +++ b/tests/components/powerwall/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tesla Powerwall integration.""" diff --git a/tests/components/powerwall/mocks.py b/tests/components/powerwall/mocks.py new file mode 100644 index 00000000000..330f1123b8f --- /dev/null +++ b/tests/components/powerwall/mocks.py @@ -0,0 +1,56 @@ +"""Mocks for powerwall.""" + +import json +import os + +from asynctest import MagicMock, PropertyMock + +from homeassistant.components.powerwall.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS + +from tests.common import load_fixture + + +async def _mock_powerwall_with_fixtures(hass): + """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") + return _mock_powerwall_return_value( + site_info=site_info, + charge=47.31993232, + sitemaster=sitemaster, + meters=meters, + grid_status="SystemGridConnected", + ) + + +def _mock_powerwall_return_value( + site_info=None, charge=None, sitemaster=None, meters=None, grid_status=None +): + powerwall_mock = MagicMock() + type(powerwall_mock).site_info = PropertyMock(return_value=site_info) + type(powerwall_mock).charge = PropertyMock(return_value=charge) + type(powerwall_mock).sitemaster = PropertyMock(return_value=sitemaster) + type(powerwall_mock).meters = PropertyMock(return_value=meters) + type(powerwall_mock).grid_status = PropertyMock(return_value=grid_status) + + return powerwall_mock + + +def _mock_powerwall_side_effect(site_info=None): + powerwall_mock = MagicMock() + type(powerwall_mock).site_info = PropertyMock(side_effect=site_info) + return powerwall_mock + + +async def _async_load_json_fixture(hass, path): + fixture = await hass.async_add_executor_job( + load_fixture, os.path.join("powerwall", path) + ) + return json.loads(fixture) + + +def _mock_get_config(): + """Return a default powerwall config.""" + return {DOMAIN: {CONF_IP_ADDRESS: "1.2.3.4"}} diff --git a/tests/components/powerwall/test_binary_sensor.py b/tests/components/powerwall/test_binary_sensor.py new file mode 100644 index 00000000000..621304793ab --- /dev/null +++ b/tests/components/powerwall/test_binary_sensor.py @@ -0,0 +1,54 @@ +"""The binary sensor tests for the powerwall platform.""" + +from asynctest import patch + +from homeassistant.components.powerwall.const import DOMAIN +from homeassistant.const import STATE_ON +from homeassistant.setup import async_setup_component + +from .mocks import _mock_get_config, _mock_powerwall_with_fixtures + + +async def test_sensors(hass): + """Test creation of the binary sensors.""" + + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + + with patch( + "homeassistant.components.powerwall.config_flow.PowerWall", + return_value=mock_powerwall, + ), patch( + "homeassistant.components.powerwall.PowerWall", return_value=mock_powerwall, + ): + assert await async_setup_component(hass, DOMAIN, _mock_get_config()) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.grid_status") + assert state.state == STATE_ON + expected_attributes = {"friendly_name": "Grid Status", "device_class": "power"} + # 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()) + + state = hass.states.get("binary_sensor.powerwall_status") + assert state.state == STATE_ON + expected_attributes = { + "region": "IEEE1547a:2014", + "grid_code": "60Hz_240V_s_IEEE1547a_2014", + "nominal_system_power_kW": 25, + "friendly_name": "Powerwall Status", + "device_class": "power", + } + # 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()) + + state = hass.states.get("binary_sensor.powerwall_connected_to_tesla") + assert state.state == STATE_ON + expected_attributes = { + "friendly_name": "Powerwall Connected to Tesla", + "device_class": "connectivity", + } + # 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()) diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py new file mode 100644 index 00000000000..f27d7e1f41b --- /dev/null +++ b/tests/components/powerwall/test_config_flow.py @@ -0,0 +1,93 @@ +"""Test the Powerwall config flow.""" + +from asynctest import patch +from tesla_powerwall import PowerWallUnreachableError + +from homeassistant import config_entries, setup +from homeassistant.components.powerwall.const import DOMAIN, POWERWALL_SITE_NAME +from homeassistant.const import CONF_IP_ADDRESS + +from .mocks import _mock_powerwall_return_value, _mock_powerwall_side_effect + + +async def test_form_source_user(hass): + """Test we get config flow setup form as a user.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + mock_powerwall = _mock_powerwall_return_value( + site_info={POWERWALL_SITE_NAME: "My site"} + ) + + with patch( + "homeassistant.components.powerwall.config_flow.PowerWall", + return_value=mock_powerwall, + ), patch( + "homeassistant.components.powerwall.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.powerwall.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "1.2.3.4"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "My site" + assert result2["data"] == {CONF_IP_ADDRESS: "1.2.3.4"} + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_source_import(hass): + """Test we setup the config entry via import.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + mock_powerwall = _mock_powerwall_return_value( + site_info={POWERWALL_SITE_NAME: "Imported site"} + ) + + with patch( + "homeassistant.components.powerwall.config_flow.PowerWall", + return_value=mock_powerwall, + ), patch( + "homeassistant.components.powerwall.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.powerwall.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_IP_ADDRESS: "1.2.3.4"}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == "Imported site" + assert result["data"] == {CONF_IP_ADDRESS: "1.2.3.4"} + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_powerwall = _mock_powerwall_side_effect(site_info=PowerWallUnreachableError) + + with patch( + "homeassistant.components.powerwall.config_flow.PowerWall", + return_value=mock_powerwall, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "1.2.3.4"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py new file mode 100644 index 00000000000..ea74f33671f --- /dev/null +++ b/tests/components/powerwall/test_sensor.py @@ -0,0 +1,103 @@ +"""The sensor tests for the powerwall platform.""" + +from asynctest import patch + +from homeassistant.components.powerwall.const import DOMAIN +from homeassistant.setup import async_setup_component + +from .mocks import _mock_get_config, _mock_powerwall_with_fixtures + + +async def test_sensors(hass): + """Test creation of the sensors.""" + + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + + with patch( + "homeassistant.components.powerwall.config_flow.PowerWall", + return_value=mock_powerwall, + ), patch( + "homeassistant.components.powerwall.PowerWall", return_value=mock_powerwall, + ): + assert await async_setup_component(hass, DOMAIN, _mock_get_config()) + await hass.async_block_till_done() + + device_registry = await hass.helpers.device_registry.async_get_registry() + reg_device = device_registry.async_get_device( + identifiers={("powerwall", "Wom Energy_60Hz_240V_s_IEEE1547a_2014_25_13.5")}, + connections=set(), + ) + assert reg_device.model == "PowerWall 2" + assert reg_device.manufacturer == "Tesla" + assert reg_device.name == "MySite" + + state = hass.states.get("sensor.powerwall_site_now") + assert state.state == "0.032" + expected_attributes = { + "frequency": 60, + "energy_exported": 10429451.9916853, + "energy_imported": 4824191.60668611, + "instant_average_voltage": 120.650001525879, + "unit_of_measurement": "kWh", + "friendly_name": "Powerwall Site Now", + "device_class": "power", + } + # 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()) + + state = hass.states.get("sensor.powerwall_load_now") + assert state.state == "1.971" + expected_attributes = { + "frequency": 60, + "energy_exported": 1056797.48917483, + "energy_imported": 4692987.91889705, + "instant_average_voltage": 120.650001525879, + "unit_of_measurement": "kWh", + "friendly_name": "Powerwall Load Now", + "device_class": "power", + } + # 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()) + + state = hass.states.get("sensor.powerwall_battery_now") + assert state.state == "-8.55" + expected_attributes = { + "frequency": 60.014, + "energy_exported": 3620010, + "energy_imported": 4216170, + "instant_average_voltage": 240.56, + "unit_of_measurement": "kWh", + "friendly_name": "Powerwall Battery Now", + "device_class": "power", + } + # 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()) + + state = hass.states.get("sensor.powerwall_solar_now") + assert state.state == "10.49" + expected_attributes = { + "frequency": 60, + "energy_exported": 9864205.82222448, + "energy_imported": 28177.5358355867, + "instant_average_voltage": 120.685001373291, + "unit_of_measurement": "kWh", + "friendly_name": "Powerwall Solar Now", + "device_class": "power", + } + # 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()) + + state = hass.states.get("sensor.powerwall_charge") + assert state.state == "47.32" + expected_attributes = { + "unit_of_measurement": "%", + "friendly_name": "Powerwall Charge", + "device_class": "battery", + } + # 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()) diff --git a/tests/fixtures/powerwall/meters.json b/tests/fixtures/powerwall/meters.json new file mode 100644 index 00000000000..8eb69593d59 --- /dev/null +++ b/tests/fixtures/powerwall/meters.json @@ -0,0 +1,62 @@ +{ + "battery" : { + "instant_power" : -8550, + "i_b_current" : 0, + "instant_average_voltage" : 240.56, + "i_a_current" : 0, + "frequency" : 60.014, + "instant_reactive_power" : 50, + "energy_imported" : 4216170, + "instant_total_current" : 185.5, + "timeout" : 1500000000, + "energy_exported" : 3620010, + "instant_apparent_power" : 8550.14619758048, + "last_communication_time" : "2020-03-15T15:58:53.855997624-05:00", + "i_c_current" : 0 + }, + "load" : { + "i_b_current" : 0, + "instant_average_voltage" : 120.650001525879, + "instant_power" : 1971.46005249023, + "instant_reactive_power" : -2119.58996582031, + "i_a_current" : 0, + "frequency" : 60, + "last_communication_time" : "2020-03-15T15:58:53.853784964-05:00", + "instant_apparent_power" : 2894.70488336392, + "i_c_current" : 0, + "instant_total_current" : 0, + "energy_imported" : 4692987.91889705, + "timeout" : 1500000000, + "energy_exported" : 1056797.48917483 + }, + "solar" : { + "i_a_current" : 0, + "frequency" : 60, + "instant_reactive_power" : -15.2600002288818, + "instant_power" : 10489.6596679688, + "i_b_current" : 0, + "instant_average_voltage" : 120.685001373291, + "timeout" : 1500000000, + "energy_exported" : 9864205.82222448, + "energy_imported" : 28177.5358355867, + "instant_total_current" : 0, + "i_c_current" : 0, + "instant_apparent_power" : 10489.6707678276, + "last_communication_time" : "2020-03-15T15:58:53.853898963-05:00" + }, + "site" : { + "instant_total_current" : 0.263575500367178, + "energy_imported" : 4824191.60668611, + "energy_exported" : 10429451.9916853, + "timeout" : 1500000000, + "last_communication_time" : "2020-03-15T15:58:53.853784964-05:00", + "instant_apparent_power" : 2154.56465790676, + "i_c_current" : 0, + "instant_average_voltage" : 120.650001525879, + "i_b_current" : 0, + "instant_power" : 31.8003845214844, + "instant_reactive_power" : -2154.32996559143, + "i_a_current" : 0, + "frequency" : 60 + } +} diff --git a/tests/fixtures/powerwall/site_info.json b/tests/fixtures/powerwall/site_info.json new file mode 100644 index 00000000000..3bd6ee59e40 --- /dev/null +++ b/tests/fixtures/powerwall/site_info.json @@ -0,0 +1,20 @@ +{ + "state" : "Somewhere", + "utility" : "Wom Energy", + "distributor" : "*", + "max_system_energy_kWh" : 0, + "nominal_system_power_kW" : 25, + "grid_voltage_setting" : 240, + "retailer" : "*", + "grid_code" : "60Hz_240V_s_IEEE1547a_2014", + "timezone" : "America/Chicago", + "nominal_system_energy_kWh" : 13.5, + "region" : "IEEE1547a:2014", + "min_site_meter_power_kW" : -1000000000, + "site_name" : "MySite", + "country" : "United States", + "max_site_meter_power_kW" : 1000000000, + "grid_phase_setting" : "Split", + "max_system_power_kW" : 0, + "grid_freq_setting" : 60 +} diff --git a/tests/fixtures/powerwall/sitemaster.json b/tests/fixtures/powerwall/sitemaster.json new file mode 100644 index 00000000000..a2d6c0dd965 --- /dev/null +++ b/tests/fixtures/powerwall/sitemaster.json @@ -0,0 +1 @@ +{"connected_to_tesla": true, "running": true, "status": "StatusUp"}