mirror of
https://github.com/home-assistant/core.git
synced 2026-05-25 18:25:10 +02:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7f3e5637f | |||
| 80cefc74ec | |||
| 2f33b4b7f9 | |||
| cf52a7a509 | |||
| f5835f849a | |||
| ec5210dca8 | |||
| 422ea1a9b1 | |||
| b6f69f6b99 | |||
| a2a3819241 | |||
| 3ce33b0ac6 | |||
| e507a97d8b | |||
| 5801fdad14 | |||
| 2f4abd6a25 | |||
| 1c045ab715 | |||
| d4ca541a96 | |||
| a07a9dc6c8 | |||
| 6d60b3a23a | |||
| 37bb895b91 | |||
| f87dc917a6 | |||
| 71a15c188e | |||
| 003ecdb867 | |||
| ec7e5e5a75 | |||
| 7587f062e1 | |||
| 11970144e4 | |||
| 70750a6d79 | |||
| a53437315f | |||
| 5c73ad0310 | |||
| 4a04a271ec | |||
| 52c27bdea5 | |||
| 6fdc52c002 | |||
| e560bbc103 | |||
| b8c573685f | |||
| 3764b70b90 | |||
| 5d2de6f82b | |||
| 64d17f44fa | |||
| 6f67d44cfe | |||
| def3befb0e | |||
| 05716ae196 | |||
| c0a864297f | |||
| 04bb84cd03 | |||
| cb55accc3b | |||
| d21c227804 | |||
| 1ebccd9fa2 | |||
| cfbd0f3217 | |||
| 4afb7c0997 | |||
| 105caccc51 | |||
| 6419551117 | |||
| 585bd6616a | |||
| b8dd97cf21 | |||
| 68fc4aed78 | |||
| 7dbb259625 | |||
| 057eac7fb6 | |||
| 31c9cdf742 | |||
| 3147104132 | |||
| d6d0f37b52 | |||
| 75e48745a8 | |||
| 533417778c | |||
| e49fd4ebbd | |||
| 8412b029b1 | |||
| c65de7521f | |||
| 752c17917e | |||
| f643c7ddc6 | |||
| 6f5d4cf991 | |||
| b52466fed1 | |||
| 189534e32b | |||
| 684ae23b18 | |||
| f4d2f65602 | |||
| 65879ff37b | |||
| d902104bee | |||
| 7bad27c412 | |||
| 74a7102cf6 | |||
| e88fb03388 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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*",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user