Compare commits

...

49 Commits

Author SHA1 Message Date
farmio
7a54c29896 Fix teardown and internal name 2026-01-10 23:51:09 +01:00
farmio
73a5e02b74 Refactor KNX expose entity class 2026-01-10 23:03:07 +01:00
Etienne C.
43ced677e5 Get the polling state of a sensor from a template (#159900) 2026-01-08 12:03:45 +01:00
Ville Skyttä
7a696935ed Add icons for Nord Pool highest and lowest price sensors (#159729) 2026-01-08 11:27:17 +01:00
Deyan Petrov
be3be360a7 Make Tuya binary sensor consider only updated properties (#160404)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-01-08 09:47:27 +01:00
Mick Vleeshouwer
092ebaaeb1 Bump pyOverkiz to 1.19.4 (#160457) 2026-01-08 08:41:30 +01:00
Retha Runolfsson
e8025317ed Bump PySwitchbot to 0.76.0 (#160470) 2026-01-08 08:39:23 +01:00
wollew
39b025dfea catch and wrap exceptions when doing pyvlx actions in velux entities (#160430)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-08 00:06:26 +01:00
DeerMaximum
1b436a8808 Use async_configure in NINA to set flow data in tests (#160435)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-07 23:48:42 +01:00
Markus Jacobsen
a7440e3756 Add battery support to Bang & Olufsen (#159994) 2026-01-07 23:40:22 +01:00
wollew
2c7852f94b remove workaround for recognition of closed velux windows (#160433) 2026-01-07 23:39:37 +01:00
Maikel Punie
bd4653f830 Update velbus quality scale rules for docs (#160200) 2026-01-07 23:32:45 +01:00
Tero Paloheimo
c0b2847a87 Update ruuvitag-ble to 0.4.0 (#160441) 2026-01-07 23:32:03 +01:00
J. Diego Rodríguez Royo
8853f6698b Add steam mode and hot air gentle programs to Home Connect (#160445) 2026-01-07 23:10:20 +01:00
Artem Draft
b1a3ad6ac3 Improve Bravia TV logging messages (#160394) 2026-01-07 23:09:46 +01:00
Arie Catsman
dafa2e69e2 Optimize enphase_envoy code for on_phase use (#160448) 2026-01-07 23:09:00 +01:00
Chris
2c6d6f8ab4 Add unique_id to openevse user flow and import flow (#160436)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-07 23:06:25 +01:00
J. Diego Rodríguez Royo
10d32b7f23 Bump aiohomeconnect to version 0.28.0 (#160438) 2026-01-07 20:44:36 +01:00
TheJulianJES
e4dc4e0ced Bump ZHA to 0.0.84 (#160440) 2026-01-07 19:57:09 +01:00
Maikel Punie
6f9794f235 Add icon translations for velbus (#160439) 2026-01-07 19:26:47 +01:00
Paul Bottein
b8cff13737 Fix hvac_mode validation in climate.hvac_mode_changed trigger (#160364) 2026-01-07 17:44:03 +01:00
Bram Kragten
7777714cc0 Update frontend to 20260107.0 (#160434) 2026-01-07 17:34:23 +01:00
Chris
f15d5cdf2a Add zeroconf discovery to openevse (#160318)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-07 16:42:32 +01:00
DeerMaximum
6181f4e7de NINA Use MockConfigEntry to setup integration in test (#160324) 2026-01-07 16:33:06 +01:00
Robert Resch
80df3b5b80 Bump deebot-client to 17.0.1 (#160428) 2026-01-07 16:07:11 +01: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
123 changed files with 3556 additions and 980 deletions

5
CODEOWNERS generated
View File

@@ -1170,8 +1170,8 @@ build.json @home-assistant/supervisor
/tests/components/open_router/ @joostlek
/homeassistant/components/openerz/ @misialq
/tests/components/openerz/ @misialq
/homeassistant/components/openevse/ @c00w
/tests/components/openevse/ @c00w
/homeassistant/components/openevse/ @c00w @firstof9
/tests/components/openevse/ @c00w @firstof9
/homeassistant/components/openexchangerates/ @MartinHjelmare
/tests/components/openexchangerates/ @MartinHjelmare
/homeassistant/components/opengarage/ @danielhiversen
@@ -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

@@ -34,7 +34,7 @@ class BeoData:
type BeoConfigEntry = ConfigEntry[BeoData]
PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:

View File

@@ -115,6 +115,7 @@ class WebsocketNotification(StrEnum):
"""Enum for WebSocket notification types."""
ACTIVE_LISTENING_MODE = "active_listening_mode"
BATTERY = "battery"
BEO_REMOTE_BUTTON = "beo_remote_button"
BUTTON = "button"
PLAYBACK_ERROR = "playback_error"

View File

@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -55,6 +56,19 @@ async def async_get_config_entry_diagnostics(
# Get remotes
for remote in await get_remotes(config_entry.runtime_data.client):
# Get Battery Sensor states
if entity_id := entity_registry.async_get_entity_id(
SENSOR_DOMAIN,
DOMAIN,
f"{remote.serial_number}_{config_entry.unique_id}_remote_battery_level",
):
if state := hass.states.get(entity_id):
state_dict = dict(state.as_dict())
# Remove context as it is not relevant
state_dict.pop("context")
data[f"remote_{remote.serial_number}_battery_level"] = state_dict
# Get key Event entity states (if enabled)
for key_type in get_remote_keys():
if entity_id := entity_registry.async_get_entity_id(
@@ -72,4 +86,15 @@ async def async_get_config_entry_diagnostics(
# Add remote Mozart model
data[f"remote_{remote.serial_number}"] = dict(remote)
# Get Mozart battery entity
if entity_id := entity_registry.async_get_entity_id(
SENSOR_DOMAIN, DOMAIN, f"{config_entry.unique_id}_battery_level"
):
if state := hass.states.get(entity_id):
state_dict = dict(state.as_dict())
# Remove context as it is not relevant
state_dict.pop("context")
data["battery_level"] = state_dict
return data

View File

@@ -0,0 +1,139 @@
"""Sensor entities for the Bang & Olufsen integration."""
from __future__ import annotations
import contextlib
from datetime import timedelta
from aiohttp import ClientConnectorError
from mozart_api.exceptions import ApiException
from mozart_api.models import BatteryState, PairedRemote
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BeoConfigEntry
from .const import CONNECTION_STATUS, DOMAIN, WebsocketNotification
from .entity import BeoEntity
from .util import get_remotes, supports_battery
SCAN_INTERVAL = timedelta(minutes=15)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BeoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sensor entities from config entry."""
entities: list[BeoSensor] = []
# Check for Mozart device with battery
if await supports_battery(config_entry.runtime_data.client):
entities.append(BeoSensorBatteryLevel(config_entry))
# Add any Beoremote One remotes
entities.extend(
[
BeoSensorRemoteBatteryLevel(config_entry, remote)
for remote in (await get_remotes(config_entry.runtime_data.client))
]
)
async_add_entities(entities, update_before_add=True)
class BeoSensor(SensorEntity, BeoEntity):
"""Base Bang & Olufsen Sensor."""
def __init__(self, config_entry: BeoConfigEntry) -> None:
"""Initialize Sensor."""
super().__init__(config_entry, config_entry.runtime_data.client)
class BeoSensorBatteryLevel(BeoSensor):
"""Battery level Sensor for Mozart devices."""
_attr_device_class = SensorDeviceClass.BATTERY
_attr_native_unit_of_measurement = PERCENTAGE
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, config_entry: BeoConfigEntry) -> None:
"""Init the battery level Sensor."""
super().__init__(config_entry)
self._attr_unique_id = f"{self._unique_id}_battery_level"
async def async_added_to_hass(self) -> None:
"""Turn on the dispatchers."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
self._async_update_connection_state,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BATTERY}",
self._update_battery,
)
)
async def _update_battery(self, data: BatteryState) -> None:
"""Update sensor value."""
self._attr_native_value = data.battery_level
self.async_write_ha_state()
class BeoSensorRemoteBatteryLevel(BeoSensor):
"""Battery level Sensor for the Beoremote One."""
_attr_device_class = SensorDeviceClass.BATTERY
_attr_native_unit_of_measurement = PERCENTAGE
_attr_should_poll = True
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, config_entry: BeoConfigEntry, remote: PairedRemote) -> None:
"""Init the battery level Sensor."""
super().__init__(config_entry)
# Serial number is not None, as the remote object is provided by get_remotes
assert remote.serial_number
self._attr_unique_id = (
f"{remote.serial_number}_{self._unique_id}_remote_battery_level"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")}
)
self._attr_native_value = remote.battery_level
self._remote = remote
async def async_added_to_hass(self) -> None:
"""Turn on the dispatchers."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
self._async_update_connection_state,
)
)
async def async_update(self) -> None:
"""Poll battery status."""
with contextlib.suppress(ApiException, ClientConnectorError, TimeoutError):
for remote in await get_remotes(self._client):
if remote.serial_number == self._remote.serial_number:
self._attr_native_value = remote.battery_level
break

View File

@@ -84,3 +84,10 @@ def get_remote_keys() -> list[str]:
for key_type in (*BEO_REMOTE_KEYS, *BEO_REMOTE_CONTROL_KEYS)
],
]
async def supports_battery(client: MozartClient) -> bool:
"""Get if a Mozart device has a battery."""
battery_state = await client.get_battery_state()
return battery_state.state != "BatteryNotPresent"

View File

@@ -6,6 +6,7 @@ import logging
from typing import TYPE_CHECKING
from mozart_api.models import (
BatteryState,
BeoRemoteButton,
ButtonEvent,
ListeningModeProps,
@@ -60,6 +61,7 @@ class BeoWebsocket(BeoBase):
self._client.get_active_listening_mode_notifications(
self.on_active_listening_mode
)
self._client.get_battery_notifications(self.on_battery_notification)
self._client.get_beo_remote_button_notifications(
self.on_beo_remote_button_notification
)
@@ -115,6 +117,14 @@ class BeoWebsocket(BeoBase):
notification,
)
def on_battery_notification(self, notification: BatteryState) -> None:
"""Send battery dispatch."""
async_dispatcher_send(
self.hass,
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BATTERY}",
notification,
)
def on_beo_remote_button_notification(self, notification: BeoRemoteButton) -> None:
"""Send beo_remote_button dispatch."""
if TYPE_CHECKING:

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

@@ -22,7 +22,7 @@ from homeassistant.components.media_player import MediaType
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CLIENT_ID, CONF_PIN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -56,8 +56,31 @@ def catch_braviatv_errors[_BraviaTVCoordinatorT: BraviaTVCoordinator, **_P](
"""Catch Bravia errors and log message."""
try:
await func(self, *args, **kwargs)
except BraviaNotFound as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_error_not_found",
translation_placeholders={
"device": self.config_entry.title,
},
) from err
except (BraviaConnectionError, BraviaConnectionTimeout, BraviaTurnedOff) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_error_offline",
translation_placeholders={
"device": self.config_entry.title,
},
) from err
except BraviaError as err:
_LOGGER.error("Command error: %s", err)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_error",
translation_placeholders={
"device": self.config_entry.title,
"error": repr(err),
},
) from err
await self.async_request_refresh()
return wrapper
@@ -165,17 +188,35 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]):
if self.skipped_updates < 10:
self.connected = False
self.skipped_updates += 1
_LOGGER.debug("Update skipped, Bravia API service is reloading")
_LOGGER.debug(
"Update for %s skipped: the Bravia API service is reloading",
self.config_entry.title,
)
return
raise UpdateFailed("Error communicating with device") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error_not_found",
translation_placeholders={
"device": self.config_entry.title,
},
) from err
except (BraviaConnectionError, BraviaConnectionTimeout, BraviaTurnedOff):
self.is_on = False
self.connected = False
_LOGGER.debug("Update skipped, Bravia TV is off")
_LOGGER.debug(
"Update for %s skipped: the TV is turned off", self.config_entry.title
)
except BraviaError as err:
self.is_on = False
self.connected = False
raise UpdateFailed("Error communicating with device") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={
"device": self.config_entry.title,
"error": repr(err),
},
) from err
async def async_update_volume(self) -> None:
"""Update volume information."""

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": {
@@ -54,5 +55,22 @@
"name": "Terminate apps"
}
}
},
"exceptions": {
"command_error": {
"message": "Error sending command to {device}: {error}"
},
"command_error_not_found": {
"message": "Error sending command to {device}: the Bravia API service is reloading"
},
"command_error_offline": {
"message": "Error sending command to {device}: the TV is turned off"
},
"update_error": {
"message": "Error updating data for {device}: {error}"
},
"update_error_not_found": {
"message": "Error updating data for {device}: the Bravia API service is stuck"
}
}
}

View File

@@ -33,7 +33,7 @@ HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_HVAC_MODE): vol.All(
cv.ensure_list, vol.Length(min=1), [HVACMode]
cv.ensure_list, vol.Length(min=1), [vol.Coerce(HVACMode)]
),
},
}

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

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==17.0.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==17.0.1"]
}

View File

@@ -206,7 +206,7 @@ class EnvoyProductionSensorEntityDescription(SensorEntityDescription):
"""Describes an Envoy production sensor entity."""
value_fn: Callable[[EnvoySystemProduction], int]
on_phase: str | None
on_phase: str | None = None
PRODUCTION_SENSORS = (
@@ -219,7 +219,6 @@ PRODUCTION_SENSORS = (
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3,
value_fn=attrgetter("watts_now"),
on_phase=None,
),
EnvoyProductionSensorEntityDescription(
key="daily_production",
@@ -230,7 +229,6 @@ PRODUCTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=2,
value_fn=attrgetter("watt_hours_today"),
on_phase=None,
),
EnvoyProductionSensorEntityDescription(
key="seven_days_production",
@@ -240,7 +238,6 @@ PRODUCTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
value_fn=attrgetter("watt_hours_last_7_days"),
on_phase=None,
),
EnvoyProductionSensorEntityDescription(
key="lifetime_production",
@@ -251,7 +248,6 @@ PRODUCTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("watt_hours_lifetime"),
on_phase=None,
),
)
@@ -277,7 +273,7 @@ class EnvoyConsumptionSensorEntityDescription(SensorEntityDescription):
"""Describes an Envoy consumption sensor entity."""
value_fn: Callable[[EnvoySystemConsumption], int]
on_phase: str | None
on_phase: str | None = None
CONSUMPTION_SENSORS = (
@@ -290,7 +286,6 @@ CONSUMPTION_SENSORS = (
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3,
value_fn=attrgetter("watts_now"),
on_phase=None,
),
EnvoyConsumptionSensorEntityDescription(
key="daily_consumption",
@@ -301,7 +296,6 @@ CONSUMPTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=2,
value_fn=attrgetter("watt_hours_today"),
on_phase=None,
),
EnvoyConsumptionSensorEntityDescription(
key="seven_days_consumption",
@@ -311,7 +305,6 @@ CONSUMPTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
value_fn=attrgetter("watt_hours_last_7_days"),
on_phase=None,
),
EnvoyConsumptionSensorEntityDescription(
key="lifetime_consumption",
@@ -322,7 +315,6 @@ CONSUMPTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("watt_hours_lifetime"),
on_phase=None,
),
)
@@ -354,7 +346,6 @@ NET_CONSUMPTION_SENSORS = (
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3,
value_fn=attrgetter("watts_now"),
on_phase=None,
),
EnvoyConsumptionSensorEntityDescription(
key="lifetime_balanced_net_consumption",
@@ -366,7 +357,6 @@ NET_CONSUMPTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("watt_hours_lifetime"),
on_phase=None,
),
)
@@ -395,7 +385,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription):
[EnvoyMeterData],
int | float | str | CtType | CtMeterStatus | CtStatusFlags | CtState | None,
]
on_phase: str | None
on_phase: str | None = None
cttype: str | None = None
@@ -411,7 +401,6 @@ CT_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_delivered"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
@@ -430,7 +419,6 @@ CT_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_received"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
@@ -449,7 +437,6 @@ CT_SENSORS = (
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3,
value_fn=attrgetter("active_power"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
@@ -468,7 +455,6 @@ CT_SENSORS = (
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (
@@ -488,7 +474,6 @@ CT_SENSORS = (
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (
@@ -508,7 +493,6 @@ CT_SENSORS = (
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("current"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
@@ -526,7 +510,6 @@ CT_SENSORS = (
suggested_display_precision=2,
entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
@@ -544,7 +527,6 @@ CT_SENSORS = (
options=list(CtMeterStatus),
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (
@@ -565,7 +547,6 @@ CT_SENSORS = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (

View File

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

View File

@@ -23,6 +23,6 @@
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"quality_scale": "platinum",
"requirements": ["aiohomeconnect==0.26.0"],
"requirements": ["aiohomeconnect==0.28.0"],
"zeroconf": ["_homeconnect._tcp.local."]
}

View File

@@ -15,7 +15,7 @@ set_program_and_options:
- active_program
- selected_program
program:
example: dishcare_dishwasher_program_auto2
example: dishcare_dishwasher_program_auto_2
selector:
select:
mode: dropdown
@@ -121,6 +121,7 @@ set_program_and_options:
- cooking_oven_program_heating_mode_pre_heating
- cooking_oven_program_heating_mode_hot_air
- cooking_oven_program_heating_mode_hot_air_eco
- cooking_oven_program_heating_mode_hot_air_gentle
- cooking_oven_program_heating_mode_hot_air_grilling
- cooking_oven_program_heating_mode_top_bottom_heating
- cooking_oven_program_heating_mode_top_bottom_heating_eco
@@ -147,6 +148,7 @@ set_program_and_options:
- cooking_oven_program_microwave_900_watt
- cooking_oven_program_microwave_1000_watt
- cooking_oven_program_microwave_max
- cooking_oven_program_steam_modes_steam
- cooking_oven_program_heating_mode_warming_drawer
- laundry_care_washer_program_auto_30
- laundry_care_washer_program_auto_40
@@ -174,7 +176,7 @@ set_program_and_options:
- laundry_care_washer_program_rinse_rinse_spin_drain
- laundry_care_washer_program_sensitive
- laundry_care_washer_program_shirts_blouses
- laundry_care_washer_program_spin_drain
- laundry_care_washer_program_spin_spin_drain
- laundry_care_washer_program_sport_fitness
- laundry_care_washer_program_super_153045_super_15
- laundry_care_washer_program_super_153045_super_1530

View File

@@ -240,6 +240,7 @@
"cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]",
"cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]",
"cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]",
"cooking_oven_program_heating_mode_hot_air_gentle": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_gentle%]",
"cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]",
"cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]",
"cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]",
@@ -350,7 +351,7 @@
"laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]",
"laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]",
"laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]",
"laundry_care_washer_program_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_drain%]",
"laundry_care_washer_program_spin_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_spin_drain%]",
"laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]",
"laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]",
"laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]",
@@ -592,6 +593,7 @@
"cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]",
"cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]",
"cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]",
"cooking_oven_program_heating_mode_hot_air_gentle": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_gentle%]",
"cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]",
"cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]",
"cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]",
@@ -612,6 +614,7 @@
"cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]",
"cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]",
"cooking_oven_program_microwave_max": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_max%]",
"cooking_oven_program_steam_modes_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_steam_modes_steam%]",
"dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_1%]",
"dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_2%]",
"dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_3%]",
@@ -702,7 +705,7 @@
"laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]",
"laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]",
"laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]",
"laundry_care_washer_program_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_drain%]",
"laundry_care_washer_program_spin_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_spin_drain%]",
"laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]",
"laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]",
"laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]",
@@ -1583,6 +1586,7 @@
"cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH",
"cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH",
"cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco",
"cooking_oven_program_heating_mode_hot_air_gentle": "Hot air gentle",
"cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling",
"cooking_oven_program_heating_mode_intensive_heat": "Intensive heat",
"cooking_oven_program_heating_mode_keep_warm": "Keep warm",
@@ -1603,6 +1607,7 @@
"cooking_oven_program_microwave_900_watt": "900 Watt",
"cooking_oven_program_microwave_90_watt": "90 Watt",
"cooking_oven_program_microwave_max": "Max",
"cooking_oven_program_steam_modes_steam": "Steam mode",
"dishcare_dishwasher_program_auto_1": "Auto 1",
"dishcare_dishwasher_program_auto_2": "Auto 2",
"dishcare_dishwasher_program_auto_3": "Auto 3",
@@ -1693,7 +1698,7 @@
"laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain",
"laundry_care_washer_program_sensitive": "Sensitive",
"laundry_care_washer_program_shirts_blouses": "Shirts/blouses",
"laundry_care_washer_program_spin_drain": "Spin/drain",
"laundry_care_washer_program_spin_spin_drain": "Spin/drain",
"laundry_care_washer_program_sport_fitness": "Sport/fitness",
"laundry_care_washer_program_super_153045_super_15": "Super 15 min",
"laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min",

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

@@ -27,7 +27,7 @@ from .const import (
SUPPORTED_PLATFORMS_UI,
SUPPORTED_PLATFORMS_YAML,
)
from .expose import create_knx_exposure
from .expose import create_combined_knx_exposure
from .knx_module import KNXModule
from .project import STORAGE_KEY as PROJECT_STORAGE_KEY
from .schema import (
@@ -121,10 +121,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[KNX_MODULE_KEY] = knx_module
if CONF_KNX_EXPOSE in config:
for expose_config in config[CONF_KNX_EXPOSE]:
knx_module.exposures.append(
create_knx_exposure(hass, knx_module.xknx, expose_config)
)
knx_module.yaml_exposures.extend(
create_combined_knx_exposure(hass, knx_module.xknx, config[CONF_KNX_EXPOSE])
)
configured_platforms_yaml = {
platform for platform in SUPPORTED_PLATFORMS_YAML if platform in config
}
@@ -149,7 +149,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# if not loaded directly return
return True
for exposure in knx_module.exposures:
for exposure in knx_module.yaml_exposures:
exposure.async_remove()
for exposure in knx_module.service_exposures.values():
exposure.async_remove()
configured_platforms_yaml = {

View File

@@ -2,14 +2,21 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Iterable
from dataclasses import dataclass
import logging
from typing import Any
from xknx import XKNX
from xknx.devices import DateDevice, DateTimeDevice, ExposeSensor, TimeDevice
from xknx.dpt import DPTNumeric, DPTString
from xknx.dpt import DPTBase, DPTNumeric, DPTString
from xknx.dpt.dpt_1 import DPT1BitEnum, DPTSwitch
from xknx.exceptions import ConversionError
from xknx.remote_value import RemoteValueSensor
from xknx.telegram.address import (
GroupAddress,
InternalGroupAddress,
parse_device_group_address,
)
from homeassistant.const import (
CONF_ENTITY_ID,
@@ -41,79 +48,159 @@ _LOGGER = logging.getLogger(__name__)
@callback
def create_knx_exposure(
hass: HomeAssistant, xknx: XKNX, config: ConfigType
) -> KNXExposeSensor | KNXExposeTime:
"""Create exposures from config."""
) -> KnxExposeEntity | KnxExposeTime:
"""Create single exposure."""
expose_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
exposure: KNXExposeSensor | KNXExposeTime
exposure: KnxExposeEntity | KnxExposeTime
if (
isinstance(expose_type, str)
and expose_type.lower() in ExposeSchema.EXPOSE_TIME_TYPES
):
exposure = KNXExposeTime(
exposure = KnxExposeTime(
xknx=xknx,
config=config,
)
else:
exposure = KNXExposeSensor(
hass,
exposure = KnxExposeEntity(
hass=hass,
xknx=xknx,
config=config,
entity_id=config[CONF_ENTITY_ID],
options=(_yaml_config_to_expose_options(config),),
)
exposure.async_register()
return exposure
class KNXExposeSensor:
"""Object to Expose Home Assistant entity to KNX bus."""
@callback
def create_combined_knx_exposure(
hass: HomeAssistant, xknx: XKNX, configs: list[ConfigType]
) -> list[KnxExposeEntity | KnxExposeTime]:
"""Create exposures from YAML config combined by entity_id."""
exposures: list[KnxExposeEntity | KnxExposeTime] = []
entity_exposure_map: dict[str, list[KnxExposeOptions]] = {}
for config in configs:
value_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
if value_type.lower() in ExposeSchema.EXPOSE_TIME_TYPES:
time_exposure = KnxExposeTime(
xknx=xknx,
config=config,
)
time_exposure.async_register()
exposures.append(time_exposure)
continue
entity_id = config[CONF_ENTITY_ID]
option = _yaml_config_to_expose_options(config)
entity_exposure_map.setdefault(entity_id, []).append(option)
for entity_id, options in entity_exposure_map.items():
entity_exposure = KnxExposeEntity(
hass=hass,
xknx=xknx,
entity_id=entity_id,
options=options,
)
entity_exposure.async_register()
exposures.append(entity_exposure)
return exposures
@dataclass(slots=True)
class KnxExposeOptions:
"""Options for KNX Expose."""
attribute: str | None
group_address: GroupAddress | InternalGroupAddress
dpt: type[DPTBase]
respond_to_read: bool
cooldown: float
default: Any | None
value_template: Template | None
def _yaml_config_to_expose_options(config: ConfigType) -> KnxExposeOptions:
"""Convert single yaml expose config to KnxExposeOptions."""
value_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
dpt: type[DPTBase]
if value_type == "binary":
# HA yaml expose flag for DPT-1 (no explicit DPT 1 definitions in xknx back then)
dpt = DPTSwitch
else:
dpt = DPTBase.parse_transcoder(config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]) # type: ignore[assignment] # checked by schema validation
ga = parse_device_group_address(config[KNX_ADDRESS])
return KnxExposeOptions(
attribute=config.get(ExposeSchema.CONF_KNX_EXPOSE_ATTRIBUTE),
group_address=ga,
dpt=dpt,
respond_to_read=config[CONF_RESPOND_TO_READ],
cooldown=config[ExposeSchema.CONF_KNX_EXPOSE_COOLDOWN],
default=config.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT),
value_template=config.get(CONF_VALUE_TEMPLATE),
)
class KnxExposeEntity:
"""Expose Home Assistant entity values to KNX bus."""
def __init__(
self,
hass: HomeAssistant,
xknx: XKNX,
config: ConfigType,
entity_id: str,
options: Iterable[KnxExposeOptions],
) -> None:
"""Initialize of Expose class."""
"""Initialize KnxExposeEntity class."""
self.hass = hass
self.xknx = xknx
self.entity_id: str = config[CONF_ENTITY_ID]
self.expose_attribute: str | None = config.get(
ExposeSchema.CONF_KNX_EXPOSE_ATTRIBUTE
)
self.expose_default = config.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT)
self.expose_type: int | str = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
self.value_template: Template | None = config.get(CONF_VALUE_TEMPLATE)
self.entity_id = entity_id
self._remove_listener: Callable[[], None] | None = None
self.device: ExposeSensor = ExposeSensor(
xknx=self.xknx,
name=f"{self.entity_id}__{self.expose_attribute or 'state'}",
group_address=config[KNX_ADDRESS],
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=self.expose_type,
cooldown=config[ExposeSchema.CONF_KNX_EXPOSE_COOLDOWN],
self._exposures = tuple(
(
option,
ExposeSensor(
xknx=self.xknx,
name=f"{self.entity_id} {option.attribute or 'state'}",
group_address=option.group_address,
respond_to_read=option.respond_to_read,
value_type=option.dpt,
cooldown=option.cooldown,
),
)
for option in options
)
@property
def name(self) -> str:
"""Return name of the expose entity."""
expose_names = [opt.attribute or "state" for opt, _ in self._exposures]
return f"{self.entity_id}__{'__'.join(expose_names)}"
@callback
def async_register(self) -> None:
"""Register listener."""
"""Register listener and XKNX devices."""
self._remove_listener = async_track_state_change_event(
self.hass, [self.entity_id], self._async_entity_changed
)
self.xknx.devices.async_add(self.device)
for _option, xknx_expose in self._exposures:
self.xknx.devices.async_add(xknx_expose)
self._init_expose_state()
@callback
def _init_expose_state(self) -> None:
"""Initialize state of the exposure."""
"""Initialize state of all exposures."""
init_state = self.hass.states.get(self.entity_id)
state_value = self._get_expose_value(init_state)
try:
self.device.sensor_value.value = state_value
except ConversionError:
_LOGGER.exception("Error during sending of expose sensor value")
for option, xknx_expose in self._exposures:
state_value = self._get_expose_value(init_state, option)
try:
xknx_expose.sensor_value.value = state_value
except ConversionError:
_LOGGER.exception(
"Error setting value %s for expose sensor %s",
state_value,
xknx_expose.name,
)
@callback
def async_remove(self) -> None:
@@ -121,53 +208,57 @@ class KNXExposeSensor:
if self._remove_listener is not None:
self._remove_listener()
self._remove_listener = None
self.xknx.devices.async_remove(self.device)
for _option, xknx_expose in self._exposures:
self.xknx.devices.async_remove(xknx_expose)
def _get_expose_value(self, state: State | None) -> bool | int | float | str | None:
"""Extract value from state."""
def _get_expose_value(
self, state: State | None, option: KnxExposeOptions
) -> bool | int | float | str | None:
"""Extract value from state for a specific option."""
if state is None or state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
if self.expose_default is None:
if option.default is None:
return None
value = self.expose_default
elif self.expose_attribute is not None:
_attr = state.attributes.get(self.expose_attribute)
value = _attr if _attr is not None else self.expose_default
value = option.default
elif option.attribute is not None:
_attr = state.attributes.get(option.attribute)
value = _attr if _attr is not None else option.default
else:
value = state.state
if self.value_template is not None:
if option.value_template is not None:
try:
value = self.value_template.async_render_with_possible_json_value(
value = option.value_template.async_render_with_possible_json_value(
value, error_value=None
)
except (TemplateError, TypeError, ValueError) as err:
_LOGGER.warning(
"Error rendering value template for KNX expose %s %s: %s",
self.device.name,
self.value_template.template,
"Error rendering value template for KNX expose %s %s %s: %s",
self.entity_id,
option.attribute or "state",
option.value_template.template,
err,
)
return None
if self.expose_type == "binary":
if issubclass(option.dpt, DPT1BitEnum):
if value in (1, STATE_ON, "True"):
return True
if value in (0, STATE_OFF, "False"):
return False
if value is not None and (
isinstance(self.device.sensor_value, RemoteValueSensor)
):
# Handle numeric and string DPT conversions
if value is not None:
try:
if issubclass(self.device.sensor_value.dpt_class, DPTNumeric):
if issubclass(option.dpt, DPTNumeric):
return float(value)
if issubclass(self.device.sensor_value.dpt_class, DPTString):
if issubclass(option.dpt, DPTString):
# DPT 16.000 only allows up to 14 Bytes
return str(value)[:14]
except (ValueError, TypeError) as err:
_LOGGER.warning(
'Could not expose %s %s value "%s" to KNX: Conversion failed: %s',
self.entity_id,
self.expose_attribute or "state",
option.attribute or "state",
value,
err,
)
@@ -175,32 +266,40 @@ class KNXExposeSensor:
return value # type: ignore[no-any-return]
async def _async_entity_changed(self, event: Event[EventStateChangedData]) -> None:
"""Handle entity change."""
"""Handle entity change for all options."""
new_state = event.data["new_state"]
if (new_value := self._get_expose_value(new_state)) is None:
return
old_state = event.data["old_state"]
# don't use default value for comparison on first state change (old_state is None)
old_value = self._get_expose_value(old_state) if old_state is not None else None
# don't send same value sequentially
if new_value != old_value:
await self._async_set_knx_value(new_value)
async def _async_set_knx_value(self, value: StateType) -> None:
for option, xknx_expose in self._exposures:
new_value = self._get_expose_value(new_state, option)
if new_value is None:
continue
# Don't use default value for comparison on first state change
old_value = (
self._get_expose_value(old_state, option)
if old_state is not None
else None
)
# Don't send same value sequentially
if new_value != old_value:
await self._async_set_knx_value(xknx_expose, new_value)
async def _async_set_knx_value(
self, xknx_expose: ExposeSensor, value: StateType
) -> None:
"""Set new value on xknx ExposeSensor."""
try:
await self.device.set(value)
await xknx_expose.set(value)
except ConversionError as err:
_LOGGER.warning(
'Could not expose %s %s value "%s" to KNX: %s',
self.entity_id,
self.expose_attribute or "state",
'Could not expose %s value "%s" to KNX: %s',
xknx_expose.name,
value,
err,
)
class KNXExposeTime:
class KnxExposeTime:
"""Object to Expose Time/Date object to KNX bus."""
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
@@ -222,6 +321,11 @@ class KNXExposeTime:
group_address=config[KNX_ADDRESS],
)
@property
def name(self) -> str:
"""Return name of the time expose object."""
return f"expose_{self.device.name}"
@callback
def async_register(self) -> None:
"""Register listener."""

View File

@@ -54,7 +54,7 @@ from .const import (
TELEGRAM_LOG_DEFAULT,
)
from .device import KNXInterfaceDevice
from .expose import KNXExposeSensor, KNXExposeTime
from .expose import KnxExposeEntity, KnxExposeTime
from .project import KNXProject
from .repairs import data_secure_group_key_issue_dispatcher
from .storage.config_store import KNXConfigStore
@@ -73,8 +73,8 @@ class KNXModule:
self.hass = hass
self.config_yaml = config
self.connected = False
self.exposures: list[KNXExposeSensor | KNXExposeTime] = []
self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {}
self.yaml_exposures: list[KnxExposeEntity | KnxExposeTime] = []
self.service_exposures: dict[str, KnxExposeEntity | KnxExposeTime] = {}
self.entry = entry
self.project = KNXProject(hass=hass, entry=entry)

View File

@@ -193,7 +193,7 @@ async def service_exposure_register_modify(call: ServiceCall) -> None:
" for '%s' - %s"
),
group_address,
replaced_exposure.device.name,
replaced_exposure.name,
)
replaced_exposure.async_remove()
exposure = create_knx_exposure(knx_module.hass, knx_module.xknx, call.data)
@@ -201,7 +201,7 @@ async def service_exposure_register_modify(call: ServiceCall) -> None:
_LOGGER.debug(
"Service exposure_register registered exposure for '%s' - %s",
group_address,
exposure.device.name,
exposure.name,
)

View File

@@ -28,9 +28,15 @@
"exchange_rate": {
"default": "mdi:currency-usd"
},
"highest_price": {
"default": "mdi:cash-plus"
},
"last_price": {
"default": "mdi:cash"
},
"lowest_price": {
"default": "mdi:cash-minus"
},
"next_price": {
"default": "mdi:cash"
},

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,13 +2,14 @@
from typing import Any
import openevsewifi
from openevsehttp.__main__ import OpenEVSE
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.helpers.service_info import zeroconf
from .const import DOMAIN
from .const import CONF_ID, CONF_SERIAL, DOMAIN
class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -17,27 +18,33 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 1
async def check_status(self, host: str) -> bool:
def __init__(self) -> None:
"""Set up the instance."""
self.discovery_info: dict[str, Any] = {}
async def check_status(self, host: str) -> tuple[bool, str | None]:
"""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:
return False
else:
return result is not None
result = await charger.test_and_get()
except TimeoutError:
return False, None
return True, result.get(CONF_SERIAL)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors = None
errors = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
if await self.check_status(user_input[CONF_HOST]):
if (result := await self.check_status(user_input[CONF_HOST]))[0]:
if (serial := result[1]) is not None:
await self.async_set_unique_id(serial, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"OpenEVSE {user_input[CONF_HOST]}",
data=user_input,
@@ -55,10 +62,53 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
self._async_abort_entries_match({CONF_HOST: data[CONF_HOST]})
if not await self.check_status(data[CONF_HOST]):
if (result := await self.check_status(data[CONF_HOST]))[0]:
if (serial := result[1]) is not None:
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured()
else:
return self.async_abort(reason="unavailable_host")
return self.async_create_entry(
title=f"OpenEVSE {data[CONF_HOST]}",
data=data,
)
async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
self._async_abort_entries_match({CONF_HOST: discovery_info.host})
await self.async_set_unique_id(discovery_info.properties[CONF_ID])
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
host = discovery_info.host
name = f"OpenEVSE {discovery_info.name.split('.')[0]}"
self.discovery_info.update(
{
CONF_HOST: host,
CONF_NAME: name,
}
)
self.context.update({"title_placeholders": {"name": name}})
if not (await self.check_status(host))[0]:
return self.async_abort(reason="cannot_connect")
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
if user_input is None:
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={"name": self.discovery_info[CONF_NAME]},
)
return self.async_create_entry(
title=self.discovery_info[CONF_NAME],
data={CONF_HOST: self.discovery_info[CONF_HOST]},
)

View File

@@ -1,4 +1,6 @@
"""Constants for the OpenEVSE integration."""
CONF_ID = "id"
CONF_SERIAL = "serial"
DOMAIN = "openevse"
INTEGRATION_TITLE = "OpenEVSE"

View File

@@ -1,12 +1,14 @@
{
"domain": "openevse",
"name": "OpenEVSE",
"codeowners": ["@c00w"],
"after_dependencies": ["zeroconf"],
"codeowners": ["@c00w", "@firstof9"],
"config_flow": true,
"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"],
"zeroconf": ["_openevse._tcp.local."]
}

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

@@ -13,7 +13,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz==1.19.3"],
"requirements": ["pyoverkiz==1.19.4"],
"zeroconf": [
{
"name": "gateway*",

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

@@ -16,5 +16,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/ruuvitag_ble",
"iot_class": "local_push",
"requirements": ["ruuvitag-ble==0.3.0"]
"requirements": ["ruuvitag-ble==0.4.0"]
}

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

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

@@ -42,5 +42,5 @@
"iot_class": "local_push",
"loggers": ["switchbot"],
"quality_scale": "gold",
"requirements": ["PySwitchbot==0.75.0"]
"requirements": ["PySwitchbot==0.76.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

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

@@ -463,6 +463,16 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity):
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._dpcode_wrapper = dpcode_wrapper
async def _handle_state_update(
self,
updated_status_properties: list[str] | None,
dp_timestamps: dict | None = None,
) -> None:
"""Handle state update, only if this entity's dpcode was actually updated."""
if self._dpcode_wrapper.skip_update(self.device, updated_status_properties):
return
self.async_write_ha_state()
@property
def is_on(self) -> bool | None:
"""Return true if sensor is on."""

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

@@ -1,4 +1,24 @@
{
"entity": {
"light": {
"button_light": {
"state": {
"off": "mdi:led-off",
"on": "mdi:led-on"
}
}
},
"select": {
"select_program": {
"state": {
"0": "mdi:numeric-0-box",
"1": "mdi:numeric-1-box",
"2": "mdi:numeric-2-box",
"3": "mdi:numeric-3-box"
}
}
}
},
"services": {
"clear_cache": {
"service": "mdi:delete"

View File

@@ -113,6 +113,7 @@ class VelbusButtonLight(VelbusEntity, LightEntity):
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
_attr_supported_features = LightEntityFeature.FLASH
_attr_translation_key = "button_light"
def __init__(self, channel: VelbusChannel) -> None:
"""Initialize the button light (led)."""

View File

@@ -40,13 +40,13 @@ rules:
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: todo
comment: |
@@ -56,7 +56,7 @@ rules:
entity-disabled-by-default: done
entity-translations: todo
exception-translations: done
icon-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt

View File

@@ -31,6 +31,7 @@ class VelbusSelect(VelbusEntity, SelectEntity):
_channel: SelectedProgram
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "select_program"
def __init__(
self,

View File

@@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VeluxConfigEntry
from .entity import VeluxEntity
from .entity import VeluxEntity, wrap_pyvlx_call_exceptions
PARALLEL_UPDATES = 1
@@ -98,10 +98,7 @@ class VeluxCover(VeluxEntity, CoverEntity):
@property
def is_closed(self) -> bool:
"""Return if the cover is closed."""
# do not use the node's closed state but rely on cover position
# until https://github.com/Julius2342/pyvlx/pull/543 is merged.
# once merged this can again return self.node.position.closed
return self.current_cover_position == 0
return self.node.position.closed
@property
def is_opening(self) -> bool:
@@ -113,14 +110,17 @@ class VeluxCover(VeluxEntity, CoverEntity):
"""Return if the cover is closing or not."""
return self.node.is_closing
@wrap_pyvlx_call_exceptions
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
await self.node.close(wait_for_completion=False)
@wrap_pyvlx_call_exceptions
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
await self.node.open(wait_for_completion=False)
@wrap_pyvlx_call_exceptions
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
position_percent = 100 - kwargs[ATTR_POSITION]
@@ -129,22 +129,27 @@ class VeluxCover(VeluxEntity, CoverEntity):
Position(position_percent=position_percent), wait_for_completion=False
)
@wrap_pyvlx_call_exceptions
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
await self.node.stop(wait_for_completion=False)
@wrap_pyvlx_call_exceptions
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close cover tilt."""
await cast(Blind, self.node).close_orientation(wait_for_completion=False)
@wrap_pyvlx_call_exceptions
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open cover tilt."""
await cast(Blind, self.node).open_orientation(wait_for_completion=False)
@wrap_pyvlx_call_exceptions
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
"""Stop cover tilt."""
await cast(Blind, self.node).stop_orientation(wait_for_completion=False)
@wrap_pyvlx_call_exceptions
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move cover tilt to a specific position."""
position_percent = 100 - kwargs[ATTR_TILT_POSITION]

View File

@@ -1,10 +1,13 @@
"""Support for VELUX KLF 200 devices."""
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
import logging
from typing import Any, ParamSpec
from pyvlx import Node
from pyvlx import Node, PyVLXException
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -12,6 +15,32 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
P = ParamSpec("P")
def wrap_pyvlx_call_exceptions(
func: Callable[P, Coroutine[Any, Any, None]],
) -> Callable[P, Coroutine[Any, Any, None]]:
"""Decorate pyvlx calls to handle exceptions.
Catches OSError and PyVLXException and wraps them into HomeAssistantError
with translation support.
"""
@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
"""Wrap async function to catch exceptions thrown in pyvlx calls."""
try:
await func(*args, **kwargs)
except (OSError, PyVLXException) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="device_communication_error",
translation_placeholders={"error": str(err)},
) from err
return wrapper
class VeluxEntity(Entity):
"""Abstraction for all Velux entities."""

View File

@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VeluxConfigEntry
from .entity import VeluxEntity
from .entity import VeluxEntity, wrap_pyvlx_call_exceptions
PARALLEL_UPDATES = 1
@@ -49,6 +49,7 @@ class VeluxLight(VeluxEntity, LightEntity):
"""Return true if light is on."""
return not self.node.intensity.off and self.node.intensity.known
@wrap_pyvlx_call_exceptions
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
if ATTR_BRIGHTNESS in kwargs:
@@ -60,6 +61,7 @@ class VeluxLight(VeluxEntity, LightEntity):
else:
await self.node.turn_on(wait_for_completion=True)
@wrap_pyvlx_call_exceptions
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
await self.node.turn_off(wait_for_completion=True)

View File

@@ -22,7 +22,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: todo
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo

View File

@@ -13,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VeluxConfigEntry
from .const import DOMAIN
from .entity import wrap_pyvlx_call_exceptions
PARALLEL_UPDATES = 1
@@ -51,6 +52,7 @@ class VeluxScene(Scene):
identifiers={(DOMAIN, f"gateway_{config_entry_id}")},
)
@wrap_pyvlx_call_exceptions
async def async_activate(self, **kwargs: Any) -> None:
"""Activate the scene."""
await self.scene.run(wait_for_completion=False)

View File

@@ -48,6 +48,9 @@
}
},
"exceptions": {
"device_communication_error": {
"message": "Failed to communicate with Velux device: {error}"
},
"no_gateway_loaded": {
"message": "No loaded Velux gateway found"
},

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.83", "serialx==0.5.0"],
"requirements": ["zha==0.0.84", "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

@@ -780,6 +780,11 @@ ZEROCONF = {
"domain": "octoprint",
},
],
"_openevse._tcp.local.": [
{
"domain": "openevse",
},
],
"_owserver._tcp.local.": [
{
"domain": "onewire",

View File

@@ -1208,7 +1208,14 @@ def config_entry_attr(
if not isinstance(config_entry_id_, str):
raise TemplateError("Must provide a config entry ID")
if attr_name not in ("domain", "title", "state", "source", "disabled_by"):
if attr_name not in (
"domain",
"title",
"state",
"source",
"disabled_by",
"pref_disable_polling",
):
raise TemplateError("Invalid config entry attribute")
config_entry = hass.config_entries.async_get_entry(config_entry_id_)

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.1
home-assistant-intents==2026.1.1
home-assistant-frontend==20260107.0
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

40
requirements_all.txt generated
View File

@@ -83,7 +83,7 @@ PyRMVtransport==0.3.3
PySrDaliGateway==0.18.0
# homeassistant.components.switchbot
PySwitchbot==0.75.0
PySwitchbot==0.76.0
# homeassistant.components.switchmate
PySwitchmate==0.5.1
@@ -277,7 +277,7 @@ aioharmony==0.5.3
aiohasupervisor==0.3.3
# homeassistant.components.home_connect
aiohomeconnect==0.26.0
aiohomeconnect==0.28.0
# homeassistant.components.homekit_controller
aiohomekit==3.2.20
@@ -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
@@ -782,7 +782,7 @@ debugpy==1.8.17
decora-wifi==1.4
# homeassistant.components.ecovacs
deebot-client==17.0.0
deebot-client==17.0.1
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -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.1
home-assistant-frontend==20260107.0
# 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
@@ -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
@@ -2291,7 +2288,7 @@ pyotgw==2.2.2
pyotp==2.9.0
# homeassistant.components.overkiz
pyoverkiz==1.19.3
pyoverkiz==1.19.4
# homeassistant.components.palazzetti
pypalazzetti==0.1.20
@@ -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
@@ -2783,7 +2783,7 @@ rpi-bad-power==0.1.0
russound==0.2.0
# homeassistant.components.ruuvitag_ble
ruuvitag-ble==0.3.0
ruuvitag-ble==0.4.0
# homeassistant.components.yamaha
rxv==0.7.0
@@ -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.83
zha==0.0.84
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13

View File

@@ -83,7 +83,7 @@ PyRMVtransport==0.3.3
PySrDaliGateway==0.18.0
# homeassistant.components.switchbot
PySwitchbot==0.75.0
PySwitchbot==0.76.0
# homeassistant.components.syncthru
PySyncThru==0.8.0
@@ -265,7 +265,7 @@ aioharmony==0.5.3
aiohasupervisor==0.3.3
# homeassistant.components.home_connect
aiohomeconnect==0.26.0
aiohomeconnect==0.28.0
# homeassistant.components.homekit_controller
aiohomekit==3.2.20
@@ -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
@@ -691,7 +691,7 @@ dbus-fast==3.1.2
debugpy==1.8.17
# homeassistant.components.ecovacs
deebot-client==17.0.0
deebot-client==17.0.1
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -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.1
home-assistant-frontend==20260107.0
# 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
@@ -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
@@ -1938,7 +1935,7 @@ pyotgw==2.2.2
pyotp==2.9.0
# homeassistant.components.overkiz
pyoverkiz==1.19.3
pyoverkiz==1.19.4
# homeassistant.components.palazzetti
pypalazzetti==0.1.20
@@ -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
@@ -2331,7 +2331,7 @@ rova==0.4.1
rpi-bad-power==0.1.0
# homeassistant.components.ruuvitag_ble
ruuvitag-ble==0.3.0
ruuvitag-ble==0.4.0
# homeassistant.components.yamaha
rxv==0.7.0
@@ -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.83
zha==0.0.84
# 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

@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, Mock, patch
from mozart_api.models import (
Action,
BatteryState,
BeolinkPeer,
BeolinkSelf,
ContentItem,
@@ -34,6 +35,7 @@ from homeassistant.components.bang_olufsen.const import DOMAIN
from homeassistant.core import HomeAssistant
from .const import (
TEST_BATTERY,
TEST_DATA_CREATE_ENTRY,
TEST_DATA_CREATE_ENTRY_2,
TEST_DATA_CREATE_ENTRY_3,
@@ -125,6 +127,7 @@ async def mock_websocket_connection(
playback_metadata_callback = (
mock_mozart_client.get_playback_metadata_notifications.call_args[0][0]
)
battery_callback = mock_mozart_client.get_battery_notifications.call_args[0][0]
# Trigger callbacks. Try to use existing data
volume_callback(mock_mozart_client.get_product_state.return_value.volume)
@@ -137,6 +140,10 @@ async def mock_websocket_connection(
playback_metadata_callback(
mock_mozart_client.get_product_state.return_value.playback.metadata
)
# This should not affect non-battery devices.
battery_callback(TEST_BATTERY)
await hass.async_block_till_done()
@@ -403,6 +410,14 @@ def mock_mozart_client() -> Generator[AsyncMock]:
)
]
)
client.get_battery_state = AsyncMock()
client.get_battery_state.return_value = BatteryState(
battery_level=0,
is_charging=False,
remaining_charging_time_minutes=0,
remaining_playing_time_minutes=0,
state="BatteryNotPresent",
)
client.post_standby = AsyncMock()
client.set_current_volume_level = AsyncMock()
client.set_volume_mute = AsyncMock()

View File

@@ -6,6 +6,7 @@ from unittest.mock import Mock
from mozart_api.exceptions import ApiException
from mozart_api.models import (
Action,
BatteryState,
ListeningModeRef,
OverlayPlayRequest,
OverlayPlayRequestTextToSpeechTextToSpeech,
@@ -71,14 +72,18 @@ TEST_NAME_4 = f"{TEST_MODEL_A5}-{TEST_SERIAL_NUMBER_4}"
TEST_JID_4 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER_4}@products.bang-olufsen.com"
TEST_MEDIA_PLAYER_ENTITY_ID_4 = f"media_player.beosound_a5_{TEST_SERIAL_NUMBER_4}"
TEST_HOST_4 = "192.168.0.4"
TEST_BATTERY_A5_SENSOR_ENTITY_ID = f"sensor.beosound_a5_{TEST_SERIAL_NUMBER_4}_battery"
# Beoremote One
TEST_REMOTE_SERIAL = "55555555"
TEST_REMOTE_SERIAL_PAIRED = f"{TEST_REMOTE_SERIAL}_{TEST_SERIAL_NUMBER}"
TEST_REMOTE_SW_VERSION = "1.0.0"
TEST_BUTTON_EVENT_ENTITY_ID = "event.beosound_balance_11111111_play_pause"
TEST_REMOTE_KEY_EVENT_ENTITY_ID = "event.beoremote_one_55555555_11111111_control_play"
TEST_REMOTE_BATTERY_LEVEL_SENSOR_ENTITY_ID = (
"sensor.beoremote_one_55555555_11111111_battery"
)
TEST_BUTTON_EVENT_ENTITY_ID = "event.beosound_balance_11111111_play_pause"
TEST_HOSTNAME_ZEROCONF = TEST_NAME.replace(" ", "-") + ".local."
TEST_TYPE_ZEROCONF = "_bangolufsen._tcp.local."
@@ -255,3 +260,10 @@ TEST_SOUND_MODES = [
TEST_ACTIVE_SOUND_MODE_NAME_2,
f"{TEST_SOUND_MODE_NAME} 2 (345)",
]
TEST_BATTERY = BatteryState(
battery_level=5,
is_charging=False,
remaining_charging_time_minutes=0,
remaining_playing_time_minutes=0,
state="BatteryVeryLow",
)

View File

@@ -106,6 +106,117 @@
'entity_id': 'event.beoremote_one_55555555_11111111_control_play',
'state': 'unknown',
}),
'remote_55555555_battery_level': dict({
'attributes': dict({
'device_class': 'battery',
'friendly_name': 'Beoremote One-55555555-11111111 Battery',
'state_class': 'measurement',
'unit_of_measurement': '%',
}),
'entity_id': 'sensor.beoremote_one_55555555_11111111_battery',
'state': '50',
}),
'websocket_connected': False,
})
# ---
# name: test_async_get_config_entry_diagnostics_with_battery
dict({
'battery_level': dict({
'attributes': dict({
'device_class': 'battery',
'friendly_name': 'Living room Balance Battery',
'state_class': 'measurement',
'unit_of_measurement': '%',
}),
'entity_id': 'sensor.beosound_a5_44444444_battery',
'state': '5',
}),
'config_entry': dict({
'data': dict({
'host': '192.168.0.4',
'jid': '1111.1111111.44444444@products.bang-olufsen.com',
'model': 'Beosound A5',
'name': 'Beosound A5-44444444',
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'bang_olufsen',
'minor_version': 1,
'options': dict({
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'user',
'subentries': list([
]),
'title': 'Beosound A5-44444444',
'unique_id': '44444444',
'version': 1,
}),
'media_player': dict({
'attributes': dict({
'beolink': dict({
'listeners': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'peers': dict({
'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com',
'Lounge room A5': '1111.1111111.44444444@products.bang-olufsen.com',
}),
'self': dict({
'Living room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
}),
}),
'device_class': 'speaker',
'entity_picture_local': None,
'friendly_name': 'Living room Balance',
'group_members': list([
'media_player.beosound_a5_44444444',
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'media_player.beosound_a5_44444444',
]),
'media_content_type': 'music',
'repeat': 'off',
'shuffle': False,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
'Test Listening Mode (123)',
'Test Listening Mode (234)',
'Test Listening Mode 2 (345)',
]),
'source_list': list([
'Tidal',
'Line-In',
'HDMI A',
]),
'supported_features': 2095933,
}),
'entity_id': 'media_player.beosound_a5_44444444',
'state': 'playing',
}),
'remote_55555555': dict({
'address': '',
'app_version': '1.0.0',
'battery_level': 50,
'connected': True,
'db_version': None,
'last_seen': None,
'name': 'BEORC',
'serial_number': '55555555',
'updated': None,
}),
'remote_55555555_battery_level': dict({
'attributes': dict({
'device_class': 'battery',
'friendly_name': 'Beoremote One-55555555-44444444 Battery',
'state_class': 'measurement',
'unit_of_measurement': '%',
}),
'entity_id': 'sensor.beoremote_one_55555555_44444444_battery',
'state': '50',
}),
'websocket_connected': False,
})
# ---

View File

@@ -100,6 +100,8 @@
'event.beoremote_one_55555555_44444444_control_function_25',
'event.beoremote_one_55555555_44444444_control_function_26',
'event.beoremote_one_55555555_44444444_control_function_27',
'sensor.beosound_a5_44444444_battery',
'sensor.beoremote_one_55555555_44444444_battery',
'media_player.beosound_a5_44444444',
])
# ---
@@ -205,6 +207,7 @@
'event.beoremote_one_55555555_11111111_control_function_25',
'event.beoremote_one_55555555_11111111_control_function_26',
'event.beoremote_one_55555555_11111111_control_function_27',
'sensor.beoremote_one_55555555_11111111_battery',
'media_player.beosound_balance_11111111',
])
# ---
@@ -308,6 +311,7 @@
'event.beoremote_one_55555555_33333333_control_function_25',
'event.beoremote_one_55555555_33333333_control_function_26',
'event.beoremote_one_55555555_33333333_control_function_27',
'sensor.beoremote_one_55555555_33333333_battery',
'media_player.beosound_premiere_33333333',
])
# ---

View File

@@ -101,6 +101,7 @@
'event.beoremote_one_55555555_11111111_control_function_25',
'event.beoremote_one_55555555_11111111_control_function_26',
'event.beoremote_one_55555555_11111111_control_function_27',
'sensor.beoremote_one_55555555_11111111_battery',
'media_player.beosound_balance_11111111',
])
# ---
@@ -206,6 +207,7 @@
'event.beoremote_one_55555555_11111111_control_function_25',
'event.beoremote_one_55555555_11111111_control_function_26',
'event.beoremote_one_55555555_11111111_control_function_27',
'sensor.beoremote_one_55555555_11111111_battery',
'media_player.beosound_balance_11111111',
'event.beoremote_one_66666666_11111111_light_blue',
'event.beoremote_one_66666666_11111111_light_digit_0',
@@ -297,6 +299,7 @@
'event.beoremote_one_66666666_11111111_control_function_25',
'event.beoremote_one_66666666_11111111_control_function_26',
'event.beoremote_one_66666666_11111111_control_function_27',
'sensor.beoremote_one_66666666_11111111_battery',
])
# ---
# name: test_on_remote_control_unpaired

View File

@@ -1,5 +1,6 @@
"""Test bang_olufsen config entry diagnostics."""
from mozart_api.models import BatteryState
from syrupy.assertion import SnapshotAssertion
from syrupy.filters import props
@@ -51,3 +52,39 @@ async def test_async_get_config_entry_diagnostics(
"modified_at",
)
)
async def test_async_get_config_entry_diagnostics_with_battery(
hass: HomeAssistant,
entity_registry: EntityRegistry,
hass_client: ClientSessionGenerator,
mock_config_entry_a5: MockConfigEntry,
mock_mozart_client: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test config entry diagnostics for devices with a battery."""
mock_mozart_client.get_battery_state.return_value = BatteryState(
battery_level=1, state="BatteryVeryLow"
)
# Load entry
mock_config_entry_a5.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry_a5.entry_id)
await mock_websocket_connection(hass, mock_mozart_client)
result = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry_a5
)
assert result == snapshot(
exclude=props(
"created_at",
"entry_id",
"id",
"last_changed",
"last_reported",
"last_updated",
"media_position_updated_at",
"modified_at",
)
)

View File

@@ -18,6 +18,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry
from .conftest import mock_websocket_connection
from .const import (
TEST_BATTERY,
TEST_BUTTON_EVENT_ENTITY_ID,
TEST_REMOTE_KEY_EVENT_ENTITY_ID,
TEST_SERIAL_NUMBER_3,
@@ -130,6 +131,7 @@ async def test_button_event_creation_a5(
snapshot: SnapshotAssertion,
) -> None:
"""Test Microphone button event entity is not created when using a Beosound A5."""
mock_mozart_client.get_battery_state.return_value = TEST_BATTERY
await _check_button_event_creation(
hass,

View File

@@ -0,0 +1,80 @@
"""Test the bang_olufsen sensor entities."""
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
from mozart_api.models import PairedRemote, PairedRemoteResponse
from homeassistant.components.bang_olufsen.sensor import SCAN_INTERVAL
from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from .conftest import mock_websocket_connection
from .const import (
TEST_BATTERY,
TEST_BATTERY_A5_SENSOR_ENTITY_ID,
TEST_REMOTE_BATTERY_LEVEL_SENSOR_ENTITY_ID,
TEST_REMOTE_SERIAL,
)
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_battery_level(
hass: HomeAssistant,
mock_mozart_client: AsyncMock,
mock_config_entry_a5: MockConfigEntry,
) -> None:
"""Test the battery level entity."""
# Ensure battery entities are created
mock_mozart_client.get_battery_state.return_value = TEST_BATTERY
# Load entry
mock_config_entry_a5.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry_a5.entry_id)
# Deliberately avoid triggering a battery notification
assert (states := hass.states.get(TEST_BATTERY_A5_SENSOR_ENTITY_ID))
assert states.state is STATE_UNKNOWN
# Check sensor reacts as expected to WebSocket events
await mock_websocket_connection(hass, mock_mozart_client)
assert (states := hass.states.get(TEST_BATTERY_A5_SENSOR_ENTITY_ID))
assert states.state == str(TEST_BATTERY.battery_level)
async def test_remote_battery_level(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
integration: None,
mock_config_entry: MockConfigEntry,
mock_mozart_client: AsyncMock,
) -> None:
"""Test the remote battery level entity."""
# Check the default value is set
assert (states := hass.states.get(TEST_REMOTE_BATTERY_LEVEL_SENSOR_ENTITY_ID))
assert states.state == "50"
# Change battery level
mock_mozart_client.get_bluetooth_remotes.return_value = PairedRemoteResponse(
items=[
PairedRemote(
address="",
app_version="1.0.0",
battery_level=45,
connected=True,
serial_number=TEST_REMOTE_SERIAL,
name="BEORC",
)
]
)
# Trigger poll update
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (states := hass.states.get(TEST_REMOTE_BATTERY_LEVEL_SENSOR_ENTITY_ID))
assert states.state == "45"

View File

@@ -130,7 +130,7 @@ async def test_on_remote_control_already_added(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
# Check device and API call count
assert mock_mozart_client.get_bluetooth_remotes.call_count == 1
assert mock_mozart_client.get_bluetooth_remotes.call_count == 3
assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)})
# Check number of entities (remote and button events and media_player)
@@ -149,7 +149,7 @@ async def test_on_remote_control_already_added(
await hass.async_block_till_done()
# Check device and API call count (triggered once by the WebSocket notification)
assert mock_mozart_client.get_bluetooth_remotes.call_count == 2
assert mock_mozart_client.get_bluetooth_remotes.call_count == 4
assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)})
# Check number of entities (remote and button events and media_player)
@@ -176,7 +176,7 @@ async def test_on_remote_control_paired(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
# Check device and API call count
assert mock_mozart_client.get_bluetooth_remotes.call_count == 1
assert mock_mozart_client.get_bluetooth_remotes.call_count == 3
assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)})
# Check number of entities (button and remote events and media_player)
@@ -217,7 +217,7 @@ async def test_on_remote_control_paired(
await hass.async_block_till_done()
# Check device and API call count
assert mock_mozart_client.get_bluetooth_remotes.call_count == 3
assert mock_mozart_client.get_bluetooth_remotes.call_count == 8
assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)})
assert device_registry.async_get_device(
{(DOMAIN, f"66666666_{TEST_SERIAL_NUMBER}")}
@@ -257,7 +257,7 @@ async def test_on_remote_control_unpaired(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
# Check device and API call count
assert mock_mozart_client.get_bluetooth_remotes.call_count == 1
assert mock_mozart_client.get_bluetooth_remotes.call_count == 3
assert device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)})
# Check number of entities (button and remote events and media_player)
@@ -280,7 +280,7 @@ async def test_on_remote_control_unpaired(
await hass.async_block_till_done()
# Check device and API call count
assert mock_mozart_client.get_bluetooth_remotes.call_count == 3
assert mock_mozart_client.get_bluetooth_remotes.call_count == 6
assert (
device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)}) is None
)

View File

@@ -11,6 +11,7 @@ from homeassistant.components.bang_olufsen.const import (
)
from .const import (
TEST_BATTERY_A5_SENSOR_ENTITY_ID,
TEST_MEDIA_PLAYER_ENTITY_ID,
TEST_MEDIA_PLAYER_ENTITY_ID_2,
TEST_MEDIA_PLAYER_ENTITY_ID_3,
@@ -51,6 +52,7 @@ def get_a5_entity_ids() -> list[str]:
"""Return a list of entity_ids that a Beosound A5 provides."""
buttons = [
TEST_MEDIA_PLAYER_ENTITY_ID_4,
TEST_BATTERY_A5_SENSOR_ENTITY_ID,
*_get_button_entity_ids("beosound_a5_44444444"),
]
buttons.remove("event.beosound_a5_44444444_microphone")
@@ -66,7 +68,9 @@ def get_remote_entity_ids(
remote_serial: str = TEST_REMOTE_SERIAL, device_serial: str = TEST_SERIAL_NUMBER
) -> list[str]:
"""Return a list of entity_ids that the Beoremote One provides."""
entity_ids: list[str] = []
entity_ids: list[str] = [
f"sensor.beoremote_one_{remote_serial}_{device_serial}_battery"
]
# Add remote light key Event entity ids
entity_ids.extend(

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,
@@ -105,12 +99,12 @@ async def test_climate_triggers_gated_by_labs_flag(
# Valid configurations
(
"climate.hvac_mode_changed",
{CONF_HVAC_MODE: [HVACMode.HEAT, HVACMode.COOL]},
{CONF_HVAC_MODE: ["heat", "cool"]},
does_not_raise(),
),
(
"climate.hvac_mode_changed",
{CONF_HVAC_MODE: HVACMode.HEAT},
{CONF_HVAC_MODE: "heat"},
does_not_raise(),
),
# Invalid configurations
@@ -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"),
@@ -280,7 +157,7 @@ def parametrize_xxx_crossed_threshold_trigger_states(
[
*parametrize_trigger_states(
trigger="climate.hvac_mode_changed",
trigger_options={CONF_HVAC_MODE: [HVACMode.HEAT, HVACMode.COOL]},
trigger_options={CONF_HVAC_MODE: ["heat", "cool"]},
target_states=[HVACMode.HEAT, HVACMode.COOL],
other_states=other_states([HVACMode.HEAT, HVACMode.COOL]),
),
@@ -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",
@@ -440,7 +325,7 @@ async def test_climate_state_attribute_trigger_behavior_any(
[
*parametrize_trigger_states(
trigger="climate.hvac_mode_changed",
trigger_options={CONF_HVAC_MODE: [HVACMode.HEAT, HVACMode.COOL]},
trigger_options={CONF_HVAC_MODE: ["heat", "cool"]},
target_states=[HVACMode.HEAT, HVACMode.COOL],
other_states=other_states([HVACMode.HEAT, HVACMode.COOL]),
),
@@ -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",
@@ -590,7 +481,7 @@ async def test_climate_state_attribute_trigger_behavior_first(
[
*parametrize_trigger_states(
trigger="climate.hvac_mode_changed",
trigger_options={CONF_HVAC_MODE: [HVACMode.HEAT, HVACMode.COOL]},
trigger_options={CONF_HVAC_MODE: ["heat", "cool"]},
target_states=[HVACMode.HEAT, HVACMode.COOL],
other_states=other_states([HVACMode.HEAT, HVACMode.COOL]),
),
@@ -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

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

@@ -1,10 +1,18 @@
"""Test fixtures for NINA."""
from collections.abc import Generator
from copy import deepcopy
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.nina.const import DOMAIN
from homeassistant.core import HomeAssistant
from .const import DUMMY_CONFIG_ENTRY
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
@@ -13,3 +21,19 @@ def mock_setup_entry() -> Generator[AsyncMock]:
"homeassistant.components.nina.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Provide a common mock config entry."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title="NINA",
data=deepcopy(DUMMY_CONFIG_ENTRY),
version=1,
minor_version=3,
)
config_entry.add_to_hass(hass)
return config_entry

View File

@@ -0,0 +1,39 @@
"""Common constants for NINA tests."""
from copy import deepcopy
from typing import Any
from homeassistant.components.nina.const import (
CONF_AREA_FILTER,
CONF_FILTERS,
CONF_HEADLINE_FILTER,
CONF_MESSAGE_SLOTS,
CONF_REGIONS,
CONST_REGION_A_TO_D,
CONST_REGION_E_TO_H,
CONST_REGION_I_TO_L,
CONST_REGION_M_TO_Q,
CONST_REGION_R_TO_U,
CONST_REGION_V_TO_Z,
)
DUMMY_USER_INPUT: dict[str, Any] = {
CONF_MESSAGE_SLOTS: 5,
CONST_REGION_A_TO_D: ["095760000000_0", "095760000000_1"],
CONST_REGION_E_TO_H: ["160650000000_14", "146260000000_0"],
CONST_REGION_I_TO_L: ["083370000000_22", "055660000000_5"],
CONST_REGION_M_TO_Q: ["010590000000_25", "032510000000_40"],
CONST_REGION_R_TO_U: ["010560000000_16", "010590000000_94"],
CONST_REGION_V_TO_Z: ["010610000000_73", "010610000000_74"],
CONF_FILTERS: {
CONF_HEADLINE_FILTER: ".*corona.*",
CONF_AREA_FILTER: ".*",
},
}
DUMMY_CONFIG_ENTRY: dict[str, Any] = {
CONF_FILTERS: deepcopy(DUMMY_USER_INPUT[CONF_FILTERS]),
CONF_MESSAGE_SLOTS: deepcopy(DUMMY_USER_INPUT[CONF_MESSAGE_SLOTS]),
CONST_REGION_A_TO_D: deepcopy(DUMMY_USER_INPUT[CONST_REGION_A_TO_D]),
CONF_REGIONS: {"095760000000": "Aach"},
}

View File

@@ -29,33 +29,26 @@ from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import entity_registry as er
from . import mocked_request_function
from .const import DUMMY_CONFIG_ENTRY, DUMMY_USER_INPUT
from tests.common import MockConfigEntry, load_fixture
DUMMY_DATA: dict[str, Any] = {
CONF_MESSAGE_SLOTS: 5,
CONST_REGION_A_TO_D: ["095760000000_0", "095760000000_1"],
CONST_REGION_E_TO_H: ["160650000000_14", "146260000000_0"],
CONST_REGION_I_TO_L: ["083370000000_22", "055660000000_5"],
CONST_REGION_M_TO_Q: ["010590000000_25", "032510000000_40"],
CONST_REGION_R_TO_U: ["010560000000_16", "010590000000_94"],
CONST_REGION_V_TO_Z: ["010610000000_73", "010610000000_74"],
CONF_FILTERS: {
CONF_HEADLINE_FILTER: ".*corona.*",
CONF_AREA_FILTER: ".*",
},
}
DUMMY_RESPONSE_REGIONS: dict[str, Any] = json.loads(
load_fixture("sample_regions.json", "nina")
)
OPTIONS_ENTRY_DATA: dict[str, Any] = {
CONF_FILTERS: deepcopy(DUMMY_DATA[CONF_FILTERS]),
CONF_MESSAGE_SLOTS: deepcopy(DUMMY_DATA[CONF_MESSAGE_SLOTS]),
CONST_REGION_A_TO_D: deepcopy(DUMMY_DATA[CONST_REGION_A_TO_D]),
CONF_REGIONS: {"095760000000": "Aach"},
}
def assert_dummy_entry_created(result: dict[str, Any]) -> None:
"""Asserts that an entry from DUMMY_USER_INPUT is created."""
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "NINA"
assert result["data"] == DUMMY_USER_INPUT | {
CONF_REGIONS: {
"095760000000": "Allersberg, M (Roth - Bayern) + Büchenbach (Roth - Bayern)"
}
}
assert result["version"] == 1
assert result["minor_version"] == 3
async def test_step_user_connection_error(hass: HomeAssistant) -> None:
@@ -65,7 +58,7 @@ async def test_step_user_connection_error(hass: HomeAssistant) -> None:
side_effect=ApiError("Could not connect to Api"),
):
result: dict[str, Any] = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA)
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
@@ -79,7 +72,7 @@ async def test_step_user_unexpected_exception(hass: HomeAssistant) -> None:
side_effect=Exception("DUMMY"),
):
result: dict[str, Any] = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA)
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
@@ -93,18 +86,15 @@ async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No
wraps=mocked_request_function,
):
result: dict[str, Any] = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA)
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "NINA"
assert result["data"] == DUMMY_DATA | {
CONF_REGIONS: {
"095760000000": "Allersberg, M (Roth - Bayern) + Büchenbach (Roth - Bayern)"
}
}
assert result["version"] == 1
assert result["minor_version"] == 3
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=deepcopy(DUMMY_USER_INPUT),
)
assert_dummy_entry_created(result)
async def test_step_user_no_selection(hass: HomeAssistant) -> None:
@@ -125,30 +115,22 @@ async def test_step_user_no_selection(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=deepcopy(DUMMY_DATA),
user_input=deepcopy(DUMMY_USER_INPUT),
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "NINA"
assert result["data"] == DUMMY_DATA | {
CONF_REGIONS: {
"095760000000": "Allersberg, M (Roth - Bayern) + Büchenbach (Roth - Bayern)"
}
}
assert_dummy_entry_created(result)
async def test_step_user_already_configured(hass: HomeAssistant) -> None:
async def test_step_user_already_configured(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test starting a flow by user, but it was already configured."""
with patch(
"pynina.baseApi.BaseAPI._makeRequest",
wraps=mocked_request_function,
):
await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA)
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA)
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
@@ -156,28 +138,21 @@ async def test_step_user_already_configured(hass: HomeAssistant) -> None:
async def test_options_flow_init(
hass: HomeAssistant, mock_setup_entry: AsyncMock
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test config flow options."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title="NINA",
data=deepcopy(OPTIONS_ENTRY_DATA),
version=1,
minor_version=3,
)
config_entry.add_to_hass(hass)
with (
patch(
"pynina.baseApi.BaseAPI._makeRequest",
wraps=mocked_request_function,
),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(config_entry.entry_id)
result = await hass.config_entries.options.async_init(
mock_config_entry.entry_id
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
@@ -201,9 +176,9 @@ async def test_options_flow_init(
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {}
assert dict(config_entry.data) == {
CONF_FILTERS: DUMMY_DATA[CONF_FILTERS],
CONF_MESSAGE_SLOTS: DUMMY_DATA[CONF_MESSAGE_SLOTS],
assert dict(mock_config_entry.data) == {
CONF_FILTERS: DUMMY_USER_INPUT[CONF_FILTERS],
CONF_MESSAGE_SLOTS: DUMMY_USER_INPUT[CONF_MESSAGE_SLOTS],
CONST_REGION_A_TO_D: ["072350000000_1"],
CONST_REGION_E_TO_H: [],
CONST_REGION_I_TO_L: [],
@@ -217,28 +192,21 @@ async def test_options_flow_init(
async def test_options_flow_with_no_selection(
hass: HomeAssistant, mock_setup_entry: AsyncMock
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test config flow options with no selection."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title="NINA",
data=deepcopy(OPTIONS_ENTRY_DATA),
version=1,
minor_version=3,
)
config_entry.add_to_hass(hass)
with (
patch(
"pynina.baseApi.BaseAPI._makeRequest",
wraps=mocked_request_function,
),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(config_entry.entry_id)
result = await hass.config_entries.options.async_init(
mock_config_entry.entry_id
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
@@ -279,9 +247,9 @@ async def test_options_flow_with_no_selection(
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {}
assert dict(config_entry.data) == {
CONF_FILTERS: DUMMY_DATA[CONF_FILTERS],
CONF_MESSAGE_SLOTS: DUMMY_DATA[CONF_MESSAGE_SLOTS],
assert dict(mock_config_entry.data) == {
CONF_FILTERS: DUMMY_USER_INPUT[CONF_FILTERS],
CONF_MESSAGE_SLOTS: DUMMY_USER_INPUT[CONF_MESSAGE_SLOTS],
CONST_REGION_A_TO_D: ["095760000000_0"],
CONST_REGION_E_TO_H: [],
CONST_REGION_I_TO_L: [],
@@ -293,54 +261,40 @@ async def test_options_flow_with_no_selection(
async def test_options_flow_connection_error(
hass: HomeAssistant, mock_setup_entry: AsyncMock
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test config flow options but no connection."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title="NINA",
data=deepcopy(OPTIONS_ENTRY_DATA),
version=1,
minor_version=3,
)
config_entry.add_to_hass(hass)
with patch(
"pynina.baseApi.BaseAPI._makeRequest",
side_effect=ApiError("Could not connect to Api"),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(config_entry.entry_id)
result = await hass.config_entries.options.async_init(
mock_config_entry.entry_id
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_fetch"
async def test_options_flow_unexpected_exception(
hass: HomeAssistant, mock_setup_entry: AsyncMock
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test config flow options but with an unexpected exception."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title="NINA",
data=deepcopy(OPTIONS_ENTRY_DATA),
version=1,
minor_version=3,
)
config_entry.add_to_hass(hass)
with (
patch(
"pynina.baseApi.BaseAPI._makeRequest",
side_effect=Exception("DUMMY"),
),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(config_entry.entry_id)
result = await hass.config_entries.options.async_init(
mock_config_entry.entry_id
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unknown"
@@ -353,7 +307,7 @@ async def test_options_flow_entity_removal(
config_entry = MockConfigEntry(
domain=DOMAIN,
title="NINA",
data=deepcopy(OPTIONS_ENTRY_DATA) | {CONF_REGIONS: {"095760000000": "Aach"}},
data=deepcopy(DUMMY_CONFIG_ENTRY) | {CONF_REGIONS: {"095760000000": "Aach"}},
version=1,
minor_version=3,
)

View File

@@ -16,23 +16,29 @@ 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
charger.test_and_get = AsyncMock()
charger.test_and_get.return_value = {
"serial": "deadbeeffeed",
"model": "openevse_wifi_v1",
}
yield charger
@@ -46,8 +52,25 @@ def mock_setup_entry() -> Generator[AsyncMock]:
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
def has_serial_number() -> bool:
"""Return a serial number."""
return True
@pytest.fixture
def serial_number(has_serial_number: bool) -> str | None:
"""Return a serial number."""
if has_serial_number:
return "deadbeeffeed"
return None
@pytest.fixture
def mock_config_entry(serial_number: str) -> MockConfigEntry:
"""Create a mock config entry."""
return MockConfigEntry(
domain=DOMAIN, data={CONF_HOST: "192.168.1.100"}, entry_id="FAKE"
domain=DOMAIN,
data={CONF_HOST: "192.168.1.100"},
entry_id="FAKE",
unique_id=serial_number,
)

View File

@@ -1,12 +1,18 @@
"""Tests for the OpenEVSE sensor platform."""
from ipaddress import ip_address
from unittest.mock import AsyncMock, MagicMock
from openevsehttp.exceptions import MissingSerial
from homeassistant.components.openevse.const import DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import MockConfigEntry
async def test_user_flow(
@@ -31,6 +37,7 @@ async def test_user_flow(
assert result["data"] == {
CONF_HOST: "10.0.0.131",
}
assert result["result"].unique_id == "deadbeeffeed"
async def test_user_flow_flaky(
@@ -45,7 +52,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 +61,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"},
@@ -64,6 +71,7 @@ async def test_user_flow_flaky(
assert result["data"] == {
CONF_HOST: "10.0.0.131",
}
assert result["result"].unique_id == "deadbeeffeed"
async def test_user_flow_duplicate(
@@ -104,6 +112,7 @@ async def test_import_flow(
assert result["data"] == {
CONF_HOST: "10.0.0.131",
}
assert result["result"].unique_id == "deadbeeffeed"
async def test_import_flow_bad(
@@ -112,7 +121,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"}
@@ -137,3 +146,168 @@ async def test_import_flow_duplicate(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_zeroconf_discovery(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_charger: MagicMock
) -> None:
"""Test zeroconf discovery."""
discovery_info = ZeroconfServiceInfo(
ip_address=ip_address("192.168.1.123"),
ip_addresses=[ip_address("192.168.1.123")],
hostname="openevse-deadbeeffeed.local.",
name="openevse-deadbeeffeed._openevse._tcp.local.",
port=80,
properties={"id": "deadbeeffeed", "type": "openevse"},
type="_openevse._tcp.local.",
)
# Trigger the zeroconf step
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=discovery_info,
)
# Should present a confirmation form
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
assert result["description_placeholders"] == {
"name": "OpenEVSE openevse-deadbeeffeed"
}
# Confirm the discovery
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
# Should create the entry
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "OpenEVSE openevse-deadbeeffeed"
assert result["data"] == {CONF_HOST: "192.168.1.123"}
assert result["result"].unique_id == "deadbeeffeed"
async def test_zeroconf_already_configured_unique_id(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_charger: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test zeroconf discovery updates info if unique_id is already configured."""
mock_config_entry.add_to_hass(hass)
discovery_info = ZeroconfServiceInfo(
ip_address=ip_address("192.168.1.124"),
ip_addresses=[ip_address("192.168.1.124"), ip_address("2001:db8::1")],
hostname="openevse-deadbeeffeed.local.",
name="openevse-deadbeeffeed._openevse._tcp.local.",
port=80,
properties={"id": "deadbeeffeed", "type": "openevse"},
type="_openevse._tcp.local.",
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=discovery_info,
)
# Should abort because unique_id matches, but it updates the config entry
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
# Verify the entry IP was updated to the new discovery IP
assert mock_config_entry.data["host"] == "192.168.1.124"
async def test_zeroconf_connection_error(
hass: HomeAssistant, mock_charger: MagicMock
) -> None:
"""Test zeroconf discovery with connection failure."""
mock_charger.test_and_get.side_effect = TimeoutError
discovery_info = ZeroconfServiceInfo(
ip_address=ip_address("192.168.1.123"),
ip_addresses=[ip_address("192.168.1.123"), ip_address("2001:db8::1")],
hostname="openevse-deadbeeffeed.local.",
name="openevse-deadbeeffeed._openevse._tcp.local.",
port=80,
properties={"id": "deadbeeffeed", "type": "openevse"},
type="_openevse._tcp.local.",
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=discovery_info,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
async def test_zeroconf_already_configured_host(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test zeroconf discovery aborts if host is already configured."""
mock_config_entry.add_to_hass(hass)
discovery_info = ZeroconfServiceInfo(
ip_address=ip_address("192.168.1.100"),
ip_addresses=[ip_address("192.168.1.100"), ip_address("2001:db8::1")],
hostname="openevse-deadbeeffeed.local.",
name="openevse-deadbeeffeed._openevse._tcp.local.",
port=80,
properties={"id": "deadbeeffeed", "type": "openevse"},
type="_openevse._tcp.local.",
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=discovery_info,
)
# Should abort because the host matches an existing entry
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_user_flow_no_serial(
hass: HomeAssistant,
mock_charger: MagicMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test user flow handles missing serial gracefully."""
mock_charger.test_and_get.side_effect = [{}, MissingSerial]
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.131"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "OpenEVSE 10.0.0.131"
assert result["result"].unique_id is None
async def test_import_flow_no_serial(
hass: HomeAssistant,
mock_charger: MagicMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test import flow handles missing serial gracefully."""
mock_charger.test_and_get.side_effect = [{}, MissingSerial]
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_HOST: "10.0.0.131"}
)
# Assert the flow continued to create the entry
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "OpenEVSE 10.0.0.131"
assert result["result"].unique_id is None

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,

Some files were not shown because too many files have changed in this diff Show More