diff --git a/.strict-typing b/.strict-typing index e93f1589cc8..1a5133efe89 100644 --- a/.strict-typing +++ b/.strict-typing @@ -401,6 +401,7 @@ homeassistant.components.select.* homeassistant.components.sensibo.* homeassistant.components.sensirion_ble.* homeassistant.components.sensor.* +homeassistant.components.sensoterra.* homeassistant.components.senz.* homeassistant.components.sfr_box.* homeassistant.components.shelly.* diff --git a/CODEOWNERS b/CODEOWNERS index 42d96ceb941..edd10858e8d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1288,6 +1288,8 @@ build.json @home-assistant/supervisor /tests/components/sensorpro/ @bdraco /homeassistant/components/sensorpush/ @bdraco /tests/components/sensorpush/ @bdraco +/homeassistant/components/sensoterra/ @markruys +/tests/components/sensoterra/ @markruys /homeassistant/components/sentry/ @dcramer @frenck /tests/components/sentry/ @dcramer @frenck /homeassistant/components/senz/ @milanmeu diff --git a/homeassistant/components/sensoterra/__init__.py b/homeassistant/components/sensoterra/__init__.py new file mode 100644 index 00000000000..b1428351f09 --- /dev/null +++ b/homeassistant/components/sensoterra/__init__.py @@ -0,0 +1,38 @@ +"""The Sensoterra integration.""" + +from __future__ import annotations + +from sensoterra.customerapi import CustomerApi + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant + +from .coordinator import SensoterraCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +type SensoterraConfigEntry = ConfigEntry[SensoterraCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: SensoterraConfigEntry) -> bool: + """Set up Sensoterra platform based on a configuration entry.""" + + # Create a coordinator and add an API instance to it. Store the coordinator + # in the configuration entry. + api = CustomerApi() + api.set_language(hass.config.language) + api.set_token(entry.data[CONF_TOKEN]) + + coordinator = SensoterraCoordinator(hass, api) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: SensoterraConfigEntry) -> bool: + """Unload the configuration entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sensoterra/config_flow.py b/homeassistant/components/sensoterra/config_flow.py new file mode 100644 index 00000000000..c98710dfa7d --- /dev/null +++ b/homeassistant/components/sensoterra/config_flow.py @@ -0,0 +1,90 @@ +"""Config flow for Sensoterra integration.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any + +from jwt import DecodeError, decode +from sensoterra.customerapi import ( + CustomerApi, + InvalidAuth as StInvalidAuth, + Timeout as StTimeout, +) +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN, LOGGER, TOKEN_EXPIRATION_DAYS + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL, autocomplete="email") + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + + +class SensoterraConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sensoterra.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Create hub entry based on config flow.""" + errors: dict[str, str] = {} + + if user_input is not None: + api = CustomerApi(user_input[CONF_EMAIL], user_input[CONF_PASSWORD]) + # We need a unique tag per HA instance + uuid = self.hass.data["core.uuid"] + expiration = datetime.now() + timedelta(TOKEN_EXPIRATION_DAYS) + + try: + token: str = await api.get_token( + f"Home Assistant {uuid}", "READONLY", expiration + ) + decoded_token = decode( + token, algorithms=["HS256"], options={"verify_signature": False} + ) + + except StInvalidAuth as exp: + LOGGER.error( + "Login attempt with %s: %s", user_input[CONF_EMAIL], exp.message + ) + errors["base"] = "invalid_auth" + except StTimeout: + LOGGER.error("Login attempt with %s: time out", user_input[CONF_EMAIL]) + errors["base"] = "cannot_connect" + except DecodeError: + LOGGER.error("Login attempt with %s: bad token", user_input[CONF_EMAIL]) + errors["base"] = "invalid_access_token" + else: + device_unique_id = decoded_token["sub"] + await self.async_set_unique_id(device_unique_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_EMAIL], + data={ + CONF_TOKEN: token, + CONF_EMAIL: user_input[CONF_EMAIL], + }, + ) + + return self.async_show_form( + step_id=SOURCE_USER, + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/sensoterra/const.py b/homeassistant/components/sensoterra/const.py new file mode 100644 index 00000000000..7c4ccf2944c --- /dev/null +++ b/homeassistant/components/sensoterra/const.py @@ -0,0 +1,10 @@ +"""Constants for the Sensoterra integration.""" + +import logging + +DOMAIN = "sensoterra" +SCAN_INTERVAL_MINUTES = 15 +SENSOR_EXPIRATION_DAYS = 2 +TOKEN_EXPIRATION_DAYS = 10 * 365 +CONFIGURATION_URL = "https://monitor.sensoterra.com" +LOGGER: logging.Logger = logging.getLogger(__package__) diff --git a/homeassistant/components/sensoterra/coordinator.py b/homeassistant/components/sensoterra/coordinator.py new file mode 100644 index 00000000000..2dffdceb443 --- /dev/null +++ b/homeassistant/components/sensoterra/coordinator.py @@ -0,0 +1,54 @@ +"""Polling coordinator for the Sensoterra integration.""" + +from collections.abc import Callable +from datetime import timedelta + +from sensoterra.customerapi import ( + CustomerApi, + InvalidAuth as ApiAuthError, + Timeout as ApiTimeout, +) +from sensoterra.probe import Probe, Sensor + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER, SCAN_INTERVAL_MINUTES + + +class SensoterraCoordinator(DataUpdateCoordinator[list[Probe]]): + """Sensoterra coordinator.""" + + def __init__(self, hass: HomeAssistant, api: CustomerApi) -> None: + """Initialize Sensoterra coordinator.""" + super().__init__( + hass, + LOGGER, + name="Sensoterra probe", + update_interval=timedelta(minutes=SCAN_INTERVAL_MINUTES), + ) + self.api = api + self.add_devices_callback: Callable[[list[Probe]], None] | None = None + + async def _async_update_data(self) -> list[Probe]: + """Fetch data from Sensoterra Customer API endpoint.""" + try: + probes = await self.api.poll() + except ApiAuthError as err: + raise ConfigEntryError(err) from err + except ApiTimeout as err: + raise UpdateFailed("Timeout communicating with Sensotera API") from err + + if self.add_devices_callback is not None: + self.add_devices_callback(probes) + + return probes + + def get_sensor(self, id: str | None) -> Sensor | None: + """Try to find the sensor in the API result.""" + for probe in self.data: + for sensor in probe.sensors(): + if sensor.id == id: + return sensor + return None diff --git a/homeassistant/components/sensoterra/manifest.json b/homeassistant/components/sensoterra/manifest.json new file mode 100644 index 00000000000..942741fdb2f --- /dev/null +++ b/homeassistant/components/sensoterra/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "sensoterra", + "name": "Sensoterra", + "codeowners": ["@markruys"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sensoterra", + "integration_type": "hub", + "iot_class": "cloud_polling", + "requirements": ["sensoterra==2.0.1"] +} diff --git a/homeassistant/components/sensoterra/sensor.py b/homeassistant/components/sensoterra/sensor.py new file mode 100644 index 00000000000..7e9f4d0840e --- /dev/null +++ b/homeassistant/components/sensoterra/sensor.py @@ -0,0 +1,172 @@ +"""Sensoterra devices.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from enum import StrEnum, auto + +from sensoterra.probe import Probe, Sensor + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import SensoterraConfigEntry +from .const import CONFIGURATION_URL, DOMAIN, SENSOR_EXPIRATION_DAYS +from .coordinator import SensoterraCoordinator + + +class ProbeSensorType(StrEnum): + """Generic sensors within a Sensoterra probe.""" + + MOISTURE = auto() + SI = auto() + TEMPERATURE = auto() + BATTERY = auto() + RSSI = auto() + + +SENSORS: dict[ProbeSensorType, SensorEntityDescription] = { + ProbeSensorType.MOISTURE: SensorEntityDescription( + key=ProbeSensorType.MOISTURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + device_class=SensorDeviceClass.MOISTURE, + native_unit_of_measurement=PERCENTAGE, + translation_key="soil_moisture_at_cm", + ), + ProbeSensorType.SI: SensorEntityDescription( + key=ProbeSensorType.SI, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + translation_key="si_at_cm", + ), + ProbeSensorType.TEMPERATURE: SensorEntityDescription( + key=ProbeSensorType.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + ProbeSensorType.BATTERY: SensorEntityDescription( + key=ProbeSensorType.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ProbeSensorType.RSSI: SensorEntityDescription( + key=ProbeSensorType.RSSI, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SensoterraConfigEntry, + async_add_devices: AddEntitiesCallback, +) -> None: + """Set up Sensoterra sensor.""" + + coordinator = entry.runtime_data + + @callback + def _async_add_devices(probes: list[Probe]) -> None: + aha = coordinator.async_contexts() + current_sensors = set(aha) + async_add_devices( + SensoterraEntity( + coordinator, + probe, + sensor, + SENSORS[ProbeSensorType[sensor.type]], + ) + for probe in probes + for sensor in probe.sensors() + if sensor.type is not None + and sensor.type.lower() in SENSORS + and sensor.id not in current_sensors + ) + + coordinator.add_devices_callback = _async_add_devices + + _async_add_devices(coordinator.data) + + +class SensoterraEntity(CoordinatorEntity[SensoterraCoordinator], SensorEntity): + """Sensoterra sensor like a soil moisture or temperature sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SensoterraCoordinator, + probe: Probe, + sensor: Sensor, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize entity.""" + super().__init__(coordinator, context=sensor.id) + + self._sensor_id = sensor.id + self._attr_unique_id = self._sensor_id + self._attr_translation_placeholders = { + "depth": "?" if sensor.depth is None else str(sensor.depth) + } + + self.entity_description = entity_description + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, probe.serial)}, + name=probe.name, + model=probe.sku, + manufacturer="Sensoterra", + serial_number=probe.serial, + suggested_area=probe.location, + configuration_url=CONFIGURATION_URL, + ) + + @property + def sensor(self) -> Sensor | None: + """Return the sensor, or None if it doesn't exist.""" + return self.coordinator.get_sensor(self._sensor_id) + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + assert self.sensor + return self.sensor.value + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if not super().available or (sensor := self.sensor) is None: + return False + + if sensor.timestamp is None: + return False + + # Expire sensor if no update within the last few days. + expiration = datetime.now(UTC) - timedelta(days=SENSOR_EXPIRATION_DAYS) + return sensor.timestamp >= expiration diff --git a/homeassistant/components/sensoterra/strings.json b/homeassistant/components/sensoterra/strings.json new file mode 100644 index 00000000000..86c4f2c2912 --- /dev/null +++ b/homeassistant/components/sensoterra/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter credentials to obtain a token", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reconfigure": { + "description": "[%key:component::sensoterra::config::step::user::description%]", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "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%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "entity": { + "sensor": { + "soil_moisture_at_cm": { + "name": "Soil moisture @ {depth} cm" + }, + "si_at_cm": { + "name": "SI @ {depth} cm" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c7c8cd0f9f1..9f4b4e42bb0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -513,6 +513,7 @@ FLOWS = { "sensirion_ble", "sensorpro", "sensorpush", + "sensoterra", "sentry", "senz", "seventeentrack", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f6854aeb58d..b4c80aa70b4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5391,6 +5391,12 @@ "config_flow": true, "iot_class": "local_push" }, + "sensoterra": { + "name": "Sensoterra", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "sentry": { "name": "Sentry", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index b352d2747be..3854477b94b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3766,6 +3766,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.sensoterra.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.senz.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 77f7e50674a..1a05bcdde77 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2609,6 +2609,9 @@ sensorpro-ble==0.5.3 # homeassistant.components.sensorpush sensorpush-ble==1.6.2 +# homeassistant.components.sensoterra +sensoterra==2.0.1 + # homeassistant.components.sentry sentry-sdk==1.40.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e67ff882b1a..98a26141f1a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2067,6 +2067,9 @@ sensorpro-ble==0.5.3 # homeassistant.components.sensorpush sensorpush-ble==1.6.2 +# homeassistant.components.sensoterra +sensoterra==2.0.1 + # homeassistant.components.sentry sentry-sdk==1.40.3 diff --git a/tests/components/sensoterra/__init__.py b/tests/components/sensoterra/__init__.py new file mode 100644 index 00000000000..f70fede6c09 --- /dev/null +++ b/tests/components/sensoterra/__init__.py @@ -0,0 +1 @@ +"""Tests for the Sensoterra integration.""" diff --git a/tests/components/sensoterra/conftest.py b/tests/components/sensoterra/conftest.py new file mode 100644 index 00000000000..2e19a96543a --- /dev/null +++ b/tests/components/sensoterra/conftest.py @@ -0,0 +1,32 @@ +"""Common fixtures for the Sensoterra tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from .const import API_TOKEN + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sensoterra.async_setup_entry", + return_value=True, + ) as mock_entry: + yield mock_entry + + +@pytest.fixture +def mock_customer_api_client() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with ( + patch( + "homeassistant.components.sensoterra.config_flow.CustomerApi", + autospec=True, + ) as mock_client, + ): + mock = mock_client.return_value + mock.get_token.return_value = API_TOKEN + yield mock diff --git a/tests/components/sensoterra/const.py b/tests/components/sensoterra/const.py new file mode 100644 index 00000000000..c85d675f9d7 --- /dev/null +++ b/tests/components/sensoterra/const.py @@ -0,0 +1,7 @@ +"""Constants for the test Sensoterra integration.""" + +API_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE4NTYzMDQwMDAsInN1YiI6IjM5In0.yxdXXlc1DqopqDRHfAVzFrMqZJl6nKLpu1dV8alHvVY" +API_EMAIL = "test-email@example.com" +API_PASSWORD = "test-password" +HASS_UUID = "phony-unique-id" +SOURCE_USER = "user" diff --git a/tests/components/sensoterra/test_config_flow.py b/tests/components/sensoterra/test_config_flow.py new file mode 100644 index 00000000000..23c57261741 --- /dev/null +++ b/tests/components/sensoterra/test_config_flow.py @@ -0,0 +1,123 @@ +"""Test the Sensoterra config flow.""" + +from unittest.mock import AsyncMock + +from jwt import DecodeError +import pytest +from sensoterra.customerapi import InvalidAuth as StInvalidAuth, Timeout as StTimeout + +from homeassistant.components.sensoterra.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import API_EMAIL, API_PASSWORD, API_TOKEN, HASS_UUID, SOURCE_USER + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_customer_api_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we can finish a config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + hass.data["core.uuid"] = HASS_UUID + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: API_EMAIL, + CONF_PASSWORD: API_PASSWORD, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == API_EMAIL + assert result["data"] == { + CONF_TOKEN: API_TOKEN, + CONF_EMAIL: API_EMAIL, + } + + assert len(mock_customer_api_client.mock_calls) == 1 + + +async def test_form_unique_id( + hass: HomeAssistant, mock_customer_api_client: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + hass.data["core.uuid"] = HASS_UUID + + entry = MockConfigEntry(unique_id="39", domain=DOMAIN) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: API_EMAIL, + CONF_PASSWORD: API_PASSWORD, + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert len(mock_customer_api_client.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (StTimeout, "cannot_connect"), + (StInvalidAuth("Invalid credentials"), "invalid_auth"), + (DecodeError("Bad API token"), "invalid_access_token"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_customer_api_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle config form exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + hass.data["core.uuid"] = HASS_UUID + + mock_customer_api_client.get_token.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: API_EMAIL, + CONF_PASSWORD: API_PASSWORD, + }, + ) + assert result["errors"] == {"base": error} + assert result["type"] is FlowResultType.FORM + + mock_customer_api_client.get_token.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: API_EMAIL, + CONF_PASSWORD: API_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == API_EMAIL + assert result["data"] == { + CONF_TOKEN: API_TOKEN, + CONF_EMAIL: API_EMAIL, + } + assert len(mock_customer_api_client.mock_calls) == 2