Compare commits

...

50 Commits

Author SHA1 Message Date
Erik 2a1baa9573 Queue nested firing of events 2026-06-11 15:08:56 +02:00
AlCalzone 8fed48d8ac Add sensor platform to openSenseMap (#172765) 2026-06-11 14:57:43 +02:00
starkillerOG f5f80e7080 Add Reolink webhook push diagnostics (#173499) 2026-06-11 14:56:39 +02:00
bkobus-bbx 1e18b77c67 Expose SET_TILT_POSITION only for calibrated tilt shutters (#173501) 2026-06-11 14:52:45 +02:00
bkobus-bbx 0dec701acd Add support for CO2Sensor to Blebox integration (#173507) 2026-06-11 14:48:48 +02:00
starkillerOG 851facd826 Reolink UID in the config entry (#173505)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-11 14:48:02 +02:00
Erik Montnemery e56c221eb1 Add tests documenting nested event firing behavior (#173491) 2026-06-11 14:46:06 +02:00
fdebrus a4eba86a6c Add binary_sensor platform to Vistapool (#172234)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-11 14:35:16 +02:00
Ken Schulz a25a55737f Handle read timeouts in google_wifi sensor update (#173511)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:31:44 +02:00
epenet 1b582f4089 Use parse_module helper in pylint import checker (visit_importfrom) (#173375)
Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com>
2026-06-11 13:56:53 +02:00
Manu c83323894c Add reconfiguration flow to SMTP integration (#173376) 2026-06-11 13:47:55 +02:00
epenet ac5e1f178b Use parse_module helper in pylint import checker (visit_import) (#173088) 2026-06-11 14:27:49 +03:00
Tom Matheussen 00a48df8cb Fix Satel Integra arm home mode selection (#173431) 2026-06-11 12:33:42 +02:00
Åke Strandberg 0d67cc0795 Add reconfigure flow to aqvify (#173355)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-11 12:00:41 +02:00
Diogo Gomes a2477d71fb Bump pytrydan to 1.0.2 (#173479) 2026-06-11 10:57:40 +02:00
Robert Resch c0b5dec23b Revert "Unify query token auth in http views" (#173466) 2026-06-11 10:30:54 +02:00
dependabot[bot] 64a68f38f0 Bump github/codeql-action from 4.36.1 to 4.36.2 (#173490)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 10:27:25 +02:00
Jan Bouwhuis d8ce17aaa3 Allow MQTT entities to be hidden by default (#168832)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-06-11 10:11:52 +02:00
bkobus-bbx da035f1ca3 Bump blebox_uniapi to v2.5.5 (#173365) 2026-06-11 09:49:25 +02:00
Simone Chemelli 5a27b29003 Bump aioamazondevices to 14.0.3 (#173478) 2026-06-11 08:31:49 +02:00
Hai-Nam Nguyen e04600eaec Bump hyponcloud to 1.0.1 (#173456) 2026-06-11 08:30:56 +02:00
starkillerOG 0b45db67e0 Bump reolink_aio to 0.21.0 (#173477) 2026-06-11 08:29:16 +02:00
Imou-OpenPlatform 3380a8ff29 Adds the camera platform for the Imou integration (#173064) 2026-06-11 08:18:42 +02:00
Erwin Douna fd21674ca1 Add MELCloud Home integration (#173185) 2026-06-11 07:45:31 +02:00
James Myatt 2e4185840a Improve todo tests (#173454) 2026-06-11 06:43:53 +02:00
renovate[bot] 1126e89d32 Update hassil to 3.7.0 (#173484)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-11 06:17:00 +02:00
renovate[bot] 08f4774e64 Update uv to 0.11.19 (#173483)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-11 06:14:51 +02:00
Stefan S c02147f386 Add integration kaku_rc (KlikAanKlikUit) (#170841) 2026-06-10 17:27:20 -04:00
Colin f48a4720e5 Update openevse quality_scale (#172801) 2026-06-10 22:46:00 +02:00
Rob Bierbooms 5b083f7959 Solve issue with double slash in url when writing data to InfluxDB (#173395) 2026-06-10 22:41:08 +02:00
Nikolai Rahimi 7bedf8074d Add debug logging for Mitsubishi Comfort polling failures (#173364) 2026-06-10 22:36:40 +02:00
James Myatt d656a1c091 Fix docstrings in shopping_list (#173462) 2026-06-10 22:36:08 +02:00
James Myatt 06d8570e2c (todo) Fix status field description (#173458) 2026-06-10 22:34:48 +02:00
Florent Thoumie 392f7b7260 iaqualink: add diagnostics support (#169518) 2026-06-10 22:31:27 +02:00
Erwin Douna eb4568fe54 Tado refactor to use dt_util (#173440) 2026-06-10 22:23:07 +02:00
Rasmus Graham 2ab3e0770f Bump vsure to 2.7.1 (#173470) 2026-06-10 22:19:17 +02:00
Joakim Plate 3435cfeaab Use dt util in gardena bluetooth (#173444) 2026-06-10 18:34:01 +02:00
Simone Chemelli 34956c1548 Redact more fields in diagnostics for Alexa devices (#173446) 2026-06-10 18:28:38 +02:00
Michael Davie 67740405a8 Add radar camera options flow to Environment Canada (#173415)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-10 18:28:04 +02:00
Denis Shulyaka 866437a0fd Claude Fable support for Anthropic (#173455) 2026-06-10 18:23:36 +02:00
Manu 03523f96c2 Bump pysml to 0.1.8 (#173449) 2026-06-10 18:02:23 +02:00
Simone Chemelli bbf91d7ee4 Change update interval for UptimeRobot (#173435) 2026-06-10 17:52:43 +02:00
orandasoft 130ca851f6 Add tests for itach integration (#173421) 2026-06-10 17:51:57 +02:00
Jan Bouwhuis f5b8e8ba81 Bump MQTT config flow to version 2.1 (#173094)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-10 17:31:23 +02:00
Michel van de Wetering d8182508bb Set Epson media player device class to projector (#172585) 2026-06-10 16:50:46 +02:00
Jan Bouwhuis 5a00de9e87 Do not enable MQTT entities though discovery that were disabled by user (#173404) 2026-06-10 14:41:45 +02:00
Christopher Fenner b1f2e80f40 Modify Bluetooth setup confirmation description for gardena_bluetooth integration (#173439) 2026-06-10 14:00:15 +02:00
Christian Lackas 1684ea7870 Bump homematicip to 2.13.0 (#173427) 2026-06-10 13:00:07 +02:00
Denis Shulyaka 742a6282f7 Bump Anthropic to 0.108.0 (#173430) 2026-06-10 12:57:36 +02:00
Joost Lekkerkerker ae23d0e3e7 Add user entities to Github (#173405) 2026-06-10 12:55:39 +02:00
162 changed files with 10302 additions and 458 deletions
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: "/language:python"
Generated
+4
View File
@@ -947,6 +947,8 @@ CLAUDE.md @home-assistant/core
/tests/components/kiosker/ @Claeysson
/homeassistant/components/kitchen_sink/ @home-assistant/core
/tests/components/kitchen_sink/ @home-assistant/core
/homeassistant/components/klik_aan_klik_uit/ @Phunkafizer
/tests/components/klik_aan_klik_uit/ @Phunkafizer
/homeassistant/components/kmtronic/ @dgomes
/tests/components/kmtronic/ @dgomes
/homeassistant/components/knocki/ @joostlek @jgatto1 @JakeBosh
@@ -1084,6 +1086,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/mediaroom/ @dgomes
/homeassistant/components/melcloud/ @erwindouna
/tests/components/melcloud/ @erwindouna
/homeassistant/components/melcloud_home/ @erwindouna
/tests/components/melcloud_home/ @erwindouna
/homeassistant/components/melissa/ @kennedyshead
/tests/components/melissa/ @kennedyshead
/homeassistant/components/melnor/ @vanstinator
@@ -12,7 +12,18 @@ from homeassistant.helpers.device_registry import DeviceEntry
from .coordinator import AmazonConfigEntry
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"}
TO_REDACT = {
CONF_NAME,
CONF_PASSWORD,
CONF_USERNAME,
"access_token",
"adp_token",
"device_private_key",
"refresh_token",
"store_authentication_cookie",
"title",
"website_cookies",
}
async def async_get_config_entry_diagnostics(
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==14.0.0"]
"requirements": ["aioamazondevices==14.0.3"]
}
@@ -1,7 +1,6 @@
"""Coordinator for the Anthropic integration."""
import datetime
import re
import anthropic
@@ -20,15 +19,12 @@ UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1)
type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator]
_model_short_form = re.compile(r"[^\d]-\d$")
@callback
def model_alias(model_id: str) -> str:
"""Resolve alias from versioned model name."""
if model_id[-2:-1] != "-" and not model_id.endswith("-preview"):
model_id = model_id[:-9]
if _model_short_form.search(model_id):
if model_id.endswith("-4"):
return model_id + "-0"
return model_id
@@ -9,5 +9,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["anthropic==0.96.0"]
"requirements": ["anthropic==0.108.0"]
}
@@ -65,18 +65,18 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
]
self._model_list_cache[entry.entry_id] = model_list
if "opus" in model:
family = "claude-opus"
elif "sonnet" in model:
family = "claude-sonnet"
else:
family = "claude-haiku"
family = (
model.removeprefix("claude-")
.removesuffix("-preview")
.translate(str.maketrans("", "", "0123456789-."))
or "haiku"
)
suggested_model = next(
(
model_option["value"]
for model_option in sorted(
(m for m in model_list if family in m["value"]),
(m for m in model_list if f"claude-{family}" in m["value"]),
key=lambda x: x["value"],
reverse=True,
)
+16 -1
View File
@@ -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"]
}
+10
View File
@@ -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",
}
+3 -3
View File
@@ -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."]
}
+15 -1
View File
@@ -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",
+18 -10
View File
@@ -1,19 +1,18 @@
"""The Brands integration."""
from collections import deque
from collections.abc import Container, Mapping
from http import HTTPStatus
import logging
from pathlib import Path
from random import SystemRandom
import time
from typing import Any, Final, override
from typing import Any, Final
from aiohttp import ClientError, web
from aiohttp import ClientError, hdrs, web
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.core import HomeAssistant, callback, valid_domain
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -109,18 +108,23 @@ def _read_brand_file(brand_dir: Path, image: str) -> bytes | None:
class _BrandsBaseView(HomeAssistantView):
"""Base view for serving brand images."""
use_query_token_for_auth = True
requires_auth = False
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the view."""
self._hass = hass
self._cache_dir = Path(hass.config.cache_path(DOMAIN))
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
return self._hass.data[DOMAIN]
def _authenticate(self, request: web.Request) -> None:
"""Authenticate the request using Bearer token or query token."""
access_tokens: deque[str] = self._hass.data[DOMAIN]
authenticated = (
request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens
)
if not authenticated:
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
raise web.HTTPForbidden
async def _serve_from_custom_integration(
self,
@@ -236,6 +240,8 @@ class BrandsIntegrationView(_BrandsBaseView):
image: str,
) -> web.Response:
"""Handle GET request for an integration brand image."""
self._authenticate(request)
if not valid_domain(domain) or image not in ALLOWED_IMAGES:
return web.Response(status=HTTPStatus.NOT_FOUND)
@@ -268,6 +274,8 @@ class BrandsHardwareView(_BrandsBaseView):
image: str,
) -> web.Response:
"""Handle GET request for a hardware brand image."""
self._authenticate(request)
if not CATEGORY_RE.match(category):
return web.Response(status=HTTPStatus.NOT_FOUND)
# Hardware images have dynamic names like "manufacturer_model.png"
+18 -14
View File
@@ -2,7 +2,7 @@
import asyncio
import collections
from collections.abc import Awaitable, Callable, Container, Coroutine, Mapping
from collections.abc import Awaitable, Callable, Coroutine
from contextlib import suppress
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
@@ -12,16 +12,16 @@ import logging
import os
from random import SystemRandom
import time
from typing import Any, Final, final, override
from typing import Any, Final, final
from aiohttp import web
from aiohttp import hdrs, web
import attr
from propcache.api import cached_property, under_cached_property
import voluptuous as vol
from webrtc_models import RTCIceCandidateInit
from homeassistant.components import websocket_api
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
@@ -776,26 +776,30 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
class CameraView(HomeAssistantView):
"""Base CameraView."""
use_query_token_for_auth = True
requires_auth = False
def __init__(self, component: EntityComponent[Camera]) -> None:
"""Initialize a basic camera view."""
self.component = component
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (camera := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return camera.access_tokens
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
if (camera := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") in camera.access_tokens
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
# Invalid sigAuth or camera access token
raise web.HTTPForbidden
if not camera.is_on:
_LOGGER.debug("Camera is off")
raise web.HTTPServiceUnavailable
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.6.0", "home-assistant-intents==2026.6.1"]
"requirements": ["hassil==3.7.0", "home-assistant-intents==2026.6.1"]
}
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["sml"],
"requirements": ["pysml==0.1.7"]
"requirements": ["pysml==0.1.8"]
}
@@ -11,7 +11,20 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import CONF_STATION, DOMAIN
from .const import (
CONF_RADAR_LAYER,
CONF_RADAR_LEGEND,
CONF_RADAR_OPACITY,
CONF_RADAR_RADIUS,
CONF_RADAR_TIMESTAMP,
CONF_STATION,
DEFAULT_RADAR_LAYER,
DEFAULT_RADAR_LEGEND,
DEFAULT_RADAR_OPACITY,
DEFAULT_RADAR_RADIUS,
DEFAULT_RADAR_TIMESTAMP,
DOMAIN,
)
from .coordinator import ECConfigEntry, ECDataUpdateCoordinator, ECRuntimeData
from .services import async_setup_services
@@ -54,7 +67,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
errors = errors + 1
_LOGGER.warning("Unable to retrieve Environment Canada weather")
radar_data = ECMap(coordinates=(lat, lon), layer="precip_type", legend=False)
options = config_entry.options
radar_data = ECMap(
coordinates=(lat, lon),
layer=options.get(CONF_RADAR_LAYER, DEFAULT_RADAR_LAYER),
legend=options.get(CONF_RADAR_LEGEND, DEFAULT_RADAR_LEGEND),
timestamp=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP),
layer_opacity=int(options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY)),
radius=int(options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS)),
)
radar_coordinator = ECDataUpdateCoordinator(
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
)
@@ -9,17 +9,42 @@ from env_canada import ECWeather, ec_exc
from env_canada.ec_weather import get_ec_sites_list
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
)
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
BooleanSelector,
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_STATION, CONF_TITLE, DOMAIN
from .const import (
CONF_RADAR_LAYER,
CONF_RADAR_LEGEND,
CONF_RADAR_OPACITY,
CONF_RADAR_RADIUS,
CONF_RADAR_TIMESTAMP,
CONF_STATION,
CONF_TITLE,
DEFAULT_RADAR_LAYER,
DEFAULT_RADAR_LEGEND,
DEFAULT_RADAR_OPACITY,
DEFAULT_RADAR_RADIUS,
DEFAULT_RADAR_TIMESTAMP,
DOMAIN,
RADAR_LAYERS,
)
_LOGGER = logging.getLogger(__name__)
@@ -57,6 +82,14 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
_station_codes: list[dict[str, str]] | None = None
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> OptionsFlowHandler:
"""Return the options flow handler."""
return OptionsFlowHandler()
async def _get_station_codes(self) -> list[dict[str, str]]:
"""Get station codes, cached after first call."""
if self._station_codes is None:
@@ -127,3 +160,55 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=data_schema, errors=errors
)
class OptionsFlowHandler(OptionsFlowWithReload):
"""Handle Environment Canada radar camera options."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the radar camera options."""
if user_input is not None:
return self.async_create_entry(data=user_input)
options = self.config_entry.options
data_schema = vol.Schema(
{
vol.Required(
CONF_RADAR_LAYER,
default=options.get(CONF_RADAR_LAYER, DEFAULT_RADAR_LAYER),
): SelectSelector(
SelectSelectorConfig(
options=RADAR_LAYERS,
translation_key="radar_layer",
)
),
vol.Required(
CONF_RADAR_LEGEND,
default=options.get(CONF_RADAR_LEGEND, DEFAULT_RADAR_LEGEND),
): BooleanSelector(),
vol.Required(
CONF_RADAR_TIMESTAMP,
default=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP),
): BooleanSelector(),
vol.Required(
CONF_RADAR_OPACITY,
default=options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY),
): NumberSelector(
NumberSelectorConfig(
min=0, max=100, step=1, mode=NumberSelectorMode.SLIDER
)
),
vol.Required(
CONF_RADAR_RADIUS,
default=options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS),
): NumberSelector(
NumberSelectorConfig(
min=10, max=2000, step=10, unit_of_measurement="km"
)
),
}
)
return self.async_show_form(step_id="init", data_schema=data_schema)
@@ -6,3 +6,19 @@ CONF_STATION = "station"
CONF_TITLE = "title"
DOMAIN = "environment_canada"
SERVICE_ENVIRONMENT_CANADA_FORECASTS = "get_forecasts"
CONF_RADAR_LAYER = "radar_layer"
CONF_RADAR_LEGEND = "radar_legend"
CONF_RADAR_TIMESTAMP = "radar_timestamp"
CONF_RADAR_OPACITY = "radar_opacity"
CONF_RADAR_RADIUS = "radar_radius"
RADAR_LAYERS = ["rain", "snow", "precip_type"]
# Defaults preserve the radar behaviour from before the options flow existed:
# the precipitation-type layer with the legend hidden.
DEFAULT_RADAR_LAYER = "precip_type"
DEFAULT_RADAR_LEGEND = False
DEFAULT_RADAR_TIMESTAMP = True
DEFAULT_RADAR_OPACITY = 65
DEFAULT_RADAR_RADIUS = 200
@@ -117,6 +117,33 @@
"message": "Environment Canada is not connected"
}
},
"options": {
"step": {
"init": {
"data": {
"radar_layer": "Radar type",
"radar_legend": "Show legend",
"radar_opacity": "Radar opacity",
"radar_radius": "Map radius",
"radar_timestamp": "Show timestamp"
},
"data_description": {
"radar_opacity": "Opacity of the radar layer overlay (0-100)",
"radar_radius": "Radius of the radar map in kilometres"
},
"title": "Radar camera options"
}
}
},
"selector": {
"radar_layer": {
"options": {
"precip_type": "Precipitation type",
"rain": "Rain",
"snow": "Snow"
}
}
},
"services": {
"get_alerts": {
"description": "Retrieves the alerts from the selected weather service.",
@@ -27,6 +27,7 @@ from epson_projector.const import (
)
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
@@ -62,6 +63,7 @@ class EpsonProjectorMediaPlayer(MediaPlayerEntity):
_attr_has_entity_name = True
_attr_name = None
_attr_device_class = MediaPlayerDeviceClass.PROJECTOR
_attr_supported_features = (
MediaPlayerEntityFeature.TURN_ON
@@ -2,7 +2,7 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
from gardena_bluetooth.const import (
AquaContourBattery,
@@ -279,7 +279,7 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity):
super()._handle_coordinator_update()
return
time = datetime.now(UTC) + timedelta(seconds=value) # pylint: disable=home-assistant-enforce-utcnow
time = dt_util.utcnow() + timedelta(seconds=value)
if not self._attr_native_value:
self._attr_native_value = time
super()._handle_coordinator_update()
@@ -10,7 +10,7 @@
},
"step": {
"confirm": {
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
"description": "Do you want to set up {name}?\n\nBefore you continue, make sure the device is in pairing mode."
},
"user": {
"data": {
+21 -5
View File
@@ -14,7 +14,12 @@ from homeassistant.helpers.aiohttp_client import (
)
from .const import CONF_REPOSITORIES, CONF_REPOSITORY, DOMAIN, SUBENTRY_TYPE_REPOSITORY
from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator
from .coordinator import (
GithubConfigEntry,
GitHubDataUpdateCoordinator,
GitHubRuntimeData,
GitHubUserDataUpdateCoordinator,
)
PLATFORMS: list[Platform] = [Platform.SENSOR]
@@ -27,7 +32,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo
client_name=SERVER_SOFTWARE,
)
entry.runtime_data = {}
user_coordinator = GitHubUserDataUpdateCoordinator(
hass=hass,
config_entry=entry,
client=client,
)
await user_coordinator.async_config_entry_first_refresh()
repositories: dict[str, GitHubDataUpdateCoordinator] = {}
for repository_subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_REPOSITORY):
repository = repository_subentry.data[CONF_REPOSITORY]
coordinator = GitHubDataUpdateCoordinator(
@@ -42,7 +54,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo
if not entry.pref_disable_polling:
await coordinator.subscribe()
entry.runtime_data[repository_subentry.subentry_id] = coordinator
repositories[repository_subentry.subentry_id] = coordinator
entry.runtime_data = GitHubRuntimeData(
user_coordinator=user_coordinator,
repositories=repositories,
)
entry.async_on_unload(entry.add_update_listener(async_update_entry))
@@ -57,8 +74,7 @@ async def async_update_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> N
async def async_unload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bool:
"""Unload a config entry."""
repositories = entry.runtime_data
for coordinator in repositories.values():
for coordinator in entry.runtime_data.repositories.values():
coordinator.unsubscribe()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+48 -1
View File
@@ -1,9 +1,11 @@
"""Custom data update coordinator for the GitHub integration."""
from dataclasses import dataclass
from typing import Any
from aiogithubapi import (
GitHubAPI,
GitHubAuthenticatedUserModel,
GitHubConnectionException,
GitHubEventModel,
GitHubException,
@@ -103,7 +105,52 @@ query ($owner: String!, $repository: String!) {
}
"""
type GithubConfigEntry = ConfigEntry[dict[str, GitHubDataUpdateCoordinator]]
type GithubConfigEntry = ConfigEntry[GitHubRuntimeData]
@dataclass
class GitHubRuntimeData:
"""Runtime data for the GitHub integration."""
user_coordinator: GitHubUserDataUpdateCoordinator
repositories: dict[str, GitHubDataUpdateCoordinator]
class GitHubUserDataUpdateCoordinator(
DataUpdateCoordinator[GitHubAuthenticatedUserModel]
):
"""Data update coordinator for the authenticated GitHub user."""
config_entry: GithubConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: GithubConfigEntry,
client: GitHubAPI,
) -> None:
"""Initialize GitHub user data update coordinator."""
self._client = client
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name="user",
update_interval=FALLBACK_UPDATE_INTERVAL,
)
async def _async_update_data(self) -> GitHubAuthenticatedUserModel:
"""Update data."""
try:
response = await self._client.user.get()
except (GitHubConnectionException, GitHubRatelimitException) as exception:
raise UpdateFailed(exception) from exception
except GitHubException as exception:
LOGGER.exception(exception)
raise UpdateFailed(exception) from exception
return response.data
class GitHubDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
@@ -33,7 +33,7 @@ async def async_get_config_entry_diagnostics(
else:
data["rate_limit"] = rate_limit_response.data.as_dict
repositories = config_entry.runtime_data
repositories = config_entry.runtime_data.repositories
data["repositories"] = {}
for coordinator in repositories.values():
@@ -4,6 +4,12 @@
"discussions_count": {
"default": "mdi:forum"
},
"followers": {
"default": "mdi:account-multiple"
},
"following": {
"default": "mdi:account-multiple-outline"
},
"forks_count": {
"default": "mdi:source-fork"
},
@@ -31,6 +37,12 @@
"merged_pulls_count": {
"default": "mdi:source-merge"
},
"public_gists": {
"default": "mdi:code-json"
},
"public_repos": {
"default": "mdi:source-repository"
},
"pulls_count": {
"default": "mdi:source-pull"
},
+87 -3
View File
@@ -4,6 +4,8 @@ from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any
from aiogithubapi import GitHubAuthenticatedUserModel
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
@@ -17,7 +19,11 @@ from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator
from .coordinator import (
GithubConfigEntry,
GitHubDataUpdateCoordinator,
GitHubUserDataUpdateCoordinator,
)
@dataclass(frozen=True, kw_only=True)
@@ -141,14 +147,58 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
)
@dataclass(frozen=True, kw_only=True)
class GitHubUserSensorEntityDescription(SensorEntityDescription):
"""Describes GitHub user sensor entity."""
value_fn: Callable[[GitHubAuthenticatedUserModel], StateType]
USER_SENSOR_DESCRIPTIONS: tuple[GitHubUserSensorEntityDescription, ...] = (
GitHubUserSensorEntityDescription(
key="followers",
translation_key="followers",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda data: data.followers,
),
GitHubUserSensorEntityDescription(
key="following",
translation_key="following",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda data: data.following,
),
GitHubUserSensorEntityDescription(
key="public_gists",
translation_key="public_gists",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda data: data.public_gists,
),
GitHubUserSensorEntityDescription(
key="public_repos",
translation_key="public_repos",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda data: data.public_repos,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: GithubConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up GitHub sensor based on a config entry."""
repositories = entry.runtime_data
for subentry_id, coordinator in repositories.items():
user_coordinator = entry.runtime_data.user_coordinator
async_add_entities(
GitHubUserSensorEntity(user_coordinator, description)
for description in USER_SENSOR_DESCRIPTIONS
)
for subentry_id, coordinator in entry.runtime_data.repositories.items():
async_add_entities(
(
GitHubSensorEntity(coordinator, description)
@@ -203,3 +253,37 @@ class GitHubSensorEntity(CoordinatorEntity[GitHubDataUpdateCoordinator], SensorE
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return the extra state attributes."""
return self.entity_description.attr_fn(self.coordinator.data)
class GitHubUserSensorEntity(
CoordinatorEntity[GitHubUserDataUpdateCoordinator], SensorEntity
):
"""Defines a GitHub user sensor entity."""
_attr_has_entity_name = True
entity_description: GitHubUserSensorEntityDescription
def __init__(
self,
coordinator: GitHubUserDataUpdateCoordinator,
entity_description: GitHubUserSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator=coordinator)
self.entity_description = entity_description
self._attr_unique_id = f"{coordinator.data.id}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(coordinator.data.id))},
name=coordinator.data.login,
manufacturer="GitHub",
configuration_url=f"https://github.com/{coordinator.data.login}",
entry_type=DeviceEntryType.SERVICE,
)
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)
@@ -36,6 +36,14 @@
"name": "Discussions",
"unit_of_measurement": "discussions"
},
"followers": {
"name": "Followers",
"unit_of_measurement": "followers"
},
"following": {
"name": "Following",
"unit_of_measurement": "users"
},
"forks_count": {
"name": "Forks",
"unit_of_measurement": "forks"
@@ -66,6 +74,14 @@
"name": "Merged pull requests",
"unit_of_measurement": "pull requests"
},
"public_gists": {
"name": "Public gists",
"unit_of_measurement": "gists"
},
"public_repos": {
"name": "Public repositories",
"unit_of_measurement": "repositories"
},
"pulls_count": {
"name": "Pull requests",
"unit_of_measurement": "pull requests"
@@ -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.
+23 -19
View File
@@ -2,21 +2,20 @@
import asyncio
import collections
from collections.abc import Container, Mapping
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
import os
from random import SystemRandom
from typing import Final, final, override
from typing import Final, final
from aiohttp import web
from aiohttp import hdrs, web
import httpx
from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import (
@@ -315,28 +314,33 @@ class ImageView(HomeAssistantView):
"""View to serve an image."""
name = "api:image:image"
use_query_token_for_auth = True
requires_auth = False
url = "/api/image_proxy/{entity_id}"
def __init__(self, component: EntityComponent[ImageEntity]) -> None:
"""Initialize an image view."""
self.component = component
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (image_entity := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return image_entity.access_tokens
@callback
def _get_image_entity(self, entity_id: str) -> ImageEntity:
"""Get image entity from request."""
async def _authenticate_request(
self, request: web.Request, entity_id: str
) -> ImageEntity:
"""Authenticate request and return image entity."""
if (image_entity := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") in image_entity.access_tokens
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
# Invalid sigAuth or image entity access token
raise web.HTTPForbidden
return image_entity
async def head(self, request: web.Request, entity_id: str) -> web.Response:
@@ -345,7 +349,7 @@ class ImageView(HomeAssistantView):
This is sent by some DLNA renderers, like Samsung ones, prior to sending
the GET request.
"""
image_entity = self._get_image_entity(entity_id)
image_entity = await self._authenticate_request(request, entity_id)
# Don't use `handle` as we don't care about the stream case, we only want
# to verify that the image exists.
@@ -361,7 +365,7 @@ class ImageView(HomeAssistantView):
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
image_entity = self._get_image_entity(entity_id)
image_entity = await self._authenticate_request(request, entity_id)
return await self.handle(request, image_entity)
async def handle(
+106
View File
@@ -0,0 +1,106 @@
"""Support for Imou camera entities."""
from pyimouapi.const import PARAM_HD, PARAM_MOTION_DETECT, PARAM_STATE
from pyimouapi.exceptions import ImouException
from pyimouapi.ha_device import ImouHaDevice
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import PARAM_HEADER_DETECT, imou_device_identifier
from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator
from .entity import ImouEntity
PARALLEL_UPDATES = 0
CAMERA_STREAM_RESOLUTION_SD = "SD"
# Defaults for pyimouapi ImouHaDeviceManager APIs (async_get_device_stream / async_get_device_image).
PYIMOUAPI_LIVE_PROTOCOL = "https"
PYIMOUAPI_SNAPSHOT_WAIT_SECONDS = 3
CAMERA_TYPES = (
("camera_sd", CAMERA_STREAM_RESOLUTION_SD),
("camera_hd", PARAM_HD),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ImouConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Imou camera entities."""
coordinator = entry.runtime_data
def _add_cameras(new_devices: list[ImouHaDevice]) -> None:
device_keys = {imou_device_identifier(device) for device in new_devices}
async_add_entities(
ImouCamera(coordinator, entity_type, device, resolution)
for device in coordinator.devices
if device.channel_id is not None
if imou_device_identifier(device) in device_keys
for entity_type, resolution in CAMERA_TYPES
)
coordinator.new_device_callbacks.append(_add_cameras)
@callback
def _remove_new_device_callback() -> None:
if _add_cameras in coordinator.new_device_callbacks:
coordinator.new_device_callbacks.remove(_add_cameras)
entry.async_on_unload(_remove_new_device_callback)
_add_cameras(coordinator.devices)
class ImouCamera(ImouEntity, Camera):
"""Representation of an Imou camera stream."""
_attr_supported_features = CameraEntityFeature.STREAM
def __init__(
self,
coordinator: ImouDataUpdateCoordinator,
entity_type: str,
device: ImouHaDevice,
resolution: str,
) -> None:
"""Initialize the camera entity."""
self._resolution = resolution
Camera.__init__(self)
super().__init__(coordinator, entity_type, device)
async def stream_source(self) -> str | None:
"""Return the live stream URL from the Imou cloud."""
try:
return await self.coordinator.device_manager.async_get_device_stream(
self.device,
self._resolution,
PYIMOUAPI_LIVE_PROTOCOL,
)
except ImouException as err:
raise HomeAssistantError(str(err)) from err
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return bytes of camera image."""
try:
return await self.coordinator.device_manager.async_get_device_image(
self.device,
PYIMOUAPI_SNAPSHOT_WAIT_SECONDS,
)
except ImouException as err:
raise HomeAssistantError(str(err)) from err
@property
def motion_detection_enabled(self) -> bool:
"""Return True when human and/or motion detection switch is on."""
header = self.device.switches.get(PARAM_HEADER_DETECT)
motion = self.device.switches.get(PARAM_MOTION_DETECT)
header_on = bool(header[PARAM_STATE]) if header else False
motion_on = bool(motion[PARAM_STATE]) if motion else False
return header_on or motion_on
+2 -2
View File
@@ -28,7 +28,7 @@ CONF_APP_SECRET = "app_secret"
PARAM_STATUS = "status"
PARAM_STATE = "state"
PARAM_HEADER_DETECT = "header_detect"
# How long each PTZ button press moves the camera, in milliseconds (Imou cloud API).
PTZ_MOVE_DURATION_MS = 500
@@ -36,4 +36,4 @@ PTZ_MOVE_DURATION_MS = 500
# Upper bound for a full coordinator refresh (device list + status for all devices).
UPDATE_TIMEOUT = 300
PLATFORMS = [Platform.BUTTON]
PLATFORMS = [Platform.BUTTON, Platform.CAMERA]
@@ -41,6 +41,14 @@
"ptz_up": {
"name": "PTZ up"
}
},
"camera": {
"camera_hd": {
"name": "Live view HD"
},
"camera_sd": {
"name": "Live view SD"
}
}
},
"selector": {
@@ -423,7 +423,7 @@ def get_influx_connection( # noqa: C901
if CONF_HOST in conf:
kwargs[CONF_HOST] = conf[CONF_HOST]
if (path := conf.get(CONF_PATH)) is not None:
if (path := conf.get(CONF_PATH)) is not None and path != "/":
kwargs[CONF_PATH] = path
if (port := conf.get(CONF_PORT)) is not None:
@@ -0,0 +1,55 @@
"""The KlikAanKlikUit RC integration."""
from dataclasses import dataclass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONF_TRANSMITTER
@dataclass(slots=True)
class KlikAanKlikUitRuntimeData:
"""Runtime data for the KlikAanKlikUit integration."""
transmitter_entity_id: str
type KlikAanKlikUitConfigEntry = ConfigEntry[KlikAanKlikUitRuntimeData]
PLATFORMS: list[Platform] = [Platform.SWITCH]
async def async_setup_entry(
hass: HomeAssistant, entry: KlikAanKlikUitConfigEntry
) -> bool:
"""Setup KlikAanKlikUit RC from a config entry."""
transmitter_entity_id = entry.data[CONF_TRANSMITTER]
if hass.states.get(transmitter_entity_id) is None:
raise ConfigEntryNotReady(
f"RF transmitter entity {transmitter_entity_id} is not available"
)
entry.runtime_data = KlikAanKlikUitRuntimeData(
transmitter_entity_id=transmitter_entity_id
)
entry.async_on_unload(entry.add_update_listener(async_update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: KlikAanKlikUitConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_update_listener(
hass: HomeAssistant, entry: KlikAanKlikUitConfigEntry
) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
@@ -0,0 +1,187 @@
"""Config flow for the KlikAanKlikUit RC integration."""
from typing import Any
from rf_protocols.commands import ModulationType
from rf_protocols.commands.kaku import KakuCommand
import voluptuous as vol
from homeassistant.components.radio_frequency import (
async_get_transmitters,
async_send_command,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE_ID
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, selector
from .const import (
CONF_CHANNEL,
CONF_GROUP,
CONF_TRANSMITTER,
DOMAIN,
REPEAT_COUNT_LEARN,
)
_SAMPLE_COMMAND = KakuCommand(
id=0,
channel=1,
group=False,
on=True,
)
_CONF_DEVICE_RESPONDED = "device_responded"
class KakuRcConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for KlikAanKlikUit."""
VERSION = 1
def __init__(self) -> None:
"""Initialize config flow."""
self._device_data: dict[str, Any] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle collecting initial setup data."""
try:
transmitters = async_get_transmitters(
self.hass,
_SAMPLE_COMMAND.frequency,
ModulationType.OOK,
)
except HomeAssistantError:
return self.async_abort(reason="no_transmitters")
if not transmitters:
return self.async_abort(reason="no_compatible_transmitters")
if user_input is not None:
transmitter: str = user_input[CONF_TRANSMITTER]
device_id: int = user_input[CONF_DEVICE_ID]
channel: int = user_input[CONF_CHANNEL]
group: bool = user_input[CONF_GROUP]
registry = er.async_get(self.hass)
entity_entry = registry.async_get(transmitter)
assert entity_entry is not None
await self.async_set_unique_id(
f"{entity_entry.id}_{device_id}_{channel}_{int(group)}"
)
self._abort_if_unique_id_configured()
self._device_data = {
CONF_TRANSMITTER: transmitter,
CONF_DEVICE_ID: device_id,
CONF_CHANNEL: channel,
CONF_GROUP: group,
}
return await self.async_step_pairing_mode()
return self.async_show_form(
step_id="user",
data_schema=self._async_user_schema(transmitters),
)
async def async_step_pairing_mode(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Ask user to put the target device in pairing mode."""
if user_input is None:
return self.async_show_form(
step_id="pairing_mode",
data_schema=vol.Schema({}),
)
assert self._device_data is not None
command = KakuCommand(
id=self._device_data[CONF_DEVICE_ID],
channel=self._device_data[CONF_CHANNEL],
group=self._device_data[CONF_GROUP],
on=True,
frame_repeats=REPEAT_COUNT_LEARN,
)
await async_send_command(
self.hass,
self._device_data[CONF_TRANSMITTER],
command,
)
return await self.async_step_pairing_result()
async def async_step_pairing_result(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm whether the device responded to the learn command."""
if user_input is not None:
if user_input[_CONF_DEVICE_RESPONDED]:
assert self._device_data is not None
title = (
f"KlikAanKlikUit ID {self._device_data[CONF_DEVICE_ID]} "
f"CH {self._device_data[CONF_CHANNEL]}"
)
return self.async_create_entry(
title=title,
data=self._device_data,
)
return await self.async_step_pairing_mode()
return self.async_show_form(
step_id="pairing_result",
data_schema=vol.Schema(
{
vol.Required(
_CONF_DEVICE_RESPONDED,
default=False,
): selector.BooleanSelector()
}
),
)
def _async_user_schema(
self,
transmitters: list[str],
user_input: dict[str, Any] | None = None,
) -> vol.Schema:
"""Build the one-step add form schema."""
if user_input is None:
user_input = {}
suggested_values: dict[str, Any] = {
CONF_TRANSMITTER: transmitters[0],
CONF_CHANNEL: 1,
CONF_GROUP: False,
}
suggested_values.update(user_input)
return self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_TRANSMITTER): selector.EntitySelector(
selector.EntitySelectorConfig(include_entities=transmitters),
),
vol.Required(CONF_DEVICE_ID): vol.All(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=0x3FFFFFF,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Coerce(int),
),
vol.Required(CONF_CHANNEL): vol.All(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=16,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Coerce(int),
),
vol.Required(CONF_GROUP): selector.BooleanSelector(),
}
),
suggested_values,
)
@@ -0,0 +1,19 @@
"""Constants and helpers for the KlikAanKlikUit (Kaku) integration."""
from typing import Final
from homeassistant.const import CONF_DEVICE_ID as HA_CONF_DEVICE_ID
DOMAIN: Final = "klik_aan_klik_uit"
CONF_TRANSMITTER: Final = "transmitter"
CONF_DEVICE_ID: Final = HA_CONF_DEVICE_ID
CONF_CHANNEL: Final = "channel"
CONF_GROUP: Final = "group"
REPEAT_COUNT_LEARN: Final = 10 # Higher repeats for learning/pairing
def format_device_summary(device_id: int, channel: int, group: bool) -> str:
"""Return a concise summary string for the configured device."""
group_text = "on" if group else "off"
return f"ID {device_id} CH {channel} Group {group_text}"
@@ -0,0 +1,11 @@
{
"domain": "klik_aan_klik_uit",
"name": "KlikAanKlikUit",
"codeowners": ["@Phunkafizer"],
"config_flow": true,
"dependencies": ["radio_frequency"],
"documentation": "https://www.home-assistant.io/integrations/klik_aan_klik_uit",
"integration_type": "device",
"iot_class": "assumed_state",
"quality_scale": "bronze"
}
@@ -0,0 +1,68 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not provide service actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not provide service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow:
status: exempt
comment: This integration uses local RF commands and has no account auth.
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
entity-category: done
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: This integration does not use outbound web requests.
strict-typing: todo
@@ -0,0 +1,41 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_compatible_transmitters": "No compatible radio frequency transmitter is available for this integration.",
"no_transmitters": "[%key:common::config_flow::abort::no_radio_frequency_transmitters%]"
},
"error": {},
"step": {
"pairing_mode": {
"description": "Bring device into learn mode by pushing it's button for more than 2 seconds, then press Ok.",
"title": "Pair device"
},
"pairing_result": {
"data": {
"device_responded": "Did the device respond?"
},
"data_description": {
"device_responded": "Select Yes if the target device reacted to the learn command."
},
"description": "Select Yes to continue setup. Select No to return to learn mode and resend the learn command.",
"title": "Confirm pairing"
},
"user": {
"data": {
"channel": "Channel",
"device_id": "Device ID",
"group": "Group",
"transmitter": "[%key:common::config_flow::data::radio_frequency_transmitter%]"
},
"data_description": {
"channel": "The channel of the target KlikAanKlikUit device (1-16).",
"device_id": "The unique KlikAanKlikUit device ID.",
"group": "Whether to send commands to the group address instead of a single device.",
"transmitter": "[%key:common::config_flow::data_description::radio_frequency_transmitter%]"
},
"description": "Choose the transmitter and configure your device settings."
}
}
}
}
@@ -0,0 +1,112 @@
"""Switch platform for KlikAanKlikUit RC on/off control."""
from typing import Any
from rf_protocols.commands.kaku import KakuCommand
from homeassistant.components.radio_frequency import async_send_command
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import CONF_DEVICE_ID, STATE_ON, STATE_UNAVAILABLE
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.restore_state import RestoreEntity
from . import KlikAanKlikUitConfigEntry
from .const import CONF_CHANNEL, CONF_GROUP, DOMAIN, format_device_summary
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
config_entry: KlikAanKlikUitConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the KlikAanKlikUit switch entity."""
async_add_entities([KlikAanKlikUitSwitch(config_entry)])
class KlikAanKlikUitSwitch(SwitchEntity, RestoreEntity):
"""Switch entity for KlikAanKlikUit devices."""
_attr_has_entity_name = True
_attr_name = "Output"
_attr_should_poll = False
def __init__(self, entry: KlikAanKlikUitConfigEntry) -> None:
"""Initialize the switch."""
self._transmitter = entry.runtime_data.transmitter_entity_id
self._device_id: int = entry.data[CONF_DEVICE_ID]
self._channel: int = entry.data[CONF_CHANNEL]
self._group: bool = entry.data[CONF_GROUP]
self._attr_unique_id = entry.entry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="KlikAanKlikUit",
model="KlikAanKlikUit RC device",
sw_version=format_device_summary(
self._device_id, self._channel, self._group
),
)
async def async_added_to_hass(self) -> None:
"""Subscribe to transmitter state and restore last switch state."""
await super().async_added_to_hass()
transmitter_entity_id = er.async_validate_entity_id(
er.async_get(self.hass), self._transmitter
)
@callback
def _async_transmitter_state_changed(
event: Event[EventStateChangedData],
) -> None:
new_state = event.data["new_state"]
available = new_state is not None and new_state.state != STATE_UNAVAILABLE
if available != self._attr_available:
self._attr_available = available
self.async_write_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass,
[transmitter_entity_id],
_async_transmitter_state_changed,
)
)
transmitter_state = self.hass.states.get(transmitter_entity_id)
self._attr_available = (
transmitter_state is not None
and transmitter_state.state != STATE_UNAVAILABLE
)
if (last_state := await self.async_get_last_state()) is not None:
self._attr_is_on = last_state.state == STATE_ON
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self._async_send(True)
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self._async_send(False)
self._attr_is_on = False
self.async_write_ha_state()
async def _async_send(self, on: bool) -> None:
"""Send on/off command."""
command = KakuCommand(
id=self._device_id,
group=self._group,
channel=self._channel,
on=on,
)
await async_send_command(
self.hass, self._transmitter, command, context=self._context
)
@@ -2,7 +2,7 @@
import asyncio
import collections
from collections.abc import Callable, Container, Mapping
from collections.abc import Callable
from contextlib import suppress
import datetime as dt
from enum import StrEnum
@@ -12,7 +12,7 @@ import hashlib
from http import HTTPStatus
import logging
import secrets
from typing import Any, Final, Required, TypedDict, final, override
from typing import Any, Final, Required, TypedDict, final
from urllib.parse import quote, urlparse
import aiohttp
@@ -24,7 +24,7 @@ import voluptuous as vol
from yarl import URL
from homeassistant.components import websocket_api
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( # noqa: F401
@@ -50,7 +50,7 @@ from homeassistant.const import ( # noqa: F401
STATE_PLAYING,
STATE_STANDBY,
)
from homeassistant.core import HomeAssistant, SupportsResponse, callback
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import Entity, EntityDescription
@@ -1249,7 +1249,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
class MediaPlayerImageView(HomeAssistantView):
"""Media player view to serve an image."""
use_query_token_for_auth = True
requires_auth = False
url = "/api/media_player_proxy/{entity_id}"
name = "api:media_player:image"
extra_urls = [
@@ -1262,15 +1262,6 @@ class MediaPlayerImageView(HomeAssistantView):
"""Initialize a media player view."""
self.component = component
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (player := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return (player.access_token,)
async def get(
self,
request: web.Request,
@@ -1280,9 +1271,21 @@ class MediaPlayerImageView(HomeAssistantView):
) -> web.Response:
"""Start a get request."""
if (player := self.component.get_entity(entity_id)) is None:
return web.Response(status=HTTPStatus.NOT_FOUND)
status = (
HTTPStatus.NOT_FOUND
if request[KEY_AUTHENTICATED]
else HTTPStatus.UNAUTHORIZED
)
return web.Response(status=status)
assert isinstance(player, MediaPlayerEntity)
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") == player.access_token
)
if not authenticated:
return web.Response(status=HTTPStatus.UNAUTHORIZED)
if media_content_type and media_content_id:
media_image_id = request.query.get("media_image_id")
@@ -0,0 +1,38 @@
"""The MELCloud Home integration."""
from aiomelcloudhome import MELCloudHome, MelCloudHomeAuth
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
PLATFORMS: list[Platform] = [Platform.CLIMATE]
async def async_setup_entry(
hass: HomeAssistant, entry: MelCloudHomeConfigEntry
) -> bool:
"""Set up MELCloud Home from a config entry."""
session = async_get_clientsession(hass)
auth = MelCloudHomeAuth(
username=entry.data[CONF_EMAIL],
password=entry.data[CONF_PASSWORD],
session=session,
)
client = MELCloudHome(auth=auth, session=session)
coordinator = MelCloudHomeCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: MelCloudHomeConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,372 @@
"""Climate platform for MELCloud Home."""
from typing import Any
from aiomelcloudhome import (
ATAFanSpeed,
ATAOperationMode,
ATAUnit,
ATAVaneHorizontal,
ATAVaneVertical,
ATWUnit,
ATWZoneMode,
)
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator
from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWZoneEntity
ATA_HVAC_MODE_TO_OPERATION: dict[HVACMode, ATAOperationMode] = {
HVACMode.HEAT: ATAOperationMode.HEAT,
HVACMode.COOL: ATAOperationMode.COOL,
HVACMode.AUTO: ATAOperationMode.AUTOMATIC,
HVACMode.DRY: ATAOperationMode.DRY,
HVACMode.FAN_ONLY: ATAOperationMode.FAN,
}
ATA_OPERATION_TO_HVAC_MODE: dict[ATAOperationMode, HVACMode] = {
value: key for key, value in ATA_HVAC_MODE_TO_OPERATION.items()
}
ATA_FAN_SPEED_TO_HA: dict[ATAFanSpeed, str] = {
ATAFanSpeed.AUTO: "auto",
ATAFanSpeed.ONE: "speed_1",
ATAFanSpeed.TWO: "speed_2",
ATAFanSpeed.THREE: "speed_3",
ATAFanSpeed.FOUR: "speed_4",
ATAFanSpeed.FIVE: "speed_5",
}
HA_FAN_SPEED_TO_ATA: dict[str, ATAFanSpeed] = {
value: key for key, value in ATA_FAN_SPEED_TO_HA.items()
}
ATA_VANE_VERTICAL_TO_HA: dict[ATAVaneVertical, str] = {
ATAVaneVertical.AUTO: "auto",
ATAVaneVertical.SWING: "swing",
ATAVaneVertical.ONE: "position_1",
ATAVaneVertical.TWO: "position_2",
ATAVaneVertical.THREE: "position_3",
ATAVaneVertical.FOUR: "position_4",
ATAVaneVertical.FIVE: "position_5",
}
HA_VANE_VERTICAL_TO_ATA: dict[str, ATAVaneVertical] = {
value: key for key, value in ATA_VANE_VERTICAL_TO_HA.items()
}
ATA_VANE_HORIZONTAL_TO_HA: dict[ATAVaneHorizontal, str] = {
ATAVaneHorizontal.AUTO: "auto",
ATAVaneHorizontal.SWING: "swing",
ATAVaneHorizontal.LEFT: "left",
ATAVaneHorizontal.LEFT_CENTRE: "left_centre",
ATAVaneHorizontal.CENTRE: "centre",
ATAVaneHorizontal.RIGHT_CENTRE: "right_centre",
ATAVaneHorizontal.RIGHT: "right",
}
HA_VANE_HORIZONTAL_TO_ATA: dict[str, ATAVaneHorizontal] = {
value: key for key, value in ATA_VANE_HORIZONTAL_TO_HA.items()
}
ATW_ZONE_MODE_TO_HVAC_MODE: dict[ATWZoneMode, HVACMode] = {
ATWZoneMode.HEAT_ROOM_TEMPERATURE: HVACMode.HEAT,
ATWZoneMode.HEAT_FLOW_TEMPERATURE: HVACMode.HEAT,
ATWZoneMode.HEAT_CURVE: HVACMode.HEAT,
ATWZoneMode.COOL_ROOM_TEMPERATURE: HVACMode.COOL,
ATWZoneMode.COOL_FLOW_TEMPERATURE: HVACMode.COOL,
}
HVAC_MODE_TO_ATW_ZONE_MODE: dict[HVACMode, ATWZoneMode] = {
HVACMode.HEAT: ATWZoneMode.HEAT_ROOM_TEMPERATURE,
HVACMode.COOL: ATWZoneMode.COOL_ROOM_TEMPERATURE,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: MelCloudHomeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MELCloud Home climate entities from a config entry."""
coordinator = entry.runtime_data
def _async_add_new_ata_units(units: list[ATAUnit]) -> None:
async_add_entities(ATAClimateEntity(coordinator, unit) for unit in units)
def _async_add_new_atw_units(units: list[ATWUnit]) -> None:
# Erwin: create zone 1 for all units, and zone 2 only when the unit supports it.
async_add_entities(
ATWZoneClimateEntity(coordinator, unit, zone_number)
for unit in units
for zone_number in (
[1, 2]
if (unit.capabilities and unit.capabilities.has_zone2)
or (unit.capabilities is None and unit.has_zone2)
else [1]
)
)
coordinator.new_ata_callbacks.append(_async_add_new_ata_units)
coordinator.new_atw_callbacks.append(_async_add_new_atw_units)
_async_add_new_ata_units(list(coordinator.ata_units.values()))
_async_add_new_atw_units(list(coordinator.atw_units.values()))
class ATAClimateEntity(MelCloudHomeATAUnitEntity, ClimateEntity):
"""Climate entity for a MELCloud Home Air-to-Air unit."""
_attr_translation_key = "ata_unit"
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_swing_modes = list(ATA_VANE_VERTICAL_TO_HA.values())
_attr_swing_horizontal_modes = list(ATA_VANE_HORIZONTAL_TO_HA.values())
def __init__(self, coordinator: MelCloudHomeCoordinator, unit: ATAUnit) -> None:
"""Initialize the entity."""
super().__init__(coordinator, unit)
features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
if unit.settings is not None:
if unit.settings.get("VaneVerticalDirection") is not None:
features |= ClimateEntityFeature.SWING_MODE
if unit.settings.get("VaneHorizontalDirection") is not None:
features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
self._attr_supported_features = features
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return HVAC modes supported by this unit based on its capabilities."""
if self.unit.capabilities is None:
return [
HVACMode.OFF,
HVACMode.HEAT,
HVACMode.COOL,
HVACMode.AUTO,
HVACMode.DRY,
HVACMode.FAN_ONLY,
]
modes = [HVACMode.OFF, HVACMode.HEAT]
if self.unit.capabilities.has_cool_operation_mode is not False:
modes.append(HVACMode.COOL)
if self.unit.capabilities.has_auto_operation_mode is not False:
modes.append(HVACMode.AUTO)
if self.unit.capabilities.has_dry_operation_mode is not False:
modes.append(HVACMode.DRY)
if self.unit.capabilities.has_fan_operation_mode is not False:
modes.append(HVACMode.FAN_ONLY)
return modes
@property
def fan_modes(self) -> list[str]:
"""Return fan modes supported by this unit based on its capabilities."""
capabilities = self.unit.capabilities
number = (
capabilities.number_of_fan_speeds
if capabilities is not None
and capabilities.number_of_fan_speeds is not None
else len(ATA_FAN_SPEED_TO_HA) - 1
)
all_speeds = list(ATA_FAN_SPEED_TO_HA.values())
return [all_speeds[0], *all_speeds[1 : number + 1]]
@property
def current_temperature(self) -> float | None:
"""Return the current room temperature."""
return self.unit.room_temperature if self.unit else None
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
return self.unit.set_temperature if self.unit else None
@property
def hvac_mode(self) -> HVACMode:
"""Return the current HVAC mode."""
return (
ATA_OPERATION_TO_HVAC_MODE.get(self.unit.operation_mode, HVACMode.OFF)
if self.unit.power and self.unit.operation_mode
else HVACMode.OFF
)
@property
def fan_mode(self) -> str | None:
"""Return the current fan mode."""
return (
ATA_FAN_SPEED_TO_HA.get(self.unit.set_fan_speed)
if self.unit.set_fan_speed is not None
else None
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
if hvac_mode == HVACMode.OFF:
await self.coordinator.client.control_ata_unit(self._unit_id, power=False)
else:
await self.coordinator.client.control_ata_unit(
self._unit_id,
power=True,
operation_mode=ATA_HVAC_MODE_TO_OPERATION[hvac_mode],
)
await self.coordinator.async_request_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the target temperature."""
await self.coordinator.client.control_ata_unit(
self._unit_id, set_temperature=kwargs[ATTR_TEMPERATURE]
)
await self.coordinator.async_request_refresh()
@property
def swing_mode(self) -> str:
"""Return the current vertical vane direction."""
return ATA_VANE_VERTICAL_TO_HA[self.unit.settings["VaneVerticalDirection"]]
@property
def swing_horizontal_mode(self) -> str:
"""Return the current horizontal vane direction."""
return ATA_VANE_HORIZONTAL_TO_HA[self.unit.settings["VaneHorizontalDirection"]]
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
"""Set the horizontal vane direction."""
await self.coordinator.client.control_ata_unit(
self._unit_id,
vane_horizontal_direction=HA_VANE_HORIZONTAL_TO_ATA[swing_horizontal_mode],
)
await self.coordinator.async_request_refresh()
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set the vertical vane direction."""
await self.coordinator.client.control_ata_unit(
self._unit_id, vane_vertical_direction=HA_VANE_VERTICAL_TO_ATA[swing_mode]
)
await self.coordinator.async_request_refresh()
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set the fan mode."""
await self.coordinator.client.control_ata_unit(
self._unit_id, set_fan_speed=HA_FAN_SPEED_TO_ATA[fan_mode]
)
await self.coordinator.async_request_refresh()
async def async_turn_on(self) -> None:
"""Turn the unit on."""
await self.coordinator.client.control_ata_unit(self._unit_id, power=True)
await self.coordinator.async_request_refresh()
async def async_turn_off(self) -> None:
"""Turn the unit off."""
await self.coordinator.client.control_ata_unit(self._unit_id, power=False)
await self.coordinator.async_request_refresh()
class ATWZoneClimateEntity(MelCloudHomeATWZoneEntity, ClimateEntity):
"""Climate entity for a MELCloud Home ATW zone."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return HVAC modes supported by this zone based on unit capabilities."""
modes = [HVACMode.OFF, HVACMode.HEAT]
if (
self.unit.capabilities is None
or self.unit.capabilities.has_cooling_mode is not False
):
modes.append(HVACMode.COOL)
return modes
@property
def _zone_mode(self) -> ATWZoneMode | None:
"""Return the current ATW zone mode."""
if self.zone_number == 1:
return self.unit.operation_mode_zone1
return self.unit.operation_mode_zone2
@property
def current_temperature(self) -> float | None:
"""Return the current zone temperature."""
return (
self.unit.room_temperature_zone1
if self.zone_number == 1
else self.unit.room_temperature_zone2
)
@property
def target_temperature(self) -> float | None:
"""Return the target zone temperature."""
return (
self.unit.set_temperature_zone1
if self.zone_number == 1
else self.unit.set_temperature_zone2
)
@property
def hvac_mode(self) -> HVACMode:
"""Return the current HVAC mode."""
return (
ATW_ZONE_MODE_TO_HVAC_MODE.get(self._zone_mode, HVACMode.OFF)
if self.unit.power and self._zone_mode
else HVACMode.OFF
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode."""
if hvac_mode == HVACMode.OFF:
await self.coordinator.client.control_atw_unit(self._unit_id, power=False)
else:
zone_mode = HVAC_MODE_TO_ATW_ZONE_MODE[hvac_mode]
if self.zone_number == 1:
await self.coordinator.client.control_atw_unit(
self._unit_id,
power=True,
operation_mode_zone1=zone_mode,
)
else:
await self.coordinator.client.control_atw_unit(
self._unit_id,
power=True,
operation_mode_zone2=zone_mode,
)
await self.coordinator.async_request_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the target temperature."""
temperature = kwargs[ATTR_TEMPERATURE]
if self.zone_number == 1:
await self.coordinator.client.control_atw_unit(
self._unit_id, set_temperature_zone1=temperature
)
else:
await self.coordinator.client.control_atw_unit(
self._unit_id, set_temperature_zone2=temperature
)
await self.coordinator.async_request_refresh()
async def async_turn_on(self) -> None:
"""Turn the zone on."""
await self.coordinator.client.control_atw_unit(self._unit_id, power=True)
await self.coordinator.async_request_refresh()
async def async_turn_off(self) -> None:
"""Turn the zone off."""
await self.coordinator.client.control_atw_unit(self._unit_id, power=False)
await self.coordinator.async_request_refresh()
@@ -0,0 +1,94 @@
"""Config flow for MELCloud Home."""
import logging
from typing import Any
from aiomelcloudhome import MELCloudHome, MelCloudHomeAuth
from aiomelcloudhome.exceptions import (
MelCloudHomeAuthenticationError,
MelCloudHomeConnectionError,
MelCloudHomeTimeoutError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
)
class MelCloudHomeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for MELCloud Home."""
async def _async_validate_credentials(
self, email: str, password: str
) -> tuple[dict[str, str], str | None]:
"""Validate credentials against MELCloud Home API."""
session = async_get_clientsession(self.hass)
auth = MelCloudHomeAuth(username=email, password=password, session=session)
client = MELCloudHome(auth=auth, session=session)
errors: dict[str, str] = {}
user_id: str | None = None
try:
context = await client.get_context()
except MelCloudHomeAuthenticationError:
errors["base"] = "invalid_auth"
except MelCloudHomeConnectionError:
errors["base"] = "cannot_connect"
except MelCloudHomeTimeoutError:
errors["base"] = "timeout_connect"
except Exception:
_LOGGER.exception(
"Unexpected error while validating MELCloud Home credentials"
)
errors["base"] = "unknown"
else:
user_id = context.id
return errors, user_id
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
errors, user_id = await self._async_validate_credentials(
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
)
if not errors:
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_EMAIL],
data={
CONF_EMAIL: user_input[CONF_EMAIL],
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
@@ -0,0 +1,3 @@
"""Constants for the MELCloud Home integration."""
DOMAIN = "melcloud_home"
@@ -0,0 +1,114 @@
"""Coordinator for MELCloud Home."""
from collections.abc import Callable
from datetime import timedelta
import logging
from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome, UserContext
from aiomelcloudhome.exceptions import (
MelCloudHomeAuthenticationError,
MelCloudHomeConnectionError,
MelCloudHomeTimeoutError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(seconds=60)
type MelCloudHomeConfigEntry = ConfigEntry[MelCloudHomeCoordinator]
class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]):
"""Coordinator to manage fetching MELCloud Home data."""
config_entry: MelCloudHomeConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: MelCloudHomeConfigEntry,
client: MELCloudHome,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
)
self.client = client
self.ata_units: dict[str, ATAUnit] = {}
self.atw_units: dict[str, ATWUnit] = {}
self.known_ata: set[str] = set()
self.known_atw: set[str] = set()
self.new_ata_callbacks: list[Callable[[list[ATAUnit]], None]] = []
self.new_atw_callbacks: list[Callable[[list[ATWUnit]], None]] = []
def _notify_new_units(self, data: UserContext) -> None:
"""Notify callbacks when new units are discovered."""
current_ata = [
unit for building in data.buildings for unit in building.air_to_air_units
]
self.ata_units = {unit.id: unit for unit in current_ata}
current_ata_ids = {unit.id for unit in current_ata}
self.known_ata &= current_ata_ids
new_ata_ids = current_ata_ids - self.known_ata
new_ata_units = [unit for unit in current_ata if unit.id in new_ata_ids]
if new_ata_units:
_LOGGER.debug("Discovered new ATA units: %s", new_ata_units)
self.known_ata.update(unit.id for unit in new_ata_units)
for ata_callback in self.new_ata_callbacks:
ata_callback(new_ata_units)
current_atw_units = [
unit for building in data.buildings for unit in building.air_to_water_units
]
self.atw_units = {unit.id: unit for unit in current_atw_units}
current_atw_ids = {unit.id for unit in current_atw_units}
self.known_atw &= current_atw_ids
new_atw_ids = current_atw_ids - self.known_atw
new_atw_units = [unit for unit in current_atw_units if unit.id in new_atw_ids]
if new_atw_units:
_LOGGER.debug("Discovered new ATW units: %s", new_atw_units)
self.known_atw.update(unit.id for unit in new_atw_units)
for atw_callback in self.new_atw_callbacks:
atw_callback(new_atw_units)
async def _async_update_data(self) -> UserContext:
"""Fetch data from the MELCloud Home API."""
try:
data = await self.client.get_context()
except MelCloudHomeAuthenticationError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except MelCloudHomeConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except MelCloudHomeTimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err
else:
return data
@callback
def _async_refresh_finished(self) -> None:
"""Notify entity callbacks after coordinator data has been updated."""
if self.data is not None:
self._notify_new_units(self.data)
@@ -0,0 +1,84 @@
"""Base entities for MELCloud Home."""
from abc import abstractmethod
from aiomelcloudhome import ATAUnit, ATWUnit
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import MelCloudHomeCoordinator
class MelCloudHomeEntity(CoordinatorEntity[MelCloudHomeCoordinator]):
"""Base entity for MELCloud Home."""
_attr_has_entity_name = True
_attr_name: str | None = None
class MelCloudHomeUnitEntity[_UnitT: (ATAUnit, ATWUnit)](MelCloudHomeEntity):
"""Base entity for a MELCloud Home unit."""
def __init__(self, coordinator: MelCloudHomeCoordinator, unit: _UnitT) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._unit_id = unit.id
self._attr_unique_id = unit.id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unit.id)},
name=unit.name,
manufacturer="Mitsubishi Electric",
)
@abstractmethod
def _units_dict(self) -> dict[str, _UnitT]:
"""Return the coordinator's units dict keyed by id."""
@property
def available(self) -> bool:
"""Return if the entity is available."""
return super().available and self._unit_id in self._units_dict()
@property
def unit(self) -> _UnitT:
"""Return the current unit state from coordinator data."""
return self._units_dict()[self._unit_id]
class MelCloudHomeATAUnitEntity(MelCloudHomeUnitEntity[ATAUnit]):
"""Base entity for a MELCloud Home Air-to-Air unit."""
def _units_dict(self) -> dict[str, ATAUnit]:
"""Return ATA units dict from coordinator."""
return self.coordinator.ata_units
class MelCloudHomeATWUnitEntity(MelCloudHomeUnitEntity[ATWUnit]):
"""Base entity for a MELCloud Home Air-to-Water unit."""
def _units_dict(self) -> dict[str, ATWUnit]:
"""Return ATW units dict from coordinator."""
return self.coordinator.atw_units
class MelCloudHomeATWZoneEntity(MelCloudHomeATWUnitEntity):
"""Base entity for an ATW zone entity."""
def __init__(
self,
coordinator: MelCloudHomeCoordinator,
unit: ATWUnit,
zone_number: int,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, unit)
self._zone_number = zone_number
self._attr_unique_id = f"{unit.id}_zone_{zone_number}"
self._attr_name = f"Zone {zone_number}"
@property
def zone_number(self) -> int:
"""Return the zone number."""
return self._zone_number
@@ -0,0 +1,12 @@
{
"domain": "melcloud_home",
"name": "MELCloud Home",
"codeowners": ["@erwindouna"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/melcloud_home",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["aiomelcloudhome"],
"quality_scale": "bronze",
"requirements": ["aiomelcloudhome==0.1.5"]
}
@@ -0,0 +1,68 @@
rules:
# Bronze
action-setup:
status: exempt
comment: No custom actions defined.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: No custom actions defined.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Coordinator handles polling.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: No custom actions defined.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
@@ -0,0 +1,77 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"timeout_connect": "Timeout while communicating with MELCloud Home API",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "Email address for your MELCloud Home account.",
"password": "Password for your MELCloud Home account."
},
"description": "Login to MELCloud Home with the email address and password associated with your account."
}
}
},
"entity": {
"climate": {
"ata_unit": {
"state_attributes": {
"fan_mode": {
"state": {
"auto": "[%key:common::state::auto%]",
"speed_1": "Speed 1",
"speed_2": "Speed 2",
"speed_3": "Speed 3",
"speed_4": "Speed 4",
"speed_5": "Speed 5"
}
},
"swing_horizontal_mode": {
"state": {
"auto": "[%key:common::state::auto%]",
"centre": "Centre",
"left": "Left",
"left_centre": "Left centre",
"right": "Right",
"right_centre": "Right centre",
"swing": "Swing"
}
},
"swing_mode": {
"state": {
"auto": "[%key:common::state::auto%]",
"position_1": "Position 1",
"position_2": "Position 2",
"position_3": "Position 3",
"position_4": "Position 4",
"position_5": "Position 5",
"swing": "Swing"
}
}
}
}
}
},
"exceptions": {
"cannot_connect": {
"message": "Error communicating with MELCloud Home API: {error}"
},
"invalid_auth": {
"message": "An error occurred while trying to authenticate: {error}"
},
"timeout_connect": {
"message": "Timeout while communicating with MELCloud Home API: {error}"
}
}
}
@@ -42,12 +42,23 @@ class MitsubishiComfortCoordinator(DataUpdateCoordinator[IndoorUnit | KumoStatio
try:
success = await self.device.update_status()
except Exception as err:
# The user-facing UpdateFailed message is translated and omits the IP;
# log it here so the failing address is visible in debug logs.
_LOGGER.debug(
"Error polling %s at %s: %s",
self.device.name,
self.device.address,
err,
)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"device_name": self.device.name},
) from err
if not success:
_LOGGER.debug(
"%s at %s returned no data", self.device.name, self.device.address
)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
+16 -15
View File
@@ -87,7 +87,6 @@ from .const import (
DEFAULT_RETAIN,
DOMAIN,
ENTITY_PLATFORMS,
ENTRY_OPTION_FIELDS,
MQTT_CONNECTION_STATE,
PROTOCOL_5,
PROTOCOL_311,
@@ -154,7 +153,6 @@ __all__ = [
"DEFAULT_RETAIN",
"DOMAIN",
"ENTITY_PLATFORMS",
"ENTRY_OPTION_FIELDS",
"MQTT",
"MQTT_BASE_SCHEMA",
"MQTT_CONNECTION_STATE",
@@ -468,27 +466,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate the options from config entry data."""
"""Migrate the config entry to the latest version."""
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
data: dict[str, Any] = dict(entry.data)
options: dict[str, Any] = dict(entry.options)
if entry.version == 1 and entry.minor_version < 2:
# Can be removed when the config entry is bumped to version 2.1
# with HA Core 2026.7.0. Read support for version 2.1 is expected with 2026.1
# From 2026.7 we will write version 2.1
for key in ENTRY_OPTION_FIELDS:
for key in (
CONF_DISCOVERY,
CONF_DISCOVERY_PREFIX,
"birth_message",
"will_message",
):
if key not in data:
continue
options[key] = data.pop(key)
# Write version 1.2 for backwards compatibility
hass.config_entries.async_update_entry(
entry,
data=data,
options=options,
version=1,
minor_version=2,
)
# Bump config entry to version 2.1
hass.config_entries.async_update_entry(
entry,
data=data,
options=options,
version=2,
minor_version=1,
)
_LOGGER.debug(
"Migration to version %s.%s successful", entry.version, entry.minor_version
@@ -273,6 +273,7 @@ ABBREVIATIONS = {
"l_ver_t": "latest_version_topic",
"l_ver_tpl": "latest_version_template",
"pl_inst": "payload_install",
"vis": "visible_by_default",
}
DEVICE_ABBREVIATIONS = {
+2 -12
View File
@@ -5,7 +5,7 @@ import logging
import jinja2
from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature
from homeassistant.const import CONF_DISCOVERY, CONF_PAYLOAD, Platform
from homeassistant.const import CONF_PAYLOAD, Platform
from homeassistant.exceptions import TemplateError
ATTR_DISCOVERY_HASH = "discovery_hash"
@@ -246,6 +246,7 @@ CONF_TILT_STATE_OPTIMISTIC = "tilt_optimistic"
CONF_TRANSITION = "transition"
CONF_URL_TEMPLATE = "url_template"
CONF_URL_TOPIC = "url_topic"
CONF_VISIBLE_BY_DEFAULT = "visible_by_default"
CONF_XY_COMMAND_TEMPLATE = "xy_command_template"
CONF_XY_COMMAND_TOPIC = "xy_command_topic"
CONF_XY_STATE_TOPIC = "xy_state_topic"
@@ -385,17 +386,6 @@ PAYLOAD_NONE = "None"
CONFIG_ENTRY_VERSION = 2
CONFIG_ENTRY_MINOR_VERSION = 1
# Split mqtt entry data and options
# Can be removed when config entry is bumped to version 2.1
# with HA Core 2026.7.0. Read support for version 2.1 is expected from 2026.1
# From 2026.7 we will write version 2.1
ENTRY_OPTION_FIELDS = (
CONF_DISCOVERY,
CONF_DISCOVERY_PREFIX,
"birth_message",
"will_message",
)
ENTITY_PLATFORMS = [
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
+35 -6
View File
@@ -95,6 +95,7 @@ from .const import (
CONF_SW_VERSION,
CONF_TOPIC,
CONF_VIA_DEVICE,
CONF_VISIBLE_BY_DEFAULT,
DOMAIN,
MQTT_CONNECTION_STATE,
)
@@ -1428,19 +1429,44 @@ class MqttEntity(
# Plan to update the entity_id based on `default_entity_id`
# if a deleted entity was found
self._update_registry_entity_id = self.entity_id
if (
self._config[CONF_ENABLED_BY_DEFAULT]
and deleted_entry
and deleted_entry.disabled_by is not None
reenable_condition := (
deleted_entry
and self._config[CONF_ENABLED_BY_DEFAULT]
and deleted_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
)
) or (
deleted_entry
and self._config[CONF_VISIBLE_BY_DEFAULT]
and deleted_entry.hidden_by is not None
):
# Enable previous deleted entity and enable it
# Enable previous deleted entity,
# if it was not disabled by the user.
# Only reset hidden by flag if it was not hidden by the user.
if (
deleted_entry.hidden_by is er.RegistryEntryHider.USER
and self._config[CONF_VISIBLE_BY_DEFAULT]
):
_LOGGER.info(
"Restored entity %s was configured as visible by default, "
"but was hidden by the user before, and will remain hidden",
self.entity_id,
)
if deleted_entry.hidden_by is er.RegistryEntryHider.USER:
hidden_by: er.RegistryEntryHider | None = er.RegistryEntryHider.USER
else:
hidden_by = (
None
if self._config[CONF_VISIBLE_BY_DEFAULT]
else er.RegistryEntryHider.INTEGRATION
)
recreated_entry = entity_registry.async_get_or_create(
entity_platform, DOMAIN, self.unique_id
)
entity_registry.async_update_entity(
recreated_entry.entity_id,
disabled_by=None,
disabled_by=None if reenable_condition else UNDEFINED,
hidden_by=hidden_by,
)
if discovery_data is None:
@@ -1589,6 +1615,9 @@ class MqttEntity(
self._attr_entity_registry_enabled_default = bool(
config.get(CONF_ENABLED_BY_DEFAULT, True)
)
self._attr_entity_registry_visible_default = bool(
config.get(CONF_VISIBLE_BY_DEFAULT, True)
)
self._attr_icon = config.get(CONF_ICON)
self._attr_entity_picture = config.get(CONF_ENTITY_PICTURE)
# Set the entity name if needed
+2
View File
@@ -52,6 +52,7 @@ from .const import (
CONF_SW_VERSION,
CONF_TOPIC,
CONF_VIA_DEVICE,
CONF_VISIBLE_BY_DEFAULT,
DEFAULT_PAYLOAD_AVAILABLE,
DEFAULT_PAYLOAD_NOT_AVAILABLE,
ENTITY_PLATFORMS,
@@ -184,6 +185,7 @@ MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
vol.Optional(CONF_DEFAULT_ENTITY_ID): cv.string,
vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval,
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(CONF_VISIBLE_BY_DEFAULT, default=True): cv.boolean,
}
)
@@ -26,31 +26,31 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: todo
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration has no options flow.
docs-installation-parameters: todo
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery: done
discovery-update-info: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: Integration supports a single device per config entry.
@@ -71,4 +71,4 @@ rules:
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
strict-typing: done
@@ -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
+25 -1
View File
@@ -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
+5
View File
@@ -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."""
+25 -1
View File
@@ -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",
+2 -3
View File
@@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import (
CONF_FALLBACK,
@@ -155,9 +156,7 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Tado resets somewhere between 12:00 and 13:00, Berlin time
# So let's pretend we're in Berlin...
reset_time = datetime.now( # pylint: disable=home-assistant-enforce-now
ZoneInfo("Europe/Berlin")
)
reset_time = dt_util.now(ZoneInfo("Europe/Berlin"))
today_reset = datetime.combine(
reset_time.date(),
+1 -1
View File
@@ -125,7 +125,7 @@
"name": "Rename item"
},
"status": {
"description": "A status or confirmation of the to-do item.",
"description": "A status for the to-do item.",
"name": "Set status"
}
},
@@ -8,8 +8,10 @@ from homeassistant.const import Platform
LOGGER: Logger = getLogger(__package__)
# The free plan is limited to 10 requests/minute
COORDINATOR_UPDATE_INTERVAL: timedelta = timedelta(seconds=10)
# The free plan is formally limited to 10 requests/minute
# But real world says 5 requests/minute is the real limit
# Opened a ticket with support with no response for 2 months
COORDINATOR_UPDATE_INTERVAL: timedelta = timedelta(seconds=15)
DOMAIN: Final = "uptimerobot"
PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
+1 -1
View File
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/v2c",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pytrydan==1.0.1"]
"requirements": ["pytrydan==1.0.2"]
}
@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["verisure"],
"requirements": ["vsure==2.7.0"]
"requirements": ["vsure==2.7.1"]
}
@@ -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
View File
@@ -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
+2
View File
@@ -386,6 +386,7 @@ FLOWS = {
"kegtron",
"keymitt_ble",
"kiosker",
"klik_aan_klik_uit",
"kmtronic",
"knocki",
"knx",
@@ -447,6 +448,7 @@ FLOWS = {
"medcom_ble",
"media_extractor",
"melcloud",
"melcloud_home",
"melnor",
"met",
"met_eireann",
+12
View File
@@ -3581,6 +3581,12 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
"klik_aan_klik_uit": {
"name": "KlikAanKlikUit",
"integration_type": "device",
"config_flow": true,
"iot_class": "assumed_state"
},
"kmtronic": {
"name": "KMtronic",
"integration_type": "device",
@@ -4166,6 +4172,12 @@
"config_flow": false,
"iot_class": "local_polling"
},
"melcloud_home": {
"name": "MELCloud Home",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"melissa": {
"name": "Melissa",
"integration_type": "hub",
+3 -15
View File
@@ -1,6 +1,6 @@
"""Helper to track the current http request."""
from collections.abc import Awaitable, Callable, Container, Mapping
from collections.abc import Awaitable, Callable
from contextvars import ContextVar
from http import HTTPStatus
import inspect
@@ -20,7 +20,7 @@ import voluptuous as vol
from homeassistant import exceptions
from homeassistant.const import CONTENT_TYPE_JSON
from homeassistant.core import Context, HomeAssistant, callback, is_callback
from homeassistant.core import Context, HomeAssistant, is_callback
from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS, format_unserializable_data
from .json import find_paths_unserializable_data, json_bytes, json_dumps
@@ -55,13 +55,7 @@ def request_handler_factory(
authenticated = request.get(KEY_AUTHENTICATED, False)
if view.use_query_token_for_auth and not authenticated:
token = request.query.get("token")
if token and token in view.get_valid_auth_tokens(request.match_info):
_LOGGER.debug("Authenticated request with query token")
authenticated = True
if (view.requires_auth or view.use_query_token_for_auth) and not authenticated:
if view.requires_auth and not authenticated:
# Import here to avoid circular dependency with network.py
from .network import NoURLAvailableError, get_url # noqa: PLC0415
@@ -135,7 +129,6 @@ class HomeAssistantView:
extra_urls: list[str] = []
# Views inheriting from this class can override this
requires_auth = True
use_query_token_for_auth = False
cors_allowed = False
@staticmethod
@@ -211,8 +204,3 @@ class HomeAssistantView:
if allow_cors:
for route in routes:
allow_cors(route)
@callback
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
return ()
+2 -2
View File
@@ -37,7 +37,7 @@ go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==6.8.3
hass-nabucasa==2.2.0
hassil==3.6.0
hassil==3.7.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260527.5
home-assistant-intents==2026.6.1
@@ -70,7 +70,7 @@ standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.9
urllib3>=2.0
uv==0.11.18
uv==0.11.19
voluptuous-openapi==0.3.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2
@@ -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
View File
@@ -74,7 +74,7 @@ dependencies = [
"typing-extensions>=4.15.0,<5.0",
"ulid-transform==2.2.9",
"urllib3>=2.0",
"uv==0.11.18",
"uv==0.11.19",
"voluptuous==0.15.2",
"voluptuous-serialize==2.7.0",
"voluptuous-openapi==0.3.0",
+2 -2
View File
@@ -25,7 +25,7 @@ cryptography==48.0.0
fnv-hash-fast==2.0.3
ha-ffmpeg==3.2.2
hass-nabucasa==2.2.0
hassil==3.6.0
hassil==3.7.0
home-assistant-bluetooth==2.0.0
home-assistant-intents==2026.6.1
httpx==0.28.1
@@ -55,7 +55,7 @@ standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.9
urllib3>=2.0
uv==0.11.18
uv==0.11.19
voluptuous-openapi==0.3.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2

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