1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 00:20:30 +01:00

Add freshr integration, based on pyfreshr (#164538)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Leon Grave
2026-03-09 15:26:03 +01:00
committed by GitHub
parent c983978a10
commit 6fa8e71b21
22 changed files with 1243 additions and 0 deletions

View File

@@ -212,6 +212,7 @@ homeassistant.components.flexit_bacnet.*
homeassistant.components.flux_led.*
homeassistant.components.folder_watcher.*
homeassistant.components.forecast_solar.*
homeassistant.components.freshr.*
homeassistant.components.fritz.*
homeassistant.components.fritzbox.*
homeassistant.components.fritzbox_callmonitor.*

2
CODEOWNERS generated
View File

@@ -551,6 +551,8 @@ build.json @home-assistant/supervisor
/tests/components/freebox/ @hacf-fr @Quentame
/homeassistant/components/freedompro/ @stefano055415
/tests/components/freedompro/ @stefano055415
/homeassistant/components/freshr/ @SierraNL
/tests/components/freshr/ @SierraNL
/homeassistant/components/fressnapf_tracker/ @eifinger
/tests/components/fressnapf_tracker/ @eifinger
/homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185

View File

@@ -0,0 +1,47 @@
"""The Fresh-r integration."""
import asyncio
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import (
FreshrConfigEntry,
FreshrData,
FreshrDevicesCoordinator,
FreshrReadingsCoordinator,
)
_PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bool:
"""Set up Fresh-r from a config entry."""
devices_coordinator = FreshrDevicesCoordinator(hass, entry)
await devices_coordinator.async_config_entry_first_refresh()
readings: dict[str, FreshrReadingsCoordinator] = {
device.id: FreshrReadingsCoordinator(
hass, entry, device, devices_coordinator.client
)
for device in devices_coordinator.data
}
await asyncio.gather(
*(
coordinator.async_config_entry_first_refresh()
for coordinator in readings.values()
)
)
entry.runtime_data = FreshrData(
devices=devices_coordinator,
readings=readings,
)
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)

View File

@@ -0,0 +1,58 @@
"""Config flow for the Fresh-r integration."""
from __future__ import annotations
from typing import Any
from aiohttp import ClientError
from pyfreshr import FreshrClient
from pyfreshr.exceptions import LoginError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class FreshrFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Fresh-r."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
client = FreshrClient(session=async_get_clientsession(self.hass))
try:
await client.login(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
except LoginError:
errors["base"] = "invalid_auth"
except ClientError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user_input[CONF_USERNAME].lower())
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"Fresh-r ({user_input[CONF_USERNAME]})",
data=user_input,
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@@ -0,0 +1,7 @@
"""Constants for the Fresh-r integration."""
import logging
from typing import Final
DOMAIN: Final = "freshr"
LOGGER = logging.getLogger(__package__)

View File

@@ -0,0 +1,116 @@
"""Coordinator for Fresh-r integration."""
from dataclasses import dataclass
from datetime import timedelta
from aiohttp import ClientError
from pyfreshr import FreshrClient
from pyfreshr.exceptions import ApiResponseError, LoginError
from pyfreshr.models import DeviceReadings, DeviceSummary
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER
DEVICES_SCAN_INTERVAL = timedelta(hours=1)
READINGS_SCAN_INTERVAL = timedelta(minutes=10)
@dataclass
class FreshrData:
"""Runtime data stored on the config entry."""
devices: FreshrDevicesCoordinator
readings: dict[str, FreshrReadingsCoordinator]
type FreshrConfigEntry = ConfigEntry[FreshrData]
class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]):
"""Coordinator that refreshes the device list once an hour."""
config_entry: FreshrConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: FreshrConfigEntry) -> None:
"""Initialize the device list coordinator."""
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}_devices",
update_interval=DEVICES_SCAN_INTERVAL,
)
self.client = FreshrClient(session=async_create_clientsession(hass))
async def _async_update_data(self) -> list[DeviceSummary]:
"""Fetch the list of devices from the Fresh-r API."""
username = self.config_entry.data[CONF_USERNAME]
password = self.config_entry.data[CONF_PASSWORD]
try:
if not self.client.logged_in:
await self.client.login(username, password)
devices = await self.client.fetch_devices()
except LoginError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
except (ApiResponseError, ClientError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
else:
return devices
class FreshrReadingsCoordinator(DataUpdateCoordinator[DeviceReadings]):
"""Coordinator that refreshes readings for a single device every 10 minutes."""
config_entry: FreshrConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: FreshrConfigEntry,
device: DeviceSummary,
client: FreshrClient,
) -> None:
"""Initialize the readings coordinator for a single device."""
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}_readings_{device.id}",
update_interval=READINGS_SCAN_INTERVAL,
)
self._device = device
self._client = client
@property
def device_id(self) -> str:
"""Return the device ID."""
return self._device.id
async def _async_update_data(self) -> DeviceReadings:
"""Fetch current readings for this device from the Fresh-r API."""
try:
return await self._client.fetch_device_current(self._device)
except LoginError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
except (ApiResponseError, ClientError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err

View File

@@ -0,0 +1,18 @@
{
"entity": {
"sensor": {
"dew_point": {
"default": "mdi:thermometer-water"
},
"flow": {
"default": "mdi:fan"
},
"inside_temperature": {
"default": "mdi:home-thermometer"
},
"outside_temperature": {
"default": "mdi:thermometer"
}
}
}
}

View File

@@ -0,0 +1,11 @@
{
"domain": "freshr",
"name": "Fresh-r",
"codeowners": ["@SierraNL"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/freshr",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["pyfreshr==1.2.0"]
}

View File

@@ -0,0 +1,72 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration uses a polling coordinator, not event-driven updates.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: Integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Integration connects to a cloud service; no local network discovery is possible.
discovery: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow.
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -0,0 +1,158 @@
"""Sensor platform for the Fresh-r integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from pyfreshr.models import DeviceReadings, DeviceType
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
StateType,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
UnitOfTemperature,
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import FreshrConfigEntry, FreshrReadingsCoordinator
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class FreshrSensorEntityDescription(SensorEntityDescription):
"""Describes a Fresh-r sensor."""
value_fn: Callable[[DeviceReadings], StateType]
_T1 = FreshrSensorEntityDescription(
key="t1",
translation_key="inside_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda r: r.t1,
)
_T2 = FreshrSensorEntityDescription(
key="t2",
translation_key="outside_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda r: r.t2,
)
_CO2 = FreshrSensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda r: r.co2,
)
_HUM = FreshrSensorEntityDescription(
key="hum",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda r: r.hum,
)
_FLOW = FreshrSensorEntityDescription(
key="flow",
translation_key="flow",
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda r: r.flow,
)
_DP = FreshrSensorEntityDescription(
key="dp",
translation_key="dew_point",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda r: r.dp,
)
_TEMP = FreshrSensorEntityDescription(
key="temp",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda r: r.temp,
)
_DEVICE_TYPE_NAMES: dict[DeviceType, str] = {
DeviceType.FRESH_R: "Fresh-r",
DeviceType.FORWARD: "Fresh-r Forward",
DeviceType.MONITOR: "Fresh-r Monitor",
}
SENSOR_TYPES: dict[DeviceType, tuple[FreshrSensorEntityDescription, ...]] = {
DeviceType.FRESH_R: (_T1, _T2, _CO2, _HUM, _FLOW, _DP),
DeviceType.FORWARD: (_T1, _T2, _CO2, _HUM, _FLOW, _DP, _TEMP),
DeviceType.MONITOR: (_CO2, _HUM, _DP, _TEMP),
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FreshrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Fresh-r sensors from a config entry."""
entities: list[FreshrSensor] = []
for device in config_entry.runtime_data.devices.data:
descriptions = SENSOR_TYPES.get(
device.device_type, SENSOR_TYPES[DeviceType.FRESH_R]
)
device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"),
serial_number=device.id,
manufacturer="Fresh-r",
)
entities.extend(
FreshrSensor(
config_entry.runtime_data.readings[device.id],
description,
device_info,
)
for description in descriptions
)
async_add_entities(entities)
class FreshrSensor(CoordinatorEntity[FreshrReadingsCoordinator], SensorEntity):
"""Representation of a Fresh-r sensor."""
_attr_has_entity_name = True
entity_description: FreshrSensorEntityDescription
def __init__(
self,
coordinator: FreshrReadingsCoordinator,
description: FreshrSensorEntityDescription,
device_info: DeviceInfo,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_device_info = device_info
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the value from coordinator data."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -0,0 +1,51 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"wrong_account": "Cannot change the account username."
},
"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%]"
},
"step": {
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "Your Fresh-r account password.",
"username": "Your Fresh-r account username (email address)."
}
}
}
},
"entity": {
"sensor": {
"dew_point": {
"name": "Dew point"
},
"flow": {
"name": "Air flow rate"
},
"inside_temperature": {
"name": "Inside temperature"
},
"outside_temperature": {
"name": "Outside temperature"
}
}
},
"exceptions": {
"auth_failed": {
"message": "Authentication failed. Check your Fresh-r username and password."
},
"cannot_connect": {
"message": "Could not connect to the Fresh-r service."
}
}
}

View File

@@ -229,6 +229,7 @@ FLOWS = {
"foscam",
"freebox",
"freedompro",
"freshr",
"fressnapf_tracker",
"fritz",
"fritzbox",

View File

@@ -2208,6 +2208,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"freshr": {
"name": "Fresh-r",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"fressnapf_tracker": {
"name": "Fressnapf Tracker",
"integration_type": "hub",

10
mypy.ini generated
View File

@@ -1876,6 +1876,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.freshr.*]
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.fritz.*]
check_untyped_defs = true
disallow_incomplete_defs = true

3
requirements_all.txt generated
View File

@@ -2112,6 +2112,9 @@ pyforked-daapd==0.1.14
# homeassistant.components.freedompro
pyfreedompro==1.1.0
# homeassistant.components.freshr
pyfreshr==1.2.0
# homeassistant.components.fritzbox
pyfritzhome==0.6.20

View File

@@ -1807,6 +1807,9 @@ pyforked-daapd==0.1.14
# homeassistant.components.freedompro
pyfreedompro==1.1.0
# homeassistant.components.freshr
pyfreshr==1.2.0
# homeassistant.components.fritzbox
pyfritzhome==0.6.20

View File

@@ -0,0 +1 @@
"""Tests for the Fresh-r integration."""

View File

@@ -0,0 +1,75 @@
"""Common fixtures for the Fresh-r tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from pyfreshr.models import DeviceReadings, DeviceSummary
import pytest
from homeassistant.components.freshr.const import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
DEVICE_ID = "SN001"
MOCK_DEVICE_CURRENT = DeviceReadings(
t1=21.5,
t2=5.3,
co2=850,
hum=45,
flow=0.12,
dp=10.2,
)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.freshr.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={CONF_USERNAME: "test-user", CONF_PASSWORD: "test-pass"},
unique_id="test-user",
)
@pytest.fixture
def mock_freshr_client() -> Generator[MagicMock]:
"""Return a mocked FreshrClient."""
with (
patch(
"homeassistant.components.freshr.coordinator.FreshrClient", autospec=True
) as mock_client_class,
patch(
"homeassistant.components.freshr.config_flow.FreshrClient",
new=mock_client_class,
),
):
client = mock_client_class.return_value
client.logged_in = False
client.fetch_devices.return_value = [DeviceSummary(id=DEVICE_ID)]
client.fetch_device_current.return_value = MOCK_DEVICE_CURRENT
yield client
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_freshr_client: MagicMock,
) -> MockConfigEntry:
"""Set up the Fresh-r integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry

View File

@@ -0,0 +1,337 @@
# serializer version: 1
# name: test_entities[sensor.fresh_r_air_flow_rate-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.fresh_r_air_flow_rate',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Air flow rate',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.VOLUME_FLOW_RATE: 'volume_flow_rate'>,
'original_icon': None,
'original_name': 'Air flow rate',
'platform': 'freshr',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'flow',
'unique_id': 'SN001_flow',
'unit_of_measurement': <UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR: 'm³/h'>,
})
# ---
# name: test_entities[sensor.fresh_r_air_flow_rate-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'volume_flow_rate',
'friendly_name': 'Fresh-r Air flow rate',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR: 'm³/h'>,
}),
'context': <ANY>,
'entity_id': 'sensor.fresh_r_air_flow_rate',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.12',
})
# ---
# name: test_entities[sensor.fresh_r_carbon_dioxide-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.fresh_r_carbon_dioxide',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Carbon dioxide',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.CO2: 'carbon_dioxide'>,
'original_icon': None,
'original_name': 'Carbon dioxide',
'platform': 'freshr',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'SN001_co2',
'unit_of_measurement': 'ppm',
})
# ---
# name: test_entities[sensor.fresh_r_carbon_dioxide-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'carbon_dioxide',
'friendly_name': 'Fresh-r Carbon dioxide',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'ppm',
}),
'context': <ANY>,
'entity_id': 'sensor.fresh_r_carbon_dioxide',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '850',
})
# ---
# name: test_entities[sensor.fresh_r_dew_point-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.fresh_r_dew_point',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Dew point',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Dew point',
'platform': 'freshr',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dew_point',
'unique_id': 'SN001_dp',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_entities[sensor.fresh_r_dew_point-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Fresh-r Dew point',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.fresh_r_dew_point',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '10.2',
})
# ---
# name: test_entities[sensor.fresh_r_humidity-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.fresh_r_humidity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
'original_icon': None,
'original_name': 'Humidity',
'platform': 'freshr',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'SN001_hum',
'unit_of_measurement': '%',
})
# ---
# name: test_entities[sensor.fresh_r_humidity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'humidity',
'friendly_name': 'Fresh-r Humidity',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.fresh_r_humidity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '45',
})
# ---
# name: test_entities[sensor.fresh_r_inside_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.fresh_r_inside_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Inside temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Inside temperature',
'platform': 'freshr',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'inside_temperature',
'unique_id': 'SN001_t1',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_entities[sensor.fresh_r_inside_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Fresh-r Inside temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.fresh_r_inside_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '21.5',
})
# ---
# name: test_entities[sensor.fresh_r_outside_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.fresh_r_outside_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Outside temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Outside temperature',
'platform': 'freshr',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'outside_temperature',
'unique_id': 'SN001_t2',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_entities[sensor.fresh_r_outside_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Fresh-r Outside temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.fresh_r_outside_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5.3',
})
# ---

View File

@@ -0,0 +1,121 @@
"""Test the Fresh-r config flow."""
from unittest.mock import AsyncMock, MagicMock
from aiohttp import ClientError
from pyfreshr.exceptions import LoginError
import pytest
from homeassistant import config_entries
from homeassistant.components.freshr.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
USER_INPUT = {CONF_USERNAME: "test-user", CONF_PASSWORD: "test-pass"}
@pytest.mark.usefixtures("mock_freshr_client")
async def test_form_success(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
"""Test successful config flow creates an entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=USER_INPUT
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Fresh-r (test-user)"
assert result["data"] == USER_INPUT
assert result["result"].unique_id == "test-user"
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "expected_error"),
[
(LoginError("bad credentials"), "invalid_auth"),
(RuntimeError("unexpected"), "unknown"),
(ClientError("network"), "cannot_connect"),
],
)
async def test_form_error(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_freshr_client: MagicMock,
exception: Exception,
expected_error: str,
) -> None:
"""Test config flow handles login errors and recovers correctly."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
mock_freshr_client.login.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=USER_INPUT
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": expected_error}
# Ensure the flow can recover after providing correct credentials
mock_freshr_client.login.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=USER_INPUT
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_freshr_client")
async def test_form_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test config flow aborts when the account is already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=USER_INPUT
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_freshr_client")
async def test_form_already_configured_case_insensitive(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test config flow aborts when the same account is configured with different casing."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={**USER_INPUT, CONF_USERNAME: USER_INPUT[CONF_USERNAME].upper()},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@@ -0,0 +1,61 @@
"""Test the Fresh-r initialization."""
from aiohttp import ClientError
from pyfreshr.exceptions import ApiResponseError
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .conftest import MagicMock, MockConfigEntry
@pytest.mark.usefixtures("init_integration")
async def test_unload_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test unloading the config entry."""
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize(
"exception",
[ApiResponseError("parse error"), ClientError("network error")],
)
async def test_setup_fetch_devices_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_freshr_client: MagicMock,
exception: Exception,
) -> None:
"""Test that a fetch_devices error during setup triggers a retry."""
mock_freshr_client.fetch_devices.side_effect = exception
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_no_devices(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_freshr_client: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that an empty device list sets up successfully with no entities."""
mock_freshr_client.fetch_devices.return_value = []
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert (
er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id)
== []
)

View File

@@ -0,0 +1,84 @@
"""Test the Fresh-r sensor platform."""
from unittest.mock import MagicMock
from aiohttp import ClientError
from freezegun.api import FrozenDateTimeFactory
from pyfreshr.exceptions import ApiResponseError
from pyfreshr.models import DeviceReadings
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.freshr.const import DOMAIN
from homeassistant.components.freshr.coordinator import READINGS_SCAN_INTERVAL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .conftest import DEVICE_ID
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the sensor entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)})
assert device_entry
entity_entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
for entity_entry in entity_entries:
assert entity_entry.device_id == device_entry.id
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_none_values(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_freshr_client: MagicMock,
) -> None:
"""Test sensors return unknown when all readings are None."""
mock_freshr_client.fetch_device_current.return_value = DeviceReadings(
t1=None, t2=None, co2=None, hum=None, flow=None, dp=None
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
for key in ("t1", "t2", "co2", "hum", "flow", "dp"):
entity_id = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{DEVICE_ID}_{key}"
)
assert entity_id is not None
assert hass.states.get(entity_id).state == "unknown"
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
@pytest.mark.parametrize(
"error",
[ApiResponseError("api error"), ClientError("network error")],
)
async def test_readings_connection_error_makes_unavailable(
hass: HomeAssistant,
mock_freshr_client: MagicMock,
freezer: FrozenDateTimeFactory,
error: Exception,
) -> None:
"""Test that connection errors during readings refresh mark entities unavailable."""
mock_freshr_client.fetch_device_current.side_effect = error
freezer.tick(READINGS_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("sensor.fresh_r_inside_temperature")
assert state is not None
assert state.state == "unavailable"