Merge branch 'dev_target_triggers_conditions' of github.com:home-assistant/core into light_target_condition

This commit is contained in:
abmantis
2025-08-04 17:35:01 +01:00
621 changed files with 42869 additions and 5634 deletions

View File

@@ -40,7 +40,7 @@ env:
CACHE_VERSION: 4
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.8"
HA_SHORT_VERSION: "2025.9"
DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13']"
# 10.3 is the oldest supported version

View File

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

View File

@@ -231,7 +231,7 @@ jobs:
- name: Detect duplicates using AI
id: ai_detection
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
uses: actions/ai-inference@v1.2.3
uses: actions/ai-inference@v1.2.4
with:
model: openai/gpt-4o
system-prompt: |

View File

@@ -57,7 +57,7 @@ jobs:
- name: Detect language using AI
id: ai_language_detection
if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/ai-inference@v1.2.3
uses: actions/ai-inference@v1.2.4
with:
model: openai/gpt-4o-mini
system-prompt: |

View File

@@ -159,7 +159,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@2025.03.0
uses: home-assistant/wheels@2025.07.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -219,7 +219,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@2025.03.0
uses: home-assistant/wheels@2025.07.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2

View File

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

2
CODEOWNERS generated
View File

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

View File

@@ -33,7 +33,10 @@ class AuthFlowContext(FlowContext, total=False):
redirect_uri: str
AuthFlowResult = FlowResult[AuthFlowContext, tuple[str, str]]
class AuthFlowResult(FlowResult[AuthFlowContext, tuple[str, str]], total=False):
"""Typed result dict for auth flow."""
result: Credentials # Only present if type is CREATE_ENTRY
@attr.s(slots=True)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
"""Diagnostics support for airOS."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from .coordinator import AirOSConfigEntry
IP_REDACT = ["addr", "ipaddr", "ip6addr", "lastip"] # IP related
HW_REDACT = ["apmac", "hwaddr", "mac"] # MAC address
TO_REDACT_HA = [CONF_HOST, CONF_PASSWORD]
TO_REDACT_AIROS = [
"hostname", # Prevent leaking device naming
"essid", # Network SSID
"lat", # GPS latitude to prevent exposing location data.
"lon", # GPS longitude to prevent exposing location data.
*HW_REDACT,
*IP_REDACT,
]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AirOSConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"entry_data": async_redact_data(entry.data, TO_REDACT_HA),
"data": async_redact_data(entry.runtime_data.data.to_dict(), TO_REDACT_AIROS),
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,21 +7,18 @@ import logging
from airthings import Airthings
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_SECRET
from .coordinator import AirthingsDataUpdateCoordinator
from .coordinator import AirthingsConfigEntry, AirthingsDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
SCAN_INTERVAL = timedelta(minutes=6)
type AirthingsConfigEntry = ConfigEntry[AirthingsDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) -> bool:
"""Set up Airthings from a config entry."""
@@ -31,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) ->
async_get_clientsession(hass),
)
coordinator = AirthingsDataUpdateCoordinator(hass, airthings)
coordinator = AirthingsDataUpdateCoordinator(hass, airthings, entry)
await coordinator.async_config_entry_first_refresh()

View File

@@ -5,6 +5,7 @@ import logging
from airthings import Airthings, AirthingsDevice, AirthingsError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -13,15 +14,23 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(minutes=6)
type AirthingsConfigEntry = ConfigEntry[AirthingsDataUpdateCoordinator]
class AirthingsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AirthingsDevice]]):
"""Coordinator for Airthings data updates."""
def __init__(self, hass: HomeAssistant, airthings: Airthings) -> None:
def __init__(
self,
hass: HomeAssistant,
airthings: Airthings,
config_entry: AirthingsConfigEntry,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_method=self._update_method,
update_interval=SCAN_INTERVAL,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ rules:
comment: There are a ton of mac address ranges in use, but also by kindles which are not supported by this integration
docs-data-update: done
docs-examples: done
docs-known-limitations: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
@@ -70,5 +70,5 @@ rules:
# Platinum
async-dependency: done
inject-websession: todo
inject-websession: done
strict-typing: done

View File

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

View File

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

View File

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

View File

@@ -430,7 +430,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
"model": device.model,
"sw_version": device.sw_version,
"hw_version": device.hw_version,
"has_suggested_area": device.suggested_area is not None,
"has_configuration_url": device.configuration_url is not None,
"via_device": None,
}

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from datetime import datetime, timedelta
import logging
from typing import Any
from typing import TYPE_CHECKING, Any
from pyasuswrt import AsusWrtError
@@ -40,6 +40,9 @@ from .const import (
SENSORS_CONNECTED_DEVICE,
)
if TYPE_CHECKING:
from . import AsusWrtConfigEntry
CONF_REQ_RELOAD = [CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP]
SCAN_INTERVAL = timedelta(seconds=30)
@@ -52,10 +55,13 @@ _LOGGER = logging.getLogger(__name__)
class AsusWrtSensorDataHandler:
"""Data handler for AsusWrt sensor."""
def __init__(self, hass: HomeAssistant, api: AsusWrtBridge) -> None:
def __init__(
self, hass: HomeAssistant, api: AsusWrtBridge, entry: AsusWrtConfigEntry
) -> None:
"""Initialize a AsusWrt sensor data handler."""
self._hass = hass
self._api = api
self._entry = entry
self._connected_devices = 0
async def _get_connected_devices(self) -> dict[str, int]:
@@ -91,6 +97,7 @@ class AsusWrtSensorDataHandler:
update_method=method,
# Polling interval. Will only be polled if there are subscribers.
update_interval=SCAN_INTERVAL if should_poll else None,
config_entry=self._entry,
)
await coordinator.async_refresh()
@@ -321,7 +328,9 @@ class AsusWrtRouter:
if self._sensors_data_handler:
return
self._sensors_data_handler = AsusWrtSensorDataHandler(self.hass, self._api)
self._sensors_data_handler = AsusWrtSensorDataHandler(
self.hass, self._api, self._entry
)
self._sensors_data_handler.update_device_count(self._connected_devices)
sensors_types = await self._api.async_get_available_sensors()

View File

@@ -28,5 +28,5 @@
"documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==8.10.0", "yalexs-ble==3.1.0"]
"requirements": ["yalexs==8.10.0", "yalexs-ble==3.1.2"]
}

View File

@@ -268,7 +268,7 @@ class LoginFlowBaseView(HomeAssistantView):
result.pop("data")
result.pop("context")
result_obj: Credentials = result.pop("result")
result_obj = result.pop("result")
# Result can be None if credential was never linked to a user before.
user = await hass.auth.async_get_user_by_credentials(result_obj)
@@ -281,7 +281,8 @@ class LoginFlowBaseView(HomeAssistantView):
)
process_success_login(request)
result["result"] = self._store_result(client_id, result_obj)
# We overwrite the Credentials object with the string code to retrieve it.
result["result"] = self._store_result(client_id, result_obj) # type: ignore[typeddict-item]
return self.json(result)

View File

@@ -1119,7 +1119,7 @@ class BackupManager:
)
if unavailable_agents:
LOGGER.warning(
"Backup agents %s are not available, will backupp to %s",
"Backup agents %s are not available, will backup to %s",
unavailable_agents,
available_agents,
)

View File

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

View File

@@ -20,7 +20,7 @@
"bluetooth-adapters==2.0.0",
"bluetooth-auto-recovery==1.5.2",
"bluetooth-data-tools==1.28.2",
"dbus-fast==2.44.2",
"dbus-fast==2.44.3",
"habluetooth==4.0.1"
]
}

View File

@@ -64,6 +64,7 @@ class BroadlinkUpdateManager(ABC, Generic[_ApiT]):
device.hass,
_LOGGER,
name=f"{device.name} ({device.api.model} at {device.api.host[0]})",
config_entry=device.config,
update_method=self.async_update,
update_interval=self.SCAN_INTERVAL,
)

View File

@@ -2,9 +2,10 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from bsblan import BSBLAN, BSBLANConfig, BSBLANError
from bsblan import BSBLAN, BSBLANAuthError, BSBLANConfig, BSBLANError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -45,7 +46,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
self.username = user_input.get(CONF_USERNAME)
self.password = user_input.get(CONF_PASSWORD)
return await self._validate_and_create()
return await self._validate_and_create(user_input)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
@@ -128,14 +129,29 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
self.username = user_input.get(CONF_USERNAME)
self.password = user_input.get(CONF_PASSWORD)
return await self._validate_and_create(is_discovery=True)
return await self._validate_and_create(user_input, is_discovery=True)
async def _validate_and_create(
self, is_discovery: bool = False
self, user_input: dict[str, Any], is_discovery: bool = False
) -> ConfigFlowResult:
"""Validate device connection and create entry."""
try:
await self._get_bsblan_info(is_discovery=is_discovery)
await self._get_bsblan_info()
except BSBLANAuthError:
if is_discovery:
return self.async_show_form(
step_id="discovery_confirm",
data_schema=vol.Schema(
{
vol.Optional(CONF_PASSKEY): str,
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
}
),
errors={"base": "invalid_auth"},
description_placeholders={"host": str(self.host)},
)
return self._show_setup_form({"base": "invalid_auth"}, user_input)
except BSBLANError:
if is_discovery:
return self.async_show_form(
@@ -154,18 +170,137 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
return self._async_create_entry()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauth flow."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth confirmation flow."""
existing_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
assert existing_entry
if user_input is None:
# Preserve existing values as defaults
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=existing_entry.data.get(
CONF_PASSKEY, vol.UNDEFINED
),
): str,
vol.Optional(
CONF_USERNAME,
default=existing_entry.data.get(
CONF_USERNAME, vol.UNDEFINED
),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
)
# Combine existing data with the user's new input for validation.
# This correctly handles adding, changing, and clearing credentials.
config_data = existing_entry.data.copy()
config_data.update(user_input)
self.host = config_data[CONF_HOST]
self.port = config_data[CONF_PORT]
self.passkey = config_data.get(CONF_PASSKEY)
self.username = config_data.get(CONF_USERNAME)
self.password = config_data.get(CONF_PASSWORD)
try:
await self._get_bsblan_info(raise_on_progress=False, is_reauth=True)
except BSBLANAuthError:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
): str,
vol.Optional(
CONF_USERNAME,
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
errors={"base": "invalid_auth"},
)
except BSBLANError:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
): str,
vol.Optional(
CONF_USERNAME,
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
errors={"base": "cannot_connect"},
)
# Update only the fields that were provided by the user
return self.async_update_reload_and_abort(
existing_entry, data_updates=user_input, reason="reauth_successful"
)
@callback
def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult:
def _show_setup_form(
self, errors: dict | None = None, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show the setup form to the user."""
# Preserve user input if provided, otherwise use defaults
defaults = user_input or {}
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
vol.Optional(CONF_PASSKEY): str,
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
vol.Required(
CONF_HOST, default=defaults.get(CONF_HOST, vol.UNDEFINED)
): str,
vol.Optional(
CONF_PORT, default=defaults.get(CONF_PORT, DEFAULT_PORT)
): int,
vol.Optional(
CONF_PASSKEY, default=defaults.get(CONF_PASSKEY, vol.UNDEFINED)
): str,
vol.Optional(
CONF_USERNAME,
default=defaults.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=defaults.get(CONF_PASSWORD, vol.UNDEFINED),
): str,
}
),
errors=errors or {},
@@ -186,7 +321,9 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def _get_bsblan_info(
self, raise_on_progress: bool = True, is_discovery: bool = False
self,
raise_on_progress: bool = True,
is_reauth: bool = False,
) -> None:
"""Get device information from a BSBLAN device."""
config = BSBLANConfig(
@@ -209,11 +346,13 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
format_mac(self.mac), raise_on_progress=raise_on_progress
)
# Always allow updating host/port for both user and discovery flows
# This ensures connectivity is maintained when devices change IP addresses
self._abort_if_unique_id_configured(
updates={
CONF_HOST: self.host,
CONF_PORT: self.port,
}
)
# Skip unique_id configuration check during reauth to prevent "already_configured" abort
if not is_reauth:
# Always allow updating host/port for both user and discovery flows
# This ensures connectivity is maintained when devices change IP addresses
self._abort_if_unique_id_configured(
updates={
CONF_HOST: self.host,
CONF_PORT: self.port,
}
)

View File

@@ -4,11 +4,19 @@ from dataclasses import dataclass
from datetime import timedelta
from random import randint
from bsblan import BSBLAN, BSBLANConnectionError, HotWaterState, Sensor, State
from bsblan import (
BSBLAN,
BSBLANAuthError,
BSBLANConnectionError,
HotWaterState,
Sensor,
State,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
@@ -62,6 +70,10 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]):
state = await self.client.state()
sensor = await self.client.sensor()
dhw = await self.client.hot_water_state()
except BSBLANAuthError as err:
raise ConfigEntryAuthFailed(
"Authentication failed for BSB-Lan device"
) from err
except BSBLANConnectionError as err:
host = self.config_entry.data[CONF_HOST] if self.config_entry else "unknown"
raise UpdateFailed(

View File

@@ -33,14 +33,25 @@
"username": "[%key:component::bsblan::config::step::user::data_description::username%]",
"password": "[%key:component::bsblan::config::step::user::data_description::password%]"
}
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The BSB-Lan integration needs to re-authenticate with {name}",
"data": {
"passkey": "[%key:component::bsblan::config::step::user::data::passkey%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"exceptions": {

View File

@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==0.110.0"],
"requirements": ["hass-nabucasa==0.110.1"],
"single_config_entry": true
}

View File

@@ -4,11 +4,13 @@ from __future__ import annotations
import asyncio
import logging
from typing import Any
from aiohttp.client_exceptions import ClientError
from hass_nabucasa import Cloud, cloud_api
from hass_nabucasa.payments_api import PaymentsApiError, SubscriptionInfo
from hass_nabucasa import (
Cloud,
MigratePaypalAgreementInfo,
PaymentsApiError,
SubscriptionInfo,
)
from .client import CloudClient
from .const import REQUEST_TIMEOUT
@@ -29,17 +31,17 @@ async def async_subscription_info(cloud: Cloud[CloudClient]) -> SubscriptionInfo
async def async_migrate_paypal_agreement(
cloud: Cloud[CloudClient],
) -> dict[str, Any] | None:
) -> MigratePaypalAgreementInfo | None:
"""Migrate a paypal agreement from legacy."""
try:
async with asyncio.timeout(REQUEST_TIMEOUT):
return await cloud_api.async_migrate_paypal_agreement(cloud)
return await cloud.payments.migrate_paypal_agreement()
except TimeoutError:
_LOGGER.error(
"A timeout of %s was reached while trying to start agreement migration",
REQUEST_TIMEOUT,
)
except ClientError as exception:
except PaymentsApiError as exception:
_LOGGER.error("Failed to start agreement migration - %s", exception)
return None

View File

@@ -146,8 +146,9 @@ def _prepare_config_flow_result_json(
return prepare_result_json(result)
data = result.copy()
entry: config_entries.ConfigEntry = data["result"]
data["result"] = entry.as_json_fragment
entry: config_entries.ConfigEntry = data["result"] # type: ignore[typeddict-item]
# We overwrite the ConfigEntry object with its json representation.
data["result"] = entry.as_json_fragment # type: ignore[typeddict-unknown-key]
data.pop("data")
data.pop("context")
return data

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/denonavr",
"iot_class": "local_push",
"loggers": ["denonavr"],
"requirements": ["denonavr==1.1.1"],
"requirements": ["denonavr==1.1.2"],
"ssdp": [
{
"manufacturer": "Denon",

View File

@@ -16,7 +16,7 @@
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.2.0",
"aiodiscover==2.7.0",
"aiodiscover==2.7.1",
"cached-ipaddress==0.10.0"
]
}

View File

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

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/emoncms",
"iot_class": "local_polling",
"requirements": ["pyemoncms==0.1.1"]
"requirements": ["pyemoncms==0.1.2"]
}

View File

@@ -12,12 +12,26 @@
},
"data_description": {
"url": "Server URL starting with the protocol (http or https)",
"api_key": "Your 32 bits API key"
"api_key": "Your 32 bits API key",
"sync_mode": "Pick your feeds manually (default) or synchronize them at once"
}
},
"choose_feeds": {
"data": {
"include_only_feed_id": "Choose feeds to include"
},
"data_description": {
"include_only_feed_id": "Pick the feeds you want to synchronize"
}
},
"reconfigure": {
"data": {
"url": "[%key:common::config_flow::data::url%]",
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"url": "[%key:component::emoncms::config::step::user::data_description::url%]",
"api_key": "[%key:component::emoncms::config::step::user::data_description::api_key%]"
}
}
},
@@ -30,8 +44,8 @@
"selector": {
"sync_mode": {
"options": {
"auto": "Synchronize all available Feeds",
"manual": "Select which Feeds to synchronize"
"auto": "Synchronize all available feeds",
"manual": "Select which feeds to synchronize"
}
}
},
@@ -89,6 +103,9 @@
"init": {
"data": {
"include_only_feed_id": "[%key:component::emoncms::config::step::choose_feeds::data::include_only_feed_id%]"
},
"data_description": {
"include_only_feed_id": "[%key:component::emoncms::config::step::choose_feeds::data_description::include_only_feed_id%]"
}
}
}

View File

@@ -116,6 +116,9 @@ async def async_get_config_entry_diagnostics(
entities.append({"entity": entity_dict, "state": state_dict})
device_dict = asdict(device)
device_dict.pop("_cache", None)
# This can be removed when suggested_area is removed from DeviceEntry
device_dict.pop("_suggested_area")
device_dict.pop("is_new", None)
device_entities.append({"device": device_dict, "entities": entities})
# remove envoy serial

View File

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

View File

@@ -51,6 +51,7 @@ from .const import (
DOMAIN,
)
from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info
from .encryption_key_storage import async_get_encryption_key_storage
from .entry_data import ESPHomeConfigEntry
from .manager import async_replace_device
@@ -159,7 +160,10 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle reauthorization flow."""
errors = {}
if await self._retrieve_encryption_key_from_dashboard():
if (
await self._retrieve_encryption_key_from_storage()
or await self._retrieve_encryption_key_from_dashboard()
):
error = await self.fetch_device_info()
if error is None:
return await self._async_authenticate_or_add()
@@ -226,9 +230,12 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
response = await self.fetch_device_info()
self._noise_psk = None
# Try to retrieve an existing key from dashboard or storage.
if (
self._device_name
and await self._retrieve_encryption_key_from_dashboard()
) or (
self._device_mac and await self._retrieve_encryption_key_from_storage()
):
response = await self.fetch_device_info()
@@ -284,6 +291,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._name = discovery_info.properties.get("friendly_name", device_name)
self._host = discovery_info.host
self._port = discovery_info.port
self._device_mac = mac_address
self._noise_required = bool(discovery_info.properties.get("api_encryption"))
# Check if already configured
@@ -308,10 +316,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
# Don't call _fetch_device_info() for ignored entries
raise AbortFlow("already_configured")
configured_host: str | None = entry.data.get(CONF_HOST)
configured_port: int | None = entry.data.get(CONF_PORT)
if configured_host == host and configured_port == port:
configured_port: int = entry.data.get(CONF_PORT, DEFAULT_PORT)
# When port is None (from DHCP discovery), only compare hosts
if configured_host == host and (port is None or configured_port == port):
# Don't probe to verify the mac is correct since
# the host and port matches.
# the host matches (and port matches if provided).
raise AbortFlow("already_configured")
configured_psk: str | None = entry.data.get(CONF_NOISE_PSK)
await self._fetch_device_info(host, port or configured_port, configured_psk)
@@ -772,6 +781,26 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._noise_psk = noise_psk
return True
async def _retrieve_encryption_key_from_storage(self) -> bool:
"""Try to retrieve the encryption key from storage.
Return boolean if a key was retrieved.
"""
# Try to get MAC address from current flow state or reauth entry
mac_address = self._device_mac
if mac_address is None and self._reauth_entry is not None:
# In reauth flow, get MAC from the existing entry's unique_id
mac_address = self._reauth_entry.unique_id
assert mac_address is not None
storage = await async_get_encryption_key_storage(self.hass)
if stored_key := await storage.async_get_key(mac_address):
self._noise_psk = stored_key
return True
return False
@staticmethod
@callback
def async_get_options_flow(

View File

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

View File

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

View File

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

View File

@@ -66,6 +66,26 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
key="last_alarm_type_name",
translation_key="last_alarm_type_name",
),
"Record_Mode": SensorEntityDescription(
key="Record_Mode",
translation_key="record_mode",
entity_registry_enabled_default=False,
),
"battery_camera_work_mode": SensorEntityDescription(
key="battery_camera_work_mode",
translation_key="battery_camera_work_mode",
entity_registry_enabled_default=False,
),
"powerStatus": SensorEntityDescription(
key="powerStatus",
translation_key="power_status",
entity_registry_enabled_default=False,
),
"OnlineStatus": SensorEntityDescription(
key="OnlineStatus",
translation_key="online_status",
entity_registry_enabled_default=False,
),
}
@@ -76,16 +96,26 @@ async def async_setup_entry(
) -> None:
"""Set up EZVIZ sensors based on a config entry."""
coordinator = entry.runtime_data
entities: list[EzvizSensor] = []
async_add_entities(
[
for camera, sensors in coordinator.data.items():
entities.extend(
EzvizSensor(coordinator, camera, sensor)
for camera in coordinator.data
for sensor, value in coordinator.data[camera].items()
if sensor in SENSOR_TYPES
if value is not None
]
)
for sensor, value in sensors.items()
if sensor in SENSOR_TYPES and value is not None
)
optionals = sensors.get("optionals", {})
entities.extend(
EzvizSensor(coordinator, camera, optional_key)
for optional_key in ("powerStatus", "OnlineStatus")
if optional_key in optionals
)
if "mode" in optionals.get("Record_Mode", {}):
entities.append(EzvizSensor(coordinator, camera, "mode"))
async_add_entities(entities)
class EzvizSensor(EzvizEntity, SensorEntity):

View File

@@ -147,6 +147,18 @@
},
"last_alarm_type_name": {
"name": "Last alarm type name"
},
"record_mode": {
"name": "Record mode"
},
"battery_camera_work_mode": {
"name": "Battery work mode"
},
"power_status": {
"name": "Power status"
},
"online_status": {
"name": "Online status"
}
},
"switch": {

View File

@@ -106,6 +106,7 @@ class FroniusSolarNet:
solar_net=self,
logger=_LOGGER,
name=f"{DOMAIN}_logger_{self.host}",
config_entry=self.config_entry,
)
await self.logger_coordinator.async_config_entry_first_refresh()
@@ -120,6 +121,7 @@ class FroniusSolarNet:
solar_net=self,
logger=_LOGGER,
name=f"{DOMAIN}_meters_{self.host}",
config_entry=self.config_entry,
)
)
@@ -129,6 +131,7 @@ class FroniusSolarNet:
solar_net=self,
logger=_LOGGER,
name=f"{DOMAIN}_ohmpilot_{self.host}",
config_entry=self.config_entry,
)
)
@@ -138,6 +141,7 @@ class FroniusSolarNet:
solar_net=self,
logger=_LOGGER,
name=f"{DOMAIN}_power_flow_{self.host}",
config_entry=self.config_entry,
)
)
@@ -147,6 +151,7 @@ class FroniusSolarNet:
solar_net=self,
logger=_LOGGER,
name=f"{DOMAIN}_storages_{self.host}",
config_entry=self.config_entry,
)
)
@@ -206,6 +211,7 @@ class FroniusSolarNet:
logger=_LOGGER,
name=_inverter_name,
inverter_info=_inverter_info,
config_entry=self.config_entry,
)
if self.config_entry.state == ConfigEntryState.LOADED:
await _coordinator.async_refresh()

View File

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

View File

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

View File

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

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/growatt_server",
"iot_class": "cloud_polling",
"loggers": ["growattServer"],
"requirements": ["growattServer==1.6.0"]
"requirements": ["growattServer==1.7.1"]
}

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@
"healthy": "Healthy",
"host_os": "Host operating system",
"installed_addons": "Installed add-ons",
"nameservers": "Nameservers",
"supervisor_api": "Supervisor API",
"supervisor_version": "Supervisor version",
"supported": "Supported",

View File

@@ -54,6 +54,15 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"error": "Unsupported",
}
nameservers = set()
for interface in network_info.get("interfaces", []):
if not interface.get("primary"):
continue
if ipv4 := interface.get("ipv4"):
nameservers.update(ipv4.get("nameservers", []))
if ipv6 := interface.get("ipv6"):
nameservers.update(ipv6.get("nameservers", []))
information = {
"host_os": host_info.get("operating_system"),
"update_channel": info.get("channel"),
@@ -62,6 +71,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"docker_version": info.get("docker"),
"disk_total": f"{host_info.get('disk_total')} GB",
"disk_used": f"{host_info.get('disk_used')} GB",
"nameservers": ", ".join(nameservers),
"healthy": healthy,
"supported": supported,
"host_connectivity": network_info.get("host_internet"),

View File

@@ -12,6 +12,7 @@ from ha_silabs_firmware_client import (
ManifestMissing,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -24,13 +25,20 @@ FIRMWARE_REFRESH_INTERVAL = timedelta(hours=8)
class FirmwareUpdateCoordinator(DataUpdateCoordinator[FirmwareManifest]):
"""Coordinator to manage firmware updates."""
def __init__(self, hass: HomeAssistant, session: ClientSession, url: str) -> None:
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
session: ClientSession,
url: str,
) -> None:
"""Initialize the firmware update coordinator."""
super().__init__(
hass,
_LOGGER,
name="firmware update coordinator",
update_interval=FIRMWARE_REFRESH_INTERVAL,
config_entry=config_entry,
)
self.hass = hass
self.session = session

View File

@@ -124,6 +124,7 @@ def _async_create_update_entity(
config_entry=config_entry,
update_coordinator=FirmwareUpdateCoordinator(
hass,
config_entry,
session,
NABU_CASA_FIRMWARE_RELEASES_URL,
),

View File

@@ -129,6 +129,7 @@ def _async_create_update_entity(
config_entry=config_entry,
update_coordinator=FirmwareUpdateCoordinator(
hass,
config_entry,
session,
NABU_CASA_FIRMWARE_RELEASES_URL,
),

View File

@@ -11,10 +11,16 @@ from pyHomee import (
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
from .const import (
DOMAIN,
RESULT_CANNOT_CONNECT,
RESULT_INVALID_AUTH,
RESULT_UNKNOWN_ERROR,
)
_LOGGER = logging.getLogger(__name__)
@@ -33,60 +39,137 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
homee: Homee
_host: str
_name: str
_reauth_host: str
_reauth_username: str
async def _connect_homee(self) -> dict[str, str]:
errors: dict[str, str] = {}
try:
await self.homee.get_access_token()
except HomeeConnectionFailedException:
errors["base"] = RESULT_CANNOT_CONNECT
except HomeeAuthenticationFailedException:
errors["base"] = RESULT_INVALID_AUTH
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = RESULT_UNKNOWN_ERROR
else:
_LOGGER.info("Got access token for homee")
self.hass.loop.create_task(self.homee.run())
_LOGGER.debug("Homee task created")
await self.homee.wait_until_connected()
_LOGGER.info("Homee connected")
self.homee.disconnect()
_LOGGER.debug("Homee disconnecting")
await self.homee.wait_until_disconnected()
_LOGGER.info("Homee config successfully tested")
await self.async_set_unique_id(
self.homee.settings.uid, raise_on_progress=self.source != SOURCE_USER
)
self._abort_if_unique_id_configured()
_LOGGER.info("Created new homee entry with ID %s", self.homee.settings.uid)
return errors
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial user step."""
errors: dict[str, str] = {}
errors = {}
if user_input is not None:
self.homee = Homee(
user_input[CONF_HOST],
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
errors = await self._connect_homee()
try:
await self.homee.get_access_token()
except HomeeConnectionFailedException:
errors["base"] = "cannot_connect"
except HomeeAuthenticationFailedException:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
_LOGGER.info("Got access token for homee")
self.hass.loop.create_task(self.homee.run())
_LOGGER.debug("Homee task created")
await self.homee.wait_until_connected()
_LOGGER.info("Homee connected")
self.homee.disconnect()
_LOGGER.debug("Homee disconnecting")
await self.homee.wait_until_disconnected()
_LOGGER.info("Homee config successfully tested")
await self.async_set_unique_id(self.homee.settings.uid)
self._abort_if_unique_id_configured()
_LOGGER.info(
"Created new homee entry with ID %s", self.homee.settings.uid
)
if not errors:
return self.async_create_entry(
title=f"{self.homee.settings.homee_name} ({self.homee.host})",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=AUTH_SCHEMA,
errors=errors,
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
# Ensure that an IPv4 address is received
self._host = discovery_info.host
self._name = discovery_info.hostname[6:18]
if discovery_info.ip_address.version == 6:
return self.async_abort(reason="ipv6_address")
await self.async_set_unique_id(self._name)
self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
# Cause an auth-error to see if homee is reachable.
self.homee = Homee(
self._host,
"dummy_username",
"dummy_password",
)
errors = await self._connect_homee()
if errors["base"] != RESULT_INVALID_AUTH:
return self.async_abort(reason=RESULT_CANNOT_CONNECT)
self.context["title_placeholders"] = {"name": self._name, "host": self._host}
return await self.async_step_zeroconf_confirm()
async def async_step_zeroconf_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the configuration of the device."""
errors: dict[str, str] = {}
if user_input is not None:
self.homee = Homee(
self._host,
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
errors = await self._connect_homee()
if not errors:
return self.async_create_entry(
title=f"{self.homee.settings.homee_name} ({self.homee.host})",
data={
CONF_HOST: self._host,
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
return self.async_show_form(
step_id="zeroconf_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
),
errors=errors,
description_placeholders={
CONF_HOST: self._name,
},
last_step=True,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
@@ -108,12 +191,12 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN):
try:
await self.homee.get_access_token()
except HomeeConnectionFailedException:
errors["base"] = "cannot_connect"
errors["base"] = RESULT_CANNOT_CONNECT
except HomeeAuthenticationFailedException:
errors["base"] = "invalid_auth"
errors["base"] = RESULT_INVALID_AUTH
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
errors["base"] = RESULT_UNKNOWN_ERROR
else:
self.hass.loop.create_task(self.homee.run())
await self.homee.wait_until_connected()
@@ -161,12 +244,12 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN):
try:
await self.homee.get_access_token()
except HomeeConnectionFailedException:
errors["base"] = "cannot_connect"
errors["base"] = RESULT_CANNOT_CONNECT
except HomeeAuthenticationFailedException:
errors["base"] = "invalid_auth"
errors["base"] = RESULT_INVALID_AUTH
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
errors["base"] = RESULT_UNKNOWN_ERROR
else:
self.hass.loop.create_task(self.homee.run())
await self.homee.wait_until_connected()

View File

@@ -20,6 +20,11 @@ from homeassistant.const import (
# General
DOMAIN = "homee"
# Error strings
RESULT_CANNOT_CONNECT = "cannot_connect"
RESULT_INVALID_AUTH = "invalid_auth"
RESULT_UNKNOWN_ERROR = "unknown"
# Sensor mappings
HOMEE_UNIT_TO_HA_UNIT = {
"": None,

View File

@@ -8,5 +8,11 @@
"iot_class": "local_push",
"loggers": ["homee"],
"quality_scale": "silver",
"requirements": ["pyHomee==1.2.10"]
"requirements": ["pyHomee==1.2.10"],
"zeroconf": [
{
"type": "_ssh._tcp.local.",
"name": "homee-*"
}
]
}

View File

@@ -46,6 +46,17 @@
"data_description": {
"host": "[%key:component::homee::config::step::user::data_description::host%]"
}
},
"zeroconf_confirm": {
"title": "Configure discovered homee {host}",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "[%key:component::homee::config::step::user::data_description::username%]",
"password": "[%key:component::homee::config::step::user::data_description::password%]"
}
}
}
},

View File

@@ -628,12 +628,12 @@ class HomeAccessory(Accessory): # type: ignore[misc]
self,
domain: str,
service: str,
service_data: dict[str, Any] | None,
service_data: dict[str, Any],
value: Any | None = None,
) -> None:
"""Fire event and call service for changes from HomeKit."""
event_data = {
ATTR_ENTITY_ID: self.entity_id,
ATTR_ENTITY_ID: service_data.get(ATTR_ENTITY_ID, self.entity_id),
ATTR_DISPLAY_NAME: self.display_name,
ATTR_SERVICE: service,
ATTR_VALUE: value,

View File

@@ -57,6 +57,8 @@ CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor"
CONF_LINKED_OBSTRUCTION_SENSOR = "linked_obstruction_sensor"
CONF_LINKED_PM25_SENSOR = "linked_pm25_sensor"
CONF_LINKED_TEMPERATURE_SENSOR = "linked_temperature_sensor"
CONF_LINKED_VALVE_DURATION = "linked_valve_duration"
CONF_LINKED_VALVE_END_TIME = "linked_valve_end_time"
CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold"
CONF_MAX_FPS = "max_fps"
CONF_MAX_HEIGHT = "max_height"
@@ -229,10 +231,12 @@ CHAR_ON = "On"
CHAR_OUTLET_IN_USE = "OutletInUse"
CHAR_POSITION_STATE = "PositionState"
CHAR_PROGRAMMABLE_SWITCH_EVENT = "ProgrammableSwitchEvent"
CHAR_REMAINING_DURATION = "RemainingDuration"
CHAR_REMOTE_KEY = "RemoteKey"
CHAR_ROTATION_DIRECTION = "RotationDirection"
CHAR_ROTATION_SPEED = "RotationSpeed"
CHAR_SATURATION = "Saturation"
CHAR_SET_DURATION = "SetDuration"
CHAR_SERIAL_NUMBER = "SerialNumber"
CHAR_SERVICE_LABEL_INDEX = "ServiceLabelIndex"
CHAR_SERVICE_LABEL_NAMESPACE = "ServiceLabelNamespace"

View File

@@ -15,6 +15,11 @@ from pyhap.const import (
)
from homeassistant.components import button, input_button
from homeassistant.components.input_number import (
ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE,
DOMAIN as INPUT_NUMBER_DOMAIN,
SERVICE_SET_VALUE as INPUT_NUMBER_SERVICE_SET_VALUE,
)
from homeassistant.components.input_select import ATTR_OPTIONS, SERVICE_SELECT_OPTION
from homeassistant.components.lawn_mower import (
DOMAIN as LAWN_MOWER_DOMAIN,
@@ -45,6 +50,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, State, callback, split_entity_id
from homeassistant.helpers.event import async_call_later
from homeassistant.util import dt as dt_util
from .accessories import TYPES, HomeAccessory, HomeDriver
from .const import (
@@ -54,7 +60,11 @@ from .const import (
CHAR_NAME,
CHAR_ON,
CHAR_OUTLET_IN_USE,
CHAR_REMAINING_DURATION,
CHAR_SET_DURATION,
CHAR_VALVE_TYPE,
CONF_LINKED_VALVE_DURATION,
CONF_LINKED_VALVE_END_TIME,
SERV_OUTLET,
SERV_SWITCH,
SERV_VALVE,
@@ -271,7 +281,21 @@ class ValveBase(HomeAccessory):
self.on_service = on_service
self.off_service = off_service
serv_valve = self.add_preload_service(SERV_VALVE)
self.chars = []
self.linked_duration_entity: str | None = self.config.get(
CONF_LINKED_VALVE_DURATION
)
self.linked_end_time_entity: str | None = self.config.get(
CONF_LINKED_VALVE_END_TIME
)
if self.linked_duration_entity:
self.chars.append(CHAR_SET_DURATION)
if self.linked_end_time_entity:
self.chars.append(CHAR_REMAINING_DURATION)
serv_valve = self.add_preload_service(SERV_VALVE, self.chars)
self.char_active = serv_valve.configure_char(
CHAR_ACTIVE, value=False, setter_callback=self.set_state
)
@@ -279,6 +303,25 @@ class ValveBase(HomeAccessory):
self.char_valve_type = serv_valve.configure_char(
CHAR_VALVE_TYPE, value=VALVE_TYPE[valve_type].valve_type
)
if CHAR_SET_DURATION in self.chars:
_LOGGER.debug(
"%s: Add characteristic %s", self.entity_id, CHAR_SET_DURATION
)
self.char_set_duration = serv_valve.configure_char(
CHAR_SET_DURATION,
value=self.get_duration(),
setter_callback=self.set_duration,
)
if CHAR_REMAINING_DURATION in self.chars:
_LOGGER.debug(
"%s: Add characteristic %s", self.entity_id, CHAR_REMAINING_DURATION
)
self.char_remaining_duration = serv_valve.configure_char(
CHAR_REMAINING_DURATION, getter_callback=self.get_remaining_duration
)
# Set the state so it is in sync on initial
# GET to avoid an event storm after homekit startup
self.async_update_state(state)
@@ -294,12 +337,75 @@ class ValveBase(HomeAccessory):
@callback
def async_update_state(self, new_state: State) -> None:
"""Update switch state after state changed."""
self._update_duration_chars()
current_state = 1 if new_state.state in self.open_states else 0
_LOGGER.debug("%s: Set active state to %s", self.entity_id, current_state)
self.char_active.set_value(current_state)
_LOGGER.debug("%s: Set in_use state to %s", self.entity_id, current_state)
self.char_in_use.set_value(current_state)
def _update_duration_chars(self) -> None:
"""Update valve duration related properties if characteristics are available."""
if CHAR_SET_DURATION in self.chars:
self.char_set_duration.set_value(self.get_duration())
if CHAR_REMAINING_DURATION in self.chars:
self.char_remaining_duration.set_value(self.get_remaining_duration())
def set_duration(self, value: int) -> None:
"""Set default duration for how long the valve should remain open."""
_LOGGER.debug("%s: Set default run time to %s", self.entity_id, value)
self.async_call_service(
INPUT_NUMBER_DOMAIN,
INPUT_NUMBER_SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: self.linked_duration_entity,
INPUT_NUMBER_ATTR_VALUE: value,
},
value,
)
def get_duration(self) -> int:
"""Get the default duration from Home Assistant."""
duration_state = self._get_entity_state(self.linked_duration_entity)
if duration_state is None:
_LOGGER.debug(
"%s: No linked duration entity state available", self.entity_id
)
return 0
try:
duration = float(duration_state)
return max(int(duration), 0)
except ValueError:
_LOGGER.debug("%s: Cannot parse linked duration entity", self.entity_id)
return 0
def get_remaining_duration(self) -> int:
"""Calculate the remaining duration based on end time in Home Assistant."""
end_time_state = self._get_entity_state(self.linked_end_time_entity)
if end_time_state is None:
_LOGGER.debug(
"%s: No linked end time entity state available", self.entity_id
)
return self.get_duration()
end_time = dt_util.parse_datetime(end_time_state)
if end_time is None:
_LOGGER.debug("%s: Cannot parse linked end time entity", self.entity_id)
return self.get_duration()
remaining_time = (end_time - dt_util.utcnow()).total_seconds()
return max(int(remaining_time), 0)
def _get_entity_state(self, entity_id: str | None) -> str | None:
"""Fetch the state of a linked entity."""
if entity_id is None:
return None
state = self.hass.states.get(entity_id)
if state is None:
return None
return state.state
@TYPES.register("ValveSwitch")
class ValveSwitch(ValveBase):

View File

@@ -17,6 +17,7 @@ import voluptuous as vol
from homeassistant.components import (
binary_sensor,
input_number,
media_player,
persistent_notification,
sensor,
@@ -69,6 +70,8 @@ from .const import (
CONF_LINKED_OBSTRUCTION_SENSOR,
CONF_LINKED_PM25_SENSOR,
CONF_LINKED_TEMPERATURE_SENSOR,
CONF_LINKED_VALVE_DURATION,
CONF_LINKED_VALVE_END_TIME,
CONF_LOW_BATTERY_THRESHOLD,
CONF_MAX_FPS,
CONF_MAX_HEIGHT,
@@ -266,7 +269,9 @@ SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend(
TYPE_VALVE,
)
),
)
),
vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain(input_number.DOMAIN),
vol.Optional(CONF_LINKED_VALVE_END_TIME): cv.entity_domain(sensor.DOMAIN),
}
)
@@ -277,6 +282,12 @@ SENSOR_SCHEMA = BASIC_INFO_SCHEMA.extend(
}
)
VALVE_SCHEMA = BASIC_INFO_SCHEMA.extend(
{
vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain(input_number.DOMAIN),
vol.Optional(CONF_LINKED_VALVE_END_TIME): cv.entity_domain(sensor.DOMAIN),
}
)
HOMEKIT_CHAR_TRANSLATIONS = {
0: " ", # nul
@@ -360,6 +371,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]:
elif domain == "sensor":
config = SENSOR_SCHEMA(config)
elif domain == "valve":
config = VALVE_SCHEMA(config)
else:
config = BASIC_INFO_SCHEMA(config)

View File

@@ -283,19 +283,19 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity):
@property
def is_closed(self) -> bool | None:
"""Return if the cover is closed."""
return self._device.doorState == DoorState.CLOSED
return self.functional_channel.doorState == DoorState.CLOSED
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
await self._device.send_door_command_async(DoorCommand.OPEN)
await self.functional_channel.async_send_door_command(DoorCommand.OPEN)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
await self._device.send_door_command_async(DoorCommand.CLOSE)
await self.functional_channel.async_send_door_command(DoorCommand.CLOSE)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
await self._device.send_door_command_async(DoorCommand.STOP)
await self.functional_channel.async_send_door_command(DoorCommand.STOP)
class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity):

View File

@@ -163,6 +163,7 @@ async def async_setup_entry(
name="light",
update_method=partial(async_safe_fetch, bridge, bridge.api.lights.update),
update_interval=SCAN_INTERVAL,
config_entry=config_entry,
request_refresh_debouncer=Debouncer(
bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True
),
@@ -197,6 +198,7 @@ async def async_setup_entry(
name="group",
update_method=partial(async_safe_fetch, bridge, bridge.api.groups.update),
update_interval=SCAN_INTERVAL,
config_entry=config_entry,
request_refresh_debouncer=Debouncer(
bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True
),

View File

@@ -53,6 +53,7 @@ class SensorManager:
LOGGER,
name="sensor",
update_method=self.async_update_data,
config_entry=bridge.config_entry,
update_interval=self.SCAN_INTERVAL,
request_refresh_debouncer=debounce.Debouncer(
bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True

View File

@@ -71,10 +71,10 @@ ERROR_KEYS = [
"cutting_drive_motor_2_defect",
"cutting_drive_motor_3_defect",
"cutting_height_blocked",
"cutting_height_problem",
"cutting_height_problem_curr",
"cutting_height_problem_dir",
"cutting_height_problem_drive",
"cutting_height_problem",
"cutting_motor_problem",
"cutting_stopped_slope_too_steep",
"cutting_system_blocked",
@@ -117,7 +117,6 @@ ERROR_KEYS = [
"no_accurate_position_from_satellites",
"no_confirmed_position",
"no_drive",
"no_error",
"no_loop_signal",
"no_power_in_charging_station",
"no_response_from_charger",
@@ -169,8 +168,8 @@ ERROR_KEYS = [
]
ERROR_KEY_LIST = list(
dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES])
ERROR_KEY_LIST = sorted(
set(ERROR_KEYS) | {state.lower() for state in ERROR_STATES} | {"no_error"}
)
INACTIVE_REASONS: list = [

View File

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

View File

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

View File

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

View File

@@ -12,5 +12,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.1"]
"requirements": ["automower-ble==0.2.7"]
}

View File

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

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["imgw_pib==1.5.1"]
"requirements": ["imgw_pib==1.5.2"]
}

View File

@@ -99,7 +99,7 @@ class OptionsFlowHandler(OptionsFlowWithReload):
),
}
)
self.add_suggested_values_to_schema(
data_schema = self.add_suggested_values_to_schema(
data_schema,
{"section_1": {"int": self.config_entry.options.get(CONF_INT, 10)}},
)

View File

@@ -135,6 +135,7 @@ class KrakenData:
self._hass,
_LOGGER,
name=DOMAIN,
config_entry=self._config_entry,
update_method=self.async_update,
update_interval=timedelta(
seconds=self._config_entry.options[CONF_SCAN_INTERVAL]

View File

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

View File

@@ -13,5 +13,5 @@
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "bronze",
"requirements": ["pylitterbot==2024.2.2"]
"requirements": ["pylitterbot==2024.2.3"]
}

View File

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

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