From cefc535edba7ae15aaf10606e756b0222f26b85f Mon Sep 17 00:00:00 2001 From: Koen van Zuijlen Date: Mon, 8 Aug 2022 23:35:05 +0200 Subject: [PATCH] Add JustNimbus integration (#75718) Co-authored-by: Franck Nijhof --- .coveragerc | 4 + CODEOWNERS | 2 + .../components/justnimbus/__init__.py | 25 +++ .../components/justnimbus/config_flow.py | 60 ++++++ homeassistant/components/justnimbus/const.py | 13 ++ .../components/justnimbus/coordinator.py | 34 +++ homeassistant/components/justnimbus/entity.py | 37 ++++ .../components/justnimbus/manifest.json | 9 + homeassistant/components/justnimbus/sensor.py | 193 ++++++++++++++++++ .../components/justnimbus/strings.json | 19 ++ .../justnimbus/translations/en.json | 19 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/justnimbus/__init__.py | 1 + .../components/justnimbus/test_config_flow.py | 84 ++++++++ 16 files changed, 507 insertions(+) create mode 100644 homeassistant/components/justnimbus/__init__.py create mode 100644 homeassistant/components/justnimbus/config_flow.py create mode 100644 homeassistant/components/justnimbus/const.py create mode 100644 homeassistant/components/justnimbus/coordinator.py create mode 100644 homeassistant/components/justnimbus/entity.py create mode 100644 homeassistant/components/justnimbus/manifest.json create mode 100644 homeassistant/components/justnimbus/sensor.py create mode 100644 homeassistant/components/justnimbus/strings.json create mode 100644 homeassistant/components/justnimbus/translations/en.json create mode 100644 tests/components/justnimbus/__init__.py create mode 100644 tests/components/justnimbus/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 0a1430cce91..8a83b98873e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -602,6 +602,10 @@ omit = homeassistant/components/juicenet/number.py homeassistant/components/juicenet/sensor.py homeassistant/components/juicenet/switch.py + homeassistant/components/justnimbus/const.py + homeassistant/components/justnimbus/coordinator.py + homeassistant/components/justnimbus/entity.py + homeassistant/components/justnimbus/sensor.py homeassistant/components/kaiterra/* homeassistant/components/kankun/switch.py homeassistant/components/keba/* diff --git a/CODEOWNERS b/CODEOWNERS index 292879aabea..c92ad3b5ba9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -555,6 +555,8 @@ build.json @home-assistant/supervisor /tests/components/jewish_calendar/ @tsvi /homeassistant/components/juicenet/ @jesserockz /tests/components/juicenet/ @jesserockz +/homeassistant/components/justnimbus/ @kvanzuijlen +/tests/components/justnimbus/ @kvanzuijlen /homeassistant/components/kaiterra/ @Michsior14 /homeassistant/components/kaleidescape/ @SteveEasley /tests/components/kaleidescape/ @SteveEasley diff --git a/homeassistant/components/justnimbus/__init__.py b/homeassistant/components/justnimbus/__init__.py new file mode 100644 index 00000000000..695faa4f529 --- /dev/null +++ b/homeassistant/components/justnimbus/__init__.py @@ -0,0 +1,25 @@ +"""The JustNimbus integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, PLATFORMS +from .coordinator import JustNimbusCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up JustNimbus from a config entry.""" + coordinator = JustNimbusCoordinator(hass=hass, entry=entry) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(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/justnimbus/config_flow.py b/homeassistant/components/justnimbus/config_flow.py new file mode 100644 index 00000000000..bb55b1852b8 --- /dev/null +++ b/homeassistant/components/justnimbus/config_flow.py @@ -0,0 +1,60 @@ +"""Config flow for JustNimbus integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import justnimbus +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_CLIENT_ID +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_CLIENT_ID): cv.string, + }, +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for JustNimbus.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + await self.async_set_unique_id(user_input[CONF_CLIENT_ID]) + self._abort_if_unique_id_configured() + + client = justnimbus.JustNimbusClient(client_id=user_input[CONF_CLIENT_ID]) + try: + await self.hass.async_add_executor_job(client.get_data) + except justnimbus.InvalidClientID: + errors["base"] = "invalid_auth" + except justnimbus.JustNimbusError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title="JustNimbus", data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/justnimbus/const.py b/homeassistant/components/justnimbus/const.py new file mode 100644 index 00000000000..cf3d4ef825f --- /dev/null +++ b/homeassistant/components/justnimbus/const.py @@ -0,0 +1,13 @@ +"""Constants for the JustNimbus integration.""" + +from typing import Final + +from homeassistant.const import Platform + +DOMAIN = "justnimbus" + +VOLUME_FLOW_RATE_LITERS_PER_MINUTE: Final = "L/min" + +PLATFORMS = [ + Platform.SENSOR, +] diff --git a/homeassistant/components/justnimbus/coordinator.py b/homeassistant/components/justnimbus/coordinator.py new file mode 100644 index 00000000000..606cea0e922 --- /dev/null +++ b/homeassistant/components/justnimbus/coordinator.py @@ -0,0 +1,34 @@ +"""JustNimbus coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +import justnimbus + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CLIENT_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class JustNimbusCoordinator(DataUpdateCoordinator[justnimbus.JustNimbusModel]): + """Data update coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=1), + ) + self._client = justnimbus.JustNimbusClient(client_id=entry.data[CONF_CLIENT_ID]) + + async def _async_update_data(self) -> justnimbus.JustNimbusModel: + """Fetch the latest data from the source.""" + return await self.hass.async_add_executor_job(self._client.get_data) diff --git a/homeassistant/components/justnimbus/entity.py b/homeassistant/components/justnimbus/entity.py new file mode 100644 index 00000000000..f9ea5ba1151 --- /dev/null +++ b/homeassistant/components/justnimbus/entity.py @@ -0,0 +1,37 @@ +"""Base Entity for JustNimbus sensors.""" +from __future__ import annotations + +import justnimbus + +from homeassistant.components.sensor import SensorEntity +from homeassistant.helpers import update_coordinator +from homeassistant.helpers.entity import DeviceInfo + +from . import JustNimbusCoordinator +from .const import DOMAIN + + +class JustNimbusEntity( + update_coordinator.CoordinatorEntity[justnimbus.JustNimbusModel], + SensorEntity, +): + """Defines a base JustNimbus entity.""" + + def __init__( + self, + *, + device_id: str, + coordinator: JustNimbusCoordinator, + ) -> None: + """Initialize the JustNimbus entity.""" + super().__init__(coordinator=coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + name="JustNimbus Sensor", + manufacturer="JustNimbus", + ) + + @property + def available(self) -> bool: + """Return device availability.""" + return super().available and getattr(self.coordinator.data, "error_code") == 0 diff --git a/homeassistant/components/justnimbus/manifest.json b/homeassistant/components/justnimbus/manifest.json new file mode 100644 index 00000000000..ca25832df00 --- /dev/null +++ b/homeassistant/components/justnimbus/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "justnimbus", + "name": "JustNimbus", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/justnimbus", + "requirements": ["justnimbus==0.6.0"], + "codeowners": ["@kvanzuijlen"], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/justnimbus/sensor.py b/homeassistant/components/justnimbus/sensor.py new file mode 100644 index 00000000000..6041f84e25a --- /dev/null +++ b/homeassistant/components/justnimbus/sensor.py @@ -0,0 +1,193 @@ +"""Support for the JustNimbus platform.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_CLIENT_ID, + PRESSURE_BAR, + TEMP_CELSIUS, + VOLUME_LITERS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import JustNimbusCoordinator +from .const import DOMAIN, VOLUME_FLOW_RATE_LITERS_PER_MINUTE +from .entity import JustNimbusEntity + + +@dataclass +class JustNimbusEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[JustNimbusCoordinator], Any] + + +@dataclass +class JustNimbusEntityDescription( + SensorEntityDescription, JustNimbusEntityDescriptionMixin +): + """Describes JustNimbus sensor entity.""" + + +SENSOR_TYPES = ( + JustNimbusEntityDescription( + key="pump_flow", + name="Pump flow", + icon="mdi:pump", + native_unit_of_measurement=VOLUME_FLOW_RATE_LITERS_PER_MINUTE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.data.pump_flow, + ), + JustNimbusEntityDescription( + key="drink_flow", + name="Drink flow", + icon="mdi:water-pump", + native_unit_of_measurement=VOLUME_FLOW_RATE_LITERS_PER_MINUTE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.data.drink_flow, + ), + JustNimbusEntityDescription( + key="pump_pressure", + name="Pump pressure", + native_unit_of_measurement=PRESSURE_BAR, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.data.pump_pressure, + ), + JustNimbusEntityDescription( + key="pump_starts", + name="Pump starts", + icon="mdi:restart", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.data.pump_starts, + ), + JustNimbusEntityDescription( + key="pump_hours", + name="Pump hours", + icon="mdi:clock", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.data.pump_hours, + ), + JustNimbusEntityDescription( + key="reservoir_temp", + name="Reservoir Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.data.reservoir_temp, + ), + JustNimbusEntityDescription( + key="reservoir_content", + name="Reservoir content", + icon="mdi:car-coolant-level", + native_unit_of_measurement=VOLUME_LITERS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.data.reservoir_content, + ), + JustNimbusEntityDescription( + key="total_saved", + name="Total saved", + icon="mdi:water-opacity", + native_unit_of_measurement=VOLUME_LITERS, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.data.total_saved, + ), + JustNimbusEntityDescription( + key="total_replenished", + name="Total replenished", + icon="mdi:water", + native_unit_of_measurement=VOLUME_LITERS, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.data.total_replenished, + ), + JustNimbusEntityDescription( + key="error_code", + name="Error code", + icon="mdi:bug", + entity_registry_enabled_default=False, + native_unit_of_measurement="", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.data.error_code, + ), + JustNimbusEntityDescription( + key="totver", + name="Total use", + icon="mdi:chart-donut", + native_unit_of_measurement=VOLUME_LITERS, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.data.totver, + ), + JustNimbusEntityDescription( + key="reservoir_content_max", + name="Max reservoir content", + icon="mdi:waves", + native_unit_of_measurement=VOLUME_LITERS, + state_class=SensorStateClass.TOTAL, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.data.reservoir_content_max, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the JustNimbus sensor.""" + coordinator: JustNimbusCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + JustNimbusSensor( + device_id=entry.data[CONF_CLIENT_ID], + description=description, + coordinator=coordinator, + ) + for description in SENSOR_TYPES + ) + + +class JustNimbusSensor( + JustNimbusEntity, +): + """Implementation of the JustNimbus sensor.""" + + def __init__( + self, + *, + device_id: str, + description: JustNimbusEntityDescription, + coordinator: JustNimbusCoordinator, + ) -> None: + """Initialize the sensor.""" + self.entity_description: JustNimbusEntityDescription = description + super().__init__( + device_id=device_id, + coordinator=coordinator, + ) + self._attr_unique_id = f"{device_id}_{description.key}" + + @property + def native_value(self) -> StateType: + """Return sensor state.""" + return self.entity_description.value_fn(self.coordinator) diff --git a/homeassistant/components/justnimbus/strings.json b/homeassistant/components/justnimbus/strings.json new file mode 100644 index 00000000000..609b1425e93 --- /dev/null +++ b/homeassistant/components/justnimbus/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "client_id": "Client ID" + } + } + }, + "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%]" + } + } +} diff --git a/homeassistant/components/justnimbus/translations/en.json b/homeassistant/components/justnimbus/translations/en.json new file mode 100644 index 00000000000..31443841e8a --- /dev/null +++ b/homeassistant/components/justnimbus/translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "client_id": "Client ID" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 597cb10fd7f..d9da1ff2057 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -183,6 +183,7 @@ FLOWS = { "izone", "jellyfin", "juicenet", + "justnimbus", "kaleidescape", "keenetic_ndms2", "kmtronic", diff --git a/requirements_all.txt b/requirements_all.txt index d2777a873f3..6b67daab6fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -925,6 +925,9 @@ jellyfin-apiclient-python==1.8.1 # homeassistant.components.rest jsonpath==0.82 +# homeassistant.components.justnimbus +justnimbus==0.6.0 + # homeassistant.components.kaiterra kaiterra-async-client==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75a0c06acc9..0c80e40f5e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -675,6 +675,9 @@ jellyfin-apiclient-python==1.8.1 # homeassistant.components.rest jsonpath==0.82 +# homeassistant.components.justnimbus +justnimbus==0.6.0 + # homeassistant.components.konnected konnected==1.2.0 diff --git a/tests/components/justnimbus/__init__.py b/tests/components/justnimbus/__init__.py new file mode 100644 index 00000000000..46d872e08c6 --- /dev/null +++ b/tests/components/justnimbus/__init__.py @@ -0,0 +1 @@ +"""Tests for the JustNimbus integration.""" diff --git a/tests/components/justnimbus/test_config_flow.py b/tests/components/justnimbus/test_config_flow.py new file mode 100644 index 00000000000..d2cfb64d4c7 --- /dev/null +++ b/tests/components/justnimbus/test_config_flow.py @@ -0,0 +1,84 @@ +"""Test the JustNimbus config flow.""" +from unittest.mock import patch + +from justnimbus.exceptions import InvalidClientID, JustNimbusError +import pytest + +from homeassistant import config_entries +from homeassistant.components.justnimbus.const import DOMAIN +from homeassistant.const import CONF_CLIENT_ID +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +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"] == FlowResultType.FORM + assert result["errors"] is None + + await _set_up_justnimbus(hass=hass, flow_id=result["flow_id"]) + + +@pytest.mark.parametrize( + "side_effect,errors", + ( + ( + InvalidClientID(client_id="test_id"), + {"base": "invalid_auth"}, + ), + ( + JustNimbusError(), + {"base": "cannot_connect"}, + ), + ), +) +async def test_form_errors( + hass: HomeAssistant, + side_effect: JustNimbusError, + errors: dict, +) -> None: + """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "justnimbus.JustNimbusClient.get_data", + side_effect=side_effect, + ): + result2 = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={ + CONF_CLIENT_ID: "test_id", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == errors + + await _set_up_justnimbus(hass=hass, flow_id=result["flow_id"]) + + +async def _set_up_justnimbus(hass: HomeAssistant, flow_id: str) -> None: + """Reusable successful setup of JustNimbus sensor.""" + with patch("justnimbus.JustNimbusClient.get_data"), patch( + "homeassistant.components.justnimbus.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + flow_id=flow_id, + user_input={ + CONF_CLIENT_ID: "test_id", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "JustNimbus" + assert result2["data"] == { + CONF_CLIENT_ID: "test_id", + } + assert len(mock_setup_entry.mock_calls) == 1