mirror of
https://github.com/home-assistant/core.git
synced 2025-11-23 09:46:54 +00:00
Compare commits
158 Commits
cursor/add
...
2025.11.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc8f8b39b4 | ||
|
|
ec0918027e | ||
|
|
8a54f8d4e2 | ||
|
|
5c27126b6d | ||
|
|
e069aff0e2 | ||
|
|
733526fae3 | ||
|
|
1ef001f8e9 | ||
|
|
7732377fde | ||
|
|
b7786e589b | ||
|
|
4f60970a91 | ||
|
|
1c1286dd57 | ||
|
|
41c9f08f60 | ||
|
|
fc4bfab0f7 | ||
|
|
769a12f74e | ||
|
|
dabaa2bc5e | ||
|
|
b674828a91 | ||
|
|
761da66658 | ||
|
|
c8aba62301 | ||
|
|
07ab2e6805 | ||
|
|
f62e0c8c08 | ||
|
|
6ca00f9dbb | ||
|
|
0fba80e30f | ||
|
|
7073c40385 | ||
|
|
8fb9d92daf | ||
|
|
2d81665f99 | ||
|
|
b398935539 | ||
|
|
95f588aae1 | ||
|
|
ffe524d95a | ||
|
|
ee05adfca1 | ||
|
|
168c915b5f | ||
|
|
6c80be52af | ||
|
|
ead92cdf82 | ||
|
|
c0f0cfef59 | ||
|
|
cefc0ba96e | ||
|
|
ad091b1062 | ||
|
|
876bc6d8c4 | ||
|
|
9f206d4363 | ||
|
|
a2d11e6d98 | ||
|
|
3b38af3984 | ||
|
|
3875f91bb9 | ||
|
|
c813776b0c | ||
|
|
3afb421cba | ||
|
|
c16633568b | ||
|
|
87f8ff2bb4 | ||
|
|
b423303f1e | ||
|
|
f6ff222679 | ||
|
|
0152fa0c03 | ||
|
|
37ebbe83bc | ||
|
|
63e036d39e | ||
|
|
f0cbf34a78 | ||
|
|
596bc89ee6 | ||
|
|
b8c877e1d2 | ||
|
|
197d9781cb | ||
|
|
f3f323637e | ||
|
|
9748abc103 | ||
|
|
596f049971 | ||
|
|
dee80cb6f5 | ||
|
|
b4ab73468b | ||
|
|
a300199a97 | ||
|
|
09dd765583 | ||
|
|
0c8b765415 | ||
|
|
0824ec502f | ||
|
|
9e0e353a5f | ||
|
|
e934b006e2 | ||
|
|
05479bb8fd | ||
|
|
d07247566d | ||
|
|
19e6097df6 | ||
|
|
2cff3cf29c | ||
|
|
5cac9b8e5e | ||
|
|
c2a516ea32 | ||
|
|
192b38d3e2 | ||
|
|
bb018e3546 | ||
|
|
4919d73cc5 | ||
|
|
f3ddffb5ff | ||
|
|
9bdfa77fa0 | ||
|
|
c65003009f | ||
|
|
0f722109b7 | ||
|
|
f7d86dec3c | ||
|
|
6b49c8a70c | ||
|
|
ab9a8f3e53 | ||
|
|
4e12628266 | ||
|
|
e6d8d4de42 | ||
|
|
6620b90eb4 | ||
|
|
6fd3af8891 | ||
|
|
46979b8418 | ||
|
|
1718a11de2 | ||
|
|
2016b1d8c7 | ||
|
|
4b72e45fc2 | ||
|
|
ead5ce905b | ||
|
|
0b91a92554 | ||
|
|
f233f2da3f | ||
|
|
7855df92c8 | ||
|
|
11309f89f0 | ||
|
|
396a987035 | ||
|
|
b7696bfb20 | ||
|
|
5cfbe2cf71 | ||
|
|
4e255286af | ||
|
|
53a96af844 | ||
|
|
accb705d8b | ||
|
|
1793abce4f | ||
|
|
8bfed0b60c | ||
|
|
016c1de2ef | ||
|
|
c270f31365 | ||
|
|
f9e06acfc7 | ||
|
|
901558b293 | ||
|
|
c09cf36345 | ||
|
|
926627b49c | ||
|
|
a8eeba9c5f | ||
|
|
e4591c27c0 | ||
|
|
40dedec602 | ||
|
|
3a65b5ca70 | ||
|
|
dbeb82861f | ||
|
|
e43c35ab2d | ||
|
|
b4c4fdefe3 | ||
|
|
965dd7c557 | ||
|
|
9a921f2c8e | ||
|
|
aaae3244a8 | ||
|
|
40ff100900 | ||
|
|
1b62b2309f | ||
|
|
9d57251aea | ||
|
|
f877614e7f | ||
|
|
170e1e87c7 | ||
|
|
e1feba5c86 | ||
|
|
9bf52b7966 | ||
|
|
3bc61a3564 | ||
|
|
d2ba94e1bf | ||
|
|
9a4ed82399 | ||
|
|
b5136d01aa | ||
|
|
d3e05090ea | ||
|
|
7e75ca7af9 | ||
|
|
6616b5775f | ||
|
|
69b82d4c59 | ||
|
|
6b9709677a | ||
|
|
a4e9c82c84 | ||
|
|
de86bedb80 | ||
|
|
9111c6df90 | ||
|
|
751f6bddb1 | ||
|
|
c9a61de0a1 | ||
|
|
01fb46d903 | ||
|
|
d26f61c9fe | ||
|
|
a47a144312 | ||
|
|
69cf4f99d1 | ||
|
|
e6c757c187 | ||
|
|
a36b0e2f3f | ||
|
|
1a7c6cd96c | ||
|
|
ba3e538402 | ||
|
|
b2cd08aa65 | ||
|
|
06dcd25a16 | ||
|
|
fd36782bae | ||
|
|
ed4573db57 | ||
|
|
78373a6483 | ||
|
|
8455c35bec | ||
|
|
00887a2f3f | ||
|
|
f1ca7543fa | ||
|
|
bb72b24ba9 | ||
|
|
322a27d992 | ||
|
|
a3b516110b | ||
|
|
95ac5c0183 |
@@ -361,6 +361,7 @@ homeassistant.components.myuplink.*
|
|||||||
homeassistant.components.nam.*
|
homeassistant.components.nam.*
|
||||||
homeassistant.components.nanoleaf.*
|
homeassistant.components.nanoleaf.*
|
||||||
homeassistant.components.nasweb.*
|
homeassistant.components.nasweb.*
|
||||||
|
homeassistant.components.neato.*
|
||||||
homeassistant.components.nest.*
|
homeassistant.components.nest.*
|
||||||
homeassistant.components.netatmo.*
|
homeassistant.components.netatmo.*
|
||||||
homeassistant.components.network.*
|
homeassistant.components.network.*
|
||||||
|
|||||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -1543,8 +1543,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/suez_water/ @ooii @jb101010-2
|
/tests/components/suez_water/ @ooii @jb101010-2
|
||||||
/homeassistant/components/sun/ @home-assistant/core
|
/homeassistant/components/sun/ @home-assistant/core
|
||||||
/tests/components/sun/ @home-assistant/core
|
/tests/components/sun/ @home-assistant/core
|
||||||
/homeassistant/components/sunricher_dali_center/ @niracler
|
/homeassistant/components/sunricher_dali/ @niracler
|
||||||
/tests/components/sunricher_dali_center/ @niracler
|
/tests/components/sunricher_dali/ @niracler
|
||||||
/homeassistant/components/supla/ @mwegrzynek
|
/homeassistant/components/supla/ @mwegrzynek
|
||||||
/homeassistant/components/surepetcare/ @benleb @danielhiversen
|
/homeassistant/components/surepetcare/ @benleb @danielhiversen
|
||||||
/tests/components/surepetcare/ @benleb @danielhiversen
|
/tests/components/surepetcare/ @benleb @danielhiversen
|
||||||
|
|||||||
4
Dockerfile
generated
4
Dockerfile
generated
@@ -25,13 +25,13 @@ RUN \
|
|||||||
"armv7") go2rtc_suffix='arm' ;; \
|
"armv7") go2rtc_suffix='arm' ;; \
|
||||||
*) go2rtc_suffix=${BUILD_ARCH} ;; \
|
*) go2rtc_suffix=${BUILD_ARCH} ;; \
|
||||||
esac \
|
esac \
|
||||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.11/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.12/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||||
&& chmod +x /bin/go2rtc \
|
&& chmod +x /bin/go2rtc \
|
||||||
# Verify go2rtc can be executed
|
# Verify go2rtc can be executed
|
||||||
&& go2rtc --version
|
&& go2rtc --version
|
||||||
|
|
||||||
# Install uv
|
# Install uv
|
||||||
RUN pip3 install uv==0.9.5
|
RUN pip3 install uv==0.9.6
|
||||||
|
|
||||||
WORKDIR /usr/src
|
WORKDIR /usr/src
|
||||||
|
|
||||||
|
|||||||
10
build.yaml
10
build.yaml
@@ -1,10 +1,10 @@
|
|||||||
image: ghcr.io/home-assistant/{arch}-homeassistant
|
image: ghcr.io/home-assistant/{arch}-homeassistant
|
||||||
build_from:
|
build_from:
|
||||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.1
|
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.11.0
|
||||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.1
|
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.11.0
|
||||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1
|
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.11.0
|
||||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1
|
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.11.0
|
||||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1
|
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.11.0
|
||||||
cosign:
|
cosign:
|
||||||
base_identity: https://github.com/home-assistant/docker/.*
|
base_identity: https://github.com/home-assistant/docker/.*
|
||||||
identity: https://github.com/home-assistant/core/.*
|
identity: https://github.com/home-assistant/core/.*
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Sending HOTP through notify service
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections import OrderedDict
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
@@ -304,14 +303,15 @@ class NotifySetupFlow(SetupFlow[NotifyAuthModule]):
|
|||||||
if not self._available_notify_services:
|
if not self._available_notify_services:
|
||||||
return self.async_abort(reason="no_available_service")
|
return self.async_abort(reason="no_available_service")
|
||||||
|
|
||||||
schema: dict[str, Any] = OrderedDict()
|
schema = vol.Schema(
|
||||||
schema["notify_service"] = vol.In(self._available_notify_services)
|
{
|
||||||
schema["target"] = vol.Optional(str)
|
vol.Required("notify_service"): vol.In(self._available_notify_services),
|
||||||
|
vol.Optional("target"): str,
|
||||||
return self.async_show_form(
|
}
|
||||||
step_id="init", data_schema=vol.Schema(schema), errors=errors
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(step_id="init", data_schema=schema, errors=errors)
|
||||||
|
|
||||||
async def async_step_setup(
|
async def async_step_setup(
|
||||||
self, user_input: dict[str, str] | None = None
|
self, user_input: dict[str, str] | None = None
|
||||||
) -> FlowResult:
|
) -> FlowResult:
|
||||||
|
|||||||
@@ -179,12 +179,18 @@ class Data:
|
|||||||
user_hash = base64.b64decode(found["password"])
|
user_hash = base64.b64decode(found["password"])
|
||||||
|
|
||||||
# bcrypt.checkpw is timing-safe
|
# bcrypt.checkpw is timing-safe
|
||||||
if not bcrypt.checkpw(password.encode(), user_hash):
|
# With bcrypt 5.0 passing a password longer than 72 bytes raises a ValueError.
|
||||||
|
# Previously the password was silently truncated.
|
||||||
|
# https://github.com/pyca/bcrypt/pull/1000
|
||||||
|
if not bcrypt.checkpw(password.encode()[:72], user_hash):
|
||||||
raise InvalidAuth
|
raise InvalidAuth
|
||||||
|
|
||||||
def hash_password(self, password: str, for_storage: bool = False) -> bytes:
|
def hash_password(self, password: str, for_storage: bool = False) -> bytes:
|
||||||
"""Encode a password."""
|
"""Encode a password."""
|
||||||
hashed: bytes = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
|
# With bcrypt 5.0 passing a password longer than 72 bytes raises a ValueError.
|
||||||
|
# Previously the password was silently truncated.
|
||||||
|
# https://github.com/pyca/bcrypt/pull/1000
|
||||||
|
hashed: bytes = bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt(rounds=12))
|
||||||
|
|
||||||
if for_storage:
|
if for_storage:
|
||||||
hashed = base64.b64encode(hashed)
|
hashed = base64.b64encode(hashed)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from homeassistant.components.bluetooth import (
|
|||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_ADDRESS
|
from homeassistant.const import CONF_ADDRESS
|
||||||
|
|
||||||
from .const import DOMAIN, MFCT_ID
|
from .const import DEVICE_MODEL, DOMAIN, MFCT_ID
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -128,15 +128,15 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Confirm discovery."""
|
"""Confirm discovery."""
|
||||||
|
assert self._discovered_device is not None
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
if (
|
if self._discovered_device.device.firmware.need_firmware_upgrade:
|
||||||
self._discovered_device is not None
|
|
||||||
and self._discovered_device.device.firmware.need_firmware_upgrade
|
|
||||||
):
|
|
||||||
return self.async_abort(reason="firmware_upgrade_required")
|
return self.async_abort(reason="firmware_upgrade_required")
|
||||||
|
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=self.context["title_placeholders"]["name"], data={}
|
title=self.context["title_placeholders"]["name"],
|
||||||
|
data={DEVICE_MODEL: self._discovered_device.device.model.value},
|
||||||
)
|
)
|
||||||
|
|
||||||
self._set_confirm_only()
|
self._set_confirm_only()
|
||||||
@@ -164,7 +164,10 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
self._discovered_device = discovery
|
self._discovered_device = discovery
|
||||||
|
|
||||||
return self.async_create_entry(title=discovery.name, data={})
|
return self.async_create_entry(
|
||||||
|
title=discovery.name,
|
||||||
|
data={DEVICE_MODEL: discovery.device.model.value},
|
||||||
|
)
|
||||||
|
|
||||||
current_addresses = self._async_current_ids(include_ignore=False)
|
current_addresses = self._async_current_ids(include_ignore=False)
|
||||||
devices: list[BluetoothServiceInfoBleak] = []
|
devices: list[BluetoothServiceInfoBleak] = []
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
"""Constants for Airthings BLE."""
|
"""Constants for Airthings BLE."""
|
||||||
|
|
||||||
|
from airthings_ble import AirthingsDeviceType
|
||||||
|
|
||||||
DOMAIN = "airthings_ble"
|
DOMAIN = "airthings_ble"
|
||||||
MFCT_ID = 820
|
MFCT_ID = 820
|
||||||
|
|
||||||
VOLUME_BECQUEREL = "Bq/m³"
|
VOLUME_BECQUEREL = "Bq/m³"
|
||||||
VOLUME_PICOCURIE = "pCi/L"
|
VOLUME_PICOCURIE = "pCi/L"
|
||||||
|
|
||||||
|
DEVICE_MODEL = "device_model"
|
||||||
|
|
||||||
DEFAULT_SCAN_INTERVAL = 300
|
DEFAULT_SCAN_INTERVAL = 300
|
||||||
|
DEVICE_SPECIFIC_SCAN_INTERVAL = {AirthingsDeviceType.CORENTIUM_HOME_2.value: 1800}
|
||||||
|
|
||||||
MAX_RETRIES_AFTER_STARTUP = 5
|
MAX_RETRIES_AFTER_STARTUP = 5
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
|||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||||
|
|
||||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
|
from .const import (
|
||||||
|
DEFAULT_SCAN_INTERVAL,
|
||||||
|
DEVICE_MODEL,
|
||||||
|
DEVICE_SPECIFIC_SCAN_INTERVAL,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,12 +39,18 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
|
|||||||
self.airthings = AirthingsBluetoothDeviceData(
|
self.airthings = AirthingsBluetoothDeviceData(
|
||||||
_LOGGER, hass.config.units is METRIC_SYSTEM
|
_LOGGER, hass.config.units is METRIC_SYSTEM
|
||||||
)
|
)
|
||||||
|
|
||||||
|
device_model = entry.data.get(DEVICE_MODEL)
|
||||||
|
interval = DEVICE_SPECIFIC_SCAN_INTERVAL.get(
|
||||||
|
device_model, DEFAULT_SCAN_INTERVAL
|
||||||
|
)
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
_LOGGER,
|
_LOGGER,
|
||||||
config_entry=entry,
|
config_entry=entry,
|
||||||
name=DOMAIN,
|
name=DOMAIN,
|
||||||
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
update_interval=timedelta(seconds=interval),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _async_setup(self) -> None:
|
async def _async_setup(self) -> None:
|
||||||
@@ -58,11 +69,29 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
|
|||||||
)
|
)
|
||||||
self.ble_device = ble_device
|
self.ble_device = ble_device
|
||||||
|
|
||||||
|
if DEVICE_MODEL not in self.config_entry.data:
|
||||||
|
_LOGGER.debug("Fetching device info for migration")
|
||||||
|
try:
|
||||||
|
data = await self.airthings.update_device(self.ble_device)
|
||||||
|
except Exception as err:
|
||||||
|
raise UpdateFailed(
|
||||||
|
f"Unable to fetch data for migration: {err}"
|
||||||
|
) from err
|
||||||
|
|
||||||
|
self.hass.config_entries.async_update_entry(
|
||||||
|
self.config_entry,
|
||||||
|
data={**self.config_entry.data, DEVICE_MODEL: data.model.value},
|
||||||
|
)
|
||||||
|
self.update_interval = timedelta(
|
||||||
|
seconds=DEVICE_SPECIFIC_SCAN_INTERVAL.get(
|
||||||
|
data.model.value, DEFAULT_SCAN_INTERVAL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def _async_update_data(self) -> AirthingsDevice:
|
async def _async_update_data(self) -> AirthingsDevice:
|
||||||
"""Get data from Airthings BLE."""
|
"""Get data from Airthings BLE."""
|
||||||
try:
|
try:
|
||||||
data = await self.airthings.update_device(self.ble_device)
|
data = await self.airthings.update_device(self.ble_device)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
raise UpdateFailed(f"Unable to fetch data: {err}") from err
|
raise UpdateFailed(f"Unable to fetch data: {err}") from err
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from collections.abc import Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice
|
from aioamazondevices.const.metadata import SENSOR_STATE_OFF
|
||||||
from aioamazondevices.const import SENSOR_STATE_OFF
|
from aioamazondevices.structures import AmazonDevice
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import (
|
||||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
|
from aioamazondevices.api import AmazonEchoApi
|
||||||
from aioamazondevices.exceptions import (
|
from aioamazondevices.exceptions import (
|
||||||
CannotAuthenticate,
|
CannotAuthenticate,
|
||||||
CannotConnect,
|
CannotConnect,
|
||||||
CannotRetrieveData,
|
CannotRetrieveData,
|
||||||
)
|
)
|
||||||
|
from aioamazondevices.structures import AmazonDevice
|
||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
@@ -15,6 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
from homeassistant.helpers.debounce import Debouncer
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
|
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
|
||||||
@@ -42,6 +44,9 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
|||||||
name=entry.title,
|
name=entry.title,
|
||||||
config_entry=entry,
|
config_entry=entry,
|
||||||
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
||||||
|
request_refresh_debouncer=Debouncer(
|
||||||
|
hass, _LOGGER, cooldown=30, immediate=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self.api = AmazonEchoApi(
|
self.api = AmazonEchoApi(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice
|
from aioamazondevices.structures import AmazonDevice
|
||||||
|
|
||||||
from homeassistant.components.diagnostics import async_redact_data
|
from homeassistant.components.diagnostics import async_redact_data
|
||||||
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
|
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Defines a base Alexa Devices entity."""
|
"""Defines a base Alexa Devices entity."""
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice
|
from aioamazondevices.const.devices import SPEAKER_GROUP_MODEL
|
||||||
from aioamazondevices.const import SPEAKER_GROUP_MODEL
|
from aioamazondevices.structures import AmazonDevice
|
||||||
|
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.entity import EntityDescription
|
from homeassistant.helpers.entity import EntityDescription
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["aioamazondevices"],
|
"loggers": ["aioamazondevices"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["aioamazondevices==6.5.5"]
|
"requirements": ["aioamazondevices==8.0.1"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ from collections.abc import Awaitable, Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Final
|
from typing import Any, Final
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
|
from aioamazondevices.api import AmazonEchoApi
|
||||||
from aioamazondevices.const import SPEAKER_GROUP_FAMILY
|
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
|
||||||
|
from aioamazondevices.structures import AmazonDevice
|
||||||
|
|
||||||
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
|
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice
|
from aioamazondevices.const.schedules import (
|
||||||
from aioamazondevices.const import (
|
|
||||||
NOTIFICATION_ALARM,
|
NOTIFICATION_ALARM,
|
||||||
NOTIFICATION_REMINDER,
|
NOTIFICATION_REMINDER,
|
||||||
NOTIFICATION_TIMER,
|
NOTIFICATION_TIMER,
|
||||||
)
|
)
|
||||||
|
from aioamazondevices.structures import AmazonDevice
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Support for services."""
|
"""Support for services."""
|
||||||
|
|
||||||
from aioamazondevices.sounds import SOUNDS_LIST
|
from aioamazondevices.const.sounds import SOUNDS_LIST
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntryState
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from collections.abc import Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Any, Final
|
from typing import TYPE_CHECKING, Any, Final
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice
|
from aioamazondevices.structures import AmazonDevice
|
||||||
|
|
||||||
from homeassistant.components.switch import (
|
from homeassistant.components.switch import (
|
||||||
DOMAIN as SWITCH_DOMAIN,
|
DOMAIN as SWITCH_DOMAIN,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Coroutine
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Any, Concatenate
|
from typing import Any, Concatenate
|
||||||
|
|
||||||
from aioamazondevices.const import SPEAKER_GROUP_FAMILY
|
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
|
||||||
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
||||||
|
|
||||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ SENSOR_DESCRIPTIONS = (
|
|||||||
translation_key="daily_rain",
|
translation_key="daily_rain",
|
||||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||||
device_class=SensorDeviceClass.PRECIPITATION,
|
device_class=SensorDeviceClass.PRECIPITATION,
|
||||||
state_class=SensorStateClass.TOTAL,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
suggested_display_precision=2,
|
suggested_display_precision=2,
|
||||||
),
|
),
|
||||||
SensorEntityDescription(
|
SensorEntityDescription(
|
||||||
@@ -150,7 +150,7 @@ SENSOR_DESCRIPTIONS = (
|
|||||||
key=TYPE_LIGHTNING_PER_DAY,
|
key=TYPE_LIGHTNING_PER_DAY,
|
||||||
translation_key="lightning_strikes_per_day",
|
translation_key="lightning_strikes_per_day",
|
||||||
native_unit_of_measurement="strikes",
|
native_unit_of_measurement="strikes",
|
||||||
state_class=SensorStateClass.TOTAL,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
),
|
),
|
||||||
SensorEntityDescription(
|
SensorEntityDescription(
|
||||||
@@ -182,7 +182,7 @@ SENSOR_DESCRIPTIONS = (
|
|||||||
translation_key="monthly_rain",
|
translation_key="monthly_rain",
|
||||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||||
device_class=SensorDeviceClass.PRECIPITATION,
|
device_class=SensorDeviceClass.PRECIPITATION,
|
||||||
state_class=SensorStateClass.TOTAL,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
suggested_display_precision=2,
|
suggested_display_precision=2,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
),
|
),
|
||||||
@@ -229,7 +229,7 @@ SENSOR_DESCRIPTIONS = (
|
|||||||
translation_key="weekly_rain",
|
translation_key="weekly_rain",
|
||||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||||
device_class=SensorDeviceClass.PRECIPITATION,
|
device_class=SensorDeviceClass.PRECIPITATION,
|
||||||
state_class=SensorStateClass.TOTAL,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
suggested_display_precision=2,
|
suggested_display_precision=2,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
),
|
),
|
||||||
@@ -262,7 +262,7 @@ SENSOR_DESCRIPTIONS = (
|
|||||||
translation_key="yearly_rain",
|
translation_key="yearly_rain",
|
||||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||||
device_class=SensorDeviceClass.PRECIPITATION,
|
device_class=SensorDeviceClass.PRECIPITATION,
|
||||||
state_class=SensorStateClass.TOTAL,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
suggested_display_precision=2,
|
suggested_display_precision=2,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/awair",
|
"documentation": "https://www.home-assistant.io/integrations/awair",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["python_awair"],
|
"loggers": ["python_awair"],
|
||||||
"requirements": ["python-awair==0.2.4"],
|
"requirements": ["python-awair==0.2.5"],
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
{
|
{
|
||||||
"name": "awair*",
|
"name": "awair*",
|
||||||
|
|||||||
@@ -8,6 +8,6 @@
|
|||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "calculated",
|
"iot_class": "calculated",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["cronsim==2.6", "securetar==2025.2.1"],
|
"requirements": ["cronsim==2.7", "securetar==2025.2.1"],
|
||||||
"single_config_entry": true
|
"single_config_entry": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ class BryantEvolutionClimate(ClimateEntity):
|
|||||||
return HVACAction.HEATING
|
return HVACAction.HEATING
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
translation_key="failed_to_parse_hvac_mode",
|
translation_key="failed_to_parse_hvac_action",
|
||||||
translation_placeholders={
|
translation_placeholders={
|
||||||
"mode_and_active": mode_and_active,
|
"mode_and_active": mode_and_active,
|
||||||
"current_temperature": str(self.current_temperature),
|
"current_temperature": str(self.current_temperature),
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
},
|
},
|
||||||
"exceptions": {
|
"exceptions": {
|
||||||
"failed_to_parse_hvac_action": {
|
"failed_to_parse_hvac_action": {
|
||||||
"message": "Could not determine HVAC action: {mode_and_active}, {self.current_temperature}, {self.target_temperature_low}"
|
"message": "Could not determine HVAC action: {mode_and_active}, {current_temperature}, {target_temperature_low}"
|
||||||
},
|
},
|
||||||
"failed_to_parse_hvac_mode": {
|
"failed_to_parse_hvac_mode": {
|
||||||
"message": "Cannot parse response to HVACMode: {mode}"
|
"message": "Cannot parse response to HVACMode: {mode}"
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
|
|||||||
super().__init__(data.fast_coordinator, data)
|
super().__init__(data.fast_coordinator, data)
|
||||||
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
|
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
|
||||||
|
|
||||||
|
# Set temperature range if available, otherwise use Home Assistant defaults
|
||||||
|
if data.static.min_temp is not None and data.static.min_temp.value is not None:
|
||||||
self._attr_min_temp = data.static.min_temp.value
|
self._attr_min_temp = data.static.min_temp.value
|
||||||
|
if data.static.max_temp is not None and data.static.max_temp.value is not None:
|
||||||
self._attr_max_temp = data.static.max_temp.value
|
self._attr_max_temp = data.static.max_temp.value
|
||||||
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
|
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["bsblan"],
|
"loggers": ["bsblan"],
|
||||||
"requirements": ["python-bsblan==3.1.0"],
|
"requirements": ["python-bsblan==3.1.1"],
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
{
|
{
|
||||||
"name": "bsb-lan*",
|
"name": "bsb-lan*",
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ async def _async_reproduce_states(
|
|||||||
await call_service(SERVICE_SET_HVAC_MODE, [], {ATTR_HVAC_MODE: state.state})
|
await call_service(SERVICE_SET_HVAC_MODE, [], {ATTR_HVAC_MODE: state.state})
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(ATTR_TEMPERATURE in state.attributes)
|
(state.attributes.get(ATTR_TEMPERATURE) is not None)
|
||||||
or (ATTR_TARGET_TEMP_HIGH in state.attributes)
|
or (state.attributes.get(ATTR_TARGET_TEMP_HIGH) is not None)
|
||||||
or (ATTR_TARGET_TEMP_LOW in state.attributes)
|
or (state.attributes.get(ATTR_TARGET_TEMP_LOW) is not None)
|
||||||
):
|
):
|
||||||
await call_service(
|
await call_service(
|
||||||
SERVICE_SET_TEMPERATURE,
|
SERVICE_SET_TEMPERATURE,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import logging
|
import logging
|
||||||
from typing import cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from hass_nabucasa import Cloud
|
from hass_nabucasa import Cloud
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -85,6 +85,10 @@ SIGNAL_CLOUD_CONNECTION_STATE: SignalType[CloudConnectionState] = SignalType(
|
|||||||
"CLOUD_CONNECTION_STATE"
|
"CLOUD_CONNECTION_STATE"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_SIGNAL_CLOUDHOOKS_UPDATED: SignalType[dict[str, Any]] = SignalType(
|
||||||
|
"CLOUDHOOKS_UPDATED"
|
||||||
|
)
|
||||||
|
|
||||||
STARTUP_REPAIR_DELAY = 1 # 1 hour
|
STARTUP_REPAIR_DELAY = 1 # 1 hour
|
||||||
|
|
||||||
ALEXA_ENTITY_SCHEMA = vol.Schema(
|
ALEXA_ENTITY_SCHEMA = vol.Schema(
|
||||||
@@ -240,6 +244,24 @@ async def async_delete_cloudhook(hass: HomeAssistant, webhook_id: str) -> None:
|
|||||||
await hass.data[DATA_CLOUD].cloudhooks.async_delete(webhook_id)
|
await hass.data[DATA_CLOUD].cloudhooks.async_delete(webhook_id)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_listen_cloudhook_change(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
webhook_id: str,
|
||||||
|
on_change: Callable[[dict[str, Any] | None], None],
|
||||||
|
) -> Callable[[], None]:
|
||||||
|
"""Listen for cloudhook changes for the given webhook and notify when modified or deleted."""
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_cloudhooks_updated(cloudhooks: dict[str, Any]) -> None:
|
||||||
|
"""Handle cloudhooks updated signal."""
|
||||||
|
on_change(cloudhooks.get(webhook_id))
|
||||||
|
|
||||||
|
return async_dispatcher_connect(
|
||||||
|
hass, _SIGNAL_CLOUDHOOKS_UPDATED, _handle_cloudhooks_updated
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bind_hass
|
@bind_hass
|
||||||
@callback
|
@callback
|
||||||
def async_remote_ui_url(hass: HomeAssistant) -> str:
|
def async_remote_ui_url(hass: HomeAssistant) -> str:
|
||||||
@@ -287,7 +309,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
|
|
||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
|
||||||
|
|
||||||
_remote_handle_prefs_updated(cloud)
|
_handle_prefs_updated(hass, cloud)
|
||||||
_setup_services(hass, prefs)
|
_setup_services(hass, prefs)
|
||||||
|
|
||||||
async def async_startup_repairs(_: datetime) -> None:
|
async def async_startup_repairs(_: datetime) -> None:
|
||||||
@@ -371,26 +393,32 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _remote_handle_prefs_updated(cloud: Cloud[CloudClient]) -> None:
|
def _handle_prefs_updated(hass: HomeAssistant, cloud: Cloud[CloudClient]) -> None:
|
||||||
"""Handle remote preferences updated."""
|
"""Register handler for cloud preferences updates."""
|
||||||
cur_pref = cloud.client.prefs.remote_enabled
|
cur_remote_enabled = cloud.client.prefs.remote_enabled
|
||||||
|
cur_cloudhooks = cloud.client.prefs.cloudhooks
|
||||||
lock = asyncio.Lock()
|
lock = asyncio.Lock()
|
||||||
|
|
||||||
# Sync remote connection with prefs
|
async def on_prefs_updated(prefs: CloudPreferences) -> None:
|
||||||
async def remote_prefs_updated(prefs: CloudPreferences) -> None:
|
"""Handle cloud preferences updates."""
|
||||||
"""Update remote status."""
|
nonlocal cur_remote_enabled
|
||||||
nonlocal cur_pref
|
nonlocal cur_cloudhooks
|
||||||
|
|
||||||
|
# Lock protects cur_ state variables from concurrent updates
|
||||||
async with lock:
|
async with lock:
|
||||||
if prefs.remote_enabled == cur_pref:
|
if cur_cloudhooks != prefs.cloudhooks:
|
||||||
|
cur_cloudhooks = prefs.cloudhooks
|
||||||
|
async_dispatcher_send(hass, _SIGNAL_CLOUDHOOKS_UPDATED, cur_cloudhooks)
|
||||||
|
|
||||||
|
if prefs.remote_enabled == cur_remote_enabled:
|
||||||
return
|
return
|
||||||
|
|
||||||
if cur_pref := prefs.remote_enabled:
|
if cur_remote_enabled := prefs.remote_enabled:
|
||||||
await cloud.remote.connect()
|
await cloud.remote.connect()
|
||||||
else:
|
else:
|
||||||
await cloud.remote.disconnect()
|
await cloud.remote.disconnect()
|
||||||
|
|
||||||
cloud.client.prefs.async_listen_updates(remote_prefs_updated)
|
cloud.client.prefs.async_listen_updates(on_prefs_updated)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
|||||||
@@ -37,13 +37,6 @@ USER_SCHEMA = vol.Schema(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string})
|
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string})
|
||||||
STEP_RECONFIGURE = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_HOST): cv.string,
|
|
||||||
vol.Required(CONF_PORT): cv.port,
|
|
||||||
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
|
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
|
||||||
@@ -175,19 +168,21 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle reconfiguration of the device."""
|
"""Handle reconfiguration of the device."""
|
||||||
reconfigure_entry = self._get_reconfigure_entry()
|
reconfigure_entry = self._get_reconfigure_entry()
|
||||||
if not user_input:
|
errors: dict[str, str] = {}
|
||||||
return self.async_show_form(
|
|
||||||
step_id="reconfigure", data_schema=STEP_RECONFIGURE
|
|
||||||
)
|
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
updated_host = user_input[CONF_HOST]
|
updated_host = user_input[CONF_HOST]
|
||||||
|
|
||||||
self._async_abort_entries_match({CONF_HOST: updated_host})
|
self._async_abort_entries_match({CONF_HOST: updated_host})
|
||||||
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await validate_input(self.hass, user_input)
|
data_to_validate = {
|
||||||
|
CONF_HOST: updated_host,
|
||||||
|
CONF_PORT: user_input[CONF_PORT],
|
||||||
|
CONF_PIN: user_input[CONF_PIN],
|
||||||
|
CONF_TYPE: reconfigure_entry.data.get(CONF_TYPE, BRIDGE),
|
||||||
|
}
|
||||||
|
await validate_input(self.hass, data_to_validate)
|
||||||
except CannotConnect:
|
except CannotConnect:
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
except InvalidAuth:
|
except InvalidAuth:
|
||||||
@@ -198,13 +193,30 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
_LOGGER.exception("Unexpected exception")
|
_LOGGER.exception("Unexpected exception")
|
||||||
errors["base"] = "unknown"
|
errors["base"] = "unknown"
|
||||||
else:
|
else:
|
||||||
|
data_updates = {
|
||||||
|
CONF_HOST: updated_host,
|
||||||
|
CONF_PORT: user_input[CONF_PORT],
|
||||||
|
CONF_PIN: user_input[CONF_PIN],
|
||||||
|
}
|
||||||
return self.async_update_reload_and_abort(
|
return self.async_update_reload_and_abort(
|
||||||
reconfigure_entry, data_updates={CONF_HOST: updated_host}
|
reconfigure_entry, data_updates=data_updates
|
||||||
|
)
|
||||||
|
|
||||||
|
schema = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(
|
||||||
|
CONF_HOST, default=reconfigure_entry.data[CONF_HOST]
|
||||||
|
): cv.string,
|
||||||
|
vol.Required(
|
||||||
|
CONF_PORT, default=reconfigure_entry.data[CONF_PORT]
|
||||||
|
): cv.port,
|
||||||
|
vol.Optional(CONF_PIN): cv.string,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="reconfigure",
|
step_id="reconfigure",
|
||||||
data_schema=STEP_RECONFIGURE,
|
data_schema=schema,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||||
"integration_type": "entity",
|
"integration_type": "entity",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["hassil==3.4.0", "home-assistant-intents==2025.10.28"]
|
"requirements": ["hassil==3.4.0", "home-assistant-intents==2025.11.7"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.util.ssl import get_default_context
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_AUTHORIZE_STRING,
|
CONF_AUTHORIZE_STRING,
|
||||||
@@ -31,9 +32,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool
|
|||||||
expires_at=entry.data[CONF_EXPIRES_AT],
|
expires_at=entry.data[CONF_EXPIRES_AT],
|
||||||
)
|
)
|
||||||
cync_auth = Auth(async_get_clientsession(hass), user=user_info)
|
cync_auth = Auth(async_get_clientsession(hass), user=user_info)
|
||||||
|
ssl_context = get_default_context()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cync = await Cync.create(cync_auth)
|
cync = await Cync.create(
|
||||||
|
auth=cync_auth,
|
||||||
|
ssl_context=ssl_context,
|
||||||
|
)
|
||||||
except AuthFailedError as ex:
|
except AuthFailedError as ex:
|
||||||
raise ConfigEntryAuthFailed("User token invalid") from ex
|
raise ConfigEntryAuthFailed("User token invalid") from ex
|
||||||
except CyncError as ex:
|
except CyncError as ex:
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["pycync==0.4.2"]
|
"requirements": ["pycync==0.4.3"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
from homeassistant.util import Throttle
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -167,6 +169,7 @@ class DecoraWifiLight(LightEntity):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
_LOGGER.error("Failed to turn off myLeviton switch")
|
_LOGGER.error("Failed to turn off myLeviton switch")
|
||||||
|
|
||||||
|
@Throttle(timedelta(seconds=30))
|
||||||
def update(self) -> None:
|
def update(self) -> None:
|
||||||
"""Fetch new state data for this switch."""
|
"""Fetch new state data for this switch."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["async_upnp_client"],
|
"loggers": ["async_upnp_client"],
|
||||||
"requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"],
|
"requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"],
|
||||||
"ssdp": [
|
"ssdp": [
|
||||||
{
|
{
|
||||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"dependencies": ["ssdp"],
|
"dependencies": ["ssdp"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["async-upnp-client==0.45.0"],
|
"requirements": ["async-upnp-client==0.46.0"],
|
||||||
"ssdp": [
|
"ssdp": [
|
||||||
{
|
{
|
||||||
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||||
"requirements": ["py-sucks==0.9.11", "deebot-client==16.1.0"]
|
"requirements": ["py-sucks==0.9.11", "deebot-client==16.3.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["eheimdigital"],
|
"loggers": ["eheimdigital"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["eheimdigital==1.3.0"],
|
"requirements": ["eheimdigital==1.4.0"],
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
|
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Virtual integration: Enmax Energy."""
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "enmax",
|
|
||||||
"name": "Enmax Energy",
|
|
||||||
"integration_type": "virtual",
|
|
||||||
"supported_by": "opower"
|
|
||||||
}
|
|
||||||
@@ -75,10 +75,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> b
|
|||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> bool:
|
||||||
"""Unload an esphome config entry."""
|
"""Unload an esphome config entry."""
|
||||||
entry_data = await cleanup_instance(entry)
|
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||||
return await hass.config_entries.async_unload_platforms(
|
entry, entry.runtime_data.loaded_platforms
|
||||||
entry, entry_data.loaded_platforms
|
|
||||||
)
|
)
|
||||||
|
if unload_ok:
|
||||||
|
await cleanup_instance(entry)
|
||||||
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> None:
|
async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> None:
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
|
|||||||
client = Firefly(
|
client = Firefly(
|
||||||
api_url=data[CONF_URL],
|
api_url=data[CONF_URL],
|
||||||
api_key=data[CONF_API_KEY],
|
api_key=data[CONF_API_KEY],
|
||||||
session=async_get_clientsession(hass),
|
session=async_get_clientsession(
|
||||||
|
hass=hass, verify_ssl=data[CONF_VERIFY_SSL]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await client.get_about()
|
await client.get_about()
|
||||||
except FireflyAuthenticationError:
|
except FireflyAuthenticationError:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
@@ -152,7 +153,9 @@ class HassFoscamCamera(FoscamEntity, Camera):
|
|||||||
async def stream_source(self) -> str | None:
|
async def stream_source(self) -> str | None:
|
||||||
"""Return the stream source."""
|
"""Return the stream source."""
|
||||||
if self._rtsp_port:
|
if self._rtsp_port:
|
||||||
return f"rtsp://{self._username}:{self._password}@{self._foscam_session.host}:{self._rtsp_port}/video{self._stream}"
|
_username = quote(self._username)
|
||||||
|
_password = quote(self._password)
|
||||||
|
return f"rtsp://{_username}:{_password}@{self._foscam_session.host}:{self._rtsp_port}/video{self._stream}"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class FoscamDeviceInfo:
|
|||||||
supports_speak_volume_adjustment: bool
|
supports_speak_volume_adjustment: bool
|
||||||
supports_pet_adjustment: bool
|
supports_pet_adjustment: bool
|
||||||
supports_car_adjustment: bool
|
supports_car_adjustment: bool
|
||||||
|
supports_human_adjustment: bool
|
||||||
supports_wdr_adjustment: bool
|
supports_wdr_adjustment: bool
|
||||||
supports_hdr_adjustment: bool
|
supports_hdr_adjustment: bool
|
||||||
|
|
||||||
@@ -115,20 +116,28 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
|
|||||||
is_open_wdr = None
|
is_open_wdr = None
|
||||||
is_open_hdr = None
|
is_open_hdr = None
|
||||||
reserve3 = product_info.get("reserve4")
|
reserve3 = product_info.get("reserve4")
|
||||||
|
model = product_info.get("model")
|
||||||
|
model_int = int(model) if model is not None else 7002
|
||||||
|
if model_int > 7001:
|
||||||
reserve3_int = int(reserve3) if reserve3 is not None else 0
|
reserve3_int = int(reserve3) if reserve3 is not None else 0
|
||||||
supports_wdr_adjustment_val = bool(int(reserve3_int & 256))
|
supports_wdr_adjustment_val = bool(int(reserve3_int & 256))
|
||||||
supports_hdr_adjustment_val = bool(int(reserve3_int & 128))
|
supports_hdr_adjustment_val = bool(int(reserve3_int & 128))
|
||||||
if supports_wdr_adjustment_val:
|
if supports_wdr_adjustment_val:
|
||||||
ret_wdr, is_open_wdr_data = self.session.getWdrMode()
|
ret_wdr, is_open_wdr_data = self.session.getWdrMode()
|
||||||
mode = is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0
|
mode = (
|
||||||
|
is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0
|
||||||
|
)
|
||||||
is_open_wdr = bool(int(mode))
|
is_open_wdr = bool(int(mode))
|
||||||
elif supports_hdr_adjustment_val:
|
elif supports_hdr_adjustment_val:
|
||||||
ret_hdr, is_open_hdr_data = self.session.getHdrMode()
|
ret_hdr, is_open_hdr_data = self.session.getHdrMode()
|
||||||
mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0
|
mode = (
|
||||||
|
is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0
|
||||||
|
)
|
||||||
is_open_hdr = bool(int(mode))
|
is_open_hdr = bool(int(mode))
|
||||||
|
else:
|
||||||
|
supports_wdr_adjustment_val = False
|
||||||
|
supports_hdr_adjustment_val = False
|
||||||
ret_sw, software_capabilities = self.session.getSWCapabilities()
|
ret_sw, software_capabilities = self.session.getSWCapabilities()
|
||||||
|
|
||||||
supports_speak_volume_adjustment_val = (
|
supports_speak_volume_adjustment_val = (
|
||||||
bool(int(software_capabilities.get("swCapabilities1")) & 32)
|
bool(int(software_capabilities.get("swCapabilities1")) & 32)
|
||||||
if ret_sw == 0
|
if ret_sw == 0
|
||||||
@@ -144,24 +153,32 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
|
|||||||
if ret_sw == 0
|
if ret_sw == 0
|
||||||
else False
|
else False
|
||||||
)
|
)
|
||||||
ret_md, mothion_config_val = self.session.get_motion_detect_config()
|
human_adjustment_val = (
|
||||||
|
bool(int(software_capabilities.get("swCapabilities2")) & 128)
|
||||||
|
if ret_sw == 0
|
||||||
|
else False
|
||||||
|
)
|
||||||
|
ret_md, motion_config_val = self.session.get_motion_detect_config()
|
||||||
if pet_adjustment_val:
|
if pet_adjustment_val:
|
||||||
is_pet_detection_on_val = (
|
is_pet_detection_on_val = (
|
||||||
mothion_config_val["petEnable"] == "1" if ret_md == 0 else False
|
motion_config_val.get("petEnable") == "1" if ret_md == 0 else False
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
is_pet_detection_on_val = False
|
is_pet_detection_on_val = False
|
||||||
|
|
||||||
if car_adjustment_val:
|
if car_adjustment_val:
|
||||||
is_car_detection_on_val = (
|
is_car_detection_on_val = (
|
||||||
mothion_config_val["carEnable"] == "1" if ret_md == 0 else False
|
motion_config_val.get("carEnable") == "1" if ret_md == 0 else False
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
is_car_detection_on_val = False
|
is_car_detection_on_val = False
|
||||||
|
|
||||||
|
if human_adjustment_val:
|
||||||
is_human_detection_on_val = (
|
is_human_detection_on_val = (
|
||||||
mothion_config_val["humanEnable"] == "1" if ret_md == 0 else False
|
motion_config_val.get("humanEnable") == "1" if ret_md == 0 else False
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
is_human_detection_on_val = False
|
||||||
|
|
||||||
return FoscamDeviceInfo(
|
return FoscamDeviceInfo(
|
||||||
dev_info=dev_info,
|
dev_info=dev_info,
|
||||||
@@ -179,6 +196,7 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
|
|||||||
supports_speak_volume_adjustment=supports_speak_volume_adjustment_val,
|
supports_speak_volume_adjustment=supports_speak_volume_adjustment_val,
|
||||||
supports_pet_adjustment=pet_adjustment_val,
|
supports_pet_adjustment=pet_adjustment_val,
|
||||||
supports_car_adjustment=car_adjustment_val,
|
supports_car_adjustment=car_adjustment_val,
|
||||||
|
supports_human_adjustment=human_adjustment_val,
|
||||||
supports_hdr_adjustment=supports_hdr_adjustment_val,
|
supports_hdr_adjustment=supports_hdr_adjustment_val,
|
||||||
supports_wdr_adjustment=supports_wdr_adjustment_val,
|
supports_wdr_adjustment=supports_wdr_adjustment_val,
|
||||||
is_open_wdr=is_open_wdr,
|
is_open_wdr=is_open_wdr,
|
||||||
|
|||||||
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/foscam",
|
"documentation": "https://www.home-assistant.io/integrations/foscam",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["libpyfoscamcgi"],
|
"loggers": ["libpyfoscamcgi"],
|
||||||
"requirements": ["libpyfoscamcgi==0.0.8"]
|
"requirements": ["libpyfoscamcgi==0.0.9"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ SWITCH_DESCRIPTIONS: list[FoscamSwitchEntityDescription] = [
|
|||||||
native_value_fn=lambda data: data.is_human_detection_on,
|
native_value_fn=lambda data: data.is_human_detection_on,
|
||||||
turn_off_fn=lambda session: set_motion_detection(session, "humanEnable", False),
|
turn_off_fn=lambda session: set_motion_detection(session, "humanEnable", False),
|
||||||
turn_on_fn=lambda session: set_motion_detection(session, "humanEnable", True),
|
turn_on_fn=lambda session: set_motion_detection(session, "humanEnable", True),
|
||||||
|
exists_fn=lambda coordinator: coordinator.data.supports_human_adjustment,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -453,7 +453,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
hass.http.app.router.register_resource(IndexView(repo_path, hass))
|
hass.http.app.router.register_resource(IndexView(repo_path, hass))
|
||||||
|
|
||||||
async_register_built_in_panel(hass, "light")
|
async_register_built_in_panel(hass, "light")
|
||||||
async_register_built_in_panel(hass, "safety")
|
async_register_built_in_panel(hass, "security")
|
||||||
async_register_built_in_panel(hass, "climate")
|
async_register_built_in_panel(hass, "climate")
|
||||||
|
|
||||||
async_register_built_in_panel(hass, "profile")
|
async_register_built_in_panel(hass, "profile")
|
||||||
|
|||||||
@@ -20,5 +20,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["home-assistant-frontend==20251029.0"]
|
"requirements": ["home-assistant-frontend==20251105.1"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,35 +60,6 @@ from .server import Server
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
_FFMPEG = "ffmpeg"
|
_FFMPEG = "ffmpeg"
|
||||||
_SUPPORTED_STREAMS = frozenset(
|
|
||||||
(
|
|
||||||
"bubble",
|
|
||||||
"dvrip",
|
|
||||||
"expr",
|
|
||||||
_FFMPEG,
|
|
||||||
"gopro",
|
|
||||||
"homekit",
|
|
||||||
"http",
|
|
||||||
"https",
|
|
||||||
"httpx",
|
|
||||||
"isapi",
|
|
||||||
"ivideon",
|
|
||||||
"kasa",
|
|
||||||
"nest",
|
|
||||||
"onvif",
|
|
||||||
"roborock",
|
|
||||||
"rtmp",
|
|
||||||
"rtmps",
|
|
||||||
"rtmpx",
|
|
||||||
"rtsp",
|
|
||||||
"rtsps",
|
|
||||||
"rtspx",
|
|
||||||
"tapo",
|
|
||||||
"tcp",
|
|
||||||
"webrtc",
|
|
||||||
"webtorrent",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
CONFIG_SCHEMA = vol.Schema(
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
@@ -197,6 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bo
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
provider = entry.runtime_data = WebRTCProvider(hass, url, session, client)
|
provider = entry.runtime_data = WebRTCProvider(hass, url, session, client)
|
||||||
|
await provider.initialize()
|
||||||
entry.async_on_unload(async_register_webrtc_provider(hass, provider))
|
entry.async_on_unload(async_register_webrtc_provider(hass, provider))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -228,16 +200,21 @@ class WebRTCProvider(CameraWebRTCProvider):
|
|||||||
self._session = session
|
self._session = session
|
||||||
self._rest_client = rest_client
|
self._rest_client = rest_client
|
||||||
self._sessions: dict[str, Go2RtcWsClient] = {}
|
self._sessions: dict[str, Go2RtcWsClient] = {}
|
||||||
|
self._supported_schemes: set[str] = set()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def domain(self) -> str:
|
def domain(self) -> str:
|
||||||
"""Return the integration domain of the provider."""
|
"""Return the integration domain of the provider."""
|
||||||
return DOMAIN
|
return DOMAIN
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
"""Initialize the provider."""
|
||||||
|
self._supported_schemes = await self._rest_client.schemes.list()
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_is_supported(self, stream_source: str) -> bool:
|
def async_is_supported(self, stream_source: str) -> bool:
|
||||||
"""Return if this provider is supports the Camera as source."""
|
"""Return if this provider is supports the Camera as source."""
|
||||||
return stream_source.partition(":")[0] in _SUPPORTED_STREAMS
|
return stream_source.partition(":")[0] in self._supported_schemes
|
||||||
|
|
||||||
async def async_handle_async_webrtc_offer(
|
async def async_handle_async_webrtc_offer(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ CONF_DEBUG_UI = "debug_ui"
|
|||||||
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
|
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
|
||||||
HA_MANAGED_API_PORT = 11984
|
HA_MANAGED_API_PORT = 11984
|
||||||
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
|
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
|
||||||
RECOMMENDED_VERSION = "1.9.11"
|
RECOMMENDED_VERSION = "1.9.12"
|
||||||
|
|||||||
@@ -8,6 +8,6 @@
|
|||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["go2rtc-client==0.2.1"],
|
"requirements": ["go2rtc-client==0.3.0"],
|
||||||
"single_config_entry": true
|
"single_config_entry": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,18 @@ _RESPAWN_COOLDOWN = 1
|
|||||||
_GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant
|
_GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant
|
||||||
# Do not edit it manually
|
# Do not edit it manually
|
||||||
|
|
||||||
|
app:
|
||||||
|
modules: {app_modules}
|
||||||
|
|
||||||
api:
|
api:
|
||||||
listen: "{api_ip}:{api_port}"
|
listen: "{api_ip}:{api_port}"
|
||||||
|
allow_paths: {api_allow_paths}
|
||||||
|
|
||||||
|
# ffmpeg needs the exec module
|
||||||
|
# Restrict execution to only ffmpeg binary
|
||||||
|
exec:
|
||||||
|
allow_paths:
|
||||||
|
- ffmpeg
|
||||||
|
|
||||||
rtsp:
|
rtsp:
|
||||||
listen: "127.0.0.1:18554"
|
listen: "127.0.0.1:18554"
|
||||||
@@ -40,6 +50,43 @@ webrtc:
|
|||||||
ice_servers: []
|
ice_servers: []
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_APP_MODULES = (
|
||||||
|
"api",
|
||||||
|
"exec", # Execution module for ffmpeg
|
||||||
|
"ffmpeg",
|
||||||
|
"http",
|
||||||
|
"mjpeg",
|
||||||
|
"onvif",
|
||||||
|
"rtmp",
|
||||||
|
"rtsp",
|
||||||
|
"srtp",
|
||||||
|
"webrtc",
|
||||||
|
"ws",
|
||||||
|
)
|
||||||
|
|
||||||
|
_API_ALLOW_PATHS = (
|
||||||
|
"/", # UI static page and version control
|
||||||
|
"/api", # Main API path
|
||||||
|
"/api/frame.jpeg", # Snapshot functionality
|
||||||
|
"/api/schemes", # Supported stream schemes
|
||||||
|
"/api/streams", # Stream management
|
||||||
|
"/api/webrtc", # Webrtc functionality
|
||||||
|
"/api/ws", # Websocket functionality (e.g. webrtc candidates)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Additional modules when UI is enabled
|
||||||
|
_UI_APP_MODULES = (
|
||||||
|
*_APP_MODULES,
|
||||||
|
"debug",
|
||||||
|
)
|
||||||
|
# Additional api paths when UI is enabled
|
||||||
|
_UI_API_ALLOW_PATHS = (
|
||||||
|
*_API_ALLOW_PATHS,
|
||||||
|
"/api/config", # UI config view
|
||||||
|
"/api/log", # UI log view
|
||||||
|
"/api/streams.dot", # UI network view
|
||||||
|
)
|
||||||
|
|
||||||
_LOG_LEVEL_MAP = {
|
_LOG_LEVEL_MAP = {
|
||||||
"TRC": logging.DEBUG,
|
"TRC": logging.DEBUG,
|
||||||
"DBG": logging.DEBUG,
|
"DBG": logging.DEBUG,
|
||||||
@@ -61,14 +108,34 @@ class Go2RTCWatchdogError(HomeAssistantError):
|
|||||||
"""Raised on watchdog error."""
|
"""Raised on watchdog error."""
|
||||||
|
|
||||||
|
|
||||||
def _create_temp_file(api_ip: str) -> str:
|
def _format_list_for_yaml(items: tuple[str, ...]) -> str:
|
||||||
|
"""Format a list of strings for yaml config."""
|
||||||
|
if not items:
|
||||||
|
return "[]"
|
||||||
|
formatted_items = ",".join(f'"{item}"' for item in items)
|
||||||
|
return f"[{formatted_items}]"
|
||||||
|
|
||||||
|
|
||||||
|
def _create_temp_file(enable_ui: bool) -> str:
|
||||||
"""Create temporary config file."""
|
"""Create temporary config file."""
|
||||||
|
app_modules: tuple[str, ...] = _APP_MODULES
|
||||||
|
api_paths: tuple[str, ...] = _API_ALLOW_PATHS
|
||||||
|
api_ip = _LOCALHOST_IP
|
||||||
|
if enable_ui:
|
||||||
|
app_modules = _UI_APP_MODULES
|
||||||
|
api_paths = _UI_API_ALLOW_PATHS
|
||||||
|
# Listen on all interfaces for allowing access from all ips
|
||||||
|
api_ip = ""
|
||||||
|
|
||||||
# Set delete=False to prevent the file from being deleted when the file is closed
|
# Set delete=False to prevent the file from being deleted when the file is closed
|
||||||
# Linux is clearing tmp folder on reboot, so no need to delete it manually
|
# Linux is clearing tmp folder on reboot, so no need to delete it manually
|
||||||
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
|
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
|
||||||
file.write(
|
file.write(
|
||||||
_GO2RTC_CONFIG_FORMAT.format(
|
_GO2RTC_CONFIG_FORMAT.format(
|
||||||
api_ip=api_ip, api_port=HA_MANAGED_API_PORT
|
api_ip=api_ip,
|
||||||
|
api_port=HA_MANAGED_API_PORT,
|
||||||
|
app_modules=_format_list_for_yaml(app_modules),
|
||||||
|
api_allow_paths=_format_list_for_yaml(api_paths),
|
||||||
).encode()
|
).encode()
|
||||||
)
|
)
|
||||||
return file.name
|
return file.name
|
||||||
@@ -86,10 +153,7 @@ class Server:
|
|||||||
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
|
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
|
||||||
self._process: asyncio.subprocess.Process | None = None
|
self._process: asyncio.subprocess.Process | None = None
|
||||||
self._startup_complete = asyncio.Event()
|
self._startup_complete = asyncio.Event()
|
||||||
self._api_ip = _LOCALHOST_IP
|
self._enable_ui = enable_ui
|
||||||
if enable_ui:
|
|
||||||
# Listen on all interfaces for allowing access from all ips
|
|
||||||
self._api_ip = ""
|
|
||||||
self._watchdog_task: asyncio.Task | None = None
|
self._watchdog_task: asyncio.Task | None = None
|
||||||
self._watchdog_tasks: list[asyncio.Task] = []
|
self._watchdog_tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
@@ -104,7 +168,7 @@ class Server:
|
|||||||
"""Start the server."""
|
"""Start the server."""
|
||||||
_LOGGER.debug("Starting go2rtc server")
|
_LOGGER.debug("Starting go2rtc server")
|
||||||
config_file = await self._hass.async_add_executor_job(
|
config_file = await self._hass.async_add_executor_job(
|
||||||
_create_temp_file, self._api_ip
|
_create_temp_file, self._enable_ui
|
||||||
)
|
)
|
||||||
|
|
||||||
self._startup_complete.clear()
|
self._startup_complete.clear()
|
||||||
|
|||||||
@@ -136,6 +136,21 @@ async def async_setup_entry(
|
|||||||
new_data[CONF_URL] = url
|
new_data[CONF_URL] = url
|
||||||
hass.config_entries.async_update_entry(config_entry, data=new_data)
|
hass.config_entries.async_update_entry(config_entry, data=new_data)
|
||||||
|
|
||||||
|
# Migrate legacy config entries without auth_type field
|
||||||
|
if CONF_AUTH_TYPE not in config:
|
||||||
|
new_data = dict(config_entry.data)
|
||||||
|
# Detect auth type based on which fields are present
|
||||||
|
if CONF_TOKEN in config:
|
||||||
|
new_data[CONF_AUTH_TYPE] = AUTH_API_TOKEN
|
||||||
|
elif CONF_USERNAME in config:
|
||||||
|
new_data[CONF_AUTH_TYPE] = AUTH_PASSWORD
|
||||||
|
else:
|
||||||
|
raise ConfigEntryError(
|
||||||
|
"Unable to determine authentication type from config entry."
|
||||||
|
)
|
||||||
|
hass.config_entries.async_update_entry(config_entry, data=new_data)
|
||||||
|
config = config_entry.data
|
||||||
|
|
||||||
# Determine API version
|
# Determine API version
|
||||||
if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN:
|
if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN:
|
||||||
api_version = "v1"
|
api_version = "v1"
|
||||||
|
|||||||
@@ -620,7 +620,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
# Pop add-on data
|
# Unload coordinator
|
||||||
|
coordinator: HassioDataUpdateCoordinator = hass.data[ADDONS_COORDINATOR]
|
||||||
|
coordinator.unload()
|
||||||
|
|
||||||
|
# Pop coordinator
|
||||||
hass.data.pop(ADDONS_COORDINATOR, None)
|
hass.data.pop(ADDONS_COORDINATOR, None)
|
||||||
|
|
||||||
return unload_ok
|
return unload_ok
|
||||||
|
|||||||
@@ -563,3 +563,8 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
|||||||
self.async_set_updated_data(data)
|
self.async_set_updated_data(data)
|
||||||
except SupervisorError as err:
|
except SupervisorError as err:
|
||||||
_LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err)
|
_LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def unload(self) -> None:
|
||||||
|
"""Clean up when config entry unloaded."""
|
||||||
|
self.jobs.unload()
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ from .const import (
|
|||||||
EVENT_SUPPORTED_CHANGED,
|
EVENT_SUPPORTED_CHANGED,
|
||||||
EXTRA_PLACEHOLDERS,
|
EXTRA_PLACEHOLDERS,
|
||||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||||
ISSUE_KEY_ADDON_DEPRECATED,
|
|
||||||
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
|
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
|
||||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||||
ISSUE_KEY_ADDON_PWNED,
|
ISSUE_KEY_ADDON_PWNED,
|
||||||
@@ -87,7 +86,6 @@ ISSUE_KEYS_FOR_REPAIRS = {
|
|||||||
"issue_system_disk_lifetime",
|
"issue_system_disk_lifetime",
|
||||||
ISSUE_KEY_SYSTEM_FREE_SPACE,
|
ISSUE_KEY_SYSTEM_FREE_SPACE,
|
||||||
ISSUE_KEY_ADDON_PWNED,
|
ISSUE_KEY_ADDON_PWNED,
|
||||||
ISSUE_KEY_ADDON_DEPRECATED,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -29,6 +30,8 @@ from .const import (
|
|||||||
)
|
)
|
||||||
from .handler import get_supervisor_client
|
from .handler import get_supervisor_client
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True, frozen=True)
|
@dataclass(slots=True, frozen=True)
|
||||||
class JobSubscription:
|
class JobSubscription:
|
||||||
@@ -45,7 +48,7 @@ class JobSubscription:
|
|||||||
event_callback: Callable[[Job], Any]
|
event_callback: Callable[[Job], Any]
|
||||||
uuid: str | None = None
|
uuid: str | None = None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
reference: str | None | type[Any] = Any
|
reference: str | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
"""Validate at least one filter option is present."""
|
"""Validate at least one filter option is present."""
|
||||||
@@ -58,7 +61,7 @@ class JobSubscription:
|
|||||||
"""Return true if job matches subscription filters."""
|
"""Return true if job matches subscription filters."""
|
||||||
if self.uuid:
|
if self.uuid:
|
||||||
return job.uuid == self.uuid
|
return job.uuid == self.uuid
|
||||||
return job.name == self.name and self.reference in (Any, job.reference)
|
return job.name == self.name and self.reference in (None, job.reference)
|
||||||
|
|
||||||
|
|
||||||
class SupervisorJobs:
|
class SupervisorJobs:
|
||||||
@@ -70,6 +73,7 @@ class SupervisorJobs:
|
|||||||
self._supervisor_client = get_supervisor_client(hass)
|
self._supervisor_client = get_supervisor_client(hass)
|
||||||
self._jobs: dict[UUID, Job] = {}
|
self._jobs: dict[UUID, Job] = {}
|
||||||
self._subscriptions: set[JobSubscription] = set()
|
self._subscriptions: set[JobSubscription] = set()
|
||||||
|
self._dispatcher_disconnect: Callable[[], None] | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_jobs(self) -> list[Job]:
|
def current_jobs(self) -> list[Job]:
|
||||||
@@ -79,20 +83,24 @@ class SupervisorJobs:
|
|||||||
def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE:
|
def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE:
|
||||||
"""Subscribe to updates for job. Return callback is used to unsubscribe.
|
"""Subscribe to updates for job. Return callback is used to unsubscribe.
|
||||||
|
|
||||||
If any jobs match the subscription at the time this is called, creates
|
If any jobs match the subscription at the time this is called, runs the
|
||||||
tasks to run their callback on it.
|
callback on them.
|
||||||
"""
|
"""
|
||||||
self._subscriptions.add(subscription)
|
self._subscriptions.add(subscription)
|
||||||
|
|
||||||
# As these are callbacks they are safe to run in the event loop
|
# Run the callback on each existing match
|
||||||
# We wrap these in an asyncio task so subscribing does not wait on the logic
|
# We catch all errors to prevent an error in one from stopping the others
|
||||||
if matches := [job for job in self._jobs.values() if subscription.matches(job)]:
|
for match in [job for job in self._jobs.values() if subscription.matches(job)]:
|
||||||
|
try:
|
||||||
async def event_callback_async(job: Job) -> Any:
|
return subscription.event_callback(match)
|
||||||
return subscription.event_callback(job)
|
except Exception as err: # noqa: BLE001
|
||||||
|
_LOGGER.error(
|
||||||
for match in matches:
|
"Error encountered processing Supervisor Job (%s %s %s) - %s",
|
||||||
self._hass.async_create_task(event_callback_async(match))
|
match.name,
|
||||||
|
match.reference,
|
||||||
|
match.uuid,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
|
||||||
return partial(self._subscriptions.discard, subscription)
|
return partial(self._subscriptions.discard, subscription)
|
||||||
|
|
||||||
@@ -131,7 +139,7 @@ class SupervisorJobs:
|
|||||||
|
|
||||||
# If this is the first update register to receive Supervisor events
|
# If this is the first update register to receive Supervisor events
|
||||||
if first_update:
|
if first_update:
|
||||||
async_dispatcher_connect(
|
self._dispatcher_disconnect = async_dispatcher_connect(
|
||||||
self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs
|
self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -158,3 +166,14 @@ class SupervisorJobs:
|
|||||||
for sub in self._subscriptions:
|
for sub in self._subscriptions:
|
||||||
if sub.matches(job):
|
if sub.matches(job):
|
||||||
sub.event_callback(job)
|
sub.event_callback(job)
|
||||||
|
|
||||||
|
# If the job is done, pop it from our cache if present after processing is done
|
||||||
|
if job.done and job.uuid in self._jobs:
|
||||||
|
del self._jobs[job.uuid]
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def unload(self) -> None:
|
||||||
|
"""Unregister with dispatcher on config entry unload."""
|
||||||
|
if self._dispatcher_disconnect:
|
||||||
|
self._dispatcher_disconnect()
|
||||||
|
self._dispatcher_disconnect = None
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["holidays==0.83", "babel==2.15.0"]
|
"requirements": ["holidays==0.84", "babel==2.15.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ from .const import (
|
|||||||
NABU_CASA_FIRMWARE_RELEASES_URL,
|
NABU_CASA_FIRMWARE_RELEASES_URL,
|
||||||
PID,
|
PID,
|
||||||
PRODUCT,
|
PRODUCT,
|
||||||
|
RADIO_TX_POWER_DBM_BY_COUNTRY,
|
||||||
|
RADIO_TX_POWER_DBM_DEFAULT,
|
||||||
SERIAL_NUMBER,
|
SERIAL_NUMBER,
|
||||||
VID,
|
VID,
|
||||||
)
|
)
|
||||||
@@ -74,7 +76,17 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
|||||||
"""Mixin for Home Assistant Connect ZBT-2 firmware methods."""
|
"""Mixin for Home Assistant Connect ZBT-2 firmware methods."""
|
||||||
|
|
||||||
context: ConfigFlowContext
|
context: ConfigFlowContext
|
||||||
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
|
|
||||||
|
ZIGBEE_BAUDRATE = 460800
|
||||||
|
|
||||||
|
# Early ZBT-2 samples used RTS/DTR to trigger the bootloader, later ones use the
|
||||||
|
# baudrate method. Since the two are mutually exclusive we just use both.
|
||||||
|
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
|
||||||
|
APPLICATION_PROBE_METHODS = [
|
||||||
|
(ApplicationType.GECKO_BOOTLOADER, 115200),
|
||||||
|
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
|
||||||
|
(ApplicationType.SPINEL, 460800),
|
||||||
|
]
|
||||||
|
|
||||||
async def async_step_install_zigbee_firmware(
|
async def async_step_install_zigbee_firmware(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
@@ -102,6 +114,21 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
|||||||
next_step_id="finish_thread_installation",
|
next_step_id="finish_thread_installation",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _extra_zha_hardware_options(self) -> dict[str, Any]:
|
||||||
|
"""Return extra ZHA hardware options."""
|
||||||
|
country = self.hass.config.country
|
||||||
|
|
||||||
|
if country is None:
|
||||||
|
tx_power = RADIO_TX_POWER_DBM_DEFAULT
|
||||||
|
else:
|
||||||
|
tx_power = RADIO_TX_POWER_DBM_BY_COUNTRY.get(
|
||||||
|
country, RADIO_TX_POWER_DBM_DEFAULT
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tx_power": tx_power,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class HomeAssistantConnectZBT2ConfigFlow(
|
class HomeAssistantConnectZBT2ConfigFlow(
|
||||||
ZBT2FirmwareMixin,
|
ZBT2FirmwareMixin,
|
||||||
@@ -112,7 +139,6 @@ class HomeAssistantConnectZBT2ConfigFlow(
|
|||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
MINOR_VERSION = 1
|
MINOR_VERSION = 1
|
||||||
ZIGBEE_BAUDRATE = 460800
|
|
||||||
|
|
||||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
"""Initialize the config flow."""
|
"""Initialize the config flow."""
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Constants for the Home Assistant Connect ZBT-2 integration."""
|
"""Constants for the Home Assistant Connect ZBT-2 integration."""
|
||||||
|
|
||||||
|
from homeassistant.generated.countries import COUNTRIES
|
||||||
|
|
||||||
DOMAIN = "homeassistant_connect_zbt2"
|
DOMAIN = "homeassistant_connect_zbt2"
|
||||||
|
|
||||||
NABU_CASA_FIRMWARE_RELEASES_URL = (
|
NABU_CASA_FIRMWARE_RELEASES_URL = (
|
||||||
@@ -17,3 +19,59 @@ VID = "vid"
|
|||||||
DEVICE = "device"
|
DEVICE = "device"
|
||||||
|
|
||||||
HARDWARE_NAME = "Home Assistant Connect ZBT-2"
|
HARDWARE_NAME = "Home Assistant Connect ZBT-2"
|
||||||
|
|
||||||
|
RADIO_TX_POWER_DBM_DEFAULT = 8
|
||||||
|
RADIO_TX_POWER_DBM_BY_COUNTRY = {
|
||||||
|
# EU Member States
|
||||||
|
"AT": 10,
|
||||||
|
"BE": 10,
|
||||||
|
"BG": 10,
|
||||||
|
"HR": 10,
|
||||||
|
"CY": 10,
|
||||||
|
"CZ": 10,
|
||||||
|
"DK": 10,
|
||||||
|
"EE": 10,
|
||||||
|
"FI": 10,
|
||||||
|
"FR": 10,
|
||||||
|
"DE": 10,
|
||||||
|
"GR": 10,
|
||||||
|
"HU": 10,
|
||||||
|
"IE": 10,
|
||||||
|
"IT": 10,
|
||||||
|
"LV": 10,
|
||||||
|
"LT": 10,
|
||||||
|
"LU": 10,
|
||||||
|
"MT": 10,
|
||||||
|
"NL": 10,
|
||||||
|
"PL": 10,
|
||||||
|
"PT": 10,
|
||||||
|
"RO": 10,
|
||||||
|
"SK": 10,
|
||||||
|
"SI": 10,
|
||||||
|
"ES": 10,
|
||||||
|
"SE": 10,
|
||||||
|
# EEA Members
|
||||||
|
"IS": 10,
|
||||||
|
"LI": 10,
|
||||||
|
"NO": 10,
|
||||||
|
# Standards harmonized with RED or ETSI
|
||||||
|
"CH": 10,
|
||||||
|
"GB": 10,
|
||||||
|
"TR": 10,
|
||||||
|
"AL": 10,
|
||||||
|
"BA": 10,
|
||||||
|
"GE": 10,
|
||||||
|
"MD": 10,
|
||||||
|
"ME": 10,
|
||||||
|
"MK": 10,
|
||||||
|
"RS": 10,
|
||||||
|
"UA": 10,
|
||||||
|
# Other CEPT nations
|
||||||
|
"AD": 10,
|
||||||
|
"AZ": 10,
|
||||||
|
"MC": 10,
|
||||||
|
"SM": 10,
|
||||||
|
"VA": 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert set(RADIO_TX_POWER_DBM_BY_COUNTRY) <= COUNTRIES
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
"dependencies": ["hardware", "usb", "homeassistant_hardware"],
|
"dependencies": ["hardware", "usb", "homeassistant_hardware"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_connect_zbt2",
|
"documentation": "https://www.home-assistant.io/integrations/homeassistant_connect_zbt2",
|
||||||
"integration_type": "hardware",
|
"integration_type": "hardware",
|
||||||
|
"loggers": [
|
||||||
|
"bellows",
|
||||||
|
"universal_silabs_flasher",
|
||||||
|
"zigpy.serial",
|
||||||
|
"serial_asyncio_fast"
|
||||||
|
],
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"usb": [
|
"usb": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
|
|||||||
from homeassistant.components.homeassistant_hardware.util import (
|
from homeassistant.components.homeassistant_hardware.util import (
|
||||||
ApplicationType,
|
ApplicationType,
|
||||||
FirmwareInfo,
|
FirmwareInfo,
|
||||||
ResetTarget,
|
|
||||||
)
|
)
|
||||||
from homeassistant.components.update import UpdateDeviceClass
|
from homeassistant.components.update import UpdateDeviceClass
|
||||||
from homeassistant.const import EntityCategory
|
from homeassistant.const import EntityCategory
|
||||||
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import HomeAssistantConnectZBT2ConfigEntry
|
from . import HomeAssistantConnectZBT2ConfigEntry
|
||||||
|
from .config_flow import ZBT2FirmwareMixin
|
||||||
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, HARDWARE_NAME, SERIAL_NUMBER
|
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, HARDWARE_NAME, SERIAL_NUMBER
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -134,7 +134,8 @@ async def async_setup_entry(
|
|||||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||||
"""Connect ZBT-2 firmware update entity."""
|
"""Connect ZBT-2 firmware update entity."""
|
||||||
|
|
||||||
bootloader_reset_methods = [ResetTarget.RTS_DTR]
|
BOOTLOADER_RESET_METHODS = ZBT2FirmwareMixin.BOOTLOADER_RESET_METHODS
|
||||||
|
APPLICATION_PROBE_METHODS = ZBT2FirmwareMixin.APPLICATION_PROBE_METHODS
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from homeassistant.config_entries import (
|
|||||||
OptionsFlow,
|
OptionsFlow,
|
||||||
)
|
)
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.data_entry_flow import AbortFlow, progress_step
|
from homeassistant.data_entry_flow import AbortFlow
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.hassio import is_hassio
|
from homeassistant.helpers.hassio import is_hassio
|
||||||
@@ -81,6 +81,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
|
|
||||||
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
|
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
|
||||||
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
|
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
|
||||||
|
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]] = []
|
||||||
|
|
||||||
_picked_firmware_type: PickedFirmwareType
|
_picked_firmware_type: PickedFirmwareType
|
||||||
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
|
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
|
||||||
@@ -97,6 +98,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
self.addon_uninstall_task: asyncio.Task | None = None
|
self.addon_uninstall_task: asyncio.Task | None = None
|
||||||
self.firmware_install_task: asyncio.Task[None] | None = None
|
self.firmware_install_task: asyncio.Task[None] | None = None
|
||||||
self.installing_firmware_name: str | None = None
|
self.installing_firmware_name: str | None = None
|
||||||
|
self._install_otbr_addon_task: asyncio.Task[None] | None = None
|
||||||
|
self._start_otbr_addon_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
# Progress flow steps cannot abort so we need to store the abort reason and then
|
||||||
|
# re-raise it in a dedicated step
|
||||||
|
self._progress_error: AbortFlow | None = None
|
||||||
|
|
||||||
def _get_translation_placeholders(self) -> dict[str, str]:
|
def _get_translation_placeholders(self) -> dict[str, str]:
|
||||||
"""Shared translation placeholders."""
|
"""Shared translation placeholders."""
|
||||||
@@ -106,6 +113,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
if self._probed_firmware_info is not None
|
if self._probed_firmware_info is not None
|
||||||
else "unknown"
|
else "unknown"
|
||||||
),
|
),
|
||||||
|
"firmware_name": (
|
||||||
|
self.installing_firmware_name
|
||||||
|
if self.installing_firmware_name is not None
|
||||||
|
else "unknown"
|
||||||
|
),
|
||||||
"model": self._hardware_name,
|
"model": self._hardware_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,22 +194,22 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
return self.async_show_progress(
|
return self.async_show_progress(
|
||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
progress_action="install_firmware",
|
progress_action="install_firmware",
|
||||||
description_placeholders={
|
description_placeholders=self._get_translation_placeholders(),
|
||||||
**self._get_translation_placeholders(),
|
|
||||||
"firmware_name": firmware_name,
|
|
||||||
},
|
|
||||||
progress_task=self.firmware_install_task,
|
progress_task=self.firmware_install_task,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.firmware_install_task
|
await self.firmware_install_task
|
||||||
except AbortFlow as err:
|
except AbortFlow as err:
|
||||||
return self.async_show_progress_done(
|
self._progress_error = err
|
||||||
next_step_id=err.reason,
|
return self.async_show_progress_done(next_step_id="progress_failed")
|
||||||
)
|
|
||||||
except HomeAssistantError:
|
except HomeAssistantError:
|
||||||
_LOGGER.exception("Failed to flash firmware")
|
_LOGGER.exception("Failed to flash firmware")
|
||||||
return self.async_show_progress_done(next_step_id="firmware_install_failed")
|
self._progress_error = AbortFlow(
|
||||||
|
reason="fw_install_failed",
|
||||||
|
description_placeholders=self._get_translation_placeholders(),
|
||||||
|
)
|
||||||
|
return self.async_show_progress_done(next_step_id="progress_failed")
|
||||||
finally:
|
finally:
|
||||||
self.firmware_install_task = None
|
self.firmware_install_task = None
|
||||||
|
|
||||||
@@ -219,7 +231,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
# Installing new firmware is only truly required if the wrong type is
|
# Installing new firmware is only truly required if the wrong type is
|
||||||
# installed: upgrading to the latest release of the current firmware type
|
# installed: upgrading to the latest release of the current firmware type
|
||||||
# isn't strictly necessary for functionality.
|
# isn't strictly necessary for functionality.
|
||||||
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
|
self._probed_firmware_info = await probe_silabs_firmware_info(
|
||||||
|
self._device,
|
||||||
|
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||||
|
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||||
|
)
|
||||||
|
|
||||||
firmware_install_required = self._probed_firmware_info is None or (
|
firmware_install_required = self._probed_firmware_info is None or (
|
||||||
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
|
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
|
||||||
@@ -241,7 +257,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
_LOGGER.debug("Skipping firmware upgrade due to index download failure")
|
_LOGGER.debug("Skipping firmware upgrade due to index download failure")
|
||||||
return
|
return
|
||||||
|
|
||||||
raise AbortFlow(reason="firmware_download_failed") from err
|
raise AbortFlow(
|
||||||
|
reason="fw_download_failed",
|
||||||
|
description_placeholders=self._get_translation_placeholders(),
|
||||||
|
) from err
|
||||||
|
|
||||||
if not firmware_install_required:
|
if not firmware_install_required:
|
||||||
assert self._probed_firmware_info is not None
|
assert self._probed_firmware_info is not None
|
||||||
@@ -270,7 +289,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Otherwise, fail
|
# Otherwise, fail
|
||||||
raise AbortFlow(reason="firmware_download_failed") from err
|
raise AbortFlow(
|
||||||
|
reason="fw_download_failed",
|
||||||
|
description_placeholders=self._get_translation_placeholders(),
|
||||||
|
) from err
|
||||||
|
|
||||||
self._probed_firmware_info = await async_flash_silabs_firmware(
|
self._probed_firmware_info = await async_flash_silabs_firmware(
|
||||||
hass=self.hass,
|
hass=self.hass,
|
||||||
@@ -278,6 +300,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
fw_data=fw_data,
|
fw_data=fw_data,
|
||||||
expected_installed_firmware_type=expected_installed_firmware_type,
|
expected_installed_firmware_type=expected_installed_firmware_type,
|
||||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||||
|
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||||
progress_callback=lambda offset, total: self.async_update_progress(
|
progress_callback=lambda offset, total: self.async_update_progress(
|
||||||
offset / total
|
offset / total
|
||||||
),
|
),
|
||||||
@@ -313,41 +336,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
|
|
||||||
await otbr_manager.async_start_addon_waiting()
|
await otbr_manager.async_start_addon_waiting()
|
||||||
|
|
||||||
async def async_step_firmware_download_failed(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Abort when firmware download failed."""
|
|
||||||
assert self.installing_firmware_name is not None
|
|
||||||
return self.async_abort(
|
|
||||||
reason="fw_download_failed",
|
|
||||||
description_placeholders={
|
|
||||||
**self._get_translation_placeholders(),
|
|
||||||
"firmware_name": self.installing_firmware_name,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_firmware_install_failed(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Abort when firmware install failed."""
|
|
||||||
assert self.installing_firmware_name is not None
|
|
||||||
return self.async_abort(
|
|
||||||
reason="fw_install_failed",
|
|
||||||
description_placeholders={
|
|
||||||
**self._get_translation_placeholders(),
|
|
||||||
"firmware_name": self.installing_firmware_name,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_unsupported_firmware(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Abort when unsupported firmware is detected."""
|
|
||||||
return self.async_abort(
|
|
||||||
reason="unsupported_firmware",
|
|
||||||
description_placeholders=self._get_translation_placeholders(),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_zigbee_installation_type(
|
async def async_step_zigbee_installation_type(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
@@ -456,6 +444,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
# This step is necessary to prevent `user_input` from being passed through
|
# This step is necessary to prevent `user_input` from being passed through
|
||||||
return await self.async_step_continue_zigbee()
|
return await self.async_step_continue_zigbee()
|
||||||
|
|
||||||
|
def _extra_zha_hardware_options(self) -> dict[str, Any]:
|
||||||
|
"""Return extra ZHA hardware options."""
|
||||||
|
return {}
|
||||||
|
|
||||||
async def async_step_continue_zigbee(
|
async def async_step_continue_zigbee(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
@@ -478,6 +470,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
},
|
},
|
||||||
"radio_type": "ezsp",
|
"radio_type": "ezsp",
|
||||||
"flow_strategy": self._zigbee_flow_strategy,
|
"flow_strategy": self._zigbee_flow_strategy,
|
||||||
|
**self._extra_zha_hardware_options(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return self._continue_zha_flow(result)
|
return self._continue_zha_flow(result)
|
||||||
@@ -506,16 +499,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
"""Install Thread firmware."""
|
"""Install Thread firmware."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@progress_step(
|
async def async_step_progress_failed(
|
||||||
description_placeholders=lambda self: {
|
|
||||||
**self._get_translation_placeholders(),
|
|
||||||
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
async def async_step_install_otbr_addon(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Show progress dialog for installing the OTBR addon."""
|
"""Abort when progress step failed."""
|
||||||
|
assert self._progress_error is not None
|
||||||
|
raise self._progress_error
|
||||||
|
|
||||||
|
async def _async_install_otbr_addon(self) -> None:
|
||||||
|
"""Do the work of installing the OTBR addon."""
|
||||||
addon_manager = get_otbr_addon_manager(self.hass)
|
addon_manager = get_otbr_addon_manager(self.hass)
|
||||||
addon_info = await self._async_get_addon_info(addon_manager)
|
addon_info = await self._async_get_addon_info(addon_manager)
|
||||||
|
|
||||||
@@ -533,18 +525,39 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
},
|
},
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
return await self.async_step_finish_thread_installation()
|
async def async_step_install_otbr_addon(
|
||||||
|
|
||||||
@progress_step(
|
|
||||||
description_placeholders=lambda self: {
|
|
||||||
**self._get_translation_placeholders(),
|
|
||||||
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
async def async_step_start_otbr_addon(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Configure OTBR to point to the SkyConnect and run the addon."""
|
"""Show progress dialog for installing the OTBR addon."""
|
||||||
|
if self._install_otbr_addon_task is None:
|
||||||
|
self._install_otbr_addon_task = self.hass.async_create_task(
|
||||||
|
self._async_install_otbr_addon(),
|
||||||
|
"Install OTBR addon",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._install_otbr_addon_task.done():
|
||||||
|
return self.async_show_progress(
|
||||||
|
step_id="install_otbr_addon",
|
||||||
|
progress_action="install_otbr_addon",
|
||||||
|
description_placeholders={
|
||||||
|
**self._get_translation_placeholders(),
|
||||||
|
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
||||||
|
},
|
||||||
|
progress_task=self._install_otbr_addon_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._install_otbr_addon_task
|
||||||
|
except AbortFlow as err:
|
||||||
|
self._progress_error = err
|
||||||
|
return self.async_show_progress_done(next_step_id="progress_failed")
|
||||||
|
finally:
|
||||||
|
self._install_otbr_addon_task = None
|
||||||
|
|
||||||
|
return self.async_show_progress_done(next_step_id="finish_thread_installation")
|
||||||
|
|
||||||
|
async def _async_start_otbr_addon(self) -> None:
|
||||||
|
"""Do the work of starting the OTBR addon."""
|
||||||
try:
|
try:
|
||||||
await self._configure_and_start_otbr_addon()
|
await self._configure_and_start_otbr_addon()
|
||||||
except AddonError as err:
|
except AddonError as err:
|
||||||
@@ -557,7 +570,36 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
},
|
},
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
return await self.async_step_pre_confirm_otbr()
|
async def async_step_start_otbr_addon(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Configure OTBR to point to the SkyConnect and run the addon."""
|
||||||
|
if self._start_otbr_addon_task is None:
|
||||||
|
self._start_otbr_addon_task = self.hass.async_create_task(
|
||||||
|
self._async_start_otbr_addon(),
|
||||||
|
"Start OTBR addon",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._start_otbr_addon_task.done():
|
||||||
|
return self.async_show_progress(
|
||||||
|
step_id="start_otbr_addon",
|
||||||
|
progress_action="start_otbr_addon",
|
||||||
|
description_placeholders={
|
||||||
|
**self._get_translation_placeholders(),
|
||||||
|
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
||||||
|
},
|
||||||
|
progress_task=self._start_otbr_addon_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._start_otbr_addon_task
|
||||||
|
except AbortFlow as err:
|
||||||
|
self._progress_error = err
|
||||||
|
return self.async_show_progress_done(next_step_id="progress_failed")
|
||||||
|
finally:
|
||||||
|
self._start_otbr_addon_task = None
|
||||||
|
|
||||||
|
return self.async_show_progress_done(next_step_id="pre_confirm_otbr")
|
||||||
|
|
||||||
async def async_step_pre_confirm_otbr(
|
async def async_step_pre_confirm_otbr(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"universal-silabs-flasher==0.0.37",
|
"universal-silabs-flasher==0.1.2",
|
||||||
"ha-silabs-firmware-client==0.3.0"
|
"ha-silabs-firmware-client==0.3.0"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ class BaseFirmwareUpdateEntity(
|
|||||||
|
|
||||||
# Subclasses provide the mapping between firmware types and entity descriptions
|
# Subclasses provide the mapping between firmware types and entity descriptions
|
||||||
entity_description: FirmwareUpdateEntityDescription
|
entity_description: FirmwareUpdateEntityDescription
|
||||||
bootloader_reset_methods: list[ResetTarget] = []
|
BOOTLOADER_RESET_METHODS: list[ResetTarget]
|
||||||
|
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]]
|
||||||
|
|
||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||||
@@ -278,7 +279,8 @@ class BaseFirmwareUpdateEntity(
|
|||||||
device=self._current_device,
|
device=self._current_device,
|
||||||
fw_data=fw_data,
|
fw_data=fw_data,
|
||||||
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
|
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
|
||||||
bootloader_reset_methods=self.bootloader_reset_methods,
|
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||||
|
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||||
progress_callback=self._update_progress,
|
progress_callback=self._update_progress,
|
||||||
domain=self._config_entry.domain,
|
domain=self._config_entry.domain,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
|
from collections.abc import AsyncIterator, Callable, Sequence
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
@@ -309,15 +309,20 @@ async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> Firmware
|
|||||||
|
|
||||||
|
|
||||||
async def probe_silabs_firmware_info(
|
async def probe_silabs_firmware_info(
|
||||||
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
|
device: str,
|
||||||
|
*,
|
||||||
|
bootloader_reset_methods: Sequence[ResetTarget],
|
||||||
|
application_probe_methods: Sequence[tuple[ApplicationType, int]],
|
||||||
) -> FirmwareInfo | None:
|
) -> FirmwareInfo | None:
|
||||||
"""Probe the running firmware on a SiLabs device."""
|
"""Probe the running firmware on a SiLabs device."""
|
||||||
flasher = Flasher(
|
flasher = Flasher(
|
||||||
device=device,
|
device=device,
|
||||||
**(
|
probe_methods=tuple(
|
||||||
{"probe_methods": [m.as_flasher_application_type() for m in probe_methods]}
|
(m.as_flasher_application_type(), baudrate)
|
||||||
if probe_methods
|
for m, baudrate in application_probe_methods
|
||||||
else {}
|
),
|
||||||
|
bootloader_reset=tuple(
|
||||||
|
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -343,11 +348,18 @@ async def probe_silabs_firmware_info(
|
|||||||
|
|
||||||
|
|
||||||
async def probe_silabs_firmware_type(
|
async def probe_silabs_firmware_type(
|
||||||
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
|
device: str,
|
||||||
|
*,
|
||||||
|
bootloader_reset_methods: Sequence[ResetTarget],
|
||||||
|
application_probe_methods: Sequence[tuple[ApplicationType, int]],
|
||||||
) -> ApplicationType | None:
|
) -> ApplicationType | None:
|
||||||
"""Probe the running firmware type on a SiLabs device."""
|
"""Probe the running firmware type on a SiLabs device."""
|
||||||
|
|
||||||
fw_info = await probe_silabs_firmware_info(device, probe_methods=probe_methods)
|
fw_info = await probe_silabs_firmware_info(
|
||||||
|
device,
|
||||||
|
bootloader_reset_methods=bootloader_reset_methods,
|
||||||
|
application_probe_methods=application_probe_methods,
|
||||||
|
)
|
||||||
if fw_info is None:
|
if fw_info is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -359,12 +371,22 @@ async def async_flash_silabs_firmware(
|
|||||||
device: str,
|
device: str,
|
||||||
fw_data: bytes,
|
fw_data: bytes,
|
||||||
expected_installed_firmware_type: ApplicationType,
|
expected_installed_firmware_type: ApplicationType,
|
||||||
bootloader_reset_methods: Sequence[ResetTarget] = (),
|
bootloader_reset_methods: Sequence[ResetTarget],
|
||||||
|
application_probe_methods: Sequence[tuple[ApplicationType, int]],
|
||||||
progress_callback: Callable[[int, int], None] | None = None,
|
progress_callback: Callable[[int, int], None] | None = None,
|
||||||
*,
|
*,
|
||||||
domain: str = DOMAIN,
|
domain: str = DOMAIN,
|
||||||
) -> FirmwareInfo:
|
) -> FirmwareInfo:
|
||||||
"""Flash firmware to the SiLabs device."""
|
"""Flash firmware to the SiLabs device."""
|
||||||
|
if not any(
|
||||||
|
method == expected_installed_firmware_type
|
||||||
|
for method, _ in application_probe_methods
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"Expected installed firmware type {expected_installed_firmware_type!r}"
|
||||||
|
f" not in application probe methods {application_probe_methods!r}"
|
||||||
|
)
|
||||||
|
|
||||||
async with async_firmware_update_context(hass, device, domain):
|
async with async_firmware_update_context(hass, device, domain):
|
||||||
firmware_info = await guess_firmware_info(hass, device)
|
firmware_info = await guess_firmware_info(hass, device)
|
||||||
_LOGGER.debug("Identified firmware info: %s", firmware_info)
|
_LOGGER.debug("Identified firmware info: %s", firmware_info)
|
||||||
@@ -373,11 +395,9 @@ async def async_flash_silabs_firmware(
|
|||||||
|
|
||||||
flasher = Flasher(
|
flasher = Flasher(
|
||||||
device=device,
|
device=device,
|
||||||
probe_methods=(
|
probe_methods=tuple(
|
||||||
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
|
(m.as_flasher_application_type(), baudrate)
|
||||||
ApplicationType.EZSP.as_flasher_application_type(),
|
for m, baudrate in application_probe_methods
|
||||||
ApplicationType.SPINEL.as_flasher_application_type(),
|
|
||||||
ApplicationType.CPC.as_flasher_application_type(),
|
|
||||||
),
|
),
|
||||||
bootloader_reset=tuple(
|
bootloader_reset=tuple(
|
||||||
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
||||||
@@ -401,7 +421,13 @@ async def async_flash_silabs_firmware(
|
|||||||
|
|
||||||
probed_firmware_info = await probe_silabs_firmware_info(
|
probed_firmware_info = await probe_silabs_firmware_info(
|
||||||
device,
|
device,
|
||||||
probe_methods=(expected_installed_firmware_type,),
|
bootloader_reset_methods=bootloader_reset_methods,
|
||||||
|
# Only probe for the expected installed firmware type
|
||||||
|
application_probe_methods=[
|
||||||
|
(method, baudrate)
|
||||||
|
for method, baudrate in application_probe_methods
|
||||||
|
if method == expected_installed_firmware_type
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
if probed_firmware_info is None:
|
if probed_firmware_info is None:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.helpers import (
|
|||||||
from homeassistant.components.homeassistant_hardware.util import (
|
from homeassistant.components.homeassistant_hardware.util import (
|
||||||
ApplicationType,
|
ApplicationType,
|
||||||
FirmwareInfo,
|
FirmwareInfo,
|
||||||
|
ResetTarget,
|
||||||
)
|
)
|
||||||
from homeassistant.components.usb import (
|
from homeassistant.components.usb import (
|
||||||
usb_service_info_from_device,
|
usb_service_info_from_device,
|
||||||
@@ -79,6 +80,20 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
|||||||
|
|
||||||
context: ConfigFlowContext
|
context: ConfigFlowContext
|
||||||
|
|
||||||
|
ZIGBEE_BAUDRATE = 115200
|
||||||
|
# There is no hardware bootloader trigger
|
||||||
|
BOOTLOADER_RESET_METHODS: list[ResetTarget] = []
|
||||||
|
APPLICATION_PROBE_METHODS = [
|
||||||
|
(ApplicationType.GECKO_BOOTLOADER, 115200),
|
||||||
|
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
|
||||||
|
(ApplicationType.SPINEL, 460800),
|
||||||
|
# CPC baudrates can be removed once multiprotocol is removed
|
||||||
|
(ApplicationType.CPC, 115200),
|
||||||
|
(ApplicationType.CPC, 230400),
|
||||||
|
(ApplicationType.CPC, 460800),
|
||||||
|
(ApplicationType.ROUTER, 115200),
|
||||||
|
]
|
||||||
|
|
||||||
def _get_translation_placeholders(self) -> dict[str, str]:
|
def _get_translation_placeholders(self) -> dict[str, str]:
|
||||||
"""Shared translation placeholders."""
|
"""Shared translation placeholders."""
|
||||||
placeholders = {
|
placeholders = {
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
"dependencies": ["hardware", "usb", "homeassistant_hardware"],
|
"dependencies": ["hardware", "usb", "homeassistant_hardware"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_sky_connect",
|
"documentation": "https://www.home-assistant.io/integrations/homeassistant_sky_connect",
|
||||||
"integration_type": "hardware",
|
"integration_type": "hardware",
|
||||||
|
"loggers": [
|
||||||
|
"bellows",
|
||||||
|
"universal_silabs_flasher",
|
||||||
|
"zigpy.serial",
|
||||||
|
"serial_asyncio_fast"
|
||||||
|
],
|
||||||
"usb": [
|
"usb": [
|
||||||
{
|
{
|
||||||
"description": "*skyconnect v1.0*",
|
"description": "*skyconnect v1.0*",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import HomeAssistantSkyConnectConfigEntry
|
from . import HomeAssistantSkyConnectConfigEntry
|
||||||
|
from .config_flow import SkyConnectFirmwareMixin
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
FIRMWARE,
|
FIRMWARE,
|
||||||
@@ -151,8 +152,8 @@ async def async_setup_entry(
|
|||||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||||
"""SkyConnect firmware update entity."""
|
"""SkyConnect firmware update entity."""
|
||||||
|
|
||||||
# The ZBT-1 does not have a hardware bootloader trigger
|
BOOTLOADER_RESET_METHODS = SkyConnectFirmwareMixin.BOOTLOADER_RESET_METHODS
|
||||||
bootloader_reset_methods = []
|
APPLICATION_PROBE_METHODS = SkyConnectFirmwareMixin.APPLICATION_PROBE_METHODS
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -82,7 +82,18 @@ else:
|
|||||||
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||||
"""Mixin for Home Assistant Yellow firmware methods."""
|
"""Mixin for Home Assistant Yellow firmware methods."""
|
||||||
|
|
||||||
|
ZIGBEE_BAUDRATE = 115200
|
||||||
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
|
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
|
||||||
|
APPLICATION_PROBE_METHODS = [
|
||||||
|
(ApplicationType.GECKO_BOOTLOADER, 115200),
|
||||||
|
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
|
||||||
|
(ApplicationType.SPINEL, 460800),
|
||||||
|
# CPC baudrates can be removed once multiprotocol is removed
|
||||||
|
(ApplicationType.CPC, 115200),
|
||||||
|
(ApplicationType.CPC, 230400),
|
||||||
|
(ApplicationType.CPC, 460800),
|
||||||
|
(ApplicationType.ROUTER, 115200),
|
||||||
|
]
|
||||||
|
|
||||||
async def async_step_install_zigbee_firmware(
|
async def async_step_install_zigbee_firmware(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
@@ -146,7 +157,11 @@ class HomeAssistantYellowConfigFlow(
|
|||||||
assert self._device is not None
|
assert self._device is not None
|
||||||
|
|
||||||
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
|
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
|
||||||
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
|
self._probed_firmware_info = await probe_silabs_firmware_info(
|
||||||
|
self._device,
|
||||||
|
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||||
|
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||||
|
)
|
||||||
|
|
||||||
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
|
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -7,5 +7,11 @@
|
|||||||
"dependencies": ["hardware", "homeassistant_hardware"],
|
"dependencies": ["hardware", "homeassistant_hardware"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow",
|
"documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow",
|
||||||
"integration_type": "hardware",
|
"integration_type": "hardware",
|
||||||
|
"loggers": [
|
||||||
|
"bellows",
|
||||||
|
"universal_silabs_flasher",
|
||||||
|
"zigpy.serial",
|
||||||
|
"serial_asyncio_fast"
|
||||||
|
],
|
||||||
"single_config_entry": true
|
"single_config_entry": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
|
|||||||
from homeassistant.components.homeassistant_hardware.util import (
|
from homeassistant.components.homeassistant_hardware.util import (
|
||||||
ApplicationType,
|
ApplicationType,
|
||||||
FirmwareInfo,
|
FirmwareInfo,
|
||||||
ResetTarget,
|
|
||||||
)
|
)
|
||||||
from homeassistant.components.update import UpdateDeviceClass
|
from homeassistant.components.update import UpdateDeviceClass
|
||||||
from homeassistant.const import EntityCategory
|
from homeassistant.const import EntityCategory
|
||||||
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import HomeAssistantYellowConfigEntry
|
from . import HomeAssistantYellowConfigEntry
|
||||||
|
from .config_flow import YellowFirmwareMixin
|
||||||
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE
|
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -150,7 +150,8 @@ async def async_setup_entry(
|
|||||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||||
"""Yellow firmware update entity."""
|
"""Yellow firmware update entity."""
|
||||||
|
|
||||||
bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset
|
BOOTLOADER_RESET_METHODS = YellowFirmwareMixin.BOOTLOADER_RESET_METHODS
|
||||||
|
APPLICATION_PROBE_METHODS = YellowFirmwareMixin.APPLICATION_PROBE_METHODS
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from homeassistant.const import (
|
|||||||
from homeassistant.core import Event, HomeAssistant, callback
|
from homeassistant.core import Event, HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir, storage
|
from homeassistant.helpers import config_validation as cv, issue_registry as ir, storage
|
||||||
|
from homeassistant.helpers.hassio import is_hassio
|
||||||
from homeassistant.helpers.http import (
|
from homeassistant.helpers.http import (
|
||||||
KEY_ALLOW_CONFIGURED_CORS,
|
KEY_ALLOW_CONFIGURED_CORS,
|
||||||
KEY_AUTHENTICATED, # noqa: F401
|
KEY_AUTHENTICATED, # noqa: F401
|
||||||
@@ -109,7 +110,7 @@ HTTP_SCHEMA: Final = vol.All(
|
|||||||
cv.deprecated(CONF_BASE_URL),
|
cv.deprecated(CONF_BASE_URL),
|
||||||
vol.Schema(
|
vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(CONF_SERVER_HOST, default=_DEFAULT_BIND): vol.All(
|
vol.Optional(CONF_SERVER_HOST): vol.All(
|
||||||
cv.ensure_list, vol.Length(min=1), [cv.string]
|
cv.ensure_list, vol.Length(min=1), [cv.string]
|
||||||
),
|
),
|
||||||
vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port,
|
vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port,
|
||||||
@@ -207,7 +208,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
if conf is None:
|
if conf is None:
|
||||||
conf = cast(ConfData, HTTP_SCHEMA({}))
|
conf = cast(ConfData, HTTP_SCHEMA({}))
|
||||||
|
|
||||||
server_host = conf[CONF_SERVER_HOST]
|
if CONF_SERVER_HOST in conf and is_hassio(hass):
|
||||||
|
ir.async_create_issue(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
"server_host_may_break_hassio",
|
||||||
|
is_fixable=False,
|
||||||
|
severity=ir.IssueSeverity.ERROR,
|
||||||
|
translation_key="server_host_may_break_hassio",
|
||||||
|
)
|
||||||
|
|
||||||
|
server_host = conf.get(CONF_SERVER_HOST, _DEFAULT_BIND)
|
||||||
server_port = conf[CONF_SERVER_PORT]
|
server_port = conf[CONF_SERVER_PORT]
|
||||||
ssl_certificate = conf.get(CONF_SSL_CERTIFICATE)
|
ssl_certificate = conf.get(CONF_SSL_CERTIFICATE)
|
||||||
ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE)
|
ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE)
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"issues": {
|
"issues": {
|
||||||
|
"server_host_may_break_hassio": {
|
||||||
|
"description": "The `server_host` configuration option in the HTTP integration is prone to break the communication between Home Assistant Core and Supervisor, and will be removed in a future release.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.",
|
||||||
|
"title": "The `server_host` HTTP configuration may break Home Assistant Core - Supervisor communication"
|
||||||
|
},
|
||||||
"ssl_configured_without_configured_urls": {
|
"ssl_configured_without_configured_urls": {
|
||||||
"description": "Home Assistant detected that SSL has been set up on your instance, however, no custom external internet URL has been set.\n\nThis may result in unexpected behavior. Text-to-speech may fail, and integrations may not be able to connect back to your instance correctly.\n\nTo address this issue, go to Settings > System > Network; under the \"Home Assistant URL\" section, configure your new \"Internet\" and \"Local network\" addresses that match your new SSL configuration.",
|
"description": "Home Assistant detected that SSL has been set up on your instance, however, no custom external internet URL has been set.\n\nThis may result in unexpected behavior. Text-to-speech may fail, and integrations may not be able to connect back to your instance correctly.\n\nTo address this issue, go to Settings > System > Network; under the \"Home Assistant URL\" section, configure your new \"Internet\" and \"Local network\" addresses that match your new SSL configuration.",
|
||||||
"title": "SSL is configured without an external URL or internal URL"
|
"title": "SSL is configured without an external URL or internal URL"
|
||||||
|
|||||||
@@ -121,12 +121,15 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]):
|
|||||||
"""Initialize AutomowerEntity."""
|
"""Initialize AutomowerEntity."""
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
self.mower_id = mower_id
|
self.mower_id = mower_id
|
||||||
parts = self.mower_attributes.system.model.split(maxsplit=2)
|
model_witout_manufacturer = self.mower_attributes.system.model.removeprefix(
|
||||||
|
"Husqvarna "
|
||||||
|
).removeprefix("HUSQVARNA ")
|
||||||
|
parts = model_witout_manufacturer.split(maxsplit=1)
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = DeviceInfo(
|
||||||
identifiers={(DOMAIN, mower_id)},
|
identifiers={(DOMAIN, mower_id)},
|
||||||
manufacturer=parts[0],
|
manufacturer="Husqvarna",
|
||||||
model=parts[1],
|
model=parts[0].capitalize().removesuffix("®"),
|
||||||
model_id=parts[2],
|
model_id=parts[1],
|
||||||
name=self.mower_attributes.system.name,
|
name=self.mower_attributes.system.name,
|
||||||
serial_number=self.mower_attributes.system.serial_number,
|
serial_number=self.mower_attributes.system.serial_number,
|
||||||
suggested_area="Garden",
|
suggested_area="Garden",
|
||||||
|
|||||||
@@ -9,5 +9,5 @@
|
|||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["aioautomower"],
|
"loggers": ["aioautomower"],
|
||||||
"quality_scale": "silver",
|
"quality_scale": "silver",
|
||||||
"requirements": ["aioautomower==2.7.0"]
|
"requirements": ["aioautomower==2.7.1"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ async def async_setup_entry(
|
|||||||
update_method=async_update_data,
|
update_method=async_update_data,
|
||||||
# Polling interval. Will only be polled if there are subscribers.
|
# Polling interval. Will only be polled if there are subscribers.
|
||||||
update_interval=timedelta(hours=1),
|
update_interval=timedelta(hours=1),
|
||||||
|
config_entry=entry,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fetch initial data so we have data when entities subscribe
|
# Fetch initial data so we have data when entities subscribe
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from typing import Any
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from hyperion import client
|
from hyperion import client
|
||||||
from hyperion.const import (
|
from hyperion.const import (
|
||||||
|
KEY_DATA,
|
||||||
KEY_IMAGE,
|
KEY_IMAGE,
|
||||||
KEY_IMAGE_STREAM,
|
KEY_IMAGE_STREAM,
|
||||||
KEY_LEDCOLORS,
|
KEY_LEDCOLORS,
|
||||||
@@ -155,7 +156,8 @@ class HyperionCamera(Camera):
|
|||||||
"""Update Hyperion components."""
|
"""Update Hyperion components."""
|
||||||
if not img:
|
if not img:
|
||||||
return
|
return
|
||||||
img_data = img.get(KEY_RESULT, {}).get(KEY_IMAGE)
|
# Prefer KEY_DATA (Hyperion server >= 2.1.1); fall back to KEY_RESULT for older server versions
|
||||||
|
img_data = img.get(KEY_DATA, img.get(KEY_RESULT, {})).get(KEY_IMAGE)
|
||||||
if not img_data or not img_data.startswith(IMAGE_STREAM_JPG_SENTINEL):
|
if not img_data or not img_data.startswith(IMAGE_STREAM_JPG_SENTINEL):
|
||||||
return
|
return
|
||||||
async with self._image_cond:
|
async with self._image_cond:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from pyicloud.exceptions import (
|
|||||||
PyiCloudFailedLoginException,
|
PyiCloudFailedLoginException,
|
||||||
PyiCloudNoDevicesException,
|
PyiCloudNoDevicesException,
|
||||||
PyiCloudServiceNotActivatedException,
|
PyiCloudServiceNotActivatedException,
|
||||||
|
PyiCloudServiceUnavailable,
|
||||||
)
|
)
|
||||||
from pyicloud.services.findmyiphone import AppleDevice
|
from pyicloud.services.findmyiphone import AppleDevice
|
||||||
|
|
||||||
@@ -130,15 +131,21 @@ class IcloudAccount:
|
|||||||
except (
|
except (
|
||||||
PyiCloudServiceNotActivatedException,
|
PyiCloudServiceNotActivatedException,
|
||||||
PyiCloudNoDevicesException,
|
PyiCloudNoDevicesException,
|
||||||
|
PyiCloudServiceUnavailable,
|
||||||
) as err:
|
) as err:
|
||||||
_LOGGER.error("No iCloud device found")
|
_LOGGER.error("No iCloud device found")
|
||||||
raise ConfigEntryNotReady from err
|
raise ConfigEntryNotReady from err
|
||||||
|
|
||||||
self._owner_fullname = f"{user_info['firstName']} {user_info['lastName']}"
|
if user_info is None:
|
||||||
|
raise ConfigEntryNotReady("No user info found in iCloud devices response")
|
||||||
|
|
||||||
|
self._owner_fullname = (
|
||||||
|
f"{user_info.get('firstName')} {user_info.get('lastName')}"
|
||||||
|
)
|
||||||
|
|
||||||
self._family_members_fullname = {}
|
self._family_members_fullname = {}
|
||||||
if user_info.get("membersInfo") is not None:
|
if user_info.get("membersInfo") is not None:
|
||||||
for prs_id, member in user_info["membersInfo"].items():
|
for prs_id, member in user_info.get("membersInfo").items():
|
||||||
self._family_members_fullname[prs_id] = (
|
self._family_members_fullname[prs_id] = (
|
||||||
f"{member['firstName']} {member['lastName']}"
|
f"{member['firstName']} {member['lastName']}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/icloud",
|
"documentation": "https://www.home-assistant.io/integrations/icloud",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["keyrings.alt", "pyicloud"],
|
"loggers": ["keyrings.alt", "pyicloud"],
|
||||||
"requirements": ["pyicloud==2.1.0"]
|
"requirements": ["pyicloud==2.2.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from propcache.api import cached_property
|
|
||||||
from pyituran import Vehicle
|
from pyituran import Vehicle
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import (
|
||||||
@@ -69,7 +68,7 @@ class IturanBinarySensor(IturanBaseEntity, BinarySensorEntity):
|
|||||||
super().__init__(coordinator, license_plate, description.key)
|
super().__init__(coordinator, license_plate, description.key)
|
||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
|
|
||||||
@cached_property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
"""Return true if the binary sensor is on."""
|
"""Return true if the binary sensor is on."""
|
||||||
return self.entity_description.value_fn(self.vehicle)
|
return self.entity_description.value_fn(self.vehicle)
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from propcache.api import cached_property
|
|
||||||
|
|
||||||
from homeassistant.components.device_tracker import TrackerEntity
|
from homeassistant.components.device_tracker import TrackerEntity
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
@@ -40,12 +38,12 @@ class IturanDeviceTracker(IturanBaseEntity, TrackerEntity):
|
|||||||
"""Initialize the device tracker."""
|
"""Initialize the device tracker."""
|
||||||
super().__init__(coordinator, license_plate, "device_tracker")
|
super().__init__(coordinator, license_plate, "device_tracker")
|
||||||
|
|
||||||
@cached_property
|
@property
|
||||||
def latitude(self) -> float | None:
|
def latitude(self) -> float | None:
|
||||||
"""Return latitude value of the device."""
|
"""Return latitude value of the device."""
|
||||||
return self.vehicle.gps_coordinates[0]
|
return self.vehicle.gps_coordinates[0]
|
||||||
|
|
||||||
@cached_property
|
@property
|
||||||
def longitude(self) -> float | None:
|
def longitude(self) -> float | None:
|
||||||
"""Return longitude value of the device."""
|
"""Return longitude value of the device."""
|
||||||
return self.vehicle.gps_coordinates[1]
|
return self.vehicle.gps_coordinates[1]
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from collections.abc import Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from propcache.api import cached_property
|
|
||||||
from pyituran import Vehicle
|
from pyituran import Vehicle
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
@@ -133,7 +132,7 @@ class IturanSensor(IturanBaseEntity, SensorEntity):
|
|||||||
super().__init__(coordinator, license_plate, description.key)
|
super().__init__(coordinator, license_plate, description.key)
|
||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
|
|
||||||
@cached_property
|
@property
|
||||||
def native_value(self) -> StateType | datetime:
|
def native_value(self) -> StateType | datetime:
|
||||||
"""Return the state of the device."""
|
"""Return the state of the device."""
|
||||||
return self.entity_description.value_fn(self.vehicle)
|
return self.entity_description.value_fn(self.vehicle)
|
||||||
|
|||||||
@@ -299,8 +299,8 @@ def _create_climate_ui(xknx: XKNX, conf: ConfigExtractor, name: str) -> XknxClim
|
|||||||
group_address_active_state=conf.get_state_and_passive(CONF_GA_ACTIVE),
|
group_address_active_state=conf.get_state_and_passive(CONF_GA_ACTIVE),
|
||||||
group_address_command_value_state=conf.get_state_and_passive(CONF_GA_VALVE),
|
group_address_command_value_state=conf.get_state_and_passive(CONF_GA_VALVE),
|
||||||
sync_state=sync_state,
|
sync_state=sync_state,
|
||||||
min_temp=conf.get(ClimateConf.MIN_TEMP),
|
min_temp=conf.get(CONF_TARGET_TEMPERATURE, ClimateConf.MIN_TEMP),
|
||||||
max_temp=conf.get(ClimateConf.MAX_TEMP),
|
max_temp=conf.get(CONF_TARGET_TEMPERATURE, ClimateConf.MAX_TEMP),
|
||||||
mode=climate_mode,
|
mode=climate_mode,
|
||||||
group_address_fan_speed=conf.get_write(CONF_GA_FAN_SPEED),
|
group_address_fan_speed=conf.get_write(CONF_GA_FAN_SPEED),
|
||||||
group_address_fan_speed_state=conf.get_state_and_passive(CONF_GA_FAN_SPEED),
|
group_address_fan_speed_state=conf.get_state_and_passive(CONF_GA_FAN_SPEED),
|
||||||
@@ -486,7 +486,7 @@ class _KnxClimate(ClimateEntity, _KnxEntityBase):
|
|||||||
ha_controller_modes.append(self._last_hvac_mode)
|
ha_controller_modes.append(self._last_hvac_mode)
|
||||||
ha_controller_modes.append(HVACMode.OFF)
|
ha_controller_modes.append(HVACMode.OFF)
|
||||||
|
|
||||||
hvac_modes = list(set(filter(None, ha_controller_modes)))
|
hvac_modes = sorted(set(filter(None, ha_controller_modes)))
|
||||||
return (
|
return (
|
||||||
hvac_modes
|
hvac_modes
|
||||||
if hvac_modes
|
if hvac_modes
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
"loggers": ["xknx", "xknxproject"],
|
"loggers": ["xknx", "xknxproject"],
|
||||||
"quality_scale": "silver",
|
"quality_scale": "silver",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"xknx==3.10.0",
|
"xknx==3.10.1",
|
||||||
"xknxproject==3.8.2",
|
"xknxproject==3.8.2",
|
||||||
"knx-frontend==2025.10.26.81530"
|
"knx-frontend==2025.10.31.195356"
|
||||||
],
|
],
|
||||||
"single_config_entry": true
|
"single_config_entry": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ CLIMATE_KNX_SCHEMA = vol.Schema(
|
|||||||
write=False, state_required=True, valid_dpt="9.001"
|
write=False, state_required=True, valid_dpt="9.001"
|
||||||
),
|
),
|
||||||
vol.Optional(CONF_GA_HUMIDITY_CURRENT): GASelector(
|
vol.Optional(CONF_GA_HUMIDITY_CURRENT): GASelector(
|
||||||
write=False, valid_dpt="9.002"
|
write=False, valid_dpt="9.007"
|
||||||
),
|
),
|
||||||
vol.Required(CONF_TARGET_TEMPERATURE): GroupSelect(
|
vol.Required(CONF_TARGET_TEMPERATURE): GroupSelect(
|
||||||
GroupSelectOption(
|
GroupSelectOption(
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ async def library_payload(hass):
|
|||||||
for child in library_info.children:
|
for child in library_info.children:
|
||||||
child.thumbnail = "https://brands.home-assistant.io/_/kodi/logo.png"
|
child.thumbnail = "https://brands.home-assistant.io/_/kodi/logo.png"
|
||||||
|
|
||||||
with contextlib.suppress(media_source.BrowseError):
|
with contextlib.suppress(BrowseError):
|
||||||
item = await media_source.async_browse_media(
|
item = await media_source.async_browse_media(
|
||||||
hass, None, content_filter=media_source_content_filter
|
hass, None, content_filter=media_source_content_filter
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
|
from asyncio import Task
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
@@ -44,7 +45,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
|
|||||||
|
|
||||||
_default_update_interval = SCAN_INTERVAL
|
_default_update_interval = SCAN_INTERVAL
|
||||||
config_entry: LaMarzoccoConfigEntry
|
config_entry: LaMarzoccoConfigEntry
|
||||||
websocket_terminated = True
|
_websocket_task: Task | None = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -64,6 +65,13 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
|
|||||||
self.device = device
|
self.device = device
|
||||||
self.cloud_client = cloud_client
|
self.cloud_client = cloud_client
|
||||||
|
|
||||||
|
@property
|
||||||
|
def websocket_terminated(self) -> bool:
|
||||||
|
"""Return True if the websocket task is terminated or not running."""
|
||||||
|
if self._websocket_task is None:
|
||||||
|
return True
|
||||||
|
return self._websocket_task.done()
|
||||||
|
|
||||||
async def _async_update_data(self) -> None:
|
async def _async_update_data(self) -> None:
|
||||||
"""Do the data update."""
|
"""Do the data update."""
|
||||||
try:
|
try:
|
||||||
@@ -95,13 +103,14 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
|||||||
# ensure token stays valid; does nothing if token is still valid
|
# ensure token stays valid; does nothing if token is still valid
|
||||||
await self.cloud_client.async_get_access_token()
|
await self.cloud_client.async_get_access_token()
|
||||||
|
|
||||||
if self.device.websocket.connected:
|
# Only skip websocket reconnection if it's currently connected and the task is still running
|
||||||
|
if self.device.websocket.connected and not self.websocket_terminated:
|
||||||
return
|
return
|
||||||
|
|
||||||
await self.device.get_dashboard()
|
await self.device.get_dashboard()
|
||||||
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
|
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
|
||||||
|
|
||||||
self.config_entry.async_create_background_task(
|
self._websocket_task = self.config_entry.async_create_background_task(
|
||||||
hass=self.hass,
|
hass=self.hass,
|
||||||
target=self.connect_websocket(),
|
target=self.connect_websocket(),
|
||||||
name="lm_websocket_task",
|
name="lm_websocket_task",
|
||||||
@@ -120,7 +129,6 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
|||||||
|
|
||||||
_LOGGER.debug("Init WebSocket in background task")
|
_LOGGER.debug("Init WebSocket in background task")
|
||||||
|
|
||||||
self.websocket_terminated = False
|
|
||||||
self.async_update_listeners()
|
self.async_update_listeners()
|
||||||
|
|
||||||
await self.device.connect_dashboard_websocket(
|
await self.device.connect_dashboard_websocket(
|
||||||
@@ -129,7 +137,6 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
|||||||
disconnect_callback=self.async_update_listeners,
|
disconnect_callback=self.async_update_listeners,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.websocket_terminated = True
|
|
||||||
self.async_update_listeners()
|
self.async_update_listeners()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
|
|||||||
await self.coordinator.device.update_firmware()
|
await self.coordinator.device.update_firmware()
|
||||||
while (
|
while (
|
||||||
update_progress := await self.coordinator.device.get_firmware()
|
update_progress := await self.coordinator.device.get_firmware()
|
||||||
).command_status is UpdateStatus.IN_PROGRESS:
|
).command_status is not UpdateStatus.UPDATED:
|
||||||
if counter >= MAX_UPDATE_WAIT:
|
if counter >= MAX_UPDATE_WAIT:
|
||||||
_raise_timeout_error()
|
_raise_timeout_error()
|
||||||
self._attr_update_percentage = update_progress.progress_percentage
|
self._attr_update_percentage = update_progress.progress_percentage
|
||||||
|
|||||||
@@ -622,6 +622,7 @@ ENERGY_USAGE_SENSORS: tuple[ThinQEnergySensorEntityDescription, ...] = (
|
|||||||
usage_period=USAGE_MONTHLY,
|
usage_period=USAGE_MONTHLY,
|
||||||
start_date_fn=lambda today: today,
|
start_date_fn=lambda today: today,
|
||||||
end_date_fn=lambda today: today,
|
end_date_fn=lambda today: today,
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
),
|
),
|
||||||
ThinQEnergySensorEntityDescription(
|
ThinQEnergySensorEntityDescription(
|
||||||
key="last_month",
|
key="last_month",
|
||||||
|
|||||||
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/libre_hardware_monitor",
|
"documentation": "https://www.home-assistant.io/integrations/libre_hardware_monitor",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"quality_scale": "silver",
|
"quality_scale": "silver",
|
||||||
"requirements": ["librehardwaremonitor-api==1.4.0"]
|
"requirements": ["librehardwaremonitor-api==1.5.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from librehardwaremonitor_api.model import LibreHardwareMonitorSensorData
|
from librehardwaremonitor_api.model import LibreHardwareMonitorSensorData
|
||||||
|
|
||||||
from homeassistant.components.sensor import SensorEntity, SensorStateClass
|
from homeassistant.components.sensor import SensorEntity, SensorStateClass
|
||||||
@@ -51,10 +53,10 @@ class LibreHardwareMonitorSensor(
|
|||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
|
|
||||||
self._attr_name: str = sensor_data.name
|
self._attr_name: str = sensor_data.name
|
||||||
self.value: str | None = sensor_data.value
|
self._attr_native_value: str | None = sensor_data.value
|
||||||
self._attr_extra_state_attributes: dict[str, str] = {
|
self._attr_extra_state_attributes: dict[str, Any] = {
|
||||||
STATE_MIN_VALUE: self._format_number_value(sensor_data.min),
|
STATE_MIN_VALUE: sensor_data.min,
|
||||||
STATE_MAX_VALUE: self._format_number_value(sensor_data.max),
|
STATE_MAX_VALUE: sensor_data.max,
|
||||||
}
|
}
|
||||||
self._attr_native_unit_of_measurement = sensor_data.unit
|
self._attr_native_unit_of_measurement = sensor_data.unit
|
||||||
self._attr_unique_id: str = f"{entry_id}_{sensor_data.sensor_id}"
|
self._attr_unique_id: str = f"{entry_id}_{sensor_data.sensor_id}"
|
||||||
@@ -72,23 +74,12 @@ class LibreHardwareMonitorSensor(
|
|||||||
def _handle_coordinator_update(self) -> None:
|
def _handle_coordinator_update(self) -> None:
|
||||||
"""Handle updated data from the coordinator."""
|
"""Handle updated data from the coordinator."""
|
||||||
if sensor_data := self.coordinator.data.sensor_data.get(self._sensor_id):
|
if sensor_data := self.coordinator.data.sensor_data.get(self._sensor_id):
|
||||||
self.value = sensor_data.value
|
self._attr_native_value = sensor_data.value
|
||||||
self._attr_extra_state_attributes = {
|
self._attr_extra_state_attributes = {
|
||||||
STATE_MIN_VALUE: self._format_number_value(sensor_data.min),
|
STATE_MIN_VALUE: sensor_data.min,
|
||||||
STATE_MAX_VALUE: self._format_number_value(sensor_data.max),
|
STATE_MAX_VALUE: sensor_data.max,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
self.value = None
|
self._attr_native_value = None
|
||||||
|
|
||||||
super()._handle_coordinator_update()
|
super()._handle_coordinator_update()
|
||||||
|
|
||||||
@property
|
|
||||||
def native_value(self) -> str | None:
|
|
||||||
"""Return the formatted sensor value or None if no value is available."""
|
|
||||||
if self.value is not None and self.value != "-":
|
|
||||||
return self._format_number_value(self.value)
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _format_number_value(number_str: str) -> str:
|
|
||||||
return number_str.replace(",", ".")
|
|
||||||
|
|||||||
@@ -13,5 +13,5 @@
|
|||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["pylitterbot"],
|
"loggers": ["pylitterbot"],
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["pylitterbot==2024.2.7"]
|
"requirements": ["pylitterbot==2025.0.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherE
|
|||||||
forecasts_by_date[date].append(timestamp)
|
forecasts_by_date[date].append(timestamp)
|
||||||
|
|
||||||
daily_forecasts = []
|
daily_forecasts = []
|
||||||
for date in sorted(forecasts_by_date.keys())[:5]:
|
for date in sorted(forecasts_by_date.keys()):
|
||||||
day_forecasts = forecasts_by_date[date]
|
day_forecasts = forecasts_by_date[date]
|
||||||
if not day_forecasts:
|
if not day_forecasts:
|
||||||
continue
|
continue
|
||||||
@@ -186,5 +186,5 @@ class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherE
|
|||||||
return None
|
return None
|
||||||
return [
|
return [
|
||||||
self._convert_forecast_data(forecast_data)
|
self._convert_forecast_data(forecast_data)
|
||||||
for forecast_data in self.coordinator.data.forecast_timestamps[:24]
|
for forecast_data in self.coordinator.data.forecast_timestamps
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
|||||||
import logging
|
import logging
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
import aiohttp
|
from aiohttp import ClientResponseError
|
||||||
|
|
||||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@@ -153,11 +153,12 @@ class MieleButton(MieleEntity, ButtonEntity):
|
|||||||
self._device_id,
|
self._device_id,
|
||||||
{PROCESS_ACTION: self.entity_description.press_data},
|
{PROCESS_ACTION: self.entity_description.press_data},
|
||||||
)
|
)
|
||||||
except aiohttp.ClientResponseError as ex:
|
except ClientResponseError as err:
|
||||||
|
_LOGGER.debug("Error setting button state for %s: %s", self.entity_id, err)
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
translation_key="set_state_error",
|
translation_key="set_state_error",
|
||||||
translation_placeholders={
|
translation_placeholders={
|
||||||
"entity": self.entity_id,
|
"entity": self.entity_id,
|
||||||
},
|
},
|
||||||
) from ex
|
) from err
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Final, cast
|
from typing import Any, Final, cast
|
||||||
|
|
||||||
import aiohttp
|
from aiohttp import ClientResponseError
|
||||||
from pymiele import MieleDevice, MieleTemperature
|
from pymiele import MieleDevice, MieleTemperature
|
||||||
|
|
||||||
from homeassistant.components.climate import (
|
from homeassistant.components.climate import (
|
||||||
@@ -250,7 +250,8 @@ class MieleClimate(MieleEntity, ClimateEntity):
|
|||||||
cast(float, kwargs.get(ATTR_TEMPERATURE)),
|
cast(float, kwargs.get(ATTR_TEMPERATURE)),
|
||||||
self.entity_description.zone,
|
self.entity_description.zone,
|
||||||
)
|
)
|
||||||
except aiohttp.ClientError as err:
|
except ClientResponseError as err:
|
||||||
|
_LOGGER.debug("Error setting climate state for %s: %s", self.entity_id, err)
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
translation_key="set_state_error",
|
translation_key="set_state_error",
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]):
|
|||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Error fetching actions for device %s: Status: %s, Message: %s",
|
"Error fetching actions for device %s: Status: %s, Message: %s",
|
||||||
device_id,
|
device_id,
|
||||||
err.status,
|
str(err.status),
|
||||||
err.message,
|
err.message,
|
||||||
)
|
)
|
||||||
actions_json = {}
|
actions_json = {}
|
||||||
|
|||||||
@@ -142,14 +142,15 @@ class MieleFan(MieleEntity, FanEntity):
|
|||||||
await self.api.send_action(
|
await self.api.send_action(
|
||||||
self._device_id, {VENTILATION_STEP: ventilation_step}
|
self._device_id, {VENTILATION_STEP: ventilation_step}
|
||||||
)
|
)
|
||||||
except ClientResponseError as ex:
|
except ClientResponseError as err:
|
||||||
|
_LOGGER.debug("Error setting fan state for %s: %s", self.entity_id, err)
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
translation_key="set_state_error",
|
translation_key="set_state_error",
|
||||||
translation_placeholders={
|
translation_placeholders={
|
||||||
"entity": self.entity_id,
|
"entity": self.entity_id,
|
||||||
},
|
},
|
||||||
) from ex
|
) from err
|
||||||
self.device.state_ventilation_step = ventilation_step
|
self.device.state_ventilation_step = ventilation_step
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@@ -171,6 +172,7 @@ class MieleFan(MieleEntity, FanEntity):
|
|||||||
translation_key="set_state_error",
|
translation_key="set_state_error",
|
||||||
translation_placeholders={
|
translation_placeholders={
|
||||||
"entity": self.entity_id,
|
"entity": self.entity_id,
|
||||||
|
"err_status": str(ex.status),
|
||||||
},
|
},
|
||||||
) from ex
|
) from ex
|
||||||
|
|
||||||
@@ -188,6 +190,7 @@ class MieleFan(MieleEntity, FanEntity):
|
|||||||
translation_key="set_state_error",
|
translation_key="set_state_error",
|
||||||
translation_placeholders={
|
translation_placeholders={
|
||||||
"entity": self.entity_id,
|
"entity": self.entity_id,
|
||||||
|
"err_status": str(ex.status),
|
||||||
},
|
},
|
||||||
) from ex
|
) from ex
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Final
|
from typing import Any, Final
|
||||||
|
|
||||||
import aiohttp
|
from aiohttp import ClientResponseError
|
||||||
|
|
||||||
from homeassistant.components.light import (
|
from homeassistant.components.light import (
|
||||||
ColorMode,
|
ColorMode,
|
||||||
@@ -131,7 +131,8 @@ class MieleLight(MieleEntity, LightEntity):
|
|||||||
await self.api.send_action(
|
await self.api.send_action(
|
||||||
self._device_id, {self.entity_description.light_type: mode}
|
self._device_id, {self.entity_description.light_type: mode}
|
||||||
)
|
)
|
||||||
except aiohttp.ClientError as err:
|
except ClientResponseError as err:
|
||||||
|
_LOGGER.debug("Error setting light state for %s: %s", self.entity_id, err)
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
translation_key="set_state_error",
|
translation_key="set_state_error",
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from homeassistant.components.sensor import (
|
|||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
PERCENTAGE,
|
PERCENTAGE,
|
||||||
REVOLUTIONS_PER_MINUTE,
|
REVOLUTIONS_PER_MINUTE,
|
||||||
STATE_UNKNOWN,
|
|
||||||
EntityCategory,
|
EntityCategory,
|
||||||
UnitOfEnergy,
|
UnitOfEnergy,
|
||||||
UnitOfTemperature,
|
UnitOfTemperature,
|
||||||
@@ -762,40 +761,35 @@ class MieleSensor(MieleEntity, SensorEntity):
|
|||||||
class MieleRestorableSensor(MieleSensor, RestoreSensor):
|
class MieleRestorableSensor(MieleSensor, RestoreSensor):
|
||||||
"""Representation of a Sensor whose internal state can be restored."""
|
"""Representation of a Sensor whose internal state can be restored."""
|
||||||
|
|
||||||
_last_value: StateType
|
_attr_native_value: StateType
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
coordinator: MieleDataUpdateCoordinator,
|
|
||||||
device_id: str,
|
|
||||||
description: MieleSensorDescription,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the sensor."""
|
|
||||||
super().__init__(coordinator, device_id, description)
|
|
||||||
self._last_value = None
|
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""When entity is added to hass."""
|
"""When entity is added to hass."""
|
||||||
await super().async_added_to_hass()
|
await super().async_added_to_hass()
|
||||||
|
|
||||||
# recover last value from cache when adding entity
|
# recover last value from cache when adding entity
|
||||||
last_value = await self.async_get_last_state()
|
last_data = await self.async_get_last_sensor_data()
|
||||||
if last_value and last_value.state != STATE_UNKNOWN:
|
if last_data:
|
||||||
self._last_value = last_value.state
|
self._attr_native_value = last_data.native_value # type: ignore[assignment]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> StateType:
|
def native_value(self) -> StateType:
|
||||||
"""Return the state of the sensor."""
|
"""Return the state of the sensor.
|
||||||
return self._last_value
|
|
||||||
|
|
||||||
def _update_last_value(self) -> None:
|
It is necessary to override `native_value` to fall back to the default
|
||||||
"""Update the last value of the sensor."""
|
attribute-based implementation, instead of the function-based
|
||||||
self._last_value = self.entity_description.value_fn(self.device)
|
implementation in `MieleSensor`.
|
||||||
|
"""
|
||||||
|
return self._attr_native_value
|
||||||
|
|
||||||
|
def _update_native_value(self) -> None:
|
||||||
|
"""Update the native value attribute of the sensor."""
|
||||||
|
self._attr_native_value = self.entity_description.value_fn(self.device)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _handle_coordinator_update(self) -> None:
|
def _handle_coordinator_update(self) -> None:
|
||||||
"""Handle updated data from the coordinator."""
|
"""Handle updated data from the coordinator."""
|
||||||
self._update_last_value()
|
self._update_native_value()
|
||||||
super()._handle_coordinator_update()
|
super()._handle_coordinator_update()
|
||||||
|
|
||||||
|
|
||||||
@@ -912,7 +906,7 @@ class MieleProgramIdSensor(MieleSensor):
|
|||||||
class MieleTimeSensor(MieleRestorableSensor):
|
class MieleTimeSensor(MieleRestorableSensor):
|
||||||
"""Representation of time sensors keeping state from cache."""
|
"""Representation of time sensors keeping state from cache."""
|
||||||
|
|
||||||
def _update_last_value(self) -> None:
|
def _update_native_value(self) -> None:
|
||||||
"""Update the last value of the sensor."""
|
"""Update the last value of the sensor."""
|
||||||
|
|
||||||
current_value = self.entity_description.value_fn(self.device)
|
current_value = self.entity_description.value_fn(self.device)
|
||||||
@@ -923,7 +917,9 @@ class MieleTimeSensor(MieleRestorableSensor):
|
|||||||
current_status == StateStatus.PROGRAM_ENDED
|
current_status == StateStatus.PROGRAM_ENDED
|
||||||
and self.entity_description.end_value_fn is not None
|
and self.entity_description.end_value_fn is not None
|
||||||
):
|
):
|
||||||
self._last_value = self.entity_description.end_value_fn(self._last_value)
|
self._attr_native_value = self.entity_description.end_value_fn(
|
||||||
|
self._attr_native_value
|
||||||
|
)
|
||||||
|
|
||||||
# keep value when program ends if no function is specified
|
# keep value when program ends if no function is specified
|
||||||
elif current_status == StateStatus.PROGRAM_ENDED:
|
elif current_status == StateStatus.PROGRAM_ENDED:
|
||||||
@@ -931,11 +927,11 @@ class MieleTimeSensor(MieleRestorableSensor):
|
|||||||
|
|
||||||
# force unknown when appliance is not working (some devices are keeping last value until a new cycle starts)
|
# force unknown when appliance is not working (some devices are keeping last value until a new cycle starts)
|
||||||
elif current_status in (StateStatus.OFF, StateStatus.ON, StateStatus.IDLE):
|
elif current_status in (StateStatus.OFF, StateStatus.ON, StateStatus.IDLE):
|
||||||
self._last_value = None
|
self._attr_native_value = None
|
||||||
|
|
||||||
# otherwise, cache value and return it
|
# otherwise, cache value and return it
|
||||||
else:
|
else:
|
||||||
self._last_value = current_value
|
self._attr_native_value = current_value
|
||||||
|
|
||||||
|
|
||||||
class MieleConsumptionSensor(MieleRestorableSensor):
|
class MieleConsumptionSensor(MieleRestorableSensor):
|
||||||
@@ -943,17 +939,23 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
|||||||
|
|
||||||
_is_reporting: bool = False
|
_is_reporting: bool = False
|
||||||
|
|
||||||
def _update_last_value(self) -> None:
|
def _update_native_value(self) -> None:
|
||||||
"""Update the last value of the sensor."""
|
"""Update the last value of the sensor."""
|
||||||
current_value = self.entity_description.value_fn(self.device)
|
current_value = self.entity_description.value_fn(self.device)
|
||||||
current_status = StateStatus(self.device.state_status)
|
current_status = StateStatus(self.device.state_status)
|
||||||
|
# Guard for corrupt restored value
|
||||||
|
restored_value = (
|
||||||
|
self._attr_native_value
|
||||||
|
if isinstance(self._attr_native_value, (int, float))
|
||||||
|
else 0
|
||||||
|
)
|
||||||
last_value = (
|
last_value = (
|
||||||
float(cast(str, self._last_value))
|
float(cast(str, restored_value))
|
||||||
if self._last_value is not None and self._last_value != STATE_UNKNOWN
|
if self._attr_native_value is not None
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
|
|
||||||
# force unknown when appliance is not able to report consumption
|
# Force unknown when appliance is not able to report consumption
|
||||||
if current_status in (
|
if current_status in (
|
||||||
StateStatus.ON,
|
StateStatus.ON,
|
||||||
StateStatus.OFF,
|
StateStatus.OFF,
|
||||||
@@ -963,7 +965,7 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
|||||||
StateStatus.SERVICE,
|
StateStatus.SERVICE,
|
||||||
):
|
):
|
||||||
self._is_reporting = False
|
self._is_reporting = False
|
||||||
self._last_value = None
|
self._attr_native_value = None
|
||||||
|
|
||||||
# appliance might report the last value for consumption of previous cycle and it will report 0
|
# appliance might report the last value for consumption of previous cycle and it will report 0
|
||||||
# only after a while, so it is necessary to force 0 until we see the 0 value coming from API, unless
|
# only after a while, so it is necessary to force 0 until we see the 0 value coming from API, unless
|
||||||
@@ -973,7 +975,7 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
|||||||
and not self._is_reporting
|
and not self._is_reporting
|
||||||
and last_value > 0
|
and last_value > 0
|
||||||
):
|
):
|
||||||
self._last_value = current_value
|
self._attr_native_value = current_value
|
||||||
self._is_reporting = True
|
self._is_reporting = True
|
||||||
|
|
||||||
elif (
|
elif (
|
||||||
@@ -982,12 +984,12 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
|||||||
and current_value is not None
|
and current_value is not None
|
||||||
and cast(int, current_value) > 0
|
and cast(int, current_value) > 0
|
||||||
):
|
):
|
||||||
self._last_value = 0
|
self._attr_native_value = 0
|
||||||
|
|
||||||
# keep value when program ends
|
# keep value when program ends
|
||||||
elif current_status == StateStatus.PROGRAM_ENDED:
|
elif current_status == StateStatus.PROGRAM_ENDED:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self._last_value = current_value
|
self._attr_native_value = current_value
|
||||||
self._is_reporting = True
|
self._is_reporting = True
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import timedelta
|
|||||||
import logging
|
import logging
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
import aiohttp
|
from aiohttp import ClientResponseError
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_TEMPERATURE
|
from homeassistant.const import ATTR_DEVICE_ID, ATTR_TEMPERATURE
|
||||||
@@ -107,7 +107,7 @@ async def set_program(call: ServiceCall) -> None:
|
|||||||
data = {"programId": call.data[ATTR_PROGRAM_ID]}
|
data = {"programId": call.data[ATTR_PROGRAM_ID]}
|
||||||
try:
|
try:
|
||||||
await api.set_program(serial_number, data)
|
await api.set_program(serial_number, data)
|
||||||
except aiohttp.ClientResponseError as ex:
|
except ClientResponseError as ex:
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
translation_key="set_program_error",
|
translation_key="set_program_error",
|
||||||
@@ -137,7 +137,7 @@ async def set_program_oven(call: ServiceCall) -> None:
|
|||||||
data["temperature"] = call.data[ATTR_TEMPERATURE]
|
data["temperature"] = call.data[ATTR_TEMPERATURE]
|
||||||
try:
|
try:
|
||||||
await api.set_program(serial_number, data)
|
await api.set_program(serial_number, data)
|
||||||
except aiohttp.ClientResponseError as ex:
|
except ClientResponseError as ex:
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
translation_key="set_program_oven_error",
|
translation_key="set_program_oven_error",
|
||||||
@@ -157,7 +157,7 @@ async def get_programs(call: ServiceCall) -> ServiceResponse:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
programs = await api.get_programs(serial_number)
|
programs = await api.get_programs(serial_number)
|
||||||
except aiohttp.ClientResponseError as ex:
|
except ClientResponseError as ex:
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
translation_key="get_programs_error",
|
translation_key="get_programs_error",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user