From bb164bb32cdb071eebb6d62811c35d139e96a3b7 Mon Sep 17 00:00:00 2001 From: Graham Wetzler Date: Wed, 19 Aug 2020 13:16:45 -0500 Subject: [PATCH] Smart Meter Texas integration (#37966) * Run scaffold script * Update version * Bump version * Initial commit * Move meter and ESIID to device attributes * Update internal to hourly due to api limit * Format with Black * Fix typo * Update tests * Update description * Disable Pylint error * Don't commit translations * Remove meter number from sensor name * Allow multiple meters per account * Move data updates to a DataUpdateCoordinator * Use setdefault to setup the component * Move strings to const.py * Fix tests * Remove meter last updated attribute * Bump smart-meter-texas version * Fix logger call Co-authored-by: J. Nick Koston * Remove unneeded manifest keys Co-authored-by: J. Nick Koston * Remove icon property Co-authored-by: J. Nick Koston * Handle instance where user already setup an account Co-authored-by: J. Nick Koston * Remove icon constant * Fix indentation * Handle config flow errors better * Use ESIID + meter number as unique ID for sensor * Update config flow tests to reach 100% coverage * Avoid reading meters on startup Cherrypick @bdraco's suggestion * Run scaffold script * Update version * Bump version * Initial commit * Move meter and ESIID to device attributes * Update internal to hourly due to api limit * Format with Black * Fix typo * Update tests * Update description * Disable Pylint error * Don't commit translations * Remove meter number from sensor name * Allow multiple meters per account * Move data updates to a DataUpdateCoordinator * Use setdefault to setup the component * Move strings to const.py * Fix tests * Remove meter last updated attribute * Bump smart-meter-texas version * Fix logger call Co-authored-by: J. Nick Koston * Remove unneeded manifest keys Co-authored-by: J. Nick Koston * Remove icon property Co-authored-by: J. Nick Koston * Handle instance where user already setup an account Co-authored-by: J. Nick Koston * Remove icon constant * Fix indentation * Handle config flow errors better * Use ESIID + meter number as unique ID for sensor * Update config flow tests to reach 100% coverage * Remove unnecessary try/except block This checks for the same exception just prior in execution on L51. * Remove unused return values * Add tests * Improve tests and coverage * Use more pythonic control flow * Remove all uses of hass.data Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/smart_meter_texas/__init__.py | 133 ++++++++++++++++++ .../smart_meter_texas/config_flow.py | 91 ++++++++++++ .../components/smart_meter_texas/const.py | 15 ++ .../smart_meter_texas/manifest.json | 8 ++ .../components/smart_meter_texas/sensor.py | 112 +++++++++++++++ .../components/smart_meter_texas/strings.json | 22 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../components/smart_meter_texas/__init__.py | 1 + .../components/smart_meter_texas/conftest.py | 100 +++++++++++++ .../smart_meter_texas/test_config_flow.py | 120 ++++++++++++++++ .../components/smart_meter_texas/test_init.py | 74 ++++++++++ .../smart_meter_texas/test_sensor.py | 65 +++++++++ .../smart_meter_texas/latestodrread.json | 9 ++ tests/fixtures/smart_meter_texas/meter.json | 22 +++ 17 files changed, 780 insertions(+) create mode 100644 homeassistant/components/smart_meter_texas/__init__.py create mode 100644 homeassistant/components/smart_meter_texas/config_flow.py create mode 100644 homeassistant/components/smart_meter_texas/const.py create mode 100644 homeassistant/components/smart_meter_texas/manifest.json create mode 100644 homeassistant/components/smart_meter_texas/sensor.py create mode 100644 homeassistant/components/smart_meter_texas/strings.json create mode 100644 tests/components/smart_meter_texas/__init__.py create mode 100644 tests/components/smart_meter_texas/conftest.py create mode 100644 tests/components/smart_meter_texas/test_config_flow.py create mode 100644 tests/components/smart_meter_texas/test_init.py create mode 100644 tests/components/smart_meter_texas/test_sensor.py create mode 100644 tests/fixtures/smart_meter_texas/latestodrread.json create mode 100644 tests/fixtures/smart_meter_texas/meter.json diff --git a/CODEOWNERS b/CODEOWNERS index f497b64d7ae..fa7d79b124e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -377,6 +377,7 @@ homeassistant/components/sky_hub/* @rogerselwyn homeassistant/components/slide/* @ualex73 homeassistant/components/sma/* @kellerza homeassistant/components/smappee/* @bsmappee +homeassistant/components/smart_meter_texas/* @grahamwetzler homeassistant/components/smarthab/* @outadoc homeassistant/components/smartthings/* @andrewsayre homeassistant/components/smarty/* @z0mbieprocess diff --git a/homeassistant/components/smart_meter_texas/__init__.py b/homeassistant/components/smart_meter_texas/__init__.py new file mode 100644 index 00000000000..29c961df5e0 --- /dev/null +++ b/homeassistant/components/smart_meter_texas/__init__.py @@ -0,0 +1,133 @@ +"""The Smart Meter Texas integration.""" +import asyncio +import logging + +from smart_meter_texas import Account, Client +from smart_meter_texas.exceptions import ( + SmartMeterTexasAPIError, + SmartMeterTexasAuthError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + Debouncer, + UpdateFailed, +) + +from .const import ( + DATA_COORDINATOR, + DATA_SMART_METER, + DEBOUNCE_COOLDOWN, + DOMAIN, + SCAN_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["sensor"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Smart Meter Texas component.""" + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Smart Meter Texas from a config entry.""" + + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + + account = Account(username, password) + smartmetertexas = SmartMeterTexasData(hass, entry, account) + try: + await smartmetertexas.client.authenticate() + except SmartMeterTexasAuthError: + _LOGGER.error("Username or password was not accepted") + return False + except asyncio.TimeoutError: + raise ConfigEntryNotReady + + await smartmetertexas.setup() + + async def async_update_data(): + _LOGGER.debug("Fetching latest data") + await smartmetertexas.read_meters() + return smartmetertexas + + # Use a DataUpdateCoordinator to manage the updates. This is due to the + # Smart Meter Texas API which takes around 30 seconds to read a meter. + # This avoids Home Assistant from complaining about the component taking + # too long to update. + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="Smart Meter Texas", + update_method=async_update_data, + update_interval=SCAN_INTERVAL, + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=DEBOUNCE_COOLDOWN, immediate=True + ), + ) + + hass.data[DOMAIN][entry.entry_id] = { + DATA_COORDINATOR: coordinator, + DATA_SMART_METER: smartmetertexas, + } + + asyncio.create_task(coordinator.async_refresh()) + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +class SmartMeterTexasData: + """Manages coordinatation of API data updates.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, account: Account): + """Initialize the data coordintator.""" + self._entry = entry + self.account = account + websession = aiohttp_client.async_get_clientsession(hass) + self.client = Client(websession, account) + self.meters = [] + + async def setup(self): + """Fetch all of the user's meters.""" + self.meters = await self.account.fetch_meters(self.client) + _LOGGER.debug("Discovered %s meter(s)", len(self.meters)) + + async def read_meters(self): + """Read each meter.""" + for meter in self.meters: + try: + await meter.read_meter(self.client) + except (SmartMeterTexasAPIError, SmartMeterTexasAuthError) as error: + raise UpdateFailed(error) + return self.meters + + +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/smart_meter_texas/config_flow.py b/homeassistant/components/smart_meter_texas/config_flow.py new file mode 100644 index 00000000000..298831273e2 --- /dev/null +++ b/homeassistant/components/smart_meter_texas/config_flow.py @@ -0,0 +1,91 @@ +"""Config flow for Smart Meter Texas integration.""" +import asyncio +import logging + +from aiohttp import ClientError +from smart_meter_texas import Account, Client +from smart_meter_texas.exceptions import ( + SmartMeterTexasAPIError, + SmartMeterTexasAuthError, +) +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): 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. + """ + + client_session = aiohttp_client.async_get_clientsession(hass) + account = Account(data["username"], data["password"]) + client = Client(client_session, account) + + try: + await client.authenticate() + except (asyncio.TimeoutError, ClientError, SmartMeterTexasAPIError): + raise CannotConnect + except SmartMeterTexasAuthError as error: + raise InvalidAuth(error) + + # Return info that you want to store in the config entry. + return {"title": account.username} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Smart Meter Texas.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def _account_already_configured(self, account): + existing_accounts = { + entry.data[CONF_USERNAME] + for entry in self._async_current_entries() + if CONF_USERNAME in entry.data + } + return account in existing_accounts + + 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 InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if not errors: + if self._account_already_configured(user_input[CONF_USERNAME]): + return self.async_abort(reason="already_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 + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/smart_meter_texas/const.py b/homeassistant/components/smart_meter_texas/const.py new file mode 100644 index 00000000000..3b87454a6a7 --- /dev/null +++ b/homeassistant/components/smart_meter_texas/const.py @@ -0,0 +1,15 @@ +"""Constants for the Smart Meter Texas integration.""" +from datetime import timedelta + +SCAN_INTERVAL = timedelta(hours=1) +DEBOUNCE_COOLDOWN = 1800 # Seconds + +DATA_COORDINATOR = "coordinator" +DATA_SMART_METER = "smart_meter_data" + +DOMAIN = "smart_meter_texas" + +METER_NUMBER = "meter_number" +ESIID = "electric_service_identifier" +LAST_UPDATE = "last_updated" +ELECTRIC_METER = "Electric Meter" diff --git a/homeassistant/components/smart_meter_texas/manifest.json b/homeassistant/components/smart_meter_texas/manifest.json new file mode 100644 index 00000000000..be1ef6b11a8 --- /dev/null +++ b/homeassistant/components/smart_meter_texas/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "smart_meter_texas", + "name": "Smart Meter Texas", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/smart_meter_texas", + "requirements": ["smart-meter-texas==0.4.0"], + "codeowners": ["@grahamwetzler"] +} diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py new file mode 100644 index 00000000000..a655a805065 --- /dev/null +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -0,0 +1,112 @@ +"""Support for Smart Meter Texas sensors.""" +import logging + +from smart_meter_texas import Meter + +from homeassistant.const import CONF_ADDRESS, ENERGY_KILO_WATT_HOUR +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + DATA_COORDINATOR, + DATA_SMART_METER, + DOMAIN, + ELECTRIC_METER, + ESIID, + METER_NUMBER, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Smart Meter Texas sensors.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] + meters = hass.data[DOMAIN][config_entry.entry_id][DATA_SMART_METER].meters + + async_add_entities( + [SmartMeterTexasSensor(meter, coordinator) for meter in meters], False + ) + + +class SmartMeterTexasSensor(RestoreEntity, Entity): + """Representation of an Smart Meter Texas sensor.""" + + def __init__(self, meter: Meter, coordinator: DataUpdateCoordinator): + """Initialize the sensor.""" + self.meter = meter + self.coordinator = coordinator + self._state = None + self._available = False + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return ENERGY_KILO_WATT_HOUR + + @property + def name(self): + """Device Name.""" + return f"{ELECTRIC_METER} {self.meter.meter}" + + @property + def unique_id(self): + """Device Uniqueid.""" + return f"{self.meter.esiid}_{self.meter.meter}" + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @property + def state(self): + """Get the latest reading.""" + return self._state + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + attributes = { + METER_NUMBER: self.meter.meter, + ESIID: self.meter.esiid, + CONF_ADDRESS: self.meter.address, + } + return attributes + + @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() + + @callback + def _state_update(self): + """Call when the coordinator has an update.""" + self._available = self.coordinator.last_update_success + if self._available: + self._state = self.meter.reading + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self.async_on_remove(self.coordinator.async_add_listener(self._state_update)) + + # If the background update finished before + # we added the entity, there is no need to restore + # state. + if self.coordinator.last_update_success: + return + + last_state = await self.async_get_last_state() + if last_state: + self._state = last_state.state + self._available = True diff --git a/homeassistant/components/smart_meter_texas/strings.json b/homeassistant/components/smart_meter_texas/strings.json new file mode 100644 index 00000000000..0e7916a269a --- /dev/null +++ b/homeassistant/components/smart_meter_texas/strings.json @@ -0,0 +1,22 @@ +{ + "title": "Smart Meter Texas", + "config": { + "step": { + "user": { + "description": "Provide your username and password for Smart Meter Texas.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4a4548571ec..d315f9b73ca 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -151,6 +151,7 @@ FLOWS = [ "shopping_list", "simplisafe", "smappee", + "smart_meter_texas", "smarthab", "smartthings", "smhi", diff --git a/requirements_all.txt b/requirements_all.txt index 58fdb388958..dcc6134a790 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1993,6 +1993,9 @@ sleepyq==0.7 # homeassistant.components.xmpp slixmpp==1.5.1 +# homeassistant.components.smart_meter_texas +smart-meter-texas==0.4.0 + # homeassistant.components.smarthab smarthab==0.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c035d938e5d..a884e8bd8a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -899,6 +899,9 @@ simplisafe-python==9.3.0 # homeassistant.components.sleepiq sleepyq==0.7 +# homeassistant.components.smart_meter_texas +smart-meter-texas==0.4.0 + # homeassistant.components.smarthab smarthab==0.21 diff --git a/tests/components/smart_meter_texas/__init__.py b/tests/components/smart_meter_texas/__init__.py new file mode 100644 index 00000000000..5ad08df7e46 --- /dev/null +++ b/tests/components/smart_meter_texas/__init__.py @@ -0,0 +1 @@ +"""Tests for the Smart Meter Texas integration.""" diff --git a/tests/components/smart_meter_texas/conftest.py b/tests/components/smart_meter_texas/conftest.py new file mode 100644 index 00000000000..99df9380813 --- /dev/null +++ b/tests/components/smart_meter_texas/conftest.py @@ -0,0 +1,100 @@ +"""Test configuration and mocks for Smart Meter Texas.""" +import asyncio +import json +from pathlib import Path + +import pytest +from smart_meter_texas.const import ( + AUTH_ENDPOINT, + BASE_ENDPOINT, + BASE_URL, + LATEST_OD_READ_ENDPOINT, + METER_ENDPOINT, + OD_READ_ENDPOINT, +) + +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) +from homeassistant.components.smart_meter_texas.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + +TEST_ENTITY_ID = "sensor.electric_meter_123456789" + + +def load_smt_fixture(name): + """Return a dict of the json fixture.""" + json_fixture = load_fixture(Path() / DOMAIN / f"{name}.json") + return json.loads(json_fixture) + + +async def setup_integration(hass, config_entry, aioclient_mock): + """Initialize the Smart Meter Texas integration for testing.""" + mock_connection(aioclient_mock) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +async def refresh_data(hass, config_entry, aioclient_mock): + """Request a DataUpdateCoordinator refresh.""" + mock_connection(aioclient_mock) + await async_setup_component(hass, HA_DOMAIN, {}) + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + +def mock_connection( + aioclient_mock, auth_fail=False, auth_timeout=False, bad_reading=False +): + """Mock all calls to the API.""" + aioclient_mock.get(BASE_URL) + + auth_endpoint = f"{BASE_ENDPOINT}{AUTH_ENDPOINT}" + if not auth_fail and not auth_timeout: + aioclient_mock.post( + auth_endpoint, json={"token": "token123"}, + ) + elif auth_fail: + aioclient_mock.post( + auth_endpoint, + status=400, + json={"errormessage": "ERR-USR-INVALIDPASSWORDERROR"}, + ) + else: # auth_timeout + aioclient_mock.post(auth_endpoint, exc=asyncio.TimeoutError) + + aioclient_mock.post( + f"{BASE_ENDPOINT}{METER_ENDPOINT}", json=load_smt_fixture("meter"), + ) + aioclient_mock.post(f"{BASE_ENDPOINT}{OD_READ_ENDPOINT}", json={"data": None}) + if not bad_reading: + aioclient_mock.post( + f"{BASE_ENDPOINT}{LATEST_OD_READ_ENDPOINT}", + json=load_smt_fixture("latestodrread"), + ) + else: + aioclient_mock.post( + f"{BASE_ENDPOINT}{LATEST_OD_READ_ENDPOINT}", json={}, + ) + + +@pytest.fixture(name="config_entry") +def mock_config_entry(hass): + """Return a mock config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="user123", + data={"username": "user123", "password": "password123"}, + ) + config_entry.add_to_hass(hass) + + return config_entry diff --git a/tests/components/smart_meter_texas/test_config_flow.py b/tests/components/smart_meter_texas/test_config_flow.py new file mode 100644 index 00000000000..d1e88df8a80 --- /dev/null +++ b/tests/components/smart_meter_texas/test_config_flow.py @@ -0,0 +1,120 @@ +"""Test the Smart Meter Texas config flow.""" +import asyncio + +from aiohttp import ClientError +import pytest +from smart_meter_texas.exceptions import ( + SmartMeterTexasAPIError, + SmartMeterTexasAuthError, +) + +from homeassistant import config_entries, setup +from homeassistant.components.smart_meter_texas.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +TEST_LOGIN = {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"} + + +async def test_form(hass): + """Test we get the form.""" + 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"] == {} + + with patch("smart_meter_texas.Client.authenticate", return_value=True), patch( + "homeassistant.components.smart_meter_texas.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.smart_meter_texas.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_LOGIN + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_LOGIN[CONF_USERNAME] + assert result2["data"] == TEST_LOGIN + 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_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "smart_meter_texas.Client.authenticate", side_effect=SmartMeterTexasAuthError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_LOGIN, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +@pytest.mark.parametrize( + "side_effect", [asyncio.TimeoutError, ClientError, SmartMeterTexasAPIError] +) +async def test_form_cannot_connect(hass, side_effect): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "smart_meter_texas.Client.authenticate", side_effect=side_effect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_LOGIN + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_exception(hass): + """Test base exception is handled.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "smart_meter_texas.Client.authenticate", side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_LOGIN, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_duplicate_account(hass): + """Test that a duplicate account cannot be configured.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="user123", + data={"username": "user123", "password": "password123"}, + ).add_to_hass(hass) + + with patch( + "smart_meter_texas.Client.authenticate", return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"username": "user123", "password": "password123"}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/smart_meter_texas/test_init.py b/tests/components/smart_meter_texas/test_init.py new file mode 100644 index 00000000000..5394c69a3f0 --- /dev/null +++ b/tests/components/smart_meter_texas/test_init.py @@ -0,0 +1,74 @@ +"""Test the Smart Meter Texas module.""" +import pytest + +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) +from homeassistant.components.smart_meter_texas import async_setup_entry +from homeassistant.components.smart_meter_texas.const import DOMAIN +from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.setup import async_setup_component + +from .conftest import TEST_ENTITY_ID, mock_connection, setup_integration + +from tests.async_mock import patch + + +async def test_setup_with_no_config(hass): + """Test that no config is successful.""" + assert await async_setup_component(hass, DOMAIN, {}) is True + await hass.async_block_till_done() + + # Assert no flows were started. + assert len(hass.config_entries.flow.async_progress()) == 0 + + +async def test_auth_failure(hass, config_entry, aioclient_mock): + """Test if user's username or password is not accepted.""" + mock_connection(aioclient_mock, auth_fail=True) + result = await async_setup_entry(hass, config_entry) + + assert result is False + + +async def test_api_timeout(hass, config_entry, aioclient_mock): + """Test that a timeout results in ConfigEntryNotReady.""" + mock_connection(aioclient_mock, auth_timeout=True) + with pytest.raises(ConfigEntryNotReady): + await async_setup_entry(hass, config_entry) + + assert config_entry.state == ENTRY_STATE_NOT_LOADED + + +async def test_update_failure(hass, config_entry, aioclient_mock): + """Test that the coordinator handles a bad response.""" + mock_connection(aioclient_mock, bad_reading=True) + await setup_integration(hass, config_entry, aioclient_mock) + await async_setup_component(hass, HA_DOMAIN, {}) + with patch("smart_meter_texas.Meter.read_meter") as updater: + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + updater.assert_called_once() + + +async def test_unload_config_entry(hass, config_entry, aioclient_mock): + """Test entry unloading.""" + await setup_integration(hass, config_entry, aioclient_mock) + + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + assert config_entries[0] is config_entry + assert config_entry.state == ENTRY_STATE_LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ENTRY_STATE_NOT_LOADED diff --git a/tests/components/smart_meter_texas/test_sensor.py b/tests/components/smart_meter_texas/test_sensor.py new file mode 100644 index 00000000000..54a3aeb80fa --- /dev/null +++ b/tests/components/smart_meter_texas/test_sensor.py @@ -0,0 +1,65 @@ +"""Test the Smart Meter Texas sensor entity.""" +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) +from homeassistant.components.smart_meter_texas.const import ( + ELECTRIC_METER, + ESIID, + METER_NUMBER, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_ADDRESS +from homeassistant.setup import async_setup_component + +from .conftest import TEST_ENTITY_ID, mock_connection, refresh_data, setup_integration + +from tests.async_mock import patch + + +async def test_sensor(hass, config_entry, aioclient_mock): + """Test that the sensor is setup.""" + mock_connection(aioclient_mock) + await setup_integration(hass, config_entry, aioclient_mock) + await refresh_data(hass, config_entry, aioclient_mock) + meter = hass.states.get(TEST_ENTITY_ID) + + assert meter + assert meter.state == "9751.212" + + +async def test_name(hass, config_entry, aioclient_mock): + """Test sensor name property.""" + mock_connection(aioclient_mock) + await setup_integration(hass, config_entry, aioclient_mock) + await refresh_data(hass, config_entry, aioclient_mock) + meter = hass.states.get(TEST_ENTITY_ID) + + assert meter.name == f"{ELECTRIC_METER} 123456789" + + +async def test_attributes(hass, config_entry, aioclient_mock): + """Test meter attributes.""" + mock_connection(aioclient_mock) + await setup_integration(hass, config_entry, aioclient_mock) + await refresh_data(hass, config_entry, aioclient_mock) + meter = hass.states.get(TEST_ENTITY_ID) + + assert meter.attributes[METER_NUMBER] == "123456789" + assert meter.attributes[ESIID] == "12345678901234567" + assert meter.attributes[CONF_ADDRESS] == "123 MAIN ST" + + +async def test_generic_entity_update_service(hass, config_entry, aioclient_mock): + """Test generic update entity service homeasasistant/update_entity.""" + mock_connection(aioclient_mock) + await setup_integration(hass, config_entry, aioclient_mock) + await async_setup_component(hass, HA_DOMAIN, {}) + with patch("smart_meter_texas.Meter.read_meter") as updater: + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + updater.assert_called_once() diff --git a/tests/fixtures/smart_meter_texas/latestodrread.json b/tests/fixtures/smart_meter_texas/latestodrread.json new file mode 100644 index 00000000000..1f48ada166a --- /dev/null +++ b/tests/fixtures/smart_meter_texas/latestodrread.json @@ -0,0 +1,9 @@ +{ + "data": { + "odrstatus": "COMPLETED", + "odrread": "9751.212", + "odrusage": "43.826", + "odrdate": "08/15/2020 14:07:40", + "responseMessage": "SUCCESS" + } +} diff --git a/tests/fixtures/smart_meter_texas/meter.json b/tests/fixtures/smart_meter_texas/meter.json new file mode 100644 index 00000000000..55e38f205c4 --- /dev/null +++ b/tests/fixtures/smart_meter_texas/meter.json @@ -0,0 +1,22 @@ +{ + "data": [ + { + "customer": "X", + "email": "Y", + "description": "123 Main St", + "address": "123 MAIN ST", + "city": "DALLAS", + "state": "TX", + "zip": "75001-0.00", + "esiid": "12345678901234567", + "meterNumber": "123456789", + "meterMultiplier": 1, + "fullAddress": "123 MAIN ST, DALLAS, TX, 75001-0.00", + "errmsg": "Success", + "recordStatus": "ESIID-RECORD-EXISTS", + "statusIndicator": true, + "dunsNumber": "NO_DUNS" + } + ] + } + \ No newline at end of file