Compare commits

...

37 Commits

Author SHA1 Message Date
Erik
fdefbf68ad Update comment 2026-01-08 16:34:16 +01:00
Erik
193c02a253 Disable pyright type checking in VS Code 2026-01-08 16:09:41 +01:00
Klaas Schoute
8d376027bf Add support for gas meter in Powerfox integration (#158196)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-08 14:53:00 +01:00
JHSL
47e91bc2ec Add dishwasher program Dishcare.Dishwasher.Program.IntensiveFixedZone (#160463) 2026-01-08 14:45:44 +01:00
Zoltán Farkasdi
33d1cdd0ac Refactor netatmo binary sensors (#160352) 2026-01-08 13:24:05 +01:00
Brett Adams
f46de054ba Add missing data_description translations to Tessie (#160511)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:02:36 +01:00
Brett Adams
741aa714dd Add missing PARALLEL_UPDATES to Tesla Fleet (#160510) 2026-01-08 12:40:38 +01:00
osohotwateriot
5fac7d4ffb Add Nettleie optimization option (#160494) 2026-01-08 12:24:00 +01:00
Glenn de Haan
341c441e61 Add HDFury integration (#159996) 2026-01-08 12:21:04 +01:00
wollew
a1edf0a77c fix rain sensor for some rare velux windows (#160504) 2026-01-08 12:19:40 +01:00
Erik Montnemery
dd84b52c7b Bump python-otbr-api to 2.7.1 (#160496) 2026-01-08 12:10:39 +01:00
Etienne C.
43ced677e5 Get the polling state of a sensor from a template (#159900) 2026-01-08 12:03:45 +01:00
Ville Skyttä
7a696935ed Add icons for Nord Pool highest and lowest price sensors (#159729) 2026-01-08 11:27:17 +01:00
Deyan Petrov
be3be360a7 Make Tuya binary sensor consider only updated properties (#160404)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-01-08 09:47:27 +01:00
Mick Vleeshouwer
092ebaaeb1 Bump pyOverkiz to 1.19.4 (#160457) 2026-01-08 08:41:30 +01:00
Retha Runolfsson
e8025317ed Bump PySwitchbot to 0.76.0 (#160470) 2026-01-08 08:39:23 +01:00
wollew
39b025dfea catch and wrap exceptions when doing pyvlx actions in velux entities (#160430)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-08 00:06:26 +01:00
DeerMaximum
1b436a8808 Use async_configure in NINA to set flow data in tests (#160435)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-07 23:48:42 +01:00
Markus Jacobsen
a7440e3756 Add battery support to Bang & Olufsen (#159994) 2026-01-07 23:40:22 +01:00
wollew
2c7852f94b remove workaround for recognition of closed velux windows (#160433) 2026-01-07 23:39:37 +01:00
Maikel Punie
bd4653f830 Update velbus quality scale rules for docs (#160200) 2026-01-07 23:32:45 +01:00
Tero Paloheimo
c0b2847a87 Update ruuvitag-ble to 0.4.0 (#160441) 2026-01-07 23:32:03 +01:00
J. Diego Rodríguez Royo
8853f6698b Add steam mode and hot air gentle programs to Home Connect (#160445) 2026-01-07 23:10:20 +01:00
Artem Draft
b1a3ad6ac3 Improve Bravia TV logging messages (#160394) 2026-01-07 23:09:46 +01:00
Arie Catsman
dafa2e69e2 Optimize enphase_envoy code for on_phase use (#160448) 2026-01-07 23:09:00 +01:00
Chris
2c6d6f8ab4 Add unique_id to openevse user flow and import flow (#160436)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-07 23:06:25 +01:00
J. Diego Rodríguez Royo
10d32b7f23 Bump aiohomeconnect to version 0.28.0 (#160438) 2026-01-07 20:44:36 +01:00
TheJulianJES
e4dc4e0ced Bump ZHA to 0.0.84 (#160440) 2026-01-07 19:57:09 +01:00
Maikel Punie
6f9794f235 Add icon translations for velbus (#160439) 2026-01-07 19:26:47 +01:00
Paul Bottein
b8cff13737 Fix hvac_mode validation in climate.hvac_mode_changed trigger (#160364) 2026-01-07 17:44:03 +01:00
Bram Kragten
7777714cc0 Update frontend to 20260107.0 (#160434) 2026-01-07 17:34:23 +01:00
Chris
f15d5cdf2a Add zeroconf discovery to openevse (#160318)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-07 16:42:32 +01:00
DeerMaximum
6181f4e7de NINA Use MockConfigEntry to setup integration in test (#160324) 2026-01-07 16:33:06 +01:00
Robert Resch
80df3b5b80 Bump deebot-client to 17.0.1 (#160428) 2026-01-07 16:07:11 +01:00
Simone Chemelli
6e32a2aa18 Bump aiovodafone to 3.1.1 (#160429) 2026-01-07 15:34:46 +01:00
Abílio Costa
3b575fe3e3 Support target triggers in automation relation extraction (#160369) 2026-01-07 15:15:44 +01:00
Joost Lekkerkerker
229400de98 Make Watts depend on the cloud integration (#160424) 2026-01-07 15:07:24 +01:00
107 changed files with 3495 additions and 325 deletions

View File

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

View File

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

6
CODEOWNERS generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ set_program_and_options:
- active_program
- selected_program
program:
example: dishcare_dishwasher_program_auto2
example: dishcare_dishwasher_program_auto_2
selector:
select:
mode: dropdown
@@ -73,6 +73,7 @@ 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
@@ -121,6 +122,7 @@ set_program_and_options:
- cooking_oven_program_heating_mode_pre_heating
- cooking_oven_program_heating_mode_hot_air
- cooking_oven_program_heating_mode_hot_air_eco
- cooking_oven_program_heating_mode_hot_air_gentle
- cooking_oven_program_heating_mode_hot_air_grilling
- cooking_oven_program_heating_mode_top_bottom_heating
- cooking_oven_program_heating_mode_top_bottom_heating_eco
@@ -147,6 +149,7 @@ set_program_and_options:
- cooking_oven_program_microwave_900_watt
- cooking_oven_program_microwave_1000_watt
- cooking_oven_program_microwave_max
- cooking_oven_program_steam_modes_steam
- cooking_oven_program_heating_mode_warming_drawer
- laundry_care_washer_program_auto_30
- laundry_care_washer_program_auto_40
@@ -174,7 +177,7 @@ set_program_and_options:
- laundry_care_washer_program_rinse_rinse_spin_drain
- laundry_care_washer_program_sensitive
- laundry_care_washer_program_shirts_blouses
- laundry_care_washer_program_spin_drain
- laundry_care_washer_program_spin_spin_drain
- laundry_care_washer_program_sport_fitness
- laundry_care_washer_program_super_153045_super_15
- laundry_care_washer_program_super_153045_super_1530

View File

@@ -240,6 +240,7 @@
"cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]",
"cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]",
"cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]",
"cooking_oven_program_heating_mode_hot_air_gentle": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_gentle%]",
"cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]",
"cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]",
"cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]",
@@ -271,6 +272,7 @@
"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%]",
@@ -350,7 +352,7 @@
"laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]",
"laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]",
"laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]",
"laundry_care_washer_program_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_drain%]",
"laundry_care_washer_program_spin_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_spin_drain%]",
"laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]",
"laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]",
"laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]",
@@ -592,6 +594,7 @@
"cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]",
"cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]",
"cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]",
"cooking_oven_program_heating_mode_hot_air_gentle": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_gentle%]",
"cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]",
"cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]",
"cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]",
@@ -612,6 +615,7 @@
"cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]",
"cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]",
"cooking_oven_program_microwave_max": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_max%]",
"cooking_oven_program_steam_modes_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_steam_modes_steam%]",
"dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_1%]",
"dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_2%]",
"dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_3%]",
@@ -623,6 +627,7 @@
"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%]",
@@ -702,7 +707,7 @@
"laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]",
"laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]",
"laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]",
"laundry_care_washer_program_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_drain%]",
"laundry_care_washer_program_spin_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_spin_drain%]",
"laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]",
"laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]",
"laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]",
@@ -1583,6 +1588,7 @@
"cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH",
"cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH",
"cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco",
"cooking_oven_program_heating_mode_hot_air_gentle": "Hot air gentle",
"cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling",
"cooking_oven_program_heating_mode_intensive_heat": "Intensive heat",
"cooking_oven_program_heating_mode_keep_warm": "Keep warm",
@@ -1603,6 +1609,7 @@
"cooking_oven_program_microwave_900_watt": "900 Watt",
"cooking_oven_program_microwave_90_watt": "90 Watt",
"cooking_oven_program_microwave_max": "Max",
"cooking_oven_program_steam_modes_steam": "Steam mode",
"dishcare_dishwasher_program_auto_1": "Auto 1",
"dishcare_dishwasher_program_auto_2": "Auto 2",
"dishcare_dishwasher_program_auto_3": "Auto 3",
@@ -1614,6 +1621,7 @@
"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",
@@ -1693,7 +1701,7 @@
"laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain",
"laundry_care_washer_program_sensitive": "Sensitive",
"laundry_care_washer_program_shirts_blouses": "Shirts/blouses",
"laundry_care_washer_program_spin_drain": "Spin/drain",
"laundry_care_washer_program_spin_spin_drain": "Spin/drain",
"laundry_care_washer_program_sport_fitness": "Sport/fitness",
"laundry_care_washer_program_super_153045_super_15": "Super 15 min",
"laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min",

View File

@@ -1,5 +1,9 @@
"""Support for Netatmo binary sensors."""
from dataclasses import dataclass
import logging
from typing import Final, cast
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
@@ -9,17 +13,33 @@ 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_SENSOR
from .const import NETATMO_CREATE_WEATHER_BINARY_SENSOR
from .data_handler import NetatmoDevice
from .entity import NetatmoWeatherModuleEntity
BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
BinarySensorEntityDescription(
_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(
key="reachable",
name="Connectivity",
netatmo_name="reachable",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
),
)
]
async def async_setup_entry(
@@ -27,36 +47,75 @@ async def async_setup_entry(
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Netatmo binary sensors based on a config entry."""
"""Set up Netatmo weather binary sensors based on a config entry."""
@callback
def _create_weather_binary_sensor_entity(netatmo_device: NetatmoDevice) -> None:
async_add_entities(
NetatmoWeatherBinarySensor(netatmo_device, description)
for description in BINARY_SENSOR_TYPES
if description.key in netatmo_device.device.features
)
"""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)
entry.async_on_unload(
async_dispatcher_connect(
hass, NETATMO_CREATE_WEATHER_SENSOR, _create_weather_binary_sensor_entity
hass,
NETATMO_CREATE_WEATHER_BINARY_SENSOR,
_create_weather_binary_sensor_entity,
)
)
class NetatmoWeatherBinarySensor(NetatmoWeatherModuleEntity, BinarySensorEntity):
"""Implementation of a Netatmo binary sensor."""
"""Implementation of a Netatmo weather binary sensor."""
entity_description: NetatmoBinarySensorEntityDescription
def __init__(
self, device: NetatmoDevice, description: BinarySensorEntityDescription
self,
netatmo_device: NetatmoDevice,
description: NetatmoBinarySensorEntityDescription,
) -> None:
"""Initialize a Netatmo binary sensor."""
super().__init__(device)
"""Initialize a Netatmo weather binary sensor."""
super().__init__(netatmo_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."""
self._attr_is_on = self.device.reachable
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.async_write_ha_state()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator
from .coordinator import (
PowerfoxConfigEntry,
PowerfoxDataUpdateCoordinator,
PowerfoxReportDataUpdateCoordinator,
)
PLATFORMS: list[Platform] = [Platform.SENSOR]
@@ -30,12 +34,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) ->
await client.close()
raise ConfigEntryNotReady from err
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
]
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))
await asyncio.gather(
*[

View File

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

View File

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

View File

@@ -2,23 +2,27 @@
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 PowerfoxDataUpdateCoordinator
from .coordinator import PowerfoxBaseCoordinator
class PowerfoxEntity(CoordinatorEntity[PowerfoxDataUpdateCoordinator]):
class PowerfoxEntity[CoordinatorT: PowerfoxBaseCoordinator[Any]](
CoordinatorEntity[CoordinatorT]
):
"""Base entity for Powerfox."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: PowerfoxDataUpdateCoordinator,
coordinator: CoordinatorT,
device: Device,
) -> None:
"""Initialize Powerfox entity."""

View File

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

View File

@@ -4,8 +4,9 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from powerfox import Device, HeatMeter, PowerMeter, WaterMeter
from powerfox import Device, GasReport, HeatMeter, PowerMeter, WaterMeter
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -13,11 +14,16 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfVolume
from homeassistant.const import CURRENCY_EURO, UnitOfEnergy, UnitOfPower, UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator
from .coordinator import (
PowerfoxBaseCoordinator,
PowerfoxConfigEntry,
PowerfoxDataUpdateCoordinator,
PowerfoxReportDataUpdateCoordinator,
)
from .entity import PowerfoxEntity
@@ -30,6 +36,13 @@ 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",
@@ -126,6 +139,104 @@ 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,
@@ -135,6 +246,20 @@ 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(
@@ -166,23 +291,49 @@ async def async_setup_entry(
async_add_entities(entities)
class PowerfoxSensorEntity(PowerfoxEntity, SensorEntity):
"""Defines a powerfox power meter sensor."""
class BasePowerfoxSensorEntity[CoordinatorT: PowerfoxBaseCoordinator[Any]](
PowerfoxEntity[CoordinatorT], SensorEntity
):
"""Common base for Powerfox sensor entities."""
entity_description: PowerfoxSensorEntityDescription
entity_description: SensorEntityDescription
def __init__(
self,
coordinator: PowerfoxDataUpdateCoordinator,
coordinator: CoordinatorT,
device: Device,
description: PowerfoxSensorEntityDescription,
description: SensorEntityDescription,
) -> None:
"""Initialize Powerfox power meter sensor."""
"""Initialize the shared Powerfox sensor."""
super().__init__(coordinator, device)
self.entity_description = description
self._attr_unique_id = f"{device.id}_{description.key}"
class PowerfoxSensorEntity(BasePowerfoxSensorEntity[PowerfoxDataUpdateCoordinator]):
"""Defines a powerfox poweropti sensor."""
coordinator: PowerfoxDataUpdateCoordinator
entity_description: PowerfoxSensorEntityDescription
@property
def native_value(self) -> float | int | None:
"""Return the state of the entity."""
return self.entity_description.value_fn(self.coordinator.data)
class PowerfoxGasSensorEntity(
BasePowerfoxSensorEntity[PowerfoxReportDataUpdateCoordinator]
):
"""Defines a powerfox gas meter sensor."""
coordinator: PowerfoxReportDataUpdateCoordinator
entity_description: PowerfoxReportSensorEntityDescription
@property
def native_value(self) -> float | int | None:
"""Return the state of the entity."""
gas_report = self.coordinator.data.gas
if TYPE_CHECKING:
assert gas_report is not None
return self.entity_description.value_fn(gas_report)

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,9 @@
"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%]"
},
@@ -21,6 +24,9 @@
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
},
"data_description": {
"access_token": "Visit developer settings and select Generate Access Token."
},
"description": "Enter your access token from {url}."
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -74,6 +74,8 @@ class VeluxRainSensor(VeluxEntity, BinarySensorEntity):
self._attr_available = True
# 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}
# 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,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==20251229.1
home-assistant-frontend==20260107.0
home-assistant-intents==2026.1.6
httpx==0.28.1
ifaddr==0.2.0

21
requirements_all.txt generated
View File

@@ -83,7 +83,7 @@ PyRMVtransport==0.3.3
PySrDaliGateway==0.18.0
# homeassistant.components.switchbot
PySwitchbot==0.75.0
PySwitchbot==0.76.0
# homeassistant.components.switchmate
PySwitchmate==0.5.1
@@ -277,7 +277,7 @@ aioharmony==0.5.3
aiohasupervisor==0.3.3
# homeassistant.components.home_connect
aiohomeconnect==0.26.0
aiohomeconnect==0.28.0
# homeassistant.components.homekit_controller
aiohomekit==3.2.20
@@ -432,7 +432,7 @@ aiousbwatcher==1.1.1
aiovlc==0.5.1
# homeassistant.components.vodafone_station
aiovodafone==3.0.0
aiovodafone==3.1.1
# homeassistant.components.waqi
aiowaqi==3.1.0
@@ -782,7 +782,7 @@ debugpy==1.8.17
decora-wifi==1.4
# homeassistant.components.ecovacs
deebot-client==17.0.0
deebot-client==17.0.1
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -1184,6 +1184,9 @@ 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
@@ -1213,7 +1216,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20251229.1
home-assistant-frontend==20260107.0
# homeassistant.components.conversation
home-assistant-intents==2026.1.6
@@ -2288,7 +2291,7 @@ pyotgw==2.2.2
pyotp==2.9.0
# homeassistant.components.overkiz
pyoverkiz==1.19.3
pyoverkiz==1.19.4
# homeassistant.components.palazzetti
pypalazzetti==0.1.20
@@ -2563,7 +2566,7 @@ python-opensky==1.0.1
# homeassistant.components.otbr
# homeassistant.components.thread
python-otbr-api==2.7.0
python-otbr-api==2.7.1
# homeassistant.components.overseerr
python-overseerr==0.8.0
@@ -2783,7 +2786,7 @@ rpi-bad-power==0.1.0
russound==0.2.0
# homeassistant.components.ruuvitag_ble
ruuvitag-ble==0.3.0
ruuvitag-ble==0.4.0
# homeassistant.components.yamaha
rxv==0.7.0
@@ -3277,7 +3280,7 @@ zeroconf==0.148.0
zeversolar==0.3.2
# homeassistant.components.zha
zha==0.0.83
zha==0.0.84
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13

View File

@@ -83,7 +83,7 @@ PyRMVtransport==0.3.3
PySrDaliGateway==0.18.0
# homeassistant.components.switchbot
PySwitchbot==0.75.0
PySwitchbot==0.76.0
# homeassistant.components.syncthru
PySyncThru==0.8.0
@@ -265,7 +265,7 @@ aioharmony==0.5.3
aiohasupervisor==0.3.3
# homeassistant.components.home_connect
aiohomeconnect==0.26.0
aiohomeconnect==0.28.0
# homeassistant.components.homekit_controller
aiohomekit==3.2.20
@@ -417,7 +417,7 @@ aiousbwatcher==1.1.1
aiovlc==0.5.1
# homeassistant.components.vodafone_station
aiovodafone==3.0.0
aiovodafone==3.1.1
# homeassistant.components.waqi
aiowaqi==3.1.0
@@ -691,7 +691,7 @@ dbus-fast==3.1.2
debugpy==1.8.17
# homeassistant.components.ecovacs
deebot-client==17.0.0
deebot-client==17.0.1
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -1051,6 +1051,9 @@ 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
@@ -1071,7 +1074,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20251229.1
home-assistant-frontend==20260107.0
# homeassistant.components.conversation
home-assistant-intents==2026.1.6
@@ -1935,7 +1938,7 @@ pyotgw==2.2.2
pyotp==2.9.0
# homeassistant.components.overkiz
pyoverkiz==1.19.3
pyoverkiz==1.19.4
# homeassistant.components.palazzetti
pypalazzetti==0.1.20
@@ -2153,7 +2156,7 @@ python-opensky==1.0.1
# homeassistant.components.otbr
# homeassistant.components.thread
python-otbr-api==2.7.0
python-otbr-api==2.7.1
# homeassistant.components.overseerr
python-overseerr==0.8.0
@@ -2331,7 +2334,7 @@ rova==0.4.1
rpi-bad-power==0.1.0
# homeassistant.components.ruuvitag_ble
ruuvitag-ble==0.3.0
ruuvitag-ble==0.4.0
# homeassistant.components.yamaha
rxv==0.7.0
@@ -2741,7 +2744,7 @@ zeroconf==0.148.0
zeversolar==0.3.2
# homeassistant.components.zha
zha==0.0.83
zha==0.0.84
# homeassistant.components.zwave_js
zwave-js-server-python==0.67.1

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,15 @@ from collections.abc import Generator
from datetime import UTC, datetime
from unittest.mock import AsyncMock, patch
from powerfox import Device, DeviceType, HeatMeter, PowerMeter, WaterMeter
from powerfox import (
Device,
DeviceReport,
DeviceType,
GasReport,
HeatMeter,
PowerMeter,
WaterMeter,
)
import pytest
from homeassistant.components.powerfox.const import DOMAIN
@@ -13,6 +21,64 @@ 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."""
@@ -61,32 +127,25 @@ def mock_powerfox_client() -> Generator[AsyncMock]:
type=DeviceType.HEAT_METER,
name="Heatopti",
),
]
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(
id="9x9x1f12xx6x",
date_added=datetime(2024, 11, 26, 9, 22, 35, tzinfo=UTC),
main_device=False,
bidirectional=False,
type=DeviceType.GAS_METER,
name="Gasopti",
),
]
device_factories = {
"9x9x1f12xx3x": _power_meter,
"9x9x1f12xx4x": _water_meter,
"9x9x1f12xx5x": _heat_meter,
}
client.device = AsyncMock(
side_effect=lambda *, device_id: device_factories[device_id]() # type: ignore[index]
)
client.report = AsyncMock(return_value=_gas_report())
yield client

View File

@@ -31,6 +31,16 @@
'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,
}),
}),
]),
})
# ---

View File

@@ -1,4 +1,649 @@
# serializer version: 1
# name: test_all_sensors[sensor.gasopti_avg_gas_hourly_consumption_today-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gasopti_avg_gas_hourly_consumption_today',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.GAS: 'gas'>,
'original_icon': None,
'original_name': 'Avg gas hourly consumption - today',
'platform': 'powerfox',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'gas_avg_consumption_today',
'unique_id': '9x9x1f12xx6x_gas_avg_consumption_today',
'unit_of_measurement': <UnitOfVolume.CUBIC_METERS: 'm³'>,
})
# ---
# name: test_all_sensors[sensor.gasopti_avg_gas_hourly_consumption_today-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'gas',
'friendly_name': 'Gasopti Avg gas hourly consumption - today',
'unit_of_measurement': <UnitOfVolume.CUBIC_METERS: 'm³'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gasopti_avg_gas_hourly_consumption_today',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.6',
})
# ---
# name: test_all_sensors[sensor.gasopti_avg_gas_hourly_energy_today-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gasopti_avg_gas_hourly_energy_today',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Avg gas hourly energy - today',
'platform': 'powerfox',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'gas_avg_consumption_energy_today',
'unique_id': '9x9x1f12xx6x_gas_avg_consumption_energy_today',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_all_sensors[sensor.gasopti_avg_gas_hourly_energy_today-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Gasopti Avg gas hourly energy - today',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gasopti_avg_gas_hourly_energy_today',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.5',
})
# ---
# name: test_all_sensors[sensor.gasopti_gas_consumption_energy_this_hour-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gasopti_gas_consumption_energy_this_hour',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Gas consumption energy - this hour',
'platform': 'powerfox',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'gas_current_consumption_energy',
'unique_id': '9x9x1f12xx6x_gas_current_consumption_energy',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_all_sensors[sensor.gasopti_gas_consumption_energy_this_hour-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Gasopti Gas consumption energy - this hour',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gasopti_gas_consumption_energy_this_hour',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '4.0',
})
# ---
# name: test_all_sensors[sensor.gasopti_gas_consumption_energy_today-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gasopti_gas_consumption_energy_today',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Gas consumption energy - today',
'platform': 'powerfox',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'gas_consumption_energy_today',
'unique_id': '9x9x1f12xx6x_gas_consumption_energy_today',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_all_sensors[sensor.gasopti_gas_consumption_energy_today-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Gasopti Gas consumption energy - today',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gasopti_gas_consumption_energy_today',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '10.5',
})
# ---
# name: test_all_sensors[sensor.gasopti_gas_consumption_this_hour-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gasopti_gas_consumption_this_hour',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.GAS: 'gas'>,
'original_icon': None,
'original_name': 'Gas consumption - this hour',
'platform': 'powerfox',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'gas_current_consumption',
'unique_id': '9x9x1f12xx6x_gas_current_consumption',
'unit_of_measurement': <UnitOfVolume.CUBIC_METERS: 'm³'>,
})
# ---
# name: test_all_sensors[sensor.gasopti_gas_consumption_this_hour-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'gas',
'friendly_name': 'Gasopti Gas consumption - this hour',
'unit_of_measurement': <UnitOfVolume.CUBIC_METERS: 'm³'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gasopti_gas_consumption_this_hour',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.4',
})
# ---
# name: test_all_sensors[sensor.gasopti_gas_consumption_today-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gasopti_gas_consumption_today',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.GAS: 'gas'>,
'original_icon': None,
'original_name': 'Gas consumption - today',
'platform': 'powerfox',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'gas_consumption_today',
'unique_id': '9x9x1f12xx6x_gas_consumption_today',
'unit_of_measurement': <UnitOfVolume.CUBIC_METERS: 'm³'>,
})
# ---
# name: test_all_sensors[sensor.gasopti_gas_consumption_today-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'gas',
'friendly_name': 'Gasopti Gas consumption - today',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfVolume.CUBIC_METERS: 'm³'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gasopti_gas_consumption_today',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '10.5',
})
# ---
# name: test_all_sensors[sensor.gasopti_gas_cost_today-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gasopti_gas_cost_today',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.MONETARY: 'monetary'>,
'original_icon': None,
'original_name': 'Gas cost - today',
'platform': 'powerfox',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'gas_cost_today',
'unique_id': '9x9x1f12xx6x_gas_cost_today',
'unit_of_measurement': '€',
})
# ---
# name: test_all_sensors[sensor.gasopti_gas_cost_today-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'monetary',
'friendly_name': 'Gasopti Gas cost - today',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': '€',
}),
'context': <ANY>,
'entity_id': 'sensor.gasopti_gas_cost_today',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2.5',
})
# ---
# name: test_all_sensors[sensor.gasopti_max_gas_hourly_consumption_today-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gasopti_max_gas_hourly_consumption_today',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.GAS: 'gas'>,
'original_icon': None,
'original_name': 'Max gas hourly consumption - today',
'platform': 'powerfox',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'gas_max_consumption_today',
'unique_id': '9x9x1f12xx6x_gas_max_consumption_today',
'unit_of_measurement': <UnitOfVolume.CUBIC_METERS: 'm³'>,
})
# ---
# name: test_all_sensors[sensor.gasopti_max_gas_hourly_consumption_today-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'gas',
'friendly_name': 'Gasopti Max gas hourly consumption - today',
'unit_of_measurement': <UnitOfVolume.CUBIC_METERS: 'm³'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gasopti_max_gas_hourly_consumption_today',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.5',
})
# ---
# name: test_all_sensors[sensor.gasopti_max_gas_hourly_cost_today-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gasopti_max_gas_hourly_cost_today',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.MONETARY: 'monetary'>,
'original_icon': None,
'original_name': 'Max gas hourly cost - today',
'platform': 'powerfox',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'gas_max_cost_today',
'unique_id': '9x9x1f12xx6x_gas_max_cost_today',
'unit_of_measurement': '€',
})
# ---
# name: test_all_sensors[sensor.gasopti_max_gas_hourly_cost_today-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'monetary',
'friendly_name': 'Gasopti Max gas hourly cost - today',
'unit_of_measurement': '€',
}),
'context': <ANY>,
'entity_id': 'sensor.gasopti_max_gas_hourly_cost_today',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.3',
})
# ---
# name: test_all_sensors[sensor.gasopti_max_gas_hourly_energy_today-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gasopti_max_gas_hourly_energy_today',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Max gas hourly energy - today',
'platform': 'powerfox',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'gas_max_consumption_energy_today',
'unique_id': '9x9x1f12xx6x_gas_max_consumption_energy_today',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_all_sensors[sensor.gasopti_max_gas_hourly_energy_today-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Gasopti Max gas hourly energy - today',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gasopti_max_gas_hourly_energy_today',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.7',
})
# ---
# name: test_all_sensors[sensor.gasopti_min_gas_hourly_consumption_today-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gasopti_min_gas_hourly_consumption_today',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.GAS: 'gas'>,
'original_icon': None,
'original_name': 'Min gas hourly consumption - today',
'platform': 'powerfox',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'gas_min_consumption_today',
'unique_id': '9x9x1f12xx6x_gas_min_consumption_today',
'unit_of_measurement': <UnitOfVolume.CUBIC_METERS: 'm³'>,
})
# ---
# name: test_all_sensors[sensor.gasopti_min_gas_hourly_consumption_today-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'gas',
'friendly_name': 'Gasopti Min gas hourly consumption - today',
'unit_of_measurement': <UnitOfVolume.CUBIC_METERS: 'm³'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gasopti_min_gas_hourly_consumption_today',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.2',
})
# ---
# name: test_all_sensors[sensor.gasopti_min_gas_hourly_energy_today-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gasopti_min_gas_hourly_energy_today',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Min gas hourly energy - today',
'platform': 'powerfox',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'gas_min_consumption_energy_today',
'unique_id': '9x9x1f12xx6x_gas_min_consumption_energy_today',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_all_sensors[sensor.gasopti_min_gas_hourly_energy_today-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Gasopti Min gas hourly energy - today',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gasopti_min_gas_hourly_energy_today',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.1',
})
# ---
# name: test_all_sensors[sensor.heatopti_delta_energy-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -6,7 +6,8 @@ from datetime import timedelta
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from powerfox import PowerfoxConnectionError
from powerfox import DeviceReport, PowerfoxConnectionError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.config_entries import ConfigEntryState
@@ -19,6 +20,7 @@ from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_sensors(
hass: HomeAssistant,
mock_powerfox_client: AsyncMock,
@@ -51,3 +53,17 @@ async def test_update_failed(
await hass.async_block_till_done()
assert hass.states.get("sensor.poweropti_energy_usage").state == STATE_UNAVAILABLE
async def test_skips_gas_sensors_when_report_missing(
hass: HomeAssistant,
mock_powerfox_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test gas sensors are not created when report lacks gas data."""
mock_powerfox_client.report.return_value = DeviceReport(gas=None)
with patch("homeassistant.components.powerfox.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
assert hass.states.get("sensor.gasopti_gas_consumption_today") is None

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