Compare commits

..

3 Commits

Author SHA1 Message Date
jbouwh
6da2ea28fc Make export selectors readonly 2025-06-18 17:27:51 +00:00
jbouwh
e7b5c599dc typo 2025-05-26 09:55:00 +00:00
jbouwh
ec41abd821 Add YAML and discovery info export feature for MQTT device subentries 2025-05-24 09:42:24 +00:00
1289 changed files with 5629 additions and 39381 deletions

View File

@@ -65,7 +65,6 @@ homeassistant.components.aladdin_connect.*
homeassistant.components.alarm_control_panel.*
homeassistant.components.alert.*
homeassistant.components.alexa.*
homeassistant.components.alexa_devices.*
homeassistant.components.alpha_vantage.*
homeassistant.components.amazon_polly.*
homeassistant.components.amberelectric.*

5
CODEOWNERS generated
View File

@@ -89,8 +89,6 @@ build.json @home-assistant/supervisor
/tests/components/alert/ @home-assistant/core @frenck
/homeassistant/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
/tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
/homeassistant/components/alexa_devices/ @chemelli74
/tests/components/alexa_devices/ @chemelli74
/homeassistant/components/amazon_polly/ @jschlyter
/homeassistant/components/amberelectric/ @madpilot
/tests/components/amberelectric/ @madpilot
@@ -305,7 +303,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/crownstone/ @Crownstone @RicArch97
/tests/components/crownstone/ @Crownstone @RicArch97
/homeassistant/components/cups/ @fabaff
/tests/components/cups/ @fabaff
/homeassistant/components/daikin/ @fredrike
/tests/components/daikin/ @fredrike
/homeassistant/components/date/ @home-assistant/core
@@ -1420,8 +1417,6 @@ build.json @home-assistant/supervisor
/tests/components/sma/ @kellerza @rklomp @erwindouna
/homeassistant/components/smappee/ @bsmappee
/tests/components/smappee/ @bsmappee
/homeassistant/components/smarla/ @explicatis @rlint-explicatis
/tests/components/smarla/ @explicatis @rlint-explicatis
/homeassistant/components/smart_meter_texas/ @grahamwetzler
/tests/components/smart_meter_texas/ @grahamwetzler
/homeassistant/components/smartthings/ @joostlek

View File

@@ -3,7 +3,6 @@
"name": "Amazon",
"integrations": [
"alexa",
"alexa_devices",
"amazon_polly",
"aws",
"aws_s3",

View File

@@ -1,6 +0,0 @@
{
"domain": "shelly",
"name": "shelly",
"integrations": ["shelly"],
"iot_standards": ["zwave"]
}

View File

@@ -40,10 +40,9 @@ class AcmedaFlowHandler(ConfigFlow, domain=DOMAIN):
entry.unique_id for entry in self._async_current_entries()
}
hubs: list[aiopulse.Hub] = []
with suppress(TimeoutError):
async with timeout(5):
hubs = [
hubs: list[aiopulse.Hub] = [
hub
async for hub in aiopulse.Hub.discover()
if hub.id not in already_configured

View File

@@ -51,16 +51,9 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]):
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
self._current_version = (
await self.client.get_current_measures()
).firmware_version
except AirGradientError as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": str(error)},
) from error
self._current_version = (
await self.client.get_current_measures()
).firmware_version
async def _async_update_data(self) -> AirGradientData:
try:

View File

@@ -1,32 +0,0 @@
"""Alexa Devices integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.NOTIFY,
Platform.SWITCH,
]
async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Set up Alexa Devices platform."""
coordinator = AmazonDevicesCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Unload a config entry."""
await entry.runtime_data.api.close()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,74 +0,0 @@
"""Support for binary sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Final
from aioamazondevices.api import AmazonDevice
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Alexa Devices binary sensor entity description."""
is_on_fn: Callable[[AmazonDevice], bool]
BINARY_SENSORS: Final = (
AmazonBinarySensorEntityDescription(
key="online",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
is_on_fn=lambda _device: _device.online,
),
AmazonBinarySensorEntityDescription(
key="bluetooth",
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="bluetooth",
is_on_fn=lambda _device: _device.bluetooth_state,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Alexa Devices binary sensors based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in BINARY_SENSORS
for serial_num in coordinator.data
)
class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
"""Binary sensor device."""
entity_description: AmazonBinarySensorEntityDescription
@property
def is_on(self) -> bool:
"""Return True if the binary sensor is on."""
return self.entity_description.is_on_fn(self.device)

View File

@@ -1,63 +0,0 @@
"""Config flow for Alexa Devices integration."""
from __future__ import annotations
from typing import Any
from aioamazondevices.api import AmazonEchoApi
from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import CountrySelector
from .const import CONF_LOGIN_DATA, DOMAIN
class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Alexa Devices."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors = {}
if user_input:
client = AmazonEchoApi(
user_input[CONF_COUNTRY],
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
try:
data = await client.login_mode_interactive(user_input[CONF_CODE])
except CannotConnect:
errors["base"] = "cannot_connect"
except CannotAuthenticate:
errors["base"] = "invalid_auth"
else:
await self.async_set_unique_id(data["customer_info"]["user_id"])
self._abort_if_unique_id_configured()
user_input.pop(CONF_CODE)
return self.async_create_entry(
title=user_input[CONF_USERNAME],
data=user_input | {CONF_LOGIN_DATA: data},
)
finally:
await client.close()
return self.async_show_form(
step_id="user",
errors=errors,
data_schema=vol.Schema(
{
vol.Required(
CONF_COUNTRY, default=self.hass.config.country
): CountrySelector(),
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_CODE): cv.string,
}
),
)

View File

@@ -1,8 +0,0 @@
"""Alexa Devices constants."""
import logging
_LOGGER = logging.getLogger(__package__)
DOMAIN = "alexa_devices"
CONF_LOGIN_DATA = "login_data"

View File

@@ -1,58 +0,0 @@
"""Support for Alexa Devices."""
from datetime import timedelta
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
from aioamazondevices.exceptions import (
CannotAuthenticate,
CannotConnect,
CannotRetrieveData,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import _LOGGER, CONF_LOGIN_DATA
SCAN_INTERVAL = 30
type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator]
class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
"""Base coordinator for Alexa Devices."""
config_entry: AmazonConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: AmazonConfigEntry,
) -> None:
"""Initialize the scanner."""
super().__init__(
hass,
_LOGGER,
name=entry.title,
config_entry=entry,
update_interval=timedelta(seconds=SCAN_INTERVAL),
)
self.api = AmazonEchoApi(
entry.data[CONF_COUNTRY],
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
entry.data[CONF_LOGIN_DATA],
)
async def _async_update_data(self) -> dict[str, AmazonDevice]:
"""Update device data."""
try:
await self.api.login_mode_stored_data()
return await self.api.get_devices_data()
except (CannotConnect, CannotRetrieveData) as err:
raise UpdateFailed(f"Error occurred while updating {self.name}") from err
except CannotAuthenticate as err:
raise ConfigEntryError("Could not authenticate") from err

View File

@@ -1,66 +0,0 @@
"""Diagnostics support for Alexa Devices integration."""
from __future__ import annotations
from typing import Any
from aioamazondevices.api import AmazonDevice
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from .coordinator import AmazonConfigEntry
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AmazonConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
devices: list[dict[str, dict[str, Any]]] = [
build_device_data(device) for device in coordinator.data.values()
]
return {
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
"device_info": {
"last_update success": coordinator.last_update_success,
"last_exception": repr(coordinator.last_exception),
"devices": devices,
},
}
async def async_get_device_diagnostics(
hass: HomeAssistant, entry: AmazonConfigEntry, device_entry: DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device."""
coordinator = entry.runtime_data
assert device_entry.serial_number
return build_device_data(coordinator.data[device_entry.serial_number])
def build_device_data(device: AmazonDevice) -> dict[str, Any]:
"""Build device data for diagnostics."""
return {
"account name": device.account_name,
"capabilities": device.capabilities,
"device family": device.device_family,
"device type": device.device_type,
"device cluster members": device.device_cluster_members,
"online": device.online,
"serial number": device.serial_number,
"software version": device.software_version,
"do not disturb": device.do_not_disturb,
"response style": device.response_style,
"bluetooth state": device.bluetooth_state,
}

View File

@@ -1,57 +0,0 @@
"""Defines a base Alexa Devices entity."""
from aioamazondevices.api import AmazonDevice
from aioamazondevices.const import SPEAKER_GROUP_MODEL
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AmazonDevicesCoordinator
class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]):
"""Defines a base Alexa Devices entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AmazonDevicesCoordinator,
serial_num: str,
description: EntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._serial_num = serial_num
model_details = coordinator.api.get_model_details(self.device) or {}
model = model_details.get("model")
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_num)},
name=self.device.account_name,
model=model,
model_id=self.device.device_type,
manufacturer=model_details.get("manufacturer", "Amazon"),
hw_version=model_details.get("hw_version"),
sw_version=(
self.device.software_version if model != SPEAKER_GROUP_MODEL else None
),
serial_number=serial_num if model != SPEAKER_GROUP_MODEL else None,
)
self.entity_description = description
self._attr_unique_id = f"{serial_num}-{description.key}"
@property
def device(self) -> AmazonDevice:
"""Return the device."""
return self.coordinator.data[self._serial_num]
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and self._serial_num in self.coordinator.data
and self.device.online
)

View File

@@ -1,12 +0,0 @@
{
"entity": {
"binary_sensor": {
"bluetooth": {
"default": "mdi:bluetooth",
"state": {
"off": "mdi:bluetooth-off"
}
}
}
}
}

View File

@@ -1,12 +0,0 @@
{
"domain": "alexa_devices",
"name": "Alexa Devices",
"codeowners": ["@chemelli74"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/alexa_devices",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "bronze",
"requirements": ["aioamazondevices==3.0.6"]
}

View File

@@ -1,74 +0,0 @@
"""Support for notification entity."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, Final
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class AmazonNotifyEntityDescription(NotifyEntityDescription):
"""Alexa Devices notify entity description."""
method: Callable[[AmazonEchoApi, AmazonDevice, str], Awaitable[None]]
subkey: str
NOTIFY: Final = (
AmazonNotifyEntityDescription(
key="speak",
translation_key="speak",
subkey="AUDIO_PLAYER",
method=lambda api, device, message: api.call_alexa_speak(device, message),
),
AmazonNotifyEntityDescription(
key="announce",
translation_key="announce",
subkey="AUDIO_PLAYER",
method=lambda api, device, message: api.call_alexa_announcement(
device, message
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Alexa Devices notification entity based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
AmazonNotifyEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in NOTIFY
for serial_num in coordinator.data
if sensor_desc.subkey in coordinator.data[serial_num].capabilities
)
class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
"""Binary sensor notify platform."""
entity_description: AmazonNotifyEntityDescription
async def async_send_message(
self, message: str, title: str | None = None, **kwargs: Any
) -> None:
"""Send a message."""
await self.entity_description.method(self.coordinator.api, self.device, message)

View File

@@ -1,76 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: no actions
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: no actions
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: entities do not explicitly subscribe to events
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage:
status: todo
comment: all tests missing
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Network information not relevant
discovery:
status: exempt
comment: There are a ton of mac address ranges in use, but also by kindles which are not supported by this integration
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: no known use cases for repair issues or flows, yet
stale-devices:
status: todo
comment: automate the cleanup process
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: done

View File

@@ -1,60 +0,0 @@
{
"common": {
"data_country": "Country code",
"data_code": "One-time password (OTP code)",
"data_description_country": "The country of your Amazon account.",
"data_description_username": "The email address of your Amazon account.",
"data_description_password": "The password of your Amazon account.",
"data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported."
},
"config": {
"flow_title": "{username}",
"step": {
"user": {
"data": {
"country": "[%key:component::alexa_devices::common::data_country%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"code": "[%key:component::alexa_devices::common::data_description_code%]"
},
"data_description": {
"country": "[%key:component::alexa_devices::common::data_description_country%]",
"username": "[%key:component::alexa_devices::common::data_description_username%]",
"password": "[%key:component::alexa_devices::common::data_description_password%]",
"code": "[%key:component::alexa_devices::common::data_description_code%]"
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
"entity": {
"binary_sensor": {
"bluetooth": {
"name": "Bluetooth"
}
},
"notify": {
"speak": {
"name": "Speak"
},
"announce": {
"name": "Announce"
}
},
"switch": {
"do_not_disturb": {
"name": "Do not disturb"
}
}
}
}

View File

@@ -1,84 +0,0 @@
"""Support for switches."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final
from aioamazondevices.api import AmazonDevice
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class AmazonSwitchEntityDescription(SwitchEntityDescription):
"""Alexa Devices switch entity description."""
is_on_fn: Callable[[AmazonDevice], bool]
subkey: str
method: str
SWITCHES: Final = (
AmazonSwitchEntityDescription(
key="do_not_disturb",
subkey="AUDIO_PLAYER",
translation_key="do_not_disturb",
is_on_fn=lambda _device: _device.do_not_disturb,
method="set_do_not_disturb",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Alexa Devices switches based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
AmazonSwitchEntity(coordinator, serial_num, switch_desc)
for switch_desc in SWITCHES
for serial_num in coordinator.data
if switch_desc.subkey in coordinator.data[serial_num].capabilities
)
class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
"""Switch device."""
entity_description: AmazonSwitchEntityDescription
async def _switch_set_state(self, state: bool) -> None:
"""Set desired switch state."""
method = getattr(self.coordinator.api, self.entity_description.method)
if TYPE_CHECKING:
assert method is not None
await method(self.device, state)
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self._switch_set_state(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self._switch_set_state(False)
@property
def is_on(self) -> bool:
"""Return True if switch is on."""
return self.entity_description.is_on_fn(self.device)

View File

@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["androidtvremote2"],
"requirements": ["androidtvremote2==0.2.2"],
"requirements": ["androidtvremote2==0.2.1"],
"zeroconf": ["_androidtvremote2._tcp.local."]
}

View File

@@ -51,10 +51,6 @@
"app_id": "Application ID",
"app_icon": "Application icon",
"app_delete": "Check to delete this application"
},
"data_description": {
"app_id": "E.g. com.plexapp.android for https://play.google.com/store/apps/details?id=com.plexapp.android",
"app_icon": "Image URL. From the Play Store app page, right click on the icon and select 'Copy image address' and then paste it here. Alternatively, download the image, upload it under /config/www/ and use the URL /local/filename"
}
}
}

View File

@@ -46,7 +46,11 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_show_form(step_id="user", data_schema=_SCHEMA)
host, port = user_input[CONF_HOST], user_input[CONF_PORT]
# Abort if an entry with same host and port is present.
self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port})
# Test the connection to the host and get the current status for serial number.
try:
async with asyncio.timeout(CONNECTION_TIMEOUT):
data = APCUPSdData(await aioapcaccess.request_status(host, port))
@@ -63,30 +67,3 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
title = data.name or data.model or data.serial_no or "APC UPS"
return self.async_create_entry(title=title, data=user_input)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of an existing entry."""
if user_input is None:
return self.async_show_form(step_id="reconfigure", data_schema=_SCHEMA)
host, port = user_input[CONF_HOST], user_input[CONF_PORT]
self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port})
try:
async with asyncio.timeout(CONNECTION_TIMEOUT):
data = APCUPSdData(await aioapcaccess.request_status(host, port))
except (OSError, asyncio.IncompleteReadError, TimeoutError):
errors = {"base": "cannot_connect"}
return self.async_show_form(
step_id="reconfigure", data_schema=_SCHEMA, errors=errors
)
await self.async_set_unique_id(data.serial_no)
self._abort_if_unique_id_mismatch(reason="wrong_apcupsd_daemon")
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data_updates=user_input,
)

View File

@@ -1,9 +1,7 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"wrong_apcupsd_daemon": "The reconfigured APC UPS Daemon is not the same as the one already configured.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"

View File

@@ -62,8 +62,6 @@ async def async_setup_entry(
target_humidity_key=Attribute.HUMIDIFICATION_SETPOINT,
min_humidity=10,
max_humidity=50,
auto_status_key=Attribute.HUMIDIFICATION_AVAILABLE,
auto_status_value=1,
default_humidity=30,
set_humidity_fn=coordinator.client.set_humidification_setpoint,
)
@@ -79,8 +77,6 @@ async def async_setup_entry(
action_map=DEHUMIDIFIER_ACTION_MAP,
current_humidity_key=Attribute.INDOOR_HUMIDITY_CONTROLLING_SENSOR_VALUE,
target_humidity_key=Attribute.DEHUMIDIFICATION_SETPOINT,
auto_status_key=None,
auto_status_value=None,
min_humidity=40,
max_humidity=90,
default_humidity=60,
@@ -104,8 +100,6 @@ class AprilaireHumidifierDescription(HumidifierEntityDescription):
target_humidity_key: str
min_humidity: int
max_humidity: int
auto_status_key: str | None
auto_status_value: int | None
default_humidity: int
set_humidity_fn: Callable[[int], Awaitable]
@@ -169,31 +163,14 @@ class AprilaireHumidifierEntity(BaseAprilaireEntity, HumidifierEntity):
def min_humidity(self) -> float:
"""Return the minimum humidity."""
if self.is_auto_humidity_mode():
return 1
return self.entity_description.min_humidity
@property
def max_humidity(self) -> float:
"""Return the maximum humidity."""
if self.is_auto_humidity_mode():
return 7
return self.entity_description.max_humidity
def is_auto_humidity_mode(self) -> bool:
"""Return whether the humidifier is in auto mode."""
if self.entity_description.auto_status_key is None:
return False
return (
self.coordinator.data.get(self.entity_description.auto_status_key)
== self.entity_description.auto_status_value
)
async def async_set_humidity(self, humidity: int) -> None:
"""Set the humidity."""

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyaprilaire"],
"requirements": ["pyaprilaire==0.9.1"]
"requirements": ["pyaprilaire==0.9.0"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["APsystemsEZ1"],
"requirements": ["apsystems-ez1==2.7.0"]
"requirements": ["apsystems-ez1==2.6.0"]
}

View File

@@ -1,9 +1,6 @@
{
"entity": {
"sensor": {
"last_update": {
"default": "mdi:update"
},
"salt_left_side_percentage": {
"default": "mdi:basket-fill"
},

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from aioaquacell import Softener
@@ -29,7 +28,7 @@ PARALLEL_UPDATES = 1
class SoftenerSensorEntityDescription(SensorEntityDescription):
"""Describes Softener sensor entity."""
value_fn: Callable[[Softener], StateType | datetime]
value_fn: Callable[[Softener], StateType]
SENSORS: tuple[SoftenerSensorEntityDescription, ...] = (
@@ -78,12 +77,6 @@ SENSORS: tuple[SoftenerSensorEntityDescription, ...] = (
"low",
],
),
SoftenerSensorEntityDescription(
key="last_update",
translation_key="last_update",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda softener: softener.lastUpdate,
),
)
@@ -118,6 +111,6 @@ class SoftenerSensor(AquacellEntity, SensorEntity):
self.entity_description = description
@property
def native_value(self) -> StateType | datetime:
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.softener)

View File

@@ -21,9 +21,6 @@
},
"entity": {
"sensor": {
"last_update": {
"name": "Last update"
},
"salt_left_side_percentage": {
"name": "Salt left side percentage"
},

View File

@@ -1178,33 +1178,25 @@ class PipelineRun:
if role := delta.get("role"):
chat_log_role = role
# We are only interested in assistant deltas
if chat_log_role != "assistant":
# We are only interested in assistant deltas with content
if chat_log_role != "assistant" or not (
content := delta.get("content")
):
return
if content := delta.get("content"):
tts_input_stream.put_nowait(content)
tts_input_stream.put_nowait(content)
if self._streamed_response_text:
return
nonlocal delta_character_count
# Streamed responses are not cached. That's why we only start streaming text after
# we have received enough characters that indicates it will be a long response
# or if we have received text, and then a tool call.
# Tool call after we already received text
start_streaming = delta_character_count > 0 and delta.get("tool_calls")
# Count characters in the content and test if we exceed streaming threshold
if not start_streaming and content:
delta_character_count += len(content)
start_streaming = delta_character_count > STREAM_RESPONSE_CHARS
if not start_streaming:
delta_character_count += len(content)
if delta_character_count < STREAM_RESPONSE_CHARS:
return
# Streamed responses are not cached. We only start streaming text after
# we have received a couple of words that indicates it will be a long response.
self._streamed_response_text = True
async def tts_input_stream_generator() -> AsyncGenerator[str]:
@@ -1212,17 +1204,6 @@ class PipelineRun:
while (tts_input := await tts_input_stream.get()) is not None:
yield tts_input
# Concatenate all existing queue items
parts = []
while not tts_input_stream.empty():
parts.append(tts_input_stream.get_nowait())
tts_input_stream.put_nowait(
"".join(
# At this point parts is only strings, None indicates end of queue
cast(list[str], parts)
)
)
assert self.tts_stream is not None
self.tts_stream.async_set_message_stream(tts_input_stream_generator())

View File

@@ -62,7 +62,6 @@ from .const import (
LOGGER,
)
from .models import (
AddonInfo,
AgentBackup,
BackupError,
BackupManagerError,
@@ -103,9 +102,7 @@ class ManagerBackup(BaseBackup):
"""Backup class."""
agents: dict[str, AgentBackupStatus]
failed_addons: list[AddonInfo]
failed_agent_ids: list[str]
failed_folders: list[Folder]
with_automatic_settings: bool | None
@@ -113,7 +110,7 @@ class ManagerBackup(BaseBackup):
class AddonErrorData:
"""Addon error class."""
addon: AddonInfo
name: str
errors: list[tuple[str, str]]
@@ -649,13 +646,9 @@ class BackupManager:
for agent_backup in result:
if (backup_id := agent_backup.backup_id) not in backups:
if known_backup := self.known_backups.get(backup_id):
failed_addons = known_backup.failed_addons
failed_agent_ids = known_backup.failed_agent_ids
failed_folders = known_backup.failed_folders
else:
failed_addons = []
failed_agent_ids = []
failed_folders = []
with_automatic_settings = self.is_our_automatic_backup(
agent_backup, await instance_id.async_get(self.hass)
)
@@ -666,9 +659,7 @@ class BackupManager:
date=agent_backup.date,
database_included=agent_backup.database_included,
extra_metadata=agent_backup.extra_metadata,
failed_addons=failed_addons,
failed_agent_ids=failed_agent_ids,
failed_folders=failed_folders,
folders=agent_backup.folders,
homeassistant_included=agent_backup.homeassistant_included,
homeassistant_version=agent_backup.homeassistant_version,
@@ -723,13 +714,9 @@ class BackupManager:
continue
if backup is None:
if known_backup := self.known_backups.get(backup_id):
failed_addons = known_backup.failed_addons
failed_agent_ids = known_backup.failed_agent_ids
failed_folders = known_backup.failed_folders
else:
failed_addons = []
failed_agent_ids = []
failed_folders = []
with_automatic_settings = self.is_our_automatic_backup(
result, await instance_id.async_get(self.hass)
)
@@ -740,9 +727,7 @@ class BackupManager:
date=result.date,
database_included=result.database_included,
extra_metadata=result.extra_metadata,
failed_addons=failed_addons,
failed_agent_ids=failed_agent_ids,
failed_folders=failed_folders,
folders=result.folders,
homeassistant_included=result.homeassistant_included,
homeassistant_version=result.homeassistant_version,
@@ -985,7 +970,7 @@ class BackupManager:
password=None,
)
await written_backup.release_stream()
self.known_backups.add(written_backup.backup, agent_errors, {}, {}, [])
self.known_backups.add(written_backup.backup, agent_errors, [])
return written_backup.backup.backup_id
async def async_create_backup(
@@ -1223,11 +1208,7 @@ class BackupManager:
finally:
await written_backup.release_stream()
self.known_backups.add(
written_backup.backup,
agent_errors,
written_backup.addon_errors,
written_backup.folder_errors,
unavailable_agents,
written_backup.backup, agent_errors, unavailable_agents
)
if not agent_errors:
if with_automatic_settings:
@@ -1435,12 +1416,7 @@ class BackupManager:
# No issues with agents or folders, but issues with add-ons
self._create_automatic_backup_failed_issue(
"automatic_backup_failed_addons",
{
"failed_addons": ", ".join(
val.addon.name or val.addon.slug
for val in addon_errors.values()
)
},
{"failed_addons": ", ".join(val.name for val in addon_errors.values())},
)
elif folder_errors and not (failed_agents or addon_errors):
# No issues with agents or add-ons, but issues with folders
@@ -1455,11 +1431,7 @@ class BackupManager:
{
"failed_agents": ", ".join(failed_agents) or "-",
"failed_addons": (
", ".join(
val.addon.name or val.addon.slug
for val in addon_errors.values()
)
or "-"
", ".join(val.name for val in addon_errors.values()) or "-"
),
"failed_folders": ", ".join(f for f in folder_errors) or "-",
},
@@ -1529,12 +1501,7 @@ class KnownBackups:
self._backups = {
backup["backup_id"]: KnownBackup(
backup_id=backup["backup_id"],
failed_addons=[
AddonInfo(name=a["name"], slug=a["slug"], version=a["version"])
for a in backup["failed_addons"]
],
failed_agent_ids=backup["failed_agent_ids"],
failed_folders=[Folder(f) for f in backup["failed_folders"]],
)
for backup in stored_backups
}
@@ -1547,16 +1514,12 @@ class KnownBackups:
self,
backup: AgentBackup,
agent_errors: dict[str, Exception],
failed_addons: dict[str, AddonErrorData],
failed_folders: dict[Folder, list[tuple[str, str]]],
unavailable_agents: list[str],
) -> None:
"""Add a backup."""
self._backups[backup.backup_id] = KnownBackup(
backup_id=backup.backup_id,
failed_addons=[val.addon for val in failed_addons.values()],
failed_agent_ids=list(chain(agent_errors, unavailable_agents)),
failed_folders=list(failed_folders),
)
self._manager.store.save()
@@ -1577,38 +1540,21 @@ class KnownBackup:
"""Persistent backup data."""
backup_id: str
failed_addons: list[AddonInfo]
failed_agent_ids: list[str]
failed_folders: list[Folder]
def to_dict(self) -> StoredKnownBackup:
"""Convert known backup to a dict."""
return {
"backup_id": self.backup_id,
"failed_addons": [
{"name": a.name, "slug": a.slug, "version": a.version}
for a in self.failed_addons
],
"failed_agent_ids": self.failed_agent_ids,
"failed_folders": [f.value for f in self.failed_folders],
}
class StoredAddonInfo(TypedDict):
"""Stored add-on info."""
name: str | None
slug: str
version: str | None
class StoredKnownBackup(TypedDict):
"""Stored persistent backup data."""
backup_id: str
failed_addons: list[StoredAddonInfo]
failed_agent_ids: list[str]
failed_folders: list[str]
class CoreBackupReaderWriter(BackupReaderWriter):

View File

@@ -13,9 +13,9 @@ from homeassistant.exceptions import HomeAssistantError
class AddonInfo:
"""Addon information."""
name: str | None
name: str
slug: str
version: str | None
version: str
class Folder(StrEnum):

View File

@@ -16,7 +16,7 @@ if TYPE_CHECKING:
STORE_DELAY_SAVE = 30
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
STORAGE_VERSION_MINOR = 7
STORAGE_VERSION_MINOR = 6
class StoredBackupData(TypedDict):
@@ -76,11 +76,6 @@ class _BackupStore(Store[StoredBackupData]):
# Version 1.6 adds agent retention settings
for agent in data["config"]["agents"]:
data["config"]["agents"][agent]["retention"] = None
if old_minor_version < 7:
# Version 1.7 adds failing addons and folders
for backup in data["backups"]:
backup["failed_addons"] = []
backup["failed_folders"] = []
# Note: We allow reading data with major version 2 in which the unused key
# data["config"]["schedule"]["state"] will be removed. The bump to 2 is

View File

@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.5.2",
"bluetooth-data-tools==1.28.1",
"dbus-fast==2.43.0",
"habluetooth==3.49.0"
"habluetooth==3.48.2"
]
}

View File

@@ -50,7 +50,7 @@ class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity):
def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None:
"""Initialise a Bosch Alarm control panel entity."""
super().__init__(panel, area_id, unique_id, True, False, True)
super().__init__(panel, area_id, unique_id, False, False, True)
self._attr_unique_id = self._area_unique_id
@property

View File

@@ -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.1.0"]
"requirements": ["caldav==1.3.9", "icalendar==6.1.0"]
}

View File

@@ -9,7 +9,6 @@ from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import CLIMATE
from homeassistant.components.climate import (
DOMAIN as CLIMATE_DOMAIN,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
@@ -18,12 +17,13 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import PRESET_MODE_AUTO, PRESET_MODE_AUTO_TARGET_TEMP, PRESET_MODE_MANUAL
from .const import DOMAIN
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
from .entity import ComelitBridgeBaseEntity
from .utils import bridge_api_call, cleanup_stale_entity, load_api_data
from .utils import bridge_api_call
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -41,13 +41,11 @@ class ClimaComelitMode(StrEnum):
class ClimaComelitCommand(StrEnum):
"""Serial Bridge clima commands."""
AUTO = "auto"
MANUAL = "man"
OFF = "off"
ON = "on"
MANUAL = "man"
SET = "set"
SNOW = "lower"
SUN = "upper"
AUTO = "auto"
class ClimaComelitApiStatus(TypedDict):
@@ -69,15 +67,11 @@ API_STATUS: dict[str, ClimaComelitApiStatus] = {
),
}
HVACMODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = {
MODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = {
HVACMode.OFF: ClimaComelitCommand.OFF,
HVACMode.COOL: ClimaComelitCommand.SNOW,
HVACMode.HEAT: ClimaComelitCommand.SUN,
}
PRESET_MODE_TO_ACTION: dict[str, ClimaComelitCommand] = {
PRESET_MODE_MANUAL: ClimaComelitCommand.MANUAL,
PRESET_MODE_AUTO: ClimaComelitCommand.AUTO,
HVACMode.AUTO: ClimaComelitCommand.AUTO,
HVACMode.COOL: ClimaComelitCommand.MANUAL,
HVACMode.HEAT: ClimaComelitCommand.MANUAL,
}
@@ -90,42 +84,26 @@ async def async_setup_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
entities: list[ClimateEntity] = []
for device in coordinator.data[CLIMATE].values():
values = load_api_data(device, CLIMATE_DOMAIN)
if values[0] == 0 and values[4] == 0:
# No climate data, device is only a humidifier/dehumidifier
await cleanup_stale_entity(
hass, config_entry, f"{config_entry.entry_id}-{device.index}", device
)
continue
entities.append(
ComelitClimateEntity(coordinator, device, config_entry.entry_id)
)
async_add_entities(entities)
async_add_entities(
ComelitClimateEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[CLIMATE].values()
)
class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
"""Climate device."""
_attr_hvac_modes = [HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF]
_attr_preset_modes = [PRESET_MODE_AUTO, PRESET_MODE_MANUAL]
_attr_hvac_modes = [HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF]
_attr_max_temp = 30
_attr_min_temp = 5
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.PRESET_MODE
)
_attr_target_temperature_step = PRECISION_TENTHS
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_name = None
_attr_translation_key = "thermostat"
def __init__(
self,
@@ -140,14 +118,20 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
def _update_attributes(self) -> None:
"""Update class attributes."""
device = self.coordinator.data[CLIMATE][self._device.index]
values = load_api_data(device, CLIMATE_DOMAIN)
if not isinstance(device.val, list):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="invalid_clima_data"
)
# CLIMATE has a 2 item tuple:
# - first for Clima
# - second for Humidifier
values = device.val[0]
_active = values[1]
_mode = values[2] # Values from API: "O", "L", "U"
_automatic = values[3] == ClimaComelitMode.AUTO
self._attr_preset_mode = PRESET_MODE_AUTO if _automatic else PRESET_MODE_MANUAL
self._attr_current_temperature = values[0] / 10
self._attr_hvac_action = None
@@ -157,6 +141,10 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
self._attr_hvac_action = API_STATUS[_mode]["hvac_action"]
self._attr_hvac_mode = None
if _mode == ClimaComelitMode.OFF:
self._attr_hvac_mode = HVACMode.OFF
if _automatic:
self._attr_hvac_mode = HVACMode.AUTO
if _mode in API_STATUS:
self._attr_hvac_mode = API_STATUS[_mode]["hvac_mode"]
@@ -172,12 +160,13 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if (
(target_temp := kwargs.get(ATTR_TEMPERATURE)) is None
or self.hvac_mode == HVACMode.OFF
or self._attr_preset_mode == PRESET_MODE_AUTO
):
target_temp := kwargs.get(ATTR_TEMPERATURE)
) is None or self.hvac_mode == HVACMode.OFF:
return
await self.coordinator.api.set_clima_status(
self._device.index, ClimaComelitCommand.MANUAL
)
await self.coordinator.api.set_clima_status(
self._device.index, ClimaComelitCommand.SET, target_temp
)
@@ -188,28 +177,12 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode."""
if self._attr_hvac_mode == HVACMode.OFF:
if hvac_mode != HVACMode.OFF:
await self.coordinator.api.set_clima_status(
self._device.index, ClimaComelitCommand.ON
)
await self.coordinator.api.set_clima_status(
self._device.index, HVACMODE_TO_ACTION[hvac_mode]
self._device.index, MODE_TO_ACTION[hvac_mode]
)
self._attr_hvac_mode = hvac_mode
self.async_write_ha_state()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new target preset mode."""
if self._attr_hvac_mode == HVACMode.OFF:
return
await self.coordinator.api.set_clima_status(
self._device.index, PRESET_MODE_TO_ACTION[preset_mode]
)
self._attr_preset_mode = preset_mode
if preset_mode == PRESET_MODE_AUTO:
self._attr_target_temperature = PRESET_MODE_AUTO_TARGET_TEMP
self.async_write_ha_state()

View File

@@ -11,8 +11,3 @@ DEFAULT_PORT = 80
DEVICE_TYPE_LIST = [BRIDGE, VEDO]
SCAN_INTERVAL = 5
PRESET_MODE_AUTO = "automatic"
PRESET_MODE_MANUAL = "manual"
PRESET_MODE_AUTO_TARGET_TEMP = 20

View File

@@ -9,7 +9,6 @@ from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import CLIMATE
from homeassistant.components.humidifier import (
DOMAIN as HUMIDIFIER_DOMAIN,
MODE_AUTO,
MODE_NORMAL,
HumidifierAction,
@@ -18,13 +17,13 @@ from homeassistant.components.humidifier import (
HumidifierEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
from .entity import ComelitBridgeBaseEntity
from .utils import bridge_api_call, cleanup_stale_entity, load_api_data
from .utils import bridge_api_call
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -68,23 +67,6 @@ async def async_setup_entry(
entities: list[ComelitHumidifierEntity] = []
for device in coordinator.data[CLIMATE].values():
values = load_api_data(device, HUMIDIFIER_DOMAIN)
if values[0] == 0 and values[4] == 0:
# No humidity data, device is only a climate
for device_class in (
HumidifierDeviceClass.HUMIDIFIER,
HumidifierDeviceClass.DEHUMIDIFIER,
):
await cleanup_stale_entity(
hass,
config_entry,
f"{config_entry.entry_id}-{device.index}-{device_class}",
device,
)
continue
entities.append(
ComelitHumidifierEntity(
coordinator,
@@ -142,7 +124,15 @@ class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity):
def _update_attributes(self) -> None:
"""Update class attributes."""
device = self.coordinator.data[CLIMATE][self._device.index]
values = load_api_data(device, HUMIDIFIER_DOMAIN)
if not isinstance(device.val, list):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="invalid_clima_data"
)
# CLIMATE has a 2 item tuple:
# - first for Clima
# - second for Humidifier
values = device.val[1]
_active = values[1]
_mode = values[2] # Values from API: "O", "L", "U"

View File

@@ -4,18 +4,6 @@
"zone_status": {
"default": "mdi:shield-check"
}
},
"climate": {
"thermostat": {
"state_attributes": {
"preset_mode": {
"state": {
"automatic": "mdi:refresh-auto",
"manual": "mdi:alpha-m"
}
}
}
}
}
}
}

View File

@@ -74,18 +74,6 @@
"dehumidifier": {
"name": "Dehumidifier"
}
},
"climate": {
"thermostat": {
"state_attributes": {
"preset_mode": {
"state": {
"automatic": "[%key:common::state::auto%]",
"manual": "[%key:common::state::manual%]"
}
}
}
}
}
},
"exceptions": {

View File

@@ -4,21 +4,14 @@ from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
from aiohttp import ClientSession, CookieJar
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
aiohttp_client,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers import aiohttp_client
from .const import _LOGGER, DOMAIN
from .const import DOMAIN
from .entity import ComelitBridgeBaseEntity
@@ -29,61 +22,6 @@ async def async_client_session(hass: HomeAssistant) -> ClientSession:
)
def load_api_data(device: ComelitSerialBridgeObject, domain: str) -> list[Any]:
"""Load data from the API."""
# This function is called when the data is loaded from the API
if not isinstance(device.val, list):
raise HomeAssistantError(
translation_domain=domain, translation_key="invalid_clima_data"
)
# CLIMATE has a 2 item tuple:
# - first for Clima
# - second for Humidifier
return device.val[0] if domain == CLIMATE_DOMAIN else device.val[1]
async def cleanup_stale_entity(
hass: HomeAssistant,
config_entry: ConfigEntry,
entry_unique_id: str,
device: ComelitSerialBridgeObject,
) -> None:
"""Cleanup stale entity."""
entity_reg: er.EntityRegistry = er.async_get(hass)
identifiers: list[str] = []
for entry in er.async_entries_for_config_entry(entity_reg, config_entry.entry_id):
if entry.unique_id == entry_unique_id:
entry_name = entry.name or entry.original_name
_LOGGER.info("Removing entity: %s [%s]", entry.entity_id, entry_name)
entity_reg.async_remove(entry.entity_id)
identifiers.append(f"{config_entry.entry_id}-{device.type}-{device.index}")
if len(identifiers) > 0:
_async_remove_state_config_entry_from_devices(hass, identifiers, config_entry)
def _async_remove_state_config_entry_from_devices(
hass: HomeAssistant, identifiers: list[str], config_entry: ConfigEntry
) -> None:
"""Remove config entry from device."""
device_registry = dr.async_get(hass)
for identifier in identifiers:
device = device_registry.async_get_device(identifiers={(DOMAIN, identifier)})
if device:
_LOGGER.info(
"Removing config entry %s from device %s",
config_entry.title,
device.name,
)
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=config_entry.entry_id,
)
def bridge_api_call[_T: ComelitBridgeBaseEntity, **_P](
func: Callable[Concatenate[_T, _P], Awaitable[None]],
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:

View File

@@ -9,12 +9,10 @@ from typing import Any
from homeassistant.components.notify import BaseNotificationService
from homeassistant.const import CONF_COMMAND
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.process import kill_subprocess
from .const import CONF_COMMAND_TIMEOUT, LOGGER
from .const import CONF_COMMAND_TIMEOUT
_LOGGER = logging.getLogger(__name__)
@@ -45,31 +43,8 @@ class CommandLineNotificationService(BaseNotificationService):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a command line."""
command = self.command
if " " not in command:
prog = command
args = None
args_compiled = None
else:
prog, args = command.split(" ", 1)
args_compiled = Template(args, self.hass)
rendered_args = None
if args_compiled:
args_to_render = {"arguments": args}
try:
rendered_args = args_compiled.async_render(args_to_render)
except TemplateError as ex:
LOGGER.exception("Error rendering command template: %s", ex)
return
if rendered_args != args:
command = f"{prog} {rendered_args}"
LOGGER.debug("Running command: %s, with message: %s", command, message)
with subprocess.Popen( # noqa: S602 # shell by design
command,
self.command,
universal_newlines=True,
stdin=subprocess.PIPE,
close_fds=False, # required for posix_spawn
@@ -81,10 +56,10 @@ class CommandLineNotificationService(BaseNotificationService):
_LOGGER.error(
"Command failed (with return code %s): %s",
proc.returncode,
command,
self.command,
)
except subprocess.TimeoutExpired:
_LOGGER.error("Timeout for command: %s", command)
_LOGGER.error("Timeout for command: %s", self.command)
kill_subprocess(proc)
except subprocess.SubprocessError:
_LOGGER.error("Error trying to exec command: %s", command)
_LOGGER.error("Error trying to exec command: %s", self.command)

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
@@ -11,23 +10,18 @@ from homeassistant import config_entries
from homeassistant.components import websocket_api
from homeassistant.components.websocket_api import ERR_NOT_FOUND, require_admin
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.entity_component import async_get_entity_suggested_object_id
from homeassistant.helpers.json import json_dumps
_LOGGER = logging.getLogger(__name__)
@callback
def async_setup(hass: HomeAssistant) -> bool:
"""Enable the Entity Registry views."""
websocket_api.async_register_command(hass, websocket_get_automatic_entity_ids)
websocket_api.async_register_command(hass, websocket_get_entities)
websocket_api.async_register_command(hass, websocket_get_entity)
websocket_api.async_register_command(hass, websocket_list_entities_for_display)
@@ -322,54 +316,3 @@ def websocket_remove_entity(
registry.async_remove(msg["entity_id"])
connection.send_message(websocket_api.result_message(msg["id"]))
@websocket_api.websocket_command(
{
vol.Required("type"): "config/entity_registry/get_automatic_entity_ids",
vol.Required("entity_ids"): cv.entity_ids,
}
)
@callback
def websocket_get_automatic_entity_ids(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return the automatic entity IDs for the given entity IDs.
This is used to help user reset entity IDs which have been customized by the user.
"""
registry = er.async_get(hass)
entity_ids = msg["entity_ids"]
automatic_entity_ids: dict[str, str | None] = {}
reserved_entity_ids: set[str] = set()
for entity_id in entity_ids:
if not (entry := registry.entities.get(entity_id)):
automatic_entity_ids[entity_id] = None
continue
try:
suggested = async_get_entity_suggested_object_id(hass, entity_id)
except HomeAssistantError as err:
# This is raised if the entity has no object.
_LOGGER.debug(
"Unable to get suggested object ID for %s, entity ID: %s (%s)",
entry.entity_id,
entity_id,
err,
)
automatic_entity_ids[entity_id] = None
continue
suggested_entity_id = registry.async_generate_entity_id(
entry.domain,
suggested or f"{entry.platform}_{entry.unique_id}",
current_entity_id=entity_id,
reserved_entity_ids=reserved_entity_ids,
)
automatic_entity_ids[entity_id] = suggested_entity_id
reserved_entity_ids.add(suggested_entity_id)
connection.send_message(
websocket_api.result_message(msg["id"], automatic_entity_ids)
)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.6.10"]
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.5.7"]
}

View File

@@ -1,4 +1 @@
"""The cups component."""
DOMAIN = "cups"
CONF_PRINTERS = "printers"

View File

@@ -14,15 +14,12 @@ from homeassistant.components.sensor import (
SensorEntity,
)
from homeassistant.const import CONF_HOST, CONF_PORT, PERCENTAGE
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import CONF_PRINTERS, DOMAIN
_LOGGER = logging.getLogger(__name__)
ATTR_MARKER_TYPE = "marker_type"
@@ -39,6 +36,7 @@ ATTR_PRINTER_STATE_REASON = "printer_state_reason"
ATTR_PRINTER_TYPE = "printer_type"
ATTR_PRINTER_URI_SUPPORTED = "printer_uri_supported"
CONF_PRINTERS = "printers"
CONF_IS_CUPS_SERVER = "is_cups_server"
DEFAULT_HOST = "127.0.0.1"
@@ -74,21 +72,6 @@ def setup_platform(
printers: list[str] = config[CONF_PRINTERS]
is_cups: bool = config[CONF_IS_CUPS_SERVER]
create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_system_packages_yaml_integration_{DOMAIN}",
breaks_in_ha_version="2025.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_system_packages_yaml_integration",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "CUPS",
},
)
if is_cups:
data = CupsData(host, port, None)
data.update()

View File

@@ -1,3 +1 @@
"""The decora component."""
DOMAIN = "decora"

View File

@@ -21,11 +21,7 @@ from homeassistant.components.light import (
LightEntity,
)
from homeassistant.const import CONF_API_KEY, CONF_DEVICES, CONF_NAME
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
from . import DOMAIN
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
@@ -94,21 +90,6 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up an Decora switch."""
create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_system_packages_yaml_integration_{DOMAIN}",
breaks_in_ha_version="2025.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_system_packages_yaml_integration",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Leviton Decora",
},
)
lights = []
for address, device_config in config[CONF_DEVICES].items():
device = {}

View File

@@ -6,10 +6,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_SOURCE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device import (
async_entity_id_to_device_id,
async_remove_stale_devices_links_keep_entity_device,
)
from homeassistant.helpers.helper_integration import async_handle_source_entity_changes
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -19,28 +17,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass, entry.entry_id, entry.options[CONF_SOURCE]
)
def set_source_entity_id_or_uuid(source_entity_id: str) -> None:
hass.config_entries.async_update_entry(
entry,
options={**entry.options, CONF_SOURCE: source_entity_id},
)
async def source_entity_removed() -> None:
# The source entity has been removed, we need to clean the device links.
async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None)
entry.async_on_unload(
async_handle_source_entity_changes(
hass,
helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
source_device_id=async_entity_id_to_device_id(
hass, entry.options[CONF_SOURCE]
),
source_entity_id_or_uuid=entry.options[CONF_SOURCE],
source_entity_removed=source_entity_removed,
)
)
await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,))
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True

View File

@@ -2,13 +2,27 @@
from __future__ import annotations
from asyncio import Semaphore
from dataclasses import dataclass
import logging
from typing import Any
from devolo_plc_api import Device
from devolo_plc_api.exceptions.device import DeviceNotFound
from devolo_plc_api.device_api import (
ConnectedStationInfo,
NeighborAPInfo,
UpdateFirmwareCheck,
WifiGuestAccessGet,
)
from devolo_plc_api.exceptions.device import (
DeviceNotFound,
DevicePasswordProtected,
DeviceUnavailable,
)
from devolo_plc_api.plcnet_api import LogicalNetwork
from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_IP_ADDRESS,
CONF_PASSWORD,
@@ -16,34 +30,38 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import UpdateFailed
from .const import (
CONNECTED_PLC_DEVICES,
CONNECTED_WIFI_CLIENTS,
DOMAIN,
FIRMWARE_UPDATE_INTERVAL,
LAST_RESTART,
LONG_UPDATE_INTERVAL,
NEIGHBORING_WIFI_NETWORKS,
REGULAR_FIRMWARE,
SHORT_UPDATE_INTERVAL,
SWITCH_GUEST_WIFI,
SWITCH_LEDS,
)
from .coordinator import (
DevoloDataUpdateCoordinator,
DevoloFirmwareUpdateCoordinator,
DevoloHomeNetworkConfigEntry,
DevoloHomeNetworkData,
DevoloLedSettingsGetCoordinator,
DevoloLogicalNetworkCoordinator,
DevoloUptimeGetCoordinator,
DevoloWifiConnectedStationsGetCoordinator,
DevoloWifiGuestAccessGetCoordinator,
DevoloWifiNeighborAPsGetCoordinator,
)
from .coordinator import DevoloDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
type DevoloHomeNetworkConfigEntry = ConfigEntry[DevoloHomeNetworkData]
@dataclass
class DevoloHomeNetworkData:
"""The devolo Home Network data."""
device: Device
coordinators: dict[str, DevoloDataUpdateCoordinator[Any]]
async def async_setup_entry(
hass: HomeAssistant, entry: DevoloHomeNetworkConfigEntry
@@ -51,6 +69,8 @@ async def async_setup_entry(
"""Set up devolo Home Network from a config entry."""
zeroconf_instance = await zeroconf.async_get_async_instance(hass)
async_client = get_async_client(hass)
device_registry = dr.async_get(hass)
semaphore = Semaphore(1)
try:
device = Device(
@@ -70,52 +90,177 @@ async def async_setup_entry(
entry.runtime_data = DevoloHomeNetworkData(device=device, coordinators={})
async def async_update_firmware_available() -> UpdateFirmwareCheck:
"""Fetch data from API endpoint."""
assert device.device
update_sw_version(device_registry, device)
try:
return await device.device.async_check_firmware_available()
except DeviceUnavailable as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"error": str(err)},
) from err
async def async_update_connected_plc_devices() -> LogicalNetwork:
"""Fetch data from API endpoint."""
assert device.plcnet
update_sw_version(device_registry, device)
try:
return await device.plcnet.async_get_network_overview()
except DeviceUnavailable as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"error": str(err)},
) from err
async def async_update_guest_wifi_status() -> WifiGuestAccessGet:
"""Fetch data from API endpoint."""
assert device.device
update_sw_version(device_registry, device)
try:
return await device.device.async_get_wifi_guest_access()
except DeviceUnavailable as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"error": str(err)},
) from err
except DevicePasswordProtected as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="password_wrong"
) from err
async def async_update_led_status() -> bool:
"""Fetch data from API endpoint."""
assert device.device
update_sw_version(device_registry, device)
try:
return await device.device.async_get_led_setting()
except DeviceUnavailable as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"error": str(err)},
) from err
async def async_update_last_restart() -> int:
"""Fetch data from API endpoint."""
assert device.device
update_sw_version(device_registry, device)
try:
return await device.device.async_uptime()
except DeviceUnavailable as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"error": str(err)},
) from err
except DevicePasswordProtected as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="password_wrong"
) from err
async def async_update_wifi_connected_station() -> list[ConnectedStationInfo]:
"""Fetch data from API endpoint."""
assert device.device
update_sw_version(device_registry, device)
try:
return await device.device.async_get_wifi_connected_station()
except DeviceUnavailable as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"error": str(err)},
) from err
async def async_update_wifi_neighbor_access_points() -> list[NeighborAPInfo]:
"""Fetch data from API endpoint."""
assert device.device
update_sw_version(device_registry, device)
try:
return await device.device.async_get_wifi_neighbor_access_points()
except DeviceUnavailable as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"error": str(err)},
) from err
async def disconnect(event: Event) -> None:
"""Disconnect from device."""
await device.async_disconnect()
coordinators: dict[str, DevoloDataUpdateCoordinator[Any]] = {}
if device.plcnet:
coordinators[CONNECTED_PLC_DEVICES] = DevoloLogicalNetworkCoordinator(
coordinators[CONNECTED_PLC_DEVICES] = DevoloDataUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
name=CONNECTED_PLC_DEVICES,
semaphore=semaphore,
update_method=async_update_connected_plc_devices,
update_interval=LONG_UPDATE_INTERVAL,
)
if device.device and "led" in device.device.features:
coordinators[SWITCH_LEDS] = DevoloLedSettingsGetCoordinator(
coordinators[SWITCH_LEDS] = DevoloDataUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
name=SWITCH_LEDS,
semaphore=semaphore,
update_method=async_update_led_status,
update_interval=SHORT_UPDATE_INTERVAL,
)
if device.device and "restart" in device.device.features:
coordinators[LAST_RESTART] = DevoloUptimeGetCoordinator(
coordinators[LAST_RESTART] = DevoloDataUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
name=LAST_RESTART,
semaphore=semaphore,
update_method=async_update_last_restart,
update_interval=SHORT_UPDATE_INTERVAL,
)
if device.device and "update" in device.device.features:
coordinators[REGULAR_FIRMWARE] = DevoloFirmwareUpdateCoordinator(
coordinators[REGULAR_FIRMWARE] = DevoloDataUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
name=REGULAR_FIRMWARE,
semaphore=semaphore,
update_method=async_update_firmware_available,
update_interval=FIRMWARE_UPDATE_INTERVAL,
)
if device.device and "wifi1" in device.device.features:
coordinators[CONNECTED_WIFI_CLIENTS] = (
DevoloWifiConnectedStationsGetCoordinator(
hass,
_LOGGER,
config_entry=entry,
)
)
coordinators[NEIGHBORING_WIFI_NETWORKS] = DevoloWifiNeighborAPsGetCoordinator(
coordinators[CONNECTED_WIFI_CLIENTS] = DevoloDataUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
name=CONNECTED_WIFI_CLIENTS,
semaphore=semaphore,
update_method=async_update_wifi_connected_station,
update_interval=SHORT_UPDATE_INTERVAL,
)
coordinators[SWITCH_GUEST_WIFI] = DevoloWifiGuestAccessGetCoordinator(
coordinators[NEIGHBORING_WIFI_NETWORKS] = DevoloDataUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
name=NEIGHBORING_WIFI_NETWORKS,
semaphore=semaphore,
update_method=async_update_wifi_neighbor_access_points,
update_interval=LONG_UPDATE_INTERVAL,
)
coordinators[SWITCH_GUEST_WIFI] = DevoloDataUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
name=SWITCH_GUEST_WIFI,
semaphore=semaphore,
update_method=async_update_guest_wifi_status,
update_interval=SHORT_UPDATE_INTERVAL,
)
for coordinator in coordinators.values():
@@ -158,3 +303,16 @@ def platforms(device: Device) -> set[Platform]:
if device.device and "update" in device.device.features:
supported_platforms.add(Platform.UPDATE)
return supported_platforms
@callback
def update_sw_version(device_registry: dr.DeviceRegistry, device: Device) -> None:
"""Update device registry with new firmware version."""
if (
device_entry := device_registry.async_get_device(
identifiers={(DOMAIN, str(device.serial_number))}
)
) and device_entry.sw_version != device.firmware_version:
device_registry.async_update_device(
device_id=device_entry.id, sw_version=device.firmware_version
)

View File

@@ -16,8 +16,9 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeNetworkConfigEntry
from .const import CONNECTED_PLC_DEVICES, CONNECTED_TO_ROUTER
from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry
from .coordinator import DevoloDataUpdateCoordinator
from .entity import DevoloCoordinatorEntity
PARALLEL_UPDATES = 0

View File

@@ -18,8 +18,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeNetworkConfigEntry
from .const import DOMAIN, IDENTIFY, PAIRING, RESTART, START_WPS
from .coordinator import DevoloHomeNetworkConfigEntry
from .entity import DevoloEntity
PARALLEL_UPDATES = 0

View File

@@ -17,8 +17,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import DevoloHomeNetworkConfigEntry
from .const import DOMAIN, PRODUCT, SERIAL_NUMBER, TITLE
from .coordinator import DevoloHomeNetworkConfigEntry
_LOGGER = logging.getLogger(__name__)

View File

@@ -1,44 +1,13 @@
"""Base coordinator."""
from asyncio import Semaphore
from dataclasses import dataclass
from collections.abc import Awaitable, Callable
from datetime import timedelta
from logging import Logger
from typing import Any
from devolo_plc_api import Device
from devolo_plc_api.device_api import (
ConnectedStationInfo,
NeighborAPInfo,
UpdateFirmwareCheck,
WifiGuestAccessGet,
)
from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable
from devolo_plc_api.plcnet_api import LogicalNetwork
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
CONNECTED_PLC_DEVICES,
CONNECTED_WIFI_CLIENTS,
DOMAIN,
FIRMWARE_UPDATE_INTERVAL,
LAST_RESTART,
LONG_UPDATE_INTERVAL,
NEIGHBORING_WIFI_NETWORKS,
REGULAR_FIRMWARE,
SHORT_UPDATE_INTERVAL,
SWITCH_GUEST_WIFI,
SWITCH_LEDS,
)
SEMAPHORE = Semaphore(1)
type DevoloHomeNetworkConfigEntry = ConfigEntry[DevoloHomeNetworkData]
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
class DevoloDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
@@ -49,256 +18,24 @@ class DevoloDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
hass: HomeAssistant,
logger: Logger,
*,
config_entry: DevoloHomeNetworkConfigEntry,
config_entry: ConfigEntry,
name: str,
update_interval: timedelta | None = None,
semaphore: Semaphore,
update_interval: timedelta,
update_method: Callable[[], Awaitable[_DataT]],
) -> None:
"""Initialize global data updater."""
self.device = config_entry.runtime_data.device
super().__init__(
hass,
logger,
config_entry=config_entry,
name=name,
update_interval=update_interval,
update_method=update_method,
)
self._semaphore = semaphore
async def _async_update_data(self) -> _DataT:
"""Fetch the latest data from the source."""
self.update_sw_version()
async with SEMAPHORE:
try:
return await super()._async_update_data()
except DeviceUnavailable as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"error": str(err)},
) from err
except DevicePasswordProtected as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="password_wrong"
) from err
@callback
def update_sw_version(self) -> None:
"""Update device registry with new firmware version, if it changed at runtime."""
device_registry = dr.async_get(self.hass)
if (
device_entry := device_registry.async_get_device(
identifiers={(DOMAIN, self.device.serial_number)}
)
) and device_entry.sw_version != self.device.firmware_version:
device_registry.async_update_device(
device_id=device_entry.id, sw_version=self.device.firmware_version
)
class DevoloFirmwareUpdateCoordinator(DevoloDataUpdateCoordinator[UpdateFirmwareCheck]):
"""Class to manage fetching data from the UpdateFirmwareCheck endpoint."""
def __init__(
self,
hass: HomeAssistant,
logger: Logger,
*,
config_entry: ConfigEntry,
name: str = REGULAR_FIRMWARE,
update_interval: timedelta | None = FIRMWARE_UPDATE_INTERVAL,
) -> None:
"""Initialize global data updater."""
super().__init__(
hass,
logger,
config_entry=config_entry,
name=name,
update_interval=update_interval,
)
self.update_method = self.async_update_firmware_available
async def async_update_firmware_available(self) -> UpdateFirmwareCheck:
"""Fetch data from API endpoint."""
assert self.device.device
return await self.device.device.async_check_firmware_available()
class DevoloLedSettingsGetCoordinator(DevoloDataUpdateCoordinator[bool]):
"""Class to manage fetching data from the LedSettingsGet endpoint."""
def __init__(
self,
hass: HomeAssistant,
logger: Logger,
*,
config_entry: ConfigEntry,
name: str = SWITCH_LEDS,
update_interval: timedelta | None = SHORT_UPDATE_INTERVAL,
) -> None:
"""Initialize global data updater."""
super().__init__(
hass,
logger,
config_entry=config_entry,
name=name,
update_interval=update_interval,
)
self.update_method = self.async_update_led_status
async def async_update_led_status(self) -> bool:
"""Fetch data from API endpoint."""
assert self.device.device
return await self.device.device.async_get_led_setting()
class DevoloLogicalNetworkCoordinator(DevoloDataUpdateCoordinator[LogicalNetwork]):
"""Class to manage fetching data from the GetNetworkOverview endpoint."""
def __init__(
self,
hass: HomeAssistant,
logger: Logger,
*,
config_entry: ConfigEntry,
name: str = CONNECTED_PLC_DEVICES,
update_interval: timedelta | None = LONG_UPDATE_INTERVAL,
) -> None:
"""Initialize global data updater."""
super().__init__(
hass,
logger,
config_entry=config_entry,
name=name,
update_interval=update_interval,
)
self.update_method = self.async_update_connected_plc_devices
async def async_update_connected_plc_devices(self) -> LogicalNetwork:
"""Fetch data from API endpoint."""
assert self.device.plcnet
return await self.device.plcnet.async_get_network_overview()
class DevoloUptimeGetCoordinator(DevoloDataUpdateCoordinator[int]):
"""Class to manage fetching data from the UptimeGet endpoint."""
def __init__(
self,
hass: HomeAssistant,
logger: Logger,
*,
config_entry: ConfigEntry,
name: str = LAST_RESTART,
update_interval: timedelta | None = SHORT_UPDATE_INTERVAL,
) -> None:
"""Initialize global data updater."""
super().__init__(
hass,
logger,
config_entry=config_entry,
name=name,
update_interval=update_interval,
)
self.update_method = self.async_update_last_restart
async def async_update_last_restart(self) -> int:
"""Fetch data from API endpoint."""
assert self.device.device
return await self.device.device.async_uptime()
class DevoloWifiConnectedStationsGetCoordinator(
DevoloDataUpdateCoordinator[list[ConnectedStationInfo]]
):
"""Class to manage fetching data from the WifiGuestAccessGet endpoint."""
def __init__(
self,
hass: HomeAssistant,
logger: Logger,
*,
config_entry: ConfigEntry,
name: str = CONNECTED_WIFI_CLIENTS,
update_interval: timedelta | None = SHORT_UPDATE_INTERVAL,
) -> None:
"""Initialize global data updater."""
super().__init__(
hass,
logger,
config_entry=config_entry,
name=name,
update_interval=update_interval,
)
self.update_method = self.async_get_wifi_connected_station
async def async_get_wifi_connected_station(self) -> list[ConnectedStationInfo]:
"""Fetch data from API endpoint."""
assert self.device.device
return await self.device.device.async_get_wifi_connected_station()
class DevoloWifiGuestAccessGetCoordinator(
DevoloDataUpdateCoordinator[WifiGuestAccessGet]
):
"""Class to manage fetching data from the WifiGuestAccessGet endpoint."""
def __init__(
self,
hass: HomeAssistant,
logger: Logger,
*,
config_entry: ConfigEntry,
name: str = SWITCH_GUEST_WIFI,
update_interval: timedelta | None = SHORT_UPDATE_INTERVAL,
) -> None:
"""Initialize global data updater."""
super().__init__(
hass,
logger,
config_entry=config_entry,
name=name,
update_interval=update_interval,
)
self.update_method = self.async_update_guest_wifi_status
async def async_update_guest_wifi_status(self) -> WifiGuestAccessGet:
"""Fetch data from API endpoint."""
assert self.device.device
return await self.device.device.async_get_wifi_guest_access()
class DevoloWifiNeighborAPsGetCoordinator(
DevoloDataUpdateCoordinator[list[NeighborAPInfo]]
):
"""Class to manage fetching data from the WifiNeighborAPsGet endpoint."""
def __init__(
self,
hass: HomeAssistant,
logger: Logger,
*,
config_entry: ConfigEntry,
name: str = NEIGHBORING_WIFI_NETWORKS,
update_interval: timedelta | None = LONG_UPDATE_INTERVAL,
) -> None:
"""Initialize global data updater."""
super().__init__(
hass,
logger,
config_entry=config_entry,
name=name,
update_interval=update_interval,
)
self.update_method = self.async_update_wifi_neighbor_access_points
async def async_update_wifi_neighbor_access_points(self) -> list[NeighborAPInfo]:
"""Fetch data from API endpoint."""
assert self.device.device
return await self.device.device.async_get_wifi_neighbor_access_points()
@dataclass
class DevoloHomeNetworkData:
"""The devolo Home Network data."""
device: Device
coordinators: dict[str, DevoloDataUpdateCoordinator[Any]]
async with self._semaphore:
return await super()._async_update_data()

View File

@@ -15,8 +15,9 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import DevoloHomeNetworkConfigEntry
from .const import CONNECTED_WIFI_CLIENTS, DOMAIN, WIFI_APTYPE, WIFI_BANDS
from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry
from .coordinator import DevoloDataUpdateCoordinator
PARALLEL_UPDATES = 0

View File

@@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant
from .coordinator import DevoloHomeNetworkConfigEntry
from . import DevoloHomeNetworkConfigEntry
TO_REDACT = {CONF_PASSWORD}

View File

@@ -15,8 +15,9 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import DevoloHomeNetworkConfigEntry
from .const import DOMAIN
from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry
from .coordinator import DevoloDataUpdateCoordinator
type _DataType = (
LogicalNetwork

View File

@@ -15,8 +15,9 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import DevoloHomeNetworkConfigEntry
from .const import IMAGE_GUEST_WIFI, SWITCH_GUEST_WIFI
from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry
from .coordinator import DevoloDataUpdateCoordinator
from .entity import DevoloCoordinatorEntity
PARALLEL_UPDATES = 0

View File

@@ -22,6 +22,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from . import DevoloHomeNetworkConfigEntry
from .const import (
CONNECTED_PLC_DEVICES,
CONNECTED_WIFI_CLIENTS,
@@ -30,7 +31,7 @@ from .const import (
PLC_RX_RATE,
PLC_TX_RATE,
)
from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry
from .coordinator import DevoloDataUpdateCoordinator
from .entity import DevoloCoordinatorEntity
PARALLEL_UPDATES = 0

View File

@@ -16,8 +16,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeNetworkConfigEntry
from .const import DOMAIN, SWITCH_GUEST_WIFI, SWITCH_LEDS
from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry
from .coordinator import DevoloDataUpdateCoordinator
from .entity import DevoloCoordinatorEntity
PARALLEL_UPDATES = 0

View File

@@ -21,8 +21,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeNetworkConfigEntry
from .const import DOMAIN, REGULAR_FIRMWARE
from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEntry
from .coordinator import DevoloDataUpdateCoordinator
from .entity import DevoloCoordinatorEntity
PARALLEL_UPDATES = 0

View File

@@ -1,3 +1 @@
"""The dlib_face_detect component."""
DOMAIN = "dlib_face_detect"

View File

@@ -11,17 +11,10 @@ from homeassistant.components.image_processing import (
ImageProcessingFaceEntity,
)
from homeassistant.const import ATTR_LOCATION, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE
from homeassistant.core import (
DOMAIN as HOMEASSISTANT_DOMAIN,
HomeAssistant,
split_entity_id,
)
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN
PLATFORM_SCHEMA = IMAGE_PROCESSING_PLATFORM_SCHEMA
@@ -32,20 +25,6 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Dlib Face detection platform."""
create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_system_packages_yaml_integration_{DOMAIN}",
breaks_in_ha_version="2025.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_system_packages_yaml_integration",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Dlib Face Detect",
},
)
source: list[dict[str, str]] = config[CONF_SOURCE]
add_entities(
DlibFaceDetectEntity(camera[CONF_ENTITY_ID], camera.get(CONF_NAME))

View File

@@ -1,4 +1 @@
"""The dlib_face_identify component."""
CONF_FACES = "faces"
DOMAIN = "dlib_face_identify"

View File

@@ -15,20 +15,14 @@ from homeassistant.components.image_processing import (
ImageProcessingFaceEntity,
)
from homeassistant.const import ATTR_NAME, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE
from homeassistant.core import (
DOMAIN as HOMEASSISTANT_DOMAIN,
HomeAssistant,
split_entity_id,
)
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import CONF_FACES, DOMAIN
_LOGGER = logging.getLogger(__name__)
CONF_FACES = "faces"
PLATFORM_SCHEMA = IMAGE_PROCESSING_PLATFORM_SCHEMA.extend(
{
@@ -45,21 +39,6 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Dlib Face detection platform."""
create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_system_packages_yaml_integration_{DOMAIN}",
breaks_in_ha_version="2025.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_system_packages_yaml_integration",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Dlib Face Identify",
},
)
confidence: float = config[CONF_CONFIDENCE]
faces: dict[str, str] = config[CONF_FACES]
source: list[dict[str, str]] = config[CONF_SOURCE]

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
import contextlib
from typing import Any, Literal
from typing import Any
import aiodns
from aiodns.error import DNSError
@@ -62,16 +62,16 @@ async def async_validate_hostname(
"""Validate hostname."""
async def async_check(
hostname: str, resolver: str, qtype: Literal["A", "AAAA"], port: int = 53
hostname: str, resolver: str, qtype: str, port: int = 53
) -> bool:
"""Return if able to resolve hostname."""
result: bool = False
result = False
with contextlib.suppress(DNSError):
_resolver = aiodns.DNSResolver(
nameservers=[resolver], udp_port=port, tcp_port=port
result = bool(
await aiodns.DNSResolver( # type: ignore[call-overload]
nameservers=[resolver], udp_port=port, tcp_port=port
).query(hostname, qtype)
)
result = bool(await _resolver.query(hostname, qtype))
return result
result: dict[str, bool] = {}

View 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.3.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==13.2.1"]
}

View File

@@ -1,6 +1 @@
"""The eddystone_temperature component."""
DOMAIN = "eddystone_temperature"
CONF_BEACONS = "beacons"
CONF_INSTANCE = "instance"
CONF_NAMESPACE = "namespace"

View File

@@ -23,18 +23,17 @@ from homeassistant.const import (
STATE_UNKNOWN,
UnitOfTemperature,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant
from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import CONF_BEACONS, CONF_INSTANCE, CONF_NAMESPACE, DOMAIN
_LOGGER = logging.getLogger(__name__)
CONF_BEACONS = "beacons"
CONF_BT_DEVICE_ID = "bt_device_id"
CONF_INSTANCE = "instance"
CONF_NAMESPACE = "namespace"
BEACON_SCHEMA = vol.Schema(
{
@@ -59,21 +58,6 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Validate configuration, create devices and start monitoring thread."""
create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_system_packages_yaml_integration_{DOMAIN}",
breaks_in_ha_version="2025.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_system_packages_yaml_integration",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Eddystone",
},
)
bt_device_id: int = config[CONF_BT_DEVICE_ID]
beacons: dict[str, dict[str, str]] = config[CONF_BEACONS]

View File

@@ -4,7 +4,7 @@ from typing import Any
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.heater import EheimDigitalHeater
from eheimdigital.types import HeaterMode, HeaterUnit
from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit
from homeassistant.components.climate import (
PRESET_NONE,
@@ -20,11 +20,12 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import HEATER_BIO_MODE, HEATER_PRESET_TO_HEATER_MODE, HEATER_SMART_MODE
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
from .entity import EheimDigitalEntity, exception_handler
from .entity import EheimDigitalEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -82,28 +83,34 @@ class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateE
self._attr_unique_id = self._device_address
self._async_update_attrs()
@exception_handler
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode."""
if preset_mode in HEATER_PRESET_TO_HEATER_MODE:
await self._device.set_operation_mode(
HEATER_PRESET_TO_HEATER_MODE[preset_mode]
)
try:
if preset_mode in HEATER_PRESET_TO_HEATER_MODE:
await self._device.set_operation_mode(
HEATER_PRESET_TO_HEATER_MODE[preset_mode]
)
except EheimDigitalClientError as err:
raise HomeAssistantError from err
@exception_handler
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set a new temperature."""
if ATTR_TEMPERATURE in kwargs:
await self._device.set_target_temperature(kwargs[ATTR_TEMPERATURE])
try:
if ATTR_TEMPERATURE in kwargs:
await self._device.set_target_temperature(kwargs[ATTR_TEMPERATURE])
except EheimDigitalClientError as err:
raise HomeAssistantError from err
@exception_handler
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the heating mode."""
match hvac_mode:
case HVACMode.OFF:
await self._device.set_active(active=False)
case HVACMode.AUTO:
await self._device.set_active(active=True)
try:
match hvac_mode:
case HVACMode.OFF:
await self._device.set_active(active=False)
case HVACMode.AUTO:
await self._device.set_active(active=True)
except EheimDigitalClientError as err:
raise HomeAssistantError from err
def _async_update_attrs(self) -> None:
if self._device.temperature_unit == HeaterUnit.CELSIUS:

View File

@@ -1,19 +0,0 @@
"""Diagnostics for the EHEIM Digital integration."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .coordinator import EheimDigitalConfigEntry
TO_REDACT = {"emailAddr", "usrName"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: EheimDigitalConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return async_redact_data(
{"entry": entry.as_dict(), "data": entry.runtime_data.data}, TO_REDACT
)

View File

@@ -1,15 +1,12 @@
"""Base entity for EHEIM Digital."""
from abc import ABC, abstractmethod
from collections.abc import Callable, Coroutine
from typing import TYPE_CHECKING, Any, Concatenate
from typing import TYPE_CHECKING
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.types import EheimDigitalClientError
from homeassistant.const import CONF_HOST
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -54,24 +51,3 @@ class EheimDigitalEntity[_DeviceT: EheimDigitalDevice](
"""Update attributes when the coordinator updates."""
self._async_update_attrs()
super()._handle_coordinator_update()
def exception_handler[_EntityT: EheimDigitalEntity[EheimDigitalDevice], **_P](
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate AirGradient calls to handle exceptions.
A decorator that wraps the passed in function, catches AirGradient errors.
"""
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
try:
await func(self, *args, **kwargs)
except EheimDigitalClientError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(error)},
) from error
return handler

View File

@@ -4,7 +4,7 @@ from typing import Any
from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.types import LightMode
from eheimdigital.types import EheimDigitalClientError, LightMode
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -15,12 +15,13 @@ from homeassistant.components.light import (
LightEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import brightness_to_value, value_to_brightness
from .const import EFFECT_DAYCL_MODE, EFFECT_TO_LIGHT_MODE
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
from .entity import EheimDigitalEntity, exception_handler
from .entity import EheimDigitalEntity
BRIGHTNESS_SCALE = (1, 100)
@@ -87,22 +88,30 @@ class EheimDigitalClassicLEDControlLight(
"""Return whether the entity is available."""
return super().available and self._device.light_level[self._channel] is not None
@exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
if ATTR_EFFECT in kwargs:
await self._device.set_light_mode(EFFECT_TO_LIGHT_MODE[kwargs[ATTR_EFFECT]])
return
if ATTR_BRIGHTNESS in kwargs:
await self._device.turn_on(
int(brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS])),
self._channel,
)
if self._device.light_mode == LightMode.DAYCL_MODE:
await self._device.set_light_mode(LightMode.MAN_MODE)
try:
await self._device.turn_on(
int(brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS])),
self._channel,
)
except EheimDigitalClientError as err:
raise HomeAssistantError from err
@exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
await self._device.turn_off(self._channel)
if self._device.light_mode == LightMode.DAYCL_MODE:
await self._device.set_light_mode(LightMode.MAN_MODE)
try:
await self._device.turn_off(self._channel)
except EheimDigitalClientError as err:
raise HomeAssistantError from err
def _async_update_attrs(self) -> None:
light_level = self._device.light_level[self._channel]

View File

@@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
from .entity import EheimDigitalEntity, exception_handler
from .entity import EheimDigitalEntity
PARALLEL_UPDATES = 0
@@ -182,7 +182,6 @@ class EheimDigitalNumber(
self._attr_unique_id = f"{self._device_address}_{description.key}"
@override
@exception_handler
async def async_set_native_value(self, value: float) -> None:
return await self.entity_description.set_value_fn(self._device, value)

View File

@@ -43,7 +43,7 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info: done
discovery: done
docs-data-update: todo
@@ -58,7 +58,7 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo

View File

@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
from .entity import EheimDigitalEntity, exception_handler
from .entity import EheimDigitalEntity
PARALLEL_UPDATES = 0
@@ -94,7 +94,6 @@ class EheimDigitalSelect(
self._attr_unique_id = f"{self._device_address}_{description.key}"
@override
@exception_handler
async def async_select_option(self, option: str) -> None:
return await self.entity_description.set_value_fn(self._device, option)

View File

@@ -101,10 +101,5 @@
"name": "Night start time"
}
}
},
"exceptions": {
"communication_error": {
"message": "An error occurred while communicating with the EHEIM Digital hub: {error}"
}
}
}

View File

@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
from .entity import EheimDigitalEntity, exception_handler
from .entity import EheimDigitalEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -58,12 +58,10 @@ class EheimDigitalClassicVarioSwitch(
self._async_update_attrs()
@override
@exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
await self._device.set_active(active=False)
@override
@exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
await self._device.set_active(active=True)

View File

@@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
from .entity import EheimDigitalEntity, exception_handler
from .entity import EheimDigitalEntity
PARALLEL_UPDATES = 0
@@ -122,7 +122,6 @@ class EheimDigitalTime(
self._attr_unique_id = f"{device.mac_address}_{description.key}"
@override
@exception_handler
async def async_set_value(self, value: time) -> None:
"""Change the time."""
return await self.entity_description.set_value_fn(self._device, value)

View File

@@ -180,15 +180,9 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
return
device_registry.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
identifiers={
(
DOMAIN,
self.envoy_serial_number,
)
},
connections={connection},
device_registry.async_update_device(
device_id=envoy_device.id,
new_connections={connection},
)
_LOGGER.debug("added connection: %s to %s", connection, self.name)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
"iot_class": "cloud_polling",
"loggers": ["env_canada"],
"requirements": ["env-canada==0.11.2"]
"requirements": ["env-canada==0.10.2"]
}

View File

@@ -22,5 +22,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.16.0"]
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.15.1"]
}

View File

@@ -226,7 +226,6 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
_static_info: _InfoT
_state: _StateT
_has_state: bool
unique_id: str
def __init__(
self,

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
from functools import partial
import logging
import re
from typing import TYPE_CHECKING, Any, NamedTuple
from aioesphomeapi import (
@@ -22,7 +23,6 @@ from aioesphomeapi import (
RequiresEncryptionAPIError,
UserService,
UserServiceArgType,
parse_log_message,
)
from awesomeversion import AwesomeVersion
import voluptuous as vol
@@ -110,6 +110,11 @@ LOGGER_TO_LOG_LEVEL = {
logging.ERROR: LogLevel.LOG_LEVEL_ERROR,
logging.CRITICAL: LogLevel.LOG_LEVEL_ERROR,
}
# 7-bit and 8-bit C1 ANSI sequences
# https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python
ANSI_ESCAPE_78BIT = re.compile(
rb"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])"
)
@callback
@@ -382,15 +387,13 @@ class ESPHomeManager:
def _async_on_log(self, msg: SubscribeLogsResponse) -> None:
"""Handle a log message from the API."""
for line in parse_log_message(
msg.message.decode("utf-8", "backslashreplace"), "", strip_ansi_escapes=True
):
_LOGGER.log(
LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG),
"%s: %s",
self.entry.title,
line,
)
log: bytes = msg.message
_LOGGER.log(
LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG),
"%s: %s",
self.entry.title,
ANSI_ESCAPE_78BIT.sub(b"", log).decode("utf-8", "backslashreplace"),
)
@callback
def _async_get_equivalent_log_level(self) -> LogLevel:

View File

@@ -17,9 +17,9 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==32.2.1",
"aioesphomeapi==31.1.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==2.16.0"
"bleak-esphome==2.15.1"
],
"zeroconf": ["_esphomelib._tcp.local."]
}

View File

@@ -78,7 +78,7 @@ class EsphomeMediaPlayer(
if self._static_info.supports_pause:
flags |= MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY
self._attr_supported_features = flags
self._entry_data.media_player_formats[self.unique_id] = cast(
self._entry_data.media_player_formats[static_info.unique_id] = cast(
MediaPlayerInfo, static_info
).supported_formats
@@ -114,8 +114,9 @@ class EsphomeMediaPlayer(
media_id = async_process_play_media_url(self.hass, media_id)
announcement = kwargs.get(ATTR_MEDIA_ANNOUNCE)
bypass_proxy = kwargs.get(ATTR_MEDIA_EXTRA, {}).get(ATTR_BYPASS_PROXY)
supported_formats: list[MediaPlayerSupportedFormat] | None = (
self._entry_data.media_player_formats.get(self.unique_id)
self._entry_data.media_player_formats.get(self._static_info.unique_id)
)
if (
@@ -138,7 +139,7 @@ class EsphomeMediaPlayer(
async def async_will_remove_from_hass(self) -> None:
"""Handle entity being removed."""
await super().async_will_remove_from_hass()
self._entry_data.media_player_formats.pop(self.unique_id, None)
self._entry_data.media_player_formats.pop(self.entity_id, None)
def _get_proxy_url(
self,

View File

@@ -105,18 +105,9 @@ DATA_SCHEMA_SETUP = vol.Schema(
)
BASE_OPTIONS_SCHEMA = {
vol.Optional(CONF_ENTITY_ID): EntitySelector(EntitySelectorConfig(read_only=True)),
vol.Optional(CONF_FILTER_NAME): SelectSelector(
SelectSelectorConfig(
options=FILTERS,
mode=SelectSelectorMode.DROPDOWN,
translation_key=CONF_FILTER_NAME,
read_only=True,
)
),
vol.Optional(CONF_FILTER_PRECISION, default=DEFAULT_PRECISION): NumberSelector(
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
),
)
}
OUTLIER_SCHEMA = vol.Schema(

View File

@@ -23,16 +23,12 @@
"data": {
"window_size": "Window size",
"precision": "Precision",
"radius": "Radius",
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data::filter%]"
"radius": "Radius"
},
"data_description": {
"window_size": "Size of the window of previous states.",
"precision": "Defines the number of decimal places of the calculated sensor value.",
"radius": "Band radius from median of previous states.",
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
"radius": "Band radius from median of previous states."
}
},
"lowpass": {
@@ -40,16 +36,12 @@
"data": {
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
"time_constant": "Time constant",
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data::filter%]"
"time_constant": "Time constant"
},
"data_description": {
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
"time_constant": "Loosely relates to the amount of time it takes for a state to influence the output.",
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
"time_constant": "Loosely relates to the amount of time it takes for a state to influence the output."
}
},
"range": {
@@ -57,16 +49,12 @@
"data": {
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
"lower_bound": "Lower bound",
"upper_bound": "Upper bound",
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data::filter%]"
"upper_bound": "Upper bound"
},
"data_description": {
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
"lower_bound": "Lower bound for filter range.",
"upper_bound": "Upper bound for filter range.",
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
"upper_bound": "Upper bound for filter range."
}
},
"time_simple_moving_average": {
@@ -74,46 +62,34 @@
"data": {
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
"type": "Type",
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data::filter%]"
"type": "Type"
},
"data_description": {
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
"type": "Defines the type of Simple Moving Average.",
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
"type": "Defines the type of Simple Moving Average."
}
},
"throttle": {
"description": "[%key:component::filter::config::step::outlier::description%]",
"data": {
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data::filter%]"
"precision": "[%key:component::filter::config::step::outlier::data::precision%]"
},
"data_description": {
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]"
}
},
"time_throttle": {
"description": "[%key:component::filter::config::step::outlier::description%]",
"data": {
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data::filter%]"
"precision": "[%key:component::filter::config::step::outlier::data::precision%]"
},
"data_description": {
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]"
}
}
}
@@ -128,16 +104,12 @@
"data": {
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
"radius": "[%key:component::filter::config::step::outlier::data::radius%]",
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data::filter%]"
"radius": "[%key:component::filter::config::step::outlier::data::radius%]"
},
"data_description": {
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
"radius": "[%key:component::filter::config::step::outlier::data_description::radius%]",
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
"radius": "[%key:component::filter::config::step::outlier::data_description::radius%]"
}
},
"lowpass": {
@@ -145,16 +117,12 @@
"data": {
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
"time_constant": "[%key:component::filter::config::step::lowpass::data::time_constant%]",
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data::filter%]"
"time_constant": "[%key:component::filter::config::step::lowpass::data::time_constant%]"
},
"data_description": {
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
"time_constant": "[%key:component::filter::config::step::lowpass::data_description::time_constant%]",
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
"time_constant": "[%key:component::filter::config::step::lowpass::data_description::time_constant%]"
}
},
"range": {
@@ -162,16 +130,12 @@
"data": {
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
"lower_bound": "[%key:component::filter::config::step::range::data::lower_bound%]",
"upper_bound": "[%key:component::filter::config::step::range::data::upper_bound%]",
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data::filter%]"
"upper_bound": "[%key:component::filter::config::step::range::data::upper_bound%]"
},
"data_description": {
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
"lower_bound": "[%key:component::filter::config::step::range::data_description::lower_bound%]",
"upper_bound": "[%key:component::filter::config::step::range::data_description::upper_bound%]",
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
"upper_bound": "[%key:component::filter::config::step::range::data_description::upper_bound%]"
}
},
"time_simple_moving_average": {
@@ -179,46 +143,34 @@
"data": {
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
"type": "[%key:component::filter::config::step::time_simple_moving_average::data::type%]",
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data::filter%]"
"type": "[%key:component::filter::config::step::time_simple_moving_average::data::type%]"
},
"data_description": {
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
"type": "[%key:component::filter::config::step::time_simple_moving_average::data_description::type%]",
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
"type": "[%key:component::filter::config::step::time_simple_moving_average::data_description::type%]"
}
},
"throttle": {
"description": "[%key:component::filter::config::step::outlier::description%]",
"data": {
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data::filter%]"
"precision": "[%key:component::filter::config::step::outlier::data::precision%]"
},
"data_description": {
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]"
}
},
"time_throttle": {
"description": "[%key:component::filter::config::step::outlier::description%]",
"data": {
"window_size": "[%key:component::filter::config::step::outlier::data::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data::precision%]",
"entity_id": "[%key:component::filter::config::step::user::data::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data::filter%]"
"precision": "[%key:component::filter::config::step::outlier::data::precision%]"
},
"data_description": {
"window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]",
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]",
"entity_id": "[%key:component::filter::config::step::user::data_description::entity_id%]",
"filter": "[%key:component::filter::config::step::user::data_description::filter%]"
"precision": "[%key:component::filter::config::step::outlier::data_description::precision%]"
}
}
}

View File

@@ -9,7 +9,6 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, UnitOfDataRate, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
@@ -29,7 +28,6 @@ CONNECTION_SENSORS: tuple[SensorEntityDescription, ...] = (
key="rate_down",
name="Freebox download speed",
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND,
icon="mdi:download-network",
),
@@ -37,7 +35,6 @@ CONNECTION_SENSORS: tuple[SensorEntityDescription, ...] = (
key="rate_up",
name="Freebox upload speed",
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND,
icon="mdi:upload-network",
),
@@ -84,7 +81,6 @@ async def async_setup_entry(
name=f"Freebox {sensor_name}",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
)
for sensor_name in router.sensors_temperature

View File

@@ -12,5 +12,5 @@
"iot_class": "local_polling",
"loggers": ["pyfronius"],
"quality_scale": "platinum",
"requirements": ["PyFronius==0.8.0"]
"requirements": ["PyFronius==0.7.7"]
}

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250531.2"]
"requirements": ["home-assistant-frontend==20250516.0"]
}

View File

@@ -84,10 +84,7 @@ async def async_migrate_entry(
new[CONF_EXPIRATION] = credentials.expiration.isoformat()
hass.config_entries.async_update_entry(
config_entry,
data=new,
minor_version=2,
version=1,
config_entry, data=new, minor_version=2, version=1
)
_LOGGER.debug(

View File

@@ -2,20 +2,9 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
import logging
from typing import Final
from fyta_cli.fyta_models import Plant
from homeassistant.components.image import (
Image,
ImageEntity,
ImageEntityDescription,
valid_image_content_type,
)
from homeassistant.components.image import ImageEntity, ImageEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -23,30 +12,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FytaConfigEntry, FytaCoordinator
from .entity import FytaPlantEntity
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class FytaImageEntityDescription(ImageEntityDescription):
"""Describes Fyta image entity."""
url_fn: Callable[[Plant], str]
name_key: str | None = None
IMAGES: Final[list[FytaImageEntityDescription]] = [
FytaImageEntityDescription(
key="plant_image",
translation_key="plant_image",
url_fn=lambda plant: plant.plant_origin_path,
),
FytaImageEntityDescription(
key="plant_image_user",
translation_key="plant_image_user",
url_fn=lambda plant: plant.user_picture_path,
),
]
async def async_setup_entry(
hass: HomeAssistant,
@@ -56,17 +21,17 @@ async def async_setup_entry(
"""Set up the FYTA plant images."""
coordinator = entry.runtime_data
description = ImageEntityDescription(key="plant_image")
async_add_entities(
FytaPlantImageEntity(coordinator, entry, description, plant_id)
for plant_id in coordinator.fyta.plant_list
if plant_id in coordinator.data
for description in IMAGES
)
def _async_add_new_device(plant_id: int) -> None:
async_add_entities(
FytaPlantImageEntity(coordinator, entry, description, plant_id)
for description in IMAGES
[FytaPlantImageEntity(coordinator, entry, description, plant_id)]
)
coordinator.new_device_callbacks.append(_async_add_new_device)
@@ -75,49 +40,26 @@ async def async_setup_entry(
class FytaPlantImageEntity(FytaPlantEntity, ImageEntity):
"""Represents a Fyta image."""
entity_description: FytaImageEntityDescription
entity_description: ImageEntityDescription
def __init__(
self,
coordinator: FytaCoordinator,
entry: ConfigEntry,
description: FytaImageEntityDescription,
description: ImageEntityDescription,
plant_id: int,
) -> None:
"""Initialize Fyta Image entity."""
"""Initiatlize Fyta Image entity."""
super().__init__(coordinator, entry, description, plant_id)
ImageEntity.__init__(self, coordinator.hass)
async def async_image(self) -> bytes | None:
"""Return bytes of image."""
if self.entity_description.key == "plant_image_user":
if self._cached_image is None:
response = await self.coordinator.fyta.get_plant_image(
self.plant.user_picture_path
)
_LOGGER.debug("Response of downloading user image: %s", response)
if response is None:
_LOGGER.debug(
"%s: Error getting new image from %s",
self.entity_id,
self.plant.user_picture_path,
)
return None
content_type, raw_image = response
self._cached_image = Image(
valid_image_content_type(content_type), raw_image
)
return self._cached_image.content
return await ImageEntity.async_image(self)
self._attr_name = None
@property
def image_url(self) -> str:
"""Return the image_url for this plant."""
url = self.entity_description.url_fn(self.plant)
if url != self._attr_image_url:
self._cached_image = None
"""Return the image_url for this sensor."""
image = self.plant.plant_origin_path
if image != self._attr_image_url:
self._attr_image_last_updated = datetime.now()
return url
return image

View File

@@ -61,14 +61,6 @@
"name": "Sensor update available"
}
},
"image": {
"plant_image": {
"name": "Plant image"
},
"plant_image_user": {
"name": "User image"
}
},
"sensor": {
"scientific_name": {
"name": "Scientific name"

View File

@@ -5,18 +5,11 @@ import voluptuous as vol
from homeassistant.components.humidifier import HumidifierDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers import (
config_validation as cv,
discovery,
entity_registry as er,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.device import (
async_entity_id_to_device_id,
async_remove_stale_devices_links_keep_entity_device,
)
from homeassistant.helpers.event import async_track_entity_registry_updated_event
from homeassistant.helpers.helper_integration import async_handle_source_entity_changes
from homeassistant.helpers.typing import ConfigType
DOMAIN = "generic_hygrostat"
@@ -95,54 +88,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.options[CONF_HUMIDIFIER],
)
def set_humidifier_entity_id_or_uuid(source_entity_id: str) -> None:
hass.config_entries.async_update_entry(
entry,
options={**entry.options, CONF_HUMIDIFIER: source_entity_id},
)
async def source_entity_removed() -> None:
# The source entity has been removed, we need to clean the device links.
async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None)
entry.async_on_unload(
# We use async_handle_source_entity_changes to track changes to the humidifer,
# but not the humidity sensor because the generic_hygrostat adds itself to the
# humidifier's device.
async_handle_source_entity_changes(
hass,
helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid,
source_device_id=async_entity_id_to_device_id(
hass, entry.options[CONF_HUMIDIFIER]
),
source_entity_id_or_uuid=entry.options[CONF_HUMIDIFIER],
source_entity_removed=source_entity_removed,
)
)
async def async_sensor_updated(
event: Event[er.EventEntityRegistryUpdatedData],
) -> None:
"""Handle entity registry update."""
data = event.data
if data["action"] != "update":
return
if "entity_id" not in data["changes"]:
return
# Entity_id changed, update the config entry
hass.config_entries.async_update_entry(
entry,
options={**entry.options, CONF_SENSOR: data["entity_id"]},
)
entry.async_on_unload(
async_track_entity_registry_updated_event(
hass, entry.options[CONF_SENSOR], async_sensor_updated
)
)
await hass.config_entries.async_forward_entry_setups(entry, (Platform.HUMIDIFIER,))
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True

View File

@@ -1,16 +1,12 @@
"""The generic_thermostat component."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device import (
async_entity_id_to_device_id,
async_remove_stale_devices_links_keep_entity_device,
)
from homeassistant.helpers.event import async_track_entity_registry_updated_event
from homeassistant.helpers.helper_integration import async_handle_source_entity_changes
from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS
from .const import CONF_HEATER, PLATFORMS
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -21,55 +17,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.entry_id,
entry.options[CONF_HEATER],
)
def set_humidifier_entity_id_or_uuid(source_entity_id: str) -> None:
hass.config_entries.async_update_entry(
entry,
options={**entry.options, CONF_HEATER: source_entity_id},
)
async def source_entity_removed() -> None:
# The source entity has been removed, we need to clean the device links.
async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None)
entry.async_on_unload(
# We use async_handle_source_entity_changes to track changes to the heater, but
# not the temperature sensor because the generic_hygrostat adds itself to the
# heater's device.
async_handle_source_entity_changes(
hass,
helper_config_entry_id=entry.entry_id,
set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid,
source_device_id=async_entity_id_to_device_id(
hass, entry.options[CONF_HEATER]
),
source_entity_id_or_uuid=entry.options[CONF_HEATER],
source_entity_removed=source_entity_removed,
)
)
async def async_sensor_updated(
event: Event[er.EventEntityRegistryUpdatedData],
) -> None:
"""Handle entity registry update."""
data = event.data
if data["action"] != "update":
return
if "entity_id" not in data["changes"]:
return
# Entity_id changed, update the config entry
hass.config_entries.async_update_entry(
entry,
options={**entry.options, CONF_SENSOR: data["entity_id"]},
)
entry.async_on_unload(
async_track_entity_registry_updated_event(
hass, entry.options[CONF_SENSOR], async_sensor_updated
)
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True

View File

@@ -8,6 +8,6 @@
"integration_type": "system",
"iot_class": "local_polling",
"quality_scale": "internal",
"requirements": ["go2rtc-client==0.2.1"],
"requirements": ["go2rtc-client==0.1.3b0"],
"single_config_entry": true
}

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/google",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
"requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.0"]
"requirements": ["gcal-sync==7.0.1", "oauth2client==4.1.3", "ical==9.2.4"]
}

View File

@@ -12,7 +12,6 @@ import aiohttp
from aiohttp import web
from gassist_text import TextAssistant
from google.oauth2.credentials import Credentials
from grpc import RpcError
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.media_player import (
@@ -26,7 +25,6 @@ from homeassistant.components.media_player import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.event import async_call_later
@@ -85,17 +83,7 @@ async def async_send_text_commands(
) as assistant:
command_response_list = []
for command in commands:
try:
resp = await hass.async_add_executor_job(assistant.assist, command)
except RpcError as err:
_LOGGER.error(
"Failed to send command '%s' to Google Assistant: %s",
command,
err,
)
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="grpc_error"
) from err
resp = await hass.async_add_executor_job(assistant.assist, command)
text_response = resp[0]
_LOGGER.debug("command: %s\nresponse: %s", command, text_response)
audio_response = resp[2]

Some files were not shown because too many files have changed in this diff Show More