forked from home-assistant/core
Compare commits
333 Commits
tibber_031
...
zha_string
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22be0eccdb | ||
|
|
618ada64f8 | ||
|
|
2d6802e06a | ||
|
|
9687a34a70 | ||
|
|
5ba0ceb6c2 | ||
|
|
d8e3e88c63 | ||
|
|
d1d1bca29d | ||
|
|
80189495c5 | ||
|
|
cad6c72cfa | ||
|
|
23ac22e213 | ||
|
|
55e664fc0d | ||
|
|
881ce45afa | ||
|
|
b80195df81 | ||
|
|
e57ce0a9df | ||
|
|
ff66ad7705 | ||
|
|
33e98ebffa | ||
|
|
8fd9e2046e | ||
|
|
32c2f47ab5 | ||
|
|
e2fc2dce84 | ||
|
|
afa97f8ec1 | ||
|
|
2708c1c94c | ||
|
|
d76ed6a3c2 | ||
|
|
695f69bd90 | ||
|
|
7da8e24e21 | ||
|
|
9d0fc0d513 | ||
|
|
ca567aa7fc | ||
|
|
27af2d8ec6 | ||
|
|
59ea6f375a | ||
|
|
6c365c94ed | ||
|
|
6693fc764f | ||
|
|
e855b6c2bc | ||
|
|
23a1dddc23 | ||
|
|
bd5fef1ddb | ||
|
|
c3ade400fb | ||
|
|
1889f0ef66 | ||
|
|
6b28af8282 | ||
|
|
f59001d45f | ||
|
|
a857461059 | ||
|
|
e4cc842584 | ||
|
|
bb52058920 | ||
|
|
c1676570da | ||
|
|
4858b2171e | ||
|
|
192aa76cd7 | ||
|
|
ddf611bfdf | ||
|
|
3164394982 | ||
|
|
b250a03ff5 | ||
|
|
2dd7f035f6 | ||
|
|
2c08b3f30c | ||
|
|
c3ec30ce3b | ||
|
|
9d4375ca76 | ||
|
|
3870b87db9 | ||
|
|
ff2fd7e9ef | ||
|
|
719dd09eb3 | ||
|
|
2cf2613dbd | ||
|
|
181a3d142e | ||
|
|
c20ad5fde1 | ||
|
|
4fcebf18dc | ||
|
|
a6e04be076 | ||
|
|
330a8e197d | ||
|
|
4300e846e6 | ||
|
|
07fd1f99df | ||
|
|
481639bcf9 | ||
|
|
376008940b | ||
|
|
b2c2db3394 | ||
|
|
a636e38d24 | ||
|
|
ae1294830c | ||
|
|
d87fdf028b | ||
|
|
6f5d5d4cdb | ||
|
|
12fdd7034a | ||
|
|
f295d72cd9 | ||
|
|
2605fda185 | ||
|
|
2189dc3e2a | ||
|
|
8364d8a2e3 | ||
|
|
96c9636086 | ||
|
|
7b1dfc35d1 | ||
|
|
2e94730491 | ||
|
|
11c6998bf2 | ||
|
|
055a024d10 | ||
|
|
f73afd71fd | ||
|
|
ec64194ab9 | ||
|
|
d49a613c62 | ||
|
|
6fc064fa6a | ||
|
|
b36b591ccf | ||
|
|
d25ba79427 | ||
|
|
df35f30321 | ||
|
|
1e3d06a993 | ||
|
|
2ee6bf7340 | ||
|
|
13a8e5e021 | ||
|
|
9a73006681 | ||
|
|
4aade14c9e | ||
|
|
8abbd35c54 | ||
|
|
34f92d584b | ||
|
|
a7919c5ce7 | ||
|
|
405725f8ee | ||
|
|
393ea0251b | ||
|
|
cdd3ce428f | ||
|
|
b17d62177c | ||
|
|
16394061cb | ||
|
|
b1403838bb | ||
|
|
e857db281f | ||
|
|
5f63612b66 | ||
|
|
987af8f7df | ||
|
|
0ab7d46d7c | ||
|
|
072d0dc567 | ||
|
|
9b9d4d7dab | ||
|
|
84305563ab | ||
|
|
db489a5069 | ||
|
|
2ef0a8557f | ||
|
|
001164ce1b | ||
|
|
848eb797e0 | ||
|
|
fd4dafaac5 | ||
|
|
0b6ea36e24 | ||
|
|
b667fb2728 | ||
|
|
2dc2b0ffac | ||
|
|
d6375a79a1 | ||
|
|
c36f8c38ae | ||
|
|
c4485c1814 | ||
|
|
e2a916ff9d | ||
|
|
a2b02537a6 | ||
|
|
b8a96d2a76 | ||
|
|
670e8dd434 | ||
|
|
27b0488f05 | ||
|
|
6003f3d135 | ||
|
|
c3dec7fb2f | ||
|
|
cfa4d37909 | ||
|
|
8ce3ead782 | ||
|
|
b626204f63 | ||
|
|
b15989f2bf | ||
|
|
eec7666416 | ||
|
|
5ea6811d01 | ||
|
|
4e1d5fbeb0 | ||
|
|
bf92db6fd5 | ||
|
|
03a26836ed | ||
|
|
99ebac5452 | ||
|
|
01ea58eb9b | ||
|
|
039383ab22 | ||
|
|
8fb4f1f7f9 | ||
|
|
15a7d13768 | ||
|
|
51562e5ab4 | ||
|
|
8623d96deb | ||
|
|
3dc7b75e4b | ||
|
|
c14d17f88c | ||
|
|
b7ce0f63a9 | ||
|
|
c2a5e1aaf9 | ||
|
|
13d7234f97 | ||
|
|
ca50fca738 | ||
|
|
acbfe54c7b | ||
|
|
49f9166646 | ||
|
|
42cacd28e7 | ||
|
|
6f9a39ab89 | ||
|
|
0d81694640 | ||
|
|
109bcf362a | ||
|
|
0260a03447 | ||
|
|
0802fc8a21 | ||
|
|
c346b932f0 | ||
|
|
a14f3ab6b1 | ||
|
|
14cd00a116 | ||
|
|
486535c189 | ||
|
|
54dce53628 | ||
|
|
a3b7cd7b4d | ||
|
|
dafda420e5 | ||
|
|
68a4e1a112 | ||
|
|
d3275c3833 | ||
|
|
2d5867cab6 | ||
|
|
49cf66269c | ||
|
|
5642d6450f | ||
|
|
6ddc2193d6 | ||
|
|
5202bbb6af | ||
|
|
39906cf65b | ||
|
|
c7745e0d02 | ||
|
|
1c1f5a779b | ||
|
|
ba0f6c3ba2 | ||
|
|
150110e221 | ||
|
|
e95e9e1a33 | ||
|
|
c68ab714b7 | ||
|
|
2d2e0d0fb9 | ||
|
|
970359c6a0 | ||
|
|
e22fbe553b | ||
|
|
87c3e2c7ce | ||
|
|
13a6c13b89 | ||
|
|
cc504da03a | ||
|
|
25f3ab3640 | ||
|
|
2cf09abb4c | ||
|
|
c1c74a6f61 | ||
|
|
8f9f531dd7 | ||
|
|
34d11521c0 | ||
|
|
561be22a60 | ||
|
|
301d308d5a | ||
|
|
d975135a7c | ||
|
|
19ee8886d6 | ||
|
|
7f4cc99a3e | ||
|
|
d4333665fc | ||
|
|
ba0c03ddbb | ||
|
|
32eb4af6ef | ||
|
|
e4b519d77a | ||
|
|
14c4cf7b63 | ||
|
|
1cc2baa95e | ||
|
|
f472bf7c87 | ||
|
|
bc9683312e | ||
|
|
6634efa3aa | ||
|
|
d0b2331a5f | ||
|
|
46951bf223 | ||
|
|
565f051ffc | ||
|
|
8c971904ca | ||
|
|
d0bc71752b | ||
|
|
6b1484a7f0 | ||
|
|
5eebadc730 | ||
|
|
fa37bc272e | ||
|
|
535d128f8a | ||
|
|
13d530d110 | ||
|
|
57f754b42b | ||
|
|
1e0a2b704f | ||
|
|
526a8ee31f | ||
|
|
ce02a5544d | ||
|
|
1044a5341d | ||
|
|
a707cbc51b | ||
|
|
adf8e50313 | ||
|
|
8356bdb506 | ||
|
|
5c7aa833ec | ||
|
|
f92d14d87c | ||
|
|
2d3a6d780c | ||
|
|
3e6473d130 | ||
|
|
9a183bc16a | ||
|
|
e540247c14 | ||
|
|
0aef8b58d8 | ||
|
|
f0501f917b | ||
|
|
97004e13cb | ||
|
|
f867a0af24 | ||
|
|
d3b3839ffa | ||
|
|
1a227d6a10 | ||
|
|
fc8c403a3a | ||
|
|
c1bf596eba | ||
|
|
63f69a9e3d | ||
|
|
e13b014b6f | ||
|
|
be0d4d926c | ||
|
|
2403fff81f | ||
|
|
8c475787cc | ||
|
|
d9fe1edd82 | ||
|
|
f5cf64700a | ||
|
|
777b04d7a5 | ||
|
|
9fc78ed4e2 | ||
|
|
d03af549d4 | ||
|
|
d91f01243c | ||
|
|
5094208db6 | ||
|
|
006f66a841 | ||
|
|
64b7d77840 | ||
|
|
abf6a809b8 | ||
|
|
1b7dd205c7 | ||
|
|
3e00366a61 | ||
|
|
a17275b559 | ||
|
|
9534a919ce | ||
|
|
422dbfef88 | ||
|
|
8e44684a61 | ||
|
|
642e7fd487 | ||
|
|
9bb9132e7b | ||
|
|
41be82f167 | ||
|
|
47140e14d9 | ||
|
|
926502b0f1 | ||
|
|
78351ff7a7 | ||
|
|
c333726867 | ||
|
|
f66feabaaf | ||
|
|
0ef098a9f3 | ||
|
|
02b028add3 | ||
|
|
34455f9743 | ||
|
|
8c4eec231f | ||
|
|
621a14d7cc | ||
|
|
4906e78a5c | ||
|
|
146e440d59 | ||
|
|
e2ede3ed19 | ||
|
|
b76ac68fb1 | ||
|
|
0691ad9362 | ||
|
|
715f116954 | ||
|
|
9f0db98745 | ||
|
|
0ba55c31e8 | ||
|
|
19b7cfbd4a | ||
|
|
a9520888cf | ||
|
|
f086f4a955 | ||
|
|
a657964c25 | ||
|
|
543104b36c | ||
|
|
bf1d2069e4 | ||
|
|
e5e1c9fb05 | ||
|
|
4c4be88323 | ||
|
|
5a83627dc5 | ||
|
|
3123a7b168 | ||
|
|
8161ce6ea8 | ||
|
|
d9cbd1b65f | ||
|
|
b7c07209b8 | ||
|
|
6c3a4f17f0 | ||
|
|
d82feb807f | ||
|
|
c373fa9296 | ||
|
|
139b48440f | ||
|
|
9de1d3b143 | ||
|
|
b69ebdaecb | ||
|
|
f25e50b017 | ||
|
|
a4a7601f9f | ||
|
|
41a503f76f | ||
|
|
f1a3d62db2 | ||
|
|
e465276464 | ||
|
|
47b45444eb | ||
|
|
cf0911cc56 | ||
|
|
da79d5b2e3 | ||
|
|
358b0c1c17 | ||
|
|
543348fe58 | ||
|
|
0635856761 | ||
|
|
081afe6034 | ||
|
|
ca14322227 | ||
|
|
a54816a6e5 | ||
|
|
27db4e90b5 | ||
|
|
e9cc624d93 | ||
|
|
5a95f43992 | ||
|
|
36a35132c0 | ||
|
|
2fbc75f89b | ||
|
|
48aa6be889 | ||
|
|
bde04bc47b | ||
|
|
7d163aa659 | ||
|
|
010b044379 | ||
|
|
00627b82e0 | ||
|
|
13aba6201e | ||
|
|
f392e0c1c7 | ||
|
|
181eca6c82 | ||
|
|
196d923ac6 | ||
|
|
4ad387c967 | ||
|
|
cb475bf153 | ||
|
|
47acceea08 | ||
|
|
fd6fb7e3bc | ||
|
|
30f7e9b441 | ||
|
|
a8beec2691 | ||
|
|
23244fb79f | ||
|
|
e5c56629e2 | ||
|
|
a793503c8a | ||
|
|
054c7a0adc | ||
|
|
6eb2d1aa7c | ||
|
|
619fdea5df |
4
.github/workflows/builder.yml
vendored
4
.github/workflows/builder.yml
vendored
@@ -509,7 +509,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -522,7 +522,7 @@ jobs:
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 2
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2025.6"
|
||||
HA_SHORT_VERSION: "2025.7"
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
ALL_PYTHON_VERSIONS: "['3.13']"
|
||||
# 10.3 is the oldest supported version
|
||||
|
||||
@@ -66,6 +66,7 @@ homeassistant.components.alarm_control_panel.*
|
||||
homeassistant.components.alert.*
|
||||
homeassistant.components.alexa.*
|
||||
homeassistant.components.alpha_vantage.*
|
||||
homeassistant.components.amazon_devices.*
|
||||
homeassistant.components.amazon_polly.*
|
||||
homeassistant.components.amberelectric.*
|
||||
homeassistant.components.ambient_network.*
|
||||
|
||||
5
CODEOWNERS
generated
5
CODEOWNERS
generated
@@ -89,6 +89,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/alert/ @home-assistant/core @frenck
|
||||
/homeassistant/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
|
||||
/tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
|
||||
/homeassistant/components/amazon_devices/ @chemelli74
|
||||
/tests/components/amazon_devices/ @chemelli74
|
||||
/homeassistant/components/amazon_polly/ @jschlyter
|
||||
/homeassistant/components/amberelectric/ @madpilot
|
||||
/tests/components/amberelectric/ @madpilot
|
||||
@@ -303,6 +305,7 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/crownstone/ @Crownstone @RicArch97
|
||||
/tests/components/crownstone/ @Crownstone @RicArch97
|
||||
/homeassistant/components/cups/ @fabaff
|
||||
/tests/components/cups/ @fabaff
|
||||
/homeassistant/components/daikin/ @fredrike
|
||||
/tests/components/daikin/ @fredrike
|
||||
/homeassistant/components/date/ @home-assistant/core
|
||||
@@ -1417,6 +1420,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/sma/ @kellerza @rklomp @erwindouna
|
||||
/homeassistant/components/smappee/ @bsmappee
|
||||
/tests/components/smappee/ @bsmappee
|
||||
/homeassistant/components/smarla/ @explicatis @rlint-explicatis
|
||||
/tests/components/smarla/ @explicatis @rlint-explicatis
|
||||
/homeassistant/components/smart_meter_texas/ @grahamwetzler
|
||||
/tests/components/smart_meter_texas/ @grahamwetzler
|
||||
/homeassistant/components/smartthings/ @joostlek
|
||||
|
||||
@@ -171,8 +171,6 @@ FRONTEND_INTEGRATIONS = {
|
||||
# Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout.
|
||||
# The substage containing recorder should have no timeout, as it could cancel a database migration.
|
||||
# Recorder freezes "recorder" timeout during a migration, but it does not freeze other timeouts.
|
||||
# The substages preceding it should also have no timeout, until we ensure that the recorder
|
||||
# is not accidentally promoted as a dependency of any of the integrations in them.
|
||||
# If we add timeouts to the frontend substages, we should make sure they don't apply in recovery mode.
|
||||
STAGE_0_INTEGRATIONS = (
|
||||
# Load logging and http deps as soon as possible
|
||||
@@ -929,7 +927,11 @@ async def _async_set_up_integrations(
|
||||
await _async_setup_multi_components(hass, stage_all_domains, config)
|
||||
continue
|
||||
try:
|
||||
async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME):
|
||||
async with hass.timeout.async_timeout(
|
||||
timeout,
|
||||
cool_down=COOLDOWN_TIME,
|
||||
cancel_message=f"Bootstrap stage {name} timeout",
|
||||
):
|
||||
await _async_setup_multi_components(hass, stage_all_domains, config)
|
||||
except TimeoutError:
|
||||
_LOGGER.warning(
|
||||
@@ -941,7 +943,11 @@ async def _async_set_up_integrations(
|
||||
# Wrap up startup
|
||||
_LOGGER.debug("Waiting for startup to wrap up")
|
||||
try:
|
||||
async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME):
|
||||
async with hass.timeout.async_timeout(
|
||||
WRAP_UP_TIMEOUT,
|
||||
cool_down=COOLDOWN_TIME,
|
||||
cancel_message="Bootstrap startup wrap up timeout",
|
||||
):
|
||||
await hass.async_block_till_done()
|
||||
except TimeoutError:
|
||||
_LOGGER.warning(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Amazon",
|
||||
"integrations": [
|
||||
"alexa",
|
||||
"amazon_devices",
|
||||
"amazon_polly",
|
||||
"aws",
|
||||
"aws_s3",
|
||||
|
||||
6
homeassistant/brands/shelly.json
Normal file
6
homeassistant/brands/shelly.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "shelly",
|
||||
"name": "shelly",
|
||||
"integrations": ["shelly"],
|
||||
"iot_standards": ["zwave"]
|
||||
}
|
||||
@@ -40,9 +40,10 @@ class AcmedaFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
entry.unique_id for entry in self._async_current_entries()
|
||||
}
|
||||
|
||||
hubs: list[aiopulse.Hub] = []
|
||||
with suppress(TimeoutError):
|
||||
async with timeout(5):
|
||||
hubs: list[aiopulse.Hub] = [
|
||||
hubs = [
|
||||
hub
|
||||
async for hub in aiopulse.Hub.discover()
|
||||
if hub.id not in already_configured
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import (
|
||||
)
|
||||
|
||||
from . import AgentDVRConfigEntry
|
||||
from .const import ATTRIBUTION, CAMERA_SCAN_INTERVAL_SECS, DOMAIN as AGENT_DOMAIN
|
||||
from .const import ATTRIBUTION, CAMERA_SCAN_INTERVAL_SECS, DOMAIN
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=CAMERA_SCAN_INTERVAL_SECS)
|
||||
|
||||
@@ -82,7 +82,7 @@ class AgentCamera(MjpegCamera):
|
||||
still_image_url=f"{device.client._server_url}{device.still_image_url}&size={device.mjpegStreamWidth}x{device.mjpegStreamHeight}", # noqa: SLF001
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(AGENT_DOMAIN, self.unique_id)},
|
||||
identifiers={(DOMAIN, self.unique_id)},
|
||||
manufacturer="Agent",
|
||||
model="Camera",
|
||||
name=f"{device.client.name} {device.name}",
|
||||
|
||||
@@ -5,23 +5,22 @@ from __future__ import annotations
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from airthings import Airthings, AirthingsDevice, AirthingsError
|
||||
from airthings import Airthings
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_SECRET, DOMAIN
|
||||
from .const import CONF_SECRET
|
||||
from .coordinator import AirthingsDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
SCAN_INTERVAL = timedelta(minutes=6)
|
||||
|
||||
type AirthingsDataCoordinatorType = DataUpdateCoordinator[dict[str, AirthingsDevice]]
|
||||
type AirthingsConfigEntry = ConfigEntry[AirthingsDataCoordinatorType]
|
||||
type AirthingsConfigEntry = ConfigEntry[AirthingsDataUpdateCoordinator]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) -> bool:
|
||||
@@ -32,21 +31,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) ->
|
||||
async_get_clientsession(hass),
|
||||
)
|
||||
|
||||
async def _update_method() -> dict[str, AirthingsDevice]:
|
||||
"""Get the latest data from Airthings."""
|
||||
try:
|
||||
return await airthings.update_devices() # type: ignore[no-any-return]
|
||||
except AirthingsError as err:
|
||||
raise UpdateFailed(f"Unable to fetch data: {err}") from err
|
||||
coordinator = AirthingsDataUpdateCoordinator(hass, airthings)
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_method=_update_method,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
36
homeassistant/components/airthings/coordinator.py
Normal file
36
homeassistant/components/airthings/coordinator.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""The Airthings integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from airthings import Airthings, AirthingsDevice, AirthingsError
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
SCAN_INTERVAL = timedelta(minutes=6)
|
||||
|
||||
|
||||
class AirthingsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AirthingsDevice]]):
|
||||
"""Coordinator for Airthings data updates."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, airthings: Airthings) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_method=self._update_method,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self.airthings = airthings
|
||||
|
||||
async def _update_method(self) -> dict[str, AirthingsDevice]:
|
||||
"""Get the latest data from Airthings."""
|
||||
try:
|
||||
return await self.airthings.update_devices() # type: ignore[no-any-return]
|
||||
except AirthingsError as err:
|
||||
raise UpdateFailed(f"Unable to fetch data: {err}") from err
|
||||
@@ -27,8 +27,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import AirthingsConfigEntry, AirthingsDataCoordinatorType
|
||||
from . import AirthingsConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AirthingsDataUpdateCoordinator
|
||||
|
||||
SENSORS: dict[str, SensorEntityDescription] = {
|
||||
"radonShortTermAvg": SensorEntityDescription(
|
||||
@@ -140,7 +141,7 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class AirthingsHeaterEnergySensor(
|
||||
CoordinatorEntity[AirthingsDataCoordinatorType], SensorEntity
|
||||
CoordinatorEntity[AirthingsDataUpdateCoordinator], SensorEntity
|
||||
):
|
||||
"""Representation of a Airthings Sensor device."""
|
||||
|
||||
@@ -149,7 +150,7 @@ class AirthingsHeaterEnergySensor(
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirthingsDataCoordinatorType,
|
||||
coordinator: AirthingsDataUpdateCoordinator,
|
||||
airthings_device: AirthingsDevice,
|
||||
entity_description: SensorEntityDescription,
|
||||
) -> None:
|
||||
|
||||
32
homeassistant/components/amazon_devices/__init__.py
Normal file
32
homeassistant/components/amazon_devices/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Amazon Devices integration."""
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.NOTIFY,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
||||
"""Set up Amazon Devices platform."""
|
||||
|
||||
coordinator = AmazonDevicesCoordinator(hass, entry)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
await entry.runtime_data.api.close()
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
71
homeassistant/components/amazon_devices/binary_sensor.py
Normal file
71
homeassistant/components/amazon_devices/binary_sensor.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Support for binary sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from aioamazondevices.api import AmazonDevice
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .entity import AmazonEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Amazon Devices binary sensor entity description."""
|
||||
|
||||
is_on_fn: Callable[[AmazonDevice], bool]
|
||||
|
||||
|
||||
BINARY_SENSORS: Final = (
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="online",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
is_on_fn=lambda _device: _device.online,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="bluetooth",
|
||||
translation_key="bluetooth",
|
||||
is_on_fn=lambda _device: _device.bluetooth_state,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AmazonConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Amazon Devices binary sensors based on a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in BINARY_SENSORS
|
||||
for serial_num in coordinator.data
|
||||
)
|
||||
|
||||
|
||||
class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
|
||||
"""Binary sensor device."""
|
||||
|
||||
entity_description: AmazonBinarySensorEntityDescription
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if the binary sensor is on."""
|
||||
return self.entity_description.is_on_fn(self.device)
|
||||
63
homeassistant/components/amazon_devices/config_flow.py
Normal file
63
homeassistant/components/amazon_devices/config_flow.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Config flow for Amazon Devices integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aioamazondevices.api import AmazonEchoApi
|
||||
from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.selector import CountrySelector
|
||||
|
||||
from .const import CONF_LOGIN_DATA, DOMAIN
|
||||
|
||||
|
||||
class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Amazon Devices."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
if user_input:
|
||||
client = AmazonEchoApi(
|
||||
user_input[CONF_COUNTRY],
|
||||
user_input[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
)
|
||||
try:
|
||||
data = await client.login_mode_interactive(user_input[CONF_CODE])
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except CannotAuthenticate:
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
await self.async_set_unique_id(data["customer_info"]["user_id"])
|
||||
self._abort_if_unique_id_configured()
|
||||
user_input.pop(CONF_CODE)
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_USERNAME],
|
||||
data=user_input | {CONF_LOGIN_DATA: data},
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
errors=errors,
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_COUNTRY, default=self.hass.config.country
|
||||
): CountrySelector(),
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Required(CONF_CODE): cv.string,
|
||||
}
|
||||
),
|
||||
)
|
||||
8
homeassistant/components/amazon_devices/const.py
Normal file
8
homeassistant/components/amazon_devices/const.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Amazon Devices constants."""
|
||||
|
||||
import logging
|
||||
|
||||
_LOGGER = logging.getLogger(__package__)
|
||||
|
||||
DOMAIN = "amazon_devices"
|
||||
CONF_LOGIN_DATA = "login_data"
|
||||
58
homeassistant/components/amazon_devices/coordinator.py
Normal file
58
homeassistant/components/amazon_devices/coordinator.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Support for Amazon Devices."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
|
||||
from aioamazondevices.exceptions import (
|
||||
CannotAuthenticate,
|
||||
CannotConnect,
|
||||
CannotRetrieveData,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import _LOGGER, CONF_LOGIN_DATA
|
||||
|
||||
SCAN_INTERVAL = 30
|
||||
|
||||
type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator]
|
||||
|
||||
|
||||
class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
"""Base coordinator for Amazon Devices."""
|
||||
|
||||
config_entry: AmazonConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: AmazonConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the scanner."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=entry.title,
|
||||
config_entry=entry,
|
||||
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
||||
)
|
||||
self.api = AmazonEchoApi(
|
||||
entry.data[CONF_COUNTRY],
|
||||
entry.data[CONF_USERNAME],
|
||||
entry.data[CONF_PASSWORD],
|
||||
entry.data[CONF_LOGIN_DATA],
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, AmazonDevice]:
|
||||
"""Update device data."""
|
||||
try:
|
||||
await self.api.login_mode_stored_data()
|
||||
return await self.api.get_devices_data()
|
||||
except (CannotConnect, CannotRetrieveData) as err:
|
||||
raise UpdateFailed(f"Error occurred while updating {self.name}") from err
|
||||
except CannotAuthenticate as err:
|
||||
raise ConfigEntryError("Could not authenticate") from err
|
||||
57
homeassistant/components/amazon_devices/entity.py
Normal file
57
homeassistant/components/amazon_devices/entity.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Defines a base Amazon Devices entity."""
|
||||
|
||||
from aioamazondevices.api import AmazonDevice
|
||||
from aioamazondevices.const import SPEAKER_GROUP_MODEL
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AmazonDevicesCoordinator
|
||||
|
||||
|
||||
class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]):
|
||||
"""Defines a base Amazon Devices entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
serial_num: str,
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._serial_num = serial_num
|
||||
model_details = coordinator.api.get_model_details(self.device)
|
||||
model = model_details["model"] if model_details else None
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial_num)},
|
||||
name=self.device.account_name,
|
||||
model=model,
|
||||
model_id=self.device.device_type,
|
||||
manufacturer="Amazon",
|
||||
hw_version=model_details["hw_version"] if model_details else None,
|
||||
sw_version=(
|
||||
self.device.software_version if model != SPEAKER_GROUP_MODEL else None
|
||||
),
|
||||
serial_number=serial_num if model != SPEAKER_GROUP_MODEL else None,
|
||||
)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{serial_num}-{description.key}"
|
||||
|
||||
@property
|
||||
def device(self) -> AmazonDevice:
|
||||
"""Return the device."""
|
||||
return self.coordinator.data[self._serial_num]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self._serial_num in self.coordinator.data
|
||||
and self.device.online
|
||||
)
|
||||
12
homeassistant/components/amazon_devices/icons.json
Normal file
12
homeassistant/components/amazon_devices/icons.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"bluetooth": {
|
||||
"default": "mdi:bluetooth",
|
||||
"state": {
|
||||
"off": "mdi:bluetooth-off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
homeassistant/components/amazon_devices/manifest.json
Normal file
35
homeassistant/components/amazon_devices/manifest.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"domain": "amazon_devices",
|
||||
"name": "Amazon Devices",
|
||||
"codeowners": ["@chemelli74"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{ "macaddress": "08A6BC*" },
|
||||
{ "macaddress": "10BF67*" },
|
||||
{ "macaddress": "440049*" },
|
||||
{ "macaddress": "443D54*" },
|
||||
{ "macaddress": "48B423*" },
|
||||
{ "macaddress": "4C1744*" },
|
||||
{ "macaddress": "50D45C*" },
|
||||
{ "macaddress": "50DCE7*" },
|
||||
{ "macaddress": "68F63B*" },
|
||||
{ "macaddress": "6C0C9A*" },
|
||||
{ "macaddress": "74D637*" },
|
||||
{ "macaddress": "7C6166*" },
|
||||
{ "macaddress": "901195*" },
|
||||
{ "macaddress": "943A91*" },
|
||||
{ "macaddress": "98226E*" },
|
||||
{ "macaddress": "9CC8E9*" },
|
||||
{ "macaddress": "A8E621*" },
|
||||
{ "macaddress": "C095CF*" },
|
||||
{ "macaddress": "D8BE65*" },
|
||||
{ "macaddress": "EC2BEB*" },
|
||||
{ "macaddress": "F02F9E*" }
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/amazon_devices",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aioamazondevices==2.1.1"]
|
||||
}
|
||||
74
homeassistant/components/amazon_devices/notify.py
Normal file
74
homeassistant/components/amazon_devices/notify.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Support for notification entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Final
|
||||
|
||||
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
|
||||
|
||||
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .entity import AmazonEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmazonNotifyEntityDescription(NotifyEntityDescription):
|
||||
"""Amazon Devices notify entity description."""
|
||||
|
||||
method: Callable[[AmazonEchoApi, AmazonDevice, str], Awaitable[None]]
|
||||
subkey: str
|
||||
|
||||
|
||||
NOTIFY: Final = (
|
||||
AmazonNotifyEntityDescription(
|
||||
key="speak",
|
||||
translation_key="speak",
|
||||
subkey="AUDIO_PLAYER",
|
||||
method=lambda api, device, message: api.call_alexa_speak(device, message),
|
||||
),
|
||||
AmazonNotifyEntityDescription(
|
||||
key="announce",
|
||||
translation_key="announce",
|
||||
subkey="AUDIO_PLAYER",
|
||||
method=lambda api, device, message: api.call_alexa_announcement(
|
||||
device, message
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AmazonConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Amazon Devices notification entity based on a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AmazonNotifyEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in NOTIFY
|
||||
for serial_num in coordinator.data
|
||||
if sensor_desc.subkey in coordinator.data[serial_num].capabilities
|
||||
)
|
||||
|
||||
|
||||
class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
|
||||
"""Binary sensor notify platform."""
|
||||
|
||||
entity_description: AmazonNotifyEntityDescription
|
||||
|
||||
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)
|
||||
74
homeassistant/components/amazon_devices/quality_scale.yaml
Normal file
74
homeassistant/components/amazon_devices/quality_scale.yaml
Normal file
@@ -0,0 +1,74 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: no actions
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: no actions
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: entities do not explicitly subscribe to events
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: all tests missing
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Network information not relevant
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: no known use cases for repair issues or flows, yet
|
||||
stale-devices:
|
||||
status: todo
|
||||
comment: automate the cleanup process
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: todo
|
||||
strict-typing: done
|
||||
60
homeassistant/components/amazon_devices/strings.json
Normal file
60
homeassistant/components/amazon_devices/strings.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"common": {
|
||||
"data_country": "Country code",
|
||||
"data_code": "One-time password (OTP code)",
|
||||
"data_description_country": "The country of your Amazon account.",
|
||||
"data_description_username": "The email address of your Amazon account.",
|
||||
"data_description_password": "The password of your Amazon account.",
|
||||
"data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported."
|
||||
},
|
||||
"config": {
|
||||
"flow_title": "{username}",
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"country": "[%key:component::amazon_devices::common::data_country%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"code": "[%key:component::amazon_devices::common::data_description_code%]"
|
||||
},
|
||||
"data_description": {
|
||||
"country": "[%key:component::amazon_devices::common::data_description_country%]",
|
||||
"username": "[%key:component::amazon_devices::common::data_description_username%]",
|
||||
"password": "[%key:component::amazon_devices::common::data_description_password%]",
|
||||
"code": "[%key:component::amazon_devices::common::data_description_code%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"bluetooth": {
|
||||
"name": "Bluetooth"
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
"speak": {
|
||||
"name": "Speak"
|
||||
},
|
||||
"announce": {
|
||||
"name": "Announce"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"do_not_disturb": {
|
||||
"name": "Do not disturb"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
84
homeassistant/components/amazon_devices/switch.py
Normal file
84
homeassistant/components/amazon_devices/switch.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Support for switches."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from aioamazondevices.api import AmazonDevice
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .entity import AmazonEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmazonSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Amazon Devices switch entity description."""
|
||||
|
||||
is_on_fn: Callable[[AmazonDevice], bool]
|
||||
subkey: str
|
||||
method: str
|
||||
|
||||
|
||||
SWITCHES: Final = (
|
||||
AmazonSwitchEntityDescription(
|
||||
key="do_not_disturb",
|
||||
subkey="AUDIO_PLAYER",
|
||||
translation_key="do_not_disturb",
|
||||
is_on_fn=lambda _device: _device.do_not_disturb,
|
||||
method="set_do_not_disturb",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AmazonConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Amazon Devices switches based on a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AmazonSwitchEntity(coordinator, serial_num, switch_desc)
|
||||
for switch_desc in SWITCHES
|
||||
for serial_num in coordinator.data
|
||||
if switch_desc.subkey in coordinator.data[serial_num].capabilities
|
||||
)
|
||||
|
||||
|
||||
class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
|
||||
"""Switch device."""
|
||||
|
||||
entity_description: AmazonSwitchEntityDescription
|
||||
|
||||
async def _switch_set_state(self, state: bool) -> None:
|
||||
"""Set desired switch state."""
|
||||
method = getattr(self.coordinator.api, self.entity_description.method)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert method is not None
|
||||
|
||||
await method(self.device, state)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self._switch_set_state(True)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self._switch_set_state(False)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if switch is on."""
|
||||
return self.entity_description.is_on_fn(self.device)
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["androidtvremote2"],
|
||||
"requirements": ["androidtvremote2==0.2.1"],
|
||||
"requirements": ["androidtvremote2==0.2.2"],
|
||||
"zeroconf": ["_androidtvremote2._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -51,6 +51,10 @@
|
||||
"app_id": "Application ID",
|
||||
"app_icon": "Application icon",
|
||||
"app_delete": "Check to delete this application"
|
||||
},
|
||||
"data_description": {
|
||||
"app_id": "E.g. com.plexapp.android for https://play.google.com/store/apps/details?id=com.plexapp.android",
|
||||
"app_icon": "Image URL. From the Play Store app page, right click on the icon and select 'Copy image address' and then paste it here. Alternatively, download the image, upload it under /config/www/ and use the URL /local/filename"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +46,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(step_id="user", data_schema=_SCHEMA)
|
||||
|
||||
host, port = user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
|
||||
# Abort if an entry with same host and port is present.
|
||||
self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port})
|
||||
|
||||
# Test the connection to the host and get the current status for serial number.
|
||||
try:
|
||||
async with asyncio.timeout(CONNECTION_TIMEOUT):
|
||||
data = APCUPSdData(await aioapcaccess.request_status(host, port))
|
||||
@@ -67,3 +63,30 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
title = data.name or data.model or data.serial_no or "APC UPS"
|
||||
return self.async_create_entry(title=title, data=user_input)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of an existing entry."""
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="reconfigure", data_schema=_SCHEMA)
|
||||
|
||||
host, port = user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port})
|
||||
try:
|
||||
async with asyncio.timeout(CONNECTION_TIMEOUT):
|
||||
data = APCUPSdData(await aioapcaccess.request_status(host, port))
|
||||
except (OSError, asyncio.IncompleteReadError, TimeoutError):
|
||||
errors = {"base": "cannot_connect"}
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure", data_schema=_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
await self.async_set_unique_id(data.serial_no)
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_apcupsd_daemon")
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(),
|
||||
data_updates=user_input,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"wrong_apcupsd_daemon": "The reconfigured APC UPS Daemon is not the same as the one already configured.",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
|
||||
@@ -62,6 +62,8 @@ async def async_setup_entry(
|
||||
target_humidity_key=Attribute.HUMIDIFICATION_SETPOINT,
|
||||
min_humidity=10,
|
||||
max_humidity=50,
|
||||
auto_status_key=Attribute.HUMIDIFICATION_AVAILABLE,
|
||||
auto_status_value=1,
|
||||
default_humidity=30,
|
||||
set_humidity_fn=coordinator.client.set_humidification_setpoint,
|
||||
)
|
||||
@@ -77,6 +79,8 @@ async def async_setup_entry(
|
||||
action_map=DEHUMIDIFIER_ACTION_MAP,
|
||||
current_humidity_key=Attribute.INDOOR_HUMIDITY_CONTROLLING_SENSOR_VALUE,
|
||||
target_humidity_key=Attribute.DEHUMIDIFICATION_SETPOINT,
|
||||
auto_status_key=None,
|
||||
auto_status_value=None,
|
||||
min_humidity=40,
|
||||
max_humidity=90,
|
||||
default_humidity=60,
|
||||
@@ -100,6 +104,8 @@ class AprilaireHumidifierDescription(HumidifierEntityDescription):
|
||||
target_humidity_key: str
|
||||
min_humidity: int
|
||||
max_humidity: int
|
||||
auto_status_key: str | None
|
||||
auto_status_value: int | None
|
||||
default_humidity: int
|
||||
set_humidity_fn: Callable[[int], Awaitable]
|
||||
|
||||
@@ -163,14 +169,31 @@ class AprilaireHumidifierEntity(BaseAprilaireEntity, HumidifierEntity):
|
||||
def min_humidity(self) -> float:
|
||||
"""Return the minimum humidity."""
|
||||
|
||||
if self.is_auto_humidity_mode():
|
||||
return 1
|
||||
|
||||
return self.entity_description.min_humidity
|
||||
|
||||
@property
|
||||
def max_humidity(self) -> float:
|
||||
"""Return the maximum humidity."""
|
||||
|
||||
if self.is_auto_humidity_mode():
|
||||
return 7
|
||||
|
||||
return self.entity_description.max_humidity
|
||||
|
||||
def is_auto_humidity_mode(self) -> bool:
|
||||
"""Return whether the humidifier is in auto mode."""
|
||||
|
||||
if self.entity_description.auto_status_key is None:
|
||||
return False
|
||||
|
||||
return (
|
||||
self.coordinator.data.get(self.entity_description.auto_status_key)
|
||||
== self.entity_description.auto_status_value
|
||||
)
|
||||
|
||||
async def async_set_humidity(self, humidity: int) -> None:
|
||||
"""Set the humidity."""
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyaprilaire"],
|
||||
"requirements": ["pyaprilaire==0.9.0"]
|
||||
"requirements": ["pyaprilaire==0.9.1"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"last_update": {
|
||||
"default": "mdi:update"
|
||||
},
|
||||
"salt_left_side_percentage": {
|
||||
"default": "mdi:basket-fill"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from aioaquacell import Softener
|
||||
|
||||
@@ -28,7 +29,7 @@ PARALLEL_UPDATES = 1
|
||||
class SoftenerSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Softener sensor entity."""
|
||||
|
||||
value_fn: Callable[[Softener], StateType]
|
||||
value_fn: Callable[[Softener], StateType | datetime]
|
||||
|
||||
|
||||
SENSORS: tuple[SoftenerSensorEntityDescription, ...] = (
|
||||
@@ -77,6 +78,12 @@ SENSORS: tuple[SoftenerSensorEntityDescription, ...] = (
|
||||
"low",
|
||||
],
|
||||
),
|
||||
SoftenerSensorEntityDescription(
|
||||
key="last_update",
|
||||
translation_key="last_update",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda softener: softener.lastUpdate,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -111,6 +118,6 @@ class SoftenerSensor(AquacellEntity, SensorEntity):
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.softener)
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"last_update": {
|
||||
"name": "Last update"
|
||||
},
|
||||
"salt_left_side_percentage": {
|
||||
"name": "Salt left side percentage"
|
||||
},
|
||||
|
||||
@@ -1178,25 +1178,33 @@ class PipelineRun:
|
||||
if role := delta.get("role"):
|
||||
chat_log_role = role
|
||||
|
||||
# We are only interested in assistant deltas with content
|
||||
if chat_log_role != "assistant" or not (
|
||||
content := delta.get("content")
|
||||
):
|
||||
# We are only interested in assistant deltas
|
||||
if chat_log_role != "assistant":
|
||||
return
|
||||
|
||||
tts_input_stream.put_nowait(content)
|
||||
if content := delta.get("content"):
|
||||
tts_input_stream.put_nowait(content)
|
||||
|
||||
if self._streamed_response_text:
|
||||
return
|
||||
|
||||
nonlocal delta_character_count
|
||||
|
||||
delta_character_count += len(content)
|
||||
if delta_character_count < STREAM_RESPONSE_CHARS:
|
||||
# Streamed responses are not cached. That's why we only start streaming text after
|
||||
# we have received enough characters that indicates it will be a long response
|
||||
# or if we have received text, and then a tool call.
|
||||
|
||||
# Tool call after we already received text
|
||||
start_streaming = delta_character_count > 0 and delta.get("tool_calls")
|
||||
|
||||
# Count characters in the content and test if we exceed streaming threshold
|
||||
if not start_streaming and content:
|
||||
delta_character_count += len(content)
|
||||
start_streaming = delta_character_count > STREAM_RESPONSE_CHARS
|
||||
|
||||
if not start_streaming:
|
||||
return
|
||||
|
||||
# Streamed responses are not cached. We only start streaming text after
|
||||
# we have received a couple of words that indicates it will be a long response.
|
||||
self._streamed_response_text = True
|
||||
|
||||
async def tts_input_stream_generator() -> AsyncGenerator[str]:
|
||||
@@ -1204,6 +1212,17 @@ class PipelineRun:
|
||||
while (tts_input := await tts_input_stream.get()) is not None:
|
||||
yield tts_input
|
||||
|
||||
# Concatenate all existing queue items
|
||||
parts = []
|
||||
while not tts_input_stream.empty():
|
||||
parts.append(tts_input_stream.get_nowait())
|
||||
tts_input_stream.put_nowait(
|
||||
"".join(
|
||||
# At this point parts is only strings, None indicates end of queue
|
||||
cast(list[str], parts)
|
||||
)
|
||||
)
|
||||
|
||||
assert self.tts_stream is not None
|
||||
self.tts_stream.async_set_message_stream(tts_input_stream_generator())
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from ..const import ATTR_MANUFACTURER, DOMAIN as AXIS_DOMAIN
|
||||
from ..const import ATTR_MANUFACTURER, DOMAIN
|
||||
from .config import AxisConfig
|
||||
from .entity_loader import AxisEntityLoader
|
||||
from .event_source import AxisEventSource
|
||||
@@ -79,7 +79,7 @@ class AxisHub:
|
||||
config_entry_id=self.config.entry.entry_id,
|
||||
configuration_url=self.api.config.url,
|
||||
connections={(CONNECTION_NETWORK_MAC, self.unique_id)},
|
||||
identifiers={(AXIS_DOMAIN, self.unique_id)},
|
||||
identifiers={(DOMAIN, self.unique_id)},
|
||||
manufacturer=ATTR_MANUFACTURER,
|
||||
model=f"{self.config.model} {self.product_type}",
|
||||
name=self.config.name,
|
||||
|
||||
@@ -62,6 +62,7 @@ from .const import (
|
||||
LOGGER,
|
||||
)
|
||||
from .models import (
|
||||
AddonInfo,
|
||||
AgentBackup,
|
||||
BackupError,
|
||||
BackupManagerError,
|
||||
@@ -102,7 +103,9 @@ class ManagerBackup(BaseBackup):
|
||||
"""Backup class."""
|
||||
|
||||
agents: dict[str, AgentBackupStatus]
|
||||
failed_addons: list[AddonInfo]
|
||||
failed_agent_ids: list[str]
|
||||
failed_folders: list[Folder]
|
||||
with_automatic_settings: bool | None
|
||||
|
||||
|
||||
@@ -110,7 +113,7 @@ class ManagerBackup(BaseBackup):
|
||||
class AddonErrorData:
|
||||
"""Addon error class."""
|
||||
|
||||
name: str
|
||||
addon: AddonInfo
|
||||
errors: list[tuple[str, str]]
|
||||
|
||||
|
||||
@@ -646,9 +649,13 @@ class BackupManager:
|
||||
for agent_backup in result:
|
||||
if (backup_id := agent_backup.backup_id) not in backups:
|
||||
if known_backup := self.known_backups.get(backup_id):
|
||||
failed_addons = known_backup.failed_addons
|
||||
failed_agent_ids = known_backup.failed_agent_ids
|
||||
failed_folders = known_backup.failed_folders
|
||||
else:
|
||||
failed_addons = []
|
||||
failed_agent_ids = []
|
||||
failed_folders = []
|
||||
with_automatic_settings = self.is_our_automatic_backup(
|
||||
agent_backup, await instance_id.async_get(self.hass)
|
||||
)
|
||||
@@ -659,7 +666,9 @@ class BackupManager:
|
||||
date=agent_backup.date,
|
||||
database_included=agent_backup.database_included,
|
||||
extra_metadata=agent_backup.extra_metadata,
|
||||
failed_addons=failed_addons,
|
||||
failed_agent_ids=failed_agent_ids,
|
||||
failed_folders=failed_folders,
|
||||
folders=agent_backup.folders,
|
||||
homeassistant_included=agent_backup.homeassistant_included,
|
||||
homeassistant_version=agent_backup.homeassistant_version,
|
||||
@@ -714,9 +723,13 @@ class BackupManager:
|
||||
continue
|
||||
if backup is None:
|
||||
if known_backup := self.known_backups.get(backup_id):
|
||||
failed_addons = known_backup.failed_addons
|
||||
failed_agent_ids = known_backup.failed_agent_ids
|
||||
failed_folders = known_backup.failed_folders
|
||||
else:
|
||||
failed_addons = []
|
||||
failed_agent_ids = []
|
||||
failed_folders = []
|
||||
with_automatic_settings = self.is_our_automatic_backup(
|
||||
result, await instance_id.async_get(self.hass)
|
||||
)
|
||||
@@ -727,7 +740,9 @@ class BackupManager:
|
||||
date=result.date,
|
||||
database_included=result.database_included,
|
||||
extra_metadata=result.extra_metadata,
|
||||
failed_addons=failed_addons,
|
||||
failed_agent_ids=failed_agent_ids,
|
||||
failed_folders=failed_folders,
|
||||
folders=result.folders,
|
||||
homeassistant_included=result.homeassistant_included,
|
||||
homeassistant_version=result.homeassistant_version,
|
||||
@@ -970,7 +985,7 @@ class BackupManager:
|
||||
password=None,
|
||||
)
|
||||
await written_backup.release_stream()
|
||||
self.known_backups.add(written_backup.backup, agent_errors, [])
|
||||
self.known_backups.add(written_backup.backup, agent_errors, {}, {}, [])
|
||||
return written_backup.backup.backup_id
|
||||
|
||||
async def async_create_backup(
|
||||
@@ -1208,7 +1223,11 @@ class BackupManager:
|
||||
finally:
|
||||
await written_backup.release_stream()
|
||||
self.known_backups.add(
|
||||
written_backup.backup, agent_errors, unavailable_agents
|
||||
written_backup.backup,
|
||||
agent_errors,
|
||||
written_backup.addon_errors,
|
||||
written_backup.folder_errors,
|
||||
unavailable_agents,
|
||||
)
|
||||
if not agent_errors:
|
||||
if with_automatic_settings:
|
||||
@@ -1416,7 +1435,12 @@ class BackupManager:
|
||||
# No issues with agents or folders, but issues with add-ons
|
||||
self._create_automatic_backup_failed_issue(
|
||||
"automatic_backup_failed_addons",
|
||||
{"failed_addons": ", ".join(val.name for val in addon_errors.values())},
|
||||
{
|
||||
"failed_addons": ", ".join(
|
||||
val.addon.name or val.addon.slug
|
||||
for val in addon_errors.values()
|
||||
)
|
||||
},
|
||||
)
|
||||
elif folder_errors and not (failed_agents or addon_errors):
|
||||
# No issues with agents or add-ons, but issues with folders
|
||||
@@ -1431,7 +1455,11 @@ class BackupManager:
|
||||
{
|
||||
"failed_agents": ", ".join(failed_agents) or "-",
|
||||
"failed_addons": (
|
||||
", ".join(val.name for val in addon_errors.values()) or "-"
|
||||
", ".join(
|
||||
val.addon.name or val.addon.slug
|
||||
for val in addon_errors.values()
|
||||
)
|
||||
or "-"
|
||||
),
|
||||
"failed_folders": ", ".join(f for f in folder_errors) or "-",
|
||||
},
|
||||
@@ -1501,7 +1529,12 @@ class KnownBackups:
|
||||
self._backups = {
|
||||
backup["backup_id"]: KnownBackup(
|
||||
backup_id=backup["backup_id"],
|
||||
failed_addons=[
|
||||
AddonInfo(name=a["name"], slug=a["slug"], version=a["version"])
|
||||
for a in backup["failed_addons"]
|
||||
],
|
||||
failed_agent_ids=backup["failed_agent_ids"],
|
||||
failed_folders=[Folder(f) for f in backup["failed_folders"]],
|
||||
)
|
||||
for backup in stored_backups
|
||||
}
|
||||
@@ -1514,12 +1547,16 @@ class KnownBackups:
|
||||
self,
|
||||
backup: AgentBackup,
|
||||
agent_errors: dict[str, Exception],
|
||||
failed_addons: dict[str, AddonErrorData],
|
||||
failed_folders: dict[Folder, list[tuple[str, str]]],
|
||||
unavailable_agents: list[str],
|
||||
) -> None:
|
||||
"""Add a backup."""
|
||||
self._backups[backup.backup_id] = KnownBackup(
|
||||
backup_id=backup.backup_id,
|
||||
failed_addons=[val.addon for val in failed_addons.values()],
|
||||
failed_agent_ids=list(chain(agent_errors, unavailable_agents)),
|
||||
failed_folders=list(failed_folders),
|
||||
)
|
||||
self._manager.store.save()
|
||||
|
||||
@@ -1540,21 +1577,38 @@ class KnownBackup:
|
||||
"""Persistent backup data."""
|
||||
|
||||
backup_id: str
|
||||
failed_addons: list[AddonInfo]
|
||||
failed_agent_ids: list[str]
|
||||
failed_folders: list[Folder]
|
||||
|
||||
def to_dict(self) -> StoredKnownBackup:
|
||||
"""Convert known backup to a dict."""
|
||||
return {
|
||||
"backup_id": self.backup_id,
|
||||
"failed_addons": [
|
||||
{"name": a.name, "slug": a.slug, "version": a.version}
|
||||
for a in self.failed_addons
|
||||
],
|
||||
"failed_agent_ids": self.failed_agent_ids,
|
||||
"failed_folders": [f.value for f in self.failed_folders],
|
||||
}
|
||||
|
||||
|
||||
class StoredAddonInfo(TypedDict):
|
||||
"""Stored add-on info."""
|
||||
|
||||
name: str | None
|
||||
slug: str
|
||||
version: str | None
|
||||
|
||||
|
||||
class StoredKnownBackup(TypedDict):
|
||||
"""Stored persistent backup data."""
|
||||
|
||||
backup_id: str
|
||||
failed_addons: list[StoredAddonInfo]
|
||||
failed_agent_ids: list[str]
|
||||
failed_folders: list[str]
|
||||
|
||||
|
||||
class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
|
||||
@@ -13,9 +13,9 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
class AddonInfo:
|
||||
"""Addon information."""
|
||||
|
||||
name: str
|
||||
name: str | None
|
||||
slug: str
|
||||
version: str
|
||||
version: str | None
|
||||
|
||||
|
||||
class Folder(StrEnum):
|
||||
|
||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
||||
STORE_DELAY_SAVE = 30
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_VERSION_MINOR = 6
|
||||
STORAGE_VERSION_MINOR = 7
|
||||
|
||||
|
||||
class StoredBackupData(TypedDict):
|
||||
@@ -76,6 +76,11 @@ class _BackupStore(Store[StoredBackupData]):
|
||||
# Version 1.6 adds agent retention settings
|
||||
for agent in data["config"]["agents"]:
|
||||
data["config"]["agents"][agent]["retention"] = None
|
||||
if old_minor_version < 7:
|
||||
# Version 1.7 adds failing addons and folders
|
||||
for backup in data["backups"]:
|
||||
backup["failed_addons"] = []
|
||||
backup["failed_folders"] = []
|
||||
|
||||
# Note: We allow reading data with major version 2 in which the unused key
|
||||
# data["config"]["schedule"]["state"] will be removed. The bump to 2 is
|
||||
|
||||
@@ -9,6 +9,7 @@ from aiocomelit import ComelitSerialBridgeObject
|
||||
from aiocomelit.const import CLIMATE
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
DOMAIN as CLIMATE_DOMAIN,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
@@ -17,13 +18,12 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import PRESET_MODE_AUTO, PRESET_MODE_AUTO_TARGET_TEMP, PRESET_MODE_MANUAL
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
from .utils import bridge_api_call
|
||||
from .utils import bridge_api_call, cleanup_stale_entity, load_api_data
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -41,11 +41,13 @@ class ClimaComelitMode(StrEnum):
|
||||
class ClimaComelitCommand(StrEnum):
|
||||
"""Serial Bridge clima commands."""
|
||||
|
||||
AUTO = "auto"
|
||||
MANUAL = "man"
|
||||
OFF = "off"
|
||||
ON = "on"
|
||||
MANUAL = "man"
|
||||
SET = "set"
|
||||
AUTO = "auto"
|
||||
SNOW = "lower"
|
||||
SUN = "upper"
|
||||
|
||||
|
||||
class ClimaComelitApiStatus(TypedDict):
|
||||
@@ -67,11 +69,15 @@ API_STATUS: dict[str, ClimaComelitApiStatus] = {
|
||||
),
|
||||
}
|
||||
|
||||
MODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = {
|
||||
HVACMODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = {
|
||||
HVACMode.OFF: ClimaComelitCommand.OFF,
|
||||
HVACMode.AUTO: ClimaComelitCommand.AUTO,
|
||||
HVACMode.COOL: ClimaComelitCommand.MANUAL,
|
||||
HVACMode.HEAT: ClimaComelitCommand.MANUAL,
|
||||
HVACMode.COOL: ClimaComelitCommand.SNOW,
|
||||
HVACMode.HEAT: ClimaComelitCommand.SUN,
|
||||
}
|
||||
|
||||
PRESET_MODE_TO_ACTION: dict[str, ClimaComelitCommand] = {
|
||||
PRESET_MODE_MANUAL: ClimaComelitCommand.MANUAL,
|
||||
PRESET_MODE_AUTO: ClimaComelitCommand.AUTO,
|
||||
}
|
||||
|
||||
|
||||
@@ -84,26 +90,42 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
async_add_entities(
|
||||
ComelitClimateEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[CLIMATE].values()
|
||||
)
|
||||
entities: list[ClimateEntity] = []
|
||||
for device in coordinator.data[CLIMATE].values():
|
||||
values = load_api_data(device, CLIMATE_DOMAIN)
|
||||
if values[0] == 0 and values[4] == 0:
|
||||
# No climate data, device is only a humidifier/dehumidifier
|
||||
|
||||
await cleanup_stale_entity(
|
||||
hass, config_entry, f"{config_entry.entry_id}-{device.index}", device
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
entities.append(
|
||||
ComelitClimateEntity(coordinator, device, config_entry.entry_id)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
|
||||
"""Climate device."""
|
||||
|
||||
_attr_hvac_modes = [HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF]
|
||||
_attr_hvac_modes = [HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF]
|
||||
_attr_preset_modes = [PRESET_MODE_AUTO, PRESET_MODE_MANUAL]
|
||||
_attr_max_temp = 30
|
||||
_attr_min_temp = 5
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.PRESET_MODE
|
||||
)
|
||||
_attr_target_temperature_step = PRECISION_TENTHS
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_name = None
|
||||
_attr_translation_key = "thermostat"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -118,20 +140,14 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
|
||||
def _update_attributes(self) -> None:
|
||||
"""Update class attributes."""
|
||||
device = self.coordinator.data[CLIMATE][self._device.index]
|
||||
if not isinstance(device.val, list):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="invalid_clima_data"
|
||||
)
|
||||
|
||||
# CLIMATE has a 2 item tuple:
|
||||
# - first for Clima
|
||||
# - second for Humidifier
|
||||
values = device.val[0]
|
||||
values = load_api_data(device, CLIMATE_DOMAIN)
|
||||
|
||||
_active = values[1]
|
||||
_mode = values[2] # Values from API: "O", "L", "U"
|
||||
_automatic = values[3] == ClimaComelitMode.AUTO
|
||||
|
||||
self._attr_preset_mode = PRESET_MODE_AUTO if _automatic else PRESET_MODE_MANUAL
|
||||
|
||||
self._attr_current_temperature = values[0] / 10
|
||||
|
||||
self._attr_hvac_action = None
|
||||
@@ -141,10 +157,6 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
|
||||
self._attr_hvac_action = API_STATUS[_mode]["hvac_action"]
|
||||
|
||||
self._attr_hvac_mode = None
|
||||
if _mode == ClimaComelitMode.OFF:
|
||||
self._attr_hvac_mode = HVACMode.OFF
|
||||
if _automatic:
|
||||
self._attr_hvac_mode = HVACMode.AUTO
|
||||
if _mode in API_STATUS:
|
||||
self._attr_hvac_mode = API_STATUS[_mode]["hvac_mode"]
|
||||
|
||||
@@ -160,13 +172,12 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
if (
|
||||
target_temp := kwargs.get(ATTR_TEMPERATURE)
|
||||
) is None or self.hvac_mode == HVACMode.OFF:
|
||||
(target_temp := kwargs.get(ATTR_TEMPERATURE)) is None
|
||||
or self.hvac_mode == HVACMode.OFF
|
||||
or self._attr_preset_mode == PRESET_MODE_AUTO
|
||||
):
|
||||
return
|
||||
|
||||
await self.coordinator.api.set_clima_status(
|
||||
self._device.index, ClimaComelitCommand.MANUAL
|
||||
)
|
||||
await self.coordinator.api.set_clima_status(
|
||||
self._device.index, ClimaComelitCommand.SET, target_temp
|
||||
)
|
||||
@@ -177,12 +188,28 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set hvac mode."""
|
||||
|
||||
if hvac_mode != HVACMode.OFF:
|
||||
if self._attr_hvac_mode == HVACMode.OFF:
|
||||
await self.coordinator.api.set_clima_status(
|
||||
self._device.index, ClimaComelitCommand.ON
|
||||
)
|
||||
await self.coordinator.api.set_clima_status(
|
||||
self._device.index, MODE_TO_ACTION[hvac_mode]
|
||||
self._device.index, HVACMODE_TO_ACTION[hvac_mode]
|
||||
)
|
||||
self._attr_hvac_mode = hvac_mode
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new target preset mode."""
|
||||
|
||||
if self._attr_hvac_mode == HVACMode.OFF:
|
||||
return
|
||||
|
||||
await self.coordinator.api.set_clima_status(
|
||||
self._device.index, PRESET_MODE_TO_ACTION[preset_mode]
|
||||
)
|
||||
self._attr_preset_mode = preset_mode
|
||||
|
||||
if preset_mode == PRESET_MODE_AUTO:
|
||||
self._attr_target_temperature = PRESET_MODE_AUTO_TARGET_TEMP
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -11,3 +11,8 @@ DEFAULT_PORT = 80
|
||||
DEVICE_TYPE_LIST = [BRIDGE, VEDO]
|
||||
|
||||
SCAN_INTERVAL = 5
|
||||
|
||||
PRESET_MODE_AUTO = "automatic"
|
||||
PRESET_MODE_MANUAL = "manual"
|
||||
|
||||
PRESET_MODE_AUTO_TARGET_TEMP = 20
|
||||
|
||||
@@ -9,6 +9,7 @@ from aiocomelit import ComelitSerialBridgeObject
|
||||
from aiocomelit.const import CLIMATE
|
||||
|
||||
from homeassistant.components.humidifier import (
|
||||
DOMAIN as HUMIDIFIER_DOMAIN,
|
||||
MODE_AUTO,
|
||||
MODE_NORMAL,
|
||||
HumidifierAction,
|
||||
@@ -17,13 +18,13 @@ from homeassistant.components.humidifier import (
|
||||
HumidifierEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
from .utils import bridge_api_call
|
||||
from .utils import bridge_api_call, cleanup_stale_entity, load_api_data
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -67,6 +68,23 @@ async def async_setup_entry(
|
||||
|
||||
entities: list[ComelitHumidifierEntity] = []
|
||||
for device in coordinator.data[CLIMATE].values():
|
||||
values = load_api_data(device, HUMIDIFIER_DOMAIN)
|
||||
if values[0] == 0 and values[4] == 0:
|
||||
# No humidity data, device is only a climate
|
||||
|
||||
for device_class in (
|
||||
HumidifierDeviceClass.HUMIDIFIER,
|
||||
HumidifierDeviceClass.DEHUMIDIFIER,
|
||||
):
|
||||
await cleanup_stale_entity(
|
||||
hass,
|
||||
config_entry,
|
||||
f"{config_entry.entry_id}-{device.index}-{device_class}",
|
||||
device,
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
entities.append(
|
||||
ComelitHumidifierEntity(
|
||||
coordinator,
|
||||
@@ -124,15 +142,7 @@ class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity):
|
||||
def _update_attributes(self) -> None:
|
||||
"""Update class attributes."""
|
||||
device = self.coordinator.data[CLIMATE][self._device.index]
|
||||
if not isinstance(device.val, list):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="invalid_clima_data"
|
||||
)
|
||||
|
||||
# CLIMATE has a 2 item tuple:
|
||||
# - first for Clima
|
||||
# - second for Humidifier
|
||||
values = device.val[1]
|
||||
values = load_api_data(device, HUMIDIFIER_DOMAIN)
|
||||
|
||||
_active = values[1]
|
||||
_mode = values[2] # Values from API: "O", "L", "U"
|
||||
|
||||
@@ -4,6 +4,18 @@
|
||||
"zone_status": {
|
||||
"default": "mdi:shield-check"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"thermostat": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"automatic": "mdi:refresh-auto",
|
||||
"manual": "mdi:alpha-m"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,18 @@
|
||||
"dehumidifier": {
|
||||
"name": "Dehumidifier"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"thermostat": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"automatic": "[%key:common::state::auto%]",
|
||||
"manual": "[%key:common::state::manual%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -4,14 +4,21 @@ from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from aiocomelit import ComelitSerialBridgeObject
|
||||
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
|
||||
from aiohttp import ClientSession, CookieJar
|
||||
|
||||
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.helpers import (
|
||||
aiohttp_client,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import _LOGGER, DOMAIN
|
||||
from .entity import ComelitBridgeBaseEntity
|
||||
|
||||
|
||||
@@ -22,6 +29,61 @@ async def async_client_session(hass: HomeAssistant) -> ClientSession:
|
||||
)
|
||||
|
||||
|
||||
def load_api_data(device: ComelitSerialBridgeObject, domain: str) -> list[Any]:
|
||||
"""Load data from the API."""
|
||||
# This function is called when the data is loaded from the API
|
||||
if not isinstance(device.val, list):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=domain, translation_key="invalid_clima_data"
|
||||
)
|
||||
# CLIMATE has a 2 item tuple:
|
||||
# - first for Clima
|
||||
# - second for Humidifier
|
||||
return device.val[0] if domain == CLIMATE_DOMAIN else device.val[1]
|
||||
|
||||
|
||||
async def cleanup_stale_entity(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
entry_unique_id: str,
|
||||
device: ComelitSerialBridgeObject,
|
||||
) -> None:
|
||||
"""Cleanup stale entity."""
|
||||
entity_reg: er.EntityRegistry = er.async_get(hass)
|
||||
|
||||
identifiers: list[str] = []
|
||||
|
||||
for entry in er.async_entries_for_config_entry(entity_reg, config_entry.entry_id):
|
||||
if entry.unique_id == entry_unique_id:
|
||||
entry_name = entry.name or entry.original_name
|
||||
_LOGGER.info("Removing entity: %s [%s]", entry.entity_id, entry_name)
|
||||
entity_reg.async_remove(entry.entity_id)
|
||||
identifiers.append(f"{config_entry.entry_id}-{device.type}-{device.index}")
|
||||
|
||||
if len(identifiers) > 0:
|
||||
_async_remove_state_config_entry_from_devices(hass, identifiers, config_entry)
|
||||
|
||||
|
||||
def _async_remove_state_config_entry_from_devices(
|
||||
hass: HomeAssistant, identifiers: list[str], config_entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Remove config entry from device."""
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
for identifier in identifiers:
|
||||
device = device_registry.async_get_device(identifiers={(DOMAIN, identifier)})
|
||||
if device:
|
||||
_LOGGER.info(
|
||||
"Removing config entry %s from device %s",
|
||||
config_entry.title,
|
||||
device.name,
|
||||
)
|
||||
device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=config_entry.entry_id,
|
||||
)
|
||||
|
||||
|
||||
def bridge_api_call[_T: ComelitBridgeBaseEntity, **_P](
|
||||
func: Callable[Concatenate[_T, _P], Awaitable[None]],
|
||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
|
||||
|
||||
@@ -9,10 +9,12 @@ from typing import Any
|
||||
from homeassistant.components.notify import BaseNotificationService
|
||||
from homeassistant.const import CONF_COMMAND
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util.process import kill_subprocess
|
||||
|
||||
from .const import CONF_COMMAND_TIMEOUT
|
||||
from .const import CONF_COMMAND_TIMEOUT, LOGGER
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,8 +45,31 @@ class CommandLineNotificationService(BaseNotificationService):
|
||||
|
||||
def send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||
"""Send a message to a command line."""
|
||||
command = self.command
|
||||
if " " not in command:
|
||||
prog = command
|
||||
args = None
|
||||
args_compiled = None
|
||||
else:
|
||||
prog, args = command.split(" ", 1)
|
||||
args_compiled = Template(args, self.hass)
|
||||
|
||||
rendered_args = None
|
||||
if args_compiled:
|
||||
args_to_render = {"arguments": args}
|
||||
try:
|
||||
rendered_args = args_compiled.async_render(args_to_render)
|
||||
except TemplateError as ex:
|
||||
LOGGER.exception("Error rendering command template: %s", ex)
|
||||
return
|
||||
|
||||
if rendered_args != args:
|
||||
command = f"{prog} {rendered_args}"
|
||||
|
||||
LOGGER.debug("Running command: %s, with message: %s", command, message)
|
||||
|
||||
with subprocess.Popen( # noqa: S602 # shell by design
|
||||
self.command,
|
||||
command,
|
||||
universal_newlines=True,
|
||||
stdin=subprocess.PIPE,
|
||||
close_fds=False, # required for posix_spawn
|
||||
@@ -56,10 +81,10 @@ class CommandLineNotificationService(BaseNotificationService):
|
||||
_LOGGER.error(
|
||||
"Command failed (with return code %s): %s",
|
||||
proc.returncode,
|
||||
self.command,
|
||||
command,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
_LOGGER.error("Timeout for command: %s", self.command)
|
||||
_LOGGER.error("Timeout for command: %s", command)
|
||||
kill_subprocess(proc)
|
||||
except subprocess.SubprocessError:
|
||||
_LOGGER.error("Error trying to exec command: %s", self.command)
|
||||
_LOGGER.error("Error trying to exec command: %s", command)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -10,18 +11,23 @@ from homeassistant import config_entries
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.websocket_api import ERR_NOT_FOUND, require_admin
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.entity_component import async_get_entity_suggested_object_id
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup(hass: HomeAssistant) -> bool:
|
||||
"""Enable the Entity Registry views."""
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_get_automatic_entity_ids)
|
||||
websocket_api.async_register_command(hass, websocket_get_entities)
|
||||
websocket_api.async_register_command(hass, websocket_get_entity)
|
||||
websocket_api.async_register_command(hass, websocket_list_entities_for_display)
|
||||
@@ -316,3 +322,54 @@ def websocket_remove_entity(
|
||||
|
||||
registry.async_remove(msg["entity_id"])
|
||||
connection.send_message(websocket_api.result_message(msg["id"]))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "config/entity_registry/get_automatic_entity_ids",
|
||||
vol.Required("entity_ids"): cv.entity_ids,
|
||||
}
|
||||
)
|
||||
@callback
|
||||
def websocket_get_automatic_entity_ids(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return the automatic entity IDs for the given entity IDs.
|
||||
|
||||
This is used to help user reset entity IDs which have been customized by the user.
|
||||
"""
|
||||
registry = er.async_get(hass)
|
||||
|
||||
entity_ids = msg["entity_ids"]
|
||||
automatic_entity_ids: dict[str, str | None] = {}
|
||||
reserved_entity_ids: set[str] = set()
|
||||
for entity_id in entity_ids:
|
||||
if not (entry := registry.entities.get(entity_id)):
|
||||
automatic_entity_ids[entity_id] = None
|
||||
continue
|
||||
try:
|
||||
suggested = async_get_entity_suggested_object_id(hass, entity_id)
|
||||
except HomeAssistantError as err:
|
||||
# This is raised if the entity has no object.
|
||||
_LOGGER.debug(
|
||||
"Unable to get suggested object ID for %s, entity ID: %s (%s)",
|
||||
entry.entity_id,
|
||||
entity_id,
|
||||
err,
|
||||
)
|
||||
automatic_entity_ids[entity_id] = None
|
||||
continue
|
||||
suggested_entity_id = registry.async_generate_entity_id(
|
||||
entry.domain,
|
||||
suggested or f"{entry.platform}_{entry.unique_id}",
|
||||
current_entity_id=entity_id,
|
||||
reserved_entity_ids=reserved_entity_ids,
|
||||
)
|
||||
automatic_entity_ids[entity_id] = suggested_entity_id
|
||||
reserved_entity_ids.add(suggested_entity_id)
|
||||
|
||||
connection.send_message(
|
||||
websocket_api.result_message(msg["id"], automatic_entity_ids)
|
||||
)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.5.7"]
|
||||
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.5.28"]
|
||||
}
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
"""The cups component."""
|
||||
|
||||
DOMAIN = "cups"
|
||||
CONF_PRINTERS = "printers"
|
||||
|
||||
@@ -14,12 +14,15 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, PERCENTAGE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import CONF_PRINTERS, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_MARKER_TYPE = "marker_type"
|
||||
@@ -36,7 +39,6 @@ ATTR_PRINTER_STATE_REASON = "printer_state_reason"
|
||||
ATTR_PRINTER_TYPE = "printer_type"
|
||||
ATTR_PRINTER_URI_SUPPORTED = "printer_uri_supported"
|
||||
|
||||
CONF_PRINTERS = "printers"
|
||||
CONF_IS_CUPS_SERVER = "is_cups_server"
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
@@ -72,6 +74,21 @@ def setup_platform(
|
||||
printers: list[str] = config[CONF_PRINTERS]
|
||||
is_cups: bool = config[CONF_IS_CUPS_SERVER]
|
||||
|
||||
create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_system_packages_yaml_integration_{DOMAIN}",
|
||||
breaks_in_ha_version="2025.12.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_system_packages_yaml_integration",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "CUPS",
|
||||
},
|
||||
)
|
||||
|
||||
if is_cups:
|
||||
data = CupsData(host, port, None)
|
||||
data.update()
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.const import ATTR_DEVICE_ID, CONF_EVENT, CONF_ID
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import CONF_GESTURE, DOMAIN as DECONZ_DOMAIN
|
||||
from .const import CONF_GESTURE, DOMAIN
|
||||
from .deconz_event import CONF_DECONZ_ALARM_EVENT, CONF_DECONZ_EVENT
|
||||
from .device_trigger import (
|
||||
CONF_BOTH_BUTTONS,
|
||||
@@ -200,6 +200,6 @@ def async_describe_events(
|
||||
}
|
||||
|
||||
async_describe_event(
|
||||
DECONZ_DOMAIN, CONF_DECONZ_ALARM_EVENT, async_describe_deconz_alarm_event
|
||||
DOMAIN, CONF_DECONZ_ALARM_EVENT, async_describe_deconz_alarm_event
|
||||
)
|
||||
async_describe_event(DECONZ_DOMAIN, CONF_DECONZ_EVENT, async_describe_deconz_event)
|
||||
async_describe_event(DOMAIN, CONF_DECONZ_EVENT, async_describe_deconz_event)
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
"""The decora component."""
|
||||
|
||||
DOMAIN = "decora"
|
||||
|
||||
@@ -21,7 +21,11 @@ from homeassistant.components.light import (
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_DEVICES, CONF_NAME
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -90,6 +94,21 @@ def setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up an Decora switch."""
|
||||
create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_system_packages_yaml_integration_{DOMAIN}",
|
||||
breaks_in_ha_version="2025.12.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_system_packages_yaml_integration",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Leviton Decora",
|
||||
},
|
||||
)
|
||||
|
||||
lights = []
|
||||
for address, device_config in config[CONF_DEVICES].items():
|
||||
device = {}
|
||||
|
||||
@@ -2,27 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import Semaphore
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devolo_plc_api import Device
|
||||
from devolo_plc_api.device_api import (
|
||||
ConnectedStationInfo,
|
||||
NeighborAPInfo,
|
||||
UpdateFirmwareCheck,
|
||||
WifiGuestAccessGet,
|
||||
)
|
||||
from devolo_plc_api.exceptions.device import (
|
||||
DeviceNotFound,
|
||||
DevicePasswordProtected,
|
||||
DeviceUnavailable,
|
||||
)
|
||||
from devolo_plc_api.plcnet_api import LogicalNetwork
|
||||
from devolo_plc_api.exceptions.device import DeviceNotFound
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_IP_ADDRESS,
|
||||
CONF_PASSWORD,
|
||||
@@ -30,38 +16,34 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.update_coordinator import UpdateFailed
|
||||
|
||||
from .const import (
|
||||
CONNECTED_PLC_DEVICES,
|
||||
CONNECTED_WIFI_CLIENTS,
|
||||
DOMAIN,
|
||||
FIRMWARE_UPDATE_INTERVAL,
|
||||
LAST_RESTART,
|
||||
LONG_UPDATE_INTERVAL,
|
||||
NEIGHBORING_WIFI_NETWORKS,
|
||||
REGULAR_FIRMWARE,
|
||||
SHORT_UPDATE_INTERVAL,
|
||||
SWITCH_GUEST_WIFI,
|
||||
SWITCH_LEDS,
|
||||
)
|
||||
from .coordinator import DevoloDataUpdateCoordinator
|
||||
from .coordinator import (
|
||||
DevoloDataUpdateCoordinator,
|
||||
DevoloFirmwareUpdateCoordinator,
|
||||
DevoloHomeNetworkConfigEntry,
|
||||
DevoloHomeNetworkData,
|
||||
DevoloLedSettingsGetCoordinator,
|
||||
DevoloLogicalNetworkCoordinator,
|
||||
DevoloUptimeGetCoordinator,
|
||||
DevoloWifiConnectedStationsGetCoordinator,
|
||||
DevoloWifiGuestAccessGetCoordinator,
|
||||
DevoloWifiNeighborAPsGetCoordinator,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type DevoloHomeNetworkConfigEntry = ConfigEntry[DevoloHomeNetworkData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DevoloHomeNetworkData:
|
||||
"""The devolo Home Network data."""
|
||||
|
||||
device: Device
|
||||
coordinators: dict[str, DevoloDataUpdateCoordinator[Any]]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: DevoloHomeNetworkConfigEntry
|
||||
@@ -69,8 +51,6 @@ async def async_setup_entry(
|
||||
"""Set up devolo Home Network from a config entry."""
|
||||
zeroconf_instance = await zeroconf.async_get_async_instance(hass)
|
||||
async_client = get_async_client(hass)
|
||||
device_registry = dr.async_get(hass)
|
||||
semaphore = Semaphore(1)
|
||||
|
||||
try:
|
||||
device = Device(
|
||||
@@ -90,177 +70,52 @@ async def async_setup_entry(
|
||||
|
||||
entry.runtime_data = DevoloHomeNetworkData(device=device, coordinators={})
|
||||
|
||||
async def async_update_firmware_available() -> UpdateFirmwareCheck:
|
||||
"""Fetch data from API endpoint."""
|
||||
assert device.device
|
||||
update_sw_version(device_registry, device)
|
||||
try:
|
||||
return await device.device.async_check_firmware_available()
|
||||
except DeviceUnavailable as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
async def async_update_connected_plc_devices() -> LogicalNetwork:
|
||||
"""Fetch data from API endpoint."""
|
||||
assert device.plcnet
|
||||
update_sw_version(device_registry, device)
|
||||
try:
|
||||
return await device.plcnet.async_get_network_overview()
|
||||
except DeviceUnavailable as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
async def async_update_guest_wifi_status() -> WifiGuestAccessGet:
|
||||
"""Fetch data from API endpoint."""
|
||||
assert device.device
|
||||
update_sw_version(device_registry, device)
|
||||
try:
|
||||
return await device.device.async_get_wifi_guest_access()
|
||||
except DeviceUnavailable as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
except DevicePasswordProtected as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="password_wrong"
|
||||
) from err
|
||||
|
||||
async def async_update_led_status() -> bool:
|
||||
"""Fetch data from API endpoint."""
|
||||
assert device.device
|
||||
update_sw_version(device_registry, device)
|
||||
try:
|
||||
return await device.device.async_get_led_setting()
|
||||
except DeviceUnavailable as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
async def async_update_last_restart() -> int:
|
||||
"""Fetch data from API endpoint."""
|
||||
assert device.device
|
||||
update_sw_version(device_registry, device)
|
||||
try:
|
||||
return await device.device.async_uptime()
|
||||
except DeviceUnavailable as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
except DevicePasswordProtected as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="password_wrong"
|
||||
) from err
|
||||
|
||||
async def async_update_wifi_connected_station() -> list[ConnectedStationInfo]:
|
||||
"""Fetch data from API endpoint."""
|
||||
assert device.device
|
||||
update_sw_version(device_registry, device)
|
||||
try:
|
||||
return await device.device.async_get_wifi_connected_station()
|
||||
except DeviceUnavailable as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
async def async_update_wifi_neighbor_access_points() -> list[NeighborAPInfo]:
|
||||
"""Fetch data from API endpoint."""
|
||||
assert device.device
|
||||
update_sw_version(device_registry, device)
|
||||
try:
|
||||
return await device.device.async_get_wifi_neighbor_access_points()
|
||||
except DeviceUnavailable as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
async def disconnect(event: Event) -> None:
|
||||
"""Disconnect from device."""
|
||||
await device.async_disconnect()
|
||||
|
||||
coordinators: dict[str, DevoloDataUpdateCoordinator[Any]] = {}
|
||||
if device.plcnet:
|
||||
coordinators[CONNECTED_PLC_DEVICES] = DevoloDataUpdateCoordinator(
|
||||
coordinators[CONNECTED_PLC_DEVICES] = DevoloLogicalNetworkCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=CONNECTED_PLC_DEVICES,
|
||||
semaphore=semaphore,
|
||||
update_method=async_update_connected_plc_devices,
|
||||
update_interval=LONG_UPDATE_INTERVAL,
|
||||
)
|
||||
if device.device and "led" in device.device.features:
|
||||
coordinators[SWITCH_LEDS] = DevoloDataUpdateCoordinator(
|
||||
coordinators[SWITCH_LEDS] = DevoloLedSettingsGetCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=SWITCH_LEDS,
|
||||
semaphore=semaphore,
|
||||
update_method=async_update_led_status,
|
||||
update_interval=SHORT_UPDATE_INTERVAL,
|
||||
)
|
||||
if device.device and "restart" in device.device.features:
|
||||
coordinators[LAST_RESTART] = DevoloDataUpdateCoordinator(
|
||||
coordinators[LAST_RESTART] = DevoloUptimeGetCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=LAST_RESTART,
|
||||
semaphore=semaphore,
|
||||
update_method=async_update_last_restart,
|
||||
update_interval=SHORT_UPDATE_INTERVAL,
|
||||
)
|
||||
if device.device and "update" in device.device.features:
|
||||
coordinators[REGULAR_FIRMWARE] = DevoloDataUpdateCoordinator(
|
||||
coordinators[REGULAR_FIRMWARE] = DevoloFirmwareUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=REGULAR_FIRMWARE,
|
||||
semaphore=semaphore,
|
||||
update_method=async_update_firmware_available,
|
||||
update_interval=FIRMWARE_UPDATE_INTERVAL,
|
||||
)
|
||||
if device.device and "wifi1" in device.device.features:
|
||||
coordinators[CONNECTED_WIFI_CLIENTS] = DevoloDataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=CONNECTED_WIFI_CLIENTS,
|
||||
semaphore=semaphore,
|
||||
update_method=async_update_wifi_connected_station,
|
||||
update_interval=SHORT_UPDATE_INTERVAL,
|
||||
coordinators[CONNECTED_WIFI_CLIENTS] = (
|
||||
DevoloWifiConnectedStationsGetCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
)
|
||||
)
|
||||
coordinators[NEIGHBORING_WIFI_NETWORKS] = DevoloDataUpdateCoordinator(
|
||||
coordinators[NEIGHBORING_WIFI_NETWORKS] = DevoloWifiNeighborAPsGetCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=NEIGHBORING_WIFI_NETWORKS,
|
||||
semaphore=semaphore,
|
||||
update_method=async_update_wifi_neighbor_access_points,
|
||||
update_interval=LONG_UPDATE_INTERVAL,
|
||||
)
|
||||
coordinators[SWITCH_GUEST_WIFI] = DevoloDataUpdateCoordinator(
|
||||
coordinators[SWITCH_GUEST_WIFI] = DevoloWifiGuestAccessGetCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=SWITCH_GUEST_WIFI,
|
||||
semaphore=semaphore,
|
||||
update_method=async_update_guest_wifi_status,
|
||||
update_interval=SHORT_UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
for coordinator in coordinators.values():
|
||||
@@ -303,16 +158,3 @@ def platforms(device: Device) -> set[Platform]:
|
||||
if device.device and "update" in device.device.features:
|
||||
supported_platforms.add(Platform.UPDATE)
|
||||
return supported_platforms
|
||||
|
||||
|
||||
@callback
|
||||
def update_sw_version(device_registry: dr.DeviceRegistry, device: Device) -> None:
|
||||
"""Update device registry with new firmware version."""
|
||||
if (
|
||||
device_entry := device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, str(device.serial_number))}
|
||||
)
|
||||
) and device_entry.sw_version != device.firmware_version:
|
||||
device_registry.async_update_device(
|
||||
device_id=device_entry.id, sw_version=device.firmware_version
|
||||
)
|
||||
|
||||
@@ -16,9 +16,8 @@ from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import CONNECTED_PLC_DEVICES, CONNECTED_TO_ROUTER
|
||||
from .coordinator import DevoloDataUpdateCoordinator
|
||||
from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry
|
||||
from .entity import DevoloCoordinatorEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -18,8 +18,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import DOMAIN, IDENTIFY, PAIRING, RESTART, START_WPS
|
||||
from .coordinator import DevoloHomeNetworkConfigEntry
|
||||
from .entity import DevoloEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -17,8 +17,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import DOMAIN, PRODUCT, SERIAL_NUMBER, TITLE
|
||||
from .coordinator import DevoloHomeNetworkConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,13 +1,44 @@
|
||||
"""Base coordinator."""
|
||||
|
||||
from asyncio import Semaphore
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from logging import Logger
|
||||
from typing import Any
|
||||
|
||||
from devolo_plc_api import Device
|
||||
from devolo_plc_api.device_api import (
|
||||
ConnectedStationInfo,
|
||||
NeighborAPInfo,
|
||||
UpdateFirmwareCheck,
|
||||
WifiGuestAccessGet,
|
||||
)
|
||||
from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable
|
||||
from devolo_plc_api.plcnet_api import LogicalNetwork
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
CONNECTED_PLC_DEVICES,
|
||||
CONNECTED_WIFI_CLIENTS,
|
||||
DOMAIN,
|
||||
FIRMWARE_UPDATE_INTERVAL,
|
||||
LAST_RESTART,
|
||||
LONG_UPDATE_INTERVAL,
|
||||
NEIGHBORING_WIFI_NETWORKS,
|
||||
REGULAR_FIRMWARE,
|
||||
SHORT_UPDATE_INTERVAL,
|
||||
SWITCH_GUEST_WIFI,
|
||||
SWITCH_LEDS,
|
||||
)
|
||||
|
||||
SEMAPHORE = Semaphore(1)
|
||||
|
||||
type DevoloHomeNetworkConfigEntry = ConfigEntry[DevoloHomeNetworkData]
|
||||
|
||||
|
||||
class DevoloDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
@@ -18,11 +49,62 @@ class DevoloDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
hass: HomeAssistant,
|
||||
logger: Logger,
|
||||
*,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: DevoloHomeNetworkConfigEntry,
|
||||
name: str,
|
||||
semaphore: Semaphore,
|
||||
update_interval: timedelta,
|
||||
update_method: Callable[[], Awaitable[_DataT]],
|
||||
update_interval: timedelta | None = None,
|
||||
) -> None:
|
||||
"""Initialize global data updater."""
|
||||
self.device = config_entry.runtime_data.device
|
||||
super().__init__(
|
||||
hass,
|
||||
logger,
|
||||
config_entry=config_entry,
|
||||
name=name,
|
||||
update_interval=update_interval,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> _DataT:
|
||||
"""Fetch the latest data from the source."""
|
||||
self.update_sw_version()
|
||||
async with SEMAPHORE:
|
||||
try:
|
||||
return await super()._async_update_data()
|
||||
except DeviceUnavailable as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
except DevicePasswordProtected as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="password_wrong"
|
||||
) from err
|
||||
|
||||
@callback
|
||||
def update_sw_version(self) -> None:
|
||||
"""Update device registry with new firmware version, if it changed at runtime."""
|
||||
device_registry = dr.async_get(self.hass)
|
||||
if (
|
||||
device_entry := device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, self.device.serial_number)}
|
||||
)
|
||||
) and device_entry.sw_version != self.device.firmware_version:
|
||||
device_registry.async_update_device(
|
||||
device_id=device_entry.id, sw_version=self.device.firmware_version
|
||||
)
|
||||
|
||||
|
||||
class DevoloFirmwareUpdateCoordinator(DevoloDataUpdateCoordinator[UpdateFirmwareCheck]):
|
||||
"""Class to manage fetching data from the UpdateFirmwareCheck endpoint."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
logger: Logger,
|
||||
*,
|
||||
config_entry: ConfigEntry,
|
||||
name: str = REGULAR_FIRMWARE,
|
||||
update_interval: timedelta | None = FIRMWARE_UPDATE_INTERVAL,
|
||||
) -> None:
|
||||
"""Initialize global data updater."""
|
||||
super().__init__(
|
||||
@@ -31,11 +113,192 @@ class DevoloDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
config_entry=config_entry,
|
||||
name=name,
|
||||
update_interval=update_interval,
|
||||
update_method=update_method,
|
||||
)
|
||||
self._semaphore = semaphore
|
||||
self.update_method = self.async_update_firmware_available
|
||||
|
||||
async def _async_update_data(self) -> _DataT:
|
||||
"""Fetch the latest data from the source."""
|
||||
async with self._semaphore:
|
||||
return await super()._async_update_data()
|
||||
async def async_update_firmware_available(self) -> UpdateFirmwareCheck:
|
||||
"""Fetch data from API endpoint."""
|
||||
assert self.device.device
|
||||
return await self.device.device.async_check_firmware_available()
|
||||
|
||||
|
||||
class DevoloLedSettingsGetCoordinator(DevoloDataUpdateCoordinator[bool]):
|
||||
"""Class to manage fetching data from the LedSettingsGet endpoint."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
logger: Logger,
|
||||
*,
|
||||
config_entry: ConfigEntry,
|
||||
name: str = SWITCH_LEDS,
|
||||
update_interval: timedelta | None = SHORT_UPDATE_INTERVAL,
|
||||
) -> None:
|
||||
"""Initialize global data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger,
|
||||
config_entry=config_entry,
|
||||
name=name,
|
||||
update_interval=update_interval,
|
||||
)
|
||||
self.update_method = self.async_update_led_status
|
||||
|
||||
async def async_update_led_status(self) -> bool:
|
||||
"""Fetch data from API endpoint."""
|
||||
assert self.device.device
|
||||
return await self.device.device.async_get_led_setting()
|
||||
|
||||
|
||||
class DevoloLogicalNetworkCoordinator(DevoloDataUpdateCoordinator[LogicalNetwork]):
|
||||
"""Class to manage fetching data from the GetNetworkOverview endpoint."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
logger: Logger,
|
||||
*,
|
||||
config_entry: ConfigEntry,
|
||||
name: str = CONNECTED_PLC_DEVICES,
|
||||
update_interval: timedelta | None = LONG_UPDATE_INTERVAL,
|
||||
) -> None:
|
||||
"""Initialize global data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger,
|
||||
config_entry=config_entry,
|
||||
name=name,
|
||||
update_interval=update_interval,
|
||||
)
|
||||
self.update_method = self.async_update_connected_plc_devices
|
||||
|
||||
async def async_update_connected_plc_devices(self) -> LogicalNetwork:
|
||||
"""Fetch data from API endpoint."""
|
||||
assert self.device.plcnet
|
||||
return await self.device.plcnet.async_get_network_overview()
|
||||
|
||||
|
||||
class DevoloUptimeGetCoordinator(DevoloDataUpdateCoordinator[int]):
|
||||
"""Class to manage fetching data from the UptimeGet endpoint."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
logger: Logger,
|
||||
*,
|
||||
config_entry: ConfigEntry,
|
||||
name: str = LAST_RESTART,
|
||||
update_interval: timedelta | None = SHORT_UPDATE_INTERVAL,
|
||||
) -> None:
|
||||
"""Initialize global data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger,
|
||||
config_entry=config_entry,
|
||||
name=name,
|
||||
update_interval=update_interval,
|
||||
)
|
||||
self.update_method = self.async_update_last_restart
|
||||
|
||||
async def async_update_last_restart(self) -> int:
|
||||
"""Fetch data from API endpoint."""
|
||||
assert self.device.device
|
||||
return await self.device.device.async_uptime()
|
||||
|
||||
|
||||
class DevoloWifiConnectedStationsGetCoordinator(
|
||||
DevoloDataUpdateCoordinator[list[ConnectedStationInfo]]
|
||||
):
|
||||
"""Class to manage fetching data from the WifiGuestAccessGet endpoint."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
logger: Logger,
|
||||
*,
|
||||
config_entry: ConfigEntry,
|
||||
name: str = CONNECTED_WIFI_CLIENTS,
|
||||
update_interval: timedelta | None = SHORT_UPDATE_INTERVAL,
|
||||
) -> None:
|
||||
"""Initialize global data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger,
|
||||
config_entry=config_entry,
|
||||
name=name,
|
||||
update_interval=update_interval,
|
||||
)
|
||||
self.update_method = self.async_get_wifi_connected_station
|
||||
|
||||
async def async_get_wifi_connected_station(self) -> list[ConnectedStationInfo]:
|
||||
"""Fetch data from API endpoint."""
|
||||
assert self.device.device
|
||||
return await self.device.device.async_get_wifi_connected_station()
|
||||
|
||||
|
||||
class DevoloWifiGuestAccessGetCoordinator(
|
||||
DevoloDataUpdateCoordinator[WifiGuestAccessGet]
|
||||
):
|
||||
"""Class to manage fetching data from the WifiGuestAccessGet endpoint."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
logger: Logger,
|
||||
*,
|
||||
config_entry: ConfigEntry,
|
||||
name: str = SWITCH_GUEST_WIFI,
|
||||
update_interval: timedelta | None = SHORT_UPDATE_INTERVAL,
|
||||
) -> None:
|
||||
"""Initialize global data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger,
|
||||
config_entry=config_entry,
|
||||
name=name,
|
||||
update_interval=update_interval,
|
||||
)
|
||||
self.update_method = self.async_update_guest_wifi_status
|
||||
|
||||
async def async_update_guest_wifi_status(self) -> WifiGuestAccessGet:
|
||||
"""Fetch data from API endpoint."""
|
||||
assert self.device.device
|
||||
return await self.device.device.async_get_wifi_guest_access()
|
||||
|
||||
|
||||
class DevoloWifiNeighborAPsGetCoordinator(
|
||||
DevoloDataUpdateCoordinator[list[NeighborAPInfo]]
|
||||
):
|
||||
"""Class to manage fetching data from the WifiNeighborAPsGet endpoint."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
logger: Logger,
|
||||
*,
|
||||
config_entry: ConfigEntry,
|
||||
name: str = NEIGHBORING_WIFI_NETWORKS,
|
||||
update_interval: timedelta | None = LONG_UPDATE_INTERVAL,
|
||||
) -> None:
|
||||
"""Initialize global data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger,
|
||||
config_entry=config_entry,
|
||||
name=name,
|
||||
update_interval=update_interval,
|
||||
)
|
||||
self.update_method = self.async_update_wifi_neighbor_access_points
|
||||
|
||||
async def async_update_wifi_neighbor_access_points(self) -> list[NeighborAPInfo]:
|
||||
"""Fetch data from API endpoint."""
|
||||
assert self.device.device
|
||||
return await self.device.device.async_get_wifi_neighbor_access_points()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DevoloHomeNetworkData:
|
||||
"""The devolo Home Network data."""
|
||||
|
||||
device: Device
|
||||
coordinators: dict[str, DevoloDataUpdateCoordinator[Any]]
|
||||
|
||||
@@ -15,9 +15,8 @@ from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import CONNECTED_WIFI_CLIENTS, DOMAIN, WIFI_APTYPE, WIFI_BANDS
|
||||
from .coordinator import DevoloDataUpdateCoordinator
|
||||
from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
from .coordinator import DevoloHomeNetworkConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_PASSWORD}
|
||||
|
||||
|
||||
@@ -15,9 +15,8 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import DevoloDataUpdateCoordinator
|
||||
from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry
|
||||
|
||||
type _DataType = (
|
||||
LogicalNetwork
|
||||
|
||||
@@ -15,9 +15,8 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import IMAGE_GUEST_WIFI, SWITCH_GUEST_WIFI
|
||||
from .coordinator import DevoloDataUpdateCoordinator
|
||||
from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry
|
||||
from .entity import DevoloCoordinatorEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -22,7 +22,6 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import (
|
||||
CONNECTED_PLC_DEVICES,
|
||||
CONNECTED_WIFI_CLIENTS,
|
||||
@@ -31,7 +30,7 @@ from .const import (
|
||||
PLC_RX_RATE,
|
||||
PLC_TX_RATE,
|
||||
)
|
||||
from .coordinator import DevoloDataUpdateCoordinator
|
||||
from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry
|
||||
from .entity import DevoloCoordinatorEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -16,9 +16,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import DOMAIN, SWITCH_GUEST_WIFI, SWITCH_LEDS
|
||||
from .coordinator import DevoloDataUpdateCoordinator
|
||||
from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry
|
||||
from .entity import DevoloCoordinatorEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -21,9 +21,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import DOMAIN, REGULAR_FIRMWARE
|
||||
from .coordinator import DevoloDataUpdateCoordinator
|
||||
from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry
|
||||
from .entity import DevoloCoordinatorEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
"""The dlib_face_detect component."""
|
||||
|
||||
DOMAIN = "dlib_face_detect"
|
||||
|
||||
@@ -11,10 +11,17 @@ from homeassistant.components.image_processing import (
|
||||
ImageProcessingFaceEntity,
|
||||
)
|
||||
from homeassistant.const import ATTR_LOCATION, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE
|
||||
from homeassistant.core import HomeAssistant, split_entity_id
|
||||
from homeassistant.core import (
|
||||
DOMAIN as HOMEASSISTANT_DOMAIN,
|
||||
HomeAssistant,
|
||||
split_entity_id,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
PLATFORM_SCHEMA = IMAGE_PROCESSING_PLATFORM_SCHEMA
|
||||
|
||||
|
||||
@@ -25,6 +32,20 @@ def setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Dlib Face detection platform."""
|
||||
create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_system_packages_yaml_integration_{DOMAIN}",
|
||||
breaks_in_ha_version="2025.12.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_system_packages_yaml_integration",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Dlib Face Detect",
|
||||
},
|
||||
)
|
||||
source: list[dict[str, str]] = config[CONF_SOURCE]
|
||||
add_entities(
|
||||
DlibFaceDetectEntity(camera[CONF_ENTITY_ID], camera.get(CONF_NAME))
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
"""The dlib_face_identify component."""
|
||||
|
||||
CONF_FACES = "faces"
|
||||
DOMAIN = "dlib_face_identify"
|
||||
|
||||
@@ -15,14 +15,20 @@ from homeassistant.components.image_processing import (
|
||||
ImageProcessingFaceEntity,
|
||||
)
|
||||
from homeassistant.const import ATTR_NAME, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE
|
||||
from homeassistant.core import HomeAssistant, split_entity_id
|
||||
from homeassistant.core import (
|
||||
DOMAIN as HOMEASSISTANT_DOMAIN,
|
||||
HomeAssistant,
|
||||
split_entity_id,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import CONF_FACES, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_FACES = "faces"
|
||||
|
||||
PLATFORM_SCHEMA = IMAGE_PROCESSING_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
@@ -39,6 +45,21 @@ def setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Dlib Face detection platform."""
|
||||
create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_system_packages_yaml_integration_{DOMAIN}",
|
||||
breaks_in_ha_version="2025.12.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_system_packages_yaml_integration",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Dlib Face Identify",
|
||||
},
|
||||
)
|
||||
|
||||
confidence: float = config[CONF_CONFIDENCE]
|
||||
faces: dict[str, str] = config[CONF_FACES]
|
||||
source: list[dict[str, str]] = config[CONF_SOURCE]
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
import aiodns
|
||||
from aiodns.error import DNSError
|
||||
@@ -62,16 +62,16 @@ async def async_validate_hostname(
|
||||
"""Validate hostname."""
|
||||
|
||||
async def async_check(
|
||||
hostname: str, resolver: str, qtype: str, port: int = 53
|
||||
hostname: str, resolver: str, qtype: Literal["A", "AAAA"], port: int = 53
|
||||
) -> bool:
|
||||
"""Return if able to resolve hostname."""
|
||||
result = False
|
||||
result: bool = False
|
||||
with contextlib.suppress(DNSError):
|
||||
result = bool(
|
||||
await aiodns.DNSResolver( # type: ignore[call-overload]
|
||||
nameservers=[resolver], udp_port=port, tcp_port=port
|
||||
).query(hostname, qtype)
|
||||
_resolver = aiodns.DNSResolver(
|
||||
nameservers=[resolver], udp_port=port, tcp_port=port
|
||||
)
|
||||
result = bool(await _resolver.query(hostname, qtype))
|
||||
|
||||
return result
|
||||
|
||||
result: dict[str, bool] = {}
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any
|
||||
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.heater import EheimDigitalHeater
|
||||
from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit
|
||||
from eheimdigital.types import HeaterMode, HeaterUnit
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
PRESET_NONE,
|
||||
@@ -20,12 +20,11 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import HEATER_BIO_MODE, HEATER_PRESET_TO_HEATER_MODE, HEATER_SMART_MODE
|
||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||
from .entity import EheimDigitalEntity
|
||||
from .entity import EheimDigitalEntity, exception_handler
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -83,34 +82,28 @@ class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateE
|
||||
self._attr_unique_id = self._device_address
|
||||
self._async_update_attrs()
|
||||
|
||||
@exception_handler
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set the preset mode."""
|
||||
try:
|
||||
if preset_mode in HEATER_PRESET_TO_HEATER_MODE:
|
||||
await self._device.set_operation_mode(
|
||||
HEATER_PRESET_TO_HEATER_MODE[preset_mode]
|
||||
)
|
||||
except EheimDigitalClientError as err:
|
||||
raise HomeAssistantError from err
|
||||
if preset_mode in HEATER_PRESET_TO_HEATER_MODE:
|
||||
await self._device.set_operation_mode(
|
||||
HEATER_PRESET_TO_HEATER_MODE[preset_mode]
|
||||
)
|
||||
|
||||
@exception_handler
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set a new temperature."""
|
||||
try:
|
||||
if ATTR_TEMPERATURE in kwargs:
|
||||
await self._device.set_target_temperature(kwargs[ATTR_TEMPERATURE])
|
||||
except EheimDigitalClientError as err:
|
||||
raise HomeAssistantError from err
|
||||
if ATTR_TEMPERATURE in kwargs:
|
||||
await self._device.set_target_temperature(kwargs[ATTR_TEMPERATURE])
|
||||
|
||||
@exception_handler
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the heating mode."""
|
||||
try:
|
||||
match hvac_mode:
|
||||
case HVACMode.OFF:
|
||||
await self._device.set_active(active=False)
|
||||
case HVACMode.AUTO:
|
||||
await self._device.set_active(active=True)
|
||||
except EheimDigitalClientError as err:
|
||||
raise HomeAssistantError from err
|
||||
match hvac_mode:
|
||||
case HVACMode.OFF:
|
||||
await self._device.set_active(active=False)
|
||||
case HVACMode.AUTO:
|
||||
await self._device.set_active(active=True)
|
||||
|
||||
def _async_update_attrs(self) -> None:
|
||||
if self._device.temperature_unit == HeaterUnit.CELSIUS:
|
||||
|
||||
19
homeassistant/components/eheimdigital/diagnostics.py
Normal file
19
homeassistant/components/eheimdigital/diagnostics.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Diagnostics for the EHEIM Digital integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import EheimDigitalConfigEntry
|
||||
|
||||
TO_REDACT = {"emailAddr", "usrName"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: EheimDigitalConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
return async_redact_data(
|
||||
{"entry": entry.as_dict(), "data": entry.runtime_data.data}, TO_REDACT
|
||||
)
|
||||
@@ -1,12 +1,15 @@
|
||||
"""Base entity for EHEIM Digital."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import TYPE_CHECKING, Any, Concatenate
|
||||
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.types import EheimDigitalClientError
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -51,3 +54,24 @@ class EheimDigitalEntity[_DeviceT: EheimDigitalDevice](
|
||||
"""Update attributes when the coordinator updates."""
|
||||
self._async_update_attrs()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
|
||||
def exception_handler[_EntityT: EheimDigitalEntity[EheimDigitalDevice], **_P](
|
||||
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
|
||||
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
|
||||
"""Decorate AirGradient calls to handle exceptions.
|
||||
|
||||
A decorator that wraps the passed in function, catches AirGradient errors.
|
||||
"""
|
||||
|
||||
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except EheimDigitalClientError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={"error": str(error)},
|
||||
) from error
|
||||
|
||||
return handler
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any
|
||||
|
||||
from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.types import EheimDigitalClientError, LightMode
|
||||
from eheimdigital.types import LightMode
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
@@ -15,13 +15,12 @@ from homeassistant.components.light import (
|
||||
LightEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.color import brightness_to_value, value_to_brightness
|
||||
|
||||
from .const import EFFECT_DAYCL_MODE, EFFECT_TO_LIGHT_MODE
|
||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||
from .entity import EheimDigitalEntity
|
||||
from .entity import EheimDigitalEntity, exception_handler
|
||||
|
||||
BRIGHTNESS_SCALE = (1, 100)
|
||||
|
||||
@@ -88,30 +87,22 @@ class EheimDigitalClassicLEDControlLight(
|
||||
"""Return whether the entity is available."""
|
||||
return super().available and self._device.light_level[self._channel] is not None
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the light."""
|
||||
if ATTR_EFFECT in kwargs:
|
||||
await self._device.set_light_mode(EFFECT_TO_LIGHT_MODE[kwargs[ATTR_EFFECT]])
|
||||
return
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
if self._device.light_mode == LightMode.DAYCL_MODE:
|
||||
await self._device.set_light_mode(LightMode.MAN_MODE)
|
||||
try:
|
||||
await self._device.turn_on(
|
||||
int(brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS])),
|
||||
self._channel,
|
||||
)
|
||||
except EheimDigitalClientError as err:
|
||||
raise HomeAssistantError from err
|
||||
await self._device.turn_on(
|
||||
int(brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS])),
|
||||
self._channel,
|
||||
)
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the light."""
|
||||
if self._device.light_mode == LightMode.DAYCL_MODE:
|
||||
await self._device.set_light_mode(LightMode.MAN_MODE)
|
||||
try:
|
||||
await self._device.turn_off(self._channel)
|
||||
except EheimDigitalClientError as err:
|
||||
raise HomeAssistantError from err
|
||||
await self._device.turn_off(self._channel)
|
||||
|
||||
def _async_update_attrs(self) -> None:
|
||||
light_level = self._device.light_level[self._channel]
|
||||
|
||||
@@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||
from .entity import EheimDigitalEntity
|
||||
from .entity import EheimDigitalEntity, exception_handler
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -182,6 +182,7 @@ class EheimDigitalNumber(
|
||||
self._attr_unique_id = f"{self._device_address}_{description.key}"
|
||||
|
||||
@override
|
||||
@exception_handler
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
return await self.entity_description.set_value_fn(self._device, value)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
@@ -58,7 +58,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||
from .entity import EheimDigitalEntity
|
||||
from .entity import EheimDigitalEntity, exception_handler
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -94,6 +94,7 @@ class EheimDigitalSelect(
|
||||
self._attr_unique_id = f"{self._device_address}_{description.key}"
|
||||
|
||||
@override
|
||||
@exception_handler
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
return await self.entity_description.set_value_fn(self._device, option)
|
||||
|
||||
|
||||
@@ -101,5 +101,10 @@
|
||||
"name": "Night start time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"communication_error": {
|
||||
"message": "An error occurred while communicating with the EHEIM Digital hub: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||
from .entity import EheimDigitalEntity
|
||||
from .entity import EheimDigitalEntity, exception_handler
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -58,10 +58,12 @@ class EheimDigitalClassicVarioSwitch(
|
||||
self._async_update_attrs()
|
||||
|
||||
@override
|
||||
@exception_handler
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
await self._device.set_active(active=False)
|
||||
|
||||
@override
|
||||
@exception_handler
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
await self._device.set_active(active=True)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||
from .entity import EheimDigitalEntity
|
||||
from .entity import EheimDigitalEntity, exception_handler
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -122,6 +122,7 @@ class EheimDigitalTime(
|
||||
self._attr_unique_id = f"{device.mac_address}_{description.key}"
|
||||
|
||||
@override
|
||||
@exception_handler
|
||||
async def async_set_value(self, value: time) -> None:
|
||||
"""Change the time."""
|
||||
return await self.entity_description.set_value_fn(self._device, value)
|
||||
|
||||
@@ -105,9 +105,18 @@ DATA_SCHEMA_SETUP = vol.Schema(
|
||||
)
|
||||
|
||||
BASE_OPTIONS_SCHEMA = {
|
||||
vol.Optional(CONF_ENTITY_ID): EntitySelector(EntitySelectorConfig(read_only=True)),
|
||||
vol.Optional(CONF_FILTER_NAME): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=FILTERS,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key=CONF_FILTER_NAME,
|
||||
read_only=True,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_FILTER_PRECISION, default=DEFAULT_PRECISION): NumberSelector(
|
||||
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
OUTLIER_SCHEMA = vol.Schema(
|
||||
|
||||
@@ -23,12 +23,16 @@
|
||||
"data": {
|
||||
"window_size": "Window size",
|
||||
"precision": "Precision",
|
||||
"radius": "Radius"
|
||||
"radius": "Radius",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data::filter%]"
|
||||
},
|
||||
"data_description": {
|
||||
"window_size": "Size of the window of previous states.",
|
||||
"precision": "Defines the number of decimal places of the calculated sensor value.",
|
||||
"radius": "Band radius from median of previous states."
|
||||
"radius": "Band radius from median of previous states.",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
|
||||
}
|
||||
},
|
||||
"lowpass": {
|
||||
@@ -36,12 +40,16 @@
|
||||
"data": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
|
||||
"time_constant": "Time constant"
|
||||
"time_constant": "Time constant",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data::filter%]"
|
||||
},
|
||||
"data_description": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
|
||||
"time_constant": "Loosely relates to the amount of time it takes for a state to influence the output."
|
||||
"time_constant": "Loosely relates to the amount of time it takes for a state to influence the output.",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
|
||||
}
|
||||
},
|
||||
"range": {
|
||||
@@ -49,12 +57,16 @@
|
||||
"data": {
|
||||
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
|
||||
"lower_bound": "Lower bound",
|
||||
"upper_bound": "Upper bound"
|
||||
"upper_bound": "Upper bound",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data::filter%]"
|
||||
},
|
||||
"data_description": {
|
||||
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
|
||||
"lower_bound": "Lower bound for filter range.",
|
||||
"upper_bound": "Upper bound for filter range."
|
||||
"upper_bound": "Upper bound for filter range.",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
|
||||
}
|
||||
},
|
||||
"time_simple_moving_average": {
|
||||
@@ -62,34 +74,46 @@
|
||||
"data": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
|
||||
"type": "Type"
|
||||
"type": "Type",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data::filter%]"
|
||||
},
|
||||
"data_description": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
|
||||
"type": "Defines the type of Simple Moving Average."
|
||||
"type": "Defines the type of Simple Moving Average.",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
|
||||
}
|
||||
},
|
||||
"throttle": {
|
||||
"description": "[%key:component::filter::config::step::outlier::description%]",
|
||||
"data": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data::precision%]"
|
||||
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data::filter%]"
|
||||
},
|
||||
"data_description": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]"
|
||||
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
|
||||
}
|
||||
},
|
||||
"time_throttle": {
|
||||
"description": "[%key:component::filter::config::step::outlier::description%]",
|
||||
"data": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data::precision%]"
|
||||
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data::filter%]"
|
||||
},
|
||||
"data_description": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]"
|
||||
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,12 +128,16 @@
|
||||
"data": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
|
||||
"radius": "[%key:component::filter::config::step::outlier::data::radius%]"
|
||||
"radius": "[%key:component::filter::config::step::outlier::data::radius%]",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data::filter%]"
|
||||
},
|
||||
"data_description": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
|
||||
"radius": "[%key:component::filter::config::step::outlier::data_description::radius%]"
|
||||
"radius": "[%key:component::filter::config::step::outlier::data_description::radius%]",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
|
||||
}
|
||||
},
|
||||
"lowpass": {
|
||||
@@ -117,12 +145,16 @@
|
||||
"data": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
|
||||
"time_constant": "[%key:component::filter::config::step::lowpass::data::time_constant%]"
|
||||
"time_constant": "[%key:component::filter::config::step::lowpass::data::time_constant%]",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data::filter%]"
|
||||
},
|
||||
"data_description": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
|
||||
"time_constant": "[%key:component::filter::config::step::lowpass::data_description::time_constant%]"
|
||||
"time_constant": "[%key:component::filter::config::step::lowpass::data_description::time_constant%]",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
|
||||
}
|
||||
},
|
||||
"range": {
|
||||
@@ -130,12 +162,16 @@
|
||||
"data": {
|
||||
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
|
||||
"lower_bound": "[%key:component::filter::config::step::range::data::lower_bound%]",
|
||||
"upper_bound": "[%key:component::filter::config::step::range::data::upper_bound%]"
|
||||
"upper_bound": "[%key:component::filter::config::step::range::data::upper_bound%]",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data::filter%]"
|
||||
},
|
||||
"data_description": {
|
||||
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
|
||||
"lower_bound": "[%key:component::filter::config::step::range::data_description::lower_bound%]",
|
||||
"upper_bound": "[%key:component::filter::config::step::range::data_description::upper_bound%]"
|
||||
"upper_bound": "[%key:component::filter::config::step::range::data_description::upper_bound%]",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
|
||||
}
|
||||
},
|
||||
"time_simple_moving_average": {
|
||||
@@ -143,34 +179,46 @@
|
||||
"data": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
|
||||
"type": "[%key:component::filter::config::step::time_simple_moving_average::data::type%]"
|
||||
"type": "[%key:component::filter::config::step::time_simple_moving_average::data::type%]",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data::filter%]"
|
||||
},
|
||||
"data_description": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
|
||||
"type": "[%key:component::filter::config::step::time_simple_moving_average::data_description::type%]"
|
||||
"type": "[%key:component::filter::config::step::time_simple_moving_average::data_description::type%]",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
|
||||
}
|
||||
},
|
||||
"throttle": {
|
||||
"description": "[%key:component::filter::config::step::outlier::description%]",
|
||||
"data": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data::precision%]"
|
||||
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data::filter%]"
|
||||
},
|
||||
"data_description": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]"
|
||||
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
|
||||
}
|
||||
},
|
||||
"time_throttle": {
|
||||
"description": "[%key:component::filter::config::step::outlier::description%]",
|
||||
"data": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data::precision%]"
|
||||
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data::filter%]"
|
||||
},
|
||||
"data_description": {
|
||||
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
|
||||
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]"
|
||||
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
|
||||
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
|
||||
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, UnitOfDataRate, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -28,6 +29,7 @@ CONNECTION_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
key="rate_down",
|
||||
name="Freebox download speed",
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND,
|
||||
icon="mdi:download-network",
|
||||
),
|
||||
@@ -35,6 +37,7 @@ CONNECTION_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
key="rate_up",
|
||||
name="Freebox upload speed",
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND,
|
||||
icon="mdi:upload-network",
|
||||
),
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyfronius"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["PyFronius==0.7.7"]
|
||||
"requirements": ["PyFronius==0.8.0"]
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250516.0"]
|
||||
"requirements": ["home-assistant-frontend==20250528.0"]
|
||||
}
|
||||
|
||||
@@ -84,7 +84,10 @@ async def async_migrate_entry(
|
||||
new[CONF_EXPIRATION] = credentials.expiration.isoformat()
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, data=new, minor_version=2, version=1
|
||||
config_entry,
|
||||
data=new,
|
||||
minor_version=2,
|
||||
version=1,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -2,9 +2,20 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.components.image import ImageEntity, ImageEntityDescription
|
||||
from fyta_cli.fyta_models import Plant
|
||||
|
||||
from homeassistant.components.image import (
|
||||
Image,
|
||||
ImageEntity,
|
||||
ImageEntityDescription,
|
||||
valid_image_content_type,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -12,6 +23,30 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .coordinator import FytaConfigEntry, FytaCoordinator
|
||||
from .entity import FytaPlantEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class FytaImageEntityDescription(ImageEntityDescription):
|
||||
"""Describes Fyta image entity."""
|
||||
|
||||
url_fn: Callable[[Plant], str]
|
||||
name_key: str | None = None
|
||||
|
||||
|
||||
IMAGES: Final[list[FytaImageEntityDescription]] = [
|
||||
FytaImageEntityDescription(
|
||||
key="plant_image",
|
||||
translation_key="plant_image",
|
||||
url_fn=lambda plant: plant.plant_origin_path,
|
||||
),
|
||||
FytaImageEntityDescription(
|
||||
key="plant_image_user",
|
||||
translation_key="plant_image_user",
|
||||
url_fn=lambda plant: plant.user_picture_path,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -21,17 +56,17 @@ async def async_setup_entry(
|
||||
"""Set up the FYTA plant images."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
description = ImageEntityDescription(key="plant_image")
|
||||
|
||||
async_add_entities(
|
||||
FytaPlantImageEntity(coordinator, entry, description, plant_id)
|
||||
for plant_id in coordinator.fyta.plant_list
|
||||
if plant_id in coordinator.data
|
||||
for description in IMAGES
|
||||
)
|
||||
|
||||
def _async_add_new_device(plant_id: int) -> None:
|
||||
async_add_entities(
|
||||
[FytaPlantImageEntity(coordinator, entry, description, plant_id)]
|
||||
FytaPlantImageEntity(coordinator, entry, description, plant_id)
|
||||
for description in IMAGES
|
||||
)
|
||||
|
||||
coordinator.new_device_callbacks.append(_async_add_new_device)
|
||||
@@ -40,26 +75,49 @@ async def async_setup_entry(
|
||||
class FytaPlantImageEntity(FytaPlantEntity, ImageEntity):
|
||||
"""Represents a Fyta image."""
|
||||
|
||||
entity_description: ImageEntityDescription
|
||||
entity_description: FytaImageEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FytaCoordinator,
|
||||
entry: ConfigEntry,
|
||||
description: ImageEntityDescription,
|
||||
description: FytaImageEntityDescription,
|
||||
plant_id: int,
|
||||
) -> None:
|
||||
"""Initiatlize Fyta Image entity."""
|
||||
"""Initialize Fyta Image entity."""
|
||||
super().__init__(coordinator, entry, description, plant_id)
|
||||
ImageEntity.__init__(self, coordinator.hass)
|
||||
|
||||
self._attr_name = None
|
||||
async def async_image(self) -> bytes | None:
|
||||
"""Return bytes of image."""
|
||||
if self.entity_description.key == "plant_image_user":
|
||||
if self._cached_image is None:
|
||||
response = await self.coordinator.fyta.get_plant_image(
|
||||
self.plant.user_picture_path
|
||||
)
|
||||
_LOGGER.debug("Response of downloading user image: %s", response)
|
||||
if response is None:
|
||||
_LOGGER.debug(
|
||||
"%s: Error getting new image from %s",
|
||||
self.entity_id,
|
||||
self.plant.user_picture_path,
|
||||
)
|
||||
return None
|
||||
|
||||
content_type, raw_image = response
|
||||
self._cached_image = Image(
|
||||
valid_image_content_type(content_type), raw_image
|
||||
)
|
||||
|
||||
return self._cached_image.content
|
||||
return await ImageEntity.async_image(self)
|
||||
|
||||
@property
|
||||
def image_url(self) -> str:
|
||||
"""Return the image_url for this sensor."""
|
||||
image = self.plant.plant_origin_path
|
||||
if image != self._attr_image_url:
|
||||
self._attr_image_last_updated = datetime.now()
|
||||
"""Return the image_url for this plant."""
|
||||
url = self.entity_description.url_fn(self.plant)
|
||||
|
||||
return image
|
||||
if url != self._attr_image_url:
|
||||
self._cached_image = None
|
||||
self._attr_image_last_updated = datetime.now()
|
||||
return url
|
||||
|
||||
@@ -61,6 +61,14 @@
|
||||
"name": "Sensor update available"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"plant_image": {
|
||||
"name": "Plant image"
|
||||
},
|
||||
"plant_image_user": {
|
||||
"name": "User image"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"scientific_name": {
|
||||
"name": "Scientific name"
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/google",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==7.0.1", "oauth2client==4.1.3", "ical==9.2.4"]
|
||||
"requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==9.2.5"]
|
||||
}
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
from collections.abc import Callable
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from dataclasses import replace
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from google.genai.errors import APIError
|
||||
from google.genai.errors import APIError, ClientError
|
||||
from google.genai.types import (
|
||||
AutomaticFunctionCallingConfig,
|
||||
Content,
|
||||
FunctionDeclaration,
|
||||
GenerateContentConfig,
|
||||
GenerateContentResponse,
|
||||
GoogleSearch,
|
||||
HarmCategory,
|
||||
Part,
|
||||
@@ -233,6 +234,81 @@ def _convert_content(
|
||||
return Content(role="model", parts=parts)
|
||||
|
||||
|
||||
async def _transform_stream(
|
||||
result: AsyncGenerator[GenerateContentResponse],
|
||||
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
|
||||
new_message = True
|
||||
try:
|
||||
async for response in result:
|
||||
LOGGER.debug("Received response chunk: %s", response)
|
||||
chunk: conversation.AssistantContentDeltaDict = {}
|
||||
|
||||
if new_message:
|
||||
chunk["role"] = "assistant"
|
||||
new_message = False
|
||||
|
||||
# According to the API docs, this would mean no candidate is returned, so we can safely throw an error here.
|
||||
if response.prompt_feedback or not response.candidates:
|
||||
reason = (
|
||||
response.prompt_feedback.block_reason_message
|
||||
if response.prompt_feedback
|
||||
else "unknown"
|
||||
)
|
||||
raise HomeAssistantError(
|
||||
f"The message got blocked due to content violations, reason: {reason}"
|
||||
)
|
||||
|
||||
candidate = response.candidates[0]
|
||||
|
||||
if (
|
||||
candidate.finish_reason is not None
|
||||
and candidate.finish_reason != "STOP"
|
||||
):
|
||||
# The message ended due to a content error as explained in: https://ai.google.dev/api/generate-content#FinishReason
|
||||
LOGGER.error(
|
||||
"Error in Google Generative AI response: %s, see: https://ai.google.dev/api/generate-content#FinishReason",
|
||||
candidate.finish_reason,
|
||||
)
|
||||
raise HomeAssistantError(
|
||||
f"{ERROR_GETTING_RESPONSE} Reason: {candidate.finish_reason}"
|
||||
)
|
||||
|
||||
response_parts = (
|
||||
candidate.content.parts
|
||||
if candidate.content is not None and candidate.content.parts is not None
|
||||
else []
|
||||
)
|
||||
|
||||
content = "".join([part.text for part in response_parts if part.text])
|
||||
tool_calls = []
|
||||
for part in response_parts:
|
||||
if not part.function_call:
|
||||
continue
|
||||
tool_call = part.function_call
|
||||
tool_name = tool_call.name if tool_call.name else ""
|
||||
tool_args = _escape_decode(tool_call.args)
|
||||
tool_calls.append(
|
||||
llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
|
||||
)
|
||||
|
||||
if tool_calls:
|
||||
chunk["tool_calls"] = tool_calls
|
||||
|
||||
chunk["content"] = content
|
||||
yield chunk
|
||||
except (
|
||||
APIError,
|
||||
ValueError,
|
||||
) as err:
|
||||
LOGGER.error("Error sending message: %s %s", type(err), err)
|
||||
if isinstance(err, APIError):
|
||||
message = err.message
|
||||
else:
|
||||
message = type(err).__name__
|
||||
error = f"{ERROR_GETTING_RESPONSE}: {message}"
|
||||
raise HomeAssistantError(error) from err
|
||||
|
||||
|
||||
class GoogleGenerativeAIConversationEntity(
|
||||
conversation.ConversationEntity, conversation.AbstractConversationAgent
|
||||
):
|
||||
@@ -240,6 +316,7 @@ class GoogleGenerativeAIConversationEntity(
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_supports_streaming = True
|
||||
|
||||
def __init__(self, entry: ConfigEntry) -> None:
|
||||
"""Initialize the agent."""
|
||||
@@ -426,80 +503,40 @@ class GoogleGenerativeAIConversationEntity(
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
try:
|
||||
chat_response = await chat.send_message(message=chat_request)
|
||||
|
||||
if chat_response.prompt_feedback:
|
||||
raise HomeAssistantError(
|
||||
f"The message got blocked due to content violations, reason: {chat_response.prompt_feedback.block_reason_message}"
|
||||
)
|
||||
if not chat_response.candidates:
|
||||
LOGGER.error(
|
||||
"No candidates found in the response: %s",
|
||||
chat_response,
|
||||
)
|
||||
raise HomeAssistantError(ERROR_GETTING_RESPONSE)
|
||||
|
||||
chat_response_generator = await chat.send_message_stream(
|
||||
message=chat_request
|
||||
)
|
||||
except (
|
||||
APIError,
|
||||
ClientError,
|
||||
ValueError,
|
||||
) as err:
|
||||
LOGGER.error("Error sending message: %s %s", type(err), err)
|
||||
error = f"Sorry, I had a problem talking to Google Generative AI: {err}"
|
||||
error = ERROR_GETTING_RESPONSE
|
||||
raise HomeAssistantError(error) from err
|
||||
|
||||
if (usage_metadata := chat_response.usage_metadata) is not None:
|
||||
chat_log.async_trace(
|
||||
{
|
||||
"stats": {
|
||||
"input_tokens": usage_metadata.prompt_token_count,
|
||||
"cached_input_tokens": usage_metadata.cached_content_token_count
|
||||
or 0,
|
||||
"output_tokens": usage_metadata.candidates_token_count,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
response_parts = chat_response.candidates[0].content.parts
|
||||
if not response_parts:
|
||||
raise HomeAssistantError(ERROR_GETTING_RESPONSE)
|
||||
content = " ".join(
|
||||
[part.text.strip() for part in response_parts if part.text]
|
||||
)
|
||||
|
||||
tool_calls = []
|
||||
for part in response_parts:
|
||||
if not part.function_call:
|
||||
continue
|
||||
tool_call = part.function_call
|
||||
tool_name = tool_call.name
|
||||
tool_args = _escape_decode(tool_call.args)
|
||||
tool_calls.append(
|
||||
llm.ToolInput(
|
||||
tool_name=self._fix_tool_name(tool_name),
|
||||
tool_args=tool_args,
|
||||
)
|
||||
)
|
||||
|
||||
chat_request = _create_google_tool_response_parts(
|
||||
[
|
||||
tool_response
|
||||
async for tool_response in chat_log.async_add_assistant_content(
|
||||
conversation.AssistantContent(
|
||||
agent_id=user_input.agent_id,
|
||||
content=content,
|
||||
tool_calls=tool_calls or None,
|
||||
)
|
||||
content
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
user_input.agent_id,
|
||||
_transform_stream(chat_response_generator),
|
||||
)
|
||||
if isinstance(content, conversation.ToolResultContent)
|
||||
]
|
||||
)
|
||||
|
||||
if not tool_calls:
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
|
||||
response = intent.IntentResponse(language=user_input.language)
|
||||
response.async_set_speech(
|
||||
" ".join([part.text.strip() for part in response_parts if part.text])
|
||||
)
|
||||
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
|
||||
LOGGER.error(
|
||||
"Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response",
|
||||
chat_log.content[-1],
|
||||
)
|
||||
raise HomeAssistantError(f"{ERROR_GETTING_RESPONSE}")
|
||||
response.async_set_speech(chat_log.content[-1].content or "")
|
||||
return conversation.ConversationResult(
|
||||
response=response,
|
||||
conversation_id=chat_log.conversation_id,
|
||||
|
||||
@@ -50,7 +50,12 @@ from .const import (
|
||||
UNITS_IMPERIAL,
|
||||
UNITS_METRIC,
|
||||
)
|
||||
from .helpers import InvalidApiKeyException, UnknownException, validate_config_entry
|
||||
from .helpers import (
|
||||
InvalidApiKeyException,
|
||||
PermissionDeniedException,
|
||||
UnknownException,
|
||||
validate_config_entry,
|
||||
)
|
||||
|
||||
RECONFIGURE_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -188,6 +193,8 @@ async def validate_input(
|
||||
user_input[CONF_ORIGIN],
|
||||
user_input[CONF_DESTINATION],
|
||||
)
|
||||
except PermissionDeniedException:
|
||||
return {"base": "permission_denied"}
|
||||
except InvalidApiKeyException:
|
||||
return {"base": "invalid_auth"}
|
||||
except TimeoutError:
|
||||
|
||||
@@ -7,6 +7,7 @@ from google.api_core.exceptions import (
|
||||
Forbidden,
|
||||
GatewayTimeout,
|
||||
GoogleAPIError,
|
||||
PermissionDenied,
|
||||
Unauthorized,
|
||||
)
|
||||
from google.maps.routing_v2 import (
|
||||
@@ -19,10 +20,18 @@ from google.maps.routing_v2 import (
|
||||
from google.type import latlng_pb2
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
from homeassistant.helpers.location import find_coordinates
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -37,7 +46,7 @@ def convert_to_waypoint(hass: HomeAssistant, location: str) -> Waypoint | None:
|
||||
try:
|
||||
formatted_coordinates = coordinates.split(",")
|
||||
vol.Schema(cv.gps(formatted_coordinates))
|
||||
except (AttributeError, vol.ExactSequenceInvalid):
|
||||
except (AttributeError, vol.Invalid):
|
||||
return Waypoint(address=location)
|
||||
return Waypoint(
|
||||
location=Location(
|
||||
@@ -67,6 +76,9 @@ async def validate_config_entry(
|
||||
await client.compute_routes(
|
||||
request, metadata=[("x-goog-fieldmask", field_mask)]
|
||||
)
|
||||
except PermissionDenied as permission_error:
|
||||
_LOGGER.error("Permission denied: %s", permission_error.message)
|
||||
raise PermissionDeniedException from permission_error
|
||||
except (Unauthorized, Forbidden) as unauthorized_error:
|
||||
_LOGGER.error("Request denied: %s", unauthorized_error.message)
|
||||
raise InvalidApiKeyException from unauthorized_error
|
||||
@@ -84,3 +96,30 @@ class InvalidApiKeyException(Exception):
|
||||
|
||||
class UnknownException(Exception):
|
||||
"""Unknown API Error."""
|
||||
|
||||
|
||||
class PermissionDeniedException(Exception):
|
||||
"""Permission Denied Error."""
|
||||
|
||||
|
||||
def create_routes_api_disabled_issue(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Create an issue for the Routes API being disabled."""
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"routes_api_disabled_{entry.entry_id}",
|
||||
learn_more_url="https://www.home-assistant.io/integrations/google_travel_time#setup",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.ERROR,
|
||||
translation_key="routes_api_disabled",
|
||||
translation_placeholders={
|
||||
"entry_title": entry.title,
|
||||
"enable_api_url": "https://cloud.google.com/endpoints/docs/openapi/enable-api",
|
||||
"api_key_restrictions_url": "https://cloud.google.com/docs/authentication/api-keys#adding-api-restrictions",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def delete_routes_api_disabled_issue(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Delete the issue for the Routes API being disabled."""
|
||||
async_delete_issue(hass, DOMAIN, f"routes_api_disabled_{entry.entry_id}")
|
||||
|
||||
@@ -7,7 +7,7 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from google.api_core.client_options import ClientOptions
|
||||
from google.api_core.exceptions import GoogleAPIError
|
||||
from google.api_core.exceptions import GoogleAPIError, PermissionDenied
|
||||
from google.maps.routing_v2 import (
|
||||
ComputeRoutesRequest,
|
||||
Route,
|
||||
@@ -58,7 +58,11 @@ from .const import (
|
||||
TRAVEL_MODES_TO_GOOGLE_SDK_ENUM,
|
||||
UNITS_TO_GOOGLE_SDK_ENUM,
|
||||
)
|
||||
from .helpers import convert_to_waypoint
|
||||
from .helpers import (
|
||||
convert_to_waypoint,
|
||||
create_routes_api_disabled_issue,
|
||||
delete_routes_api_disabled_issue,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -271,8 +275,14 @@ class GoogleTravelTimeSensor(SensorEntity):
|
||||
response = await self._client.compute_routes(
|
||||
request, metadata=[("x-goog-fieldmask", FIELD_MASK)]
|
||||
)
|
||||
_LOGGER.debug("Received response: %s", response)
|
||||
if response is not None and len(response.routes) > 0:
|
||||
self._route = response.routes[0]
|
||||
delete_routes_api_disabled_issue(self.hass, self._config_entry)
|
||||
except PermissionDenied:
|
||||
_LOGGER.error("Routes API is disabled for this API key")
|
||||
create_routes_api_disabled_issue(self.hass, self._config_entry)
|
||||
self._route = None
|
||||
except GoogleAPIError as ex:
|
||||
_LOGGER.error("Error getting travel time: %s", ex)
|
||||
self._route = None
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"permission_denied": "The Routes API is not enabled for this API key. Please see the setup instructions for detailed information.",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]"
|
||||
@@ -100,5 +101,11 @@
|
||||
"fewer_transfers": "Fewer transfers"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"routes_api_disabled": {
|
||||
"title": "The Routes API must be enabled",
|
||||
"description": "Your Google Travel Time integration `{entry_title}` uses an API key which does not have the Routes API enabled.\n\n Please follow the instructions to [enable the API for your project]({enable_api_url}) and make sure your [API key restrictions]({api_key_restrictions_url}) allow access to the Routes API.\n\n After enabling the API this issue will be resolved automatically."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ from homeassistant.helpers.issue_registry import (
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
|
||||
|
||||
from .const import CONF_IGNORE_NON_NUMERIC, DOMAIN as GROUP_DOMAIN
|
||||
from .const import CONF_IGNORE_NON_NUMERIC, DOMAIN
|
||||
from .entity import GroupEntity
|
||||
|
||||
DEFAULT_NAME = "Sensor Group"
|
||||
@@ -509,7 +509,7 @@ class SensorGroup(GroupEntity, SensorEntity):
|
||||
return state_classes[0]
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
GROUP_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{self.entity_id}_state_classes_not_matching",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
@@ -566,7 +566,7 @@ class SensorGroup(GroupEntity, SensorEntity):
|
||||
return device_classes[0]
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
GROUP_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{self.entity_id}_device_classes_not_matching",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
@@ -654,7 +654,7 @@ class SensorGroup(GroupEntity, SensorEntity):
|
||||
if device_class:
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
GROUP_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{self.entity_id}_uoms_not_matching_device_class",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
@@ -670,7 +670,7 @@ class SensorGroup(GroupEntity, SensorEntity):
|
||||
else:
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
GROUP_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{self.entity_id}_uoms_not_matching_no_device_class",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
"""The gstreamer component."""
|
||||
|
||||
DOMAIN = "gstreamer"
|
||||
|
||||
@@ -19,16 +19,18 @@ from homeassistant.components.media_player import (
|
||||
async_process_play_media_url,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_PIPELINE = "pipeline"
|
||||
|
||||
DOMAIN = "gstreamer"
|
||||
|
||||
PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
|
||||
{vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_PIPELINE): cv.string}
|
||||
@@ -48,6 +50,20 @@ def setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Gstreamer platform."""
|
||||
create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_system_packages_yaml_integration_{DOMAIN}",
|
||||
breaks_in_ha_version="2025.12.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_system_packages_yaml_integration",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "GStreamer",
|
||||
},
|
||||
)
|
||||
|
||||
name = config.get(CONF_NAME)
|
||||
pipeline = config.get(CONF_PIPELINE)
|
||||
|
||||
@@ -429,10 +429,19 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
for slug, errors in _addon_errors.items():
|
||||
try:
|
||||
addon_info = await self._client.addons.addon_info(slug)
|
||||
addon_errors[slug] = AddonErrorData(name=addon_info.name, errors=errors)
|
||||
addon_errors[slug] = AddonErrorData(
|
||||
addon=AddonInfo(
|
||||
name=addon_info.name,
|
||||
slug=addon_info.slug,
|
||||
version=addon_info.version,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
except SupervisorError as err:
|
||||
_LOGGER.debug("Error getting addon %s: %s", slug, err)
|
||||
addon_errors[slug] = AddonErrorData(name=slug, errors=errors)
|
||||
addon_errors[slug] = AddonErrorData(
|
||||
addon=AddonInfo(name=None, slug=slug, version=None), errors=errors
|
||||
)
|
||||
|
||||
_folder_errors = _collect_errors(
|
||||
full_status, "backup_store_folders", "backup_folder_save"
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
"macaddress": "C8D778*"
|
||||
},
|
||||
{
|
||||
"hostname": "(bosch|siemens)-*",
|
||||
"hostname": "(balay|bosch|neff|siemens)-*",
|
||||
"macaddress": "68A40E*"
|
||||
},
|
||||
{
|
||||
"hostname": "siemens-*",
|
||||
"hostname": "(siemens|neff)-*",
|
||||
"macaddress": "38B4D3*"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"reauth_confirm": {
|
||||
"title": "[%key:common::config_flow::title::reauth%]",
|
||||
"description": "The Home Connect integration needs to re-authenticate your account"
|
||||
},
|
||||
"oauth_discovery": {
|
||||
"description": "Home Assistant has found a Home Connect device on your network. Press **Submit** to continue setting up Home Connect."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
@@ -156,28 +159,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated_program_switch_in_automations_scripts": {
|
||||
"title": "Deprecated program switch detected in some automations or scripts",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "[%key:component::home_connect::issues::deprecated_program_switch_in_automations_scripts::title%]",
|
||||
"description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated_program_switch": {
|
||||
"title": "Deprecated program switch entities",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "[%key:component::home_connect::issues::deprecated_program_switch::title%]",
|
||||
"description": "The switch entity `{entity_id}` and all the other program switches are deprecated.\n\nPlease use the active program select entity instead."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated_set_program_and_option_actions": {
|
||||
"title": "The executed action is deprecated",
|
||||
"fix_flow": {
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
from collections.abc import Callable, Coroutine
|
||||
import itertools as it
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -31,14 +31,22 @@ from homeassistant.core import (
|
||||
split_entity_id,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, Unauthorized, UnknownUser
|
||||
from homeassistant.helpers import config_validation as cv, recorder, restore_state
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
issue_registry as ir,
|
||||
recorder,
|
||||
restore_state,
|
||||
)
|
||||
from homeassistant.helpers.entity_component import async_update_entity
|
||||
from homeassistant.helpers.importlib import async_import_module
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity
|
||||
from homeassistant.helpers.service import (
|
||||
async_extract_config_entry_ids,
|
||||
async_extract_referenced_entity_ids,
|
||||
async_register_admin_service,
|
||||
)
|
||||
from homeassistant.helpers.signal import KEY_HA_STOP
|
||||
from homeassistant.helpers.system_info import async_get_system_info
|
||||
from homeassistant.helpers.template import async_load_custom_templates
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -81,6 +89,11 @@ SCHEMA_RESTART = vol.Schema({vol.Optional(ATTR_SAFE_MODE, default=False): bool})
|
||||
|
||||
SHUTDOWN_SERVICES = (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART)
|
||||
|
||||
DEPRECATION_URL = (
|
||||
"https://www.home-assistant.io/blog/2025/05/22/"
|
||||
"deprecating-core-and-supervised-installation-methods-and-32-bit-systems/"
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901
|
||||
"""Set up general services related to Home Assistant."""
|
||||
@@ -386,6 +399,83 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
hass.data[DATA_EXPOSED_ENTITIES] = exposed_entities
|
||||
async_set_stop_handler(hass, _async_stop)
|
||||
|
||||
info = await async_get_system_info(hass)
|
||||
|
||||
installation_type = info["installation_type"][15:]
|
||||
deprecated_method = installation_type in {
|
||||
"Core",
|
||||
"Supervised",
|
||||
}
|
||||
arch = info["arch"]
|
||||
if arch == "armv7":
|
||||
if installation_type == "OS":
|
||||
# Local import to avoid circular dependencies
|
||||
# We use the import helper because hassio
|
||||
# may not be loaded yet and we don't want to
|
||||
# do blocking I/O in the event loop to import it.
|
||||
if TYPE_CHECKING:
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components import hassio
|
||||
else:
|
||||
hassio = await async_import_module(
|
||||
hass, "homeassistant.components.hassio"
|
||||
)
|
||||
os_info = hassio.get_os_info(hass)
|
||||
assert os_info is not None
|
||||
issue_id = "deprecated_os_"
|
||||
board = os_info.get("board")
|
||||
if board in {"rpi3", "rpi4"}:
|
||||
issue_id += "aarch64"
|
||||
elif board in {"tinker", "odroid-xu4", "rpi2"}:
|
||||
issue_id += "armv7"
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
breaks_in_ha_version="2025.12.0",
|
||||
learn_more_url=DEPRECATION_URL,
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key=issue_id,
|
||||
translation_placeholders={
|
||||
"installation_guide": "https://www.home-assistant.io/installation/",
|
||||
},
|
||||
)
|
||||
elif installation_type == "Container":
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_container_armv7",
|
||||
breaks_in_ha_version="2025.12.0",
|
||||
learn_more_url=DEPRECATION_URL,
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_container_armv7",
|
||||
)
|
||||
deprecated_architecture = False
|
||||
if arch in {"i386", "armhf"} or (arch == "armv7" and deprecated_method):
|
||||
deprecated_architecture = True
|
||||
if deprecated_method or deprecated_architecture:
|
||||
issue_id = "deprecated"
|
||||
if deprecated_method:
|
||||
issue_id += "_method"
|
||||
if deprecated_architecture:
|
||||
issue_id += "_architecture"
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
breaks_in_ha_version="2025.12.0",
|
||||
learn_more_url=DEPRECATION_URL,
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key=issue_id,
|
||||
translation_placeholders={
|
||||
"installation_type": installation_type,
|
||||
"arch": arch,
|
||||
},
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user