mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 20:27:08 +00:00
Add Sensoterra integration (#119642)
* Initial version * Baseline release * Refactor based on first PR feedback * Refactoring based on second PR feedback * Initial version * Baseline release * Refactor based on first PR feedback * Refactoring based on second PR feedback * Refactoring based on PR feedback * Refactoring based on PR feedback * Remove extra attribute soil type Soil type isn't really a sensor, but more like a configuration entity. Move soil type to a different PR to keep this PR simpler. * Refactor SensoterraSensor to a named tuple * Implement feedback on PR * Remove .coveragerc * Add async_set_unique_id to config flow * Small fix based on feedback * Add test form unique_id * Fix * Fix --------- Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
parent
bbeecb40ae
commit
9e312f2063
@ -401,6 +401,7 @@ homeassistant.components.select.*
|
|||||||
homeassistant.components.sensibo.*
|
homeassistant.components.sensibo.*
|
||||||
homeassistant.components.sensirion_ble.*
|
homeassistant.components.sensirion_ble.*
|
||||||
homeassistant.components.sensor.*
|
homeassistant.components.sensor.*
|
||||||
|
homeassistant.components.sensoterra.*
|
||||||
homeassistant.components.senz.*
|
homeassistant.components.senz.*
|
||||||
homeassistant.components.sfr_box.*
|
homeassistant.components.sfr_box.*
|
||||||
homeassistant.components.shelly.*
|
homeassistant.components.shelly.*
|
||||||
|
@ -1288,6 +1288,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/sensorpro/ @bdraco
|
/tests/components/sensorpro/ @bdraco
|
||||||
/homeassistant/components/sensorpush/ @bdraco
|
/homeassistant/components/sensorpush/ @bdraco
|
||||||
/tests/components/sensorpush/ @bdraco
|
/tests/components/sensorpush/ @bdraco
|
||||||
|
/homeassistant/components/sensoterra/ @markruys
|
||||||
|
/tests/components/sensoterra/ @markruys
|
||||||
/homeassistant/components/sentry/ @dcramer @frenck
|
/homeassistant/components/sentry/ @dcramer @frenck
|
||||||
/tests/components/sentry/ @dcramer @frenck
|
/tests/components/sentry/ @dcramer @frenck
|
||||||
/homeassistant/components/senz/ @milanmeu
|
/homeassistant/components/senz/ @milanmeu
|
||||||
|
38
homeassistant/components/sensoterra/__init__.py
Normal file
38
homeassistant/components/sensoterra/__init__.py
Normal file
@ -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)
|
90
homeassistant/components/sensoterra/config_flow.py
Normal file
90
homeassistant/components/sensoterra/config_flow.py
Normal file
@ -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,
|
||||||
|
)
|
10
homeassistant/components/sensoterra/const.py
Normal file
10
homeassistant/components/sensoterra/const.py
Normal file
@ -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__)
|
54
homeassistant/components/sensoterra/coordinator.py
Normal file
54
homeassistant/components/sensoterra/coordinator.py
Normal file
@ -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
|
10
homeassistant/components/sensoterra/manifest.json
Normal file
10
homeassistant/components/sensoterra/manifest.json
Normal file
@ -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"]
|
||||||
|
}
|
172
homeassistant/components/sensoterra/sensor.py
Normal file
172
homeassistant/components/sensoterra/sensor.py
Normal file
@ -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
|
38
homeassistant/components/sensoterra/strings.json
Normal file
38
homeassistant/components/sensoterra/strings.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -513,6 +513,7 @@ FLOWS = {
|
|||||||
"sensirion_ble",
|
"sensirion_ble",
|
||||||
"sensorpro",
|
"sensorpro",
|
||||||
"sensorpush",
|
"sensorpush",
|
||||||
|
"sensoterra",
|
||||||
"sentry",
|
"sentry",
|
||||||
"senz",
|
"senz",
|
||||||
"seventeentrack",
|
"seventeentrack",
|
||||||
|
@ -5391,6 +5391,12 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "local_push"
|
"iot_class": "local_push"
|
||||||
},
|
},
|
||||||
|
"sensoterra": {
|
||||||
|
"name": "Sensoterra",
|
||||||
|
"integration_type": "hub",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "cloud_polling"
|
||||||
|
},
|
||||||
"sentry": {
|
"sentry": {
|
||||||
"name": "Sentry",
|
"name": "Sentry",
|
||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
|
10
mypy.ini
10
mypy.ini
@ -3766,6 +3766,16 @@ disallow_untyped_defs = true
|
|||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unreachable = 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.*]
|
[mypy-homeassistant.components.senz.*]
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
disallow_incomplete_defs = true
|
disallow_incomplete_defs = true
|
||||||
|
@ -2609,6 +2609,9 @@ sensorpro-ble==0.5.3
|
|||||||
# homeassistant.components.sensorpush
|
# homeassistant.components.sensorpush
|
||||||
sensorpush-ble==1.6.2
|
sensorpush-ble==1.6.2
|
||||||
|
|
||||||
|
# homeassistant.components.sensoterra
|
||||||
|
sensoterra==2.0.1
|
||||||
|
|
||||||
# homeassistant.components.sentry
|
# homeassistant.components.sentry
|
||||||
sentry-sdk==1.40.3
|
sentry-sdk==1.40.3
|
||||||
|
|
||||||
|
@ -2067,6 +2067,9 @@ sensorpro-ble==0.5.3
|
|||||||
# homeassistant.components.sensorpush
|
# homeassistant.components.sensorpush
|
||||||
sensorpush-ble==1.6.2
|
sensorpush-ble==1.6.2
|
||||||
|
|
||||||
|
# homeassistant.components.sensoterra
|
||||||
|
sensoterra==2.0.1
|
||||||
|
|
||||||
# homeassistant.components.sentry
|
# homeassistant.components.sentry
|
||||||
sentry-sdk==1.40.3
|
sentry-sdk==1.40.3
|
||||||
|
|
||||||
|
1
tests/components/sensoterra/__init__.py
Normal file
1
tests/components/sensoterra/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for the Sensoterra integration."""
|
32
tests/components/sensoterra/conftest.py
Normal file
32
tests/components/sensoterra/conftest.py
Normal file
@ -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
|
7
tests/components/sensoterra/const.py
Normal file
7
tests/components/sensoterra/const.py
Normal file
@ -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"
|
123
tests/components/sensoterra/test_config_flow.py
Normal file
123
tests/components/sensoterra/test_config_flow.py
Normal file
@ -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
|
Loading…
x
Reference in New Issue
Block a user