Compare commits

..

41 Commits

Author SHA1 Message Date
J. Nick Koston bf48806ee9 Merge remote-tracking branch 'upstream/cache-split-tests' into cache-split-tests 2026-05-22 14:48:53 -05:00
J. Nick Koston 2c25c5ad26 another round of copilot comments 2026-05-22 14:48:31 -05:00
J. Nick Koston ff1177dde4 Merge branch 'dev' into cache-split-tests 2026-05-22 14:33:56 -05:00
J. Nick Koston 1cc91cd3b6 another round of copilot 2026-05-22 13:32:08 -05:00
J. Nick Koston ecac38a359 Merge branch 'dev' into cache-split-tests 2026-05-22 13:20:15 -05:00
J. Nick Koston 8301addc94 bot comments 2026-05-22 13:06:55 -05:00
J. Nick Koston 77bc932cf0 will copilot ever end 2026-05-22 12:57:17 -05:00
J. Nick Koston 11903ac62e dry 2026-05-22 12:54:51 -05:00
J. Nick Koston 878761cb41 preen 2026-05-22 12:49:43 -05:00
J. Nick Koston d7bf7df59f preen 2026-05-22 12:49:16 -05:00
J. Nick Koston e5890172a0 preen 2026-05-22 12:48:39 -05:00
J. Nick Koston e9a58cdd20 restore 2026-05-22 12:47:15 -05:00
J. Nick Koston cab7c41a7f more cleanups 2026-05-22 12:37:54 -05:00
J. Nick Koston 277a2d847a more cleanups 2026-05-22 12:30:30 -05:00
J. Nick Koston 7835a4992a simplify 2026-05-22 11:51:05 -05:00
J. Nick Koston 9dc37a2f46 Merge remote-tracking branch 'upstream/cache-split-tests' into cache-split-tests 2026-05-22 11:43:16 -05:00
J. Nick Koston 7534c438c1 fix cache bust 2026-05-22 11:42:22 -05:00
J. Nick Koston 69efa8ee1a Merge branch 'dev' into cache-split-tests 2026-05-22 11:28:43 -05:00
J. Nick Koston 305b5d6e00 preen 2026-05-22 11:00:46 -05:00
J. Nick Koston d94226260b handle bot review comments 2026-05-22 10:40:23 -05:00
J. Nick Koston ecc8e52f3e make bot happy 2026-05-22 10:33:22 -05:00
J. Nick Koston 5771b0c86c Merge remote-tracking branch 'refs/remotes/upstream/cache-split-tests' into cache-split-tests 2026-05-22 10:31:11 -05:00
J. Nick Koston 3e289da366 drop bad copilot suggest 2026-05-22 10:31:00 -05:00
J. Nick Koston 944fb1ef67 Merge branch 'dev' into cache-split-tests 2026-05-22 10:17:10 -05:00
J. Nick Koston 1b6e9f5094 trim 2026-05-22 09:53:51 -05:00
J. Nick Koston b2257caeb7 touch ups 2026-05-22 09:11:01 -05:00
J. Nick Koston 0ec0ea30ac single pass 2026-05-22 09:06:15 -05:00
J. Nick Koston 584b32c8b3 address copilot, cleanups 2026-05-22 09:01:39 -05:00
J. Nick Koston 4033a8b83a Apply suggestions from code review
Co-authored-by: J. Nick Koston <nick+github@koston.org>
2026-05-22 08:53:47 -05:00
J. Nick Koston add8a5f799 Merge branch 'dev' into cache-split-tests 2026-05-22 08:53:27 -05:00
J. Nick Koston 7c137b5c73 cleanup 2026-05-22 08:34:32 -05:00
J. Nick Koston 4a6c5b5a22 cleanups 2026-05-22 07:46:31 -05:00
J. Nick Koston 1009ce4180 Merge branch 'dev' into cache-split-tests 2026-05-21 23:09:44 -05:00
J. Nick Koston 22fb68b7a1 Revert "DNM: test cache, touch cloud manifest only"
This reverts commit a8bc244a7a.
2026-05-21 16:53:19 -05:00
J. Nick Koston 81e06539e6 Revert "DNM: test cache bust, touch cloud conftest"
This reverts commit 7c18b67b2e.
2026-05-21 16:53:19 -05:00
J. Nick Koston 7c18b67b2e DNM: test cache bust, touch cloud conftest 2026-05-21 16:48:05 -05:00
J. Nick Koston a8bc244a7a DNM: test cache, touch cloud manifest only 2026-05-21 16:44:43 -05:00
J. Nick Koston 5975f4b179 Skip cache walking when --cache is not passed
Address Copilot review feedback on the cache PR:

* Split collect_tests into _collect_tests_uncached (the original
  directory-based pre-cache flow) and _collect_tests_cached (walks
  the tree to build per-file hashes).  Without --cache we now skip
  the walk + per-file hash entirely.
* A single-file root has no ancestor conftests to walk, so the
  conftest_hash would always be empty and stale counts could survive
  a real conftest change; bypass the cache for the file-root case.
* Save the cache file with explicit utf-8 encoding and
  ensure_ascii=False.
2026-05-21 16:08:44 -05:00
J. Nick Koston 9ed16b63a3 Cache per-file test counts in split_tests
Persist the result of pytest --collect-only between CI runs as a JSON
file keyed by content hash, so unchanged test files are served from
cache and only edited or new files are re-collected.  The cache is
self-healing:

* Missing, corrupt, or wrong-version files fall back to a full collect.
* Any conftest.py change anywhere under the test root invalidates the
  whole cache, so fixture parametrization shifts cannot silently skew
  counts.
* Files pytest returns nothing for (helper modules named test_*.py with
  no test functions) are cached as zero so they don't get re-collected
  forever.

Walking is done once with os.walk (~2x faster than Path.rglob) and
collects test files plus conftests in a single pass.  When the cache
is fully cold we feed pytest top-level directories rather than
thousands of file paths so cold runs stay as fast as before the cache
landed.

Wire the new --cache flag through the prepare-pytest-full job and back
the cache file with actions/cache so PRs can pick up the latest dev
snapshot via restore-keys.  Local timings: cold 11s, warm with no diff
0.4s, warm with one file edited 2.3s.
2026-05-21 15:56:08 -05:00
J. Nick Koston 8dadaa2f9e Filter fan-out children and fail fast on empty batch list
Only pass directories and test_*.py files to pytest --collect-only so
helpers like tests/components/conftest.py and tests/components/common.py
are not treated as explicit collection targets, and bail out with a
clear error if no eligible paths are found instead of running pytest
with no arguments.
2026-05-21 15:17:42 -05:00
J. Nick Koston 4f98c71586 Run pytest --collect-only in parallel batches in split_tests
cProfile showed 99.6% of split_tests.py wall time was spent in the
single pytest --collect-only subprocess.  Fan out the collection across
``os.cpu_count()`` workers; round-robin chunking keeps each batch
roughly equal, and tests/components is expanded one level deeper so
the ~1000 integration subdirectories distribute evenly.  Local wall
time dropped from ~132s to ~11s on an 18-core box.  Bucket output is
unchanged because we still parse the same pytest -qq output, just
aggregated from multiple invocations.
2026-05-21 15:10:01 -05:00
408 changed files with 8211 additions and 17717 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"
}
}
}
+41 -4
View File
@@ -917,12 +917,49 @@ jobs:
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore pytest test counts cache
id: cache-pytest-counts
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: pytest_test_counts.json
# Primary key is a sentinel; restore-keys pick the most recent
# prefix match since the real (content-addressed) key isn't
# known until split_tests.py runs below.
key: >-
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}-restore-sentinel
restore-keys: |
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }}-
- name: Run split_tests.py
env:
TEST_GROUP_COUNT: ${{ needs.info.outputs.test_group_count }}
run: |
. venv/bin/activate
python -m script.split_tests ${TEST_GROUP_COUNT} tests
python -m script.split_tests \
--cache pytest_test_counts.json \
${TEST_GROUP_COUNT} tests
- name: Hash pytest test counts cache
id: cache-pytest-counts-hash
run: |
echo "hash=$(sha256sum pytest_test_counts.json | cut -d' ' -f1)" \
>> "$GITHUB_OUTPUT"
- name: Save pytest test counts cache
# Content-addressed key: identical content reuses the same entry.
# Skip the save when the restore already matched that hash.
if: >-
!endsWith(
steps.cache-pytest-counts.outputs.cache-matched-key,
steps.cache-pytest-counts-hash.outputs.hash
)
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: pytest_test_counts.json
key: >-
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}-${{
steps.cache-pytest-counts-hash.outputs.hash }}
- name: Upload pytest_buckets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@@ -1421,7 +1458,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 +1629,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 +1657,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
+4 -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
@@ -945,6 +945,8 @@ CLAUDE.md @home-assistant/core
/tests/components/knx/ @Julius2342 @farmio @marvin-w
/homeassistant/components/kodi/ @OnFreund
/tests/components/kodi/ @OnFreund
/homeassistant/components/konnected/ @heythisisnate
/tests/components/konnected/ @heythisisnate
/homeassistant/components/kostal_plenticore/ @stegm
/tests/components/kostal_plenticore/ @stegm
/homeassistant/components/kraken/ @eifinger
@@ -1930,8 +1932,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
@@ -39,6 +39,7 @@ from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_SUPPORTED_FEATURES,
ATTR_UNIT_OF_MEASUREMENT,
CLOUD_NEVER_EXPOSED_ENTITIES,
CONF_DESCRIPTION,
CONF_NAME,
UnitOfTemperature,
@@ -372,6 +373,9 @@ def async_get_entities(
"""Return all entities that are supported by Alexa."""
entities: list[AlexaEntity] = []
for state in hass.states.async_all():
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
continue
if state.domain not in ENTITY_ADAPTERS:
continue
@@ -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:
@@ -16,13 +16,6 @@ from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceIn
from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN
STEP_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
}
)
class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle config flow."""
@@ -38,22 +31,13 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(uuid)
self._abort_if_unique_id_configured({CONF_HOST: host, CONF_PORT: port})
async def _async_try_connect(self, host: str, port: int) -> dict[str, str]:
"""Verify the device is reachable; return errors keyed by reason."""
async def _async_try_connect(self, host: str, port: int) -> None:
"""Verify the device is reachable."""
client = Client(host, port)
try:
await client.start()
except socket.gaierror:
return {"base": "invalid_host"}
except TimeoutError:
return {"base": "timeout_connect"}
except ConnectionRefusedError:
return {"base": "connection_refused"}
except ConnectionFailed, OSError:
return {"base": "cannot_connect"}
finally:
await client.stop()
return {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -69,10 +53,19 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
user_input[CONF_HOST], user_input[CONF_PORT], uuid
)
errors = await self._async_try_connect(
user_input[CONF_HOST], user_input[CONF_PORT]
)
if not errors:
try:
await self._async_try_connect(
user_input[CONF_HOST], user_input[CONF_PORT]
)
except socket.gaierror:
errors["base"] = "invalid_host"
except TimeoutError:
errors["base"] = "timeout_connect"
except ConnectionRefusedError:
errors["base"] = "connection_refused"
except ConnectionFailed, OSError:
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title=f"{DEFAULT_NAME} ({user_input[CONF_HOST]})",
data={
@@ -81,46 +74,16 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
},
)
schema = STEP_DATA_SCHEMA
fields = {
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
}
schema = vol.Schema(fields)
if user_input is not None:
schema = self.add_suggested_values_to_schema(schema, user_input)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of an existing entry."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
uuid = await get_uniqueid_from_host(
async_get_clientsession(self.hass), user_input[CONF_HOST]
)
if uuid:
await self.async_set_unique_id(uuid)
self._abort_if_unique_id_mismatch()
errors = await self._async_try_connect(
user_input[CONF_HOST], user_input[CONF_PORT]
)
if not errors:
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={
CONF_HOST: user_input[CONF_HOST],
CONF_PORT: user_input[CONF_PORT],
},
)
schema = self.add_suggested_values_to_schema(
STEP_DATA_SCHEMA, user_input or reconfigure_entry.data
)
return self.async_show_form(
step_id="reconfigure", data_schema=schema, errors=errors
)
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -150,7 +113,9 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
await self._async_set_unique_id_and_update(host, port, uuid)
if await self._async_try_connect(host, port):
try:
await self._async_try_connect(host, port)
except ConnectionFailed, OSError:
return self.async_abort(reason="cannot_connect")
self.host = host
@@ -3,9 +3,7 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -18,13 +16,6 @@
"confirm": {
"description": "Do you want to add Arcam FMJ on `{host}` to Home Assistant?"
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"description": "[%key:component::arcam_fmj::config::step::user::description%]"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
@@ -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
+17 -6
View File
@@ -8,6 +8,7 @@ from urllib.parse import urlsplit
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_IGNORE,
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigEntry,
@@ -138,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
@@ -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"
@@ -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",
+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.2.0",
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.9",
"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.2.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"
@@ -32,6 +32,7 @@ from homeassistant.components.homeassistant.exposed_entities import (
async_should_expose,
)
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import Event, HomeAssistant, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, start
@@ -274,6 +275,9 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
def _should_expose_legacy(self, entity_id: str) -> bool:
"""If an entity should be exposed."""
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
entity_configs = self._prefs.alexa_entity_configs
entity_config = entity_configs.get(entity_id, {})
entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE)
@@ -304,6 +308,8 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
"""If an entity should be exposed."""
entity_filter: EntityFilter = self._config[CONF_FILTER]
if not entity_filter.empty_filter:
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
return entity_filter(entity_id)
return async_should_expose(self.hass, CLOUD_ALEXA, entity_id)
@@ -22,6 +22,7 @@ from homeassistant.components.homeassistant.exposed_entities import (
async_should_expose,
)
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import (
CoreState,
Event,
@@ -275,16 +276,15 @@ 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."""
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
entity_configs = self._prefs.google_entity_configs
entity_config = entity_configs.get(entity_id, {})
entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE)
@@ -312,6 +312,16 @@ 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:
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
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 +473,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 +496,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"]
)
+5 -2
View File
@@ -29,6 +29,7 @@ from homeassistant.components.homeassistant import exposed_entities
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.system_health import get_info as get_system_health_info
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
@@ -972,7 +973,7 @@ async def google_assistant_get(
return
entity = google_helpers.GoogleEntity(hass, gconf, state)
if not entity.is_supported():
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity.is_supported():
connection.send_error(
msg["id"],
websocket_api.ERR_NOT_SUPPORTED,
@@ -1074,7 +1075,9 @@ async def alexa_get(
"""Get data for a single alexa entity."""
entity_id: str = msg["entity_id"]
if not entity_supported_by_alexa(hass, entity_id):
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity_supported_by_alexa(
hass, entity_id
):
connection.send_error(
msg["id"],
websocket_api.ERR_NOT_SUPPORTED,
@@ -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
+2 -2
View File
@@ -15,8 +15,8 @@
],
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.2.6",
"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)
@@ -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,11 +18,9 @@ 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
+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.0.4",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.9.1"
"bleak-esphome==3.7.3"
],
"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"]
}
@@ -18,6 +18,7 @@ from homeassistant.components import webhook
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_SUPPORTED_FEATURES,
CLOUD_NEVER_EXPOSED_ENTITIES,
CONF_NAME,
STATE_UNAVAILABLE,
)
@@ -185,7 +186,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 +533,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 +550,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 +734,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
@@ -802,6 +803,8 @@ def async_get_entities(
is_supported_cache = config.is_supported_cache
for state in hass.states.async_all():
entity_id = state.entity_id
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
continue
# Check check inlined for performance to avoid
# function calls for every entity since we enumerate
# the entire state machine here
@@ -12,7 +12,8 @@ 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.const import CLOUD_NEVER_EXPOSED_ENTITIES
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 +158,20 @@ 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
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
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 +180,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"}

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