├── hacs.json ├── custom_components └── poolstation │ ├── const.py │ ├── util.py │ ├── manifest.json │ ├── strings.json │ ├── translations │ └── en.json │ ├── entity.py │ ├── switch.py │ ├── number.py │ ├── binary_sensor.py │ ├── config_flow.py │ ├── __init__.py │ └── sensor.py ├── info.md ├── LICENSE ├── .gitignore └── README.md /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Poolstation", 3 | "country": "ES", 4 | "domains": ["sensor", "number", "switch"], 5 | "homeassistant": "2024.1.0" 6 | } -------------------------------------------------------------------------------- /custom_components/poolstation/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Poolstation integration.""" 2 | from typing import Final 3 | 4 | DOMAIN: Final = "poolstation" 5 | TOKEN: Final = "token" 6 | COORDINATORS: Final = "coordinators" 7 | DEVICES: Final = "devices" 8 | AUTH_RETRIES: Final[int] = 10 9 | -------------------------------------------------------------------------------- /custom_components/poolstation/util.py: -------------------------------------------------------------------------------- 1 | """Shared useful methods for Poolstation integration.""" 2 | from pypoolstation import Account 3 | 4 | def create_account(session, email, password, logger=None): 5 | """Create a pypoolstation.Account object with the given email, password.""" 6 | return Account(session, username=email, password=password, logger=logger) 7 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | ## Poolstation for Home Assistant 2 | 3 | The component uses your usename and password to connect to the Poolstation platform and from the inforation there 4 | creates the devices for your pools, like your chlorinator. 5 | 6 | Using the sensors, numbers and switches provided you can fully control your pool from within home assistant. 7 | This integration uses Cloud Polling and internet access to work. 8 | -------------------------------------------------------------------------------- /custom_components/poolstation/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "poolstation", 3 | "name": "Poolstation", 4 | "config_flow": true, 5 | "documentation": "https://github.com/cibernox/homeassistant-poolstation", 6 | "issue_tracker": "https://github.com/cibernox/homeassistant-poolstation/issues", 7 | "requirements": [ 8 | "pypoolstation==0.5.8" 9 | ], 10 | "codeowners": [ 11 | "@cibernox" 12 | ], 13 | "iot_class": "cloud_polling", 14 | "version": "0.1.5" 15 | } -------------------------------------------------------------------------------- /custom_components/poolstation/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" 5 | }, 6 | "error": { 7 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 8 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", 9 | "unknown": "[%key:common::config_flow::error::unknown%]" 10 | }, 11 | "step": { 12 | "user": { 13 | "title": "Connect to your Poolstation account", 14 | "data": { 15 | "email": "[%key:common::config_flow::data::email%]", 16 | "password": "[%key:common::config_flow::data::password%]" 17 | } 18 | } 19 | }, 20 | } 21 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Miguel Camba 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /custom_components/poolstation/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured", 5 | "reauth_successful": "Credentials updated successfully" 6 | }, 7 | "error": { 8 | "cannot_connect": "Failed to connect", 9 | "invalid_auth": "Invalid authentication", 10 | "unknown": "Unexpected error" 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "email": "Email", 16 | "password": "Password" 17 | }, 18 | "description": "Do you want to start set up?", 19 | "title": "Connect to your Poolstation account" 20 | }, 21 | "reauth_confirm": { 22 | "data": { 23 | "email": "Email", 24 | "password": "Password" 25 | }, 26 | "description": "Please update your credentials", 27 | "title": "Authentication with Poolstation failed" 28 | } 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /custom_components/poolstation/entity.py: -------------------------------------------------------------------------------- 1 | """Base class for Poolstation entity.""" 2 | from __future__ import annotations 3 | 4 | from pypoolstation import Pool 5 | 6 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 7 | 8 | from . import PoolstationDataUpdateCoordinator 9 | from .const import DOMAIN 10 | 11 | 12 | class PoolEntity(CoordinatorEntity): 13 | """Representation of a pool entity.""" 14 | 15 | coordinator: PoolstationDataUpdateCoordinator 16 | 17 | def __init__( 18 | self, 19 | pool: Pool, 20 | coordinator: PoolstationDataUpdateCoordinator, 21 | entity_suffix: str, 22 | ) -> None: 23 | """Init from config, hookup pool and coordinator.""" 24 | super().__init__(coordinator) 25 | self.pool = pool 26 | 27 | pool_id = self.pool.id 28 | name = self.pool.alias 29 | 30 | self._attr_name = f"{name}{entity_suffix}" 31 | self._attr_unique_id = f"{pool_id}{entity_suffix}" 32 | self._attr_device_info = {"name": name, "identifiers": {(DOMAIN, pool_id)}} 33 | 34 | @property 35 | def available(self) -> bool: 36 | """Return if the entity is available.""" 37 | return super().available # for now, IDK if I can tell or not. 38 | -------------------------------------------------------------------------------- /custom_components/poolstation/switch.py: -------------------------------------------------------------------------------- 1 | """Support for Poolstation switches.""" 2 | from __future__ import annotations 3 | 4 | from typing import Any 5 | 6 | from pypoolstation import Pool, Relay 7 | 8 | from homeassistant.components.switch import SwitchEntity 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.core import HomeAssistant, callback 11 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 12 | 13 | from . import PoolstationDataUpdateCoordinator 14 | from .const import COORDINATORS, DEVICES, DOMAIN 15 | from .entity import PoolEntity 16 | 17 | 18 | async def async_setup_entry( 19 | hass: HomeAssistant, 20 | config_entry: ConfigEntry, 21 | async_add_entities: AddEntitiesCallback, 22 | ) -> None: 23 | """Set up the pool relays.""" 24 | pools = hass.data[DOMAIN][config_entry.entry_id][DEVICES] 25 | coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] 26 | entities = [] 27 | for pool_id, pool in pools.items(): 28 | coordinator = coordinators[pool_id] 29 | for relay in pool.relays: 30 | entities.append(PoolRelaySwitch(pool, coordinator, relay)) 31 | 32 | async_add_entities(entities) 33 | 34 | 35 | class PoolRelaySwitch(PoolEntity, SwitchEntity): 36 | """Representation of a pool relay switch.""" 37 | 38 | def __init__( 39 | self, pool: Pool, coordinator: PoolstationDataUpdateCoordinator, relay: Relay 40 | ) -> None: 41 | """Initialize the pool relay switch.""" 42 | super().__init__(pool, coordinator, f" Relay {relay.name}") 43 | self.relay = relay 44 | self._attr_is_on = self.relay.active 45 | 46 | async def async_turn_on(self, **kwargs: Any) -> None: 47 | """Turn the relay on.""" 48 | 49 | self._attr_is_on = await self.relay.set_active(True) 50 | self.async_write_ha_state() 51 | 52 | async def async_turn_off(self, **kwargs: Any) -> None: 53 | """Turn the relay off.""" 54 | self._attr_is_on = await self.relay.set_active(False) 55 | self.async_write_ha_state() 56 | 57 | @callback 58 | def _handle_coordinator_update(self) -> None: 59 | """Handle updated data from the coordinator.""" 60 | self._attr_is_on = self.relay.active 61 | self.async_write_ha_state() -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | .idea -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) 2 | 3 | # Poolstation integration for Home Assistant 4 | 5 | ## What does it do? 6 | 7 | This [Home Assistant](https://home-assistant.io/) custom component will integrate devices that connect to the poolstation domotic platform 8 | for pool equipment like smart chlorinators of brands of the fluidra group like Idegis or Astral Pool. 9 | It is very possible that this integration will also work with devices integrating in the Fluidra Connect platform with minimal changes but 10 | i couldn't test it myself. 11 | 12 | ## Instalation with HACS 13 | Once you have HACS installed in your home assistant, add `git@github.com:cibernox/homeassistant-poolstation.git` as a custom repository 14 | for the category `Integrations`. 15 | 16 | ![HACS-add-poolstation](https://github.com/cibernox/homeassistant-poolstation/assets/265339/98ca21ee-5a01-454b-98b7-55c2c1fa4bf3) 17 | 18 | After it's done (it might take a moment to refresh the list of integrations) you should be able to add this integration to your home assistant. 19 | 20 | ## Current features 21 | 22 | After connecting to the poolstation API with your email and password, it will create as many devices as pools you have in your account. 23 | 24 | Each pool contains many entities: 25 | 26 | - Water temperature (sensor: read only) 27 | - Current PH level (sensor: read only) 28 | - Salt concentration (sensor: read only) 29 | - Electrolysis production (sensor: read only) 30 | - Target PH level (number: read/write) 31 | - Target electrolysis production (number: read/write) 32 | - Relays (switch) (The number of relays depends on your model, mine has 4 relays: Pump, Light, Watering and Waterfall. The last three relays can be used for anything you want, those are just the default names, but a relay is a relay) 33 | - Current ORP (sensor: read only) 34 | - Target ORP (number: read/write) 35 | - Current free chlorine (sensor: read only) 36 | - Target free chlorine (number: read/write) 37 | - Binary inputs (binary sensor: read only) (Depends on the model, mine has 4 which I don't use.) 38 | 39 | 40 | ## What can I do with it? 41 | 42 | First, you don't have to use the web or ios/android app to turn on or off your pool, check the water temperature or adjust any parameter, you can do it from home assistant like the rest of your home. With some very nice UI if you want to spend some time: 43 | 44 | ![UI widget](https://user-images.githubusercontent.com/265339/132487373-dd1b9bdd-949e-44f2-b26d-27f126aa0681.jpg) 45 | 46 | Also, you can create any kind of automations. Personally I prefer to schedule working times of the pump from home assistant with the scheduler card that from the poolstation platform. 47 | 48 | You can configure notification when some value gets out of range. I turn my filtration on only when I have excess solar production from my solar panels to save energy. 49 | -------------------------------------------------------------------------------- /custom_components/poolstation/number.py: -------------------------------------------------------------------------------- 1 | """Support for Poolstation numbers.""" 2 | from __future__ import annotations 3 | 4 | from collections.abc import Awaitable, Callable 5 | from dataclasses import dataclass 6 | from typing import Any 7 | 8 | from pypoolstation import Pool 9 | 10 | from homeassistant.components.number import ( 11 | NumberDeviceClass, 12 | NumberEntity, 13 | NumberEntityDescription, 14 | ) 15 | from homeassistant.config_entries import ConfigEntry 16 | from homeassistant.const import PERCENTAGE 17 | from homeassistant.core import HomeAssistant 18 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 19 | 20 | from . import PoolstationDataUpdateCoordinator 21 | from .const import COORDINATORS, DEVICES, DOMAIN 22 | from .entity import PoolEntity 23 | 24 | 25 | @dataclass 26 | class PoolstationNumberEntityDescriptionMixin: 27 | """Mixin for required keys.""" 28 | 29 | value_fn: Callable[[Pool], int | float] 30 | set_value_fn: Callable[[Pool, int | float], Awaitable[Any]] 31 | 32 | 33 | @dataclass 34 | class PoolstationNumberEntityDescription( 35 | NumberEntityDescription, PoolstationNumberEntityDescriptionMixin 36 | ): 37 | """Class describing Poolstation number entities.""" 38 | 39 | 40 | MIN_PH = 6.0 41 | MAX_PH = 8.0 42 | 43 | MIN_ORP = 600 44 | MAX_ORP = 850 45 | 46 | MIN_CHLORINE = 0.30 47 | MAX_CHLORINE = 3.50 48 | 49 | if hasattr(NumberDeviceClass, "PH"): 50 | TARGET_PH_DESCRIPTION = PoolstationNumberEntityDescription( 51 | key="target_ph", 52 | name="Target PH", 53 | native_max_value=MAX_PH, 54 | native_min_value=MIN_PH, 55 | device_class=NumberDeviceClass.PH, 56 | native_step=0.01, 57 | value_fn=lambda pool: pool.target_ph, 58 | set_value_fn=lambda pool, value: pool.set_target_ph(value), 59 | ) 60 | else: 61 | TARGET_PH_DESCRIPTION = PoolstationNumberEntityDescription( 62 | key="target_ph", 63 | name="Target PH", 64 | native_max_value=MAX_PH, 65 | native_min_value=MIN_PH, 66 | native_step=0.01, 67 | value_fn=lambda pool: pool.target_ph, 68 | set_value_fn=lambda pool, value: pool.set_target_ph(value), 69 | ) 70 | 71 | ENTITY_DESCRIPTIONS = ( 72 | TARGET_PH_DESCRIPTION, 73 | PoolstationNumberEntityDescription( 74 | key="target_orp", 75 | name="Target ORP", 76 | icon="mdi:gauge", 77 | native_max_value=MAX_ORP, 78 | native_min_value=MIN_ORP, 79 | device_class=NumberDeviceClass.VOLTAGE, 80 | native_step=1, 81 | value_fn=lambda pool: pool.target_orp, 82 | set_value_fn=lambda pool, value: pool.set_target_orp(int(value)), 83 | ), 84 | PoolstationNumberEntityDescription( 85 | key="target_chlorine", 86 | name="Target Chlorine", 87 | icon="mdi:gauge", 88 | native_max_value=MAX_CHLORINE, 89 | native_min_value=MIN_CHLORINE, 90 | device_class=NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, 91 | native_step=0.01, 92 | value_fn=lambda pool: pool.target_clppm, 93 | set_value_fn=lambda pool, value: pool.set_target_clppm(value), 94 | ), 95 | PoolstationNumberEntityDescription( 96 | key="target_production", 97 | name="Target Production", 98 | icon="mdi:gauge", 99 | native_max_value=100, 100 | native_min_value=0, 101 | native_step=1, 102 | native_unit_of_measurement=PERCENTAGE, 103 | value_fn=lambda pool: pool.target_percentage_electrolysis, 104 | set_value_fn=lambda pool, value: pool.set_target_percentage_electrolysis( 105 | int(value) 106 | ), 107 | ), 108 | ) 109 | 110 | 111 | async def async_setup_entry( 112 | hass: HomeAssistant, 113 | config_entry: ConfigEntry, 114 | async_add_entities: AddEntitiesCallback, 115 | ) -> None: 116 | """Set up the pool numbers.""" 117 | pools = hass.data[DOMAIN][config_entry.entry_id][DEVICES] 118 | coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] 119 | entities: list[PoolEntity] = [] 120 | for pool_id, pool in pools.items(): 121 | coordinator = coordinators[pool_id] 122 | for description in ENTITY_DESCRIPTIONS: 123 | entities.append(PoolNumberEntity(pool, coordinator, description)) 124 | 125 | async_add_entities(entities) 126 | 127 | 128 | class PoolNumberEntity(PoolEntity, NumberEntity): 129 | """Representation of a pool number entity.""" 130 | 131 | entity_description: PoolstationNumberEntityDescription 132 | 133 | def __init__( 134 | self, 135 | pool: Pool, 136 | coordinator: PoolstationDataUpdateCoordinator, 137 | description: PoolstationNumberEntityDescription, 138 | ) -> None: 139 | """Initialize the pool's target PH.""" 140 | super().__init__(pool, coordinator, " " + description.name) 141 | self.entity_description = description 142 | 143 | @property 144 | def native_value(self) -> float: 145 | """Return the number value.""" 146 | return self.entity_description.value_fn(self.coordinator.pool) 147 | 148 | async def async_set_native_value(self, value: float) -> None: 149 | """Change to new number value.""" 150 | await self.entity_description.set_value_fn(self.coordinator.pool, value) 151 | self.async_write_ha_state() 152 | -------------------------------------------------------------------------------- /custom_components/poolstation/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Support for Poolstation binary sensors.""" 2 | from __future__ import annotations 3 | 4 | from collections.abc import Callable 5 | from dataclasses import dataclass 6 | 7 | from pypoolstation import Pool 8 | 9 | from homeassistant.components.binary_sensor import ( 10 | BinarySensorDeviceClass, 11 | BinarySensorEntity, 12 | BinarySensorEntityDescription, 13 | ) 14 | from homeassistant.config_entries import ConfigEntry 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.const import EntityCategory 17 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 18 | 19 | from . import PoolstationDataUpdateCoordinator 20 | from .const import COORDINATORS, DEVICES, DOMAIN 21 | from .entity import PoolEntity 22 | 23 | 24 | @dataclass 25 | class PoolstationentityDescriptionMixin: 26 | """Mixin values for Poolstation entities.""" 27 | 28 | is_on_fn: Callable[[Pool], bool] 29 | has_fn: Callable[[Pool], bool] 30 | 31 | @dataclass 32 | class PoolstationBinarySensorEntityDescription( 33 | BinarySensorEntityDescription, PoolstationentityDescriptionMixin 34 | ): 35 | """Class describing Poolstation binary sensor entities.""" 36 | 37 | ENTITY_DESCRIPTIONS = ( 38 | PoolstationBinarySensorEntityDescription( 39 | key="water_flow", 40 | name="Water Flow", 41 | device_class=BinarySensorDeviceClass.PROBLEM, 42 | is_on_fn=lambda pool: pool.waterflow_problem, 43 | has_fn=lambda pool: pool.waterflow_problem is not None, 44 | ), 45 | PoolstationBinarySensorEntityDescription( 46 | key="binary_input_1", 47 | name="Digital input 1", 48 | is_on_fn=lambda pool: pool.binary_input_1, 49 | has_fn=lambda pool: pool.binary_input_1 is not None, 50 | ), 51 | PoolstationBinarySensorEntityDescription( 52 | key="binary_input_2", 53 | name="Digital input 2", 54 | is_on_fn=lambda pool: pool.binary_input_2, 55 | has_fn=lambda pool: pool.binary_input_2 is not None, 56 | ), 57 | PoolstationBinarySensorEntityDescription( 58 | key="binary_input_3", 59 | name="Digital input 3", 60 | is_on_fn=lambda pool: pool.binary_input_3, 61 | has_fn=lambda pool: pool.binary_input_3 is not None, 62 | ), 63 | PoolstationBinarySensorEntityDescription( 64 | key="binary_input_4", 65 | name="Digital input 4", 66 | is_on_fn=lambda pool: pool.binary_input_4, 67 | has_fn=lambda pool: pool.binary_input_4 is not None, 68 | ), 69 | PoolstationBinarySensorEntityDescription( 70 | key="uv_available", 71 | name="UV Available", 72 | is_on_fn=lambda pool: pool.uv_available, 73 | has_fn=lambda pool: pool.uv_available is not None, 74 | ), 75 | PoolstationBinarySensorEntityDescription( 76 | key="uv_enabled", 77 | name="UV Enabled", 78 | is_on_fn=lambda pool: pool.uv_enabled, 79 | has_fn=lambda pool: pool.uv_enabled is not None, 80 | ), 81 | PoolstationBinarySensorEntityDescription( 82 | key="uv_light", 83 | name="UV Light", 84 | is_on_fn=lambda pool: pool.uv_on, 85 | has_fn=lambda pool: pool.uv_on is not None, 86 | ), 87 | PoolstationBinarySensorEntityDescription( 88 | key="uv_ballast", 89 | name="UV Ballast", 90 | device_class=BinarySensorDeviceClass.PROBLEM, 91 | entity_category=EntityCategory.DIAGNOSTIC, 92 | is_on_fn=lambda pool: pool.uv_ballast_problem, 93 | has_fn=lambda pool: pool.uv_ballast_problem is not None, 94 | ), 95 | PoolstationBinarySensorEntityDescription( 96 | key="uv_fuse", 97 | name="UV Fuse", 98 | device_class=BinarySensorDeviceClass.PROBLEM, 99 | entity_category=EntityCategory.DIAGNOSTIC, 100 | is_on_fn=lambda pool: pool.uv_fuse_problem, 101 | has_fn=lambda pool: pool.uv_fuse_problem is not None, 102 | ), 103 | ) 104 | 105 | 106 | async def async_setup_entry( 107 | hass: HomeAssistant, 108 | config_entry: ConfigEntry, 109 | async_add_entities: AddEntitiesCallback, 110 | ) -> None: 111 | """Set up the pool binary sensors.""" 112 | pools = hass.data[DOMAIN][config_entry.entry_id][DEVICES] 113 | coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] 114 | entities: list[PoolEntity] = [] 115 | for pool_id, pool in pools.items(): 116 | coordinator = coordinators[pool_id] 117 | for description in ENTITY_DESCRIPTIONS: 118 | entities.append(PoolBinarySensorEntity(pool, coordinator, description)) 119 | 120 | async_add_entities(entities) 121 | 122 | 123 | 124 | class PoolBinarySensorEntity(PoolEntity, BinarySensorEntity): 125 | """Defines a Poolstation binary sensor entity.""" 126 | 127 | entity_description: PoolstationBinarySensorEntityDescription 128 | 129 | def __init__( 130 | self, 131 | pool: Pool, 132 | coordinator: PoolstationDataUpdateCoordinator, 133 | description: PoolstationBinarySensorEntityDescription, 134 | ) -> None: 135 | """Initialize the pool binary sensor""" 136 | super().__init__(pool, coordinator, " " + description.name) 137 | self.entity_description = description 138 | 139 | 140 | @property 141 | def is_on(self) -> bool: 142 | """Return the state of the binary sensor.""" 143 | return self.entity_description.is_on_fn(self.coordinator.pool) 144 | -------------------------------------------------------------------------------- /custom_components/poolstation/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for PoolStation integration.""" 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | import logging 6 | from typing import Any, Final 7 | 8 | from aiohttp import ClientResponseError, DummyCookieJar 9 | from pypoolstation import AuthenticationException 10 | import voluptuous as vol 11 | 12 | from homeassistant import config_entries 13 | from homeassistant.const import CONF_EMAIL, CONF_PASSWORD 14 | from homeassistant.data_entry_flow import FlowResult 15 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 16 | 17 | from .const import DOMAIN, TOKEN 18 | from .util import create_account 19 | 20 | _LOGGER: Final = logging.getLogger(__name__) 21 | 22 | DATA_SCHEMA: Final = vol.Schema( 23 | { 24 | vol.Required(CONF_EMAIL): str, 25 | vol.Required(CONF_PASSWORD): str, 26 | } 27 | ) 28 | 29 | 30 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 31 | """Handle a config flow for Poolstation.""" 32 | 33 | VERSION = 1 34 | 35 | def __init__(self) -> None: 36 | """Initialize config flow.""" 37 | super().__init__() 38 | self._original_data: Any = None 39 | 40 | async def async_step_user( 41 | self, user_input: dict[str, Any] | None = None 42 | ) -> FlowResult: 43 | """Handle the initial step.""" 44 | if user_input is None: 45 | return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) 46 | return await self._attempt_login(user_input) 47 | 48 | async def async_step_reauth(self, user_input: dict[str, Any]) -> FlowResult: 49 | """Perform reauth upon an API authentication error.""" 50 | self._original_data = user_input.copy() 51 | return await self.async_step_reauth_confirm() 52 | 53 | async def async_step_reauth_confirm( 54 | self, user_input: dict[str, Any] | None = None 55 | ) -> FlowResult: 56 | """Dialog that informs the user that reauth is required.""" 57 | if not user_input: 58 | return self._show_reauth_confirm_form() 59 | 60 | account = self._create_account(user_input) 61 | errors: dict[str, str] 62 | errors = {} 63 | try: 64 | token = await account.login() 65 | except (asyncio.TimeoutError, ClientResponseError): 66 | errors["base"] = "cannot_connect" 67 | except AuthenticationException: 68 | errors["base"] = "invalid_auth" 69 | except Exception: # pylint: disable=broad-except 70 | _LOGGER.exception("Unexpected exception") 71 | errors["base"] = "unknown" 72 | else: 73 | existing_entry = await self.async_set_unique_id( 74 | self._original_data[CONF_EMAIL].lower() 75 | ) 76 | if existing_entry: 77 | self.hass.config_entries.async_update_entry( 78 | existing_entry, 79 | data={ 80 | TOKEN: token, 81 | CONF_EMAIL: user_input[CONF_EMAIL], 82 | CONF_PASSWORD: user_input[CONF_PASSWORD], 83 | }, 84 | ) 85 | await self.hass.config_entries.async_reload(existing_entry.entry_id) 86 | return self.async_abort(reason="reauth_successful") 87 | return self.async_abort(reason="reauth_failed_existing") 88 | return self.async_abort(reason="reauth_unsuccessful") 89 | 90 | def _create_account(self, user_input): 91 | session = async_create_clientsession(self.hass, cookie_jar=DummyCookieJar()) 92 | return create_account( 93 | session, user_input[CONF_EMAIL], user_input[CONF_PASSWORD], _LOGGER 94 | ) 95 | 96 | async def _attempt_login(self, user_input): 97 | errors: dict[str, str] 98 | errors = {} 99 | account = self._create_account(user_input) 100 | 101 | try: 102 | token = await account.login() 103 | except (TimeoutError, ClientResponseError): 104 | errors["base"] = "cannot_connect" 105 | except AuthenticationException: 106 | errors["base"] = "invalid_auth" 107 | except Exception: # pylint: disable=broad-except 108 | _LOGGER.exception("Unexpected exception") 109 | errors["base"] = "unknown" 110 | else: 111 | await self.async_set_unique_id(user_input[CONF_EMAIL].lower()) 112 | self._abort_if_unique_id_configured() 113 | return self.async_create_entry( 114 | title=user_input[CONF_EMAIL].lower(), 115 | data={ 116 | TOKEN: token, 117 | CONF_EMAIL: user_input[CONF_EMAIL], 118 | CONF_PASSWORD: user_input[CONF_PASSWORD], 119 | }, 120 | ) 121 | 122 | return self.async_show_form( 123 | step_id="user", data_schema=DATA_SCHEMA, errors=errors 124 | ) 125 | 126 | def _show_reauth_confirm_form( 127 | self, errors: dict[str, Any] | None = None 128 | ) -> FlowResult: 129 | """Show the API keys form.""" 130 | return self.async_show_form( 131 | step_id="reauth_confirm", 132 | data_schema=vol.Schema( 133 | { 134 | vol.Required( 135 | CONF_EMAIL, default=self._original_data[CONF_EMAIL] 136 | ): str, 137 | vol.Required(CONF_PASSWORD): str, 138 | } 139 | ), 140 | errors=errors or {}, 141 | ) 142 | -------------------------------------------------------------------------------- /custom_components/poolstation/__init__.py: -------------------------------------------------------------------------------- 1 | """The Poolstation integration.""" 2 | from datetime import timedelta 3 | import logging 4 | from typing import Final 5 | 6 | import aiohttp 7 | from pypoolstation import Account, AuthenticationException, Pool 8 | 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady 13 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 14 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 15 | from aiohttp import ClientError, ClientResponseError 16 | 17 | from .const import COORDINATORS, DEVICES, DOMAIN, AUTH_RETRIES 18 | from .util import create_account 19 | 20 | PLATFORMS: Final = ["sensor", "number", "switch", "binary_sensor"] 21 | 22 | _LOGGER: Final = logging.getLogger(__name__) 23 | 24 | SCAN_INTERVAL: Final = timedelta(seconds=60) 25 | 26 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 27 | """Set up Poolstation from a config entry.""" 28 | session = async_create_clientsession(hass, cookie_jar=aiohttp.DummyCookieJar()) 29 | account = Account(session, token=entry.data[CONF_TOKEN], logger=_LOGGER) 30 | 31 | _LOGGER.info("Pool station setup init.") 32 | 33 | try: 34 | pools = await Pool.get_all_pools(session, account=account) 35 | except aiohttp.ClientError as err: 36 | _LOGGER.warning("Pool station Client error: %s", err) 37 | await session.close() # Ensure session is closed on error 38 | raise ConfigEntryNotReady from err 39 | 40 | except AuthenticationException as err: 41 | _LOGGER.warning("Pool station Auth error: %s", err) 42 | account = create_account( 43 | session, entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD], _LOGGER 44 | ) 45 | try: 46 | token = await account.login() 47 | except AuthenticationException as login_err: 48 | _LOGGER.warning("Pool station Auth retry error: %s", login_err) 49 | # Unfortunately the poolstation API is crap and logging with wrong credentials returns a 500 instead of a 401 50 | # That's why this block is probably never being called. Instead the next except will. 51 | raise ConfigEntryAuthFailed from login_err 52 | except aiohttp.ClientResponseError as response_err: 53 | _LOGGER.warning("Pool station Client retry error: %s", response_err) 54 | raise ConfigEntryAuthFailed from response_err 55 | else: 56 | hass.config_entries.async_update_entry( 57 | entry, 58 | data={ 59 | CONF_TOKEN: token, 60 | CONF_EMAIL: entry.data[CONF_EMAIL], 61 | CONF_PASSWORD: entry.data[CONF_PASSWORD], 62 | }, 63 | ) 64 | pools = await Pool.get_all_pools(session, account=account) 65 | 66 | hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { 67 | COORDINATORS: {}, 68 | DEVICES: {}, 69 | } 70 | 71 | for pool in pools: 72 | pool_id = pool.id 73 | coordinator = PoolstationDataUpdateCoordinator(hass, pool) 74 | await coordinator.async_config_entry_first_refresh() 75 | 76 | hass.data[DOMAIN][entry.entry_id][DEVICES][pool_id] = pool 77 | hass.data[DOMAIN][entry.entry_id][COORDINATORS][pool_id] = coordinator 78 | 79 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 80 | 81 | return True 82 | 83 | 84 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 85 | """Unload a config entry.""" 86 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 87 | if unload_ok: 88 | hass.data[DOMAIN].pop(entry.entry_id) 89 | 90 | return unload_ok 91 | 92 | 93 | class PoolstationDataUpdateCoordinator(DataUpdateCoordinator): 94 | """Class to manage fetching Poolstation device info.""" 95 | 96 | def __init__(self, hass: HomeAssistant, pool: Pool) -> None: 97 | """Initialize global Poolstation data updater.""" 98 | self.pool = pool 99 | self.auth_retries = AUTH_RETRIES # Initialize auth_retries here 100 | super().__init__( 101 | hass, 102 | _LOGGER, 103 | name=f"{DOMAIN}-{pool.alias}", 104 | update_interval=SCAN_INTERVAL, 105 | ) 106 | 107 | async def _async_update_data(self) -> dict | None: 108 | """Fetch data from poolstation.net.""" 109 | _LOGGER.debug("Starting data update for pool: %s (auth_retries: %d)", self.pool.alias, self.auth_retries) 110 | try: 111 | await self.pool.sync_info() 112 | _LOGGER.debug("Successfully updated pool data for: %s (auth_retries: %d)", self.pool.alias, self.auth_retries) 113 | # reset counter 114 | self.auth_retries = AUTH_RETRIES 115 | except ClientResponseError as err: 116 | # ignore the error, most likely a server side timeout 117 | _LOGGER.warning("ClientResponse error while retrieving data for pool %s: %s", self.pool.alias, err) 118 | except AuthenticationException as err: 119 | if self.auth_retries > 0: 120 | self.auth_retries -= 1 121 | _LOGGER.warning("Ignore authentication error for pool %s (auth_retries: %d): %s", self.pool.alias, self.auth_retries, err) 122 | else: 123 | _LOGGER.warning("Max retries (%d) reached for pool %s, raising authentication error: %s", AUTH_RETRIES, self.pool.alias, err) 124 | raise ConfigEntryAuthFailed from err -------------------------------------------------------------------------------- /custom_components/poolstation/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for Poolstation sensors.""" 2 | from __future__ import annotations 3 | 4 | from collections.abc import Callable 5 | from dataclasses import dataclass 6 | 7 | from pypoolstation import Pool 8 | 9 | from homeassistant.components.sensor import ( 10 | SensorDeviceClass, 11 | SensorEntity, 12 | SensorEntityDescription, 13 | SensorStateClass, 14 | ) 15 | from homeassistant.config_entries import ConfigEntry 16 | from homeassistant.const import PERCENTAGE, UnitOfTemperature 17 | from homeassistant.core import HomeAssistant 18 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 19 | 20 | from . import PoolstationDataUpdateCoordinator 21 | from .const import COORDINATORS, DEVICES, DOMAIN 22 | from .entity import PoolEntity 23 | 24 | 25 | @dataclass 26 | class PoolstationEntityDescriptionMixin: 27 | """Mixin values for Poolstation entities.""" 28 | 29 | value_fn: Callable[[Pool], int | str] 30 | 31 | 32 | @dataclass 33 | class PoolstationSensorEntityDescription( 34 | SensorEntityDescription, PoolstationEntityDescriptionMixin 35 | ): 36 | """Class describing Poolstation sensor entities.""" 37 | 38 | has_fn: Callable[[Pool], bool] = lambda _: True 39 | 40 | 41 | if hasattr(SensorDeviceClass, "PH"): 42 | PH_SENSOR_DESCRIPTION = PoolstationSensorEntityDescription( 43 | key="pH", 44 | name="pH", 45 | device_class=SensorDeviceClass.PH, 46 | state_class=SensorStateClass.MEASUREMENT, 47 | value_fn=lambda pool: pool.current_ph, 48 | has_fn=lambda pool: pool.current_ph is not None, 49 | ) 50 | else: 51 | PH_SENSOR_DESCRIPTION = PoolstationSensorEntityDescription( 52 | key="pH", 53 | name="pH", 54 | state_class=SensorStateClass.MEASUREMENT, 55 | value_fn=lambda pool: pool.current_ph, 56 | has_fn=lambda pool: pool.current_ph is not None, 57 | ) 58 | 59 | ENTITY_DESCRIPTIONS = ( 60 | PH_SENSOR_DESCRIPTION, 61 | PoolstationSensorEntityDescription( 62 | key="temperature", 63 | name="Temperature", 64 | icon="mdi:coolant-temperature", 65 | state_class=SensorStateClass.MEASUREMENT, 66 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 67 | value_fn=lambda pool: pool.temperature, 68 | has_fn=lambda pool: pool.temperature is not None, 69 | ), 70 | PoolstationSensorEntityDescription( 71 | key="salt_concentration", 72 | name="Salt Concentration", 73 | icon="mdi:shaker", 74 | native_unit_of_measurement="gr/l", 75 | state_class=SensorStateClass.MEASUREMENT, 76 | value_fn=lambda pool: pool.salt_concentration, 77 | has_fn=lambda pool: pool.salt_concentration is not None, 78 | ), 79 | PoolstationSensorEntityDescription( 80 | key="percentage_electrolysis", 81 | name="Electrolysis", 82 | native_unit_of_measurement=PERCENTAGE, 83 | state_class=SensorStateClass.MEASUREMENT, 84 | icon="mdi:water-percent", 85 | value_fn=lambda pool: pool.percentage_electrolysis, 86 | has_fn=lambda pool: pool.percentage_electrolysis is not None, 87 | ), 88 | PoolstationSensorEntityDescription( 89 | key="current_orp", 90 | name="ORP", 91 | icon="mdi:atom", 92 | device_class=SensorDeviceClass.VOLTAGE, 93 | state_class=SensorStateClass.MEASUREMENT, 94 | native_unit_of_measurement="mV", 95 | value_fn=lambda pool: pool.current_orp, 96 | has_fn=lambda pool: pool.current_orp is not None, 97 | ), 98 | PoolstationSensorEntityDescription( 99 | key="free_chlorine", 100 | name="Chlorine", 101 | icon="mdi:cup-water", 102 | device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, 103 | state_class=SensorStateClass.MEASUREMENT, 104 | native_unit_of_measurement="ppm", 105 | value_fn=lambda pool: pool.current_clppm, 106 | has_fn=lambda pool: pool.current_clppm is not None, 107 | ), 108 | PoolstationSensorEntityDescription( 109 | key="uv_current_timer", 110 | name="UV Current Timer", 111 | icon="mdi:clock", 112 | device_class=SensorDeviceClass.DURATION, 113 | state_class=SensorStateClass.MEASUREMENT, 114 | native_unit_of_measurement="h", 115 | value_fn=lambda pool: pool.current_uv_timer, 116 | has_fn=lambda pool: pool.current_uv_timer is not None, 117 | ), 118 | PoolstationSensorEntityDescription( 119 | key="uv_total_timer", 120 | name="UV Total Timer", 121 | icon="mdi:clock", 122 | device_class=SensorDeviceClass.DURATION, 123 | state_class=SensorStateClass.MEASUREMENT, 124 | native_unit_of_measurement="h", 125 | value_fn=lambda pool: pool.total_uv_timer, 126 | has_fn=lambda pool: pool.total_uv_timer is not None, 127 | ) 128 | ) 129 | 130 | 131 | class PoolSensorEntity(PoolEntity, SensorEntity): 132 | """Representation of a pool sensor.""" 133 | 134 | entity_description: PoolstationSensorEntityDescription 135 | 136 | def __init__( 137 | self, 138 | pool: Pool, 139 | coordinator: PoolstationDataUpdateCoordinator, 140 | description: PoolstationSensorEntityDescription, 141 | ) -> None: 142 | """Initialize the pool's target PH.""" 143 | super().__init__(pool, coordinator, " " + description.name) 144 | self.entity_description = description 145 | 146 | @property 147 | def native_value(self) -> str | int: 148 | """Return the sensor value.""" 149 | return self.entity_description.value_fn(self.coordinator.pool) 150 | 151 | 152 | async def async_setup_entry( 153 | hass: HomeAssistant, 154 | config_entry: ConfigEntry, 155 | async_add_entities: AddEntitiesCallback, 156 | ) -> None: 157 | """Set up the poolstation sensors.""" 158 | stations = hass.data[DOMAIN][config_entry.entry_id][DEVICES] 159 | coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] 160 | entities: list[PoolEntity] = [] 161 | for pool_id, pool in stations.items(): 162 | coordinator = coordinators[pool_id] 163 | for description in ENTITY_DESCRIPTIONS: 164 | entities.append(PoolSensorEntity(pool, coordinator, description)) 165 | 166 | async_add_entities(entities) 167 | --------------------------------------------------------------------------------