mirror of
https://github.com/home-assistant/core.git
synced 2025-11-22 09:17:02 +00:00
Compare commits
90 Commits
mqtt-entit
...
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 | ||
|
|
f233f2da3f |
2
Dockerfile
generated
2
Dockerfile
generated
@@ -25,7 +25,7 @@ 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
|
||||||
|
|||||||
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.6"]
|
"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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,8 +74,11 @@ 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"
|
||||||
|
|
||||||
self._attr_min_temp = data.static.min_temp.value
|
# Set temperature range if available, otherwise use Home Assistant defaults
|
||||||
self._attr_max_temp = data.static.max_temp.value
|
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
|
||||||
|
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_temperature_unit = data.fast_coordinator.client.get_temperature_unit
|
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -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,36 +168,55 @@ 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:
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="reconfigure", data_schema=STEP_RECONFIGURE
|
|
||||||
)
|
|
||||||
|
|
||||||
updated_host = user_input[CONF_HOST]
|
|
||||||
|
|
||||||
self._async_abort_entries_match({CONF_HOST: updated_host})
|
|
||||||
|
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
try:
|
if user_input is not None:
|
||||||
await validate_input(self.hass, user_input)
|
updated_host = user_input[CONF_HOST]
|
||||||
except CannotConnect:
|
|
||||||
errors["base"] = "cannot_connect"
|
self._async_abort_entries_match({CONF_HOST: updated_host})
|
||||||
except InvalidAuth:
|
|
||||||
errors["base"] = "invalid_auth"
|
try:
|
||||||
except InvalidPin:
|
data_to_validate = {
|
||||||
errors["base"] = "invalid_pin"
|
CONF_HOST: updated_host,
|
||||||
except Exception: # noqa: BLE001
|
CONF_PORT: user_input[CONF_PORT],
|
||||||
_LOGGER.exception("Unexpected exception")
|
CONF_PIN: user_input[CONF_PIN],
|
||||||
errors["base"] = "unknown"
|
CONF_TYPE: reconfigure_entry.data.get(CONF_TYPE, BRIDGE),
|
||||||
else:
|
}
|
||||||
return self.async_update_reload_and_abort(
|
await validate_input(self.hass, data_to_validate)
|
||||||
reconfigure_entry, data_updates={CONF_HOST: updated_host}
|
except CannotConnect:
|
||||||
)
|
errors["base"] = "cannot_connect"
|
||||||
|
except InvalidAuth:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
except InvalidPin:
|
||||||
|
errors["base"] = "invalid_pin"
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_LOGGER.exception("Unexpected exception")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
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(
|
||||||
|
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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
reserve3_int = int(reserve3) if reserve3 is not None else 0
|
model = product_info.get("model")
|
||||||
supports_wdr_adjustment_val = bool(int(reserve3_int & 256))
|
model_int = int(model) if model is not None else 7002
|
||||||
supports_hdr_adjustment_val = bool(int(reserve3_int & 128))
|
if model_int > 7001:
|
||||||
if supports_wdr_adjustment_val:
|
reserve3_int = int(reserve3) if reserve3 is not None else 0
|
||||||
ret_wdr, is_open_wdr_data = self.session.getWdrMode()
|
supports_wdr_adjustment_val = bool(int(reserve3_int & 256))
|
||||||
mode = is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0
|
supports_hdr_adjustment_val = bool(int(reserve3_int & 128))
|
||||||
is_open_wdr = bool(int(mode))
|
if supports_wdr_adjustment_val:
|
||||||
elif supports_hdr_adjustment_val:
|
ret_wdr, is_open_wdr_data = self.session.getWdrMode()
|
||||||
ret_hdr, is_open_hdr_data = self.session.getHdrMode()
|
mode = (
|
||||||
mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0
|
is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0
|
||||||
is_open_hdr = bool(int(mode))
|
)
|
||||||
|
is_open_wdr = bool(int(mode))
|
||||||
|
elif supports_hdr_adjustment_val:
|
||||||
|
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
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
is_human_detection_on_val = (
|
if human_adjustment_val:
|
||||||
mothion_config_val["humanEnable"] == "1" if ret_md == 0 else False
|
is_human_detection_on_val = (
|
||||||
)
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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==20251105.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"
|
||||||
|
|||||||
@@ -76,9 +76,18 @@ 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
|
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
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -511,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)
|
||||||
|
|
||||||
@@ -538,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:
|
||||||
@@ -562,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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"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.31.195356"
|
"knx-frontend==2025.10.31.195356"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -943,13 +943,19 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
|||||||
"""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._attr_native_value))
|
float(cast(str, restored_value))
|
||||||
if self._attr_native_value is not None
|
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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
from pymiele import MieleDevice
|
||||||
|
|
||||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||||
@@ -165,7 +165,8 @@ class MieleSwitch(MieleEntity, SwitchEntity):
|
|||||||
"""Set switch to mode."""
|
"""Set switch to mode."""
|
||||||
try:
|
try:
|
||||||
await self.api.send_action(self._device_id, mode)
|
await self.api.send_action(self._device_id, mode)
|
||||||
except aiohttp.ClientError as err:
|
except ClientResponseError as err:
|
||||||
|
_LOGGER.debug("Error setting switch 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",
|
||||||
@@ -197,7 +198,8 @@ class MielePowerSwitch(MieleSwitch):
|
|||||||
"""Set switch to mode."""
|
"""Set switch to mode."""
|
||||||
try:
|
try:
|
||||||
await self.api.send_action(self._device_id, mode)
|
await self.api.send_action(self._device_id, mode)
|
||||||
except aiohttp.ClientError as err:
|
except ClientResponseError as err:
|
||||||
|
_LOGGER.debug("Error setting switch 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",
|
||||||
|
|||||||
@@ -189,14 +189,15 @@ class MieleVacuum(MieleEntity, StateVacuumEntity):
|
|||||||
"""Send action to the device."""
|
"""Send action to the device."""
|
||||||
try:
|
try:
|
||||||
await self.api.send_action(device_id, action)
|
await self.api.send_action(device_id, action)
|
||||||
except ClientResponseError as ex:
|
except ClientResponseError as err:
|
||||||
|
_LOGGER.debug("Error setting vacuum 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
|
||||||
|
|
||||||
async def async_clean_spot(self, **kwargs: Any) -> None:
|
async def async_clean_spot(self, **kwargs: Any) -> None:
|
||||||
"""Clean spot."""
|
"""Clean spot."""
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/mill",
|
"documentation": "https://www.home-assistant.io/integrations/mill",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["mill", "mill_local"],
|
"loggers": ["mill", "mill_local"],
|
||||||
"requirements": ["millheater==0.14.0", "mill-local==0.3.0"]
|
"requirements": ["millheater==0.14.1", "mill-local==0.3.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,12 +128,41 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
registration_name = f"Mobile App: {registration[ATTR_DEVICE_NAME]}"
|
registration_name = f"Mobile App: {registration[ATTR_DEVICE_NAME]}"
|
||||||
webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook)
|
webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook)
|
||||||
|
|
||||||
|
def clean_cloudhook() -> None:
|
||||||
|
"""Clean up cloudhook from config entry."""
|
||||||
|
if CONF_CLOUDHOOK_URL in entry.data:
|
||||||
|
data = dict(entry.data)
|
||||||
|
data.pop(CONF_CLOUDHOOK_URL)
|
||||||
|
hass.config_entries.async_update_entry(entry, data=data)
|
||||||
|
|
||||||
|
def on_cloudhook_change(cloudhook: dict[str, Any] | None) -> None:
|
||||||
|
"""Handle cloudhook changes."""
|
||||||
|
if cloudhook:
|
||||||
|
if entry.data.get(CONF_CLOUDHOOK_URL) == cloudhook[CONF_CLOUDHOOK_URL]:
|
||||||
|
return
|
||||||
|
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
entry,
|
||||||
|
data={**entry.data, CONF_CLOUDHOOK_URL: cloudhook[CONF_CLOUDHOOK_URL]},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
clean_cloudhook()
|
||||||
|
|
||||||
async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
|
async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
|
||||||
if (
|
if (
|
||||||
state is cloud.CloudConnectionState.CLOUD_CONNECTED
|
state is cloud.CloudConnectionState.CLOUD_CONNECTED
|
||||||
and CONF_CLOUDHOOK_URL not in entry.data
|
and CONF_CLOUDHOOK_URL not in entry.data
|
||||||
):
|
):
|
||||||
await async_create_cloud_hook(hass, webhook_id, entry)
|
await async_create_cloud_hook(hass, webhook_id, entry)
|
||||||
|
elif (
|
||||||
|
state is cloud.CloudConnectionState.CLOUD_DISCONNECTED
|
||||||
|
and not cloud.async_is_logged_in(hass)
|
||||||
|
):
|
||||||
|
clean_cloudhook()
|
||||||
|
|
||||||
|
entry.async_on_unload(
|
||||||
|
cloud.async_listen_cloudhook_change(hass, webhook_id, on_cloudhook_change)
|
||||||
|
)
|
||||||
|
|
||||||
if cloud.async_is_logged_in(hass):
|
if cloud.async_is_logged_in(hass):
|
||||||
if (
|
if (
|
||||||
@@ -144,9 +173,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
await async_create_cloud_hook(hass, webhook_id, entry)
|
await async_create_cloud_hook(hass, webhook_id, entry)
|
||||||
elif CONF_CLOUDHOOK_URL in entry.data:
|
elif CONF_CLOUDHOOK_URL in entry.data:
|
||||||
# If we have a cloudhook but no longer logged in to the cloud, remove it from the entry
|
# If we have a cloudhook but no longer logged in to the cloud, remove it from the entry
|
||||||
data = dict(entry.data)
|
clean_cloudhook()
|
||||||
data.pop(CONF_CLOUDHOOK_URL)
|
|
||||||
hass.config_entries.async_update_entry(entry, data=data)
|
|
||||||
|
|
||||||
entry.async_on_unload(cloud.async_listen_connection_change(hass, manage_cloudhook))
|
entry.async_on_unload(cloud.async_listen_connection_change(hass, manage_cloudhook))
|
||||||
|
|
||||||
|
|||||||
@@ -61,10 +61,12 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
async_add_entities([MobileAppBinarySensor(data, config_entry)])
|
async_add_entities([MobileAppBinarySensor(data, config_entry)])
|
||||||
|
|
||||||
async_dispatcher_connect(
|
config_entry.async_on_unload(
|
||||||
hass,
|
async_dispatcher_connect(
|
||||||
f"{DOMAIN}_{ENTITY_TYPE}_register",
|
hass,
|
||||||
handle_sensor_registration,
|
f"{DOMAIN}_{ENTITY_TYPE}_register",
|
||||||
|
handle_sensor_registration,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -72,10 +72,12 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
async_add_entities([MobileAppSensor(data, config_entry)])
|
async_add_entities([MobileAppSensor(data, config_entry)])
|
||||||
|
|
||||||
async_dispatcher_connect(
|
config_entry.async_on_unload(
|
||||||
hass,
|
async_dispatcher_connect(
|
||||||
f"{DOMAIN}_{ENTITY_TYPE}_register",
|
hass,
|
||||||
handle_sensor_registration,
|
f"{DOMAIN}_{ENTITY_TYPE}_register",
|
||||||
|
handle_sensor_registration,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -738,10 +738,9 @@ async def webhook_get_config(
|
|||||||
"theme_color": MANIFEST_JSON["theme_color"],
|
"theme_color": MANIFEST_JSON["theme_color"],
|
||||||
}
|
}
|
||||||
|
|
||||||
if CONF_CLOUDHOOK_URL in config_entry.data:
|
|
||||||
resp[CONF_CLOUDHOOK_URL] = config_entry.data[CONF_CLOUDHOOK_URL]
|
|
||||||
|
|
||||||
if cloud.async_active_subscription(hass):
|
if cloud.async_active_subscription(hass):
|
||||||
|
if CONF_CLOUDHOOK_URL in config_entry.data:
|
||||||
|
resp[CONF_CLOUDHOOK_URL] = config_entry.data[CONF_CLOUDHOOK_URL]
|
||||||
with suppress(cloud.CloudNotAvailable):
|
with suppress(cloud.CloudNotAvailable):
|
||||||
resp[CONF_REMOTE_UI_URL] = cloud.async_remote_ui_url(hass)
|
resp[CONF_REMOTE_UI_URL] = cloud.async_remote_ui_url(hass)
|
||||||
|
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ class MotionBaseDevice(MotionCoordinatorEntity, CoverEntity):
|
|||||||
angle = kwargs.get(ATTR_TILT_POSITION)
|
angle = kwargs.get(ATTR_TILT_POSITION)
|
||||||
if angle is not None:
|
if angle is not None:
|
||||||
angle = angle * 180 / 100
|
angle = angle * 180 / 100
|
||||||
|
angle = 180 - angle
|
||||||
async with self._api_lock:
|
async with self._api_lock:
|
||||||
await self.hass.async_add_executor_job(
|
await self.hass.async_add_executor_job(
|
||||||
self._blind.Set_position,
|
self._blind.Set_position,
|
||||||
|
|||||||
@@ -4237,7 +4237,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="entity",
|
step_id="entity",
|
||||||
data_schema=data_schema,
|
data_schema=data_schema,
|
||||||
description_placeholders={
|
description_placeholders=TRANSLATION_DESCRIPTION_PLACEHOLDERS
|
||||||
|
| {
|
||||||
"mqtt_device": device_name,
|
"mqtt_device": device_name,
|
||||||
"entity_name_label": entity_name_label,
|
"entity_name_label": entity_name_label,
|
||||||
"platform_label": platform_label,
|
"platform_label": platform_label,
|
||||||
|
|||||||
@@ -1312,6 +1312,7 @@
|
|||||||
"speed": "[%key:component::sensor::entity_component::speed::name%]",
|
"speed": "[%key:component::sensor::entity_component::speed::name%]",
|
||||||
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
|
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
|
||||||
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
||||||
|
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
|
||||||
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
|
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
|
||||||
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
||||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from homeassistant.helpers.typing import ConfigType
|
|||||||
|
|
||||||
from .const import DOMAIN, PLATFORMS
|
from .const import DOMAIN, PLATFORMS
|
||||||
from .coordinator import (
|
from .coordinator import (
|
||||||
OhmeAdvancedSettingsCoordinator,
|
|
||||||
OhmeChargeSessionCoordinator,
|
OhmeChargeSessionCoordinator,
|
||||||
OhmeConfigEntry,
|
OhmeConfigEntry,
|
||||||
OhmeDeviceInfoCoordinator,
|
OhmeDeviceInfoCoordinator,
|
||||||
@@ -56,7 +55,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool
|
|||||||
|
|
||||||
coordinators = (
|
coordinators = (
|
||||||
OhmeChargeSessionCoordinator(hass, entry, client),
|
OhmeChargeSessionCoordinator(hass, entry, client),
|
||||||
OhmeAdvancedSettingsCoordinator(hass, entry, client),
|
|
||||||
OhmeDeviceInfoCoordinator(hass, entry, client),
|
OhmeDeviceInfoCoordinator(hass, entry, client),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import logging
|
|||||||
from ohme import ApiException, OhmeApiClient
|
from ohme import ApiException, OhmeApiClient
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
@@ -23,7 +23,6 @@ class OhmeRuntimeData:
|
|||||||
"""Dataclass to hold ohme coordinators."""
|
"""Dataclass to hold ohme coordinators."""
|
||||||
|
|
||||||
charge_session_coordinator: OhmeChargeSessionCoordinator
|
charge_session_coordinator: OhmeChargeSessionCoordinator
|
||||||
advanced_settings_coordinator: OhmeAdvancedSettingsCoordinator
|
|
||||||
device_info_coordinator: OhmeDeviceInfoCoordinator
|
device_info_coordinator: OhmeDeviceInfoCoordinator
|
||||||
|
|
||||||
|
|
||||||
@@ -78,31 +77,6 @@ class OhmeChargeSessionCoordinator(OhmeBaseCoordinator):
|
|||||||
await self.client.async_get_charge_session()
|
await self.client.async_get_charge_session()
|
||||||
|
|
||||||
|
|
||||||
class OhmeAdvancedSettingsCoordinator(OhmeBaseCoordinator):
|
|
||||||
"""Coordinator to pull settings and charger state from the API."""
|
|
||||||
|
|
||||||
coordinator_name = "Advanced Settings"
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, hass: HomeAssistant, config_entry: OhmeConfigEntry, client: OhmeApiClient
|
|
||||||
) -> None:
|
|
||||||
"""Initialise coordinator."""
|
|
||||||
super().__init__(hass, config_entry, client)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _dummy_listener() -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# This coordinator is used by the API library to determine whether the
|
|
||||||
# charger is online and available. It is therefore required even if no
|
|
||||||
# entities are using it.
|
|
||||||
self.async_add_listener(_dummy_listener)
|
|
||||||
|
|
||||||
async def _internal_update_data(self) -> None:
|
|
||||||
"""Fetch data from API endpoint."""
|
|
||||||
await self.client.async_get_advanced_settings()
|
|
||||||
|
|
||||||
|
|
||||||
class OhmeDeviceInfoCoordinator(OhmeBaseCoordinator):
|
class OhmeDeviceInfoCoordinator(OhmeBaseCoordinator):
|
||||||
"""Coordinator to pull device info and charger settings from the API."""
|
"""Coordinator to pull device info and charger settings from the API."""
|
||||||
|
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["ohme==1.5.2"]
|
"requirements": ["ohme==1.6.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class OhmeSensorDescription(OhmeEntityDescription, SensorEntityDescription):
|
|||||||
value_fn: Callable[[OhmeApiClient], str | int | float | None]
|
value_fn: Callable[[OhmeApiClient], str | int | float | None]
|
||||||
|
|
||||||
|
|
||||||
SENSOR_CHARGE_SESSION = [
|
SENSORS = [
|
||||||
OhmeSensorDescription(
|
OhmeSensorDescription(
|
||||||
key="status",
|
key="status",
|
||||||
translation_key="status",
|
translation_key="status",
|
||||||
@@ -91,18 +91,6 @@ SENSOR_CHARGE_SESSION = [
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
SENSOR_ADVANCED_SETTINGS = [
|
|
||||||
OhmeSensorDescription(
|
|
||||||
key="ct_current",
|
|
||||||
translation_key="ct_current",
|
|
||||||
device_class=SensorDeviceClass.CURRENT,
|
|
||||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
|
||||||
value_fn=lambda client: client.power.ct_amps,
|
|
||||||
is_supported_fn=lambda client: client.ct_connected,
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@@ -110,16 +98,11 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up sensors."""
|
"""Set up sensors."""
|
||||||
coordinators = config_entry.runtime_data
|
coordinator = config_entry.runtime_data.charge_session_coordinator
|
||||||
coordinator_map = [
|
|
||||||
(SENSOR_CHARGE_SESSION, coordinators.charge_session_coordinator),
|
|
||||||
(SENSOR_ADVANCED_SETTINGS, coordinators.advanced_settings_coordinator),
|
|
||||||
]
|
|
||||||
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
OhmeSensor(coordinator, description)
|
OhmeSensor(coordinator, description)
|
||||||
for entities, coordinator in coordinator_map
|
for description in SENSORS
|
||||||
for description in entities
|
|
||||||
if description.is_supported_fn(coordinator.client)
|
if description.is_supported_fn(coordinator.client)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,5 +10,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["onedrive_personal_sdk"],
|
"loggers": ["onedrive_personal_sdk"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["onedrive-personal-sdk==0.0.15"]
|
"requirements": ["onedrive-personal-sdk==0.0.17"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,6 @@
|
|||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["aio_ownet"],
|
"loggers": ["aio_ownet"],
|
||||||
"quality_scale": "silver",
|
"quality_scale": "silver",
|
||||||
"requirements": ["aio-ownet==0.0.4"],
|
"requirements": ["aio-ownet==0.0.5"],
|
||||||
"zeroconf": ["_owserver._tcp.local."]
|
"zeroconf": ["_owserver._tcp.local."]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,5 @@
|
|||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["openai==2.2.0", "python-open-router==0.3.2"]
|
"requirements": ["openai==2.2.0", "python-open-router==0.3.3"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/palazzetti",
|
"documentation": "https://www.home-assistant.io/integrations/palazzetti",
|
||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["pypalazzetti==0.1.19"]
|
"requirements": ["pypalazzetti==0.1.20"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["pyportainer==1.0.12"]
|
"requirements": ["pyportainer==1.0.14"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,7 @@
|
|||||||
"speed": "[%key:component::sensor::entity_component::speed::name%]",
|
"speed": "[%key:component::sensor::entity_component::speed::name%]",
|
||||||
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
|
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
|
||||||
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
||||||
|
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
|
||||||
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
|
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
|
||||||
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
||||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ def validate_db_schema(instance: Recorder) -> set[str]:
|
|||||||
schema_errors |= validate_table_schema_supports_utf8(
|
schema_errors |= validate_table_schema_supports_utf8(
|
||||||
instance, StatisticsMeta, (StatisticsMeta.statistic_id,)
|
instance, StatisticsMeta, (StatisticsMeta.statistic_id,)
|
||||||
)
|
)
|
||||||
|
schema_errors |= validate_table_schema_has_correct_collation(
|
||||||
|
instance, StatisticsMeta
|
||||||
|
)
|
||||||
for table in (Statistics, StatisticsShortTerm):
|
for table in (Statistics, StatisticsShortTerm):
|
||||||
schema_errors |= validate_db_schema_precision(instance, table)
|
schema_errors |= validate_db_schema_precision(instance, table)
|
||||||
schema_errors |= validate_table_schema_has_correct_collation(instance, table)
|
schema_errors |= validate_table_schema_has_correct_collation(instance, table)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36
|
|||||||
EVENT_TYPE_IDS_SCHEMA_VERSION = 37
|
EVENT_TYPE_IDS_SCHEMA_VERSION = 37
|
||||||
STATES_META_SCHEMA_VERSION = 38
|
STATES_META_SCHEMA_VERSION = 38
|
||||||
CIRCULAR_MEAN_SCHEMA_VERSION = 49
|
CIRCULAR_MEAN_SCHEMA_VERSION = 49
|
||||||
UNIT_CLASS_SCHEMA_VERSION = 51
|
UNIT_CLASS_SCHEMA_VERSION = 52
|
||||||
|
|
||||||
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
|
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
|
||||||
LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43
|
LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class LegacyBase(DeclarativeBase):
|
|||||||
"""Base class for tables, used for schema migration."""
|
"""Base class for tables, used for schema migration."""
|
||||||
|
|
||||||
|
|
||||||
SCHEMA_VERSION = 51
|
SCHEMA_VERSION = 52
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,15 @@ from typing import TYPE_CHECKING, Any, TypedDict, cast, final
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text, update
|
from sqlalchemy import (
|
||||||
|
ForeignKeyConstraint,
|
||||||
|
MetaData,
|
||||||
|
Table,
|
||||||
|
cast as cast_,
|
||||||
|
func,
|
||||||
|
text,
|
||||||
|
update,
|
||||||
|
)
|
||||||
from sqlalchemy.engine import CursorResult, Engine
|
from sqlalchemy.engine import CursorResult, Engine
|
||||||
from sqlalchemy.exc import (
|
from sqlalchemy.exc import (
|
||||||
DatabaseError,
|
DatabaseError,
|
||||||
@@ -26,8 +34,9 @@ from sqlalchemy.exc import (
|
|||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
from sqlalchemy.schema import AddConstraint, CreateTable, DropConstraint
|
from sqlalchemy.schema import AddConstraint, CreateTable, DropConstraint
|
||||||
from sqlalchemy.sql.expression import true
|
from sqlalchemy.sql.expression import and_, true
|
||||||
from sqlalchemy.sql.lambdas import StatementLambdaElement
|
from sqlalchemy.sql.lambdas import StatementLambdaElement
|
||||||
|
from sqlalchemy.types import BINARY
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.util.enum import try_parse_enum
|
from homeassistant.util.enum import try_parse_enum
|
||||||
@@ -2044,14 +2053,74 @@ class _SchemaVersion50Migrator(_SchemaVersionMigrator, target_version=50):
|
|||||||
class _SchemaVersion51Migrator(_SchemaVersionMigrator, target_version=51):
|
class _SchemaVersion51Migrator(_SchemaVersionMigrator, target_version=51):
|
||||||
def _apply_update(self) -> None:
|
def _apply_update(self) -> None:
|
||||||
"""Version specific update method."""
|
"""Version specific update method."""
|
||||||
# Add unit class column to StatisticsMeta
|
# Replaced with version 52 which corrects issues with MySQL string comparisons.
|
||||||
|
|
||||||
|
|
||||||
|
class _SchemaVersion52Migrator(_SchemaVersionMigrator, target_version=52):
|
||||||
|
def _apply_update(self) -> None:
|
||||||
|
"""Version specific update method."""
|
||||||
|
if self.engine.dialect.name == SupportedDialect.MYSQL:
|
||||||
|
self._apply_update_mysql()
|
||||||
|
else:
|
||||||
|
self._apply_update_postgresql_sqlite()
|
||||||
|
|
||||||
|
def _apply_update_mysql(self) -> None:
|
||||||
|
"""Version specific update method for mysql."""
|
||||||
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
|
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
|
||||||
with session_scope(session=self.session_maker()) as session:
|
with session_scope(session=self.session_maker()) as session:
|
||||||
connection = session.connection()
|
connection = session.connection()
|
||||||
for conv in _PRIMARY_UNIT_CONVERTERS:
|
for conv in _PRIMARY_UNIT_CONVERTERS:
|
||||||
|
case_sensitive_units = {
|
||||||
|
u.encode("utf-8") if u else u for u in conv.VALID_UNITS
|
||||||
|
}
|
||||||
|
# Reset unit_class to None for entries that do not match
|
||||||
|
# the valid units (case sensitive) but matched before due to
|
||||||
|
# case insensitive comparisons.
|
||||||
connection.execute(
|
connection.execute(
|
||||||
update(StatisticsMeta)
|
update(StatisticsMeta)
|
||||||
.where(StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS))
|
.where(
|
||||||
|
and_(
|
||||||
|
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
|
||||||
|
cast_(StatisticsMeta.unit_of_measurement, BINARY).not_in(
|
||||||
|
case_sensitive_units
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values(unit_class=None)
|
||||||
|
)
|
||||||
|
# Do an explicitly case sensitive match (actually binary) to set the
|
||||||
|
# correct unit_class. This is needed because we use the case sensitive
|
||||||
|
# utf8mb4_unicode_ci collation.
|
||||||
|
connection.execute(
|
||||||
|
update(StatisticsMeta)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
cast_(StatisticsMeta.unit_of_measurement, BINARY).in_(
|
||||||
|
case_sensitive_units
|
||||||
|
),
|
||||||
|
StatisticsMeta.unit_class.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values(unit_class=conv.UNIT_CLASS)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_update_postgresql_sqlite(self) -> None:
|
||||||
|
"""Version specific update method for postgresql and sqlite."""
|
||||||
|
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
|
||||||
|
with session_scope(session=self.session_maker()) as session:
|
||||||
|
connection = session.connection()
|
||||||
|
for conv in _PRIMARY_UNIT_CONVERTERS:
|
||||||
|
# Set the correct unit_class. Unlike MySQL, Postgres and SQLite
|
||||||
|
# have case sensitive string comparisons by default, so we
|
||||||
|
# can directly match on the valid units.
|
||||||
|
connection.execute(
|
||||||
|
update(StatisticsMeta)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
|
||||||
|
StatisticsMeta.unit_class.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
.values(unit_class=conv.UNIT_CLASS)
|
.values(unit_class=conv.UNIT_CLASS)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,5 +19,5 @@
|
|||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["reolink_aio"],
|
"loggers": ["reolink_aio"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["reolink-aio==0.16.4"]
|
"requirements": ["reolink-aio==0.16.5"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
def is_matching(self, other_flow: Self) -> bool:
|
def is_matching(self, other_flow: Self) -> bool:
|
||||||
"""Return True if other_flow is matching this flow."""
|
"""Return True if other_flow is matching this flow."""
|
||||||
return other_flow._host == self._host # noqa: SLF001
|
return getattr(other_flow, "_host", None) == self._host
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _abort_if_manufacturer_is_not_samsung(self) -> None:
|
def _abort_if_manufacturer_is_not_samsung(self) -> None:
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
"samsungctl[websocket]==0.7.1",
|
"samsungctl[websocket]==0.7.1",
|
||||||
"samsungtvws[async,encrypted]==2.7.2",
|
"samsungtvws[async,encrypted]==2.7.2",
|
||||||
"wakeonlan==3.1.0",
|
"wakeonlan==3.1.0",
|
||||||
"async-upnp-client==0.45.0"
|
"async-upnp-client==0.46.0"
|
||||||
],
|
],
|
||||||
"ssdp": [
|
"ssdp": [
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user