mirror of
https://github.com/home-assistant/core.git
synced 2026-01-11 18:17:17 +01:00
Compare commits
42 Commits
tibber_bin
...
power-sens
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f4ffd6f8a | ||
|
|
294c93e3ed | ||
|
|
51faa35f1b | ||
|
|
303a4091a7 | ||
|
|
fc9a86b919 | ||
|
|
2be7b57e48 | ||
|
|
27ecfd1319 | ||
|
|
ade50c93cf | ||
|
|
b029a48ed4 | ||
|
|
b05a6dadf6 | ||
|
|
6e32a2aa18 | ||
|
|
3b575fe3e3 | ||
|
|
229400de98 | ||
|
|
e963adfdf0 | ||
|
|
fd7bbc68c6 | ||
|
|
9281ab018c | ||
|
|
80baf86e23 | ||
|
|
db497b23fe | ||
|
|
a2fb8f5a72 | ||
|
|
6953bd4599 | ||
|
|
225be65f71 | ||
|
|
7b0463f763 | ||
|
|
4d305b657a | ||
|
|
d5a553c8c7 | ||
|
|
9169b68254 | ||
|
|
fde9bd95d5 | ||
|
|
e4db8ff86e | ||
|
|
a084e51345 | ||
|
|
00381e6dfd | ||
|
|
b6d493696a | ||
|
|
5f0500c3cd | ||
|
|
c61a63cc6f | ||
|
|
5445a4f40f | ||
|
|
2888cacc3f | ||
|
|
16f3e6d2c9 | ||
|
|
7a872970fa | ||
|
|
4f5ca986ce | ||
|
|
b58e058da5 | ||
|
|
badebe0c7f | ||
|
|
7817ec1a52 | ||
|
|
c773998946 | ||
|
|
2bc9397103 |
1
CODEOWNERS
generated
1
CODEOWNERS
generated
@@ -1803,6 +1803,7 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/waqi/ @joostlek
|
||||
/homeassistant/components/water_heater/ @home-assistant/core
|
||||
/tests/components/water_heater/ @home-assistant/core
|
||||
/homeassistant/components/waterfurnace/ @sdague @masterkoppa
|
||||
/homeassistant/components/watergate/ @adam-the-hero
|
||||
/tests/components/watergate/ @adam-the-hero
|
||||
/homeassistant/components/watson_tts/ @rutkai
|
||||
|
||||
@@ -7,7 +7,7 @@ import asyncio
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, Protocol, cast
|
||||
from typing import Any, Literal, Protocol, cast
|
||||
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
@@ -16,7 +16,10 @@ from homeassistant.components import labs, websocket_api
|
||||
from homeassistant.components.blueprint import CONF_USE_BLUEPRINT
|
||||
from homeassistant.components.labs import async_listen as async_labs_listen
|
||||
from homeassistant.const import (
|
||||
ATTR_AREA_ID,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_FLOOR_ID,
|
||||
ATTR_LABEL_ID,
|
||||
ATTR_MODE,
|
||||
ATTR_NAME,
|
||||
CONF_ACTIONS,
|
||||
@@ -30,6 +33,7 @@ from homeassistant.const import (
|
||||
CONF_OPTIONS,
|
||||
CONF_PATH,
|
||||
CONF_PLATFORM,
|
||||
CONF_TARGET,
|
||||
CONF_TRIGGERS,
|
||||
CONF_VARIABLES,
|
||||
CONF_ZONE,
|
||||
@@ -589,20 +593,32 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
"""Return True if entity is on."""
|
||||
return self._async_detach_triggers is not None or self._is_enabled
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def referenced_labels(self) -> set[str]:
|
||||
"""Return a set of referenced labels."""
|
||||
return self.action_script.referenced_labels
|
||||
referenced = self.action_script.referenced_labels
|
||||
|
||||
@property
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_LABEL_ID))
|
||||
return referenced
|
||||
|
||||
@cached_property
|
||||
def referenced_floors(self) -> set[str]:
|
||||
"""Return a set of referenced floors."""
|
||||
return self.action_script.referenced_floors
|
||||
referenced = self.action_script.referenced_floors
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_FLOOR_ID))
|
||||
return referenced
|
||||
|
||||
@cached_property
|
||||
def referenced_areas(self) -> set[str]:
|
||||
"""Return a set of referenced areas."""
|
||||
return self.action_script.referenced_areas
|
||||
referenced = self.action_script.referenced_areas
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_AREA_ID))
|
||||
return referenced
|
||||
|
||||
@property
|
||||
def referenced_blueprint(self) -> str | None:
|
||||
@@ -1210,6 +1226,9 @@ def _trigger_extract_devices(trigger_conf: dict) -> list[str]:
|
||||
if trigger_conf[CONF_PLATFORM] == "tag" and CONF_DEVICE_ID in trigger_conf:
|
||||
return trigger_conf[CONF_DEVICE_ID] # type: ignore[no-any-return]
|
||||
|
||||
if target_devices := _get_targets_from_trigger_config(trigger_conf, CONF_DEVICE_ID):
|
||||
return target_devices
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@@ -1240,9 +1259,28 @@ def _trigger_extract_entities(trigger_conf: dict) -> list[str]:
|
||||
):
|
||||
return [trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID]]
|
||||
|
||||
if target_entities := _get_targets_from_trigger_config(
|
||||
trigger_conf, CONF_ENTITY_ID
|
||||
):
|
||||
return target_entities
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@callback
|
||||
def _get_targets_from_trigger_config(
|
||||
config: dict,
|
||||
target: Literal["entity_id", "device_id", "area_id", "floor_id", "label_id"],
|
||||
) -> list[str]:
|
||||
"""Extract targets from a target config."""
|
||||
if not (target_conf := config.get(CONF_TARGET)):
|
||||
return []
|
||||
if not (targets := target_conf.get(target)):
|
||||
return []
|
||||
|
||||
return [targets] if isinstance(targets, str) else targets
|
||||
|
||||
|
||||
@websocket_api.websocket_command({"type": "automation/config", "entity_id": str})
|
||||
def websocket_config(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.const import CONF_HOST, CONF_MAC, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import CONF_USE_SSL
|
||||
from .coordinator import BraviaTVConfigEntry, BraviaTVCoordinator
|
||||
|
||||
PLATFORMS: Final[list[Platform]] = [
|
||||
@@ -26,11 +27,12 @@ async def async_setup_entry(
|
||||
"""Set up a config entry."""
|
||||
host = config_entry.data[CONF_HOST]
|
||||
mac = config_entry.data[CONF_MAC]
|
||||
ssl = config_entry.data.get(CONF_USE_SSL, False)
|
||||
|
||||
session = async_create_clientsession(
|
||||
hass, cookie_jar=CookieJar(unsafe=True, quote_cookie=False)
|
||||
)
|
||||
client = BraviaClient(host, mac, session=session)
|
||||
client = BraviaClient(host, mac, session=session, ssl=ssl)
|
||||
coordinator = BraviaTVCoordinator(
|
||||
hass=hass,
|
||||
config_entry=config_entry,
|
||||
|
||||
@@ -28,6 +28,7 @@ from .const import (
|
||||
ATTR_MODEL,
|
||||
CONF_NICKNAME,
|
||||
CONF_USE_PSK,
|
||||
CONF_USE_SSL,
|
||||
DOMAIN,
|
||||
NICKNAME_PREFIX,
|
||||
)
|
||||
@@ -46,11 +47,12 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
def create_client(self) -> None:
|
||||
"""Create Bravia TV client from config."""
|
||||
host = self.device_config[CONF_HOST]
|
||||
ssl = self.device_config[CONF_USE_SSL]
|
||||
session = async_create_clientsession(
|
||||
self.hass,
|
||||
cookie_jar=CookieJar(unsafe=True, quote_cookie=False),
|
||||
)
|
||||
self.client = BraviaClient(host=host, session=session)
|
||||
self.client = BraviaClient(host=host, session=session, ssl=ssl)
|
||||
|
||||
async def gen_instance_ids(self) -> tuple[str, str]:
|
||||
"""Generate client_id and nickname."""
|
||||
@@ -123,10 +125,10 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle authorize step."""
|
||||
self.create_client()
|
||||
|
||||
if user_input is not None:
|
||||
self.device_config[CONF_USE_PSK] = user_input[CONF_USE_PSK]
|
||||
self.device_config[CONF_USE_SSL] = user_input[CONF_USE_SSL]
|
||||
self.create_client()
|
||||
if user_input[CONF_USE_PSK]:
|
||||
return await self.async_step_psk()
|
||||
return await self.async_step_pin()
|
||||
@@ -136,6 +138,7 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USE_PSK, default=False): bool,
|
||||
vol.Required(CONF_USE_SSL, default=False): bool,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ ATTR_MODEL: Final = "model"
|
||||
|
||||
CONF_NICKNAME: Final = "nickname"
|
||||
CONF_USE_PSK: Final = "use_psk"
|
||||
CONF_USE_SSL: Final = "use_ssl"
|
||||
|
||||
DOMAIN: Final = "braviatv"
|
||||
LEGACY_CLIENT_ID: Final = "HomeAssistant"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pybravia"],
|
||||
"requirements": ["pybravia==0.3.4"],
|
||||
"requirements": ["pybravia==0.4.1"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Sony Corporation",
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
"step": {
|
||||
"authorize": {
|
||||
"data": {
|
||||
"use_psk": "Use PSK authentication"
|
||||
"use_psk": "Use PSK authentication",
|
||||
"use_ssl": "Use SSL connection"
|
||||
},
|
||||
"description": "Make sure that «Control remotely» is enabled on your TV, go to: \nSettings -> Network -> Remote device settings -> Control remotely. \n\nThere are two authorization methods: PIN code or PSK (Pre-Shared Key). \nAuthorization via PSK is recommended as more stable.",
|
||||
"description": "Make sure that «Control remotely» is enabled on your TV. Go to: \nSettings -> Network -> Remote device settings -> Control remotely. \n\nThere are two authorization methods: PIN code or PSK (Pre-Shared Key). \nAuthorization via PSK is recommended, as it is more stable. \n\nUse an SSL connection only if your TV supports this connection type.",
|
||||
"title": "Authorize Sony Bravia TV"
|
||||
},
|
||||
"confirm": {
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.1"]
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.6"]
|
||||
}
|
||||
|
||||
@@ -59,13 +59,38 @@ class FlowToGridSourceType(TypedDict):
|
||||
number_energy_price: float | None # Price for energy ($/kWh)
|
||||
|
||||
|
||||
class GridPowerSourceType(TypedDict):
|
||||
class PowerConfig(TypedDict, total=False):
|
||||
"""Dictionary holding power sensor configuration options.
|
||||
|
||||
Users can configure power sensors in three ways:
|
||||
1. Standard: single sensor (positive=discharge/from_grid, negative=charge/to_grid)
|
||||
2. Inverted: single sensor with opposite polarity (needs to be multiplied by -1)
|
||||
3. Two sensors: separate positive sensors for each direction
|
||||
"""
|
||||
|
||||
# Standard: single sensor (positive=discharge/from_grid, negative=charge/to_grid)
|
||||
stat_rate: str
|
||||
|
||||
# Inverted: single sensor with opposite polarity (needs to be multiplied by -1)
|
||||
stat_rate_inverted: str
|
||||
|
||||
# Two sensors: separate positive sensors for each direction
|
||||
# Result = stat_rate_from - stat_rate_to (positive when net outflow)
|
||||
stat_rate_from: str # Battery: discharge, Grid: consumption
|
||||
stat_rate_to: str # Battery: charge, Grid: return
|
||||
|
||||
|
||||
class GridPowerSourceType(TypedDict, total=False):
|
||||
"""Dictionary holding the source of grid power consumption."""
|
||||
|
||||
# statistic_id of a power meter (kW)
|
||||
# negative values indicate grid return
|
||||
# This is either the original sensor or a generated template sensor
|
||||
stat_rate: str
|
||||
|
||||
# User's original power sensor configuration
|
||||
power_config: PowerConfig
|
||||
|
||||
|
||||
class GridSourceType(TypedDict):
|
||||
"""Dictionary holding the source of grid energy consumption."""
|
||||
@@ -97,8 +122,12 @@ class BatterySourceType(TypedDict):
|
||||
stat_energy_from: str
|
||||
stat_energy_to: str
|
||||
# positive when discharging, negative when charging
|
||||
# This is either the original sensor or a generated template sensor
|
||||
stat_rate: NotRequired[str]
|
||||
|
||||
# User's original power sensor configuration
|
||||
power_config: NotRequired[PowerConfig]
|
||||
|
||||
|
||||
class GasSourceType(TypedDict):
|
||||
"""Dictionary holding the source of gas consumption."""
|
||||
@@ -211,10 +240,47 @@ FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
GRID_POWER_SOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("stat_rate"): str,
|
||||
}
|
||||
|
||||
def _validate_power_config(val: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate power_config has at least one option."""
|
||||
if not val:
|
||||
raise vol.Invalid("power_config must have at least one option")
|
||||
return val
|
||||
|
||||
|
||||
POWER_CONFIG_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Exclusive("stat_rate", "power_source"): str,
|
||||
vol.Exclusive("stat_rate_inverted", "power_source"): str,
|
||||
# stat_rate_from/stat_rate_to: two sensors for bidirectional power
|
||||
# Battery: from=discharge (out), to=charge (in)
|
||||
# Grid: from=consumption, to=return
|
||||
vol.Inclusive("stat_rate_from", "two_sensors"): str,
|
||||
vol.Inclusive("stat_rate_to", "two_sensors"): str,
|
||||
}
|
||||
),
|
||||
_validate_power_config,
|
||||
)
|
||||
|
||||
|
||||
def _validate_grid_power_source(val: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate grid power source has either stat_rate or power_config."""
|
||||
if "stat_rate" not in val and "power_config" not in val:
|
||||
raise vol.Invalid("Either stat_rate or power_config is required")
|
||||
return val
|
||||
|
||||
|
||||
GRID_POWER_SOURCE_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
# stat_rate and power_config are both optional schema keys, but the validator
|
||||
# requires that at least one is provided; power_config takes precedence
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
|
||||
}
|
||||
),
|
||||
_validate_grid_power_source,
|
||||
)
|
||||
|
||||
|
||||
@@ -225,7 +291,7 @@ def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[di
|
||||
val: list[dict],
|
||||
) -> list[dict]:
|
||||
"""Ensure that the user doesn't add duplicate values."""
|
||||
counts = Counter(flow_from[key] for flow_from in val)
|
||||
counts = Counter(item.get(key) for item in val if item.get(key) is not None)
|
||||
|
||||
for value, count in counts.items():
|
||||
if count > 1:
|
||||
@@ -267,7 +333,10 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
|
||||
vol.Required("type"): "battery",
|
||||
vol.Required("stat_energy_from"): str,
|
||||
vol.Required("stat_energy_to"): str,
|
||||
# Both stat_rate and power_config are optional
|
||||
# If power_config is provided, it takes precedence and stat_rate is overwritten
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
|
||||
}
|
||||
)
|
||||
GAS_SOURCE_SCHEMA = vol.Schema(
|
||||
@@ -387,6 +456,12 @@ class EnergyManager:
|
||||
if key in update:
|
||||
data[key] = update[key]
|
||||
|
||||
# Process energy sources and set stat_rate for power configs
|
||||
if "energy_sources" in update:
|
||||
data["energy_sources"] = self._process_energy_sources(
|
||||
data["energy_sources"]
|
||||
)
|
||||
|
||||
self.data = data
|
||||
self._store.async_delay_save(lambda: data, 60)
|
||||
|
||||
@@ -395,6 +470,74 @@ class EnergyManager:
|
||||
|
||||
await asyncio.gather(*(listener() for listener in self._update_listeners))
|
||||
|
||||
def _process_energy_sources(self, sources: list[SourceType]) -> list[SourceType]:
|
||||
"""Process energy sources and set stat_rate for power configs."""
|
||||
from .helpers import generate_power_sensor_entity_id # noqa: PLC0415
|
||||
|
||||
processed: list[SourceType] = []
|
||||
for source in sources:
|
||||
if source["type"] == "battery":
|
||||
source = self._process_battery_power(
|
||||
source, generate_power_sensor_entity_id
|
||||
)
|
||||
elif source["type"] == "grid":
|
||||
source = self._process_grid_power(
|
||||
source, generate_power_sensor_entity_id
|
||||
)
|
||||
processed.append(source)
|
||||
return processed
|
||||
|
||||
def _process_battery_power(
|
||||
self,
|
||||
source: BatterySourceType,
|
||||
generate_entity_id: Callable[[str, PowerConfig], str],
|
||||
) -> BatterySourceType:
|
||||
"""Set stat_rate for battery if power_config is specified."""
|
||||
if "power_config" not in source:
|
||||
return source
|
||||
|
||||
config = source["power_config"]
|
||||
|
||||
# If power_config has stat_rate (standard), just use it directly
|
||||
if "stat_rate" in config:
|
||||
return {**source, "stat_rate": config["stat_rate"]}
|
||||
|
||||
# For inverted or two-sensor config, set stat_rate to the generated entity_id
|
||||
entity_id = generate_entity_id("battery", config)
|
||||
if entity_id:
|
||||
return {**source, "stat_rate": entity_id}
|
||||
|
||||
return source
|
||||
|
||||
def _process_grid_power(
|
||||
self,
|
||||
source: GridSourceType,
|
||||
generate_entity_id: Callable[[str, PowerConfig], str],
|
||||
) -> GridSourceType:
|
||||
"""Set stat_rate for grid power sources if power_config is specified."""
|
||||
if "power" not in source:
|
||||
return source
|
||||
|
||||
processed_power: list[GridPowerSourceType] = []
|
||||
for power in source["power"]:
|
||||
if "power_config" in power:
|
||||
config = power["power_config"]
|
||||
|
||||
# If power_config has stat_rate (standard), just use it directly
|
||||
if "stat_rate" in config:
|
||||
processed_power.append({**power, "stat_rate": config["stat_rate"]})
|
||||
continue
|
||||
|
||||
# For inverted or two-sensor config, set stat_rate to generated entity_id
|
||||
entity_id = generate_entity_id("grid", config)
|
||||
if entity_id:
|
||||
processed_power.append({**power, "stat_rate": entity_id})
|
||||
continue
|
||||
|
||||
processed_power.append(power)
|
||||
|
||||
return {**source, "power": processed_power}
|
||||
|
||||
@callback
|
||||
def async_listen_updates(self, update_listener: Callable[[], Awaitable]) -> None:
|
||||
"""Listen for data updates."""
|
||||
|
||||
35
homeassistant/components/energy/helpers.py
Normal file
35
homeassistant/components/energy/helpers.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Helpers for the Energy integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .data import PowerConfig
|
||||
|
||||
|
||||
def generate_power_sensor_unique_id(source_type: str, config: PowerConfig) -> str:
|
||||
"""Generate a unique ID for a power transform sensor."""
|
||||
if "stat_rate_inverted" in config:
|
||||
sensor_id = config["stat_rate_inverted"].replace(".", "_")
|
||||
return f"energy_power_{source_type}_inv_{sensor_id}"
|
||||
if "stat_rate_from" in config and "stat_rate_to" in config:
|
||||
from_id = config["stat_rate_from"].replace(".", "_")
|
||||
to_id = config["stat_rate_to"].replace(".", "_")
|
||||
return f"energy_power_{source_type}_combined_{from_id}_{to_id}"
|
||||
return ""
|
||||
|
||||
|
||||
def generate_power_sensor_entity_id(source_type: str, config: PowerConfig) -> str:
|
||||
"""Generate an entity ID for a power transform sensor."""
|
||||
if "stat_rate_inverted" in config:
|
||||
# Use source sensor name with _inverted suffix
|
||||
source = config["stat_rate_inverted"]
|
||||
if source.startswith("sensor."):
|
||||
return f"{source}_inverted"
|
||||
return f"sensor.{source.replace('.', '_')}_inverted"
|
||||
if "stat_rate_from" in config and "stat_rate_to" in config:
|
||||
# Include sensor IDs to avoid collisions when multiple combined configs exist
|
||||
from_sensor = config["stat_rate_from"].removeprefix("sensor.")
|
||||
return f"sensor.energy_{source_type}_{from_sensor}_power"
|
||||
return ""
|
||||
@@ -19,7 +19,12 @@ from homeassistant.components.sensor import (
|
||||
from homeassistant.components.sensor.recorder import ( # pylint: disable=hass-component-root-import
|
||||
reset_detected,
|
||||
)
|
||||
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfEnergy, UnitOfVolume
|
||||
from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
State,
|
||||
@@ -36,7 +41,8 @@ from homeassistant.util import dt as dt_util, unit_conversion
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from .const import DOMAIN
|
||||
from .data import EnergyManager, async_get_manager
|
||||
from .data import EnergyManager, PowerConfig, async_get_manager
|
||||
from .helpers import generate_power_sensor_entity_id, generate_power_sensor_unique_id
|
||||
|
||||
SUPPORTED_STATE_CLASSES = {
|
||||
SensorStateClass.MEASUREMENT,
|
||||
@@ -137,6 +143,7 @@ class SensorManager:
|
||||
self.manager = manager
|
||||
self.async_add_entities = async_add_entities
|
||||
self.current_entities: dict[tuple[str, str | None, str], EnergyCostSensor] = {}
|
||||
self.current_power_entities: dict[str, EnergyPowerSensor] = {}
|
||||
|
||||
async def async_start(self) -> None:
|
||||
"""Start."""
|
||||
@@ -147,8 +154,9 @@ class SensorManager:
|
||||
|
||||
async def _process_manager_data(self) -> None:
|
||||
"""Process manager data."""
|
||||
to_add: list[EnergyCostSensor] = []
|
||||
to_add: list[EnergyCostSensor | EnergyPowerSensor] = []
|
||||
to_remove = dict(self.current_entities)
|
||||
power_to_remove = dict(self.current_power_entities)
|
||||
|
||||
async def finish() -> None:
|
||||
if to_add:
|
||||
@@ -159,6 +167,10 @@ class SensorManager:
|
||||
self.current_entities.pop(key)
|
||||
await entity.async_remove()
|
||||
|
||||
for power_key, power_entity in power_to_remove.items():
|
||||
self.current_power_entities.pop(power_key)
|
||||
await power_entity.async_remove()
|
||||
|
||||
if not self.manager.data:
|
||||
await finish()
|
||||
return
|
||||
@@ -185,6 +197,13 @@ class SensorManager:
|
||||
to_remove,
|
||||
)
|
||||
|
||||
# Process power sensors for battery and grid sources
|
||||
self._process_power_sensor_data(
|
||||
energy_source,
|
||||
to_add,
|
||||
power_to_remove,
|
||||
)
|
||||
|
||||
await finish()
|
||||
|
||||
@callback
|
||||
@@ -192,7 +211,7 @@ class SensorManager:
|
||||
self,
|
||||
adapter: SourceAdapter,
|
||||
config: Mapping[str, Any],
|
||||
to_add: list[EnergyCostSensor],
|
||||
to_add: list[EnergyCostSensor | EnergyPowerSensor],
|
||||
to_remove: dict[tuple[str, str | None, str], EnergyCostSensor],
|
||||
) -> None:
|
||||
"""Process sensor data."""
|
||||
@@ -220,6 +239,74 @@ class SensorManager:
|
||||
)
|
||||
to_add.append(self.current_entities[key])
|
||||
|
||||
@callback
|
||||
def _process_power_sensor_data(
|
||||
self,
|
||||
energy_source: Mapping[str, Any],
|
||||
to_add: list[EnergyCostSensor | EnergyPowerSensor],
|
||||
to_remove: dict[str, EnergyPowerSensor],
|
||||
) -> None:
|
||||
"""Process power sensor data for battery and grid sources."""
|
||||
source_type = energy_source.get("type")
|
||||
|
||||
if source_type == "battery":
|
||||
power_config = energy_source.get("power_config")
|
||||
if power_config and self._needs_power_sensor(power_config):
|
||||
self._create_or_keep_power_sensor(
|
||||
source_type, power_config, to_add, to_remove
|
||||
)
|
||||
|
||||
elif source_type == "grid":
|
||||
for power in energy_source.get("power", []):
|
||||
power_config = power.get("power_config")
|
||||
if power_config and self._needs_power_sensor(power_config):
|
||||
self._create_or_keep_power_sensor(
|
||||
source_type, power_config, to_add, to_remove
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _needs_power_sensor(power_config: PowerConfig) -> bool:
|
||||
"""Check if power_config needs a transform sensor."""
|
||||
# Only create sensors for inverted or two-sensor configs
|
||||
# Standard stat_rate configs don't need a transform sensor
|
||||
return "stat_rate_inverted" in power_config or (
|
||||
"stat_rate_from" in power_config and "stat_rate_to" in power_config
|
||||
)
|
||||
|
||||
def _create_or_keep_power_sensor(
|
||||
self,
|
||||
source_type: str,
|
||||
power_config: PowerConfig,
|
||||
to_add: list[EnergyCostSensor | EnergyPowerSensor],
|
||||
to_remove: dict[str, EnergyPowerSensor],
|
||||
) -> None:
|
||||
"""Create a power sensor or keep an existing one."""
|
||||
unique_id = generate_power_sensor_unique_id(source_type, power_config)
|
||||
if not unique_id:
|
||||
return
|
||||
|
||||
# If entity already exists, keep it
|
||||
if unique_id in to_remove:
|
||||
to_remove.pop(unique_id)
|
||||
return
|
||||
|
||||
# If we already have this entity, skip
|
||||
if unique_id in self.current_power_entities:
|
||||
return
|
||||
|
||||
entity_id = generate_power_sensor_entity_id(source_type, power_config)
|
||||
if not entity_id:
|
||||
return
|
||||
|
||||
sensor = EnergyPowerSensor(
|
||||
source_type,
|
||||
power_config,
|
||||
unique_id,
|
||||
entity_id,
|
||||
)
|
||||
self.current_power_entities[unique_id] = sensor
|
||||
to_add.append(sensor)
|
||||
|
||||
|
||||
def _set_result_unless_done(future: asyncio.Future[None]) -> None:
|
||||
"""Set the result of a future unless it is done."""
|
||||
@@ -495,3 +582,196 @@ class EnergyCostSensor(SensorEntity):
|
||||
prefix = self._config[self._adapter.stat_energy_key]
|
||||
|
||||
return f"{prefix}_{self._adapter.source_type}_{self._adapter.entity_id_suffix}"
|
||||
|
||||
|
||||
class EnergyPowerSensor(SensorEntity):
|
||||
"""Transform power sensor values (invert or combine two sensors).
|
||||
|
||||
This sensor handles non-standard power sensor configurations for the energy
|
||||
dashboard by either inverting polarity or combining two positive sensors.
|
||||
"""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_device_class = SensorDeviceClass.POWER
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_type: str,
|
||||
config: PowerConfig,
|
||||
unique_id: str,
|
||||
entity_id: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__()
|
||||
self._source_type = source_type
|
||||
self._config: PowerConfig = config
|
||||
self._attr_unique_id = unique_id
|
||||
self.entity_id = entity_id
|
||||
self._source_sensors: list[str] = []
|
||||
self._is_inverted = "stat_rate_inverted" in config
|
||||
self._is_combined = "stat_rate_from" in config and "stat_rate_to" in config
|
||||
|
||||
# Determine source sensors
|
||||
if self._is_inverted:
|
||||
self._source_sensors = [config["stat_rate_inverted"]]
|
||||
elif self._is_combined:
|
||||
self._source_sensors = [
|
||||
config["stat_rate_from"],
|
||||
config["stat_rate_to"],
|
||||
]
|
||||
|
||||
# add_finished is set when either async_added_to_hass or add_to_platform_abort
|
||||
# is called
|
||||
self.add_finished: asyncio.Future[None] = (
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
if self._is_inverted:
|
||||
source = self.hass.states.get(self._source_sensors[0])
|
||||
return source is not None and source.state not in (
|
||||
"unknown",
|
||||
"unavailable",
|
||||
)
|
||||
if self._is_combined:
|
||||
discharge = self.hass.states.get(self._source_sensors[0])
|
||||
charge = self.hass.states.get(self._source_sensors[1])
|
||||
return (
|
||||
discharge is not None
|
||||
and charge is not None
|
||||
and discharge.state not in ("unknown", "unavailable")
|
||||
and charge.state not in ("unknown", "unavailable")
|
||||
)
|
||||
return True
|
||||
|
||||
@callback
|
||||
def _update_state(self) -> None:
|
||||
"""Update the sensor state based on source sensors."""
|
||||
if self._is_inverted:
|
||||
source_state = self.hass.states.get(self._source_sensors[0])
|
||||
if source_state is None or source_state.state in ("unknown", "unavailable"):
|
||||
self._attr_native_value = None
|
||||
return
|
||||
try:
|
||||
value = float(source_state.state)
|
||||
self._attr_native_value = value * -1
|
||||
except ValueError:
|
||||
self._attr_native_value = None
|
||||
|
||||
elif self._is_combined:
|
||||
discharge_state = self.hass.states.get(self._source_sensors[0])
|
||||
charge_state = self.hass.states.get(self._source_sensors[1])
|
||||
|
||||
if (
|
||||
discharge_state is None
|
||||
or charge_state is None
|
||||
or discharge_state.state in ("unknown", "unavailable")
|
||||
or charge_state.state in ("unknown", "unavailable")
|
||||
):
|
||||
self._attr_native_value = None
|
||||
return
|
||||
|
||||
try:
|
||||
discharge = float(discharge_state.state)
|
||||
charge = float(charge_state.state)
|
||||
|
||||
# Get units from state attributes
|
||||
discharge_unit = discharge_state.attributes.get(
|
||||
ATTR_UNIT_OF_MEASUREMENT
|
||||
)
|
||||
charge_unit = charge_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
|
||||
# Convert to Watts if units are present
|
||||
if discharge_unit:
|
||||
discharge = unit_conversion.PowerConverter.convert(
|
||||
discharge, discharge_unit, UnitOfPower.WATT
|
||||
)
|
||||
if charge_unit:
|
||||
charge = unit_conversion.PowerConverter.convert(
|
||||
charge, charge_unit, UnitOfPower.WATT
|
||||
)
|
||||
|
||||
self._attr_native_value = discharge - charge
|
||||
except ValueError:
|
||||
self._attr_native_value = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
# Set name based on source sensor(s)
|
||||
if self._source_sensors:
|
||||
entity_reg = er.async_get(self.hass)
|
||||
device_id = None
|
||||
source_name = None
|
||||
# Check first sensor
|
||||
if source_entry := entity_reg.async_get(self._source_sensors[0]):
|
||||
device_id = source_entry.device_id
|
||||
# For combined mode, always use Watts because we may have different source units; for inverted mode, copy source unit
|
||||
if self._is_combined:
|
||||
self._attr_native_unit_of_measurement = UnitOfPower.WATT
|
||||
else:
|
||||
self._attr_native_unit_of_measurement = (
|
||||
source_entry.unit_of_measurement
|
||||
)
|
||||
# Get source name from registry
|
||||
source_name = source_entry.name or source_entry.original_name
|
||||
# Assign power sensor to same device as source sensor(s)
|
||||
# Note: We use manual entity registry update instead of _attr_device_info
|
||||
# because device assignment depends on runtime information from the entity
|
||||
# registry (which source sensor has a device). This information isn't
|
||||
# available during __init__, and the entity is already registered before
|
||||
# async_added_to_hass runs, making the standard _attr_device_info pattern
|
||||
# incompatible with this use case.
|
||||
# If first sensor has no device and we have a second sensor, check it
|
||||
if not device_id and len(self._source_sensors) > 1:
|
||||
if source_entry := entity_reg.async_get(self._source_sensors[1]):
|
||||
device_id = source_entry.device_id
|
||||
# Update entity registry entry with device_id
|
||||
if device_id and (power_entry := entity_reg.async_get(self.entity_id)):
|
||||
entity_reg.async_update_entity(
|
||||
power_entry.entity_id, device_id=device_id
|
||||
)
|
||||
else:
|
||||
self._attr_has_entity_name = False
|
||||
|
||||
# Set name for inverted mode
|
||||
if self._is_inverted:
|
||||
if source_name:
|
||||
self._attr_name = f"{source_name} Inverted"
|
||||
else:
|
||||
# Fall back to entity_id if no name in registry
|
||||
sensor_name = split_entity_id(self._source_sensors[0])[1].replace(
|
||||
"_", " "
|
||||
)
|
||||
self._attr_name = f"{sensor_name.title()} Inverted"
|
||||
|
||||
# Set name for combined mode
|
||||
if self._is_combined:
|
||||
self._attr_name = f"{self._source_type.title()} Power"
|
||||
|
||||
self._update_state()
|
||||
|
||||
# Track state changes on all source sensors
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
self._source_sensors,
|
||||
self._async_state_changed_listener,
|
||||
)
|
||||
)
|
||||
_set_result_unless_done(self.add_finished)
|
||||
|
||||
@callback
|
||||
def _async_state_changed_listener(self, *_: Any) -> None:
|
||||
"""Handle source sensor state changes."""
|
||||
self._update_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def add_to_platform_abort(self) -> None:
|
||||
"""Abort adding an entity to a platform."""
|
||||
_set_result_unless_done(self.add_finished)
|
||||
super().add_to_platform_abort()
|
||||
|
||||
@@ -23,5 +23,5 @@
|
||||
"winter_mode": {}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20251229.0"]
|
||||
"requirements": ["home-assistant-frontend==20251229.1"]
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ class IsraelRailEntitySensor(
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the state of the sensor."""
|
||||
if self.entity_description.index >= len(self.coordinator.data):
|
||||
return None
|
||||
return self.entity_description.value_fn(
|
||||
self.coordinator.data[self.entity_description.index]
|
||||
)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["jvcprojector"],
|
||||
"requirements": ["pyjvcprojector==1.1.2"]
|
||||
"requirements": ["pyjvcprojector==1.1.3"]
|
||||
}
|
||||
|
||||
@@ -41,6 +41,13 @@ COMMANDS = {
|
||||
"mode_1": const.REMOTE_MODE_1,
|
||||
"mode_2": const.REMOTE_MODE_2,
|
||||
"mode_3": const.REMOTE_MODE_3,
|
||||
"mode_4": const.REMOTE_MODE_4,
|
||||
"mode_5": const.REMOTE_MODE_5,
|
||||
"mode_6": const.REMOTE_MODE_6,
|
||||
"mode_7": const.REMOTE_MODE_7,
|
||||
"mode_8": const.REMOTE_MODE_8,
|
||||
"mode_9": const.REMOTE_MODE_9,
|
||||
"mode_10": const.REMOTE_MODE_10,
|
||||
"lens_ap": const.REMOTE_LENS_AP,
|
||||
"gamma": const.REMOTE_GAMMA,
|
||||
"color_temp": const.REMOTE_COLOR_TEMP,
|
||||
|
||||
@@ -2,23 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import openevsewifi
|
||||
from openevsehttp.__main__ import OpenEVSE
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
|
||||
type OpenEVSEConfigEntry = ConfigEntry[openevsewifi.Charger]
|
||||
type OpenEVSEConfigEntry = ConfigEntry[OpenEVSE]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool:
|
||||
"""Set up openevse from a config entry."""
|
||||
|
||||
entry.runtime_data = openevsewifi.Charger(entry.data[CONF_HOST])
|
||||
entry.runtime_data = OpenEVSE(entry.data[CONF_HOST])
|
||||
try:
|
||||
await hass.async_add_executor_job(entry.runtime_data.getStatus)
|
||||
except AttributeError as ex:
|
||||
await entry.runtime_data.test_and_get()
|
||||
except TimeoutError as ex:
|
||||
raise ConfigEntryError("Unable to connect to charger") from ex
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR])
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
import openevsewifi
|
||||
from openevsehttp.__main__ import OpenEVSE
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
@@ -20,13 +20,13 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
async def check_status(self, host: str) -> bool:
|
||||
"""Check if we can connect to the OpenEVSE charger."""
|
||||
|
||||
charger = openevsewifi.Charger(host)
|
||||
charger = OpenEVSE(host)
|
||||
try:
|
||||
result = await self.hass.async_add_executor_job(charger.getStatus)
|
||||
except AttributeError:
|
||||
await charger.test_and_get()
|
||||
except TimeoutError:
|
||||
return False
|
||||
else:
|
||||
return result is not None
|
||||
return True
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/openevse",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["openevsewifi"],
|
||||
"loggers": ["openevsehttp"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["openevsewifi==1.1.2"]
|
||||
"requirements": ["python-openevse-http==0.2.1"]
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import openevsewifi
|
||||
from requests import RequestException
|
||||
from openevsehttp.__main__ import OpenEVSE
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -175,7 +174,7 @@ class OpenEVSESensor(SensorEntity):
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
charger: openevsewifi.Charger,
|
||||
charger: OpenEVSE,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
@@ -183,25 +182,28 @@ class OpenEVSESensor(SensorEntity):
|
||||
self.host = host
|
||||
self.charger = charger
|
||||
|
||||
def update(self) -> None:
|
||||
async def async_update(self) -> None:
|
||||
"""Get the monitored data from the charger."""
|
||||
try:
|
||||
sensor_type = self.entity_description.key
|
||||
if sensor_type == "status":
|
||||
self._attr_native_value = self.charger.getStatus()
|
||||
elif sensor_type == "charge_time":
|
||||
self._attr_native_value = self.charger.getChargeTimeElapsed() / 60
|
||||
elif sensor_type == "ambient_temp":
|
||||
self._attr_native_value = self.charger.getAmbientTemperature()
|
||||
elif sensor_type == "ir_temp":
|
||||
self._attr_native_value = self.charger.getIRTemperature()
|
||||
elif sensor_type == "rtc_temp":
|
||||
self._attr_native_value = self.charger.getRTCTemperature()
|
||||
elif sensor_type == "usage_session":
|
||||
self._attr_native_value = float(self.charger.getUsageSession()) / 1000
|
||||
elif sensor_type == "usage_total":
|
||||
self._attr_native_value = float(self.charger.getUsageTotal()) / 1000
|
||||
else:
|
||||
self._attr_native_value = "Unknown"
|
||||
except (RequestException, ValueError, KeyError):
|
||||
await self.charger.update()
|
||||
except TimeoutError:
|
||||
_LOGGER.warning("Could not update status for %s", self.name)
|
||||
return
|
||||
|
||||
sensor_type = self.entity_description.key
|
||||
if sensor_type == "status":
|
||||
self._attr_native_value = self.charger.status
|
||||
elif sensor_type == "charge_time":
|
||||
self._attr_native_value = self.charger.charge_time_elapsed / 60
|
||||
elif sensor_type == "ambient_temp":
|
||||
self._attr_native_value = self.charger.ambient_temperature
|
||||
elif sensor_type == "ir_temp":
|
||||
self._attr_native_value = self.charger.ir_temperature
|
||||
elif sensor_type == "rtc_temp":
|
||||
self._attr_native_value = self.charger.rtc_temperature
|
||||
elif sensor_type == "usage_session":
|
||||
self._attr_native_value = float(self.charger.usage_session) / 1000
|
||||
elif sensor_type == "usage_total":
|
||||
self._attr_native_value = float(self.charger.usage_total) / 1000
|
||||
else:
|
||||
self._attr_native_value = "Unknown"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Enter the IP Address of your openevse. Should match the address you used to set it up."
|
||||
"host": "Enter the IP address of your OpenEVSE. Should match the address you used to set it up."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,10 +453,6 @@ async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall)
|
||||
# Imports deferred to avoid loading modules
|
||||
# in memory since usually only one part of this
|
||||
# integration is used at a time
|
||||
if sys.version_info >= (3, 14):
|
||||
raise HomeAssistantError(
|
||||
"Memory profiling is not supported on Python 3.14. Please use Python 3.13."
|
||||
)
|
||||
from guppy import hpy # noqa: PLC0415
|
||||
|
||||
start_time = int(time.time() * 1000000)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"pyprof2calltree==1.4.5",
|
||||
"guppy3==3.1.5;python_version<'3.14'",
|
||||
"guppy3==3.1.6",
|
||||
"objgraph==3.5.0"
|
||||
],
|
||||
"single_config_entry": true
|
||||
|
||||
@@ -128,8 +128,9 @@ class RingCam(RingEntity[RingDoorBell], Camera):
|
||||
self._device = self._get_coordinator_data().get_video_device(
|
||||
self._device.device_api_id
|
||||
)
|
||||
|
||||
history_data = self._device.last_history
|
||||
if history_data:
|
||||
if history_data and self._device.has_subscription:
|
||||
self._last_event = history_data[0]
|
||||
# will call async_update to update the attributes and get the
|
||||
# video url from the api
|
||||
@@ -154,8 +155,16 @@ class RingCam(RingEntity[RingDoorBell], Camera):
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Return a still image response from the camera."""
|
||||
if self._video_url is None:
|
||||
if not self._device.has_subscription:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_subscription",
|
||||
)
|
||||
return None
|
||||
|
||||
key = (width, height)
|
||||
if not (image := self._images.get(key)) and self._video_url is not None:
|
||||
if not (image := self._images.get(key)):
|
||||
image = await ffmpeg.async_get_image(
|
||||
self.hass,
|
||||
self._video_url,
|
||||
|
||||
@@ -151,6 +151,9 @@
|
||||
"api_timeout": {
|
||||
"message": "Timeout communicating with Ring API"
|
||||
},
|
||||
"no_subscription": {
|
||||
"message": "Ring Protect subscription required for snapshots"
|
||||
},
|
||||
"sdp_m_line_index_required": {
|
||||
"message": "Error negotiating stream for {device}"
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"loggers": ["roborock"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"python-roborock==4.2.0",
|
||||
"python-roborock==4.2.1",
|
||||
"vacuum-map-parser-roborock==0.1.4"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -391,15 +391,6 @@ Q7_B01_SENSOR_DESCRIPTIONS = [
|
||||
translation_key="mop_life_time_left",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
RoborockSensorDescriptionB01(
|
||||
key="total_cleaning_time",
|
||||
value_fn=lambda data: data.real_clean_time,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
translation_key="total_cleaning_time",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pysaunum"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["pysaunum==0.1.0"]
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ rules:
|
||||
status: exempt
|
||||
comment: Device cannot be discovered and the Modbus TCP API does not provide MAC address or other unique network identifiers needed to update connection information.
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/sentry",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["sentry-sdk==1.45.1"]
|
||||
"requirements": ["sentry-sdk==2.48.0"]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioshelly"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioshelly==13.23.0"],
|
||||
"requirements": ["aioshelly==13.23.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "shelly*",
|
||||
|
||||
@@ -46,7 +46,7 @@ SENSOR_TYPES = [
|
||||
key="lifetime_energy",
|
||||
json_key="lifeTimeData",
|
||||
translation_key="lifetime_energy",
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
),
|
||||
@@ -55,6 +55,7 @@ SENSOR_TYPES = [
|
||||
json_key="lastYearData",
|
||||
translation_key="energy_this_year",
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
),
|
||||
@@ -63,6 +64,7 @@ SENSOR_TYPES = [
|
||||
json_key="lastMonthData",
|
||||
translation_key="energy_this_month",
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
),
|
||||
@@ -71,6 +73,7 @@ SENSOR_TYPES = [
|
||||
json_key="lastDayData",
|
||||
translation_key="energy_today",
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
),
|
||||
@@ -123,24 +126,32 @@ SENSOR_TYPES = [
|
||||
json_key="LOAD",
|
||||
translation_key="power_consumption",
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
),
|
||||
SolarEdgeSensorEntityDescription(
|
||||
key="solar_power",
|
||||
json_key="PV",
|
||||
translation_key="solar_power",
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
),
|
||||
SolarEdgeSensorEntityDescription(
|
||||
key="grid_power",
|
||||
json_key="GRID",
|
||||
translation_key="grid_power",
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
),
|
||||
SolarEdgeSensorEntityDescription(
|
||||
key="storage_power",
|
||||
json_key="STORAGE",
|
||||
translation_key="storage_power",
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
),
|
||||
SolarEdgeSensorEntityDescription(
|
||||
key="purchased_energy",
|
||||
@@ -194,6 +205,7 @@ SENSOR_TYPES = [
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["solarlog_cli"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["solarlog_cli==0.6.1"]
|
||||
"requirements": ["solarlog_cli==0.7.0"]
|
||||
}
|
||||
|
||||
@@ -80,10 +80,6 @@ class TelegramNotificationService(BaseNotificationService):
|
||||
def send_message(self, message="", **kwargs):
|
||||
"""Send a message to a user."""
|
||||
service_data = {ATTR_TARGET: kwargs.get(ATTR_TARGET, self._chat_id)}
|
||||
if ATTR_TITLE in kwargs:
|
||||
service_data.update({ATTR_TITLE: kwargs.get(ATTR_TITLE)})
|
||||
if message:
|
||||
service_data.update({ATTR_MESSAGE: message})
|
||||
data = kwargs.get(ATTR_DATA)
|
||||
|
||||
# Set message tag
|
||||
@@ -161,6 +157,12 @@ class TelegramNotificationService(BaseNotificationService):
|
||||
)
|
||||
|
||||
# Send message
|
||||
|
||||
if ATTR_TITLE in kwargs:
|
||||
service_data.update({ATTR_TITLE: kwargs.get(ATTR_TITLE)})
|
||||
if message:
|
||||
service_data.update({ATTR_MESSAGE: message})
|
||||
|
||||
_LOGGER.debug(
|
||||
"TELEGRAM NOTIFIER calling %s.send_message with %s",
|
||||
TELEGRAM_BOT_DOMAIN,
|
||||
|
||||
@@ -33,7 +33,7 @@ from .const import (
|
||||
from .coordinator import TibberDataAPICoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.NOTIFY, Platform.SENSOR]
|
||||
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
"""Support for Tibber binary sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from tibber.data_api import TibberDevice
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, TibberConfigEntry
|
||||
from .coordinator import TibberDataAPICoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class TibberBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes Tibber binary sensor entity."""
|
||||
|
||||
is_on_fn: Callable[[str | None], bool | None]
|
||||
|
||||
|
||||
def _connector_status_is_on(value: str | None) -> bool | None:
|
||||
"""Map connector status value to binary sensor state."""
|
||||
if value == "connected":
|
||||
return True
|
||||
if value == "disconnected":
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _charging_status_is_on(value: str | None) -> bool | None:
|
||||
"""Map charging status value to binary sensor state."""
|
||||
if value == "charging":
|
||||
return True
|
||||
if value == "idle":
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _device_status_is_on(value: str | None) -> bool | None:
|
||||
"""Map device status value to binary sensor state."""
|
||||
if value == "on":
|
||||
return True
|
||||
if value == "off":
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
DATA_API_BINARY_SENSORS: tuple[TibberBinarySensorEntityDescription, ...] = (
|
||||
TibberBinarySensorEntityDescription(
|
||||
key="connector.status",
|
||||
device_class=BinarySensorDeviceClass.PLUG,
|
||||
is_on_fn=_connector_status_is_on,
|
||||
),
|
||||
TibberBinarySensorEntityDescription(
|
||||
key="charging.status",
|
||||
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||
is_on_fn=_charging_status_is_on,
|
||||
),
|
||||
TibberBinarySensorEntityDescription(
|
||||
key="onOff",
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
is_on_fn=_device_status_is_on,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: TibberConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Tibber binary sensors."""
|
||||
coordinator = entry.runtime_data.data_api_coordinator
|
||||
assert coordinator is not None
|
||||
|
||||
entities: list[TibberDataAPIBinarySensor] = []
|
||||
api_binary_sensors = {sensor.key: sensor for sensor in DATA_API_BINARY_SENSORS}
|
||||
|
||||
for device in coordinator.data.values():
|
||||
for sensor in device.sensors:
|
||||
description: TibberBinarySensorEntityDescription | None = (
|
||||
api_binary_sensors.get(sensor.id)
|
||||
)
|
||||
if description is None:
|
||||
continue
|
||||
entities.append(TibberDataAPIBinarySensor(coordinator, device, description))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class TibberDataAPIBinarySensor(
|
||||
CoordinatorEntity[TibberDataAPICoordinator], BinarySensorEntity
|
||||
):
|
||||
"""Representation of a Tibber Data API binary sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
entity_description: TibberBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: TibberDataAPICoordinator,
|
||||
device: TibberDevice,
|
||||
entity_description: TibberBinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the binary sensor."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._device_id: str = device.id
|
||||
self.entity_description = entity_description
|
||||
|
||||
self._attr_unique_id = f"{device.external_id}_{self.entity_description.key}"
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.external_id)},
|
||||
name=device.name,
|
||||
manufacturer=device.brand,
|
||||
model=device.model,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the state of the binary sensor."""
|
||||
sensors = self.coordinator.sensors_by_device.get(self._device_id, {})
|
||||
sensor = sensors[self.entity_description.key]
|
||||
value: str | None = str(sensor.value) if sensor.value is not None else None
|
||||
return self.entity_description.is_on_fn(value)
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/tibber",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tibber"],
|
||||
"requirements": ["pyTibber==0.34.0"]
|
||||
"requirements": ["pyTibber==0.34.1"]
|
||||
}
|
||||
|
||||
@@ -430,6 +430,9 @@ def _setup_data_api_sensors(
|
||||
for sensor in device.sensors:
|
||||
description: SensorEntityDescription | None = api_sensors.get(sensor.id)
|
||||
if description is None:
|
||||
_LOGGER.debug(
|
||||
"Sensor %s not found in DATA_API_SENSORS, skipping", sensor
|
||||
)
|
||||
continue
|
||||
entities.append(TibberDataAPISensor(coordinator, device, description))
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -183,13 +183,12 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
|
||||
self._state_wrapper = state_wrapper
|
||||
|
||||
# Determine supported modes
|
||||
if action_wrapper.options:
|
||||
if "arm_home" in action_wrapper.options:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
|
||||
if "arm_away" in action_wrapper.options:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
if "trigger" in action_wrapper.options:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
|
||||
if "arm_home" in action_wrapper.options:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
|
||||
if "arm_away" in action_wrapper.options:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
if "trigger" in action_wrapper.options:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
|
||||
|
||||
@property
|
||||
def alarm_state(self) -> AlarmControlPanelState | None:
|
||||
|
||||
@@ -368,7 +368,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
# Determine HVAC modes
|
||||
self._attr_hvac_modes: list[HVACMode] = []
|
||||
self._hvac_to_tuya = {}
|
||||
if hvac_mode_wrapper and hvac_mode_wrapper.options is not None:
|
||||
if hvac_mode_wrapper:
|
||||
self._attr_hvac_modes = [HVACMode.OFF]
|
||||
unknown_hvac_modes: list[str] = []
|
||||
for tuya_mode in hvac_mode_wrapper.options:
|
||||
|
||||
@@ -351,7 +351,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
self._set_position = set_position
|
||||
self._tilt_position = tilt_position
|
||||
|
||||
if instruction_wrapper and instruction_wrapper.options:
|
||||
if instruction_wrapper:
|
||||
if "open" in instruction_wrapper.options:
|
||||
self._attr_supported_features |= CoverEntityFeature.OPEN
|
||||
if "close" in instruction_wrapper.options:
|
||||
@@ -424,11 +424,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
if (
|
||||
self._instruction_wrapper
|
||||
and (options := self._instruction_wrapper.options)
|
||||
and "stop" in options
|
||||
):
|
||||
if self._instruction_wrapper and "stop" in self._instruction_wrapper.options:
|
||||
await self._async_send_wrapper_updates(self._instruction_wrapper, "stop")
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
|
||||
@@ -21,6 +21,7 @@ from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
from .models import (
|
||||
DeviceWrapper,
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeRawWrapper,
|
||||
DPCodeStringWrapper,
|
||||
@@ -28,75 +29,58 @@ from .models import (
|
||||
)
|
||||
|
||||
|
||||
class _DPCodeEventWrapper(DPCodeTypeInformationWrapper):
|
||||
"""Base class for Tuya event wrappers."""
|
||||
class _EventEnumWrapper(DPCodeEnumWrapper):
|
||||
"""Wrapper for event enum DP codes."""
|
||||
|
||||
options: list[str]
|
||||
def read_device_status(self, device: CustomerDevice) -> tuple[str, None] | None:
|
||||
"""Return the event details."""
|
||||
if (raw_value := super().read_device_status(device)) is None:
|
||||
return None
|
||||
return (raw_value, None)
|
||||
|
||||
|
||||
class _AlarmMessageWrapper(DPCodeStringWrapper):
|
||||
"""Wrapper for a STRING message on DPCode.ALARM_MESSAGE."""
|
||||
|
||||
def __init__(self, dpcode: str, type_information: Any) -> None:
|
||||
"""Init _DPCodeEventWrapper."""
|
||||
"""Init _AlarmMessageWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self.options = ["triggered"]
|
||||
|
||||
def get_event_type(
|
||||
self, device: CustomerDevice, updated_status_properties: list[str] | None
|
||||
) -> str | None:
|
||||
"""Return the event type."""
|
||||
if (
|
||||
updated_status_properties is None
|
||||
or self.dpcode not in updated_status_properties
|
||||
):
|
||||
return None
|
||||
return "triggered"
|
||||
|
||||
def get_event_attributes(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
"""Return the event attributes."""
|
||||
return None
|
||||
|
||||
|
||||
class _EventEnumWrapper(DPCodeEnumWrapper, _DPCodeEventWrapper):
|
||||
"""Wrapper for event enum DP codes."""
|
||||
|
||||
def get_event_type(
|
||||
self, device: CustomerDevice, updated_status_properties: list[str] | None
|
||||
) -> str | None:
|
||||
"""Return the triggered event type."""
|
||||
if (
|
||||
updated_status_properties is None
|
||||
or self.dpcode not in updated_status_properties
|
||||
):
|
||||
return None
|
||||
return self.read_device_status(device)
|
||||
|
||||
|
||||
class _AlarmMessageWrapper(DPCodeStringWrapper, _DPCodeEventWrapper):
|
||||
"""Wrapper for a STRING message on DPCode.ALARM_MESSAGE."""
|
||||
|
||||
def get_event_attributes(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
def read_device_status(
|
||||
self, device: CustomerDevice
|
||||
) -> tuple[str, dict[str, Any]] | None:
|
||||
"""Return the event attributes for the alarm message."""
|
||||
if (raw_value := device.status.get(self.dpcode)) is None:
|
||||
if (raw_value := super().read_device_status(device)) is None:
|
||||
return None
|
||||
return {"message": b64decode(raw_value).decode("utf-8")}
|
||||
return ("triggered", {"message": b64decode(raw_value).decode("utf-8")})
|
||||
|
||||
|
||||
class _DoorbellPicWrapper(DPCodeRawWrapper, _DPCodeEventWrapper):
|
||||
class _DoorbellPicWrapper(DPCodeRawWrapper):
|
||||
"""Wrapper for a RAW message on DPCode.DOORBELL_PIC.
|
||||
|
||||
It is expected that the RAW data is base64/utf8 encoded URL of the picture.
|
||||
"""
|
||||
|
||||
def get_event_attributes(self, device: CustomerDevice) -> dict[str, Any] | None:
|
||||
def __init__(self, dpcode: str, type_information: Any) -> None:
|
||||
"""Init _DoorbellPicWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self.options = ["triggered"]
|
||||
|
||||
def read_device_status(
|
||||
self, device: CustomerDevice
|
||||
) -> tuple[str, dict[str, Any]] | None:
|
||||
"""Return the event attributes for the doorbell picture."""
|
||||
if (status := super().read_device_status(device)) is None:
|
||||
return None
|
||||
return {"message": status.decode("utf-8")}
|
||||
return ("triggered", {"message": status.decode("utf-8")})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuyaEventEntityDescription(EventEntityDescription):
|
||||
"""Describe a Tuya Event entity."""
|
||||
|
||||
wrapper_class: type[_DPCodeEventWrapper] = _EventEnumWrapper
|
||||
wrapper_class: type[DPCodeTypeInformationWrapper] = _EventEnumWrapper
|
||||
|
||||
|
||||
# All descriptions can be found here. Mostly the Enum data types in the
|
||||
@@ -222,7 +206,7 @@ class TuyaEventEntity(TuyaEntity, EventEntity):
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
description: EventEntityDescription,
|
||||
dpcode_wrapper: _DPCodeEventWrapper,
|
||||
dpcode_wrapper: DeviceWrapper[tuple[str, dict[str, Any] | None]],
|
||||
) -> None:
|
||||
"""Init Tuya event entity."""
|
||||
super().__init__(device, device_manager)
|
||||
@@ -236,15 +220,11 @@ class TuyaEventEntity(TuyaEntity, EventEntity):
|
||||
updated_status_properties: list[str] | None,
|
||||
dp_timestamps: dict | None = None,
|
||||
) -> None:
|
||||
if (
|
||||
event_type := self._dpcode_wrapper.get_event_type(
|
||||
self.device, updated_status_properties
|
||||
)
|
||||
) is None:
|
||||
if self._dpcode_wrapper.skip_update(
|
||||
self.device, updated_status_properties
|
||||
) or not (event_data := self._dpcode_wrapper.read_device_status(self.device)):
|
||||
return
|
||||
|
||||
self._trigger_event(
|
||||
event_type,
|
||||
self._dpcode_wrapper.get_event_attributes(self.device),
|
||||
)
|
||||
event_type, event_attributes = event_data
|
||||
self._trigger_event(event_type, event_attributes)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -198,7 +198,9 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
|
||||
|
||||
if speed_wrapper:
|
||||
self._attr_supported_features |= FanEntityFeature.SET_SPEED
|
||||
if speed_wrapper.options is not None:
|
||||
# if speed is from an enum, set speed count from options
|
||||
# else keep entity default 100
|
||||
if hasattr(speed_wrapper, "options"):
|
||||
self._attr_speed_count = len(speed_wrapper.options)
|
||||
|
||||
if oscillate_wrapper:
|
||||
|
||||
@@ -706,7 +706,6 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
elif (
|
||||
color_supported(color_modes)
|
||||
and color_mode_wrapper is not None
|
||||
and color_mode_wrapper.options
|
||||
and WorkMode.WHITE in color_mode_wrapper.options
|
||||
):
|
||||
color_modes.add(ColorMode.WHITE)
|
||||
|
||||
@@ -22,13 +22,23 @@ class DeviceWrapper[T]:
|
||||
"""Base device wrapper."""
|
||||
|
||||
native_unit: str | None = None
|
||||
options: list[str] | None = None
|
||||
suggested_unit: str | None = None
|
||||
|
||||
max_value: float
|
||||
min_value: float
|
||||
value_step: float
|
||||
|
||||
options: list[str]
|
||||
|
||||
def skip_update(
|
||||
self, device: CustomerDevice, updated_status_properties: list[str] | None
|
||||
) -> bool:
|
||||
"""Determine if the wrapper should skip an update.
|
||||
|
||||
The default is to always skip, unless overridden in subclasses.
|
||||
"""
|
||||
return True
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> T | None:
|
||||
"""Read device status and convert to a Home Assistant value."""
|
||||
raise NotImplementedError
|
||||
@@ -51,6 +61,19 @@ class DPCodeWrapper(DeviceWrapper):
|
||||
"""Init DPCodeWrapper."""
|
||||
self.dpcode = dpcode
|
||||
|
||||
def skip_update(
|
||||
self, device: CustomerDevice, updated_status_properties: list[str] | None
|
||||
) -> bool:
|
||||
"""Determine if the wrapper should skip an update.
|
||||
|
||||
By default, skip if updated_status_properties is given and
|
||||
does not include this dpcode.
|
||||
"""
|
||||
return (
|
||||
updated_status_properties is None
|
||||
or self.dpcode not in updated_status_properties
|
||||
)
|
||||
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
|
||||
"""Convert a Home Assistant value back to a raw device value.
|
||||
|
||||
@@ -138,7 +161,6 @@ class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeInformation]):
|
||||
"""Simple wrapper for EnumTypeInformation values."""
|
||||
|
||||
_DPTYPE = EnumTypeInformation
|
||||
options: list[str]
|
||||
|
||||
def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
|
||||
"""Init DPCodeEnumWrapper."""
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
@@ -402,8 +400,6 @@ class TuyaSelectEntity(TuyaEntity, SelectEntity):
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{super().unique_id}{description.key}"
|
||||
self._dpcode_wrapper = dpcode_wrapper
|
||||
if TYPE_CHECKING:
|
||||
assert dpcode_wrapper.options
|
||||
self._attr_options = dpcode_wrapper.options
|
||||
|
||||
@property
|
||||
|
||||
@@ -212,7 +212,7 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
self._attr_fan_speed_list = []
|
||||
self._attr_supported_features = VacuumEntityFeature.SEND_COMMAND
|
||||
|
||||
if action_wrapper and action_wrapper.options:
|
||||
if action_wrapper:
|
||||
if "pause" in action_wrapper.options:
|
||||
self._attr_supported_features |= VacuumEntityFeature.PAUSE
|
||||
if "return_to_base" in action_wrapper.options:
|
||||
@@ -227,7 +227,7 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
if activity_wrapper:
|
||||
self._attr_supported_features |= VacuumEntityFeature.STATE
|
||||
|
||||
if fan_speed_wrapper and fan_speed_wrapper.options:
|
||||
if fan_speed_wrapper:
|
||||
self._attr_fan_speed_list = fan_speed_wrapper.options
|
||||
self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["uiprotect", "unifi_discovery"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["uiprotect==7.33.3", "unifi-discovery==1.2.0"],
|
||||
"requirements": ["uiprotect==8.0.0", "unifi-discovery==1.2.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
|
||||
from pyvesync.base_devices import VeSyncHumidifier
|
||||
from pyvesync.base_devices.fan_base import VeSyncFanBase
|
||||
from pyvesync.base_devices.fryer_base import VeSyncFryer
|
||||
from pyvesync.base_devices.outlet_base import VeSyncOutlet
|
||||
from pyvesync.base_devices.purifier_base import VeSyncPurifier
|
||||
from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
|
||||
@@ -62,3 +63,9 @@ def is_purifier(device: VeSyncBaseDevice) -> bool:
|
||||
"""Check if the device represents an air purifier."""
|
||||
|
||||
return isinstance(device, VeSyncPurifier)
|
||||
|
||||
|
||||
def is_air_fryer(device: VeSyncBaseDevice) -> bool:
|
||||
"""Check if the device represents an air fryer."""
|
||||
|
||||
return isinstance(device, VeSyncFryer)
|
||||
|
||||
@@ -62,3 +62,14 @@ OUTLET_NIGHT_LIGHT_LEVEL_ON = "on"
|
||||
PURIFIER_NIGHT_LIGHT_LEVEL_DIM = "dim"
|
||||
PURIFIER_NIGHT_LIGHT_LEVEL_OFF = "off"
|
||||
PURIFIER_NIGHT_LIGHT_LEVEL_ON = "on"
|
||||
|
||||
AIR_FRYER_MODE_MAP = {
|
||||
"cookend": "cooking_end",
|
||||
"cooking": "cooking",
|
||||
"cookstop": "cooking_stop",
|
||||
"heating": "heating",
|
||||
"preheatend": "preheat_end",
|
||||
"preheatstop": "preheat_stop",
|
||||
"pullout": "pull_out",
|
||||
"standby": "standby",
|
||||
}
|
||||
|
||||
@@ -23,14 +23,15 @@ from homeassistant.const import (
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfTemperature,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .common import is_humidifier, is_outlet, rgetattr
|
||||
from .const import VS_DEVICES, VS_DISCOVERY
|
||||
from .common import is_air_fryer, is_humidifier, is_outlet, rgetattr
|
||||
from .const import AIR_FRYER_MODE_MAP, VS_DEVICES, VS_DISCOVERY
|
||||
from .coordinator import VesyncConfigEntry, VeSyncDataCoordinator
|
||||
from .entity import VeSyncBaseEntity
|
||||
|
||||
@@ -47,6 +48,8 @@ class VeSyncSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
exists_fn: Callable[[VeSyncBaseDevice], bool]
|
||||
|
||||
use_device_temperature_unit: bool = False
|
||||
|
||||
|
||||
SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
|
||||
VeSyncSensorEntityDescription(
|
||||
@@ -167,6 +170,59 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
|
||||
exists_fn=lambda device: is_humidifier(device)
|
||||
and device.state.temperature is not None,
|
||||
),
|
||||
VeSyncSensorEntityDescription(
|
||||
key="cook_status",
|
||||
translation_key="cook_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
value_fn=lambda device: AIR_FRYER_MODE_MAP.get(
|
||||
device.state.cook_status.lower(), device.state.cook_status.lower()
|
||||
),
|
||||
exists_fn=is_air_fryer,
|
||||
options=[
|
||||
"cooking_end",
|
||||
"cooking",
|
||||
"cooking_stop",
|
||||
"heating",
|
||||
"preheat_end",
|
||||
"preheat_stop",
|
||||
"pull_out",
|
||||
"standby",
|
||||
],
|
||||
),
|
||||
VeSyncSensorEntityDescription(
|
||||
key="current_temp",
|
||||
translation_key="current_temp",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
use_device_temperature_unit=True,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda device: device.state.current_temp,
|
||||
exists_fn=is_air_fryer,
|
||||
),
|
||||
VeSyncSensorEntityDescription(
|
||||
key="cook_set_temp",
|
||||
translation_key="cook_set_temp",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
use_device_temperature_unit=True,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda device: device.state.cook_set_temp,
|
||||
exists_fn=is_air_fryer,
|
||||
),
|
||||
VeSyncSensorEntityDescription(
|
||||
key="cook_set_time",
|
||||
translation_key="cook_set_time",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
value_fn=lambda device: device.state.cook_set_time,
|
||||
exists_fn=is_air_fryer,
|
||||
),
|
||||
VeSyncSensorEntityDescription(
|
||||
key="preheat_set_time",
|
||||
translation_key="preheat_set_time",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
value_fn=lambda device: device.state.preheat_set_time,
|
||||
exists_fn=is_air_fryer,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -232,3 +288,13 @@ class VeSyncSensorEntity(VeSyncBaseEntity, SensorEntity):
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.device)
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit the value was reported in by the sensor."""
|
||||
if self.entity_description.use_device_temperature_unit:
|
||||
if self.device.temp_unit == "celsius":
|
||||
return UnitOfTemperature.CELSIUS
|
||||
if self.device.temp_unit == "fahrenheit":
|
||||
return UnitOfTemperature.FAHRENHEIT
|
||||
return super().native_unit_of_measurement
|
||||
|
||||
@@ -92,9 +92,31 @@
|
||||
"air_quality": {
|
||||
"name": "Air quality"
|
||||
},
|
||||
"cook_set_temp": {
|
||||
"name": "Cooking set temperature"
|
||||
},
|
||||
"cook_set_time": {
|
||||
"name": "Cooking set time"
|
||||
},
|
||||
"cook_status": {
|
||||
"name": "Cooking status",
|
||||
"state": {
|
||||
"cooking": "Cooking",
|
||||
"cooking_end": "Cooking finished",
|
||||
"cooking_stop": "Cooking stopped",
|
||||
"heating": "Preheating",
|
||||
"preheat_end": "Preheating finished",
|
||||
"preheat_stop": "Preheating stopped",
|
||||
"pull_out": "Drawer pulled out",
|
||||
"standby": "[%key:common::state::standby%]"
|
||||
}
|
||||
},
|
||||
"current_power": {
|
||||
"name": "Current power"
|
||||
},
|
||||
"current_temp": {
|
||||
"name": "Current temperature"
|
||||
},
|
||||
"current_voltage": {
|
||||
"name": "Current voltage"
|
||||
},
|
||||
@@ -112,6 +134,9 @@
|
||||
},
|
||||
"filter_life": {
|
||||
"name": "Filter lifetime"
|
||||
},
|
||||
"preheat_set_time": {
|
||||
"name": "Preheating set time"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiovodafone"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiovodafone==3.0.0"]
|
||||
"requirements": ["aiovodafone==3.1.1"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"domain": "waterfurnace",
|
||||
"name": "WaterFurnace",
|
||||
"codeowners": [],
|
||||
"codeowners": ["@sdague", "@masterkoppa"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/waterfurnace",
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["waterfurnace"],
|
||||
"quality_scale": "legacy",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Watts Vision +",
|
||||
"codeowners": ["@theobld-ww", "@devender-verma-ww", "@ssi-spyro"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"dependencies": ["application_credentials", "cloud"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/watts",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"universal_silabs_flasher",
|
||||
"serialx"
|
||||
],
|
||||
"requirements": ["zha==0.0.82", "serialx==0.5.0"],
|
||||
"requirements": ["zha==0.0.83", "serialx==0.5.0"],
|
||||
"usb": [
|
||||
{
|
||||
"description": "*2652*",
|
||||
|
||||
@@ -7468,7 +7468,7 @@
|
||||
},
|
||||
"waterfurnace": {
|
||||
"name": "WaterFurnace",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": false,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
|
||||
@@ -537,7 +537,7 @@ def _validate_range[_T: dict[str, Any]](
|
||||
|
||||
_NUMBER_OR_ENTITY_CHOOSE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("chosen_selector"): vol.In(["number", "entity"]),
|
||||
vol.Required("active_choice"): vol.In(["number", "entity"]),
|
||||
vol.Optional("entity"): cv.entity_id,
|
||||
vol.Optional("number"): vol.Coerce(float),
|
||||
}
|
||||
@@ -548,7 +548,7 @@ def _validate_number_or_entity(value: dict | float | str) -> float | str:
|
||||
"""Validate number or entity selector result."""
|
||||
if isinstance(value, dict):
|
||||
_NUMBER_OR_ENTITY_CHOOSE_SCHEMA(value)
|
||||
return value[value["chosen_selector"]] # type: ignore[no-any-return]
|
||||
return value[value["active_choice"]] # type: ignore[no-any-return]
|
||||
return value
|
||||
|
||||
|
||||
|
||||
@@ -39,8 +39,8 @@ habluetooth==5.8.0
|
||||
hass-nabucasa==1.7.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-frontend==20251229.0
|
||||
home-assistant-intents==2026.1.1
|
||||
home-assistant-frontend==20251229.1
|
||||
home-assistant-intents==2026.1.6
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
Jinja2==3.1.6
|
||||
@@ -226,3 +226,6 @@ gql<4.0.0
|
||||
|
||||
# Pin pytest-rerunfailures to prevent accidental breaks
|
||||
pytest-rerunfailures==16.0.1
|
||||
|
||||
# Fixes detected blocking call to load_default_certs https://github.com/home-assistant/core/issues/157475
|
||||
aiomqtt>=2.5.0
|
||||
|
||||
2
requirements.txt
generated
2
requirements.txt
generated
@@ -27,7 +27,7 @@ ha-ffmpeg==3.2.2
|
||||
hass-nabucasa==1.7.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-intents==2026.1.1
|
||||
home-assistant-intents==2026.1.6
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
Jinja2==3.1.6
|
||||
|
||||
32
requirements_all.txt
generated
32
requirements_all.txt
generated
@@ -390,7 +390,7 @@ aiorussound==4.9.0
|
||||
aioruuvigateway==0.1.0
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==13.23.0
|
||||
aioshelly==13.23.1
|
||||
|
||||
# homeassistant.components.skybell
|
||||
aioskybell==22.7.0
|
||||
@@ -432,7 +432,7 @@ aiousbwatcher==1.1.1
|
||||
aiovlc==0.5.1
|
||||
|
||||
# homeassistant.components.vodafone_station
|
||||
aiovodafone==3.0.0
|
||||
aiovodafone==3.1.1
|
||||
|
||||
# homeassistant.components.waqi
|
||||
aiowaqi==3.1.0
|
||||
@@ -1145,7 +1145,7 @@ growattServer==1.7.1
|
||||
gspread==5.5.0
|
||||
|
||||
# homeassistant.components.profiler
|
||||
guppy3==3.1.5;python_version<'3.14'
|
||||
guppy3==3.1.6
|
||||
|
||||
# homeassistant.components.iaqualink
|
||||
h2==4.3.0
|
||||
@@ -1213,10 +1213,10 @@ hole==0.9.0
|
||||
holidays==0.84
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20251229.0
|
||||
home-assistant-frontend==20251229.1
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.1.1
|
||||
home-assistant-intents==2026.1.6
|
||||
|
||||
# homeassistant.components.gentex_homelink
|
||||
homelink-integration-api==0.0.1
|
||||
@@ -1662,9 +1662,6 @@ openai==2.11.0
|
||||
# homeassistant.components.openerz
|
||||
openerz-api==0.3.0
|
||||
|
||||
# homeassistant.components.openevse
|
||||
openevsewifi==1.1.2
|
||||
|
||||
# homeassistant.components.openhome
|
||||
openhomedevice==2.2.0
|
||||
|
||||
@@ -1867,7 +1864,7 @@ pyRFXtrx==0.31.1
|
||||
pySDCP==1
|
||||
|
||||
# homeassistant.components.tibber
|
||||
pyTibber==0.34.0
|
||||
pyTibber==0.34.1
|
||||
|
||||
# homeassistant.components.dlink
|
||||
pyW215==0.8.0
|
||||
@@ -1931,7 +1928,7 @@ pyblu==2.0.5
|
||||
pybotvac==0.0.28
|
||||
|
||||
# homeassistant.components.braviatv
|
||||
pybravia==0.3.4
|
||||
pybravia==0.4.1
|
||||
|
||||
# homeassistant.components.nissan_leaf
|
||||
pycarwings2==2.14
|
||||
@@ -2133,7 +2130,7 @@ pyitachip2ir==0.0.7
|
||||
pyituran==0.1.5
|
||||
|
||||
# homeassistant.components.jvc_projector
|
||||
pyjvcprojector==1.1.2
|
||||
pyjvcprojector==1.1.3
|
||||
|
||||
# homeassistant.components.kaleidescape
|
||||
pykaleidescape==1.0.2
|
||||
@@ -2558,6 +2555,9 @@ python-open-router==0.3.3
|
||||
# homeassistant.components.swiss_public_transport
|
||||
python-opendata-transport==0.5.0
|
||||
|
||||
# homeassistant.components.openevse
|
||||
python-openevse-http==0.2.1
|
||||
|
||||
# homeassistant.components.opensky
|
||||
python-opensky==1.0.1
|
||||
|
||||
@@ -2581,7 +2581,7 @@ python-rabbitair==0.0.8
|
||||
python-ripple-api==0.0.3
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==4.2.0
|
||||
python-roborock==4.2.1
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.46
|
||||
@@ -2838,7 +2838,7 @@ sensoterra==2.0.1
|
||||
sentence-stream==1.2.0
|
||||
|
||||
# homeassistant.components.sentry
|
||||
sentry-sdk==1.45.1
|
||||
sentry-sdk==2.48.0
|
||||
|
||||
# homeassistant.components.homeassistant_hardware
|
||||
# homeassistant.components.zha
|
||||
@@ -2896,7 +2896,7 @@ solaredge-local==0.2.3
|
||||
solaredge-web==0.0.1
|
||||
|
||||
# homeassistant.components.solarlog
|
||||
solarlog_cli==0.6.1
|
||||
solarlog_cli==0.7.0
|
||||
|
||||
# homeassistant.components.solax
|
||||
solax==3.2.3
|
||||
@@ -3078,7 +3078,7 @@ typedmonarchmoney==0.4.4
|
||||
uasiren==0.0.1
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
uiprotect==7.33.3
|
||||
uiprotect==8.0.0
|
||||
|
||||
# homeassistant.components.landisgyr_heat_meter
|
||||
ultraheat-api==0.5.7
|
||||
@@ -3277,7 +3277,7 @@ zeroconf==0.148.0
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==0.0.82
|
||||
zha==0.0.83
|
||||
|
||||
# homeassistant.components.zhong_hong
|
||||
zhong-hong-hvac==1.0.13
|
||||
|
||||
32
requirements_test_all.txt
generated
32
requirements_test_all.txt
generated
@@ -375,7 +375,7 @@ aiorussound==4.9.0
|
||||
aioruuvigateway==0.1.0
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==13.23.0
|
||||
aioshelly==13.23.1
|
||||
|
||||
# homeassistant.components.skybell
|
||||
aioskybell==22.7.0
|
||||
@@ -417,7 +417,7 @@ aiousbwatcher==1.1.1
|
||||
aiovlc==0.5.1
|
||||
|
||||
# homeassistant.components.vodafone_station
|
||||
aiovodafone==3.0.0
|
||||
aiovodafone==3.1.1
|
||||
|
||||
# homeassistant.components.waqi
|
||||
aiowaqi==3.1.0
|
||||
@@ -1015,7 +1015,7 @@ growattServer==1.7.1
|
||||
gspread==5.5.0
|
||||
|
||||
# homeassistant.components.profiler
|
||||
guppy3==3.1.5;python_version<'3.14'
|
||||
guppy3==3.1.6
|
||||
|
||||
# homeassistant.components.iaqualink
|
||||
h2==4.3.0
|
||||
@@ -1071,10 +1071,10 @@ hole==0.9.0
|
||||
holidays==0.84
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20251229.0
|
||||
home-assistant-frontend==20251229.1
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.1.1
|
||||
home-assistant-intents==2026.1.6
|
||||
|
||||
# homeassistant.components.gentex_homelink
|
||||
homelink-integration-api==0.0.1
|
||||
@@ -1445,9 +1445,6 @@ openai==2.11.0
|
||||
# homeassistant.components.openerz
|
||||
openerz-api==0.3.0
|
||||
|
||||
# homeassistant.components.openevse
|
||||
openevsewifi==1.1.2
|
||||
|
||||
# homeassistant.components.openhome
|
||||
openhomedevice==2.2.0
|
||||
|
||||
@@ -1598,7 +1595,7 @@ pyHomee==1.3.8
|
||||
pyRFXtrx==0.31.1
|
||||
|
||||
# homeassistant.components.tibber
|
||||
pyTibber==0.34.0
|
||||
pyTibber==0.34.1
|
||||
|
||||
# homeassistant.components.dlink
|
||||
pyW215==0.8.0
|
||||
@@ -1653,7 +1650,7 @@ pyblu==2.0.5
|
||||
pybotvac==0.0.28
|
||||
|
||||
# homeassistant.components.braviatv
|
||||
pybravia==0.3.4
|
||||
pybravia==0.4.1
|
||||
|
||||
# homeassistant.components.cloudflare
|
||||
pycfdns==3.0.0
|
||||
@@ -1804,7 +1801,7 @@ pyisy==3.4.1
|
||||
pyituran==0.1.5
|
||||
|
||||
# homeassistant.components.jvc_projector
|
||||
pyjvcprojector==1.1.2
|
||||
pyjvcprojector==1.1.3
|
||||
|
||||
# homeassistant.components.kaleidescape
|
||||
pykaleidescape==1.0.2
|
||||
@@ -2148,6 +2145,9 @@ python-open-router==0.3.3
|
||||
# homeassistant.components.swiss_public_transport
|
||||
python-opendata-transport==0.5.0
|
||||
|
||||
# homeassistant.components.openevse
|
||||
python-openevse-http==0.2.1
|
||||
|
||||
# homeassistant.components.opensky
|
||||
python-opensky==1.0.1
|
||||
|
||||
@@ -2168,7 +2168,7 @@ python-pooldose==0.8.1
|
||||
python-rabbitair==0.0.8
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==4.2.0
|
||||
python-roborock==4.2.1
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.46
|
||||
@@ -2380,7 +2380,7 @@ sensoterra==2.0.1
|
||||
sentence-stream==1.2.0
|
||||
|
||||
# homeassistant.components.sentry
|
||||
sentry-sdk==1.45.1
|
||||
sentry-sdk==2.48.0
|
||||
|
||||
# homeassistant.components.homeassistant_hardware
|
||||
# homeassistant.components.zha
|
||||
@@ -2423,7 +2423,7 @@ soco==0.30.14
|
||||
solaredge-web==0.0.1
|
||||
|
||||
# homeassistant.components.solarlog
|
||||
solarlog_cli==0.6.1
|
||||
solarlog_cli==0.7.0
|
||||
|
||||
# homeassistant.components.solax
|
||||
solax==3.2.3
|
||||
@@ -2572,7 +2572,7 @@ typedmonarchmoney==0.4.4
|
||||
uasiren==0.0.1
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
uiprotect==7.33.3
|
||||
uiprotect==8.0.0
|
||||
|
||||
# homeassistant.components.landisgyr_heat_meter
|
||||
ultraheat-api==0.5.7
|
||||
@@ -2741,7 +2741,7 @@ zeroconf==0.148.0
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==0.0.82
|
||||
zha==0.0.83
|
||||
|
||||
# homeassistant.components.zwave_js
|
||||
zwave-js-server-python==0.67.1
|
||||
|
||||
@@ -217,6 +217,9 @@ gql<4.0.0
|
||||
|
||||
# Pin pytest-rerunfailures to prevent accidental breaks
|
||||
pytest-rerunfailures==16.0.1
|
||||
|
||||
# Fixes detected blocking call to load_default_certs https://github.com/home-assistant/core/issues/157475
|
||||
aiomqtt>=2.5.0
|
||||
"""
|
||||
|
||||
GENERATED_MESSAGE = (
|
||||
|
||||
@@ -10,6 +10,8 @@ from homeassistant.const import (
|
||||
ATTR_DEVICE_ID,
|
||||
ATTR_FLOOR_ID,
|
||||
ATTR_LABEL_ID,
|
||||
CONF_ABOVE,
|
||||
CONF_BELOW,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_OPTIONS,
|
||||
CONF_PLATFORM,
|
||||
@@ -25,6 +27,12 @@ from homeassistant.helpers import (
|
||||
floor_registry as fr,
|
||||
label_registry as lr,
|
||||
)
|
||||
from homeassistant.helpers.trigger import (
|
||||
CONF_LOWER_LIMIT,
|
||||
CONF_THRESHOLD_TYPE,
|
||||
CONF_UPPER_LIMIT,
|
||||
ThresholdType,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry, mock_device_registry
|
||||
@@ -343,6 +351,123 @@ def parametrize_trigger_states(
|
||||
return tests
|
||||
|
||||
|
||||
def parametrize_numerical_attribute_changed_trigger_states(
|
||||
trigger: str, state: str, attribute: str
|
||||
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts for numerical changed triggers."""
|
||||
return [
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={},
|
||||
target_states=[
|
||||
(state, {attribute: 0}),
|
||||
(state, {attribute: 50}),
|
||||
(state, {attribute: 100}),
|
||||
],
|
||||
other_states=[(state, {attribute: None})],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={CONF_ABOVE: 10},
|
||||
target_states=[
|
||||
(state, {attribute: 50}),
|
||||
(state, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(state, {attribute: None}),
|
||||
(state, {attribute: 0}),
|
||||
],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={CONF_BELOW: 90},
|
||||
target_states=[
|
||||
(state, {attribute: 0}),
|
||||
(state, {attribute: 50}),
|
||||
],
|
||||
other_states=[
|
||||
(state, {attribute: None}),
|
||||
(state, {attribute: 100}),
|
||||
],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
trigger: str, state: str, attribute: str
|
||||
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts for numerical crossed threshold triggers."""
|
||||
return [
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(state, {attribute: 50}),
|
||||
(state, {attribute: 60}),
|
||||
],
|
||||
other_states=[
|
||||
(state, {attribute: None}),
|
||||
(state, {attribute: 0}),
|
||||
(state, {attribute: 100}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(state, {attribute: 0}),
|
||||
(state, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(state, {attribute: None}),
|
||||
(state, {attribute: 50}),
|
||||
(state, {attribute: 60}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.ABOVE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
},
|
||||
target_states=[
|
||||
(state, {attribute: 50}),
|
||||
(state, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(state, {attribute: None}),
|
||||
(state, {attribute: 0}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BELOW,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(state, {attribute: 0}),
|
||||
(state, {attribute: 50}),
|
||||
],
|
||||
other_states=[
|
||||
(state, {attribute: None}),
|
||||
(state, {attribute: 100}),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def arm_trigger(
|
||||
hass: HomeAssistant,
|
||||
trigger: str,
|
||||
|
||||
@@ -2232,6 +2232,202 @@ async def test_extraction_functions(
|
||||
assert automation.blueprint_in_automation(hass, "automation.test3") is None
|
||||
|
||||
|
||||
async def test_extraction_functions_with_targets(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test extraction functions with targets in triggers.
|
||||
|
||||
This test verifies that targets specified in trigger configurations
|
||||
(using new-style triggers that support target) are properly extracted for
|
||||
entity, device, area, floor, and label references.
|
||||
"""
|
||||
config_entry = MockConfigEntry(domain="fake_integration", data={})
|
||||
config_entry.mock_state(hass, ConfigEntryState.LOADED)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
trigger_device = device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:01")},
|
||||
)
|
||||
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
await async_setup_component(
|
||||
hass, "scene", {"scene": {"name": "test", "entities": {}}}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Enable the new_triggers_conditions feature flag to allow new-style triggers
|
||||
assert await async_setup_component(hass, "labs", {})
|
||||
ws_client = await hass_ws_client(hass)
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "automation",
|
||||
"preview_feature": "new_triggers_conditions",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
msg = await ws_client.receive_json()
|
||||
assert msg["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
DOMAIN,
|
||||
{
|
||||
DOMAIN: [
|
||||
{
|
||||
"alias": "test1",
|
||||
"triggers": [
|
||||
# Single entity_id in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {"entity_id": "scene.target_entity"},
|
||||
},
|
||||
# Multiple entity_ids in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {
|
||||
"entity_id": [
|
||||
"scene.target_entity_list1",
|
||||
"scene.target_entity_list2",
|
||||
]
|
||||
},
|
||||
},
|
||||
# Single device_id in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {"device_id": trigger_device.id},
|
||||
},
|
||||
# Multiple device_ids in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {
|
||||
"device_id": [
|
||||
"target-device-1",
|
||||
"target-device-2",
|
||||
]
|
||||
},
|
||||
},
|
||||
# Single area_id in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {"area_id": "area-target-single"},
|
||||
},
|
||||
# Multiple area_ids in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {"area_id": ["area-target-1", "area-target-2"]},
|
||||
},
|
||||
# Single floor_id in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {"floor_id": "floor-target-single"},
|
||||
},
|
||||
# Multiple floor_ids in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {
|
||||
"floor_id": ["floor-target-1", "floor-target-2"]
|
||||
},
|
||||
},
|
||||
# Single label_id in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {"label_id": "label-target-single"},
|
||||
},
|
||||
# Multiple label_ids in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {
|
||||
"label_id": ["label-target-1", "label-target-2"]
|
||||
},
|
||||
},
|
||||
# Combined targets
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {
|
||||
"entity_id": "scene.combined_entity",
|
||||
"device_id": "combined-device",
|
||||
"area_id": "combined-area",
|
||||
"floor_id": "combined-floor",
|
||||
"label_id": "combined-label",
|
||||
},
|
||||
},
|
||||
],
|
||||
"conditions": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "test.script",
|
||||
"data": {"entity_id": "light.action_entity"},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
# Test entity extraction from trigger targets
|
||||
assert set(automation.entities_in_automation(hass, "automation.test1")) == {
|
||||
"scene.target_entity",
|
||||
"scene.target_entity_list1",
|
||||
"scene.target_entity_list2",
|
||||
"scene.combined_entity",
|
||||
"light.action_entity",
|
||||
}
|
||||
|
||||
# Test device extraction from trigger targets
|
||||
assert set(automation.devices_in_automation(hass, "automation.test1")) == {
|
||||
trigger_device.id,
|
||||
"target-device-1",
|
||||
"target-device-2",
|
||||
"combined-device",
|
||||
}
|
||||
|
||||
# Test area extraction from trigger targets
|
||||
assert set(automation.areas_in_automation(hass, "automation.test1")) == {
|
||||
"area-target-single",
|
||||
"area-target-1",
|
||||
"area-target-2",
|
||||
"combined-area",
|
||||
}
|
||||
|
||||
# Test floor extraction from trigger targets
|
||||
assert set(automation.floors_in_automation(hass, "automation.test1")) == {
|
||||
"floor-target-single",
|
||||
"floor-target-1",
|
||||
"floor-target-2",
|
||||
"combined-floor",
|
||||
}
|
||||
|
||||
# Test label extraction from trigger targets
|
||||
assert set(automation.labels_in_automation(hass, "automation.test1")) == {
|
||||
"label-target-single",
|
||||
"label-target-1",
|
||||
"label-target-2",
|
||||
"combined-label",
|
||||
}
|
||||
|
||||
# Test automations_with_* functions
|
||||
assert set(automation.automations_with_entity(hass, "scene.target_entity")) == {
|
||||
"automation.test1"
|
||||
}
|
||||
assert set(automation.automations_with_device(hass, trigger_device.id)) == {
|
||||
"automation.test1"
|
||||
}
|
||||
assert set(automation.automations_with_area(hass, "area-target-single")) == {
|
||||
"automation.test1"
|
||||
}
|
||||
assert set(automation.automations_with_floor(hass, "floor-target-single")) == {
|
||||
"automation.test1"
|
||||
}
|
||||
assert set(automation.automations_with_label(hass, "label-target-single")) == {
|
||||
"automation.test1"
|
||||
}
|
||||
|
||||
|
||||
async def test_logbook_humanify_automation_triggered_event(hass: HomeAssistant) -> None:
|
||||
"""Test humanifying Automation Trigger event."""
|
||||
hass.config.components.add("recorder")
|
||||
|
||||
@@ -13,6 +13,7 @@ import pytest
|
||||
from homeassistant.components.braviatv.const import (
|
||||
CONF_NICKNAME,
|
||||
CONF_USE_PSK,
|
||||
CONF_USE_SSL,
|
||||
DOMAIN,
|
||||
NICKNAME_PREFIX,
|
||||
)
|
||||
@@ -131,7 +132,7 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None:
|
||||
assert result["step_id"] == "authorize"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_USE_PSK: False}
|
||||
result["flow_id"], user_input={CONF_USE_PSK: False, CONF_USE_SSL: False}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
@@ -148,6 +149,7 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None:
|
||||
CONF_HOST: "bravia-host",
|
||||
CONF_PIN: "1234",
|
||||
CONF_USE_PSK: False,
|
||||
CONF_USE_SSL: False,
|
||||
CONF_MAC: "AA:BB:CC:DD:EE:FF",
|
||||
CONF_CLIENT_ID: uuid,
|
||||
CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}",
|
||||
@@ -307,8 +309,17 @@ async def test_duplicate_error(hass: HomeAssistant) -> None:
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_create_entry(hass: HomeAssistant) -> None:
|
||||
"""Test that entry is added correctly with PIN auth."""
|
||||
@pytest.mark.parametrize(
|
||||
("use_psk", "use_ssl"),
|
||||
[
|
||||
(True, False),
|
||||
(False, False),
|
||||
(True, True),
|
||||
(False, True),
|
||||
],
|
||||
)
|
||||
async def test_create_entry(hass: HomeAssistant, use_psk, use_ssl) -> None:
|
||||
"""Test that entry is added correctly."""
|
||||
uuid = await instance_id.async_get(hass)
|
||||
|
||||
with (
|
||||
@@ -328,14 +339,14 @@ async def test_create_entry(hass: HomeAssistant) -> None:
|
||||
assert result["step_id"] == "authorize"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_USE_PSK: False}
|
||||
result["flow_id"], user_input={CONF_USE_PSK: use_psk, CONF_USE_SSL: use_ssl}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "pin"
|
||||
assert result["step_id"] == "psk" if use_psk else "pin"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_PIN: "1234"}
|
||||
result["flow_id"], user_input={CONF_PIN: "secret"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
@@ -343,50 +354,18 @@ async def test_create_entry(hass: HomeAssistant) -> None:
|
||||
assert result["title"] == "BRAVIA TV-Model"
|
||||
assert result["data"] == {
|
||||
CONF_HOST: "bravia-host",
|
||||
CONF_PIN: "1234",
|
||||
CONF_USE_PSK: False,
|
||||
CONF_MAC: "AA:BB:CC:DD:EE:FF",
|
||||
CONF_CLIENT_ID: uuid,
|
||||
CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}",
|
||||
}
|
||||
|
||||
|
||||
async def test_create_entry_psk(hass: HomeAssistant) -> None:
|
||||
"""Test that entry is added correctly with PSK auth."""
|
||||
with (
|
||||
patch("pybravia.BraviaClient.connect"),
|
||||
patch("pybravia.BraviaClient.set_wol_mode"),
|
||||
patch(
|
||||
"pybravia.BraviaClient.get_system_info",
|
||||
return_value=BRAVIA_SYSTEM_INFO,
|
||||
),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "authorize"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_USE_PSK: True}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "psk"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_PIN: "mypsk"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["result"].unique_id == "very_unique_string"
|
||||
assert result["title"] == "BRAVIA TV-Model"
|
||||
assert result["data"] == {
|
||||
CONF_HOST: "bravia-host",
|
||||
CONF_PIN: "mypsk",
|
||||
CONF_USE_PSK: True,
|
||||
CONF_PIN: "secret",
|
||||
CONF_USE_PSK: use_psk,
|
||||
CONF_USE_SSL: use_ssl,
|
||||
CONF_MAC: "AA:BB:CC:DD:EE:FF",
|
||||
**(
|
||||
{
|
||||
CONF_CLIENT_ID: uuid,
|
||||
CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}",
|
||||
}
|
||||
if not use_psk
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,25 +20,19 @@ from homeassistant.components.climate.trigger import CONF_HVAC_MODE
|
||||
from homeassistant.const import (
|
||||
ATTR_LABEL_ID,
|
||||
ATTR_TEMPERATURE,
|
||||
CONF_ABOVE,
|
||||
CONF_BELOW,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_OPTIONS,
|
||||
CONF_TARGET,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers.trigger import (
|
||||
CONF_LOWER_LIMIT,
|
||||
CONF_THRESHOLD_TYPE,
|
||||
CONF_UPPER_LIMIT,
|
||||
ThresholdType,
|
||||
async_validate_trigger_config,
|
||||
)
|
||||
from homeassistant.helpers.trigger import async_validate_trigger_config
|
||||
|
||||
from tests.components import (
|
||||
StateDescription,
|
||||
arm_trigger,
|
||||
other_states,
|
||||
parametrize_numerical_attribute_changed_trigger_states,
|
||||
parametrize_numerical_attribute_crossed_threshold_trigger_states,
|
||||
parametrize_target_entities,
|
||||
parametrize_trigger_states,
|
||||
set_or_remove_state,
|
||||
@@ -153,123 +147,6 @@ async def test_climate_trigger_validation(
|
||||
)
|
||||
|
||||
|
||||
def parametrize_xxx_changed_trigger_states(
|
||||
trigger: str, attribute: str
|
||||
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts for xxx_changed triggers."""
|
||||
return [
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={},
|
||||
target_states=[
|
||||
(HVACMode.AUTO, {attribute: 0}),
|
||||
(HVACMode.AUTO, {attribute: 50}),
|
||||
(HVACMode.AUTO, {attribute: 100}),
|
||||
],
|
||||
other_states=[(HVACMode.AUTO, {attribute: None})],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={CONF_ABOVE: 10},
|
||||
target_states=[
|
||||
(HVACMode.AUTO, {attribute: 50}),
|
||||
(HVACMode.AUTO, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(HVACMode.AUTO, {attribute: None}),
|
||||
(HVACMode.AUTO, {attribute: 0}),
|
||||
],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={CONF_BELOW: 90},
|
||||
target_states=[
|
||||
(HVACMode.AUTO, {attribute: 0}),
|
||||
(HVACMode.AUTO, {attribute: 50}),
|
||||
],
|
||||
other_states=[
|
||||
(HVACMode.AUTO, {attribute: None}),
|
||||
(HVACMode.AUTO, {attribute: 100}),
|
||||
],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def parametrize_xxx_crossed_threshold_trigger_states(
|
||||
trigger: str, attribute: str
|
||||
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts for xxx_crossed_threshold triggers."""
|
||||
return [
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(HVACMode.AUTO, {attribute: 50}),
|
||||
(HVACMode.AUTO, {attribute: 60}),
|
||||
],
|
||||
other_states=[
|
||||
(HVACMode.AUTO, {attribute: None}),
|
||||
(HVACMode.AUTO, {attribute: 0}),
|
||||
(HVACMode.AUTO, {attribute: 100}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(HVACMode.AUTO, {attribute: 0}),
|
||||
(HVACMode.AUTO, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(HVACMode.AUTO, {attribute: None}),
|
||||
(HVACMode.AUTO, {attribute: 50}),
|
||||
(HVACMode.AUTO, {attribute: 60}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.ABOVE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
},
|
||||
target_states=[
|
||||
(HVACMode.AUTO, {attribute: 50}),
|
||||
(HVACMode.AUTO, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(HVACMode.AUTO, {attribute: None}),
|
||||
(HVACMode.AUTO, {attribute: 0}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BELOW,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(HVACMode.AUTO, {attribute: 0}),
|
||||
(HVACMode.AUTO, {attribute: 50}),
|
||||
],
|
||||
other_states=[
|
||||
(HVACMode.AUTO, {attribute: None}),
|
||||
(HVACMode.AUTO, {attribute: 100}),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
@@ -351,29 +228,37 @@ async def test_climate_state_trigger_behavior_any(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_xxx_changed_trigger_states(
|
||||
"climate.current_humidity_changed", ATTR_CURRENT_HUMIDITY
|
||||
*parametrize_numerical_attribute_changed_trigger_states(
|
||||
"climate.current_humidity_changed", HVACMode.AUTO, ATTR_CURRENT_HUMIDITY
|
||||
),
|
||||
*parametrize_xxx_changed_trigger_states(
|
||||
"climate.current_temperature_changed", ATTR_CURRENT_TEMPERATURE
|
||||
*parametrize_numerical_attribute_changed_trigger_states(
|
||||
"climate.current_temperature_changed",
|
||||
HVACMode.AUTO,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
),
|
||||
*parametrize_xxx_changed_trigger_states(
|
||||
"climate.target_humidity_changed", ATTR_HUMIDITY
|
||||
*parametrize_numerical_attribute_changed_trigger_states(
|
||||
"climate.target_humidity_changed", HVACMode.AUTO, ATTR_HUMIDITY
|
||||
),
|
||||
*parametrize_xxx_changed_trigger_states(
|
||||
"climate.target_temperature_changed", ATTR_TEMPERATURE
|
||||
*parametrize_numerical_attribute_changed_trigger_states(
|
||||
"climate.target_temperature_changed", HVACMode.AUTO, ATTR_TEMPERATURE
|
||||
),
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"climate.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.current_humidity_crossed_threshold",
|
||||
HVACMode.AUTO,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"climate.current_temperature_crossed_threshold", ATTR_CURRENT_TEMPERATURE
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.current_temperature_crossed_threshold",
|
||||
HVACMode.AUTO,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
),
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"climate.target_humidity_crossed_threshold", ATTR_HUMIDITY
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
|
||||
),
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"climate.target_temperature_crossed_threshold", ATTR_TEMPERATURE
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.target_temperature_crossed_threshold",
|
||||
HVACMode.AUTO,
|
||||
ATTR_TEMPERATURE,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="climate.started_cooling",
|
||||
@@ -512,17 +397,23 @@ async def test_climate_state_trigger_behavior_first(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"climate.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.current_humidity_crossed_threshold",
|
||||
HVACMode.AUTO,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"climate.current_temperature_crossed_threshold", ATTR_CURRENT_TEMPERATURE
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.current_temperature_crossed_threshold",
|
||||
HVACMode.AUTO,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
),
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"climate.target_humidity_crossed_threshold", ATTR_HUMIDITY
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
|
||||
),
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"climate.target_temperature_crossed_threshold", ATTR_TEMPERATURE
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.target_temperature_crossed_threshold",
|
||||
HVACMode.AUTO,
|
||||
ATTR_TEMPERATURE,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="climate.started_cooling",
|
||||
@@ -661,17 +552,23 @@ async def test_climate_state_trigger_behavior_last(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"climate.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.current_humidity_crossed_threshold",
|
||||
HVACMode.AUTO,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"climate.current_temperature_crossed_threshold", ATTR_CURRENT_TEMPERATURE
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.current_temperature_crossed_threshold",
|
||||
HVACMode.AUTO,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
),
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"climate.target_humidity_crossed_threshold", ATTR_HUMIDITY
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
|
||||
),
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"climate.target_temperature_crossed_threshold", ATTR_TEMPERATURE
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.target_temperature_crossed_threshold",
|
||||
HVACMode.AUTO,
|
||||
ATTR_TEMPERATURE,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="climate.started_cooling",
|
||||
|
||||
@@ -72,3 +72,183 @@ async def test_energy_preferences_migration_from_old_version(
|
||||
assert manager.data is not None
|
||||
assert "device_consumption_water" in manager.data
|
||||
assert manager.data["device_consumption_water"] == []
|
||||
|
||||
|
||||
async def test_battery_power_config_inverted_sets_stat_rate(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test that battery with inverted power_config sets stat_rate to generated entity_id."""
|
||||
manager = EnergyManager(hass)
|
||||
await manager.async_initialize()
|
||||
manager.data = manager.default_preferences()
|
||||
|
||||
await manager.async_update(
|
||||
{
|
||||
"energy_sources": [
|
||||
{
|
||||
"type": "battery",
|
||||
"stat_energy_from": "sensor.battery_energy_from",
|
||||
"stat_energy_to": "sensor.battery_energy_to",
|
||||
"power_config": {
|
||||
"stat_rate_inverted": "sensor.battery_power",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Verify stat_rate was set to the expected entity_id
|
||||
assert manager.data is not None
|
||||
assert len(manager.data["energy_sources"]) == 1
|
||||
source = manager.data["energy_sources"][0]
|
||||
assert source["stat_rate"] == "sensor.battery_power_inverted"
|
||||
# Verify power_config is preserved
|
||||
assert source["power_config"] == {"stat_rate_inverted": "sensor.battery_power"}
|
||||
|
||||
|
||||
async def test_battery_power_config_two_sensors_sets_stat_rate(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test that battery with two-sensor power_config sets stat_rate."""
|
||||
manager = EnergyManager(hass)
|
||||
await manager.async_initialize()
|
||||
manager.data = manager.default_preferences()
|
||||
|
||||
await manager.async_update(
|
||||
{
|
||||
"energy_sources": [
|
||||
{
|
||||
"type": "battery",
|
||||
"stat_energy_from": "sensor.battery_energy_from",
|
||||
"stat_energy_to": "sensor.battery_energy_to",
|
||||
"power_config": {
|
||||
"stat_rate_from": "sensor.battery_discharge",
|
||||
"stat_rate_to": "sensor.battery_charge",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert manager.data is not None
|
||||
source = manager.data["energy_sources"][0]
|
||||
# Entity ID includes discharge sensor name to avoid collisions
|
||||
assert source["stat_rate"] == "sensor.energy_battery_battery_discharge_power"
|
||||
|
||||
|
||||
async def test_grid_power_config_inverted_sets_stat_rate(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test that grid with inverted power_config sets stat_rate."""
|
||||
manager = EnergyManager(hass)
|
||||
await manager.async_initialize()
|
||||
manager.data = manager.default_preferences()
|
||||
|
||||
await manager.async_update(
|
||||
{
|
||||
"energy_sources": [
|
||||
{
|
||||
"type": "grid",
|
||||
"flow_from": [],
|
||||
"flow_to": [],
|
||||
"power": [
|
||||
{
|
||||
"power_config": {
|
||||
"stat_rate_inverted": "sensor.grid_power",
|
||||
},
|
||||
}
|
||||
],
|
||||
"cost_adjustment_day": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert manager.data is not None
|
||||
grid_source = manager.data["energy_sources"][0]
|
||||
assert grid_source["power"][0]["stat_rate"] == "sensor.grid_power_inverted"
|
||||
|
||||
|
||||
async def test_power_config_standard_uses_stat_rate_directly(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test that power_config with standard stat_rate uses it directly."""
|
||||
manager = EnergyManager(hass)
|
||||
await manager.async_initialize()
|
||||
manager.data = manager.default_preferences()
|
||||
|
||||
await manager.async_update(
|
||||
{
|
||||
"energy_sources": [
|
||||
{
|
||||
"type": "battery",
|
||||
"stat_energy_from": "sensor.battery_energy_from",
|
||||
"stat_energy_to": "sensor.battery_energy_to",
|
||||
"power_config": {
|
||||
"stat_rate": "sensor.battery_power",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert manager.data is not None
|
||||
source = manager.data["energy_sources"][0]
|
||||
# stat_rate should be set directly from power_config.stat_rate
|
||||
assert source["stat_rate"] == "sensor.battery_power"
|
||||
|
||||
|
||||
async def test_battery_without_power_config_unchanged(hass: HomeAssistant) -> None:
|
||||
"""Test that battery without power_config is unchanged."""
|
||||
manager = EnergyManager(hass)
|
||||
await manager.async_initialize()
|
||||
manager.data = manager.default_preferences()
|
||||
|
||||
await manager.async_update(
|
||||
{
|
||||
"energy_sources": [
|
||||
{
|
||||
"type": "battery",
|
||||
"stat_energy_from": "sensor.battery_energy_from",
|
||||
"stat_energy_to": "sensor.battery_energy_to",
|
||||
"stat_rate": "sensor.battery_power",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert manager.data is not None
|
||||
source = manager.data["energy_sources"][0]
|
||||
assert source["stat_rate"] == "sensor.battery_power"
|
||||
assert "power_config" not in source
|
||||
|
||||
|
||||
async def test_power_config_takes_precedence_over_stat_rate(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test that power_config takes precedence when both are provided."""
|
||||
manager = EnergyManager(hass)
|
||||
await manager.async_initialize()
|
||||
manager.data = manager.default_preferences()
|
||||
|
||||
# Frontend sends both stat_rate and power_config
|
||||
await manager.async_update(
|
||||
{
|
||||
"energy_sources": [
|
||||
{
|
||||
"type": "battery",
|
||||
"stat_energy_from": "sensor.battery_energy_from",
|
||||
"stat_energy_to": "sensor.battery_energy_to",
|
||||
"stat_rate": "sensor.battery_power", # This should be ignored
|
||||
"power_config": {
|
||||
"stat_rate_inverted": "sensor.battery_power",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert manager.data is not None
|
||||
source = manager.data["energy_sources"][0]
|
||||
# stat_rate should be overwritten to point to the generated inverted sensor
|
||||
assert source["stat_rate"] == "sensor.battery_power_inverted"
|
||||
|
||||
1248
tests/components/energy/test_power_sensor.py
Normal file
1248
tests/components/energy/test_power_sensor.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,25 +11,14 @@ from homeassistant.components.humidifier.const import (
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
HumidifierAction,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_LABEL_ID,
|
||||
CONF_ABOVE,
|
||||
CONF_BELOW,
|
||||
CONF_ENTITY_ID,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers.trigger import (
|
||||
CONF_LOWER_LIMIT,
|
||||
CONF_THRESHOLD_TYPE,
|
||||
CONF_UPPER_LIMIT,
|
||||
ThresholdType,
|
||||
)
|
||||
|
||||
from tests.components import (
|
||||
StateDescription,
|
||||
arm_trigger,
|
||||
parametrize_numerical_attribute_changed_trigger_states,
|
||||
parametrize_numerical_attribute_crossed_threshold_trigger_states,
|
||||
parametrize_target_entities,
|
||||
parametrize_trigger_states,
|
||||
set_or_remove_state,
|
||||
@@ -82,123 +71,6 @@ async def test_humidifier_triggers_gated_by_labs_flag(
|
||||
) in caplog.text
|
||||
|
||||
|
||||
def parametrize_xxx_changed_trigger_states(
|
||||
trigger: str, attribute: str
|
||||
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts for xxx_changed triggers."""
|
||||
return [
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
other_states=[(STATE_ON, {attribute: None})],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={CONF_ABOVE: 10},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 0}),
|
||||
],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={CONF_BELOW: 90},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 50}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def parametrize_xxx_crossed_threshold_trigger_states(
|
||||
trigger: str, attribute: str
|
||||
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts for xxx_crossed_threshold triggers."""
|
||||
return [
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 60}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 60}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.ABOVE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 0}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BELOW,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 50}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
@@ -265,11 +137,13 @@ async def test_humidifier_state_trigger_behavior_any(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_xxx_changed_trigger_states(
|
||||
"humidifier.current_humidity_changed", ATTR_CURRENT_HUMIDITY
|
||||
*parametrize_numerical_attribute_changed_trigger_states(
|
||||
"humidifier.current_humidity_changed", STATE_ON, ATTR_CURRENT_HUMIDITY
|
||||
),
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"humidifier.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"humidifier.current_humidity_crossed_threshold",
|
||||
STATE_ON,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.started_drying",
|
||||
@@ -386,8 +260,10 @@ async def test_humidifier_state_trigger_behavior_first(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"humidifier.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"humidifier.current_humidity_crossed_threshold",
|
||||
STATE_ON,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.started_drying",
|
||||
@@ -504,8 +380,10 @@ async def test_humidifier_state_trigger_behavior_last(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"humidifier.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"humidifier.current_humidity_crossed_threshold",
|
||||
STATE_ON,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.started_drying",
|
||||
|
||||
@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
@@ -66,3 +66,43 @@ async def test_fail_query(
|
||||
assert len(hass.states.async_entity_ids()) == 6
|
||||
departure_sensor = hass.states.get("sensor.mock_title_departure")
|
||||
assert departure_sensor.state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_no_departures(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
mock_israelrail: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test handling when there are no departures available."""
|
||||
await init_integration(hass, mock_config_entry)
|
||||
assert len(hass.states.async_entity_ids()) == 6
|
||||
|
||||
# Simulate no departures (e.g., after-hours)
|
||||
mock_israelrail.query.return_value = []
|
||||
|
||||
await goto_future(hass, freezer)
|
||||
|
||||
# All sensors should still exist
|
||||
assert len(hass.states.async_entity_ids()) == 6
|
||||
|
||||
# Departure sensors should have unknown state (None)
|
||||
departure_sensor = hass.states.get("sensor.mock_title_departure")
|
||||
assert departure_sensor.state == STATE_UNKNOWN
|
||||
|
||||
departure_sensor_1 = hass.states.get("sensor.mock_title_departure_1")
|
||||
assert departure_sensor_1.state == STATE_UNKNOWN
|
||||
|
||||
departure_sensor_2 = hass.states.get("sensor.mock_title_departure_2")
|
||||
assert departure_sensor_2.state == STATE_UNKNOWN
|
||||
|
||||
# Non-departure sensors (platform, trains, train_number) also access index 0
|
||||
# and should have unknown state when no departures available
|
||||
platform_sensor = hass.states.get("sensor.mock_title_platform")
|
||||
assert platform_sensor.state == STATE_UNKNOWN
|
||||
|
||||
trains_sensor = hass.states.get("sensor.mock_title_trains")
|
||||
assert trains_sensor.state == STATE_UNKNOWN
|
||||
|
||||
train_number_sensor = hass.states.get("sensor.mock_title_train_number")
|
||||
assert train_number_sensor.state == STATE_UNKNOWN
|
||||
|
||||
@@ -7,25 +7,14 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.light import ATTR_BRIGHTNESS
|
||||
from homeassistant.const import (
|
||||
ATTR_LABEL_ID,
|
||||
CONF_ABOVE,
|
||||
CONF_BELOW,
|
||||
CONF_ENTITY_ID,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers.trigger import (
|
||||
CONF_LOWER_LIMIT,
|
||||
CONF_THRESHOLD_TYPE,
|
||||
CONF_UPPER_LIMIT,
|
||||
ThresholdType,
|
||||
)
|
||||
|
||||
from tests.components import (
|
||||
StateDescription,
|
||||
arm_trigger,
|
||||
parametrize_numerical_attribute_changed_trigger_states,
|
||||
parametrize_numerical_attribute_crossed_threshold_trigger_states,
|
||||
parametrize_target_entities,
|
||||
parametrize_trigger_states,
|
||||
set_or_remove_state,
|
||||
@@ -76,122 +65,6 @@ async def test_light_triggers_gated_by_labs_flag(
|
||||
) in caplog.text
|
||||
|
||||
|
||||
def parametrize_xxx_changed_trigger_states(
|
||||
trigger: str, attribute: str
|
||||
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts for xxx_changed triggers."""
|
||||
return [
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
other_states=[(STATE_ON, {attribute: None})],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={CONF_ABOVE: 10},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 0}),
|
||||
],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={CONF_BELOW: 90},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 50}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def parametrize_xxx_crossed_threshold_trigger_states(
|
||||
trigger: str, attribute: str
|
||||
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts for xxx_crossed_threshold triggers."""
|
||||
return [
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 60}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 60}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.ABOVE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 0}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BELOW,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 50}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
@@ -258,11 +131,11 @@ async def test_light_state_trigger_behavior_any(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_xxx_changed_trigger_states(
|
||||
"light.brightness_changed", ATTR_BRIGHTNESS
|
||||
*parametrize_numerical_attribute_changed_trigger_states(
|
||||
"light.brightness_changed", STATE_ON, ATTR_BRIGHTNESS
|
||||
),
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"light.brightness_crossed_threshold", ATTR_BRIGHTNESS
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"light.brightness_crossed_threshold", STATE_ON, ATTR_BRIGHTNESS
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -369,8 +242,8 @@ async def test_light_state_trigger_behavior_first(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"light.brightness_crossed_threshold", ATTR_BRIGHTNESS
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"light.brightness_crossed_threshold", STATE_ON, ATTR_BRIGHTNESS
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -477,8 +350,8 @@ async def test_light_state_trigger_behavior_last(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"light.brightness_crossed_threshold", ATTR_BRIGHTNESS
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"light.brightness_crossed_threshold", STATE_ON, ATTR_BRIGHTNESS
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -16,22 +16,23 @@ def mock_charger() -> Generator[MagicMock]:
|
||||
"""Create a mock OpenEVSE charger."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.openevse.openevsewifi.Charger",
|
||||
"homeassistant.components.openevse.OpenEVSE",
|
||||
autospec=True,
|
||||
) as mock,
|
||||
patch(
|
||||
"homeassistant.components.openevse.config_flow.openevsewifi.Charger",
|
||||
"homeassistant.components.openevse.config_flow.OpenEVSE",
|
||||
new=mock,
|
||||
),
|
||||
):
|
||||
charger = mock.return_value
|
||||
charger.getStatus.return_value = "Charging"
|
||||
charger.getChargeTimeElapsed.return_value = 3600 # 60 minutes in seconds
|
||||
charger.getAmbientTemperature.return_value = 25.5
|
||||
charger.getIRTemperature.return_value = 30.2
|
||||
charger.getRTCTemperature.return_value = 28.7
|
||||
charger.getUsageSession.return_value = 15000 # 15 kWh in Wh
|
||||
charger.getUsageTotal.return_value = 500000 # 500 kWh in Wh
|
||||
charger.update = AsyncMock()
|
||||
charger.status = "Charging"
|
||||
charger.charge_time_elapsed = 3600 # 60 minutes in seconds
|
||||
charger.ambient_temperature = 25.5
|
||||
charger.ir_temperature = 30.2
|
||||
charger.rtc_temperature = 28.7
|
||||
charger.usage_session = 15000 # 15 kWh in Wh
|
||||
charger.usage_total = 500000 # 500 kWh in Wh
|
||||
charger.charging_current = 32.0
|
||||
yield charger
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ async def test_user_flow_flaky(
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
mock_charger.getStatus.side_effect = AttributeError
|
||||
mock_charger.test_and_get.side_effect = TimeoutError
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_HOST: "10.0.0.131"},
|
||||
@@ -54,7 +54,7 @@ async def test_user_flow_flaky(
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"host": "cannot_connect"}
|
||||
|
||||
mock_charger.getStatus.side_effect = "Charging"
|
||||
mock_charger.test_and_get.side_effect = None
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_HOST: "10.0.0.131"},
|
||||
@@ -112,7 +112,7 @@ async def test_import_flow_bad(
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test import flow with bad charger."""
|
||||
mock_charger.getStatus.side_effect = AttributeError
|
||||
mock_charger.test_and_get.side_effect = TimeoutError
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_HOST: "10.0.0.131"}
|
||||
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
@@ -73,9 +72,6 @@ async def test_basic_usage(hass: HomeAssistant, tmp_path: Path) -> None:
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info >= (3, 14), reason="not yet available on Python 3.14"
|
||||
)
|
||||
async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None:
|
||||
"""Test we can setup and the service is registered."""
|
||||
test_dir = tmp_path / "profiles"
|
||||
@@ -107,24 +103,6 @@ async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None:
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info < (3, 14), reason="still works on python 3.13")
|
||||
async def test_memory_usage_py313(hass: HomeAssistant, tmp_path: Path) -> None:
|
||||
"""Test raise an error on python3.13."""
|
||||
entry = MockConfigEntry(domain=DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.services.has_service(DOMAIN, SERVICE_MEMORY)
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match="Memory profiling is not supported on Python 3.14. Please use Python 3.13.",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN, SERVICE_MEMORY, {CONF_SECONDS: 0.000001}, blocking=True
|
||||
)
|
||||
|
||||
|
||||
async def test_object_growth_logging(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
|
||||
@@ -325,6 +325,38 @@ async def test_camera_image(
|
||||
assert image.content == SMALLEST_VALID_JPEG_BYTES
|
||||
|
||||
|
||||
async def test_camera_live_view_no_subscription(
|
||||
hass: HomeAssistant,
|
||||
mock_ring_client,
|
||||
mock_ring_devices,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test live view camera skips recording URL when no subscription."""
|
||||
await setup_platform(hass, Platform.CAMERA)
|
||||
|
||||
front_camera_mock = mock_ring_devices.get_device(765432)
|
||||
# Set device to not have subscription
|
||||
front_camera_mock.has_subscription = False
|
||||
|
||||
state = hass.states.get("camera.front_live_view")
|
||||
assert state is not None
|
||||
|
||||
# Reset mock call counts
|
||||
front_camera_mock.async_recording_url.reset_mock()
|
||||
|
||||
# Trigger coordinator update
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
# For cameras without subscription, recording URL should NOT be fetched
|
||||
front_camera_mock.async_recording_url.assert_not_called()
|
||||
|
||||
# Requesting an image without subscription should raise an error
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await async_get_image(hass, "camera.front_live_view")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_camera_stream_attributes(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -751,62 +751,6 @@
|
||||
'state': 'sweep_moping',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.roborock_q7_total_cleaning_time-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.roborock_q7_total_cleaning_time',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
'sensor.private': dict({
|
||||
'suggested_unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Total cleaning time',
|
||||
'platform': 'roborock',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'total_cleaning_time',
|
||||
'unique_id': 'total_cleaning_time_q7_duid',
|
||||
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.roborock_q7_total_cleaning_time-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'duration',
|
||||
'friendly_name': 'Roborock Q7 Total cleaning time',
|
||||
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.roborock_q7_total_cleaning_time',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '50.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.roborock_s7_2_battery-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""The tests for the telegram.notify platform."""
|
||||
|
||||
from unittest.mock import patch
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, call, patch
|
||||
|
||||
from homeassistant import config as hass_config
|
||||
from homeassistant.components import notify
|
||||
from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TITLE
|
||||
from homeassistant.components.telegram import DOMAIN
|
||||
from homeassistant.const import SERVICE_RELOAD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, ServiceRegistry
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
@@ -54,3 +56,108 @@ async def test_reload_notify(
|
||||
issue_id="migrate_notify",
|
||||
)
|
||||
assert len(issue_registry.issues) == 1
|
||||
|
||||
|
||||
async def test_notify(hass: HomeAssistant) -> None:
|
||||
"""Test notify."""
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
notify.DOMAIN,
|
||||
{
|
||||
notify.DOMAIN: [
|
||||
{
|
||||
"name": DOMAIN,
|
||||
"platform": DOMAIN,
|
||||
"chat_id": 1,
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
original_call = ServiceRegistry.async_call
|
||||
with patch(
|
||||
"homeassistant.core.ServiceRegistry.async_call", new_callable=AsyncMock
|
||||
) as mock_service_call:
|
||||
# setup mock
|
||||
|
||||
async def call_service(*args, **kwargs) -> Any:
|
||||
if args[0] == notify.DOMAIN:
|
||||
return await original_call(
|
||||
hass.services, args[0], args[1], args[2], kwargs["blocking"]
|
||||
)
|
||||
return AsyncMock()
|
||||
|
||||
mock_service_call.side_effect = call_service
|
||||
|
||||
# test send message
|
||||
|
||||
data: dict[str, Any] = {"title": "mock title", "message": "mock message"}
|
||||
await hass.services.async_call(
|
||||
notify.DOMAIN,
|
||||
DOMAIN,
|
||||
{ATTR_TITLE: "mock title", ATTR_MESSAGE: "mock message"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_service_call.mock_calls == [
|
||||
call(
|
||||
"notify",
|
||||
"telegram",
|
||||
data,
|
||||
blocking=True,
|
||||
),
|
||||
call(
|
||||
"telegram_bot",
|
||||
"send_message",
|
||||
{"target": 1, "title": "mock title", "message": "mock message"},
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
]
|
||||
|
||||
mock_service_call.reset_mock()
|
||||
|
||||
# test send file
|
||||
|
||||
data = {
|
||||
ATTR_TITLE: "mock title",
|
||||
ATTR_MESSAGE: "mock message",
|
||||
ATTR_DATA: {
|
||||
"photo": {"url": "https://mock/photo.jpg", "caption": "mock caption"}
|
||||
},
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
notify.DOMAIN,
|
||||
DOMAIN,
|
||||
data,
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_service_call.mock_calls == [
|
||||
call(
|
||||
"notify",
|
||||
"telegram",
|
||||
data,
|
||||
blocking=True,
|
||||
),
|
||||
call(
|
||||
"telegram_bot",
|
||||
"send_photo",
|
||||
{
|
||||
"target": 1,
|
||||
"url": "https://mock/photo.jpg",
|
||||
"caption": "mock caption",
|
||||
},
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
"""Tests for the Tibber binary sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import tibber
|
||||
|
||||
from homeassistant.components.recorder import Recorder
|
||||
from homeassistant.components.tibber.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
def create_tibber_device_with_binary_sensors(
|
||||
device_id: str = "device-id",
|
||||
external_id: str = "external-id",
|
||||
name: str = "Test Device",
|
||||
brand: str = "Tibber",
|
||||
model: str = "Gen1",
|
||||
connector_status: str | None = "connected",
|
||||
charging_status: str | None = "charging",
|
||||
device_status: str | None = "on",
|
||||
home_id: str = "home-id",
|
||||
) -> tibber.data_api.TibberDevice:
|
||||
"""Create a fake Tibber Data API device with binary sensor capabilities."""
|
||||
device_data = {
|
||||
"id": device_id,
|
||||
"externalId": external_id,
|
||||
"info": {
|
||||
"name": name,
|
||||
"brand": brand,
|
||||
"model": model,
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"id": "connector.status",
|
||||
"value": connector_status,
|
||||
"description": "Connector status",
|
||||
"unit": "",
|
||||
},
|
||||
{
|
||||
"id": "charging.status",
|
||||
"value": charging_status,
|
||||
"description": "Charging status",
|
||||
"unit": "",
|
||||
},
|
||||
{
|
||||
"id": "onOff",
|
||||
"value": device_status,
|
||||
"description": "Device status",
|
||||
"unit": "",
|
||||
},
|
||||
],
|
||||
}
|
||||
return tibber.data_api.TibberDevice(device_data, home_id=home_id)
|
||||
|
||||
|
||||
async def test_binary_sensors_are_created(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
data_api_client_mock: AsyncMock,
|
||||
setup_credentials: None,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Ensure binary sensors are created from Data API devices."""
|
||||
device = create_tibber_device_with_binary_sensors()
|
||||
data_api_client_mock.get_all_devices = AsyncMock(return_value={"device-id": device})
|
||||
data_api_client_mock.update_devices = AsyncMock(return_value={"device-id": device})
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
connector_unique_id = "external-id_connector.status"
|
||||
connector_entity_id = entity_registry.async_get_entity_id(
|
||||
"binary_sensor", DOMAIN, connector_unique_id
|
||||
)
|
||||
assert connector_entity_id is not None
|
||||
state = hass.states.get(connector_entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "on"
|
||||
|
||||
charging_unique_id = "external-id_charging.status"
|
||||
charging_entity_id = entity_registry.async_get_entity_id(
|
||||
"binary_sensor", DOMAIN, charging_unique_id
|
||||
)
|
||||
assert charging_entity_id is not None
|
||||
state = hass.states.get(charging_entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "on"
|
||||
|
||||
device_unique_id = "external-id_onOff"
|
||||
device_entity_id = entity_registry.async_get_entity_id(
|
||||
"binary_sensor", DOMAIN, device_unique_id
|
||||
)
|
||||
assert device_entity_id is not None
|
||||
state = hass.states.get(device_entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "on"
|
||||
|
||||
|
||||
async def test_device_status_on(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
data_api_client_mock: AsyncMock,
|
||||
setup_credentials: None,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test device status on state."""
|
||||
device = create_tibber_device_with_binary_sensors(device_status="on")
|
||||
data_api_client_mock.get_all_devices = AsyncMock(return_value={"device-id": device})
|
||||
data_api_client_mock.update_devices = AsyncMock(return_value={"device-id": device})
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
unique_id = "external-id_onOff"
|
||||
entity_id = entity_registry.async_get_entity_id("binary_sensor", DOMAIN, unique_id)
|
||||
assert entity_id is not None
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "on"
|
||||
|
||||
|
||||
async def test_device_status_off(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
data_api_client_mock: AsyncMock,
|
||||
setup_credentials: None,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test device status off state."""
|
||||
device = create_tibber_device_with_binary_sensors(device_status="off")
|
||||
data_api_client_mock.get_all_devices = AsyncMock(return_value={"device-id": device})
|
||||
data_api_client_mock.update_devices = AsyncMock(return_value={"device-id": device})
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
unique_id = "external-id_onOff"
|
||||
entity_id = entity_registry.async_get_entity_id("binary_sensor", DOMAIN, unique_id)
|
||||
assert entity_id is not None
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "off"
|
||||
@@ -77,6 +77,20 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = {
|
||||
"Humidifier 6000s": [
|
||||
("post", "/cloud/v2/deviceManaged/bypassV2", "humidifier-6000s-detail.json")
|
||||
],
|
||||
"CS158-AF Air Fryer Standby": [
|
||||
(
|
||||
"post",
|
||||
"/cloud/v1/deviceManaged/bypass",
|
||||
"air-fryer-CS158-AF-detail-standby.json",
|
||||
)
|
||||
],
|
||||
"CS158-AF Air Fryer Cooking": [
|
||||
(
|
||||
"post",
|
||||
"/cloud/v1/deviceManaged/bypass",
|
||||
"air-fryer-CS158-AF-detail-cooking.json",
|
||||
)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"traceId": "1234",
|
||||
"code": 0,
|
||||
"msg": "request success",
|
||||
"module": null,
|
||||
"stacktrace": null,
|
||||
"result": {
|
||||
"returnStatus": {
|
||||
"curentTemp": 17,
|
||||
"cookSetTemp": 180,
|
||||
"mode": "manual",
|
||||
"cookSetTime": 15,
|
||||
"cookLastTime": 10,
|
||||
"cookStatus": "cooking",
|
||||
"tempUnit": "celsius",
|
||||
"accountId": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"traceId": "1234",
|
||||
"code": 0,
|
||||
"msg": "request success",
|
||||
"module": null,
|
||||
"stacktrace": null,
|
||||
"result": {
|
||||
"returnStatus": {
|
||||
"cookStatus": "standby"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,56 @@
|
||||
"wifiMac": "00:10:f0:aa:bb:cc",
|
||||
"mistLevel": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"deviceRegion": "EU",
|
||||
"isOwner": true,
|
||||
"authKey": null,
|
||||
"deviceName": "CS158-AF Air Fryer Standby",
|
||||
"deviceImg": "https://image.vesync.com/defaultImages/deviceDefaultImages/wifi_airfryer_cs158-af_eu_240.png",
|
||||
"cid": "CS158Standby",
|
||||
"deviceStatus": "off",
|
||||
"connectionStatus": "online",
|
||||
"connectionType": "wifi",
|
||||
"deviceType": "CS158-AF",
|
||||
"type": "SKA",
|
||||
"uuid": "##_REDACTED_##",
|
||||
"configModule": "WiFi_AirFryer_CS158-AF_EU",
|
||||
"macID": null,
|
||||
"mode": null,
|
||||
"speed": null,
|
||||
"currentFirmVersion": null,
|
||||
"subDeviceNo": null,
|
||||
"subDeviceType": null,
|
||||
"deviceFirstSetupTime": "Dec 3, 2025 1:52:19 PM",
|
||||
"subDeviceList": null,
|
||||
"extension": null,
|
||||
"deviceProp": null
|
||||
},
|
||||
{
|
||||
"deviceRegion": "EU",
|
||||
"isOwner": true,
|
||||
"authKey": null,
|
||||
"deviceName": "CS158-AF Air Fryer Cooking",
|
||||
"deviceImg": "https://image.vesync.com/defaultImages/deviceDefaultImages/wifi_airfryer_cs158-af_eu_240.png",
|
||||
"cid": "CS158Cooking",
|
||||
"deviceStatus": "off",
|
||||
"connectionStatus": "online",
|
||||
"connectionType": "wifi",
|
||||
"deviceType": "CS158-AF",
|
||||
"type": "SKA",
|
||||
"uuid": "##_REDACTED_##",
|
||||
"configModule": "WiFi_AirFryer_CS158-AF_EU",
|
||||
"macID": null,
|
||||
"mode": null,
|
||||
"speed": null,
|
||||
"currentFirmVersion": null,
|
||||
"subDeviceNo": null,
|
||||
"subDeviceType": null,
|
||||
"deviceFirstSetupTime": "Dec 3, 2025 1:52:19 PM",
|
||||
"subDeviceList": null,
|
||||
"extension": null,
|
||||
"deviceProp": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -147,6 +147,80 @@
|
||||
list([
|
||||
])
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Cooking][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vesync',
|
||||
'CS158Cooking',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'VeSync',
|
||||
'model': 'CS158-AF',
|
||||
'model_id': None,
|
||||
'name': 'CS158-AF Air Fryer Cooking',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Cooking][entities]
|
||||
list([
|
||||
])
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Standby][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vesync',
|
||||
'CS158Standby',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'VeSync',
|
||||
'model': 'CS158-AF',
|
||||
'model_id': None,
|
||||
'name': 'CS158-AF Air Fryer Standby',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Standby][entities]
|
||||
list([
|
||||
])
|
||||
# ---
|
||||
# name: test_sensor_state[Dimmable Light][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
|
||||
@@ -399,6 +399,80 @@
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_fan_state[CS158-AF Air Fryer Cooking][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vesync',
|
||||
'CS158Cooking',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'VeSync',
|
||||
'model': 'CS158-AF',
|
||||
'model_id': None,
|
||||
'name': 'CS158-AF Air Fryer Cooking',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_fan_state[CS158-AF Air Fryer Cooking][entities]
|
||||
list([
|
||||
])
|
||||
# ---
|
||||
# name: test_fan_state[CS158-AF Air Fryer Standby][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vesync',
|
||||
'CS158Standby',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'VeSync',
|
||||
'model': 'CS158-AF',
|
||||
'model_id': None,
|
||||
'name': 'CS158-AF Air Fryer Standby',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_fan_state[CS158-AF Air Fryer Standby][entities]
|
||||
list([
|
||||
])
|
||||
# ---
|
||||
# name: test_fan_state[Dimmable Light][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
|
||||
@@ -147,6 +147,80 @@
|
||||
list([
|
||||
])
|
||||
# ---
|
||||
# name: test_humidifier_state[CS158-AF Air Fryer Cooking][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vesync',
|
||||
'CS158Cooking',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'VeSync',
|
||||
'model': 'CS158-AF',
|
||||
'model_id': None,
|
||||
'name': 'CS158-AF Air Fryer Cooking',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_humidifier_state[CS158-AF Air Fryer Cooking][entities]
|
||||
list([
|
||||
])
|
||||
# ---
|
||||
# name: test_humidifier_state[CS158-AF Air Fryer Standby][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vesync',
|
||||
'CS158Standby',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'VeSync',
|
||||
'model': 'CS158-AF',
|
||||
'model_id': None,
|
||||
'name': 'CS158-AF Air Fryer Standby',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_humidifier_state[CS158-AF Air Fryer Standby][entities]
|
||||
list([
|
||||
])
|
||||
# ---
|
||||
# name: test_humidifier_state[Dimmable Light][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
|
||||
@@ -147,6 +147,80 @@
|
||||
list([
|
||||
])
|
||||
# ---
|
||||
# name: test_light_state[CS158-AF Air Fryer Cooking][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vesync',
|
||||
'CS158Cooking',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'VeSync',
|
||||
'model': 'CS158-AF',
|
||||
'model_id': None,
|
||||
'name': 'CS158-AF Air Fryer Cooking',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_light_state[CS158-AF Air Fryer Cooking][entities]
|
||||
list([
|
||||
])
|
||||
# ---
|
||||
# name: test_light_state[CS158-AF Air Fryer Standby][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vesync',
|
||||
'CS158Standby',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'VeSync',
|
||||
'model': 'CS158-AF',
|
||||
'model_id': None,
|
||||
'name': 'CS158-AF Air Fryer Standby',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_light_state[CS158-AF Air Fryer Standby][entities]
|
||||
list([
|
||||
])
|
||||
# ---
|
||||
# name: test_light_state[Dimmable Light][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
|
||||
@@ -587,6 +587,628 @@
|
||||
'state': '5',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Cooking][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vesync',
|
||||
'CS158Cooking',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'VeSync',
|
||||
'model': 'CS158-AF',
|
||||
'model_id': None,
|
||||
'name': 'CS158-AF Air Fryer Cooking',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Cooking][entities]
|
||||
list([
|
||||
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.cs158_af_air_fryer_cooking_current_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': 'Current temperature',
|
||||
'platform': 'vesync',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'current_temp',
|
||||
'unique_id': 'CS158Cooking-current_temp',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
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.cs158_af_air_fryer_cooking_cooking_set_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': 'Cooking set temperature',
|
||||
'platform': 'vesync',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'cook_set_temp',
|
||||
'unique_id': 'CS158Cooking-cook_set_temp',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
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': None,
|
||||
'entity_id': 'sensor.cs158_af_air_fryer_cooking_cooking_set_time',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Cooking set time',
|
||||
'platform': 'vesync',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'cook_set_time',
|
||||
'unique_id': 'CS158Cooking-cook_set_time',
|
||||
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||
}),
|
||||
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': None,
|
||||
'entity_id': 'sensor.cs158_af_air_fryer_cooking_preheating_set_time',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Preheating set time',
|
||||
'platform': 'vesync',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'preheat_set_time',
|
||||
'unique_id': 'CS158Cooking-preheat_set_time',
|
||||
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||
}),
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'cooking_end',
|
||||
'cooking',
|
||||
'cooking_stop',
|
||||
'heating',
|
||||
'preheat_end',
|
||||
'preheat_stop',
|
||||
'pull_out',
|
||||
'standby',
|
||||
]),
|
||||
}),
|
||||
'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.cs158_af_air_fryer_cooking_cooking_status',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Cooking status',
|
||||
'platform': 'vesync',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'cook_status',
|
||||
'unique_id': 'CS158Cooking-cook_status',
|
||||
'unit_of_measurement': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Cooking][sensor.cs158_af_air_fryer_cooking_cooking_set_temperature]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'CS158-AF Air Fryer Cooking Cooking set temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cs158_af_air_fryer_cooking_cooking_set_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '180',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Cooking][sensor.cs158_af_air_fryer_cooking_cooking_set_time]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'duration',
|
||||
'friendly_name': 'CS158-AF Air Fryer Cooking Cooking set time',
|
||||
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cs158_af_air_fryer_cooking_cooking_set_time',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '15',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Cooking][sensor.cs158_af_air_fryer_cooking_cooking_status]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'enum',
|
||||
'friendly_name': 'CS158-AF Air Fryer Cooking Cooking status',
|
||||
'options': list([
|
||||
'cooking_end',
|
||||
'cooking',
|
||||
'cooking_stop',
|
||||
'heating',
|
||||
'preheat_end',
|
||||
'preheat_stop',
|
||||
'pull_out',
|
||||
'standby',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cs158_af_air_fryer_cooking_cooking_status',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'cooking',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Cooking][sensor.cs158_af_air_fryer_cooking_current_temperature]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'CS158-AF Air Fryer Cooking Current temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cs158_af_air_fryer_cooking_current_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '17',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Cooking][sensor.cs158_af_air_fryer_cooking_preheating_set_time]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'duration',
|
||||
'friendly_name': 'CS158-AF Air Fryer Cooking Preheating set time',
|
||||
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cs158_af_air_fryer_cooking_preheating_set_time',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Standby][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vesync',
|
||||
'CS158Standby',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'VeSync',
|
||||
'model': 'CS158-AF',
|
||||
'model_id': None,
|
||||
'name': 'CS158-AF Air Fryer Standby',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Standby][entities]
|
||||
list([
|
||||
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.cs158_af_air_fryer_standby_current_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Current temperature',
|
||||
'platform': 'vesync',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'current_temp',
|
||||
'unique_id': 'CS158Standby-current_temp',
|
||||
'unit_of_measurement': None,
|
||||
}),
|
||||
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.cs158_af_air_fryer_standby_cooking_set_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Cooking set temperature',
|
||||
'platform': 'vesync',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'cook_set_temp',
|
||||
'unique_id': 'CS158Standby-cook_set_temp',
|
||||
'unit_of_measurement': None,
|
||||
}),
|
||||
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': None,
|
||||
'entity_id': 'sensor.cs158_af_air_fryer_standby_cooking_set_time',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Cooking set time',
|
||||
'platform': 'vesync',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'cook_set_time',
|
||||
'unique_id': 'CS158Standby-cook_set_time',
|
||||
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||
}),
|
||||
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': None,
|
||||
'entity_id': 'sensor.cs158_af_air_fryer_standby_preheating_set_time',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Preheating set time',
|
||||
'platform': 'vesync',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'preheat_set_time',
|
||||
'unique_id': 'CS158Standby-preheat_set_time',
|
||||
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||
}),
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'cooking_end',
|
||||
'cooking',
|
||||
'cooking_stop',
|
||||
'heating',
|
||||
'preheat_end',
|
||||
'preheat_stop',
|
||||
'pull_out',
|
||||
'standby',
|
||||
]),
|
||||
}),
|
||||
'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.cs158_af_air_fryer_standby_cooking_status',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Cooking status',
|
||||
'platform': 'vesync',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'cook_status',
|
||||
'unique_id': 'CS158Standby-cook_status',
|
||||
'unit_of_measurement': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Standby][sensor.cs158_af_air_fryer_standby_cooking_set_temperature]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'CS158-AF Air Fryer Standby Cooking set temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cs158_af_air_fryer_standby_cooking_set_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Standby][sensor.cs158_af_air_fryer_standby_cooking_set_time]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'duration',
|
||||
'friendly_name': 'CS158-AF Air Fryer Standby Cooking set time',
|
||||
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cs158_af_air_fryer_standby_cooking_set_time',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Standby][sensor.cs158_af_air_fryer_standby_cooking_status]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'enum',
|
||||
'friendly_name': 'CS158-AF Air Fryer Standby Cooking status',
|
||||
'options': list([
|
||||
'cooking_end',
|
||||
'cooking',
|
||||
'cooking_stop',
|
||||
'heating',
|
||||
'preheat_end',
|
||||
'preheat_stop',
|
||||
'pull_out',
|
||||
'standby',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cs158_af_air_fryer_standby_cooking_status',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'standby',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Standby][sensor.cs158_af_air_fryer_standby_current_temperature]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'CS158-AF Air Fryer Standby Current temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cs158_af_air_fryer_standby_current_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_state[CS158-AF Air Fryer Standby][sensor.cs158_af_air_fryer_standby_preheating_set_time]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'duration',
|
||||
'friendly_name': 'CS158-AF Air Fryer Standby Preheating set time',
|
||||
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cs158_af_air_fryer_standby_preheating_set_time',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_state[Dimmable Light][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
|
||||
@@ -469,6 +469,80 @@
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_switch_state[CS158-AF Air Fryer Cooking][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vesync',
|
||||
'CS158Cooking',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'VeSync',
|
||||
'model': 'CS158-AF',
|
||||
'model_id': None,
|
||||
'name': 'CS158-AF Air Fryer Cooking',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_switch_state[CS158-AF Air Fryer Cooking][entities]
|
||||
list([
|
||||
])
|
||||
# ---
|
||||
# name: test_switch_state[CS158-AF Air Fryer Standby][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vesync',
|
||||
'CS158Standby',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'VeSync',
|
||||
'model': 'CS158-AF',
|
||||
'model_id': None,
|
||||
'name': 'CS158-AF Air Fryer Standby',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_switch_state[CS158-AF Air Fryer Standby][entities]
|
||||
list([
|
||||
])
|
||||
# ---
|
||||
# name: test_switch_state[Dimmable Light][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
|
||||
@@ -383,6 +383,198 @@
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_update_state[CS158-AF Air Fryer Cooking][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vesync',
|
||||
'CS158Cooking',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'VeSync',
|
||||
'model': 'CS158-AF',
|
||||
'model_id': None,
|
||||
'name': 'CS158-AF Air Fryer Cooking',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_update_state[CS158-AF Air Fryer Cooking][entities]
|
||||
list([
|
||||
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': 'update',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'update.cs158_af_air_fryer_cooking_firmware',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <UpdateDeviceClass.FIRMWARE: 'firmware'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Firmware',
|
||||
'platform': 'vesync',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'CS158Cooking',
|
||||
'unit_of_measurement': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_update_state[CS158-AF Air Fryer Cooking][update.cs158_af_air_fryer_cooking_firmware]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'auto_update': False,
|
||||
'device_class': 'firmware',
|
||||
'display_precision': 0,
|
||||
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
|
||||
'friendly_name': 'CS158-AF Air Fryer Cooking Firmware',
|
||||
'in_progress': False,
|
||||
'installed_version': None,
|
||||
'latest_version': None,
|
||||
'release_summary': None,
|
||||
'release_url': None,
|
||||
'skipped_version': None,
|
||||
'supported_features': <UpdateEntityFeature: 0>,
|
||||
'title': None,
|
||||
'update_percentage': None,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'update.cs158_af_air_fryer_cooking_firmware',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_update_state[CS158-AF Air Fryer Standby][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vesync',
|
||||
'CS158Standby',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'VeSync',
|
||||
'model': 'CS158-AF',
|
||||
'model_id': None,
|
||||
'name': 'CS158-AF Air Fryer Standby',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_update_state[CS158-AF Air Fryer Standby][entities]
|
||||
list([
|
||||
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': 'update',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'update.cs158_af_air_fryer_standby_firmware',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <UpdateDeviceClass.FIRMWARE: 'firmware'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Firmware',
|
||||
'platform': 'vesync',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'CS158Standby',
|
||||
'unit_of_measurement': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_update_state[CS158-AF Air Fryer Standby][update.cs158_af_air_fryer_standby_firmware]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'auto_update': False,
|
||||
'device_class': 'firmware',
|
||||
'display_precision': 0,
|
||||
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
|
||||
'friendly_name': 'CS158-AF Air Fryer Standby Firmware',
|
||||
'in_progress': False,
|
||||
'installed_version': None,
|
||||
'latest_version': None,
|
||||
'release_summary': None,
|
||||
'release_url': None,
|
||||
'skipped_version': None,
|
||||
'supported_features': <UpdateEntityFeature: 0>,
|
||||
'title': None,
|
||||
'update_percentage': None,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'update.cs158_af_air_fryer_standby_firmware',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_update_state[Dimmable Light][devices]
|
||||
list([
|
||||
DeviceRegistryEntrySnapshot({
|
||||
|
||||
@@ -12,3 +12,4 @@ TEST_PASSWORD = "fake_password"
|
||||
TEST_TYPE = DeviceType.SERCOMM
|
||||
TEST_URL = f"https://{TEST_HOST}"
|
||||
TEST_USERNAME = "fake_username"
|
||||
TEST_SERIAL_NUMBER = "m123456789"
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
from .const import TEST_SERIAL_NUMBER
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
@@ -51,7 +52,7 @@ async def test_pressing_button(
|
||||
await hass.services.async_call(
|
||||
BUTTON_DOMAIN,
|
||||
SERVICE_PRESS,
|
||||
{ATTR_ENTITY_ID: "button.vodafone_station_m123456789_restart"},
|
||||
{ATTR_ENTITY_ID: f"button.vodafone_station_{TEST_SERIAL_NUMBER}_restart"},
|
||||
blocking=True,
|
||||
)
|
||||
mock_vodafone_station_router.restart_router.assert_called_once()
|
||||
@@ -84,7 +85,7 @@ async def test_button_fails(
|
||||
await hass.services.async_call(
|
||||
BUTTON_DOMAIN,
|
||||
SERVICE_PRESS,
|
||||
{ATTR_ENTITY_ID: "button.vodafone_station_m123456789_restart"},
|
||||
{ATTR_ENTITY_ID: f"button.vodafone_station_{TEST_SERIAL_NUMBER}_restart"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
from .const import TEST_SERIAL_NUMBER
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
|
||||
@@ -52,7 +53,9 @@ async def test_active_connection_type(
|
||||
"""Test device connection type."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
active_connection_entity = "sensor.vodafone_station_m123456789_active_connection"
|
||||
active_connection_entity = (
|
||||
f"sensor.vodafone_station_{TEST_SERIAL_NUMBER}_active_connection"
|
||||
)
|
||||
|
||||
assert (state := hass.states.get(active_connection_entity))
|
||||
assert state.state == STATE_UNKNOWN
|
||||
@@ -80,7 +83,7 @@ async def test_uptime(
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
uptime = "2024-11-19T20:19:00+00:00"
|
||||
uptime_entity = "sensor.vodafone_station_m123456789_uptime"
|
||||
uptime_entity = f"sensor.vodafone_station_{TEST_SERIAL_NUMBER}_uptime"
|
||||
|
||||
assert (state := hass.states.get(uptime_entity))
|
||||
assert state.state == uptime
|
||||
@@ -119,5 +122,7 @@ async def test_coordinator_client_connector_error(
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert (state := hass.states.get("sensor.vodafone_station_m123456789_uptime"))
|
||||
assert (
|
||||
state := hass.states.get(f"sensor.vodafone_station_{TEST_SERIAL_NUMBER}_uptime")
|
||||
)
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
@@ -101,3 +101,16 @@ def mock_config_entry() -> MockConfigEntry:
|
||||
entry_id="01J0BC4QM2YBRP6H5G933CETI8",
|
||||
unique_id=TEST_USER_ID,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="skip_cloud", autouse=True)
|
||||
def skip_cloud_fixture():
|
||||
"""Skip setting up cloud.
|
||||
|
||||
Cloud already has its own tests for account link.
|
||||
|
||||
We do not need to test it here as we only need to test our
|
||||
usage of the oauth2 helpers.
|
||||
"""
|
||||
with patch("homeassistant.components.cloud.async_setup", return_value=True):
|
||||
yield
|
||||
|
||||
@@ -1207,19 +1207,19 @@ async def test_subscribe_triggers_no_triggers(
|
||||
),
|
||||
# Test verbose choose selector options
|
||||
(
|
||||
{CONF_ABOVE: {"chosen_selector": "entity", "entity": "sensor.test"}},
|
||||
{CONF_ABOVE: {"active_choice": "entity", "entity": "sensor.test"}},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{CONF_ABOVE: {"chosen_selector": "number", "number": 10}},
|
||||
{CONF_ABOVE: {"active_choice": "number", "number": 10}},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{CONF_BELOW: {"chosen_selector": "entity", "entity": "sensor.test"}},
|
||||
{CONF_BELOW: {"active_choice": "entity", "entity": "sensor.test"}},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{CONF_BELOW: {"chosen_selector": "number", "number": 90}},
|
||||
{CONF_BELOW: {"active_choice": "number", "number": 90}},
|
||||
does_not_raise(),
|
||||
),
|
||||
# Test invalid configurations
|
||||
@@ -1235,7 +1235,7 @@ async def test_subscribe_triggers_no_triggers(
|
||||
),
|
||||
(
|
||||
# Invalid choose selector option
|
||||
{CONF_BELOW: {"chosen_selector": "cat", "cat": 90}},
|
||||
{CONF_BELOW: {"active_choice": "cat", "cat": 90}},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user