Skip to content
Merged

2026.4.4 #169092

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion homeassistant/components/alexa_devices/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.4.1"]
"requirements": ["aioamazondevices==13.4.3"]
}
2 changes: 1 addition & 1 deletion homeassistant/components/frontend/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260325.7"]
"requirements": ["home-assistant-frontend==20260325.8"]
}
6 changes: 4 additions & 2 deletions homeassistant/components/gardena_bluetooth/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,15 @@ def context(self) -> set[str]:
key=FlowStatistics.overall.unique_id,
translation_key="flow_statistics_overall",
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.VOLUME,
device_class=SensorDeviceClass.WATER,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfVolume.LITERS,
char=FlowStatistics.overall,
),
GardenaBluetoothSensorEntityDescription(
key=FlowStatistics.current.unique_id,
translation_key="flow_statistics_current",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
Expand All @@ -150,7 +151,7 @@ def context(self) -> set[str]:
key=FlowStatistics.resettable.unique_id,
translation_key="flow_statistics_resettable",
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.VOLUME,
device_class=SensorDeviceClass.WATER,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfVolume.LITERS,
char=FlowStatistics.resettable,
Expand All @@ -166,6 +167,7 @@ def context(self) -> set[str]:
GardenaBluetoothSensorEntityDescription(
key=Spray.current_distance.unique_id,
translation_key="spray_current_distance",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=PERCENTAGE,
char=Spray.current_distance,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def _parse_audio_mime_type(mime_type: str) -> dict[str, int]:
integers if found, otherwise None.

"""
if not mime_type.startswith("audio/L"):
if not mime_type.lower().startswith("audio/l"):
LOGGER.warning("Received unexpected MIME type %s", mime_type)
raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}")

Expand All @@ -65,9 +65,9 @@ def _parse_audio_mime_type(mime_type: str) -> dict[str, int]:
with suppress(ValueError, IndexError):
rate_str = param.split("=", 1)[1]
rate = int(rate_str)
elif param.startswith("audio/L"):
elif param.lower().startswith("audio/l"):
# Keep bits_per_sample as default if conversion fails
with suppress(ValueError, IndexError):
bits_per_sample = int(param.split("L", 1)[1])
bits_per_sample = int(param.upper().split("L", 1)[1])

return {"bits_per_sample": bits_per_sample, "rate": rate}
2 changes: 1 addition & 1 deletion homeassistant/components/hive/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["apyhiveapi"],
"requirements": ["pyhive-integration==1.0.8"]
"requirements": ["pyhive-integration==1.0.9"]
}
40 changes: 4 additions & 36 deletions homeassistant/components/http/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

from collections.abc import Awaitable, Callable
from datetime import timedelta
from ipaddress import ip_address
import logging
import secrets
import time
Expand All @@ -24,16 +23,14 @@

from homeassistant.auth import jwt_wrapper
from homeassistant.auth.const import GROUP_ID_READ_ONLY
from homeassistant.auth.models import User
from homeassistant.components import websocket_api
from homeassistant.const import HASSIO_USER_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.http import current_request
from homeassistant.helpers.json import json_bytes
from homeassistant.helpers.network import is_cloud_connection
from homeassistant.helpers.storage import Store
from homeassistant.util.network import is_local

from .auth_util import async_user_not_allowed_do_auth
from .const import (
KEY_AUTHENTICATED,
KEY_HASS_REFRESH_TOKEN_ID,
Expand Down Expand Up @@ -99,38 +96,6 @@ def async_sign_path(
return f"{url.path}?{url.query_string}"


@callback
def async_user_not_allowed_do_auth(
hass: HomeAssistant, user: User, request: Request | None = None
) -> str | None:
"""Validate that user is not allowed to do auth things."""
if not user.is_active:
return "User is not active"

if not user.local_only:
return None

# User is marked as local only, check if they are allowed to do auth
if request is None:
request = current_request.get()

if not request:
return "No request available to validate local access"

if is_cloud_connection(hass):
return "User is local only"

try:
remote_address = ip_address(request.remote) # type: ignore[arg-type]
except ValueError:
return "Invalid remote IP"

if is_local(remote_address):
return None

return "User cannot authenticate remotely"


async def async_setup_auth( # noqa: C901
hass: HomeAssistant,
app: Application,
Expand Down Expand Up @@ -217,6 +182,9 @@ def async_validate_signed_request(request: Request) -> bool:
if refresh_token is None:
return False

if async_user_not_allowed_do_auth(hass, refresh_token.user, request):
return False

request[KEY_HASS_USER] = refresh_token.user
request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id
return True
Expand Down
45 changes: 45 additions & 0 deletions homeassistant/components/http/auth_util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Auth utilities for the HTTP component."""

from __future__ import annotations

from ipaddress import ip_address

from aiohttp.web import Request

from homeassistant.auth.models import User
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.http import current_request
from homeassistant.helpers.network import is_cloud_connection
from homeassistant.util.network import is_local


@callback
def async_user_not_allowed_do_auth(
hass: HomeAssistant, user: User, request: Request | None = None
) -> str | None:
"""Validate that user is not allowed to do auth things."""
if not user.is_active:
return "User is not active"

if not user.local_only:
return None

# User is marked as local only, check if they are allowed to do auth
if request is None:
request = current_request.get()

if not request:
return "No request available to validate local access"

if is_cloud_connection(hass):
return "User is local only"

try:
remote_address = ip_address(request.remote) # type: ignore[arg-type]
except ValueError:
return "Invalid remote IP"

if is_local(remote_address):
return None

return "User cannot authenticate remotely"
22 changes: 21 additions & 1 deletion homeassistant/components/imap/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,7 @@ async def async_start(self) -> None:

async def _async_wait_push_loop(self) -> None:
"""Wait for data push from server."""
idle: asyncio.Future | None = None
while True:
try:
self.number_of_messages = await self._async_fetch_number_of_messages()
Expand Down Expand Up @@ -527,8 +528,9 @@ async def _async_wait_push_loop(self) -> None:
else:
self.auth_errors = 0
self.async_set_updated_data(self.number_of_messages)

try:
idle: asyncio.Future = await self.imap_client.idle_start()
idle = await self.imap_client.idle_start()
await self.imap_client.wait_server_push()
self.imap_client.idle_done()
async with asyncio.timeout(10):
Expand All @@ -543,6 +545,24 @@ async def _async_wait_push_loop(self) -> None:
await self._cleanup()
await asyncio.sleep(BACKOFF_TIME)

finally:
# Ensure no pending IDLE future survives
if idle is not None and not idle.done():
idle.cancel()
_LOGGER.debug(
"Canceling IDLE wait for %s",
self.config_entry.data[CONF_SERVER],
)
try:
await idle
except asyncio.CancelledError:
if (
current_task := asyncio.current_task()
) and current_task.cancelling():
raise
except AioImapException:
pass

async def shutdown(self, *_: Any) -> None:
"""Close resources."""
if self._push_wait_task:
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/kodi/browse_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async def build_item_response(media_library, payload, get_thumbnail_url=None):
media_content_id=search_id,
media_content_type=search_type,
title=title,
can_play=search_type in PLAYABLE_MEDIA_TYPES and search_id,
can_play=bool(search_type in PLAYABLE_MEDIA_TYPES and search_id),
can_expand=True,
children=children,
thumbnail=thumbnail,
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/mqtt/light/schema_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,8 +337,8 @@ async def _subscribe_topics(self) -> None:
self._attr_brightness = last_attributes.get(
ATTR_BRIGHTNESS, self.brightness
)
self._attr_color_mode = last_attributes.get(
ATTR_COLOR_MODE, self.color_mode
self._attr_color_mode = (
last_attributes.get(ATTR_COLOR_MODE) or self.color_mode
)
self._attr_color_temp_kelvin = last_attributes.get(
ATTR_COLOR_TEMP_KELVIN, self.color_temp_kelvin
Expand Down
32 changes: 21 additions & 11 deletions homeassistant/components/roborock/vacuum.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,14 +240,16 @@ async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
translation_domain=DOMAIN,
translation_key="update_options_failed",
)
await self.send(
RoborockCommand.SET_CUSTOM_MODE,
[
{v: k for k, v in self._status_trait.fan_speed_mapping.items()}[
fan_speed
]
],
)
code_mapping = {v: k for k, v in self._status_trait.fan_speed_mapping.items()}
if (fan_speed_code := code_mapping.get(fan_speed)) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_fan_speed",
translation_placeholders={
"fan_speed": fan_speed,
},
)
await self.send(RoborockCommand.SET_CUSTOM_MODE, [fan_speed_code])

async def async_set_vacuum_goto_position(self, x: int, y: int) -> None:
"""Send vacuum to a specific target point."""
Expand Down Expand Up @@ -458,9 +460,17 @@ async def async_locate(self, **kwargs: Any) -> None:
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
"""Set vacuum fan speed."""
try:
await self.coordinator.api.set_fan_speed(
SCWindMapping.from_value(fan_speed)
)
fan_speed_code = SCWindMapping.from_value(fan_speed)
except ValueError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_fan_speed",
translation_placeholders={
"fan_speed": fan_speed,
},
) from err
try:
await self.coordinator.api.set_fan_speed(fan_speed_code)
except RoborockException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/tibber/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.37.1"]
"requirements": ["pyTibber==0.37.2"]
}
25 changes: 15 additions & 10 deletions homeassistant/components/tractive/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: TractiveConfigEntry) ->

tractive = TractiveClient(hass, client, creds["user_id"], entry)

trackables = []
try:
trackable_objects = await client.trackable_objects()
trackables = await asyncio.gather(
*(_generate_trackables(client, item) for item in trackable_objects)
)
for obj in await client.trackable_objects():
# To avoid hitting Tractive API rate limits, we add a small
# delay between requests to fetch trackable details.
await asyncio.sleep(2)
trackables.append(await _generate_trackables(client, obj))
except aiotractive.exceptions.TractiveError as error:
await client.close()
raise ConfigEntryNotReady from error
except ConfigEntryNotReady:
await client.close()
raise

# When the pet defined in Tractive has no tracker linked we get None as `trackable`.
# So we have to remove None values from trackables list.
Expand Down Expand Up @@ -164,12 +170,11 @@ async def _generate_trackables(
tracker = client.tracker(trackable_data["device_id"])
trackable_pet = client.trackable_object(trackable_data["_id"])

tracker_details, hw_info, pos_report, health_overview = await asyncio.gather(
tracker.details(),
tracker.hw_info(),
tracker.pos_report(),
trackable_pet.health_overview(),
)
# Sequential fetching to prevent HTTP 429 Rate Limits
tracker_details = await tracker.details()
hw_info = await tracker.hw_info()
pos_report = await tracker.pos_report()
health_overview = await trackable_pet.health_overview()

if not tracker_details.get("_id"):
raise ConfigEntryNotReady(
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/tractive/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_push",
"loggers": ["aiotractive"],
"requirements": ["aiotractive==1.0.2"]
"requirements": ["aiotractive==1.0.3"]
}
Loading
Loading