Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot] 1148ed285d Update zizmor 2026-05-23 09:55:24 +00:00
411 changed files with 1679 additions and 18645 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 -1
View File
@@ -18,7 +18,7 @@ repos:
exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.24.1
rev: v1.25.2
hooks:
- id: zizmor
args:
-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."""
@@ -2,7 +2,8 @@
import axis
from axis.errors import Unauthorized
from axis.models.mqtt import ClientState, mqtt_json_to_event
from axis.interfaces.mqtt import mqtt_json_to_event
from axis.models.mqtt import ClientState
from axis.stream_manager import Signal, State
from homeassistant.components import mqtt
+1 -1
View File
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==72"],
"requirements": ["axis==71"],
"ssdp": [
{
"manufacturer": "AXIS"
@@ -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(
+5 -10
View File
@@ -11,7 +11,6 @@ from bluetooth_adapters import (
ADAPTER_CONNECTION_SLOTS,
ADAPTER_HW_VERSION,
ADAPTER_MANUFACTURER,
ADAPTER_PASSIVE_SCAN,
ADAPTER_SW_VERSION,
DEFAULT_ADDRESS,
DEFAULT_CONNECTION_SLOTS,
@@ -70,7 +69,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,
@@ -81,6 +79,7 @@ from .const import (
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS,
CONF_ADAPTER,
CONF_DETAILS,
CONF_PASSIVE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN,
@@ -94,7 +93,7 @@ from .manager import HomeAssistantBluetoothManager
from .match import BluetoothCallbackMatcher, IntegrationMatcher
from .models import BluetoothCallback, BluetoothChange
from .storage import BluetoothStorage
from .util import adapter_title, resolve_scanning_mode
from .util import adapter_title
if TYPE_CHECKING:
from homeassistant.helpers.typing import ConfigType
@@ -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",
@@ -389,15 +387,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady(
f"Bluetooth adapter {adapter} with address {address} not found"
)
passive = entry.options.get(CONF_PASSIVE)
adapters = await manager.async_get_bluetooth_adapters()
details = adapters[adapter]
mode = resolve_scanning_mode(entry.options)
# AUTO needs passive scanning support to flip on demand; without it
# the scanner would start passive on hardware that can't do passive.
if mode is BluetoothScanningMode.AUTO and not details.get(ADAPTER_PASSIVE_SCAN):
mode = BluetoothScanningMode.ACTIVE
mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE
scanner = HaScanner(mode, adapter, address)
scanner.async_setup()
details = adapters[adapter]
if entry.title == address:
hass.config_entries.async_update_entry(
entry, title=adapter_title(adapter, details)
@@ -68,20 +68,9 @@ class ActiveBluetoothProcessorCoordinator[_DataT](
| None = None,
poll_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None,
connectable: bool = True,
scan_interval: float | None = None,
scan_duration: float | None = None,
) -> None:
"""Initialize the processor."""
super().__init__(
hass,
logger,
address,
mode,
update_method,
connectable,
scan_interval,
scan_duration,
)
super().__init__(hass, logger, address, mode, update_method, connectable)
self._needs_poll_method = needs_poll_method
self._poll_method = poll_method
+6 -31
View File
@@ -130,26 +130,17 @@ def async_register_callback(
callback: BluetoothCallback,
match_dict: BluetoothCallbackMatcher | None,
mode: BluetoothScanningMode,
*,
scan_interval: float | None = None,
scan_duration: float | None = None,
) -> Callable[[], None]:
"""Register to receive a callback on bluetooth change.
When ``mode`` is not PASSIVE and ``match_dict["address"]`` is set,
the address is registered with habluetooth's active-scan scheduler
so AUTO-mode scanners flip ACTIVE on demand for that device.
``scan_interval`` / ``scan_duration`` default to habluetooth's
DEFAULT_ACTIVE_SCAN_* (5 minutes / 10 seconds) when not provided;
integrations that need a different cadence can pass explicit
values. Without an address in the matcher the active-scan request
is skipped; the callback itself still fires normally.
mode is currently not used as we only support active scanning.
Passive scanning will be available in the future. The flag
is required to be present to avoid a future breaking change
when we support passive scanning.
Returns a callback that can be used to cancel the registration.
"""
return _get_manager(hass).async_register_callback(
callback, match_dict, mode, scan_interval, scan_duration
)
return _get_manager(hass).async_register_callback(callback, match_dict)
async def async_process_advertisements(
@@ -170,7 +161,7 @@ async def async_process_advertisements(
done.set_result(service_info)
unload = _get_manager(hass).async_register_callback(
_async_discovered_device, match_dict, mode, scan_duration=timeout
_async_discovered_device, match_dict
)
try:
@@ -284,19 +275,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)
@@ -12,7 +12,7 @@ from bluetooth_adapters import (
adapter_model,
get_adapters,
)
from habluetooth import BluetoothScanningMode, get_manager
from habluetooth import get_manager
import voluptuous as vol
from homeassistant.components import onboarding
@@ -24,21 +24,14 @@ from homeassistant.config_entries import (
)
from homeassistant.core import callback
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaFlowFormStep,
SchemaOptionsFlowHandler,
)
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.helpers.typing import DiscoveryInfoType
from .const import (
CONF_ADAPTER,
CONF_DETAILS,
CONF_MODE,
CONF_PASSIVE,
CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID,
@@ -47,39 +40,15 @@ from .const import (
CONF_SOURCE_MODEL,
DOMAIN,
)
from .util import adapter_title, resolve_scanning_mode
from .util import adapter_title
_MODE_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[
BluetoothScanningMode.AUTO.value,
BluetoothScanningMode.ACTIVE.value,
BluetoothScanningMode.PASSIVE.value,
],
translation_key="mode",
mode=SelectSelectorMode.DROPDOWN,
)
OPTIONS_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSIVE, default=False): bool,
}
)
async def _options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
"""Build the options schema with the saved mode as the default."""
current = resolve_scanning_mode(handler.options).value
return vol.Schema({vol.Required(CONF_MODE, default=current): _MODE_SELECTOR})
async def _validate_options(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Mirror CONF_MODE into the legacy CONF_PASSIVE for downgrade safety."""
user_input[CONF_PASSIVE] = (
user_input[CONF_MODE] == BluetoothScanningMode.PASSIVE.value
)
return user_input
OPTIONS_FLOW = {
"init": SchemaFlowFormStep(_options_schema, validate_user_input=_validate_options),
"init": SchemaFlowFormStep(OPTIONS_SCHEMA),
}
@@ -7,21 +7,14 @@ from habluetooth import ( # noqa: F401
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
BluetoothScanningMode,
)
from homeassistant.const import CONF_MODE # noqa: F401
DOMAIN = "bluetooth"
CONF_ADAPTER = "adapter"
CONF_DETAILS = "details"
# CONF_PASSIVE is the legacy boolean option; we keep writing it alongside
# CONF_MODE so a downgrade to a pre-AUTO release reads a sensible value.
CONF_PASSIVE = "passive"
DEFAULT_MODE = BluetoothScanningMode.AUTO.value
# pylint: disable-next=home-assistant-duplicate-const
CONF_SOURCE: Final = "source"
+1 -20
View File
@@ -202,9 +202,6 @@ class HomeAssistantBluetoothManager(BluetoothManager):
self,
callback: BluetoothCallback,
matcher: BluetoothCallbackMatcher | None,
mode: BluetoothScanningMode = BluetoothScanningMode.ACTIVE,
scan_interval: float | None = None,
scan_duration: float | None = None,
) -> Callable[[], None]:
"""Register a callback."""
callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback)
@@ -219,31 +216,15 @@ class HomeAssistantBluetoothManager(BluetoothManager):
connectable = callback_matcher[CONNECTABLE]
self._callback_index.add_callback_matcher(callback_matcher)
# If the matcher targets a specific address and the caller
# didn't explicitly ask for PASSIVE, wire it into habluetooth's
# active-scan scheduler so AUTO-mode scanners flip ACTIVE on
# demand for this device. ``scan_interval``/``scan_duration``
# default to habluetooth's DEFAULT_ACTIVE_SCAN_* when None.
cancel_active_scan: Callable[[], None] | None = None
if (
mode is not BluetoothScanningMode.PASSIVE
and (address := callback_matcher.get(ADDRESS)) is not None
):
cancel_active_scan = self.async_register_active_scan(
address, scan_interval, scan_duration
)
def _async_remove_callback() -> None:
self._callback_index.remove_callback_matcher(callback_matcher)
if cancel_active_scan is not None:
cancel_active_scan()
# If we have history for the subscriber, we can trigger the callback
# immediately with the last packet so the subscriber can see the
# device.
history = self._connectable_history if connectable else self._all_history
service_infos: Iterable[BluetoothServiceInfoBleak] = []
if (address := callback_matcher.get(ADDRESS)) is not None:
if address := callback_matcher.get(ADDRESS):
if service_info := history.get(address):
service_infos = [service_info]
else:
@@ -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.11",
"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.4.0"
]
}
@@ -298,13 +298,9 @@ class PassiveBluetoothProcessorCoordinator[_DataT](BasePassiveBluetoothCoordinat
mode: BluetoothScanningMode,
update_method: Callable[[BluetoothServiceInfoBleak], _DataT],
connectable: bool = False,
scan_interval: float | None = None,
scan_duration: float | None = None,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass, logger, address, mode, connectable, scan_interval, scan_duration
)
super().__init__(hass, logger, address, mode, connectable)
self._processors: list[PassiveBluetoothDataProcessor[Any, _DataT]] = []
self._update_method = update_method
self.last_update_success = True
@@ -48,21 +48,9 @@
"step": {
"init": {
"data": {
"mode": "Scanning mode"
},
"data_description": {
"mode": "Auto is recommended for most setups. It saves battery on your Bluetooth devices while still catching new devices and updates quickly."
"passive": "Passive scanning"
}
}
}
},
"selector": {
"mode": {
"options": {
"active": "Active (uses more device battery, fastest updates)",
"auto": "Auto (recommended, saves device battery)",
"passive": "Passive (lowest device battery use, some details may be missing)"
}
}
}
}
@@ -30,8 +30,6 @@ class BasePassiveBluetoothCoordinator(ABC):
address: str,
mode: BluetoothScanningMode,
connectable: bool,
scan_interval: float | None = None,
scan_duration: float | None = None,
) -> None:
"""Initialize the coordinator."""
self.hass = hass
@@ -40,8 +38,6 @@ class BasePassiveBluetoothCoordinator(ABC):
self.connectable = connectable
self._on_stop: list[CALLBACK_TYPE] = []
self.mode = mode
self._scan_interval = scan_interval
self._scan_duration = scan_duration
self._last_unavailable_time = 0.0
self._last_name = address
# Subclasses are responsible for setting _available to True
@@ -96,8 +92,6 @@ class BasePassiveBluetoothCoordinator(ABC):
address=self.address, connectable=self.connectable
),
self.mode,
scan_interval=self._scan_interval,
scan_duration=self._scan_duration,
)
)
self._on_stop.append(
+1 -23
View File
@@ -1,9 +1,5 @@
"""The bluetooth integration utilities."""
from collections.abc import Mapping
import logging
from typing import Any
from bluetooth_adapters import (
ADAPTER_ADDRESS,
ADAPTER_MANUFACTURER,
@@ -13,32 +9,14 @@ from bluetooth_adapters import (
adapter_unique_name,
)
from bluetooth_data_tools import monotonic_time_coarse
from habluetooth import BluetoothScanningMode, get_manager
from habluetooth import get_manager
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from .const import CONF_MODE, CONF_PASSIVE, DEFAULT_MODE
from .models import BluetoothServiceInfoBleak
from .storage import BluetoothStorage
_LOGGER = logging.getLogger(__name__)
def resolve_scanning_mode(options: Mapping[str, Any]) -> BluetoothScanningMode:
"""Resolve CONF_MODE, falling back to legacy CONF_PASSIVE or DEFAULT_MODE."""
if (mode_value := options.get(CONF_MODE)) is not None:
try:
return BluetoothScanningMode(mode_value)
except TypeError, ValueError:
_LOGGER.warning("Unknown bluetooth scanning mode %r", mode_value)
return BluetoothScanningMode(DEFAULT_MODE)
if (legacy_passive := options.get(CONF_PASSIVE)) is True:
return BluetoothScanningMode.PASSIVE
if legacy_passive is False:
return BluetoothScanningMode.ACTIVE
return BluetoothScanningMode(DEFAULT_MODE)
class InvalidConfigEntryID(HomeAssistantError):
"""Invalid config entry id."""
@@ -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
)
+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
+2 -2
View File
@@ -15,8 +15,8 @@
],
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.2.7",
"aiodhcpwatcher==1.2.1",
"aiodiscover==3.2.3",
"cached-ipaddress==1.1.1"
"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": [
{
@@ -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 ---
@@ -4,10 +4,10 @@ from functools import partial
from aioesphomeapi import (
AlarmControlPanelCommand,
AlarmControlPanelEntityFeature as ESPHomeAlarmControlPanelEntityFeature,
AlarmControlPanelEntityState as ESPHomeAlarmControlPanelEntityState,
AlarmControlPanelInfo,
AlarmControlPanelState as ESPHomeAlarmControlPanelState,
APIIntEnum,
EntityInfo,
)
@@ -50,28 +50,16 @@ _ESPHOME_ACP_STATE_TO_HASS_STATE: EsphomeEnumMapper[
}
)
_FEATURES: dict[
ESPHomeAlarmControlPanelEntityFeature, AlarmControlPanelEntityFeature
] = {
ESPHomeAlarmControlPanelEntityFeature.ARM_HOME: (
AlarmControlPanelEntityFeature.ARM_HOME
),
ESPHomeAlarmControlPanelEntityFeature.ARM_AWAY: (
AlarmControlPanelEntityFeature.ARM_AWAY
),
ESPHomeAlarmControlPanelEntityFeature.ARM_NIGHT: (
AlarmControlPanelEntityFeature.ARM_NIGHT
),
ESPHomeAlarmControlPanelEntityFeature.TRIGGER: (
AlarmControlPanelEntityFeature.TRIGGER
),
ESPHomeAlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS: (
AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
),
ESPHomeAlarmControlPanelEntityFeature.ARM_VACATION: (
AlarmControlPanelEntityFeature.ARM_VACATION
),
}
class EspHomeACPFeatures(APIIntEnum):
"""ESPHome AlarmControlPanel feature numbers."""
ARM_HOME = 1
ARM_AWAY = 2
ARM_NIGHT = 4
TRIGGER = 8
ARM_CUSTOM_BYPASS = 16
ARM_VACATION = 32
class EsphomeAlarmControlPanel(
@@ -85,14 +73,20 @@ class EsphomeAlarmControlPanel(
"""Set attrs from static info."""
super()._on_static_info_update(static_info)
static_info = self._static_info
esp_flags = ESPHomeAlarmControlPanelEntityFeature(
static_info.supported_features
)
flags = AlarmControlPanelEntityFeature(0)
for esp_flag in esp_flags:
if (flag := _FEATURES.get(esp_flag)) is not None:
flags |= flag
self._attr_supported_features = flags
feature = 0
if static_info.supported_features & EspHomeACPFeatures.ARM_HOME:
feature |= AlarmControlPanelEntityFeature.ARM_HOME
if static_info.supported_features & EspHomeACPFeatures.ARM_AWAY:
feature |= AlarmControlPanelEntityFeature.ARM_AWAY
if static_info.supported_features & EspHomeACPFeatures.ARM_NIGHT:
feature |= AlarmControlPanelEntityFeature.ARM_NIGHT
if static_info.supported_features & EspHomeACPFeatures.TRIGGER:
feature |= AlarmControlPanelEntityFeature.TRIGGER
if static_info.supported_features & EspHomeACPFeatures.ARM_CUSTOM_BYPASS:
feature |= AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
if static_info.supported_features & EspHomeACPFeatures.ARM_VACATION:
feature |= AlarmControlPanelEntityFeature.ARM_VACATION
self._attr_supported_features = AlarmControlPanelEntityFeature(feature)
self._attr_code_format = (
CodeFormat.NUMBER if static_info.requires_code else None
)
+18 -94
View File
@@ -1,33 +1,16 @@
"""Bluetooth support for esphome."""
from functools import partial
import logging
from typing import TYPE_CHECKING
from aioesphomeapi import (
APIClient,
APIVersion,
BluetoothProxyFeature,
BluetoothScannerMode,
BluetoothScannerStateResponse,
DeviceInfo,
)
from aioesphomeapi import APIClient, DeviceInfo
from bleak_esphome import connect_scanner
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
async_register_scanner,
)
from homeassistant.components.bluetooth import async_register_scanner
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
from .const import CONF_BLUETOOTH_SCANNING_MODE, DEFAULT_BLUETOOTH_SCANNING_MODE, DOMAIN
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
if TYPE_CHECKING:
from bleak_esphome.backend.scanner import ESPHomeScanner
_LOGGER = logging.getLogger(__name__)
_VALID_SCANNING_MODES = {mode.value for mode in BluetoothScanningMode}
from .const import DOMAIN
from .entry_data import RuntimeEntryData
@hass_callback
@@ -40,7 +23,6 @@ def _async_unload(unload_callbacks: list[CALLBACK_TYPE]) -> None:
@hass_callback
def async_connect_scanner(
hass: HomeAssistant,
entry: ESPHomeConfigEntry,
entry_data: RuntimeEntryData,
cli: APIClient,
device_info: DeviceInfo,
@@ -53,75 +35,17 @@ def async_connect_scanner(
scanner = client_data.scanner
if TYPE_CHECKING:
assert scanner is not None
api_version = cli.api_version or APIVersion()
feature_flags = device_info.bluetooth_proxy_feature_flags_compat(api_version)
state_and_mode = bool(feature_flags & BluetoothProxyFeature.FEATURE_STATE_AND_MODE)
# Pin mode before async_register_scanner so habluetooth spawns the AUTO worker.
deferred_migration: CALLBACK_TYPE | None = None
if state_and_mode:
deferred_migration = _async_apply_scanning_mode(hass, entry, scanner, cli)
callbacks: list[CALLBACK_TYPE] = [
async_register_scanner(
hass,
scanner,
source_domain=DOMAIN,
source_model=device_info.model,
source_config_entry_id=entry_data.entry_id,
source_device_id=device_id,
),
scanner.async_setup(),
]
if deferred_migration is not None:
callbacks.append(deferred_migration)
return partial(_async_unload, callbacks)
@hass_callback
def _async_apply_scanning_mode(
hass: HomeAssistant,
entry: ESPHomeConfigEntry,
scanner: ESPHomeScanner,
cli: APIClient,
) -> CALLBACK_TYPE | None:
"""Apply saved scanning mode synchronously; migrate from configured_mode later."""
saved = entry.options.get(CONF_BLUETOOTH_SCANNING_MODE)
if saved is not None and saved not in _VALID_SCANNING_MODES:
_LOGGER.warning("%s: unknown scanning mode %r", entry.title, saved)
saved = None
initial_value = saved if saved is not None else DEFAULT_BLUETOOTH_SCANNING_MODE
scanner.async_set_scanning_mode(BluetoothScanningMode(initial_value))
if saved is not None:
return None
unsub_holder: list[CALLBACK_TYPE] = []
@hass_callback
def _migrate(state: BluetoothScannerStateResponse) -> None:
# proto3 unset enums decode to None; wait for a real value.
if (configured_pb := state.configured_mode) is None:
return
if unsub_holder:
unsub_holder.pop()()
if configured_pb is BluetoothScannerMode.PASSIVE:
new_mode = BluetoothScanningMode.PASSIVE
else:
new_mode = BluetoothScanningMode(DEFAULT_BLUETOOTH_SCANNING_MODE)
hass.config_entries.async_update_entry(
entry,
options={
**entry.options,
CONF_BLUETOOTH_SCANNING_MODE: new_mode.value,
},
)
# AUTO -> AUTO is already pinned; only re-apply on a downgrade.
if new_mode is not BluetoothScanningMode(DEFAULT_BLUETOOTH_SCANNING_MODE):
scanner.async_set_scanning_mode(new_mode)
unsub_holder.append(cli.subscribe_bluetooth_scanner_state(_migrate))
@hass_callback
def _unsubscribe() -> None:
if unsub_holder:
unsub_holder.pop()()
return _unsubscribe
return partial(
_async_unload,
[
async_register_scanner(
hass,
scanner,
source_domain=DOMAIN,
source_model=device_info.model,
source_config_entry_id=entry_data.entry_id,
source_device_id=device_id,
),
scanner.async_setup(),
],
)
+12 -59
View File
@@ -9,7 +9,6 @@ from typing import Any, cast
from aioesphomeapi import (
APIClient,
APIConnectionError,
BluetoothProxyFeature,
DeviceInfo,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
@@ -21,7 +20,6 @@ import aiohttp
import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.config_entries import (
SOURCE_ESPHOME,
SOURCE_IGNORE,
@@ -40,11 +38,6 @@ from homeassistant.data_entry_flow import AbortFlow, FlowResultType
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.importlib import async_import_module
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
@@ -54,12 +47,10 @@ from homeassistant.util.json import json_loads_object
from .const import (
CONF_ALLOW_SERVICE_CALLS,
CONF_BLUETOOTH_SCANNING_MODE,
CONF_DEVICE_NAME,
CONF_NOISE_PSK,
CONF_SUBSCRIBE_LOGS,
DEFAULT_ALLOW_SERVICE_CALLS,
DEFAULT_BLUETOOTH_SCANNING_MODE,
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
DEFAULT_PORT,
DOMAIN,
@@ -77,18 +68,6 @@ _LOGGER = logging.getLogger(__name__)
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
DEFAULT_NAME = "ESPHome"
_BLUETOOTH_SCANNING_MODE_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[
BluetoothScanningMode.AUTO.value,
BluetoothScanningMode.ACTIVE.value,
BluetoothScanningMode.PASSIVE.value,
],
translation_key="bluetooth_scanning_mode",
mode=SelectSelectorMode.DROPDOWN,
)
)
class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a esphome config flow."""
@@ -957,44 +936,18 @@ class OptionsFlowHandler(OptionsFlowWithReload):
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
options = self.config_entry.options
schema: dict[Any, Any] = {
vol.Required(
CONF_ALLOW_SERVICE_CALLS,
default=options.get(
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
),
): bool,
vol.Required(
CONF_SUBSCRIBE_LOGS,
default=options.get(CONF_SUBSCRIBE_LOGS, False),
): bool,
}
if _entry_has_bluetooth_scanner(self.config_entry):
schema[
data_schema = vol.Schema(
{
vol.Required(
CONF_BLUETOOTH_SCANNING_MODE,
default=options.get(
CONF_BLUETOOTH_SCANNING_MODE, DEFAULT_BLUETOOTH_SCANNING_MODE
CONF_ALLOW_SERVICE_CALLS,
default=self.config_entry.options.get(
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
),
)
] = _BLUETOOTH_SCANNING_MODE_SELECTOR
return self.async_show_form(step_id="init", data_schema=vol.Schema(schema))
@callback
def _entry_has_bluetooth_scanner(entry: ESPHomeConfigEntry) -> bool:
"""Return True if the entry exposes a bluetooth proxy scanner or has one saved."""
# Keep showing the option if it was previously saved, even when the
# device is offline or stops advertising the feature flag, so the
# saved value isn't silently dropped on the next options save.
if CONF_BLUETOOTH_SCANNING_MODE in entry.options:
return True
if entry.state is ConfigEntryState.LOADED and (
device_info := entry.runtime_data.device_info
):
flags = device_info.bluetooth_proxy_feature_flags_compat(
entry.runtime_data.api_version
): bool,
vol.Required(
CONF_SUBSCRIBE_LOGS,
default=self.config_entry.options.get(CONF_SUBSCRIBE_LOGS, False),
): bool,
}
)
return bool(flags & BluetoothProxyFeature.FEATURE_STATE_AND_MODE)
return False
return self.async_show_form(step_id="init", data_schema=data_schema)
+1 -4
View File
@@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Final
from awesomeversion import AwesomeVersion
from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
@@ -19,15 +18,13 @@ CONF_SUBSCRIBE_LOGS = "subscribe_logs"
CONF_DEVICE_NAME = "device_name"
CONF_NOISE_PSK = "noise_psk"
CONF_BLUETOOTH_MAC_ADDRESS = "bluetooth_mac_address"
CONF_BLUETOOTH_SCANNING_MODE = "bluetooth_scanning_mode"
DEFAULT_ALLOW_SERVICE_CALLS = True
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
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),
)
+1 -1
View File
@@ -669,7 +669,7 @@ class ESPHomeManager:
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
entry_data.disconnect_callbacks.add(
async_connect_scanner(
hass, self.entry, entry_data, cli, device_info, self.device_id
hass, entry_data, cli, device_info, self.device_id
)
)
else:
@@ -17,9 +17,9 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==45.2.2",
"aioesphomeapi==45.1.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.9.1"
"bleak-esphome==3.8.1"
],
"zeroconf": ["_esphomelib._tcp.local."]
}
@@ -209,24 +209,13 @@
"init": {
"data": {
"allow_service_calls": "Allow the device to perform Home Assistant actions.",
"bluetooth_scanning_mode": "Bluetooth scanning mode",
"subscribe_logs": "Subscribe to logs from the device."
},
"data_description": {
"allow_service_calls": "When enabled, ESPHome devices can perform Home Assistant actions or send events. Only enable this if you trust the device.",
"bluetooth_scanning_mode": "Auto is recommended for most setups. It saves battery on your Bluetooth devices while still catching new devices and updates quickly.",
"subscribe_logs": "When enabled, the device will send logs to Home Assistant and you can view them in the logs panel."
}
}
}
},
"selector": {
"bluetooth_scanning_mode": {
"options": {
"active": "Active (uses more device battery, fastest updates)",
"auto": "Auto (recommended, saves device battery)",
"passive": "Passive (lowest device battery use, some details may be missing)"
}
}
}
}
@@ -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"
-2
View File
@@ -53,8 +53,6 @@ class FritzDeviceBase(CoordinatorEntity[AvmWrapper]):
class FritzBoxBaseEntity:
"""Fritz host entity base class."""
_attr_has_entity_name = True
def __init__(self, avm_wrapper: AvmWrapper, device_name: str) -> None:
"""Init device info class."""
self._avm_wrapper = avm_wrapper
+1
View File
@@ -76,6 +76,7 @@ class FritzGuestWifiQRImage(FritzBoxBaseEntity, ImageEntity):
_attr_content_type = "image/png"
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_has_entity_name = True
_attr_should_poll = True
def __init__(
+1
View File
@@ -170,6 +170,7 @@ class SwitchInfo(TypedDict):
"""FRITZ!Box switch info class."""
description: str
friendly_name: str
icon: str
type: str
callback_update: Callable
+52 -17
View File
@@ -380,18 +380,44 @@ class FritzBoxBaseSwitch(FritzBoxBaseEntity, SwitchEntity):
"""Init Fritzbox base switch."""
super().__init__(avm_wrapper, device_friendly_name)
description = switch_info["description"]
self._description = switch_info["description"]
self._friendly_name = switch_info["friendly_name"]
self._icon = switch_info["icon"]
self._type = switch_info["type"]
self._update = switch_info["callback_update"]
self._switch = switch_info["callback_switch"]
self._attr_icon = switch_info["icon"]
self._attr_is_on = switch_info["init_state"]
self._attr_name = description
self._attr_unique_id = f"{self._avm_wrapper.unique_id}-{slugify(description)}"
self._attr_extra_state_attributes: dict[str, Any | None] = {}
self._attr_available = True
self._name = f"{self._friendly_name} {self._description}"
self._unique_id = f"{self._avm_wrapper.unique_id}-{slugify(self._description)}"
self._attributes: dict[str, str | None] = {}
self._is_available = True
@property
def name(self) -> str:
"""Return name."""
return self._name
@property
def icon(self) -> str:
"""Return icon."""
return self._icon
@property
def unique_id(self) -> str:
"""Return unique id."""
return self._unique_id
@property
def available(self) -> bool:
"""Return availability."""
return self._is_available
@property
def extra_state_attributes(self) -> dict[str, str | None]:
"""Return device attributes."""
return self._attributes
async def async_update(self) -> None:
"""Update data."""
@@ -412,6 +438,7 @@ class FritzBoxBaseSwitch(FritzBoxBaseEntity, SwitchEntity):
self._attr_is_on = turn_on
# pylint: disable-next=home-assistant-missing-has-entity-name
class FritzBoxPortSwitch(FritzBoxBaseSwitch):
"""Defines a FRITZ!Box Tools PortForward switch."""
@@ -425,6 +452,9 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch):
connection_type: str,
) -> None:
"""Init Fritzbox port switch."""
self._avm_wrapper = avm_wrapper
self._attributes = {}
self.connection_type = connection_type
# dict in the format as it comes from fritzconnection,
# eg: {"NewRemoteHost": "0.0.0.0", "NewExternalPort": 22, ...}
@@ -434,6 +464,7 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch):
switch_info = SwitchInfo(
description=f"Port forward {port_name}",
friendly_name=device_friendly_name,
icon="mdi:check-network",
type=SWITCH_TYPE_PORTFORWARD,
callback_update=self._async_fetch_update,
@@ -452,11 +483,11 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch):
"Specific %s response: %s", SWITCH_TYPE_PORTFORWARD, self.port_mapping
)
if not self.port_mapping:
self._attr_available = False
self._is_available = False
return
self._attr_is_on = self.port_mapping["NewEnabled"] is True
self._attr_available = True
self._is_available = True
attributes_dict = {
"NewInternalClient": "internal_ip",
@@ -467,7 +498,7 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch):
}
for key, attr in attributes_dict.items():
self._attr_extra_state_attributes[attr] = self.port_mapping[key]
self._attributes[attr] = self.port_mapping[key]
async def _async_switch_on_off_executor(self, turn_on: bool) -> None:
self.port_mapping["NewEnabled"] = "1" if turn_on else "0"
@@ -574,6 +605,7 @@ class FritzBoxProfileSwitch(FritzBoxBaseCoordinatorSwitch):
self.async_write_ha_state()
# pylint: disable-next=home-assistant-missing-has-entity-name
class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
"""Defines a FRITZ!Box Tools Wifi switch."""
@@ -585,8 +617,10 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
network_data: dict[str, Any],
) -> None:
"""Init Fritz Wifi switch."""
self._avm_wrapper = avm_wrapper
self._wifi_info = network_data
self._attributes = {}
self._attr_entity_category = EntityCategory.CONFIG
self._attr_entity_registry_enabled_default = (
avm_wrapper.mesh_role is not MeshRoles.SLAVE
@@ -598,13 +632,14 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
switch_info = SwitchInfo(
description=description,
friendly_name=device_friendly_name,
icon="mdi:wifi",
type=SWITCH_TYPE_WIFINETWORK,
callback_update=self._async_fetch_update,
callback_switch=self._async_switch_on_off_executor,
init_state=network_data["NewEnable"],
)
super().__init__(avm_wrapper, device_friendly_name, switch_info)
super().__init__(self._avm_wrapper, device_friendly_name, switch_info)
async def _async_fetch_update(self) -> None:
"""Fetch updates."""
@@ -617,16 +652,16 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
)
if not wifi_info:
self._attr_available = False
self._is_available = False
return
self._attr_is_on = wifi_info["NewEnable"] is True
self._attr_available = True
self._is_available = True
std = wifi_info["NewStandard"]
self._attr_extra_state_attributes["standard"] = std or None
self._attr_extra_state_attributes["bssid"] = wifi_info["NewBSSID"]
self._attr_extra_state_attributes["mac_address_control"] = wifi_info[
self._attributes["standard"] = std or None
self._attributes["bssid"] = wifi_info["NewBSSID"]
self._attributes["mac_address_control"] = wifi_info[
"NewMACAddressControlEnabled"
]
self._wifi_info = wifi_info
@@ -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"
+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.4"]
"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,

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