From 724f5dbf1a35412c6d850ff046433f7bb348deb5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Jan 2022 09:15:39 -1000 Subject: [PATCH] Add Oncue by Kohler integration (#63203) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/oncue/__init__.py | 54 +++++ homeassistant/components/oncue/config_flow.py | 62 ++++++ homeassistant/components/oncue/const.py | 9 + homeassistant/components/oncue/manifest.json | 9 + homeassistant/components/oncue/sensor.py | 164 +++++++++++++++ homeassistant/components/oncue/strings.json | 20 ++ .../components/oncue/translations/en.json | 20 ++ homeassistant/generated/config_flows.py | 1 + mypy.ini | 11 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/oncue/__init__.py | 197 ++++++++++++++++++ tests/components/oncue/test_config_flow.py | 106 ++++++++++ tests/components/oncue/test_init.py | 69 ++++++ tests/components/oncue/test_sensor.py | 94 +++++++++ 17 files changed, 825 insertions(+) create mode 100644 homeassistant/components/oncue/__init__.py create mode 100644 homeassistant/components/oncue/config_flow.py create mode 100644 homeassistant/components/oncue/const.py create mode 100644 homeassistant/components/oncue/manifest.json create mode 100644 homeassistant/components/oncue/sensor.py create mode 100644 homeassistant/components/oncue/strings.json create mode 100644 homeassistant/components/oncue/translations/en.json create mode 100644 tests/components/oncue/__init__.py create mode 100644 tests/components/oncue/test_config_flow.py create mode 100644 tests/components/oncue/test_init.py create mode 100644 tests/components/oncue/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 2630bd6985b..0dbd24f6c60 100644 --- a/.strict-typing +++ b/.strict-typing @@ -98,6 +98,7 @@ homeassistant.components.no_ip.* homeassistant.components.notify.* homeassistant.components.notion.* homeassistant.components.number.* +homeassistant.components.oncue.* homeassistant.components.onewire.* homeassistant.components.open_meteo.* homeassistant.components.openuv.* diff --git a/CODEOWNERS b/CODEOWNERS index ad8c7750c30..f2c90a63746 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -646,6 +646,8 @@ homeassistant/components/omnilogic/* @oliver84 @djtimca @gentoosu tests/components/omnilogic/* @oliver84 @djtimca @gentoosu homeassistant/components/onboarding/* @home-assistant/core tests/components/onboarding/* @home-assistant/core +homeassistant/components/oncue/* @bdraco +tests/components/oncue/* @bdraco homeassistant/components/ondilo_ico/* @JeromeHXP tests/components/ondilo_ico/* @JeromeHXP homeassistant/components/onewire/* @garbled1 @epenet diff --git a/homeassistant/components/oncue/__init__.py b/homeassistant/components/oncue/__init__.py new file mode 100644 index 00000000000..150d8c7f01d --- /dev/null +++ b/homeassistant/components/oncue/__init__.py @@ -0,0 +1,54 @@ +"""The Oncue integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from aiooncue import LoginFailedException, Oncue + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONNECTION_EXCEPTIONS, DOMAIN + +PLATFORMS: list[str] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Oncue from a config entry.""" + data = entry.data + websession = async_get_clientsession(hass) + client = Oncue(data[CONF_USERNAME], data[CONF_PASSWORD], websession) + try: + await client.async_login() + except CONNECTION_EXCEPTIONS as ex: + raise ConfigEntryNotReady(ex) from ex + except LoginFailedException as ex: + _LOGGER.error("Failed to login to oncue service: %s", ex) + return False + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"Oncue {entry.data[CONF_USERNAME]}", + update_interval=timedelta(minutes=10), + update_method=client.async_fetch_all, + ) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/oncue/config_flow.py b/homeassistant/components/oncue/config_flow.py new file mode 100644 index 00000000000..cedb4feb7a4 --- /dev/null +++ b/homeassistant/components/oncue/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for Oncue integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from aiooncue import LoginFailedException, Oncue +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONNECTION_EXCEPTIONS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Oncue.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + + if user_input is not None: + try: + await Oncue( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + async_get_clientsession(self.hass), + ).async_login() + except CONNECTION_EXCEPTIONS: + errors["base"] = "cannot_connect" + except LoginFailedException: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + normalized_username = user_input[CONF_USERNAME].lower() + await self.async_set_unique_id(normalized_username) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=normalized_username, data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/oncue/const.py b/homeassistant/components/oncue/const.py new file mode 100644 index 00000000000..5adabc84bcf --- /dev/null +++ b/homeassistant/components/oncue/const.py @@ -0,0 +1,9 @@ +"""Constants for the Oncue integration.""" + +import asyncio + +import aiohttp + +DOMAIN = "oncue" + +CONNECTION_EXCEPTIONS = (asyncio.TimeoutError, aiohttp.ClientError) diff --git a/homeassistant/components/oncue/manifest.json b/homeassistant/components/oncue/manifest.json new file mode 100644 index 00000000000..96b8453c1b7 --- /dev/null +++ b/homeassistant/components/oncue/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "oncue", + "name": "Oncue by Kohler", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/oncue", + "requirements": ["aiooncue==0.3.0"], + "codeowners": ["@bdraco"], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/oncue/sensor.py b/homeassistant/components/oncue/sensor.py new file mode 100644 index 00000000000..0a0eadc253c --- /dev/null +++ b/homeassistant/components/oncue/sensor.py @@ -0,0 +1,164 @@ +"""Support for Oncue sensors.""" +from __future__ import annotations + +from aiooncue import OncueDevice, OncueSensor + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ELECTRIC_POTENTIAL_VOLT, + FREQUENCY_HERTZ, + PERCENTAGE, + POWER_WATT, + PRESSURE_PSI, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="LatestFirmware", + icon="mdi:update", + ), + SensorEntityDescription(key="EngineSpeed", icon="mdi:speedometer"), + SensorEntityDescription( + key="EngineOilPressure", + native_unit_of_measurement=PRESSURE_PSI, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="EngineCoolantTemperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="BatteryVoltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="LubeOilTemperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="GensetControllerTemperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="EngineCompartmentTemperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="GeneratorTrueTotalPower", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="GeneratorTruePercentOfRatedPower", + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="GeneratorVoltageAverageLineToLine", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="GeneratorFrequency", + native_unit_of_measurement=FREQUENCY_HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription(key="GensetState", icon="mdi:home-lightning-bolt"), + SensorEntityDescription( + key="GensetControllerTotalOperationTime", icon="mdi:hours-24" + ), + SensorEntityDescription(key="EngineTotalRunTime", icon="mdi:hours-24"), + SensorEntityDescription(key="AtsContactorPosition", icon="mdi:electric-switch"), + SensorEntityDescription(key="IPAddress", icon="mdi:ip-network"), + SensorEntityDescription(key="ConnectedServerIPAddress", icon="mdi:server-network"), +) + +SENSOR_MAP = {description.key: description for description in SENSOR_TYPES} + +UNIT_MAPPINGS = { + "C": TEMP_CELSIUS, + "F": TEMP_FAHRENHEIT, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors.""" + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + entities: list[OncueSensorEntity] = [] + devices: dict[str, OncueDevice] = coordinator.data + for device_id, device in devices.items(): + entities.extend( + OncueSensorEntity(coordinator, device_id, device, sensor, SENSOR_MAP[key]) + for key, sensor in device.sensors.items() + if key in SENSOR_MAP + ) + + async_add_entities(entities) + + +class OncueSensorEntity(CoordinatorEntity, SensorEntity): + """Representation of an Oncue sensor.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + device_id: str, + device: OncueDevice, + sensor: OncueSensor, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._device_id = device_id + self._attr_unique_id = f"{device_id}_{description.key}" + self._attr_name = f"{device.name} {sensor.display_name}" + if not description.native_unit_of_measurement and sensor.unit is not None: + self._attr_native_unit_of_measurement = UNIT_MAPPINGS.get( + sensor.unit, sensor.unit + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + name=device.name, + hw_version=device.hardware_version, + sw_version=device.sensors["FirmwareVersion"].display_value, + model=device.sensors["GensetModelNumberSelect"].display_value, + manufacturer="Kohler", + ) + + @property + def native_value(self) -> str | None: + """Return the sensors state.""" + device: OncueDevice = self.coordinator.data[self._device_id] + sensor: OncueSensor = device.sensors[self.entity_description.key] + return sensor.value diff --git a/homeassistant/components/oncue/strings.json b/homeassistant/components/oncue/strings.json new file mode 100644 index 00000000000..34c915dce2d --- /dev/null +++ b/homeassistant/components/oncue/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/oncue/translations/en.json b/homeassistant/components/oncue/translations/en.json new file mode 100644 index 00000000000..cb0e7bed7ea --- /dev/null +++ b/homeassistant/components/oncue/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f0a919efdfc..c2c2ce53ed7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -217,6 +217,7 @@ FLOWS = [ "nzbget", "octoprint", "omnilogic", + "oncue", "ondilo_ico", "onewire", "onvif", diff --git a/mypy.ini b/mypy.ini index 4d39c63c1df..389e4b13829 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1089,6 +1089,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.oncue.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.onewire.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index efde5e6d160..c1f7cf12990 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -232,6 +232,9 @@ aionotify==0.2.0 # homeassistant.components.notion aionotion==3.0.2 +# homeassistant.components.oncue +aiooncue==0.3.0 + # homeassistant.components.acmeda aiopulse==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b48ead9351..c900afed2f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -161,6 +161,9 @@ aionanoleaf==0.1.1 # homeassistant.components.notion aionotion==3.0.2 +# homeassistant.components.oncue +aiooncue==0.3.0 + # homeassistant.components.acmeda aiopulse==0.4.3 diff --git a/tests/components/oncue/__init__.py b/tests/components/oncue/__init__.py new file mode 100644 index 00000000000..5acacfdb732 --- /dev/null +++ b/tests/components/oncue/__init__.py @@ -0,0 +1,197 @@ +"""Tests for the Oncue integration.""" +from contextlib import contextmanager +from unittest.mock import patch + +from aiooncue import OncueDevice, OncueSensor + +MOCK_ASYNC_FETCH_ALL = { + "123456": OncueDevice( + name="My Generator", + state="Off", + product_name="RDC 2.4", + hardware_version="319", + serial_number="SERIAL", + sensors={ + "Product": OncueSensor( + name="Product", + display_name="Controller Type", + value="RDC 2.4", + display_value="RDC 2.4", + unit=None, + ), + "FirmwareVersion": OncueSensor( + name="FirmwareVersion", + display_name="Current Firmware", + value="2.0.6", + display_value="2.0.6", + unit=None, + ), + "LatestFirmware": OncueSensor( + name="LatestFirmware", + display_name="Latest Firmware", + value="2.0.6", + display_value="2.0.6", + unit=None, + ), + "EngineSpeed": OncueSensor( + name="EngineSpeed", + display_name="Engine Speed", + value="0", + display_value="0 R/min", + unit="R/min", + ), + "EngineOilPressure": OncueSensor( + name="EngineOilPressure", + display_name="Engine Oil Pressure", + value=0, + display_value="0 Psi", + unit="Psi", + ), + "EngineCoolantTemperature": OncueSensor( + name="EngineCoolantTemperature", + display_name="Engine Coolant Temperature", + value=32, + display_value="32 F", + unit="F", + ), + "BatteryVoltage": OncueSensor( + name="BatteryVoltage", + display_name="Battery Voltage", + value="13.5", + display_value="13.5 V", + unit="V", + ), + "LubeOilTemperature": OncueSensor( + name="LubeOilTemperature", + display_name="Lube Oil Temperature", + value=32, + display_value="32 F", + unit="F", + ), + "GensetControllerTemperature": OncueSensor( + name="GensetControllerTemperature", + display_name="Generator Controller Temperature", + value=100.4, + display_value="100.4 F", + unit="F", + ), + "EngineCompartmentTemperature": OncueSensor( + name="EngineCompartmentTemperature", + display_name="Engine Compartment Temperature", + value=84.2, + display_value="84.2 F", + unit="F", + ), + "GeneratorTrueTotalPower": OncueSensor( + name="GeneratorTrueTotalPower", + display_name="Generator True Total Power", + value="0.0", + display_value="0.0 W", + unit="W", + ), + "GeneratorTruePercentOfRatedPower": OncueSensor( + name="GeneratorTruePercentOfRatedPower", + display_name="Generator True Percent Of Rated Power", + value="0", + display_value="0 %", + unit="%", + ), + "GeneratorVoltageAverageLineToLine": OncueSensor( + name="GeneratorVoltageAverageLineToLine", + display_name="Generator Voltage Average Line To Line", + value="0.0", + display_value="0.0 V", + unit="V", + ), + "GeneratorFrequency": OncueSensor( + name="GeneratorFrequency", + display_name="Generator Frequency", + value="0.0", + display_value="0.0 Hz", + unit="Hz", + ), + "GensetSerialNumber": OncueSensor( + name="GensetSerialNumber", + display_name="Generator Serial Number", + value="33FDGMFR0026", + display_value="33FDGMFR0026", + unit=None, + ), + "GensetState": OncueSensor( + name="GensetState", + display_name="Generator State", + value="Off", + display_value="Off", + unit=None, + ), + "GensetModelNumberSelect": OncueSensor( + name="GensetModelNumberSelect", + display_name="Genset Model Number Select", + value="38 RCLB", + display_value="38 RCLB", + unit=None, + ), + "GensetControllerClockTime": OncueSensor( + name="GensetControllerClockTime", + display_name="Generator Controller Clock Time", + value="2022-01-01 17:20:52", + display_value="2022-01-01 17:20:52", + unit=None, + ), + "GensetControllerTotalOperationTime": OncueSensor( + name="GensetControllerTotalOperationTime", + display_name="Generator Controller Total Operation Time", + value="16482.0", + display_value="16482.0 h", + unit="h", + ), + "EngineTotalRunTime": OncueSensor( + name="EngineTotalRunTime", + display_name="Engine Total Run Time", + value="28.1", + display_value="28.1 h", + unit="h", + ), + "AtsContactorPosition": OncueSensor( + name="AtsContactorPosition", + display_name="Ats Contactor Position", + value="Source1", + display_value="Source1", + unit=None, + ), + "IPAddress": OncueSensor( + name="IPAddress", + display_name="IP Address", + value="1.2.3.4:1026", + display_value="1.2.3.4:1026", + unit=None, + ), + "ConnectedServerIPAddress": OncueSensor( + name="ConnectedServerIPAddress", + display_name="Connected Server IP Address", + value="40.117.195.28", + display_value="40.117.195.28", + unit=None, + ), + "NetworkConnectionEstablished": OncueSensor( + name="NetworkConnectionEstablished", + display_name="Network Connection Established", + value="true", + display_value="True", + unit=None, + ), + }, + ) +} + + +def _patch_login_and_data(): + @contextmanager + def _patcher(): + with patch("homeassistant.components.oncue.Oncue.async_login",), patch( + "homeassistant.components.oncue.Oncue.async_fetch_all", + return_value=MOCK_ASYNC_FETCH_ALL, + ): + yield + + return _patcher() diff --git a/tests/components/oncue/test_config_flow.py b/tests/components/oncue/test_config_flow.py new file mode 100644 index 00000000000..5350270c15b --- /dev/null +++ b/tests/components/oncue/test_config_flow.py @@ -0,0 +1,106 @@ +"""Test the Oncue config flow.""" +import asyncio +from unittest.mock import patch + +from aiooncue import LoginFailedException + +from homeassistant import config_entries +from homeassistant.components.oncue.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch("homeassistant.components.oncue.config_flow.Oncue.async_login"), patch( + "homeassistant.components.oncue.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "TEST-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "test-username" + assert result2["data"] == { + "username": "TEST-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.oncue.config_flow.Oncue.async_login", + side_effect=LoginFailedException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.oncue.config_flow.Oncue.async_login", + side_effect=asyncio.TimeoutError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_exception(hass: HomeAssistant) -> None: + """Test we handle unknown exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.oncue.config_flow.Oncue.async_login", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/oncue/test_init.py b/tests/components/oncue/test_init.py new file mode 100644 index 00000000000..ea733bb13b5 --- /dev/null +++ b/tests/components/oncue/test_init.py @@ -0,0 +1,69 @@ +"""Tests for the oncue component.""" +from __future__ import annotations + +import asyncio +from unittest.mock import patch + +from aiooncue import LoginFailedException + +from homeassistant.components import oncue +from homeassistant.components.oncue.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import _patch_login_and_data + +from tests.common import MockConfigEntry + + +async def test_config_entry_reload(hass: HomeAssistant) -> None: + """Test that a config entry can be reloaded.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, + unique_id="any", + ) + config_entry.add_to_hass(hass) + with _patch_login_and_data(): + await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + + +async def test_config_entry_login_error(hass: HomeAssistant) -> None: + """Test that a config entry is failed on login error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, + unique_id="any", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.oncue.Oncue.async_login", + side_effect=LoginFailedException, + ): + await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + +async def test_config_entry_retry_later(hass: HomeAssistant) -> None: + """Test that a config entry retry on connection error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, + unique_id="any", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.oncue.Oncue.async_login", + side_effect=asyncio.TimeoutError, + ): + await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/oncue/test_sensor.py b/tests/components/oncue/test_sensor.py new file mode 100644 index 00000000000..3f5ee060ff3 --- /dev/null +++ b/tests/components/oncue/test_sensor.py @@ -0,0 +1,94 @@ +"""Tests for the oncue component.""" +from __future__ import annotations + +from homeassistant.components import oncue +from homeassistant.components.oncue.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import _patch_login_and_data + +from tests.common import MockConfigEntry + + +async def test_sensors(hass: HomeAssistant) -> None: + """Test that the sensors are setup with the expected values.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, + unique_id="any", + ) + config_entry.add_to_hass(hass) + with _patch_login_and_data(): + await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + assert len(hass.states.async_all()) == 18 + assert hass.states.get("sensor.my_generator_latest_firmware").state == "2.0.6" + + assert hass.states.get("sensor.my_generator_engine_speed").state == "0" + + assert hass.states.get("sensor.my_generator_engine_oil_pressure").state == "0" + + assert ( + hass.states.get("sensor.my_generator_engine_coolant_temperature").state == "0" + ) + + assert hass.states.get("sensor.my_generator_battery_voltage").state == "13.5" + + assert hass.states.get("sensor.my_generator_lube_oil_temperature").state == "0" + + assert ( + hass.states.get("sensor.my_generator_generator_controller_temperature").state + == "38.0" + ) + + assert ( + hass.states.get("sensor.my_generator_engine_compartment_temperature").state + == "29.0" + ) + + assert ( + hass.states.get("sensor.my_generator_generator_true_total_power").state == "0.0" + ) + + assert ( + hass.states.get( + "sensor.my_generator_generator_true_percent_of_rated_power" + ).state + == "0" + ) + + assert ( + hass.states.get( + "sensor.my_generator_generator_voltage_average_line_to_line" + ).state + == "0.0" + ) + + assert hass.states.get("sensor.my_generator_generator_frequency").state == "0.0" + + assert hass.states.get("sensor.my_generator_generator_state").state == "Off" + + assert ( + hass.states.get( + "sensor.my_generator_generator_controller_total_operation_time" + ).state + == "16482.0" + ) + + assert hass.states.get("sensor.my_generator_engine_total_run_time").state == "28.1" + + assert ( + hass.states.get("sensor.my_generator_ats_contactor_position").state == "Source1" + ) + + assert hass.states.get("sensor.my_generator_ip_address").state == "1.2.3.4:1026" + + assert ( + hass.states.get("sensor.my_generator_connected_server_ip_address").state + == "40.117.195.28" + )