Update nut to use DataUpdateCoordinator (#33831)

* Convert nut to use DataUpdateCoordinator

* Adjust per review

* ups_list is a dict with {id: name, ...}
This commit is contained in:
J. Nick Koston 2020-04-08 20:26:10 -05:00 committed by GitHub
parent bdb998bdb3
commit 8be7cb4539
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 81 additions and 61 deletions

View file

@ -1,7 +1,9 @@
"""The nut component.""" """The nut component."""
import asyncio import asyncio
from datetime import timedelta
import logging import logging
import async_timeout
from pynut2.nut2 import PyNUTClient, PyNUTError from pynut2.nut2 import PyNUTClient, PyNUTError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -15,8 +17,10 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import ( from .const import (
COORDINATOR,
DOMAIN, DOMAIN,
PLATFORMS, PLATFORMS,
PYNUT_DATA, PYNUT_DATA,
@ -24,7 +28,6 @@ from .const import (
PYNUT_MANUFACTURER, PYNUT_MANUFACTURER,
PYNUT_MODEL, PYNUT_MODEL,
PYNUT_NAME, PYNUT_NAME,
PYNUT_STATUS,
PYNUT_UNIQUE_ID, PYNUT_UNIQUE_ID,
) )
@ -51,7 +54,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
data = PyNUTData(host, port, alias, username, password) data = PyNUTData(host, port, alias, username, password)
status = await hass.async_add_executor_job(pynutdata_status, data) async def async_update_data():
"""Fetch data from NUT."""
async with async_timeout.timeout(10):
return await hass.async_add_executor_job(data.update)
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name="NUT resource status",
update_method=async_update_data,
update_interval=timedelta(seconds=60),
)
# Fetch initial data so we have data when entities subscribe
await coordinator.async_refresh()
status = data.status
if not status: if not status:
_LOGGER.error("NUT Sensor has no data, unable to set up") _LOGGER.error("NUT Sensor has no data, unable to set up")
@ -60,8 +78,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
_LOGGER.debug("NUT Sensors Available: %s", status) _LOGGER.debug("NUT Sensors Available: %s", status)
hass.data[DOMAIN][entry.entry_id] = { hass.data[DOMAIN][entry.entry_id] = {
COORDINATOR: coordinator,
PYNUT_DATA: data, PYNUT_DATA: data,
PYNUT_STATUS: status,
PYNUT_UNIQUE_ID: _unique_id_from_status(status), PYNUT_UNIQUE_ID: _unique_id_from_status(status),
PYNUT_MANUFACTURER: _manufacturer_from_status(status), PYNUT_MANUFACTURER: _manufacturer_from_status(status),
PYNUT_MODEL: _model_from_status(status), PYNUT_MODEL: _model_from_status(status),
@ -143,11 +161,6 @@ def find_resources_in_config_entry(config_entry):
return config_entry.data[CONF_RESOURCES] return config_entry.data[CONF_RESOURCES]
def pynutdata_status(data):
"""Wrap for data update as a callable."""
return data.status
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = all( unload_ok = all(
@ -180,12 +193,12 @@ class PyNUTData:
# Establish client with persistent=False to open/close connection on # Establish client with persistent=False to open/close connection on
# each update call. This is more reliable with async. # each update call. This is more reliable with async.
self._client = PyNUTClient(self._host, port, username, password, 5, False) self._client = PyNUTClient(self._host, port, username, password, 5, False)
self.ups_list = None
self._status = None self._status = None
@property @property
def status(self): def status(self):
"""Get latest update if throttle allows. Return status.""" """Get latest update if throttle allows. Return status."""
self.update()
return self._status return self._status
@property @property
@ -193,18 +206,21 @@ class PyNUTData:
"""Return the name of the ups.""" """Return the name of the ups."""
return self._alias return self._alias
def list_ups(self):
"""List UPSes connected to the NUT server."""
return self._client.list_ups()
def _get_alias(self): def _get_alias(self):
"""Get the ups alias from NUT.""" """Get the ups alias from NUT."""
try: try:
return next(iter(self.list_ups())) ups_list = self._client.list_ups()
except PyNUTError as err: except PyNUTError as err:
_LOGGER.error("Failure getting NUT ups alias, %s", err) _LOGGER.error("Failure getting NUT ups alias, %s", err)
return None return None
if not ups_list:
_LOGGER.error("Empty list while getting NUT ups aliases")
return None
self.ups_list = ups_list
return list(ups_list)[0]
def _get_status(self): def _get_status(self):
"""Get the ups status from NUT.""" """Get the ups status from NUT."""
if self._alias is None: if self._alias is None:

View file

@ -15,7 +15,7 @@ from homeassistant.const import (
from homeassistant.core import callback from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from . import PyNUTData, find_resources_in_config_entry, pynutdata_status from . import PyNUTData, find_resources_in_config_entry
from .const import DEFAULT_HOST, DEFAULT_PORT, SENSOR_TYPES from .const import DEFAULT_HOST, DEFAULT_PORT, SENSOR_TYPES
from .const import DOMAIN # pylint:disable=unused-import from .const import DOMAIN # pylint:disable=unused-import
@ -54,9 +54,7 @@ def _resource_schema(available_resources, selected_resources):
def _ups_schema(ups_list): def _ups_schema(ups_list):
"""UPS selection schema.""" """UPS selection schema."""
ups_map = {ups: ups for ups in ups_list} return vol.Schema({vol.Required(CONF_ALIAS): vol.In(ups_list)})
return vol.Schema({vol.Required(CONF_ALIAS): vol.In(ups_map)})
async def validate_input(hass: core.HomeAssistant, data): async def validate_input(hass: core.HomeAssistant, data):
@ -72,16 +70,12 @@ async def validate_input(hass: core.HomeAssistant, data):
password = data.get(CONF_PASSWORD) password = data.get(CONF_PASSWORD)
data = PyNUTData(host, port, alias, username, password) data = PyNUTData(host, port, alias, username, password)
await hass.async_add_executor_job(data.update)
ups_list = await hass.async_add_executor_job(data.list_ups) status = data.status
if not ups_list:
raise CannotConnect
status = await hass.async_add_executor_job(pynutdata_status, data)
if not status: if not status:
raise CannotConnect raise CannotConnect
return {"ups_list": ups_list, "available_resources": status} return {"ups_list": data.ups_list, "available_resources": status}
def _format_host_port_alias(user_input): def _format_host_port_alias(user_input):
@ -135,6 +129,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self.ups_list = info["ups_list"] self.ups_list = info["ups_list"]
return await self.async_step_ups() return await self.async_step_ups()
if self._host_port_alias_already_configured(self.nut_config):
return self.async_abort(reason="already_configured")
self.available_resources.update(info["available_resources"]) self.available_resources.update(info["available_resources"])
return await self.async_step_resources() return await self.async_step_resources()

View file

@ -18,8 +18,9 @@ DEFAULT_PORT = 3493
KEY_STATUS = "ups.status" KEY_STATUS = "ups.status"
KEY_STATUS_DISPLAY = "ups.status.display" KEY_STATUS_DISPLAY = "ups.status.display"
COORDINATOR = "coordinator"
PYNUT_DATA = "data" PYNUT_DATA = "data"
PYNUT_STATUS = "status"
PYNUT_UNIQUE_ID = "unique_id" PYNUT_UNIQUE_ID = "unique_id"
PYNUT_MANUFACTURER = "manufacturer" PYNUT_MANUFACTURER = "manufacturer"
PYNUT_MODEL = "model" PYNUT_MODEL = "model"

View file

@ -1,5 +1,4 @@
"""Provides a sensor to track various status aspects of a UPS.""" """Provides a sensor to track various status aspects of a UPS."""
from datetime import timedelta
import logging import logging
import voluptuous as vol import voluptuous as vol
@ -21,6 +20,7 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from .const import ( from .const import (
COORDINATOR,
DEFAULT_HOST, DEFAULT_HOST,
DEFAULT_NAME, DEFAULT_NAME,
DEFAULT_PORT, DEFAULT_PORT,
@ -32,7 +32,6 @@ from .const import (
PYNUT_MANUFACTURER, PYNUT_MANUFACTURER,
PYNUT_MODEL, PYNUT_MODEL,
PYNUT_NAME, PYNUT_NAME,
PYNUT_STATUS,
PYNUT_UNIQUE_ID, PYNUT_UNIQUE_ID,
SENSOR_DEVICE_CLASS, SENSOR_DEVICE_CLASS,
SENSOR_ICON, SENSOR_ICON,
@ -45,9 +44,6 @@ from .const import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=60)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{ {
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
@ -75,13 +71,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the NUT sensors.""" """Set up the NUT sensors."""
pynut_data = hass.data[DOMAIN][config_entry.entry_id] pynut_data = hass.data[DOMAIN][config_entry.entry_id]
data = pynut_data[PYNUT_DATA]
status = pynut_data[PYNUT_STATUS]
unique_id = pynut_data[PYNUT_UNIQUE_ID] unique_id = pynut_data[PYNUT_UNIQUE_ID]
manufacturer = pynut_data[PYNUT_MANUFACTURER] manufacturer = pynut_data[PYNUT_MANUFACTURER]
model = pynut_data[PYNUT_MODEL] model = pynut_data[PYNUT_MODEL]
firmware = pynut_data[PYNUT_FIRMWARE] firmware = pynut_data[PYNUT_FIRMWARE]
name = pynut_data[PYNUT_NAME] name = pynut_data[PYNUT_NAME]
coordinator = pynut_data[COORDINATOR]
data = pynut_data[PYNUT_DATA]
status = data.status
entities = [] entities = []
@ -100,8 +97,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
): ):
entities.append( entities.append(
NUTSensor( NUTSensor(
name.title(), coordinator,
data, data,
name.title(),
sensor_type, sensor_type,
unique_id, unique_id,
manufacturer, manufacturer,
@ -123,10 +121,18 @@ class NUTSensor(Entity):
"""Representation of a sensor entity for NUT status values.""" """Representation of a sensor entity for NUT status values."""
def __init__( def __init__(
self, name, data, sensor_type, unique_id, manufacturer, model, firmware self,
coordinator,
data,
name,
sensor_type,
unique_id,
manufacturer,
model,
firmware,
): ):
"""Initialize the sensor.""" """Initialize the sensor."""
self._data = data self._coordinator = coordinator
self._type = sensor_type self._type = sensor_type
self._manufacturer = manufacturer self._manufacturer = manufacturer
self._firmware = firmware self._firmware = firmware
@ -134,10 +140,8 @@ class NUTSensor(Entity):
self._device_name = name self._device_name = name
self._name = f"{name} {SENSOR_TYPES[sensor_type][SENSOR_NAME]}" self._name = f"{name} {SENSOR_TYPES[sensor_type][SENSOR_NAME]}"
self._unit = SENSOR_TYPES[sensor_type][SENSOR_UNIT] self._unit = SENSOR_TYPES[sensor_type][SENSOR_UNIT]
self._state = None self._data = data
self._unique_id = unique_id self._unique_id = unique_id
self._display_state = None
self._available = False
@property @property
def device_info(self): def device_info(self):
@ -185,41 +189,42 @@ class NUTSensor(Entity):
@property @property
def state(self): def state(self):
"""Return entity state from ups.""" """Return entity state from ups."""
return self._state if self._type == KEY_STATUS_DISPLAY:
return _format_display_state(self._data.status)
return self._data.status.get(self._type)
@property @property
def unit_of_measurement(self): def unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any.""" """Return the unit of measurement of this entity, if any."""
return self._unit return self._unit
@property
def should_poll(self):
"""No need to poll. Coordinator notifies entity of updates."""
return False
@property @property
def available(self): def available(self):
"""Return if the device is polling successfully.""" """Return if entity is available."""
return self._available return self._coordinator.last_update_success
@property @property
def device_state_attributes(self): def device_state_attributes(self):
"""Return the sensor attributes.""" """Return the sensor attributes."""
return {ATTR_STATE: self._display_state} return {ATTR_STATE: _format_display_state(self._data.status)}
def update(self): async def async_update(self):
"""Get the latest status and use it to update our sensor state.""" """Update the entity.
status = self._data.status
if status is None: Only used by the generic entity update service.
self._available = False """
return await self._coordinator.async_request_refresh()
self._available = True async def async_added_to_hass(self):
self._display_state = _format_display_state(status) """When entity is added to hass."""
# In case of the display status sensor, keep a human-readable form self.async_on_remove(
# as the sensor state. self._coordinator.async_add_listener(self.async_write_ha_state)
if self._type == KEY_STATUS_DISPLAY: )
self._state = self._display_state
elif self._type not in status:
self._state = None
else:
self._state = status[self._type]
def _format_display_state(status): def _format_display_state(status):

View file

@ -86,7 +86,8 @@ async def test_form_user_multiple_ups(hass):
assert result["errors"] == {} assert result["errors"] == {}
mock_pynut = _get_mock_pynutclient( mock_pynut = _get_mock_pynutclient(
list_vars={"battery.voltage": "voltage"}, list_ups=["ups1", "ups2"] list_vars={"battery.voltage": "voltage"},
list_ups={"ups1": "UPS 1", "ups2": "UPS2"},
) )
with patch( with patch(
@ -146,7 +147,8 @@ async def test_form_import(hass):
await setup.async_setup_component(hass, "persistent_notification", {}) await setup.async_setup_component(hass, "persistent_notification", {})
mock_pynut = _get_mock_pynutclient( mock_pynut = _get_mock_pynutclient(
list_vars={"battery.voltage": "serial"}, list_ups=["ups1"] list_vars={"battery.voltage": "serial"},
list_ups={"ups1": "UPS 1", "ups2": "UPS2"},
) )
with patch( with patch(