Compare commits

..

2 Commits

Author SHA1 Message Date
J. Nick Koston bda7244e5a Extend INKBIRD fallback poll interval to 330s to match AUTO scan cadence 2026-05-24 08:55:24 -05:00
J. Nick Koston 3dc1b4cabc Request active scan cadence for INKBIRD passive sensors 2026-05-23 23:52:57 -05:00
387 changed files with 1659 additions and 17883 deletions
-9
View File
@@ -1,9 +0,0 @@
{
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {
"version": "1.1.0",
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
"integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
}
}
}
+3 -3
View File
@@ -1421,7 +1421,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
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@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
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@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
report_type: test_results
fail_ci_if_error: true
-1
View File
@@ -609,7 +609,6 @@ 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 -4
View File
@@ -236,8 +236,8 @@ CLAUDE.md @home-assistant/core
/tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx
/homeassistant/components/blink/ @fronzbot
/tests/components/blink/ @fronzbot
/homeassistant/components/blue_current/ @gleeuwen @jtodorova23
/tests/components/blue_current/ @gleeuwen @jtodorova23
/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
/tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
/homeassistant/components/bluemaestro/ @bdraco
/tests/components/bluemaestro/ @bdraco
/homeassistant/components/blueprint/ @home-assistant/core
@@ -1930,8 +1930,6 @@ 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,11 +7,10 @@ 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 DOMAIN
from .const import CONF_HOST, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -1,3 +1,6 @@
"""Constants for the Altruist integration."""
DOMAIN = "altruist"
# pylint: disable-next=home-assistant-duplicate-const
CONF_HOST = "host"
@@ -10,12 +10,13 @@ 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,7 +24,6 @@ from pyatv.interface import (
PushListener,
PushUpdater,
)
from yarl import URL
from homeassistant.components import media_source
from homeassistant.components.media_player import (
@@ -346,10 +345,7 @@ class AppleTvMediaPlayer(
play_item = await media_source.async_resolve_media(
self.hass, media_id, self.entity_id
)
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_id = async_process_play_media_url(self.hass, play_item.url)
media_type = MediaType.MUSIC
if self._is_feature_available(FeatureName.StreamFile) and (
@@ -357,16 +353,11 @@ 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) and (
(parsed_url := URL(media_id)).is_absolute() and parsed_url.host
):
elif self._is_feature_available(FeatureName.PlayUrl):
_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 for %s",
media_id,
)
_LOGGER.error("Media streaming is not possible with current configuration")
@property
def media_image_hash(self) -> str | None:
+1 -5
View File
@@ -193,11 +193,7 @@ async def async_setup_entry(
Aranet4BluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(
entry.runtime_data.async_register_processor(
processor, AranetSensorEntityDescription
)
)
entry.async_on_unload(entry.runtime_data.async_register_processor(processor))
class Aranet4BluetoothSensorEntity(
@@ -9,11 +9,12 @@ import voluptuous as vol
from homeassistant.components import usb
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import ATTR_MODEL, ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT
from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT
from homeassistant.core import HomeAssistant
from .const import (
ATTR_FIRMWARE,
ATTR_MODEL,
DEFAULT_ADDRESS,
DEFAULT_INTEGRATION_TITLE,
DOMAIN,
@@ -19,4 +19,8 @@ DEVICES = "devices"
MANUFACTURER = "ABB"
ATTR_DEVICE_NAME = "device_name"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_DEVICE_ID = "device_id"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODEL = "model"
ATTR_FIRMWARE = "firmware"
@@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
ATTR_MODEL,
ATTR_SERIAL_NUMBER,
EntityCategory,
UnitOfElectricCurrent,
@@ -32,6 +31,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTR_DEVICE_NAME,
ATTR_FIRMWARE,
ATTR_MODEL,
DEFAULT_DEVICE_NAME,
DOMAIN,
MANUFACTURER,
+1 -2
View File
@@ -17,11 +17,10 @@ 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, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .const import CONF_BUCKET, CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .helpers import async_list_backups_from_s3
_LOGGER = logging.getLogger(__name__)
@@ -8,7 +8,6 @@ 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,
@@ -21,6 +20,7 @@ from .const import (
CONF_ACCESS_KEY_ID,
CONF_BUCKET,
CONF_ENDPOINT_URL,
CONF_PREFIX,
CONF_SECRET_ACCESS_KEY,
DEFAULT_ENDPOINT_URL,
DESCRIPTION_AWS_S3_DOCS_URL,
+2
View File
@@ -11,6 +11,8 @@ 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,11 +8,10 @@ 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, DOMAIN
from .const import CONF_BUCKET, CONF_PREFIX, DOMAIN
from .helpers import async_list_backups_from_s3
SCAN_INTERVAL = timedelta(hours=6)
@@ -5,10 +5,15 @@ 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_SECRET_ACCESS_KEY, DOMAIN
from .const import (
CONF_ACCESS_KEY_ID,
CONF_BUCKET,
CONF_PREFIX,
CONF_SECRET_ACCESS_KEY,
DOMAIN,
)
from .coordinator import S3ConfigEntry
from .helpers import async_list_backups_from_s3
+19 -25
View File
@@ -2,12 +2,13 @@
from collections.abc import Mapping
from ipaddress import ip_address
from typing import TYPE_CHECKING, Any
from typing import Any
from urllib.parse import urlsplit
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_IGNORE,
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigEntry,
@@ -49,9 +50,6 @@ from .const import (
from .errors import AuthenticationRequired, CannotConnect
from .hub import AxisHub, get_axis_api
if TYPE_CHECKING:
import axis
AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f", "e8:27:25"}
DEFAULT_PORT = 443
DEFAULT_PROTOCOL = "https"
@@ -96,8 +94,7 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
else:
if (serial := self._get_serial_number(api)) is None:
return self.async_abort(reason="no_serial_number")
serial = api.vapix.serial_number
config = {
CONF_PROTOCOL: user_input[CONF_PROTOCOL],
CONF_HOST: user_input[CONF_HOST],
@@ -142,15 +139,25 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
async def _create_entry(self, serial: str) -> ConfigFlowResult:
"""Create entry for device.
Use the discovered device name when available.
Generate a name to be used as a prefix for device entities.
"""
if (title_placeholders := self.context.get("title_placeholders")) is not None:
name = title_placeholders[CONF_NAME]
else:
name = f"{self.config[CONF_MODEL]} - {serial}"
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
self.config[CONF_NAME] = name
return self.async_create_entry(title=name, data=self.config)
title = f"{model} - {serial}"
return self.async_create_entry(title=title, data=self.config)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
@@ -262,19 +269,6 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_user()
@staticmethod
def _get_serial_number(api: axis.AxisDevice) -> str | None:
"""Retrieve the device serial number from the Axis API.
Tries basic_device_info first, then property_handler. Returns None if not found.
"""
vapix = api.vapix
if vapix.basic_device_info.initialized:
return vapix.basic_device_info["0"].serial_number
if vapix.params.property_handler.initialized:
return vapix.params.property_handler["0"].system_serial_number
return None
class AxisOptionsFlowHandler(OptionsFlow):
"""Handle Axis device options."""
@@ -3,7 +3,6 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"link_local_address": "Link local addresses are not supported",
"no_serial_number": "Could not retrieve a serial number from the device. Please check device connectivity and try again.",
"not_axis_device": "Discovered device not an Axis device",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["blebox_uniapi"],
"requirements": ["blebox-uniapi==2.5.4"],
"requirements": ["blebox-uniapi==2.5.3"],
"zeroconf": ["_bbxsrv._tcp.local."]
}
@@ -1,7 +1,7 @@
{
"domain": "blue_current",
"name": "Blue Current",
"codeowners": ["@gleeuwen", "@jtodorova23"],
"codeowners": ["@gleeuwen", "@NickKoepr", "@jtodorova23"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/blue_current",
"integration_type": "hub",
@@ -124,9 +124,7 @@ async def async_setup_entry(
BlueMaestroBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(
coordinator.async_register_processor(processor, SensorEntityDescription)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
class BlueMaestroBluetoothSensorEntity(
@@ -70,7 +70,6 @@ from .api import (
async_register_callback,
async_register_scanner,
async_remove_scanner,
async_request_active_scan,
async_scanner_by_source,
async_scanner_count,
async_scanner_devices_by_address,
@@ -129,7 +128,6 @@ __all__ = [
"async_register_callback",
"async_register_scanner",
"async_remove_scanner",
"async_request_active_scan",
"async_scanner_by_source",
"async_scanner_count",
"async_scanner_devices_by_address",
-16
View File
@@ -284,19 +284,3 @@ def async_set_fallback_availability_interval(
) -> None:
"""Override the fallback availability timeout for a MAC address."""
_get_manager(hass).async_set_fallback_availability_interval(address, interval)
async def async_request_active_scan(
hass: HomeAssistant, duration: float | None = None
) -> None:
"""Run an on-demand active sweep across every AUTO scanner.
Intended for config-flow discovery and other one-shot probes that
need fresh advertisements without waiting for the periodic
rediscovery cadence. Awaits ``duration`` seconds so the caller can
then read newly discovered advertisements. Defaults to habluetooth's
on-demand sweep duration when ``duration`` is not provided; the
scheduler clamps the value to its allowed range. Concurrent callers
dedupe to a single bus-wide window.
"""
await _get_manager(hass).async_request_active_scan(duration)
@@ -22,7 +22,6 @@ from homeassistant.config_entries import (
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_SOURCE
from homeassistant.core import callback
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
@@ -41,6 +40,7 @@ from .const import (
CONF_DETAILS,
CONF_MODE,
CONF_PASSIVE,
CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN,
@@ -22,6 +22,9 @@ CONF_PASSIVE = "passive"
DEFAULT_MODE = BluetoothScanningMode.AUTO.value
# pylint: disable-next=home-assistant-duplicate-const
CONF_SOURCE: Final = "source"
CONF_SOURCE_DOMAIN: Final = "source_domain"
CONF_SOURCE_MODEL: Final = "source_model"
CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id"
@@ -21,11 +21,7 @@ from habluetooth import (
)
from homeassistant import config_entries
from homeassistant.const import (
CONF_SOURCE,
EVENT_HOMEASSISTANT_STOP,
EVENT_LOGGING_CHANGED,
)
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED
from homeassistant.core import (
CALLBACK_TYPE,
Event,
@@ -37,6 +33,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util.package import is_docker_env
from .const import (
CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN,
@@ -15,12 +15,12 @@
],
"quality_scale": "internal",
"requirements": [
"bleak==3.0.2",
"bleak-retry-connector==4.6.1",
"bluetooth-adapters==2.3.0",
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.14",
"habluetooth==6.7.4"
"bleak==2.1.1",
"bleak-retry-connector==4.6.0",
"bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.29.11",
"dbus-fast==5.0.3",
"habluetooth==6.5.0"
]
}
@@ -9,14 +9,7 @@ from pybravia import BraviaAuthError, BraviaClient, BraviaError, BraviaNotSuppor
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import (
ATTR_MODEL,
CONF_CLIENT_ID,
CONF_HOST,
CONF_MAC,
CONF_NAME,
CONF_PIN,
)
from homeassistant.const import CONF_CLIENT_ID, CONF_HOST, CONF_MAC, CONF_NAME, CONF_PIN
from homeassistant.helpers import instance_id
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.service_info.ssdp import (
@@ -30,6 +23,7 @@ from homeassistant.util.network import is_host_valid
from .const import (
ATTR_CID,
ATTR_MAC,
ATTR_MODEL,
CONF_NICKNAME,
CONF_USE_PSK,
CONF_USE_SSL,
@@ -6,6 +6,8 @@ from typing import Final
ATTR_CID: Final = "cid"
ATTR_MAC: Final = "macAddr"
ATTR_MANUFACTURER: Final = "Sony"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODEL: Final = "model"
CONF_NICKNAME: Final = "nickname"
CONF_USE_PSK: Final = "use_psk"
@@ -14,7 +14,6 @@ from homeassistant.components.notify import (
)
from homeassistant.const import (
CONF_API_KEY,
CONF_LANGUAGE,
CONF_NAME,
CONF_RECIPIENT,
CONF_USERNAME,
@@ -30,6 +29,8 @@ 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,13 +275,9 @@ class CloudGoogleConfig(AbstractConfig):
)
)
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(self, state: State) -> bool:
"""If a state object should be exposed."""
return self._should_expose_entity_id(state.entity_id)
def _should_expose_legacy(self, entity_id: str) -> bool:
"""If an entity ID should be exposed."""
@@ -312,6 +308,14 @@ 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."""
@@ -463,7 +467,7 @@ class CloudGoogleConfig(AbstractConfig):
entity_id = event.data["entity_id"]
if not self.should_expose(entity_id):
if not self._should_expose_entity_id(entity_id):
return
self.async_schedule_google_sync_all()
@@ -486,7 +490,8 @@ 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_entry.entity_id)
entity_entry.area_id is None
and self._should_expose_entity_id(entity_entry.entity_id)
for entity_entry in er.async_entries_for_device(
er.async_get(self.hass), event.data["device_id"]
)
@@ -17,11 +17,10 @@ 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, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .const import CONF_BUCKET, CONF_PREFIX, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
_LOGGER = logging.getLogger(__name__)
CACHE_TTL = 300
@@ -13,7 +13,6 @@ 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,
@@ -26,6 +25,7 @@ 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,6 +11,8 @@ 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
@@ -5,3 +5,6 @@ ATTR_URL = "color_extract_url"
DOMAIN = "color_extractor"
DEFAULT_NAME = "Color extractor"
# pylint: disable-next=home-assistant-duplicate-const
SERVICE_TURN_ON = "turn_on"
@@ -14,11 +14,11 @@ from homeassistant.components.light import (
DOMAIN as LIGHT_DOMAIN,
LIGHT_TURN_ON_SCHEMA,
)
from homeassistant.const import SERVICE_TURN_ON
from homeassistant.const import SERVICE_TURN_ON as LIGHT_SERVICE_TURN_ON
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import aiohttp_client, config_validation as cv
from .const import ATTR_PATH, ATTR_URL, DOMAIN
from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON
_LOGGER = logging.getLogger(__name__)
@@ -141,7 +141,7 @@ async def async_handle_service(service_call: ServiceCall) -> None:
service_data[ATTR_RGB_COLOR] = color
await service_call.hass.services.async_call(
LIGHT_DOMAIN, SERVICE_TURN_ON, service_data, blocking=True
LIGHT_DOMAIN, LIGHT_SERVICE_TURN_ON, service_data, blocking=True
)
+1 -3
View File
@@ -60,9 +60,7 @@ class CheckConfigView(HomeAssistantView):
vol.Optional("location_name"): str,
vol.Optional("longitude"): cv.longitude,
vol.Optional("radius"): cv.positive_int,
# Validated by async_set_time_zone in the executor to avoid
# blocking I/O loading zoneinfo data on the event loop.
vol.Optional("time_zone"): str,
vol.Optional("time_zone"): cv.time_zone,
vol.Optional("update_units"): bool,
vol.Optional("unit_system"): unit_system.validate_unit_system,
}
+26 -51
View File
@@ -42,35 +42,6 @@ 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:
@@ -78,37 +49,41 @@ 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 (first migration, entities used config_entry_id as prefix)
# Add the unique uuid
cookidoo = await cookidoo_from_config_entry(hass, config_entry)
try:
await cookidoo.login()
user_info = await cookidoo.get_user_info()
auth_data = await cookidoo.login()
except (CookidooRequestException, CookidooAuthException) as e:
_LOGGER.error("Could not migrate config entry: %s", e)
_LOGGER.error(
"Could not migrate config config_entry: %s",
str(e),
)
return False
_migrate_identifiers(hass, config_entry, config_entry.entry_id, user_info.id)
hass.config_entries.async_update_entry(
config_entry, unique_id=user_info.id, minor_version=3
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),
)
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
config_entry, unique_id=auth_data.sub, minor_version=2
)
_LOGGER.debug(
+2 -12
View File
@@ -3,11 +3,7 @@
from datetime import date, datetime, timedelta
import logging
from cookidoo_api import (
CookidooAuthException,
CookidooException,
CookidooRequestException,
)
from cookidoo_api import CookidooAuthException, CookidooException
from cookidoo_api.types import CookidooCalendarDayRecipe
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
@@ -78,13 +74,7 @@ class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity):
week_day
)
except CookidooAuthException:
try:
await self.coordinator.cookidoo.login()
except (CookidooAuthException, CookidooRequestException) as exc:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="calendar_fetch_failed",
) from exc
await self.coordinator.cookidoo.refresh_token()
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 = 3
MINOR_VERSION = 2
COUNTRY_DATA_SCHEMA: dict
LANGUAGE_DATA_SCHEMA: dict
@@ -223,9 +223,8 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
cookidoo = await cookidoo_from_config_data(self.hass, data_input)
try:
await cookidoo.login()
user_info = await cookidoo.get_user_info()
self.user_uuid = user_info.id
auth_data = await cookidoo.login()
self.user_uuid = auth_data.sub
if language_input:
await cookidoo.get_additional_items()
except CookidooRequestException:
@@ -87,7 +87,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
)
except CookidooAuthException:
try:
await self.cookidoo.login()
await self.cookidoo.refresh_token()
except CookidooAuthException as exc:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
@@ -96,11 +96,6 @@ 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 -3
View File
@@ -2,12 +2,11 @@
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_create_clientsession
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import CookidooConfigEntry
@@ -22,7 +21,7 @@ async def cookidoo_from_config_data(
)
return Cookidoo(
async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)),
async_get_clientsession(hass),
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.17.2"]
"requirements": ["cookidoo-api==0.14.0"]
}
@@ -6,7 +6,6 @@ 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,
@@ -208,7 +207,6 @@ class TrackerEntityDescription(EntityDescription, frozen_or_thawed=True):
CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = {
"in_zones",
"latitude",
"location_accuracy",
"location_name",
@@ -222,7 +220,6 @@ 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
@@ -242,16 +239,6 @@ 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.
@@ -282,20 +269,6 @@ 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
@@ -307,9 +280,7 @@ 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
) or self.__in_zones is not None:
if self.latitude is not None and self.longitude is not None:
zone_state = self.__active_zone
if zone_state is None:
state = STATE_NOT_HOME
@@ -325,10 +296,11 @@ class TrackerEntity(
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_IN_ZONES: self.__in_zones or []}
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
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
@@ -357,23 +329,6 @@ class BaseScannerEntity(BaseTrackerEntity):
"""Return true if the device is connected."""
raise NotImplementedError
@final
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
attr.update(super().state_attributes)
if not self.is_connected:
return attr
attr[ATTR_IN_ZONES] = [
zone.ENTITY_ID_HOME,
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME),
]
return attr
class ScannerEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes tracker entities."""
@@ -501,12 +456,9 @@ class ScannerEntity(
# Do this last or else the entity registry update listener has been installed
await super().async_internal_added_to_hass()
# BaseScannerEntity.state_attributes is @final to keep external subclasses
# from tampering with it; ScannerEntity is an in-tree subclass that
# intentionally extends it with ip/mac/hostname.
@final # type: ignore[misc]
@final
@property
def state_attributes(self) -> dict[str, Any]:
def state_attributes(self) -> dict[str, StateType]:
"""Return the device state attributes."""
attr = super().state_attributes
+3 -3
View File
@@ -15,8 +15,8 @@
],
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.2.7",
"aiodiscover==3.2.4",
"cached-ipaddress==1.1.1"
"aiodhcpwatcher==1.2.1",
"aiodiscover==3.2.3",
"cached-ipaddress==1.0.1"
]
}
@@ -6,13 +6,13 @@ from typing import TYPE_CHECKING, Any
from dropmqttapi.discovery import DropDiscovery
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE_ID
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from .const import (
CONF_COMMAND_TOPIC,
CONF_DATA_TOPIC,
CONF_DEVICE_DESC,
CONF_DEVICE_ID,
CONF_DEVICE_NAME,
CONF_DEVICE_OWNER_ID,
CONF_DEVICE_TYPE,
@@ -4,6 +4,8 @@
CONF_COMMAND_TOPIC = "drop_command_topic"
CONF_DATA_TOPIC = "drop_data_topic"
CONF_DEVICE_DESC = "device_desc"
# pylint: disable-next=home-assistant-duplicate-const
CONF_DEVICE_ID = "device_id"
CONF_DEVICE_TYPE = "device_type"
CONF_HUB_ID = "drop_hub_id"
CONF_DEVICE_NAME = "name"
@@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
TEMPERATURE,
EntityCategory,
UnitOfPressure,
UnitOfTemperature,
@@ -50,6 +49,8 @@ CURRENT_SYSTEM_PRESSURE = "current_system_pressure"
HIGH_SYSTEM_PRESSURE = "high_system_pressure"
LOW_SYSTEM_PRESSURE = "low_system_pressure"
BATTERY = "battery"
# pylint: disable-next=home-assistant-duplicate-const
TEMPERATURE = "temperature"
INLET_TDS = "inlet_tds"
OUTLET_TDS = "outlet_tds"
CARTRIDGE_1_LIFE = "cart1"
@@ -10,7 +10,7 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyecobee"],
"requirements": ["python-ecobee-api==0.4.0"],
"requirements": ["python-ecobee-api==0.3.2"],
"single_config_entry": true,
"zeroconf": [
{
@@ -3,13 +3,12 @@
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.selector import SerialPortSelector
from .const import CONF_SERIAL_PORT, DEFAULT_TITLE, DOMAIN
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_SERIAL_PORT): SerialPortSelector(),
vol.Required(CONF_SERIAL_PORT): str,
}
)
+2 -2
View File
@@ -4,8 +4,8 @@
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/edl21",
"integration_type": "device",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["sml"],
"requirements": ["pysml==0.1.7"]
"requirements": ["pysml==0.1.5"]
}
+11
View File
@@ -1,6 +1,7 @@
"""Support for EDL21 Smart Meters."""
from collections.abc import Mapping
from datetime import timedelta
from typing import Any
from sml import SmlGetListResponse
@@ -28,6 +29,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from .const import (
CONF_SERIAL_PORT,
@@ -37,6 +39,8 @@ from .const import (
SIGNAL_EDL21_TELEGRAM,
)
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
# OBIS format: A-B:C.D.E*F
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
# A=1: Electricity
@@ -387,6 +391,8 @@ class EDL21Entity(SensorEntity):
self._electricity_id = electricity_id
self._obis = obis
self._telegram = telegram
self._min_time = MIN_TIME_BETWEEN_UPDATES
self._last_update = utcnow()
self._async_remove_dispatcher = None
self.entity_description = entity_description
self._attr_unique_id = f"{electricity_id}_{obis}"
@@ -408,7 +414,12 @@ class EDL21Entity(SensorEntity):
if self._telegram == telegram:
return
now = utcnow()
if now - self._last_update < self._min_time:
return
self._telegram = telegram
self._last_update = now
self.async_write_ha_state()
self._async_remove_dispatcher = async_dispatcher_connect(
+1 -4
View File
@@ -6,10 +6,7 @@
"step": {
"user": {
"data": {
"serial_port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"serial_port": "Serial port path to connect to"
"serial_port": "[%key:common::config_flow::data::usb_path%]"
},
"title": "Add your EDL21 smart meter"
}
@@ -8,7 +8,7 @@ from elevenlabs.core import ApiError
from httpx import ConnectError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_MODEL, Platform
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
@@ -17,7 +17,7 @@ from homeassistant.exceptions import (
)
from homeassistant.helpers.httpx_client import get_async_client
from .const import CONF_STT_MODEL
from .const import CONF_MODEL, CONF_STT_MODEL
_LOGGER = logging.getLogger(__name__)
@@ -8,7 +8,7 @@ from elevenlabs.core import ApiError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_API_KEY, CONF_MODEL
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.selector import (
@@ -20,6 +20,7 @@ from homeassistant.helpers.selector import (
from . import ElevenLabsConfigEntry
from .const import (
CONF_CONFIGURE_VOICE,
CONF_MODEL,
CONF_SIMILARITY,
CONF_STABILITY,
CONF_STT_AUTO_LANGUAGE,
@@ -1,6 +1,11 @@
"""Constants for the ElevenLabs text-to-speech integration."""
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODEL = "model"
CONF_VOICE = "voice"
# pylint: disable-next=home-assistant-duplicate-const
CONF_MODEL = "model"
CONF_CONFIGURE_VOICE = "configure_voice"
CONF_STABILITY = "stability"
CONF_SIMILARITY = "similarity"
+2 -1
View File
@@ -20,7 +20,7 @@ from homeassistant.components.tts import (
TtsAudioType,
Voice,
)
from homeassistant.const import ATTR_MODEL, EntityCategory
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
@@ -28,6 +28,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ElevenLabsConfigEntry
from .const import (
ATTR_MODEL,
CONF_SIMILARITY,
CONF_STABILITY,
CONF_STYLE,
@@ -10,7 +10,7 @@ from aiohttp import ClientError, ClientResponseError
from energyid_webhooks.client_v2 import WebhookClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import (
CALLBACK_TYPE,
Event,
@@ -28,6 +28,7 @@ from homeassistant.helpers.event import (
)
from .const import (
CONF_DEVICE_ID,
CONF_DEVICE_NAME,
CONF_ENERGYID_KEY,
CONF_HA_ENTITY_UUID,
@@ -15,13 +15,13 @@ from homeassistant.config_entries import (
ConfigFlowResult,
ConfigSubentryFlow,
)
from homeassistant.const import CONF_DEVICE_ID
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
from .const import (
CONF_DEVICE_ID,
CONF_DEVICE_NAME,
CONF_PROVISIONING_KEY,
CONF_PROVISIONING_SECRET,
@@ -8,6 +8,8 @@ NAME: Final = "EnergyID"
# --- Config Flow and Entry Data ---
CONF_PROVISIONING_KEY: Final = "provisioning_key"
CONF_PROVISIONING_SECRET: Final = "provisioning_secret"
# pylint: disable-next=home-assistant-duplicate-const
CONF_DEVICE_ID: Final = "device_id"
CONF_DEVICE_NAME: Final = "device_name"
# --- Subentry (Mapping) Data ---
+1 -1
View File
@@ -27,7 +27,7 @@ DEFAULT_BLUETOOTH_SCANNING_MODE = BluetoothScanningMode.AUTO.value
DEFAULT_PORT: Final = 6053
STABLE_BLE_VERSION_STR = "2026.5.1"
STABLE_BLE_VERSION_STR = "2025.11.0"
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR)
PROJECT_URLS = {
"esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/",
+2 -7
View File
@@ -53,7 +53,7 @@ def async_static_info_updated(
platform: entity_platform.EntityPlatform,
async_add_entities: AddEntitiesCallback,
info_type: type[_InfoT],
entity_type: Callable[[RuntimeEntryData, EntityInfo, type[_StateT]], _EntityT],
entity_type: type[_EntityT],
state_type: type[_StateT],
infos: list[EntityInfo],
) -> None:
@@ -188,7 +188,7 @@ async def platform_async_setup_entry(
async_add_entities: AddEntitiesCallback,
*,
info_type: type[_InfoT],
entity_type: Callable[[RuntimeEntryData, EntityInfo, type[_StateT]], _EntityT],
entity_type: type[_EntityT],
state_type: type[_StateT],
info_filter: Callable[[_InfoT], bool] | None = None,
) -> None:
@@ -196,11 +196,6 @@ async def platform_async_setup_entry(
This method is in charge of receiving, distributing and storing
info and state updates.
`entity_type` is any callable that builds an entity from
`(entry_data, info, state_type)`. A regular entity class satisfies this,
and platforms with multiple entity classes can pass a factory function
that picks the class per static info.
"""
entry_data = entry.runtime_data
entry_data.info[info_type] = {}
+11 -88
View File
@@ -1,34 +1,28 @@
"""Infrared platform for ESPHome."""
import functools
from functools import partial
import logging
from typing import TYPE_CHECKING
from aioesphomeapi import EntityInfo, EntityState, InfraredCapability, InfraredInfo
from aioesphomeapi.client import InfraredRFReceiveEventModel
from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo
from homeassistant.components.infrared import (
InfraredCommand,
InfraredEmitterEntity,
InfraredReceivedSignal,
InfraredReceiverEntity,
)
from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
from homeassistant.core import callback
from .entity import (
EsphomeEntity,
convert_api_error_ha_error,
platform_async_setup_entry,
)
from .entry_data import RuntimeEntryData
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
class _EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState]):
"""Common base for ESPHome infrared entities."""
class EsphomeInfraredEntity(
EsphomeEntity[InfraredInfo, EntityState], InfraredEmitterEntity
):
"""ESPHome infrared emitter entity using native API."""
@callback
def _on_device_update(self) -> None:
@@ -38,10 +32,6 @@ class _EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState]):
# Infrared entities should go available as soon as the device comes online
self.async_write_ha_state()
class EsphomeInfraredEmitterEntity(_EsphomeInfraredEntity, InfraredEmitterEntity):
"""ESPHome infrared emitter entity using native API."""
@convert_api_error_ha_error
async def async_send_command(self, command: InfraredCommand) -> None:
"""Send an IR command."""
@@ -56,77 +46,10 @@ class EsphomeInfraredEmitterEntity(_EsphomeInfraredEntity, InfraredEmitterEntity
)
class EsphomeInfraredReceiverEntity(_EsphomeInfraredEntity, InfraredReceiverEntity):
"""ESPHome infrared receiver entity using native API."""
_unsub_receive: CALLBACK_TYPE | None = None
async def async_added_to_hass(self) -> None:
"""Register callbacks including IR receive subscription."""
await super().async_added_to_hass()
self._async_subscribe_receive()
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from the device on entity removal."""
await super().async_will_remove_from_hass()
if self._unsub_receive is not None:
self._unsub_receive()
self._unsub_receive = None
@callback
def _async_subscribe_receive(self) -> None:
"""Subscribe to IR receive events if the device is connected."""
# Subscribing requires an active API connection; defer to
# _on_device_update when the device is not (yet) available.
if self._unsub_receive is not None or not self._entry_data.available:
return
self._unsub_receive = self._client.subscribe_infrared_rf_receive(
self._on_infrared_rf_receive
)
@callback
def _on_device_update(self) -> None:
"""Call when device updates or entry data changes."""
super()._on_device_update()
if self._entry_data.available:
self._async_subscribe_receive()
elif self._unsub_receive is not None:
self._unsub_receive = None
@callback
def _on_infrared_rf_receive(self, event: InfraredRFReceiveEventModel) -> None:
"""Handle a received IR signal from the device."""
if (
event.key != self._static_info.key
or event.device_id != self._static_info.device_id
):
return
self._handle_received_signal(InfraredReceivedSignal(timings=event.timings))
def _make_infrared_entity(
entry_data: RuntimeEntryData,
info: EntityInfo,
state_type: type[EntityState],
) -> _EsphomeInfraredEntity:
"""Build the right infrared entity based on the InfraredInfo capabilities."""
if TYPE_CHECKING:
assert isinstance(info, InfraredInfo)
cls = (
EsphomeInfraredReceiverEntity
if info.capabilities & InfraredCapability.RECEIVER
else EsphomeInfraredEmitterEntity
)
return cls(entry_data, info, state_type)
async_setup_entry = functools.partial(
async_setup_entry = partial(
platform_async_setup_entry,
info_type=InfraredInfo,
entity_type=_make_infrared_entity,
entity_type=EsphomeInfraredEntity,
state_type=EntityState,
info_filter=lambda info: bool(
info.capabilities
& (InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER)
),
info_filter=lambda info: bool(info.capabilities & InfraredCapability.TRANSMITTER),
)
@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==45.2.2",
"aioesphomeapi==45.2.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.9.1"
],
@@ -5,7 +5,6 @@ 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,
@@ -76,7 +75,6 @@ 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
@@ -2,7 +2,6 @@
from datetime import timedelta
import logging
from typing import Any
from pyfireservicerota import (
ExpiredTokenError,
@@ -178,12 +177,10 @@ class FireServiceRotaClient:
if await self.oauth.async_refresh_tokens():
self.token_refresh_failure = False
await self._hass.async_add_executor_job(self.websocket.start_listener)
def _restart_and_call() -> Any:
self.websocket.start_listener()
return func(*args)
return await self._hass.async_add_executor_job(_restart_and_call)
# pylint: disable-next=home-assistant-sequential-executor-jobs
return await self._hass.async_add_executor_job(func, *args)
async def async_update(self) -> dict | None:
"""Get the latest availability data."""
@@ -5,10 +5,11 @@ import logging
from fishaudio import AsyncFishAudio
from fishaudio.exceptions import AuthenticationError, FishAudioError
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import CONF_API_KEY
from .types import FishAudioConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -16,7 +16,6 @@ from homeassistant.config_entries import (
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.selector import (
LanguageSelector,
@@ -30,8 +29,11 @@ from homeassistant.helpers.selector import (
from .const import (
API_KEYS_URL,
BACKEND_MODELS,
CONF_API_KEY,
CONF_BACKEND,
CONF_LANGUAGE,
CONF_LATENCY,
CONF_NAME,
CONF_SELF_ONLY,
CONF_SORT_BY,
CONF_TITLE,
@@ -4,10 +4,17 @@ from typing import Literal
DOMAIN = "fish_audio"
# pylint: disable-next=home-assistant-duplicate-const
CONF_NAME: Literal["name"] = "name"
CONF_USER_ID: Literal["user_id"] = "user_id"
# pylint: disable-next=home-assistant-duplicate-const
CONF_API_KEY: Literal["api_key"] = "api_key"
CONF_VOICE_ID: Literal["voice_id"] = "voice_id"
CONF_BACKEND: Literal["backend"] = "backend"
CONF_SELF_ONLY: Literal["self_only"] = "self_only"
# pylint: disable-next=home-assistant-duplicate-const
CONF_LANGUAGE: Literal["language"] = "language"
CONF_SORT_BY: Literal["sort_by"] = "sort_by"
CONF_LATENCY: Literal["latency"] = "latency"
CONF_TITLE: Literal["title"] = "title"
+2
View File
@@ -13,6 +13,8 @@ ATTR_LAST_SAVED_AT: Final = "last_saved_at"
ATTR_DURATION: Final = "duration"
ATTR_DISTANCE: Final = "distance"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_ELEVATION: Final = "elevation"
ATTR_HEIGHT: Final = "height"
ATTR_WEIGHT: Final = "weight"
ATTR_BODY: Final = "body"
@@ -5,11 +5,11 @@ from contextlib import suppress
from ayla_iot_unofficial import new_ayla_api
from ayla_iot_unofficial.fujitsu_consts import FGLAIR_APP_CREDENTIALS
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME, Platform
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from .const import API_TIMEOUT, CONF_EUROPE, REGION_DEFAULT, REGION_EU
from .const import API_TIMEOUT, CONF_EUROPE, CONF_REGION, REGION_DEFAULT, REGION_EU
from .coordinator import FGLairConfigEntry, FGLairCoordinator
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR]
@@ -9,11 +9,11 @@ from ayla_iot_unofficial.fujitsu_consts import FGLAIR_APP_CREDENTIALS
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from .const import API_TIMEOUT, DOMAIN, REGION_DEFAULT, REGION_EU
from .const import API_TIMEOUT, CONF_REGION, DOMAIN, REGION_DEFAULT, REGION_EU
_LOGGER = logging.getLogger(__name__)
@@ -7,6 +7,8 @@ API_REFRESH = timedelta(minutes=5)
DOMAIN = "fujitsu_fglair"
# pylint: disable-next=home-assistant-duplicate-const
CONF_REGION = "region"
CONF_EUROPE = "is_europe"
REGION_EU = "eu"
REGION_DEFAULT = "default"
@@ -4,14 +4,24 @@ import logging
from bleak.backends.device import BLEDevice
from gardena_bluetooth.client import CachedConnection, Client
from gardena_bluetooth.const import ProductType
from gardena_bluetooth.const import AquaContour, DeviceConfiguration, DeviceInformation
from gardena_bluetooth.exceptions import (
CharacteristicNoAccess,
CharacteristicNotFound,
CommunicationFailure,
)
from gardena_bluetooth.parse import CharacteristicTime, ProductType
from gardena_bluetooth.scan import async_get_manufacturer_data
from homeassistant.components import bluetooth
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import (
DeviceUnavailable,
GardenaBluetoothConfigEntry,
@@ -29,6 +39,7 @@ PLATFORMS: list[Platform] = [
Platform.VALVE,
]
LOGGER = logging.getLogger(__name__)
TIMEOUT = 20.0
DISCONNECT_DELAY = 5
@@ -46,6 +57,15 @@ def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
return CachedConnection(DISCONNECT_DELAY, _device_lookup)
async def _update_timestamp(client: Client, characteristics: CharacteristicTime):
try:
await client.update_timestamp(characteristics, dt_util.now())
except CharacteristicNotFound:
pass
except CharacteristicNoAccess:
LOGGER.debug("No access to update internal time")
async def async_setup_entry(
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
) -> bool:
@@ -53,30 +73,49 @@ async def async_setup_entry(
address = entry.data[CONF_ADDRESS]
try:
mfg_data = await async_get_manufacturer_data({address})
except TimeoutError as exc:
raise ConfigEntryNotReady("Unable to find product type") from exc
mfg_data = await async_get_manufacturer_data({address})
product_type = mfg_data[address].product_type
if product_type is ProductType.UNKNOWN:
raise ConfigEntryNotReady("Unable to find product type")
client = Client(get_connection(hass, address), product_type)
try:
chars = await client.get_all_characteristics()
coordinator = GardenaBluetoothCoordinator(
hass,
entry,
LOGGER,
client,
address,
sw_version = await client.read_char(DeviceInformation.firmware_version, None)
manufacturer = await client.read_char(DeviceInformation.manufacturer_name, None)
model = await client.read_char(DeviceInformation.model_number, None)
name = entry.title
name = await client.read_char(DeviceConfiguration.custom_device_name, name)
name = await client.read_char(AquaContour.custom_device_name, name)
await _update_timestamp(client, DeviceConfiguration.unix_timestamp)
await _update_timestamp(client, AquaContour.unix_timestamp)
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
await client.disconnect()
raise ConfigEntryNotReady(
f"Unable to connect to device {address} due to {exception}"
) from exception
device = DeviceInfo(
identifiers={(DOMAIN, address)},
connections={(dr.CONNECTION_BLUETOOTH, address)},
name=name,
sw_version=sw_version,
manufacturer=manufacturer,
model=model,
)
await coordinator.async_config_entry_first_refresh()
coordinator = GardenaBluetoothCoordinator(
hass, entry, LOGGER, client, set(chars.keys()), device, address
)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await coordinator.async_request_refresh()
await coordinator.async_refresh()
return True
@@ -84,4 +123,7 @@ async def async_unload_entry(
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await entry.runtime_data.async_shutdown()
return unload_ok
@@ -4,28 +4,17 @@ from datetime import timedelta
import logging
from gardena_bluetooth.client import Client
from gardena_bluetooth.const import AquaContour, DeviceConfiguration, DeviceInformation
from gardena_bluetooth.exceptions import (
CharacteristicNoAccess,
CharacteristicNotFound,
CommunicationFailure,
GardenaBluetoothException,
)
from gardena_bluetooth.parse import (
Characteristic,
CharacteristicTime,
CharacteristicType,
)
from gardena_bluetooth.parse import Characteristic, CharacteristicType
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import DOMAIN
SCAN_INTERVAL = timedelta(seconds=60)
LOGGER = logging.getLogger(__name__)
@@ -48,6 +37,8 @@ class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]):
config_entry: GardenaBluetoothConfigEntry,
logger: logging.Logger,
client: Client,
characteristics: set[str],
device_info: DeviceInfo,
address: str,
) -> None:
"""Initialize global data updater."""
@@ -61,63 +52,14 @@ class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]):
self.address = address
self.data = {}
self.client = client
self.characteristics: set[str] = set()
self.device_info = DeviceInfo(
identifiers={(DOMAIN, address)},
connections={(dr.CONNECTION_BLUETOOTH, address)},
name=config_entry.title,
)
self.characteristics = characteristics
self.device_info = device_info
async def async_shutdown(self) -> None:
"""Shutdown coordinator and any connection."""
await super().async_shutdown()
await self.client.disconnect()
async def _async_setup(self) -> None:
"""Set up the coordinator and read initial device metadata."""
try:
chars = await self.client.get_all_characteristics()
sw_version = await self.client.read_char(
DeviceInformation.firmware_version, None
)
manufacturer = await self.client.read_char(
DeviceInformation.manufacturer_name, None
)
model = await self.client.read_char(DeviceInformation.model_number, None)
name = self.config_entry.title
name = await self.client.read_char(
DeviceConfiguration.custom_device_name, name
)
name = await self.client.read_char(AquaContour.custom_device_name, name)
await self._update_timestamp(DeviceConfiguration.unix_timestamp)
await self._update_timestamp(AquaContour.unix_timestamp)
self.characteristics = set(chars.keys())
self.device_info = DeviceInfo(
{
**self.device_info,
"name": name,
"sw_version": sw_version,
"manufacturer": manufacturer,
"model": model,
}
)
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
raise UpdateFailed(
f"Unable to set up Gardena Bluetooth device due to {exception}"
) from exception
async def _update_timestamp(self, char: CharacteristicTime) -> None:
try:
await self.client.update_timestamp(char, dt_util.now())
except CharacteristicNotFound:
pass
except CharacteristicNoAccess:
LOGGER.debug("No access to update internal time")
async def _async_update_data(self) -> dict[str, bytes]:
"""Poll the device."""
uuids: set[str] = {
+1 -1
View File
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["dacite", "gios"],
"quality_scale": "platinum",
"requirements": ["gios==7.1.0"]
"requirements": ["gios==7.0.0"]
}
@@ -8,5 +8,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.5"]
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.2"]
}
@@ -185,7 +185,7 @@ class AbstractConfig(ABC):
"""
@abstractmethod
def should_expose(self, entity_id: str) -> bool:
def should_expose(self, state) -> 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.entity_id}: {self.state.name}>"
return f"<GoogleEntity {self.state.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.entity_id)
return self.config.should_expose(self.state)
@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.entity_id}",
f"Unable to execute {command} for {self.state.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, split_entity_id
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -157,13 +157,17 @@ class GoogleConfig(AbstractConfig):
return None
def should_expose(self, entity_id: str) -> bool:
def should_expose(self, state) -> 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(entity_id)
registry_entry = entity_registry.async_get(state.entity_id)
if registry_entry:
auxiliary_entity = (
registry_entry.entity_category is not None
@@ -172,10 +176,10 @@ class GoogleConfig(AbstractConfig):
else:
auxiliary_entity = False
explicit_expose = self.entity_config.get(entity_id, {}).get(CONF_EXPOSE)
explicit_expose = self.entity_config.get(state.entity_id, {}).get(CONF_EXPOSE)
domain_exposed_by_default = (
expose_by_default and split_entity_id(entity_id)[0] in exposed_domains
expose_by_default and state.domain 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.entity_id)
and google_config.should_expose(new_state)
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, CONF_PROMPT
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import llm
from homeassistant.helpers.selector import (
@@ -38,6 +38,7 @@ 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, CONF_PROMPT
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.helpers import llm
LOGGER = logging.getLogger(__package__)
@@ -15,6 +15,8 @@ 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, CONF_PROMPT, MATCH_ALL
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .const import CONF_PROMPT, DOMAIN
from .entity import GoogleGenerativeAILLMBaseEntity
@@ -7,11 +7,16 @@ 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, DEFAULT_STT_PROMPT, LOGGER, RECOMMENDED_STT_MODEL
from .const import (
CONF_CHAT_MODEL,
CONF_PROMPT,
DEFAULT_STT_PROMPT,
LOGGER,
RECOMMENDED_STT_MODEL,
)
from .entity import GoogleGenerativeAILLMBaseEntity
from .helpers import convert_to_wav
@@ -84,9 +84,11 @@ async def _async_handle_upload(call: ServiceCall) -> ServiceResponse:
scopes = config_entry.data["token"]["scope"].split(" ")
if UPLOAD_SCOPE not in scopes:
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="missing_upload_permission",
translation_placeholders={"target": DOMAIN},
)
coordinator = config_entry.runtime_data
client_api = coordinator.client
@@ -12,6 +12,8 @@ DOMAIN = "google_travel_time"
ATTRIBUTION = "Powered by Google"
CONF_DESTINATION = "destination"
# pylint: disable-next=home-assistant-duplicate-const
CONF_OPTIONS = "options"
CONF_ORIGIN = "origin"
CONF_AVOID = "avoid"
CONF_UNITS = "units"
@@ -5,7 +5,6 @@ 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,
@@ -77,7 +76,6 @@ 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,7 +25,6 @@ Error handling pattern for reauth:
"""
from collections.abc import Mapping
import datetime
from json import JSONDecodeError
import logging
@@ -35,9 +34,7 @@ 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, device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -49,13 +46,10 @@ 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
@@ -247,6 +241,9 @@ 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]:
@@ -356,7 +353,7 @@ async def async_setup_entry(
hass, config_entry, device["deviceSn"], device["deviceType"], plant_id
)
for device in devices
if device["deviceType"] in SUPPORTED_DEVICE_TYPES
if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min", "sph"]
}
# Perform the first refresh for the total coordinator
@@ -375,96 +372,6 @@ 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
@@ -12,7 +12,6 @@ from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResu
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
CONF_REGION,
CONF_TOKEN,
CONF_URL,
CONF_USERNAME,
@@ -26,6 +25,7 @@ from .const import (
AUTH_PASSWORD,
CONF_AUTH_TYPE,
CONF_PLANT_ID,
CONF_REGION,
DEFAULT_URL,
DOMAIN,
ERROR_CANNOT_CONNECT,
@@ -1,12 +1,15 @@
"""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"
# pylint: disable-next=home-assistant-duplicate-const
CONF_REGION = "region"
# API key support
# pylint: disable-next=home-assistant-duplicate-const
CONF_API_KEY = "api_key"
# Auth types for config flow
AUTH_PASSWORD = "password"
@@ -66,9 +69,3 @@ 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,7 +6,6 @@ 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
@@ -28,7 +27,6 @@ from .const import (
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
V1_API_ERROR_NO_PRIVILEGE,
V1_DEVICE_TYPES,
)
from .models import GrowattRuntimeData
@@ -62,15 +60,6 @@ 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
@@ -98,58 +87,10 @@ 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)
@@ -191,16 +132,12 @@ 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.
# 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))
# Classic API: use plant_info as before
total_info = 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":
@@ -315,15 +252,6 @@ 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,10 +7,9 @@ from growattServer import GrowattV1ApiError
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
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
@@ -89,17 +88,6 @@ 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,
@@ -108,29 +96,15 @@ 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(
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,
GrowattNumber(device_coordinator, description)
for device_coordinator in runtime_data.devices.values()
if (
device_coordinator.device_type == "min"
and device_coordinator.api_version == "v1"
)
for description in MIN_NUMBER_TYPES
)
@@ -51,7 +51,7 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
dynamic-devices: todo
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: done
stale-devices: todo
# Platinum
async-dependency: todo
@@ -5,9 +5,8 @@ from datetime import date, datetime
import logging
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
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
@@ -25,46 +24,15 @@ 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
@@ -80,29 +48,38 @@ async def async_setup_entry(
for description in TOTAL_SENSOR_TYPES
)
# Add sensors for each existing device
for device_coordinator in data.devices.values():
entities.extend(_create_sensors_for_device(device_coordinator))
# 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
)
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,10 +8,9 @@ from growattServer import GrowattV1ApiError
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
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
@@ -46,17 +45,6 @@ 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,
@@ -65,29 +53,15 @@ 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(
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,
GrowattSwitch(device_coordinator, description)
for device_coordinator in runtime_data.devices.values()
if (
device_coordinator.device_type == "min"
and device_coordinator.api_version == "v1"
)
for description in MIN_SWITCH_TYPES
)
@@ -249,7 +249,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
if not errors and login is not None:
await self.async_set_unique_id(str(login.id))
self._abort_if_unique_id_mismatch()
return self.async_update_and_abort(
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_API_KEY: login.apiToken},
)
@@ -261,7 +261,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
}
)
if not errors and user is not None:
return self.async_update_and_abort(
return self.async_update_reload_and_abort(
reauth_entry, data_updates=user_input[SECTION_REAUTH_API_KEY]
)
else:
@@ -309,7 +309,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
}
)
if not errors and user is not None:
return self.async_update_and_abort(
return self.async_update_reload_and_abort(
reconf_entry,
data_updates={
CONF_API_KEY: user_input[CONF_API_KEY],
@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioharmony", "slixmpp"],
"requirements": ["aioharmony==1.0.8"],
"requirements": ["aioharmony==1.0.3"],
"ssdp": [
{
"deviceType": "urn:myharmony-com:device:harmony:1",
@@ -9,10 +9,10 @@ import voluptuous as vol
from yarl import URL
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MODEL
from homeassistant.const import CONF_HOST
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from .const import DEFAULT_PORT, DOMAIN, MODEL_INPUTS
from .const import CONF_MODEL, DEFAULT_PORT, DOMAIN, MODEL_INPUTS
_LOGGER = logging.getLogger(__name__)
+2
View File
@@ -3,6 +3,8 @@
DOMAIN = "hegel"
DEFAULT_PORT = 50001
# pylint: disable-next=home-assistant-duplicate-const
CONF_MODEL = "model"
CONF_MAX_VOLUME = "max_volume" # 1.0 means amp's internal max
HEARTBEAT_TIMEOUT_MINUTES = 3

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