Compare commits

..

42 Commits

Author SHA1 Message Date
Petar Petrov
5f4ffd6f8a Add availability checks 2026-01-08 18:07:00 +02:00
Petar Petrov
294c93e3ed PR comments 2026-01-08 17:53:20 +02:00
Petar Petrov
51faa35f1b Update homeassistant/components/energy/data.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-08 17:35:05 +02:00
Petar Petrov
303a4091a7 Handle different units in combined sensors 2026-01-08 15:41:08 +02:00
Petar Petrov
fc9a86b919 naming 2026-01-08 15:15:18 +02:00
Petar Petrov
2be7b57e48 validation tweak 2026-01-08 14:09:37 +02:00
Petar Petrov
27ecfd1319 type fix 2026-01-08 10:59:25 +02:00
Petar Petrov
ade50c93cf typing 2026-01-08 09:28:33 +02:00
Petar Petrov
b029a48ed4 add tests 2026-01-08 09:09:03 +02:00
Petar Petrov
b05a6dadf6 Add non standard power sensor support 2026-01-07 16:51:59 +02:00
Simone Chemelli
6e32a2aa18 Bump aiovodafone to 3.1.1 (#160429) 2026-01-07 15:34:46 +01:00
Abílio Costa
3b575fe3e3 Support target triggers in automation relation extraction (#160369) 2026-01-07 15:15:44 +01:00
Joost Lekkerkerker
229400de98 Make Watts depend on the cloud integration (#160424) 2026-01-07 15:07:24 +01:00
Norbert Rittel
e963adfdf0 Fix capitalization in openevse data_description string (#160423) 2026-01-07 14:53:19 +01:00
Simone Chemelli
fd7bbc68c6 Bump aioshelly to 13.23.1 (#160420) 2026-01-07 14:49:18 +01:00
Robert Resch
9281ab018c Constraint aiomqtt>=2.5.0 to fix blocking call (#160410)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-07 14:21:49 +01:00
Andres Ruiz
80baf86e23 Add codeowners and integration_type for waterfurnace (#160397) 2026-01-07 13:12:58 +01:00
Simone Chemelli
db497b23fe Small cleanup for Vodafone Station tests (#160415) 2026-01-07 12:50:12 +01:00
cdnninja
a2fb8f5a72 Add Vesync Air Fryer Sensors (#160170)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-07 12:41:34 +01:00
hanwg
6953bd4599 Fix schema validation error in Telegram (#160367) 2026-01-07 12:27:17 +01:00
Xiangxuan Qu
225be65f71 Fix IndexError in Israel Rail sensor when no departures available (#160351)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-07 12:22:39 +01:00
momala454
7b0463f763 Add additional lens modes 4 to 10 to JVC projector remote (#159657)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-07 12:22:19 +01:00
Luke Lashley
4d305b657a Bump python-roborock to 4.2.1 (#160398) 2026-01-07 11:23:40 +01:00
Paul Tarjan
d5a553c8c7 Fix Ring integration log flooding for accounts without subscription (#158012)
Co-authored-by: Robert Resch <robert@resch.dev>
2026-01-07 11:14:05 +01:00
Ivan Dlugos
9169b68254 Bump sentry-sdk to 2.48.0 (#159415)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 11:05:38 +01:00
Colin
fde9bd95d5 Replace openevse backend library (#160325) 2026-01-07 10:25:15 +01:00
Marc Mueller
e4db8ff86e Update guppy3 to 3.1.6 (#160356) 2026-01-07 10:11:01 +01:00
Erik Montnemery
a084e51345 Add test helpers for numerical state triggers (#160308)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-01-07 08:53:35 +01:00
Luke Lashley
00381e6dfd Remove q7 total cleaning time for Roborock (#160399) 2026-01-06 20:27:09 -08:00
Michael Hansen
b6d493696a Bump intents to 2026.1.6 (#160389) 2026-01-06 17:11:54 -06:00
Artem Draft
5f0500c3cd Add SSL support in Bravia TV (#160373)
Co-authored-by: Maciej Bieniek <bieniu@users.noreply.github.com>
2026-01-06 23:59:47 +01:00
dontinelli
c61a63cc6f Bump solarlog_cli to 0.7.0 (#160382) 2026-01-06 23:59:16 +01:00
Raphael Hehl
5445a4f40f Bump uiprotect to 8.0.0 (#160384)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-01-06 23:57:19 +01:00
Daniel Hjelseth Høyer
2888cacc3f Bump pyTibber to 0.34.1 (#160380)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-06 23:56:26 +01:00
TheJulianJES
16f3e6d2c9 Bump ZHA to 0.0.83 (#160342) 2026-01-06 12:11:40 -05:00
Bram Kragten
7a872970fa Update frontend to 20251229.1 (#160372) 2026-01-06 17:53:56 +01:00
Bram Kragten
4f5ca986ce Fix number or entity choose schema (#160358) 2026-01-06 17:23:24 +01:00
Artem Draft
b58e058da5 Bump pybravia to 0.4.1 (#160368) 2026-01-06 16:42:58 +01:00
epenet
badebe0c7f Refactor Tuya event platform to use DeviceWrapper (#160366) 2026-01-06 16:09:13 +01:00
mettolen
7817ec1a52 Update Saunum integration to gold quality tier (#159783) 2026-01-06 16:07:28 +01:00
epenet
c773998946 Remove default in Tuya DeviceWrapper options (#160303) 2026-01-06 13:06:53 +01:00
Mika
2bc9397103 Fix missing state class to solaredge (#160336) 2026-01-06 12:36:49 +01:00
94 changed files with 4219 additions and 1089 deletions

1
CODEOWNERS generated
View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
}
),
)

View File

@@ -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"

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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"]
}

View File

@@ -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."""

View 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 ""

View File

@@ -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()

View File

@@ -23,5 +23,5 @@
"winter_mode": {}
},
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251229.0"]
"requirements": ["home-assistant-frontend==20251229.1"]
}

View File

@@ -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]
)

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["jvcprojector"],
"requirements": ["pyjvcprojector==1.1.2"]
"requirements": ["pyjvcprojector==1.1.3"]
}

View File

@@ -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,

View File

@@ -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])

View File

@@ -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

View File

@@ -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"]
}

View File

@@ -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"

View File

@@ -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."
}
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

View File

@@ -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}"
}

View File

@@ -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"
]
}

View File

@@ -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,
),
]

View File

@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pysaunum"],
"quality_scale": "silver",
"quality_scale": "gold",
"requirements": ["pysaunum==0.1.0"]
}

View File

@@ -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

View File

@@ -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"]
}

View File

@@ -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*",

View File

@@ -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,
),
]

View File

@@ -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"]
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"]
}

View File

@@ -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)

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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()

View File

@@ -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:

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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)

View File

@@ -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",
}

View File

@@ -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

View File

@@ -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": {

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aiovodafone"],
"quality_scale": "platinum",
"requirements": ["aiovodafone==3.0.0"]
"requirements": ["aiovodafone==3.1.1"]
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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*",

View File

@@ -7468,7 +7468,7 @@
},
"waterfurnace": {
"name": "WaterFurnace",
"integration_type": "hub",
"integration_type": "device",
"config_flow": false,
"iot_class": "cloud_polling"
},

View File

@@ -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

View File

@@ -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
View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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 = (

View File

@@ -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,

View File

@@ -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")

View File

@@ -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 {}
),
}

View File

@@ -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",

View File

@@ -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"

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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

View File

@@ -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
),
],
)

View File

@@ -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

View File

@@ -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"}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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({

View File

@@ -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,
),
]

View File

@@ -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"

View File

@@ -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",
)
],
}

View File

@@ -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": ""
}
}
}

View File

@@ -0,0 +1,12 @@
{
"traceId": "1234",
"code": 0,
"msg": "request success",
"module": null,
"stacktrace": null,
"result": {
"returnStatus": {
"cookStatus": "standby"
}
}
}

View File

@@ -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
}
]
}

View File

@@ -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({

View File

@@ -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({

View File

@@ -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({

View File

@@ -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({

View File

@@ -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({

View File

@@ -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({

View File

@@ -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({

View File

@@ -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"

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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

View File

@@ -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),
),
],