mirror of
https://github.com/home-assistant/core.git
synced 2026-01-11 18:17:17 +01:00
Compare commits
49 Commits
homevolt
...
knx-expose
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a54c29896 | ||
|
|
73a5e02b74 | ||
|
|
43ced677e5 | ||
|
|
7a696935ed | ||
|
|
be3be360a7 | ||
|
|
092ebaaeb1 | ||
|
|
e8025317ed | ||
|
|
39b025dfea | ||
|
|
1b436a8808 | ||
|
|
a7440e3756 | ||
|
|
2c7852f94b | ||
|
|
bd4653f830 | ||
|
|
c0b2847a87 | ||
|
|
8853f6698b | ||
|
|
b1a3ad6ac3 | ||
|
|
dafa2e69e2 | ||
|
|
2c6d6f8ab4 | ||
|
|
10d32b7f23 | ||
|
|
e4dc4e0ced | ||
|
|
6f9794f235 | ||
|
|
b8cff13737 | ||
|
|
7777714cc0 | ||
|
|
f15d5cdf2a | ||
|
|
6181f4e7de | ||
|
|
80df3b5b80 | ||
|
|
6e32a2aa18 | ||
|
|
3b575fe3e3 | ||
|
|
229400de98 | ||
|
|
e963adfdf0 | ||
|
|
fd7bbc68c6 | ||
|
|
9281ab018c | ||
|
|
80baf86e23 | ||
|
|
db497b23fe | ||
|
|
a2fb8f5a72 | ||
|
|
6953bd4599 | ||
|
|
225be65f71 | ||
|
|
7b0463f763 | ||
|
|
4d305b657a | ||
|
|
d5a553c8c7 | ||
|
|
9169b68254 | ||
|
|
fde9bd95d5 | ||
|
|
e4db8ff86e | ||
|
|
a084e51345 | ||
|
|
00381e6dfd | ||
|
|
b6d493696a | ||
|
|
5f0500c3cd | ||
|
|
c61a63cc6f | ||
|
|
5445a4f40f | ||
|
|
2888cacc3f |
5
CODEOWNERS
generated
5
CODEOWNERS
generated
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
139
homeassistant/components/bang_olufsen/sensor.py
Normal file
139
homeassistant/components/bang_olufsen/sensor.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.const import CONF_HOST, CONF_MAC, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import CONF_USE_SSL
|
||||
from .coordinator import BraviaTVConfigEntry, BraviaTVCoordinator
|
||||
|
||||
PLATFORMS: Final[list[Platform]] = [
|
||||
@@ -26,11 +27,12 @@ async def async_setup_entry(
|
||||
"""Set up a config entry."""
|
||||
host = config_entry.data[CONF_HOST]
|
||||
mac = config_entry.data[CONF_MAC]
|
||||
ssl = config_entry.data.get(CONF_USE_SSL, False)
|
||||
|
||||
session = async_create_clientsession(
|
||||
hass, cookie_jar=CookieJar(unsafe=True, quote_cookie=False)
|
||||
)
|
||||
client = BraviaClient(host, mac, session=session)
|
||||
client = BraviaClient(host, mac, session=session, ssl=ssl)
|
||||
coordinator = BraviaTVCoordinator(
|
||||
hass=hass,
|
||||
config_entry=config_entry,
|
||||
|
||||
@@ -28,6 +28,7 @@ from .const import (
|
||||
ATTR_MODEL,
|
||||
CONF_NICKNAME,
|
||||
CONF_USE_PSK,
|
||||
CONF_USE_SSL,
|
||||
DOMAIN,
|
||||
NICKNAME_PREFIX,
|
||||
)
|
||||
@@ -46,11 +47,12 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
def create_client(self) -> None:
|
||||
"""Create Bravia TV client from config."""
|
||||
host = self.device_config[CONF_HOST]
|
||||
ssl = self.device_config[CONF_USE_SSL]
|
||||
session = async_create_clientsession(
|
||||
self.hass,
|
||||
cookie_jar=CookieJar(unsafe=True, quote_cookie=False),
|
||||
)
|
||||
self.client = BraviaClient(host=host, session=session)
|
||||
self.client = BraviaClient(host=host, session=session, ssl=ssl)
|
||||
|
||||
async def gen_instance_ids(self) -> tuple[str, str]:
|
||||
"""Generate client_id and nickname."""
|
||||
@@ -123,10 +125,10 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle authorize step."""
|
||||
self.create_client()
|
||||
|
||||
if user_input is not None:
|
||||
self.device_config[CONF_USE_PSK] = user_input[CONF_USE_PSK]
|
||||
self.device_config[CONF_USE_SSL] = user_input[CONF_USE_SSL]
|
||||
self.create_client()
|
||||
if user_input[CONF_USE_PSK]:
|
||||
return await self.async_step_psk()
|
||||
return await self.async_step_pin()
|
||||
@@ -136,6 +138,7 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USE_PSK, default=False): bool,
|
||||
vol.Required(CONF_USE_SSL, default=False): bool,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ ATTR_MODEL: Final = "model"
|
||||
|
||||
CONF_NICKNAME: Final = "nickname"
|
||||
CONF_USE_PSK: Final = "use_psk"
|
||||
CONF_USE_SSL: Final = "use_ssl"
|
||||
|
||||
DOMAIN: Final = "braviatv"
|
||||
LEGACY_CLIENT_ID: Final = "HomeAssistant"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -23,5 +23,5 @@
|
||||
"winter_mode": {}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20251229.1"]
|
||||
"requirements": ["home-assistant-frontend==20260107.0"]
|
||||
}
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -116,6 +116,8 @@ class IsraelRailEntitySensor(
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the state of the sensor."""
|
||||
if self.entity_description.index >= len(self.coordinator.data):
|
||||
return None
|
||||
return self.entity_description.value_fn(
|
||||
self.coordinator.data[self.entity_description.index]
|
||||
)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["jvcprojector"],
|
||||
"requirements": ["pyjvcprojector==1.1.2"]
|
||||
"requirements": ["pyjvcprojector==1.1.3"]
|
||||
}
|
||||
|
||||
@@ -41,6 +41,13 @@ COMMANDS = {
|
||||
"mode_1": const.REMOTE_MODE_1,
|
||||
"mode_2": const.REMOTE_MODE_2,
|
||||
"mode_3": const.REMOTE_MODE_3,
|
||||
"mode_4": const.REMOTE_MODE_4,
|
||||
"mode_5": const.REMOTE_MODE_5,
|
||||
"mode_6": const.REMOTE_MODE_6,
|
||||
"mode_7": const.REMOTE_MODE_7,
|
||||
"mode_8": const.REMOTE_MODE_8,
|
||||
"mode_9": const.REMOTE_MODE_9,
|
||||
"mode_10": const.REMOTE_MODE_10,
|
||||
"lens_ap": const.REMOTE_LENS_AP,
|
||||
"gamma": const.REMOTE_GAMMA,
|
||||
"color_temp": const.REMOTE_COLOR_TEMP,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -2,23 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import openevsewifi
|
||||
from openevsehttp.__main__ import OpenEVSE
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
|
||||
type OpenEVSEConfigEntry = ConfigEntry[openevsewifi.Charger]
|
||||
type OpenEVSEConfigEntry = ConfigEntry[OpenEVSE]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool:
|
||||
"""Set up openevse from a config entry."""
|
||||
|
||||
entry.runtime_data = openevsewifi.Charger(entry.data[CONF_HOST])
|
||||
entry.runtime_data = OpenEVSE(entry.data[CONF_HOST])
|
||||
try:
|
||||
await hass.async_add_executor_job(entry.runtime_data.getStatus)
|
||||
except AttributeError as ex:
|
||||
await entry.runtime_data.test_and_get()
|
||||
except TimeoutError as ex:
|
||||
raise ConfigEntryError("Unable to connect to charger") from ex
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR])
|
||||
|
||||
@@ -2,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]},
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Constants for the OpenEVSE integration."""
|
||||
|
||||
CONF_ID = "id"
|
||||
CONF_SERIAL = "serial"
|
||||
DOMAIN = "openevse"
|
||||
INTEGRATION_TITLE = "OpenEVSE"
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import openevsewifi
|
||||
from requests import RequestException
|
||||
from openevsehttp.__main__ import OpenEVSE
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -175,7 +174,7 @@ class OpenEVSESensor(SensorEntity):
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
charger: openevsewifi.Charger,
|
||||
charger: OpenEVSE,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
@@ -183,25 +182,28 @@ class OpenEVSESensor(SensorEntity):
|
||||
self.host = host
|
||||
self.charger = charger
|
||||
|
||||
def update(self) -> None:
|
||||
async def async_update(self) -> None:
|
||||
"""Get the monitored data from the charger."""
|
||||
try:
|
||||
sensor_type = self.entity_description.key
|
||||
if sensor_type == "status":
|
||||
self._attr_native_value = self.charger.getStatus()
|
||||
elif sensor_type == "charge_time":
|
||||
self._attr_native_value = self.charger.getChargeTimeElapsed() / 60
|
||||
elif sensor_type == "ambient_temp":
|
||||
self._attr_native_value = self.charger.getAmbientTemperature()
|
||||
elif sensor_type == "ir_temp":
|
||||
self._attr_native_value = self.charger.getIRTemperature()
|
||||
elif sensor_type == "rtc_temp":
|
||||
self._attr_native_value = self.charger.getRTCTemperature()
|
||||
elif sensor_type == "usage_session":
|
||||
self._attr_native_value = float(self.charger.getUsageSession()) / 1000
|
||||
elif sensor_type == "usage_total":
|
||||
self._attr_native_value = float(self.charger.getUsageTotal()) / 1000
|
||||
else:
|
||||
self._attr_native_value = "Unknown"
|
||||
except (RequestException, ValueError, KeyError):
|
||||
await self.charger.update()
|
||||
except TimeoutError:
|
||||
_LOGGER.warning("Could not update status for %s", self.name)
|
||||
return
|
||||
|
||||
sensor_type = self.entity_description.key
|
||||
if sensor_type == "status":
|
||||
self._attr_native_value = self.charger.status
|
||||
elif sensor_type == "charge_time":
|
||||
self._attr_native_value = self.charger.charge_time_elapsed / 60
|
||||
elif sensor_type == "ambient_temp":
|
||||
self._attr_native_value = self.charger.ambient_temperature
|
||||
elif sensor_type == "ir_temp":
|
||||
self._attr_native_value = self.charger.ir_temperature
|
||||
elif sensor_type == "rtc_temp":
|
||||
self._attr_native_value = self.charger.rtc_temperature
|
||||
elif sensor_type == "usage_session":
|
||||
self._attr_native_value = float(self.charger.usage_session) / 1000
|
||||
elif sensor_type == "usage_total":
|
||||
self._attr_native_value = float(self.charger.usage_total) / 1000
|
||||
else:
|
||||
self._attr_native_value = "Unknown"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Enter the IP Address of your openevse. Should match the address you used to set it up."
|
||||
"host": "Enter the IP address of your OpenEVSE. Should match the address you used to set it up."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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*",
|
||||
|
||||
@@ -453,10 +453,6 @@ async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall)
|
||||
# Imports deferred to avoid loading modules
|
||||
# in memory since usually only one part of this
|
||||
# integration is used at a time
|
||||
if sys.version_info >= (3, 14):
|
||||
raise HomeAssistantError(
|
||||
"Memory profiling is not supported on Python 3.14. Please use Python 3.13."
|
||||
)
|
||||
from guppy import hpy # noqa: PLC0415
|
||||
|
||||
start_time = int(time.time() * 1000000)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"pyprof2calltree==1.4.5",
|
||||
"guppy3==3.1.5;python_version<'3.14'",
|
||||
"guppy3==3.1.6",
|
||||
"objgraph==3.5.0"
|
||||
],
|
||||
"single_config_entry": true
|
||||
|
||||
@@ -128,8 +128,9 @@ class RingCam(RingEntity[RingDoorBell], Camera):
|
||||
self._device = self._get_coordinator_data().get_video_device(
|
||||
self._device.device_api_id
|
||||
)
|
||||
|
||||
history_data = self._device.last_history
|
||||
if history_data:
|
||||
if history_data and self._device.has_subscription:
|
||||
self._last_event = history_data[0]
|
||||
# will call async_update to update the attributes and get the
|
||||
# video url from the api
|
||||
@@ -154,8 +155,16 @@ class RingCam(RingEntity[RingDoorBell], Camera):
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Return a still image response from the camera."""
|
||||
if self._video_url is None:
|
||||
if not self._device.has_subscription:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_subscription",
|
||||
)
|
||||
return None
|
||||
|
||||
key = (width, height)
|
||||
if not (image := self._images.get(key)) and self._video_url is not None:
|
||||
if not (image := self._images.get(key)):
|
||||
image = await ffmpeg.async_get_image(
|
||||
self.hass,
|
||||
self._video_url,
|
||||
|
||||
@@ -151,6 +151,9 @@
|
||||
"api_timeout": {
|
||||
"message": "Timeout communicating with Ring API"
|
||||
},
|
||||
"no_subscription": {
|
||||
"message": "Ring Protect subscription required for snapshots"
|
||||
},
|
||||
"sdp_m_line_index_required": {
|
||||
"message": "Error negotiating stream for {device}"
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"loggers": ["roborock"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"python-roborock==4.2.0",
|
||||
"python-roborock==4.2.1",
|
||||
"vacuum-map-parser-roborock==0.1.4"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -391,15 +391,6 @@ Q7_B01_SENSOR_DESCRIPTIONS = [
|
||||
translation_key="mop_life_time_left",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
RoborockSensorDescriptionB01(
|
||||
key="total_cleaning_time",
|
||||
value_fn=lambda data: data.real_clean_time,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
translation_key="total_cleaning_time",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/sentry",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["sentry-sdk==1.45.1"]
|
||||
"requirements": ["sentry-sdk==2.48.0"]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioshelly"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioshelly==13.23.0"],
|
||||
"requirements": ["aioshelly==13.23.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "shelly*",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["switchbot"],
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["PySwitchbot==0.75.0"]
|
||||
"requirements": ["PySwitchbot==0.76.0"]
|
||||
}
|
||||
|
||||
@@ -80,10 +80,6 @@ class TelegramNotificationService(BaseNotificationService):
|
||||
def send_message(self, message="", **kwargs):
|
||||
"""Send a message to a user."""
|
||||
service_data = {ATTR_TARGET: kwargs.get(ATTR_TARGET, self._chat_id)}
|
||||
if ATTR_TITLE in kwargs:
|
||||
service_data.update({ATTR_TITLE: kwargs.get(ATTR_TITLE)})
|
||||
if message:
|
||||
service_data.update({ATTR_MESSAGE: message})
|
||||
data = kwargs.get(ATTR_DATA)
|
||||
|
||||
# Set message tag
|
||||
@@ -161,6 +157,12 @@ class TelegramNotificationService(BaseNotificationService):
|
||||
)
|
||||
|
||||
# Send message
|
||||
|
||||
if ATTR_TITLE in kwargs:
|
||||
service_data.update({ATTR_TITLE: kwargs.get(ATTR_TITLE)})
|
||||
if message:
|
||||
service_data.update({ATTR_MESSAGE: message})
|
||||
|
||||
_LOGGER.debug(
|
||||
"TELEGRAM NOTIFIER calling %s.send_message with %s",
|
||||
TELEGRAM_BOT_DOMAIN,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,7 @@ class VelbusSelect(VelbusEntity, SelectEntity):
|
||||
|
||||
_channel: SelectedProgram
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "select_program"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
|
||||
from pyvesync.base_devices import VeSyncHumidifier
|
||||
from pyvesync.base_devices.fan_base import VeSyncFanBase
|
||||
from pyvesync.base_devices.fryer_base import VeSyncFryer
|
||||
from pyvesync.base_devices.outlet_base import VeSyncOutlet
|
||||
from pyvesync.base_devices.purifier_base import VeSyncPurifier
|
||||
from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
|
||||
@@ -62,3 +63,9 @@ def is_purifier(device: VeSyncBaseDevice) -> bool:
|
||||
"""Check if the device represents an air purifier."""
|
||||
|
||||
return isinstance(device, VeSyncPurifier)
|
||||
|
||||
|
||||
def is_air_fryer(device: VeSyncBaseDevice) -> bool:
|
||||
"""Check if the device represents an air fryer."""
|
||||
|
||||
return isinstance(device, VeSyncFryer)
|
||||
|
||||
@@ -62,3 +62,14 @@ OUTLET_NIGHT_LIGHT_LEVEL_ON = "on"
|
||||
PURIFIER_NIGHT_LIGHT_LEVEL_DIM = "dim"
|
||||
PURIFIER_NIGHT_LIGHT_LEVEL_OFF = "off"
|
||||
PURIFIER_NIGHT_LIGHT_LEVEL_ON = "on"
|
||||
|
||||
AIR_FRYER_MODE_MAP = {
|
||||
"cookend": "cooking_end",
|
||||
"cooking": "cooking",
|
||||
"cookstop": "cooking_stop",
|
||||
"heating": "heating",
|
||||
"preheatend": "preheat_end",
|
||||
"preheatstop": "preheat_stop",
|
||||
"pullout": "pull_out",
|
||||
"standby": "standby",
|
||||
}
|
||||
|
||||
@@ -23,14 +23,15 @@ from homeassistant.const import (
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfTemperature,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .common import is_humidifier, is_outlet, rgetattr
|
||||
from .const import VS_DEVICES, VS_DISCOVERY
|
||||
from .common import is_air_fryer, is_humidifier, is_outlet, rgetattr
|
||||
from .const import AIR_FRYER_MODE_MAP, VS_DEVICES, VS_DISCOVERY
|
||||
from .coordinator import VesyncConfigEntry, VeSyncDataCoordinator
|
||||
from .entity import VeSyncBaseEntity
|
||||
|
||||
@@ -47,6 +48,8 @@ class VeSyncSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
exists_fn: Callable[[VeSyncBaseDevice], bool]
|
||||
|
||||
use_device_temperature_unit: bool = False
|
||||
|
||||
|
||||
SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
|
||||
VeSyncSensorEntityDescription(
|
||||
@@ -167,6 +170,59 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
|
||||
exists_fn=lambda device: is_humidifier(device)
|
||||
and device.state.temperature is not None,
|
||||
),
|
||||
VeSyncSensorEntityDescription(
|
||||
key="cook_status",
|
||||
translation_key="cook_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
value_fn=lambda device: AIR_FRYER_MODE_MAP.get(
|
||||
device.state.cook_status.lower(), device.state.cook_status.lower()
|
||||
),
|
||||
exists_fn=is_air_fryer,
|
||||
options=[
|
||||
"cooking_end",
|
||||
"cooking",
|
||||
"cooking_stop",
|
||||
"heating",
|
||||
"preheat_end",
|
||||
"preheat_stop",
|
||||
"pull_out",
|
||||
"standby",
|
||||
],
|
||||
),
|
||||
VeSyncSensorEntityDescription(
|
||||
key="current_temp",
|
||||
translation_key="current_temp",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
use_device_temperature_unit=True,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda device: device.state.current_temp,
|
||||
exists_fn=is_air_fryer,
|
||||
),
|
||||
VeSyncSensorEntityDescription(
|
||||
key="cook_set_temp",
|
||||
translation_key="cook_set_temp",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
use_device_temperature_unit=True,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda device: device.state.cook_set_temp,
|
||||
exists_fn=is_air_fryer,
|
||||
),
|
||||
VeSyncSensorEntityDescription(
|
||||
key="cook_set_time",
|
||||
translation_key="cook_set_time",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
value_fn=lambda device: device.state.cook_set_time,
|
||||
exists_fn=is_air_fryer,
|
||||
),
|
||||
VeSyncSensorEntityDescription(
|
||||
key="preheat_set_time",
|
||||
translation_key="preheat_set_time",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
value_fn=lambda device: device.state.preheat_set_time,
|
||||
exists_fn=is_air_fryer,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -232,3 +288,13 @@ class VeSyncSensorEntity(VeSyncBaseEntity, SensorEntity):
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.device)
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit the value was reported in by the sensor."""
|
||||
if self.entity_description.use_device_temperature_unit:
|
||||
if self.device.temp_unit == "celsius":
|
||||
return UnitOfTemperature.CELSIUS
|
||||
if self.device.temp_unit == "fahrenheit":
|
||||
return UnitOfTemperature.FAHRENHEIT
|
||||
return super().native_unit_of_measurement
|
||||
|
||||
@@ -92,9 +92,31 @@
|
||||
"air_quality": {
|
||||
"name": "Air quality"
|
||||
},
|
||||
"cook_set_temp": {
|
||||
"name": "Cooking set temperature"
|
||||
},
|
||||
"cook_set_time": {
|
||||
"name": "Cooking set time"
|
||||
},
|
||||
"cook_status": {
|
||||
"name": "Cooking status",
|
||||
"state": {
|
||||
"cooking": "Cooking",
|
||||
"cooking_end": "Cooking finished",
|
||||
"cooking_stop": "Cooking stopped",
|
||||
"heating": "Preheating",
|
||||
"preheat_end": "Preheating finished",
|
||||
"preheat_stop": "Preheating stopped",
|
||||
"pull_out": "Drawer pulled out",
|
||||
"standby": "[%key:common::state::standby%]"
|
||||
}
|
||||
},
|
||||
"current_power": {
|
||||
"name": "Current power"
|
||||
},
|
||||
"current_temp": {
|
||||
"name": "Current temperature"
|
||||
},
|
||||
"current_voltage": {
|
||||
"name": "Current voltage"
|
||||
},
|
||||
@@ -112,6 +134,9 @@
|
||||
},
|
||||
"filter_life": {
|
||||
"name": "Filter lifetime"
|
||||
},
|
||||
"preheat_set_time": {
|
||||
"name": "Preheating set time"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiovodafone"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiovodafone==3.0.0"]
|
||||
"requirements": ["aiovodafone==3.1.1"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"domain": "waterfurnace",
|
||||
"name": "WaterFurnace",
|
||||
"codeowners": [],
|
||||
"codeowners": ["@sdague", "@masterkoppa"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/waterfurnace",
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["waterfurnace"],
|
||||
"quality_scale": "legacy",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Watts Vision +",
|
||||
"codeowners": ["@theobld-ww", "@devender-verma-ww", "@ssi-spyro"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"dependencies": ["application_credentials", "cloud"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/watts",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"universal_silabs_flasher",
|
||||
"serialx"
|
||||
],
|
||||
"requirements": ["zha==0.0.83", "serialx==0.5.0"],
|
||||
"requirements": ["zha==0.0.84", "serialx==0.5.0"],
|
||||
"usb": [
|
||||
{
|
||||
"description": "*2652*",
|
||||
|
||||
@@ -7468,7 +7468,7 @@
|
||||
},
|
||||
"waterfurnace": {
|
||||
"name": "WaterFurnace",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": false,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
|
||||
5
homeassistant/generated/zeroconf.py
generated
5
homeassistant/generated/zeroconf.py
generated
@@ -780,6 +780,11 @@ ZEROCONF = {
|
||||
"domain": "octoprint",
|
||||
},
|
||||
],
|
||||
"_openevse._tcp.local.": [
|
||||
{
|
||||
"domain": "openevse",
|
||||
},
|
||||
],
|
||||
"_owserver._tcp.local.": [
|
||||
{
|
||||
"domain": "onewire",
|
||||
|
||||
@@ -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_)
|
||||
|
||||
@@ -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
2
requirements.txt
generated
@@ -27,7 +27,7 @@ ha-ffmpeg==3.2.2
|
||||
hass-nabucasa==1.7.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-intents==2026.1.1
|
||||
home-assistant-intents==2026.1.6
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
Jinja2==3.1.6
|
||||
|
||||
40
requirements_all.txt
generated
40
requirements_all.txt
generated
@@ -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
|
||||
|
||||
40
requirements_test_all.txt
generated
40
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -217,6 +217,9 @@ gql<4.0.0
|
||||
|
||||
# Pin pytest-rerunfailures to prevent accidental breaks
|
||||
pytest-rerunfailures==16.0.1
|
||||
|
||||
# Fixes detected blocking call to load_default_certs https://github.com/home-assistant/core/issues/157475
|
||||
aiomqtt>=2.5.0
|
||||
"""
|
||||
|
||||
GENERATED_MESSAGE = (
|
||||
|
||||
@@ -10,6 +10,8 @@ from homeassistant.const import (
|
||||
ATTR_DEVICE_ID,
|
||||
ATTR_FLOOR_ID,
|
||||
ATTR_LABEL_ID,
|
||||
CONF_ABOVE,
|
||||
CONF_BELOW,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_OPTIONS,
|
||||
CONF_PLATFORM,
|
||||
@@ -25,6 +27,12 @@ from homeassistant.helpers import (
|
||||
floor_registry as fr,
|
||||
label_registry as lr,
|
||||
)
|
||||
from homeassistant.helpers.trigger import (
|
||||
CONF_LOWER_LIMIT,
|
||||
CONF_THRESHOLD_TYPE,
|
||||
CONF_UPPER_LIMIT,
|
||||
ThresholdType,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry, mock_device_registry
|
||||
@@ -343,6 +351,123 @@ def parametrize_trigger_states(
|
||||
return tests
|
||||
|
||||
|
||||
def parametrize_numerical_attribute_changed_trigger_states(
|
||||
trigger: str, state: str, attribute: str
|
||||
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts for numerical changed triggers."""
|
||||
return [
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={},
|
||||
target_states=[
|
||||
(state, {attribute: 0}),
|
||||
(state, {attribute: 50}),
|
||||
(state, {attribute: 100}),
|
||||
],
|
||||
other_states=[(state, {attribute: None})],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={CONF_ABOVE: 10},
|
||||
target_states=[
|
||||
(state, {attribute: 50}),
|
||||
(state, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(state, {attribute: None}),
|
||||
(state, {attribute: 0}),
|
||||
],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={CONF_BELOW: 90},
|
||||
target_states=[
|
||||
(state, {attribute: 0}),
|
||||
(state, {attribute: 50}),
|
||||
],
|
||||
other_states=[
|
||||
(state, {attribute: None}),
|
||||
(state, {attribute: 100}),
|
||||
],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
trigger: str, state: str, attribute: str
|
||||
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts for numerical crossed threshold triggers."""
|
||||
return [
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(state, {attribute: 50}),
|
||||
(state, {attribute: 60}),
|
||||
],
|
||||
other_states=[
|
||||
(state, {attribute: None}),
|
||||
(state, {attribute: 0}),
|
||||
(state, {attribute: 100}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(state, {attribute: 0}),
|
||||
(state, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(state, {attribute: None}),
|
||||
(state, {attribute: 50}),
|
||||
(state, {attribute: 60}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.ABOVE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
},
|
||||
target_states=[
|
||||
(state, {attribute: 50}),
|
||||
(state, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(state, {attribute: None}),
|
||||
(state, {attribute: 0}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BELOW,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(state, {attribute: 0}),
|
||||
(state, {attribute: 50}),
|
||||
],
|
||||
other_states=[
|
||||
(state, {attribute: None}),
|
||||
(state, {attribute: 100}),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def arm_trigger(
|
||||
hass: HomeAssistant,
|
||||
trigger: str,
|
||||
|
||||
@@ -2232,6 +2232,202 @@ async def test_extraction_functions(
|
||||
assert automation.blueprint_in_automation(hass, "automation.test3") is None
|
||||
|
||||
|
||||
async def test_extraction_functions_with_targets(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test extraction functions with targets in triggers.
|
||||
|
||||
This test verifies that targets specified in trigger configurations
|
||||
(using new-style triggers that support target) are properly extracted for
|
||||
entity, device, area, floor, and label references.
|
||||
"""
|
||||
config_entry = MockConfigEntry(domain="fake_integration", data={})
|
||||
config_entry.mock_state(hass, ConfigEntryState.LOADED)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
trigger_device = device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:01")},
|
||||
)
|
||||
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
await async_setup_component(
|
||||
hass, "scene", {"scene": {"name": "test", "entities": {}}}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Enable the new_triggers_conditions feature flag to allow new-style triggers
|
||||
assert await async_setup_component(hass, "labs", {})
|
||||
ws_client = await hass_ws_client(hass)
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "automation",
|
||||
"preview_feature": "new_triggers_conditions",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
msg = await ws_client.receive_json()
|
||||
assert msg["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
DOMAIN,
|
||||
{
|
||||
DOMAIN: [
|
||||
{
|
||||
"alias": "test1",
|
||||
"triggers": [
|
||||
# Single entity_id in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {"entity_id": "scene.target_entity"},
|
||||
},
|
||||
# Multiple entity_ids in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {
|
||||
"entity_id": [
|
||||
"scene.target_entity_list1",
|
||||
"scene.target_entity_list2",
|
||||
]
|
||||
},
|
||||
},
|
||||
# Single device_id in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {"device_id": trigger_device.id},
|
||||
},
|
||||
# Multiple device_ids in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {
|
||||
"device_id": [
|
||||
"target-device-1",
|
||||
"target-device-2",
|
||||
]
|
||||
},
|
||||
},
|
||||
# Single area_id in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {"area_id": "area-target-single"},
|
||||
},
|
||||
# Multiple area_ids in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {"area_id": ["area-target-1", "area-target-2"]},
|
||||
},
|
||||
# Single floor_id in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {"floor_id": "floor-target-single"},
|
||||
},
|
||||
# Multiple floor_ids in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {
|
||||
"floor_id": ["floor-target-1", "floor-target-2"]
|
||||
},
|
||||
},
|
||||
# Single label_id in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {"label_id": "label-target-single"},
|
||||
},
|
||||
# Multiple label_ids in target
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {
|
||||
"label_id": ["label-target-1", "label-target-2"]
|
||||
},
|
||||
},
|
||||
# Combined targets
|
||||
{
|
||||
"trigger": "scene.activated",
|
||||
"target": {
|
||||
"entity_id": "scene.combined_entity",
|
||||
"device_id": "combined-device",
|
||||
"area_id": "combined-area",
|
||||
"floor_id": "combined-floor",
|
||||
"label_id": "combined-label",
|
||||
},
|
||||
},
|
||||
],
|
||||
"conditions": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "test.script",
|
||||
"data": {"entity_id": "light.action_entity"},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
# Test entity extraction from trigger targets
|
||||
assert set(automation.entities_in_automation(hass, "automation.test1")) == {
|
||||
"scene.target_entity",
|
||||
"scene.target_entity_list1",
|
||||
"scene.target_entity_list2",
|
||||
"scene.combined_entity",
|
||||
"light.action_entity",
|
||||
}
|
||||
|
||||
# Test device extraction from trigger targets
|
||||
assert set(automation.devices_in_automation(hass, "automation.test1")) == {
|
||||
trigger_device.id,
|
||||
"target-device-1",
|
||||
"target-device-2",
|
||||
"combined-device",
|
||||
}
|
||||
|
||||
# Test area extraction from trigger targets
|
||||
assert set(automation.areas_in_automation(hass, "automation.test1")) == {
|
||||
"area-target-single",
|
||||
"area-target-1",
|
||||
"area-target-2",
|
||||
"combined-area",
|
||||
}
|
||||
|
||||
# Test floor extraction from trigger targets
|
||||
assert set(automation.floors_in_automation(hass, "automation.test1")) == {
|
||||
"floor-target-single",
|
||||
"floor-target-1",
|
||||
"floor-target-2",
|
||||
"combined-floor",
|
||||
}
|
||||
|
||||
# Test label extraction from trigger targets
|
||||
assert set(automation.labels_in_automation(hass, "automation.test1")) == {
|
||||
"label-target-single",
|
||||
"label-target-1",
|
||||
"label-target-2",
|
||||
"combined-label",
|
||||
}
|
||||
|
||||
# Test automations_with_* functions
|
||||
assert set(automation.automations_with_entity(hass, "scene.target_entity")) == {
|
||||
"automation.test1"
|
||||
}
|
||||
assert set(automation.automations_with_device(hass, trigger_device.id)) == {
|
||||
"automation.test1"
|
||||
}
|
||||
assert set(automation.automations_with_area(hass, "area-target-single")) == {
|
||||
"automation.test1"
|
||||
}
|
||||
assert set(automation.automations_with_floor(hass, "floor-target-single")) == {
|
||||
"automation.test1"
|
||||
}
|
||||
assert set(automation.automations_with_label(hass, "label-target-single")) == {
|
||||
"automation.test1"
|
||||
}
|
||||
|
||||
|
||||
async def test_logbook_humanify_automation_triggered_event(hass: HomeAssistant) -> None:
|
||||
"""Test humanifying Automation Trigger event."""
|
||||
hass.config.components.add("recorder")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
# ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
80
tests/components/bang_olufsen/test_sensor.py
Normal file
80
tests/components/bang_olufsen/test_sensor.py
Normal 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"
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -13,6 +13,7 @@ import pytest
|
||||
from homeassistant.components.braviatv.const import (
|
||||
CONF_NICKNAME,
|
||||
CONF_USE_PSK,
|
||||
CONF_USE_SSL,
|
||||
DOMAIN,
|
||||
NICKNAME_PREFIX,
|
||||
)
|
||||
@@ -131,7 +132,7 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None:
|
||||
assert result["step_id"] == "authorize"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_USE_PSK: False}
|
||||
result["flow_id"], user_input={CONF_USE_PSK: False, CONF_USE_SSL: False}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
@@ -148,6 +149,7 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None:
|
||||
CONF_HOST: "bravia-host",
|
||||
CONF_PIN: "1234",
|
||||
CONF_USE_PSK: False,
|
||||
CONF_USE_SSL: False,
|
||||
CONF_MAC: "AA:BB:CC:DD:EE:FF",
|
||||
CONF_CLIENT_ID: uuid,
|
||||
CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}",
|
||||
@@ -307,8 +309,17 @@ async def test_duplicate_error(hass: HomeAssistant) -> None:
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_create_entry(hass: HomeAssistant) -> None:
|
||||
"""Test that entry is added correctly with PIN auth."""
|
||||
@pytest.mark.parametrize(
|
||||
("use_psk", "use_ssl"),
|
||||
[
|
||||
(True, False),
|
||||
(False, False),
|
||||
(True, True),
|
||||
(False, True),
|
||||
],
|
||||
)
|
||||
async def test_create_entry(hass: HomeAssistant, use_psk, use_ssl) -> None:
|
||||
"""Test that entry is added correctly."""
|
||||
uuid = await instance_id.async_get(hass)
|
||||
|
||||
with (
|
||||
@@ -328,14 +339,14 @@ async def test_create_entry(hass: HomeAssistant) -> None:
|
||||
assert result["step_id"] == "authorize"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_USE_PSK: False}
|
||||
result["flow_id"], user_input={CONF_USE_PSK: use_psk, CONF_USE_SSL: use_ssl}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "pin"
|
||||
assert result["step_id"] == "psk" if use_psk else "pin"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_PIN: "1234"}
|
||||
result["flow_id"], user_input={CONF_PIN: "secret"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
@@ -343,50 +354,18 @@ async def test_create_entry(hass: HomeAssistant) -> None:
|
||||
assert result["title"] == "BRAVIA TV-Model"
|
||||
assert result["data"] == {
|
||||
CONF_HOST: "bravia-host",
|
||||
CONF_PIN: "1234",
|
||||
CONF_USE_PSK: False,
|
||||
CONF_MAC: "AA:BB:CC:DD:EE:FF",
|
||||
CONF_CLIENT_ID: uuid,
|
||||
CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}",
|
||||
}
|
||||
|
||||
|
||||
async def test_create_entry_psk(hass: HomeAssistant) -> None:
|
||||
"""Test that entry is added correctly with PSK auth."""
|
||||
with (
|
||||
patch("pybravia.BraviaClient.connect"),
|
||||
patch("pybravia.BraviaClient.set_wol_mode"),
|
||||
patch(
|
||||
"pybravia.BraviaClient.get_system_info",
|
||||
return_value=BRAVIA_SYSTEM_INFO,
|
||||
),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "authorize"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_USE_PSK: True}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "psk"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_PIN: "mypsk"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["result"].unique_id == "very_unique_string"
|
||||
assert result["title"] == "BRAVIA TV-Model"
|
||||
assert result["data"] == {
|
||||
CONF_HOST: "bravia-host",
|
||||
CONF_PIN: "mypsk",
|
||||
CONF_USE_PSK: True,
|
||||
CONF_PIN: "secret",
|
||||
CONF_USE_PSK: use_psk,
|
||||
CONF_USE_SSL: use_ssl,
|
||||
CONF_MAC: "AA:BB:CC:DD:EE:FF",
|
||||
**(
|
||||
{
|
||||
CONF_CLIENT_ID: uuid,
|
||||
CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}",
|
||||
}
|
||||
if not use_psk
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,25 +20,19 @@ from homeassistant.components.climate.trigger import CONF_HVAC_MODE
|
||||
from homeassistant.const import (
|
||||
ATTR_LABEL_ID,
|
||||
ATTR_TEMPERATURE,
|
||||
CONF_ABOVE,
|
||||
CONF_BELOW,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_OPTIONS,
|
||||
CONF_TARGET,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers.trigger import (
|
||||
CONF_LOWER_LIMIT,
|
||||
CONF_THRESHOLD_TYPE,
|
||||
CONF_UPPER_LIMIT,
|
||||
ThresholdType,
|
||||
async_validate_trigger_config,
|
||||
)
|
||||
from homeassistant.helpers.trigger import async_validate_trigger_config
|
||||
|
||||
from tests.components import (
|
||||
StateDescription,
|
||||
arm_trigger,
|
||||
other_states,
|
||||
parametrize_numerical_attribute_changed_trigger_states,
|
||||
parametrize_numerical_attribute_crossed_threshold_trigger_states,
|
||||
parametrize_target_entities,
|
||||
parametrize_trigger_states,
|
||||
set_or_remove_state,
|
||||
@@ -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",
|
||||
|
||||
@@ -11,25 +11,14 @@ from homeassistant.components.humidifier.const import (
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
HumidifierAction,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_LABEL_ID,
|
||||
CONF_ABOVE,
|
||||
CONF_BELOW,
|
||||
CONF_ENTITY_ID,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers.trigger import (
|
||||
CONF_LOWER_LIMIT,
|
||||
CONF_THRESHOLD_TYPE,
|
||||
CONF_UPPER_LIMIT,
|
||||
ThresholdType,
|
||||
)
|
||||
|
||||
from tests.components import (
|
||||
StateDescription,
|
||||
arm_trigger,
|
||||
parametrize_numerical_attribute_changed_trigger_states,
|
||||
parametrize_numerical_attribute_crossed_threshold_trigger_states,
|
||||
parametrize_target_entities,
|
||||
parametrize_trigger_states,
|
||||
set_or_remove_state,
|
||||
@@ -82,123 +71,6 @@ async def test_humidifier_triggers_gated_by_labs_flag(
|
||||
) in caplog.text
|
||||
|
||||
|
||||
def parametrize_xxx_changed_trigger_states(
|
||||
trigger: str, attribute: str
|
||||
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts for xxx_changed triggers."""
|
||||
return [
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
other_states=[(STATE_ON, {attribute: None})],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={CONF_ABOVE: 10},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 0}),
|
||||
],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={CONF_BELOW: 90},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 50}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def parametrize_xxx_crossed_threshold_trigger_states(
|
||||
trigger: str, attribute: str
|
||||
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts for xxx_crossed_threshold triggers."""
|
||||
return [
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 60}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 60}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.ABOVE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 0}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BELOW,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 50}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
@@ -265,11 +137,13 @@ async def test_humidifier_state_trigger_behavior_any(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_xxx_changed_trigger_states(
|
||||
"humidifier.current_humidity_changed", ATTR_CURRENT_HUMIDITY
|
||||
*parametrize_numerical_attribute_changed_trigger_states(
|
||||
"humidifier.current_humidity_changed", STATE_ON, ATTR_CURRENT_HUMIDITY
|
||||
),
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"humidifier.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"humidifier.current_humidity_crossed_threshold",
|
||||
STATE_ON,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.started_drying",
|
||||
@@ -386,8 +260,10 @@ async def test_humidifier_state_trigger_behavior_first(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"humidifier.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"humidifier.current_humidity_crossed_threshold",
|
||||
STATE_ON,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.started_drying",
|
||||
@@ -504,8 +380,10 @@ async def test_humidifier_state_trigger_behavior_last(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"humidifier.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"humidifier.current_humidity_crossed_threshold",
|
||||
STATE_ON,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.started_drying",
|
||||
|
||||
@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
@@ -66,3 +66,43 @@ async def test_fail_query(
|
||||
assert len(hass.states.async_entity_ids()) == 6
|
||||
departure_sensor = hass.states.get("sensor.mock_title_departure")
|
||||
assert departure_sensor.state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_no_departures(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
mock_israelrail: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test handling when there are no departures available."""
|
||||
await init_integration(hass, mock_config_entry)
|
||||
assert len(hass.states.async_entity_ids()) == 6
|
||||
|
||||
# Simulate no departures (e.g., after-hours)
|
||||
mock_israelrail.query.return_value = []
|
||||
|
||||
await goto_future(hass, freezer)
|
||||
|
||||
# All sensors should still exist
|
||||
assert len(hass.states.async_entity_ids()) == 6
|
||||
|
||||
# Departure sensors should have unknown state (None)
|
||||
departure_sensor = hass.states.get("sensor.mock_title_departure")
|
||||
assert departure_sensor.state == STATE_UNKNOWN
|
||||
|
||||
departure_sensor_1 = hass.states.get("sensor.mock_title_departure_1")
|
||||
assert departure_sensor_1.state == STATE_UNKNOWN
|
||||
|
||||
departure_sensor_2 = hass.states.get("sensor.mock_title_departure_2")
|
||||
assert departure_sensor_2.state == STATE_UNKNOWN
|
||||
|
||||
# Non-departure sensors (platform, trains, train_number) also access index 0
|
||||
# and should have unknown state when no departures available
|
||||
platform_sensor = hass.states.get("sensor.mock_title_platform")
|
||||
assert platform_sensor.state == STATE_UNKNOWN
|
||||
|
||||
trains_sensor = hass.states.get("sensor.mock_title_trains")
|
||||
assert trains_sensor.state == STATE_UNKNOWN
|
||||
|
||||
train_number_sensor = hass.states.get("sensor.mock_title_train_number")
|
||||
assert train_number_sensor.state == STATE_UNKNOWN
|
||||
|
||||
@@ -7,25 +7,14 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.light import ATTR_BRIGHTNESS
|
||||
from homeassistant.const import (
|
||||
ATTR_LABEL_ID,
|
||||
CONF_ABOVE,
|
||||
CONF_BELOW,
|
||||
CONF_ENTITY_ID,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers.trigger import (
|
||||
CONF_LOWER_LIMIT,
|
||||
CONF_THRESHOLD_TYPE,
|
||||
CONF_UPPER_LIMIT,
|
||||
ThresholdType,
|
||||
)
|
||||
|
||||
from tests.components import (
|
||||
StateDescription,
|
||||
arm_trigger,
|
||||
parametrize_numerical_attribute_changed_trigger_states,
|
||||
parametrize_numerical_attribute_crossed_threshold_trigger_states,
|
||||
parametrize_target_entities,
|
||||
parametrize_trigger_states,
|
||||
set_or_remove_state,
|
||||
@@ -76,122 +65,6 @@ async def test_light_triggers_gated_by_labs_flag(
|
||||
) in caplog.text
|
||||
|
||||
|
||||
def parametrize_xxx_changed_trigger_states(
|
||||
trigger: str, attribute: str
|
||||
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts for xxx_changed triggers."""
|
||||
return [
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
other_states=[(STATE_ON, {attribute: None})],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={CONF_ABOVE: 10},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 0}),
|
||||
],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={CONF_BELOW: 90},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 50}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def parametrize_xxx_crossed_threshold_trigger_states(
|
||||
trigger: str, attribute: str
|
||||
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts for xxx_crossed_threshold triggers."""
|
||||
return [
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 60}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 60}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.ABOVE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 50}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 0}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BELOW,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(STATE_ON, {attribute: 0}),
|
||||
(STATE_ON, {attribute: 50}),
|
||||
],
|
||||
other_states=[
|
||||
(STATE_ON, {attribute: None}),
|
||||
(STATE_ON, {attribute: 100}),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
@@ -258,11 +131,11 @@ async def test_light_state_trigger_behavior_any(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_xxx_changed_trigger_states(
|
||||
"light.brightness_changed", ATTR_BRIGHTNESS
|
||||
*parametrize_numerical_attribute_changed_trigger_states(
|
||||
"light.brightness_changed", STATE_ON, ATTR_BRIGHTNESS
|
||||
),
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"light.brightness_crossed_threshold", ATTR_BRIGHTNESS
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"light.brightness_crossed_threshold", STATE_ON, ATTR_BRIGHTNESS
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -369,8 +242,8 @@ async def test_light_state_trigger_behavior_first(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"light.brightness_crossed_threshold", ATTR_BRIGHTNESS
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"light.brightness_crossed_threshold", STATE_ON, ATTR_BRIGHTNESS
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -477,8 +350,8 @@ async def test_light_state_trigger_behavior_last(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"light.brightness_crossed_threshold", ATTR_BRIGHTNESS
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"light.brightness_crossed_threshold", STATE_ON, ATTR_BRIGHTNESS
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
39
tests/components/nina/const.py
Normal file
39
tests/components/nina/const.py
Normal 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"},
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user