Merge branch 'dev' into hassfest_condition_target

This commit is contained in:
Abílio Costa 2025-07-30 15:58:29 +01:00 committed by GitHub
commit 4d9fe3a439
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
176 changed files with 15028 additions and 969 deletions

View File

@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.29.4
uses: github/codeql-action/init@v3.29.5
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.29.4
uses: github/codeql-action/analyze@v3.29.5
with:
category: "/language:python"

View File

@ -53,6 +53,7 @@ homeassistant.components.air_quality.*
homeassistant.components.airgradient.*
homeassistant.components.airly.*
homeassistant.components.airnow.*
homeassistant.components.airos.*
homeassistant.components.airq.*
homeassistant.components.airthings.*
homeassistant.components.airthings_ble.*

2
CODEOWNERS generated
View File

@ -67,6 +67,8 @@ build.json @home-assistant/supervisor
/tests/components/airly/ @bieniu
/homeassistant/components/airnow/ @asymworks
/tests/components/airnow/ @asymworks
/homeassistant/components/airos/ @CoMPaTech
/tests/components/airos/ @CoMPaTech
/homeassistant/components/airq/ @Sibgatulin @dl2080
/tests/components/airq/ @Sibgatulin @dl2080
/homeassistant/components/airthings/ @danielhiversen @LaStrada

View File

@ -0,0 +1,5 @@
{
"domain": "frient",
"name": "Frient",
"iot_standards": ["zigbee"]
}

View File

@ -1,5 +1,5 @@
{
"domain": "third_reality",
"name": "Third Reality",
"iot_standards": ["zigbee"]
"iot_standards": ["matter", "zigbee"]
}

View File

@ -1,5 +1,5 @@
{
"domain": "ubiquiti",
"name": "Ubiquiti",
"integrations": ["unifi", "unifi_direct", "unifiled", "unifiprotect"]
"integrations": ["airos", "unifi", "unifi_direct", "unifiled", "unifiprotect"]
}

View File

@ -0,0 +1,42 @@
"""The Ubiquiti airOS integration."""
from __future__ import annotations
from airos.airos8 import AirOS
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator
_PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Set up Ubiquiti airOS from a config entry."""
# By default airOS 8 comes with self-signed SSL certificates,
# with no option in the web UI to change or upload a custom certificate.
session = async_get_clientsession(hass, verify_ssl=False)
airos_device = AirOS(
host=entry.data[CONF_HOST],
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
session=session,
)
coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device)
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: AirOSConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)

View File

@ -0,0 +1,82 @@
"""Config flow for the Ubiquiti airOS integration."""
from __future__ import annotations
import logging
from typing import Any
from airos.exceptions import (
ConnectionAuthenticationError,
ConnectionSetupError,
DataMissingError,
DeviceConnectionError,
KeyDataMissingError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import AirOS
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME, default="ubnt"): str,
vol.Required(CONF_PASSWORD): str,
}
)
class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ubiquiti airOS."""
VERSION = 1
async def async_step_user(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
# By default airOS 8 comes with self-signed SSL certificates,
# with no option in the web UI to change or upload a custom certificate.
session = async_get_clientsession(self.hass, verify_ssl=False)
airos_device = AirOS(
host=user_input[CONF_HOST],
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
session=session,
)
try:
await airos_device.login()
airos_data = await airos_device.status()
except (
ConnectionSetupError,
DeviceConnectionError,
):
errors["base"] = "cannot_connect"
except (ConnectionAuthenticationError, DataMissingError):
errors["base"] = "invalid_auth"
except KeyDataMissingError:
errors["base"] = "key_data_missing"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(airos_data.derived.mac)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=airos_data.host.hostname, data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@ -0,0 +1,9 @@
"""Constants for the Ubiquiti airOS integration."""
from datetime import timedelta
DOMAIN = "airos"
SCAN_INTERVAL = timedelta(minutes=1)
MANUFACTURER = "Ubiquiti"

View File

@ -0,0 +1,66 @@
"""DataUpdateCoordinator for AirOS."""
from __future__ import annotations
import logging
from airos.airos8 import AirOS, AirOSData
from airos.exceptions import (
ConnectionAuthenticationError,
ConnectionSetupError,
DataMissingError,
DeviceConnectionError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator]
class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSData]):
"""Class to manage fetching AirOS data from single endpoint."""
config_entry: AirOSConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS
) -> None:
"""Initialize the coordinator."""
self.airos_device = airos_device
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
async def _async_update_data(self) -> AirOSData:
"""Fetch data from AirOS."""
try:
await self.airos_device.login()
return await self.airos_device.status()
except (ConnectionAuthenticationError,) as err:
_LOGGER.exception("Error authenticating with airOS device")
raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="invalid_auth"
) from err
except (ConnectionSetupError, DeviceConnectionError, TimeoutError) as err:
_LOGGER.error("Error connecting to airOS device: %s", err)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
except (DataMissingError,) as err:
_LOGGER.error("Expected data not returned by airOS device: %s", err)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="error_data_missing",
) from err

View File

@ -0,0 +1,36 @@
"""Generic AirOS Entity Class."""
from __future__ import annotations
from homeassistant.const import CONF_HOST
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import AirOSDataUpdateCoordinator
class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]):
"""Represent a AirOS Entity."""
_attr_has_entity_name = True
def __init__(self, coordinator: AirOSDataUpdateCoordinator) -> None:
"""Initialise the gateway."""
super().__init__(coordinator)
airos_data = self.coordinator.data
configuration_url: str | None = (
f"https://{coordinator.config_entry.data[CONF_HOST]}"
)
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, airos_data.derived.mac)},
configuration_url=configuration_url,
identifiers={(DOMAIN, str(airos_data.host.device_id))},
manufacturer=MANUFACTURER,
model=airos_data.host.devmodel,
name=airos_data.host.hostname,
sw_version=airos_data.host.fwversion,
)

View File

@ -0,0 +1,10 @@
{
"domain": "airos",
"name": "Ubiquiti airOS",
"codeowners": ["@CoMPaTech"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airos",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["airos==0.2.1"]
}

View File

@ -0,0 +1,72 @@
rules:
# Bronze
action-setup:
status: exempt
comment: airOS does not have 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: airOS does not have actions
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: local_polling without 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:
status: exempt
comment: airOS does not have actions
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: todo
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: todo
comment: prepared binary_sensors will provide this
entity-translations: done
exception-translations: done
icon-translations:
status: exempt
comment: no (custom) icons used or envisioned
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@ -0,0 +1,152 @@
"""AirOS Sensor component for Home Assistant."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from airos.data import NetRole, WirelessMode
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
UnitOfDataRate,
UnitOfFrequency,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import AirOSConfigEntry, AirOSData, AirOSDataUpdateCoordinator
from .entity import AirOSEntity
_LOGGER = logging.getLogger(__name__)
WIRELESS_MODE_OPTIONS = [mode.value.replace("-", "_").lower() for mode in WirelessMode]
NETROLE_OPTIONS = [mode.value for mode in NetRole]
@dataclass(frozen=True, kw_only=True)
class AirOSSensorEntityDescription(SensorEntityDescription):
"""Describe an AirOS sensor."""
value_fn: Callable[[AirOSData], StateType]
SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
AirOSSensorEntityDescription(
key="host_cpuload",
translation_key="host_cpuload",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.host.cpuload,
entity_registry_enabled_default=False,
),
AirOSSensorEntityDescription(
key="host_netrole",
translation_key="host_netrole",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data.host.netrole.value,
options=NETROLE_OPTIONS,
),
AirOSSensorEntityDescription(
key="wireless_frequency",
translation_key="wireless_frequency",
native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ,
device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.wireless.frequency,
),
AirOSSensorEntityDescription(
key="wireless_essid",
translation_key="wireless_essid",
value_fn=lambda data: data.wireless.essid,
),
AirOSSensorEntityDescription(
key="wireless_mode",
translation_key="wireless_mode",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data.wireless.mode.value.replace("-", "_").lower(),
options=WIRELESS_MODE_OPTIONS,
),
AirOSSensorEntityDescription(
key="wireless_antenna_gain",
translation_key="wireless_antenna_gain",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.wireless.antenna_gain,
),
AirOSSensorEntityDescription(
key="wireless_throughput_tx",
translation_key="wireless_throughput_tx",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.wireless.throughput.tx,
),
AirOSSensorEntityDescription(
key="wireless_throughput_rx",
translation_key="wireless_throughput_rx",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.wireless.throughput.rx,
),
AirOSSensorEntityDescription(
key="wireless_polling_dl_capacity",
translation_key="wireless_polling_dl_capacity",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.wireless.polling.dl_capacity,
),
AirOSSensorEntityDescription(
key="wireless_polling_ul_capacity",
translation_key="wireless_polling_ul_capacity",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.wireless.polling.ul_capacity,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirOSConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the AirOS sensors from a config entry."""
coordinator = config_entry.runtime_data
async_add_entities(AirOSSensor(coordinator, description) for description in SENSORS)
class AirOSSensor(AirOSEntity, SensorEntity):
"""Representation of a Sensor."""
entity_description: AirOSSensorEntityDescription
def __init__(
self,
coordinator: AirOSDataUpdateCoordinator,
description: AirOSSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.derived.mac}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@ -0,0 +1,87 @@
{
"config": {
"flow_title": "Ubiquiti airOS device",
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"host": "IP address or hostname of the airOS device",
"username": "Administrator username for the airOS device, normally 'ubnt'",
"password": "Password configured through the UISP app or web interface"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"key_data_missing": "Expected data not returned from the device, check the documentation for supported devices",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"entity": {
"sensor": {
"host_cpuload": {
"name": "CPU load"
},
"host_netrole": {
"name": "Network role",
"state": {
"bridge": "Bridge",
"router": "Router"
}
},
"wireless_frequency": {
"name": "Wireless frequency"
},
"wireless_essid": {
"name": "Wireless SSID"
},
"wireless_mode": {
"name": "Wireless mode",
"state": {
"ap_ptp": "Access point",
"sta_ptp": "Station"
}
},
"wireless_antenna_gain": {
"name": "Antenna gain"
},
"wireless_throughput_tx": {
"name": "Throughput transmit (actual)"
},
"wireless_throughput_rx": {
"name": "Throughput receive (actual)"
},
"wireless_polling_dl_capacity": {
"name": "Download capacity"
},
"wireless_polling_ul_capacity": {
"name": "Upload capacity"
},
"wireless_remote_hostname": {
"name": "Remote hostname"
}
}
},
"exceptions": {
"invalid_auth": {
"message": "[%key:common::config_flow::error::invalid_auth%]"
},
"cannot_connect": {
"message": "[%key:common::config_flow::error::cannot_connect%]"
},
"key_data_missing": {
"message": "Key data not returned from device"
},
"error_data_missing": {
"message": "Data incomplete or missing"
}
}
}

View File

@ -2,8 +2,12 @@
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .services import async_setup_services
PLATFORMS = [
Platform.BINARY_SENSOR,
@ -12,11 +16,20 @@ PLATFORMS = [
Platform.SWITCH,
]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Alexa Devices component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Set up Alexa Devices platform."""
coordinator = AmazonDevicesCoordinator(hass, entry)
session = aiohttp_client.async_create_clientsession(hass)
coordinator = AmazonDevicesCoordinator(hass, entry, session)
await coordinator.async_config_entry_first_refresh()
@ -29,8 +42,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Unload a config entry."""
coordinator = entry.runtime_data
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await coordinator.api.close()
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -17,6 +17,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import CountrySelector
@ -33,18 +34,15 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema(
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
session = aiohttp_client.async_create_clientsession(hass)
api = AmazonEchoApi(
session,
data[CONF_COUNTRY],
data[CONF_USERNAME],
data[CONF_PASSWORD],
)
try:
data = await api.login_mode_interactive(data[CONF_CODE])
finally:
await api.close()
return data
return await api.login_mode_interactive(data[CONF_CODE])
class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):

View File

@ -8,6 +8,7 @@ from aioamazondevices.exceptions import (
CannotConnect,
CannotRetrieveData,
)
from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
@ -31,6 +32,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
self,
hass: HomeAssistant,
entry: AmazonConfigEntry,
session: ClientSession,
) -> None:
"""Initialize the scanner."""
super().__init__(
@ -41,6 +43,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
update_interval=timedelta(seconds=SCAN_INTERVAL),
)
self.api = AmazonEchoApi(
session,
entry.data[CONF_COUNTRY],
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],

View File

@ -38,5 +38,13 @@
}
}
}
},
"services": {
"send_sound": {
"service": "mdi:cast-audio"
},
"send_text_command": {
"service": "mdi:microphone-message"
}
}
}

View File

@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "silver",
"requirements": ["aioamazondevices==3.5.1"]
"requirements": ["aioamazondevices==4.0.0"]
}

View File

@ -48,7 +48,7 @@ rules:
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: done
docs-examples: done
docs-known-limitations: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
@ -70,5 +70,5 @@ rules:
# Platinum
async-dependency: done
inject-websession: todo
inject-websession: done
strict-typing: done

View File

@ -0,0 +1,121 @@
"""Support for services."""
from aioamazondevices.sounds import SOUNDS_LIST
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from .const import DOMAIN
from .coordinator import AmazonConfigEntry
ATTR_TEXT_COMMAND = "text_command"
ATTR_SOUND = "sound"
ATTR_SOUND_VARIANT = "sound_variant"
SERVICE_TEXT_COMMAND = "send_text_command"
SERVICE_SOUND_NOTIFICATION = "send_sound"
SCHEMA_SOUND_SERVICE = vol.Schema(
{
vol.Required(ATTR_SOUND): cv.string,
vol.Required(ATTR_SOUND_VARIANT): cv.positive_int,
vol.Required(ATTR_DEVICE_ID): cv.string,
},
)
SCHEMA_CUSTOM_COMMAND = vol.Schema(
{
vol.Required(ATTR_TEXT_COMMAND): cv.string,
vol.Required(ATTR_DEVICE_ID): cv.string,
}
)
@callback
def async_get_entry_id_for_service_call(
call: ServiceCall,
) -> tuple[dr.DeviceEntry, AmazonConfigEntry]:
"""Get the entry ID related to a service call (by device ID)."""
device_registry = dr.async_get(call.hass)
device_id = call.data[ATTR_DEVICE_ID]
if (device_entry := device_registry.async_get(device_id)) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_device_id",
translation_placeholders={"device_id": device_id},
)
for entry_id in device_entry.config_entries:
if (entry := call.hass.config_entries.async_get_entry(entry_id)) is None:
continue
if entry.domain == DOMAIN:
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_loaded",
translation_placeholders={"entry": entry.title},
)
return (device_entry, entry)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="config_entry_not_found",
translation_placeholders={"device_id": device_id},
)
async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
"""Execute action on the device."""
device, config_entry = async_get_entry_id_for_service_call(call)
assert device.serial_number
value: str = call.data[attribute]
coordinator = config_entry.runtime_data
if attribute == ATTR_SOUND:
variant: int = call.data[ATTR_SOUND_VARIANT]
pad = "_" if variant > 10 else "_0"
file = f"{value}{pad}{variant!s}"
if value not in SOUNDS_LIST or variant > SOUNDS_LIST[value]:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_sound_value",
translation_placeholders={"sound": value, "variant": str(variant)},
)
await coordinator.api.call_alexa_sound(
coordinator.data[device.serial_number], file
)
elif attribute == ATTR_TEXT_COMMAND:
await coordinator.api.call_alexa_text_command(
coordinator.data[device.serial_number], value
)
async def async_send_sound_notification(call: ServiceCall) -> None:
"""Send a sound notification to a AmazonDevice."""
await _async_execute_action(call, ATTR_SOUND)
async def async_send_text_command(call: ServiceCall) -> None:
"""Send a custom command to a AmazonDevice."""
await _async_execute_action(call, ATTR_TEXT_COMMAND)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Amazon Devices integration."""
for service_name, method, schema in (
(
SERVICE_SOUND_NOTIFICATION,
async_send_sound_notification,
SCHEMA_SOUND_SERVICE,
),
(
SERVICE_TEXT_COMMAND,
async_send_text_command,
SCHEMA_CUSTOM_COMMAND,
),
):
hass.services.async_register(DOMAIN, service_name, method, schema=schema)

View File

@ -0,0 +1,504 @@
send_text_command:
fields:
device_id:
required: true
selector:
device:
integration: alexa_devices
text_command:
required: true
example: "Play B.B.C. on TuneIn"
selector:
text:
send_sound:
fields:
device_id:
required: true
selector:
device:
integration: alexa_devices
sound_variant:
required: true
example: 1
default: 1
selector:
number:
min: 1
max: 50
sound:
required: true
example: amzn_sfx_doorbell_chime
default: amzn_sfx_doorbell_chime
selector:
select:
options:
- air_horn
- air_horns
- airboat
- airport
- aliens
- amzn_sfx_airplane_takeoff_whoosh
- amzn_sfx_army_march_clank_7x
- amzn_sfx_army_march_large_8x
- amzn_sfx_army_march_small_8x
- amzn_sfx_baby_big_cry
- amzn_sfx_baby_cry
- amzn_sfx_baby_fuss
- amzn_sfx_battle_group_clanks
- amzn_sfx_battle_man_grunts
- amzn_sfx_battle_men_grunts
- amzn_sfx_battle_men_horses
- amzn_sfx_battle_noisy_clanks
- amzn_sfx_battle_yells_men
- amzn_sfx_battle_yells_men_run
- amzn_sfx_bear_groan_roar
- amzn_sfx_bear_roar_grumble
- amzn_sfx_bear_roar_small
- amzn_sfx_beep_1x
- amzn_sfx_bell_med_chime
- amzn_sfx_bell_short_chime
- amzn_sfx_bell_timer
- amzn_sfx_bicycle_bell_ring
- amzn_sfx_bird_chickadee_chirp_1x
- amzn_sfx_bird_chickadee_chirps
- amzn_sfx_bird_forest
- amzn_sfx_bird_forest_short
- amzn_sfx_bird_robin_chirp_1x
- amzn_sfx_boing_long_1x
- amzn_sfx_boing_med_1x
- amzn_sfx_boing_short_1x
- amzn_sfx_bus_drive_past
- amzn_sfx_buzz_electronic
- amzn_sfx_buzzer_loud_alarm
- amzn_sfx_buzzer_small
- amzn_sfx_car_accelerate
- amzn_sfx_car_accelerate_noisy
- amzn_sfx_car_click_seatbelt
- amzn_sfx_car_close_door_1x
- amzn_sfx_car_drive_past
- amzn_sfx_car_honk_1x
- amzn_sfx_car_honk_2x
- amzn_sfx_car_honk_3x
- amzn_sfx_car_honk_long_1x
- amzn_sfx_car_into_driveway
- amzn_sfx_car_into_driveway_fast
- amzn_sfx_car_slam_door_1x
- amzn_sfx_car_undo_seatbelt
- amzn_sfx_cat_angry_meow_1x
- amzn_sfx_cat_angry_screech_1x
- amzn_sfx_cat_long_meow_1x
- amzn_sfx_cat_meow_1x
- amzn_sfx_cat_purr
- amzn_sfx_cat_purr_meow
- amzn_sfx_chicken_cluck
- amzn_sfx_church_bell_1x
- amzn_sfx_church_bells_ringing
- amzn_sfx_clear_throat_ahem
- amzn_sfx_clock_ticking
- amzn_sfx_clock_ticking_long
- amzn_sfx_copy_machine
- amzn_sfx_cough
- amzn_sfx_crow_caw_1x
- amzn_sfx_crowd_applause
- amzn_sfx_crowd_bar
- amzn_sfx_crowd_bar_rowdy
- amzn_sfx_crowd_boo
- amzn_sfx_crowd_cheer_med
- amzn_sfx_crowd_excited_cheer
- amzn_sfx_dog_med_bark_1x
- amzn_sfx_dog_med_bark_2x
- amzn_sfx_dog_med_bark_growl
- amzn_sfx_dog_med_growl_1x
- amzn_sfx_dog_med_woof_1x
- amzn_sfx_dog_small_bark_2x
- amzn_sfx_door_open
- amzn_sfx_door_shut
- amzn_sfx_doorbell
- amzn_sfx_doorbell_buzz
- amzn_sfx_doorbell_chime
- amzn_sfx_drinking_slurp
- amzn_sfx_drum_and_cymbal
- amzn_sfx_drum_comedy
- amzn_sfx_earthquake_rumble
- amzn_sfx_electric_guitar
- amzn_sfx_electronic_beep
- amzn_sfx_electronic_major_chord
- amzn_sfx_elephant
- amzn_sfx_elevator_bell_1x
- amzn_sfx_elevator_open_bell
- amzn_sfx_fairy_melodic_chimes
- amzn_sfx_fairy_sparkle_chimes
- amzn_sfx_faucet_drip
- amzn_sfx_faucet_running
- amzn_sfx_fireplace_crackle
- amzn_sfx_fireworks
- amzn_sfx_fireworks_firecrackers
- amzn_sfx_fireworks_launch
- amzn_sfx_fireworks_whistles
- amzn_sfx_food_frying
- amzn_sfx_footsteps
- amzn_sfx_footsteps_muffled
- amzn_sfx_ghost_spooky
- amzn_sfx_glass_on_table
- amzn_sfx_glasses_clink
- amzn_sfx_horse_gallop_4x
- amzn_sfx_horse_huff_whinny
- amzn_sfx_horse_neigh
- amzn_sfx_horse_neigh_low
- amzn_sfx_horse_whinny
- amzn_sfx_human_walking
- amzn_sfx_jar_on_table_1x
- amzn_sfx_kitchen_ambience
- amzn_sfx_large_crowd_cheer
- amzn_sfx_large_fire_crackling
- amzn_sfx_laughter
- amzn_sfx_laughter_giggle
- amzn_sfx_lightning_strike
- amzn_sfx_lion_roar
- amzn_sfx_magic_blast_1x
- amzn_sfx_monkey_calls_3x
- amzn_sfx_monkey_chimp
- amzn_sfx_monkeys_chatter
- amzn_sfx_motorcycle_accelerate
- amzn_sfx_motorcycle_engine_idle
- amzn_sfx_motorcycle_engine_rev
- amzn_sfx_musical_drone_intro
- amzn_sfx_oars_splashing_rowboat
- amzn_sfx_object_on_table_2x
- amzn_sfx_ocean_wave_1x
- amzn_sfx_ocean_wave_on_rocks_1x
- amzn_sfx_ocean_wave_surf
- amzn_sfx_people_walking
- amzn_sfx_person_running
- amzn_sfx_piano_note_1x
- amzn_sfx_punch
- amzn_sfx_rain
- amzn_sfx_rain_on_roof
- amzn_sfx_rain_thunder
- amzn_sfx_rat_squeak_2x
- amzn_sfx_rat_squeaks
- amzn_sfx_raven_caw_1x
- amzn_sfx_raven_caw_2x
- amzn_sfx_restaurant_ambience
- amzn_sfx_rooster_crow
- amzn_sfx_scifi_air_escaping
- amzn_sfx_scifi_alarm
- amzn_sfx_scifi_alien_voice
- amzn_sfx_scifi_boots_walking
- amzn_sfx_scifi_close_large_explosion
- amzn_sfx_scifi_door_open
- amzn_sfx_scifi_engines_on
- amzn_sfx_scifi_engines_on_large
- amzn_sfx_scifi_engines_on_short_burst
- amzn_sfx_scifi_explosion
- amzn_sfx_scifi_explosion_2x
- amzn_sfx_scifi_incoming_explosion
- amzn_sfx_scifi_laser_gun_battle
- amzn_sfx_scifi_laser_gun_fires
- amzn_sfx_scifi_laser_gun_fires_large
- amzn_sfx_scifi_long_explosion_1x
- amzn_sfx_scifi_missile
- amzn_sfx_scifi_motor_short_1x
- amzn_sfx_scifi_open_airlock
- amzn_sfx_scifi_radar_high_ping
- amzn_sfx_scifi_radar_low
- amzn_sfx_scifi_radar_medium
- amzn_sfx_scifi_run_away
- amzn_sfx_scifi_sheilds_up
- amzn_sfx_scifi_short_low_explosion
- amzn_sfx_scifi_small_whoosh_flyby
- amzn_sfx_scifi_small_zoom_flyby
- amzn_sfx_scifi_sonar_ping_3x
- amzn_sfx_scifi_sonar_ping_4x
- amzn_sfx_scifi_spaceship_flyby
- amzn_sfx_scifi_timer_beep
- amzn_sfx_scifi_zap_backwards
- amzn_sfx_scifi_zap_electric
- amzn_sfx_sheep_baa
- amzn_sfx_sheep_bleat
- amzn_sfx_silverware_clank
- amzn_sfx_sirens
- amzn_sfx_sleigh_bells
- amzn_sfx_small_stream
- amzn_sfx_sneeze
- amzn_sfx_stream
- amzn_sfx_strong_wind_desert
- amzn_sfx_strong_wind_whistling
- amzn_sfx_subway_leaving
- amzn_sfx_subway_passing
- amzn_sfx_subway_stopping
- amzn_sfx_swoosh_cartoon_fast
- amzn_sfx_swoosh_fast_1x
- amzn_sfx_swoosh_fast_6x
- amzn_sfx_test_tone
- amzn_sfx_thunder_rumble
- amzn_sfx_toilet_flush
- amzn_sfx_trumpet_bugle
- amzn_sfx_turkey_gobbling
- amzn_sfx_typing_medium
- amzn_sfx_typing_short
- amzn_sfx_typing_typewriter
- amzn_sfx_vacuum_off
- amzn_sfx_vacuum_on
- amzn_sfx_walking_in_mud
- amzn_sfx_walking_in_snow
- amzn_sfx_walking_on_grass
- amzn_sfx_water_dripping
- amzn_sfx_water_droplets
- amzn_sfx_wind_strong_gusting
- amzn_sfx_wind_whistling_desert
- amzn_sfx_wings_flap_4x
- amzn_sfx_wings_flap_fast
- amzn_sfx_wolf_howl
- amzn_sfx_wolf_young_howl
- amzn_sfx_wooden_door
- amzn_sfx_wooden_door_creaks_long
- amzn_sfx_wooden_door_creaks_multiple
- amzn_sfx_wooden_door_creaks_open
- amzn_ui_sfx_gameshow_bridge
- amzn_ui_sfx_gameshow_countdown_loop_32s_full
- amzn_ui_sfx_gameshow_countdown_loop_64s_full
- amzn_ui_sfx_gameshow_countdown_loop_64s_minimal
- amzn_ui_sfx_gameshow_intro
- amzn_ui_sfx_gameshow_negative_response
- amzn_ui_sfx_gameshow_neutral_response
- amzn_ui_sfx_gameshow_outro
- amzn_ui_sfx_gameshow_player1
- amzn_ui_sfx_gameshow_player2
- amzn_ui_sfx_gameshow_player3
- amzn_ui_sfx_gameshow_player4
- amzn_ui_sfx_gameshow_positive_response
- amzn_ui_sfx_gameshow_tally_negative
- amzn_ui_sfx_gameshow_tally_positive
- amzn_ui_sfx_gameshow_waiting_loop_30s
- anchor
- answering_machines
- arcs_sparks
- arrows_bows
- baby
- back_up_beeps
- bars_restaurants
- baseball
- basketball
- battles
- beeps_tones
- bell
- bikes
- billiards
- board_games
- body
- boing
- books
- bow_wash
- box
- break_shatter_smash
- breaks
- brooms_mops
- bullets
- buses
- buzz
- buzz_hums
- buzzers
- buzzers_pistols
- cables_metal
- camera
- cannons
- car_alarm
- car_alarms
- car_cell_phones
- carnivals_fairs
- cars
- casino
- casinos
- cellar
- chimes
- chimes_bells
- chorus
- christmas
- church_bells
- clock
- cloth
- concrete
- construction
- construction_factory
- crashes
- crowds
- debris
- dining_kitchens
- dinosaurs
- dripping
- drops
- electric
- electrical
- elevator
- evolution_monsters
- explosions
- factory
- falls
- fax_scanner_copier
- feedback_mics
- fight
- fire
- fire_extinguisher
- fireballs
- fireworks
- fishing_pole
- flags
- football
- footsteps
- futuristic
- futuristic_ship
- gameshow
- gear
- ghosts_demons
- giant_monster
- glass
- glasses_clink
- golf
- gorilla
- grenade_lanucher
- griffen
- gyms_locker_rooms
- handgun_loading
- handgun_shot
- handle
- hands
- heartbeats_ekg
- helicopter
- high_tech
- hit_punch_slap
- hits
- horns
- horror
- hot_tub_filling_up
- human
- human_vocals
- hygene # codespell:ignore
- ice_skating
- ignitions
- infantry
- intro
- jet
- juggling
- key_lock
- kids
- knocks
- lab_equip
- lacrosse
- lamps_lanterns
- leather
- liquid_suction
- locker_doors
- machine_gun
- magic_spells
- medium_large_explosions
- metal
- modern_rings
- money_coins
- motorcycles
- movement
- moves
- nature
- oar_boat
- pagers
- paintball
- paper
- parachute
- pay_phones
- phone_beeps
- pigmy_bats
- pills
- pour_water
- power_up_down
- printers
- prison
- public_space
- racquetball
- radios_static
- rain
- rc_airplane
- rc_car
- refrigerators_freezers
- regular
- respirator
- rifle
- roller_coaster
- rollerskates_rollerblades
- room_tones
- ropes_climbing
- rotary_rings
- rowboat_canoe
- rubber
- running
- sails
- sand_gravel
- screen_doors
- screens
- seats_stools
- servos
- shoes_boots
- shotgun
- shower
- sink_faucet
- sink_filling_water
- sink_run_and_off
- sink_water_splatter
- sirens
- skateboards
- ski
- skids_tires
- sled
- slides
- small_explosions
- snow
- snowmobile
- soldiers
- splash_water
- splashes_sprays
- sports_whistles
- squeaks
- squeaky
- stairs
- steam
- submarine_diesel
- swing_doors
- switches_levers
- swords
- tape
- tape_machine
- televisions_shows
- tennis_pingpong
- textile
- throw
- thunder
- ticks
- timer
- toilet_flush
- tone
- tones_noises
- toys
- tractors
- traffic
- train
- trucks_vans
- turnstiles
- typing
- umbrella
- underwater
- vampires
- various
- video_tunes
- volcano_earthquake
- watches
- water
- water_running
- werewolves
- winches_gears
- wind
- wood
- wood_boat
- woosh
- zap
- zippers
translation_key: sound

View File

@ -4,7 +4,8 @@
"data_description_country": "The country where your Amazon account is registered.",
"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."
"data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported.",
"device_id_description": "The ID of the device to send the command to."
},
"config": {
"flow_title": "{username}",
@ -84,12 +85,532 @@
}
}
},
"services": {
"send_sound": {
"name": "Send sound",
"description": "Sends a sound to a device",
"fields": {
"device_id": {
"name": "Device",
"description": "[%key:component::alexa_devices::common::device_id_description%]"
},
"sound": {
"name": "Alexa Skill sound file",
"description": "The sound file to play."
},
"sound_variant": {
"name": "Sound variant",
"description": "The variant of the sound to play."
}
}
},
"send_text_command": {
"name": "Send text command",
"description": "Sends a text command to a device",
"fields": {
"text_command": {
"name": "Alexa text command",
"description": "The text command to send."
},
"device_id": {
"name": "Device",
"description": "[%key:component::alexa_devices::common::device_id_description%]"
}
}
}
},
"selector": {
"sound": {
"options": {
"air_horn": "Air Horn",
"air_horns": "Air Horns",
"airboat": "Airboat",
"airport": "Airport",
"aliens": "Aliens",
"amzn_sfx_airplane_takeoff_whoosh": "Airplane Takeoff Whoosh",
"amzn_sfx_army_march_clank_7x": "Army March Clank 7x",
"amzn_sfx_army_march_large_8x": "Army March Large 8x",
"amzn_sfx_army_march_small_8x": "Army March Small 8x",
"amzn_sfx_baby_big_cry": "Baby Big Cry",
"amzn_sfx_baby_cry": "Baby Cry",
"amzn_sfx_baby_fuss": "Baby Fuss",
"amzn_sfx_battle_group_clanks": "Battle Group Clanks",
"amzn_sfx_battle_man_grunts": "Battle Man Grunts",
"amzn_sfx_battle_men_grunts": "Battle Men Grunts",
"amzn_sfx_battle_men_horses": "Battle Men Horses",
"amzn_sfx_battle_noisy_clanks": "Battle Noisy Clanks",
"amzn_sfx_battle_yells_men": "Battle Yells Men",
"amzn_sfx_battle_yells_men_run": "Battle Yells Men Run",
"amzn_sfx_bear_groan_roar": "Bear Groan Roar",
"amzn_sfx_bear_roar_grumble": "Bear Roar Grumble",
"amzn_sfx_bear_roar_small": "Bear Roar Small",
"amzn_sfx_beep_1x": "Beep 1x",
"amzn_sfx_bell_med_chime": "Bell Med Chime",
"amzn_sfx_bell_short_chime": "Bell Short Chime",
"amzn_sfx_bell_timer": "Bell Timer",
"amzn_sfx_bicycle_bell_ring": "Bicycle Bell Ring",
"amzn_sfx_bird_chickadee_chirp_1x": "Bird Chickadee Chirp 1x",
"amzn_sfx_bird_chickadee_chirps": "Bird Chickadee Chirps",
"amzn_sfx_bird_forest": "Bird Forest",
"amzn_sfx_bird_forest_short": "Bird Forest Short",
"amzn_sfx_bird_robin_chirp_1x": "Bird Robin Chirp 1x",
"amzn_sfx_boing_long_1x": "Boing Long 1x",
"amzn_sfx_boing_med_1x": "Boing Med 1x",
"amzn_sfx_boing_short_1x": "Boing Short 1x",
"amzn_sfx_bus_drive_past": "Bus Drive Past",
"amzn_sfx_buzz_electronic": "Buzz Electronic",
"amzn_sfx_buzzer_loud_alarm": "Buzzer Loud Alarm",
"amzn_sfx_buzzer_small": "Buzzer Small",
"amzn_sfx_car_accelerate": "Car Accelerate",
"amzn_sfx_car_accelerate_noisy": "Car Accelerate Noisy",
"amzn_sfx_car_click_seatbelt": "Car Click Seatbelt",
"amzn_sfx_car_close_door_1x": "Car Close Door 1x",
"amzn_sfx_car_drive_past": "Car Drive Past",
"amzn_sfx_car_honk_1x": "Car Honk 1x",
"amzn_sfx_car_honk_2x": "Car Honk 2x",
"amzn_sfx_car_honk_3x": "Car Honk 3x",
"amzn_sfx_car_honk_long_1x": "Car Honk Long 1x",
"amzn_sfx_car_into_driveway": "Car Into Driveway",
"amzn_sfx_car_into_driveway_fast": "Car Into Driveway Fast",
"amzn_sfx_car_slam_door_1x": "Car Slam Door 1x",
"amzn_sfx_car_undo_seatbelt": "Car Undo Seatbelt",
"amzn_sfx_cat_angry_meow_1x": "Cat Angry Meow 1x",
"amzn_sfx_cat_angry_screech_1x": "Cat Angry Screech 1x",
"amzn_sfx_cat_long_meow_1x": "Cat Long Meow 1x",
"amzn_sfx_cat_meow_1x": "Cat Meow 1x",
"amzn_sfx_cat_purr": "Cat Purr",
"amzn_sfx_cat_purr_meow": "Cat Purr Meow",
"amzn_sfx_chicken_cluck": "Chicken Cluck",
"amzn_sfx_church_bell_1x": "Church Bell 1x",
"amzn_sfx_church_bells_ringing": "Church Bells Ringing",
"amzn_sfx_clear_throat_ahem": "Clear Throat Ahem",
"amzn_sfx_clock_ticking": "Clock Ticking",
"amzn_sfx_clock_ticking_long": "Clock Ticking Long",
"amzn_sfx_copy_machine": "Copy Machine",
"amzn_sfx_cough": "Cough",
"amzn_sfx_crow_caw_1x": "Crow Caw 1x",
"amzn_sfx_crowd_applause": "Crowd Applause",
"amzn_sfx_crowd_bar": "Crowd Bar",
"amzn_sfx_crowd_bar_rowdy": "Crowd Bar Rowdy",
"amzn_sfx_crowd_boo": "Crowd Boo",
"amzn_sfx_crowd_cheer_med": "Crowd Cheer Med",
"amzn_sfx_crowd_excited_cheer": "Crowd Excited Cheer",
"amzn_sfx_dog_med_bark_1x": "Dog Med Bark 1x",
"amzn_sfx_dog_med_bark_2x": "Dog Med Bark 2x",
"amzn_sfx_dog_med_bark_growl": "Dog Med Bark Growl",
"amzn_sfx_dog_med_growl_1x": "Dog Med Growl 1x",
"amzn_sfx_dog_med_woof_1x": "Dog Med Woof 1x",
"amzn_sfx_dog_small_bark_2x": "Dog Small Bark 2x",
"amzn_sfx_door_open": "Door Open",
"amzn_sfx_door_shut": "Door Shut",
"amzn_sfx_doorbell": "Doorbell",
"amzn_sfx_doorbell_buzz": "Doorbell Buzz",
"amzn_sfx_doorbell_chime": "Doorbell Chime",
"amzn_sfx_drinking_slurp": "Drinking Slurp",
"amzn_sfx_drum_and_cymbal": "Drum And Cymbal",
"amzn_sfx_drum_comedy": "Drum Comedy",
"amzn_sfx_earthquake_rumble": "Earthquake Rumble",
"amzn_sfx_electric_guitar": "Electric Guitar",
"amzn_sfx_electronic_beep": "Electronic Beep",
"amzn_sfx_electronic_major_chord": "Electronic Major Chord",
"amzn_sfx_elephant": "Elephant",
"amzn_sfx_elevator_bell_1x": "Elevator Bell 1x",
"amzn_sfx_elevator_open_bell": "Elevator Open Bell",
"amzn_sfx_fairy_melodic_chimes": "Fairy Melodic Chimes",
"amzn_sfx_fairy_sparkle_chimes": "Fairy Sparkle Chimes",
"amzn_sfx_faucet_drip": "Faucet Drip",
"amzn_sfx_faucet_running": "Faucet Running",
"amzn_sfx_fireplace_crackle": "Fireplace Crackle",
"amzn_sfx_fireworks": "Fireworks",
"amzn_sfx_fireworks_firecrackers": "Fireworks Firecrackers",
"amzn_sfx_fireworks_launch": "Fireworks Launch",
"amzn_sfx_fireworks_whistles": "Fireworks Whistles",
"amzn_sfx_food_frying": "Food Frying",
"amzn_sfx_footsteps": "Footsteps",
"amzn_sfx_footsteps_muffled": "Footsteps Muffled",
"amzn_sfx_ghost_spooky": "Ghost Spooky",
"amzn_sfx_glass_on_table": "Glass On Table",
"amzn_sfx_glasses_clink": "Glasses Clink",
"amzn_sfx_horse_gallop_4x": "Horse Gallop 4x",
"amzn_sfx_horse_huff_whinny": "Horse Huff Whinny",
"amzn_sfx_horse_neigh": "Horse Neigh",
"amzn_sfx_horse_neigh_low": "Horse Neigh Low",
"amzn_sfx_horse_whinny": "Horse Whinny",
"amzn_sfx_human_walking": "Human Walking",
"amzn_sfx_jar_on_table_1x": "Jar On Table 1x",
"amzn_sfx_kitchen_ambience": "Kitchen Ambience",
"amzn_sfx_large_crowd_cheer": "Large Crowd Cheer",
"amzn_sfx_large_fire_crackling": "Large Fire Crackling",
"amzn_sfx_laughter": "Laughter",
"amzn_sfx_laughter_giggle": "Laughter Giggle",
"amzn_sfx_lightning_strike": "Lightning Strike",
"amzn_sfx_lion_roar": "Lion Roar",
"amzn_sfx_magic_blast_1x": "Magic Blast 1x",
"amzn_sfx_monkey_calls_3x": "Monkey Calls 3x",
"amzn_sfx_monkey_chimp": "Monkey Chimp",
"amzn_sfx_monkeys_chatter": "Monkeys Chatter",
"amzn_sfx_motorcycle_accelerate": "Motorcycle Accelerate",
"amzn_sfx_motorcycle_engine_idle": "Motorcycle Engine Idle",
"amzn_sfx_motorcycle_engine_rev": "Motorcycle Engine Rev",
"amzn_sfx_musical_drone_intro": "Musical Drone Intro",
"amzn_sfx_oars_splashing_rowboat": "Oars Splashing Rowboat",
"amzn_sfx_object_on_table_2x": "Object On Table 2x",
"amzn_sfx_ocean_wave_1x": "Ocean Wave 1x",
"amzn_sfx_ocean_wave_on_rocks_1x": "Ocean Wave On Rocks 1x",
"amzn_sfx_ocean_wave_surf": "Ocean Wave Surf",
"amzn_sfx_people_walking": "People Walking",
"amzn_sfx_person_running": "Person Running",
"amzn_sfx_piano_note_1x": "Piano Note 1x",
"amzn_sfx_punch": "Punch",
"amzn_sfx_rain": "Rain",
"amzn_sfx_rain_on_roof": "Rain On Roof",
"amzn_sfx_rain_thunder": "Rain Thunder",
"amzn_sfx_rat_squeak_2x": "Rat Squeak 2x",
"amzn_sfx_rat_squeaks": "Rat Squeaks",
"amzn_sfx_raven_caw_1x": "Raven Caw 1x",
"amzn_sfx_raven_caw_2x": "Raven Caw 2x",
"amzn_sfx_restaurant_ambience": "Restaurant Ambience",
"amzn_sfx_rooster_crow": "Rooster Crow",
"amzn_sfx_scifi_air_escaping": "Scifi Air Escaping",
"amzn_sfx_scifi_alarm": "Scifi Alarm",
"amzn_sfx_scifi_alien_voice": "Scifi Alien Voice",
"amzn_sfx_scifi_boots_walking": "Scifi Boots Walking",
"amzn_sfx_scifi_close_large_explosion": "Scifi Close Large Explosion",
"amzn_sfx_scifi_door_open": "Scifi Door Open",
"amzn_sfx_scifi_engines_on": "Scifi Engines On",
"amzn_sfx_scifi_engines_on_large": "Scifi Engines On Large",
"amzn_sfx_scifi_engines_on_short_burst": "Scifi Engines On Short Burst",
"amzn_sfx_scifi_explosion": "Scifi Explosion",
"amzn_sfx_scifi_explosion_2x": "Scifi Explosion 2x",
"amzn_sfx_scifi_incoming_explosion": "Scifi Incoming Explosion",
"amzn_sfx_scifi_laser_gun_battle": "Scifi Laser Gun Battle",
"amzn_sfx_scifi_laser_gun_fires": "Scifi Laser Gun Fires",
"amzn_sfx_scifi_laser_gun_fires_large": "Scifi Laser Gun Fires Large",
"amzn_sfx_scifi_long_explosion_1x": "Scifi Long Explosion 1x",
"amzn_sfx_scifi_missile": "Scifi Missile",
"amzn_sfx_scifi_motor_short_1x": "Scifi Motor Short 1x",
"amzn_sfx_scifi_open_airlock": "Scifi Open Airlock",
"amzn_sfx_scifi_radar_high_ping": "Scifi Radar High Ping",
"amzn_sfx_scifi_radar_low": "Scifi Radar Low",
"amzn_sfx_scifi_radar_medium": "Scifi Radar Medium",
"amzn_sfx_scifi_run_away": "Scifi Run Away",
"amzn_sfx_scifi_sheilds_up": "Scifi Sheilds Up",
"amzn_sfx_scifi_short_low_explosion": "Scifi Short Low Explosion",
"amzn_sfx_scifi_small_whoosh_flyby": "Scifi Small Whoosh Flyby",
"amzn_sfx_scifi_small_zoom_flyby": "Scifi Small Zoom Flyby",
"amzn_sfx_scifi_sonar_ping_3x": "Scifi Sonar Ping 3x",
"amzn_sfx_scifi_sonar_ping_4x": "Scifi Sonar Ping 4x",
"amzn_sfx_scifi_spaceship_flyby": "Scifi Spaceship Flyby",
"amzn_sfx_scifi_timer_beep": "Scifi Timer Beep",
"amzn_sfx_scifi_zap_backwards": "Scifi Zap Backwards",
"amzn_sfx_scifi_zap_electric": "Scifi Zap Electric",
"amzn_sfx_sheep_baa": "Sheep Baa",
"amzn_sfx_sheep_bleat": "Sheep Bleat",
"amzn_sfx_silverware_clank": "Silverware Clank",
"amzn_sfx_sirens": "Sirens",
"amzn_sfx_sleigh_bells": "Sleigh Bells",
"amzn_sfx_small_stream": "Small Stream",
"amzn_sfx_sneeze": "Sneeze",
"amzn_sfx_stream": "Stream",
"amzn_sfx_strong_wind_desert": "Strong Wind Desert",
"amzn_sfx_strong_wind_whistling": "Strong Wind Whistling",
"amzn_sfx_subway_leaving": "Subway Leaving",
"amzn_sfx_subway_passing": "Subway Passing",
"amzn_sfx_subway_stopping": "Subway Stopping",
"amzn_sfx_swoosh_cartoon_fast": "Swoosh Cartoon Fast",
"amzn_sfx_swoosh_fast_1x": "Swoosh Fast 1x",
"amzn_sfx_swoosh_fast_6x": "Swoosh Fast 6x",
"amzn_sfx_test_tone": "Test Tone",
"amzn_sfx_thunder_rumble": "Thunder Rumble",
"amzn_sfx_toilet_flush": "Toilet Flush",
"amzn_sfx_trumpet_bugle": "Trumpet Bugle",
"amzn_sfx_turkey_gobbling": "Turkey Gobbling",
"amzn_sfx_typing_medium": "Typing Medium",
"amzn_sfx_typing_short": "Typing Short",
"amzn_sfx_typing_typewriter": "Typing Typewriter",
"amzn_sfx_vacuum_off": "Vacuum Off",
"amzn_sfx_vacuum_on": "Vacuum On",
"amzn_sfx_walking_in_mud": "Walking In Mud",
"amzn_sfx_walking_in_snow": "Walking In Snow",
"amzn_sfx_walking_on_grass": "Walking On Grass",
"amzn_sfx_water_dripping": "Water Dripping",
"amzn_sfx_water_droplets": "Water Droplets",
"amzn_sfx_wind_strong_gusting": "Wind Strong Gusting",
"amzn_sfx_wind_whistling_desert": "Wind Whistling Desert",
"amzn_sfx_wings_flap_4x": "Wings Flap 4x",
"amzn_sfx_wings_flap_fast": "Wings Flap Fast",
"amzn_sfx_wolf_howl": "Wolf Howl",
"amzn_sfx_wolf_young_howl": "Wolf Young Howl",
"amzn_sfx_wooden_door": "Wooden Door",
"amzn_sfx_wooden_door_creaks_long": "Wooden Door Creaks Long",
"amzn_sfx_wooden_door_creaks_multiple": "Wooden Door Creaks Multiple",
"amzn_sfx_wooden_door_creaks_open": "Wooden Door Creaks Open",
"amzn_ui_sfx_gameshow_bridge": "Gameshow Bridge",
"amzn_ui_sfx_gameshow_countdown_loop_32s_full": "Gameshow Countdown Loop 32s Full",
"amzn_ui_sfx_gameshow_countdown_loop_64s_full": "Gameshow Countdown Loop 64s Full",
"amzn_ui_sfx_gameshow_countdown_loop_64s_minimal": "Gameshow Countdown Loop 64s Minimal",
"amzn_ui_sfx_gameshow_intro": "Gameshow Intro",
"amzn_ui_sfx_gameshow_negative_response": "Gameshow Negative Response",
"amzn_ui_sfx_gameshow_neutral_response": "Gameshow Neutral Response",
"amzn_ui_sfx_gameshow_outro": "Gameshow Outro",
"amzn_ui_sfx_gameshow_player1": "Gameshow Player1",
"amzn_ui_sfx_gameshow_player2": "Gameshow Player2",
"amzn_ui_sfx_gameshow_player3": "Gameshow Player3",
"amzn_ui_sfx_gameshow_player4": "Gameshow Player4",
"amzn_ui_sfx_gameshow_positive_response": "Gameshow Positive Response",
"amzn_ui_sfx_gameshow_tally_negative": "Gameshow Tally Negative",
"amzn_ui_sfx_gameshow_tally_positive": "Gameshow Tally Positive",
"amzn_ui_sfx_gameshow_waiting_loop_30s": "Gameshow Waiting Loop 30s",
"anchor": "Anchor",
"answering_machines": "Answering Machines",
"arcs_sparks": "Arcs Sparks",
"arrows_bows": "Arrows Bows",
"baby": "Baby",
"back_up_beeps": "Back Up Beeps",
"bars_restaurants": "Bars Restaurants",
"baseball": "Baseball",
"basketball": "Basketball",
"battles": "Battles",
"beeps_tones": "Beeps Tones",
"bell": "Bell",
"bikes": "Bikes",
"billiards": "Billiards",
"board_games": "Board Games",
"body": "Body",
"boing": "Boing",
"books": "Books",
"bow_wash": "Bow Wash",
"box": "Box",
"break_shatter_smash": "Break Shatter Smash",
"breaks": "Breaks",
"brooms_mops": "Brooms Mops",
"bullets": "Bullets",
"buses": "Buses",
"buzz": "Buzz",
"buzz_hums": "Buzz Hums",
"buzzers": "Buzzers",
"buzzers_pistols": "Buzzers Pistols",
"cables_metal": "Cables Metal",
"camera": "Camera",
"cannons": "Cannons",
"car_alarm": "Car Alarm",
"car_alarms": "Car Alarms",
"car_cell_phones": "Car Cell Phones",
"carnivals_fairs": "Carnivals Fairs",
"cars": "Cars",
"casino": "Casino",
"casinos": "Casinos",
"cellar": "Cellar",
"chimes": "Chimes",
"chimes_bells": "Chimes Bells",
"chorus": "Chorus",
"christmas": "Christmas",
"church_bells": "Church Bells",
"clock": "Clock",
"cloth": "Cloth",
"concrete": "Concrete",
"construction": "Construction",
"construction_factory": "Construction Factory",
"crashes": "Crashes",
"crowds": "Crowds",
"debris": "Debris",
"dining_kitchens": "Dining Kitchens",
"dinosaurs": "Dinosaurs",
"dripping": "Dripping",
"drops": "Drops",
"electric": "Electric",
"electrical": "Electrical",
"elevator": "Elevator",
"evolution_monsters": "Evolution Monsters",
"explosions": "Explosions",
"factory": "Factory",
"falls": "Falls",
"fax_scanner_copier": "Fax Scanner Copier",
"feedback_mics": "Feedback Mics",
"fight": "Fight",
"fire": "Fire",
"fire_extinguisher": "Fire Extinguisher",
"fireballs": "Fireballs",
"fireworks": "Fireworks",
"fishing_pole": "Fishing Pole",
"flags": "Flags",
"football": "Football",
"footsteps": "Footsteps",
"futuristic": "Futuristic",
"futuristic_ship": "Futuristic Ship",
"gameshow": "Gameshow",
"gear": "Gear",
"ghosts_demons": "Ghosts Demons",
"giant_monster": "Giant Monster",
"glass": "Glass",
"glasses_clink": "Glasses Clink",
"golf": "Golf",
"gorilla": "Gorilla",
"grenade_lanucher": "Grenade Lanucher",
"griffen": "Griffen",
"gyms_locker_rooms": "Gyms Locker Rooms",
"handgun_loading": "Handgun Loading",
"handgun_shot": "Handgun Shot",
"handle": "Handle",
"hands": "Hands",
"heartbeats_ekg": "Heartbeats EKG",
"helicopter": "Helicopter",
"high_tech": "High Tech",
"hit_punch_slap": "Hit Punch Slap",
"hits": "Hits",
"horns": "Horns",
"horror": "Horror",
"hot_tub_filling_up": "Hot Tub Filling Up",
"human": "Human",
"human_vocals": "Human Vocals",
"hygene": "Hygene",
"ice_skating": "Ice Skating",
"ignitions": "Ignitions",
"infantry": "Infantry",
"intro": "Intro",
"jet": "Jet",
"juggling": "Juggling",
"key_lock": "Key Lock",
"kids": "Kids",
"knocks": "Knocks",
"lab_equip": "Lab Equip",
"lacrosse": "Lacrosse",
"lamps_lanterns": "Lamps Lanterns",
"leather": "Leather",
"liquid_suction": "Liquid Suction",
"locker_doors": "Locker Doors",
"machine_gun": "Machine Gun",
"magic_spells": "Magic Spells",
"medium_large_explosions": "Medium Large Explosions",
"metal": "Metal",
"modern_rings": "Modern Rings",
"money_coins": "Money Coins",
"motorcycles": "Motorcycles",
"movement": "Movement",
"moves": "Moves",
"nature": "Nature",
"oar_boat": "Oar Boat",
"pagers": "Pagers",
"paintball": "Paintball",
"paper": "Paper",
"parachute": "Parachute",
"pay_phones": "Pay Phones",
"phone_beeps": "Phone Beeps",
"pigmy_bats": "Pigmy Bats",
"pills": "Pills",
"pour_water": "Pour Water",
"power_up_down": "Power Up Down",
"printers": "Printers",
"prison": "Prison",
"public_space": "Public Space",
"racquetball": "Racquetball",
"radios_static": "Radios Static",
"rain": "Rain",
"rc_airplane": "RC Airplane",
"rc_car": "RC Car",
"refrigerators_freezers": "Refrigerators Freezers",
"regular": "Regular",
"respirator": "Respirator",
"rifle": "Rifle",
"roller_coaster": "Roller Coaster",
"rollerskates_rollerblades": "RollerSkates RollerBlades",
"room_tones": "Room Tones",
"ropes_climbing": "Ropes Climbing",
"rotary_rings": "Rotary Rings",
"rowboat_canoe": "Rowboat Canoe",
"rubber": "Rubber",
"running": "Running",
"sails": "Sails",
"sand_gravel": "Sand Gravel",
"screen_doors": "Screen Doors",
"screens": "Screens",
"seats_stools": "Seats Stools",
"servos": "Servos",
"shoes_boots": "Shoes Boots",
"shotgun": "Shotgun",
"shower": "Shower",
"sink_faucet": "Sink Faucet",
"sink_filling_water": "Sink Filling Water",
"sink_run_and_off": "Sink Run And Off",
"sink_water_splatter": "Sink Water Splatter",
"sirens": "Sirens",
"skateboards": "Skateboards",
"ski": "Ski",
"skids_tires": "Skids Tires",
"sled": "Sled",
"slides": "Slides",
"small_explosions": "Small Explosions",
"snow": "Snow",
"snowmobile": "Snowmobile",
"soldiers": "Soldiers",
"splash_water": "Splash Water",
"splashes_sprays": "Splashes Sprays",
"sports_whistles": "Sports Whistles",
"squeaks": "Squeaks",
"squeaky": "Squeaky",
"stairs": "Stairs",
"steam": "Steam",
"submarine_diesel": "Submarine Diesel",
"swing_doors": "Swing Doors",
"switches_levers": "Switches Levers",
"swords": "Swords",
"tape": "Tape",
"tape_machine": "Tape Machine",
"televisions_shows": "Televisions Shows",
"tennis_pingpong": "Tennis PingPong",
"textile": "Textile",
"throw": "Throw",
"thunder": "Thunder",
"ticks": "Ticks",
"timer": "Timer",
"toilet_flush": "Toilet Flush",
"tone": "Tone",
"tones_noises": "Tones Noises",
"toys": "Toys",
"tractors": "Tractors",
"traffic": "Traffic",
"train": "Train",
"trucks_vans": "Trucks Vans",
"turnstiles": "Turnstiles",
"typing": "Typing",
"umbrella": "Umbrella",
"underwater": "Underwater",
"vampires": "Vampires",
"various": "Various",
"video_tunes": "Video Tunes",
"volcano_earthquake": "Volcano Earthquake",
"watches": "Watches",
"water": "Water",
"water_running": "Water Running",
"werewolves": "Werewolves",
"winches_gears": "Winches Gears",
"wind": "Wind",
"wood": "Wood",
"wood_boat": "Wood Boat",
"woosh": "Woosh",
"zap": "Zap",
"zippers": "Zippers"
}
}
},
"exceptions": {
"cannot_connect_with_error": {
"message": "Error connecting: {error}"
},
"cannot_retrieve_data_with_error": {
"message": "Error retrieving data: {error}"
},
"device_serial_number_missing": {
"message": "Device serial number missing: {device_id}"
},
"invalid_device_id": {
"message": "Invalid device ID specified: {device_id}"
},
"invalid_sound_value": {
"message": "Invalid sound {sound} with variant {variant} specified"
},
"entry_not_loaded": {
"message": "Entry not loaded: {entry}"
}
}
}

View File

@ -6,7 +6,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bluesound",
"iot_class": "local_polling",
"requirements": ["pyblu==2.0.1"],
"requirements": ["pyblu==2.0.4"],
"zeroconf": [
{
"type": "_musc._tcp.local."

View File

@ -75,7 +75,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: DatadogConfigEntry) -> b
prefix = options[CONF_PREFIX]
sample_rate = options[CONF_RATE]
statsd_client = DogStatsd(host=host, port=port, namespace=prefix)
statsd_client = DogStatsd(
host=host, port=port, namespace=prefix, disable_telemetry=True
)
entry.runtime_data = statsd_client
initialize(statsd_host=host, statsd_port=port)

View File

@ -58,7 +58,6 @@ class DatadogConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_RATE: user_input[CONF_RATE],
},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
@ -107,7 +106,26 @@ class DatadogOptionsFlowHandler(OptionsFlow):
options = self.config_entry.options
if user_input is None:
user_input = {}
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
CONF_PREFIX,
default=options.get(
CONF_PREFIX, data.get(CONF_PREFIX, DEFAULT_PREFIX)
),
): str,
vol.Required(
CONF_RATE,
default=options.get(
CONF_RATE, data.get(CONF_RATE, DEFAULT_RATE)
),
): int,
}
),
errors={},
)
success = await validate_datadog_connection(
self.hass,

View File

@ -4,7 +4,7 @@ DOMAIN = "datadog"
CONF_RATE = "rate"
DEFAULT_HOST = "localhost"
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8125
DEFAULT_PREFIX = "hass"
DEFAULT_RATE = 1

View File

@ -7,5 +7,5 @@
"iot_class": "local_push",
"loggers": ["datadog"],
"quality_scale": "legacy",
"requirements": ["datadog==0.15.0"]
"requirements": ["datadog==0.52.0"]
}

View File

@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
from deebot_client.capabilities import Capabilities, DeviceType
from deebot_client.device import Device
from deebot_client.events import BatteryEvent, FanSpeedEvent, RoomsEvent, StateEvent
from deebot_client.events import FanSpeedEvent, RoomsEvent, StateEvent
from deebot_client.models import CleanAction, CleanMode, Room, State
import sucks
@ -216,7 +216,6 @@ class EcovacsVacuum(
VacuumEntityFeature.PAUSE
| VacuumEntityFeature.STOP
| VacuumEntityFeature.RETURN_HOME
| VacuumEntityFeature.BATTERY
| VacuumEntityFeature.SEND_COMMAND
| VacuumEntityFeature.LOCATE
| VacuumEntityFeature.STATE
@ -243,10 +242,6 @@ class EcovacsVacuum(
"""Set up the event listeners now that hass is ready."""
await super().async_added_to_hass()
async def on_battery(event: BatteryEvent) -> None:
self._attr_battery_level = event.value
self.async_write_ha_state()
async def on_rooms(event: RoomsEvent) -> None:
self._rooms = event.rooms
self.async_write_ha_state()
@ -255,7 +250,6 @@ class EcovacsVacuum(
self._attr_activity = _STATE_TO_VACUUM_STATE[event.state]
self.async_write_ha_state()
self._subscribe(self._capability.battery.event, on_battery)
self._subscribe(self._capability.state.event, on_status)
if self._capability.fan_speed:

View File

@ -7,7 +7,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==2.2.2"],
"requirements": ["pyenphase==2.2.3"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."

View File

@ -51,6 +51,7 @@ from .const import (
DOMAIN,
)
from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info
from .encryption_key_storage import async_get_encryption_key_storage
from .entry_data import ESPHomeConfigEntry
from .manager import async_replace_device
@ -159,7 +160,10 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle reauthorization flow."""
errors = {}
if await self._retrieve_encryption_key_from_dashboard():
if (
await self._retrieve_encryption_key_from_storage()
or await self._retrieve_encryption_key_from_dashboard()
):
error = await self.fetch_device_info()
if error is None:
return await self._async_authenticate_or_add()
@ -226,9 +230,12 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
response = await self.fetch_device_info()
self._noise_psk = None
# Try to retrieve an existing key from dashboard or storage.
if (
self._device_name
and await self._retrieve_encryption_key_from_dashboard()
) or (
self._device_mac and await self._retrieve_encryption_key_from_storage()
):
response = await self.fetch_device_info()
@ -284,6 +291,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._name = discovery_info.properties.get("friendly_name", device_name)
self._host = discovery_info.host
self._port = discovery_info.port
self._device_mac = mac_address
self._noise_required = bool(discovery_info.properties.get("api_encryption"))
# Check if already configured
@ -772,6 +780,26 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._noise_psk = noise_psk
return True
async def _retrieve_encryption_key_from_storage(self) -> bool:
"""Try to retrieve the encryption key from storage.
Return boolean if a key was retrieved.
"""
# Try to get MAC address from current flow state or reauth entry
mac_address = self._device_mac
if mac_address is None and self._reauth_entry is not None:
# In reauth flow, get MAC from the existing entry's unique_id
mac_address = self._reauth_entry.unique_id
assert mac_address is not None
storage = await async_get_encryption_key_storage(self.hass)
if stored_key := await storage.async_get_key(mac_address):
self._noise_psk = stored_key
return True
return False
@staticmethod
@callback
def async_get_options_flow(

View File

@ -0,0 +1,94 @@
"""Encryption key storage for ESPHome devices."""
from __future__ import annotations
import logging
from typing import TypedDict
from homeassistant.core import HomeAssistant
from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store
from homeassistant.util.hass_dict import HassKey
_LOGGER = logging.getLogger(__name__)
ENCRYPTION_KEY_STORAGE_VERSION = 1
ENCRYPTION_KEY_STORAGE_KEY = "esphome.encryption_keys"
class EncryptionKeyData(TypedDict):
"""Encryption key storage data."""
keys: dict[str, str] # MAC address -> base64 encoded key
KEY_ENCRYPTION_STORAGE: HassKey[ESPHomeEncryptionKeyStorage] = HassKey(
"esphome_encryption_key_storage"
)
class ESPHomeEncryptionKeyStorage:
"""Storage for ESPHome encryption keys."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the encryption key storage."""
self.hass = hass
self._store = Store[EncryptionKeyData](
hass,
ENCRYPTION_KEY_STORAGE_VERSION,
ENCRYPTION_KEY_STORAGE_KEY,
encoder=JSONEncoder,
)
self._data: EncryptionKeyData | None = None
async def async_load(self) -> None:
"""Load encryption keys from storage."""
if self._data is None:
data = await self._store.async_load()
self._data = data or {"keys": {}}
async def async_save(self) -> None:
"""Save encryption keys to storage."""
if self._data is not None:
await self._store.async_save(self._data)
async def async_get_key(self, mac_address: str) -> str | None:
"""Get encryption key for a MAC address."""
await self.async_load()
assert self._data is not None
return self._data["keys"].get(mac_address.lower())
async def async_store_key(self, mac_address: str, key: str) -> None:
"""Store encryption key for a MAC address."""
await self.async_load()
assert self._data is not None
self._data["keys"][mac_address.lower()] = key
await self.async_save()
_LOGGER.debug(
"Stored encryption key for device with MAC %s",
mac_address,
)
async def async_remove_key(self, mac_address: str) -> None:
"""Remove encryption key for a MAC address."""
await self.async_load()
assert self._data is not None
lower_mac_address = mac_address.lower()
if lower_mac_address in self._data["keys"]:
del self._data["keys"][lower_mac_address]
await self.async_save()
_LOGGER.debug(
"Removed encryption key for device with MAC %s",
mac_address,
)
@singleton(KEY_ENCRYPTION_STORAGE, async_=True)
async def async_get_encryption_key_storage(
hass: HomeAssistant,
) -> ESPHomeEncryptionKeyStorage:
"""Get the encryption key storage instance."""
storage = ESPHomeEncryptionKeyStorage(hass)
await storage.async_load()
return storage

View File

@ -3,8 +3,10 @@
from __future__ import annotations
import asyncio
import base64
from functools import partial
import logging
import secrets
from typing import TYPE_CHECKING, Any, NamedTuple
from aioesphomeapi import (
@ -68,6 +70,7 @@ from .const import (
CONF_ALLOW_SERVICE_CALLS,
CONF_BLUETOOTH_MAC_ADDRESS,
CONF_DEVICE_NAME,
CONF_NOISE_PSK,
CONF_SUBSCRIBE_LOGS,
DEFAULT_ALLOW_SERVICE_CALLS,
DEFAULT_URL,
@ -78,6 +81,7 @@ from .const import (
)
from .dashboard import async_get_dashboard
from .domain_data import DomainData
from .encryption_key_storage import async_get_encryption_key_storage
# Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
@ -85,9 +89,7 @@ from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}"
if TYPE_CHECKING:
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
SubscribeLogsResponse,
)
from aioesphomeapi.api_pb2 import SubscribeLogsResponse # type: ignore[attr-defined] # noqa: I001
_LOGGER = logging.getLogger(__name__)
@ -515,6 +517,8 @@ class ESPHomeManager:
assert api_version is not None, "API version must be set"
entry_data.async_on_connect(device_info, api_version)
await self._handle_dynamic_encryption_key(device_info)
if device_info.name:
reconnect_logic.name = device_info.name
@ -618,6 +622,7 @@ class ESPHomeManager:
),
):
return
if isinstance(err, InvalidEncryptionKeyAPIError):
if (
(received_name := err.received_name)
@ -648,6 +653,93 @@ class ESPHomeManager:
return
self.entry.async_start_reauth(self.hass)
async def _handle_dynamic_encryption_key(
self, device_info: EsphomeDeviceInfo
) -> None:
"""Handle dynamic encryption keys.
If a device reports it supports encryption, but we connected without a key,
we need to generate and store one.
"""
noise_psk: str | None = self.entry.data.get(CONF_NOISE_PSK)
if noise_psk:
# we're already connected with a noise PSK - nothing to do
return
if not device_info.api_encryption_supported:
# device does not support encryption - nothing to do
return
# Connected to device without key and the device supports encryption
storage = await async_get_encryption_key_storage(self.hass)
# First check if we have a key in storage for this device
from_storage: bool = False
if self.entry.unique_id and (
stored_key := await storage.async_get_key(self.entry.unique_id)
):
_LOGGER.debug(
"Retrieved encryption key from storage for device %s",
self.entry.unique_id,
)
# Use the stored key
new_key = stored_key.encode()
new_key_str = stored_key
from_storage = True
else:
# No stored key found, generate a new one
_LOGGER.debug(
"Generating new encryption key for device %s", self.entry.unique_id
)
new_key = base64.b64encode(secrets.token_bytes(32))
new_key_str = new_key.decode()
try:
# Store the key on the device using the existing connection
result = await self.cli.noise_encryption_set_key(new_key)
except APIConnectionError as ex:
_LOGGER.error(
"Connection error while storing encryption key for device %s (%s): %s",
self.entry.data.get(CONF_DEVICE_NAME, self.host),
self.entry.unique_id,
ex,
)
return
else:
if not result:
_LOGGER.error(
"Failed to set dynamic encryption key on device %s (%s)",
self.entry.data.get(CONF_DEVICE_NAME, self.host),
self.entry.unique_id,
)
return
# Key stored successfully on device
assert self.entry.unique_id is not None
# Only store in storage if it was newly generated
if not from_storage:
await storage.async_store_key(self.entry.unique_id, new_key_str)
# Always update config entry
self.hass.config_entries.async_update_entry(
self.entry,
data={**self.entry.data, CONF_NOISE_PSK: new_key_str},
)
if from_storage:
_LOGGER.info(
"Set encryption key from storage on device %s (%s)",
self.entry.data.get(CONF_DEVICE_NAME, self.host),
self.entry.unique_id,
)
else:
_LOGGER.info(
"Generated and stored encryption key for device %s (%s)",
self.entry.data.get(CONF_DEVICE_NAME, self.host),
self.entry.unique_id,
)
@callback
def _async_handle_logging_changed(self, _event: Event) -> None:
"""Handle when the logging level changes."""

View File

@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==37.1.2",
"aioesphomeapi==37.1.5",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.1.0"
],

View File

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

View File

@ -141,9 +141,7 @@ async def light_switch_options_schema(
"""Generate options schema."""
return (await basic_group_options_schema(domain, handler)).extend(
{
vol.Required(
CONF_ALL, default=False, description={"advanced": True}
): selector.BooleanSelector(),
vol.Required(CONF_ALL, default=False): selector.BooleanSelector(),
}
)

View File

@ -21,12 +21,14 @@
},
"binary_sensor": {
"title": "[%key:component::group::config::step::user::title%]",
"description": "If \"all entities\" is enabled, the group's state is on only if all members are on. If \"all entities\" is disabled, the group's state is on if any member is on.",
"data": {
"all": "All entities",
"entities": "Members",
"hide_members": "Hide members",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"all": "If enabled, the group's state is on only if all members are on. If disabled, the group's state is on if any member is on."
}
},
"button": {
@ -105,6 +107,9 @@
"device_class": "Device class",
"state_class": "State class",
"unit_of_measurement": "Unit of measurement"
},
"data_description": {
"ignore_non_numeric": "If enabled, the group's state is calculated if at least one member has a numerical value. If disabled, the group's state is calculated only if all group members have numerical values."
}
},
"switch": {
@ -120,11 +125,13 @@
"options": {
"step": {
"binary_sensor": {
"description": "[%key:component::group::config::step::binary_sensor::description%]",
"data": {
"all": "[%key:component::group::config::step::binary_sensor::data::all%]",
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]"
},
"data_description": {
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
}
},
"button": {
@ -146,11 +153,13 @@
}
},
"light": {
"description": "[%key:component::group::config::step::binary_sensor::description%]",
"data": {
"all": "[%key:component::group::config::step::binary_sensor::data::all%]",
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]"
},
"data_description": {
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
}
},
"lock": {
@ -172,7 +181,6 @@
}
},
"sensor": {
"description": "If \"ignore non-numeric\" is enabled, the group's state is calculated if at least one member has a numerical value. If \"ignore non-numeric\" is disabled, the group's state is calculated only if all group members have numerical values.",
"data": {
"ignore_non_numeric": "[%key:component::group::config::step::sensor::data::ignore_non_numeric%]",
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
@ -182,14 +190,19 @@
"device_class": "[%key:component::group::config::step::sensor::data::device_class%]",
"state_class": "[%key:component::group::config::step::sensor::data::state_class%]",
"unit_of_measurement": "[%key:component::group::config::step::sensor::data::unit_of_measurement%]"
},
"data_description": {
"ignore_non_numeric": "[%key:component::group::config::step::sensor::data_description::ignore_non_numeric%]"
}
},
"switch": {
"description": "[%key:component::group::config::step::binary_sensor::description%]",
"data": {
"all": "[%key:component::group::config::step::binary_sensor::data::all%]",
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]"
},
"data_description": {
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
}
}
}

View File

@ -7,15 +7,7 @@ from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from aiohttp import ClientError
from habiticalib import (
HabiticaClass,
HabiticaException,
NotAuthorizedError,
Skill,
TaskType,
TooManyRequestsError,
)
from habiticalib import Habitica, HabiticaClass, Skill, TaskType
from homeassistant.components.button import (
DOMAIN as BUTTON_DOMAIN,
@ -23,16 +15,11 @@ from homeassistant.components.button import (
ButtonEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ASSETS_URL, DOMAIN
from .coordinator import (
HabiticaConfigEntry,
HabiticaData,
HabiticaDataUpdateCoordinator,
)
from .coordinator import HabiticaConfigEntry, HabiticaData
from .entity import HabiticaBase
PARALLEL_UPDATES = 1
@ -42,7 +29,7 @@ PARALLEL_UPDATES = 1
class HabiticaButtonEntityDescription(ButtonEntityDescription):
"""Describes Habitica button entity."""
press_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
press_fn: Callable[[Habitica], Any]
available_fn: Callable[[HabiticaData], bool]
class_needed: HabiticaClass | None = None
entity_picture: str | None = None
@ -73,13 +60,13 @@ BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.RUN_CRON,
translation_key=HabiticaButtonEntity.RUN_CRON,
press_fn=lambda coordinator: coordinator.habitica.run_cron(),
press_fn=lambda habitica: habitica.run_cron(),
available_fn=lambda data: data.user.needsCron is True,
),
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.BUY_HEALTH_POTION,
translation_key=HabiticaButtonEntity.BUY_HEALTH_POTION,
press_fn=lambda coordinator: coordinator.habitica.buy_health_potion(),
press_fn=lambda habitica: habitica.buy_health_potion(),
available_fn=(
lambda data: (data.user.stats.gp or 0) >= 25
and (data.user.stats.hp or 0) < 50
@ -89,7 +76,7 @@ BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS,
translation_key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS,
press_fn=lambda coordinator: coordinator.habitica.allocate_stat_points(),
press_fn=lambda habitica: habitica.allocate_stat_points(),
available_fn=(
lambda data: data.user.preferences.automaticAllocation is True
and (data.user.stats.points or 0) > 0
@ -98,7 +85,7 @@ BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.REVIVE,
translation_key=HabiticaButtonEntity.REVIVE,
press_fn=lambda coordinator: coordinator.habitica.revive(),
press_fn=lambda habitica: habitica.revive(),
available_fn=lambda data: data.user.stats.hp == 0,
),
)
@ -108,9 +95,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.MPHEAL,
translation_key=HabiticaButtonEntity.MPHEAL,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.ETHEREAL_SURGE)
),
press_fn=lambda habitica: habitica.cast_skill(Skill.ETHEREAL_SURGE),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 12
and (data.user.stats.mp or 0) >= 30
@ -121,7 +106,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.EARTH,
translation_key=HabiticaButtonEntity.EARTH,
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.EARTHQUAKE),
press_fn=lambda habitica: habitica.cast_skill(Skill.EARTHQUAKE),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 13
and (data.user.stats.mp or 0) >= 35
@ -132,9 +117,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.FROST,
translation_key=HabiticaButtonEntity.FROST,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.CHILLING_FROST)
),
press_fn=lambda habitica: habitica.cast_skill(Skill.CHILLING_FROST),
# chilling frost can only be cast once per day (streaks buff is false)
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 14
@ -147,9 +130,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.DEFENSIVE_STANCE,
translation_key=HabiticaButtonEntity.DEFENSIVE_STANCE,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.DEFENSIVE_STANCE)
),
press_fn=lambda habitica: habitica.cast_skill(Skill.DEFENSIVE_STANCE),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 12
and (data.user.stats.mp or 0) >= 25
@ -160,9 +141,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.VALOROUS_PRESENCE,
translation_key=HabiticaButtonEntity.VALOROUS_PRESENCE,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.VALOROUS_PRESENCE)
),
press_fn=lambda habitica: habitica.cast_skill(Skill.VALOROUS_PRESENCE),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 13
and (data.user.stats.mp or 0) >= 20
@ -173,9 +152,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.INTIMIDATE,
translation_key=HabiticaButtonEntity.INTIMIDATE,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.INTIMIDATING_GAZE)
),
press_fn=lambda habitica: habitica.cast_skill(Skill.INTIMIDATING_GAZE),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 14
and (data.user.stats.mp or 0) >= 15
@ -186,11 +163,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.TOOLS_OF_TRADE,
translation_key=HabiticaButtonEntity.TOOLS_OF_TRADE,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(
Skill.TOOLS_OF_THE_TRADE
)
),
press_fn=lambda habitica: habitica.cast_skill(Skill.TOOLS_OF_THE_TRADE),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 13
and (data.user.stats.mp or 0) >= 25
@ -201,7 +174,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.STEALTH,
translation_key=HabiticaButtonEntity.STEALTH,
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.STEALTH),
press_fn=lambda habitica: habitica.cast_skill(Skill.STEALTH),
# Stealth buffs stack and it can only be cast if the amount of
# buffs is smaller than the amount of unfinished dailies
available_fn=(
@ -224,9 +197,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.HEAL,
translation_key=HabiticaButtonEntity.HEAL,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.HEALING_LIGHT)
),
press_fn=lambda habitica: habitica.cast_skill(Skill.HEALING_LIGHT),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 11
and (data.user.stats.mp or 0) >= 15
@ -238,11 +209,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.BRIGHTNESS,
translation_key=HabiticaButtonEntity.BRIGHTNESS,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(
Skill.SEARING_BRIGHTNESS
)
),
press_fn=lambda habitica: habitica.cast_skill(Skill.SEARING_BRIGHTNESS),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 12
and (data.user.stats.mp or 0) >= 15
@ -253,9 +220,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.PROTECT_AURA,
translation_key=HabiticaButtonEntity.PROTECT_AURA,
press_fn=(
lambda coordinator: coordinator.habitica.cast_skill(Skill.PROTECTIVE_AURA)
),
press_fn=lambda habitica: habitica.cast_skill(Skill.PROTECTIVE_AURA),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 13
and (data.user.stats.mp or 0) >= 30
@ -266,7 +231,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
HabiticaButtonEntityDescription(
key=HabiticaButtonEntity.HEAL_ALL,
translation_key=HabiticaButtonEntity.HEAL_ALL,
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.BLESSING),
press_fn=lambda habitica: habitica.cast_skill(Skill.BLESSING),
available_fn=(
lambda data: (data.user.stats.lvl or 0) >= 14
and (data.user.stats.mp or 0) >= 25
@ -332,33 +297,9 @@ class HabiticaButton(HabiticaBase, ButtonEntity):
async def async_press(self) -> None:
"""Handle the button press."""
try:
await self.entity_description.press_fn(self.coordinator)
except TooManyRequestsError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
translation_placeholders={"retry_after": str(e.retry_after)},
) from e
except NotAuthorizedError as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="service_call_unallowed",
) from e
except HabiticaException as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
translation_placeholders={"reason": e.error.message},
) from e
except ClientError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_call_exception",
translation_placeholders={"reason": str(e)},
) from e
else:
await self.coordinator.async_request_refresh()
await self.coordinator.execute(self.entity_description.press_fn)
await self.coordinator.async_request_refresh()
@property
def available(self) -> bool:

View File

@ -28,6 +28,7 @@ from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
ServiceValidationError,
)
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -130,19 +131,22 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
else:
return HabiticaData(user=user, tasks=tasks + completed_todos)
async def execute(
self, func: Callable[[HabiticaDataUpdateCoordinator], Any]
) -> None:
async def execute(self, func: Callable[[Habitica], Any]) -> None:
"""Execute an API call."""
try:
await func(self)
await func(self.habitica)
except TooManyRequestsError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="setup_rate_limit_exception",
translation_placeholders={"retry_after": str(e.retry_after)},
) from e
except NotAuthorizedError as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="service_call_unallowed",
) from e
except HabiticaException as e:
raise HomeAssistantError(
translation_domain=DOMAIN,

View File

@ -7,6 +7,8 @@ from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from habiticalib import Habitica
from homeassistant.components.switch import (
SwitchDeviceClass,
SwitchEntity,
@ -15,11 +17,7 @@ from homeassistant.components.switch import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import (
HabiticaConfigEntry,
HabiticaData,
HabiticaDataUpdateCoordinator,
)
from .coordinator import HabiticaConfigEntry, HabiticaData
from .entity import HabiticaBase
PARALLEL_UPDATES = 1
@ -29,8 +27,8 @@ PARALLEL_UPDATES = 1
class HabiticaSwitchEntityDescription(SwitchEntityDescription):
"""Describes Habitica switch entity."""
turn_on_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
turn_off_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
turn_on_fn: Callable[[Habitica], Any]
turn_off_fn: Callable[[Habitica], Any]
is_on_fn: Callable[[HabiticaData], bool | None]
@ -45,8 +43,8 @@ SWTICH_DESCRIPTIONS: tuple[HabiticaSwitchEntityDescription, ...] = (
key=HabiticaSwitchEntity.SLEEP,
translation_key=HabiticaSwitchEntity.SLEEP,
device_class=SwitchDeviceClass.SWITCH,
turn_on_fn=lambda coordinator: coordinator.habitica.toggle_sleep(),
turn_off_fn=lambda coordinator: coordinator.habitica.toggle_sleep(),
turn_on_fn=lambda habitica: habitica.toggle_sleep(),
turn_off_fn=lambda habitica: habitica.toggle_sleep(),
is_on_fn=lambda data: data.user.preferences.sleep,
),
)

View File

@ -19,6 +19,7 @@ type HusqvarnaConfigEntry = ConfigEntry[HusqvarnaCoordinator]
PLATFORMS = [
Platform.LAWN_MOWER,
Platform.SENSOR,
]

View File

@ -21,7 +21,7 @@ if TYPE_CHECKING:
SCAN_INTERVAL = timedelta(seconds=60)
class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, bytes]]):
class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, str | int]]):
"""Class to manage fetching data."""
def __init__(
@ -67,11 +67,11 @@ class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, bytes]]):
except BleakError as err:
raise UpdateFailed("Failed to connect") from err
async def _async_update_data(self) -> dict[str, bytes]:
async def _async_update_data(self) -> dict[str, str | int]:
"""Poll the device."""
LOGGER.debug("Polling device")
data: dict[str, bytes] = {}
data: dict[str, str | int] = {}
try:
if not self.mower.is_connected():

View File

@ -3,6 +3,7 @@
from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
@ -28,3 +29,18 @@ class HusqvarnaAutomowerBleEntity(CoordinatorEntity[HusqvarnaCoordinator]):
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.mower.is_connected()
class HusqvarnaAutomowerBleDescriptorEntity(HusqvarnaAutomowerBleEntity):
"""Coordinator entity for entities with entity description."""
def __init__(
self, coordinator: HusqvarnaCoordinator, description: EntityDescription
) -> None:
"""Initialize description entity."""
super().__init__(coordinator)
self._attr_unique_id = (
f"{coordinator.address}_{coordinator.channel_id}_{description.key}"
)
self.entity_description = description

View File

@ -0,0 +1,51 @@
"""Support for sensor entities."""
from __future__ import annotations
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HusqvarnaConfigEntry
from .entity import HusqvarnaAutomowerBleDescriptorEntity
DESCRIPTIONS = (
SensorEntityDescription(
key="battery_level",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=PERCENTAGE,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HusqvarnaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Husqvarna Automower Ble sensor based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
HusqvarnaAutomowerBleSensor(coordinator, description)
for description in DESCRIPTIONS
if description.key in coordinator.data
)
class HusqvarnaAutomowerBleSensor(HusqvarnaAutomowerBleDescriptorEntity, SensorEntity):
"""Representation of a sensor."""
entity_description: SensorEntityDescription
@property
def native_value(self) -> str | int:
"""Return the previously fetched value."""
return self.coordinator.data[self.entity_description.key]

View File

@ -13,7 +13,7 @@
}
},
"abort": {
"no_devices_found": "No supported LeaOne devices found in range; If the device is in range, ensure it has been activated in the last few minutes. If you need clarification on whether the device is in-range, download the diagnostics for the integration that provides your Bluetooth adapter or proxy and check if the MAC address of the LeaOne device is present.",
"no_devices_found": "No supported LeaOne devices found in range. If the device is in range, ensure it has been activated in the last few minutes. If you need clarification on whether the device is in range, download the diagnostics for the integration that provides your Bluetooth adapter or proxy and check if the MAC address of the LeaOne device is present.",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}

View File

@ -17,5 +17,7 @@ ATTR_INCLUDE_TAGS = "include_tags"
ATTR_ENTRY_TYPE = "entry_type"
ATTR_NOTE_TITLE = "note_title"
ATTR_NOTE_TEXT = "note_text"
ATTR_SEARCH_TERMS = "search_terms"
ATTR_RESULT_LIMIT = "result_limit"
MIN_REQUIRED_MEALIE_VERSION = AwesomeVersion("v1.0.0")

View File

@ -30,6 +30,9 @@
"get_recipe": {
"service": "mdi:map"
},
"get_recipes": {
"service": "mdi:book-open-page-variant"
},
"import_recipe": {
"service": "mdi:map-search"
},

View File

@ -32,6 +32,8 @@ from .const import (
ATTR_NOTE_TEXT,
ATTR_NOTE_TITLE,
ATTR_RECIPE_ID,
ATTR_RESULT_LIMIT,
ATTR_SEARCH_TERMS,
ATTR_START_DATE,
ATTR_URL,
DOMAIN,
@ -55,6 +57,15 @@ SERVICE_GET_RECIPE_SCHEMA = vol.Schema(
}
)
SERVICE_GET_RECIPES = "get_recipes"
SERVICE_GET_RECIPES_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
vol.Optional(ATTR_SEARCH_TERMS): str,
vol.Optional(ATTR_RESULT_LIMIT): int,
}
)
SERVICE_IMPORT_RECIPE = "import_recipe"
SERVICE_IMPORT_RECIPE_SCHEMA = vol.Schema(
{
@ -159,6 +170,27 @@ async def _async_get_recipe(call: ServiceCall) -> ServiceResponse:
return {"recipe": asdict(recipe)}
async def _async_get_recipes(call: ServiceCall) -> ServiceResponse:
"""Get recipes."""
entry = _async_get_entry(call)
search_terms = call.data.get(ATTR_SEARCH_TERMS)
result_limit = call.data.get(ATTR_RESULT_LIMIT, 10)
client = entry.runtime_data.client
try:
recipes = await client.get_recipes(search=search_terms, per_page=result_limit)
except MealieConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="connection_error",
) from err
except MealieNotFoundError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_recipes_found",
) from err
return {"recipes": asdict(recipes)}
async def _async_import_recipe(call: ServiceCall) -> ServiceResponse:
"""Import a recipe."""
entry = _async_get_entry(call)
@ -242,6 +274,13 @@ def async_setup_services(hass: HomeAssistant) -> None:
schema=SERVICE_GET_RECIPE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_RECIPES,
_async_get_recipes,
schema=SERVICE_GET_RECIPES_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_IMPORT_RECIPE,

View File

@ -24,6 +24,27 @@ get_recipe:
selector:
text:
get_recipes:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: mealie
search_terms:
required: false
selector:
text:
result_limit:
required: false
default: 10
selector:
number:
min: 1
max: 100
mode: box
unit_of_measurement: recipes
import_recipe:
fields:
config_entry_id:

View File

@ -109,6 +109,9 @@
"recipe_not_found": {
"message": "Recipe with ID or slug `{recipe_id}` not found."
},
"no_recipes_found": {
"message": "No recipes found matching your search."
},
"could_not_import_recipe": {
"message": "Mealie could not import the recipe from the URL."
},
@ -176,6 +179,24 @@
}
}
},
"get_recipes": {
"name": "Get recipes",
"description": "Searches for recipes with any matching properties in Mealie",
"fields": {
"config_entry_id": {
"name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]",
"description": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::description%]"
},
"search_terms": {
"name": "Search terms",
"description": "Terms to search for in recipe properties."
},
"result_limit": {
"name": "Result limit",
"description": "Maximum number of recipes to return (default: 10)."
}
}
},
"import_recipe": {
"name": "Import recipe",
"description": "Imports a recipe from an URL",

View File

@ -110,6 +110,9 @@
},
"set_program": {
"service": "mdi:arrow-right-circle-outline"
},
"set_program_oven": {
"service": "mdi:arrow-right-circle-outline"
}
}
}

View File

@ -1,12 +1,13 @@
"""Services for Miele integration."""
from datetime import timedelta
import logging
from typing import cast
import aiohttp
import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.const import ATTR_DEVICE_ID, ATTR_TEMPERATURE
from homeassistant.core import (
HomeAssistant,
ServiceCall,
@ -32,6 +33,19 @@ SERVICE_SET_PROGRAM_SCHEMA = vol.Schema(
},
)
SERVICE_SET_PROGRAM_OVEN = "set_program_oven"
SERVICE_SET_PROGRAM_OVEN_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): str,
vol.Required(ATTR_PROGRAM_ID): cv.positive_int,
vol.Optional(ATTR_TEMPERATURE): cv.positive_int,
vol.Optional(ATTR_DURATION): vol.All(
cv.time_period,
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12)),
),
},
)
SERVICE_GET_PROGRAMS = "get_programs"
SERVICE_GET_PROGRAMS_SCHEMA = vol.Schema(
{
@ -103,6 +117,36 @@ async def set_program(call: ServiceCall) -> None:
) from ex
async def set_program_oven(call: ServiceCall) -> None:
"""Set a program on a Miele oven."""
_LOGGER.debug("Set program call: %s", call)
config_entry = await _extract_config_entry(call)
api = config_entry.runtime_data.api
serial_number = await _get_serial_number(call)
data = {"programId": call.data[ATTR_PROGRAM_ID]}
if call.data.get(ATTR_DURATION) is not None:
td = call.data[ATTR_DURATION]
data["duration"] = [
td.seconds // 3600, # hours
(td.seconds // 60) % 60, # minutes
]
if call.data.get(ATTR_TEMPERATURE) is not None:
data["temperature"] = call.data[ATTR_TEMPERATURE]
try:
await api.set_program(serial_number, data)
except aiohttp.ClientResponseError as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_program_oven_error",
translation_placeholders={
"status": str(ex.status),
"message": ex.message,
},
) from ex
async def get_programs(call: ServiceCall) -> ServiceResponse:
"""Get available programs from appliance."""
@ -126,7 +170,7 @@ async def get_programs(call: ServiceCall) -> ServiceResponse:
"programs": [
{
"program_id": item["programId"],
"program": item["program"],
"program": item["program"].strip(),
"parameters": (
{
"temperature": (
@ -172,7 +216,17 @@ async def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
hass.services.async_register(
DOMAIN, SERVICE_SET_PROGRAM, set_program, SERVICE_SET_PROGRAM_SCHEMA
DOMAIN,
SERVICE_SET_PROGRAM,
set_program,
SERVICE_SET_PROGRAM_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_SET_PROGRAM_OVEN,
set_program_oven,
SERVICE_SET_PROGRAM_OVEN_SCHEMA,
)
hass.services.async_register(

View File

@ -23,3 +23,33 @@ set_program:
max: 99999
mode: box
example: 24
set_program_oven:
fields:
device_id:
selector:
device:
integration: miele
required: true
program_id:
required: true
selector:
number:
min: 0
max: 99999
mode: box
example: 24
temperature:
required: false
selector:
number:
min: 30
max: 300
unit_of_measurement: "°C"
mode: box
example: 180
duration:
required: false
selector:
duration:
example: 1:15:00

View File

@ -1063,10 +1063,13 @@
"message": "Invalid device targeted."
},
"get_programs_error": {
"message": "'Get programs' action failed {status} / {message}."
"message": "'Get programs' action failed: {status} / {message}"
},
"set_program_error": {
"message": "'Set program' action failed {status} / {message}."
"message": "'Set program' action failed: {status} / {message}"
},
"set_program_oven_error": {
"message": "'Set program on oven' action failed: {status} / {message}"
},
"set_state_error": {
"message": "Failed to set state for {entity}."
@ -1096,6 +1099,28 @@
"name": "Program ID"
}
}
},
"set_program_oven": {
"name": "Set program on oven",
"description": "[%key:component::miele::services::set_program::description%]",
"fields": {
"device_id": {
"description": "[%key:component::miele::services::set_program::fields::device_id::description%]",
"name": "[%key:component::miele::services::set_program::fields::device_id::name%]"
},
"program_id": {
"description": "[%key:component::miele::services::set_program::fields::program_id::description%]",
"name": "[%key:component::miele::services::set_program::fields::program_id::name%]"
},
"temperature": {
"description": "The target temperature for the oven program.",
"name": "[%key:component::sensor::entity_component::temperature::name%]"
},
"duration": {
"description": "The duration for the oven program.",
"name": "[%key:component::sensor::entity_component::duration::name%]"
}
}
}
}
}

View File

@ -60,6 +60,17 @@ from .const import (
CONF_CURRENT_HUMIDITY_TOPIC,
CONF_CURRENT_TEMP_TEMPLATE,
CONF_CURRENT_TEMP_TOPIC,
CONF_FAN_MODE_COMMAND_TEMPLATE,
CONF_FAN_MODE_COMMAND_TOPIC,
CONF_FAN_MODE_LIST,
CONF_FAN_MODE_STATE_TEMPLATE,
CONF_FAN_MODE_STATE_TOPIC,
CONF_HUMIDITY_COMMAND_TEMPLATE,
CONF_HUMIDITY_COMMAND_TOPIC,
CONF_HUMIDITY_MAX,
CONF_HUMIDITY_MIN,
CONF_HUMIDITY_STATE_TEMPLATE,
CONF_HUMIDITY_STATE_TOPIC,
CONF_MODE_COMMAND_TEMPLATE,
CONF_MODE_COMMAND_TOPIC,
CONF_MODE_LIST,
@ -68,14 +79,39 @@ from .const import (
CONF_POWER_COMMAND_TEMPLATE,
CONF_POWER_COMMAND_TOPIC,
CONF_PRECISION,
CONF_PRESET_MODE_COMMAND_TEMPLATE,
CONF_PRESET_MODE_COMMAND_TOPIC,
CONF_PRESET_MODE_STATE_TOPIC,
CONF_PRESET_MODE_VALUE_TEMPLATE,
CONF_PRESET_MODES_LIST,
CONF_RETAIN,
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE,
CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC,
CONF_SWING_HORIZONTAL_MODE_LIST,
CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE,
CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC,
CONF_SWING_MODE_COMMAND_TEMPLATE,
CONF_SWING_MODE_COMMAND_TOPIC,
CONF_SWING_MODE_LIST,
CONF_SWING_MODE_STATE_TEMPLATE,
CONF_SWING_MODE_STATE_TOPIC,
CONF_TEMP_COMMAND_TEMPLATE,
CONF_TEMP_COMMAND_TOPIC,
CONF_TEMP_HIGH_COMMAND_TEMPLATE,
CONF_TEMP_HIGH_COMMAND_TOPIC,
CONF_TEMP_HIGH_STATE_TEMPLATE,
CONF_TEMP_HIGH_STATE_TOPIC,
CONF_TEMP_INITIAL,
CONF_TEMP_LOW_COMMAND_TEMPLATE,
CONF_TEMP_LOW_COMMAND_TOPIC,
CONF_TEMP_LOW_STATE_TEMPLATE,
CONF_TEMP_LOW_STATE_TOPIC,
CONF_TEMP_MAX,
CONF_TEMP_MIN,
CONF_TEMP_STATE_TEMPLATE,
CONF_TEMP_STATE_TOPIC,
CONF_TEMP_STEP,
DEFAULT_CLIMATE_INITIAL_TEMPERATURE,
DEFAULT_OPTIMISTIC,
PAYLOAD_NONE,
)
@ -95,49 +131,6 @@ PARALLEL_UPDATES = 0
DEFAULT_NAME = "MQTT HVAC"
CONF_FAN_MODE_COMMAND_TEMPLATE = "fan_mode_command_template"
CONF_FAN_MODE_COMMAND_TOPIC = "fan_mode_command_topic"
CONF_FAN_MODE_LIST = "fan_modes"
CONF_FAN_MODE_STATE_TEMPLATE = "fan_mode_state_template"
CONF_FAN_MODE_STATE_TOPIC = "fan_mode_state_topic"
CONF_HUMIDITY_COMMAND_TEMPLATE = "target_humidity_command_template"
CONF_HUMIDITY_COMMAND_TOPIC = "target_humidity_command_topic"
CONF_HUMIDITY_STATE_TEMPLATE = "target_humidity_state_template"
CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic"
CONF_HUMIDITY_MAX = "max_humidity"
CONF_HUMIDITY_MIN = "min_humidity"
CONF_PRESET_MODE_STATE_TOPIC = "preset_mode_state_topic"
CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic"
CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template"
CONF_PRESET_MODE_COMMAND_TEMPLATE = "preset_mode_command_template"
CONF_PRESET_MODES_LIST = "preset_modes"
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template"
CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC = "swing_horizontal_mode_command_topic"
CONF_SWING_HORIZONTAL_MODE_LIST = "swing_horizontal_modes"
CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE = "swing_horizontal_mode_state_template"
CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC = "swing_horizontal_mode_state_topic"
CONF_SWING_MODE_COMMAND_TEMPLATE = "swing_mode_command_template"
CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic"
CONF_SWING_MODE_LIST = "swing_modes"
CONF_SWING_MODE_STATE_TEMPLATE = "swing_mode_state_template"
CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic"
CONF_TEMP_HIGH_COMMAND_TEMPLATE = "temperature_high_command_template"
CONF_TEMP_HIGH_COMMAND_TOPIC = "temperature_high_command_topic"
CONF_TEMP_HIGH_STATE_TEMPLATE = "temperature_high_state_template"
CONF_TEMP_HIGH_STATE_TOPIC = "temperature_high_state_topic"
CONF_TEMP_LOW_COMMAND_TEMPLATE = "temperature_low_command_template"
CONF_TEMP_LOW_COMMAND_TOPIC = "temperature_low_command_topic"
CONF_TEMP_LOW_STATE_TEMPLATE = "temperature_low_state_template"
CONF_TEMP_LOW_STATE_TOPIC = "temperature_low_state_topic"
CONF_TEMP_STEP = "temp_step"
DEFAULT_INITIAL_TEMPERATURE = 21.0
MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset(
{
climate.ATTR_CURRENT_HUMIDITY,
@ -299,8 +292,9 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend(
vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string,
vol.Optional(CONF_POWER_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(CONF_POWER_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_PRECISION): vol.In(
[PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]
vol.Optional(CONF_PRECISION): vol.All(
vol.Coerce(float),
vol.In([PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]),
),
vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
vol.Optional(CONF_ACTION_TEMPLATE): cv.template,
@ -577,7 +571,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
init_temp: float = config.get(
CONF_TEMP_INITIAL,
TemperatureConverter.convert(
DEFAULT_INITIAL_TEMPERATURE,
DEFAULT_CLIMATE_INITIAL_TEMPERATURE,
UnitOfTemperature.CELSIUS,
self.temperature_unit,
),

View File

@ -29,6 +29,13 @@ import yaml
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.button import ButtonDeviceClass
from homeassistant.components.climate import (
DEFAULT_MAX_HUMIDITY,
DEFAULT_MAX_TEMP,
DEFAULT_MIN_HUMIDITY,
DEFAULT_MIN_TEMP,
PRESET_NONE,
)
from homeassistant.components.cover import CoverDeviceClass
from homeassistant.components.file_upload import process_uploaded_file
from homeassistant.components.hassio import AddonError, AddonManager, AddonState
@ -80,6 +87,7 @@ from homeassistant.const import (
CONF_PORT,
CONF_PROTOCOL,
CONF_STATE_TEMPLATE,
CONF_TEMPERATURE_UNIT,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_USERNAME,
@ -89,8 +97,9 @@ from homeassistant.const import (
STATE_OPEN,
STATE_OPENING,
EntityCategory,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant, async_get_hass, callback
from homeassistant.data_entry_flow import AbortFlow, SectionConfig, section
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.hassio import is_hassio
@ -115,6 +124,7 @@ from homeassistant.helpers.selector import (
)
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
from homeassistant.util.unit_conversion import TemperatureConverter
from .addon import get_addon_manager
from .client import MqttClientSetup
@ -123,6 +133,8 @@ from .const import (
ATTR_QOS,
ATTR_RETAIN,
ATTR_TOPIC,
CONF_ACTION_TEMPLATE,
CONF_ACTION_TOPIC,
CONF_AVAILABILITY_TEMPLATE,
CONF_AVAILABILITY_TOPIC,
CONF_BIRTH_MESSAGE,
@ -149,6 +161,10 @@ from .const import (
CONF_COMMAND_ON_TEMPLATE,
CONF_COMMAND_TEMPLATE,
CONF_COMMAND_TOPIC,
CONF_CURRENT_HUMIDITY_TEMPLATE,
CONF_CURRENT_HUMIDITY_TOPIC,
CONF_CURRENT_TEMP_TEMPLATE,
CONF_CURRENT_TEMP_TOPIC,
CONF_DIRECTION_COMMAND_TEMPLATE,
CONF_DIRECTION_COMMAND_TOPIC,
CONF_DIRECTION_STATE_TOPIC,
@ -162,6 +178,11 @@ from .const import (
CONF_EFFECT_VALUE_TEMPLATE,
CONF_ENTITY_PICTURE,
CONF_EXPIRE_AFTER,
CONF_FAN_MODE_COMMAND_TEMPLATE,
CONF_FAN_MODE_COMMAND_TOPIC,
CONF_FAN_MODE_LIST,
CONF_FAN_MODE_STATE_TEMPLATE,
CONF_FAN_MODE_STATE_TOPIC,
CONF_FLASH,
CONF_FLASH_TIME_LONG,
CONF_FLASH_TIME_SHORT,
@ -172,10 +193,21 @@ from .const import (
CONF_HS_COMMAND_TOPIC,
CONF_HS_STATE_TOPIC,
CONF_HS_VALUE_TEMPLATE,
CONF_HUMIDITY_COMMAND_TEMPLATE,
CONF_HUMIDITY_COMMAND_TOPIC,
CONF_HUMIDITY_MAX,
CONF_HUMIDITY_MIN,
CONF_HUMIDITY_STATE_TEMPLATE,
CONF_HUMIDITY_STATE_TOPIC,
CONF_KEEPALIVE,
CONF_LAST_RESET_VALUE_TEMPLATE,
CONF_MAX_KELVIN,
CONF_MIN_KELVIN,
CONF_MODE_COMMAND_TEMPLATE,
CONF_MODE_COMMAND_TOPIC,
CONF_MODE_LIST,
CONF_MODE_STATE_TEMPLATE,
CONF_MODE_STATE_TOPIC,
CONF_OFF_DELAY,
CONF_ON_COMMAND_TYPE,
CONF_OPTIONS,
@ -200,6 +232,9 @@ from .const import (
CONF_PERCENTAGE_VALUE_TEMPLATE,
CONF_POSITION_CLOSED,
CONF_POSITION_OPEN,
CONF_POWER_COMMAND_TEMPLATE,
CONF_POWER_COMMAND_TOPIC,
CONF_PRECISION,
CONF_PRESET_MODE_COMMAND_TEMPLATE,
CONF_PRESET_MODE_COMMAND_TOPIC,
CONF_PRESET_MODE_STATE_TOPIC,
@ -236,6 +271,32 @@ from .const import (
CONF_STATE_VALUE_TEMPLATE,
CONF_SUGGESTED_DISPLAY_PRECISION,
CONF_SUPPORTED_COLOR_MODES,
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE,
CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC,
CONF_SWING_HORIZONTAL_MODE_LIST,
CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE,
CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC,
CONF_SWING_MODE_COMMAND_TEMPLATE,
CONF_SWING_MODE_COMMAND_TOPIC,
CONF_SWING_MODE_LIST,
CONF_SWING_MODE_STATE_TEMPLATE,
CONF_SWING_MODE_STATE_TOPIC,
CONF_TEMP_COMMAND_TEMPLATE,
CONF_TEMP_COMMAND_TOPIC,
CONF_TEMP_HIGH_COMMAND_TEMPLATE,
CONF_TEMP_HIGH_COMMAND_TOPIC,
CONF_TEMP_HIGH_STATE_TEMPLATE,
CONF_TEMP_HIGH_STATE_TOPIC,
CONF_TEMP_INITIAL,
CONF_TEMP_LOW_COMMAND_TEMPLATE,
CONF_TEMP_LOW_COMMAND_TOPIC,
CONF_TEMP_LOW_STATE_TEMPLATE,
CONF_TEMP_LOW_STATE_TOPIC,
CONF_TEMP_MAX,
CONF_TEMP_MIN,
CONF_TEMP_STATE_TEMPLATE,
CONF_TEMP_STATE_TOPIC,
CONF_TEMP_STEP,
CONF_TILT_CLOSED_POSITION,
CONF_TILT_COMMAND_TEMPLATE,
CONF_TILT_COMMAND_TOPIC,
@ -260,6 +321,7 @@ from .const import (
CONFIG_ENTRY_MINOR_VERSION,
CONFIG_ENTRY_VERSION,
DEFAULT_BIRTH,
DEFAULT_CLIMATE_INITIAL_TEMPERATURE,
DEFAULT_DISCOVERY,
DEFAULT_ENCODING,
DEFAULT_KEEPALIVE,
@ -392,6 +454,7 @@ KEY_UPLOAD_SELECTOR = FileSelector(
SUBENTRY_PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.COVER,
Platform.FAN,
Platform.LIGHT,
@ -493,6 +556,59 @@ TIMEOUT_SELECTOR = NumberSelector(
NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0)
)
# Climate specific selectors
CLIMATE_MODE_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=["auto", "off", "cool", "heat", "dry", "fan_only"],
multiple=True,
translation_key="climate_modes",
)
)
@callback
def temperature_selector(config: dict[str, Any]) -> Selector:
"""Return a temperature selector with configured or system unit."""
return NumberSelector(
NumberSelectorConfig(
mode=NumberSelectorMode.BOX,
unit_of_measurement=cv.temperature_unit(config[CONF_TEMPERATURE_UNIT]),
)
)
@callback
def temperature_step_selector(config: dict[str, Any]) -> Selector:
"""Return a temperature step selector."""
return NumberSelector(
NumberSelectorConfig(
mode=NumberSelectorMode.BOX,
min=0.1,
max=10.0,
step=0.1,
unit_of_measurement=cv.temperature_unit(config[CONF_TEMPERATURE_UNIT]),
)
)
TEMPERATURE_UNIT_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(value="C", label="°C"),
SelectOptionDict(value="F", label="°F"),
],
mode=SelectSelectorMode.DROPDOWN,
)
)
PRECISION_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=["1.0", "0.5", "0.1"],
mode=SelectSelectorMode.DROPDOWN,
)
)
# Cover specific selectors
POSITION_SELECTOR = NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX))
@ -567,10 +683,91 @@ SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector(
EXCLUDE_FROM_CONFIG_IF_NONE = {CONF_ENTITY_CATEGORY}
# Target temperature feature selector
@callback
def validate_cover_platform_config(
config: dict[str, Any],
) -> dict[str, str]:
def configured_target_temperature_feature(config: dict[str, Any]) -> str:
"""Calculate current target temperature feature from config."""
if (
config == {CONF_PLATFORM: Platform.CLIMATE.value}
or CONF_TEMP_COMMAND_TOPIC in config
):
# default to single on initial set
return "single"
if CONF_TEMP_HIGH_COMMAND_TOPIC in config:
return "high_low"
return "none"
TARGET_TEMPERATURE_FEATURE_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=["single", "high_low", "none"],
mode=SelectSelectorMode.DROPDOWN,
translation_key="target_temperature_feature",
)
)
HUMIDITY_SELECTOR = vol.All(
NumberSelector(
NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=100, step=1)
),
vol.Coerce(int),
)
@callback
def temperature_default_from_celsius_to_system_default(
value: float,
) -> Callable[[dict[str, Any]], int]:
"""Return temperature in Celsius in system default unit."""
def _default(config: dict[str, Any]) -> int:
return round(
TemperatureConverter.convert(
value,
UnitOfTemperature.CELSIUS,
cv.temperature_unit(config[CONF_TEMPERATURE_UNIT]),
)
)
return _default
@callback
def default_precision(config: dict[str, Any]) -> str:
"""Return the thermostat precision for system default unit."""
return str(
config.get(
CONF_PRECISION,
0.1
if cv.temperature_unit(config[CONF_TEMPERATURE_UNIT])
is UnitOfTemperature.CELSIUS
else 1.0,
)
)
@callback
def validate_climate_platform_config(config: dict[str, Any]) -> dict[str, str]:
"""Validate the climate platform options."""
errors: dict[str, str] = {}
if (
CONF_PRESET_MODES_LIST in config
and PRESET_NONE in config[CONF_PRESET_MODES_LIST]
):
errors["climate_preset_mode_settings"] = "preset_mode_none_not_allowed"
if (
CONF_HUMIDITY_MIN in config
and config[CONF_HUMIDITY_MIN] >= config[CONF_HUMIDITY_MAX]
):
errors["target_humidity_settings"] = "max_below_min_humidity"
if CONF_TEMP_MIN in config and config[CONF_TEMP_MIN] >= config[CONF_TEMP_MAX]:
errors["target_temperature_settings"] = "max_below_min_temperature"
return errors
@callback
def validate_cover_platform_config(config: dict[str, Any]) -> dict[str, str]:
"""Validate the cover platform options."""
errors: dict[str, str] = {}
@ -680,6 +877,14 @@ def validate_sensor_platform_config(
return errors
@callback
def no_empty_list(value: list[Any]) -> list[Any]:
"""Validate a selector returns at least one item."""
if not value:
raise vol.Invalid("empty_list_not_allowed")
return value
@callback
def validate(validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
"""Run validator, then return the unmodified input."""
@ -695,13 +900,13 @@ def validate(validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
class PlatformField:
"""Stores a platform config field schema, required flag and validator."""
selector: Selector[Any] | Callable[..., Selector[Any]]
selector: Selector[Any] | Callable[[dict[str, Any]], Selector[Any]]
required: bool
validator: Callable[..., Any] | None = None
validator: Callable[[Any], Any] | None = None
error: str | None = None
default: (
str | int | bool | None | Callable[[dict[str, Any]], Any] | vol.Undefined
) = vol.UNDEFINED
default: Any | None | Callable[[dict[str, Any]], Any] | vol.Undefined = (
vol.UNDEFINED
)
is_schema_default: bool = False
exclude_from_reconfig: bool = False
exclude_from_config: bool = False
@ -790,6 +995,78 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = {
required=False,
),
},
Platform.CLIMATE.value: {
CONF_TEMPERATURE_UNIT: PlatformField(
selector=TEMPERATURE_UNIT_SELECTOR,
validator=validate(cv.temperature_unit),
required=True,
exclude_from_reconfig=True,
default=lambda _: "C"
if async_get_hass().config.units.temperature_unit
is UnitOfTemperature.CELSIUS
else "F",
),
"climate_feature_action": PlatformField(
selector=BOOLEAN_SELECTOR,
required=False,
exclude_from_config=True,
default=lambda config: bool(config.get(CONF_ACTION_TOPIC)),
),
"climate_feature_target_temperature": PlatformField(
selector=TARGET_TEMPERATURE_FEATURE_SELECTOR,
required=False,
exclude_from_config=True,
default=configured_target_temperature_feature,
),
"climate_feature_current_temperature": PlatformField(
selector=BOOLEAN_SELECTOR,
required=False,
exclude_from_config=True,
default=lambda config: bool(config.get(CONF_CURRENT_TEMP_TOPIC)),
),
"climate_feature_target_humidity": PlatformField(
selector=BOOLEAN_SELECTOR,
required=False,
exclude_from_config=True,
default=lambda config: bool(config.get(CONF_HUMIDITY_COMMAND_TOPIC)),
),
"climate_feature_current_humidity": PlatformField(
selector=BOOLEAN_SELECTOR,
required=False,
exclude_from_config=True,
default=lambda config: bool(config.get(CONF_HUMIDITY_STATE_TOPIC)),
),
"climate_feature_preset_modes": PlatformField(
selector=BOOLEAN_SELECTOR,
required=False,
exclude_from_config=True,
default=lambda config: bool(config.get(CONF_PRESET_MODES_LIST)),
),
"climate_feature_fan_modes": PlatformField(
selector=BOOLEAN_SELECTOR,
required=False,
exclude_from_config=True,
default=lambda config: bool(config.get(CONF_FAN_MODE_LIST)),
),
"climate_feature_swing_modes": PlatformField(
selector=BOOLEAN_SELECTOR,
required=False,
exclude_from_config=True,
default=lambda config: bool(config.get(CONF_SWING_MODE_LIST)),
),
"climate_feature_swing_horizontal_modes": PlatformField(
selector=BOOLEAN_SELECTOR,
required=False,
exclude_from_config=True,
default=lambda config: bool(config.get(CONF_SWING_HORIZONTAL_MODE_LIST)),
),
"climate_feature_power": PlatformField(
selector=BOOLEAN_SELECTOR,
required=False,
exclude_from_config=True,
default=lambda config: bool(config.get(CONF_POWER_COMMAND_TOPIC)),
),
},
Platform.COVER.value: {
CONF_DEVICE_CLASS: PlatformField(
selector=COVER_DEVICE_CLASS_SELECTOR,
@ -929,6 +1206,496 @@ PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = {
),
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
},
Platform.CLIMATE.value: {
# operation mode settings
CONF_MODE_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_publish_topic,
error="invalid_publish_topic",
),
CONF_MODE_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_MODE_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
),
CONF_MODE_STATE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_MODE_LIST: PlatformField(
selector=CLIMATE_MODE_SELECTOR,
required=True,
default=[],
validator=validate(no_empty_list),
error="empty_list_not_allowed",
),
CONF_RETAIN: PlatformField(
selector=BOOLEAN_SELECTOR, required=False, validator=validate(bool)
),
CONF_OPTIMISTIC: PlatformField(
selector=BOOLEAN_SELECTOR, required=False, validator=validate(bool)
),
# current action settings
CONF_ACTION_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
section="climate_action_settings",
conditions=({"climate_feature_action": True},),
),
CONF_ACTION_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="climate_action_settings",
conditions=({"climate_feature_action": True},),
),
# target temperature settings
CONF_TEMP_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
section="target_temperature_settings",
conditions=({"climate_feature_target_temperature": "single"},),
),
CONF_TEMP_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="target_temperature_settings",
conditions=({"climate_feature_target_temperature": "single"},),
),
CONF_TEMP_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
section="target_temperature_settings",
conditions=({"climate_feature_target_temperature": "single"},),
),
CONF_TEMP_STATE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="target_temperature_settings",
conditions=({"climate_feature_target_temperature": "single"},),
),
CONF_TEMP_LOW_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
section="target_temperature_settings",
conditions=({"climate_feature_target_temperature": "high_low"},),
),
CONF_TEMP_LOW_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="target_temperature_settings",
conditions=({"climate_feature_target_temperature": "high_low"},),
),
CONF_TEMP_LOW_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
section="target_temperature_settings",
conditions=({"climate_feature_target_temperature": "high_low"},),
),
CONF_TEMP_LOW_STATE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="target_temperature_settings",
conditions=({"climate_feature_target_temperature": "high_low"},),
),
CONF_TEMP_HIGH_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
section="target_temperature_settings",
conditions=({"climate_feature_target_temperature": "high_low"},),
),
CONF_TEMP_HIGH_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="target_temperature_settings",
conditions=({"climate_feature_target_temperature": "high_low"},),
),
CONF_TEMP_HIGH_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
section="target_temperature_settings",
conditions=({"climate_feature_target_temperature": "high_low"},),
),
CONF_TEMP_HIGH_STATE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="target_temperature_settings",
conditions=({"climate_feature_target_temperature": "high_low"},),
),
CONF_TEMP_MIN: PlatformField(
selector=temperature_selector,
custom_filtering=True,
required=True,
default=temperature_default_from_celsius_to_system_default(
DEFAULT_MIN_TEMP
),
section="target_temperature_settings",
conditions=(
{"climate_feature_target_temperature": "high_low"},
{"climate_feature_target_temperature": "single"},
),
),
CONF_TEMP_MAX: PlatformField(
selector=temperature_selector,
custom_filtering=True,
required=True,
default=temperature_default_from_celsius_to_system_default(
DEFAULT_MAX_TEMP
),
section="target_temperature_settings",
conditions=(
{"climate_feature_target_temperature": "high_low"},
{"climate_feature_target_temperature": "single"},
),
),
CONF_PRECISION: PlatformField(
selector=PRECISION_SELECTOR,
required=False,
default=default_precision,
section="target_temperature_settings",
conditions=(
{"climate_feature_target_temperature": "high_low"},
{"climate_feature_target_temperature": "single"},
),
),
CONF_TEMP_STEP: PlatformField(
selector=temperature_step_selector,
custom_filtering=True,
required=False,
default=1.0,
section="target_temperature_settings",
conditions=(
{"climate_feature_target_temperature": "high_low"},
{"climate_feature_target_temperature": "single"},
),
),
CONF_TEMP_INITIAL: PlatformField(
selector=temperature_selector,
custom_filtering=True,
required=False,
default=temperature_default_from_celsius_to_system_default(
DEFAULT_CLIMATE_INITIAL_TEMPERATURE
),
section="target_temperature_settings",
conditions=(
{"climate_feature_target_temperature": "high_low"},
{"climate_feature_target_temperature": "single"},
),
),
# current temperature settings
CONF_CURRENT_TEMP_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
section="current_temperature_settings",
conditions=({"climate_feature_current_temperature": True},),
),
CONF_CURRENT_TEMP_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="current_temperature_settings",
conditions=({"climate_feature_current_temperature": True},),
),
# target humidity settings
CONF_HUMIDITY_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
section="target_humidity_settings",
conditions=({"climate_feature_target_humidity": True},),
),
CONF_HUMIDITY_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="target_humidity_settings",
conditions=({"climate_feature_target_humidity": True},),
),
CONF_HUMIDITY_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
section="target_humidity_settings",
conditions=({"climate_feature_target_humidity": True},),
),
CONF_HUMIDITY_STATE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="target_humidity_settings",
conditions=({"climate_feature_target_humidity": True},),
),
CONF_HUMIDITY_MIN: PlatformField(
selector=HUMIDITY_SELECTOR,
required=True,
default=DEFAULT_MIN_HUMIDITY,
section="target_humidity_settings",
conditions=({"climate_feature_target_humidity": True},),
),
CONF_HUMIDITY_MAX: PlatformField(
selector=HUMIDITY_SELECTOR,
required=True,
default=DEFAULT_MAX_HUMIDITY,
section="target_humidity_settings",
conditions=({"climate_feature_target_humidity": True},),
),
# current humidity settings
CONF_CURRENT_HUMIDITY_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
section="current_humidity_settings",
conditions=({"climate_feature_current_humidity": True},),
),
CONF_CURRENT_HUMIDITY_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="current_humidity_settings",
conditions=({"climate_feature_current_humidity": True},),
),
# power on/off support
CONF_POWER_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_publish_topic,
error="invalid_publish_topic",
section="climate_power_settings",
conditions=({"climate_feature_power": True},),
),
CONF_POWER_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="climate_power_settings",
conditions=({"climate_feature_power": True},),
),
CONF_PAYLOAD_OFF: PlatformField(
selector=TEXT_SELECTOR,
required=False,
default=DEFAULT_PAYLOAD_OFF,
section="climate_power_settings",
conditions=({"climate_feature_power": True},),
),
CONF_PAYLOAD_ON: PlatformField(
selector=TEXT_SELECTOR,
required=False,
default=DEFAULT_PAYLOAD_ON,
section="climate_power_settings",
conditions=({"climate_feature_power": True},),
),
# preset mode settings
CONF_PRESET_MODE_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
section="climate_preset_mode_settings",
conditions=({"climate_feature_preset_modes": True},),
),
CONF_PRESET_MODE_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="climate_preset_mode_settings",
conditions=({"climate_feature_preset_modes": True},),
),
CONF_PRESET_MODE_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
section="climate_preset_mode_settings",
conditions=({"climate_feature_preset_modes": True},),
),
CONF_PRESET_MODE_VALUE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="climate_preset_mode_settings",
conditions=({"climate_feature_preset_modes": True},),
),
CONF_PRESET_MODES_LIST: PlatformField(
selector=PRESET_MODES_SELECTOR,
required=True,
validator=validate(no_empty_list),
error="empty_list_not_allowed",
section="climate_preset_mode_settings",
conditions=({"climate_feature_preset_modes": True},),
),
# fan mode settings
CONF_FAN_MODE_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
section="climate_fan_mode_settings",
conditions=({"climate_feature_fan_modes": True},),
),
CONF_FAN_MODE_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="climate_fan_mode_settings",
conditions=({"climate_feature_fan_modes": True},),
),
CONF_FAN_MODE_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
section="climate_fan_mode_settings",
conditions=({"climate_feature_fan_modes": True},),
),
CONF_FAN_MODE_STATE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="climate_fan_mode_settings",
conditions=({"climate_feature_fan_modes": True},),
),
CONF_FAN_MODE_LIST: PlatformField(
selector=PRESET_MODES_SELECTOR,
required=True,
validator=validate(no_empty_list),
error="empty_list_not_allowed",
section="climate_fan_mode_settings",
conditions=({"climate_feature_fan_modes": True},),
),
# swing mode settings
CONF_SWING_MODE_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
section="climate_swing_mode_settings",
conditions=({"climate_feature_swing_modes": True},),
),
CONF_SWING_MODE_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="climate_swing_mode_settings",
conditions=({"climate_feature_swing_modes": True},),
),
CONF_SWING_MODE_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
section="climate_swing_mode_settings",
conditions=({"climate_feature_swing_modes": True},),
),
CONF_SWING_MODE_STATE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="climate_swing_mode_settings",
conditions=({"climate_feature_swing_modes": True},),
),
CONF_SWING_MODE_LIST: PlatformField(
selector=PRESET_MODES_SELECTOR,
required=True,
validator=validate(no_empty_list),
error="empty_list_not_allowed",
section="climate_swing_mode_settings",
conditions=({"climate_feature_swing_modes": True},),
),
# swing horizontal mode settings
CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
section="climate_swing_horizontal_mode_settings",
conditions=({"climate_feature_swing_horizontal_modes": True},),
),
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="climate_swing_horizontal_mode_settings",
conditions=({"climate_feature_swing_horizontal_modes": True},),
),
CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
section="climate_swing_horizontal_mode_settings",
conditions=({"climate_feature_swing_horizontal_modes": True},),
),
CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
section="climate_swing_horizontal_mode_settings",
conditions=({"climate_feature_swing_horizontal_modes": True},),
),
CONF_SWING_HORIZONTAL_MODE_LIST: PlatformField(
selector=PRESET_MODES_SELECTOR,
required=True,
validator=validate(no_empty_list),
error="empty_list_not_allowed",
section="climate_swing_horizontal_mode_settings",
conditions=({"climate_feature_swing_horizontal_modes": True},),
),
},
Platform.COVER.value: {
CONF_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
@ -1904,6 +2671,7 @@ ENTITY_CONFIG_VALIDATOR: dict[
] = {
Platform.BINARY_SENSOR.value: None,
Platform.BUTTON.value: None,
Platform.CLIMATE.value: validate_climate_platform_config,
Platform.COVER.value: validate_cover_platform_config,
Platform.FAN.value: validate_fan_platform_config,
Platform.LIGHT.value: validate_light_platform_config,
@ -2097,15 +2865,15 @@ def data_schema_from_fields(
no_reconfig_options: set[Any] = set()
for schema_section in sections:
data_schema_element = {
vol.Required(field_name, default=field_details.default)
vol.Required(field_name, default=get_default(field_details))
if field_details.required
else vol.Optional(
field_name,
default=get_default(field_details)
if field_details.default is not None
else vol.UNDEFINED,
): field_details.selector(component_data_with_user_input) # type: ignore[operator]
if field_details.custom_filtering
): field_details.selector(component_data_with_user_input or {})
if callable(field_details.selector) and field_details.custom_filtering
else field_details.selector
for field_name, field_details in data_schema_fields.items()
if not field_details.is_schema_default
@ -2127,12 +2895,20 @@ def data_schema_from_fields(
if not data_schema_element:
# Do not show empty sections
continue
# Collapse if values are changed or required fields need to be set
collapsed = (
not any(
(default := data_schema_fields[str(option)].default) is vol.UNDEFINED
or component_data_with_user_input[str(option)] != default
or (
str(option) in component_data_with_user_input
and component_data_with_user_input[str(option)] != default
)
for option in data_element_options
if option in component_data_with_user_input
or (
str(option) in data_schema_fields
and data_schema_fields[str(option)].required
)
)
if component_data_with_user_input is not None
else True

View File

@ -26,7 +26,6 @@ CONF_PAYLOAD_AVAILABLE = "payload_available"
CONF_PAYLOAD_NOT_AVAILABLE = "payload_not_available"
CONF_AVAILABILITY = "availability"
CONF_AVAILABILITY_MODE = "availability_mode"
CONF_AVAILABILITY_TEMPLATE = "availability_template"
CONF_AVAILABILITY_TOPIC = "availability_topic"
@ -53,7 +52,6 @@ CONF_WS_HEADERS = "ws_headers"
CONF_WILL_MESSAGE = "will_message"
CONF_PAYLOAD_RESET = "payload_reset"
CONF_SUPPORTED_FEATURES = "supported_features"
CONF_ACTION_TEMPLATE = "action_template"
CONF_ACTION_TOPIC = "action_topic"
CONF_BLUE_TEMPLATE = "blue_template"
@ -91,6 +89,11 @@ CONF_EFFECT_TEMPLATE = "effect_template"
CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template"
CONF_ENTITY_PICTURE = "entity_picture"
CONF_EXPIRE_AFTER = "expire_after"
CONF_FAN_MODE_COMMAND_TEMPLATE = "fan_mode_command_template"
CONF_FAN_MODE_COMMAND_TOPIC = "fan_mode_command_topic"
CONF_FAN_MODE_LIST = "fan_modes"
CONF_FAN_MODE_STATE_TEMPLATE = "fan_mode_state_template"
CONF_FAN_MODE_STATE_TOPIC = "fan_mode_state_topic"
CONF_FLASH = "flash"
CONF_FLASH_TIME_LONG = "flash_time_long"
CONF_FLASH_TIME_SHORT = "flash_time_short"
@ -101,6 +104,12 @@ CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
CONF_HS_COMMAND_TOPIC = "hs_command_topic"
CONF_HS_STATE_TOPIC = "hs_state_topic"
CONF_HS_VALUE_TEMPLATE = "hs_value_template"
CONF_HUMIDITY_COMMAND_TEMPLATE = "target_humidity_command_template"
CONF_HUMIDITY_COMMAND_TOPIC = "target_humidity_command_topic"
CONF_HUMIDITY_STATE_TEMPLATE = "target_humidity_state_template"
CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic"
CONF_HUMIDITY_MAX = "max_humidity"
CONF_HUMIDITY_MIN = "min_humidity"
CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template"
CONF_MAX_KELVIN = "max_kelvin"
CONF_MAX_MIREDS = "max_mireds"
@ -166,13 +175,32 @@ CONF_STATE_OPENING = "state_opening"
CONF_STATE_STOPPED = "state_stopped"
CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision"
CONF_SUPPORTED_COLOR_MODES = "supported_color_modes"
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template"
CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC = "swing_horizontal_mode_command_topic"
CONF_SWING_HORIZONTAL_MODE_LIST = "swing_horizontal_modes"
CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE = "swing_horizontal_mode_state_template"
CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC = "swing_horizontal_mode_state_topic"
CONF_SWING_MODE_COMMAND_TEMPLATE = "swing_mode_command_template"
CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic"
CONF_SWING_MODE_LIST = "swing_modes"
CONF_SWING_MODE_STATE_TEMPLATE = "swing_mode_state_template"
CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic"
CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template"
CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic"
CONF_TEMP_STATE_TEMPLATE = "temperature_state_template"
CONF_TEMP_STATE_TOPIC = "temperature_state_topic"
CONF_TEMP_HIGH_COMMAND_TEMPLATE = "temperature_high_command_template"
CONF_TEMP_HIGH_COMMAND_TOPIC = "temperature_high_command_topic"
CONF_TEMP_HIGH_STATE_TEMPLATE = "temperature_high_state_template"
CONF_TEMP_HIGH_STATE_TOPIC = "temperature_high_state_topic"
CONF_TEMP_INITIAL = "initial"
CONF_TEMP_LOW_COMMAND_TEMPLATE = "temperature_low_command_template"
CONF_TEMP_LOW_COMMAND_TOPIC = "temperature_low_command_topic"
CONF_TEMP_LOW_STATE_TEMPLATE = "temperature_low_state_template"
CONF_TEMP_LOW_STATE_TOPIC = "temperature_low_state_topic"
CONF_TEMP_MAX = "max_temp"
CONF_TEMP_MIN = "min_temp"
CONF_TEMP_STATE_TEMPLATE = "temperature_state_template"
CONF_TEMP_STATE_TOPIC = "temperature_state_topic"
CONF_TEMP_STEP = "temp_step"
CONF_TILT_COMMAND_TEMPLATE = "tilt_command_template"
CONF_TILT_COMMAND_TOPIC = "tilt_command_topic"
CONF_TILT_STATUS_TOPIC = "tilt_status_topic"
@ -213,6 +241,7 @@ CONF_SUPPORT_URL = "support_url"
DEFAULT_BRIGHTNESS = False
DEFAULT_BRIGHTNESS_SCALE = 255
DEFAULT_CLIMATE_INITIAL_TEMPERATURE = 21.0
DEFAULT_PREFIX = "homeassistant"
DEFAULT_BIRTH_WILL_TOPIC = DEFAULT_PREFIX + "/status"
DEFAULT_DISCOVERY = True

View File

@ -364,6 +364,15 @@ class EntityTopicState:
entity_id, entity = self.subscribe_calls.popitem()
try:
entity.async_write_ha_state()
except ValueError as exc:
_LOGGER.error(
"Value error while updating state of %s, topic: "
"'%s' with payload: %s: %s",
entity_id,
msg.topic,
msg.payload,
exc,
)
except Exception:
_LOGGER.exception(
"Exception raised while updating state of %s, topic: "

View File

@ -239,6 +239,16 @@
"title": "Configure MQTT device \"{mqtt_device}\"",
"description": "Please configure specific details for {platform} entity \"{entity}\":",
"data": {
"climate_feature_action": "Current action support",
"climate_feature_current_humidity": "Current humidity support",
"climate_feature_current_temperature": "Current temperature support",
"climate_feature_fan_modes": "Fan mode support",
"climate_feature_power": "Power on/off support",
"climate_feature_preset_modes": "[%key:component::mqtt::config_subentries::device::step::entity_platform_config::data::fan_feature_preset_modes%]",
"climate_feature_swing_horizontal_modes": "Horizontal swing mode support",
"climate_feature_swing_modes": "Swing mode support",
"climate_feature_target_temperature": "Target temperature support",
"climate_feature_target_humidity": "Target humidity support",
"device_class": "Device class",
"entity_category": "Entity category",
"fan_feature_speed": "Speed support",
@ -249,9 +259,20 @@
"schema": "Schema",
"state_class": "State class",
"suggested_display_precision": "Suggested display precision",
"temperature_unit": "Temperature unit",
"unit_of_measurement": "Unit of measurement"
},
"data_description": {
"climate_feature_action": "The climate supports reporting the current action.",
"climate_feature_current_humidity": "The climate supports reporting the current humidity.",
"climate_feature_current_temperature": "The climate supports reporting the current temperature.",
"climate_feature_fan_modes": "The climate supports fan modes.",
"climate_feature_power": "The climate supports the power \"on\" and \"off\" commands.",
"climate_feature_preset_modes": "The climate supports preset modes.",
"climate_feature_swing_horizontal_modes": "The climate supports horizontal swing modes.",
"climate_feature_swing_modes": "The climate supports swing modes.",
"climate_feature_target_temperature": "The climate supports setting the target temperature.",
"climate_feature_target_humidity": "The climate supports setting the target humidity.",
"device_class": "The device class of the {platform} entity. [Learn more.]({url}#device_class)",
"entity_category": "Allows marking an entity as device configuration or diagnostics. An entity with a category will not be exposed to cloud, Alexa, or Google Assistant components, nor included in indirect action calls to devices or areas. Sensor entities cannot be assigned a device configuration class. [Learn more.](https://developers.home-assistant.io/docs/core/entity/#registry-properties)",
"fan_feature_speed": "The fan supports multiple speeds.",
@ -262,6 +283,7 @@
"schema": "The schema to use. [Learn more.]({url}#comparison-of-light-mqtt-schemas)",
"state_class": "The [State class](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes) of the sensor. [Learn more.]({url}#state_class)",
"suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)",
"temperature_unit": "This determines the native unit of measurement the MQTT climate device works with.",
"unit_of_measurement": "Defines the unit of measurement of the sensor, if any."
},
"sections": {
@ -290,6 +312,11 @@
"force_update": "Force update",
"green_template": "Green template",
"last_reset_value_template": "Last reset value template",
"modes": "Supported operation modes",
"mode_command_topic": "Operation mode command topic",
"mode_command_template": "Operation mode command template",
"mode_state_topic": "Operation mode state topic",
"mode_state_template": "Operation mode value template",
"on_command_type": "ON command type",
"optimistic": "Optimistic",
"payload_off": "Payload \"off\"",
@ -317,6 +344,11 @@
"force_update": "Sends update events even if the value hasnt changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)",
"green_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract green color from the state payload value. Expected result of the template is an integer from 0-255 range.",
"last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)",
"modes": "A list of supported operation modes. [Learn more.]({url}#modes)",
"mode_command_topic": "The MQTT topic to publish commands to change the climate operation mode. [Learn more.]({url}#mode_command_topic)",
"mode_command_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to define the operation mode to be sent to the operation mode command topic. [Learn more.]({url}#mode_command_template)",
"mode_state_topic": "The MQTT topic subscribed to receive operation mode state messages. [Learn more.]({url}#mode_state_topic)",
"mode_state_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the operation mode state. [Learn more.]({url}#mode_state_template)",
"on_command_type": "Defines when the payload \"on\" is sent. Using \"Last\" (the default) will send any style (brightness, color, etc) topics first and then a payload \"on\" to the command topic. Using \"First\" will send the payload \"on\" and then any style topics. Using \"Brightness\" will only send brightness commands instead of the payload \"on\" to turn the light on.",
"optimistic": "Flag that defines if the {platform} entity works in optimistic mode. [Learn more.]({url}#optimistic)",
"payload_off": "The payload that represents the \"off\" state.",
@ -356,6 +388,100 @@
"transition": "Enable the transition feature for this light"
}
},
"climate_action_settings": {
"name": "Current action settings",
"data": {
"action_template": "Action template",
"action_topic": "Action topic"
},
"data_description": {
"action_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the action topic with.",
"action_topic": "The MQTT topic to subscribe for changes of the current action. If this is set, the climate graph uses the value received as data source. A \"None\" payload resets the current action state. An empty payload is ignored. Valid action values are: \"off\", \"heating\", \"cooling\", \"drying\", \"idle\" and \"fan\". [Learn more.]({url}#action_topic)"
}
},
"climate_fan_mode_settings": {
"name": "Fan mode settings",
"data": {
"fan_modes": "Fan modes",
"fan_mode_command_topic": "Fan mode command topic",
"fan_mode_command_template": "Fan mode command template",
"fan_mode_state_topic": "Fan mode state topic",
"fan_mode_state_template": "Fan mode state template"
},
"data_description": {
"fan_modes": "List of fan modes this climate is capable of running at. Common fan modes that offer translations are `off`, `on`, `auto`, `low`, `medium`, `high`, `middle`, `focus` and `diffuse`.",
"fan_mode_command_topic": "The MQTT topic to publish commands to change the climate fan mode. [Learn more.]({url}#fan_mode_command_topic)",
"fan_mode_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the fan mode command topic.",
"fan_mode_state_topic": "The MQTT topic subscribed to receive the climate fan mode. [Learn more.]({url}#fan_mode_state_topic)",
"fan_mode_state_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the climate fan mode value."
}
},
"climate_power_settings": {
"name": "Power settings",
"data": {
"payload_off": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data::payload_off%]",
"payload_on": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data::payload_on%]",
"power_command_template": "Power command template",
"power_command_topic": "Power command topic"
},
"data_description": {
"payload_off": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data_description::payload_off%]",
"payload_on": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data_description::payload_off%]",
"power_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the power command topic. The `value` parameter is the payload set for payload \"on\" or payload \"off\".",
"power_command_topic": "The MQTT topic to publish commands to change the climate power state. Sends the payload configured with payload \"on\" or payload \"off\". [Learn more.]({url}#power_command_topic)"
}
},
"climate_preset_mode_settings": {
"name": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::name%]",
"data": {
"preset_mode_command_template": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data::preset_mode_command_template%]",
"preset_mode_command_topic": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data::preset_mode_command_topic%]",
"preset_mode_value_template": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data::preset_mode_value_template%]",
"preset_mode_state_topic": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data::preset_mode_state_topic%]",
"preset_modes": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data::preset_modes%]"
},
"data_description": {
"preset_mode_command_template": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data_description::preset_mode_command_template%]",
"preset_mode_command_topic": "The MQTT topic to publish commands to change the climate preset mode. [Learn more.]({url}#preset_mode_command_topic)",
"preset_mode_value_template": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data_description::preset_mode_value_template%]",
"preset_mode_state_topic": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data_description::preset_mode_state_topic%]",
"preset_modes": "List of preset modes this climate is capable of running at. Common preset modes that offer translations are `none`, `away`, `eco`, `boost`, `comfort`, `home`, `sleep` and `activity`."
}
},
"climate_swing_horizontal_mode_settings": {
"name": "Horizontal swing mode settings",
"data": {
"swing_horizontal_modes": "Horizontal swing modes",
"swing_horizontal_mode_command_topic": "Horizontal swing mode command topic",
"swing_horizontal_mode_command_template": "Horizontal swing mode command template",
"swing_horizontal_mode_state_topic": "Horizontal swing mode state topic",
"swing_horizontal_mode_state_template": "Horizontal swing mode state template"
},
"data_description": {
"swing_horizontal_modes": "List of horizontal swing modes this climate is capable of running at. Common horizontal swing modes that offer translations are `off` and `on`.",
"swing_horizontal_mode_command_topic": "The MQTT topic to publish commands to change the climate horizontal swing mode. [Learn more.]({url}#swing_horizontal_mode_command_topic)",
"swing_horizontal_mode_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the horizontal swing mode command topic.",
"swing_horizontal_mode_state_topic": "The MQTT topic subscribed to receive the climate horizontal swing mode. [Learn more.]({url}#swing_horizontal_mode_state_topic)",
"swing_horizontal_mode_state_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the climate horizontal swing mode value."
}
},
"climate_swing_mode_settings": {
"name": "Swing mode settings",
"data": {
"swing_modes": "Swing modes",
"swing_mode_command_topic": "Swing mode command topic",
"swing_mode_command_template": "Swing mode command template",
"swing_mode_state_topic": "Swing mode state topic",
"swing_mode_state_template": "Swing mode state template"
},
"data_description": {
"swing_modes": "List of swing modes this climate is capable of running at. Common swing modes that offer translations are `off`, `on`, `vertical`, `horizontal` and `both`.",
"swing_mode_command_topic": "The MQTT topic to publish commands to change the climate swing mode. [Learn more.]({url}#swing_mode_command_topic)",
"swing_mode_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the swing mode command topic.",
"swing_mode_state_topic": "The MQTT topic subscribed to receive the climate swing mode. [Learn more.]({url}#swing_mode_state_topic)",
"swing_mode_state_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the climate swing mode value."
}
},
"cover_payload_settings": {
"name": "Payload settings",
"data": {
@ -425,6 +551,28 @@
"tilt_optimistic": "Flag that defines if tilt works in optimistic mode. If tilt status topic is not defined, tilt works in optimistic mode by default. [Learn more.]({url}#tilt_optimistic)"
}
},
"current_humidity_settings": {
"name": "Current humidity settings",
"data": {
"current_humidity_template": "Current humidity template",
"current_humidity_topic": "Current humidity topic"
},
"data_description": {
"current_humidity_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the current humidity value. [Learn more.]({url}#current_humidity_template)",
"current_humidity_topic": "The MQTT topic subscribed to receive current humidity update values. [Learn more.]({url}#current_humidity_topic)"
}
},
"current_temperature_settings": {
"name": "Current temperature settings",
"data": {
"current_temperature_template": "Current temperature template",
"current_temperature_topic": "Current temperature topic"
},
"data_description": {
"current_temperature_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the current temperature value. [Learn more.]({url}#current_temperature_template)",
"current_temperature_topic": "The MQTT topic subscribed to receive current temperature update values. [Learn more.]({url}#current_temperature_topic)"
}
},
"light_brightness_settings": {
"name": "Brightness settings",
"data": {
@ -648,6 +796,66 @@
"xy_state_topic": "The MQTT topic subscribed to receive XY state updates. The expected payload is the X and Y color values separated by commas, for example, `0.675,0.322`. [Learn more.]({url}#xy_state_topic)",
"xy_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the XY value."
}
},
"target_humidity_settings": {
"name": "Target humidity settings",
"data": {
"max_humidity": "Maximum humidity",
"min_humidity": "Minimum humidity",
"target_humidity_command_template": "Humidity command template",
"target_humidity_command_topic": "Humidity command topic",
"target_humidity_state_template": "Humidity state template",
"target_humidity_state_topic": "Humidity state topic"
},
"data_description": {
"max_humidity": "The maximum target humidity that can be set.",
"min_humidity": "The minimum target humidity that can be set.",
"target_humidity_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the humidity command topic.",
"target_humidity_command_topic": "The MQTT topic to publish commands to change the climate target humidity. [Learn more.]({url}#humidity_command_topic)",
"target_humidity_state_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the humidity state topic with.",
"target_humidity_state_topic": "The MQTT topic to subscribe for changes of the target humidity. [Learn more.]({url}#humidity_state_topic)"
}
},
"target_temperature_settings": {
"name": "Target temperature settings",
"data": {
"initial": "Initial temperature",
"max_temp": "Maximum temperature",
"min_temp": "Minimum temperature",
"precision": "Precision",
"temp_step": "Temperature step",
"temperature_command_template": "Temperature command template",
"temperature_command_topic": "Temperature command topic",
"temperature_high_command_template": "Upper temperature command template",
"temperature_high_command_topic": "Upper temperature command topic",
"temperature_low_command_template": "Lower temperature command template",
"temperature_low_command_topic": "Lower temperature command topic",
"temperature_state_template": "Temperature state template",
"temperature_state_topic": "Temperature state topic",
"temperature_high_state_template": "Upper temperature state template",
"temperature_high_state_topic": "Upper temperature state topic",
"temperature_low_state_template": "Lower temperature state template",
"temperature_low_state_topic": "Lower temperature state topic"
},
"data_description": {
"initial": "The climate initalizes with this target temperature.",
"max_temp": "The maximum target temperature that can be set.",
"min_temp": "The minimum target temperature that can be set.",
"precision": "The precision in degrees the thermostat is working at.",
"temp_step": "The target temperature step in degrees Celsius or Fahrenheit.",
"temperature_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the temperature command topic.",
"temperature_command_topic": "The MQTT topic to publish commands to change the climate target temperature. [Learn more.]({url}#temperature_command_topic)",
"temperature_high_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the upper temperature command topic.",
"temperature_high_command_topic": "The MQTT topic to publish commands to change the climate upper target temperature. [Learn more.]({url}#temperature_high_command_topic)",
"temperature_low_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the lower temperature command topic.",
"temperature_low_command_topic": "The MQTT topic to publish commands to change the climate lower target temperature. [Learn more.]({url}#temperature_low_command_topic)",
"temperature_state_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the temperature state topic with.",
"temperature_state_topic": "The MQTT topic to subscribe for changes of the target temperature. [Learn more.]({url}#temperature_state_topic)",
"temperature_high_state_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the upper temperature state topic with.",
"temperature_high_state_topic": "The MQTT topic to subscribe for changes of the upper target temperature. [Learn more.]({url}#temperature_high_state_topic)",
"temperature_low_state_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the lower temperature state topic with.",
"temperature_low_state_topic": "The MQTT topic to subscribe for changes of the lower target temperature. [Learn more.]({url}#temperature_low_state_topic)"
}
}
}
},
@ -695,6 +903,7 @@
"cover_tilt_command_template_must_be_used_with_tilt_command_topic": "The tilt command template must be used with the tilt command topic",
"cover_tilt_status_template_must_be_used_with_tilt_status_topic": "The tilt value template must be used with the tilt status topic",
"cover_value_template_must_be_used_with_state_topic": "The value template must be used with the state topic option",
"empty_list_not_allowed": "Empty list is not allowed. Add at least one item",
"fan_speed_range_max_must_be_greater_than_speed_range_min": "Speed range max must be greater than speed range min",
"fan_preset_mode_reset_in_preset_modes_list": "Payload \"reset preset mode\" is not a valid as a preset mode",
"invalid_input": "Invalid value",
@ -705,10 +914,13 @@
"invalid_uom_for_state_class": "The unit of measurement \"{unit_of_measurement}\" is not supported by the selected state class, please either remove the state class, select a state class which supports \"{unit_of_measurement}\", or pick a supported unit of measurement from the list",
"invalid_url": "Invalid URL",
"last_reset_not_with_state_class_total": "The last reset value template option should be used with state class 'Total' only",
"max_below_min_humidity": "Max humidity value should be greater than min humidity value",
"max_below_min_kelvin": "Max Kelvin value should be greater than min Kelvin value",
"max_below_min_temperature": "Max temperature value should be greater than min temperature value",
"options_not_allowed_with_state_class_or_uom": "The 'Options' setting is not allowed when state class or unit of measurement are used",
"options_device_class_enum": "The 'Options' setting must be used with the Enumeration device class. If you continue, the existing options will be reset",
"options_with_enum_device_class": "Configure options for the enumeration sensor",
"preset_mode_none_not_allowed": "Preset \"none\" is not a valid preset mode",
"uom_required_for_device_class": "The selected device class requires a unit"
}
}
@ -826,6 +1038,17 @@
}
},
"selector": {
"climate_modes": {
"options": {
"off": "[%key:common::state::off%]",
"auto": "[%key:common::state::auto%]",
"heat": "[%key:component::climate::entity_component::_::state::heat%]",
"cool": "[%key:component::climate::entity_component::_::state::cool%]",
"heat_cool": "[%key:component::climate::entity_component::_::state::heat_cool%]",
"dry": "[%key:component::climate::entity_component::_::state::dry%]",
"fan_only": "[%key:component::climate::entity_component::_::state::fan_only%]"
}
},
"device_class_binary_sensor": {
"options": {
"battery": "[%key:component::binary_sensor::entity_component::battery::name%]",
@ -969,6 +1192,7 @@
"options": {
"binary_sensor": "[%key:component::binary_sensor::title%]",
"button": "[%key:component::button::title%]",
"climate": "[%key:component::climate::title%]",
"cover": "[%key:component::cover::title%]",
"fan": "[%key:component::fan::title%]",
"light": "[%key:component::light::title%]",
@ -1004,6 +1228,13 @@
"rgbww": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgbww%]",
"white": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::white%]"
}
},
"target_temperature_feature": {
"options": {
"single": "Single target temperature",
"high_low": "Upper/lower target temperature",
"none": "No target temperature"
}
}
},
"services": {

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/mysensors",
"iot_class": "local_push",
"loggers": ["mysensors"],
"requirements": ["pymysensors==0.25.0"]
"requirements": ["pymysensors==0.26.0"]
}

View File

@ -1,7 +1,7 @@
"""The ONVIF integration."""
import asyncio
from contextlib import suppress
from contextlib import AsyncExitStack, suppress
from http import HTTPStatus
import logging
@ -45,50 +45,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
device = ONVIFDevice(hass, entry)
try:
await device.async_setup()
if not entry.data.get(CONF_SNAPSHOT_AUTH):
await async_populate_snapshot_auth(hass, device, entry)
except (TimeoutError, aiohttp.ClientError) as err:
await device.device.close()
raise ConfigEntryNotReady(
f"Could not connect to camera {device.device.host}:{device.device.port}: {err}"
) from err
except Fault as err:
await device.device.close()
if is_auth_error(err):
raise ConfigEntryAuthFailed(
f"Auth Failed: {stringify_onvif_error(err)}"
) from err
raise ConfigEntryNotReady(
f"Could not connect to camera: {stringify_onvif_error(err)}"
) from err
except ONVIFError as err:
await device.device.close()
raise ConfigEntryNotReady(
f"Could not setup camera {device.device.host}:{device.device.port}: {stringify_onvif_error(err)}"
) from err
except TransportError as err:
await device.device.close()
stringified_onvif_error = stringify_onvif_error(err)
if err.status_code in (
HTTPStatus.UNAUTHORIZED.value,
HTTPStatus.FORBIDDEN.value,
):
raise ConfigEntryAuthFailed(
f"Auth Failed: {stringified_onvif_error}"
) from err
raise ConfigEntryNotReady(
f"Could not setup camera {device.device.host}:{device.device.port}: {stringified_onvif_error}"
) from err
except asyncio.CancelledError as err:
# After https://github.com/agronholm/anyio/issues/374 is resolved
# this may be able to be removed
await device.device.close()
raise ConfigEntryNotReady(f"Setup was unexpectedly canceled: {err}") from err
async with AsyncExitStack() as stack:
# Register cleanup callback for device
@stack.push_async_callback
async def _cleanup():
await _async_stop_device(hass, device)
if not device.available:
raise ConfigEntryNotReady
try:
await device.async_setup()
if not entry.data.get(CONF_SNAPSHOT_AUTH):
await async_populate_snapshot_auth(hass, device, entry)
except (TimeoutError, aiohttp.ClientError) as err:
raise ConfigEntryNotReady(
f"Could not connect to camera {device.device.host}:{device.device.port}: {err}"
) from err
except Fault as err:
if is_auth_error(err):
raise ConfigEntryAuthFailed(
f"Auth Failed: {stringify_onvif_error(err)}"
) from err
raise ConfigEntryNotReady(
f"Could not connect to camera: {stringify_onvif_error(err)}"
) from err
except ONVIFError as err:
raise ConfigEntryNotReady(
f"Could not setup camera {device.device.host}:{device.device.port}: {stringify_onvif_error(err)}"
) from err
except TransportError as err:
stringified_onvif_error = stringify_onvif_error(err)
if err.status_code in (
HTTPStatus.UNAUTHORIZED.value,
HTTPStatus.FORBIDDEN.value,
):
raise ConfigEntryAuthFailed(
f"Auth Failed: {stringified_onvif_error}"
) from err
raise ConfigEntryNotReady(
f"Could not setup camera {device.device.host}:{device.device.port}: {stringified_onvif_error}"
) from err
except asyncio.CancelledError as err:
# After https://github.com/agronholm/anyio/issues/374 is resolved
# this may be able to be removed
raise ConfigEntryNotReady(
f"Setup was unexpectedly canceled: {err}"
) from err
if not device.available:
raise ConfigEntryNotReady
# If we get here, setup was successful - prevent cleanup
stack.pop_all()
hass.data[DOMAIN][entry.unique_id] = device
@ -111,17 +117,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
device: ONVIFDevice = hass.data[DOMAIN][entry.unique_id]
async def _async_stop_device(hass: HomeAssistant, device: ONVIFDevice) -> None:
"""Stop the ONVIF device."""
if device.capabilities.events and device.events.started:
try:
await device.events.async_stop()
except (TimeoutError, ONVIFError, Fault, aiohttp.ClientError, TransportError):
LOGGER.warning("Error while stopping events: %s", device.name)
await device.device.close()
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
device: ONVIFDevice = hass.data[DOMAIN][entry.unique_id]
await _async_stop_device(hass, device)
return await hass.config_entries.async_unload_platforms(entry, device.platforms)

View File

@ -12,7 +12,7 @@ from homeassistant.helpers.httpx_client import get_async_client
from .const import LOGGER
PLATFORMS = [Platform.CONVERSATION]
PLATFORMS = [Platform.AI_TASK, Platform.CONVERSATION]
type OpenRouterConfigEntry = ConfigEntry[AsyncOpenAI]

View File

@ -0,0 +1,75 @@
"""AI Task integration for OpenRouter."""
from __future__ import annotations
from json import JSONDecodeError
import logging
from homeassistant.components import ai_task, conversation
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
from . import OpenRouterConfigEntry
from .entity import OpenRouterEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OpenRouterConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AI Task entities."""
for subentry in config_entry.subentries.values():
if subentry.subentry_type != "ai_task_data":
continue
async_add_entities(
[OpenRouterAITaskEntity(config_entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class OpenRouterAITaskEntity(
ai_task.AITaskEntity,
OpenRouterEntity,
):
"""OpenRouter AI Task entity."""
_attr_name = None
_attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA
async def _async_generate_data(
self,
task: ai_task.GenDataTask,
chat_log: conversation.ChatLog,
) -> ai_task.GenDataTaskResult:
"""Handle a generate data task."""
await self._async_handle_chat_log(chat_log, task.name, task.structure)
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
raise HomeAssistantError(
"Last content in chat log is not an AssistantContent"
)
text = chat_log.content[-1].content or ""
if not task.structure:
return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id,
data=text,
)
try:
data = json_loads(text)
except JSONDecodeError as err:
raise HomeAssistantError(
"Error with OpenRouter structured response"
) from err
return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id,
data=data,
)

View File

@ -5,7 +5,12 @@ from __future__ import annotations
import logging
from typing import Any
from python_open_router import Model, OpenRouterClient, OpenRouterError
from python_open_router import (
Model,
OpenRouterClient,
OpenRouterError,
SupportedParameter,
)
import voluptuous as vol
from homeassistant.config_entries import (
@ -43,7 +48,10 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN):
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {"conversation": ConversationFlowHandler}
return {
"conversation": ConversationFlowHandler,
"ai_task_data": AITaskDataFlowHandler,
}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@ -78,13 +86,26 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN):
)
class ConversationFlowHandler(ConfigSubentryFlow):
"""Handle subentry flow."""
class OpenRouterSubentryFlowHandler(ConfigSubentryFlow):
"""Handle subentry flow for OpenRouter."""
def __init__(self) -> None:
"""Initialize the subentry flow."""
self.models: dict[str, Model] = {}
async def _get_models(self) -> None:
"""Fetch models from OpenRouter."""
entry = self._get_entry()
client = OpenRouterClient(
entry.data[CONF_API_KEY], async_get_clientsession(self.hass)
)
models = await client.get_models()
self.models = {model.id: model for model in models}
class ConversationFlowHandler(OpenRouterSubentryFlowHandler):
"""Handle subentry flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
@ -95,14 +116,16 @@ class ConversationFlowHandler(ConfigSubentryFlow):
return self.async_create_entry(
title=self.models[user_input[CONF_MODEL]].name, data=user_input
)
entry = self._get_entry()
client = OpenRouterClient(
entry.data[CONF_API_KEY], async_get_clientsession(self.hass)
)
models = await client.get_models()
self.models = {model.id: model for model in models}
try:
await self._get_models()
except OpenRouterError:
return self.async_abort(reason="cannot_connect")
except Exception:
_LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
options = [
SelectOptionDict(value=model.id, label=model.name) for model in models
SelectOptionDict(value=model.id, label=model.name)
for model in self.models.values()
]
hass_apis: list[SelectOptionDict] = [
@ -138,3 +161,40 @@ class ConversationFlowHandler(ConfigSubentryFlow):
}
),
)
class AITaskDataFlowHandler(OpenRouterSubentryFlowHandler):
"""Handle subentry flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""User flow to create a sensor subentry."""
if user_input is not None:
return self.async_create_entry(
title=self.models[user_input[CONF_MODEL]].name, data=user_input
)
try:
await self._get_models()
except OpenRouterError:
return self.async_abort(reason="cannot_connect")
except Exception:
_LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
options = [
SelectOptionDict(value=model.id, label=model.name)
for model in self.models.values()
if SupportedParameter.STRUCTURED_OUTPUTS in model.supported_parameters
]
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_MODEL): SelectSelector(
SelectSelectorConfig(
options=options, mode=SelectSelectorMode.DROPDOWN, sort=True
),
),
}
),
)

View File

@ -20,6 +20,8 @@ async def async_setup_entry(
) -> None:
"""Set up conversation entities."""
for subentry_id, subentry in config_entry.subentries.items():
if subentry.subentry_type != "conversation":
continue
async_add_entities(
[OpenRouterConversationEntity(config_entry, subentry)],
config_subentry_id=subentry_id,

View File

@ -4,10 +4,9 @@ from __future__ import annotations
from collections.abc import AsyncGenerator, Callable
import json
from typing import Any, Literal
from typing import TYPE_CHECKING, Any, Literal
import openai
from openai import NOT_GIVEN
from openai.types.chat import (
ChatCompletionAssistantMessageParam,
ChatCompletionMessage,
@ -19,7 +18,9 @@ from openai.types.chat import (
ChatCompletionUserMessageParam,
)
from openai.types.chat.chat_completion_message_tool_call_param import Function
from openai.types.shared_params import FunctionDefinition
from openai.types.shared_params import FunctionDefinition, ResponseFormatJSONSchema
from openai.types.shared_params.response_format_json_schema import JSONSchema
import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.components import conversation
@ -36,6 +37,50 @@ from .const import DOMAIN, LOGGER
MAX_TOOL_ITERATIONS = 10
def _adjust_schema(schema: dict[str, Any]) -> None:
"""Adjust the schema to be compatible with OpenRouter API."""
if schema["type"] == "object":
if "properties" not in schema:
return
if "required" not in schema:
schema["required"] = []
# Ensure all properties are required
for prop, prop_info in schema["properties"].items():
_adjust_schema(prop_info)
if prop not in schema["required"]:
prop_info["type"] = [prop_info["type"], "null"]
schema["required"].append(prop)
elif schema["type"] == "array":
if "items" not in schema:
return
_adjust_schema(schema["items"])
def _format_structured_output(
name: str, schema: vol.Schema, llm_api: llm.APIInstance | None
) -> JSONSchema:
"""Format the schema to be compatible with OpenRouter API."""
result: JSONSchema = {
"name": name,
"strict": True,
}
result_schema = convert(
schema,
custom_serializer=(
llm_api.custom_serializer if llm_api else llm.selector_serializer
),
)
_adjust_schema(result_schema)
result["schema"] = result_schema
return result
def _format_tool(
tool: llm.Tool,
custom_serializer: Callable[[Any], Any] | None,
@ -136,9 +181,24 @@ class OpenRouterEntity(Entity):
entry_type=dr.DeviceEntryType.SERVICE,
)
async def _async_handle_chat_log(self, chat_log: conversation.ChatLog) -> None:
async def _async_handle_chat_log(
self,
chat_log: conversation.ChatLog,
structure_name: str | None = None,
structure: vol.Schema | None = None,
) -> None:
"""Generate an answer for the chat log."""
model_args = {
"model": self.model,
"user": chat_log.conversation_id,
"extra_headers": {
"X-Title": "Home Assistant",
"HTTP-Referer": "https://www.home-assistant.io/integrations/open_router",
},
"extra_body": {"require_parameters": True},
}
tools: list[ChatCompletionToolParam] | None = None
if chat_log.llm_api:
tools = [
@ -146,33 +206,37 @@ class OpenRouterEntity(Entity):
for tool in chat_log.llm_api.tools
]
messages = [
if tools:
model_args["tools"] = tools
model_args["messages"] = [
m
for content in chat_log.content
if (m := _convert_content_to_chat_message(content))
]
if structure:
if TYPE_CHECKING:
assert structure_name is not None
model_args["response_format"] = ResponseFormatJSONSchema(
type="json_schema",
json_schema=_format_structured_output(
structure_name, structure, chat_log.llm_api
),
)
client = self.entry.runtime_data
for _iteration in range(MAX_TOOL_ITERATIONS):
try:
result = await client.chat.completions.create(
model=self.model,
messages=messages,
tools=tools or NOT_GIVEN,
user=chat_log.conversation_id,
extra_headers={
"X-Title": "Home Assistant",
"HTTP-Referer": "https://www.home-assistant.io/integrations/open_router",
},
)
result = await client.chat.completions.create(**model_args)
except openai.OpenAIError as err:
LOGGER.error("Error talking to API: %s", err)
raise HomeAssistantError("Error talking to API") from err
result_message = result.choices[0].message
messages.extend(
model_args["messages"].extend(
[
msg
async for content in chat_log.async_add_delta_content_stream(

View File

@ -37,7 +37,28 @@
"initiate_flow": {
"user": "Add conversation agent"
},
"entry_type": "Conversation agent"
"entry_type": "Conversation agent",
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
"ai_task_data": {
"step": {
"user": {
"data": {
"model": "[%key:component::open_router::config_subentries::conversation::step::user::data::model%]"
}
}
},
"initiate_flow": {
"user": "Add Generate data with AI service"
},
"entry_type": "Generate data with AI service",
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
}
}
}

View File

@ -211,7 +211,7 @@
},
"turn_away_mode_on": {
"name": "Set away mode",
"description": "Turns away mode on for the heater",
"description": "Turns on away mode for the water heater",
"fields": {
"duration_days": {
"name": "Duration in days",

View File

@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant
from .const import CONF_NPSSO
from .coordinator import (
PlaystationNetworkConfigEntry,
PlaystationNetworkFriendDataCoordinator,
PlaystationNetworkGroupsUpdateCoordinator,
PlaystationNetworkRuntimeData,
PlaystationNetworkTrophyTitlesCoordinator,
@ -39,14 +40,33 @@ async def async_setup_entry(
groups = PlaystationNetworkGroupsUpdateCoordinator(hass, psn, entry)
await groups.async_config_entry_first_refresh()
friends = {}
for subentry_id, subentry in entry.subentries.items():
friend_coordinator = PlaystationNetworkFriendDataCoordinator(
hass, psn, entry, subentry
)
await friend_coordinator.async_config_entry_first_refresh()
friends[subentry_id] = friend_coordinator
entry.runtime_data = PlaystationNetworkRuntimeData(
coordinator, trophy_titles, groups
coordinator, trophy_titles, groups, friends
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True
async def _async_update_listener(
hass: HomeAssistant, entry: PlaystationNetworkConfigEntry
) -> None:
"""Handle update."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(
hass: HomeAssistant, entry: PlaystationNetworkConfigEntry
) -> bool:

View File

@ -10,13 +10,28 @@ from psnawp_api.core.psnawp_exceptions import (
PSNAWPInvalidTokenError,
PSNAWPNotFoundError,
)
from psnawp_api.models import User
from psnawp_api.utils.misc import parse_npsso_token
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_NAME
from homeassistant.core import callback
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
)
from .const import CONF_NPSSO, DOMAIN, NPSSO_LINK, PSN_LINK
from .const import CONF_ACCOUNT_ID, CONF_NPSSO, DOMAIN, NPSSO_LINK, PSN_LINK
from .coordinator import PlaystationNetworkConfigEntry
from .helpers import PlaystationNetwork
_LOGGER = logging.getLogger(__name__)
@ -27,6 +42,14 @@ STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_NPSSO): str})
class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Playstation Network."""
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {"friend": FriendSubentryFlowHandler}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@ -54,6 +77,15 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
else:
await self.async_set_unique_id(user.account_id)
self._abort_if_unique_id_configured()
config_entries = self.hass.config_entries.async_entries(DOMAIN)
for entry in config_entries:
if user.account_id in {
subentry.unique_id for subentry in entry.subentries.values()
}:
return self.async_abort(
reason="already_configured_as_subentry"
)
return self.async_create_entry(
title=user.online_id,
data={CONF_NPSSO: npsso},
@ -132,3 +164,61 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
"psn_link": PSN_LINK,
},
)
class FriendSubentryFlowHandler(ConfigSubentryFlow):
"""Handle subentry flow for adding a friend."""
friends_list: dict[str, User]
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Subentry user flow."""
config_entry: PlaystationNetworkConfigEntry = self._get_entry()
if user_input is not None:
config_entries = self.hass.config_entries.async_entries(DOMAIN)
if user_input[CONF_ACCOUNT_ID] in {
entry.unique_id for entry in config_entries
}:
return self.async_abort(reason="already_configured_as_entry")
for entry in config_entries:
if user_input[CONF_ACCOUNT_ID] in {
subentry.unique_id for subentry in entry.subentries.values()
}:
return self.async_abort(reason="already_configured")
return self.async_create_entry(
title=self.friends_list[user_input[CONF_ACCOUNT_ID]].online_id,
data={},
unique_id=user_input[CONF_ACCOUNT_ID],
)
self.friends_list = await self.hass.async_add_executor_job(
lambda: {
friend.account_id: friend
for friend in config_entry.runtime_data.user_data.psn.user.friends_list()
}
)
options = [
SelectOptionDict(
value=friend.account_id,
label=friend.online_id,
)
for friend in self.friends_list.values()
]
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_ACCOUNT_ID): SelectSelector(
SelectSelectorConfig(options=options)
)
}
),
user_input,
),
)

View File

@ -6,6 +6,7 @@ from psnawp_api.models.trophies import PlatformType
DOMAIN = "playstation_network"
CONF_NPSSO: Final = "npsso"
CONF_ACCOUNT_ID: Final = "account_id"
SUPPORTED_PLATFORMS = {
PlatformType.PS_VITA,

View File

@ -6,21 +6,30 @@ from abc import abstractmethod
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Any
from psnawp_api.core.psnawp_exceptions import (
PSNAWPAuthenticationError,
PSNAWPClientError,
PSNAWPError,
PSNAWPForbiddenError,
PSNAWPNotFoundError,
PSNAWPServerError,
)
from psnawp_api.models import User
from psnawp_api.models.group.group_datatypes import GroupDetails
from psnawp_api.models.trophies import TrophyTitle
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
ConfigEntryNotReady,
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
from .const import CONF_ACCOUNT_ID, DOMAIN
from .helpers import PlaystationNetwork, PlaystationNetworkData
_LOGGER = logging.getLogger(__name__)
@ -35,6 +44,7 @@ class PlaystationNetworkRuntimeData:
user_data: PlaystationNetworkUserDataCoordinator
trophy_titles: PlaystationNetworkTrophyTitlesCoordinator
groups: PlaystationNetworkGroupsUpdateCoordinator
friends: dict[str, PlaystationNetworkFriendDataCoordinator]
class PlayStationNetworkBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
@ -140,3 +150,78 @@ class PlaystationNetworkGroupsUpdateCoordinator(
if not group_info.group_id.startswith("~")
}
)
class PlaystationNetworkFriendDataCoordinator(
PlayStationNetworkBaseCoordinator[PlaystationNetworkData]
):
"""Friend status data update coordinator for PSN."""
user: User
profile: dict[str, Any]
def __init__(
self,
hass: HomeAssistant,
psn: PlaystationNetwork,
config_entry: PlaystationNetworkConfigEntry,
subentry: ConfigSubentry,
) -> None:
"""Initialize the Coordinator."""
self._update_interval = timedelta(
seconds=max(9 * len(config_entry.subentries), 180)
)
super().__init__(hass, psn, config_entry)
self.subentry = subentry
def _setup(self) -> None:
"""Set up the coordinator."""
self.user = self.psn.psn.user(account_id=self.subentry.data[CONF_ACCOUNT_ID])
self.profile = self.user.profile()
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
await self.hass.async_add_executor_job(self._setup)
except PSNAWPNotFoundError as error:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="user_not_found",
translation_placeholders={"user": self.subentry.title},
) from error
except PSNAWPAuthenticationError as error:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="not_ready",
) from error
except (PSNAWPServerError, PSNAWPClientError) as error:
_LOGGER.debug("Update failed", exc_info=True)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="update_failed",
) from error
def _update_data(self) -> PlaystationNetworkData:
"""Update friend status data."""
try:
return PlaystationNetworkData(
username=self.user.online_id,
account_id=self.user.account_id,
presence=self.user.get_presence(),
profile=self.profile,
)
except PSNAWPForbiddenError as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="user_profile_private",
translation_placeholders={"user": self.subentry.title},
) from error
except PSNAWPError:
raise
async def update_data(self) -> PlaystationNetworkData:
"""Update friend status data."""
return await self.hass.async_add_executor_job(self._update_data)

View File

@ -2,12 +2,14 @@
from typing import TYPE_CHECKING
from homeassistant.config_entries import ConfigSubentry
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import PlayStationNetworkBaseCoordinator
from .helpers import PlaystationNetworkData
class PlaystationNetworkServiceEntity(
@ -21,18 +23,32 @@ class PlaystationNetworkServiceEntity(
self,
coordinator: PlayStationNetworkBaseCoordinator,
entity_description: EntityDescription,
subentry: ConfigSubentry | None = None,
) -> None:
"""Initialize PlayStation Network Service Entity."""
super().__init__(coordinator)
if TYPE_CHECKING:
assert coordinator.config_entry.unique_id
self.entity_description = entity_description
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}_{entity_description.key}"
self.subentry = subentry
unique_id = (
subentry.unique_id
if subentry is not None and subentry.unique_id
else coordinator.config_entry.unique_id
)
self._attr_unique_id = f"{unique_id}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.config_entry.unique_id)},
name=coordinator.psn.user.online_id,
identifiers={(DOMAIN, unique_id)},
name=(
coordinator.data.username
if isinstance(coordinator.data, PlaystationNetworkData)
else coordinator.psn.user.online_id
),
entry_type=DeviceEntryType.SERVICE,
manufacturer="Sony Interactive Entertainment",
)
if subentry:
self._attr_device_info.update(
DeviceInfo(via_device=(DOMAIN, coordinator.config_entry.unique_id))
)

View File

@ -38,7 +38,6 @@ class PlaystationNetworkData:
presence: dict[str, Any] = field(default_factory=dict)
username: str = ""
account_id: str = ""
availability: str = "unavailable"
active_sessions: dict[PlatformType, SessionData] = field(default_factory=dict)
registered_platforms: set[PlatformType] = field(default_factory=set)
trophy_summary: TrophySummary | None = None
@ -61,6 +60,7 @@ class PlaystationNetwork:
self.legacy_profile: dict[str, Any] | None = None
self.trophy_titles: list[TrophyTitle] = []
self._title_icon_urls: dict[str, str] = {}
self.friends_list: dict[str, User] | None = None
def _setup(self) -> None:
"""Setup PSN."""
@ -97,6 +97,7 @@ class PlaystationNetwork:
# check legacy platforms if owned
if LEGACY_PLATFORMS & data.registered_platforms:
self.legacy_profile = self.client.get_profile_legacy()
return data
async def get_data(self) -> PlaystationNetworkData:
@ -105,7 +106,6 @@ class PlaystationNetwork:
data.username = self.user.online_id
data.account_id = self.user.account_id
data.shareable_profile_link = self.shareable_profile_link
data.availability = data.presence["basicPresence"]["availability"]
if "platform" in data.presence["basicPresence"]["primaryPlatformInfo"]:
primary_platform = PlatformType(
@ -193,3 +193,17 @@ class PlaystationNetwork:
def normalize_title(name: str) -> str:
"""Normalize trophy title."""
return name.removesuffix("Trophies").removesuffix("Trophy Set").strip()
def get_game_title_info(presence: dict[str, Any]) -> dict[str, Any]:
"""Retrieve title info from presence."""
return (
next((title for title in game_title_info), {})
if (
game_title_info := presence.get("basicPresence", {}).get(
"gameTitleInfoList"
)
)
else {}
)

View File

@ -42,6 +42,13 @@
"availabletocommunicate": "mdi:cellphone",
"offline": "mdi:account-off-outline"
}
},
"now_playing": {
"default": "mdi:controller",
"state": {
"unknown": "mdi:controller-off",
"unavailable": "mdi:controller-off"
}
}
},
"image": {

View File

@ -5,18 +5,23 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from typing import TYPE_CHECKING
from homeassistant.components.image import ImageEntity, ImageEntityDescription
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import (
PlayStationNetworkBaseCoordinator,
PlaystationNetworkConfigEntry,
PlaystationNetworkData,
PlaystationNetworkFriendDataCoordinator,
PlaystationNetworkUserDataCoordinator,
)
from .entity import PlaystationNetworkServiceEntity
from .helpers import get_game_title_info
PARALLEL_UPDATES = 0
@ -26,6 +31,7 @@ class PlaystationNetworkImage(StrEnum):
AVATAR = "avatar"
SHARE_PROFILE = "share_profile"
NOW_PLAYING_IMAGE = "now_playing_image"
@dataclass(kw_only=True, frozen=True)
@ -35,12 +41,14 @@ class PlaystationNetworkImageEntityDescription(ImageEntityDescription):
image_url_fn: Callable[[PlaystationNetworkData], str | None]
IMAGE_DESCRIPTIONS: tuple[PlaystationNetworkImageEntityDescription, ...] = (
IMAGE_DESCRIPTIONS_ME: tuple[PlaystationNetworkImageEntityDescription, ...] = (
PlaystationNetworkImageEntityDescription(
key=PlaystationNetworkImage.SHARE_PROFILE,
translation_key=PlaystationNetworkImage.SHARE_PROFILE,
image_url_fn=lambda data: data.shareable_profile_link["shareImageUrl"],
),
)
IMAGE_DESCRIPTIONS_ALL: tuple[PlaystationNetworkImageEntityDescription, ...] = (
PlaystationNetworkImageEntityDescription(
key=PlaystationNetworkImage.AVATAR,
translation_key=PlaystationNetworkImage.AVATAR,
@ -55,6 +63,14 @@ IMAGE_DESCRIPTIONS: tuple[PlaystationNetworkImageEntityDescription, ...] = (
)
),
),
PlaystationNetworkImageEntityDescription(
key=PlaystationNetworkImage.NOW_PLAYING_IMAGE,
translation_key=PlaystationNetworkImage.NOW_PLAYING_IMAGE,
image_url_fn=(
lambda data: get_game_title_info(data.presence).get("conceptIconUrl")
or get_game_title_info(data.presence).get("npTitleIconUrl")
),
),
)
@ -70,25 +86,43 @@ async def async_setup_entry(
async_add_entities(
[
PlaystationNetworkImageEntity(hass, coordinator, description)
for description in IMAGE_DESCRIPTIONS
for description in IMAGE_DESCRIPTIONS_ME + IMAGE_DESCRIPTIONS_ALL
]
)
for (
subentry_id,
friend_data_coordinator,
) in config_entry.runtime_data.friends.items():
async_add_entities(
[
PlaystationNetworkFriendImageEntity(
hass,
friend_data_coordinator,
description,
config_entry.subentries[subentry_id],
)
for description in IMAGE_DESCRIPTIONS_ALL
],
config_subentry_id=subentry_id,
)
class PlaystationNetworkImageEntity(PlaystationNetworkServiceEntity, ImageEntity):
class PlaystationNetworkImageBaseEntity(PlaystationNetworkServiceEntity, ImageEntity):
"""An image entity."""
entity_description: PlaystationNetworkImageEntityDescription
coordinator: PlaystationNetworkUserDataCoordinator
coordinator: PlayStationNetworkBaseCoordinator
def __init__(
self,
hass: HomeAssistant,
coordinator: PlaystationNetworkUserDataCoordinator,
coordinator: PlayStationNetworkBaseCoordinator,
entity_description: PlaystationNetworkImageEntityDescription,
subentry: ConfigSubentry | None = None,
) -> None:
"""Initialize the image entity."""
super().__init__(coordinator, entity_description)
super().__init__(coordinator, entity_description, subentry)
ImageEntity.__init__(self, hass)
self._attr_image_url = self.entity_description.image_url_fn(coordinator.data)
@ -96,6 +130,8 @@ class PlaystationNetworkImageEntity(PlaystationNetworkServiceEntity, ImageEntity
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if TYPE_CHECKING:
assert isinstance(self.coordinator.data, PlaystationNetworkData)
url = self.entity_description.image_url_fn(self.coordinator.data)
if url != self._attr_image_url:
@ -104,3 +140,15 @@ class PlaystationNetworkImageEntity(PlaystationNetworkServiceEntity, ImageEntity
self._attr_image_last_updated = dt_util.utcnow()
super()._handle_coordinator_update()
class PlaystationNetworkImageEntity(PlaystationNetworkImageBaseEntity):
"""An image entity."""
coordinator: PlaystationNetworkUserDataCoordinator
class PlaystationNetworkFriendImageEntity(PlaystationNetworkImageBaseEntity):
"""An image entity."""
coordinator: PlaystationNetworkFriendDataCoordinator

View File

@ -19,11 +19,14 @@ from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
from .coordinator import (
PlayStationNetworkBaseCoordinator,
PlaystationNetworkConfigEntry,
PlaystationNetworkData,
PlaystationNetworkFriendDataCoordinator,
PlaystationNetworkUserDataCoordinator,
)
from .entity import PlaystationNetworkServiceEntity
from .helpers import get_game_title_info
PARALLEL_UPDATES = 0
@ -33,7 +36,6 @@ class PlaystationNetworkSensorEntityDescription(SensorEntityDescription):
"""PlayStation Network sensor description."""
value_fn: Callable[[PlaystationNetworkData], StateType | datetime]
entity_picture: str | None = None
available_fn: Callable[[PlaystationNetworkData], bool] = lambda _: True
@ -49,9 +51,10 @@ class PlaystationNetworkSensor(StrEnum):
ONLINE_ID = "online_id"
LAST_ONLINE = "last_online"
ONLINE_STATUS = "online_status"
NOW_PLAYING = "now_playing"
SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
SENSOR_DESCRIPTIONS_TROPHY: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.TROPHY_LEVEL,
translation_key=PlaystationNetworkSensor.TROPHY_LEVEL,
@ -103,6 +106,8 @@ SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
else None
),
),
)
SENSOR_DESCRIPTIONS_USER: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.ONLINE_ID,
translation_key=PlaystationNetworkSensor.ONLINE_ID,
@ -122,10 +127,19 @@ SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.ONLINE_STATUS,
translation_key=PlaystationNetworkSensor.ONLINE_STATUS,
value_fn=lambda psn: psn.availability.lower().replace("unavailable", "offline"),
value_fn=(
lambda psn: psn.presence["basicPresence"]["availability"]
.lower()
.replace("unavailable", "offline")
),
device_class=SensorDeviceClass.ENUM,
options=["offline", "availabletoplay", "availabletocommunicate", "busy"],
),
PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.NOW_PLAYING,
translation_key=PlaystationNetworkSensor.NOW_PLAYING,
value_fn=lambda psn: get_game_title_info(psn.presence).get("titleName"),
),
)
@ -138,18 +152,34 @@ async def async_setup_entry(
coordinator = config_entry.runtime_data.user_data
async_add_entities(
PlaystationNetworkSensorEntity(coordinator, description)
for description in SENSOR_DESCRIPTIONS
for description in SENSOR_DESCRIPTIONS_TROPHY + SENSOR_DESCRIPTIONS_USER
)
for (
subentry_id,
friend_data_coordinator,
) in config_entry.runtime_data.friends.items():
async_add_entities(
[
PlaystationNetworkFriendSensorEntity(
friend_data_coordinator,
description,
config_entry.subentries[subentry_id],
)
for description in SENSOR_DESCRIPTIONS_USER
],
config_subentry_id=subentry_id,
)
class PlaystationNetworkSensorEntity(
class PlaystationNetworkSensorBaseEntity(
PlaystationNetworkServiceEntity,
SensorEntity,
):
"""Representation of a PlayStation Network sensor entity."""
"""Base sensor entity."""
entity_description: PlaystationNetworkSensorEntityDescription
coordinator: PlaystationNetworkUserDataCoordinator
coordinator: PlayStationNetworkBaseCoordinator
@property
def native_value(self) -> StateType | datetime:
@ -169,14 +199,24 @@ class PlaystationNetworkSensorEntity(
(pic.get("url") for pic in profile_pictures if pic.get("size") == "xl"),
None,
)
return super().entity_picture
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
self.entity_description.available_fn(self.coordinator.data)
and super().available
return super().available and self.entity_description.available_fn(
self.coordinator.data
)
class PlaystationNetworkSensorEntity(PlaystationNetworkSensorBaseEntity):
"""Representation of a PlayStation Network sensor entity."""
coordinator: PlaystationNetworkUserDataCoordinator
class PlaystationNetworkFriendSensorEntity(PlaystationNetworkSensorBaseEntity):
"""Representation of a PlayStation Network sensor entity."""
coordinator: PlaystationNetworkFriendDataCoordinator

View File

@ -39,11 +39,40 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_configured_as_subentry": "Already configured as a friend for another account. Delete the existing entry first.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unique_id_mismatch": "The provided NPSSO token corresponds to the account {wrong_account}. Please re-authenticate with the account **{name}**",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"config_subentries": {
"friend": {
"step": {
"user": {
"title": "Friend online status",
"description": "Track the online status of a PlayStation Network friend.",
"data": {
"account_id": "Online ID"
},
"data_description": {
"account_id": "Select a friend from your friend list to track their online status."
}
}
},
"initiate_flow": {
"user": "Add friend"
},
"entry_type": "Friend",
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured_as_entry": "Already configured as a service. This account cannot be added as a friend.",
"already_configured": "Already configured as a friend in this or another account."
}
}
},
"exceptions": {
"not_ready": {
"message": "Authentication to the PlayStation Network failed."
@ -59,6 +88,12 @@
},
"send_message_failed": {
"message": "Failed to send message to group {group_name}. Try again later."
},
"user_profile_private": {
"message": "Unable to retrieve data for {user}. Privacy settings restrict access to activity."
},
"user_not_found": {
"message": "Unable to retrieve data for {user}. User does not exist or has been removed."
}
},
"entity": {
@ -104,6 +139,9 @@
"availabletocommunicate": "Online on PS App",
"busy": "Away"
}
},
"now_playing": {
"name": "Now playing"
}
},
"image": {
@ -112,6 +150,9 @@
},
"avatar": {
"name": "Avatar"
},
"now_playing_image": {
"name": "[%key:component::playstation_network::entity::sensor::now_playing::name%]"
}
},
"notify": {

View File

@ -6,5 +6,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/scrape",
"iot_class": "cloud_polling",
"requirements": ["beautifulsoup4==4.13.3", "lxml==5.3.0"]
"requirements": ["beautifulsoup4==4.13.3", "lxml==6.0.0"]
}

View File

@ -30,6 +30,7 @@ PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.CLIMATE,
Platform.FAN,
Platform.LIGHT,
Platform.LOCK,
Platform.SENSOR,
Platform.SWITCH,
@ -53,6 +54,7 @@ class SwitchbotDevices:
vacuums: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
fans: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
lights: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
@dataclass
@ -142,12 +144,15 @@ async def make_device_data(
hass, entry, api, device, coordinators_by_id
)
devices_data.sensors.append((device, coordinator))
if isinstance(device, Device) and device.device_type in [
"K10+",
"K10+ Pro",
"Robot Vacuum Cleaner S1",
"Robot Vacuum Cleaner S1 Plus",
"K20+ Pro",
"Robot Vacuum Cleaner K10+ Pro Combo",
"Robot Vacuum Cleaner S10",
"S20",
]:
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id, True
@ -188,6 +193,17 @@ async def make_device_data(
devices_data.fans.append((device, coordinator))
devices_data.sensors.append((device, coordinator))
if isinstance(device, Device) and device.device_type in [
"Strip Light",
"Strip Light 3",
"Floor Lamp",
"Color Bulb",
]:
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id
)
devices_data.lights.append((device, coordinator))
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up SwitchBot via API from a config entry."""

View File

@ -15,3 +15,5 @@ VACUUM_FAN_SPEED_QUIET = "quiet"
VACUUM_FAN_SPEED_STANDARD = "standard"
VACUUM_FAN_SPEED_STRONG = "strong"
VACUUM_FAN_SPEED_MAX = "max"
AFTER_COMMAND_REFRESH = 5

View File

@ -0,0 +1,153 @@
"""Support for the Switchbot Light."""
import asyncio
from typing import Any
from switchbot_api import (
CommonCommands,
Device,
Remote,
RGBWLightCommands,
RGBWWLightCommands,
SwitchBotAPI,
)
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitchbotCloudData, SwitchBotCoordinator
from .const import AFTER_COMMAND_REFRESH, DOMAIN
from .entity import SwitchBotCloudEntity
def value_map_brightness(value: int) -> int:
"""Return value for brightness map."""
return int(value / 255 * 100)
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SwitchBot Cloud entry."""
data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id]
async_add_entities(
_async_make_entity(data.api, device, coordinator)
for device, coordinator in data.devices.lights
)
class SwitchBotCloudLight(SwitchBotCloudEntity, LightEntity):
"""Base Class for SwitchBot Light."""
_attr_is_on: bool | None = None
_attr_name: str | None = None
_attr_color_mode = ColorMode.UNKNOWN
def _set_attributes(self) -> None:
"""Set attributes from coordinator data."""
if self.coordinator.data is None:
return
power: str | None = self.coordinator.data.get("power")
brightness: int | None = self.coordinator.data.get("brightness")
color: str | None = self.coordinator.data.get("color")
color_temperature: int | None = self.coordinator.data.get("colorTemperature")
self._attr_is_on = power == "on" if power else None
self._attr_brightness: int | None = brightness if brightness else None
self._attr_rgb_color: tuple | None = (
(tuple(int(i) for i in color.split(":"))) if color else None
)
self._attr_color_temp_kelvin: int | None = (
color_temperature if color_temperature else None
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self.send_api_command(CommonCommands.OFF)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
brightness: int | None = kwargs.get("brightness")
rgb_color: tuple[int, int, int] | None = kwargs.get("rgb_color")
color_temp_kelvin: int | None = kwargs.get("color_temp_kelvin")
if brightness is not None:
self._attr_color_mode = ColorMode.RGB
await self._send_brightness_command(brightness)
elif rgb_color is not None:
self._attr_color_mode = ColorMode.RGB
await self._send_rgb_color_command(rgb_color)
elif color_temp_kelvin is not None:
self._attr_color_mode = ColorMode.COLOR_TEMP
await self._send_color_temperature_command(color_temp_kelvin)
else:
self._attr_color_mode = ColorMode.RGB
await self.send_api_command(CommonCommands.ON)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def _send_brightness_command(self, brightness: int) -> None:
"""Send a brightness command."""
await self.send_api_command(
RGBWLightCommands.SET_BRIGHTNESS,
parameters=str(value_map_brightness(brightness)),
)
async def _send_rgb_color_command(self, rgb_color: tuple) -> None:
"""Send an RGB command."""
await self.send_api_command(
RGBWLightCommands.SET_COLOR,
parameters=f"{rgb_color[2]}:{rgb_color[1]}:{rgb_color[0]}",
)
async def _send_color_temperature_command(self, color_temp_kelvin: int) -> None:
"""Send a color temperature command."""
await self.send_api_command(
RGBWWLightCommands.SET_COLOR_TEMPERATURE,
parameters=str(color_temp_kelvin),
)
class SwitchBotCloudStripLight(SwitchBotCloudLight):
"""Representation of a SwitchBot Strip Light."""
_attr_supported_color_modes = {ColorMode.RGB}
class SwitchBotCloudRGBWWLight(SwitchBotCloudLight):
"""Representation of SwitchBot |Strip Light|Floor Lamp|Color Bulb."""
_attr_max_color_temp_kelvin = 6500
_attr_min_color_temp_kelvin = 2700
_attr_supported_color_modes = {ColorMode.RGB, ColorMode.COLOR_TEMP}
async def _send_brightness_command(self, brightness: int) -> None:
"""Send a brightness command."""
await self.send_api_command(
RGBWWLightCommands.SET_BRIGHTNESS,
parameters=str(value_map_brightness(brightness)),
)
async def _send_rgb_color_command(self, rgb_color: tuple) -> None:
"""Send an RGB command."""
await self.send_api_command(
RGBWWLightCommands.SET_COLOR,
parameters=f"{rgb_color[0]}:{rgb_color[1]}:{rgb_color[2]}",
)
@callback
def _async_make_entity(
api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator
) -> SwitchBotCloudStripLight | SwitchBotCloudRGBWWLight:
"""Make a SwitchBotCloudLight."""
if device.device_type == "Strip Light":
return SwitchBotCloudStripLight(api, device, coordinator)
return SwitchBotCloudRGBWWLight(api, device, coordinator)

View File

@ -2,7 +2,15 @@
from typing import Any
from switchbot_api import Device, Remote, SwitchBotAPI, VacuumCommands
from switchbot_api import (
Device,
Remote,
SwitchBotAPI,
VacuumCleanerV2Commands,
VacuumCleanerV3Commands,
VacuumCleanMode,
VacuumCommands,
)
from homeassistant.components.vacuum import (
StateVacuumEntity,
@ -63,6 +71,11 @@ VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED: dict[str, str] = {
class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity):
"""Representation of a SwitchBot vacuum."""
# "K10+"
# "K10+ Pro"
# "Robot Vacuum Cleaner S1"
# "Robot Vacuum Cleaner S1 Plus"
_attr_supported_features: VacuumEntityFeature = (
VacuumEntityFeature.BATTERY
| VacuumEntityFeature.FAN_SPEED
@ -85,23 +98,26 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity):
VacuumCommands.POW_LEVEL,
parameters=VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED[fan_speed],
)
self.async_write_ha_state()
await self.coordinator.async_request_refresh()
async def async_pause(self) -> None:
"""Pause the cleaning task."""
await self.send_api_command(VacuumCommands.STOP)
self.async_write_ha_state()
async def async_return_to_base(self, **kwargs: Any) -> None:
"""Set the vacuum cleaner to return to the dock."""
await self.send_api_command(VacuumCommands.DOCK)
await self.coordinator.async_request_refresh()
async def async_start(self) -> None:
"""Start or resume the cleaning task."""
await self.send_api_command(VacuumCommands.START)
await self.coordinator.async_request_refresh()
def _set_attributes(self) -> None:
"""Set attributes from coordinator data."""
if not self.coordinator.data:
if self.coordinator.data is None:
return
self._attr_battery_level = self.coordinator.data.get("battery")
@ -109,11 +125,127 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity):
switchbot_state = str(self.coordinator.data.get("workingStatus"))
self._attr_activity = VACUUM_SWITCHBOT_STATE_TO_HA_STATE.get(switchbot_state)
if self._attr_fan_speed is None:
self._attr_fan_speed = VACUUM_FAN_SPEED_QUIET
class SwitchBotCloudVacuumK20PlusPro(SwitchBotCloudVacuum):
"""Representation of a SwitchBot K20+ Pro."""
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
"""Set fan speed."""
self._attr_fan_speed = fan_speed
await self.send_api_command(
VacuumCleanerV2Commands.CHANGE_PARAM,
parameters={
"fanLevel": int(VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED[fan_speed]) + 1,
"waterLevel": 1,
"times": 1,
},
)
await self.coordinator.async_request_refresh()
async def async_pause(self) -> None:
"""Pause the cleaning task."""
await self.send_api_command(VacuumCleanerV2Commands.PAUSE)
await self.coordinator.async_request_refresh()
async def async_return_to_base(self, **kwargs: Any) -> None:
"""Set the vacuum cleaner to return to the dock."""
await self.send_api_command(VacuumCleanerV2Commands.DOCK)
await self.coordinator.async_request_refresh()
async def async_start(self) -> None:
"""Start or resume the cleaning task."""
fan_level = (
VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED.get(self.fan_speed)
if self.fan_speed
else None
)
await self.send_api_command(
VacuumCleanerV2Commands.START_CLEAN,
parameters={
"action": VacuumCleanMode.SWEEP.value,
"param": {
"fanLevel": int(fan_level if fan_level else VACUUM_FAN_SPEED_QUIET)
+ 1,
"times": 1,
},
},
)
await self.coordinator.async_request_refresh()
class SwitchBotCloudVacuumK10PlusProCombo(SwitchBotCloudVacuumK20PlusPro):
"""Representation of a SwitchBot vacuum K10+ Pro Combo."""
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
"""Set fan speed."""
self._attr_fan_speed = fan_speed
if fan_speed in VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED:
await self.send_api_command(
VacuumCleanerV2Commands.CHANGE_PARAM,
parameters={
"fanLevel": int(VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED[fan_speed])
+ 1,
"times": 1,
},
)
await self.coordinator.async_request_refresh()
class SwitchBotCloudVacuumV3(SwitchBotCloudVacuumK20PlusPro):
"""Representation of a SwitchBot vacuum Robot Vacuum Cleaner S10 & S20."""
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
"""Set fan speed."""
self._attr_fan_speed = fan_speed
await self.send_api_command(
VacuumCleanerV3Commands.CHANGE_PARAM,
parameters={
"fanLevel": int(VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED[fan_speed]) + 1,
"waterLevel": 1,
"times": 1,
},
)
await self.coordinator.async_request_refresh()
async def async_start(self) -> None:
"""Start or resume the cleaning task."""
fan_level = (
VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED.get(self.fan_speed)
if self.fan_speed
else None
)
await self.send_api_command(
VacuumCleanerV3Commands.START_CLEAN,
parameters={
"action": VacuumCleanMode.SWEEP.value,
"param": {
"fanLevel": int(fan_level if fan_level else VACUUM_FAN_SPEED_QUIET),
"waterLevel": 1,
"times": 1,
},
},
)
await self.coordinator.async_request_refresh()
@callback
def _async_make_entity(
api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator
) -> SwitchBotCloudVacuum:
) -> (
SwitchBotCloudVacuum
| SwitchBotCloudVacuumK20PlusPro
| SwitchBotCloudVacuumV3
| SwitchBotCloudVacuumK10PlusProCombo
):
"""Make a SwitchBotCloudVacuum."""
if device.device_type in VacuumCleanerV2Commands.get_supported_devices():
if device.device_type == "K20+ Pro":
return SwitchBotCloudVacuumK20PlusPro(api, device, coordinator)
return SwitchBotCloudVacuumK10PlusProCombo(api, device, coordinator)
if device.device_type in VacuumCleanerV3Commands.get_supported_devices():
return SwitchBotCloudVacuumV3(api, device, coordinator)
return SwitchBotCloudVacuum(api, device, coordinator)

View File

@ -11,6 +11,7 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.button import ButtonDeviceClass
from homeassistant.components.cover import CoverDeviceClass
from homeassistant.components.sensor import (
CONF_STATE_CLASS,
DEVICE_CLASS_STATE_CLASSES,
@ -62,6 +63,32 @@ from .const import (
CONF_TURN_ON,
DOMAIN,
)
from .cover import (
CLOSE_ACTION,
CONF_OPEN_AND_CLOSE,
CONF_POSITION,
OPEN_ACTION,
POSITION_ACTION,
STOP_ACTION,
async_create_preview_cover,
)
from .fan import (
CONF_OFF_ACTION,
CONF_ON_ACTION,
CONF_PERCENTAGE,
CONF_SET_PERCENTAGE_ACTION,
CONF_SPEED_COUNT,
async_create_preview_fan,
)
from .light import (
CONF_HS,
CONF_HS_ACTION,
CONF_LEVEL,
CONF_LEVEL_ACTION,
CONF_TEMPERATURE,
CONF_TEMPERATURE_ACTION,
async_create_preview_light,
)
from .number import (
CONF_MAX,
CONF_MIN,
@ -143,12 +170,57 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
)
}
if domain == Platform.COVER:
schema |= _SCHEMA_STATE | {
vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): selector.ActionSelector(),
vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): selector.ActionSelector(),
vol.Optional(STOP_ACTION): selector.ActionSelector(),
vol.Optional(CONF_POSITION): selector.TemplateSelector(),
vol.Optional(POSITION_ACTION): selector.ActionSelector(),
}
if flow_type == "config":
schema |= {
vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[cls.value for cls in CoverDeviceClass],
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="cover_device_class",
sort=True,
),
)
}
if domain == Platform.FAN:
schema |= _SCHEMA_STATE | {
vol.Required(CONF_ON_ACTION): selector.ActionSelector(),
vol.Required(CONF_OFF_ACTION): selector.ActionSelector(),
vol.Optional(CONF_PERCENTAGE): selector.TemplateSelector(),
vol.Optional(CONF_SET_PERCENTAGE_ACTION): selector.ActionSelector(),
vol.Optional(CONF_SPEED_COUNT): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1, max=100, step=1, mode=selector.NumberSelectorMode.BOX
),
),
}
if domain == Platform.IMAGE:
schema |= {
vol.Required(CONF_URL): selector.TemplateSelector(),
vol.Optional(CONF_VERIFY_SSL, default=True): selector.BooleanSelector(),
}
if domain == Platform.LIGHT:
schema |= _SCHEMA_STATE | {
vol.Required(CONF_TURN_ON): selector.ActionSelector(),
vol.Required(CONF_TURN_OFF): selector.ActionSelector(),
vol.Optional(CONF_LEVEL): selector.TemplateSelector(),
vol.Optional(CONF_LEVEL_ACTION): selector.ActionSelector(),
vol.Optional(CONF_HS): selector.TemplateSelector(),
vol.Optional(CONF_HS_ACTION): selector.ActionSelector(),
vol.Optional(CONF_TEMPERATURE): selector.TemplateSelector(),
vol.Optional(CONF_TEMPERATURE_ACTION): selector.ActionSelector(),
}
if domain == Platform.NUMBER:
schema |= {
vol.Required(CONF_STATE): selector.TemplateSelector(),
@ -327,7 +399,10 @@ TEMPLATE_TYPES = [
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.COVER,
Platform.FAN,
Platform.IMAGE,
Platform.LIGHT,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
@ -350,11 +425,26 @@ CONFIG_FLOW = {
config_schema(Platform.BUTTON),
validate_user_input=validate_user_input(Platform.BUTTON),
),
Platform.COVER: SchemaFlowFormStep(
config_schema(Platform.COVER),
preview="template",
validate_user_input=validate_user_input(Platform.COVER),
),
Platform.FAN: SchemaFlowFormStep(
config_schema(Platform.FAN),
preview="template",
validate_user_input=validate_user_input(Platform.FAN),
),
Platform.IMAGE: SchemaFlowFormStep(
config_schema(Platform.IMAGE),
preview="template",
validate_user_input=validate_user_input(Platform.IMAGE),
),
Platform.LIGHT: SchemaFlowFormStep(
config_schema(Platform.LIGHT),
preview="template",
validate_user_input=validate_user_input(Platform.LIGHT),
),
Platform.NUMBER: SchemaFlowFormStep(
config_schema(Platform.NUMBER),
preview="template",
@ -394,11 +484,26 @@ OPTIONS_FLOW = {
options_schema(Platform.BUTTON),
validate_user_input=validate_user_input(Platform.BUTTON),
),
Platform.COVER: SchemaFlowFormStep(
options_schema(Platform.COVER),
preview="template",
validate_user_input=validate_user_input(Platform.COVER),
),
Platform.FAN: SchemaFlowFormStep(
options_schema(Platform.FAN),
preview="template",
validate_user_input=validate_user_input(Platform.FAN),
),
Platform.IMAGE: SchemaFlowFormStep(
options_schema(Platform.IMAGE),
preview="template",
validate_user_input=validate_user_input(Platform.IMAGE),
),
Platform.LIGHT: SchemaFlowFormStep(
options_schema(Platform.LIGHT),
preview="template",
validate_user_input=validate_user_input(Platform.LIGHT),
),
Platform.NUMBER: SchemaFlowFormStep(
options_schema(Platform.NUMBER),
preview="template",
@ -427,6 +532,9 @@ CREATE_PREVIEW_ENTITY: dict[
] = {
Platform.ALARM_CONTROL_PANEL: async_create_preview_alarm_control_panel,
Platform.BINARY_SENSOR: async_create_preview_binary_sensor,
Platform.COVER: async_create_preview_cover,
Platform.FAN: async_create_preview_fan,
Platform.LIGHT: async_create_preview_light,
Platform.NUMBER: async_create_preview_number,
Platform.SELECT: async_create_preview_select,
Platform.SENSOR: async_create_preview_sensor,

View File

@ -18,6 +18,7 @@ from homeassistant.components.cover import (
CoverEntity,
CoverEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_COVERS,
CONF_DEVICE_CLASS,
@ -31,14 +32,22 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator
from .const import DOMAIN
from .entity import AbstractTemplateEntity
from .helpers import async_setup_template_platform
from .helpers import (
async_setup_template_entry,
async_setup_template_platform,
async_setup_template_preview,
)
from .template_entity import (
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY,
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA,
TemplateEntity,
@ -91,23 +100,29 @@ LEGACY_FIELDS = {
DEFAULT_NAME = "Template Cover"
COVER_COMMON_SCHEMA = vol.Schema(
{
vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA,
vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_POSITION): cv.template,
vol.Optional(CONF_STATE): cv.template,
vol.Optional(CONF_TILT): cv.template,
vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA,
}
)
COVER_YAML_SCHEMA = vol.All(
vol.Schema(
{
vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA,
vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_POSITION): cv.template,
vol.Optional(CONF_STATE): cv.template,
vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean,
vol.Optional(CONF_TILT): cv.template,
vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA,
}
)
.extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA),
.extend(COVER_COMMON_SCHEMA.schema)
.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA)
.extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema),
cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION),
)
@ -139,6 +154,11 @@ PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend(
{vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_LEGACY_YAML_SCHEMA)}
)
COVER_CONFIG_ENTRY_SCHEMA = vol.All(
COVER_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema),
cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION),
)
async def async_setup_platform(
hass: HomeAssistant,
@ -160,6 +180,37 @@ async def async_setup_platform(
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
await async_setup_template_entry(
hass,
config_entry,
async_add_entities,
StateCoverEntity,
COVER_CONFIG_ENTRY_SCHEMA,
True,
)
@callback
def async_create_preview_cover(
hass: HomeAssistant, name: str, config: dict[str, Any]
) -> StateCoverEntity:
"""Create a preview."""
return async_setup_template_preview(
hass,
name,
config,
StateCoverEntity,
COVER_CONFIG_ENTRY_SCHEMA,
True,
)
class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
"""Representation of a template cover features."""

View File

@ -20,6 +20,7 @@ from homeassistant.components.fan import (
FanEntity,
FanEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ENTITY_ID,
CONF_FRIENDLY_NAME,
@ -34,15 +35,23 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DOMAIN
from .coordinator import TriggerUpdateCoordinator
from .entity import AbstractTemplateEntity
from .helpers import async_setup_template_platform
from .helpers import (
async_setup_template_entry,
async_setup_template_platform,
async_setup_template_preview,
)
from .template_entity import (
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY,
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA,
TemplateEntity,
make_template_entity_common_modern_schema,
@ -132,6 +141,10 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(
{vol.Required(CONF_FANS): cv.schema_with_slug_keys(FAN_LEGACY_YAML_SCHEMA)}
)
FAN_CONFIG_ENTRY_SCHEMA = FAN_COMMON_SCHEMA.extend(
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema
)
async def async_setup_platform(
hass: HomeAssistant,
@ -153,6 +166,35 @@ async def async_setup_platform(
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
await async_setup_template_entry(
hass,
config_entry,
async_add_entities,
StateFanEntity,
FAN_CONFIG_ENTRY_SCHEMA,
)
@callback
def async_create_preview_fan(
hass: HomeAssistant, name: str, config: dict[str, Any]
) -> StateFanEntity:
"""Create a preview."""
return async_setup_template_preview(
hass,
name,
config,
StateFanEntity,
FAN_CONFIG_ENTRY_SCHEMA,
)
class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
"""Representation of a template fan features."""

View File

@ -242,7 +242,7 @@ async def async_setup_template_entry(
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
state_entity_cls: type[TemplateEntity],
config_schema: vol.Schema,
config_schema: vol.Schema | vol.All,
replace_value_template: bool = False,
) -> None:
"""Setup the Template from a config entry."""
@ -267,7 +267,7 @@ def async_setup_template_preview[T: TemplateEntity](
name: str,
config: ConfigType,
state_entity_cls: type[T],
schema: vol.Schema,
schema: vol.Schema | vol.All,
replace_value_template: bool = False,
) -> T:
"""Setup the Template preview."""

View File

@ -27,6 +27,7 @@ from homeassistant.components.light import (
LightEntityFeature,
filter_supported_color_modes,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_EFFECT,
CONF_ENTITY_ID,
@ -43,15 +44,23 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import color as color_util
from . import TriggerUpdateCoordinator
from .const import DOMAIN
from .entity import AbstractTemplateEntity
from .helpers import async_setup_template_platform
from .helpers import (
async_setup_template_entry,
async_setup_template_platform,
async_setup_template_preview,
)
from .template_entity import (
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY,
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA,
TemplateEntity,
@ -135,6 +144,8 @@ LIGHT_COMMON_SCHEMA = vol.Schema(
vol.Optional(CONF_MIN_MIREDS): cv.template,
vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA,
vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA,
vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_RGB): cv.template,
vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA,
@ -195,6 +206,10 @@ PLATFORM_SCHEMA = vol.All(
),
)
LIGHT_CONFIG_ENTRY_SCHEMA = LIGHT_COMMON_SCHEMA.extend(
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema
)
async def async_setup_platform(
hass: HomeAssistant,
@ -216,6 +231,37 @@ async def async_setup_platform(
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
await async_setup_template_entry(
hass,
config_entry,
async_add_entities,
StateLightEntity,
LIGHT_CONFIG_ENTRY_SCHEMA,
True,
)
@callback
def async_create_preview_light(
hass: HomeAssistant, name: str, config: dict[str, Any]
) -> StateLightEntity:
"""Create a preview."""
return async_setup_template_preview(
hass,
name,
config,
StateLightEntity,
LIGHT_CONFIG_ENTRY_SCHEMA,
True,
)
class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
"""Representation of a template lights features."""

View File

@ -17,14 +17,9 @@ from homeassistant.components.number import (
NumberEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_NAME,
CONF_OPTIMISTIC,
CONF_STATE,
CONF_UNIT_OF_MEASUREMENT,
)
from homeassistant.const import CONF_NAME, CONF_STATE, CONF_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@ -33,6 +28,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator
from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN
from .entity import AbstractTemplateEntity
from .helpers import (
async_setup_template_entry,
async_setup_template_platform,
@ -40,6 +36,7 @@ from .helpers import (
)
from .template_entity import (
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA,
TemplateEntity,
make_template_entity_common_modern_schema,
)
@ -57,21 +54,15 @@ NUMBER_COMMON_SCHEMA = vol.Schema(
vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template,
vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template,
vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA,
vol.Required(CONF_STATE): cv.template,
vol.Required(CONF_STEP): cv.template,
vol.Optional(CONF_STATE): cv.template,
vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.template,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
}
)
).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
NUMBER_YAML_SCHEMA = (
vol.Schema(
{
vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
}
)
.extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
.extend(NUMBER_COMMON_SCHEMA.schema)
)
NUMBER_YAML_SCHEMA = NUMBER_COMMON_SCHEMA.extend(
TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA
).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
NUMBER_CONFIG_ENTRY_SCHEMA = NUMBER_COMMON_SCHEMA.extend(
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema
@ -121,69 +112,28 @@ def async_create_preview_number(
)
class StateNumberEntity(TemplateEntity, NumberEntity):
"""Representation of a template number."""
class AbstractTemplateNumber(AbstractTemplateEntity, NumberEntity):
"""Representation of a template number features."""
_attr_should_poll = False
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
def __init__(
self,
hass: HomeAssistant,
config,
unique_id: str | None,
) -> None:
"""Initialize the number."""
TemplateEntity.__init__(self, hass, config, unique_id)
if TYPE_CHECKING:
assert self._attr_name is not None
self._value_template = config[CONF_STATE]
self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], self._attr_name, DOMAIN)
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called
"""Initialize the features."""
self._step_template = config[CONF_STEP]
self._min_value_template = config[CONF_MIN]
self._max_value_template = config[CONF_MAX]
self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC)
self._min_template = config[CONF_MIN]
self._max_template = config[CONF_MAX]
self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT)
self._attr_native_step = DEFAULT_STEP
self._attr_native_min_value = DEFAULT_MIN_VALUE
self._attr_native_max_value = DEFAULT_MAX_VALUE
@callback
def _async_setup_templates(self) -> None:
"""Set up templates."""
self.add_template_attribute(
"_attr_native_value",
self._value_template,
validator=vol.Coerce(float),
none_on_template_error=True,
)
self.add_template_attribute(
"_attr_native_step",
self._step_template,
validator=vol.Coerce(float),
none_on_template_error=True,
)
if self._min_value_template is not None:
self.add_template_attribute(
"_attr_native_min_value",
self._min_value_template,
validator=vol.Coerce(float),
none_on_template_error=True,
)
if self._max_value_template is not None:
self.add_template_attribute(
"_attr_native_max_value",
self._max_value_template,
validator=vol.Coerce(float),
none_on_template_error=True,
)
super()._async_setup_templates()
async def async_set_native_value(self, value: float) -> None:
"""Set value of the number."""
if self._optimistic:
if self._attr_assumed_state:
self._attr_native_value = value
self.async_write_ha_state()
if set_value := self._action_scripts.get(CONF_SET_VALUE):
@ -194,17 +144,65 @@ class StateNumberEntity(TemplateEntity, NumberEntity):
)
class TriggerNumberEntity(TriggerEntity, NumberEntity):
class StateNumberEntity(TemplateEntity, AbstractTemplateNumber):
"""Representation of a template number."""
_attr_should_poll = False
def __init__(
self,
hass: HomeAssistant,
config: ConfigType,
unique_id: str | None,
) -> None:
"""Initialize the number."""
TemplateEntity.__init__(self, hass, config, unique_id)
AbstractTemplateNumber.__init__(self, config)
name = self._attr_name
if TYPE_CHECKING:
assert name is not None
self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], name, DOMAIN)
@callback
def _async_setup_templates(self) -> None:
"""Set up templates."""
if self._template is not None:
self.add_template_attribute(
"_attr_native_value",
self._template,
vol.Coerce(float),
none_on_template_error=True,
)
if self._step_template is not None:
self.add_template_attribute(
"_attr_native_step",
self._step_template,
vol.Coerce(float),
none_on_template_error=True,
)
if self._min_template is not None:
self.add_template_attribute(
"_attr_native_min_value",
self._min_template,
validator=vol.Coerce(float),
none_on_template_error=True,
)
if self._max_template is not None:
self.add_template_attribute(
"_attr_native_max_value",
self._max_template,
validator=vol.Coerce(float),
none_on_template_error=True,
)
super()._async_setup_templates()
class TriggerNumberEntity(TriggerEntity, AbstractTemplateNumber):
"""Number entity based on trigger data."""
_entity_id_format = ENTITY_ID_FORMAT
domain = NUMBER_DOMAIN
extra_template_keys = (
CONF_STATE,
CONF_STEP,
CONF_MIN,
CONF_MAX,
)
def __init__(
self,
@ -213,47 +211,49 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity):
config: dict,
) -> None:
"""Initialize the entity."""
super().__init__(hass, coordinator, config)
TriggerEntity.__init__(self, hass, coordinator, config)
AbstractTemplateNumber.__init__(self, config)
name = self._rendered.get(CONF_NAME, DEFAULT_NAME)
self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], name, DOMAIN)
for key in (
CONF_STATE,
CONF_STEP,
CONF_MIN,
CONF_MAX,
):
if isinstance(config.get(key), template.Template):
self._to_render_simple.append(key)
self._parse_result.add(key)
self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT)
@property
def native_value(self) -> float | None:
"""Return the currently selected option."""
return vol.Any(vol.Coerce(float), None)(self._rendered.get(CONF_STATE))
@property
def native_min_value(self) -> int:
"""Return the minimum value."""
return vol.Any(vol.Coerce(float), None)(
self._rendered.get(CONF_MIN, super().native_min_value)
self.add_script(
CONF_SET_VALUE,
config[CONF_SET_VALUE],
self._rendered.get(CONF_NAME, DEFAULT_NAME),
DOMAIN,
)
@property
def native_max_value(self) -> int:
"""Return the maximum value."""
return vol.Any(vol.Coerce(float), None)(
self._rendered.get(CONF_MAX, super().native_max_value)
)
def _handle_coordinator_update(self):
"""Handle updated data from the coordinator."""
self._process_data()
@property
def native_step(self) -> int:
"""Return the increment/decrement step."""
return vol.Any(vol.Coerce(float), None)(
self._rendered.get(CONF_STEP, super().native_step)
)
async def async_set_native_value(self, value: float) -> None:
"""Set value of the number."""
if self._config[CONF_OPTIMISTIC]:
self._attr_native_value = value
if not self.available:
self.async_write_ha_state()
return
write_ha_state = False
for key, attr in (
(CONF_STATE, "_attr_native_value"),
(CONF_STEP, "_attr_native_step"),
(CONF_MIN, "_attr_native_min_value"),
(CONF_MAX, "_attr_native_max_value"),
):
if (rendered := self._rendered.get(key)) is not None:
setattr(self, attr, vol.Any(vol.Coerce(float), None)(rendered))
write_ha_state = True
if len(self._rendered) > 0:
# In case any non optimistic template
write_ha_state = True
if write_ha_state:
self.async_set_context(self.coordinator.data["context"])
self.async_write_ha_state()
if set_value := self._action_scripts.get(CONF_SET_VALUE):
await self.async_run_script(
set_value,
run_variables={ATTR_VALUE: value},
context=self._context,
)

View File

@ -80,6 +80,67 @@
},
"title": "Template button"
},
"cover": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
"device_class": "[%key:component::template::common::device_class%]",
"name": "[%key:common::config_flow::data::name%]",
"state": "[%key:component::template::common::state%]",
"open_cover": "Actions on open",
"close_cover": "Actions on close",
"stop_cover": "Actions on stop",
"position": "Position",
"set_cover_position": "Actions on set position"
},
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]",
"state": "Defines a template to get the state of the cover. Valid output values from the template are `open`, `opening`, `closing` and `closed` which are directly mapped to the corresponding states. If both a state and a position are specified, only `opening` and `closing` are set from the state template.",
"open_cover": "Defines actions to run when the cover is opened.",
"close_cover": "Defines actions to run when the cover is closed.",
"stop_cover": "Defines actions to run when the cover is stopped.",
"position": "Defines a template to get the position of the cover. Value values are numbers between `0` (`closed`) and `100` (`open`).",
"set_cover_position": "Defines actions to run when the cover is given a `set_cover_position` command."
},
"sections": {
"advanced_options": {
"name": "[%key:component::template::common::advanced_options%]",
"data": {
"availability": "[%key:component::template::common::availability%]"
}
}
},
"title": "Template cover"
},
"fan": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
"name": "[%key:common::config_flow::data::name%]",
"state": "[%key:component::template::common::state%]",
"turn_off": "[%key:component::template::common::turn_off%]",
"turn_on": "[%key:component::template::common::turn_on%]",
"percentage": "Percentage",
"set_percentage": "Actions on set percentage",
"speed_count": "Speed count"
},
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]",
"state": "Defines a template to get the state of the fan. Valid values: `on`, `off`.",
"turn_off": "Defines actions to run when the fan is turned off.",
"turn_on": "Defines actions to run when the fan is turned on.",
"percentage": "Defines a template to get the speed percentage of the fan.",
"set_percentage": "Defines actions to run when the fan is given a speed percentage command.",
"speed_count": "The number of speeds the fan supports. Used to calculate the percentage step for the `fan.increase_speed` and `fan.decrease_speed` actions."
},
"sections": {
"advanced_options": {
"name": "[%key:component::template::common::advanced_options%]",
"data": {
"availability": "[%key:component::template::common::availability%]"
}
}
},
"title": "Template fan"
},
"image": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
@ -100,6 +161,33 @@
},
"title": "Template image"
},
"light": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
"name": "[%key:common::config_flow::data::name%]",
"state": "[%key:component::template::common::state%]",
"turn_off": "[%key:component::template::common::turn_off%]",
"turn_on": "[%key:component::template::common::turn_on%]",
"level": "Brightness level",
"set_level": "Actions on set level",
"hs": "HS color",
"set_hs": "Actions on set HS color",
"temperature": "Color temperature",
"set_temperature": "Actions on set color temperature"
},
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]"
},
"sections": {
"advanced_options": {
"name": "[%key:component::template::common::advanced_options%]",
"data": {
"availability": "[%key:component::template::common::availability%]"
}
}
},
"title": "Template light"
},
"number": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
@ -173,7 +261,10 @@
"alarm_control_panel": "Template an alarm control panel",
"binary_sensor": "Template a binary sensor",
"button": "Template a button",
"cover": "Template a cover",
"fan": "Template a fan",
"image": "Template an image",
"light": "Template a light",
"number": "Template a number",
"select": "Template a select",
"sensor": "Template a sensor",
@ -270,6 +361,65 @@
},
"title": "[%key:component::template::config::step::button::title%]"
},
"cover": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
"state": "[%key:component::template::common::state%]",
"open_cover": "[%key:component::template::config::step::cover::data::open_cover%]",
"close_cover": "[%key:component::template::config::step::cover::data::close_cover%]",
"stop_cover": "[%key:component::template::config::step::cover::data::stop_cover%]",
"position": "[%key:component::template::config::step::cover::data::position%]",
"set_cover_position": "[%key:component::template::config::step::cover::data::set_cover_position%]"
},
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]",
"state": "[%key:component::template::config::step::cover::data_description::state%]",
"open_cover": "[%key:component::template::config::step::cover::data_description::open_cover%]",
"close_cover": "[%key:component::template::config::step::cover::data_description::close_cover%]",
"stop_cover": "[%key:component::template::config::step::cover::data_description::stop_cover%]",
"position": "[%key:component::template::config::step::cover::data_description::position%]",
"set_cover_position": "[%key:component::template::config::step::cover::data_description::set_cover_position%]"
},
"sections": {
"advanced_options": {
"name": "[%key:component::template::common::advanced_options%]",
"data": {
"availability": "[%key:component::template::common::availability%]"
}
}
},
"title": "[%key:component::template::config::step::cover::title%]"
},
"fan": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
"state": "[%key:component::template::common::state%]",
"turn_off": "[%key:component::template::common::turn_off%]",
"turn_on": "[%key:component::template::common::turn_on%]",
"percentage": "[%key:component::template::config::step::fan::data::percentage%]",
"set_percentage": "[%key:component::template::config::step::fan::data::set_percentage%]",
"speed_count": "[%key:component::template::config::step::fan::data::speed_count%]"
},
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]",
"state": "[%key:component::template::config::step::fan::data_description::state%]",
"turn_off": "[%key:component::template::config::step::fan::data_description::turn_off%]",
"turn_on": "[%key:component::template::config::step::fan::data_description::turn_on%]",
"percentage": "[%key:component::template::config::step::fan::data_description::percentage%]",
"set_percentage": "[%key:component::template::config::step::fan::data_description::set_percentage%]",
"speed_count": "[%key:component::template::config::step::fan::data_description::speed_count%]"
},
"sections": {
"advanced_options": {
"name": "[%key:component::template::common::advanced_options%]",
"data": {
"availability": "[%key:component::template::common::availability%]"
}
}
},
"title": "[%key:component::template::config::step::fan::title%]"
},
"image": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
@ -289,6 +439,33 @@
},
"title": "[%key:component::template::config::step::image::title%]"
},
"light": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
"name": "[%key:common::config_flow::data::name%]",
"state": "[%key:component::template::common::state%]",
"turn_off": "[%key:component::template::common::turn_off%]",
"turn_on": "[%key:component::template::common::turn_on%]",
"level": "[%key:component::template::config::step::light::data::level%]",
"set_level": "[%key:component::template::config::step::light::data::set_level%]",
"hs": "[%key:component::template::config::step::light::data::hs%]",
"set_hs": "[%key:component::template::config::step::light::data::set_hs%]",
"temperature": "[%key:component::template::config::step::light::data::temperature%]",
"set_temperature": "[%key:component::template::config::step::light::data::set_temperature%]"
},
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]"
},
"sections": {
"advanced_options": {
"name": "[%key:component::template::common::advanced_options%]",
"data": {
"availability": "[%key:component::template::common::availability%]"
}
}
},
"title": "[%key:component::template::config::step::light::title%]"
},
"number": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
@ -425,6 +602,20 @@
"update": "[%key:component::button::entity_component::update::name%]"
}
},
"cover_device_class": {
"options": {
"awning": "[%key:component::cover::entity_component::awning::name%]",
"blind": "[%key:component::cover::entity_component::blind::name%]",
"curtain": "[%key:component::cover::entity_component::curtain::name%]",
"damper": "[%key:component::cover::entity_component::damper::name%]",
"door": "[%key:component::cover::entity_component::door::name%]",
"garage": "[%key:component::cover::entity_component::garage::name%]",
"gate": "[%key:component::cover::entity_component::gate::name%]",
"shade": "[%key:component::cover::entity_component::shade::name%]",
"shutter": "[%key:component::cover::entity_component::shutter::name%]",
"window": "[%key:component::cover::entity_component::window::name%]"
}
},
"sensor_device_class": {
"options": {
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",

View File

@ -153,17 +153,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool
# Register known device IDs
device_registry = dr.async_get(hass)
for device in manager.device_map.values():
if not device.status and not device.status_range and not device.function:
# If the device has no status, status_range or function,
# it cannot be supported
LOGGER.info(
"Device %s (%s) has been ignored as it does not provide any"
" standard instructions (status, status_range and function are"
" all empty) - see %s",
device.product_name,
device.id,
"https://github.com/tuya/tuya-device-sharing-sdk/issues/11",
)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, device.id)},

View File

@ -744,86 +744,26 @@
"switch": {
"name": "Switch"
},
"indexed_switch": {
"name": "Switch {index}"
},
"socket": {
"name": "Socket"
},
"indexed_socket": {
"name": "Socket {index}"
},
"radio": {
"name": "Radio"
},
"alarm_1": {
"name": "Alarm 1"
},
"alarm_2": {
"name": "Alarm 2"
},
"alarm_3": {
"name": "Alarm 3"
},
"alarm_4": {
"name": "Alarm 4"
"indexed_alarm": {
"name": "Alarm {index}"
},
"sleep_aid": {
"name": "Sleep aid"
},
"switch_1": {
"name": "Switch 1"
},
"switch_2": {
"name": "Switch 2"
},
"switch_3": {
"name": "Switch 3"
},
"switch_4": {
"name": "Switch 4"
},
"switch_5": {
"name": "Switch 5"
},
"switch_6": {
"name": "Switch 6"
},
"switch_7": {
"name": "Switch 7"
},
"switch_8": {
"name": "Switch 8"
},
"usb_1": {
"name": "USB 1"
},
"usb_2": {
"name": "USB 2"
},
"usb_3": {
"name": "USB 3"
},
"usb_4": {
"name": "USB 4"
},
"usb_5": {
"name": "USB 5"
},
"usb_6": {
"name": "USB 6"
},
"socket_1": {
"name": "Socket 1"
},
"socket_2": {
"name": "Socket 2"
},
"socket_3": {
"name": "Socket 3"
},
"socket_4": {
"name": "Socket 4"
},
"socket_5": {
"name": "Socket 5"
},
"socket_6": {
"name": "Socket 6"
"indexed_usb": {
"name": "USB {index}"
},
"ionizer": {
"name": "Ionizer"

View File

@ -232,35 +232,43 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
"ggq": (
SwitchEntityDescription(
key=DPCode.SWITCH_1,
translation_key="switch_1",
translation_key="indexed_switch",
translation_placeholders={"index": "1"},
),
SwitchEntityDescription(
key=DPCode.SWITCH_2,
translation_key="switch_2",
translation_key="indexed_switch",
translation_placeholders={"index": "2"},
),
SwitchEntityDescription(
key=DPCode.SWITCH_3,
translation_key="switch_3",
translation_key="indexed_switch",
translation_placeholders={"index": "3"},
),
SwitchEntityDescription(
key=DPCode.SWITCH_4,
translation_key="switch_4",
translation_key="indexed_switch",
translation_placeholders={"index": "4"},
),
SwitchEntityDescription(
key=DPCode.SWITCH_5,
translation_key="switch_5",
translation_key="indexed_switch",
translation_placeholders={"index": "5"},
),
SwitchEntityDescription(
key=DPCode.SWITCH_6,
translation_key="switch_6",
translation_key="indexed_switch",
translation_placeholders={"index": "6"},
),
SwitchEntityDescription(
key=DPCode.SWITCH_7,
translation_key="switch_7",
translation_key="indexed_switch",
translation_placeholders={"index": "7"},
),
SwitchEntityDescription(
key=DPCode.SWITCH_8,
translation_key="switch_8",
translation_key="indexed_switch",
translation_placeholders={"index": "8"},
),
),
# Wake Up Light II
@ -272,22 +280,26 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
),
SwitchEntityDescription(
key=DPCode.SWITCH_2,
translation_key="alarm_1",
translation_key="indexed_alarm",
translation_placeholders={"index": "1"},
entity_category=EntityCategory.CONFIG,
),
SwitchEntityDescription(
key=DPCode.SWITCH_3,
translation_key="alarm_2",
translation_key="indexed_alarm",
translation_placeholders={"index": "2"},
entity_category=EntityCategory.CONFIG,
),
SwitchEntityDescription(
key=DPCode.SWITCH_4,
translation_key="alarm_3",
translation_key="indexed_alarm",
translation_placeholders={"index": "3"},
entity_category=EntityCategory.CONFIG,
),
SwitchEntityDescription(
key=DPCode.SWITCH_5,
translation_key="alarm_4",
translation_key="indexed_alarm",
translation_placeholders={"index": "4"},
entity_category=EntityCategory.CONFIG,
),
SwitchEntityDescription(
@ -324,67 +336,81 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
),
SwitchEntityDescription(
key=DPCode.SWITCH_1,
translation_key="switch_1",
translation_key="indexed_switch",
translation_placeholders={"index": "1"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_2,
translation_key="switch_2",
translation_key="indexed_switch",
translation_placeholders={"index": "2"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_3,
translation_key="switch_3",
translation_key="indexed_switch",
translation_placeholders={"index": "3"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_4,
translation_key="switch_4",
translation_key="indexed_switch",
translation_placeholders={"index": "4"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_5,
translation_key="switch_5",
translation_key="indexed_switch",
translation_placeholders={"index": "5"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_6,
translation_key="switch_6",
translation_key="indexed_switch",
translation_placeholders={"index": "6"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_7,
translation_key="switch_7",
translation_key="indexed_switch",
translation_placeholders={"index": "7"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_8,
translation_key="switch_8",
translation_key="indexed_switch",
translation_placeholders={"index": "8"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_USB1,
translation_key="usb_1",
translation_key="indexed_usb",
translation_placeholders={"index": "1"},
),
SwitchEntityDescription(
key=DPCode.SWITCH_USB2,
translation_key="usb_2",
translation_key="indexed_usb",
translation_placeholders={"index": "2"},
),
SwitchEntityDescription(
key=DPCode.SWITCH_USB3,
translation_key="usb_3",
translation_key="indexed_usb",
translation_placeholders={"index": "3"},
),
SwitchEntityDescription(
key=DPCode.SWITCH_USB4,
translation_key="usb_4",
translation_key="indexed_usb",
translation_placeholders={"index": "4"},
),
SwitchEntityDescription(
key=DPCode.SWITCH_USB5,
translation_key="usb_5",
translation_key="indexed_usb",
translation_placeholders={"index": "5"},
),
SwitchEntityDescription(
key=DPCode.SWITCH_USB6,
translation_key="usb_6",
translation_key="indexed_usb",
translation_placeholders={"index": "6"},
),
SwitchEntityDescription(
key=DPCode.SWITCH,
@ -487,57 +513,69 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
),
SwitchEntityDescription(
key=DPCode.SWITCH_1,
translation_key="socket_1",
translation_key="indexed_socket",
translation_placeholders={"index": "1"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_2,
translation_key="socket_2",
translation_key="indexed_socket",
translation_placeholders={"index": "2"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_3,
translation_key="socket_3",
translation_key="indexed_socket",
translation_placeholders={"index": "3"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_4,
translation_key="socket_4",
translation_key="indexed_socket",
translation_placeholders={"index": "4"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_5,
translation_key="socket_5",
translation_key="indexed_socket",
translation_placeholders={"index": "5"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_6,
translation_key="socket_6",
translation_key="indexed_socket",
translation_placeholders={"index": "6"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_USB1,
translation_key="usb_1",
translation_key="indexed_usb",
translation_placeholders={"index": "1"},
),
SwitchEntityDescription(
key=DPCode.SWITCH_USB2,
translation_key="usb_2",
translation_key="indexed_usb",
translation_placeholders={"index": "2"},
),
SwitchEntityDescription(
key=DPCode.SWITCH_USB3,
translation_key="usb_3",
translation_key="indexed_usb",
translation_placeholders={"index": "3"},
),
SwitchEntityDescription(
key=DPCode.SWITCH_USB4,
translation_key="usb_4",
translation_key="indexed_usb",
translation_placeholders={"index": "4"},
),
SwitchEntityDescription(
key=DPCode.SWITCH_USB5,
translation_key="usb_5",
translation_key="indexed_usb",
translation_placeholders={"index": "5"},
),
SwitchEntityDescription(
key=DPCode.SWITCH_USB6,
translation_key="usb_6",
translation_key="indexed_usb",
translation_placeholders={"index": "6"},
),
SwitchEntityDescription(
key=DPCode.SWITCH,
@ -698,22 +736,26 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
"tdq": (
SwitchEntityDescription(
key=DPCode.SWITCH_1,
translation_key="switch_1",
translation_key="indexed_switch",
translation_placeholders={"index": "1"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_2,
translation_key="switch_2",
translation_key="indexed_switch",
translation_placeholders={"index": "2"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_3,
translation_key="switch_3",
translation_key="indexed_switch",
translation_placeholders={"index": "3"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_4,
translation_key="switch_4",
translation_key="indexed_switch",
translation_placeholders={"index": "4"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
@ -746,12 +788,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
"wkcz": (
SwitchEntityDescription(
key=DPCode.SWITCH_1,
translation_key="switch_1",
translation_key="indexed_switch",
translation_placeholders={"index": "1"},
device_class=SwitchDeviceClass.OUTLET,
),
SwitchEntityDescription(
key=DPCode.SWITCH_2,
translation_key="switch_2",
translation_key="indexed_switch",
translation_placeholders={"index": "2"},
device_class=SwitchDeviceClass.OUTLET,
),
),

View File

@ -6,7 +6,7 @@ from pythonkuma.update import UpdateChecker
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.hass_dict import HassKey
@ -19,7 +19,6 @@ from .coordinator import (
_PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.UPDATE]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
UPTIME_KUMA_KEY: HassKey[UptimeKumaSoftwareUpdateCoordinator] = HassKey(DOMAIN)
@ -43,6 +42,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -
return True
async def async_remove_config_entry_device(
hass: HomeAssistant,
config_entry: UptimeKumaConfigEntry,
device_entry: dr.DeviceEntry,
) -> bool:
"""Remove a stale device from a config entry."""
def normalize_key(id: str) -> int | str:
key = id.removeprefix(f"{config_entry.entry_id}_")
return int(key) if key.isnumeric() else key
return not any(
identifier
for identifier in device_entry.identifiers
if identifier[0] == DOMAIN
and (
identifier[1] == config_entry.entry_id
or normalize_key(identifier[1]) in config_entry.runtime_data.data
)
)
async def async_unload_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)

View File

@ -23,6 +23,7 @@ from homeassistant.helpers.selector import (
TextSelectorConfig,
TextSelectorType,
)
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from .const import DOMAIN
@ -47,7 +48,7 @@ async def validate_connection(
hass: HomeAssistant,
url: URL | str,
verify_ssl: bool,
api_key: str,
api_key: str | None,
) -> dict[str, str]:
"""Validate Uptime Kuma connectivity."""
errors: dict[str, str] = {}
@ -69,6 +70,8 @@ async def validate_connection(
class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Uptime Kuma."""
_hassio_discovery: HassioServiceInfo | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@ -168,3 +171,61 @@ class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN):
),
errors=errors,
)
async def async_step_hassio(
self, discovery_info: HassioServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for Uptime Kuma add-on.
This flow is triggered by the discovery component.
"""
self._async_abort_entries_match({CONF_URL: discovery_info.config[CONF_URL]})
await self.async_set_unique_id(discovery_info.uuid)
self._abort_if_unique_id_configured(
updates={CONF_URL: discovery_info.config[CONF_URL]}
)
self._hassio_discovery = discovery_info
return await self.async_step_hassio_confirm()
async def async_step_hassio_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm Supervisor discovery."""
assert self._hassio_discovery
errors: dict[str, str] = {}
api_key = user_input[CONF_API_KEY] if user_input else None
if not (
errors := await validate_connection(
self.hass,
self._hassio_discovery.config[CONF_URL],
True,
api_key,
)
):
if user_input is None:
self._set_confirm_only()
return self.async_show_form(
step_id="hassio_confirm",
description_placeholders={
"addon": self._hassio_discovery.config["addon"]
},
)
return self.async_create_entry(
title=self._hassio_discovery.slug,
data={
CONF_URL: self._hassio_discovery.config[CONF_URL],
CONF_VERIFY_SSL: True,
CONF_API_KEY: api_key,
},
)
return self.async_show_form(
step_id="hassio_confirm",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_REAUTH_DATA_SCHEMA, suggested_values=user_input
),
description_placeholders={"addon": self._hassio_discovery.config["addon"]},
errors=errors if user_input is not None else None,
)

View File

@ -44,12 +44,10 @@ rules:
# Gold
devices: done
diagnostics: done
discovery-update-info:
status: exempt
comment: is not locally discoverable
discovery-update-info: done
discovery:
status: exempt
comment: is not locally discoverable
status: done
comment: hassio addon supports discovery, other installation methods are not discoverable
docs-data-update: done
docs-examples: todo
docs-known-limitations: done

View File

@ -36,6 +36,16 @@
"verify_ssl": "[%key:component::uptime_kuma::config::step::user::data_description::verify_ssl%]",
"api_key": "[%key:component::uptime_kuma::config::step::user::data_description::api_key%]"
}
},
"hassio_confirm": {
"title": "Uptime Kuma via Home Assistant add-on",
"description": "Do you want to configure Home Assistant to connect to the Uptime Kuma service provided by the add-on: {addon}?",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::uptime_kuma::config::step::user::data_description::api_key%]"
}
}
},
"error": {

View File

@ -34,6 +34,60 @@
"lightning_strike_last_epoch": {
"default": "mdi:lightning-bolt"
},
"precip_accum_local_day": {
"default": "mdi:umbrella-closed",
"range": {
"0.01": "mdi:umbrella"
}
},
"precip_accum_local_day_final": {
"default": "mdi:umbrella-closed",
"range": {
"0.01": "mdi:umbrella"
}
},
"precip_accum_local_yesterday": {
"default": "mdi:umbrella-closed",
"range": {
"0.01": "mdi:umbrella"
}
},
"precip_accum_local_yesterday_final": {
"default": "mdi:umbrella-closed",
"range": {
"0.01": "mdi:umbrella"
}
},
"precip_minutes_local_day": {
"default": "mdi:umbrella-closed",
"range": {
"1": "mdi:umbrella"
}
},
"precip_minutes_local_yesterday": {
"default": "mdi:umbrella-closed",
"range": {
"1": "mdi:umbrella"
}
},
"precip_minutes_local_yesterday_final": {
"default": "mdi:umbrella-closed",
"range": {
"1": "mdi:umbrella"
}
},
"precip_analysis_type_yesterday": {
"default": "mdi:radar",
"state": {
"rain": "mdi:weather-rainy",
"snow": "mdi:weather-snowy",
"rain_snow": "mdi:weather-snoy-rainy",
"lightning": "mdi:weather-lightning-rainy"
}
},
"sea_level_pressure": {
"default": "mdi:gauge"
},
@ -49,6 +103,7 @@
"wind_chill": {
"default": "mdi:snowflake-thermometer"
},
"wind_direction": {
"default": "mdi:compass",
"range": {

View File

@ -39,6 +39,14 @@ from .const import DOMAIN
from .coordinator import WeatherFlowObservationCoordinator, WeatherFlowWindCoordinator
from .entity import WeatherFlowCloudEntity
PRECIPITATION_TYPE = {
0: "none",
1: "rain",
2: "snow",
3: "sleet",
4: "storm",
}
@dataclass(frozen=True, kw_only=True)
class WeatherFlowCloudSensorEntityDescription(
@ -223,6 +231,81 @@ WF_SENSORS: tuple[WeatherFlowCloudSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=3,
),
# Rain Sensors
WeatherFlowCloudSensorEntityDescription(
key="precip_accum_last_1hr",
translation_key="precip_accum_last_1hr",
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.precip_accum_last_1hr,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
),
WeatherFlowCloudSensorEntityDescription(
key="precip_accum_local_day",
translation_key="precip_accum_local_day",
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.precip_accum_local_day,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
),
WeatherFlowCloudSensorEntityDescription(
key="precip_accum_local_day_final",
translation_key="precip_accum_local_day_final",
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.precip_accum_local_day_final,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
),
WeatherFlowCloudSensorEntityDescription(
key="precip_accum_local_yesterday",
translation_key="precip_accum_local_yesterday",
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.precip_accum_local_yesterday,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
),
WeatherFlowCloudSensorEntityDescription(
key="precip_accum_local_yesterday_final",
translation_key="precip_accum_local_yesterday_final",
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.precip_accum_local_yesterday_final,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
),
WeatherFlowCloudSensorEntityDescription(
key="precip_analysis_type_yesterday",
translation_key="precip_analysis_type_yesterday",
device_class=SensorDeviceClass.ENUM,
options=["none", "rain", "snow", "sleet", "storm"],
suggested_display_precision=1,
value_fn=lambda data: PRECIPITATION_TYPE.get(
data.precip_analysis_type_yesterday
),
),
WeatherFlowCloudSensorEntityDescription(
key="precip_minutes_local_day",
translation_key="precip_minutes_local_day",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTime.MINUTES,
suggested_display_precision=1,
value_fn=lambda data: data.precip_minutes_local_day,
),
WeatherFlowCloudSensorEntityDescription(
key="precip_minutes_local_yesterday",
translation_key="precip_minutes_local_yesterday",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTime.MINUTES,
suggested_display_precision=1,
value_fn=lambda data: data.precip_minutes_local_yesterday,
),
WeatherFlowCloudSensorEntityDescription(
key="precip_minutes_local_yesterday_final",
translation_key="precip_minutes_local_yesterday_final",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTime.MINUTES,
suggested_display_precision=1,
value_fn=lambda data: data.precip_minutes_local_yesterday_final,
),
# Lightning Sensors
WeatherFlowCloudSensorEntityDescription(
key="lightning_strike_count",

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