mirror of
https://github.com/home-assistant/core.git
synced 2025-07-31 17:18:23 +00:00
Merge branch 'dev' into hassfest_condition_target
This commit is contained in:
commit
4d9fe3a439
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@ -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"
|
||||
|
@ -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
2
CODEOWNERS
generated
@ -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
|
||||
|
5
homeassistant/brands/frient.json
Normal file
5
homeassistant/brands/frient.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "frient",
|
||||
"name": "Frient",
|
||||
"iot_standards": ["zigbee"]
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "third_reality",
|
||||
"name": "Third Reality",
|
||||
"iot_standards": ["zigbee"]
|
||||
"iot_standards": ["matter", "zigbee"]
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "ubiquiti",
|
||||
"name": "Ubiquiti",
|
||||
"integrations": ["unifi", "unifi_direct", "unifiled", "unifiprotect"]
|
||||
"integrations": ["airos", "unifi", "unifi_direct", "unifiled", "unifiprotect"]
|
||||
}
|
||||
|
42
homeassistant/components/airos/__init__.py
Normal file
42
homeassistant/components/airos/__init__.py
Normal 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)
|
82
homeassistant/components/airos/config_flow.py
Normal file
82
homeassistant/components/airos/config_flow.py
Normal 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
|
||||
)
|
9
homeassistant/components/airos/const.py
Normal file
9
homeassistant/components/airos/const.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""Constants for the Ubiquiti airOS integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
DOMAIN = "airos"
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
MANUFACTURER = "Ubiquiti"
|
66
homeassistant/components/airos/coordinator.py
Normal file
66
homeassistant/components/airos/coordinator.py
Normal 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
|
36
homeassistant/components/airos/entity.py
Normal file
36
homeassistant/components/airos/entity.py
Normal 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,
|
||||
)
|
10
homeassistant/components/airos/manifest.json
Normal file
10
homeassistant/components/airos/manifest.json
Normal 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"]
|
||||
}
|
72
homeassistant/components/airos/quality_scale.yaml
Normal file
72
homeassistant/components/airos/quality_scale.yaml
Normal 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
|
152
homeassistant/components/airos/sensor.py
Normal file
152
homeassistant/components/airos/sensor.py
Normal 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)
|
87
homeassistant/components/airos/strings.json
Normal file
87
homeassistant/components/airos/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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],
|
||||
|
@ -38,5 +38,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"send_sound": {
|
||||
"service": "mdi:cast-audio"
|
||||
},
|
||||
"send_text_command": {
|
||||
"service": "mdi:microphone-message"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioamazondevices==3.5.1"]
|
||||
"requirements": ["aioamazondevices==4.0.0"]
|
||||
}
|
||||
|
@ -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
|
||||
|
121
homeassistant/components/alexa_devices/services.py
Normal file
121
homeassistant/components/alexa_devices/services.py
Normal 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)
|
504
homeassistant/components/alexa_devices/services.yaml
Normal file
504
homeassistant/components/alexa_devices/services.yaml
Normal 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
|
@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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."
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -7,5 +7,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["datadog"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["datadog==0.15.0"]
|
||||
"requirements": ["datadog==0.52.0"]
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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."
|
||||
|
@ -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(
|
||||
|
94
homeassistant/components/esphome/encryption_key_storage.py
Normal file
94
homeassistant/components/esphome/encryption_key_storage.py
Normal 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
|
@ -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."""
|
||||
|
@ -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"
|
||||
],
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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(),
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
@ -19,6 +19,7 @@ type HusqvarnaConfigEntry = ConfigEntry[HusqvarnaCoordinator]
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.LAWN_MOWER,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
|
@ -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():
|
||||
|
@ -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
|
||||
|
51
homeassistant/components/husqvarna_automower_ble/sensor.py
Normal file
51
homeassistant/components/husqvarna_automower_ble/sensor.py
Normal 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]
|
@ -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%]"
|
||||
}
|
||||
|
@ -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")
|
||||
|
@ -30,6 +30,9 @@
|
||||
"get_recipe": {
|
||||
"service": "mdi:map"
|
||||
},
|
||||
"get_recipes": {
|
||||
"service": "mdi:book-open-page-variant"
|
||||
},
|
||||
"import_recipe": {
|
||||
"service": "mdi:map-search"
|
||||
},
|
||||
|
@ -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,
|
||||
|
@ -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:
|
||||
|
@ -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",
|
||||
|
@ -110,6 +110,9 @@
|
||||
},
|
||||
"set_program": {
|
||||
"service": "mdi:arrow-right-circle-outline"
|
||||
},
|
||||
"set_program_oven": {
|
||||
"service": "mdi:arrow-right-circle-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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: "
|
||||
|
@ -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 hasn’t 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": {
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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]
|
||||
|
||||
|
75
homeassistant/components/open_router/ai_task.py
Normal file
75
homeassistant/components/open_router/ai_task.py
Normal 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,
|
||||
)
|
@ -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
|
||||
),
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
@ -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,
|
||||
|
@ -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(
|
||||
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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))
|
||||
)
|
||||
|
@ -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 {}
|
||||
)
|
||||
|
@ -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": {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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": {
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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."""
|
||||
|
@ -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
|
||||
|
153
homeassistant/components/switchbot_cloud/light.py
Normal file
153
homeassistant/components/switchbot_cloud/light.py
Normal 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)
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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."""
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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%]",
|
||||
|
@ -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)},
|
||||
|
@ -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"
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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": {
|
||||
|
@ -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": {
|
||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user