Compare commits

..

5 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer
439f48c722 Merge branch 'dev' into tibber_binary_sensor 2026-01-12 20:04:07 +01:00
Daniel Hjelseth Høyer
61180aa64f Apply suggestions from code review
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-12 20:03:21 +01:00
Daniel Hjelseth Høyer
d2a55dec55 Add Tibber binary sensors
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-06 18:29:57 +01:00
Daniel Hjelseth Høyer
cddc4bdf8f Add Tibber binary sensors
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-06 18:16:48 +01:00
Daniel Hjelseth Høyer
adc201bb4e Add Tibber binary sensors
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-06 14:34:46 +01:00
36 changed files with 582 additions and 1040 deletions

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
)
STEP_SMS_CODE_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_SMS_CODE): str,
vol.Required(CONF_SMS_CODE): int,
}
)
@@ -75,7 +75,7 @@ class FressnapfTrackerConfigFlow(ConfigFlow, domain=DOMAIN):
return errors, False
async def _async_verify_sms_code(
self, sms_code: str
self, sms_code: int
) -> tuple[dict[str, str], str | None]:
"""Verify SMS code and return errors and access_token."""
errors: dict[str, str] = {}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["fressnapftracker==0.2.1"]
"requirements": ["fressnapftracker==0.2.0"]
}

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@
"loggers": ["xknx", "xknxproject"],
"quality_scale": "platinum",
"requirements": [
"xknx==3.14.0",
"xknx==3.13.0",
"xknxproject==3.8.2",
"knx-frontend==2025.12.30.151231"
],

View File

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

View File

@@ -42,7 +42,7 @@
},
"conditions": {
"is_off": {
"description": "Tests if one or more lights are off.",
"description": "Test if a light is off.",
"fields": {
"behavior": {
"description": "[%key:component::light::common::condition_behavior_description%]",
@@ -52,7 +52,7 @@
"name": "If a light is off"
},
"is_on": {
"description": "Tests if one or more lights are on.",
"description": "Test if a light is on.",
"fields": {
"behavior": {
"description": "[%key:component::light::common::condition_behavior_description%]",

View File

@@ -7,7 +7,6 @@ from mill_local import OperationMode
import voluptuous as vol
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
@@ -112,16 +111,13 @@ class MillHeater(MillBaseEntity, ClimateEntity):
super().__init__(coordinator, device)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature and optionally HVAC mode."""
"""Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
await self.coordinator.mill_data_connection.set_heater_temp(
self._id, float(temperature)
)
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
await self.async_handle_set_hvac_mode_service(hvac_mode)
else:
await self.coordinator.async_request_refresh()
await self.coordinator.async_request_refresh()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
@@ -129,11 +125,12 @@ class MillHeater(MillBaseEntity, ClimateEntity):
await self.coordinator.mill_data_connection.heater_control(
self._id, power_status=True
)
await self.coordinator.async_request_refresh()
elif hvac_mode == HVACMode.OFF:
await self.coordinator.mill_data_connection.heater_control(
self._id, power_status=False
)
await self.coordinator.async_request_refresh()
await self.coordinator.async_request_refresh()
@callback
def _update_attr(self, device: mill.Heater) -> None:
@@ -192,26 +189,25 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit
self._update_attr()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature and optionally HVAC mode."""
"""Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
await self.coordinator.mill_data_connection.set_target_temperature(
float(temperature)
)
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
await self.async_handle_set_hvac_mode_service(hvac_mode)
else:
await self.coordinator.async_request_refresh()
await self.coordinator.async_request_refresh()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
if hvac_mode == HVACMode.HEAT:
await self.coordinator.mill_data_connection.set_operation_mode_control_individually()
await self.coordinator.async_request_refresh()
elif hvac_mode == HVACMode.OFF:
await self.coordinator.mill_data_connection.set_operation_mode_off()
await self.coordinator.async_request_refresh()
elif hvac_mode == HVACMode.AUTO:
await self.coordinator.mill_data_connection.set_operation_mode_weekly_program()
await self.coordinator.async_request_refresh()
await self.coordinator.async_request_refresh()
@callback
def _handle_coordinator_update(self) -> None:

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "bronze",
"requirements": ["opower==0.16.2"]
"requirements": ["opower==0.16.1"]
}

View File

@@ -12,5 +12,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "gold",
"requirements": ["python-pooldose==0.8.2"]
"requirements": ["python-pooldose==0.8.1"]
}

View File

@@ -33,7 +33,7 @@ from .const import (
from .coordinator import TibberDataAPICoordinator
from .services import async_setup_services
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
PLATFORMS = [Platform.BINARY_SENSOR, Platform.NOTIFY, Platform.SENSOR]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)

View File

@@ -0,0 +1,143 @@
"""Support for Tibber binary sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from tibber.data_api import TibberDevice
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, TibberConfigEntry
from .coordinator import TibberDataAPICoordinator
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class TibberBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes Tibber binary sensor entity."""
is_on_fn: Callable[[str | None], bool | None]
def _connector_status_is_on(value: str | None) -> bool | None:
"""Map connector status value to binary sensor state."""
if value == "connected":
return True
if value == "disconnected":
return False
return None
def _charging_status_is_on(value: str | None) -> bool | None:
"""Map charging status value to binary sensor state."""
if value == "charging":
return True
if value == "idle":
return False
return None
def _device_status_is_on(value: str | None) -> bool | None:
"""Map device status value to binary sensor state."""
if value == "on":
return True
if value == "off":
return False
return None
DATA_API_BINARY_SENSORS: tuple[TibberBinarySensorEntityDescription, ...] = (
TibberBinarySensorEntityDescription(
key="connector.status",
device_class=BinarySensorDeviceClass.PLUG,
is_on_fn={"connected": True, "disconnected": False}.get,
),
TibberBinarySensorEntityDescription(
key="charging.status",
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
is_on_fn={"charging": True, "idle": False}.get,
),
TibberBinarySensorEntityDescription(
key="onOff",
device_class=BinarySensorDeviceClass.POWER,
is_on_fn={"on": True, "off": False}.get,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tibber binary sensors."""
coordinator = entry.runtime_data.data_api_coordinator
assert coordinator is not None
entities: list[TibberDataAPIBinarySensor] = []
api_binary_sensors = {sensor.key: sensor for sensor in DATA_API_BINARY_SENSORS}
for device in coordinator.data.values():
for sensor in device.sensors:
description: TibberBinarySensorEntityDescription | None = (
api_binary_sensors.get(sensor.id)
)
if description is None:
continue
entities.append(TibberDataAPIBinarySensor(coordinator, device, description))
async_add_entities(entities)
class TibberDataAPIBinarySensor(
CoordinatorEntity[TibberDataAPICoordinator], BinarySensorEntity
):
"""Representation of a Tibber Data API binary sensor."""
_attr_has_entity_name = True
entity_description: TibberBinarySensorEntityDescription
def __init__(
self,
coordinator: TibberDataAPICoordinator,
device: TibberDevice,
entity_description: TibberBinarySensorEntityDescription,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
self._device_id: str = device.id
self.entity_description = entity_description
self._attr_unique_id = f"{device.external_id}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.external_id)},
name=device.name,
manufacturer=device.brand,
model=device.model,
)
@property
def available(self) -> bool:
return super().available and self._device_id in self.coordinator.sensors_by_device
@property
def device(self) -> dict[str, tibber.data_api.Sensor]:
return self.coordinator.sensors_by_device[self._device_id]
@property
def is_on(self) -> bool | None:
"""Return the state of the binary sensor."""
return self.entity_description.is_on_fn(self.device[self.entity_description.key])

View File

@@ -430,9 +430,6 @@ def _setup_data_api_sensors(
for sensor in device.sensors:
description: SensorEntityDescription | None = api_sensors.get(sensor.id)
if description is None:
_LOGGER.debug(
"Sensor %s not found in DATA_API_SENSORS, skipping", sensor
)
continue
entities.append(TibberDataAPISensor(coordinator, device, description))
async_add_entities(entities)

View File

@@ -41,7 +41,7 @@
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
"quality_scale": "platinum",
"requirements": ["uiprotect==8.1.1", "unifi-discovery==1.2.0"],
"requirements": ["uiprotect==8.0.0", "unifi-discovery==1.2.0"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",

10
requirements_all.txt generated
View File

@@ -1011,7 +1011,7 @@ freebox-api==1.2.2
freesms==0.2.0
# homeassistant.components.fressnapf_tracker
fressnapftracker==0.2.1
fressnapftracker==0.2.0
# homeassistant.components.fritz
# homeassistant.components.fritzbox_callmonitor
@@ -1684,7 +1684,7 @@ openwrt-luci-rpc==1.1.17
openwrt-ubus-rpc==0.0.2
# homeassistant.components.opower
opower==0.16.2
opower==0.16.1
# homeassistant.components.oralb
oralb-ble==1.0.2
@@ -2575,7 +2575,7 @@ python-overseerr==0.8.0
python-picnic-api2==1.3.1
# homeassistant.components.pooldose
python-pooldose==0.8.2
python-pooldose==0.8.1
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@@ -3081,7 +3081,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
uiprotect==8.1.1
uiprotect==8.0.0
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -3219,7 +3219,7 @@ wyoming==1.7.2
xiaomi-ble==1.4.1
# homeassistant.components.knx
xknx==3.14.0
xknx==3.13.0
# homeassistant.components.knx
xknxproject==3.8.2

View File

@@ -890,7 +890,7 @@ forecast-solar==4.2.0
freebox-api==1.2.2
# homeassistant.components.fressnapf_tracker
fressnapftracker==0.2.1
fressnapftracker==0.2.0
# homeassistant.components.fritz
# homeassistant.components.fritzbox_callmonitor
@@ -1458,7 +1458,7 @@ openrgb-python==0.3.6
openwebifpy==4.3.1
# homeassistant.components.opower
opower==0.16.2
opower==0.16.1
# homeassistant.components.oralb
oralb-ble==1.0.2
@@ -2165,7 +2165,7 @@ python-overseerr==0.8.0
python-picnic-api2==1.3.1
# homeassistant.components.pooldose
python-pooldose==0.8.2
python-pooldose==0.8.1
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@@ -2575,7 +2575,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
uiprotect==8.1.1
uiprotect==8.0.0
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -2692,7 +2692,7 @@ wyoming==1.7.2
xiaomi-ble==1.4.1
# homeassistant.components.knx
xknx==3.14.0
xknx==3.13.0
# homeassistant.components.knx
xknxproject==3.8.2

View File

@@ -24,12 +24,7 @@ def gather_info(arguments) -> Info:
info = _gather_info(
{
"domain": {
"prompt": (
"""What is the domain?
Hint: The domain is a short name consisting of characters and underscores.
This domain has to be unique, cannot be changed, and has to match the directory name of the integration."""
),
"prompt": "What is the domain?",
"validators": [
CHECK_EMPTY,
[
@@ -77,8 +72,13 @@ def gather_new_integration(determine_auth: bool) -> Info:
},
"codeowner": {
"prompt": "What is your GitHub handle?",
"validators": [CHECK_EMPTY],
"converter": lambda value: value if value.startswith("@") else f"@{value}",
"validators": [
CHECK_EMPTY,
[
'GitHub handles need to start with an "@"',
lambda value: value.startswith("@"),
],
],
},
"requirement": {
"prompt": "What PyPI package and version do you depend on? Leave blank for none.",

View File

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

View File

@@ -50,7 +50,7 @@ async def test_user_flow_success(
# Submit SMS code
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: "0123456"},
{CONF_SMS_CODE: 123456},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@@ -107,7 +107,7 @@ async def test_user_flow_request_sms_code_errors(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: "0123456"},
{CONF_SMS_CODE: 123456},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@@ -142,7 +142,7 @@ async def test_user_flow_verify_phone_number_errors(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: "999999"},
{CONF_SMS_CODE: 999999},
)
assert result["type"] is FlowResultType.FORM
@@ -153,7 +153,7 @@ async def test_user_flow_verify_phone_number_errors(
mock_auth_client.verify_phone_number.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: "0123456"},
{CONF_SMS_CODE: 123456},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@@ -246,7 +246,7 @@ async def test_reauth_reconfigure_flow(
# Submit SMS code
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: "0123456"},
{CONF_SMS_CODE: 123456},
)
assert result["type"] is FlowResultType.ABORT
@@ -311,7 +311,7 @@ async def test_reauth_reconfigure_flow_invalid_phone_number(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: "0123456"},
{CONF_SMS_CODE: 123456},
)
assert result["type"] is FlowResultType.ABORT
@@ -358,7 +358,7 @@ async def test_reauth_reconfigure_flow_invalid_sms_code(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: "999999"},
{CONF_SMS_CODE: 999999},
)
assert result["type"] is FlowResultType.FORM
@@ -369,7 +369,7 @@ async def test_reauth_reconfigure_flow_invalid_sms_code(
mock_auth_client.verify_phone_number.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: "0123456"},
{CONF_SMS_CODE: 123456},
)
assert result["type"] is FlowResultType.ABORT
@@ -436,7 +436,7 @@ async def test_reauth_reconfigure_flow_invalid_user_id(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_SMS_CODE: "0123456"},
{CONF_SMS_CODE: 123456},
)
assert result["type"] is FlowResultType.ABORT

View File

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

View File

@@ -96,7 +96,7 @@ async def integration_fixture(
"eve_energy_20ecn4101",
"eve_energy_plug",
"eve_energy_plug_patched",
"eve_thermo_v4",
"eve_thermo",
"eve_thermo_v5",
"eve_shutter",
"eve_weather_sensor",

View File

@@ -61,7 +61,7 @@
"0/40/0": 17,
"0/40/1": "Eve Systems",
"0/40/2": 4874,
"0/40/3": "Eve Thermo 20EBP1701",
"0/40/3": "Eve Thermo",
"0/40/4": 79,
"0/40/5": "",
"0/40/6": "**REDACTED**",

View File

@@ -391,7 +391,7 @@
'state': 'off',
})
# ---
# name: test_binary_sensors[eve_thermo_v4][binary_sensor.eve_thermo_20ebp1701_local_temperature_remote_sensing-entry]
# name: test_binary_sensors[eve_thermo][binary_sensor.eve_thermo_local_temperature_remote_sensing-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -404,7 +404,7 @@
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.eve_thermo_20ebp1701_local_temperature_remote_sensing',
'entity_id': 'binary_sensor.eve_thermo_local_temperature_remote_sensing',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -426,20 +426,20 @@
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[eve_thermo_v4][binary_sensor.eve_thermo_20ebp1701_local_temperature_remote_sensing-state]
# name: test_binary_sensors[eve_thermo][binary_sensor.eve_thermo_local_temperature_remote_sensing-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Eve Thermo 20EBP1701 Local temperature remote sensing',
'friendly_name': 'Eve Thermo Local temperature remote sensing',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.eve_thermo_20ebp1701_local_temperature_remote_sensing',
'entity_id': 'binary_sensor.eve_thermo_local_temperature_remote_sensing',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_binary_sensors[eve_thermo_v4][binary_sensor.eve_thermo_20ebp1701_outdoor_temperature_remote_sensing-entry]
# name: test_binary_sensors[eve_thermo][binary_sensor.eve_thermo_outdoor_temperature_remote_sensing-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -452,7 +452,7 @@
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.eve_thermo_20ebp1701_outdoor_temperature_remote_sensing',
'entity_id': 'binary_sensor.eve_thermo_outdoor_temperature_remote_sensing',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -474,13 +474,13 @@
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[eve_thermo_v4][binary_sensor.eve_thermo_20ebp1701_outdoor_temperature_remote_sensing-state]
# name: test_binary_sensors[eve_thermo][binary_sensor.eve_thermo_outdoor_temperature_remote_sensing-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Eve Thermo 20EBP1701 Outdoor temperature remote sensing',
'friendly_name': 'Eve Thermo Outdoor temperature remote sensing',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.eve_thermo_20ebp1701_outdoor_temperature_remote_sensing',
'entity_id': 'binary_sensor.eve_thermo_outdoor_temperature_remote_sensing',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,

View File

@@ -1418,7 +1418,7 @@
'state': 'unknown',
})
# ---
# name: test_buttons[eve_thermo_v4][button.eve_thermo_20ebp1701_identify-entry]
# name: test_buttons[eve_thermo][button.eve_thermo_identify-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -1431,7 +1431,7 @@
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.eve_thermo_20ebp1701_identify',
'entity_id': 'button.eve_thermo_identify',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -1453,14 +1453,14 @@
'unit_of_measurement': None,
})
# ---
# name: test_buttons[eve_thermo_v4][button.eve_thermo_20ebp1701_identify-state]
# name: test_buttons[eve_thermo][button.eve_thermo_identify-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'identify',
'friendly_name': 'Eve Thermo 20EBP1701 Identify',
'friendly_name': 'Eve Thermo Identify',
}),
'context': <ANY>,
'entity_id': 'button.eve_thermo_20ebp1701_identify',
'entity_id': 'button.eve_thermo_identify',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,

View File

@@ -191,7 +191,7 @@
'state': 'heat',
})
# ---
# name: test_climates[eve_thermo_v4][climate.eve_thermo_20ebp1701-entry]
# name: test_climates[eve_thermo][climate.eve_thermo-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -211,7 +211,7 @@
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.eve_thermo_20ebp1701',
'entity_id': 'climate.eve_thermo',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -233,11 +233,11 @@
'unit_of_measurement': None,
})
# ---
# name: test_climates[eve_thermo_v4][climate.eve_thermo_20ebp1701-state]
# name: test_climates[eve_thermo][climate.eve_thermo-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 21.0,
'friendly_name': 'Eve Thermo 20EBP1701',
'friendly_name': 'Eve Thermo',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
@@ -248,7 +248,7 @@
'temperature': 17.0,
}),
'context': <ANY>,
'entity_id': 'climate.eve_thermo_20ebp1701',
'entity_id': 'climate.eve_thermo',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,

View File

@@ -1037,7 +1037,7 @@
'state': '3',
})
# ---
# name: test_numbers[eve_thermo_v4][number.eve_thermo_20ebp1701_temperature_offset-entry]
# name: test_numbers[eve_thermo][number.eve_thermo_temperature_offset-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -1055,7 +1055,7 @@
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.eve_thermo_20ebp1701_temperature_offset',
'entity_id': 'number.eve_thermo_temperature_offset',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -1077,11 +1077,11 @@
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_numbers[eve_thermo_v4][number.eve_thermo_20ebp1701_temperature_offset-state]
# name: test_numbers[eve_thermo][number.eve_thermo_temperature_offset-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Eve Thermo 20EBP1701 Temperature offset',
'friendly_name': 'Eve Thermo Temperature offset',
'max': 50,
'min': -50,
'mode': <NumberMode.BOX: 'box'>,
@@ -1089,7 +1089,7 @@
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'number.eve_thermo_20ebp1701_temperature_offset',
'entity_id': 'number.eve_thermo_temperature_offset',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,

View File

@@ -1400,7 +1400,7 @@
'state': 'previous',
})
# ---
# name: test_selects[eve_thermo_v4][select.eve_thermo_20ebp1701_temperature_display_mode-entry]
# name: test_selects[eve_thermo][select.eve_thermo_temperature_display_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -1418,7 +1418,7 @@
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.eve_thermo_20ebp1701_temperature_display_mode',
'entity_id': 'select.eve_thermo_temperature_display_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -1440,17 +1440,17 @@
'unit_of_measurement': None,
})
# ---
# name: test_selects[eve_thermo_v4][select.eve_thermo_20ebp1701_temperature_display_mode-state]
# name: test_selects[eve_thermo][select.eve_thermo_temperature_display_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Eve Thermo 20EBP1701 Temperature display mode',
'friendly_name': 'Eve Thermo Temperature display mode',
'options': list([
'Celsius',
'Fahrenheit',
]),
}),
'context': <ANY>,
'entity_id': 'select.eve_thermo_20ebp1701_temperature_display_mode',
'entity_id': 'select.eve_thermo_temperature_display_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,

View File

@@ -4904,7 +4904,7 @@
'state': '100',
})
# ---
# name: test_sensors[eve_thermo_v4][sensor.eve_thermo_20ebp1701_battery-entry]
# name: test_sensors[eve_thermo][sensor.eve_thermo_battery-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -4919,7 +4919,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.eve_thermo_20ebp1701_battery',
'entity_id': 'sensor.eve_thermo_battery',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -4941,23 +4941,23 @@
'unit_of_measurement': '%',
})
# ---
# name: test_sensors[eve_thermo_v4][sensor.eve_thermo_20ebp1701_battery-state]
# name: test_sensors[eve_thermo][sensor.eve_thermo_battery-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'Eve Thermo 20EBP1701 Battery',
'friendly_name': 'Eve Thermo Battery',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.eve_thermo_20ebp1701_battery',
'entity_id': 'sensor.eve_thermo_battery',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '100',
})
# ---
# name: test_sensors[eve_thermo_v4][sensor.eve_thermo_20ebp1701_battery_voltage-entry]
# name: test_sensors[eve_thermo][sensor.eve_thermo_battery_voltage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -4972,7 +4972,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.eve_thermo_20ebp1701_battery_voltage',
'entity_id': 'sensor.eve_thermo_battery_voltage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -5000,23 +5000,23 @@
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
})
# ---
# name: test_sensors[eve_thermo_v4][sensor.eve_thermo_20ebp1701_battery_voltage-state]
# name: test_sensors[eve_thermo][sensor.eve_thermo_battery_voltage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'voltage',
'friendly_name': 'Eve Thermo 20EBP1701 Battery voltage',
'friendly_name': 'Eve Thermo Battery voltage',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
}),
'context': <ANY>,
'entity_id': 'sensor.eve_thermo_20ebp1701_battery_voltage',
'entity_id': 'sensor.eve_thermo_battery_voltage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '3.05',
})
# ---
# name: test_sensors[eve_thermo_v4][sensor.eve_thermo_20ebp1701_temperature-entry]
# name: test_sensors[eve_thermo][sensor.eve_thermo_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -5031,7 +5031,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.eve_thermo_20ebp1701_temperature',
'entity_id': 'sensor.eve_thermo_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -5056,23 +5056,23 @@
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[eve_thermo_v4][sensor.eve_thermo_20ebp1701_temperature-state]
# name: test_sensors[eve_thermo][sensor.eve_thermo_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Eve Thermo 20EBP1701 Temperature',
'friendly_name': 'Eve Thermo Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.eve_thermo_20ebp1701_temperature',
'entity_id': 'sensor.eve_thermo_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '21.0',
})
# ---
# name: test_sensors[eve_thermo_v4][sensor.eve_thermo_20ebp1701_valve_position-entry]
# name: test_sensors[eve_thermo][sensor.eve_thermo_valve_position-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -5085,7 +5085,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.eve_thermo_20ebp1701_valve_position',
'entity_id': 'sensor.eve_thermo_valve_position',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -5107,14 +5107,14 @@
'unit_of_measurement': '%',
})
# ---
# name: test_sensors[eve_thermo_v4][sensor.eve_thermo_20ebp1701_valve_position-state]
# name: test_sensors[eve_thermo][sensor.eve_thermo_valve_position-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Eve Thermo 20EBP1701 Valve position',
'friendly_name': 'Eve Thermo Valve position',
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.eve_thermo_20ebp1701_valve_position',
'entity_id': 'sensor.eve_thermo_valve_position',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,

View File

@@ -487,7 +487,7 @@
'state': 'off',
})
# ---
# name: test_switches[eve_thermo_v4][switch.eve_thermo_20ebp1701_child_lock-entry]
# name: test_switches[eve_thermo][switch.eve_thermo_child_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -500,7 +500,7 @@
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.eve_thermo_20ebp1701_child_lock',
'entity_id': 'switch.eve_thermo_child_lock',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -522,13 +522,13 @@
'unit_of_measurement': None,
})
# ---
# name: test_switches[eve_thermo_v4][switch.eve_thermo_20ebp1701_child_lock-state]
# name: test_switches[eve_thermo][switch.eve_thermo_child_lock-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Eve Thermo 20EBP1701 Child lock',
'friendly_name': 'Eve Thermo Child lock',
}),
'context': <ANY>,
'entity_id': 'switch.eve_thermo_20ebp1701_child_lock',
'entity_id': 'switch.eve_thermo_child_lock',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,

View File

@@ -201,7 +201,7 @@ async def test_battery_sensor_description(
state = hass.states.get("sensor.smoke_sensor_battery_type") is None
@pytest.mark.parametrize("node_fixture", ["eve_thermo_v4"])
@pytest.mark.parametrize("node_fixture", ["eve_thermo"])
async def test_eve_thermo_sensor(
hass: HomeAssistant,
matter_client: MagicMock,
@@ -209,26 +209,26 @@ async def test_eve_thermo_sensor(
) -> None:
"""Test Eve Thermo."""
# Valve position
state = hass.states.get("sensor.eve_thermo_20ebp1701_valve_position")
state = hass.states.get("sensor.eve_thermo_valve_position")
assert state
assert state.state == "10"
set_node_attribute(matter_node, 1, 319486977, 319422488, 0)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("sensor.eve_thermo_20ebp1701_valve_position")
state = hass.states.get("sensor.eve_thermo_valve_position")
assert state
assert state.state == "0"
# LocalTemperature
state = hass.states.get("sensor.eve_thermo_20ebp1701_temperature")
state = hass.states.get("sensor.eve_thermo_temperature")
assert state
assert state.state == "21.0"
set_node_attribute(matter_node, 1, 513, 0, 1800)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("sensor.eve_thermo_20ebp1701_temperature")
state = hass.states.get("sensor.eve_thermo_temperature")
assert state
assert state.state == "18.0"

View File

@@ -116,32 +116,32 @@ async def test_power_switch(hass: HomeAssistant, matter_node: MatterNode) -> Non
assert state.attributes["friendly_name"] == "Room AirConditioner Power"
@pytest.mark.parametrize("node_fixture", ["eve_thermo_v4"])
@pytest.mark.parametrize("node_fixture", ["eve_thermo"])
async def test_numeric_switch(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test numeric switch entity is discovered and working using an Eve Thermo fixture ."""
state = hass.states.get("switch.eve_thermo_20ebp1701_child_lock")
state = hass.states.get("switch.eve_thermo_child_lock")
assert state
assert state.state == "off"
# name should be derived from description attribute
assert state.attributes["friendly_name"] == "Eve Thermo 20EBP1701 Child lock"
assert state.attributes["friendly_name"] == "Eve Thermo Child lock"
# test attribute changes
set_node_attribute(matter_node, 1, 516, 1, 1)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("switch.eve_thermo_20ebp1701_child_lock")
state = hass.states.get("switch.eve_thermo_child_lock")
assert state.state == "on"
set_node_attribute(matter_node, 1, 516, 1, 0)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("switch.eve_thermo_20ebp1701_child_lock")
state = hass.states.get("switch.eve_thermo_child_lock")
assert state.state == "off"
# test switch service
await hass.services.async_call(
"switch",
"turn_on",
{"entity_id": "switch.eve_thermo_20ebp1701_child_lock"},
{"entity_id": "switch.eve_thermo_child_lock"},
blocking=True,
)
assert matter_client.write_attribute.call_count == 1
@@ -156,7 +156,7 @@ async def test_numeric_switch(
await hass.services.async_call(
"switch",
"turn_off",
{"entity_id": "switch.eve_thermo_20ebp1701_child_lock"},
{"entity_id": "switch.eve_thermo_child_lock"},
blocking=True,
)
assert matter_client.write_attribute.call_count == 2

View File

@@ -1,572 +0,0 @@
"""Tests for Mill climate."""
import contextlib
from contextlib import nullcontext
from unittest.mock import MagicMock, call, patch
from mill import Heater
from mill_local import OperationMode
import pytest
from homeassistant.components import mill
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
DOMAIN as CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_TEMPERATURE,
HVACMode,
)
from homeassistant.components.mill.const import DOMAIN
from homeassistant.components.recorder import Recorder
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from tests.common import MockConfigEntry
HEATER_ID = "dev_id"
HEATER_NAME = "heater_name"
ENTITY_CLIMATE = f"climate.{HEATER_NAME}"
TEST_SET_TEMPERATURE = 25
TEST_AMBIENT_TEMPERATURE = 20
NULL_EFFECT = nullcontext()
## MILL AND LOCAL MILL FIXTURES
@pytest.fixture
async def mock_mill():
"""Mock the mill.Mill object.
It is imported and initialized only in /homeassistant/components/mill/__init__.py
"""
with (
patch(
"homeassistant.components.mill.Mill",
autospec=True,
) as mock_mill_class,
):
mill = mock_mill_class.return_value
mill.connect.return_value = True
mill.fetch_heater_and_sensor_data.return_value = {}
mill.fetch_historic_energy_usage.return_value = {}
yield mill
@pytest.fixture
async def mock_mill_local():
"""Mock the mill_local.Mill object."""
with (
patch(
"homeassistant.components.mill.MillLocal",
autospec=True,
) as mock_mill_local_class,
):
milllocal = mock_mill_local_class.return_value
milllocal.url = "http://dummy.url"
milllocal.name = HEATER_NAME
milllocal.mac_address = "dead:beef"
milllocal.version = "0x210927"
milllocal.connect.return_value = {
"name": milllocal.name,
"mac_address": milllocal.mac_address,
"version": milllocal.version,
"operation_key": "",
"status": "ok",
}
status = {
"ambient_temperature": TEST_AMBIENT_TEMPERATURE,
"set_temperature": TEST_AMBIENT_TEMPERATURE,
"current_power": 0,
"control_signal": 0,
"raw_ambient_temperature": TEST_AMBIENT_TEMPERATURE,
"operation_mode": OperationMode.OFF.value,
}
milllocal.fetch_heater_and_sensor_data.return_value = status
milllocal._status = status
yield milllocal
## CLOUD HEATER INTEGRATION
@pytest.fixture
async def cloud_heater(hass: HomeAssistant, mock_mill: MagicMock) -> Heater:
"""Load Mill integration and creates one cloud heater."""
heater = Heater(
name=HEATER_NAME,
device_id=HEATER_ID,
available=True,
is_heating=False,
power_status=False,
current_temp=float(TEST_AMBIENT_TEMPERATURE),
set_temp=float(TEST_AMBIENT_TEMPERATURE),
)
devices = {HEATER_ID: heater}
mock_mill.fetch_heater_and_sensor_data.return_value = devices
mock_mill.devices = devices
config_entry = MockConfigEntry(
domain=DOMAIN,
data={
mill.CONF_USERNAME: "user",
mill.CONF_PASSWORD: "pswd",
mill.CONNECTION_TYPE: mill.CLOUD,
},
)
config_entry.add_to_hass(hass)
# We just need to load the climate component.
with patch("homeassistant.components.mill.PLATFORMS", [Platform.CLIMATE]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return heater
@pytest.fixture
async def cloud_heater_set_temp(mock_mill: MagicMock, cloud_heater: MagicMock):
"""Gets mock for the cloud heater `set_heater_temp` method."""
return mock_mill.set_heater_temp
@pytest.fixture
async def cloud_heater_control(mock_mill: MagicMock, cloud_heater: MagicMock):
"""Gets mock for the cloud heater `heater_control` method."""
return mock_mill.heater_control
@pytest.fixture
async def functional_cloud_heater(
cloud_heater: MagicMock,
cloud_heater_set_temp: MagicMock,
cloud_heater_control: MagicMock,
) -> Heater:
"""Make sure the cloud heater is "functional".
This will create a pseudo-functional cloud heater,
meaning that function calls will edit the original cloud heater
in a similar way that the API would.
"""
def calculate_heating():
if (
cloud_heater.power_status
and cloud_heater.set_temp > cloud_heater.current_temp
):
cloud_heater.is_heating = True
def set_temperature(device_id: str, set_temp: float):
assert device_id == HEATER_ID, "set_temperature called with wrong device_id"
cloud_heater.set_temp = set_temp
calculate_heating()
def heater_control(device_id: str, power_status: bool):
assert device_id == HEATER_ID, "set_temperature called with wrong device_id"
# power_status gives the "do we want to heat, Y/N", while is_heating is based on temperature and internal state and whatnot.
cloud_heater.power_status = power_status
calculate_heating()
cloud_heater_set_temp.side_effect = set_temperature
cloud_heater_control.side_effect = heater_control
return cloud_heater
## LOCAL HEATER INTEGRATION
@pytest.fixture
async def local_heater(hass: HomeAssistant, mock_mill_local: MagicMock) -> dict:
"""Local Mill Heater.
This returns a by-reference status dict
with which this heater's information is organised and updated.
"""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={
mill.CONF_IP_ADDRESS: "192.168.1.59",
mill.CONNECTION_TYPE: mill.LOCAL,
},
)
config_entry.add_to_hass(hass)
# We just need to load the climate component.
with patch("homeassistant.components.mill.PLATFORMS", [Platform.CLIMATE]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return mock_mill_local._status
@pytest.fixture
async def local_heater_set_target_temperature(
mock_mill_local: MagicMock, local_heater: MagicMock
):
"""Gets mock for the local heater `set_target_temperature` method."""
return mock_mill_local.set_target_temperature
@pytest.fixture
async def local_heater_set_mode_control_individually(
mock_mill_local: MagicMock, local_heater: MagicMock
):
"""Gets mock for the local heater `set_operation_mode_control_individually` method."""
return mock_mill_local.set_operation_mode_control_individually
@pytest.fixture
async def local_heater_set_mode_off(
mock_mill_local: MagicMock, local_heater: MagicMock
):
"""Gets mock for the local heater `set_operation_mode_off` method."""
return mock_mill_local.set_operation_mode_off
@pytest.fixture
async def functional_local_heater(
mock_mill_local: MagicMock,
local_heater_set_target_temperature: MagicMock,
local_heater_set_mode_control_individually: MagicMock,
local_heater_set_mode_off: MagicMock,
local_heater: MagicMock,
) -> None:
"""Make sure the local heater is "functional".
This will create a pseudo-functional local heater,
meaning that function calls will edit the original local heater
in a similar way that the API would.
"""
def set_temperature(target_temperature: float):
local_heater["set_temperature"] = target_temperature
def set_operation_mode(operation_mode: OperationMode):
local_heater["operation_mode"] = operation_mode.value
def mode_control_individually():
set_operation_mode(OperationMode.CONTROL_INDIVIDUALLY)
def mode_off():
set_operation_mode(OperationMode.OFF)
local_heater_set_target_temperature.side_effect = set_temperature
local_heater_set_mode_control_individually.side_effect = mode_control_individually
local_heater_set_mode_off.side_effect = mode_off
### CLOUD
@pytest.mark.parametrize(
(
"before_state",
"before_attrs",
"service_name",
"service_params",
"effect",
"heater_control_calls",
"heater_set_temp_calls",
"after_state",
"after_attrs",
),
[
# set_hvac_mode
(
HVACMode.OFF,
{},
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.HEAT},
NULL_EFFECT,
[call(HEATER_ID, power_status=True)],
[],
HVACMode.HEAT,
{},
),
(
HVACMode.OFF,
{},
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.OFF},
NULL_EFFECT,
[call(HEATER_ID, power_status=False)],
[],
HVACMode.OFF,
{},
),
(
HVACMode.OFF,
{},
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.COOL},
pytest.raises(HomeAssistantError),
[],
[],
HVACMode.OFF,
{},
),
# set_temperature (with hvac mode)
(
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.HEAT},
NULL_EFFECT,
[call(HEATER_ID, power_status=True)],
[call(HEATER_ID, float(TEST_SET_TEMPERATURE))],
HVACMode.HEAT,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
),
(
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.OFF},
NULL_EFFECT,
[call(HEATER_ID, power_status=False)],
[call(HEATER_ID, float(TEST_SET_TEMPERATURE))],
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
),
(
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
NULL_EFFECT,
[],
[call(HEATER_ID, float(TEST_SET_TEMPERATURE))],
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
),
(
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.COOL},
pytest.raises(HomeAssistantError),
# MillHeater will set the temperature before calling async_handle_set_hvac_mode,
# meaning an invalid HVAC mode will raise only after the temperature is set.
[],
[call(HEATER_ID, float(TEST_SET_TEMPERATURE))],
HVACMode.OFF,
# likewise, in this test, it hasn't had the chance to update its ambient temperature,
# because the exception is raised before a refresh can be requested from the coordinator
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
),
],
)
async def test_cloud_heater(
recorder_mock: Recorder,
hass: HomeAssistant,
functional_cloud_heater: MagicMock,
cloud_heater_control: MagicMock,
cloud_heater_set_temp: MagicMock,
before_state: HVACMode,
before_attrs: dict,
service_name: str,
service_params: dict,
effect: "contextlib.AbstractContextManager",
heater_control_calls: list,
heater_set_temp_calls: list,
after_state: HVACMode,
after_attrs: dict,
) -> None:
"""Tests setting HVAC mode (directly or through set_temperature) for a cloud heater."""
state = hass.states.get(ENTITY_CLIMATE)
assert state is not None
assert state.state == before_state
for attr, value in before_attrs.items():
assert state.attributes.get(attr) == value
with effect:
await hass.services.async_call(
CLIMATE_DOMAIN,
service_name,
service_params | {ATTR_ENTITY_ID: ENTITY_CLIMATE},
blocking=True,
)
await hass.async_block_till_done()
cloud_heater_control.assert_has_calls(heater_control_calls)
cloud_heater_set_temp.assert_has_calls(heater_set_temp_calls)
state = hass.states.get(ENTITY_CLIMATE)
assert state is not None
assert state.state == after_state
for attr, value in after_attrs.items():
assert state.attributes.get(attr) == value
### LOCAL
@pytest.mark.parametrize(
(
"before_state",
"before_attrs",
"service_name",
"service_params",
"effect",
"heater_mode_set_individually_calls",
"heater_mode_set_off_calls",
"heater_set_target_temperature_calls",
"after_state",
"after_attrs",
),
[
# set_hvac_mode
(
HVACMode.OFF,
{},
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.HEAT},
NULL_EFFECT,
[call()],
[],
[],
HVACMode.HEAT,
{},
),
(
HVACMode.OFF,
{},
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.OFF},
NULL_EFFECT,
[],
[call()],
[],
HVACMode.OFF,
{},
),
(
HVACMode.OFF,
{},
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.COOL},
pytest.raises(HomeAssistantError),
[],
[],
[],
HVACMode.OFF,
{},
),
# set_temperature (with hvac mode)
(
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.HEAT},
NULL_EFFECT,
[call()],
[],
[call(float(TEST_SET_TEMPERATURE))],
HVACMode.HEAT,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
),
(
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.OFF},
NULL_EFFECT,
[],
[call()],
[call(float(TEST_SET_TEMPERATURE))],
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
),
(
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
NULL_EFFECT,
[],
[],
[call(float(TEST_SET_TEMPERATURE))],
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
),
(
HVACMode.OFF,
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.COOL},
pytest.raises(HomeAssistantError),
# LocalMillHeater will set the temperature before calling async_handle_set_hvac_mode,
# meaning an invalid HVAC mode will raise only after the temperature is set.
[],
[],
[call(float(TEST_SET_TEMPERATURE))],
HVACMode.OFF,
# likewise, in this test, it hasn't had the chance to update its ambient temperature,
# because the exception is raised before a refresh can be requested from the coordinator
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
),
],
)
async def test_local_heater(
hass: HomeAssistant,
functional_local_heater: MagicMock,
local_heater_set_mode_control_individually: MagicMock,
local_heater_set_mode_off: MagicMock,
local_heater_set_target_temperature: MagicMock,
before_state: HVACMode,
before_attrs: dict,
service_name: str,
service_params: dict,
effect: "contextlib.AbstractContextManager",
heater_mode_set_individually_calls: list,
heater_mode_set_off_calls: list,
heater_set_target_temperature_calls: list,
after_state: HVACMode,
after_attrs: dict,
) -> None:
"""Tests setting HVAC mode (directly or through set_temperature) for a local heater."""
state = hass.states.get(ENTITY_CLIMATE)
assert state is not None
assert state.state == before_state
for attr, value in before_attrs.items():
assert state.attributes.get(attr) == value
with effect:
await hass.services.async_call(
CLIMATE_DOMAIN,
service_name,
service_params | {ATTR_ENTITY_ID: ENTITY_CLIMATE},
blocking=True,
)
await hass.async_block_till_done()
local_heater_set_mode_control_individually.assert_has_calls(
heater_mode_set_individually_calls
)
local_heater_set_mode_off.assert_has_calls(heater_mode_set_off_calls)
local_heater_set_target_temperature.assert_has_calls(
heater_set_target_temperature_calls
)
state = hass.states.get(ENTITY_CLIMATE)
assert state is not None
assert state.state == after_state
for attr, value in after_attrs.items():
assert state.attributes.get(attr) == value

View File

@@ -0,0 +1,150 @@
"""Tests for the Tibber binary sensors."""
from __future__ import annotations
from unittest.mock import AsyncMock
import tibber
from homeassistant.components.recorder import Recorder
from homeassistant.components.tibber.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
def create_tibber_device_with_binary_sensors(
device_id: str = "device-id",
external_id: str = "external-id",
name: str = "Test Device",
brand: str = "Tibber",
model: str = "Gen1",
connector_status: str | None = "connected",
charging_status: str | None = "charging",
device_status: str | None = "on",
home_id: str = "home-id",
) -> tibber.data_api.TibberDevice:
"""Create a fake Tibber Data API device with binary sensor capabilities."""
device_data = {
"id": device_id,
"externalId": external_id,
"info": {
"name": name,
"brand": brand,
"model": model,
},
"capabilities": [
{
"id": "connector.status",
"value": connector_status,
"description": "Connector status",
"unit": "",
},
{
"id": "charging.status",
"value": charging_status,
"description": "Charging status",
"unit": "",
},
{
"id": "onOff",
"value": device_status,
"description": "Device status",
"unit": "",
},
],
}
return tibber.data_api.TibberDevice(device_data, home_id=home_id)
async def test_binary_sensors_are_created(
recorder_mock: Recorder,
hass: HomeAssistant,
config_entry: MockConfigEntry,
data_api_client_mock: AsyncMock,
setup_credentials: None,
entity_registry: er.EntityRegistry,
) -> None:
"""Ensure binary sensors are created from Data API devices."""
device = create_tibber_device_with_binary_sensors()
data_api_client_mock.get_all_devices = AsyncMock(return_value={"device-id": device})
data_api_client_mock.update_devices = AsyncMock(return_value={"device-id": device})
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
connector_unique_id = "external-id_connector.status"
connector_entity_id = entity_registry.async_get_entity_id(
"binary_sensor", DOMAIN, connector_unique_id
)
assert connector_entity_id is not None
state = hass.states.get(connector_entity_id)
assert state is not None
assert state.state == "on"
charging_unique_id = "external-id_charging.status"
charging_entity_id = entity_registry.async_get_entity_id(
"binary_sensor", DOMAIN, charging_unique_id
)
assert charging_entity_id is not None
state = hass.states.get(charging_entity_id)
assert state is not None
assert state.state == "on"
device_unique_id = "external-id_onOff"
device_entity_id = entity_registry.async_get_entity_id(
"binary_sensor", DOMAIN, device_unique_id
)
assert device_entity_id is not None
state = hass.states.get(device_entity_id)
assert state is not None
assert state.state == "on"
async def test_device_status_on(
recorder_mock: Recorder,
hass: HomeAssistant,
config_entry: MockConfigEntry,
data_api_client_mock: AsyncMock,
setup_credentials: None,
entity_registry: er.EntityRegistry,
) -> None:
"""Test device status on state."""
device = create_tibber_device_with_binary_sensors(device_status="on")
data_api_client_mock.get_all_devices = AsyncMock(return_value={"device-id": device})
data_api_client_mock.update_devices = AsyncMock(return_value={"device-id": device})
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
unique_id = "external-id_onOff"
entity_id = entity_registry.async_get_entity_id("binary_sensor", DOMAIN, unique_id)
assert entity_id is not None
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "on"
async def test_device_status_off(
recorder_mock: Recorder,
hass: HomeAssistant,
config_entry: MockConfigEntry,
data_api_client_mock: AsyncMock,
setup_credentials: None,
entity_registry: er.EntityRegistry,
) -> None:
"""Test device status off state."""
device = create_tibber_device_with_binary_sensors(device_status="off")
data_api_client_mock.get_all_devices = AsyncMock(return_value={"device-id": device})
data_api_client_mock.update_devices = AsyncMock(return_value={"device-id": device})
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
unique_id = "external-id_onOff"
entity_id = entity_registry.async_get_entity_id("binary_sensor", DOMAIN, unique_id)
assert entity_id is not None
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "off"