Compare commits

..

2 Commits

Author SHA1 Message Date
Erik
45f1fc32e2 Address comments 2026-01-08 09:36:34 +01:00
Erik
1e95598c96 Add helper for creating entity condition tests 2026-01-07 14:58:50 +01:00
109 changed files with 471 additions and 3584 deletions

View File

@@ -40,8 +40,7 @@
"python.terminal.activateEnvInCurrentTerminal": true,
"python.testing.pytestArgs": ["--no-cov"],
"pylint.importStrategy": "fromEnvironment",
// Pyright type checking is too not compatible with mypy which Home Assistant uses for type checking
"python.analysis.typeCheckingMode": "off",
"python.analysis.typeCheckingMode": "basic",
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,

View File

@@ -7,8 +7,8 @@
"python.testing.pytestEnabled": false,
// https://code.visualstudio.com/docs/python/linting#_general-settings
"pylint.importStrategy": "fromEnvironment",
// Pyright type checking is too not compatible with mypy which Home Assistant uses for type checking
"python.analysis.typeCheckingMode": "off",
// Pyright is too pedantic for Home Assistant
"python.analysis.typeCheckingMode": "basic",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
},

6
CODEOWNERS generated
View File

@@ -661,8 +661,6 @@ build.json @home-assistant/supervisor
/tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan
/homeassistant/components/hassio/ @home-assistant/supervisor
/tests/components/hassio/ @home-assistant/supervisor
/homeassistant/components/hdfury/ @glenndehaan
/tests/components/hdfury/ @glenndehaan
/homeassistant/components/hdmi_cec/ @inytar
/tests/components/hdmi_cec/ @inytar
/homeassistant/components/heatmiser/ @andylockran
@@ -1172,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 @firstof9
/tests/components/openevse/ @c00w @firstof9
/homeassistant/components/openevse/ @c00w
/tests/components/openevse/ @c00w
/homeassistant/components/openexchangerates/ @MartinHjelmare
/tests/components/openexchangerates/ @MartinHjelmare
/homeassistant/components/opengarage/ @danielhiversen

View File

@@ -7,7 +7,7 @@ import asyncio
from collections.abc import Callable, Mapping
from dataclasses import dataclass
import logging
from typing import Any, Literal, Protocol, cast
from typing import Any, Protocol, cast
from propcache.api import cached_property
import voluptuous as vol
@@ -16,10 +16,7 @@ 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,
@@ -33,7 +30,6 @@ from homeassistant.const import (
CONF_OPTIONS,
CONF_PATH,
CONF_PLATFORM,
CONF_TARGET,
CONF_TRIGGERS,
CONF_VARIABLES,
CONF_ZONE,
@@ -593,32 +589,20 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Return True if entity is on."""
return self._async_detach_triggers is not None or self._is_enabled
@cached_property
@property
def referenced_labels(self) -> set[str]:
"""Return a set of referenced labels."""
referenced = self.action_script.referenced_labels
return self.action_script.referenced_labels
for conf in self._trigger_config:
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_LABEL_ID))
return referenced
@cached_property
@property
def referenced_floors(self) -> set[str]:
"""Return a set of 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
return self.action_script.referenced_floors
@cached_property
def referenced_areas(self) -> set[str]:
"""Return a set of 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
return self.action_script.referenced_areas
@property
def referenced_blueprint(self) -> str | None:
@@ -1226,9 +1210,6 @@ 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 []
@@ -1259,28 +1240,9 @@ def _trigger_extract_entities(trigger_conf: dict) -> list[str]:
):
return [trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID]]
if target_entities := _get_targets_from_trigger_config(
trigger_conf, CONF_ENTITY_ID
):
return target_entities
return []
@callback
def _get_targets_from_trigger_config(
config: dict,
target: Literal["entity_id", "device_id", "area_id", "floor_id", "label_id"],
) -> list[str]:
"""Extract targets from a target config."""
if not (target_conf := config.get(CONF_TARGET)):
return []
if not (targets := target_conf.get(target)):
return []
return [targets] if isinstance(targets, str) else targets
@websocket_api.websocket_command({"type": "automation/config", "entity_id": str})
def websocket_config(
hass: HomeAssistant,

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ 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
@@ -56,19 +55,6 @@ 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(
@@ -86,15 +72,4 @@ async def async_get_config_entry_diagnostics(
# Add remote Mozart model
data[f"remote_{remote.serial_number}"] = dict(remote)
# Get Mozart battery entity
if entity_id := entity_registry.async_get_entity_id(
SENSOR_DOMAIN, DOMAIN, f"{config_entry.unique_id}_battery_level"
):
if state := hass.states.get(entity_id):
state_dict = dict(state.as_dict())
# Remove context as it is not relevant
state_dict.pop("context")
data["battery_level"] = state_dict
return data

View File

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

View File

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

View File

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

View File

@@ -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, HomeAssistantError
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -56,31 +56,8 @@ 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:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_error",
translation_placeholders={
"device": self.config_entry.title,
"error": repr(err),
},
) from err
_LOGGER.error("Command error: %s", err)
await self.async_request_refresh()
return wrapper
@@ -188,35 +165,17 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]):
if self.skipped_updates < 10:
self.connected = False
self.skipped_updates += 1
_LOGGER.debug(
"Update for %s skipped: the Bravia API service is reloading",
self.config_entry.title,
)
_LOGGER.debug("Update skipped, Bravia API service is reloading")
return
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error_not_found",
translation_placeholders={
"device": self.config_entry.title,
},
) from err
raise UpdateFailed("Error communicating with device") from err
except (BraviaConnectionError, BraviaConnectionTimeout, BraviaTurnedOff):
self.is_on = False
self.connected = False
_LOGGER.debug(
"Update for %s skipped: the TV is turned off", self.config_entry.title
)
_LOGGER.debug("Update skipped, Bravia TV is off")
except BraviaError as err:
self.is_on = False
self.connected = False
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={
"device": self.config_entry.title,
"error": repr(err),
},
) from err
raise UpdateFailed("Error communicating with device") from err
async def async_update_volume(self) -> None:
"""Update volume information."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,29 +0,0 @@
"""The HDFury Integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import HDFuryConfigEntry, HDFuryCoordinator
PLATFORMS = [
Platform.SELECT,
]
async def async_setup_entry(hass: HomeAssistant, entry: HDFuryConfigEntry) -> bool:
"""Set up HDFury as config entry."""
coordinator = HDFuryCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HDFuryConfigEntry) -> bool:
"""Unload a HDFury config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,54 +0,0 @@
"""Config flow for HDFury Integration."""
from typing import Any
from hdfury import HDFuryAPI, HDFuryError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
class HDFuryConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle Config Flow for HDFury."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle Initial Setup."""
errors: dict[str, str] = {}
if user_input is not None:
host = user_input[CONF_HOST]
serial = await self._validate_connection(host)
if serial is not None:
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"HDFury ({host})", data=user_input
)
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
errors=errors,
)
async def _validate_connection(self, host: str) -> str | None:
"""Try to fetch serial number to confirm it's a valid HDFury device."""
client = HDFuryAPI(host, async_get_clientsession(self.hass))
try:
data = await client.get_board()
except HDFuryError:
return None
return data["serial"]

View File

@@ -1,3 +0,0 @@
"""Constants for HDFury Integration."""
DOMAIN = "hdfury"

View File

@@ -1,67 +0,0 @@
"""DataUpdateCoordinator for HDFury Integration."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Final
from hdfury import HDFuryAPI, HDFuryError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL: Final = timedelta(seconds=60)
type HDFuryConfigEntry = ConfigEntry[HDFuryCoordinator]
@dataclass(kw_only=True, frozen=True)
class HDFuryData:
"""HDFury Data Class."""
board: dict[str, str]
info: dict[str, str]
config: dict[str, str]
class HDFuryCoordinator(DataUpdateCoordinator[HDFuryData]):
"""HDFury Device Coordinator Class."""
def __init__(self, hass: HomeAssistant, entry: HDFuryConfigEntry) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name="HDFury",
update_interval=SCAN_INTERVAL,
)
self.host: str = entry.data[CONF_HOST]
self.client = HDFuryAPI(self.host, async_get_clientsession(hass))
async def _async_update_data(self) -> HDFuryData:
"""Fetch the latest device data."""
try:
board = await self.client.get_board()
info = await self.client.get_info()
config = await self.client.get_config()
except HDFuryError as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="communication_error",
) from error
return HDFuryData(
board=board,
info=info,
config=config,
)

View File

@@ -1,39 +0,0 @@
"""Base class for HDFury entities."""
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import HDFuryCoordinator
class HDFuryEntity(CoordinatorEntity[HDFuryCoordinator]):
"""Common elements for all entities."""
_attr_has_entity_name = True
def __init__(
self, coordinator: HDFuryCoordinator, entity_description: EntityDescription
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = (
f"{coordinator.data.board['serial']}_{entity_description.key}"
)
self._attr_device_info = DeviceInfo(
name=f"HDFury {coordinator.data.board['hostname']}",
manufacturer="HDFury",
model=coordinator.data.board["hostname"].split("-")[0],
serial_number=coordinator.data.board["serial"],
sw_version=coordinator.data.board["version"].removeprefix("FW: "),
hw_version=coordinator.data.board.get("pcbv"),
configuration_url=f"http://{coordinator.host}",
connections={
(dr.CONNECTION_NETWORK_MAC, coordinator.data.config["macaddr"])
},
)

View File

@@ -1,15 +0,0 @@
{
"entity": {
"select": {
"opmode": {
"default": "mdi:cogs"
},
"portseltx0": {
"default": "mdi:hdmi-port"
},
"portseltx1": {
"default": "mdi:hdmi-port"
}
}
}
}

View File

@@ -1,11 +0,0 @@
{
"domain": "hdfury",
"name": "HDFury",
"codeowners": ["@glenndehaan"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hdfury",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["hdfury==1.3.1"]
}

View File

@@ -1,74 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Entities do not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration has no options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: Integration has no authentication flow.
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices:
status: exempt
comment: Device type integration.
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: exempt
comment: Device type integration.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -1,122 +0,0 @@
"""Select platform for HDFury Integration."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from hdfury import (
OPERATION_MODES,
TX0_INPUT_PORTS,
TX1_INPUT_PORTS,
HDFuryAPI,
HDFuryError,
)
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import HDFuryConfigEntry, HDFuryCoordinator
from .entity import HDFuryEntity
@dataclass(kw_only=True, frozen=True)
class HDFurySelectEntityDescription(SelectEntityDescription):
"""Description for HDFury select entities."""
set_value_fn: Callable[[HDFuryAPI, str], Awaitable[None]]
SELECT_PORTS: tuple[HDFurySelectEntityDescription, ...] = (
HDFurySelectEntityDescription(
key="portseltx0",
translation_key="portseltx0",
options=list(TX0_INPUT_PORTS.keys()),
set_value_fn=lambda coordinator, value: _set_ports(coordinator),
),
HDFurySelectEntityDescription(
key="portseltx1",
translation_key="portseltx1",
options=list(TX1_INPUT_PORTS.keys()),
set_value_fn=lambda coordinator, value: _set_ports(coordinator),
),
)
SELECT_OPERATION_MODE: HDFurySelectEntityDescription = HDFurySelectEntityDescription(
key="opmode",
translation_key="opmode",
options=list(OPERATION_MODES.keys()),
set_value_fn=lambda coordinator, value: coordinator.client.set_operation_mode(
value
),
)
async def _set_ports(coordinator: HDFuryCoordinator) -> None:
tx0 = coordinator.data.info.get("portseltx0")
tx1 = coordinator.data.info.get("portseltx1")
if tx0 is None or tx1 is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="tx_state_error",
translation_placeholders={"details": f"tx0={tx0}, tx1={tx1}"},
)
await coordinator.client.set_port_selection(tx0, tx1)
async def async_setup_entry(
hass: HomeAssistant,
entry: HDFuryConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up selects using the platform schema."""
coordinator = entry.runtime_data
entities: list[HDFuryEntity] = []
for description in SELECT_PORTS:
if description.key not in coordinator.data.info:
continue
entities.append(HDFurySelect(coordinator, description))
# Add OPMODE select if present
if "opmode" in coordinator.data.info:
entities.append(HDFurySelect(coordinator, SELECT_OPERATION_MODE))
async_add_entities(entities)
class HDFurySelect(HDFuryEntity, SelectEntity):
"""HDFury Select Class."""
entity_description: HDFurySelectEntityDescription
@property
def current_option(self) -> str:
"""Return the current option."""
return self.coordinator.data.info[self.entity_description.key]
async def async_select_option(self, option: str) -> None:
"""Update the current option."""
# Update local data first
self.coordinator.data.info[self.entity_description.key] = option
# Send command to device
try:
await self.entity_description.set_value_fn(self.coordinator, option)
except HDFuryError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
) from error
# Trigger HA coordinator refresh
await self.coordinator.async_request_refresh()

View File

@@ -1,64 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "Hostname or IP address of your HDFury device."
},
"description": "Set up your HDFury to integrate with Home Assistant."
}
}
},
"entity": {
"select": {
"opmode": {
"name": "Operation mode",
"state": {
"0": "Mode 0 - Splitter TX0/TX1 FRL5 VRR",
"1": "Mode 1 - Splitter TX0/TX1 UPSCALE FRL5",
"2": "Mode 2 - Matrix TMDS",
"3": "Mode 3 - Matrix FRL->TMDS",
"4": "Mode 4 - Matrix DOWNSCALE",
"5": "Mode 5 - Matrix RX0:FRL5 + RX1-3:TMDS"
}
},
"portseltx0": {
"name": "Port select TX0",
"state": {
"0": "Input 0",
"1": "Input 1",
"2": "Input 2",
"3": "Input 3",
"4": "Copy TX1"
}
},
"portseltx1": {
"name": "Port select TX1",
"state": {
"0": "Input 0",
"1": "Input 1",
"2": "Input 2",
"3": "Input 3",
"4": "Copy TX0"
}
}
}
},
"exceptions": {
"communication_error": {
"message": "An error occurred while communicating with HDFury device"
},
"tx_state_error": {
"message": "An error occurred while validating TX states: {details}"
}
}
}

View File

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

View File

@@ -15,7 +15,7 @@ set_program_and_options:
- active_program
- selected_program
program:
example: dishcare_dishwasher_program_auto_2
example: dishcare_dishwasher_program_auto2
selector:
select:
mode: dropdown
@@ -73,7 +73,6 @@ set_program_and_options:
- dishcare_dishwasher_program_intensiv_45
- dishcare_dishwasher_program_auto_half_load
- dishcare_dishwasher_program_intensiv_power
- dishcare_dishwasher_program_intensive_fixed_zone
- dishcare_dishwasher_program_magic_daily
- dishcare_dishwasher_program_super_60
- dishcare_dishwasher_program_kurz_60
@@ -122,7 +121,6 @@ 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
@@ -149,7 +147,6 @@ 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
@@ -177,7 +174,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_spin_drain
- laundry_care_washer_program_spin_drain
- laundry_care_washer_program_sport_fitness
- laundry_care_washer_program_super_153045_super_15
- laundry_care_washer_program_super_153045_super_1530

View File

@@ -240,7 +240,6 @@
"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%]",
@@ -272,7 +271,6 @@
"dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_45%]",
"dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_70%]",
"dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_power%]",
"dishcare_dishwasher_program_intensive_fixed_zone": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensive_fixed_zone%]",
"dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_kurz_60%]",
"dishcare_dishwasher_program_learning_dishwasher": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_learning_dishwasher%]",
"dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_machine_care%]",
@@ -352,7 +350,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_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_spin_drain%]",
"laundry_care_washer_program_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_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%]",
@@ -594,7 +592,6 @@
"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%]",
@@ -615,7 +612,6 @@
"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%]",
@@ -627,7 +623,6 @@
"dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_45%]",
"dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_70%]",
"dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_power%]",
"dishcare_dishwasher_program_intensive_fixed_zone": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensive_fixed_zone%]",
"dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_kurz_60%]",
"dishcare_dishwasher_program_learning_dishwasher": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_learning_dishwasher%]",
"dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_machine_care%]",
@@ -707,7 +702,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_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_spin_drain%]",
"laundry_care_washer_program_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_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%]",
@@ -1588,7 +1583,6 @@
"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",
@@ -1609,7 +1603,6 @@
"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",
@@ -1621,7 +1614,6 @@
"dishcare_dishwasher_program_intensiv_45": "Intensive 45ºC",
"dishcare_dishwasher_program_intensiv_70": "Intensive 70ºC",
"dishcare_dishwasher_program_intensiv_power": "Intensive power",
"dishcare_dishwasher_program_intensive_fixed_zone": "Intensive fixed zone",
"dishcare_dishwasher_program_kurz_60": "Speed 60ºC",
"dishcare_dishwasher_program_learning_dishwasher": "Intelligent",
"dishcare_dishwasher_program_machine_care": "Machine care",
@@ -1701,7 +1693,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_spin_drain": "Spin/drain",
"laundry_care_washer_program_spin_drain": "Spin/drain",
"laundry_care_washer_program_sport_fitness": "Sport/fitness",
"laundry_care_washer_program_super_153045_super_15": "Super 15 min",
"laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min",

View File

@@ -1,9 +1,5 @@
"""Support for Netatmo binary sensors."""
from dataclasses import dataclass
import logging
from typing import Final, cast
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
@@ -13,33 +9,17 @@ from homeassistant.config_entries import ConfigEntry
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 .const import NETATMO_CREATE_WEATHER_BINARY_SENSOR
from .const import NETATMO_CREATE_WEATHER_SENSOR
from .data_handler import NetatmoDevice
from .entity import NetatmoWeatherModuleEntity
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class NetatmoBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes Netatmo binary sensor entity."""
name: str | None = None # The default name of the sensor
netatmo_name: str # The name used by Netatmo API for this sensor
NETATMO_WEATHER_BINARY_SENSOR_DESCRIPTIONS: Final[
list[NetatmoBinarySensorEntityDescription]
] = [
NetatmoBinarySensorEntityDescription(
BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
BinarySensorEntityDescription(
key="reachable",
name="Connectivity",
netatmo_name="reachable",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
),
]
)
async def async_setup_entry(
@@ -47,75 +27,36 @@ async def async_setup_entry(
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Netatmo weather binary sensors based on a config entry."""
"""Set up Netatmo binary sensors based on a config entry."""
@callback
def _create_weather_binary_sensor_entity(netatmo_device: NetatmoDevice) -> None:
"""Create weather binary sensor entities for a Netatmo weather device."""
descriptions_to_add = NETATMO_WEATHER_BINARY_SENSOR_DESCRIPTIONS
entities: list[NetatmoWeatherBinarySensor] = []
# Create binary sensors for module
for description in descriptions_to_add:
# Actual check is simple for reachable
feature_check = description.key
if feature_check in netatmo_device.device.features:
_LOGGER.debug(
'Adding "%s" weather binary sensor for device %s',
feature_check,
netatmo_device.device.name,
)
entities.append(
NetatmoWeatherBinarySensor(
netatmo_device,
description,
)
)
if entities:
async_add_entities(entities)
async_add_entities(
NetatmoWeatherBinarySensor(netatmo_device, description)
for description in BINARY_SENSOR_TYPES
if description.key in netatmo_device.device.features
)
entry.async_on_unload(
async_dispatcher_connect(
hass,
NETATMO_CREATE_WEATHER_BINARY_SENSOR,
_create_weather_binary_sensor_entity,
hass, NETATMO_CREATE_WEATHER_SENSOR, _create_weather_binary_sensor_entity
)
)
class NetatmoWeatherBinarySensor(NetatmoWeatherModuleEntity, BinarySensorEntity):
"""Implementation of a Netatmo weather binary sensor."""
entity_description: NetatmoBinarySensorEntityDescription
"""Implementation of a Netatmo binary sensor."""
def __init__(
self,
netatmo_device: NetatmoDevice,
description: NetatmoBinarySensorEntityDescription,
self, device: NetatmoDevice, description: BinarySensorEntityDescription
) -> None:
"""Initialize a Netatmo weather binary sensor."""
super().__init__(netatmo_device)
"""Initialize a Netatmo binary sensor."""
super().__init__(device)
self.entity_description = description
self._attr_unique_id = f"{self.device.entity_id}-{description.key}"
@callback
def async_update_callback(self) -> None:
"""Update the entity's state."""
value: StateType | None = None
value = getattr(self.device, self.entity_description.netatmo_name, None)
if value is None:
self._attr_available = False
self._attr_is_on = False
else:
self._attr_available = True
self._attr_is_on = cast(bool, value)
self._attr_is_on = self.device.reachable
self.async_write_ha_state()

View File

@@ -53,7 +53,6 @@ NETATMO_CREATE_ROOM_SENSOR = "netatmo_create_room_sensor"
NETATMO_CREATE_SELECT = "netatmo_create_select"
NETATMO_CREATE_SENSOR = "netatmo_create_sensor"
NETATMO_CREATE_SWITCH = "netatmo_create_switch"
NETATMO_CREATE_WEATHER_BINARY_SENSOR = "netatmo_create_weather_binary_sensor"
NETATMO_CREATE_WEATHER_SENSOR = "netatmo_create_weather_sensor"
CONF_AREA_NAME = "area_name"

View File

@@ -45,7 +45,6 @@ from .const import (
NETATMO_CREATE_SELECT,
NETATMO_CREATE_SENSOR,
NETATMO_CREATE_SWITCH,
NETATMO_CREATE_WEATHER_BINARY_SENSOR,
NETATMO_CREATE_WEATHER_SENSOR,
PLATFORMS,
WEBHOOK_ACTIVATION,
@@ -333,20 +332,16 @@ class NetatmoDataHandler:
"""Set up home coach/air care modules."""
for module in self.account.modules.values():
if module.device_category is NetatmoDeviceCategory.air_care:
for signal in (
NETATMO_CREATE_WEATHER_BINARY_SENSOR,
async_dispatcher_send(
self.hass,
NETATMO_CREATE_WEATHER_SENSOR,
):
async_dispatcher_send(
self.hass,
signal,
NetatmoDevice(
self,
module,
AIR_CARE,
AIR_CARE,
),
)
NetatmoDevice(
self,
module,
AIR_CARE,
AIR_CARE,
),
)
def setup_modules(self, home: pyatmo.Home, signal_home: str) -> None:
"""Set up modules."""
@@ -384,20 +379,16 @@ class NetatmoDataHandler:
),
)
if module.device_category is NetatmoDeviceCategory.weather:
for signal in (
NETATMO_CREATE_WEATHER_BINARY_SENSOR,
async_dispatcher_send(
self.hass,
NETATMO_CREATE_WEATHER_SENSOR,
):
async_dispatcher_send(
self.hass,
signal,
NetatmoDevice(
self,
module,
home.entity_id,
WEATHER,
),
)
NetatmoDevice(
self,
module,
home.entity_id,
WEATHER,
),
)
def setup_rooms(self, home: pyatmo.Home, signal_home: str) -> None:
"""Set up rooms."""

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,12 @@
{
"domain": "openevse",
"name": "OpenEVSE",
"after_dependencies": ["zeroconf"],
"codeowners": ["@c00w", "@firstof9"],
"codeowners": ["@c00w"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/openevse",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["openevsehttp"],
"quality_scale": "legacy",
"requirements": ["python-openevse-http==0.2.1"],
"zeroconf": ["_openevse._tcp.local."]
"requirements": ["python-openevse-http==0.2.1"]
}

View File

@@ -55,7 +55,7 @@ SENSOR_TYPES: dict[str, OSOEnergySensorEntityDescription] = {
key="optimization_mode",
translation_key="optimization_mode",
device_class=SensorDeviceClass.ENUM,
options=["off", "oso", "gridcompany", "smartcompany", "advanced", "nettleie"],
options=["off", "oso", "gridcompany", "smartcompany", "advanced"],
value_fn=lambda entity_data: entity_data.state.lower(),
),
"power_load": OSOEnergySensorEntityDescription(

View File

@@ -58,7 +58,6 @@
"state": {
"advanced": "Advanced",
"gridcompany": "Grid company",
"nettleie": "Nettleie",
"off": "[%key:common::state::off%]",
"oso": "OSO",
"smartcompany": "Smart company"

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/otbr",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["python-otbr-api==2.7.1"]
"requirements": ["python-otbr-api==2.7.0"]
}

View File

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

View File

@@ -11,11 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import (
PowerfoxConfigEntry,
PowerfoxDataUpdateCoordinator,
PowerfoxReportDataUpdateCoordinator,
)
from .coordinator import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
@@ -34,16 +30,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) ->
await client.close()
raise ConfigEntryNotReady from err
coordinators: list[
PowerfoxDataUpdateCoordinator | PowerfoxReportDataUpdateCoordinator
] = []
for device in devices:
if device.type == DeviceType.GAS_METER:
coordinators.append(
PowerfoxReportDataUpdateCoordinator(hass, entry, client, device)
)
continue
coordinators.append(PowerfoxDataUpdateCoordinator(hass, entry, client, device))
coordinators: list[PowerfoxDataUpdateCoordinator] = [
PowerfoxDataUpdateCoordinator(hass, entry, client, device)
for device in devices
# Filter out gas meter devices (Powerfox FLOW adapters) as they are not yet supported and cause integration failures
if device.type != DeviceType.GAS_METER
]
await asyncio.gather(
*[

View File

@@ -2,11 +2,8 @@
from __future__ import annotations
from datetime import datetime
from powerfox import (
Device,
DeviceReport,
Powerfox,
PowerfoxAuthenticationError,
PowerfoxConnectionError,
@@ -18,18 +15,14 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
type PowerfoxCoordinator = (
"PowerfoxDataUpdateCoordinator" | "PowerfoxReportDataUpdateCoordinator"
)
type PowerfoxConfigEntry = ConfigEntry[list[PowerfoxCoordinator]]
type PowerfoxConfigEntry = ConfigEntry[list[PowerfoxDataUpdateCoordinator]]
class PowerfoxBaseCoordinator[T](DataUpdateCoordinator[T]):
"""Base coordinator handling shared Powerfox logic."""
class PowerfoxDataUpdateCoordinator(DataUpdateCoordinator[Poweropti]):
"""Class to manage fetching Powerfox data from the API."""
config_entry: PowerfoxConfigEntry
@@ -40,7 +33,7 @@ class PowerfoxBaseCoordinator[T](DataUpdateCoordinator[T]):
client: Powerfox,
device: Device,
) -> None:
"""Initialize shared Powerfox coordinator."""
"""Initialize global Powerfox data updater."""
super().__init__(
hass,
LOGGER,
@@ -51,37 +44,11 @@ class PowerfoxBaseCoordinator[T](DataUpdateCoordinator[T]):
self.client = client
self.device = device
async def _async_update_data(self) -> T:
"""Fetch data and normalize Powerfox errors."""
async def _async_update_data(self) -> Poweropti:
"""Fetch data from Powerfox API."""
try:
return await self._async_fetch_data()
return await self.client.device(device_id=self.device.id)
except PowerfoxAuthenticationError as err:
raise ConfigEntryAuthFailed(err) from err
except (PowerfoxConnectionError, PowerfoxNoDataError) as err:
raise UpdateFailed(err) from err
async def _async_fetch_data(self) -> T:
"""Fetch data from the Powerfox API."""
raise NotImplementedError
class PowerfoxDataUpdateCoordinator(PowerfoxBaseCoordinator[Poweropti]):
"""Class to manage fetching Powerfox data from the API."""
async def _async_fetch_data(self) -> Poweropti:
"""Fetch live device data from the Powerfox API."""
return await self.client.device(device_id=self.device.id)
class PowerfoxReportDataUpdateCoordinator(PowerfoxBaseCoordinator[DeviceReport]):
"""Coordinator handling report data from the API."""
async def _async_fetch_data(self) -> DeviceReport:
"""Fetch report data from the Powerfox API."""
local_now = datetime.now(tz=dt_util.get_time_zone(self.hass.config.time_zone))
return await self.client.report(
device_id=self.device.id,
year=local_now.year,
month=local_now.month,
day=local_now.day,
)

View File

@@ -5,18 +5,18 @@ from __future__ import annotations
from datetime import datetime
from typing import Any
from powerfox import DeviceReport, HeatMeter, PowerMeter, WaterMeter
from powerfox import HeatMeter, PowerMeter, WaterMeter
from homeassistant.core import HomeAssistant
from .coordinator import PowerfoxConfigEntry
from .coordinator import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: PowerfoxConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for Powerfox config entry."""
powerfox_data = entry.runtime_data
powerfox_data: list[PowerfoxDataUpdateCoordinator] = entry.runtime_data
return {
"devices": [
@@ -68,21 +68,6 @@ async def async_get_config_entry_diagnostics(
if isinstance(coordinator.data, HeatMeter)
else {}
),
**(
{
"gas_meter": {
"sum": coordinator.data.gas.sum,
"consumption": coordinator.data.gas.consumption,
"consumption_kwh": coordinator.data.gas.consumption_kwh,
"current_consumption": coordinator.data.gas.current_consumption,
"current_consumption_kwh": coordinator.data.gas.current_consumption_kwh,
"sum_currency": coordinator.data.gas.sum_currency,
}
}
if isinstance(coordinator.data, DeviceReport)
and coordinator.data.gas
else {}
),
}
for coordinator in powerfox_data
],

View File

@@ -2,27 +2,23 @@
from __future__ import annotations
from typing import Any
from powerfox import Device
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import PowerfoxBaseCoordinator
from .coordinator import PowerfoxDataUpdateCoordinator
class PowerfoxEntity[CoordinatorT: PowerfoxBaseCoordinator[Any]](
CoordinatorEntity[CoordinatorT]
):
class PowerfoxEntity(CoordinatorEntity[PowerfoxDataUpdateCoordinator]):
"""Base entity for Powerfox."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: CoordinatorT,
coordinator: PowerfoxDataUpdateCoordinator,
device: Device,
) -> None:
"""Initialize Powerfox entity."""

View File

@@ -70,7 +70,10 @@ rules:
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-disabled-by-default:
status: exempt
comment: |
This integration does not have any entities that should disabled by default.
entity-translations: done
exception-translations: done
icon-translations:

View File

@@ -4,9 +4,8 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from powerfox import Device, GasReport, HeatMeter, PowerMeter, WaterMeter
from powerfox import Device, HeatMeter, PowerMeter, WaterMeter
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -14,16 +13,11 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import CURRENCY_EURO, UnitOfEnergy, UnitOfPower, UnitOfVolume
from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import (
PowerfoxBaseCoordinator,
PowerfoxConfigEntry,
PowerfoxDataUpdateCoordinator,
PowerfoxReportDataUpdateCoordinator,
)
from .coordinator import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator
from .entity import PowerfoxEntity
@@ -36,13 +30,6 @@ class PowerfoxSensorEntityDescription[T: (PowerMeter, WaterMeter, HeatMeter)](
value_fn: Callable[[T], float | int | None]
@dataclass(frozen=True, kw_only=True)
class PowerfoxReportSensorEntityDescription(SensorEntityDescription):
"""Describes Powerfox report sensor entity."""
value_fn: Callable[[GasReport], float | int | None]
SENSORS_POWER: tuple[PowerfoxSensorEntityDescription[PowerMeter], ...] = (
PowerfoxSensorEntityDescription[PowerMeter](
key="power",
@@ -139,104 +126,6 @@ SENSORS_HEAT: tuple[PowerfoxSensorEntityDescription[HeatMeter], ...] = (
),
)
SENSORS_GAS: tuple[PowerfoxReportSensorEntityDescription, ...] = (
PowerfoxReportSensorEntityDescription(
key="gas_consumption_today",
translation_key="gas_consumption_today",
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda gas: gas.sum,
),
PowerfoxReportSensorEntityDescription(
key="gas_consumption_energy_today",
translation_key="gas_consumption_energy_today",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
value_fn=lambda gas: gas.consumption_kwh,
),
PowerfoxReportSensorEntityDescription(
key="gas_current_consumption",
translation_key="gas_current_consumption",
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.GAS,
value_fn=lambda gas: gas.current_consumption,
),
PowerfoxReportSensorEntityDescription(
key="gas_current_consumption_energy",
translation_key="gas_current_consumption_energy",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
entity_registry_enabled_default=False,
value_fn=lambda gas: gas.current_consumption_kwh,
),
PowerfoxReportSensorEntityDescription(
key="gas_cost_today",
translation_key="gas_cost_today",
native_unit_of_measurement=CURRENCY_EURO,
device_class=SensorDeviceClass.MONETARY,
suggested_display_precision=2,
state_class=SensorStateClass.TOTAL,
value_fn=lambda gas: gas.sum_currency,
),
PowerfoxReportSensorEntityDescription(
key="gas_max_consumption_today",
translation_key="gas_max_consumption_today",
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.GAS,
value_fn=lambda gas: gas.max_consumption,
),
PowerfoxReportSensorEntityDescription(
key="gas_min_consumption_today",
translation_key="gas_min_consumption_today",
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.GAS,
value_fn=lambda gas: gas.min_consumption,
),
PowerfoxReportSensorEntityDescription(
key="gas_avg_consumption_today",
translation_key="gas_avg_consumption_today",
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.GAS,
entity_registry_enabled_default=False,
value_fn=lambda gas: gas.avg_consumption,
),
PowerfoxReportSensorEntityDescription(
key="gas_max_consumption_energy_today",
translation_key="gas_max_consumption_energy_today",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
entity_registry_enabled_default=False,
value_fn=lambda gas: gas.max_consumption_kwh,
),
PowerfoxReportSensorEntityDescription(
key="gas_min_consumption_energy_today",
translation_key="gas_min_consumption_energy_today",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
entity_registry_enabled_default=False,
value_fn=lambda gas: gas.min_consumption_kwh,
),
PowerfoxReportSensorEntityDescription(
key="gas_avg_consumption_energy_today",
translation_key="gas_avg_consumption_energy_today",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
entity_registry_enabled_default=False,
value_fn=lambda gas: gas.avg_consumption_kwh,
),
PowerfoxReportSensorEntityDescription(
key="gas_max_cost_today",
translation_key="gas_max_cost_today",
native_unit_of_measurement=CURRENCY_EURO,
device_class=SensorDeviceClass.MONETARY,
suggested_display_precision=2,
value_fn=lambda gas: gas.max_currency,
),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -246,20 +135,6 @@ async def async_setup_entry(
"""Set up Powerfox sensors based on a config entry."""
entities: list[SensorEntity] = []
for coordinator in entry.runtime_data:
if isinstance(coordinator, PowerfoxReportDataUpdateCoordinator):
gas_report = coordinator.data.gas
if gas_report is None:
continue
entities.extend(
PowerfoxGasSensorEntity(
coordinator=coordinator,
description=description,
device=coordinator.device,
)
for description in SENSORS_GAS
if description.value_fn(gas_report) is not None
)
continue
if isinstance(coordinator.data, PowerMeter):
entities.extend(
PowerfoxSensorEntity(
@@ -291,49 +166,23 @@ async def async_setup_entry(
async_add_entities(entities)
class BasePowerfoxSensorEntity[CoordinatorT: PowerfoxBaseCoordinator[Any]](
PowerfoxEntity[CoordinatorT], SensorEntity
):
"""Common base for Powerfox sensor entities."""
class PowerfoxSensorEntity(PowerfoxEntity, SensorEntity):
"""Defines a powerfox power meter sensor."""
entity_description: SensorEntityDescription
entity_description: PowerfoxSensorEntityDescription
def __init__(
self,
coordinator: CoordinatorT,
coordinator: PowerfoxDataUpdateCoordinator,
device: Device,
description: SensorEntityDescription,
description: PowerfoxSensorEntityDescription,
) -> None:
"""Initialize the shared Powerfox sensor."""
"""Initialize Powerfox power meter sensor."""
super().__init__(coordinator, device)
self.entity_description = description
self._attr_unique_id = f"{device.id}_{description.key}"
class PowerfoxSensorEntity(BasePowerfoxSensorEntity[PowerfoxDataUpdateCoordinator]):
"""Defines a powerfox poweropti sensor."""
coordinator: PowerfoxDataUpdateCoordinator
entity_description: PowerfoxSensorEntityDescription
@property
def native_value(self) -> float | int | None:
"""Return the state of the entity."""
return self.entity_description.value_fn(self.coordinator.data)
class PowerfoxGasSensorEntity(
BasePowerfoxSensorEntity[PowerfoxReportDataUpdateCoordinator]
):
"""Defines a powerfox gas meter sensor."""
coordinator: PowerfoxReportDataUpdateCoordinator
entity_description: PowerfoxReportSensorEntityDescription
@property
def native_value(self) -> float | int | None:
"""Return the state of the entity."""
gas_report = self.coordinator.data.gas
if TYPE_CHECKING:
assert gas_report is not None
return self.entity_description.value_fn(gas_report)

View File

@@ -62,42 +62,6 @@
"energy_usage_low_tariff": {
"name": "Energy usage low tariff"
},
"gas_avg_consumption_energy_today": {
"name": "Avg gas hourly energy - today"
},
"gas_avg_consumption_today": {
"name": "Avg gas hourly consumption - today"
},
"gas_consumption_energy_today": {
"name": "Gas consumption energy - today"
},
"gas_consumption_today": {
"name": "Gas consumption - today"
},
"gas_cost_today": {
"name": "Gas cost - today"
},
"gas_current_consumption": {
"name": "Gas consumption - this hour"
},
"gas_current_consumption_energy": {
"name": "Gas consumption energy - this hour"
},
"gas_max_consumption_energy_today": {
"name": "Max gas hourly energy - today"
},
"gas_max_consumption_today": {
"name": "Max gas hourly consumption - today"
},
"gas_max_cost_today": {
"name": "Max gas hourly cost - today"
},
"gas_min_consumption_energy_today": {
"name": "Min gas hourly energy - today"
},
"gas_min_consumption_today": {
"name": "Min gas hourly consumption - today"
},
"heat_delta_energy": {
"name": "Delta energy"
},

View File

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

View File

@@ -42,5 +42,5 @@
"iot_class": "local_push",
"loggers": ["switchbot"],
"quality_scale": "gold",
"requirements": ["PySwitchbot==0.76.0"]
"requirements": ["PySwitchbot==0.75.0"]
}

View File

@@ -12,8 +12,6 @@ from homeassistant.helpers.restore_state import RestoreEntity
from .entity import TeslaFleetVehicleEntity
from .models import TeslaFleetVehicleData
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,

View File

@@ -14,9 +14,6 @@
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
},
"data_description": {
"access_token": "[%key:component::tessie::config::step::user::data_description::access_token%]"
},
"description": "[%key:component::tessie::config::step::user::description%]",
"title": "[%key:common::config_flow::title::reauth%]"
},
@@ -24,9 +21,6 @@
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
},
"data_description": {
"access_token": "Visit developer settings and select Generate Access Token."
},
"description": "Enter your access token from {url}."
}
}

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/thread",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["python-otbr-api==2.7.1", "pyroute2==0.7.5"],
"requirements": ["python-otbr-api==2.7.0", "pyroute2==0.7.5"],
"single_config_entry": true,
"zeroconf": ["_meshcop._udp.local."]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -74,8 +74,6 @@ class VeluxRainSensor(VeluxEntity, BinarySensorEntity):
self._attr_available = True
# Velux windows with rain sensors report an opening limitation when rain is detected.
# So far we've seen 89, 91, 93 (most cases) or 100 (Velux GPU). It probably makes sense to
# assume that any large enough limitation (we use >=89) means rain is detected.
# Documentation on this is non-existent AFAIK.
self._attr_is_on = limitation.min_value >= 89
# Velux windows with rain sensors report an opening limitation of 93 or 100 (Velux GPU) when rain is detected.
# So far, only 93 and 100 have been observed in practice, documentation on this is non-existent AFAIK.
self._attr_is_on = limitation.min_value in {93, 100}

View File

@@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VeluxConfigEntry
from .entity import VeluxEntity, wrap_pyvlx_call_exceptions
from .entity import VeluxEntity
PARALLEL_UPDATES = 1
@@ -98,7 +98,10 @@ class VeluxCover(VeluxEntity, CoverEntity):
@property
def is_closed(self) -> bool:
"""Return if the cover is closed."""
return self.node.position.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
@property
def is_opening(self) -> bool:
@@ -110,17 +113,14 @@ 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,27 +129,22 @@ class VeluxCover(VeluxEntity, CoverEntity):
Position(position_percent=position_percent), wait_for_completion=False
)
@wrap_pyvlx_call_exceptions
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
await self.node.stop(wait_for_completion=False)
@wrap_pyvlx_call_exceptions
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close cover tilt."""
await cast(Blind, self.node).close_orientation(wait_for_completion=False)
@wrap_pyvlx_call_exceptions
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open cover tilt."""
await cast(Blind, self.node).open_orientation(wait_for_completion=False)
@wrap_pyvlx_call_exceptions
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
"""Stop cover tilt."""
await cast(Blind, self.node).stop_orientation(wait_for_completion=False)
@wrap_pyvlx_call_exceptions
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move cover tilt to a specific position."""
position_percent = 100 - kwargs[ATTR_TILT_POSITION]

View File

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

View File

@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VeluxConfigEntry
from .entity import VeluxEntity, wrap_pyvlx_call_exceptions
from .entity import VeluxEntity
PARALLEL_UPDATES = 1
@@ -49,7 +49,6 @@ 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:
@@ -61,7 +60,6 @@ class VeluxLight(VeluxEntity, LightEntity):
else:
await self.node.turn_on(wait_for_completion=True)
@wrap_pyvlx_call_exceptions
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
await self.node.turn_off(wait_for_completion=True)

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
"name": "Watts Vision +",
"codeowners": ["@theobld-ww", "@devender-verma-ww", "@ssi-spyro"],
"config_flow": true,
"dependencies": ["application_credentials", "cloud"],
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/watts",
"iot_class": "cloud_polling",
"quality_scale": "bronze",

View File

@@ -23,7 +23,7 @@
"universal_silabs_flasher",
"serialx"
],
"requirements": ["zha==0.0.84", "serialx==0.5.0"],
"requirements": ["zha==0.0.83", "serialx==0.5.0"],
"usb": [
{
"description": "*2652*",

View File

@@ -278,7 +278,6 @@ FLOWS = {
"habitica",
"hanna",
"harmony",
"hdfury",
"heos",
"here_travel_time",
"hikvision",

View File

@@ -2678,12 +2678,6 @@
"config_flow": false,
"iot_class": "local_polling"
},
"hdfury": {
"name": "HDFury",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"hdmi_cec": {
"name": "HDMI-CEC",
"integration_type": "hub",

View File

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

View File

@@ -1208,14 +1208,7 @@ 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",
"pref_disable_polling",
):
if attr_name not in ("domain", "title", "state", "source", "disabled_by"):
raise TemplateError("Invalid config entry attribute")
config_entry = hass.config_entries.async_get_entry(config_entry_id_)

View File

@@ -39,7 +39,7 @@ habluetooth==5.8.0
hass-nabucasa==1.7.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20260107.0
home-assistant-frontend==20251229.1
home-assistant-intents==2026.1.6
httpx==0.28.1
ifaddr==0.2.0

21
requirements_all.txt generated
View File

@@ -83,7 +83,7 @@ PyRMVtransport==0.3.3
PySrDaliGateway==0.18.0
# homeassistant.components.switchbot
PySwitchbot==0.76.0
PySwitchbot==0.75.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.28.0
aiohomeconnect==0.26.0
# homeassistant.components.homekit_controller
aiohomekit==3.2.20
@@ -432,7 +432,7 @@ aiousbwatcher==1.1.1
aiovlc==0.5.1
# homeassistant.components.vodafone_station
aiovodafone==3.1.1
aiovodafone==3.0.0
# 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.1
deebot-client==17.0.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -1184,9 +1184,6 @@ hassil==3.5.0
# homeassistant.components.jewish_calendar
hdate[astral]==1.1.2
# homeassistant.components.hdfury
hdfury==1.3.1
# homeassistant.components.heatmiser
heatmiserV3==2.0.4
@@ -1216,7 +1213,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20260107.0
home-assistant-frontend==20251229.1
# homeassistant.components.conversation
home-assistant-intents==2026.1.6
@@ -2291,7 +2288,7 @@ pyotgw==2.2.2
pyotp==2.9.0
# homeassistant.components.overkiz
pyoverkiz==1.19.4
pyoverkiz==1.19.3
# homeassistant.components.palazzetti
pypalazzetti==0.1.20
@@ -2566,7 +2563,7 @@ python-opensky==1.0.1
# homeassistant.components.otbr
# homeassistant.components.thread
python-otbr-api==2.7.1
python-otbr-api==2.7.0
# homeassistant.components.overseerr
python-overseerr==0.8.0
@@ -2786,7 +2783,7 @@ rpi-bad-power==0.1.0
russound==0.2.0
# homeassistant.components.ruuvitag_ble
ruuvitag-ble==0.4.0
ruuvitag-ble==0.3.0
# homeassistant.components.yamaha
rxv==0.7.0
@@ -3280,7 +3277,7 @@ zeroconf==0.148.0
zeversolar==0.3.2
# homeassistant.components.zha
zha==0.0.84
zha==0.0.83
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13

View File

@@ -83,7 +83,7 @@ PyRMVtransport==0.3.3
PySrDaliGateway==0.18.0
# homeassistant.components.switchbot
PySwitchbot==0.76.0
PySwitchbot==0.75.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.28.0
aiohomeconnect==0.26.0
# homeassistant.components.homekit_controller
aiohomekit==3.2.20
@@ -417,7 +417,7 @@ aiousbwatcher==1.1.1
aiovlc==0.5.1
# homeassistant.components.vodafone_station
aiovodafone==3.1.1
aiovodafone==3.0.0
# 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.1
deebot-client==17.0.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -1051,9 +1051,6 @@ hassil==3.5.0
# homeassistant.components.jewish_calendar
hdate[astral]==1.1.2
# homeassistant.components.hdfury
hdfury==1.3.1
# homeassistant.components.here_travel_time
here-routing==1.2.0
@@ -1074,7 +1071,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20260107.0
home-assistant-frontend==20251229.1
# homeassistant.components.conversation
home-assistant-intents==2026.1.6
@@ -1938,7 +1935,7 @@ pyotgw==2.2.2
pyotp==2.9.0
# homeassistant.components.overkiz
pyoverkiz==1.19.4
pyoverkiz==1.19.3
# homeassistant.components.palazzetti
pypalazzetti==0.1.20
@@ -2156,7 +2153,7 @@ python-opensky==1.0.1
# homeassistant.components.otbr
# homeassistant.components.thread
python-otbr-api==2.7.1
python-otbr-api==2.7.0
# homeassistant.components.overseerr
python-overseerr==0.8.0
@@ -2334,7 +2331,7 @@ rova==0.4.1
rpi-bad-power==0.1.0
# homeassistant.components.ruuvitag_ble
ruuvitag-ble==0.4.0
ruuvitag-ble==0.3.0
# homeassistant.components.yamaha
rxv==0.7.0
@@ -2744,7 +2741,7 @@ zeroconf==0.148.0
zeversolar==0.3.2
# homeassistant.components.zha
zha==0.0.84
zha==0.0.83
# homeassistant.components.zwave_js
zwave-js-server-python==0.67.1

View File

@@ -172,6 +172,88 @@ class StateDescription(TypedDict):
count: int
class ConditionStateDescription(TypedDict):
"""Test state and expected service call count."""
included: _StateDescription
excluded: _StateDescription
count: int
valid: bool
def parametrize_condition_states(
*,
condition: str,
condition_options: dict[str, Any] | None = None,
target_states: list[str | None | tuple[str | None, dict]],
other_states: list[str | None | tuple[str | None, dict]],
additional_attributes: dict | None = None,
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
"""Parametrize states and expected service call counts.
The target_states and other_states iterables are either iterables of
states or iterables of (state, attributes) tuples.
Returns a list of tuples with (trigger, list of states),
where states is a list of ConditionStateDescription dicts.
"""
additional_attributes = additional_attributes or {}
condition_options = condition_options or {}
def state_with_attributes(
state: str | None | tuple[str | None, dict], count: int, valid: bool
) -> ConditionStateDescription:
"""Return (state, attributes) dict."""
if isinstance(state, str) or state is None:
return {
"included": {
"state": state,
"attributes": additional_attributes,
},
"excluded": {
"state": state,
"attributes": {},
},
"count": count,
"valid": valid,
}
return {
"included": {
"state": state[0],
"attributes": state[1] | additional_attributes,
},
"excluded": {
"state": state[0],
"attributes": state[1],
},
"count": count,
"valid": valid,
}
return [
(
condition,
condition_options,
list(
itertools.chain(
(state_with_attributes(None, 0, False),),
(state_with_attributes(STATE_UNAVAILABLE, 0, False),),
(state_with_attributes(STATE_UNKNOWN, 0, False),),
(
state_with_attributes(other_state, 0, True)
for other_state in other_states
),
(
state_with_attributes(target_state, 1, True)
for target_state in target_states
),
)
),
),
]
def parametrize_trigger_states(
*,
trigger: str,
@@ -202,7 +284,7 @@ def parametrize_trigger_states(
def state_with_attributes(
state: str | None | tuple[str | None, dict], count: int
) -> dict:
) -> StateDescription:
"""Return (state, attributes) dict."""
if isinstance(state, str) or state is None:
return {

View File

@@ -2232,202 +2232,6 @@ async def test_extraction_functions(
assert automation.blueprint_in_automation(hass, "automation.test3") is None
async def test_extraction_functions_with_targets(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test extraction functions with targets in triggers.
This test verifies that targets specified in trigger configurations
(using new-style triggers that support target) are properly extracted for
entity, device, area, floor, and label references.
"""
config_entry = MockConfigEntry(domain="fake_integration", data={})
config_entry.mock_state(hass, ConfigEntryState.LOADED)
config_entry.add_to_hass(hass)
trigger_device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:01")},
)
await async_setup_component(hass, "homeassistant", {})
await async_setup_component(
hass, "scene", {"scene": {"name": "test", "entities": {}}}
)
await hass.async_block_till_done()
# Enable the new_triggers_conditions feature flag to allow new-style triggers
assert await async_setup_component(hass, "labs", {})
ws_client = await hass_ws_client(hass)
await ws_client.send_json_auto_id(
{
"type": "labs/update",
"domain": "automation",
"preview_feature": "new_triggers_conditions",
"enabled": True,
}
)
msg = await ws_client.receive_json()
assert msg["success"]
await hass.async_block_till_done()
assert await async_setup_component(
hass,
DOMAIN,
{
DOMAIN: [
{
"alias": "test1",
"triggers": [
# Single entity_id in target
{
"trigger": "scene.activated",
"target": {"entity_id": "scene.target_entity"},
},
# Multiple entity_ids in target
{
"trigger": "scene.activated",
"target": {
"entity_id": [
"scene.target_entity_list1",
"scene.target_entity_list2",
]
},
},
# Single device_id in target
{
"trigger": "scene.activated",
"target": {"device_id": trigger_device.id},
},
# Multiple device_ids in target
{
"trigger": "scene.activated",
"target": {
"device_id": [
"target-device-1",
"target-device-2",
]
},
},
# Single area_id in target
{
"trigger": "scene.activated",
"target": {"area_id": "area-target-single"},
},
# Multiple area_ids in target
{
"trigger": "scene.activated",
"target": {"area_id": ["area-target-1", "area-target-2"]},
},
# Single floor_id in target
{
"trigger": "scene.activated",
"target": {"floor_id": "floor-target-single"},
},
# Multiple floor_ids in target
{
"trigger": "scene.activated",
"target": {
"floor_id": ["floor-target-1", "floor-target-2"]
},
},
# Single label_id in target
{
"trigger": "scene.activated",
"target": {"label_id": "label-target-single"},
},
# Multiple label_ids in target
{
"trigger": "scene.activated",
"target": {
"label_id": ["label-target-1", "label-target-2"]
},
},
# Combined targets
{
"trigger": "scene.activated",
"target": {
"entity_id": "scene.combined_entity",
"device_id": "combined-device",
"area_id": "combined-area",
"floor_id": "combined-floor",
"label_id": "combined-label",
},
},
],
"conditions": [],
"actions": [
{
"action": "test.script",
"data": {"entity_id": "light.action_entity"},
},
],
},
]
},
)
# Test entity extraction from trigger targets
assert set(automation.entities_in_automation(hass, "automation.test1")) == {
"scene.target_entity",
"scene.target_entity_list1",
"scene.target_entity_list2",
"scene.combined_entity",
"light.action_entity",
}
# Test device extraction from trigger targets
assert set(automation.devices_in_automation(hass, "automation.test1")) == {
trigger_device.id,
"target-device-1",
"target-device-2",
"combined-device",
}
# Test area extraction from trigger targets
assert set(automation.areas_in_automation(hass, "automation.test1")) == {
"area-target-single",
"area-target-1",
"area-target-2",
"combined-area",
}
# Test floor extraction from trigger targets
assert set(automation.floors_in_automation(hass, "automation.test1")) == {
"floor-target-single",
"floor-target-1",
"floor-target-2",
"combined-floor",
}
# Test label extraction from trigger targets
assert set(automation.labels_in_automation(hass, "automation.test1")) == {
"label-target-single",
"label-target-1",
"label-target-2",
"combined-label",
}
# Test automations_with_* functions
assert set(automation.automations_with_entity(hass, "scene.target_entity")) == {
"automation.test1"
}
assert set(automation.automations_with_device(hass, trigger_device.id)) == {
"automation.test1"
}
assert set(automation.automations_with_area(hass, "area-target-single")) == {
"automation.test1"
}
assert set(automation.automations_with_floor(hass, "floor-target-single")) == {
"automation.test1"
}
assert set(automation.automations_with_label(hass, "label-target-single")) == {
"automation.test1"
}
async def test_logbook_humanify_automation_triggered_event(hass: HomeAssistant) -> None:
"""Test humanifying Automation Trigger event."""
hass.config.components.add("recorder")

View File

@@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, Mock, patch
from mozart_api.models import (
Action,
BatteryState,
BeolinkPeer,
BeolinkSelf,
ContentItem,
@@ -35,7 +34,6 @@ 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,
@@ -127,7 +125,6 @@ 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)
@@ -140,10 +137,6 @@ 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()
@@ -410,14 +403,6 @@ def mock_mozart_client() -> Generator[AsyncMock]:
)
]
)
client.get_battery_state = AsyncMock()
client.get_battery_state.return_value = BatteryState(
battery_level=0,
is_charging=False,
remaining_charging_time_minutes=0,
remaining_playing_time_minutes=0,
state="BatteryNotPresent",
)
client.post_standby = AsyncMock()
client.set_current_volume_level = AsyncMock()
client.set_volume_mute = AsyncMock()

View File

@@ -6,7 +6,6 @@ from unittest.mock import Mock
from mozart_api.exceptions import ApiException
from mozart_api.models import (
Action,
BatteryState,
ListeningModeRef,
OverlayPlayRequest,
OverlayPlayRequestTextToSpeechTextToSpeech,
@@ -72,18 +71,14 @@ 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_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_REMOTE_KEY_EVENT_ENTITY_ID = "event.beoremote_one_55555555_11111111_control_play"
TEST_HOSTNAME_ZEROCONF = TEST_NAME.replace(" ", "-") + ".local."
TEST_TYPE_ZEROCONF = "_bangolufsen._tcp.local."
@@ -260,10 +255,3 @@ TEST_SOUND_MODES = [
TEST_ACTIVE_SOUND_MODE_NAME_2,
f"{TEST_SOUND_MODE_NAME} 2 (345)",
]
TEST_BATTERY = BatteryState(
battery_level=5,
is_charging=False,
remaining_charging_time_minutes=0,
remaining_playing_time_minutes=0,
state="BatteryVeryLow",
)

View File

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

View File

@@ -100,8 +100,6 @@
'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',
])
# ---
@@ -207,7 +205,6 @@
'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',
])
# ---
@@ -311,7 +308,6 @@
'event.beoremote_one_55555555_33333333_control_function_25',
'event.beoremote_one_55555555_33333333_control_function_26',
'event.beoremote_one_55555555_33333333_control_function_27',
'sensor.beoremote_one_55555555_33333333_battery',
'media_player.beosound_premiere_33333333',
])
# ---

View File

@@ -101,7 +101,6 @@
'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',
])
# ---
@@ -207,7 +206,6 @@
'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',
@@ -299,7 +297,6 @@
'event.beoremote_one_66666666_11111111_control_function_25',
'event.beoremote_one_66666666_11111111_control_function_26',
'event.beoremote_one_66666666_11111111_control_function_27',
'sensor.beoremote_one_66666666_11111111_battery',
])
# ---
# name: test_on_remote_control_unpaired

View File

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

View File

@@ -18,7 +18,6 @@ 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,
@@ -131,7 +130,6 @@ async def test_button_event_creation_a5(
snapshot: SnapshotAssertion,
) -> None:
"""Test Microphone button event entity is not created when using a Beosound A5."""
mock_mozart_client.get_battery_state.return_value = TEST_BATTERY
await _check_button_event_creation(
hass,

View File

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

View File

@@ -130,7 +130,7 @@ async def test_on_remote_control_already_added(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
# Check device and API call count
assert mock_mozart_client.get_bluetooth_remotes.call_count == 3
assert mock_mozart_client.get_bluetooth_remotes.call_count == 1
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 == 4
assert mock_mozart_client.get_bluetooth_remotes.call_count == 2
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 == 3
assert mock_mozart_client.get_bluetooth_remotes.call_count == 1
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 == 8
assert mock_mozart_client.get_bluetooth_remotes.call_count == 3
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 == 3
assert mock_mozart_client.get_bluetooth_remotes.call_count == 1
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 == 6
assert mock_mozart_client.get_bluetooth_remotes.call_count == 3
assert (
device_registry.async_get_device({(DOMAIN, TEST_REMOTE_SERIAL_PAIRED)}) is None
)

View File

@@ -11,7 +11,6 @@ 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,
@@ -52,7 +51,6 @@ 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")
@@ -68,9 +66,7 @@ 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] = [
f"sensor.beoremote_one_{remote_serial}_{device_serial}_battery"
]
entity_ids: list[str] = []
# Add remote light key Event entity ids
entity_ids.extend(

View File

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

View File

@@ -1,13 +0,0 @@
"""Tests for the HDFury integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Fixture for setting up the integration."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@@ -1,79 +0,0 @@
"""Common fixtures for the HDFury tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.hdfury.const import DOMAIN
from homeassistant.const import CONF_HOST
from tests.common import MockConfigEntry
TEST_HOST = "192.168.1.123"
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.hdfury.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
domain=DOMAIN,
unique_id="000123456789",
data={
CONF_HOST: TEST_HOST,
},
)
@pytest.fixture(autouse=True)
def mock_hdfury_client() -> Generator[AsyncMock]:
"""Mock a HDFury client."""
with (
patch(
"homeassistant.components.hdfury.config_flow.HDFuryAPI",
autospec=True,
) as mock_cf_client,
patch(
"homeassistant.components.hdfury.coordinator.HDFuryAPI",
autospec=True,
) as mock_coord_client,
):
# Config flow client
cf_client = mock_cf_client.return_value
cf_client.get_board = AsyncMock(
return_value={
"hostname": "VRROOM-02",
"ipaddress": "192.168.1.123",
"serial": "000123456789",
"pcbv": "3",
"version": "FW: 0.61",
}
)
# Coordinator client
coord_client = mock_coord_client.return_value
coord_client.get_board = cf_client.get_board
coord_client.get_info = AsyncMock(
return_value={
"portseltx0": "0",
"portseltx1": "4",
"opmode": "0",
}
)
coord_client.get_config = AsyncMock(
return_value={
"macaddr": "c7:1c:df:9d:f6:40",
}
)
yield coord_client

View File

@@ -1,192 +0,0 @@
# serializer version: 1
# name: test_select_entities[select.hdfury_vrroom_02_operation_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'0',
'1',
'2',
'3',
'4',
'5',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.hdfury_vrroom_02_operation_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Operation mode',
'platform': 'hdfury',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'opmode',
'unique_id': '000123456789_opmode',
'unit_of_measurement': None,
})
# ---
# name: test_select_entities[select.hdfury_vrroom_02_operation_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'HDFury VRROOM-02 Operation mode',
'options': list([
'0',
'1',
'2',
'3',
'4',
'5',
]),
}),
'context': <ANY>,
'entity_id': 'select.hdfury_vrroom_02_operation_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_select_entities[select.hdfury_vrroom_02_port_select_tx0-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'0',
'1',
'2',
'3',
'4',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.hdfury_vrroom_02_port_select_tx0',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Port select TX0',
'platform': 'hdfury',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'portseltx0',
'unique_id': '000123456789_portseltx0',
'unit_of_measurement': None,
})
# ---
# name: test_select_entities[select.hdfury_vrroom_02_port_select_tx0-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'HDFury VRROOM-02 Port select TX0',
'options': list([
'0',
'1',
'2',
'3',
'4',
]),
}),
'context': <ANY>,
'entity_id': 'select.hdfury_vrroom_02_port_select_tx0',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_select_entities[select.hdfury_vrroom_02_port_select_tx1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'0',
'1',
'2',
'3',
'4',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.hdfury_vrroom_02_port_select_tx1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Port select TX1',
'platform': 'hdfury',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'portseltx1',
'unique_id': '000123456789_portseltx1',
'unit_of_measurement': None,
})
# ---
# name: test_select_entities[select.hdfury_vrroom_02_port_select_tx1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'HDFury VRROOM-02 Port select TX1',
'options': list([
'0',
'1',
'2',
'3',
'4',
]),
}),
'context': <ANY>,
'entity_id': 'select.hdfury_vrroom_02_port_select_tx1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '4',
})
# ---

View File

@@ -1,96 +0,0 @@
"""Test the HDFury config flow."""
from unittest.mock import AsyncMock
from hdfury import HDFuryError
from homeassistant.components.hdfury.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_async_step_user_gets_form_and_creates_entry(
hass: HomeAssistant,
mock_hdfury_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test that the we can view the form and that the config flow creates an entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "192.168.1.123"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_HOST: "192.168.1.123",
}
assert result["result"].unique_id == "000123456789"
async def test_abort_if_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
) -> None:
"""Test that we abort if we attempt to submit the same entry twice."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "192.168.1.123"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_successful_recovery_after_connection_error(
hass: HomeAssistant,
mock_hdfury_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test error shown when connection fails."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
# Simulate a connection error by raising a HDFuryError
mock_hdfury_client.get_board.side_effect = HDFuryError()
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "192.168.1.123"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
# Simulate successful connection on retry
mock_hdfury_client.get_board.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "192.168.1.123"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_HOST: "192.168.1.123",
}
assert result["result"].unique_id == "000123456789"

View File

@@ -1,30 +0,0 @@
"""Tests for the HDFury select platform."""
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
import homeassistant.helpers.entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.SELECT]
async def test_select_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test HDFury select entities."""
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@@ -1,6 +1,7 @@
"""Test light conditions."""
from collections.abc import Generator
from typing import Any
from unittest.mock import patch
import pytest
@@ -13,24 +14,18 @@ from homeassistant.const import (
CONF_TARGET,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
from tests.components import (
ConditionStateDescription,
parametrize_condition_states,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
INVALID_STATES = [
{"state": STATE_UNAVAILABLE, "attributes": {}},
{"state": STATE_UNKNOWN, "attributes": {}},
{"state": None, "attributes": {}},
]
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
@@ -76,15 +71,15 @@ async def setup_automation_with_light_condition(
)
async def has_call_after_trigger(
async def calls_after_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> bool:
"""Check if there are service calls after the trigger event."""
) -> int:
"""Return number of service calls after the trigger event."""
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
has_calls = len(service_calls) == 1
num_calls = len(service_calls)
service_calls.clear()
return has_calls
return num_calls
@pytest.fixture(name="enable_experimental_triggers_conditions")
@@ -125,17 +120,17 @@ async def test_light_conditions_gated_by_labs_flag(
parametrize_target_entities("light"),
)
@pytest.mark.parametrize(
("condition", "target_state", "other_state"),
("condition", "condition_options", "states"),
[
(
"light.is_on",
{"state": STATE_ON, "attributes": {}},
{"state": STATE_OFF, "attributes": {}},
*parametrize_condition_states(
condition="light.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
(
"light.is_off",
{"state": STATE_OFF, "attributes": {}},
{"state": STATE_ON, "attributes": {}},
*parametrize_condition_states(
condition="light.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
],
)
@@ -148,15 +143,15 @@ async def test_light_state_condition_behavior_any(
entity_id: str,
entities_in_target: int,
condition: str,
target_state: str,
other_state: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the light state condition with the 'any' behavior."""
other_entity_ids = set(target_lights) - {entity_id}
# Set all lights, including the tested light, to the initial state
for eid in target_lights:
set_or_remove_state(hass, eid, other_state)
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await setup_automation_with_light_condition(
@@ -167,38 +162,23 @@ async def test_light_state_condition_behavior_any(
)
# Set state for switches to ensure that they don't impact the condition
for eid in target_switches:
set_or_remove_state(hass, eid, other_state)
await hass.async_block_till_done()
assert not await has_call_after_trigger(hass, service_calls)
for state in states:
for eid in target_switches:
set_or_remove_state(hass, eid, state["included"])
await hass.async_block_till_done()
assert await calls_after_trigger(hass, service_calls) == 0
for eid in target_switches:
set_or_remove_state(hass, eid, target_state)
await hass.async_block_till_done()
assert not await has_call_after_trigger(hass, service_calls)
for state in states:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert await calls_after_trigger(hass, service_calls) == state["count"]
# Set one light to the condition state -> condition pass
set_or_remove_state(hass, entity_id, target_state)
assert await has_call_after_trigger(hass, service_calls)
# Set all remaining lights to the condition state -> condition pass
for eid in other_entity_ids:
set_or_remove_state(hass, eid, target_state)
assert await has_call_after_trigger(hass, service_calls)
for invalid_state in INVALID_STATES:
# Set one light to the invalid state -> condition pass if there are
# other lights in the condition state
set_or_remove_state(hass, entity_id, invalid_state)
assert await has_call_after_trigger(hass, service_calls) == bool(
entities_in_target - 1
)
for invalid_state in INVALID_STATES:
# Set all lights to invalid state -> condition fail
for eid in other_entity_ids:
set_or_remove_state(hass, eid, invalid_state)
assert not await has_call_after_trigger(hass, service_calls)
# Check if changing other lights also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert await calls_after_trigger(hass, service_calls) == state["count"]
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@@ -207,17 +187,17 @@ async def test_light_state_condition_behavior_any(
parametrize_target_entities("light"),
)
@pytest.mark.parametrize(
("condition", "target_state", "other_state"),
("condition", "condition_options", "states"),
[
(
"light.is_on",
{"state": STATE_ON, "attributes": {}},
{"state": STATE_OFF, "attributes": {}},
*parametrize_condition_states(
condition="light.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
(
"light.is_off",
{"state": STATE_OFF, "attributes": {}},
{"state": STATE_ON, "attributes": {}},
*parametrize_condition_states(
condition="light.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
],
)
@@ -229,8 +209,8 @@ async def test_light_state_condition_behavior_all(
entity_id: str,
entities_in_target: int,
condition: str,
target_state: str,
other_state: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the light state condition with the 'all' behavior."""
# Set state for two switches to ensure that they don't impact the condition
@@ -241,7 +221,7 @@ async def test_light_state_condition_behavior_all(
# Set all lights, including the tested light, to the initial state
for eid in target_lights:
set_or_remove_state(hass, eid, other_state)
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await setup_automation_with_light_condition(
@@ -251,27 +231,22 @@ async def test_light_state_condition_behavior_all(
behavior="all",
)
# No lights on the condition state
assert not await has_call_after_trigger(hass, service_calls)
for state in states:
included_state = state["included"]
# Set one light to the condition state -> condition fail
set_or_remove_state(hass, entity_id, target_state)
assert await has_call_after_trigger(hass, service_calls) == (
entities_in_target == 1
)
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
# The condition passes if all entities are either in a target state or invalid
assert await calls_after_trigger(hass, service_calls) == (
(not state["valid"]) or (state["count"] if entities_in_target == 1 else 0)
)
# Set all remaining lights to the condition state -> condition pass
for eid in other_entity_ids:
set_or_remove_state(hass, eid, target_state)
assert await has_call_after_trigger(hass, service_calls)
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
for invalid_state in INVALID_STATES:
# Set one light to the invalid state -> condition still pass
set_or_remove_state(hass, entity_id, invalid_state)
assert await has_call_after_trigger(hass, service_calls)
for invalid_state in INVALID_STATES:
# Set all lights to unavailable -> condition passes
for eid in other_entity_ids:
set_or_remove_state(hass, eid, invalid_state)
assert await has_call_after_trigger(hass, service_calls)
# The condition passes if all entities are either in a target state or invalid
assert (
await calls_after_trigger(hass, service_calls) == (not state["valid"])
or state["count"]
)

View File

@@ -1,18 +1,10 @@
"""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]:
@@ -21,19 +13,3 @@ def mock_setup_entry() -> Generator[AsyncMock]:
"homeassistant.components.nina.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Provide a common mock config entry."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title="NINA",
data=deepcopy(DUMMY_CONFIG_ENTRY),
version=1,
minor_version=3,
)
config_entry.add_to_hass(hass)
return config_entry

View File

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

View File

@@ -29,26 +29,33 @@ 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")
)
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
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"},
}
async def test_step_user_connection_error(hass: HomeAssistant) -> None:
@@ -58,7 +65,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}
DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA)
)
assert result["type"] is FlowResultType.ABORT
@@ -72,7 +79,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}
DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA)
)
assert result["type"] is FlowResultType.ABORT
@@ -86,15 +93,18 @@ 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}
DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA)
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=deepcopy(DUMMY_USER_INPUT),
)
assert_dummy_entry_created(result)
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
async def test_step_user_no_selection(hass: HomeAssistant) -> None:
@@ -115,22 +125,30 @@ 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_USER_INPUT),
user_input=deepcopy(DUMMY_DATA),
)
assert_dummy_entry_created(result)
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)"
}
}
async def test_step_user_already_configured(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
async def test_step_user_already_configured(hass: HomeAssistant) -> 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}
DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA)
)
assert result["type"] is FlowResultType.ABORT
@@ -138,21 +156,28 @@ async def test_step_user_already_configured(
async def test_options_flow_init(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> 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(mock_config_entry.entry_id)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(
mock_config_entry.entry_id
)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
@@ -176,9 +201,9 @@ async def test_options_flow_init(
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {}
assert dict(mock_config_entry.data) == {
CONF_FILTERS: DUMMY_USER_INPUT[CONF_FILTERS],
CONF_MESSAGE_SLOTS: DUMMY_USER_INPUT[CONF_MESSAGE_SLOTS],
assert dict(config_entry.data) == {
CONF_FILTERS: DUMMY_DATA[CONF_FILTERS],
CONF_MESSAGE_SLOTS: DUMMY_DATA[CONF_MESSAGE_SLOTS],
CONST_REGION_A_TO_D: ["072350000000_1"],
CONST_REGION_E_TO_H: [],
CONST_REGION_I_TO_L: [],
@@ -192,21 +217,28 @@ async def test_options_flow_init(
async def test_options_flow_with_no_selection(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> 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(mock_config_entry.entry_id)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(
mock_config_entry.entry_id
)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
@@ -247,9 +279,9 @@ async def test_options_flow_with_no_selection(
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {}
assert dict(mock_config_entry.data) == {
CONF_FILTERS: DUMMY_USER_INPUT[CONF_FILTERS],
CONF_MESSAGE_SLOTS: DUMMY_USER_INPUT[CONF_MESSAGE_SLOTS],
assert dict(config_entry.data) == {
CONF_FILTERS: DUMMY_DATA[CONF_FILTERS],
CONF_MESSAGE_SLOTS: DUMMY_DATA[CONF_MESSAGE_SLOTS],
CONST_REGION_A_TO_D: ["095760000000_0"],
CONST_REGION_E_TO_H: [],
CONST_REGION_I_TO_L: [],
@@ -261,40 +293,54 @@ async def test_options_flow_with_no_selection(
async def test_options_flow_connection_error(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> 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(mock_config_entry.entry_id)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(
mock_config_entry.entry_id
)
result = await hass.config_entries.options.async_init(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, mock_config_entry: MockConfigEntry
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> 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(mock_config_entry.entry_id)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(
mock_config_entry.entry_id
)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unknown"
@@ -307,7 +353,7 @@ async def test_options_flow_entity_removal(
config_entry = MockConfigEntry(
domain=DOMAIN,
title="NINA",
data=deepcopy(DUMMY_CONFIG_ENTRY) | {CONF_REGIONS: {"095760000000": "Aach"}},
data=deepcopy(OPTIONS_ENTRY_DATA) | {CONF_REGIONS: {"095760000000": "Aach"}},
version=1,
minor_version=3,
)

View File

@@ -34,11 +34,6 @@ def mock_charger() -> Generator[MagicMock]:
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
@@ -52,25 +47,8 @@ def mock_setup_entry() -> Generator[AsyncMock]:
@pytest.fixture
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:
def mock_config_entry() -> MockConfigEntry:
"""Create a mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "192.168.1.100"},
entry_id="FAKE",
unique_id=serial_number,
domain=DOMAIN, data={CONF_HOST: "192.168.1.100"}, entry_id="FAKE"
)

View File

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

View File

@@ -4,15 +4,7 @@ from collections.abc import Generator
from datetime import UTC, datetime
from unittest.mock import AsyncMock, patch
from powerfox import (
Device,
DeviceReport,
DeviceType,
GasReport,
HeatMeter,
PowerMeter,
WaterMeter,
)
from powerfox import Device, DeviceType, HeatMeter, PowerMeter, WaterMeter
import pytest
from homeassistant.components.powerfox.const import DOMAIN
@@ -21,64 +13,6 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from tests.common import MockConfigEntry
def _power_meter() -> PowerMeter:
"""Return a mocked power meter reading."""
return PowerMeter(
outdated=False,
timestamp=datetime(2024, 11, 26, 10, 48, 51, tzinfo=UTC),
power=111,
energy_usage=1111.111,
energy_return=111.111,
energy_usage_high_tariff=111.111,
energy_usage_low_tariff=111.111,
)
def _water_meter() -> WaterMeter:
"""Return a mocked water meter reading."""
return WaterMeter(
outdated=False,
timestamp=datetime(2024, 11, 26, 10, 48, 51, tzinfo=UTC),
cold_water=1111.111,
warm_water=0.0,
)
def _heat_meter() -> HeatMeter:
"""Return a mocked heat meter reading."""
return HeatMeter(
outdated=False,
timestamp=datetime(2024, 11, 26, 10, 48, 51, tzinfo=UTC),
total_energy=1111.111,
delta_energy=111,
total_volume=1111.111,
delta_volume=0.111,
)
def _gas_report() -> DeviceReport:
"""Return a mocked gas report."""
return DeviceReport(
gas=GasReport(
total_delta=100,
sum=10.5,
total_delta_currency=5.0,
current_consumption=0.4,
current_consumption_kwh=4.0,
consumption=10.5,
consumption_kwh=10.5,
max_consumption=1.5,
min_consumption=0.2,
avg_consumption=0.6,
max_consumption_kwh=1.7,
min_consumption_kwh=0.1,
avg_consumption_kwh=0.5,
sum_currency=2.5,
max_currency=0.3,
)
)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
@@ -127,25 +61,32 @@ def mock_powerfox_client() -> Generator[AsyncMock]:
type=DeviceType.HEAT_METER,
name="Heatopti",
),
Device(
id="9x9x1f12xx6x",
date_added=datetime(2024, 11, 26, 9, 22, 35, tzinfo=UTC),
main_device=False,
bidirectional=False,
type=DeviceType.GAS_METER,
name="Gasopti",
]
client.device.side_effect = [
PowerMeter(
outdated=False,
timestamp=datetime(2024, 11, 26, 10, 48, 51, tzinfo=UTC),
power=111,
energy_usage=1111.111,
energy_return=111.111,
energy_usage_high_tariff=111.111,
energy_usage_low_tariff=111.111,
),
WaterMeter(
outdated=False,
timestamp=datetime(2024, 11, 26, 10, 48, 51, tzinfo=UTC),
cold_water=1111.111,
warm_water=0.0,
),
HeatMeter(
outdated=False,
timestamp=datetime(2024, 11, 26, 10, 48, 51, tzinfo=UTC),
total_energy=1111.111,
delta_energy=111,
total_volume=1111.111,
delta_volume=0.111,
),
]
device_factories = {
"9x9x1f12xx3x": _power_meter,
"9x9x1f12xx4x": _water_meter,
"9x9x1f12xx5x": _heat_meter,
}
client.device = AsyncMock(
side_effect=lambda *, device_id: device_factories[device_id]() # type: ignore[index]
)
client.report = AsyncMock(return_value=_gas_report())
yield client

View File

@@ -31,16 +31,6 @@
'total_volume': 1111.111,
}),
}),
dict({
'gas_meter': dict({
'consumption': 10.5,
'consumption_kwh': 10.5,
'current_consumption': 0.4,
'current_consumption_kwh': 4.0,
'sum': 10.5,
'sum_currency': 2.5,
}),
}),
]),
})
# ---

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