diff --git a/homeassistant/components/besen/sensor.py b/homeassistant/components/besen/sensor.py index 04de805d343ed1..b70ca1a090f560 100644 --- a/homeassistant/components/besen/sensor.py +++ b/homeassistant/components/besen/sensor.py @@ -1,8 +1,8 @@ """Sensor platform for Besen.""" -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import override +from typing import Final, override from besen.models import BesenData @@ -30,16 +30,146 @@ PARALLEL_UPDATES = 0 +ERROR_STATES: Final = { + "Relay Stick Error": "relay_stick_error", + "OFFLINE": "offline", + "CC Error": "cc_error", + "CP Error": "cp_error", + "Emergency Stop": "emergency_stop", + "Over Temperature": "over_temperature", + "Leakage Protection": "leakage_protection", + "Short Circuit": "short_circuit", + "Over Current": "over_current", + "Ungrounded": "ungrounded", + "Over Voltage": "over_voltage", + "Low Voltage": "low_voltage", + "Input Power Error": "input_power_error", + "DLB Over Current - Mains overload": "dlb_over_current", + "Diode Short Circuit": "diode_short_circuit", + "RTC Failure": "rtc_failure", + "Flash Memory Failure": "flash_memory_failure", + "EEPROM Failure": "eeprom_failure", + "Metering Module Failure": "metering_module_failure", + "No Error": "no_error", +} + +CHARGING_STATES: Final = { + "Start": "start", + "Finish Charging": "finish_charging", + "Waiting": "waiting", + "Finished": "finished", + "Cancel": "canceled", + "Connect": "connect", + "Fault": "fault", +} + +CHARGING_MESSAGES: Final = { + "EV is connected, please press start": "ev_connected_press_start", + "Charging": "charging", + "Charging has started, waiting for EV.": "waiting_for_ev", + "Charging completed": "charging_completed", + "Charging reservation.": "charging_reservation", + "The plug is not connected, please start charging after connecting.": ( + "plug_not_connected" + ), + "See Error State": "see_error_state", + "Wait for the swipe to start": "waiting_for_swipe", + "Wait for the button to activate": "waiting_for_button", +} + +PLUG_STATES: Final = { + "Disconnected": "disconnected", + "Connected Unlocked": "connected_unlocked", + "Connected Locked": "connected_locked", +} + +OUTPUT_STATES: Final = { + "Charging": "charging", + "Idle": "idle", +} + +CURRENT_STATES: Final = { + "Fault": "fault", + "Charging Fault 1": "charging_fault_1", + "Charging Fault 2": "charging_fault_2", + "Waiting for swipe": "waiting_for_swipe", + "Waiting for button": "waiting_for_button", + "Not Connected": "not_connected", + "Ready to charge": "ready_to_charge", + "Charging": "charging", + "Completed": "completed", + "Completed Full Charge": "completed_full_charge", + "Charging Reservation": "charging_reservation", +} + + +def _enum_state(value: str | None, states: Mapping[str, str]) -> str | None: + """Return the stable Home Assistant value for a charger state.""" + + return states.get(value) if value is not None else None + @dataclass(frozen=True, kw_only=True) class BesenSensorEntityDescription(SensorEntityDescription): """Describe a Besen sensor entity.""" - value_fn: Callable[[BesenData], float | int | None] + value_fn: Callable[[BesenData], StateType] three_phase_only: bool = False SENSOR_DESCRIPTIONS: tuple[BesenSensorEntityDescription, ...] = ( + BesenSensorEntityDescription( + key="charging_status", + translation_key="charging_status", + device_class=SensorDeviceClass.ENUM, + options=list(CHARGING_STATES.values()), + value_fn=lambda data: _enum_state(data.charge.charging_status, CHARGING_STATES), + ), + BesenSensorEntityDescription( + key="charging_message", + translation_key="charging_message", + device_class=SensorDeviceClass.ENUM, + options=list(CHARGING_MESSAGES.values()), + value_fn=lambda data: _enum_state( + data.charge.charging_status_description, CHARGING_MESSAGES + ), + ), + BesenSensorEntityDescription( + key="error_state", + translation_key="error_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=list(ERROR_STATES.values()), + value_fn=lambda data: _enum_state(data.charge.error_details, ERROR_STATES), + ), + BesenSensorEntityDescription( + key="plug_state", + translation_key="plug_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=list(PLUG_STATES.values()), + value_fn=lambda data: _enum_state(data.charge.plug_state, PLUG_STATES), + ), + BesenSensorEntityDescription( + key="output_state", + translation_key="output_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=list(OUTPUT_STATES.values()), + value_fn=lambda data: _enum_state(data.charge.output_state, OUTPUT_STATES), + ), + BesenSensorEntityDescription( + key="current_state", + translation_key="current_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=list(CURRENT_STATES.values()), + value_fn=lambda data: _enum_state(data.charge.current_state, CURRENT_STATES), + ), BesenSensorEntityDescription( key="charging_power", translation_key="charging_power", diff --git a/homeassistant/components/besen/strings.json b/homeassistant/components/besen/strings.json index a9508a9df882e9..547a52e1feed10 100644 --- a/homeassistant/components/besen/strings.json +++ b/homeassistant/components/besen/strings.json @@ -42,7 +42,74 @@ "charging_current": { "name": "Charging current" } }, "sensor": { + "charging_message": { + "name": "Charging message", + "state": { + "charging": "[%key:common::state::charging%]", + "charging_completed": "Charging completed", + "charging_reservation": "Charging reservation", + "ev_connected_press_start": "EV is connected, press start", + "plug_not_connected": "The plug is not connected. Connect it before starting charging.", + "see_error_state": "See error state", + "waiting_for_button": "Waiting for the button to activate", + "waiting_for_ev": "Charging started, waiting for the EV", + "waiting_for_swipe": "Waiting for a card swipe" + } + }, "charging_power": { "name": "Charging power" }, + "charging_status": { + "name": "Charging status", + "state": { + "canceled": "Canceled", + "connect": "Connect", + "fault": "[%key:common::state::fault%]", + "finish_charging": "Finish charging", + "finished": "Finished", + "start": "Start", + "waiting": "Waiting" + } + }, + "current_state": { + "name": "Current state", + "state": { + "charging": "[%key:common::state::charging%]", + "charging_fault_1": "Charging fault 1", + "charging_fault_2": "Charging fault 2", + "charging_reservation": "Charging reservation", + "completed": "Completed", + "completed_full_charge": "Completed, fully charged", + "fault": "[%key:common::state::fault%]", + "not_connected": "Not connected", + "ready_to_charge": "Ready to charge", + "waiting_for_button": "Waiting for button", + "waiting_for_swipe": "Waiting for card swipe" + } + }, + "error_state": { + "name": "Error state", + "state": { + "cc_error": "CC error", + "cp_error": "CP error", + "diode_short_circuit": "Diode short circuit", + "dlb_over_current": "DLB overcurrent, mains overload", + "eeprom_failure": "EEPROM failure", + "emergency_stop": "Emergency stop", + "flash_memory_failure": "Flash memory failure", + "input_power_error": "Input power error", + "leakage_protection": "Leakage protection", + "low_voltage": "Low voltage", + "metering_module_failure": "Metering module failure", + "no_error": "No error", + "offline": "Offline", + "over_current": "Overcurrent", + "over_temperature": "Overtemperature", + "over_voltage": "Overvoltage", + "relay_stick_error": "Relay stick error", + "rtc_failure": "RTC failure", + "short_circuit": "Short circuit", + "ungrounded": "Ungrounded" + } + }, "external_temperature": { "name": "External temperature" }, "internal_temperature": { "name": "Internal temperature" }, "l1_current": { "name": "L1 current" }, @@ -51,6 +118,21 @@ "l2_voltage": { "name": "L2 voltage" }, "l3_current": { "name": "L3 current" }, "l3_voltage": { "name": "L3 voltage" }, + "output_state": { + "name": "Output state", + "state": { + "charging": "[%key:common::state::charging%]", + "idle": "[%key:common::state::idle%]" + } + }, + "plug_state": { + "name": "Plug state", + "state": { + "connected_locked": "Connected, locked", + "connected_unlocked": "Connected, unlocked", + "disconnected": "[%key:common::state::disconnected%]" + } + }, "session_energy": { "name": "Session energy" }, "total_energy": { "name": "Total energy" } }, diff --git a/homeassistant/components/collection_image/config_flow.py b/homeassistant/components/collection_image/config_flow.py index bb529de913f560..35f29d58f88107 100644 --- a/homeassistant/components/collection_image/config_flow.py +++ b/homeassistant/components/collection_image/config_flow.py @@ -4,16 +4,21 @@ import voluptuous as vol +from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.components.media_player import BrowseError, MediaClass -from homeassistant.components.media_source import async_browse_media +from homeassistant.components.media_source import URI_SCHEME, async_browse_media from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.helpers.selector import MediaSelector from .const import CONF_MEDIA, DOMAIN +IMAGE_MEDIA_URI = f"{URI_SCHEME}{IMAGE_DOMAIN}" + STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_MEDIA): MediaSelector({"accept": ["directory"]}), + vol.Required(CONF_MEDIA): MediaSelector( + {"accept": ["directory"], "multiple": True} + ), } ) @@ -28,24 +33,41 @@ async def async_step_user( """Handle the initial step.""" errors: dict[str, str] = {} placeholders: dict[str, str] = {} + found_pictures = False + title = "Unnamed collection" if user_input is not None: - user_media = user_input[CONF_MEDIA] - try: - browse = await async_browse_media( - self.hass, user_media["media_content_id"] - ) - except BrowseError as err: - errors["media"] = "failed_browse" - placeholders["error"] = str(err) - else: - if browse.children and any( - item.media_class == MediaClass.IMAGE for item in browse.children - ): + user_media_list = user_input[CONF_MEDIA] + for user_media in user_media_list: + if user_media["media_content_id"] == IMAGE_MEDIA_URI: + errors["media"] = "invalid_selection" + placeholders["error"] = IMAGE_MEDIA_URI + break + try: + browse = await async_browse_media( + self.hass, user_media["media_content_id"] + ) + except BrowseError as err: + errors["media"] = "failed_browse" + placeholders["error"] = str(err) + break + else: + if ( + not found_pictures + and browse.children + and any( + item.media_class == MediaClass.IMAGE + for item in browse.children + ) + ): + found_pictures = True + if browse.title: + title = f"{browse.title} collection" + if "media" not in errors: + if found_pictures: return self.async_create_entry( - title=f"{browse.title or 'Unnamed'} collection", + title=title, data=user_input, ) - errors["media"] = "selected_media_no_images" return self.async_show_form( diff --git a/homeassistant/components/collection_image/image.py b/homeassistant/components/collection_image/image.py index 7284083ec6fdf1..ad601054ee9f7b 100644 --- a/homeassistant/components/collection_image/image.py +++ b/homeassistant/components/collection_image/image.py @@ -37,11 +37,15 @@ async def async_setup_entry( ) -> None: """Set up the Collection Image image entities.""" media = entry.data[CONF_MEDIA] + if isinstance(media, dict): + content_ids = [media["media_content_id"]] + else: + content_ids = [item["media_content_id"] for item in media] async_add_entities( [ CollectionImageImageEntity( name=entry.title, - media_content_id=media["media_content_id"], + media_content_ids=content_ids, unique_id=entry.entry_id, hass=hass, ) @@ -58,7 +62,7 @@ class CollectionImageImageEntity(ImageEntity): def __init__( self, name: str, - media_content_id: str, + media_content_ids: list[str], unique_id: str, hass: HomeAssistant, ) -> None: @@ -67,7 +71,7 @@ def __init__( self.path = None self._attr_unique_id = unique_id self._attr_name = name - self.media_content_id = media_content_id + self.media_content_ids = media_content_ids def set_unavailable(self) -> None: """Set the entity to unavailable state.""" @@ -79,23 +83,30 @@ def set_unavailable(self) -> None: async def get_valid_images(self) -> list[BrowseMedia]: """Given the configured media directory for the entity, get a list of all child images.""" - try: - media = await async_browse_media(self.hass, self.media_content_id) - except BrowseError as err: - _LOGGER.warning("%s: %s", self.entity_id, str(err)) - return [] - images = [ - item - for item in (media.children or []) - if item.media_class == MediaClass.IMAGE - ] - if not images: - _LOGGER.warning( - "%s: No valid images in %s", - self.entity_id, - self.media_content_id, - ) + images: list[BrowseMedia] = [] + + for media_content_id in self.media_content_ids: + try: + media = await async_browse_media(self.hass, media_content_id) + except BrowseError as err: + _LOGGER.warning("%s: %s", self.entity_id, str(err)) + continue + + directory_images = [ + item + for item in (media.children or []) + if item.media_class == MediaClass.IMAGE + ] + if directory_images: + images.extend(directory_images) + else: + _LOGGER.warning( + "%s: No valid images in %s", + self.entity_id, + media_content_id, + ) + return images async def get_random_image(self) -> None: diff --git a/homeassistant/components/collection_image/strings.json b/homeassistant/components/collection_image/strings.json index 2dfab4ea68e7b1..df62a7351561c8 100644 --- a/homeassistant/components/collection_image/strings.json +++ b/homeassistant/components/collection_image/strings.json @@ -2,6 +2,7 @@ "config": { "error": { "failed_browse": "Failed to browse media: {error}", + "invalid_selection": "Invalid media selected: {error}", "selected_media_no_images": "The selected media has no images. Please select a media directory with images." }, "step": { @@ -10,9 +11,9 @@ "media": "Media" }, "data_description": { - "media": "The media directory where images will be retrieved from." + "media": "The media where images will be retrieved from." }, - "description": "The Collection Image integration creates a single image entity by selecting an image from the selected media folder.", + "description": "The Collection Image integration creates an image entity which renders an image from the selected media.", "submit": "Create" } } diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json index 425385c24f9cd8..1e4b65190bb594 100644 --- a/homeassistant/components/duco/manifest.json +++ b/homeassistant/components/duco/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_polling", "loggers": ["duco_connectivity"], "quality_scale": "platinum", - "requirements": ["python-duco-connectivity==0.15.0"], + "requirements": ["python-duco-connectivity==0.16.0"], "zeroconf": [ { "name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*", diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index fb3b62aa43a26b..85a9d6500a9ce4 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==46.4.0", + "aioesphomeapi==46.4.1", "esphome-dashboard-api==1.4.0", "bleak-esphome==4.0.0" ], diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py index 0af8b0702cf96d..61228c2a534fc9 100644 --- a/homeassistant/components/fyta/sensor.py +++ b/homeassistant/components/fyta/sensor.py @@ -22,6 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util from .const import ( CONF_MAX_ACCEPTABLE, @@ -132,6 +133,15 @@ class FytaMeasurementSensorEntityDescription(FytaSensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda plant: plant.battery_level, ), + FytaSensorEntityDescription( + key="last_updated", + translation_key="last_update", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda plant: ( + dt_util.as_local(plant.last_updated) if plant.last_updated else None + ), + ), ] MEASUREMENT_SENSORS: Final[list[FytaMeasurementSensorEntityDescription]] = [ diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index 7f2062ad7209c8..15a3c85097223d 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -71,6 +71,9 @@ "last_fertilised": { "name": "Last fertilized" }, + "last_update": { + "name": "Last update" + }, "light": { "name": "Light", "state_attributes": { diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 11119fbca7ba7d..5fe9605f80c593 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -6,6 +6,7 @@ from homematicip.base.enums import ( BinaryBehaviorType, + FunctionalChannelType, LockState, SmokeDetectorAlarmType, WindowState, @@ -48,7 +49,7 @@ from .const import DOMAIN from .entity import HomematicipGenericEntity from .hap import HomematicIPConfigEntry, HomematicipHAP -from .helpers import smoke_detector_channel_data_exists +from .helpers import get_channel_index_by_type, smoke_detector_channel_data_exists ATTR_ACCELERATION_SENSOR_MODE = "acceleration_sensor_mode" ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position" @@ -427,7 +428,14 @@ class HomematicipTiltVibrationSensor(HomematicipBaseActionSensor): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the tilt vibration sensor.""" - super().__init__(hap, device, feature_id="tilt_vibration") + super().__init__( + hap, + device, + feature_id="tilt_vibration", + channel_real_index=get_channel_index_by_type( + device, FunctionalChannelType.TILT_VIBRATION_SENSOR_CHANNEL + ), + ) class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEntity): diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py index 4ac44c80c11d86..78627a16cf3a74 100644 --- a/homeassistant/components/homematicip_cloud/entity.py +++ b/homeassistant/components/homematicip_cloud/entity.py @@ -372,8 +372,7 @@ def get_current_channel(self) -> FunctionalChannel: """Return the FunctionalChannel for the device. Resolution priority: - 1. For multi-channel entities with a real index, find - channel by index match. + 1. With a real index, find channel by index match. 2. For multi-channel entities without a real index, use the provided channel position. 3. For non multi-channel entities with >1 channels, use @@ -388,20 +387,20 @@ def get_current_channel(self) -> FunctionalChannel: " has no functionalChannels" ) + # Prefer real index mapping when provided to avoid ordering issues. + if self._channel_real_index is not None: + for channel in functional_channels: + if channel.index == self._channel_real_index: + return channel + raise ValueError( + f"Real channel index" + f" {self._channel_real_index}" + " not found for device" + f" {getattr(self._device, 'id', 'unknown')}" + ) + # Multi-channel handling if self._is_multi_channel: - # Prefer real index mapping when provided to avoid - # ordering issues. - if self._channel_real_index is not None: - for channel in functional_channels: - if channel.index == self._channel_real_index: - return channel - raise ValueError( - f"Real channel index" - f" {self._channel_real_index}" - " not found for device" - f" {getattr(self._device, 'id', 'unknown')}" - ) # Fallback: positional channel (already sorted as strings upstream). if self._channel is not None and 0 <= self._channel < len( functional_channels diff --git a/homeassistant/components/homematicip_cloud/helpers.py b/homeassistant/components/homematicip_cloud/helpers.py index 151abf3f63a153..7320f988b077fc 100644 --- a/homeassistant/components/homematicip_cloud/helpers.py +++ b/homeassistant/components/homematicip_cloud/helpers.py @@ -61,6 +61,14 @@ def get_channels_from_device(device: Device, channel_type: FunctionalChannelType ] +def get_channel_index_by_type( + device: Device, channel_type: FunctionalChannelType +) -> int | None: + """Return the index of the device's first channel of the given type.""" + channels = get_channels_from_device(device, channel_type) + return channels[0].index if channels else None + + def smoke_detector_channel_data_exists(device: Device, field: str) -> bool: """Check if a smoke detector's channel payload contains a specific field. diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index c73cdada998c8b..f74b499d4d0ee8 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -67,7 +67,11 @@ from .entity import HomematicipGenericEntity from .hap import HomematicIPConfigEntry, HomematicipHAP -from .helpers import get_channels_from_device, smoke_detector_channel_data_exists +from .helpers import ( + get_channel_index_by_type, + get_channels_from_device, + smoke_detector_channel_data_exists, +) @dataclass(frozen=True, kw_only=True) @@ -144,6 +148,8 @@ class HmipSensorDescription[_DeviceT: Device](SensorEntityDescription): extra_attrs_fn: Callable[[_DeviceT], dict[str, Any]] | None = None icon_fn: Callable[[_DeviceT], str] | None = None channel: int + # for devices whose channel does not sit at the position `channel` names + channel_type: FunctionalChannelType | None = None ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position" @@ -393,6 +399,20 @@ def _passage_counter_extras(device: Device) -> dict[str, Any]: state_class=SensorStateClass.MEASUREMENT_ANGLE, channel_value_fn=lambda channel: getattr(channel, "absoluteAngle", None), channel=1, + channel_type=FunctionalChannelType.TILT_VIBRATION_SENSOR_CHANNEL, +) + +# Only the ELV-SH-TACO carries a temperature channel next to the tilt channel. +TILT_TEMPERATURE_DESC = HmipSensorDescription[Device]( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=_temperature_value, + extra_attrs_fn=_temperature_extras, + exists_fn=lambda d: hasattr(d, "actualTemperature"), + channel=1, + channel_type=FunctionalChannelType.TEMPERATURE_SENSOR_CHANNEL, ) @@ -433,7 +453,7 @@ def _passage_counter_extras(device: Device) -> dict[str, Any]: TEMPERATURE_EXTERNAL_CH2_DESC, TEMPERATURE_EXTERNAL_DELTA_DESC, ), - TiltVibrationSensor: (TILT_ANGLE_DESC,), + TiltVibrationSensor: (TILT_ANGLE_DESC, TILT_TEMPERATURE_DESC), WeatherSensor: ( TEMPERATURE_DESC, HUMIDITY_DESC, @@ -721,7 +741,15 @@ class HomematicipTiltStateSensor(HomematicipGenericEntity, SensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the tilt sensor device.""" - super().__init__(hap, device, post="Tilt State", feature_id="tilt_state") + super().__init__( + hap, + device, + post="Tilt State", + feature_id="tilt_state", + channel_real_index=get_channel_index_by_type( + device, FunctionalChannelType.TILT_VIBRATION_SENSOR_CHANNEL + ), + ) @property @override @@ -1054,11 +1082,17 @@ def __init__( description: HmipSensorDescription[_DeviceT], ) -> None: """Initialize the described sensor.""" + channel_real_index = ( + get_channel_index_by_type(device, description.channel_type) + if description.channel_type is not None + else None + ) super().__init__( hap, device, feature_id=description.key, channel=description.channel, + channel_real_index=channel_real_index, use_description_name=True, ) self.entity_description = description diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index ec442defd126d6..567e739febc8f9 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["iaqualink"], "quality_scale": "silver", - "requirements": ["iaqualink==0.7.0", "h2==4.3.0"], + "requirements": ["iaqualink==0.7.0", "h2==4.4.1"], "single_config_entry": true } diff --git a/homeassistant/components/librenms/__init__.py b/homeassistant/components/librenms/__init__.py index 6ec24f2f3ad115..acd4e75d356bc6 100644 --- a/homeassistant/components/librenms/__init__.py +++ b/homeassistant/components/librenms/__init__.py @@ -5,7 +5,7 @@ from .coordinator import LibrenmsConfigEntry, LibrenmsDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: LibrenmsConfigEntry) -> bool: diff --git a/homeassistant/components/librenms/config_flow.py b/homeassistant/components/librenms/config_flow.py index 777d3183786610..2cc54a32bfe234 100644 --- a/homeassistant/components/librenms/config_flow.py +++ b/homeassistant/components/librenms/config_flow.py @@ -57,6 +57,7 @@ def _parse_url(url: str) -> tuple[str, int, bool]: (host := parsed_url.host) is None or (port := parsed_url.port) is None or (scheme := parsed_url.scheme) is None + or scheme not in ["http", "https"] ): raise InvalidUrl return host, port, scheme == "https" @@ -123,3 +124,121 @@ async def async_step_user( return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Trigger a reauthentication flow.""" + self._current_data = entry_data + self._name = entry_data[CONF_HOST] + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthorization flow.""" + errors = {} + + if user_input is not None: + try: + await check_connection( + self.hass, + self._current_data[CONF_HOST], + self._current_data[CONF_PORT], + self._current_data[CONF_SSL], + self._current_data[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + except LibrenmsUnauthenticatedError: + errors["base"] = "invalid_auth" + except CONNECT_ERRORS: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.PASSWORD) + ) + } + ), + description_placeholders={"name": self._name}, + errors=errors, + ) + + async def async_step_reconfigure( + self, + user_input: Mapping[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle reconfiguration of LibreNMS.""" + entry = self._get_reconfigure_entry() + current_data = entry.data + + url = str( + URL.build( + scheme="https" if current_data[CONF_SSL] else "http", + host=current_data[CONF_HOST], + port=current_data[CONF_PORT], + ) + ) + verify_ssl = current_data[CONF_VERIFY_SSL] + + errors: dict[str, str] = {} + if user_input is not None: + url = user_input[CONF_URL] + verify_ssl = user_input[CONF_VERIFY_SSL] + try: + (host, port, ssl) = _parse_url(user_input[CONF_URL]) + except InvalidUrl: + errors[CONF_URL] = "invalid_url" + else: + self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port}) + try: + await check_connection( + self.hass, + host, + port, + ssl, + user_input[CONF_VERIFY_SSL], + current_data[CONF_API_KEY], + ) + except LibrenmsUnauthenticatedError: + errors["base"] = "invalid_auth" + except CONNECT_ERRORS: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + entry, + data_updates={ + **current_data, + CONF_HOST: host, + CONF_PORT: port, + CONF_SSL: ssl, + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required(CONF_URL, default=url): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Required(CONF_VERIFY_SSL, default=verify_ssl): bool, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/librenms/entity.py b/homeassistant/components/librenms/entity.py index 152d475df03aa9..906e611bad6013 100644 --- a/homeassistant/components/librenms/entity.py +++ b/homeassistant/components/librenms/entity.py @@ -1,10 +1,10 @@ -"""Base entity for the LibreNMS integration.""" +"""Base entities for the LibreNMS integration.""" from typing import override from aiolibrenms.devices.models import LibrenmsDeviceInfo -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -53,3 +53,25 @@ def available(self) -> bool: def _data(self) -> LibrenmsDeviceInfo: """Get DeviceInfo from coordinator.""" return self.coordinator.data.devices[self.device_id] + + +class LibrenmsSystemEntity(CoordinatorEntity[LibrenmsDataUpdateCoordinator]): + """Define LibreNMS base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: LibrenmsDataUpdateCoordinator, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + manufacturer="LibreNMS", + sw_version=coordinator.data.system.local_ver, + entry_type=DeviceEntryType.SERVICE, + configuration_url=coordinator.configuration_url, + name="LibreNMS", + ) diff --git a/homeassistant/components/librenms/icons.json b/homeassistant/components/librenms/icons.json new file mode 100644 index 00000000000000..fcca916b7c10f4 --- /dev/null +++ b/homeassistant/components/librenms/icons.json @@ -0,0 +1,21 @@ +{ + "entity": { + "sensor": { + "database_version": { + "default": "mdi:database" + }, + "netsnmp_version": { + "default": "mdi:network-outline" + }, + "php_version": { + "default": "mdi:language-php" + }, + "python_version": { + "default": "mdi:language-python" + }, + "rrdtool_version": { + "default": "mdi:database-clock" + } + } + } +} diff --git a/homeassistant/components/librenms/quality_scale.yaml b/homeassistant/components/librenms/quality_scale.yaml index 17a61715387e90..5fd10affe884ca 100644 --- a/homeassistant/components/librenms/quality_scale.yaml +++ b/homeassistant/components/librenms/quality_scale.yaml @@ -40,7 +40,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold @@ -66,7 +66,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No repair issues needed diff --git a/homeassistant/components/librenms/sensor.py b/homeassistant/components/librenms/sensor.py new file mode 100644 index 00000000000000..09e3dd237d963d --- /dev/null +++ b/homeassistant/components/librenms/sensor.py @@ -0,0 +1,114 @@ +"""Sensor platform for the LibreNMS integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import ( + LibrenmsConfigEntry, + LibrenmsData, + LibrenmsDataUpdateCoordinator, +) +from .entity import LibrenmsSystemEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class LibrenmsSystemSensorEntityDescription(SensorEntityDescription): + """Librenms system sensor entity description.""" + + value: Callable[[LibrenmsData], StateType] + is_suitable: Callable[[LibrenmsData], bool] = lambda _: True + + +SYSTEM_SENSOR_TYPES: tuple[LibrenmsSystemSensorEntityDescription, ...] = ( + LibrenmsSystemSensorEntityDescription( + key="device_count", + translation_key="device_count", + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: len(data.devices), + ), + LibrenmsSystemSensorEntityDescription( + key="database_version", + translation_key="database_version", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value=lambda data: data.system.database_ver, + ), + LibrenmsSystemSensorEntityDescription( + key="netsnmp_version", + translation_key="netsnmp_version", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value=lambda data: data.system.netsnmp_ver, + ), + LibrenmsSystemSensorEntityDescription( + key="php_version", + translation_key="php_version", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value=lambda data: data.system.php_ver, + ), + LibrenmsSystemSensorEntityDescription( + key="python_version", + translation_key="python_version", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value=lambda data: data.system.python_ver, + ), + LibrenmsSystemSensorEntityDescription( + key="rrdtool_version", + translation_key="rrdtool_version", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value=lambda data: data.system.rrdtool_ver, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LibrenmsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add LibreNMS server state sensors.""" + coordinator = entry.runtime_data + async_add_entities( + LibrenmsSystemSensorEntity(coordinator, description) + for description in SYSTEM_SENSOR_TYPES + if description.is_suitable(coordinator.data) + ) + + +class LibrenmsSystemSensorEntity(LibrenmsSystemEntity, SensorEntity): + """Define Librenms sensor entity.""" + + entity_description: LibrenmsSystemSensorEntityDescription + + def __init__( + self, + coordinator: LibrenmsDataUpdateCoordinator, + description: LibrenmsSystemSensorEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self.entity_description = description + + @property + @override + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value(self.coordinator.data) diff --git a/homeassistant/components/librenms/strings.json b/homeassistant/components/librenms/strings.json index 3544e152ec19aa..66f688606039cb 100644 --- a/homeassistant/components/librenms/strings.json +++ b/homeassistant/components/librenms/strings.json @@ -6,7 +6,9 @@ }, "config": { "abort": { - "already_configured": "This LibreNMS instance is already configured." + "already_configured": "This LibreNMS instance is already configured.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -15,6 +17,25 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::librenms::common::data_desc_api_key%]" + }, + "description": "Update the API key for {name}." + }, + "reconfigure": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "url": "[%key:component::librenms::common::data_desc_url%]", + "verify_ssl": "[%key:component::librenms::common::data_desc_ssl_verify%]" + } + }, "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]", @@ -34,6 +55,16 @@ "status": { "name": "Status" } + }, + "sensor": { + "database_version": { "name": "Database version" }, + "device_count": { + "name": "Total device count" + }, + "netsnmp_version": { "name": "NetSNMP version" }, + "php_version": { "name": "PHP version" }, + "python_version": { "name": "Python version" }, + "rrdtool_version": { "name": "RRDTool version" } } }, "exceptions": { diff --git a/homeassistant/components/mcp/manifest.json b/homeassistant/components/mcp/manifest.json index 83896593c83f55..0d4866d577c62c 100644 --- a/homeassistant/components/mcp/manifest.json +++ b/homeassistant/components/mcp/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/mcp", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["mcp==1.26.0"] + "requirements": ["mcp==1.28.1"] } diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json index d54fc33ebc506e..a70914369e0431 100644 --- a/homeassistant/components/mcp_server/manifest.json +++ b/homeassistant/components/mcp_server/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["mcp==1.26.0", "aiohttp_sse==2.2.0", "anyio==4.13.0"], + "requirements": ["mcp==1.28.1", "aiohttp_sse==2.2.0", "anyio==4.13.0"], "single_config_entry": true } diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 477769b9646106..3121843a618d12 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -685,7 +685,7 @@ async def _get_location_data(self) -> dict[str, str]: "name": "approximate_location", "description": "Approximate location data of the user " "for refined web search results", - "schema": to_openapi(location_schema), + "schema": to_openapi(location_schema, openapi_version="3.1.0"), "strict": False, } }, diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 21c8e772e355dc..9156fa18955c5c 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -150,6 +150,7 @@ def _format_structured_output( custom_serializer=( llm_api.custom_serializer if llm_api else llm.selector_serializer ), + openapi_version="3.1.0", ) _adjust_schema(result) @@ -162,7 +163,9 @@ def _format_tool( ) -> FunctionToolParam: """Format tool specification.""" unsupported_keys = {"oneOf", "anyOf", "allOf", "enum", "not"} - schema = to_openapi(tool.parameters, custom_serializer=custom_serializer) + schema = to_openapi( + tool.parameters, custom_serializer=custom_serializer, openapi_version="3.1.0" + ) if unsupported_keys.intersection(schema): schema = {k: v for k, v in schema.items() if k not in unsupported_keys} diff --git a/homeassistant/components/qingping/manifest.json b/homeassistant/components/qingping/manifest.json index 31cadb86fe827f..eb5ea8816bda07 100644 --- a/homeassistant/components/qingping/manifest.json +++ b/homeassistant/components/qingping/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/qingping", "integration_type": "device", "iot_class": "local_push", - "requirements": ["qingping-ble==1.1.5"] + "requirements": ["qingping-ble==1.1.6"] } diff --git a/homeassistant/components/saj/__init__.py b/homeassistant/components/saj/__init__.py index d180982cc6701b..1a801425b2a86c 100644 --- a/homeassistant/components/saj/__init__.py +++ b/homeassistant/components/saj/__init__.py @@ -1,198 +1,31 @@ """The saj component.""" -from collections.abc import Callable, Coroutine -from dataclasses import dataclass -from datetime import datetime -import logging from typing import Any import pysaj -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_TYPE, CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.start import async_at_start +from homeassistant.core import HomeAssistant from .const import CONNECTION_TYPES - -_LOGGER = logging.getLogger(__name__) +from .coordinator import SAJConfigEntry, SAJDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -MIN_INTERVAL_SEC = 5 -MAX_INTERVAL_SEC = 300 - - -@callback -def async_track_time_interval_backoff( - hass: HomeAssistant, action: Callable[[], Coroutine[Any, Any, bool]] -) -> CALLBACK_TYPE: - """Fire `action` on an interval; double the interval (capped) when it returns False.""" - remove: CALLBACK_TYPE | None = None - interval = MIN_INTERVAL_SEC - stopped = False - - async def interval_listener(_now: datetime | None = None) -> None: - nonlocal interval, remove, stopped - try: - if await action(): - interval = MIN_INTERVAL_SEC - else: - interval = min(interval * 2, MAX_INTERVAL_SEC) - finally: - if not stopped: - remove = async_call_later(hass, interval, interval_listener) - - hass.async_create_task(interval_listener()) - - def remove_listener() -> None: - nonlocal remove, stopped - stopped = True - if remove: - remove() - remove = None - - return remove_listener - - -class SAJPolling: - """Interval polling with backoff; entities register for per-poll callbacks.""" - - def __init__( - self, - hass: HomeAssistant, - entry: ConfigEntry, - saj: pysaj.SAJ, - sensor_def: pysaj.Sensors, - ) -> None: - """Initialize polling for one config entry.""" - self._hass = hass - self._entry = entry - self._saj = saj - self._sensor_def = sensor_def - self._listeners: list[Callable[[bool], None]] = [] - self._remove_backoff: CALLBACK_TYPE | None = None - self._cancel_at_start: CALLBACK_TYPE | None = None - self._unsub_stop: CALLBACK_TYPE | None = None - - @callback - def async_add_poll_listener( - self, target: Callable[[bool], None] - ) -> Callable[[], None]: - """Register to be called after each poll with the read success flag.""" - - @callback - def remove_listener() -> None: - self._listeners.remove(target) - if not self._listeners: - self._async_stop_backoff() - if self._cancel_at_start: - self._cancel_at_start() - self._cancel_at_start = None - - self._listeners.append(target) - if len(self._listeners) == 1: - self._schedule_polling_start() - return remove_listener - - def _schedule_polling_start(self) -> None: - @callback - def start(_hass: HomeAssistant) -> None: - self._cancel_at_start = None - if not self._listeners: - return - self._async_start_backoff() - - self._cancel_at_start = async_at_start(self._hass, start) - - @callback - def _async_start_backoff(self) -> None: - self._remove_backoff = async_track_time_interval_backoff( - self._hass, self._async_poll_with_notify - ) - - @callback - def stop_on_hass_stop(_event: Event) -> None: - self._async_stop_backoff() - - self._unsub_stop = self._hass.bus.async_listen( - EVENT_HOMEASSISTANT_STOP, stop_on_hass_stop - ) - - async def _async_poll_with_notify(self) -> bool: - success = False - try: - success = await self._saj.read(self._sensor_def) - except pysaj.UnauthorizedException: - _LOGGER.error( - "Username and/or password rejected during polling for %s", - self._entry.title, - ) - except pysaj.UnexpectedResponseException as err: - _LOGGER.error( - "Error in SAJ, please check host/ip address. Original error: %s", err - ) - except (TimeoutError, OSError) as err: - _LOGGER.error("Error communicating with SAJ: %s", err) - except Exception as err: # noqa: BLE001 - _LOGGER.error( - "Unexpected error polling SAJ inverter %s: %s", - self._entry.title, - err, - ) - - for listener in list(self._listeners): - listener(success) - return success - - @callback - def _async_stop_backoff(self) -> None: - if self._remove_backoff: - self._remove_backoff() - self._remove_backoff = None - if self._unsub_stop: - self._unsub_stop() - self._unsub_stop = None - - @callback - def async_shutdown(self) -> None: - """Cancel polling and any deferred start.""" - self._listeners.clear() - self._async_stop_backoff() - if self._cancel_at_start: - self._cancel_at_start() - self._cancel_at_start = None - - -@dataclass(frozen=True, slots=True) -class SAJRuntimeData: - """Runtime data attached to a SAJ config entry.""" - - saj: pysaj.SAJ - sensor_def: pysaj.Sensors - polling: SAJPolling - - -type SAJConfigEntry = ConfigEntry[SAJRuntimeData] - async def async_setup_entry(hass: HomeAssistant, entry: SAJConfigEntry) -> bool: """Set up SAJ from a config entry.""" host = entry.data[CONF_HOST] connection_type = entry.data[CONF_TYPE] - username = entry.data.get(CONF_USERNAME, None) - password = entry.data.get(CONF_PASSWORD, None) + username = entry.data.get(CONF_USERNAME) + password = entry.data.get(CONF_PASSWORD) - # Create SAJ connection kwargs: dict[str, Any] = {} wifi = connection_type == CONNECTION_TYPES[1] if wifi: @@ -202,33 +35,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: SAJConfigEntry) -> bool: if password: kwargs["password"] = password - async def _async_connect() -> tuple[pysaj.SAJ, pysaj.Sensors]: - """Connect to SAJ and verify connection.""" - saj = pysaj.SAJ(host, **kwargs) - sensor_def = pysaj.Sensors(wifi) - done = await saj.read(sensor_def) - if not done: - raise ConfigEntryNotReady("Failed to read initial sensor data") - return saj, sensor_def + saj = pysaj.SAJ(host, **kwargs) + sensor_def = pysaj.Sensors(wifi) - try: - saj, sensor_def = await _async_connect() - except pysaj.UnauthorizedException as err: - if wifi: - raise ConfigEntryAuthFailed("Authentication failed") from err - raise ConfigEntryNotReady( - "Wrong connection type or device rejected connection" - ) from err - except pysaj.UnexpectedResponseException as err: - raise ConfigEntryNotReady(f"Connection error: {err}") from err - except TimeoutError as err: - raise ConfigEntryNotReady(f"Connection timeout: {err}") from err - except OSError as err: - raise ConfigEntryNotReady(f"Network error: {err}") from err + coordinator = SAJDataUpdateCoordinator(hass, entry, saj, sensor_def, wifi=wifi) + await coordinator.async_config_entry_first_refresh() - polling = SAJPolling(hass, entry, saj, sensor_def) - entry.runtime_data = SAJRuntimeData(saj=saj, sensor_def=sensor_def, polling=polling) - entry.async_on_unload(polling.async_shutdown) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/saj/coordinator.py b/homeassistant/components/saj/coordinator.py new file mode 100644 index 00000000000000..74e6a8040e20d3 --- /dev/null +++ b/homeassistant/components/saj/coordinator.py @@ -0,0 +1,66 @@ +"""DataUpdateCoordinator for the SAJ Solar Inverter integration.""" + +from datetime import timedelta +import logging +from typing import override + +import pysaj + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=60) + +type SAJConfigEntry = ConfigEntry[SAJDataUpdateCoordinator] + + +class SAJDataUpdateCoordinator(DataUpdateCoordinator[pysaj.Sensors]): + """Coordinator to poll a SAJ inverter and share data with all sensors.""" + + config_entry: SAJConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: SAJConfigEntry, + saj: pysaj.SAJ, + sensor_def: pysaj.Sensors, + *, + wifi: bool, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.saj = saj + self.sensor_def = sensor_def + self._wifi = wifi + + @override + async def _async_update_data(self) -> pysaj.Sensors: + """Fetch the latest data from the inverter.""" + try: + success = await self.saj.read(self.sensor_def) + except pysaj.UnauthorizedException as err: + # On ethernet an unauthorized response usually means a wrong + # connection type, which is a recoverable connection problem. + if self._wifi: + raise ConfigEntryAuthFailed("Authentication failed") from err + raise UpdateFailed("Wrong connection type or cannot connect") from err + except (pysaj.UnexpectedResponseException, TimeoutError, OSError) as err: + raise UpdateFailed(f"Error communicating with the inverter: {err}") from err + + if not success: + raise UpdateFailed("Failed to read sensor data from the inverter") + + return self.sensor_def diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index be0b81fa44e987..e7d26f8942d416 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -1,6 +1,5 @@ """SAJ solar inverter interface.""" -from datetime import date from typing import override import pysaj @@ -25,7 +24,7 @@ UnitOfTemperature, UnitOfTime, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity_platform import ( @@ -33,10 +32,10 @@ AddEntitiesCallback, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -from homeassistant.util import dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SAJConfigEntry, SAJRuntimeData from .const import CONNECTION_TYPES, DOMAIN, INTEGRATION_TITLE +from .coordinator import SAJConfigEntry, SAJDataUpdateCoordinator SAJ_UNIT_MAPPINGS = { "": None, @@ -64,16 +63,13 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SAJ sensors from a config entry.""" - runtime = entry.runtime_data - sensor_def = runtime.sensor_def + coordinator = entry.runtime_data - hass_sensors = [ - SAJsensor(runtime, entry.unique_id, sensor, inverter_name=None) - for sensor in sensor_def + async_add_entities( + SAJsensor(coordinator, entry.unique_id, sensor) + for sensor in coordinator.sensor_def if sensor.enabled - ] - - async_add_entities(hass_sensors) + ) async def async_setup_platform( @@ -123,25 +119,18 @@ async def async_setup_platform( ) -class SAJsensor(SensorEntity): +class SAJsensor(CoordinatorEntity[SAJDataUpdateCoordinator], SensorEntity): """Representation of a SAJ sensor.""" - _attr_should_poll = False - _state: StateType - def __init__( self, - runtime: SAJRuntimeData, + coordinator: SAJDataUpdateCoordinator, serialnumber: str | None, pysaj_sensor: pysaj.Sensor, - inverter_name: str | None = None, ) -> None: """Initialize the SAJ sensor.""" - self._runtime = runtime + super().__init__(coordinator) self._sensor = pysaj_sensor - self._inverter_name = inverter_name - self._serialnumber = serialnumber - self._state = self._sensor.value if pysaj_sensor.name in ("current_power", "temperature"): self._attr_state_class = SensorStateClass.MEASUREMENT @@ -151,10 +140,7 @@ def __init__( self._attr_unique_id = f"{serialnumber}_{pysaj_sensor.name}" native_uom = SAJ_UNIT_MAPPINGS[pysaj_sensor.unit] self._attr_native_unit_of_measurement = native_uom - if self._inverter_name: - self._attr_name = f"saj_{self._inverter_name}_{pysaj_sensor.name}" - else: - self._attr_name = f"saj_{pysaj_sensor.name}" + self._attr_name = f"saj_{pysaj_sensor.name}" if native_uom == UnitOfPower.WATT: self._attr_device_class = SensorDeviceClass.POWER if native_uom == UnitOfEnergy.KILO_WATT_HOUR: @@ -165,53 +151,8 @@ def __init__( ): self._attr_device_class = SensorDeviceClass.TEMPERATURE - @override - async def async_added_to_hass(self) -> None: - """Register for inverter poll updates.""" - await super().async_added_to_hass() - self.async_on_remove( - self._runtime.polling.async_add_poll_listener(self._on_poll_success) - ) - @property @override def native_value(self) -> StateType: """Return the state of the sensor.""" - return self._state - - @property - def per_day_basis(self) -> bool: - """Return if the sensors value is on daily basis or not.""" - return self._sensor.per_day_basis - - @property - def per_total_basis(self) -> bool: - """Return if the sensors value is cumulative or not.""" - return self._sensor.per_total_basis - - @property - def date_updated(self) -> date: - """Return the date when the sensor was last updated.""" - return self._sensor.date - - @callback - def _on_poll_success(self, success: bool) -> None: - """Update state from the inverter after a poll.""" - state_unknown = False - if not success and ( - (self.per_day_basis and dt_util.now().date() > self.date_updated) - or (not self.per_day_basis and not self.per_total_basis) - ): - state_unknown = True - - update = False - if self._sensor.value != self._state: - update = True - self._state = self._sensor.value - - if state_unknown and self._state is not None: - update = True - self._state = None - - if update: - self.async_write_ha_state() + return self._sensor.value diff --git a/homeassistant/components/sftp_storage/manifest.json b/homeassistant/components/sftp_storage/manifest.json index c206bd13811f60..2c98c3a7238668 100644 --- a/homeassistant/components/sftp_storage/manifest.json +++ b/homeassistant/components/sftp_storage/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["asyncssh==2.21.0"] + "requirements": ["asyncssh==2.23.1"] } diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index bfaf3458a9e321..3df302e6edca5f 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.52", "sqlparse==0.5.5"] + "requirements": ["SQLAlchemy==2.0.52", "sqlparse==0.6.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3aa9230ec35160..9a63ad90eb62fb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -72,7 +72,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.9 +uv==0.12.10 webrtc-models==0.3.0 yarl==1.24.5 zeroconf==0.151.3 diff --git a/pyproject.toml b/pyproject.toml index 8a2440f3a6f36c..f3013f6ba286db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ dependencies = [ "typing-extensions>=4.16.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.12.9", + "uv==0.12.10", "probatio==0.11.4", "yarl==1.24.5", "webrtc-models==0.3.0", diff --git a/requirements.txt b/requirements.txt index d1a3b660e805b4..267866fb93a389 100644 --- a/requirements.txt +++ b/requirements.txt @@ -57,7 +57,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.9 +uv==0.12.10 webrtc-models==0.3.0 yarl==1.24.5 zeroconf==0.151.3 diff --git a/requirements_all.txt b/requirements_all.txt index 59ea9e257ebb87..a885b555f30b5a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -260,7 +260,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==46.4.0 +aioesphomeapi==46.4.1 # homeassistant.components.matrix # homeassistant.components.slack @@ -603,7 +603,7 @@ asyncpysupla==0.0.5 asyncsleepiq==1.7.1 # homeassistant.components.sftp_storage -asyncssh==2.21.0 +asyncssh==2.23.1 # homeassistant.components.aurora auroranoaa==0.0.5 @@ -1235,7 +1235,7 @@ guntamatic==1.12.0 guppy3==3.1.7 # homeassistant.components.iaqualink -h2==4.3.0 +h2==4.4.1 # homeassistant.components.ffmpeg ha-ffmpeg==3.2.2 @@ -1587,7 +1587,7 @@ mbddns==0.1.2 # homeassistant.components.mcp # homeassistant.components.mcp_server -mcp==1.26.0 +mcp==1.28.1 # homeassistant.components.minecraft_server mcstatus==13.1.0 @@ -2714,7 +2714,7 @@ python-digitalocean==1.13.2 python-dropbox-api==0.1.4 # homeassistant.components.duco -python-duco-connectivity==0.15.0 +python-duco-connectivity==0.16.0 # homeassistant.components.ecobee python-ecobee-api==0.4.1 @@ -2941,7 +2941,7 @@ qbittorrent-api==2026.5.1 qbusmqttapi==1.5.1 # homeassistant.components.qingping -qingping-ble==1.1.5 +qingping-ble==1.1.6 # homeassistant.components.qnap qnapstats==0.4.0 @@ -3162,7 +3162,7 @@ speedtest-cli==2.1.3 spotifyaio==2.0.2 # homeassistant.components.sql -sqlparse==0.5.5 +sqlparse==0.6.0 # homeassistant.components.srp_energy srpenergy==1.3.8 diff --git a/tests/components/besen/conftest.py b/tests/components/besen/conftest.py index 9de1e42b0e0765..8f79e287e6a79a 100644 --- a/tests/components/besen/conftest.py +++ b/tests/components/besen/conftest.py @@ -80,6 +80,12 @@ def charger_state( if charge is not None else ChargeStatus( charger_status=charger_status, + error_details="No Error", + charging_status="Start", + charging_status_description="EV is connected, please press start", + plug_state="Connected Locked", + output_state="Charging", + current_state="Charging", power=3500, total_energy=12.3, session_energy=1.2, diff --git a/tests/components/besen/snapshots/test_sensor.ambr b/tests/components/besen/snapshots/test_sensor.ambr index ef3c9a4baf5cc6..423723bf38c826 100644 --- a/tests/components/besen/snapshots/test_sensor.ambr +++ b/tests/components/besen/snapshots/test_sensor.ambr @@ -1,4 +1,78 @@ # serializer version: 1 +# name: test_sensor_state[single_phase][sensor.garage_charging_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'ev_connected_press_start', + 'charging', + 'waiting_for_ev', + 'charging_completed', + 'charging_reservation', + 'plug_not_connected', + 'see_error_state', + 'waiting_for_swipe', + 'waiting_for_button', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_charging_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging message', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging message', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_message', + 'unique_id': 'AA:BB_charging_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_charging_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Garage Charging message', + : list([ + 'ev_connected_press_start', + 'charging', + 'waiting_for_ev', + 'charging_completed', + 'charging_reservation', + 'plug_not_connected', + 'see_error_state', + 'waiting_for_swipe', + 'waiting_for_button', + ]), + }), + 'context': , + 'entity_id': 'sensor.garage_charging_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ev_connected_press_start', + }) +# --- # name: test_sensor_state[single_phase][sensor.garage_charging_power-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -57,6 +131,250 @@ 'state': '3500', }) # --- +# name: test_sensor_state[single_phase][sensor.garage_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'start', + 'finish_charging', + 'waiting', + 'finished', + 'canceled', + 'connect', + 'fault', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'AA:BB_charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Garage Charging status', + : list([ + 'start', + 'finish_charging', + 'waiting', + 'finished', + 'canceled', + 'connect', + 'fault', + ]), + }), + 'context': , + 'entity_id': 'sensor.garage_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'start', + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_current_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'fault', + 'charging_fault_1', + 'charging_fault_2', + 'waiting_for_swipe', + 'waiting_for_button', + 'not_connected', + 'ready_to_charge', + 'charging', + 'completed', + 'completed_full_charge', + 'charging_reservation', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.garage_current_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current state', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_state', + 'unique_id': 'AA:BB_current_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_current_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Garage Current state', + : list([ + 'fault', + 'charging_fault_1', + 'charging_fault_2', + 'waiting_for_swipe', + 'waiting_for_button', + 'not_connected', + 'ready_to_charge', + 'charging', + 'completed', + 'completed_full_charge', + 'charging_reservation', + ]), + }), + 'context': , + 'entity_id': 'sensor.garage_current_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charging', + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_error_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'relay_stick_error', + 'offline', + 'cc_error', + 'cp_error', + 'emergency_stop', + 'over_temperature', + 'leakage_protection', + 'short_circuit', + 'over_current', + 'ungrounded', + 'over_voltage', + 'low_voltage', + 'input_power_error', + 'dlb_over_current', + 'diode_short_circuit', + 'rtc_failure', + 'flash_memory_failure', + 'eeprom_failure', + 'metering_module_failure', + 'no_error', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.garage_error_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Error state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error state', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'error_state', + 'unique_id': 'AA:BB_error_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_error_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Garage Error state', + : list([ + 'relay_stick_error', + 'offline', + 'cc_error', + 'cp_error', + 'emergency_stop', + 'over_temperature', + 'leakage_protection', + 'short_circuit', + 'over_current', + 'ungrounded', + 'over_voltage', + 'low_voltage', + 'input_power_error', + 'dlb_over_current', + 'diode_short_circuit', + 'rtc_failure', + 'flash_memory_failure', + 'eeprom_failure', + 'metering_module_failure', + 'no_error', + ]), + }), + 'context': , + 'entity_id': 'sensor.garage_error_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- # name: test_sensor_state[single_phase][sensor.garage_external_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -289,6 +607,128 @@ 'state': '230.0', }) # --- +# name: test_sensor_state[single_phase][sensor.garage_output_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'charging', + 'idle', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.garage_output_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Output state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output state', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'output_state', + 'unique_id': 'AA:BB_output_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_output_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Garage Output state', + : list([ + 'charging', + 'idle', + ]), + }), + 'context': , + 'entity_id': 'sensor.garage_output_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charging', + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_plug_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'disconnected', + 'connected_unlocked', + 'connected_locked', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.garage_plug_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Plug state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug state', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'AA:BB_plug_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_plug_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Garage Plug state', + : list([ + 'disconnected', + 'connected_unlocked', + 'connected_locked', + ]), + }), + 'context': , + 'entity_id': 'sensor.garage_plug_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'connected_locked', + }) +# --- # name: test_sensor_state[single_phase][sensor.garage_session_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -405,6 +845,80 @@ 'state': '12.3', }) # --- +# name: test_sensor_state[three_phase][sensor.garage_charging_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'ev_connected_press_start', + 'charging', + 'waiting_for_ev', + 'charging_completed', + 'charging_reservation', + 'plug_not_connected', + 'see_error_state', + 'waiting_for_swipe', + 'waiting_for_button', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_charging_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging message', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging message', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_message', + 'unique_id': 'AA:BB_charging_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_charging_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Garage Charging message', + : list([ + 'ev_connected_press_start', + 'charging', + 'waiting_for_ev', + 'charging_completed', + 'charging_reservation', + 'plug_not_connected', + 'see_error_state', + 'waiting_for_swipe', + 'waiting_for_button', + ]), + }), + 'context': , + 'entity_id': 'sensor.garage_charging_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ev_connected_press_start', + }) +# --- # name: test_sensor_state[three_phase][sensor.garage_charging_power-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -463,6 +977,250 @@ 'state': '3500', }) # --- +# name: test_sensor_state[three_phase][sensor.garage_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'start', + 'finish_charging', + 'waiting', + 'finished', + 'canceled', + 'connect', + 'fault', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garage_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'AA:BB_charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Garage Charging status', + : list([ + 'start', + 'finish_charging', + 'waiting', + 'finished', + 'canceled', + 'connect', + 'fault', + ]), + }), + 'context': , + 'entity_id': 'sensor.garage_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'start', + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_current_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'fault', + 'charging_fault_1', + 'charging_fault_2', + 'waiting_for_swipe', + 'waiting_for_button', + 'not_connected', + 'ready_to_charge', + 'charging', + 'completed', + 'completed_full_charge', + 'charging_reservation', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.garage_current_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current state', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_state', + 'unique_id': 'AA:BB_current_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_current_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Garage Current state', + : list([ + 'fault', + 'charging_fault_1', + 'charging_fault_2', + 'waiting_for_swipe', + 'waiting_for_button', + 'not_connected', + 'ready_to_charge', + 'charging', + 'completed', + 'completed_full_charge', + 'charging_reservation', + ]), + }), + 'context': , + 'entity_id': 'sensor.garage_current_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charging', + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_error_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'relay_stick_error', + 'offline', + 'cc_error', + 'cp_error', + 'emergency_stop', + 'over_temperature', + 'leakage_protection', + 'short_circuit', + 'over_current', + 'ungrounded', + 'over_voltage', + 'low_voltage', + 'input_power_error', + 'dlb_over_current', + 'diode_short_circuit', + 'rtc_failure', + 'flash_memory_failure', + 'eeprom_failure', + 'metering_module_failure', + 'no_error', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.garage_error_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Error state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error state', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'error_state', + 'unique_id': 'AA:BB_error_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_error_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Garage Error state', + : list([ + 'relay_stick_error', + 'offline', + 'cc_error', + 'cp_error', + 'emergency_stop', + 'over_temperature', + 'leakage_protection', + 'short_circuit', + 'over_current', + 'ungrounded', + 'over_voltage', + 'low_voltage', + 'input_power_error', + 'dlb_over_current', + 'diode_short_circuit', + 'rtc_failure', + 'flash_memory_failure', + 'eeprom_failure', + 'metering_module_failure', + 'no_error', + ]), + }), + 'context': , + 'entity_id': 'sensor.garage_error_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- # name: test_sensor_state[three_phase][sensor.garage_external_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -927,6 +1685,128 @@ 'state': '232.0', }) # --- +# name: test_sensor_state[three_phase][sensor.garage_output_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'charging', + 'idle', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.garage_output_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Output state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output state', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'output_state', + 'unique_id': 'AA:BB_output_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_output_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Garage Output state', + : list([ + 'charging', + 'idle', + ]), + }), + 'context': , + 'entity_id': 'sensor.garage_output_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charging', + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_plug_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'disconnected', + 'connected_unlocked', + 'connected_locked', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.garage_plug_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Plug state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug state', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'AA:BB_plug_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_plug_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Garage Plug state', + : list([ + 'disconnected', + 'connected_unlocked', + 'connected_locked', + ]), + }), + 'context': , + 'entity_id': 'sensor.garage_plug_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'connected_locked', + }) +# --- # name: test_sensor_state[three_phase][sensor.garage_session_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/besen/test_sensor.py b/tests/components/besen/test_sensor.py index 27495e588b2b67..12576814fefb38 100644 --- a/tests/components/besen/test_sensor.py +++ b/tests/components/besen/test_sensor.py @@ -2,10 +2,26 @@ from unittest.mock import Mock +from besen.const import ( + CHARGING_STATUS, + CHARGING_STATUS_DESCRIPTIONS, + CURRENT_STATE, + ERRORS, + OUTPUT_STATE, + PLUG_STATE, +) from besen.models import ChargeStatus import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.besen.sensor import ( + CHARGING_MESSAGES, + CHARGING_STATES, + CURRENT_STATES, + ERROR_STATES, + OUTPUT_STATES, + PLUG_STATES, +) from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -21,6 +37,8 @@ from tests.common import MockConfigEntry, snapshot_platform POWER_ENTITY_ID = "sensor.garage_charging_power" +CHARGING_STATUS_ENTITY_ID = "sensor.garage_charging_status" +CHARGING_MESSAGE_ENTITY_ID = "sensor.garage_charging_message" @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -42,6 +60,7 @@ async def test_sensor_state( mock_besen_client.async_start.assert_awaited_once() +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_updates_from_client( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -55,6 +74,12 @@ async def test_sensor_updates_from_client( mock_besen_client, charger_state( charge=ChargeStatus( + error_details="Emergency Stop", + charging_status="Fault", + charging_status_description="See Error State", + plug_state="Disconnected", + output_state="Idle", + current_state="Ready to charge", power=7200, total_energy=123.45, session_energy=4.56, @@ -72,6 +97,18 @@ async def test_sensor_updates_from_client( assert state.state == "4.56" assert (state := hass.states.get("sensor.garage_internal_temperature")) is not None assert state.state == "26.5" + assert (state := hass.states.get(CHARGING_STATUS_ENTITY_ID)) is not None + assert state.state == "fault" + assert (state := hass.states.get(CHARGING_MESSAGE_ENTITY_ID)) is not None + assert state.state == "see_error_state" + assert (state := hass.states.get("sensor.garage_error_state")) is not None + assert state.state == "emergency_stop" + assert (state := hass.states.get("sensor.garage_plug_state")) is not None + assert state.state == "disconnected" + assert (state := hass.states.get("sensor.garage_output_state")) is not None + assert state.state == "idle" + assert (state := hass.states.get("sensor.garage_current_state")) is not None + assert state.state == "ready_to_charge" async def test_sensor_unknown_value( @@ -88,6 +125,60 @@ async def test_sensor_unknown_value( assert (state := hass.states.get(POWER_ENTITY_ID)) is not None assert state.state == STATE_UNKNOWN + assert (state := hass.states.get(CHARGING_STATUS_ENTITY_ID)) is not None + assert state.state == STATE_UNKNOWN + assert (state := hass.states.get(CHARGING_MESSAGE_ENTITY_ID)) is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "value", + [ + pytest.param(None, id="missing"), + pytest.param("Unexpected", id="unsupported"), + pytest.param("Unknown", id="unknown"), + *( + pytest.param(f"Unknown {index}", id=f"unknown_{index}") + for index in range(11) + ), + ], +) +async def test_enum_sensors_unknown_for_unsupported_values( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, + value: str | None, +) -> None: + """Test missing and undocumented protocol values clear the previous state.""" + + await setup_integration(hass, mock_config_entry, [Platform.SENSOR]) + + publish_besen_state( + mock_besen_client, + charger_state( + charge=ChargeStatus( + error_details=value, + charging_status=value, + charging_status_description=value, + plug_state=value, + output_state=value, + current_state=value, + ) + ), + ) + await hass.async_block_till_done() + + for entity_id in ( + CHARGING_STATUS_ENTITY_ID, + CHARGING_MESSAGE_ENTITY_ID, + "sensor.garage_error_state", + "sensor.garage_plug_state", + "sensor.garage_output_state", + "sensor.garage_current_state", + ): + assert (state := hass.states.get(entity_id)) is not None + assert state.state == STATE_UNKNOWN @pytest.mark.parametrize( @@ -139,12 +230,16 @@ async def test_diagnostic_sensors_disabled_by_default( } assert set(diagnostic_entries) == { f"{mock_besen_client.address}_external_temperature", + f"{mock_besen_client.address}_error_state", + f"{mock_besen_client.address}_current_state", f"{mock_besen_client.address}_l1_current", f"{mock_besen_client.address}_l1_voltage", f"{mock_besen_client.address}_l2_current", f"{mock_besen_client.address}_l2_voltage", f"{mock_besen_client.address}_l3_current", f"{mock_besen_client.address}_l3_voltage", + f"{mock_besen_client.address}_output_state", + f"{mock_besen_client.address}_plug_state", } for entry in diagnostic_entries.values(): assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION @@ -178,3 +273,20 @@ async def test_three_phase_sensor_filtering( f"{mock_besen_client.address}_l3_current", } assert three_phase_unique_ids.issubset(unique_ids) is expected + + +def test_enum_sensor_options_cover_known_library_states() -> None: + """Test every known library state has a stable Home Assistant option.""" + + assert set(ERROR_STATES) == set(ERRORS.values()) - {"Unknown"} + assert set(CHARGING_STATES) == set(CHARGING_STATUS.values()) + assert set(CHARGING_MESSAGES) == set(CHARGING_STATUS_DESCRIPTIONS.values()) + assert set(PLUG_STATES) == set(PLUG_STATE) - { + f"Unknown {index}" for index in range(6) + } + assert set(OUTPUT_STATES) == set(OUTPUT_STATE) - { + f"Unknown {index}" for index in range(7) + } + assert set(CURRENT_STATES) == set(CURRENT_STATE) - { + f"Unknown {index}" for index in range(1, 11) + } diff --git a/tests/components/collection_image/helpers.py b/tests/components/collection_image/helpers.py index e13741052584e9..9e14f8d1d53163 100644 --- a/tests/components/collection_image/helpers.py +++ b/tests/components/collection_image/helpers.py @@ -7,15 +7,23 @@ from tests.common import MockConfigEntry -def config_entry_from_uri(uri: str) -> MockConfigEntry: - """From a uri, construct a config entry.""" +def config_entry_from_uri(uri: str | list[str]) -> MockConfigEntry: + """Construct a mock config entry from one URI or a list of URIs.""" + + def media_item(content_id: str) -> dict[str, str]: + return { + "media_content_id": content_id, + "media_content_type": "", + } + + media: dict[str, str] | list[dict[str, str]] + if isinstance(uri, str): + media = media_item(uri) + else: + media = [media_item(item) for item in uri] + return MockConfigEntry( - data={ - "media": { - "media_content_id": uri, - "media_content_type": "", - }, - }, + data={"media": media}, domain=DOMAIN, title="Random Image", ) diff --git a/tests/components/collection_image/test_config_flow.py b/tests/components/collection_image/test_config_flow.py index fdc365fc781250..f832ec33c99dca 100644 --- a/tests/components/collection_image/test_config_flow.py +++ b/tests/components/collection_image/test_config_flow.py @@ -5,12 +5,14 @@ import pytest from homeassistant import config_entries +from homeassistant.components.collection_image.config_flow import IMAGE_MEDIA_URI from homeassistant.components.collection_image.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from .const import ( MOCK_MEDIA_DIR_URI_1, + MOCK_MEDIA_DIR_URI_2, MOCK_MEDIA_DIR_URI_BROWSE_ERROR, MOCK_MEDIA_DIR_URI_EMPTY, ) @@ -27,18 +29,31 @@ def mock_setup_entry(): yield mock_setup -def _data_from_uri(uri: str) -> dict: +def _data_from_uris(uris: list[str]) -> dict: return { - "media": { - "media_content_id": uri, - "media_content_type": "", - "metadata": {"a": "b"}, - } + "media": [ + { + "media_content_id": uri, + "media_content_type": "", + "metadata": {"a": "b"}, + } + for uri in uris + ] } +@pytest.mark.parametrize( + ("uris", "expected_title"), + [ + ([MOCK_MEDIA_DIR_URI_1], "My pictures collection"), + ([MOCK_MEDIA_DIR_URI_1, MOCK_MEDIA_DIR_URI_2], "My pictures collection"), + ([MOCK_MEDIA_DIR_URI_2, MOCK_MEDIA_DIR_URI_1], "Three pictures collection"), + ], +) @pytest.mark.usefixtures("mock_media_source") -async def test_config_flow(hass: HomeAssistant, mock_setup_entry) -> None: +async def test_config_flow( + hass: HomeAssistant, mock_setup_entry, uris: list[str], expected_title: str +) -> None: """Test the config flow.""" result = await hass.config_entries.flow.async_init( @@ -47,8 +62,7 @@ async def test_config_flow(hass: HomeAssistant, mock_setup_entry) -> None: assert result.get("type") is FlowResultType.FORM assert result.get("errors") == {} - data = _data_from_uri(MOCK_MEDIA_DIR_URI_1) - expected_title = "My pictures collection" + data = _data_from_uris(uris) result = await hass.config_entries.flow.async_configure(result["flow_id"], data) @@ -59,25 +73,44 @@ async def test_config_flow(hass: HomeAssistant, mock_setup_entry) -> None: @pytest.mark.parametrize( - ("uri", "error", "placeholders"), + ("uris", "error", "placeholders"), [ ( - MOCK_MEDIA_DIR_URI_EMPTY, + [MOCK_MEDIA_DIR_URI_EMPTY], "selected_media_no_images", {}, ), ( - MOCK_MEDIA_DIR_URI_BROWSE_ERROR, + [MOCK_MEDIA_DIR_URI_EMPTY, MOCK_MEDIA_DIR_URI_EMPTY], + "selected_media_no_images", + {}, + ), + ( + [MOCK_MEDIA_DIR_URI_BROWSE_ERROR], + "failed_browse", + {"error": "Mock directory failed to browse"}, + ), + ( + [ + MOCK_MEDIA_DIR_URI_1, + MOCK_MEDIA_DIR_URI_EMPTY, + MOCK_MEDIA_DIR_URI_BROWSE_ERROR, + ], "failed_browse", {"error": "Mock directory failed to browse"}, ), + ( + [MOCK_MEDIA_DIR_URI_1, IMAGE_MEDIA_URI], + "invalid_selection", + {"error": IMAGE_MEDIA_URI}, + ), ], ) @pytest.mark.usefixtures("mock_media_source") async def test_config_flow_error( hass: HomeAssistant, mock_setup_entry, - uri: str, + uris: list[str], error: str, placeholders: dict, ) -> None: @@ -89,7 +122,7 @@ async def test_config_flow_error( assert result.get("type") is FlowResultType.FORM assert result.get("errors") == {} - data = _data_from_uri(uri) + data = _data_from_uris(uris) result = await hass.config_entries.flow.async_configure(result["flow_id"], data) await hass.async_block_till_done() @@ -102,18 +135,19 @@ async def test_config_flow_error( for key in result["data_schema"].schema if getattr(key, "schema", key) == "media" ) - assert media_key.description["suggested_value"]["media_content_id"] == uri - assert ( - media_key.description["suggested_value"]["metadata"] - == data["media"]["metadata"] - ) + for idx, uri in enumerate(uris): + assert media_key.description["suggested_value"][idx]["media_content_id"] == uri + assert ( + media_key.description["suggested_value"][idx]["metadata"] + == data["media"][idx]["metadata"] + ) assert result.get("errors") == {"media": error} assert result.get("description_placeholders") == placeholders assert len(mock_setup_entry.mock_calls) == 0 # Try again successfully to ensure we can recover from errors - data = _data_from_uri(MOCK_MEDIA_DIR_URI_1) + data = _data_from_uris([MOCK_MEDIA_DIR_URI_1]) expected_title = "My pictures collection" result = await hass.config_entries.flow.async_configure(result["flow_id"], data) diff --git a/tests/components/collection_image/test_image.py b/tests/components/collection_image/test_image.py index 1bd470d93f863c..05a3a6206059c8 100644 --- a/tests/components/collection_image/test_image.py +++ b/tests/components/collection_image/test_image.py @@ -22,6 +22,7 @@ from .conftest import MediaSourceMocks, MediaSourceState from .const import ( DEFAULT_ENTITY_ID, + MOCK_MEDIA_DIR_URI_1, MOCK_MEDIA_DIR_URI_2, MOCK_MEDIA_DIR_URI_BROWSE_ERROR, MOCK_MEDIA_DIR_URI_EMPTY, @@ -76,6 +77,80 @@ async def test_image( await _verify_path_image(hass, hass_client) +@pytest.mark.usefixtures("mock_media_source") +@pytest.mark.parametrize( + ("uris", "expected_images"), + [ + ( + [MOCK_MEDIA_DIR_URI_1, MOCK_MEDIA_DIR_URI_2], + [ + MOCK_MEDIA_IMAGE_URI_1, + MOCK_MEDIA_IMAGE_URI_2, + MOCK_MEDIA_IMAGE_URI_3, + MOCK_MEDIA_IMAGE_URI_4, + ], + ), + ( + [MOCK_MEDIA_DIR_URI_2, MOCK_MEDIA_DIR_URI_1], + [ + MOCK_MEDIA_IMAGE_URI_2, + MOCK_MEDIA_IMAGE_URI_3, + MOCK_MEDIA_IMAGE_URI_4, + MOCK_MEDIA_IMAGE_URI_1, + ], + ), + ( + [MOCK_MEDIA_DIR_URI_1, MOCK_MEDIA_DIR_URI_BROWSE_ERROR], + [MOCK_MEDIA_IMAGE_URI_1], + ), + ( + [ + MOCK_MEDIA_DIR_URI_BROWSE_ERROR, + MOCK_MEDIA_DIR_URI_1, + MOCK_MEDIA_DIR_URI_EMPTY, + ], + [MOCK_MEDIA_IMAGE_URI_1], + ), + ( + [ + MOCK_MEDIA_DIR_URI_EMPTY, + MOCK_MEDIA_DIR_URI_1, + ], + [MOCK_MEDIA_IMAGE_URI_1], + ), + ], +) +async def test_image_multi( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + media_source_state: MediaSourceState, + uris: list[str], + expected_images: list[str], +) -> None: + """Test multiple media sources.""" + with ( + freeze_time(TEST_TIME), + patch( + "homeassistant.components.collection_image.image.random.choice", + return_value=media_source_state.browse_results[ + MOCK_MEDIA_DIR_URI_2 + ].children[2], + ) as mock_choice, + ): + config_entry = config_entry_from_uri(uris) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert [ + image.media_content_id for image in mock_choice.call_args.args[0] + ] == expected_images + state = hass.states.get(DEFAULT_ENTITY_ID) + assert state and state.state == TEST_TIME + await _verify_path_image(hass, hass_client) + + async def test_image_during_startup( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -152,42 +227,36 @@ async def test_image_url( @pytest.mark.usefixtures("mock_media_source") -async def test_no_images( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test when there are no images in the media folder.""" - config_entry = config_entry_from_uri(MOCK_MEDIA_DIR_URI_EMPTY) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get(DEFAULT_ENTITY_ID) - - assert state and state.state == STATE_UNAVAILABLE - - await hass.async_block_till_done(wait_background_tasks=True) - - assert ( - f"image.random_image: No valid images in {MOCK_MEDIA_DIR_URI_EMPTY}" - in caplog.text - ) - - client = await hass_client() - resp = await client.get(f"/api/image_proxy/{DEFAULT_ENTITY_ID}") - assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR - - -@pytest.mark.usefixtures("mock_media_source") +@pytest.mark.parametrize( + ("uris", "error_messages"), + [ + ( + MOCK_MEDIA_DIR_URI_EMPTY, + [f"image.random_image: No valid images in {MOCK_MEDIA_DIR_URI_EMPTY}"], + ), + ( + [MOCK_MEDIA_DIR_URI_BROWSE_ERROR], + ["image.random_image: Mock directory failed to browse"], + ), + ( + [MOCK_MEDIA_DIR_URI_EMPTY, MOCK_MEDIA_DIR_URI_BROWSE_ERROR], + [ + f"image.random_image: No valid images in {MOCK_MEDIA_DIR_URI_EMPTY}", + "image.random_image: Mock directory failed to browse", + ], + ), + ], +) async def test_media_error( hass: HomeAssistant, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, + uris: str | list[str], + error_messages: list[str], ) -> None: - """Test when media browse throws an error.""" + """Test various cases where media fails to browse images.""" - config_entry = config_entry_from_uri(MOCK_MEDIA_DIR_URI_BROWSE_ERROR) + config_entry = config_entry_from_uri(uris) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -198,7 +267,8 @@ async def test_media_error( await hass.async_block_till_done(wait_background_tasks=True) - assert "image.random_image: Mock directory failed to browse" in caplog.text + for err in error_messages: + assert err in caplog.text client = await hass_client() resp = await client.get(f"/api/image_proxy/{DEFAULT_ENTITY_ID}") diff --git a/tests/components/fyta/snapshots/test_sensor.ambr b/tests/components/fyta/snapshots/test_sensor.ambr index 845760a49bf96f..6aa119b5222774 100644 --- a/tests/components/fyta/snapshots/test_sensor.ambr +++ b/tests/components/fyta/snapshots/test_sensor.ambr @@ -105,6 +105,57 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[sensor.gummibaum_last_update-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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gummibaum_last_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Last update', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last update', + 'platform': 'fyta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_update', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-last_updated', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.gummibaum_last_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Gummibaum Last update', + }), + 'context': , + 'entity_id': 'sensor.gummibaum_last_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-01-10T18:10:00+00:00', + }) +# --- # name: test_all_entities[sensor.gummibaum_light-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1011,6 +1062,57 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[sensor.kakaobaum_last_update-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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.kakaobaum_last_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Last update', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last update', + 'platform': 'fyta', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_update', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-last_updated', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.kakaobaum_last_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Kakaobaum Last update', + }), + 'context': , + 'entity_id': 'sensor.kakaobaum_last_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-01-02T18:10:00+00:00', + }) +# --- # name: test_all_entities[sensor.kakaobaum_light-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json index cfa932c3890c0e..e3a10f5551dbaf 100644 --- a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json +++ b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json @@ -665,6 +665,93 @@ "type": "TILT_VIBRATION_SENSOR", "updateState": "UP_TO_DATE" }, + "3014F711000000000000TACO": { + "availableFirmwareVersion": "1.2.4", + "connectionType": "HMIP_RF", + "firmwareVersion": "1.2.4", + "firmwareVersionInteger": 66052, + "functionalChannels": { + "0": { + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "deviceId": "3014F711000000000000TACO", + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "dutyCycle": false, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": [], + "index": 0, + "label": "", + "lowBat": false, + "multicastRoutingEnabled": false, + "powerShortCircuit": null, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -80, + "rssiPeerValue": null, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureDeviceTemperatureOutOfRange": true, + "IFeatureRssiValue": true, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureLowBat": true + }, + "temperatureOutOfRange": false, + "unreach": false + }, + "1": { + "actualTemperature": 22.9, + "channelRole": "WEATHER_SENSOR", + "deviceId": "3014F711000000000000TACO", + "functionalChannelType": "TEMPERATURE_SENSOR_CHANNEL", + "groupIndex": 1, + "groups": [], + "index": 1, + "label": "" + }, + "2": { + "absoluteAngle": 92, + "accelerationSensorEventFilterPeriod": 3.0, + "accelerationSensorMode": "ANY_MOTION", + "accelerationSensorNeutralPosition": "HORIZONTAL", + "accelerationSensorSecondTriggerAngle": 75, + "accelerationSensorSensitivity": "SENSOR_RANGE_2G", + "accelerationSensorTriggerAngle": 20, + "accelerationSensorTriggered": true, + "channelRole": "ACCELERATION_SENSOR", + "deviceId": "3014F711000000000000TACO", + "functionalChannelType": "TILT_VIBRATION_SENSOR_CHANNEL", + "groupIndex": 2, + "groups": [], + "index": 2, + "label": "", + "supportedOptionalFeatures": { + "IOptionalFeatureTiltDetection": true + }, + "tiltState": "NON_NEUTRAL", + "tiltVisualization": "GENERIC" + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F711000000000000TACO", + "label": "Wassertemperatursensor", + "lastStatusUpdate": 1598610615630, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manufacturerCode": 1, + "modelId": 546, + "modelType": "ELV-SH-TACO", + "oem": "eQ-3", + "permanentlyReachable": false, + "serializedGlobalTradeItemNumber": "3014F711000000000000TACO", + "type": "TEMPERATURE_TILT_VIBRATION_SENSOR", + "updateState": "UP_TO_DATE" + }, "3014F711000WIREDSWITCH8": { "availableFirmwareVersion": "0.0.0", "connectionType": "HMIP_WIRED", diff --git a/tests/components/homematicip_cloud/test_binary_sensor.py b/tests/components/homematicip_cloud/test_binary_sensor.py index b94f9ad9c9a61e..ddf03a299dbdef 100644 --- a/tests/components/homematicip_cloud/test_binary_sensor.py +++ b/tests/components/homematicip_cloud/test_binary_sensor.py @@ -32,6 +32,7 @@ ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics @@ -228,6 +229,31 @@ async def test_hmip_tilt_vibration_sensor( assert len(hmip_device.mock_calls) == service_call_counter + 2 +async def test_hmip_temperature_tilt_vibration_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + default_mock_hap_factory: HomeFactory, +) -> None: + """Test the ELV-SH-TACO, whose tilt channel sits at index 2, not 1.""" + entity_id = "binary_sensor.wassertemperatursensor_moving" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Wassertemperatursensor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, "Wassertemperatursensor Moving", "ELV-SH-TACO" + ) + assert ha_state.state == STATE_ON + + entity = entity_registry.async_get(entity_id) + assert entity.unique_id == "3014F711000000000000TACO_2_tilt_vibration" + + await async_manipulate_test_data( + hass, hmip_device, "accelerationSensorTriggered", False + ) + assert hass.states.get(entity_id).state == STATE_OFF + + async def test_hmip_contact_interface( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 0dc457147e3d58..364ff675300b54 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -23,7 +23,7 @@ async def test_hmip_load_all_supported_devices( test_devices=None, test_groups=None ) - assert len(mock_hap.hmip_device_by_entity_id) == 385 + assert len(mock_hap.hmip_device_by_entity_id) == 390 async def test_hmip_remove_device( diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index 94d58791648453..14f5cfed2710cc 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -822,6 +822,47 @@ async def test_hmip_tilt_vibration_sensor_tilt_angle( assert ha_state.state == "89" +async def test_hmip_temperature_tilt_vibration_sensor( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test the ELV-SH-TACO, whose tilt channel sits at index 2, not 1.""" + device_model = "ELV-SH-TACO" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Wassertemperatursensor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, + mock_hap, + "sensor.wassertemperatursensor_tilt_state", + "Wassertemperatursensor Tilt State", + device_model, + ) + assert ha_state.state == "non_neutral" + + await async_manipulate_test_data(hass, hmip_device, "tiltState", "TILTED", 2) + ha_state = hass.states.get("sensor.wassertemperatursensor_tilt_state") + assert ha_state.state == "tilted" + + ha_state, _ = get_and_check_entity_basics( + hass, + mock_hap, + "sensor.wassertemperatursensor_tilt_angle", + "Wassertemperatursensor Tilt angle", + device_model, + ) + assert ha_state.state == "92" + + ha_state, _ = get_and_check_entity_basics( + hass, + mock_hap, + "sensor.wassertemperatursensor_temperature", + "Wassertemperatursensor Temperature", + device_model, + ) + assert ha_state.state == "22.9" + + async def test_hmip_absolute_humidity_sensor( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/librenms/snapshots/test_sensor.ambr b/tests/components/librenms/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..95e64f0e5ef762 --- /dev/null +++ b/tests/components/librenms/snapshots/test_sensor.ambr @@ -0,0 +1,336 @@ +# serializer version: 1 +# name: test_sensors.12 + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': 'https://librenms', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'librenms', + '01KXX1E2EMMSCDQ2K4A0C7JA9T', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'LibreNMS', + 'model': None, + 'model_id': None, + 'name': 'LibreNMS', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': '26.6.1', + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[sensor.librenms_database_version-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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.librenms_database_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Database version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Database version', + 'platform': 'librenms', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'database_version', + 'unique_id': '01KXX1E2EMMSCDQ2K4A0C7JA9T_database_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.librenms_database_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LibreNMS Database version', + }), + 'context': , + 'entity_id': 'sensor.librenms_database_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'MariaDB 10.5.29-MariaDB-ubu2004', + }) +# --- +# name: test_sensors[sensor.librenms_netsnmp_version-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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.librenms_netsnmp_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'NetSNMP version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'NetSNMP version', + 'platform': 'librenms', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'netsnmp_version', + 'unique_id': '01KXX1E2EMMSCDQ2K4A0C7JA9T_netsnmp_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.librenms_netsnmp_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LibreNMS NetSNMP version', + }), + 'context': , + 'entity_id': 'sensor.librenms_netsnmp_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.9.5.2', + }) +# --- +# name: test_sensors[sensor.librenms_php_version-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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.librenms_php_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PHP version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PHP version', + 'platform': 'librenms', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'php_version', + 'unique_id': '01KXX1E2EMMSCDQ2K4A0C7JA9T_php_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.librenms_php_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LibreNMS PHP version', + }), + 'context': , + 'entity_id': 'sensor.librenms_php_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.4.21', + }) +# --- +# name: test_sensors[sensor.librenms_python_version-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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.librenms_python_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Python version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Python version', + 'platform': 'librenms', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'python_version', + 'unique_id': '01KXX1E2EMMSCDQ2K4A0C7JA9T_python_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.librenms_python_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LibreNMS Python version', + }), + 'context': , + 'entity_id': 'sensor.librenms_python_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.12.13', + }) +# --- +# name: test_sensors[sensor.librenms_rrdtool_version-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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.librenms_rrdtool_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'RRDTool version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RRDTool version', + 'platform': 'librenms', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rrdtool_version', + 'unique_id': '01KXX1E2EMMSCDQ2K4A0C7JA9T_rrdtool_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.librenms_rrdtool_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LibreNMS RRDTool version', + }), + 'context': , + 'entity_id': 'sensor.librenms_rrdtool_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.9.0', + }) +# --- +# name: test_sensors[sensor.librenms_total_device_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.librenms_total_device_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total device count', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total device count', + 'platform': 'librenms', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'device_count', + 'unique_id': '01KXX1E2EMMSCDQ2K4A0C7JA9T_device_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.librenms_total_device_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'LibreNMS Total device count', + : , + }), + 'context': , + 'entity_id': 'sensor.librenms_total_device_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- diff --git a/tests/components/librenms/test_config_flow.py b/tests/components/librenms/test_config_flow.py index de9e4d3586c511..f05e92f4bd9f89 100644 --- a/tests/components/librenms/test_config_flow.py +++ b/tests/components/librenms/test_config_flow.py @@ -8,7 +8,14 @@ from homeassistant.components.librenms.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_URL +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_URL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -80,8 +87,14 @@ async def test_step_user_error_handling( assert result["type"] is FlowResultType.CREATE_ENTRY +@pytest.mark.parametrize( + ("invalid_url"), + ["hts://invalid", "hts://invalid:123"], +) @pytest.mark.usefixtures("mock_setup_entry") -async def test_step_user_invalid_url(hass: HomeAssistant, mock_librenms: Mock) -> None: +async def test_step_user_invalid_url( + hass: HomeAssistant, mock_librenms: Mock, invalid_url: str +) -> None: """Test a user initiated config flow with errors.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -91,7 +104,7 @@ async def test_step_user_invalid_url(hass: HomeAssistant, mock_librenms: Mock) - result = await hass.config_entries.flow.async_configure( result["flow_id"], - {**MOCK_USER_DATA, CONF_URL: "hts://invalid"}, + {**MOCK_USER_DATA, CONF_URL: invalid_url}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -123,3 +136,203 @@ async def test_user_already_configured( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow( + hass: HomeAssistant, mock_librenms: Mock, mock_config_entry: MockConfigEntry +) -> None: + """Test reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "other_fake_api_key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "other_fake_api_key" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + LibrenmsUnauthenticatedError({"message": "Unauthenticated."}), + "invalid_auth", + ), + (ClientError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_error_handling( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_librenms: Mock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reauthentication flow with errors.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_librenms.system.async_get_system_info.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "other_fake_api_key", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + + mock_librenms.system.async_get_system_info.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "other_fake_api_key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "other_fake_api_key" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_flow( + hass: HomeAssistant, mock_librenms: Mock, mock_config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow.""" + 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 hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "https://librenms:8443", CONF_VERIFY_SSL: True}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == "librenms" + assert mock_config_entry.data[CONF_PORT] == 8443 + assert mock_config_entry.data[CONF_SSL] is True + assert mock_config_entry.data[CONF_VERIFY_SSL] is True + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + LibrenmsUnauthenticatedError({"message": "Unauthenticated."}), + "invalid_auth", + ), + (ClientError, "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_step_reconfigure_error_handling( + hass: HomeAssistant, + mock_librenms: Mock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test a user initiated config flow with errors.""" + 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" + + mock_librenms.system.async_get_system_info.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "https://librenms:8443", CONF_VERIFY_SSL: True}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": error} + + mock_librenms.system.async_get_system_info.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "https://librenms:8443", CONF_VERIFY_SSL: True}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_step_reconfigure_invalid_url( + hass: HomeAssistant, mock_librenms: Mock, mock_config_entry: MockConfigEntry +) -> None: + """Test a user initiated config flow with errors.""" + 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 hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "hts://invalid"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {CONF_URL: "invalid_url"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "https://librenms:8443", CONF_VERIFY_SSL: True}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_already_configured( + hass: HomeAssistant, mock_librenms: Mock, mock_config_entry: MockConfigEntry +) -> None: + """Test duplicate-reconfiguration guard.""" + mock_config_entry2 = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "librenms2", + CONF_API_KEY: "abcdef0123456789", + CONF_PORT: 8443, + CONF_SSL: True, + CONF_VERIFY_SSL: True, + }, + title="librenms2", + ) + + mock_config_entry.add_to_hass(hass) + mock_config_entry2.add_to_hass(hass) + + result = await mock_config_entry2.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "https://librenms:443", CONF_VERIFY_SSL: True}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/librenms/test_sensor.py b/tests/components/librenms/test_sensor.py new file mode 100644 index 00000000000000..f153c7e7ba02dc --- /dev/null +++ b/tests/components/librenms/test_sensor.py @@ -0,0 +1,36 @@ +"""Test the LibreNMS sensor platform.""" + +from unittest.mock import Mock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + mock_librenms: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the LibreNMS sensor platform.""" + + with patch("homeassistant.components.librenms.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert devices == snapshot diff --git a/tests/components/saj/conftest.py b/tests/components/saj/conftest.py index 772e46351d5418..54ca658e07f14b 100644 --- a/tests/components/saj/conftest.py +++ b/tests/components/saj/conftest.py @@ -1,7 +1,6 @@ """Fixtures for saj tests.""" from collections.abc import Generator -from datetime import date from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -56,9 +55,9 @@ def mock_setup_entry() -> Generator[AsyncMock]: def mock_pysaj_sensors() -> Generator[list[MagicMock]]: """Mock pysaj.Sensors across SAJ integration modules.""" sensors: list[MagicMock] = [] - for key, value, unit, per_day_basis, per_total_basis in ( - ("current_power", 5000.0, "W", False, False), - ("today_yield", 25.5, "kWh", True, False), + for key, value, unit in ( + ("current_power", 5000.0, "W"), + ("today_yield", 25.5, "kWh"), ): sensor = MagicMock() sensor.name = key @@ -66,9 +65,6 @@ def mock_pysaj_sensors() -> Generator[list[MagicMock]]: sensor.value = value sensor.unit = unit sensor.enabled = True - sensor.per_day_basis = per_day_basis - sensor.per_total_basis = per_total_basis - sensor.date = date.today() sensors.append(sensor) with ( @@ -81,10 +77,6 @@ def mock_pysaj_sensors() -> Generator[list[MagicMock]]: "homeassistant.components.saj.config_flow.pysaj.Sensors", new=sensors_cls, ), - patch( - "homeassistant.components.saj.sensor.pysaj.Sensors", - new=sensors_cls, - ), ): yield sensors @@ -106,9 +98,5 @@ def mock_pysaj_saj(mock_pysaj_sensors: list[MagicMock]) -> Generator[MagicMock]: "homeassistant.components.saj.config_flow.pysaj.SAJ", new=saj_cls, ), - patch( - "homeassistant.components.saj.sensor.pysaj.SAJ", - new=saj_cls, - ), ): yield saj_instance diff --git a/tests/components/saj/test_init.py b/tests/components/saj/test_init.py index 4bf9eb82b4f31e..901660ec4364c2 100644 --- a/tests/components/saj/test_init.py +++ b/tests/components/saj/test_init.py @@ -49,36 +49,31 @@ async def test_setup_entry_auth_failed( assert entry.state is ConfigEntryState.SETUP_ERROR -async def test_setup_entry_ethernet_unauthorized_retries( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_pysaj_saj: MagicMock, -) -> None: - """Ethernet UnauthorizedException is treated as not ready (e.g. wrong type).""" - mock_pysaj_saj.read.side_effect = pysaj.UnauthorizedException("unexpected") - entry = await setup_integration(hass, mock_config_entry) - assert entry.state is ConfigEntryState.SETUP_RETRY - - @pytest.mark.parametrize( "exception", [ - Exception("Unexpected error"), - RuntimeError("Unexpected runtime error"), + pytest.param( + pysaj.UnauthorizedException("unexpected"), id="ethernet_unauthorized" + ), + pytest.param( + pysaj.UnexpectedResponseException("bad response"), id="unexpected_response" + ), + pytest.param(TimeoutError("timed out"), id="timeout"), + pytest.param(OSError("network unreachable"), id="os_error"), + pytest.param(Exception("Unexpected error"), id="unexpected"), + pytest.param(RuntimeError("Unexpected runtime error"), id="runtime_error"), ], ) -async def test_setup_entry_unexpected_error( +async def test_setup_entry_retries( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pysaj_saj: MagicMock, exception: Exception, ) -> None: - """Test async_setup_entry handles unexpected errors.""" + """Test errors during setup result in a retry.""" mock_pysaj_saj.read.side_effect = exception entry = await setup_integration(hass, mock_config_entry) - # Truly unexpected exceptions should result in SETUP_ERROR - # so the actual error is visible rather than being hidden - assert entry.state is ConfigEntryState.SETUP_ERROR + assert entry.state is ConfigEntryState.SETUP_RETRY @pytest.mark.usefixtures("mock_pysaj_saj") diff --git a/tests/components/saj/test_sensor.py b/tests/components/saj/test_sensor.py index 674c84a95ff417..c9e84f8ed44b41 100644 --- a/tests/components/saj/test_sensor.py +++ b/tests/components/saj/test_sensor.py @@ -1,6 +1,5 @@ """Test the saj sensor platform.""" -from datetime import timedelta from unittest.mock import AsyncMock, MagicMock from freezegun.api import FrozenDateTimeFactory @@ -8,10 +7,10 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.saj import MIN_INTERVAL_SEC from homeassistant.components.saj.const import DOMAIN +from homeassistant.components.saj.coordinator import SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, STATE_UNKNOWN, Platform +from homeassistant.const import CONF_HOST, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component @@ -45,28 +44,22 @@ async def test_sensor_update_failure( mock_pysaj_saj: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: - """Test sensor update handles failures.""" - # Setup read + initial scheduled poll succeed; next poll fails (unknown state). - mock_pysaj_saj.read = AsyncMock(side_effect=[True, True, False]) - + """Test sensors become unavailable when an update fails.""" entry = await setup_integration(hass, mock_config_entry) assert entry.state is ConfigEntryState.LOADED - await hass.async_block_till_done() - state = hass.states.get("sensor.saj_current_power") assert state is not None assert state.state == "5000.0" - assert mock_pysaj_saj.read.await_count == 2 - freezer.tick(timedelta(seconds=MIN_INTERVAL_SEC + 1)) + mock_pysaj_saj.read = AsyncMock(return_value=False) + freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert mock_pysaj_saj.read.await_count == 3 state = hass.states.get("sensor.saj_current_power") assert state is not None - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE async def test_yaml_import_creates_deprecated_issue( diff --git a/tests/e2e/package.json b/tests/e2e/package.json index a3c8e6b3929350..d739c15e5267de 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "End-to-end browser tests for Home Assistant Core", "private": true, - "packageManager": "pnpm@12.3.2", + "packageManager": "pnpm@12.3.4", "scripts": { "test": "playwright test" }, diff --git a/tests/e2e/pnpm-lock.yaml b/tests/e2e/pnpm-lock.yaml index ed22669a4aa35c..c52be4c7bff088 100644 --- a/tests/e2e/pnpm-lock.yaml +++ b/tests/e2e/pnpm-lock.yaml @@ -7,96 +7,96 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.3.2 - version: 12.3.2 + specifier: 12.3.4 + version: 12.3.4 packages: - '@pnpm/exe.darwin-arm64@12.3.2': - resolution: {integrity: sha512-6/s7P0pq+ULV5FwspTAJcGznMrGCbLOsJdD5d9YpoJONb9jI73MmNTKxlDMtlMikNNK+no8HZDWPFh7Ga1avVA==} + '@pnpm/exe.darwin-arm64@12.3.4': + resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.3.2': - resolution: {integrity: sha512-PQympeXAJXS+ittZ5AHg+eZbcW89ajoCVlM23kFIcrv2eDDiciLJNk4+8AZgmMXMRAbEnjtqVjZbk3lFxHoFEw==} + '@pnpm/exe.darwin-x64@12.3.4': + resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==} cpu: [x64] os: [darwin] - '@pnpm/exe.linux-arm64-musl@12.3.2': - resolution: {integrity: sha512-uHgJwDuohgKwlik5zGem0JIhs5c3NNzdh8hB9OW7MmD2s1L2z3/sloMknvCEdx4tne2vS9NN/yb3Enhpj9ZxwQ==} + '@pnpm/exe.linux-arm64-musl@12.3.4': + resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.3.2': - resolution: {integrity: sha512-SMj5eTAWvjojqCYCXGKQO4qSanTBCe1xhtXQ0qgfBZWJcLCMJkyea65HotYEZLWmAXEDWPh87GZczcJZXEzCIQ==} + '@pnpm/exe.linux-arm64@12.3.4': + resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.3.2': - resolution: {integrity: sha512-YYKfJubIb18iBG/pm9peDIP/STy5l5GrOQQkuFr9yOZdkc+hlYH5MBdTD8ffjTkemP9AfTYJ1R/QB/L+AZfRXA==} + '@pnpm/exe.linux-x64-musl@12.3.4': + resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.3.2': - resolution: {integrity: sha512-qc3jzpaGtMNABOG6Xl0Mee0IOXddAFiDD6sFoOqHpzcq+3YALy+6HMms47g7bYOFNSKWULqPv+SyFYJfZ8EMsA==} + '@pnpm/exe.linux-x64@12.3.4': + resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.3.2': - resolution: {integrity: sha512-W1+M+HdebF/i3DjF2Tx9/+i7BoIJFX+AwNd3WXjWozylQQVfhBA024Ea0rrx7nwToPkS2M8sd8axpwIFLL5bbw==} + '@pnpm/exe.win32-arm64@12.3.4': + resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.3.2': - resolution: {integrity: sha512-LjekndG6dhq9g34qTNeEcynAAVypRUoYuga79lG96AA4JCKxH5dC3jx0mLRKeClQKwGVE1yXuQrCj+alVU2qQA==} + '@pnpm/exe.win32-x64@12.3.4': + resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==} cpu: [x64] os: [win32] - pnpm@12.3.2: - resolution: {integrity: sha512-gvkDUxRkbuqYGt7IXHEaIatfOwVlmz+c6dJwy7k+ig9hTaF5Irrgl5AuK4YO6OttXBp+iSmaFod8DCQ/NrjhWw==} + pnpm@12.3.4: + resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.darwin-arm64@12.3.2': + '@pnpm/exe.darwin-arm64@12.3.4': optional: true - '@pnpm/exe.darwin-x64@12.3.2': + '@pnpm/exe.darwin-x64@12.3.4': optional: true - '@pnpm/exe.linux-arm64-musl@12.3.2': + '@pnpm/exe.linux-arm64-musl@12.3.4': optional: true - '@pnpm/exe.linux-arm64@12.3.2': + '@pnpm/exe.linux-arm64@12.3.4': optional: true - '@pnpm/exe.linux-x64-musl@12.3.2': + '@pnpm/exe.linux-x64-musl@12.3.4': optional: true - '@pnpm/exe.linux-x64@12.3.2': + '@pnpm/exe.linux-x64@12.3.4': optional: true - '@pnpm/exe.win32-arm64@12.3.2': + '@pnpm/exe.win32-arm64@12.3.4': optional: true - '@pnpm/exe.win32-x64@12.3.2': + '@pnpm/exe.win32-x64@12.3.4': optional: true - pnpm@12.3.2: + pnpm@12.3.4: optionalDependencies: - '@pnpm/exe.darwin-arm64': 12.3.2 - '@pnpm/exe.darwin-x64': 12.3.2 - '@pnpm/exe.linux-arm64': 12.3.2 - '@pnpm/exe.linux-arm64-musl': 12.3.2 - '@pnpm/exe.linux-x64': 12.3.2 - '@pnpm/exe.linux-x64-musl': 12.3.2 - '@pnpm/exe.win32-arm64': 12.3.2 - '@pnpm/exe.win32-x64': 12.3.2 + '@pnpm/exe.darwin-arm64': 12.3.4 + '@pnpm/exe.darwin-x64': 12.3.4 + '@pnpm/exe.linux-arm64': 12.3.4 + '@pnpm/exe.linux-arm64-musl': 12.3.4 + '@pnpm/exe.linux-x64': 12.3.4 + '@pnpm/exe.linux-x64-musl': 12.3.4 + '@pnpm/exe.win32-arm64': 12.3.4 + '@pnpm/exe.win32-x64': 12.3.4 --- lockfileVersion: '9.0'