Add Venstar runtimes and battery sensors (#60414)

* Venstar: Add runtimes and battery sensors

* Address review - replace classes with lambda functions

* Clean up patch usage, make temperature_unit it's own function

* Remove double define of entities
This commit is contained in:
Tim Rightnour 2021-12-05 09:23:22 -07:00 committed by GitHub
parent bf1cacf4b2
commit dd4ede09c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 124 additions and 45 deletions

View file

@ -1,4 +1,6 @@
"""The venstar component."""
from __future__ import annotations
import asyncio
from datetime import timedelta
@ -18,7 +20,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import update_coordinator
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import _LOGGER, DOMAIN, VENSTAR_TIMEOUT
from .const import _LOGGER, DOMAIN, VENSTAR_SLEEP, VENSTAR_TIMEOUT
PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR]
@ -78,6 +80,7 @@ class VenstarDataUpdateCoordinator(update_coordinator.DataUpdateCoordinator):
update_interval=timedelta(seconds=60),
)
self.client = venstar_connection
self.runtimes: list[dict[str, int]] = []
async def _async_update_data(self) -> None:
"""Update the state."""
@ -89,7 +92,7 @@ class VenstarDataUpdateCoordinator(update_coordinator.DataUpdateCoordinator):
) from ex
# older venstars sometimes cannot handle rapid sequential connections
await asyncio.sleep(1)
await asyncio.sleep(VENSTAR_SLEEP)
try:
await self.hass.async_add_executor_job(self.client.update_sensors)
@ -99,7 +102,7 @@ class VenstarDataUpdateCoordinator(update_coordinator.DataUpdateCoordinator):
) from ex
# older venstars sometimes cannot handle rapid sequential connections
await asyncio.sleep(1)
await asyncio.sleep(VENSTAR_SLEEP)
try:
await self.hass.async_add_executor_job(self.client.update_alerts)
@ -107,6 +110,19 @@ class VenstarDataUpdateCoordinator(update_coordinator.DataUpdateCoordinator):
raise update_coordinator.UpdateFailed(
f"Exception during Venstar alert update: {ex}"
) from ex
# older venstars sometimes cannot handle rapid sequential connections
await asyncio.sleep(VENSTAR_SLEEP)
try:
self.runtimes = await self.hass.async_add_executor_job(
self.client.get_runtimes
)
except (OSError, RequestException) as ex:
raise update_coordinator.UpdateFailed(
f"Exception during Venstar runtime update: {ex}"
) from ex
return None

View file

@ -25,5 +25,6 @@ HOLD_MODE_OFF = "off"
HOLD_MODE_TEMPERATURE = "temperature"
VENSTAR_TIMEOUT = 5
VENSTAR_SLEEP = 1.0
_LOGGER = logging.getLogger(__name__)

View file

@ -1,9 +1,12 @@
"""Representation of Venstar sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.sensor import (
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
STATE_CLASS_MEASUREMENT,
@ -11,19 +14,51 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, TEMP_CELSIUS, TEMP_FAHRENHEIT
from homeassistant.const import PERCENTAGE, TEMP_CELSIUS, TEMP_FAHRENHEIT, TIME_MINUTES
from homeassistant.helpers.entity import Entity
from . import VenstarDataUpdateCoordinator, VenstarEntity
from .const import DOMAIN
RUNTIME_HEAT1 = "heat1"
RUNTIME_HEAT2 = "heat2"
RUNTIME_COOL1 = "cool1"
RUNTIME_COOL2 = "cool2"
RUNTIME_AUX1 = "aux1"
RUNTIME_AUX2 = "aux2"
RUNTIME_FC = "fc"
RUNTIME_OV = "ov"
RUNTIME_DEVICES = [
RUNTIME_HEAT1,
RUNTIME_HEAT2,
RUNTIME_COOL1,
RUNTIME_COOL2,
RUNTIME_AUX1,
RUNTIME_AUX2,
RUNTIME_FC,
RUNTIME_OV,
]
RUNTIME_ATTRIBUTES = {
RUNTIME_HEAT1: "Heating Stage 1",
RUNTIME_HEAT2: "Heating Stage 2",
RUNTIME_COOL1: "Cooling Stage 1",
RUNTIME_COOL2: "Cooling Stage 2",
RUNTIME_AUX1: "Aux Stage 1",
RUNTIME_AUX2: "Aux Stage 2",
RUNTIME_FC: "Free Cooling",
RUNTIME_OV: "Override",
}
@dataclass
class VenstarSensorTypeMixin:
"""Mixin for sensor required keys."""
cls: type[VenstarSensor]
stype: str
value_fn: Callable[[Any, Any], Any]
name_fn: Callable[[Any, Any], str]
uom_fn: Callable[[Any], str]
@dataclass
@ -40,21 +75,34 @@ async def async_setup_entry(hass, config_entry, async_add_entities) -> None:
if not sensors:
return
entities = []
for sensor_name in sensors:
entities.extend(
[
description.cls(coordinator, config_entry, description, sensor_name)
VenstarSensor(coordinator, config_entry, description, sensor_name)
for description in SENSOR_ENTITIES
if coordinator.client.get_sensor(sensor_name, description.stype)
if coordinator.client.get_sensor(sensor_name, description.key)
is not None
]
)
runtimes = coordinator.runtimes[-1]
for sensor_name in runtimes:
if sensor_name in RUNTIME_DEVICES:
entities.append(
VenstarSensor(coordinator, config_entry, RUNTIME_ENTITY, sensor_name)
)
async_add_entities(entities)
def temperature_unit(coordinator: VenstarDataUpdateCoordinator) -> str:
"""Return the correct unit for temperature."""
unit = TEMP_CELSIUS
if coordinator.client.tempunits == coordinator.client.TEMPUNITS_F:
unit = TEMP_FAHRENHEIT
return unit
class VenstarSensor(VenstarEntity, SensorEntity):
"""Base class for a Venstar sensor."""
@ -78,58 +126,59 @@ class VenstarSensor(VenstarEntity, SensorEntity):
"""Return the unique id."""
return f"{self._config.entry_id}_{self.sensor_name.replace(' ', '_')}_{self.entity_description.key}"
class VenstarHumiditySensor(VenstarSensor):
"""Represent a Venstar humidity sensor."""
@property
def name(self):
"""Return the name of the device."""
return f"{self._client.name} {self.sensor_name} Humidity"
return self.entity_description.name_fn(self.coordinator, self.sensor_name)
@property
def native_value(self) -> int:
"""Return state of the sensor."""
return self._client.get_sensor(self.sensor_name, "hum")
class VenstarTemperatureSensor(VenstarSensor):
"""Represent a Venstar temperature sensor."""
@property
def name(self):
"""Return the name of the device."""
return (
f"{self._client.name} {self.sensor_name.replace(' Temp', '')} Temperature"
)
return self.entity_description.value_fn(self.coordinator, self.sensor_name)
@property
def native_unit_of_measurement(self) -> str:
"""Return unit of measurement the value is expressed in."""
if self._client.tempunits == self._client.TEMPUNITS_F:
return TEMP_FAHRENHEIT
return TEMP_CELSIUS
@property
def native_value(self) -> float:
"""Return state of the sensor."""
return round(float(self._client.get_sensor(self.sensor_name, "temp")), 1)
return self.entity_description.uom_fn(self.coordinator)
SENSOR_ENTITIES: tuple[VenstarSensorEntityDescription, ...] = (
VenstarSensorEntityDescription(
key="humidity",
key="hum",
device_class=DEVICE_CLASS_HUMIDITY,
state_class=STATE_CLASS_MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
cls=VenstarHumiditySensor,
stype="hum",
uom_fn=lambda coordinator: PERCENTAGE,
value_fn=lambda coordinator, sensor_name: coordinator.client.get_sensor(
sensor_name, "hum"
),
name_fn=lambda coordinator, sensor_name: f"{coordinator.client.name} {sensor_name} Humidity",
),
VenstarSensorEntityDescription(
key="temperature",
key="temp",
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
cls=VenstarTemperatureSensor,
stype="temp",
uom_fn=temperature_unit,
value_fn=lambda coordinator, sensor_name: round(
float(coordinator.client.get_sensor(sensor_name, "temp")), 1
),
name_fn=lambda coordinator, sensor_name: f"{coordinator.client.name} {sensor_name.replace(' Temp', '')} Temperature",
),
VenstarSensorEntityDescription(
key="battery",
device_class=DEVICE_CLASS_BATTERY,
state_class=STATE_CLASS_MEASUREMENT,
uom_fn=lambda coordinator: PERCENTAGE,
value_fn=lambda coordinator, sensor_name: coordinator.client.get_sensor(
sensor_name, "battery"
),
name_fn=lambda coordinator, sensor_name: f"{coordinator.client.name} {sensor_name} Battery",
),
)
RUNTIME_ENTITY = VenstarSensorEntityDescription(
key="runtime",
state_class=STATE_CLASS_MEASUREMENT,
uom_fn=lambda coordinator: TIME_MINUTES,
value_fn=lambda coordinator, sensor_name: coordinator.runtimes[-1][sensor_name],
name_fn=lambda coordinator, sensor_name: f"{coordinator.client.name} {RUNTIME_ATTRIBUTES[sensor_name]} Runtime",
)

View file

@ -60,3 +60,7 @@ class VenstarColorTouchMock:
def update_alerts(self):
"""Mock update_alerts."""
return True
def get_runtimes(self):
"""Mock get runtimes."""
return {}

View file

@ -0,0 +1 @@
{"runtimes":[{"ts":1637452800,"heat1":0,"heat2":0,"cool1":156,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637539200,"heat1":0,"heat2":0,"cool1":216,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637625600,"heat1":0,"heat2":0,"cool1":234,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637712000,"heat1":0,"heat2":0,"cool1":225,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637798400,"heat1":0,"heat2":0,"cool1":153,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637884800,"heat1":0,"heat2":0,"cool1":94,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637921499,"heat1":0,"heat2":0,"cool1":12,"cool2":0,"aux1":0,"aux2":0,"fc":0}]}

View file

@ -0,0 +1 @@
{"runtimes":[{"ts":1637452800,"heat1":0,"heat2":0,"cool1":156,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637539200,"heat1":0,"heat2":0,"cool1":216,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637625600,"heat1":0,"heat2":0,"cool1":234,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637712000,"heat1":0,"heat2":0,"cool1":225,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637798400,"heat1":0,"heat2":0,"cool1":153,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637884800,"heat1":0,"heat2":0,"cool1":94,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637921489,"heat1":0,"heat2":0,"cool1":12,"cool2":0,"aux1":0,"aux2":0,"fc":0}]}

View file

@ -20,7 +20,7 @@ EXPECTED_BASE_SUPPORTED_FEATURES = (
async def test_colortouch(hass):
"""Test interfacing with a venstar colortouch with attached humidifier."""
with patch("homeassistant.components.onewire.sensor.asyncio.sleep"):
with patch("homeassistant.components.venstar.VENSTAR_SLEEP", new=0):
await async_init_integration(hass)
state = hass.states.get("climate.colortouch")
@ -56,7 +56,7 @@ async def test_colortouch(hass):
async def test_t2000(hass):
"""Test interfacing with a venstar T2000 presently turned off."""
with patch("homeassistant.components.onewire.sensor.asyncio.sleep"):
with patch("homeassistant.components.venstar.VENSTAR_SLEEP", new=0):
await async_init_integration(hass)
state = hass.states.get("climate.t2000")

View file

@ -37,7 +37,11 @@ async def test_setup_entry(hass: HomeAssistant):
"homeassistant.components.venstar.VenstarColorTouch.update_alerts",
new=VenstarColorTouchMock.update_alerts,
), patch(
"homeassistant.components.onewire.sensor.asyncio.sleep"
"homeassistant.components.venstar.VenstarColorTouch.get_runtimes",
new=VenstarColorTouchMock.get_runtimes,
), patch(
"homeassistant.components.venstar.VENSTAR_SLEEP",
new=0,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@ -72,6 +76,9 @@ async def test_setup_entry_exception(hass: HomeAssistant):
), patch(
"homeassistant.components.venstar.VenstarColorTouch.update_alerts",
new=VenstarColorTouchMock.update_alerts,
), patch(
"homeassistant.components.venstar.VenstarColorTouch.get_runtimes",
new=VenstarColorTouchMock.get_runtimes,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()