Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0683344079 | |||
| 0b77cf9e4b | |||
| e0a87d966d | |||
| af53d2d082 | |||
| da7fa80e75 | |||
| 6cf1e7fb48 | |||
| 18fa0ac47d | |||
| 4afced1a49 | |||
| 74a4471160 | |||
| 857a3de066 | |||
| 06bf2ff6de | |||
| 6a5dae9cc3 | |||
| 475ebbc028 | |||
| 6e7643e997 | |||
| 1f954cda0d | |||
| 2961fca1b1 | |||
| 106b189206 | |||
| 0387034f4e | |||
| f81b6abca9 | |||
| 43f6e7977e | |||
| 706fea4ec5 | |||
| 74d23503e7 | |||
| 4ca5da2365 | |||
| 53c77ae2ef | |||
| 14968f9d67 |
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+4
@@ -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,
|
||||
|
||||
@@ -4351,7 +4351,7 @@
|
||||
"mitsubishi_comfort": {
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling",
|
||||
"iot_class": "local_polling",
|
||||
"name": "Mitsubishi Comfort"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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."
|
||||
|
||||
Generated
+9
-9
@@ -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
|
||||
|
||||
@@ -27,7 +27,6 @@ MISSING_INTEGRATION_TYPE = {
|
||||
"folder_watcher",
|
||||
"forked_daapd",
|
||||
"geniushub",
|
||||
"gentex_homelink",
|
||||
"geofency",
|
||||
"govee_light_local",
|
||||
"gpsd",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user