diff --git a/CODEOWNERS b/CODEOWNERS index 85bfabb1f1f6cb..d93afdbbdb837d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -625,8 +625,8 @@ CLAUDE.md @home-assistant/core /tests/components/generic_hygrostat/ @Shulyaka /homeassistant/components/geniushub/ @manzanotti /tests/components/geniushub/ @manzanotti -/homeassistant/components/gentex_homelink/ @niaexa @ryanjones-gentex -/tests/components/gentex_homelink/ @niaexa @ryanjones-gentex +/homeassistant/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex +/tests/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex /homeassistant/components/geo_json_events/ @exxamalte /tests/components/geo_json_events/ @exxamalte /homeassistant/components/geo_location/ @home-assistant/core diff --git a/homeassistant/components/actron_air/manifest.json b/homeassistant/components/actron_air/manifest.json index 06978d83b460c4..bb28e65bc641f5 100644 --- a/homeassistant/components/actron_air/manifest.json +++ b/homeassistant/components/actron_air/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["actron-neo-api==0.5.6"] + "requirements": ["actron-neo-api==0.5.12"] } diff --git a/homeassistant/components/airos/manifest.json b/homeassistant/components/airos/manifest.json index f3bf5d829d6046..7c7296b7795c6a 100644 --- a/homeassistant/components/airos/manifest.json +++ b/homeassistant/components/airos/manifest.json @@ -8,5 +8,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["airos==0.6.5"] + "requirements": ["airos==0.6.8"] } diff --git a/homeassistant/components/gentex_homelink/manifest.json b/homeassistant/components/gentex_homelink/manifest.json index 57ce93f674de18..34110d6db8235c 100644 --- a/homeassistant/components/gentex_homelink/manifest.json +++ b/homeassistant/components/gentex_homelink/manifest.json @@ -1,11 +1,12 @@ { "domain": "gentex_homelink", "name": "HomeLink", - "codeowners": ["@niaexa", "@ryanjones-gentex"], + "codeowners": ["@Gentex-Corporation/Homelink", "@rjones-gentex"], "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/gentex_homelink", + "integration_type": "hub", "iot_class": "cloud_push", "quality_scale": "bronze", - "requirements": ["homelink-integration-api==0.0.1"] + "requirements": ["homelink-integration-api==0.0.5"] } diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index da7dcb7822bfad..8e8301e959d83d 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -23,6 +23,6 @@ "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], "quality_scale": "platinum", - "requirements": ["aiohomeconnect==0.36.0"], + "requirements": ["aiohomeconnect==0.36.1"], "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py index 6614348e4d9b79..4b56c3a7fd9bbd 100644 --- a/homeassistant/components/homee/cover.py +++ b/homeassistant/components/homee/cover.py @@ -1,5 +1,6 @@ """The homee cover platform.""" +from enum import Enum import logging from typing import TYPE_CHECKING, Any, cast @@ -24,12 +25,6 @@ PARALLEL_UPDATES = 0 -OPEN_CLOSE_ATTRIBUTES = [ - AttributeType.OPEN_CLOSE, - AttributeType.SLAT_ROTATION_IMPULSE, - AttributeType.UP_DOWN, -] -POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION] COVER_DEVICE_PROFILES = { NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE, NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE, @@ -43,6 +38,23 @@ ] +class HomeeCoverState(float, Enum): + """Open/closed states for covers in homee.""" + + OPEN = 0.0 + CLOSED = 1.0 + STOPPED = 2.0 + OPENING = 3.0 + CLOSING = 4.0 + + +class HomeeSlatState(float, Enum): + """Slat states for covers in homee.""" + + CLOSED = 1.0 + OPEN = 2.0 + + def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None: """Return the attribute used for opening/closing the cover.""" # We assume, that no device has UP_DOWN and OPEN_CLOSE, but only one of them. @@ -187,9 +199,9 @@ def is_opening(self) -> bool | None: """Return the opening status of the cover.""" if self._open_close_attribute is not None: return ( - self._open_close_attribute.get_value() == 3 + self._open_close_attribute.get_value() == HomeeCoverState.OPENING if not self._open_close_attribute.is_reversed - else self._open_close_attribute.get_value() == 4 + else self._open_close_attribute.get_value() == HomeeCoverState.CLOSING ) return None @@ -199,9 +211,9 @@ def is_closing(self) -> bool | None: """Return the closing status of the cover.""" if self._open_close_attribute is not None: return ( - self._open_close_attribute.get_value() == 4 + self._open_close_attribute.get_value() == HomeeCoverState.CLOSING if not self._open_close_attribute.is_reversed - else self._open_close_attribute.get_value() == 3 + else self._open_close_attribute.get_value() == HomeeCoverState.OPENING ) return None @@ -216,9 +228,9 @@ def is_closed(self) -> bool: if self._open_close_attribute is not None: if not self._open_close_attribute.is_reversed: - return self._open_close_attribute.get_value() == 1 + return self._open_close_attribute.get_value() == HomeeCoverState.CLOSED - return self._open_close_attribute.get_value() == 0 + return self._open_close_attribute.get_value() == HomeeCoverState.OPEN # If none of the above is present, it will be a slat only cover. attribute = self._node.get_attribute_by_type( @@ -235,17 +247,25 @@ async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" assert self._open_close_attribute is not None if not self._open_close_attribute.is_reversed: - await self.async_set_homee_value(self._open_close_attribute, 0) + await self.async_set_homee_value( + self._open_close_attribute, HomeeCoverState.OPEN + ) else: - await self.async_set_homee_value(self._open_close_attribute, 1) + await self.async_set_homee_value( + self._open_close_attribute, HomeeCoverState.CLOSED + ) async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" assert self._open_close_attribute is not None if not self._open_close_attribute.is_reversed: - await self.async_set_homee_value(self._open_close_attribute, 1) + await self.async_set_homee_value( + self._open_close_attribute, HomeeCoverState.CLOSED + ) else: - await self.async_set_homee_value(self._open_close_attribute, 0) + await self.async_set_homee_value( + self._open_close_attribute, HomeeCoverState.OPEN + ) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" @@ -265,7 +285,9 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" if self._open_close_attribute is not None: - await self.async_set_homee_value(self._open_close_attribute, 2) + await self.async_set_homee_value( + self._open_close_attribute, HomeeCoverState.STOPPED + ) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" @@ -275,9 +297,9 @@ async def async_open_cover_tilt(self, **kwargs: Any) -> None: ) ) is not None: if not slat_attribute.is_reversed: - await self.async_set_homee_value(slat_attribute, 2) + await self.async_set_homee_value(slat_attribute, HomeeSlatState.OPEN) else: - await self.async_set_homee_value(slat_attribute, 1) + await self.async_set_homee_value(slat_attribute, HomeeSlatState.CLOSED) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" @@ -287,9 +309,9 @@ async def async_close_cover_tilt(self, **kwargs: Any) -> None: ) ) is not None: if not slat_attribute.is_reversed: - await self.async_set_homee_value(slat_attribute, 1) + await self.async_set_homee_value(slat_attribute, HomeeSlatState.CLOSED) else: - await self.async_set_homee_value(slat_attribute, 2) + await self.async_set_homee_value(slat_attribute, HomeeSlatState.OPEN) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 51b0eee72f6daf..e34e4181ac8751 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2.7.5"] + "requirements": ["aioautomower==2.7.6"] } diff --git a/homeassistant/components/itach/manifest.json b/homeassistant/components/itach/manifest.json index 68b34b4321ee07..ba3a1cfb727dde 100644 --- a/homeassistant/components/itach/manifest.json +++ b/homeassistant/components/itach/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/itach", "iot_class": "assumed_state", "quality_scale": "legacy", - "requirements": ["pyitachip2ir==0.0.7"] + "requirements": ["pyitachip2ir2==0.0.8"] } diff --git a/homeassistant/components/opendisplay/__init__.py b/homeassistant/components/opendisplay/__init__.py index 4fcc7c95d0a26c..fb695f84ab4929 100644 --- a/homeassistant/components/opendisplay/__init__.py +++ b/homeassistant/components/opendisplay/__init__.py @@ -15,7 +15,11 @@ OpenDisplayError, ) -from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.components.bluetooth import ( + BluetoothReachabilityIntent, + async_address_reachability_diagnostics, + async_ble_device_from_address, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -83,9 +87,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) ble_device = async_ble_device_from_address(hass, address, connectable=True) if ble_device is None: raise ConfigEntryNotReady( - f"Could not find OpenDisplay device with address {address}" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "address": address, + "reason": async_address_reachability_diagnostics( + hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, ) - encryption_key = _get_encryption_key(entry) try: diff --git a/homeassistant/components/opendisplay/services.py b/homeassistant/components/opendisplay/services.py index 9bbf17d5177589..c5a7824c39ae76 100644 --- a/homeassistant/components/opendisplay/services.py +++ b/homeassistant/components/opendisplay/services.py @@ -22,7 +22,11 @@ from PIL import Image as PILImage, ImageOps import voluptuous as vol -from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.components.bluetooth import ( + BluetoothReachabilityIntent, + async_address_reachability_diagnostics, + async_ble_device_from_address, +) from homeassistant.components.http.auth import async_sign_path from homeassistant.components.media_source import async_resolve_media from homeassistant.config_entries import ConfigEntryState @@ -108,7 +112,7 @@ def _get_entry_for_device(call: ServiceCall) -> OpenDisplayConfigEntry: if entry is None or entry.state is not ConfigEntryState.LOADED: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="device_not_found", + translation_key="config_entry_not_found", translation_placeholders={"address": mac_address}, ) @@ -171,7 +175,14 @@ async def _async_upload_image(call: ServiceCall) -> None: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="device_not_found", - translation_placeholders={"address": address}, + translation_placeholders={ + "address": address, + "reason": async_address_reachability_diagnostics( + call.hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, ) current = asyncio.current_task() diff --git a/homeassistant/components/opendisplay/strings.json b/homeassistant/components/opendisplay/strings.json index 0d3e8e92cd21d1..90eb2b716afaee 100644 --- a/homeassistant/components/opendisplay/strings.json +++ b/homeassistant/components/opendisplay/strings.json @@ -74,8 +74,11 @@ "authentication_error": { "message": "Authentication failed. Please update the encryption key." }, + "config_entry_not_found": { + "message": "Config entry not found: `{address}`" + }, "device_not_found": { - "message": "Could not find Bluetooth device with address `{address}`." + "message": "Could not find Bluetooth device with address `{address}`. Reason: {reason}" }, "invalid_device_id": { "message": "Device `{device_id}` is not a valid OpenDisplay device." diff --git a/homeassistant/components/openevse/__init__.py b/homeassistant/components/openevse/__init__.py index 55ed04b513c1fb..6b1090e11f7de7 100644 --- a/homeassistant/components/openevse/__init__.py +++ b/homeassistant/components/openevse/__init__.py @@ -11,7 +11,7 @@ from .const import DOMAIN from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator -PLATFORMS = [Platform.NUMBER, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool: diff --git a/homeassistant/components/openevse/binary_sensor.py b/homeassistant/components/openevse/binary_sensor.py new file mode 100644 index 00000000000000..1fb74a52495b7c --- /dev/null +++ b/homeassistant/components/openevse/binary_sensor.py @@ -0,0 +1,120 @@ +"""Support for monitoring OpenEVSE Charger binary sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from openevsehttp.__main__ import OpenEVSE + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import ATTR_CONNECTIONS, ATTR_SERIAL_NUMBER, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OpenEVSEBinarySensorDescription(BinarySensorEntityDescription): + """Describes an OpenEVSE binary sensor entity.""" + + value_fn: Callable[[OpenEVSE], bool | None] + + +BINARY_SENSOR_TYPES: tuple[OpenEVSEBinarySensorDescription, ...] = ( + OpenEVSEBinarySensorDescription( + key="vehicle", + translation_key="vehicle", + device_class=BinarySensorDeviceClass.PLUG, + value_fn=lambda ev: ev.vehicle, + ), + OpenEVSEBinarySensorDescription( + key="divert_active", + translation_key="divert_active", + value_fn=lambda ev: ev.divert_active, + ), + OpenEVSEBinarySensorDescription( + key="using_ethernet", + translation_key="using_ethernet", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda ev: ev.using_ethernet, + ), + OpenEVSEBinarySensorDescription( + key="shaper_active", + translation_key="shaper_active", + value_fn=lambda ev: ev.shaper_active, + ), + OpenEVSEBinarySensorDescription( + key="has_limit", + translation_key="has_limit", + entity_registry_enabled_default=False, + value_fn=lambda ev: ev.has_limit, + ), + OpenEVSEBinarySensorDescription( + key="mqtt_connected", + translation_key="mqtt_connected", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda ev: ev.mqtt_connected, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OpenEVSEConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up OpenEVSE binary sensors based on config entry.""" + coordinator = entry.runtime_data + identifier = entry.unique_id or entry.entry_id + async_add_entities( + OpenEVSEBinarySensor(coordinator, description, identifier, entry.unique_id) + for description in BINARY_SENSOR_TYPES + ) + + +class OpenEVSEBinarySensor( + CoordinatorEntity[OpenEVSEDataUpdateCoordinator], BinarySensorEntity +): + """Implementation of an OpenEVSE binary sensor.""" + + _attr_has_entity_name = True + entity_description: OpenEVSEBinarySensorDescription + + def __init__( + self, + coordinator: OpenEVSEDataUpdateCoordinator, + description: OpenEVSEBinarySensorDescription, + identifier: str, + unique_id: str | None, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{identifier}-{description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, identifier)}, + manufacturer="OpenEVSE", + ) + if unique_id: + self._attr_device_info[ATTR_CONNECTIONS] = { + (CONNECTION_NETWORK_MAC, unique_id) + } + self._attr_device_info[ATTR_SERIAL_NUMBER] = unique_id + + @property + def is_on(self) -> bool | None: + """Return True if the binary sensor is on.""" + return self.entity_description.value_fn(self.coordinator.charger) diff --git a/homeassistant/components/openevse/strings.json b/homeassistant/components/openevse/strings.json index a585e6b821eafd..71b8fec91947e9 100644 --- a/homeassistant/components/openevse/strings.json +++ b/homeassistant/components/openevse/strings.json @@ -42,6 +42,26 @@ } }, "entity": { + "binary_sensor": { + "divert_active": { + "name": "Divert active" + }, + "has_limit": { + "name": "Limit active" + }, + "mqtt_connected": { + "name": "MQTT connected" + }, + "shaper_active": { + "name": "Shaper active" + }, + "using_ethernet": { + "name": "Ethernet connected" + }, + "vehicle": { + "name": "Vehicle connected" + } + }, "number": { "charge_rate": { "name": "Charge rate" diff --git a/homeassistant/components/opnsense/device_tracker.py b/homeassistant/components/opnsense/device_tracker.py index 5fc232f21ea26d..b8366022114478 100644 --- a/homeassistant/components/opnsense/device_tracker.py +++ b/homeassistant/components/opnsense/device_tracker.py @@ -1,7 +1,5 @@ """Device tracker support for OPNsense routers.""" -from typing import Any - from homeassistant.components.device_tracker import ScannerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -98,20 +96,3 @@ def hostname(self) -> str | None: hostname = device_data.get("hostname") return hostname or None return None - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - device_data = self.device_data - if not device_data: - return {} - - attrs = {} - if manufacturer := device_data.get("manufacturer"): - attrs["manufacturer"] = manufacturer - if interface := device_data.get("intf_description"): - attrs["interface"] = interface - if expires := device_data.get("expires"): - attrs["expires"] = expires - - return attrs diff --git a/homeassistant/components/rabbitair/fan.py b/homeassistant/components/rabbitair/fan.py index a0a97ddb2c1beb..99aa80dae00792 100644 --- a/homeassistant/components/rabbitair/fan.py +++ b/homeassistant/components/rabbitair/fan.py @@ -23,9 +23,9 @@ Speed.Turbo, ] -PRESET_MODE_AUTO = "Auto" -PRESET_MODE_MANUAL = "Manual" -PRESET_MODE_POLLEN = "Pollen" +PRESET_MODE_AUTO = "auto" +PRESET_MODE_MANUAL = "manual" +PRESET_MODE_POLLEN = "pollen" PRESET_MODES = { PRESET_MODE_AUTO: Mode.Auto, @@ -46,6 +46,7 @@ async def async_setup_entry( class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity): """Fan control functions of the Rabbit Air air purifier.""" + _attr_translation_key = "rabbitair" _attr_supported_features = ( FanEntityFeature.PRESET_MODE | FanEntityFeature.SET_SPEED diff --git a/homeassistant/components/rabbitair/icons.json b/homeassistant/components/rabbitair/icons.json new file mode 100644 index 00000000000000..5ee0b6eccf138f --- /dev/null +++ b/homeassistant/components/rabbitair/icons.json @@ -0,0 +1,17 @@ +{ + "entity": { + "fan": { + "rabbitair": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "mdi:fan-auto", + "manual": "mdi:fan", + "pollen": "mdi:flower-pollen" + } + } + } + } + } + } +} diff --git a/homeassistant/components/rabbitair/strings.json b/homeassistant/components/rabbitair/strings.json index 070ba3be3c54c3..d24af81560b56f 100644 --- a/homeassistant/components/rabbitair/strings.json +++ b/homeassistant/components/rabbitair/strings.json @@ -18,5 +18,20 @@ } } } + }, + "entity": { + "fan": { + "rabbitair": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]", + "pollen": "Pollen" + } + } + } + } + } } } diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index abb29c202e5291..10c48451acfa22 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -20,5 +20,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.20.0"] + "requirements": ["reolink-aio==0.20.1"] } diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 153b2ba40067cd..17379df01a1141 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["verisure"], - "requirements": ["vsure==2.6.7"] + "requirements": ["vsure==2.7.0"] } diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 68a52fca9ab606..5ca6cd6ddce34b 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -980,7 +980,24 @@ async def handle_subscribe_trigger( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle subscribe trigger command.""" - trigger_config = await async_validate_trigger_config(hass, msg["trigger"]) + # Validating the trigger config can fail on bad user input. Handle those + # errors here so they are reported to the client without being logged as + # unexpected errors by the default websocket error handler. + try: + trigger_config = await async_validate_trigger_config(hass, msg["trigger"]) + except vol.Invalid as err: + connection.send_error(msg["id"], const.ERR_INVALID_FORMAT, str(err)) + return + except HomeAssistantError as err: + connection.send_error( + msg["id"], + const.ERR_HOME_ASSISTANT_ERROR, + str(err), + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) + return @callback def forward_triggers( diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index 14f95166f05bc9..316740afd51e3a 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -211,7 +211,7 @@ def is_valid_state(self, entity_state: State) -> bool: _OCCUPANCY_CONDITION_SCHEMA = vol.Schema( { vol.Required(CONF_OPTIONS, default={}): { - vol.Required(CONF_ZONE): cv.entity_domain("zone"), + vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN), vol.Optional(CONF_FOR): cv.positive_time_period, }, } @@ -221,7 +221,7 @@ def is_valid_state(self, entity_state: State) -> bool: class _ZoneOccupancyConditionBase(EntityConditionBase): """Base for zone occupancy conditions (single zone, no behavior).""" - _domain_specs = {"zone": DomainSpec()} + _domain_specs = {DOMAIN: DomainSpec()} _schema = _OCCUPANCY_CONDITION_SCHEMA @classmethod diff --git a/pylint/plugins/README.md b/pylint/plugins/README.md index c5c62c5e15a3ac..7677616d3af065 100644 --- a/pylint/plugins/README.md +++ b/pylint/plugins/README.md @@ -105,6 +105,25 @@ Every check has a code following the | `W7421` | [`home-assistant-tests-direct-async-migrate-entry`](#w7421-home-assistant-tests-direct-async-migrate-entry) | Tests should not call an integration's `async_migrate_entry` directly | | `W7422` | [`home-assistant-tests-direct-async-setup`](#w7422-home-assistant-tests-direct-async-setup) | Tests should not call an integration's `async_setup` directly | | `C7414` | [`home-assistant-enforce-utcnow`](#c7414-home-assistant-enforce-utcnow) | Use `homeassistant.util.dt.utcnow` instead of `datetime.now(UTC)` | +| `C7412` | [`home-assistant-entity-description-redundant-default`](#c7412-home-assistant-entity-description-redundant-default) | Setting an EntityDescription field to its default value is redundant | +| `C7413` | [`home-assistant-duplicate-const`](#c7413-home-assistant-duplicate-const) | Constant duplicates one in `homeassistant.const` with the same value | +| `E7405` | [`home-assistant-action-swallowed-exception`](#e7405-home-assistant-action-swallowed-exception) | Action handler must not swallow exceptions | +| `W7414` | [`home-assistant-service-registered-in-setup-entry`](#w7414-home-assistant-service-registered-in-setup-entry) | Services should be registered in `async_setup`, not `async_setup_entry` | +| `W7417` | [`home-assistant-exception-not-translated`](#w7417-home-assistant-exception-not-translated) | `HomeAssistantError` should use `translation_key`/`translation_domain` | +| `W7419` | [`home-assistant-exception-message-with-translation`](#w7419-home-assistant-exception-message-with-translation) | Don't pass a positional message when `translation_key` is set | +| `E7406` | [`home-assistant-exception-translation-key-missing`](#e7406-home-assistant-exception-translation-key-missing) | Translation key not found in `strings.json` exceptions section | +| `E7408` | [`home-assistant-exception-translation-key-domain-mismatch`](#e7408-home-assistant-exception-translation-key-domain-mismatch) | Only one of `translation_key` / `translation_domain` is set | +| `E7418` | [`home-assistant-exception-placeholder-mismatch`](#e7418-home-assistant-exception-placeholder-mismatch) | Translation placeholders in code don't match `strings.json` | +| `E7409` | [`home-assistant-mdi-icon-not-found`](#e7409-home-assistant-mdi-icon-not-found) | MDI icon string does not exist in the Material Design Icons set | +| `E7410` | [`home-assistant-mdi-icon-json-not-found`](#e7410-home-assistant-mdi-icon-json-not-found) | MDI icon in `icons.json` does not exist in the Material Design Icons set | +| `R7403` | [`home-assistant-tests-redundant-usefixtures`](#r7403-home-assistant-tests-redundant-usefixtures) | `@pytest.mark.usefixtures` redundant when `pytestmark` already applies it | +| `W7409` | [`home-assistant-test-non-deterministic`](#w7409-home-assistant-test-non-deterministic) | Test contains `if`/`match` creating non-deterministic execution | +| `W7410` | [`home-assistant-missing-reauthentication-flow`](#w7410-home-assistant-missing-reauthentication-flow) | Config flow should implement `async_step_reauth` | +| `W7411` | [`home-assistant-missing-parallel-updates`](#w7411-home-assistant-missing-parallel-updates) | Platform module should define `PARALLEL_UPDATES` | +| `W7412` | [`home-assistant-missing-diagnostics`](#w7412-home-assistant-missing-diagnostics) | Integration diagnostics module should implement a diagnostics function | +| `W7413` | [`home-assistant-missing-config-entry-unloading`](#w7413-home-assistant-missing-config-entry-unloading) | Integration should implement `async_unload_entry` | +| `W7415` | [`home-assistant-sequential-executor-jobs`](#w7415-home-assistant-sequential-executor-jobs) | Sequential `async_add_executor_job` calls should be grouped | +| `W7416` | [`home-assistant-missing-has-entity-name`](#w7416-home-assistant-missing-has-entity-name) | Entity class should set `_attr_has_entity_name = True` | ## `home_assistant_logger` checker @@ -435,3 +454,204 @@ The helper is implemented as `functools.partial(datetime.datetime.now, UTC)` and avoids the global lookup of `UTC` on every call, while keeping the codebase consistent in how the current UTC time is obtained. + + +## `home_assistant_entity_description_defaults` checker + +Detects fields in `EntityDescription` (and subclasses) that are explicitly +set to their default value. + +### `C7412`: `home-assistant-entity-description-redundant-default` + +An EntityDescription field is set equal to a default already declared +anywhere in the class hierarchy; the assignment can be removed. Only the +literal defaults `None`, `True`, and `False` are checked; other default +values are not flagged. + + +## `home_assistant_duplicate_const` checker + +Detects constants in integration modules that duplicate one already exported +from `homeassistant.const` with the same value. + +### `C7413`: `home-assistant-duplicate-const` + +Import the constant from `homeassistant.const` instead of redefining it. + + +## `home_assistant_actions_swallowed_exceptions` checker + +Detects action handlers that catch exceptions without re-raising. Swallowed +exceptions are not surfaced to the user. + +### `E7405`: `home-assistant-action-swallowed-exception` + +Action handlers must re-raise so the user is notified of the failure. +The checker detects empty `except` blocks, blocks that only log, +`contextlib.suppress(...)`, and equivalent patterns on decorators. It does +not validate the *type* of exception being raised; a separate rule covers +that. + + +## `home_assistant_actions_service_registration` checker + +Detects services registered inside `async_setup_entry` rather than +`async_setup`. + +### `W7414`: `home-assistant-service-registered-in-setup-entry` + +Services should be registered in `async_setup` so they are available for +automation validation even when no config entry is loaded. Registrations +inside helper functions that are called from `async_setup_entry` are +caught too. + +See the [action-setup quality scale rule](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/action-setup). + + +## `home_assistant_exception_translations` checker + +Ensures `HomeAssistantError` and its subclasses use the translation system +(`translation_domain`, `translation_key`) instead of hardcoded English +strings. Also verifies that referenced translation keys exist in the +integration's `strings.json` and that placeholders match. + +### `W7417`: `home-assistant-exception-not-translated` + +A `HomeAssistantError` subclass is raised with a hardcoded message; use +`translation_domain` and `translation_key` instead. Quality-scale-gated. + +### `W7419`: `home-assistant-exception-message-with-translation` + +Don't pass a positional message argument when `translation_key` is also set; +the translation system supplies the message. + +### `E7406`: `home-assistant-exception-translation-key-missing` + +The translation key referenced from code is missing from `strings.json` +under the `exceptions` section. + +### `E7408`: `home-assistant-exception-translation-key-domain-mismatch` + +Both `translation_key` and `translation_domain` must be set together; only +one of the two was provided. + +### `E7418`: `home-assistant-exception-placeholder-mismatch` + +The placeholders passed in code (e.g. `translation_placeholders={...}`) +don't match the `{placeholder}` slots in the `strings.json` message. + + +## `home_assistant_mdi_icons` checker + +Validates that `mdi:` icon references in code and `icons.json` refer to +icons that actually exist in the Material Design Icons set. + +### `E7409`: `home-assistant-mdi-icon-not-found` + +MDI icon reference in Python code does not exist in the Material Design +Icons set. + +### `E7410`: `home-assistant-mdi-icon-json-not-found` + +MDI icon reference in `icons.json` does not exist in the Material Design +Icons set. + + +## `home_assistant_tests_redundant_usefixtures` checker + +Detects `@pytest.mark.usefixtures(...)` decorators that duplicate a fixture +already applied module-wide, either through a module-level `pytestmark` +or via `autouse=True` on a fixture defined in a parent `conftest.py`. + +### `R7403`: `home-assistant-tests-redundant-usefixtures` + +Drop the redundant `@pytest.mark.usefixtures` decorator; the fixture is +already applied to every test in the module. + + +## `home_assistant_test_determinism` checker + +`if` and `match` statements inside test functions create non-deterministic +execution paths: some branches may never run, silently hiding failures. + +### `W7409`: `home-assistant-test-non-deterministic` + +Test function contains an `if` or `match` statement. Use +`@pytest.mark.parametrize` to cover cases explicitly, or split into separate +test functions. `if` statements have several exemptions: guard clauses +(`return`/`raise`/`pytest.skip`/`pytest.xfail`/`pytest.fail`), +conditions that reference a function parameter, and branches that contain +no `assert`. `match` statements have no exemptions. + + +## `home_assistant_reauthentication_flow` checker + +Quality-scale-gated checker for the +[`reauthentication-flow`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reauthentication-flow) +Silver rule. Fires only when the integration claims the rule as `done`. + +### `W7410`: `home-assistant-missing-reauthentication-flow` + +Integration's `config_flow.py` should implement `async_step_reauth`. + + +## `home_assistant_parallel_updates` checker + +Quality-scale-gated checker for the +[`parallel-updates`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/parallel-updates) +Silver rule. Fires only when the integration claims the rule as `done`. + +### `W7411`: `home-assistant-missing-parallel-updates` + +Platform module should define a module-level `PARALLEL_UPDATES` constant. + + +## `home_assistant_diagnostics` checker + +Quality-scale-gated checker for the +[`diagnostics`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/diagnostics) +Gold rule. Fires only when the integration claims the rule as `done`. + +### `W7412`: `home-assistant-missing-diagnostics` + +Integration's `diagnostics.py` should implement +`async_get_config_entry_diagnostics` or `async_get_device_diagnostics`. + + +## `home_assistant_config_entry_unloading` checker + +Quality-scale-gated checker for the +[`config-entry-unloading`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-entry-unloading) +Silver rule. Fires only when the integration claims the rule as `done`. + +### `W7413`: `home-assistant-missing-config-entry-unloading` + +Integration's `__init__.py` should implement `async_unload_entry`. + + +## `home_assistant_sequential_executor_jobs` checker + +Detects consecutive `async_add_executor_job` calls in integration modules +that could be grouped into a single executor job. + +### `W7415`: `home-assistant-sequential-executor-jobs` + +Two or more `async_add_executor_job` calls appearing as consecutive +statements (uninterrupted by control flow such as `if`/`try`/`with`/`for`) +should be combined into a single executor job that performs all the work, +avoiding unnecessary context switches back to the event loop between +blocking calls. The rule applies to integration modules only. + + +## `home_assistant_has_entity_name` checker + +Quality-scale-gated checker for the +[`has-entity-name`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/has-entity-name) +Bronze rule. Fires only when the integration claims the rule as `done`. + +### `W7416`: `home-assistant-missing-has-entity-name` + +Entity class should statically guarantee `_attr_has_entity_name = True`: +either set at class level, set unconditionally at the top of a method, or +supplied by an `entity_description` whose class sets `has_entity_name = True`. +Conditional patterns are rejected. diff --git a/requirements_all.txt b/requirements_all.txt index 5c50284b5384be..fe1e617cc97a64 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -133,7 +133,7 @@ WSDiscovery==2.1.2 accuweather==5.1.0 # homeassistant.components.actron_air -actron-neo-api==0.5.6 +actron-neo-api==0.5.12 # homeassistant.components.adax adax==0.4.0 @@ -212,7 +212,7 @@ aioaseko==1.0.0 aioasuswrt==1.5.4 # homeassistant.components.husqvarna_automower -aioautomower==2.7.5 +aioautomower==2.7.6 # homeassistant.components.azure_devops aioazuredevops==2.2.2 @@ -285,7 +285,7 @@ aioharmony==1.0.8 aiohasupervisor==0.4.3 # homeassistant.components.home_connect -aiohomeconnect==0.36.0 +aiohomeconnect==0.36.1 # homeassistant.components.homekit_controller aiohomekit==3.2.20 @@ -474,7 +474,7 @@ airgradient==0.9.2 airly==1.1.0 # homeassistant.components.airos -airos==0.6.5 +airos==0.6.8 # homeassistant.components.airpatrol airpatrol==0.1.0 @@ -1278,7 +1278,7 @@ home-assistant-intents==2026.6.1 homekit-audio-proxy==1.2.1 # homeassistant.components.gentex_homelink -homelink-integration-api==0.0.1 +homelink-integration-api==0.0.5 # homeassistant.components.homematicip_cloud homematicip==2.12.0 @@ -2262,7 +2262,7 @@ pyiss==1.0.1 pyisy==3.6.1 # homeassistant.components.itach -pyitachip2ir==0.0.7 +pyitachip2ir2==0.0.8 # homeassistant.components.ituran pyituran==0.1.5 @@ -2890,7 +2890,7 @@ renault-api==0.5.11 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.20.0 +reolink-aio==0.20.1 # homeassistant.components.radio_frequency rf-protocols==4.0.1 @@ -3323,7 +3323,7 @@ volkszaehler==0.4.0 volvocarsapi==0.4.3 # homeassistant.components.verisure -vsure==2.6.7 +vsure==2.7.0 # homeassistant.components.vasttrafik vtjp==0.2.1 diff --git a/script/hassfest/integration_type.py b/script/hassfest/integration_type.py index 1d0302bc98773f..5e83c4eceb2693 100644 --- a/script/hassfest/integration_type.py +++ b/script/hassfest/integration_type.py @@ -27,7 +27,6 @@ "folder_watcher", "forked_daapd", "geniushub", - "gentex_homelink", "geofency", "govee_light_local", "gpsd", diff --git a/tests/components/duco/test_config_flow.py b/tests/components/duco/test_config_flow.py index 3ab0fda5ec79b3..100ef572eb05e0 100644 --- a/tests/components/duco/test_config_flow.py +++ b/tests/components/duco/test_config_flow.py @@ -13,7 +13,12 @@ import pytest from homeassistant.components.duco.const import DOMAIN -from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import ( + SOURCE_DHCP, + SOURCE_USER, + SOURCE_ZEROCONF, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -83,17 +88,41 @@ ] -@pytest.mark.usefixtures("mock_setup_entry") -async def test_user_flow_success( - hass: HomeAssistant, mock_duco_client: AsyncMock -) -> None: - """Test a successful user flow.""" +async def _start_user_flow(hass: HomeAssistant) -> ConfigFlowResult: + """Start the user config flow and assert the initial form.""" 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"] == {} + return result + + +async def _start_reconfigure_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> ConfigFlowResult: + """Start the reconfigure flow for an existing config entry.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + return result + + +def _set_board_info_value(mock_duco_client: AsyncMock, board_info: BoardInfo) -> None: + """Set the board info returned by the next config flow step.""" + mock_duco_client.async_get_board_info.side_effect = None + mock_duco_client.async_get_board_info.return_value = board_info + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_success( + hass: HomeAssistant, mock_duco_client: AsyncMock +) -> None: + """Test a successful user flow.""" + result = await _start_user_flow(hass) result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT @@ -122,9 +151,7 @@ async def test_user_flow_error( expected_error: str, ) -> None: """Test handling of connection and unknown errors in the user flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) + result = await _start_user_flow(hass) mock_duco_client.async_get_board_info.side_effect = exception result = await hass.config_entries.flow.async_configure( @@ -152,9 +179,7 @@ async def test_user_flow_duplicate( mock_config_entry.add_to_hass(hass) # Second attempt for the same device - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) + result = await _start_user_flow(hass) result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT ) @@ -269,12 +294,7 @@ async def test_reconfigure_flow_success( mock_config_entry: MockConfigEntry, ) -> None: """Test a successful reconfigure flow updates host and reloads.""" - mock_config_entry.add_to_hass(hass) - - result = await mock_config_entry.start_reconfigure_flow(hass) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reconfigure" + result = await _start_reconfigure_flow(hass, mock_config_entry) mock_duco_client.async_get_board_info.side_effect = DucoConnectionError( "Connection refused" @@ -305,9 +325,7 @@ async def test_reconfigure_flow_wrong_device( mock_config_entry: MockConfigEntry, ) -> None: """Test reconfigure flow aborts when pointing to a different device.""" - mock_config_entry.add_to_hass(hass) - - result = await mock_config_entry.start_reconfigure_flow(hass) + result = await _start_reconfigure_flow(hass, mock_config_entry) # Simulate a different MAC returned by the new host different_mac = "11:22:33:44:55:66" @@ -347,9 +365,7 @@ async def test_reconfigure_flow_error( expected_error: str, ) -> None: """Test reconfigure flow shows error on connection failure.""" - mock_config_entry.add_to_hass(hass) - - result = await mock_config_entry.start_reconfigure_flow(hass) + result = await _start_reconfigure_flow(hass, mock_config_entry) mock_duco_client.async_get_board_info.side_effect = exception result = await hass.config_entries.flow.async_configure( @@ -376,9 +392,7 @@ async def test_reconfigure_flow_without_info_endpoint( mock_config_entry: MockConfigEntry, ) -> None: """Test reconfigure flow rejects boards that do not expose the supported API.""" - mock_config_entry.add_to_hass(hass) - - result = await mock_config_entry.start_reconfigure_flow(hass) + result = await _start_reconfigure_flow(hass, mock_config_entry) mock_duco_client.async_get_board_info.side_effect = DucoResponseError(404, "/info") result = await hass.config_entries.flow.async_configure( @@ -540,11 +554,9 @@ async def test_user_flow_unsupported_board_from_board_info( unsupported_board_info: BoardInfo, ) -> None: """Test user flow shows unsupported_board error when board validation fails.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) + result = await _start_user_flow(hass) - mock_duco_client.async_get_board_info.return_value = unsupported_board_info + _set_board_info_value(mock_duco_client, unsupported_board_info) result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT ) @@ -553,7 +565,7 @@ async def test_user_flow_unsupported_board_from_board_info( assert result["step_id"] == "user" assert result["errors"] == {"base": "unsupported_board"} - mock_duco_client.async_get_board_info.return_value = mock_board_info + _set_board_info_value(mock_duco_client, mock_board_info) result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT ) @@ -569,11 +581,9 @@ async def test_user_flow_allows_api_compatible_board_info( supported_board_info: BoardInfo, ) -> None: """Test user flow allows boards that expose a compatible API version.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) + result = await _start_user_flow(hass) - mock_duco_client.async_get_board_info.return_value = supported_board_info + _set_board_info_value(mock_duco_client, supported_board_info) result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT ) @@ -593,11 +603,9 @@ async def test_reconfigure_flow_unsupported_board_from_board_info( unsupported_board_info: BoardInfo, ) -> None: """Test reconfigure flow shows unsupported_board when board validation fails.""" - mock_config_entry.add_to_hass(hass) - - result = await mock_config_entry.start_reconfigure_flow(hass) + result = await _start_reconfigure_flow(hass, mock_config_entry) - mock_duco_client.async_get_board_info.return_value = unsupported_board_info + _set_board_info_value(mock_duco_client, unsupported_board_info) result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "192.168.1.50"} ) @@ -606,7 +614,7 @@ async def test_reconfigure_flow_unsupported_board_from_board_info( assert result["step_id"] == "reconfigure" assert result["errors"] == {"base": "unsupported_board"} - mock_duco_client.async_get_board_info.return_value = mock_board_info + _set_board_info_value(mock_duco_client, mock_board_info) result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "192.168.1.200"} ) @@ -622,7 +630,7 @@ async def test_zeroconf_discovery_allows_api_compatible_board_info( supported_board_info: BoardInfo, ) -> None: """Test zeroconf discovery allows boards that expose a compatible API version.""" - mock_duco_client.async_get_board_info.return_value = supported_board_info + _set_board_info_value(mock_duco_client, supported_board_info) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -644,7 +652,7 @@ async def test_zeroconf_discovery_unsupported_board_from_board_info( unsupported_board_info: BoardInfo, ) -> None: """Test zeroconf discovery aborts with unsupported_board when board validation fails.""" - mock_duco_client.async_get_board_info.return_value = unsupported_board_info + _set_board_info_value(mock_duco_client, unsupported_board_info) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -663,7 +671,7 @@ async def test_dhcp_discovery_allows_api_compatible_board_info( supported_board_info: BoardInfo, ) -> None: """Test DHCP discovery allows boards that expose a compatible API version.""" - mock_duco_client.async_get_board_info.return_value = supported_board_info + _set_board_info_value(mock_duco_client, supported_board_info) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -685,7 +693,7 @@ async def test_dhcp_discovery_unsupported_board_from_board_info( unsupported_board_info: BoardInfo, ) -> None: """Test DHCP discovery aborts with unsupported_board when board validation fails.""" - mock_duco_client.async_get_board_info.return_value = unsupported_board_info + _set_board_info_value(mock_duco_client, unsupported_board_info) result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/openevse/conftest.py b/tests/components/openevse/conftest.py index 92af5adfd7a516..99e10c58de8553 100644 --- a/tests/components/openevse/conftest.py +++ b/tests/components/openevse/conftest.py @@ -38,7 +38,6 @@ def mock_charger() -> Generator[MagicMock]: charger.callback = None # Status sensors charger.status = "Charging" - charger.vehicle = True charger.mode = "STA" charger.charge_mode = "fast" charger.divertmode = "normal" @@ -90,6 +89,13 @@ def mock_charger() -> Generator[MagicMock]: # System diagnostic sensors charger.uptime = 86400 # 1 day in seconds charger.freeram = 50000 + # Binary sensors + charger.vehicle = True + charger.divert_active = False + charger.using_ethernet = False + charger.shaper_active = False + charger.has_limit = False + charger.mqtt_connected = False yield charger diff --git a/tests/components/openevse/snapshots/test_binary_sensor.ambr b/tests/components/openevse/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..67516e25af3e3a --- /dev/null +++ b/tests/components/openevse/snapshots/test_binary_sensor.ambr @@ -0,0 +1,303 @@ +# serializer version: 1 +# name: test_entities[binary_sensor.openevse_mock_config_divert_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.openevse_mock_config_divert_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Divert active', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Divert active', + 'platform': 'openevse', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'divert_active', + 'unique_id': 'deadbeeffeed-divert_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.openevse_mock_config_divert_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'openevse_mock_config Divert active', + }), + 'context': , + 'entity_id': 'binary_sensor.openevse_mock_config_divert_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.openevse_mock_config_ethernet_connected-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.openevse_mock_config_ethernet_connected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Ethernet connected', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ethernet connected', + 'platform': 'openevse', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'using_ethernet', + 'unique_id': 'deadbeeffeed-using_ethernet', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.openevse_mock_config_ethernet_connected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'openevse_mock_config Ethernet connected', + }), + 'context': , + 'entity_id': 'binary_sensor.openevse_mock_config_ethernet_connected', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.openevse_mock_config_limit_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.openevse_mock_config_limit_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Limit active', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Limit active', + 'platform': 'openevse', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'has_limit', + 'unique_id': 'deadbeeffeed-has_limit', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.openevse_mock_config_limit_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'openevse_mock_config Limit active', + }), + 'context': , + 'entity_id': 'binary_sensor.openevse_mock_config_limit_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.openevse_mock_config_mqtt_connected-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.openevse_mock_config_mqtt_connected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'MQTT connected', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'MQTT connected', + 'platform': 'openevse', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mqtt_connected', + 'unique_id': 'deadbeeffeed-mqtt_connected', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.openevse_mock_config_mqtt_connected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'openevse_mock_config MQTT connected', + }), + 'context': , + 'entity_id': 'binary_sensor.openevse_mock_config_mqtt_connected', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.openevse_mock_config_shaper_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.openevse_mock_config_shaper_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Shaper active', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Shaper active', + 'platform': 'openevse', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'shaper_active', + 'unique_id': 'deadbeeffeed-shaper_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.openevse_mock_config_shaper_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'openevse_mock_config Shaper active', + }), + 'context': , + 'entity_id': 'binary_sensor.openevse_mock_config_shaper_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.openevse_mock_config_vehicle_connected-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.openevse_mock_config_vehicle_connected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Vehicle connected', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Vehicle connected', + 'platform': 'openevse', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle', + 'unique_id': 'deadbeeffeed-vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.openevse_mock_config_vehicle_connected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'openevse_mock_config Vehicle connected', + }), + 'context': , + 'entity_id': 'binary_sensor.openevse_mock_config_vehicle_connected', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/openevse/test_binary_sensor.py b/tests/components/openevse/test_binary_sensor.py new file mode 100644 index 00000000000000..941e0b0e38fdf8 --- /dev/null +++ b/tests/components/openevse/test_binary_sensor.py @@ -0,0 +1,119 @@ +"""Tests for the OpenEVSE binary sensor platform.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test the binary sensor entities.""" + with patch("homeassistant.components.openevse.PLATFORMS", [Platform.BINARY_SENSOR]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_disabled_by_default_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test the disabled by default binary sensor entities.""" + with patch("homeassistant.components.openevse.PLATFORMS", [Platform.BINARY_SENSOR]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get("binary_sensor.openevse_mock_config_ethernet_connected") + assert state is None + + entry = entity_registry.async_get( + "binary_sensor.openevse_mock_config_ethernet_connected" + ) + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + state = hass.states.get("binary_sensor.openevse_mock_config_limit_active") + assert state is None + + entry = entity_registry.async_get("binary_sensor.openevse_mock_config_limit_active") + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + state = hass.states.get("binary_sensor.openevse_mock_config_mqtt_connected") + assert state is None + + entry = entity_registry.async_get( + "binary_sensor.openevse_mock_config_mqtt_connected" + ) + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +async def test_missing_sensor_graceful_handling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test that missing binary sensor attributes are handled gracefully.""" + mock_charger.shaper_active = None + + with patch("homeassistant.components.openevse.PLATFORMS", [Platform.BINARY_SENSOR]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # The binary sensor with missing attribute should be unknown + state = hass.states.get("binary_sensor.openevse_mock_config_shaper_active") + assert state is not None + assert state.state == STATE_UNKNOWN + + # Other binary sensors should still work + state = hass.states.get("binary_sensor.openevse_mock_config_vehicle_connected") + assert state is not None + assert state.state == "on" + + +async def test_binary_sensor_unavailable_on_coordinator_timeout( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test binary sensors become unavailable when coordinator times out.""" + with patch("homeassistant.components.openevse.PLATFORMS", [Platform.BINARY_SENSOR]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.openevse_mock_config_vehicle_connected") + assert state + assert state.state != STATE_UNAVAILABLE + + mock_charger.update.side_effect = TimeoutError("Connection timed out") + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.openevse_mock_config_vehicle_connected") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/opnsense/test_device_tracker.py b/tests/components/opnsense/test_device_tracker.py index a1baa8b89dc2b9..0f30e2a24c4223 100644 --- a/tests/components/opnsense/test_device_tracker.py +++ b/tests/components/opnsense/test_device_tracker.py @@ -90,7 +90,6 @@ async def test_device_tracker_states( assert state_1.state == "home" # Should be connected since it's in ARP table assert state_1.attributes.get("ip") == "192.168.0.123" assert state_1.attributes.get("mac") == "ff:ff:ff:ff:ff:ff" - assert state_1.attributes.get("interface") == "LAN" # Test second device (with hostname and manufacturer) entity_id_2 = entity_ids_by_unique_id["ff:ff:ff:ff:ff:fe"] @@ -99,8 +98,6 @@ async def test_device_tracker_states( assert state_2.state == "home" # Should be connected since it's in ARP table assert state_2.attributes.get("ip") == "192.168.0.167" assert state_2.attributes.get("mac") == "ff:ff:ff:ff:ff:fe" - assert state_2.attributes.get("interface") == "LAN" - assert state_2.attributes.get("manufacturer") == "OEM" async def test_device_tracker_with_interfaces_filter( diff --git a/tests/components/verisure/test_config_flow.py b/tests/components/verisure/test_config_flow.py index 87efbf96de3df3..6a2befe6fbe01f 100644 --- a/tests/components/verisure/test_config_flow.py +++ b/tests/components/verisure/test_config_flow.py @@ -224,8 +224,8 @@ async def test_full_user_flow_multiple_installations_with_mfa( @pytest.mark.parametrize( ("side_effect", "error"), [ - (VerisureLoginError, "invalid_auth"), - (VerisureError, "unknown"), + (VerisureLoginError("Login failed"), "invalid_auth"), + (VerisureError("Unknown error"), "unknown"), ], ) async def test_verisure_errors( @@ -433,8 +433,8 @@ async def test_reauth_flow_with_mfa( @pytest.mark.parametrize( ("side_effect", "error"), [ - (VerisureLoginError, "invalid_auth"), - (VerisureError, "unknown"), + (VerisureLoginError("Login failed"), "invalid_auth"), + (VerisureError("Unknown error"), "unknown"), ], ) async def test_reauth_flow_errors( diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 71771e355bdfd1..b987ce14dd72df 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2745,6 +2745,103 @@ async def test_subscribe_trigger( assert sum(hass.bus.async_listeners().values()) == init_count +@pytest.mark.parametrize( + ("trigger", "expected_error"), + [ + # Unknown trigger platform + ( + {"platform": "nonexistent"}, + { + "code": "invalid_format", + "message": "Invalid trigger 'nonexistent' specified", + }, + ), + # Missing mandatory config for the trigger platform + ( + {"platform": "numeric_state"}, + { + "code": "invalid_format", + "message": "required key not provided @ data['entity_id']", + }, + ), + # Unknown device, raised as a HomeAssistantError by the platform validator + ( + {"platform": "device", "domain": "light", "device_id": "nonexistent"}, + { + "code": "home_assistant_error", + "message": "Unknown device 'nonexistent'", + }, + ), + ], +) +async def test_subscribe_trigger_config_error( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, + trigger: dict, + expected_error: dict, +) -> None: + """Test trigger config errors are reported to the client without logging.""" + caplog.set_level(logging.ERROR) + + await websocket_client.send_json_auto_id( + {"type": "subscribe_trigger", "trigger": trigger} + ) + + msg = await websocket_client.receive_json() + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert msg["error"] == expected_error + + # The expected error is not logged by the default websocket error handler + assert "Error handling message" not in caplog.text + + +async def test_subscribe_trigger_template_error_spams_log( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that a failing trigger template spams the log. + + This documents unwanted behavior. Unlike subscribe_condition and + test_condition, subscribe_trigger does not suppress repeated template + variable errors: a trigger is evaluated event-driven by its platform (here + via async_track_template_result), outside any trace context, so the + trace-based suppression used for conditions does not apply. This should be + fixed in the future so the error is suppressed/forwarded instead of being + logged on every re-render. + """ + caplog.set_level(logging.WARNING) + hass.states.async_set("sensor.test", "1") + + await websocket_client.send_json_auto_id( + { + "type": "subscribe_trigger", + "trigger": { + "platform": "template", + "value_template": "{{ states('sensor.test') }}{{ undefined_variable }}", + }, + } + ) + + msg = await websocket_client.receive_json() + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + # Each re-render of the template logs the undefined variable error again + for state in ("2", "3", "4"): + hass.states.async_set("sensor.test", state) + await hass.async_block_till_done() + + assert ( + caplog.text.count( + "Template variable warning: 'undefined_variable' is undefined" + ) + > 1 + ) + + async def test_test_condition( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: