diff --git a/.coveragerc b/.coveragerc index 150867054b296861fc666b60eea3a0f8e167b77c..3f537e39f0dd79fc1e70d887f2b15474609e596e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -624,6 +624,8 @@ omit = homeassistant/components/plum_lightpad/light.py homeassistant/components/pocketcasts/sensor.py homeassistant/components/point/* + homeassistant/components/poolsense/__init__.py + homeassistant/components/poolsense/sensor.py homeassistant/components/prezzibenzina/sensor.py homeassistant/components/proliphix/climate.py homeassistant/components/prometheus/* diff --git a/CODEOWNERS b/CODEOWNERS index f91f857ac1aefc81437ace71965408b8bcaf010f..a2f9668b33032047a0a7ef7a649b2582cb2717e5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -318,6 +318,7 @@ homeassistant/components/plex/* @jjlawren homeassistant/components/plugwise/* @CoMPaTech @bouwew homeassistant/components/plum_lightpad/* @ColinHarrington @prystupa homeassistant/components/point/* @fredrike +homeassistant/components/poolsense/* @haemishkyd homeassistant/components/powerwall/* @bdraco @jrester homeassistant/components/prometheus/* @knyar homeassistant/components/proxmoxve/* @k4ds3 @jhollowe diff --git a/homeassistant/components/poolsense/__init__.py b/homeassistant/components/poolsense/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..472c09ffef9089249fcb6b23fe195193230a8be5 --- /dev/null +++ b/homeassistant/components/poolsense/__init__.py @@ -0,0 +1,103 @@ +"""The PoolSense integration.""" +import asyncio +from datetime import timedelta +import logging + +import async_timeout +from poolsense import PoolSense +from poolsense.exceptions import PoolSenseError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, update_coordinator +from homeassistant.helpers.update_coordinator import UpdateFailed + +from .const import DOMAIN + +PLATFORMS = ["sensor"] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the PoolSense component.""" + # Make sure coordinator is initialized. + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up PoolSense from a config entry.""" + poolsense = PoolSense() + auth_valid = await poolsense.test_poolsense_credentials( + aiohttp_client.async_get_clientsession(hass), + entry.data[CONF_EMAIL], + entry.data[CONF_PASSWORD], + ) + + if not auth_valid: + _LOGGER.error("Invalid authentication") + return False + + coordinator = await get_coordinator(hass, entry) + + await hass.data[DOMAIN][entry.entry_id].async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = coordinator + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +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 + + +async def get_coordinator(hass, entry): + """Get the data update coordinator.""" + + async def async_get_data(): + _LOGGER.info("Run query to server") + poolsense = PoolSense() + return_data = {} + with async_timeout.timeout(10): + try: + return_data = await poolsense.get_poolsense_data( + aiohttp_client.async_get_clientsession(hass), + entry.data[CONF_EMAIL], + entry.data[CONF_PASSWORD], + ) + except (PoolSenseError) as error: + raise UpdateFailed(error) + + return return_data + + return update_coordinator.DataUpdateCoordinator( + hass, + logging.getLogger(__name__), + name=DOMAIN, + update_method=async_get_data, + update_interval=timedelta(hours=1), + ) diff --git a/homeassistant/components/poolsense/config_flow.py b/homeassistant/components/poolsense/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..61087ce89b87500b65c6e0ae34ad4d6c7164ed7c --- /dev/null +++ b/homeassistant/components/poolsense/config_flow.py @@ -0,0 +1,71 @@ +"""Config flow for PoolSense integration.""" +import logging + +from poolsense import PoolSense +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class PoolSenseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for PoolSense.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + _options = None + + def __init__(self): + """Initialize PoolSense config flow.""" + self._email = None + self._password = None + self._errors = {} + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + self._errors = {} + + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_EMAIL]) + self._abort_if_unique_id_configured() + + self._email = user_input[CONF_EMAIL] + self._password = user_input[CONF_PASSWORD] + _LOGGER.debug("Configuring user: %s - Password hidden.", self._email) + + poolsense = PoolSense() + api_key_valid = await poolsense.test_poolsense_credentials( + aiohttp_client.async_get_clientsession(self.hass), + self._email, + self._password, + ) + + if not api_key_valid: + self._errors["base"] = "auth" + + if not self._errors: + return self.async_create_entry( + title=self._email, + data={CONF_EMAIL: self._email, CONF_PASSWORD: self._password}, + ) + + return await self._show_setup_form(user_input, self._errors) + + async def _show_setup_form(self, user_input=None, errors=None): + """Show the setup form to the user.""" + if user_input is None: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors or {}, + ) diff --git a/homeassistant/components/poolsense/const.py b/homeassistant/components/poolsense/const.py new file mode 100644 index 0000000000000000000000000000000000000000..ef4fc46dd5e2156a75de0e64e17aefa55c90d8ea --- /dev/null +++ b/homeassistant/components/poolsense/const.py @@ -0,0 +1,4 @@ +"""Constants for the PoolSense integration.""" + +DOMAIN = "poolsense" +ATTRIBUTION = "PoolSense Data" diff --git a/homeassistant/components/poolsense/manifest.json b/homeassistant/components/poolsense/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..b50ed2771706c95bb3cdaf3cdcb63c0a352a0903 --- /dev/null +++ b/homeassistant/components/poolsense/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "poolsense", + "name": "PoolSense", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/poolsense", + "requirements": [ + "poolsense==0.0.5" + ], + "codeowners": [ + "@haemishkyd" + ] +} diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..6c816cba0c5d2f6de009477192f2d02e666dbcdc --- /dev/null +++ b/homeassistant/components/poolsense/sensor.py @@ -0,0 +1,172 @@ +"""Sensor platform for the PoolSense sensor.""" +import logging + +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_EMAIL, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, + STATE_OK, + STATE_PROBLEM, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) +from homeassistant.helpers.entity import Entity + +from .const import ATTRIBUTION, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SENSORS = { + "Chlorine": { + "unit": "mV", + "icon": "mdi:pool", + "name": "Chlorine", + "device_class": None, + }, + "pH": {"unit": None, "icon": "mdi:pool", "name": "pH", "device_class": None}, + "Battery": { + "unit": UNIT_PERCENTAGE, + "icon": "mdi:battery", + "name": "Battery", + "device_class": DEVICE_CLASS_BATTERY, + }, + "Water Temp": { + "unit": TEMP_CELSIUS, + "icon": "mdi:coolant-temperature", + "name": "Temperature", + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "Last Seen": { + "unit": None, + "icon": "mdi:clock", + "name": "Last Seen", + "device_class": DEVICE_CLASS_TIMESTAMP, + }, + "Chlorine High": { + "unit": "mV", + "icon": "mdi:pool", + "name": "Chlorine High", + "device_class": None, + }, + "Chlorine Low": { + "unit": "mV", + "icon": "mdi:pool", + "name": "Chlorine Low", + "device_class": None, + }, + "pH High": { + "unit": None, + "icon": "mdi:pool", + "name": "pH High", + "device_class": None, + }, + "pH Low": { + "unit": None, + "icon": "mdi:pool", + "name": "pH Low", + "device_class": None, + }, + "pH Status": { + "unit": None, + "icon": "mdi:pool", + "name": "pH Status", + "device_class": None, + }, + "Chlorine Status": { + "unit": None, + "icon": "mdi:pool", + "name": "Chlorine Status", + "device_class": None, + }, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer sensor setup to the shared sensor module.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + PoolSenseSensor(coordinator, config_entry.data[CONF_EMAIL], info_type) + for info_type in SENSORS + ) + + +class PoolSenseSensor(Entity): + """Sensor representing poolsense data.""" + + unique_id = None + + def __init__(self, coordinator, email, info_type): + """Initialize poolsense sensor.""" + self._email = email + self.unique_id = f"{email}-{info_type}" + self.coordinator = coordinator + self.info_type = info_type + + @property + def available(self): + """Return if sensor is available.""" + return self.coordinator.last_update_success + + @property + def name(self): + """Return the name of the particular component.""" + return "PoolSense {}".format(SENSORS[self.info_type]["name"]) + + @property + def should_poll(self): + """Return False, updates are controlled via coordinator.""" + return False + + @property + def state(self): + """State of the sensor.""" + if self.info_type == "pH Status": + if self.coordinator.data[self.info_type] == "red": + return STATE_PROBLEM + return STATE_OK + if self.info_type == "Chlorine Status": + if self.coordinator.data[self.info_type] == "red": + return STATE_PROBLEM + return STATE_OK + return self.coordinator.data[self.info_type] + + @property + def device_class(self): + """Return the device class.""" + return SENSORS[self.info_type]["device_class"] + + @property + def icon(self): + """Return the icon.""" + if self.info_type == "pH Status": + if self.coordinator.data[self.info_type] == "red": + return "mdi:thumb-down" + return "mdi:thumb-up" + if self.info_type == "Chlorine Status": + if self.coordinator.data[self.info_type] == "red": + return "mdi:thumb-down" + return "mdi:thumb-up" + return SENSORS[self.info_type]["icon"] + + @property + def unit_of_measurement(self): + """Return unit of measurement.""" + return SENSORS[self.info_type]["unit"] + + @property + def device_state_attributes(self): + """Return device attributes.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + async def async_update(self): + """Update status of sensor.""" + await self.coordinator.async_request_refresh() + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) diff --git a/homeassistant/components/poolsense/strings.json b/homeassistant/components/poolsense/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..21a1ab393a4d41a34aed7c64e55e5dcdcd5b8864 --- /dev/null +++ b/homeassistant/components/poolsense/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "title": "PoolSense", + "description": "[%key:common::config_flow::description%]", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "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/components/poolsense/translations/en.json b/homeassistant/components/poolsense/translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..92588cbfd3aeed6104324f78c525db99676dc1aa --- /dev/null +++ b/homeassistant/components/poolsense/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "title": "PoolSense", + "description": "Set up PoolSense integration. Register on the dedicated app to get your username and password. Serial is optional.", + "data": { + "email": "Email", + "password": "Password" + } + } + }, + "error": { + "cannot_connect": "Can't connect to PoolSense.", + "invalid_auth": "Invalid authorisation details.", + "unknown": "Unknown Error." + }, + "abort": { + "already_configured": "Device already configured." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 977be4bae875c1a4b84be5aaa74a61d49d74254b..fa95014ee7ac2cfb87f1dff9721705c5588ddcd6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -124,6 +124,7 @@ FLOWS = [ "plugwise", "plum_lightpad", "point", + "poolsense", "powerwall", "ps4", "pvpc_hourly_pricing", diff --git a/requirements_all.txt b/requirements_all.txt index 55e9c617f0b56eea59da4c20e0e63a11e4488e17..97ceb797d8e7c2666fd19eafa84d55249cb9867e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1106,6 +1106,9 @@ pmsensor==0.4 # homeassistant.components.pocketcasts pocketcasts==0.1 +# homeassistant.components.poolsense +poolsense==0.0.5 + # homeassistant.components.reddit praw==6.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index afa1e12433d19019a0bfc9bb8f8fc8195b39f2ac..9605228036409c30a82af38867009e03edb494d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -496,6 +496,9 @@ plumlightpad==0.0.11 # homeassistant.components.serial_pm pmsensor==0.4 +# homeassistant.components.poolsense +poolsense==0.0.5 + # homeassistant.components.reddit praw==6.5.1 diff --git a/tests/components/poolsense/__init__.py b/tests/components/poolsense/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ace3a6997fb85cd0e7b605db4dbfd1b3c9858d6d --- /dev/null +++ b/tests/components/poolsense/__init__.py @@ -0,0 +1 @@ +"""Tests for the PoolSense integration.""" diff --git a/tests/components/poolsense/test_config_flow.py b/tests/components/poolsense/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..4a7b824f22690422ef3369b3d6b9e833e40bbece --- /dev/null +++ b/tests/components/poolsense/test_config_flow.py @@ -0,0 +1,55 @@ +"""Test the PoolSense config flow.""" +from homeassistant import data_entry_flow +from homeassistant.components.poolsense.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from tests.async_mock import patch + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + + +async def test_invalid_credentials(hass): + """Test we handle invalid credentials.""" + with patch( + "poolsense.PoolSense.test_poolsense_credentials", return_value=False, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_EMAIL: "test-email", CONF_PASSWORD: "test-password"}, + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "auth"} + + +async def test_valid_credentials(hass): + """Test we handle invalid credentials.""" + with patch( + "poolsense.PoolSense.test_poolsense_credentials", return_value=True + ), patch( + "homeassistant.components.poolsense.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.poolsense.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_EMAIL: "test-email", CONF_PASSWORD: "test-password"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "test-email" + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1