diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 14f02620c9161..b4445f6fe4596 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.4.6"] + "requirements": ["aioairzone-cloud==0.4.7"] } diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py index ae920383e408e..ee180ab548014 100644 --- a/homeassistant/components/august/activity.py +++ b/homeassistant/components/august/activity.py @@ -5,6 +5,7 @@ from datetime import datetime from functools import partial import logging +from time import monotonic from aiohttp import ClientError from yalexs.activity import Activity, ActivityType @@ -26,9 +27,11 @@ ACTIVITY_STREAM_FETCH_LIMIT = 10 ACTIVITY_CATCH_UP_FETCH_LIMIT = 2500 +INITIAL_LOCK_RESYNC_TIME = 60 + # If there is a storm of activity (ie lock, unlock, door open, door close, etc) # we want to debounce the updates so we don't hammer the activity api too much. -ACTIVITY_DEBOUNCE_COOLDOWN = 3 +ACTIVITY_DEBOUNCE_COOLDOWN = 4 @callback @@ -62,6 +65,7 @@ def __init__( self.pubnub = pubnub self._update_debounce: dict[str, Debouncer] = {} self._update_debounce_jobs: dict[str, HassJob] = {} + self._start_time: float | None = None @callback def _async_update_house_id_later(self, debouncer: Debouncer, _: datetime) -> None: @@ -70,6 +74,7 @@ def _async_update_house_id_later(self, debouncer: Debouncer, _: datetime) -> Non async def async_setup(self) -> None: """Token refresh check and catch up the activity stream.""" + self._start_time = monotonic() update_debounce = self._update_debounce update_debounce_jobs = self._update_debounce_jobs for house_id in self._house_ids: @@ -140,11 +145,25 @@ def async_schedule_house_id_refresh(self, house_id: str) -> None: debouncer = self._update_debounce[house_id] debouncer.async_schedule_call() + # Schedule two updates past the debounce time # to ensure we catch the case where the activity # api does not update right away and we need to poll # it again. Sometimes the lock operator or a doorbell # will not show up in the activity stream right away. + # Only do additional polls if we are past + # the initial lock resync time to avoid a storm + # of activity at setup. + if ( + not self._start_time + or monotonic() - self._start_time < INITIAL_LOCK_RESYNC_TIME + ): + _LOGGER.debug( + "Skipping additional updates due to ongoing initial lock resync time" + ) + return + + _LOGGER.debug("Scheduling additional updates for house id %s", house_id) job = self._update_debounce_jobs[house_id] for step in (1, 2): future_updates.append( diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py index 0cbd21f397e03..6aa033c62b265 100644 --- a/homeassistant/components/august/const.py +++ b/homeassistant/components/august/const.py @@ -40,7 +40,7 @@ # Limit battery, online, and hardware updates to hourly # in order to reduce the number of api requests and # avoid hitting rate limits -MIN_TIME_BETWEEN_DETAIL_UPDATES = timedelta(hours=1) +MIN_TIME_BETWEEN_DETAIL_UPDATES = timedelta(hours=24) # Activity needs to be checked more frequently as the # doorbell motion and rings are included here diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py index e800b5cb60420..9332080d9ad66 100644 --- a/homeassistant/components/august/subscriber.py +++ b/homeassistant/components/august/subscriber.py @@ -49,9 +49,17 @@ def _async_scheduled_refresh(self, now: datetime) -> None: """Call the refresh method.""" self._hass.async_create_task(self._async_refresh(now), eager_start=True) + @callback + def _async_cancel_update_interval(self, _: Event | None = None) -> None: + """Cancel the scheduled update.""" + if self._unsub_interval: + self._unsub_interval() + self._unsub_interval = None + @callback def _async_setup_listeners(self) -> None: """Create interval and stop listeners.""" + self._async_cancel_update_interval() self._unsub_interval = async_track_time_interval( self._hass, self._async_scheduled_refresh, @@ -59,17 +67,12 @@ def _async_setup_listeners(self) -> None: name="august refresh", ) - @callback - def _async_cancel_update_interval(_: Event) -> None: - self._stop_interval = None - if self._unsub_interval: - self._unsub_interval() - - self._stop_interval = self._hass.bus.async_listen( - EVENT_HOMEASSISTANT_STOP, - _async_cancel_update_interval, - run_immediately=True, - ) + if not self._stop_interval: + self._stop_interval = self._hass.bus.async_listen( + EVENT_HOMEASSISTANT_STOP, + self._async_cancel_update_interval, + run_immediately=True, + ) @callback def async_unsubscribe_device_id( @@ -82,13 +85,7 @@ def async_unsubscribe_device_id( if self._subscriptions: return - - if self._unsub_interval: - self._unsub_interval() - self._unsub_interval = None - if self._stop_interval: - self._stop_interval() - self._stop_interval = None + self._async_cancel_update_interval() @callback def async_signal_device_id_update(self, device_id: str) -> None: diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index 769be676a7832..025244fb67587 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -56,6 +56,7 @@ def __init__(self, hub: AxisHub) -> None: mjpeg_url=self.mjpeg_source, still_image_url=self.image_source, authentication=HTTP_DIGEST_AUTHENTICATION, + verify_ssl=False, unique_id=f"{hub.unique_id}-camera", ) @@ -74,16 +75,18 @@ def _generate_sources(self) -> None: Additionally used when device change IP address. """ + proto = self.hub.config.protocol + host = self.hub.config.host + port = self.hub.config.port + image_options = self.generate_options(skip_stream_profile=True) self._still_image_url = ( - f"http://{self.hub.config.host}:{self.hub.config.port}/axis-cgi" - f"/jpg/image.cgi{image_options}" + f"{proto}://{host}:{port}/axis-cgi/jpg/image.cgi{image_options}" ) mjpeg_options = self.generate_options() self._mjpeg_url = ( - f"http://{self.hub.config.host}:{self.hub.config.port}/axis-cgi" - f"/mjpg/video.cgi{mjpeg_options}" + f"{proto}://{host}:{port}/axis-cgi/mjpg/video.cgi{mjpeg_options}" ) stream_options = self.generate_options(add_video_codec_h264=True) @@ -95,10 +98,7 @@ def _generate_sources(self) -> None: self.hub.additional_diagnostics["camera_sources"] = { "Image": self._still_image_url, "MJPEG": self._mjpeg_url, - "Stream": ( - f"rtsp://user:pass@{self.hub.config.host}/axis-media" - f"/media.amp{stream_options}" - ), + "Stream": (f"rtsp://user:pass@{host}/axis-media/media.amp{stream_options}"), } @property diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 30bc653c2028d..80872fc9be4cc 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -168,16 +168,13 @@ async def _redo_configuration( self, entry_data: Mapping[str, Any], keep_password: bool ) -> ConfigFlowResult: """Re-run configuration step.""" + protocol = entry_data.get(CONF_PROTOCOL, "http") + password = entry_data[CONF_PASSWORD] if keep_password else "" self.discovery_schema = { - vol.Required( - CONF_PROTOCOL, default=entry_data.get(CONF_PROTOCOL, "http") - ): str, + vol.Required(CONF_PROTOCOL, default=protocol): vol.In(PROTOCOL_CHOICES), vol.Required(CONF_HOST, default=entry_data[CONF_HOST]): str, vol.Required(CONF_USERNAME, default=entry_data[CONF_USERNAME]): str, - vol.Required( - CONF_PASSWORD, - default=entry_data[CONF_PASSWORD] if keep_password else "", - ): str, + vol.Required(CONF_PASSWORD, default=password): str, vol.Required(CONF_PORT, default=entry_data[CONF_PORT]): int, } diff --git a/homeassistant/components/axis/hub/config.py b/homeassistant/components/axis/hub/config.py index e6d8378b45c7b..eba706edc837d 100644 --- a/homeassistant/components/axis/hub/config.py +++ b/homeassistant/components/axis/hub/config.py @@ -12,6 +12,7 @@ CONF_NAME, CONF_PASSWORD, CONF_PORT, + CONF_PROTOCOL, CONF_TRIGGER_TIME, CONF_USERNAME, ) @@ -31,6 +32,7 @@ class AxisConfig: entry: ConfigEntry + protocol: str host: str port: int username: str @@ -54,6 +56,7 @@ def from_config_entry(cls, config_entry: ConfigEntry) -> Self: options = config_entry.options return cls( entry=config_entry, + protocol=config.get(CONF_PROTOCOL, "http"), host=config[CONF_HOST], username=config[CONF_USERNAME], password=config[CONF_PASSWORD], diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py index 94d243e2cf27e..3fded1215c48c 100644 --- a/homeassistant/components/downloader/__init__.py +++ b/homeassistant/components/downloader/__init__.py @@ -11,7 +11,11 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + HomeAssistant, + ServiceCall, +) from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -43,6 +47,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if DOMAIN not in config: return True + hass.async_create_task(_async_import_config(hass, config)) + return True + + +async def _async_import_config(hass: HomeAssistant, config: ConfigType) -> None: + """Import the Downloader component from the YAML file.""" + import_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, @@ -51,28 +62,40 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: }, ) - translation_key = "deprecated_yaml" if ( import_result["type"] == FlowResultType.ABORT - and import_result["reason"] == "import_failed" + and import_result["reason"] != "single_instance_allowed" ): - translation_key = "import_failed" - - async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.9.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key=translation_key, - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Downloader", - }, - ) - return True + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.10.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="directory_does_not_exist", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Downloader", + "url": "/config/integrations/dashboard/add?domain=downloader", + }, + ) + else: + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.10.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Downloader", + }, + ) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -83,7 +106,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not os.path.isabs(download_path): download_path = hass.config.path(download_path) - if not os.path.isdir(download_path): + if not await hass.async_add_executor_job(os.path.isdir, download_path): _LOGGER.error( "Download path %s does not exist. File Downloader not active", download_path ) diff --git a/homeassistant/components/downloader/config_flow.py b/homeassistant/components/downloader/config_flow.py index 15af8b56163bf..94b33f4e93fa5 100644 --- a/homeassistant/components/downloader/config_flow.py +++ b/homeassistant/components/downloader/config_flow.py @@ -46,12 +46,16 @@ async def async_step_user( errors=errors, ) - async def async_step_import( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: """Handle a flow initiated by configuration file.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") - return await self.async_step_user(user_input) + try: + await self._validate_input(user_input) + except DirectoryDoesNotExist: + return self.async_abort(reason="directory_does_not_exist") + return self.async_create_entry(title=DEFAULT_NAME, data=user_input) async def _validate_input(self, user_input: dict[str, Any]) -> None: """Validate the user input if the directory exists.""" diff --git a/homeassistant/components/downloader/strings.json b/homeassistant/components/downloader/strings.json index 77dd0abd9d32a..4cadabf96c678 100644 --- a/homeassistant/components/downloader/strings.json +++ b/homeassistant/components/downloader/strings.json @@ -37,13 +37,9 @@ } }, "issues": { - "deprecated_yaml": { - "title": "The {integration_title} YAML configuration is being removed", - "description": "Configuring {integration_title} using YAML is being removed.\n\nYour configuration is already imported.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - }, - "import_failed": { + "directory_does_not_exist": { "title": "The {integration_title} failed to import", - "description": "The {integration_title} integration failed to import.\n\nPlease check the logs for more details." + "description": "The {integration_title} integration failed to import because the configured directory does not exist.\n\nEnsure the directory exists and restart Home Assistant to try again or remove the {integration_title} configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." } } } diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 1890572bf5a10..028fb28f01bb7 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20240403.1"] + "requirements": ["home-assistant-frontend==20240404.1"] } diff --git a/homeassistant/components/lovelace/cast.py b/homeassistant/components/lovelace/cast.py index 02f5d0c0478ca..82a92b94ae5d8 100644 --- a/homeassistant/components/lovelace/cast.py +++ b/homeassistant/components/lovelace/cast.py @@ -179,7 +179,7 @@ async def _get_dashboard_info(hass, url_path): "views": views, } - if config is None: + if config is None or "views" not in config: return data for idx, view in enumerate(config["views"]): diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index 18b5edd10396b..eb003fd431ab6 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -141,7 +141,7 @@ def turn_on(self, **kwargs: Any) -> None: else: brightness = self._prev_brightness self._prev_brightness = brightness - args = {"new_level": brightness} + args = {"new_level": to_lutron_level(brightness)} if ATTR_TRANSITION in kwargs: args["fade_time_seconds"] = kwargs[ATTR_TRANSITION] self._lutron_device.set_level(**args) diff --git a/homeassistant/components/myuplink/__init__.py b/homeassistant/components/myuplink/__init__.py index 5dee46b24cfa0..42bb90077898e 100644 --- a/homeassistant/components/myuplink/__init__.py +++ b/homeassistant/components/myuplink/__init__.py @@ -5,7 +5,7 @@ from http import HTTPStatus from aiohttp import ClientError, ClientResponseError -from myuplink import MyUplinkAPI, get_manufacturer, get_system_name +from myuplink import MyUplinkAPI, get_manufacturer, get_model, get_system_name from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -92,7 +92,7 @@ def create_devices( identifiers={(DOMAIN, device_id)}, name=get_system_name(system), manufacturer=get_manufacturer(device), - model=device.productName, + model=get_model(device), sw_version=device.firmwareCurrent, serial_number=device.product_serial_number, ) diff --git a/homeassistant/components/myuplink/manifest.json b/homeassistant/components/myuplink/manifest.json index a76f596ade356..0e638a72715da 100644 --- a/homeassistant/components/myuplink/manifest.json +++ b/homeassistant/components/myuplink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/myuplink", "iot_class": "cloud_polling", - "requirements": ["myuplink==0.5.0"] + "requirements": ["myuplink==0.6.0"] } diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index ca45e3a6d168d..1793a0cfd474e 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -108,7 +108,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: (CONF_REFRESH_TOKEN, client.refresh_token), (CONF_USER_UUID, client.user_uuid), ): - if entry.data[key] == value: + if entry.data.get(key) == value: continue entry_updates["data"][key] = value diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 879aeb0327b17..51ad669733bde 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.4.2"] + "requirements": ["opower==0.4.3"] } diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 5b24448211d4c..eac743c3d75f8 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -715,6 +715,7 @@ class Statistics(Base, StatisticsBase): "start_ts", unique=True, ), + _DEFAULT_TABLE_ARGS, ) __tablename__ = TABLE_STATISTICS @@ -732,6 +733,7 @@ class StatisticsShortTerm(Base, StatisticsBase): "start_ts", unique=True, ), + _DEFAULT_TABLE_ARGS, ) __tablename__ = TABLE_STATISTICS_SHORT_TERM @@ -760,7 +762,10 @@ def from_meta(meta: StatisticMetaData) -> StatisticsMeta: class RecorderRuns(Base): """Representation of recorder run.""" - __table_args__ = (Index("ix_recorder_runs_start_end", "start", "end"),) + __table_args__ = ( + Index("ix_recorder_runs_start_end", "start", "end"), + _DEFAULT_TABLE_ARGS, + ) __tablename__ = TABLE_RECORDER_RUNS run_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) start: Mapped[datetime] = mapped_column(DATETIME_TYPE, default=dt_util.utcnow) @@ -789,6 +794,7 @@ class MigrationChanges(Base): """Representation of migration changes.""" __tablename__ = TABLE_MIGRATION_CHANGES + __table_args__ = (_DEFAULT_TABLE_ARGS,) migration_id: Mapped[str] = mapped_column(String(255), primary_key=True) version: Mapped[int] = mapped_column(SmallInteger) @@ -798,6 +804,8 @@ class SchemaChanges(Base): """Representation of schema version changes.""" __tablename__ = TABLE_SCHEMA_CHANGES + __table_args__ = (_DEFAULT_TABLE_ARGS,) + change_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) schema_version: Mapped[int | None] = mapped_column(Integer) changed: Mapped[datetime] = mapped_column(DATETIME_TYPE, default=dt_util.utcnow) @@ -816,6 +824,8 @@ class StatisticsRuns(Base): """Representation of statistics run.""" __tablename__ = TABLE_STATISTICS_RUNS + __table_args__ = (_DEFAULT_TABLE_ARGS,) + run_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) start: Mapped[datetime] = mapped_column(DATETIME_TYPE, index=True) diff --git a/homeassistant/components/romy/manifest.json b/homeassistant/components/romy/manifest.json index 1257c2d1d600f..efb8072ebbcdb 100644 --- a/homeassistant/components/romy/manifest.json +++ b/homeassistant/components/romy/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/romy", "iot_class": "local_polling", - "requirements": ["romy==0.0.7"], + "requirements": ["romy==0.0.10"], "zeroconf": ["_aicu-http._tcp.local."] } diff --git a/homeassistant/components/rova/sensor.py b/homeassistant/components/rova/sensor.py index e510bcf0caf92..f63b9893c02fc 100644 --- a/homeassistant/components/rova/sensor.py +++ b/homeassistant/components/rova/sensor.py @@ -54,7 +54,7 @@ vol.Optional(CONF_HOUSE_NUMBER_SUFFIX, default=""): cv.string, vol.Optional(CONF_NAME, default="Rova"): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS, default=["bio"]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(["bio", "paper", "plastic", "residual"])] ), } ) diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index f55cd07effb58..972b91319355e 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -270,7 +270,7 @@ def _decode_value(self, value): "SNMP OID %s received type=%s and data %s", self._baseoid, type(value), - bytes(value), + value, ) if isinstance(value, NoSuchObject): _LOGGER.error( diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index b4365fda778d9..aea66d22f62a8 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["systembridgeconnector"], "quality_scale": "silver", - "requirements": ["systembridgeconnector==4.0.3"], + "requirements": ["systembridgeconnector==4.0.3", "systembridgemodels==4.0.4"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index 8376bd1b50d7e..361349dcbe881 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", "iot_class": "cloud_polling", - "requirements": ["weatherflow4py==0.2.17"] + "requirements": ["weatherflow4py==0.2.20"] } diff --git a/homeassistant/const.py b/homeassistant/const.py index 6e08c49f970a8..b642ce6ce8c5f 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 = "0" +PATCH_VERSION: Final = "1" __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/core.py b/homeassistant/core.py index 4794b284fd258..d4510e970f914 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -401,6 +401,7 @@ def __init__(self, config_dir: str) -> None: self.services = ServiceRegistry(self) self.states = StateMachine(self.bus, self.loop) self.config = Config(self, config_dir) + self.config.async_initialize() self.components = loader.Components(self) self.helpers = loader.Helpers(self) self.state: CoreState = CoreState.not_running @@ -2589,12 +2590,12 @@ async def _execute_service( class Config: """Configuration settings for Home Assistant.""" + _store: Config._ConfigStore + def __init__(self, hass: HomeAssistant, config_dir: str) -> None: """Initialize a new config object.""" self.hass = hass - self._store = self._ConfigStore(self.hass, config_dir) - self.latitude: float = 0 self.longitude: float = 0 @@ -2645,6 +2646,13 @@ def __init__(self, hass: HomeAssistant, config_dir: str) -> None: # If Home Assistant is running in safe mode self.safe_mode: bool = False + def async_initialize(self) -> None: + """Finish initializing a config object. + + This must be called before the config object is used. + """ + self._store = self._ConfigStore(self.hass) + def distance(self, lat: float, lon: float) -> float | None: """Calculate distance from Home Assistant. @@ -2850,7 +2858,6 @@ async def _async_store(self) -> None: "country": self.country, "language": self.language, } - await self._store.async_save(data) # Circular dependency prevents us from generating the class at top level @@ -2860,7 +2867,7 @@ async def _async_store(self) -> None: class _ConfigStore(Store[dict[str, Any]]): """Class to help storing Config data.""" - def __init__(self, hass: HomeAssistant, config_dir: str) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize storage class.""" super().__init__( hass, @@ -2869,7 +2876,6 @@ def __init__(self, hass: HomeAssistant, config_dir: str) -> None: private=True, atomic_writes=True, minor_version=CORE_STORAGE_MINOR_VERSION, - config_dir=config_dir, ) self._original_unit_system: str | None = None # from old store 1.1 diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index f7245607be726..70de144d5c867 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1855,6 +1855,12 @@ def determine_script_action(action: dict[str, Any]) -> str: """Determine action type.""" if not (actions := ACTIONS_SET.intersection(action)): raise ValueError("Unable to determine action") + if len(actions) > 1: + # Ambiguous action, select the first one in the + # order of the ACTIONS_MAP + for action_key, _script_action in ACTIONS_MAP.items(): + if action_key in actions: + return _script_action return ACTIONS_MAP[actions.pop()] diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 2413a53e60516..92a31ae934542 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -95,9 +95,7 @@ def load_old_config(): return config -def get_internal_store_manager( - hass: HomeAssistant, config_dir: str | None = None -) -> _StoreManager: +def get_internal_store_manager(hass: HomeAssistant) -> _StoreManager: """Get the store manager. This function is not part of the API and should only be @@ -105,7 +103,7 @@ def get_internal_store_manager( guaranteed to be stable. """ if STORAGE_MANAGER not in hass.data: - manager = _StoreManager(hass, config_dir or hass.config.config_dir) + manager = _StoreManager(hass) hass.data[STORAGE_MANAGER] = manager return hass.data[STORAGE_MANAGER] @@ -116,13 +114,13 @@ class _StoreManager: The store manager is used to cache and manage storage files. """ - def __init__(self, hass: HomeAssistant, config_dir: str) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize storage manager class.""" self._hass = hass self._invalidated: set[str] = set() self._files: set[str] | None = None self._data_preload: dict[str, json_util.JsonValueType] = {} - self._storage_path: Path = Path(config_dir).joinpath(STORAGE_DIR) + self._storage_path: Path = Path(hass.config.config_dir).joinpath(STORAGE_DIR) self._cancel_cleanup: asyncio.TimerHandle | None = None async def async_initialize(self) -> None: @@ -251,7 +249,6 @@ def __init__( encoder: type[JSONEncoder] | None = None, minor_version: int = 1, read_only: bool = False, - config_dir: str | None = None, ) -> None: """Initialize storage class.""" self.version = version @@ -268,7 +265,7 @@ def __init__( self._atomic_writes = atomic_writes self._read_only = read_only self._next_write_time = 0.0 - self._manager = get_internal_store_manager(hass, config_dir) + self._manager = get_internal_store_manager(hass) @cached_property def path(self): diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6bb6bd4d2d3fa..bd35403340fa3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ habluetooth==2.4.2 hass-nabucasa==0.79.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240403.1 +home-assistant-frontend==20240404.1 home-assistant-intents==2024.4.3 httpx==0.27.0 ifaddr==0.2.0 diff --git a/pyproject.toml b/pyproject.toml index e0f07fac6b69d..2dd3a9632c6f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.4.0" +version = "2024.4.1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -513,8 +513,6 @@ filterwarnings = [ "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", # https://github.com/pyudev/pyudev/pull/466 - >=0.24.0 "ignore:invalid escape sequence:SyntaxWarning:.*pyudev.monitor", - # https://github.com/xeniter/romy/pull/1 - >=0.0.8 - "ignore:with timeout\\(\\) is deprecated, use async with timeout\\(\\) instead:DeprecationWarning:romy.utils", # https://github.com/grahamwetzler/smart-meter-texas/pull/143 - >0.5.3 "ignore:ssl.OP_NO_SSL\\*/ssl.OP_NO_TLS\\* options are deprecated:DeprecationWarning:smart_meter_texas", # https://github.com/mvantellingen/python-zeep/pull/1364 - >4.2.1 diff --git a/requirements_all.txt b/requirements_all.txt index 76dc587d6b9d4..a87df9614d126 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aio-georss-gdacs==0.9 aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.4.6 +aioairzone-cloud==0.4.7 # homeassistant.components.airzone aioairzone==0.7.6 @@ -1077,7 +1077,7 @@ hole==0.8.0 holidays==0.46 # homeassistant.components.frontend -home-assistant-frontend==20240403.1 +home-assistant-frontend==20240404.1 # homeassistant.components.conversation home-assistant-intents==2024.4.3 @@ -1349,7 +1349,7 @@ mutesync==0.0.1 mypermobil==0.1.8 # homeassistant.components.myuplink -myuplink==0.5.0 +myuplink==0.6.0 # homeassistant.components.nad nad-receiver==0.3.0 @@ -1482,7 +1482,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.4.2 +opower==0.4.3 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -2459,7 +2459,7 @@ rocketchat-API==0.6.1 rokuecp==0.19.2 # homeassistant.components.romy -romy==0.0.7 +romy==0.0.10 # homeassistant.components.roomba roombapy==1.8.1 @@ -2654,6 +2654,9 @@ synology-srm==0.2.0 # homeassistant.components.system_bridge systembridgeconnector==4.0.3 +# homeassistant.components.system_bridge +systembridgemodels==4.0.4 + # homeassistant.components.tailscale tailscale==0.6.0 @@ -2838,7 +2841,7 @@ watchdog==2.3.1 waterfurnace==1.1.0 # homeassistant.components.weatherflow_cloud -weatherflow4py==0.2.17 +weatherflow4py==0.2.20 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f329b782aaa8..1f5d01eb46cd8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,7 +164,7 @@ aio-georss-gdacs==0.9 aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.4.6 +aioairzone-cloud==0.4.7 # homeassistant.components.airzone aioairzone==0.7.6 @@ -876,7 +876,7 @@ hole==0.8.0 holidays==0.46 # homeassistant.components.frontend -home-assistant-frontend==20240403.1 +home-assistant-frontend==20240404.1 # homeassistant.components.conversation home-assistant-intents==2024.4.3 @@ -1088,7 +1088,7 @@ mutesync==0.0.1 mypermobil==0.1.8 # homeassistant.components.myuplink -myuplink==0.5.0 +myuplink==0.6.0 # homeassistant.components.keenetic_ndms2 ndms2-client==0.1.2 @@ -1176,7 +1176,7 @@ openerz-api==0.3.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.4.2 +opower==0.4.3 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1893,7 +1893,7 @@ ring-doorbell[listen]==0.8.9 rokuecp==0.19.2 # homeassistant.components.romy -romy==0.0.7 +romy==0.0.10 # homeassistant.components.roomba roombapy==1.8.1 @@ -2049,6 +2049,9 @@ switchbot-api==2.0.0 # homeassistant.components.system_bridge systembridgeconnector==4.0.3 +# homeassistant.components.system_bridge +systembridgemodels==4.0.4 + # homeassistant.components.tailscale tailscale==0.6.0 @@ -2185,7 +2188,7 @@ wallbox==0.6.0 watchdog==2.3.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==0.2.17 +weatherflow4py==0.2.20 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index 39c1745d967e7..4de931e6979e4 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -4,9 +4,11 @@ from unittest.mock import Mock from aiohttp import ClientResponseError +from freezegun.api import FrozenDateTimeFactory import pytest from yalexs.pubnub_async import AugustPubNub +from homeassistant.components.august.activity import INITIAL_LOCK_RESYNC_TIME from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, STATE_JAMMED, @@ -155,7 +157,9 @@ async def test_one_lock_operation( async def test_one_lock_operation_pubnub_connected( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Test lock and unlock operations are async when pubnub is connected.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) @@ -230,6 +234,23 @@ async def test_one_lock_operation_pubnub_connected( == STATE_UNKNOWN ) + freezer.tick(INITIAL_LOCK_RESYNC_TIME) + + pubnub.message( + pubnub, + Mock( + channel=lock_one.pubsub_channel, + timetoken=(dt_util.utcnow().timestamp() + 2) * 10000000, + message={ + "status": "kAugLockState_Unlocked", + }, + ), + ) + await hass.async_block_till_done() + + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + assert lock_online_with_doorsense_name.state == STATE_UNLOCKED + async def test_lock_jammed(hass: HomeAssistant) -> None: """Test lock gets jammed on unlock.""" diff --git a/tests/components/downloader/test_config_flow.py b/tests/components/downloader/test_config_flow.py index 5e75a9b33ba33..897fbba0c599f 100644 --- a/tests/components/downloader/test_config_flow.py +++ b/tests/components/downloader/test_config_flow.py @@ -99,3 +99,19 @@ async def test_import_flow_success(hass: HomeAssistant) -> None: assert result["title"] == "Downloader" assert result["data"] == {} assert result["options"] == {} + + +async def test_import_flow_directory_not_found(hass: HomeAssistant) -> None: + """Test import flow.""" + with patch("os.path.isdir", return_value=False): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_DOWNLOAD_DIR: "download_dir", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "directory_does_not_exist" diff --git a/tests/components/downloader/test_init.py b/tests/components/downloader/test_init.py new file mode 100644 index 0000000000000..5832c0402b420 --- /dev/null +++ b/tests/components/downloader/test_init.py @@ -0,0 +1,111 @@ +"""Tests for the downloader component init.""" + +from unittest.mock import patch + +from homeassistant.components.downloader import ( + CONF_DOWNLOAD_DIR, + DOMAIN, + SERVICE_DOWNLOAD_FILE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_initialization(hass: HomeAssistant) -> None: + """Test the initialization of the downloader component.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_DOWNLOAD_DIR: "/test_dir", + }, + ) + config_entry.add_to_hass(hass) + with patch("os.path.isdir", return_value=True): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + assert hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE) + assert config_entry.state is ConfigEntryState.LOADED + + +async def test_import(hass: HomeAssistant, issue_registry: ir.IssueRegistry) -> None: + """Test the import of the downloader component.""" + with patch("os.path.isdir", return_value=True): + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_DOWNLOAD_DIR: "/test_dir", + }, + }, + ) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {CONF_DOWNLOAD_DIR: "/test_dir"} + assert config_entry.state is ConfigEntryState.LOADED + assert hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE) + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue( + issue_id="deprecated_yaml_downloader", domain=HOMEASSISTANT_DOMAIN + ) + assert issue + + +async def test_import_directory_missing( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the import of the downloader component.""" + with patch("os.path.isdir", return_value=False): + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_DOWNLOAD_DIR: "/test_dir", + }, + }, + ) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue( + issue_id="deprecated_yaml_downloader", domain=DOMAIN + ) + assert issue + + +async def test_import_already_exists( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the import of the downloader component.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_DOWNLOAD_DIR: "/test_dir", + }, + ) + config_entry.add_to_hass(hass) + with patch("os.path.isdir", return_value=True): + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_DOWNLOAD_DIR: "/test_dir", + }, + }, + ) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue( + issue_id="deprecated_yaml_downloader", domain=HOMEASSISTANT_DOMAIN + ) + assert issue diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index cde2da3cc83c2..206c356bad8bd 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -27,6 +27,7 @@ DOMAIN, SQLITE_URL_PREFIX, Recorder, + db_schema, get_instance, migration, pool, @@ -2598,3 +2599,9 @@ def run(self, instance: Recorder) -> None: await verify_states_in_queue_future await verify_session_commit_future + + +def test_all_tables_use_default_table_args(hass: HomeAssistant) -> None: + """Test that all tables use the default table args.""" + for table in db_schema.Base.metadata.tables.values(): + assert table.kwargs.items() >= db_schema._DEFAULT_TABLE_ARGS.items() diff --git a/tests/components/snmp/test_negative_sensor.py b/tests/components/snmp/test_negative_sensor.py new file mode 100644 index 0000000000000..c5ac6460841fc --- /dev/null +++ b/tests/components/snmp/test_negative_sensor.py @@ -0,0 +1,79 @@ +"""SNMP sensor tests.""" + +from unittest.mock import patch + +from pysnmp.hlapi import Integer32 +import pytest + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +def hlapi_mock(): + """Mock out 3rd party API.""" + mock_data = Integer32(-13) + with patch( + "homeassistant.components.snmp.sensor.getCmd", + return_value=(None, None, None, [[mock_data]]), + ): + yield + + +async def test_basic_config(hass: HomeAssistant) -> None: + """Test basic entity configuration.""" + + config = { + SENSOR_DOMAIN: { + "platform": "snmp", + "host": "192.168.1.32", + "baseoid": "1.3.6.1.4.1.2021.10.1.3.1", + }, + } + + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.snmp") + assert state.state == "-13" + assert state.attributes == {"friendly_name": "SNMP"} + + +async def test_entity_config(hass: HomeAssistant) -> None: + """Test entity configuration.""" + + config = { + SENSOR_DOMAIN: { + # SNMP configuration + "platform": "snmp", + "host": "192.168.1.32", + "baseoid": "1.3.6.1.4.1.2021.10.1.3.1", + # Entity configuration + "icon": "{{'mdi:one_two_three'}}", + "picture": "{{'blabla.png'}}", + "device_class": "temperature", + "name": "{{'SNMP' + ' ' + 'Sensor'}}", + "state_class": "measurement", + "unique_id": "very_unique", + "unit_of_measurement": "°C", + }, + } + + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + assert entity_registry.async_get("sensor.snmp_sensor").unique_id == "very_unique" + + state = hass.states.get("sensor.snmp_sensor") + assert state.state == "-13" + assert state.attributes == { + "device_class": "temperature", + "entity_picture": "blabla.png", + "friendly_name": "SNMP Sensor", + "icon": "mdi:one_two_three", + "state_class": "measurement", + "unit_of_measurement": "°C", + } diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 133e5e80442dd..9816dc381897c 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1672,3 +1672,25 @@ def test_color_hex() -> None: with pytest.raises(vol.Invalid, match=msg): cv.color_hex(123456) + + +def test_determine_script_action_ambiguous(): + """Test determine script action with ambiguous actions.""" + assert ( + cv.determine_script_action( + { + "type": "is_power", + "condition": "device", + "device_id": "9c2bda81bc7997c981f811c32cafdb22", + "entity_id": "2ee287ec70dd0c6db187b539bee429b7", + "domain": "sensor", + "below": "15", + } + ) + == "condition" + ) + + +def test_determine_script_action_non_ambiguous(): + """Test determine script action with a non ambiguous action.""" + assert cv.determine_script_action({"delay": "00:00:05"}) == "delay" diff --git a/tests/test_core.py b/tests/test_core.py index a0a197096cdcf..905d8efe6de96 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2288,6 +2288,7 @@ async def test_additional_data_in_core_config( ) -> None: """Test that we can handle additional data in core configuration.""" config = ha.Config(hass, "/test/ha-config") + config.async_initialize() hass_storage[ha.CORE_STORAGE_KEY] = { "version": 1, "data": {"location_name": "Test Name", "additional_valid_key": "value"}, @@ -2301,6 +2302,7 @@ async def test_incorrect_internal_external_url( ) -> None: """Test that we warn when detecting invalid internal/external url.""" config = ha.Config(hass, "/test/ha-config") + config.async_initialize() hass_storage[ha.CORE_STORAGE_KEY] = { "version": 1, @@ -2314,6 +2316,7 @@ async def test_incorrect_internal_external_url( assert "Invalid internal_url set" not in caplog.text config = ha.Config(hass, "/test/ha-config") + config.async_initialize() hass_storage[ha.CORE_STORAGE_KEY] = { "version": 1,