mirror of
https://github.com/home-assistant/core.git
synced 2026-06-24 23:55:21 +02:00
Compare commits
149 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e62ff35fd | |||
| 0a5c1ef8eb | |||
| a88093afd2 | |||
| 9fd90283b3 | |||
| 3159242b68 | |||
| f5985b03e4 | |||
| 040a3bcb10 | |||
| 5aaf6704a9 | |||
| 2fcd00b301 | |||
| 0b439e6e4c | |||
| d13a5b7eec | |||
| de49716ec1 | |||
| 67c6921847 | |||
| 002b638013 | |||
| 4b60ed30c7 | |||
| 6f1deec507 | |||
| 227ba8032f | |||
| 7da3ecf033 | |||
| 8b293a18d3 | |||
| 3dc077f280 | |||
| d368a95323 | |||
| 495f41a742 | |||
| 9f7529706d | |||
| 8f6b1dff9c | |||
| f260a1bb7b | |||
| 157e137ea9 | |||
| b2e1a296d4 | |||
| e78a2c9f01 | |||
| 9011225a42 | |||
| 81ef9b99c2 | |||
| fa0207698a | |||
| 275883a95a | |||
| ebd252a225 | |||
| 2de6c0281d | |||
| f95671f0f4 | |||
| 5fcae9ecf7 | |||
| 0b86cfa496 | |||
| d45bdf37d5 | |||
| a9205df4a3 | |||
| c333744fd2 | |||
| 2f64601990 | |||
| cbd35be271 | |||
| 92ac14f42a | |||
| 45e568c73e | |||
| a121b8d146 | |||
| a2bd7d5857 | |||
| a6e639377b | |||
| 2147a851c3 | |||
| 9034afd29e | |||
| 5c5d259f63 | |||
| cc16a9086f | |||
| 5d1f8f770c | |||
| cea6b9b0b7 | |||
| 77f7c26399 | |||
| 8e0a5b258c | |||
| f8b942818c | |||
| 9660d12c77 | |||
| 7f1533a6e1 | |||
| 336d9e9126 | |||
| 1dde2d918e | |||
| 34a6b0ca61 | |||
| e92286ecd6 | |||
| 82bb9748db | |||
| 68e5e58a1c | |||
| f3e8403e9a | |||
| 28076bcad6 | |||
| ff25428e56 | |||
| 608acd422f | |||
| c860e83ec9 | |||
| c9f3f4a265 | |||
| e346a801d1 | |||
| a5c193931f | |||
| d273350db1 | |||
| 45f27b8b6e | |||
| d3208a420f | |||
| d0d35e380f | |||
| 2735e58d7f | |||
| ad3eab80c3 | |||
| 18e5d284b4 | |||
| e5052eaf44 | |||
| 62c2e8d2fd | |||
| 1f505067dd | |||
| 72875b3b5e | |||
| 3be755e496 | |||
| 5285798052 | |||
| da49e37946 | |||
| 2f9de98f2d | |||
| 383a6426fc | |||
| 5ed60cd057 | |||
| a1250b7bfb | |||
| 240e5219ad | |||
| 418f352ce7 | |||
| 599967b1d8 | |||
| ad82729357 | |||
| 30d8bf4231 | |||
| 5436d8af9b | |||
| 88adf39ef3 | |||
| 14b14bddf1 | |||
| 3c4a30be6b | |||
| 2988eb4b19 | |||
| 00eef14558 | |||
| d02516dd09 | |||
| aabb6b3d04 | |||
| 50c2c7c4bc | |||
| e81dd426bb | |||
| c4c569c181 | |||
| 6182426132 | |||
| a073cc4f7d | |||
| 07ddc08d84 | |||
| 17673dcf55 | |||
| a864bc1c80 | |||
| a15d80daa2 | |||
| e123b29258 | |||
| 5669a7b602 | |||
| fe358a4a1f | |||
| 3a93d6370b | |||
| 89576f01e6 | |||
| f51895b0c9 | |||
| d0dcbfadaa | |||
| 5e0d3627c2 | |||
| 80c90732a3 | |||
| 16eca3909a | |||
| 1b471da31f | |||
| 7391209f48 | |||
| 0683344079 | |||
| 0b77cf9e4b | |||
| e0a87d966d | |||
| af53d2d082 | |||
| da7fa80e75 | |||
| 6cf1e7fb48 | |||
| 18fa0ac47d | |||
| 4afced1a49 | |||
| 74a4471160 | |||
| 857a3de066 | |||
| 06bf2ff6de | |||
| 6a5dae9cc3 | |||
| 475ebbc028 | |||
| 6e7643e997 | |||
| 1f954cda0d | |||
| 2961fca1b1 | |||
| 106b189206 | |||
| 0387034f4e | |||
| f81b6abca9 | |||
| 43f6e7977e | |||
| 706fea4ec5 | |||
| 74d23503e7 | |||
| 4ca5da2365 | |||
| 53c77ae2ef | |||
| 14968f9d67 |
@@ -1326,7 +1326,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@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
@@ -1485,7 +1485,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@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
@@ -1513,7 +1513,7 @@ jobs:
|
||||
with:
|
||||
pattern: test-results-*
|
||||
- name: Upload test results to Codecov
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
report_type: test_results
|
||||
fail_ci_if_error: true
|
||||
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
|
||||
Generated
+2
-2
@@ -623,8 +623,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/generic_hygrostat/ @Shulyaka
|
||||
/homeassistant/components/geniushub/ @manzanotti
|
||||
/tests/components/geniushub/ @manzanotti
|
||||
/homeassistant/components/gentex_homelink/ @niaexa @ryanjones-gentex
|
||||
/tests/components/gentex_homelink/ @niaexa @ryanjones-gentex
|
||||
/homeassistant/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex
|
||||
/tests/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex
|
||||
/homeassistant/components/geo_json_events/ @exxamalte
|
||||
/tests/components/geo_json_events/ @exxamalte
|
||||
/homeassistant/components/geo_location/ @home-assistant/core
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["actron-neo-api==0.5.6"]
|
||||
"requirements": ["actron-neo-api==0.5.12"]
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
"title": "Re-authenticate AirVisual"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"type": "Integration type"
|
||||
},
|
||||
"description": "Pick what type of AirVisual data you want to monitor.",
|
||||
"title": "Configure AirVisual"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call
|
||||
from .entity import AmazonServiceEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
@@ -49,4 +49,5 @@ class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity):
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle button press action."""
|
||||
await self.coordinator.api.call_routine(self._routine)
|
||||
async with alexa_api_call(self.coordinator):
|
||||
await self.coordinator.api.call_routine(self._routine)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Support for Alexa Devices."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import timedelta
|
||||
|
||||
from aioamazondevices.api import AmazonEchoApi
|
||||
@@ -19,7 +21,11 @@ from aiohttp import ClientSession
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
@@ -29,6 +35,65 @@ from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
|
||||
|
||||
SCAN_INTERVAL = 300
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def alexa_api_call(
|
||||
coordinator: DataUpdateCoordinator | None = None,
|
||||
) -> AsyncGenerator[None]:
|
||||
"""Handle common Alexa API exceptions as HomeAssistantError."""
|
||||
try:
|
||||
yield
|
||||
except CannotAuthenticate as err:
|
||||
if coordinator:
|
||||
coordinator.last_update_success = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except CannotConnect as err:
|
||||
if coordinator:
|
||||
coordinator.last_update_success = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except (CannotRetrieveData, ValueError) as err:
|
||||
if coordinator:
|
||||
coordinator.last_update_success = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def alexa_config_entry_errors() -> AsyncGenerator[None]:
|
||||
"""Handle common Alexa API exceptions as ConfigEntry errors."""
|
||||
try:
|
||||
yield
|
||||
except CannotAuthenticate as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except (CannotConnect, TimeoutError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except (CannotRetrieveData, ValueError, KeyError, StopIteration) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
|
||||
type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator]
|
||||
|
||||
|
||||
@@ -113,14 +178,23 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except ValueError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
else:
|
||||
current_devices = set(data.keys())
|
||||
if stale_devices := self.previous_devices - current_devices:
|
||||
await self._async_remove_device_stale(stale_devices)
|
||||
self.previous_devices = current_devices
|
||||
|
||||
current_routines = {slugify(routine) for routine in self.api.routines}
|
||||
if stale_routines := self.previous_routines - current_routines:
|
||||
current_routines = {
|
||||
f"{slugify(self.config_entry.unique_id)}-{slugify(routine)}"
|
||||
for routine in self.api.routines
|
||||
}
|
||||
if stale_routines := (self.previous_routines - current_routines):
|
||||
await self._async_remove_routine_stale(stale_routines)
|
||||
self.previous_routines = current_routines
|
||||
|
||||
@@ -154,41 +228,25 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
"""Remove stale routine."""
|
||||
entity_registry = er.async_get(self.hass)
|
||||
|
||||
for routine in stale_routines:
|
||||
_LOGGER.debug(
|
||||
"Detected change in routines: routine %s removed",
|
||||
routine,
|
||||
)
|
||||
for routine_unique_id in stale_routines:
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
Platform.BUTTON,
|
||||
DOMAIN,
|
||||
f"{slugify(self.config_entry.unique_id)}-{slugify(routine)}",
|
||||
routine_unique_id,
|
||||
)
|
||||
if entity_id:
|
||||
_LOGGER.debug(
|
||||
"Detected change in routines: routine %s removed",
|
||||
routine_unique_id.replace(
|
||||
f"{slugify(self.config_entry.unique_id)}-", ""
|
||||
),
|
||||
)
|
||||
entity_registry.async_remove(entity_id)
|
||||
|
||||
async def sync_history_state(self) -> None:
|
||||
"""Sync history state."""
|
||||
try:
|
||||
async with alexa_config_entry_errors():
|
||||
self._vocal_records = await self.api.sync_history_state()
|
||||
except CannotAuthenticate as e:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(e)},
|
||||
) from e
|
||||
except CannotConnect as e:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_with_error",
|
||||
translation_placeholders={"error": repr(e)},
|
||||
) from e
|
||||
except BaseException as e:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(e)},
|
||||
) from e
|
||||
|
||||
async def history_state_event_handler(
|
||||
self, vocal_records: dict[str, AmazonVocalRecord]
|
||||
@@ -204,26 +262,8 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
|
||||
async def sync_media_state(self) -> None:
|
||||
"""Sync media state."""
|
||||
try:
|
||||
async with alexa_config_entry_errors():
|
||||
await self.api.sync_media_state()
|
||||
except CannotAuthenticate as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except (CannotConnect, TimeoutError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except (CannotRetrieveData, ValueError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
async def media_state_event_handler(
|
||||
self, media_state: dict[str, AmazonMediaState]
|
||||
|
||||
@@ -12,7 +12,18 @@ from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from .coordinator import AmazonConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"}
|
||||
TO_REDACT = {
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
"access_token",
|
||||
"adp_token",
|
||||
"device_private_key",
|
||||
"refresh_token",
|
||||
"store_authentication_cookie",
|
||||
"title",
|
||||
"website_cookies",
|
||||
}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
|
||||
@@ -2,13 +2,20 @@
|
||||
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.components.event import EventEntity, EventEntityDescription
|
||||
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
|
||||
|
||||
from homeassistant.components.event import (
|
||||
DOMAIN as EVENT_DOMAIN,
|
||||
EventEntity,
|
||||
EventEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import _LOGGER
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
from .entity import AmazonEntity
|
||||
from .utils import async_remove_entity_from_virtual_group
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -31,6 +38,11 @@ async def async_setup_entry(
|
||||
"""Set up Alexa Devices events based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Remove voice event from virtual groups
|
||||
await async_remove_entity_from_virtual_group(
|
||||
hass, coordinator, EVENT_DOMAIN, "voice_event"
|
||||
)
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
@@ -42,6 +54,7 @@ async def async_setup_entry(
|
||||
AlexaVoiceEvent(coordinator, serial_num, event_desc)
|
||||
for event_desc in EVENTS
|
||||
for serial_num in new_devices
|
||||
if coordinator.data[serial_num].device_family != SPEAKER_GROUP_FAMILY
|
||||
)
|
||||
|
||||
_check_device()
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==14.0.0"]
|
||||
"requirements": ["aioamazondevices==14.1.3"]
|
||||
}
|
||||
|
||||
@@ -22,9 +22,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import _LOGGER
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call
|
||||
from .entity import AmazonEntity
|
||||
from .utils import alexa_api_call
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -216,16 +215,15 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
provider = media_type.value if isinstance(media_type, MediaType) else media_type
|
||||
await self.async_call_alexa_music(media_id, provider)
|
||||
|
||||
@alexa_api_call
|
||||
async def async_call_alexa_music(
|
||||
self, search_phrase: str, provider_id: str
|
||||
) -> None:
|
||||
"""Call alexa music."""
|
||||
await self.coordinator.api.call_alexa_music(
|
||||
self.device, search_phrase, provider_id
|
||||
)
|
||||
async with alexa_api_call(self.coordinator):
|
||||
await self.coordinator.api.call_alexa_music(
|
||||
self.device, search_phrase, provider_id
|
||||
)
|
||||
|
||||
@alexa_api_call
|
||||
async def async_set_device_volume(self, volume: int) -> None:
|
||||
"""Set the device volume."""
|
||||
_LOGGER.debug(
|
||||
@@ -233,7 +231,8 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
self.device.serial_number,
|
||||
volume,
|
||||
)
|
||||
await self.coordinator.api.set_device_volume(self.device, volume)
|
||||
async with alexa_api_call(self.coordinator):
|
||||
await self.coordinator.api.set_device_volume(self.device, volume)
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set the volume level (0.0 to 1.0)."""
|
||||
@@ -263,12 +262,12 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
await self.async_set_volume_level(target_volume / 100)
|
||||
self._prev_volume = None
|
||||
|
||||
@alexa_api_call
|
||||
async def _send_media_command(self, command: AmazonMediaControls) -> None:
|
||||
_LOGGER.debug(
|
||||
"Sending media command '%s' to %s", command, self.device.serial_number
|
||||
)
|
||||
await self.coordinator.api.send_media_command(self.device, command)
|
||||
async with alexa_api_call(self.coordinator):
|
||||
await self.coordinator.api.send_media_command(self.device, command)
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Send stop command."""
|
||||
|
||||
@@ -12,9 +12,8 @@ from homeassistant.components.notify import NotifyEntity, NotifyEntityDescriptio
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .coordinator import AmazonConfigEntry, alexa_api_call
|
||||
from .entity import AmazonEntity
|
||||
from .utils import alexa_api_call
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -80,10 +79,11 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
|
||||
|
||||
entity_description: AmazonNotifyEntityDescription
|
||||
|
||||
@alexa_api_call
|
||||
async def async_send_message(
|
||||
self, message: str, title: str | None = None, **kwargs: Any
|
||||
) -> None:
|
||||
"""Send a message."""
|
||||
|
||||
await self.entity_description.method(self.coordinator.api, self.device, message)
|
||||
async with alexa_api_call(self.coordinator):
|
||||
await self.entity_description.method(
|
||||
self.coordinator.api, self.device, message
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
|
||||
from .const import DOMAIN, INFO_SKILLS_MAPPING
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .coordinator import AmazonConfigEntry, alexa_api_call
|
||||
|
||||
ATTR_TEXT_COMMAND = "text_command"
|
||||
ATTR_SOUND = "sound"
|
||||
@@ -85,13 +85,15 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
|
||||
translation_key="invalid_sound_value",
|
||||
translation_placeholders={"sound": value},
|
||||
)
|
||||
await coordinator.api.call_alexa_sound(
|
||||
coordinator.data[device.serial_number], value
|
||||
)
|
||||
async with alexa_api_call():
|
||||
await coordinator.api.call_alexa_sound(
|
||||
coordinator.data[device.serial_number], value
|
||||
)
|
||||
elif attribute == ATTR_TEXT_COMMAND:
|
||||
await coordinator.api.call_alexa_text_command(
|
||||
coordinator.data[device.serial_number], value
|
||||
)
|
||||
async with alexa_api_call():
|
||||
await coordinator.api.call_alexa_text_command(
|
||||
coordinator.data[device.serial_number], value
|
||||
)
|
||||
elif attribute == ATTR_INFO_SKILL:
|
||||
info_skill = INFO_SKILLS_MAPPING.get(value)
|
||||
if info_skill not in ALEXA_INFO_SKILLS:
|
||||
@@ -100,9 +102,10 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
|
||||
translation_key="invalid_info_skill_value",
|
||||
translation_placeholders={"info_skill": value},
|
||||
)
|
||||
await coordinator.api.call_alexa_info_skill(
|
||||
coordinator.data[device.serial_number], info_skill
|
||||
)
|
||||
async with alexa_api_call():
|
||||
await coordinator.api.call_alexa_info_skill(
|
||||
coordinator.data[device.serial_number], info_skill
|
||||
)
|
||||
|
||||
|
||||
async def async_send_sound_notification(call: ServiceCall) -> None:
|
||||
|
||||
@@ -14,13 +14,9 @@ from homeassistant.components.switch import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .coordinator import AmazonConfigEntry, alexa_api_call
|
||||
from .entity import AmazonEntity
|
||||
from .utils import (
|
||||
alexa_api_call,
|
||||
async_remove_dnd_from_virtual_group,
|
||||
async_update_unique_id,
|
||||
)
|
||||
from .utils import async_remove_entity_from_virtual_group, async_update_unique_id
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -62,7 +58,9 @@ async def async_setup_entry(
|
||||
new_key = "dnd"
|
||||
|
||||
# Remove old DND switch from virtual groups
|
||||
await async_remove_dnd_from_virtual_group(hass, coordinator, old_key)
|
||||
await async_remove_entity_from_virtual_group(
|
||||
hass, coordinator, SWITCH_DOMAIN, old_key
|
||||
)
|
||||
|
||||
# Replace unique id for DND switch
|
||||
await async_update_unique_id(hass, coordinator, SWITCH_DOMAIN, old_key, new_key)
|
||||
@@ -90,7 +88,6 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
|
||||
|
||||
entity_description: AmazonSwitchEntityDescription
|
||||
|
||||
@alexa_api_call
|
||||
async def _switch_set_state(self, state: bool) -> None:
|
||||
"""Set desired switch state."""
|
||||
method = getattr(self.coordinator.api, self.entity_description.method)
|
||||
@@ -98,7 +95,8 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
|
||||
if TYPE_CHECKING:
|
||||
assert method is not None
|
||||
|
||||
await method(self.device, state)
|
||||
async with alexa_api_call(self.coordinator):
|
||||
await method(self.device, state)
|
||||
self.coordinator.data[self.device.serial_number].sensors[
|
||||
self.entity_description.key
|
||||
].value = state
|
||||
|
||||
@@ -1,54 +1,18 @@
|
||||
"""Utils for Alexa Devices."""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
|
||||
from aioamazondevices.const.schedules import (
|
||||
NOTIFICATION_ALARM,
|
||||
NOTIFICATION_REMINDER,
|
||||
NOTIFICATION_TIMER,
|
||||
)
|
||||
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
|
||||
from .const import _LOGGER, DOMAIN
|
||||
from .coordinator import AmazonDevicesCoordinator
|
||||
from .entity import AmazonEntity
|
||||
|
||||
|
||||
def alexa_api_call[_T: AmazonEntity, **_P](
|
||||
func: Callable[Concatenate[_T, _P], Awaitable[None]],
|
||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
|
||||
"""Catch Alexa API call exceptions."""
|
||||
|
||||
@wraps(func)
|
||||
async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
"""Wrap all command methods."""
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except CannotConnect as err:
|
||||
self.coordinator.last_update_success = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except CannotRetrieveData as err:
|
||||
self.coordinator.last_update_success = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
return cmd_wrapper
|
||||
|
||||
|
||||
async def async_update_unique_id(
|
||||
@@ -73,23 +37,22 @@ async def async_update_unique_id(
|
||||
entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id)
|
||||
|
||||
|
||||
async def async_remove_dnd_from_virtual_group(
|
||||
async def async_remove_entity_from_virtual_group(
|
||||
hass: HomeAssistant,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
platform: str,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Remove entity DND from virtual group."""
|
||||
"""Remove entity from virtual group."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
for serial_num in coordinator.data:
|
||||
unique_id = f"{serial_num}-{key}"
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
DOMAIN, SWITCH_DOMAIN, unique_id
|
||||
)
|
||||
entity_id = entity_registry.async_get_entity_id(DOMAIN, platform, unique_id)
|
||||
is_group = coordinator.data[serial_num].device_family == SPEAKER_GROUP_FAMILY
|
||||
if entity_id and is_group:
|
||||
entity_registry.async_remove(entity_id)
|
||||
_LOGGER.debug("Removed DND switch from virtual group %s", entity_id)
|
||||
_LOGGER.debug("Removed entity '%s' from virtual group", entity_id)
|
||||
|
||||
|
||||
async def async_remove_unsupported_notification_sensors(
|
||||
|
||||
@@ -34,11 +34,13 @@ def generate_site_selector_name(site: Site) -> str:
|
||||
|
||||
|
||||
def filter_sites(sites: list[Site]) -> list[Site]:
|
||||
"""Deduplicates the list of sites."""
|
||||
"""Filter out closed sites and deduplicate the list of sites."""
|
||||
filtered: list[Site] = []
|
||||
filtered_nmi: set[str] = set()
|
||||
|
||||
for site in sorted(sites, key=lambda site: site.status):
|
||||
if site.status == SiteStatus.CLOSED:
|
||||
continue
|
||||
if site.status == SiteStatus.ACTIVE or site.nmi not in filtered_nmi:
|
||||
filtered.append(site)
|
||||
filtered_nmi.add(site.nmi)
|
||||
|
||||
@@ -1816,6 +1816,11 @@ class PipelineInput:
|
||||
await self.run.text_to_speech(tts_input)
|
||||
|
||||
except PipelineError as err:
|
||||
if self.run.tts_stream:
|
||||
# Clean up TTS stream
|
||||
self.run.tts_stream.delete()
|
||||
self.run.tts_stream = None
|
||||
|
||||
self.run.process_event(
|
||||
PipelineEvent(
|
||||
PipelineEventType.ERROR,
|
||||
@@ -1885,15 +1890,17 @@ class PipelineInput:
|
||||
):
|
||||
prepare_tasks.append(self.run.prepare_recognize_intent(self.session))
|
||||
|
||||
if prepare_tasks:
|
||||
await asyncio.gather(*prepare_tasks)
|
||||
|
||||
# Do TTS prepare separately so we don't create a ResultStream if the
|
||||
# pipeline is invalid.
|
||||
if (
|
||||
start_stage_index
|
||||
<= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS)
|
||||
<= end_stage_index
|
||||
):
|
||||
prepare_tasks.append(self.run.prepare_text_to_speech())
|
||||
|
||||
if prepare_tasks:
|
||||
await asyncio.gather(*prepare_tasks)
|
||||
await self.run.prepare_text_to_speech()
|
||||
|
||||
|
||||
class PipelinePreferred(CollectionError):
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0"]
|
||||
"requirements": ["hassil==3.7.0"]
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
"description": "The credentials for {username} need to be updated",
|
||||
"title": "Re-authenticate Blink"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -6,6 +6,7 @@ These APIs are the only documented way to interact with the bluetooth integratio
|
||||
import asyncio
|
||||
from asyncio import Future
|
||||
from collections.abc import Callable, Iterable
|
||||
from contextlib import ExitStack
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from bleak import BleakScanner
|
||||
@@ -178,15 +179,20 @@ async def async_process_advertisements(
|
||||
if not done.done() and callback(service_info):
|
||||
done.set_result(service_info)
|
||||
|
||||
unload = _get_manager(hass).async_register_callback(
|
||||
_async_discovered_device, match_dict, mode, scan_duration=timeout
|
||||
)
|
||||
manager = _get_manager(hass)
|
||||
|
||||
with ExitStack() as stack:
|
||||
unload = manager.async_register_callback(
|
||||
_async_discovered_device, match_dict, mode
|
||||
)
|
||||
stack.callback(unload)
|
||||
|
||||
if mode == BluetoothScanningMode.ACTIVE:
|
||||
task = hass.async_create_task(manager.async_request_active_scan(timeout))
|
||||
stack.callback(task.cancel)
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(timeout):
|
||||
return await done
|
||||
finally:
|
||||
unload()
|
||||
|
||||
|
||||
@hass_callback
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bthome-ble==3.23.2"]
|
||||
"requirements": ["bthome-ble==3.23.4"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiostreammagic"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiostreammagic==2.13.1"],
|
||||
"requirements": ["aiostreammagic==2.13.2"],
|
||||
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiocomelit==2.0.3"]
|
||||
"requirements": ["aiocomelit==2.0.7"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.6.1"]
|
||||
"requirements": ["hassil==3.7.0", "home-assistant-intents==2026.6.1"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pydaikin"],
|
||||
"requirements": ["pydaikin==2.17.2"],
|
||||
"requirements": ["pydaikin==2.18.1"],
|
||||
"zeroconf": ["_dkapi._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.2.7",
|
||||
"aiodiscover==3.2.4",
|
||||
"cached-ipaddress==1.1.1"
|
||||
"aiodiscover==3.3.2",
|
||||
"cached-ipaddress==1.1.2"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["dsmr_parser"],
|
||||
"requirements": ["dsmr-parser==1.7.0"]
|
||||
"requirements": ["dsmr-parser==1.9.0"]
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"name": "Target flow level"
|
||||
},
|
||||
"time_state_end": {
|
||||
"name": "Mode end time"
|
||||
"name": "State end time"
|
||||
},
|
||||
"ventilation_state": {
|
||||
"name": "Ventilation state",
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Please enter the API key obtained from ecobee.com."
|
||||
}
|
||||
|
||||
@@ -39,12 +39,12 @@ class EconetFanModeSelect(EcoNetEntity[Thermostat], SelectEntity):
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
"""Return available select options."""
|
||||
return [e.value for e in self._econet.fan_modes]
|
||||
return [e.name for e in self._econet.fan_modes]
|
||||
|
||||
@property
|
||||
def current_option(self) -> str:
|
||||
"""Return current select option."""
|
||||
return self._econet.fan_mode.value
|
||||
return self._econet.fan_mode.name
|
||||
|
||||
def select_option(self, option: str) -> None:
|
||||
"""Set the selected option."""
|
||||
|
||||
@@ -246,8 +246,8 @@ class Elkm1ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
if device is not None and device.mac_address:
|
||||
await self.async_set_unique_id(dr.format_mac(device.mac_address))
|
||||
# aborts if user tried to switch devices
|
||||
self._abort_if_unique_id_mismatch()
|
||||
if reconfigure_entry.unique_id is not None:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
else:
|
||||
# If we cannot confirm identity, keep existing
|
||||
# behavior (don't block reconfigure)
|
||||
@@ -255,6 +255,7 @@ class Elkm1ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
unique_id=self.unique_id,
|
||||
data_updates={
|
||||
**reconfigure_entry.data,
|
||||
CONF_HOST: info[CONF_HOST],
|
||||
|
||||
@@ -166,6 +166,8 @@ class RuntimeEntryData:
|
||||
)
|
||||
loaded_platforms: set[Platform] = field(default_factory=set)
|
||||
platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
# Set once the first connection has finished scanner setup or teardown.
|
||||
first_connect_done: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
_storage_contents: StoreData | None = None
|
||||
_pending_storage: Callable[[], StoreData] | None = None
|
||||
assist_pipeline_update_callbacks: list[CALLBACK_TYPE] = field(default_factory=list)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Manager for esphome devices."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from functools import partial
|
||||
import logging
|
||||
import secrets
|
||||
import struct
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
from typing import TYPE_CHECKING, Any, Final, NamedTuple
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
@@ -106,6 +107,9 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Max time to wait at startup for a BLE proxy to register its scanner.
|
||||
STARTUP_SCANNER_WAIT: Final = 3.0
|
||||
|
||||
LOG_LEVEL_TO_LOGGER = {
|
||||
LogLevel.LOG_LEVEL_NONE: logging.DEBUG,
|
||||
LogLevel.LOG_LEVEL_ERROR: logging.ERROR,
|
||||
@@ -677,6 +681,8 @@ class ESPHomeManager:
|
||||
hass, device_info.bluetooth_mac_address or device_info.mac_address
|
||||
)
|
||||
|
||||
entry_data.first_connect_done.set()
|
||||
|
||||
if device_info.voice_assistant_feature_flags_compat(api_version) and (
|
||||
Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms
|
||||
):
|
||||
@@ -988,6 +994,21 @@ class ESPHomeManager:
|
||||
|
||||
await reconnect_logic.start()
|
||||
|
||||
# Wait for a cached BLE proxy to register its scanner before finishing setup.
|
||||
if (
|
||||
device_info := entry_data.device_info
|
||||
) is not None and device_info.bluetooth_proxy_feature_flags_compat(
|
||||
entry_data.api_version
|
||||
):
|
||||
try:
|
||||
async with asyncio.timeout(STARTUP_SCANNER_WAIT):
|
||||
await entry_data.first_connect_done.wait()
|
||||
except TimeoutError:
|
||||
_LOGGER.debug(
|
||||
"%s: Timed out waiting for Bluetooth scanner to register",
|
||||
self.host,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_setup_device_registry(
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
"discovery_confirm": {
|
||||
"description": "Do you want to set up {model} {id} ({ipaddr})?"
|
||||
},
|
||||
"pick_device": {
|
||||
"data": {
|
||||
"device": "[%key:common::config_flow::data::device%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260527.4"]
|
||||
"requirements": ["home-assistant-frontend==20260527.7"]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""The Gardena Bluetooth integration."""
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from gardena_bluetooth.client import CachedConnection, Client
|
||||
from gardena_bluetooth.const import ProductType
|
||||
from gardena_bluetooth.scan import async_get_manufacturer_data
|
||||
from gardena_bluetooth.const import ScanService
|
||||
from gardena_bluetooth.parse import ManufacturerData, ProductType
|
||||
from habluetooth import BluetoothServiceInfoBleak
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
@@ -30,6 +32,64 @@ PLATFORMS: list[Platform] = [
|
||||
]
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
DISCONNECT_DELAY = 5
|
||||
PRODUCTS_SCAN_TIMEOUT = 10
|
||||
PRODUCT_TYPE_TIMEOUT = 30
|
||||
|
||||
|
||||
async def async_get_product_type(hass: HomeAssistant, address: str) -> ProductType:
|
||||
"""Get a product type for the given address."""
|
||||
|
||||
data = ManufacturerData()
|
||||
|
||||
def _data_callback(info: BluetoothServiceInfoBleak) -> bool:
|
||||
LOGGER.debug("Processing advertisement from %s: %s", info.address, info)
|
||||
if info.device.address != address:
|
||||
return False
|
||||
|
||||
data.update(info.manufacturer_data.get(ManufacturerData.company, b""))
|
||||
return data.product_type is not ProductType.UNKNOWN
|
||||
|
||||
with suppress(TimeoutError):
|
||||
await bluetooth.async_process_advertisements(
|
||||
hass,
|
||||
_data_callback,
|
||||
bluetooth.BluetoothCallbackMatcher(
|
||||
address=address, manufacturer_id=ManufacturerData.company
|
||||
),
|
||||
mode=bluetooth.BluetoothScanningMode.ACTIVE,
|
||||
timeout=PRODUCT_TYPE_TIMEOUT,
|
||||
)
|
||||
return data.product_type
|
||||
|
||||
|
||||
async def async_get_products(hass: HomeAssistant) -> dict[str, ManufacturerData]:
|
||||
"""Get all products that are currently advertising."""
|
||||
products: dict[str, ManufacturerData] = {}
|
||||
|
||||
def _data_callback(info: BluetoothServiceInfoBleak) -> bool:
|
||||
LOGGER.debug("Processing advertisement from %s: %s", info.address, info)
|
||||
if ScanService not in info.service_uuids:
|
||||
return False
|
||||
|
||||
raw = info.manufacturer_data.get(ManufacturerData.company, b"")
|
||||
if (data := products.get(info.device.address)) is None:
|
||||
data = ManufacturerData()
|
||||
products[info.device.address] = data
|
||||
|
||||
data.update(raw)
|
||||
return False
|
||||
|
||||
with suppress(TimeoutError):
|
||||
await bluetooth.async_process_advertisements(
|
||||
hass,
|
||||
_data_callback,
|
||||
bluetooth.BluetoothCallbackMatcher(
|
||||
manufacturer_id=ManufacturerData.company
|
||||
),
|
||||
mode=bluetooth.BluetoothScanningMode.ACTIVE,
|
||||
timeout=PRODUCTS_SCAN_TIMEOUT,
|
||||
)
|
||||
return products
|
||||
|
||||
|
||||
def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
|
||||
@@ -53,12 +113,7 @@ async def async_setup_entry(
|
||||
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
|
||||
try:
|
||||
mfg_data = await async_get_manufacturer_data({address})
|
||||
except TimeoutError as exc:
|
||||
raise ConfigEntryNotReady("Unable to find product type") from exc
|
||||
|
||||
product_type = mfg_data[address].product_type
|
||||
product_type = await async_get_product_type(hass, address)
|
||||
if product_type is ProductType.UNKNOWN:
|
||||
raise ConfigEntryNotReady("Unable to find product type")
|
||||
|
||||
|
||||
@@ -4,21 +4,17 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from gardena_bluetooth.client import Client
|
||||
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation, ScanService
|
||||
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation
|
||||
from gardena_bluetooth.exceptions import CharacteristicNotFound, CommunicationFailure
|
||||
from gardena_bluetooth.parse import ManufacturerData, ProductType
|
||||
from gardena_bluetooth.scan import async_get_manufacturer_data
|
||||
from gardena_bluetooth.parse import ProductType
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfo,
|
||||
async_discovered_service_info,
|
||||
)
|
||||
from homeassistant.components.bluetooth import BluetoothServiceInfo
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
|
||||
from . import get_connection
|
||||
from . import async_get_product_type, async_get_products, get_connection
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -33,17 +29,6 @@ _SUPPORTED_PRODUCT_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
def _is_supported(discovery_info: BluetoothServiceInfo):
|
||||
"""Check if device is supported."""
|
||||
if ScanService not in discovery_info.service_uuids:
|
||||
return False
|
||||
|
||||
if discovery_info.manufacturer_data.get(ManufacturerData.company) is None:
|
||||
_LOGGER.debug("Missing manufacturer data: %s", discovery_info)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Gardena Bluetooth."""
|
||||
|
||||
@@ -75,8 +60,7 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the bluetooth discovery step."""
|
||||
_LOGGER.debug("Discovered device: %s", discovery_info)
|
||||
data = await async_get_manufacturer_data({discovery_info.address})
|
||||
product_type = data[discovery_info.address].product_type
|
||||
product_type = await async_get_product_type(self.hass, discovery_info.address)
|
||||
if product_type not in _SUPPORTED_PRODUCT_TYPES:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
@@ -117,22 +101,16 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.async_step_confirm()
|
||||
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
candidates = set()
|
||||
for discovery_info in async_discovered_service_info(self.hass):
|
||||
address = discovery_info.address
|
||||
if address in current_addresses or not _is_supported(discovery_info):
|
||||
continue
|
||||
candidates.add(address)
|
||||
|
||||
data = await async_get_manufacturer_data(candidates)
|
||||
for address, mfg_data in data.items():
|
||||
if mfg_data.product_type not in _SUPPORTED_PRODUCT_TYPES:
|
||||
continue
|
||||
self.devices[address] = PRODUCT_NAMES[mfg_data.product_type]
|
||||
current = self._async_current_ids(include_ignore=False)
|
||||
devices = await async_get_products(self.hass)
|
||||
|
||||
# Keep selection sorted by address to ensure stable tests
|
||||
self.devices = dict(sorted(self.devices.items(), key=lambda x: x[0]))
|
||||
self.devices = {
|
||||
address: PRODUCT_NAMES[data.product_type]
|
||||
for address in sorted(devices)
|
||||
if address not in current
|
||||
and (data := devices[address]).product_type in _SUPPORTED_PRODUCT_TYPES
|
||||
}
|
||||
|
||||
if not self.devices:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"domain": "gentex_homelink",
|
||||
"name": "HomeLink",
|
||||
"codeowners": ["@niaexa", "@ryanjones-gentex"],
|
||||
"codeowners": ["@Gentex-Corporation/Homelink", "@rjones-gentex"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/gentex_homelink",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["homelink-integration-api==0.0.1"]
|
||||
"requirements": ["homelink-integration-api==0.0.5"]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"device": "[%key:common::config_flow::data::device%]",
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
|
||||
@@ -189,7 +189,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
) from err
|
||||
total_info["todayEnergy"] = total_info["today_energy"]
|
||||
total_info["totalEnergy"] = total_info["total_energy"]
|
||||
total_info["invTodayPpv"] = total_info["current_power"]
|
||||
# V1 API returns current_power in kW, convert to W
|
||||
total_info["invTodayPpv"] = total_info["current_power"] * 1000
|
||||
else:
|
||||
# Classic API: use plant_info as before.
|
||||
# Copy the response to avoid mutating the dict returned by the library
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"title": "[%key:component::here_travel_time::config::step::destination_menu::title%]"
|
||||
},
|
||||
"destination_entity_id": {
|
||||
"destination_entity": {
|
||||
"data": {
|
||||
"destination_entity_id": "Destination using an entity"
|
||||
},
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"title": "[%key:component::here_travel_time::config::step::origin_menu::title%]"
|
||||
},
|
||||
"origin_entity_id": {
|
||||
"origin_entity": {
|
||||
"data": {
|
||||
"origin_entity_id": "Origin using an entity"
|
||||
},
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Hi-Link HLK-SW-16 device."
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.97", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.98", "babel==2.15.0"]
|
||||
}
|
||||
|
||||
@@ -135,7 +135,24 @@ async def async_migrate_entry(
|
||||
)
|
||||
|
||||
if canonical.entry_id != config_entry.entry_id:
|
||||
# The canonical entry's migration will remove this duplicate.
|
||||
if canonical.minor_version < 2:
|
||||
# The canonical entry has not been migrated yet and its
|
||||
# migration will remove this duplicate.
|
||||
return False
|
||||
|
||||
# The canonical entry is already fully migrated and will not run
|
||||
# a migration that removes this duplicate, so remove it here. The
|
||||
# entry can't remove itself while its setup lock is held, so
|
||||
# schedule the removal instead.
|
||||
_LOGGER.debug(
|
||||
"Removing duplicate config entry %s for serial %s in favor of %s",
|
||||
config_entry.entry_id,
|
||||
serial_number,
|
||||
canonical.entry_id,
|
||||
)
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_remove(config_entry.entry_id)
|
||||
)
|
||||
return False
|
||||
|
||||
for duplicate in duplicates:
|
||||
|
||||
@@ -239,7 +239,24 @@ async def async_migrate_entry(
|
||||
)
|
||||
|
||||
if canonical.entry_id != config_entry.entry_id:
|
||||
# The canonical entry's migration will remove this duplicate.
|
||||
if canonical.minor_version < 5:
|
||||
# The canonical entry has not been migrated yet and its
|
||||
# migration will remove this duplicate.
|
||||
return False
|
||||
|
||||
# The canonical entry is already fully migrated and will not run
|
||||
# a migration that removes this duplicate, so remove it here. The
|
||||
# entry can't remove itself while its setup lock is held, so
|
||||
# schedule the removal instead.
|
||||
_LOGGER.warning(
|
||||
"Removing duplicate config entry %s for serial %s in favor of %s",
|
||||
config_entry.entry_id,
|
||||
serial_number,
|
||||
canonical.entry_id,
|
||||
)
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_remove(config_entry.entry_id)
|
||||
)
|
||||
return False
|
||||
|
||||
for duplicate in duplicates:
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "The Honeywell integration needs to re-authenticate your account",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
|
||||
@@ -9,10 +9,12 @@ import time
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import urlparse
|
||||
import uuid
|
||||
import warnings
|
||||
|
||||
from aiohttp import ClientError, ClientResponse, ClientSession, web
|
||||
from aiohttp.hdrs import AUTHORIZATION
|
||||
import jwt
|
||||
from jwt.warnings import InsecureKeyLengthWarning
|
||||
from py_vapid import Vapid
|
||||
from pywebpush import WebPusher, WebPushException, webpush_async
|
||||
import voluptuous as vol
|
||||
@@ -325,7 +327,8 @@ class HTML5PushCallbackView(HomeAssistantView):
|
||||
if target_check.get(ATTR_TARGET) in self.registrations:
|
||||
possible_target = self.registrations[target_check[ATTR_TARGET]]
|
||||
key = possible_target["subscription"]["keys"]["auth"]
|
||||
with suppress(jwt.exceptions.DecodeError):
|
||||
with suppress(jwt.exceptions.DecodeError), warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", InsecureKeyLengthWarning)
|
||||
return jwt.decode(token, key, algorithms=["ES256", "HS256"])
|
||||
|
||||
return self.json_message(
|
||||
@@ -585,7 +588,9 @@ def add_jwt(timestamp: int, target: str, tag: str, jwt_secret: str) -> str:
|
||||
ATTR_TARGET: target,
|
||||
ATTR_TAG: tag,
|
||||
}
|
||||
return jwt.encode(jwt_claims, jwt_secret)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", InsecureKeyLengthWarning)
|
||||
return jwt.encode(jwt_claims, jwt_secret)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"id": "Hue bridge"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Hue bridge."
|
||||
|
||||
@@ -85,6 +85,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
|
||||
entity_description = LightEntityDescription(
|
||||
key="hue_grouped_light",
|
||||
translation_key="hue_grouped_light",
|
||||
has_entity_name=True,
|
||||
name=None,
|
||||
)
|
||||
|
||||
@@ -166,8 +166,10 @@ class HueLightLevelSensor(HueSensorBase):
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int:
|
||||
def native_value(self) -> int | None:
|
||||
"""Return the value reported by the sensor."""
|
||||
if self.resource.light.value is None:
|
||||
return None
|
||||
# Light level in 10000 log10 (lux) +1 measured by sensor. Logarithm
|
||||
# scale used because the human eye adjusts to light levels and small
|
||||
# changes at low lux levels are more noticeable than at high lux
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2.7.5"]
|
||||
"requirements": ["aioautomower==2.7.6"]
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
|
||||
await hass.async_add_executor_job(account.setup)
|
||||
|
||||
entry.runtime_data = account
|
||||
entry.async_on_unload(account.cancel_fetch)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -68,4 +67,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
await hass.async_add_executor_job(entry.runtime_data.cancel_fetch)
|
||||
return unload_ok
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["idasen-ha==2.6.5"]
|
||||
"requirements": ["idasen-ha==2.7.0"]
|
||||
}
|
||||
|
||||
@@ -248,7 +248,10 @@ def _generate_thumbnail_if_file_does_not_exist(
|
||||
if not target_file.is_file():
|
||||
image = ImageOps.exif_transpose(Image.open(original_path))
|
||||
image.thumbnail(target_size)
|
||||
image.save(target_path, format=content_type.partition("/")[-1])
|
||||
save_format = content_type.partition("/")[-1]
|
||||
if save_format == "jpeg" and image.mode not in ("RGB", "L", "CMYK"):
|
||||
image = image.convert("RGB")
|
||||
image.save(target_path, format=save_format)
|
||||
|
||||
|
||||
def _validate_size_from_filename(filename: str) -> tuple[int, int]:
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["imgw_pib==2.2.0"]
|
||||
"requirements": ["imgw_pib==2.2.2"]
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ class ImmichDataUpdateCoordinator(DataUpdateCoordinator[ImmichData]):
|
||||
async def _async_setup(self) -> None:
|
||||
"""Handle setup of the coordinator."""
|
||||
try:
|
||||
await self.api.async_setup()
|
||||
user_info = await self.api.users.async_get_my_user()
|
||||
except ImmichUnauthorizedError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
@@ -119,7 +120,7 @@ class ImmichDataUpdateCoordinator(DataUpdateCoordinator[ImmichData]):
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
return ImmichData(
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioimmich"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioimmich==0.14.1"]
|
||||
"requirements": ["aioimmich==0.15.0"]
|
||||
}
|
||||
|
||||
@@ -225,10 +225,9 @@ class ImmichMediaSource(MediaSource):
|
||||
entry.title,
|
||||
)
|
||||
try:
|
||||
album_info = await immich_api.albums.async_get_album_info(
|
||||
identifier.collection_id
|
||||
assets = await immich_api.search.async_get_all_by_album_ids(
|
||||
[identifier.collection_id]
|
||||
)
|
||||
assets = album_info.assets
|
||||
except ImmichForbiddenError as err:
|
||||
raise BrowseError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -53,7 +53,7 @@ async def _async_upload_file(service_call: ServiceCall) -> None:
|
||||
|
||||
if target_album := service_call.data.get(CONF_ALBUM_ID):
|
||||
try:
|
||||
await coordinator.api.albums.async_get_album_info(target_album, True)
|
||||
await coordinator.api.albums.async_get_album_info(target_album)
|
||||
except ImmichError as ex:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -423,7 +423,7 @@ def get_influx_connection( # noqa: C901
|
||||
if CONF_HOST in conf:
|
||||
kwargs[CONF_HOST] = conf[CONF_HOST]
|
||||
|
||||
if (path := conf.get(CONF_PATH)) is not None:
|
||||
if (path := conf.get(CONF_PATH)) is not None and path != "/":
|
||||
kwargs[CONF_PATH] = path
|
||||
|
||||
if (port := conf.get(CONF_PORT)) is not None:
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"protocol": "Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Hostname or IP address of your Iskra device."
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"location": "[%key:common::config_flow::data::location%]",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"description": "Do you want to set up Islamic Prayer Times?",
|
||||
"title": "Set up Islamic Prayer Times"
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyituran==0.1.5"]
|
||||
"requirements": ["pyituran==0.1.6"]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"location": {
|
||||
"data": {
|
||||
"location": "[%key:common::config_flow::data::location%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -121,7 +121,7 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
native_step=PRECISION_TENTHS,
|
||||
native_min_value=0,
|
||||
native_max_value=10,
|
||||
native_max_value=30,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_value_fn=(
|
||||
lambda machine, value: machine.set_pre_extraction_times(
|
||||
@@ -163,7 +163,7 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
native_step=PRECISION_TENTHS,
|
||||
native_min_value=0,
|
||||
native_max_value=10,
|
||||
native_max_value=30,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_value_fn=(
|
||||
lambda machine, value: machine.set_pre_extraction_times(
|
||||
@@ -207,7 +207,7 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
native_step=PRECISION_TENTHS,
|
||||
native_min_value=0,
|
||||
native_max_value=10,
|
||||
native_max_value=30,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
set_value_fn=(
|
||||
lambda machine, value: machine.set_pre_extraction_times(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Support for LG TV running on NetCast 3 or 4."""
|
||||
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -133,13 +134,22 @@ class LgTVDevice(MediaPlayerEntity):
|
||||
|
||||
channel_list = client.query_data("channel_list")
|
||||
if channel_list:
|
||||
channel_names = []
|
||||
channel_pairs = []
|
||||
for channel in channel_list:
|
||||
channel_name = channel.find("chname")
|
||||
if channel_name is not None:
|
||||
channel_names.append(str(channel_name.text))
|
||||
self._sources = dict(zip(channel_names, channel_list, strict=False))
|
||||
# sort source names by the major channel number
|
||||
channel_pairs.append((str(channel_name.text), channel))
|
||||
|
||||
name_count = Counter(name for name, _ in channel_pairs)
|
||||
|
||||
self._sources = {}
|
||||
for name, channel in channel_pairs:
|
||||
if name_count[name] > 1:
|
||||
major = channel.find("major")
|
||||
if major is not None:
|
||||
name = f"{name} ({major.text})"
|
||||
self._sources[name] = channel
|
||||
|
||||
source_tuples = [
|
||||
(k, source.find("major").text)
|
||||
for k, source in self._sources.items()
|
||||
|
||||
@@ -51,7 +51,7 @@ class LinkPlayBaseEntity(Entity):
|
||||
)
|
||||
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
configuration_url=bridge.endpoint,
|
||||
configuration_url=str(bridge.endpoint),
|
||||
connections=connections,
|
||||
hw_version=bridge.device.properties["hardware"],
|
||||
identifiers={(DOMAIN, bridge.device.uuid)},
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
"invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details."
|
||||
},
|
||||
"step": {
|
||||
"import": {
|
||||
"import_ics_file": {
|
||||
"data": {
|
||||
"ics_file": "ICS file"
|
||||
},
|
||||
"description": "You can import events in iCal format (.ics file)."
|
||||
},
|
||||
"user": {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]"
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,11 +142,13 @@ def get_setpoint_status(status: str, time: str) -> str | None:
|
||||
return LYRIC_SETPOINT_STATUS_NAMES.get(status)
|
||||
|
||||
|
||||
def get_datetime_from_future_time(time_str: str) -> datetime:
|
||||
def get_datetime_from_future_time(time_str: str | None) -> datetime | None:
|
||||
"""Get datetime from future time provided."""
|
||||
if time_str is None:
|
||||
return None
|
||||
time = dt_util.parse_time(time_str)
|
||||
if time is None:
|
||||
raise ValueError(f"Unable to parse time {time_str}")
|
||||
return None
|
||||
now = dt_util.utcnow()
|
||||
if time <= now.time():
|
||||
now = now + timedelta(days=1)
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
"bluetooth_confirm": {
|
||||
"description": "Do you want to add the Melnor Bluetooth valve `{name}` to Home Assistant?",
|
||||
"title": "Discovered Melnor Bluetooth valve"
|
||||
},
|
||||
"pick_device": {
|
||||
"data": {
|
||||
"address": "[%key:common::config_flow::data::device%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"code": "Station code"
|
||||
"station_code": "Station code"
|
||||
},
|
||||
"data_description": {
|
||||
"code": "Looks like ESCAT4300000043206B"
|
||||
"station_code": "Looks like ESCAT4300000043206B"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,19 +508,20 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True):
|
||||
solar_save = 9, 34
|
||||
gentle = 10, 35, 210
|
||||
extra_quiet = 11, 36, 207
|
||||
hygiene = 12, 37
|
||||
quick_power_wash = 13, 38
|
||||
hygiene = 12, 37, 206
|
||||
quick_power_wash = 13, 38, 216
|
||||
pasta_paela = 14
|
||||
tall_items = 17, 42
|
||||
glasses_warm = 19
|
||||
quick_intense = 21
|
||||
normal = 23, 30
|
||||
normal = 23, 30, 217
|
||||
pre_wash = 24
|
||||
pot_rests_and_filters = 25
|
||||
power_wash = 44, 204
|
||||
comfort_wash = 203
|
||||
comfort_wash_plus = 209
|
||||
rinse_salt = 215
|
||||
rinse_and_hold = 219
|
||||
|
||||
|
||||
class TumbleDryerProgramId(MieleEnum, missing_to_none=True):
|
||||
|
||||
@@ -135,6 +135,8 @@ class MieleFan(MieleEntity, FanEntity):
|
||||
_LOGGER.debug("Calc ventilation_step: %s", ventilation_step)
|
||||
if ventilation_step == 0:
|
||||
await self.async_turn_off()
|
||||
elif ventilation_step == self.device.state_ventilation_step:
|
||||
return
|
||||
else:
|
||||
try:
|
||||
await self.api.send_action(
|
||||
|
||||
@@ -791,6 +791,7 @@
|
||||
"rice_pudding_rapid_steam_cooking": "Rice pudding (rapid steam cooking)",
|
||||
"rice_pudding_steam_cooking": "Rice pudding (steam cooking)",
|
||||
"rinse": "Rinse",
|
||||
"rinse_and_hold": "Rinse and hold",
|
||||
"rinse_out_lint": "Rinse out lint",
|
||||
"rinse_salt": "Rinse salt",
|
||||
"risotto": "Risotto",
|
||||
|
||||
@@ -14,9 +14,16 @@ from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionE
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DEFAULT_CONNECT_TIMEOUT, DEFAULT_RESPONSE_TIMEOUT, DOMAIN, PLATFORMS
|
||||
from .const import (
|
||||
CONF_ADDRESSES,
|
||||
DEFAULT_CONNECT_TIMEOUT,
|
||||
DEFAULT_RESPONSE_TIMEOUT,
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -25,13 +32,14 @@ _LOGGER = logging.getLogger(__name__)
|
||||
def _make_device(
|
||||
info: DeviceInfo,
|
||||
serial: str,
|
||||
address: str,
|
||||
session,
|
||||
) -> IndoorUnit | KumoStation:
|
||||
"""Create the appropriate device instance from DeviceInfo."""
|
||||
cls = IndoorUnit if info.is_indoor_unit else KumoStation
|
||||
return cls(
|
||||
name=info.label,
|
||||
address=info.address,
|
||||
address=address,
|
||||
password_b64=info.password,
|
||||
crypto_serial_hex=info.crypto_serial,
|
||||
serial=serial,
|
||||
@@ -64,12 +72,39 @@ async def async_setup_entry(
|
||||
translation_key="no_devices",
|
||||
)
|
||||
|
||||
# The cloud provides each device's MAC but never its LAN IP. Register every
|
||||
# device with its MAC so the manifest's "registered_devices" DHCP matcher
|
||||
# tracks it; DHCP discovery then supplies the IP via async_step_dhcp.
|
||||
device_registry = dr.async_get(hass)
|
||||
owned_macs = {dr.format_mac(info.mac) for info in devices.values()}
|
||||
for serial, info in devices.items():
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, serial)},
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(info.mac))},
|
||||
manufacturer="Mitsubishi",
|
||||
name=info.label,
|
||||
serial_number=serial,
|
||||
)
|
||||
|
||||
# Resolved IPs are stored keyed by MAC. Drop any for devices that are no
|
||||
# longer on the account.
|
||||
stored: dict[str, str] = entry.data.get(CONF_ADDRESSES, {})
|
||||
addresses = {mac: ip for mac, ip in stored.items() if mac in owned_macs}
|
||||
if addresses != stored:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data={**entry.data, CONF_ADDRESSES: addresses}
|
||||
)
|
||||
|
||||
coordinators: dict[str, MitsubishiComfortCoordinator] = {}
|
||||
for serial, info in devices.items():
|
||||
if not info.address or not info.password or not info.crypto_serial:
|
||||
_LOGGER.warning("Device %s missing credentials, skipping", info.label)
|
||||
address = addresses.get(dr.format_mac(info.mac))
|
||||
if not address or not info.password or not info.crypto_serial:
|
||||
# No LAN address yet: the device is registered, so DHCP discovery
|
||||
# supplies its IP and reloads the entry to add it.
|
||||
_LOGGER.debug("Device %s has no known LAN address yet", info.label)
|
||||
continue
|
||||
device = _make_device(info, serial, session)
|
||||
device = _make_device(info, serial, address, session)
|
||||
coordinators[serial] = MitsubishiComfortCoordinator(
|
||||
hass, entry, device, info.mac
|
||||
)
|
||||
|
||||
@@ -9,9 +9,11 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import CONF_ADDRESSES, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -71,3 +73,41 @@ class MitsubishiComfortConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=USER_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_dhcp(
|
||||
self, discovery_info: DhcpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a registered device discovered on the local network via DHCP.
|
||||
|
||||
The cloud API never returns a device's LAN IP, so DHCP discovery is the
|
||||
source of addresses. Each device is registered with its MAC during setup,
|
||||
so "registered_devices" discovery only fires for our own devices: record
|
||||
the IP on the owning entry and reload to set the device up or recover a
|
||||
changed IP.
|
||||
"""
|
||||
mac = dr.format_mac(discovery_info.macaddress)
|
||||
device = dr.async_get(self.hass).async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, mac)}
|
||||
)
|
||||
entry = next(
|
||||
(
|
||||
entry
|
||||
for entry in self._async_current_entries(include_ignore=False)
|
||||
if device is not None and entry.entry_id in device.config_entries
|
||||
),
|
||||
None,
|
||||
)
|
||||
if entry is None:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
addresses = entry.data.get(CONF_ADDRESSES, {})
|
||||
if addresses.get(mac) != discovery_info.ip:
|
||||
self.hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={
|
||||
**entry.data,
|
||||
CONF_ADDRESSES: {**addresses, mac: discovery_info.ip},
|
||||
},
|
||||
)
|
||||
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
@@ -7,6 +7,13 @@ from homeassistant.const import Platform
|
||||
|
||||
DOMAIN: Final = "mitsubishi_comfort"
|
||||
PLATFORMS: Final = [Platform.CLIMATE]
|
||||
|
||||
# Config entry data key holding the per-device LAN address cache, keyed by the
|
||||
# device's formatted MAC. The cloud API only returns each device's MAC, never
|
||||
# its LAN IP, so addresses are resolved from DHCP discovery and persisted here
|
||||
# to survive restarts without re-discovery.
|
||||
CONF_ADDRESSES: Final = "addresses"
|
||||
|
||||
DEFAULT_SCAN_INTERVAL = timedelta(seconds=60)
|
||||
DEFAULT_CONNECT_TIMEOUT: Final = 1.2
|
||||
DEFAULT_RESPONSE_TIMEOUT: Final = 8.0
|
||||
|
||||
@@ -42,12 +42,23 @@ class MitsubishiComfortCoordinator(DataUpdateCoordinator[IndoorUnit | KumoStatio
|
||||
try:
|
||||
success = await self.device.update_status()
|
||||
except Exception as err:
|
||||
# The user-facing UpdateFailed message is translated and omits the IP;
|
||||
# log it here so the failing address is visible in debug logs.
|
||||
_LOGGER.debug(
|
||||
"Error polling %s at %s: %s",
|
||||
self.device.name,
|
||||
self.device.address,
|
||||
err,
|
||||
)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={"device_name": self.device.name},
|
||||
) from err
|
||||
if not success:
|
||||
_LOGGER.debug(
|
||||
"%s at %s returned no data", self.device.name, self.device.address
|
||||
)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
"name": "Mitsubishi Comfort",
|
||||
"codeowners": ["@nikolairahimi"],
|
||||
"config_flow": true,
|
||||
"dhcp": [{ "registered_devices": true }],
|
||||
"documentation": "https://www.home-assistant.io/integrations/mitsubishi_comfort",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["mitsubishi-comfort==0.3.0"]
|
||||
"requirements": ["mitsubishi-comfort==0.3.1"]
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ rules:
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
dynamic-devices: todo
|
||||
discovery-update-info: todo
|
||||
discovery-update-info: done
|
||||
repair-issues: todo
|
||||
docs-use-cases: done
|
||||
docs-supported-devices: done
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
"device": "[%key:common::config_flow::data::device%]"
|
||||
},
|
||||
"description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller ID information with an option to reject an incoming call."
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"data": {
|
||||
"blind_type": "Blind type"
|
||||
},
|
||||
"description": "What kind of blind is {display_name}?"
|
||||
},
|
||||
"user": {
|
||||
|
||||
@@ -412,6 +412,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def _reload_config(call: ServiceCall) -> None:
|
||||
"""Reload the platforms."""
|
||||
if not mqtt_config_entry_enabled(hass):
|
||||
_LOGGER.debug(
|
||||
"Skipped reloading MQTT integration, "
|
||||
"the MQTT config entry is not enabled"
|
||||
)
|
||||
return
|
||||
entry: ConfigEntry = next(iter(hass.config_entries.async_entries(DOMAIN)))
|
||||
mqtt_data = hass.data[DATA_MQTT]
|
||||
|
||||
@@ -504,6 +510,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# Can be removed with HA Core 2027.1
|
||||
new_entry_data = entry.data.copy()
|
||||
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
|
||||
# Create temporary certificate files from entry
|
||||
await async_create_certificate_temp_files(hass, new_entry_data)
|
||||
# Try the connection with protocol version 5
|
||||
# And update the protocol if successful
|
||||
if await hass.async_add_executor_job(
|
||||
|
||||
@@ -2451,7 +2451,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
validator=valid_subscribe_topic,
|
||||
error="invalid_subscribe_topic",
|
||||
),
|
||||
CONF_VALUE_TEMPLATE: PlatformField(
|
||||
CONF_STATE_VALUE_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
@@ -3395,7 +3395,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
validator=valid_subscribe_topic,
|
||||
error="invalid_subscribe_topic",
|
||||
),
|
||||
CONF_VALUE_TEMPLATE: PlatformField(
|
||||
CONF_STATE_VALUE_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
@@ -4179,7 +4179,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_PROTOCOL: DEFAULT_PROTOCOL,
|
||||
CONF_USERNAME: addon_discovery_config.get(CONF_USERNAME),
|
||||
CONF_PASSWORD: addon_discovery_config.get(CONF_PASSWORD),
|
||||
CONF_DISCOVERY: DEFAULT_DISCOVERY,
|
||||
}
|
||||
except AddonError:
|
||||
# We do not have discovery information yet
|
||||
@@ -4420,7 +4419,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_PROTOCOL: DEFAULT_PROTOCOL,
|
||||
CONF_USERNAME: data.get(CONF_USERNAME),
|
||||
CONF_PASSWORD: data.get(CONF_PASSWORD),
|
||||
CONF_DISCOVERY: DEFAULT_DISCOVERY,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1432,9 +1432,10 @@ class MqttEntity(
|
||||
if (
|
||||
self._config[CONF_ENABLED_BY_DEFAULT]
|
||||
and deleted_entry
|
||||
and deleted_entry.disabled_by is not None
|
||||
and deleted_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
|
||||
):
|
||||
# Enable previous deleted entity and enable it
|
||||
# Enable previous deleted entity and enable it,
|
||||
# if it was not disabled by the user
|
||||
recreated_entry = entity_registry.async_get_or_create(
|
||||
entity_platform, DOMAIN, self.unique_id
|
||||
)
|
||||
|
||||
@@ -52,5 +52,5 @@ class OpenGarageEntity(CoordinatorEntity[OpenGarageDataUpdateCoordinator]):
|
||||
manufacturer="Open Garage",
|
||||
name=self.coordinator.data["name"],
|
||||
suggested_area="Garage",
|
||||
sw_version=self.coordinator.data["fwv"],
|
||||
sw_version=str(self.coordinator.data["fwv"]),
|
||||
)
|
||||
|
||||
@@ -159,11 +159,14 @@ class OpenThermGatewayHub:
|
||||
_LOGGER.debug("Received report: %s", status)
|
||||
async_dispatcher_send(self.hass, self.update_signal, status)
|
||||
|
||||
boiler_manufacturer = status[OpenThermDataSource.BOILER].get(
|
||||
gw_vars.DATA_SLAVE_MEMBERID
|
||||
)
|
||||
dev_reg.async_update_device(
|
||||
boiler_device.id,
|
||||
manufacturer=status[OpenThermDataSource.BOILER].get(
|
||||
gw_vars.DATA_SLAVE_MEMBERID
|
||||
),
|
||||
manufacturer=str(boiler_manufacturer)
|
||||
if boiler_manufacturer is not None
|
||||
else None,
|
||||
model_id=status[OpenThermDataSource.BOILER].get(
|
||||
gw_vars.DATA_SLAVE_PRODUCT_TYPE
|
||||
),
|
||||
@@ -175,11 +178,14 @@ class OpenThermGatewayHub:
|
||||
),
|
||||
)
|
||||
|
||||
thermostat_manufacturer = status[OpenThermDataSource.THERMOSTAT].get(
|
||||
gw_vars.DATA_MASTER_MEMBERID
|
||||
)
|
||||
dev_reg.async_update_device(
|
||||
thermostat_device.id,
|
||||
manufacturer=status[OpenThermDataSource.THERMOSTAT].get(
|
||||
gw_vars.DATA_MASTER_MEMBERID
|
||||
),
|
||||
manufacturer=str(thermostat_manufacturer)
|
||||
if thermostat_manufacturer is not None
|
||||
else None,
|
||||
model_id=status[OpenThermDataSource.THERMOSTAT].get(
|
||||
gw_vars.DATA_MASTER_PRODUCT_TYPE
|
||||
),
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["opower==0.18.2"]
|
||||
"requirements": ["opower==0.18.5"]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"step": {
|
||||
"confirm": {
|
||||
"data": {
|
||||
"code": "Verification code (OTP)"
|
||||
"code": "Verification code (OTP)",
|
||||
"qr_code": "QR code"
|
||||
},
|
||||
"data_description": {
|
||||
"code": "The six-digit code currently displayed in your authentication app."
|
||||
|
||||
@@ -565,7 +565,7 @@ class Person(
|
||||
self._latitude = coordinates.attributes.get(ATTR_LATITUDE)
|
||||
self._longitude = coordinates.attributes.get(ATTR_LONGITUDE)
|
||||
self._gps_accuracy = coordinates.attributes.get(ATTR_GPS_ACCURACY)
|
||||
self._in_zones = coordinates.attributes.get(ATTR_IN_ZONES, [])
|
||||
self._in_zones = state.attributes.get(ATTR_IN_ZONES, [])
|
||||
|
||||
@callback
|
||||
def _update_extra_state_attributes(self) -> None:
|
||||
|
||||
@@ -45,8 +45,8 @@ USER_SCHEMA = vol.Schema(
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_API_VERSION,
|
||||
default=1,
|
||||
): vol.In([1, 5, 6]),
|
||||
default="1",
|
||||
): vol.In(["1", "5", "6"]),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -223,7 +223,7 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._current = user_input
|
||||
try:
|
||||
await self._async_attempt_prepare(
|
||||
user_input[CONF_HOST], user_input[CONF_API_VERSION], False
|
||||
user_input[CONF_HOST], int(user_input[CONF_API_VERSION]), False
|
||||
)
|
||||
except GeneralFailure as exc:
|
||||
LOGGER.error(exc)
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"update_interval": "Update interval (minutes)"
|
||||
"scan_interval": "Update interval (minutes)"
|
||||
},
|
||||
"description": "Set the update interval (minutes)",
|
||||
"title": "Options for Plaato"
|
||||
|
||||
@@ -155,9 +155,9 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float:
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
return self.device["sensors"]["temperature"]
|
||||
return self.device["sensors"].get("temperature")
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float:
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["plugwise"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["plugwise==1.11.3"],
|
||||
"requirements": ["plugwise==1.11.4"],
|
||||
"zeroconf": ["_plugwise._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) ->
|
||||
api_url=entry.data[CONF_URL],
|
||||
api_key=entry.data[CONF_API_TOKEN],
|
||||
session=session,
|
||||
request_timeout=60,
|
||||
request_timeout=120,
|
||||
max_retries=API_MAX_RETRIES,
|
||||
)
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyrainbird"],
|
||||
"requirements": ["pyrainbird==6.3.0"]
|
||||
"requirements": ["pyrainbird==6.3.1"]
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ class RainMachineEntity(CoordinatorEntity[RainMachineDataUpdateCoordinator]):
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, self._data.controller.mac)},
|
||||
name=self._data.controller.name.capitalize(),
|
||||
manufacturer="RainMachine",
|
||||
hw_version=self._version_coordinator.data["hwVer"],
|
||||
hw_version=str(self._version_coordinator.data["hwVer"]),
|
||||
sw_version=f"{self._version_coordinator.data['swVer']} "
|
||||
f"(API: {self._version_coordinator.data['apiVer']})",
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["renault_api"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["renault-api==0.5.11"]
|
||||
"requirements": ["renault-api==0.5.12"]
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ async def async_get_config_entry_diagnostics(
|
||||
"HTTP(S) port": api.port,
|
||||
"Baichuan port": api.baichuan.port,
|
||||
"Baichuan only": api.baichuan_only,
|
||||
"Baichuan connection": api.baichuan.connection_type.value,
|
||||
"WiFi connection": api.wifi_connection(),
|
||||
"WiFi signal": api.wifi_signal(),
|
||||
"RTMP enabled": api.rtmp_enabled,
|
||||
@@ -48,10 +49,15 @@ async def async_get_config_entry_diagnostics(
|
||||
"ONVIF enabled": api.onvif_enabled,
|
||||
"event connection": host.event_connection,
|
||||
"stream protocol": api.protocol,
|
||||
"is NVR": api.is_nvr,
|
||||
"is Hub": api.is_hub,
|
||||
"is Battery": api.is_battery,
|
||||
"channels": api.channels,
|
||||
"stream channels": api.stream_channels,
|
||||
"IPC cams": ipc_cam,
|
||||
"Chimes": chimes,
|
||||
"Broken cmds": api.broken_cmds,
|
||||
"Baichuan fallbacks": api.baichuan_cmds,
|
||||
"capabilities": api.capabilities,
|
||||
"cmd list": host.update_cmd,
|
||||
"firmware ch list": host.firmware_ch_list,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user