Compare commits

..

13 Commits

Author SHA1 Message Date
Artur Pragacz 905e154f63 Merge branch 'dev' into fail_on_templated_service_data 2025-11-10 17:53:59 +01:00
Artur Pragacz 329fdcbe98 Update homeassistant/helpers/config_validation.py 2025-11-10 17:49:41 +01:00
Artur Pragacz 0eb2feb537 Remove entity service fields workaround 2025-11-10 17:49:29 +01:00
Erik Montnemery 569aeb6ddf Merge branch 'dev' into fail_on_templated_service_data 2025-10-30 15:39:32 +01:00
Erik accc48a49e Fix lint error 2025-06-27 20:55:18 +02:00
Erik Montnemery 3c0c71212b Merge branch 'dev' into fail_on_templated_service_data 2025-06-27 18:43:15 +02:00
Erik 4c0fcbb824 Don't fail on camera 2024-11-08 13:26:04 +01:00
Erik d8716b6b72 Don't fail on unifiprotect 2024-11-08 13:25:23 +01:00
Erik e88092dc52 Extend checks 2024-11-08 13:25:23 +01:00
Erik 6e4b5f31d4 Extend checks 2024-11-08 13:25:23 +01:00
Erik df6763328f Remove explicit templating of knx service data 2024-11-08 13:25:23 +01:00
Erik 51e409c1c7 Check all services 2024-11-08 13:25:23 +01:00
Erik 8c9bf7e41c Fail on templated services 2024-11-08 13:23:12 +01:00
75 changed files with 398 additions and 3445 deletions
+1 -1
View File
@@ -622,7 +622,7 @@ jobs:
steps:
- *checkout
- name: Dependency review
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
with:
license-check: false # We use our own license audit checks
Generated
+2 -2
View File
@@ -1017,8 +1017,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/msteams/ @peroyvind
/homeassistant/components/mullvad/ @meichthys
/tests/components/mullvad/ @meichthys
/homeassistant/components/music_assistant/ @music-assistant @arturpragacz
/tests/components/music_assistant/ @music-assistant @arturpragacz
/homeassistant/components/music_assistant/ @music-assistant
/tests/components/music_assistant/ @music-assistant
/homeassistant/components/mutesync/ @currentoor
/tests/components/mutesync/ @currentoor
/homeassistant/components/my/ @home-assistant/core
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
import logging
from typing import Any, Literal
from typing import Literal
from hassil.recognize import RecognizeResult
import voluptuous as vol
@@ -21,7 +21,6 @@ from homeassistant.core import (
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, intent
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
@@ -53,8 +52,6 @@ from .const import (
DATA_COMPONENT,
DOMAIN,
HOME_ASSISTANT_AGENT,
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
SERVICE_PROCESS,
SERVICE_RELOAD,
ConversationEntityFeature,
@@ -269,13 +266,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass)
hass.data[DATA_COMPONENT] = entity_component
manager = get_agent_manager(hass)
hass_config_path = hass.config.path()
config_intents = _get_config_intents(config, hass_config_path)
manager.update_config_intents(config_intents)
await async_setup_default_agent(hass, entity_component)
agent_config = config.get(DOMAIN, {})
await async_setup_default_agent(
hass, entity_component, config_intents=agent_config.get("intents", {})
)
async def handle_process(service: ServiceCall) -> ServiceResponse:
"""Parse text into commands."""
@@ -300,16 +294,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def handle_reload(service: ServiceCall) -> None:
"""Reload intents."""
language = service.data.get(ATTR_LANGUAGE)
if language is None:
conf = await async_integration_yaml_config(hass, DOMAIN)
if conf is not None:
config_intents = _get_config_intents(conf, hass_config_path)
manager.update_config_intents(config_intents)
agent = manager.default_agent
agent = get_agent_manager(hass).default_agent
if agent is not None:
await agent.async_reload(language=language)
await agent.async_reload(language=service.data.get(ATTR_LANGUAGE))
hass.services.async_register(
DOMAIN,
@@ -326,27 +313,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
def _get_config_intents(config: ConfigType, hass_config_path: str) -> dict[str, Any]:
"""Return config intents."""
intents = config.get(DOMAIN, {}).get("intents", {})
return {
"intents": {
intent_name: {
"data": [
{
"sentences": sentences,
"metadata": {
METADATA_CUSTOM_SENTENCE: True,
METADATA_CUSTOM_FILE: hass_config_path,
},
}
]
}
for intent_name, sentences in intents.items()
}
}
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
@@ -147,7 +147,6 @@ class AgentManager:
self.hass = hass
self._agents: dict[str, AbstractConversationAgent] = {}
self.default_agent: DefaultAgent | None = None
self.config_intents: dict[str, Any] = {}
self.triggers_details: list[TriggerDetails] = []
@callback
@@ -200,16 +199,9 @@ class AgentManager:
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
"""Set up the default agent."""
agent.update_config_intents(self.config_intents)
agent.update_triggers(self.triggers_details)
self.default_agent = agent
def update_config_intents(self, intents: dict[str, Any]) -> None:
"""Update config intents."""
self.config_intents = intents
if self.default_agent is not None:
self.default_agent.update_config_intents(intents)
def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE:
"""Register a trigger."""
self.triggers_details.append(trigger_details)
@@ -30,7 +30,3 @@ class ConversationEntityFeature(IntFlag):
"""Supported features of the conversation entity."""
CONTROL = 1
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"
@@ -77,12 +77,7 @@ from homeassistant.util.json import JsonObjectType, json_loads_object
from .agent_manager import get_agent_manager
from .chat_log import AssistantContent, ChatLog
from .const import (
DOMAIN,
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
ConversationEntityFeature,
)
from .const import DOMAIN, ConversationEntityFeature
from .entity import ConversationEntity
from .models import ConversationInput, ConversationResult
from .trace import ConversationTraceEventType, async_conversation_trace_append
@@ -96,6 +91,8 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"
METADATA_FUZZY_MATCH = "hass_fuzzy_match"
ERROR_SENTINEL = object()
@@ -205,9 +202,10 @@ class IntentCache:
async def async_setup_default_agent(
hass: HomeAssistant,
entity_component: EntityComponent[ConversationEntity],
config_intents: dict[str, Any],
) -> None:
"""Set up entity registry listener for the default agent."""
agent = DefaultAgent(hass)
agent = DefaultAgent(hass, config_intents)
await entity_component.async_add_entities([agent])
await get_agent_manager(hass).async_setup_default_agent(agent)
@@ -232,14 +230,14 @@ class DefaultAgent(ConversationEntity):
_attr_name = "Home Assistant"
_attr_supported_features = ConversationEntityFeature.CONTROL
def __init__(self, hass: HomeAssistant) -> None:
def __init__(self, hass: HomeAssistant, config_intents: dict[str, Any]) -> None:
"""Initialize the default agent."""
self.hass = hass
self._lang_intents: dict[str, LanguageIntents | object] = {}
self._load_intents_lock = asyncio.Lock()
# Intents from common conversation config
self._config_intents: dict[str, Any] = {}
# intent -> [sentences]
self._config_intents: dict[str, Any] = config_intents
# Sentences that will trigger a callback (skipping intent recognition)
self._triggers_details: list[TriggerDetails] = []
@@ -1037,14 +1035,6 @@ class DefaultAgent(ConversationEntity):
# Intents have changed, so we must clear the cache
self._intent_cache.clear()
@callback
def update_config_intents(self, intents: dict[str, Any]) -> None:
"""Update config intents."""
self._config_intents = intents
# Intents have changed, so we must clear the cache
self._intent_cache.clear()
async def async_prepare(self, language: str | None = None) -> None:
"""Load intents for a language."""
if language is None:
@@ -1169,10 +1159,33 @@ class DefaultAgent(ConversationEntity):
custom_sentences_path,
)
merge_dict(
intents_dict,
self._config_intents,
)
# Load sentences from HA config for default language only
if self._config_intents and (
self.hass.config.language in (language, language_variant)
):
hass_config_path = self.hass.config.path()
merge_dict(
intents_dict,
{
"intents": {
intent_name: {
"data": [
{
"sentences": sentences,
"metadata": {
METADATA_CUSTOM_SENTENCE: True,
METADATA_CUSTOM_FILE: hass_config_path,
},
}
]
}
for intent_name, sentences in self._config_intents.items()
}
},
)
_LOGGER.debug(
"Loaded intents from configuration.yaml",
)
if not intents_dict:
return None
@@ -1237,7 +1237,7 @@
"message": "Error obtaining data from the API: {error}"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
"message": "OAuth2 implementation temporarily unavailable, will retry"
},
"pause_program": {
"message": "Error pausing program: {error}"
@@ -4,7 +4,6 @@ import logging
from typing import TYPE_CHECKING
from aiopvapi.resources.model import PowerviewData
from aiopvapi.resources.shade_data import PowerviewShadeData
from aiopvapi.rooms import Rooms
from aiopvapi.scenes import Scenes
from aiopvapi.shades import Shades
@@ -17,6 +16,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import DOMAIN, HUB_EXCEPTIONS, MANUFACTURER
from .coordinator import PowerviewShadeUpdateCoordinator
from .model import PowerviewConfigEntry, PowerviewEntryData
from .shade_data import PowerviewShadeData
from .util import async_connect_hub
PARALLEL_UPDATES = 1
@@ -8,7 +8,6 @@ import logging
from aiopvapi.helpers.aiorequest import PvApiMaintenance
from aiopvapi.hub import Hub
from aiopvapi.resources.shade_data import PowerviewShadeData
from aiopvapi.shades import Shades
from homeassistant.config_entries import ConfigEntry
@@ -16,6 +15,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import HUB_EXCEPTIONS
from .shade_data import PowerviewShadeData
_LOGGER = logging.getLogger(__name__)
@@ -208,13 +208,13 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
async def _async_execute_move(self, move: ShadePosition) -> None:
"""Execute a move that can affect multiple positions."""
_LOGGER.debug("Move request %s: %s", self.name, move)
# Store the requested positions so subsequent move
# requests contain the secondary shade positions
self.data.update_shade_position(self._shade.id, move)
async with self.coordinator.radio_operation_lock:
response = await self._shade.move(move)
_LOGGER.debug("Move response %s: %s", self.name, response)
# Process the response from the hub (including new positions)
self.data.update_shade_position(self._shade.id, response)
async def _async_set_cover_position(self, target_hass_position: int) -> None:
"""Move the shade to a position."""
target_hass_position = self._clamp_cover_limit(target_hass_position)
@@ -3,7 +3,6 @@
import logging
from aiopvapi.resources.shade import BaseShade, ShadePosition
from aiopvapi.resources.shade_data import PowerviewShadeData
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
@@ -12,6 +11,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import PowerviewShadeUpdateCoordinator
from .model import PowerviewDeviceInfo
from .shade_data import PowerviewShadeData
_LOGGER = logging.getLogger(__name__)
@@ -0,0 +1,80 @@
"""Shade data for the Hunter Douglas PowerView integration."""
from __future__ import annotations
from dataclasses import fields
from typing import Any
from aiopvapi.resources.model import PowerviewData
from aiopvapi.resources.shade import BaseShade, ShadePosition
from .util import async_map_data_by_id
POSITION_FIELDS = [field for field in fields(ShadePosition) if field.name != "velocity"]
def copy_position_data(source: ShadePosition, target: ShadePosition) -> ShadePosition:
"""Copy position data from source to target for None values only."""
for field in POSITION_FIELDS:
if (value := getattr(source, field.name)) is not None:
setattr(target, field.name, value)
class PowerviewShadeData:
"""Coordinate shade data between multiple api calls."""
def __init__(self) -> None:
"""Init the shade data."""
self._raw_data_by_id: dict[int, dict[str | int, Any]] = {}
self._shade_group_data_by_id: dict[int, BaseShade] = {}
self.positions: dict[int, ShadePosition] = {}
def get_raw_data(self, shade_id: int) -> dict[str | int, Any]:
"""Get data for the shade."""
return self._raw_data_by_id[shade_id]
def get_all_raw_data(self) -> dict[int, dict[str | int, Any]]:
"""Get data for all shades."""
return self._raw_data_by_id
def get_shade(self, shade_id: int) -> BaseShade:
"""Get specific shade from the coordinator."""
return self._shade_group_data_by_id[shade_id]
def get_shade_position(self, shade_id: int) -> ShadePosition:
"""Get positions for a shade."""
if shade_id not in self.positions:
shade_position = ShadePosition()
# If we have the group data, use it to populate the initial position
if shade := self._shade_group_data_by_id.get(shade_id):
copy_position_data(shade.current_position, shade_position)
self.positions[shade_id] = shade_position
return self.positions[shade_id]
def update_from_group_data(self, shade_id: int) -> None:
"""Process an update from the group data."""
data = self._shade_group_data_by_id[shade_id]
copy_position_data(data.current_position, self.get_shade_position(data.id))
def store_group_data(self, shade_data: PowerviewData) -> None:
"""Store data from the all shades endpoint.
This does not update the shades or positions (self.positions)
as the data may be stale. update_from_group_data
with a shade_id will update a specific shade
from the group data.
"""
self._shade_group_data_by_id = shade_data.processed
self._raw_data_by_id = async_map_data_by_id(shade_data.raw)
def update_shade_position(self, shade_id: int, new_position: ShadePosition) -> None:
"""Update a single shades position."""
copy_position_data(new_position, self.get_shade_position(shade_id))
def update_shade_velocity(self, shade_id: int, shade_data: ShadePosition) -> None:
"""Update a single shades velocity."""
# the hub will always return a velocity of 0 on initial connect,
# separate definition to store consistent value in HA
# this value is purely driven from HA
if shade_data.velocity is not None:
self.get_shade_position(shade_id).velocity = shade_data.velocity
@@ -2,15 +2,25 @@
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from aiopvapi.helpers.aiorequest import AioRequest
from aiopvapi.helpers.constants import ATTR_ID
from aiopvapi.hub import Hub
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .model import PowerviewAPI, PowerviewDeviceInfo
@callback
def async_map_data_by_id(data: Iterable[dict[str | int, Any]]):
"""Return a dict with the key being the id for a list of entries."""
return {entry[ATTR_ID]: entry for entry in data}
async def async_connect_hub(
hass: HomeAssistant, address: str, api_version: int | None = None
) -> PowerviewAPI:
@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from propcache.api import cached_property
from pyituran import Vehicle
from homeassistant.components.binary_sensor import (
@@ -68,7 +69,7 @@ class IturanBinarySensor(IturanBaseEntity, BinarySensorEntity):
super().__init__(coordinator, license_plate, description.key)
self.entity_description = description
@property
@cached_property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self.entity_description.value_fn(self.vehicle)
@@ -2,6 +2,8 @@
from __future__ import annotations
from propcache.api import cached_property
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -38,12 +40,12 @@ class IturanDeviceTracker(IturanBaseEntity, TrackerEntity):
"""Initialize the device tracker."""
super().__init__(coordinator, license_plate, "device_tracker")
@property
@cached_property
def latitude(self) -> float | None:
"""Return latitude value of the device."""
return self.vehicle.gps_coordinates[0]
@property
@cached_property
def longitude(self) -> float | None:
"""Return longitude value of the device."""
return self.vehicle.gps_coordinates[1]
+2 -1
View File
@@ -6,6 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from propcache.api import cached_property
from pyituran import Vehicle
from homeassistant.components.sensor import (
@@ -132,7 +133,7 @@ class IturanSensor(IturanBaseEntity, SensorEntity):
super().__init__(coordinator, license_plate, description.key)
self.entity_description = description
@property
@cached_property
def native_value(self) -> StateType | datetime:
"""Return the state of the device."""
return self.entity_description.value_fn(self.vehicle)
+2 -1
View File
@@ -12,7 +12,7 @@ from xknx.telegram import Telegram
from xknx.telegram.address import parse_device_group_address
from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite
from homeassistant.const import CONF_TYPE, SERVICE_RELOAD
from homeassistant.const import CONF_TYPE, CONF_VALUE_TEMPLATE, SERVICE_RELOAD
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
@@ -144,6 +144,7 @@ SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA = vol.Any(
ExposeSchema.EXPOSE_SENSOR_SCHEMA.extend(
{
vol.Optional(SERVICE_KNX_ATTR_REMOVE, default=False): cv.boolean,
vol.Optional(CONF_VALUE_TEMPLATE): cv.string,
}
),
vol.Schema(
@@ -353,13 +353,17 @@ DISCOVERY_SCHEMAS = [
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
# DeviceFault or SupplyFault bit enabled
device_to_ha=lambda x: bool(
x
& (
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault
| clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault
)
),
device_to_ha={
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault: True,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault: True,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedLow: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedHigh: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kLocalOverride: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemotePressure: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemoteFlow: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemoteTemperature: False,
}.get,
),
entity_class=MatterBinarySensor,
required_attributes=(
@@ -373,9 +377,9 @@ DISCOVERY_SCHEMAS = [
key="PumpStatusRunning",
translation_key="pump_running",
device_class=BinarySensorDeviceClass.RUNNING,
device_to_ha=lambda x: bool(
device_to_ha=lambda x: (
x
& clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning
== clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning
),
),
entity_class=MatterBinarySensor,
@@ -391,8 +395,8 @@ DISCOVERY_SCHEMAS = [
translation_key="dishwasher_alarm_inflow",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: bool(
x & clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError
device_to_ha=lambda x: (
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError
),
),
entity_class=MatterBinarySensor,
@@ -406,8 +410,8 @@ DISCOVERY_SCHEMAS = [
translation_key="alarm_door",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: bool(
x & clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError
device_to_ha=lambda x: (
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError
),
),
entity_class=MatterBinarySensor,
@@ -477,8 +481,8 @@ DISCOVERY_SCHEMAS = [
translation_key="alarm_door",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: bool(
x & clusters.RefrigeratorAlarm.Bitmaps.AlarmBitmap.kDoorOpen
device_to_ha=lambda x: (
x == clusters.RefrigeratorAlarm.Bitmaps.AlarmBitmap.kDoorOpen
),
),
entity_class=MatterBinarySensor,
+2 -2
View File
@@ -1009,7 +1009,7 @@
"cleaning_care_program": "Cleaning/care program",
"maintenance_program": "Maintenance program",
"normal_operation_mode": "Normal operation mode",
"own_program": "Program"
"own_program": "Own program"
}
},
"remaining_time": {
@@ -1089,7 +1089,7 @@
"message": "Invalid device targeted."
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
"message": "OAuth2 implementation unavailable, will retry"
},
"set_program_error": {
"message": "'Set program' action failed: {status} / {message}"
@@ -2,7 +2,7 @@
"domain": "music_assistant",
"name": "Music Assistant",
"after_dependencies": ["media_source", "media_player"],
"codeowners": ["@music-assistant", "@arturpragacz"],
"codeowners": ["@music-assistant"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
"iot_class": "local_push",
@@ -57,7 +57,7 @@
"message": "Error while loading the integration."
},
"implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
"message": "OAuth2 implementation is not available, will retry."
},
"incorrect_oauth2_scope": {
"message": "Stored permissions are invalid. Please login again to update permissions."
@@ -26,9 +26,6 @@ def validate_db_schema(instance: Recorder) -> set[str]:
schema_errors |= validate_table_schema_supports_utf8(
instance, StatisticsMeta, (StatisticsMeta.statistic_id,)
)
schema_errors |= validate_table_schema_has_correct_collation(
instance, StatisticsMeta
)
for table in (Statistics, StatisticsShortTerm):
schema_errors |= validate_db_schema_precision(instance, table)
schema_errors |= validate_table_schema_has_correct_collation(instance, table)
+1 -1
View File
@@ -54,7 +54,7 @@ CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36
EVENT_TYPE_IDS_SCHEMA_VERSION = 37
STATES_META_SCHEMA_VERSION = 38
CIRCULAR_MEAN_SCHEMA_VERSION = 49
UNIT_CLASS_SCHEMA_VERSION = 52
UNIT_CLASS_SCHEMA_VERSION = 51
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43
@@ -71,7 +71,7 @@ class LegacyBase(DeclarativeBase):
"""Base class for tables, used for schema migration."""
SCHEMA_VERSION = 52
SCHEMA_VERSION = 51
_LOGGER = logging.getLogger(__name__)
+4 -73
View File
@@ -13,15 +13,7 @@ from typing import TYPE_CHECKING, Any, TypedDict, cast, final
from uuid import UUID
import sqlalchemy
from sqlalchemy import (
ForeignKeyConstraint,
MetaData,
Table,
cast as cast_,
func,
text,
update,
)
from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text, update
from sqlalchemy.engine import CursorResult, Engine
from sqlalchemy.exc import (
DatabaseError,
@@ -34,9 +26,8 @@ from sqlalchemy.exc import (
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.session import Session
from sqlalchemy.schema import AddConstraint, CreateTable, DropConstraint
from sqlalchemy.sql.expression import and_, true
from sqlalchemy.sql.expression import true
from sqlalchemy.sql.lambdas import StatementLambdaElement
from sqlalchemy.types import BINARY
from homeassistant.core import HomeAssistant
from homeassistant.util.enum import try_parse_enum
@@ -2053,74 +2044,14 @@ class _SchemaVersion50Migrator(_SchemaVersionMigrator, target_version=50):
class _SchemaVersion51Migrator(_SchemaVersionMigrator, target_version=51):
def _apply_update(self) -> None:
"""Version specific update method."""
# Replaced with version 52 which corrects issues with MySQL string comparisons.
class _SchemaVersion52Migrator(_SchemaVersionMigrator, target_version=52):
def _apply_update(self) -> None:
"""Version specific update method."""
if self.engine.dialect.name == SupportedDialect.MYSQL:
self._apply_update_mysql()
else:
self._apply_update_postgresql_sqlite()
def _apply_update_mysql(self) -> None:
"""Version specific update method for mysql."""
# Add unit class column to StatisticsMeta
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
with session_scope(session=self.session_maker()) as session:
connection = session.connection()
for conv in _PRIMARY_UNIT_CONVERTERS:
case_sensitive_units = {
u.encode("utf-8") if u else u for u in conv.VALID_UNITS
}
# Reset unit_class to None for entries that do not match
# the valid units (case sensitive) but matched before due to
# case insensitive comparisons.
connection.execute(
update(StatisticsMeta)
.where(
and_(
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
cast_(StatisticsMeta.unit_of_measurement, BINARY).not_in(
case_sensitive_units
),
)
)
.values(unit_class=None)
)
# Do an explicitly case sensitive match (actually binary) to set the
# correct unit_class. This is needed because we use the case sensitive
# utf8mb4_unicode_ci collation.
connection.execute(
update(StatisticsMeta)
.where(
and_(
cast_(StatisticsMeta.unit_of_measurement, BINARY).in_(
case_sensitive_units
),
StatisticsMeta.unit_class.is_(None),
)
)
.values(unit_class=conv.UNIT_CLASS)
)
def _apply_update_postgresql_sqlite(self) -> None:
"""Version specific update method for postgresql and sqlite."""
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
with session_scope(session=self.session_maker()) as session:
connection = session.connection()
for conv in _PRIMARY_UNIT_CONVERTERS:
# Set the correct unit_class. Unlike MySQL, Postgres and SQLite
# have case sensitive string comparisons by default, so we
# can directly match on the valid units.
connection.execute(
update(StatisticsMeta)
.where(
and_(
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
StatisticsMeta.unit_class.is_(None),
)
)
.where(StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS))
.values(unit_class=conv.UNIT_CLASS)
)
@@ -26,7 +26,7 @@ CACHE_SIZE = 8192
_LOGGER = logging.getLogger(__name__)
QUERY_STATISTICS_META = (
QUERY_STATISTIC_META = (
StatisticsMeta.id,
StatisticsMeta.statistic_id,
StatisticsMeta.source,
@@ -55,7 +55,7 @@ def _generate_get_metadata_stmt(
Depending on the schema version, either mean_type (added in version 49) or has_mean column is used.
"""
columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTICS_META)
columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTIC_META)
if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION:
columns.append(StatisticsMeta.mean_type)
else:
+11 -23
View File
@@ -12,7 +12,6 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError
from homeassistant.components.number import (
DOMAIN as NUMBER_PLATFORM,
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberExtraStoredData,
@@ -108,9 +107,6 @@ class RpcNumber(ShellyRpcAttributeEntity, NumberEntity):
if description.mode_fn is not None:
self._attr_mode = description.mode_fn(coordinator.device.config[key])
if hasattr(self, "_attr_name") and description.role != ROLE_GENERIC:
delattr(self, "_attr_name")
@property
def native_value(self) -> float | None:
"""Return value of number."""
@@ -185,6 +181,7 @@ NUMBERS: dict[tuple[str, str], BlockNumberDescription] = {
("device", "valvePos"): BlockNumberDescription(
key="device|valvepos",
translation_key="valve_position",
name="Valve position",
native_unit_of_measurement=PERCENTAGE,
available=lambda block: cast(int, block.valveError) != 1,
entity_category=EntityCategory.CONFIG,
@@ -203,12 +200,12 @@ RPC_NUMBERS: Final = {
key="blutrv",
sub_key="current_C",
translation_key="external_temperature",
name="External temperature",
native_min_value=-50,
native_max_value=50,
native_step=0.1,
mode=NumberMode.BOX,
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
method="blu_trv_set_external_temperature",
entity_class=RpcBluTrvExtTempNumber,
@@ -216,7 +213,7 @@ RPC_NUMBERS: Final = {
"number_generic": RpcNumberDescription(
key="number",
sub_key="value",
removal_condition=lambda config, _, key: not is_view_for_platform(
removal_condition=lambda config, _status, key: not is_view_for_platform(
config, key, NUMBER_PLATFORM
),
max_fn=lambda config: config["max"],
@@ -232,11 +229,9 @@ RPC_NUMBERS: Final = {
"number_current_limit": RpcNumberDescription(
key="number",
sub_key="value",
translation_key="current_limit",
device_class=NumberDeviceClass.CURRENT,
max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"],
mode_fn=lambda _: NumberMode.SLIDER,
mode_fn=lambda config: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit,
method="number_set",
@@ -246,11 +241,10 @@ RPC_NUMBERS: Final = {
"number_position": RpcNumberDescription(
key="number",
sub_key="value",
translation_key="valve_position",
entity_registry_enabled_default=False,
max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"],
mode_fn=lambda _: NumberMode.SLIDER,
mode_fn=lambda config: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit,
method="number_set",
@@ -260,12 +254,10 @@ RPC_NUMBERS: Final = {
"number_target_humidity": RpcNumberDescription(
key="number",
sub_key="value",
translation_key="target_humidity",
device_class=NumberDeviceClass.HUMIDITY,
entity_registry_enabled_default=False,
max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"],
mode_fn=lambda _: NumberMode.SLIDER,
mode_fn=lambda config: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit,
method="number_set",
@@ -275,12 +267,10 @@ RPC_NUMBERS: Final = {
"number_target_temperature": RpcNumberDescription(
key="number",
sub_key="value",
translation_key="target_temperature",
device_class=NumberDeviceClass.TEMPERATURE,
entity_registry_enabled_default=False,
max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"],
mode_fn=lambda _: NumberMode.SLIDER,
mode_fn=lambda config: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit,
method="number_set",
@@ -291,20 +281,21 @@ RPC_NUMBERS: Final = {
key="blutrv",
sub_key="pos",
translation_key="valve_position",
name="Valve position",
native_min_value=0,
native_max_value=100,
native_step=1,
mode=NumberMode.SLIDER,
native_unit_of_measurement=PERCENTAGE,
method="blu_trv_set_valve_position",
removal_condition=lambda config, _, key: config[key].get("enable", True)
removal_condition=lambda config, _status, key: config[key].get("enable", True)
is True,
entity_class=RpcBluTrvNumber,
),
"left_slot_intensity": RpcNumberDescription(
key="cury",
sub_key="slots",
translation_key="left_slot_intensity",
name="Left slot intensity",
value=lambda status, _: status["left"]["intensity"],
native_min_value=0,
native_max_value=100,
@@ -320,7 +311,7 @@ RPC_NUMBERS: Final = {
"right_slot_intensity": RpcNumberDescription(
key="cury",
sub_key="slots",
translation_key="right_slot_intensity",
name="Right slot intensity",
value=lambda status, _: status["right"]["intensity"],
native_min_value=0,
native_max_value=100,
@@ -411,9 +402,6 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber):
self.restored_data: NumberExtraStoredData | None = None
super().__init__(coordinator, block, attribute, description, entry)
if hasattr(self, "_attr_name"):
delattr(self, "_attr_name")
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
await super().async_added_to_hass()
@@ -188,29 +188,6 @@
}
}
},
"number": {
"current_limit": {
"name": "Current limit"
},
"external_temperature": {
"name": "External temperature"
},
"left_slot_intensity": {
"name": "Left slot intensity"
},
"right_slot_intensity": {
"name": "Right slot intensity"
},
"target_humidity": {
"name": "Target humidity"
},
"target_temperature": {
"name": "Target temperature"
},
"valve_position": {
"name": "Valve position"
}
},
"select": {
"cury_mode": {
"name": "Mode",
+7 -2
View File
@@ -19,11 +19,13 @@ from homeassistant.core import (
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.trigger_template_entity import ValueTemplate
from homeassistant.util.json import JsonValueType
from .const import CONF_QUERY, DOMAIN
from .util import (
async_create_sessionmaker,
check_and_render_sql_query,
convert_value,
generate_lambda_stmt,
redact_credentials,
@@ -37,7 +39,9 @@ _LOGGER = logging.getLogger(__name__)
SERVICE_QUERY = "query"
SERVICE_QUERY_SCHEMA = vol.Schema(
{
vol.Required(CONF_QUERY): vol.All(cv.string, validate_sql_select),
vol.Required(CONF_QUERY): vol.All(
cv.template, ValueTemplate.from_template, validate_sql_select
),
vol.Optional(CONF_DB_URL): cv.string,
}
)
@@ -72,8 +76,9 @@ async def _async_query_service(
def _execute_and_convert_query() -> list[JsonValueType]:
"""Execute the query and return the results with converted types."""
sess: Session = sessmaker()
rendered_query = check_and_render_sql_query(call.hass, query_str)
try:
result: Result = sess.execute(generate_lambda_stmt(query_str))
result: Result = sess.execute(generate_lambda_stmt(rendered_query))
except SQLAlchemyError as err:
_LOGGER.debug(
"Error executing query %s: %s",
+4 -8
View File
@@ -18,7 +18,7 @@ import voluptuous as vol
from homeassistant.components.recorder import SupportedDialect, get_instance
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, async_get_hass, callback
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.template import Template
@@ -46,15 +46,11 @@ def resolve_db_url(hass: HomeAssistant, db_url: str | None) -> str:
return get_instance(hass).db_url
def validate_sql_select(value: Template | str) -> Template | str:
def validate_sql_select(value: Template) -> Template:
"""Validate that value is a SQL SELECT query."""
hass: HomeAssistant
if isinstance(value, str):
hass = async_get_hass()
else:
hass = value.hass # type: ignore[assignment]
try:
check_and_render_sql_query(hass, value)
assert value.hass
check_and_render_sql_query(value.hass, value)
except (TemplateError, InvalidSqlQuery) as err:
raise vol.Invalid(str(err)) from err
return value
@@ -84,7 +84,6 @@
"abort": {
"already_configured": "Chat already configured"
},
"entry_type": "Allowed chat ID",
"error": {
"chat_not_found": "Chat not found"
},
@@ -19,9 +19,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import DPCodeEnumWrapper
from .models import EnumTypeData, find_dpcode
from .util import get_dpcode
@@ -85,21 +85,9 @@ async def async_setup_entry(
device = manager.device_map[device_id]
if descriptions := ALARM.get(device.category):
entities.extend(
TuyaAlarmEntity(
device,
manager,
description,
action_dpcode_wrapper=action_dpcode_wrapper,
state_dpcode_wrapper=DPCodeEnumWrapper.find_dpcode(
device, description.master_state
),
)
TuyaAlarmEntity(device, manager, description)
for description in descriptions
if (
action_dpcode_wrapper := DPCodeEnumWrapper.find_dpcode(
device, description.key, prefer_function=True
)
)
if description.key in device.status
)
async_add_entities(entities)
@@ -115,6 +103,7 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
_attr_name = None
_attr_code_arm_required = False
_master_state: EnumTypeData | None = None
_alarm_msg_dpcode: DPCode | None = None
def __init__(
@@ -122,24 +111,33 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
device: CustomerDevice,
device_manager: Manager,
description: TuyaAlarmControlPanelEntityDescription,
*,
action_dpcode_wrapper: DPCodeEnumWrapper,
state_dpcode_wrapper: DPCodeEnumWrapper | None,
) -> None:
"""Init Tuya Alarm."""
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._action_dpcode_wrapper = action_dpcode_wrapper
self._state_dpcode_wrapper = state_dpcode_wrapper
# Determine supported modes
if Mode.HOME in action_dpcode_wrapper.type_information.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
if Mode.ARM in action_dpcode_wrapper.type_information.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
if Mode.SOS in action_dpcode_wrapper.type_information.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
if supported_modes := find_dpcode(
self.device, description.key, dptype=DPType.ENUM, prefer_function=True
):
if Mode.HOME in supported_modes.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
if Mode.ARM in supported_modes.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
if Mode.SOS in supported_modes.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
# Determine master state
if enum_type := find_dpcode(
self.device,
description.master_state,
dptype=DPType.ENUM,
prefer_function=True,
):
self._master_state = enum_type
# Determine alarm message
if dp_code := get_dpcode(self.device, description.alarm_msg):
@@ -151,8 +149,8 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
# When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'.
# The 'mode' doesn't change, and stays as 'arm' or 'home'.
if (
self._state_dpcode_wrapper is not None
and self.device.status.get(self._state_dpcode_wrapper.dpcode) == State.ALARM
self._master_state is not None
and self.device.status.get(self._master_state.dpcode) == State.ALARM
):
# Only report as triggered if NOT a battery warning
if (
@@ -168,26 +166,28 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
def changed_by(self) -> str | None:
"""Last change triggered by."""
if (
self._state_dpcode_wrapper is not None
self._master_state is not None
and self._alarm_msg_dpcode is not None
and self.device.status.get(self._state_dpcode_wrapper.dpcode) == State.ALARM
and self.device.status.get(self._master_state.dpcode) == State.ALARM
and (encoded_msg := self.device.status.get(self._alarm_msg_dpcode))
):
return b64decode(encoded_msg).decode("utf-16be")
return None
async def async_alarm_disarm(self, code: str | None = None) -> None:
def alarm_disarm(self, code: str | None = None) -> None:
"""Send Disarm command."""
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.DISARMED)
self._send_command(
[{"code": self.entity_description.key, "value": Mode.DISARMED}]
)
async def async_alarm_arm_home(self, code: str | None = None) -> None:
def alarm_arm_home(self, code: str | None = None) -> None:
"""Send Home command."""
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.HOME)
self._send_command([{"code": self.entity_description.key, "value": Mode.HOME}])
async def async_alarm_arm_away(self, code: str | None = None) -> None:
def alarm_arm_away(self, code: str | None = None) -> None:
"""Send Arm command."""
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.ARM)
self._send_command([{"code": self.entity_description.key, "value": Mode.ARM}])
async def async_alarm_trigger(self, code: str | None = None) -> None:
def alarm_trigger(self, code: str | None = None) -> None:
"""Send SOS command."""
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.SOS)
self._send_command([{"code": self.entity_description.key, "value": Mode.SOS}])
+1 -1
View File
@@ -196,7 +196,7 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
def find_dpcode(
cls,
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
dpcodes: str | DPCode | tuple[DPCode, ...],
*,
prefer_function: bool = False,
) -> Self | None:
+8 -15
View File
@@ -19,7 +19,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
from .models import DPCodeBooleanWrapper
SIRENS: dict[DeviceCategory, tuple[SirenEntityDescription, ...]] = {
DeviceCategory.CO2BJ: (
@@ -65,13 +64,9 @@ async def async_setup_entry(
device = manager.device_map[device_id]
if descriptions := SIRENS.get(device.category):
entities.extend(
TuyaSirenEntity(device, manager, description, dpcode_wrapper)
TuyaSirenEntity(device, manager, description)
for description in descriptions
if (
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
device, description.key, prefer_function=True
)
)
if description.key in device.status
)
async_add_entities(entities)
@@ -94,23 +89,21 @@ class TuyaSirenEntity(TuyaEntity, SirenEntity):
device: CustomerDevice,
device_manager: Manager,
description: SirenEntityDescription,
dpcode_wrapper: DPCodeBooleanWrapper,
) -> None:
"""Init Tuya Siren."""
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._dpcode_wrapper = dpcode_wrapper
@property
def is_on(self) -> bool | None:
def is_on(self) -> bool:
"""Return true if siren is on."""
return self._dpcode_wrapper.read_device_status(self.device)
return self.device.status.get(self.entity_description.key, False)
async def async_turn_on(self, **kwargs: Any) -> None:
def turn_on(self, **kwargs: Any) -> None:
"""Turn the siren on."""
await self._async_send_dpcode_update(self._dpcode_wrapper, True)
self._send_command([{"code": self.entity_description.key, "value": True}])
async def async_turn_off(self, **kwargs: Any) -> None:
def turn_off(self, **kwargs: Any) -> None:
"""Turn the siren off."""
await self._async_send_dpcode_update(self._dpcode_wrapper, False)
self._send_command([{"code": self.entity_description.key, "value": False}])
@@ -14,7 +14,7 @@
"velbus-protocol"
],
"quality_scale": "bronze",
"requirements": ["velbus-aio==2025.11.0"],
"requirements": ["velbus-aio==2025.8.0"],
"usb": [
{
"pid": "0B1B",
@@ -11,7 +11,6 @@ from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import DOMAIN, SERVICE_UPDATE_DEVS, VS_COORDINATOR, VS_MANAGER
@@ -122,21 +121,3 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
hass.config_entries.async_update_entry(config_entry, minor_version=2)
return True
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
manager = hass.data[DOMAIN][VS_MANAGER]
await manager.get_devices()
for dev in manager.devices:
if isinstance(dev.sub_device_no, int):
device_id = f"{dev.cid}{dev.sub_device_no!s}"
else:
device_id = dev.cid
identifier = next(iter(device_entry.identifiers), None)
if identifier and device_id == identifier[1]:
return False
return True
+1 -1
View File
@@ -99,7 +99,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
"message": "OAuth2 implementation temporarily unavailable, will retry"
},
"request_exception": {
"message": "Failed to connect to Xbox Network"
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast",
"iot_class": "local_push",
"loggers": ["aiomusiccast"],
"requirements": ["aiomusiccast==0.15.0"],
"requirements": ["aiomusiccast==0.14.8"],
"ssdp": [
{
"manufacturer": "Yamaha Corporation"
+1 -1
View File
@@ -134,7 +134,7 @@
"message": "Config entry not found or not loaded!"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
"message": "OAuth2 implementation temporarily unavailable, will retry"
},
"valve_inoperable_currently": {
"message": "The Valve cannot be operated currently."
+3
View File
@@ -2624,6 +2624,9 @@ class ServiceRegistry:
This method must be run in the event loop.
"""
from .helpers import config_validation as cv # noqa: PLC0415
cv.raise_on_templated_service(domain, service, schema)
domain = domain.lower()
service = service.lower()
service_obj = Service(
@@ -1423,6 +1423,48 @@ def _make_entity_service_schema(schema: dict, extra: int) -> VolSchemaType:
BASE_ENTITY_SCHEMA = _make_entity_service_schema({}, vol.PREVENT_EXTRA)
def raise_on_templated_service(
domain: str, _service: str, schema: VolDictType | VolSchemaType | None
) -> None:
"""Raise if service schema explicitly allows templates."""
return _raise_on_templated_service(domain, _service, schema, [])
def _raise_on_templated_service(
domain: str,
_service: str,
schema: Any,
_path: list[str | int],
) -> None:
"""Raise if service schema explicitly allows templates."""
if not schema:
return
if isinstance(schema, dict):
for key, val in schema.items():
_raise_on_templated_service(domain, _service, val, [*_path, key])
return
if isinstance(schema, list):
for pos, val in enumerate(schema):
_raise_on_templated_service(domain, _service, val, [*_path, pos])
return
if isinstance(schema, vol.All):
for pos, val in enumerate(schema.validators):
_raise_on_templated_service(domain, _service, val, [*_path, "All", pos])
if isinstance(schema, vol.Any):
for pos, val in enumerate(schema.validators):
_raise_on_templated_service(domain, _service, val, [*_path, "Any", pos])
if isinstance(schema, (vol.Schema)):
_raise_on_templated_service(domain, _service, schema.schema, _path)
if domain == "camera" and _service in ("record", "snapshot"):
return
if domain == "velbus" and _service == "set_memo_text":
return
if schema in (dynamic_template, template, template_complex):
raise ValueError(
f"Template in service data is not allowed! {domain}.{_service}:{_path}"
)
def make_entity_service_schema(
schema: dict | None, *, extra: int = vol.PREVENT_EXTRA
) -> VolSchemaType:
+1 -5
View File
@@ -1304,11 +1304,7 @@ def issues(hass: HomeAssistant) -> dict[tuple[str, str], dict[str, Any]]:
"""Return all open issues."""
current_issues = ir.async_get(hass).issues
# Use JSON for safe representation
return {
key: issue_entry.to_json()
for (key, issue_entry) in current_issues.items()
if issue_entry.active
}
return {k: v.to_json() for (k, v) in current_issues.items()}
def issue(hass: HomeAssistant, domain: str, issue_id: str) -> dict[str, Any] | None:
-5
View File
@@ -115,11 +115,6 @@
"turned_on": "{entity_name} turned on"
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
}
},
"generic": {
"model": "Model",
"ui_managed": "Managed via UI"
+2 -2
View File
@@ -321,7 +321,7 @@ aiomealie==1.1.0
aiomodernforms==0.1.8
# homeassistant.components.yamaha_musiccast
aiomusiccast==0.15.0
aiomusiccast==0.14.8
# homeassistant.components.nanoleaf
aionanoleaf==0.2.1
@@ -3076,7 +3076,7 @@ vegehub==0.1.26
vehicle==2.2.2
# homeassistant.components.velbus
velbus-aio==2025.11.0
velbus-aio==2025.8.0
# homeassistant.components.venstar
venstarcolortouch==0.21
+1 -1
View File
@@ -21,7 +21,7 @@ pydantic==2.12.2
pylint==4.0.1
pylint-per-file-ignores==1.4.0
pipdeptree==2.26.1
pytest-asyncio==1.3.0
pytest-asyncio==1.2.0
pytest-aiohttp==1.1.0
pytest-cov==7.0.0
pytest-freezer==0.4.9
+2 -2
View File
@@ -303,7 +303,7 @@ aiomealie==1.1.0
aiomodernforms==0.1.8
# homeassistant.components.yamaha_musiccast
aiomusiccast==0.15.0
aiomusiccast==0.14.8
# homeassistant.components.nanoleaf
aionanoleaf==0.2.1
@@ -2543,7 +2543,7 @@ vegehub==0.1.26
vehicle==2.2.2
# homeassistant.components.velbus
velbus-aio==2025.11.0
velbus-aio==2025.8.0
# homeassistant.components.venstar
venstarcolortouch==0.21
+2 -8
View File
@@ -174,7 +174,6 @@ def gen_data_entry_schema(
flow_title: int,
require_step_title: bool,
mandatory_description: str | None = None,
subentry_flow: bool = False,
) -> vol.All:
"""Generate a data entry schema."""
step_title_class = vol.Required if require_step_title else vol.Optional
@@ -207,13 +206,9 @@ def gen_data_entry_schema(
vol.Optional("abort"): {str: translation_value_validator},
vol.Optional("progress"): {str: translation_value_validator},
vol.Optional("create_entry"): {str: translation_value_validator},
vol.Optional("initiate_flow"): {str: translation_value_validator},
vol.Optional("entry_type"): translation_value_validator,
}
if subentry_flow:
schema[vol.Required("entry_type")] = translation_value_validator
schema[vol.Required("initiate_flow")] = {
vol.Required("user"): translation_value_validator,
str: translation_value_validator,
}
if flow_title == REQUIRED:
schema[vol.Required("title")] = translation_value_validator
elif flow_title == REMOVED:
@@ -319,7 +314,6 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
integration=integration,
flow_title=REMOVED,
require_step_title=False,
subentry_flow=True,
),
slug_validator=vol.Any("_", cv.slug),
),
@@ -3,6 +3,7 @@
from datetime import datetime
from unittest.mock import patch
from freezegun import freeze_time
import pytest
from homeassistant.components import (
@@ -452,7 +453,7 @@ async def test_todo_add_item_fr(
assert intent_obj.slots.get("item", {}).get("value", "").strip() == "farine"
@pytest.mark.freeze_time(
@freeze_time(
datetime(
year=2013,
month=9,
+6 -25
View File
@@ -144,7 +144,7 @@ async def test_custom_agent(
@pytest.mark.usefixtures("init_components")
async def test_reload(hass: HomeAssistant) -> None:
async def test_prepare_reload(hass: HomeAssistant) -> None:
"""Test calling the reload service."""
language = hass.config.language
agent = async_get_agent(hass)
@@ -154,39 +154,20 @@ async def test_reload(hass: HomeAssistant) -> None:
# Confirm intents are loaded
assert agent._lang_intents.get(language)
# Confirm config intents are empty
assert not agent._config_intents["intents"]
# Try to clear for a different language
await hass.services.async_call(
"conversation", "reload", {"language": "elvish"}, blocking=True
)
await hass.services.async_call("conversation", "reload", {"language": "elvish"})
await hass.async_block_till_done()
# Confirm intents are still loaded
assert agent._lang_intents.get(language)
# Confirm config intents are still empty
assert not agent._config_intents["intents"]
# Reload from a changed configuration file
hass_config_new = {
"conversation": {
"intents": {
"TestIntent": [
"Test intent phrase",
"Another test intent phrase",
]
}
}
}
with patch(
"homeassistant.config.load_yaml_config_file", return_value=hass_config_new
):
await hass.services.async_call("conversation", "reload", {}, blocking=True)
# Clear cache for all languages
await hass.services.async_call("conversation", "reload", {})
await hass.async_block_till_done()
# Confirm intent cache is cleared
assert not agent._lang_intents.get(language)
# Confirm new config intents are loaded
assert agent._config_intents["intents"]
@pytest.mark.usefixtures("init_components")
+43 -4
View File
@@ -4,14 +4,17 @@ from collections.abc import Callable
import time
from typing import Any
from aiohttp.test_utils import TestClient
from freezegun import freeze_time
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.auth.models import Credentials
from homeassistant.core import HomeAssistant
from .conftest import TEST_EVENT, ApiResult, ComponentSetup
from tests.common import MockConfigEntry
from tests.common import CLIENT_ID, MockConfigEntry, MockUser
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
@@ -26,13 +29,41 @@ def mock_test_setup(
mock_calendars_list({"items": [test_api_calendar]})
@pytest.mark.freeze_time("2023-03-13 12:05:00-07:00")
async def generate_new_hass_access_token(
hass: HomeAssistant, hass_admin_user: MockUser, hass_admin_credential: Credentials
) -> str:
"""Return an access token to access Home Assistant."""
await hass.auth.async_link_user(hass_admin_user, hass_admin_credential)
refresh_token = await hass.auth.async_create_refresh_token(
hass_admin_user, CLIENT_ID, credential=hass_admin_credential
)
return hass.auth.async_create_access_token(refresh_token)
def _get_test_client_generator(
hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, new_token: str
):
"""Return a test client generator.""."""
async def auth_client() -> TestClient:
return await aiohttp_client(
hass.http.app, headers={"Authorization": f"Bearer {new_token}"}
)
return auth_client
@freeze_time("2023-03-13 12:05:00-07:00")
@pytest.mark.usefixtures("socket_enabled")
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
component_setup: ComponentSetup,
mock_events_list_items: Callable[[list[dict[str, Any]]], None],
hass_admin_user: MockUser,
hass_admin_credential: Credentials,
config_entry: MockConfigEntry,
aiohttp_client: ClientSessionGenerator,
snapshot: SnapshotAssertion,
aioclient_mock: AiohttpClientMocker,
) -> None:
@@ -72,5 +103,13 @@ async def test_diagnostics(
assert await component_setup()
data = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
# Since we are freezing time only when we enter this test, we need to
# manually create a new token and clients since the token created by
# the fixtures would not be valid.
new_token = await generate_new_hass_access_token(
hass, hass_admin_user, hass_admin_credential
)
data = await get_diagnostics_for_config_entry(
hass, _get_test_client_generator(hass, aiohttp_client, new_token), config_entry
)
assert data == snapshot
@@ -3,6 +3,7 @@
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
from freezegun import freeze_time
from google.genai.types import File, FileState, GenerateContentResponse
import pytest
import voluptuous as vol
@@ -222,7 +223,7 @@ async def test_generate_data(
@pytest.mark.usefixtures("mock_init_component")
@pytest.mark.freeze_time("2025-06-14 22:59:00")
@freeze_time("2025-06-14 22:59:00")
async def test_generate_image(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
+2 -1
View File
@@ -3,6 +3,7 @@
from collections.abc import Generator
from unittest.mock import patch
from freezegun.api import freeze_time
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -32,7 +33,7 @@ async def set_tz(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("habitica")
@pytest.mark.freeze_time("2024-09-20T22:00:00.000Z")
@freeze_time("2024-09-20T22:00:00.000Z")
async def test_calendar_platform(
hass: HomeAssistant,
config_entry: MockConfigEntry,
+2 -2
View File
@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch
from uuid import UUID
from aiohttp import ClientError
from freezegun.api import FrozenDateTimeFactory
from freezegun.api import FrozenDateTimeFactory, freeze_time
from habiticalib import HabiticaGroupMembersResponse
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -82,7 +82,7 @@ async def test_notify_platform(
),
],
)
@pytest.mark.freeze_time("2025-08-13T00:00:00+00:00")
@freeze_time("2025-08-13T00:00:00+00:00")
async def test_send_message(
hass: HomeAssistant,
config_entry: MockConfigEntry,
+4 -3
View File
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch
from uuid import UUID
from aiohttp import ClientError
from freezegun.api import freeze_time
from habiticalib import (
Checklist,
Direction,
@@ -1844,7 +1845,7 @@ async def test_create_todo(
],
)
@pytest.mark.usefixtures("mock_uuid4")
@pytest.mark.freeze_time("2025-02-25T22:00:00.000Z")
@freeze_time("2025-02-25T22:00:00.000Z")
async def test_update_daily(
hass: HomeAssistant,
config_entry: MockConfigEntry,
@@ -2022,7 +2023,7 @@ async def test_update_daily(
],
)
@pytest.mark.usefixtures("mock_uuid4")
@pytest.mark.freeze_time("2025-02-25T22:00:00.000Z")
@freeze_time("2025-02-25T22:00:00.000Z")
async def test_create_daily(
hass: HomeAssistant,
config_entry: MockConfigEntry,
@@ -2063,7 +2064,7 @@ async def test_create_daily(
],
)
@pytest.mark.usefixtures("mock_uuid4")
@pytest.mark.freeze_time("2025-02-25T22:00:00.000Z")
@freeze_time("2025-02-25T22:00:00.000Z")
async def test_update_daily_service_validation_errors(
hass: HomeAssistant,
config_entry: MockConfigEntry,
+2 -1
View File
@@ -2,6 +2,7 @@
from unittest.mock import patch
from freezegun import freeze_time
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -14,7 +15,7 @@ from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.freeze_time("2021-01-01T12:00:00Z")
@freeze_time("2021-01-01T12:00:00Z")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_sensor_entities(
hass: HomeAssistant,
+2 -1
View File
@@ -2,6 +2,7 @@
from unittest.mock import AsyncMock, patch
from freezegun import freeze_time
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -14,7 +15,7 @@ from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.freeze_time("2021-01-01T12:00:00Z")
@freeze_time("2021-01-01T12:00:00Z")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_sensor_entities(
hass: HomeAssistant,
+2 -1
View File
@@ -2,6 +2,7 @@
from datetime import timedelta
from freezegun import freeze_time
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -347,7 +348,7 @@ async def test_expose_conversion_exception(
)
@pytest.mark.freeze_time("2022-1-7 9:13:14") # UTC -> +1h = Vienna in winter (9 -> 0xA)
@freeze_time("2022-1-7 9:13:14") # UTC -> +1h = Vienna in winter (9 -> 0xA)
@pytest.mark.parametrize(
("time_type", "raw"),
[
-1
View File
@@ -79,7 +79,6 @@ async def integration_fixture(
"aqara_door_window_p2",
"aqara_motion_p2",
"aqara_presence_fp300",
"aqara_sensor_w100",
"aqara_thermostat_w500",
"aqara_u200",
"battery_storage",
@@ -1,528 +0,0 @@
{
"node_id": 75,
"date_commissioned": "2025-06-07T15:30:15.263101",
"last_interview": "2025-06-07T15:30:15.263113",
"interview_version": 6,
"available": true,
"is_bridge": false,
"attributes": {
"0/29/0": [
{
"0": 18,
"1": 1
},
{
"0": 22,
"1": 3
}
],
"0/29/1": [29, 31, 40, 42, 48, 49, 51, 52, 53, 60, 62, 63, 70],
"0/29/2": [41],
"0/29/3": [1, 2, 3, 4, 5, 6],
"0/29/65532": 0,
"0/29/65533": 2,
"0/29/65528": [],
"0/29/65529": [],
"0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"0/31/0": [
{
"1": 5,
"2": 2,
"3": [112233],
"4": null,
"254": 4
}
],
"0/31/1": [],
"0/31/2": 4,
"0/31/3": 3,
"0/31/4": 4,
"0/31/65532": 0,
"0/31/65533": 1,
"0/31/65528": [],
"0/31/65529": [],
"0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
"0/40/0": 17,
"0/40/1": "Aqara",
"0/40/2": 4447,
"0/40/3": "Aqara Climate Sensor W100",
"0/40/4": 8196,
"0/40/5": "Climate Sensor W100",
"0/40/6": "**REDACTED**",
"0/40/7": 12,
"0/40/8": "0.0.1.2",
"0/40/9": 1010,
"0/40/10": "1.0.1.0",
"0/40/11": "20250108",
"0/40/12": "AA016",
"0/40/13": "https://www.aqara.com/en/products.html",
"0/40/14": "Aqara Climate Sensor W100",
"0/40/15": "***************",
"0/40/16": false,
"0/40/18": "***************",
"0/40/19": {
"0": 3,
"1": 3
},
"0/40/21": 16973824,
"0/40/22": 1,
"0/40/65532": 0,
"0/40/65533": 3,
"0/40/65528": [],
"0/40/65529": [],
"0/40/65531": [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22,
65528, 65529, 65531, 65532, 65533
],
"0/42/0": [],
"0/42/1": true,
"0/42/2": 1,
"0/42/3": null,
"0/42/65532": 0,
"0/42/65533": 1,
"0/42/65528": [],
"0/42/65529": [0],
"0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"0/48/0": 0,
"0/48/1": {
"0": 60,
"1": 900
},
"0/48/2": 0,
"0/48/3": 0,
"0/48/4": true,
"0/48/65532": 0,
"0/48/65533": 1,
"0/48/65528": [1, 3, 5],
"0/48/65529": [0, 2, 4],
"0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
"0/49/0": 1,
"0/49/1": [
{
"0": "aFq/aOcqMFo=",
"1": true
}
],
"0/49/2": 10,
"0/49/3": 20,
"0/49/4": true,
"0/49/5": 0,
"0/49/6": "aFq/aOcqMFo=",
"0/49/7": null,
"0/49/9": 4,
"0/49/10": 4,
"0/49/65532": 2,
"0/49/65533": 2,
"0/49/65528": [1, 5, 7],
"0/49/65529": [0, 3, 4, 6, 8],
"0/49/65531": [
0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 65528, 65529, 65531, 65532, 65533
],
"0/51/0": [
{
"0": "AqaraHome-0123",
"1": true,
"2": null,
"3": null,
"4": "piylcw37nWM=",
"5": [],
"6": [
"/RXRKakLAAFKcohVnCFKow==",
"/Z4/qUibGFsAAAD//gAcAg==",
"/Z4/qUibGFsYCaOd1Hp6Vg==",
"/oAAAAAAAACkLKVzDfudYw=="
],
"7": 4
}
],
"0/51/1": 1,
"0/51/2": 299,
"0/51/4": 6,
"0/51/5": [],
"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, 4, 5, 8, 65528, 65529, 65531, 65532, 65533],
"0/52/0": [
{
"0": 2,
"1": "sys_evt",
"3": 1952
},
{
"0": 11,
"1": "Bluetoot",
"3": 1438
},
{
"0": 3,
"1": "THREAD",
"3": 1651
},
{
"0": 1,
"1": "Bluetoot",
"3": 306
},
{
"0": 10,
"1": "Bluetoot",
"3": 107
},
{
"0": 7,
"1": "Tmr Svc",
"3": 943
},
{
"0": 8,
"1": "app",
"3": 748
},
{
"0": 6,
"1": "IDLE",
"3": 231
},
{
"0": 4,
"1": "CHIP",
"3": 305
}
],
"0/52/1": 46224,
"0/52/2": 35696,
"0/52/3": 56048,
"0/52/65532": 1,
"0/52/65533": 1,
"0/52/65528": [],
"0/52/65529": [0],
"0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"0/53/0": 11,
"0/53/1": 2,
"0/53/2": "AqaraHome-0123",
"0/53/3": 23343,
"0/53/4": 7519532985124270170,
"0/53/5": "QP2eP6lImxhb",
"0/53/6": 0,
"0/53/7": [
{
"0": 17151429082474872369,
"1": 284,
"2": 7168,
"3": 295817,
"4": 111774,
"5": 3,
"6": -74,
"7": -74,
"8": 37,
"9": 0,
"10": true,
"11": true,
"12": true,
"13": false
}
],
"0/53/8": [
{
"0": 17151429082474872369,
"1": 7168,
"2": 7,
"3": 0,
"4": 0,
"5": 3,
"6": 3,
"7": 28,
"8": true,
"9": true
}
],
"0/53/9": 405350277,
"0/53/22": 2799,
"0/53/23": 2797,
"0/53/24": 2,
"0/53/39": 503,
"0/53/40": 503,
"0/53/41": 0,
"0/53/65532": 15,
"0/53/65533": 2,
"0/53/65528": [],
"0/53/65529": [0],
"0/53/65531": [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 22, 23, 24, 39, 40, 41, 65528, 65529, 65531,
65532, 65533
],
"0/60/0": 0,
"0/60/1": null,
"0/60/2": null,
"0/60/65532": 1,
"0/60/65533": 1,
"0/60/65528": [],
"0/60/65529": [0, 1, 2],
"0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533],
"0/62/0": [
{
"1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRSxgkBwEkCAEwCUEEL5gmAVxeNTcndwbt1d1SNaICqrmw8Mk3fQ7CkQlM0XhpLv0XzjnnmI+jorFA31RvWDYa0URByx588JSq6G/d7DcKNQEoARgkAgE2AwQCBAEYMAQUPES5ZFkTssoDCAkEz+kBgkL3jMcwBRRT9HTfU5Nds+HA8j+/MRP+0pVyIxgwC0B5OoI+cs5wwGlxvfMdinguUmA+VEWBZjQP6rEvd929qf4zpgpkfyjX7LFYCvoqqKJCOW052dLhgfYGUOqCfo7AGA==",
"2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyT62Yt4qMI+MorlmQ/Hxh2CpLetznVknlAbhvYAwTexpSxp9GnhR09SrcUhz3mOb0eZa2TylqcnPBhHJ2Ih2RTcKNQEpARgkAmAwBBRT9HTfU5Nds+HA8j+/MRP+0pVyIzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQI/Kc38hQyK7AkT7/pN4hiYW3LoWKT3NA43+ssMJoVpDcaZ989GXBQKIbHKbBEXzUQ1J8wfL7l2pL0Z8Lso9JwgY",
"254": 4
}
],
"0/62/1": [
{
"1": "BIrruNo7r0gX6j6lq1dDi5zeK3jxcTavjt2o4adCCSCYtbxOakfb7C3GXqgV4LzulFSinbewmYkdqFBHqm5pxvU=",
"2": 4939,
"3": 2,
"4": 75,
"5": "",
"254": 4
}
],
"0/62/2": 5,
"0/62/3": 4,
"0/62/4": [
"FTABAQAkAgE3AyYUyakYCSYVj6gLsxgmBGoW1y8kBQA3BiYUyakYCSYVj6gLsxgkBwEkCAEwCUEEgYwxrTB+tyiEGfrRwjlXTG34MiQtJXbg5Qqd0ohdRW7MfwYY7vZiX/0h9hI8MqUralFaVPcnghAP0MSJm1YrqTcKNQEpARgkAmAwBBS3BS9aJzt+p6i28Nj+trB2Uu+vdzAFFLcFL1onO36nqLbw2P62sHZS7693GDALQMvassZTgvO/snCPohEojdKdGb2IpuRpSsu4HkM1JJQ9yFwhkyl0OOS2kvOVUNlfb2YnoJaH4L2jz0G9GVclBIgY",
"FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=",
"FTABD38O1NiPyscyxScZaN7uECQCATcDJhSoQfl2GCYEIqqfLyYFImy36zcGJhSoQfl2GCQHASQIATAJQQT5WrI2v6EgLRXdxlmZLlXX3rxeBe1C3NN/x9QV0tMVF+gH/FPSyq69dZKuoyskx0UOHcN20wdPffFuqgy/4uiaNwo1ASkBGCQCYDAEFM8XoLF/WKnSeqflSO5TQBQz4ObIMAUUzxegsX9YqdJ6p+VI7lNAFDPg5sgYMAtAHTWpsQPPwqR9gCqBGcDbPu2gusKeVuytcD5v7qK1/UjVr2/WGjMw3SYM10HWKdPTQZa2f3JI3uxv1nFnlcQpDBg=",
"FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEiuu42juvSBfqPqWrV0OLnN4rePFxNq+O3ajhp0IJIJi1vE5qR9vsLcZeqBXgvO6UVKKdt7CZiR2oUEeqbmnG9TcKNQEpARgkAmAwBBTjAjvCZO2QpJyarhRj7T8yYjarAzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQE7hTxTRg92QOxwA1hK3xv8DaxvxL71r6ZHcNRzug9wNnonJ+NC84SFKvKDxwcBxHYqFdIyDiDgwJNTQIBgasmIY"
],
"0/62/5": 4,
"0/62/65532": 0,
"0/62/65533": 1,
"0/62/65528": [1, 3, 5, 8],
"0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11],
"0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533],
"0/63/0": [],
"0/63/1": [],
"0/63/2": 4,
"0/63/3": 3,
"0/63/65532": 0,
"0/63/65533": 2,
"0/63/65528": [2, 5],
"0/63/65529": [0, 1, 3, 4],
"0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"0/70/0": 300,
"0/70/1": 0,
"0/70/2": 1000,
"0/70/65532": 0,
"0/70/65533": 2,
"0/70/65528": [],
"0/70/65529": [],
"0/70/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533],
"1/3/0": 0,
"1/3/1": 4,
"1/3/65532": 0,
"1/3/65533": 4,
"1/3/65528": [],
"1/3/65529": [0],
"1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
"1/29/0": [
{
"0": 770,
"1": 1
}
],
"1/29/1": [3, 29, 1026],
"1/29/2": [],
"1/29/3": [],
"1/29/65532": 0,
"1/29/65533": 2,
"1/29/65528": [],
"1/29/65529": [],
"1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"1/1026/0": 2773,
"1/1026/1": -4000,
"1/1026/2": 12500,
"1/1026/65532": 0,
"1/1026/65533": 4,
"1/1026/65528": [],
"1/1026/65529": [],
"1/1026/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533],
"2/3/0": 0,
"2/3/1": 4,
"2/3/65532": 0,
"2/3/65533": 4,
"2/3/65528": [],
"2/3/65529": [0],
"2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
"2/29/0": [
{
"0": 775,
"1": 1
}
],
"2/29/1": [3, 29, 1029],
"2/29/2": [],
"2/29/3": [],
"2/29/65532": 0,
"2/29/65533": 2,
"2/29/65528": [],
"2/29/65529": [],
"2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"2/1029/0": 4472,
"2/1029/1": 0,
"2/1029/2": 10000,
"2/1029/65532": 0,
"2/1029/65533": 3,
"2/1029/65528": [],
"2/1029/65529": [],
"2/1029/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533],
"3/3/0": 0,
"3/3/1": 4,
"3/3/65532": 0,
"3/3/65533": 4,
"3/3/65528": [],
"3/3/65529": [0],
"3/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
"3/29/0": [
{
"0": 15,
"1": 3
}
],
"3/29/1": [3, 29, 59],
"3/29/2": [],
"3/29/3": [],
"3/29/4": [
{
"0": null,
"1": 7,
"2": 1
},
{
"0": null,
"1": 8,
"2": 2
}
],
"3/29/65532": 1,
"3/29/65533": 2,
"3/29/65528": [],
"3/29/65529": [],
"3/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
"3/59/0": 2,
"3/59/1": 0,
"3/59/2": 2,
"3/59/65532": 30,
"3/59/65533": 1,
"3/59/65528": [],
"3/59/65529": [],
"3/59/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533],
"4/3/0": 0,
"4/3/1": 4,
"4/3/65532": 0,
"4/3/65533": 4,
"4/3/65528": [],
"4/3/65529": [0],
"4/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
"4/29/0": [
{
"0": 15,
"1": 3
}
],
"4/29/1": [3, 29, 59],
"4/29/2": [],
"4/29/3": [],
"4/29/4": [
{
"0": null,
"1": 7,
"2": 2
},
{
"0": null,
"1": 8,
"2": 4
}
],
"4/29/65532": 1,
"4/29/65533": 2,
"4/29/65528": [],
"4/29/65529": [],
"4/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
"4/59/0": 2,
"4/59/1": 0,
"4/59/2": 2,
"4/59/65532": 30,
"4/59/65533": 1,
"4/59/65528": [],
"4/59/65529": [],
"4/59/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533],
"5/3/0": 0,
"5/3/1": 4,
"5/3/65532": 0,
"5/3/65533": 4,
"5/3/65528": [],
"5/3/65529": [0],
"5/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
"5/29/0": [
{
"0": 15,
"1": 3
}
],
"5/29/1": [3, 29, 59],
"5/29/2": [],
"5/29/3": [],
"5/29/4": [
{
"0": null,
"1": 7,
"2": 3
},
{
"0": null,
"1": 8,
"2": 3
}
],
"5/29/65532": 1,
"5/29/65533": 2,
"5/29/65528": [],
"5/29/65529": [],
"5/29/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
"5/59/0": 2,
"5/59/1": 0,
"5/59/2": 2,
"5/59/65532": 30,
"5/59/65533": 1,
"5/59/65528": [],
"5/59/65529": [],
"5/59/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533],
"6/29/0": [
{
"0": 17,
"1": 1
}
],
"6/29/1": [29, 47],
"6/29/2": [],
"6/29/3": [],
"6/29/65532": 0,
"6/29/65533": 2,
"6/29/65528": [],
"6/29/65529": [],
"6/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
"6/47/0": 1,
"6/47/1": 0,
"6/47/2": "Battery",
"6/47/11": 3120,
"6/47/12": 200,
"6/47/14": 0,
"6/47/15": false,
"6/47/16": 2,
"6/47/19": "CR2450",
"6/47/25": 2,
"6/47/31": [],
"6/47/65532": 10,
"6/47/65533": 2,
"6/47/65528": [],
"6/47/65529": [],
"6/47/65531": [
0, 1, 2, 11, 12, 14, 15, 16, 19, 25, 31, 65528, 65529, 65531, 65532, 65533
]
},
"attribute_subscriptions": []
}
@@ -438,251 +438,6 @@
'state': 'unknown',
})
# ---
# name: test_buttons[aqara_sensor_w100][button.climate_sensor_w100_identify_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.climate_sensor_w100_identify_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <ButtonDeviceClass.IDENTIFY: 'identify'>,
'original_icon': None,
'original_name': 'Identify (1)',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-1-IdentifyButton-3-1',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[aqara_sensor_w100][button.climate_sensor_w100_identify_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'identify',
'friendly_name': 'Climate Sensor W100 Identify (1)',
}),
'context': <ANY>,
'entity_id': 'button.climate_sensor_w100_identify_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[aqara_sensor_w100][button.climate_sensor_w100_identify_2-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.climate_sensor_w100_identify_2',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <ButtonDeviceClass.IDENTIFY: 'identify'>,
'original_icon': None,
'original_name': 'Identify (2)',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-2-IdentifyButton-3-1',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[aqara_sensor_w100][button.climate_sensor_w100_identify_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'identify',
'friendly_name': 'Climate Sensor W100 Identify (2)',
}),
'context': <ANY>,
'entity_id': 'button.climate_sensor_w100_identify_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[aqara_sensor_w100][button.climate_sensor_w100_identify_3-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.climate_sensor_w100_identify_3',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <ButtonDeviceClass.IDENTIFY: 'identify'>,
'original_icon': None,
'original_name': 'Identify (3)',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-3-IdentifyButton-3-1',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[aqara_sensor_w100][button.climate_sensor_w100_identify_3-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'identify',
'friendly_name': 'Climate Sensor W100 Identify (3)',
}),
'context': <ANY>,
'entity_id': 'button.climate_sensor_w100_identify_3',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[aqara_sensor_w100][button.climate_sensor_w100_identify_4-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.climate_sensor_w100_identify_4',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <ButtonDeviceClass.IDENTIFY: 'identify'>,
'original_icon': None,
'original_name': 'Identify (4)',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-4-IdentifyButton-3-1',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[aqara_sensor_w100][button.climate_sensor_w100_identify_4-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'identify',
'friendly_name': 'Climate Sensor W100 Identify (4)',
}),
'context': <ANY>,
'entity_id': 'button.climate_sensor_w100_identify_4',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[aqara_sensor_w100][button.climate_sensor_w100_identify_5-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.climate_sensor_w100_identify_5',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <ButtonDeviceClass.IDENTIFY: 'identify'>,
'original_icon': None,
'original_name': 'Identify (5)',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-5-IdentifyButton-3-1',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[aqara_sensor_w100][button.climate_sensor_w100_identify_5-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'identify',
'friendly_name': 'Climate Sensor W100 Identify (5)',
}),
'context': <ANY>,
'entity_id': 'button.climate_sensor_w100_identify_5',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[aqara_thermostat_w500][button.floor_heating_thermostat_identify_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -1,193 +1,4 @@
# serializer version: 1
# name: test_events[aqara_sensor_w100][event.climate_sensor_w100_button_3-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'event_types': list([
'multi_press_1',
'multi_press_2',
'long_press',
'long_release',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': None,
'entity_id': 'event.climate_sensor_w100_button_3',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <EventDeviceClass.BUTTON: 'button'>,
'original_icon': None,
'original_name': 'Button (3)',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'button',
'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-3-GenericSwitch-59-1',
'unit_of_measurement': None,
})
# ---
# name: test_events[aqara_sensor_w100][event.climate_sensor_w100_button_3-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'button',
'event_type': None,
'event_types': list([
'multi_press_1',
'multi_press_2',
'long_press',
'long_release',
]),
'friendly_name': 'Climate Sensor W100 Button (3)',
}),
'context': <ANY>,
'entity_id': 'event.climate_sensor_w100_button_3',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_events[aqara_sensor_w100][event.climate_sensor_w100_button_4-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'event_types': list([
'multi_press_1',
'multi_press_2',
'long_press',
'long_release',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': None,
'entity_id': 'event.climate_sensor_w100_button_4',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <EventDeviceClass.BUTTON: 'button'>,
'original_icon': None,
'original_name': 'Button (4)',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'button',
'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-4-GenericSwitch-59-1',
'unit_of_measurement': None,
})
# ---
# name: test_events[aqara_sensor_w100][event.climate_sensor_w100_button_4-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'button',
'event_type': None,
'event_types': list([
'multi_press_1',
'multi_press_2',
'long_press',
'long_release',
]),
'friendly_name': 'Climate Sensor W100 Button (4)',
}),
'context': <ANY>,
'entity_id': 'event.climate_sensor_w100_button_4',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_events[aqara_sensor_w100][event.climate_sensor_w100_button_5-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'event_types': list([
'multi_press_1',
'multi_press_2',
'long_press',
'long_release',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': None,
'entity_id': 'event.climate_sensor_w100_button_5',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <EventDeviceClass.BUTTON: 'button'>,
'original_icon': None,
'original_name': 'Button (5)',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'button',
'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-5-GenericSwitch-59-1',
'unit_of_measurement': None,
})
# ---
# name: test_events[aqara_sensor_w100][event.climate_sensor_w100_button_5-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'button',
'event_type': None,
'event_types': list([
'multi_press_1',
'multi_press_2',
'long_press',
'long_release',
]),
'friendly_name': 'Climate Sensor W100 Button (5)',
}),
'context': <ANY>,
'entity_id': 'event.climate_sensor_w100_button_5',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_events[generic_switch][event.mock_generic_switch_button-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -1944,428 +1944,6 @@
'state': '27.94',
})
# ---
# name: test_sensors[aqara_sensor_w100][sensor.climate_sensor_w100_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.climate_sensor_w100_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-000000000000004B-MatterNodeDevice-6-PowerSource-47-12',
'unit_of_measurement': '%',
})
# ---
# name: test_sensors[aqara_sensor_w100][sensor.climate_sensor_w100_battery-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'Climate Sensor W100 Battery',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.climate_sensor_w100_battery',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '100',
})
# ---
# name: test_sensors[aqara_sensor_w100][sensor.climate_sensor_w100_battery_type-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'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.climate_sensor_w100_battery_type',
'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': 'Battery type',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'battery_replacement_description',
'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-6-PowerSourceBatReplacementDescription-47-19',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[aqara_sensor_w100][sensor.climate_sensor_w100_battery_type-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Climate Sensor W100 Battery type',
}),
'context': <ANY>,
'entity_id': 'sensor.climate_sensor_w100_battery_type',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'CR2450',
})
# ---
# name: test_sensors[aqara_sensor_w100][sensor.climate_sensor_w100_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.climate_sensor_w100_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-000000000000004B-MatterNodeDevice-6-PowerSourceBatVoltage-47-11',
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
})
# ---
# name: test_sensors[aqara_sensor_w100][sensor.climate_sensor_w100_battery_voltage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'voltage',
'friendly_name': 'Climate Sensor W100 Battery voltage',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
}),
'context': <ANY>,
'entity_id': 'sensor.climate_sensor_w100_battery_voltage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '3.12',
})
# ---
# name: test_sensors[aqara_sensor_w100][sensor.climate_sensor_w100_current_switch_position_3-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.climate_sensor_w100_current_switch_position_3',
'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': 'Current switch position (3)',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'switch_current_position',
'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-3-SwitchCurrentPosition-59-1',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[aqara_sensor_w100][sensor.climate_sensor_w100_current_switch_position_3-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Climate Sensor W100 Current switch position (3)',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.climate_sensor_w100_current_switch_position_3',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensors[aqara_sensor_w100][sensor.climate_sensor_w100_current_switch_position_4-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.climate_sensor_w100_current_switch_position_4',
'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': 'Current switch position (4)',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'switch_current_position',
'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-4-SwitchCurrentPosition-59-1',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[aqara_sensor_w100][sensor.climate_sensor_w100_current_switch_position_4-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Climate Sensor W100 Current switch position (4)',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.climate_sensor_w100_current_switch_position_4',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensors[aqara_sensor_w100][sensor.climate_sensor_w100_current_switch_position_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': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.climate_sensor_w100_current_switch_position_5',
'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': 'Current switch position (5)',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'switch_current_position',
'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-5-SwitchCurrentPosition-59-1',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[aqara_sensor_w100][sensor.climate_sensor_w100_current_switch_position_5-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Climate Sensor W100 Current switch position (5)',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.climate_sensor_w100_current_switch_position_5',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensors[aqara_sensor_w100][sensor.climate_sensor_w100_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.climate_sensor_w100_humidity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
'original_icon': None,
'original_name': 'Humidity',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-2-HumiditySensor-1029-0',
'unit_of_measurement': '%',
})
# ---
# name: test_sensors[aqara_sensor_w100][sensor.climate_sensor_w100_humidity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'humidity',
'friendly_name': 'Climate Sensor W100 Humidity',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.climate_sensor_w100_humidity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '44.72',
})
# ---
# name: test_sensors[aqara_sensor_w100][sensor.climate_sensor_w100_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.climate_sensor_w100_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': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-1-TemperatureSensor-1026-0',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[aqara_sensor_w100][sensor.climate_sensor_w100_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Climate Sensor W100 Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.climate_sensor_w100_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '27.73',
})
# ---
# name: test_sensors[aqara_thermostat_w500][sensor.floor_heating_thermostat_energy-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
+3 -28
View File
@@ -239,12 +239,11 @@ async def test_pump(
assert state
assert state.state == "off"
# Initial state: kRunning bit only (no fault bits) should be off
# PumpStatus --> DeviceFault bit
state = hass.states.get("binary_sensor.mock_pump_problem")
assert state
assert state.state == "off"
assert state.state == "unknown"
# Set DeviceFault bit
set_node_attribute(matter_node, 1, 512, 16, 1)
await trigger_subscription_callback(hass, matter_client)
@@ -252,14 +251,7 @@ async def test_pump(
assert state
assert state.state == "on"
# Clear all bits - problem sensor should be off
set_node_attribute(matter_node, 1, 512, 16, 0)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("binary_sensor.mock_pump_problem")
assert state
assert state.state == "off"
# Set SupplyFault bit
# PumpStatus --> SupplyFault bit
set_node_attribute(matter_node, 1, 512, 16, 2)
await trigger_subscription_callback(hass, matter_client)
@@ -278,7 +270,6 @@ async def test_dishwasher_alarm(
state = hass.states.get("binary_sensor.dishwasher_door_alarm")
assert state
# set DoorAlarm alarm
set_node_attribute(matter_node, 1, 93, 2, 4)
await trigger_subscription_callback(hass, matter_client)
@@ -286,22 +277,6 @@ async def test_dishwasher_alarm(
assert state
assert state.state == "on"
# clear DoorAlarm alarm
set_node_attribute(matter_node, 1, 93, 2, 0)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("binary_sensor.dishwasher_inflow_alarm")
assert state
assert state.state == "off"
# set InflowError alarm
set_node_attribute(matter_node, 1, 93, 2, 1)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("binary_sensor.dishwasher_inflow_alarm")
assert state
assert state.state == "on"
@pytest.mark.parametrize("node_fixture", ["valve"])
async def test_water_valve(
@@ -52,7 +52,7 @@
'event_types': list([
'Title: Hello',
]),
'expires': HAFakeDatetime(2025, 3, 29, 5, 58, 46, tzinfo=datetime.timezone.utc),
'expires': datetime.datetime(2025, 3, 29, 5, 58, 46, tzinfo=datetime.timezone.utc),
'friendly_name': 'mytopic',
'icon': 'https://example.com/icon.png',
'id': 'h6Y2hKA5sy0U',
@@ -61,7 +61,7 @@
'tags': list([
'octopus',
]),
'time': HAFakeDatetime(2025, 3, 28, 17, 58, 46, tzinfo=datetime.timezone.utc),
'time': datetime.datetime(2025, 3, 28, 17, 58, 46, tzinfo=datetime.timezone.utc),
'title': 'Title',
'topic': 'mytopic',
}),
+2 -2
View File
@@ -13,7 +13,7 @@ from aiontfy.exceptions import (
NtfyTimeoutError,
NtfyUnauthorizedAuthenticationError,
)
from freezegun.api import FrozenDateTimeFactory
from freezegun.api import FrozenDateTimeFactory, freeze_time
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -44,7 +44,7 @@ async def event_only() -> AsyncGenerator[None]:
@pytest.mark.usefixtures("mock_aiontfy")
@pytest.mark.freeze_time("2025-09-03T22:00:00.000Z")
@freeze_time("2025-09-03T22:00:00.000Z")
async def test_event_platform(
hass: HomeAssistant,
config_entry: MockConfigEntry,
+2 -1
View File
@@ -9,6 +9,7 @@ from aiontfy.exceptions import (
NtfyHTTPError,
NtfyUnauthorizedAuthenticationError,
)
from freezegun.api import freeze_time
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -56,7 +57,7 @@ async def test_notify_platform(
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
@pytest.mark.freeze_time("2025-01-09T12:00:00+00:00")
@freeze_time("2025-01-09T12:00:00+00:00")
async def test_send_message(
hass: HomeAssistant,
config_entry: MockConfigEntry,
-893
View File
@@ -1,893 +0,0 @@
"""Models for SQLAlchemy.
This file contains the model definitions for schema version 51.
It is used to test the schema migration logic.
"""
from __future__ import annotations
from collections.abc import Callable
from datetime import datetime, timedelta
import logging
import time
from typing import Any, Final, Protocol, Self
import ciso8601
from fnv_hash_fast import fnv1a_32
from sqlalchemy import (
CHAR,
JSON,
BigInteger,
Boolean,
ColumnElement,
DateTime,
Float,
ForeignKey,
Identity,
Index,
Integer,
LargeBinary,
SmallInteger,
String,
Text,
case,
type_coerce,
)
from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite
from sqlalchemy.engine.interfaces import Dialect
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import DeclarativeBase, Mapped, aliased, mapped_column, relationship
from sqlalchemy.types import TypeDecorator
from homeassistant.components.recorder.const import (
ALL_DOMAIN_EXCLUDE_ATTRS,
SupportedDialect,
)
from homeassistant.components.recorder.models import (
StatisticData,
StatisticDataTimestamp,
StatisticMeanType,
StatisticMetaData,
datetime_to_timestamp_or_none,
process_timestamp,
ulid_to_bytes_or_none,
uuid_hex_to_bytes_or_none,
)
from homeassistant.components.sensor import ATTR_STATE_CLASS
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_FRIENDLY_NAME,
ATTR_UNIT_OF_MEASUREMENT,
MATCH_ALL,
MAX_LENGTH_EVENT_EVENT_TYPE,
MAX_LENGTH_STATE_ENTITY_ID,
MAX_LENGTH_STATE_STATE,
)
from homeassistant.core import Event, EventStateChangedData
from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null
from homeassistant.util import dt as dt_util
# SQLAlchemy Schema
class Base(DeclarativeBase):
"""Base class for tables."""
class LegacyBase(DeclarativeBase):
"""Base class for tables, used for schema migration."""
SCHEMA_VERSION = 51
_LOGGER = logging.getLogger(__name__)
TABLE_EVENTS = "events"
TABLE_EVENT_DATA = "event_data"
TABLE_EVENT_TYPES = "event_types"
TABLE_STATES = "states"
TABLE_STATE_ATTRIBUTES = "state_attributes"
TABLE_STATES_META = "states_meta"
TABLE_RECORDER_RUNS = "recorder_runs"
TABLE_SCHEMA_CHANGES = "schema_changes"
TABLE_STATISTICS = "statistics"
TABLE_STATISTICS_META = "statistics_meta"
TABLE_STATISTICS_RUNS = "statistics_runs"
TABLE_STATISTICS_SHORT_TERM = "statistics_short_term"
TABLE_MIGRATION_CHANGES = "migration_changes"
STATISTICS_TABLES = ("statistics", "statistics_short_term")
MAX_STATE_ATTRS_BYTES = 16384
MAX_EVENT_DATA_BYTES = 32768
PSQL_DIALECT = SupportedDialect.POSTGRESQL
ALL_TABLES = [
TABLE_STATES,
TABLE_STATE_ATTRIBUTES,
TABLE_EVENTS,
TABLE_EVENT_DATA,
TABLE_EVENT_TYPES,
TABLE_RECORDER_RUNS,
TABLE_SCHEMA_CHANGES,
TABLE_MIGRATION_CHANGES,
TABLE_STATES_META,
TABLE_STATISTICS,
TABLE_STATISTICS_META,
TABLE_STATISTICS_RUNS,
TABLE_STATISTICS_SHORT_TERM,
]
TABLES_TO_CHECK = [
TABLE_STATES,
TABLE_EVENTS,
TABLE_RECORDER_RUNS,
TABLE_SCHEMA_CHANGES,
]
LAST_UPDATED_INDEX_TS = "ix_states_last_updated_ts"
METADATA_ID_LAST_UPDATED_INDEX_TS = "ix_states_metadata_id_last_updated_ts"
EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin"
STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin"
LEGACY_STATES_EVENT_ID_INDEX = "ix_states_event_id"
LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX = "ix_states_entity_id_last_updated_ts"
LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID: Final = 36
CONTEXT_ID_BIN_MAX_LENGTH = 16
MYSQL_COLLATE = "utf8mb4_unicode_ci"
MYSQL_DEFAULT_CHARSET = "utf8mb4"
MYSQL_ENGINE = "InnoDB"
_DEFAULT_TABLE_ARGS = {
"mysql_default_charset": MYSQL_DEFAULT_CHARSET,
"mysql_collate": MYSQL_COLLATE,
"mysql_engine": MYSQL_ENGINE,
"mariadb_default_charset": MYSQL_DEFAULT_CHARSET,
"mariadb_collate": MYSQL_COLLATE,
"mariadb_engine": MYSQL_ENGINE,
}
_MATCH_ALL_KEEP = {
ATTR_DEVICE_CLASS,
ATTR_STATE_CLASS,
ATTR_UNIT_OF_MEASUREMENT,
ATTR_FRIENDLY_NAME,
}
class UnusedDateTime(DateTime):
"""An unused column type that behaves like a datetime."""
class Unused(CHAR):
"""An unused column type that behaves like a string."""
@compiles(UnusedDateTime, "mysql", "mariadb", "sqlite")
@compiles(Unused, "mysql", "mariadb", "sqlite")
def compile_char_zero(type_: TypeDecorator, compiler: Any, **kw: Any) -> str:
"""Compile UnusedDateTime and Unused as CHAR(0) on mysql, mariadb, and sqlite."""
return "CHAR(0)" # Uses 1 byte on MySQL (no change on sqlite)
@compiles(Unused, "postgresql")
def compile_char_one(type_: TypeDecorator, compiler: Any, **kw: Any) -> str:
"""Compile Unused as CHAR(1) on postgresql."""
return "CHAR(1)" # Uses 1 byte
class FAST_PYSQLITE_DATETIME(sqlite.DATETIME):
"""Use ciso8601 to parse datetimes instead of sqlalchemy built-in regex."""
def result_processor(self, dialect: Dialect, coltype: Any) -> Callable | None:
"""Offload the datetime parsing to ciso8601."""
return lambda value: None if value is None else ciso8601.parse_datetime(value)
class NativeLargeBinary(LargeBinary):
"""A faster version of LargeBinary for engines that support python bytes natively."""
def result_processor(self, dialect: Dialect, coltype: Any) -> Callable | None:
"""No conversion needed for engines that support native bytes."""
return None
# Although all integers are same in SQLite, it does not allow an identity column to be BIGINT
# https://sqlite.org/forum/info/2dfa968a702e1506e885cb06d92157d492108b22bf39459506ab9f7125bca7fd
ID_TYPE = BigInteger().with_variant(sqlite.INTEGER, "sqlite")
# For MariaDB and MySQL we can use an unsigned integer type since it will fit 2**32
# for sqlite and postgresql we use a bigint
UINT_32_TYPE = BigInteger().with_variant(
mysql.INTEGER(unsigned=True), # type: ignore[no-untyped-call]
"mysql",
"mariadb",
)
JSON_VARIANT_CAST = Text().with_variant(
postgresql.JSON(none_as_null=True),
"postgresql",
)
JSONB_VARIANT_CAST = Text().with_variant(
postgresql.JSONB(none_as_null=True),
"postgresql",
)
DATETIME_TYPE = (
DateTime(timezone=True)
.with_variant(mysql.DATETIME(timezone=True, fsp=6), "mysql", "mariadb") # type: ignore[no-untyped-call]
.with_variant(FAST_PYSQLITE_DATETIME(), "sqlite") # type: ignore[no-untyped-call]
)
DOUBLE_TYPE = (
Float()
.with_variant(mysql.DOUBLE(asdecimal=False), "mysql", "mariadb") # type: ignore[no-untyped-call]
.with_variant(oracle.DOUBLE_PRECISION(), "oracle")
.with_variant(postgresql.DOUBLE_PRECISION(), "postgresql")
)
UNUSED_LEGACY_COLUMN = Unused(0)
UNUSED_LEGACY_DATETIME_COLUMN = UnusedDateTime(timezone=True)
UNUSED_LEGACY_INTEGER_COLUMN = SmallInteger()
DOUBLE_PRECISION_TYPE_SQL = "DOUBLE PRECISION"
BIG_INTEGER_SQL = "BIGINT"
CONTEXT_BINARY_TYPE = LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH).with_variant(
NativeLargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), "mysql", "mariadb", "sqlite"
)
TIMESTAMP_TYPE = DOUBLE_TYPE
class _LiteralProcessorType(Protocol):
def __call__(self, value: Any) -> str: ...
class JSONLiteral(JSON):
"""Teach SA how to literalize json."""
def literal_processor(self, dialect: Dialect) -> _LiteralProcessorType:
"""Processor to convert a value to JSON."""
def process(value: Any) -> str:
"""Dump json."""
return JSON_DUMP(value)
return process
class Events(Base):
"""Event history data."""
__table_args__ = (
# Used for fetching events at a specific time
# see logbook
Index(
"ix_events_event_type_id_time_fired_ts", "event_type_id", "time_fired_ts"
),
Index(
EVENTS_CONTEXT_ID_BIN_INDEX,
"context_id_bin",
mysql_length=CONTEXT_ID_BIN_MAX_LENGTH,
mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH,
),
_DEFAULT_TABLE_ARGS,
)
__tablename__ = TABLE_EVENTS
event_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True)
event_type: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN)
event_data: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN)
origin: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN)
origin_idx: Mapped[int | None] = mapped_column(SmallInteger)
time_fired: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN)
time_fired_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, index=True)
context_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN)
context_user_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN)
context_parent_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN)
data_id: Mapped[int | None] = mapped_column(
ID_TYPE, ForeignKey("event_data.data_id"), index=True
)
context_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE)
context_user_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE)
context_parent_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE)
event_type_id: Mapped[int | None] = mapped_column(
ID_TYPE, ForeignKey("event_types.event_type_id")
)
event_data_rel: Mapped[EventData | None] = relationship("EventData")
event_type_rel: Mapped[EventTypes | None] = relationship("EventTypes")
def __repr__(self) -> str:
"""Return string representation of instance for debugging."""
return (
"<recorder.Events("
f"id={self.event_id}, event_type_id='{self.event_type_id}', "
f"origin_idx='{self.origin_idx}', time_fired='{self._time_fired_isotime}'"
f", data_id={self.data_id})>"
)
@property
def _time_fired_isotime(self) -> str | None:
"""Return time_fired as an isotime string."""
date_time: datetime | None
if self.time_fired_ts is not None:
date_time = dt_util.utc_from_timestamp(self.time_fired_ts)
else:
date_time = process_timestamp(self.time_fired)
if date_time is None:
return None
return date_time.isoformat(sep=" ", timespec="seconds")
@staticmethod
def from_event(event: Event) -> Events:
"""Create an event database object from a native event."""
context = event.context
return Events(
event_type=None,
event_data=None,
origin_idx=event.origin.idx,
time_fired=None,
time_fired_ts=event.time_fired_timestamp,
context_id=None,
context_id_bin=ulid_to_bytes_or_none(context.id),
context_user_id=None,
context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id),
context_parent_id=None,
context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id),
)
class LegacyEvents(LegacyBase):
"""Event history data with event_id, used for schema migration."""
__table_args__ = (_DEFAULT_TABLE_ARGS,)
__tablename__ = TABLE_EVENTS
event_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True)
context_id: Mapped[str | None] = mapped_column(
String(LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID), index=True
)
class EventData(Base):
"""Event data history."""
__table_args__ = (_DEFAULT_TABLE_ARGS,)
__tablename__ = TABLE_EVENT_DATA
data_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True)
hash: Mapped[int | None] = mapped_column(UINT_32_TYPE, index=True)
# Note that this is not named attributes to avoid confusion with the states table
shared_data: Mapped[str | None] = mapped_column(
Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb")
)
def __repr__(self) -> str:
"""Return string representation of instance for debugging."""
return (
"<recorder.EventData("
f"id={self.data_id}, hash='{self.hash}', data='{self.shared_data}'"
")>"
)
@staticmethod
def shared_data_bytes_from_event(
event: Event, dialect: SupportedDialect | None
) -> bytes:
"""Create shared_data from an event."""
encoder = json_bytes_strip_null if dialect == PSQL_DIALECT else json_bytes
bytes_result = encoder(event.data)
if len(bytes_result) > MAX_EVENT_DATA_BYTES:
_LOGGER.warning(
"Event data for %s exceed maximum size of %s bytes. "
"This can cause database performance issues; Event data "
"will not be stored",
event.event_type,
MAX_EVENT_DATA_BYTES,
)
return b"{}"
return bytes_result
@staticmethod
def hash_shared_data_bytes(shared_data_bytes: bytes) -> int:
"""Return the hash of json encoded shared data."""
return fnv1a_32(shared_data_bytes)
class EventTypes(Base):
"""Event type history."""
__table_args__ = (_DEFAULT_TABLE_ARGS,)
__tablename__ = TABLE_EVENT_TYPES
event_type_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True)
event_type: Mapped[str | None] = mapped_column(
String(MAX_LENGTH_EVENT_EVENT_TYPE), index=True, unique=True
)
def __repr__(self) -> str:
"""Return string representation of instance for debugging."""
return (
"<recorder.EventTypes("
f"id={self.event_type_id}, event_type='{self.event_type}'"
")>"
)
class States(Base):
"""State change history."""
__table_args__ = (
# Used for fetching the state of entities at a specific time
# (get_states in history.py)
Index(METADATA_ID_LAST_UPDATED_INDEX_TS, "metadata_id", "last_updated_ts"),
Index(
STATES_CONTEXT_ID_BIN_INDEX,
"context_id_bin",
mysql_length=CONTEXT_ID_BIN_MAX_LENGTH,
mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH,
),
_DEFAULT_TABLE_ARGS,
)
__tablename__ = TABLE_STATES
state_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True)
entity_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN)
state: Mapped[str | None] = mapped_column(String(MAX_LENGTH_STATE_STATE))
attributes: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN)
event_id: Mapped[int | None] = mapped_column(UNUSED_LEGACY_INTEGER_COLUMN)
last_changed: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN)
last_changed_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE)
last_reported_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE)
last_updated: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN)
last_updated_ts: Mapped[float | None] = mapped_column(
TIMESTAMP_TYPE, default=time.time, index=True
)
old_state_id: Mapped[int | None] = mapped_column(
ID_TYPE, ForeignKey("states.state_id"), index=True
)
attributes_id: Mapped[int | None] = mapped_column(
ID_TYPE, ForeignKey("state_attributes.attributes_id"), index=True
)
context_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN)
context_user_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN)
context_parent_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN)
origin_idx: Mapped[int | None] = mapped_column(
SmallInteger
) # 0 is local, 1 is remote
old_state: Mapped[States | None] = relationship("States", remote_side=[state_id])
state_attributes: Mapped[StateAttributes | None] = relationship("StateAttributes")
context_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE)
context_user_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE)
context_parent_id_bin: Mapped[bytes | None] = mapped_column(CONTEXT_BINARY_TYPE)
metadata_id: Mapped[int | None] = mapped_column(
ID_TYPE, ForeignKey("states_meta.metadata_id")
)
states_meta_rel: Mapped[StatesMeta | None] = relationship("StatesMeta")
def __repr__(self) -> str:
"""Return string representation of instance for debugging."""
return (
f"<recorder.States(id={self.state_id}, entity_id='{self.entity_id}'"
f" metadata_id={self.metadata_id},"
f" state='{self.state}', event_id='{self.event_id}',"
f" last_updated='{self._last_updated_isotime}',"
f" old_state_id={self.old_state_id}, attributes_id={self.attributes_id})>"
)
@property
def _last_updated_isotime(self) -> str | None:
"""Return last_updated as an isotime string."""
date_time: datetime | None
if self.last_updated_ts is not None:
date_time = dt_util.utc_from_timestamp(self.last_updated_ts)
else:
date_time = process_timestamp(self.last_updated)
if date_time is None:
return None
return date_time.isoformat(sep=" ", timespec="seconds")
@staticmethod
def from_event(event: Event[EventStateChangedData]) -> States:
"""Create object from a state_changed event."""
state = event.data["new_state"]
# None state means the state was removed from the state machine
if state is None:
state_value = ""
last_updated_ts = event.time_fired_timestamp
last_changed_ts = None
last_reported_ts = None
else:
state_value = state.state
last_updated_ts = state.last_updated_timestamp
if state.last_updated == state.last_changed:
last_changed_ts = None
else:
last_changed_ts = state.last_changed_timestamp
if state.last_updated == state.last_reported:
last_reported_ts = None
else:
last_reported_ts = state.last_reported_timestamp
context = event.context
return States(
state=state_value,
entity_id=None,
attributes=None,
context_id=None,
context_id_bin=ulid_to_bytes_or_none(context.id),
context_user_id=None,
context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id),
context_parent_id=None,
context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id),
origin_idx=event.origin.idx,
last_updated=None,
last_changed=None,
last_updated_ts=last_updated_ts,
last_changed_ts=last_changed_ts,
last_reported_ts=last_reported_ts,
)
class LegacyStates(LegacyBase):
"""State change history with entity_id, used for schema migration."""
__table_args__ = (
Index(
LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX,
"entity_id",
"last_updated_ts",
),
_DEFAULT_TABLE_ARGS,
)
__tablename__ = TABLE_STATES
state_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True)
entity_id: Mapped[str | None] = mapped_column(UNUSED_LEGACY_COLUMN)
last_updated_ts: Mapped[float | None] = mapped_column(
TIMESTAMP_TYPE, default=time.time, index=True
)
context_id: Mapped[str | None] = mapped_column(
String(LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID), index=True
)
class StateAttributes(Base):
"""State attribute change history."""
__table_args__ = (_DEFAULT_TABLE_ARGS,)
__tablename__ = TABLE_STATE_ATTRIBUTES
attributes_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True)
hash: Mapped[int | None] = mapped_column(UINT_32_TYPE, index=True)
# Note that this is not named attributes to avoid confusion with the states table
shared_attrs: Mapped[str | None] = mapped_column(
Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb")
)
def __repr__(self) -> str:
"""Return string representation of instance for debugging."""
return (
f"<recorder.StateAttributes(id={self.attributes_id}, hash='{self.hash}',"
f" attributes='{self.shared_attrs}')>"
)
@staticmethod
def shared_attrs_bytes_from_event(
event: Event[EventStateChangedData],
dialect: SupportedDialect | None,
) -> bytes:
"""Create shared_attrs from a state_changed event."""
# None state means the state was removed from the state machine
if (state := event.data["new_state"]) is None:
return b"{}"
if state_info := state.state_info:
unrecorded_attributes = state_info["unrecorded_attributes"]
exclude_attrs = {
*ALL_DOMAIN_EXCLUDE_ATTRS,
*unrecorded_attributes,
}
if MATCH_ALL in unrecorded_attributes:
# Don't exclude device class, state class, unit of measurement
# or friendly name when using the MATCH_ALL exclude constant
exclude_attrs.update(state.attributes)
exclude_attrs -= _MATCH_ALL_KEEP
else:
exclude_attrs = ALL_DOMAIN_EXCLUDE_ATTRS
encoder = json_bytes_strip_null if dialect == PSQL_DIALECT else json_bytes
bytes_result = encoder(
{k: v for k, v in state.attributes.items() if k not in exclude_attrs}
)
if len(bytes_result) > MAX_STATE_ATTRS_BYTES:
_LOGGER.warning(
"State attributes for %s exceed maximum size of %s bytes. "
"This can cause database performance issues; Attributes "
"will not be stored",
state.entity_id,
MAX_STATE_ATTRS_BYTES,
)
return b"{}"
return bytes_result
@staticmethod
def hash_shared_attrs_bytes(shared_attrs_bytes: bytes) -> int:
"""Return the hash of json encoded shared attributes."""
return fnv1a_32(shared_attrs_bytes)
class StatesMeta(Base):
"""Metadata for states."""
__table_args__ = (_DEFAULT_TABLE_ARGS,)
__tablename__ = TABLE_STATES_META
metadata_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True)
entity_id: Mapped[str | None] = mapped_column(
String(MAX_LENGTH_STATE_ENTITY_ID), index=True, unique=True
)
def __repr__(self) -> str:
"""Return string representation of instance for debugging."""
return (
"<recorder.StatesMeta("
f"id={self.metadata_id}, entity_id='{self.entity_id}'"
")>"
)
class StatisticsBase:
"""Statistics base class."""
id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True)
created: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN)
created_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, default=time.time)
metadata_id: Mapped[int | None] = mapped_column(
ID_TYPE,
ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"),
)
start: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN)
start_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, index=True)
mean: Mapped[float | None] = mapped_column(DOUBLE_TYPE)
mean_weight: Mapped[float | None] = mapped_column(DOUBLE_TYPE)
min: Mapped[float | None] = mapped_column(DOUBLE_TYPE)
max: Mapped[float | None] = mapped_column(DOUBLE_TYPE)
last_reset: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN)
last_reset_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE)
state: Mapped[float | None] = mapped_column(DOUBLE_TYPE)
sum: Mapped[float | None] = mapped_column(DOUBLE_TYPE)
duration: timedelta
@classmethod
def from_stats(
cls, metadata_id: int, stats: StatisticData, now_timestamp: float | None = None
) -> Self:
"""Create object from a statistics with datetime objects."""
return cls( # type: ignore[call-arg]
metadata_id=metadata_id,
created=None,
created_ts=now_timestamp or time.time(),
start=None,
start_ts=stats["start"].timestamp(),
mean=stats.get("mean"),
mean_weight=stats.get("mean_weight"),
min=stats.get("min"),
max=stats.get("max"),
last_reset=None,
last_reset_ts=datetime_to_timestamp_or_none(stats.get("last_reset")),
state=stats.get("state"),
sum=stats.get("sum"),
)
@classmethod
def from_stats_ts(
cls,
metadata_id: int,
stats: StatisticDataTimestamp,
now_timestamp: float | None = None,
) -> Self:
"""Create object from a statistics with timestamps."""
return cls( # type: ignore[call-arg]
metadata_id=metadata_id,
created=None,
created_ts=now_timestamp or time.time(),
start=None,
start_ts=stats["start_ts"],
mean=stats.get("mean"),
mean_weight=stats.get("mean_weight"),
min=stats.get("min"),
max=stats.get("max"),
last_reset=None,
last_reset_ts=stats.get("last_reset_ts"),
state=stats.get("state"),
sum=stats.get("sum"),
)
class Statistics(Base, StatisticsBase):
"""Long term statistics."""
duration = timedelta(hours=1)
__table_args__ = (
# Used for fetching statistics for a certain entity at a specific time
Index(
"ix_statistics_statistic_id_start_ts",
"metadata_id",
"start_ts",
unique=True,
),
_DEFAULT_TABLE_ARGS,
)
__tablename__ = TABLE_STATISTICS
class _StatisticsShortTerm(StatisticsBase):
"""Short term statistics."""
duration = timedelta(minutes=5)
__tablename__ = TABLE_STATISTICS_SHORT_TERM
class StatisticsShortTerm(Base, _StatisticsShortTerm):
"""Short term statistics."""
__table_args__ = (
# Used for fetching statistics for a certain entity at a specific time
Index(
"ix_statistics_short_term_statistic_id_start_ts",
"metadata_id",
"start_ts",
unique=True,
),
_DEFAULT_TABLE_ARGS,
)
class LegacyStatisticsShortTerm(LegacyBase, _StatisticsShortTerm):
"""Short term statistics with 32-bit index, used for schema migration."""
__table_args__ = (
# Used for fetching statistics for a certain entity at a specific time
Index(
"ix_statistics_short_term_statistic_id_start_ts",
"metadata_id",
"start_ts",
unique=True,
),
_DEFAULT_TABLE_ARGS,
)
metadata_id: Mapped[int | None] = mapped_column(
Integer,
ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"),
use_existing_column=True,
)
class _StatisticsMeta:
"""Statistics meta data."""
__table_args__ = (_DEFAULT_TABLE_ARGS,)
__tablename__ = TABLE_STATISTICS_META
id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True)
statistic_id: Mapped[str | None] = mapped_column(
String(255), index=True, unique=True
)
source: Mapped[str | None] = mapped_column(String(32))
unit_of_measurement: Mapped[str | None] = mapped_column(String(255))
unit_class: Mapped[str | None] = mapped_column(String(255))
has_mean: Mapped[bool | None] = mapped_column(Boolean)
has_sum: Mapped[bool | None] = mapped_column(Boolean)
name: Mapped[str | None] = mapped_column(String(255))
mean_type: Mapped[StatisticMeanType] = mapped_column(
SmallInteger, nullable=False, default=StatisticMeanType.NONE.value
) # See StatisticMeanType
@staticmethod
def from_meta(meta: StatisticMetaData) -> StatisticsMeta:
"""Create object from meta data."""
return StatisticsMeta(**meta)
class StatisticsMeta(Base, _StatisticsMeta):
"""Statistics meta data."""
class LegacyStatisticsMeta(LegacyBase, _StatisticsMeta):
"""Statistics meta data with 32-bit index, used for schema migration."""
id: Mapped[int] = mapped_column(
Integer,
Identity(),
primary_key=True,
use_existing_column=True,
)
class RecorderRuns(Base):
"""Representation of recorder run."""
__table_args__ = (
Index("ix_recorder_runs_start_end", "start", "end"),
_DEFAULT_TABLE_ARGS,
)
__tablename__ = TABLE_RECORDER_RUNS
run_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True)
start: Mapped[datetime] = mapped_column(DATETIME_TYPE, default=dt_util.utcnow)
end: Mapped[datetime | None] = mapped_column(DATETIME_TYPE)
closed_incorrect: Mapped[bool] = mapped_column(Boolean, default=False)
created: Mapped[datetime] = mapped_column(DATETIME_TYPE, default=dt_util.utcnow)
def __repr__(self) -> str:
"""Return string representation of instance for debugging."""
end = (
f"'{self.end.isoformat(sep=' ', timespec='seconds')}'" if self.end else None
)
return (
f"<recorder.RecorderRuns(id={self.run_id},"
f" start='{self.start.isoformat(sep=' ', timespec='seconds')}', end={end},"
f" closed_incorrect={self.closed_incorrect},"
f" created='{self.created.isoformat(sep=' ', timespec='seconds')}')>"
)
class MigrationChanges(Base):
"""Representation of migration changes."""
__tablename__ = TABLE_MIGRATION_CHANGES
__table_args__ = (_DEFAULT_TABLE_ARGS,)
migration_id: Mapped[str] = mapped_column(String(255), primary_key=True)
version: Mapped[int] = mapped_column(SmallInteger)
class SchemaChanges(Base):
"""Representation of schema version changes."""
__tablename__ = TABLE_SCHEMA_CHANGES
__table_args__ = (_DEFAULT_TABLE_ARGS,)
change_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True)
schema_version: Mapped[int | None] = mapped_column(Integer)
changed: Mapped[datetime] = mapped_column(DATETIME_TYPE, default=dt_util.utcnow)
def __repr__(self) -> str:
"""Return string representation of instance for debugging."""
return (
"<recorder.SchemaChanges("
f"id={self.change_id}, schema_version={self.schema_version}, "
f"changed='{self.changed.isoformat(sep=' ', timespec='seconds')}'"
")>"
)
class StatisticsRuns(Base):
"""Representation of statistics run."""
__tablename__ = TABLE_STATISTICS_RUNS
__table_args__ = (_DEFAULT_TABLE_ARGS,)
run_id: Mapped[int] = mapped_column(ID_TYPE, Identity(), primary_key=True)
start: Mapped[datetime] = mapped_column(DATETIME_TYPE, index=True)
def __repr__(self) -> str:
"""Return string representation of instance for debugging."""
return (
f"<recorder.StatisticsRuns(id={self.run_id},"
f" start='{self.start.isoformat(sep=' ', timespec='seconds')}', )>"
)
EVENT_DATA_JSON = type_coerce(
EventData.shared_data.cast(JSONB_VARIANT_CAST), JSONLiteral(none_as_null=True)
)
OLD_FORMAT_EVENT_DATA_JSON = type_coerce(
Events.event_data.cast(JSONB_VARIANT_CAST), JSONLiteral(none_as_null=True)
)
SHARED_ATTRS_JSON = type_coerce(
StateAttributes.shared_attrs.cast(JSON_VARIANT_CAST), JSON(none_as_null=True)
)
OLD_FORMAT_ATTRS_JSON = type_coerce(
States.attributes.cast(JSON_VARIANT_CAST), JSON(none_as_null=True)
)
ENTITY_ID_IN_EVENT: ColumnElement = EVENT_DATA_JSON["entity_id"]
OLD_ENTITY_ID_IN_EVENT: ColumnElement = OLD_FORMAT_EVENT_DATA_JSON["entity_id"]
DEVICE_ID_IN_EVENT: ColumnElement = EVENT_DATA_JSON["device_id"]
OLD_STATE = aliased(States, name="old_state")
SHARED_ATTR_OR_LEGACY_ATTRIBUTES = case(
(StateAttributes.shared_attrs.is_(None), States.attributes),
else_=StateAttributes.shared_attrs,
).label("attributes")
SHARED_DATA_OR_LEGACY_EVENT_DATA = case(
(EventData.shared_data.is_(None), Events.event_data), else_=EventData.shared_data
).label("event_data")
@@ -1,4 +1,4 @@
"""Test for migration from DB schema version 50."""
"""The tests for the recorder filter matching the EntityFilter component."""
import importlib
import sys
@@ -134,26 +134,6 @@ async def test_migrate_statistics_meta(
name="Test 3",
mean_type=StatisticMeanType.NONE,
),
# Wrong case
old_db_schema.StatisticsMeta(
statistic_id="sensor.test4",
source="recorder",
unit_of_measurement="l/min",
has_mean=None,
has_sum=True,
name="Test 4",
mean_type=StatisticMeanType.NONE,
),
# Wrong encoding
old_db_schema.StatisticsMeta(
statistic_id="sensor.test5",
source="recorder",
unit_of_measurement="",
has_mean=None,
has_sum=True,
name="Test 5",
mean_type=StatisticMeanType.NONE,
),
)
)
@@ -271,28 +251,6 @@ async def test_migrate_statistics_meta(
"statistics_unit_of_measurement": "ppm",
"unit_class": "unitless",
},
{
"display_unit_of_measurement": "l/min",
"has_mean": False,
"has_sum": True,
"mean_type": StatisticMeanType.NONE,
"name": "Test 4",
"source": "recorder",
"statistic_id": "sensor.test4",
"statistics_unit_of_measurement": "l/min",
"unit_class": None,
},
{
"display_unit_of_measurement": "",
"has_mean": False,
"has_sum": True,
"mean_type": StatisticMeanType.NONE,
"name": "Test 5",
"source": "recorder",
"statistic_id": "sensor.test5",
"statistics_unit_of_measurement": "",
"unit_class": None,
},
]
)
assert post_migration_metadata_db == {
@@ -329,27 +287,5 @@ async def test_migrate_statistics_meta(
"unit_class": "unitless",
"unit_of_measurement": "ppm",
},
"sensor.test4": {
"has_mean": None,
"has_sum": True,
"id": 4,
"mean_type": 0,
"name": "Test 4",
"source": "recorder",
"statistic_id": "sensor.test4",
"unit_class": None,
"unit_of_measurement": "l/min",
},
"sensor.test5": {
"has_mean": None,
"has_sum": True,
"id": 5,
"mean_type": 0,
"name": "Test 5",
"source": "recorder",
"statistic_id": "sensor.test5",
"unit_class": None,
"unit_of_measurement": "",
},
}
assert post_migration_metadata_api == unordered(pre_migration_metadata_api)
@@ -1,456 +0,0 @@
"""Test for migration from DB schema version 51."""
import importlib
import sys
import threading
from unittest.mock import patch
import pytest
from pytest_unordered import unordered
from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import Session
from homeassistant.components import recorder
from homeassistant.components.recorder import core, migration, statistics
from homeassistant.components.recorder.const import UNIT_CLASS_SCHEMA_VERSION
from homeassistant.components.recorder.db_schema import StatisticsMeta
from homeassistant.components.recorder.models import StatisticMeanType
from homeassistant.components.recorder.util import session_scope
from homeassistant.core import HomeAssistant
from .common import (
async_recorder_block_till_done,
async_wait_recording_done,
get_patched_live_version,
)
from .conftest import instrument_migration
from tests.common import async_test_home_assistant
from tests.typing import RecorderInstanceContextManager
CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine"
SCHEMA_MODULE_51 = "tests.components.recorder.db_schema_51"
@pytest.fixture
async def mock_recorder_before_hass(
async_test_recorder: RecorderInstanceContextManager,
) -> None:
"""Set up recorder."""
async def _async_wait_migration_done(hass: HomeAssistant) -> None:
"""Wait for the migration to be done."""
await recorder.get_instance(hass).async_block_till_done()
await async_recorder_block_till_done(hass)
def _create_engine_test(*args, **kwargs):
"""Test version of create_engine that initializes with old schema.
This simulates an existing db with the old schema.
"""
importlib.import_module(SCHEMA_MODULE_51)
old_db_schema = sys.modules[SCHEMA_MODULE_51]
engine = create_engine(*args, **kwargs)
old_db_schema.Base.metadata.create_all(engine)
with Session(engine) as session:
session.add(
recorder.db_schema.StatisticsRuns(start=statistics.get_start_time())
)
session.add(
recorder.db_schema.SchemaChanges(
schema_version=old_db_schema.SCHEMA_VERSION
)
)
session.commit()
return engine
@pytest.fixture
def db_schema_51():
"""Fixture to initialize the db with the old schema."""
importlib.import_module(SCHEMA_MODULE_51)
old_db_schema = sys.modules[SCHEMA_MODULE_51]
with (
patch.object(recorder, "db_schema", old_db_schema),
patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION),
patch.object(
migration,
"LIVE_MIGRATION_MIN_SCHEMA_VERSION",
get_patched_live_version(old_db_schema),
),
patch.object(migration, "non_live_data_migration_needed", return_value=False),
patch.object(core, "StatesMeta", old_db_schema.StatesMeta),
patch.object(core, "EventTypes", old_db_schema.EventTypes),
patch.object(core, "EventData", old_db_schema.EventData),
patch.object(core, "States", old_db_schema.States),
patch.object(core, "Events", old_db_schema.Events),
patch.object(core, "StateAttributes", old_db_schema.StateAttributes),
patch(CREATE_ENGINE_TARGET, new=_create_engine_test),
):
yield
@pytest.mark.parametrize(
("persistent_database", "expected_unit_class"),
[
(
True,
{
# MariaDB/MySQL should correct unit class of sensor.test4 + sensor.test5
"mysql": {
"sensor.test1": "energy",
"sensor.test2": "power",
"sensor.test3": "unitless",
"sensor.test4": None,
"sensor.test5": None,
},
# PostgreSQL is not modified by the migration
"postgresql": {
"sensor.test1": "energy",
"sensor.test2": "power",
"sensor.test3": "unitless",
"sensor.test4": "volume_flow_rate",
"sensor.test5": "area",
},
# SQLite is not modified by the migration
"sqlite": {
"sensor.test1": "energy",
"sensor.test2": "power",
"sensor.test3": "unitless",
"sensor.test4": "volume_flow_rate",
"sensor.test5": "area",
},
},
),
],
)
@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage
async def test_migrate_statistics_meta(
async_test_recorder: RecorderInstanceContextManager,
caplog: pytest.LogCaptureFixture,
expected_unit_class: dict[str, dict[str, str | None]],
) -> None:
"""Test we can fix bad migration to version 51."""
importlib.import_module(SCHEMA_MODULE_51)
old_db_schema = sys.modules[SCHEMA_MODULE_51]
def _insert_metadata():
with session_scope(hass=hass) as session:
session.add_all(
(
old_db_schema.StatisticsMeta(
statistic_id="sensor.test1",
source="recorder",
unit_of_measurement="kWh",
has_mean=None,
has_sum=True,
name="Test 1",
mean_type=StatisticMeanType.NONE,
unit_class="energy",
),
# Unexpected, but will not be changed by migration
old_db_schema.StatisticsMeta(
statistic_id="sensor.test2",
source="recorder",
unit_of_measurement="cats",
has_mean=None,
has_sum=True,
name="Test 2",
mean_type=StatisticMeanType.NONE,
unit_class="power",
),
# This will be updated to "unitless" when migration runs again
old_db_schema.StatisticsMeta(
statistic_id="sensor.test3",
source="recorder",
unit_of_measurement="ppm",
has_mean=None,
has_sum=True,
name="Test 3",
mean_type=StatisticMeanType.NONE,
unit_class=None,
),
# Wrong case
old_db_schema.StatisticsMeta(
statistic_id="sensor.test4",
source="recorder",
unit_of_measurement="l/min",
has_mean=None,
has_sum=True,
name="Test 4",
mean_type=StatisticMeanType.NONE,
unit_class="volume_flow_rate",
),
# Wrong encoding
old_db_schema.StatisticsMeta(
statistic_id="sensor.test5",
source="recorder",
unit_of_measurement="",
has_mean=None,
has_sum=True,
name="Test 5",
mean_type=StatisticMeanType.NONE,
unit_class="area",
),
)
)
# Create database with old schema
with (
patch.object(recorder, "db_schema", old_db_schema),
patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION),
patch.object(
migration,
"LIVE_MIGRATION_MIN_SCHEMA_VERSION",
get_patched_live_version(old_db_schema),
),
patch.object(migration.EventsContextIDMigration, "migrate_data"),
patch(CREATE_ENGINE_TARGET, new=_create_engine_test),
):
async with (
async_test_home_assistant() as hass,
async_test_recorder(hass) as instance,
):
await instance.async_add_executor_job(_insert_metadata)
await async_wait_recording_done(hass)
await _async_wait_migration_done(hass)
await hass.async_stop()
await hass.async_block_till_done()
def _object_as_dict(obj):
return {c.key: getattr(obj, c.key) for c in inspect(obj).mapper.column_attrs}
def _fetch_metadata():
with session_scope(hass=hass) as session:
metadatas = session.query(StatisticsMeta).all()
return {
metadata.statistic_id: _object_as_dict(metadata)
for metadata in metadatas
}
# Run again with new schema, let migration run
async with async_test_home_assistant() as hass:
with (
instrument_migration(hass) as instrumented_migration,
):
# Stall migration when the last non-live schema migration is done
instrumented_migration.stall_on_schema_version = UNIT_CLASS_SCHEMA_VERSION
async with async_test_recorder(
hass, wait_recorder=False, wait_recorder_setup=False
) as instance:
engine_name = instance.engine.dialect.name
# Wait for migration to reach migration of unit class
await hass.async_add_executor_job(
instrumented_migration.apply_update_stalled.wait
)
# Check that it's possible to read metadata via the API, this will
# stop working when version 50 is migrated off line
pre_migration_metadata_api = await instance.async_add_executor_job(
statistics.list_statistic_ids,
hass,
None,
None,
)
instrumented_migration.migration_stall.set()
instance.recorder_and_worker_thread_ids.add(threading.get_ident())
await hass.async_block_till_done()
await async_wait_recording_done(hass)
await async_wait_recording_done(hass)
post_migration_metadata_db = await instance.async_add_executor_job(
_fetch_metadata
)
post_migration_metadata_api = await instance.async_add_executor_job(
statistics.list_statistic_ids,
hass,
None,
None,
)
await hass.async_stop()
await hass.async_block_till_done()
assert pre_migration_metadata_api == unordered(
[
{
"display_unit_of_measurement": "kWh",
"has_mean": False,
"has_sum": True,
"mean_type": StatisticMeanType.NONE,
"name": "Test 1",
"source": "recorder",
"statistic_id": "sensor.test1",
"statistics_unit_of_measurement": "kWh",
"unit_class": "energy",
},
{
"display_unit_of_measurement": "cats",
"has_mean": False,
"has_sum": True,
"mean_type": StatisticMeanType.NONE,
"name": "Test 2",
"source": "recorder",
"statistic_id": "sensor.test2",
"statistics_unit_of_measurement": "cats",
"unit_class": None,
},
{
"display_unit_of_measurement": "ppm",
"has_mean": False,
"has_sum": True,
"mean_type": StatisticMeanType.NONE,
"name": "Test 3",
"source": "recorder",
"statistic_id": "sensor.test3",
"statistics_unit_of_measurement": "ppm",
"unit_class": "unitless",
},
{
"display_unit_of_measurement": "l/min",
"has_mean": False,
"has_sum": True,
"mean_type": StatisticMeanType.NONE,
"name": "Test 4",
"source": "recorder",
"statistic_id": "sensor.test4",
"statistics_unit_of_measurement": "l/min",
"unit_class": None,
},
{
"display_unit_of_measurement": "",
"has_mean": False,
"has_sum": True,
"mean_type": StatisticMeanType.NONE,
"name": "Test 5",
"source": "recorder",
"statistic_id": "sensor.test5",
"statistics_unit_of_measurement": "",
"unit_class": None,
},
]
)
assert post_migration_metadata_db == {
"sensor.test1": {
"has_mean": None,
"has_sum": True,
"id": 1,
"mean_type": 0,
"name": "Test 1",
"source": "recorder",
"statistic_id": "sensor.test1",
"unit_class": expected_unit_class[engine_name]["sensor.test1"],
"unit_of_measurement": "kWh",
},
"sensor.test2": {
"has_mean": None,
"has_sum": True,
"id": 2,
"mean_type": 0,
"name": "Test 2",
"source": "recorder",
"statistic_id": "sensor.test2",
"unit_class": expected_unit_class[engine_name]["sensor.test2"],
"unit_of_measurement": "cats",
},
"sensor.test3": {
"has_mean": None,
"has_sum": True,
"id": 3,
"mean_type": 0,
"name": "Test 3",
"source": "recorder",
"statistic_id": "sensor.test3",
"unit_class": expected_unit_class[engine_name]["sensor.test3"],
"unit_of_measurement": "ppm",
},
"sensor.test4": {
"has_mean": None,
"has_sum": True,
"id": 4,
"mean_type": 0,
"name": "Test 4",
"source": "recorder",
"statistic_id": "sensor.test4",
"unit_class": expected_unit_class[engine_name]["sensor.test4"],
"unit_of_measurement": "l/min",
},
"sensor.test5": {
"has_mean": None,
"has_sum": True,
"id": 5,
"mean_type": 0,
"name": "Test 5",
"source": "recorder",
"statistic_id": "sensor.test5",
"unit_class": expected_unit_class[engine_name]["sensor.test5"],
"unit_of_measurement": "",
},
}
assert post_migration_metadata_api == unordered(
[
{
"display_unit_of_measurement": "kWh",
"has_mean": False,
"has_sum": True,
"mean_type": StatisticMeanType.NONE,
"name": "Test 1",
"source": "recorder",
"statistic_id": "sensor.test1",
"statistics_unit_of_measurement": "kWh",
"unit_class": expected_unit_class[engine_name]["sensor.test1"],
},
{
"display_unit_of_measurement": "cats",
"has_mean": False,
"has_sum": True,
"mean_type": StatisticMeanType.NONE,
"name": "Test 2",
"source": "recorder",
"statistic_id": "sensor.test2",
"statistics_unit_of_measurement": "cats",
"unit_class": expected_unit_class[engine_name]["sensor.test2"],
},
{
"display_unit_of_measurement": "ppm",
"has_mean": False,
"has_sum": True,
"mean_type": StatisticMeanType.NONE,
"name": "Test 3",
"source": "recorder",
"statistic_id": "sensor.test3",
"statistics_unit_of_measurement": "ppm",
"unit_class": expected_unit_class[engine_name]["sensor.test3"],
},
{
"display_unit_of_measurement": "l/min",
"has_mean": False,
"has_sum": True,
"mean_type": StatisticMeanType.NONE,
"name": "Test 4",
"source": "recorder",
"statistic_id": "sensor.test4",
"statistics_unit_of_measurement": "l/min",
"unit_class": expected_unit_class[engine_name]["sensor.test4"],
},
{
"display_unit_of_measurement": "",
"has_mean": False,
"has_sum": True,
"mean_type": StatisticMeanType.NONE,
"name": "Test 5",
"source": "recorder",
"statistic_id": "sensor.test5",
"statistics_unit_of_measurement": "",
"unit_class": expected_unit_class[engine_name]["sensor.test5"],
},
]
)
@@ -181,7 +181,7 @@
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'left_slot_intensity',
'translation_key': None,
'unique_id': '123456789ABC-cury:0-left_slot_intensity',
'unit_of_measurement': '%',
})
@@ -239,7 +239,7 @@
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'right_slot_intensity',
'translation_key': None,
'unique_id': '123456789ABC-cury:0-right_slot_intensity',
'unit_of_measurement': '%',
})
@@ -27,7 +27,7 @@
'name': None,
'options': dict({
}),
'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>,
'original_device_class': None,
'original_icon': None,
'original_name': 'External temperature',
'platform': 'shelly',
@@ -42,7 +42,6 @@
# name: test_blu_trv_number_entity[number.trv_name_external_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'TRV-Name External temperature',
'max': 50,
'min': -50,
@@ -151,7 +150,7 @@
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'left_slot_intensity',
'translation_key': None,
'unique_id': '123456789ABC-cury:0-left_slot_intensity',
'unit_of_measurement': '%',
})
@@ -209,7 +208,7 @@
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'right_slot_intensity',
'translation_key': None,
'unique_id': '123456789ABC-cury:0-right_slot_intensity',
'unit_of_measurement': '%',
})
@@ -2,22 +2,14 @@
from __future__ import annotations
from typing import Any
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.alarm_control_panel import (
DOMAIN as ALARM_DOMAIN,
SERVICE_ALARM_ARM_AWAY,
SERVICE_ALARM_ARM_HOME,
SERVICE_ALARM_DISARM,
SERVICE_ALARM_TRIGGER,
AlarmControlPanelState,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.components.alarm_control_panel import AlarmControlPanelState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -41,45 +33,6 @@ async def test_platform_setup_and_discovery(
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.ALARM_CONTROL_PANEL])
@pytest.mark.parametrize(
"mock_device_code",
["mal_gyitctrjj1kefxp2"],
)
@pytest.mark.parametrize(
("service", "command"),
[
(SERVICE_ALARM_ARM_AWAY, {"code": "master_mode", "value": "arm"}),
(SERVICE_ALARM_ARM_HOME, {"code": "master_mode", "value": "home"}),
(SERVICE_ALARM_DISARM, {"code": "master_mode", "value": "disarmed"}),
(SERVICE_ALARM_TRIGGER, {"code": "master_mode", "value": "sos"}),
],
)
async def test_service(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
service: str,
command: dict[str, Any],
) -> None:
"""Test service."""
entity_id = "alarm_control_panel.multifunction_alarm"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
await hass.services.async_call(
ALARM_DOMAIN,
service,
{
ATTR_ENTITY_ID: entity_id,
},
blocking=True,
)
mock_manager.send_commands.assert_called_once_with(mock_device.id, [command])
@pytest.mark.parametrize(
"mock_device_code",
["mal_gyitctrjj1kefxp2"],
+1 -67
View File
@@ -4,16 +4,10 @@ from __future__ import annotations
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.siren import (
DOMAIN as SIREN_DOMAIN,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -35,63 +29,3 @@ async def test_platform_setup_and_discovery(
await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SIREN])
@pytest.mark.parametrize(
"mock_device_code",
["sp_sdd5f5f2dl5wydjf"],
)
async def test_turn_on(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test turning on."""
entity_id = "siren.c9"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
await hass.services.async_call(
SIREN_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: entity_id,
},
blocking=True,
)
mock_manager.send_commands.assert_called_once_with(
mock_device.id, [{"code": "siren_switch", "value": True}]
)
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SIREN])
@pytest.mark.parametrize(
"mock_device_code",
["sp_sdd5f5f2dl5wydjf"],
)
async def test_turn_off(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test turning off."""
entity_id = "siren.c9"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
await hass.services.async_call(
SIREN_DOMAIN,
SERVICE_TURN_OFF,
{
ATTR_ENTITY_ID: entity_id,
},
blocking=True,
)
mock_manager.send_commands.assert_called_once_with(
mock_device.id, [{"code": "siren_switch", "value": False}]
)
+2 -54
View File
@@ -5,16 +5,12 @@ from unittest.mock import AsyncMock, patch
from pyvesync import VeSync
from pyvesync.utils.errors import VeSyncLoginError
from homeassistant.components.vesync import (
SERVICE_UPDATE_DEVS,
async_remove_config_entry_device,
async_setup_entry,
)
from homeassistant.components.vesync import SERVICE_UPDATE_DEVS, async_setup_entry
from homeassistant.components.vesync.const import DOMAIN, VS_MANAGER
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
@@ -169,51 +165,3 @@ async def test_migrate_config_entry(
e for e in entity_registry.entities.values() if e.domain == "humidifer"
]
assert len(humidifer_entities) == 1
async def test_async_remove_config_entry_device_positive(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
config_entry: ConfigEntry,
manager: VeSync,
fan,
) -> None:
"""Test removing a config entry from a device when no match is found."""
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
manager._dev_list["fans"].append(fan)
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, "test_device")},
)
result = await async_remove_config_entry_device(hass, config_entry, device_entry)
assert result is True
async def test_async_remove_config_entry_device_negative(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
config_entry: ConfigEntry,
manager: VeSync,
fan,
) -> None:
"""Test removing a config entry from a device when a match is found."""
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
manager._dev_list["fans"].append(fan)
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, "fan")},
)
# Call the remove method
result = await async_remove_config_entry_device(hass, config_entry, device_entry)
# Assert it returns False (device matched)
assert result is False
-49
View File
@@ -2878,55 +2878,6 @@ async def test_issues(hass: HomeAssistant, issue_registry: ir.IssueRegistry) ->
assert_result_info(info, {})
assert info.rate_limit is None
issue = ir.IssueEntry(
active=False,
breaks_in_ha_version="2025.12",
created=dt_util.utcnow(),
data=None,
dismissed_version=None,
domain="test",
is_fixable=False,
is_persistent=False,
issue_domain="test",
issue_id="issue 2",
learn_more_url=None,
severity="warning",
translation_key="abc_1234",
translation_placeholders={"abc": "123"},
)
# Add non active issue
issue_registry.issues[("test", "issue 2")] = issue
# Test non active issue is omitted
issue_entry = issue_registry.async_get_issue("test", "issue 2")
assert issue_entry
issue_2_created = issue_entry.created
assert issue_entry and not issue_entry.active
info = render_to_info(hass, "{{ issues() }}")
assert_result_info(info, {})
assert info.rate_limit is None
# Load and activate the issue
ir.async_create_issue(
hass=hass,
breaks_in_ha_version="2025.12",
data=None,
domain="test",
is_fixable=False,
is_persistent=False,
issue_domain="test",
issue_id="issue 2",
learn_more_url=None,
severity="warning",
translation_key="abc_1234",
translation_placeholders={"abc": "123"},
)
activated_issue_entry = issue_registry.async_get_issue("test", "issue 2")
assert activated_issue_entry and activated_issue_entry.active
assert issue_2_created == activated_issue_entry.created
info = render_to_info(hass, "{{ issues()['test', 'issue 2'] }}")
assert_result_info(info, activated_issue_entry.to_json())
assert info.rate_limit is None
async def test_issue(hass: HomeAssistant, issue_registry: ir.IssueRegistry) -> None:
"""Test issue function."""