diff --git a/CODEOWNERS b/CODEOWNERS index 85603250b7ca..c6cee80ea806 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1249,6 +1249,8 @@ build.json @home-assistant/supervisor /homeassistant/components/sms/ @ocalvo /homeassistant/components/snapcast/ @luar123 /tests/components/snapcast/ @luar123 +/homeassistant/components/snmp/ @nmaggioni +/tests/components/snmp/ @nmaggioni /homeassistant/components/snooz/ @AustinBrunkhorst /tests/components/snooz/ @AustinBrunkhorst /homeassistant/components/solaredge/ @frenck diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 1065783d9576..b3898b7aab85 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -26,7 +26,7 @@ "iot_class": "local_push", "loggers": ["axis"], "quality_scale": "platinum", - "requirements": ["axis==60"], + "requirements": ["axis==61"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index 9ca18a95a1e1..3bbaf40f686f 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "quality_scale": "platinum", - "requirements": ["brother==4.0.2"], + "requirements": ["brother==4.1.0"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index eed2bda421b2..49a3fc0bf5c4 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.79.0"] + "requirements": ["hass-nabucasa==0.78.0"] } diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index 6e672e9cc974..fd6ec74050d0 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -121,6 +121,7 @@ async def async_setup_entry( Platform.COVER, Platform.LIGHT, Platform.LOCK, + Platform.SENSOR, Platform.SWITCH, ) for device in controller.fibaro_devices[platform] diff --git a/homeassistant/components/fyta/manifest.json b/homeassistant/components/fyta/manifest.json index a93a76a9e1d3..55255777994d 100644 --- a/homeassistant/components/fyta/manifest.json +++ b/homeassistant/components/fyta/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/fyta", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["fyta_cli==0.3.3"] + "requirements": ["fyta_cli==0.3.5"] } diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py index 0643c69981ec..2b9e8e3de076 100644 --- a/homeassistant/components/fyta/sensor.py +++ b/homeassistant/components/fyta/sensor.py @@ -46,35 +46,35 @@ class FytaSensorEntityDescription(SensorEntityDescription): translation_key="plant_status", device_class=SensorDeviceClass.ENUM, options=PLANT_STATUS_LIST, - value_fn=lambda value: PLANT_STATUS[value], + value_fn=PLANT_STATUS.get, ), FytaSensorEntityDescription( key="temperature_status", translation_key="temperature_status", device_class=SensorDeviceClass.ENUM, options=PLANT_STATUS_LIST, - value_fn=lambda value: PLANT_STATUS[value], + value_fn=PLANT_STATUS.get, ), FytaSensorEntityDescription( key="light_status", translation_key="light_status", device_class=SensorDeviceClass.ENUM, options=PLANT_STATUS_LIST, - value_fn=lambda value: PLANT_STATUS[value], + value_fn=PLANT_STATUS.get, ), FytaSensorEntityDescription( key="moisture_status", translation_key="moisture_status", device_class=SensorDeviceClass.ENUM, options=PLANT_STATUS_LIST, - value_fn=lambda value: PLANT_STATUS[value], + value_fn=PLANT_STATUS.get, ), FytaSensorEntityDescription( key="salinity_status", translation_key="salinity_status", device_class=SensorDeviceClass.ENUM, options=PLANT_STATUS_LIST, - value_fn=lambda value: PLANT_STATUS[value], + value_fn=PLANT_STATUS.get, ), FytaSensorEntityDescription( key="temperature", diff --git a/homeassistant/components/homematic/climate.py b/homeassistant/components/homematic/climate.py index efdb9324f76a..16c345c56354 100644 --- a/homeassistant/components/homematic/climate.py +++ b/homeassistant/components/homematic/climate.py @@ -113,7 +113,11 @@ def preset_mode(self): @property def preset_modes(self): """Return a list of available preset modes.""" - return [HM_PRESET_MAP[mode] for mode in self._hmdevice.ACTIONNODE] + return [ + HM_PRESET_MAP[mode] + for mode in self._hmdevice.ACTIONNODE + if mode in HM_PRESET_MAP + ] @property def current_humidity(self): diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 99c150a8346d..af0c6b8d01cf 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "quality_scale": "platinum", "requirements": [ "xknx==2.12.2", - "xknxproject==3.7.0", + "xknxproject==3.7.1", "knx-frontend==2024.1.20.105944" ], "single_config_entry": true diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index ea096a908fc4..66ade5f356c5 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "requirements": ["pylitterbot==2023.4.9"] + "requirements": ["pylitterbot==2023.4.11"] } diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index 4f9efa2dff7b..d752609d7de2 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -35,6 +35,7 @@ LitterBoxStatus.CLEAN_CYCLE: STATE_CLEANING, LitterBoxStatus.EMPTY_CYCLE: STATE_CLEANING, LitterBoxStatus.CLEAN_CYCLE_COMPLETE: STATE_DOCKED, + LitterBoxStatus.CAT_DETECTED: STATE_DOCKED, LitterBoxStatus.CAT_SENSOR_TIMING: STATE_DOCKED, LitterBoxStatus.DRAWER_FULL_1: STATE_DOCKED, LitterBoxStatus.DRAWER_FULL_2: STATE_DOCKED, diff --git a/homeassistant/components/nobo_hub/manifest.json b/homeassistant/components/nobo_hub/manifest.json index 4741eb39e294..ce32244e1cec 100644 --- a/homeassistant/components/nobo_hub/manifest.json +++ b/homeassistant/components/nobo_hub/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nobo_hub", "integration_type": "hub", "iot_class": "local_push", - "requirements": ["pynobo==1.8.0"] + "requirements": ["pynobo==1.8.1"] } diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index d3a307a66162..0ec931ceade9 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -258,7 +258,7 @@ def _remove_labelsets( self, entity_id: str, friendly_name: str | None = None ) -> None: """Remove labelsets matching the given entity id from all metrics.""" - for metric in self._metrics.values(): + for metric in list(self._metrics.values()): for sample in cast(list[prometheus_client.Metric], metric.collect())[ 0 ].samples: diff --git a/homeassistant/components/snapcast/config_flow.py b/homeassistant/components/snapcast/config_flow.py index c9f69c48ab5f..b37921fd3746 100644 --- a/homeassistant/components/snapcast/config_flow.py +++ b/homeassistant/components/snapcast/config_flow.py @@ -45,7 +45,7 @@ async def async_step_user(self, user_input=None) -> ConfigFlowResult: except OSError: errors["base"] = "cannot_connect" else: - await client.stop() + client.stop() return self.async_create_entry(title=DEFAULT_TITLE, data=user_input) return self.async_show_form( step_id="user", data_schema=SNAPCAST_SCHEMA, errors=errors diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 4b8ab073b9c4..a1a91116f0f9 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -5,8 +5,19 @@ import binascii import logging -from pysnmp.entity import config as cfg -from pysnmp.entity.rfc3413.oneliner import cmdgen +from pysnmp.error import PySnmpError +from pysnmp.hlapi.asyncio import ( + CommunityData, + ContextData, + ObjectIdentity, + ObjectType, + SnmpEngine, + Udp6TransportTarget, + UdpTransportTarget, + UsmUserData, + bulkWalkCmd, + isEndOfMib, +) import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -24,7 +35,13 @@ CONF_BASEOID, CONF_COMMUNITY, CONF_PRIV_KEY, + DEFAULT_AUTH_PROTOCOL, DEFAULT_COMMUNITY, + DEFAULT_PORT, + DEFAULT_PRIV_PROTOCOL, + DEFAULT_TIMEOUT, + DEFAULT_VERSION, + SNMP_VERSIONS, ) _LOGGER = logging.getLogger(__name__) @@ -40,9 +57,12 @@ ) -def get_scanner(hass: HomeAssistant, config: ConfigType) -> SnmpScanner | None: +async def async_get_scanner( + hass: HomeAssistant, config: ConfigType +) -> SnmpScanner | None: """Validate the configuration and return an SNMP scanner.""" scanner = SnmpScanner(config[DOMAIN]) + await scanner.async_init() return scanner if scanner.success_init else None @@ -51,39 +71,75 @@ class SnmpScanner(DeviceScanner): """Queries any SNMP capable Access Point for connected devices.""" def __init__(self, config): - """Initialize the scanner.""" - - self.snmp = cmdgen.CommandGenerator() - - self.host = cmdgen.UdpTransportTarget((config[CONF_HOST], 161)) - if CONF_AUTH_KEY not in config or CONF_PRIV_KEY not in config: - self.auth = cmdgen.CommunityData(config[CONF_COMMUNITY]) + """Initialize the scanner and test the target device.""" + host = config[CONF_HOST] + community = config[CONF_COMMUNITY] + baseoid = config[CONF_BASEOID] + authkey = config.get(CONF_AUTH_KEY) + authproto = DEFAULT_AUTH_PROTOCOL + privkey = config.get(CONF_PRIV_KEY) + privproto = DEFAULT_PRIV_PROTOCOL + + try: + # Try IPv4 first. + target = UdpTransportTarget((host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT) + except PySnmpError: + # Then try IPv6. + try: + target = Udp6TransportTarget( + (host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT + ) + except PySnmpError as err: + _LOGGER.error("Invalid SNMP host: %s", err) + return + + if authkey is not None or privkey is not None: + if not authkey: + authproto = "none" + if not privkey: + privproto = "none" + + request_args = [ + SnmpEngine(), + UsmUserData( + community, + authKey=authkey or None, + privKey=privkey or None, + authProtocol=authproto, + privProtocol=privproto, + ), + target, + ContextData(), + ] else: - self.auth = cmdgen.UsmUserData( - config[CONF_COMMUNITY], - config[CONF_AUTH_KEY], - config[CONF_PRIV_KEY], - authProtocol=cfg.usmHMACSHAAuthProtocol, - privProtocol=cfg.usmAesCfb128Protocol, - ) - self.baseoid = cmdgen.MibVariable(config[CONF_BASEOID]) + request_args = [ + SnmpEngine(), + CommunityData(community, mpModel=SNMP_VERSIONS[DEFAULT_VERSION]), + target, + ContextData(), + ] + + self.request_args = request_args + self.baseoid = baseoid self.last_results = [] + self.success_init = False - # Test the router is accessible - data = self.get_snmp_data() + async def async_init(self): + """Make a one-off read to check if the target device is reachable and readable.""" + data = await self.async_get_snmp_data() self.success_init = data is not None - def scan_devices(self): + async def async_scan_devices(self): """Scan for new devices and return a list with found device IDs.""" - self._update_info() + await self._async_update_info() return [client["mac"] for client in self.last_results if client.get("mac")] - def get_device_name(self, device): + async def async_get_device_name(self, device): """Return the name of the given device or None if we don't know.""" # We have no names return None - def _update_info(self): + async def _async_update_info(self): """Ensure the information from the device is up to date. Return boolean if scanning successful. @@ -91,38 +147,42 @@ def _update_info(self): if not self.success_init: return False - if not (data := self.get_snmp_data()): + if not (data := await self.async_get_snmp_data()): return False self.last_results = data return True - def get_snmp_data(self): + async def async_get_snmp_data(self): """Fetch MAC addresses from access point via SNMP.""" devices = [] - errindication, errstatus, errindex, restable = self.snmp.nextCmd( - self.auth, self.host, self.baseoid + walker = bulkWalkCmd( + *self.request_args, + 0, + 50, + ObjectType(ObjectIdentity(self.baseoid)), + lexicographicMode=False, ) - - if errindication: - _LOGGER.error("SNMPLIB error: %s", errindication) - return - if errstatus: - _LOGGER.error( - "SNMP error: %s at %s", - errstatus.prettyPrint(), - errindex and restable[int(errindex) - 1][0] or "?", - ) - return - - for resrow in restable: - for _, val in resrow: - try: - mac = binascii.hexlify(val.asOctets()).decode("utf-8") - except AttributeError: - continue - _LOGGER.debug("Found MAC address: %s", mac) - mac = ":".join([mac[i : i + 2] for i in range(0, len(mac), 2)]) - devices.append({"mac": mac}) + async for errindication, errstatus, errindex, res in walker: + if errindication: + _LOGGER.error("SNMPLIB error: %s", errindication) + return + if errstatus: + _LOGGER.error( + "SNMP error: %s at %s", + errstatus.prettyPrint(), + errindex and res[int(errindex) - 1][0] or "?", + ) + return + + for _oid, value in res: + if not isEndOfMib(res): + try: + mac = binascii.hexlify(value.asOctets()).decode("utf-8") + except AttributeError: + continue + _LOGGER.debug("Found MAC address: %s", mac) + mac = ":".join([mac[i : i + 2] for i in range(0, len(mac), 2)]) + devices.append({"mac": mac}) return devices diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index c4aa82f2a745..d79910c44cd8 100644 --- a/homeassistant/components/snmp/manifest.json +++ b/homeassistant/components/snmp/manifest.json @@ -1,7 +1,7 @@ { "domain": "snmp", "name": "SNMP", - "codeowners": [], + "codeowners": ["@nmaggioni"], "documentation": "https://www.home-assistant.io/integrations/snmp", "iot_class": "local_polling", "loggers": ["pyasn1", "pysmi", "pysnmp"], diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 7579f3507743..28dc750bc916 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -116,7 +116,7 @@ def is_on(self) -> bool: @property def available(self) -> bool: """Return True if entity is available.""" - return bool(self._api.security) + return bool(self._api.security) and super().available @property def extra_state_attributes(self) -> dict[str, str]: diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index 19f95c710d0c..82d15138f054 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -108,7 +108,7 @@ def device_info(self) -> DeviceInfo: @property def available(self) -> bool: """Return the availability of the camera.""" - return self.camera_data.is_enabled and self.coordinator.last_update_success + return self.camera_data.is_enabled and super().available @property def is_recording(self) -> bool: diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index 4bb523831486..4a7018119be5 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -286,18 +286,7 @@ async def async_unload(self) -> None: async def async_update(self) -> None: """Update function for updating API information.""" - try: - await self._update() - except SYNOLOGY_CONNECTION_EXCEPTIONS as err: - LOGGER.debug( - "Connection error during update of '%s' with exception: %s", - self._entry.unique_id, - err, - ) - LOGGER.warning( - "Connection error during update, fallback by reloading the entry" - ) - await self._hass.config_entries.async_reload(self._entry.entry_id) + await self._update() async def _update(self) -> None: """Update function for updating API information.""" diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 47483ee4a63c..4f20a6233f34 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -366,7 +366,7 @@ def native_value(self) -> StateType: @property def available(self) -> bool: """Return True if entity is available.""" - return bool(self._api.utilisation) + return bool(self._api.utilisation) and super().available class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor): diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index 6e1e38675a09..c19cdb8c8154 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -98,7 +98,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: @property def available(self) -> bool: """Return True if entity is available.""" - return bool(self._api.surveillance_station) + return bool(self._api.surveillance_station) and super().available @property def device_info(self) -> DeviceInfo: diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py index 7b1a36c57b3c..c7bcff48cea8 100644 --- a/homeassistant/components/synology_dsm/update.py +++ b/homeassistant/components/synology_dsm/update.py @@ -59,7 +59,7 @@ class SynoDSMUpdateEntity( @property def available(self) -> bool: """Return True if entity is available.""" - return bool(self._api.upgrade) + return bool(self._api.upgrade) and super().available @property def installed_version(self) -> str | None: diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 72e93f5655ae..5da68d99dd61 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -325,12 +325,12 @@ def async_start(self, duration: timedelta | None = None) -> None: self._end = start + self._remaining + self.async_write_ha_state() self.hass.bus.async_fire(event, {ATTR_ENTITY_ID: self.entity_id}) self._listener = async_track_point_in_utc_time( self.hass, self._async_finished, self._end ) - self.async_write_ha_state() @callback def async_change(self, duration: timedelta) -> None: @@ -351,11 +351,11 @@ def async_change(self, duration: timedelta) -> None: self._listener() self._end += duration self._remaining = self._end - dt_util.utcnow().replace(microsecond=0) + self.async_write_ha_state() self.hass.bus.async_fire(EVENT_TIMER_CHANGED, {ATTR_ENTITY_ID: self.entity_id}) self._listener = async_track_point_in_utc_time( self.hass, self._async_finished, self._end ) - self.async_write_ha_state() @callback def async_pause(self) -> None: @@ -368,8 +368,8 @@ def async_pause(self) -> None: self._remaining = self._end - dt_util.utcnow().replace(microsecond=0) self._state = STATUS_PAUSED self._end = None - self.hass.bus.async_fire(EVENT_TIMER_PAUSED, {ATTR_ENTITY_ID: self.entity_id}) self.async_write_ha_state() + self.hass.bus.async_fire(EVENT_TIMER_PAUSED, {ATTR_ENTITY_ID: self.entity_id}) @callback def async_cancel(self) -> None: @@ -381,10 +381,10 @@ def async_cancel(self) -> None: self._end = None self._remaining = None self._running_duration = self._configured_duration + self.async_write_ha_state() self.hass.bus.async_fire( EVENT_TIMER_CANCELLED, {ATTR_ENTITY_ID: self.entity_id} ) - self.async_write_ha_state() @callback def async_finish(self) -> None: @@ -400,11 +400,11 @@ def async_finish(self) -> None: self._end = None self._remaining = None self._running_duration = self._configured_duration + self.async_write_ha_state() self.hass.bus.async_fire( EVENT_TIMER_FINISHED, {ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()}, ) - self.async_write_ha_state() @callback def _async_finished(self, time: datetime) -> None: @@ -418,11 +418,11 @@ def _async_finished(self, time: datetime) -> None: self._end = None self._remaining = None self._running_duration = self._configured_duration + self.async_write_ha_state() self.hass.bus.async_fire( EVENT_TIMER_FINISHED, {ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()}, ) - self.async_write_ha_state() async def async_update_config(self, config: ConfigType) -> None: """Handle when the config is updated.""" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 26582df1b448..014cd93b53b9 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -578,7 +578,13 @@ async def _async_reset_meter(self, event): async def async_reset_meter(self, entity_id): """Reset meter.""" - if self._tariff is not None and self._tariff_entity != entity_id: + if self._tariff_entity is not None and self._tariff_entity != entity_id: + return + if ( + self._tariff_entity is None + and entity_id is not None + and self.entity_id != entity_id + ): return _LOGGER.debug("Reset utility meter <%s>", self.entity_id) self._last_reset = dt_util.utcnow() diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 1c51c58d238d..6f817a233253 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2024.4.0"], + "requirements": ["velbus-aio==2024.4.1"], "usb": [ { "vid": "10CF", diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 8b3b071161c0..cd6759b5864e 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.4.1"] + "requirements": ["yolink-api==0.4.2"] } diff --git a/homeassistant/const.py b/homeassistant/const.py index b642ce6ce8c5..e4359f5bbfbe 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -18,7 +18,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "1" +PATCH_VERSION: Final = "2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 1cff472af72e..6d7ed7ed1b85 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -631,7 +631,16 @@ async def async_add_entities( if ( (self.config_entry and self.config_entry.pref_disable_polling) or self._async_unsub_polling is not None - or not any(entity.should_poll for entity in entities) + or not any( + # Entity may have failed to add or called `add_to_platform_abort` + # so we check if the entity is in self.entities before + # checking `entity.should_poll` since `should_poll` may need to + # check `self.hass` which will be `None` if the entity did not add + entity.entity_id + and entity.entity_id in self.entities + and entity.should_poll + for entity in entities + ) ): return diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index a86df259f115..b4e02e0e4adf 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -286,6 +286,9 @@ def make_script_schema( cv.SCRIPT_ACTION_WAIT_TEMPLATE, ) +REPEAT_WARN_ITERATIONS = 5000 +REPEAT_TERMINATE_ITERATIONS = 10000 + async def async_validate_actions_config( hass: HomeAssistant, actions: list[ConfigType] @@ -846,6 +849,7 @@ def set_repeat_var( # pylint: disable-next=protected-access script = self._script._get_repeat_script(self._step) + warned_too_many_loops = False async def async_run_sequence(iteration, extra_msg=""): self._log("Repeating %s: Iteration %i%s", description, iteration, extra_msg) @@ -916,6 +920,36 @@ async def async_run_sequence(iteration, extra_msg=""): _LOGGER.warning("Error in 'while' evaluation:\n%s", ex) break + if iteration > 1: + if iteration > REPEAT_WARN_ITERATIONS: + if not warned_too_many_loops: + warned_too_many_loops = True + _LOGGER.warning( + "While condition %s in script `%s` looped %s times", + repeat[CONF_WHILE], + self._script.name, + REPEAT_WARN_ITERATIONS, + ) + + if iteration > REPEAT_TERMINATE_ITERATIONS: + _LOGGER.critical( + "While condition %s in script `%s` " + "terminated because it looped %s times", + repeat[CONF_WHILE], + self._script.name, + REPEAT_TERMINATE_ITERATIONS, + ) + raise _AbortScript( + f"While condition {repeat[CONF_WHILE]} " + "terminated because it looped " + f" {REPEAT_TERMINATE_ITERATIONS} times" + ) + + # If the user creates a script with a tight loop, + # yield to the event loop so the system stays + # responsive while all the cpu time is consumed. + await asyncio.sleep(0) + await async_run_sequence(iteration) elif CONF_UNTIL in repeat: @@ -934,6 +968,35 @@ async def async_run_sequence(iteration, extra_msg=""): _LOGGER.warning("Error in 'until' evaluation:\n%s", ex) break + if iteration >= REPEAT_WARN_ITERATIONS: + if not warned_too_many_loops: + warned_too_many_loops = True + _LOGGER.warning( + "Until condition %s in script `%s` looped %s times", + repeat[CONF_UNTIL], + self._script.name, + REPEAT_WARN_ITERATIONS, + ) + + if iteration >= REPEAT_TERMINATE_ITERATIONS: + _LOGGER.critical( + "Until condition %s in script `%s` " + "terminated because it looped %s times", + repeat[CONF_UNTIL], + self._script.name, + REPEAT_TERMINATE_ITERATIONS, + ) + raise _AbortScript( + f"Until condition {repeat[CONF_UNTIL]} " + "terminated because it looped " + f"{REPEAT_TERMINATE_ITERATIONS} times" + ) + + # If the user creates a script with a tight loop, + # yield to the event loop so the system stays responsive + # while all the cpu time is consumed. + await asyncio.sleep(0) + if saved_repeat_vars: self._variables["repeat"] = saved_repeat_vars else: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bd35403340fa..4ba42672c4d9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.2.0 habluetooth==2.4.2 -hass-nabucasa==0.79.0 +hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 home-assistant-frontend==20240404.1 diff --git a/pyproject.toml b/pyproject.toml index 2dd3a9632c6f..a6484fa33492 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.4.1" +version = "2024.4.2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -38,7 +38,7 @@ dependencies = [ "fnv-hash-fast==0.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.79.0", + "hass-nabucasa==0.78.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.27.0", diff --git a/requirements.txt b/requirements.txt index 1dd9b1811d3d..05d66a798731 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ bcrypt==4.1.2 certifi>=2021.5.30 ciso8601==2.3.1 fnv-hash-fast==0.5.0 -hass-nabucasa==0.79.0 +hass-nabucasa==0.78.0 httpx==0.27.0 home-assistant-bluetooth==1.12.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index a87df9614d12..cbcae805bd07 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -514,7 +514,7 @@ aurorapy==0.2.7 # avion==0.10 # homeassistant.components.axis -axis==60 +axis==61 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 @@ -609,7 +609,7 @@ bring-api==0.5.7 broadlink==0.18.3 # homeassistant.components.brother -brother==4.0.2 +brother==4.1.0 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 @@ -899,7 +899,7 @@ freesms==0.2.0 fritzconnection[qr]==1.13.2 # homeassistant.components.fyta -fyta_cli==0.3.3 +fyta_cli==0.3.5 # homeassistant.components.google_translate gTTS==2.2.4 @@ -1037,7 +1037,7 @@ habitipy==0.2.0 habluetooth==2.4.2 # homeassistant.components.cloud -hass-nabucasa==0.79.0 +hass-nabucasa==0.78.0 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -1943,7 +1943,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.2 # homeassistant.components.litterrobot -pylitterbot==2023.4.9 +pylitterbot==2023.4.11 # homeassistant.components.lutron_caseta pylutron-caseta==0.20.0 @@ -1991,7 +1991,7 @@ pynetgear==0.10.10 pynetio==0.1.9.1 # homeassistant.components.nobo_hub -pynobo==1.8.0 +pynobo==1.8.1 # homeassistant.components.nuki pynuki==1.6.3 @@ -2798,7 +2798,7 @@ vallox-websocket-api==5.1.1 vehicle==2.2.1 # homeassistant.components.velbus -velbus-aio==2024.4.0 +velbus-aio==2024.4.1 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2880,7 +2880,7 @@ xiaomi-ble==0.28.0 xknx==2.12.2 # homeassistant.components.knx -xknxproject==3.7.0 +xknxproject==3.7.1 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2910,7 +2910,7 @@ yeelight==0.7.14 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.4.1 +yolink-api==0.4.2 # homeassistant.components.youless youless-api==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f5d01eb46cd..77dbd53a73e0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -454,7 +454,7 @@ auroranoaa==0.0.3 aurorapy==0.2.7 # homeassistant.components.axis -axis==60 +axis==61 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 @@ -520,7 +520,7 @@ bring-api==0.5.7 broadlink==0.18.3 # homeassistant.components.brother -brother==4.0.2 +brother==4.1.0 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 @@ -731,7 +731,7 @@ freebox-api==1.1.0 fritzconnection[qr]==1.13.2 # homeassistant.components.fyta -fyta_cli==0.3.3 +fyta_cli==0.3.5 # homeassistant.components.google_translate gTTS==2.2.4 @@ -848,7 +848,7 @@ habitipy==0.2.0 habluetooth==2.4.2 # homeassistant.components.cloud -hass-nabucasa==0.79.0 +hass-nabucasa==0.78.0 # homeassistant.components.conversation hassil==1.6.1 @@ -1509,7 +1509,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.2 # homeassistant.components.litterrobot -pylitterbot==2023.4.9 +pylitterbot==2023.4.11 # homeassistant.components.lutron_caseta pylutron-caseta==0.20.0 @@ -1545,7 +1545,7 @@ pymysensors==0.24.0 pynetgear==0.10.10 # homeassistant.components.nobo_hub -pynobo==1.8.0 +pynobo==1.8.1 # homeassistant.components.nuki pynuki==1.6.3 @@ -2154,7 +2154,7 @@ vallox-websocket-api==5.1.1 vehicle==2.2.1 # homeassistant.components.velbus -velbus-aio==2024.4.0 +velbus-aio==2024.4.1 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2224,7 +2224,7 @@ xiaomi-ble==0.28.0 xknx==2.12.2 # homeassistant.components.knx -xknxproject==3.7.0 +xknxproject==3.7.1 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2248,7 +2248,7 @@ yalexs==2.0.0 yeelight==0.7.14 # homeassistant.components.yolink -yolink-api==0.4.1 +yolink-api==0.4.2 # homeassistant.components.youless youless-api==1.0.1 diff --git a/script/translations/download.py b/script/translations/download.py index 958a4b35a7be..8f7327c07ec6 100755 --- a/script/translations/download.py +++ b/script/translations/download.py @@ -39,6 +39,8 @@ def run_download_docker(): CORE_PROJECT_ID, "--original-filenames=false", "--replace-breaks=false", + "--filter-data", + "nonfuzzy", "--export-empty-as", "skip", "--format", diff --git a/tests/components/litterrobot/common.py b/tests/components/litterrobot/common.py index fe6202edc476..cac81aad4ef9 100644 --- a/tests/components/litterrobot/common.py +++ b/tests/components/litterrobot/common.py @@ -33,6 +33,7 @@ "wifiRssi": -53.0, "unitPowerType": "AC", "catWeight": 12.0, + "displayCode": "DC_MODE_IDLE", "unitTimezone": "America/New_York", "unitTime": None, "cleanCycleWaitTime": 15, @@ -66,7 +67,7 @@ "isDFIResetPending": False, "DFINumberOfCycles": 104, "DFILevelPercent": 76, - "isDFIFull": True, + "isDFIFull": False, "DFIFullCounter": 3, "DFITriggerCount": 42, "litterLevel": 460, diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index 9002894d0abc..8d1f2b68e052 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -86,7 +86,7 @@ async def test_litter_robot_sensor( assert sensor.state == "2022-09-17T12:06:37+00:00" assert sensor.attributes["device_class"] == SensorDeviceClass.TIMESTAMP sensor = hass.states.get("sensor.test_status_code") - assert sensor.state == "dfs" + assert sensor.state == "rdy" assert sensor.attributes["device_class"] == SensorDeviceClass.ENUM sensor = hass.states.get("sensor.test_litter_level") assert sensor.state == "70.0" diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 9013d6e83eb0..68ebae1e239d 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -5,6 +5,7 @@ from typing import Any from unittest.mock import MagicMock +from pylitterbot import Robot import pytest from homeassistant.components.litterrobot import DOMAIN @@ -16,6 +17,7 @@ SERVICE_STOP, STATE_DOCKED, STATE_ERROR, + STATE_PAUSED, ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -96,6 +98,30 @@ async def test_vacuum_with_error( assert vacuum.state == STATE_ERROR +@pytest.mark.parametrize( + ("robot_data", "expected_state"), + [ + ({"displayCode": "DC_CAT_DETECT"}, STATE_DOCKED), + ({"isDFIFull": True}, STATE_ERROR), + ({"robotCycleState": "CYCLE_STATE_CAT_DETECT"}, STATE_PAUSED), + ], +) +async def test_vacuum_states( + hass: HomeAssistant, + mock_account_with_litterrobot_4: MagicMock, + robot_data: dict[str, str | bool], + expected_state: str, +) -> None: + """Test sending commands to the switch.""" + await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + robot: Robot = mock_account_with_litterrobot_4.robots[0] + robot._update_data(robot_data, partial=True) + + vacuum = hass.states.get(VACUUM_ENTITY_ID) + assert vacuum + assert vacuum.state == expected_state + + @pytest.mark.parametrize( ("service", "command", "extra"), [ diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 5aca1625d1f1..c1c9f56094bc 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -45,7 +45,7 @@ EVENT_STATE_CHANGED, SERVICE_RELOAD, ) -from homeassistant.core import Context, CoreState, HomeAssistant, State +from homeassistant.core import Context, CoreState, Event, HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.restore_state import StoredState, async_get @@ -156,11 +156,12 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: assert state assert state.state == STATUS_IDLE - results = [] + results: list[tuple[Event, str]] = [] - def fake_event_listener(event): + @callback + def fake_event_listener(event: Event): """Fake event listener for trigger.""" - results.append(event) + results.append((event, hass.states.get("timer.test1").state)) hass.bus.async_listen(EVENT_TIMER_STARTED, fake_event_listener) hass.bus.async_listen(EVENT_TIMER_RESTARTED, fake_event_listener) @@ -262,7 +263,10 @@ def fake_event_listener(event): if step["event"] is not None: expected_events += 1 - assert results[-1].event_type == step["event"] + last_result = results[-1] + event, state = last_result + assert event.event_type == step["event"] + assert state == step["state"] assert len(results) == expected_events @@ -404,6 +408,7 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None: results = [] + @callback def fake_event_listener(event): """Fake event listener for trigger.""" results.append(event) @@ -580,6 +585,7 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None: results = [] + @callback def fake_event_listener(event): """Fake event listener for trigger.""" results.append(event) @@ -647,6 +653,7 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None: results = [] + @callback def fake_event_listener(event): """Fake event listener for trigger.""" results.append(event) diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 99a63809329e..43a71eca85e0 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -983,6 +983,139 @@ async def test_service_reset_no_tariffs( assert state.attributes.get("last_period") == "3" +@pytest.mark.parametrize( + ("yaml_config", "config_entry_configs"), + [ + ( + { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + }, + "water_bill": { + "source": "sensor.water", + }, + }, + }, + None, + ), + ( + None, + [ + { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": "sensor.energy", + "tariffs": [], + }, + { + "cycle": "none", + "delta_values": False, + "name": "Water bill", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": "sensor.water", + "tariffs": [], + }, + ], + ), + ], +) +async def test_service_reset_no_tariffs_correct_with_multi( + hass: HomeAssistant, yaml_config, config_entry_configs +) -> None: + """Test complex utility sensor service reset for multiple sensors with no tarrifs. + + See GitHub issue #114864: Service "utility_meter.reset" affects all meters. + """ + + # Home assistant is not runnit yet + hass.state = CoreState.not_running + last_reset = "2023-10-01T00:00:00+00:00" + + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "sensor.energy_bill", + "3", + attributes={ + ATTR_LAST_RESET: last_reset, + }, + ), + {}, + ), + ( + State( + "sensor.water_bill", + "6", + attributes={ + ATTR_LAST_RESET: last_reset, + }, + ), + {}, + ), + ], + ) + + if yaml_config: + assert await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + else: + for entry in config_entry_configs: + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=entry, + title=entry["name"], + ) + 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("sensor.energy_bill") + assert state + assert state.state == "3" + assert state.attributes.get("last_reset") == last_reset + assert state.attributes.get("last_period") == "0" + + state = hass.states.get("sensor.water_bill") + assert state + assert state.state == "6" + assert state.attributes.get("last_reset") == last_reset + assert state.attributes.get("last_period") == "0" + + now = dt_util.utcnow() + with freeze_time(now): + await hass.services.async_call( + domain=DOMAIN, + service=SERVICE_RESET, + service_data={}, + target={"entity_id": "sensor.energy_bill"}, + blocking=True, + ) + + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state + assert state.state == "0" + assert state.attributes.get("last_reset") == now.isoformat() + assert state.attributes.get("last_period") == "3" + + state = hass.states.get("sensor.water_bill") + assert state + assert state.state == "6" + assert state.attributes.get("last_reset") == last_reset + assert state.attributes.get("last_period") == "0" + + @pytest.mark.parametrize( ("yaml_config", "config_entry_config"), [ diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 31c6f8e6e30c..59c4f7357f3d 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -5,7 +5,7 @@ from datetime import timedelta import logging from typing import Any -from unittest.mock import ANY, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch import pytest @@ -78,6 +78,40 @@ async def test_polling_only_updates_entities_it_should_poll( assert poll_ent.async_update.called +async def test_polling_check_works_if_entity_add_fails( + hass: HomeAssistant, +) -> None: + """Test the polling check works if an entity add fails.""" + component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) + await component.async_setup({}) + + class MockEntityNeedsSelfHassInShouldPoll(MockEntity): + """Mock entity that needs self.hass in should_poll.""" + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled.""" + return self.hass.data is not None + + working_poll_ent = MockEntityNeedsSelfHassInShouldPoll(should_poll=True) + working_poll_ent.async_update = AsyncMock() + broken_poll_ent = MockEntityNeedsSelfHassInShouldPoll(should_poll=True) + broken_poll_ent.async_update = AsyncMock(side_effect=Exception("Broken")) + + await component.async_add_entities( + [broken_poll_ent, working_poll_ent], update_before_add=True + ) + + working_poll_ent.async_update.reset_mock() + broken_poll_ent.async_update.reset_mock() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20)) + await hass.async_block_till_done(wait_background_tasks=True) + + assert not broken_poll_ent.async_update.called + assert working_poll_ent.async_update.called + + async def test_polling_disabled_by_config_entry(hass: HomeAssistant) -> None: """Test the polling of only updated entities.""" entity_platform = MockEntityPlatform(hass) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 86fb84eb582d..409b3639d430 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -2837,6 +2837,58 @@ async def test_repeat_nested( assert_action_trace(expected_trace) +@pytest.mark.parametrize( + ("condition", "check"), [("while", "above"), ("until", "below")] +) +async def test_repeat_limits( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str, check: str +) -> None: + """Test limits on repeats prevent the system from hanging.""" + event = "test_event" + events = async_capture_events(hass, event) + hass.states.async_set("sensor.test", "0.5") + + sequence = { + "repeat": { + "sequence": [ + { + "event": event, + }, + ], + } + } + sequence["repeat"][condition] = { + "condition": "numeric_state", + "entity_id": "sensor.test", + check: "0", + } + + with ( + patch.object(script, "REPEAT_WARN_ITERATIONS", 5), + patch.object(script, "REPEAT_TERMINATE_ITERATIONS", 10), + ): + script_obj = script.Script( + hass, cv.SCRIPT_SCHEMA(sequence), f"Test {condition}", "test_domain" + ) + + caplog.clear() + caplog.set_level(logging.WARNING) + + hass.async_create_task(script_obj.async_run(context=Context())) + await asyncio.wait_for(hass.async_block_till_done(), 1) + + title_condition = condition.title() + + assert f"{title_condition} condition" in caplog.text + assert f"in script `Test {condition}` looped 5 times" in caplog.text + assert ( + f"script `Test {condition}` terminated because it looped 10 times" + in caplog.text + ) + + assert len(events) == 10 + + async def test_choose_warning( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: