mirror of
https://github.com/home-assistant/core.git
synced 2026-01-10 17:47:16 +01:00
Compare commits
2 Commits
disable_py
...
condition_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45f1fc32e2 | ||
|
|
1e95598c96 |
@@ -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,
|
||||
|
||||
4
.vscode/settings.default.jsonc
vendored
4
.vscode/settings.default.jsonc
vendored
@@ -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
6
CODEOWNERS
generated
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -23,5 +23,5 @@
|
||||
"winter_mode": {}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260107.0"]
|
||||
"requirements": ["home-assistant-frontend==20251229.1"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Constants for HDFury Integration."""
|
||||
|
||||
DOMAIN = "hdfury"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"])
|
||||
},
|
||||
)
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"select": {
|
||||
"opmode": {
|
||||
"default": "mdi:cogs"
|
||||
},
|
||||
"portseltx0": {
|
||||
"default": "mdi:hdmi-port"
|
||||
},
|
||||
"portseltx1": {
|
||||
"default": "mdi:hdmi-port"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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]},
|
||||
)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"""Constants for the OpenEVSE integration."""
|
||||
|
||||
CONF_ID = "id"
|
||||
CONF_SERIAL = "serial"
|
||||
DOMAIN = "openevse"
|
||||
INTEGRATION_TITLE = "OpenEVSE"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
"state": {
|
||||
"advanced": "Advanced",
|
||||
"gridcompany": "Grid company",
|
||||
"nettleie": "Nettleie",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"oso": "OSO",
|
||||
"smartcompany": "Smart company"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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*",
|
||||
|
||||
@@ -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(
|
||||
*[
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["switchbot"],
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["PySwitchbot==0.76.0"]
|
||||
"requirements": ["PySwitchbot==0.75.0"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,7 +31,6 @@ class VelbusSelect(VelbusEntity, SelectEntity):
|
||||
|
||||
_channel: SelectedProgram
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "select_program"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiovodafone"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiovodafone==3.1.1"]
|
||||
"requirements": ["aiovodafone==3.0.0"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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*",
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -278,7 +278,6 @@ FLOWS = {
|
||||
"habitica",
|
||||
"hanna",
|
||||
"harmony",
|
||||
"hdfury",
|
||||
"heos",
|
||||
"here_travel_time",
|
||||
"hikvision",
|
||||
|
||||
@@ -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",
|
||||
|
||||
5
homeassistant/generated/zeroconf.py
generated
5
homeassistant/generated/zeroconf.py
generated
@@ -780,11 +780,6 @@ ZEROCONF = {
|
||||
"domain": "octoprint",
|
||||
},
|
||||
],
|
||||
"_openevse._tcp.local.": [
|
||||
{
|
||||
"domain": "openevse",
|
||||
},
|
||||
],
|
||||
"_owserver._tcp.local.": [
|
||||
{
|
||||
"domain": "onewire",
|
||||
|
||||
@@ -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_)
|
||||
|
||||
@@ -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
21
requirements_all.txt
generated
@@ -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
|
||||
|
||||
21
requirements_test_all.txt
generated
21
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
# ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]),
|
||||
),
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user