From 7383e816092d33280f5db11affb0da57ac75341d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2020 11:45:45 -0500 Subject: [PATCH] Convert nut to a multi step config flow (#33803) * Convert nut to a multi step config flow * Users can now choose the ups they want in step 2 (or skipped if only one) * Users can now select the resources they want to monitor based on what is actually available on the device * CONF_NAME has been removed as we now get the name from NUT * Device classes have been added which allows the battery charge state to be seen in the devices UI * Remove update_interval as its for a followup PR * explict * reduce * fix bug, add tests for options flow * Test for dupe import * Test for dupe import * Call step directly * Update homeassistant/components/nut/config_flow.py Co-Authored-By: Martin Hjelmare Co-authored-by: Martin Hjelmare --- .../components/nut/.translations/en.json | 77 +++---- homeassistant/components/nut/__init__.py | 13 +- homeassistant/components/nut/config_flow.py | 151 ++++++++++---- homeassistant/components/nut/const.py | 191 ++++++++++++------ homeassistant/components/nut/sensor.py | 31 ++- homeassistant/components/nut/strings.json | 19 +- tests/components/nut/test_config_flow.py | 160 +++++++++++++-- 7 files changed, 482 insertions(+), 160 deletions(-) diff --git a/homeassistant/components/nut/.translations/en.json b/homeassistant/components/nut/.translations/en.json index 66ea276eca0..6519d914df2 100644 --- a/homeassistant/components/nut/.translations/en.json +++ b/homeassistant/components/nut/.translations/en.json @@ -1,37 +1,46 @@ { - "config": { - "abort": { - "already_configured": "Device is already configured" - }, - "error": { - "cannot_connect": "Failed to connect, please try again", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "alias": "Alias", - "host": "Host", - "name": "Name", - "password": "Password", - "port": "Port", - "resources": "Resources", - "username": "Username" - }, - "description": "If there are multiple UPSs attached to the NUT server, enter the name UPS to query in the 'Alias' field.", - "title": "Connect to the NUT server" - } - }, - "title": "Network UPS Tools (NUT)" - }, - "options": { - "step": { - "init": { - "data": { - "resources": "Resources" - }, - "description": "Choose Sensor Resources" - } + "config": { + "title": "Network UPS Tools (NUT)", + "step": { + "user": { + "title": "Connect to the NUT server", + "data": { + "host": "Host", + "port": "Port", + "username": "Username", + "password": "Password" } + }, + "ups": { + "title": "Choose the UPS to Monitor", + "data": { + "alias": "Alias", + "resources": "Resources" + } + }, + "resources": { + "title": "Choose the Resources to Monitor", + "data": { + "resources": "Resources" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Device is already configured" } -} \ No newline at end of file + }, + "options": { + "step": { + "init": { + "description": "Choose Sensor Resources.", + "data": { + "resources": "Resources" + } + } + } + } +} diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index a990cdf94b8..793dd5f2f3e 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -23,6 +23,7 @@ from .const import ( PYNUT_FIRMWARE, PYNUT_MANUFACTURER, PYNUT_MODEL, + PYNUT_NAME, PYNUT_STATUS, PYNUT_UNIQUE_ID, ) @@ -65,6 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): PYNUT_MANUFACTURER: _manufacturer_from_status(status), PYNUT_MODEL: _model_from_status(status), PYNUT_FIRMWARE: _firmware_from_status(status), + PYNUT_NAME: data.name, } entry.add_update_listener(_async_update_listener) @@ -186,10 +188,19 @@ class PyNUTData: self.update() return self._status + @property + def name(self): + """Return the name of the ups.""" + return self._alias + + def list_ups(self): + """List UPSes connected to the NUT server.""" + return self._client.list_ups() + def _get_alias(self): """Get the ups alias from NUT.""" try: - return next(iter(self._client.list_ups())) + return next(iter(self.list_ups())) except PyNUTError as err: _LOGGER.error("Failure getting NUT ups alias, %s", err) return None diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index 04889bb3f3f..53bfe7554ea 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -7,7 +7,6 @@ from homeassistant import config_entries, core, exceptions from homeassistant.const import ( CONF_ALIAS, CONF_HOST, - CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_RESOURCES, @@ -17,7 +16,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from . import PyNUTData, find_resources_in_config_entry, pynutdata_status -from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, SENSOR_TYPES +from .const import DEFAULT_HOST, DEFAULT_PORT, SENSOR_TYPES from .const import DOMAIN # pylint:disable=unused-import _LOGGER = logging.getLogger(__name__) @@ -27,17 +26,39 @@ SENSOR_DICT = {sensor_id: SENSOR_TYPES[sensor_id][0] for sensor_id in SENSOR_TYP DATA_SCHEMA = vol.Schema( { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, - vol.Required(CONF_RESOURCES): cv.multi_select(SENSOR_DICT), vol.Optional(CONF_HOST, default=DEFAULT_HOST): str, vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, - vol.Optional(CONF_ALIAS): str, vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str, } ) +def _resource_schema(available_resources, selected_resources): + """Resource selection schema.""" + + known_available_resources = { + sensor_id: sensor[0] + for sensor_id, sensor in SENSOR_TYPES.items() + if sensor_id in available_resources + } + + return vol.Schema( + { + vol.Required(CONF_RESOURCES, default=selected_resources): cv.multi_select( + known_available_resources + ) + } + ) + + +def _ups_schema(ups_list): + """UPS selection schema.""" + ups_map = {ups: ups for ups in ups_list} + + return vol.Schema({vol.Required(CONF_ALIAS): vol.In(ups_map)}) + + async def validate_input(hass: core.HomeAssistant, data): """Validate the user input allows us to connect. @@ -52,16 +73,22 @@ async def validate_input(hass: core.HomeAssistant, data): data = PyNUTData(host, port, alias, username, password) - status = await hass.async_add_executor_job(pynutdata_status, data) + ups_list = await hass.async_add_executor_job(data.list_ups) + if not ups_list: + raise CannotConnect + status = await hass.async_add_executor_job(pynutdata_status, data) if not status: raise CannotConnect - return {"title": _format_host_port_alias(host, port, alias)} + return {"ups_list": ups_list, "available_resources": status} -def _format_host_port_alias(host, port, alias): +def _format_host_port_alias(user_input): """Format a host, port, and alias so it can be used for comparison or display.""" + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + alias = user_input.get(CONF_ALIAS) if alias: return f"{alias}@{host}:{port}" return f"{host}:{port}" @@ -73,40 +100,96 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL - async def async_step_user(self, user_input=None): - """Handle the initial step.""" + def __init__(self): + """Initialize the nut config flow.""" + self.nut_config = {} + self.available_resources = {} + self.ups_list = None + self.title = None + + async def async_step_import(self, user_input=None): + """Handle the import.""" errors = {} if user_input is not None: - if self._host_port_alias_already_configured( - user_input[CONF_HOST], user_input[CONF_PORT], user_input.get(CONF_ALIAS) - ): + if self._host_port_alias_already_configured(user_input): return self.async_abort(reason="already_configured") - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + _, errors = await self._async_validate_or_error(user_input) - if "base" not in errors: - return self.async_create_entry(title=info["title"], data=user_input) + if not errors: + title = _format_host_port_alias(user_input) + return self.async_create_entry(title=title, data=user_input) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - def _host_port_alias_already_configured(self, host, port, alias): + async def async_step_user(self, user_input=None): + """Handle the user input.""" + errors = {} + if user_input is not None: + info, errors = await self._async_validate_or_error(user_input) + + if not errors: + self.nut_config.update(user_input) + if len(info["ups_list"]) > 1: + self.ups_list = info["ups_list"] + return await self.async_step_ups() + + self.available_resources.update(info["available_resources"]) + return await self.async_step_resources() + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_ups(self, user_input=None): + """Handle the picking the ups.""" + errors = {} + + if user_input is not None: + self.nut_config.update(user_input) + if self._host_port_alias_already_configured(self.nut_config): + return self.async_abort(reason="already_configured") + info, errors = await self._async_validate_or_error(self.nut_config) + if not errors: + self.available_resources.update(info["available_resources"]) + return await self.async_step_resources() + + return self.async_show_form( + step_id="ups", data_schema=_ups_schema(self.ups_list), errors=errors, + ) + + async def async_step_resources(self, user_input=None): + """Handle the picking the resources.""" + if user_input is None: + return self.async_show_form( + step_id="resources", + data_schema=_resource_schema(self.available_resources, []), + ) + + self.nut_config.update(user_input) + title = _format_host_port_alias(self.nut_config) + return self.async_create_entry(title=title, data=self.nut_config) + + def _host_port_alias_already_configured(self, user_input): """See if we already have a nut entry matching user input configured.""" existing_host_port_aliases = { - _format_host_port_alias(host, port, alias) + _format_host_port_alias(entry.data) for entry in self._async_current_entries() } - return _format_host_port_alias(host, port, alias) in existing_host_port_aliases + return _format_host_port_alias(user_input) in existing_host_port_aliases - async def async_step_import(self, user_input): - """Handle import.""" - return await self.async_step_user(user_input) + async def _async_validate_or_error(self, config): + errors = {} + info = {} + try: + info = await validate_input(self.hass, config) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return info, errors @staticmethod @callback @@ -129,14 +212,12 @@ class OptionsFlowHandler(config_entries.OptionsFlow): resources = find_resources_in_config_entry(self.config_entry) - data_schema = vol.Schema( - { - vol.Required(CONF_RESOURCES, default=resources): cv.multi_select( - SENSOR_DICT - ), - } + info = await validate_input(self.hass, self.config_entry.data) + + return self.async_show_form( + step_id="init", + data_schema=_resource_schema(info["available_resources"], resources), ) - return self.async_show_form(step_id="init", data_schema=data_schema) class CannotConnect(exceptions.HomeAssistantError): diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index ea164e70b93..d7138ef865c 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -1,4 +1,9 @@ """The nut component.""" +from homeassistant.components.sensor import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, +) from homeassistant.const import POWER_WATT, TEMP_CELSIUS, TIME_SECONDS, UNIT_PERCENTAGE DOMAIN = "nut" @@ -19,91 +24,146 @@ PYNUT_UNIQUE_ID = "unique_id" PYNUT_MANUFACTURER = "manufacturer" PYNUT_MODEL = "model" PYNUT_FIRMWARE = "firmware" +PYNUT_NAME = "name" SENSOR_TYPES = { - "ups.status.display": ["Status", "", "mdi:information-outline"], - "ups.status": ["Status Data", "", "mdi:information-outline"], - "ups.alarm": ["Alarms", "", "mdi:alarm"], - "ups.temperature": ["UPS Temperature", TEMP_CELSIUS, "mdi:thermometer"], - "ups.load": ["Load", UNIT_PERCENTAGE, "mdi:gauge"], - "ups.load.high": ["Overload Setting", UNIT_PERCENTAGE, "mdi:gauge"], - "ups.id": ["System identifier", "", "mdi:information-outline"], - "ups.delay.start": ["Load Restart Delay", TIME_SECONDS, "mdi:timer"], - "ups.delay.reboot": ["UPS Reboot Delay", TIME_SECONDS, "mdi:timer"], - "ups.delay.shutdown": ["UPS Shutdown Delay", TIME_SECONDS, "mdi:timer"], - "ups.timer.start": ["Load Start Timer", TIME_SECONDS, "mdi:timer"], - "ups.timer.reboot": ["Load Reboot Timer", TIME_SECONDS, "mdi:timer"], - "ups.timer.shutdown": ["Load Shutdown Timer", TIME_SECONDS, "mdi:timer"], - "ups.test.interval": ["Self-Test Interval", TIME_SECONDS, "mdi:timer"], - "ups.test.result": ["Self-Test Result", "", "mdi:information-outline"], - "ups.test.date": ["Self-Test Date", "", "mdi:calendar"], - "ups.display.language": ["Language", "", "mdi:information-outline"], - "ups.contacts": ["External Contacts", "", "mdi:information-outline"], - "ups.efficiency": ["Efficiency", UNIT_PERCENTAGE, "mdi:gauge"], - "ups.power": ["Current Apparent Power", "VA", "mdi:flash"], - "ups.power.nominal": ["Nominal Power", "VA", "mdi:flash"], - "ups.realpower": ["Current Real Power", POWER_WATT, "mdi:flash"], - "ups.realpower.nominal": ["Nominal Real Power", POWER_WATT, "mdi:flash"], - "ups.beeper.status": ["Beeper Status", "", "mdi:information-outline"], - "ups.type": ["UPS Type", "", "mdi:information-outline"], - "ups.watchdog.status": ["Watchdog Status", "", "mdi:information-outline"], - "ups.start.auto": ["Start on AC", "", "mdi:information-outline"], - "ups.start.battery": ["Start on Battery", "", "mdi:information-outline"], - "ups.start.reboot": ["Reboot on Battery", "", "mdi:information-outline"], - "ups.shutdown": ["Shutdown Ability", "", "mdi:information-outline"], - "battery.charge": ["Battery Charge", UNIT_PERCENTAGE, "mdi:gauge"], - "battery.charge.low": ["Low Battery Setpoint", UNIT_PERCENTAGE, "mdi:gauge"], + "ups.status.display": ["Status", "", "mdi:information-outline", None], + "ups.status": ["Status Data", "", "mdi:information-outline", None], + "ups.alarm": ["Alarms", "", "mdi:alarm", None], + "ups.temperature": [ + "UPS Temperature", + TEMP_CELSIUS, + "mdi:thermometer", + DEVICE_CLASS_TEMPERATURE, + ], + "ups.load": ["Load", UNIT_PERCENTAGE, "mdi:gauge", None], + "ups.load.high": ["Overload Setting", UNIT_PERCENTAGE, "mdi:gauge", None], + "ups.id": ["System identifier", "", "mdi:information-outline", None], + "ups.delay.start": ["Load Restart Delay", TIME_SECONDS, "mdi:timer", None], + "ups.delay.reboot": ["UPS Reboot Delay", TIME_SECONDS, "mdi:timer", None], + "ups.delay.shutdown": ["UPS Shutdown Delay", TIME_SECONDS, "mdi:timer", None], + "ups.timer.start": ["Load Start Timer", TIME_SECONDS, "mdi:timer", None], + "ups.timer.reboot": ["Load Reboot Timer", TIME_SECONDS, "mdi:timer", None], + "ups.timer.shutdown": ["Load Shutdown Timer", TIME_SECONDS, "mdi:timer", None], + "ups.test.interval": ["Self-Test Interval", TIME_SECONDS, "mdi:timer", None], + "ups.test.result": ["Self-Test Result", "", "mdi:information-outline", None], + "ups.test.date": ["Self-Test Date", "", "mdi:calendar", None], + "ups.display.language": ["Language", "", "mdi:information-outline", None], + "ups.contacts": ["External Contacts", "", "mdi:information-outline", None], + "ups.efficiency": ["Efficiency", UNIT_PERCENTAGE, "mdi:gauge", None], + "ups.power": ["Current Apparent Power", "VA", "mdi:flash", None], + "ups.power.nominal": ["Nominal Power", "VA", "mdi:flash", None], + "ups.realpower": [ + "Current Real Power", + POWER_WATT, + "mdi:flash", + DEVICE_CLASS_POWER, + ], + "ups.realpower.nominal": [ + "Nominal Real Power", + POWER_WATT, + "mdi:flash", + DEVICE_CLASS_POWER, + ], + "ups.beeper.status": ["Beeper Status", "", "mdi:information-outline", None], + "ups.type": ["UPS Type", "", "mdi:information-outline", None], + "ups.watchdog.status": ["Watchdog Status", "", "mdi:information-outline", None], + "ups.start.auto": ["Start on AC", "", "mdi:information-outline", None], + "ups.start.battery": ["Start on Battery", "", "mdi:information-outline", None], + "ups.start.reboot": ["Reboot on Battery", "", "mdi:information-outline", None], + "ups.shutdown": ["Shutdown Ability", "", "mdi:information-outline", None], + "battery.charge": [ + "Battery Charge", + UNIT_PERCENTAGE, + "mdi:gauge", + DEVICE_CLASS_BATTERY, + ], + "battery.charge.low": ["Low Battery Setpoint", UNIT_PERCENTAGE, "mdi:gauge", None], "battery.charge.restart": [ "Minimum Battery to Start", UNIT_PERCENTAGE, "mdi:gauge", + None, ], "battery.charge.warning": [ "Warning Battery Setpoint", UNIT_PERCENTAGE, "mdi:gauge", + None, ], - "battery.charger.status": ["Charging Status", "", "mdi:information-outline"], - "battery.voltage": ["Battery Voltage", "V", "mdi:flash"], - "battery.voltage.nominal": ["Nominal Battery Voltage", "V", "mdi:flash"], - "battery.voltage.low": ["Low Battery Voltage", "V", "mdi:flash"], - "battery.voltage.high": ["High Battery Voltage", "V", "mdi:flash"], - "battery.capacity": ["Battery Capacity", "Ah", "mdi:flash"], - "battery.current": ["Battery Current", "A", "mdi:flash"], - "battery.current.total": ["Total Battery Current", "A", "mdi:flash"], - "battery.temperature": ["Battery Temperature", TEMP_CELSIUS, "mdi:thermometer"], - "battery.runtime": ["Battery Runtime", TIME_SECONDS, "mdi:timer"], - "battery.runtime.low": ["Low Battery Runtime", TIME_SECONDS, "mdi:timer"], + "battery.charger.status": ["Charging Status", "", "mdi:information-outline", None], + "battery.voltage": ["Battery Voltage", "V", "mdi:flash", None], + "battery.voltage.nominal": ["Nominal Battery Voltage", "V", "mdi:flash", None], + "battery.voltage.low": ["Low Battery Voltage", "V", "mdi:flash", None], + "battery.voltage.high": ["High Battery Voltage", "V", "mdi:flash", None], + "battery.capacity": ["Battery Capacity", "Ah", "mdi:flash", None], + "battery.current": ["Battery Current", "A", "mdi:flash", None], + "battery.current.total": ["Total Battery Current", "A", "mdi:flash", None], + "battery.temperature": [ + "Battery Temperature", + TEMP_CELSIUS, + "mdi:thermometer", + DEVICE_CLASS_TEMPERATURE, + ], + "battery.runtime": ["Battery Runtime", TIME_SECONDS, "mdi:timer", None], + "battery.runtime.low": ["Low Battery Runtime", TIME_SECONDS, "mdi:timer", None], "battery.runtime.restart": [ "Minimum Battery Runtime to Start", TIME_SECONDS, "mdi:timer", + None, ], "battery.alarm.threshold": [ "Battery Alarm Threshold", "", "mdi:information-outline", + None, ], - "battery.date": ["Battery Date", "", "mdi:calendar"], - "battery.mfr.date": ["Battery Manuf. Date", "", "mdi:calendar"], - "battery.packs": ["Number of Batteries", "", "mdi:information-outline"], - "battery.packs.bad": ["Number of Bad Batteries", "", "mdi:information-outline"], - "battery.type": ["Battery Chemistry", "", "mdi:information-outline"], - "input.sensitivity": ["Input Power Sensitivity", "", "mdi:information-outline"], - "input.transfer.low": ["Low Voltage Transfer", "V", "mdi:flash"], - "input.transfer.high": ["High Voltage Transfer", "V", "mdi:flash"], - "input.transfer.reason": ["Voltage Transfer Reason", "", "mdi:information-outline"], - "input.voltage": ["Input Voltage", "V", "mdi:flash"], - "input.voltage.nominal": ["Nominal Input Voltage", "V", "mdi:flash"], - "input.frequency": ["Input Line Frequency", "hz", "mdi:flash"], - "input.frequency.nominal": ["Nominal Input Line Frequency", "hz", "mdi:flash"], - "input.frequency.status": ["Input Frequency Status", "", "mdi:information-outline"], - "output.current": ["Output Current", "A", "mdi:flash"], - "output.current.nominal": ["Nominal Output Current", "A", "mdi:flash"], - "output.voltage": ["Output Voltage", "V", "mdi:flash"], - "output.voltage.nominal": ["Nominal Output Voltage", "V", "mdi:flash"], - "output.frequency": ["Output Frequency", "hz", "mdi:flash"], - "output.frequency.nominal": ["Nominal Output Frequency", "hz", "mdi:flash"], + "battery.date": ["Battery Date", "", "mdi:calendar", None], + "battery.mfr.date": ["Battery Manuf. Date", "", "mdi:calendar", None], + "battery.packs": ["Number of Batteries", "", "mdi:information-outline", None], + "battery.packs.bad": [ + "Number of Bad Batteries", + "", + "mdi:information-outline", + None, + ], + "battery.type": ["Battery Chemistry", "", "mdi:information-outline", None], + "input.sensitivity": [ + "Input Power Sensitivity", + "", + "mdi:information-outline", + None, + ], + "input.transfer.low": ["Low Voltage Transfer", "V", "mdi:flash", None], + "input.transfer.high": ["High Voltage Transfer", "V", "mdi:flash", None], + "input.transfer.reason": [ + "Voltage Transfer Reason", + "", + "mdi:information-outline", + None, + ], + "input.voltage": ["Input Voltage", "V", "mdi:flash", None], + "input.voltage.nominal": ["Nominal Input Voltage", "V", "mdi:flash", None], + "input.frequency": ["Input Line Frequency", "hz", "mdi:flash", None], + "input.frequency.nominal": [ + "Nominal Input Line Frequency", + "hz", + "mdi:flash", + None, + ], + "input.frequency.status": [ + "Input Frequency Status", + "", + "mdi:information-outline", + None, + ], + "output.current": ["Output Current", "A", "mdi:flash", None], + "output.current.nominal": ["Nominal Output Current", "A", "mdi:flash", None], + "output.voltage": ["Output Voltage", "V", "mdi:flash", None], + "output.voltage.nominal": ["Nominal Output Voltage", "V", "mdi:flash", None], + "output.frequency": ["Output Frequency", "hz", "mdi:flash", None], + "output.frequency.nominal": ["Nominal Output Frequency", "hz", "mdi:flash", None], } STATE_TYPES = { @@ -123,3 +183,8 @@ STATE_TYPES = { "FSD": "Forced Shutdown", "ALARM": "Alarm", } + +SENSOR_NAME = 0 +SENSOR_UNIT = 1 +SENSOR_ICON = 2 +SENSOR_DEVICE_CLASS = 3 diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index f6e809d5280..15c09001762 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -31,9 +31,14 @@ from .const import ( PYNUT_FIRMWARE, PYNUT_MANUFACTURER, PYNUT_MODEL, + PYNUT_NAME, PYNUT_STATUS, PYNUT_UNIQUE_ID, + SENSOR_DEVICE_CLASS, + SENSOR_ICON, + SENSOR_NAME, SENSOR_TYPES, + SENSOR_UNIT, STATE_TYPES, ) @@ -69,7 +74,6 @@ def setup_platform(hass, config, add_entities, discovery_info=None): async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the NUT sensors.""" - config = config_entry.data pynut_data = hass.data[DOMAIN][config_entry.entry_id] data = pynut_data[PYNUT_DATA] status = pynut_data[PYNUT_STATUS] @@ -77,10 +81,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): manufacturer = pynut_data[PYNUT_MANUFACTURER] model = pynut_data[PYNUT_MODEL] firmware = pynut_data[PYNUT_FIRMWARE] + name = pynut_data[PYNUT_NAME] entities = [] - name = config[CONF_NAME] if CONF_RESOURCES in config_entry.options: resources = config_entry.options[CONF_RESOURCES] else: @@ -96,7 +100,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ): entities.append( NUTSensor( - name, data, sensor_type, unique_id, manufacturer, model, firmware + name.title(), + data, + sensor_type, + unique_id, + manufacturer, + model, + firmware, ) ) else: @@ -122,8 +132,8 @@ class NUTSensor(Entity): self._firmware = firmware self._model = model self._device_name = name - self._name = f"{name} {SENSOR_TYPES[sensor_type][0]}" - self._unit = SENSOR_TYPES[sensor_type][1] + self._name = f"{name} {SENSOR_TYPES[sensor_type][SENSOR_NAME]}" + self._unit = SENSOR_TYPES[sensor_type][SENSOR_UNIT] self._state = None self._unique_id = unique_id self._display_state = None @@ -161,7 +171,16 @@ class NUTSensor(Entity): @property def icon(self): """Icon to use in the frontend, if any.""" - return SENSOR_TYPES[self._type][2] + if SENSOR_TYPES[self._type][SENSOR_DEVICE_CLASS]: + # The UI will assign an icon + # if it has a class + return None + return SENSOR_TYPES[self._type][SENSOR_ICON] + + @property + def device_class(self): + """Device class of the sensor.""" + return SENSOR_TYPES[self._type][SENSOR_DEVICE_CLASS] @property def state(self): diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 8e5f5ee2fcb..6519d914df2 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -4,14 +4,23 @@ "step": { "user": { "title": "Connect to the NUT server", - "description": "If there are multiple UPSs attached to the NUT server, enter the name UPS to query in the 'Alias' field.", "data": { - "name": "Name", "host": "Host", "port": "Port", - "alias": "Alias", "username": "Username", - "password": "Password", + "password": "Password" + } + }, + "ups": { + "title": "Choose the UPS to Monitor", + "data": { + "alias": "Alias", + "resources": "Resources" + } + }, + "resources": { + "title": "Choose the Resources to Monitor", + "data": { "resources": "Resources" } } @@ -27,7 +36,7 @@ "options": { "step": { "init": { - "description": "Choose Sensor Resources", + "description": "Choose Sensor Resources.", "data": { "resources": "Resources" } diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index 362f6c0b2ba..38953ebd235 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -1,18 +1,28 @@ """Test the Network UPS Tools (NUT) config flow.""" from asynctest import MagicMock, patch -from homeassistant import config_entries, setup +from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.nut.const import DOMAIN +from homeassistant.const import CONF_RESOURCES + +from tests.common import MockConfigEntry + +VALID_CONFIG = { + "host": "localhost", + "port": 123, + "name": "name", + "resources": ["battery.charge"], +} -def _get_mock_pynutclient(list_vars=None): +def _get_mock_pynutclient(list_vars=None, list_ups=None): pynutclient = MagicMock() - type(pynutclient).list_ups = MagicMock(return_value=["ups1"]) + type(pynutclient).list_ups = MagicMock(return_value=list_ups) type(pynutclient).list_vars = MagicMock(return_value=list_vars) return pynutclient -async def test_form(hass): +async def test_form_user_one_ups(hass): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -21,7 +31,25 @@ async def test_form(hass): assert result["type"] == "form" assert result["errors"] == {} - mock_pynut = _get_mock_pynutclient(list_vars={"battery.voltage": "voltage"}) + mock_pynut = _get_mock_pynutclient( + list_vars={"battery.voltage": "voltage"}, list_ups=["ups1"] + ) + + with patch( + "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + "port": 2222, + }, + ) + + assert result2["step_id"] == "resources" + assert result2["type"] == "form" with patch( "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut, @@ -30,6 +58,40 @@ async def test_form(hass): ) as mock_setup, patch( "homeassistant.components.nut.async_setup_entry", return_value=True, ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"resources": ["battery.voltage"]}, + ) + + assert result3["type"] == "create_entry" + assert result3["title"] == "1.1.1.1:2222" + assert result3["data"] == { + "host": "1.1.1.1", + "password": "test-password", + "port": 2222, + "resources": ["battery.voltage"], + "username": "test-username", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_user_multiple_ups(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + mock_pynut = _get_mock_pynutclient( + list_vars={"battery.voltage": "voltage"}, list_ups=["ups1", "ups2"] + ) + + with patch( + "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut, + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -37,20 +99,41 @@ async def test_form(hass): "username": "test-username", "password": "test-password", "port": 2222, - "alias": "ups1", - "resources": ["battery.charge"], }, ) - assert result2["type"] == "create_entry" - assert result2["title"] == "ups1@1.1.1.1:2222" - assert result2["data"] == { - "alias": "ups1", + assert result2["step_id"] == "ups" + assert result2["type"] == "form" + + with patch( + "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"alias": "ups2"}, + ) + + assert result3["step_id"] == "resources" + assert result3["type"] == "form" + + with patch( + "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut, + ), patch( + "homeassistant.components.nut.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.nut.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], {"resources": ["battery.voltage"]}, + ) + + assert result4["type"] == "create_entry" + assert result4["title"] == "ups2@1.1.1.1:2222" + assert result4["data"] == { "host": "1.1.1.1", - "name": "NUT UPS", "password": "test-password", + "alias": "ups2", "port": 2222, - "resources": ["battery.charge"], + "resources": ["battery.voltage"], "username": "test-username", } await hass.async_block_till_done() @@ -62,7 +145,9 @@ async def test_form_import(hass): """Test we get the form with import source.""" await setup.async_setup_component(hass, "persistent_notification", {}) - mock_pynut = _get_mock_pynutclient(list_vars={"battery.voltage": "serial"}) + mock_pynut = _get_mock_pynutclient( + list_vars={"battery.voltage": "serial"}, list_ups=["ups1"] + ) with patch( "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut, @@ -95,6 +180,20 @@ async def test_form_import(hass): assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_import_dupe(hass): + """Test we get abort on duplicate import.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "import"}, data=VALID_CONFIG + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + async def test_form_cannot_connect(hass): """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( @@ -113,10 +212,39 @@ async def test_form_cannot_connect(hass): "username": "test-username", "password": "test-password", "port": 2222, - "alias": "ups1", - "resources": ["battery.charge"], }, ) assert result2["type"] == "form" assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_options_flow(hass): + """Test config flow options.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="abcde12345", + data=VALID_CONFIG, + options={CONF_RESOURCES: ["battery.charge"]}, + ) + config_entry.add_to_hass(hass) + + mock_pynut = _get_mock_pynutclient( + list_vars={"battery.voltage": "voltage"}, list_ups=["ups1"] + ) + + with patch( + "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut, + ), patch("homeassistant.components.nut.async_setup_entry", return_value=True): + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_RESOURCES: ["battery.voltage"]} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == {CONF_RESOURCES: ["battery.voltage"]}