This commit is contained in:
Franck Nijhof
2026-04-24 20:37:21 +02:00
committed by GitHub
31 changed files with 553 additions and 123 deletions
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.4.1"]
"requirements": ["aioamazondevices==13.4.3"]
}
@@ -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"]
}
@@ -133,7 +133,7 @@ DESCRIPTIONS = (
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,
@@ -141,6 +141,7 @@ DESCRIPTIONS = (
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,
@@ -150,7 +151,7 @@ DESCRIPTIONS = (
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,
@@ -166,6 +167,7 @@ DESCRIPTIONS = (
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,
@@ -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}")
@@ -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}
+1 -1
View File
@@ -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"]
}
+4 -36
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from datetime import timedelta
from ipaddress import ip_address
import logging
import secrets
import time
@@ -24,16 +23,14 @@ from yarl import URL
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,
@@ -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,
@@ -217,6 +182,9 @@ async def async_setup_auth( # noqa: C901
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
@@ -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"
+21 -1
View File
@@ -494,6 +494,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
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()
@@ -527,8 +528,9 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
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):
@@ -543,6 +545,24 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
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:
@@ -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,
@@ -337,8 +337,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
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
+21 -11
View File
@@ -240,14 +240,16 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
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."""
@@ -458,9 +460,17 @@ class RoborockQ7Vacuum(RoborockCoordinatedEntityB01Q7, StateVacuumEntity):
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,
@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.37.1"]
"requirements": ["pyTibber==0.37.2"]
}
+15 -10
View File
@@ -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.
@@ -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(
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_push",
"loggers": ["aiotractive"],
"requirements": ["aiotractive==1.0.2"]
"requirements": ["aiotractive==1.0.3"]
}
@@ -3,9 +3,11 @@
from __future__ import annotations
import logging
from struct import error as struct_error
from sensor_state_data import SensorUpdate
from victron_ble_ha_parser import VictronBluetoothDeviceData
from victron_ble_ha_parser.parser import detect_device_type
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
@@ -38,25 +40,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
nonlocal consecutive_failures
update = data.update(service_info)
# Only consider a reauth when the device type is recognised (devices
# populated) but the advertisement key fails the quick-check built into
# validate_advertisement_key. Using the key check instead of counting
# entity values avoids false positives: some devices legitimately return
# few (or zero) sensor values when in certain error or alarm states.
# Only assess key validity for instant-readout advertisements
# (0x10 prefix) whose device type the parser actually recognizes.
# Unrecognized mode bytes or non-instant-readout packets are neutral:
# they say nothing about whether the encryption key is correct, so
# they must not increment or reset the failure counter.
raw_data = service_info.manufacturer_data.get(VICTRON_IDENTIFIER)
if update.devices and raw_data is not None:
if not data.validate_advertisement_key(raw_data):
consecutive_failures += 1
if consecutive_failures >= REAUTH_AFTER_FAILURES:
_LOGGER.debug(
"Triggering reauth for %s after %d consecutive failures",
address,
consecutive_failures,
)
entry.async_start_reauth(hass)
try:
is_recognizable = (
raw_data[:1] == b"\x10" and detect_device_type(raw_data) is not None
)
except struct_error, IndexError:
is_recognizable = False
if is_recognizable:
if not data.validate_advertisement_key(raw_data):
consecutive_failures += 1
if consecutive_failures >= REAUTH_AFTER_FAILURES:
_LOGGER.debug(
"Triggering reauth for %s after %d consecutive failures",
address,
consecutive_failures,
)
entry.async_start_reauth(hass)
consecutive_failures = 0
else:
consecutive_failures = 0
else:
consecutive_failures = 0
else:
consecutive_failures = 0
@@ -9,6 +9,7 @@ from aiohttp.web import Request
import voluptuous as vol
from voluptuous.humanize import humanize_error
from homeassistant.components.http.auth_util import async_user_not_allowed_do_auth
from homeassistant.components.http.ban import process_success_login, process_wrong_login
from homeassistant.components.http.const import KEY_HASS_USER
from homeassistant.const import __version__
@@ -97,6 +98,13 @@ class AuthPhase:
if (access_token := valid_msg.get("access_token")) and (
refresh_token := self._hass.auth.async_validate_access_token(access_token)
):
if user_access_error := async_user_not_allowed_do_auth(
self._hass, refresh_token.user, self._request
):
await self._send_bytes_text(auth_invalid_message(user_access_error))
await process_wrong_login(self._request)
raise Disconnect
conn = ActiveConnection(
self._logger,
self._hass,
+1 -1
View File
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 4
PATCH_VERSION: Final = "3"
PATCH_VERSION: Final = "4"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2)
+1 -1
View File
@@ -39,7 +39,7 @@ habluetooth==5.11.1
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20260325.7
home-assistant-frontend==20260325.8
home-assistant-intents==2026.3.24
httpx==0.28.1
ifaddr==0.2.0
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2026.4.3"
version = "2026.4.4"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
+5 -5
View File
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==13.4.1
aioamazondevices==13.4.3
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -425,7 +425,7 @@ aiotankerkoenig==0.5.1
aiotedee==0.3.0
# homeassistant.components.tractive
aiotractive==1.0.2
aiotractive==1.0.3
# homeassistant.components.unifi
aiounifi==88
@@ -1229,7 +1229,7 @@ hole==0.9.0
holidays==0.94
# homeassistant.components.frontend
home-assistant-frontend==20260325.7
home-assistant-frontend==20260325.8
# homeassistant.components.conversation
home-assistant-intents==2026.3.24
@@ -1925,7 +1925,7 @@ pyRFXtrx==0.31.1
pySDCP==1
# homeassistant.components.tibber
pyTibber==0.37.1
pyTibber==0.37.2
# homeassistant.components.dlink
pyW215==0.8.0
@@ -2155,7 +2155,7 @@ pyhaversion==22.8.0
pyheos==1.0.6
# homeassistant.components.hive
pyhive-integration==1.0.8
pyhive-integration==1.0.9
# homeassistant.components.homematic
pyhomematic==0.1.77
+5 -5
View File
@@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==13.4.1
aioamazondevices==13.4.3
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -410,7 +410,7 @@ aiotankerkoenig==0.5.1
aiotedee==0.3.0
# homeassistant.components.tractive
aiotractive==1.0.2
aiotractive==1.0.3
# homeassistant.components.unifi
aiounifi==88
@@ -1093,7 +1093,7 @@ hole==0.9.0
holidays==0.94
# homeassistant.components.frontend
home-assistant-frontend==20260325.7
home-assistant-frontend==20260325.8
# homeassistant.components.conversation
home-assistant-intents==2026.3.24
@@ -1668,7 +1668,7 @@ pyHomee==1.3.8
pyRFXtrx==0.31.1
# homeassistant.components.tibber
pyTibber==0.37.1
pyTibber==0.37.2
# homeassistant.components.dlink
pyW215==0.8.0
@@ -1847,7 +1847,7 @@ pyhaversion==22.8.0
pyheos==1.0.6
# homeassistant.components.hive
pyhive-integration==1.0.8
pyhive-integration==1.0.9
# homeassistant.components.homematic
pyhomematic==0.1.77
@@ -92,7 +92,9 @@
None,
]),
'area_id': None,
'capabilities': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -127,6 +129,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Title Current distance',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
@@ -143,7 +146,9 @@
None,
]),
'area_id': None,
'capabilities': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -182,6 +187,7 @@
'attributes': ReadOnlyDict({
'device_class': 'volume_flow_rate',
'friendly_name': 'Mock Title Current flow',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfVolumeFlowRate.LITERS_PER_MINUTE: 'L/min'>,
}),
'context': <ANY>,
@@ -399,7 +405,7 @@
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.VOLUME: 'volume'>,
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
'original_icon': None,
'original_name': 'Overall flow',
'platform': 'gardena_bluetooth',
@@ -414,7 +420,7 @@
# name: test_sensors[aqua_contour][sensor.mock_title_overall_flow-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'volume',
'device_class': 'water',
'friendly_name': 'Mock Title Overall flow',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
@@ -0,0 +1,28 @@
"""Tests for the Google Generative AI Conversation helpers."""
from __future__ import annotations
import pytest
from homeassistant.components.google_generative_ai_conversation.helpers import (
_parse_audio_mime_type,
)
from homeassistant.exceptions import HomeAssistantError
def test_parse_audio_mime_type_uppercase() -> None:
"""Test parsing uppercase MIME type audio/L16;rate=24000."""
result = _parse_audio_mime_type("audio/L16;rate=24000")
assert result == {"bits_per_sample": 16, "rate": 24000}
def test_parse_audio_mime_type_lowercase() -> None:
"""Test parsing lowercase MIME type audio/l16; rate=24000; channels=1."""
result = _parse_audio_mime_type("audio/l16; rate=24000; channels=1")
assert result == {"bits_per_sample": 16, "rate": 24000}
def test_parse_audio_mime_type_unsupported_raises() -> None:
"""Test that an unsupported MIME type raises HomeAssistantError."""
with pytest.raises(HomeAssistantError):
_parse_audio_mime_type("video/mp4")
+85
View File
@@ -592,6 +592,91 @@ async def test_local_only_user_rejected(
assert req.status == HTTPStatus.UNAUTHORIZED
async def test_auth_access_signed_path_with_local_only_user(
hass: HomeAssistant,
app: web.Application,
aiohttp_client: ClientSessionGenerator,
hass_access_token: str,
) -> None:
"""Test access with signed url for a local-only user."""
app.router.add_post("/", mock_handler)
app.router.add_get("/another_path", mock_handler)
await async_setup_auth(hass, app)
set_mock_ip = mock_real_ip(app)
client = await aiohttp_client(app)
refresh_token = hass.auth.async_validate_access_token(hass_access_token)
refresh_token.user.local_only = True
signed_path = async_sign_path(
hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id
)
# Local IP is allowed for local-only user
set_mock_ip("192.168.1.123")
req = await client.head(signed_path)
assert req.status == HTTPStatus.OK
req = await client.get(signed_path)
assert req.status == HTTPStatus.OK
data = await req.json()
assert data["user_id"] == refresh_token.user.id
# Remote IP is rejected for local-only user
for remote_addr in EXTERNAL_ADDRESSES:
set_mock_ip(remote_addr)
signed_path = async_sign_path(
hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id
)
req = await client.head(signed_path)
assert req.status == HTTPStatus.UNAUTHORIZED
req = await client.get(signed_path)
assert req.status == HTTPStatus.UNAUTHORIZED
async def test_auth_access_signed_path_with_inactive_user(
hass: HomeAssistant,
app: web.Application,
aiohttp_client: ClientSessionGenerator,
hass_access_token: str,
) -> None:
"""Test access with signed url for an inactive user."""
app.router.add_post("/", mock_handler)
app.router.add_get("/another_path", mock_handler)
await async_setup_auth(hass, app)
client = await aiohttp_client(app)
refresh_token = hass.auth.async_validate_access_token(hass_access_token)
signed_path = async_sign_path(
hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id
)
# Active user is allowed
req = await client.head(signed_path)
assert req.status == HTTPStatus.OK
req = await client.get(signed_path)
assert req.status == HTTPStatus.OK
data = await req.json()
assert data["user_id"] == refresh_token.user.id
signed_path = async_sign_path(
hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id
)
# Inactive user is rejected
refresh_token.user.is_active = False
req = await client.head(signed_path)
assert req.status == HTTPStatus.UNAUTHORIZED
req = await client.get(signed_path)
assert req.status == HTTPStatus.UNAUTHORIZED
async def test_async_user_not_allowed_do_auth(
hass: HomeAssistant, app: web.Application
) -> None:
+5
View File
@@ -538,6 +538,7 @@ async def test_lost_connection_with_imap_push(
) -> None:
"""Test error handling when the connection is lost."""
# Mock an error in waiting for a pushed update
mock_imap_protocol.idle_start.return_value = asyncio.Future()
mock_imap_protocol.wait_server_push.side_effect = imap_wait_server_push_exception
config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG)
config_entry.add_to_hass(hass)
@@ -550,6 +551,10 @@ async def test_lost_connection_with_imap_push(
assert state is not None
assert state.state == "0"
async_fire_time_changed(hass, utcnow() + timedelta(seconds=30))
await hass.async_block_till_done()
assert "Canceling IDLE wait for imap.server.com" in caplog.text
@pytest.mark.parametrize("imap_has_capability", [True], ids=["push"])
async def test_fetch_number_of_messages(
+57
View File
@@ -565,6 +565,63 @@ async def test_single_color_mode_turn_on(
assert state.state == STATE_ON
@pytest.mark.parametrize(
"hass_config",
[
{
mqtt.DOMAIN: {
light.DOMAIN: {
"schema": "json",
"name": "test",
"command_topic": "test_light/set",
"supported_color_modes": ["brightness"],
}
}
},
{
mqtt.DOMAIN: {
light.DOMAIN: {
"schema": "json",
"name": "test",
"command_topic": "test_light/set",
"supported_color_modes": ["color_temp"],
}
}
},
],
)
async def test_restore_state_with_none_color_mode(
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
) -> None:
"""Test restoring state with a None color_mode does not break turn_on.
Regression test: When an optimistic light was off at the time the state
was last saved, `state_attributes` stores `color_mode: None`. On restart,
the restore path would overwrite the correctly initialized color_mode with
None, causing turn_on to raise "does not report a color mode".
"""
fake_state = State(
"light.test",
STATE_OFF,
{"color_mode": None, "brightness": None},
)
mock_restore_cache(hass, (fake_state,))
mqtt_mock = await mqtt_mock_entry()
state = hass.states.get("light.test")
assert state.state == STATE_OFF
# This should not raise "does not report a color mode"
await common.async_turn_on(hass, "light.test")
mqtt_mock.async_publish.assert_called_once_with(
"test_light/set", '{"state":"ON"}', 0, False
)
state = hass.states.get("light.test")
assert state.state == STATE_ON
assert state.attributes.get("color_mode") is not None
@pytest.mark.parametrize(
"hass_config",
[
+23 -19
View File
@@ -142,6 +142,29 @@ async def test_commands(
assert vacuum_command.send.call_args == call(command, params=called_params)
@pytest.mark.parametrize(
"entity_id",
[
ENTITY_ID,
Q7_ENTITY_ID,
Q10_ENTITY_ID,
],
)
async def test_set_fan_speed_invalid(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
entity_id: str,
) -> None:
"""Test calling set_fan_speed with an invalid mode."""
with pytest.raises(ServiceValidationError, match="Invalid fan speed: some-mode"):
await hass.services.async_call(
VACUUM_DOMAIN,
SERVICE_SET_FAN_SPEED,
{ATTR_ENTITY_ID: entity_id, "fan_speed": "some-mode"},
blocking=True,
)
@pytest.mark.parametrize(
("in_cleaning_int", "in_returning_int", "expected_command"),
[
@@ -880,25 +903,6 @@ async def test_q10_set_fan_speed_command(
assert q10_vacuum_api.vacuum.set_fan_level.call_args[0] == (YXFanLevel.QUIET,)
async def test_q10_set_invalid_fan_speed(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
q10_vacuum_api: Mock,
) -> None:
"""Test that setting an invalid fan speed raises an error."""
vacuum = hass.states.get(Q10_ENTITY_ID)
assert vacuum
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
VACUUM_DOMAIN,
SERVICE_SET_FAN_SPEED,
{ATTR_ENTITY_ID: Q10_ENTITY_ID, "fan_speed": "invalid_speed"},
blocking=True,
)
assert q10_vacuum_api.vacuum.set_fan_level.call_count == 0
@pytest.mark.parametrize(
"command",
[
+4
View File
@@ -89,6 +89,10 @@ def mock_tractive_client() -> Generator[AsyncMock]:
patch(
"homeassistant.components.tractive.aiotractive.Tractive", autospec=True
) as mock_client,
patch(
"homeassistant.components.tractive.asyncio.sleep",
new_callable=AsyncMock,
),
):
client = mock_client.return_value
client.authenticate.return_value = {"user_id": "12345"}
+16
View File
@@ -201,6 +201,22 @@ VICTRON_DC_DC_CONVERTER_UNKNOWN_OFF_REASON_SERVICE_INFO = BluetoothServiceInfo(
source="local",
)
# Same Victron manufacturer data prefix but with an unrecognized mode byte
# (0xEE at offset 4). detect_device_type returns None for this payload,
# so validate_advertisement_key would also return False. The reauth logic
# must treat this as neutral (not a key failure).
VICTRON_VEBUS_UNRECOGNIZED_MODE_SERVICE_INFO = BluetoothServiceInfo(
name="Inverter Charger",
address="01:02:03:04:05:06",
rssi=-60,
manufacturer_data={
0x02E1: bytes.fromhex("10038027ee1252dad26f0b8eb39162074d140df410")
},
service_data={},
service_uuids=[],
source="local",
)
VICTRON_VEBUS_SENSORS = {
"inverter_charger_device_state": "float",
"inverter_charger_battery_voltage": "14.45",
@@ -41,6 +41,7 @@ from .fixtures import (
VICTRON_VEBUS_BAD_KEY_SERVICE_INFO,
VICTRON_VEBUS_SERVICE_INFO,
VICTRON_VEBUS_TOKEN,
VICTRON_VEBUS_UNRECOGNIZED_MODE_SERVICE_INFO,
)
from tests.common import MockConfigEntry, snapshot_platform
@@ -165,6 +166,28 @@ def _inject_bad_advertisement(hass: HomeAssistant, seq: int = 0) -> None:
)
def _inject_unrecognized_mode_advertisement(hass: HomeAssistant, seq: int = 0) -> None:
"""Inject a Victron advertisement with an unrecognized mode byte.
detect_device_type returns None for this payload so the reauth guard
must treat it as neutral (neither increment nor reset the failure counter).
"""
info = VICTRON_VEBUS_UNRECOGNIZED_MODE_SERVICE_INFO
raw = bytearray(info.manufacturer_data[VICTRON_IDENTIFIER])
raw[-1] = seq & 0xFF
device = generate_ble_device(address=info.address, name=info.name, details={})
adv = generate_advertisement_data(
local_name=info.name,
manufacturer_data={VICTRON_IDENTIFIER: bytes(raw)},
service_data=info.service_data,
service_uuids=info.service_uuids,
rssi=-60,
)
inject_advertisement_with_time_and_source_connectable(
hass, device, adv, time.monotonic(), "local", True
)
@pytest.mark.usefixtures("enable_bluetooth")
async def test_reauth_triggered_after_consecutive_failures(
hass: HomeAssistant,
@@ -323,3 +346,76 @@ async def test_charger_error_state(
state = hass.states.get("sensor.solar_charger_charger_error")
assert state is not None
assert state.state == expected_state
@pytest.mark.usefixtures("enable_bluetooth")
async def test_reauth_not_triggered_on_unrecognized_mode(
hass: HomeAssistant,
mock_config_entry_added_to_hass: MockConfigEntry,
) -> None:
"""Test reauth is NOT triggered by advertisements with unrecognized mode bytes.
Some Victron devices broadcast advertisements with mode bytes that
detect_device_type does not recognize (returns None).
validate_advertisement_key also returns False for these, but that does
not mean the encryption key is wrong.
Regression test for https://github.com/home-assistant/core/issues/168019
"""
entry = mock_config_entry_added_to_hass
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
# First inject a valid advertisement so update.devices is populated
inject_bluetooth_service_info(hass, VICTRON_VEBUS_SERVICE_INFO)
await hass.async_block_till_done()
# Now send many unrecognized-mode advertisements
for i in range(REAUTH_AFTER_FAILURES + 5):
_inject_unrecognized_mode_advertisement(hass, seq=i)
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
assert len(flows) == 0
@pytest.mark.usefixtures("enable_bluetooth")
async def test_reauth_still_triggers_across_unrecognized_mode(
hass: HomeAssistant,
mock_config_entry_added_to_hass: MockConfigEntry,
) -> None:
"""Test that unrecognized-mode advertisements are neutral for the failure counter.
The sequence bad bad unrecognized bad must still trigger reauth
because unrecognized advertisements should neither increment nor reset the
consecutive failure counter.
Regression test for https://github.com/home-assistant/core/issues/168019
"""
entry = mock_config_entry_added_to_hass
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
# First inject a valid advertisement so update.devices is populated
inject_bluetooth_service_info(hass, VICTRON_VEBUS_SERVICE_INFO)
await hass.async_block_till_done()
# bad, bad (2 failures)
_inject_bad_advertisement(hass, seq=100)
await hass.async_block_till_done()
_inject_bad_advertisement(hass, seq=101)
await hass.async_block_till_done()
# unrecognized mode — should be neutral
_inject_unrecognized_mode_advertisement(hass, seq=50)
await hass.async_block_till_done()
# one more bad → 3 consecutive failures → reauth
_inject_bad_advertisement(hass, seq=102)
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
assert len(flows) == 1
assert flows[0]["context"]["source"] == "reauth"
@@ -23,6 +23,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.setup import async_setup_component
from tests.test_util import mock_real_ip
from tests.typing import ClientSessionGenerator
@@ -151,6 +152,66 @@ async def test_auth_active_user_inactive(
assert auth_msg["type"] == TYPE_AUTH_INVALID
async def test_auth_local_only_user_rejected_remote(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
hass_access_token: str,
) -> None:
"""Test that a local-only user cannot authenticate from a remote IP."""
refresh_token = hass.auth.async_validate_access_token(hass_access_token)
refresh_token.user.local_only = True
assert await async_setup_component(hass, "websocket_api", {})
await hass.async_block_till_done()
set_mock_ip = mock_real_ip(hass.http.app)
set_mock_ip("198.51.100.1")
client = await hass_client_no_auth()
with patch(
"homeassistant.components.websocket_api.auth.process_wrong_login",
) as mock_process_wrong_login:
async with client.ws_connect(URL) as ws:
auth_msg = await ws.receive_json()
assert auth_msg["type"] == TYPE_AUTH_REQUIRED
await ws.send_json({"type": TYPE_AUTH, "access_token": hass_access_token})
auth_msg = await ws.receive_json()
assert auth_msg["type"] == TYPE_AUTH_INVALID
assert auth_msg["message"] == "User cannot authenticate remotely"
assert mock_process_wrong_login.called
async def test_auth_local_only_user_allowed_local(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
hass_access_token: str,
) -> None:
"""Test that a local-only user can authenticate from a local IP."""
refresh_token = hass.auth.async_validate_access_token(hass_access_token)
refresh_token.user.local_only = True
assert await async_setup_component(hass, "websocket_api", {})
await hass.async_block_till_done()
set_mock_ip = mock_real_ip(hass.http.app)
set_mock_ip("192.168.1.100")
client = await hass_client_no_auth()
async with client.ws_connect(URL) as ws:
auth_msg = await ws.receive_json()
assert auth_msg["type"] == TYPE_AUTH_REQUIRED
await ws.send_json({"type": TYPE_AUTH, "access_token": hass_access_token})
auth_msg = await ws.receive_json()
assert auth_msg["type"] == TYPE_AUTH_OK
async def test_auth_active_with_password_not_allow(
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator
) -> None: