Compare commits

...

72 Commits

Author SHA1 Message Date
J. Nick Koston e7f3e5637f Bump aioshelly to 13.26.1 (#172160) 2026-05-25 10:31:21 -05:00
Paul Bottein 80cefc74ec Update rf-protocols to 4.0.0 (#172131) 2026-05-25 17:17:53 +02:00
J. Nick Koston 2f33b4b7f9 Bump aioharmony to 1.0.8 (#172116) 2026-05-25 17:16:47 +02:00
J. Nick Koston cf52a7a509 Bump bluetooth-adapters to 2.2.0 (#172120) 2026-05-25 10:10:54 -05:00
Mattias Arrelid f5835f849a Update anyio to 4.13.0 (#172138) 2026-05-25 09:36:53 -05:00
J. Nick Koston ec5210dca8 Bump led-ble to 1.1.11 (#172154) 2026-05-25 09:35:11 -05:00
Michael 422ea1a9b1 Bump wakeonlan to 3.3.0 (#172150) 2026-05-25 16:13:38 +02:00
Artur Pragacz b6f69f6b99 Clean up should_expose in google assistant (#171937) 2026-05-25 13:48:49 +02:00
Tom a2a3819241 Extract ProxmoxVE TOKEN_ID from full token string (#172129) 2026-05-25 12:57:05 +02:00
Erwin Douna 3ce33b0ac6 Proxmox fix duplicate const (#171352) 2026-05-25 12:56:19 +02:00
bkobus-bbx e507a97d8b Bump blebox_uniapi to v2.5.4 (#172130) 2026-05-25 12:44:25 +02:00
Erik Montnemery 5801fdad14 Add property in_zones to TrackerEntity (#171765) 2026-05-25 12:22:45 +02:00
johanzander 2f4abd6a25 growatt_server: implement dynamic-devices and stale-devices Gold rules (#166081)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-25 11:37:25 +02:00
Erik Montnemery 1c045ab715 Add negative test to WS API test test_test_condition (#171427) 2026-05-25 11:26:14 +02:00
Simone Chemelli d4ca541a96 Cleanup tests for Waze Travel Time (#172122) 2026-05-25 11:23:34 +02:00
rlrghb a07a9dc6c8 Add Lichess sensors for Ultra Bullet, Correspondence, and variant perfs (#172098)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 11:22:51 +02:00
Mick Vleeshouwer 6d60b3a23a Add tests for Overkiz switch platform (#171901) 2026-05-25 11:19:58 +02:00
Will Pike 37bb895b91 Bump python-ecobee-api to 0.4.0 (#172108) 2026-05-25 11:19:45 +02:00
Onero-testdev f87dc917a6 Add support for SwitchBot Weather Station (#170571)
Co-authored-by: Fan Kai <fankai@onero.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-25 11:14:42 +02:00
Marc Hörsken 71a15c188e Bump pywmspro to 0.3.5 to avoid freeze of WMS WebControl pro (#172096) 2026-05-25 11:14:15 +02:00
Simone Chemelli 003ecdb867 Handle connection error in Waze Travel Time (#172086) 2026-05-25 11:13:37 +02:00
A. Gideonse ec7e5e5a75 Switch DHCP discovery to zeroconf for Indevolt (#172093) 2026-05-25 11:13:00 +02:00
Tomer 7587f062e1 Bump victron-mqtt to 2026.5.4 (#170876) 2026-05-25 11:09:53 +02:00
J. Nick Koston 11970144e4 Bump bluetooth-auto-recovery to 1.6.4 (#172114) 2026-05-25 10:50:13 +02:00
Paulus Schoutsen 70750a6d79 Guard AppleTV will only send valid URLs to AirPlay (#172103) 2026-05-25 10:48:17 +02:00
dependabot[bot] a53437315f Bump codecov/codecov-action from 6.0.0 to 6.0.1 (#172124)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 10:41:52 +02:00
Markus Adrario 5c73ad0310 Homee: exception-translations (#171995) 2026-05-25 09:13:38 +02:00
J. Nick Koston 4a04a271ec Bump cached-ipaddress to 1.1.1 (#172110) 2026-05-25 08:40:05 +02:00
J. Nick Koston 52c27bdea5 Bump inkbird-ble to 1.2.3 (#172113) 2026-05-25 08:39:42 +02:00
J. Nick Koston 6fdc52c002 Bump dbus-fast to 5.0.9 (#172118) 2026-05-25 08:39:19 +02:00
J. Nick Koston e560bbc103 Bump aiodhcpwatcher to 1.2.6 (#172105) 2026-05-24 23:10:11 -05:00
J. Nick Koston b8c573685f Trigger active scan when picking an idasen_desk device in the config flow (#172068) 2026-05-24 23:58:11 -04:00
J. Nick Koston 3764b70b90 Bump bleak, habluetooth, and bleak-retry-connector for BlueZ backend fix (#172094) 2026-05-24 23:57:29 -04:00
Paulus Schoutsen 5d2de6f82b Prefer local file access for streaming in AppleTV (#172102) 2026-05-24 22:56:35 -04:00
fdebrus 64d17f44fa Add aquarite integration (#168051)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-25 00:48:48 +02:00
Sebastian Lövdahl 6f67d44cfe Fix swallowed exceptions in vallox action handlers (#170839) 2026-05-25 00:42:13 +02:00
Robert Svensson def3befb0e Use discovered Axis name for config entry title and device name (#171894) 2026-05-25 00:35:56 +02:00
renovate[bot] 05716ae196 Update infrared-protocols to 5.6.0 (#171916) 2026-05-25 00:29:13 +02:00
rlrghb c0a864297f Update aiolichess to 1.3.0 (#172082)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:26:04 +02:00
J. Nick Koston 04bb84cd03 Add AUTO bluetooth scanner mode to Shelly (#172008) 2026-05-24 14:53:54 -05:00
J. Nick Koston cb55accc3b Use latest service info for INKBIRD fallback poll recency check (#172041) 2026-05-24 14:43:58 -05:00
Erwin Douna d21c227804 SMA refactor validate input (#171956) 2026-05-24 19:59:31 +02:00
Cyrill Raccaud 1ebccd9fa2 Update cookidoo API requirement to version 0.17.2 (#171793) 2026-05-24 19:57:44 +02:00
rlrghb cfbd0f3217 Add puzzles to Lichess integration (#171987)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-24 19:55:07 +02:00
Kamil Breguła 4afb7c0997 Use explicit translation keys in WLED number entities (#171984)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-24 19:49:48 +02:00
Allen Porter 105caccc51 Add shared rainbird device lock and increase calendar timeout (#172002) 2026-05-24 19:38:07 +02:00
J. Nick Koston 6419551117 Trigger active scan when picking an inkbird device in the config flow (#172048) 2026-05-24 19:30:42 +02:00
J. Nick Koston 585bd6616a Trigger active scan when picking a switchbot device in the config flow (#172046) 2026-05-24 19:30:33 +02:00
Max Michels b8dd97cf21 Replace duplicate constants in google_generative_ai_conversation with homeassistant.const imports (#172050) 2026-05-24 19:29:24 +02:00
J. Nick Koston 68fc4aed78 Trigger active scan when picking a govee_ble device in the config flow (#172051) 2026-05-24 19:29:03 +02:00
Max Michels 7dbb259625 Replace duplicate constants in aws_s3 with homeassistant.const imports (#172055) 2026-05-24 19:27:40 +02:00
J. Nick Koston 057eac7fb6 Trigger active scan when picking a rapt_ble device in the config flow (#172054) 2026-05-24 19:27:07 +02:00
Max Michels 31c9cdf742 Replace duplicate constants in xthings_cloud with homeassistant.const imports (#172076) 2026-05-24 19:25:39 +02:00
J. Nick Koston 3147104132 Trigger active scan when picking a togrill device in the config flow (#172072) 2026-05-24 19:22:52 +02:00
Max Michels d6d0f37b52 Replace duplicate constants in clicksend_tts with homeassistant.const imports (#172058) 2026-05-24 19:21:39 +02:00
G Johansson 75e48745a8 Remove useless test from trafikverket_camera (#172059) 2026-05-24 19:21:29 +02:00
J. Nick Koston 533417778c Trigger active scan when picking a sensirion_ble device in the config flow (#172056) 2026-05-24 19:21:21 +02:00
J. Nick Koston e49fd4ebbd Trigger active scan when picking a victron_ble device in the config flow (#172057) 2026-05-24 19:21:18 +02:00
Max Michels 8412b029b1 Replace duplicate constants in cloudflare_r2 with homeassistant.const imports (#172060) 2026-05-24 19:21:08 +02:00
J. Nick Koston c65de7521f Trigger active scan when picking a tilt_ble device in the config flow (#172053) 2026-05-24 19:20:48 +02:00
J. Nick Koston 752c17917e Trigger active scan when picking a ruuvitag_ble device in the config flow (#172062) 2026-05-24 19:19:58 +02:00
Max Michels f643c7ddc6 Replace duplicate constants in intent_script with homeassistant.const imports (#172066) 2026-05-24 19:19:29 +02:00
J. Nick Koston 6f5d4cf991 Trigger active scan when picking a ld2410_ble device in the config flow (#172061) 2026-05-24 19:18:55 +02:00
Max Michels b52466fed1 Replace duplicate constants in linux_battery with homeassistant.const imports (#172070) 2026-05-24 19:18:42 +02:00
J. Nick Koston 189534e32b Trigger active scan when picking a eufylife_ble device in the config flow (#172067) 2026-05-24 19:18:30 +02:00
J. Nick Koston 684ae23b18 Trigger active scan when picking a thermopro device in the config flow (#172052) 2026-05-24 19:17:35 +02:00
J. Nick Koston f4d2f65602 Trigger active scan when picking a qingping device in the config flow (#172071) 2026-05-24 19:17:05 +02:00
J. Nick Koston 65879ff37b Trigger active scan when picking a xiaomi_ble device in the config flow (#172074) 2026-05-24 19:16:57 +02:00
J. Nick Koston d902104bee Trigger active scan when picking a keymitt_ble device in the config flow (#172075) 2026-05-24 19:16:43 +02:00
J. Nick Koston 7bad27c412 Trigger active scan when picking a snooz device in the config flow (#172073) 2026-05-24 19:16:28 +02:00
Max Michels 74a7102cf6 Replace duplicate constants in altruist with homeassistant.const imports (#172078) 2026-05-24 19:14:36 +02:00
Max Michels e88fb03388 Replace duplicate constants in husqvarna_automower with homeassistant.const imports (#172064) 2026-05-24 19:13:21 +02:00
214 changed files with 15577 additions and 810 deletions
+3 -3
View File
@@ -1421,7 +1421,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
fail_ci_if_error: true
flags: full-suite
@@ -1592,7 +1592,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
@@ -1620,7 +1620,7 @@ jobs:
with:
pattern: test-results-*
- name: Upload test results to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
report_type: test_results
fail_ci_if_error: true
+1
View File
@@ -609,6 +609,7 @@ homeassistant.components.valve.*
homeassistant.components.velbus.*
homeassistant.components.velux.*
homeassistant.components.victron_gx.*
homeassistant.components.vistapool.*
homeassistant.components.vivotek.*
homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.*
Generated
+2
View File
@@ -1930,6 +1930,8 @@ CLAUDE.md @home-assistant/core
/tests/components/victron_remote_monitoring/ @AndyTempel
/homeassistant/components/vilfo/ @ManneW
/tests/components/vilfo/ @ManneW
/homeassistant/components/vistapool/ @fdebrus
/tests/components/vistapool/ @fdebrus
/homeassistant/components/vivotek/ @HarlemSquirrel
/tests/components/vivotek/ @HarlemSquirrel
/homeassistant/components/vizio/ @raman325
@@ -7,10 +7,11 @@ from altruistclient import AltruistClient, AltruistDeviceModel, AltruistError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_HOST, DOMAIN
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -1,6 +1,3 @@
"""Constants for the Altruist integration."""
DOMAIN = "altruist"
# pylint: disable-next=home-assistant-duplicate-const
CONF_HOST = "host"
@@ -10,13 +10,12 @@ import logging
from altruistclient import AltruistClient, AltruistError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_HOST
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(seconds=15)
@@ -24,6 +24,7 @@ from pyatv.interface import (
PushListener,
PushUpdater,
)
from yarl import URL
from homeassistant.components import media_source
from homeassistant.components.media_player import (
@@ -345,7 +346,10 @@ class AppleTvMediaPlayer(
play_item = await media_source.async_resolve_media(
self.hass, media_id, self.entity_id
)
media_id = async_process_play_media_url(self.hass, play_item.url)
if play_item.path and self._is_feature_available(FeatureName.StreamFile):
media_id = str(play_item.path)
else:
media_id = async_process_play_media_url(self.hass, play_item.url)
media_type = MediaType.MUSIC
if self._is_feature_available(FeatureName.StreamFile) and (
@@ -353,11 +357,16 @@ class AppleTvMediaPlayer(
):
_LOGGER.debug("Streaming %s via RAOP", media_id)
await self.atv.stream.stream_file(media_id)
elif self._is_feature_available(FeatureName.PlayUrl):
elif self._is_feature_available(FeatureName.PlayUrl) and (
(parsed_url := URL(media_id)).is_absolute() and parsed_url.host
):
_LOGGER.debug("Playing %s via AirPlay", media_id)
await self.atv.stream.play_url(media_id)
else:
_LOGGER.error("Media streaming is not possible with current configuration")
_LOGGER.error(
"Media streaming is not possible with current configuration for %s",
media_id,
)
@property
def media_image_hash(self) -> str | None:
+2 -1
View File
@@ -17,10 +17,11 @@ from homeassistant.components.backup import (
OnProgressCallback,
suggested_filename,
)
from homeassistant.const import CONF_PREFIX
from homeassistant.core import HomeAssistant, callback
from . import S3ConfigEntry
from .const import CONF_BUCKET, CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .helpers import async_list_backups_from_s3
_LOGGER = logging.getLogger(__name__)
@@ -8,6 +8,7 @@ from botocore.exceptions import ClientError, ConnectionError, ParamValidationErr
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PREFIX
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
TextSelector,
@@ -20,7 +21,6 @@ from .const import (
CONF_ACCESS_KEY_ID,
CONF_BUCKET,
CONF_ENDPOINT_URL,
CONF_PREFIX,
CONF_SECRET_ACCESS_KEY,
DEFAULT_ENDPOINT_URL,
DESCRIPTION_AWS_S3_DOCS_URL,
-2
View File
@@ -11,8 +11,6 @@ CONF_ACCESS_KEY_ID = "access_key_id"
CONF_SECRET_ACCESS_KEY = "secret_access_key"
CONF_ENDPOINT_URL = "endpoint_url"
CONF_BUCKET = "bucket"
# pylint: disable-next=home-assistant-duplicate-const
CONF_PREFIX = "prefix"
AWS_DOMAIN = "amazonaws.com"
DEFAULT_ENDPOINT_URL = f"https://s3.eu-central-1.{AWS_DOMAIN}/"
@@ -8,10 +8,11 @@ from aiobotocore.client import AioBaseClient as S3Client
from botocore.exceptions import BotoCoreError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PREFIX
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_BUCKET, CONF_PREFIX, DOMAIN
from .const import CONF_BUCKET, DOMAIN
from .helpers import async_list_backups_from_s3
SCAN_INTERVAL = timedelta(hours=6)
@@ -5,15 +5,10 @@ from typing import Any
from homeassistant.components.backup import DATA_MANAGER as BACKUP_DATA_MANAGER
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PREFIX
from homeassistant.core import HomeAssistant
from .const import (
CONF_ACCESS_KEY_ID,
CONF_BUCKET,
CONF_PREFIX,
CONF_SECRET_ACCESS_KEY,
DOMAIN,
)
from .const import CONF_ACCESS_KEY_ID, CONF_BUCKET, CONF_SECRET_ACCESS_KEY, DOMAIN
from .coordinator import S3ConfigEntry
from .helpers import async_list_backups_from_s3
+6 -17
View File
@@ -8,7 +8,6 @@ from urllib.parse import urlsplit
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_IGNORE,
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigEntry,
@@ -139,25 +138,15 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
async def _create_entry(self, serial: str) -> ConfigFlowResult:
"""Create entry for device.
Generate a name to be used as a prefix for device entities.
Use the discovered device name when available.
"""
model = self.config[CONF_MODEL]
same_model = [
entry.data[CONF_NAME]
for entry in self.hass.config_entries.async_entries(DOMAIN)
if entry.source != SOURCE_IGNORE and entry.data[CONF_MODEL] == model
]
name = model
for idx in range(len(same_model) + 1):
name = f"{model} {idx}"
if name not in same_model:
break
if (title_placeholders := self.context.get("title_placeholders")) is not None:
name = title_placeholders[CONF_NAME]
else:
name = f"{self.config[CONF_MODEL]} - {serial}"
self.config[CONF_NAME] = name
title = f"{model} - {serial}"
return self.async_create_entry(title=title, data=self.config)
return self.async_create_entry(title=name, data=self.config)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["blebox_uniapi"],
"requirements": ["blebox-uniapi==2.5.3"],
"requirements": ["blebox-uniapi==2.5.4"],
"zeroconf": ["_bbxsrv._tcp.local."]
}
@@ -15,12 +15,12 @@
],
"quality_scale": "internal",
"requirements": [
"bleak==2.1.1",
"bleak-retry-connector==4.6.0",
"bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3",
"bleak==3.0.2",
"bleak-retry-connector==4.6.1",
"bluetooth-adapters==2.2.0",
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.3",
"habluetooth==6.7.2"
"dbus-fast==5.0.9",
"habluetooth==6.7.3"
]
}
@@ -14,6 +14,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import (
CONF_API_KEY,
CONF_LANGUAGE,
CONF_NAME,
CONF_RECIPIENT,
CONF_USERNAME,
@@ -29,8 +30,6 @@ BASE_API_URL = "https://rest.clicksend.com/v3"
HEADERS = {"Content-Type": CONTENT_TYPE_JSON}
# pylint: disable-next=home-assistant-duplicate-const
CONF_LANGUAGE = "language"
CONF_VOICE = "voice"
MALE_VOICE = "male"
@@ -275,9 +275,13 @@ class CloudGoogleConfig(AbstractConfig):
)
)
def should_expose(self, state: State) -> bool:
"""If a state object should be exposed."""
return self._should_expose_entity_id(state.entity_id)
def should_expose(self, entity_id: str) -> bool:
"""If an entity should be exposed."""
entity_filter: EntityFilter = self._config[CONF_FILTER]
if not entity_filter.empty_filter:
return entity_filter(entity_id)
return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id)
def _should_expose_legacy(self, entity_id: str) -> bool:
"""If an entity ID should be exposed."""
@@ -308,14 +312,6 @@ class CloudGoogleConfig(AbstractConfig):
and _supported_legacy(self.hass, entity_id)
)
def _should_expose_entity_id(self, entity_id: str) -> bool:
"""If an entity should be exposed."""
entity_filter: EntityFilter = self._config[CONF_FILTER]
if not entity_filter.empty_filter:
return entity_filter(entity_id)
return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id)
@property
def agent_user_id(self) -> str:
"""Return Agent User Id to use for query responses."""
@@ -467,7 +463,7 @@ class CloudGoogleConfig(AbstractConfig):
entity_id = event.data["entity_id"]
if not self._should_expose_entity_id(entity_id):
if not self.should_expose(entity_id):
return
self.async_schedule_google_sync_all()
@@ -490,8 +486,7 @@ class CloudGoogleConfig(AbstractConfig):
# Check if any exposed entity uses the device area
if not any(
entity_entry.area_id is None
and self._should_expose_entity_id(entity_entry.entity_id)
entity_entry.area_id is None and self.should_expose(entity_entry.entity_id)
for entity_entry in er.async_entries_for_device(
er.async_get(self.hass), event.data["device_id"]
)
@@ -17,10 +17,11 @@ from homeassistant.components.backup import (
OnProgressCallback,
suggested_filename,
)
from homeassistant.const import CONF_PREFIX
from homeassistant.core import HomeAssistant, callback
from . import R2ConfigEntry
from .const import CONF_BUCKET, CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
_LOGGER = logging.getLogger(__name__)
CACHE_TTL = 300
@@ -13,6 +13,7 @@ from botocore.exceptions import (
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PREFIX
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
TextSelector,
@@ -25,7 +26,6 @@ from .const import (
CONF_ACCESS_KEY_ID,
CONF_BUCKET,
CONF_ENDPOINT_URL,
CONF_PREFIX,
CONF_SECRET_ACCESS_KEY,
DEFAULT_ENDPOINT_URL,
DESCRIPTION_R2_AUTH_DOCS_URL,
@@ -11,8 +11,6 @@ CONF_ACCESS_KEY_ID = "access_key_id"
CONF_SECRET_ACCESS_KEY = "secret_access_key"
CONF_ENDPOINT_URL = "endpoint_url"
CONF_BUCKET = "bucket"
# pylint: disable-next=home-assistant-duplicate-const
CONF_PREFIX = "prefix"
# R2 is S3-compatible. Endpoint should be like:
# https://<accountid>.r2.cloudflarestorage.com
+53 -28
View File
@@ -42,6 +42,35 @@ async def async_unload_entry(hass: HomeAssistant, entry: CookidooConfigEntry) ->
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
def _migrate_identifiers(
hass: HomeAssistant,
config_entry: CookidooConfigEntry,
old_prefix: str,
new_unique_id: str,
) -> None:
"""Migrate device identifiers and entity unique_ids from old to new prefix."""
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry_id=config_entry.entry_id
)
entity_entries = er.async_entries_for_config_entry(
entity_registry, config_entry_id=config_entry.entry_id
)
for dev in device_entries:
new_identifiers = {
(DOMAIN, new_unique_id) if domain == DOMAIN else (domain, identifier)
for domain, identifier in dev.identifiers
}
device_registry.async_update_device(dev.id, new_identifiers=new_identifiers)
for ent in entity_entries:
if ent.unique_id and ent.unique_id.startswith(f"{old_prefix}_"):
entity_registry.async_update_entity(
ent.entity_id,
new_unique_id=f"{new_unique_id}{ent.unique_id[len(old_prefix) :]}",
)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: CookidooConfigEntry
) -> bool:
@@ -49,41 +78,37 @@ async def async_migrate_entry(
_LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.version == 1 and config_entry.minor_version == 1:
# Add the unique uuid
# Add the unique uuid (first migration, entities used config_entry_id as prefix)
cookidoo = await cookidoo_from_config_entry(hass, config_entry)
try:
auth_data = await cookidoo.login()
await cookidoo.login()
user_info = await cookidoo.get_user_info()
except (CookidooRequestException, CookidooAuthException) as e:
_LOGGER.error(
"Could not migrate config config_entry: %s",
str(e),
)
_LOGGER.error("Could not migrate config entry: %s", e)
return False
unique_id = auth_data.sub
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry_id=config_entry.entry_id
)
entity_entries = er.async_entries_for_config_entry(
entity_registry, config_entry_id=config_entry.entry_id
)
for dev in device_entries:
device_registry.async_update_device(
dev.id, new_identifiers={(DOMAIN, unique_id)}
)
for ent in entity_entries:
assert ent.config_entry_id
entity_registry.async_update_entity(
ent.entity_id,
new_unique_id=ent.unique_id.replace(ent.config_entry_id, unique_id),
)
_migrate_identifiers(hass, config_entry, config_entry.entry_id, user_info.id)
hass.config_entries.async_update_entry(
config_entry, unique_id=auth_data.sub, minor_version=2
config_entry, unique_id=user_info.id, minor_version=3
)
if config_entry.version == 1 and config_entry.minor_version == 2:
# Migrate unique_id from old CIAM sub to community profile id
cookidoo = await cookidoo_from_config_entry(hass, config_entry)
try:
await cookidoo.login()
user_info = await cookidoo.get_user_info()
except (CookidooRequestException, CookidooAuthException) as e:
_LOGGER.error("Could not migrate config entry: %s", e)
return False
old_unique_id = config_entry.unique_id
if old_unique_id:
_migrate_identifiers(hass, config_entry, old_unique_id, user_info.id)
hass.config_entries.async_update_entry(
config_entry, unique_id=user_info.id, minor_version=3
)
_LOGGER.debug(
+12 -2
View File
@@ -3,7 +3,11 @@
from datetime import date, datetime, timedelta
import logging
from cookidoo_api import CookidooAuthException, CookidooException
from cookidoo_api import (
CookidooAuthException,
CookidooException,
CookidooRequestException,
)
from cookidoo_api.types import CookidooCalendarDayRecipe
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
@@ -74,7 +78,13 @@ class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity):
week_day
)
except CookidooAuthException:
await self.coordinator.cookidoo.refresh_token()
try:
await self.coordinator.cookidoo.login()
except (CookidooAuthException, CookidooRequestException) as exc:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="calendar_fetch_failed",
) from exc
return await self.coordinator.cookidoo.get_recipes_in_calendar_week(
week_day
)
@@ -54,7 +54,7 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Cookidoo."""
VERSION = 1
MINOR_VERSION = 2
MINOR_VERSION = 3
COUNTRY_DATA_SCHEMA: dict
LANGUAGE_DATA_SCHEMA: dict
@@ -223,8 +223,9 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
cookidoo = await cookidoo_from_config_data(self.hass, data_input)
try:
auth_data = await cookidoo.login()
self.user_uuid = auth_data.sub
await cookidoo.login()
user_info = await cookidoo.get_user_info()
self.user_uuid = user_info.id
if language_input:
await cookidoo.get_additional_items()
except CookidooRequestException:
@@ -87,7 +87,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
)
except CookidooAuthException:
try:
await self.cookidoo.refresh_token()
await self.cookidoo.login()
except CookidooAuthException as exc:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
@@ -96,6 +96,11 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
CONF_EMAIL: self.config_entry.data[CONF_EMAIL]
},
) from exc
except CookidooRequestException as exc:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="setup_request_exception",
) from exc
_LOGGER.debug(
"Authentication failed but re-authentication"
" was successful, trying again later"
+3 -2
View File
@@ -2,11 +2,12 @@
from typing import Any
from aiohttp import CookieJar
from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options
from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .coordinator import CookidooConfigEntry
@@ -21,7 +22,7 @@ async def cookidoo_from_config_data(
)
return Cookidoo(
async_get_clientsession(hass),
async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)),
CookidooConfig(
email=data[CONF_EMAIL],
password=data[CONF_PASSWORD],
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["cookidoo_api"],
"quality_scale": "silver",
"requirements": ["cookidoo-api==0.14.0"]
"requirements": ["cookidoo-api==0.17.2"]
}
@@ -6,6 +6,7 @@ from typing import Any, final
from propcache.api import cached_property
from homeassistant.components import zone
from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
@@ -207,6 +208,7 @@ class TrackerEntityDescription(EntityDescription, frozen_or_thawed=True):
CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = {
"in_zones",
"latitude",
"location_accuracy",
"location_name",
@@ -220,6 +222,7 @@ class TrackerEntity(
"""Base class for a tracked device."""
entity_description: TrackerEntityDescription
_attr_in_zones: list[str] | None = None
_attr_latitude: float | None = None
_attr_location_accuracy: float = 0
_attr_location_name: str | None = None
@@ -239,6 +242,16 @@ class TrackerEntity(
"""All updates need to be written to the state machine if we're not polling."""
return not self.should_poll
@cached_property
def in_zones(self) -> list[str] | None:
"""Return the entity_id of zones the device is currently in.
The list may be in any order; the base class sorts it by zone radius
and discards zones which do not exist. Ignored if latitude and
longitude are both set.
"""
return self._attr_in_zones
@cached_property
def location_accuracy(self) -> float:
"""Return the location accuracy of the device.
@@ -269,6 +282,20 @@ class TrackerEntity(
self.__active_zone, self.__in_zones = zone.async_in_zones(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
elif (zones := self.in_zones) is not None:
zone_states = sorted(
(
zone_state
for entity_id in zones
if (zone_state := self.hass.states.get(entity_id)) is not None
),
key=lambda z: z.attributes[ATTR_RADIUS],
)
self.__active_zone = next(
(z for z in zone_states if not z.attributes.get(ATTR_PASSIVE)),
None,
)
self.__in_zones = [z.entity_id for z in zone_states]
else:
self.__active_zone = None
self.__in_zones = None
@@ -280,7 +307,9 @@ class TrackerEntity(
if self.location_name is not None:
return self.location_name
if self.latitude is not None and self.longitude is not None:
if (
self.latitude is not None and self.longitude is not None
) or self.__in_zones is not None:
zone_state = self.__active_zone
if zone_state is None:
state = STATE_NOT_HOME
@@ -296,11 +325,10 @@ class TrackerEntity(
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
attr: dict[str, Any] = {ATTR_IN_ZONES: self.__in_zones or []}
attr.update(super().state_attributes)
if self.latitude is not None and self.longitude is not None:
attr[ATTR_IN_ZONES] = self.__in_zones or []
attr[ATTR_LATITUDE] = self.latitude
attr[ATTR_LONGITUDE] = self.longitude
attr[ATTR_GPS_ACCURACY] = self.location_accuracy
+2 -2
View File
@@ -15,8 +15,8 @@
],
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.2.1",
"aiodhcpwatcher==1.2.6",
"aiodiscover==3.2.3",
"cached-ipaddress==1.0.1"
"cached-ipaddress==1.1.1"
]
}
@@ -10,7 +10,7 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyecobee"],
"requirements": ["python-ecobee-api==0.3.2"],
"requirements": ["python-ecobee-api==0.4.0"],
"single_config_entry": true,
"zeroconf": [
{
@@ -5,6 +5,7 @@ from typing import Any
from eufylife_ble_client import MODEL_TO_NAME
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
@@ -75,6 +76,7 @@ class EufyLifeConfigFlow(ConfigFlow, domain=DOMAIN):
data={CONF_MODEL: model},
)
await bluetooth.async_request_active_scan(self.hass)
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
@@ -185,7 +185,7 @@ class AbstractConfig(ABC):
"""
@abstractmethod
def should_expose(self, state) -> bool:
def should_expose(self, entity_id: str) -> bool:
"""Return if entity should be exposed."""
@abstractmethod
@@ -532,7 +532,7 @@ class GoogleEntity:
def __repr__(self) -> str:
"""Return the representation."""
return f"<GoogleEntity {self.state.entity_id}: {self.state.name}>"
return f"<GoogleEntity {self.entity_id}: {self.state.name}>"
@callback
def traits(self) -> list[trait._Trait]:
@@ -549,7 +549,7 @@ class GoogleEntity:
@callback
def should_expose(self):
"""If entity should be exposed."""
return self.config.should_expose(self.state)
return self.config.should_expose(self.entity_id)
@callback
def should_expose_local(self) -> bool:
@@ -733,7 +733,7 @@ class GoogleEntity:
if not executed:
raise SmartHomeError(
ERR_FUNCTION_NOT_SUPPORTED,
f"Unable to execute {command} for {self.state.entity_id}",
f"Unable to execute {command} for {self.entity_id}",
)
@callback
@@ -12,7 +12,7 @@ import jwt
from homeassistant.components import webhook
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -157,17 +157,13 @@ class GoogleConfig(AbstractConfig):
return None
def should_expose(self, state) -> bool:
def should_expose(self, entity_id: str) -> bool:
"""Return if entity should be exposed."""
expose_by_default = self._config.get(CONF_EXPOSE_BY_DEFAULT)
exposed_domains = self._config.get(CONF_EXPOSED_DOMAINS)
if state.attributes.get("view") is not None:
# Ignore entities that are views
return False
entity_registry = er.async_get(self.hass)
registry_entry = entity_registry.async_get(state.entity_id)
registry_entry = entity_registry.async_get(entity_id)
if registry_entry:
auxiliary_entity = (
registry_entry.entity_category is not None
@@ -176,10 +172,10 @@ class GoogleConfig(AbstractConfig):
else:
auxiliary_entity = False
explicit_expose = self.entity_config.get(state.entity_id, {}).get(CONF_EXPOSE)
explicit_expose = self.entity_config.get(entity_id, {}).get(CONF_EXPOSE)
domain_exposed_by_default = (
expose_by_default and state.domain in exposed_domains
expose_by_default and split_entity_id(entity_id)[0] in exposed_domains
)
# Expose an entity by default if the entity's domain is exposed by default
@@ -73,7 +73,7 @@ def async_enable_report_state(
return bool(
hass.is_running
and (new_state := data["new_state"])
and google_config.should_expose(new_state)
and google_config.should_expose(new_state.entity_id)
and async_get_google_entity_if_supported_cached(
hass, google_config, new_state
)
@@ -19,7 +19,7 @@ from homeassistant.config_entries import (
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_PROMPT
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import llm
from homeassistant.helpers.selector import (
@@ -38,7 +38,6 @@ from .const import (
CONF_HARASSMENT_BLOCK_THRESHOLD,
CONF_HATE_BLOCK_THRESHOLD,
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_RECOMMENDED,
CONF_SEXUAL_BLOCK_THRESHOLD,
CONF_TEMPERATURE,
@@ -2,7 +2,7 @@
import logging
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT
from homeassistant.helpers import llm
LOGGER = logging.getLogger(__package__)
@@ -15,8 +15,6 @@ DEFAULT_STT_NAME = "Google AI STT"
DEFAULT_TTS_NAME = "Google AI TTS"
DEFAULT_AI_TASK_NAME = "Google AI Task"
# pylint: disable-next=home-assistant-duplicate-const
CONF_PROMPT = "prompt"
DEFAULT_STT_PROMPT = "Transcribe the attached audio"
CONF_RECOMMENDED = "recommended"
@@ -4,11 +4,11 @@ from typing import Literal
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_PROMPT, DOMAIN
from .const import DOMAIN
from .entity import GoogleGenerativeAILLMBaseEntity
@@ -7,16 +7,11 @@ from google.genai.types import Part
from homeassistant.components import stt
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import CONF_PROMPT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_CHAT_MODEL,
CONF_PROMPT,
DEFAULT_STT_PROMPT,
LOGGER,
RECOMMENDED_STT_MODEL,
)
from .const import CONF_CHAT_MODEL, DEFAULT_STT_PROMPT, LOGGER, RECOMMENDED_STT_MODEL
from .entity import GoogleGenerativeAILLMBaseEntity
from .helpers import convert_to_wav
@@ -5,6 +5,7 @@ from typing import Any
from govee_ble import GoveeBluetoothDeviceData as DeviceData
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
@@ -76,6 +77,7 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
title=title, data={CONF_DEVICE_TYPE: device.device_type}
)
await bluetooth.async_request_active_scan(self.hass)
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
@@ -25,6 +25,7 @@ Error handling pattern for reauth:
"""
from collections.abc import Mapping
import datetime
from json import JSONDecodeError
import logging
@@ -34,7 +35,9 @@ from requests import RequestException
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -46,10 +49,13 @@ from .const import (
DEFAULT_PLANT_ID,
DEFAULT_URL,
DEPRECATED_URLS,
DEVICE_SCAN_INTERVAL,
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
PLATFORMS,
SUPPORTED_DEVICE_TYPES,
V1_API_ERROR_NO_PRIVILEGE,
V1_DEVICE_TYPES,
)
from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .models import GrowattRuntimeData
@@ -241,9 +247,6 @@ def _login_classic_api(
return login_response
V1_DEVICE_TYPES: dict[int, str] = {5: "sph", 7: "min"}
def get_device_list_v1(
api, config: Mapping[str, str]
) -> tuple[list[dict[str, str]], str]:
@@ -353,7 +356,7 @@ async def async_setup_entry(
hass, config_entry, device["deviceSn"], device["deviceType"], plant_id
)
for device in devices
if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min", "sph"]
if device["deviceType"] in SUPPORTED_DEVICE_TYPES
}
# Perform the first refresh for the total coordinator
@@ -372,6 +375,96 @@ async def async_setup_entry(
# Set up all the entities
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
async def _async_scan_for_new_devices(_now: datetime.datetime) -> None:
"""Scan for new or removed devices and update HA accordingly."""
# Fetch current config (in case it was updated via reauth or options)
current_plant_id = config_entry.data[CONF_PLANT_ID]
total_coordinator = config_entry.runtime_data.total_coordinator
# Signal the coordinator to also fetch the device list on its next
# _sync_update_data run, then force an immediate refresh. This keeps
# the device_list call in the same executor thread as the existing
# login() + plant-overview call, so for Classic API there is no extra
# login and no thread-safety concern with the shared session.
total_coordinator.request_device_list_scan()
await total_coordinator.async_refresh()
if not total_coordinator.last_update_success:
_LOGGER.debug("Coordinator refresh failed during device scan, skipping")
return
current_devices = total_coordinator.device_list
if current_devices is None:
_LOGGER.debug(
"Device list not populated after coordinator refresh, skipping scan"
)
return
runtime_data = config_entry.runtime_data
current_device_sns = {device["deviceSn"] for device in current_devices}
# Remove stale devices
device_registry = dr.async_get(hass)
for device_entry in dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
):
device_domain_ids = {
identifier[1]
for identifier in device_entry.identifiers
if identifier[0] == DOMAIN
}
if not device_domain_ids:
continue
# Skip the plant "total" device
if current_plant_id in device_domain_ids:
continue
if device_domain_ids.isdisjoint(current_device_sns):
for device_sn in device_domain_ids:
if coordinator := runtime_data.devices.pop(device_sn, None):
await coordinator.async_shutdown()
device_registry.async_update_device(
device_entry.id,
remove_config_entry_id=config_entry.entry_id,
)
# Add new devices
new_coordinators: list[GrowattCoordinator] = []
for device in current_devices:
device_sn = device["deviceSn"]
device_type = device["deviceType"]
if device_sn in runtime_data.devices:
continue
if device_type not in SUPPORTED_DEVICE_TYPES:
_LOGGER.debug(
"New device %s with type %s is not supported, skipping",
device_sn,
device_type,
)
continue
coordinator = GrowattCoordinator(
hass, config_entry, device_sn, device_type, current_plant_id
)
await coordinator.async_refresh()
if not coordinator.last_update_success:
_LOGGER.debug("Failed to refresh new device %s, skipping", device_sn)
await coordinator.async_shutdown()
continue
runtime_data.devices[device_sn] = coordinator
new_coordinators.append(coordinator)
if new_coordinators:
async_dispatcher_send(
hass,
f"{DOMAIN}_new_device_{config_entry.entry_id}",
new_coordinators,
)
config_entry.async_on_unload(
async_track_time_interval(
hass, _async_scan_for_new_devices, DEVICE_SCAN_INTERVAL
)
)
return True
@@ -1,7 +1,11 @@
"""Define constants for the Growatt Server component."""
from datetime import timedelta
from homeassistant.const import Platform
DEVICE_SCAN_INTERVAL = timedelta(hours=1)
CONF_PLANT_ID = "plant_id"
# Auth types for config flow
@@ -62,3 +66,9 @@ BATT_MODE_GRID_FIRST = 2
# Used to pass logged-in session from async_migrate_entry to async_setup_entry
# to avoid double login() calls that trigger API rate limiting
CACHED_API_KEY = "_cached_api_"
# Supported device types for coordinator creation
SUPPORTED_DEVICE_TYPES = ["inverter", "tlx", "storage", "mix", "min", "sph"]
# Maps V1 API device type integers to coordinator device-type strings
V1_DEVICE_TYPES: dict[int, str] = {5: "sph", 7: "min"}
@@ -6,6 +6,7 @@ import logging
from typing import TYPE_CHECKING, Any
import growattServer
from requests import RequestException
from homeassistant.components.sensor import SensorStateClass
from homeassistant.config_entries import ConfigEntry
@@ -27,6 +28,7 @@ from .const import (
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
V1_API_ERROR_NO_PRIVILEGE,
V1_DEVICE_TYPES,
)
from .models import GrowattRuntimeData
@@ -60,6 +62,15 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.plant_id = plant_id
self.previous_values: dict[str, Any] = {}
self._pre_reset_values: dict[str, float] = {}
# Populated during _sync_update_data when request_device_list_scan() was called.
# Consumed by _async_scan_for_new_devices to avoid a separate executor job
# and the extra login() call that would otherwise be required (Classic API).
# Thread safety: written in the executor thread, read on the event loop after
# async_refresh() awaits the executor job — ordering guarantees safe access.
self.device_list: list[dict[str, str]] | None = None
# Flag set on the event loop (request_device_list_scan) and consumed in the
# executor thread (_sync_update_data). Bool assignment is atomic under CPython's GIL.
self._fetch_device_list: bool = False
if self.api_version == "v1":
self.username = None
@@ -87,10 +98,58 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
config_entry=config_entry,
)
def _sync_fetch_device_list(self) -> None:
"""Fetch the device list for the current plant."""
if self.api_version == "v1":
try:
devices_dict = self.api.device_list(self.plant_id)
devices = devices_dict.get("devices", [])
self.device_list = [
{
"deviceSn": device.get("device_sn", ""),
"deviceType": V1_DEVICE_TYPES[device.get("type")],
}
for device in devices
if device.get("type") in V1_DEVICE_TYPES
]
except growattServer.GrowattV1ApiError as err:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
) from err
_LOGGER.debug("Failed to fetch V1 device list during scan: %s", err)
self.device_list = None
else:
try:
# login() was already called above; reuse the same session.
devices = self.api.device_list(self.plant_id)
self.device_list = [
{
"deviceSn": device["deviceSn"],
"deviceType": device["deviceType"],
}
for device in devices
]
except (
RequestException,
json.JSONDecodeError,
KeyError,
TypeError,
) as err:
_LOGGER.debug(
"Failed to fetch Classic device list during scan: %s", err
)
self.device_list = None
def _sync_update_data(self) -> dict[str, Any]:
"""Update data via library synchronously."""
_LOGGER.debug("Updating data for %s (%s)", self.device_id, self.device_type)
# Consume the scan flag immediately so it is cleared even if an exception
# is raised later in this method.
fetch_device_list = self._fetch_device_list
self._fetch_device_list = False
# login only required for classic API
if self.api_version == "classic":
login_response = self.api.login(self.username, self.password)
@@ -132,12 +191,16 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
total_info["totalEnergy"] = total_info["total_energy"]
total_info["invTodayPpv"] = total_info["current_power"]
else:
# Classic API: use plant_info as before
total_info = self.api.plant_info(self.device_id)
# Classic API: use plant_info as before.
# Copy the response to avoid mutating the dict returned by the library
# (important for test mocks, harmless in production).
total_info = dict(self.api.plant_info(self.device_id))
del total_info["deviceList"]
plant_money_text, currency = total_info["plantMoneyText"].split("/")
total_info["plantMoneyText"] = plant_money_text
total_info["currency"] = currency
if fetch_device_list:
self._sync_fetch_device_list()
_LOGGER.debug("Total info for plant %s: %r", self.plant_id, total_info)
self.data = total_info
elif self.device_type == "inverter":
@@ -252,6 +315,15 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except json.decoder.JSONDecodeError as err:
raise UpdateFailed(f"Error fetching data: {err}") from err
def request_device_list_scan(self) -> None:
"""Request that the next _sync_update_data also fetches the device list.
Setting this flag before async_refresh() keeps the device_list call in
the same executor thread as the existing login() + plant-overview fetch,
so no extra login is needed and there is no thread-safety concern.
"""
self._fetch_device_list = True
def get_currency(self):
"""Get the currency."""
return self.data.get("currency")
@@ -7,9 +7,10 @@ from growattServer import GrowattV1ApiError
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -88,6 +89,17 @@ MIN_NUMBER_TYPES: tuple[GrowattNumberEntityDescription, ...] = (
)
def _create_numbers_for_device(
coordinator: GrowattCoordinator,
) -> list[GrowattNumber]:
"""Create number entities for a device coordinator."""
if coordinator.device_type == "min" and coordinator.api_version == "v1":
return [
GrowattNumber(coordinator, description) for description in MIN_NUMBER_TYPES
]
return []
async def async_setup_entry(
hass: HomeAssistant,
entry: GrowattConfigEntry,
@@ -96,15 +108,29 @@ async def async_setup_entry(
"""Set up Growatt number entities."""
runtime_data = entry.runtime_data
# Add number entities for each MIN device (only supported with V1 API)
async_add_entities(
GrowattNumber(device_coordinator, description)
for device_coordinator in runtime_data.devices.values()
if (
device_coordinator.device_type == "min"
and device_coordinator.api_version == "v1"
entity
for coordinator in runtime_data.devices.values()
for entity in _create_numbers_for_device(coordinator)
)
@callback
def _async_new_device(coordinators: list[GrowattCoordinator]) -> None:
"""Add number entities for new devices."""
new_entities = [
entity
for coordinator in coordinators
for entity in _create_numbers_for_device(coordinator)
]
if new_entities:
async_add_entities(new_entities)
entry.async_on_unload(
async_dispatcher_connect(
hass,
f"{DOMAIN}_new_device_{entry.entry_id}",
_async_new_device,
)
for description in MIN_NUMBER_TYPES
)
@@ -51,7 +51,7 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
@@ -62,7 +62,7 @@ rules:
repair-issues:
status: exempt
comment: Integration does not raise repairable issues.
stale-devices: todo
stale-devices: done
# Platinum
async-dependency: todo
@@ -5,8 +5,9 @@ from datetime import date, datetime
import logging
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -24,15 +25,46 @@ from .total import TOTAL_SENSOR_TYPES
_LOGGER = logging.getLogger(__name__)
def _create_sensors_for_device(
coordinator: GrowattCoordinator,
) -> list[GrowattSensor]:
"""Create sensor entities for a device coordinator."""
if coordinator.device_type == "inverter":
sensor_descriptions = INVERTER_SENSOR_TYPES
elif coordinator.device_type in ("tlx", "min"):
sensor_descriptions = TLX_SENSOR_TYPES
elif coordinator.device_type == "storage":
sensor_descriptions = STORAGE_SENSOR_TYPES
elif coordinator.device_type == "mix":
sensor_descriptions = MIX_SENSOR_TYPES
elif coordinator.device_type == "sph":
sensor_descriptions = SPH_SENSOR_TYPES
else:
_LOGGER.debug(
"Device type %s was found but is not supported right now",
coordinator.device_type,
)
return []
device_sn = coordinator.device_id
return [
GrowattSensor(
coordinator,
name=device_sn,
serial_id=device_sn,
unique_id=f"{device_sn}-{description.key}",
description=description,
)
for description in sensor_descriptions
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: GrowattConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Growatt sensor."""
# Use runtime_data instead of hass.data
data = config_entry.runtime_data
entities: list[GrowattSensor] = []
# Add total sensors
@@ -48,38 +80,29 @@ async def async_setup_entry(
for description in TOTAL_SENSOR_TYPES
)
# Add sensors for each device
for device_sn, device_coordinator in data.devices.items():
sensor_descriptions: list = []
if device_coordinator.device_type == "inverter":
sensor_descriptions = list(INVERTER_SENSOR_TYPES)
elif device_coordinator.device_type in ("tlx", "min"):
sensor_descriptions = list(TLX_SENSOR_TYPES)
elif device_coordinator.device_type == "storage":
sensor_descriptions = list(STORAGE_SENSOR_TYPES)
elif device_coordinator.device_type == "mix":
sensor_descriptions = list(MIX_SENSOR_TYPES)
elif device_coordinator.device_type == "sph":
sensor_descriptions = list(SPH_SENSOR_TYPES)
else:
_LOGGER.debug(
"Device type %s was found but is not supported right now",
device_coordinator.device_type,
)
entities.extend(
GrowattSensor(
device_coordinator,
name=device_sn,
serial_id=device_sn,
unique_id=f"{device_sn}-{description.key}",
description=description,
)
for description in sensor_descriptions
)
# Add sensors for each existing device
for device_coordinator in data.devices.values():
entities.extend(_create_sensors_for_device(device_coordinator))
async_add_entities(entities)
@callback
def _async_new_device(coordinators: list[GrowattCoordinator]) -> None:
"""Add sensor entities for new devices."""
new_entities: list[GrowattSensor] = []
for coordinator in coordinators:
new_entities.extend(_create_sensors_for_device(coordinator))
if new_entities:
async_add_entities(new_entities)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"{DOMAIN}_new_device_{config_entry.entry_id}",
_async_new_device,
)
)
class GrowattSensor(CoordinatorEntity[GrowattCoordinator], SensorEntity):
"""Representation of a Growatt Sensor."""
@@ -8,9 +8,10 @@ from growattServer import GrowattV1ApiError
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -45,6 +46,17 @@ MIN_SWITCH_TYPES: tuple[GrowattSwitchEntityDescription, ...] = (
)
def _create_switches_for_device(
coordinator: GrowattCoordinator,
) -> list[GrowattSwitch]:
"""Create switch entities for a device coordinator."""
if coordinator.device_type == "min" and coordinator.api_version == "v1":
return [
GrowattSwitch(coordinator, description) for description in MIN_SWITCH_TYPES
]
return []
async def async_setup_entry(
hass: HomeAssistant,
entry: GrowattConfigEntry,
@@ -53,15 +65,29 @@ async def async_setup_entry(
"""Set up Growatt switch entities."""
runtime_data = entry.runtime_data
# Add switch entities for each MIN device (only supported with V1 API)
async_add_entities(
GrowattSwitch(device_coordinator, description)
for device_coordinator in runtime_data.devices.values()
if (
device_coordinator.device_type == "min"
and device_coordinator.api_version == "v1"
entity
for coordinator in runtime_data.devices.values()
for entity in _create_switches_for_device(coordinator)
)
@callback
def _async_new_device(coordinators: list[GrowattCoordinator]) -> None:
"""Add switch entities for new devices."""
new_entities = [
entity
for coordinator in coordinators
for entity in _create_switches_for_device(coordinator)
]
if new_entities:
async_add_entities(new_entities)
entry.async_on_unload(
async_dispatcher_connect(
hass,
f"{DOMAIN}_new_device_{entry.entry_id}",
_async_new_device,
)
for description in MIN_SWITCH_TYPES
)
@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioharmony", "slixmpp"],
"requirements": ["aioharmony==1.0.3"],
"requirements": ["aioharmony==1.0.8"],
"ssdp": [
{
"deviceType": "urn:myharmony-com:device:harmony:1",
+6 -2
View File
@@ -54,10 +54,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo
try:
await homee.get_access_token()
except HomeeConnectionFailedException as exc:
raise ConfigEntryNotReady(f"Connection to Homee failed: {exc.reason}") from exc
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connection_failed",
) from exc
except HomeeAuthFailedException as exc:
raise ConfigEntryAuthFailed(
f"Authentication to Homee failed: {exc.reason}"
translation_domain=DOMAIN,
translation_key="auth_failed",
) from exc
hass.loop.create_task(homee.run())
@@ -59,7 +59,7 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues:
@@ -487,9 +487,15 @@
}
},
"exceptions": {
"auth_failed": {
"message": "Authentication to homee failed."
},
"connection_closed": {
"message": "Could not connect to homee while setting attribute."
},
"connection_failed": {
"message": "Connection to homee failed."
},
"disarm_not_supported": {
"message": "Disarm is not supported by homee."
},
@@ -10,6 +10,7 @@ from homeassistant.components.event import (
EventEntity,
EventEntityDescription,
)
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -23,10 +24,6 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
ATTR_SEVERITY = "severity"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LATITUDE = "latitude"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LONGITUDE = "longitude"
ATTR_DATE_TIME = "date_time"
@@ -9,6 +9,7 @@ from idasen_ha import Desk
from idasen_ha.errors import AuthFailedError
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
@@ -85,6 +86,7 @@ class IdasenDeskConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
else:
await bluetooth.async_request_active_scan(self.hass)
current_addresses = self._async_current_ids(include_ignore=False)
for discovery in async_discovered_service_info(self.hass):
if (
@@ -11,6 +11,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MODEL
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_GENERATION, CONF_SERIAL_NUMBER, DEFAULT_PORT, DOMAIN
@@ -93,7 +94,7 @@ class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery — probe the device to confirm it is an Indevolt device."""
"""Handle DHCP discovery for registered Indevolt devices."""
host = discovery_info.ip
try:
@@ -110,12 +111,41 @@ class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered_host = host
self._discovered_device_data = device_data
return await self.async_step_discovery_confirm()
return await self.async_step_zeroconf_confirm()
async def async_step_discovery_confirm(
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery — probe the device to confirm it is an Indevolt device."""
host = str(discovery_info.ip_address)
# The mDNS hostname encodes the SN as "{sn}.local." — if it is not in
# that form, this is not a recognisable Indevolt device; abort without probing.
if (
sn := discovery_info.hostname.removesuffix(".local.")
) == discovery_info.hostname:
return self.async_abort(reason="cannot_connect")
await self.async_set_unique_id(sn)
self._abort_if_unique_id_configured(
updates={CONF_HOST: host}, reload_on_update=True
)
try:
device_data = await self._async_get_device_data(host)
except OSError, ClientError, KeyError:
return self.async_abort(reason="cannot_connect")
self.context["title_placeholders"] = {"model": device_data[CONF_MODEL]}
self._discovered_host = host
self._discovered_device_data = device_data
return await self.async_step_zeroconf_confirm()
async def async_step_zeroconf_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm DHCP discovery by user."""
"""Confirm zeroconf discovery by user."""
assert self._discovered_host is not None
assert self._discovered_device_data is not None
@@ -132,7 +162,7 @@ class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN):
# Retrieve user confirmation
self._set_confirm_only()
return self.async_show_form(
step_id="discovery_confirm",
step_id="zeroconf_confirm",
description_placeholders={
CONF_HOST: self._discovered_host,
CONF_MODEL: self._discovered_device_data[CONF_MODEL],
@@ -3,15 +3,11 @@
"name": "Indevolt",
"codeowners": ["@xirt"],
"config_flow": true,
"dhcp": [
{ "macaddress": "1C784B*" },
{ "macaddress": "34EAE7*" },
{ "macaddress": "7C3E82*" },
{ "registered_devices": true }
],
"dhcp": [{ "registered_devices": true }],
"documentation": "https://www.home-assistant.io/integrations/indevolt",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["indevolt-api==1.8.1"]
"requirements": ["indevolt-api==1.8.1"],
"zeroconf": [{ "name": "igen_fw*", "type": "_http._tcp.local." }]
}
@@ -12,10 +12,6 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"discovery_confirm": {
"description": "Do you want to add {model} ({host}) to Home Assistant?",
"title": "Discovered Indevolt {model}"
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
@@ -35,6 +31,10 @@
},
"description": "Enter the connection details for your Indevolt device.",
"title": "Connect to Indevolt device"
},
"zeroconf_confirm": {
"description": "Do you want to add {model} ({host}) to Home Assistant?",
"title": "Discovered Indevolt {model}"
}
}
},
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["infrared-protocols==5.4.0"]
"requirements": ["infrared-protocols==5.6.0"]
}
@@ -5,6 +5,7 @@ from typing import Any
from inkbird_ble import INKBIRDBluetoothDeviceData as DeviceData
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
@@ -74,6 +75,7 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
title=title, data={CONF_DEVICE_TYPE: device_type}
)
await bluetooth.async_request_active_scan(self.hass)
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
@@ -128,7 +128,18 @@ class INKBIRDActiveBluetoothProcessorCoordinator(
@callback
def _async_schedule_poll(self, _: datetime) -> None:
"""Schedule a poll of the device."""
if self._last_service_info and self._async_needs_poll(
self._last_service_info, self._last_poll
):
# ``self._last_service_info`` only tracks dispatched events, so when
# the device keeps broadcasting the same payload (HA dedupes the
# repeats before dispatch) its timestamp stops advancing. Pull the
# latest service info from the bluetooth manager instead so the
# recency check in ``poll_needed`` sees every observed advertisement.
service_info = (
async_last_service_info(self.hass, self.address, connectable=False)
or self._last_service_info
)
if service_info and self.needs_poll(service_info):
# Seed ``_last_service_info`` so the debounced poll has a service
# info to hand to ``_async_poll_data``; the base ``_async_poll``
# asserts on it.
self._last_service_info = service_info
self._debounced_poll.async_schedule_call()
@@ -63,5 +63,5 @@
"documentation": "https://www.home-assistant.io/integrations/inkbird",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["inkbird-ble==1.2.2"]
"requirements": ["inkbird-ble==1.2.3"]
}
@@ -6,7 +6,7 @@ from typing import Any, TypedDict
import voluptuous as vol
from homeassistant.components.script import CONF_MODE
from homeassistant.const import CONF_DESCRIPTION, CONF_TYPE, SERVICE_RELOAD
from homeassistant.const import CONF_ACTION, CONF_DESCRIPTION, CONF_TYPE, SERVICE_RELOAD
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
@@ -29,8 +29,6 @@ CONF_INTENTS = "intents"
CONF_SPEECH = "speech"
CONF_REPROMPT = "reprompt"
# pylint: disable-next=home-assistant-duplicate-const
CONF_ACTION = "action"
CONF_CARD = "card"
CONF_TITLE = "title"
CONF_CONTENT = "content"
@@ -12,6 +12,7 @@ from microbot import (
)
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
@@ -83,6 +84,7 @@ class MicroBotConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery := self._discovered_adv:
self._discovered_advs[discovery.address] = discovery
else:
await bluetooth.async_request_active_scan(self.hass)
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass):
self._ble_device = discovery_info.device
@@ -7,6 +7,7 @@ from bluetooth_data_tools import human_readable_name
from ld2410_ble import BLEAK_EXCEPTIONS, LD2410BLE
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
@@ -77,6 +78,7 @@ class Ld2410BleConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
else:
await bluetooth.async_request_active_scan(self.hass)
current_addresses = self._async_current_ids(include_ignore=False)
for discovery in async_discovered_service_info(self.hass):
if (
@@ -36,5 +36,5 @@
"documentation": "https://www.home-assistant.io/integrations/led_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["bluetooth-data-tools==1.29.18", "led-ble==1.1.8"]
"requirements": ["bluetooth-data-tools==1.29.18", "led-ble==1.1.11"]
}
@@ -1,6 +1,18 @@
{
"entity": {
"sensor": {
"antichess_games": {
"default": "mdi:chess-pawn"
},
"antichess_rating": {
"default": "mdi:chart-line"
},
"atomic_games": {
"default": "mdi:chess-pawn"
},
"atomic_rating": {
"default": "mdi:chart-line"
},
"blitz_games": {
"default": "mdi:chess-pawn"
},
@@ -13,17 +25,71 @@
"bullet_rating": {
"default": "mdi:chart-line"
},
"chess960_games": {
"default": "mdi:chess-pawn"
},
"chess960_rating": {
"default": "mdi:chart-line"
},
"classical_games": {
"default": "mdi:chess-pawn"
},
"classical_rating": {
"default": "mdi:chart-line"
},
"correspondence_games": {
"default": "mdi:chess-pawn"
},
"correspondence_rating": {
"default": "mdi:chart-line"
},
"crazyhouse_games": {
"default": "mdi:chess-pawn"
},
"crazyhouse_rating": {
"default": "mdi:chart-line"
},
"horde_games": {
"default": "mdi:chess-pawn"
},
"horde_rating": {
"default": "mdi:chart-line"
},
"king_of_the_hill_games": {
"default": "mdi:chess-pawn"
},
"king_of_the_hill_rating": {
"default": "mdi:chart-line"
},
"puzzle_games": {
"default": "mdi:puzzle"
},
"puzzle_rating": {
"default": "mdi:chart-line"
},
"racing_kings_games": {
"default": "mdi:chess-pawn"
},
"racing_kings_rating": {
"default": "mdi:chart-line"
},
"rapid_games": {
"default": "mdi:chess-pawn"
},
"rapid_rating": {
"default": "mdi:chart-line"
},
"three_check_games": {
"default": "mdi:chess-pawn"
},
"three_check_rating": {
"default": "mdi:chart-line"
},
"ultra_bullet_games": {
"default": "mdi:chess-pawn"
},
"ultra_bullet_rating": {
"default": "mdi:chart-line"
}
}
}
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["aiolichess==1.2.0"]
"requirements": ["aiolichess==1.3.0"]
}
+165
View File
@@ -79,6 +79,171 @@ SENSORS: tuple[LichessEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda state: state.classical_games,
),
LichessEntityDescription(
key="ultra_bullet_rating",
translation_key="ultra_bullet_rating",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda state: state.ultra_bullet_rating,
),
LichessEntityDescription(
key="ultra_bullet_games",
translation_key="ultra_bullet_games",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda state: state.ultra_bullet_games,
),
LichessEntityDescription(
key="correspondence_rating",
translation_key="correspondence_rating",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda state: state.correspondence_rating,
),
LichessEntityDescription(
key="correspondence_games",
translation_key="correspondence_games",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda state: state.correspondence_games,
),
LichessEntityDescription(
key="chess960_rating",
translation_key="chess960_rating",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda state: state.chess960_rating,
),
LichessEntityDescription(
key="chess960_games",
translation_key="chess960_games",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda state: state.chess960_games,
),
LichessEntityDescription(
key="crazyhouse_rating",
translation_key="crazyhouse_rating",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda state: state.crazyhouse_rating,
),
LichessEntityDescription(
key="crazyhouse_games",
translation_key="crazyhouse_games",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda state: state.crazyhouse_games,
),
LichessEntityDescription(
key="antichess_rating",
translation_key="antichess_rating",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda state: state.antichess_rating,
),
LichessEntityDescription(
key="antichess_games",
translation_key="antichess_games",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda state: state.antichess_games,
),
LichessEntityDescription(
key="atomic_rating",
translation_key="atomic_rating",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda state: state.atomic_rating,
),
LichessEntityDescription(
key="atomic_games",
translation_key="atomic_games",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda state: state.atomic_games,
),
LichessEntityDescription(
key="horde_rating",
translation_key="horde_rating",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda state: state.horde_rating,
),
LichessEntityDescription(
key="horde_games",
translation_key="horde_games",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda state: state.horde_games,
),
LichessEntityDescription(
key="king_of_the_hill_rating",
translation_key="king_of_the_hill_rating",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda state: state.king_of_the_hill_rating,
),
LichessEntityDescription(
key="king_of_the_hill_games",
translation_key="king_of_the_hill_games",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda state: state.king_of_the_hill_games,
),
LichessEntityDescription(
key="racing_kings_rating",
translation_key="racing_kings_rating",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda state: state.racing_kings_rating,
),
LichessEntityDescription(
key="racing_kings_games",
translation_key="racing_kings_games",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda state: state.racing_kings_games,
),
LichessEntityDescription(
key="three_check_rating",
translation_key="three_check_rating",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda state: state.three_check_rating,
),
LichessEntityDescription(
key="three_check_games",
translation_key="three_check_games",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda state: state.three_check_games,
),
LichessEntityDescription(
key="puzzle_rating",
translation_key="puzzle_rating",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda state: state.puzzle_rating,
),
LichessEntityDescription(
key="puzzle_games",
translation_key="puzzle_games",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda state: state.puzzle_games,
),
)
@@ -21,6 +21,20 @@
},
"entity": {
"sensor": {
"antichess_games": {
"name": "Antichess games",
"unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]"
},
"antichess_rating": {
"name": "Antichess rating"
},
"atomic_games": {
"name": "Atomic games",
"unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]"
},
"atomic_rating": {
"name": "Atomic rating"
},
"blitz_games": {
"name": "Blitz games",
"unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]"
@@ -35,6 +49,13 @@
"bullet_rating": {
"name": "Bullet rating"
},
"chess960_games": {
"name": "Chess960 games",
"unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]"
},
"chess960_rating": {
"name": "Chess960 rating"
},
"classical_games": {
"name": "Classical games",
"unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]"
@@ -42,12 +63,68 @@
"classical_rating": {
"name": "Classical rating"
},
"correspondence_games": {
"name": "Correspondence games",
"unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]"
},
"correspondence_rating": {
"name": "Correspondence rating"
},
"crazyhouse_games": {
"name": "Crazyhouse games",
"unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]"
},
"crazyhouse_rating": {
"name": "Crazyhouse rating"
},
"horde_games": {
"name": "Horde games",
"unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]"
},
"horde_rating": {
"name": "Horde rating"
},
"king_of_the_hill_games": {
"name": "King of the Hill games",
"unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]"
},
"king_of_the_hill_rating": {
"name": "King of the Hill rating"
},
"puzzle_games": {
"name": "Puzzle games",
"unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]"
},
"puzzle_rating": {
"name": "Puzzle rating"
},
"racing_kings_games": {
"name": "Racing Kings games",
"unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]"
},
"racing_kings_rating": {
"name": "Racing Kings rating"
},
"rapid_games": {
"name": "Rapid games",
"unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]"
},
"rapid_rating": {
"name": "Rapid rating"
},
"three_check_games": {
"name": "Three-check games",
"unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]"
},
"three_check_rating": {
"name": "Three-check rating"
},
"ultra_bullet_games": {
"name": "UltraBullet games",
"unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]"
},
"ultra_bullet_rating": {
"name": "UltraBullet rating"
}
}
}
@@ -12,7 +12,13 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
)
from homeassistant.const import ATTR_NAME, ATTR_SERIAL_NUMBER, CONF_NAME, PERCENTAGE
from homeassistant.const import (
ATTR_MANUFACTURER,
ATTR_NAME,
ATTR_SERIAL_NUMBER,
CONF_NAME,
PERCENTAGE,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -28,8 +34,6 @@ ATTR_CYCLE_COUNT = "cycle_count"
ATTR_ENERGY_FULL = "energy_full"
ATTR_ENERGY_FULL_DESIGN = "energy_full_design"
ATTR_ENERGY_NOW = "energy_now"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MANUFACTURER = "manufacturer"
ATTR_MODEL_NAME = "model_name"
ATTR_POWER_NOW = "power_now"
ATTR_STATUS = "status"
@@ -8,6 +8,6 @@
"integration_type": "service",
"iot_class": "local_push",
"quality_scale": "silver",
"requirements": ["mcp==1.26.0", "aiohttp_sse==2.2.0", "anyio==4.10.0"],
"requirements": ["mcp==1.26.0", "aiohttp_sse==2.2.0", "anyio==4.13.0"],
"single_config_entry": true
}
@@ -1,7 +0,0 @@
"""Command names for the Novy Cooker Hood RF codes."""
from typing import Final
COMMAND_LIGHT: Final = "light"
COMMAND_PLUS: Final = "plus"
COMMAND_MINUS: Final = "minus"
@@ -3,7 +3,7 @@
import asyncio
from typing import Any
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton
import voluptuous as vol
from homeassistant.components.radio_frequency import (
@@ -19,7 +19,6 @@ from homeassistant.const import CONF_CODE
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, selector
from .commands import COMMAND_LIGHT
from .const import (
CODE_MAX,
CODE_MIN,
@@ -128,10 +127,8 @@ class NovyCookerHoodConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Toggle the hood light on then off so it ends in its starting state."""
assert self._transmitter_entity_id is not None
command = NovyCookerHoodButton.LIGHT.to_command(channel=self._code)
try:
command = await get_codes_for_code(self._code).async_load_command(
COMMAND_LIGHT
)
await async_send_command(self.hass, self._transmitter_entity_id, command)
await asyncio.sleep(_TOGGLE_GAP)
await async_send_command(self.hass, self._transmitter_entity_id, command)
@@ -3,7 +3,8 @@
import math
from typing import Any
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton
from rf_protocols.commands.novy import NovyCookerHoodCommand
from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature
from homeassistant.components.radio_frequency import async_send_command
@@ -17,7 +18,6 @@ from homeassistant.util.percentage import (
ranged_value_to_percentage,
)
from .commands import COMMAND_MINUS, COMMAND_PLUS
from .const import SPEED_COUNT
from .entity import NovyCookerHoodEntity
@@ -49,7 +49,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the fan."""
super().__init__(entry)
self._codes = get_codes_for_code(entry.data[CONF_CODE])
self._code: int = entry.data[CONF_CODE]
self._level = 0
self._attr_unique_id = entry.entry_id
@@ -103,7 +103,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
async def async_increase_speed(self, percentage_step: int | None = None) -> None:
"""Bump speed up by N hardware levels (no recalibration)."""
steps = self._steps_from_percentage(percentage_step)
plus = await self._codes.async_load_command(COMMAND_PLUS)
plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code)
for _ in range(steps):
await self._async_send(plus)
self._level = min(SPEED_COUNT, self._level + steps)
@@ -112,7 +112,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
async def async_decrease_speed(self, percentage_step: int | None = None) -> None:
"""Bump speed down by N hardware levels (no recalibration)."""
steps = self._steps_from_percentage(percentage_step)
minus = await self._codes.async_load_command(COMMAND_MINUS)
minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code)
for _ in range(steps):
await self._async_send(minus)
self._level = max(0, self._level - steps)
@@ -127,17 +127,17 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
async def _async_set_level(self, level: int) -> None:
"""Reset to off with `SPEED_COUNT` minus presses, then climb to level."""
minus = await self._codes.async_load_command(COMMAND_MINUS)
minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code)
for _ in range(SPEED_COUNT):
await self._async_send(minus)
if level > 0:
plus = await self._codes.async_load_command(COMMAND_PLUS)
plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code)
for _ in range(level):
await self._async_send(plus)
self._level = level
self.async_write_ha_state()
async def _async_send(self, command: Any) -> None:
async def _async_send(self, command: NovyCookerHoodCommand) -> None:
"""Send a single RF command via the configured transmitter."""
await async_send_command(
self.hass, self._transmitter, command, context=self._context
@@ -2,7 +2,7 @@
from typing import Any
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.components.radio_frequency import async_send_command
@@ -12,7 +12,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .commands import COMMAND_LIGHT
from .entity import NovyCookerHoodEntity
PARALLEL_UPDATES = 1
@@ -37,7 +36,7 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity):
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the light."""
super().__init__(entry)
self._codes = get_codes_for_code(entry.data[CONF_CODE])
self._code = entry.data[CONF_CODE]
self._attr_unique_id = entry.entry_id
async def async_added_to_hass(self) -> None:
@@ -48,19 +47,19 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on by sending the toggle command."""
await self._async_send_command(COMMAND_LIGHT)
await self._async_send_light()
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off by sending the toggle command."""
await self._async_send_command(COMMAND_LIGHT)
await self._async_send_light()
self._attr_is_on = False
self.async_write_ha_state()
async def _async_send_command(self, name: str) -> None:
"""Load the named command and send it via the configured transmitter."""
command = await self._codes.async_load_command(name)
async def _async_send_light(self) -> None:
"""Send the light toggle command via the configured transmitter."""
command = NovyCookerHoodButton.LIGHT.to_command(channel=self._code)
await async_send_command(
self.hass, self._transmitter, command, context=self._context
)
@@ -9,6 +9,7 @@ from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_TOKEN,
CONF_USERNAME,
CONF_VERIFY_SSL,
Platform,
@@ -31,7 +32,6 @@ from .const import (
CONF_NODE,
CONF_NODES,
CONF_REALM,
CONF_TOKEN,
CONF_TOKEN_ID,
CONF_TOKEN_SECRET,
CONF_VMS,
+4 -1
View File
@@ -5,7 +5,7 @@ from typing import Any
from homeassistant.const import CONF_USERNAME
from .const import AUTH_OTHER, CONF_AUTH_METHOD, CONF_REALM
from .const import AUTH_OTHER, CONF_AUTH_METHOD, CONF_REALM, CONF_TOKEN_ID
def sanitize_config_entry(input_data: Mapping[str, Any]) -> dict[str, Any]:
@@ -21,4 +21,7 @@ def sanitize_config_entry(input_data: Mapping[str, Any]) -> dict[str, Any]:
data[CONF_REALM] = realm
data[CONF_USERNAME] = f"{username}@{realm}"
if CONF_TOKEN_ID in data and "!" in data[CONF_TOKEN_ID]:
data[CONF_TOKEN_ID] = data[CONF_TOKEN_ID].split("!")[1]
return data
@@ -15,6 +15,7 @@ from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_TOKEN,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
@@ -35,7 +36,6 @@ from .const import (
CONF_NODE,
CONF_NODES,
CONF_REALM,
CONF_TOKEN,
CONF_TOKEN_ID,
CONF_TOKEN_SECRET,
CONF_VMS,
@@ -7,8 +7,6 @@ CONF_AUTH_METHOD = "auth_method"
CONF_REALM = "realm"
CONF_NODE = "node"
CONF_NODES = "nodes"
# pylint: disable-next=home-assistant-duplicate-const
CONF_TOKEN = "token"
CONF_TOKEN_ID = "token_id"
CONF_TOKEN_SECRET = "token_value"
CONF_VMS = "vms"
@@ -16,6 +16,7 @@ from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_TOKEN,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
@@ -26,7 +27,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .common import sanitize_config_entry
from .const import (
CONF_NODE,
CONF_TOKEN,
CONF_TOKEN_ID,
CONF_TOKEN_SECRET,
DEFAULT_VERIFY_SSL,
@@ -5,6 +5,7 @@ from typing import Any
from qingping_ble import QingpingBluetoothDeviceData as DeviceData
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
BluetoothServiceInfoBleak,
@@ -96,6 +97,7 @@ class QingpingConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
await bluetooth.async_request_active_scan(self.hass)
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/radio_frequency",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["rf-protocols==3.2.0"]
"requirements": ["rf-protocols==4.0.0"]
}
+10 -2
View File
@@ -113,11 +113,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) ->
except RainbirdApiException as err:
raise ConfigEntryNotReady from err
# Rain Bird devices can only handle a single request at a time. This shared
# lock ensures that the background coordinators do not poll the device
# concurrently.
device_lock = asyncio.Lock()
data = RainbirdData(
controller,
model_info,
coordinator=RainbirdUpdateCoordinator(hass, entry, controller, model_info),
schedule_coordinator=RainbirdScheduleUpdateCoordinator(hass, entry, controller),
coordinator=RainbirdUpdateCoordinator(
hass, entry, controller, model_info, device_lock
),
schedule_coordinator=RainbirdScheduleUpdateCoordinator(
hass, entry, controller, device_lock
),
)
await data.coordinator.async_config_entry_first_refresh()
@@ -25,6 +25,7 @@ UPDATE_INTERVAL = datetime.timedelta(minutes=1)
# The calendar data requires RPCs for each program/zone, and the data rarely
# changes, so we refresh it less often.
CALENDAR_UPDATE_INTERVAL = datetime.timedelta(minutes=15)
CALENDAR_TIMEOUT_SECONDS = 30
# The valves state are not immediately reflected after issuing a command. We add
# small delay to give additional time to reflect the new state.
@@ -64,6 +65,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]):
config_entry: RainbirdConfigEntry,
controller: AsyncRainbirdController,
model_info: ModelAndVersion,
device_lock: asyncio.Lock,
) -> None:
"""Initialize RainbirdUpdateCoordinator."""
super().__init__(
@@ -80,6 +82,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]):
self._unique_id = config_entry.unique_id
self._zones: set[int] | None = None
self._model_info = model_info
self._device_lock = device_lock
@property
def controller(self) -> AsyncRainbirdController:
@@ -112,8 +115,9 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]):
async def _async_update_data(self) -> RainbirdDeviceState:
"""Fetch data from Rain Bird device."""
try:
async with asyncio.timeout(TIMEOUT_SECONDS):
return await self._fetch_data()
async with self._device_lock:
async with asyncio.timeout(TIMEOUT_SECONDS):
return await self._fetch_data()
except RainbirdDeviceBusyException as err:
raise UpdateFailed("Rain Bird device is busy") from err
except RainbirdApiException as err:
@@ -147,6 +151,7 @@ class RainbirdScheduleUpdateCoordinator(DataUpdateCoordinator[Schedule]):
hass: HomeAssistant,
config_entry: RainbirdConfigEntry,
controller: AsyncRainbirdController,
device_lock: asyncio.Lock,
) -> None:
"""Initialize ZoneStateUpdateCoordinator."""
super().__init__(
@@ -158,11 +163,13 @@ class RainbirdScheduleUpdateCoordinator(DataUpdateCoordinator[Schedule]):
update_interval=CALENDAR_UPDATE_INTERVAL,
)
self._controller = controller
self._device_lock = device_lock
async def _async_update_data(self) -> Schedule:
"""Fetch data from Rain Bird device."""
try:
async with asyncio.timeout(TIMEOUT_SECONDS):
return await self._controller.get_schedule()
async with self._device_lock:
async with asyncio.timeout(CALENDAR_TIMEOUT_SECONDS):
return await self._controller.get_schedule()
except RainbirdApiException as err:
raise UpdateFailed(f"Error communicating with Device: {err}") from err
@@ -5,6 +5,7 @@ from typing import Any
from rapt_ble import RAPTPillBluetoothDeviceData as DeviceData
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
@@ -70,6 +71,7 @@ class RAPTPillConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
await bluetooth.async_request_active_scan(self.hass)
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
@@ -5,6 +5,7 @@ from typing import Any
from ruuvitag_ble import RuuvitagBluetoothDeviceData
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
@@ -70,6 +71,7 @@ class RuuvitagConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
await bluetooth.async_request_active_scan(self.hass)
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
@@ -39,7 +39,7 @@
"getmac==0.9.5",
"samsungctl[websocket]==0.7.1",
"samsungtvws[async,encrypted]==2.7.2",
"wakeonlan==3.1.0",
"wakeonlan==3.3.0",
"async-upnp-client==0.46.2"
],
"ssdp": [
@@ -5,6 +5,7 @@ from typing import Any
from sensirion_ble import SensirionBluetoothDeviceData
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
@@ -70,6 +71,7 @@ class SensirionConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
await bluetooth.async_request_active_scan(self.hass)
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
@@ -39,6 +39,7 @@ from homeassistant.helpers.typing import ConfigType
from .const import (
BLOCK_EXPECTED_SLEEP_PERIOD,
BLOCK_WRONG_SLEEP_PERIOD,
CONF_BLE_SCANNER_MODE,
CONF_COAP_PORT,
CONF_SLEEP_PERIOD,
DOMAIN,
@@ -46,6 +47,7 @@ from .const import (
LOGGER,
MODELS_WITH_WRONG_SLEEP_PERIOD,
PUSH_UPDATE_ISSUE_ID,
BLEScannerMode,
)
from .coordinator import (
ShellyBlockCoordinator,
@@ -125,6 +127,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_migrate_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> bool:
"""Migrate old config entries."""
if entry.version > 1 or (entry.version == 1 and entry.minor_version > 3):
return False
if entry.minor_version < 3:
# One-time flip of explicit Active scanning to Auto so existing
# installs get the new battery-friendly default; Passive stays
# Passive because users picked it deliberately.
options = dict(entry.options)
if options.get(CONF_BLE_SCANNER_MODE) == BLEScannerMode.ACTIVE:
options[CONF_BLE_SCANNER_MODE] = BLEScannerMode.AUTO
hass.config_entries.async_update_entry(entry, options=options, minor_version=3)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> bool:
"""Set up Shelly from a config entry."""
entry.runtime_data = ShellyEntryData([])
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE = {
BLEScannerMode.PASSIVE: BluetoothScanningMode.PASSIVE,
BLEScannerMode.ACTIVE: BluetoothScanningMode.ACTIVE,
BLEScannerMode.AUTO: BluetoothScanningMode.AUTO,
}
@@ -31,13 +32,25 @@ async def async_connect_scanner(
"""Connect scanner."""
device = coordinator.device
entry = coordinator.config_entry
bluetooth_scanning_mode = BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE[scanner_mode]
# Options persist as plain strings, coerce so `is` checks work.
scanner_mode = BLEScannerMode(scanner_mode)
requested_mode = BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE[scanner_mode]
# AUTO runs the radio passive and lets habluetooth's auto-scheduler
# flip the BLE script to active on demand.
firmware_active = scanner_mode is BLEScannerMode.ACTIVE
current_mode = (
BluetoothScanningMode.ACTIVE
if firmware_active
else BluetoothScanningMode.PASSIVE
)
scanner = create_scanner(
coordinator.bluetooth_source,
entry.title,
requested_mode=bluetooth_scanning_mode,
current_mode=bluetooth_scanning_mode,
requested_mode=requested_mode,
current_mode=current_mode,
)
if scanner_mode is BLEScannerMode.AUTO:
scanner.set_active_window_provider(device)
unload_callbacks = [
async_register_scanner(
hass,
@@ -52,7 +65,7 @@ async def async_connect_scanner(
]
await async_start_scanner(
device=device,
active=scanner_mode == BLEScannerMode.ACTIVE,
active=firmware_active,
event_type=BLE_SCAN_RESULT_EVENT,
data_version=BLE_SCAN_RESULT_VERSION,
)
@@ -103,6 +103,7 @@ CONFIG_SCHEMA: Final = vol.Schema(
BLE_SCANNER_OPTIONS = [
BLEScannerMode.DISABLED,
BLEScannerMode.AUTO,
BLEScannerMode.ACTIVE,
BLEScannerMode.PASSIVE,
]
@@ -205,7 +206,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Shelly."""
VERSION = 1
MINOR_VERSION = 2
MINOR_VERSION = 3
host: str = ""
port: int = DEFAULT_HTTP_PORT
+1
View File
@@ -237,6 +237,7 @@ class BLEScannerMode(StrEnum):
DISABLED = "disabled"
ACTIVE = "active"
PASSIVE = "passive"
AUTO = "auto"
BLE_SCANNER_MIN_FIRMWARE = "1.5.1"
@@ -17,7 +17,7 @@
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "platinum",
"requirements": ["aioshelly==13.26.0"],
"requirements": ["aioshelly==13.26.1"],
"zeroconf": [
{
"name": "shelly*",
+3 -4
View File
@@ -53,10 +53,9 @@ def async_manage_ble_scanner_firmware_unsupported_issue(
if supports_scripts and device.model not in (MODEL_PLUG_S_G3, MODEL_OUT_PLUG_S_G3):
firmware = AwesomeVersion(device.shelly["ver"])
if (
firmware < BLE_SCANNER_MIN_FIRMWARE
and entry.options.get(CONF_BLE_SCANNER_MODE) == BLEScannerMode.ACTIVE
):
if firmware < BLE_SCANNER_MIN_FIRMWARE and entry.options.get(
CONF_BLE_SCANNER_MODE
) in (BLEScannerMode.ACTIVE, BLEScannerMode.AUTO):
ir.async_create_issue(
hass,
DOMAIN,
+5 -4
View File
@@ -685,7 +685,7 @@
},
"step": {
"confirm": {
"description": "Your Shelly device {device_name} with IP address {ip_address} is running firmware {firmware} and acts as BLE scanner with active mode. This firmware version is not supported for BLE scanner active mode.\n\nSelect **Submit** button to start the OTA update to the latest stable firmware version.",
"description": "Your Shelly device {device_name} with IP address {ip_address} is running firmware {firmware} and acts as a BLE scanner in Active or Auto mode. This firmware version is not supported for these BLE scanner modes.\n\nSelect **Submit** button to start the OTA update to the latest stable firmware version.",
"title": "[%key:component::shelly::issues::ble_scanner_firmware_unsupported::title%]"
}
}
@@ -787,16 +787,17 @@
"data_description": {
"ble_scanner_mode": "The scanner mode to use for Bluetooth scanning."
},
"description": "Bluetooth scanning can be active or passive. With active, the Shelly requests data from nearby devices; with passive, the Shelly receives unsolicited data from nearby devices."
"description": "Auto is recommended for most setups; the Shelly listens passively and only briefly switches to active when needed, saving battery on your Bluetooth devices."
}
}
},
"selector": {
"ble_scanner_mode": {
"options": {
"active": "[%key:common::state::active%]",
"active": "Active (uses more device battery, fastest updates)",
"auto": "Auto (recommended, saves device battery)",
"disabled": "[%key:common::state::disabled%]",
"passive": "Passive"
"passive": "Passive (lowest device battery use, some details may be missing)"
}
},
"device": {
+7 -4
View File
@@ -40,14 +40,17 @@ async def validate_input(
data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
session = async_get_clientsession(hass, verify_ssl=user_input[CONF_VERIFY_SSL])
protocol = "https" if user_input[CONF_SSL] else "http"
host = data[CONF_HOST] if data is not None else user_input[CONF_HOST]
url = URL.build(scheme=protocol, host=host)
url = str(URL.build(scheme=protocol, host=host))
sma = SMAWebConnect(
session, str(url), user_input[CONF_PASSWORD], group=user_input[CONF_GROUP]
session=async_get_clientsession(hass, verify_ssl=user_input[CONF_VERIFY_SSL]),
url=url,
**{
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_GROUP: user_input[CONF_GROUP],
},
)
# new_session raises SmaAuthenticationException on failure
@@ -7,6 +7,7 @@ from typing import Any
from pysnooz.advertisement import SnoozAdvertisementData
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
BluetoothServiceInfo,
@@ -94,6 +95,7 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return self._create_snooz_entry(discovered)
await bluetooth.async_request_active_scan(self.hass)
configured_addresses = self._async_current_ids(include_ignore=False)
for info in async_discovered_service_info(self.hass):
@@ -184,6 +184,7 @@ PLATFORMS_BY_TYPE = {
Platform.LOCK,
Platform.SENSOR,
],
SupportedModels.WEATHER_STATION.value: [Platform.SENSOR],
}
CLASS_BY_DEVICE = {
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
@@ -14,6 +14,7 @@ from switchbot import (
)
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
@@ -424,6 +425,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_password()
return await self._async_create_entry_from_discovery(user_input)
await bluetooth.async_request_active_scan(self.hass)
self._async_discover_devices()
if len(self._discovered_advs) == 1:
# If there is only one device we can ask for a password
@@ -70,6 +70,7 @@ class SupportedModels(StrEnum):
LOCK_VISION_PRO = "lock_vision_pro"
LOCK_VISION = "lock_vision"
LOCK_PRO_WIFI = "lock_pro_wifi"
WEATHER_STATION = "weather_station"
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -134,6 +135,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.HUBMINI_MATTER: SupportedModels.HUBMINI_MATTER,
SwitchbotModel.HUB3: SupportedModels.HUB3,
SwitchbotModel.CLIMATE_PANEL: SupportedModels.CLIMATE_PANEL,
SwitchbotModel.WEATHER_STATION: SupportedModels.WEATHER_STATION,
}
SUPPORTED_MODEL_TYPES = (
@@ -5,6 +5,7 @@ from typing import Any
from thermopro_ble import ThermoProBluetoothDeviceData as DeviceData
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
@@ -70,6 +71,7 @@ class ThermoProConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
await bluetooth.async_request_active_scan(self.hass)
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
@@ -5,6 +5,7 @@ from typing import Any
from tilt_ble import TiltBluetoothDeviceData as DeviceData
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
@@ -70,6 +71,7 @@ class TiltConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
await bluetooth.async_request_active_scan(self.hass)
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
@@ -8,6 +8,7 @@ from togrill_bluetooth.client import Client
from togrill_bluetooth.packets import PacketA0Notify
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
@@ -113,6 +114,7 @@ class ToGrillBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovery_infos[address]
)
await bluetooth.async_request_active_scan(self.hass)
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, True):
address = discovery_info.address

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