diff --git a/.coveragerc b/.coveragerc index 378532dfd88cfd..0ca73662a84de3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -639,6 +639,12 @@ omit = homeassistant/components/izone/climate.py homeassistant/components/izone/discovery.py homeassistant/components/joaoapps_join/* + homeassistant/components/juicenet/__init__.py + homeassistant/components/juicenet/device.py + homeassistant/components/juicenet/entity.py + homeassistant/components/juicenet/number.py + homeassistant/components/juicenet/sensor.py + homeassistant/components/juicenet/switch.py homeassistant/components/justnimbus/coordinator.py homeassistant/components/justnimbus/entity.py homeassistant/components/justnimbus/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 1424469a94bbe4..759d3cd84d3926 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -669,6 +669,8 @@ build.json @home-assistant/supervisor /tests/components/jellyfin/ @j-stienstra @ctalkington /homeassistant/components/jewish_calendar/ @tsvi /tests/components/jewish_calendar/ @tsvi +/homeassistant/components/juicenet/ @jesserockz +/tests/components/juicenet/ @jesserockz /homeassistant/components/justnimbus/ @kvanzuijlen /tests/components/justnimbus/ @kvanzuijlen /homeassistant/components/jvc_projector/ @SteveEasley @msavazzi diff --git a/homeassistant/components/airthings_ble/__init__.py b/homeassistant/components/airthings_ble/__init__.py index 3a97813741b42c..8258f7baf3d928 100644 --- a/homeassistant/components/airthings_ble/__init__.py +++ b/homeassistant/components/airthings_ble/__init__.py @@ -27,7 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) address = entry.unique_id - elevation = hass.config.elevation is_metric = hass.config.units is METRIC_SYSTEM assert address is not None @@ -40,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Could not find Airthings device with address {address}" ) - airthings = AirthingsBluetoothDeviceData(_LOGGER, elevation, is_metric) + airthings = AirthingsBluetoothDeviceData(_LOGGER, is_metric) async def _async_update_method() -> AirthingsDevice: """Get data from Airthings BLE.""" diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index 97e27793da2c20..3f7bd02a33e7ba 100644 --- a/homeassistant/components/airthings_ble/manifest.json +++ b/homeassistant/components/airthings_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/airthings_ble", "iot_class": "local_polling", - "requirements": ["airthings-ble==0.6.1"] + "requirements": ["airthings-ble==0.7.1"] } diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index 39c55e0b4655c7..2bc2d5e726afe6 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -16,7 +16,6 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, - LIGHT_LUX, PERCENTAGE, EntityCategory, Platform, @@ -106,8 +105,7 @@ ), "illuminance": SensorEntityDescription( key="illuminance", - device_class=SensorDeviceClass.ILLUMINANCE, - native_unit_of_measurement=LIGHT_LUX, + native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), } @@ -222,7 +220,7 @@ def __init__( manufacturer=airthings_device.manufacturer, hw_version=airthings_device.hw_version, sw_version=airthings_device.sw_version, - model=airthings_device.model, + model=airthings_device.model.name, ) @property diff --git a/homeassistant/components/aussie_broadband/__init__.py b/homeassistant/components/aussie_broadband/__init__.py index 093480afd7d23e..e65abaebb98fc0 100644 --- a/homeassistant/components/aussie_broadband/__init__.py +++ b/homeassistant/components/aussie_broadband/__init__.py @@ -31,9 +31,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_get_clientsession(hass), ) + # Ignore services that don't support usage data + ignore_types = FETCH_TYPES + ["Hardware"] + try: await client.login() - services = await client.get_services(drop_types=FETCH_TYPES) + services = await client.get_services(drop_types=ignore_types) except AuthenticationException as exc: raise ConfigEntryAuthFailed() from exc except ClientError as exc: diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 5311d18f991370..f56df16918ef25 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==50"], + "requirements": ["axis==54"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/homeassistant/components/bosch_shc/manifest.json b/homeassistant/components/bosch_shc/manifest.json index e29865153b3b6f..efae159adc20bc 100644 --- a/homeassistant/components/bosch_shc/manifest.json +++ b/homeassistant/components/bosch_shc/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/bosch_shc", "iot_class": "local_push", "loggers": ["boschshcpy"], - "requirements": ["boschshcpy==0.2.75"], + "requirements": ["boschshcpy==0.2.82"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index d8bfc6c7ebde1a..6b905a61b7dbe4 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bring", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["bring-api==0.5.5"] + "requirements": ["bring-api==0.5.6"] } diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index 32fee44de998f2..56d16ba77314b8 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -33,7 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: brother = await Brother.create( host, printer_type=printer_type, snmp_engine=snmp_engine ) - except (ConnectionError, SnmpError) as error: + except (ConnectionError, SnmpError, TimeoutError) as error: raise ConfigEntryNotReady from error coordinator = BrotherDataUpdateCoordinator(hass, brother) diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index 26317b39ab5fa2..9ca18a95a1e1d5 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.0"], + "requirements": ["brother==4.0.2"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index a3e974bf71e2e5..5fb90bb5998fe0 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.6.0"] + "requirements": ["bthome-ble==3.8.0"] } diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index bef0e2fc09f9f4..670d448a430067 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -189,6 +189,11 @@ def _validate_rrule(value: Any) -> str: return str(value) +def _empty_as_none(value: str | None) -> str | None: + """Convert any empty string values to None.""" + return value or None + + CREATE_EVENT_SERVICE = "create_event" CREATE_EVENT_SCHEMA = vol.All( cv.has_at_least_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), @@ -733,7 +738,9 @@ async def handle_calendar_event_create( vol.Required("type"): "calendar/event/delete", vol.Required("entity_id"): cv.entity_id, vol.Required(EVENT_UID): cv.string, - vol.Optional(EVENT_RECURRENCE_ID): cv.string, + vol.Optional(EVENT_RECURRENCE_ID): vol.Any( + vol.All(cv.string, _empty_as_none), None + ), vol.Optional(EVENT_RECURRENCE_RANGE): cv.string, } ) @@ -777,7 +784,9 @@ async def handle_calendar_event_delete( vol.Required("type"): "calendar/event/update", vol.Required("entity_id"): cv.entity_id, vol.Required(EVENT_UID): cv.string, - vol.Optional(EVENT_RECURRENCE_ID): cv.string, + vol.Optional(EVENT_RECURRENCE_ID): vol.Any( + vol.All(cv.string, _empty_as_none), None + ), vol.Optional(EVENT_RECURRENCE_RANGE): cv.string, vol.Required(CONF_EVENT): WEBSOCKET_EVENT_SCHEMA, } diff --git a/homeassistant/components/camera/media_source.py b/homeassistant/components/camera/media_source.py index 3c9a386f958f6e..a49ce11413d065 100644 --- a/homeassistant/components/camera/media_source.py +++ b/homeassistant/components/camera/media_source.py @@ -12,6 +12,7 @@ PlayMedia, ) from homeassistant.components.stream import FORMAT_CONTENT_TYPE, HLS_PROVIDER +from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_component import EntityComponent @@ -25,13 +26,20 @@ async def async_get_media_source(hass: HomeAssistant) -> CameraMediaSource: return CameraMediaSource(hass) -def _media_source_for_camera(camera: Camera, content_type: str) -> BrowseMediaSource: +def _media_source_for_camera( + hass: HomeAssistant, camera: Camera, content_type: str +) -> BrowseMediaSource: + camera_state = hass.states.get(camera.entity_id) + title = camera.name + if camera_state: + title = camera_state.attributes.get(ATTR_FRIENDLY_NAME, camera.name) + return BrowseMediaSource( domain=DOMAIN, identifier=camera.entity_id, media_class=MediaClass.VIDEO, media_content_type=content_type, - title=camera.name, + title=title, thumbnail=f"/api/camera_proxy/{camera.entity_id}", can_play=True, can_expand=False, @@ -89,7 +97,7 @@ async def async_browse_media( async def _filter_browsable_camera(camera: Camera) -> BrowseMediaSource | None: stream_type = camera.frontend_stream_type if stream_type is None: - return _media_source_for_camera(camera, camera.content_type) + return _media_source_for_camera(self.hass, camera, camera.content_type) if not can_stream_hls: return None @@ -97,7 +105,7 @@ async def _filter_browsable_camera(camera: Camera) -> BrowseMediaSource | None: if stream_type != StreamType.HLS and not (await camera.stream_source()): return None - return _media_source_for_camera(camera, content_type) + return _media_source_for_camera(self.hass, camera, content_type) component: EntityComponent[Camera] = self.hass.data[DOMAIN] results = await asyncio.gather( diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json index 4317698b2579cc..5afce637ed5592 100644 --- a/homeassistant/components/climate/icons.json +++ b/homeassistant/components/climate/icons.json @@ -6,6 +6,7 @@ "fan_mode": { "default": "mdi:circle-medium", "state": { + "auto": "mdi:fan-auto", "diffuse": "mdi:weather-windy", "focus": "mdi:target", "high": "mdi:speedometer", diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 6f484941a3d96d..00f645ea0f3e88 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.6.1", "home-assistant-intents==2024.2.28"] + "requirements": ["hassil==1.6.1", "home-assistant-intents==2024.3.12"] } diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index d609e9ec7ae1f4..95c925a8d33200 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -15,7 +15,7 @@ ], "quality_scale": "internal", "requirements": [ - "aiodhcpwatcher==0.8.0", + "aiodhcpwatcher==0.8.1", "aiodiscover==1.6.1", "cached_ipaddress==0.3.0" ] diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 63e10547ead102..9f437ee9945683 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.19.1"], + "requirements": ["pyenphase==1.19.2"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index cea376fa8ffb35..f49a632cae2174 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==20240306.0"] + "requirements": ["home-assistant-frontend==20240307.0"] } diff --git a/homeassistant/components/gardena_bluetooth/coordinator.py b/homeassistant/components/gardena_bluetooth/coordinator.py index 73552e25c03533..12a212fe44bc0d 100644 --- a/homeassistant/components/gardena_bluetooth/coordinator.py +++ b/homeassistant/components/gardena_bluetooth/coordinator.py @@ -12,7 +12,6 @@ ) from gardena_bluetooth.parse import Characteristic, CharacteristicType -from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -117,13 +116,7 @@ def __init__(self, coordinator: Coordinator, context: Any = None) -> None: @property def available(self) -> bool: """Return if entity is available.""" - return ( - self.coordinator.last_update_success - and bluetooth.async_address_present( - self.hass, self.coordinator.address, True - ) - and self._attr_available - ) + return self.coordinator.last_update_success and self._attr_available class GardenaBluetoothDescriptorEntity(GardenaBluetoothEntity): diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 1b13430128fa4c..0437f8f617268e 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -229,11 +229,11 @@ def native_value(self) -> StateType: @property def available(self) -> bool: """Return if entity is available.""" - available = super().available sensor_data = getattr(self.coordinator.data, self.entity_description.key) + available = super().available and bool(sensor_data) # Sometimes the API returns sensor data without indexes - if self.entity_description.subkey: + if self.entity_description.subkey and available: return available and bool(sensor_data.index) - return available and bool(sensor_data) + return available diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 01c20595c557d3..a08daee89618ba 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/calendar.google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==6.0.3", "oauth2client==4.1.3", "ical==7.0.0"] + "requirements": ["gcal-sync==6.0.3", "oauth2client==4.1.3", "ical==7.0.1"] } diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 169fa30386df5f..aba6e461778193 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -2706,10 +2706,9 @@ class SensorStateTrait(_Trait): name = TRAIT_SENSOR_STATE commands: list[str] = [] - def _air_quality_description_for_aqi(self, aqi): - if aqi is None or aqi.isnumeric() is False: + def _air_quality_description_for_aqi(self, aqi: float | None) -> str: + if aqi is None or aqi < 0: return "unknown" - aqi = int(aqi) if aqi <= 50: return "healthy" if aqi <= 100: @@ -2764,11 +2763,17 @@ def query_attributes(self): if device_class is None or data is None: return {} - sensor_data = {"name": data[0], "rawValue": self.state.state} + try: + value = float(self.state.state) + except ValueError: + value = None + if self.state.state == STATE_UNKNOWN: + value = None + sensor_data = {"name": data[0], "rawValue": value} if device_class == sensor.SensorDeviceClass.AQI: sensor_data["currentSensorState"] = self._air_quality_description_for_aqi( - self.state.state + value ) return {"currentSensorStateData": [sensor_data]} diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index b495745e87ddfa..b31c0f1cf1592d 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -19,6 +19,7 @@ ATTR_HOMEASSISTANT_EXCLUDE_DATABASE = "homeassistant_exclude_database" ATTR_INPUT = "input" ATTR_ISSUES = "issues" +ATTR_MESSAGE = "message" ATTR_METHOD = "method" ATTR_PANELS = "panels" ATTR_PASSWORD = "password" diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 9b8e6367647f22..82a2db3c2341b2 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.loader import bind_hass -from .const import ATTR_DISCOVERY, DOMAIN, X_HASS_SOURCE +from .const import ATTR_DISCOVERY, ATTR_MESSAGE, ATTR_RESULT, DOMAIN, X_HASS_SOURCE _P = ParamSpec("_P") @@ -262,10 +262,7 @@ async def async_update_core( @bind_hass @_api_bool async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> dict: - """Apply a suggestion from supervisor's resolution center. - - The caller of the function should handle HassioAPIError. - """ + """Apply a suggestion from supervisor's resolution center.""" hassio: HassIO = hass.data[DOMAIN] command = f"/resolution/suggestion/{suggestion_uuid}" return await hassio.send_command(command, timeout=None) @@ -576,7 +573,7 @@ async def send_command( raise HassioAPIError() try: - request = await self.websession.request( + response = await self.websession.request( method, joined_url, json=payload, @@ -589,14 +586,23 @@ async def send_command( timeout=aiohttp.ClientTimeout(total=timeout), ) - if request.status != HTTPStatus.OK: - _LOGGER.error("%s return code %d", command, request.status) + if response.status != HTTPStatus.OK: + error = await response.json(encoding="utf-8") + if error.get(ATTR_RESULT) == "error": + raise HassioAPIError(error.get(ATTR_MESSAGE)) + + _LOGGER.error( + "Request to %s method %s returned with code %d", + command, + method, + response.status, + ) raise HassioAPIError() if return_text: - return await request.text(encoding="utf-8") + return await response.text(encoding="utf-8") - return await request.json(encoding="utf-8") + return await response.json(encoding="utf-8") except TimeoutError: _LOGGER.error("Timeout on %s request", command) diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 8bd47faef080fe..925c2d70afb82d 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -3,11 +3,13 @@ import asyncio from dataclasses import dataclass, field +from datetime import datetime import logging from typing import Any, NotRequired, TypedDict -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJob, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, @@ -35,6 +37,7 @@ EVENT_SUPPORTED_CHANGED, ISSUE_KEY_SYSTEM_DOCKER_CONFIG, PLACEHOLDER_KEY_REFERENCE, + REQUEST_REFRESH_DELAY, UPDATE_KEY_SUPERVISOR, SupervisorIssueContext, ) @@ -302,12 +305,17 @@ async def setup(self) -> None: self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues ) - async def update(self) -> None: + async def update(self, _: datetime | None = None) -> None: """Update issues from Supervisor resolution center.""" try: data = await self._client.get_resolution_info() except HassioAPIError as err: _LOGGER.error("Failed to update supervisor issues: %r", err) + async_call_later( + self._hass, + REQUEST_REFRESH_DELAY, + HassJob(self.update, cancel_on_shutdown=True), + ) return self.unhealthy_reasons = set(data[ATTR_UNHEALTHY]) self.unsupported_reasons = set(data[ATTR_UNSUPPORTED]) diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index fcfe23dda6e6c6..4538d9e1b332de 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -18,7 +18,7 @@ PLACEHOLDER_KEY_REFERENCE, SupervisorIssueContext, ) -from .handler import HassioAPIError, async_apply_suggestion +from .handler import async_apply_suggestion from .issues import Issue, Suggestion SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"} @@ -109,12 +109,9 @@ async def _async_step_apply_suggestion( if not confirmed and suggestion.key in SUGGESTION_CONFIRMATION_REQUIRED: return self._async_form_for_suggestion(suggestion) - try: - await async_apply_suggestion(self.hass, suggestion.uuid) - except HassioAPIError: - return self.async_abort(reason="apply_suggestion_fail") - - return self.async_create_entry(data={}) + if await async_apply_suggestion(self.hass, suggestion.uuid): + return self.async_create_entry(data={}) + return self.async_abort(reason="apply_suggestion_fail") @staticmethod def _async_step( diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index cf59f8de7f74be..13c258dd68c305 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -21,7 +21,6 @@ ATTR_DATA, ATTR_ENDPOINT, ATTR_METHOD, - ATTR_RESULT, ATTR_SESSION_DATA_USER_ID, ATTR_TIMEOUT, ATTR_WS_EVENT, @@ -131,9 +130,6 @@ async def websocket_supervisor_api( payload=payload, source="core.websocket_api", ) - - if result.get(ATTR_RESULT) == "error": - raise HassioAPIError(result.get("message")) except HassioAPIError as err: _LOGGER.error("Failed to to call %s - %s", msg[ATTR_ENDPOINT], err) connection.send_error( diff --git a/homeassistant/components/hue/switch.py b/homeassistant/components/hue/switch.py index c9da30a779c10c..c0689a85f21428 100644 --- a/homeassistant/components/hue/switch.py +++ b/homeassistant/components/hue/switch.py @@ -57,7 +57,7 @@ def async_add_entity( event_type: EventType, resource: BehaviorInstance | LightLevel | Motion ) -> None: """Add entity from Hue resource.""" - async_add_entities([switch_class(bridge, api.sensors.motion, resource)]) + async_add_entities([switch_class(bridge, controller, resource)]) # add all current items in controller for item in controller: diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 8ce6d287551b91..8f9def48a27dca 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -269,10 +269,7 @@ def _update_values(self) -> None: self._dynamic_mode_active = lights_in_dynamic_mode > 0 self._attr_supported_color_modes = supported_color_modes # pick a winner for the current colormode - if ( - lights_with_color_temp_support > 0 - and lights_in_colortemp_mode == lights_with_color_temp_support - ): + if lights_with_color_temp_support > 0 and lights_in_colortemp_mode > 0: self._attr_color_mode = ColorMode.COLOR_TEMP elif lights_with_color_support > 0: self._attr_color_mode = ColorMode.XY diff --git a/homeassistant/components/husqvarna_automower/config_flow.py b/homeassistant/components/husqvarna_automower/config_flow.py index cafe942a8944d4..e306be7137c015 100644 --- a/homeassistant/components/husqvarna_automower/config_flow.py +++ b/homeassistant/components/husqvarna_automower/config_flow.py @@ -2,7 +2,7 @@ import logging from typing import Any -from aioautomower.utils import async_structure_token +from aioautomower.utils import structure_token from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.data_entry_flow import FlowResult @@ -27,7 +27,7 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Create an entry for the flow.""" token = data[CONF_TOKEN] user_id = token[CONF_USER_ID] - structured_token = await async_structure_token(token[CONF_ACCESS_TOKEN]) + structured_token = structure_token(token[CONF_ACCESS_TOKEN]) first_name = structured_token.user.first_name last_name = structured_token.user.last_name await self.async_set_unique_id(user_id) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index dc40116f31efcb..f00baad661de3e 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -6,5 +6,6 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower", "iot_class": "cloud_push", - "requirements": ["aioautomower==2024.2.10"] + "loggers": ["aioautomower"], + "requirements": ["aioautomower==2024.3.0"] } diff --git a/homeassistant/components/hydrawise/config_flow.py b/homeassistant/components/hydrawise/config_flow.py index 72df86606d79c4..ea3b9e58926edc 100644 --- a/homeassistant/components/hydrawise/config_flow.py +++ b/homeassistant/components/hydrawise/config_flow.py @@ -52,7 +52,10 @@ def _import_issue(self, error_type: str) -> FlowResult: is_fixable=False, severity=IssueSeverity.ERROR, translation_key="deprecated_yaml_import_issue", - translation_placeholders={"error_type": error_type}, + translation_placeholders={ + "error_type": error_type, + "url": "/config/integrations/dashboard/add?domain=hydrawise", + }, ) return self.async_abort(reason=error_type) diff --git a/homeassistant/components/ipp/diagnostics.py b/homeassistant/components/ipp/diagnostics.py new file mode 100644 index 00000000000000..67b841839777c7 --- /dev/null +++ b/homeassistant/components/ipp/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics support for Internet Printing Protocol (IPP).""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import IPPDataUpdateCoordinator + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: IPPDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + return { + "entry": { + "data": { + **config_entry.data, + }, + "unique_id": config_entry.unique_id, + }, + "data": coordinator.data.as_dict(), + } diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index 3625a2d867eff8..5168c5de1fa7f7 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["deepmerge", "pyipp"], "quality_scale": "platinum", - "requirements": ["pyipp==0.14.5"], + "requirements": ["pyipp==0.15.0"], "zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."] } diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index 763438187022a2..0f4b58b17e867d 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -149,7 +149,9 @@ def _update_from_session_data(self) -> None: media_content_type = CONTENT_TYPE_MAP.get(self.now_playing["Type"], None) media_content_id = self.now_playing["Id"] media_title = self.now_playing["Name"] - media_duration = int(self.now_playing["RunTimeTicks"] / 10000000) + + if "RunTimeTicks" in self.now_playing: + media_duration = int(self.now_playing["RunTimeTicks"] / 10000000) if media_content_type == MediaType.EPISODE: media_content_type = MediaType.TVSHOW diff --git a/homeassistant/components/juicenet/__init__.py b/homeassistant/components/juicenet/__init__.py index 820f0d1fcc031e..bcefe763e159fb 100644 --- a/homeassistant/components/juicenet/__init__.py +++ b/homeassistant/components/juicenet/__init__.py @@ -1,37 +1,107 @@ """The JuiceNet integration.""" -from __future__ import annotations +from datetime import timedelta +import logging -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +import aiohttp +from pyjuicenet import Api, TokenError +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR +from .device import JuiceNetApi + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] + +CONFIG_SCHEMA = vol.Schema( + vol.All( + cv.deprecated(DOMAIN), + {DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})}, + ), + extra=vol.ALLOW_EXTRA, +) + -DOMAIN = "juicenet" +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the JuiceNet component.""" + conf = config.get(DOMAIN) + hass.data.setdefault(DOMAIN, {}) + + if not conf: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + ) + return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up JuiceNet from a config entry.""" - ir.async_create_issue( + + config = entry.data + + session = async_get_clientsession(hass) + + access_token = config[CONF_ACCESS_TOKEN] + api = Api(access_token, session) + + juicenet = JuiceNetApi(api) + + try: + await juicenet.setup() + except TokenError as error: + _LOGGER.error("JuiceNet Error %s", error) + return False + except aiohttp.ClientError as error: + _LOGGER.error("Could not reach the JuiceNet API %s", error) + raise ConfigEntryNotReady from error + + if not juicenet.devices: + _LOGGER.error("No JuiceNet devices found for this account") + return False + _LOGGER.info("%d JuiceNet device(s) found", len(juicenet.devices)) + + async def async_update_data(): + """Update all device states from the JuiceNet API.""" + for device in juicenet.devices: + await device.update_state(True) + return True + + coordinator = DataUpdateCoordinator( hass, - DOMAIN, - DOMAIN, - is_fixable=False, - severity=ir.IssueSeverity.ERROR, - translation_key="integration_removed", - translation_placeholders={ - "entries": "/config/integrations/integration/juicenet", - }, + _LOGGER, + name="JuiceNet", + update_method=async_update_data, + update_interval=timedelta(seconds=30), ) + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = { + JUICENET_API: juicenet, + JUICENET_COORDINATOR: coordinator, + } + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if all( - config_entry.state is ConfigEntryState.NOT_LOADED - for config_entry in hass.config_entries.async_entries(DOMAIN) - if config_entry.entry_id != entry.entry_id - ): - ir.async_delete_issue(hass, DOMAIN, DOMAIN) - - return True + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/juicenet/config_flow.py b/homeassistant/components/juicenet/config_flow.py index 7fdc024df47c9e..35c1853b974fc5 100644 --- a/homeassistant/components/juicenet/config_flow.py +++ b/homeassistant/components/juicenet/config_flow.py @@ -1,11 +1,77 @@ """Config flow for JuiceNet integration.""" +import logging -from homeassistant import config_entries +import aiohttp +from pyjuicenet import Api, TokenError +import voluptuous as vol -from . import DOMAIN +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + session = async_get_clientsession(hass) + juicenet = Api(data[CONF_ACCESS_TOKEN], session) + + try: + await juicenet.get_devices() + except TokenError as error: + _LOGGER.error("Token Error %s", error) + raise InvalidAuth from error + except aiohttp.ClientError as error: + _LOGGER.error("Error connecting %s", error) + raise CannotConnect from error + + # Return info that you want to store in the config entry. + return {"title": "JuiceNet"} class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for JuiceNet.""" VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_ACCESS_TOKEN]) + self._abort_if_unique_id_configured() + + try: + info = await validate_input(self.hass, user_input) + return self.async_create_entry(title=info["title"], data=user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, user_input): + """Handle import.""" + return await self.async_step_user(user_input) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/juicenet/const.py b/homeassistant/components/juicenet/const.py new file mode 100644 index 00000000000000..5dc3e5c3e27582 --- /dev/null +++ b/homeassistant/components/juicenet/const.py @@ -0,0 +1,6 @@ +"""Constants used by the JuiceNet component.""" + +DOMAIN = "juicenet" + +JUICENET_API = "juicenet_api" +JUICENET_COORDINATOR = "juicenet_coordinator" diff --git a/homeassistant/components/juicenet/device.py b/homeassistant/components/juicenet/device.py new file mode 100644 index 00000000000000..86e1c92e4da809 --- /dev/null +++ b/homeassistant/components/juicenet/device.py @@ -0,0 +1,19 @@ +"""Adapter to wrap the pyjuicenet api for home assistant.""" + + +class JuiceNetApi: + """Represent a connection to JuiceNet.""" + + def __init__(self, api): + """Create an object from the provided API instance.""" + self.api = api + self._devices = [] + + async def setup(self): + """JuiceNet device setup.""" # noqa: D403 + self._devices = await self.api.get_devices() + + @property + def devices(self) -> list: + """Get a list of devices managed by this account.""" + return self._devices diff --git a/homeassistant/components/juicenet/entity.py b/homeassistant/components/juicenet/entity.py new file mode 100644 index 00000000000000..b3433948582819 --- /dev/null +++ b/homeassistant/components/juicenet/entity.py @@ -0,0 +1,34 @@ +"""Adapter to wrap the pyjuicenet api for home assistant.""" + +from pyjuicenet import Charger + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + + +class JuiceNetDevice(CoordinatorEntity): + """Represent a base JuiceNet device.""" + + _attr_has_entity_name = True + + def __init__( + self, device: Charger, key: str, coordinator: DataUpdateCoordinator + ) -> None: + """Initialise the sensor.""" + super().__init__(coordinator) + self.device = device + self.key = key + self._attr_unique_id = f"{device.id}-{key}" + self._attr_device_info = DeviceInfo( + configuration_url=( + f"https://home.juice.net/Portal/Details?unitID={device.id}" + ), + identifiers={(DOMAIN, device.id)}, + manufacturer="JuiceNet", + name=device.name, + ) diff --git a/homeassistant/components/juicenet/manifest.json b/homeassistant/components/juicenet/manifest.json index 5bdad83ac1ec52..979e540af014d2 100644 --- a/homeassistant/components/juicenet/manifest.json +++ b/homeassistant/components/juicenet/manifest.json @@ -1,9 +1,10 @@ { "domain": "juicenet", "name": "JuiceNet", - "codeowners": [], + "codeowners": ["@jesserockz"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/juicenet", - "integration_type": "system", "iot_class": "cloud_polling", - "requirements": [] + "loggers": ["pyjuicenet"], + "requirements": ["python-juicenet==1.1.0"] } diff --git a/homeassistant/components/juicenet/number.py b/homeassistant/components/juicenet/number.py new file mode 100644 index 00000000000000..fd2535c5bf391d --- /dev/null +++ b/homeassistant/components/juicenet/number.py @@ -0,0 +1,99 @@ +"""Support for controlling juicenet/juicepoint/juicebox based EVSE numbers.""" +from __future__ import annotations + +from dataclasses import dataclass + +from pyjuicenet import Api, Charger + +from homeassistant.components.number import ( + DEFAULT_MAX_VALUE, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR +from .entity import JuiceNetDevice + + +@dataclass(frozen=True) +class JuiceNetNumberEntityDescriptionMixin: + """Mixin for required keys.""" + + setter_key: str + + +@dataclass(frozen=True) +class JuiceNetNumberEntityDescription( + NumberEntityDescription, JuiceNetNumberEntityDescriptionMixin +): + """An entity description for a JuiceNetNumber.""" + + native_max_value_key: str | None = None + + +NUMBER_TYPES: tuple[JuiceNetNumberEntityDescription, ...] = ( + JuiceNetNumberEntityDescription( + translation_key="amperage_limit", + key="current_charging_amperage_limit", + native_min_value=6, + native_max_value_key="max_charging_amperage", + native_step=1, + setter_key="set_charging_amperage_limit", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the JuiceNet Numbers.""" + juicenet_data = hass.data[DOMAIN][config_entry.entry_id] + api: Api = juicenet_data[JUICENET_API] + coordinator = juicenet_data[JUICENET_COORDINATOR] + + entities = [ + JuiceNetNumber(device, description, coordinator) + for device in api.devices + for description in NUMBER_TYPES + ] + async_add_entities(entities) + + +class JuiceNetNumber(JuiceNetDevice, NumberEntity): + """Implementation of a JuiceNet number.""" + + entity_description: JuiceNetNumberEntityDescription + + def __init__( + self, + device: Charger, + description: JuiceNetNumberEntityDescription, + coordinator: DataUpdateCoordinator, + ) -> None: + """Initialise the number.""" + super().__init__(device, description.key, coordinator) + self.entity_description = description + + @property + def native_value(self) -> float | None: + """Return the value of the entity.""" + return getattr(self.device, self.entity_description.key, None) + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + if self.entity_description.native_max_value_key is not None: + return getattr(self.device, self.entity_description.native_max_value_key) + if self.entity_description.native_max_value is not None: + return self.entity_description.native_max_value + return DEFAULT_MAX_VALUE + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + await getattr(self.device, self.entity_description.setter_key)(value) diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py new file mode 100644 index 00000000000000..5f71e066b9c24b --- /dev/null +++ b/homeassistant/components/juicenet/sensor.py @@ -0,0 +1,116 @@ +"""Support for monitoring juicenet/juicepoint/juicebox based EVSE sensors.""" +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR +from .entity import JuiceNetDevice + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="status", + name="Charging Status", + ), + SensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + ), + SensorEntityDescription( + key="amps", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="watts", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="charge_time", + translation_key="charge_time", + native_unit_of_measurement=UnitOfTime.SECONDS, + icon="mdi:timer-outline", + ), + SensorEntityDescription( + key="energy_added", + translation_key="energy_added", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the JuiceNet Sensors.""" + juicenet_data = hass.data[DOMAIN][config_entry.entry_id] + api = juicenet_data[JUICENET_API] + coordinator = juicenet_data[JUICENET_COORDINATOR] + + entities = [ + JuiceNetSensorDevice(device, coordinator, description) + for device in api.devices + for description in SENSOR_TYPES + ] + async_add_entities(entities) + + +class JuiceNetSensorDevice(JuiceNetDevice, SensorEntity): + """Implementation of a JuiceNet sensor.""" + + def __init__( + self, device, coordinator, description: SensorEntityDescription + ) -> None: + """Initialise the sensor.""" + super().__init__(device, description.key, coordinator) + self.entity_description = description + + @property + def icon(self): + """Return the icon of the sensor.""" + icon = None + if self.entity_description.key == "status": + status = self.device.status + if status == "standby": + icon = "mdi:power-plug-off" + elif status == "plugged": + icon = "mdi:power-plug" + elif status == "charging": + icon = "mdi:battery-positive" + else: + icon = self.entity_description.icon + return icon + + @property + def native_value(self): + """Return the state.""" + return getattr(self.device, self.entity_description.key, None) diff --git a/homeassistant/components/juicenet/strings.json b/homeassistant/components/juicenet/strings.json index 6e25130955b11b..0e3732c66d2bfa 100644 --- a/homeassistant/components/juicenet/strings.json +++ b/homeassistant/components/juicenet/strings.json @@ -1,8 +1,41 @@ { - "issues": { - "integration_removed": { - "title": "The JuiceNet integration has been removed", - "description": "Enel X has dropped support for JuiceNet in favor of JuicePass, and the JuiceNet integration has been removed from Home Assistant as it was no longer working.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing JuiceNet integration entries]({entries})." + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + }, + "description": "You will need the API Token from https://home.juice.net/Manage.", + "title": "Connect to JuiceNet" + } + } + }, + "entity": { + "number": { + "amperage_limit": { + "name": "Amperage limit" + } + }, + "sensor": { + "charge_time": { + "name": "Charge time" + }, + "energy_added": { + "name": "Energy added" + } + }, + "switch": { + "charge_now": { + "name": "Charge now" + } } } } diff --git a/homeassistant/components/juicenet/switch.py b/homeassistant/components/juicenet/switch.py new file mode 100644 index 00000000000000..7c373eeeb245b7 --- /dev/null +++ b/homeassistant/components/juicenet/switch.py @@ -0,0 +1,49 @@ +"""Support for monitoring juicenet/juicepoint/juicebox based EVSE switches.""" +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR +from .entity import JuiceNetDevice + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the JuiceNet switches.""" + entities = [] + juicenet_data = hass.data[DOMAIN][config_entry.entry_id] + api = juicenet_data[JUICENET_API] + coordinator = juicenet_data[JUICENET_COORDINATOR] + + for device in api.devices: + entities.append(JuiceNetChargeNowSwitch(device, coordinator)) + async_add_entities(entities) + + +class JuiceNetChargeNowSwitch(JuiceNetDevice, SwitchEntity): + """Implementation of a JuiceNet switch.""" + + _attr_translation_key = "charge_now" + + def __init__(self, device, coordinator): + """Initialise the switch.""" + super().__init__(device, "charge_now", coordinator) + + @property + def is_on(self): + """Return true if switch is on.""" + return self.device.override_time != 0 + + async def async_turn_on(self, **kwargs: Any) -> None: + """Charge now.""" + await self.device.set_override(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Don't charge now.""" + await self.device.set_override(False) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 53fd61a2924c21..25ec9f2ccc6d96 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==7.0.0"] + "requirements": ["ical==7.0.1"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index b45eec12e62d9b..81f0f9dc199fb3 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==7.0.0"] + "requirements": ["ical==7.0.1"] } diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index 292f8237776686..5b25abf8e21fcb 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util from .const import CONF_TODO_LIST_NAME, DOMAIN from .store import LocalTodoListStore @@ -124,6 +125,9 @@ def __init__( self._attr_name = name.capitalize() self._attr_unique_id = unique_id + def _new_todo_store(self) -> TodoStore: + return TodoStore(self._calendar, tzinfo=dt_util.DEFAULT_TIME_ZONE) + async def async_update(self) -> None: """Update entity state based on the local To-do items.""" todo_items = [] @@ -147,20 +151,20 @@ async def async_update(self) -> None: async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" todo = _convert_item(item) - TodoStore(self._calendar).add(todo) + self._new_todo_store().add(todo) await self.async_save() await self.async_update_ha_state(force_refresh=True) async def async_update_todo_item(self, item: TodoItem) -> None: """Update an item to the To-do list.""" todo = _convert_item(item) - TodoStore(self._calendar).edit(todo.uid, todo) + self._new_todo_store().edit(todo.uid, todo) await self.async_save() await self.async_update_ha_state(force_refresh=True) async def async_delete_todo_items(self, uids: list[str]) -> None: """Delete an item from the To-do list.""" - store = TodoStore(self._calendar) + store = self._new_todo_store() for uid in uids: store.delete(uid) await self.async_save() diff --git a/homeassistant/components/loqed/manifest.json b/homeassistant/components/loqed/manifest.json index 7c682b3189d0aa..46ac84b1768db5 100644 --- a/homeassistant/components/loqed/manifest.json +++ b/homeassistant/components/loqed/manifest.json @@ -7,7 +7,7 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/loqed", "iot_class": "local_push", - "requirements": ["loqedAPI==2.1.8"], + "requirements": ["loqedAPI==2.1.10"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/luci/manifest.json b/homeassistant/components/luci/manifest.json index 2412aaad0a1564..597aad30648cd8 100644 --- a/homeassistant/components/luci/manifest.json +++ b/homeassistant/components/luci/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/luci", "iot_class": "local_polling", "loggers": ["openwrt_luci_rpc"], - "requirements": ["openwrt-luci-rpc==1.1.16"] + "requirements": ["openwrt-luci-rpc==1.1.17"] } diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index d424df620cf751..6a5c4e3203bbca 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -134,12 +134,11 @@ async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - # DigestAuth is not supported if ( self._authentication == HTTP_DIGEST_AUTHENTICATION or self._still_image_url is None ): - return await self._async_digest_camera_image() + return await self._async_digest_or_fallback_camera_image() websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl) try: @@ -157,15 +156,17 @@ async def async_camera_image( return None - def _get_digest_auth(self) -> httpx.DigestAuth: - """Return a DigestAuth object.""" + def _get_httpx_auth(self) -> httpx.Auth: + """Return a httpx auth object.""" username = "" if self._username is None else self._username - return httpx.DigestAuth(username, self._password) + digest_auth = self._authentication == HTTP_DIGEST_AUTHENTICATION + cls = httpx.DigestAuth if digest_auth else httpx.BasicAuth + return cls(username, self._password) - async def _async_digest_camera_image(self) -> bytes | None: + async def _async_digest_or_fallback_camera_image(self) -> bytes | None: """Return a still image response from the camera using digest authentication.""" client = get_async_client(self.hass, verify_ssl=self._verify_ssl) - auth = self._get_digest_auth() + auth = self._get_httpx_auth() try: if self._still_image_url: # Fallback to MJPEG stream if still image URL is not available @@ -196,7 +197,7 @@ async def _handle_async_mjpeg_digest_stream( ) -> web.StreamResponse | None: """Generate an HTTP MJPEG stream from the camera using digest authentication.""" async with get_async_client(self.hass, verify_ssl=self._verify_ssl).stream( - "get", self._mjpeg_url, auth=self._get_digest_auth(), timeout=TIMEOUT + "get", self._mjpeg_url, auth=self._get_httpx_auth(), timeout=TIMEOUT ) as stream: response = web.StreamResponse(headers=stream.headers) await response.prepare(request) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index b90f5663643153..6b072457144b8c 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pymodbus"], "quality_scale": "gold", - "requirements": ["pymodbus==3.6.4"] + "requirements": ["pymodbus==3.6.5"] } diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 765ce4d8be391a..650083dc7e443a 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -301,16 +301,17 @@ def validate_modbus(hub: dict, hub_name_inx: int) -> bool: def validate_entity( hub_name: str, + component: str, entity: dict, minimum_scan_interval: int, ent_names: set, ent_addr: set, ) -> bool: """Validate entity.""" - name = entity[CONF_NAME] + name = f"{component}.{entity[CONF_NAME]}" addr = f"{hub_name}{entity[CONF_ADDRESS]}" scan_interval = entity.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - if scan_interval < 5: + if 0 < scan_interval < 5: _LOGGER.warning( ( "%s %s scan_interval(%d) is lower than 5 seconds, " @@ -368,15 +369,18 @@ def validate_entity( if not validate_modbus(hub, hub_name_inx): del config[hub_inx] continue - for _component, conf_key in PLATFORMS: + minimum_scan_interval = 9999 + no_entities = True + for component, conf_key in PLATFORMS: if conf_key not in hub: continue + no_entities = False entity_inx = 0 entities = hub[conf_key] - minimum_scan_interval = 9999 while entity_inx < len(entities): if not validate_entity( hub[CONF_NAME], + component, entities[entity_inx], minimum_scan_interval, ent_names, @@ -385,7 +389,11 @@ def validate_entity( del entities[entity_inx] else: entity_inx += 1 - + if no_entities: + err = f"Modbus {hub[CONF_NAME]} contain no entities, this will cause instability, please add at least one entity!" + _LOGGER.warning(err) + # Ensure timeout is not started/handled. + hub[CONF_TIMEOUT] = -1 if hub[CONF_TIMEOUT] >= minimum_scan_interval: hub[CONF_TIMEOUT] = minimum_scan_interval - 1 _LOGGER.warning( diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 3ed2c7bdb930fa..33a749909282cb 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -180,6 +180,10 @@ def async_save_refresh_token(refresh_token: str) -> None: # Create a callback to save the refresh token when it changes: entry.async_on_unload(client.add_refresh_token_callback(async_save_refresh_token)) + # Save the client's refresh token if it's different than what we already have: + if (token := client.refresh_token) and token != entry.data[CONF_REFRESH_TOKEN]: + async_save_refresh_token(token) + hass.config_entries.async_update_entry(entry, **entry_updates) async def async_update() -> NotionData: diff --git a/homeassistant/components/notion/manifest.json b/homeassistant/components/notion/manifest.json index 5fc94b5e6469e0..d4d2cb579a35c4 100644 --- a/homeassistant/components/notion/manifest.json +++ b/homeassistant/components/notion/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aionotion"], - "requirements": ["aionotion==2024.02.2"] + "requirements": ["aionotion==2024.03.0"] } diff --git a/homeassistant/components/numato/manifest.json b/homeassistant/components/numato/manifest.json index 7dcbeae1f21aa9..e6efcea5315629 100644 --- a/homeassistant/components/numato/manifest.json +++ b/homeassistant/components/numato/manifest.json @@ -3,7 +3,8 @@ "name": "Numato USB GPIO Expander", "codeowners": ["@clssn"], "documentation": "https://www.home-assistant.io/integrations/numato", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["numato_gpio"], - "requirements": ["numato-gpio==0.10.0"] + "requirements": ["numato-gpio==0.12.0"] } diff --git a/homeassistant/components/rainforest_raven/coordinator.py b/homeassistant/components/rainforest_raven/coordinator.py index edae4f1143381e..dbb7203768bd38 100644 --- a/homeassistant/components/rainforest_raven/coordinator.py +++ b/homeassistant/components/rainforest_raven/coordinator.py @@ -132,16 +132,27 @@ def device_info(self) -> DeviceInfo | None: ) return None + async def async_shutdown(self) -> None: + """Shutdown the coordinator.""" + await self._cleanup_device() + await super().async_shutdown() + async def _async_update_data(self) -> dict[str, Any]: try: device = await self._get_device() async with asyncio.timeout(5): return await _get_all_data(device, self.entry.data[CONF_MAC]) except RAVEnConnectionError as err: - if self._raven_device: - await self._raven_device.close() - self._raven_device = None + await self._cleanup_device() raise UpdateFailed(f"RAVEnConnectionError: {err}") from err + except TimeoutError: + await self._cleanup_device() + raise + + async def _cleanup_device(self) -> None: + device, self._raven_device = self._raven_device, None + if device is not None: + await device.close() async def _get_device(self) -> RAVEnSerialDevice: if self._raven_device is not None: @@ -149,15 +160,14 @@ async def _get_device(self) -> RAVEnSerialDevice: device = RAVEnSerialDevice(self.entry.data[CONF_DEVICE]) - async with asyncio.timeout(5): - await device.open() - - try: + try: + async with asyncio.timeout(5): + await device.open() await device.synchronize() self._device_info = await device.get_device_info() - except Exception: - await device.close() - raise + except: + await device.close() + raise self._raven_device = device return device diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 8885116dbfd8de..547c78e2a7ae10 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -924,7 +924,7 @@ def _process_one_task_or_event_or_recover(self, task: RecorderTask | Event) -> N # that is pending before running the task if TYPE_CHECKING: assert isinstance(task, RecorderTask) - if not task.commit_before: + if task.commit_before: self._commit_event_session_or_retry() return task.run(self) except exc.DatabaseError as err: diff --git a/homeassistant/components/recorder/entity_registry.py b/homeassistant/components/recorder/entity_registry.py index fbf6e6917770de..24f33cd815e67a 100644 --- a/homeassistant/components/recorder/entity_registry.py +++ b/homeassistant/components/recorder/entity_registry.py @@ -6,7 +6,7 @@ from homeassistant.helpers.start import async_at_start from .core import Recorder -from .util import get_instance, session_scope +from .util import filter_unique_constraint_integrity_error, get_instance, session_scope _LOGGER = logging.getLogger(__name__) @@ -61,7 +61,10 @@ def update_states_metadata( ) return - with session_scope(session=instance.get_session()) as session: + with session_scope( + session=instance.get_session(), + exception_filter=filter_unique_constraint_integrity_error(instance, "state"), + ) as session: if not states_meta_manager.update_metadata(session, entity_id, new_entity_id): _LOGGER.warning( "Cannot migrate history for entity_id `%s` to `%s` " diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 5abe395a8d7ec8..ab4626c192bc4d 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -3,7 +3,6 @@ from collections import defaultdict from collections.abc import Callable, Iterable, Sequence -import contextlib import dataclasses from datetime import datetime, timedelta from functools import lru_cache, partial @@ -15,7 +14,7 @@ from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text from sqlalchemy.engine.row import Row -from sqlalchemy.exc import SQLAlchemyError, StatementError +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm.session import Session from sqlalchemy.sql.lambdas import StatementLambdaElement import voluptuous as vol @@ -72,6 +71,7 @@ from .util import ( execute, execute_stmt_lambda_element, + filter_unique_constraint_integrity_error, get_instance, retryable_database_job, session_scope, @@ -454,7 +454,9 @@ def compile_missing_statistics(instance: Recorder) -> bool: with session_scope( session=instance.get_session(), - exception_filter=_filter_unique_constraint_integrity_error(instance), + exception_filter=filter_unique_constraint_integrity_error( + instance, "statistic" + ), ) as session: # Find the newest statistics run, if any if last_run := session.query(func.max(StatisticsRuns.start)).scalar(): @@ -486,7 +488,9 @@ def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) - # Return if we already have 5-minute statistics for the requested period with session_scope( session=instance.get_session(), - exception_filter=_filter_unique_constraint_integrity_error(instance), + exception_filter=filter_unique_constraint_integrity_error( + instance, "statistic" + ), ) as session: modified_statistic_ids = _compile_statistics( instance, session, start, fire_events @@ -737,7 +741,9 @@ def update_statistics_metadata( if new_statistic_id is not UNDEFINED and new_statistic_id is not None: with session_scope( session=instance.get_session(), - exception_filter=_filter_unique_constraint_integrity_error(instance), + exception_filter=filter_unique_constraint_integrity_error( + instance, "statistic" + ), ) as session: statistics_meta_manager.update_statistic_id( session, DOMAIN, statistic_id, new_statistic_id @@ -2246,54 +2252,6 @@ def async_add_external_statistics( _async_import_statistics(hass, metadata, statistics) -def _filter_unique_constraint_integrity_error( - instance: Recorder, -) -> Callable[[Exception], bool]: - def _filter_unique_constraint_integrity_error(err: Exception) -> bool: - """Handle unique constraint integrity errors.""" - if not isinstance(err, StatementError): - return False - - assert instance.engine is not None - dialect_name = instance.engine.dialect.name - - ignore = False - if ( - dialect_name == SupportedDialect.SQLITE - and "UNIQUE constraint failed" in str(err) - ): - ignore = True - if ( - dialect_name == SupportedDialect.POSTGRESQL - and err.orig - and hasattr(err.orig, "pgcode") - and err.orig.pgcode == "23505" - ): - ignore = True - if ( - dialect_name == SupportedDialect.MYSQL - and err.orig - and hasattr(err.orig, "args") - ): - with contextlib.suppress(TypeError): - if err.orig.args[0] == 1062: - ignore = True - - if ignore: - _LOGGER.warning( - ( - "Blocked attempt to insert duplicated statistic rows, please report" - " at %s" - ), - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+recorder%22", - exc_info=err, - ) - - return ignore - - return _filter_unique_constraint_integrity_error - - def _import_statistics_with_session( instance: Recorder, session: Session, @@ -2397,7 +2355,9 @@ def import_statistics( with session_scope( session=instance.get_session(), - exception_filter=_filter_unique_constraint_integrity_error(instance), + exception_filter=filter_unique_constraint_integrity_error( + instance, "statistic" + ), ) as session: return _import_statistics_with_session( instance, session, metadata, statistics, table diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py index 76def3a22fe5d8..d6c69e2682bce0 100644 --- a/homeassistant/components/recorder/table_managers/statistics_meta.py +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -307,11 +307,18 @@ def update_statistic_id( recorder thread. """ self._assert_in_recorder_thread() + if self.get(session, new_statistic_id): + _LOGGER.error( + "Cannot rename statistic_id `%s` to `%s` because the new statistic_id is already in use", + old_statistic_id, + new_statistic_id, + ) + return session.query(StatisticsMeta).filter( (StatisticsMeta.statistic_id == old_statistic_id) & (StatisticsMeta.source == source) ).update({StatisticsMeta.statistic_id: new_statistic_id}) - self._clear_cache([old_statistic_id, new_statistic_id]) + self._clear_cache([old_statistic_id]) def delete(self, session: Session, statistic_ids: list[str]) -> None: """Clear statistics for a list of statistic_ids. diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index f684160f86fa1c..8ed5c3e8cdc454 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Collection, Generator, Iterable, Sequence +import contextlib from contextlib import contextmanager from datetime import date, datetime, timedelta import functools @@ -21,7 +22,7 @@ from sqlalchemy import inspect, text from sqlalchemy.engine import Result, Row from sqlalchemy.engine.interfaces import DBAPIConnection -from sqlalchemy.exc import OperationalError, SQLAlchemyError +from sqlalchemy.exc import OperationalError, SQLAlchemyError, StatementError from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session from sqlalchemy.sql.lambdas import StatementLambdaElement @@ -906,3 +907,54 @@ def get_index_by_name(session: Session, table_name: str, index_name: str) -> str ), None, ) + + +def filter_unique_constraint_integrity_error( + instance: Recorder, row_type: str +) -> Callable[[Exception], bool]: + """Create a filter for unique constraint integrity errors.""" + + def _filter_unique_constraint_integrity_error(err: Exception) -> bool: + """Handle unique constraint integrity errors.""" + if not isinstance(err, StatementError): + return False + + assert instance.engine is not None + dialect_name = instance.engine.dialect.name + + ignore = False + if ( + dialect_name == SupportedDialect.SQLITE + and "UNIQUE constraint failed" in str(err) + ): + ignore = True + if ( + dialect_name == SupportedDialect.POSTGRESQL + and err.orig + and hasattr(err.orig, "pgcode") + and err.orig.pgcode == "23505" + ): + ignore = True + if ( + dialect_name == SupportedDialect.MYSQL + and err.orig + and hasattr(err.orig, "args") + ): + with contextlib.suppress(TypeError): + if err.orig.args[0] == 1062: + ignore = True + + if ignore: + _LOGGER.warning( + ( + "Blocked attempt to insert duplicated %s rows, please report" + " at %s" + ), + row_type, + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+recorder%22", + exc_info=err, + ) + + return ignore + + return _filter_unique_constraint_integrity_error diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 50a4b57fcdd8c6..ce4513fb31661b 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -11,7 +11,7 @@ "iot_class": "local_polling", "loggers": ["rokuecp"], "quality_scale": "silver", - "requirements": ["rokuecp==0.19.1"], + "requirements": ["rokuecp==0.19.2"], "ssdp": [ { "st": "roku:ecp", diff --git a/homeassistant/components/rova/manifest.json b/homeassistant/components/rova/manifest.json index 03c6ddbb12cc6e..a87ec2241225a0 100644 --- a/homeassistant/components/rova/manifest.json +++ b/homeassistant/components/rova/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/rova", "iot_class": "cloud_polling", "loggers": ["rova"], - "requirements": ["rova==0.4.0"] + "requirements": ["rova==0.4.1"] } diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index dd9a2f5270aeb7..cd6c1dd91524c2 100644 --- a/homeassistant/components/snmp/manifest.json +++ b/homeassistant/components/snmp/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/snmp", "iot_class": "local_polling", "loggers": ["pyasn1", "pysmi", "pysnmp"], - "requirements": ["pysnmp-lextudio==6.0.2"] + "requirements": ["pysnmp-lextudio==6.0.9"] } diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index a30cf93bcded39..3578f0e0c1d95e 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -237,7 +237,7 @@ def __init__( async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" - # If vartype set, use it - http://snmplabs.com/pysnmp/docs/api-reference.html#pysnmp.smi.rfc1902.ObjectType + # If vartype set, use it - https://www.pysnmp.com/pysnmp/docs/api-reference.html#pysnmp.smi.rfc1902.ObjectType await self._execute_command(self._command_payload_on) async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 5d17655c10496b..47bd2bc16f3cb8 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -401,9 +401,9 @@ def target_temperature(self) -> float | None: def set_timer( self, - temperature: float, - time_period: int, - requested_overlay: str, + temperature: float | None = None, + time_period: int | None = None, + requested_overlay: str | None = None, ): """Set the timer on the entity, and temperature if supported.""" diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index a3e29e1b40f886..1f2a2405a445b8 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -6,5 +6,6 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", - "requirements": ["pytedee-async==0.2.15"] + "loggers": ["pytedee_async"], + "requirements": ["pytedee-async==0.2.16"] } diff --git a/homeassistant/components/twitch/__init__.py b/homeassistant/components/twitch/__init__.py index a26b7e940351b0..fdc1a74d2d2b9f 100644 --- a/homeassistant/components/twitch/__init__.py +++ b/homeassistant/components/twitch/__init__.py @@ -16,7 +16,7 @@ async_get_config_entry_implementation, ) -from .const import DOMAIN, OAUTH_SCOPES, PLATFORMS +from .const import CLIENT, DOMAIN, OAUTH_SCOPES, PLATFORMS, SESSION async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -45,7 +45,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client.auto_refresh_auth = False await client.set_user_authentication(access_token, scope=OAUTH_SCOPES) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + CLIENT: client, + SESSION: session, + } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/twitch/const.py b/homeassistant/components/twitch/const.py index 22286437eab0b9..e99e76a94e99d1 100644 --- a/homeassistant/components/twitch/const.py +++ b/homeassistant/components/twitch/const.py @@ -16,5 +16,7 @@ DOMAIN = "twitch" CONF_CHANNELS = "channels" +CLIENT = "client" +SESSION = "session" OAUTH_SCOPES = [AuthScope.USER_READ_SUBSCRIPTIONS, AuthScope.USER_READ_FOLLOWS] diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index 05fd3fa3e7111d..2d2a79a62445b6 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -19,12 +19,13 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_CHANNELS, DOMAIN, LOGGER, OAUTH_SCOPES +from .const import CLIENT, CONF_CHANNELS, DOMAIN, LOGGER, OAUTH_SCOPES, SESSION PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -51,6 +52,8 @@ STATE_OFFLINE = "offline" STATE_STREAMING = "streaming" +PARALLEL_UPDATES = 1 + def chunk_list(lst: list, chunk_size: int) -> list[list]: """Split a list into chunks of chunk_size.""" @@ -97,7 +100,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Initialize entries.""" - client = hass.data[DOMAIN][entry.entry_id] + client = hass.data[DOMAIN][entry.entry_id][CLIENT] + session = hass.data[DOMAIN][entry.entry_id][SESSION] channels = entry.options[CONF_CHANNELS] @@ -107,7 +111,7 @@ async def async_setup_entry( for chunk in chunk_list(channels, 100): entities.extend( [ - TwitchSensor(channel, client) + TwitchSensor(channel, session, client) async for channel in client.get_users(logins=chunk) ] ) @@ -120,8 +124,11 @@ class TwitchSensor(SensorEntity): _attr_icon = ICON - def __init__(self, channel: TwitchUser, client: Twitch) -> None: + def __init__( + self, channel: TwitchUser, session: OAuth2Session, client: Twitch + ) -> None: """Initialize the sensor.""" + self._session = session self._client = client self._channel = channel self._enable_user_auth = client.has_required_auth(AuthType.USER, OAUTH_SCOPES) @@ -130,9 +137,17 @@ def __init__(self, channel: TwitchUser, client: Twitch) -> None: async def async_update(self) -> None: """Update device state.""" - followers = (await self._client.get_channel_followers(self._channel.id)).total + await self._session.async_ensure_token_valid() + await self._client.set_user_authentication( + self._session.token["access_token"], + OAUTH_SCOPES, + self._session.token["refresh_token"], + False, + ) + followers = await self._client.get_channel_followers(self._channel.id) + self._attr_extra_state_attributes = { - ATTR_FOLLOWING: followers, + ATTR_FOLLOWING: followers.total, ATTR_VIEWS: self._channel.view_count, } if self._enable_user_auth: @@ -166,7 +181,7 @@ async def _async_add_user_attributes(self) -> None: self._attr_extra_state_attributes[ATTR_SUBSCRIPTION] = True self._attr_extra_state_attributes[ATTR_SUBSCRIPTION_GIFTED] = sub.is_gift except TwitchResourceNotFound: - LOGGER.debug("User is not subscribed") + LOGGER.debug("User is not subscribed to %s", self._channel.display_name) except TwitchAPIException as exc: LOGGER.error("Error response on check_user_subscription: %s", exc) diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 2e546f8893f51b..905a3de75f099d 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -79,6 +79,7 @@ async def device_discovered( # Create device. assert discovery_info is not None + assert discovery_info.ssdp_udn assert discovery_info.ssdp_all_locations location = get_preferred_location(discovery_info.ssdp_all_locations) try: @@ -117,7 +118,9 @@ async def device_discovered( if device.serial_number: identifiers.add((IDENTIFIER_SERIAL_NUMBER, device.serial_number)) - connections = {(dr.CONNECTION_UPNP, device.udn)} + connections = {(dr.CONNECTION_UPNP, discovery_info.ssdp_udn)} + if discovery_info.ssdp_udn != device.udn: + connections.add((dr.CONNECTION_UPNP, device.udn)) if device_mac_address: connections.add((dr.CONNECTION_NETWORK_MAC, device_mac_address)) diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index fa33d4b29d3fe4..c7882285b9c0ad 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -42,7 +42,7 @@ def _friendly_name_from_discovery(discovery_info: ssdp.SsdpServiceInfo) -> str: def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool: """Test if discovery is complete and usable.""" return bool( - ssdp.ATTR_UPNP_UDN in discovery_info.upnp + discovery_info.ssdp_udn and discovery_info.ssdp_st and discovery_info.ssdp_all_locations and discovery_info.ssdp_usn @@ -80,9 +80,8 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 # Paths: - # - ssdp(discovery_info) --> ssdp_confirm(None) - # --> ssdp_confirm({}) --> create_entry() - # - user(None): scan --> user({...}) --> create_entry() + # 1: ssdp(discovery_info) --> ssdp_confirm(None) --> ssdp_confirm({}) --> create_entry() + # 2: user(None): scan --> user({...}) --> create_entry() @property def _discoveries(self) -> dict[str, SsdpServiceInfo]: @@ -241,9 +240,9 @@ async def async_step_ignore(self, user_input: dict[str, Any]) -> FlowResult: discovery = self._remove_discovery(usn) mac_address = await _async_mac_address_from_discovery(self.hass, discovery) data = { - CONFIG_ENTRY_UDN: discovery.upnp[ssdp.ATTR_UPNP_UDN], + CONFIG_ENTRY_UDN: discovery.ssdp_udn, CONFIG_ENTRY_ST: discovery.ssdp_st, - CONFIG_ENTRY_ORIGINAL_UDN: discovery.upnp[ssdp.ATTR_UPNP_UDN], + CONFIG_ENTRY_ORIGINAL_UDN: discovery.ssdp_udn, CONFIG_ENTRY_MAC_ADDRESS: mac_address, CONFIG_ENTRY_HOST: discovery.ssdp_headers["_host"], CONFIG_ENTRY_LOCATION: get_preferred_location(discovery.ssdp_all_locations), @@ -265,9 +264,9 @@ async def _async_create_entry_from_discovery( title = _friendly_name_from_discovery(discovery) mac_address = await _async_mac_address_from_discovery(self.hass, discovery) data = { - CONFIG_ENTRY_UDN: discovery.upnp[ssdp.ATTR_UPNP_UDN], + CONFIG_ENTRY_UDN: discovery.ssdp_udn, CONFIG_ENTRY_ST: discovery.ssdp_st, - CONFIG_ENTRY_ORIGINAL_UDN: discovery.upnp[ssdp.ATTR_UPNP_UDN], + CONFIG_ENTRY_ORIGINAL_UDN: discovery.ssdp_udn, CONFIG_ENTRY_LOCATION: get_preferred_location(discovery.ssdp_all_locations), CONFIG_ENTRY_MAC_ADDRESS: mac_address, CONFIG_ENTRY_HOST: discovery.ssdp_headers["_host"], diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 10cc1a15c9e25b..c419e50e98c4f6 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -52,6 +52,7 @@ VICARE_MODE_DHW = "dhw" VICARE_MODE_HEATING = "heating" +VICARE_MODE_HEATINGCOOLING = "heatingCooling" VICARE_MODE_DHWANDHEATING = "dhwAndHeating" VICARE_MODE_DHWANDHEATINGCOOLING = "dhwAndHeatingCooling" VICARE_MODE_FORCEDREDUCED = "forcedReduced" @@ -71,6 +72,7 @@ VICARE_MODE_DHW: HVACMode.OFF, VICARE_MODE_DHWANDHEATINGCOOLING: HVACMode.AUTO, VICARE_MODE_DHWANDHEATING: HVACMode.AUTO, + VICARE_MODE_HEATINGCOOLING: HVACMode.AUTO, VICARE_MODE_HEATING: HVACMode.AUTO, VICARE_MODE_FORCEDNORMAL: HVACMode.HEAT, } diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index 6abbeef02df40b..72a49c0cf19522 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.1.12"] + "requirements": ["weatherflow4py==0.1.17"] } diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 60cf917d9f63ed..3e6dd05a8b5038 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -491,23 +491,27 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN VERSION = 4 - async def _set_unique_id_or_update_path( + async def _set_unique_id_and_update_ignored_flow( self, unique_id: str, device_path: str ) -> None: - """Set the flow's unique ID and update the device path if it isn't unique.""" + """Set the flow's unique ID and update the device path in an ignored flow.""" current_entry = await self.async_set_unique_id(unique_id) if not current_entry: return - self._abort_if_unique_id_configured( - updates={ - CONF_DEVICE: { - **current_entry.data.get(CONF_DEVICE, {}), - CONF_DEVICE_PATH: device_path, - }, - } - ) + if current_entry.source != config_entries.SOURCE_IGNORE: + self._abort_if_unique_id_configured() + else: + # Only update the current entry if it is an ignored discovery + self._abort_if_unique_id_configured( + updates={ + CONF_DEVICE: { + **current_entry.data.get(CONF_DEVICE, {}), + CONF_DEVICE_PATH: device_path, + }, + } + ) @staticmethod @callback @@ -575,7 +579,7 @@ async def async_step_usb(self, discovery_info: usb.UsbServiceInfo) -> FlowResult description = discovery_info.description dev_path = discovery_info.device - await self._set_unique_id_or_update_path( + await self._set_unique_id_and_update_ignored_flow( unique_id=f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}", device_path=dev_path, ) @@ -625,7 +629,7 @@ async def async_step_zeroconf( node_name = local_name.removesuffix(".local") device_path = f"socket://{discovery_info.host}:{port}" - await self._set_unique_id_or_update_path( + await self._set_unique_id_and_update_ignored_flow( unique_id=node_name, device_path=device_path, ) @@ -650,7 +654,7 @@ async def async_step_hardware( device_settings = discovery_data["port"] device_path = device_settings[CONF_DEVICE_PATH] - await self._set_unique_id_or_update_path( + await self._set_unique_id_and_update_ignored_flow( unique_id=f"{name}_{radio_type.name}_{device_path}", device_path=device_path, ) diff --git a/homeassistant/const.py b/homeassistant/const.py index 78085695b0e01f..847387e76ae558 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 3 -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, 11, 0) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 55d77e26336444..3dc07c4b28722e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -255,6 +255,7 @@ "isy994", "izone", "jellyfin", + "juicenet", "justnimbus", "jvc_projector", "kaleidescape", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6b6c41e412c117..11aab652967c66 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2911,6 +2911,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "juicenet": { + "name": "JuiceNet", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "justnimbus": { "name": "JustNimbus", "integration_type": "hub", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 03a08a442a8d5c..1b850316b91183 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,6 +1,6 @@ # Automatically generated by gen_requirements_all.py, do not edit -aiodhcpwatcher==0.8.0 +aiodhcpwatcher==0.8.1 aiodiscover==1.6.1 aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.3.1 @@ -30,8 +30,8 @@ habluetooth==2.4.2 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240306.0 -home-assistant-intents==2024.2.28 +home-assistant-frontend==20240307.0 +home-assistant-intents==2024.3.12 httpx==0.27.0 ifaddr==0.2.0 janus==1.0.0 diff --git a/pyproject.toml b/pyproject.toml index ba2360adc2b5e6..d8a1545fbb357f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.3.0" +version = "2024.3.1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -35,6 +35,9 @@ dependencies = [ "bcrypt==4.1.2", "certifi>=2021.5.30", "ciso8601==2.3.1", + # hass-nabucasa is imported by helpers which don't depend on the cloud + # integration + "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", @@ -525,9 +528,6 @@ filterwarnings = [ "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", # https://github.com/thecynic/pylutron - v0.2.10 "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", - # Fixed for Python 3.12 - # https://github.com/lextudio/pysnmp/issues/10 - "ignore:The asyncore module is deprecated and will be removed in Python 3.12:DeprecationWarning:pysnmp.carrier.asyncore.base", # Wrong stacklevel # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:bs4.builder", diff --git a/requirements.txt b/requirements.txt index 8ded95427c28a7..23ab0b085677d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,7 @@ awesomeversion==24.2.0 bcrypt==4.1.2 certifi>=2021.5.30 ciso8601==2.3.1 +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 62d5b75af2d5db..add077d5b82f39 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -206,7 +206,7 @@ aioaseko==0.0.2 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.2.10 +aioautomower==2024.3.0 # homeassistant.components.azure_devops aioazuredevops==1.3.5 @@ -221,7 +221,7 @@ aiobotocore==2.9.1 aiocomelit==0.9.0 # homeassistant.components.dhcp -aiodhcpwatcher==0.8.0 +aiodhcpwatcher==0.8.1 # homeassistant.components.dhcp aiodiscover==1.6.1 @@ -315,7 +315,7 @@ aiomusiccast==0.14.8 aionanoleaf==0.2.1 # homeassistant.components.notion -aionotion==2024.02.2 +aionotion==2024.03.0 # homeassistant.components.oncue aiooncue==0.3.5 @@ -419,7 +419,7 @@ aioymaps==1.2.2 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.6.1 +airthings-ble==0.7.1 # homeassistant.components.airthings airthings-cloud==0.2.0 @@ -514,7 +514,7 @@ aurorapy==0.2.7 # avion==0.10 # homeassistant.components.axis -axis==50 +axis==54 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 @@ -596,20 +596,20 @@ bluetooth-data-tools==1.19.0 bond-async==0.2.1 # homeassistant.components.bosch_shc -boschshcpy==0.2.75 +boschshcpy==0.2.82 # homeassistant.components.amazon_polly # homeassistant.components.route53 boto3==1.33.13 # homeassistant.components.bring -bring-api==0.5.5 +bring-api==0.5.6 # homeassistant.components.broadlink broadlink==0.18.3 # homeassistant.components.brother -brother==4.0.0 +brother==4.0.2 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 @@ -621,7 +621,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.6.0 +bthome-ble==3.8.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 @@ -1074,10 +1074,10 @@ hole==0.8.0 holidays==0.44 # homeassistant.components.frontend -home-assistant-frontend==20240306.0 +home-assistant-frontend==20240307.0 # homeassistant.components.conversation -home-assistant-intents==2024.2.28 +home-assistant-intents==2024.3.12 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -1115,7 +1115,7 @@ ibmiotf==0.3.4 # homeassistant.components.google # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==7.0.0 +ical==7.0.1 # homeassistant.components.ping icmplib==3.0 @@ -1253,7 +1253,7 @@ logi-circle==0.2.3 london-tube-status==0.5 # homeassistant.components.loqed -loqedAPI==2.1.8 +loqedAPI==2.1.10 # homeassistant.components.luftdaten luftdaten==0.7.4 @@ -1409,7 +1409,7 @@ nsw-fuel-api-client==1.1.0 nuheat==1.0.1 # homeassistant.components.numato -numato-gpio==0.10.0 +numato-gpio==0.12.0 # homeassistant.components.compensation # homeassistant.components.iqvia @@ -1474,7 +1474,7 @@ opensensemap-api==0.2.0 openwebifpy==4.2.4 # homeassistant.components.luci -openwrt-luci-rpc==1.1.16 +openwrt-luci-rpc==1.1.17 # homeassistant.components.ubus openwrt-ubus-rpc==0.0.2 @@ -1794,7 +1794,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.19.1 +pyenphase==1.19.2 # homeassistant.components.envisalink pyenvisalink==4.6 @@ -1878,7 +1878,7 @@ pyintesishome==1.8.0 pyipma==3.0.7 # homeassistant.components.ipp -pyipp==0.14.5 +pyipp==0.15.0 # homeassistant.components.iqvia pyiqvia==2022.04.0 @@ -1971,7 +1971,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.6.4 +pymodbus==3.6.5 # homeassistant.components.monoprice pymonoprice==0.4 @@ -2155,7 +2155,7 @@ pysmartthings==0.7.8 pysml==0.0.12 # homeassistant.components.snmp -pysnmp-lextudio==6.0.2 +pysnmp-lextudio==6.0.9 # homeassistant.components.snooz pysnooz==0.8.6 @@ -2182,7 +2182,7 @@ pyswitchbee==1.8.0 pytautulli==23.1.1 # homeassistant.components.tedee -pytedee-async==0.2.15 +pytedee-async==0.2.16 # homeassistant.components.tfiac pytfiac==0.4 @@ -2244,6 +2244,9 @@ python-izone==1.2.9 # homeassistant.components.joaoapps_join python-join-api==0.0.9 +# homeassistant.components.juicenet +python-juicenet==1.1.0 + # homeassistant.components.tplink python-kasa[speedups]==0.6.2.1 @@ -2454,7 +2457,7 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.19.1 +rokuecp==0.19.2 # homeassistant.components.romy romy==0.0.7 @@ -2466,7 +2469,7 @@ roombapy==1.6.13 roonapi==0.1.6 # homeassistant.components.rova -rova==0.4.0 +rova==0.4.1 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 @@ -2836,7 +2839,7 @@ watchdog==2.3.1 waterfurnace==1.1.0 # homeassistant.components.weatherflow_cloud -weatherflow4py==0.1.12 +weatherflow4py==0.1.17 # homeassistant.components.webmin webmin-xmlrpc==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 552e9074daf896..a50fefa22907dc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -185,7 +185,7 @@ aioaseko==0.0.2 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.2.10 +aioautomower==2024.3.0 # homeassistant.components.azure_devops aioazuredevops==1.3.5 @@ -200,7 +200,7 @@ aiobotocore==2.9.1 aiocomelit==0.9.0 # homeassistant.components.dhcp -aiodhcpwatcher==0.8.0 +aiodhcpwatcher==0.8.1 # homeassistant.components.dhcp aiodiscover==1.6.1 @@ -288,7 +288,7 @@ aiomusiccast==0.14.8 aionanoleaf==0.2.1 # homeassistant.components.notion -aionotion==2024.02.2 +aionotion==2024.03.0 # homeassistant.components.oncue aiooncue==0.3.5 @@ -392,7 +392,7 @@ aioymaps==1.2.2 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.6.1 +airthings-ble==0.7.1 # homeassistant.components.airthings airthings-cloud==0.2.0 @@ -454,7 +454,7 @@ auroranoaa==0.0.3 aurorapy==0.2.7 # homeassistant.components.axis -axis==50 +axis==54 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 @@ -511,16 +511,16 @@ bluetooth-data-tools==1.19.0 bond-async==0.2.1 # homeassistant.components.bosch_shc -boschshcpy==0.2.75 +boschshcpy==0.2.82 # homeassistant.components.bring -bring-api==0.5.5 +bring-api==0.5.6 # homeassistant.components.broadlink broadlink==0.18.3 # homeassistant.components.brother -brother==4.0.0 +brother==4.0.2 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 @@ -529,7 +529,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.6.0 +bthome-ble==3.8.0 # homeassistant.components.buienradar buienradar==1.0.5 @@ -873,10 +873,10 @@ hole==0.8.0 holidays==0.44 # homeassistant.components.frontend -home-assistant-frontend==20240306.0 +home-assistant-frontend==20240307.0 # homeassistant.components.conversation -home-assistant-intents==2024.2.28 +home-assistant-intents==2024.3.12 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -905,7 +905,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.google # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==7.0.0 +ical==7.0.1 # homeassistant.components.ping icmplib==3.0 @@ -1001,7 +1001,7 @@ logi-circle==0.2.3 london-tube-status==0.5 # homeassistant.components.loqed -loqedAPI==2.1.8 +loqedAPI==2.1.10 # homeassistant.components.luftdaten luftdaten==0.7.4 @@ -1127,7 +1127,7 @@ nsw-fuel-api-client==1.1.0 nuheat==1.0.1 # homeassistant.components.numato -numato-gpio==0.10.0 +numato-gpio==0.12.0 # homeassistant.components.compensation # homeassistant.components.iqvia @@ -1390,7 +1390,7 @@ pyeconet==0.1.22 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.19.1 +pyenphase==1.19.2 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1453,7 +1453,7 @@ pyinsteon==1.5.3 pyipma==3.0.7 # homeassistant.components.ipp -pyipp==0.14.5 +pyipp==0.15.0 # homeassistant.components.iqvia pyiqvia==2022.04.0 @@ -1525,7 +1525,7 @@ pymeteoclimatic==0.1.0 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.6.4 +pymodbus==3.6.5 # homeassistant.components.monoprice pymonoprice==0.4 @@ -1673,7 +1673,7 @@ pysmartthings==0.7.8 pysml==0.0.12 # homeassistant.components.snmp -pysnmp-lextudio==6.0.2 +pysnmp-lextudio==6.0.9 # homeassistant.components.snooz pysnooz==0.8.6 @@ -1697,7 +1697,7 @@ pyswitchbee==1.8.0 pytautulli==23.1.1 # homeassistant.components.tedee -pytedee-async==0.2.15 +pytedee-async==0.2.16 # homeassistant.components.motionmount python-MotionMount==0.3.1 @@ -1723,6 +1723,9 @@ python-homewizard-energy==4.3.1 # homeassistant.components.izone python-izone==1.2.9 +# homeassistant.components.juicenet +python-juicenet==1.1.0 + # homeassistant.components.tplink python-kasa[speedups]==0.6.2.1 @@ -1882,7 +1885,7 @@ rflink==0.0.66 ring-doorbell[listen]==0.8.7 # homeassistant.components.roku -rokuecp==0.19.1 +rokuecp==0.19.2 # homeassistant.components.romy romy==0.0.7 @@ -2174,7 +2177,7 @@ wallbox==0.6.0 watchdog==2.3.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==0.1.12 +weatherflow4py==0.1.17 # homeassistant.components.webmin webmin-xmlrpc==0.0.1 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 9ad9c3676b8102..8b9f73336fe484 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -33,6 +33,7 @@ "blink", "ezviz", "hdmi_cec", + "juicenet", "lupusec", "rainbird", "slide", diff --git a/tests/components/airthings_ble/__init__.py b/tests/components/airthings_ble/__init__.py index 231ec12cb5f60c..50e3e0069bb067 100644 --- a/tests/components/airthings_ble/__init__.py +++ b/tests/components/airthings_ble/__init__.py @@ -3,7 +3,11 @@ from unittest.mock import patch -from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice +from airthings_ble import ( + AirthingsBluetoothDeviceData, + AirthingsDevice, + AirthingsDeviceType, +) from homeassistant.components.airthings_ble.const import DOMAIN from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak @@ -161,8 +165,7 @@ def patch_airthings_device_update(): manufacturer="Airthings AS", hw_version="REV A", sw_version="G-BLE-1.5.3-master+0", - model="Wave Plus", - model_raw="2930", + model=AirthingsDeviceType.WAVE_PLUS, name="Airthings Wave+", identifier="123456", sensors={ diff --git a/tests/components/airthings_ble/test_config_flow.py b/tests/components/airthings_ble/test_config_flow.py index 65ec91e69c26b9..2f20d889a85406 100644 --- a/tests/components/airthings_ble/test_config_flow.py +++ b/tests/components/airthings_ble/test_config_flow.py @@ -1,7 +1,7 @@ """Test the Airthings BLE config flow.""" from unittest.mock import patch -from airthings_ble import AirthingsDevice +from airthings_ble import AirthingsDevice, AirthingsDeviceType from bleak import BleakError from homeassistant.components.airthings_ble.const import DOMAIN @@ -28,8 +28,7 @@ async def test_bluetooth_discovery(hass: HomeAssistant) -> None: with patch_async_ble_device_from_address(WAVE_SERVICE_INFO), patch_airthings_ble( AirthingsDevice( manufacturer="Airthings AS", - model="Wave Plus", - model_raw="2930", + model=AirthingsDeviceType.WAVE_PLUS, name="Airthings Wave Plus", identifier="123456", ) @@ -111,8 +110,7 @@ async def test_user_setup(hass: HomeAssistant) -> None: ), patch_async_ble_device_from_address(WAVE_SERVICE_INFO), patch_airthings_ble( AirthingsDevice( manufacturer="Airthings AS", - model="Wave Plus", - model_raw="2930", + model=AirthingsDeviceType.WAVE_PLUS, name="Airthings Wave Plus", identifier="123456", ) diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index 217373236710c9..bb5812680f0578 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -7,6 +7,7 @@ from homeassistant.components.camera.const import StreamType from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.setup import async_setup_component from .common import WEBRTC_ANSWER @@ -69,3 +70,37 @@ async def mock_camera_web_rtc_fixture(hass): return_value=WEBRTC_ANSWER, ): yield + + +@pytest.fixture(name="mock_camera_with_device") +async def mock_camera_with_device_fixture(): + """Initialize a demo camera platform with a device.""" + dev_info = DeviceInfo( + identifiers={("camera", "test_unique_id")}, + name="Test Camera Device", + ) + + class UniqueIdMock(PropertyMock): + def __get__(self, obj, obj_type=None): + return obj.name + + with patch( + "homeassistant.components.camera.Camera.has_entity_name", + new_callable=PropertyMock(return_value=True), + ), patch( + "homeassistant.components.camera.Camera.unique_id", new=UniqueIdMock() + ), patch( + "homeassistant.components.camera.Camera.device_info", + new_callable=PropertyMock(return_value=dev_info), + ): + yield + + +@pytest.fixture(name="mock_camera_with_no_name") +async def mock_camera_with_no_name_fixture(mock_camera_with_device): + """Initialize a demo camera platform with a device and no name.""" + with patch( + "homeassistant.components.camera.Camera._attr_name", + new_callable=PropertyMock(return_value=None), + ): + yield diff --git a/tests/components/camera/test_media_source.py b/tests/components/camera/test_media_source.py index f965bdadb0950a..a70bb262103d17 100644 --- a/tests/components/camera/test_media_source.py +++ b/tests/components/camera/test_media_source.py @@ -16,6 +16,26 @@ async def setup_media_source(hass): assert await async_setup_component(hass, "media_source", {}) +async def test_device_with_device( + hass: HomeAssistant, mock_camera_with_device, mock_camera +) -> None: + """Test browsing when camera has a device and a name.""" + item = await media_source.async_browse_media(hass, "media-source://camera") + assert item.not_shown == 2 + assert len(item.children) == 1 + assert item.children[0].title == "Test Camera Device Demo camera without stream" + + +async def test_device_with_no_name( + hass: HomeAssistant, mock_camera_with_no_name, mock_camera +) -> None: + """Test browsing when camera has device and name == None.""" + item = await media_source.async_browse_media(hass, "media-source://camera") + assert item.not_shown == 2 + assert len(item.children) == 1 + assert item.children[0].title == "Test Camera Device Demo camera without stream" + + async def test_browsing_hls(hass: HomeAssistant, mock_camera_hls) -> None: """Test browsing HLS camera media source.""" item = await media_source.async_browse_media(hass, "media-source://camera") @@ -41,6 +61,7 @@ async def test_browsing_mjpeg(hass: HomeAssistant, mock_camera) -> None: assert len(item.children) == 1 assert item.not_shown == 2 assert item.children[0].media_content_type == "image/jpg" + assert item.children[0].title == "Demo camera without stream" async def test_browsing_web_rtc(hass: HomeAssistant, mock_camera_web_rtc) -> None: diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index 7a7a735ff42365..6714882ad3ffe8 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -1,4 +1,6 @@ """Test sensor of GIOS integration.""" + +from copy import deepcopy from datetime import timedelta import json from unittest.mock import patch @@ -276,22 +278,24 @@ async def test_availability(hass: HomeAssistant) -> None: async_fire_time_changed(hass, future) await hass.async_block_till_done() - state = hass.states.get("sensor.home_pm2_5") - assert state - assert state.state == STATE_UNAVAILABLE + state = hass.states.get("sensor.home_pm2_5") + assert state + assert state.state == STATE_UNAVAILABLE - state = hass.states.get("sensor.home_pm2_5_index") - assert state - assert state.state == STATE_UNAVAILABLE + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == STATE_UNAVAILABLE - state = hass.states.get("sensor.home_air_quality_index") - assert state - assert state.state == STATE_UNAVAILABLE + state = hass.states.get("sensor.home_air_quality_index") + assert state + assert state.state == STATE_UNAVAILABLE + incomplete_sensors = deepcopy(sensors) + incomplete_sensors["pm2.5"] = {} future = utcnow() + timedelta(minutes=120) with patch( "homeassistant.components.gios.Gios._get_all_sensors", - return_value=sensors, + return_value=incomplete_sensors, ), patch( "homeassistant.components.gios.Gios._get_indexes", return_value={}, @@ -299,21 +303,22 @@ async def test_availability(hass: HomeAssistant) -> None: async_fire_time_changed(hass, future) await hass.async_block_till_done() - state = hass.states.get("sensor.home_pm2_5") - assert state - assert state.state == "4" + # There is no PM2.5 data so the state should be unavailable + state = hass.states.get("sensor.home_pm2_5") + assert state + assert state.state == STATE_UNAVAILABLE - # Indexes are empty so the state should be unavailable - state = hass.states.get("sensor.home_air_quality_index") - assert state - assert state.state == STATE_UNAVAILABLE + # Indexes are empty so the state should be unavailable + state = hass.states.get("sensor.home_air_quality_index") + assert state + assert state.state == STATE_UNAVAILABLE - # Indexes are empty so the state should be unavailable - state = hass.states.get("sensor.home_pm2_5_index") - assert state - assert state.state == STATE_UNAVAILABLE + # Indexes are empty so the state should be unavailable + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == STATE_UNAVAILABLE - future = utcnow() + timedelta(minutes=180) + future = utcnow() + timedelta(minutes=180) with patch( "homeassistant.components.gios.Gios._get_all_sensors", return_value=sensors ), patch( @@ -323,17 +328,17 @@ async def test_availability(hass: HomeAssistant) -> None: async_fire_time_changed(hass, future) await hass.async_block_till_done() - state = hass.states.get("sensor.home_pm2_5") - assert state - assert state.state == "4" + state = hass.states.get("sensor.home_pm2_5") + assert state + assert state.state == "4" - state = hass.states.get("sensor.home_pm2_5_index") - assert state - assert state.state == "good" + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == "good" - state = hass.states.get("sensor.home_air_quality_index") - assert state - assert state.state == "good" + state = hass.states.get("sensor.home_air_quality_index") + assert state + assert state.state == "good" async def test_invalid_indexes(hass: HomeAssistant) -> None: diff --git a/tests/components/google/test_diagnostics.py b/tests/components/google/test_diagnostics.py index 5ebc683485b4e8..32c61d0f9457dc 100644 --- a/tests/components/google/test_diagnostics.py +++ b/tests/components/google/test_diagnostics.py @@ -1,5 +1,6 @@ """Tests for diagnostics platform of google calendar.""" from collections.abc import Callable +import time from typing import Any from aiohttp.test_utils import TestClient @@ -15,6 +16,7 @@ from tests.common import CLIENT_ID, MockConfigEntry, MockUser from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -69,8 +71,21 @@ async def test_diagnostics( aiohttp_client: ClientSessionGenerator, socket_enabled: None, snapshot: SnapshotAssertion, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test diagnostics for the calendar.""" + + expires_in = 86400 + expires_at = time.time() + expires_in + aioclient_mock.post( + "https://oauth2.googleapis.com/token", + json={ + "refresh_token": "some-refresh-token", + "access_token": "some-updated-token", + "expires_at": expires_at, + "expires_in": expires_in, + }, + ) mock_events_list_items( [ { diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 58cbc5dce0e159..c3b60a32850b70 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1,5 +1,6 @@ """Tests for the Google Assistant traits.""" from datetime import datetime, timedelta +from typing import Any from unittest.mock import ANY, patch from freezegun.api import FrozenDateTimeFactory @@ -3925,16 +3926,15 @@ async def test_air_quality_description_for_aqi(hass: HomeAssistant) -> None: BASIC_CONFIG, ) - assert trt._air_quality_description_for_aqi("0") == "healthy" - assert trt._air_quality_description_for_aqi("75") == "moderate" + assert trt._air_quality_description_for_aqi(0) == "healthy" + assert trt._air_quality_description_for_aqi(75) == "moderate" assert ( - trt._air_quality_description_for_aqi("125") == "unhealthy for sensitive groups" + trt._air_quality_description_for_aqi(125.0) == "unhealthy for sensitive groups" ) - assert trt._air_quality_description_for_aqi("175") == "unhealthy" - assert trt._air_quality_description_for_aqi("250") == "very unhealthy" - assert trt._air_quality_description_for_aqi("350") == "hazardous" - assert trt._air_quality_description_for_aqi("-1") == "unknown" - assert trt._air_quality_description_for_aqi("non-numeric") == "unknown" + assert trt._air_quality_description_for_aqi(175) == "unhealthy" + assert trt._air_quality_description_for_aqi(250) == "very unhealthy" + assert trt._air_quality_description_for_aqi(350) == "hazardous" + assert trt._air_quality_description_for_aqi(-1) == "unknown" async def test_null_device_class(hass: HomeAssistant) -> None: @@ -3955,7 +3955,19 @@ async def test_null_device_class(hass: HomeAssistant) -> None: assert trt.query_attributes() == {} -async def test_sensorstate(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("value", "published", "aqi"), + [ + (100.0, 100.0, "moderate"), + (10.0, 10.0, "healthy"), + (0, 0.0, "healthy"), + ("", None, "unknown"), + ("unknown", None, "unknown"), + ], +) +async def test_sensorstate( + hass: HomeAssistant, value: Any, published: Any, aqi: Any +) -> None: """Test SensorState trait support for sensor domain.""" sensor_types = { sensor.SensorDeviceClass.AQI: ("AirQuality", "AQI"), @@ -3977,7 +3989,7 @@ async def test_sensorstate(hass: HomeAssistant) -> None: hass, State( "sensor.test", - 100.0, + value, { "device_class": sensor_type, }, @@ -4023,16 +4035,14 @@ async def test_sensorstate(hass: HomeAssistant) -> None: "currentSensorStateData": [ { "name": name, - "currentSensorState": trt._air_quality_description_for_aqi( - trt.state.state - ), - "rawValue": trt.state.state, + "currentSensorState": aqi, + "rawValue": published, }, ] } else: assert trt.query_attributes() == { - "currentSensorStateData": [{"name": name, "rawValue": trt.state.state}] + "currentSensorStateData": [{"name": name, "rawValue": published}] } assert helpers.get_google_type(sensor.DOMAIN, None) is not None diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index 21cd249bd53b75..b5a852710fe38f 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -1,6 +1,8 @@ """Test issues from supervisor issues.""" from __future__ import annotations +import asyncio +from http import HTTPStatus import os from typing import Any from unittest.mock import ANY, patch @@ -13,7 +15,7 @@ from .test_init import MOCK_ENVIRON -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse from tests.typing import WebSocketGenerator @@ -40,6 +42,7 @@ def mock_resolution_info( unsupported: list[str] | None = None, unhealthy: list[str] | None = None, issues: list[dict[str, str]] | None = None, + suggestion_result: str = "ok", ): """Mock resolution/info endpoint with unsupported/unhealthy reasons and/or issues.""" aioclient_mock.get( @@ -76,7 +79,7 @@ def mock_resolution_info( for suggestion in suggestions: aioclient_mock.post( f"http://127.0.0.1/resolution/suggestion/{suggestion['uuid']}", - json={"result": "ok"}, + json={"result": suggestion_result}, ) @@ -528,6 +531,80 @@ async def test_supervisor_issues( ) +async def test_supervisor_issues_initial_failure( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test issues manager retries after initial update failure.""" + responses = [ + AiohttpClientMockResponse( + method="get", + url="http://127.0.0.1/resolution/info", + status=HTTPStatus.BAD_REQUEST, + json={ + "result": "error", + "message": "System is not ready with state: setup", + }, + ), + AiohttpClientMockResponse( + method="get", + url="http://127.0.0.1/resolution/info", + status=HTTPStatus.OK, + json={ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [ + { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + }, + ], + "checks": [ + {"enabled": True, "slug": "supervisor_trust"}, + {"enabled": True, "slug": "free_space"}, + ], + }, + }, + ), + ] + + async def mock_responses(*args): + nonlocal responses + return responses.pop(0) + + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + side_effect=mock_responses, + ) + aioclient_mock.get( + "http://127.0.0.1/resolution/issue/1234/suggestions", + json={"result": "ok", "data": {"suggestions": []}}, + ) + + with patch("homeassistant.components.hassio.issues.REQUEST_REFRESH_DELAY", new=0.1): + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 0 + + await asyncio.sleep(0.1) + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + + async def test_supervisor_issues_add_remove( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 5dd73a2161538e..97a13fe1e5dcf4 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -404,6 +404,78 @@ async def test_supervisor_issue_repair_flow_skip_confirmation( ) +async def test_mount_failed_repair_flow_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + all_setup_requests, +) -> None: + """Test repair flow fails when repair fails to apply.""" + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "mount_failed", + "context": "mount", + "reference": "backup_share", + "suggestions": [ + { + "uuid": "1235", + "type": "execute_reload", + "context": "mount", + "reference": "backup_share", + }, + { + "uuid": "1236", + "type": "execute_remove", + "context": "mount", + "reference": "backup_share", + }, + ], + }, + ], + suggestion_result=False, + ) + + assert await async_setup_component(hass, "hassio", {}) + + repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + flow_id = data["flow_id"] + + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", + json={"next_step_id": "mount_execute_reload"}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "abort", + "flow_id": flow_id, + "handler": "hassio", + "reason": "apply_suggestion_fail", + "result": None, + "description_placeholders": None, + } + + assert issue_registry.async_get_issue(domain="hassio", issue_id="1234") + + async def test_mount_failed_repair_flow( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index b2f9e06cb43c74..ee17ad62e743a0 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -171,6 +171,7 @@ async def test_websocket_supervisor_api_error( aioclient_mock.get( "http://127.0.0.1/ping", json={"result": "error", "message": "example error"}, + status=400, ) await websocket_client.send_json( @@ -183,9 +184,39 @@ async def test_websocket_supervisor_api_error( ) msg = await websocket_client.receive_json() + assert msg["error"]["code"] == "unknown_error" assert msg["error"]["message"] == "example error" +async def test_websocket_supervisor_api_error_without_msg( + hassio_env, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test Supervisor websocket api error.""" + assert await async_setup_component(hass, "hassio", {}) + websocket_client = await hass_ws_client(hass) + aioclient_mock.get( + "http://127.0.0.1/ping", + json={}, + status=400, + ) + + await websocket_client.send_json( + { + WS_ID: 1, + WS_TYPE: WS_TYPE_API, + ATTR_ENDPOINT: "/ping", + ATTR_METHOD: "get", + } + ) + + msg = await websocket_client.receive_json() + assert msg["error"]["code"] == "unknown_error" + assert msg["error"]["message"] == "" + + async def test_websocket_non_admin_user( hassio_env, hass: HomeAssistant, diff --git a/tests/components/husqvarna_automower/fixtures/mower.json b/tests/components/husqvarna_automower/fixtures/mower.json index 0af4926c561e6f..1e608e654a63f0 100644 --- a/tests/components/husqvarna_automower/fixtures/mower.json +++ b/tests/components/husqvarna_automower/fixtures/mower.json @@ -62,6 +62,18 @@ "connected": true, "statusTimestamp": 1697669932683 }, + "workAreas": [ + { + "workAreaId": 123456, + "name": "Front lawn", + "cuttingHeight": 50 + }, + { + "workAreaId": 0, + "name": "", + "cuttingHeight": 50 + } + ], "positions": [ { "latitude": 35.5402913, @@ -120,10 +132,6 @@ "longitude": -82.5520054 } ], - "cuttingHeight": 4, - "headlight": { - "mode": "EVENING_ONLY" - }, "statistics": { "cuttingBladeUsageTime": 123, "numberOfChargingCycles": 1380, @@ -133,6 +141,20 @@ "totalDriveDistance": 1780272, "totalRunningTime": 4564800, "totalSearchingTime": 370800 + }, + "stayOutZones": { + "dirty": false, + "zones": [ + { + "id": "81C6EEA2-D139-4FEA-B134-F22A6B3EA403", + "name": "Springflowers", + "enabled": true + } + ] + }, + "cuttingHeight": 4, + "headlight": { + "mode": "EVENING_ONLY" } } } diff --git a/tests/components/ipp/snapshots/test_diagnostics.ambr b/tests/components/ipp/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..98d0055c98209c --- /dev/null +++ b/tests/components/ipp/snapshots/test_diagnostics.ambr @@ -0,0 +1,100 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'info': dict({ + 'command_set': 'ESCPL2,BDC,D4,D4PX,ESCPR7,END4,GENEP,URF', + 'location': None, + 'manufacturer': 'TEST', + 'model': 'HA-1000 Series', + 'more_info': 'http://192.168.1.31:80/PRESENTATION/BONJOUR', + 'name': 'Test HA-1000 Series', + 'printer_info': 'Test HA-1000 Series', + 'printer_name': 'Test Printer', + 'printer_uri_supported': list([ + 'ipps://192.168.1.31:631/ipp/print', + 'ipp://192.168.1.31:631/ipp/print', + ]), + 'serial': '555534593035345555', + 'uptime': 30, + 'uuid': 'cfe92100-67c4-11d4-a45f-f8d027761251', + 'version': '20.23.06HA', + }), + 'markers': list([ + dict({ + 'color': '#000000', + 'high_level': 100, + 'level': 58, + 'low_level': 10, + 'marker_id': 0, + 'marker_type': 'ink-cartridge', + 'name': 'Black ink', + }), + dict({ + 'color': '#00FFFF', + 'high_level': 100, + 'level': 91, + 'low_level': 10, + 'marker_id': 2, + 'marker_type': 'ink-cartridge', + 'name': 'Cyan ink', + }), + dict({ + 'color': '#FF00FF', + 'high_level': 100, + 'level': 73, + 'low_level': 10, + 'marker_id': 4, + 'marker_type': 'ink-cartridge', + 'name': 'Magenta ink', + }), + dict({ + 'color': '#000000', + 'high_level': 100, + 'level': 98, + 'low_level': 10, + 'marker_id': 1, + 'marker_type': 'ink-cartridge', + 'name': 'Photo black ink', + }), + dict({ + 'color': '#FFFF00', + 'high_level': 100, + 'level': 95, + 'low_level': 10, + 'marker_id': 3, + 'marker_type': 'ink-cartridge', + 'name': 'Yellow ink', + }), + ]), + 'state': dict({ + 'message': None, + 'printer_state': 'idle', + 'reasons': None, + }), + 'uris': list([ + dict({ + 'authentication': None, + 'security': 'tls', + 'uri': 'ipps://192.168.1.31:631/ipp/print', + }), + dict({ + 'authentication': None, + 'security': None, + 'uri': 'ipp://192.168.1.31:631/ipp/print', + }), + ]), + }), + 'entry': dict({ + 'data': dict({ + 'base_path': '/ipp/print', + 'host': '192.168.1.31', + 'port': 631, + 'ssl': False, + 'uuid': 'cfe92100-67c4-11d4-a45f-f8d027761251', + 'verify_ssl': True, + }), + 'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251', + }), + }) +# --- diff --git a/tests/components/ipp/test_diagnostics.py b/tests/components/ipp/test_diagnostics.py new file mode 100644 index 00000000000000..08446601e69c51 --- /dev/null +++ b/tests/components/ipp/test_diagnostics.py @@ -0,0 +1,22 @@ +"""Tests for the diagnostics data provided by the Internet Printing Protocol (IPP) integration.""" + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/juicenet/test_config_flow.py b/tests/components/juicenet/test_config_flow.py new file mode 100644 index 00000000000000..6adc841862e8fc --- /dev/null +++ b/tests/components/juicenet/test_config_flow.py @@ -0,0 +1,124 @@ +"""Test the JuiceNet config flow.""" +from unittest.mock import MagicMock, patch + +import aiohttp +from pyjuicenet import TokenError + +from homeassistant import config_entries +from homeassistant.components.juicenet.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + + +def _mock_juicenet_return_value(get_devices=None): + juicenet_mock = MagicMock() + type(juicenet_mock).get_devices = MagicMock(return_value=get_devices) + return juicenet_mock + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.juicenet.config_flow.Api.get_devices", + return_value=MagicMock(), + ), patch( + "homeassistant.components.juicenet.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.juicenet.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"} + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "JuiceNet" + assert result2["data"] == {CONF_ACCESS_TOKEN: "access_token"} + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.juicenet.config_flow.Api.get_devices", + side_effect=TokenError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"} + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.juicenet.config_flow.Api.get_devices", + side_effect=aiohttp.ClientError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"} + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_catch_unknown_errors(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.juicenet.config_flow.Api.get_devices", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"} + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_import(hass: HomeAssistant) -> None: + """Test that import works as expected.""" + + with patch( + "homeassistant.components.juicenet.config_flow.Api.get_devices", + return_value=MagicMock(), + ), patch( + "homeassistant.components.juicenet.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.juicenet.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_ACCESS_TOKEN: "access_token"}, + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == "JuiceNet" + assert result["data"] == {CONF_ACCESS_TOKEN: "access_token"} + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/juicenet/test_init.py b/tests/components/juicenet/test_init.py deleted file mode 100644 index 8896798abe32e2..00000000000000 --- a/tests/components/juicenet/test_init.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Tests for the JuiceNet component.""" - -from homeassistant.components.juicenet import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir - -from tests.common import MockConfigEntry - - -async def test_juicenet_repair_issue( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: - """Test the JuiceNet configuration entry loading/unloading handles the repair.""" - config_entry_1 = MockConfigEntry( - title="Example 1", - domain=DOMAIN, - ) - config_entry_1.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry_1.entry_id) - await hass.async_block_till_done() - assert config_entry_1.state is ConfigEntryState.LOADED - - # Add a second one - config_entry_2 = MockConfigEntry( - title="Example 2", - domain=DOMAIN, - ) - config_entry_2.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry_2.entry_id) - await hass.async_block_till_done() - - assert config_entry_2.state is ConfigEntryState.LOADED - assert issue_registry.async_get_issue(DOMAIN, DOMAIN) - - # Remove the first one - await hass.config_entries.async_remove(config_entry_1.entry_id) - await hass.async_block_till_done() - - assert config_entry_1.state is ConfigEntryState.NOT_LOADED - assert config_entry_2.state is ConfigEntryState.LOADED - assert issue_registry.async_get_issue(DOMAIN, DOMAIN) - - # Remove the second one - await hass.config_entries.async_remove(config_entry_2.entry_id) - await hass.async_block_till_done() - - assert config_entry_1.state is ConfigEntryState.NOT_LOADED - assert config_entry_2.state is ConfigEntryState.NOT_LOADED - assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 060ea1149732fe..2fa0063dfd8899 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -408,6 +408,46 @@ async def test_websocket_delete_recurring( ] +async def test_websocket_delete_empty_recurrence_id( + ws_client: ClientFixture, setup_integration: None, get_events: GetEventsFn +) -> None: + """Test websocket delete command with an empty recurrence id no-op.""" + client = await ws_client() + await client.cmd_result( + "create", + { + "entity_id": TEST_ENTITY, + "event": { + "summary": "Bastille Day Party", + "dtstart": "1997-07-14T17:00:00+00:00", + "dtend": "1997-07-15T04:00:00+00:00", + }, + }, + ) + + events = await get_events("1997-07-14T00:00:00", "1997-07-16T00:00:00") + assert list(map(event_fields, events)) == [ + { + "summary": "Bastille Day Party", + "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, + "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, + } + ] + uid = events[0]["uid"] + + # Delete the event with an empty recurrence id + await client.cmd_result( + "delete", + { + "entity_id": TEST_ENTITY, + "uid": uid, + "recurrence_id": "", + }, + ) + events = await get_events("1997-07-14T00:00:00", "1997-07-16T00:00:00") + assert list(map(event_fields, events)) == [] + + async def test_websocket_update( ws_client: ClientFixture, setup_integration: None, get_events: GetEventsFn ) -> None: @@ -458,6 +498,58 @@ async def test_websocket_update( ] +async def test_websocket_update_empty_recurrence( + ws_client: ClientFixture, setup_integration: None, get_events: GetEventsFn +) -> None: + """Test an edit with an empty recurrence id (no-op).""" + client = await ws_client() + await client.cmd_result( + "create", + { + "entity_id": TEST_ENTITY, + "event": { + "summary": "Bastille Day Party", + "dtstart": "1997-07-14T17:00:00+00:00", + "dtend": "1997-07-15T04:00:00+00:00", + }, + }, + ) + + events = await get_events("1997-07-14T00:00:00", "1997-07-16T00:00:00") + assert list(map(event_fields, events)) == [ + { + "summary": "Bastille Day Party", + "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, + "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, + } + ] + uid = events[0]["uid"] + + # Update the event with an empty string for the recurrence id which should + # have no effect. + await client.cmd_result( + "update", + { + "entity_id": TEST_ENTITY, + "uid": uid, + "recurrence_id": "", + "event": { + "summary": "Bastille Day Party [To be rescheduled]", + "dtstart": "1997-07-15T11:00:00-06:00", + "dtend": "1997-07-15T22:00:00-06:00", + }, + }, + ) + events = await get_events("1997-07-14T00:00:00", "1997-07-16T00:00:00") + assert list(map(event_fields, events)) == [ + { + "summary": "Bastille Day Party [To be rescheduled]", + "start": {"dateTime": "1997-07-15T11:00:00-06:00"}, + "end": {"dateTime": "1997-07-15T22:00:00-06:00"}, + } + ] + + async def test_websocket_update_recurring_this_and_future( ws_client: ClientFixture, setup_integration: None, get_events: GetEventsFn ) -> None: diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 4de9a439a0122e..bd590a9e15c76a 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -858,6 +858,30 @@ async def test_duplicate_fan_mode_validator(do_config) -> None: ], 2, ), + ( + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_TIMEOUT: 3, + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1179, + CONF_SLAVE: 0, + }, + ], + }, + ], + 1, + ), ], ) async def test_duplicate_addresses(do_config, sensor_cnt) -> None: @@ -867,6 +891,41 @@ async def test_duplicate_addresses(do_config, sensor_cnt) -> None: assert len(do_config[use_inx][CONF_SENSORS]) == sensor_cnt +@pytest.mark.parametrize( + "do_config", + [ + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_TIMEOUT: 3, + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + ], + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME + "1", + CONF_ADDRESS: 1179, + CONF_SLAVE: 0, + }, + ], + }, + ], + ], +) +async def test_no_duplicate_names(do_config) -> None: + """Test duplicate entity validator.""" + check_config(do_config) + assert len(do_config[0][CONF_SENSORS]) == 1 + assert len(do_config[0][CONF_BINARY_SENSORS]) == 1 + + @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/notion/conftest.py b/tests/components/notion/conftest.py index 61a54ca58bad0b..366f78b8c6c1ed 100644 --- a/tests/components/notion/conftest.py +++ b/tests/components/notion/conftest.py @@ -9,8 +9,8 @@ from aionotion.user.models import UserPreferences import pytest -from homeassistant.components.notion import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.notion import CONF_REFRESH_TOKEN, CONF_USER_UUID, DOMAIN +from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -81,7 +81,8 @@ def config_fixture(): """Define a config entry data fixture.""" return { CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, + CONF_USER_UUID: TEST_USER_UUID, + CONF_REFRESH_TOKEN: TEST_REFRESH_TOKEN, } diff --git a/tests/components/notion/test_config_flow.py b/tests/components/notion/test_config_flow.py index 646bd7a6e87d3c..72bb3dfee0bafd 100644 --- a/tests/components/notion/test_config_flow.py +++ b/tests/components/notion/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .conftest import TEST_REFRESH_TOKEN, TEST_USER_UUID, TEST_USERNAME +from .conftest import TEST_PASSWORD, TEST_REFRESH_TOKEN, TEST_USER_UUID, TEST_USERNAME pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -26,7 +26,6 @@ async def test_create_entry( hass: HomeAssistant, client, - config, errors, get_client_with_exception, mock_aionotion, @@ -44,13 +43,22 @@ async def test_create_entry( get_client_with_exception, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=config + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, ) assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["errors"] == errors result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=config + result["flow_id"], + user_input={ + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["title"] == TEST_USERNAME diff --git a/tests/components/rainforest_raven/test_coordinator.py b/tests/components/rainforest_raven/test_coordinator.py index 6b29c944aeb0fb..1a5f4d3d3f76b7 100644 --- a/tests/components/rainforest_raven/test_coordinator.py +++ b/tests/components/rainforest_raven/test_coordinator.py @@ -1,4 +1,8 @@ """Tests for the Rainforest RAVEn data coordinator.""" + +import asyncio +import functools + from aioraven.device import RAVEnConnectionError import pytest @@ -83,6 +87,19 @@ async def test_coordinator_device_error_update(hass: HomeAssistant, mock_device) assert coordinator.last_update_success is False +async def test_coordinator_device_timeout_update(hass: HomeAssistant, mock_device): + """Test handling of a device timeout during an update.""" + entry = create_mock_entry() + coordinator = RAVEnDataCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + assert coordinator.last_update_success is True + + mock_device.get_network_info.side_effect = functools.partial(asyncio.sleep, 10) + await coordinator.async_refresh() + assert coordinator.last_update_success is False + + async def test_coordinator_comm_error(hass: HomeAssistant, mock_device): """Test handling of an error parsing or reading raw device data.""" entry = create_mock_entry() diff --git a/tests/components/recorder/test_entity_registry.py b/tests/components/recorder/test_entity_registry.py index 0d675574e12606..9ec3087d4773bc 100644 --- a/tests/components/recorder/test_entity_registry.py +++ b/tests/components/recorder/test_entity_registry.py @@ -1,10 +1,12 @@ """The tests for sensor recorder platform.""" from collections.abc import Callable +from unittest.mock import patch import pytest from sqlalchemy import select from sqlalchemy.orm import Session +from homeassistant.components import recorder from homeassistant.components.recorder import history from homeassistant.components.recorder.db_schema import StatesMeta from homeassistant.components.recorder.util import session_scope @@ -260,4 +262,101 @@ def rename_entry(): assert _count_entity_id_in_states_meta(hass, session, "sensor.test99") == 1 assert _count_entity_id_in_states_meta(hass, session, "sensor.test1") == 1 + # We should hit the safeguard in the states_meta_manager assert "the new entity_id is already in use" in caplog.text + + # We should not hit the safeguard in the entity_registry + assert "Blocked attempt to insert duplicated state rows" not in caplog.text + + +def test_rename_entity_collision_without_states_meta_safeguard( + hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +) -> None: + """Test states meta is not migrated when there is a collision. + + This test disables the safeguard in the states_meta_manager + and relies on the filter_unique_constraint_integrity_error safeguard. + """ + hass = hass_recorder() + setup_component(hass, "sensor", {}) + + entity_reg = mock_registry(hass) + + @callback + def add_entry(): + reg_entry = entity_reg.async_get_or_create( + "sensor", + "test", + "unique_0000", + suggested_object_id="test1", + ) + assert reg_entry.entity_id == "sensor.test1" + + hass.add_job(add_entry) + hass.block_till_done() + + zero, four, states = record_states(hass) + hist = history.get_significant_states( + hass, zero, four, list(set(states) | {"sensor.test99", "sensor.test1"}) + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + assert len(hist["sensor.test1"]) == 3 + + hass.states.set("sensor.test99", "collision") + hass.states.remove("sensor.test99") + + hass.block_till_done() + wait_recording_done(hass) + + # Verify history before collision + hist = history.get_significant_states( + hass, zero, four, list(set(states) | {"sensor.test99", "sensor.test1"}) + ) + assert len(hist["sensor.test1"]) == 3 + assert len(hist["sensor.test99"]) == 2 + + instance = recorder.get_instance(hass) + # Patch out the safeguard in the states meta manager + # so that we hit the filter_unique_constraint_integrity_error safeguard in the entity_registry + with patch.object(instance.states_meta_manager, "get", return_value=None): + # Rename entity sensor.test1 to sensor.test99 + @callback + def rename_entry(): + entity_reg.async_update_entity( + "sensor.test1", new_entity_id="sensor.test99" + ) + + hass.add_job(rename_entry) + wait_recording_done(hass) + + # History is not migrated on collision + hist = history.get_significant_states( + hass, zero, four, list(set(states) | {"sensor.test99", "sensor.test1"}) + ) + assert len(hist["sensor.test1"]) == 3 + assert len(hist["sensor.test99"]) == 2 + + with session_scope(hass=hass) as session: + assert _count_entity_id_in_states_meta(hass, session, "sensor.test99") == 1 + + hass.states.set("sensor.test99", "post_migrate") + wait_recording_done(hass) + + new_hist = history.get_significant_states( + hass, + zero, + dt_util.utcnow(), + list(set(states) | {"sensor.test99", "sensor.test1"}), + ) + assert new_hist["sensor.test99"][-1].state == "post_migrate" + assert len(hist["sensor.test99"]) == 2 + + with session_scope(hass=hass) as session: + assert _count_entity_id_in_states_meta(hass, session, "sensor.test99") == 1 + assert _count_entity_id_in_states_meta(hass, session, "sensor.test1") == 1 + + # We should not hit the safeguard in the states_meta_manager + assert "the new entity_id is already in use" not in caplog.text + + # We should hit the safeguard in the entity_registry + assert "Blocked attempt to insert duplicated state rows" in caplog.text diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 1c8e9da551e839..b1380cb300c767 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -2485,3 +2485,73 @@ def get_events() -> list[Event]: await hass.async_block_till_done() assert not instance.engine + + +async def test_commit_before_commits_pending_writes( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + tmp_path: Path, +) -> None: + """Test commit_before with a non-zero commit interval. + + All of our test run with a commit interval of 0 by + default, so we need to test this with a non-zero commit + """ + config = { + recorder.CONF_DB_URL: recorder_db_url, + recorder.CONF_COMMIT_INTERVAL: 60, + } + + recorder_helper.async_initialize_recorder(hass) + hass.create_task(async_setup_recorder_instance(hass, config)) + await recorder_helper.async_wait_recorder(hass) + instance = get_instance(hass) + assert instance.commit_interval == 60 + verify_states_in_queue_future = hass.loop.create_future() + verify_session_commit_future = hass.loop.create_future() + + class VerifyCommitBeforeTask(recorder.tasks.RecorderTask): + """Task to verify that commit before ran. + + If commit_before is true, we should have no pending writes. + """ + + commit_before = True + + def run(self, instance: Recorder) -> None: + if not instance._event_session_has_pending_writes: + hass.loop.call_soon_threadsafe( + verify_session_commit_future.set_result, None + ) + return + hass.loop.call_soon_threadsafe( + verify_session_commit_future.set_exception, + RuntimeError("Session still has pending write"), + ) + + class VerifyStatesInQueueTask(recorder.tasks.RecorderTask): + """Task to verify that states are in the queue.""" + + commit_before = False + + def run(self, instance: Recorder) -> None: + if instance._event_session_has_pending_writes: + hass.loop.call_soon_threadsafe( + verify_states_in_queue_future.set_result, None + ) + return + hass.loop.call_soon_threadsafe( + verify_states_in_queue_future.set_exception, + RuntimeError("Session has no pending write"), + ) + + # First insert an event + instance.queue_task(Event("fake_event")) + # Next verify that the event session has pending writes + instance.queue_task(VerifyStatesInQueueTask()) + # Finally, verify that the session was committed + instance.queue_task(VerifyCommitBeforeTask()) + + await verify_states_in_queue_future + await verify_session_commit_future diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 16033188549326..69d42a6e7bd41c 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -453,7 +453,11 @@ def test_statistics_during_period_set_back_compat( def test_rename_entity_collision( hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture ) -> None: - """Test statistics is migrated when entity_id is changed.""" + """Test statistics is migrated when entity_id is changed. + + This test relies on the the safeguard in the statistics_meta_manager + and should not hit the filter_unique_constraint_integrity_error safeguard. + """ hass = hass_recorder() setup_component(hass, "sensor", {}) @@ -530,8 +534,117 @@ def rename_entry(): # Statistics failed to migrate due to the collision stats = statistics_during_period(hass, zero, period="5minute") assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + + # Verify the safeguard in the states meta manager was hit + assert ( + "Cannot rename statistic_id `sensor.test1` to `sensor.test99` " + "because the new statistic_id is already in use" + ) in caplog.text + + # Verify the filter_unique_constraint_integrity_error safeguard was not hit + assert "Blocked attempt to insert duplicated statistic rows" not in caplog.text + + +def test_rename_entity_collision_states_meta_check_disabled( + hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +) -> None: + """Test statistics is migrated when entity_id is changed. + + This test disables the safeguard in the statistics_meta_manager + and relies on the filter_unique_constraint_integrity_error safeguard. + """ + hass = hass_recorder() + setup_component(hass, "sensor", {}) + + entity_reg = mock_registry(hass) + + @callback + def add_entry(): + reg_entry = entity_reg.async_get_or_create( + "sensor", + "test", + "unique_0000", + suggested_object_id="test1", + ) + assert reg_entry.entity_id == "sensor.test1" + + hass.add_job(add_entry) + hass.block_till_done() + + zero, four, states = record_states(hass) + hist = history.get_significant_states(hass, zero, four, list(states)) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): + stats = statistics_during_period(hass, zero, period="5minute", **kwargs) + assert stats == {} + stats = get_last_short_term_statistics( + hass, + 0, + "sensor.test1", + True, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + assert stats == {} + + do_adhoc_statistics(hass, start=zero) + wait_recording_done(hass) + expected_1 = { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(14.915254237288135), + "min": pytest.approx(10.0), + "max": pytest.approx(20.0), + "last_reset": None, + "state": None, + "sum": None, + } + expected_stats1 = [expected_1] + expected_stats2 = [expected_1] + + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + + # Insert metadata for sensor.test99 + metadata_1 = { + "has_mean": True, + "has_sum": False, + "name": "Total imported energy", + "source": "test", + "statistic_id": "sensor.test99", + "unit_of_measurement": "kWh", + } + + with session_scope(hass=hass) as session: + session.add(recorder.db_schema.StatisticsMeta.from_meta(metadata_1)) + + instance = recorder.get_instance(hass) + # Patch out the safeguard in the states meta manager + # so that we hit the filter_unique_constraint_integrity_error safeguard in the statistics + with patch.object(instance.statistics_meta_manager, "get", return_value=None): + # Rename entity sensor.test1 to sensor.test99 + @callback + def rename_entry(): + entity_reg.async_update_entity( + "sensor.test1", new_entity_id="sensor.test99" + ) + + hass.add_job(rename_entry) + wait_recording_done(hass) + + # Statistics failed to migrate due to the collision + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + + # Verify the filter_unique_constraint_integrity_error safeguard was hit assert "Blocked attempt to insert duplicated statistic rows" in caplog.text + # Verify the safeguard in the states meta manager was not hit + assert ( + "Cannot rename statistic_id `sensor.test1` to `sensor.test99` " + "because the new statistic_id is already in use" + ) not in caplog.text + def test_statistics_duplicated( hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture diff --git a/tests/components/twitch/__init__.py b/tests/components/twitch/__init__.py index 26746c7abb4196..268f18d87c1f6f 100644 --- a/tests/components/twitch/__init__.py +++ b/tests/components/twitch/__init__.py @@ -152,6 +152,7 @@ async def set_user_authentication( self, token: str, scope: list[AuthScope], + refresh_token: str | None = None, validate: bool = True, ) -> None: """Set user authentication.""" diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index 7c542e33c9d981..67b4e5b10e68ad 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -1,5 +1,6 @@ """Test UPnP/IGD config flow.""" +import copy from copy import deepcopy from unittest.mock import patch @@ -111,6 +112,7 @@ async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_SSDP}, data=ssdp.SsdpServiceInfo( ssdp_usn=TEST_USN, + # ssdp_udn=TEST_UDN, # Not provided. ssdp_st=TEST_ST, ssdp_location=TEST_LOCATION, upnp={ @@ -132,12 +134,12 @@ async def test_flow_ssdp_non_igd_device(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_SSDP}, data=ssdp.SsdpServiceInfo( ssdp_usn=TEST_USN, + ssdp_udn=TEST_UDN, ssdp_st=TEST_ST, ssdp_location=TEST_LOCATION, ssdp_all_locations=[TEST_LOCATION], upnp={ ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:WFADevice:1", # Non-IGD - ssdp.ATTR_UPNP_UDN: TEST_UDN, }, ), ) @@ -442,3 +444,40 @@ async def test_flow_user_no_discovery(hass: HomeAssistant) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "no_devices_found" + + +@pytest.mark.usefixtures( + "ssdp_instant_discovery", + "mock_setup_entry", + "mock_get_source_ip", + "mock_mac_address_from_host", +) +async def test_flow_ssdp_with_mismatched_udn(hass: HomeAssistant) -> None: + """Test config flow: discovered + configured through ssdp, where the UDN differs in the SSDP-discovery vs device description.""" + # Discovered via step ssdp. + test_discovery = copy.deepcopy(TEST_DISCOVERY) + test_discovery.upnp[ssdp.ATTR_UPNP_UDN] = "uuid:another_udn" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=test_discovery, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "ssdp_confirm" + + # Confirm via step ssdp_confirm. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_FRIENDLY_NAME + assert result["data"] == { + CONFIG_ENTRY_ST: TEST_ST, + CONFIG_ENTRY_UDN: TEST_UDN, + CONFIG_ENTRY_ORIGINAL_UDN: TEST_UDN, + CONFIG_ENTRY_LOCATION: TEST_LOCATION, + CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, + CONFIG_ENTRY_HOST: TEST_HOST, + } diff --git a/tests/components/upnp/test_init.py b/tests/components/upnp/test_init.py index d1d3dfa6c35055..aeb228a1433677 100644 --- a/tests/components/upnp/test_init.py +++ b/tests/components/upnp/test_init.py @@ -1,10 +1,12 @@ """Test UPnP/IGD setup process.""" from __future__ import annotations -from unittest.mock import AsyncMock +import copy +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from homeassistant.components import ssdp from homeassistant.components.upnp.const import ( CONFIG_ENTRY_LOCATION, CONFIG_ENTRY_MAC_ADDRESS, @@ -15,7 +17,14 @@ ) from homeassistant.core import HomeAssistant -from .conftest import TEST_LOCATION, TEST_MAC_ADDRESS, TEST_ST, TEST_UDN, TEST_USN +from .conftest import ( + TEST_DISCOVERY, + TEST_LOCATION, + TEST_MAC_ADDRESS, + TEST_ST, + TEST_UDN, + TEST_USN, +) from tests.common import MockConfigEntry @@ -94,3 +103,44 @@ async def test_async_setup_entry_multi_location( # Ensure that the IPv4 location is used. mock_async_create_device.assert_called_once_with(TEST_LOCATION) + + +@pytest.mark.usefixtures("mock_get_source_ip", "mock_mac_address_from_host") +async def test_async_setup_udn_mismatch( + hass: HomeAssistant, mock_async_create_device: AsyncMock +) -> None: + """Test async_setup_entry for a device which reports a different UDN from SSDP-discovery and device description.""" + test_discovery = copy.deepcopy(TEST_DISCOVERY) + test_discovery.upnp[ssdp.ATTR_UPNP_UDN] = "uuid:another_udn" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USN, + data={ + CONFIG_ENTRY_ST: TEST_ST, + CONFIG_ENTRY_UDN: TEST_UDN, + CONFIG_ENTRY_ORIGINAL_UDN: TEST_UDN, + CONFIG_ENTRY_LOCATION: TEST_LOCATION, + CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, + }, + ) + + # Set up device discovery callback. + async def register_callback(hass, callback, match_dict): + """Immediately do callback.""" + await callback(test_discovery, ssdp.SsdpChange.ALIVE) + return MagicMock() + + with patch( + "homeassistant.components.ssdp.async_register_callback", + side_effect=register_callback, + ), patch( + "homeassistant.components.ssdp.async_get_discovery_info_by_st", + return_value=[test_discovery], + ): + # Load config_entry. + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) is True + + # Ensure that the IPv4 location is used. + mock_async_create_device.assert_called_once_with(TEST_LOCATION) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 3cd20771e6ed88..0972918a648444 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -293,45 +293,6 @@ async def test_efr32_via_zeroconf(hass: HomeAssistant) -> None: } -@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) -@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) -async def test_discovery_via_zeroconf_ip_change(hass: HomeAssistant) -> None: - """Test zeroconf flow -- radio detected.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="tube_zb_gw_cc2652p2_poe", - data={ - CONF_DEVICE: { - CONF_DEVICE_PATH: "socket://192.168.1.5:6638", - CONF_BAUDRATE: 115200, - CONF_FLOW_CONTROL: None, - } - }, - ) - entry.add_to_hass(hass) - - service_info = zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("192.168.1.22"), - ip_addresses=[ip_address("192.168.1.22")], - hostname="tube_zb_gw_cc2652p2_poe.local.", - name="mock_name", - port=6053, - properties={"address": "tube_zb_gw_cc2652p2_poe.local"}, - type="mock_type", - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info - ) - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - assert entry.data[CONF_DEVICE] == { - CONF_DEVICE_PATH: "socket://192.168.1.22:6638", - CONF_BAUDRATE: 115200, - CONF_FLOW_CONTROL: None, - } - - @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) async def test_discovery_via_zeroconf_ip_change_ignored(hass: HomeAssistant) -> None: @@ -547,8 +508,8 @@ async def test_discovery_via_usb_already_setup(hass: HomeAssistant) -> None: @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) -async def test_discovery_via_usb_path_changes(hass: HomeAssistant) -> None: - """Test usb flow already setup and the path changes.""" +async def test_discovery_via_usb_path_does_not_change(hass: HomeAssistant) -> None: + """Test usb flow already set up and the path does not change.""" entry = MockConfigEntry( domain=DOMAIN, @@ -579,7 +540,7 @@ async def test_discovery_via_usb_path_changes(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" assert entry.data[CONF_DEVICE] == { - CONF_DEVICE_PATH: "/dev/ttyZIGBEE", + CONF_DEVICE_PATH: "/dev/ttyUSB1", CONF_BAUDRATE: 115200, CONF_FLOW_CONTROL: None, }