Compare commits

..

2 Commits

Author SHA1 Message Date
Petar Petrov adeae40ce1 Strip unknown labels instead of erroring 2026-06-11 11:00:57 +03:00
Petar Petrov a6d3fb1808 Reject unknown label ids in registry websocket APIs 2026-06-10 14:01:04 +03:00
125 changed files with 642 additions and 6082 deletions
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
category: "/language:python"
Generated
-4
View File
@@ -947,8 +947,6 @@ CLAUDE.md @home-assistant/core
/tests/components/kiosker/ @Claeysson
/homeassistant/components/kitchen_sink/ @home-assistant/core
/tests/components/kitchen_sink/ @home-assistant/core
/homeassistant/components/klik_aan_klik_uit/ @Phunkafizer
/tests/components/klik_aan_klik_uit/ @Phunkafizer
/homeassistant/components/kmtronic/ @dgomes
/tests/components/kmtronic/ @dgomes
/homeassistant/components/knocki/ @joostlek @jgatto1 @JakeBosh
@@ -1086,8 +1084,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/mediaroom/ @dgomes
/homeassistant/components/melcloud/ @erwindouna
/tests/components/melcloud/ @erwindouna
/homeassistant/components/melcloud_home/ @erwindouna
/tests/components/melcloud_home/ @erwindouna
/homeassistant/components/melissa/ @kennedyshead
/tests/components/melissa/ @kennedyshead
/homeassistant/components/melnor/ @vanstinator
@@ -12,18 +12,7 @@ from homeassistant.helpers.device_registry import DeviceEntry
from .coordinator import AmazonConfigEntry
TO_REDACT = {
CONF_NAME,
CONF_PASSWORD,
CONF_USERNAME,
"access_token",
"adp_token",
"device_private_key",
"refresh_token",
"store_authentication_cookie",
"title",
"website_cookies",
}
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"}
async def async_get_config_entry_diagnostics(
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==14.0.3"]
"requirements": ["aioamazondevices==14.0.0"]
}
@@ -1,6 +1,7 @@
"""Coordinator for the Anthropic integration."""
import datetime
import re
import anthropic
@@ -19,12 +20,15 @@ UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1)
type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator]
_model_short_form = re.compile(r"[^\d]-\d$")
@callback
def model_alias(model_id: str) -> str:
"""Resolve alias from versioned model name."""
if model_id[-2:-1] != "-" and not model_id.endswith("-preview"):
model_id = model_id[:-9]
if model_id.endswith("-4"):
if _model_short_form.search(model_id):
return model_id + "-0"
return model_id
@@ -9,5 +9,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["anthropic==0.108.0"]
"requirements": ["anthropic==0.96.0"]
}
@@ -65,18 +65,18 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
]
self._model_list_cache[entry.entry_id] = model_list
family = (
model.removeprefix("claude-")
.removesuffix("-preview")
.translate(str.maketrans("", "", "0123456789-."))
or "haiku"
)
if "opus" in model:
family = "claude-opus"
elif "sonnet" in model:
family = "claude-sonnet"
else:
family = "claude-haiku"
suggested_model = next(
(
model_option["value"]
for model_option in sorted(
(m for m in model_list if f"claude-{family}" in m["value"]),
(m for m in model_list if family in m["value"]),
key=lambda x: x["value"],
reverse=True,
)
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.7.0"]
"requirements": ["hassil==3.6.0"]
}
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["blebox_uniapi"],
"requirements": ["blebox-uniapi==2.5.5"],
"requirements": ["blebox-uniapi==2.5.4"],
"zeroconf": ["_bbxsrv._tcp.local."]
}
+10 -18
View File
@@ -1,18 +1,19 @@
"""The Brands integration."""
from collections import deque
from collections.abc import Container, Mapping
from http import HTTPStatus
import logging
from pathlib import Path
from random import SystemRandom
import time
from typing import Any, Final
from typing import Any, Final, override
from aiohttp import ClientError, hdrs, web
from aiohttp import ClientError, web
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant, callback, valid_domain
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -108,23 +109,18 @@ def _read_brand_file(brand_dir: Path, image: str) -> bytes | None:
class _BrandsBaseView(HomeAssistantView):
"""Base view for serving brand images."""
requires_auth = False
use_query_token_for_auth = True
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the view."""
self._hass = hass
self._cache_dir = Path(hass.config.cache_path(DOMAIN))
def _authenticate(self, request: web.Request) -> None:
"""Authenticate the request using Bearer token or query token."""
access_tokens: deque[str] = self._hass.data[DOMAIN]
authenticated = (
request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens
)
if not authenticated:
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
raise web.HTTPForbidden
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
return self._hass.data[DOMAIN]
async def _serve_from_custom_integration(
self,
@@ -240,8 +236,6 @@ class BrandsIntegrationView(_BrandsBaseView):
image: str,
) -> web.Response:
"""Handle GET request for an integration brand image."""
self._authenticate(request)
if not valid_domain(domain) or image not in ALLOWED_IMAGES:
return web.Response(status=HTTPStatus.NOT_FOUND)
@@ -274,8 +268,6 @@ class BrandsHardwareView(_BrandsBaseView):
image: str,
) -> web.Response:
"""Handle GET request for a hardware brand image."""
self._authenticate(request)
if not CATEGORY_RE.match(category):
return web.Response(status=HTTPStatus.NOT_FOUND)
# Hardware images have dynamic names like "manufacturer_model.png"
+14 -18
View File
@@ -2,7 +2,7 @@
import asyncio
import collections
from collections.abc import Awaitable, Callable, Coroutine
from collections.abc import Awaitable, Callable, Container, Coroutine, Mapping
from contextlib import suppress
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
@@ -12,16 +12,16 @@ import logging
import os
from random import SystemRandom
import time
from typing import Any, Final, final
from typing import Any, Final, final, override
from aiohttp import hdrs, web
from aiohttp import web
import attr
from propcache.api import cached_property, under_cached_property
import voluptuous as vol
from webrtc_models import RTCIceCandidateInit
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
@@ -776,30 +776,26 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
class CameraView(HomeAssistantView):
"""Base CameraView."""
requires_auth = False
use_query_token_for_auth = True
def __init__(self, component: EntityComponent[Camera]) -> None:
"""Initialize a basic camera view."""
self.component = component
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (camera := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return camera.access_tokens
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
if (camera := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") in camera.access_tokens
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
# Invalid sigAuth or camera access token
raise web.HTTPForbidden
if not camera.is_on:
_LOGGER.debug("Camera is off")
raise web.HTTPServiceUnavailable
@@ -6,7 +6,7 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import area_registry as ar
from homeassistant.helpers import area_registry as ar, label_registry as lr
@callback
@@ -69,8 +69,9 @@ def websocket_create_area(
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
if "labels" in data:
# Convert labels to a set
data["labels"] = set(data["labels"])
# Strip labels which are not in the label registry
labels = set(data["labels"])
data["labels"] = labels - lr.async_get_missing_label_ids(hass, labels)
try:
entry = registry.async_create(**data)
@@ -139,8 +140,11 @@ def websocket_update_area(
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
if "labels" in data:
# Convert labels to a set
data["labels"] = set(data["labels"])
# Strip labels which are not in the label registry. This also cleans up
# any stale labels already stored on the area (e.g. left behind by a
# deleted label) the next time it is saved.
labels = set(data["labels"])
data["labels"] = labels - lr.async_get_missing_label_ids(hass, labels)
try:
entry = registry.async_update(**data)
@@ -9,7 +9,7 @@ from homeassistant.components import websocket_api
from homeassistant.components.websocket_api import require_admin
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import device_registry as dr, label_registry as lr
from homeassistant.helpers.device_registry import DeviceEntry, DeviceEntryDisabler
@@ -84,8 +84,11 @@ def websocket_update_device(
msg["disabled_by"] = DeviceEntryDisabler(msg["disabled_by"])
if "labels" in msg:
# Convert labels to a set
msg["labels"] = set(msg["labels"])
# Strip labels which are not in the label registry. This also cleans up
# any stale labels already stored on the device (e.g. left behind by a
# deleted label) the next time it is saved.
labels = set(msg["labels"])
msg["labels"] = labels - lr.async_get_missing_label_ids(hass, labels)
entry = cast(DeviceEntry, registry.async_update_device(**msg))
@@ -13,6 +13,7 @@ from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
label_registry as lr,
)
from homeassistant.helpers.json import json_dumps
@@ -234,8 +235,11 @@ def websocket_update_entity(
aliases.append(alias)
if "labels" in msg:
# Convert labels to a set
changes["labels"] = set(msg["labels"])
# Strip labels which are not in the label registry. This also cleans up
# any stale labels already stored on the entity (e.g. left behind by a
# deleted label) the next time it is saved.
labels = set(msg["labels"])
changes["labels"] = labels - lr.async_get_missing_label_ids(hass, labels)
if "disabled_by" in msg and msg["disabled_by"] is None:
# Don't allow enabling an entity of a disabled device
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.7.0", "home-assistant-intents==2026.6.1"]
"requirements": ["hassil==3.6.0", "home-assistant-intents==2026.6.1"]
}
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["sml"],
"requirements": ["pysml==0.1.8"]
"requirements": ["pysml==0.1.7"]
}
@@ -11,20 +11,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_RADAR_LAYER,
CONF_RADAR_LEGEND,
CONF_RADAR_OPACITY,
CONF_RADAR_RADIUS,
CONF_RADAR_TIMESTAMP,
CONF_STATION,
DEFAULT_RADAR_LAYER,
DEFAULT_RADAR_LEGEND,
DEFAULT_RADAR_OPACITY,
DEFAULT_RADAR_RADIUS,
DEFAULT_RADAR_TIMESTAMP,
DOMAIN,
)
from .const import CONF_STATION, DOMAIN
from .coordinator import ECConfigEntry, ECDataUpdateCoordinator, ECRuntimeData
from .services import async_setup_services
@@ -67,15 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
errors = errors + 1
_LOGGER.warning("Unable to retrieve Environment Canada weather")
options = config_entry.options
radar_data = ECMap(
coordinates=(lat, lon),
layer=options.get(CONF_RADAR_LAYER, DEFAULT_RADAR_LAYER),
legend=options.get(CONF_RADAR_LEGEND, DEFAULT_RADAR_LEGEND),
timestamp=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP),
layer_opacity=int(options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY)),
radius=int(options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS)),
)
radar_data = ECMap(coordinates=(lat, lon), layer="precip_type", legend=False)
radar_coordinator = ECDataUpdateCoordinator(
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
)
@@ -9,42 +9,17 @@ from env_canada import ECWeather, ec_exc
from env_canada.ec_weather import get_ec_sites_list
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
BooleanSelector,
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import (
CONF_RADAR_LAYER,
CONF_RADAR_LEGEND,
CONF_RADAR_OPACITY,
CONF_RADAR_RADIUS,
CONF_RADAR_TIMESTAMP,
CONF_STATION,
CONF_TITLE,
DEFAULT_RADAR_LAYER,
DEFAULT_RADAR_LEGEND,
DEFAULT_RADAR_OPACITY,
DEFAULT_RADAR_RADIUS,
DEFAULT_RADAR_TIMESTAMP,
DOMAIN,
RADAR_LAYERS,
)
from .const import CONF_STATION, CONF_TITLE, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -82,14 +57,6 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
_station_codes: list[dict[str, str]] | None = None
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> OptionsFlowHandler:
"""Return the options flow handler."""
return OptionsFlowHandler()
async def _get_station_codes(self) -> list[dict[str, str]]:
"""Get station codes, cached after first call."""
if self._station_codes is None:
@@ -160,55 +127,3 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=data_schema, errors=errors
)
class OptionsFlowHandler(OptionsFlowWithReload):
"""Handle Environment Canada radar camera options."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the radar camera options."""
if user_input is not None:
return self.async_create_entry(data=user_input)
options = self.config_entry.options
data_schema = vol.Schema(
{
vol.Required(
CONF_RADAR_LAYER,
default=options.get(CONF_RADAR_LAYER, DEFAULT_RADAR_LAYER),
): SelectSelector(
SelectSelectorConfig(
options=RADAR_LAYERS,
translation_key="radar_layer",
)
),
vol.Required(
CONF_RADAR_LEGEND,
default=options.get(CONF_RADAR_LEGEND, DEFAULT_RADAR_LEGEND),
): BooleanSelector(),
vol.Required(
CONF_RADAR_TIMESTAMP,
default=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP),
): BooleanSelector(),
vol.Required(
CONF_RADAR_OPACITY,
default=options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY),
): NumberSelector(
NumberSelectorConfig(
min=0, max=100, step=1, mode=NumberSelectorMode.SLIDER
)
),
vol.Required(
CONF_RADAR_RADIUS,
default=options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS),
): NumberSelector(
NumberSelectorConfig(
min=10, max=2000, step=10, unit_of_measurement="km"
)
),
}
)
return self.async_show_form(step_id="init", data_schema=data_schema)
@@ -6,19 +6,3 @@ CONF_STATION = "station"
CONF_TITLE = "title"
DOMAIN = "environment_canada"
SERVICE_ENVIRONMENT_CANADA_FORECASTS = "get_forecasts"
CONF_RADAR_LAYER = "radar_layer"
CONF_RADAR_LEGEND = "radar_legend"
CONF_RADAR_TIMESTAMP = "radar_timestamp"
CONF_RADAR_OPACITY = "radar_opacity"
CONF_RADAR_RADIUS = "radar_radius"
RADAR_LAYERS = ["rain", "snow", "precip_type"]
# Defaults preserve the radar behaviour from before the options flow existed:
# the precipitation-type layer with the legend hidden.
DEFAULT_RADAR_LAYER = "precip_type"
DEFAULT_RADAR_LEGEND = False
DEFAULT_RADAR_TIMESTAMP = True
DEFAULT_RADAR_OPACITY = 65
DEFAULT_RADAR_RADIUS = 200
@@ -117,33 +117,6 @@
"message": "Environment Canada is not connected"
}
},
"options": {
"step": {
"init": {
"data": {
"radar_layer": "Radar type",
"radar_legend": "Show legend",
"radar_opacity": "Radar opacity",
"radar_radius": "Map radius",
"radar_timestamp": "Show timestamp"
},
"data_description": {
"radar_opacity": "Opacity of the radar layer overlay (0-100)",
"radar_radius": "Radius of the radar map in kilometres"
},
"title": "Radar camera options"
}
}
},
"selector": {
"radar_layer": {
"options": {
"precip_type": "Precipitation type",
"rain": "Rain",
"snow": "Snow"
}
}
},
"services": {
"get_alerts": {
"description": "Retrieves the alerts from the selected weather service.",
@@ -27,7 +27,6 @@ from epson_projector.const import (
)
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
@@ -63,7 +62,6 @@ class EpsonProjectorMediaPlayer(MediaPlayerEntity):
_attr_has_entity_name = True
_attr_name = None
_attr_device_class = MediaPlayerDeviceClass.PROJECTOR
_attr_supported_features = (
MediaPlayerEntityFeature.TURN_ON
@@ -2,7 +2,7 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from gardena_bluetooth.const import (
AquaContourBattery,
@@ -279,7 +279,7 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity):
super()._handle_coordinator_update()
return
time = dt_util.utcnow() + timedelta(seconds=value)
time = datetime.now(UTC) + timedelta(seconds=value) # pylint: disable=home-assistant-enforce-utcnow
if not self._attr_native_value:
self._attr_native_value = time
super()._handle_coordinator_update()
@@ -10,7 +10,7 @@
},
"step": {
"confirm": {
"description": "Do you want to set up {name}?\n\nBefore you continue, make sure the device is in pairing mode."
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
},
"user": {
"data": {
+5 -21
View File
@@ -14,12 +14,7 @@ from homeassistant.helpers.aiohttp_client import (
)
from .const import CONF_REPOSITORIES, CONF_REPOSITORY, DOMAIN, SUBENTRY_TYPE_REPOSITORY
from .coordinator import (
GithubConfigEntry,
GitHubDataUpdateCoordinator,
GitHubRuntimeData,
GitHubUserDataUpdateCoordinator,
)
from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
@@ -32,14 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo
client_name=SERVER_SOFTWARE,
)
user_coordinator = GitHubUserDataUpdateCoordinator(
hass=hass,
config_entry=entry,
client=client,
)
await user_coordinator.async_config_entry_first_refresh()
repositories: dict[str, GitHubDataUpdateCoordinator] = {}
entry.runtime_data = {}
for repository_subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_REPOSITORY):
repository = repository_subentry.data[CONF_REPOSITORY]
coordinator = GitHubDataUpdateCoordinator(
@@ -54,12 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo
if not entry.pref_disable_polling:
await coordinator.subscribe()
repositories[repository_subentry.subentry_id] = coordinator
entry.runtime_data = GitHubRuntimeData(
user_coordinator=user_coordinator,
repositories=repositories,
)
entry.runtime_data[repository_subentry.subentry_id] = coordinator
entry.async_on_unload(entry.add_update_listener(async_update_entry))
@@ -74,7 +57,8 @@ async def async_update_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> N
async def async_unload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bool:
"""Unload a config entry."""
for coordinator in entry.runtime_data.repositories.values():
repositories = entry.runtime_data
for coordinator in repositories.values():
coordinator.unsubscribe()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+1 -48
View File
@@ -1,11 +1,9 @@
"""Custom data update coordinator for the GitHub integration."""
from dataclasses import dataclass
from typing import Any
from aiogithubapi import (
GitHubAPI,
GitHubAuthenticatedUserModel,
GitHubConnectionException,
GitHubEventModel,
GitHubException,
@@ -105,52 +103,7 @@ query ($owner: String!, $repository: String!) {
}
"""
type GithubConfigEntry = ConfigEntry[GitHubRuntimeData]
@dataclass
class GitHubRuntimeData:
"""Runtime data for the GitHub integration."""
user_coordinator: GitHubUserDataUpdateCoordinator
repositories: dict[str, GitHubDataUpdateCoordinator]
class GitHubUserDataUpdateCoordinator(
DataUpdateCoordinator[GitHubAuthenticatedUserModel]
):
"""Data update coordinator for the authenticated GitHub user."""
config_entry: GithubConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: GithubConfigEntry,
client: GitHubAPI,
) -> None:
"""Initialize GitHub user data update coordinator."""
self._client = client
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name="user",
update_interval=FALLBACK_UPDATE_INTERVAL,
)
async def _async_update_data(self) -> GitHubAuthenticatedUserModel:
"""Update data."""
try:
response = await self._client.user.get()
except (GitHubConnectionException, GitHubRatelimitException) as exception:
raise UpdateFailed(exception) from exception
except GitHubException as exception:
LOGGER.exception(exception)
raise UpdateFailed(exception) from exception
return response.data
type GithubConfigEntry = ConfigEntry[dict[str, GitHubDataUpdateCoordinator]]
class GitHubDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
@@ -33,7 +33,7 @@ async def async_get_config_entry_diagnostics(
else:
data["rate_limit"] = rate_limit_response.data.as_dict
repositories = config_entry.runtime_data.repositories
repositories = config_entry.runtime_data
data["repositories"] = {}
for coordinator in repositories.values():
@@ -4,12 +4,6 @@
"discussions_count": {
"default": "mdi:forum"
},
"followers": {
"default": "mdi:account-multiple"
},
"following": {
"default": "mdi:account-multiple-outline"
},
"forks_count": {
"default": "mdi:source-fork"
},
@@ -37,12 +31,6 @@
"merged_pulls_count": {
"default": "mdi:source-merge"
},
"public_gists": {
"default": "mdi:code-json"
},
"public_repos": {
"default": "mdi:source-repository"
},
"pulls_count": {
"default": "mdi:source-pull"
},
+3 -87
View File
@@ -4,8 +4,6 @@ from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any
from aiogithubapi import GitHubAuthenticatedUserModel
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
@@ -19,11 +17,7 @@ from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import (
GithubConfigEntry,
GitHubDataUpdateCoordinator,
GitHubUserDataUpdateCoordinator,
)
from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator
@dataclass(frozen=True, kw_only=True)
@@ -147,58 +141,14 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
)
@dataclass(frozen=True, kw_only=True)
class GitHubUserSensorEntityDescription(SensorEntityDescription):
"""Describes GitHub user sensor entity."""
value_fn: Callable[[GitHubAuthenticatedUserModel], StateType]
USER_SENSOR_DESCRIPTIONS: tuple[GitHubUserSensorEntityDescription, ...] = (
GitHubUserSensorEntityDescription(
key="followers",
translation_key="followers",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda data: data.followers,
),
GitHubUserSensorEntityDescription(
key="following",
translation_key="following",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda data: data.following,
),
GitHubUserSensorEntityDescription(
key="public_gists",
translation_key="public_gists",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda data: data.public_gists,
),
GitHubUserSensorEntityDescription(
key="public_repos",
translation_key="public_repos",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda data: data.public_repos,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: GithubConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up GitHub sensor based on a config entry."""
user_coordinator = entry.runtime_data.user_coordinator
async_add_entities(
GitHubUserSensorEntity(user_coordinator, description)
for description in USER_SENSOR_DESCRIPTIONS
)
for subentry_id, coordinator in entry.runtime_data.repositories.items():
repositories = entry.runtime_data
for subentry_id, coordinator in repositories.items():
async_add_entities(
(
GitHubSensorEntity(coordinator, description)
@@ -253,37 +203,3 @@ class GitHubSensorEntity(CoordinatorEntity[GitHubDataUpdateCoordinator], SensorE
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return the extra state attributes."""
return self.entity_description.attr_fn(self.coordinator.data)
class GitHubUserSensorEntity(
CoordinatorEntity[GitHubUserDataUpdateCoordinator], SensorEntity
):
"""Defines a GitHub user sensor entity."""
_attr_has_entity_name = True
entity_description: GitHubUserSensorEntityDescription
def __init__(
self,
coordinator: GitHubUserDataUpdateCoordinator,
entity_description: GitHubUserSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator=coordinator)
self.entity_description = entity_description
self._attr_unique_id = f"{coordinator.data.id}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(coordinator.data.id))},
name=coordinator.data.login,
manufacturer="GitHub",
configuration_url=f"https://github.com/{coordinator.data.login}",
entry_type=DeviceEntryType.SERVICE,
)
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)
@@ -36,14 +36,6 @@
"name": "Discussions",
"unit_of_measurement": "discussions"
},
"followers": {
"name": "Followers",
"unit_of_measurement": "followers"
},
"following": {
"name": "Following",
"unit_of_measurement": "users"
},
"forks_count": {
"name": "Forks",
"unit_of_measurement": "forks"
@@ -74,14 +66,6 @@
"name": "Merged pull requests",
"unit_of_measurement": "pull requests"
},
"public_gists": {
"name": "Public gists",
"unit_of_measurement": "gists"
},
"public_repos": {
"name": "Public repositories",
"unit_of_measurement": "repositories"
},
"pulls_count": {
"name": "Pull requests",
"unit_of_measurement": "pull requests"
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.13.0"]
"requirements": ["homematicip==2.12.0"]
}
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["hyponcloud==1.0.1"]
"requirements": ["hyponcloud==1.0.0"]
}
@@ -1,31 +0,0 @@
"""Diagnostics platform for iAquaLink."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from . import AqualinkConfigEntry
TO_REDACT = {"serial", "serial_number"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AqualinkConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
systems = [
{
"online": coordinator.system.online,
"data": {k: v for k, v in coordinator.system.data.items() if k != "name"},
"devices": {
name: {"class": obj.__class__.__name__, "data": obj.data}
for name, obj in (
getattr(coordinator.system, "devices", None) or {}
).items()
},
}
for coordinator in entry.runtime_data.coordinators.values()
]
return {"systems": async_redact_data(systems, TO_REDACT)}
@@ -39,7 +39,7 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: This integration uses a cloud account.
+19 -23
View File
@@ -2,20 +2,21 @@
import asyncio
import collections
from collections.abc import Container, Mapping
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
import os
from random import SystemRandom
from typing import Final, final
from typing import Final, final, override
from aiohttp import hdrs, web
from aiohttp import web
import httpx
from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import (
@@ -314,33 +315,28 @@ class ImageView(HomeAssistantView):
"""View to serve an image."""
name = "api:image:image"
requires_auth = False
use_query_token_for_auth = True
url = "/api/image_proxy/{entity_id}"
def __init__(self, component: EntityComponent[ImageEntity]) -> None:
"""Initialize an image view."""
self.component = component
async def _authenticate_request(
self, request: web.Request, entity_id: str
) -> ImageEntity:
"""Authenticate request and return image entity."""
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (image_entity := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return image_entity.access_tokens
@callback
def _get_image_entity(self, entity_id: str) -> ImageEntity:
"""Get image entity from request."""
if (image_entity := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") in image_entity.access_tokens
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
# Invalid sigAuth or image entity access token
raise web.HTTPForbidden
return image_entity
async def head(self, request: web.Request, entity_id: str) -> web.Response:
@@ -349,7 +345,7 @@ class ImageView(HomeAssistantView):
This is sent by some DLNA renderers, like Samsung ones, prior to sending
the GET request.
"""
image_entity = await self._authenticate_request(request, entity_id)
image_entity = self._get_image_entity(entity_id)
# Don't use `handle` as we don't care about the stream case, we only want
# to verify that the image exists.
@@ -365,7 +361,7 @@ class ImageView(HomeAssistantView):
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
image_entity = await self._authenticate_request(request, entity_id)
image_entity = self._get_image_entity(entity_id)
return await self.handle(request, image_entity)
async def handle(
-106
View File
@@ -1,106 +0,0 @@
"""Support for Imou camera entities."""
from pyimouapi.const import PARAM_HD, PARAM_MOTION_DETECT, PARAM_STATE
from pyimouapi.exceptions import ImouException
from pyimouapi.ha_device import ImouHaDevice
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import PARAM_HEADER_DETECT, imou_device_identifier
from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator
from .entity import ImouEntity
PARALLEL_UPDATES = 0
CAMERA_STREAM_RESOLUTION_SD = "SD"
# Defaults for pyimouapi ImouHaDeviceManager APIs (async_get_device_stream / async_get_device_image).
PYIMOUAPI_LIVE_PROTOCOL = "https"
PYIMOUAPI_SNAPSHOT_WAIT_SECONDS = 3
CAMERA_TYPES = (
("camera_sd", CAMERA_STREAM_RESOLUTION_SD),
("camera_hd", PARAM_HD),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ImouConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Imou camera entities."""
coordinator = entry.runtime_data
def _add_cameras(new_devices: list[ImouHaDevice]) -> None:
device_keys = {imou_device_identifier(device) for device in new_devices}
async_add_entities(
ImouCamera(coordinator, entity_type, device, resolution)
for device in coordinator.devices
if device.channel_id is not None
if imou_device_identifier(device) in device_keys
for entity_type, resolution in CAMERA_TYPES
)
coordinator.new_device_callbacks.append(_add_cameras)
@callback
def _remove_new_device_callback() -> None:
if _add_cameras in coordinator.new_device_callbacks:
coordinator.new_device_callbacks.remove(_add_cameras)
entry.async_on_unload(_remove_new_device_callback)
_add_cameras(coordinator.devices)
class ImouCamera(ImouEntity, Camera):
"""Representation of an Imou camera stream."""
_attr_supported_features = CameraEntityFeature.STREAM
def __init__(
self,
coordinator: ImouDataUpdateCoordinator,
entity_type: str,
device: ImouHaDevice,
resolution: str,
) -> None:
"""Initialize the camera entity."""
self._resolution = resolution
Camera.__init__(self)
super().__init__(coordinator, entity_type, device)
async def stream_source(self) -> str | None:
"""Return the live stream URL from the Imou cloud."""
try:
return await self.coordinator.device_manager.async_get_device_stream(
self.device,
self._resolution,
PYIMOUAPI_LIVE_PROTOCOL,
)
except ImouException as err:
raise HomeAssistantError(str(err)) from err
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return bytes of camera image."""
try:
return await self.coordinator.device_manager.async_get_device_image(
self.device,
PYIMOUAPI_SNAPSHOT_WAIT_SECONDS,
)
except ImouException as err:
raise HomeAssistantError(str(err)) from err
@property
def motion_detection_enabled(self) -> bool:
"""Return True when human and/or motion detection switch is on."""
header = self.device.switches.get(PARAM_HEADER_DETECT)
motion = self.device.switches.get(PARAM_MOTION_DETECT)
header_on = bool(header[PARAM_STATE]) if header else False
motion_on = bool(motion[PARAM_STATE]) if motion else False
return header_on or motion_on
+2 -2
View File
@@ -28,7 +28,7 @@ CONF_APP_SECRET = "app_secret"
PARAM_STATUS = "status"
PARAM_STATE = "state"
PARAM_HEADER_DETECT = "header_detect"
# How long each PTZ button press moves the camera, in milliseconds (Imou cloud API).
PTZ_MOVE_DURATION_MS = 500
@@ -36,4 +36,4 @@ PTZ_MOVE_DURATION_MS = 500
# Upper bound for a full coordinator refresh (device list + status for all devices).
UPDATE_TIMEOUT = 300
PLATFORMS = [Platform.BUTTON, Platform.CAMERA]
PLATFORMS = [Platform.BUTTON]
@@ -41,14 +41,6 @@
"ptz_up": {
"name": "PTZ up"
}
},
"camera": {
"camera_hd": {
"name": "Live view HD"
},
"camera_sd": {
"name": "Live view SD"
}
}
},
"selector": {
@@ -423,7 +423,7 @@ def get_influx_connection( # noqa: C901
if CONF_HOST in conf:
kwargs[CONF_HOST] = conf[CONF_HOST]
if (path := conf.get(CONF_PATH)) is not None and path != "/":
if (path := conf.get(CONF_PATH)) is not None:
kwargs[CONF_PATH] = path
if (port := conf.get(CONF_PORT)) is not None:
@@ -1,55 +0,0 @@
"""The KlikAanKlikUit RC integration."""
from dataclasses import dataclass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONF_TRANSMITTER
@dataclass(slots=True)
class KlikAanKlikUitRuntimeData:
"""Runtime data for the KlikAanKlikUit integration."""
transmitter_entity_id: str
type KlikAanKlikUitConfigEntry = ConfigEntry[KlikAanKlikUitRuntimeData]
PLATFORMS: list[Platform] = [Platform.SWITCH]
async def async_setup_entry(
hass: HomeAssistant, entry: KlikAanKlikUitConfigEntry
) -> bool:
"""Setup KlikAanKlikUit RC from a config entry."""
transmitter_entity_id = entry.data[CONF_TRANSMITTER]
if hass.states.get(transmitter_entity_id) is None:
raise ConfigEntryNotReady(
f"RF transmitter entity {transmitter_entity_id} is not available"
)
entry.runtime_data = KlikAanKlikUitRuntimeData(
transmitter_entity_id=transmitter_entity_id
)
entry.async_on_unload(entry.add_update_listener(async_update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: KlikAanKlikUitConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_update_listener(
hass: HomeAssistant, entry: KlikAanKlikUitConfigEntry
) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
@@ -1,187 +0,0 @@
"""Config flow for the KlikAanKlikUit RC integration."""
from typing import Any
from rf_protocols.commands import ModulationType
from rf_protocols.commands.kaku import KakuCommand
import voluptuous as vol
from homeassistant.components.radio_frequency import (
async_get_transmitters,
async_send_command,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE_ID
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, selector
from .const import (
CONF_CHANNEL,
CONF_GROUP,
CONF_TRANSMITTER,
DOMAIN,
REPEAT_COUNT_LEARN,
)
_SAMPLE_COMMAND = KakuCommand(
id=0,
channel=1,
group=False,
on=True,
)
_CONF_DEVICE_RESPONDED = "device_responded"
class KakuRcConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for KlikAanKlikUit."""
VERSION = 1
def __init__(self) -> None:
"""Initialize config flow."""
self._device_data: dict[str, Any] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle collecting initial setup data."""
try:
transmitters = async_get_transmitters(
self.hass,
_SAMPLE_COMMAND.frequency,
ModulationType.OOK,
)
except HomeAssistantError:
return self.async_abort(reason="no_transmitters")
if not transmitters:
return self.async_abort(reason="no_compatible_transmitters")
if user_input is not None:
transmitter: str = user_input[CONF_TRANSMITTER]
device_id: int = user_input[CONF_DEVICE_ID]
channel: int = user_input[CONF_CHANNEL]
group: bool = user_input[CONF_GROUP]
registry = er.async_get(self.hass)
entity_entry = registry.async_get(transmitter)
assert entity_entry is not None
await self.async_set_unique_id(
f"{entity_entry.id}_{device_id}_{channel}_{int(group)}"
)
self._abort_if_unique_id_configured()
self._device_data = {
CONF_TRANSMITTER: transmitter,
CONF_DEVICE_ID: device_id,
CONF_CHANNEL: channel,
CONF_GROUP: group,
}
return await self.async_step_pairing_mode()
return self.async_show_form(
step_id="user",
data_schema=self._async_user_schema(transmitters),
)
async def async_step_pairing_mode(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Ask user to put the target device in pairing mode."""
if user_input is None:
return self.async_show_form(
step_id="pairing_mode",
data_schema=vol.Schema({}),
)
assert self._device_data is not None
command = KakuCommand(
id=self._device_data[CONF_DEVICE_ID],
channel=self._device_data[CONF_CHANNEL],
group=self._device_data[CONF_GROUP],
on=True,
frame_repeats=REPEAT_COUNT_LEARN,
)
await async_send_command(
self.hass,
self._device_data[CONF_TRANSMITTER],
command,
)
return await self.async_step_pairing_result()
async def async_step_pairing_result(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm whether the device responded to the learn command."""
if user_input is not None:
if user_input[_CONF_DEVICE_RESPONDED]:
assert self._device_data is not None
title = (
f"KlikAanKlikUit ID {self._device_data[CONF_DEVICE_ID]} "
f"CH {self._device_data[CONF_CHANNEL]}"
)
return self.async_create_entry(
title=title,
data=self._device_data,
)
return await self.async_step_pairing_mode()
return self.async_show_form(
step_id="pairing_result",
data_schema=vol.Schema(
{
vol.Required(
_CONF_DEVICE_RESPONDED,
default=False,
): selector.BooleanSelector()
}
),
)
def _async_user_schema(
self,
transmitters: list[str],
user_input: dict[str, Any] | None = None,
) -> vol.Schema:
"""Build the one-step add form schema."""
if user_input is None:
user_input = {}
suggested_values: dict[str, Any] = {
CONF_TRANSMITTER: transmitters[0],
CONF_CHANNEL: 1,
CONF_GROUP: False,
}
suggested_values.update(user_input)
return self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_TRANSMITTER): selector.EntitySelector(
selector.EntitySelectorConfig(include_entities=transmitters),
),
vol.Required(CONF_DEVICE_ID): vol.All(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=0x3FFFFFF,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Coerce(int),
),
vol.Required(CONF_CHANNEL): vol.All(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=16,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Coerce(int),
),
vol.Required(CONF_GROUP): selector.BooleanSelector(),
}
),
suggested_values,
)
@@ -1,19 +0,0 @@
"""Constants and helpers for the KlikAanKlikUit (Kaku) integration."""
from typing import Final
from homeassistant.const import CONF_DEVICE_ID as HA_CONF_DEVICE_ID
DOMAIN: Final = "klik_aan_klik_uit"
CONF_TRANSMITTER: Final = "transmitter"
CONF_DEVICE_ID: Final = HA_CONF_DEVICE_ID
CONF_CHANNEL: Final = "channel"
CONF_GROUP: Final = "group"
REPEAT_COUNT_LEARN: Final = 10 # Higher repeats for learning/pairing
def format_device_summary(device_id: int, channel: int, group: bool) -> str:
"""Return a concise summary string for the configured device."""
group_text = "on" if group else "off"
return f"ID {device_id} CH {channel} Group {group_text}"
@@ -1,11 +0,0 @@
{
"domain": "klik_aan_klik_uit",
"name": "KlikAanKlikUit",
"codeowners": ["@Phunkafizer"],
"config_flow": true,
"dependencies": ["radio_frequency"],
"documentation": "https://www.home-assistant.io/integrations/klik_aan_klik_uit",
"integration_type": "device",
"iot_class": "assumed_state",
"quality_scale": "bronze"
}
@@ -1,68 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not provide service actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not provide service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow:
status: exempt
comment: This integration uses local RF commands and has no account auth.
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
entity-category: done
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: This integration does not use outbound web requests.
strict-typing: todo
@@ -1,41 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_compatible_transmitters": "No compatible radio frequency transmitter is available for this integration.",
"no_transmitters": "[%key:common::config_flow::abort::no_radio_frequency_transmitters%]"
},
"error": {},
"step": {
"pairing_mode": {
"description": "Bring device into learn mode by pushing it's button for more than 2 seconds, then press Ok.",
"title": "Pair device"
},
"pairing_result": {
"data": {
"device_responded": "Did the device respond?"
},
"data_description": {
"device_responded": "Select Yes if the target device reacted to the learn command."
},
"description": "Select Yes to continue setup. Select No to return to learn mode and resend the learn command.",
"title": "Confirm pairing"
},
"user": {
"data": {
"channel": "Channel",
"device_id": "Device ID",
"group": "Group",
"transmitter": "[%key:common::config_flow::data::radio_frequency_transmitter%]"
},
"data_description": {
"channel": "The channel of the target KlikAanKlikUit device (1-16).",
"device_id": "The unique KlikAanKlikUit device ID.",
"group": "Whether to send commands to the group address instead of a single device.",
"transmitter": "[%key:common::config_flow::data_description::radio_frequency_transmitter%]"
},
"description": "Choose the transmitter and configure your device settings."
}
}
}
}
@@ -1,112 +0,0 @@
"""Switch platform for KlikAanKlikUit RC on/off control."""
from typing import Any
from rf_protocols.commands.kaku import KakuCommand
from homeassistant.components.radio_frequency import async_send_command
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import CONF_DEVICE_ID, STATE_ON, STATE_UNAVAILABLE
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.restore_state import RestoreEntity
from . import KlikAanKlikUitConfigEntry
from .const import CONF_CHANNEL, CONF_GROUP, DOMAIN, format_device_summary
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
config_entry: KlikAanKlikUitConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the KlikAanKlikUit switch entity."""
async_add_entities([KlikAanKlikUitSwitch(config_entry)])
class KlikAanKlikUitSwitch(SwitchEntity, RestoreEntity):
"""Switch entity for KlikAanKlikUit devices."""
_attr_has_entity_name = True
_attr_name = "Output"
_attr_should_poll = False
def __init__(self, entry: KlikAanKlikUitConfigEntry) -> None:
"""Initialize the switch."""
self._transmitter = entry.runtime_data.transmitter_entity_id
self._device_id: int = entry.data[CONF_DEVICE_ID]
self._channel: int = entry.data[CONF_CHANNEL]
self._group: bool = entry.data[CONF_GROUP]
self._attr_unique_id = entry.entry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="KlikAanKlikUit",
model="KlikAanKlikUit RC device",
sw_version=format_device_summary(
self._device_id, self._channel, self._group
),
)
async def async_added_to_hass(self) -> None:
"""Subscribe to transmitter state and restore last switch state."""
await super().async_added_to_hass()
transmitter_entity_id = er.async_validate_entity_id(
er.async_get(self.hass), self._transmitter
)
@callback
def _async_transmitter_state_changed(
event: Event[EventStateChangedData],
) -> None:
new_state = event.data["new_state"]
available = new_state is not None and new_state.state != STATE_UNAVAILABLE
if available != self._attr_available:
self._attr_available = available
self.async_write_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass,
[transmitter_entity_id],
_async_transmitter_state_changed,
)
)
transmitter_state = self.hass.states.get(transmitter_entity_id)
self._attr_available = (
transmitter_state is not None
and transmitter_state.state != STATE_UNAVAILABLE
)
if (last_state := await self.async_get_last_state()) is not None:
self._attr_is_on = last_state.state == STATE_ON
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self._async_send(True)
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self._async_send(False)
self._attr_is_on = False
self.async_write_ha_state()
async def _async_send(self, on: bool) -> None:
"""Send on/off command."""
command = KakuCommand(
id=self._device_id,
group=self._group,
channel=self._channel,
on=on,
)
await async_send_command(
self.hass, self._transmitter, command, context=self._context
)
@@ -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
@@ -1249,7 +1249,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 = [
@@ -1262,6 +1262,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,
@@ -1271,21 +1280,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")
@@ -1,38 +0,0 @@
"""The MELCloud Home integration."""
from aiomelcloudhome import MELCloudHome, MelCloudHomeAuth
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
PLATFORMS: list[Platform] = [Platform.CLIMATE]
async def async_setup_entry(
hass: HomeAssistant, entry: MelCloudHomeConfigEntry
) -> bool:
"""Set up MELCloud Home from a config entry."""
session = async_get_clientsession(hass)
auth = MelCloudHomeAuth(
username=entry.data[CONF_EMAIL],
password=entry.data[CONF_PASSWORD],
session=session,
)
client = MELCloudHome(auth=auth, session=session)
coordinator = MelCloudHomeCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: MelCloudHomeConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,372 +0,0 @@
"""Climate platform for MELCloud Home."""
from typing import Any
from aiomelcloudhome import (
ATAFanSpeed,
ATAOperationMode,
ATAUnit,
ATAVaneHorizontal,
ATAVaneVertical,
ATWUnit,
ATWZoneMode,
)
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWZoneEntity
ATA_HVAC_MODE_TO_OPERATION: dict[HVACMode, ATAOperationMode] = {
HVACMode.HEAT: ATAOperationMode.HEAT,
HVACMode.COOL: ATAOperationMode.COOL,
HVACMode.AUTO: ATAOperationMode.AUTOMATIC,
HVACMode.DRY: ATAOperationMode.DRY,
HVACMode.FAN_ONLY: ATAOperationMode.FAN,
}
ATA_OPERATION_TO_HVAC_MODE: dict[ATAOperationMode, HVACMode] = {
value: key for key, value in ATA_HVAC_MODE_TO_OPERATION.items()
}
ATA_FAN_SPEED_TO_HA: dict[ATAFanSpeed, str] = {
ATAFanSpeed.AUTO: "auto",
ATAFanSpeed.ONE: "speed_1",
ATAFanSpeed.TWO: "speed_2",
ATAFanSpeed.THREE: "speed_3",
ATAFanSpeed.FOUR: "speed_4",
ATAFanSpeed.FIVE: "speed_5",
}
HA_FAN_SPEED_TO_ATA: dict[str, ATAFanSpeed] = {
value: key for key, value in ATA_FAN_SPEED_TO_HA.items()
}
ATA_VANE_VERTICAL_TO_HA: dict[ATAVaneVertical, str] = {
ATAVaneVertical.AUTO: "auto",
ATAVaneVertical.SWING: "swing",
ATAVaneVertical.ONE: "position_1",
ATAVaneVertical.TWO: "position_2",
ATAVaneVertical.THREE: "position_3",
ATAVaneVertical.FOUR: "position_4",
ATAVaneVertical.FIVE: "position_5",
}
HA_VANE_VERTICAL_TO_ATA: dict[str, ATAVaneVertical] = {
value: key for key, value in ATA_VANE_VERTICAL_TO_HA.items()
}
ATA_VANE_HORIZONTAL_TO_HA: dict[ATAVaneHorizontal, str] = {
ATAVaneHorizontal.AUTO: "auto",
ATAVaneHorizontal.SWING: "swing",
ATAVaneHorizontal.LEFT: "left",
ATAVaneHorizontal.LEFT_CENTRE: "left_centre",
ATAVaneHorizontal.CENTRE: "centre",
ATAVaneHorizontal.RIGHT_CENTRE: "right_centre",
ATAVaneHorizontal.RIGHT: "right",
}
HA_VANE_HORIZONTAL_TO_ATA: dict[str, ATAVaneHorizontal] = {
value: key for key, value in ATA_VANE_HORIZONTAL_TO_HA.items()
}
ATW_ZONE_MODE_TO_HVAC_MODE: dict[ATWZoneMode, HVACMode] = {
ATWZoneMode.HEAT_ROOM_TEMPERATURE: HVACMode.HEAT,
ATWZoneMode.HEAT_FLOW_TEMPERATURE: HVACMode.HEAT,
ATWZoneMode.HEAT_CURVE: HVACMode.HEAT,
ATWZoneMode.COOL_ROOM_TEMPERATURE: HVACMode.COOL,
ATWZoneMode.COOL_FLOW_TEMPERATURE: HVACMode.COOL,
}
HVAC_MODE_TO_ATW_ZONE_MODE: dict[HVACMode, ATWZoneMode] = {
HVACMode.HEAT: ATWZoneMode.HEAT_ROOM_TEMPERATURE,
HVACMode.COOL: ATWZoneMode.COOL_ROOM_TEMPERATURE,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: MelCloudHomeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MELCloud Home climate entities from a config entry."""
coordinator = entry.runtime_data
def _async_add_new_ata_units(units: list[ATAUnit]) -> None:
async_add_entities(ATAClimateEntity(coordinator, unit) for unit in units)
def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
# Erwin: create zone 1 for all units, and zone 2 only when the unit supports it.
async_add_entities(
ATWZoneClimateEntity(coordinator, unit, zone_number)
for unit in units
for zone_number in (
[1, 2]
if (unit.capabilities and unit.capabilities.has_zone2)
or (unit.capabilities is None and unit.has_zone2)
else [1]
)
)
coordinator.new_ata_callbacks.append(_async_add_new_ata_units)
coordinator.new_atw_callbacks.append(_async_add_new_atw_units)
_async_add_new_ata_units(list(coordinator.ata_units.values()))
_async_add_new_atw_units(list(coordinator.atw_units.values()))
class ATAClimateEntity(MelCloudHomeATAUnitEntity, ClimateEntity):
"""Climate entity for a MELCloud Home Air-to-Air unit."""
_attr_translation_key = "ata_unit"
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_swing_modes = list(ATA_VANE_VERTICAL_TO_HA.values())
_attr_swing_horizontal_modes = list(ATA_VANE_HORIZONTAL_TO_HA.values())
def __init__(self, coordinator: MelCloudHomeCoordinator, unit: ATAUnit) -> None:
"""Initialize the entity."""
super().__init__(coordinator, unit)
features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
if unit.settings is not None:
if unit.settings.get("VaneVerticalDirection") is not None:
features |= ClimateEntityFeature.SWING_MODE
if unit.settings.get("VaneHorizontalDirection") is not None:
features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
self._attr_supported_features = features
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return HVAC modes supported by this unit based on its capabilities."""
if self.unit.capabilities is None:
return [
HVACMode.OFF,
HVACMode.HEAT,
HVACMode.COOL,
HVACMode.AUTO,
HVACMode.DRY,
HVACMode.FAN_ONLY,
]
modes = [HVACMode.OFF, HVACMode.HEAT]
if self.unit.capabilities.has_cool_operation_mode is not False:
modes.append(HVACMode.COOL)
if self.unit.capabilities.has_auto_operation_mode is not False:
modes.append(HVACMode.AUTO)
if self.unit.capabilities.has_dry_operation_mode is not False:
modes.append(HVACMode.DRY)
if self.unit.capabilities.has_fan_operation_mode is not False:
modes.append(HVACMode.FAN_ONLY)
return modes
@property
def fan_modes(self) -> list[str]:
"""Return fan modes supported by this unit based on its capabilities."""
capabilities = self.unit.capabilities
number = (
capabilities.number_of_fan_speeds
if capabilities is not None
and capabilities.number_of_fan_speeds is not None
else len(ATA_FAN_SPEED_TO_HA) - 1
)
all_speeds = list(ATA_FAN_SPEED_TO_HA.values())
return [all_speeds[0], *all_speeds[1 : number + 1]]
@property
def current_temperature(self) -> float | None:
"""Return the current room temperature."""
return self.unit.room_temperature if self.unit else None
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
return self.unit.set_temperature if self.unit else None
@property
def hvac_mode(self) -> HVACMode:
"""Return the current HVAC mode."""
return (
ATA_OPERATION_TO_HVAC_MODE.get(self.unit.operation_mode, HVACMode.OFF)
if self.unit.power and self.unit.operation_mode
else HVACMode.OFF
)
@property
def fan_mode(self) -> str | None:
"""Return the current fan mode."""
return (
ATA_FAN_SPEED_TO_HA.get(self.unit.set_fan_speed)
if self.unit.set_fan_speed is not None
else None
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
if hvac_mode == HVACMode.OFF:
await self.coordinator.client.control_ata_unit(self._unit_id, power=False)
else:
await self.coordinator.client.control_ata_unit(
self._unit_id,
power=True,
operation_mode=ATA_HVAC_MODE_TO_OPERATION[hvac_mode],
)
await self.coordinator.async_request_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the target temperature."""
await self.coordinator.client.control_ata_unit(
self._unit_id, set_temperature=kwargs[ATTR_TEMPERATURE]
)
await self.coordinator.async_request_refresh()
@property
def swing_mode(self) -> str:
"""Return the current vertical vane direction."""
return ATA_VANE_VERTICAL_TO_HA[self.unit.settings["VaneVerticalDirection"]]
@property
def swing_horizontal_mode(self) -> str:
"""Return the current horizontal vane direction."""
return ATA_VANE_HORIZONTAL_TO_HA[self.unit.settings["VaneHorizontalDirection"]]
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
"""Set the horizontal vane direction."""
await self.coordinator.client.control_ata_unit(
self._unit_id,
vane_horizontal_direction=HA_VANE_HORIZONTAL_TO_ATA[swing_horizontal_mode],
)
await self.coordinator.async_request_refresh()
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set the vertical vane direction."""
await self.coordinator.client.control_ata_unit(
self._unit_id, vane_vertical_direction=HA_VANE_VERTICAL_TO_ATA[swing_mode]
)
await self.coordinator.async_request_refresh()
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set the fan mode."""
await self.coordinator.client.control_ata_unit(
self._unit_id, set_fan_speed=HA_FAN_SPEED_TO_ATA[fan_mode]
)
await self.coordinator.async_request_refresh()
async def async_turn_on(self) -> None:
"""Turn the unit on."""
await self.coordinator.client.control_ata_unit(self._unit_id, power=True)
await self.coordinator.async_request_refresh()
async def async_turn_off(self) -> None:
"""Turn the unit off."""
await self.coordinator.client.control_ata_unit(self._unit_id, power=False)
await self.coordinator.async_request_refresh()
class ATWZoneClimateEntity(MelCloudHomeATWZoneEntity, ClimateEntity):
"""Climate entity for a MELCloud Home ATW zone."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return HVAC modes supported by this zone based on unit capabilities."""
modes = [HVACMode.OFF, HVACMode.HEAT]
if (
self.unit.capabilities is None
or self.unit.capabilities.has_cooling_mode is not False
):
modes.append(HVACMode.COOL)
return modes
@property
def _zone_mode(self) -> ATWZoneMode | None:
"""Return the current ATW zone mode."""
if self.zone_number == 1:
return self.unit.operation_mode_zone1
return self.unit.operation_mode_zone2
@property
def current_temperature(self) -> float | None:
"""Return the current zone temperature."""
return (
self.unit.room_temperature_zone1
if self.zone_number == 1
else self.unit.room_temperature_zone2
)
@property
def target_temperature(self) -> float | None:
"""Return the target zone temperature."""
return (
self.unit.set_temperature_zone1
if self.zone_number == 1
else self.unit.set_temperature_zone2
)
@property
def hvac_mode(self) -> HVACMode:
"""Return the current HVAC mode."""
return (
ATW_ZONE_MODE_TO_HVAC_MODE.get(self._zone_mode, HVACMode.OFF)
if self.unit.power and self._zone_mode
else HVACMode.OFF
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
if hvac_mode == HVACMode.OFF:
await self.coordinator.client.control_atw_unit(self._unit_id, power=False)
else:
zone_mode = HVAC_MODE_TO_ATW_ZONE_MODE[hvac_mode]
if self.zone_number == 1:
await self.coordinator.client.control_atw_unit(
self._unit_id,
power=True,
operation_mode_zone1=zone_mode,
)
else:
await self.coordinator.client.control_atw_unit(
self._unit_id,
power=True,
operation_mode_zone2=zone_mode,
)
await self.coordinator.async_request_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the target temperature."""
temperature = kwargs[ATTR_TEMPERATURE]
if self.zone_number == 1:
await self.coordinator.client.control_atw_unit(
self._unit_id, set_temperature_zone1=temperature
)
else:
await self.coordinator.client.control_atw_unit(
self._unit_id, set_temperature_zone2=temperature
)
await self.coordinator.async_request_refresh()
async def async_turn_on(self) -> None:
"""Turn the zone on."""
await self.coordinator.client.control_atw_unit(self._unit_id, power=True)
await self.coordinator.async_request_refresh()
async def async_turn_off(self) -> None:
"""Turn the zone off."""
await self.coordinator.client.control_atw_unit(self._unit_id, power=False)
await self.coordinator.async_request_refresh()
@@ -1,94 +0,0 @@
"""Config flow for MELCloud Home."""
import logging
from typing import Any
from aiomelcloudhome import MELCloudHome, MelCloudHomeAuth
from aiomelcloudhome.exceptions import (
MelCloudHomeAuthenticationError,
MelCloudHomeConnectionError,
MelCloudHomeTimeoutError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
)
class MelCloudHomeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for MELCloud Home."""
async def _async_validate_credentials(
self, email: str, password: str
) -> tuple[dict[str, str], str | None]:
"""Validate credentials against MELCloud Home API."""
session = async_get_clientsession(self.hass)
auth = MelCloudHomeAuth(username=email, password=password, session=session)
client = MELCloudHome(auth=auth, session=session)
errors: dict[str, str] = {}
user_id: str | None = None
try:
context = await client.get_context()
except MelCloudHomeAuthenticationError:
errors["base"] = "invalid_auth"
except MelCloudHomeConnectionError:
errors["base"] = "cannot_connect"
except MelCloudHomeTimeoutError:
errors["base"] = "timeout_connect"
except Exception:
_LOGGER.exception(
"Unexpected error while validating MELCloud Home credentials"
)
errors["base"] = "unknown"
else:
user_id = context.id
return errors, user_id
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
errors, user_id = await self._async_validate_credentials(
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
)
if not errors:
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_EMAIL],
data={
CONF_EMAIL: user_input[CONF_EMAIL],
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
@@ -1,3 +0,0 @@
"""Constants for the MELCloud Home integration."""
DOMAIN = "melcloud_home"
@@ -1,114 +0,0 @@
"""Coordinator for MELCloud Home."""
from collections.abc import Callable
from datetime import timedelta
import logging
from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome, UserContext
from aiomelcloudhome.exceptions import (
MelCloudHomeAuthenticationError,
MelCloudHomeConnectionError,
MelCloudHomeTimeoutError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(seconds=60)
type MelCloudHomeConfigEntry = ConfigEntry[MelCloudHomeCoordinator]
class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]):
"""Coordinator to manage fetching MELCloud Home data."""
config_entry: MelCloudHomeConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: MelCloudHomeConfigEntry,
client: MELCloudHome,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
)
self.client = client
self.ata_units: dict[str, ATAUnit] = {}
self.atw_units: dict[str, ATWUnit] = {}
self.known_ata: set[str] = set()
self.known_atw: set[str] = set()
self.new_ata_callbacks: list[Callable[[list[ATAUnit]], None]] = []
self.new_atw_callbacks: list[Callable[[list[ATWUnit]], None]] = []
def _notify_new_units(self, data: UserContext) -> None:
"""Notify callbacks when new units are discovered."""
current_ata = [
unit for building in data.buildings for unit in building.air_to_air_units
]
self.ata_units = {unit.id: unit for unit in current_ata}
current_ata_ids = {unit.id for unit in current_ata}
self.known_ata &= current_ata_ids
new_ata_ids = current_ata_ids - self.known_ata
new_ata_units = [unit for unit in current_ata if unit.id in new_ata_ids]
if new_ata_units:
_LOGGER.debug("Discovered new ATA units: %s", new_ata_units)
self.known_ata.update(unit.id for unit in new_ata_units)
for ata_callback in self.new_ata_callbacks:
ata_callback(new_ata_units)
current_atw_units = [
unit for building in data.buildings for unit in building.air_to_water_units
]
self.atw_units = {unit.id: unit for unit in current_atw_units}
current_atw_ids = {unit.id for unit in current_atw_units}
self.known_atw &= current_atw_ids
new_atw_ids = current_atw_ids - self.known_atw
new_atw_units = [unit for unit in current_atw_units if unit.id in new_atw_ids]
if new_atw_units:
_LOGGER.debug("Discovered new ATW units: %s", new_atw_units)
self.known_atw.update(unit.id for unit in new_atw_units)
for atw_callback in self.new_atw_callbacks:
atw_callback(new_atw_units)
async def _async_update_data(self) -> UserContext:
"""Fetch data from the MELCloud Home API."""
try:
data = await self.client.get_context()
except MelCloudHomeAuthenticationError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except MelCloudHomeConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except MelCloudHomeTimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err
else:
return data
@callback
def _async_refresh_finished(self) -> None:
"""Notify entity callbacks after coordinator data has been updated."""
if self.data is not None:
self._notify_new_units(self.data)
@@ -1,84 +0,0 @@
"""Base entities for MELCloud Home."""
from abc import abstractmethod
from aiomelcloudhome import ATAUnit, ATWUnit
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import MelCloudHomeCoordinator
class MelCloudHomeEntity(CoordinatorEntity[MelCloudHomeCoordinator]):
"""Base entity for MELCloud Home."""
_attr_has_entity_name = True
_attr_name: str | None = None
class MelCloudHomeUnitEntity[_UnitT: (ATAUnit, ATWUnit)](MelCloudHomeEntity):
"""Base entity for a MELCloud Home unit."""
def __init__(self, coordinator: MelCloudHomeCoordinator, unit: _UnitT) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._unit_id = unit.id
self._attr_unique_id = unit.id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unit.id)},
name=unit.name,
manufacturer="Mitsubishi Electric",
)
@abstractmethod
def _units_dict(self) -> dict[str, _UnitT]:
"""Return the coordinator's units dict keyed by id."""
@property
def available(self) -> bool:
"""Return if the entity is available."""
return super().available and self._unit_id in self._units_dict()
@property
def unit(self) -> _UnitT:
"""Return the current unit state from coordinator data."""
return self._units_dict()[self._unit_id]
class MelCloudHomeATAUnitEntity(MelCloudHomeUnitEntity[ATAUnit]):
"""Base entity for a MELCloud Home Air-to-Air unit."""
def _units_dict(self) -> dict[str, ATAUnit]:
"""Return ATA units dict from coordinator."""
return self.coordinator.ata_units
class MelCloudHomeATWUnitEntity(MelCloudHomeUnitEntity[ATWUnit]):
"""Base entity for a MELCloud Home Air-to-Water unit."""
def _units_dict(self) -> dict[str, ATWUnit]:
"""Return ATW units dict from coordinator."""
return self.coordinator.atw_units
class MelCloudHomeATWZoneEntity(MelCloudHomeATWUnitEntity):
"""Base entity for an ATW zone entity."""
def __init__(
self,
coordinator: MelCloudHomeCoordinator,
unit: ATWUnit,
zone_number: int,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, unit)
self._zone_number = zone_number
self._attr_unique_id = f"{unit.id}_zone_{zone_number}"
self._attr_name = f"Zone {zone_number}"
@property
def zone_number(self) -> int:
"""Return the zone number."""
return self._zone_number
@@ -1,12 +0,0 @@
{
"domain": "melcloud_home",
"name": "MELCloud Home",
"codeowners": ["@erwindouna"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/melcloud_home",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["aiomelcloudhome"],
"quality_scale": "bronze",
"requirements": ["aiomelcloudhome==0.1.5"]
}
@@ -1,68 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: No custom actions defined.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: No custom actions defined.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Coordinator handles polling.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: No custom actions defined.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
@@ -1,77 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"timeout_connect": "Timeout while communicating with MELCloud Home API",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "Email address for your MELCloud Home account.",
"password": "Password for your MELCloud Home account."
},
"description": "Login to MELCloud Home with the email address and password associated with your account."
}
}
},
"entity": {
"climate": {
"ata_unit": {
"state_attributes": {
"fan_mode": {
"state": {
"auto": "[%key:common::state::auto%]",
"speed_1": "Speed 1",
"speed_2": "Speed 2",
"speed_3": "Speed 3",
"speed_4": "Speed 4",
"speed_5": "Speed 5"
}
},
"swing_horizontal_mode": {
"state": {
"auto": "[%key:common::state::auto%]",
"centre": "Centre",
"left": "Left",
"left_centre": "Left centre",
"right": "Right",
"right_centre": "Right centre",
"swing": "Swing"
}
},
"swing_mode": {
"state": {
"auto": "[%key:common::state::auto%]",
"position_1": "Position 1",
"position_2": "Position 2",
"position_3": "Position 3",
"position_4": "Position 4",
"position_5": "Position 5",
"swing": "Swing"
}
}
}
}
}
},
"exceptions": {
"cannot_connect": {
"message": "Error communicating with MELCloud Home API: {error}"
},
"invalid_auth": {
"message": "An error occurred while trying to authenticate: {error}"
},
"timeout_connect": {
"message": "Timeout while communicating with MELCloud Home API: {error}"
}
}
}
@@ -42,23 +42,12 @@ class MitsubishiComfortCoordinator(DataUpdateCoordinator[IndoorUnit | KumoStatio
try:
success = await self.device.update_status()
except Exception as err:
# The user-facing UpdateFailed message is translated and omits the IP;
# log it here so the failing address is visible in debug logs.
_LOGGER.debug(
"Error polling %s at %s: %s",
self.device.name,
self.device.address,
err,
)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"device_name": self.device.name},
) from err
if not success:
_LOGGER.debug(
"%s at %s returned no data", self.device.name, self.device.address
)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
+15 -16
View File
@@ -87,6 +87,7 @@ from .const import (
DEFAULT_RETAIN,
DOMAIN,
ENTITY_PLATFORMS,
ENTRY_OPTION_FIELDS,
MQTT_CONNECTION_STATE,
PROTOCOL_5,
PROTOCOL_311,
@@ -153,6 +154,7 @@ __all__ = [
"DEFAULT_RETAIN",
"DOMAIN",
"ENTITY_PLATFORMS",
"ENTRY_OPTION_FIELDS",
"MQTT",
"MQTT_BASE_SCHEMA",
"MQTT_CONNECTION_STATE",
@@ -466,30 +468,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate the config entry to the latest version."""
"""Migrate the options from config entry data."""
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
data: dict[str, Any] = dict(entry.data)
options: dict[str, Any] = dict(entry.options)
if entry.version == 1 and entry.minor_version < 2:
for key in (
CONF_DISCOVERY,
CONF_DISCOVERY_PREFIX,
"birth_message",
"will_message",
):
# Can be removed when the config entry is bumped to version 2.1
# with HA Core 2026.7.0. Read support for version 2.1 is expected with 2026.1
# From 2026.7 we will write version 2.1
for key in ENTRY_OPTION_FIELDS:
if key not in data:
continue
options[key] = data.pop(key)
# Bump config entry to version 2.1
hass.config_entries.async_update_entry(
entry,
data=data,
options=options,
version=2,
minor_version=1,
)
# Write version 1.2 for backwards compatibility
hass.config_entries.async_update_entry(
entry,
data=data,
options=options,
version=1,
minor_version=2,
)
_LOGGER.debug(
"Migration to version %s.%s successful", entry.version, entry.minor_version
@@ -273,7 +273,6 @@ ABBREVIATIONS = {
"l_ver_t": "latest_version_topic",
"l_ver_tpl": "latest_version_template",
"pl_inst": "payload_install",
"vis": "visible_by_default",
}
DEVICE_ABBREVIATIONS = {
+12 -2
View File
@@ -5,7 +5,7 @@ import logging
import jinja2
from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature
from homeassistant.const import CONF_PAYLOAD, Platform
from homeassistant.const import CONF_DISCOVERY, CONF_PAYLOAD, Platform
from homeassistant.exceptions import TemplateError
ATTR_DISCOVERY_HASH = "discovery_hash"
@@ -246,7 +246,6 @@ CONF_TILT_STATE_OPTIMISTIC = "tilt_optimistic"
CONF_TRANSITION = "transition"
CONF_URL_TEMPLATE = "url_template"
CONF_URL_TOPIC = "url_topic"
CONF_VISIBLE_BY_DEFAULT = "visible_by_default"
CONF_XY_COMMAND_TEMPLATE = "xy_command_template"
CONF_XY_COMMAND_TOPIC = "xy_command_topic"
CONF_XY_STATE_TOPIC = "xy_state_topic"
@@ -386,6 +385,17 @@ PAYLOAD_NONE = "None"
CONFIG_ENTRY_VERSION = 2
CONFIG_ENTRY_MINOR_VERSION = 1
# Split mqtt entry data and options
# Can be removed when config entry is bumped to version 2.1
# with HA Core 2026.7.0. Read support for version 2.1 is expected from 2026.1
# From 2026.7 we will write version 2.1
ENTRY_OPTION_FIELDS = (
CONF_DISCOVERY,
CONF_DISCOVERY_PREFIX,
"birth_message",
"will_message",
)
ENTITY_PLATFORMS = [
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
+6 -35
View File
@@ -95,7 +95,6 @@ from .const import (
CONF_SW_VERSION,
CONF_TOPIC,
CONF_VIA_DEVICE,
CONF_VISIBLE_BY_DEFAULT,
DOMAIN,
MQTT_CONNECTION_STATE,
)
@@ -1429,44 +1428,19 @@ class MqttEntity(
# Plan to update the entity_id based on `default_entity_id`
# if a deleted entity was found
self._update_registry_entity_id = self.entity_id
if (
reenable_condition := (
deleted_entry
and self._config[CONF_ENABLED_BY_DEFAULT]
and deleted_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
)
) or (
deleted_entry
and self._config[CONF_VISIBLE_BY_DEFAULT]
and deleted_entry.hidden_by is not None
self._config[CONF_ENABLED_BY_DEFAULT]
and deleted_entry
and deleted_entry.disabled_by is not None
):
# Enable previous deleted entity,
# if it was not disabled by the user.
# Only reset hidden by flag if it was not hidden by the user.
if (
deleted_entry.hidden_by is er.RegistryEntryHider.USER
and self._config[CONF_VISIBLE_BY_DEFAULT]
):
_LOGGER.info(
"Restored entity %s was configured as visible by default, "
"but was hidden by the user before, and will remain hidden",
self.entity_id,
)
if deleted_entry.hidden_by is er.RegistryEntryHider.USER:
hidden_by: er.RegistryEntryHider | None = er.RegistryEntryHider.USER
else:
hidden_by = (
None
if self._config[CONF_VISIBLE_BY_DEFAULT]
else er.RegistryEntryHider.INTEGRATION
)
# Enable previous deleted entity and enable it
recreated_entry = entity_registry.async_get_or_create(
entity_platform, DOMAIN, self.unique_id
)
entity_registry.async_update_entity(
recreated_entry.entity_id,
disabled_by=None if reenable_condition else UNDEFINED,
hidden_by=hidden_by,
disabled_by=None,
)
if discovery_data is None:
@@ -1615,9 +1589,6 @@ class MqttEntity(
self._attr_entity_registry_enabled_default = bool(
config.get(CONF_ENABLED_BY_DEFAULT, True)
)
self._attr_entity_registry_visible_default = bool(
config.get(CONF_VISIBLE_BY_DEFAULT, True)
)
self._attr_icon = config.get(CONF_ICON)
self._attr_entity_picture = config.get(CONF_ENTITY_PICTURE)
# Set the entity name if needed
-2
View File
@@ -52,7 +52,6 @@ from .const import (
CONF_SW_VERSION,
CONF_TOPIC,
CONF_VIA_DEVICE,
CONF_VISIBLE_BY_DEFAULT,
DEFAULT_PAYLOAD_AVAILABLE,
DEFAULT_PAYLOAD_NOT_AVAILABLE,
ENTITY_PLATFORMS,
@@ -185,7 +184,6 @@ MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
vol.Optional(CONF_DEFAULT_ENTITY_ID): cv.string,
vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval,
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(CONF_VISIBLE_BY_DEFAULT, default=True): cv.boolean,
}
)
@@ -26,31 +26,31 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: done
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration has no options flow.
docs-installation-parameters: done
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery: done
discovery-update-info: done
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: Integration supports a single device per config entry.
@@ -71,4 +71,4 @@ rules:
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
strict-typing: todo
@@ -20,5 +20,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.21.0"]
"requirements": ["reolink-aio==0.20.1"]
}
@@ -51,7 +51,7 @@ class ShoppingTodoListEntity(TodoListEntity):
)
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update an item in the To-do list."""
"""Update an item to the To-do list."""
data = {
"name": item.summary,
"complete": item.status == TodoItemStatus.COMPLETED,
@@ -64,7 +64,7 @@ class ShoppingTodoListEntity(TodoListEntity):
) from err
async def async_delete_todo_items(self, uids: list[str]) -> None:
"""Delete items from the To-do list."""
"""Add an item to the To-do list."""
await self._data.async_remove_items(set(uids))
async def async_move_todo_item(
+3 -2
View File
@@ -14,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import (
CONF_FALLBACK,
@@ -156,7 +155,9 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Tado resets somewhere between 12:00 and 13:00, Berlin time
# So let's pretend we're in Berlin...
reset_time = dt_util.now(ZoneInfo("Europe/Berlin"))
reset_time = datetime.now( # pylint: disable=home-assistant-enforce-now
ZoneInfo("Europe/Berlin")
)
today_reset = datetime.combine(
reset_time.date(),
+1 -1
View File
@@ -125,7 +125,7 @@
"name": "Rename item"
},
"status": {
"description": "A status for the to-do item.",
"description": "A status or confirmation of the to-do item.",
"name": "Set status"
}
},
@@ -8,10 +8,8 @@ from homeassistant.const import Platform
LOGGER: Logger = getLogger(__package__)
# The free plan is formally limited to 10 requests/minute
# But real world says 5 requests/minute is the real limit
# Opened a ticket with support with no response for 2 months
COORDINATOR_UPDATE_INTERVAL: timedelta = timedelta(seconds=15)
# The free plan is limited to 10 requests/minute
COORDINATOR_UPDATE_INTERVAL: timedelta = timedelta(seconds=10)
DOMAIN: Final = "uptimerobot"
PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
+1 -1
View File
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/v2c",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pytrydan==1.0.2"]
"requirements": ["pytrydan==1.0.1"]
}
@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["verisure"],
"requirements": ["vsure==2.7.1"]
"requirements": ["vsure==2.7.0"]
}
-2
View File
@@ -386,7 +386,6 @@ FLOWS = {
"kegtron",
"keymitt_ble",
"kiosker",
"klik_aan_klik_uit",
"kmtronic",
"knocki",
"knx",
@@ -448,7 +447,6 @@ FLOWS = {
"medcom_ble",
"media_extractor",
"melcloud",
"melcloud_home",
"melnor",
"met",
"met_eireann",
-12
View File
@@ -3581,12 +3581,6 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
"klik_aan_klik_uit": {
"name": "KlikAanKlikUit",
"integration_type": "device",
"config_flow": true,
"iot_class": "assumed_state"
},
"kmtronic": {
"name": "KMtronic",
"integration_type": "device",
@@ -4172,12 +4166,6 @@
"config_flow": false,
"iot_class": "local_polling"
},
"melcloud_home": {
"name": "MELCloud Home",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"melissa": {
"name": "Melissa",
"integration_type": "hub",
+15 -3
View File
@@ -1,6 +1,6 @@
"""Helper to track the current http request."""
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Container, Mapping
from contextvars import ContextVar
from http import HTTPStatus
import inspect
@@ -20,7 +20,7 @@ import voluptuous as vol
from homeassistant import exceptions
from homeassistant.const import CONTENT_TYPE_JSON
from homeassistant.core import Context, HomeAssistant, is_callback
from homeassistant.core import Context, HomeAssistant, callback, is_callback
from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS, format_unserializable_data
from .json import find_paths_unserializable_data, json_bytes, json_dumps
@@ -55,7 +55,13 @@ def request_handler_factory(
authenticated = request.get(KEY_AUTHENTICATED, False)
if view.requires_auth and not authenticated:
if view.use_query_token_for_auth and not authenticated:
token = request.query.get("token")
if token and token in view.get_valid_auth_tokens(request.match_info):
_LOGGER.debug("Authenticated request with query token")
authenticated = True
if (view.requires_auth or view.use_query_token_for_auth) and not authenticated:
# Import here to avoid circular dependency with network.py
from .network import NoURLAvailableError, get_url # noqa: PLC0415
@@ -129,6 +135,7 @@ class HomeAssistantView:
extra_urls: list[str] = []
# Views inheriting from this class can override this
requires_auth = True
use_query_token_for_auth = False
cors_allowed = False
@staticmethod
@@ -204,3 +211,8 @@ class HomeAssistantView:
if allow_cors:
for route in routes:
allow_cors(route)
@callback
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
return ()
+11
View File
@@ -268,6 +268,17 @@ def async_get(hass: HomeAssistant) -> LabelRegistry:
return LabelRegistry(hass)
@callback
def async_get_missing_label_ids(
hass: HomeAssistant, label_ids: Iterable[str]
) -> set[str]:
"""Return the label ids which are missing from the label registry."""
registry = async_get(hass)
return {
label_id for label_id in label_ids if registry.async_get_label(label_id) is None
}
async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None:
"""Load label registry."""
assert DATA_REGISTRY not in hass.data
+2 -2
View File
@@ -37,7 +37,7 @@ go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==6.8.3
hass-nabucasa==2.2.0
hassil==3.7.0
hassil==3.6.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260527.5
home-assistant-intents==2026.6.1
@@ -70,7 +70,7 @@ standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.9
urllib3>=2.0
uv==0.11.19
uv==0.11.18
voluptuous-openapi==0.3.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2
+1 -1
View File
@@ -74,7 +74,7 @@ dependencies = [
"typing-extensions>=4.15.0,<5.0",
"ulid-transform==2.2.9",
"urllib3>=2.0",
"uv==0.11.19",
"uv==0.11.18",
"voluptuous==0.15.2",
"voluptuous-serialize==2.7.0",
"voluptuous-openapi==0.3.0",
+2 -2
View File
@@ -25,7 +25,7 @@ cryptography==48.0.0
fnv-hash-fast==2.0.3
ha-ffmpeg==3.2.2
hass-nabucasa==2.2.0
hassil==3.7.0
hassil==3.6.0
home-assistant-bluetooth==2.0.0
home-assistant-intents==2026.6.1
httpx==0.28.1
@@ -55,7 +55,7 @@ standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.9
urllib3>=2.0
uv==0.11.19
uv==0.11.18
voluptuous-openapi==0.3.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2
+10 -13
View File
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==14.0.3
aioamazondevices==14.0.0
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -332,9 +332,6 @@ aiolyric==2.1.1
# homeassistant.components.mealie
aiomealie==1.2.4
# homeassistant.components.melcloud_home
aiomelcloudhome==0.1.5
# homeassistant.components.modern_forms
aiomodernforms==0.1.8
@@ -522,7 +519,7 @@ anova-wifi==0.17.0
anthemav==1.4.1
# homeassistant.components.anthropic
anthropic==0.108.0
anthropic==0.96.0
# homeassistant.components.mcp_server
anyio==4.13.0
@@ -666,7 +663,7 @@ bleak-retry-connector==4.6.1
bleak==3.0.2
# homeassistant.components.blebox
blebox-uniapi==2.5.5
blebox-uniapi==2.5.4
# homeassistant.components.blink
blinkpy==0.25.2
@@ -1232,7 +1229,7 @@ hass-splunk==0.1.4
# homeassistant.components.assist_satellite
# homeassistant.components.conversation
hassil==3.7.0
hassil==3.6.0
# homeassistant.components.jewish_calendar
hdate[astral]==1.2.1
@@ -1284,7 +1281,7 @@ homekit-audio-proxy==1.2.1
homelink-integration-api==0.0.5
# homeassistant.components.homematicip_cloud
homematicip==2.13.0
homematicip==2.12.0
# homeassistant.components.homevolt
homevolt==0.5.0
@@ -1305,7 +1302,7 @@ huum==0.8.2
hyperion-py==0.7.6
# homeassistant.components.hypontech
hyponcloud==1.0.1
hyponcloud==1.0.0
# homeassistant.components.iammeter
iammeter==0.2.1
@@ -2567,7 +2564,7 @@ pysmarty2==0.10.3
pysmhi==2.0.0
# homeassistant.components.edl21
pysml==0.1.8
pysml==0.1.7
# homeassistant.components.smlight
pysmlight==0.3.2
@@ -2794,7 +2791,7 @@ pytradfri[async]==9.0.1
pytrafikverket==1.1.1
# homeassistant.components.v2c
pytrydan==1.0.2
pytrydan==1.0.1
# homeassistant.components.uptimerobot
pyuptimerobot==25.0.0
@@ -2899,7 +2896,7 @@ renault-api==0.5.12
renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.21.0
reolink-aio==0.20.1
# homeassistant.components.radio_frequency
rf-protocols==4.1.0
@@ -3332,7 +3329,7 @@ volkszaehler==0.4.0
volvocarsapi==0.4.3
# homeassistant.components.verisure
vsure==2.7.1
vsure==2.7.0
# homeassistant.components.vasttrafik
vtjp==0.2.1
+8 -82
View File
@@ -53,80 +53,6 @@ from anthropic.types.web_fetch_tool_result_block import (
)
model_list = [
ModelInfo(
id="claude-fable-5",
capabilities=ModelCapabilities(
batch=CapabilitySupport(supported=True),
citations=CapabilitySupport(supported=True),
code_execution=CapabilitySupport(supported=True),
context_management=ContextManagementCapability(
clear_thinking_20251015=CapabilitySupport(supported=True),
clear_tool_uses_20250919=CapabilitySupport(supported=True),
compact_20260112=CapabilitySupport(supported=True),
supported=True,
),
effort=EffortCapability(
high=CapabilitySupport(supported=True),
low=CapabilitySupport(supported=True),
max=CapabilitySupport(supported=True),
medium=CapabilitySupport(supported=True),
supported=True,
xhigh=CapabilitySupport(supported=True),
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
structured_outputs=CapabilitySupport(supported=True),
thinking=ThinkingCapability(
supported=True,
types=ThinkingTypes(
adaptive=CapabilitySupport(supported=True),
enabled=CapabilitySupport(supported=False),
),
),
),
created_at=datetime.datetime(2026, 6, 7, 0, 0, tzinfo=datetime.UTC),
display_name="Claude Fable 5",
max_input_tokens=1000000,
max_tokens=128000,
type="model",
),
ModelInfo(
id="claude-opus-4-8",
capabilities=ModelCapabilities(
batch=CapabilitySupport(supported=True),
citations=CapabilitySupport(supported=True),
code_execution=CapabilitySupport(supported=True),
context_management=ContextManagementCapability(
clear_thinking_20251015=CapabilitySupport(supported=True),
clear_tool_uses_20250919=CapabilitySupport(supported=True),
compact_20260112=CapabilitySupport(supported=True),
supported=True,
),
effort=EffortCapability(
high=CapabilitySupport(supported=True),
low=CapabilitySupport(supported=True),
max=CapabilitySupport(supported=True),
medium=CapabilitySupport(supported=True),
supported=True,
xhigh=CapabilitySupport(supported=True),
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
structured_outputs=CapabilitySupport(supported=True),
thinking=ThinkingCapability(
supported=True,
types=ThinkingTypes(
adaptive=CapabilitySupport(supported=True),
enabled=CapabilitySupport(supported=False),
),
),
),
created_at=datetime.datetime(2026, 5, 28, 0, 0, tzinfo=datetime.UTC),
display_name="Claude Opus 4.8",
max_input_tokens=1000000,
max_tokens=128000,
type="model",
),
ModelInfo(
id="claude-opus-4-7",
capabilities=ModelCapabilities(
@@ -182,7 +108,7 @@ model_list = [
max=CapabilitySupport(supported=True),
medium=CapabilitySupport(supported=True),
supported=True,
xhigh=CapabilitySupport(supported=False),
xhigh=None,
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
@@ -219,7 +145,7 @@ model_list = [
max=CapabilitySupport(supported=True),
medium=CapabilitySupport(supported=True),
supported=True,
xhigh=CapabilitySupport(supported=False),
xhigh=None,
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
@@ -256,7 +182,7 @@ model_list = [
max=CapabilitySupport(supported=False),
medium=CapabilitySupport(supported=True),
supported=True,
xhigh=CapabilitySupport(supported=False),
xhigh=None,
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
@@ -293,7 +219,7 @@ model_list = [
max=CapabilitySupport(supported=False),
medium=CapabilitySupport(supported=False),
supported=False,
xhigh=CapabilitySupport(supported=False),
xhigh=None,
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
@@ -330,7 +256,7 @@ model_list = [
max=CapabilitySupport(supported=False),
medium=CapabilitySupport(supported=False),
supported=False,
xhigh=CapabilitySupport(supported=False),
xhigh=None,
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
@@ -367,7 +293,7 @@ model_list = [
max=CapabilitySupport(supported=False),
medium=CapabilitySupport(supported=False),
supported=False,
xhigh=CapabilitySupport(supported=False),
xhigh=None,
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
@@ -404,7 +330,7 @@ model_list = [
max=CapabilitySupport(supported=False),
medium=CapabilitySupport(supported=False),
supported=False,
xhigh=CapabilitySupport(supported=False),
xhigh=None,
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
@@ -441,7 +367,7 @@ model_list = [
max=CapabilitySupport(supported=False),
medium=CapabilitySupport(supported=False),
supported=False,
xhigh=CapabilitySupport(supported=False),
xhigh=None,
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
@@ -1,14 +1,6 @@
# serializer version: 1
# name: test_model_list
list([
dict({
'label': 'Claude Fable 5',
'value': 'claude-fable-5',
}),
dict({
'label': 'Claude Opus 4.8',
'value': 'claude-opus-4-8',
}),
dict({
'label': 'Claude Opus 4.7',
'value': 'claude-opus-4-7',
+1 -1
View File
@@ -101,7 +101,7 @@ async def test_stream_wrong_type(
mock_create_stream.return_value = Message(
type="message",
id="message_id",
model="claude-fable-5",
model="claude-opus-4-6",
role="assistant",
content=[TextBlock(type="text", text="This is not a stream")],
usage=Usage(input_tokens=42, output_tokens=42),
@@ -659,7 +659,7 @@ async def test_invalid_model(
( # Model with thinking effort options
{
CONF_RECOMMENDED: False,
CONF_CHAT_MODEL: "claude-fable-5",
CONF_CHAT_MODEL: "claude-opus-4-6",
CONF_PROMPT: "bla",
CONF_PROMPT_CACHING: "automatic",
CONF_TOOL_SEARCH: True,
@@ -656,7 +656,7 @@ async def test_stream_wrong_type(
mock_create_stream.return_value = Message(
type="message",
id="message_id",
model="claude-fable-5",
model="claude-opus-4-6",
role="assistant",
content=[TextBlock(type="text", text="This is not a stream")],
usage=Usage(input_tokens=42, output_tokens=42),
@@ -1897,7 +1897,7 @@ async def test_web_fetch_error(
data={
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
CONF_CODE_EXECUTION: True,
CONF_CHAT_MODEL: "claude-fable-5",
CONF_CHAT_MODEL: "claude-opus-4-6",
CONF_WEB_FETCH: True,
CONF_WEB_FETCH_MAX_USES: 5,
},
+7 -7
View File
@@ -809,30 +809,30 @@ async def test_token_query_param_authentication(
assert await resp.read() == FAKE_PNG
async def test_unauthenticated_request_forbidden(
async def test_unauthenticated_request_unauthorized(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that unauthenticated requests are forbidden."""
"""Test that unauthenticated requests are unauthorized."""
client = await hass_client_no_auth()
resp = await client.get("/api/brands/integration/hue/icon.png")
assert resp.status == HTTPStatus.FORBIDDEN
assert resp.status == HTTPStatus.UNAUTHORIZED
resp = await client.get("/api/brands/hardware/boards/green.png")
assert resp.status == HTTPStatus.FORBIDDEN
assert resp.status == HTTPStatus.UNAUTHORIZED
async def test_invalid_token_forbidden(
async def test_invalid_token_unauthorized(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test that an invalid access token in query param is forbidden."""
"""Test that an invalid access token in query param is unauthorized."""
client = await hass_client_no_auth()
resp = await client.get("/api/brands/integration/hue/icon.png?token=invalid_token")
assert resp.status == HTTPStatus.FORBIDDEN
assert resp.status == HTTPStatus.UNAUTHORIZED
async def test_invalid_bearer_token_unauthorized(
+24
View File
@@ -691,6 +691,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."""
+70 -1
View File
@@ -16,7 +16,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import area_registry as ar
from homeassistant.helpers import area_registry as ar, label_registry as lr
from homeassistant.util.dt import utcnow
from tests.common import ANY
@@ -113,10 +113,13 @@ async def test_list_areas(
async def test_create_area(
client: MockHAClientWebSocket,
area_registry: ar.AreaRegistry,
label_registry: lr.LabelRegistry,
freezer: FrozenDateTimeFactory,
mock_temperature_humidity_entity: None,
) -> None:
"""Test create entry."""
label_registry.async_create("label_1")
label_registry.async_create("label_2")
# Create area with only mandatory parameters
await client.send_json_auto_id(
{"name": "mock", "type": "config/area_registry/create"}
@@ -261,10 +264,13 @@ async def test_delete_non_existing_area(
async def test_update_area(
client: MockHAClientWebSocket,
area_registry: ar.AreaRegistry,
label_registry: lr.LabelRegistry,
freezer: FrozenDateTimeFactory,
mock_temperature_humidity_entity: None,
) -> None:
"""Test update entry."""
label_registry.async_create("label_1")
label_registry.async_create("label_2")
created_at = datetime.fromisoformat("2024-07-16T13:30:00.900075+00:00")
freezer.move_to(created_at)
area = area_registry.async_create("mock 1")
@@ -372,6 +378,69 @@ async def test_update_area(
assert len(area_registry.areas) == 1
async def test_create_area_strips_unknown_labels(
client: MockHAClientWebSocket,
area_registry: ar.AreaRegistry,
label_registry: lr.LabelRegistry,
) -> None:
"""Test labels not in the label registry are stripped when creating an area."""
label_registry.async_create("label_1")
await client.send_json_auto_id(
{
"type": "config/area_registry/create",
"name": "mock",
"labels": ["label_1", "missing"],
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["labels"] == ["label_1"]
assert area_registry.async_get_area(msg["result"]["area_id"]).labels == {"label_1"}
@pytest.mark.parametrize(
("labels", "expected_labels"),
[
pytest.param(["label_1", "missing"], {"label_1"}, id="strip_unknown"),
pytest.param(["label_1", "stale_label"], {"label_1"}, id="strip_stale_resent"),
pytest.param(["stale_label", "missing"], set(), id="strip_all_unknown"),
pytest.param([], set(), id="remove_all"),
],
)
async def test_update_area_strips_unknown_labels(
client: MockHAClientWebSocket,
area_registry: ar.AreaRegistry,
label_registry: lr.LabelRegistry,
labels: list[str],
expected_labels: set[str],
) -> None:
"""Test labels not in the label registry are stripped on update.
A stale label already stored on the area is cleaned up when the area is
next saved, even if the client sends it back.
"""
# Seed a stale label via the helper layer, bypassing WS stripping
area = area_registry.async_create("mock", labels={"stale_label"})
label_registry.async_create("label_1")
await client.send_json_auto_id(
{
"type": "config/area_registry/update",
"area_id": area.id,
"labels": labels,
}
)
msg = await client.receive_json()
assert msg["success"]
assert set(msg["result"]["labels"]) == expected_labels
assert area_registry.async_get_area(area.id).labels == expected_labels
async def test_update_area_with_same_name(
client: MockHAClientWebSocket, area_registry: ar.AreaRegistry
) -> None:
@@ -9,7 +9,7 @@ from pytest_unordered import unordered
from homeassistant.components.config import DOMAIN, device_registry
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import device_registry as dr, label_registry as lr
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
@@ -216,9 +216,12 @@ async def test_update_device_labels(
hass: HomeAssistant,
client: MockHAClientWebSocket,
device_registry: dr.DeviceRegistry,
label_registry: lr.LabelRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test update entry labels."""
label_registry.async_create("label1")
label_registry.async_create("label2")
entry = MockConfigEntry(title=None)
entry.add_to_hass(hass)
created_at = datetime.fromisoformat("2024-07-16T13:30:00.900075+00:00")
@@ -262,6 +265,53 @@ async def test_update_device_labels(
assert getattr(device, key) == value
@pytest.mark.parametrize(
("labels", "expected_labels"),
[
pytest.param(["label1", "missing"], {"label1"}, id="strip_unknown"),
pytest.param(["label1", "stale_label"], {"label1"}, id="strip_stale_resent"),
pytest.param(["stale_label", "missing"], set(), id="strip_all_unknown"),
pytest.param([], set(), id="remove_all"),
],
)
async def test_update_device_strips_unknown_labels(
hass: HomeAssistant,
client: MockHAClientWebSocket,
device_registry: dr.DeviceRegistry,
label_registry: lr.LabelRegistry,
labels: list[str],
expected_labels: set[str],
) -> None:
"""Test labels not in the label registry are stripped on update.
A stale label already stored on the device is cleaned up when the device
is next saved, even if the client sends it back.
"""
entry = MockConfigEntry(title=None)
entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={("bridgeid", "0123")},
)
# Seed a stale label via the helper layer, bypassing WS stripping
device_registry.async_update_device(device.id, labels={"stale_label"})
label_registry.async_create("label1")
await client.send_json_auto_id(
{
"type": "config/device_registry/update",
"device_id": device.id,
"labels": labels,
}
)
msg = await client.receive_json()
assert msg["success"]
assert set(msg["result"]["labels"]) == expected_labels
assert device_registry.async_get(device.id).labels == expected_labels
async def test_remove_config_entry_from_device(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
@@ -10,7 +10,11 @@ from pytest_unordered import unordered
from homeassistant.components.config import entity_registry
from homeassistant.const import ATTR_ICON, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
label_registry as lr,
)
from homeassistant.helpers.device_registry import DeviceEntryDisabler
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_registry import (
@@ -607,9 +611,14 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket)
async def test_update_entity(
hass: HomeAssistant, client: MockHAClientWebSocket, freezer: FrozenDateTimeFactory
hass: HomeAssistant,
client: MockHAClientWebSocket,
freezer: FrozenDateTimeFactory,
label_registry: lr.LabelRegistry,
) -> None:
"""Test updating entity."""
label_registry.async_create("label1")
label_registry.async_create("label2")
created = datetime.fromisoformat("2024-02-14T12:00:00.900075+00:00")
freezer.move_to(created)
registry = mock_registry(
@@ -999,6 +1008,55 @@ async def test_update_entity(
}
@pytest.mark.parametrize(
("labels", "expected_labels"),
[
pytest.param(["label1", "missing"], {"label1"}, id="strip_unknown"),
pytest.param(["label1", "stale_label"], {"label1"}, id="strip_stale_resent"),
pytest.param(["stale_label", "missing"], set(), id="strip_all_unknown"),
pytest.param([], set(), id="remove_all"),
],
)
async def test_update_entity_strips_unknown_labels(
hass: HomeAssistant,
client: MockHAClientWebSocket,
label_registry: lr.LabelRegistry,
labels: list[str],
expected_labels: set[str],
) -> None:
"""Test labels not in the label registry are stripped on update.
A stale label already stored on the entity is cleaned up when the entity
is next saved, even if the client sends it back.
"""
registry = mock_registry(
hass,
{
"test_domain.world": RegistryEntryWithDefaults(
entity_id="test_domain.world",
unique_id="1234",
platform="test_platform",
labels={"stale_label"}, # not in the label registry
)
},
)
label_registry.async_create("label1")
await client.send_json_auto_id(
{
"type": "config/entity_registry/update",
"entity_id": "test_domain.world",
"labels": labels,
}
)
msg = await client.receive_json()
assert msg["success"]
assert set(msg["result"]["entity_entry"]["labels"]) == expected_labels
assert registry.entities["test_domain.world"].labels == expected_labels
async def test_update_entity_require_restart(
hass: HomeAssistant, client: MockHAClientWebSocket, freezer: FrozenDateTimeFactory
) -> None:
@@ -1,7 +1,6 @@
"""Tests for the Environment Canada integration."""
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from homeassistant.components.environment_canada.const import CONF_STATION, DOMAIN
@@ -18,10 +17,10 @@ FIXTURE_USER_INPUT = {
}
def build_mocks(ec_data) -> tuple[MagicMock, MagicMock, MagicMock]:
"""Build the weather, AQHI and radar library mocks used during setup."""
async def init_integration(hass: HomeAssistant, ec_data) -> MockConfigEntry:
"""Set up the Environment Canada integration in Home Assistant."""
def mock_ec() -> MagicMock:
def mock_ec():
ec_mock = MagicMock()
ec_mock.station_id = FIXTURE_USER_INPUT[CONF_STATION]
ec_mock.lat = FIXTURE_USER_INPUT[CONF_LATITUDE]
@@ -30,6 +29,9 @@ def build_mocks(ec_data) -> tuple[MagicMock, MagicMock, MagicMock]:
ec_mock.update = AsyncMock()
return ec_mock
config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT, title="Home")
config_entry.add_to_hass(hass)
weather_mock = mock_ec()
ec_data["metadata"].timestamp = datetime(2022, 10, 4, tzinfo=UTC)
weather_mock.conditions = ec_data["conditions"]
@@ -45,22 +47,6 @@ def build_mocks(ec_data) -> tuple[MagicMock, MagicMock, MagicMock]:
radar_mock.metadata = {"attribution": "Data provided by Environment Canada"}
radar_mock.clear_cache = MagicMock()
return weather_mock, mock_ec(), radar_mock
async def init_integration(
hass: HomeAssistant,
ec_data,
options: dict[str, Any] | None = None,
) -> MockConfigEntry:
"""Set up the Environment Canada integration in Home Assistant."""
config_entry = MockConfigEntry(
domain=DOMAIN, data=FIXTURE_USER_INPUT, title="Home", options=options or {}
)
config_entry.add_to_hass(hass)
weather_mock, aqhi_mock, radar_mock = build_mocks(ec_data)
with (
patch(
"homeassistant.components.environment_canada.ECWeather",
@@ -68,7 +54,7 @@ async def init_integration(
),
patch(
"homeassistant.components.environment_canada.ECAirQuality",
return_value=aqhi_mock,
return_value=mock_ec(),
),
patch(
"homeassistant.components.environment_canada.ECMap",
@@ -7,21 +7,7 @@ import json
from env_canada.ec_weather import MetaData
import pytest
from homeassistant.components.environment_canada.const import DOMAIN
from . import FIXTURE_USER_INPUT
from tests.common import MockConfigEntry, load_fixture
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
data=FIXTURE_USER_INPUT,
title="Home",
)
from tests.common import load_fixture
@pytest.fixture
@@ -1,6 +1,5 @@
"""Test the Environment Canada (EC) config flow."""
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import xml.etree.ElementTree as ET
@@ -8,26 +7,11 @@ import aiohttp
import pytest
from homeassistant import config_entries
from homeassistant.components.environment_canada.const import (
CONF_RADAR_LAYER,
CONF_RADAR_LEGEND,
CONF_RADAR_OPACITY,
CONF_RADAR_RADIUS,
CONF_RADAR_TIMESTAMP,
CONF_STATION,
DEFAULT_RADAR_LAYER,
DEFAULT_RADAR_LEGEND,
DEFAULT_RADAR_OPACITY,
DEFAULT_RADAR_RADIUS,
DEFAULT_RADAR_TIMESTAMP,
DOMAIN,
)
from homeassistant.components.environment_canada.const import CONF_STATION, DOMAIN
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import build_mocks, init_integration
from tests.common import MockConfigEntry
FAKE_CONFIG = {
@@ -199,155 +183,3 @@ async def test_coordinates_without_station(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == FAKE_CONFIG
assert result["title"] == FAKE_TITLE
async def _setup_with_options(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
ec_data: dict[str, Any],
options: dict[str, Any],
) -> MagicMock:
"""Set up the integration and return the patched ECMap constructor mock."""
mock_config_entry.add_to_hass(hass)
hass.config_entries.async_update_entry(mock_config_entry, options=options)
weather_mock, aqhi_mock, radar_mock = build_mocks(ec_data)
ecmap = MagicMock(return_value=radar_mock)
with (
patch(
"homeassistant.components.environment_canada.ECWeather",
return_value=weather_mock,
),
patch(
"homeassistant.components.environment_canada.ECAirQuality",
return_value=aqhi_mock,
),
patch("homeassistant.components.environment_canada.ECMap", ecmap),
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return ecmap
async def test_options_flow_form(hass: HomeAssistant, ec_data: dict[str, Any]) -> None:
"""Test the options form shows all radar fields."""
config_entry = await init_integration(hass, ec_data)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
schema_keys = {str(k) for k in result["data_schema"].schema}
assert schema_keys == {
CONF_RADAR_LAYER,
CONF_RADAR_LEGEND,
CONF_RADAR_TIMESTAMP,
CONF_RADAR_OPACITY,
CONF_RADAR_RADIUS,
}
async def test_options_flow_save(hass: HomeAssistant, ec_data: dict[str, Any]) -> None:
"""Test submitting the options form stores the values and reloads the entry."""
config_entry = await init_integration(hass, ec_data)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
new_options = {
CONF_RADAR_LAYER: "rain",
CONF_RADAR_LEGEND: True,
CONF_RADAR_TIMESTAMP: False,
CONF_RADAR_OPACITY: 30,
CONF_RADAR_RADIUS: 100,
}
with patch(
"homeassistant.components.environment_canada.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.options.async_configure(
result["flow_id"], new_options
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert config_entry.options == new_options
assert mock_setup_entry.called
async def test_options_flow_prefills_saved_options(
hass: HomeAssistant, ec_data: dict[str, Any]
) -> None:
"""Test the options form is pre-filled with previously saved values."""
saved_options = {
CONF_RADAR_LAYER: "snow",
CONF_RADAR_LEGEND: True,
CONF_RADAR_TIMESTAMP: False,
CONF_RADAR_OPACITY: 50,
CONF_RADAR_RADIUS: 300,
}
config_entry = await init_integration(hass, ec_data, options=saved_options)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
defaults = {str(k): k.default() for k in result["data_schema"].schema}
assert defaults[CONF_RADAR_LAYER] == "snow"
assert defaults[CONF_RADAR_LEGEND] is True
assert defaults[CONF_RADAR_TIMESTAMP] is False
assert defaults[CONF_RADAR_OPACITY] == 50
assert defaults[CONF_RADAR_RADIUS] == 300
@pytest.mark.parametrize(
("options", "expected"),
[
pytest.param(
{},
{
"layer": DEFAULT_RADAR_LAYER,
"legend": DEFAULT_RADAR_LEGEND,
"timestamp": DEFAULT_RADAR_TIMESTAMP,
"layer_opacity": DEFAULT_RADAR_OPACITY,
"radius": DEFAULT_RADAR_RADIUS,
},
id="defaults",
),
pytest.param(
{
CONF_RADAR_LAYER: "snow",
CONF_RADAR_LEGEND: True,
CONF_RADAR_TIMESTAMP: False,
CONF_RADAR_OPACITY: 40.0,
CONF_RADAR_RADIUS: 150.0,
},
{
"layer": "snow",
"legend": True,
"timestamp": False,
"layer_opacity": 40,
"radius": 150,
},
id="custom",
),
],
)
async def test_ecmap_built_from_options(
hass: HomeAssistant,
ec_data: dict[str, Any],
mock_config_entry: MockConfigEntry,
options: dict[str, Any],
expected: dict[str, Any],
) -> None:
"""Test the radar ECMap is constructed from the saved options."""
ecmap = await _setup_with_options(hass, mock_config_entry, ec_data, options)
ecmap.assert_called_once()
kwargs = ecmap.call_args.kwargs
assert kwargs["layer"] == expected["layer"]
assert kwargs["legend"] is expected["legend"]
assert kwargs["timestamp"] is expected["timestamp"]
assert kwargs["layer_opacity"] == expected["layer_opacity"]
assert isinstance(kwargs["layer_opacity"], int)
assert kwargs["radius"] == expected["radius"]
assert isinstance(kwargs["radius"], int)
-6
View File
@@ -5,7 +5,6 @@ from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from aiogithubapi import (
GitHubAuthenticatedUserModel,
GitHubLoginDeviceModel,
GitHubLoginOauthModel,
GitHubRateLimitModel,
@@ -156,10 +155,5 @@ def github_client(hass: HomeAssistant) -> Generator[AsyncMock]:
graphql_mock = AsyncMock()
graphql_mock.data = load_json_object_fixture("graphql.json", DOMAIN)
client.graphql.return_value = graphql_mock
user_response_mock = MagicMock()
user_response_mock.data = GitHubAuthenticatedUserModel(
load_json_object_fixture("user.json", DOMAIN)
)
client.user.get = AsyncMock(return_value=user_response_mock)
client.repos.events.subscribe = AsyncMock()
yield client
@@ -1,20 +0,0 @@
{
"login": "octocat",
"id": 583231,
"avatar_url": "https://avatars.githubusercontent.com/u/583231?v=4",
"html_url": "https://github.com/octocat",
"type": "User",
"name": "The Octocat",
"company": "@github",
"blog": "https://github.blog",
"location": "San Francisco",
"email": null,
"hireable": null,
"bio": null,
"public_repos": 8,
"public_gists": 8,
"followers": 17265,
"following": 9,
"created_at": "2011-01-25T18:44:36Z",
"updated_at": "2024-01-01T00:00:00Z"
}
@@ -1,919 +0,0 @@
# serializer version: 1
# name: test_all_entities[sensor.octocat_followers-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.octocat_followers',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Followers',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Followers',
'platform': 'github',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'followers',
'unique_id': '583231_followers',
'unit_of_measurement': 'followers',
})
# ---
# name: test_all_entities[sensor.octocat_followers-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'octocat Followers',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'followers',
}),
'context': <ANY>,
'entity_id': 'sensor.octocat_followers',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '17265',
})
# ---
# name: test_all_entities[sensor.octocat_following-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.octocat_following',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Following',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Following',
'platform': 'github',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'following',
'unique_id': '583231_following',
'unit_of_measurement': 'users',
})
# ---
# name: test_all_entities[sensor.octocat_following-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'octocat Following',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'users',
}),
'context': <ANY>,
'entity_id': 'sensor.octocat_following',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '9',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_discussions-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.octocat_hello_world_discussions',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Discussions',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Discussions',
'platform': 'github',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'discussions_count',
'unique_id': '1296269_discussions_count',
'unit_of_measurement': 'discussions',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_discussions-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by the GitHub API',
'friendly_name': 'octocat/Hello-World Discussions',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'discussions',
}),
'context': <ANY>,
'entity_id': 'sensor.octocat_hello_world_discussions',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_forks-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.octocat_hello_world_forks',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Forks',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Forks',
'platform': 'github',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'forks_count',
'unique_id': '1296269_forks_count',
'unit_of_measurement': 'forks',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_forks-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by the GitHub API',
'friendly_name': 'octocat/Hello-World Forks',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'forks',
}),
'context': <ANY>,
'entity_id': 'sensor.octocat_hello_world_forks',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '9',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_issues-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.octocat_hello_world_issues',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Issues',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Issues',
'platform': 'github',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'issues_count',
'unique_id': '1296269_issues_count',
'unit_of_measurement': 'issues',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_issues-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by the GitHub API',
'friendly_name': 'octocat/Hello-World Issues',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'issues',
}),
'context': <ANY>,
'entity_id': 'sensor.octocat_hello_world_issues',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_latest_commit-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.octocat_hello_world_latest_commit',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Latest commit',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Latest commit',
'platform': 'github',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'latest_commit',
'unique_id': '1296269_latest_commit',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_latest_commit-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by the GitHub API',
'friendly_name': 'octocat/Hello-World Latest commit',
'sha': '6dcb09b5b57875f334f61aebed695e2e4193db5e',
'url': 'https://github.com/octocat/Hello-World/commit/6dcb09b5b57875f334f61aebed695e2e4193db5e',
}),
'context': <ANY>,
'entity_id': 'sensor.octocat_hello_world_latest_commit',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Fix all the bugs',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_latest_discussion-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.octocat_hello_world_latest_discussion',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Latest discussion',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Latest discussion',
'platform': 'github',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'latest_discussion',
'unique_id': '1296269_latest_discussion',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_latest_discussion-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by the GitHub API',
'friendly_name': 'octocat/Hello-World Latest discussion',
'number': 1347,
'url': 'https://github.com/octocat/Hello-World/discussions/1347',
}),
'context': <ANY>,
'entity_id': 'sensor.octocat_hello_world_latest_discussion',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'First discussion',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_latest_issue-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.octocat_hello_world_latest_issue',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Latest issue',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Latest issue',
'platform': 'github',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'latest_issue',
'unique_id': '1296269_latest_issue',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_latest_issue-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by the GitHub API',
'friendly_name': 'octocat/Hello-World Latest issue',
'number': 1347,
'url': 'https://github.com/octocat/Hello-World/issues/1347',
}),
'context': <ANY>,
'entity_id': 'sensor.octocat_hello_world_latest_issue',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Found a bug',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_latest_pull_request-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.octocat_hello_world_latest_pull_request',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Latest pull request',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Latest pull request',
'platform': 'github',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'latest_pull_request',
'unique_id': '1296269_latest_pull_request',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_latest_pull_request-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by the GitHub API',
'friendly_name': 'octocat/Hello-World Latest pull request',
'number': 1347,
'url': 'https://github.com/octocat/Hello-World/pull/1347',
}),
'context': <ANY>,
'entity_id': 'sensor.octocat_hello_world_latest_pull_request',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Amazing new feature',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_latest_release-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.octocat_hello_world_latest_release',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Latest release',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Latest release',
'platform': 'github',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'latest_release',
'unique_id': '1296269_latest_release',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_latest_release-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by the GitHub API',
'friendly_name': 'octocat/Hello-World Latest release',
'tag': 'v1.0.0',
'url': 'https://github.com/octocat/Hello-World/releases/v1.0.0',
}),
'context': <ANY>,
'entity_id': 'sensor.octocat_hello_world_latest_release',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'v1.0.0',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_latest_tag-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.octocat_hello_world_latest_tag',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Latest tag',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Latest tag',
'platform': 'github',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'latest_tag',
'unique_id': '1296269_latest_tag',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_latest_tag-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by the GitHub API',
'friendly_name': 'octocat/Hello-World Latest tag',
'url': 'https://github.com/octocat/Hello-World/commit/6dcb09b5b57875f334f61aebed695e2e4193db5e',
}),
'context': <ANY>,
'entity_id': 'sensor.octocat_hello_world_latest_tag',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'v1.0.0',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_merged_pull_requests-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.octocat_hello_world_merged_pull_requests',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Merged pull requests',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Merged pull requests',
'platform': 'github',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'merged_pulls_count',
'unique_id': '1296269_merged_pulls_count',
'unit_of_measurement': 'pull requests',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_merged_pull_requests-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by the GitHub API',
'friendly_name': 'octocat/Hello-World Merged pull requests',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'pull requests',
}),
'context': <ANY>,
'entity_id': 'sensor.octocat_hello_world_merged_pull_requests',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '42',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_pull_requests-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.octocat_hello_world_pull_requests',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Pull requests',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Pull requests',
'platform': 'github',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'pulls_count',
'unique_id': '1296269_pulls_count',
'unit_of_measurement': 'pull requests',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_pull_requests-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by the GitHub API',
'friendly_name': 'octocat/Hello-World Pull requests',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'pull requests',
}),
'context': <ANY>,
'entity_id': 'sensor.octocat_hello_world_pull_requests',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_stars-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.octocat_hello_world_stars',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Stars',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Stars',
'platform': 'github',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'stargazers_count',
'unique_id': '1296269_stargazers_count',
'unit_of_measurement': 'stars',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_stars-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by the GitHub API',
'friendly_name': 'octocat/Hello-World Stars',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'stars',
}),
'context': <ANY>,
'entity_id': 'sensor.octocat_hello_world_stars',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '9',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_watchers-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.octocat_hello_world_watchers',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Watchers',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Watchers',
'platform': 'github',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'subscribers_count',
'unique_id': '1296269_subscribers_count',
'unit_of_measurement': 'watchers',
})
# ---
# name: test_all_entities[sensor.octocat_hello_world_watchers-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by the GitHub API',
'friendly_name': 'octocat/Hello-World Watchers',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'watchers',
}),
'context': <ANY>,
'entity_id': 'sensor.octocat_hello_world_watchers',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '9',
})
# ---
# name: test_all_entities[sensor.octocat_public_gists-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.octocat_public_gists',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Public gists',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Public gists',
'platform': 'github',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'public_gists',
'unique_id': '583231_public_gists',
'unit_of_measurement': 'gists',
})
# ---
# name: test_all_entities[sensor.octocat_public_gists-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'octocat Public gists',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'gists',
}),
'context': <ANY>,
'entity_id': 'sensor.octocat_public_gists',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '8',
})
# ---
# name: test_all_entities[sensor.octocat_public_repositories-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.octocat_public_repositories',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Public repositories',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Public repositories',
'platform': 'github',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'public_repos',
'unique_id': '583231_public_repos',
'unit_of_measurement': 'repositories',
})
# ---
# name: test_all_entities[sensor.octocat_public_repositories-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'octocat Public repositories',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'repositories',
}),
'context': <ANY>,
'entity_id': 'sensor.octocat_public_repositories',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '8',
})
# ---
+2 -4
View File
@@ -28,8 +28,7 @@ async def test_device_registry_cleanup(
config_entry_id=mock_config_entry.entry_id,
)
# One device for the authenticated user, one for the configured repository
assert len(devices) == 2
assert len(devices) == 1
hass.config_entries.async_remove_subentry(
mock_config_entry, list(mock_config_entry.subentries)[0]
@@ -41,8 +40,7 @@ async def test_device_registry_cleanup(
config_entry_id=mock_config_entry.entry_id,
)
# Only the user device remains after removing the repository subentry
assert len(devices) == 1
assert len(devices) == 0
async def test_subscription_setup(
+3 -20
View File
@@ -1,37 +1,20 @@
"""Test GitHub sensor."""
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.github.const import FALLBACK_UPDATE_INTERVAL
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
from tests.common import MockConfigEntry, async_fire_time_changed
TEST_SENSOR_ENTITY = "sensor.octocat_hello_world_latest_release"
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
github_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test all entities."""
with patch("homeassistant.components.github.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_sensor_updates_with_empty_release_array(
hass: HomeAssistant,
github_client: AsyncMock,
+16 -3
View File
@@ -405,14 +405,27 @@ async def test_failed_login_attempts_counter(
app.router.add_get(
"/auth_true",
request_handler_factory(hass, Mock(requires_auth=True), auth_true_handler),
request_handler_factory(
hass,
Mock(requires_auth=True, use_query_token_for_auth=False),
auth_true_handler,
),
)
app.router.add_get(
"/auth_false",
request_handler_factory(hass, Mock(requires_auth=True), auth_handler),
request_handler_factory(
hass,
Mock(requires_auth=True, use_query_token_for_auth=False),
auth_handler,
),
)
app.router.add_get(
"/", request_handler_factory(hass, Mock(requires_auth=False), auth_handler)
"/",
request_handler_factory(
hass,
Mock(requires_auth=False, use_query_token_for_auth=False),
auth_handler,
),
)
setup_bans(hass, app, 5)
+61 -8
View File
@@ -61,7 +61,7 @@ async def test_handling_unauthorized(mock_request: Mock) -> None:
with pytest.raises(HTTPUnauthorized):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=False),
Mock(requires_auth=False, use_query_token_for_auth=False),
AsyncMock(side_effect=Unauthorized),
)(mock_request)
@@ -71,7 +71,7 @@ async def test_handling_invalid_data(mock_request: Mock) -> None:
with pytest.raises(HTTPBadRequest):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=False),
Mock(requires_auth=False, use_query_token_for_auth=False),
AsyncMock(side_effect=vol.Invalid("yo")),
)(mock_request)
@@ -81,7 +81,7 @@ async def test_handling_service_not_found(mock_request: Mock) -> None:
with pytest.raises(HTTPInternalServerError):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=False),
Mock(requires_auth=False, use_query_token_for_auth=False),
AsyncMock(side_effect=ServiceNotFound("test", "test")),
)(mock_request)
@@ -90,7 +90,7 @@ async def test_not_running(mock_request_with_stopping: Mock) -> None:
"""Test we get a 503 when not running."""
response = await request_handler_factory(
mock_request_with_stopping.app[KEY_HASS],
Mock(requires_auth=False),
Mock(requires_auth=False, use_query_token_for_auth=False),
AsyncMock(side_effect=Unauthorized),
)(mock_request_with_stopping)
assert response.status == HTTPStatus.SERVICE_UNAVAILABLE
@@ -101,11 +101,64 @@ async def test_invalid_handler(mock_request: Mock) -> None:
with pytest.raises(TypeError):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=False),
Mock(requires_auth=False, use_query_token_for_auth=False),
AsyncMock(return_value=["not valid"]),
)(mock_request)
async def test_query_token_auth_valid(mock_request: Mock) -> None:
"""Test authentication with a valid query token."""
mock_request.get = Mock(return_value=False)
mock_request.query = {"token": "valid-token"}
handler = AsyncMock(return_value=None)
response = await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(
requires_auth=False,
use_query_token_for_auth=True,
get_valid_auth_tokens=Mock(return_value={"valid-token"}),
),
handler,
)(mock_request)
assert response.status == HTTPStatus.OK
handler.assert_awaited_once()
@pytest.mark.parametrize(
"query",
[{"token": "wrong-token"}, {}],
ids=["invalid_token", "missing_token"],
)
async def test_query_token_auth_unauthorized(
mock_request: Mock, query: dict[str, str]
) -> None:
"""Test an invalid or missing query token is rejected."""
mock_request.get = Mock(return_value=False)
mock_request.query = query
handler = AsyncMock()
with (
patch(
"homeassistant.helpers.network.get_url",
return_value="https://example.com",
),
pytest.raises(HTTPUnauthorized),
):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(
requires_auth=False,
use_query_token_for_auth=True,
get_valid_auth_tokens=Mock(return_value={"valid-token"}),
),
handler,
)(mock_request)
handler.assert_not_awaited()
async def test_requires_auth_includes_www_authenticate(
mock_request: Mock,
) -> None:
@@ -120,7 +173,7 @@ async def test_requires_auth_includes_www_authenticate(
):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=True),
Mock(requires_auth=True, use_query_token_for_auth=False),
AsyncMock(),
)(mock_request)
assert exc_info.value.headers["WWW-Authenticate"] == (
@@ -143,7 +196,7 @@ async def test_requires_auth_omits_www_authenticate_without_url(
):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=True),
Mock(requires_auth=True, use_query_token_for_auth=False),
AsyncMock(),
)(mock_request)
assert "WWW-Authenticate" not in exc_info.value.headers
@@ -212,7 +265,7 @@ async def test_requires_auth_www_authenticate_prefer_external(
with pytest.raises(HTTPUnauthorized) as exc_info:
await request_handler_factory(
hass,
Mock(requires_auth=True),
Mock(requires_auth=True, use_query_token_for_auth=False),
AsyncMock(),
)(mock_current_request)
@@ -1,29 +0,0 @@
# serializer version: 1
# name: test_diagnostics
dict({
'systems': list([
dict({
'data': dict({
'serial_number': '**REDACTED**',
}),
'devices': dict({
'aux_1': dict({
'class': 'IaquaLightSwitch',
'data': dict({
'name': 'aux_1',
'state': '1',
}),
}),
'pool_temp': dict({
'class': 'IaquaSensor',
'data': dict({
'name': 'pool_temp',
'value': '82',
}),
}),
}),
'online': True,
}),
]),
})
# ---
@@ -1,60 +0,0 @@
"""Tests for iAquaLink diagnostics."""
from unittest.mock import AsyncMock, patch
from iaqualink.client import AqualinkClient
from iaqualink.systems.iaqua.device import IaquaLightSwitch, IaquaSensor
from iaqualink.systems.iaqua.system import IaquaSystem
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from .conftest import get_aqualink_device, get_aqualink_system
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
config_entry: MockConfigEntry,
client: AqualinkClient,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
config_entry.add_to_hass(hass)
system = get_aqualink_system(client, cls=IaquaSystem)
system.data["serial_number"] = "SN00001"
system.online = True
system.update = AsyncMock()
systems = {system.serial: system}
light = get_aqualink_device(
system, name="aux_1", cls=IaquaLightSwitch, data={"state": "1"}
)
sensor = get_aqualink_device(
system, name="pool_temp", cls=IaquaSensor, data={"value": "82"}
)
devices = {light.name: light, sensor.name: sensor}
system.devices = devices
system.get_devices = AsyncMock(return_value=devices)
with (
patch(
"homeassistant.components.iaqualink.AqualinkClient.login",
return_value=None,
),
patch(
"homeassistant.components.iaqualink.AqualinkClient.get_systems",
return_value=systems,
),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert (
await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
== snapshot
)
+9 -3
View File
@@ -234,24 +234,30 @@ async def test_fetch_image_unauthenticated(
client = await hass_client_no_auth()
resp = await client.get("/api/image_proxy/image.test")
assert resp.status == HTTPStatus.FORBIDDEN
assert resp.status == HTTPStatus.UNAUTHORIZED
resp = await client.get("/api/image_proxy/image.test")
assert resp.status == HTTPStatus.FORBIDDEN
assert resp.status == HTTPStatus.UNAUTHORIZED
resp = await client.get(
"/api/image_proxy/image.test", headers={hdrs.AUTHORIZATION: "blabla"}
)
assert resp.status == HTTPStatus.UNAUTHORIZED
# An invalid token is also unauthorized
resp = await client.get("/api/image_proxy/image.test?token=invalid")
assert resp.status == HTTPStatus.UNAUTHORIZED
state = hass.states.get("image.test")
resp = await client.get(state.attributes["entity_picture"])
assert resp.status == HTTPStatus.OK
body = await resp.read()
assert body == b"Test"
# Unknown entities are also unauthorized for an unauthenticated client, so
# their existence is not leaked
resp = await client.get("/api/image_proxy/image.unknown")
assert resp.status == HTTPStatus.NOT_FOUND
assert resp.status == HTTPStatus.UNAUTHORIZED
@respx.mock

Some files were not shown because too many files have changed in this diff Show More