Skip to content

Commit

Permalink
Add waterfurnace platform (home-assistant#11732)
Browse files Browse the repository at this point in the history
Add waterfurnace platform

This adds support for waterfurnace geothermal systems. This is
implemented as a component as there will eventually be some active
control elements. This is not done as a climate platform because
geothermal systems work best when set at a constant temperature as
they are tuned to keep within 0.5 degrees F of a setpoint, and large
temperature shifts are slow and expensive.

This is done in the Data + Sensors model, with the Data component
having a regular update thread. That thread needs to call the read()
function at least every 30 seconds otherwise the underlying websocket
is closed by the server.
  • Loading branch information
sdague authored Jan 20, 2018
1 parent dd81af4 commit 8c78a21
Show file tree
Hide file tree
Showing 4 changed files with 256 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,9 @@ omit =
homeassistant/components/volvooncall.py
homeassistant/components/*/volvooncall.py

homeassistant/components/waterfurnace.py
homeassistant/components/*/waterfurnace.py

homeassistant/components/*/webostv.py

homeassistant/components/wemo.py
Expand Down
114 changes: 114 additions & 0 deletions homeassistant/components/sensor/waterfurnace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""
Support for Waterfurnace.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/sensor.waterfurnace/
"""
import asyncio

from homeassistant.components.sensor import ENTITY_ID_FORMAT
from homeassistant.components.waterfurnace import (
DOMAIN as WF_DOMAIN, UPDATE_TOPIC
)
from homeassistant.const import TEMP_FAHRENHEIT
from homeassistant.core import callback
from homeassistant.helpers.entity import Entity
from homeassistant.util import slugify


class WFSensorConfig(object):
"""Water Furnace Sensor configuration."""

def __init__(self, friendly_name, field, icon="mdi:guage",
unit_of_measurement=None):
"""Initialize configuration."""
self.friendly_name = friendly_name
self.field = field
self.icon = icon
self.unit_of_measurement = unit_of_measurement


SENSORS = [
WFSensorConfig("Furnace Mode", "mode"),
WFSensorConfig("Total Power", "totalunitpower", "mdi:flash", "W"),
WFSensorConfig("Active Setpoint", "tstatactivesetpoint",
"mdi:thermometer", TEMP_FAHRENHEIT),
WFSensorConfig("Leaving Air", "leavingairtemp",
"mdi:thermometer", TEMP_FAHRENHEIT),
WFSensorConfig("Room Temp", "tstatroomtemp",
"mdi:thermometer", TEMP_FAHRENHEIT),
WFSensorConfig("Loop Temp", "enteringwatertemp",
"mdi:thermometer", TEMP_FAHRENHEIT),
WFSensorConfig("Humidity Set Point", "tstathumidsetpoint",
"mdi:water-percent", "%"),
WFSensorConfig("Humidity", "tstatrelativehumidity",
"mdi:water-percent", "%"),
]


def setup_platform(hass, config, add_devices, discovery_info=None):
"""Set up the Waterfurnace sensor."""
if discovery_info is None:
return

sensors = []
client = hass.data[WF_DOMAIN]
for sconfig in SENSORS:
sensors.append(WaterFurnaceSensor(client, sconfig))

add_devices(sensors)


class WaterFurnaceSensor(Entity):
"""Implementing the Waterfurnace sensor."""

def __init__(self, client, config):
"""Initialize the sensor."""
self.client = client
self._name = config.friendly_name
self._attr = config.field
self._state = None
self._icon = config.icon
self._unit_of_measurement = config.unit_of_measurement

# This ensures that the sensors are isolated per waterfurnace unit
self.entity_id = ENTITY_ID_FORMAT.format(
'wf_{}_{}'.format(slugify(self.client.unit), slugify(self._attr)))

@property
def name(self):
"""Return the name of the sensor."""
return self._name

@property
def state(self):
"""Return the state of the sensor."""
return self._state

@property
def icon(self):
"""Return icon."""
return self._icon

@property
def unit_of_measurement(self):
"""Return the units of measurement."""
return self._unit_of_measurement

@property
def should_poll(self):
"""Return the polling state."""
return False

@asyncio.coroutine
def async_added_to_hass(self):
"""Register callbacks."""
self.hass.helpers.dispatcher.async_dispatcher_connect(
UPDATE_TOPIC, self.async_update_callback)

@callback
def async_update_callback(self):
"""Update state."""
if self.client.data is not None:
self._state = getattr(self.client.data, self._attr, None)
self.async_schedule_update_ha_state()
136 changes: 136 additions & 0 deletions homeassistant/components/waterfurnace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""
Support for Waterfurnace component.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/waterfurnace/
"""
from datetime import timedelta
import logging
import time
import threading

import voluptuous as vol

from homeassistant.const import (
CONF_USERNAME, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
)
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import discovery

REQUIREMENTS = ["waterfurnace==0.2.0"]

_LOGGER = logging.getLogger(__name__)

DOMAIN = "waterfurnace"
UPDATE_TOPIC = DOMAIN + "_update"
CONF_UNIT = "unit"
SCAN_INTERVAL = timedelta(seconds=10)

CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_UNIT): cv.string,
}),
}, extra=vol.ALLOW_EXTRA)


def setup(hass, base_config):
"""Setup waterfurnace platform."""
import waterfurnace.waterfurnace as wf
config = base_config.get(DOMAIN)

username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
unit = config.get(CONF_UNIT)

wfconn = wf.WaterFurnace(username, password, unit)
# NOTE(sdague): login will throw an exception if this doesn't
# work, which will abort the setup.
try:
wfconn.login()
except wf.WFCredentialError:
_LOGGER.error("Invalid credentials for waterfurnace login.")
return False

hass.data[DOMAIN] = WaterFurnaceData(hass, wfconn)
hass.data[DOMAIN].start()

discovery.load_platform(hass, 'sensor', DOMAIN, {}, config)
return True


class WaterFurnaceData(threading.Thread):
"""WaterFurnace Data collector.
This is implemented as a dedicated thread polling a websocket in a
tight loop. The websocket will shut itself from the server side if
a packet is not sent at least every 30 seconds. The reading is
cheap, the login is less cheap, so keeping this open and polling
on a very regular cadence is actually the least io intensive thing
to do.
"""

def __init__(self, hass, client):
"""Initialize the data object."""
super().__init__()
self.hass = hass
self.client = client
self.unit = client.unit
self.data = None
self._shutdown = False

def run(self):
"""Thread run loop."""
@callback
def register():
"""Connect to hass for shutdown."""
def shutdown(event):
"""Shutdown the thread."""
_LOGGER.debug("Signaled to shutdown.")
self._shutdown = True
self.join()

self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown)

self.hass.add_job(register)

# This does a tight loop in sending read calls to the
# websocket. That's a blocking call, which returns pretty
# quickly (1 second). It's important that we do this
# frequently though, because if we don't call the websocket at
# least every 30 seconds the server side closes the
# connection.
while True:
if self._shutdown:
_LOGGER.debug("Graceful shutdown")
return

try:
self.data = self.client.read()

except ConnectionError:
# attempt to log back in if there was a session expiration.
try:
self.client.login()
except Exception: # pylint: disable=broad-except
# nested exception handling, something really bad
# happened during the login, which means we're not
# in a recoverable state. Stop the thread so we
# don't do just keep poking at the service.
_LOGGER.error(
"Failed to refresh login credentials. Thread stopped.")
return
else:
_LOGGER.error(
"Lost our connection to websocket, trying again")
time.sleep(SCAN_INTERVAL.seconds)

except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error updating waterfurnace data.")
time.sleep(SCAN_INTERVAL.seconds)

else:
self.hass.helpers.dispatcher.dispatcher_send(UPDATE_TOPIC)
time.sleep(SCAN_INTERVAL.seconds)
3 changes: 3 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1195,6 +1195,9 @@ waqiasync==1.0.0
# homeassistant.components.cloud
warrant==0.6.1

# homeassistant.components.waterfurnace
waterfurnace==0.2.0

# homeassistant.components.media_player.gpmdp
websocket-client==0.37.0

Expand Down

0 comments on commit 8c78a21

Please sign in to comment.