Skip to content

Commit

Permalink
Merge pull request #11 from natekspencer/balboa
Browse files Browse the repository at this point in the history
Extend functionality of Balboa Spa component
  • Loading branch information
garbled1 authored Dec 5, 2020
2 parents 6266674 + bf755e8 commit 87eee53
Show file tree
Hide file tree
Showing 13 changed files with 328 additions and 216 deletions.
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs)

# Balboa Spa integration for home-assistant
Home assistant integration for a Balboa spa wifi controller.
# Balboa Spa Client integration for home-assistant
Home assistant integration for a spa equipped with a Balboa BP system and a
bwa™ Wi-Fi Module (50350).

## Configuration

There is a config flow for the spa. After installing,
go to integrations, hit + to setup a new integration, search for "Balboa Spa",
go to integrations, hit + to setup a new integration, search for "Balboa Spa Client",
select that, and add the IP address or hostname of your spa's wifi adapter.

If you have a blower, it will be listed as a "fan" in the climate device for
the spa. Currently the code assumes you have a 3-speed blower, if you only
have a 1-speed, only use LOW and OFF.

## Screenshot
## Screenshots

![Screenshot](Screenshot_spa.png)
![Screenshots](Screenshot_spa.png)

## See also
## Related Projects

<https://github.com/garbled1/pybalboa>
* https://github.com/garbled1/pybalboa - Python library for local spa control
* https://github.com/plmilord/Hass.io-custom-component-spaclient - Another HASS custom component (and source of "spaclient" logos)
* https://github.com/ccutrer/balboa_worldwide_app - Fountain of knowledge for most of the messages sent from the spa wifi module
* https://github.com/natekspencer/BwaSpaManager - A SmartThings cloud-based solution
100 changes: 70 additions & 30 deletions custom_components/balboa/__init__.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
"""The Balboa Spa integration."""
"""The Balboa Spa Client integration."""
import asyncio
import logging
import time
from typing import Any, Dict

from pybalboa import BalboaSpaWifi
import homeassistant.helpers.config_validation as cv
import homeassistant.util.dt as dt_util
import voluptuous as vol

from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from pybalboa import BalboaSpaWifi

from .const import BALBOA_PLATFORMS, DOMAIN

_LOGGER = logging.getLogger(__name__)
from .const import (
_LOGGER,
CONF_SYNC_TIME,
DEFAULT_SYNC_TIME,
DOMAIN,
PLATFORMS,
SPA,
UNSUB,
)

BALBOA_CONFIG_SCHEMA = vol.Schema(
{vol.Required(CONF_HOST): cv.string, vol.Required(CONF_NAME): cv.string}
Expand All @@ -31,7 +38,7 @@


async def async_setup(hass: HomeAssistant, config: dict):
"""Configure the Balboa Spa component using flow only."""
"""Configure the Balboa Spa Client component using flow only."""
hass.data[DOMAIN] = {}

if DOMAIN in config:
Expand All @@ -48,28 +55,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Balboa Spa from a config entry."""
host = entry.data[CONF_HOST]

_LOGGER.debug("Attempting to connect to %s", host)
unsub = entry.add_update_listener(update_listener)

_LOGGER.info("Attempting to connect to %s", host)
spa = BalboaSpaWifi(host)
hass.data[DOMAIN][entry.entry_id] = spa
hass.data[DOMAIN][entry.entry_id] = {SPA: spa, UNSUB: unsub}

connected = await spa.connect()
if not connected:
_LOGGER.error("Failed to connect to spa at %s", host)
return False
raise ConfigEntryNotReady

# send config requests, and then listen until we are configured.
await spa.send_config_req()
await spa.send_mod_ident_req()
await spa.send_panel_req(0, 1)
# configured = await spa.listen_until_configured()

_LOGGER.debug("Starting listener and monitor tasks.")
_LOGGER.info("Starting listener and monitor tasks.")
hass.loop.create_task(spa.listen())
hass.loop.create_task(spa.check_connection_status())
await spa.spa_configured()
hass.loop.create_task(spa.check_connection_status())

# At this point we have a configured spa.
forward_setup = hass.config_entries.async_forward_entry_setup
for component in BALBOA_PLATFORMS:
for component in PLATFORMS:
hass.async_create_task(forward_setup(entry, component))

async def _async_balboa_update_cb():
Expand All @@ -79,29 +88,53 @@ async def _async_balboa_update_cb():

spa.new_data_cb = _async_balboa_update_cb

# call update_listener on startup
await update_listener(hass, entry)

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""

spa = hass.data[DOMAIN][entry.entry_id]
spa.disconnect()
_LOGGER.info("Disconnecting from spa")
spa = hass.data[DOMAIN][entry.entry_id][SPA]
await spa.disconnect()

unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in BALBOA_PLATFORMS
for component in PLATFORMS
]
)
)

hass.data[DOMAIN][entry.entry_id][UNSUB]()

if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)

return unload_ok


async def update_listener(hass, entry):
"""Handle options update."""
if entry.options.get(CONF_SYNC_TIME, DEFAULT_SYNC_TIME):
_LOGGER.info("Setting up daily time sync.")
spa = hass.data[DOMAIN][entry.entry_id][SPA]

async def sync_time():
while entry.options.get(CONF_SYNC_TIME, DEFAULT_SYNC_TIME):
_LOGGER.info("Syncing time with Home Assistant.")
await spa.set_time(
time.strptime(str(dt_util.now()), "%Y-%m-%d %H:%M:%S.%f%z")
)
await asyncio.sleep(86400)

hass.loop.create_task(sync_time())


class BalboaEntity(Entity):
"""Abstract class for all Balboa platforms.
Expand All @@ -111,12 +144,18 @@ class BalboaEntity(Entity):
accessors.
"""

def __init__(self, hass, client, name, entry):
"""Initialize the spa."""
def __init__(self, hass, entry, type, num=None):
"""Initialize the spa entity."""
self.hass = hass
self._client = client
self._name = name
self._entry = entry
self._client = hass.data[DOMAIN][entry.entry_id][SPA]
self._device_name = entry.data[CONF_NAME]
self._type = type
self._num = num

@property
def name(self):
"""Return the name of the entity."""
return f'{self._device_name}: {self._type}{self._num or ""}'

async def async_added_to_hass(self) -> None:
"""Set up a listener for the entity."""
Expand All @@ -125,7 +164,7 @@ async def async_added_to_hass(self) -> None:
@callback
def _update_callback(self) -> None:
"""Call from dispatcher when state changes."""
_LOGGER.debug("Updating spa state with new data. %s", self._name)
_LOGGER.debug(f"Updating {self.name} state with new data.")
self.async_schedule_update_ha_state(force_refresh=True)

@property
Expand All @@ -136,7 +175,7 @@ def should_poll(self) -> bool:
@property
def unique_id(self):
"""Set unique_id for this entity."""
return f'{self._name}-{self._client.get_macaddr().replace(":","")[-6:]}'
return f'{self._device_name}-{self._type}{self._num or ""}-{self._client.get_macaddr().replace(":","")[-6:]}'

@property
def assumed_state(self) -> bool:
Expand All @@ -152,11 +191,12 @@ def available(self) -> bool:

@property
def device_info(self) -> Dict[str, Any]:
"""Return device information for this sensor."""
"""Return the device information for this entity."""
return {
"identifiers": {(DOMAIN, self._client.get_macaddr())},
"name": self._entry.data[CONF_NAME],
"manufacturer": 'Balboa Water Group',
"name": self._device_name,
"manufacturer": "Balboa Water Group",
"model": self._client.get_model_name(),
"sw_version": self._client.get_ssid()
"sw_version": self._client.get_ssid(),
"connections": {(CONNECTION_NETWORK_MAC, self._client.get_macaddr())},
}
42 changes: 12 additions & 30 deletions custom_components/balboa/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -1,60 +1,42 @@
"""Support for Balboa Spa binary sensors."""
import logging

from homeassistant.components.binary_sensor import (
DEVICE_CLASS_MOVING,
BinarySensorEntity,
)
from homeassistant.const import CONF_NAME

from . import BalboaEntity
from .const import DOMAIN as BALBOA_DOMAIN

_LOGGER = logging.getLogger(__name__)


async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up of the spa is done through async_setup_entry."""
pass
from .const import _LOGGER, CIRC_PUMP, DOMAIN, FILTER, SPA


async def async_setup_entry(hass, entry, async_add_entities):
"""Set up the spa's binary sensors."""
spa = hass.data[BALBOA_DOMAIN][entry.entry_id]
name = entry.data[CONF_NAME]
spa = hass.data[DOMAIN][entry.entry_id][SPA]
devs = []

devs.append(BalboaSpaBinarySensor(hass, spa, f"{name}-filter1",
entry, "filter1"))
devs.append(BalboaSpaBinarySensor(hass, spa, f"{name}-filter2",
entry, "filter2"))
devs.append(BalboaSpaBinarySensor(hass, entry, FILTER, 1))
devs.append(BalboaSpaBinarySensor(hass, entry, FILTER, 2))

if spa.have_circ_pump():
devs.append(BalboaSpaBinarySensor(hass, spa, f"{name}-circ_pump",
entry, "circ_pump"))
devs.append(BalboaSpaBinarySensor(hass, entry, CIRC_PUMP))

async_add_entities(devs, True)


class BalboaSpaBinarySensor(BalboaEntity, BinarySensorEntity):
"""Representation of a Balboa Spa binary sensor device."""

def __init__(self, hass, client, name, entry, bsensor_key):
"""Initialize the binary sensor."""
super().__init__(hass, client, name, entry)
self.bsensor_key = bsensor_key
"""Representation of a Balboa Spa binary sensor entity."""

@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
if "circ_pump" in self.bsensor_key:
if self._type == CIRC_PUMP:
return self._client.get_circ_pump()
if "filter" in self.bsensor_key:
if self._type == FILTER:
fmode = self._client.get_filtermode()
if fmode == self._client.FILTER_OFF:
return False
if "filter1" in self.bsensor_key and fmode != self._client.FILTER_2:
if self._num == 1 and fmode != self._client.FILTER_2:
return True
if "filter2" in self.bsensor_key and fmode >= self._client.FILTER_2:
if self._num == 2 and fmode >= self._client.FILTER_2:
return True
return False
return False
Expand All @@ -67,6 +49,6 @@ def device_class(self):
@property
def icon(self):
"""Return the icon to use in the frontend, if any."""
if "circ_pump" in self.bsensor_key:
if self._type == CIRC_PUMP:
return "mdi:water-pump" if self.is_on else "mdi:water-pump-off"
return "mdi:sync" if self.is_on else "mdi:sync-off"
Loading

0 comments on commit 87eee53

Please sign in to comment.