mirror of
https://github.com/home-assistant/core.git
synced 2026-06-11 11:41:42 +02:00
Compare commits
36 Commits
fix-173069
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| a2477d71fb | |||
| c0b5dec23b | |||
| 64a68f38f0 | |||
| d8ce17aaa3 | |||
| da035f1ca3 | |||
| 5a27b29003 | |||
| e04600eaec | |||
| 0b45db67e0 | |||
| 3380a8ff29 | |||
| fd21674ca1 | |||
| 2e4185840a | |||
| 1126e89d32 | |||
| 08f4774e64 | |||
| c02147f386 | |||
| f48a4720e5 | |||
| 5b083f7959 | |||
| 7bedf8074d | |||
| d656a1c091 | |||
| 06d8570e2c | |||
| 392f7b7260 | |||
| eb4568fe54 | |||
| 2ab3e0770f | |||
| 3435cfeaab | |||
| 34956c1548 | |||
| 67740405a8 | |||
| 866437a0fd | |||
| 03523f96c2 | |||
| bbf91d7ee4 | |||
| 130ca851f6 | |||
| f5b8e8ba81 | |||
| d8182508bb | |||
| 5a00de9e87 | |||
| b1f2e80f40 | |||
| 1684ea7870 | |||
| 742a6282f7 | |||
| ae23d0e3e7 |
@@ -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
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+2
-2
@@ -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
|
||||
|
||||
Generated
+13
-10
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
Reference in New Issue
Block a user