mirror of
https://github.com/home-assistant/core.git
synced 2026-01-06 07:08:16 +00:00
Compare commits
78 Commits
2025.8.0b5
...
trigger_de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6caa86ab3 | ||
|
|
4318e29ce8 | ||
|
|
fea5c63bba | ||
|
|
b2349ac2bd | ||
|
|
08f7b708a4 | ||
|
|
1236801b7d | ||
|
|
72d9dbf39d | ||
|
|
755864f9f3 | ||
|
|
fa476d4e34 | ||
|
|
018197e41a | ||
|
|
7dd2b9e422 | ||
|
|
3e615fd373 | ||
|
|
c0bf167e10 | ||
|
|
45f6778ff4 | ||
|
|
bddd4d621a | ||
|
|
b0e75e9ee4 | ||
|
|
d45c03a795 | ||
|
|
8562c8d32f | ||
|
|
ae42d71123 | ||
|
|
9616c8cd7b | ||
|
|
9394546668 | ||
|
|
d43f21c2e2 | ||
|
|
8d68fee9f8 | ||
|
|
b4a4e218ec | ||
|
|
fb2d62d692 | ||
|
|
f538807d6e | ||
|
|
a08c3c9f44 | ||
|
|
506431c75f | ||
|
|
37579440e6 | ||
|
|
5ce2729dc2 | ||
|
|
b5e4ae4a53 | ||
|
|
3d4386ea6d | ||
|
|
9f1cec893e | ||
|
|
bc87140a6f | ||
|
|
d77a3fca83 | ||
|
|
924a86dfb6 | ||
|
|
0d7608f7c5 | ||
|
|
22e054f4cd | ||
|
|
8b53b26333 | ||
|
|
4d59e8cd80 | ||
|
|
61396d92a5 | ||
|
|
c72c600de4 | ||
|
|
b86b0c10bd | ||
|
|
eb222f6c5d | ||
|
|
4b5fe424ed | ||
|
|
61ca42e923 | ||
|
|
21c1427abf | ||
|
|
aa6b37bc7c | ||
|
|
bbc1466cfc | ||
|
|
21a9799060 | ||
|
|
f7d54b46ec | ||
|
|
6ad1b8dcb1 | ||
|
|
5f6b1212a3 | ||
|
|
58dc6a952e | ||
|
|
59d8df142d | ||
|
|
04fb86b4ba | ||
|
|
3d744f032f | ||
|
|
f7c8cdb3a7 | ||
|
|
3952544822 | ||
|
|
42101dd432 | ||
|
|
f7eacaa48d | ||
|
|
ad0db5c83a | ||
|
|
63216b77c2 | ||
|
|
7a55373b0b | ||
|
|
f9e7459901 | ||
|
|
94dc2e2ea3 | ||
|
|
2cf144fb25 | ||
|
|
f318766021 | ||
|
|
ec7fb140ac | ||
|
|
2706c7d67d | ||
|
|
b4e50902eb | ||
|
|
1ead01bc9a | ||
|
|
389a1251a1 | ||
|
|
8d27ca1e21 | ||
|
|
a76af50c10 | ||
|
|
09b91bd76a | ||
|
|
736d582d04 | ||
|
|
8114df4219 |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 4
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2025.8"
|
||||
HA_SHORT_VERSION: "2025.9"
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
ALL_PYTHON_VERSIONS: "['3.13']"
|
||||
# 10.3 is the oldest supported version
|
||||
|
||||
4
.github/workflows/wheels.yml
vendored
4
.github/workflows/wheels.yml
vendored
@@ -159,7 +159,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@2025.03.0
|
||||
uses: home-assistant/wheels@2025.07.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
@@ -219,7 +219,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@2025.03.0
|
||||
uses: home-assistant/wheels@2025.07.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
|
||||
@@ -33,7 +33,10 @@ class AuthFlowContext(FlowContext, total=False):
|
||||
redirect_uri: str
|
||||
|
||||
|
||||
AuthFlowResult = FlowResult[AuthFlowContext, tuple[str, str]]
|
||||
class AuthFlowResult(FlowResult[AuthFlowContext, tuple[str, str]], total=False):
|
||||
"""Typed result dict for auth flow."""
|
||||
|
||||
result: Credentials # Only present if type is CREATE_ENTRY
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
|
||||
@@ -6,11 +6,11 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from airos.exceptions import (
|
||||
AirOSConnectionAuthenticationError,
|
||||
AirOSConnectionSetupError,
|
||||
AirOSDataMissingError,
|
||||
AirOSDeviceConnectionError,
|
||||
AirOSKeyDataMissingError,
|
||||
ConnectionAuthenticationError,
|
||||
ConnectionSetupError,
|
||||
DataMissingError,
|
||||
DeviceConnectionError,
|
||||
KeyDataMissingError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -59,13 +59,13 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
airos_data = await airos_device.status()
|
||||
|
||||
except (
|
||||
AirOSConnectionSetupError,
|
||||
AirOSDeviceConnectionError,
|
||||
ConnectionSetupError,
|
||||
DeviceConnectionError,
|
||||
):
|
||||
errors["base"] = "cannot_connect"
|
||||
except (AirOSConnectionAuthenticationError, AirOSDataMissingError):
|
||||
except (ConnectionAuthenticationError, DataMissingError):
|
||||
errors["base"] = "invalid_auth"
|
||||
except AirOSKeyDataMissingError:
|
||||
except KeyDataMissingError:
|
||||
errors["base"] = "key_data_missing"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
|
||||
@@ -6,10 +6,10 @@ import logging
|
||||
|
||||
from airos.airos8 import AirOS, AirOSData
|
||||
from airos.exceptions import (
|
||||
AirOSConnectionAuthenticationError,
|
||||
AirOSConnectionSetupError,
|
||||
AirOSDataMissingError,
|
||||
AirOSDeviceConnectionError,
|
||||
ConnectionAuthenticationError,
|
||||
ConnectionSetupError,
|
||||
DataMissingError,
|
||||
DeviceConnectionError,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -47,22 +47,18 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSData]):
|
||||
try:
|
||||
await self.airos_device.login()
|
||||
return await self.airos_device.status()
|
||||
except (AirOSConnectionAuthenticationError,) as err:
|
||||
except (ConnectionAuthenticationError,) as err:
|
||||
_LOGGER.exception("Error authenticating with airOS device")
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN, translation_key="invalid_auth"
|
||||
) from err
|
||||
except (
|
||||
AirOSConnectionSetupError,
|
||||
AirOSDeviceConnectionError,
|
||||
TimeoutError,
|
||||
) as err:
|
||||
except (ConnectionSetupError, DeviceConnectionError, TimeoutError) as err:
|
||||
_LOGGER.error("Error connecting to airOS device: %s", err)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
except (AirOSDataMissingError,) as err:
|
||||
except (DataMissingError,) as err:
|
||||
_LOGGER.error("Expected data not returned by airOS device: %s", err)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airos",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["airos==0.2.4"]
|
||||
"requirements": ["airos==0.2.1"]
|
||||
}
|
||||
|
||||
@@ -69,6 +69,13 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
|
||||
translation_key="wireless_essid",
|
||||
value_fn=lambda data: data.wireless.essid,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_mode",
|
||||
translation_key="wireless_mode",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
value_fn=lambda data: data.wireless.mode.value.replace("-", "_").lower(),
|
||||
options=WIRELESS_MODE_OPTIONS,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_antenna_gain",
|
||||
translation_key="wireless_antenna_gain",
|
||||
|
||||
@@ -43,6 +43,13 @@
|
||||
"wireless_essid": {
|
||||
"name": "Wireless SSID"
|
||||
},
|
||||
"wireless_mode": {
|
||||
"name": "Wireless mode",
|
||||
"state": {
|
||||
"ap_ptp": "Access point",
|
||||
"sta_ptp": "Station"
|
||||
}
|
||||
},
|
||||
"wireless_antenna_gain": {
|
||||
"name": "Antenna gain"
|
||||
},
|
||||
|
||||
@@ -7,18 +7,21 @@ import logging
|
||||
|
||||
from airthings import Airthings
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_SECRET
|
||||
from .coordinator import AirthingsConfigEntry, AirthingsDataUpdateCoordinator
|
||||
from .coordinator import AirthingsDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
SCAN_INTERVAL = timedelta(minutes=6)
|
||||
|
||||
type AirthingsConfigEntry = ConfigEntry[AirthingsDataUpdateCoordinator]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) -> bool:
|
||||
"""Set up Airthings from a config entry."""
|
||||
@@ -28,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) ->
|
||||
async_get_clientsession(hass),
|
||||
)
|
||||
|
||||
coordinator = AirthingsDataUpdateCoordinator(hass, airthings, entry)
|
||||
coordinator = AirthingsDataUpdateCoordinator(hass, airthings)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import logging
|
||||
|
||||
from airthings import Airthings, AirthingsDevice, AirthingsError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -14,23 +13,15 @@ from .const import DOMAIN
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
SCAN_INTERVAL = timedelta(minutes=6)
|
||||
|
||||
type AirthingsConfigEntry = ConfigEntry[AirthingsDataUpdateCoordinator]
|
||||
|
||||
|
||||
class AirthingsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AirthingsDevice]]):
|
||||
"""Coordinator for Airthings data updates."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
airthings: Airthings,
|
||||
config_entry: AirthingsConfigEntry,
|
||||
) -> None:
|
||||
def __init__(self, hass: HomeAssistant, airthings: Airthings) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_method=self._update_method,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
|
||||
@@ -81,15 +81,11 @@ async def async_update_options(
|
||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
"""Migrate integration entry structure."""
|
||||
|
||||
# Make sure we get enabled config entries first
|
||||
entries = sorted(
|
||||
hass.config_entries.async_entries(DOMAIN),
|
||||
key=lambda e: e.disabled_by is not None,
|
||||
)
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
if not any(entry.version == 1 for entry in entries):
|
||||
return
|
||||
|
||||
api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {}
|
||||
api_keys_entries: dict[str, ConfigEntry] = {}
|
||||
entity_registry = er.async_get(hass)
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
@@ -103,61 +99,30 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
)
|
||||
if entry.data[CONF_API_KEY] not in api_keys_entries:
|
||||
use_existing = True
|
||||
all_disabled = all(
|
||||
e.disabled_by is not None
|
||||
for e in entries
|
||||
if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY]
|
||||
)
|
||||
api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled)
|
||||
api_keys_entries[entry.data[CONF_API_KEY]] = entry
|
||||
|
||||
parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]]
|
||||
parent_entry = api_keys_entries[entry.data[CONF_API_KEY]]
|
||||
|
||||
hass.config_entries.async_add_subentry(parent_entry, subentry)
|
||||
conversation_entity_id = entity_registry.async_get_entity_id(
|
||||
conversation_entity = entity_registry.async_get_entity_id(
|
||||
"conversation",
|
||||
DOMAIN,
|
||||
entry.entry_id,
|
||||
)
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, entry.entry_id)}
|
||||
)
|
||||
|
||||
if conversation_entity_id is not None:
|
||||
conversation_entity_entry = entity_registry.entities[conversation_entity_id]
|
||||
entity_disabled_by = conversation_entity_entry.disabled_by
|
||||
if (
|
||||
entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY
|
||||
and not all_disabled
|
||||
):
|
||||
# Device and entity registries don't update the disabled_by flag
|
||||
# when moving a device or entity from one config entry to another,
|
||||
# so we need to do it manually.
|
||||
entity_disabled_by = (
|
||||
er.RegistryEntryDisabler.DEVICE
|
||||
if device
|
||||
else er.RegistryEntryDisabler.USER
|
||||
)
|
||||
if conversation_entity is not None:
|
||||
entity_registry.async_update_entity(
|
||||
conversation_entity_id,
|
||||
conversation_entity,
|
||||
config_entry_id=parent_entry.entry_id,
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
disabled_by=entity_disabled_by,
|
||||
new_unique_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, entry.entry_id)}
|
||||
)
|
||||
if device is not None:
|
||||
# Device and entity registries don't update the disabled_by flag when
|
||||
# moving a device or entity from one config entry to another, so we
|
||||
# need to do it manually.
|
||||
device_disabled_by = device.disabled_by
|
||||
if (
|
||||
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
||||
and not all_disabled
|
||||
):
|
||||
device_disabled_by = dr.DeviceEntryDisabler.USER
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
disabled_by=device_disabled_by,
|
||||
new_identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
add_config_subentry_id=subentry.subentry_id,
|
||||
add_config_entry_id=parent_entry.entry_id,
|
||||
@@ -182,7 +147,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
title=DEFAULT_CONVERSATION_NAME,
|
||||
options={},
|
||||
version=2,
|
||||
minor_version=3,
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
|
||||
@@ -208,38 +173,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 2:
|
||||
# Fix migration where the disabled_by flag was not set correctly.
|
||||
# We can currently only correct this for enabled config entries,
|
||||
# because migration does not run for disabled config entries. This
|
||||
# is asserted in tests, and if that behavior is changed, we should
|
||||
# correct also disabled config entries.
|
||||
device_registry = dr.async_get(hass)
|
||||
entity_registry = er.async_get(hass)
|
||||
devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
|
||||
entity_entries = er.async_entries_for_config_entry(
|
||||
entity_registry, entry.entry_id
|
||||
)
|
||||
if entry.disabled_by is None:
|
||||
# If the config entry is not disabled, we need to set the disabled_by
|
||||
# flag on devices to USER, and on entities to DEVICE, if they are set
|
||||
# to CONFIG_ENTRY.
|
||||
for device in devices:
|
||||
if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY:
|
||||
continue
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
disabled_by=dr.DeviceEntryDisabler.USER,
|
||||
)
|
||||
for entity in entity_entries:
|
||||
if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY:
|
||||
continue
|
||||
entity_registry.async_update_entity(
|
||||
entity.entity_id,
|
||||
disabled_by=er.RegistryEntryDisabler.DEVICE,
|
||||
)
|
||||
hass.config_entries.async_update_entry(entry, minor_version=3)
|
||||
|
||||
LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
@@ -75,7 +75,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Anthropic."""
|
||||
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 3
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable, Mapping
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from pyasuswrt import AsusWrtError
|
||||
|
||||
@@ -40,9 +40,6 @@ from .const import (
|
||||
SENSORS_CONNECTED_DEVICE,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import AsusWrtConfigEntry
|
||||
|
||||
CONF_REQ_RELOAD = [CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP]
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
@@ -55,13 +52,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class AsusWrtSensorDataHandler:
|
||||
"""Data handler for AsusWrt sensor."""
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, api: AsusWrtBridge, entry: AsusWrtConfigEntry
|
||||
) -> None:
|
||||
def __init__(self, hass: HomeAssistant, api: AsusWrtBridge) -> None:
|
||||
"""Initialize a AsusWrt sensor data handler."""
|
||||
self._hass = hass
|
||||
self._api = api
|
||||
self._entry = entry
|
||||
self._connected_devices = 0
|
||||
|
||||
async def _get_connected_devices(self) -> dict[str, int]:
|
||||
@@ -97,7 +91,6 @@ class AsusWrtSensorDataHandler:
|
||||
update_method=method,
|
||||
# Polling interval. Will only be polled if there are subscribers.
|
||||
update_interval=SCAN_INTERVAL if should_poll else None,
|
||||
config_entry=self._entry,
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
@@ -328,9 +321,7 @@ class AsusWrtRouter:
|
||||
if self._sensors_data_handler:
|
||||
return
|
||||
|
||||
self._sensors_data_handler = AsusWrtSensorDataHandler(
|
||||
self.hass, self._api, self._entry
|
||||
)
|
||||
self._sensors_data_handler = AsusWrtSensorDataHandler(self.hass, self._api)
|
||||
self._sensors_data_handler.update_device_count(self._connected_devices)
|
||||
|
||||
sensors_types = await self._api.async_get_available_sensors()
|
||||
|
||||
@@ -28,5 +28,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==8.11.1", "yalexs-ble==3.1.2"]
|
||||
"requirements": ["yalexs==8.10.0", "yalexs-ble==3.1.0"]
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ class LoginFlowBaseView(HomeAssistantView):
|
||||
result.pop("data")
|
||||
result.pop("context")
|
||||
|
||||
result_obj: Credentials = result.pop("result")
|
||||
result_obj = result.pop("result")
|
||||
|
||||
# Result can be None if credential was never linked to a user before.
|
||||
user = await hass.auth.async_get_user_by_credentials(result_obj)
|
||||
@@ -281,7 +281,8 @@ class LoginFlowBaseView(HomeAssistantView):
|
||||
)
|
||||
|
||||
process_success_login(request)
|
||||
result["result"] = self._store_result(client_id, result_obj)
|
||||
# We overwrite the Credentials object with the string code to retrieve it.
|
||||
result["result"] = self._store_result(client_id, result_obj) # type: ignore[typeddict-item]
|
||||
|
||||
return self.json(result)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==65"],
|
||||
"requirements": ["axis==64"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.0.0",
|
||||
"bluetooth-auto-recovery==1.5.2",
|
||||
"bluetooth-data-tools==1.28.2",
|
||||
"dbus-fast==2.44.3",
|
||||
"habluetooth==4.0.2"
|
||||
"dbus-fast==2.44.2",
|
||||
"habluetooth==4.0.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ class BroadlinkUpdateManager(ABC, Generic[_ApiT]):
|
||||
device.hass,
|
||||
_LOGGER,
|
||||
name=f"{device.name} ({device.api.model} at {device.api.host[0]})",
|
||||
config_entry=device.config,
|
||||
update_method=self.async_update,
|
||||
update_interval=self.SCAN_INTERVAL,
|
||||
)
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from bsblan import BSBLAN, BSBLANConfig, BSBLANError
|
||||
from bsblan import BSBLAN, BSBLANAuthError, BSBLANConfig, BSBLANError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
@@ -45,7 +46,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self.username = user_input.get(CONF_USERNAME)
|
||||
self.password = user_input.get(CONF_PASSWORD)
|
||||
|
||||
return await self._validate_and_create()
|
||||
return await self._validate_and_create(user_input)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
@@ -128,14 +129,29 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self.username = user_input.get(CONF_USERNAME)
|
||||
self.password = user_input.get(CONF_PASSWORD)
|
||||
|
||||
return await self._validate_and_create(is_discovery=True)
|
||||
return await self._validate_and_create(user_input, is_discovery=True)
|
||||
|
||||
async def _validate_and_create(
|
||||
self, is_discovery: bool = False
|
||||
self, user_input: dict[str, Any], is_discovery: bool = False
|
||||
) -> ConfigFlowResult:
|
||||
"""Validate device connection and create entry."""
|
||||
try:
|
||||
await self._get_bsblan_info(is_discovery=is_discovery)
|
||||
await self._get_bsblan_info()
|
||||
except BSBLANAuthError:
|
||||
if is_discovery:
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_PASSKEY): str,
|
||||
vol.Optional(CONF_USERNAME): str,
|
||||
vol.Optional(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
errors={"base": "invalid_auth"},
|
||||
description_placeholders={"host": str(self.host)},
|
||||
)
|
||||
return self._show_setup_form({"base": "invalid_auth"}, user_input)
|
||||
except BSBLANError:
|
||||
if is_discovery:
|
||||
return self.async_show_form(
|
||||
@@ -154,18 +170,145 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return self._async_create_entry()
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth flow."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth confirmation flow."""
|
||||
existing_entry = self.hass.config_entries.async_get_entry(
|
||||
self.context["entry_id"]
|
||||
)
|
||||
assert existing_entry
|
||||
|
||||
if user_input is None:
|
||||
# Preserve existing values as defaults
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=existing_entry.data.get(
|
||||
CONF_PASSKEY, vol.UNDEFINED
|
||||
),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=existing_entry.data.get(
|
||||
CONF_USERNAME, vol.UNDEFINED
|
||||
),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Use existing host and port, update auth credentials
|
||||
self.host = existing_entry.data[CONF_HOST]
|
||||
self.port = existing_entry.data[CONF_PORT]
|
||||
self.passkey = user_input.get(CONF_PASSKEY) or existing_entry.data.get(
|
||||
CONF_PASSKEY
|
||||
)
|
||||
self.username = user_input.get(CONF_USERNAME) or existing_entry.data.get(
|
||||
CONF_USERNAME
|
||||
)
|
||||
self.password = user_input.get(CONF_PASSWORD)
|
||||
|
||||
try:
|
||||
await self._get_bsblan_info(raise_on_progress=False, is_reauth=True)
|
||||
except BSBLANAuthError:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors={"base": "invalid_auth"},
|
||||
)
|
||||
except BSBLANError:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors={"base": "cannot_connect"},
|
||||
)
|
||||
|
||||
# Update the config entry with new auth data
|
||||
data_updates = {}
|
||||
if self.passkey is not None:
|
||||
data_updates[CONF_PASSKEY] = self.passkey
|
||||
if self.username is not None:
|
||||
data_updates[CONF_USERNAME] = self.username
|
||||
if self.password is not None:
|
||||
data_updates[CONF_PASSWORD] = self.password
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
existing_entry, data_updates=data_updates, reason="reauth_successful"
|
||||
)
|
||||
|
||||
@callback
|
||||
def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult:
|
||||
def _show_setup_form(
|
||||
self, errors: dict | None = None, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show the setup form to the user."""
|
||||
# Preserve user input if provided, otherwise use defaults
|
||||
defaults = user_input or {}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
vol.Optional(CONF_PASSKEY): str,
|
||||
vol.Optional(CONF_USERNAME): str,
|
||||
vol.Optional(CONF_PASSWORD): str,
|
||||
vol.Required(
|
||||
CONF_HOST, default=defaults.get(CONF_HOST, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PORT, default=defaults.get(CONF_PORT, DEFAULT_PORT)
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_PASSKEY, default=defaults.get(CONF_PASSKEY, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=defaults.get(CONF_USERNAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=defaults.get(CONF_PASSWORD, vol.UNDEFINED),
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors=errors or {},
|
||||
@@ -186,7 +329,9 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
async def _get_bsblan_info(
|
||||
self, raise_on_progress: bool = True, is_discovery: bool = False
|
||||
self,
|
||||
raise_on_progress: bool = True,
|
||||
is_reauth: bool = False,
|
||||
) -> None:
|
||||
"""Get device information from a BSBLAN device."""
|
||||
config = BSBLANConfig(
|
||||
@@ -209,11 +354,13 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
format_mac(self.mac), raise_on_progress=raise_on_progress
|
||||
)
|
||||
|
||||
# Always allow updating host/port for both user and discovery flows
|
||||
# This ensures connectivity is maintained when devices change IP addresses
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
}
|
||||
)
|
||||
# Skip unique_id configuration check during reauth to prevent "already_configured" abort
|
||||
if not is_reauth:
|
||||
# Always allow updating host/port for both user and discovery flows
|
||||
# This ensures connectivity is maintained when devices change IP addresses
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4,11 +4,19 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from random import randint
|
||||
|
||||
from bsblan import BSBLAN, BSBLANConnectionError, HotWaterState, Sensor, State
|
||||
from bsblan import (
|
||||
BSBLAN,
|
||||
BSBLANAuthError,
|
||||
BSBLANConnectionError,
|
||||
HotWaterState,
|
||||
Sensor,
|
||||
State,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
|
||||
@@ -62,6 +70,10 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]):
|
||||
state = await self.client.state()
|
||||
sensor = await self.client.sensor()
|
||||
dhw = await self.client.hot_water_state()
|
||||
except BSBLANAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Authentication failed for BSB-Lan device"
|
||||
) from err
|
||||
except BSBLANConnectionError as err:
|
||||
host = self.config_entry.data[CONF_HOST] if self.config_entry else "unknown"
|
||||
raise UpdateFailed(
|
||||
|
||||
@@ -33,14 +33,25 @@
|
||||
"username": "[%key:component::bsblan::config::step::user::data_description::username%]",
|
||||
"password": "[%key:component::bsblan::config::step::user::data_description::password%]"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "[%key:common::config_flow::title::reauth%]",
|
||||
"description": "The BSB-Lan integration needs to re-authenticate with {name}",
|
||||
"data": {
|
||||
"passkey": "[%key:component::bsblan::config::step::user::data::passkey%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/caldav",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["caldav", "vobject"],
|
||||
"requirements": ["caldav==1.6.0", "icalendar==6.3.1"]
|
||||
"requirements": ["caldav==1.6.0", "icalendar==6.1.0"]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==0.111.1"],
|
||||
"requirements": ["hass-nabucasa==0.110.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -146,8 +146,9 @@ def _prepare_config_flow_result_json(
|
||||
return prepare_result_json(result)
|
||||
|
||||
data = result.copy()
|
||||
entry: config_entries.ConfigEntry = data["result"]
|
||||
data["result"] = entry.as_json_fragment
|
||||
entry: config_entries.ConfigEntry = data["result"] # type: ignore[typeddict-item]
|
||||
# We overwrite the ConfigEntry object with its json representation.
|
||||
data["result"] = entry.as_json_fragment # type: ignore[typeddict-unknown-key]
|
||||
data.pop("data")
|
||||
data.pop("context")
|
||||
return data
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.2.0",
|
||||
"aiodiscover==2.7.1",
|
||||
"aiodiscover==2.7.0",
|
||||
"cached-ipaddress==0.10.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -18,9 +18,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# If path is relative, we assume relative to Home Assistant config dir
|
||||
if not os.path.isabs(download_path):
|
||||
download_path = hass.config.path(download_path)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data={**entry.data, CONF_DOWNLOAD_DIR: download_path}
|
||||
)
|
||||
|
||||
if not await hass.async_add_executor_job(os.path.isdir, download_path):
|
||||
_LOGGER.error(
|
||||
|
||||
@@ -11,7 +11,6 @@ import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
|
||||
@@ -35,33 +34,24 @@ def download_file(service: ServiceCall) -> None:
|
||||
|
||||
entry = service.hass.config_entries.async_loaded_entries(DOMAIN)[0]
|
||||
download_path = entry.data[CONF_DOWNLOAD_DIR]
|
||||
url: str = service.data[ATTR_URL]
|
||||
subdir: str | None = service.data.get(ATTR_SUBDIR)
|
||||
target_filename: str | None = service.data.get(ATTR_FILENAME)
|
||||
overwrite: bool = service.data[ATTR_OVERWRITE]
|
||||
|
||||
if subdir:
|
||||
# Check the path
|
||||
try:
|
||||
raise_if_invalid_path(subdir)
|
||||
except ValueError as err:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="subdir_invalid",
|
||||
translation_placeholders={"subdir": subdir},
|
||||
) from err
|
||||
if os.path.isabs(subdir):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="subdir_not_relative",
|
||||
translation_placeholders={"subdir": subdir},
|
||||
)
|
||||
|
||||
def do_download() -> None:
|
||||
"""Download the file."""
|
||||
final_path = None
|
||||
filename = target_filename
|
||||
try:
|
||||
url = service.data[ATTR_URL]
|
||||
|
||||
subdir = service.data.get(ATTR_SUBDIR)
|
||||
|
||||
filename = service.data.get(ATTR_FILENAME)
|
||||
|
||||
overwrite = service.data.get(ATTR_OVERWRITE)
|
||||
|
||||
if subdir:
|
||||
# Check the path
|
||||
raise_if_invalid_path(subdir)
|
||||
|
||||
final_path = None
|
||||
|
||||
req = requests.get(url, stream=True, timeout=10)
|
||||
|
||||
if req.status_code != HTTPStatus.OK:
|
||||
|
||||
@@ -12,14 +12,6 @@
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"subdir_invalid": {
|
||||
"message": "Invalid subdirectory, got: {subdir}"
|
||||
},
|
||||
"subdir_not_relative": {
|
||||
"message": "Subdirectory must be relative, got: {subdir}"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"download_file": {
|
||||
"name": "Download file",
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.6.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.5.0"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/emoncms",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pyemoncms==0.1.1"]
|
||||
"requirements": ["pyemoncms==0.1.2"]
|
||||
}
|
||||
|
||||
@@ -12,12 +12,26 @@
|
||||
},
|
||||
"data_description": {
|
||||
"url": "Server URL starting with the protocol (http or https)",
|
||||
"api_key": "Your 32 bits API key"
|
||||
"api_key": "Your 32 bits API key",
|
||||
"sync_mode": "Pick your feeds manually (default) or synchronize them at once"
|
||||
}
|
||||
},
|
||||
"choose_feeds": {
|
||||
"data": {
|
||||
"include_only_feed_id": "Choose feeds to include"
|
||||
},
|
||||
"data_description": {
|
||||
"include_only_feed_id": "Pick the feeds you want to synchronize"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"url": "[%key:component::emoncms::config::step::user::data_description::url%]",
|
||||
"api_key": "[%key:component::emoncms::config::step::user::data_description::api_key%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -30,8 +44,8 @@
|
||||
"selector": {
|
||||
"sync_mode": {
|
||||
"options": {
|
||||
"auto": "Synchronize all available Feeds",
|
||||
"manual": "Select which Feeds to synchronize"
|
||||
"auto": "Synchronize all available feeds",
|
||||
"manual": "Select which feeds to synchronize"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -89,6 +103,9 @@
|
||||
"init": {
|
||||
"data": {
|
||||
"include_only_feed_id": "[%key:component::emoncms::config::step::choose_feeds::data::include_only_feed_id%]"
|
||||
},
|
||||
"data_description": {
|
||||
"include_only_feed_id": "[%key:component::emoncms::config::step::choose_feeds::data_description::include_only_feed_id%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ async def async_get_config_entry_diagnostics(
|
||||
device_dict.pop("_cache", None)
|
||||
# This can be removed when suggested_area is removed from DeviceEntry
|
||||
device_dict.pop("_suggested_area")
|
||||
device_dict.pop("is_new", None)
|
||||
device_entities.append({"device": device_dict, "entities": entities})
|
||||
|
||||
# remove envoy serial
|
||||
|
||||
@@ -66,6 +66,26 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
|
||||
key="last_alarm_type_name",
|
||||
translation_key="last_alarm_type_name",
|
||||
),
|
||||
"Record_Mode": SensorEntityDescription(
|
||||
key="Record_Mode",
|
||||
translation_key="record_mode",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"battery_camera_work_mode": SensorEntityDescription(
|
||||
key="battery_camera_work_mode",
|
||||
translation_key="battery_camera_work_mode",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"powerStatus": SensorEntityDescription(
|
||||
key="powerStatus",
|
||||
translation_key="power_status",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"OnlineStatus": SensorEntityDescription(
|
||||
key="OnlineStatus",
|
||||
translation_key="online_status",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -76,16 +96,26 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up EZVIZ sensors based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
entities: list[EzvizSensor] = []
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
for camera, sensors in coordinator.data.items():
|
||||
entities.extend(
|
||||
EzvizSensor(coordinator, camera, sensor)
|
||||
for camera in coordinator.data
|
||||
for sensor, value in coordinator.data[camera].items()
|
||||
if sensor in SENSOR_TYPES
|
||||
if value is not None
|
||||
]
|
||||
)
|
||||
for sensor, value in sensors.items()
|
||||
if sensor in SENSOR_TYPES and value is not None
|
||||
)
|
||||
|
||||
optionals = sensors.get("optionals", {})
|
||||
entities.extend(
|
||||
EzvizSensor(coordinator, camera, optional_key)
|
||||
for optional_key in ("powerStatus", "OnlineStatus")
|
||||
if optional_key in optionals
|
||||
)
|
||||
|
||||
if "mode" in optionals.get("Record_Mode", {}):
|
||||
entities.append(EzvizSensor(coordinator, camera, "mode"))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class EzvizSensor(EzvizEntity, SensorEntity):
|
||||
|
||||
@@ -147,6 +147,18 @@
|
||||
},
|
||||
"last_alarm_type_name": {
|
||||
"name": "Last alarm type name"
|
||||
},
|
||||
"record_mode": {
|
||||
"name": "Record mode"
|
||||
},
|
||||
"battery_camera_work_mode": {
|
||||
"name": "Battery work mode"
|
||||
},
|
||||
"power_status": {
|
||||
"name": "Power status"
|
||||
},
|
||||
"online_status": {
|
||||
"name": "Online status"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -106,7 +106,6 @@ class FroniusSolarNet:
|
||||
solar_net=self,
|
||||
logger=_LOGGER,
|
||||
name=f"{DOMAIN}_logger_{self.host}",
|
||||
config_entry=self.config_entry,
|
||||
)
|
||||
await self.logger_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -121,7 +120,6 @@ class FroniusSolarNet:
|
||||
solar_net=self,
|
||||
logger=_LOGGER,
|
||||
name=f"{DOMAIN}_meters_{self.host}",
|
||||
config_entry=self.config_entry,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -131,7 +129,6 @@ class FroniusSolarNet:
|
||||
solar_net=self,
|
||||
logger=_LOGGER,
|
||||
name=f"{DOMAIN}_ohmpilot_{self.host}",
|
||||
config_entry=self.config_entry,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -141,7 +138,6 @@ class FroniusSolarNet:
|
||||
solar_net=self,
|
||||
logger=_LOGGER,
|
||||
name=f"{DOMAIN}_power_flow_{self.host}",
|
||||
config_entry=self.config_entry,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -151,7 +147,6 @@ class FroniusSolarNet:
|
||||
solar_net=self,
|
||||
logger=_LOGGER,
|
||||
name=f"{DOMAIN}_storages_{self.host}",
|
||||
config_entry=self.config_entry,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -211,7 +206,6 @@ class FroniusSolarNet:
|
||||
logger=_LOGGER,
|
||||
name=_inverter_name,
|
||||
inverter_info=_inverter_info,
|
||||
config_entry=self.config_entry,
|
||||
)
|
||||
if self.config_entry.state == ConfigEntryState.LOADED:
|
||||
await _coordinator.async_refresh()
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250806.0"]
|
||||
"requirements": ["home-assistant-frontend==20250731.0"]
|
||||
}
|
||||
|
||||
@@ -123,10 +123,10 @@
|
||||
},
|
||||
"ai_task_data": {
|
||||
"initiate_flow": {
|
||||
"user": "Add AI task",
|
||||
"reconfigure": "Reconfigure AI task"
|
||||
"user": "Add Generate data with AI service",
|
||||
"reconfigure": "Reconfigure Generate data with AI service"
|
||||
},
|
||||
"entry_type": "AI task",
|
||||
"entry_type": "Generate data with AI service",
|
||||
"step": {
|
||||
"set_options": {
|
||||
"data": {
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/growatt_server",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["growattServer"],
|
||||
"requirements": ["growattServer==1.6.0"]
|
||||
"requirements": ["growattServer==1.7.1"]
|
||||
}
|
||||
|
||||
@@ -86,11 +86,9 @@ UNSUPPORTED_REASONS = {
|
||||
UNSUPPORTED_SKIP_REPAIR = {"privileged"}
|
||||
UNHEALTHY_REASONS = {
|
||||
"docker",
|
||||
"duplicate_os_installation",
|
||||
"oserror_bad_message",
|
||||
"privileged",
|
||||
"setup",
|
||||
"supervisor",
|
||||
"setup",
|
||||
"privileged",
|
||||
"untrusted",
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"healthy": "Healthy",
|
||||
"host_os": "Host operating system",
|
||||
"installed_addons": "Installed add-ons",
|
||||
"nameservers": "Nameservers",
|
||||
"supervisor_api": "Supervisor API",
|
||||
"supervisor_version": "Supervisor version",
|
||||
"supported": "Supported",
|
||||
@@ -116,43 +117,35 @@
|
||||
},
|
||||
"unhealthy": {
|
||||
"title": "Unhealthy system - {reason}",
|
||||
"description": "System is currently unhealthy due to {reason}. For troubleshooting information, select Learn more."
|
||||
"description": "System is currently unhealthy due to {reason}. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unhealthy_docker": {
|
||||
"title": "Unhealthy system - Docker misconfigured",
|
||||
"description": "System is currently unhealthy because Docker is configured incorrectly. For troubleshooting information, select Learn more."
|
||||
},
|
||||
"unhealthy_duplicate_os_installation": {
|
||||
"description": "System is currently unhealthy because it has detected multiple Home Assistant OS installations. For troubleshooting information, select Learn more.",
|
||||
"title": "Unhealthy system - Duplicate Home Assistant OS installation"
|
||||
},
|
||||
"unhealthy_oserror_bad_message": {
|
||||
"description": "System is currently unhealthy because the operating system has reported an OS error: Bad message. For troubleshooting information, select Learn more.",
|
||||
"title": "Unhealthy system - Operating System error: Bad message"
|
||||
},
|
||||
"unhealthy_privileged": {
|
||||
"title": "Unhealthy system - Not privileged",
|
||||
"description": "System is currently unhealthy because it does not have privileged access to the docker runtime. For troubleshooting information, select Learn more."
|
||||
},
|
||||
"unhealthy_setup": {
|
||||
"title": "Unhealthy system - Setup failed",
|
||||
"description": "System is currently unhealthy because setup failed to complete. There are a number of reasons this can occur, For troubleshooting information, select Learn more."
|
||||
"description": "System is currently unhealthy because Docker is configured incorrectly. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unhealthy_supervisor": {
|
||||
"title": "Unhealthy system - Supervisor update failed",
|
||||
"description": "System is currently unhealthy because an attempt to update Supervisor to the latest version has failed. For troubleshooting information, select Learn more."
|
||||
"description": "System is currently unhealthy because an attempt to update Supervisor to the latest version has failed. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unhealthy_setup": {
|
||||
"title": "Unhealthy system - Setup failed",
|
||||
"description": "System is currently unhealthy because setup failed to complete. There are a number of reasons this can occur, use the link to learn more and how to fix this."
|
||||
},
|
||||
"unhealthy_privileged": {
|
||||
"title": "Unhealthy system - Not privileged",
|
||||
"description": "System is currently unhealthy because it does not have privileged access to the docker runtime. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unhealthy_untrusted": {
|
||||
"title": "Unhealthy system - Untrusted code",
|
||||
"description": "System is currently unhealthy because it has detected untrusted code or images in use. For troubleshooting information, select Learn more."
|
||||
"description": "System is currently unhealthy because it has detected untrusted code or images in use. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported": {
|
||||
"title": "Unsupported system - {reason}",
|
||||
"description": "System is unsupported due to {reason}. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported due to {reason}. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_apparmor": {
|
||||
"title": "Unsupported system - AppArmor issues",
|
||||
"description": "System is unsupported because AppArmor is working incorrectly and add-ons are running in an unprotected and insecure way. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because AppArmor is working incorrectly and add-ons are running in an unprotected and insecure way. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_cgroup_version": {
|
||||
"title": "Unsupported system - CGroup version",
|
||||
@@ -160,23 +153,23 @@
|
||||
},
|
||||
"unsupported_connectivity_check": {
|
||||
"title": "Unsupported system - Connectivity check disabled",
|
||||
"description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_content_trust": {
|
||||
"title": "Unsupported system - Content-trust check disabled",
|
||||
"description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_dbus": {
|
||||
"title": "Unsupported system - D-Bus issues",
|
||||
"description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_dns_server": {
|
||||
"title": "Unsupported system - DNS server issues",
|
||||
"description": "System is unsupported because the provided DNS server does not work correctly and the fallback DNS option has been disabled. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because the provided DNS server does not work correctly and the fallback DNS option has been disabled. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_docker_configuration": {
|
||||
"title": "Unsupported system - Docker misconfigured",
|
||||
"description": "System is unsupported because the Docker daemon is running in an unexpected way. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because the Docker daemon is running in an unexpected way. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_docker_version": {
|
||||
"title": "Unsupported system - Docker version",
|
||||
@@ -184,15 +177,15 @@
|
||||
},
|
||||
"unsupported_job_conditions": {
|
||||
"title": "Unsupported system - Protections disabled",
|
||||
"description": "System is unsupported because one or more job conditions have been disabled which protect from unexpected failures and breakages. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because one or more job conditions have been disabled which protect from unexpected failures and breakages. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_lxc": {
|
||||
"title": "Unsupported system - LXC detected",
|
||||
"description": "System is unsupported because it is being run in an LXC virtual machine. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because it is being run in an LXC virtual machine. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_network_manager": {
|
||||
"title": "Unsupported system - Network Manager issues",
|
||||
"description": "System is unsupported because Network Manager is missing, inactive or misconfigured. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because Network Manager is missing, inactive or misconfigured. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_os": {
|
||||
"title": "Unsupported system - Operating System",
|
||||
@@ -200,43 +193,39 @@
|
||||
},
|
||||
"unsupported_os_agent": {
|
||||
"title": "Unsupported system - OS-Agent issues",
|
||||
"description": "System is unsupported because OS-Agent is missing, inactive or misconfigured. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because OS-Agent is missing, inactive or misconfigured. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_restart_policy": {
|
||||
"title": "Unsupported system - Container restart policy",
|
||||
"description": "System is unsupported because a Docker container has a restart policy set which could cause issues on startup. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because a Docker container has a restart policy set which could cause issues on startup. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_software": {
|
||||
"title": "Unsupported system - Unsupported software",
|
||||
"description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_source_mods": {
|
||||
"title": "Unsupported system - Supervisor source modifications",
|
||||
"description": "System is unsupported because Supervisor source code has been modified. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because Supervisor source code has been modified. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_supervisor_version": {
|
||||
"title": "Unsupported system - Supervisor version",
|
||||
"description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_systemd": {
|
||||
"title": "Unsupported system - Systemd issues",
|
||||
"description": "System is unsupported because Systemd is missing, inactive or misconfigured. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because Systemd is missing, inactive or misconfigured. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_systemd_journal": {
|
||||
"title": "Unsupported system - Systemd Journal issues",
|
||||
"description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_systemd_resolved": {
|
||||
"title": "Unsupported system - Systemd-Resolved issues",
|
||||
"description": "System is unsupported because Systemd Resolved is missing, inactive or misconfigured. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because Systemd Resolved is missing, inactive or misconfigured. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"unsupported_virtualization_image": {
|
||||
"title": "Unsupported system - Incorrect OS image for virtualization",
|
||||
"description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. For troubleshooting information, select Learn more."
|
||||
},
|
||||
"unsupported_os_version": {
|
||||
"title": "Unsupported system - Home Assistant OS version",
|
||||
"description": "System is unsupported because the Home Assistant OS version in use is not supported. For troubleshooting information, select Learn more."
|
||||
"description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. Use the link to learn more and how to fix this."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -54,6 +54,15 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"error": "Unsupported",
|
||||
}
|
||||
|
||||
nameservers = set()
|
||||
for interface in network_info.get("interfaces", []):
|
||||
if not interface.get("primary"):
|
||||
continue
|
||||
if ipv4 := interface.get("ipv4"):
|
||||
nameservers.update(ipv4.get("nameservers", []))
|
||||
if ipv6 := interface.get("ipv6"):
|
||||
nameservers.update(ipv6.get("nameservers", []))
|
||||
|
||||
information = {
|
||||
"host_os": host_info.get("operating_system"),
|
||||
"update_channel": info.get("channel"),
|
||||
@@ -62,6 +71,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"docker_version": info.get("docker"),
|
||||
"disk_total": f"{host_info.get('disk_total')} GB",
|
||||
"disk_used": f"{host_info.get('disk_used')} GB",
|
||||
"nameservers": ", ".join(nameservers),
|
||||
"healthy": healthy,
|
||||
"supported": supported,
|
||||
"host_connectivity": network_info.get("host_internet"),
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.78", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.77", "babel==2.15.0"]
|
||||
}
|
||||
|
||||
@@ -628,12 +628,12 @@ class HomeAccessory(Accessory): # type: ignore[misc]
|
||||
self,
|
||||
domain: str,
|
||||
service: str,
|
||||
service_data: dict[str, Any] | None,
|
||||
service_data: dict[str, Any],
|
||||
value: Any | None = None,
|
||||
) -> None:
|
||||
"""Fire event and call service for changes from HomeKit."""
|
||||
event_data = {
|
||||
ATTR_ENTITY_ID: self.entity_id,
|
||||
ATTR_ENTITY_ID: service_data.get(ATTR_ENTITY_ID, self.entity_id),
|
||||
ATTR_DISPLAY_NAME: self.display_name,
|
||||
ATTR_SERVICE: service,
|
||||
ATTR_VALUE: value,
|
||||
|
||||
@@ -57,6 +57,8 @@ CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor"
|
||||
CONF_LINKED_OBSTRUCTION_SENSOR = "linked_obstruction_sensor"
|
||||
CONF_LINKED_PM25_SENSOR = "linked_pm25_sensor"
|
||||
CONF_LINKED_TEMPERATURE_SENSOR = "linked_temperature_sensor"
|
||||
CONF_LINKED_VALVE_DURATION = "linked_valve_duration"
|
||||
CONF_LINKED_VALVE_END_TIME = "linked_valve_end_time"
|
||||
CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold"
|
||||
CONF_MAX_FPS = "max_fps"
|
||||
CONF_MAX_HEIGHT = "max_height"
|
||||
@@ -229,10 +231,12 @@ CHAR_ON = "On"
|
||||
CHAR_OUTLET_IN_USE = "OutletInUse"
|
||||
CHAR_POSITION_STATE = "PositionState"
|
||||
CHAR_PROGRAMMABLE_SWITCH_EVENT = "ProgrammableSwitchEvent"
|
||||
CHAR_REMAINING_DURATION = "RemainingDuration"
|
||||
CHAR_REMOTE_KEY = "RemoteKey"
|
||||
CHAR_ROTATION_DIRECTION = "RotationDirection"
|
||||
CHAR_ROTATION_SPEED = "RotationSpeed"
|
||||
CHAR_SATURATION = "Saturation"
|
||||
CHAR_SET_DURATION = "SetDuration"
|
||||
CHAR_SERIAL_NUMBER = "SerialNumber"
|
||||
CHAR_SERVICE_LABEL_INDEX = "ServiceLabelIndex"
|
||||
CHAR_SERVICE_LABEL_NAMESPACE = "ServiceLabelNamespace"
|
||||
|
||||
@@ -15,6 +15,11 @@ from pyhap.const import (
|
||||
)
|
||||
|
||||
from homeassistant.components import button, input_button
|
||||
from homeassistant.components.input_number import (
|
||||
ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE,
|
||||
DOMAIN as INPUT_NUMBER_DOMAIN,
|
||||
SERVICE_SET_VALUE as INPUT_NUMBER_SERVICE_SET_VALUE,
|
||||
)
|
||||
from homeassistant.components.input_select import ATTR_OPTIONS, SERVICE_SELECT_OPTION
|
||||
from homeassistant.components.lawn_mower import (
|
||||
DOMAIN as LAWN_MOWER_DOMAIN,
|
||||
@@ -45,6 +50,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State, callback, split_entity_id
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .accessories import TYPES, HomeAccessory, HomeDriver
|
||||
from .const import (
|
||||
@@ -54,7 +60,11 @@ from .const import (
|
||||
CHAR_NAME,
|
||||
CHAR_ON,
|
||||
CHAR_OUTLET_IN_USE,
|
||||
CHAR_REMAINING_DURATION,
|
||||
CHAR_SET_DURATION,
|
||||
CHAR_VALVE_TYPE,
|
||||
CONF_LINKED_VALVE_DURATION,
|
||||
CONF_LINKED_VALVE_END_TIME,
|
||||
SERV_OUTLET,
|
||||
SERV_SWITCH,
|
||||
SERV_VALVE,
|
||||
@@ -271,7 +281,21 @@ class ValveBase(HomeAccessory):
|
||||
self.on_service = on_service
|
||||
self.off_service = off_service
|
||||
|
||||
serv_valve = self.add_preload_service(SERV_VALVE)
|
||||
self.chars = []
|
||||
|
||||
self.linked_duration_entity: str | None = self.config.get(
|
||||
CONF_LINKED_VALVE_DURATION
|
||||
)
|
||||
self.linked_end_time_entity: str | None = self.config.get(
|
||||
CONF_LINKED_VALVE_END_TIME
|
||||
)
|
||||
|
||||
if self.linked_duration_entity:
|
||||
self.chars.append(CHAR_SET_DURATION)
|
||||
if self.linked_end_time_entity:
|
||||
self.chars.append(CHAR_REMAINING_DURATION)
|
||||
|
||||
serv_valve = self.add_preload_service(SERV_VALVE, self.chars)
|
||||
self.char_active = serv_valve.configure_char(
|
||||
CHAR_ACTIVE, value=False, setter_callback=self.set_state
|
||||
)
|
||||
@@ -279,6 +303,25 @@ class ValveBase(HomeAccessory):
|
||||
self.char_valve_type = serv_valve.configure_char(
|
||||
CHAR_VALVE_TYPE, value=VALVE_TYPE[valve_type].valve_type
|
||||
)
|
||||
|
||||
if CHAR_SET_DURATION in self.chars:
|
||||
_LOGGER.debug(
|
||||
"%s: Add characteristic %s", self.entity_id, CHAR_SET_DURATION
|
||||
)
|
||||
self.char_set_duration = serv_valve.configure_char(
|
||||
CHAR_SET_DURATION,
|
||||
value=self.get_duration(),
|
||||
setter_callback=self.set_duration,
|
||||
)
|
||||
|
||||
if CHAR_REMAINING_DURATION in self.chars:
|
||||
_LOGGER.debug(
|
||||
"%s: Add characteristic %s", self.entity_id, CHAR_REMAINING_DURATION
|
||||
)
|
||||
self.char_remaining_duration = serv_valve.configure_char(
|
||||
CHAR_REMAINING_DURATION, getter_callback=self.get_remaining_duration
|
||||
)
|
||||
|
||||
# Set the state so it is in sync on initial
|
||||
# GET to avoid an event storm after homekit startup
|
||||
self.async_update_state(state)
|
||||
@@ -294,12 +337,75 @@ class ValveBase(HomeAccessory):
|
||||
@callback
|
||||
def async_update_state(self, new_state: State) -> None:
|
||||
"""Update switch state after state changed."""
|
||||
self._update_duration_chars()
|
||||
current_state = 1 if new_state.state in self.open_states else 0
|
||||
_LOGGER.debug("%s: Set active state to %s", self.entity_id, current_state)
|
||||
self.char_active.set_value(current_state)
|
||||
_LOGGER.debug("%s: Set in_use state to %s", self.entity_id, current_state)
|
||||
self.char_in_use.set_value(current_state)
|
||||
|
||||
def _update_duration_chars(self) -> None:
|
||||
"""Update valve duration related properties if characteristics are available."""
|
||||
if CHAR_SET_DURATION in self.chars:
|
||||
self.char_set_duration.set_value(self.get_duration())
|
||||
if CHAR_REMAINING_DURATION in self.chars:
|
||||
self.char_remaining_duration.set_value(self.get_remaining_duration())
|
||||
|
||||
def set_duration(self, value: int) -> None:
|
||||
"""Set default duration for how long the valve should remain open."""
|
||||
_LOGGER.debug("%s: Set default run time to %s", self.entity_id, value)
|
||||
self.async_call_service(
|
||||
INPUT_NUMBER_DOMAIN,
|
||||
INPUT_NUMBER_SERVICE_SET_VALUE,
|
||||
{
|
||||
ATTR_ENTITY_ID: self.linked_duration_entity,
|
||||
INPUT_NUMBER_ATTR_VALUE: value,
|
||||
},
|
||||
value,
|
||||
)
|
||||
|
||||
def get_duration(self) -> int:
|
||||
"""Get the default duration from Home Assistant."""
|
||||
duration_state = self._get_entity_state(self.linked_duration_entity)
|
||||
if duration_state is None:
|
||||
_LOGGER.debug(
|
||||
"%s: No linked duration entity state available", self.entity_id
|
||||
)
|
||||
return 0
|
||||
|
||||
try:
|
||||
duration = float(duration_state)
|
||||
return max(int(duration), 0)
|
||||
except ValueError:
|
||||
_LOGGER.debug("%s: Cannot parse linked duration entity", self.entity_id)
|
||||
return 0
|
||||
|
||||
def get_remaining_duration(self) -> int:
|
||||
"""Calculate the remaining duration based on end time in Home Assistant."""
|
||||
end_time_state = self._get_entity_state(self.linked_end_time_entity)
|
||||
if end_time_state is None:
|
||||
_LOGGER.debug(
|
||||
"%s: No linked end time entity state available", self.entity_id
|
||||
)
|
||||
return self.get_duration()
|
||||
|
||||
end_time = dt_util.parse_datetime(end_time_state)
|
||||
if end_time is None:
|
||||
_LOGGER.debug("%s: Cannot parse linked end time entity", self.entity_id)
|
||||
return self.get_duration()
|
||||
|
||||
remaining_time = (end_time - dt_util.utcnow()).total_seconds()
|
||||
return max(int(remaining_time), 0)
|
||||
|
||||
def _get_entity_state(self, entity_id: str | None) -> str | None:
|
||||
"""Fetch the state of a linked entity."""
|
||||
if entity_id is None:
|
||||
return None
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state is None:
|
||||
return None
|
||||
return state.state
|
||||
|
||||
|
||||
@TYPES.register("ValveSwitch")
|
||||
class ValveSwitch(ValveBase):
|
||||
|
||||
@@ -17,6 +17,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import (
|
||||
binary_sensor,
|
||||
input_number,
|
||||
media_player,
|
||||
persistent_notification,
|
||||
sensor,
|
||||
@@ -69,6 +70,8 @@ from .const import (
|
||||
CONF_LINKED_OBSTRUCTION_SENSOR,
|
||||
CONF_LINKED_PM25_SENSOR,
|
||||
CONF_LINKED_TEMPERATURE_SENSOR,
|
||||
CONF_LINKED_VALVE_DURATION,
|
||||
CONF_LINKED_VALVE_END_TIME,
|
||||
CONF_LOW_BATTERY_THRESHOLD,
|
||||
CONF_MAX_FPS,
|
||||
CONF_MAX_HEIGHT,
|
||||
@@ -266,7 +269,9 @@ SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
||||
TYPE_VALVE,
|
||||
)
|
||||
),
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain(input_number.DOMAIN),
|
||||
vol.Optional(CONF_LINKED_VALVE_END_TIME): cv.entity_domain(sensor.DOMAIN),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -277,6 +282,12 @@ SENSOR_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
||||
}
|
||||
)
|
||||
|
||||
VALVE_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain(input_number.DOMAIN),
|
||||
vol.Optional(CONF_LINKED_VALVE_END_TIME): cv.entity_domain(sensor.DOMAIN),
|
||||
}
|
||||
)
|
||||
|
||||
HOMEKIT_CHAR_TRANSLATIONS = {
|
||||
0: " ", # nul
|
||||
@@ -360,6 +371,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]:
|
||||
elif domain == "sensor":
|
||||
config = SENSOR_SCHEMA(config)
|
||||
|
||||
elif domain == "valve":
|
||||
config = VALVE_SCHEMA(config)
|
||||
|
||||
else:
|
||||
config = BASIC_INFO_SCHEMA(config)
|
||||
|
||||
|
||||
@@ -283,19 +283,19 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity):
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return if the cover is closed."""
|
||||
return self._device.doorState == DoorState.CLOSED
|
||||
return self.functional_channel.doorState == DoorState.CLOSED
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
await self._device.send_door_command_async(DoorCommand.OPEN)
|
||||
await self.functional_channel.async_send_door_command(DoorCommand.OPEN)
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close the cover."""
|
||||
await self._device.send_door_command_async(DoorCommand.CLOSE)
|
||||
await self.functional_channel.async_send_door_command(DoorCommand.CLOSE)
|
||||
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
await self._device.send_door_command_async(DoorCommand.STOP)
|
||||
await self.functional_channel.async_send_door_command(DoorCommand.STOP)
|
||||
|
||||
|
||||
class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity):
|
||||
|
||||
@@ -163,7 +163,6 @@ async def async_setup_entry(
|
||||
name="light",
|
||||
update_method=partial(async_safe_fetch, bridge, bridge.api.lights.update),
|
||||
update_interval=SCAN_INTERVAL,
|
||||
config_entry=config_entry,
|
||||
request_refresh_debouncer=Debouncer(
|
||||
bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True
|
||||
),
|
||||
@@ -198,7 +197,6 @@ async def async_setup_entry(
|
||||
name="group",
|
||||
update_method=partial(async_safe_fetch, bridge, bridge.api.groups.update),
|
||||
update_interval=SCAN_INTERVAL,
|
||||
config_entry=config_entry,
|
||||
request_refresh_debouncer=Debouncer(
|
||||
bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True
|
||||
),
|
||||
|
||||
@@ -53,7 +53,6 @@ class SensorManager:
|
||||
LOGGER,
|
||||
name="sensor",
|
||||
update_method=self.async_update_data,
|
||||
config_entry=bridge.config_entry,
|
||||
update_interval=self.SCAN_INTERVAL,
|
||||
request_refresh_debouncer=debounce.Debouncer(
|
||||
bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2.1.2"]
|
||||
"requirements": ["aioautomower==2.1.1"]
|
||||
}
|
||||
|
||||
@@ -71,10 +71,10 @@ ERROR_KEYS = [
|
||||
"cutting_drive_motor_2_defect",
|
||||
"cutting_drive_motor_3_defect",
|
||||
"cutting_height_blocked",
|
||||
"cutting_height_problem",
|
||||
"cutting_height_problem_curr",
|
||||
"cutting_height_problem_dir",
|
||||
"cutting_height_problem_drive",
|
||||
"cutting_height_problem",
|
||||
"cutting_motor_problem",
|
||||
"cutting_stopped_slope_too_steep",
|
||||
"cutting_system_blocked",
|
||||
@@ -117,6 +117,7 @@ ERROR_KEYS = [
|
||||
"no_accurate_position_from_satellites",
|
||||
"no_confirmed_position",
|
||||
"no_drive",
|
||||
"no_error",
|
||||
"no_loop_signal",
|
||||
"no_power_in_charging_station",
|
||||
"no_response_from_charger",
|
||||
@@ -168,8 +169,8 @@ ERROR_KEYS = [
|
||||
]
|
||||
|
||||
|
||||
ERROR_KEY_LIST = sorted(
|
||||
set(ERROR_KEYS) | {state.lower() for state in ERROR_STATES} | {"no_error"}
|
||||
ERROR_KEY_LIST = list(
|
||||
dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES])
|
||||
)
|
||||
|
||||
INACTIVE_REASONS: list = [
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["imgw_pib==1.5.2"]
|
||||
"requirements": ["imgw_pib==1.5.1"]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"requirements": [
|
||||
"xknx==3.8.0",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2025.8.6.52906"
|
||||
"knx-frontend==2025.7.23.50952"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -135,7 +135,6 @@ class KrakenData:
|
||||
self._hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
config_entry=self._config_entry,
|
||||
update_method=self.async_update,
|
||||
update_interval=timedelta(
|
||||
seconds=self._config_entry.options[CONF_SCAN_INTERVAL]
|
||||
|
||||
@@ -285,9 +285,7 @@ DISCOVERY_SCHEMAS = [
|
||||
native_min_value=0.5,
|
||||
native_step=0.5,
|
||||
device_to_ha=(
|
||||
lambda x: None
|
||||
if x is None
|
||||
else min(x, 200) / 2 # Matter range (1-200, capped at 200)
|
||||
lambda x: None if x is None else x / 2 # Matter range (1-200)
|
||||
),
|
||||
ha_to_device=lambda x: round(x * 2), # HA range 0.5–100.0%
|
||||
mode=NumberMode.SLIDER,
|
||||
|
||||
@@ -140,6 +140,11 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
def _update_from_device(self) -> None:
|
||||
"""Update from device."""
|
||||
self._calculate_features()
|
||||
# optional battery level
|
||||
if VacuumEntityFeature.BATTERY & self._attr_supported_features:
|
||||
self._attr_battery_level = self.get_matter_attribute_value(
|
||||
clusters.PowerSource.Attributes.BatPercentRemaining
|
||||
)
|
||||
# derive state from the run mode + operational state
|
||||
run_mode_raw: int = self.get_matter_attribute_value(
|
||||
clusters.RvcRunMode.Attributes.CurrentMode
|
||||
@@ -183,6 +188,11 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
supported_features |= VacuumEntityFeature.STATE
|
||||
supported_features |= VacuumEntityFeature.STOP
|
||||
|
||||
# optional battery attribute = battery feature
|
||||
if self.get_matter_attribute_value(
|
||||
clusters.PowerSource.Attributes.BatPercentRemaining
|
||||
):
|
||||
supported_features |= VacuumEntityFeature.BATTERY
|
||||
# optional identify cluster = locate feature (value must be not None or 0)
|
||||
if self.get_matter_attribute_value(clusters.Identify.Attributes.IdentifyType):
|
||||
supported_features |= VacuumEntityFeature.LOCATE
|
||||
@@ -220,6 +230,7 @@ DISCOVERY_SCHEMAS = [
|
||||
clusters.RvcRunMode.Attributes.CurrentMode,
|
||||
clusters.RvcOperationalState.Attributes.OperationalState,
|
||||
),
|
||||
optional_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,),
|
||||
device_type=(device_types.RoboticVacuumCleaner,),
|
||||
allow_none_value=True,
|
||||
),
|
||||
|
||||
@@ -130,7 +130,6 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity):
|
||||
list_id=self._shopping_list_id,
|
||||
note=item.summary.strip() if item.summary else item.summary,
|
||||
position=position,
|
||||
quantity=0.0,
|
||||
)
|
||||
try:
|
||||
await self.coordinator.client.add_shopping_item(new_shopping_item)
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2025.07.21"],
|
||||
"requirements": ["yt-dlp[default]==2025.06.09"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"Météo-France forecast for city {entry.title}",
|
||||
config_entry=entry,
|
||||
update_method=_async_update_data_forecast_forecast,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
@@ -81,7 +80,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"Météo-France rain for city {entry.title}",
|
||||
config_entry=entry,
|
||||
update_method=_async_update_data_rain,
|
||||
update_interval=SCAN_INTERVAL_RAIN,
|
||||
)
|
||||
@@ -105,7 +103,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"Météo-France alert for department {department}",
|
||||
config_entry=entry,
|
||||
update_method=_async_update_data_alert,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
|
||||
@@ -43,7 +43,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
historic_data_coordinator = MillHistoricDataUpdateCoordinator(
|
||||
hass,
|
||||
entry,
|
||||
mill_data_connection=mill_data_connection,
|
||||
)
|
||||
historic_data_coordinator.async_add_listener(lambda: None)
|
||||
|
||||
@@ -60,7 +60,6 @@ class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
*,
|
||||
mill_data_connection: Mill,
|
||||
) -> None:
|
||||
@@ -71,7 +70,6 @@ class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="MillHistoricDataUpdateCoordinator",
|
||||
config_entry=config_entry,
|
||||
)
|
||||
|
||||
async def _async_update_data(self):
|
||||
|
||||
@@ -289,17 +289,23 @@ class MotionTiltDevice(MotionPositionDevice):
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Set_angle, 180)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Close the cover tilt."""
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Set_angle, 0)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover tilt to a specific position."""
|
||||
angle = kwargs[ATTR_TILT_POSITION] * 180 / 100
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Set_angle, angle)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
async with self._api_lock:
|
||||
@@ -360,11 +366,15 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Open)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Close the cover tilt."""
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Close)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover tilt to a specific position."""
|
||||
angle = kwargs[ATTR_TILT_POSITION]
|
||||
@@ -376,6 +386,8 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Set_position, angle)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
async def async_set_absolute_position(self, **kwargs):
|
||||
"""Move the cover to a specific absolute position (see TDBU)."""
|
||||
angle = kwargs.get(ATTR_TILT_POSITION)
|
||||
@@ -390,6 +402,8 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Set_position, angle)
|
||||
|
||||
await self.async_request_position_till_stop()
|
||||
|
||||
|
||||
class MotionTDBUDevice(MotionBaseDevice):
|
||||
"""Representation of a Motion Top Down Bottom Up blind Device."""
|
||||
|
||||
@@ -42,6 +42,7 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind
|
||||
|
||||
self._requesting_position: CALLBACK_TYPE | None = None
|
||||
self._previous_positions: list[int | dict | None] = []
|
||||
self._previous_angles: list[int | None] = []
|
||||
|
||||
if blind.device_type in DEVICE_TYPES_WIFI:
|
||||
self._update_interval_moving = UPDATE_INTERVAL_MOVING_WIFI
|
||||
@@ -112,17 +113,27 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind
|
||||
"""Request a state update from the blind at a scheduled point in time."""
|
||||
# add the last position to the list and keep the list at max 2 items
|
||||
self._previous_positions.append(self._blind.position)
|
||||
self._previous_angles.append(self._blind.angle)
|
||||
if len(self._previous_positions) > 2:
|
||||
del self._previous_positions[: len(self._previous_positions) - 2]
|
||||
if len(self._previous_angles) > 2:
|
||||
del self._previous_angles[: len(self._previous_angles) - 2]
|
||||
|
||||
async with self._api_lock:
|
||||
await self.hass.async_add_executor_job(self._blind.Update_trigger)
|
||||
|
||||
self.coordinator.async_update_listeners()
|
||||
|
||||
if len(self._previous_positions) < 2 or not all(
|
||||
self._blind.position == prev_position
|
||||
for prev_position in self._previous_positions
|
||||
if (
|
||||
len(self._previous_positions) < 2
|
||||
or not all(
|
||||
self._blind.position == prev_position
|
||||
for prev_position in self._previous_positions
|
||||
)
|
||||
or len(self._previous_angles) < 2
|
||||
or not all(
|
||||
self._blind.angle == prev_angle for prev_angle in self._previous_angles
|
||||
)
|
||||
):
|
||||
# keep updating the position @self._update_interval_moving until the position does not change.
|
||||
self._requesting_position = async_call_later(
|
||||
@@ -132,6 +143,7 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind
|
||||
)
|
||||
else:
|
||||
self._previous_positions = []
|
||||
self._previous_angles = []
|
||||
self._requesting_position = None
|
||||
|
||||
async def async_request_position_till_stop(self, delay: int | None = None) -> None:
|
||||
@@ -140,7 +152,8 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind
|
||||
delay = self._update_interval_moving
|
||||
|
||||
self._previous_positions = []
|
||||
if self._blind.position is None:
|
||||
self._previous_angles = []
|
||||
if self._blind.position is None and self._blind.angle is None:
|
||||
return
|
||||
if self._requesting_position is not None:
|
||||
self._requesting_position()
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
{
|
||||
"issues": {
|
||||
"deprecated_vacuum_battery_feature": {
|
||||
"title": "Deprecated battery feature used",
|
||||
"description": "Vacuum entity {entity_id} implements the battery feature which is deprecated. This will stop working in Home Assistant 2026.2. Implement a separate entity for the battery state instead. To fix the issue, remove the `battery` feature from the configured supported features, and restart Home Assistant."
|
||||
},
|
||||
"invalid_platform_config": {
|
||||
"title": "Invalid config found for MQTT {domain} item",
|
||||
"description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue."
|
||||
|
||||
@@ -17,7 +17,7 @@ from homeassistant.components.vacuum import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolSchemaType
|
||||
@@ -25,11 +25,11 @@ from homeassistant.util.json import json_loads_object
|
||||
|
||||
from . import subscription
|
||||
from .config import MQTT_BASE_SCHEMA
|
||||
from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, DOMAIN
|
||||
from .entity import IssueSeverity, MqttEntity, async_setup_entity_entry_helper
|
||||
from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC
|
||||
from .entity import MqttEntity, async_setup_entity_entry_helper
|
||||
from .models import ReceiveMessage
|
||||
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
|
||||
from .util import learn_more_url, valid_publish_topic
|
||||
from .util import valid_publish_topic
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -84,8 +84,6 @@ SERVICE_TO_STRING: dict[VacuumEntityFeature, str] = {
|
||||
VacuumEntityFeature.STOP: "stop",
|
||||
VacuumEntityFeature.RETURN_HOME: "return_home",
|
||||
VacuumEntityFeature.FAN_SPEED: "fan_speed",
|
||||
# Use of the battery feature was deprecated in HA Core 2025.8
|
||||
# and will be removed with HA Core 2026.2
|
||||
VacuumEntityFeature.BATTERY: "battery",
|
||||
VacuumEntityFeature.STATUS: "status",
|
||||
VacuumEntityFeature.SEND_COMMAND: "send_command",
|
||||
@@ -98,6 +96,7 @@ DEFAULT_SERVICES = (
|
||||
VacuumEntityFeature.START
|
||||
| VacuumEntityFeature.STOP
|
||||
| VacuumEntityFeature.RETURN_HOME
|
||||
| VacuumEntityFeature.BATTERY
|
||||
| VacuumEntityFeature.CLEAN_SPOT
|
||||
)
|
||||
ALL_SERVICES = (
|
||||
@@ -252,35 +251,10 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
|
||||
)
|
||||
}
|
||||
|
||||
async def mqtt_async_added_to_hass(self) -> None:
|
||||
"""Check for use of deprecated battery features."""
|
||||
if self.supported_features & VacuumEntityFeature.BATTERY:
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_vacuum_battery_feature_{self.entity_id}",
|
||||
issue_domain=vacuum.DOMAIN,
|
||||
breaks_in_ha_version="2026.2",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
learn_more_url=learn_more_url(vacuum.DOMAIN),
|
||||
translation_placeholders={"entity_id": self.entity_id},
|
||||
translation_key="deprecated_vacuum_battery_feature",
|
||||
)
|
||||
_LOGGER.warning(
|
||||
"MQTT vacuum entity %s implements the battery feature "
|
||||
"which is deprecated. This will stop working "
|
||||
"in Home Assistant 2026.2. Implement a separate entity "
|
||||
"for the battery status instead",
|
||||
self.entity_id,
|
||||
)
|
||||
|
||||
def _update_state_attributes(self, payload: dict[str, Any]) -> None:
|
||||
"""Update the entity state attributes."""
|
||||
self._state_attrs.update(payload)
|
||||
self._attr_fan_speed = self._state_attrs.get(FAN_SPEED, 0)
|
||||
# Use of the battery feature was deprecated in HA Core 2025.8
|
||||
# and will be removed with HA Core 2026.2
|
||||
self._attr_battery_level = max(0, min(100, self._state_attrs.get(BATTERY, 0)))
|
||||
|
||||
@callback
|
||||
|
||||
@@ -92,15 +92,11 @@ async def async_update_options(hass: HomeAssistant, entry: OllamaConfigEntry) ->
|
||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
"""Migrate integration entry structure."""
|
||||
|
||||
# Make sure we get enabled config entries first
|
||||
entries = sorted(
|
||||
hass.config_entries.async_entries(DOMAIN),
|
||||
key=lambda e: e.disabled_by is not None,
|
||||
)
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
if not any(entry.version == 1 for entry in entries):
|
||||
return
|
||||
|
||||
url_entries: dict[str, tuple[ConfigEntry, bool]] = {}
|
||||
api_keys_entries: dict[str, ConfigEntry] = {}
|
||||
entity_registry = er.async_get(hass)
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
@@ -116,64 +112,33 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
title=entry.title,
|
||||
unique_id=None,
|
||||
)
|
||||
if entry.data[CONF_URL] not in url_entries:
|
||||
if entry.data[CONF_URL] not in api_keys_entries:
|
||||
use_existing = True
|
||||
all_disabled = all(
|
||||
e.disabled_by is not None
|
||||
for e in entries
|
||||
if e.data[CONF_URL] == entry.data[CONF_URL]
|
||||
)
|
||||
url_entries[entry.data[CONF_URL]] = (entry, all_disabled)
|
||||
api_keys_entries[entry.data[CONF_URL]] = entry
|
||||
|
||||
parent_entry, all_disabled = url_entries[entry.data[CONF_URL]]
|
||||
parent_entry = api_keys_entries[entry.data[CONF_URL]]
|
||||
|
||||
hass.config_entries.async_add_subentry(parent_entry, subentry)
|
||||
|
||||
conversation_entity_id = entity_registry.async_get_entity_id(
|
||||
conversation_entity = entity_registry.async_get_entity_id(
|
||||
"conversation",
|
||||
DOMAIN,
|
||||
entry.entry_id,
|
||||
)
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, entry.entry_id)}
|
||||
)
|
||||
|
||||
if conversation_entity_id is not None:
|
||||
conversation_entity_entry = entity_registry.entities[conversation_entity_id]
|
||||
entity_disabled_by = conversation_entity_entry.disabled_by
|
||||
if (
|
||||
entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY
|
||||
and not all_disabled
|
||||
):
|
||||
# Device and entity registries don't update the disabled_by flag
|
||||
# when moving a device or entity from one config entry to another,
|
||||
# so we need to do it manually.
|
||||
entity_disabled_by = (
|
||||
er.RegistryEntryDisabler.DEVICE
|
||||
if device
|
||||
else er.RegistryEntryDisabler.USER
|
||||
)
|
||||
if conversation_entity is not None:
|
||||
entity_registry.async_update_entity(
|
||||
conversation_entity_id,
|
||||
conversation_entity,
|
||||
config_entry_id=parent_entry.entry_id,
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
disabled_by=entity_disabled_by,
|
||||
new_unique_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, entry.entry_id)}
|
||||
)
|
||||
if device is not None:
|
||||
# Device and entity registries don't update the disabled_by flag when
|
||||
# moving a device or entity from one config entry to another, so we
|
||||
# need to do it manually.
|
||||
device_disabled_by = device.disabled_by
|
||||
if (
|
||||
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
||||
and not all_disabled
|
||||
):
|
||||
device_disabled_by = dr.DeviceEntryDisabler.USER
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
disabled_by=device_disabled_by,
|
||||
new_identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
add_config_subentry_id=subentry.subentry_id,
|
||||
add_config_entry_id=parent_entry.entry_id,
|
||||
@@ -193,7 +158,6 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
if not use_existing:
|
||||
await hass.config_entries.async_remove(entry.entry_id)
|
||||
else:
|
||||
_add_ai_task_subentry(hass, entry)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
title=DEFAULT_NAME,
|
||||
@@ -201,7 +165,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
data={CONF_URL: entry.data[CONF_URL]},
|
||||
options={},
|
||||
version=3,
|
||||
minor_version=3,
|
||||
minor_version=1,
|
||||
)
|
||||
|
||||
|
||||
@@ -247,69 +211,32 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) ->
|
||||
)
|
||||
|
||||
if entry.version == 3 and entry.minor_version == 1:
|
||||
_add_ai_task_subentry(hass, entry)
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
if entry.version == 3 and entry.minor_version == 2:
|
||||
# Fix migration where the disabled_by flag was not set correctly.
|
||||
# We can currently only correct this for enabled config entries,
|
||||
# because migration does not run for disabled config entries. This
|
||||
# is asserted in tests, and if that behavior is changed, we should
|
||||
# correct also disabled config entries.
|
||||
device_registry = dr.async_get(hass)
|
||||
entity_registry = er.async_get(hass)
|
||||
devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
|
||||
entity_entries = er.async_entries_for_config_entry(
|
||||
entity_registry, entry.entry_id
|
||||
# Add AI Task subentry with default options. We can only create a new
|
||||
# subentry if we can find an existing model in the entry. The model
|
||||
# was removed in the previous migration step, so we need to
|
||||
# check the subentries for an existing model.
|
||||
existing_model = next(
|
||||
iter(
|
||||
model
|
||||
for subentry in entry.subentries.values()
|
||||
if (model := subentry.data.get(CONF_MODEL)) is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
if entry.disabled_by is None:
|
||||
# If the config entry is not disabled, we need to set the disabled_by
|
||||
# flag on devices to USER, and on entities to DEVICE, if they are set
|
||||
# to CONFIG_ENTRY.
|
||||
for device in devices:
|
||||
if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY:
|
||||
continue
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
disabled_by=dr.DeviceEntryDisabler.USER,
|
||||
)
|
||||
for entity in entity_entries:
|
||||
if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY:
|
||||
continue
|
||||
entity_registry.async_update_entity(
|
||||
entity.entity_id,
|
||||
disabled_by=er.RegistryEntryDisabler.DEVICE,
|
||||
)
|
||||
hass.config_entries.async_update_entry(entry, minor_version=3)
|
||||
if existing_model:
|
||||
hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
ConfigSubentry(
|
||||
data=MappingProxyType({CONF_MODEL: existing_model}),
|
||||
subentry_type="ai_task_data",
|
||||
title=DEFAULT_AI_TASK_NAME,
|
||||
unique_id=None,
|
||||
),
|
||||
)
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _add_ai_task_subentry(hass: HomeAssistant, entry: OllamaConfigEntry) -> None:
|
||||
"""Add AI Task subentry to the config entry."""
|
||||
# Add AI Task subentry with default options. We can only create a new
|
||||
# subentry if we can find an existing model in the entry. The model
|
||||
# was removed in the previous migration step, so we need to
|
||||
# check the subentries for an existing model.
|
||||
existing_model = next(
|
||||
iter(
|
||||
model
|
||||
for subentry in entry.subentries.values()
|
||||
if (model := subentry.data.get(CONF_MODEL)) is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing_model:
|
||||
hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
ConfigSubentry(
|
||||
data=MappingProxyType({CONF_MODEL: existing_model}),
|
||||
subentry_type="ai_task_data",
|
||||
title=DEFAULT_AI_TASK_NAME,
|
||||
unique_id=None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -76,7 +76,7 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Ollama."""
|
||||
|
||||
VERSION = 3
|
||||
MINOR_VERSION = 3
|
||||
MINOR_VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize config flow."""
|
||||
|
||||
@@ -58,10 +58,10 @@
|
||||
},
|
||||
"ai_task_data": {
|
||||
"initiate_flow": {
|
||||
"user": "Add AI task",
|
||||
"reconfigure": "Reconfigure AI task"
|
||||
"user": "Add Generate data with AI service",
|
||||
"reconfigure": "Reconfigure Generate data with AI service"
|
||||
},
|
||||
"entry_type": "AI task",
|
||||
"entry_type": "Generate data with AI service",
|
||||
"step": {
|
||||
"set_options": {
|
||||
"data": {
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["openai==1.93.3", "python-open-router==0.3.1"]
|
||||
"requirements": ["openai==1.93.3", "python-open-router==0.3.0"]
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@
|
||||
}
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add AI task"
|
||||
"user": "Add Generate data with AI service"
|
||||
},
|
||||
"entry_type": "AI task",
|
||||
"entry_type": "Generate data with AI service",
|
||||
"abort": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
|
||||
@@ -272,15 +272,11 @@ async def async_update_options(hass: HomeAssistant, entry: OpenAIConfigEntry) ->
|
||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
"""Migrate integration entry structure."""
|
||||
|
||||
# Make sure we get enabled config entries first
|
||||
entries = sorted(
|
||||
hass.config_entries.async_entries(DOMAIN),
|
||||
key=lambda e: e.disabled_by is not None,
|
||||
)
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
if not any(entry.version == 1 for entry in entries):
|
||||
return
|
||||
|
||||
api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {}
|
||||
api_keys_entries: dict[str, ConfigEntry] = {}
|
||||
entity_registry = er.async_get(hass)
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
@@ -294,61 +290,30 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
)
|
||||
if entry.data[CONF_API_KEY] not in api_keys_entries:
|
||||
use_existing = True
|
||||
all_disabled = all(
|
||||
e.disabled_by is not None
|
||||
for e in entries
|
||||
if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY]
|
||||
)
|
||||
api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled)
|
||||
api_keys_entries[entry.data[CONF_API_KEY]] = entry
|
||||
|
||||
parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]]
|
||||
parent_entry = api_keys_entries[entry.data[CONF_API_KEY]]
|
||||
|
||||
hass.config_entries.async_add_subentry(parent_entry, subentry)
|
||||
conversation_entity_id = entity_registry.async_get_entity_id(
|
||||
conversation_entity = entity_registry.async_get_entity_id(
|
||||
"conversation",
|
||||
DOMAIN,
|
||||
entry.entry_id,
|
||||
)
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, entry.entry_id)}
|
||||
)
|
||||
|
||||
if conversation_entity_id is not None:
|
||||
conversation_entity_entry = entity_registry.entities[conversation_entity_id]
|
||||
entity_disabled_by = conversation_entity_entry.disabled_by
|
||||
if (
|
||||
entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY
|
||||
and not all_disabled
|
||||
):
|
||||
# Device and entity registries don't update the disabled_by flag
|
||||
# when moving a device or entity from one config entry to another,
|
||||
# so we need to do it manually.
|
||||
entity_disabled_by = (
|
||||
er.RegistryEntryDisabler.DEVICE
|
||||
if device
|
||||
else er.RegistryEntryDisabler.USER
|
||||
)
|
||||
if conversation_entity is not None:
|
||||
entity_registry.async_update_entity(
|
||||
conversation_entity_id,
|
||||
conversation_entity,
|
||||
config_entry_id=parent_entry.entry_id,
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
disabled_by=entity_disabled_by,
|
||||
new_unique_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, entry.entry_id)}
|
||||
)
|
||||
if device is not None:
|
||||
# Device and entity registries don't update the disabled_by flag when
|
||||
# moving a device or entity from one config entry to another, so we
|
||||
# need to do it manually.
|
||||
device_disabled_by = device.disabled_by
|
||||
if (
|
||||
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
||||
and not all_disabled
|
||||
):
|
||||
device_disabled_by = dr.DeviceEntryDisabler.USER
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
disabled_by=device_disabled_by,
|
||||
new_identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
add_config_subentry_id=subentry.subentry_id,
|
||||
add_config_entry_id=parent_entry.entry_id,
|
||||
@@ -368,13 +333,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
if not use_existing:
|
||||
await hass.config_entries.async_remove(entry.entry_id)
|
||||
else:
|
||||
_add_ai_task_subentry(hass, entry)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
title=DEFAULT_NAME,
|
||||
options={},
|
||||
version=2,
|
||||
minor_version=4,
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
|
||||
@@ -401,56 +365,19 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) ->
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 2:
|
||||
_add_ai_task_subentry(hass, entry)
|
||||
hass.config_entries.async_update_entry(entry, minor_version=3)
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 3:
|
||||
# Fix migration where the disabled_by flag was not set correctly.
|
||||
# We can currently only correct this for enabled config entries,
|
||||
# because migration does not run for disabled config entries. This
|
||||
# is asserted in tests, and if that behavior is changed, we should
|
||||
# correct also disabled config entries.
|
||||
device_registry = dr.async_get(hass)
|
||||
entity_registry = er.async_get(hass)
|
||||
devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
|
||||
entity_entries = er.async_entries_for_config_entry(
|
||||
entity_registry, entry.entry_id
|
||||
hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
ConfigSubentry(
|
||||
data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS),
|
||||
subentry_type="ai_task_data",
|
||||
title=DEFAULT_AI_TASK_NAME,
|
||||
unique_id=None,
|
||||
),
|
||||
)
|
||||
if entry.disabled_by is None:
|
||||
# If the config entry is not disabled, we need to set the disabled_by
|
||||
# flag on devices to USER, and on entities to DEVICE, if they are set
|
||||
# to CONFIG_ENTRY.
|
||||
for device in devices:
|
||||
if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY:
|
||||
continue
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
disabled_by=dr.DeviceEntryDisabler.USER,
|
||||
)
|
||||
for entity in entity_entries:
|
||||
if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY:
|
||||
continue
|
||||
entity_registry.async_update_entity(
|
||||
entity.entity_id,
|
||||
disabled_by=er.RegistryEntryDisabler.DEVICE,
|
||||
)
|
||||
hass.config_entries.async_update_entry(entry, minor_version=4)
|
||||
hass.config_entries.async_update_entry(entry, minor_version=3)
|
||||
|
||||
LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _add_ai_task_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None:
|
||||
"""Add AI Task subentry to the config entry."""
|
||||
hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
ConfigSubentry(
|
||||
data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS),
|
||||
subentry_type="ai_task_data",
|
||||
title=DEFAULT_AI_TASK_NAME,
|
||||
unique_id=None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -98,7 +98,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for OpenAI Conversation."""
|
||||
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 4
|
||||
MINOR_VERSION = 3
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
|
||||
@@ -73,10 +73,10 @@
|
||||
},
|
||||
"ai_task_data": {
|
||||
"initiate_flow": {
|
||||
"user": "Add AI task",
|
||||
"reconfigure": "Reconfigure AI task"
|
||||
"user": "Add Generate data with AI service",
|
||||
"reconfigure": "Reconfigure Generate data with AI service"
|
||||
},
|
||||
"entry_type": "AI task",
|
||||
"entry_type": "Generate data with AI service",
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
|
||||
@@ -9,8 +9,6 @@ from typing import Any
|
||||
from opower import (
|
||||
CannotConnect,
|
||||
InvalidAuth,
|
||||
MfaChallenge,
|
||||
MfaHandlerBase,
|
||||
Opower,
|
||||
create_cookie_jar,
|
||||
get_supported_utility_names,
|
||||
@@ -18,34 +16,49 @@ from opower import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
|
||||
from .const import CONF_LOGIN_DATA, CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN
|
||||
from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_MFA_CODE = "mfa_code"
|
||||
CONF_MFA_METHOD = "mfa_method"
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_UTILITY): vol.In(get_supported_utility_names()),
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _validate_login(
|
||||
hass: HomeAssistant,
|
||||
data: Mapping[str, Any],
|
||||
) -> None:
|
||||
"""Validate login data and raise exceptions on failure."""
|
||||
hass: HomeAssistant, login_data: dict[str, str]
|
||||
) -> dict[str, str]:
|
||||
"""Validate login data and return any errors."""
|
||||
api = Opower(
|
||||
async_create_clientsession(hass, cookie_jar=create_cookie_jar()),
|
||||
data[CONF_UTILITY],
|
||||
data[CONF_USERNAME],
|
||||
data[CONF_PASSWORD],
|
||||
data.get(CONF_TOTP_SECRET),
|
||||
data.get(CONF_LOGIN_DATA),
|
||||
login_data[CONF_UTILITY],
|
||||
login_data[CONF_USERNAME],
|
||||
login_data[CONF_PASSWORD],
|
||||
login_data.get(CONF_TOTP_SECRET),
|
||||
)
|
||||
await api.async_login()
|
||||
errors: dict[str, str] = {}
|
||||
try:
|
||||
await api.async_login()
|
||||
except InvalidAuth:
|
||||
_LOGGER.exception(
|
||||
"Invalid auth when connecting to %s", login_data[CONF_UTILITY]
|
||||
)
|
||||
errors["base"] = "invalid_auth"
|
||||
except CannotConnect:
|
||||
_LOGGER.exception("Could not connect to %s", login_data[CONF_UTILITY])
|
||||
errors["base"] = "cannot_connect"
|
||||
return errors
|
||||
|
||||
|
||||
class OpowerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@@ -55,147 +68,81 @@ class OpowerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize a new OpowerConfigFlow."""
|
||||
self._data: dict[str, Any] = {}
|
||||
self.mfa_handler: MfaHandlerBase | None = None
|
||||
self.utility_info: dict[str, Any] | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step (select utility)."""
|
||||
if user_input is not None:
|
||||
self._data[CONF_UTILITY] = user_input[CONF_UTILITY]
|
||||
return await self.async_step_credentials()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(CONF_UTILITY): vol.In(get_supported_utility_names())}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_credentials(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle credentials step."""
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
utility = select_utility(self._data[CONF_UTILITY])
|
||||
|
||||
if user_input is not None:
|
||||
self._data.update(user_input)
|
||||
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_UTILITY: self._data[CONF_UTILITY],
|
||||
CONF_USERNAME: self._data[CONF_USERNAME],
|
||||
CONF_UTILITY: user_input[CONF_UTILITY],
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
}
|
||||
)
|
||||
if select_utility(user_input[CONF_UTILITY]).accepts_mfa():
|
||||
self.utility_info = user_input
|
||||
return await self.async_step_mfa()
|
||||
|
||||
try:
|
||||
await _validate_login(self.hass, self._data)
|
||||
except MfaChallenge as exc:
|
||||
self.mfa_handler = exc.handler
|
||||
return await self.async_step_mfa_options()
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
return self._async_create_opower_entry(self._data)
|
||||
|
||||
schema_dict: VolDictType = {
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
if utility.accepts_totp_secret():
|
||||
schema_dict[vol.Optional(CONF_TOTP_SECRET)] = str
|
||||
|
||||
errors = await _validate_login(self.hass, user_input)
|
||||
if not errors:
|
||||
return self._async_create_opower_entry(user_input)
|
||||
else:
|
||||
user_input = {}
|
||||
user_input.pop(CONF_PASSWORD, None)
|
||||
return self.async_show_form(
|
||||
step_id="credentials",
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(schema_dict), user_input
|
||||
STEP_USER_DATA_SCHEMA, user_input
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_mfa_options(
|
||||
async def async_step_mfa(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle MFA options step."""
|
||||
errors: dict[str, str] = {}
|
||||
assert self.mfa_handler is not None
|
||||
|
||||
if user_input is not None:
|
||||
method = user_input[CONF_MFA_METHOD]
|
||||
try:
|
||||
await self.mfa_handler.async_select_mfa_option(method)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
return await self.async_step_mfa_code()
|
||||
|
||||
mfa_options = await self.mfa_handler.async_get_mfa_options()
|
||||
if not mfa_options:
|
||||
return await self.async_step_mfa_code()
|
||||
return self.async_show_form(
|
||||
step_id="mfa_options",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema({vol.Required(CONF_MFA_METHOD): vol.In(mfa_options)}),
|
||||
user_input,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_mfa_code(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle MFA code submission step."""
|
||||
assert self.mfa_handler is not None
|
||||
"""Handle MFA step."""
|
||||
assert self.utility_info is not None
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
code = user_input[CONF_MFA_CODE]
|
||||
try:
|
||||
login_data = await self.mfa_handler.async_submit_mfa_code(code)
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_mfa_code"
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
self._data[CONF_LOGIN_DATA] = login_data
|
||||
if self.source == SOURCE_REAUTH:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data=self._data
|
||||
)
|
||||
return self._async_create_opower_entry(self._data)
|
||||
data = {**self.utility_info, **user_input}
|
||||
errors = await _validate_login(self.hass, data)
|
||||
if not errors:
|
||||
return self._async_create_opower_entry(data)
|
||||
|
||||
if errors:
|
||||
schema = {
|
||||
vol.Required(
|
||||
CONF_USERNAME, default=self.utility_info[CONF_USERNAME]
|
||||
): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
else:
|
||||
schema = {}
|
||||
|
||||
schema[vol.Required(CONF_TOTP_SECRET)] = str
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="mfa_code",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema({vol.Required(CONF_MFA_CODE): str}), user_input
|
||||
),
|
||||
step_id="mfa",
|
||||
data_schema=vol.Schema(schema),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_create_opower_entry(
|
||||
self, data: dict[str, Any], **kwargs: Any
|
||||
) -> ConfigFlowResult:
|
||||
def _async_create_opower_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Create the config entry."""
|
||||
return self.async_create_entry(
|
||||
title=f"{data[CONF_UTILITY]} ({data[CONF_USERNAME]})",
|
||||
data=data,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle configuration by re-auth."""
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
self._data = dict(reauth_entry.data)
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
description_placeholders={CONF_NAME: reauth_entry.title},
|
||||
)
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -203,34 +150,21 @@ class OpowerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
self._data.update(user_input)
|
||||
try:
|
||||
await _validate_login(self.hass, self._data)
|
||||
except MfaChallenge as exc:
|
||||
self.mfa_handler = exc.handler
|
||||
return await self.async_step_mfa_options()
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(reauth_entry, data=self._data)
|
||||
data = {**reauth_entry.data, **user_input}
|
||||
errors = await _validate_login(self.hass, data)
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(reauth_entry, data=data)
|
||||
|
||||
utility = select_utility(self._data[CONF_UTILITY])
|
||||
schema_dict: VolDictType = {
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
schema: VolDictType = {
|
||||
vol.Required(CONF_USERNAME): reauth_entry.data[CONF_USERNAME],
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
if utility.accepts_totp_secret():
|
||||
schema_dict[vol.Optional(CONF_TOTP_SECRET)] = str
|
||||
|
||||
if select_utility(reauth_entry.data[CONF_UTILITY]).accepts_mfa():
|
||||
schema[vol.Optional(CONF_TOTP_SECRET)] = str
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(schema_dict), self._data
|
||||
),
|
||||
data_schema=vol.Schema(schema),
|
||||
errors=errors,
|
||||
description_placeholders={CONF_NAME: reauth_entry.title},
|
||||
)
|
||||
|
||||
@@ -4,4 +4,3 @@ DOMAIN = "opower"
|
||||
|
||||
CONF_UTILITY = "utility"
|
||||
CONF_TOTP_SECRET = "totp_secret"
|
||||
CONF_LOGIN_DATA = "login_data"
|
||||
|
||||
@@ -14,7 +14,7 @@ from opower import (
|
||||
ReadResolution,
|
||||
create_cookie_jar,
|
||||
)
|
||||
from opower.exceptions import ApiException, CannotConnect, InvalidAuth, MfaChallenge
|
||||
from opower.exceptions import ApiException, CannotConnect, InvalidAuth
|
||||
|
||||
from homeassistant.components.recorder import get_instance
|
||||
from homeassistant.components.recorder.models import (
|
||||
@@ -36,7 +36,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import CONF_LOGIN_DATA, CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN
|
||||
from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -69,7 +69,6 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
|
||||
config_entry.data[CONF_USERNAME],
|
||||
config_entry.data[CONF_PASSWORD],
|
||||
config_entry.data.get(CONF_TOTP_SECRET),
|
||||
config_entry.data.get(CONF_LOGIN_DATA),
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -91,7 +90,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
|
||||
# Given the infrequent updating (every 12h)
|
||||
# assume previous session has expired and re-login.
|
||||
await self.api.async_login()
|
||||
except (InvalidAuth, MfaChallenge) as err:
|
||||
except InvalidAuth as err:
|
||||
_LOGGER.error("Error during login: %s", err)
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except CannotConnect as err:
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/opower",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"requirements": ["opower==0.15.1"]
|
||||
"requirements": ["opower==0.12.4"]
|
||||
}
|
||||
|
||||
@@ -3,43 +3,27 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"utility": "Utility name"
|
||||
"utility": "Utility name",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"utility": "The name of your utility provider"
|
||||
"utility": "The name of your utility provider",
|
||||
"username": "The username for your utility account",
|
||||
"password": "The password for your utility account"
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"title": "Enter Credentials",
|
||||
"mfa": {
|
||||
"description": "The TOTP secret below is not one of the 6-digit time-based numeric codes. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation.",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"totp_secret": "TOTP secret"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "The username for your utility account",
|
||||
"password": "The password for your utility account",
|
||||
"totp_secret": "This is not a 6-digit code. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation."
|
||||
}
|
||||
},
|
||||
"mfa_options": {
|
||||
"title": "Multi-factor authentication",
|
||||
"description": "Your account requires multi-factor authentication (MFA). Select a method to receive your security code.",
|
||||
"data": {
|
||||
"mfa_method": "MFA method"
|
||||
},
|
||||
"data_description": {
|
||||
"mfa_method": "How to receive your security code"
|
||||
}
|
||||
},
|
||||
"mfa_code": {
|
||||
"title": "Enter security code",
|
||||
"description": "A security code has been sent via your selected method. Please enter it below to complete login.",
|
||||
"data": {
|
||||
"mfa_code": "Security code"
|
||||
},
|
||||
"data_description": {
|
||||
"mfa_code": "Typically a 6-digit code"
|
||||
"username": "[%key:component::opower::config::step::user::data_description::username%]",
|
||||
"password": "[%key:component::opower::config::step::user::data_description::password%]",
|
||||
"totp_secret": "The TOTP secret for your utility account, used for multi-factor authentication (MFA)."
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
@@ -47,19 +31,18 @@
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"totp_secret": "[%key:component::opower::config::step::credentials::data::totp_secret%]"
|
||||
"totp_secret": "[%key:component::opower::config::step::mfa::data::totp_secret%]"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "[%key:component::opower::config::step::credentials::data_description::username%]",
|
||||
"password": "[%key:component::opower::config::step::credentials::data_description::password%]",
|
||||
"totp_secret": "[%key:component::opower::config::step::credentials::data_description::totp_secret%]"
|
||||
"username": "[%key:component::opower::config::step::user::data_description::username%]",
|
||||
"password": "[%key:component::opower::config::step::user::data_description::password%]",
|
||||
"totp_secret": "The TOTP secret for your utility account, used for multi-factor authentication (MFA)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_mfa_code": "The security code is incorrect. Please try again."
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from psnawp_api.core.psnawp_exceptions import (
|
||||
PSNAWPClientError,
|
||||
@@ -10,12 +11,14 @@ from psnawp_api.core.psnawp_exceptions import (
|
||||
PSNAWPNotFoundError,
|
||||
PSNAWPServerError,
|
||||
)
|
||||
from psnawp_api.models.group.group import Group
|
||||
|
||||
from homeassistant.components.notify import (
|
||||
DOMAIN as NOTIFY_DOMAIN,
|
||||
NotifyEntity,
|
||||
NotifyEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -24,6 +27,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
PlaystationNetworkConfigEntry,
|
||||
PlaystationNetworkFriendDataCoordinator,
|
||||
PlaystationNetworkGroupsUpdateCoordinator,
|
||||
)
|
||||
from .entity import PlaystationNetworkServiceEntity
|
||||
@@ -35,6 +39,7 @@ class PlaystationNetworkNotify(StrEnum):
|
||||
"""PlayStation Network sensors."""
|
||||
|
||||
GROUP_MESSAGE = "group_message"
|
||||
DIRECT_MESSAGE = "direct_message"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -45,6 +50,7 @@ async def async_setup_entry(
|
||||
"""Set up the notify entity platform."""
|
||||
|
||||
coordinator = config_entry.runtime_data.groups
|
||||
|
||||
groups_added: set[str] = set()
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
@@ -72,8 +78,50 @@ async def async_setup_entry(
|
||||
coordinator.async_add_listener(add_entities)
|
||||
add_entities()
|
||||
|
||||
for subentry_id, friend_coordinator in config_entry.runtime_data.friends.items():
|
||||
async_add_entities(
|
||||
[
|
||||
PlaystationNetworkDirectMessageNotifyEntity(
|
||||
friend_coordinator,
|
||||
config_entry.subentries[subentry_id],
|
||||
)
|
||||
],
|
||||
config_subentry_id=subentry_id,
|
||||
)
|
||||
|
||||
class PlaystationNetworkNotifyEntity(PlaystationNetworkServiceEntity, NotifyEntity):
|
||||
|
||||
class PlaystationNetworkNotifyBaseEntity(PlaystationNetworkServiceEntity, NotifyEntity):
|
||||
"""Base class of PlayStation Network notify entity."""
|
||||
|
||||
group: Group | None = None
|
||||
|
||||
def send_message(self, message: str, title: str | None = None) -> None:
|
||||
"""Send a message."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.group
|
||||
try:
|
||||
self.group.send_message(message)
|
||||
except PSNAWPNotFoundError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="group_invalid",
|
||||
translation_placeholders=dict(self.translation_placeholders),
|
||||
) from e
|
||||
except PSNAWPForbiddenError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="send_message_forbidden",
|
||||
translation_placeholders=dict(self.translation_placeholders),
|
||||
) from e
|
||||
except (PSNAWPServerError, PSNAWPClientError) as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="send_message_failed",
|
||||
translation_placeholders=dict(self.translation_placeholders),
|
||||
) from e
|
||||
|
||||
|
||||
class PlaystationNetworkNotifyEntity(PlaystationNetworkNotifyBaseEntity):
|
||||
"""Representation of a PlayStation Network notify entity."""
|
||||
|
||||
coordinator: PlaystationNetworkGroupsUpdateCoordinator
|
||||
@@ -101,26 +149,31 @@ class PlaystationNetworkNotifyEntity(PlaystationNetworkServiceEntity, NotifyEnti
|
||||
|
||||
super().__init__(coordinator, self.entity_description)
|
||||
|
||||
|
||||
class PlaystationNetworkDirectMessageNotifyEntity(PlaystationNetworkNotifyBaseEntity):
|
||||
"""Representation of a PlayStation Network notify entity for sending direct messages."""
|
||||
|
||||
coordinator: PlaystationNetworkFriendDataCoordinator
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PlaystationNetworkFriendDataCoordinator,
|
||||
subentry: ConfigSubentry,
|
||||
) -> None:
|
||||
"""Initialize a notification entity."""
|
||||
|
||||
self.entity_description = NotifyEntityDescription(
|
||||
key=PlaystationNetworkNotify.DIRECT_MESSAGE,
|
||||
translation_key=PlaystationNetworkNotify.DIRECT_MESSAGE,
|
||||
)
|
||||
|
||||
super().__init__(coordinator, self.entity_description, subentry)
|
||||
|
||||
def send_message(self, message: str, title: str | None = None) -> None:
|
||||
"""Send a message."""
|
||||
|
||||
try:
|
||||
self.group.send_message(message)
|
||||
except PSNAWPNotFoundError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="group_invalid",
|
||||
translation_placeholders=dict(self.translation_placeholders),
|
||||
) from e
|
||||
except PSNAWPForbiddenError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="send_message_forbidden",
|
||||
translation_placeholders=dict(self.translation_placeholders),
|
||||
) from e
|
||||
except (PSNAWPServerError, PSNAWPClientError) as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="send_message_failed",
|
||||
translation_placeholders=dict(self.translation_placeholders),
|
||||
) from e
|
||||
if not self.group:
|
||||
self.group = self.coordinator.psn.psn.group(
|
||||
users_list=[self.coordinator.user]
|
||||
)
|
||||
super().send_message(message, title)
|
||||
|
||||
@@ -158,6 +158,9 @@
|
||||
"notify": {
|
||||
"group_message": {
|
||||
"name": "Group: {group_name}"
|
||||
},
|
||||
"direct_message": {
|
||||
"name": "Direct message"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import QbusConfigEntry
|
||||
from .entity import QbusEntity, add_new_outputs
|
||||
from .entity import QbusEntity, create_new_entities
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -42,13 +42,13 @@ async def async_setup_entry(
|
||||
added_outputs: list[QbusMqttOutput] = []
|
||||
|
||||
def _check_outputs() -> None:
|
||||
add_new_outputs(
|
||||
entities = create_new_entities(
|
||||
coordinator,
|
||||
added_outputs,
|
||||
lambda output: output.type == "thermo",
|
||||
QbusClimate,
|
||||
async_add_entities,
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
|
||||
@@ -10,6 +10,7 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.COVER,
|
||||
Platform.LIGHT,
|
||||
Platform.SCENE,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import QbusConfigEntry
|
||||
from .entity import QbusEntity, add_new_outputs
|
||||
from .entity import QbusEntity, create_new_entities
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -36,13 +36,13 @@ async def async_setup_entry(
|
||||
added_outputs: list[QbusMqttOutput] = []
|
||||
|
||||
def _check_outputs() -> None:
|
||||
add_new_outputs(
|
||||
entities = create_new_entities(
|
||||
coordinator,
|
||||
added_outputs,
|
||||
lambda output: output.type == "shutter",
|
||||
QbusCover,
|
||||
async_add_entities,
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
|
||||
@@ -14,7 +14,6 @@ from qbusmqttapi.state import QbusMqttState
|
||||
from homeassistant.components.mqtt import ReceiveMessage, client as mqtt
|
||||
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .coordinator import QbusControllerCoordinator
|
||||
@@ -24,14 +23,24 @@ _REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$")
|
||||
StateT = TypeVar("StateT", bound=QbusMqttState)
|
||||
|
||||
|
||||
def add_new_outputs(
|
||||
def create_new_entities(
|
||||
coordinator: QbusControllerCoordinator,
|
||||
added_outputs: list[QbusMqttOutput],
|
||||
filter_fn: Callable[[QbusMqttOutput], bool],
|
||||
entity_type: type[QbusEntity],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Call async_add_entities for new outputs."""
|
||||
) -> list[QbusEntity]:
|
||||
"""Create entities for new outputs."""
|
||||
|
||||
new_outputs = determine_new_outputs(coordinator, added_outputs, filter_fn)
|
||||
return [entity_type(output) for output in new_outputs]
|
||||
|
||||
|
||||
def determine_new_outputs(
|
||||
coordinator: QbusControllerCoordinator,
|
||||
added_outputs: list[QbusMqttOutput],
|
||||
filter_fn: Callable[[QbusMqttOutput], bool],
|
||||
) -> list[QbusMqttOutput]:
|
||||
"""Determine new outputs."""
|
||||
|
||||
added_ref_ids = {k.ref_id for k in added_outputs}
|
||||
|
||||
@@ -43,7 +52,8 @@ def add_new_outputs(
|
||||
|
||||
if new_outputs:
|
||||
added_outputs.extend(new_outputs)
|
||||
async_add_entities([entity_type(output) for output in new_outputs])
|
||||
|
||||
return new_outputs
|
||||
|
||||
|
||||
def format_ref_id(ref_id: str) -> str | None:
|
||||
@@ -67,7 +77,13 @@ class QbusEntity(Entity, Generic[StateT], ABC):
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
mqtt_output: QbusMqttOutput,
|
||||
*,
|
||||
id_suffix: str = "",
|
||||
link_to_main_device: bool = False,
|
||||
) -> None:
|
||||
"""Initialize the Qbus entity."""
|
||||
|
||||
self._mqtt_output = mqtt_output
|
||||
@@ -79,17 +95,25 @@ class QbusEntity(Entity, Generic[StateT], ABC):
|
||||
)
|
||||
|
||||
ref_id = format_ref_id(mqtt_output.ref_id)
|
||||
unique_id = f"ctd_{mqtt_output.device.serial_number}_{ref_id}"
|
||||
|
||||
self._attr_unique_id = f"ctd_{mqtt_output.device.serial_number}_{ref_id}"
|
||||
if id_suffix:
|
||||
unique_id += f"_{id_suffix}"
|
||||
|
||||
# Create linked device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name=mqtt_output.name.title(),
|
||||
manufacturer=MANUFACTURER,
|
||||
identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")},
|
||||
suggested_area=mqtt_output.location.title(),
|
||||
via_device=create_main_device_identifier(mqtt_output),
|
||||
)
|
||||
self._attr_unique_id = unique_id
|
||||
|
||||
if link_to_main_device:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={create_main_device_identifier(mqtt_output)}
|
||||
)
|
||||
else:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name=mqtt_output.name.title(),
|
||||
manufacturer=MANUFACTURER,
|
||||
identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")},
|
||||
suggested_area=mqtt_output.location.title(),
|
||||
via_device=create_main_device_identifier(mqtt_output),
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.color import brightness_to_value, value_to_brightness
|
||||
|
||||
from .coordinator import QbusConfigEntry
|
||||
from .entity import QbusEntity, add_new_outputs
|
||||
from .entity import QbusEntity, create_new_entities
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -27,13 +27,13 @@ async def async_setup_entry(
|
||||
added_outputs: list[QbusMqttOutput] = []
|
||||
|
||||
def _check_outputs() -> None:
|
||||
add_new_outputs(
|
||||
entities = create_new_entities(
|
||||
coordinator,
|
||||
added_outputs,
|
||||
lambda output: output.type == "analog",
|
||||
QbusLight,
|
||||
async_add_entities,
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/qbus",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["qbusmqttapi"],
|
||||
"mqtt": [
|
||||
"cloudapp/QBUSMQTTGW/state",
|
||||
"cloudapp/QBUSMQTTGW/config",
|
||||
|
||||
@@ -7,11 +7,10 @@ from qbusmqttapi.state import QbusMqttState, StateAction, StateType
|
||||
|
||||
from homeassistant.components.scene import Scene
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import QbusConfigEntry
|
||||
from .entity import QbusEntity, add_new_outputs, create_main_device_identifier
|
||||
from .entity import QbusEntity, create_new_entities
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -27,13 +26,13 @@ async def async_setup_entry(
|
||||
added_outputs: list[QbusMqttOutput] = []
|
||||
|
||||
def _check_outputs() -> None:
|
||||
add_new_outputs(
|
||||
entities = create_new_entities(
|
||||
coordinator,
|
||||
added_outputs,
|
||||
lambda output: output.type == "scene",
|
||||
QbusScene,
|
||||
async_add_entities,
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
@@ -45,12 +44,8 @@ class QbusScene(QbusEntity, Scene):
|
||||
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
|
||||
"""Initialize scene entity."""
|
||||
|
||||
super().__init__(mqtt_output)
|
||||
super().__init__(mqtt_output, link_to_main_device=True)
|
||||
|
||||
# Add to main controller device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={create_main_device_identifier(mqtt_output)}
|
||||
)
|
||||
self._attr_name = mqtt_output.name.title()
|
||||
|
||||
async def async_activate(self, **kwargs: Any) -> None:
|
||||
|
||||
378
homeassistant/components/qbus/sensor.py
Normal file
378
homeassistant/components/qbus/sensor.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""Support for Qbus sensor."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from qbusmqttapi.discovery import QbusMqttOutput
|
||||
from qbusmqttapi.state import (
|
||||
GaugeStateProperty,
|
||||
QbusMqttGaugeState,
|
||||
QbusMqttHumidityState,
|
||||
QbusMqttThermoState,
|
||||
QbusMqttVentilationState,
|
||||
QbusMqttWeatherState,
|
||||
)
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
LIGHT_LUX,
|
||||
PERCENTAGE,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfLength,
|
||||
UnitOfPower,
|
||||
UnitOfPressure,
|
||||
UnitOfSoundPressure,
|
||||
UnitOfSpeed,
|
||||
UnitOfTemperature,
|
||||
UnitOfVolume,
|
||||
UnitOfVolumeFlowRate,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import QbusConfigEntry
|
||||
from .entity import QbusEntity, create_new_entities, determine_new_outputs
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class QbusWeatherDescription(SensorEntityDescription):
|
||||
"""Description for Qbus weather entities."""
|
||||
|
||||
property: str
|
||||
|
||||
|
||||
_WEATHER_DESCRIPTIONS = (
|
||||
QbusWeatherDescription(
|
||||
key="daylight",
|
||||
property="dayLight",
|
||||
translation_key="daylight",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
),
|
||||
QbusWeatherDescription(
|
||||
key="light",
|
||||
property="light",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
),
|
||||
QbusWeatherDescription(
|
||||
key="light_east",
|
||||
property="lightEast",
|
||||
translation_key="light_east",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
),
|
||||
QbusWeatherDescription(
|
||||
key="light_south",
|
||||
property="lightSouth",
|
||||
translation_key="light_south",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
),
|
||||
QbusWeatherDescription(
|
||||
key="light_west",
|
||||
property="lightWest",
|
||||
translation_key="light_west",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
),
|
||||
QbusWeatherDescription(
|
||||
key="temperature",
|
||||
property="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
),
|
||||
QbusWeatherDescription(
|
||||
key="wind",
|
||||
property="wind",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
),
|
||||
)
|
||||
|
||||
_GAUGE_VARIANT_DESCRIPTIONS = {
|
||||
"AIRPRESSURE": SensorEntityDescription(
|
||||
key="airpressure",
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
native_unit_of_measurement=UnitOfPressure.MBAR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"AIRQUALITY": SensorEntityDescription(
|
||||
key="airquality",
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"CURRENT": SensorEntityDescription(
|
||||
key="current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"ENERGY": SensorEntityDescription(
|
||||
key="energy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
"GAS": SensorEntityDescription(
|
||||
key="gas",
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"GASFLOW": SensorEntityDescription(
|
||||
key="gasflow",
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"HUMIDITY": SensorEntityDescription(
|
||||
key="humidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"LIGHT": SensorEntityDescription(
|
||||
key="light",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"LOUDNESS": SensorEntityDescription(
|
||||
key="loudness",
|
||||
device_class=SensorDeviceClass.SOUND_PRESSURE,
|
||||
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"POWER": SensorEntityDescription(
|
||||
key="power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"PRESSURE": SensorEntityDescription(
|
||||
key="pressure",
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
native_unit_of_measurement=UnitOfPressure.KPA,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"TEMPERATURE": SensorEntityDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"VOLTAGE": SensorEntityDescription(
|
||||
key="voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"VOLUME": SensorEntityDescription(
|
||||
key="volume",
|
||||
device_class=SensorDeviceClass.VOLUME_STORAGE,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"WATER": SensorEntityDescription(
|
||||
key="water",
|
||||
device_class=SensorDeviceClass.WATER,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
"WATERFLOW": SensorEntityDescription(
|
||||
key="waterflow",
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"WATERLEVEL": SensorEntityDescription(
|
||||
key="waterlevel",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.METERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"WATERPRESSURE": SensorEntityDescription(
|
||||
key="waterpressure",
|
||||
device_class=SensorDeviceClass.PRESSURE,
|
||||
native_unit_of_measurement=UnitOfPressure.MBAR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"WIND": SensorEntityDescription(
|
||||
key="wind",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _is_gauge_with_variant(output: QbusMqttOutput) -> bool:
|
||||
return (
|
||||
output.type == "gauge"
|
||||
and isinstance(output.variant, str)
|
||||
and _GAUGE_VARIANT_DESCRIPTIONS.get(output.variant.upper()) is not None
|
||||
)
|
||||
|
||||
|
||||
def _is_ventilation_with_co2(output: QbusMqttOutput) -> bool:
|
||||
return output.type == "ventilation" and output.properties.get("co2") is not None
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: QbusConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensor entities."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
added_outputs: list[QbusMqttOutput] = []
|
||||
|
||||
def _create_weather_entities() -> list[QbusEntity]:
|
||||
new_outputs = determine_new_outputs(
|
||||
coordinator, added_outputs, lambda output: output.type == "weatherstation"
|
||||
)
|
||||
|
||||
return [
|
||||
QbusWeatherSensor(output, description)
|
||||
for output in new_outputs
|
||||
for description in _WEATHER_DESCRIPTIONS
|
||||
]
|
||||
|
||||
def _check_outputs() -> None:
|
||||
entities: list[QbusEntity] = [
|
||||
*create_new_entities(
|
||||
coordinator,
|
||||
added_outputs,
|
||||
_is_gauge_with_variant,
|
||||
QbusGaugeVariantSensor,
|
||||
),
|
||||
*create_new_entities(
|
||||
coordinator,
|
||||
added_outputs,
|
||||
lambda output: output.type == "humidity",
|
||||
QbusHumiditySensor,
|
||||
),
|
||||
*create_new_entities(
|
||||
coordinator,
|
||||
added_outputs,
|
||||
lambda output: output.type == "thermo",
|
||||
QbusThermoSensor,
|
||||
),
|
||||
*create_new_entities(
|
||||
coordinator,
|
||||
added_outputs,
|
||||
_is_ventilation_with_co2,
|
||||
QbusVentilationSensor,
|
||||
),
|
||||
*_create_weather_entities(),
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
|
||||
|
||||
class QbusGaugeVariantSensor(QbusEntity, SensorEntity):
|
||||
"""Representation of a Qbus sensor entity for gauges with variant."""
|
||||
|
||||
_state_cls = QbusMqttGaugeState
|
||||
|
||||
_attr_name = None
|
||||
_attr_suggested_display_precision = 2
|
||||
|
||||
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
|
||||
"""Initialize sensor entity."""
|
||||
|
||||
super().__init__(mqtt_output)
|
||||
|
||||
variant = str(mqtt_output.variant)
|
||||
self.entity_description = _GAUGE_VARIANT_DESCRIPTIONS[variant.upper()]
|
||||
|
||||
async def _handle_state_received(self, state: QbusMqttGaugeState) -> None:
|
||||
self._attr_native_value = state.read_value(GaugeStateProperty.CURRENT_VALUE)
|
||||
|
||||
|
||||
class QbusHumiditySensor(QbusEntity, SensorEntity):
|
||||
"""Representation of a Qbus sensor entity for humidity modules."""
|
||||
|
||||
_state_cls = QbusMqttHumidityState
|
||||
|
||||
_attr_device_class = SensorDeviceClass.HUMIDITY
|
||||
_attr_name = None
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
async def _handle_state_received(self, state: QbusMqttHumidityState) -> None:
|
||||
self._attr_native_value = state.read_value()
|
||||
|
||||
|
||||
class QbusThermoSensor(QbusEntity, SensorEntity):
|
||||
"""Representation of a Qbus sensor entity for thermostats."""
|
||||
|
||||
_state_cls = QbusMqttThermoState
|
||||
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
async def _handle_state_received(self, state: QbusMqttThermoState) -> None:
|
||||
self._attr_native_value = state.read_current_temperature()
|
||||
|
||||
|
||||
class QbusVentilationSensor(QbusEntity, SensorEntity):
|
||||
"""Representation of a Qbus sensor entity for ventilations."""
|
||||
|
||||
_state_cls = QbusMqttVentilationState
|
||||
|
||||
_attr_device_class = SensorDeviceClass.CO2
|
||||
_attr_name = None
|
||||
_attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_suggested_display_precision = 0
|
||||
|
||||
async def _handle_state_received(self, state: QbusMqttVentilationState) -> None:
|
||||
self._attr_native_value = state.read_co2()
|
||||
|
||||
|
||||
class QbusWeatherSensor(QbusEntity, SensorEntity):
|
||||
"""Representation of a Qbus weather sensor."""
|
||||
|
||||
_state_cls = QbusMqttWeatherState
|
||||
|
||||
entity_description: QbusWeatherDescription
|
||||
|
||||
def __init__(
|
||||
self, mqtt_output: QbusMqttOutput, description: QbusWeatherDescription
|
||||
) -> None:
|
||||
"""Initialize sensor entity."""
|
||||
|
||||
super().__init__(mqtt_output, id_suffix=description.key)
|
||||
|
||||
self.entity_description = description
|
||||
|
||||
if description.key == "temperature":
|
||||
self._attr_name = None
|
||||
|
||||
async def _handle_state_received(self, state: QbusMqttWeatherState) -> None:
|
||||
if value := state.read_property(self.entity_description.property, None):
|
||||
self.native_value = value
|
||||
@@ -16,6 +16,22 @@
|
||||
"no_controller": "No controllers were found"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"daylight": {
|
||||
"name": "Daylight"
|
||||
},
|
||||
"light_east": {
|
||||
"name": "Illuminance east"
|
||||
},
|
||||
"light_south": {
|
||||
"name": "Illuminance south"
|
||||
},
|
||||
"light_west": {
|
||||
"name": "Illuminance west"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_preset": {
|
||||
"message": "Preset mode \"{preset}\" is not valid. Valid preset modes are: {options}."
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import QbusConfigEntry
|
||||
from .entity import QbusEntity, add_new_outputs
|
||||
from .entity import QbusEntity, create_new_entities
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -26,13 +26,13 @@ async def async_setup_entry(
|
||||
added_outputs: list[QbusMqttOutput] = []
|
||||
|
||||
def _check_outputs() -> None:
|
||||
add_new_outputs(
|
||||
entities = create_new_entities(
|
||||
coordinator,
|
||||
added_outputs,
|
||||
lambda output: output.type == "onoff",
|
||||
QbusSwitch,
|
||||
async_add_entities,
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
||||
"timestamp": "[%key:component::sensor::entity_component::timestamp::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::name%]",
|
||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
||||
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
|
||||
"volume": "[%key:component::sensor::entity_component::volume::name%]",
|
||||
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
|
||||
|
||||
@@ -39,7 +39,6 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]):
|
||||
_LOGGER,
|
||||
name=f"{DOMAIN}_{config_entry.title}",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
config_entry=config_entry,
|
||||
always_update=True,
|
||||
)
|
||||
self._client = get_async_client(hass)
|
||||
|
||||
@@ -59,7 +59,7 @@ PLATFORMS = [
|
||||
Platform.UPDATE,
|
||||
]
|
||||
DEVICE_UPDATE_INTERVAL = timedelta(seconds=60)
|
||||
FIRMWARE_UPDATE_INTERVAL = timedelta(hours=24)
|
||||
FIRMWARE_UPDATE_INTERVAL = timedelta(hours=12)
|
||||
NUM_CRED_ERRORS = 3
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
@@ -24,7 +24,7 @@ async def async_get_config_entry_diagnostics(
|
||||
IPC_cam[ch]["hardware version"] = api.camera_hardware_version(ch)
|
||||
IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch)
|
||||
IPC_cam[ch]["encoding main"] = await api.get_encoding(ch)
|
||||
if (signal := api.wifi_signal(ch)) is not None and api.wifi_connection(ch):
|
||||
if (signal := api.wifi_signal(ch)) is not None:
|
||||
IPC_cam[ch]["WiFi signal"] = signal
|
||||
|
||||
chimes: dict[int, dict[str, Any]] = {}
|
||||
@@ -43,7 +43,7 @@ async def async_get_config_entry_diagnostics(
|
||||
"HTTP(S) port": api.port,
|
||||
"Baichuan port": api.baichuan.port,
|
||||
"Baichuan only": api.baichuan_only,
|
||||
"WiFi connection": api.wifi_connection(),
|
||||
"WiFi connection": api.wifi_connection,
|
||||
"WiFi signal": api.wifi_signal(),
|
||||
"RTMP enabled": api.rtmp_enabled,
|
||||
"RTSP enabled": api.rtsp_enabled,
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.14.6"]
|
||||
"requirements": ["reolink-aio==0.14.5"]
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ HOST_SENSORS = (
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
entity_registry_enabled_default=False,
|
||||
value=lambda api: api.wifi_signal(),
|
||||
supported=lambda api: api.supported(None, "wifi") and api.wifi_connection(),
|
||||
supported=lambda api: api.supported(None, "wifi") and api.wifi_connection,
|
||||
),
|
||||
ReolinkHostSensorEntityDescription(
|
||||
key="cpu_usage",
|
||||
|
||||
@@ -89,8 +89,6 @@ class RepairsFlowManager(data_entry_flow.FlowManager):
|
||||
"""
|
||||
if result.get("type") != data_entry_flow.FlowResultType.ABORT:
|
||||
ir.async_delete_issue(self.hass, flow.handler, flow.init_data["issue_id"])
|
||||
if "result" not in result:
|
||||
result["result"] = None
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
"selector": {
|
||||
"device_class": {
|
||||
"options": {
|
||||
"absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]",
|
||||
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
|
||||
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
|
||||
"area": "[%key:component::sensor::entity_component::area::name%]",
|
||||
@@ -155,6 +156,7 @@
|
||||
"distance": "[%key:component::sensor::entity_component::distance::name%]",
|
||||
"duration": "[%key:component::sensor::entity_component::duration::name%]",
|
||||
"energy": "[%key:component::sensor::entity_component::energy::name%]",
|
||||
"energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]",
|
||||
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
|
||||
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
|
||||
"gas": "[%key:component::sensor::entity_component::gas::name%]",
|
||||
@@ -184,13 +186,14 @@
|
||||
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
||||
"timestamp": "[%key:component::sensor::entity_component::timestamp::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::name%]",
|
||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
||||
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
|
||||
"volume": "[%key:component::sensor::entity_component::volume::name%]",
|
||||
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
|
||||
"volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]",
|
||||
"water": "[%key:component::sensor::entity_component::water::name%]",
|
||||
"weight": "[%key:component::sensor::entity_component::weight::name%]",
|
||||
"wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]",
|
||||
"wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -573,7 +573,6 @@ class SimpliSafe:
|
||||
self._hass,
|
||||
LOGGER,
|
||||
name=self.entry.title,
|
||||
config_entry=self.entry,
|
||||
update_interval=DEFAULT_SCAN_INTERVAL,
|
||||
update_method=self.async_update,
|
||||
)
|
||||
|
||||
@@ -74,7 +74,6 @@ class SmartTubController:
|
||||
self._hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
config_entry=entry,
|
||||
update_method=self.async_update_data,
|
||||
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user