Compare commits

..

36 Commits

Author SHA1 Message Date
Diogo Gomes a2477d71fb Bump pytrydan to 1.0.2 (#173479) 2026-06-11 10:57:40 +02:00
Robert Resch c0b5dec23b Revert "Unify query token auth in http views" (#173466) 2026-06-11 10:30:54 +02:00
dependabot[bot] 64a68f38f0 Bump github/codeql-action from 4.36.1 to 4.36.2 (#173490)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 10:27:25 +02:00
Jan Bouwhuis d8ce17aaa3 Allow MQTT entities to be hidden by default (#168832)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-06-11 10:11:52 +02:00
bkobus-bbx da035f1ca3 Bump blebox_uniapi to v2.5.5 (#173365) 2026-06-11 09:49:25 +02:00
Simone Chemelli 5a27b29003 Bump aioamazondevices to 14.0.3 (#173478) 2026-06-11 08:31:49 +02:00
Hai-Nam Nguyen e04600eaec Bump hyponcloud to 1.0.1 (#173456) 2026-06-11 08:30:56 +02:00
starkillerOG 0b45db67e0 Bump reolink_aio to 0.21.0 (#173477) 2026-06-11 08:29:16 +02:00
Imou-OpenPlatform 3380a8ff29 Adds the camera platform for the Imou integration (#173064) 2026-06-11 08:18:42 +02:00
Erwin Douna fd21674ca1 Add MELCloud Home integration (#173185) 2026-06-11 07:45:31 +02:00
James Myatt 2e4185840a Improve todo tests (#173454) 2026-06-11 06:43:53 +02:00
renovate[bot] 1126e89d32 Update hassil to 3.7.0 (#173484)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-11 06:17:00 +02:00
renovate[bot] 08f4774e64 Update uv to 0.11.19 (#173483)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-11 06:14:51 +02:00
Stefan S c02147f386 Add integration kaku_rc (KlikAanKlikUit) (#170841) 2026-06-10 17:27:20 -04:00
Colin f48a4720e5 Update openevse quality_scale (#172801) 2026-06-10 22:46:00 +02:00
Rob Bierbooms 5b083f7959 Solve issue with double slash in url when writing data to InfluxDB (#173395) 2026-06-10 22:41:08 +02:00
Nikolai Rahimi 7bedf8074d Add debug logging for Mitsubishi Comfort polling failures (#173364) 2026-06-10 22:36:40 +02:00
James Myatt d656a1c091 Fix docstrings in shopping_list (#173462) 2026-06-10 22:36:08 +02:00
James Myatt 06d8570e2c (todo) Fix status field description (#173458) 2026-06-10 22:34:48 +02:00
Florent Thoumie 392f7b7260 iaqualink: add diagnostics support (#169518) 2026-06-10 22:31:27 +02:00
Erwin Douna eb4568fe54 Tado refactor to use dt_util (#173440) 2026-06-10 22:23:07 +02:00
Rasmus Graham 2ab3e0770f Bump vsure to 2.7.1 (#173470) 2026-06-10 22:19:17 +02:00
Joakim Plate 3435cfeaab Use dt util in gardena bluetooth (#173444) 2026-06-10 18:34:01 +02:00
Simone Chemelli 34956c1548 Redact more fields in diagnostics for Alexa devices (#173446) 2026-06-10 18:28:38 +02:00
Michael Davie 67740405a8 Add radar camera options flow to Environment Canada (#173415)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-10 18:28:04 +02:00
Denis Shulyaka 866437a0fd Claude Fable support for Anthropic (#173455) 2026-06-10 18:23:36 +02:00
Manu 03523f96c2 Bump pysml to 0.1.8 (#173449) 2026-06-10 18:02:23 +02:00
Simone Chemelli bbf91d7ee4 Change update interval for UptimeRobot (#173435) 2026-06-10 17:52:43 +02:00
orandasoft 130ca851f6 Add tests for itach integration (#173421) 2026-06-10 17:51:57 +02:00
Jan Bouwhuis f5b8e8ba81 Bump MQTT config flow to version 2.1 (#173094)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-10 17:31:23 +02:00
Michel van de Wetering d8182508bb Set Epson media player device class to projector (#172585) 2026-06-10 16:50:46 +02:00
Jan Bouwhuis 5a00de9e87 Do not enable MQTT entities though discovery that were disabled by user (#173404) 2026-06-10 14:41:45 +02:00
Christopher Fenner b1f2e80f40 Modify Bluetooth setup confirmation description for gardena_bluetooth integration (#173439) 2026-06-10 14:00:15 +02:00
Christian Lackas 1684ea7870 Bump homematicip to 2.13.0 (#173427) 2026-06-10 13:00:07 +02:00
Denis Shulyaka 742a6282f7 Bump Anthropic to 0.108.0 (#173430) 2026-06-10 12:57:36 +02:00
Joost Lekkerkerker ae23d0e3e7 Add user entities to Github (#173405) 2026-06-10 12:55:39 +02:00
125 changed files with 6082 additions and 642 deletions
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: "/language:python"
Generated
+4
View File
@@ -947,6 +947,8 @@ 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
@@ -1084,6 +1086,8 @@ 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,7 +12,18 @@ from homeassistant.helpers.device_registry import DeviceEntry
from .coordinator import AmazonConfigEntry
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"}
TO_REDACT = {
CONF_NAME,
CONF_PASSWORD,
CONF_USERNAME,
"access_token",
"adp_token",
"device_private_key",
"refresh_token",
"store_authentication_cookie",
"title",
"website_cookies",
}
async def async_get_config_entry_diagnostics(
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==14.0.0"]
"requirements": ["aioamazondevices==14.0.3"]
}
@@ -1,7 +1,6 @@
"""Coordinator for the Anthropic integration."""
import datetime
import re
import anthropic
@@ -20,15 +19,12 @@ 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_short_form.search(model_id):
if model_id.endswith("-4"):
return model_id + "-0"
return model_id
@@ -9,5 +9,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["anthropic==0.96.0"]
"requirements": ["anthropic==0.108.0"]
}
@@ -65,18 +65,18 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
]
self._model_list_cache[entry.entry_id] = model_list
if "opus" in model:
family = "claude-opus"
elif "sonnet" in model:
family = "claude-sonnet"
else:
family = "claude-haiku"
family = (
model.removeprefix("claude-")
.removesuffix("-preview")
.translate(str.maketrans("", "", "0123456789-."))
or "haiku"
)
suggested_model = next(
(
model_option["value"]
for model_option in sorted(
(m for m in model_list if family in m["value"]),
(m for m in model_list if f"claude-{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.6.0"]
"requirements": ["hassil==3.7.0"]
}
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["blebox_uniapi"],
"requirements": ["blebox-uniapi==2.5.4"],
"requirements": ["blebox-uniapi==2.5.5"],
"zeroconf": ["_bbxsrv._tcp.local."]
}
+18 -10
View File
@@ -1,19 +1,18 @@
"""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, override
from typing import Any, Final
from aiohttp import ClientError, web
from aiohttp import ClientError, hdrs, web
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http import KEY_AUTHENTICATED, 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
@@ -109,18 +108,23 @@ def _read_brand_file(brand_dir: Path, image: str) -> bytes | None:
class _BrandsBaseView(HomeAssistantView):
"""Base view for serving brand images."""
use_query_token_for_auth = True
requires_auth = False
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the view."""
self._hass = hass
self._cache_dir = Path(hass.config.cache_path(DOMAIN))
@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]
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
async def _serve_from_custom_integration(
self,
@@ -236,6 +240,8 @@ 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)
@@ -268,6 +274,8 @@ 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"
+18 -14
View File
@@ -2,7 +2,7 @@
import asyncio
import collections
from collections.abc import Awaitable, Callable, Container, Coroutine, Mapping
from collections.abc import Awaitable, Callable, Coroutine
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, override
from typing import Any, Final, final
from aiohttp import web
from aiohttp import hdrs, 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 HomeAssistantView
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
@@ -776,26 +776,30 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
class CameraView(HomeAssistantView):
"""Base CameraView."""
use_query_token_for_auth = True
requires_auth = False
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, label_registry as lr
from homeassistant.helpers import area_registry as ar
@callback
@@ -69,9 +69,8 @@ def websocket_create_area(
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
if "labels" in data:
# Strip labels which are not in the label registry
labels = set(data["labels"])
data["labels"] = labels - lr.async_get_missing_label_ids(hass, labels)
# Convert labels to a set
data["labels"] = set(data["labels"])
try:
entry = registry.async_create(**data)
@@ -140,11 +139,8 @@ def websocket_update_area(
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
if "labels" in data:
# 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)
# Convert labels to a set
data["labels"] = set(data["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, label_registry as lr
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry, DeviceEntryDisabler
@@ -84,11 +84,8 @@ def websocket_update_device(
msg["disabled_by"] = DeviceEntryDisabler(msg["disabled_by"])
if "labels" in msg:
# 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)
# Convert labels to a set
msg["labels"] = set(msg["labels"])
entry = cast(DeviceEntry, registry.async_update_device(**msg))
@@ -13,7 +13,6 @@ 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
@@ -235,11 +234,8 @@ def websocket_update_entity(
aliases.append(alias)
if "labels" in msg:
# 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)
# Convert labels to a set
changes["labels"] = set(msg["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.6.0", "home-assistant-intents==2026.6.1"]
"requirements": ["hassil==3.7.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.7"]
"requirements": ["pysml==0.1.8"]
}
@@ -11,7 +11,20 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import CONF_STATION, DOMAIN
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 .coordinator import ECConfigEntry, ECDataUpdateCoordinator, ECRuntimeData
from .services import async_setup_services
@@ -54,7 +67,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
errors = errors + 1
_LOGGER.warning("Unable to retrieve Environment Canada weather")
radar_data = ECMap(coordinates=(lat, lon), layer="precip_type", legend=False)
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_coordinator = ECDataUpdateCoordinator(
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
)
@@ -9,17 +9,42 @@ 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 ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
)
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_STATION, CONF_TITLE, DOMAIN
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,
)
_LOGGER = logging.getLogger(__name__)
@@ -57,6 +82,14 @@ 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:
@@ -127,3 +160,55 @@ 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,3 +6,19 @@ 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,6 +117,33 @@
"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,6 +27,7 @@ from epson_projector.const import (
)
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
@@ -62,6 +63,7 @@ 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 UTC, datetime, timedelta
from datetime import datetime, timedelta
from gardena_bluetooth.const import (
AquaContourBattery,
@@ -279,7 +279,7 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity):
super()._handle_coordinator_update()
return
time = datetime.now(UTC) + timedelta(seconds=value) # pylint: disable=home-assistant-enforce-utcnow
time = dt_util.utcnow() + timedelta(seconds=value)
if not self._attr_native_value:
self._attr_native_value = time
super()._handle_coordinator_update()
@@ -10,7 +10,7 @@
},
"step": {
"confirm": {
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
"description": "Do you want to set up {name}?\n\nBefore you continue, make sure the device is in pairing mode."
},
"user": {
"data": {
+21 -5
View File
@@ -14,7 +14,12 @@ from homeassistant.helpers.aiohttp_client import (
)
from .const import CONF_REPOSITORIES, CONF_REPOSITORY, DOMAIN, SUBENTRY_TYPE_REPOSITORY
from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator
from .coordinator import (
GithubConfigEntry,
GitHubDataUpdateCoordinator,
GitHubRuntimeData,
GitHubUserDataUpdateCoordinator,
)
PLATFORMS: list[Platform] = [Platform.SENSOR]
@@ -27,7 +32,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo
client_name=SERVER_SOFTWARE,
)
entry.runtime_data = {}
user_coordinator = GitHubUserDataUpdateCoordinator(
hass=hass,
config_entry=entry,
client=client,
)
await user_coordinator.async_config_entry_first_refresh()
repositories: dict[str, GitHubDataUpdateCoordinator] = {}
for repository_subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_REPOSITORY):
repository = repository_subentry.data[CONF_REPOSITORY]
coordinator = GitHubDataUpdateCoordinator(
@@ -42,7 +54,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo
if not entry.pref_disable_polling:
await coordinator.subscribe()
entry.runtime_data[repository_subentry.subentry_id] = coordinator
repositories[repository_subentry.subentry_id] = coordinator
entry.runtime_data = GitHubRuntimeData(
user_coordinator=user_coordinator,
repositories=repositories,
)
entry.async_on_unload(entry.add_update_listener(async_update_entry))
@@ -57,8 +74,7 @@ async def async_update_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> N
async def async_unload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bool:
"""Unload a config entry."""
repositories = entry.runtime_data
for coordinator in repositories.values():
for coordinator in entry.runtime_data.repositories.values():
coordinator.unsubscribe()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+48 -1
View File
@@ -1,9 +1,11 @@
"""Custom data update coordinator for the GitHub integration."""
from dataclasses import dataclass
from typing import Any
from aiogithubapi import (
GitHubAPI,
GitHubAuthenticatedUserModel,
GitHubConnectionException,
GitHubEventModel,
GitHubException,
@@ -103,7 +105,52 @@ query ($owner: String!, $repository: String!) {
}
"""
type GithubConfigEntry = ConfigEntry[dict[str, GitHubDataUpdateCoordinator]]
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
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 = config_entry.runtime_data.repositories
data["repositories"] = {}
for coordinator in repositories.values():
@@ -4,6 +4,12 @@
"discussions_count": {
"default": "mdi:forum"
},
"followers": {
"default": "mdi:account-multiple"
},
"following": {
"default": "mdi:account-multiple-outline"
},
"forks_count": {
"default": "mdi:source-fork"
},
@@ -31,6 +37,12 @@
"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"
},
+87 -3
View File
@@ -4,6 +4,8 @@ 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,
@@ -17,7 +19,11 @@ from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator
from .coordinator import (
GithubConfigEntry,
GitHubDataUpdateCoordinator,
GitHubUserDataUpdateCoordinator,
)
@dataclass(frozen=True, kw_only=True)
@@ -141,14 +147,58 @@ 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."""
repositories = entry.runtime_data
for subentry_id, coordinator in repositories.items():
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():
async_add_entities(
(
GitHubSensorEntity(coordinator, description)
@@ -203,3 +253,37 @@ 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,6 +36,14 @@
"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"
@@ -66,6 +74,14 @@
"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.12.0"]
"requirements": ["homematicip==2.13.0"]
}
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["hyponcloud==1.0.0"]
"requirements": ["hyponcloud==1.0.1"]
}
@@ -0,0 +1,31 @@
"""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: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: This integration uses a cloud account.
+23 -19
View File
@@ -2,21 +2,20 @@
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, override
from typing import Final, final
from aiohttp import web
from aiohttp import hdrs, web
import httpx
from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import (
@@ -315,28 +314,33 @@ class ImageView(HomeAssistantView):
"""View to serve an image."""
name = "api:image:image"
use_query_token_for_auth = True
requires_auth = False
url = "/api/image_proxy/{entity_id}"
def __init__(self, component: EntityComponent[ImageEntity]) -> None:
"""Initialize an image 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 (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."""
async def _authenticate_request(
self, request: web.Request, entity_id: str
) -> ImageEntity:
"""Authenticate request and return image entity."""
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:
@@ -345,7 +349,7 @@ class ImageView(HomeAssistantView):
This is sent by some DLNA renderers, like Samsung ones, prior to sending
the GET request.
"""
image_entity = self._get_image_entity(entity_id)
image_entity = await self._authenticate_request(request, entity_id)
# Don't use `handle` as we don't care about the stream case, we only want
# to verify that the image exists.
@@ -361,7 +365,7 @@ class ImageView(HomeAssistantView):
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
image_entity = self._get_image_entity(entity_id)
image_entity = await self._authenticate_request(request, entity_id)
return await self.handle(request, image_entity)
async def handle(
+106
View File
@@ -0,0 +1,106 @@
"""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]
PLATFORMS = [Platform.BUTTON, Platform.CAMERA]
@@ -41,6 +41,14 @@
"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:
if (path := conf.get(CONF_PATH)) is not None and path != "/":
kwargs[CONF_PATH] = path
if (port := conf.get(CONF_PORT)) is not None:
@@ -0,0 +1,55 @@
"""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)
@@ -0,0 +1,187 @@
"""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,
)
@@ -0,0 +1,19 @@
"""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}"
@@ -0,0 +1,11 @@
{
"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"
}
@@ -0,0 +1,68 @@
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
@@ -0,0 +1,41 @@
{
"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."
}
}
}
}
@@ -0,0 +1,112 @@
"""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, Container, Mapping
from collections.abc import Callable
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, override
from typing import Any, Final, Required, TypedDict, final
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 HomeAssistantView
from homeassistant.components.http import KEY_AUTHENTICATED, 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, callback
from homeassistant.core import HomeAssistant, SupportsResponse
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."""
use_query_token_for_auth = True
requires_auth = False
url = "/api/media_player_proxy/{entity_id}"
name = "api:media_player:image"
extra_urls = [
@@ -1262,15 +1262,6 @@ 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,
@@ -1280,9 +1271,21 @@ class MediaPlayerImageView(HomeAssistantView):
) -> web.Response:
"""Start a get request."""
if (player := self.component.get_entity(entity_id)) is None:
return web.Response(status=HTTPStatus.NOT_FOUND)
status = (
HTTPStatus.NOT_FOUND
if request[KEY_AUTHENTICATED]
else HTTPStatus.UNAUTHORIZED
)
return web.Response(status=status)
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")
@@ -0,0 +1,38 @@
"""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)
@@ -0,0 +1,372 @@
"""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()
@@ -0,0 +1,94 @@
"""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,
)
@@ -0,0 +1,3 @@
"""Constants for the MELCloud Home integration."""
DOMAIN = "melcloud_home"
@@ -0,0 +1,114 @@
"""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)
@@ -0,0 +1,84 @@
"""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
@@ -0,0 +1,12 @@
{
"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"]
}
@@ -0,0 +1,68 @@
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
@@ -0,0 +1,77 @@
{
"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,12 +42,23 @@ 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",
+16 -15
View File
@@ -87,7 +87,6 @@ from .const import (
DEFAULT_RETAIN,
DOMAIN,
ENTITY_PLATFORMS,
ENTRY_OPTION_FIELDS,
MQTT_CONNECTION_STATE,
PROTOCOL_5,
PROTOCOL_311,
@@ -154,7 +153,6 @@ __all__ = [
"DEFAULT_RETAIN",
"DOMAIN",
"ENTITY_PLATFORMS",
"ENTRY_OPTION_FIELDS",
"MQTT",
"MQTT_BASE_SCHEMA",
"MQTT_CONNECTION_STATE",
@@ -468,27 +466,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate the options from config entry data."""
"""Migrate the config entry to the latest version."""
_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:
# 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:
for key in (
CONF_DISCOVERY,
CONF_DISCOVERY_PREFIX,
"birth_message",
"will_message",
):
if key not in data:
continue
options[key] = data.pop(key)
# Write version 1.2 for backwards compatibility
hass.config_entries.async_update_entry(
entry,
data=data,
options=options,
version=1,
minor_version=2,
)
# Bump config entry to version 2.1
hass.config_entries.async_update_entry(
entry,
data=data,
options=options,
version=2,
minor_version=1,
)
_LOGGER.debug(
"Migration to version %s.%s successful", entry.version, entry.minor_version
@@ -273,6 +273,7 @@ ABBREVIATIONS = {
"l_ver_t": "latest_version_topic",
"l_ver_tpl": "latest_version_template",
"pl_inst": "payload_install",
"vis": "visible_by_default",
}
DEVICE_ABBREVIATIONS = {
+2 -12
View File
@@ -5,7 +5,7 @@ import logging
import jinja2
from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature
from homeassistant.const import CONF_DISCOVERY, CONF_PAYLOAD, Platform
from homeassistant.const import CONF_PAYLOAD, Platform
from homeassistant.exceptions import TemplateError
ATTR_DISCOVERY_HASH = "discovery_hash"
@@ -246,6 +246,7 @@ 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"
@@ -385,17 +386,6 @@ 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,
+35 -6
View File
@@ -95,6 +95,7 @@ from .const import (
CONF_SW_VERSION,
CONF_TOPIC,
CONF_VIA_DEVICE,
CONF_VISIBLE_BY_DEFAULT,
DOMAIN,
MQTT_CONNECTION_STATE,
)
@@ -1428,19 +1429,44 @@ 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 (
self._config[CONF_ENABLED_BY_DEFAULT]
and deleted_entry
and deleted_entry.disabled_by is not None
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
):
# Enable previous deleted entity and enable it
# 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
)
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,
disabled_by=None if reenable_condition else UNDEFINED,
hidden_by=hidden_by,
)
if discovery_data is None:
@@ -1589,6 +1615,9 @@ 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,6 +52,7 @@ from .const import (
CONF_SW_VERSION,
CONF_TOPIC,
CONF_VIA_DEVICE,
CONF_VISIBLE_BY_DEFAULT,
DEFAULT_PAYLOAD_AVAILABLE,
DEFAULT_PAYLOAD_NOT_AVAILABLE,
ENTITY_PLATFORMS,
@@ -184,6 +185,7 @@ 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: todo
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration has no options flow.
docs-installation-parameters: todo
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery: done
discovery-update-info: 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
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
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: todo
strict-typing: done
@@ -20,5 +20,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.20.1"]
"requirements": ["reolink-aio==0.21.0"]
}
@@ -51,7 +51,7 @@ class ShoppingTodoListEntity(TodoListEntity):
)
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update an item to the To-do list."""
"""Update an item in 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:
"""Add an item to the To-do list."""
"""Delete items from the To-do list."""
await self._data.async_remove_items(set(uids))
async def async_move_todo_item(
+2 -3
View File
@@ -14,6 +14,7 @@ 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,
@@ -155,9 +156,7 @@ 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 = datetime.now( # pylint: disable=home-assistant-enforce-now
ZoneInfo("Europe/Berlin")
)
reset_time = dt_util.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 or confirmation of the to-do item.",
"description": "A status for the to-do item.",
"name": "Set status"
}
},
@@ -8,8 +8,10 @@ from homeassistant.const import Platform
LOGGER: Logger = getLogger(__package__)
# The free plan is limited to 10 requests/minute
COORDINATOR_UPDATE_INTERVAL: timedelta = timedelta(seconds=10)
# 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)
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.1"]
"requirements": ["pytrydan==1.0.2"]
}
@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["verisure"],
"requirements": ["vsure==2.7.0"]
"requirements": ["vsure==2.7.1"]
}
+2
View File
@@ -386,6 +386,7 @@ FLOWS = {
"kegtron",
"keymitt_ble",
"kiosker",
"klik_aan_klik_uit",
"kmtronic",
"knocki",
"knx",
@@ -447,6 +448,7 @@ FLOWS = {
"medcom_ble",
"media_extractor",
"melcloud",
"melcloud_home",
"melnor",
"met",
"met_eireann",
+12
View File
@@ -3581,6 +3581,12 @@
"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",
@@ -4166,6 +4172,12 @@
"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",
+3 -15
View File
@@ -1,6 +1,6 @@
"""Helper to track the current http request."""
from collections.abc import Awaitable, Callable, Container, Mapping
from collections.abc import Awaitable, Callable
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, callback, is_callback
from homeassistant.core import Context, HomeAssistant, 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,13 +55,7 @@ def request_handler_factory(
authenticated = request.get(KEY_AUTHENTICATED, False)
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:
if view.requires_auth and not authenticated:
# Import here to avoid circular dependency with network.py
from .network import NoURLAvailableError, get_url # noqa: PLC0415
@@ -135,7 +129,6 @@ 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
@@ -211,8 +204,3 @@ 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,17 +268,6 @@ 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.6.0
hassil==3.7.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.18
uv==0.11.19
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.18",
"uv==0.11.19",
"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.6.0
hassil==3.7.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.18
uv==0.11.19
voluptuous-openapi==0.3.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2
+13 -10
View File
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==14.0.0
aioamazondevices==14.0.3
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -332,6 +332,9 @@ 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
@@ -519,7 +522,7 @@ anova-wifi==0.17.0
anthemav==1.4.1
# homeassistant.components.anthropic
anthropic==0.96.0
anthropic==0.108.0
# homeassistant.components.mcp_server
anyio==4.13.0
@@ -663,7 +666,7 @@ bleak-retry-connector==4.6.1
bleak==3.0.2
# homeassistant.components.blebox
blebox-uniapi==2.5.4
blebox-uniapi==2.5.5
# homeassistant.components.blink
blinkpy==0.25.2
@@ -1229,7 +1232,7 @@ hass-splunk==0.1.4
# homeassistant.components.assist_satellite
# homeassistant.components.conversation
hassil==3.6.0
hassil==3.7.0
# homeassistant.components.jewish_calendar
hdate[astral]==1.2.1
@@ -1281,7 +1284,7 @@ homekit-audio-proxy==1.2.1
homelink-integration-api==0.0.5
# homeassistant.components.homematicip_cloud
homematicip==2.12.0
homematicip==2.13.0
# homeassistant.components.homevolt
homevolt==0.5.0
@@ -1302,7 +1305,7 @@ huum==0.8.2
hyperion-py==0.7.6
# homeassistant.components.hypontech
hyponcloud==1.0.0
hyponcloud==1.0.1
# homeassistant.components.iammeter
iammeter==0.2.1
@@ -2564,7 +2567,7 @@ pysmarty2==0.10.3
pysmhi==2.0.0
# homeassistant.components.edl21
pysml==0.1.7
pysml==0.1.8
# homeassistant.components.smlight
pysmlight==0.3.2
@@ -2791,7 +2794,7 @@ pytradfri[async]==9.0.1
pytrafikverket==1.1.1
# homeassistant.components.v2c
pytrydan==1.0.1
pytrydan==1.0.2
# homeassistant.components.uptimerobot
pyuptimerobot==25.0.0
@@ -2896,7 +2899,7 @@ renault-api==0.5.12
renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.20.1
reolink-aio==0.21.0
# homeassistant.components.radio_frequency
rf-protocols==4.1.0
@@ -3329,7 +3332,7 @@ volkszaehler==0.4.0
volvocarsapi==0.4.3
# homeassistant.components.verisure
vsure==2.7.0
vsure==2.7.1
# homeassistant.components.vasttrafik
vtjp==0.2.1
+82 -8
View File
@@ -53,6 +53,80 @@ 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(
@@ -108,7 +182,7 @@ model_list = [
max=CapabilitySupport(supported=True),
medium=CapabilitySupport(supported=True),
supported=True,
xhigh=None,
xhigh=CapabilitySupport(supported=False),
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
@@ -145,7 +219,7 @@ model_list = [
max=CapabilitySupport(supported=True),
medium=CapabilitySupport(supported=True),
supported=True,
xhigh=None,
xhigh=CapabilitySupport(supported=False),
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
@@ -182,7 +256,7 @@ model_list = [
max=CapabilitySupport(supported=False),
medium=CapabilitySupport(supported=True),
supported=True,
xhigh=None,
xhigh=CapabilitySupport(supported=False),
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
@@ -219,7 +293,7 @@ model_list = [
max=CapabilitySupport(supported=False),
medium=CapabilitySupport(supported=False),
supported=False,
xhigh=None,
xhigh=CapabilitySupport(supported=False),
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
@@ -256,7 +330,7 @@ model_list = [
max=CapabilitySupport(supported=False),
medium=CapabilitySupport(supported=False),
supported=False,
xhigh=None,
xhigh=CapabilitySupport(supported=False),
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
@@ -293,7 +367,7 @@ model_list = [
max=CapabilitySupport(supported=False),
medium=CapabilitySupport(supported=False),
supported=False,
xhigh=None,
xhigh=CapabilitySupport(supported=False),
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
@@ -330,7 +404,7 @@ model_list = [
max=CapabilitySupport(supported=False),
medium=CapabilitySupport(supported=False),
supported=False,
xhigh=None,
xhigh=CapabilitySupport(supported=False),
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
@@ -367,7 +441,7 @@ model_list = [
max=CapabilitySupport(supported=False),
medium=CapabilitySupport(supported=False),
supported=False,
xhigh=None,
xhigh=CapabilitySupport(supported=False),
),
image_input=CapabilitySupport(supported=True),
pdf_input=CapabilitySupport(supported=True),
@@ -1,6 +1,14 @@
# 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-opus-4-6",
model="claude-fable-5",
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-opus-4-6",
CONF_CHAT_MODEL: "claude-fable-5",
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-opus-4-6",
model="claude-fable-5",
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-opus-4-6",
CONF_CHAT_MODEL: "claude-fable-5",
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_unauthorized(
async def test_unauthenticated_request_forbidden(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that unauthenticated requests are unauthorized."""
"""Test that unauthenticated requests are forbidden."""
client = await hass_client_no_auth()
resp = await client.get("/api/brands/integration/hue/icon.png")
assert resp.status == HTTPStatus.UNAUTHORIZED
assert resp.status == HTTPStatus.FORBIDDEN
resp = await client.get("/api/brands/hardware/boards/green.png")
assert resp.status == HTTPStatus.UNAUTHORIZED
assert resp.status == HTTPStatus.FORBIDDEN
async def test_invalid_token_unauthorized(
async def test_invalid_token_forbidden(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
) -> None:
"""Test that an invalid access token in query param is unauthorized."""
"""Test that an invalid access token in query param is forbidden."""
client = await hass_client_no_auth()
resp = await client.get("/api/brands/integration/hue/icon.png?token=invalid_token")
assert resp.status == HTTPStatus.UNAUTHORIZED
assert resp.status == HTTPStatus.FORBIDDEN
async def test_invalid_bearer_token_unauthorized(
-24
View File
@@ -691,30 +691,6 @@ 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."""
+1 -70
View File
@@ -16,7 +16,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import area_registry as ar, label_registry as lr
from homeassistant.helpers import area_registry as ar
from homeassistant.util.dt import utcnow
from tests.common import ANY
@@ -113,13 +113,10 @@ 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"}
@@ -264,13 +261,10 @@ 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")
@@ -378,69 +372,6 @@ 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, label_registry as lr
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
@@ -216,12 +216,9 @@ 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")
@@ -265,53 +262,6 @@ 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,11 +10,7 @@ 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,
label_registry as lr,
)
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryDisabler
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_registry import (
@@ -611,14 +607,9 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket)
async def test_update_entity(
hass: HomeAssistant,
client: MockHAClientWebSocket,
freezer: FrozenDateTimeFactory,
label_registry: lr.LabelRegistry,
hass: HomeAssistant, client: MockHAClientWebSocket, freezer: FrozenDateTimeFactory
) -> 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(
@@ -1008,55 +999,6 @@ 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,6 +1,7 @@
"""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
@@ -17,10 +18,10 @@ FIXTURE_USER_INPUT = {
}
async def init_integration(hass: HomeAssistant, ec_data) -> MockConfigEntry:
"""Set up the Environment Canada integration in Home Assistant."""
def build_mocks(ec_data) -> tuple[MagicMock, MagicMock, MagicMock]:
"""Build the weather, AQHI and radar library mocks used during setup."""
def mock_ec():
def mock_ec() -> MagicMock:
ec_mock = MagicMock()
ec_mock.station_id = FIXTURE_USER_INPUT[CONF_STATION]
ec_mock.lat = FIXTURE_USER_INPUT[CONF_LATITUDE]
@@ -29,9 +30,6 @@ async def init_integration(hass: HomeAssistant, ec_data) -> MockConfigEntry:
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"]
@@ -47,6 +45,22 @@ async def init_integration(hass: HomeAssistant, ec_data) -> MockConfigEntry:
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",
@@ -54,7 +68,7 @@ async def init_integration(hass: HomeAssistant, ec_data) -> MockConfigEntry:
),
patch(
"homeassistant.components.environment_canada.ECAirQuality",
return_value=mock_ec(),
return_value=aqhi_mock,
),
patch(
"homeassistant.components.environment_canada.ECMap",
@@ -7,7 +7,21 @@ import json
from env_canada.ec_weather import MetaData
import pytest
from tests.common import load_fixture
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",
)
@pytest.fixture
@@ -1,5 +1,6 @@
"""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
@@ -7,11 +8,26 @@ import aiohttp
import pytest
from homeassistant import config_entries
from homeassistant.components.environment_canada.const import CONF_STATION, DOMAIN
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.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 = {
@@ -183,3 +199,155 @@ 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,6 +5,7 @@ from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from aiogithubapi import (
GitHubAuthenticatedUserModel,
GitHubLoginDeviceModel,
GitHubLoginOauthModel,
GitHubRateLimitModel,
@@ -155,5 +156,10 @@ 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
@@ -0,0 +1,20 @@
{
"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"
}
@@ -0,0 +1,919 @@
# 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',
})
# ---
+4 -2
View File
@@ -28,7 +28,8 @@ async def test_device_registry_cleanup(
config_entry_id=mock_config_entry.entry_id,
)
assert len(devices) == 1
# One device for the authenticated user, one for the configured repository
assert len(devices) == 2
hass.config_entries.async_remove_subentry(
mock_config_entry, list(mock_config_entry.subentries)[0]
@@ -40,7 +41,8 @@ async def test_device_registry_cleanup(
config_entry_id=mock_config_entry.entry_id,
)
assert len(devices) == 0
# Only the user device remains after removing the repository subentry
assert len(devices) == 1
async def test_subscription_setup(
+20 -3
View File
@@ -1,20 +1,37 @@
"""Test GitHub sensor."""
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
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
from homeassistant.const import STATE_UNAVAILABLE, Platform
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
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
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,
+3 -16
View File
@@ -405,27 +405,14 @@ async def test_failed_login_attempts_counter(
app.router.add_get(
"/auth_true",
request_handler_factory(
hass,
Mock(requires_auth=True, use_query_token_for_auth=False),
auth_true_handler,
),
request_handler_factory(hass, Mock(requires_auth=True), auth_true_handler),
)
app.router.add_get(
"/auth_false",
request_handler_factory(
hass,
Mock(requires_auth=True, use_query_token_for_auth=False),
auth_handler,
),
request_handler_factory(hass, Mock(requires_auth=True), auth_handler),
)
app.router.add_get(
"/",
request_handler_factory(
hass,
Mock(requires_auth=False, use_query_token_for_auth=False),
auth_handler,
),
"/", request_handler_factory(hass, Mock(requires_auth=False), auth_handler)
)
setup_bans(hass, app, 5)
+8 -61
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, use_query_token_for_auth=False),
Mock(requires_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, use_query_token_for_auth=False),
Mock(requires_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, use_query_token_for_auth=False),
Mock(requires_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, use_query_token_for_auth=False),
Mock(requires_auth=False),
AsyncMock(side_effect=Unauthorized),
)(mock_request_with_stopping)
assert response.status == HTTPStatus.SERVICE_UNAVAILABLE
@@ -101,64 +101,11 @@ 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, use_query_token_for_auth=False),
Mock(requires_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:
@@ -173,7 +120,7 @@ async def test_requires_auth_includes_www_authenticate(
):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=True, use_query_token_for_auth=False),
Mock(requires_auth=True),
AsyncMock(),
)(mock_request)
assert exc_info.value.headers["WWW-Authenticate"] == (
@@ -196,7 +143,7 @@ async def test_requires_auth_omits_www_authenticate_without_url(
):
await request_handler_factory(
mock_request.app[KEY_HASS],
Mock(requires_auth=True, use_query_token_for_auth=False),
Mock(requires_auth=True),
AsyncMock(),
)(mock_request)
assert "WWW-Authenticate" not in exc_info.value.headers
@@ -265,7 +212,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, use_query_token_for_auth=False),
Mock(requires_auth=True),
AsyncMock(),
)(mock_current_request)
@@ -0,0 +1,29 @@
# 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,
}),
]),
})
# ---
@@ -0,0 +1,60 @@
"""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
)
+3 -9
View File
@@ -234,30 +234,24 @@ 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.UNAUTHORIZED
assert resp.status == HTTPStatus.FORBIDDEN
resp = await client.get("/api/image_proxy/image.test")
assert resp.status == HTTPStatus.UNAUTHORIZED
assert resp.status == HTTPStatus.FORBIDDEN
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.UNAUTHORIZED
assert resp.status == HTTPStatus.NOT_FOUND
@respx.mock

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