Merge remote-tracking branch 'upstream/sub_devices_esphome' into sub_devices_esphome

This commit is contained in:
J. Nick Koston
2025-06-23 23:26:01 +02:00
73 changed files with 4233 additions and 214 deletions

View File

@@ -67,6 +67,7 @@ homeassistant.components.alert.*
homeassistant.components.alexa.*
homeassistant.components.alexa_devices.*
homeassistant.components.alpha_vantage.*
homeassistant.components.altruist.*
homeassistant.components.amazon_polly.*
homeassistant.components.amberelectric.*
homeassistant.components.ambient_network.*

4
CODEOWNERS generated
View File

@@ -93,6 +93,8 @@ build.json @home-assistant/supervisor
/tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
/homeassistant/components/alexa_devices/ @chemelli74
/tests/components/alexa_devices/ @chemelli74
/homeassistant/components/altruist/ @airalab @LoSk-p
/tests/components/altruist/ @airalab @LoSk-p
/homeassistant/components/amazon_polly/ @jschlyter
/homeassistant/components/amberelectric/ @madpilot
/tests/components/amberelectric/ @madpilot
@@ -1580,6 +1582,8 @@ build.json @home-assistant/supervisor
/tests/components/tile/ @bachya
/homeassistant/components/tilt_ble/ @apt-itude
/tests/components/tilt_ble/ @apt-itude
/homeassistant/components/tilt_pi/ @michaelheyman
/tests/components/tilt_pi/ @michaelheyman
/homeassistant/components/time/ @home-assistant/core
/tests/components/time/ @home-assistant/core
/homeassistant/components/time_date/ @fabaff

View File

@@ -1,5 +1,6 @@
{
"domain": "switchbot",
"name": "SwitchBot",
"integrations": ["switchbot", "switchbot_cloud"]
"integrations": ["switchbot", "switchbot_cloud"],
"iot_standards": ["matter"]
}

View File

@@ -0,0 +1,5 @@
{
"domain": "tilt",
"name": "Tilt",
"integrations": ["tilt_ble", "tilt_pi"]
}

View File

@@ -5,6 +5,7 @@ import logging
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import (
HassJobType,
HomeAssistant,
@@ -17,9 +18,17 @@ from homeassistant.helpers import config_validation as cv, storage
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
from .const import DATA_COMPONENT, DATA_PREFERENCES, DOMAIN, AITaskEntityFeature
from .const import (
ATTR_INSTRUCTIONS,
ATTR_TASK_NAME,
DATA_COMPONENT,
DATA_PREFERENCES,
DOMAIN,
SERVICE_GENERATE_TEXT,
AITaskEntityFeature,
)
from .entity import AITaskEntity
from .http import async_setup as async_setup_conversation_http
from .http import async_setup as async_setup_http
from .task import GenTextTask, GenTextTaskResult, async_generate_text
__all__ = [
@@ -45,16 +54,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.data[DATA_COMPONENT] = entity_component
hass.data[DATA_PREFERENCES] = AITaskPreferences(hass)
await hass.data[DATA_PREFERENCES].async_load()
async_setup_conversation_http(hass)
async_setup_http(hass)
hass.services.async_register(
DOMAIN,
"generate_text",
SERVICE_GENERATE_TEXT,
async_service_generate_text,
schema=vol.Schema(
{
vol.Required("task_name"): cv.string,
vol.Optional("entity_id"): cv.entity_id,
vol.Required("instructions"): cv.string,
vol.Required(ATTR_TASK_NAME): cv.string,
vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
vol.Required(ATTR_INSTRUCTIONS): cv.string,
}
),
supports_response=SupportsResponse.ONLY,

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from enum import IntFlag
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Final
from homeassistant.util.hass_dict import HassKey
@@ -17,6 +17,11 @@ DOMAIN = "ai_task"
DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN)
DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences")
SERVICE_GENERATE_TEXT = "generate_text"
ATTR_INSTRUCTIONS: Final = "instructions"
ATTR_TASK_NAME: Final = "task_name"
DEFAULT_SYSTEM_PROMPT = (
"You are a Home Assistant expert and help users with their tasks."
)

View File

@@ -6,7 +6,7 @@ generate_text:
selector:
text:
instructions:
example: "Generate a funny notification that garage door was left open"
example: "Generate a funny notification that the garage door was left open"
required: true
selector:
text:

View File

@@ -5,7 +5,7 @@
"description": "Use AI to run a task that generates text.",
"fields": {
"task_name": {
"name": "Task Name",
"name": "Task name",
"description": "Name of the task."
},
"instructions": {

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature
@@ -21,14 +22,16 @@ async def async_generate_text(
entity_id = hass.data[DATA_PREFERENCES].gen_text_entity_id
if entity_id is None:
raise ValueError("No entity_id provided and no preferred entity set")
raise HomeAssistantError("No entity_id provided and no preferred entity set")
entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
if entity is None:
raise ValueError(f"AI Task entity {entity_id} not found")
raise HomeAssistantError(f"AI Task entity {entity_id} not found")
if AITaskEntityFeature.GENERATE_TEXT not in entity.supported_features:
raise ValueError(f"AI Task entity {entity_id} does not support generating text")
raise HomeAssistantError(
f"AI Task entity {entity_id} does not support generating text"
)
return await entity.internal_async_generate_text(
GenTextTask(

View File

@@ -0,0 +1,27 @@
"""The Altruist integration."""
from __future__ import annotations
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AltruistConfigEntry, AltruistDataUpdateCoordinator
PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: AltruistConfigEntry) -> bool:
"""Set up Altruist from a config entry."""
coordinator = AltruistDataUpdateCoordinator(hass, entry)
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: AltruistConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,107 @@
"""Config flow for the Altruist integration."""
import logging
from typing import Any
from altruistclient import AltruistClient, AltruistDeviceModel, AltruistError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_HOST, DOMAIN
_LOGGER = logging.getLogger(__name__)
class AltruistConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Altruist."""
device: AltruistDeviceModel
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
ip_address = ""
if user_input is not None:
ip_address = user_input[CONF_HOST]
try:
client = await AltruistClient.from_ip_address(
async_get_clientsession(self.hass), ip_address
)
except AltruistError:
errors["base"] = "no_device_found"
else:
self.device = client.device
await self.async_set_unique_id(
client.device_id, raise_on_progress=False
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=self.device.id,
data={
CONF_HOST: ip_address,
},
)
data_schema = self.add_suggested_values_to_schema(
vol.Schema({vol.Required(CONF_HOST): str}),
{CONF_HOST: ip_address},
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
errors=errors,
description_placeholders={
"ip_address": ip_address,
},
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
_LOGGER.debug("Zeroconf discovery: %s", discovery_info)
try:
client = await AltruistClient.from_ip_address(
async_get_clientsession(self.hass), str(discovery_info.ip_address)
)
except AltruistError:
return self.async_abort(reason="no_device_found")
self.device = client.device
_LOGGER.debug("Zeroconf device: %s", client.device)
await self.async_set_unique_id(client.device_id)
self._abort_if_unique_id_configured()
self.context.update(
{
"title_placeholders": {
"name": self.device.id,
}
}
)
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
if user_input is not None:
return self.async_create_entry(
title=self.device.id,
data={
CONF_HOST: self.device.ip_address,
},
)
self._set_confirm_only()
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={
"model": self.device.id,
},
)

View File

@@ -0,0 +1,5 @@
"""Constants for the Altruist integration."""
DOMAIN = "altruist"
CONF_HOST = "host"

View File

@@ -0,0 +1,64 @@
"""Coordinator module for Altruist integration in Home Assistant.
This module defines the AltruistDataUpdateCoordinator class, which manages
data updates for Altruist sensors using the AltruistClient.
"""
from datetime import timedelta
import logging
from altruistclient import AltruistClient, AltruistError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_HOST
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(seconds=15)
type AltruistConfigEntry = ConfigEntry[AltruistDataUpdateCoordinator]
class AltruistDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
"""Coordinates data updates for Altruist sensors."""
client: AltruistClient
def __init__(
self,
hass: HomeAssistant,
config_entry: AltruistConfigEntry,
) -> None:
"""Initialize the data update coordinator for Altruist sensors."""
device_id = config_entry.unique_id
super().__init__(
hass,
logger=_LOGGER,
config_entry=config_entry,
name=f"Altruist {device_id}",
update_interval=UPDATE_INTERVAL,
)
self._ip_address = config_entry.data[CONF_HOST]
async def _async_setup(self) -> None:
try:
self.client = await AltruistClient.from_ip_address(
async_get_clientsession(self.hass), self._ip_address
)
await self.client.fetch_data()
except AltruistError as e:
raise ConfigEntryNotReady("Error in Altruist setup") from e
async def _async_update_data(self) -> dict[str, str]:
try:
fetched_data = await self.client.fetch_data()
except AltruistError as ex:
raise UpdateFailed(
f"The Altruist {self.client.device_id} is unavailable: {ex}"
) from ex
return {item["value_type"]: item["value"] for item in fetched_data}

View File

@@ -0,0 +1,15 @@
{
"entity": {
"sensor": {
"pm_10": {
"default": "mdi:thought-bubble"
},
"pm_25": {
"default": "mdi:thought-bubble-outline"
},
"radiation": {
"default": "mdi:radioactive"
}
}
}
}

View File

@@ -0,0 +1,12 @@
{
"domain": "altruist",
"name": "Altruist",
"codeowners": ["@airalab", "@LoSk-p"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/altruist",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["altruistclient==0.1.1"],
"zeroconf": ["_altruist._tcp.local."]
}

View File

@@ -0,0 +1,83 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration does not explicitly subscribe to events.
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: |
This integration does not provide additional actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
This integration does not provide options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
Device type integration
entity-category: todo
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: No known use cases for repair issues or flows, yet
stale-devices:
status: exempt
comment: |
Device type integration
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -0,0 +1,249 @@
"""Defines the Altruist sensor platform."""
from collections.abc import Callable
from dataclasses import dataclass
import logging
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfPressure,
UnitOfSoundPressure,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import AltruistConfigEntry
from .coordinator import AltruistDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True)
class AltruistSensorEntityDescription(SensorEntityDescription):
"""Class to describe a Sensor entity."""
native_value_fn: Callable[[str], float] = float
state_class = SensorStateClass.MEASUREMENT
SENSOR_DESCRIPTIONS = [
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.HUMIDITY,
key="BME280_humidity",
translation_key="humidity",
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=2,
translation_placeholders={"sensor_name": "BME280"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.PRESSURE,
key="BME280_pressure",
translation_key="pressure",
native_unit_of_measurement=UnitOfPressure.PA,
suggested_unit_of_measurement=UnitOfPressure.MMHG,
suggested_display_precision=0,
translation_placeholders={"sensor_name": "BME280"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.TEMPERATURE,
key="BME280_temperature",
translation_key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=2,
translation_placeholders={"sensor_name": "BME280"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.PRESSURE,
key="BMP_pressure",
translation_key="pressure",
native_unit_of_measurement=UnitOfPressure.PA,
suggested_unit_of_measurement=UnitOfPressure.MMHG,
suggested_display_precision=0,
translation_placeholders={"sensor_name": "BMP"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.TEMPERATURE,
key="BMP_temperature",
translation_key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=2,
translation_placeholders={"sensor_name": "BMP"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.TEMPERATURE,
key="BMP280_temperature",
translation_key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=2,
translation_placeholders={"sensor_name": "BMP280"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.PRESSURE,
key="BMP280_pressure",
translation_key="pressure",
native_unit_of_measurement=UnitOfPressure.PA,
suggested_unit_of_measurement=UnitOfPressure.MMHG,
suggested_display_precision=0,
translation_placeholders={"sensor_name": "BMP280"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.HUMIDITY,
key="HTU21D_humidity",
translation_key="humidity",
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=2,
translation_placeholders={"sensor_name": "HTU21D"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.TEMPERATURE,
key="HTU21D_temperature",
translation_key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=2,
translation_placeholders={"sensor_name": "HTU21D"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.PM10,
translation_key="pm_10",
key="SDS_P1",
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
suggested_display_precision=2,
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.PM25,
translation_key="pm_25",
key="SDS_P2",
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
suggested_display_precision=2,
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.HUMIDITY,
key="SHT3X_humidity",
translation_key="humidity",
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=2,
translation_placeholders={"sensor_name": "SHT3X"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.TEMPERATURE,
key="SHT3X_temperature",
translation_key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=2,
translation_placeholders={"sensor_name": "SHT3X"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
key="signal",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=0,
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.SOUND_PRESSURE,
key="PCBA_noiseMax",
translation_key="noise_max",
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
suggested_display_precision=0,
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.SOUND_PRESSURE,
key="PCBA_noiseAvg",
translation_key="noise_avg",
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
suggested_display_precision=0,
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
translation_key="co2",
key="CCS_CO2",
suggested_display_precision=2,
translation_placeholders={"sensor_name": "CCS"},
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
key="CCS_TVOC",
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
suggested_display_precision=2,
),
AltruistSensorEntityDescription(
key="GC",
native_unit_of_measurement="μR/h",
translation_key="radiation",
suggested_display_precision=2,
),
AltruistSensorEntityDescription(
device_class=SensorDeviceClass.CO2,
translation_key="co2",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
key="SCD4x_co2",
suggested_display_precision=2,
translation_placeholders={"sensor_name": "SCD4x"},
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AltruistConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add sensors for passed config_entry in HA."""
coordinator = config_entry.runtime_data
async_add_entities(
AltruistSensor(coordinator, sensor_description)
for sensor_description in SENSOR_DESCRIPTIONS
if sensor_description.key in coordinator.data
)
class AltruistSensor(CoordinatorEntity[AltruistDataUpdateCoordinator], SensorEntity):
"""Implementation of a Altruist sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AltruistDataUpdateCoordinator,
description: AltruistSensorEntityDescription,
) -> None:
"""Initialize the Altruist sensor."""
super().__init__(coordinator)
self._device = coordinator.client.device
self.entity_description: AltruistSensorEntityDescription = description
self._attr_unique_id = f"{self._device.id}-{description.key}"
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, self._device.id)},
manufacturer="Robonomics",
model="Altruist",
sw_version=self._device.fw_version,
configuration_url=f"http://{self._device.ip_address}",
serial_number=self._device.id,
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available and self.entity_description.key in self.coordinator.data
)
@property
def native_value(self) -> float | int:
"""Return the native value of the sensor."""
string_value = self.coordinator.data[self.entity_description.key]
return self.entity_description.native_value_fn(string_value)

View File

@@ -0,0 +1,51 @@
{
"config": {
"flow_title": "{name}",
"step": {
"discovery_confirm": {
"description": "Do you want to start setup {model}?"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "Altruist IP address or hostname in the local network"
},
"description": "Fill in Altruist IP address or hostname in your local network"
}
},
"abort": {
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"no_device_found": "[%key:common::config_flow::error::cannot_connect%]"
}
},
"entity": {
"sensor": {
"humidity": {
"name": "{sensor_name} humidity"
},
"pressure": {
"name": "{sensor_name} pressure"
},
"temperature": {
"name": "{sensor_name} temperature"
},
"noise_max": {
"name": "Maximum noise"
},
"noise_avg": {
"name": "Average noise"
},
"co2": {
"name": "{sensor_name} CO2"
},
"radiation": {
"name": "Radiation level"
}
}
}
}

View File

@@ -11,6 +11,6 @@
"documentation": "https://www.home-assistant.io/integrations/bosch_alarm",
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze",
"quality_scale": "platinum",
"requirements": ["bosch-alarm-mode2==0.4.6"]
}

View File

@@ -28,38 +28,41 @@ rules:
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
docs-configuration-parameters:
status: exempt
comment: |
No options flow is provided.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: 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:
status: exempt
comment: |
Device type integration
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: |

View File

@@ -13,7 +13,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["demetriek"],
"requirements": ["demetriek==1.2.0"],
"requirements": ["demetriek==1.3.0"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:LaMetric:1"

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/lg_thinq",
"iot_class": "cloud_push",
"loggers": ["thinqconnect"],
"requirements": ["thinqconnect==1.0.5"]
"requirements": ["thinqconnect==1.0.7"]
}

View File

@@ -2,25 +2,16 @@
from __future__ import annotations
import asyncio
from datetime import timedelta
from http import HTTPStatus
import logging
from aiohttp.client_exceptions import ClientResponseError
from aiolyric import Lyric
from aiolyric.exceptions import LyricAuthenticationException, LyricException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import (
aiohttp_client,
config_entry_oauth2_flow,
config_validation as cv,
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import (
ConfigEntryLyricClient,
@@ -28,11 +19,10 @@ from .api import (
OAuth2SessionLyric,
)
from .const import DOMAIN
from .coordinator import LyricDataUpdateCoordinator
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
@@ -54,53 +44,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
client_id = implementation.client_id
lyric = Lyric(client, client_id)
async def async_update_data(force_refresh_token: bool = False) -> Lyric:
"""Fetch data from Lyric."""
try:
if not force_refresh_token:
await oauth_session.async_ensure_token_valid()
else:
await oauth_session.force_refresh_token()
except ClientResponseError as exception:
if exception.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
raise ConfigEntryAuthFailed from exception
raise UpdateFailed(exception) from exception
try:
async with asyncio.timeout(60):
await lyric.get_locations()
await asyncio.gather(
*(
lyric.get_thermostat_rooms(
location.location_id, device.device_id
)
for location in lyric.locations
for device in location.devices
if device.device_class == "Thermostat"
and device.device_id.startswith("LCC")
)
)
except LyricAuthenticationException as exception:
# Attempt to refresh the token before failing.
# Honeywell appear to have issues keeping tokens saved.
_LOGGER.debug("Authentication failed. Attempting to refresh token")
if not force_refresh_token:
return await async_update_data(force_refresh_token=True)
raise ConfigEntryAuthFailed from exception
except (LyricException, ClientResponseError) as exception:
raise UpdateFailed(exception) from exception
return lyric
coordinator = DataUpdateCoordinator[Lyric](
coordinator = LyricDataUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
# Name of the data. For logging purposes.
name="lyric_coordinator",
update_method=async_update_data,
# Polling interval. Will only be polled if there are subscribers.
update_interval=timedelta(seconds=300),
oauth_session=oauth_session,
lyric=lyric,
)
# Fetch initial data so we have data when entities subscribe

View File

@@ -8,7 +8,6 @@ import logging
from time import localtime, strftime, time
from typing import Any
from aiolyric import Lyric
from aiolyric.objects.device import LyricDevice
from aiolyric.objects.location import LyricLocation
import voluptuous as vol
@@ -37,7 +36,6 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
DOMAIN,
@@ -48,6 +46,7 @@ from .const import (
PRESET_TEMPORARY_HOLD,
PRESET_VACATION_HOLD,
)
from .coordinator import LyricDataUpdateCoordinator
from .entity import LyricDeviceEntity
_LOGGER = logging.getLogger(__name__)
@@ -126,7 +125,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Honeywell Lyric climate platform based on a config entry."""
coordinator: DataUpdateCoordinator[Lyric] = hass.data[DOMAIN][entry.entry_id]
coordinator: LyricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
(
@@ -164,7 +163,7 @@ class LyricThermostatType(enum.Enum):
class LyricClimate(LyricDeviceEntity, ClimateEntity):
"""Defines a Honeywell Lyric climate entity."""
coordinator: DataUpdateCoordinator[Lyric]
coordinator: LyricDataUpdateCoordinator
entity_description: ClimateEntityDescription
_attr_name = None
@@ -178,7 +177,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
def __init__(
self,
coordinator: DataUpdateCoordinator[Lyric],
coordinator: LyricDataUpdateCoordinator,
description: ClimateEntityDescription,
location: LyricLocation,
device: LyricDevice,

View File

@@ -0,0 +1,87 @@
"""The Honeywell Lyric integration."""
from __future__ import annotations
import asyncio
from datetime import timedelta
from http import HTTPStatus
import logging
from aiohttp.client_exceptions import ClientResponseError
from aiolyric import Lyric
from aiolyric.exceptions import LyricAuthenticationException, LyricException
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import OAuth2SessionLyric
_LOGGER = logging.getLogger(__name__)
class LyricDataUpdateCoordinator(DataUpdateCoordinator[Lyric]):
"""Data update coordinator for Honeywell Lyric."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
oauth_session: OAuth2SessionLyric,
lyric: Lyric,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name="lyric_coordinator",
update_interval=timedelta(seconds=300),
)
self.oauth_session = oauth_session
self.lyric = lyric
async def _async_update_data(self) -> Lyric:
"""Fetch data from Lyric."""
return await self._run_update(False)
async def _run_update(self, force_refresh_token: bool) -> Lyric:
"""Fetch data from Lyric."""
try:
if not force_refresh_token:
await self.oauth_session.async_ensure_token_valid()
else:
await self.oauth_session.force_refresh_token()
except ClientResponseError as exception:
if exception.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
raise ConfigEntryAuthFailed from exception
raise UpdateFailed(exception) from exception
try:
async with asyncio.timeout(60):
await self.lyric.get_locations()
await asyncio.gather(
*(
self.lyric.get_thermostat_rooms(
location.location_id, device.device_id
)
for location in self.lyric.locations
for device in location.devices
if device.device_class == "Thermostat"
and device.device_id.startswith("LCC")
)
)
except LyricAuthenticationException as exception:
# Attempt to refresh the token before failing.
# Honeywell appear to have issues keeping tokens saved.
_LOGGER.debug("Authentication failed. Attempting to refresh token")
if not force_refresh_token:
return await self._run_update(True)
raise ConfigEntryAuthFailed from exception
except (LyricException, ClientResponseError) as exception:
raise UpdateFailed(exception) from exception
return self.lyric

View File

@@ -2,27 +2,25 @@
from __future__ import annotations
from aiolyric import Lyric
from aiolyric.objects.device import LyricDevice
from aiolyric.objects.location import LyricLocation
from aiolyric.objects.priority import LyricAccessory, LyricRoom
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import LyricDataUpdateCoordinator
class LyricEntity(CoordinatorEntity[DataUpdateCoordinator[Lyric]]):
class LyricEntity(CoordinatorEntity[LyricDataUpdateCoordinator]):
"""Defines a base Honeywell Lyric entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: DataUpdateCoordinator[Lyric],
coordinator: LyricDataUpdateCoordinator,
location: LyricLocation,
device: LyricDevice,
key: str,
@@ -71,7 +69,7 @@ class LyricAccessoryEntity(LyricDeviceEntity):
def __init__(
self,
coordinator: DataUpdateCoordinator[Lyric],
coordinator: LyricDataUpdateCoordinator,
location: LyricLocation,
device: LyricDevice,
room: LyricRoom,

View File

@@ -6,7 +6,6 @@ from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from aiolyric import Lyric
from aiolyric.objects.device import LyricDevice
from aiolyric.objects.location import LyricLocation
from aiolyric.objects.priority import LyricAccessory, LyricRoom
@@ -22,7 +21,6 @@ from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from .const import (
@@ -33,6 +31,7 @@ from .const import (
PRESET_TEMPORARY_HOLD,
PRESET_VACATION_HOLD,
)
from .coordinator import LyricDataUpdateCoordinator
from .entity import LyricAccessoryEntity, LyricDeviceEntity
LYRIC_SETPOINT_STATUS_NAMES = {
@@ -164,7 +163,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Honeywell Lyric sensor platform based on a config entry."""
coordinator: DataUpdateCoordinator[Lyric] = hass.data[DOMAIN][entry.entry_id]
coordinator: LyricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
LyricSensor(
@@ -199,7 +198,7 @@ class LyricSensor(LyricDeviceEntity, SensorEntity):
def __init__(
self,
coordinator: DataUpdateCoordinator[Lyric],
coordinator: LyricDataUpdateCoordinator,
description: LyricSensorEntityDescription,
location: LyricLocation,
device: LyricDevice,
@@ -231,7 +230,7 @@ class LyricAccessorySensor(LyricAccessoryEntity, SensorEntity):
def __init__(
self,
coordinator: DataUpdateCoordinator[Lyric],
coordinator: LyricDataUpdateCoordinator,
description: LyricSensorAccessoryEntityDescription,
location: LyricLocation,
parentDevice: LyricDevice,

View File

@@ -81,9 +81,21 @@
"valve_position": {
"default": "mdi:valve"
},
"battery_charge_state": {
"default": "mdi:battery-charging"
},
"battery_replacement_description": {
"default": "mdi:battery-sync-outline"
},
"battery_time_remaining": {
"default": "mdi:battery-clock-outline"
},
"battery_time_to_full_charge": {
"default": "mdi:battery-clock"
},
"esa_opt_out_state": {
"default": "mdi:home-lightning-bolt"
},
"evse_state": {
"default": "mdi:ev-station"
},

View File

@@ -38,6 +38,7 @@ from homeassistant.const import (
UnitOfPower,
UnitOfPressure,
UnitOfTemperature,
UnitOfTime,
UnitOfVolume,
UnitOfVolumeFlowRate,
)
@@ -84,6 +85,21 @@ BOOST_STATE_MAP = {
clusters.WaterHeaterManagement.Enums.BoostStateEnum.kUnknownEnumValue: None,
}
CHARGE_STATE_MAP = {
clusters.PowerSource.Enums.BatChargeStateEnum.kUnknown: None,
clusters.PowerSource.Enums.BatChargeStateEnum.kIsNotCharging: "not_charging",
clusters.PowerSource.Enums.BatChargeStateEnum.kIsCharging: "charging",
clusters.PowerSource.Enums.BatChargeStateEnum.kIsAtFullCharge: "full_charge",
clusters.PowerSource.Enums.BatChargeStateEnum.kUnknownEnumValue: None,
}
DEM_OPT_OUT_STATE_MAP = {
clusters.DeviceEnergyManagement.Enums.OptOutStateEnum.kNoOptOut: "no_opt_out",
clusters.DeviceEnergyManagement.Enums.OptOutStateEnum.kLocalOptOut: "local_opt_out",
clusters.DeviceEnergyManagement.Enums.OptOutStateEnum.kGridOptOut: "grid_opt_out",
clusters.DeviceEnergyManagement.Enums.OptOutStateEnum.kOptOut: "opt_out",
}
ESA_STATE_MAP = {
clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kOffline: "offline",
clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kOnline: "online",
@@ -355,6 +371,47 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterSensor,
required_attributes=(clusters.PowerSource.Attributes.BatVoltage,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="PowerSourceBatTimeRemaining",
translation_key="battery_time_remaining",
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.MINUTES,
device_class=SensorDeviceClass.DURATION,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
required_attributes=(clusters.PowerSource.Attributes.BatTimeRemaining,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="PowerSourceBatChargeState",
translation_key="battery_charge_state",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=[state for state in CHARGE_STATE_MAP.values() if state is not None],
measurement_to_ha=CHARGE_STATE_MAP.get,
),
entity_class=MatterSensor,
required_attributes=(clusters.PowerSource.Attributes.BatChargeState,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="PowerSourceBatTimeToFullCharge",
translation_key="battery_time_to_full_charge",
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.MINUTES,
device_class=SensorDeviceClass.DURATION,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
required_attributes=(clusters.PowerSource.Attributes.BatTimeToFullCharge,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
@@ -1109,6 +1166,19 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterSensor,
required_attributes=(clusters.DeviceEnergyManagement.Attributes.ESAState,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="ESAOptOutState",
translation_key="esa_opt_out_state",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=list(DEM_OPT_OUT_STATE_MAP.values()),
measurement_to_ha=DEM_OPT_OUT_STATE_MAP.get,
),
entity_class=MatterSensor,
required_attributes=(clusters.DeviceEnergyManagement.Attributes.OptOutState,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(

View File

@@ -330,6 +330,20 @@
"battery_replacement_description": {
"name": "Battery type"
},
"battery_charge_state": {
"name": "Battery charge state",
"state": {
"charging": "[%key:common::state::charging%]",
"full_charge": "Full charge",
"not_charging": "Not charging"
}
},
"battery_time_remaining": {
"name": "Time remaining"
},
"battery_time_to_full_charge": {
"name": "Time to full charge"
},
"battery_voltage": {
"name": "Battery voltage"
},
@@ -349,6 +363,15 @@
"paused": "[%key:common::state::paused%]"
}
},
"esa_opt_out_state": {
"name": "Energy optimization opt-out",
"state": {
"no_opt_out": "[%key:common::state::off%]",
"local_opt_out": "Local",
"grid_opt_out": "Grid",
"opt_out": "Local and grid"
}
},
"evse_fault_state": {
"name": "Fault state",
"state": {

View File

@@ -835,6 +835,15 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
except InvalidAuthError:
self.config_entry.async_start_reauth(self.hass)
return
except RpcCallError as err:
# Ignore 404 (No handler for) error
if err.code != 404:
LOGGER.debug(
"Error during shutdown for device %s: %s",
self.name,
err.message,
)
return
except DeviceConnectionError as err:
# If the device is restarting or has gone offline before
# the ping/pong timeout happens, the shutdown command

View File

@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components.alarm_control_panel import (
DOMAIN as ALARM_CONTROL_PANEL_DOMAIN,
ENTITY_ID_FORMAT,
PLATFORM_SCHEMA as ALARM_CONTROL_PANEL_PLATFORM_SCHEMA,
AlarmControlPanelEntity,
@@ -42,6 +43,7 @@ from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_OBJECT_ID, DOMAIN
from .coordinator import TriggerUpdateCoordinator
from .entity import AbstractTemplateEntity
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
@@ -49,6 +51,7 @@ from .template_entity import (
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
_VALID_STATES = [
@@ -253,6 +256,13 @@ async def async_setup_platform(
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerAlarmControlPanelEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
_async_create_template_tracking_entities(
async_add_entities,
hass,
@@ -276,8 +286,11 @@ class AbstractTemplateAlarmControlPanel(
self._attr_code_format = config[CONF_CODE_FORMAT].value
self._state: AlarmControlPanelState | None = None
self._attr_supported_features: AlarmControlPanelEntityFeature = (
AlarmControlPanelEntityFeature(0)
)
def _register_scripts(
def _iterate_scripts(
self, config: dict[str, Any]
) -> Generator[
tuple[str, Sequence[dict[str, Any]], AlarmControlPanelEntityFeature | int]
@@ -423,8 +436,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPane
if TYPE_CHECKING:
assert name is not None
self._attr_supported_features = AlarmControlPanelEntityFeature(0)
for action_id, action_config, supported_feature in self._register_scripts(
for action_id, action_config, supported_feature in self._iterate_scripts(
config
):
self.add_script(action_id, action_config, name, DOMAIN)
@@ -456,3 +468,55 @@ class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPane
"_state", self._template, None, self._update_state
)
super()._async_setup_templates()
class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControlPanel):
"""Alarm Control Panel entity based on trigger data."""
domain = ALARM_CONTROL_PANEL_DOMAIN
def __init__(
self,
hass: HomeAssistant,
coordinator: TriggerUpdateCoordinator,
config: ConfigType,
) -> None:
"""Initialize the entity."""
TriggerEntity.__init__(self, hass, coordinator, config)
AbstractTemplateAlarmControlPanel.__init__(self, config)
self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME)
if isinstance(config.get(CONF_STATE), template.Template):
self._to_render_simple.append(CONF_STATE)
self._parse_result.add(CONF_STATE)
for action_id, action_config, supported_feature in self._iterate_scripts(
config
):
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature
self._attr_device_info = async_device_info_to_link_from_device_id(
hass,
config.get(CONF_DEVICE_ID),
)
async def async_added_to_hass(self) -> None:
"""Restore last state."""
await super().async_added_to_hass()
await self._async_handle_restored_state()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle update of the data."""
self._process_data()
if not self.available:
self.async_write_ha_state()
return
if (rendered := self._rendered.get(CONF_STATE)) is not None:
self._handle_state(rendered)
self.async_set_context(self.coordinator.data["context"])
self.async_write_ha_state()

View File

@@ -157,10 +157,8 @@ CONFIG_SECTION_SCHEMA = vol.All(
},
),
ensure_domains_do_not_have_trigger_or_action(
DOMAIN_ALARM_CONTROL_PANEL,
DOMAIN_BUTTON,
DOMAIN_FAN,
DOMAIN_LOCK,
DOMAIN_VACUUM,
),
)

View File

@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components.lock import (
DOMAIN as LOCK_DOMAIN,
PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA,
LockEntity,
LockEntityFeature,
@@ -23,11 +24,12 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError, TemplateError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_PICTURE, DOMAIN
from .coordinator import TriggerUpdateCoordinator
from .entity import AbstractTemplateEntity
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
@@ -36,6 +38,7 @@ from .template_entity import (
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
from .trigger_entity import TriggerEntity
CONF_CODE_FORMAT_TEMPLATE = "code_format_template"
CONF_CODE_FORMAT = "code_format"
@@ -123,6 +126,13 @@ async def async_setup_platform(
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerLockEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
_async_create_template_tracking_entities(
async_add_entities,
hass,
@@ -147,7 +157,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity):
self._optimistic = config.get(CONF_OPTIMISTIC)
self._attr_assumed_state = bool(self._optimistic)
def _register_scripts(
def _iterate_scripts(
self, config: dict[str, Any]
) -> Generator[tuple[str, Sequence[dict[str, Any]], LockEntityFeature | int]]:
for action_id, supported_feature in (
@@ -314,7 +324,7 @@ class TemplateLock(TemplateEntity, AbstractTemplateLock):
if TYPE_CHECKING:
assert name is not None
for action_id, action_config, supported_feature in self._register_scripts(
for action_id, action_config, supported_feature in self._iterate_scripts(
config
):
self.add_script(action_id, action_config, name, DOMAIN)
@@ -346,3 +356,60 @@ class TemplateLock(TemplateEntity, AbstractTemplateLock):
self._update_code_format,
)
super()._async_setup_templates()
class TriggerLockEntity(TriggerEntity, AbstractTemplateLock):
"""Lock entity based on trigger data."""
domain = LOCK_DOMAIN
extra_template_keys = (CONF_STATE,)
def __init__(
self,
hass: HomeAssistant,
coordinator: TriggerUpdateCoordinator,
config: ConfigType,
) -> None:
"""Initialize the entity."""
TriggerEntity.__init__(self, hass, coordinator, config)
AbstractTemplateLock.__init__(self, config)
self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME)
if isinstance(config.get(CONF_CODE_FORMAT), template.Template):
self._to_render_simple.append(CONF_CODE_FORMAT)
self._parse_result.add(CONF_CODE_FORMAT)
for action_id, action_config, supported_feature in self._iterate_scripts(
config
):
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature
@callback
def _handle_coordinator_update(self) -> None:
"""Handle update of the data."""
self._process_data()
if not self.available:
self.async_write_ha_state()
return
write_ha_state = False
for key, updater in (
(CONF_STATE, self._handle_state),
(CONF_CODE_FORMAT, self._update_code_format),
):
if (rendered := self._rendered.get(key)) is not None:
updater(rendered)
write_ha_state = True
if not self._optimistic:
self.async_set_context(self.coordinator.data["context"])
write_ha_state = True
elif self._optimistic and len(self._rendered) > 0:
# In case any non optimistic template
write_ha_state = True
if write_ha_state:
self.async_write_ha_state()

View File

@@ -0,0 +1,28 @@
"""The Tilt Pi integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import TiltPiConfigEntry, TiltPiDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: TiltPiConfigEntry) -> bool:
"""Set up Tilt Pi from a config entry."""
coordinator = TiltPiDataUpdateCoordinator(
hass,
entry,
)
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: TiltPiConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,63 @@
"""Config flow for Tilt Pi integration."""
from typing import Any
import aiohttp
from tiltpi import TiltPiClient, TiltPiError
import voluptuous as vol
from yarl import URL
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_URL
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
class TiltPiConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Tilt Pi."""
async def _check_connection(self, host: str, port: int) -> str | None:
"""Check if we can connect to the TiltPi instance."""
client = TiltPiClient(
host,
port,
session=async_get_clientsession(self.hass),
)
try:
await client.get_hydrometers()
except (TiltPiError, TimeoutError, aiohttp.ClientError):
return "cannot_connect"
return None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a configuration flow initialized by the user."""
errors = {}
if user_input is not None:
url = URL(user_input[CONF_URL])
if (host := url.host) is None:
errors[CONF_URL] = "invalid_host"
else:
self._async_abort_entries_match({CONF_HOST: host})
port = url.port
assert port
error = await self._check_connection(host=host, port=port)
if error:
errors["base"] = error
else:
return self.async_create_entry(
title="Tilt Pi",
data={
CONF_HOST: host,
CONF_PORT: port,
},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_URL): str}),
errors=errors,
)

View File

@@ -0,0 +1,8 @@
"""Constants for the Tilt Pi integration."""
import logging
from typing import Final
LOGGER = logging.getLogger(__package__)
DOMAIN: Final = "tilt_pi"

View File

@@ -0,0 +1,53 @@
"""Data update coordinator for Tilt Pi."""
from datetime import timedelta
from typing import Final
from tiltpi import TiltHydrometerData, TiltPiClient, TiltPiError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import LOGGER
SCAN_INTERVAL: Final = timedelta(seconds=60)
type TiltPiConfigEntry = ConfigEntry[TiltPiDataUpdateCoordinator]
class TiltPiDataUpdateCoordinator(DataUpdateCoordinator[dict[str, TiltHydrometerData]]):
"""Class to manage fetching Tilt Pi data."""
config_entry: TiltPiConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: TiltPiConfigEntry,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name="Tilt Pi",
update_interval=SCAN_INTERVAL,
)
self._api = TiltPiClient(
host=config_entry.data[CONF_HOST],
port=config_entry.data[CONF_PORT],
session=async_get_clientsession(hass),
)
self.identifier = config_entry.entry_id
async def _async_update_data(self) -> dict[str, TiltHydrometerData]:
"""Fetch data from Tilt Pi and return as a dict keyed by mac_id."""
try:
hydrometers = await self._api.get_hydrometers()
except TiltPiError as err:
raise UpdateFailed(f"Error communicating with Tilt Pi: {err}") from err
return {h.mac_id: h for h in hydrometers}

View File

@@ -0,0 +1,39 @@
"""Base entity for Tilt Pi integration."""
from tiltpi import TiltHydrometerData
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import TiltPiDataUpdateCoordinator
class TiltEntity(CoordinatorEntity[TiltPiDataUpdateCoordinator]):
"""Base class for Tilt entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: TiltPiDataUpdateCoordinator,
hydrometer: TiltHydrometerData,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._mac_id = hydrometer.mac_id
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, hydrometer.mac_id)},
name=f"Tilt {hydrometer.color}",
manufacturer="Tilt Hydrometer",
model=f"{hydrometer.color} Tilt Hydrometer",
)
@property
def current_hydrometer(self) -> TiltHydrometerData:
"""Return the current hydrometer data for this entity."""
return self.coordinator.data[self._mac_id]
@property
def available(self) -> bool:
"""Return True if the hydrometer is available (present in coordinator data)."""
return super().available and self._mac_id in self.coordinator.data

View File

@@ -0,0 +1,9 @@
{
"entity": {
"sensor": {
"gravity": {
"default": "mdi:water"
}
}
}
}

View File

@@ -0,0 +1,10 @@
{
"domain": "tilt_pi",
"name": "Tilt Pi",
"codeowners": ["@michaelheyman"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tilt_pi",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["tilt-pi==0.2.1"]
}

View File

@@ -0,0 +1,80 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration does not explicitly subscribe to events.
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: |
No custom actions are defined.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options to configure
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category:
status: done
comment: |
The entities are categorized well by using default category.
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: No disabled entities implemented
entity-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
No repairs/issues.
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,93 @@
"""Support for Tilt Hydrometer sensors."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Final
from tiltpi import TiltHydrometerData
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import TiltPiConfigEntry, TiltPiDataUpdateCoordinator
from .entity import TiltEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
ATTR_TEMPERATURE = "temperature"
ATTR_GRAVITY = "gravity"
@dataclass(frozen=True, kw_only=True)
class TiltEntityDescription(SensorEntityDescription):
"""Describes TiltHydrometerData sensor entity."""
value_fn: Callable[[TiltHydrometerData], StateType]
SENSOR_TYPES: Final[list[TiltEntityDescription]] = [
TiltEntityDescription(
key=ATTR_TEMPERATURE,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.temperature,
),
TiltEntityDescription(
key=ATTR_GRAVITY,
translation_key="gravity",
native_unit_of_measurement="SG",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.gravity,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TiltPiConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tilt Hydrometer sensors."""
coordinator = config_entry.runtime_data
async_add_entities(
TiltSensor(
coordinator,
description,
hydrometer,
)
for description in SENSOR_TYPES
for hydrometer in coordinator.data.values()
)
class TiltSensor(TiltEntity, SensorEntity):
"""Defines a Tilt sensor."""
entity_description: TiltEntityDescription
def __init__(
self,
coordinator: TiltPiDataUpdateCoordinator,
description: TiltEntityDescription,
hydrometer: TiltHydrometerData,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, hydrometer)
self.entity_description = description
self._attr_unique_id = f"{hydrometer.mac_id}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the sensor value."""
return self.entity_description.value_fn(self.current_hydrometer)

View File

@@ -0,0 +1,31 @@
{
"config": {
"step": {
"confirm": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
},
"user": {
"data": {
"url": "[%key:common::config_flow::data::url%]"
},
"data_description": {
"url": "The URL of the Tilt Pi instance."
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_host": "[%key:common::config_flow::error::invalid_host%]"
}
},
"entity": {
"sensor": {
"gravity": {
"name": "Gravity"
}
}
}
}

View File

@@ -27,6 +27,7 @@ from homeassistant.const import (
CONF_ENTITY_ID,
CONF_FRIENDLY_NAME,
CONF_SENSORS,
CONF_UNIQUE_ID,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
@@ -89,6 +90,7 @@ SENSOR_SCHEMA = vol.All(
vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float),
vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int,
vol.Optional(CONF_MIN_SAMPLES, default=2): cv.positive_int,
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
),
_validate_min_max,
@@ -121,6 +123,7 @@ async def async_setup_platform(
min_samples=sensor_config[CONF_MIN_SAMPLES],
max_samples=sensor_config[CONF_MAX_SAMPLES],
device_class=sensor_config.get(CONF_DEVICE_CLASS),
unique_id=sensor_config.get(CONF_UNIQUE_ID),
sensor_entity_id=generate_entity_id(
ENTITY_ID_FORMAT, sensor_name, hass=hass
),

View File

@@ -48,6 +48,7 @@ FLOWS = {
"airzone_cloud",
"alarmdecoder",
"alexa_devices",
"altruist",
"amberelectric",
"ambient_network",
"ambient_station",
@@ -647,6 +648,7 @@ FLOWS = {
"tibber",
"tile",
"tilt_ble",
"tilt_pi",
"time_date",
"todoist",
"tolo",

View File

@@ -204,6 +204,12 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
"altruist": {
"name": "Altruist",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"amazon": {
"name": "Amazon",
"integrations": {
@@ -6426,7 +6432,10 @@
"iot_class": "cloud_polling",
"name": "SwitchBot Cloud"
}
}
},
"iot_standards": [
"matter"
]
},
"switcher_kis": {
"name": "Switcher",
@@ -6742,11 +6751,22 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"tilt_ble": {
"name": "Tilt Hydrometer BLE",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push"
"tilt": {
"name": "Tilt",
"integrations": {
"tilt_ble": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push",
"name": "Tilt Hydrometer BLE"
},
"tilt_pi": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling",
"name": "Tilt Pi"
}
}
},
"time_date": {
"integration_type": "service",

View File

@@ -342,6 +342,11 @@ ZEROCONF = {
"domain": "apple_tv",
},
],
"_altruist._tcp.local.": [
{
"domain": "altruist",
},
],
"_amzn-alexa._tcp.local.": [
{
"domain": "roomba",

10
mypy.ini generated
View File

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

10
requirements_all.txt generated
View File

@@ -461,6 +461,9 @@ airtouch5py==0.3.0
# homeassistant.components.alpha_vantage
alpha-vantage==2.3.1
# homeassistant.components.altruist
altruistclient==0.1.1
# homeassistant.components.amberelectric
amberelectric==2.0.12
@@ -776,7 +779,7 @@ defusedxml==0.7.1
deluge-client==1.10.2
# homeassistant.components.lametric
demetriek==1.2.0
demetriek==1.3.0
# homeassistant.components.denonavr
denonavr==1.1.1
@@ -2928,7 +2931,7 @@ thermopro-ble==0.13.0
thingspeak==1.0.0
# homeassistant.components.lg_thinq
thinqconnect==1.0.5
thinqconnect==1.0.7
# homeassistant.components.tikteck
tikteck==0.4
@@ -2936,6 +2939,9 @@ tikteck==0.4
# homeassistant.components.tilt_ble
tilt-ble==0.2.3
# homeassistant.components.tilt_pi
tilt-pi==0.2.1
# homeassistant.components.tmb
tmb==0.0.4

View File

@@ -440,6 +440,9 @@ airtouch4pyapi==1.0.5
# homeassistant.components.airtouch5
airtouch5py==0.3.0
# homeassistant.components.altruist
altruistclient==0.1.1
# homeassistant.components.amberelectric
amberelectric==2.0.12
@@ -676,7 +679,7 @@ defusedxml==0.7.1
deluge-client==1.10.2
# homeassistant.components.lametric
demetriek==1.2.0
demetriek==1.3.0
# homeassistant.components.denonavr
denonavr==1.1.1
@@ -2408,11 +2411,14 @@ thermobeacon-ble==0.10.0
thermopro-ble==0.13.0
# homeassistant.components.lg_thinq
thinqconnect==1.0.5
thinqconnect==1.0.7
# homeassistant.components.tilt_ble
tilt-ble==0.2.3
# homeassistant.components.tilt_pi
tilt-pi==0.2.1
# homeassistant.components.todoist
todoist-api-python==2.1.7

View File

@@ -8,6 +8,7 @@ from homeassistant.components.ai_task import AITaskEntityFeature, async_generate
from homeassistant.components.conversation import async_get_chat_log
from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import chat_session
from .conftest import TEST_ENTITY_ID, MockAITaskEntity
@@ -25,7 +26,7 @@ async def test_run_task_preferred_entity(
client = await hass_ws_client(hass)
with pytest.raises(
ValueError, match="No entity_id provided and no preferred entity set"
HomeAssistantError, match="No entity_id provided and no preferred entity set"
):
await async_generate_text(
hass,
@@ -42,7 +43,9 @@ async def test_run_task_preferred_entity(
msg = await client.receive_json()
assert msg["success"]
with pytest.raises(ValueError, match="AI Task entity ai_task.unknown not found"):
with pytest.raises(
HomeAssistantError, match="AI Task entity ai_task.unknown not found"
):
await async_generate_text(
hass,
task_name="Test Task",
@@ -74,7 +77,7 @@ async def test_run_task_preferred_entity(
mock_ai_task_entity.supported_features = AITaskEntityFeature(0)
with pytest.raises(
ValueError,
HomeAssistantError,
match="AI Task entity ai_task.test_task_entity does not support generating text",
):
await async_generate_text(
@@ -91,7 +94,7 @@ async def test_run_text_task_unknown_entity(
"""Test running a text task with an unknown entity."""
with pytest.raises(
ValueError, match="AI Task entity ai_task.unknown_entity not found"
HomeAssistantError, match="AI Task entity ai_task.unknown_entity not found"
):
await async_generate_text(
hass,

View File

@@ -0,0 +1,13 @@
"""Tests for the Altruist integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@@ -0,0 +1,82 @@
"""Altruist tests configuration."""
from collections.abc import Generator
import json
from unittest.mock import AsyncMock, Mock, patch
from altruistclient import AltruistDeviceModel, AltruistError
import pytest
from homeassistant.components.altruist.const import CONF_HOST, DOMAIN
from tests.common import MockConfigEntry, load_fixture
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.altruist.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "192.168.1.100"},
unique_id="5366960e8b18",
title="5366960e8b18",
)
@pytest.fixture
def mock_altruist_device() -> Mock:
"""Return a mock AltruistDeviceModel."""
device = Mock(spec=AltruistDeviceModel)
device.id = "5366960e8b18"
device.name = "Altruist Sensor"
device.ip_address = "192.168.1.100"
device.fw_version = "R_2025-03"
return device
@pytest.fixture
def mock_altruist_client(mock_altruist_device: Mock) -> Generator[AsyncMock]:
"""Return a mock AltruistClient."""
with (
patch(
"homeassistant.components.altruist.coordinator.AltruistClient",
autospec=True,
) as mock_client_class,
patch(
"homeassistant.components.altruist.config_flow.AltruistClient",
new=mock_client_class,
),
):
mock_instance = AsyncMock()
mock_instance.device = mock_altruist_device
mock_instance.device_id = mock_altruist_device.id
mock_instance.sensor_names = json.loads(
load_fixture("sensor_names.json", DOMAIN)
)
mock_instance.fetch_data.return_value = json.loads(
load_fixture("real_data.json", DOMAIN)
)
mock_client_class.from_ip_address = AsyncMock(return_value=mock_instance)
yield mock_instance
@pytest.fixture
def mock_altruist_client_fails_once(mock_altruist_client: AsyncMock) -> Generator[None]:
"""Patch AltruistClient to fail once and then succeed."""
with patch(
"homeassistant.components.altruist.config_flow.AltruistClient.from_ip_address",
side_effect=[AltruistError("Connection failed"), mock_altruist_client],
):
yield

View File

@@ -0,0 +1,38 @@
[
{
"value_type": "signal",
"value": "-48"
},
{
"value_type": "SDS_P1",
"value": "0.1"
},
{
"value_type": "SDS_P2",
"value": "0.23"
},
{
"value_type": "BME280_humidity",
"value": "54.94141"
},
{
"value_type": "BME280_temperature",
"value": "22.95313"
},
{
"value_type": "BME280_pressure",
"value": "99978.16"
},
{
"value_type": "PCBA_noiseMax",
"value": "60"
},
{
"value_type": "PCBA_noiseAvg",
"value": "51"
},
{
"value_type": "GC",
"value": "15.2"
}
]

View File

@@ -0,0 +1,11 @@
[
"signal",
"SDS_P1",
"SDS_P2",
"BME280_humidity",
"BME280_temperature",
"BME280_pressure",
"PCBA_noiseMax",
"PCBA_noiseAvg",
"GC"
]

View File

@@ -0,0 +1,507 @@
# serializer version: 1
# name: test_all_entities[sensor.5366960e8b18_average_noise-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.5366960e8b18_average_noise',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.SOUND_PRESSURE: 'sound_pressure'>,
'original_icon': None,
'original_name': 'Average noise',
'platform': 'altruist',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'noise_avg',
'unique_id': '5366960e8b18-PCBA_noiseAvg',
'unit_of_measurement': <UnitOfSoundPressure.DECIBEL: 'dB'>,
})
# ---
# name: test_all_entities[sensor.5366960e8b18_average_noise-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'sound_pressure',
'friendly_name': '5366960e8b18 Average noise',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfSoundPressure.DECIBEL: 'dB'>,
}),
'context': <ANY>,
'entity_id': 'sensor.5366960e8b18_average_noise',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '51.0',
})
# ---
# name: test_all_entities[sensor.5366960e8b18_bme280_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.5366960e8b18_bme280_humidity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
'original_icon': None,
'original_name': 'BME280 humidity',
'platform': 'altruist',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'humidity',
'unique_id': '5366960e8b18-BME280_humidity',
'unit_of_measurement': '%',
})
# ---
# name: test_all_entities[sensor.5366960e8b18_bme280_humidity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'humidity',
'friendly_name': '5366960e8b18 BME280 humidity',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.5366960e8b18_bme280_humidity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '54.94141',
})
# ---
# name: test_all_entities[sensor.5366960e8b18_bme280_pressure-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.5366960e8b18_bme280_pressure',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfPressure.MMHG: 'mmHg'>,
}),
}),
'original_device_class': <SensorDeviceClass.PRESSURE: 'pressure'>,
'original_icon': None,
'original_name': 'BME280 pressure',
'platform': 'altruist',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'pressure',
'unique_id': '5366960e8b18-BME280_pressure',
'unit_of_measurement': <UnitOfPressure.MMHG: 'mmHg'>,
})
# ---
# name: test_all_entities[sensor.5366960e8b18_bme280_pressure-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'pressure',
'friendly_name': '5366960e8b18 BME280 pressure',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPressure.MMHG: 'mmHg'>,
}),
'context': <ANY>,
'entity_id': 'sensor.5366960e8b18_bme280_pressure',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '749.897762397492',
})
# ---
# name: test_all_entities[sensor.5366960e8b18_bme280_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.5366960e8b18_bme280_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'BME280 temperature',
'platform': 'altruist',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'temperature',
'unique_id': '5366960e8b18-BME280_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[sensor.5366960e8b18_bme280_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': '5366960e8b18 BME280 temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.5366960e8b18_bme280_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '22.95313',
})
# ---
# name: test_all_entities[sensor.5366960e8b18_maximum_noise-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.5366960e8b18_maximum_noise',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.SOUND_PRESSURE: 'sound_pressure'>,
'original_icon': None,
'original_name': 'Maximum noise',
'platform': 'altruist',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'noise_max',
'unique_id': '5366960e8b18-PCBA_noiseMax',
'unit_of_measurement': <UnitOfSoundPressure.DECIBEL: 'dB'>,
})
# ---
# name: test_all_entities[sensor.5366960e8b18_maximum_noise-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'sound_pressure',
'friendly_name': '5366960e8b18 Maximum noise',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfSoundPressure.DECIBEL: 'dB'>,
}),
'context': <ANY>,
'entity_id': 'sensor.5366960e8b18_maximum_noise',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '60.0',
})
# ---
# name: test_all_entities[sensor.5366960e8b18_pm10-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.5366960e8b18_pm10',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.PM10: 'pm10'>,
'original_icon': None,
'original_name': 'PM10',
'platform': 'altruist',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'pm_10',
'unique_id': '5366960e8b18-SDS_P1',
'unit_of_measurement': 'µg/m³',
})
# ---
# name: test_all_entities[sensor.5366960e8b18_pm10-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'pm10',
'friendly_name': '5366960e8b18 PM10',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'µg/m³',
}),
'context': <ANY>,
'entity_id': 'sensor.5366960e8b18_pm10',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.1',
})
# ---
# name: test_all_entities[sensor.5366960e8b18_pm2_5-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.5366960e8b18_pm2_5',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.PM25: 'pm25'>,
'original_icon': None,
'original_name': 'PM2.5',
'platform': 'altruist',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'pm_25',
'unique_id': '5366960e8b18-SDS_P2',
'unit_of_measurement': 'µg/m³',
})
# ---
# name: test_all_entities[sensor.5366960e8b18_pm2_5-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'pm25',
'friendly_name': '5366960e8b18 PM2.5',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'µg/m³',
}),
'context': <ANY>,
'entity_id': 'sensor.5366960e8b18_pm2_5',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.23',
})
# ---
# name: test_all_entities[sensor.5366960e8b18_radiation_level-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.5366960e8b18_radiation_level',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Radiation level',
'platform': 'altruist',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'radiation',
'unique_id': '5366960e8b18-GC',
'unit_of_measurement': 'μR/h',
})
# ---
# name: test_all_entities[sensor.5366960e8b18_radiation_level-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '5366960e8b18 Radiation level',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'μR/h',
}),
'context': <ANY>,
'entity_id': 'sensor.5366960e8b18_radiation_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '15.2',
})
# ---
# name: test_all_entities[sensor.5366960e8b18_signal_strength-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': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.5366960e8b18_signal_strength',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.SIGNAL_STRENGTH: 'signal_strength'>,
'original_icon': None,
'original_name': 'Signal strength',
'platform': 'altruist',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '5366960e8b18-signal',
'unit_of_measurement': 'dBm',
})
# ---
# name: test_all_entities[sensor.5366960e8b18_signal_strength-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'signal_strength',
'friendly_name': '5366960e8b18 Signal strength',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'dBm',
}),
'context': <ANY>,
'entity_id': 'sensor.5366960e8b18_signal_strength',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-48.0',
})
# ---

View File

@@ -0,0 +1,169 @@
"""Test the Altruist config flow."""
from ipaddress import ip_address
from unittest.mock import AsyncMock
from homeassistant.components.altruist.const import CONF_HOST, DOMAIN
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import MockConfigEntry
ZEROCONF_DISCOVERY = ZeroconfServiceInfo(
ip_address=ip_address("192.168.1.100"),
ip_addresses=[ip_address("192.168.1.100")],
hostname="altruist-purple.local.",
name="altruist-purple._altruist._tcp.local.",
port=80,
type="_altruist._tcp.local.",
properties={
"PATH": "/config",
},
)
async def test_form_user_step_success(
hass: HomeAssistant,
mock_altruist_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test user step shows form and succeeds with valid input."""
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"],
{CONF_HOST: "192.168.1.100"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "5366960e8b18"
assert result["data"] == {
CONF_HOST: "192.168.1.100",
}
assert result["result"].unique_id == "5366960e8b18"
async def test_form_user_step_cannot_connect_then_recovers(
hass: HomeAssistant,
mock_altruist_client: AsyncMock,
mock_altruist_client_fails_once: None,
mock_setup_entry: AsyncMock,
) -> None:
"""Test we handle connection error and allow recovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
# First attempt triggers an error
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.1.100"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "no_device_found"}
# Second attempt recovers with a valid client
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.1.100"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "5366960e8b18"
assert result["result"].unique_id == "5366960e8b18"
assert result["data"] == {
CONF_HOST: "192.168.1.100",
}
async def test_form_user_step_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_altruist_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test we abort if already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.1.100"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_zeroconf_discovery(
hass: HomeAssistant,
mock_altruist_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test zeroconf discovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "5366960e8b18"
assert result["data"] == {
CONF_HOST: "192.168.1.100",
}
assert result["result"].unique_id == "5366960e8b18"
async def test_zeroconf_discovery_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_altruist_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test zeroconf discovery when already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_zeroconf_discovery_cant_create_client(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_altruist_client_fails_once: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test zeroconf discovery when already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_device_found"

View File

@@ -0,0 +1,53 @@
"""Test the Altruist integration."""
from unittest.mock import AsyncMock
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_setup_entry_client_creation_failure(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_altruist_client_fails_once: None,
) -> None:
"""Test setup failure when client creation fails."""
mock_config_entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_entry_fetch_data_failure(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_altruist_client: AsyncMock,
) -> None:
"""Test setup failure when initial data fetch fails."""
mock_config_entry.add_to_hass(hass)
mock_altruist_client.fetch_data.side_effect = Exception("Fetch failed")
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_unload_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_altruist_client: AsyncMock,
) -> None:
"""Test unloading of config entry."""
mock_config_entry.add_to_hass(hass)
assert 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
# Now test unloading
assert 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

View File

@@ -0,0 +1,55 @@
"""Tests for the Altruist integration sensor platform."""
from datetime import timedelta
from unittest.mock import AsyncMock, patch
from altruistclient import AltruistError
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_altruist_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
with patch("homeassistant.components.altruist.PLATFORMS", [Platform.SENSOR]):
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_connection_error(
hass: HomeAssistant,
mock_altruist_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test coordinator error handling during update."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_altruist_client.fetch_data.side_effect = AltruistError()
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
hass.states.get("sensor.5366960e8b18_bme280_temperature").state
== STATE_UNAVAILABLE
)

View File

@@ -46,6 +46,7 @@
'name': '**REDACTED**',
'os_version': '2.2.2',
'serial_number': '**REDACTED**',
'update': None,
'wifi': dict({
'active': True,
'available': True,

View File

@@ -76,6 +76,7 @@ async def integration_fixture(
params=[
"air_purifier",
"air_quality_sensor",
"battery_storage",
"color_temperature_light",
"cooktop",
"dimmable_light",

View File

@@ -0,0 +1,271 @@
{
"node_id": 25,
"date_commissioned": "2025-06-19T17:13:40.727316",
"last_interview": "2025-06-19T17:13:40.727333",
"interview_version": 6,
"available": true,
"is_bridge": false,
"attributes": {
"0/29/0": [
{
"0": 22,
"1": 3
},
{
"0": 18,
"1": 1
}
],
"0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 42],
"0/29/2": [41],
"0/29/3": [1],
"0/29/65532": 0,
"0/29/65533": 3,
"0/29/65528": [],
"0/29/65529": [],
"0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531],
"0/31/0": [
{
"1": 5,
"2": 2,
"3": [112233],
"4": null,
"254": 3
}
],
"0/31/2": 4,
"0/31/4": 4,
"0/31/3": 3,
"0/31/65532": 0,
"0/31/65533": 2,
"0/31/65528": [],
"0/31/65529": [],
"0/31/65531": [0, 2, 4, 3, 65532, 65533, 65528, 65529, 65531],
"0/40/65532": 0,
"0/40/0": 19,
"0/40/6": "**REDACTED**",
"0/40/1": "TEST_VENDOR",
"0/40/2": 65521,
"0/40/3": "Mock Battery Storage",
"0/40/4": 32768,
"0/40/7": 0,
"0/40/8": "TEST_VERSION",
"0/40/9": 1,
"0/40/10": "1.0",
"0/40/18": "6C89C9D11F0BDAAD",
"0/40/19": {
"0": 3,
"1": 3
},
"0/40/21": 17104896,
"0/40/22": 1,
"0/40/65533": 5,
"0/40/5": "",
"0/40/65528": [],
"0/40/65529": [],
"0/40/65531": [
65532, 0, 6, 1, 2, 3, 4, 7, 8, 9, 10, 18, 19, 21, 22, 65533, 5, 65528,
65529, 65531
],
"0/48/65532": 0,
"0/48/2": 0,
"0/48/3": 0,
"0/48/1": {
"0": 60,
"1": 900
},
"0/48/4": true,
"0/48/65533": 2,
"0/48/0": 0,
"0/48/65528": [1, 3, 5],
"0/48/65529": [0, 2, 4],
"0/48/65531": [65532, 2, 3, 1, 4, 65533, 0, 65528, 65529, 65531],
"0/49/0": 1,
"0/49/1": [
{
"0": "RnJlZWJveC03Mjg2ODE=",
"1": true
}
],
"0/49/4": true,
"0/49/5": 0,
"0/49/6": "RnJlZWJveC03Mjg2ODE=",
"0/49/7": null,
"0/49/2": 10,
"0/49/3": 30,
"0/49/8": [0],
"0/49/65532": 1,
"0/49/65533": 2,
"0/49/65528": [1, 5, 7],
"0/49/65529": [0, 2, 4, 6, 8],
"0/49/65531": [
0, 1, 4, 5, 6, 7, 2, 3, 8, 65532, 65533, 65528, 65529, 65531
],
"0/51/0": [
{
"0": "WIFI_STA_DEF",
"1": true,
"2": null,
"3": null,
"4": "YFX59wI0",
"5": ["wKgBqA=="],
"6": ["/oAAAAAAAABiVfn//vcCNA==", "KgEOCgKzOZBiVfn//vcCNA=="],
"7": 1
}
],
"0/51/1": 1,
"0/51/2": 245,
"0/51/8": false,
"0/51/65532": 0,
"0/51/65533": 2,
"0/51/65528": [2],
"0/51/65529": [0, 1],
"0/51/65531": [0, 1, 2, 8, 65532, 65533, 65528, 65529, 65531],
"0/60/65532": 0,
"0/60/0": 0,
"0/60/1": null,
"0/60/2": null,
"0/60/65533": 1,
"0/60/65528": [],
"0/60/65529": [0, 2],
"0/60/65531": [65532, 0, 1, 2, 65533, 65528, 65529, 65531],
"0/62/65532": 0,
"0/62/0": [
{
"1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRGRgkBwEkCAEwCUEEdGR9Cz5LAJceV7SCSogqC7oif2ZaaFbkT0aMcnoFyyfBgkEg7K/IzbpMUEbatodbeOpCPFebunhR9wCXs7B8lTcKNQEoARgkAgE2AwQCBAEYMAQUTYn5+OBsvnwU4qs/Er+byaEnS/AwBRS5+zzv8ZPGnI9mC3wH9vq10JnwlhgwC0D4oAj5zm+W4u/MaHn8Xzqh3zzGdKh2OrSqols1utweoW2ODVMf+AT0WNmG9sOxeaoOPppaFVorZf5T1KtB0T9gGA==",
"2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE/DujEcdTsX19xbxX+KuKKWiMaA5D9u99P/pVxIOmscd2BA2PadEMNnjvtPOpf+WE2Zxar4rby1IfAClGUUuQrTcKNQEpARgkAmAwBBS5+zzv8ZPGnI9mC3wH9vq10JnwljAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQGkPpvsbkAFEbfPN6H3Kf23R0zzmW/gpAA3kgaL6wKB2Ofm+Tmylw22qM536Kj8mOMwaV0EL1dCCGcuxF98aL6gY",
"254": 3
}
],
"0/62/2": 5,
"0/62/3": 3,
"0/62/1": [
{
"1": "BBmX+KwLR5HGlVNbvlC+dO8Jv9fPthHiTfGpUzi2JJADX5az6GxBAFn02QKHwLcZHyh+lh9faf6rf38/nPYF7/M=",
"2": 4939,
"3": 2,
"4": 25,
"5": "Home",
"254": 3
}
],
"0/62/4": [
"FTABAQAkAgE3AyYUyakYCSYVj6gLsxgmBPIA5y8kBQA3BiYUyakYCSYVj6gLsxgkBwEkCAEwCUEEgYwxrTB+tyiEGfrRwjlXTG34MiQtJXbg5Qqd0ohdRW7MfwYY7vZiX/0h9hI8MqUralFaVPcnghAP0MSJm1YrqTcKNQEpARgkAmAwBBS3BS9aJzt+p6i28Nj+trB2Uu+vdzAFFLcFL1onO36nqLbw2P62sHZS7693GDALQPnIJqOtiZpRoUcwAo5GzvuP5SeVloEfg6jDfAMYWb+Sm6X4b9FLaO9IVlUmABOKG5Ay+6ayHN5KRUFmoo4TrxIY",
"FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=",
"FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEGZf4rAtHkcaVU1u+UL507wm/18+2EeJN8alTOLYkkANflrPobEEAWfTZAofAtxkfKH6WH19p/qt/fz+c9gXv8zcKNQEpARgkAmAwBBT0+qfdyShnG+4Pq01pwOnrxdhHRjAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQPVrsFnfFplsQGV5m5EUua+rmo9hAr+OP1bvaifdLqiEIn3uXLTLoKmVUkPImRL2Fb+xcMEAqR2p7RM6ZlFCR20Y"
],
"0/62/5": 3,
"0/62/65533": 2,
"0/62/65528": [1, 3, 5, 8, 14],
"0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11, 12, 13],
"0/62/65531": [65532, 0, 2, 3, 1, 4, 5, 65533, 65528, 65529, 65531],
"0/63/65532": 0,
"0/63/65533": 2,
"0/63/0": [],
"0/63/1": [],
"0/63/2": 0,
"0/63/3": 3,
"0/63/65528": [2, 5],
"0/63/65529": [0, 1, 3, 4],
"0/63/65531": [65532, 65533, 0, 1, 2, 3, 65528, 65529, 65531],
"0/42/65532": 0,
"0/42/0": [],
"0/42/65533": 1,
"0/42/1": true,
"0/42/2": 1,
"0/42/3": null,
"0/42/65528": [],
"0/42/65529": [0],
"0/42/65531": [65532, 0, 65533, 1, 2, 3, 65528, 65529, 65531],
"1/29/0": [
{
"0": 24,
"1": 1
},
{
"0": 17,
"1": 1
},
{
"0": 1296,
"1": 1
},
{
"0": 1293,
"1": 2
}
],
"1/29/1": [29, 47, 156, 144, 145, 152, 159],
"1/29/2": [],
"1/29/3": [],
"1/29/65532": 0,
"1/29/65533": 3,
"1/29/4": [],
"1/29/65528": [],
"1/29/65529": [],
"1/29/65531": [0, 1, 2, 3, 65532, 65533, 4, 65528, 65529, 65531],
"1/47/65532": 7,
"1/47/65533": 3,
"1/47/0": 0,
"1/47/1": 0,
"1/47/2": "Main",
"1/47/31": [],
"1/47/5": 0,
"1/47/11": 48000,
"1/47/12": 180,
"1/47/13": 7200,
"1/47/18": [],
"1/47/24": 100000,
"1/47/27": 1800,
"1/47/29": null,
"1/47/30": null,
"1/47/65528": [],
"1/47/65529": [],
"1/47/65531": [
65532, 65533, 0, 1, 2, 31, 5, 11, 12, 13, 18, 24, 27, 29, 30, 65528,
65529, 65531
],
"1/156/65532": 0,
"1/156/65533": 1,
"1/156/65528": [],
"1/156/65529": [],
"1/156/65531": [65532, 65533, 65528, 65529, 65531],
"1/144/65532": 0,
"1/144/0": 0,
"1/144/1": 0,
"1/144/2": null,
"1/144/8": 0,
"1/144/65533": 1,
"1/144/4": 0,
"1/144/5": 0,
"1/144/65528": [],
"1/144/65529": [],
"1/144/65531": [65532, 0, 1, 2, 8, 65533, 4, 5, 65528, 65529, 65531],
"1/145/65532": 0,
"1/145/0": null,
"1/145/65533": 1,
"1/145/65528": [],
"1/145/65529": [],
"1/145/65531": [65532, 0, 65533, 65528, 65529, 65531],
"1/152/65532": 1,
"1/152/0": 5,
"1/152/1": false,
"1/152/2": 1,
"1/152/3": 0,
"1/152/4": 0,
"1/152/65533": 4,
"1/152/5": null,
"1/152/7": 0,
"1/152/65528": [],
"1/152/65529": [0, 1],
"1/152/65531": [65532, 0, 1, 2, 3, 4, 65533, 5, 7, 65528, 65529, 65531],
"1/159/65532": 0,
"1/159/0": null,
"1/159/65533": 2,
"1/159/1": 0,
"1/159/65528": [1],
"1/159/65529": [0],
"1/159/65531": [65532, 0, 65533, 1, 65528, 65529, 65531]
},
"attribute_subscriptions": []
}

View File

@@ -1251,6 +1251,539 @@
'state': '189.0',
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_appliance_energy_state-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'offline',
'online',
'fault',
'power_adjust_active',
'paused',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_battery_storage_appliance_energy_state',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Appliance energy state',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'esa_state',
'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ESAState-152-2',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_appliance_energy_state-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Mock Battery Storage Appliance energy state',
'options': list([
'offline',
'online',
'fault',
'power_adjust_active',
'paused',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.mock_battery_storage_appliance_energy_state',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'online',
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_battery-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': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_battery_storage_battery',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.BATTERY: 'battery'>,
'original_icon': None,
'original_name': 'Battery',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-PowerSource-47-12',
'unit_of_measurement': '%',
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_battery-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'Mock Battery Storage Battery',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.mock_battery_storage_battery',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '90',
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_battery_voltage-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': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_battery_storage_battery_voltage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
}),
}),
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
'original_icon': None,
'original_name': 'Battery voltage',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'battery_voltage',
'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-PowerSourceBatVoltage-47-11',
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_battery_voltage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'voltage',
'friendly_name': 'Mock Battery Storage Battery voltage',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_battery_storage_battery_voltage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '48.0',
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_current-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': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_battery_storage_current',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
}),
'original_device_class': <SensorDeviceClass.CURRENT: 'current'>,
'original_icon': None,
'original_name': 'Current',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementActiveCurrent-144-5',
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_current-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'current',
'friendly_name': 'Mock Battery Storage Current',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_battery_storage_current',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_energy_optimization_opt_out-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'no_opt_out',
'local_opt_out',
'grid_opt_out',
'opt_out',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_battery_storage_energy_optimization_opt_out',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Energy optimization opt-out',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'esa_opt_out_state',
'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ESAOptOutState-152-7',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_energy_optimization_opt_out-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Mock Battery Storage Energy optimization opt-out',
'options': list([
'no_opt_out',
'local_opt_out',
'grid_opt_out',
'opt_out',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.mock_battery_storage_energy_optimization_opt_out',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'no_opt_out',
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_power-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': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_battery_storage_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
'original_name': 'Power',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementWatt-144-8',
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_power-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'power',
'friendly_name': 'Mock Battery Storage Power',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_battery_storage_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_time_remaining-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': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_battery_storage_time_remaining',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Time remaining',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'battery_time_remaining',
'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-PowerSourceBatTimeRemaining-47-13',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_time_remaining-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'Mock Battery Storage Time remaining',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_battery_storage_time_remaining',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '120.0',
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_time_to_full_charge-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': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_battery_storage_time_to_full_charge',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Time to full charge',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'battery_time_to_full_charge',
'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-PowerSourceBatTimeToFullCharge-47-27',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_time_to_full_charge-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'Mock Battery Storage Time to full charge',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_battery_storage_time_to_full_charge',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '30.0',
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_voltage-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': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_battery_storage_voltage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
}),
}),
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
'original_icon': None,
'original_name': 'Voltage',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementVoltage-144-4',
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
})
# ---
# name: test_sensors[battery_storage][sensor.mock_battery_storage_voltage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'voltage',
'friendly_name': 'Mock Battery Storage Voltage',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_battery_storage_voltage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
})
# ---
# name: test_sensors[cooktop][sensor.mock_cooktop_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -4174,6 +4707,68 @@
'state': '32.0',
})
# ---
# name: test_sensors[silabs_evse_charging][sensor.evse_energy_optimization_opt_out-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'no_opt_out',
'local_opt_out',
'grid_opt_out',
'opt_out',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.evse_energy_optimization_opt_out',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Energy optimization opt-out',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'esa_opt_out_state',
'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-ESAOptOutState-152-7',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[silabs_evse_charging][sensor.evse_energy_optimization_opt_out-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'evse Energy optimization opt-out',
'options': list([
'no_opt_out',
'local_opt_out',
'grid_opt_out',
'opt_out',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.evse_energy_optimization_opt_out',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'no_opt_out',
})
# ---
# name: test_sensors[silabs_evse_charging][sensor.evse_fault_state-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -4918,6 +5513,68 @@
'state': '0.1',
})
# ---
# name: test_sensors[silabs_water_heater][sensor.water_heater_energy_optimization_opt_out-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'no_opt_out',
'local_opt_out',
'grid_opt_out',
'opt_out',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.water_heater_energy_optimization_opt_out',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Energy optimization opt-out',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'esa_opt_out_state',
'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ESAOptOutState-152-7',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[silabs_water_heater][sensor.water_heater_energy_optimization_opt_out-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Water Heater Energy optimization opt-out',
'options': list([
'no_opt_out',
'local_opt_out',
'grid_opt_out',
'opt_out',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.water_heater_energy_optimization_opt_out',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'no_opt_out',
})
# ---
# name: test_sensors[silabs_water_heater][sensor.water_heater_hot_water_level-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -524,6 +524,18 @@ async def test_water_heater(
assert state
assert state.state == "offline"
# DeviceEnergyManagement -> OptOutState attribute
state = hass.states.get("sensor.water_heater_energy_optimization_opt_out")
assert state
assert state.state == "no_opt_out"
set_node_attribute(matter_node, 2, 152, 7, 3)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("sensor.water_heater_energy_optimization_opt_out")
assert state
assert state.state == "opt_out"
@pytest.mark.parametrize("node_fixture", ["pump"])
async def test_pump(

View File

@@ -30,6 +30,7 @@ from tests.common import MockConfigEntry, assert_setup_component, mock_restore_c
TEST_OBJECT_ID = "test_template_panel"
TEST_ENTITY_ID = f"alarm_control_panel.{TEST_OBJECT_ID}"
TEST_STATE_ENTITY_ID = "alarm_control_panel.test"
TEST_SWITCH = "switch.test_state"
@pytest.fixture
@@ -110,6 +111,14 @@ TEMPLATE_ALARM_CONFIG = {
**OPTIMISTIC_TEMPLATE_ALARM_CONFIG,
}
TEST_STATE_TRIGGER = {
"triggers": {"trigger": "state", "entity_id": [TEST_STATE_ENTITY_ID, TEST_SWITCH]},
"variables": {"triggering_entity": "{{ trigger.entity_id }}"},
"actions": [
{"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}}
],
}
async def async_setup_legacy_format(
hass: HomeAssistant, count: int, panel_config: dict[str, Any]
@@ -146,6 +155,24 @@ async def async_setup_modern_format(
await hass.async_block_till_done()
async def async_setup_trigger_format(
hass: HomeAssistant, count: int, panel_config: dict[str, Any]
) -> None:
"""Do setup of alarm control panel integration via trigger format."""
config = {"template": {"alarm_control_panel": panel_config, **TEST_STATE_TRIGGER}}
with assert_setup_component(count, template.DOMAIN):
assert await async_setup_component(
hass,
template.DOMAIN,
config,
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
@pytest.fixture
async def setup_panel(
hass: HomeAssistant,
@@ -158,6 +185,8 @@ async def setup_panel(
await async_setup_legacy_format(hass, count, panel_config)
elif style == ConfigurationStyle.MODERN:
await async_setup_modern_format(hass, count, panel_config)
elif style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(hass, count, panel_config)
async def async_setup_state_panel(
@@ -188,6 +217,16 @@ async def async_setup_state_panel(
**OPTIMISTIC_TEMPLATE_ALARM_CONFIG,
},
)
elif style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(
hass,
count,
{
"name": TEST_OBJECT_ID,
"state": state_template,
**OPTIMISTIC_TEMPLATE_ALARM_CONFIG,
},
)
@pytest.fixture
@@ -228,6 +267,17 @@ async def setup_base_panel(
**panel_config,
},
)
elif style == ConfigurationStyle.TRIGGER:
extra = {"state": state_template} if state_template else {}
await async_setup_trigger_format(
hass,
count,
{
"name": TEST_OBJECT_ID,
**extra,
**panel_config,
},
)
@pytest.fixture
@@ -264,13 +314,25 @@ async def setup_single_attribute_state_panel(
**extra,
},
)
elif style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(
hass,
count,
{
"name": TEST_OBJECT_ID,
**OPTIMISTIC_TEMPLATE_ALARM_CONFIG,
"state": state_template,
**extra,
},
)
@pytest.mark.parametrize(
("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")]
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.usefixtures("setup_state_panel")
async def test_template_state_text(hass: HomeAssistant) -> None:
@@ -301,56 +363,72 @@ async def test_template_state_text(hass: HomeAssistant) -> None:
@pytest.mark.parametrize("count", [1])
@pytest.mark.parametrize(
("state_template", "expected"),
("state_template", "expected", "trigger_expected"),
[
("{{ 'disarmed' }}", AlarmControlPanelState.DISARMED),
("{{ 'armed_home' }}", AlarmControlPanelState.ARMED_HOME),
("{{ 'armed_away' }}", AlarmControlPanelState.ARMED_AWAY),
("{{ 'armed_night' }}", AlarmControlPanelState.ARMED_NIGHT),
("{{ 'armed_vacation' }}", AlarmControlPanelState.ARMED_VACATION),
("{{ 'armed_custom_bypass' }}", AlarmControlPanelState.ARMED_CUSTOM_BYPASS),
("{{ 'pending' }}", AlarmControlPanelState.PENDING),
("{{ 'arming' }}", AlarmControlPanelState.ARMING),
("{{ 'disarming' }}", AlarmControlPanelState.DISARMING),
("{{ 'triggered' }}", AlarmControlPanelState.TRIGGERED),
("{{ x - 1 }}", STATE_UNKNOWN),
("{{ 'disarmed' }}", AlarmControlPanelState.DISARMED, None),
("{{ 'armed_home' }}", AlarmControlPanelState.ARMED_HOME, None),
("{{ 'armed_away' }}", AlarmControlPanelState.ARMED_AWAY, None),
("{{ 'armed_night' }}", AlarmControlPanelState.ARMED_NIGHT, None),
("{{ 'armed_vacation' }}", AlarmControlPanelState.ARMED_VACATION, None),
(
"{{ 'armed_custom_bypass' }}",
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
None,
),
("{{ 'pending' }}", AlarmControlPanelState.PENDING, None),
("{{ 'arming' }}", AlarmControlPanelState.ARMING, None),
("{{ 'disarming' }}", AlarmControlPanelState.DISARMING, None),
("{{ 'triggered' }}", AlarmControlPanelState.TRIGGERED, None),
("{{ x - 1 }}", STATE_UNKNOWN, STATE_UNAVAILABLE),
],
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.usefixtures("setup_state_panel")
async def test_state_template_states(hass: HomeAssistant, expected: str) -> None:
async def test_state_template_states(
hass: HomeAssistant, expected: str, trigger_expected: str, style: ConfigurationStyle
) -> None:
"""Test the state template."""
# Force a trigger
hass.states.async_set(TEST_STATE_ENTITY_ID, None)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
if trigger_expected and style == ConfigurationStyle.TRIGGER:
expected = trigger_expected
assert state.state == expected
@pytest.mark.parametrize(
("count", "state_template", "attribute_template"),
("count", "state_template", "attribute_template", "attribute"),
[
(
1,
"{{ 'disarmed' }}",
"{% if states.switch.test_state.state %}mdi:check{% endif %}",
"icon",
)
],
)
@pytest.mark.parametrize(
("style", "attribute"),
("style", "initial_state"),
[
(ConfigurationStyle.MODERN, "icon"),
(ConfigurationStyle.MODERN, ""),
(ConfigurationStyle.TRIGGER, None),
],
)
@pytest.mark.usefixtures("setup_single_attribute_state_panel")
async def test_icon_template(
hass: HomeAssistant,
) -> None:
async def test_icon_template(hass: HomeAssistant, initial_state: str) -> None:
"""Test icon template."""
state = hass.states.get(TEST_ENTITY_ID)
assert state.attributes.get("icon") in ("", None)
assert state.attributes.get("icon") == initial_state
hass.states.async_set("switch.test_state", STATE_ON)
hass.states.async_set(TEST_SWITCH, STATE_ON)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
@@ -358,30 +436,30 @@ async def test_icon_template(
@pytest.mark.parametrize(
("count", "state_template", "attribute_template"),
("count", "state_template", "attribute_template", "attribute"),
[
(
1,
"{{ 'disarmed' }}",
"{% if states.switch.test_state.state %}local/panel.png{% endif %}",
"picture",
)
],
)
@pytest.mark.parametrize(
("style", "attribute"),
("style", "initial_state"),
[
(ConfigurationStyle.MODERN, "picture"),
(ConfigurationStyle.MODERN, ""),
(ConfigurationStyle.TRIGGER, None),
],
)
@pytest.mark.usefixtures("setup_single_attribute_state_panel")
async def test_picture_template(
hass: HomeAssistant,
) -> None:
async def test_picture_template(hass: HomeAssistant, initial_state: str) -> None:
"""Test icon template."""
state = hass.states.get(TEST_ENTITY_ID)
assert state.attributes.get("entity_picture") in ("", None)
assert state.attributes.get("entity_picture") == initial_state
hass.states.async_set("switch.test_state", STATE_ON)
hass.states.async_set(TEST_SWITCH, STATE_ON)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
@@ -425,7 +503,8 @@ async def test_setup_config_entry(
@pytest.mark.parametrize(("count", "state_template"), [(1, None)])
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.parametrize(
"panel_config", [OPTIMISTIC_TEMPLATE_ALARM_CONFIG, EMPTY_ACTIONS]
@@ -459,7 +538,8 @@ async def test_optimistic_states(hass: HomeAssistant) -> None:
@pytest.mark.parametrize("count", [0])
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.parametrize(
("panel_config", "state_template", "msg"),
@@ -538,11 +618,15 @@ async def test_legacy_template_syntax_error(
[
(ConfigurationStyle.LEGACY, TEST_ENTITY_ID),
(ConfigurationStyle.MODERN, "alarm_control_panel.template_alarm_panel"),
(ConfigurationStyle.TRIGGER, "alarm_control_panel.unnamed_device"),
],
)
@pytest.mark.usefixtures("setup_single_attribute_state_panel")
async def test_name(hass: HomeAssistant, test_entity_id: str) -> None:
"""Test the accessibility of the name attribute."""
hass.states.async_set(TEST_STATE_ENTITY_ID, "disarmed")
await hass.async_block_till_done()
state = hass.states.get(test_entity_id)
assert state is not None
assert state.attributes.get("friendly_name") == "Template Alarm Panel"
@@ -552,7 +636,8 @@ async def test_name(hass: HomeAssistant, test_entity_id: str) -> None:
("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")]
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.parametrize(
"service",
@@ -615,6 +700,21 @@ async def test_actions(
],
ConfigurationStyle.MODERN,
),
(
[
{
"name": "test_template_alarm_control_panel_01",
"state": "{{ true }}",
**UNIQUE_ID_CONFIG,
},
{
"name": "test_template_alarm_control_panel_02",
"state": "{{ false }}",
**UNIQUE_ID_CONFIG,
},
],
ConfigurationStyle.TRIGGER,
),
],
)
@pytest.mark.usefixtures("setup_panel")
@@ -669,7 +769,8 @@ async def test_nested_unique_id(
@pytest.mark.parametrize(("count", "state_template"), [(1, "disarmed")])
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.parametrize(
("panel_config", "code_format", "code_arm_required"),
@@ -714,7 +815,8 @@ async def test_code_config(hass: HomeAssistant, code_format, code_arm_required)
("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")]
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.parametrize(
("restored_state", "initial_state"),

View File

@@ -25,7 +25,19 @@ from tests.common import assert_setup_component
TEST_OBJECT_ID = "test_template_lock"
TEST_ENTITY_ID = f"lock.{TEST_OBJECT_ID}"
TEST_STATE_ENTITY_ID = "switch.test_state"
TEST_STATE_ENTITY_ID = "sensor.test_state"
TEST_AVAILABILITY_ENTITY_ID = "availability_state.state"
TEST_STATE_TRIGGER = {
"trigger": {
"trigger": "state",
"entity_id": [TEST_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY_ID],
},
"variables": {"triggering_entity": "{{ trigger.entity_id }}"},
"action": [
{"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}}
],
}
LOCK_ACTION = {
"lock": {
@@ -113,6 +125,29 @@ async def async_setup_modern_format(
await hass.async_block_till_done()
async def async_setup_trigger_format(
hass: HomeAssistant, count: int, lock_config: dict[str, Any]
) -> None:
"""Do setup of lock integration via trigger format."""
config = {
"template": {
"lock": {"name": TEST_OBJECT_ID, **lock_config},
**TEST_STATE_TRIGGER,
}
}
with assert_setup_component(count, template.DOMAIN):
assert await async_setup_component(
hass,
template.DOMAIN,
config,
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
@pytest.fixture
async def setup_lock(
hass: HomeAssistant,
@@ -125,6 +160,8 @@ async def setup_lock(
await async_setup_legacy_format(hass, count, lock_config)
elif style == ConfigurationStyle.MODERN:
await async_setup_modern_format(hass, count, lock_config)
elif style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(hass, count, lock_config)
@pytest.fixture
@@ -148,6 +185,12 @@ async def setup_base_lock(
count,
{"state": state_template, **extra_config},
)
elif style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(
hass,
count,
{"state": state_template, **extra_config},
)
@pytest.fixture
@@ -176,6 +219,15 @@ async def setup_state_lock(
"state": state_template,
},
)
elif style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(
hass,
count,
{
**OPTIMISTIC_LOCK,
"state": state_template,
},
)
@pytest.fixture
@@ -199,6 +251,12 @@ async def setup_state_lock_with_extra_config(
count,
{**OPTIMISTIC_LOCK, "state": state_template, **extra_config},
)
elif style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(
hass,
count,
{**OPTIMISTIC_LOCK, "state": state_template, **extra_config},
)
@pytest.fixture
@@ -228,13 +286,20 @@ async def setup_state_lock_with_attribute(
count,
{**OPTIMISTIC_LOCK, "state": state_template, **extra},
)
elif style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(
hass,
count,
{**OPTIMISTIC_LOCK, "state": state_template, **extra},
)
@pytest.mark.parametrize(
("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")]
("count", "state_template"), [(1, "{{ states.sensor.test_state.state }}")]
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.usefixtures("setup_state_lock")
async def test_template_state(hass: HomeAssistant) -> None:
@@ -260,10 +325,11 @@ async def test_template_state(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
("count", "state_template", "extra_config"),
[(1, "{{ states.switch.test_state.state }}", {"optimistic": True, **OPEN_ACTION})],
[(1, "{{ states.sensor.test_state.state }}", {"optimistic": True, **OPEN_ACTION})],
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.usefixtures("setup_state_lock_with_extra_config")
async def test_open_lock_optimistic(
@@ -293,18 +359,24 @@ async def test_open_lock_optimistic(
@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 1 }}")])
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.usefixtures("setup_state_lock")
async def test_template_state_boolean_on(hass: HomeAssistant) -> None:
"""Test the setting of the state with boolean on."""
# Ensure the trigger executes for trigger configurations
hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.state == LockState.LOCKED
@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 2 }}")])
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.usefixtures("setup_state_lock")
async def test_template_state_boolean_off(hass: HomeAssistant) -> None:
@@ -315,7 +387,8 @@ async def test_template_state_boolean_off(hass: HomeAssistant) -> None:
@pytest.mark.parametrize("count", [0])
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.parametrize(
("state_template", "extra_config"),
@@ -326,7 +399,7 @@ async def test_template_state_boolean_off(hass: HomeAssistant) -> None:
(
"{{ 1==1 }}",
{
"not_value_template": "{{ states.switch.test_state.state }}",
"not_value_template": "{{ states.sensor.test_state.state }}",
**OPTIMISTIC_LOCK,
},
),
@@ -345,6 +418,7 @@ async def test_template_syntax_error(hass: HomeAssistant) -> None:
[
(ConfigurationStyle.LEGACY, "code_format_template"),
(ConfigurationStyle.MODERN, "code_format"),
(ConfigurationStyle.TRIGGER, "code_format"),
],
)
@pytest.mark.usefixtures("setup_state_lock_with_attribute")
@@ -355,7 +429,8 @@ async def test_template_code_template_syntax_error(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 + 1 }}")])
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.usefixtures("setup_state_lock")
async def test_template_static(hass: HomeAssistant) -> None:
@@ -371,7 +446,8 @@ async def test_template_static(hass: HomeAssistant) -> None:
@pytest.mark.parametrize("count", [1])
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.parametrize(
("state_template", "expected"),
@@ -384,26 +460,33 @@ async def test_template_static(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("setup_state_lock")
async def test_state_template(hass: HomeAssistant, expected: str) -> None:
"""Test state and value_template template."""
# Ensure the trigger executes for trigger configurations
hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.state == expected
@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1==1 }}")])
@pytest.mark.parametrize(
"attribute_template",
["{% if states.switch.test_state.state %}/local/switch.png{% endif %}"],
("count", "state_template", "attribute"), [(1, "{{ 1==1 }}", "picture")]
)
@pytest.mark.parametrize(
("style", "attribute"),
"attribute_template",
["{% if states.sensor.test_state.state %}/local/switch.png{% endif %}"],
)
@pytest.mark.parametrize(
("style", "initial_state"),
[
(ConfigurationStyle.MODERN, "picture"),
(ConfigurationStyle.MODERN, ""),
(ConfigurationStyle.TRIGGER, None),
],
)
@pytest.mark.usefixtures("setup_state_lock_with_attribute")
async def test_picture_template(hass: HomeAssistant) -> None:
async def test_picture_template(hass: HomeAssistant, initial_state: str) -> None:
"""Test entity_picture template."""
state = hass.states.get(TEST_ENTITY_ID)
assert state.attributes.get("entity_picture") in ("", None)
assert state.attributes.get("entity_picture") == initial_state
hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON)
await hass.async_block_till_done()
@@ -412,22 +495,25 @@ async def test_picture_template(hass: HomeAssistant) -> None:
assert state.attributes["entity_picture"] == "/local/switch.png"
@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1==1 }}")])
@pytest.mark.parametrize(
"attribute_template",
["{% if states.switch.test_state.state %}mdi:eye{% endif %}"],
("count", "state_template", "attribute"), [(1, "{{ 1==1 }}", "icon")]
)
@pytest.mark.parametrize(
("style", "attribute"),
"attribute_template",
["{% if states.sensor.test_state.state %}mdi:eye{% endif %}"],
)
@pytest.mark.parametrize(
("style", "initial_state"),
[
(ConfigurationStyle.MODERN, "icon"),
(ConfigurationStyle.MODERN, ""),
(ConfigurationStyle.TRIGGER, None),
],
)
@pytest.mark.usefixtures("setup_state_lock_with_attribute")
async def test_icon_template(hass: HomeAssistant) -> None:
async def test_icon_template(hass: HomeAssistant, initial_state: str) -> None:
"""Test entity_picture template."""
state = hass.states.get(TEST_ENTITY_ID)
assert state.attributes.get("icon") in ("", None)
assert state.attributes.get("icon") == initial_state
hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON)
await hass.async_block_till_done()
@@ -437,10 +523,11 @@ async def test_icon_template(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")]
("count", "state_template"), [(1, "{{ states.sensor.test_state.state }}")]
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.usefixtures("setup_state_lock")
async def test_lock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None:
@@ -464,10 +551,11 @@ async def test_lock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> Non
@pytest.mark.parametrize(
("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")]
("count", "state_template"), [(1, "{{ states.sensor.test_state.state }}")]
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.usefixtures("setup_state_lock")
async def test_unlock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None:
@@ -492,10 +580,11 @@ async def test_unlock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> N
@pytest.mark.parametrize(
("count", "state_template", "extra_config"),
[(1, "{{ states.switch.test_state.state }}", OPEN_ACTION)],
[(1, "{{ states.sensor.test_state.state }}", OPEN_ACTION)],
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.usefixtures("setup_state_lock_with_extra_config")
async def test_open_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None:
@@ -523,7 +612,7 @@ async def test_open_action(hass: HomeAssistant, calls: list[ServiceCall]) -> Non
[
(
1,
"{{ states.switch.test_state.state }}",
"{{ states.sensor.test_state.state }}",
"{{ '.+' }}",
)
],
@@ -533,6 +622,7 @@ async def test_open_action(hass: HomeAssistant, calls: list[ServiceCall]) -> Non
[
(ConfigurationStyle.LEGACY, "code_format_template"),
(ConfigurationStyle.MODERN, "code_format"),
(ConfigurationStyle.TRIGGER, "code_format"),
],
)
@pytest.mark.usefixtures("setup_state_lock_with_attribute")
@@ -564,7 +654,7 @@ async def test_lock_action_with_code(
[
(
1,
"{{ states.switch.test_state.state }}",
"{{ states.sensor.test_state.state }}",
"{{ '.+' }}",
)
],
@@ -574,6 +664,7 @@ async def test_lock_action_with_code(
[
(ConfigurationStyle.LEGACY, "code_format_template"),
(ConfigurationStyle.MODERN, "code_format"),
(ConfigurationStyle.TRIGGER, "code_format"),
],
)
@pytest.mark.usefixtures("setup_state_lock_with_attribute")
@@ -616,6 +707,7 @@ async def test_unlock_action_with_code(
[
(ConfigurationStyle.LEGACY, "code_format_template"),
(ConfigurationStyle.MODERN, "code_format"),
(ConfigurationStyle.TRIGGER, "code_format"),
],
)
@pytest.mark.parametrize(
@@ -630,6 +722,10 @@ async def test_lock_actions_fail_with_invalid_code(
hass: HomeAssistant, calls: list[ServiceCall], test_action
) -> None:
"""Test invalid lock codes."""
# Ensure trigger entities updated
hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON)
await hass.async_block_till_done()
await hass.services.async_call(
lock.DOMAIN,
test_action,
@@ -656,17 +752,23 @@ async def test_lock_actions_fail_with_invalid_code(
],
)
@pytest.mark.parametrize(
("style", "attribute"),
("style", "attribute", "expected"),
[
(ConfigurationStyle.LEGACY, "code_format_template"),
(ConfigurationStyle.MODERN, "code_format"),
(ConfigurationStyle.LEGACY, "code_format_template", 0),
(ConfigurationStyle.MODERN, "code_format", 0),
(ConfigurationStyle.TRIGGER, "code_format", 2),
],
)
@pytest.mark.usefixtures("setup_state_lock_with_attribute")
async def test_lock_actions_dont_execute_with_code_template_rendering_error(
hass: HomeAssistant, calls: list[ServiceCall]
hass: HomeAssistant, calls: list[ServiceCall], expected: int
) -> None:
"""Test lock code format rendering fails block lock/unlock actions."""
# Ensure trigger entities updated
hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON)
await hass.async_block_till_done()
await hass.services.async_call(
lock.DOMAIN,
lock.SERVICE_LOCK,
@@ -679,7 +781,10 @@ async def test_lock_actions_dont_execute_with_code_template_rendering_error(
)
await hass.async_block_till_done()
assert len(calls) == 0
# Trigger expects calls here because trigger based entities don't
# allow template exception resolutions into code_format property so
# the actions will fire using the previous code_format.
assert len(calls) == expected
@pytest.mark.parametrize(
@@ -687,7 +792,7 @@ async def test_lock_actions_dont_execute_with_code_template_rendering_error(
[
(
1,
"{{ states.switch.test_state.state }}",
"{{ states.sensor.test_state.state }}",
"{{ None }}",
)
],
@@ -697,6 +802,7 @@ async def test_lock_actions_dont_execute_with_code_template_rendering_error(
[
(ConfigurationStyle.LEGACY, "code_format_template"),
(ConfigurationStyle.MODERN, "code_format"),
(ConfigurationStyle.TRIGGER, "code_format"),
],
)
@pytest.mark.parametrize("action", [lock.SERVICE_LOCK, lock.SERVICE_UNLOCK])
@@ -729,7 +835,7 @@ async def test_actions_with_none_as_codeformat_ignores_code(
[
(
1,
"{{ states.switch.test_state.state }}",
"{{ states.sensor.test_state.state }}",
"[12]{1",
)
],
@@ -739,6 +845,7 @@ async def test_actions_with_none_as_codeformat_ignores_code(
[
(ConfigurationStyle.LEGACY, "code_format_template"),
(ConfigurationStyle.MODERN, "code_format"),
(ConfigurationStyle.TRIGGER, "code_format"),
],
)
@pytest.mark.parametrize("action", [lock.SERVICE_LOCK, lock.SERVICE_UNLOCK])
@@ -774,10 +881,11 @@ async def test_actions_with_invalid_regexp_as_codeformat_never_execute(
@pytest.mark.parametrize(
("count", "state_template"), [(1, "{{ states.input_select.test_state.state }}")]
("count", "state_template"), [(1, "{{ states.sensor.test_state.state }}")]
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.parametrize(
"test_state", [LockState.UNLOCKING, LockState.LOCKING, LockState.JAMMED]
@@ -785,7 +893,7 @@ async def test_actions_with_invalid_regexp_as_codeformat_never_execute(
@pytest.mark.usefixtures("setup_state_lock")
async def test_lock_state(hass: HomeAssistant, test_state) -> None:
"""Test value template."""
hass.states.async_set("input_select.test_state", test_state)
hass.states.async_set(TEST_STATE_ENTITY_ID, test_state)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
@@ -797,7 +905,7 @@ async def test_lock_state(hass: HomeAssistant, test_state) -> None:
[
(
1,
"{{ states('switch.test_state') }}",
"{{ states('sensor.test_state') }}",
"{{ is_state('availability_state.state', 'on') }}",
)
],
@@ -807,20 +915,21 @@ async def test_lock_state(hass: HomeAssistant, test_state) -> None:
[
(ConfigurationStyle.LEGACY, "availability_template"),
(ConfigurationStyle.MODERN, "availability"),
(ConfigurationStyle.TRIGGER, "availability"),
],
)
@pytest.mark.usefixtures("setup_state_lock_with_attribute")
async def test_available_template_with_entities(hass: HomeAssistant) -> None:
"""Test availability templates with values from other entities."""
# When template returns true..
hass.states.async_set("availability_state.state", STATE_ON)
hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_ON)
await hass.async_block_till_done()
# Device State should not be unavailable
assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE
# When Availability template returns false
hass.states.async_set("availability_state.state", STATE_OFF)
hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_OFF)
await hass.async_block_till_done()
# device state should be unavailable
@@ -842,15 +951,20 @@ async def test_available_template_with_entities(hass: HomeAssistant) -> None:
[
(ConfigurationStyle.LEGACY, "availability_template"),
(ConfigurationStyle.MODERN, "availability"),
(ConfigurationStyle.TRIGGER, "availability"),
],
)
@pytest.mark.usefixtures("setup_state_lock_with_attribute")
async def test_invalid_availability_template_keeps_component_available(
hass: HomeAssistant, caplog_setup_text
hass: HomeAssistant, caplog_setup_text, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that an invalid availability keeps the device available."""
hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON)
await hass.async_block_till_done()
assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE
assert ("UndefinedError: 'x' is undefined") in caplog_setup_text
err = "'x' is undefined"
assert err in caplog_setup_text or err in caplog.text
@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)])

View File

@@ -0,0 +1,12 @@
"""Tests for the Tilt Pi integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Fixture for setting up the integration."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)

View File

@@ -0,0 +1,70 @@
"""Common fixtures for the Tilt Pi tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from tiltpi import TiltColor, TiltHydrometerData
from homeassistant.components.tilt_pi.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PORT
from tests.common import MockConfigEntry
TEST_NAME = "Test Tilt Pi"
TEST_HOST = "192.168.1.123"
TEST_PORT = 1880
TEST_URL = f"http://{TEST_HOST}:{TEST_PORT}"
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.tilt_pi.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_HOST: TEST_HOST,
CONF_PORT: TEST_PORT,
},
)
@pytest.fixture
def mock_tiltpi_client() -> Generator[AsyncMock]:
"""Mock a TiltPi client."""
with (
patch(
"homeassistant.components.tilt_pi.coordinator.TiltPiClient",
autospec=True,
) as mock_client,
patch(
"homeassistant.components.tilt_pi.config_flow.TiltPiClient",
new=mock_client,
),
):
client = mock_client.return_value
client.get_hydrometers.return_value = [
TiltHydrometerData(
mac_id="00:1A:2B:3C:4D:5E",
color=TiltColor.BLACK,
temperature=55.0,
gravity=1.010,
),
TiltHydrometerData(
mac_id="00:1s:99:f1:d2:4f",
color=TiltColor.YELLOW,
temperature=68.0,
gravity=1.015,
),
]
yield client

View File

@@ -0,0 +1,217 @@
# serializer version: 1
# name: test_all_sensors[sensor.tilt_black_gravity-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.tilt_black_gravity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Gravity',
'platform': 'tilt_pi',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'gravity',
'unique_id': '00:1A:2B:3C:4D:5E_gravity',
'unit_of_measurement': 'SG',
})
# ---
# name: test_all_sensors[sensor.tilt_black_gravity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Tilt Black Gravity',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'SG',
}),
'context': <ANY>,
'entity_id': 'sensor.tilt_black_gravity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.01',
})
# ---
# name: test_all_sensors[sensor.tilt_black_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.tilt_black_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'tilt_pi',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00:1A:2B:3C:4D:5E_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_sensors[sensor.tilt_black_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Tilt Black Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.tilt_black_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '12.7777777777778',
})
# ---
# name: test_all_sensors[sensor.tilt_yellow_gravity-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.tilt_yellow_gravity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Gravity',
'platform': 'tilt_pi',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'gravity',
'unique_id': '00:1s:99:f1:d2:4f_gravity',
'unit_of_measurement': 'SG',
})
# ---
# name: test_all_sensors[sensor.tilt_yellow_gravity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Tilt Yellow Gravity',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'SG',
}),
'context': <ANY>,
'entity_id': 'sensor.tilt_yellow_gravity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.015',
})
# ---
# name: test_all_sensors[sensor.tilt_yellow_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.tilt_yellow_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'tilt_pi',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00:1s:99:f1:d2:4f_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_sensors[sensor.tilt_yellow_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Tilt Yellow Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.tilt_yellow_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '20.0',
})
# ---

View File

@@ -0,0 +1,125 @@
"""Test the Tilt config flow."""
from unittest.mock import AsyncMock
from homeassistant.components.tilt_pi.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_async_step_user_gets_form_and_creates_entry(
hass: HomeAssistant,
mock_tiltpi_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test that the we can view the form and that the 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["step_id"] == "user"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_URL: "http://192.168.1.123:1880"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_HOST: "192.168.1.123",
CONF_PORT: 1880,
}
async def test_abort_if_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
) -> None:
"""Test that we abort if we attempt to submit the same entry twice."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_URL: "http://192.168.1.123:1880"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_successful_recovery_after_invalid_host(
hass: HomeAssistant,
mock_tiltpi_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test error shown when user submits invalid host."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
# Simulate a invalid host error by providing an invalid URL
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_URL: "not-a-valid-url"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"url": "invalid_host"}
# Demonstrate successful connection on retry
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_URL: "http://192.168.1.123:1880"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_HOST: "192.168.1.123",
CONF_PORT: 1880,
}
async def test_successful_recovery_after_connection_error(
hass: HomeAssistant,
mock_tiltpi_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test error shown when connection fails."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
# Simulate a connection error by raising a TimeoutError
mock_tiltpi_client.get_hydrometers.side_effect = TimeoutError()
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_URL: "http://192.168.1.123:1880"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
# Simulate successful connection on retry
mock_tiltpi_client.get_hydrometers.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_URL: "http://192.168.1.123:1880"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_HOST: "192.168.1.123",
CONF_PORT: 1880,
}

View File

@@ -0,0 +1,84 @@
"""Test the Tilt Hydrometer sensors."""
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from syrupy.assertion import SnapshotAssertion
from tiltpi import TiltColor, TiltPiConnectionError
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
async def test_all_sensors(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_tiltpi_client: AsyncMock,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the Tilt Pi sensors.
When making changes to this test, ensure that the snapshot reflects the
new data by generating it via:
$ pytest tests/components/tilt_pi/test_sensor.py -v --snapshot-update
"""
with patch("homeassistant.components.tilt_pi.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_availability(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_tiltpi_client: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that entities become unavailable when the coordinator fails."""
with patch("homeassistant.components.tilt_pi.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
# Simulate a coordinator update failure
mock_tiltpi_client.get_hydrometers.side_effect = TiltPiConnectionError()
freezer.tick(60)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Check that entities are unavailable
for color in (TiltColor.BLACK, TiltColor.YELLOW):
temperature_entity_id = f"sensor.tilt_{color}_temperature"
gravity_entity_id = f"sensor.tilt_{color}_gravity"
temperature_state = hass.states.get(temperature_entity_id)
assert temperature_state is not None
assert temperature_state.state == STATE_UNAVAILABLE
gravity_state = hass.states.get(gravity_entity_id)
assert gravity_state is not None
assert gravity_state.state == STATE_UNAVAILABLE
# Simulate a coordinator update success
mock_tiltpi_client.get_hydrometers.side_effect = None
freezer.tick(60)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Check that entities are now available
for color in (TiltColor.BLACK, TiltColor.YELLOW):
temperature_entity_id = f"sensor.tilt_{color}_temperature"
gravity_entity_id = f"sensor.tilt_{color}_gravity"
temperature_state = hass.states.get(temperature_entity_id)
assert temperature_state is not None
assert temperature_state.state != STATE_UNAVAILABLE
gravity_state = hass.states.get(gravity_entity_id)
assert gravity_state is not None
assert gravity_state.state != STATE_UNAVAILABLE

View File

@@ -48,6 +48,7 @@ async def _setup_legacy_component(hass: HomeAssistant, params: dict[str, Any]) -
)
async def test_basic_trend_setup_from_yaml(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
states: list[str],
inverted: bool,
expected_state: str,
@@ -72,6 +73,43 @@ async def test_basic_trend_setup_from_yaml(
assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor"))
assert sensor_state.state == expected_state
# Verify that entity without unique_id in YAML is not in the registry
entity_entry = entity_registry.async_get("binary_sensor.test_trend_sensor")
assert entity_entry is None
async def test_trend_setup_from_yaml_with_unique_id(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test trend setup from YAML with unique_id."""
await _setup_legacy_component(
hass,
{
"friendly_name": "Test state with ID",
"entity_id": "sensor.cpu_temp",
"unique_id": "my_unique_trend_sensor",
"max_samples": 2.0,
"min_gradient": 0.0,
"sample_duration": 0.0,
},
)
# Set some states to ensure the sensor works
hass.states.async_set("sensor.cpu_temp", "1")
await hass.async_block_till_done()
hass.states.async_set("sensor.cpu_temp", "2")
await hass.async_block_till_done()
# Check that the sensor exists and has the correct state
assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor"))
assert sensor_state.state == STATE_ON
# Check that the entity is registered with the correct unique_id
entity_entry = entity_registry.async_get("binary_sensor.test_trend_sensor")
assert entity_entry is not None
assert entity_entry.unique_id == "my_unique_trend_sensor"
@pytest.mark.parametrize(
("states", "inverted", "expected_state"),