mirror of
https://github.com/home-assistant/core.git
synced 2026-06-11 19:51:39 +02:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a1baa9573 | |||
| 8fed48d8ac | |||
| f5f80e7080 | |||
| 1e18b77c67 | |||
| 0dec701acd | |||
| 851facd826 | |||
| e56c221eb1 | |||
| a4eba86a6c | |||
| a25a55737f | |||
| 1b582f4089 | |||
| c83323894c | |||
| ac5e1f178b | |||
| 00a48df8cb | |||
| 0d67cc0795 | |||
| 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,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,11 @@ from aiohttp import ClientResponseError
|
||||
from pyaqvify import AqvifyAPI, AqvifyAuthException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
@@ -49,6 +53,11 @@ class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(account_data.account_id)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(), data_updates=user_input
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title="Aqvify", data=user_input)
|
||||
|
||||
@@ -96,3 +105,9 @@ class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: Mapping[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""User initiated reconfiguration."""
|
||||
return await self.async_step_user()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The entered API key corresponds to a different account."
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -24,3 +24,13 @@ OPEN_STATUS: dict[int, str] = {
|
||||
|
||||
LIGHT_MAX_KELVINS = 6500 # 154 Mireds
|
||||
LIGHT_MIN_KELVINS = 2700 # 370 Mireds
|
||||
|
||||
CO2_LEVEL: dict[int, str] = {
|
||||
0: "excellent",
|
||||
1: "good",
|
||||
2: "acceptable",
|
||||
3: "medium",
|
||||
4: "poor",
|
||||
5: "unhealthy",
|
||||
6: "hazardous",
|
||||
}
|
||||
|
||||
@@ -90,10 +90,10 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
|
||||
if feature.has_tilt:
|
||||
self._attr_supported_features |= (
|
||||
CoverEntityFeature.SET_TILT_POSITION
|
||||
| CoverEntityFeature.OPEN_TILT
|
||||
| CoverEntityFeature.CLOSE_TILT
|
||||
CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT
|
||||
)
|
||||
if feature.is_calibrated:
|
||||
self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION
|
||||
|
||||
if feature.tilt_only:
|
||||
self._attr_supported_features &= ~(
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"co2_level": {
|
||||
"default": "mdi:molecule-co2"
|
||||
},
|
||||
"open_status": {
|
||||
"default": "mdi:window-open"
|
||||
},
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
LIGHT_LUX,
|
||||
PERCENTAGE,
|
||||
UnitOfApparentPower,
|
||||
@@ -31,7 +32,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .const import OPEN_STATUS
|
||||
from .const import CO2_LEVEL, OPEN_STATUS
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
@@ -149,6 +150,19 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = (
|
||||
options=list(OPEN_STATUS.values()),
|
||||
value_fn=lambda v: OPEN_STATUS.get(int(v)) if v is not None else None,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="co2",
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="co2Definition",
|
||||
translation_key="co2_level",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(CO2_LEVEL.values()),
|
||||
value_fn=lambda v: CO2_LEVEL.get(int(v)) if v is not None else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,18 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"co2_level": {
|
||||
"name": "Carbon dioxide level",
|
||||
"state": {
|
||||
"acceptable": "Acceptable",
|
||||
"excellent": "Excellent",
|
||||
"good": "Good",
|
||||
"hazardous": "Hazardous",
|
||||
"medium": "Medium",
|
||||
"poor": "Poor",
|
||||
"unhealthy": "Unhealthy"
|
||||
}
|
||||
},
|
||||
"open_status": {
|
||||
"state": {
|
||||
"ajar": "Ajar",
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -155,7 +155,7 @@ class GoogleWifiSensor(SensorEntity):
|
||||
class GoogleWifiAPI:
|
||||
"""Get the latest data and update the states."""
|
||||
|
||||
def __init__(self, host, conditions):
|
||||
def __init__(self, host, conditions) -> None:
|
||||
"""Initialize the data object."""
|
||||
uri = "http://"
|
||||
resource = f"{uri}{host}{ENDPOINT}"
|
||||
@@ -182,7 +182,7 @@ class GoogleWifiAPI:
|
||||
self.raw_data = response.json()
|
||||
self.data_format()
|
||||
self.available = True
|
||||
except ValueError, requests.exceptions.ConnectionError:
|
||||
except ValueError, requests.exceptions.RequestException:
|
||||
_LOGGER.warning("Unable to fetch data from Google Wifi")
|
||||
self.available = False
|
||||
self.raw_data = None
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from .const import CONF_STATION_ID
|
||||
from .coordinator import OpenSenseMapConfigEntry, OpenSenseMapCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.AIR_QUALITY]
|
||||
PLATFORMS: list[Platform] = [Platform.AIR_QUALITY, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -117,9 +117,9 @@ class OpenSenseMapQuality(CoordinatorEntity[OpenSenseMapCoordinator], AirQuality
|
||||
@property
|
||||
def particulate_matter_2_5(self) -> float | None:
|
||||
"""Return the particulate matter 2.5 level."""
|
||||
return self.coordinator.data.pm2_5
|
||||
return self.coordinator.data.pm2_5.value
|
||||
|
||||
@property
|
||||
def particulate_matter_10(self) -> float | None:
|
||||
"""Return the particulate matter 10 level."""
|
||||
return self.coordinator.data.pm10
|
||||
return self.coordinator.data.pm10.value
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import NamedTuple
|
||||
|
||||
from opensensemap_api import OpenSenseMap
|
||||
from opensensemap_api import _TITLES, OpenSenseMap
|
||||
from opensensemap_api.exceptions import OpenSenseMapError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -14,13 +16,68 @@ from .const import DOMAIN, LOGGER
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
# Stations report the same phenomenon in different units, but the library
|
||||
# exposes only values. These map a station's reported unit (normalized to
|
||||
# lowercase) to the matching Home Assistant unit so values convert correctly.
|
||||
TEMPERATURE_UNITS: dict[str, str] = {
|
||||
"°c": UnitOfTemperature.CELSIUS,
|
||||
"c": UnitOfTemperature.CELSIUS,
|
||||
"°f": UnitOfTemperature.FAHRENHEIT,
|
||||
"f": UnitOfTemperature.FAHRENHEIT,
|
||||
}
|
||||
WIND_SPEED_UNITS: dict[str, str] = {
|
||||
"m/s": UnitOfSpeed.METERS_PER_SECOND,
|
||||
"km/h": UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
"mph": UnitOfSpeed.MILES_PER_HOUR,
|
||||
}
|
||||
PRESSURE_UNITS: dict[str, str] = {
|
||||
"hpa": UnitOfPressure.HPA,
|
||||
"pa": UnitOfPressure.PA,
|
||||
"pascal": UnitOfPressure.PA,
|
||||
"mbar": UnitOfPressure.MBAR,
|
||||
"kpa": UnitOfPressure.KPA,
|
||||
}
|
||||
|
||||
|
||||
class Measurement(NamedTuple):
|
||||
"""A station measurement paired with its detected unit, if any."""
|
||||
|
||||
value: float | None
|
||||
unit: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class OpenSenseMapStationData:
|
||||
"""Immutable measurements for an openSenseMap station."""
|
||||
|
||||
pm2_5: float | None
|
||||
pm10: float | None
|
||||
pm2_5: Measurement
|
||||
pm10: Measurement
|
||||
pm1_0: Measurement
|
||||
temperature: Measurement
|
||||
humidity: Measurement
|
||||
air_pressure: Measurement
|
||||
illuminance: Measurement
|
||||
wind_speed: Measurement
|
||||
wind_direction: Measurement
|
||||
|
||||
|
||||
def _detect_unit(
|
||||
api: OpenSenseMap, title_key: str, unit_map: dict[str, str]
|
||||
) -> str | None:
|
||||
"""Return the Home Assistant unit for a phenomenon reported by the station."""
|
||||
|
||||
# The library resolves a measurement by matching localized sensor titles
|
||||
# (opensensemap_api._TITLES) and returns the first matching sensor that has a
|
||||
# value. Mirror that approach to find the matching unit.
|
||||
for title in (*_TITLES.get(title_key, ()), title_key):
|
||||
for sensor in api.data.get("sensors", []):
|
||||
measurement = sensor.get("lastMeasurement") or {}
|
||||
if (
|
||||
sensor.get("title", "").casefold() == title.casefold()
|
||||
and measurement.get("value") is not None
|
||||
):
|
||||
return unit_map.get((sensor.get("unit") or "").strip().casefold())
|
||||
return None
|
||||
|
||||
|
||||
type OpenSenseMapConfigEntry = ConfigEntry[OpenSenseMapCoordinator]
|
||||
@@ -55,4 +112,23 @@ class OpenSenseMapCoordinator(DataUpdateCoordinator[OpenSenseMapStationData]):
|
||||
raise UpdateFailed(
|
||||
f"Unable to fetch data from openSenseMap: {err}"
|
||||
) from err
|
||||
return OpenSenseMapStationData(pm2_5=self.api.pm2_5, pm10=self.api.pm10)
|
||||
return OpenSenseMapStationData(
|
||||
pm2_5=Measurement(self.api.pm2_5),
|
||||
pm10=Measurement(self.api.pm10),
|
||||
pm1_0=Measurement(self.api.pm1_0),
|
||||
temperature=Measurement(
|
||||
self.api.temperature,
|
||||
_detect_unit(self.api, "Temperature", TEMPERATURE_UNITS),
|
||||
),
|
||||
humidity=Measurement(self.api.humidity),
|
||||
air_pressure=Measurement(
|
||||
self.api.air_pressure,
|
||||
_detect_unit(self.api, "Air Pressure", PRESSURE_UNITS),
|
||||
),
|
||||
illuminance=Measurement(self.api.illuminance),
|
||||
wind_speed=Measurement(
|
||||
self.api.wind_speed,
|
||||
_detect_unit(self.api, "Wind Speed", WIND_SPEED_UNITS),
|
||||
),
|
||||
wind_direction=Measurement(self.api.wind_direction),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Support for openSenseMap sensors."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
DEGREE,
|
||||
LIGHT_LUX,
|
||||
PERCENTAGE,
|
||||
UnitOfPressure,
|
||||
UnitOfSpeed,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import CONF_STATION_ID, DOMAIN, INTEGRATION_TITLE
|
||||
from .coordinator import (
|
||||
Measurement,
|
||||
OpenSenseMapConfigEntry,
|
||||
OpenSenseMapCoordinator,
|
||||
OpenSenseMapStationData,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class OpenSenseMapSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes openSenseMap sensor entities."""
|
||||
|
||||
value_fn: Callable[[OpenSenseMapStationData], Measurement]
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS: tuple[OpenSenseMapSensorEntityDescription, ...] = (
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="pm2_5",
|
||||
device_class=SensorDeviceClass.PM25,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.pm2_5,
|
||||
),
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="pm10",
|
||||
device_class=SensorDeviceClass.PM10,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.pm10,
|
||||
),
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="pm1_0",
|
||||
device_class=SensorDeviceClass.PM1,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.pm1_0,
|
||||
),
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.temperature,
|
||||
),
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="humidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.humidity,
|
||||
),
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="air_pressure",
|
||||
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
|
||||
native_unit_of_measurement=UnitOfPressure.HPA,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.air_pressure,
|
||||
),
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="illuminance",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.illuminance,
|
||||
),
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="wind_speed",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.wind_speed,
|
||||
),
|
||||
OpenSenseMapSensorEntityDescription(
|
||||
key="wind_direction",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
native_unit_of_measurement=DEGREE,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
value_fn=lambda data: data.wind_direction,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OpenSenseMapConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up openSenseMap sensors from a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
entities: list[OpenSenseMapSensor] = []
|
||||
for description in SENSOR_DESCRIPTIONS:
|
||||
measurement = description.value_fn(coordinator.data)
|
||||
if measurement.value is None:
|
||||
continue
|
||||
native_unit = measurement.unit or description.native_unit_of_measurement
|
||||
entities.append(OpenSenseMapSensor(coordinator, description, native_unit))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class OpenSenseMapSensor(CoordinatorEntity[OpenSenseMapCoordinator], SensorEntity):
|
||||
"""Sensor entity representing a single measurement from an openSenseMap station."""
|
||||
|
||||
_attr_attribution = "Data provided by openSenseMap"
|
||||
_attr_has_entity_name = True
|
||||
entity_description: OpenSenseMapSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: OpenSenseMapCoordinator,
|
||||
description: OpenSenseMapSensorEntityDescription,
|
||||
native_unit: str | None,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_native_unit_of_measurement = native_unit
|
||||
station_id = coordinator.config_entry.data[CONF_STATION_ID]
|
||||
self._attr_unique_id = f"{station_id}_{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, station_id)},
|
||||
manufacturer=INTEGRATION_TITLE,
|
||||
configuration_url=f"https://opensensemap.org/explore/{station_id}",
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | str | None:
|
||||
"""Return the latest value reported by the station."""
|
||||
return self.entity_description.value_fn(self.coordinator.data).value
|
||||
@@ -8,11 +8,16 @@ from time import time
|
||||
from typing import Any
|
||||
|
||||
from reolink_aio.api import DUAL_LENS_DUAL_MOTION_MODELS, RETRY_ATTEMPTS
|
||||
from reolink_aio.const import UNKNOWN
|
||||
from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError
|
||||
|
||||
from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
@@ -29,6 +34,7 @@ from .const import (
|
||||
CONF_BC_PORT,
|
||||
CONF_FIRMWARE_CHECK_TIME,
|
||||
CONF_SUPPORTS_PRIVACY_MODE,
|
||||
CONF_UID,
|
||||
CONF_USE_HTTPS,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -95,6 +101,22 @@ async def async_setup_entry(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop)
|
||||
)
|
||||
|
||||
# do not allow changes to the UID
|
||||
if (
|
||||
config_entry.data.get(CONF_UID, host.api.uid) != host.api.uid
|
||||
and config_entry.data.get(CONF_UID) != UNKNOWN
|
||||
):
|
||||
await host.stop()
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="uid_mismatch",
|
||||
translation_placeholders={
|
||||
"name": host.api.nvr_name,
|
||||
"conf_uid": config_entry.data.get(CONF_UID, ""),
|
||||
"uid": host.api.uid,
|
||||
},
|
||||
)
|
||||
|
||||
# update the config info if needed for the next time
|
||||
if (
|
||||
host.api.port != config_entry.data[CONF_PORT]
|
||||
@@ -105,6 +127,7 @@ async def async_setup_entry(
|
||||
or host.api.baichuan_only != config_entry.data.get(CONF_BC_ONLY)
|
||||
or host.api.baichuan.connection_type.value
|
||||
!= config_entry.data.get(CONF_BC_CONNECT)
|
||||
or host.api.uid != config_entry.data.get(CONF_UID)
|
||||
):
|
||||
if host.api.port != config_entry.data[CONF_PORT]:
|
||||
_LOGGER.warning(
|
||||
@@ -130,6 +153,7 @@ async def async_setup_entry(
|
||||
CONF_BC_PORT: host.api.baichuan.port,
|
||||
CONF_BC_ONLY: host.api.baichuan_only,
|
||||
CONF_BC_CONNECT: host.api.baichuan.connection_type.value,
|
||||
CONF_UID: host.api.uid,
|
||||
CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"),
|
||||
}
|
||||
hass.config_entries.async_update_entry(config_entry, data=data)
|
||||
|
||||
@@ -41,6 +41,7 @@ from .const import (
|
||||
CONF_BC_ONLY,
|
||||
CONF_BC_PORT,
|
||||
CONF_SUPPORTS_PRIVACY_MODE,
|
||||
CONF_UID,
|
||||
CONF_USE_HTTPS,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -312,6 +313,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_BC_PORT] = host.api.baichuan.port
|
||||
user_input[CONF_BC_ONLY] = host.api.baichuan_only
|
||||
user_input[CONF_BC_CONNECT] = host.api.baichuan.connection_type.value
|
||||
user_input[CONF_UID] = host.api.uid
|
||||
user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported(
|
||||
None, "privacy_mode"
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ CONF_BC_ONLY = "baichuan_only"
|
||||
CONF_BC_CONNECT = "baichuan_connection"
|
||||
CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported"
|
||||
CONF_FIRMWARE_CHECK_TIME = "firmware_check_time"
|
||||
CONF_UID = "uid"
|
||||
|
||||
# Conserve battery by not waking the battery cameras each minute during normal update
|
||||
# Most props are cached in the Home Hub and updated, but some are skipped
|
||||
|
||||
@@ -11,6 +11,7 @@ import aiohttp
|
||||
from aiohttp.web import Request
|
||||
from reolink_aio.api import ALLOWED_SPECIAL_CHARS, Host
|
||||
from reolink_aio.baichuan import DEFAULT_BC_PORT
|
||||
from reolink_aio.const import UNKNOWN
|
||||
from reolink_aio.enums import ConnectionEnum, SubType
|
||||
from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError
|
||||
|
||||
@@ -40,6 +41,7 @@ from .const import (
|
||||
CONF_BC_ONLY,
|
||||
CONF_BC_PORT,
|
||||
CONF_SUPPORTS_PRIVACY_MODE,
|
||||
CONF_UID,
|
||||
CONF_USE_HTTPS,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -105,6 +107,7 @@ class ReolinkHost:
|
||||
bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT),
|
||||
bc_connection=bc_connection,
|
||||
bc_only=config.get(CONF_BC_ONLY, False),
|
||||
uid=config.get(CONF_UID, UNKNOWN),
|
||||
)
|
||||
|
||||
self.last_wake: defaultdict[int, float] = defaultdict(float)
|
||||
@@ -935,6 +938,8 @@ class ReolinkHost:
|
||||
def event_connection(self) -> str:
|
||||
"""Type of connection to receive events."""
|
||||
if self._api.baichuan.events_active:
|
||||
if self._api.baichuan.webhook_subscribed:
|
||||
return "Webhook push"
|
||||
return "TCP push"
|
||||
if self._webhook_reachable:
|
||||
return "ONVIF push"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -918,6 +918,9 @@
|
||||
"timeout": {
|
||||
"message": "Timeout waiting on a response: {err}"
|
||||
},
|
||||
"uid_mismatch": {
|
||||
"message": "UID {uid} of Reolink camera \"{name}\" did not match the stored configuration UID {conf_uid}, please check the connection details"
|
||||
},
|
||||
"unexpected": {
|
||||
"message": "Unexpected Reolink error: {err}"
|
||||
},
|
||||
|
||||
@@ -63,11 +63,21 @@ CODE_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
ARM_HOME_MODE_OPTIONS = ["1", "2", "3"]
|
||||
|
||||
PARTITION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Required(CONF_ARM_HOME_MODE, default=DEFAULT_CONF_ARM_HOME_MODE): vol.In(
|
||||
[1, 2, 3]
|
||||
vol.Required(CONF_ARM_HOME_MODE, default=DEFAULT_CONF_ARM_HOME_MODE): vol.All(
|
||||
vol.Coerce(str),
|
||||
selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=ARM_HOME_MODE_OPTIONS,
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
translation_key="arm_home_mode",
|
||||
)
|
||||
),
|
||||
vol.Coerce(int),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Constants for the Satel Integra integration."""
|
||||
|
||||
DEFAULT_CONF_ARM_HOME_MODE = 1
|
||||
DEFAULT_CONF_ARM_HOME_MODE = "1"
|
||||
DEFAULT_PORT = 7094
|
||||
|
||||
DOMAIN = "satel_integra"
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
"partition_number": "Partition number"
|
||||
},
|
||||
"data_description": {
|
||||
"arm_home_mode": "The mode in which the partition is armed when 'arm home' is used. For more information on what the differences are between them, please refer to Satel Integra manual.",
|
||||
"arm_home_mode": "The arming mode to use for 'arm home':\nMode 1 fully arms and bypasses zones that have the 'Bypassed if no exit' option enabled.\nMode 2 disarms interior zones; exterior zones trigger silent alarms and other alarm zones trigger loud alarms.\nMode 3 is like mode 2, but delayed zones are instant.",
|
||||
"name": "The name to give to the alarm panel",
|
||||
"partition_number": "Enter partition number to configure"
|
||||
},
|
||||
@@ -223,6 +223,13 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"arm_home_mode": {
|
||||
"options": {
|
||||
"1": "1 - Full arming + bypasses",
|
||||
"2": "2 - No interior zones",
|
||||
"3": "3 - No interior zones or entry delay"
|
||||
}
|
||||
},
|
||||
"binary_sensor_device_class": {
|
||||
"options": {
|
||||
"battery": "[%key:component::binary_sensor::entity_component::battery::name%]",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -170,6 +170,37 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
return result
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfigure flow."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_SERVER: user_input[CONF_SERVER],
|
||||
CONF_SENDER: user_input[CONF_SENDER],
|
||||
CONF_USERNAME: user_input.get(CONF_USERNAME),
|
||||
}
|
||||
)
|
||||
errors = await self.hass.async_add_executor_job(validate_input, user_input)
|
||||
if not errors:
|
||||
return self.async_update_and_abort(
|
||||
entry,
|
||||
data=user_input,
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
suggested_values=user_input or entry.data,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Import config from yaml."""
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -10,6 +11,29 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"encryption": "[%key:component::smtp::config::step::user::data::encryption%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"sender": "[%key:component::smtp::config::step::user::data::sender%]",
|
||||
"sender_name": "[%key:component::smtp::config::step::user::data::sender_name%]",
|
||||
"server": "[%key:common::config_flow::data::host%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"encryption": "[%key:component::smtp::config::step::user::data_description::encryption%]",
|
||||
"password": "[%key:component::smtp::config::step::user::data_description::password%]",
|
||||
"port": "[%key:component::smtp::config::step::user::data_description::port%]",
|
||||
"sender": "[%key:component::smtp::config::step::user::data_description::sender%]",
|
||||
"sender_name": "[%key:component::smtp::config::step::user::data_description::sender_name%]",
|
||||
"server": "[%key:component::smtp::config::step::user::data_description::server%]",
|
||||
"username": "[%key:component::smtp::config::step::user::data_description::username%]",
|
||||
"verify_ssl": "[%key:component::smtp::config::step::user::data_description::verify_ssl%]"
|
||||
},
|
||||
"title": "Reconfigure SMTP"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"encryption": "Connection security",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ from .coordinator import VistapoolDataUpdateCoordinator
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Vistapool Binary Sensor entities."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import VistapoolConfigEntry
|
||||
from .const import (
|
||||
PATH_HASCD,
|
||||
PATH_HASCL,
|
||||
PATH_HASHIDRO,
|
||||
PATH_HASIO,
|
||||
PATH_HASPH,
|
||||
PATH_HASRX,
|
||||
)
|
||||
from .coordinator import VistapoolDataUpdateCoordinator
|
||||
from .entity import VistapoolEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
TANK_MODULE_PATHS = (
|
||||
"modules.ph.tank",
|
||||
"modules.rx.tank",
|
||||
"modules.cl.tank",
|
||||
"modules.cd.tank",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class VistapoolBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes a Vistapool binary sensor entity."""
|
||||
|
||||
value_path: str
|
||||
exists_path: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
BINARY_SENSOR_DESCRIPTIONS: tuple[VistapoolBinarySensorEntityDescription, ...] = (
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="filtration",
|
||||
translation_key="filtration",
|
||||
device_class=BinarySensorDeviceClass.RUNNING,
|
||||
value_path="filtration.status",
|
||||
),
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="backwash",
|
||||
translation_key="backwash",
|
||||
device_class=BinarySensorDeviceClass.RUNNING,
|
||||
value_path="backwash.status",
|
||||
),
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="heating",
|
||||
translation_key="heating",
|
||||
device_class=BinarySensorDeviceClass.RUNNING,
|
||||
value_path="relays.filtration.heating.status",
|
||||
),
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="hidro_flow",
|
||||
translation_key="hidro_flow",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
value_path="hidro.fl1",
|
||||
exists_path=PATH_HASHIDRO,
|
||||
),
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="hidro_cover_reduction",
|
||||
translation_key="hidro_cover_reduction",
|
||||
device_class=BinarySensorDeviceClass.RUNNING,
|
||||
value_path="hidro.cover",
|
||||
exists_path=PATH_HASHIDRO,
|
||||
),
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="hidro_fl2",
|
||||
translation_key="hidro_fl2",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
value_path="hidro.fl2",
|
||||
exists_path=(PATH_HASHIDRO, PATH_HASCL),
|
||||
),
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="chlorine_pump",
|
||||
translation_key="chlorine_pump",
|
||||
device_class=BinarySensorDeviceClass.RUNNING,
|
||||
value_path="modules.cl.pump_status",
|
||||
exists_path=PATH_HASCL,
|
||||
),
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="redox_pump",
|
||||
translation_key="redox_pump",
|
||||
device_class=BinarySensorDeviceClass.RUNNING,
|
||||
value_path="modules.rx.pump_status",
|
||||
exists_path=PATH_HASRX,
|
||||
),
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="ph_pump_alarm",
|
||||
translation_key="ph_pump_alarm",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
value_path="modules.ph.al3",
|
||||
exists_path=PATH_HASPH,
|
||||
),
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="ph_acid_pump",
|
||||
translation_key="ph_acid_pump",
|
||||
device_class=BinarySensorDeviceClass.RUNNING,
|
||||
value_path="modules.ph.pump_high_on",
|
||||
exists_path=PATH_HASPH,
|
||||
),
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="ph_base_pump",
|
||||
translation_key="ph_base_pump",
|
||||
device_class=BinarySensorDeviceClass.RUNNING,
|
||||
value_path="modules.ph.pump_low_on",
|
||||
exists_path=PATH_HASPH,
|
||||
),
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="conductivity_module",
|
||||
translation_key="conductivity_module",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_path=PATH_HASCD,
|
||||
),
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="chlorine_module",
|
||||
translation_key="chlorine_module",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_path=PATH_HASCL,
|
||||
),
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="redox_module",
|
||||
translation_key="redox_module",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_path=PATH_HASRX,
|
||||
),
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="ph_module",
|
||||
translation_key="ph_module",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_path=PATH_HASPH,
|
||||
),
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="io_module",
|
||||
translation_key="io_module",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_path=PATH_HASIO,
|
||||
),
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="hidro_module",
|
||||
translation_key="hidro_module",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_path=PATH_HASHIDRO,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: VistapoolConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Vistapool binary sensors for every pool on the account."""
|
||||
entities: list[BinarySensorEntity] = []
|
||||
|
||||
for coordinator in entry.runtime_data.coordinators.values():
|
||||
for description in BINARY_SENSOR_DESCRIPTIONS:
|
||||
if description.exists_path is not None:
|
||||
required = (
|
||||
(description.exists_path,)
|
||||
if isinstance(description.exists_path, str)
|
||||
else description.exists_path
|
||||
)
|
||||
if not all(coordinator.get_value(path) for path in required):
|
||||
continue
|
||||
entities.append(VistapoolBinarySensor(coordinator, description))
|
||||
|
||||
if coordinator.get_value(PATH_HASHIDRO):
|
||||
is_electrolysis = coordinator.get_value("hidro.is_electrolysis")
|
||||
entities.append(
|
||||
VistapoolBinarySensor(
|
||||
coordinator,
|
||||
VistapoolBinarySensorEntityDescription(
|
||||
key="electrolysis_low" if is_electrolysis else "hydrolysis_low",
|
||||
translation_key=(
|
||||
"electrolysis_low" if is_electrolysis else "hydrolysis_low"
|
||||
),
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
value_path="hidro.low",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if any(
|
||||
coordinator.get_value(path)
|
||||
for path in (PATH_HASCD, PATH_HASCL, PATH_HASPH, PATH_HASRX)
|
||||
):
|
||||
entities.append(VistapoolDosingTankBinarySensor(coordinator))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class VistapoolBinarySensor(VistapoolEntity, BinarySensorEntity):
|
||||
"""Generic Vistapool binary sensor driven by an entity description."""
|
||||
|
||||
entity_description: VistapoolBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: VistapoolDataUpdateCoordinator,
|
||||
description: VistapoolBinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the binary sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = self.build_unique_id(description.key)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
value = self.coordinator.get_value(self.entity_description.value_path)
|
||||
if value is None:
|
||||
return None
|
||||
return value in (True, "1")
|
||||
|
||||
|
||||
class VistapoolDosingTankBinarySensor(VistapoolEntity, BinarySensorEntity):
|
||||
"""Dosing-tank low-level sensor: on if any installed dosing module reports low."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.PROBLEM
|
||||
_attr_translation_key = "dosing_tank"
|
||||
|
||||
def __init__(self, coordinator: VistapoolDataUpdateCoordinator) -> None:
|
||||
"""Initialize the dosing-tank binary sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = self.build_unique_id("dosing_tank")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if any tank is low, or None if no tank data is available."""
|
||||
values: list[Any] = []
|
||||
for path in TANK_MODULE_PATHS:
|
||||
value = self.coordinator.get_value(path)
|
||||
if value is not None:
|
||||
values.append(value)
|
||||
if not values:
|
||||
return None
|
||||
return any(value in (True, "1") for value in values)
|
||||
@@ -7,6 +7,7 @@ MODEL = "Vistapool"
|
||||
PATH_PREFIX = "main."
|
||||
PATH_HASCD = f"{PATH_PREFIX}hasCD"
|
||||
PATH_HASCL = f"{PATH_PREFIX}hasCL"
|
||||
PATH_HASIO = f"{PATH_PREFIX}hasIO"
|
||||
PATH_HASPH = f"{PATH_PREFIX}hasPH"
|
||||
PATH_HASRX = f"{PATH_PREFIX}hasRX"
|
||||
PATH_HASUV = f"{PATH_PREFIX}hasUV"
|
||||
|
||||
@@ -39,6 +39,68 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"backwash": {
|
||||
"name": "Backwash"
|
||||
},
|
||||
"chlorine_module": {
|
||||
"name": "Chlorine module"
|
||||
},
|
||||
"chlorine_pump": {
|
||||
"name": "Chlorine pump"
|
||||
},
|
||||
"conductivity_module": {
|
||||
"name": "Conductivity module"
|
||||
},
|
||||
"dosing_tank": {
|
||||
"name": "Dosing tank"
|
||||
},
|
||||
"electrolysis_low": {
|
||||
"name": "Electrolysis low"
|
||||
},
|
||||
"filtration": {
|
||||
"name": "Filtration"
|
||||
},
|
||||
"heating": {
|
||||
"name": "Heating"
|
||||
},
|
||||
"hidro_cover_reduction": {
|
||||
"name": "Hidro cover reduction"
|
||||
},
|
||||
"hidro_fl2": {
|
||||
"name": "Hidro FL2"
|
||||
},
|
||||
"hidro_flow": {
|
||||
"name": "Hidro flow"
|
||||
},
|
||||
"hidro_module": {
|
||||
"name": "Hidro module"
|
||||
},
|
||||
"hydrolysis_low": {
|
||||
"name": "Hydrolysis low"
|
||||
},
|
||||
"io_module": {
|
||||
"name": "IO module"
|
||||
},
|
||||
"ph_acid_pump": {
|
||||
"name": "pH acid pump"
|
||||
},
|
||||
"ph_base_pump": {
|
||||
"name": "pH base pump"
|
||||
},
|
||||
"ph_module": {
|
||||
"name": "pH module"
|
||||
},
|
||||
"ph_pump_alarm": {
|
||||
"name": "pH pump alarm"
|
||||
},
|
||||
"redox_module": {
|
||||
"name": "Redox module"
|
||||
},
|
||||
"redox_pump": {
|
||||
"name": "Redox pump"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"led_pulse": {
|
||||
"name": "LED next color"
|
||||
|
||||
+67
-2
@@ -5,7 +5,7 @@ of entities and react to changes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections import UserDict, defaultdict
|
||||
from collections import UserDict, defaultdict, deque
|
||||
from collections.abc import (
|
||||
Callable,
|
||||
Collection,
|
||||
@@ -1428,10 +1428,23 @@ def _verify_event_type_length_or_raise(event_type: EventType[_DataT] | str) -> N
|
||||
raise MaxLengthExceeded(event_type, "event_type", MAX_LENGTH_EVENT_EVENT_TYPE)
|
||||
|
||||
|
||||
# Maximum number of events fired by event listeners which will be dispatched
|
||||
# from a single top-level fire, to guard against event listeners firing
|
||||
# events in an endless loop.
|
||||
_MAX_QUEUED_EVENT_DISPATCHES: Final = 10_000
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""Allow the firing of and listening for events."""
|
||||
|
||||
__slots__ = ("_debug", "_hass", "_listeners", "_match_all_listeners")
|
||||
__slots__ = (
|
||||
"_debug",
|
||||
"_dispatching",
|
||||
"_fire_queue",
|
||||
"_hass",
|
||||
"_listeners",
|
||||
"_match_all_listeners",
|
||||
)
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize a new event bus."""
|
||||
@@ -1441,6 +1454,10 @@ class EventBus:
|
||||
self._match_all_listeners: list[_FilterableJobType[Any]] = []
|
||||
self._listeners[MATCH_ALL] = self._match_all_listeners
|
||||
self._hass = hass
|
||||
self._fire_queue: deque[
|
||||
tuple[EventType[Any] | str, Any, EventOrigin, Context | None, float]
|
||||
] = deque()
|
||||
self._dispatching = False
|
||||
self._async_logging_changed()
|
||||
self.async_listen(EVENT_LOGGING_CHANGED, self._async_logging_changed)
|
||||
|
||||
@@ -1520,6 +1537,54 @@ class EventBus:
|
||||
"Bus:Handling %s", _event_repr(event_type, origin, event_data)
|
||||
)
|
||||
|
||||
if self._dispatching:
|
||||
# Non-reentrant dispatch: an event fired from within an event
|
||||
# listener is queued and dispatched after the current dispatch
|
||||
# completes, so all listeners observe events in fire order. The
|
||||
# fire time is captured now since dispatch is deferred.
|
||||
self._fire_queue.append(
|
||||
(event_type, event_data, origin, context, time_fired or time.time())
|
||||
)
|
||||
return
|
||||
|
||||
self._dispatching = True
|
||||
try:
|
||||
self._async_dispatch(event_type, event_data, origin, context, time_fired)
|
||||
if self._fire_queue:
|
||||
self._async_drain_fire_queue()
|
||||
finally:
|
||||
self._dispatching = False
|
||||
|
||||
@callback
|
||||
def _async_drain_fire_queue(self) -> None:
|
||||
"""Dispatch events queued by event listeners, in fire order."""
|
||||
fire_queue = self._fire_queue
|
||||
dispatched = 0
|
||||
while fire_queue:
|
||||
if dispatched >= _MAX_QUEUED_EVENT_DISPATCHES:
|
||||
_LOGGER.error(
|
||||
"Aborting event dispatch: %d events were fired by event"
|
||||
" listeners while dispatching a single event; event"
|
||||
" listeners are likely firing events in an endless loop."
|
||||
" Dropping queued events: %s",
|
||||
dispatched,
|
||||
", ".join(sorted({str(item[0]) for item in fire_queue})),
|
||||
)
|
||||
fire_queue.clear()
|
||||
return
|
||||
self._async_dispatch(*fire_queue.popleft())
|
||||
dispatched += 1
|
||||
|
||||
@callback
|
||||
def _async_dispatch(
|
||||
self,
|
||||
event_type: EventType[_DataT] | str,
|
||||
event_data: _DataT | None,
|
||||
origin: EventOrigin,
|
||||
context: Context | None,
|
||||
time_fired: float | None,
|
||||
) -> None:
|
||||
"""Dispatch an event to its listeners."""
|
||||
listeners = self._listeners.get(event_type, EMPTY_LIST)
|
||||
if event_type not in EVENTS_EXCLUDED_FROM_MATCH_ALL:
|
||||
match_all_listeners = self._match_all_listeners
|
||||
|
||||
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 ()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ from pylint.checkers import BaseChecker
|
||||
from pylint.lint import PyLinter
|
||||
|
||||
from pylint_home_assistant.const import Module
|
||||
from pylint_home_assistant.helpers.module_info import parse_module
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -230,17 +231,16 @@ class HassImportsFormatChecker(BaseChecker):
|
||||
"""Check for improper `import _` invocations."""
|
||||
if self.current_package is None:
|
||||
return
|
||||
for module, _alias in node.names:
|
||||
if module.startswith(f"{self.current_package}."):
|
||||
for other_module, _alias in node.names:
|
||||
if other_module.startswith(f"{self.current_package}."):
|
||||
self.add_message("home-assistant-relative-import", node=node)
|
||||
continue
|
||||
if (
|
||||
module.startswith("homeassistant.components.")
|
||||
and len(module.split(".")) > 3
|
||||
):
|
||||
other_parsed := parse_module(other_module)
|
||||
) and other_parsed.module is not None:
|
||||
if (
|
||||
self.current_package.startswith("tests.components.")
|
||||
and self.current_package.split(".")[2] == module.split(".")[2]
|
||||
and self.current_package.split(".")[2] == other_parsed.domain
|
||||
):
|
||||
# Ignore check if the component being tested matches
|
||||
# the component being imported from
|
||||
@@ -314,18 +314,18 @@ class HassImportsFormatChecker(BaseChecker):
|
||||
self,
|
||||
node: nodes.ImportFrom,
|
||||
current_component: str | None,
|
||||
imported_parts: list[str],
|
||||
imported_component: str,
|
||||
other_component: str,
|
||||
other_module: str | None,
|
||||
) -> bool:
|
||||
"""Check for hass-component-root-import."""
|
||||
if (
|
||||
current_component == imported_component
|
||||
or imported_component in _IGNORE_ROOT_IMPORT
|
||||
current_component == other_component
|
||||
or other_component in _IGNORE_ROOT_IMPORT
|
||||
):
|
||||
return True
|
||||
|
||||
# Check for `from homeassistant.components.other.module import something`
|
||||
if len(imported_parts) > 3:
|
||||
if other_module is not None:
|
||||
self.add_message("home-assistant-component-root-import", node=node)
|
||||
return False
|
||||
|
||||
@@ -385,19 +385,16 @@ class HassImportsFormatChecker(BaseChecker):
|
||||
):
|
||||
return
|
||||
|
||||
if node.modname.startswith("homeassistant.components."):
|
||||
imported_parts = node.modname.split(".")
|
||||
imported_component = imported_parts[2]
|
||||
|
||||
if other_parsed := parse_module(node.modname):
|
||||
# Checks for hass-component-root-import
|
||||
if not self._check_for_component_root_import(
|
||||
node, current_component, imported_parts, imported_component
|
||||
node, current_component, other_parsed.domain, other_parsed.module
|
||||
):
|
||||
return
|
||||
|
||||
# Checks for hass-import-constant-alias
|
||||
if not self._check_for_constant_alias(
|
||||
node, current_component, imported_component
|
||||
node, current_component, other_parsed.domain
|
||||
):
|
||||
return
|
||||
|
||||
|
||||
+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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user