Skip to content

Commit

Permalink
Merge pull request #30 from robert-alfaro/feature/config-flow-support
Browse files Browse the repository at this point in the history
Add Config Flow support
  • Loading branch information
rsnodgrass authored Oct 12, 2024
2 parents 85dc5d5 + 0ba49b7 commit c891612
Show file tree
Hide file tree
Showing 9 changed files with 346 additions and 148 deletions.
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,15 @@ Make sure [Home Assistant Community Store (HACS)](https://github.com/custom-comp

### Configuration

Under Settings of the Pool Math iOS or Android application, find the Sharing section. Turn this on, which allows anyone with access to the unique URL to be able to view data about your pool. Your pool's URL will be displayed, use that in the YAML configuration for the poolmath sensor.
Under Settings of the Pool Math iOS or Android application, find the Sharing section. Turn this on, which allows anyone with access to the unique URL to be able to view data about your pool. Your pool's URL will be displayed. The Share ID from the URL will be used to configure the poolmath service.

```yaml
sensor:
- platform: poolmath
url: https://api.poolmathapp.com/share/7WPG8yL.json
```
Example URL: `https://troublefreepool.com/mypool/7WPG8yL`

Example Share ID: `7WPG8yL`

Configure `Pool Math (Trouble Free Pool)` via integrations page or press the blue button below.

[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=poolmath)

NOTE: This updates the state from PoolMath every 2 minutes to keep from overwhelming their service, as the majority of Pool Math users update their data manual after testing rather than automated. The check interval can be changed in yaml config by adding a 'scan_interval' for the sensor.

Expand Down
59 changes: 59 additions & 0 deletions custom_components/poolmath/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,60 @@
"""Integration with Pool Math by Trouble Free Pool"""

import logging

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady

from .const import CONF_SHARE_ID, CONF_TARGET, CONF_TIMEOUT, DOMAIN

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Pool Math from a config entry."""

# prefer options
share_id = entry.options.get(CONF_SHARE_ID, entry.data[CONF_SHARE_ID])
name = entry.options.get(CONF_NAME, entry.data[CONF_NAME])
timeout = entry.options.get(CONF_TIMEOUT, entry.data[CONF_TIMEOUT])
target = entry.options.get(CONF_TARGET, entry.data[CONF_TARGET])

# store options
hass.config_entries.async_update_entry(
entry,
options={
CONF_SHARE_ID: share_id,
CONF_NAME: name,
CONF_TIMEOUT: timeout,
CONF_TARGET: target,
},
)

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {}

# listen for options updates
entry.async_on_unload(entry.add_update_listener(async_reload_entry))

# forward entry setup to platform(s)
await hass.config_entries.async_forward_entry_setup(entry, Platform.SENSOR)

return True


async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle an options update."""
await hass.config_entries.async_reload(entry.entry_id)


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a Pool Math config entry."""

unload_ok = await hass.config_entries.async_unload_platforms(
entry, [Platform.SENSOR]
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)

return unload_ok
94 changes: 41 additions & 53 deletions custom_components/poolmath/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,66 +14,50 @@

LOG = logging.getLogger(__name__)

DEFAULT_POOL_ID = 'unknown'

KNOWN_SENSOR_KEYS = [
'fc',
'cc',
'cya',
'ch',
'ph',
'ta',
'salt',
'bor',
'tds',
'csi',
'waterTemp',
'flowRate',
'pressure',
'swgCellPercent',
"fc",
"cc",
"cya",
"ch",
"ph",
"ta",
"salt",
"bor",
"tds",
"csi",
"waterTemp",
"flowRate",
"pressure",
"swgCellPercent",
]

ONLY_INCLUDE_IF_TRACKED = {
'salt': 'trackSalt',
'bor': 'trackBor',
'cc': 'trackCC',
'csi': 'trackCSI',
"salt": "trackSalt",
"bor": "trackBor",
"cc": "trackCC",
"csi": "trackCSI",
}

EXAMPLE_URL = 'https://api.poolmathapp.com/share/XXXXXX.json'


class PoolMathClient:
def __init__(self, url, name=DEFAULT_NAME, timeout=DEFAULT_TIMEOUT):
self._url = url
def __init__(self, share_id: str, name=DEFAULT_NAME, timeout=DEFAULT_TIMEOUT):
self._share_id = share_id
self._name = name
self._timeout = timeout

# parse out the unique pool identifier from the provided URL (include hyphen for tfp-)
self._pool_id = DEFAULT_POOL_ID
match = re.search(r'poolmathapp.com/(mypool|share)/([a-zA-Z0-9-]+)', self._url)
if match:
self._pool_id = match[2]
else:
LOG.error(f'Invalid URL for PoolMath {self._url}, use {EXAMPLE_URL} format')

self._json_url = f'https://api.poolmathapp.com/share/{self._pool_id}.json'
if self._json_url != self._url:
LOG.warning(
f'Using JSON URL {self._json_url} instead of yaml configured URL {self._url}'
)
self._json_url = f"https://api.poolmathapp.com/share/{self._share_id}.json"
LOG.debug(f"Using JSON URL: {self._json_url}")

async def async_update(self):
"""Fetch latest json formatted data from the Pool Math API"""

async with httpx.AsyncClient() as client:
LOG.info(
f'GET {self._json_url} (timeout={self._timeout}; name={self.name}; id={self.pool_id})'
f"GET {self._json_url} (timeout={self._timeout}; name={self._name}; id={self._share_id})"
)
response = await client.request(
'GET', self._json_url, timeout=self._timeout, follow_redirects=True
"GET", self._json_url, timeout=self._timeout, follow_redirects=True
)
LOG.debug(f'GET {self._json_url} response: {response.status_code}')
LOG.debug(f"GET {self._json_url} response: {response.status_code}")

if response.status_code == httpx.codes.OK:
return json.loads(response.text)
Expand All @@ -87,12 +71,12 @@ async def process_log_entry_callbacks(self, poolmath_json, async_callback):
if not poolmath_json:
return

pools = poolmath_json.get('pools')
pools = poolmath_json.get("pools")
if not pools:
return

pool = pools[0].get('pool')
overview = pool.get('overview')
pool = pools[0].get("pool")
overview = pool.get("overview")

latest_timestamp = None
for measurement in KNOWN_SENSOR_KEYS:
Expand All @@ -105,30 +89,30 @@ async def process_log_entry_callbacks(self, poolmath_json, async_callback):
if measurement in ONLY_INCLUDE_IF_TRACKED:
if not pool.get(ONLY_INCLUDE_IF_TRACKED.get(measurement)):
LOG.info(
f'Ignoring measurement {measurement} since tracking is disable in PoolMath'
f"Ignoring measurement {measurement} since tracking is disable in PoolMath"
)
continue

timestamp = overview.get(f'{measurement}Ts')
timestamp = overview.get(f"{measurement}Ts")

# find the timestamp of the most recent measurement update
if not latest_timestamp or timestamp > latest_timestamp:
latest_timestamp = timestamp

# add any attributes relevent to this measurement
attributes = {}
value_min = pool.get(f'{measurement}Min')
value_min = pool.get(f"{measurement}Min")
if value_min:
attributes[ATTR_TARGET_MIN] = value_min

value_max = pool.get(f'{measurement}Max')
value_max = pool.get(f"{measurement}Max")
if value_max:
attributes[ATTR_TARGET_MAX] = value_max

target = pool.get(f'{measurement}Target')
target = pool.get(f"{measurement}Target")
if target:
attributes['target'] = target
attributes[ATTR_TARGET_SOURCE] = 'PoolMath'
attributes["target"] = target
attributes[ATTR_TARGET_SOURCE] = "PoolMath"

# update the sensor
await async_callback(
Expand All @@ -139,7 +123,11 @@ async def process_log_entry_callbacks(self, poolmath_json, async_callback):

@property
def pool_id(self):
return self._pool_id
return self._share_id

@property
def share_id(self):
return self._share_id

@property
def name(self):
Expand All @@ -151,4 +139,4 @@ def url(self):

@staticmethod
def _entry_timestamp(entry):
return entry.find('time', class_='timestamp timereal').text
return entry.find("time", class_="timestamp timereal").text
102 changes: 102 additions & 0 deletions custom_components/poolmath/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Config flow for Pool Math integration."""

import logging
from typing import Any, Union

import voluptuous as vol

from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
OptionsFlowWithConfigEntry,
)
from homeassistant.const import CONF_NAME
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_validation as cv

from .const import (
CONF_SHARE_ID,
CONF_TARGET,
CONF_TIMEOUT,
DEFAULT_NAME,
DEFAULT_TARGET,
DEFAULT_TIMEOUT,
DOMAIN,
INTEGRATION_NAME,
)

LOG = logging.getLogger(__name__)


def _initial_form(flow: Union[ConfigFlow, OptionsFlowWithConfigEntry]):
"""Return flow form for init/user step id."""

if isinstance(flow, ConfigFlow):
step_id = "user"
share_id = None
name = DEFAULT_NAME
timeout = DEFAULT_TIMEOUT
target = DEFAULT_TARGET
elif isinstance(flow, OptionsFlowWithConfigEntry):
step_id = "init"
share_id = flow.config_entry.options.get(CONF_SHARE_ID)
name = flow.config_entry.options.get(CONF_NAME, DEFAULT_NAME)
timeout = flow.config_entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT)
target = flow.config_entry.options.get(CONF_TARGET, DEFAULT_TARGET)
else:
raise TypeError("Invalid flow type")

return flow.async_show_form(
step_id=step_id, # parameterized to follow guidance on using "user"
data_schema=vol.Schema(
{
vol.Required(CONF_SHARE_ID, default=share_id): cv.string,
vol.Optional(CONF_NAME, default=name): cv.string,
vol.Optional(CONF_TIMEOUT, default=timeout): cv.positive_int,
# NOTE: targets are not really implemented, other than tfp
vol.Optional(
CONF_TARGET, default=target
): cv.string, # targets/*.yaml file with min/max targets
# FIXME: allow specifying EXACTLY which log types to monitor, always create the sensors
# vol.Optional(CONF_LOG_TYPES, default=None):
}
),
)


class PoolMathOptionsFlow(OptionsFlowWithConfigEntry):
"""Handle Pool Math options."""

async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage Pool Math options."""
if user_input is not None:
return self.async_create_entry(title=INTEGRATION_NAME, data=user_input)

return _initial_form(self)


class PoolMathFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a Pool Math config flow."""

async def async_step_user(self, user_input=None) -> FlowResult:
"""Handle the initial step."""
if user_input is not None:
# already configured share_id?
share_id = user_input.get(CONF_SHARE_ID)
await self.async_set_unique_id(share_id)
self._abort_if_unique_id_configured()

return self.async_create_entry(title=INTEGRATION_NAME, data=user_input)

return _initial_form(self)

@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> PoolMathOptionsFlow:
"""Get the options flow for this handler."""
return PoolMathOptionsFlow(config_entry)
28 changes: 15 additions & 13 deletions custom_components/poolmath/const.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
"""Constants for Pool Math."""

DOMAIN = 'poolmath'
INTEGRATION_NAME = "Pool Math"
DOMAIN = "poolmath"

ATTRIBUTION = 'Data by PoolMath (Trouble Free Pool)'
ATTRIBUTION = "Data by PoolMath (Trouble Free Pool)"

ATTR_ATTRIBUTION = 'attribution'
ATTR_DESCRIPTION = 'description'
ATTR_LAST_UPDATED_TIME = 'last_updated'
ATTR_TARGET_MIN = 'target_min'
ATTR_TARGET_MAX = 'target_max'
ATTR_TARGET_SOURCE = 'target_source'
ATTR_DESCRIPTION = "description"
ATTR_LAST_UPDATED_TIME = "last_updated"
ATTR_TARGET_MIN = "target_min"
ATTR_TARGET_MAX = "target_max"
ATTR_TARGET_SOURCE = "target_source"

CONF_TARGET = 'target'
CONF_TIMEOUT = 'timeout'
CONF_SHARE_ID = "share_id"
CONF_TARGET = "target"
CONF_TIMEOUT = "timeout"

DEFAULT_NAME = 'Pool'
DEFAULT_NAME = "Pool"
DEFAULT_TIMEOUT = 15.0
DEFAULT_TARGET = "tfp"

ICON_GAUGE = 'mdi:gauge'
ICON_POOL = 'mdi:pool'
ICON_GAUGE = "mdi:gauge"
ICON_POOL = "mdi:pool"
2 changes: 2 additions & 0 deletions custom_components/poolmath/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
"codeowners": [
"@rsnodgrass"
],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/rsnodgrass/hass-poolmath/",
"integration_type": "service",
"iot_class": "cloud_polling",
"issue_tracker": "https://community.home-assistant.io/t/custom-component-pool-math-sensors-for-pool-chemicals-and-operations/435126",
"requirements": [
Expand Down
Loading

0 comments on commit c891612

Please sign in to comment.