Compare commits

...

25 Commits
dev ... rc

Author SHA1 Message Date
Franck Nijhof 0683344079 Bump version to 2026.6.1 2026-06-05 18:02:28 +00:00
Joakim Plate 0b77cf9e4b Fix process advertisement for active scans (#173116) 2026-06-05 18:02:10 +00:00
Noah Husby e0a87d966d Bump aiostreammagic to 2.13.2 (#173114) 2026-06-05 18:02:08 +00:00
Paul Bottein af53d2d082 Bump yoto-api to 3.1.6 (#173104) 2026-06-05 18:02:06 +00:00
Joost Lekkerkerker da7fa80e75 Bump pySmartThings to 4.0.1 (#173092) 2026-06-05 18:02:04 +00:00
Robert Resch 6cf1e7fb48 Bump wheels to 2026.06.0 (#173089) 2026-06-05 18:02:02 +00:00
Jan Bouwhuis 18fa0ac47d Create certificate files before trying to migrate the MQTT config entry (#173087)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-05 18:01:59 +00:00
Robert Resch 4afced1a49 Unify query token auth in http views (#173082) 2026-06-05 18:01:57 +00:00
Ronald van der Meer 74a4471160 Fix Duco mode end time sensor name (#173045) 2026-06-05 18:01:55 +00:00
Franck Nijhof 857a3de066 Convert LinkPlay configuration_url to string for device registry (#173034) 2026-06-05 18:01:53 +00:00
Erwin Douna 06bf2ff6de Portainer extend timeout for disk space coordinator (#173032) 2026-06-05 18:01:51 +00:00
G Johansson 6a5dae9cc3 Bump holidays to 0.98 (#173029) 2026-06-05 18:01:49 +00:00
Erik Montnemery 475ebbc028 Fix person in_zones propagation from scanner in home zone (#173007) 2026-06-05 18:01:47 +00:00
Maciej Bieniek 6e7643e997 Bump imgw_pib to 2.2.2 (#172999) 2026-06-05 18:01:45 +00:00
Erik Montnemery 1f954cda0d Improve person tests (#172997) 2026-06-05 18:01:43 +00:00
Jan Bouwhuis 2961fca1b1 Fix value template in MQTT Fan and Siren subentry setup (#172980) 2026-06-05 18:01:41 +00:00
Abílio Costa 106b189206 Bump idasen-ha to 2.7.0 (#172962) 2026-06-05 18:01:39 +00:00
Nikolai Rahimi 0387034f4e Fix Mitsubishi Comfort devices skipped due to unresolved local address (#172959) 2026-06-05 18:01:37 +00:00
starkillerOG f81b6abca9 Add more Reolink diagnostic info (#172945) 2026-06-05 18:01:35 +00:00
Thomas55555 43f6e7977e Bump aioautomower to 2.7.6 (#172937) 2026-06-05 18:01:33 +00:00
Samuel Xiao 706fea4ec5 Switchbot Cloud: Fixed an issue where condition filtering for enabled Webhooks was abnormal (#172903) 2026-06-05 18:01:32 +00:00
Kurt Chrisford 74d23503e7 Bump actron-neo-api to 0.5.12 (#172902) 2026-06-05 18:01:30 +00:00
rjones-gentex 4ca5da2365 Upgrade HomeLink package, set integration type (#172371) 2026-06-05 17:50:58 +00:00
Eric Stern 53c77ae2ef Fix SleepIQ 401 storm by isolating client session cookies (#172276) 2026-06-05 17:50:56 +00:00
bk86a 14968f9d67 Fix Lyric sensor crash when next_period_time is None (#167831)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-05 17:50:54 +00:00
58 changed files with 1018 additions and 296 deletions
+2 -2
View File
@@ -137,7 +137,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -195,7 +195,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
Generated
+2 -2
View File
@@ -623,8 +623,8 @@ CLAUDE.md @home-assistant/core
/tests/components/generic_hygrostat/ @Shulyaka
/homeassistant/components/geniushub/ @manzanotti
/tests/components/geniushub/ @manzanotti
/homeassistant/components/gentex_homelink/ @niaexa @ryanjones-gentex
/tests/components/gentex_homelink/ @niaexa @ryanjones-gentex
/homeassistant/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex
/tests/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex
/homeassistant/components/geo_json_events/ @exxamalte
/tests/components/geo_json_events/ @exxamalte
/homeassistant/components/geo_location/ @home-assistant/core
@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["actron-neo-api==0.5.6"]
"requirements": ["actron-neo-api==0.5.12"]
}
+12 -6
View File
@@ -6,6 +6,7 @@ These APIs are the only documented way to interact with the bluetooth integratio
import asyncio
from asyncio import Future
from collections.abc import Callable, Iterable
from contextlib import ExitStack
from typing import TYPE_CHECKING, cast
from bleak import BleakScanner
@@ -178,15 +179,20 @@ async def async_process_advertisements(
if not done.done() and callback(service_info):
done.set_result(service_info)
unload = _get_manager(hass).async_register_callback(
_async_discovered_device, match_dict, mode, scan_duration=timeout
)
manager = _get_manager(hass)
with ExitStack() as stack:
unload = manager.async_register_callback(
_async_discovered_device, match_dict, mode
)
stack.callback(unload)
if mode == BluetoothScanningMode.ACTIVE:
task = hass.async_create_task(manager.async_request_active_scan(timeout))
stack.callback(task.cancel)
try:
async with asyncio.timeout(timeout):
return await done
finally:
unload()
@hass_callback
+10 -18
View File
@@ -1,18 +1,19 @@
"""The Brands integration."""
from collections import deque
from collections.abc import Container, Mapping
from http import HTTPStatus
import logging
from pathlib import Path
from random import SystemRandom
import time
from typing import Any, Final
from typing import Any, Final, override
from aiohttp import ClientError, hdrs, web
from aiohttp import ClientError, web
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant, callback, valid_domain
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -108,23 +109,18 @@ def _read_brand_file(brand_dir: Path, image: str) -> bytes | None:
class _BrandsBaseView(HomeAssistantView):
"""Base view for serving brand images."""
requires_auth = False
use_query_token_for_auth = True
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the view."""
self._hass = hass
self._cache_dir = Path(hass.config.cache_path(DOMAIN))
def _authenticate(self, request: web.Request) -> None:
"""Authenticate the request using Bearer token or query token."""
access_tokens: deque[str] = self._hass.data[DOMAIN]
authenticated = (
request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens
)
if not authenticated:
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
raise web.HTTPForbidden
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
return self._hass.data[DOMAIN]
async def _serve_from_custom_integration(
self,
@@ -240,8 +236,6 @@ class BrandsIntegrationView(_BrandsBaseView):
image: str,
) -> web.Response:
"""Handle GET request for an integration brand image."""
self._authenticate(request)
if not valid_domain(domain) or image not in ALLOWED_IMAGES:
return web.Response(status=HTTPStatus.NOT_FOUND)
@@ -274,8 +268,6 @@ class BrandsHardwareView(_BrandsBaseView):
image: str,
) -> web.Response:
"""Handle GET request for a hardware brand image."""
self._authenticate(request)
if not CATEGORY_RE.match(category):
return web.Response(status=HTTPStatus.NOT_FOUND)
# Hardware images have dynamic names like "manufacturer_model.png"
@@ -8,6 +8,6 @@
"iot_class": "local_push",
"loggers": ["aiostreammagic"],
"quality_scale": "platinum",
"requirements": ["aiostreammagic==2.13.1"],
"requirements": ["aiostreammagic==2.13.2"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
}
+14 -18
View File
@@ -2,7 +2,7 @@
import asyncio
import collections
from collections.abc import Awaitable, Callable, Coroutine
from collections.abc import Awaitable, Callable, Container, Coroutine, Mapping
from contextlib import suppress
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
@@ -12,16 +12,16 @@ import logging
import os
from random import SystemRandom
import time
from typing import Any, Final, final
from typing import Any, Final, final, override
from aiohttp import hdrs, web
from aiohttp import web
import attr
from propcache.api import cached_property, under_cached_property
import voluptuous as vol
from webrtc_models import RTCIceCandidateInit
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
@@ -776,30 +776,26 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
class CameraView(HomeAssistantView):
"""Base CameraView."""
requires_auth = False
use_query_token_for_auth = True
def __init__(self, component: EntityComponent[Camera]) -> None:
"""Initialize a basic camera view."""
self.component = component
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (camera := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return camera.access_tokens
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
if (camera := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") in camera.access_tokens
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
# Invalid sigAuth or camera access token
raise web.HTTPForbidden
if not camera.is_on:
_LOGGER.debug("Camera is off")
raise web.HTTPServiceUnavailable
+1 -1
View File
@@ -59,7 +59,7 @@
"name": "Target flow level"
},
"time_state_end": {
"name": "Mode end time"
"name": "State end time"
},
"ventilation_state": {
"name": "Ventilation state",
@@ -1,11 +1,12 @@
{
"domain": "gentex_homelink",
"name": "HomeLink",
"codeowners": ["@niaexa", "@ryanjones-gentex"],
"codeowners": ["@Gentex-Corporation/Homelink", "@rjones-gentex"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/gentex_homelink",
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["homelink-integration-api==0.0.1"]
"requirements": ["homelink-integration-api==0.0.5"]
}
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.97", "babel==2.15.0"]
"requirements": ["holidays==0.98", "babel==2.15.0"]
}
@@ -9,5 +9,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2.7.5"]
"requirements": ["aioautomower==2.7.6"]
}
@@ -13,5 +13,5 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["idasen-ha==2.6.5"]
"requirements": ["idasen-ha==2.7.0"]
}
+19 -23
View File
@@ -2,20 +2,21 @@
import asyncio
import collections
from collections.abc import Container, Mapping
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
import os
from random import SystemRandom
from typing import Final, final
from typing import Final, final, override
from aiohttp import hdrs, web
from aiohttp import web
import httpx
from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import (
@@ -314,33 +315,28 @@ class ImageView(HomeAssistantView):
"""View to serve an image."""
name = "api:image:image"
requires_auth = False
use_query_token_for_auth = True
url = "/api/image_proxy/{entity_id}"
def __init__(self, component: EntityComponent[ImageEntity]) -> None:
"""Initialize an image view."""
self.component = component
async def _authenticate_request(
self, request: web.Request, entity_id: str
) -> ImageEntity:
"""Authenticate request and return image entity."""
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (image_entity := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return image_entity.access_tokens
@callback
def _get_image_entity(self, entity_id: str) -> ImageEntity:
"""Get image entity from request."""
if (image_entity := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") in image_entity.access_tokens
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
# Invalid sigAuth or image entity access token
raise web.HTTPForbidden
return image_entity
async def head(self, request: web.Request, entity_id: str) -> web.Response:
@@ -349,7 +345,7 @@ class ImageView(HomeAssistantView):
This is sent by some DLNA renderers, like Samsung ones, prior to sending
the GET request.
"""
image_entity = await self._authenticate_request(request, entity_id)
image_entity = self._get_image_entity(entity_id)
# Don't use `handle` as we don't care about the stream case, we only want
# to verify that the image exists.
@@ -365,7 +361,7 @@ class ImageView(HomeAssistantView):
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
image_entity = await self._authenticate_request(request, entity_id)
image_entity = self._get_image_entity(entity_id)
return await self.handle(request, image_entity)
async def handle(
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["imgw_pib==2.2.0"]
"requirements": ["imgw_pib==2.2.2"]
}
+1 -1
View File
@@ -51,7 +51,7 @@ class LinkPlayBaseEntity(Entity):
)
self._attr_device_info = dr.DeviceInfo(
configuration_url=bridge.endpoint,
configuration_url=str(bridge.endpoint),
connections=connections,
hw_version=bridge.device.properties["hardware"],
identifiers={(DOMAIN, bridge.device.uuid)},
+4 -2
View File
@@ -142,11 +142,13 @@ def get_setpoint_status(status: str, time: str) -> str | None:
return LYRIC_SETPOINT_STATUS_NAMES.get(status)
def get_datetime_from_future_time(time_str: str) -> datetime:
def get_datetime_from_future_time(time_str: str | None) -> datetime | None:
"""Get datetime from future time provided."""
if time_str is None:
return None
time = dt_util.parse_time(time_str)
if time is None:
raise ValueError(f"Unable to parse time {time_str}")
return None
now = dt_util.utcnow()
if time <= now.time():
now = now + timedelta(days=1)
@@ -2,7 +2,7 @@
import asyncio
import collections
from collections.abc import Callable
from collections.abc import Callable, Container, Mapping
from contextlib import suppress
import datetime as dt
from enum import StrEnum
@@ -12,7 +12,7 @@ import hashlib
from http import HTTPStatus
import logging
import secrets
from typing import Any, Final, Required, TypedDict, final
from typing import Any, Final, Required, TypedDict, final, override
from urllib.parse import quote, urlparse
import aiohttp
@@ -24,7 +24,7 @@ import voluptuous as vol
from yarl import URL
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( # noqa: F401
@@ -50,7 +50,7 @@ from homeassistant.const import ( # noqa: F401
STATE_PLAYING,
STATE_STANDBY,
)
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.core import HomeAssistant, SupportsResponse, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import Entity, EntityDescription
@@ -1248,7 +1248,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
class MediaPlayerImageView(HomeAssistantView):
"""Media player view to serve an image."""
requires_auth = False
use_query_token_for_auth = True
url = "/api/media_player_proxy/{entity_id}"
name = "api:media_player:image"
extra_urls = [
@@ -1261,6 +1261,15 @@ class MediaPlayerImageView(HomeAssistantView):
"""Initialize a media player view."""
self.component = component
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (player := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return (player.access_token,)
async def get(
self,
request: web.Request,
@@ -1270,21 +1279,9 @@ class MediaPlayerImageView(HomeAssistantView):
) -> web.Response:
"""Start a get request."""
if (player := self.component.get_entity(entity_id)) is None:
status = (
HTTPStatus.NOT_FOUND
if request[KEY_AUTHENTICATED]
else HTTPStatus.UNAUTHORIZED
)
return web.Response(status=status)
return web.Response(status=HTTPStatus.NOT_FOUND)
assert isinstance(player, MediaPlayerEntity)
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") == player.access_token
)
if not authenticated:
return web.Response(status=HTTPStatus.UNAUTHORIZED)
if media_content_type and media_content_id:
media_image_id = request.query.get("media_image_id")
@@ -14,9 +14,16 @@ from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionE
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DEFAULT_CONNECT_TIMEOUT, DEFAULT_RESPONSE_TIMEOUT, DOMAIN, PLATFORMS
from .const import (
CONF_ADDRESSES,
DEFAULT_CONNECT_TIMEOUT,
DEFAULT_RESPONSE_TIMEOUT,
DOMAIN,
PLATFORMS,
)
from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -25,13 +32,14 @@ _LOGGER = logging.getLogger(__name__)
def _make_device(
info: DeviceInfo,
serial: str,
address: str,
session,
) -> IndoorUnit | KumoStation:
"""Create the appropriate device instance from DeviceInfo."""
cls = IndoorUnit if info.is_indoor_unit else KumoStation
return cls(
name=info.label,
address=info.address,
address=address,
password_b64=info.password,
crypto_serial_hex=info.crypto_serial,
serial=serial,
@@ -64,12 +72,39 @@ async def async_setup_entry(
translation_key="no_devices",
)
# The cloud provides each device's MAC but never its LAN IP. Register every
# device with its MAC so the manifest's "registered_devices" DHCP matcher
# tracks it; DHCP discovery then supplies the IP via async_step_dhcp.
device_registry = dr.async_get(hass)
owned_macs = {dr.format_mac(info.mac) for info in devices.values()}
for serial, info in devices.items():
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, serial)},
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(info.mac))},
manufacturer="Mitsubishi",
name=info.label,
serial_number=serial,
)
# Resolved IPs are stored keyed by MAC. Drop any for devices that are no
# longer on the account.
stored: dict[str, str] = entry.data.get(CONF_ADDRESSES, {})
addresses = {mac: ip for mac, ip in stored.items() if mac in owned_macs}
if addresses != stored:
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_ADDRESSES: addresses}
)
coordinators: dict[str, MitsubishiComfortCoordinator] = {}
for serial, info in devices.items():
if not info.address or not info.password or not info.crypto_serial:
_LOGGER.warning("Device %s missing credentials, skipping", info.label)
address = addresses.get(dr.format_mac(info.mac))
if not address or not info.password or not info.crypto_serial:
# No LAN address yet: the device is registered, so DHCP discovery
# supplies its IP and reloads the entry to add it.
_LOGGER.debug("Device %s has no known LAN address yet", info.label)
continue
device = _make_device(info, serial, session)
device = _make_device(info, serial, address, session)
coordinators[serial] = MitsubishiComfortCoordinator(
hass, entry, device, info.mac
)
@@ -9,9 +9,11 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DOMAIN
from .const import CONF_ADDRESSES, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -71,3 +73,41 @@ class MitsubishiComfortConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=USER_SCHEMA, errors=errors
)
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle a registered device discovered on the local network via DHCP.
The cloud API never returns a device's LAN IP, so DHCP discovery is the
source of addresses. Each device is registered with its MAC during setup,
so "registered_devices" discovery only fires for our own devices: record
the IP on the owning entry and reload to set the device up or recover a
changed IP.
"""
mac = dr.format_mac(discovery_info.macaddress)
device = dr.async_get(self.hass).async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, mac)}
)
entry = next(
(
entry
for entry in self._async_current_entries(include_ignore=False)
if device is not None and entry.entry_id in device.config_entries
),
None,
)
if entry is None:
return self.async_abort(reason="already_configured")
addresses = entry.data.get(CONF_ADDRESSES, {})
if addresses.get(mac) != discovery_info.ip:
self.hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_ADDRESSES: {**addresses, mac: discovery_info.ip},
},
)
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_abort(reason="already_configured")
@@ -7,6 +7,13 @@ from homeassistant.const import Platform
DOMAIN: Final = "mitsubishi_comfort"
PLATFORMS: Final = [Platform.CLIMATE]
# Config entry data key holding the per-device LAN address cache, keyed by the
# device's formatted MAC. The cloud API only returns each device's MAC, never
# its LAN IP, so addresses are resolved from DHCP discovery and persisted here
# to survive restarts without re-discovery.
CONF_ADDRESSES: Final = "addresses"
DEFAULT_SCAN_INTERVAL = timedelta(seconds=60)
DEFAULT_CONNECT_TIMEOUT: Final = 1.2
DEFAULT_RESPONSE_TIMEOUT: Final = 8.0
@@ -3,9 +3,10 @@
"name": "Mitsubishi Comfort",
"codeowners": ["@nikolairahimi"],
"config_flow": true,
"dhcp": [{ "registered_devices": true }],
"documentation": "https://www.home-assistant.io/integrations/mitsubishi_comfort",
"integration_type": "hub",
"iot_class": "cloud_polling",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["mitsubishi-comfort==0.3.0"]
}
@@ -56,7 +56,7 @@ rules:
icon-translations: todo
reconfiguration-flow: todo
dynamic-devices: todo
discovery-update-info: todo
discovery-update-info: done
repair-issues: todo
docs-use-cases: done
docs-supported-devices: done
@@ -504,6 +504,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Can be removed with HA Core 2027.1
new_entry_data = entry.data.copy()
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
# Create temporary certificate files from entry
await async_create_certificate_temp_files(hass, new_entry_data)
# Try the connection with protocol version 5
# And update the protocol if successful
if await hass.async_add_executor_job(
+2 -2
View File
@@ -2451,7 +2451,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
),
CONF_VALUE_TEMPLATE: PlatformField(
CONF_STATE_VALUE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
@@ -3395,7 +3395,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
),
CONF_VALUE_TEMPLATE: PlatformField(
CONF_STATE_VALUE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
+1 -1
View File
@@ -565,7 +565,7 @@ class Person(
self._latitude = coordinates.attributes.get(ATTR_LATITUDE)
self._longitude = coordinates.attributes.get(ATTR_LONGITUDE)
self._gps_accuracy = coordinates.attributes.get(ATTR_GPS_ACCURACY)
self._in_zones = coordinates.attributes.get(ATTR_IN_ZONES, [])
self._in_zones = state.attributes.get(ATTR_IN_ZONES, [])
@callback
def _update_extra_state_attributes(self) -> None:
@@ -63,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) ->
api_url=entry.data[CONF_URL],
api_key=entry.data[CONF_API_TOKEN],
session=session,
request_timeout=60,
request_timeout=120,
max_retries=API_MAX_RETRIES,
)
@@ -41,6 +41,7 @@ async def async_get_config_entry_diagnostics(
"HTTP(S) port": api.port,
"Baichuan port": api.baichuan.port,
"Baichuan only": api.baichuan_only,
"Baichuan connection": api.baichuan.connection_type.value,
"WiFi connection": api.wifi_connection(),
"WiFi signal": api.wifi_signal(),
"RTMP enabled": api.rtmp_enabled,
@@ -48,10 +49,15 @@ async def async_get_config_entry_diagnostics(
"ONVIF enabled": api.onvif_enabled,
"event connection": host.event_connection,
"stream protocol": api.protocol,
"is NVR": api.is_nvr,
"is Hub": api.is_hub,
"is Battery": api.is_battery,
"channels": api.channels,
"stream channels": api.stream_channels,
"IPC cams": ipc_cam,
"Chimes": chimes,
"Broken cmds": api.broken_cmds,
"Baichuan fallbacks": api.baichuan_cmds,
"capabilities": api.capabilities,
"cmd list": host.update_cmd,
"firmware ch list": host.firmware_ch_list,
+2 -2
View File
@@ -16,7 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, PRESSURE, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, IS_IN_BED, SLEEP_NUMBER
@@ -69,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SleepIQConfigEntry) -> b
email = conf[CONF_USERNAME]
password = conf[CONF_PASSWORD]
client_session = async_get_clientsession(hass)
client_session = async_create_clientsession(hass)
gateway = AsyncSleepIQ(client_session=client_session)
@@ -47,11 +47,16 @@ class SleepIQDataUpdateCoordinator(DataUpdateCoordinator[None]):
bed.foundation.update_foundation_status()
for bed in self.client.beds.values()
]
await asyncio.gather(*tasks)
try:
await asyncio.gather(*tasks)
except SleepIQTimeoutException as err:
raise UpdateFailed(f"Timed out fetching SleepIQ data: {err}") from err
except SleepIQAPIException as err:
raise UpdateFailed(f"Failed to fetch SleepIQ data: {err}") from err
class SleepIQPauseUpdateCoordinator(DataUpdateCoordinator[None]):
"""SleepIQ data update coordinator."""
"""SleepIQ pause update coordinator."""
config_entry: SleepIQConfigEntry
@@ -72,9 +77,14 @@ class SleepIQPauseUpdateCoordinator(DataUpdateCoordinator[None]):
self.client = client
async def _async_update_data(self) -> None:
await asyncio.gather(
*[bed.fetch_pause_mode() for bed in self.client.beds.values()]
)
try:
await asyncio.gather(
*[bed.fetch_pause_mode() for bed in self.client.beds.values()]
)
except SleepIQTimeoutException as err:
raise UpdateFailed(f"Timed out fetching SleepIQ pause data: {err}") from err
except SleepIQAPIException as err:
raise UpdateFailed(f"Failed to fetch SleepIQ pause data: {err}") from err
class SleepIQSleepDataCoordinator(DataUpdateCoordinator[None]):
@@ -38,5 +38,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==4.0.0"]
"requirements": ["pysmartthings==4.0.1"]
}
@@ -504,9 +504,15 @@ def _create_handle_webhook(
_LOGGER.debug("Received data from switchbot webhook: %s", repr(data))
device_mac = data["context"]["deviceMac"]
if device_mac not in coordinators_by_id:
_LOGGER.error(
"Received data for unknown entity from switchbot webhook: %s", data
registered_device_macs = [
coordinator.data.get("deviceMac") or coordinator.data.get("deviceId")
for coordinator in coordinators_by_id.values()
if coordinator.manageable_by_webhook() and coordinator.data is not None
]
if device_mac not in registered_device_macs:
_LOGGER.debug(
"Received data for an unregistered webhook entity from SwitchBot Webhook: %s",
data,
)
return
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
"requirements": ["holidays==0.97"]
"requirements": ["holidays==0.98"]
}
+1 -1
View File
@@ -10,5 +10,5 @@
"iot_class": "cloud_push",
"loggers": ["yoto_api"],
"quality_scale": "bronze",
"requirements": ["yoto-api==3.1.5"]
"requirements": ["yoto-api==3.1.6"]
}
+1 -1
View File
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 6
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, 14, 2)
+4
View File
@@ -443,6 +443,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "lyric-*",
"macaddress": "00D02D*",
},
{
"domain": "mitsubishi_comfort",
"registered_devices": True,
},
{
"domain": "motion_blinds",
"registered_devices": True,
+1 -1
View File
@@ -4351,7 +4351,7 @@
"mitsubishi_comfort": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling",
"iot_class": "local_polling",
"name": "Mitsubishi Comfort"
}
}
+15 -3
View File
@@ -1,6 +1,6 @@
"""Helper to track the current http request."""
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Container, Mapping
from contextvars import ContextVar
from http import HTTPStatus
import inspect
@@ -20,7 +20,7 @@ import voluptuous as vol
from homeassistant import exceptions
from homeassistant.const import CONTENT_TYPE_JSON
from homeassistant.core import Context, HomeAssistant, is_callback
from homeassistant.core import Context, HomeAssistant, callback, is_callback
from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS, format_unserializable_data
from .json import find_paths_unserializable_data, json_bytes, json_dumps
@@ -55,7 +55,13 @@ def request_handler_factory(
authenticated = request.get(KEY_AUTHENTICATED, False)
if view.requires_auth and not authenticated:
if view.use_query_token_for_auth and not authenticated:
token = request.query.get("token")
if token and token in view.get_valid_auth_tokens(request.match_info):
_LOGGER.debug("Authenticated request with query token")
authenticated = True
if (view.requires_auth or view.use_query_token_for_auth) and not authenticated:
# Import here to avoid circular dependency with network.py
from .network import NoURLAvailableError, get_url # noqa: PLC0415
@@ -129,6 +135,7 @@ class HomeAssistantView:
extra_urls: list[str] = []
# Views inheriting from this class can override this
requires_auth = True
use_query_token_for_auth = False
cors_allowed = False
@staticmethod
@@ -204,3 +211,8 @@ class HomeAssistantView:
if allow_cors:
for route in routes:
allow_cors(route)
@callback
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
return ()
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2026.6.0"
version = "2026.6.1"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
+9 -9
View File
@@ -133,7 +133,7 @@ WSDiscovery==2.1.2
accuweather==5.1.0
# homeassistant.components.actron_air
actron-neo-api==0.5.6
actron-neo-api==0.5.12
# homeassistant.components.adax
adax==0.4.0
@@ -212,7 +212,7 @@ aioaseko==1.0.0
aioasuswrt==1.5.4
# homeassistant.components.husqvarna_automower
aioautomower==2.7.5
aioautomower==2.7.6
# homeassistant.components.azure_devops
aioazuredevops==2.2.2
@@ -420,7 +420,7 @@ aiosolaredge==1.0.2
aiosteamist==1.0.1
# homeassistant.components.cambridge_audio
aiostreammagic==2.13.1
aiostreammagic==2.13.2
# homeassistant.components.switcher_kis
aioswitcher==6.1.1
@@ -1263,7 +1263,7 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.97
holidays==0.98
# homeassistant.components.frontend
home-assistant-frontend==20260527.4
@@ -1275,7 +1275,7 @@ home-assistant-intents==2026.6.1
homekit-audio-proxy==1.2.1
# homeassistant.components.gentex_homelink
homelink-integration-api==0.0.1
homelink-integration-api==0.0.5
# homeassistant.components.homematicip_cloud
homematicip==2.12.0
@@ -1323,7 +1323,7 @@ icalendar==6.3.1
icmplib==3.0.4
# homeassistant.components.idasen_desk
idasen-ha==2.6.5
idasen-ha==2.7.0
# homeassistant.components.idrive_e2
idrive-e2-client==0.1.1
@@ -1344,7 +1344,7 @@ ihcsdk==2.8.12
imeon_inverter_api==0.4.0
# homeassistant.components.imgw_pib
imgw_pib==2.2.0
imgw_pib==2.2.2
# homeassistant.components.incomfort
incomfort-client==0.7.0
@@ -2540,7 +2540,7 @@ pysmappee==0.2.29
pysmarlaapi==1.0.2
# homeassistant.components.smartthings
pysmartthings==4.0.0
pysmartthings==4.0.1
# homeassistant.components.smarty
pysmarty2==0.10.3
@@ -3415,7 +3415,7 @@ yeelightsunflower==0.0.10
yolink-api==0.6.5
# homeassistant.components.yoto
yoto-api==3.1.5
yoto-api==3.1.6
# homeassistant.components.youless
youless-api==2.2.0
-1
View File
@@ -27,7 +27,6 @@ MISSING_INTEGRATION_TYPE = {
"folder_watcher",
"forked_daapd",
"geniushub",
"gentex_homelink",
"geofency",
"govee_light_local",
"gpsd",
+10 -4
View File
@@ -2666,21 +2666,26 @@ async def test_process_advertisements_timeout(
@pytest.mark.usefixtures("enable_bluetooth", "mock_bleak_scanner_start")
async def test_process_advertisements_wires_timeout_as_scan_duration(
async def test_process_advertisements_triggers_active_scan_of_correct_duration(
hass: HomeAssistant,
) -> None:
"""async_process_advertisements forwards its timeout as scan_duration."""
"""async_process_advertisements triggers active scan now."""
def _callback(service_info: BluetoothServiceInfo) -> bool:
return False
timeout = 0.001
mock_cancel = Mock()
with (
patch.object(
HomeAssistantBluetoothManager,
"async_register_active_scan",
return_value=mock_cancel,
) as mock_register,
patch.object(
HomeAssistantBluetoothManager, "async_request_active_scan"
) as mock_request_active_scan,
pytest.raises(TimeoutError),
):
await async_process_advertisements(
@@ -2688,9 +2693,10 @@ async def test_process_advertisements_wires_timeout_as_scan_duration(
_callback,
{"address": "aa:44:33:11:23:45"},
BluetoothScanningMode.ACTIVE,
0,
timeout,
)
mock_register.assert_called_once_with("aa:44:33:11:23:45", None, 0)
mock_register.assert_called_once_with("aa:44:33:11:23:45", None, None)
mock_request_active_scan.assert_called_once_with(timeout)
mock_cancel.assert_called_once()
+7 -7
View File
@@ -809,30 +809,30 @@ async def test_token_query_param_authentication(
assert await resp.read() == FAKE_PNG
async def test_unauthenticated_request_forbidden(
async def test_unauthenticated_request_unauthorized(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that unauthenticated requests are forbidden."""
"""Test that unauthenticated requests are unauthorized."""
client = await hass_client_no_auth()
resp = await client.get("/api/brands/integration/hue/icon.png")
assert resp.status == HTTPStatus.FORBIDDEN
assert resp.status == HTTPStatus.UNAUTHORIZED
resp = await client.get("/api/brands/hardware/boards/green.png")
assert resp.status == HTTPStatus.FORBIDDEN
assert resp.status == HTTPStatus.UNAUTHORIZED
async def test_invalid_token_forbidden(
async def test_invalid_token_unauthorized(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test that an invalid access token in query param is forbidden."""
"""Test that an invalid access token in query param is unauthorized."""
client = await hass_client_no_auth()
resp = await client.get("/api/brands/integration/hue/icon.png?token=invalid_token")
assert resp.status == HTTPStatus.FORBIDDEN
assert resp.status == HTTPStatus.UNAUTHORIZED
async def test_invalid_bearer_token_unauthorized(
+24
View File
@@ -693,6 +693,30 @@ async def test_camera_proxy_stream(hass_client: ClientSessionGenerator) -> None:
assert response.status == HTTPStatus.BAD_GATEWAY
@pytest.mark.usefixtures("mock_camera")
async def test_camera_proxy_query_token_auth(
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator
) -> None:
"""Test the camera proxy authenticates via the access token query param."""
client = await hass_client_no_auth()
state = hass.states.get("camera.demo_camera")
assert state is not None
# A valid access token in the query param authenticates the request
resp = await client.get(state.attributes["entity_picture"])
assert resp.status == HTTPStatus.OK
assert await resp.read() == b"Test"
# Without a token the request is unauthorized
resp = await client.get("/api/camera_proxy/camera.demo_camera")
assert resp.status == HTTPStatus.UNAUTHORIZED
# An invalid token is also unauthorized
resp = await client.get("/api/camera_proxy/camera.demo_camera?token=invalid")
assert resp.status == HTTPStatus.UNAUTHORIZED
@pytest.mark.usefixtures("mock_camera")
async def test_state_streaming(hass: HomeAssistant) -> None:
"""Camera state."""
@@ -435,7 +435,7 @@
'state': '90',
})
# ---
# name: test_sensor_entities_state[sensor.living_mode_end_time-entry]
# name: test_sensor_entities_state[sensor.living_state_end_time-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -449,7 +449,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.living_mode_end_time',
'entity_id': 'sensor.living_state_end_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -457,12 +457,12 @@
'labels': set({
}),
'name': None,
'object_id_base': 'Mode end time',
'object_id_base': 'State end time',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Mode end time',
'original_name': 'State end time',
'platform': 'duco',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -472,14 +472,14 @@
'unit_of_measurement': None,
})
# ---
# name: test_sensor_entities_state[sensor.living_mode_end_time-state]
# name: test_sensor_entities_state[sensor.living_state_end_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Living Mode end time',
'friendly_name': 'Living State end time',
}),
'context': <ANY>,
'entity_id': 'sensor.living_mode_end_time',
'entity_id': 'sensor.living_state_end_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
+16 -3
View File
@@ -404,14 +404,27 @@ async def test_failed_login_attempts_counter(
app.router.add_get(
"/auth_true",
request_handler_factory(hass, Mock(requires_auth=True), auth_true_handler),
request_handler_factory(
hass,
Mock(requires_auth=True, use_query_token_for_auth=False),
auth_true_handler,
),
)
app.router.add_get(
"/auth_false",
request_handler_factory(hass, Mock(requires_auth=True), auth_handler),
request_handler_factory(
hass,
Mock(requires_auth=True, use_query_token_for_auth=False),
auth_handler,
),
)
app.router.add_get(
"/", request_handler_factory(hass, Mock(requires_auth=False), auth_handler)
"/",
request_handler_factory(
hass,
Mock(requires_auth=False, use_query_token_for_auth=False),
auth_handler,
),
)
setup_bans(hass, app, 5)
+61 -8
View File
@@ -61,7 +61,7 @@ async def test_handling_unauthorized(mock_request: Mock) -> None:
with pytest.raises(HTTPUnauthorized):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=False),
Mock(requires_auth=False, use_query_token_for_auth=False),
AsyncMock(side_effect=Unauthorized),
)(mock_request)
@@ -71,7 +71,7 @@ async def test_handling_invalid_data(mock_request: Mock) -> None:
with pytest.raises(HTTPBadRequest):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=False),
Mock(requires_auth=False, use_query_token_for_auth=False),
AsyncMock(side_effect=vol.Invalid("yo")),
)(mock_request)
@@ -81,7 +81,7 @@ async def test_handling_service_not_found(mock_request: Mock) -> None:
with pytest.raises(HTTPInternalServerError):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=False),
Mock(requires_auth=False, use_query_token_for_auth=False),
AsyncMock(side_effect=ServiceNotFound("test", "test")),
)(mock_request)
@@ -90,7 +90,7 @@ async def test_not_running(mock_request_with_stopping: Mock) -> None:
"""Test we get a 503 when not running."""
response = await request_handler_factory(
mock_request_with_stopping.app[KEY_HASS],
Mock(requires_auth=False),
Mock(requires_auth=False, use_query_token_for_auth=False),
AsyncMock(side_effect=Unauthorized),
)(mock_request_with_stopping)
assert response.status == HTTPStatus.SERVICE_UNAVAILABLE
@@ -101,11 +101,64 @@ async def test_invalid_handler(mock_request: Mock) -> None:
with pytest.raises(TypeError):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=False),
Mock(requires_auth=False, use_query_token_for_auth=False),
AsyncMock(return_value=["not valid"]),
)(mock_request)
async def test_query_token_auth_valid(mock_request: Mock) -> None:
"""Test authentication with a valid query token."""
mock_request.get = Mock(return_value=False)
mock_request.query = {"token": "valid-token"}
handler = AsyncMock(return_value=None)
response = await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(
requires_auth=False,
use_query_token_for_auth=True,
get_valid_auth_tokens=Mock(return_value={"valid-token"}),
),
handler,
)(mock_request)
assert response.status == HTTPStatus.OK
handler.assert_awaited_once()
@pytest.mark.parametrize(
"query",
[{"token": "wrong-token"}, {}],
ids=["invalid_token", "missing_token"],
)
async def test_query_token_auth_unauthorized(
mock_request: Mock, query: dict[str, str]
) -> None:
"""Test an invalid or missing query token is rejected."""
mock_request.get = Mock(return_value=False)
mock_request.query = query
handler = AsyncMock()
with (
patch(
"homeassistant.helpers.network.get_url",
return_value="https://example.com",
),
pytest.raises(HTTPUnauthorized),
):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(
requires_auth=False,
use_query_token_for_auth=True,
get_valid_auth_tokens=Mock(return_value={"valid-token"}),
),
handler,
)(mock_request)
handler.assert_not_awaited()
async def test_requires_auth_includes_www_authenticate(
mock_request: Mock,
) -> None:
@@ -120,7 +173,7 @@ async def test_requires_auth_includes_www_authenticate(
):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=True),
Mock(requires_auth=True, use_query_token_for_auth=False),
AsyncMock(),
)(mock_request)
assert exc_info.value.headers["WWW-Authenticate"] == (
@@ -143,7 +196,7 @@ async def test_requires_auth_omits_www_authenticate_without_url(
):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=True),
Mock(requires_auth=True, use_query_token_for_auth=False),
AsyncMock(),
)(mock_request)
assert "WWW-Authenticate" not in exc_info.value.headers
@@ -212,7 +265,7 @@ async def test_requires_auth_www_authenticate_prefer_external(
with pytest.raises(HTTPUnauthorized) as exc_info:
await request_handler_factory(
hass,
Mock(requires_auth=True),
Mock(requires_auth=True, use_query_token_for_auth=False),
AsyncMock(),
)(mock_current_request)
+9 -3
View File
@@ -234,24 +234,30 @@ async def test_fetch_image_unauthenticated(
client = await hass_client_no_auth()
resp = await client.get("/api/image_proxy/image.test")
assert resp.status == HTTPStatus.FORBIDDEN
assert resp.status == HTTPStatus.UNAUTHORIZED
resp = await client.get("/api/image_proxy/image.test")
assert resp.status == HTTPStatus.FORBIDDEN
assert resp.status == HTTPStatus.UNAUTHORIZED
resp = await client.get(
"/api/image_proxy/image.test", headers={hdrs.AUTHORIZATION: "blabla"}
)
assert resp.status == HTTPStatus.UNAUTHORIZED
# An invalid token is also unauthorized
resp = await client.get("/api/image_proxy/image.test?token=invalid")
assert resp.status == HTTPStatus.UNAUTHORIZED
state = hass.states.get("image.test")
resp = await client.get(state.attributes["entity_picture"])
assert resp.status == HTTPStatus.OK
body = await resp.read()
assert body == b"Test"
# Unknown entities are also unauthorized for an unauthenticated client, so
# their existence is not leaked
resp = await client.get("/api/image_proxy/image.unknown")
assert resp.status == HTTPStatus.NOT_FOUND
assert resp.status == HTTPStatus.UNAUTHORIZED
@respx.mock
+21
View File
@@ -0,0 +1,21 @@
"""Tests for the Honeywell Lyric sensor platform."""
from datetime import datetime
from homeassistant.components.lyric.sensor import get_datetime_from_future_time
def test_get_datetime_from_future_time_none() -> None:
"""Test that None input returns None instead of raising."""
assert get_datetime_from_future_time(None) is None
def test_get_datetime_from_future_time_invalid() -> None:
"""Test that an unparsable time string returns None."""
assert get_datetime_from_future_time("not_a_time") is None
def test_get_datetime_from_future_time_valid() -> None:
"""Test that a valid time string returns a datetime."""
result = get_datetime_from_future_time("13:30:00")
assert isinstance(result, datetime)
@@ -113,6 +113,28 @@ async def test_get_image_http(
assert content == b"image"
async def test_get_image_http_unauthenticated(
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator
) -> None:
"""Test get image via http command without a valid token is unauthorized."""
await async_setup_component(
hass, "media_player", {"media_player": {"platform": "demo"}}
)
await hass.async_block_till_done()
client = await hass_client_no_auth()
# Without a token the request is unauthorized
resp = await client.get("/api/media_player_proxy/media_player.bedroom")
assert resp.status == HTTPStatus.UNAUTHORIZED
# An invalid token is also unauthorized
resp = await client.get(
"/api/media_player_proxy/media_player.bedroom?token=invalid"
)
assert resp.status == HTTPStatus.UNAUTHORIZED
async def test_get_image_http_remote(
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator
) -> None:
@@ -14,13 +14,17 @@ from mitsubishi_comfort import (
)
import pytest
from homeassistant.components.mitsubishi_comfort.const import DOMAIN
from homeassistant.components.mitsubishi_comfort.const import CONF_ADDRESSES, DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.device_registry import format_mac
from tests.common import MockConfigEntry
MOCK_USERNAME = "test@test.com"
MOCK_PASSWORD = "testpass"
MOCK_SERIAL = "SERIAL001"
MOCK_MAC = "AA:BB:CC:DD:EE:FF"
MOCK_ADDRESS = "192.168.1.100"
def _make_device_status(
@@ -63,12 +67,13 @@ def _make_device_status(
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a mock config entry."""
"""Return a mock config entry with a previously resolved device address."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_USERNAME: MOCK_USERNAME,
CONF_PASSWORD: MOCK_PASSWORD,
CONF_ADDRESSES: {format_mac(MOCK_MAC): MOCK_ADDRESS},
},
unique_id="user-12345",
)
@@ -76,12 +81,12 @@ def mock_config_entry() -> MockConfigEntry:
@pytest.fixture
def mock_device_info() -> DeviceInfo:
"""Return a mock DeviceInfo."""
"""Return a mock DeviceInfo as returned by the cloud (no LAN address)."""
return DeviceInfo(
serial="SERIAL001",
serial=MOCK_SERIAL,
label="Living Room",
address="192.168.1.100",
mac="AA:BB:CC:DD:EE:FF",
address="",
mac=MOCK_MAC,
unit_type="ductless",
password="dGVzdHBhc3M=",
crypto_serial="0102030405060708090a",
@@ -7,10 +7,14 @@ from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionE
import pytest
from homeassistant import config_entries
from homeassistant.components.mitsubishi_comfort.const import DOMAIN
from homeassistant.components.mitsubishi_comfort.const import CONF_ADDRESSES, DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .conftest import MOCK_MAC, MOCK_SERIAL
from tests.common import MockConfigEntry
@@ -112,3 +116,107 @@ async def test_user_step_already_configured(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
def _dhcp_info(ip: str, mac: str = MOCK_MAC) -> DhcpServiceInfo:
"""Build DHCP discovery info (DHCP reports MACs without separators)."""
return DhcpServiceInfo(
ip=ip, hostname="kumo", macaddress=mac.replace(":", "").lower()
)
def _register_device(
device_registry: dr.DeviceRegistry, entry: MockConfigEntry, mac: str = MOCK_MAC
) -> None:
"""Register a device with a MAC connection, as setup does."""
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, MOCK_SERIAL)},
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac))},
)
async def test_dhcp_updates_address_and_reloads(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test DHCP discovery records a new IP for a registered device and reloads."""
mock_config_entry.add_to_hass(hass)
_register_device(device_registry, mock_config_entry)
with patch(
"homeassistant.config_entries.ConfigEntries.async_schedule_reload"
) as mock_reload:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=_dhcp_info("192.168.1.250"),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_config_entry.data[CONF_ADDRESSES][dr.format_mac(MOCK_MAC)] == (
"192.168.1.250"
)
mock_reload.assert_called_once_with(mock_config_entry.entry_id)
async def test_dhcp_same_address_does_not_reload(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test DHCP discovery of an unchanged IP does not trigger a reload."""
mock_config_entry.add_to_hass(hass)
_register_device(device_registry, mock_config_entry)
with patch(
"homeassistant.config_entries.ConfigEntries.async_schedule_reload"
) as mock_reload:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=_dhcp_info("192.168.1.100"),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
mock_reload.assert_not_called()
async def test_dhcp_unregistered_device_ignored(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test DHCP discovery of a MAC with no registered device changes nothing."""
mock_config_entry.add_to_hass(hass)
_register_device(device_registry, mock_config_entry)
original = dict(mock_config_entry.data[CONF_ADDRESSES])
with patch(
"homeassistant.config_entries.ConfigEntries.async_schedule_reload"
) as mock_reload:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=_dhcp_info("192.168.1.251", mac="99:99:99:99:99:99"),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_config_entry.data[CONF_ADDRESSES] == original
mock_reload.assert_not_called()
async def test_dhcp_no_account_aborts(hass: HomeAssistant) -> None:
"""Test DHCP discovery with no configured account aborts without a flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=_dhcp_info("192.168.1.252"),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@@ -6,10 +6,13 @@ from mitsubishi_comfort import DeviceInfo
from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionError
import pytest
from homeassistant.components.mitsubishi_comfort.const import DOMAIN
from homeassistant.components.mitsubishi_comfort.const import CONF_ADDRESSES, DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .conftest import MOCK_ADDRESS, MOCK_MAC, MOCK_PASSWORD, MOCK_USERNAME
from tests.common import MockConfigEntry
@@ -69,24 +72,57 @@ async def test_setup_entry_no_devices_raises(
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
async def test_setup_entry_incomplete_credentials_loads_empty(
async def test_setup_entry_no_address_loads_and_registers(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
mock_cloud_account: AsyncMock,
) -> None:
"""Test setup with no known LAN address loads and registers the device.
The cloud returns each device's credentials but never its LAN IP. Without a
resolved address the device cannot be polled, so it creates no entity but
it is registered with its MAC so "registered_devices" DHCP discovery can
supply the IP and reload the entry. Setup must not retry (which would hammer
the cloud API) since retrying can never resolve the address.
"""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD},
unique_id="user-12345",
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
assert not er.async_entries_for_config_entry(entity_registry, entry.entry_id)
assert device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(MOCK_MAC))}
)
async def test_setup_entry_resolves_address_from_entry(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_device_info: DeviceInfo,
mock_cloud_account: AsyncMock,
mock_setup_integration: tuple[AsyncMock, MagicMock],
) -> None:
"""Test setup loads with no entities when devices have incomplete credentials."""
"""Test the LAN address is injected from the entry's persisted cache.
The cloud-discovered device carries no address; the entity is only created
because the entry holds a previously resolved address for the device's MAC.
"""
mock_config_entry.add_to_hass(hass)
mock_device_info.password = ""
mock_device_info.address = ""
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert not er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
assert entity_registry.async_get_entity_id("climate", DOMAIN, "SERIAL001")
assert mock_config_entry.data[CONF_ADDRESSES][dr.format_mac(MOCK_MAC)] == (
MOCK_ADDRESS
)
+6 -4
View File
@@ -354,7 +354,7 @@ MOCK_SUBENTRY_FAN_COMPONENT = {
"entity_category": None,
"state_topic": "test-topic",
"command_template": "{{ value }}",
"value_template": "{{ value_json.value }}",
"state_value_template": "{{ value_json.value }}",
"percentage_command_topic": "test-topic/pct",
"percentage_state_topic": "test-topic/pct",
"percentage_command_template": "{{ value }}",
@@ -637,7 +637,7 @@ MOCK_SUBENTRY_SIREN_COMPONENT = {
"state_topic": "test-topic",
"command_template": "{{ value }}",
"command_off_template": "{{ value }}",
"value_template": "{{ value_json.value }}",
"state_value_template": "{{ value_json.value }}",
"payload_off": "OFF",
"payload_on": "ON",
"available_tones": ["Happy hour", "Cooling alarm"],
@@ -957,11 +957,13 @@ MOCK_SUBENTRY_DATA_SET_MIX = {
},
}
},
"components": MOCK_SUBENTRY_NOTIFY_COMPONENT1
"components": MOCK_SUBENTRY_FAN_COMPONENT
| MOCK_SUBENTRY_NOTIFY_COMPONENT1
| MOCK_SUBENTRY_NOTIFY_COMPONENT2
| MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT
| MOCK_SUBENTRY_SWITCH_COMPONENT
| MOCK_SUBENTRY_SENSOR_COMPONENT_UOM_NULL,
| MOCK_SUBENTRY_SENSOR_COMPONENT_UOM_NULL
| MOCK_SUBENTRY_SIREN_COMPONENT,
} | MOCK_SUBENTRY_AVAILABILITY_DATA
_SENTINEL = object()
+2 -2
View File
@@ -3242,7 +3242,7 @@ async def test_migrate_of_incompatible_config_entry(
"command_topic": "test-topic",
"command_template": "{{ value }}",
"state_topic": "test-topic",
"value_template": "{{ value_json.value }}",
"state_value_template": "{{ value_json.value }}",
"fan_speed_settings": {
"percentage_command_template": "{{ value }}",
"percentage_command_topic": "test-topic/pct",
@@ -3801,7 +3801,7 @@ async def test_migrate_of_incompatible_config_entry(
"command_topic": "test-topic",
"command_template": "{{ value }}",
"state_topic": "test-topic",
"value_template": "{{ value_json.value }}",
"state_value_template": "{{ value_json.value }}",
"optimistic": True,
"available_tones": ["Happy hour", "Cooling alarm"],
"support_duration": True,
+1 -1
View File
@@ -676,7 +676,7 @@ async def test_loading_subentries(
entity_id = f"{platform}.{slugify(device.name)}_{slugify(component['name'])}"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "unknown"
assert state.state in ("off", "unknown")
@pytest.mark.parametrize(
+402 -100
View File
@@ -1,8 +1,10 @@
"""The tests for the person component."""
from datetime import timedelta
from typing import Any
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components import person
@@ -46,11 +48,13 @@ async def test_minimal_setup(hass: HomeAssistant) -> None:
state = hass.states.get("person.test_person")
assert state.state == STATE_UNKNOWN
assert state.attributes.get(ATTR_LATITUDE) is None
assert state.attributes.get(ATTR_LONGITUDE) is None
assert state.attributes.get(ATTR_SOURCE) is None
assert state.attributes.get(ATTR_USER_ID) is None
assert state.attributes.get(ATTR_ENTITY_PICTURE) is None
assert state.attributes == {
ATTR_DEVICE_TRACKERS: [],
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "test person",
ATTR_ID: "1234",
ATTR_IN_ZONES: [],
}
async def test_setup_no_id(hass: HomeAssistant) -> None:
@@ -73,11 +77,14 @@ async def test_setup_user_id(hass: HomeAssistant, hass_admin_user: MockUser) ->
state = hass.states.get("person.test_person")
assert state.state == STATE_UNKNOWN
assert state.attributes.get(ATTR_ID) == "1234"
assert state.attributes.get(ATTR_LATITUDE) is None
assert state.attributes.get(ATTR_LONGITUDE) is None
assert state.attributes.get(ATTR_SOURCE) is None
assert state.attributes.get(ATTR_USER_ID) == user_id
assert state.attributes == {
ATTR_DEVICE_TRACKERS: [],
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "test person",
ATTR_ID: "1234",
ATTR_IN_ZONES: [],
ATTR_USER_ID: user_id,
}
async def test_valid_invalid_user_ids(
@@ -95,11 +102,14 @@ async def test_valid_invalid_user_ids(
state = hass.states.get("person.test_valid_user")
assert state.state == STATE_UNKNOWN
assert state.attributes.get(ATTR_ID) == "1234"
assert state.attributes.get(ATTR_LATITUDE) is None
assert state.attributes.get(ATTR_LONGITUDE) is None
assert state.attributes.get(ATTR_SOURCE) is None
assert state.attributes.get(ATTR_USER_ID) == user_id
assert state.attributes == {
ATTR_DEVICE_TRACKERS: [],
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "test valid user",
ATTR_ID: "1234",
ATTR_IN_ZONES: [],
ATTR_USER_ID: user_id,
}
state = hass.states.get("person.test_bad_user")
assert state is None
@@ -211,52 +221,46 @@ async def test_setup_two_trackers(
}
assert await async_setup_component(hass, DOMAIN, config)
expected_attributes = {
ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER, DEVICE_TRACKER_2],
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "tracked person",
ATTR_ID: "1234",
ATTR_IN_ZONES: [],
ATTR_USER_ID: user_id,
}
state = hass.states.get("person.tracked_person")
assert state.state == STATE_UNKNOWN
assert state.attributes.get(ATTR_ID) == "1234"
assert state.attributes.get(ATTR_LATITUDE) is None
assert state.attributes.get(ATTR_LONGITUDE) is None
assert state.attributes.get(ATTR_SOURCE) is None
assert state.attributes.get(ATTR_USER_ID) == user_id
assert state.attributes == expected_attributes
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done()
# Router tracker at home with gps_accuracy — the person entity should get
# coordinates from the home zone (which has no gps_accuracy),not from the
# router tracker's attributes.
# Note: This is not a realistic test case, a router tracker would not have
# gps_accuracy, but we want to assert that the person entity uses latitude
# longitude and accuracy from the home zone, not from the state attributes
# of the device tracker.
# Router tracker at home — person gets coordinates from the home zone,
# not from the router tracker. The router tracker has gps_accuracy=99
# and in_zones=["zone.fake"] to verify these are NOT propagated.
# Router tracker at home — the person entity gets latitude, longitude and
# accuracy from the home zone (the coordinates source), not from the router
# tracker's own attributes. `in_zones`, however, is propagated from the
# source tracker.
# Note: a router tracker would not really have gps_accuracy; it is set here
# only to assert it is NOT propagated.
hass.states.async_set(
DEVICE_TRACKER,
"home",
{
ATTR_SOURCE_TYPE: SourceType.ROUTER,
ATTR_GPS_ACCURACY: 99,
ATTR_IN_ZONES: ["zone.fake"],
ATTR_IN_ZONES: ["zone.home"],
},
)
await hass.async_block_till_done()
state = hass.states.get("person.tracked_person")
assert state.state == "home"
assert state.attributes.get(ATTR_ID) == "1234"
assert state.attributes.get(ATTR_LATITUDE) == 32.87336
assert state.attributes.get(ATTR_LONGITUDE) == -117.22743
# GPS accuracy and in_zones come from the coordinates source (home zone),
# not from the state source (router tracker).
assert state.attributes.get(ATTR_GPS_ACCURACY) is None
assert state.attributes.get(ATTR_IN_ZONES) == []
assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER
assert state.attributes.get(ATTR_USER_ID) == user_id
assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [
DEVICE_TRACKER,
DEVICE_TRACKER_2,
]
assert state.attributes == expected_attributes | {
ATTR_IN_ZONES: ["zone.home"],
ATTR_LATITUDE: 32.87336,
ATTR_LONGITUDE: -117.22743,
ATTR_SOURCE: DEVICE_TRACKER,
}
hass.states.async_set(
DEVICE_TRACKER_2,
@@ -277,24 +281,20 @@ async def test_setup_two_trackers(
state = hass.states.get("person.tracked_person")
assert state.state == "not_home"
assert state.attributes.get(ATTR_ID) == "1234"
assert state.attributes.get(ATTR_LATITUDE) == 12.123456
assert state.attributes.get(ATTR_LONGITUDE) == 13.123456
assert state.attributes.get(ATTR_GPS_ACCURACY) == 12
assert state.attributes.get(ATTR_IN_ZONES) == ["zone.work"]
assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER_2
assert state.attributes.get(ATTR_USER_ID) == user_id
assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [
DEVICE_TRACKER,
DEVICE_TRACKER_2,
]
assert state.attributes == expected_attributes | {
ATTR_GPS_ACCURACY: 12,
ATTR_LATITUDE: 12.123456,
ATTR_LONGITUDE: 13.123456,
ATTR_IN_ZONES: ["zone.work"],
ATTR_SOURCE: DEVICE_TRACKER_2,
}
hass.states.async_set(DEVICE_TRACKER_2, "zone1", {ATTR_SOURCE_TYPE: SourceType.GPS})
await hass.async_block_till_done()
state = hass.states.get("person.tracked_person")
assert state.state == "zone1"
assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER_2
assert state.attributes == expected_attributes | {ATTR_SOURCE: DEVICE_TRACKER_2}
hass.states.async_set(DEVICE_TRACKER, "home", {ATTR_SOURCE_TYPE: SourceType.ROUTER})
await hass.async_block_till_done()
@@ -303,7 +303,11 @@ async def test_setup_two_trackers(
state = hass.states.get("person.tracked_person")
assert state.state == "home"
assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER
assert state.attributes == expected_attributes | {
ATTR_LATITUDE: 32.87336,
ATTR_LONGITUDE: -117.22743,
ATTR_SOURCE: DEVICE_TRACKER,
}
async def test_setup_router_ble_trackers(
@@ -326,13 +330,18 @@ async def test_setup_router_ble_trackers(
}
assert await async_setup_component(hass, DOMAIN, config)
expected_attributes = {
ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER, DEVICE_TRACKER_2],
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "tracked person",
ATTR_ID: "1234",
ATTR_IN_ZONES: [],
ATTR_USER_ID: user_id,
}
state = hass.states.get("person.tracked_person")
assert state.state == STATE_UNKNOWN
assert state.attributes.get(ATTR_ID) == "1234"
assert state.attributes.get(ATTR_LATITUDE) is None
assert state.attributes.get(ATTR_LONGITUDE) is None
assert state.attributes.get(ATTR_SOURCE) is None
assert state.attributes.get(ATTR_USER_ID) == user_id
assert state.attributes == expected_attributes
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done()
@@ -343,16 +352,7 @@ async def test_setup_router_ble_trackers(
state = hass.states.get("person.tracked_person")
assert state.state == "not_home"
assert state.attributes.get(ATTR_ID) == "1234"
assert state.attributes.get(ATTR_LATITUDE) is None
assert state.attributes.get(ATTR_LONGITUDE) is None
assert state.attributes.get(ATTR_GPS_ACCURACY) is None
assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER
assert state.attributes.get(ATTR_USER_ID) == user_id
assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [
DEVICE_TRACKER,
DEVICE_TRACKER_2,
]
assert state.attributes == expected_attributes | {ATTR_SOURCE: DEVICE_TRACKER}
# Set the BLE tracker to the "office" zone.
hass.states.async_set(
@@ -371,17 +371,295 @@ async def test_setup_router_ble_trackers(
# The person should be in the office.
state = hass.states.get("person.tracked_person")
assert state.state == "office"
assert state.attributes.get(ATTR_ID) == "1234"
assert state.attributes.get(ATTR_LATITUDE) == 12.123456
assert state.attributes.get(ATTR_LONGITUDE) == 13.123456
assert state.attributes.get(ATTR_GPS_ACCURACY) == 12
assert state.attributes.get(ATTR_IN_ZONES) == ["zone.office"]
assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER_2
assert state.attributes.get(ATTR_USER_ID) == user_id
assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [
DEVICE_TRACKER,
DEVICE_TRACKER_2,
]
assert state.attributes == expected_attributes | {
ATTR_GPS_ACCURACY: 12,
ATTR_LATITUDE: 12.123456,
ATTR_LONGITUDE: 13.123456,
ATTR_IN_ZONES: ["zone.office"],
ATTR_SOURCE: DEVICE_TRACKER_2,
}
# Representative device tracker states for the three priority buckets used by
# `Person._update_state`, in priority order:
# 1. a non-GPS tracker reporting "home" (highest priority)
# 2. any GPS tracker, regardless of its state (middle priority)
# 3. everything else, e.g. a non-GPS scanner associated with a non-home zone
# or a non-GPS tracker reporting "not_home" (lowest priority)
# Each value is a (state, attributes) tuple passed to `hass.states.async_set`.
_ROUTER_HOME: tuple[str, dict[str, Any]] = (
"home",
{ATTR_SOURCE_TYPE: SourceType.ROUTER, ATTR_IN_ZONES: ["zone.home"]},
)
_ROUTER_NOT_HOME: tuple[str, dict[str, Any]] = (
"not_home",
{ATTR_SOURCE_TYPE: SourceType.ROUTER, ATTR_IN_ZONES: []},
)
# A scanner tracker associated with a non-home zone reports the zone's name as
# its state and lists the zone in `in_zones` (see device_tracker PR #172157).
_SCANNER_OFFICE: tuple[str, dict[str, Any]] = (
"office",
{ATTR_SOURCE_TYPE: SourceType.ROUTER, ATTR_IN_ZONES: ["zone.office"]},
)
_GPS_NOT_HOME: tuple[str, dict[str, Any]] = (
"not_home",
{
ATTR_SOURCE_TYPE: SourceType.GPS,
ATTR_LATITUDE: 1.0,
ATTR_LONGITUDE: 2.0,
ATTR_GPS_ACCURACY: 5,
ATTR_IN_ZONES: [],
},
)
_GPS_WORK: tuple[str, dict[str, Any]] = (
"work",
{
ATTR_SOURCE_TYPE: SourceType.GPS,
ATTR_LATITUDE: 3.0,
ATTR_LONGITUDE: 4.0,
ATTR_GPS_ACCURACY: 7,
ATTR_IN_ZONES: ["zone.work"],
},
)
async def _async_setup_person_two_trackers(hass: HomeAssistant, user_id: str) -> None:
"""Set up a person tracked by two device trackers, with hass running."""
hass.set_state(CoreState.not_running)
config = {
DOMAIN: {
"id": "1234",
"name": "tracked person",
"user_id": user_id,
"device_trackers": [DEVICE_TRACKER, DEVICE_TRACKER_2],
}
}
assert await async_setup_component(hass, DOMAIN, config)
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done()
@pytest.mark.parametrize(
("high_priority", "low_priority", "expected_state", "expected_extra"),
[
# A non-GPS "home" tracker outranks a GPS tracker reporting coordinates.
# Its coordinates come from the home zone (it has none of its own).
pytest.param(
_ROUTER_HOME,
_GPS_NOT_HOME,
"home",
{
ATTR_IN_ZONES: ["zone.home"],
ATTR_LATITUDE: 32.87336,
ATTR_LONGITUDE: -117.22743,
ATTR_SOURCE: DEVICE_TRACKER,
},
id="home_beats_gps",
),
# A non-GPS "home" tracker outranks a scanner in another zone.
pytest.param(
_ROUTER_HOME,
_SCANNER_OFFICE,
"home",
{
ATTR_IN_ZONES: ["zone.home"],
ATTR_LATITUDE: 32.87336,
ATTR_LONGITUDE: -117.22743,
ATTR_SOURCE: DEVICE_TRACKER,
},
id="home_beats_other_zone",
),
# A GPS tracker outranks a scanner associated with another zone.
pytest.param(
_GPS_WORK,
_SCANNER_OFFICE,
"work",
{
ATTR_GPS_ACCURACY: 7,
ATTR_LATITUDE: 3.0,
ATTR_LONGITUDE: 4.0,
ATTR_IN_ZONES: ["zone.work"],
ATTR_SOURCE: DEVICE_TRACKER,
},
id="gps_beats_other_zone",
),
],
)
async def test_state_priority_overrides_recency(
hass: HomeAssistant,
hass_admin_user: MockUser,
freezer: FrozenDateTimeFactory,
high_priority: tuple[str, dict[str, Any]],
low_priority: tuple[str, dict[str, Any]],
expected_state: str,
expected_extra: dict[str, Any],
) -> None:
"""Test the higher-priority bucket wins even when its state is stale.
There is no time-based expiry: a long-stale state from a higher-priority
bucket still wins over a fresh state from a lower-priority bucket.
"""
await _async_setup_person_two_trackers(hass, hass_admin_user.id)
# The higher-priority tracker reports first and then goes stale.
hass.states.async_set(DEVICE_TRACKER, high_priority[0], high_priority[1])
await hass.async_block_till_done()
freezer.tick(timedelta(hours=2))
# The lower-priority tracker reports a much more recent update.
hass.states.async_set(DEVICE_TRACKER_2, low_priority[0], low_priority[1])
await hass.async_block_till_done()
state = hass.states.get("person.tracked_person")
assert state.state == expected_state
assert (
state.attributes
== {
ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER, DEVICE_TRACKER_2],
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "tracked person",
ATTR_ID: "1234",
ATTR_IN_ZONES: [],
ATTR_USER_ID: hass_admin_user.id,
}
| expected_extra
)
@pytest.mark.parametrize(
("older", "newer", "expected_state", "expected_extra"),
[
# GPS bucket: the most recent GPS state wins.
pytest.param(
_GPS_WORK,
_GPS_NOT_HOME,
"not_home",
{
ATTR_GPS_ACCURACY: 5,
ATTR_LATITUDE: 1.0,
ATTR_LONGITUDE: 2.0,
ATTR_SOURCE: DEVICE_TRACKER_2,
},
id="gps_newer_not_home",
),
pytest.param(
_GPS_NOT_HOME,
_GPS_WORK,
"work",
{
ATTR_GPS_ACCURACY: 7,
ATTR_LATITUDE: 3.0,
ATTR_LONGITUDE: 4.0,
ATTR_IN_ZONES: ["zone.work"],
ATTR_SOURCE: DEVICE_TRACKER_2,
},
id="gps_newer_work",
),
# Lowest-priority bucket: a fresh scanner in another zone wins over a
# stale "not_home", and vice versa.
pytest.param(
_ROUTER_NOT_HOME,
_SCANNER_OFFICE,
"office",
{ATTR_IN_ZONES: ["zone.office"], ATTR_SOURCE: DEVICE_TRACKER_2},
id="other_newer_office",
),
pytest.param(
_SCANNER_OFFICE,
_ROUTER_NOT_HOME,
"not_home",
{ATTR_SOURCE: DEVICE_TRACKER_2},
id="other_newer_not_home",
),
# "home" bucket: the most recent "home" tracker becomes the source and
# its coordinates come from the home zone.
pytest.param(
_ROUTER_HOME,
_ROUTER_HOME,
"home",
{
ATTR_IN_ZONES: ["zone.home"],
ATTR_LATITUDE: 32.87336,
ATTR_LONGITUDE: -117.22743,
ATTR_SOURCE: DEVICE_TRACKER_2,
},
id="home_newer",
),
],
)
async def test_most_recent_state_in_bucket_wins(
hass: HomeAssistant,
hass_admin_user: MockUser,
freezer: FrozenDateTimeFactory,
older: tuple[str, dict[str, Any]],
newer: tuple[str, dict[str, Any]],
expected_state: str,
expected_extra: dict[str, Any],
) -> None:
"""Test that within a bucket the most recently updated state is picked."""
await _async_setup_person_two_trackers(hass, hass_admin_user.id)
hass.states.async_set(DEVICE_TRACKER, older[0], older[1])
await hass.async_block_till_done()
freezer.tick(timedelta(minutes=5))
hass.states.async_set(DEVICE_TRACKER_2, newer[0], newer[1])
await hass.async_block_till_done()
state = hass.states.get("person.tracked_person")
assert state.state == expected_state
# The newer tracker is the source.
assert (
state.attributes
== {
ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER, DEVICE_TRACKER_2],
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "tracked person",
ATTR_ID: "1234",
ATTR_IN_ZONES: [],
ATTR_USER_ID: hass_admin_user.id,
}
| expected_extra
)
async def test_scanner_associated_with_other_zone(
hass: HomeAssistant, hass_admin_user: MockUser
) -> None:
"""Test a person tracked by a scanner associated with a non-home zone.
A connected scanner associated with a non-home zone reports the zone name
and lists the zone in `in_zones`. As a non-GPS tracker not reporting "home"
it lands in the lowest-priority bucket, so it has no coordinate fallback.
"""
hass.set_state(CoreState.not_running)
user_id = hass_admin_user.id
config = {
DOMAIN: {
"id": "1234",
"name": "tracked person",
"user_id": user_id,
"device_trackers": DEVICE_TRACKER,
}
}
assert await async_setup_component(hass, DOMAIN, config)
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done()
hass.states.async_set(DEVICE_TRACKER, _SCANNER_OFFICE[0], _SCANNER_OFFICE[1])
await hass.async_block_till_done()
# No coordinates: a scanner tracker provides none of its own, and as a
# lowest-priority state it gets no coordinate fallback from the home zone.
state = hass.states.get("person.tracked_person")
assert state.state == "office"
assert state.attributes == {
ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER],
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "tracked person",
ATTR_ID: "1234",
ATTR_IN_ZONES: ["zone.office"],
ATTR_SOURCE: DEVICE_TRACKER,
ATTR_USER_ID: user_id,
}
async def test_ignore_unavailable_states(
@@ -400,8 +678,18 @@ async def test_ignore_unavailable_states(
}
assert await async_setup_component(hass, DOMAIN, config)
expected_attributes = {
ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER, DEVICE_TRACKER_2],
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "tracked person",
ATTR_ID: "1234",
ATTR_IN_ZONES: [],
ATTR_USER_ID: user_id,
}
state = hass.states.get("person.tracked_person")
assert state.state == STATE_UNKNOWN
assert state.attributes == expected_attributes
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done()
@@ -413,6 +701,7 @@ async def test_ignore_unavailable_states(
# Unknown, as only 1 device tracker has a state, but we ignore that one
state = hass.states.get("person.tracked_person")
assert state.state == STATE_UNKNOWN
assert state.attributes == expected_attributes
hass.states.async_set(DEVICE_TRACKER_2, "not_home")
await hass.async_block_till_done()
@@ -420,6 +709,7 @@ async def test_ignore_unavailable_states(
# Take state of tracker 2
state = hass.states.get("person.tracked_person")
assert state.state == "not_home"
assert state.attributes == expected_attributes | {ATTR_SOURCE: DEVICE_TRACKER_2}
# state 1 is newer but ignored, keep tracker 2 state
hass.states.async_set(DEVICE_TRACKER, "unknown")
@@ -427,6 +717,7 @@ async def test_ignore_unavailable_states(
state = hass.states.get("person.tracked_person")
assert state.state == "not_home"
assert state.attributes == expected_attributes | {ATTR_SOURCE: DEVICE_TRACKER_2}
async def test_restore_home_state(
@@ -456,15 +747,21 @@ async def test_restore_home_state(
}
assert await async_setup_component(hass, DOMAIN, config)
# When restoring state the entity_id of the person will be used as source.
state = hass.states.get("person.tracked_person")
assert state.state == "home"
assert state.attributes.get(ATTR_ID) == "1234"
assert state.attributes.get(ATTR_LATITUDE) == 10.12346
assert state.attributes.get(ATTR_LONGITUDE) == 11.12346
# When restoring state the entity_id of the person will be used as source.
assert state.attributes.get(ATTR_SOURCE) == "person.tracked_person"
assert state.attributes.get(ATTR_USER_ID) == user_id
assert state.attributes.get(ATTR_ENTITY_PICTURE) == "/bla"
assert state.attributes == {
ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER],
ATTR_EDITABLE: False,
ATTR_ENTITY_PICTURE: "/bla",
ATTR_FRIENDLY_NAME: "tracked person",
ATTR_ID: "1234",
ATTR_IN_ZONES: [],
ATTR_LATITUDE: 10.12346,
ATTR_LONGITUDE: 11.12346,
ATTR_SOURCE: "person.tracked_person",
ATTR_USER_ID: user_id,
}
async def test_duplicate_ids(hass: HomeAssistant, hass_admin_user: MockUser) -> None:
@@ -502,13 +799,18 @@ async def test_load_person_storage(
hass: HomeAssistant, hass_admin_user: MockUser, storage_setup
) -> None:
"""Test set up person from storage."""
expected_attributes = {
ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER],
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "tracked person",
ATTR_ID: "1234",
ATTR_IN_ZONES: [],
ATTR_USER_ID: hass_admin_user.id,
}
state = hass.states.get("person.tracked_person")
assert state.state == STATE_UNKNOWN
assert state.attributes.get(ATTR_ID) == "1234"
assert state.attributes.get(ATTR_LATITUDE) is None
assert state.attributes.get(ATTR_LONGITUDE) is None
assert state.attributes.get(ATTR_SOURCE) is None
assert state.attributes.get(ATTR_USER_ID) == hass_admin_user.id
assert state.attributes == expected_attributes
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done()
@@ -517,11 +819,11 @@ async def test_load_person_storage(
state = hass.states.get("person.tracked_person")
assert state.state == "home"
assert state.attributes.get(ATTR_ID) == "1234"
assert state.attributes.get(ATTR_LATITUDE) == 32.87336
assert state.attributes.get(ATTR_LONGITUDE) == -117.22743
assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER
assert state.attributes.get(ATTR_USER_ID) == hass_admin_user.id
assert state.attributes == expected_attributes | {
ATTR_LATITUDE: 32.87336,
ATTR_LONGITUDE: -117.22743,
ATTR_SOURCE: DEVICE_TRACKER,
}
async def test_load_person_storage_two_nonlinked(
+2
View File
@@ -130,6 +130,8 @@ def _init_host_mock(host_mock: MagicMock) -> None:
host_mock.firmware_update_available.return_value = False
host_mock.session_active = True
host_mock.timeout = 60
host_mock.broken_cmds = ["GetManualRec"]
host_mock.baichuan_cmds = ["GetPtzCurPos"]
host_mock.renewtimer.return_value = 600
host_mock.wifi_connection.return_value = False
host_mock.wifi_signal.return_value = -45
@@ -10,8 +10,15 @@
'pushAlarm': 7,
}),
}),
'Baichuan connection': 'tcp',
'Baichuan fallbacks': list([
'GetPtzCurPos',
]),
'Baichuan only': False,
'Baichuan port': 5678,
'Broken cmds': list([
'GetManualRec',
]),
'Chimes': dict({
'12345678': dict({
'channel': 0,
@@ -215,6 +222,9 @@
]),
'firmware version': 'v1.0.0.0.0.0000',
'hardware version': 'IPC_00000',
'is Battery': False,
'is Hub': False,
'is NVR': True,
'model': 'RLN8-410',
'stream channels': list([
0,